One text layout for the browser and the server, so the live preview is the printed page

2026-11-03来源 dev.to WebDevEnglish

How we lay out a handwritten dedication twice, with canvas in the browser and fontkit on the server, and keep both within 3 pixels.

parent types a few words for their child. The page beside the text box redraws as they type. A day later, that same page is printed into their book.

If the preview wraps "together." onto a new line and the printed page doesn't, the parent was shown something that isn't true. So the preview and the final page have to agree on every line break and every position, even though one is drawn by a browser and the other by a Node server.

This post is how we did that: one shared layout module, two ways of measuring text, and a test that compares the two.

Context: I'm building Pebblehug, personalized picture books starring a child and their Mom or Dad. The dedication editor described here is in testing and not live yet.

The page
The dedication page is a fixed 1088 × 1408 px image: a painted background with a safe area for text. It has four blocks, set like a handwritten letter:

To Mia, on the left, just above the message
the message, up to 120 characters and four lines, on the left
the signature ("Love, Dad"), on the right, 36 px below the message's lowest glyph
the date, on the right, 10 px below the signature
Everything is in one handwriting font (Kalam Bold). The signature and date follow the block above them, so a three-line message pushes them down and a one-line message pulls them up. That's what makes it look written rather than templated, and it's also why every block depends on exact measurements of the one before it.

The idea: share everything except the ruler
Both sides import the same contract.ts (canvas size, safe area, block positions, font files and their SHA-256) and the same typography.ts (which font each character uses, how lines wrap). The only thing that differs is the function that measures a piece of text:

// typography.ts (shared)
export type TextMetrics = { width: number; advance?: number; minX: number; maxX: number; minY: number; maxY: number };
export type Measure = (text: string, font: DedicationFont, fontSize: number) => TextMetrics;
In the browser, Measure is a canvas:

// browser.ts (simplified)
export function canvasMeasure(): Measure | undefined {
const context = document.createElement("canvas").getContext("2d");
if (!context) return;
return (text, font, fontSize) => {
context.font = ${DEDICATION_FONTS[font].weight} ${fontSize}px "${DEDICATION_FONT_FAMILY[font]}";
const m = context.measureText(text);
const minX = -m.actualBoundingBoxLeft, maxX = m.actualBoundingBoxRight;
return { advance: m.width, width: Math.max(m.width, maxX) - Math.min(0, minX),
minX, maxX, minY: -m.actualBoundingBoxDescent, maxY: m.actualBoundingBoxAscent };
};
}
On the server, it's fontkit, walking the shaped glyphs and their bounding boxes:

// layout.ts (simplified)
function shape(text: string, font: Font, fontSize: number) {
const run = font.layout(text), scale = fontSize / font.unitsPerEm;
let penX = 0, minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
run.glyphs.forEach((glyph, i) => {
const p = run.positions[i];
if (glyph.path.commands.length) {
minX = Math.min(minX, penX + p.xOffset + glyph.bbox.minX);
maxX = Math.max(maxX, penX + p.xOffset + glyph.bbox.maxX);
minY = Math.min(minY, p.yOffset + glyph.bbox.minY);
maxY = Math.max(maxY, p.yOffset + glyph.bbox.maxY);
}
penX += p.xAdvance;
});
return { advance: penX * scale, width: (Math.max(penX, maxX) - Math.min(0, minX)) * scale,
minX: minX * scale, maxX: maxX * scale, minY: minY * scale, maxY: maxY * scale };
}
Wrapping, shrinking a long name to fit, placing each line and stacking the blocks all happen in shared code that only ever calls measure(...):

// typography.ts (simplified)
export function wrapDedicationText(text: string, font: DedicationFont, size: number, measure: Measure) {
const lines: string[] = [], width = DEDICATION_LAYOUT.safeArea.width;
const fits = (value: string) => measureDedicationText(value, font, size, measure).width <= width;
for (const paragraph of text.split("\n")) {
let current = "";
for (const word of paragraph.split(" ")) {
const candidate = current ? ${current} ${word} : word;
if (fits(candidate)) { current = candidate; continue; }
if (!fits(word)) return undefined; // the server answers does_not_fit
if (current) lines.push(current);
current = word;
}
lines.push(current);
}
return lines;
}
The real version also breaks long names between graphemes and handles CJK text, but the shape is the same.

Who wins
While the parent types, the browser lays out the draft and the page redraws immediately. A second after they stop, the words are saved. The server normalizes them, lays them out with fontkit and returns the lines. From then on, the browser shows the server's lines, not its own.

The server never trusts coordinates from the browser. At payment the saved layout is frozen with a version string. When the book is made, the server lays the words out again, checks the result is identical to what was frozen, and only then draws the glyph outlines onto the background:

// layout.ts (simplified): glyph outlines as SVG paths over the original PNG
paths.push(<path transform="translate(${x} ${y}) scale(${scale} ${-scale})" d="${glyph.path.toSVG()}"/>);
// ...
const overlay = Buffer.from(<svg xmlns="http://www.w3.org/2000/svg" width="1088" height="1408">${paths.join("")}</svg>);
return sharp(background).composite([{ input: overlay, left: 0, top: 0 }]).jpeg({ quality: 95 }).toBuffer();
Customer text never becomes markup there. Only numbers from our own layout and path data from our own font files go into the SVG.

What tripped us up
Measuring before the font loads. measureText happily measures with whatever fallback font is active. We load the exact font files with FontFace, register them under our own family names (so site CSS can never change them), and use a rough estimate until they're ready. As soon as they load, the draft is laid out again with real metrics.

Width isn't the ink. A handwriting font has strokes that hang outside the advance width, so measureText().width alone can let a glyph poke past the safe area. Both sides use the glyph bounds instead: width = max(advance, maxX) - min(0, minX).

Normalize the same way, in the same order. \r\n vs \n, NFC vs NFD accents, double spaces: any difference changes line counts. Both sides run the same normalization (CRLF to LF, NFC, single spaces, trimmed lines) before measuring.

Characters the handwriting font doesn't have. Names come in every script, and people add emoji. Instead of letting the browser pick a system font (which the server can't reproduce), each grapheme is assigned to one of our bundled fonts: Kalam first, then Noto Sans, Noto Sans CJK, and Noto Emoji. Each line is stored as runs with their own font and x-offset, and both sides draw the runs the same way. Choosing per grapheme, not per code point, keeps accents and emoji sequences together.

Big fallback fonts load only when needed. The CJK font file is about 16 MB. The browser works out which fonts the current text needs and loads only those, so most parents only ever download the handwriting font.

Browsers adjust CJK punctuation. Browsers apply contextual punctuation trimming to CJK text that fontkit's layout doesn't. We put each CJK grapheme in its own run, so the browser has no neighbouring characters to adjust against.

Emoji presentation selectors. A trailing U+FE0F can make the browser reach for the OS color emoji font. We strip presentation selectors before drawing a run in our emoji font, and keep the saved text untouched.

Old orders must keep their layout. The layout changed several times during development. Each saved page records its layout version, and production can still re-run the older versions it knows. A new font or wrapping rule gets a new version instead of silently re-flowing a page someone already paid for.

How we test it
A Playwright test runs the real flow against the real save API: type a message, read the line positions from the browser's draft, wait for the save, read the server's lines, and compare.

const draft = await drawn(editor); // [{ text, x, y }] from the SVG
expect((await saved).status()).toBe(200);
const server = await drawn(editor);
expect(server.map((l) => l.text)).toEqual(draft.map((l) => l.text));
for (const [i, line] of server.entries()) {
expect(Math.abs(line.x - draft[i].x)).toBeLessThan(3);
expect(Math.abs(line.y - draft[i].y)).toBeLessThan(3);
}
Same line breaks, and every line within 3 px of where the server puts it. A second test types mixed Chinese and emoji, checks that the CJK and emoji fonts weren't downloaded before they were needed, saves with Done, confirms the server stored fallback-font runs, and reloads to make sure the words come back.

What I'd still like to improve
The 3 px tolerance comes from canvas and fontkit rounding glyph bounds slightly differently. It never changes a line break in our tests, but I'd like to know if anyone has got canvas text metrics to match a server-side shaper exactly. Have you shared one layout between a browser preview and a server renderer? I'd like to hear what broke for you.

原文:https://www.a8q9.cn/en/article/one-text-layout-for-the-browser-and-the-server-so-the-live-preview-is-the-printe-db6fe3.html

相关文章