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 `Manyangles guest album QR code`; } export function signPaper(design: Pick) { 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) => `${escapeXml(line)}`).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) => `${escapeXml(line)}`).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) => `${escapeXml(line)}`).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 ? `` : design.showBrand !== false ? `Manyangles` : ""; 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 ` ${escapeXml(design.title)} — guest photo sign ${sizedText(design.headline, textX, landscape ? 260 : 170, 76, design.headlineScale ?? 100, textWidth, 185, 600)} ${sizedText(design.message, textX, landscape ? 450 : 355, 29, design.messageScale ?? 100, textWidth, landscape ? 150 : 100)} ${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)} ${brandMarkup} `; } return ` ${escapeXml(design.title)} — guest photo sign ${photoLed ? ` ${photo ? `` : `Add your event photo`} ` : ""} ${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)} ${design.headlineScale === undefined ? headlineLines.map((line, index) => `${escapeXml(line)}`).join("") : sizedText(design.headline, left, top + 80, fontSize, design.headlineScale, column, messageY - top - 95, 650)} ${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)} ${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)} ${brandMarkup} `; } return ` ${escapeXml(design.title)} — guest photo sign ${fontFaces ? `` : ""} ${photo ? `` : ""} ${design.decoration !== "minimal" ? `` : ""} ${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)} ${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)} ${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)} ${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)} ${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 ? `` : design.showBrand !== false ? `Manyangles` : ""} `; } export function signPrintHtml(svg: string, paper: SignPaper | SignDesign) { const size = signPaper(typeof paper === "string" ? { paper } : paper); return `Manyangles guest sign${svg}`; }