Skip to content

Commit 0c6113b

Browse files
committed
feat: replace render-time text wrapping with browser-native wrapping
Use `foreignObject` with CSS line-clamp to let the browser handle text wrapping natively instead of manually wrapping on the server. This provides better font-aware wrapping while keeping server-side line estimation for SVG height calculation. Signed-off-by: Takuma IMAMURA <209989118+hyperfinitism@users.noreply.github.com>
1 parent b863992 commit 0c6113b

5 files changed

Lines changed: 231 additions & 64 deletions

File tree

packages/core/src/cards/gist.js

Lines changed: 32 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,28 @@
22

33
import { default as Card } from "../common/Card.js";
44
import { getCardColors } from "../common/color.js";
5-
import { kFormatter, wrapTextMultiline } from "../common/fmt.js";
6-
import { encodeHTML } from "../common/html.js";
5+
import { kFormatter } from "../common/fmt.js";
76
import { icons } from "../common/icons.js";
87
import languageColors from "../common/languageColors.json" with { type: "json" };
98
import { parseEmojis } from "../common/ops.js";
109
import {
10+
countWrappedLines,
1111
createLanguageNode,
1212
flexLayout,
1313
iconWithLabel,
1414
measureText,
15+
wrappedTextNode,
16+
wrappedTextStyles,
1517
} from "../common/render.js";
1618

1719
const ICON_SIZE = 16;
1820
const CARD_DEFAULT_WIDTH = 400;
1921
const X_OFFSET = 25;
2022
const HEADER_MAX_LENGTH = 35;
23+
const DESCRIPTION_BOX_WIDTH = CARD_DEFAULT_WIDTH - 2 * X_OFFSET;
24+
const DESCRIPTION_FONT_SIZE = 13;
25+
const DESCRIPTION_LINE_HEIGHT_PX = 16;
26+
const DESCRIPTION_MAX_LINES = 10;
2127

2228
/**
2329
* @typedef {import('./types').GistCardOptions} GistCardOptions Gist card options.
@@ -57,16 +63,28 @@ const renderGistCard = (gistData, options = {}) => {
5763
theme,
5864
});
5965

60-
const lineWidth = 59;
61-
const linesLimit = 10;
6266
const desc = parseEmojis(description || "No description provided");
63-
const multiLineDescription = wrapTextMultiline(desc, lineWidth, linesLimit);
64-
const descriptionLines = multiLineDescription.length;
65-
const descriptionSvg = multiLineDescription
66-
.map(
67-
(line) => `<tspan dy="1.2em" x="${X_OFFSET}">${encodeHTML(line)}</tspan>`,
68-
)
69-
.join("");
67+
// The browser performs the actual text wrapping inside the foreignObject;
68+
// we only estimate the line count server-side so the SVG can reserve enough
69+
// height. The estimate uses measureText for font-aware widths instead of a
70+
// fixed character count.
71+
const descriptionLines = countWrappedLines(
72+
desc,
73+
DESCRIPTION_FONT_SIZE,
74+
DESCRIPTION_BOX_WIDTH,
75+
DESCRIPTION_MAX_LINES,
76+
);
77+
78+
const descriptionSvg = wrappedTextNode({
79+
text: desc,
80+
x: X_OFFSET,
81+
y: 0,
82+
width: DESCRIPTION_BOX_WIDTH,
83+
height: descriptionLines * DESCRIPTION_LINE_HEIGHT_PX,
84+
lineCount: descriptionLines,
85+
className: "description",
86+
testId: "description-text",
87+
});
7088

7189
const lineHeight = descriptionLines > 3 ? 12 : 10;
7290
const height =
@@ -124,16 +142,15 @@ const renderGistCard = (gistData, options = {}) => {
124142
});
125143

126144
card.setCSS(`
127-
.description { font: 400 13px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${textColor} }
145+
.description {
146+
font: 400 ${DESCRIPTION_FONT_SIZE}px 'Segoe UI', Ubuntu, Sans-Serif;${wrappedTextStyles(textColor)} }
128147
.gray { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${textColor} }
129148
.icon { fill: ${iconColor} }
130149
`);
131150
card.setHideBorder(hide_border);
132151

133152
return card.render(`
134-
<text class="description" x="${X_OFFSET}" y="-5">
135-
${descriptionSvg}
136-
</text>
153+
${descriptionSvg}
137154
138155
<g transform="translate(30, ${height - 75})">
139156
${starAndForkCount}

packages/core/src/cards/repo.js

Lines changed: 31 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -3,24 +3,27 @@
33
import { Card } from "../common/Card.js";
44
import { I18n } from "../common/I18n.js";
55
import { getCardColors } from "../common/color.js";
6-
import { kFormatter, wrapTextMultiline } from "../common/fmt.js";
7-
import { encodeHTML } from "../common/html.js";
6+
import { kFormatter } from "../common/fmt.js";
87
import { icons } from "../common/icons.js";
98
import { buildSearchFilter, clampValue, parseEmojis } from "../common/ops.js";
109
import {
10+
countWrappedLines,
1111
createLanguageNode,
1212
flexLayout,
1313
iconWithLabel,
1414
measureText,
15+
wrappedTextNode,
16+
wrappedTextStyles,
1517
} from "../common/render.js";
1618
import { repoCardLocales } from "../translations.js";
1719

1820
import { createTextNode } from "./stats.js";
1921

2022
const ICON_SIZE = 16;
21-
const DESCRIPTION_LINE_WIDTH = 59;
2223
const CARD_DEFAULT_WIDTH = 400;
2324
const X_OFFSET = 25;
25+
const DESCRIPTION_FONT_SIZE = 13;
26+
const DESCRIPTION_LINE_HEIGHT_PX = 16;
2427
const DESCRIPTION_MAX_LINES = 3;
2528

2629
/**
@@ -177,27 +180,32 @@ const renderRepoCard = (repo, options = {}) => {
177180
const header = show_owner ? nameWithOwner : name;
178181
const langName = (primaryLanguage && primaryLanguage.name) || "Unspecified";
179182
const langColor = (primaryLanguage && primaryLanguage.color) || "#333";
180-
const descriptionMaxLines = description_lines_count
181-
? clampValue(description_lines_count, 1, DESCRIPTION_MAX_LINES)
182-
: DESCRIPTION_MAX_LINES;
183183

184+
const descriptionBoxWidth = card_width - 2 * X_OFFSET;
184185
const desc = parseEmojis(description || "No description provided");
185-
const multiLineDescription = wrapTextMultiline(
186-
desc,
187-
Math.round(
188-
(card_width - CARD_DEFAULT_WIDTH) / 5.93 + DESCRIPTION_LINE_WIDTH,
189-
),
190-
descriptionMaxLines,
191-
);
186+
// The browser performs the actual text wrapping inside the foreignObject;
187+
// we only estimate the line count server-side so the SVG can reserve enough
188+
// height. The estimate uses measureText for font-aware widths instead of a
189+
// fixed character count.
192190
const descriptionLinesCount = description_lines_count
193191
? clampValue(description_lines_count, 1, DESCRIPTION_MAX_LINES)
194-
: multiLineDescription.length;
192+
: countWrappedLines(
193+
desc,
194+
DESCRIPTION_FONT_SIZE,
195+
descriptionBoxWidth,
196+
DESCRIPTION_MAX_LINES,
197+
);
195198

196-
const descriptionSvg = multiLineDescription
197-
.map(
198-
(line) => `<tspan dy="1.2em" x="${X_OFFSET}">${encodeHTML(line)}</tspan>`,
199-
)
200-
.join("");
199+
const descriptionSvg = wrappedTextNode({
200+
text: desc,
201+
x: X_OFFSET,
202+
y: 0,
203+
width: descriptionBoxWidth,
204+
height: descriptionLinesCount * DESCRIPTION_LINE_HEIGHT_PX,
205+
lineCount: descriptionLinesCount,
206+
className: "description",
207+
testId: "description-text",
208+
});
201209

202210
const extraHeight = Object.keys(STATS).length
203211
? -7 + (Math.ceil(statItems.length / 2) + 1) * extraLHeight
@@ -279,11 +287,12 @@ const renderRepoCard = (repo, options = {}) => {
279287
card.setHideBorder(hide_border);
280288
card.setHideTitle(false);
281289
card.setCSS(`
282-
.description { font: 400 13px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${colors.textColor} }
290+
.description {
291+
font: 400 ${DESCRIPTION_FONT_SIZE}px 'Segoe UI', Ubuntu, Sans-Serif;${wrappedTextStyles(colors.textColor)} }
283292
.gray { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${colors.textColor} }
284293
.badge { font: 600 11px 'Segoe UI', Ubuntu, Sans-Serif; }
285294
.badge rect { opacity: 0.2 }
286-
295+
287296
.stat { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${colors.textColor} }
288297
.stagger {
289298
opacity: 0;
@@ -316,9 +325,7 @@ const renderRepoCard = (repo, options = {}) => {
316325
: ""
317326
}
318327
319-
<text class="description" x="${X_OFFSET}" y="-5">
320-
${descriptionSvg}
321-
</text>
328+
${descriptionSvg}
322329
323330
<g transform="translate(30, ${height - 75 - extraHeight})">
324331
${starAndForkCount}

packages/core/src/common/render.js

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,65 @@ const createProgressNode = ({
8686
`;
8787
};
8888

89+
/**
90+
* Renders multi-line text via a `foreignObject` so the browser performs
91+
* native, font-aware wrapping. Content overflowing `lineCount` lines is
92+
* clipped (with an ellipsis on the last visible line) by CSS line-clamp.
93+
*
94+
* @param {object} props Function properties.
95+
* @param {string} props.text Text to render (will be HTML-encoded).
96+
* @param {number} props.x X position of the foreignObject.
97+
* @param {number} props.y Y position of the foreignObject.
98+
* @param {number} props.width Width of the wrap box.
99+
* @param {number} props.height Height of the wrap box.
100+
* @param {number} props.lineCount Maximum number of lines to display.
101+
* @param {string} props.className CSS class applied to the inner element.
102+
* @param {string=} props.testId Optional test id for the inner element.
103+
* @returns {string} foreignObject SVG node.
104+
*/
105+
const wrappedTextNode = ({
106+
text,
107+
x,
108+
y,
109+
width,
110+
height,
111+
lineCount,
112+
className,
113+
testId,
114+
}) => {
115+
const testIdAttr = testId ? ` data-testid="${testId}"` : "";
116+
return `
117+
<foreignObject x="${x}" y="${y}" width="${width}" height="${height}">
118+
<div xmlns="http://www.w3.org/1999/xhtml" class="${className}" style="--lines: ${lineCount};"${testIdAttr}>${encodeHTML(
119+
text,
120+
)}</div>
121+
</foreignObject>
122+
`;
123+
};
124+
125+
/**
126+
* CSS rules used to render multi-line text inside a `foreignObject`. Apply this
127+
* to a CSS class (e.g. `.description`) shared with `wrappedTextNode` so the
128+
* browser handles wrapping and the line count is taken from the `--lines`
129+
* custom property set on the element.
130+
*
131+
* @param {string} color Text color (CSS `color` property).
132+
* @returns {string} CSS rules block (without the surrounding selector).
133+
*/
134+
const wrappedTextStyles = (color) => `
135+
color: ${color};
136+
margin: 0;
137+
line-height: 1.2;
138+
overflow-wrap: anywhere;
139+
word-break: break-word;
140+
display: -webkit-box;
141+
-webkit-box-orient: vertical;
142+
-webkit-line-clamp: var(--lines);
143+
line-clamp: var(--lines);
144+
overflow: hidden;
145+
text-overflow: ellipsis;
146+
`;
147+
89148
/**
90149
* Creates an icon with label to display repository/gist stats like forks, stars, etc.
91150
*
@@ -228,11 +287,92 @@ const measureText = (str, fontSize = 10) => {
228287
);
229288
};
230289

290+
/**
291+
* Estimate how many lines a string will wrap to when laid out greedily at the
292+
* given font size inside a box of width `maxWidth`. Uses `measureText` so the
293+
* estimate reflects actual font metrics rather than a fixed character count.
294+
* The browser still does the real wrap inside the foreignObject; this is only
295+
* used to size the SVG.
296+
*
297+
* @param {string} text Text to estimate.
298+
* @param {number} fontSize Font size in px (matches `measureText`).
299+
* @param {number} maxWidth Available wrap width in px.
300+
* @param {number} maxLines Cap on the returned line count.
301+
* @returns {number} Estimated line count, at least 1, at most `maxLines`.
302+
*/
303+
const countWrappedLines = (text, fontSize, maxWidth, maxLines) => {
304+
if (!text) {
305+
return 1;
306+
}
307+
308+
// Match wrapTextMultiline's Chinese heuristic: full-width comma is the
309+
// expected break point, so the line count tracks the punctuation count.
310+
const fullWidthComma = ",";
311+
if (text.includes(fullWidthComma)) {
312+
return clampValue(text.split(fullWidthComma).length, 1, maxLines);
313+
}
314+
315+
// Tokenize into alternating runs of non-whitespace (words) and whitespace.
316+
const tokens = text.match(/\S+|\s+/g);
317+
if (!tokens) {
318+
return 1;
319+
}
320+
321+
const whitespaceWidth = (run) => {
322+
const collapsed = run.replace(/[\t\n\r ]+/g, " ");
323+
let width = 0;
324+
for (const ch of collapsed) {
325+
width += ch === " " ? fontSize : measureText(ch, fontSize);
326+
}
327+
return width;
328+
};
329+
330+
let lines = 1;
331+
let currentWidth = 0;
332+
333+
for (const token of tokens) {
334+
if (/^\s+$/.test(token)) {
335+
// Whitespace at the start of a line is dropped by browsers.
336+
if (currentWidth === 0) {
337+
continue;
338+
}
339+
currentWidth += whitespaceWidth(token);
340+
continue;
341+
}
342+
343+
const wordWidth = measureText(token, fontSize);
344+
345+
if (currentWidth === 0) {
346+
currentWidth = wordWidth;
347+
} else if (currentWidth + wordWidth <= maxWidth) {
348+
currentWidth += wordWidth;
349+
} else {
350+
lines += 1;
351+
currentWidth = wordWidth;
352+
}
353+
354+
// A single word wider than the box wraps mid-word (overflow-wrap: anywhere).
355+
while (currentWidth > maxWidth) {
356+
lines += 1;
357+
currentWidth -= maxWidth;
358+
}
359+
360+
if (lines >= maxLines) {
361+
return maxLines;
362+
}
363+
}
364+
365+
return Math.min(lines, maxLines);
366+
};
367+
231368
export {
232369
renderError,
233370
createLanguageNode,
234371
createProgressNode,
235372
iconWithLabel,
236373
flexLayout,
237374
measureText,
375+
countWrappedLines,
376+
wrappedTextNode,
377+
wrappedTextStyles,
238378
};

0 commit comments

Comments
 (0)