Widen landscape card headlines with proportional wrapping
This commit is contained in:
@@ -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();
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user