Let card headlines expand upward before fitting to safe margins
This commit is contained in:
@@ -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 () => {
|
||||
const url = "https://ma.hadlock.tech/e/demo";
|
||||
for (const layout of [undefined, "compact", "photo", "typography"] as const) {
|
||||
|
||||
@@ -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) => `<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) {
|
||||
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>
|
||||
<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 font-family="${headingFont}" fill="${headingColor}" letter-spacing="-1.8">
|
||||
${sizedText(design.headline, textX, landscape ? 260 : 170, 76, design.headlineScale ?? 100, textWidth, 185, 600)}
|
||||
<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 })}
|
||||
</g>
|
||||
${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>
|
||||
|
||||
Reference in New Issue
Block a user