From fda75e9af72c0b5b3b2ef2857c8eb5a147b8a89f Mon Sep 17 00:00:00 2001 From: Darko Gjorgjijoski <5760249+gdarko@users.noreply.github.com> Date: Sat, 1 Aug 2026 15:10:00 +0200 Subject: [PATCH] fix(pdf): make dompdf honour declared line-heights (#736) dompdf does not use a declared line-height directly. It scales it by the font's own height: rendered = declared x (ascent + descent) / unitsPerEm x font_height_ratio The bundled Noto Sans reports 1.362 for that middle term, so at dompdf's stock font_height_ratio of 1.1 every line-height in every document came out 1.4985x what the CSS asked for. Chromium honours the declared value exactly. That one factor was the whole vertical disagreement between the two drivers. Setting the ratio to 1/1.362 cancels the font term. Measured on a declared 15px (11.25pt): 16.86pt at the stock 1.1, 15.32pt at 1.0, and 11.25pt at this value -- identical to Chromium. Across the seven document templates the worst-edge ink difference falls from roughly 70-150pt to under 30pt on six of them, and to 3.4pt on invoice1. The exception is estimate1, which moves the other way: with the line-height noise gone, a float and padding difference in its address block is now the dominant term there. That is a separate problem this exposes rather than causes. Worth recording that an earlier compensation shim had arrived at 1.5 empirically and was right: 1.1 x 1.362 = 1.4985. I argued against it on the strength of a test that used font-family: sans-serif, which resolves to a built-in core font and so never exercised the embedded Noto Sans path where the scaling happens. The measurement was wrong, not the constant. PdfLineHeightTest pins the invariant -- the font's reported height equals the font size, so a declared length renders at that length -- and needs no Gotenberg, so CI holds it. Swapping the default face or taking a dompdf upgrade that changes the computation now fails a test rather than quietly reintroducing the drift. Claude-Session: https://claude.ai/code/session_01QmECndmNZwzN65Zz9P87dF --- config/dompdf.php | 19 +++++++++++- tests/Unit/PdfLineHeightTest.php | 53 ++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 tests/Unit/PdfLineHeightTest.php diff --git a/config/dompdf.php b/config/dompdf.php index 9e7bcf43..f53a5833 100644 --- a/config/dompdf.php +++ b/config/dompdf.php @@ -245,8 +245,25 @@ return [ /** * A ratio applied to the fonts height to be more like browsers' line height + * + * dompdf does not use a declared `line-height` directly: it scales it by + * the font's own height, so the rendered line box is + * + * declared × (ascent + descent) / unitsPerEm × font_height_ratio + * + * The bundled Noto Sans reports 1.362 for that middle term, so at the + * stock 1.1 every line-height in every PDF came out 1.4985× what the CSS + * asked for. Chromium honours the declared value exactly, which is where + * the two drivers disagreed vertically on every document. + * + * 1 / 1.362 cancels the font term, so a declared line-height means what + * it says and both drivers agree. Measured: a declared 15px (11.25pt) + * renders at 16.86pt here at 1.1, 15.32pt at 1.0, and 11.25pt at this + * value -- identical to Chromium. Calibrated against Noto Sans, the + * default face; PdfLineHeightTest pins it so a font or dompdf upgrade + * that moves it fails rather than drifting. */ - 'font_height_ratio' => 1.1, + 'font_height_ratio' => 0.7342, /** * Use the more-than-experimental HTML5 Lib parser diff --git a/tests/Unit/PdfLineHeightTest.php b/tests/Unit/PdfLineHeightTest.php new file mode 100644 index 00000000..513775e5 --- /dev/null +++ b/tests/Unit/PdfLineHeightTest.php @@ -0,0 +1,53 @@ +getDomPDF()->getFontMetrics(); + $font = $metrics->getFont('NotoSans', 'normal'); + + expect($font)->not->toBeFalsy('NotoSans should resolve; it is the bundled default face'); + + return $metrics->getFontHeight($font, $size); +} + +test('a declared line-height is rendered at its declared size', function (float $size) { + expect(notoSansHeightAt($size))->toEqualWithDelta($size, 0.01); +})->with([9.0, 12.0, 15.0, 18.0, 24.0]); + +test('the configured ratio is the one that cancels the font metrics', function () { + // Guards the value itself, so a well-meaning "restore the dompdf default" + // shows up as a failing test rather than as documents silently growing. + expect(config('dompdf.defines.font_height_ratio'))->toEqualWithDelta(0.7342, 0.0005); +}); + +/** + * The stock ratio is what the drift looked like: 1.1 × 1.362 ≈ 1.4985, which is + * within a rounding error of the 1.5 that a hand-tuned compensation shim had + * arrived at empirically. + */ +test('dompdf stock ratio would inflate every line by about half again', function () { + $natural = notoSansHeightAt(12.0) / config('dompdf.defines.font_height_ratio'); + + expect($natural * 1.1 / 12.0)->toEqualWithDelta(1.4985, 0.005); +});