Let card headlines expand upward before fitting to safe margins

This commit is contained in:
2026-09-10 13:53:38 -04:00
parent 2c22ff550f
commit 6933c7a2e6
2 changed files with 57 additions and 5 deletions
+48
View File
@@ -45,6 +45,54 @@ test("text controls change each text independently without resizing the QR", ()
} }
}); });
const headlineGroupPattern = /<g\b[^>]*\bdata-sign-headline(?:="[^"]*")?[^>]*>[\s\S]*?<\/g>/;
function headlineGeometry(svg: string) {
const group = svg.match(headlineGroupPattern)?.[0];
expect(group).toBeDefined();
const lines = [...group!.matchAll(/<text\b([^>]*)>/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 () => { test("maximum text sizing keeps QR scannable across layouts and orientations", async () => {
const url = "https://ma.hadlock.tech/e/demo"; const url = "https://ma.hadlock.tech/e/demo";
for (const layout of [undefined, "compact", "photo", "typography"] as const) { for (const layout of [undefined, "compact", "photo", "typography"] as const) {
+9 -5
View File
@@ -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(""); .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) => { 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 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)))); 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; size -= .5;
wrapped = signLines(value, Math.max(1, Math.floor(width / (size * .65)))); wrapped = signLines(value, Math.max(1, Math.floor(width / (size * .65))));
} }
return wrapped.map((line, index) => `<text x="${x}" y="${y + index * size * 1.25}"${centered ? ' text-anchor="middle"' : ""} font-size="${size}" font-weight="${weight}">${escapeXml(line)}</text>`).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) => `<text x="${x}" y="${baseline + index * size * 1.25}"${centered ? ' text-anchor="middle"' : ""} font-size="${size}" font-weight="${weight}">${escapeXml(line)}</text>`).join("");
}; };
if (design.layout) { if (design.layout) {
const compact = design.layout === "compact"; const compact = design.layout === "compact";
@@ -111,8 +115,8 @@ export function guestSignSvg(design: SignDesign, guestUrl: string, qr: SignQr) {
<title>${escapeXml(design.title)} — guest photo sign</title><style>${fontFaces}</style> <title>${escapeXml(design.title)} — guest photo sign</title><style>${fontFaces}</style>
<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 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, 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 })}
</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>