Files
manyangles/apps/web/src/lib/guest-sign.ts
T

180 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { SIGN_PAPERS, type SavedSign } from "@album/contracts";
import { brandPalette } from "@album/contracts";
export { SIGN_PAPERS };
export type SignPaper = keyof typeof SIGN_PAPERS | "custom";
export type SignDesign = SavedSign & {
fontData?: string; interFontData?: string; geologicaFontData?: string;
};
export type SignQr = { path: string; size: number };
export function guestQrSvg(qr: SignQr) {
const size = qr.size + 8;
return `<svg xmlns="http://www.w3.org/2000/svg" width="${size * 16}" height="${size * 16}" viewBox="0 0 ${size} ${size}" shape-rendering="crispEdges"><title>Manyangles guest album QR code</title><rect width="100%" height="100%" fill="white"/><path d="${qr.path}" transform="translate(4 4)" fill="black"/></svg>`;
}
export function signPaper(design: Pick<SignDesign, "paper" | "customWidth" | "customHeight" | "unit">) {
if (design.paper !== "custom") return SIGN_PAPERS[design.paper];
const unit = design.unit === "mm" ? "mm" : "in";
const width = design.customWidth ?? 8.5, height = design.customHeight ?? 11;
const inches = unit === "mm" ? 25.4 : 1;
if (![width, height].every(n => Number.isFinite(n) && n / inches >= 3 && n / inches <= 48)) throw new Error("Choose dimensions between 3 and 48 inches (76.21219.2 mm).");
if (Math.max(width / height, height / width) > 2.5) throw new Error("Keep the long edge within 2.5 times the short edge so the sign stays readable.");
return { label: "Custom", width: `${width}${unit}`, height: `${height}${unit}`, viewHeight: 850 * height / width };
}
export function escapeXml(value: string) {
return value.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&apos;" })[c]!);
}
export function signPrintMetrics(design: SignDesign) {
const paper = signPaper(design);
const landscape = paper.viewHeight < 850;
const scale = Math.min(850 / (landscape ? 1200 : 850), paper.viewHeight / (landscape ? 800 : 1100));
const widthInches = parseFloat(paper.width) / (paper.width.endsWith("mm") ? 25.4 : 1);
const qrUnits = design.layout === "compact" ? 320 : design.layout === "photo" ? 240 : design.layout === "typography" ? 340 : 370;
return { qrInches: widthInches / 850 * scale * qrUnits };
}
// Hard-wrap even unbroken names/URLs; no user input becomes SVG markup.
export function signLines(value: string, limit: number) {
const words = value.trim().split(/\s+/).flatMap(word => word.match(new RegExp(`.{1,${limit}}`, "gu")) ?? []);
const lines: string[] = [];
for (const word of words) {
const last = lines.length - 1;
if (last >= 0 && `${lines[last]} ${word}`.length <= limit) lines[last] += ` ${word}`;
else lines.push(word);
}
return lines;
}
export function guestSignSvg(design: SignDesign, guestUrl: string, qr: SignQr) {
const paper = signPaper(design);
const color = (value: string | undefined, fallback: string) => /^#[\da-f]{6}$/i.test(value ?? "") ? value! : fallback;
const ink = color(design.accent, design.ink === "black" ? brandPalette.ink : brandPalette.primary);
const luminance = (hex: string) => {
const channels = [1, 3, 5].map(index => parseInt(hex.slice(index, index + 2), 16) / 255).map(c => c <= .04045 ? c / 12.92 : ((c + .055) / 1.055) ** 2.4);
return channels[0]! * .2126 + channels[1]! * .7152 + channels[2]! * .0722;
};
const background = color(design.background, brandPalette.background);
const raster = (value?: string) => /^data:image\/(png|jpeg|webp);base64,[A-Za-z0-9+/=]+$/.test(value ?? "") ? value : undefined;
const photo = raster(design.backgroundImage), logo = raster(design.logoImage);
const surfaceLuminance = luminance(background);
const bodyInk = photo || surfaceLuminance < .18 ? "#ffffff" : brandPalette.ink;
const accentContrast = (Math.max(luminance(ink), surfaceLuminance) + .05) / (Math.min(luminance(ink), surfaceLuminance) + .05);
const headingInk = !photo && accentContrast >= 4.5 ? ink : bodyInk;
const landscape = paper.viewHeight < 850;
const w = landscape ? 1200 : 850, h = landscape ? 800 : 1100;
const scale = Math.min(850 / w, paper.viewHeight / h);
const cx = landscape ? 328 : 425;
const text = (value: string, y: number, size: number, limit: number, weight = 400, x = cx) => signLines(value, limit).map((line, i) =>
`<text x="${x}" y="${y + i * size * 1.25}" text-anchor="middle" font-size="${size}" font-weight="${weight}">${escapeXml(line)}</text>`).join("");
const headingFont = design.font === "geologica" ? "SignGeologica, sans-serif" : design.font === "inter" ? "SignInter, sans-serif" : "SignFunnel, sans-serif";
const fontFaces = [["SignFunnel", design.fontData], ["SignInter", design.interFontData], ["SignGeologica", design.geologicaFontData]]
.filter(([, data]) => /^data:font\/woff2;base64,[A-Za-z0-9+/=]+$/.test(data ?? ""))
.map(([family, data]) => `@font-face{font-family:${family};src:url('${data}') format('woff2');font-weight:100 900;font-style:normal}`).join("");
const qx = landscape ? 710 : 240, qy = landscape ? 235 : 420;
// Keep text inside its own region; changing type never moves or shrinks the QR.
const sizedText = (value: string, x: number, y: number, base: number, percent: number, width: number, height: number, weight = 400, centered = false) => {
let size = base * Math.min(150, Math.max(75, Number.isFinite(percent) ? percent : 100)) / 100;
let wrapped = signLines(value, Math.max(1, Math.floor(width / (size * .65))));
while (wrapped.length * size * 1.25 > height && size > 8) {
size -= .5;
wrapped = signLines(value, Math.max(1, Math.floor(width / (size * .65))));
}
return wrapped.map((line, index) => `<text x="${x}" y="${y + index * size * 1.25}"${centered ? ' text-anchor="middle"' : ""} font-size="${size}" font-weight="${weight}">${escapeXml(line)}</text>`).join("");
};
if (design.layout) {
const compact = design.layout === "compact";
const photoLed = design.layout === "photo";
const foreground = surfaceLuminance < .18 ? "#ffffff" : brandPalette.ink;
const headingColor = accentContrast >= 4.5 ? ink : foreground;
const left = landscape && photoLed ? 650 : 70;
const top = photoLed ? (landscape ? 80 : 600) : 85;
const column = photoLed ? (landscape ? 480 : 410) : (landscape ? 600 : 710);
const fontSize = compact ? 58 : photoLed ? 46 : (landscape ? 80 : 88);
const headlineLines = signLines(design.headline, compact ? 20 : photoLed ? 18 : 16);
const headlineSize = Math.min(fontSize, (photoLed ? 170 : 350) / (headlineLines.length * 1.1));
const lines = (value: string, x: number, y: number, size: number, limit: number, weight = 400) =>
signLines(value, limit).map((line, index) => `<text x="${x}" y="${y + index * size * 1.25}" font-size="${size}" font-weight="${weight}">${escapeXml(line)}</text>`).join("");
const qrSize = compact ? 320 : photoLed ? 240 : 340;
const qrX = landscape ? (photoLed ? 870 : 790) : (photoLed ? 540 : 440);
const qrY = compact ? (landscape ? 260 : 530) : landscape ? (photoLed ? 415 : 235) : (photoLed ? 755 : 605);
const messageY = top + (compact ? 310 : photoLed ? 250 : 445);
const brandMarkup = logo ? `<image href="${logo}" x="${cx - 100}" y="${landscape ? 700 : 1005}" width="200" height="48" preserveAspectRatio="xMidYMid meet"/>` : design.showBrand !== false ? `<g data-brand-lockup="manyangles" color="${headingColor}" fill="currentColor" transform="translate(${cx - 96} ${landscape ? 711 : 1016})"><svg width="32" height="32" viewBox="0 0 32 32" fill="none"><path d="M14 4H8a4 4 0 0 0-4 4v6M18 4h6a4 4 0 0 1 4 4v6M28 18v6a4 4 0 0 1-4 4h-6M14 28H8a4 4 0 0 1-4-4v-6" stroke="currentColor" stroke-width="3" stroke-linecap="round"/><circle cx="21" cy="11" r="2.25" fill="currentColor"/><path d="m8.5 22 5-6.2a1.7 1.7 0 0 1 2.6-.1l2.6 2.9 1.7-1.8a1.7 1.7 0 0 1 2.5 0l1.6 1.8" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></svg><text x="40" y="24" font-family="SignGeologica, sans-serif" font-size="26.5" font-weight="700" letter-spacing="-1.06" style="font-variation-settings:&#39;SHRP&#39; 70">Manyangles</text></g>` : "";
if (!photoLed) {
const textX = landscape ? 100 : 85;
const textWidth = landscape ? 530 : 680;
const codeX = landscape ? 760 : (w - qrSize) / 2;
const codeY = landscape ? 210 : 475;
const footerY = landscape ? 655 : 955;
return `<svg xmlns="http://www.w3.org/2000/svg" width="${paper.width}" height="${paper.height}" viewBox="0 0 850 ${paper.viewHeight}">
<title>${escapeXml(design.title)} — guest photo sign</title><style>${fontFaces}</style>
<rect width="850" height="${paper.viewHeight}" fill="${background}"/>
<g transform="translate(${(850 - w * scale) / 2} ${(paper.viewHeight - h * scale) / 2}) scale(${scale})" font-family="SignInter, sans-serif" fill="${foreground}">
<g font-family="${headingFont}" fill="${headingColor}" letter-spacing="-1.8">
${sizedText(design.headline, textX, landscape ? 260 : 170, 76, design.headlineScale ?? 100, textWidth, 185, 600)}
</g>
${sizedText(design.message, textX, landscape ? 450 : 355, 29, design.messageScale ?? 100, textWidth, landscape ? 150 : 100)}
<svg x="${codeX}" y="${codeY}" width="${qrSize}" height="${qrSize}" viewBox="0 0 ${qr.size + 8} ${qr.size + 8}" shape-rendering="crispEdges"><rect width="100%" height="100%" fill="white"/><path d="${qr.path}" transform="translate(4 4)" fill="black"/></svg>
${sizedText("Scan to upload", codeX + qrSize / 2, codeY + qrSize + 45, 25, 100, qrSize, 40, 500, true)}
${design.showUrl === false ? "" : sizedText(guestUrl.replace(/^https?:\/\//, ""), codeX + qrSize / 2, codeY + qrSize + 78, 16, 100, qrSize + 40, 50, 400, true)}
${sizedText(design.title, textX, footerY, 23, design.titleScale ?? 100, landscape ? 530 : 480, 65, 500)}
<g transform="translate(${landscape ? 948 : 598} ${footerY - 21}) scale(.7) translate(${-(cx - 100)} ${-(landscape ? 700 : 1005)})">${brandMarkup}</g>
</g>
</svg>`;
}
return `<svg xmlns="http://www.w3.org/2000/svg" width="${paper.width}" height="${paper.height}" viewBox="0 0 850 ${paper.viewHeight}">
<title>${escapeXml(design.title)} — guest photo sign</title>
<style>${fontFaces}</style>
<rect width="850" height="${paper.viewHeight}" fill="${background}"/>
<g transform="translate(${(850 - w * scale) / 2} ${(paper.viewHeight - h * scale) / 2}) scale(${scale})" font-family="SignInter, sans-serif" fill="${foreground}">
${photoLed ? `<svg width="${landscape ? 580 : w}" height="${landscape ? h : 530}" viewBox="0 0 ${landscape ? 580 : w} ${landscape ? h : 530}">
<rect width="100%" height="100%" fill="${ink}"/>
${photo ? `<image href="${photo}" width="100%" height="100%" preserveAspectRatio="xMidYMid slice"/>` : `<text x="50%" y="50%" text-anchor="middle" fill="white" font-size="22">Add your event photo</text>`}
</svg>` : ""}
${design.titleScale === undefined ? lines(design.title, left, top, compact ? 25 : 19, Math.floor(column / (compact ? 15 : 11)), 500) : sizedText(design.title, left, top, compact ? 25 : 19, design.titleScale, column, 55, 500)}
<g font-family="${headingFont}" fill="${headingColor}" letter-spacing="-1.8">
${design.headlineScale === undefined ? headlineLines.map((line, index) => `<text x="${left}" y="${top + 80 + index * headlineSize * 1.1}" font-size="${headlineSize}" font-weight="650">${escapeXml(line)}</text>`).join("") : sizedText(design.headline, left, top + 80, fontSize, design.headlineScale, column, messageY - top - 95, 650)}
</g>
${design.messageScale === undefined ? lines(design.message, left, messageY, compact ? 26 : 18, compact ? (landscape ? 38 : 24) : photoLed ? (landscape ? 42 : 35) : (landscape ? 48 : 32)) : sizedText(design.message, left, messageY, compact ? 26 : 18, design.messageScale, landscape && photoLed ? column : Math.min(column, qrX - left - 40), photoLed ? (landscape ? 65 : 85) : landscape ? 230 : 280)}
<svg x="${qrX}" y="${qrY}" width="${qrSize}" height="${qrSize}" viewBox="0 0 ${qr.size + 8} ${qr.size + 8}" shape-rendering="crispEdges"><rect width="100%" height="100%" fill="white"/><path d="${qr.path}" transform="translate(4 4)" fill="black"/></svg>
${lines("Scan to share", qrX, qrY - 22, compact ? 24 : 18, 30, 600)}
${design.showUrl === false ? "" : lines(guestUrl.replace(/^https?:\/\//, ""), qrX, qrY + qrSize + 24, compact ? 15 : 11, Math.floor(qrSize / (compact ? 9 : 7)))}
${!photoLed && !compact ? lines("Your photos. Our story.", left, landscape ? 665 : 850, 24, 30, 600) : ""}
${lines("No app or account needed.", left, photoLed ? (landscape ? 665 : 950) : (landscape ? 705 : 890), compact ? 22 : 16, photoLed && landscape ? 22 : 32)}
${lines("Photos and notes welcome.", left, photoLed ? (landscape ? 710 : 976) : (landscape ? 735 : 920), compact ? 22 : 16, photoLed && landscape ? 22 : 32)}
<g transform="translate(${(landscape ? (photoLed ? 650 : 790) : 70) - (cx - 96)} ${landscape ? (photoLed ? 39 : compact ? -21 : -626) : 20})">${brandMarkup}</g>
</g>
</svg>`;
}
return `<svg xmlns="http://www.w3.org/2000/svg" width="${paper.width}" height="${paper.height}" viewBox="0 0 850 ${paper.viewHeight}">
<title>${escapeXml(design.title)} — guest photo sign</title>
${fontFaces ? `<style>${fontFaces}</style>` : ""}
<rect width="850" height="${paper.viewHeight}" fill="${background}"/>
${photo ? `<image href="${photo}" width="850" height="${paper.viewHeight}" preserveAspectRatio="xMidYMid slice"/><rect width="850" height="${paper.viewHeight}" fill="black" opacity=".68"/>` : ""}
<g font-family="SignInter, sans-serif" fill="${bodyInk}" transform="translate(${(850 - w * scale) / 2} ${(paper.viewHeight - h * scale) / 2}) scale(${scale})">
${design.decoration !== "minimal" ? `<rect x="35" y="35" width="${w - 70}" height="${h - 70}" rx="${design.decoration === "arch" ? 24 : 0}" fill="none" stroke="${headingInk}" stroke-opacity=".3" stroke-width="1"/>` : ""}
<path d="M${landscape ? 90 : 85} 158h${landscape ? 470 : 680}" stroke="${headingInk}" stroke-opacity=".25" stroke-width="1"/>
<g font-family="${headingFont}">
${design.titleScale === undefined ? text(design.title, 108, 22, landscape ? 34 : 48, 500) : sizedText(design.title, cx, 108, 22, design.titleScale, landscape ? 470 : 680, 45, 500, true)}
<g fill="${headingInk}" letter-spacing="-1.5">${design.headlineScale === undefined ? text(design.headline, landscape ? 265 : 230, 52, landscape ? 18 : 24, 600) : sizedText(design.headline, cx, landscape ? 265 : 230, 52, design.headlineScale, landscape ? 470 : 680, landscape ? 190 : 115, 600, true)}</g>
</g>
${design.messageScale === undefined ? text(design.message, landscape ? 485 : 359, 19, landscape ? 40 : 60) : sizedText(design.message, cx, landscape ? 485 : 359, 19, design.messageScale, landscape ? 470 : 680, landscape ? 85 : 55, 400, true)}
<svg x="${qx}" y="${qy}" width="370" height="370" viewBox="0 0 ${qr.size + 8} ${qr.size + 8}" shape-rendering="crispEdges">
<rect width="100%" height="100%" fill="white"/><path d="${qr.path}" transform="translate(4 4)" fill="black"/>
</svg>
${text("Scan with your phone camera", landscape ? 644 : 820, 20, 40, 600, qx + 185)}
${design.showUrl === false ? "" : text(guestUrl.replace(/^https?:\/\//, ""), landscape ? 675 : 854, guestUrl.length > 160 ? 10 : 14, guestUrl.length > 160 ? (landscape ? 68 : 110) : (landscape ? 46 : 78), 400, qx + 185)}
<path d="M${landscape ? 90 : 85} ${landscape ? 575 : 901}h${landscape ? 470 : 680}" stroke="${headingInk}" stroke-opacity=".25" stroke-width="1"/>
${text("Scan. Choose your photos. Upload.", landscape ? 611 : 935, 21, 50, 500)}
${text("No app or account needed. Notes welcome, too.", landscape ? 644 : 965, 16, landscape ? 36 : 55)}
${logo ? `<image href="${logo}" x="${cx - 100}" y="${landscape ? 700 : 1005}" width="200" height="48" preserveAspectRatio="xMidYMid meet"/>` : design.showBrand !== false ? `<g data-brand-lockup="manyangles" color="${headingInk}" fill="currentColor" transform="translate(${cx - 96} ${landscape ? 711 : 1016})"><svg width="32" height="32" viewBox="0 0 32 32" fill="none"><path d="M14 4H8a4 4 0 0 0-4 4v6M18 4h6a4 4 0 0 1 4 4v6M28 18v6a4 4 0 0 1-4 4h-6M14 28H8a4 4 0 0 1-4-4v-6" stroke="currentColor" stroke-width="3" stroke-linecap="round"/><circle cx="21" cy="11" r="2.25" fill="currentColor"/><path d="m8.5 22 5-6.2a1.7 1.7 0 0 1 2.6-.1l2.6 2.9 1.7-1.8a1.7 1.7 0 0 1 2.5 0l1.6 1.8" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></svg><text x="40" y="24" font-family="SignGeologica, sans-serif" font-size="26.5" font-weight="700" letter-spacing="-1.06" style="font-variation-settings:&#39;SHRP&#39; 70">Manyangles</text></g>` : ""}
</g>
</svg>`;
}
export function signPrintHtml(svg: string, paper: SignPaper | SignDesign) {
const size = signPaper(typeof paper === "string" ? { paper } : paper);
return `<!doctype html><html><head><title>Manyangles guest sign</title><style>@page{size:${size.width} ${size.height};margin:0}html,body{margin:0;padding:0;background:white}svg{display:block;width:${size.width};height:${size.height}}*{print-color-adjust:exact;-webkit-print-color-adjust:exact}</style></head><body>${svg}</body></html>`;
}