Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
73.81% covered (warning)
73.81%
31 / 42
28.57% covered (danger)
28.57%
2 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
Rasteriser
73.81% covered (warning)
73.81%
31 / 42
28.57% covered (danger)
28.57%
2 / 7
14.59
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 rasterise
90.48% covered (success)
90.48%
19 / 21
0.00% covered (danger)
0.00%
0 / 1
4.01
 fillAdjustPrologue
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 supportsFillAdjust
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 dpi
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 ghostscriptBinary
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isAvailable
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3declare(strict_types=1);
4
5namespace 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 */
39final 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}