diff --git a/apps/web/src/lib/guest-sign.test.ts b/apps/web/src/lib/guest-sign.test.ts index 021f828..152c168 100644 --- a/apps/web/src/lib/guest-sign.test.ts +++ b/apps/web/src/lib/guest-sign.test.ts @@ -45,6 +45,54 @@ test("text controls change each text independently without resizing the QR", () } }); +const headlineGroupPattern = /]*\bdata-sign-headline(?:="[^"]*")?[^>]*>[\s\S]*?<\/g>/; +function headlineGeometry(svg: string) { + const group = svg.match(headlineGroupPattern)?.[0]; + expect(group).toBeDefined(); + const lines = [...group!.matchAll(/]*)>/g)].map(([, attributes]) => ({ + baseline: Number(attributes!.match(/\by="([^"]+)"/)?.[1]), + size: Number(attributes!.match(/\bfont-size="([^"]+)"/)?.[1]), + })); + expect(lines.length).toBeGreaterThan(0); + expect(lines.every(line => Number.isFinite(line.baseline) && line.size > 0)).toBe(true); + return { lines, top: lines[0]!.baseline - lines[0]!.size, bottom: lines.at(-1)!.baseline + lines.at(-1)!.size * .25 }; +} + +test("larger card headlines expand upward without moving instructions, footer, or QR", () => { + const url = "https://ma.hadlock.tech/e/demo", qr = signQr(url); + for (const layout of ["compact", "typography"] as const) { + for (const paper of ["card4x6", "card6x4"] as const) { + const base: SignDesign = { ...design, layout, paper, headline: "Share your photos", headlineScale: 100 }; + const normal = guestSignSvg(base, url, qr); + const larger = guestSignSvg({ ...base, headlineScale: 150 }, url, qr); + const before = headlineGeometry(normal), after = headlineGeometry(larger); + expect(after.top).toBeLessThan(before.top); + expect(after.lines[0]!.size).toBeGreaterThan(before.lines[0]!.size); + expect(after.bottom).toBeCloseTo(before.bottom); + expect(after.top).toBeGreaterThanOrEqual(paper === "card6x4" ? 80 : 55); + expect(larger.replace(headlineGroupPattern, "")).toBe(normal.replace(headlineGroupPattern, "")); + } + } +}); + +test("long wedding headlines use upper space and remain bounded at maximum sizing", async () => { + const url = "https://ma.hadlock.tech/e/demo", qr = signQr(url); + for (const layout of ["compact", "typography"] as const) { + for (const paper of ["card4x6", "card6x4"] as const) { + const base: SignDesign = { ...design, layout, paper, headline: "Share", headlineScale: 150 }; + const short = headlineGeometry(guestSignSvg(base, url, qr)); + const svg = guestSignSvg({ ...base, headline: "Our favorite day, with our favorite people." }, url, qr); + const long = headlineGeometry(svg); + expect(long.lines.length).toBeGreaterThan(short.lines.length); + expect(long.top).toBeLessThan(short.top); + expect(long.bottom).toBeCloseTo(short.bottom); + expect(long.top).toBeGreaterThanOrEqual(paper === "card6x4" ? 80 : 55); + const { data, info } = await sharp(Buffer.from(svg), { density: 150 }).ensureAlpha().raw().toBuffer({ resolveWithObject: true }); + expect(jsQR(new Uint8ClampedArray(data), info.width, info.height)?.data).toBe(url); + } + } +}); + test("maximum text sizing keeps QR scannable across layouts and orientations", async () => { const url = "https://ma.hadlock.tech/e/demo"; for (const layout of [undefined, "compact", "photo", "typography"] as const) { diff --git a/apps/web/src/lib/guest-sign.ts b/apps/web/src/lib/guest-sign.ts index ea1232e..211c0af 100644 --- a/apps/web/src/lib/guest-sign.ts +++ b/apps/web/src/lib/guest-sign.ts @@ -74,14 +74,18 @@ export function guestSignSvg(design: SignDesign, guestUrl: string, qr: SignQr) { .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) => { + const sizedText = (value: string, x: number, y: number, base: number, percent: number, width: number, height: number, weight = 400, centered = false, upwardRegion?: { top: number; bottom: number }) => { 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) { + const availableHeight = upwardRegion ? upwardRegion.bottom - upwardRegion.top : height; + while (wrapped.length * size * 1.25 > availableHeight && size > 8) { size -= .5; wrapped = signLines(value, Math.max(1, Math.floor(width / (size * .65)))); } - return wrapped.map((line, index) => `${escapeXml(line)}`).join(""); + // Anchor the bottom (including descenders), so more/larger lines consume the + // unused space above before fitting down at the print-safe top margin. + const baseline = upwardRegion ? upwardRegion.bottom - size * .25 - (wrapped.length - 1) * size * 1.25 : y; + return wrapped.map((line, index) => `${escapeXml(line)}`).join(""); }; if (design.layout) { const compact = design.layout === "compact"; @@ -111,8 +115,8 @@ export function guestSignSvg(design: SignDesign, guestUrl: string, qr: SignQr) { ${escapeXml(design.title)} — guest photo sign - - ${sizedText(design.headline, textX, landscape ? 260 : 170, 76, design.headlineScale ?? 100, textWidth, 185, 600)} + + ${sizedText(design.headline, textX, 0, 76, design.headlineScale ?? 100, textWidth, 0, 600, false, { top: landscape ? 80 : 55, bottom: landscape ? 380 : 285 })} ${sizedText(design.message, textX, landscape ? 450 : 355, 29, design.messageScale ?? 100, textWidth, landscape ? 150 : 100)}