mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 12:06:16 -08:00
fix(release): wrap card text by estimated width, not character count
A character budget is not a width budget. Codex measured a 34-character line of `W` at font-size 52 rendering ~1948px into 1072px of available space: the previous fix guaranteed line LENGTH, so its test passed while the card still cropped. Card text now wraps by estimated rendered width. `estimateTextWidth` assigns per-glyph advance factors — calibrated against that measurement and deliberately erring high, since over-estimating wraps a line early (invisible) while under-estimating crops the card — and every call site passes the real pixel budget instead of a character count. Text that still cannot fit is ellipsized to width, and each emitted line carries an SVG `textLength`/`spacingAndGlyphs` clamp when the estimate says it would overflow anyway, so a mis-measured glyph compresses rather than crops. Verified by rendering the pathological 60-`W` headline through sharp on both card layouts and the hero: all stay inside the canvas. Docs: the 60-character cap is now described as the authoring guideline it is, not a rendering guarantee. 187 tests passing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
62359c80fc
commit
091693924c
6 files changed
+210
-65
No files matched your search
+3
-2
@@ -52,8 +52,9 @@ The body is capped at 400 characters — depth belongs in the blog post.
|
||||
- ✅ "Series whose title carries a season marker no longer show the wrong season"
|
||||
|
||||
`highlight` marks the change as one of the release's headline features and
|
||||
gives it a short, poster-worthy name. The 60-character cap is the hero card's
|
||||
single-line budget, so a valid highlight always renders in full. Highlights lead the Telegram/Reddit
|
||||
gives it a short, poster-worthy name. The 60-character cap keeps it roughly to
|
||||
one line on the hero card; card text wraps by estimated width, so a headline of
|
||||
unusually wide glyphs may still wrap or ellipsize rather than overflow. Highlights lead the Telegram/Reddit
|
||||
announcements (everything else collapses into a "+N more" counter) and become
|
||||
ready-made section headings in the blog scaffold. Set it on the two or three
|
||||
changes worth announcing — a release where everything is a highlight has none.
|
||||
|
||||
@@ -66,7 +66,7 @@ This file provides guidance to coding agents working in this repository.
|
||||
- Name it `<area>-<short-slug>.md`; `area` matches the conventional-commit scope. There is no version field — the release version is chosen at release time.
|
||||
- Write the body for a user, not a reviewer: "the player now remembers volume between episodes", not "hoist volume state into the session". Max 400 characters; depth belongs in the release blog post.
|
||||
- `type: internal` records invisible maintenance. Internal notes stay collapsed in `CHANGELOG.md`, are omitted from the blog scaffold, and are removed from the authored public GitHub body by `extract-changelog-section.mjs --public`; GitHub's generated commit list remains separate, so an internal-only release can have an empty authored body.
|
||||
- `highlight: <short headline>` (max 60 characters — the hero card's single-line budget — and rejected on `type: internal`) marks a note as one of the release's two or three headline changes. Highlights lead the Telegram/Reddit announcement drafts, become ready-made blog section headings, and are the input the highlight-card generator renders from. A release where everything is a highlight has none.
|
||||
- `highlight: <short headline>` (max 60 characters, rejected on `type: internal`) marks a note as one of the release's two or three headline changes. Highlights lead the Telegram/Reddit announcement drafts, become ready-made blog section headings, and are the input the highlight-card generator renders from. A release where everything is a highlight has none.
|
||||
- Skip the note for test-only changes, docs, CI/workflow plumbing, and pure refactors with no behavior change. When skipping on a PR that touches `apps/**` or `libs/**`, apply the `no-release-note` label.
|
||||
- CI enforces this: the "Release note gate" job in `.github/workflows/ci.yml` fails PRs that change runtime code without an added `.changes/*.md` or the label (policy in `tools/release/check-release-note-gate.mjs`; tests/e2e/website/mock-server/docs paths are auto-exempt).
|
||||
- The `release-notes` skill covers writing notes; the `release-cut` skill covers the full release sequence. Canonical contract — surfaces, ordering constraints, the required draft asset set: `docs/architecture/release-pipeline.md`.
|
||||
|
||||
@@ -32,7 +32,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
- Name it `<area>-<short-slug>.md`; `area` matches the conventional-commit scope. There is no version field — the release version is chosen at release time.
|
||||
- Write the body for a user, not a reviewer: "the player now remembers volume between episodes", not "hoist volume state into the session". Max 400 characters; depth belongs in the release blog post.
|
||||
- `type: internal` records invisible maintenance. Internal notes stay collapsed in `CHANGELOG.md`, are omitted from the blog scaffold, and are removed from the authored public GitHub body by `extract-changelog-section.mjs --public`; GitHub's generated commit list remains separate, so an internal-only release can have an empty authored body.
|
||||
- `highlight: <short headline>` (max 60 characters — the hero card's single-line budget — and rejected on `type: internal`) marks a note as one of the release's two or three headline changes. Highlights lead the Telegram/Reddit announcement drafts, become ready-made blog section headings, and are the input the highlight-card generator renders from. A release where everything is a highlight has none.
|
||||
- `highlight: <short headline>` (max 60 characters, rejected on `type: internal`) marks a note as one of the release's two or three headline changes. Highlights lead the Telegram/Reddit announcement drafts, become ready-made blog section headings, and are the input the highlight-card generator renders from. A release where everything is a highlight has none.
|
||||
- Skip the note for test-only changes, docs, CI/workflow plumbing, and pure refactors with no behavior change. When skipping on a PR that touches `apps/**` or `libs/**`, apply the `no-release-note` label.
|
||||
- CI enforces this: the "Release note gate" job in `.github/workflows/ci.yml` fails PRs that change runtime code without an added `.changes/*.md` or the label (policy in `tools/release/check-release-note-gate.mjs`; tests/e2e/website/mock-server/docs paths are auto-exempt).
|
||||
- The `release-notes` skill covers writing notes; the `release-cut` skill covers the full release sequence. Canonical contract — surfaces, ordering constraints, the required draft asset set: `docs/architecture/release-pipeline.md`.
|
||||
|
||||
@@ -47,9 +47,17 @@ published its frames, since they composite them.
|
||||
## `highlight:` — what leads a release
|
||||
|
||||
An optional note field naming one of the release's two or three headline
|
||||
changes. It is rejected on `type: internal`, and capped at **60 characters**
|
||||
because that is the hero card's single-line budget (`wrapText(headline, 60, 1)`)
|
||||
— a longer value would be silently ellipsized on an image nobody re-reads.
|
||||
changes. It is rejected on `type: internal`, and capped at **60 characters** to
|
||||
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.
|
||||
|
||||
Highlights drive three behaviors:
|
||||
|
||||
|
||||
@@ -21,6 +21,13 @@ export const SHOT_TOP = 330;
|
||||
export const SHOT_LEFT = (CARD_WIDTH - SHOT_WIDTH) / 2;
|
||||
export const SHOT_RADIUS = 14;
|
||||
|
||||
/** Left margin, and the width text may occupy before the right margin. */
|
||||
const TEXT_LEFT = 64;
|
||||
export const TEXT_MAX_WIDTH = CARD_WIDTH - TEXT_LEFT * 2;
|
||||
/** Hero bullet lines start further right, after the accent dot. */
|
||||
const HERO_BULLET_LEFT = 100;
|
||||
export const HERO_BULLET_MAX_WIDTH = CARD_WIDTH - HERO_BULLET_LEFT - TEXT_LEFT;
|
||||
|
||||
/** Feature-card text block, laid out to always clear the screenshot frame. */
|
||||
const HEADLINE_TOP = 168;
|
||||
const HEADLINE_LINE_HEIGHT = 58;
|
||||
@@ -60,35 +67,89 @@ export function escapeXml(text) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Greedy word wrap by character budget. SVG has no automatic text layout and
|
||||
* font metrics vary by host, so the budget is conservative.
|
||||
* Per-character advance width as a fraction of the font size.
|
||||
*
|
||||
* Every returned line is at most `maxChars` long, including when a single
|
||||
* word exceeds the budget: such a word is broken at the budget rather than
|
||||
* left whole. Leaving it whole is what a naive wrap does, and a 60-character
|
||||
* unbroken headline — well inside the validated limit — then rendered past
|
||||
* the edge of the 1200px card and was cropped.
|
||||
* 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.
|
||||
*/
|
||||
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
|
||||
*/
|
||||
export function estimateTextWidth(text, fontSize) {
|
||||
let units = 0;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
return units * fontSize;
|
||||
}
|
||||
|
||||
/** Splits one overlong word into chunks that each fit `maxWidth`. */
|
||||
function breakWord(word, maxWidth, fontSize) {
|
||||
const chunks = [];
|
||||
let chunk = '';
|
||||
|
||||
for (const character of word) {
|
||||
if (
|
||||
chunk &&
|
||||
estimateTextWidth(chunk + character, fontSize) > maxWidth
|
||||
) {
|
||||
chunks.push(chunk);
|
||||
chunk = character;
|
||||
continue;
|
||||
}
|
||||
|
||||
chunk += character;
|
||||
}
|
||||
|
||||
if (chunk) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Greedy word wrap by estimated rendered width. SVG has no automatic text
|
||||
* layout, so the wrap has to decide the breaks itself; every returned line is
|
||||
* estimated to fit `maxWidth`, including when a single word does not — such a
|
||||
* word is broken rather than left to run off the canvas.
|
||||
*
|
||||
* @param {string} text
|
||||
* @param {number} maxChars
|
||||
* @param {number} maxLines
|
||||
* @returns {string[]} at most maxLines lines, the last one ellipsized on overflow
|
||||
* @param {{ maxWidth: number, fontSize: number, maxLines: number }} options
|
||||
* @returns {string[]} at most maxLines lines, the last ellipsized on overflow
|
||||
*/
|
||||
export function wrapText(text, maxChars, maxLines) {
|
||||
const words = text.replace(/\s+/g, ' ').trim().split(' ');
|
||||
export function wrapText(text, { maxWidth, fontSize, maxLines }) {
|
||||
const words = text.replace(/\s+/g, ' ').trim().split(' ').filter(Boolean);
|
||||
const lines = [];
|
||||
let current = '';
|
||||
|
||||
for (const word of words) {
|
||||
if (word.length > maxChars) {
|
||||
if (estimateTextWidth(word, fontSize) > maxWidth) {
|
||||
if (current) {
|
||||
lines.push(current);
|
||||
}
|
||||
|
||||
for (let start = 0; start < word.length; start += maxChars) {
|
||||
lines.push(word.slice(start, start + maxChars));
|
||||
}
|
||||
|
||||
lines.push(...breakWord(word, maxWidth, fontSize));
|
||||
// Keep the final chunk open so a following short word can join it.
|
||||
current = lines.pop() ?? '';
|
||||
continue;
|
||||
@@ -96,7 +157,7 @@ export function wrapText(text, maxChars, maxLines) {
|
||||
|
||||
const candidate = current ? `${current} ${word}` : word;
|
||||
|
||||
if (candidate.length <= maxChars || !current) {
|
||||
if (!current || estimateTextWidth(candidate, fontSize) <= maxWidth) {
|
||||
current = candidate;
|
||||
continue;
|
||||
}
|
||||
@@ -111,10 +172,16 @@ export function wrapText(text, maxChars, maxLines) {
|
||||
|
||||
if (lines.length > maxLines) {
|
||||
const kept = lines.slice(0, maxLines);
|
||||
const last = kept[maxLines - 1];
|
||||
let last = kept[maxLines - 1];
|
||||
|
||||
kept[maxLines - 1] =
|
||||
`${last.slice(0, Math.max(1, maxChars - 1)).trimEnd()}…`;
|
||||
while (
|
||||
last.length > 1 &&
|
||||
estimateTextWidth(`${last}…`, fontSize) > maxWidth
|
||||
) {
|
||||
last = last.slice(0, -1);
|
||||
}
|
||||
|
||||
kept[maxLines - 1] = `${last.trimEnd()}…`;
|
||||
|
||||
return kept;
|
||||
}
|
||||
@@ -249,12 +316,21 @@ function brandHeader(version) {
|
||||
].join('');
|
||||
}
|
||||
|
||||
function textLines(lines, { x, y, size, weight, fill, lineHeight }) {
|
||||
/**
|
||||
* `maxWidth` is a hard backstop, not the wrap budget: the wrap already fits
|
||||
* every line by estimate, and this clamps anything the estimate got wrong so
|
||||
* a mis-measured glyph compresses instead of running off the canvas.
|
||||
*/
|
||||
function textLines(lines, { x, y, size, weight, fill, lineHeight, maxWidth }) {
|
||||
return lines
|
||||
.map(
|
||||
(line, index) =>
|
||||
`<text x="${x}" y="${y + index * lineHeight}" font-family="${FONT_STACK}" font-size="${size}" font-weight="${weight}" fill="${fill}">${escapeXml(line)}</text>`
|
||||
)
|
||||
.map((line, index) => {
|
||||
const clamp =
|
||||
maxWidth && estimateTextWidth(line, size) > maxWidth
|
||||
? ` textLength="${maxWidth}" lengthAdjust="spacingAndGlyphs"`
|
||||
: '';
|
||||
|
||||
return `<text x="${x}" y="${y + index * lineHeight}" font-family="${FONT_STACK}" font-size="${size}" font-weight="${weight}" fill="${fill}"${clamp}>${escapeXml(line)}</text>`;
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
|
||||
@@ -277,7 +353,11 @@ export function buildFeatureCardSvg(job, version) {
|
||||
];
|
||||
|
||||
if (job.screenshotPath) {
|
||||
const headline = wrapText(job.headline, 34, 2);
|
||||
const headline = wrapText(job.headline, {
|
||||
maxWidth: TEXT_MAX_WIDTH,
|
||||
fontSize: 52,
|
||||
maxLines: 2,
|
||||
});
|
||||
// The body's line budget is derived from the space actually left above
|
||||
// the screenshot frame, never assumed: the frame is opaque and painted
|
||||
// after the text, so a fixed line count silently sliced the last line
|
||||
@@ -291,26 +371,32 @@ export function buildFeatureCardSvg(job, version) {
|
||||
Math.floor((TEXT_BOTTOM - bodyTop) / BODY_LINE_HEIGHT) + 1
|
||||
)
|
||||
);
|
||||
const body = wrapText(job.body, 88, bodyLines);
|
||||
const body = wrapText(job.body, {
|
||||
maxWidth: TEXT_MAX_WIDTH,
|
||||
fontSize: 23,
|
||||
maxLines: bodyLines,
|
||||
});
|
||||
|
||||
parts.push(
|
||||
textLines(headline, {
|
||||
x: 64,
|
||||
x: TEXT_LEFT,
|
||||
y: HEADLINE_TOP,
|
||||
size: 52,
|
||||
weight: 800,
|
||||
fill: BRAND.text,
|
||||
lineHeight: HEADLINE_LINE_HEIGHT,
|
||||
maxWidth: TEXT_MAX_WIDTH,
|
||||
})
|
||||
);
|
||||
parts.push(
|
||||
textLines(body, {
|
||||
x: 64,
|
||||
x: TEXT_LEFT,
|
||||
y: bodyTop,
|
||||
size: 23,
|
||||
weight: 400,
|
||||
fill: BRAND.muted,
|
||||
lineHeight: BODY_LINE_HEIGHT,
|
||||
maxWidth: TEXT_MAX_WIDTH,
|
||||
})
|
||||
);
|
||||
// Frame stroke sits behind the composited screenshot; the strip is
|
||||
@@ -319,31 +405,41 @@ export function buildFeatureCardSvg(job, version) {
|
||||
`<rect x="${SHOT_LEFT - 2}" y="${SHOT_TOP - 2}" width="${SHOT_WIDTH + 4}" height="${CARD_HEIGHT - SHOT_TOP + 4}" rx="${SHOT_RADIUS + 2}" fill="${BRAND.frame}"/>`
|
||||
);
|
||||
} else {
|
||||
const headline = wrapText(job.headline, 30, 2);
|
||||
const body = wrapText(job.body, 74, 3);
|
||||
const headline = wrapText(job.headline, {
|
||||
maxWidth: TEXT_MAX_WIDTH,
|
||||
fontSize: 62,
|
||||
maxLines: 2,
|
||||
});
|
||||
const body = wrapText(job.body, {
|
||||
maxWidth: TEXT_MAX_WIDTH,
|
||||
fontSize: 26,
|
||||
maxLines: 3,
|
||||
});
|
||||
const headlineY = 250;
|
||||
|
||||
parts.push(
|
||||
textLines(headline, {
|
||||
x: 64,
|
||||
x: TEXT_LEFT,
|
||||
y: headlineY,
|
||||
size: 62,
|
||||
weight: 800,
|
||||
fill: BRAND.text,
|
||||
lineHeight: 74,
|
||||
maxWidth: TEXT_MAX_WIDTH,
|
||||
})
|
||||
);
|
||||
parts.push(
|
||||
`<rect x="64" y="${headlineY + headline.length * 74 - 44}" width="120" height="6" rx="3" fill="${BRAND.warm}"/>`
|
||||
`<rect x="${TEXT_LEFT}" y="${headlineY + headline.length * 74 - 44}" width="120" height="6" rx="3" fill="${BRAND.warm}"/>`
|
||||
);
|
||||
parts.push(
|
||||
textLines(body, {
|
||||
x: 64,
|
||||
x: TEXT_LEFT,
|
||||
y: headlineY + headline.length * 74 + 8,
|
||||
size: 26,
|
||||
weight: 400,
|
||||
fill: BRAND.muted,
|
||||
lineHeight: 38,
|
||||
maxWidth: TEXT_MAX_WIDTH,
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -380,10 +476,22 @@ export function buildHeroCardSvg(hero) {
|
||||
);
|
||||
// wrapText yields no lines for whitespace-only input; validation
|
||||
// rejects that upstream, but a card run must not die half-written.
|
||||
const [line = ''] = wrapText(headline, 60, 1);
|
||||
const [line = ''] = wrapText(headline, {
|
||||
maxWidth: HERO_BULLET_MAX_WIDTH,
|
||||
fontSize: 30,
|
||||
maxLines: 1,
|
||||
});
|
||||
|
||||
parts.push(
|
||||
`<text x="100" y="${y}" font-family="${FONT_STACK}" font-size="30" font-weight="600" fill="${BRAND.text}">${escapeXml(line)}</text>`
|
||||
textLines([line], {
|
||||
x: HERO_BULLET_LEFT,
|
||||
y,
|
||||
size: 30,
|
||||
weight: 600,
|
||||
fill: BRAND.text,
|
||||
lineHeight: 0,
|
||||
maxWidth: HERO_BULLET_MAX_WIDTH,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -20,10 +20,13 @@ import {
|
||||
CARD_HEIGHT,
|
||||
CARD_WIDTH,
|
||||
escapeXml,
|
||||
estimateTextWidth,
|
||||
HERO_BULLET_MAX_WIDTH,
|
||||
isOwnedCardFile,
|
||||
planHighlightCards,
|
||||
SHOT_TOP,
|
||||
TEXT_BOTTOM,
|
||||
TEXT_MAX_WIDTH,
|
||||
wrapText,
|
||||
} from './highlight-cards.mjs';
|
||||
import {
|
||||
@@ -70,46 +73,71 @@ after(() => {
|
||||
});
|
||||
|
||||
describe('wrapText', () => {
|
||||
it('wraps on word boundaries within the budget', () => {
|
||||
assert.deepEqual(wrapText('one two three four', 9, 3), [
|
||||
const budget = (maxWidth, fontSize, maxLines) => ({
|
||||
maxWidth,
|
||||
fontSize,
|
||||
maxLines,
|
||||
});
|
||||
|
||||
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',
|
||||
]);
|
||||
});
|
||||
|
||||
it('breaks a word longer than the budget instead of overflowing the card', () => {
|
||||
assert.deepEqual(wrapText('supercalifragilistic ok', 10, 3), [
|
||||
'supercalif',
|
||||
'ragilistic',
|
||||
'ok',
|
||||
]);
|
||||
it('breaks a word wider than the budget instead of overflowing the card', () => {
|
||||
const lines = wrapText('supercalifragilistic ok', budget(60, 10, 4));
|
||||
|
||||
assert.ok(lines.length > 1);
|
||||
assert.equal(lines.join('').replace(/ /g, ''), 'supercalifragilisticok');
|
||||
});
|
||||
|
||||
it('never returns a line wider than the budget', () => {
|
||||
// A 60-character unbroken highlight is valid input; on a 34-character
|
||||
// feature-card budget it must still fit inside the canvas.
|
||||
for (const [text, maxChars, maxLines] of [
|
||||
['W'.repeat(60), 34, 2],
|
||||
['W'.repeat(60), 30, 2],
|
||||
['W'.repeat(60), 60, 1],
|
||||
['a'.repeat(200), 34, 2],
|
||||
['short words only here', 34, 2],
|
||||
it('keeps every line inside the budget, wide glyphs included', () => {
|
||||
// Counting characters is not a width budget: 34 `W` at font-size 52
|
||||
// measures ~1948px where only 1072px are available.
|
||||
for (const [text, maxWidth, fontSize, maxLines] of [
|
||||
['W'.repeat(60), TEXT_MAX_WIDTH, 52, 2],
|
||||
['W'.repeat(60), TEXT_MAX_WIDTH, 62, 2],
|
||||
['W'.repeat(60), HERO_BULLET_MAX_WIDTH, 30, 1],
|
||||
['M'.repeat(200), TEXT_MAX_WIDTH, 23, 3],
|
||||
['ЖЮ'.repeat(40), TEXT_MAX_WIDTH, 52, 2],
|
||||
['Advanced subtitles with external files', TEXT_MAX_WIDTH, 52, 2],
|
||||
]) {
|
||||
for (const line of wrapText(text, maxChars, maxLines)) {
|
||||
for (const line of wrapText(
|
||||
text,
|
||||
budget(maxWidth, fontSize, maxLines)
|
||||
)) {
|
||||
const width = estimateTextWidth(line, fontSize);
|
||||
|
||||
assert.ok(
|
||||
line.length <= maxChars,
|
||||
`"${line}" is ${line.length} > ${maxChars}`
|
||||
width <= maxWidth,
|
||||
`"${line}" estimates ${Math.round(width)}px > ${maxWidth}px`
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
assert.ok(estimate > 1800, `estimate ${estimate} is too low`);
|
||||
});
|
||||
|
||||
it('ellipsizes the last kept line on overflow', () => {
|
||||
const lines = wrapText('aaa bbb ccc ddd eee', 3, 2);
|
||||
const lines = wrapText('aaa bbb ccc ddd eee', budget(30, 10, 2));
|
||||
|
||||
assert.equal(lines.length, 2);
|
||||
assert.match(lines[1], /…$/);
|
||||
assert.ok(estimateTextWidth(lines[1], 10) <= 30);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user