Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
73.81% |
31 / 42 |
|
28.57% |
2 / 7 |
CRAP | |
0.00% |
0 / 1 |
| Rasteriser | |
73.81% |
31 / 42 |
|
28.57% |
2 / 7 |
14.59 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| rasterise | |
90.48% |
19 / 21 |
|
0.00% |
0 / 1 |
4.01 | |||
| fillAdjustPrologue | |
66.67% |
2 / 3 |
|
0.00% |
0 / 1 |
2.15 | |||
| supportsFillAdjust | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
2 | |||
| dpi | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| ghostscriptBinary | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| isAvailable | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
2 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace Phpdftk\WptHarness; |
| 6 | |
| 7 | /** |
| 8 | * Renders a PDF to a PNG file for downstream perceptual diffing. |
| 9 | * |
| 10 | * v1 implementation: shells out to Ghostscript with `-sDEVICE=png16m` |
| 11 | * at the configured DPI. One PNG per page; this method returns the |
| 12 | * path for `$pageIndex`. |
| 13 | * |
| 14 | * The default is **72 dpi**, which makes one device pixel one PDF |
| 15 | * point and — because the renderer emits 1 CSS px as 1 pt — one CSS |
| 16 | * pixel. That is the geometry WPT reftests are written against, where |
| 17 | * the browser runs at one device pixel per CSS pixel. |
| 18 | * |
| 19 | * Sampling denser than that looks free, because a reftest compares two |
| 20 | * renders that scale together, but it is not: a fixture's |
| 21 | * `<meta name="fuzzy">` `totalPixels` bound is an ABSOLUTE count of |
| 22 | * differing pixels, chosen by its author against a 1:1 browser. The |
| 23 | * harness used to rasterise at 96 dpi, where a CSS pixel covers |
| 24 | * (96/72)^2 = 1.78 device pixels, so every declared budget was being |
| 25 | * asked to cover 1.78x the pixels it was written for and fixtures |
| 26 | * failed on arithmetic rather than on rendering. |
| 27 | * |
| 28 | * Resolution decides more than the budgets, because it decides which |
| 29 | * coordinates are fractional. At 96 dpi an integer CSS coordinate |
| 30 | * lands on a device pixel boundary only when it is a multiple of |
| 31 | * three; at 72 dpi it always does, which keeps whole-pixel CSS |
| 32 | * geometry off the rounding boundary entirely. See |
| 33 | * {@see self::fillAdjustPrologue()} for what happens at that boundary. |
| 34 | * |
| 35 | * v2 implementation (Phase 4C): swap to `phpdftk/raster` so the |
| 36 | * harness has zero external dependencies. Until then the gs path |
| 37 | * is the canonical implementation. |
| 38 | */ |
| 39 | final class Rasteriser |
| 40 | { |
| 41 | /** |
| 42 | * Sets Ghostscript's fill adjustment to zero in both axes. |
| 43 | * See {@see self::fillAdjustPrologue()}. |
| 44 | */ |
| 45 | private const FILL_ADJUST = '0 0 .setfilladjust2'; |
| 46 | |
| 47 | /** |
| 48 | * Memoised {@see self::supportsFillAdjust()} probe, keyed by |
| 49 | * binary — one `gs` spawn per process rather than per page. |
| 50 | * |
| 51 | * @var array<string, bool> |
| 52 | */ |
| 53 | private static array $fillAdjustSupport = []; |
| 54 | |
| 55 | public function __construct( |
| 56 | private readonly int $dpi = 72, |
| 57 | private readonly string $ghostscriptBinary = 'gs', |
| 58 | ) {} |
| 59 | |
| 60 | /** |
| 61 | * Rasterise `$pdfPath` to a temp PNG. Returns the path. Caller |
| 62 | * is responsible for unlinking the result. |
| 63 | * |
| 64 | * `$pageIndex` is 0-based; Ghostscript's `-dFirstPage` / |
| 65 | * `-dLastPage` are 1-based so we add 1 internally. |
| 66 | */ |
| 67 | public function rasterise(string $pdfPath, int $pageIndex = 0): string |
| 68 | { |
| 69 | if (!is_file($pdfPath)) { |
| 70 | throw new \RuntimeException("PDF not found: $pdfPath"); |
| 71 | } |
| 72 | $outPath = tempnam(sys_get_temp_dir(), 'wpt_render_') . '.png'; |
| 73 | $page = $pageIndex + 1; |
| 74 | $cmd = sprintf( |
| 75 | '%s -dNOPAUSE -dBATCH -dQUIET -sDEVICE=png16m ' |
| 76 | . '-r%d -dFirstPage=%d -dLastPage=%d ' |
| 77 | . '-sOutputFile=%s %s%s 2>&1', |
| 78 | escapeshellcmd($this->ghostscriptBinary), |
| 79 | $this->dpi, |
| 80 | $page, |
| 81 | $page, |
| 82 | escapeshellarg($outPath), |
| 83 | $this->fillAdjustPrologue(), |
| 84 | escapeshellarg($pdfPath), |
| 85 | ); |
| 86 | exec($cmd, $output, $status); |
| 87 | if ($status !== 0 || !is_file($outPath)) { |
| 88 | $err = implode("\n", $output); |
| 89 | throw new \RuntimeException("Ghostscript rasterise failed (exit $status): $err"); |
| 90 | } |
| 91 | return $outPath; |
| 92 | } |
| 93 | |
| 94 | /** |
| 95 | * `-c '<prologue>' -f` clause that puts path fills on the same |
| 96 | * rounding rule as every other paint path, or `''` on a |
| 97 | * Ghostscript build that cannot do it. |
| 98 | * |
| 99 | * Ghostscript expands each path fill by half a device pixel |
| 100 | * before scan-converting it, so `re ... f` paints every pixel the |
| 101 | * fill touches at all. Shadings, image XObjects and glyphs are |
| 102 | * resolved at the pixel CENTRE instead. An edge landing in the |
| 103 | * upper half of a pixel therefore belongs to one device column |
| 104 | * when a rectangle draws it and the next column right when a |
| 105 | * gradient, an image or a glyph draws it — the renderer itself |
| 106 | * rounds nothing, emitting both at full precision, so this is the |
| 107 | * pipeline's only rounding decision and it was being taken two |
| 108 | * different ways. |
| 109 | * |
| 110 | * A reftest reaches the same picture down two paint paths by |
| 111 | * construction (a gradient with a hard stop against the solid |
| 112 | * rectangles of its reference, say), so the disagreement shows up |
| 113 | * as a whole-colour seam one pixel wide and fails an exact-match |
| 114 | * comparison. Zeroing the adjustment leaves one rule — pixel |
| 115 | * centre — for the whole pipeline. |
| 116 | * |
| 117 | * The cost is dropout: a fill thinner than a device pixel that |
| 118 | * clears every pixel centre now paints nothing, where the |
| 119 | * adjustment would have widened it to a full pixel. Anything one |
| 120 | * CSS pixel or wider is unaffected at 72 dpi, since one CSS pixel |
| 121 | * is one device pixel and always covers exactly one centre. |
| 122 | */ |
| 123 | private function fillAdjustPrologue(): string |
| 124 | { |
| 125 | if (!self::supportsFillAdjust($this->ghostscriptBinary)) { |
| 126 | return ''; |
| 127 | } |
| 128 | return '-c ' . escapeshellarg(self::FILL_ADJUST) . ' -f '; |
| 129 | } |
| 130 | |
| 131 | /** |
| 132 | * Whether `$binary` understands {@see self::FILL_ADJUST}. |
| 133 | * |
| 134 | * It is a Ghostscript internal operator, not a documented switch: |
| 135 | * PostScript's `setfilladjust` was dropped in Ghostscript 10 and |
| 136 | * `-dFILLADJUST` has never existed (it parses, and does nothing). |
| 137 | * So probe once per binary per process and degrade to the default |
| 138 | * behaviour rather than failing every rasterise on a build that |
| 139 | * lacks it. |
| 140 | */ |
| 141 | private static function supportsFillAdjust(string $binary): bool |
| 142 | { |
| 143 | if (!isset(self::$fillAdjustSupport[$binary])) { |
| 144 | $cmd = sprintf( |
| 145 | '%s -dNOPAUSE -dBATCH -dQUIET -dNODISPLAY -c %s >/dev/null 2>&1', |
| 146 | escapeshellcmd($binary), |
| 147 | escapeshellarg(self::FILL_ADJUST . ' quit'), |
| 148 | ); |
| 149 | exec($cmd, $_, $status); |
| 150 | self::$fillAdjustSupport[$binary] = $status === 0; |
| 151 | } |
| 152 | return self::$fillAdjustSupport[$binary]; |
| 153 | } |
| 154 | |
| 155 | public function dpi(): int |
| 156 | { |
| 157 | return $this->dpi; |
| 158 | } |
| 159 | |
| 160 | public function ghostscriptBinary(): string |
| 161 | { |
| 162 | return $this->ghostscriptBinary; |
| 163 | } |
| 164 | |
| 165 | /** |
| 166 | * Probe whether the configured Ghostscript binary is callable. |
| 167 | * Used by the harness CLI to fail fast with a useful message |
| 168 | * when the substrate isn't installed. |
| 169 | */ |
| 170 | public function isAvailable(): bool |
| 171 | { |
| 172 | $cmd = sprintf( |
| 173 | '%s --version 2>/dev/null', |
| 174 | escapeshellcmd($this->ghostscriptBinary), |
| 175 | ); |
| 176 | exec($cmd, $_, $status); |
| 177 | return $status === 0; |
| 178 | } |
| 179 | } |