From 6f321ebf7ccaac7db9fc7f86378f7ecbf015efaa Mon Sep 17 00:00:00 2001 From: Jake Jackson Date: Tue, 22 Sep 2026 17:20:40 +1000 Subject: [PATCH] Apply the metrics and the collection font a fontdata entry sets to the font it registers (#321) Since 93515f7 the Ascent, Descent and Leading overrides were written to a local $desc that nothing read, while the font was registered with the metrics from the cache. They now go into $font['desc'], as they went into $desc before v7.1.8, so line heights, the baseline and the embedded FontDescriptor follow them again. The cache is not rewritten, so another document reading it gets the font's own metrics. The same commit set TTCfontID to the result of isset(), so every configured font of a TrueType Collection was read as its first. It is the configured index again, and metrics cached for another font of the collection, including the ones cached while every font was read as the first, are regenerated rather than reused. Co-Authored-By: Claude Opus 5 (1M context) --- src/Mpdf.php | 14 +- tests/Mpdf/FontdataMetricOverrideTest.php | 189 ++++++++++++++++++++++ tests/Mpdf/TtcFontIdTest.php | 144 +++++++++++++++++ 3 files changed, 343 insertions(+), 4 deletions(-) create mode 100644 tests/Mpdf/FontdataMetricOverrideTest.php create mode 100644 tests/Mpdf/TtcFontIdTest.php diff --git a/src/Mpdf.php b/src/Mpdf.php index 5f60029d4..b273eca7b 100644 --- a/src/Mpdf.php +++ b/src/Mpdf.php @@ -4039,13 +4039,13 @@ private function registerFont($fontkey, $family, array $font) // Override with values from config_font.php if (isset($this->fontdata[$family]['Ascent']) && $this->fontdata[$family]['Ascent']) { - $desc['Ascent'] = $this->fontdata[$family]['Ascent']; + $font['desc']['Ascent'] = $this->fontdata[$family]['Ascent']; } if (isset($this->fontdata[$family]['Descent']) && $this->fontdata[$family]['Descent']) { - $desc['Descent'] = $this->fontdata[$family]['Descent']; + $font['desc']['Descent'] = $this->fontdata[$family]['Descent']; } if (isset($this->fontdata[$family]['Leading']) && $this->fontdata[$family]['Leading']) { - $desc['Leading'] = $this->fontdata[$family]['Leading']; + $font['desc']['Leading'] = $this->fontdata[$family]['Leading']; } $i = count($this->fonts) + $this->extraFontSubsets + 1; @@ -4173,7 +4173,7 @@ public function fontMetrics($family, $style) $ttffile = $this->fontFileFinder->findFontFile($this->fontdata[$family][$stylekey]); $ttfstat = stat($ttffile); - $TTCfontID = isset($this->fontdata[$family]['TTCfontID'][$stylekey]) ? isset($this->fontdata[$family]['TTCfontID'][$stylekey]) : 0; + $TTCfontID = isset($this->fontdata[$family]['TTCfontID'][$stylekey]) ? $this->fontdata[$family]['TTCfontID'][$stylekey] : 0; $fontUseOTL = isset($this->fontdata[$family]['useOTL']) ? $this->fontdata[$family]['useOTL'] : false; $BMPonly = in_array($family, $this->BMPonly) ? true : false; @@ -4196,6 +4196,12 @@ public function fontMetrics($family, $style) $regenerate = true; } // mPDF 6 + /* The cache may hold another font of the same collection. Compared as integers because caches + * written from v7.1.8 until #321 stored true for every configured TTCfontID, and true == 2 */ + if ((int) (isset($font['TTCfontID']) ? $font['TTCfontID'] : 0) !== (int) $TTCfontID) { + $regenerate = true; + } + // A cache written by a release that laid its files out differently cannot be read by this one if (!MetricsGenerator::isCurrent($font)) { $regenerate = true; diff --git a/tests/Mpdf/FontdataMetricOverrideTest.php b/tests/Mpdf/FontdataMetricOverrideTest.php new file mode 100644 index 000000000..b2dbe30c9 --- /dev/null +++ b/tests/Mpdf/FontdataMetricOverrideTest.php @@ -0,0 +1,189 @@ + 928, 'Descent' => -236, 'Leading' => 0]; + + private static $overrides = ['Ascent' => 1500, 'Descent' => -900, 'Leading' => 400]; + + /** A test's documents share it, so the second one reads the cache the first one wrote */ + private $tempDir; + + /** + * Give each test a cache of its own + */ + protected function set_up() + { + parent::set_up(); + + $this->tempDir = sys_get_temp_dir() . '/mpdf-fontdata-metric-override-' . uniqid('', true); + } + + /** + * Remove the cache the documents wrote + */ + protected function tear_down() + { + /* The directories a Cache makes, innermost first, then the tempDir itself */ + foreach ([$this->tempDir . '/mpdf/ttfontdata', $this->tempDir . '/mpdf', $this->tempDir] as $directory) { + if (!is_dir($directory)) { + continue; + } + + foreach (glob($directory . '/*') as $entry) { + if (is_file($entry)) { + unlink($entry); + } + } + + rmdir($directory); + } + + parent::tear_down(); + } + + /** + * The registered font carries each override, and the font's own value for any metric left alone + * + * @dataProvider overrideProvider + * + * @param array $overrides + */ + public function testOverridesReplaceTheFontMetrics(array $overrides) + { + $mpdf = $this->document($overrides); + + $this->assertSame(array_merge(self::$fontMetrics, $overrides), $this->metrics($mpdf->fonts['dejavusans']['desc'])); + } + + /** + * Each override alone, and all three together + * + * @return array + */ + public function overrideProvider() + { + return [ + 'Ascent' => [['Ascent' => 1500]], + 'Descent' => [['Descent' => -900]], + 'Leading' => [['Leading' => 400]], + 'all three' => [self::$overrides], + ]; + } + + /** + * A normal line is adjustFontDescLineheight times Ascent - Descent + Leading, with the Leading added again as a + * line gap, so the lines of a paragraph move apart by exactly what the overrides imply + */ + public function testOverridesSetTheLineHeight() + { + $plain = $this->document([]); + $overridden = $this->document(self::$overrides); + + $this->assertEqualsWithDelta($this->pitch(self::$fontMetrics, $plain), $this->linePitch($plain), 0.002); + $this->assertEqualsWithDelta($this->pitch(self::$overrides, $overridden), $this->linePitch($overridden), 0.002); + } + + /** + * The embedded font describes itself with the overridden metrics, as it did before they were lost + */ + public function testOverridesReachTheFontDescriptor() + { + $pdf = $this->output($this->document(self::$overrides)); + + $this->assertMatchesRegularExpression('/\/FontDescriptor\s+\/FontName \/\w+\+DejaVuSans\s+[^>]*\/Ascent 1500\s+\/Descent -900\s+\/Leading 400\s/', $pdf); + } + + /** + * A document without overrides that reads the cache a document with them wrote gets the font's own metrics + */ + public function testOverridesStayOutOfTheCache() + { + $this->output($this->document(self::$overrides)); + + $cached = json_decode(file_get_contents($this->tempDir . '/mpdf/ttfontdata/dejavusans.mtx.json'), true); + $this->assertSame(self::$fontMetrics, $this->metrics($cached['desc'])); + + $plain = $this->document([]); + $this->assertSame(self::$fontMetrics, $this->metrics($plain->fonts['dejavusans']['desc'])); + } + + /** + * A document in DejaVu Sans, with $overrides on its fontdata entry, and three lines written + * + * @param array $overrides + * + * @return Mpdf + */ + private function document(array $overrides) + { + $mpdf = new Mpdf([ + 'mode' => 'utf-8', + 'tempDir' => $this->tempDir, + 'default_font' => 'dejavusans', + 'default_font_size' => self::FONT_SIZE, + 'fontdata' => ['dejavusans' => ['R' => 'DejaVuSans.ttf'] + $overrides], + ]); + $mpdf->compress = false; + $mpdf->WriteHTML('

Line one
Line two
Line three

'); + + return $mpdf; + } + + /** + * @param array $desc + * + * @return array Ascent, Descent and Leading from a font's desc, in that order + */ + private function metrics(array $desc) + { + return [ + 'Ascent' => $desc['Ascent'], + 'Descent' => $desc['Descent'], + 'Leading' => $desc['Leading'], + ]; + } + + /** + * @param array $metrics + * @param Mpdf $mpdf + * + * @return float The distance between baselines, in points, that a normal line height gives these metrics + */ + private function pitch(array $metrics, Mpdf $mpdf) + { + $lineHeight = $mpdf->adjustFontDescLineheight * ($metrics['Ascent'] - $metrics['Descent'] + $metrics['Leading']) / 1000; + + return ($lineHeight + $metrics['Leading'] / 1000) * self::FONT_SIZE; + } + + /** + * @param Mpdf $mpdf + * + * @return float The distance between baselines, in points, of the three lines drawn, which must be even + */ + private function linePitch(Mpdf $mpdf) + { + preg_match_all('/BT [\d.]+ ([\d.]+) Td/', $this->output($mpdf), $matches); + $baselines = array_map('floatval', $matches[1]); + + $this->assertCount(3, $baselines); + $this->assertEqualsWithDelta($baselines[0] - $baselines[1], $baselines[1] - $baselines[2], 0.002); + + return $baselines[0] - $baselines[1]; + } + +} diff --git a/tests/Mpdf/TtcFontIdTest.php b/tests/Mpdf/TtcFontIdTest.php new file mode 100644 index 000000000..7b3b102db --- /dev/null +++ b/tests/Mpdf/TtcFontIdTest.php @@ -0,0 +1,144 @@ +tempDir = sys_get_temp_dir() . '/mpdf-ttc-font-id-' . uniqid('', true); + mkdir($this->tempDir); + + $fixtures = __DIR__ . '/../data/ttf/'; + file_put_contents($this->tempDir . '/Pair.ttc', $this->collection([ + file_get_contents($fixtures . 'NotoSans-GPOS3-Synthetic.ttf'), + file_get_contents($fixtures . 'Carlito-MarkAttachmentType-Subset.ttf'), + ])); + } + + /** + * Remove the collection and the cache + */ + protected function tear_down() + { + $files = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($this->tempDir, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST); + foreach ($files as $file) { + $file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname()); + } + rmdir($this->tempDir); + + parent::tear_down(); + } + + /** + * The face registered, and the one embedded, is the one TTCfontID names + * + * @dataProvider faceProvider + * + * @param int $TTCfontID + * @param string $name The PostScript name of that face + */ + public function testTheConfiguredFaceIsTheOneUsed($TTCfontID, $name) + { + $this->assertFace($TTCfontID, $name); + } + + /** + * Each face of the collection, with its PostScript name + * + * @return array + */ + public function faceProvider() + { + return [ + 'the first face' => [1, 'NotoSans-Regular'], + 'the second face' => [2, 'Carlito-Regular'], + ]; + } + + /** + * Metrics cached for one face are not reused for another, which is also what catches a cache written while every + * configured TTCfontID was read as the first face + */ + public function testTheCacheOfAnotherFaceIsNotReused() + { + $this->assertFace(1, 'NotoSans-Regular'); + $this->assertFace(2, 'Carlito-Regular'); + } + + /** + * Render a document in the collection's face $TTCfontID and assert it is the face named $name + * + * @param int $TTCfontID + * @param string $name + */ + private function assertFace($TTCfontID, $name) + { + $mpdf = new Mpdf([ + 'mode' => 'utf-8', + 'tempDir' => $this->tempDir, + 'fontDir' => [$this->tempDir], + 'default_font' => 'pair', + 'fontdata' => ['pair' => ['R' => 'Pair.ttc', 'TTCfontID' => ['R' => $TTCfontID]]], + ]); + $mpdf->compress = false; + $mpdf->WriteHTML('

abc

'); + + $this->assertSame($TTCfontID, $mpdf->fonts['pair']['TTCfontID']); + $this->assertSame($name, $mpdf->fonts['pair']['name']); + $this->assertMatchesRegularExpression('/\/FontDescriptor\s+\/FontName \/[A-Z]{6}\+' . $name . '\s/', $this->output($mpdf)); + } + + /** + * A TrueType Collection of $fonts, each whole font copied in with its table offsets moved to where it now starts. + * Nothing is shared between them, which the format allows but does not require. + * + * @param string[] $fonts + * + * @return string + */ + private function collection(array $fonts) + { + $header = 'ttcf' . TableWriter::uint32(0x00010000) . TableWriter::uint32(count($fonts)); + $start = strlen($header) + 4 * count($fonts); + $body = ''; + + foreach ($fonts as $font) { + $header .= TableWriter::uint32($start); + + $reader = new BlobReader($font); + $reader->seek(4); + $numTables = $reader->readUInt16(); + + for ($i = 0; $i < $numTables; $i++) { + $record = 12 + 16 * $i + 8; + $reader->seek($record); + $font = TableWriter::replace($font, $record, TableWriter::uint32($reader->readUInt32() + $start)); + } + + $font .= str_repeat("\0", (4 - strlen($font) % 4) % 4); + $body .= $font; + $start += strlen($font); + } + + return $header . $body; + } + +}