Repository navigation
Faster font loading: packed kerning, one-unpack tables, font state for caches (from #47) with follow-up fixes - #51
Merged
Merged
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>
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>
# Conflicts: # CHANGELOG.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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::$compressionLeveloutside zlib's -1..9 is refused with anInvalidArgumentExceptionnaming the property, instead of aValueErrorfromgzcompressin the middle of a render.@experimentaland validated in depth.TtfFile::exportState()/fromState()(andKerningTable's) become a public format in 1.x, so we mark them experimental and state that bumpingSTATE_FORMATis 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 withInvalidArgumentExceptionrather than as aTypeErrorlater. 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.kerningTable()isnullfor 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.var_export()and OPcache; Octane and queue workers are already covered byfromFile()'s in-process cache) and$compressionLevelindocs/{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/Fontand 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