diff --git a/src/cards/gist.js b/src/cards/gist.js index 62910420ad2f5..5c3ec53d77fa8 100644 --- a/src/cards/gist.js +++ b/src/cards/gist.js @@ -66,10 +66,16 @@ const renderGistCard = (gistData, options = {}) => { theme, }); - const lineWidth = 59; + const lineWidth = 360; // px — card width (400) - x offset (25) - right margin (15) + const fontSize = 13; // px — must match the .description font-size const linesLimit = 10; const desc = parseEmojis(description || "No description provided"); - const multiLineDescription = wrapTextMultiline(desc, lineWidth, linesLimit); + const multiLineDescription = wrapTextMultiline( + desc, + lineWidth, + linesLimit, + fontSize, + ); const descriptionLines = multiLineDescription.length; const descriptionSvg = multiLineDescription .map((line) => `${encodeHTML(line)}`) diff --git a/src/cards/repo.js b/src/cards/repo.js index a9c2afc38a222..4c9b900e86858 100644 --- a/src/cards/repo.js +++ b/src/cards/repo.js @@ -16,7 +16,8 @@ import { import { repoCardLocales } from "../translations.js"; const ICON_SIZE = 16; -const DESCRIPTION_LINE_WIDTH = 59; +const DESCRIPTION_LINE_WIDTH = 360; // px — card width (400) - x offset (25) - right margin (15) +const DESCRIPTION_FONT_SIZE = 13; // px — must match the .description font-size const DESCRIPTION_MAX_LINES = 3; /** @@ -91,6 +92,7 @@ const renderRepoCard = (repo, options = {}) => { desc, DESCRIPTION_LINE_WIDTH, descriptionMaxLines, + DESCRIPTION_FONT_SIZE, ); const descriptionLinesCount = description_lines_count ? clampValue(description_lines_count, 1, DESCRIPTION_MAX_LINES) diff --git a/src/common/fmt.js b/src/common/fmt.js index 5820b53e3f078..7ba9a7877ace6 100644 --- a/src/common/fmt.js +++ b/src/common/fmt.js @@ -2,6 +2,7 @@ import wrap from "word-wrap"; import { encodeHTML } from "./html.js"; +import { measureText } from "./render.js"; /** * Retrieves num with suffix k(thousands) precise to given decimal places. @@ -55,12 +56,17 @@ const formatBytes = (bytes) => { /** * Split text over multiple lines based on the card width. * + * When `fontSize` is provided, `width` is treated as a pixel budget and lines + * are broken using {@link measureText} for font-proportional accuracy. + * Otherwise `width` is a character count and the `word-wrap` library is used. + * * @param {string} text Text to split. - * @param {number} width Line width in number of characters. + * @param {number} width Line width — pixels when `fontSize` is set, characters otherwise. * @param {number} maxLines Maximum number of lines. + * @param {number} [fontSize] Font size in px. Enables pixel-width wrapping. * @returns {string[]} Array of lines. */ -const wrapTextMultiline = (text, width = 59, maxLines = 3) => { +const wrapTextMultiline = (text, width = 59, maxLines = 3, fontSize) => { const fullWidthComma = ","; const encoded = encodeHTML(text); const isChinese = encoded.includes(fullWidthComma); @@ -69,6 +75,24 @@ const wrapTextMultiline = (text, width = 59, maxLines = 3) => { if (isChinese) { wrapped = encoded.split(fullWidthComma); // Chinese full punctuation + } else if (fontSize) { + // Pixel-width wrapping: measure raw text so HTML entities (e.g. — + // for —) are counted as single rendered glyphs, not multiple characters. + const words = text.split(/\s+/).filter(Boolean); + let currentLine = ""; + + for (const word of words) { + const testLine = currentLine ? `${currentLine} ${word}` : word; + if (measureText(testLine, fontSize) > width && currentLine) { + wrapped.push(encodeHTML(currentLine)); + currentLine = word; + } else { + currentLine = testLine; + } + } + if (currentLine) { + wrapped.push(encodeHTML(currentLine)); + } } else { wrapped = wrap(encoded, { width, diff --git a/src/common/render.js b/src/common/render.js index 594abaa549679..4e9f3a5c9a04a 100644 --- a/src/common/render.js +++ b/src/common/render.js @@ -187,7 +187,7 @@ const renderError = ({ }; /** - * Retrieve text length. + * Retrieve text length based on Segoe UI font. * * @see https://stackoverflow.com/a/48172630/10629172 * @param {string} str String to measure. @@ -197,27 +197,10 @@ const renderError = ({ const measureText = (str, fontSize = 10) => { // prettier-ignore const widths = [ - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0.2796875, 0.2765625, - 0.3546875, 0.5546875, 0.5546875, 0.8890625, 0.665625, 0.190625, - 0.3328125, 0.3328125, 0.3890625, 0.5828125, 0.2765625, 0.3328125, - 0.2765625, 0.3015625, 0.5546875, 0.5546875, 0.5546875, 0.5546875, - 0.5546875, 0.5546875, 0.5546875, 0.5546875, 0.5546875, 0.5546875, - 0.2765625, 0.2765625, 0.584375, 0.5828125, 0.584375, 0.5546875, - 1.0140625, 0.665625, 0.665625, 0.721875, 0.721875, 0.665625, - 0.609375, 0.7765625, 0.721875, 0.2765625, 0.5, 0.665625, - 0.5546875, 0.8328125, 0.721875, 0.7765625, 0.665625, 0.7765625, - 0.721875, 0.665625, 0.609375, 0.721875, 0.665625, 0.94375, - 0.665625, 0.665625, 0.609375, 0.2765625, 0.3546875, 0.2765625, - 0.4765625, 0.5546875, 0.3328125, 0.5546875, 0.5546875, 0.5, - 0.5546875, 0.5546875, 0.2765625, 0.5546875, 0.5546875, 0.221875, - 0.240625, 0.5, 0.221875, 0.8328125, 0.5546875, 0.5546875, - 0.5546875, 0.5546875, 0.3328125, 0.5, 0.2765625, 0.5546875, - 0.5, 0.721875, 0.5, 0.5, 0.5, 0.3546875, 0.259375, 0.353125, 0.5890625, + 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.2733333110809326,0.28499999046325686,0.39166667461395266,0.5900000095367431,0.5383333206176758,0.8183333396911621,0.8,0.22999999523162842,0.30166666507720946,0.30166666507720946,0.41666665077209475,0.6833333492279052,0.21666667461395264,0.4,0.21666667461395264,0.3900000095367432,0.5383333206176758,0.5383333206176758,0.5383333206176758,0.5383333206176758,0.5383333206176758,0.5383333206176758,0.5383333206176758,0.5383333206176758,0.5383333206176758,0.5383333206176758,0.21666667461395264,0.21666667461395264,0.6833333492279052,0.6833333492279052,0.6833333492279052,0.4483333110809326,0.9550000190734863,0.6449999809265137,0.5733333110809327,0.6183333396911621,0.7016666889190674,0.5066666603088379,0.48833332061767576,0.6866666793823242,0.7099999904632568,0.26666667461395266,0.35666666030883787,0.5800000190734863,0.4699999809265137,0.8983333587646485,0.7483333110809326,0.753333330154419,0.5599999904632569,0.753333330154419,0.5983333110809326,0.5316666603088379,0.5233333110809326,0.6866666793823242,0.621666669845581,0.9333333015441895,0.5900000095367431,0.553333330154419,0.5699999809265137,0.30166666507720946,0.37833333015441895,0.30166666507720946,0.6833333492279052,0.41500000953674315,0.26833333969116213,0.5083333492279053,0.5883333206176757,0.4616666793823242,0.5883333206176757,0.5233333110809326,0.3133333444595337,0.5883333206176757,0.5666666507720948,0.24166667461395264,0.24166667461395264,0.49666666984558105,0.24166667461395264,0.8616666793823242,0.5666666507720948,0.5866666793823242,0.5883333206176757,0.5883333206176757,0.3483333349227905,0.425,0.33833334445953367,0.5666666507720948,0.4783333301544189,0.7233333110809326,0.45833334922790525,0.4833333492279053,0.45166668891906736,0.30166666507720946,0.24000000953674316,0.30166666507720946,0.6833333492279052 ]; - const avg = 0.5279276315789471; + const avg = 0.5131403493881227; return ( str .split("") diff --git a/tests/renderGistCard.test.js b/tests/renderGistCard.test.js index c397281a561bc..7aa85d0d97431 100644 --- a/tests/renderGistCard.test.js +++ b/tests/renderGistCard.test.js @@ -66,7 +66,7 @@ describe("test renderGistCard", () => { expect( document.getElementsByClassName("description")[0].children[1].textContent, - ).toBe("English-language pangram—a sentence that contains all"); + ).toBe("English-language pangram—a sentence that contains all of the"); }); it("should not trim description if it is short", () => { diff --git a/tests/renderRepoCard.test.js b/tests/renderRepoCard.test.js index 9fb5ab36c90de..745e3ec87623d 100644 --- a/tests/renderRepoCard.test.js +++ b/tests/renderRepoCard.test.js @@ -75,7 +75,7 @@ describe("Test renderRepoCard", () => { expect( document.getElementsByClassName("description")[0].children[1].textContent, - ).toBe("English-language pangram—a sentence that contains all"); + ).toBe("English-language pangram—a sentence that contains all of the"); // Should not trim document.body.innerHTML = renderRepoCard({ diff --git a/tests/renderStatsCard.test.js b/tests/renderStatsCard.test.js index cd84a3f0f1e2a..e465272f7425c 100644 --- a/tests/renderStatsCard.test.js +++ b/tests/renderStatsCard.test.js @@ -145,7 +145,7 @@ describe("Test renderStatsCard", () => { }); expect(document.querySelector("svg")).toHaveAttribute( "width", - "305.81250000000006", + "299.9666657447815", ); // Test minimum card width with rank and icons. @@ -156,7 +156,7 @@ describe("Test renderStatsCard", () => { }); expect(document.querySelector("svg")).toHaveAttribute( "width", - "322.81250000000006", + "316.9666657447815", ); // Test minimum card width with icons but without rank. @@ -356,7 +356,7 @@ describe("Test renderStatsCard", () => { expect( document.body.getElementsByTagName("svg")[0].getAttribute("width"), - ).toBe("305.81250000000006"); + ).toBe("299.9666657447815"); }); it("should auto resize if hide_rank is true & custom_title is set", () => {