Skip to content

Font-family matching (match-font) + cmap parse-once cache - #1

Merged
ynniv merged 2 commits into
masterfrom
font-matching-and-cmap-cache
Jul 2, 2026
Merged

Font-family matching (match-font) + cmap parse-once cache#1
ynniv merged 2 commits into
masterfrom
font-matching-and-cmap-cache

Conversation

@ynniv

@ynniv ynniv commented Jul 2, 2026

Copy link
Copy Markdown
Member

Two additions from driving a web engine (weft) against scribe — a new consumer-facing API and a performance fix that turned out to be load-bearing for text-heavy pages.

1. match-font — CSS font-family matching (5edbe15)

Adds a match-font (family-list &key weight style) → font service so a consumer can resolve a CSS font-family stack + weight + style to a concrete face, with no NIL returns (graceful fallback chain ending at Liberation Sans Regular).

  • Ships the metric-compatible Liberation face set (Sans/Serif/Mono × Regular/Bold/Italic/BoldItalic, + SIL OFL license) under fonts/.
  • Case-insensitive family mapping (generics + Arial/Helvetica/Verdana/Times/Georgia/Courier/… → the right family), weight ≥ 600 → Bold, italic/oblique → Italic; lazy-open + cached.
  • Exports font-ascent / font-descent / font-line-gap (hhea) so a consumer can compute line-height: normal from real metrics.

Verified against a browser: match-font('("Verdana" "sans-serif")) measures a fixed string at 420.7px, matching Chromium's Liberation-Sans substitution to the pixel.

2. Parse the cmap once, cache it on the font (b6f0012)

font-glyph-index rebuilt the entire cmap hash table on every codepoint lookup — reading the whole subtable (thousands of entries) to map a single character. On a consumer that measures every word repeatedly (a web engine's table auto-layout), this is quadratic and dominates wall time.

Fix: parse the best subtable once into a new lazy %cmap slot (mirroring the existing %cff/%fvar/%gdef caches); lookups are now a single gethash. Behavior-identical — same subtable, same mappings, glyph indices unchanged.

Impact measured downstream (weft rendering a 40 KB Hacker News page): 7.5 s → 0.18 s render, layout 6.9 s → 20 ms (345×), output byte-for-byte unchanged.

🤖 Generated with Claude Code

https://claude.ai/code/session_01JA1saK7BkgavHNeurmK65k

ynniv and others added 2 commits July 1, 2026 14:57
… faces

Ship the Liberation Sans/Serif/Mono family (Regular/Bold/Italic/BoldItalic)
in fonts/ — the metric-compatible substitutes browsers use on Linux for
Arial/Helvetica, Times, and Courier — so a consumer measures browser-accurate
text widths.

  - match-font (family-list &key weight style) -> open-font. Case-insensitive
    CSS family mapping (generic + common Windows/macOS aliases), lazy open +
    cache, weight>=600 -> Bold, italic/oblique -> Italic. Never returns NIL:
    falls back through the family list to LiberationSans-Regular.
  - font-ascent / font-descent / font-line-gap: hhea metric accessors for
    line-height computation.
  - FONT-LICENSES.md + fonts/LICENSE-Liberation.txt (SIL OFL 1.1).

Exported match-font + the metric accessors. Test suite stays green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JA1saK7BkgavHNeurmK65k
font-glyph-index rebuilt the entire cmap hash table on EVERY codepoint lookup —
reading the whole subtable (thousands of entries) to map a single character. On
text-heavy consumers (a web engine measuring every word, repeatedly, during table
auto-layout) this is quadratic and dominates wall time. Parse the best subtable
once into a new lazy %cmap slot (mirroring %cff/%fvar/%gdef); lookups are now a
single gethash. Behavior-identical (same subtable, same mappings); glyph indices
unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JA1saK7BkgavHNeurmK65k
@ynniv
ynniv merged commit 16a944f into master Jul 2, 2026
2 checks passed
@ynniv
ynniv deleted the font-matching-and-cmap-cache branch July 2, 2026 14:14
@ynniv

ynniv commented Jul 2, 2026

Copy link
Copy Markdown
Member Author

Merged via rebase (linear history preserved; two clean commits). Review notes for the record:

cmap cache — verified behavior-preserving: swept 34,816 codepoints (ASCII, BMP, and non-BMP incl. format-12 ranges) across LiberationSans/Serif/Mono + DejaVuSans, comparing the cached font-glyph-index against a fresh re-parse of the pre-cache logic on a separate font instance. 0 mismatches. Subtable-selection order is byte-identical to the old code; the empty-cmap path now caches an empty hash-table (parsed once, still returns 0) rather than re-parsing forever. Cache is properly memoized (font-cmap returns eq object across calls). Fonts are immutable after open-font, so per-font caching is sound.

match-font — never returns NIL across exact/unknown/empty-list/string-arg/generic/DejaVu inputs; distinct objects for regular vs bold; exports match-font/font-ascent/font-descent/font-line-gap all present; metrics plausible (upem 2048, ascent 1854, descent -434, lineGap 67). All 12 Liberation faces + OFL license committed (not gitignored) and load via open-font.

Suiteinspect/run-all.sh: 10/10 gates PASS (table x5, cmap x3, shape vs HarfBuzz x2); clean compile, no warnings. CI gates also green.

Non-blocking follow-up: match.lisp resolves DejaVu from inspect/corpus/ (test-only data) via *corpus-dir*. Not a blocker — match-font degrades to Liberation and never errors if the corpus is absent from a distribution — but shipping code depending on the test corpus is worth revisiting if DejaVu is meant to be a first-class shipped face.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant