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);