mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 01:56:16 -08:00
fix(release): invert the width model so it cannot under-estimate
Chasing individual wide glyphs does not converge. Each review pass found another one the list had missed — `W`, then CJK and emoji, then the `ae` ligature, which passes the Latin range check and took the lowercase 0.6 factor while rendering about 1.05 em. Every miss crops the card silently, because an under-estimate skips both the wrap and the textLength clamp. The model is now inverted: narrow characters are enumerated and everything else is assumed wide (1.15 em). Accented Latin, ligatures, Cyrillic, Greek, CJK, kana, hangul and emoji all take the wide default, so the estimate can only ever run high — and running high costs an early line break nobody sees. There is no list left to miss an entry from. The real guard is a new test that renders each sample through sharp and asserts the estimate never falls below the measured ink width. It covers Latin, ligatures, CJK, hangul, Cyrillic, symbols and accents, and would have caught every one of the preceding rounds at once. Measured locally: 34 `W` renders 1669px against a 2033px estimate, 34 `ae` 1601px, 28 `CJK` 1459px — every case over-estimated, including both widths reported in review from a different font environment. 190 tests passing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ae7077c283
commit
a32ab20b0b
3 files changed
+91
-42
No files matched your search
@@ -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:
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 = [
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="6000" height="240">',
|
||||
`<text x="0" y="150" font-family="DM Sans, Helvetica Neue, Helvetica, Arial, sans-serif" font-size="${fontSize}" font-weight="800" fill="#fff">`,
|
||||
escapeXml(text),
|
||||
'</text></svg>',
|
||||
].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`
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user