From ae7077c28373c03437f93364e8592ae1b9d62be0 Mon Sep 17 00:00:00 2001 From: 4gray Date: Thu, 27 Aug 2026 10:28:04 +0200 Subject: [PATCH] fix(release): assume uncalibrated scripts are full-width MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- tools/release/highlight-cards.mjs | 60 +++++++++++++++++++++----- tools/release/highlight-cards.test.mjs | 19 ++++++++ 2 files changed, 68 insertions(+), 11 deletions(-) diff --git a/tools/release/highlight-cards.mjs b/tools/release/highlight-cards.mjs index 8ba5a6225..79bed39ec 100644 --- a/tools/release/highlight-cards.mjs +++ b/tools/release/highlight-cards.mjs @@ -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; diff --git a/tools/release/highlight-cards.test.mjs b/tools/release/highlight-cards.test.mjs index 390323fd2..273796659 100644 --- a/tools/release/highlight-cards.test.mjs +++ b/tools/release/highlight-cards.test.mjs @@ -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);