Accurate Text Measurement and Rendering in Canvas with Font Shaping and HarfBuzz
This article explains how to load custom fonts, handle text shaping, measure and wrap text accurately in HTML5 canvas, and overcome issues like Arabic glyph variations and letter‑spacing by using ctx.measureText, whole‑string measurement, performance‑aware algorithms, and the HarfBuzz shaping engine.
Introduction
In HTML/CSS we normally write text directly inside a node (e.g., <p>) and style it with CSS, adding uncommon fonts via @font-face. The following example shows a simple style block that defines a custom font and applies it to a paragraph.
<style>
p {
width: 200px;
background: #F00;
font-family: 'PuHui';
font-size: 16px;
}
@font-face {
font-family: 'PuHui';
src: url(xxx.ttf);
}
</style>
<p>html的文字标签和css样式16px普惠体。</p>Challenges
When drawing with canvas, everything must be built from scratch. Fonts can be loaded with FontFace() and document.fonts.add(), set via ctx.font, and rendered with ctx.fillText(). Line breaking is typically done by measuring each character with ctx.measureText() and accumulating widths until a line exceeds the container width.
However, this per‑character approach hides complex shaping behavior. For example, Arabic letters change shape when they appear consecutively, and even Latin letters and numbers can have variant glyphs (e.g., the digit “1” or the letter “f”). Browsers such as Chrome and Firefox rely on HarfBuzz for shaping.
karas.render(
<canvas width="360" height="360">
<span style={{
fontSize: 32,
fontFamily: 'Arial',
background: '#F00',
}}>1111111111</span>
</canvas>,
'#test'
);Measuring the whole string reveals that the combined width of repeated characters can be smaller than the sum of individual widths, leading to layout errors if shaping is ignored.
Solution
Instead of accumulating per‑character widths, use ctx.measureText() on the entire string. The browser already includes shaping, so the measurement is accurate.
For multi‑line text, keep adding characters to a substring and measuring the whole substring until its width exceeds the container limit. This simple loop can be costly because each measureText call crosses the JavaScript‑C++ boundary.
let str = '111111';
let widthLimit = 50;
for (let i = 1, length = str.length; i <= length; i++) {
let s = str.slice(0, i);
if (ctx.measureText(s).width > widthLimit) {
// break line here
break;
}
}Karas optimizes this by predicting the remaining width: assuming a Chinese character roughly equals fontSize and an English character occupies 0.5–1 × fontSize, it estimates how many characters can fit, then refines the guess with a few adjustments, drastically reducing loop iterations.
Use Cases
Accurate text layout enables complex scenarios such as mixed image‑text cards, personalized share pages, and online document editors. For example, Google Docs supports bidirectional text but lacks letter‑spacing, while Tencent Docs provides letter‑spacing but fails to shape Arabic glyphs correctly.
Because ctx.fillText() cannot set letter-spacing, a workaround is to render the text without spacing to an off‑screen canvas, then use HarfBuzz (via WebAssembly) to obtain shaping information, split the text into glyphs, and draw each glyph with the desired spacing onto the main canvas.
In environments where the font set is known (e.g., online docs), you can still use ctx.measureText() for width and add characterCount × letterSpacing to compute the final size.
Overall, text shaping and measurement are just the tip of the iceberg; additional challenges such as block‑level layout, white‑space handling, line‑clamp, and punctuation squeezing increase complexity dramatically.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
