Widen landscape card headlines with proportional wrapping

This commit is contained in:
2026-09-10 14:02:01 -04:00
parent 6933c7a2e6
commit ed160f8b93
2 changed files with 41 additions and 4 deletions
+11
View File
@@ -46,6 +46,17 @@ test("text controls change each text independently without resizing the QR", ()
}); });
const headlineGroupPattern = /<g\b[^>]*\bdata-sign-headline(?:="[^"]*")?[^>]*>[\s\S]*?<\/g>/; const headlineGroupPattern = /<g\b[^>]*\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\b[^>]*>(.*?)<\/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) { function headlineGeometry(svg: string) {
const group = svg.match(headlineGroupPattern)?.[0]; const group = svg.match(headlineGroupPattern)?.[0];
expect(group).toBeDefined(); expect(group).toBeDefined();
+30 -4
View File
@@ -47,6 +47,30 @@ export function signLines(value: string, limit: number) {
return lines; 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) { export function guestSignSvg(design: SignDesign, guestUrl: string, qr: SignQr) {
const paper = signPaper(design); const paper = signPaper(design);
const color = (value: string | undefined, fallback: string) => /^#[\da-f]{6}$/i.test(value ?? "") ? value! : fallback; 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(""); .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; const qx = landscape ? 710 : 240, qy = landscape ? 235 : 420;
// Keep text inside its own region; changing type never moves or shrinks the QR. // 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 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; const availableHeight = upwardRegion ? upwardRegion.bottom - upwardRegion.top : height;
while (wrapped.length * size * 1.25 > availableHeight && size > 8) { while (wrapped.length * size * 1.25 > availableHeight && size > 8) {
size -= .5; 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 // Anchor the bottom (including descenders), so more/larger lines consume the
// unused space above before fitting down at the print-safe top margin. // 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 textX = landscape ? 100 : 85;
const textWidth = landscape ? 530 : 680; const textWidth = landscape ? 530 : 680;
const codeX = landscape ? 760 : (w - qrSize) / 2; const codeX = landscape ? 760 : (w - qrSize) / 2;
const headlineWidth = landscape ? codeX - textX - 40 : textWidth;
const codeY = landscape ? 210 : 475; const codeY = landscape ? 210 : 475;
const footerY = landscape ? 655 : 955; const footerY = landscape ? 655 : 955;
return `<svg xmlns="http://www.w3.org/2000/svg" width="${paper.width}" height="${paper.height}" viewBox="0 0 850 ${paper.viewHeight}"> return `<svg xmlns="http://www.w3.org/2000/svg" width="${paper.width}" height="${paper.height}" viewBox="0 0 850 ${paper.viewHeight}">
@@ -116,7 +142,7 @@ export function guestSignSvg(design: SignDesign, guestUrl: string, qr: SignQr) {
<rect width="850" height="${paper.viewHeight}" fill="${background}"/> <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}"> <g transform="translate(${(850 - w * scale) / 2} ${(paper.viewHeight - h * scale) / 2}) scale(${scale})" font-family="SignInter, sans-serif" fill="${foreground}">
<g data-sign-headline="true" font-family="${headingFont}" fill="${headingColor}" letter-spacing="-1.8"> <g data-sign-headline="true" font-family="${headingFont}" fill="${headingColor}" letter-spacing="-1.8">
${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)}
</g> </g>
${sizedText(design.message, textX, landscape ? 450 : 355, 29, design.messageScale ?? 100, textWidth, landscape ? 150 : 100)} ${sizedText(design.message, textX, landscape ? 450 : 355, 29, design.messageScale ?? 100, textWidth, landscape ? 150 : 100)}
<svg x="${codeX}" y="${codeY}" 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> <svg x="${codeX}" y="${codeY}" 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>