Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/test-package-lib.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
5 changes: 5 additions & 0 deletions badge-maker/fonts/.gitignore
Original file line number Diff line number Diff line change
@@ -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
41 changes: 41 additions & 0 deletions badge-maker/fonts/README.md
Original file line number Diff line number Diff line change
@@ -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.
18 changes: 14 additions & 4 deletions badge-maker/lib/badge-renderers.js
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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 }) {
Expand Down Expand Up @@ -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

Expand Down
57 changes: 57 additions & 0 deletions badge-maker/lib/canvas-polyfill.js
Original file line number Diff line number Diff line change
@@ -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
}
}
3 changes: 2 additions & 1 deletion badge-maker/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
Loading
Loading