fix(release): assume uncalibrated scripts are full-width

The width model classified anything uncased as narrow lowercase (0.6 em),
so a 60-glyph CJK headline estimated small enough to skip both the wrap and
the textLength clamp, and painted into the canvas's right margin. Emoji
fell through the same way.

Character classification is now fail-safe: outside the calibrated scripts
(Latin through Extended-B, Greek, Cyrillic, ASCII punctuation) a glyph is
assumed full-width. CJK, kana, hangul and emoji really are about one em,
and for a genuinely narrow script the cost of guessing wide is an early
line break — invisible — against a cropped card for guessing narrow.

Keeping the estimate rather than measuring preserves this module as pure,
dependency-free and unit-testable; the fail-safe default is what makes an
estimate acceptable there.

Verified by rendering a 60-glyph CJK headline with an emoji body through
sharp: both stay inside the canvas.

189 tests passing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-08-27 10:28:04 +02:00
1 parent 091693924c
commit ae7077c283
2 files changed
+68 -11

No files matched your search

+49 -11
View File
@@ -78,6 +78,53 @@ export function escapeXml(text) {
const WIDE_GLYPHS = new Set('MWmw@%ЖШЩбюфЮ');
const NARROW_GLYPHS = new Set("iljItfrJ.,;:'\"`!|()[]{}/\\-ЁІ");
/**
* @param {string} text
* @param {number} fontSize
* @returns {number} estimated rendered width in pixels
*/
/**
* Latin (through Extended-B), Greek and Cyrillic plus ASCII punctuation — the
* scripts the factors above were calibrated on.
*/
function isCalibratedScript(codePoint) {
return (
codePoint <= 0x024f || (codePoint >= 0x0370 && codePoint <= 0x04ff)
);
}
/**
* @param {string} character a single code point
* @returns {number} advance width as a fraction of the font size
*/
function advanceFactor(character) {
if (character === ' ') {
return 0.3;
}
if (NARROW_GLYPHS.has(character)) {
return 0.35;
}
if (WIDE_GLYPHS.has(character)) {
return 1.1;
}
// Anything outside the calibrated scripts is assumed full-width. CJK,
// kana, hangul and emoji genuinely are around one em, and for a narrow
// script the cost of guessing wide is an early line break — invisible —
// against a cropped card for guessing narrow.
if (!isCalibratedScript(character.codePointAt(0))) {
return 1.1;
}
const isUppercase =
character === character.toUpperCase() &&
character !== character.toLowerCase();
return isUppercase ? 0.78 : 0.6;
}
/**
* @param {string} text
* @param {number} fontSize
@@ -86,18 +133,9 @@ const NARROW_GLYPHS = new Set("iljItfrJ.,;:'\"`!|()[]{}/\\-ЁІ");
export function estimateTextWidth(text, fontSize) {
let units = 0;
// Iterating the string yields code points, so an emoji counts once.
for (const character of text) {
if (character === ' ') {
units += 0.3;
} else if (NARROW_GLYPHS.has(character)) {
units += 0.35;
} else if (WIDE_GLYPHS.has(character)) {
units += 1.1;
} else if (character === character.toUpperCase() && character !== character.toLowerCase()) {
units += 0.78;
} else {
units += 0.6;
}
units += advanceFactor(character);
}
return units * fontSize;
+19
View File
@@ -125,6 +125,25 @@ describe('wrapText', () => {
}
});
it('treats uncalibrated scripts as full-width rather than narrow', () => {
// CJK, kana, hangul and emoji are ~1 em; falling through to the
// lowercase-Latin factor let a 60-glyph headline paint off-canvas.
for (const glyph of ['界', 'ひ', '한', '🎉', 'Ω'.repeat(1)]) {
const wide = estimateTextWidth(glyph.repeat(28), 52);
assert.ok(
wide > TEXT_MAX_WIDTH,
`28 × "${glyph}" estimates only ${Math.round(wide)}px`
);
}
});
it('keeps a full-width headline inside the canvas', () => {
for (const line of wrapText('界'.repeat(60), budget(TEXT_MAX_WIDTH, 52, 2))) {
assert.ok(estimateTextWidth(line, 52) <= TEXT_MAX_WIDTH, line);
}
});
it('estimates a wide glyph run near its measured rendered width', () => {
// Calibration anchor: 34 `W` at font-size 52 renders ~1948px.
const estimate = estimateTextWidth('W'.repeat(34), 52);