From da88b8a8ba8b6d567e8667e1688e876586c7ed88 Mon Sep 17 00:00:00 2001 From: Dan Krieger Date: Wed, 21 Oct 2026 15:52:47 -0700 Subject: [PATCH 01/11] perf(ttf): read cmap and hmtx with one unpack per array 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 --- CHANGELOG.md | 10 +++ src/Font/Ttf/TtfFile.php | 107 ++++++++++++++++++--------------- tests/Font/Ttf/TtfFileTest.php | 16 +++++ 3 files changed, 84 insertions(+), 49 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1084f32..612a930 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ All notable changes to `dskripchenko/php-pdf` are documented in this file. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Changed +- Fonts parse two to three times faster: the `cmap` (formats 4 and 12) and + `hmtx` tables are read with one `unpack()` per array instead of one call + per value. Inter Regular parses in 0.7 ms instead of 1.9 ms, Noto Sans JP + (17,000 glyphs) in 3.4 ms instead of 9.2 ms. The parsed tables are + unchanged: identical on 241 fonts compared, the 8 others rejected with + the same errors as before. + ## [1.25.0] — 2026-10-19 ### Fixed diff --git a/src/Font/Ttf/TtfFile.php b/src/Font/Ttf/TtfFile.php index c0229d2..da4adf3 100644 --- a/src/Font/Ttf/TtfFile.php +++ b/src/Font/Ttf/TtfFile.php @@ -657,16 +657,22 @@ private function parseHhea(): void private function parseHmtx(): void { $offset = $this->requireTable('hmtx'); - $this->reader->seek($offset); $this->advanceWidths = []; + if ($this->numHMetrics === 0) { + $this->advanceWidths = $this->numGlyphs > 0 ? array_fill(0, $this->numGlyphs, 0) : []; - $lastWidth = 0; - for ($i = 0; $i < $this->numHMetrics; $i++) { - $lastWidth = $this->reader->readUInt16(); - $this->reader->skip(2); // lsb (signed, not used) - $this->advanceWidths[$i] = $lastWidth; + return; + } + // longHorMetric: uint16 advanceWidth, int16 lsb — read in one unpack. + $words = unpack('n*', substr($this->bytes, $offset, $this->numHMetrics * 4)); + if ($words === false || count($words) < $this->numHMetrics * 2) { + throw new \OutOfBoundsException('hmtx table is truncated'); + } + for ($i = 0, $w = 1; $i < $this->numHMetrics; $i++, $w += 2) { + $this->advanceWidths[$i] = $words[$w]; } // Remaining glyphs — lsb only, width = lastWidth. + $lastWidth = $this->advanceWidths[$this->numHMetrics - 1]; for ($i = $this->numHMetrics; $i < $this->numGlyphs; $i++) { $this->advanceWidths[$i] = $lastWidth; } @@ -834,53 +840,52 @@ private function cmapSubtablePriority(int $platformId, int $encodingId): ?int */ private function parseCmapFormat4(int $subtableOffset): array { - $this->reader->seek($subtableOffset + 6); - $segCountX2 = $this->reader->readUInt16(); - $segCount = $segCountX2 / 2; - $this->reader->skip(6); // searchRange + entrySelector + rangeShift - - $endCode = []; - for ($i = 0; $i < $segCount; $i++) { - $endCode[$i] = $this->reader->readUInt16(); + $segCount = $this->reader->peekUInt16($subtableOffset + 6) >> 1; + if ($segCount === 0) { + return []; } - $this->reader->skip(2); // reservedPad - - $startCode = []; - for ($i = 0; $i < $segCount; $i++) { - $startCode[$i] = $this->reader->readUInt16(); - } - - $idDelta = []; - for ($i = 0; $i < $segCount; $i++) { - $idDelta[$i] = $this->reader->readInt16(); - } - - $idRangeOffsetStart = $this->reader->tell(); - $idRangeOffset = []; - for ($i = 0; $i < $segCount; $i++) { - $idRangeOffset[$i] = $this->reader->readUInt16(); + // endCode[segCount], reservedPad, startCode[], idDelta[], idRangeOffset[]: + // all uint16 words, read in one unpack. + $arrays = $subtableOffset + 14; + $words = unpack('n*', substr($this->bytes, $arrays, ($segCount * 4 + 1) * 2)); + if ($words === false || count($words) < $segCount * 4 + 1) { + throw new \OutOfBoundsException('cmap format 4 subtable is truncated'); } + $startAt = $segCount + 2; // 1-based index of startCode[0] + $deltaAt = $segCount * 2 + 2; // idDelta[0] + $rangeAt = $segCount * 3 + 2; // idRangeOffset[0] + $idRangeOffsetStart = $arrays + ($segCount * 3 + 1) * 2; $cmap = []; for ($i = 0; $i < $segCount; $i++) { - $start = $startCode[$i]; - $end = $endCode[$i]; + $end = $words[1 + $i]; + $start = $words[$startAt + $i]; // Last segment is always 0xFFFF..0xFFFF mapping to 0. Skip. - if ($start === 0xFFFF && $end === 0xFFFF) { + if (($start === 0xFFFF && $end === 0xFFFF) || $end < $start) { continue; } - for ($c = $start; $c <= $end; $c++) { - if ($idRangeOffset[$i] === 0) { - $glyphId = ($c + $idDelta[$i]) & 0xFFFF; - } else { - $glyphIndexPos = $idRangeOffsetStart + $i * 2 - + $idRangeOffset[$i] - + ($c - $start) * 2; - $glyphId = $this->reader->peekUInt16($glyphIndexPos); + $delta = $words[$deltaAt + $i]; + $rangeOffset = $words[$rangeAt + $i]; + if ($rangeOffset === 0) { + for ($c = $start; $c <= $end; $c++) { + $glyphId = ($c + $delta) & 0xFFFF; if ($glyphId !== 0) { - $glyphId = ($glyphId + $idDelta[$i]) & 0xFFFF; + $cmap[$c] = $glyphId; } } + + continue; + } + // glyphIdArray slice for this segment, read in one unpack + $from = $idRangeOffsetStart + $i * 2 + $rangeOffset; + $count = $end - $start + 1; + $slice = substr($this->bytes, $from, $count * 2); + $glyphs = strlen($slice) === $count * 2 ? unpack('n*', $slice) : false; + for ($c = $start, $k = 1; $c <= $end; $c++, $k++) { + $glyphId = $glyphs === false ? $this->reader->peekUInt16($from + ($c - $start) * 2) : $glyphs[$k]; + if ($glyphId !== 0) { + $glyphId = ($glyphId + $delta) & 0xFFFF; + } if ($glyphId !== 0) { $cmap[$c] = $glyphId; } @@ -907,15 +912,19 @@ private function parseCmapFormat4(int $subtableOffset): array */ private function parseCmapFormat12(int $subtableOffset): array { - $this->reader->seek($subtableOffset + 12); - $numGroups = $this->reader->readUInt32(); + $numGroups = $this->reader->peekUInt32($subtableOffset + 12); + if ($numGroups === 0) { + return []; + } + // SequentialMapGroup: uint32 startCharCode, endCharCode, startGlyphID — one unpack. + $groups = unpack('N*', substr($this->bytes, $subtableOffset + 16, $numGroups * 12)); + if ($groups === false || count($groups) < $numGroups * 3) { + throw new \OutOfBoundsException('cmap format 12 subtable is truncated'); + } $cmap = []; - for ($g = 0; $g < $numGroups; $g++) { - $start = $this->reader->readUInt32(); - $end = $this->reader->readUInt32(); - $startGlyph = $this->reader->readUInt32(); - for ($c = $start, $glyph = $startGlyph; $c <= $end; $c++, $glyph++) { + for ($g = 1, $n = $numGroups * 3; $g <= $n; $g += 3) { + for ($c = $groups[$g], $end = $groups[$g + 1], $glyph = $groups[$g + 2]; $c <= $end; $c++, $glyph++) { $cmap[$c] = $glyph; } } diff --git a/tests/Font/Ttf/TtfFileTest.php b/tests/Font/Ttf/TtfFileTest.php index 838d3a1..fb90974 100644 --- a/tests/Font/Ttf/TtfFileTest.php +++ b/tests/Font/Ttf/TtfFileTest.php @@ -162,4 +162,20 @@ public function liberation_serif_italic_has_negative_angle(): void // Italic typically has a negative italicAngle (-12, for instance). self::assertLessThan(0, $ttf->italicAngle()); } + + #[Test] + public function glyphs_past_the_long_metrics_take_the_last_advance_width(): void + { + // Liberation Mono Italic: 2425 glyphs, 2423 long metrics in hmtx. + $path = dirname($this->liberationSansPath).'/LiberationMono-Italic.ttf'; + if (! is_readable($path)) { + self::markTestSkipped('Liberation Mono Italic not cached.'); + } + $ttf = TtfFile::fromFile($path); + + self::assertSame(2425, $ttf->numGlyphs()); + self::assertSame(1229, $ttf->advanceWidth(2422)); + self::assertSame(1229, $ttf->advanceWidth(2423)); + self::assertSame(1229, $ttf->advanceWidth(2424)); + } } From 96978a61e6793205780f12859748c5deddabfdb1 Mon Sep 17 00:00:00 2001 From: Dan Krieger Date: Wed, 21 Oct 2026 15:54:11 -0700 Subject: [PATCH 02/11] perf(gpos): keep kerning packed, class subtables as classes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 12 ++ src/Font/Ttf/GposReader.php | 269 ++++++++++++++++------------ src/Font/Ttf/KerningTable.php | 203 +++++++++++++++++++-- tests/Font/Ttf/GposReaderTest.php | 2 +- tests/Font/Ttf/KerningTableTest.php | 103 +++++++++++ 5 files changed, 463 insertions(+), 126 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 612a930..b061b21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] ### Changed +- Kerning is no longer expanded into glyph pairs. GPOS class-based + subtables (PairPos format 2) are kept as classes, and the whole table is + held as packed binary strings — for the usual value format, slices of the + font itself — searched in place. Fonts with class kerning parsed for tens + of milliseconds and held tens of megabytes: Inter Regular took 35 ms and + 26 MB for its 447,676 pairs, Fira Sans 37 ms and 27 MB. They now take + about 1 ms and 0.25 MB; Merriweather's 102,000 explicit pairs take + 0.7 ms and 0.5 MB instead of 38 ms and 7 MB. Every lookup answers as + before: compared pair by pair on 77 fonts. +- `KerningTable::addPairs()` and `KerningTable::addClasses()` add packed + pairs and class subtables. `pairCount()` now counts by expanding the + table, so it is meant for diagnostics rather than the render path. - Fonts parse two to three times faster: the `cmap` (formats 4 and 12) and `hmtx` tables are read with one `unpack()` per array instead of one call per value. Inter Regular parses in 0.7 ms instead of 1.9 ms, Noto Sans JP diff --git a/src/Font/Ttf/GposReader.php b/src/Font/Ttf/GposReader.php index 81202da..5019986 100644 --- a/src/Font/Ttf/GposReader.php +++ b/src/Font/Ttf/GposReader.php @@ -31,16 +31,6 @@ final class GposReader private const VALUE_X_ADVANCE = 0x0004; - private const VALUE_Y_ADVANCE = 0x0008; - - private const VALUE_X_PLA_DEVICE = 0x0010; - - private const VALUE_Y_PLA_DEVICE = 0x0020; - - private const VALUE_X_ADV_DEVICE = 0x0040; - - private const VALUE_Y_ADV_DEVICE = 0x0080; - /** * @param array{offset: int, length: int} $gposTableInfo */ @@ -161,16 +151,17 @@ private function readPairAdjustmentSubtable(string $bytes, int $subOffset, Kerni return; // No X_ADVANCE for the first glyph — not kerning in our sense } - // Parse coverage list — glyph IDs that can be 'first'. - $coverageGlyphs = $this->readCoverage($bytes, $subOffset + $coverageOffset); - if ($coverageGlyphs === []) { - return; - } - if ($posFormat === 1) { - $this->readFormat1($bytes, $reader, $subOffset, $coverageGlyphs, $valueFormat1, $valueFormat2, $table); + // Coverage list — glyph IDs that can be 'first', in pair set order. + $coverageGlyphs = $this->readCoverage($bytes, $subOffset + $coverageOffset); + if ($coverageGlyphs !== []) { + $this->readFormat1($bytes, $reader, $subOffset, $coverageGlyphs, $valueFormat1, $valueFormat2, $table); + } } elseif ($posFormat === 2) { - $this->readFormat2($bytes, $reader, $subOffset, $coverageGlyphs, $valueFormat1, $valueFormat2, $table); + $coverage = $this->coverageMask($bytes, $subOffset + $coverageOffset); + if ($coverage !== '') { + $this->readFormat2($bytes, $reader, $subOffset, $coverage, $valueFormat1, $valueFormat2, $table); + } } } @@ -224,31 +215,41 @@ private function readPairSet( int $valueSize2, KerningTable $table, ): void { - $reader = new BinaryReader($bytes); - $reader->seek($pairSetOffset); - $pairValueCount = $reader->readUInt16(); - - for ($j = 0; $j < $pairValueCount; $j++) { - $secondGid = $reader->readUInt16(); - $value1 = $this->readValueRecord($reader, $valueFormat1); - // Skip value2 (we only use value1.xAdvance). - $reader->skip($valueSize2); - - $xAdvance = $value1['xAdvance']; - if ($xAdvance !== 0) { - $table->add($firstGid, $secondGid, $xAdvance); - } + $pairValueCount = (new BinaryReader($bytes))->peekUInt16($pairSetOffset); + if ($pairValueCount === 0) { + return; + } + + // PairValueRecord: uint16 secondGlyph, ValueRecord value1, ValueRecord value2. + // With xAdvance as the only value (the usual kerning format) the set + // already is (secondGlyph, xAdvance) words: it is kept as it is. + if ($valueFormat1 === self::VALUE_X_ADVANCE && $valueFormat2 === 0) { + $table->addPairs($firstGid, substr($bytes, $pairSetOffset + 2, $pairValueCount * 4)); + + return; } + $words = 1 + (($valueSize1 + $valueSize2) >> 1); + $xAt = 1 + ($valueFormat1 & self::VALUE_X_PLACEMENT ? 1 : 0) + ($valueFormat1 & self::VALUE_Y_PLACEMENT ? 1 : 0); + $set = unpack('n*', substr($bytes, $pairSetOffset + 2, $pairValueCount * $words * 2)); + if ($set === false || count($set) < $pairValueCount * $words) { + return; // truncated + } + $pairs = []; + for ($j = 0, $i = 1; $j < $pairValueCount; $j++, $i += $words) { + $pairs[] = $set[$i]; + $pairs[] = $set[$i + $xAt]; + } + $table->addPairs($firstGid, pack('n*', ...$pairs)); } /** - * @param list $coverageGlyphs + * @param string $coverage one byte per glyph id, "\1" for a first glyph the subtable covers */ private function readFormat2( string $bytes, BinaryReader $reader, int $subOffset, - array $coverageGlyphs, + string $coverage, int $valueFormat1, int $valueFormat2, KerningTable $table, @@ -263,8 +264,8 @@ private function readFormat2( $class1Count = $reader->readUInt16(); $class2Count = $reader->readUInt16(); - $classDef1 = $this->readClassDef($bytes, $subOffset + $classDef1Offset); - $classDef2 = $this->readClassDef($bytes, $subOffset + $classDef2Offset); + $classDef1 = $this->classString($bytes, $subOffset + $classDef1Offset); + $classDef2 = $this->classString($bytes, $subOffset + $classDef2Offset); $valueSize1 = $this->valueRecordSize($valueFormat1); $valueSize2 = $this->valueRecordSize($valueFormat2); @@ -274,48 +275,34 @@ private function readFormat2( // Each record contains value1 + value2 for the (class1, class2) pair. $classRecordsBase = $reader->tell(); - // Pre-compute (class1, class2) → xAdvance — skip cells that have - // zero adjustments (most pairs). - $kerningByClass = []; // class1 → class2 → xAdvance - for ($c1 = 0; $c1 < $class1Count; $c1++) { - for ($c2 = 0; $c2 < $class2Count; $c2++) { - $cellOffset = $classRecordsBase + ($c1 * $class2Count + $c2) * $recordSize; - $reader->seek($cellOffset); - $value1 = $this->readValueRecord($reader, $valueFormat1); - $xAdvance = $value1['xAdvance']; - if ($xAdvance !== 0) { - $kerningByClass[$c1][$c2] = $xAdvance; - } + // The class1 × class2 matrix of xAdvance values. With xAdvance as the + // only value it is a slice of the font; otherwise it is read with one + // unpack (every value is a 16-bit word) and the xAdvance words kept. + $cells = $class1Count * $class2Count; + if ($valueFormat1 === self::VALUE_X_ADVANCE && $valueFormat2 === 0) { + $matrix = substr($bytes, $classRecordsBase, $cells * 2); + if (strlen($matrix) < $cells * 2) { + return; // truncated } - } - - // Expand (class1, class2) → (gid1, gid2). For each covered - // first-glyph (i.e. in the coverage list) we look up class1, then - // for each class2 with a non-zero adjustment we add a pair for - // all gid2 in class2. - // - // Class 0 = "default class", contains all glyphs that are not - // assigned an explicit class. For kerning we care only about - // explicit-classed glyphs in Coverage (for first) and in - // classDef2 (for second). - - // Reverse classDef2: class → list. - $class2Members = []; - foreach ($classDef2 as $gid => $class) { - $class2Members[$class][] = $gid; - } - - foreach ($coverageGlyphs as $firstGid) { - $c1 = $classDef1[$firstGid] ?? 0; - if (! isset($kerningByClass[$c1])) { - continue; + } else { + $words = $recordSize >> 1; + $xAt = ($valueFormat1 & self::VALUE_X_PLACEMENT ? 1 : 0) + ($valueFormat1 & self::VALUE_Y_PLACEMENT ? 1 : 0); + $all = $cells === 0 ? [] : unpack('n*', substr($bytes, $classRecordsBase, $cells * $recordSize)); + if ($all === false || count($all) < $cells * $words) { + return; // truncated } - foreach ($kerningByClass[$c1] as $c2 => $xAdvance) { - foreach ($class2Members[$c2] ?? [] as $secondGid) { - $table->add($firstGid, $secondGid, $xAdvance); - } + $values = []; + for ($i = 1 + $xAt, $end = $cells * $words; $i <= $end; $i += $words) { + $values[] = $all[$i]; } + $matrix = $values === [] ? '' : pack('n*', ...$values); } + + // Kept as classes rather than expanded into (gid1, gid2) pairs. + // Class 0 = "default class", contains all glyphs that are not + // assigned an explicit class: it applies to covered first glyphs, + // never to second glyphs. + $table->addClasses($coverage, $classDef1, $classDef2, $class2Count, $matrix); } /** @@ -362,6 +349,102 @@ private function readCoverage(string $bytes, int $offset): array return $out; } + /** + * A Coverage table as one byte per glyph id: "\1" covered, "\0" not. + * Format 2 ranges are written whole, never glyph by glyph. + */ + private function coverageMask(string $bytes, int $offset): string + { + $reader = new BinaryReader($bytes); + $reader->seek($offset); + $format = $reader->readUInt16(); + $count = $reader->readUInt16(); + if ($count === 0 || ($format !== 1 && $format !== 2)) { + return ''; + } + $words = unpack('n*', substr($bytes, $offset + 4, $count * ($format === 1 ? 2 : 6))); + if ($words === false || count($words) < $count * ($format === 1 ? 1 : 3)) { + return ''; // truncated + } + if ($format === 1) { + $mask = str_repeat("\0", max($words) + 1); + foreach ($words as $gid) { + $mask[$gid] = "\1"; + } + + return $mask; + } + $last = 0; + for ($i = 1; $i <= $count * 3; $i += 3) { + $last = max($last, $words[$i + 1]); + } + $mask = str_repeat("\0", $last + 1); + for ($i = 1; $i <= $count * 3; $i += 3) { + if ($words[$i + 1] >= $words[$i]) { + $mask = substr_replace($mask, str_repeat("\1", $words[$i + 1] - $words[$i] + 1), $words[$i], $words[$i + 1] - $words[$i] + 1); + } + } + + return $mask; + } + + /** + * A ClassDef table as a big-endian uint16 class per glyph id (0 past the + * end). Format 1 is a slice of the font behind zero padding; format 2 + * ranges are written whole when sorted and disjoint, as the spec requires. + */ + private function classString(string $bytes, int $offset): string + { + $reader = new BinaryReader($bytes); + $reader->seek($offset); + $format = $reader->readUInt16(); + if ($format === 1) { + $start = $reader->readUInt16(); + $count = $reader->readUInt16(); + $values = substr($bytes, $offset + 6, $count * 2); + if (strlen($values) < $count * 2) { + return $this->packClasses($this->readClassDef($bytes, $offset)); + } + + return str_repeat("\0\0", $start).$values; + } + if ($format !== 2) { + return ''; + } + $count = $reader->readUInt16(); + $words = $count === 0 ? [] : unpack('n*', substr($bytes, $offset + 4, $count * 6)); + if ($words === false || count($words) < $count * 3) { + return $this->packClasses($this->readClassDef($bytes, $offset)); + } + $out = ''; + $next = 0; + for ($i = 1; $i <= $count * 3; $i += 3) { + [$start, $end, $class] = [$words[$i], $words[$i + 1], $words[$i + 2]]; + if ($start < $next || $end < $start) { + // unsorted or overlapping ranges: glyph by glyph, as before + return $this->packClasses($this->readClassDef($bytes, $offset)); + } + $out .= str_repeat("\0\0", $start - $next).str_repeat(pack('n', $class), $end - $start + 1); + $next = $end + 1; + } + + return $out; + } + + /** @param array $classes glyph id → class */ + private function packClasses(array $classes): string + { + if ($classes === []) { + return ''; + } + $words = array_fill(0, max(array_keys($classes)) + 1, 0); + foreach ($classes as $gid => $class) { + $words[$gid] = $class; + } + + return pack('n*', ...$words); + } + /** * ClassDef table — two formats: * @@ -431,42 +514,4 @@ private function valueRecordSize(int $valueFormat): int return $size; } - - /** - * Reads ValueRecord by valueFormat. Returns only xAdvance — the only - * thing we care about for kerning. - * - * @return array{xAdvance: int} - */ - private function readValueRecord(BinaryReader $reader, int $valueFormat): array - { - $xAdvance = 0; - if ($valueFormat & self::VALUE_X_PLACEMENT) { - $reader->skip(2); - } - if ($valueFormat & self::VALUE_Y_PLACEMENT) { - $reader->skip(2); - } - if ($valueFormat & self::VALUE_X_ADVANCE) { - $xAdvance = $reader->readInt16(); - } - if ($valueFormat & self::VALUE_Y_ADVANCE) { - $reader->skip(2); - } - // Device tables — uint16 offsets (skip). - if ($valueFormat & self::VALUE_X_PLA_DEVICE) { - $reader->skip(2); - } - if ($valueFormat & self::VALUE_Y_PLA_DEVICE) { - $reader->skip(2); - } - if ($valueFormat & self::VALUE_X_ADV_DEVICE) { - $reader->skip(2); - } - if ($valueFormat & self::VALUE_Y_ADV_DEVICE) { - $reader->skip(2); - } - - return ['xAdvance' => $xAdvance]; - } } diff --git a/src/Font/Ttf/KerningTable.php b/src/Font/Ttf/KerningTable.php index 195ebda..dea86d2 100644 --- a/src/Font/Ttf/KerningTable.php +++ b/src/Font/Ttf/KerningTable.php @@ -17,31 +17,82 @@ * - Legacy `kern` table (Apple TT) — not supported here; modern fonts * use GPOS exclusively. * + * Storage is packed binary strings rather than nested arrays, in layers kept + * in the order they were added: + * - pairs: per first glyph, big-endian uint16 (secondGlyph, xAdvance) words — + * for the common GPOS value format this is a slice of the font itself; + * - classes (format 2): a byte per glyph marking the covered first glyphs, + * a uint16 class per glyph for each side (0 = default class), and the + * class1 × class2 matrix of int16 xAdvance values — the ClassDef and + * matrix are slices of the font, or built range by range. + * Nothing is expanded into glyph pairs or unpacked: a lookup searches a row + * in place with strrpos() and reads classes and matrix cells by offset, and + * its answer is memoized. A lookup asks the newest layer first, so a later + * non-zero adjustment wins over an earlier one; a zero adjustment is never + * an answer, as it was never stored. + * + * A font such as Inter holds about half a million pairs once classes are + * expanded: tens of milliseconds to build and tens of megabytes to hold. + * Packed, the same table is a few hundred kilobytes of strings. + * * Use cases: * - TextMeasurer: accounts for kerning when measuring pt-width of a string * - PdfFont: emits TJ-operator with position adjustments in content stream - * - * Performance: O(1) lookup via nested-array indexing. */ final class KerningTable { - /** @var array> leftGid → rightGid → xAdvance FU */ - private array $pairs = []; + /** + * @var list}|array{0: 'classes', 1: string, 2: string, 3: string, 4: int, 5: string}> + */ + private array $layers = []; + + /** @var array> answered lookups */ + private array $memo = []; - private int $pairCount = 0; + private ?int $pairCount = null; public function add(int $leftGid, int $rightGid, int $xAdvanceFu): void { if ($xAdvanceFu === 0) { return; // do not store zero-adjustment pairs } - if (! isset($this->pairs[$leftGid])) { - $this->pairs[$leftGid] = []; + $this->addPairs($leftGid, pack('nn', $rightGid, $xAdvanceFu & 0xFFFF)); + } + + /** + * Adds pairs for one first glyph: big-endian uint16 words, alternately the + * second glyph and its xAdvance (two's complement). Later pairs win over + * earlier ones; zero adjustments are ignored. + */ + public function addPairs(int $leftGid, string $packed): void + { + if ($packed === '' || strlen($packed) % 4 !== 0) { + return; + } + $last = array_key_last($this->layers); + if ($last === null || $this->layers[$last][0] !== 'pairs') { + $this->layers[] = ['pairs', []]; + $last = array_key_last($this->layers); } - if (! isset($this->pairs[$leftGid][$rightGid])) { - $this->pairCount++; + $this->layers[$last][1][$leftGid] = ($this->layers[$last][1][$leftGid] ?? '').$packed; + $this->changed(); + } + + /** + * Adds a class-based subtable (GPOS PairPos format 2) without expanding it. + * + * @param string $coverage a byte per glyph id: "\1" for a first glyph the subtable covers + * @param string $firstClasses uint16 per glyph id: the first glyph's class (0 = default class) + * @param string $secondClasses uint16 per glyph id: the second glyph's class (0 = none) + * @param string $matrix int16 xAdvance per (class1, class2), row-major, class2Count wide + */ + public function addClasses(string $coverage, string $firstClasses, string $secondClasses, int $class2Count, string $matrix): void + { + if ($class2Count < 1 || trim($matrix, "\0") === '' || trim($coverage, "\0") === '' || trim($secondClasses, "\0") === '') { + return; } - $this->pairs[$leftGid][$rightGid] = $xAdvanceFu; + $this->layers[] = ['classes', $coverage, $firstClasses, $secondClasses, $class2Count, $matrix]; + $this->changed(); } /** @@ -50,16 +101,142 @@ public function add(int $leftGid, int $rightGid, int $xAdvanceFu): void */ public function lookup(int $leftGid, int $rightGid): int { - return $this->pairs[$leftGid][$rightGid] ?? 0; + return $this->memo[$leftGid][$rightGid] ??= $this->find($leftGid, $rightGid); + } + + private function find(int $leftGid, int $rightGid): int + { + for ($i = count($this->layers) - 1; $i >= 0; $i--) { + $layer = $this->layers[$i]; + if ($layer[0] === 'pairs') { + if (! isset($layer[1][$leftGid])) { + continue; + } + $value = self::findInRow($layer[1][$leftGid], $rightGid); + if ($value !== 0) { + return $value; + } + + continue; + } + if (($layer[1][$leftGid] ?? "\0") !== "\1") { + continue; // first glyph not covered + } + $at = $rightGid << 1; + $class2 = isset($layer[3][$at + 1]) ? (ord($layer[3][$at]) << 8) | ord($layer[3][$at + 1]) : 0; + if ($class2 === 0 || $class2 >= $layer[4]) { + continue; // no class, or one the matrix has no column for (malformed) + } + $at = $leftGid << 1; + $class1 = isset($layer[2][$at + 1]) ? (ord($layer[2][$at]) << 8) | ord($layer[2][$at + 1]) : 0; + $at = ($class1 * $layer[4] + $class2) << 1; + $value = isset($layer[5][$at + 1]) ? (ord($layer[5][$at]) << 8) | ord($layer[5][$at + 1]) : 0; + if ($value !== 0) { + return $value >= 0x8000 ? $value - 0x10000 : $value; + } + } + + return 0; + } + + /** The uint16 at word $index of $packed, or 0 past its end. */ + private static function u16(string $packed, int $index): int + { + $at = $index << 1; + + return isset($packed[$at + 1]) ? (ord($packed[$at]) << 8) | ord($packed[$at + 1]) : 0; + } + + /** + * The last non-zero xAdvance for $rightGid in a packed row, or 0. Searches + * from the end with strrpos (C speed, no unpacking); a hit must sit on a + * (secondGlyph, xAdvance) boundary, as glyph ids can also match inside values. + */ + private static function findInRow(string $row, int $rightGid): int + { + $needle = chr($rightGid >> 8).chr($rightGid & 0xFF); + $length = strlen($row); + $before = $length; + while ($before > 0 && ($at = strrpos($row, $needle, $before - $length - 1)) !== false) { + if ($at % 4 === 0) { + $value = (ord($row[$at + 2]) << 8) | ord($row[$at + 3]); + if ($value !== 0) { + return $value >= 0x8000 ? $value - 0x10000 : $value; + } + } + $before = $at; + } + + return 0; + } + + /** @return array second glyph → non-zero xAdvance */ + private static function unpackRow(string $packed): array + { + $words = unpack('n*', $packed) ?: []; + $row = []; + for ($i = 1, $n = count($words); $i < $n; $i += 2) { + $value = $words[$i + 1]; + if ($value !== 0) { + $row[$words[$i]] = $value >= 0x8000 ? $value - 0x10000 : $value; + } + } + + return $row; + } + + private function changed(): void + { + $this->memo = []; + $this->pairCount = null; } public function isEmpty(): bool { - return $this->pairCount === 0; + return $this->layers === []; } + /** + * Number of distinct glyph pairs with an adjustment. This expands every + * layer, so it is meant for diagnostics, not the render path. + */ public function pairCount(): int { - return $this->pairCount; + if ($this->pairCount !== null) { + return $this->pairCount; + } + $seen = []; + foreach ($this->layers as $layer) { + if ($layer[0] === 'pairs') { + foreach ($layer[1] as $left => $packed) { + foreach (self::unpackRow($packed) as $right => $_) { + $seen[$left][$right] = true; + } + } + + continue; + } + $members = []; + foreach ($layer[3] === '' ? [] : (unpack('n*', $layer[3]) ?: []) as $i => $class) { + if ($class !== 0 && $class < $layer[4]) { + $members[$class][] = $i - 1; + } + } + for ($left = 0, $n = strlen($layer[1]); $left < $n; $left++) { + if ($layer[1][$left] !== "\1") { + continue; + } + $class1 = self::u16($layer[2], $left); + foreach ($members as $class2 => $rights) { + if (self::u16($layer[5], $class1 * $layer[4] + $class2) !== 0) { + foreach ($rights as $right) { + $seen[$left][$right] = true; + } + } + } + } + } + + return $this->pairCount = array_sum(array_map('count', $seen)); } } diff --git a/tests/Font/Ttf/GposReaderTest.php b/tests/Font/Ttf/GposReaderTest.php index 3678e83..62cc2e2 100644 --- a/tests/Font/Ttf/GposReaderTest.php +++ b/tests/Font/Ttf/GposReaderTest.php @@ -28,7 +28,7 @@ public function liberation_sans_has_kerning_table(): void self::assertNotNull($kt); self::assertFalse($kt->isEmpty()); // Liberation Sans 2.1.5 has 2015 kerning pairs (through class-based GPOS). - self::assertGreaterThan(1500, $kt->pairCount()); + self::assertSame(2015, $kt->pairCount()); } #[Test] diff --git a/tests/Font/Ttf/KerningTableTest.php b/tests/Font/Ttf/KerningTableTest.php index bc40287..df441b0 100644 --- a/tests/Font/Ttf/KerningTableTest.php +++ b/tests/Font/Ttf/KerningTableTest.php @@ -62,4 +62,107 @@ public function adding_same_pair_overwrites(): void self::assertSame(-200, $kt->lookup(1, 2)); self::assertSame(1, $kt->pairCount()); // не дублируется } + + /** Packs (secondGlyph, xAdvance) pairs as GPOS stores them. */ + private static function pairs(int ...$words): string + { + return pack('n*', ...array_map(static fn (int $w): int => $w & 0xFFFF, $words)); + } + + /** @param array $byGid glyph id → value */ + private static function perGlyph(array $byGid, string $format = 'n'): string + { + $out = array_fill(0, max(array_keys($byGid)) + 1, 0); + foreach ($byGid as $gid => $value) { + $out[$gid] = $value; + } + + return $format === 'C' ? implode('', array_map('chr', $out)) : pack('n*', ...$out); + } + + #[Test] + public function packed_pairs_answer_like_added_ones(): void + { + $kt = new KerningTable; + $kt->addPairs(36, self::pairs(57, -152, 60, 40, 61, 0)); + self::assertSame(-152, $kt->lookup(36, 57)); + self::assertSame(40, $kt->lookup(36, 60)); + self::assertSame(0, $kt->lookup(36, 61)); + self::assertSame(0, $kt->lookup(36, 99)); + self::assertSame(0, $kt->lookup(37, 57)); + self::assertSame(2, $kt->pairCount()); + } + + #[Test] + public function a_later_pair_wins_but_a_later_zero_does_not_hide_an_earlier_value(): void + { + $kt = new KerningTable; + $kt->addPairs(1, self::pairs(2, -100, 3, -30)); + $kt->addPairs(1, self::pairs(2, -200, 3, 0)); + self::assertSame(-200, $kt->lookup(1, 2)); + self::assertSame(-30, $kt->lookup(1, 3)); + self::assertSame(2, $kt->pairCount()); + } + + #[Test] + public function a_glyph_id_matching_inside_a_value_is_not_a_pair(): void + { + $kt = new KerningTable; + // the value of the pair (5, 7) is 7: looking up second glyph 7 must not read it as a glyph + $kt->addPairs(1, self::pairs(5, 7, 9, -20)); + self::assertSame(0, $kt->lookup(1, 7)); + self::assertSame(7, $kt->lookup(1, 5)); + } + + #[Test] + public function class_subtable_answers_without_expanding(): void + { + $kt = new KerningTable; + // first glyphs 10 (class 1) and 11 (default class 0) covered; 12 not covered + // second glyphs 20 and 21 in class 1, 22 in class 0; matrix 2 x 2 + $kt->addClasses( + self::perGlyph([10 => 1, 11 => 1], 'C'), + self::perGlyph([10 => 1]), + self::perGlyph([20 => 1, 21 => 1]), + 2, + pack('n*', 0, -30 & 0xFFFF, 0, -80 & 0xFFFF), + ); + self::assertFalse($kt->isEmpty()); + self::assertSame(-80, $kt->lookup(10, 21)); + self::assertSame(-30, $kt->lookup(11, 20)); + self::assertSame(0, $kt->lookup(12, 20), 'not covered'); + self::assertSame(0, $kt->lookup(10, 22), 'second glyph in class 0'); + self::assertSame(0, $kt->lookup(999, 999), 'past the end of every string'); + self::assertSame(4, $kt->pairCount()); + } + + #[Test] + public function a_class_the_matrix_has_no_column_for_kerns_nothing(): void + { + $kt = new KerningTable; + // second glyph 20 is in class 2, but the matrix is 2 x 2 (classes 0 and 1) + $kt->addClasses(self::perGlyph([10 => 1], 'C'), self::perGlyph([10 => 0]), self::perGlyph([20 => 2, 21 => 1]), 2, pack('n*', 0, 50, 0, 70)); + self::assertSame(0, $kt->lookup(10, 20)); + self::assertSame(50, $kt->lookup(10, 21)); + } + + #[Test] + public function newer_layers_answer_first_as_with_add(): void + { + $kt = new KerningTable; + $kt->add(10, 20, -5); + $kt->addClasses(self::perGlyph([10 => 1], 'C'), self::perGlyph([10 => 0]), self::perGlyph([20 => 1, 21 => 1]), 2, pack('n*', 0, -50 & 0xFFFF)); + $kt->add(10, 21, -7); + self::assertSame(-50, $kt->lookup(10, 20)); + self::assertSame(-7, $kt->lookup(10, 21)); + self::assertSame(2, $kt->pairCount()); + } + + #[Test] + public function an_all_zero_class_subtable_is_not_kept(): void + { + $kt = new KerningTable; + $kt->addClasses(self::perGlyph([10 => 1], 'C'), self::perGlyph([10 => 0]), self::perGlyph([20 => 1]), 2, pack('n*', 0, 0)); + self::assertTrue($kt->isEmpty()); + } } From e34c869f8b97478361a4cbcb497bbab20b2352f1 Mon Sep 17 00:00:00 2001 From: Dan Krieger Date: Wed, 21 Oct 2026 15:55:12 -0700 Subject: [PATCH 03/11] fix(subset): set checkSumAdjustment for the subset, not the source font 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 --- CHANGELOG.md | 7 +++++++ src/Font/Ttf/TtfSubsetter.php | 18 +++++++++++++++++- tests/Font/Ttf/TtfSubsetterTest.php | 28 ++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b061b21..1793b73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,13 @@ versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). unchanged: identical on 241 fonts compared, the 8 others rejected with the same errors as before. +### Fixed +- Embedded font subsets kept the source font's `head.checkSumAdjustment`, + and the `head` entry of their table directory was summed with it, so the + subset's checksums were wrong: strict validators and font tools flag such + a font. The adjustment is zeroed for the table checksums and set so the + whole subset sums to `0xB1B0AFBA`, as OpenType requires. + ## [1.25.0] — 2026-10-19 ### Fixed diff --git a/src/Font/Ttf/TtfSubsetter.php b/src/Font/Ttf/TtfSubsetter.php index be5ce8c..437fba0 100644 --- a/src/Font/Ttf/TtfSubsetter.php +++ b/src/Font/Ttf/TtfSubsetter.php @@ -442,6 +442,11 @@ private function buildOutputTtf(string $sourceBytes, array $tables, string $newG ), default => substr($sourceBytes, $info['offset'], $info['length']), }; + if ($tag === 'head' && strlen($bytes) >= 12) { + // checkSumAdjustment counts as 0 in the checksums; it is set + // once the whole font is assembled. + $bytes = substr_replace($bytes, "\0\0\0\0", 8, 4); + } $newTableData[$tag] = $bytes; } @@ -463,7 +468,11 @@ private function buildOutputTtf(string $sourceBytes, array $tables, string $newG $directory = ''; $tableData = ''; $currentOffset = $tableDataOffset; + $headOffset = null; foreach ($newTableData as $tag => $bytes) { + if ($tag === 'head' && strlen($bytes) >= 12) { + $headOffset = $currentOffset; + } $length = strlen($bytes); // Checksum: sum of 32-bit big-endian dwords. $checksum = $this->tableChecksum($bytes); @@ -479,7 +488,14 @@ private function buildOutputTtf(string $sourceBytes, array $tables, string $newG $currentOffset += $length + $pad; } - return $header.$directory.$tableData; + $font = $header.$directory.$tableData; + if ($headOffset !== null) { + // OpenType: the whole font, checkSumAdjustment included, sums to 0xB1B0AFBA. + $adjustment = (0xB1B0AFBA - $this->tableChecksum($font)) & 0xFFFFFFFF; + $font = substr_replace($font, pack('N', $adjustment), $headOffset + 8, 4); + } + + return $font; } /** diff --git a/tests/Font/Ttf/TtfSubsetterTest.php b/tests/Font/Ttf/TtfSubsetterTest.php index 758acf4..f9a0e1f 100644 --- a/tests/Font/Ttf/TtfSubsetterTest.php +++ b/tests/Font/Ttf/TtfSubsetterTest.php @@ -141,6 +141,34 @@ public function full_embed_mode_keeps_original_size(): void self::assertGreaterThan(380_000, strlen($bytes)); } + #[Test] + public function table_checksums_and_the_font_checksum_are_valid(): void + { + $subset = (new TtfSubsetter)->subset($this->ttf, [3, 43, 72, 79, 82, 1000]); + $sum = static function (string $bytes): int { + $bytes .= str_repeat("\0", (4 - strlen($bytes) % 4) % 4); + $total = 0; + for ($i = 0, $n = strlen($bytes); $i < $n; $i += 4) { + $total = ($total + unpack('N', $bytes, $i)[1]) & 0xFFFFFFFF; + } + + return $total; + }; + + $numTables = unpack('n', $subset, 4)[1]; + self::assertGreaterThan(5, $numTables); + for ($i = 0; $i < $numTables; $i++) { + ['tag' => $tag, 'sum' => $stored, 'offset' => $offset, 'length' => $length] + = unpack('a4tag/Nsum/Noffset/Nlength', $subset, 12 + $i * 16); + $table = substr($subset, $offset, $length); + if ($tag === 'head') { + $table = substr_replace($table, "\0\0\0\0", 8, 4); // checkSumAdjustment counts as 0 + } + self::assertSame($stored, $sum($table), "checksum of $tag"); + } + self::assertSame(0xB1B0AFBA, $sum($subset), 'whole-font checksum'); + } + private function commandExists(string $cmd): bool { $out = shell_exec('which '.escapeshellarg($cmd).' 2>/dev/null'); From cc6b9ef204b7c9a3009c6f29db0586c9656d626e Mon Sep 17 00:00:00 2001 From: Dan Krieger Date: Wed, 21 Oct 2026 15:55:42 -0700 Subject: [PATCH 04/11] perf(subset): sum table checksums from one unpack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 4 ++++ src/Font/Ttf/TtfSubsetter.php | 11 ++++++----- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1793b73..11e1073 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,10 @@ versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - `KerningTable::addPairs()` and `KerningTable::addClasses()` add packed pairs and class subtables. `pairCount()` now counts by expanding the table, so it is meant for diagnostics rather than the render path. +- Font subsetting is about twice as fast: table checksums are summed from + one `unpack()` instead of one call per 32-bit word. Inter Regular + subsets in 0.8 ms instead of 1.9 ms, Noto Sans JP in 4.0 ms instead of + 8.2 ms; the subsets are byte-identical. - Fonts parse two to three times faster: the `cmap` (formats 4 and 12) and `hmtx` tables are read with one `unpack()` per array instead of one call per value. Inter Regular parses in 0.7 ms instead of 1.9 ms, Noto Sans JP diff --git a/src/Font/Ttf/TtfSubsetter.php b/src/Font/Ttf/TtfSubsetter.php index 437fba0..f30aaf7 100644 --- a/src/Font/Ttf/TtfSubsetter.php +++ b/src/Font/Ttf/TtfSubsetter.php @@ -507,12 +507,13 @@ private function tableChecksum(string $bytes): int if ($pad > 0) { $bytes .= str_repeat("\x00", $pad); } - $sum = 0; - $numDwords = strlen($bytes) / 4; - for ($i = 0; $i < $numDwords; $i++) { - $sum = ($sum + unpack('N', substr($bytes, $i * 4, 4))[1]) & 0xFFFFFFFF; + if ($bytes === '') { + return 0; } - return $sum; + // One unpack for the whole table. The sum of its dwords fits in a + // 64-bit int (each < 2^32, far fewer than 2^31 of them), so it is + // masked once at the end rather than at every step. + return array_sum(unpack('N*', $bytes)) & 0xFFFFFFFF; } } From 18e5c820b088e36fa6ea3904d9c88ae87add4b93 Mon Sep 17 00:00:00 2001 From: Dan Krieger Date: Wed, 21 Oct 2026 15:56:35 -0700 Subject: [PATCH 05/11] feat(font): PdfFont::$compressionLevel for embedded font bodies 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 --- CHANGELOG.md | 5 +++++ src/Pdf/PdfFont.php | 13 +++++++++++-- tests/Pdf/PdfFontCompressionCacheTest.php | 15 +++++++++++++++ 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 11e1073..f924eeb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added +- `PdfFont::$compressionLevel` sets the zlib level for embedded font + bodies (default 6, as before). Level 1 compresses a subset two to five + times faster and the stream is within a few percent of the level-6 size. + ### Changed - Kerning is no longer expanded into glyph pairs. GPOS class-based subtables (PairPos format 2) are kept as classes, and the whole table is diff --git a/src/Pdf/PdfFont.php b/src/Pdf/PdfFont.php index 41ea581..1d66a27 100644 --- a/src/Pdf/PdfFont.php +++ b/src/Pdf/PdfFont.php @@ -152,6 +152,15 @@ public function reset(): void */ public static int $compressedCacheLimit = 24; + /** + * zlib level (0-9) for embedded font bodies. + * + * Level 6 is the default. Level 1 compresses a typical subset two to five + * times faster and the stream comes out within a few percent of the + * level-6 size: worth it where documents are rendered per request. + */ + public static int $compressionLevel = 6; + /** * Compresses a font body, remembering the result. * @@ -166,7 +175,7 @@ public function reset(): void */ private static function compress(string $fontBytes): string { - $key = hash('xxh3', $fontBytes); + $key = self::$compressionLevel.':'.hash('xxh3', $fontBytes); if (isset(self::$compressedCache[$key])) { return self::$compressedCache[$key]; @@ -178,7 +187,7 @@ private static function compress(string $fontBytes): string array_shift(self::$compressedCache); } - return self::$compressedCache[$key] = (string) gzcompress($fontBytes, 6); + return self::$compressedCache[$key] = (string) gzcompress($fontBytes, self::$compressionLevel); } /** Forgets the compressed font bodies. */ diff --git a/tests/Pdf/PdfFontCompressionCacheTest.php b/tests/Pdf/PdfFontCompressionCacheTest.php index 1cd6898..f95ceb1 100644 --- a/tests/Pdf/PdfFontCompressionCacheTest.php +++ b/tests/Pdf/PdfFontCompressionCacheTest.php @@ -31,6 +31,7 @@ protected function setUp(): void protected function tearDown(): void { PdfFont::forgetCompressedCache(); + PdfFont::$compressionLevel = 6; } /** Access to the private compression — it is what is under test, not the whole render. */ @@ -83,4 +84,18 @@ public function cache_does_not_grow_without_bound(): void self::assertLessThanOrEqual($limit, count($prop->getValue())); } + + #[Test] + public function the_compression_level_is_used_and_kept_apart_in_the_cache(): void + { + $bytes = random_bytes(2048).str_repeat('font body ', 4000); + $atSix = $this->compress($bytes); + + PdfFont::$compressionLevel = 1; + $atOne = $this->compress($bytes); + + self::assertSame(gzcompress($bytes, 1), $atOne, 'level 1, not the level-6 body from the cache'); + self::assertNotSame($atSix, $atOne); + self::assertSame($bytes, gzuncompress($atOne)); + } } From d440f34a4bbef04408027e230d9546bab682b1fc Mon Sep 17 00:00:00 2001 From: Dan Krieger Date: Wed, 21 Oct 2026 17:04:48 -0700 Subject: [PATCH 06/11] feat(ttf): TtfFile::exportState()/fromState() with a format version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 8 ++ src/Font/Ttf/KerningTable.php | 48 ++++++++ src/Font/Ttf/TtfFile.php | 138 +++++++++++++++++++++ tests/Font/Ttf/TtfFileStateTest.php | 178 ++++++++++++++++++++++++++++ 4 files changed, 372 insertions(+) create mode 100644 tests/Font/Ttf/TtfFileStateTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index f924eeb..8ada9f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] ### Added +- `TtfFile::exportState()` and `TtfFile::fromState()` save a parsed font, + kerning included, as plain data and restore it without parsing — for a + cache across requests. A restore takes 0.07–0.55 ms against 0.4–3.6 ms to + parse. The state carries `TtfFile::STATE_FORMAT` and a fingerprint of the + font's table directory: `fromState()` refuses, with an + `InvalidArgumentException`, a state of another format, of another font + file, or a damaged one. `KerningTable::exportState()` and + `KerningTable::fromState()` do the same for a kerning table. - `PdfFont::$compressionLevel` sets the zlib level for embedded font bodies (default 6, as before). Level 1 compresses a subset two to five times faster and the stream is within a few percent of the level-6 size. diff --git a/src/Font/Ttf/KerningTable.php b/src/Font/Ttf/KerningTable.php index dea86d2..6fe0268 100644 --- a/src/Font/Ttf/KerningTable.php +++ b/src/Font/Ttf/KerningTable.php @@ -95,6 +95,54 @@ public function addClasses(string $coverage, string $firstClasses, string $secon $this->changed(); } + /** + * The table as plain data, for TtfFile::exportState(): its packed layers. + * + * @return list}|array{0: 'classes', 1: string, 2: string, 3: string, 4: int, 5: string}> + */ + public function exportState(): array + { + return $this->layers; + } + + /** + * Restores a table from exportState(). + * + * @param array $layers + * + * @throws \InvalidArgumentException for layers of any other shape + */ + public static function fromState(array $layers): self + { + $table = new self; + foreach ($layers as $layer) { + if (! is_array($layer) || ! array_is_list($layer)) { + throw new \InvalidArgumentException('Kerning state is damaged.'); + } + if (count($layer) === 2 && $layer[0] === 'pairs' && is_array($layer[1])) { + $rows = []; + foreach ($layer[1] as $left => $row) { + if (! is_int($left) || ! is_string($row) || strlen($row) % 4 !== 0) { + throw new \InvalidArgumentException('Kerning state is damaged.'); + } + $rows[$left] = $row; + } + $table->layers[] = ['pairs', $rows]; + + continue; + } + if (count($layer) === 6 && $layer[0] === 'classes' && is_string($layer[1]) && is_string($layer[2]) + && is_string($layer[3]) && is_int($layer[4]) && $layer[4] >= 1 && is_string($layer[5])) { + $table->layers[] = ['classes', $layer[1], $layer[2], $layer[3], $layer[4], $layer[5]]; + + continue; + } + throw new \InvalidArgumentException('Kerning state is damaged.'); + } + + return $table; + } + /** * Lookup xAdvance adjustment for pair (left, right). Returns 0 if * the pair is not defined (no kerning). diff --git a/src/Font/Ttf/TtfFile.php b/src/Font/Ttf/TtfFile.php index da4adf3..5a09de1 100644 --- a/src/Font/Ttf/TtfFile.php +++ b/src/Font/Ttf/TtfFile.php @@ -149,6 +149,144 @@ public static function forgetCache(): void self::$cache = []; } + /** + * Version of the exportState() layout. Bumped whenever what a state holds, + * or how it holds it, changes: fromState() refuses any other version, so a + * cache never restores a state written by a php-pdf that laid it out + * differently. + */ + public const STATE_FORMAT = 1; + + /** Properties fromState() rebuilds rather than reads from a state. */ + public const STATE_REBUILT = ['reader', 'bytes', 'cff', 'kerningParsed']; + + /** Properties a state leaves unparsed: they parse on first use, as after `new TtfFile()`. */ + public const STATE_LAZY = [ + 'ligatures', 'ligaturesParsed', 'singleSubstByFeature', 'fvarParsed', + 'avar', 'avarParsed', 'hvar', 'hvarParsed', 'mvar', 'mvarParsed', + 'gvar', 'gvarParsed', 'advanceHeights', + ]; + + /** + * The parsed font as plain data — ints, strings, bools and arrays only — for + * a cache across processes: serialize(), var_export() and JSON round-trip + * it. Kerning is parsed first, as a render always needs it. + * + * fromState() with the same font bytes restores the font without parsing. + * + * @return array + */ + public function exportState(): array + { + return [ + 'format' => self::STATE_FORMAT, + 'font' => self::fingerprint($this->bytes), + 'tables' => $this->tables, + 'unitsPerEm' => $this->unitsPerEm, + 'numGlyphs' => $this->numGlyphs, + 'bbox' => $this->bbox, + 'ascent' => $this->ascent, + 'descent' => $this->descent, + 'numHMetrics' => $this->numHMetrics, + 'advanceWidths' => $this->advanceWidths, + 'cmap' => $this->cmap, + 'postScriptName' => $this->postScriptName, + 'italicAngle' => $this->italicAngle, + 'isFixedPitch' => $this->isFixedPitch, + 'namesById' => $this->namesById, + 'kerningTable' => $this->kerningTable()?->exportState(), + ]; + } + + /** + * Restores a font from exportState() and the same font's bytes, without + * parsing its tables. + * + * @param array $state + * + * @throws \InvalidArgumentException for a state of another format, of + * another font, or damaged — parse the font instead + */ + public static function fromState(array $state, string $bytes): self + { + if (($state['format'] ?? null) !== self::STATE_FORMAT) { + throw new \InvalidArgumentException('Font state is of another format than '.self::STATE_FORMAT.'.'); + } + if (($state['font'] ?? null) !== self::fingerprint($bytes)) { + throw new \InvalidArgumentException('Font state is of another font file.'); + } + $kerning = $state['kerningTable'] ?? null; + if (! array_key_exists('kerningTable', $state) || ($kerning !== null && ! is_array($kerning))) { + throw new \InvalidArgumentException('Font state is damaged: kerningTable.'); + } + + // The constructor parses; a restored font skips it. Its two readonly + // properties are initialized as the constructor would set them. + $font = (new \ReflectionClass(self::class))->newInstanceWithoutConstructor(); + (new \ReflectionProperty(self::class, 'bytes'))->setValue($font, $bytes); + (new \ReflectionProperty(self::class, 'reader'))->setValue($font, new BinaryReader($bytes)); + $font->tables = self::stateArray($state, 'tables'); + $font->unitsPerEm = self::stateInt($state, 'unitsPerEm'); + $font->numGlyphs = self::stateInt($state, 'numGlyphs'); + $font->bbox = self::stateArray($state, 'bbox'); + $font->ascent = self::stateInt($state, 'ascent'); + $font->descent = self::stateInt($state, 'descent'); + $font->numHMetrics = self::stateInt($state, 'numHMetrics'); + $font->advanceWidths = self::stateArray($state, 'advanceWidths'); + $font->cmap = self::stateArray($state, 'cmap'); + $font->postScriptName = self::stateString($state, 'postScriptName'); + $font->italicAngle = self::stateInt($state, 'italicAngle'); + $font->isFixedPitch = self::stateBool($state, 'isFixedPitch'); + $font->namesById = self::stateArray($state, 'namesById'); + $font->kerningTable = $kerning === null ? null : KerningTable::fromState($kerning); + $font->kerningParsed = true; + try { + $font->parseCff(); + } catch (\RuntimeException $e) { + throw new \InvalidArgumentException('Font state does not fit the font: '.$e->getMessage(), 0, $e); + } + + return $font; + } + + /** + * Identifies a font file cheaply: the hash of its table directory, which + * holds every table's checksum, offset and length. + */ + private static function fingerprint(string $bytes): string + { + $numTables = strlen($bytes) >= 6 ? unpack('n', $bytes, 4)[1] : 0; + + return hash('xxh3', substr($bytes, 0, 12 + 16 * $numTables)); + } + + /** @param array $state */ + private static function stateInt(array $state, string $key): int + { + return is_int($state[$key] ?? null) ? $state[$key] : throw new \InvalidArgumentException("Font state is damaged: $key."); + } + + /** @param array $state */ + private static function stateString(array $state, string $key): string + { + return is_string($state[$key] ?? null) ? $state[$key] : throw new \InvalidArgumentException("Font state is damaged: $key."); + } + + /** @param array $state */ + private static function stateBool(array $state, string $key): bool + { + return is_bool($state[$key] ?? null) ? $state[$key] : throw new \InvalidArgumentException("Font state is damaged: $key."); + } + + /** + * @param array $state + * @return array + */ + private static function stateArray(array $state, string $key): array + { + return is_array($state[$key] ?? null) ? $state[$key] : throw new \InvalidArgumentException("Font state is damaged: $key."); + } + public function rawBytes(): string { return $this->bytes; diff --git a/tests/Font/Ttf/TtfFileStateTest.php b/tests/Font/Ttf/TtfFileStateTest.php new file mode 100644 index 0000000..cb5744d --- /dev/null +++ b/tests/Font/Ttf/TtfFileStateTest.php @@ -0,0 +1,178 @@ +font('liberation-fonts-ttf-2.1.5/LiberationSans-Regular.ttf'); + $parsed = new TtfFile($bytes); + + $restored = TtfFile::fromState(unserialize(serialize($parsed->exportState())), $bytes); + + self::assertSame($parsed->unitsPerEm(), $restored->unitsPerEm()); + self::assertSame($parsed->numGlyphs(), $restored->numGlyphs()); + self::assertSame($parsed->bbox(), $restored->bbox()); + self::assertSame($parsed->ascent(), $restored->ascent()); + self::assertSame($parsed->descent(), $restored->descent()); + self::assertSame($parsed->postScriptName(), $restored->postScriptName()); + self::assertSame($parsed->italicAngle(), $restored->italicAngle()); + self::assertSame($parsed->isFixedPitch(), $restored->isFixedPitch()); + self::assertSame($parsed->nameById(1), $restored->nameById(1)); + self::assertSame($bytes, $restored->rawBytes()); + foreach ([0x41, 0x56, 0xE9, 0x416, 0x20AC] as $cp) { + $gid = $parsed->glyphIdForChar($cp); + self::assertSame($gid, $restored->glyphIdForChar($cp)); + self::assertSame($parsed->advanceWidth($gid), $restored->advanceWidth($gid)); + } + $a = $parsed->glyphIdForChar(0x41); + $v = $parsed->glyphIdForChar(0x56); + self::assertNotSame(0, $restored->kerningTable()?->lookup($a, $v)); + self::assertSame($parsed->kerningTable()?->lookup($a, $v), $restored->kerningTable()?->lookup($a, $v)); + self::assertSame($parsed->kerningTable()?->pairCount(), $restored->kerningTable()?->pairCount()); + self::assertSame($parsed->ligatures()?->isEmpty(), $restored->ligatures()?->isEmpty(), 'lazy tables still parse'); + } + + #[Test] + public function a_restored_font_subsets_to_the_same_bytes(): void + { + $bytes = $this->font('liberation-fonts-ttf-2.1.5/LiberationSerif-Bold.ttf'); + $glyphs = [3, 36, 37, 68, 69, 70, 500]; + + $fromParse = (new TtfSubsetter)->subset(new TtfFile($bytes), $glyphs); + TtfSubsetter::clearCache(); + $fromState = (new TtfSubsetter)->subset(TtfFile::fromState((new TtfFile($bytes))->exportState(), $bytes), $glyphs); + + self::assertSame($fromParse, $fromState); + } + + #[Test] + public function a_cff_font_round_trips(): void + { + $bytes = $this->font('SourceSans3-Regular.otf'); + $parsed = new TtfFile($bytes); + + $restored = TtfFile::fromState($parsed->exportState(), $bytes); + + self::assertTrue($restored->isCff()); + self::assertSame($parsed->cffTableBytes(), $restored->cffTableBytes()); + self::assertSame($parsed->cidForGlyph(40), $restored->cidForGlyph(40)); + } + + #[Test] + public function the_state_is_plain_data_that_var_export_round_trips(): void + { + $state = (new TtfFile($this->font('liberation-fonts-ttf-2.1.5/LiberationSans-Regular.ttf')))->exportState(); + + array_walk_recursive($state, static function (mixed $value): void { + self::assertTrue(is_scalar($value) || $value === null, 'only scalars, strings, null and arrays'); + }); + self::assertSame(TtfFile::STATE_FORMAT, $state['format']); + self::assertSame($state, eval('return '.var_export($state, true).';')); + } + + #[Test] + public function a_state_of_another_format_is_refused(): void + { + $bytes = $this->font('liberation-fonts-ttf-2.1.5/LiberationSans-Regular.ttf'); + $state = (new TtfFile($bytes))->exportState(); + $state['format'] = TtfFile::STATE_FORMAT + 1; + + $this->expectException(\InvalidArgumentException::class); + TtfFile::fromState($state, $bytes); + } + + #[Test] + public function a_state_is_refused_for_another_font(): void + { + $state = (new TtfFile($this->font('liberation-fonts-ttf-2.1.5/LiberationSans-Regular.ttf')))->exportState(); + + $this->expectException(\InvalidArgumentException::class); + TtfFile::fromState($state, $this->font('liberation-fonts-ttf-2.1.5/LiberationSans-Bold.ttf')); + } + + #[Test] + public function a_damaged_state_is_refused(): void + { + $bytes = $this->font('liberation-fonts-ttf-2.1.5/LiberationSans-Regular.ttf'); + $good = (new TtfFile($bytes))->exportState(); + + $damaged = [ + 'missing key' => array_diff_key($good, ['cmap' => true]), + 'wrong type' => ['unitsPerEm' => 'many'] + $good, + 'bad kerning' => ['kerningTable' => [['pairs', 'not an array']]] + $good, + 'not an array' => ['cmap' => 'x'] + $good, + ]; + foreach ($damaged as $case => $state) { + try { + TtfFile::fromState($state, $bytes); + self::fail("$case: accepted"); + } catch (\InvalidArgumentException) { + $this->addToAssertionCount(1); + } + } + } + + #[Test] + public function every_property_is_saved_rebuilt_or_left_lazy(): void + { + // When this fails, TtfFile gained a property: add it to exportState()/fromState() + // or to the lazy list there, and bump STATE_FORMAT. + $state = (new TtfFile($this->font('liberation-fonts-ttf-2.1.5/LiberationSans-Regular.ttf')))->exportState(); + $covered = [...array_keys($state), ...TtfFile::STATE_REBUILT, ...TtfFile::STATE_LAZY]; + + $properties = []; + foreach ((new \ReflectionClass(TtfFile::class))->getProperties() as $p) { + if (! $p->isStatic()) { + $properties[] = $p->getName(); + } + } + $missing = array_diff($properties, $covered); + + self::assertSame([], array_values($missing), 'properties no state decision covers'); + } + + #[Test] + public function kerning_tables_round_trip_their_packed_layers(): void + { + $kt = new KerningTable; + $kt->add(1, 2, -50); + $kt->add(1, 3, -20); + // glyph 1 covered in class 0; glyph 2 in second class 1; matrix 1 x 2 + $kt->addClasses("\0\1", pack('n*', 0, 0), pack('n*', 0, 0, 1), 2, pack('n*', 0, (-30) & 0xFFFF)); + + $restored = KerningTable::fromState(unserialize(serialize($kt->exportState()))); + + self::assertSame(-30, $restored->lookup(1, 2), 'the newer class layer still answers first'); + self::assertSame(-20, $restored->lookup(1, 3)); + self::assertSame(0, $restored->lookup(2, 3)); + self::assertSame($kt->pairCount(), $restored->pairCount()); + } +} From aa3979f2dc6aceac3ec8da4c9e61d3baea2652f3 Mon Sep 17 00:00:00 2001 From: Denis Skripchenko Date: Tue, 27 Oct 2026 15:31:44 +0300 Subject: [PATCH 07/11] fix(font): refuse a PdfFont::$compressionLevel outside zlib's -1..9 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 --- src/Pdf/PdfFont.php | 12 +++++++++++- tests/Pdf/PdfFontCompressionCacheTest.php | 17 +++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/Pdf/PdfFont.php b/src/Pdf/PdfFont.php index 1d66a27..35e3bb0 100644 --- a/src/Pdf/PdfFont.php +++ b/src/Pdf/PdfFont.php @@ -153,11 +153,12 @@ public function reset(): void public static int $compressedCacheLimit = 24; /** - * zlib level (0-9) for embedded font bodies. + * zlib level for embedded font bodies: 0-9, or -1 for zlib's own default. * * Level 6 is the default. Level 1 compresses a typical subset two to five * times faster and the stream comes out within a few percent of the * level-6 size: worth it where documents are rendered per request. + * Any other value makes embedding throw an InvalidArgumentException. */ public static int $compressionLevel = 6; @@ -175,6 +176,15 @@ public function reset(): void */ private static function compress(string $fontBytes): string { + // Checked here, as a static property cannot validate its assignment; + // gzcompress() would otherwise fail with a bare ValueError. + if (self::$compressionLevel < -1 || self::$compressionLevel > 9) { + throw new \InvalidArgumentException(sprintf( + 'PdfFont::$compressionLevel must be between -1 and 9, %d given.', + self::$compressionLevel, + )); + } + $key = self::$compressionLevel.':'.hash('xxh3', $fontBytes); if (isset(self::$compressedCache[$key])) { diff --git a/tests/Pdf/PdfFontCompressionCacheTest.php b/tests/Pdf/PdfFontCompressionCacheTest.php index f95ceb1..b3b2eb8 100644 --- a/tests/Pdf/PdfFontCompressionCacheTest.php +++ b/tests/Pdf/PdfFontCompressionCacheTest.php @@ -98,4 +98,21 @@ public function the_compression_level_is_used_and_kept_apart_in_the_cache(): voi self::assertNotSame($atSix, $atOne); self::assertSame($bytes, gzuncompress($atOne)); } + + #[Test] + public function a_level_outside_zlib_range_is_refused(): void + { + foreach ([-2, 10] as $level) { + PdfFont::$compressionLevel = $level; + try { + $this->compress('font body'); + self::fail("level $level: accepted"); + } catch (\InvalidArgumentException $e) { + self::assertStringContainsString('compressionLevel', $e->getMessage()); + } + } + + PdfFont::$compressionLevel = -1; + self::assertSame(gzcompress('font body', -1), $this->compress('font body'), 'zlib default is allowed'); + } } From 767407ade6133a38685d3957d3e15039104a2c78 Mon Sep 17 00:00:00 2001 From: Denis Skripchenko Date: Tue, 27 Oct 2026 15:33:14 +0300 Subject: [PATCH 08/11] fix(ttf): validate a restored font state in depth, mark it experimental 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 --- src/Font/Ttf/KerningTable.php | 6 +- src/Font/Ttf/TtfFile.php | 119 ++++++++++++++++++++++++---- tests/Font/Ttf/TtfFileStateTest.php | 21 ++++- 3 files changed, 127 insertions(+), 19 deletions(-) diff --git a/src/Font/Ttf/KerningTable.php b/src/Font/Ttf/KerningTable.php index 6fe0268..099f390 100644 --- a/src/Font/Ttf/KerningTable.php +++ b/src/Font/Ttf/KerningTable.php @@ -98,6 +98,8 @@ public function addClasses(string $coverage, string $firstClasses, string $secon /** * The table as plain data, for TtfFile::exportState(): its packed layers. * + * @experimental As TtfFile::exportState(); the layout follows TtfFile::STATE_FORMAT. + * * @return list}|array{0: 'classes', 1: string, 2: string, 3: string, 4: int, 5: string}> */ public function exportState(): array @@ -106,7 +108,9 @@ public function exportState(): array } /** - * Restores a table from exportState(). + * Restores a table from exportState() of a trusted cache. + * + * @experimental As TtfFile::fromState(). * * @param array $layers * diff --git a/src/Font/Ttf/TtfFile.php b/src/Font/Ttf/TtfFile.php index 5a09de1..ce52128 100644 --- a/src/Font/Ttf/TtfFile.php +++ b/src/Font/Ttf/TtfFile.php @@ -153,20 +153,13 @@ public static function forgetCache(): void * Version of the exportState() layout. Bumped whenever what a state holds, * or how it holds it, changes: fromState() refuses any other version, so a * cache never restores a state written by a php-pdf that laid it out - * differently. + * differently. A bump is not a breaking change: the cache misses once and + * is filled again from a parse. + * + * @experimental */ public const STATE_FORMAT = 1; - /** Properties fromState() rebuilds rather than reads from a state. */ - public const STATE_REBUILT = ['reader', 'bytes', 'cff', 'kerningParsed']; - - /** Properties a state leaves unparsed: they parse on first use, as after `new TtfFile()`. */ - public const STATE_LAZY = [ - 'ligatures', 'ligaturesParsed', 'singleSubstByFeature', 'fvarParsed', - 'avar', 'avarParsed', 'hvar', 'hvarParsed', 'mvar', 'mvarParsed', - 'gvar', 'gvarParsed', 'advanceHeights', - ]; - /** * The parsed font as plain data — ints, strings, bools and arrays only — for * a cache across processes: serialize(), var_export() and JSON round-trip @@ -174,6 +167,14 @@ public static function forgetCache(): void * * fromState() with the same font bytes restores the font without parsing. * + * The state is meant for the application's own cache, and only a trusted + * one: it is checked for shape, not for integrity. Store it with + * var_export() or JSON, or read it back with + * unserialize($s, ['allowed_classes' => false]) — it never holds objects. + * + * @experimental The layout may change in any release, which bumps + * STATE_FORMAT; a cache keyed by it just parses again. + * * @return array */ public function exportState(): array @@ -202,6 +203,12 @@ public function exportState(): array * Restores a font from exportState() and the same font's bytes, without * parsing its tables. * + * The state must come from exportState() of a trusted cache (see there). + * Every value is checked for its type before use, so a damaged state is + * refused here rather than failing later in a render. + * + * @experimental See exportState(). + * * @param array $state * * @throws \InvalidArgumentException for a state of another format, of @@ -225,19 +232,25 @@ public static function fromState(array $state, string $bytes): self $font = (new \ReflectionClass(self::class))->newInstanceWithoutConstructor(); (new \ReflectionProperty(self::class, 'bytes'))->setValue($font, $bytes); (new \ReflectionProperty(self::class, 'reader'))->setValue($font, new BinaryReader($bytes)); - $font->tables = self::stateArray($state, 'tables'); + $font->tables = self::stateTables($state, strlen($bytes)); $font->unitsPerEm = self::stateInt($state, 'unitsPerEm'); + if ($font->unitsPerEm < 1) { + throw new \InvalidArgumentException('Font state is damaged: unitsPerEm.'); + } $font->numGlyphs = self::stateInt($state, 'numGlyphs'); - $font->bbox = self::stateArray($state, 'bbox'); + $font->bbox = self::stateIntList($state, 'bbox'); + if (count($font->bbox) !== 4) { + throw new \InvalidArgumentException('Font state is damaged: bbox.'); + } $font->ascent = self::stateInt($state, 'ascent'); $font->descent = self::stateInt($state, 'descent'); $font->numHMetrics = self::stateInt($state, 'numHMetrics'); - $font->advanceWidths = self::stateArray($state, 'advanceWidths'); - $font->cmap = self::stateArray($state, 'cmap'); + $font->advanceWidths = self::stateIntList($state, 'advanceWidths'); + $font->cmap = self::stateIntMap($state, 'cmap'); $font->postScriptName = self::stateString($state, 'postScriptName'); $font->italicAngle = self::stateInt($state, 'italicAngle'); $font->isFixedPitch = self::stateBool($state, 'isFixedPitch'); - $font->namesById = self::stateArray($state, 'namesById'); + $font->namesById = self::stateStringMap($state, 'namesById'); $font->kerningTable = $kerning === null ? null : KerningTable::fromState($kerning); $font->kerningParsed = true; try { @@ -287,6 +300,80 @@ private static function stateArray(array $state, string $key): array return is_array($state[$key] ?? null) ? $state[$key] : throw new \InvalidArgumentException("Font state is damaged: $key."); } + /** + * The table directory, every entry inside a font of $size bytes. + * + * @param array $state + * @return array + */ + private static function stateTables(array $state, int $size): array + { + $tables = []; + foreach (self::stateArray($state, 'tables') as $tag => $info) { + if (! is_string($tag) || ! is_array($info) || ! is_int($info['offset'] ?? null) || ! is_int($info['length'] ?? null) + || $info['offset'] < 0 || $info['length'] < 0 || $info['offset'] + $info['length'] > $size) { + throw new \InvalidArgumentException('Font state is damaged: tables.'); + } + $tables[$tag] = ['offset' => $info['offset'], 'length' => $info['length']]; + } + + return $tables; + } + + /** + * @param array $state + * @return list + */ + private static function stateIntList(array $state, string $key): array + { + $list = self::stateArray($state, $key); + if (! array_is_list($list)) { + throw new \InvalidArgumentException("Font state is damaged: $key."); + } + foreach ($list as $value) { + if (! is_int($value)) { + throw new \InvalidArgumentException("Font state is damaged: $key."); + } + } + + /** @var list $list */ + return $list; + } + + /** + * @param array $state + * @return array + */ + private static function stateIntMap(array $state, string $key): array + { + $map = self::stateArray($state, $key); + foreach ($map as $k => $value) { + if (! is_int($k) || ! is_int($value)) { + throw new \InvalidArgumentException("Font state is damaged: $key."); + } + } + + /** @var array $map */ + return $map; + } + + /** + * @param array $state + * @return array + */ + private static function stateStringMap(array $state, string $key): array + { + $map = self::stateArray($state, $key); + foreach ($map as $k => $value) { + if (! is_int($k) || ! is_string($value)) { + throw new \InvalidArgumentException("Font state is damaged: $key."); + } + } + + /** @var array $map */ + return $map; + } + public function rawBytes(): string { return $this->bytes; diff --git a/tests/Font/Ttf/TtfFileStateTest.php b/tests/Font/Ttf/TtfFileStateTest.php index cb5744d..994e0ab 100644 --- a/tests/Font/Ttf/TtfFileStateTest.php +++ b/tests/Font/Ttf/TtfFileStateTest.php @@ -129,6 +129,15 @@ public function a_damaged_state_is_refused(): void 'wrong type' => ['unitsPerEm' => 'many'] + $good, 'bad kerning' => ['kerningTable' => [['pairs', 'not an array']]] + $good, 'not an array' => ['cmap' => 'x'] + $good, + 'cmap value' => ['cmap' => [65 => 'A']] + $good, + 'cmap key' => ['cmap' => ['A' => 36]] + $good, + 'advance widths not a list' => ['advanceWidths' => [5 => 500]] + $good, + 'advance width type' => ['advanceWidths' => [500, '500']] + $good, + 'bbox size' => ['bbox' => [0, 0, 100]] + $good, + 'zero units per em' => ['unitsPerEm' => 0] + $good, + 'table past the end' => ['tables' => ['head' => ['offset' => strlen($bytes), 'length' => 54]]] + $good, + 'table entry' => ['tables' => ['head' => ['offset' => '0', 'length' => 54]]] + $good, + 'name value' => ['namesById' => [1 => 42]] + $good, ]; foreach ($damaged as $case => $state) { try { @@ -144,9 +153,17 @@ public function a_damaged_state_is_refused(): void public function every_property_is_saved_rebuilt_or_left_lazy(): void { // When this fails, TtfFile gained a property: add it to exportState()/fromState() - // or to the lazy list there, and bump STATE_FORMAT. + // or to one of the lists below, and bump STATE_FORMAT. $state = (new TtfFile($this->font('liberation-fonts-ttf-2.1.5/LiberationSans-Regular.ttf')))->exportState(); - $covered = [...array_keys($state), ...TtfFile::STATE_REBUILT, ...TtfFile::STATE_LAZY]; + // fromState() rebuilds these from the font bytes rather than reading them from a state. + $rebuilt = ['reader', 'bytes', 'cff', 'kerningParsed']; + // A state leaves these unparsed: they parse on first use, as after `new TtfFile()`. + $lazy = [ + 'ligatures', 'ligaturesParsed', 'singleSubstByFeature', 'fvarParsed', + 'avar', 'avarParsed', 'hvar', 'hvarParsed', 'mvar', 'mvarParsed', + 'gvar', 'gvarParsed', 'advanceHeights', + ]; + $covered = [...array_keys($state), ...$rebuilt, ...$lazy]; $properties = []; foreach ((new \ReflectionClass(TtfFile::class))->getProperties() as $p) { From 53bf9093b41d26b4e160faa0b4bfd524271c16bc Mon Sep 17 00:00:00 2001 From: Denis Skripchenko Date: Tue, 27 Oct 2026 15:34:10 +0300 Subject: [PATCH 09/11] fix(kerning): skip PairSets whose adjustments are all zero 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 --- src/Font/Ttf/KerningTable.php | 6 ++++++ tests/Font/Ttf/KerningTableTest.php | 14 ++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/src/Font/Ttf/KerningTable.php b/src/Font/Ttf/KerningTable.php index 099f390..55fbf76 100644 --- a/src/Font/Ttf/KerningTable.php +++ b/src/Font/Ttf/KerningTable.php @@ -69,6 +69,12 @@ public function addPairs(int $leftGid, string $packed): void if ($packed === '' || strlen($packed) % 4 !== 0) { return; } + // A set of zero adjustments only adds nothing a lookup could answer; + // skipped, so that isEmpty() still means "no pairs". The values are + // masked out in one string operation, not pair by pair. + if (trim($packed & str_repeat("\0\0\xFF\xFF", strlen($packed) >> 2), "\0") === '') { + return; + } $last = array_key_last($this->layers); if ($last === null || $this->layers[$last][0] !== 'pairs') { $this->layers[] = ['pairs', []]; diff --git a/tests/Font/Ttf/KerningTableTest.php b/tests/Font/Ttf/KerningTableTest.php index df441b0..5298799 100644 --- a/tests/Font/Ttf/KerningTableTest.php +++ b/tests/Font/Ttf/KerningTableTest.php @@ -165,4 +165,18 @@ public function an_all_zero_class_subtable_is_not_kept(): void $kt->addClasses(self::perGlyph([10 => 1], 'C'), self::perGlyph([10 => 0]), self::perGlyph([20 => 1]), 2, pack('n*', 0, 0)); self::assertTrue($kt->isEmpty()); } + + #[Test] + public function a_pair_set_of_zero_adjustments_is_not_kept(): void + { + $kt = new KerningTable; + // glyph ids are non-zero, every value is zero: nothing to answer + $kt->addPairs(1, self::pairs(2, 0, 0xFF00, 0, 3, 0)); + self::assertTrue($kt->isEmpty()); + self::assertSame(0, $kt->pairCount()); + + $kt->addPairs(1, self::pairs(2, 0, 3, -1)); + self::assertFalse($kt->isEmpty()); + self::assertSame(-1, $kt->lookup(1, 3)); + } } From 83bbb0f811aa550673a63cc11b13e7e0de3c5b78 Mon Sep 17 00:00:00 2001 From: Denis Skripchenko Date: Tue, 27 Oct 2026 15:34:20 +0300 Subject: [PATCH 10/11] docs(kerning): mark KerningTable::pairCount() as diagnostics-only 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 --- CHANGELOG.md | 8 ++++++-- src/Font/Ttf/KerningTable.php | 7 +++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ada9f8..2fa5532 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,8 +30,12 @@ versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 0.7 ms and 0.5 MB instead of 38 ms and 7 MB. Every lookup answers as before: compared pair by pair on 77 fonts. - `KerningTable::addPairs()` and `KerningTable::addClasses()` add packed - pairs and class subtables. `pairCount()` now counts by expanding the - table, so it is meant for diagnostics rather than the render path. + pairs and class subtables. +- `KerningTable::pairCount()` returns the same number as before, but now + counts by expanding every class subtable into glyph pairs: it is meant + for diagnostics, not the render path. On a font with class kerning it + takes milliseconds and megabytes, as parsing did before; on a damaged + `GPOS` table it can take far more. - Font subsetting is about twice as fast: table checksums are summed from one `unpack()` instead of one call per 32-bit word. Inter Regular subsets in 0.8 ms instead of 1.9 ms, Noto Sans JP in 4.0 ms instead of diff --git a/src/Font/Ttf/KerningTable.php b/src/Font/Ttf/KerningTable.php index 55fbf76..c2ca16c 100644 --- a/src/Font/Ttf/KerningTable.php +++ b/src/Font/Ttf/KerningTable.php @@ -255,8 +255,11 @@ public function isEmpty(): bool } /** - * Number of distinct glyph pairs with an adjustment. This expands every - * layer, so it is meant for diagnostics, not the render path. + * Number of distinct glyph pairs with an adjustment. + * + * Diagnostics only, never the render path: it expands every class + * subtable into glyph pairs — the work and memory the packed layout + * avoids, and far more for a damaged GPOS table. Lookups never need it. */ public function pairCount(): int { From e8ced93b51fd0e8adca79dc5dd5e0588222a0f18 Mon Sep 17 00:00:00 2001 From: Denis Skripchenko Date: Tue, 27 Oct 2026 15:35:51 +0300 Subject: [PATCH 11/11] docs: font state cache and compression level in USAGE, four languages 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 --- CHANGELOG.md | 13 ++++++---- docs/de/USAGE.md | 62 ++++++++++++++++++++++++++++++++++++++++++++++++ docs/en/USAGE.md | 59 +++++++++++++++++++++++++++++++++++++++++++++ docs/ru/USAGE.md | 60 ++++++++++++++++++++++++++++++++++++++++++++++ docs/zh/USAGE.md | 54 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 244 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fa5532..d73f4e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,15 +9,20 @@ versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Added - `TtfFile::exportState()` and `TtfFile::fromState()` save a parsed font, kerning included, as plain data and restore it without parsing — for a - cache across requests. A restore takes 0.07–0.55 ms against 0.4–3.6 ms to - parse. The state carries `TtfFile::STATE_FORMAT` and a fingerprint of the + cache across requests. A restore takes well under a millisecond against + 0.4–3.6 ms to parse. The state carries `TtfFile::STATE_FORMAT` and a fingerprint of the font's table directory: `fromState()` refuses, with an `InvalidArgumentException`, a state of another format, of another font - file, or a damaged one. `KerningTable::exportState()` and - `KerningTable::fromState()` do the same for a kerning table. + file, or a damaged one, nested values included. `KerningTable::exportState()` + and `KerningTable::fromState()` do the same for a kerning table. The four + methods are experimental: the state is for the application's own, + trusted cache, and a release that changes its layout bumps + `STATE_FORMAT`, which only makes such a cache parse again. Documented in + `docs/*/USAGE.md` under Custom fonts. - `PdfFont::$compressionLevel` sets the zlib level for embedded font bodies (default 6, as before). Level 1 compresses a subset two to five times faster and the stream is within a few percent of the level-6 size. + A level outside -1..9 is refused with an `InvalidArgumentException`. ### Changed - Kerning is no longer expanded into glyph pairs. GPOS class-based diff --git a/docs/de/USAGE.md b/docs/de/USAGE.md index e99fc35..f674c6c 100644 --- a/docs/de/USAGE.md +++ b/docs/de/USAGE.md @@ -19,6 +19,7 @@ einen Rundgang oder springen Sie direkt zum benötigten Feature. - [Seitenaufbau und Abschnittsumbrüche](#seitenaufbau-und-abschnittsumbrüche) - [HTML- / CSS-Eingabe](#html---css-eingabe) - [Eigene Schriftarten](#eigene-schriftarten) + - [Schriftarten in Prozessen pro Anfrage](#schriftarten-in-prozessen-pro-anfrage) - [Barcodes](#barcodes) - [Diagramme](#diagramme) - [Mathematische Ausdrücke](#mathematische-ausdrücke) @@ -553,6 +554,67 @@ PDF/A-1 zulässig); die Ausgabe enthält also die volle CFF-Größe CFF2 (variables CFF) werden abgelehnt. Kerning, einfache Ligaturen und ToUnicode-CMaps werden automatisch ausgegeben. +### Schriftarten in Prozessen pro Anfrage + +`TtfFile::fromFile()` hält geparste Schriftarten für die Lebensdauer des +Prozesses vor (bis zu `TtfFile::$cacheLimit` Stück): Ein Queue-Worker oder +Octane parst jede Schriftart nur einmal. Unter PHP-FPM beginnt jede Anfrage +leer und parst ihre Schriftarten erneut — etwa eine Millisekunde für eine +lateinische Schrift, mehrere für eine CJK-Schrift. `exportState()` speichert +eine geparste Schriftart als einfache Daten (Ganzzahlen, Strings, Booleans +und Arrays), und `TtfFile::fromState()` stellt sie aus diesen Daten und +derselben Schriftdatei ohne Parsen wieder her: + +```php +use Dskripchenko\PhpPdf\Font\Ttf\TtfFile; +use Dskripchenko\PhpPdf\Pdf\PdfFont; + +function cachedFont(string $path, string $cacheDir): TtfFile +{ + $bytes = (string) file_get_contents($path); + $file = $cacheDir.'/'.basename($path).'.v'.TtfFile::STATE_FORMAT.'.php'; + + if (is_file($file)) { + try { + return TtfFile::fromState(require $file, $bytes); + } catch (\InvalidArgumentException) { + // andere Schriftdatei oder beschädigter Zustand: unten neu parsen + } + } + + $font = new TtfFile($bytes); + $tmp = $file.'.'.getmypid(); + file_put_contents($tmp, 'exportState(), true).';'); + rename($tmp, $file); + + return $font; +} + +$regular = new PdfFont(cachedFont(__DIR__.'/fonts/DejaVuSans.ttf', __DIR__.'/var/fonts')); +``` + +Ein mit `var_export()` geschriebener und per `require` gelesener Zustand +liegt im gemeinsamen Speicher von OPcache, die Wiederherstellung kostet +also einen Bruchteil des Parsens. JSON funktioniert ebenso; bei +`serialize()` lesen Sie mit `unserialize($s, ['allowed_classes' => false])` +zurück — ein Zustand enthält nie Objekte. + +Der Zustand ist für den eigenen Cache Ihrer Anwendung gedacht: +`fromState()` prüft seine Form, nicht seine Integrität, stellen Sie also nie +einen Zustand aus fremder Quelle wieder her. Einen Zustand mit anderem +`TtfFile::STATE_FORMAT`, von einer anderen Schriftdatei oder einen +beschädigten weist es mit einer `InvalidArgumentException` zurück; dann wird +wie oben neu geparst. Die beiden Methoden sind experimentell: Ein Release, +das das Format ändert, erhöht `STATE_FORMAT`, der Cache verfehlt einmal und +füllt sich neu, sonst bricht nichts. Ist `STATE_FORMAT` wie oben Teil des +Cache-Schlüssels, bleiben Zustände verschiedener Versionen getrennt. + +Eingebettete Schriftdaten werden mit zlib-Stufe 6 komprimiert. Wo Dokumente +pro Anfrage erzeugt werden, komprimiert `PdfFont::$compressionLevel = 1;` +eine Teilmenge zwei- bis fünfmal schneller, bei einem um wenige Prozent +größeren Stream. Werte außerhalb von -1..9 werden mit einer +`InvalidArgumentException` abgelehnt. + --- ## Barcodes diff --git a/docs/en/USAGE.md b/docs/en/USAGE.md index da840b6..b9772f4 100644 --- a/docs/en/USAGE.md +++ b/docs/en/USAGE.md @@ -19,6 +19,7 @@ the feature you need. - [Page setup and section breaks](#page-setup-and-section-breaks) - [HTML / CSS input](#html--css-input) - [Custom fonts](#custom-fonts) + - [Fonts in per-request processes](#fonts-in-per-request-processes) - [Barcodes](#barcodes) - [Charts](#charts) - [Math expressions](#math-expressions) @@ -531,6 +532,64 @@ PDF/A-1 too); expect the full CFF size in the output (100–200 KB for a Latin plus Cyrillic face). CFF2 (variable CFF) fonts are rejected. Kerning, basic ligatures, and ToUnicode CMaps are emitted automatically. +### Fonts in per-request processes + +`TtfFile::fromFile()` keeps parsed fonts for the life of the process +(`TtfFile::$cacheLimit` of them), so a queue worker or Octane parses each +font once. Under PHP-FPM every request starts empty and parses its fonts +again — about a millisecond for a Latin face, several for a CJK one. +`exportState()` saves a parsed font as plain data (ints, strings, bools +and arrays), and `TtfFile::fromState()` restores it from that data and the +same font file without parsing: + +```php +use Dskripchenko\PhpPdf\Font\Ttf\TtfFile; +use Dskripchenko\PhpPdf\Pdf\PdfFont; + +function cachedFont(string $path, string $cacheDir): TtfFile +{ + $bytes = (string) file_get_contents($path); + $file = $cacheDir.'/'.basename($path).'.v'.TtfFile::STATE_FORMAT.'.php'; + + if (is_file($file)) { + try { + return TtfFile::fromState(require $file, $bytes); + } catch (\InvalidArgumentException) { + // another font file or a damaged state: parse again below + } + } + + $font = new TtfFile($bytes); + $tmp = $file.'.'.getmypid(); + file_put_contents($tmp, 'exportState(), true).';'); + rename($tmp, $file); + + return $font; +} + +$regular = new PdfFont(cachedFont(__DIR__.'/fonts/DejaVuSans.ttf', __DIR__.'/var/fonts')); +``` + +A state written with `var_export()` and read back with `require` sits in +OPcache's shared memory, so the restore costs a fraction of a parse. JSON +works too; with `serialize()`, read it back with +`unserialize($s, ['allowed_classes' => false])` — a state never holds +objects. + +The state is for your application's own cache: `fromState()` checks its +shape, not its integrity, so never restore one that came from outside. +It refuses — with an `InvalidArgumentException` — a state of another +`TtfFile::STATE_FORMAT`, of another font file, or a damaged one; fall back +to parsing, as above. The pair is experimental: a release that changes +the layout bumps `STATE_FORMAT`, the cache misses once and fills again, +and nothing else breaks. Keying the cache by `STATE_FORMAT`, as above, +keeps states of different versions apart. + +Embedded font bodies are compressed at zlib level 6. Where documents are +rendered per request, `PdfFont::$compressionLevel = 1;` compresses a +subset two to five times faster for a stream a few percent larger. Values +outside -1..9 are refused with an `InvalidArgumentException`. + --- ## Barcodes diff --git a/docs/ru/USAGE.md b/docs/ru/USAGE.md index 3c506c6..1627999 100644 --- a/docs/ru/USAGE.md +++ b/docs/ru/USAGE.md @@ -19,6 +19,7 @@ - [Настройка страницы и разрывы секций](#настройка-страницы-и-разрывы-секций) - [Вход HTML / CSS](#вход-html--css) - [Пользовательские шрифты](#пользовательские-шрифты) + - [Шрифты в процессах «на запрос»](#шрифты-в-процессах-на-запрос) - [Штрихкоды](#штрихкоды) - [Графики](#графики) - [Математические выражения](#математические-выражения) @@ -547,6 +548,65 @@ OpenType-шрифты на основе CFF (`.otf` с таблицей `CFF `) с CFF2 (вариативный CFF) отклоняются. Кернинг, базовые лигатуры и ToUnicode CMaps эмитятся автоматически. +### Шрифты в процессах «на запрос» + +`TtfFile::fromFile()` держит разобранные шрифты всё время жизни процесса +(не больше `TtfFile::$cacheLimit`), поэтому воркер очереди или Octane +разбирает каждый шрифт один раз. Под PHP-FPM каждый запрос начинает с нуля +и разбирает шрифты заново — около миллисекунды на латинскую гарнитуру и +несколько на CJK. `exportState()` сохраняет разобранный шрифт как простые +данные (целые, строки, булевы значения и массивы), а `TtfFile::fromState()` +восстанавливает его из этих данных и того же файла шрифта без разбора: + +```php +use Dskripchenko\PhpPdf\Font\Ttf\TtfFile; +use Dskripchenko\PhpPdf\Pdf\PdfFont; + +function cachedFont(string $path, string $cacheDir): TtfFile +{ + $bytes = (string) file_get_contents($path); + $file = $cacheDir.'/'.basename($path).'.v'.TtfFile::STATE_FORMAT.'.php'; + + if (is_file($file)) { + try { + return TtfFile::fromState(require $file, $bytes); + } catch (\InvalidArgumentException) { + // другой файл шрифта или повреждённое состояние: разбираем заново ниже + } + } + + $font = new TtfFile($bytes); + $tmp = $file.'.'.getmypid(); + file_put_contents($tmp, 'exportState(), true).';'); + rename($tmp, $file); + + return $font; +} + +$regular = new PdfFont(cachedFont(__DIR__.'/fonts/DejaVuSans.ttf', __DIR__.'/var/fonts')); +``` + +Состояние, записанное через `var_export()` и прочитанное `require`, лежит в +общей памяти OPcache, и восстановление стоит долю разбора. Подойдёт и JSON; +при `serialize()` читайте обратно через +`unserialize($s, ['allowed_classes' => false])` — объектов в состоянии +не бывает. + +Состояние предназначено для собственного кэша приложения: `fromState()` +проверяет его форму, но не целостность, поэтому не восстанавливайте +состояние, пришедшее извне. Состояние другого `TtfFile::STATE_FORMAT`, от +другого файла шрифта или повреждённое отклоняется с +`InvalidArgumentException` — тогда шрифт разбирается заново, как в примере. +Пара методов экспериментальная: релиз, меняющий формат, повышает +`STATE_FORMAT`, кэш один раз промахивается и заполняется снова, больше +ничего не ломается. Если `STATE_FORMAT` входит в ключ кэша, как в примере, +состояния разных версий не смешиваются. + +Тела встроенных шрифтов сжимаются zlib на уровне 6. Там, где документы +строятся на каждый запрос, `PdfFont::$compressionLevel = 1;` сжимает +подмножество в два–пять раз быстрее, а поток выходит больше на несколько +процентов. Значения вне -1..9 отклоняются с `InvalidArgumentException`. + --- ## Штрихкоды diff --git a/docs/zh/USAGE.md b/docs/zh/USAGE.md index 49aa8d2..4cc9619 100644 --- a/docs/zh/USAGE.md +++ b/docs/zh/USAGE.md @@ -18,6 +18,7 @@ HTML 转 PDF 一直深入到最底层的页面发射。每个章节都是自包 - [页面设置与分节符](#页面设置与分节符) - [HTML / CSS 输入](#html--css-输入) - [自定义字体](#自定义字体) + - [按请求运行的进程中的字体](#按请求运行的进程中的字体) - [条形码](#条形码) - [图表](#图表) - [数学公式](#数学公式) @@ -506,6 +507,59 @@ TrueType 轮廓字体(`.ttf`、TrueType 风格的 `.otf`)按需子集化 — CFF2(可变 CFF)字体会被拒绝。 字距调整、基础连字与 ToUnicode CMap 会自动发射。 +### 按请求运行的进程中的字体 + +`TtfFile::fromFile()` 会在整个进程生命周期内保留已解析的字体(最多 +`TtfFile::$cacheLimit` 个),因此队列 worker 或 Octane 对每个字体只解析一次。 +在 PHP-FPM 下,每个请求都从零开始并重新解析字体——拉丁字体约需一毫秒, +CJK 字体需要数毫秒。`exportState()` 将已解析的字体保存为纯数据(整数、 +字符串、布尔值和数组),`TtfFile::fromState()` 则根据这些数据和同一个 +字体文件恢复字体,无需解析: + +```php +use Dskripchenko\PhpPdf\Font\Ttf\TtfFile; +use Dskripchenko\PhpPdf\Pdf\PdfFont; + +function cachedFont(string $path, string $cacheDir): TtfFile +{ + $bytes = (string) file_get_contents($path); + $file = $cacheDir.'/'.basename($path).'.v'.TtfFile::STATE_FORMAT.'.php'; + + if (is_file($file)) { + try { + return TtfFile::fromState(require $file, $bytes); + } catch (\InvalidArgumentException) { + // 其他字体文件或状态已损坏:在下面重新解析 + } + } + + $font = new TtfFile($bytes); + $tmp = $file.'.'.getmypid(); + file_put_contents($tmp, 'exportState(), true).';'); + rename($tmp, $file); + + return $font; +} + +$regular = new PdfFont(cachedFont(__DIR__.'/fonts/DejaVuSans.ttf', __DIR__.'/var/fonts')); +``` + +用 `var_export()` 写出、再用 `require` 读回的状态会驻留在 OPcache 的共享 +内存中,恢复只需解析耗时的一小部分。也可以使用 JSON;若使用 +`serialize()`,请用 `unserialize($s, ['allowed_classes' => false])` 读回—— +状态中从不包含对象。 + +状态仅供应用自己的缓存使用:`fromState()` 只检查其结构,不检查其完整性, +因此切勿恢复来自外部的状态。对于 `TtfFile::STATE_FORMAT` 不同、属于其他 +字体文件或已损坏的状态,它会抛出 `InvalidArgumentException` 拒绝;此时 +按上例重新解析即可。这对方法属于实验性 API:改变格式的版本会提高 +`STATE_FORMAT`,缓存未命中一次后重新填充,不会造成其他破坏。像上例那样 +把 `STATE_FORMAT` 放进缓存键,不同版本的状态就不会混用。 + +嵌入的字体数据以 zlib 6 级压缩。在按请求生成文档的场景中, +`PdfFont::$compressionLevel = 1;` 压缩子集的速度快两到五倍,数据流只大 +几个百分点。-1..9 以外的取值会以 `InvalidArgumentException` 拒绝。 + --- ## 条形码