fix(release): raise width factors above every observed rendering

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 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-08-27 10:47:11 +02:00
1 parent a32ab20b0b
commit 29499a0bae
3 files changed
+43 -19

No files matched your search

+6
View File
@@ -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
+14 -7
View File
@@ -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,
+23 -12
View File
@@ -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`
);
}
});