diff --git a/docs/architecture/release-pipeline.md b/docs/architecture/release-pipeline.md index b90a94c8a..4f0cc7bcd 100644 --- a/docs/architecture/release-pipeline.md +++ b/docs/architecture/release-pipeline.md @@ -52,12 +52,22 @@ keep it headline-sized — roughly what the hero card fits on one line. The cap is an authoring guideline, not a rendering guarantee: character count is not width. Card text wraps by *estimated rendered width* -(`estimateTextWidth`, calibrated against a measured bold rendering and erring -high), because 34 `W` at font-size 52 measures ~1948px where 1072px are -available — a character-capped line still ran off the canvas. Text that cannot -fit even after wrapping is ellipsized, and every emitted line carries an SVG -`textLength` clamp when the estimate says it would still overflow, so a -mis-measured glyph compresses rather than crops. +(`estimateTextWidth`), because 34 `W` at font-size 52 measures ~1948px where +1072px are available — a character-capped line still ran off the canvas. + +That estimate is deliberately **inverted**: narrow characters are enumerated +and everything else is assumed wide. Enumerating the wide ones instead cannot +converge — successive review passes each found another under-estimated glyph +(`W`, then CJK and emoji, then the `ae` ligature) — and a glyph the list misses +crops the card while every unit test still passes. With the wide default the +estimate can only run high, and running high costs an early line break nobody +sees. `highlight-cards.test.mjs` renders each sample through sharp and asserts +the estimate never falls below the measured ink width, which is the guard +against that whole class of bug. + +Text that cannot fit even after wrapping is ellipsized, and each emitted line +carries an SVG `textLength` clamp when the estimate still says it would +overflow. Highlights drive three behaviors: diff --git a/tools/release/highlight-cards.mjs b/tools/release/highlight-cards.mjs index 79bed39ec..c509aa27d 100644 --- a/tools/release/highlight-cards.mjs +++ b/tools/release/highlight-cards.mjs @@ -71,58 +71,51 @@ export function escapeXml(text) { * * Counting characters is not a width budget: 34 `W` at font-size 52 measures * ~1948px where only ~1072px are available, so a character-capped line still - * overflowed the canvas. These factors are calibrated against that measured - * bold rendering and deliberately err high — over-estimating wraps a line - * early, which is invisible; under-estimating crops the card. + * overflowed the canvas. + * + * The model is deliberately inverted — narrow characters are enumerated and + * **everything else is assumed wide**. Enumerating the wide ones instead is a + * game that cannot be won: successive passes each found another + * under-estimated glyph (`W`, then CJK and emoji, then the `ae` ligature), + * and any glyph the list misses crops the card silently. In this shape the + * estimate can only ever run high, and running high costs an early line + * break nobody sees. */ -const WIDE_GLYPHS = new Set('MWmw@%ЖШЩбюфЮ'); -const NARROW_GLYPHS = new Set("iljItfrJ.,;:'\"`!|()[]{}/\\-ЁІ"); +const WIDEST_FACTOR = 1.15; -/** - * @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) - ); -} +/** ASCII advance factors, padded above the measured bold rendering. */ +const ASCII_FACTORS = new Map([ + [' ', 0.3], + ...[...".,;:'\"`!|()[]{}/\\-ilIjtfr"].map((character) => [character, 0.38]), + ...[...'MWmw@%&#'].map((character) => [character, WIDEST_FACTOR]), +]); /** * @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; + const known = ASCII_FACTORS.get(character); + + if (known !== undefined) { + return known; } - if (NARROW_GLYPHS.has(character)) { - return 0.35; + if (character >= 'a' && character <= 'z') { + return 0.62; } - if (WIDE_GLYPHS.has(character)) { - return 1.1; + if (character >= 'A' && character <= 'Z') { + return 0.82; } - // 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; + if (character >= '0' && character <= '9') { + return 0.62; } - const isUppercase = - character === character.toUpperCase() && - character !== character.toLowerCase(); - - return isUppercase ? 0.78 : 0.6; + // Everything else: accented Latin, ligatures, Cyrillic, Greek, CJK, kana, + // hangul, emoji, and whatever else a headline turns out to carry. + return WIDEST_FACTOR; } /** diff --git a/tools/release/highlight-cards.test.mjs b/tools/release/highlight-cards.test.mjs index 273796659..f82795e6a 100644 --- a/tools/release/highlight-cards.test.mjs +++ b/tools/release/highlight-cards.test.mjs @@ -631,3 +631,49 @@ describe('generate-highlight-cards CLI', () => { assert.deepEqual(readdirSync(outputDir), ['card-old-feature.png']); }); }); + +describe('estimateTextWidth against real rendering', () => { + /** Ink width of one rendered line, via sharp's trim. */ + async function measureRenderedWidth(text, fontSize) { + const svg = [ + '', + ``, + escapeXml(text), + '', + ].join(''); + const { info } = await sharp(Buffer.from(svg)) + .trim({ threshold: 1 }) + .toBuffer({ resolveWithObject: true }); + + return info.width; + } + + it('never under-estimates a rendered line', async () => { + // The guard against the whole class of bug that produced this model: + // an under-estimate skips both the wrap and the textLength clamp, and + // the card is cropped with every unit test still passing. + const samples = [ + ['Advanced subtitles with external files', 52], + ['Live TV recordings in the download manager', 52], + ['W'.repeat(34), 52], + ['M'.repeat(34), 62], + ['æ'.repeat(34), 52], + ['œ'.repeat(30), 52], + ['界'.repeat(28), 52], + ['한'.repeat(28), 30], + ['Дашборд с рекомендациями TMDB', 52], + ['#@%&'.repeat(10), 52], + ['Ünïcödé áccênts thrøughöut', 52], + ]; + + for (const [text, fontSize] of samples) { + const measured = await measureRenderedWidth(text, fontSize); + const estimate = estimateTextWidth(text, fontSize); + + assert.ok( + estimate >= measured, + `"${text.slice(0, 30)}" at ${fontSize}px: estimate ${Math.round(estimate)}px < measured ${measured}px` + ); + } + }); +});