From 29499a0bae5204ba7f2b85255e226f2724357f54 Mon Sep 17 00:00:00 2001 From: 4gray Date: Thu, 27 Aug 2026 10:47:11 +0200 Subject: [PATCH] fix(release): raise width factors above every observed rendering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The narrow factors were themselves under-estimates. `r` was assigned 0.38 while measuring 0.389 em here and about 0.49 em in the environment that reported it, so a 54-`r` line estimated 1067px, rendered 1381px there, and was neither wrapped nor clamped. The guard test added in the previous commit did not catch it only because no sample held a long narrow run. Root cause: the SVG names `DM Sans` and nothing guarantees it is installed, so each host resolves the fallback chain differently and renders the same line at a different width. No estimate can be both tight and correct across environments, so the factors now sit well above the widest observation — every sample over-estimates by at least 1.25x locally, which covers the wider measurements reported in review. The guard test gains the cases that were failing (long `r` and `i` runs, punctuation, digits) and its failure message says to raise the factors rather than suggesting the test is wrong. The word-wrap test is rewritten as a property — whole words, in order, each line within budget — because pinning exact break points made every future calibration look like a regression. 190 tests passing. Co-Authored-By: Claude Fable 5 --- docs/architecture/release-pipeline.md | 6 +++++ tools/release/highlight-cards.mjs | 21 ++++++++++------ tools/release/highlight-cards.test.mjs | 35 +++++++++++++++++--------- 3 files changed, 43 insertions(+), 19 deletions(-) diff --git a/docs/architecture/release-pipeline.md b/docs/architecture/release-pipeline.md index 4f0cc7bcd..4f6287170 100644 --- a/docs/architecture/release-pipeline.md +++ b/docs/architecture/release-pipeline.md @@ -55,6 +55,12 @@ is not width. Card text wraps by *estimated rendered width* (`estimateTextWidth`), because 34 `W` at font-size 52 measures ~1948px where 1072px are available — a character-capped line still ran off the canvas. +The SVG names `DM Sans`, but nothing guarantees it is installed: every host +resolves the fallback chain differently, and the same line measures 0.389 em +per `r` here against about 0.49 em elsewhere. No estimate can be both tight +and correct across environments, so the factors sit well above the widest +observation — locally the model over-estimates every sample by at least 1.25×. + 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 diff --git a/tools/release/highlight-cards.mjs b/tools/release/highlight-cards.mjs index c509aa27d..e5bda076a 100644 --- a/tools/release/highlight-cards.mjs +++ b/tools/release/highlight-cards.mjs @@ -81,12 +81,19 @@ export function escapeXml(text) { * estimate can only ever run high, and running high costs an early line * break nobody sees. */ -const WIDEST_FACTOR = 1.15; +const WIDEST_FACTOR = 1.25; -/** ASCII advance factors, padded above the measured bold rendering. */ +/** + * ASCII advance factors. The SVG names `DM Sans` but nothing guarantees it is + * installed, so every host resolves the fallback chain differently and the + * same line renders at different widths: `r` measures 0.389 em here and about + * 0.49 em in the environment that reported this. These factors sit above the + * widest of those observations with margin, because the failure that matters + * is one-directional — an over-estimate wraps early, an under-estimate crops. + */ const ASCII_FACTORS = new Map([ - [' ', 0.3], - ...[...".,;:'\"`!|()[]{}/\\-ilIjtfr"].map((character) => [character, 0.38]), + [' ', 0.4], + ...[...".,;:'\"`!|()[]{}/\\-ilIjtfr"].map((character) => [character, 0.55]), ...[...'MWmw@%&#'].map((character) => [character, WIDEST_FACTOR]), ]); @@ -102,15 +109,15 @@ function advanceFactor(character) { } if (character >= 'a' && character <= 'z') { - return 0.62; + return 0.75; } if (character >= 'A' && character <= 'Z') { - return 0.82; + return 0.92; } if (character >= '0' && character <= '9') { - return 0.62; + return 0.75; } // Everything else: accented Latin, ligatures, Cyrillic, Greek, CJK, kana, diff --git a/tools/release/highlight-cards.test.mjs b/tools/release/highlight-cards.test.mjs index f82795e6a..cbfe7fbe4 100644 --- a/tools/release/highlight-cards.test.mjs +++ b/tools/release/highlight-cards.test.mjs @@ -80,17 +80,17 @@ describe('wrapText', () => { }); it('wraps on word boundaries within the width budget', () => { - assert.deepEqual(wrapText('one two three four', budget(60, 10, 4)), [ - 'one two', - 'three four', - ]); - // A tighter budget breaks earlier, still only between words. - assert.deepEqual(wrapText('one two three four', budget(30, 10, 4)), [ - 'one', - 'two', - 'three', - 'four', - ]); + // Asserted as a property, not an exact split: the break points move + // whenever the advance factors are tuned, and pinning them would make + // every future calibration look like a regression. + const text = 'one two three four'; + const lines = wrapText(text, budget(60, 10, 4)); + + assert.ok(lines.length > 1, 'expected the text to wrap at all'); + assert.equal(lines.join(' '), text, 'words were lost or reordered'); + for (const line of lines) { + assert.ok(estimateTextWidth(line, 10) <= 60, line); + } }); it('breaks a word wider than the budget instead of overflowing the card', () => { @@ -655,6 +655,12 @@ describe('estimateTextWidth against real rendering', () => { const samples = [ ['Advanced subtitles with external files', 52], ['Live TV recordings in the download manager', 52], + // A long run of a "narrow" glyph: the case that proved the + // narrow factors were themselves under-estimates. + ['r'.repeat(54), 52], + ['i'.repeat(54), 52], + ['....------', 52], + ['0123456789', 52], ['W'.repeat(34), 52], ['M'.repeat(34), 62], ['æ'.repeat(34), 52], @@ -670,9 +676,14 @@ describe('estimateTextWidth against real rendering', () => { const measured = await measureRenderedWidth(text, fontSize); const estimate = estimateTextWidth(text, fontSize); + // Fonts resolve differently per host — the SVG names DM Sans and + // nothing guarantees it is installed — so this asserts the model + // stays conservative in whatever environment runs it. A failure + // here means the factors in `advanceFactor` are too low for this + // host's fallback and must be raised, not that the test is wrong. assert.ok( estimate >= measured, - `"${text.slice(0, 30)}" at ${fontSize}px: estimate ${Math.round(estimate)}px < measured ${measured}px` + `"${text.slice(0, 30)}" at ${fontSize}px: estimate ${Math.round(estimate)}px < measured ${measured}px — raise the advanceFactor values` ); } });