diff --git a/CHANGELOG.md b/CHANGELOG.md index 19011ab..dac2be6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,58 @@ 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 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, 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 + 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. +- `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 + 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 + (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. + ### 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. - Embedded TrueType subsets were named after the whole font. Their `/BaseFont` and `/FontName` now start with a six-letter tag (ISO 32000-1 §9.6.4), taken from a hash of the subset's bytes, so two 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` 拒绝。 + --- ## 条形码 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..c2ca16c 100644 --- a/src/Font/Ttf/KerningTable.php +++ b/src/Font/Ttf/KerningTable.php @@ -17,31 +17,140 @@ * - 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; + } + // 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', []]; + $last = array_key_last($this->layers); + } + $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; } - if (! isset($this->pairs[$leftGid][$rightGid])) { - $this->pairCount++; + $this->layers[] = ['classes', $coverage, $firstClasses, $secondClasses, $class2Count, $matrix]; + $this->changed(); + } + + /** + * 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 + { + return $this->layers; + } + + /** + * Restores a table from exportState() of a trusted cache. + * + * @experimental As TtfFile::fromState(). + * + * @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.'); } - $this->pairs[$leftGid][$rightGid] = $xAdvanceFu; + + return $table; } /** @@ -50,16 +159,145 @@ 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. + * + * 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 { - 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/src/Font/Ttf/TtfFile.php b/src/Font/Ttf/TtfFile.php index c0229d2..ce52128 100644 --- a/src/Font/Ttf/TtfFile.php +++ b/src/Font/Ttf/TtfFile.php @@ -149,6 +149,231 @@ 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. A bump is not a breaking change: the cache misses once and + * is filled again from a parse. + * + * @experimental + */ + public const STATE_FORMAT = 1; + + /** + * 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. + * + * 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 + { + 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. + * + * 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 + * 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::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::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::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::stateStringMap($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."); + } + + /** + * 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; @@ -657,16 +882,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 +1065,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(); - } - $this->reader->skip(2); // reservedPad - - $startCode = []; - for ($i = 0; $i < $segCount; $i++) { - $startCode[$i] = $this->reader->readUInt16(); + $segCount = $this->reader->peekUInt16($subtableOffset + 6) >> 1; + if ($segCount === 0) { + return []; } - - $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 +1137,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/src/Font/Ttf/TtfSubsetter.php b/src/Font/Ttf/TtfSubsetter.php index be5ce8c..f30aaf7 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; } /** @@ -491,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; } } diff --git a/src/Pdf/PdfFont.php b/src/Pdf/PdfFont.php index d1b2d35..9d570dc 100644 --- a/src/Pdf/PdfFont.php +++ b/src/Pdf/PdfFont.php @@ -152,6 +152,16 @@ public function reset(): void */ public static int $compressedCacheLimit = 24; + /** + * 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; + /** * Compresses a font body, remembering the result. * @@ -166,7 +176,16 @@ public function reset(): void */ private static function compress(string $fontBytes): string { - $key = hash('xxh3', $fontBytes); + // 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])) { return self::$compressedCache[$key]; @@ -178,7 +197,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/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..5298799 100644 --- a/tests/Font/Ttf/KerningTableTest.php +++ b/tests/Font/Ttf/KerningTableTest.php @@ -62,4 +62,121 @@ 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()); + } + + #[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)); + } } diff --git a/tests/Font/Ttf/TtfFileStateTest.php b/tests/Font/Ttf/TtfFileStateTest.php new file mode 100644 index 0000000..994e0ab --- /dev/null +++ b/tests/Font/Ttf/TtfFileStateTest.php @@ -0,0 +1,195 @@ +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, + '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 { + 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 one of the lists below, and bump STATE_FORMAT. + $state = (new TtfFile($this->font('liberation-fonts-ttf-2.1.5/LiberationSans-Regular.ttf')))->exportState(); + // 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) { + 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()); + } +} 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)); + } } 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'); diff --git a/tests/Pdf/PdfFontCompressionCacheTest.php b/tests/Pdf/PdfFontCompressionCacheTest.php index 1cd6898..b3b2eb8 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,35 @@ 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)); + } + + #[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'); + } }