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 ``;
}
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 ``;
}
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}`;
}