Skip to content

Faster font loading: packed kerning, one-unpack tables, font state for caches (from #47) with follow-up fixes - #51

Merged
dskripchenko merged 12 commits into
mainfrom
perf/font-loading
Oct 27, 2026
Merged

dskripchenko merged 12 commits into
mainfrom
perf/font-loading

Conversation

@dskripchenko

Copy link
Copy Markdown
Owner

This carries the six commits of #47 by @serpentblade unchanged, authorship kept, and adds five follow-up commits from the review. Thank you — a 4× faster median render and a quarter of the memory for typical fonts, with a measurement table for each step, is an excellent contribution. We checked kerning against main on 17 fonts (every stored pair plus 200,000 random lookups each): identical. The PR also makes damaged GPOS tables survivable where main ran out of memory.

The follow-ups:

  • PdfFont::$compressionLevel outside zlib's -1..9 is refused with an InvalidArgumentException naming the property, instead of a ValueError from gzcompress in the middle of a render.
  • Font state is marked @experimental and validated in depth. TtfFile::exportState()/fromState() (and KerningTable's) become a public format in 1.x, so we mark them experimental and state that bumping STATE_FORMAT is not a BC break (a cache simply rebuilds). The docs say the state must come from the application's own cache. fromState() now checks nested arrays too, so malformed state fails there with InvalidArgumentException rather than as a TypeError later. The lists of rebuilt and lazy properties live in the test that guards the state layout, not as public constants. Restore stays well under a millisecond.
  • All-zero PairSets are skipped, so kerningTable() is null for a font with no effective kerning, as on main.
  • KerningTable::pairCount() is documented as diagnostics-only, with a CHANGELOG note that it expands the whole table.
  • Docs in four languages: the font state cache (FPM example with var_export() and OPcache; Octane and queue workers are already covered by fromFile()'s in-process cache) and $compressionLevel in docs/{en,de,ru,zh}/USAGE.md.

Merged with main after #50 (subset tags): the compression cache key now includes the level; font tests 142 OK. On the branch: tests/Font and the compression cache tests pass on PHP 8.5 and 8.2, PHPStan clean, veraPDF passes all nine PDF/A documents.

Separately, outside this PR: an oversized group range in a cmap format 12 table can exhaust memory on main too; that gets its own fix.

Closes #47.

🤖 Generated with Claude Code

serpentblade and others added 12 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>
gzcompress() failed with a bare ValueError mid-render. compress() now
checks the level first and throws an InvalidArgumentException naming the
property; -1 (zlib's default) is accepted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fromState() checked only the top-level types, so a damaged cmap,
advanceWidths, bbox, table directory or name table passed and failed
later in a render with a TypeError. Every nested value is now checked,
table entries must lie inside the font, and unitsPerEm must be positive;
anything else is an InvalidArgumentException from fromState() itself.
The checks cost about 0.2 ms per 30,000 cmap entries: DroidSansFallback
restores in 0.7 ms against 3 ms to parse, Liberation Sans in 0.05 ms.

STATE_REBUILT and STATE_LAZY named private properties in the public API
and were read only by the test that guards the state layout; the lists
now live in that test. exportState()/fromState() on TtfFile and
KerningTable are @experimental: the state is for the application's own,
trusted cache, a STATE_FORMAT bump only makes the cache parse again, and
the PHPDoc recommends var_export(), JSON or unserialize() with
allowed_classes => false.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A PairSet in the usual value format is kept as a slice of the font, zero
adjustments included. A font whose pair sets held only zeros got a
non-empty KerningTable, so TtfFile::kerningTable() returned a table where
1.25 returned null. Such a slice is now skipped: the values are masked
out with one string AND, and isEmpty() means "no pairs" again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
pairCount() keeps its result but now expands class subtables to count.
Its PHPDoc and the changelog say so, and that a damaged GPOS table makes
it costly, so nobody calls it on the render path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A "Fonts in per-request processes" subsection under Custom fonts in
docs/{en,de,ru,zh}/USAGE.md: fromFile() already caches for queue workers
and Octane; under PHP-FPM, exportState()/fromState() with a var_export()
file in OPcache (keyed by STATE_FORMAT, parsed again on refusal), the
trusted-cache rule and unserialize() without classes, the experimental
status, and PdfFont::$compressionLevel. The example was run as written.

CHANGELOG: the experimental status, nested-state validation and the
compression level's range; restore timings no longer quote the numbers
measured before validation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dskripchenko
dskripchenko merged commit 95e65b4 into main Oct 27, 2026
10 checks passed
@dskripchenko
dskripchenko deleted the perf/font-loading branch October 27, 2026 12:41
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