From ed160f8b93dfc183a86803c32f4cdae8723ba297 Mon Sep 17 00:00:00 2001 From: Sean O'Connor Date: Thu, 10 Sep 2026 14:02:01 -0400 Subject: [PATCH] Widen landscape card headlines with proportional wrapping --- apps/web/src/lib/guest-sign.test.ts | 11 ++++++++++ apps/web/src/lib/guest-sign.ts | 34 +++++++++++++++++++++++++---- 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/apps/web/src/lib/guest-sign.test.ts b/apps/web/src/lib/guest-sign.test.ts index 152c168..cc20010 100644 --- a/apps/web/src/lib/guest-sign.test.ts +++ b/apps/web/src/lib/guest-sign.test.ts @@ -46,6 +46,17 @@ test("text controls change each text independently without resizing the QR", () }); const headlineGroupPattern = /]*\bdata-sign-headline(?:="[^"]*")?[^>]*>[\s\S]*?<\/g>/; +test("landscape wedding headline uses three wider lines without stretching letters", () => { + const url = "https://ma.hadlock.tech/e/demo"; + for (const layout of ["compact", "typography"] as const) { + const svg = guestSignSvg({ ...design, paper: "card6x4", layout, headline: "Our favorite day, with our favorite people." }, url, signQr(url)); + const heading = svg.match(headlineGroupPattern)![0]; + const lines = [...heading.matchAll(/]*>(.*?)<\/text>/g)].map(match => match[1]); + expect(lines).toEqual(["Our favorite day,", "with our favorite", "people."]); + expect(heading).not.toContain("textLength"); + expect(heading).not.toContain("scale("); + } +}); function headlineGeometry(svg: string) { const group = svg.match(headlineGroupPattern)?.[0]; expect(group).toBeDefined(); diff --git a/apps/web/src/lib/guest-sign.ts b/apps/web/src/lib/guest-sign.ts index 211c0af..4cad117 100644 --- a/apps/web/src/lib/guest-sign.ts +++ b/apps/web/src/lib/guest-sign.ts @@ -47,6 +47,30 @@ export function signLines(value: string, limit: number) { return lines; } +// Headlines need proportional wrapping: an "i" should not reserve the same +// space as a "W". Keep a conservative estimate for wide glyphs in our fonts. +function headlineLinesForWidth(value: string, width: number, size: number) { + const measure = (text: string) => Array.from(text).reduce((sum, letter) => sum + + (/\s/u.test(letter) ? .3 : /[MW@]/u.test(letter) ? 1.05 : /[mw]/u.test(letter) ? .85 : /[ilI.,!':;’]/u.test(letter) ? .28 : /[A-Z]/u.test(letter) ? .78 : .5), 0) * size; + const chunks = value.trim().split(/\s+/u).flatMap(word => { + const parts: string[] = []; + let part = ""; + for (const letter of word) { + if (part && measure(part + letter) > width) { parts.push(part); part = ""; } + part += letter; + } + if (part) parts.push(part); + return parts; + }); + const lines: string[] = []; + for (const chunk of chunks) { + const last = lines.length - 1; + if (last >= 0 && measure(`${lines[last]} ${chunk}`) <= width) lines[last] += ` ${chunk}`; + else lines.push(chunk); + } + 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; @@ -74,13 +98,14 @@ 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, upwardRegion?: { top: number; bottom: number }) => { + 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 }, proportional = 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)))); + const wrap = () => proportional ? headlineLinesForWidth(value, width, size) : signLines(value, Math.max(1, Math.floor(width / (size * .65)))); + let wrapped = wrap(); 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)))); + wrapped = wrap(); } // Anchor the bottom (including descenders), so more/larger lines consume the // unused space above before fitting down at the print-safe top margin. @@ -109,6 +134,7 @@ export function guestSignSvg(design: SignDesign, guestUrl: string, qr: SignQr) { const textX = landscape ? 100 : 85; const textWidth = landscape ? 530 : 680; const codeX = landscape ? 760 : (w - qrSize) / 2; + const headlineWidth = landscape ? codeX - textX - 40 : textWidth; const codeY = landscape ? 210 : 475; const footerY = landscape ? 655 : 955; return ` @@ -116,7 +142,7 @@ export function guestSignSvg(design: SignDesign, guestUrl: string, qr: SignQr) { - ${sizedText(design.headline, textX, 0, 76, design.headlineScale ?? 100, textWidth, 0, 600, false, { top: landscape ? 80 : 55, bottom: landscape ? 380 : 285 })} + ${sizedText(design.headline, textX, 0, 76, design.headlineScale ?? 100, headlineWidth, 0, 600, false, { top: landscape ? 80 : 55, bottom: landscape ? 380 : 285 }, landscape)} ${sizedText(design.message, textX, landscape ? 450 : 355, 29, design.messageScale ?? 100, textWidth, landscape ? 150 : 100)}