180 lines
17 KiB
TypeScript
180 lines
17 KiB
TypeScript
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.2–1219.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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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:'SHRP' 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:'SHRP' 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>`;
|
||
}
|