CSS has been quietly good at CJK line breaking for years. word-break: normal plus line-break: strict plus the browser's default break-opportunity table gets you typographically sane paragraphs without writing a line of logic.
Canvas does not. <canvas> gives you measureText() — a width per string — and nothing else. If you build anything that composes text into an image (a share card, a certificate, a dynamic cover, a mini-program poster), you are writing your own line breaker. And the naive one is wrong in a way that is easy not to notice if you do not read Chinese.
The naive implementation, and exactly how it fails
accumulate characters until measureText(line + ch).width > maxWidth
then push the line and start a new one
For Latin text this is roughly acceptable, because the breaks almost always land near spaces. For CJK there are no spaces, so the break lands wherever the width runs out:
本产品支持批量导出
图片并自动压缩
That is fine by luck. Now shift the box 20px:
本产品支持批量导
出图片并自动压缩
导出 has been split. A Chinese reader does not read this as a slightly awkward break, they read it as a typo. The same code, two different results, and only one of them is acceptable.
Three rules that fix most of it
1. Prefer a break at punctuation. Before accepting a width-driven break, scan back up to ~12 characters for the nearest closing punctuation — ,。、:;!?)」』】—… — and break there instead. This single rule accounts for the majority of natural break points in running prose.
2. Keep numbers and their units together. If the break would leave 20 at the end of one line and + or 项 at the start of the next, pull the number down. Readers parse 20 / + 项 as two separate values.
3. Never start a line with closing punctuation. A full stop, comma, or closing bracket at the start of a line is the single most visible CJK typographic error, and it is also the easiest to check for.
const CLOSERS = ",。、:;!?)」』】—…";
const OPENERS = "(「『【";
function breakBefore(ctx, text, i, font, maxWidth, indent = 12) {
// try punctuation within `indent` characters, walking backwards
for (let k = 1; k <= indent && i - k > 0; k++) {
if (CLOSERS.includes(text[i - k])) return i - k + 1; // break AFTER the closer
}
// fall back: never leave a closer stranded at line start
if (CLOSERS.includes(text[i])) return i + 1;
return i;
}
Two implementation details that are easy to get wrong:
-
Measure with the same font object you draw with.
ctx.measureText()after changingctx.fontreflects the new font. Cache the width of each character once per (font, size) pair; measuring per character per frame is the usual reason a text layout janks. -
Let CJK characters wrap individually but keep Latin words intact. A run of
[A-Za-z0-9]+should be treated as one indivisible unit. Mixing the two scripts in one paragraph is the normal case in Chinese product copy, and it is where most homegrown wrappers fall apart.
If you can, do not write this at all
Modern browsers ship Intl.Segmenter with granularity: "word", which gives you the same break-opportunity data the CSS engine uses:
const seg = new Intl.Segmenter("zh-Hans", { granularity: "word" });
[...seg.segment("本产品支持批量导出图片并自动压缩")].map(s => s.segment);
Support is now broad enough to use directly, with a character-by-character fallback. If you are on the web and only need to lay out HTML, prefer CSS and stop reading. Canvas is the case where you have no choice.
The part that only shows up in production
Canvas fails differently from Python: there is no missing-glyph exception, silently, the same way. If a font fallback chain resolves to a face without the needed glyph, you get tofu boxes or blanks inside your drawn text. In a headless renderer (server-side, or a mini-program's offscreen canvas) you often do not see it until a user screenshots the result.
So after layout, run a cheap audit over the union of every character you intend to draw, and assert the chosen font actually covers it. Which characters break is always domain-specific — the rare surname on a certificate, the technical term on a data card — so audit your real strings, not lorem ipsum.
The rule set above, a wrapped-measureText layout pass, and the fallback chain is roughly what cjk-text-render does on the Python side. Writing the browser version is an afternoon; getting the break rules right is the part people skip.
Top comments (0)