Repository navigation
Fonts: kerning kept packed and unexpanded, faster cmap, hmtx and subsetting, valid subset checksums, a versioned font state for caches - #47
Conversation
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>
Faster font loading: packed kerning, one-unpack tables, font state for caches (from #47) with follow-up fixes
|
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:
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! |
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
GposReaderexpanded 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.
compressionLevel = 1For applications that cache fonts across requests, the last commit adds
TtfFile::exportState()andTtfFile::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))GposReaderexpanded each PairPos format 2 subtable into every (first, second) glyph pair, andKerningTableheld them as nested arrays:KerningTablenow holds layers of packed binary strings, in the order they were added. Nothing is expanded: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 asadd()behaved.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()andpairCount()keep their signatures and results.addPairs()andaddClasses().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 inGposReaderhad no callers left and are removed. Both were private.2. Faster cmap and hmtx parsing (
perf(ttf))parseCmapFormat4(),parseCmapFormat12()andparseHmtx()calledBinaryReaderonce per value. Each array is now read with oneunpack(), and each format 4 segment's glyphIdArray slice with another.3. Valid subset checksums (
fix(subset))The subsetter copied
headunchanged, so a subset carried the source font'scheckSumAdjustment, and the table directory summedheadwith it. Both were wrong for the subset; strict validators and font tools flag such a font.checkSumAdjustmentis now zeroed while the table checksums are computed, then set so the whole subset sums to0xB1B0AFBA, as OpenType requires.4. Faster table checksums (
perf(subset))tableChecksum()calledunpack()once per 32-bit word, over every table and, since commit 3, over the whole subset once more. It now sums a singleunpack('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: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
$compressedCacheLimitandTtfFile::$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), soserialize(),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:TtfFile::STATE_FORMAT, to be bumped whenever its layout changes.fromState()throwsInvalidArgumentExceptionfor 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.new TtfFile().TtfFilegains a property that is neither saved, rebuilt nor lazy, so a new property can't slip past the format version.KerningTable::exportState()andfromState()do the same for a kerning table.How it was checked
headtable).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 pastnumHMetricstake 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 thatvar_export()round-trips; another format, another font and damaged states are refused; everyTtfFileproperty is saved, rebuilt or lazy; kerning layers round-trip in order.🤖 Generated with Claude Code