Add printable guest sign studio and refine event imagery
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
export const SIGN_PAPERS = {
|
||||
letter: { label: "US Letter · 8.5 × 11 in", width: "8.5in", height: "11in", viewHeight: 1100 },
|
||||
a4: { label: "A4 · 210 × 297 mm", width: "210mm", height: "297mm", viewHeight: 1202 },
|
||||
letterLandscape: { label: "US Letter landscape · 11 × 8.5 in", width: "11in", height: "8.5in", viewHeight: 850 * 8.5 / 11 },
|
||||
a4Landscape: { label: "A4 landscape · 297 × 210 mm", width: "297mm", height: "210mm", viewHeight: 850 * 210 / 297 },
|
||||
a5: { label: "A5 · 148 × 210 mm", width: "148mm", height: "210mm", viewHeight: 850 * 210 / 148 },
|
||||
a5Landscape: { label: "A5 landscape · 210 × 148 mm", width: "210mm", height: "148mm", viewHeight: 850 * 148 / 210 },
|
||||
card3x5: { label: "Small card · 3 × 5 in", width: "3in", height: "5in", viewHeight: 850 * 5 / 3 },
|
||||
card5x3: { label: "Small landscape card · 5 × 3 in", width: "5in", height: "3in", viewHeight: 510 },
|
||||
card4x6: { label: "Postcard · 4 × 6 in", width: "4in", height: "6in", viewHeight: 1275 },
|
||||
card6x4: { label: "Landscape postcard · 6 × 4 in", width: "6in", height: "4in", viewHeight: 850 * 4 / 6 },
|
||||
card5x7: { label: "Table card · 5 × 7 in", width: "5in", height: "7in", viewHeight: 1190 },
|
||||
card7x5: { label: "Landscape table card · 7 × 5 in", width: "7in", height: "5in", viewHeight: 850 * 5 / 7 },
|
||||
square: { label: "Square card · 5 × 5 in", width: "5in", height: "5in", viewHeight: 850 },
|
||||
} as const;
|
||||
export type SignPaper = keyof typeof SIGN_PAPERS | "custom";
|
||||
export type SignDesign = { title: string; headline: string; message: string; paper: SignPaper; ink: "indigo" | "black";
|
||||
customWidth?: number; customHeight?: number; unit?: "in" | "mm";
|
||||
layout?: "photo" | "typography" | "compact";
|
||||
background?: string; accent?: string; font?: "funnel" | "geologica" | "inter";
|
||||
decoration?: "arch" | "frame" | "minimal"; backgroundImage?: string; logoImage?: string;
|
||||
showBrand?: boolean; 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]!);
|
||||
}
|
||||
|
||||
// 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" ? "#171717" : "#4055b5");
|
||||
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, "#eeece5");
|
||||
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" : "#171717";
|
||||
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;
|
||||
if (design.layout) {
|
||||
const compact = design.layout === "compact";
|
||||
const photoLed = design.layout === "photo";
|
||||
const foreground = surfaceLuminance < .18 ? "#ffffff" : "#171717";
|
||||
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>` : "";
|
||||
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>` : ""}
|
||||
${lines(design.title, left, top, compact ? 25 : 19, Math.floor(column / (compact ? 15 : 11)), 500)}
|
||||
<g font-family="${headingFont}" fill="${headingColor}" letter-spacing="-1.8">
|
||||
${headlineLines.map((line, index) => `<text x="${left}" y="${top + 80 + index * headlineSize * 1.1}" font-size="${headlineSize}" font-weight="650">${escapeXml(line)}</text>`).join("")}
|
||||
</g>
|
||||
${lines(design.message, left, messageY, compact ? 26 : 18, compact ? (landscape ? 38 : 24) : photoLed ? (landscape ? 42 : 35) : (landscape ? 48 : 32))}
|
||||
<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)}
|
||||
${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}">
|
||||
${text(design.title, 108, 22, landscape ? 34 : 48, 500)}
|
||||
<g fill="${headingInk}" letter-spacing="-1.5">${text(design.headline, landscape ? 265 : 230, 52, landscape ? 18 : 24, 600)}</g>
|
||||
</g>
|
||||
${text(design.message, landscape ? 485 : 359, 19, landscape ? 40 : 60)}
|
||||
<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)}
|
||||
${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>`;
|
||||
}
|
||||
Reference in New Issue
Block a user