diff --git a/.github/workflows/test-package-lib.yml b/.github/workflows/test-package-lib.yml index ba8b1e03b5f32..adc2a9bf03a4d 100644 --- a/.github/workflows/test-package-lib.yml +++ b/.github/workflows/test-package-lib.yml @@ -34,5 +34,15 @@ jobs: env: NPM_CONFIG_ENGINE_STRICT: ${{ matrix.engine-strict }} + - name: Install fonts for text measurement + run: | + echo "ttf-mscorefonts-installer msttcorefonts/accepted-mscorefonts-eula select true" | sudo debconf-set-selections + sudo apt-get update + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + ttf-mscorefonts-installer + + - name: Download fonts + run: node scripts/download-fonts.js + - name: Package tests uses: ./.github/actions/package-tests diff --git a/badge-maker/fonts/.gitignore b/badge-maker/fonts/.gitignore new file mode 100644 index 0000000000000..af3c199a368c3 --- /dev/null +++ b/badge-maker/fonts/.gitignore @@ -0,0 +1,5 @@ +# Exclude all font files from version control. +# Verdana is proprietary (© Microsoft) and cannot be committed. +# All fonts are downloaded/copied by `node scripts/download-fonts.js`. +*.ttf +*.otf diff --git a/badge-maker/fonts/README.md b/badge-maker/fonts/README.md new file mode 100644 index 0000000000000..37803e9871abf --- /dev/null +++ b/badge-maker/fonts/README.md @@ -0,0 +1,41 @@ +# badge-maker/fonts/ + +Runtime fonts for badge text-width measurement. + +`@chenglou/pretext` measures text width through canvas text rendering, so the computed width depends on the fonts that are actually available at runtime. That means badge rendering is only reproducible if CI and local development use the same font files. + +## Setup + +Populate this directory before running badge-maker tests: + +```sh +node scripts/download-fonts.js +``` + +The script downloads free fonts from GitHub and copies Verdana from your system (see below). + +This setup is required for two reasons: + +1. CI machines may not have the fonts needed by `pretext` installed. +2. Developers should run `pretext` against the same fonts as CI so text-width calculation stays consistent across environments and matches the committed snapshots. + +## Fonts + +| File | Family | License | Notes | +| --- | --- | --- | --- | +| `DejaVuSans.ttf` | DejaVu Sans | [Bitstream Vera](https://dejavu-fonts.github.io/License.html) | Free to redistribute | +| `DejaVuSans-Bold.ttf` | DejaVu Sans | Bitstream Vera | Free to redistribute | +| `LiberationSans-Regular.ttf` | Liberation Sans | [SIL OFL 1.1](https://scripts.sil.org/OFL) | Free to redistribute; metric-compatible with Arial/Helvetica | +| `LiberationSans-Bold.ttf` | Liberation Sans | SIL OFL 1.1 | Free to redistribute | +| `Verdana.ttf` | Verdana | © Microsoft | **Proprietary — do NOT commit** | +| `Verdana_Bold.ttf` | Verdana | © Microsoft | **Proprietary — do NOT commit** | + +## .gitignore + +Verdana is excluded from version control (see `badge-maker/.gitignore`). The free fonts (DejaVu Sans, Liberation Sans) are also excluded since they are re-downloaded by `scripts/download-fonts.js` during CI setup. + +In other words, this directory is part of the runtime contract for text measurement: `pretext` needs these fonts to produce stable widths in both CI and local development. + +## Why Liberation Sans for Helvetica? + +The social badge style measures text with `bold 11px Helvetica`. Helvetica is not freely distributable and is not available on most Linux systems. Liberation Sans is metric-compatible with Arial and Helvetica as specified in the [Liberation Fonts README](https://github.com/liberationfonts/liberation-fonts); registering it under both family names in `canvas-polyfill.js` gives identical glyph widths on all platforms. diff --git a/badge-maker/lib/badge-renderers.js b/badge-maker/lib/badge-renderers.js index c5e1705a3bed9..0399f4b9b33b7 100644 --- a/badge-maker/lib/badge-renderers.js +++ b/badge-maker/lib/badge-renderers.js @@ -1,4 +1,5 @@ -import anafanafo from 'anafanafo' +import './canvas-polyfill.js' +import { prepareWithSegments, walkLineRanges } from '@chenglou/pretext' import { brightness } from './color.js' import { XmlElement, ElementList } from './xml.js' @@ -27,9 +28,18 @@ function roundUpToOdd(val) { return val % 2 === 0 ? val + 1 : val } +function measureTextWidth(str, font) { + const prepared = prepareWithSegments(str, font) + let width = 0 + walkLineRanges(prepared, Infinity, line => { + width = line.width + }) + return width +} + function preferredWidthOf(str, options) { // Increase chances of pixel grid alignment. - return roundUpToOdd(anafanafo(str, options) | 0) + return roundUpToOdd(measureTextWidth(str, options.font) | 0) } function createAccessibleText({ label, message }) { @@ -787,11 +797,11 @@ function forTheBadge({ // the discrepancy. Ideally, swapping out `textLength` for `letterSpacing` // should not affect the appearance. const labelTextWidth = label.length - ? (anafanafo(label, { font: `${FONT_SIZE}px Verdana` }) | 0) + + ? (measureTextWidth(label, `${FONT_SIZE}px Verdana`) | 0) + LETTER_SPACING * label.length : 0 const messageTextWidth = message.length - ? (anafanafo(message, { font: `bold ${FONT_SIZE}px Verdana` }) | 0) + + ? (measureTextWidth(message, `bold ${FONT_SIZE}px Verdana`) | 0) + LETTER_SPACING * message.length : 0 diff --git a/badge-maker/lib/canvas-polyfill.js b/badge-maker/lib/canvas-polyfill.js new file mode 100644 index 0000000000000..17fe5e2bff71b --- /dev/null +++ b/badge-maker/lib/canvas-polyfill.js @@ -0,0 +1,57 @@ +// Polyfill OffscreenCanvas for Node.js environments where it's not available. +// Required by @chenglou/pretext which uses canvas measureText for text width. +if (typeof globalThis.OffscreenCanvas === 'undefined') { + try { + const { createCanvas, GlobalFonts } = await import('@napi-rs/canvas') + const { existsSync } = await import('fs') + const { fileURLToPath } = await import('url') + const { join, dirname } = await import('path') + + // @napi-rs/canvas (Skia) does not automatically load system fonts on Linux. + // Fonts are bundled in badge-maker/fonts/ (run `node scripts/download-fonts.js` + // to populate the directory before running tests). + const fontsDir = join( + dirname(fileURLToPath(import.meta.url)), + '..', + 'fonts', + ) + + // [filename, familyNameOverride] + // familyNameOverride replaces the font's own internal family name in the + // Skia registry, so that CSS font strings like 'bold 11px Helvetica' resolve + // to the bundled metric-compatible substitute (Liberation Sans). + const fonts = [ + // Verdana — flat / plastic / for-the-badge measurement font + ['Verdana.ttf'], + ['Verdana_Bold.ttf'], + // Liberation Sans registered as Helvetica for social-badge measurement + ['LiberationSans-Regular.ttf', 'Helvetica'], + ['LiberationSans-Bold.ttf', 'Helvetica'], + // Liberation Sans also registered as Arial (font-family fallback in SVG) + ['LiberationSans-Regular.ttf', 'Arial'], + ['LiberationSans-Bold.ttf', 'Arial'], + // DejaVu Sans — fallback in badge font-family lists + ['DejaVuSans.ttf'], + ['DejaVuSans-Bold.ttf'], + ] + + for (const [filename, familyName] of fonts) { + const fontPath = join(fontsDir, filename) + if (existsSync(fontPath)) { + GlobalFonts.registerFromPath(fontPath, familyName) + } + } + + globalThis.OffscreenCanvas = class OffscreenCanvas { + constructor(width, height) { + this._canvas = createCanvas(width, height) + } + + getContext(type) { + return this._canvas.getContext(type) + } + } + } catch { + // @napi-rs/canvas not available; OffscreenCanvas must be provided by the runtime + } +} diff --git a/badge-maker/package.json b/badge-maker/package.json index 8f5aa3bb9fd0c..5049545a3ca1b 100644 --- a/badge-maker/package.json +++ b/badge-maker/package.json @@ -41,7 +41,8 @@ "logo": "https://opencollective.com/opencollective/logo.txt" }, "dependencies": { - "anafanafo": "2.0.0", + "@chenglou/pretext": "0.0.3", + "@napi-rs/canvas": "^0.1.97", "css-color-converter": "^2.0.0" }, "scripts": { diff --git a/compare.html b/compare.html new file mode 100644 index 0000000000000..ea8959b121c94 --- /dev/null +++ b/compare.html @@ -0,0 +1,1386 @@ + + + + + Badge Comparison: anafanafo vs pretext + + + +

Badge Comparison: anafanafo vs pretext

+ +
+

Why snapshots changed

+ +

+ This PR replaces anafanafo with + @chenglou/pretext for text width measurement in badge + generation. The two libraries measure text width slightly differently, + which causes some snapshot values to change. +

+ +

Summary of all snapshot changes

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CategoryCountSVG width diffCause
flat, flat-square, plastic templates00px + No change — pretext + + preferredWidthOf() produces identical results to + anafanafo for Verdana 11px +
for-the-badge template00px + No change — letter-spacing compensation already in place, + pretext matches anafanafo for Verdana 10px +
social template (with label text)All0px + No change — pretext text widths match anafanafo for + Helvetica bold 11px with the current social badge renderer +
social template (empty label)4-1px + Empty string: anafanafo returned width 10 (1px), pretext returns + 0. See details below. +
+ +

Root cause: empty string width

+ +

+ The only difference comes from the + empty string edge case. When measuring an empty label + (""): +

+ + + +

+ This 1px difference cascades into the SVG: the label rect shrinks by + 1px, and all downstream x-coordinates shift accordingly. +

+ +

Why the pretext behavior is correct

+ +

+ An empty string should have zero width. The old anafanafo value of 10 + (1px) was a quirk of how anafanafo handled the empty-string case. The + new pretext value of 0 produces a more accurate badge — there is + no visible difference since the label text is empty anyway. +

+ +

Affected snapshots (4 total)

+ +

+ The only differences are in the 4 snapshots with empty labels. These + show a 1px width reduction due to the empty string width change. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
SnapshotOld widthNew widthtextLength change
"social" message only, no logo59px58px10 → 0
"social" message only, with logo73px72px10 → 0
"social" message only, with logo and labelColor73px72px10 → 0
"social" logo-only26px25px10 → 0
+ +

Width alignment notes

+ +

+ pretext measures raw glyph widths. Only the + for-the-badge template still needs explicit letter-spacing + compensation to match the previous anafanafo measurements: +

+ + +
+ +
+ +

Visual comparisons: social template (with label text)

+ +

+ pretext produces identical text widths to anafanafo for non-empty social + badge labels. The badge appearance is the same. +

+ +

message/label, no logo

+ +
+
+ pretext (current) — width: 95px, textLength: Cactus=370, grown=330 + (matches anafanafo) +
+ + Cactus: grown + + + + + + + + + + + + +
+ +

message only, no logo (empty label edge case)

+ +
+
+ anafanafo (old) — width: 59px, label rect width: 11, label + textLength: 10 +
+ + grown + + + + + + + + + + + + +
+ +
+
+ pretext (current) — + width: 58px, label rect width: 10, label textLength: 0 (1px diff from + anafanafo) +
+ + grown + + + + + + + + + + + + +
+ +
+

+ Visual comparisons: empty label snapshots (diff > 1px in inner + attributes) +

+

+ These 4 snapshots are the only ones with any difference. The root cause is + that anafanafo("") returned 10 while + pretext correctly returns 0 for empty strings. This causes a + 1px SVG width reduction and shifts internal x-coordinates by up to 10 + units (1px at scale(.1)). +

+ +

"social" template: message only, no logo

+

+ SVG width: 59 → 58 (-1px) | textLength[0]: 10 → 0 | + textLength[1]: 10 → 0 +

+ +
+
+
+ anafanafo (old) — width: 59px, textLengths: 10, 10, 330, 330 +
+
+ + grown + + + + + + + + + + + + + + + + + +
+
+
+
+ pretext (current) — width: 58px, textLengths: 0, 0, 330, 330 +
+
+ + grown + + + + + + + + + + + + + + + + + +
+
+
+ +

"social" template: message only, with logo

+

+ SVG width: 73 → 72 (-1px) | textLength[0]: 10 → 0 | + textLength[1]: 10 → 0 +

+ +
+
+
+ anafanafo (old) — width: 73px, textLengths: 10, 10, 330, 330 +
+
+ + grown + + + + + + + + + + + + + + + + + + +
+
+
+
+ pretext (current) — width: 72px, textLengths: 0, 0, 330, 330 +
+
+ + grown + + + + + + + + + + + + + + + + + + +
+
+
+ +

"social" template: message only, with logo and labelColor

+

+ SVG width: 73 → 72 (-1px) | textLength[0]: 10 → 0 | + textLength[1]: 10 → 0 +

+ +
+
+
+ anafanafo (old) — width: 73px, textLengths: 10, 10, 330, 330 +
+
+ + grown + + + + + + + + + + + + + + + + + + +
+
+
+
+ pretext (current) — width: 72px, textLengths: 0, 0, 330, 330 +
+
+ + grown + + + + + + + + + + + + + + + + + + +
+
+
+ +

social badge, logo-only

+

+ SVG width: 26 → 25 (-1px) | textLength[0]: 10 → 0 | + textLength[1]: 10 → 0 +

+ +
+
+
+ anafanafo (old) — width: 26px, textLengths: 10, 10 +
+
+ + + + + + + + + + + + + + + + + +
+
+
+
+ pretext (current) — width: 25px, textLengths: 0, 0 +
+
+ + + + + + + + + + + + + + + + + +
+
+
+ + diff --git a/package-lock.json b/package-lock.json index 8efca27b70103..a373e2bb4dc0d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -60,6 +60,7 @@ "@docusaurus/preset-classic": "^3.9.2", "@easyops-cn/docusaurus-search-local": "^0.55.1", "@mdx-js/react": "^3.1.1", + "@napi-rs/canvas": "^0.1.97", "@typescript-eslint/parser": "^8.58.0", "c8": "^11.0.0", "caller": "^1.1.0", @@ -123,7 +124,8 @@ "version": "5.0.2", "license": "CC0-1.0", "dependencies": { - "anafanafo": "2.0.0", + "@chenglou/pretext": "0.0.3", + "@napi-rs/canvas": "^0.1.97", "css-color-converter": "^2.0.0" }, "bin": { @@ -2299,6 +2301,12 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/@chenglou/pretext": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@chenglou/pretext/-/pretext-0.0.3.tgz", + "integrity": "sha512-RQmqMqUAPRCyv4R3LlRi/ao6KbNWYclqLA+V1HS7sWgyUUbjn3JmmlfXZSY/BjM4rbmIaMSyIVisYocYGYftiQ==", + "license": "MIT" + }, "node_modules/@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", @@ -6198,6 +6206,270 @@ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/@napi-rs/canvas": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.97.tgz", + "integrity": "sha512-8cFniXvrIEnVwuNSRCW9wirRZbHvrD3JVujdS2P5n5xiJZNZMOZcfOvJ1pb66c7jXMKHHglJEDVJGbm8XWFcXQ==", + "license": "MIT", + "workspaces": [ + "e2e/*" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "0.1.97", + "@napi-rs/canvas-darwin-arm64": "0.1.97", + "@napi-rs/canvas-darwin-x64": "0.1.97", + "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.97", + "@napi-rs/canvas-linux-arm64-gnu": "0.1.97", + "@napi-rs/canvas-linux-arm64-musl": "0.1.97", + "@napi-rs/canvas-linux-riscv64-gnu": "0.1.97", + "@napi-rs/canvas-linux-x64-gnu": "0.1.97", + "@napi-rs/canvas-linux-x64-musl": "0.1.97", + "@napi-rs/canvas-win32-arm64-msvc": "0.1.97", + "@napi-rs/canvas-win32-x64-msvc": "0.1.97" + } + }, + "node_modules/@napi-rs/canvas-android-arm64": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.97.tgz", + "integrity": "sha512-V1c/WVw+NzH8vk7ZK/O8/nyBSCQimU8sfMsB/9qeSvdkGKNU7+mxy/bIF0gTgeBFmHpj30S4E9WHMSrxXGQuVQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.97.tgz", + "integrity": "sha512-ok+SCEF4YejcxuJ9Rm+WWunHHpf2HmiPxfz6z1a/NFQECGXtsY7A4B8XocK1LmT1D7P174MzwPF9Wy3AUAwEPw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-x64": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.97.tgz", + "integrity": "sha512-PUP6e6/UGlclUvAQNnuXCcnkpdUou6VYZfQOQxExLp86epOylmiwLkqXIvpFmjoTEDmPmXrI+coL/9EFU1gKPA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.97.tgz", + "integrity": "sha512-XyXH2L/cic8eTNtbrXCcvqHtMX/nEOxN18+7rMrAM2XtLYC/EB5s0wnO1FsLMWmK+04ZSLN9FBGipo7kpIkcOw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.97.tgz", + "integrity": "sha512-Kuq/M3djq0K8ktgz6nPlK7Ne5d4uWeDxPpyKWOjWDK2RIOhHVtLtyLiJw2fuldw7Vn4mhw05EZXCEr4Q76rs9w==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.97.tgz", + "integrity": "sha512-kKmSkQVnWeqg7qdsiXvYxKhAFuHz3tkBjW/zyQv5YKUPhotpaVhpBGv5LqCngzyuRV85SXoe+OFj+Tv0a0QXkQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.97.tgz", + "integrity": "sha512-Jc7I3A51jnEOIAXeLsN/M/+Z28LUeakcsXs07FLq9prXc0eYOtVwsDEv913Gr+06IRo34gJJVgT0TXvmz+N2VA==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.97.tgz", + "integrity": "sha512-iDUBe7AilfuBSRbSa8/IGX38Mf+iCSBqoVKLSQ5XaY2JLOaqz1TVyPFEyIck7wT6mRQhQt5sN6ogfjIDfi74tg==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.97.tgz", + "integrity": "sha512-AKLFd/v0Z5fvgqBDqhvqtAdx+fHMJ5t9JcUNKq4FIZ5WH+iegGm8HPdj00NFlCSnm83Fp3Ln8I2f7uq1aIiWaA==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-arm64-msvc": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.97.tgz", + "integrity": "sha512-u883Yr6A6fO7Vpsy9YE4FVCIxzzo5sO+7pIUjjoDLjS3vQaNMkVzx5bdIpEL+ob+gU88WDK4VcxYMZ6nmnoX9A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.97.tgz", + "integrity": "sha512-sWtD2EE3fV0IzN+iiQUqr/Q1SwqWhs2O1FKItFlxtdDkikpEj5g7DKQpY3x55H/MAOnL8iomnlk3mcEeGiUMoQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.1.1.tgz", @@ -9025,14 +9297,6 @@ "algoliasearch": ">= 3.1 < 6" } }, - "node_modules/anafanafo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/anafanafo/-/anafanafo-2.0.0.tgz", - "integrity": "sha512-Nlfq7NC4AOkTJerWRIZcOAiMNtIDVIGWGvQ98O7Jl6Kr2Dk0dX5u4MqN778kSRTy5KRqchpLdF2RtLFEz9FVkQ==", - "dependencies": { - "char-width-table-consumer": "^1.0.0" - } - }, "node_modules/ansi-align": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz", @@ -9755,11 +10019,6 @@ "node": ">=8" } }, - "node_modules/binary-search": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/binary-search/-/binary-search-1.3.6.tgz", - "integrity": "sha512-nbE1WxOTTrUWIfsfZ4aHGYu5DOuNkbxGokjV6Z2kxfJK3uaAb8zNK1muzOeipoLHZjInT4Br88BHpzevc681xA==" - }, "node_modules/bintrees": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/bintrees/-/bintrees-1.0.2.tgz", @@ -10532,14 +10791,6 @@ "node": ">=10" } }, - "node_modules/char-width-table-consumer": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/char-width-table-consumer/-/char-width-table-consumer-1.0.0.tgz", - "integrity": "sha512-Fz4UD0LBpxPgL9i29CJ5O4KANwaMnX/OhhbxzvNa332h+9+nRKyeuLw4wA51lt/ex67+/AdsoBQJF3kgX2feYQ==", - "dependencies": { - "binary-search": "^1.3.5" - } - }, "node_modules/character-entities": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", diff --git a/package.json b/package.json index 77b8d1741d358..0293b0834edb5 100644 --- a/package.json +++ b/package.json @@ -148,6 +148,7 @@ "@docusaurus/preset-classic": "^3.9.2", "@easyops-cn/docusaurus-search-local": "^0.55.1", "@mdx-js/react": "^3.1.1", + "@napi-rs/canvas": "^0.1.97", "@typescript-eslint/parser": "^8.58.0", "c8": "^11.0.0", "caller": "^1.1.0", diff --git a/performance.html b/performance.html new file mode 100644 index 0000000000000..036d9e6a3248e --- /dev/null +++ b/performance.html @@ -0,0 +1,772 @@ + + + + + + Performance Report: anafanafo vs pretext + + + +

Performance Report: anafanafo vs pretext

+

+ Text width measurement benchmark for Shields.io badge rendering — + commit 0c49f70 +

+ + +

Summary

+
+
+
~2.2×
+
anafanafo is faster (raw speed)
+
+
+
< 1 px
+
Average width difference (Verdana)
+
+
+
Sub-pixel
+
pretext precision level
+
+
+
5,000
+
Iterations per font × 26 strings
+
+
+ +
+ Key Takeaway: anafanafo is approximately + 2.2× faster than pretext in raw throughput. However, + pretext provides sub-pixel precise + measurements using real font metrics via canvas, resulting in more + accurate badge rendering. The absolute time difference (~0.003 ms per + call) is negligible for Shields.io's use case where badges are rendered + server-side on demand. +
+ + +

Speed Comparison

+ +
+
+
+ anafanafo 2.0.0 +
+
+
+ @chenglou/pretext 0.0.3 +
+
+ +
+ + +

Width Accuracy Comparison

+

+ pretext measures text using actual font outlines via the Canvas API (@napi-rs/canvas + Skia backend), while anafanafo uses pre-computed character-width lookup + tables. The table below shows measured widths and their differences. +

+ +
+
+
+
+ + +

Methodology

+
+ +
+ + +

How It Works

+ +

anafanafo

+

+ Uses pre-computed character-width data stored as JSON arrays, one per + supported font. Each character's width is looked up and summed to estimate + the total string width. Fast but limited to the bundled font set, and + returns rounded integer values. +

+ +

@chenglou/pretext

+

+ A text layout library that delegates measurement to the Canvas API. In the + Shields.io server environment, @napi-rs/canvas provides an + OffscreenCanvas polyfill backed by Skia, giving access to + real font metrics. Returns floating-point widths with sub-pixel precision. +

+ + +

Trade-offs

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
anafanafopretext
Speed~350 ms / 130k calls~800 ms / 130k calls
Per-call cost~0.003 ms~0.006 ms
PrecisionInteger pixels (rounded)Sub-pixel (float)
Font support4 bundled font/size combosAny system / loaded font
DependenciesNone (pure JS + data)@napi-rs/canvas (native addon)
AccuracyApproximation from metrics tablesReal font shaping via Skia
Bundle size~45 KB (data tables)~15 KB (JS) + native binary
+ + + + + + diff --git a/scripts/download-fonts.js b/scripts/download-fonts.js new file mode 100644 index 0000000000000..e18427bdc5670 --- /dev/null +++ b/scripts/download-fonts.js @@ -0,0 +1,223 @@ +#!/usr/bin/env node +/** + * Populate badge-maker/fonts/ with fonts required for badge text-width measurement. + * + * Free fonts (DejaVu Sans and Liberation Sans) are downloaded from GitHub. + * Verdana (proprietary, © Microsoft) is copied from the system font directory; + * it must already be installed: + * - Linux: sudo apt-get install ttf-mscorefonts-installer + * - macOS: included in Microsoft Office or as a standalone download + * + * Run once before executing badge-maker tests: + * node scripts/download-fonts.js + */ + +import { + createWriteStream, + existsSync, + mkdirSync, + copyFileSync, + writeFileSync, +} from 'fs' +import { unlink } from 'fs/promises' +import { join, dirname } from 'path' +import { fileURLToPath } from 'url' +import { pipeline } from 'stream/promises' +import { Readable } from 'stream' +import { execFileSync } from 'child_process' +import { tmpdir } from 'os' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const fontsDir = join(__dirname, '..', 'badge-maker', 'fonts') +mkdirSync(fontsDir, { recursive: true }) + +let hasError = false + +/** + * Download a URL to a local file. + * + * @param {string} url The URL to download. + * @param {string} destPath The destination file path. + * @returns {Promise} + */ +async function downloadFile(url, destPath) { + const resp = await fetch(url, { + redirect: 'follow', + headers: { 'User-Agent': 'shields.io/download-fonts' }, + }) + if (!resp.ok) throw new Error(`HTTP ${resp.status} fetching ${url}`) + await pipeline(Readable.fromWeb(resp.body), createWriteStream(destPath)) +} + +/** + * Convert an unknown thrown value to a printable message. + * + * @param {unknown} err The thrown value. + * @returns {string} The error message. + */ +function errorMessage(err) { + return err instanceof Error ? err.message : String(err) +} + +/** + * Download a .tar.gz archive and extract specific TTF files from it. + * Uses the system `tar` binary available on both Linux and macOS. + * + * @param {string} url The archive URL. + * @param {string[]} filenames The font filenames to extract. + * @returns {Promise} + */ +async function downloadFromTarball(url, filenames) { + const needed = filenames.filter(f => !existsSync(join(fontsDir, f))) + if (needed.length === 0) { + for (const f of filenames) console.log(` skip (exists): ${f}`) + return + } + + const tarPath = join(tmpdir(), `shields-fonts-${Date.now()}.tar.gz`) + process.stdout.write( + ` downloading: ${filenames.join(', ')} (via tarball) ...`, + ) + try { + await downloadFile(url, tarPath) + console.log(' downloaded, extracting...') + + const listing = execFileSync('tar', ['-tzf', tarPath]) + .toString() + .split('\n') + + for (const filename of needed) { + const archivePath = listing.find(path => path.endsWith(`/${filename}`)) + process.stdout.write(` extracting: ${filename} ...`) + if (!archivePath) { + console.log(' FAILED (not found in archive listing)') + hasError = true + continue + } + try { + const content = execFileSync( + 'tar', + ['-xzf', tarPath, '--to-stdout', archivePath], + { + maxBuffer: 8 * 1024 * 1024, + }, + ) + writeFileSync(join(fontsDir, filename), content) + console.log(' done') + } catch { + console.log(` FAILED (${archivePath} could not be extracted)`) + hasError = true + } + } + } catch (err) { + console.log(` FAILED: ${errorMessage(err)}`) + hasError = true + } finally { + await unlink(tarPath).catch(() => {}) + } +} + +/** + * Copy a font file from the first existing candidate path into badge-maker/fonts/. + * + * @param {string[]} candidates Ordered source paths to try. + * @param {string} filename The output filename under badge-maker/fonts/. + * @returns {boolean} True if the font was copied or already exists. + */ +function copyFromSystem(candidates, filename) { + const dest = join(fontsDir, filename) + if (existsSync(dest)) { + console.log(` skip (exists): ${filename}`) + return true + } + for (const src of candidates) { + if (existsSync(src)) { + copyFileSync(src, dest) + console.log(` copied: ${filename} (from ${src})`) + return true + } + } + return false +} + +// ── DejaVu Sans ────────────────────────────────────────────────────────────── +// License: Bitstream Vera Fonts copyright (free to redistribute). +// On ubuntu-latest this is provided by fonts-dejavu-core. +console.log('DejaVu Sans:') +const dejavuOk = copyFromSystem( + [ + // Linux (fonts-dejavu-core) + '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', + // Common macOS Homebrew path (if installed) + '/opt/homebrew/share/fonts/DejaVuSans.ttf', + '/usr/local/share/fonts/DejaVuSans.ttf', + ], + 'DejaVuSans.ttf', +) +const dejavuBoldOk = copyFromSystem( + [ + '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf', + '/opt/homebrew/share/fonts/DejaVuSans-Bold.ttf', + '/usr/local/share/fonts/DejaVuSans-Bold.ttf', + ], + 'DejaVuSans-Bold.ttf', +) + +if (!dejavuOk || !dejavuBoldOk) { + console.warn( + ' warning: DejaVu Sans not found on this host. Using Verdana/Liberation Sans only.', + ) +} + +// ── Liberation Sans ─────────────────────────────────────────────────────────── +// License: SIL Open Font Licence 1.1 (free to redistribute) +// Source: https://github.com/liberationfonts/liberation-fonts +// Metric-compatible with Arial and Helvetica — registered under those family +// names in canvas-polyfill.js so 'bold 11px Helvetica' resolves correctly. +console.log('Liberation Sans:') +await downloadFromTarball( + 'https://github.com/liberationfonts/liberation-fonts/files/7261482/liberation-fonts-ttf-2.1.5.tar.gz', + ['LiberationSans-Regular.ttf', 'LiberationSans-Bold.ttf'], +) + +// ── Verdana ─────────────────────────────────────────────────────────────────── +// License: © Microsoft Corporation — proprietary, do NOT commit to repository. +// Must be installed on the host system before running this script. +// Linux: sudo apt-get install ttf-mscorefonts-installer +// macOS: pre-installed with Microsoft Office or certain system updates +console.log('Verdana:') +const verdanaOk = copyFromSystem( + [ + // Linux (ttf-mscorefonts-installer) + '/usr/share/fonts/truetype/msttcorefonts/Verdana.ttf', + // macOS + '/System/Library/Fonts/Supplemental/Verdana.ttf', + '/Library/Fonts/Verdana.ttf', + ], + 'Verdana.ttf', +) +const verdanaBoldOk = copyFromSystem( + [ + '/usr/share/fonts/truetype/msttcorefonts/Verdana_Bold.ttf', + '/System/Library/Fonts/Supplemental/Verdana Bold.ttf', + '/Library/Fonts/Verdana Bold.ttf', + ], + 'Verdana_Bold.ttf', +) + +if (!verdanaOk || !verdanaBoldOk) { + console.warn( + '\nWARNING: Verdana not found on this system.\n' + + ' Badge widths may differ from the committed snapshots.\n' + + ' Linux: sudo apt-get install ttf-mscorefonts-installer\n' + + ' macOS: install Microsoft Office or download the web fonts package.\n', + ) + hasError = true +} + +if (hasError) { + console.error('\nSome fonts could not be obtained. See warnings above.') + process.exit(1) +} else { + console.log('\nAll fonts ready in badge-maker/fonts/') +}