Skip to content

Fonts: kerning kept packed and unexpanded, faster cmap, hmtx and subsetting, valid subset checksums, a versioned font state for caches - #47

Closed
serpentblade wants to merge 6 commits into
dskripchenko:mainfrom
One-Learning-Community:perf/font-loading
Closed

serpentblade wants to merge 6 commits into
dskripchenko:mainfrom
One-Learning-Community:perf/font-loading

Conversation

@serpentblade

Copy link
Copy Markdown
Contributor

Why

We render one PDF per request under PHP-FPM, so every request parses its fonts from scratch. Profiling showed font loading, not layout, was most of the time, and almost all of it was kerning: modern families kern by class, and GposReader expanded every class pair into glyph pairs. Inter Regular came to 447,676 pairs, which took 35 ms to build and 26 MB to hold, on every request.

Across 33 font families (29 popular Google Fonts, Inter, and Arial, Times New Roman and Georgia), one page per request under PHP-FPM with no caching, the median render goes from 14.0 ms to 3.2 ms and median peak memory from 7.8 MB to 1.7 MB. PDF output is unchanged apart from the corrected subset checksums.

Font main this branch + compressionLevel = 1
Merriweather 83.5 ms, 16.3 MB 6.8 ms, 3.6 MB 5.6 ms
Inter 74.8 ms, 56.9 MB 9.7 ms, 3.1 MB 6.6 ms
Fira Sans 71.5 ms, 55.4 MB 5.7 ms, 2.5 MB 5.1 ms
Montserrat 58.7 ms, 45.3 MB 5.9 ms, 2.0 MB 4.6 ms
Noto Sans JP 56.9 ms, 20.3 MB 34.0 ms, 19.6 MB 21.0 ms
EB Garamond 40.3 ms, 24.1 MB 7.6 ms, 2.8 MB 5.6 ms
Source Sans 3 35.4 ms, 17.8 MB 6.0 ms, 2.1 MB 4.5 ms
Noto Sans 23.7 ms, 9.0 MB 7.6 ms, 3.0 MB 5.5 ms
Work Sans 23.0 ms, 14.6 MB 2.9 ms, 1.6 MB 2.8 ms
Roboto 15.5 ms, 9.2 MB 3.3 ms, 1.7 MB 3.3 ms
Lora 14.0 ms, 7.8 MB 2.9 ms, 1.7 MB 2.7 ms
Arial 7.7 ms, 3.3 MB 5.3 ms, 3.3 MB 4.4 ms
Open Sans 3.8 ms, 1.5 MB 2.6 ms, 1.5 MB 2.4 ms
All 33, summed 718 ms 165 ms 132 ms

For applications that cache fonts across requests, the last commit adds TtfFile::exportState() and TtfFile::fromState(). They save a parsed font as plain data with a format version, and restore it in 0.07–0.55 ms instead of parsing (section 6).

Each request draws a heading and a name in bold and a paragraph in regular, so two faces are parsed, subset and embedded. Times are the median of 9 requests, each with a different name. Peak is memory_get_peak_usage(). Every change is its own commit, so you can review them one at a time or leave out any you'd rather not take (the state API in section 6 builds on the packed kerning in section 1).

1. Kerning kept packed, class subtables as classes (perf(gpos))

GposReader expanded each PairPos format 2 subtable into every (first, second) glyph pair, and KerningTable held them as nested arrays:

Font Pairs Parse Memory
Inter Regular 447,676 35 ms 26 MB
Fira Sans 522,276 37 ms 27 MB
Montserrat 370,697 26 ms 22 MB
Merriweather 101,891 (explicit, no classes) 38 ms 7 MB

KerningTable now holds layers of packed binary strings, in the order they were added. Nothing is expanded:

  • Pairs: for each first glyph, big-endian uint16 (secondGlyph, xAdvance) words. When xAdvance is the only value, the usual format, a PairSet already has exactly that layout, so it's kept as a slice of the font with no per-pair work.
  • Classes: a coverage byte per glyph, a uint16 class per glyph for each side, and the class matrix of xAdvance values. ClassDef format 1 and the matrix are slices of the font, and ClassDef format 2 ranges are written whole with str_repeat().

A lookup searches a row in place with strrpos() (a hit must sit on a pair boundary, since a glyph ID can also match inside a value), or reads the classes and the matrix cell by offset. It checks the newest layer first and memoizes the answer. A later non-zero value wins and a zero never answers, exactly as add() behaved.

Font Parse Memory Serialized
Inter Regular 35 → 1.0 ms 26 → 0.23 MB 5.4 MB → 120 KB
Fira Sans 37 → 1.1 ms 27 → 0.25 MB 6.2 MB → 200 KB
Montserrat 26 → 0.65 ms 22 → 0.12 MB 4.3 MB → 89 KB
Merriweather 38 → 0.65 ms 6.9 → 0.56 MB 1.2 MB → 431 KB

The first lookup of a pair costs a few microseconds more than an array read; a page of text spends 50–100 µs on them in total. Repeat lookups cost the same as before.

Compatibility:

  • add(), lookup(), isEmpty() and pairCount() keep their signatures and results.
  • New: addPairs() and addClasses().
  • pairCount() now counts by expanding the table, so it's for diagnostics rather than the render path. Nothing in the library calls it while rendering.
  • readValueRecord() and five value-format constants in GposReader had no callers left and are removed. Both were private.
  • Malformed fonts behave as before: unsorted or overlapping ClassDef ranges fall back to reading glyph by glyph, and a class with no matrix column kerns nothing.

2. Faster cmap and hmtx parsing (perf(ttf))

parseCmapFormat4(), parseCmapFormat12() and parseHmtx() called BinaryReader once per value. Each array is now read with one unpack(), and each format 4 segment's glyphIdArray slice with another.

  • Inter Regular: 1.9 → 0.7 ms
  • Noto Sans JP (17,000 glyphs): 9.2 → 3.4 ms
  • DroidSansFallback: 6.4 → 3.2 ms

3. Valid subset checksums (fix(subset))

The subsetter copied head unchanged, so a subset carried the source font's checkSumAdjustment, and the table directory summed head with it. Both were wrong for the subset; strict validators and font tools flag such a font. checkSumAdjustment is now zeroed while the table checksums are computed, then set so the whole subset sums to 0xB1B0AFBA, as OpenType requires.

4. Faster table checksums (perf(subset))

tableChecksum() called unpack() once per 32-bit word, over every table and, since commit 3, over the whole subset once more. It now sums a single unpack('N*') and masks once at the end; the sum fits a 64-bit int. Subsetting takes half the time, and the subsets are byte-identical:

  • Inter Regular: 1.88 → 0.83 ms
  • Noto Sans JP: 8.17 → 3.96 ms
  • Times New Roman Bold: 2.21 → 0.94 ms

5. PdfFont::$compressionLevel (feat(font))

Font bodies were always compressed at zlib level 6, the costliest step of embedding a subset. Level 1 is 2–5× faster on a typical 60 KB subset (Inter 1.75 → 0.35 ms) and the stream is within a few percent of the size. The default stays 6. The compressed-body cache is keyed by level too, so changing the level never serves a body compressed at the other one. It's a static setting, like $compressedCacheLimit and TtfFile::$cacheLimit.

6. A versioned font state for caches (feat(ttf))

Under PHP-FPM every request parses its fonts. We cache them across requests, and without an API that meant copying TtfFile's private properties. The only guard against a php-pdf that laid them out differently was its version number, which doesn't change under a path repository or a patched vendor. We hit exactly that: a cached kerning table of the old layout restored into the new class.

exportState() returns the parsed font as plain data (ints, strings, bools and arrays), so serialize(), var_export() and JSON all round-trip it. Kerning is parsed first, since a render always needs it. fromState($state, $bytes) restores the font from that data and the same font's bytes without parsing:

Font Parse Restore (unserialize + fromState) Serialized
Inter Regular 1.65 ms 0.18 ms 198 KB
Fira Sans 1.35 ms 0.07 ms 245 KB
Noto Sans JP 3.57 ms 0.55 ms 615 KB
Source Sans 3 (CFF) 1.60 ms 0.12 ms 285 KB
  • Format version: the state carries TtfFile::STATE_FORMAT, to be bumped whenever its layout changes.
  • Fingerprint: it also carries a hash of the font's table directory, which holds every table's checksum, offset and length.
  • Refusal: fromState() throws InvalidArgumentException for a state of another format, of another font file, or a damaged one, so a cache falls back to parsing rather than restoring a font wrongly.
  • Rebuilt, not saved: the reader and the CFF table come from the bytes. GSUB and the variable-font tables stay lazy, as after new TtfFile().
  • Guard test: a test fails when TtfFile gains a property that is neither saved, rebuilt nor lazy, so a new property can't slip past the format version.

KerningTable::exportState() and fromState() do the same for a kerning table.

How it was checked

  • Kerning: for 77 fonts (the 66 Google Fonts faces above, Inter, Liberation, Arial, Tahoma, Verdana, Georgia), the old expanded table was dumped and compared with the new one: every stored pair must give the same value, the pair counts must match, and 200,000 random glyph pairs must not kern where they didn't before. No differences.
  • cmap, widths and metrics: compared on 249 fonts, including all of macOS's Supplemental folder. 241 are identical; the other 8 are rejected with the same error as before (symbol fonts with no Unicode cmap, and one with no head table).
  • Subsets: byte-identical across commit 4 for several glyph sets per font.
  • Timings: PHP 8.4 without Xdebug, opcache on, Apple Silicon.

Tests

  • tests/Font/Ttf/KerningTableTest.php: packed pairs, later pairs winning and later zeros not hiding earlier values, a glyph ID that matches inside a value, class coverage and the default class, a class with no matrix column, layer order across pairs and classes, and an all-zero class subtable.
  • tests/Font/Ttf/GposReaderTest.php: Liberation Sans now asserts its exact 2,015 pairs instead of "more than 1,500".
  • tests/Font/Ttf/TtfFileTest.php: glyphs past numHMetrics take the last advance width.
  • tests/Font/Ttf/TtfSubsetterTest.php: every table checksum in the subset's directory, and the whole-font checksum, recomputed independently.
  • tests/Pdf/PdfFontCompressionCacheTest.php: the level is used, and the cache keeps levels apart.
  • tests/Font/Ttf/TtfFileStateTest.php: a restored TrueType and a CFF font answer like the parsed ones (metrics, cmap, widths, names, kerning, lazy GSUB) and subset to the same bytes; the state is plain data that var_export() round-trips; another format, another font and damaged states are refused; every TtfFile property is saved, rebuilt or lazy; kerning layers round-trip in order.

🤖 Generated with Claude Code

serpentblade and others added 6 commits October 21, 2026 15:52
Parsing called BinaryReader once per value: every cmap segment array,
every format 12 group and every advance width. Each array is now read
with a single unpack(), and a format 4 segment's glyphIdArray slice with
another.

Inter Regular parses in 0.7 ms (was 1.9 ms), Noto Sans JP with 17,000
glyphs in 3.4 ms (was 9.2 ms), DroidSansFallback in 3.2 ms (was 6.4 ms).
The parsed cmap, widths and metrics are identical on 241 fonts (66
Google Fonts faces, Liberation, Amiri, DroidSansFallback, macOS
Supplemental); the 8 others are rejected with the same error as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GposReader expanded every class-based subtable (PairPos format 2) into
glyph pairs, then KerningTable held them as nested PHP arrays. Fonts with
class kerning — most modern families — paid for it on every parse:

  Inter Regular   447,676 pairs   35 ms   26 MB
  Fira Sans       522,276 pairs   37 ms   27 MB
  Montserrat      370,697 pairs   26 ms   22 MB
  Merriweather    101,891 explicit pairs, 38 ms, 7 MB

KerningTable now holds layers of packed binary strings, in the order
they were added:

- pairs: per first glyph, (secondGlyph, xAdvance) uint16 words. With
  xAdvance as the only value — the usual format — a PairSet already is
  that, so it is kept as a slice of the font with no per-pair work.
- classes: a coverage byte per glyph, a uint16 class per glyph for each
  side, and the class matrix of xAdvance values. ClassDef format 1 and
  the matrix are slices of the font; format 2 ranges are written whole.

A lookup searches a row in place with strrpos() (a hit must sit on a
pair boundary) or reads classes and the matrix cell by offset, newest
layer first, and memoizes the answer. Later non-zero values win and zero
values never answer, exactly as add() behaved.

The same fonts now parse in 0.3-1.3 ms and hold 0.07-0.6 MB. Every
lookup matches the expanded table: compared pair by pair (counts, every
stored pair, and 200,000 random probes for spurious pairs) on 77 fonts —
66 Google Fonts faces, Inter, Liberation, Arial, Tahoma, Verdana and
Georgia. The first lookup of a pair costs a few microseconds more than
an array read; a page of text spends 50-100 us on them in total.

Unchanged malformed-font behaviour: unsorted or overlapping ClassDef
ranges fall back to the glyph-by-glyph read, and a class the matrix has
no column for kerns nothing, as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The subsetter copied `head` unchanged, so the subset carried the source
font's checkSumAdjustment and the table directory summed `head` with it.
Both checksums were wrong for the subset. checkSumAdjustment is now
zeroed while the table checksums are computed, then set so the whole
font sums to 0xB1B0AFBA.

A test recomputes every table checksum from the directory and the
whole-font sum.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tableChecksum() called unpack() once per 32-bit word, and it runs over
every table of every subset and, since the checkSumAdjustment fix, over
the whole subset once more. It now sums one unpack('N*') and masks at the
end: the sum of a table's dwords fits a 64-bit int.

Subsetting takes about half the time — Inter Regular 0.83 ms (was
1.88), Noto Sans JP 3.96 ms (was 8.17), Times New Roman Bold 0.94 ms (was
2.21) — and the subsets are byte-identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Font bodies were always compressed at zlib level 6, the costliest step of
embedding a subset. Level 1 is 2-5x faster on a typical 60 KB subset
(Inter 1.75 -> 0.35 ms, Arial 0.80 -> 0.41 ms) and the stream is within
a few percent of the size (Inter 26 KB either way, Arial 28 -> 30 KB).

The default stays 6. The compressed-body cache is keyed by level too, so
changing it never serves a body compressed at the other level.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A process that renders one document per request (PHP-FPM) parses its
fonts every time. exportState() returns the parsed font as plain data —
ints, strings, bools and arrays, so serialize(), var_export() and JSON
round-trip it — with kerning parsed first, since a render always needs
it. fromState() restores it from that data and the same font's bytes
without parsing:

  Inter Regular       1.65 ms -> 0.18 ms
  Fira Sans           1.35 ms -> 0.07 ms
  Noto Sans JP        3.57 ms -> 0.55 ms
  Source Sans 3 (CFF) 1.60 ms -> 0.12 ms

The state carries TtfFile::STATE_FORMAT, bumped whenever the layout
changes, and a fingerprint of the table directory (every table's
checksum, offset and length). fromState() throws InvalidArgumentException
for a state of another format, another font file, or a damaged one, so a
cache falls back to parsing rather than restoring a font wrongly.

Without this, a cache had to copy private properties, and the only guard
against a php-pdf that laid them out differently was its version number,
which does not change under a path repository or a patched vendor.

The reader and the CFF table are rebuilt from the bytes; GSUB and the
variable-font tables stay lazy, as after `new TtfFile()`. A test fails
when TtfFile gains a property that is neither saved, rebuilt nor lazy, so
a new property cannot slip past the format version.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dskripchenko added a commit that referenced this pull request Oct 27, 2026
Faster font loading: packed kerning, one-unpack tables, font state for caches (from #47) with follow-up fixes
@dskripchenko

Copy link
Copy Markdown
Owner

Thank you, @serpentblade — this is an excellent piece of work: a 4× faster median render and about a quarter of the memory for typical fonts, with a measurement for every step. We checked kerning against main on 17 fonts (every stored pair plus 200,000 random lookups each) and found it identical. Damaged GPOS tables that ran main out of memory now render.

All six of your commits are merged unchanged, with your authorship, through #51. GitHub refused our push to this branch (a fork owned by an organisation), so we carried them over to a branch here and added five follow-ups.

What we changed, and why:

  • PdfFont::$compressionLevel outside -1..9 is refused with an InvalidArgumentException, rather than a ValueError from gzcompress in the middle of a render.
  • The font state is @experimental and validated in depth. exportState()/fromState() would otherwise become a format we commit to for all of 1.x. As experimental, a bump of STATE_FORMAT is not a BC break: the cache simply rebuilds. The docs say the state must come from the application's own cache. fromState() now checks the nested arrays too, so malformed state fails there with InvalidArgumentException rather than as a TypeError later. The property lists moved into the test that guards the layout instead of being public constants. Restore stays well under a millisecond.
  • All-zero PairSets are skipped, so a font without effective kerning still has no kerning table, as on main.
  • pairCount() is documented as diagnostics-only, with a CHANGELOG note that it expands the whole table.
  • Docs in four languages: the font state cache (an FPM example with var_export() and OPcache) and $compressionLevel in docs/*/USAGE.md — we document public API in en/de/ru/zh.

A small request for next time: please open pull requests from a fork under your personal account with "Allow edits by maintainers" ticked. GitHub honours it for personal forks, and we can then push fixes straight into your branch.

Thanks again!

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.

2 participants