Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.78% covered (danger)
0.78%
1 / 129
9.09% covered (danger)
9.09%
1 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
Scorer
0.78% covered (danger)
0.78%
1 / 129
9.09% covered (danger)
9.09%
1 / 11
1849.33
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
 diff
0.00% covered (danger)
0.00%
0 / 37
0.00% covered (danger)
0.00%
0 / 1
90
 withSolidEvidence
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 isSolidColour
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
156
 diffAgainstDeclaredTolerance
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
30
 runCompare
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
56
 failure
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 dimensions
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 pixelBudget
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 compareBinary
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 * Perceptual visual diff between a rendered PDF page and a WPT
9 * reference image.
10 *
11 * Returns a normalised score in `[0.0, 1.0]` where `0.0` is
12 * byte-identical and `1.0` is "completely different".
13 *
14 * v1 implementation: shells out to ImageMagick `compare -metric AE`
15 * (absolute-error pixel count), normalises by total pixel count.
16 *
17 * v2 implementation (Phase 4C / 4A.3 follow-up): switch to
18 * `phpdftk/raster` perceptual-diff once it lands.
19 *
20 * Two comparison regimes, and which one applies is decided by the
21 * fixture, not by us:
22 *
23 *  - **Fixture-declared** — the test carries a WPT
24 *    `<meta name="fuzzy">`. WPT then abandons exact matching entirely
25 *    and scores against the two declared tolerances; the harness does
26 *    the same via {@see FuzzyTolerance::permits()}. No extra slack is
27 *    added on top: the fixture author already said how much
28 *    difference is acceptable, and inventing more would overrule
29 *    them.
30 *  - **Harness default** — no annotation. WPT requires a byte-exact
31 *    screenshot match here, and so does this: {@see self::$pixelBudget}
32 *    defaults to zero differing pixels.
33 *
34 * The default used to be a *fraction* of the frame, `0.01`. On the
35 * 816×1056 page the harness rasterised at the time that is 8,616
36 * pixels — a 93×93 square, nearly the whole 100×100 indicator square
37 * WPT tests are built around, so a fixture could be wrong across 86%
38 * of its test square and still pass. Worse, being a fraction, it meant
39 * something different at every page geometry and every rasterisation
40 * resolution; the page is 612×792 now that {@see Rasteriser} samples
41 * one device pixel per CSS pixel, and the same 1% would mean a 69×69
42 * square. Rendered evidence from the corpus, at scores the old
43 * threshold called comfortable passes:
44 *
45 *   -    81px (0.0001) `css-backgrounds/background-origin-004` draws a
46 *                      red line across a box whose own text reads
47 *                      "Test passes if there is no red".
48 *   -    44px (0.0001) `counter-style-at-rule/system-additive-invalid`
49 *                      renders `-2. foo` where the reference renders
50 *                      `:2. foo` — the fallback the test exists to check.
51 *   -   602px (0.0007) `svg/text/reftests/text-transform-002` renders
52 *                      "hello, world!" against a reference reading
53 *                      "Hello, World!" — `text-transform` unimplemented.
54 *   -  2001px (0.0023) `svg/shapes/ellipse-03` renders NOTHING against a
55 *                      reference drawing a blue circle.
56 *
57 * The justification offered for the fraction was anti-aliasing noise
58 * between independently rasterised documents. It does not survive
59 * contact with the pipeline. Both sides are rendered by the same engine
60 * and rasterised by the same Ghostscript at the same DPI (verified
61 * byte-reproducible across runs), and no in-scope fixture in the corpus
62 * has a checked-in PNG reference, so there is no cross-engine
63 * comparison anywhere in the scored set. Identical layout produces
64 * identical pixels; a differing pixel means the two documents drew
65 * different things. The measured distribution agrees — 65% of passes
66 * are already pixel-exact, and the non-exact tail has no noise cluster
67 * to cut above: the smallest non-exact pass in the corpus is 1 pixel,
68 * and real failures start at 10.
69 *
70 * One grain of slack survives, and it is a colour tolerance rather than
71 * a pixel count: the differing-pixel count is taken at ImageMagick's
72 * `-fuzz 1%`, so a channel that rounds to 127 down one code path and
73 * 128 down another is not counted. It cannot hide geometry — a
74 * one-pixel shift of an anti-aliased edge moves channels by far more
75 * than 2/255 — and it is the only remaining divergence from WPT's
76 * byte-exact default.
77 *
78 * @phpstan-type DiffResult array{score: float, passed: bool,
79 *     reason: string|null, diffImage: string|null, bothSolid: bool}
80 */
81final class Scorer
82{
83    /**
84     * Grid step for {@see self::isSolidColour()}'s reject pass. 16px
85     * puts ~3.4k probes on a letter-size page, which rejects a page
86     * that drew anything in well under a millisecond.
87     */
88    private const SOLID_PROBE_STRIDE = 16;
89
90    public function __construct(
91        /**
92         * How many differing pixels a fixture that declares no
93         * tolerance of its own may have and still pass — an absolute
94         * count, so the criterion says the same thing on any page
95         * geometry. Zero is WPT's own default and the evidenced one;
96         * see the class docblock. Callers comparing renders from
97         * *different* engines (the cross-browser oracle) are the ones
98         * with a reason to raise it.
99         */
100        private readonly int $pixelBudget = 0,
101        private readonly string $compareBinary = 'compare',
102    ) {}
103
104    /**
105     * Compute the perceptual diff between two image files. Both must
106     * exist; if either is missing the scorer returns `1.0` (max diff)
107     * with a non-zero `$reason`.
108     *
109     * `$fuzzy` carries the fixture's own `<meta name="fuzzy">`
110     * tolerance when it declared one. It replaces the harness default
111     * threshold outright — see the class docblock.
112     *
113     * `bothSolid` is the evidence flag described on
114     * {@see self::isSolidColour()}: true when the two frames compared
115     * EQUAL *and* neither of them drew anything. It is only evaluated
116     * when they compared equal, because that is the only outcome
117     * blankness can manufacture; frames that differ have already drawn
118     * something different, and the scan is not free. Whether equal
119     * frames are a pass is the caller's question — a `rel=mismatch`
120     * reference makes them a failure.
121     *
122     * @return array{score: float, passed: bool, reason: string|null,
123     *               diffImage: string|null, bothSolid: bool}
124     */
125    public function diff(string $renderedPath, string $referencePath, ?FuzzyTolerance $fuzzy = null): array
126    {
127        if (!is_file($renderedPath)) {
128            return self::failure("rendered image not found: $renderedPath");
129        }
130        if (!is_file($referencePath)) {
131            return self::failure("reference image not found: $referencePath");
132        }
133
134        $dim = self::dimensions($renderedPath);
135        $totalPixels = $dim['w'] * $dim['h'];
136        if ($totalPixels <= 0) {
137            return self::failure('rendered image has zero area');
138        }
139
140        $diffImage = tempnam(sys_get_temp_dir(), 'wpt_diff_') . '.png';
141
142        if ($fuzzy !== null && !$fuzzy->isTrivial()) {
143            return $this->withSolidEvidence(
144                $this->diffAgainstDeclaredTolerance(
145                    $renderedPath,
146                    $referencePath,
147                    $diffImage,
148                    $fuzzy,
149                    $totalPixels,
150                ),
151                $renderedPath,
152                $referencePath,
153            );
154        }
155
156        // Harness default: count pixels outside a 1% colour fuzz and
157        // require them to fit the absolute budget.
158        $metric = $this->runCompare('AE', $renderedPath, $referencePath, $diffImage, 1.0);
159        if ($metric['error'] !== null) {
160            return self::failure($metric['error']);
161        }
162        $errorPixels = (int) round($metric['raw']);
163        $passed = $errorPixels <= $this->pixelBudget;
164
165        return $this->withSolidEvidence([
166            // The score stays a fraction of the frame: it is what the
167            // ledger sorts and trends on, and it is not the criterion.
168            'score' => min(1.0, $errorPixels / $totalPixels),
169            'passed' => $passed,
170            'reason' => $passed ? null : sprintf(
171                '%d pixels differ; budget is %d',
172                $errorPixels,
173                $this->pixelBudget,
174            ),
175            'diffImage' => is_file($diffImage) ? $diffImage : null,
176            'bothSolid' => false,
177        ], $renderedPath, $referencePath);
178    }
179
180    /**
181     * Fill in the `bothSolid` evidence flag for a finished
182     * comparison.
183     *
184     * Only a *passing* comparison is probed. On a fail the verdict
185     * already says the renders disagree, and the probe costs a full
186     * pixel scan of both frames.
187     *
188     * @param array{score: float, passed: bool, reason: string|null,
189     *              diffImage: string|null, bothSolid: bool} $result
190     * @return array{score: float, passed: bool, reason: string|null,
191     *               diffImage: string|null, bothSolid: bool}
192     */
193    private function withSolidEvidence(array $result, string $renderedPath, string $referencePath): array
194    {
195        if (!$result['passed']) {
196            return $result;
197        }
198        $result['bothSolid'] = self::isSolidColour($renderedPath)
199            && self::isSolidColour($referencePath);
200
201        return $result;
202    }
203
204    /**
205     * Does this image consist of exactly one colour?
206     *
207     * This is WPT's own `check_if_solid_color` criterion
208     * (`executors/base.py`), which reads the per-channel extrema of
209     * the RGB image and reports the screenshot as solid when every
210     * channel's min equals its max. Alpha is excluded because WPT
211     * converts to `RGB` before looking.
212     *
213     * WPT only ever *logs* it, because a browser comparing one
214     * document against another cannot produce two blank screenshots
215     * by accident. This harness can: it renders the test AND the
216     * reference with the same engine, so any feature the engine does
217     * not implement is absent from both sides and the two blank pages
218     * match perfectly. Such a pass carries no evidence that anything
219     * was rendered correctly — it only shows the two documents agree
220     * about drawing nothing.
221     *
222     * Returns false for an unreadable image: the caller is counting
223     * evidence, and no evidence of blankness is not evidence of
224     * blankness.
225     *
226     * Implementation note: the coarse grid pass exists only for
227     * speed. A sampled pixel that differs is a real difference, so
228     * the grid can reject but never confirm; anything that survives
229     * it is settled by the exhaustive scan.
230     */
231    public static function isSolidColour(string $path): bool
232    {
233        $image = @imagecreatefrompng($path);
234        if ($image === false) {
235            return false;
236        }
237        try {
238            // A palette image's `imagecolorat` returns an index, and
239            // two indices can name the same colour — promote so the
240            // comparison is over actual colour values.
241            if (!imageistruecolor($image) && !imagepalettetotruecolor($image)) {
242                return false;
243            }
244            $width = imagesx($image);
245            $height = imagesy($image);
246            if ($width < 1 || $height < 1) {
247                return false;
248            }
249            $first = imagecolorat($image, 0, 0) & 0xFFFFFF;
250            for ($y = 0; $y < $height; $y += self::SOLID_PROBE_STRIDE) {
251                for ($x = 0; $x < $width; $x += self::SOLID_PROBE_STRIDE) {
252                    if ((imagecolorat($image, $x, $y) & 0xFFFFFF) !== $first) {
253                        return false;
254                    }
255                }
256            }
257            for ($y = 0; $y < $height; $y++) {
258                for ($x = 0; $x < $width; $x++) {
259                    if ((imagecolorat($image, $x, $y) & 0xFFFFFF) !== $first) {
260                        return false;
261                    }
262                }
263            }
264
265            return true;
266        } finally {
267            imagedestroy($image);
268        }
269    }
270
271    /**
272     * WPT's fuzzy comparison, which needs two measurements that no
273     * single ImageMagick metric provides:
274     *
275     *  - `pixels_different` — pixels differing *at all*. That is
276     *    `-metric AE` with **zero** fuzz. Running it at the harness's
277     *    1% default, as this class used to, silently forgives every
278     *    difference below ~2.5/255 before the fixture's own tolerance
279     *    is even consulted.
280     *  - `max_per_channel` — the largest single-channel absolute
281     *    difference, i.e. `-metric PAE`, denormalised to 0-255.
282     *
283     * (`get_differences` in wptrunner computes the pair with PIL:
284     * `ImageChops.difference` then `ImageStat.Stat(...).extrema` /
285     * `.count`.)
286     *
287     * @return array{score: float, passed: bool, reason: string|null,
288     *               diffImage: string|null, bothSolid: bool}
289     */
290    private function diffAgainstDeclaredTolerance(
291        string $renderedPath,
292        string $referencePath,
293        string $diffImage,
294        FuzzyTolerance $fuzzy,
295        int $totalPixels,
296    ): array {
297        $absolute = $this->runCompare('AE', $renderedPath, $referencePath, $diffImage, 0.0);
298        if ($absolute['error'] !== null) {
299            return self::failure($absolute['error']);
300        }
301        $peak = $this->runCompare('PAE', $renderedPath, $referencePath, null, 0.0);
302        if ($peak['error'] !== null) {
303            return self::failure($peak['error']);
304        }
305
306        $pixelsDifferent = (int) round($absolute['raw']);
307        // PAE's parenthesised figure is the quantum-independent
308        // normalised peak; WPT's per-channel extrema are 8-bit.
309        $maxPerChannel = (int) round($peak['normalised'] * 255.0);
310        $passed = $fuzzy->permits($maxPerChannel, $pixelsDifferent);
311
312        return [
313            'score' => min(1.0, $pixelsDifferent / $totalPixels),
314            'passed' => $passed,
315            'reason' => $passed ? null : sprintf(
316                'outside declared fuzzy tolerance (%s): maxDifference=%d, totalPixels=%d',
317                $fuzzy->describe(),
318                $maxPerChannel,
319                $pixelsDifferent,
320            ),
321            'diffImage' => is_file($diffImage) ? $diffImage : null,
322            'bothSolid' => false,
323        ];
324    }
325
326    /**
327     * Run ImageMagick `compare` for one metric.
328     *
329     * `compare` writes `<raw> (<normalised>)` to stderr and exits 0
330     * when the images match within fuzz, 1 when they differ, 2 on a
331     * hard error.
332     *
333     * @return array{raw: float, normalised: float, error: string|null}
334     */
335    private function runCompare(
336        string $metric,
337        string $renderedPath,
338        string $referencePath,
339        ?string $diffImage,
340        float $fuzzPercent,
341    ): array {
342        $cmd = sprintf(
343            '%s -metric %s -fuzz %s%% %s %s %s 2>&1',
344            escapeshellcmd($this->compareBinary),
345            escapeshellarg($metric),
346            escapeshellarg(rtrim(rtrim(sprintf('%.4F', $fuzzPercent), '0'), '.') ?: '0'),
347            escapeshellarg($renderedPath),
348            escapeshellarg($referencePath),
349            escapeshellarg($diffImage ?? 'null:'),
350        );
351        exec($cmd, $output, $status);
352        $text = trim(implode("\n", $output));
353        if ($status === 2) {
354            return ['raw' => 0.0, 'normalised' => 0.0, 'error' => "compare error: $text"];
355        }
356        // The metric is the last line; ImageMagick may print warnings
357        // ahead of it, and those contain digits too.
358        $number = '-?[0-9.]+(?:e[+-]?\\d+)?';
359        foreach (array_reverse($output) as $line) {
360            $line = trim($line);
361            if ($line === '') {
362                continue;
363            }
364            if (preg_match("~^($number)(?:\\s*\\(($number)\\))?$~i", $line, $m) !== 1) {
365                continue;
366            }
367            return [
368                'raw' => (float) $m[1],
369                'normalised' => isset($m[2]) ? (float) $m[2] : 0.0,
370                'error' => null,
371            ];
372        }
373        return ['raw' => 0.0, 'normalised' => 0.0, 'error' => "unparsable compare output: $text"];
374    }
375
376    /**
377     * @return array{score: float, passed: bool, reason: string,
378     *               diffImage: null, bothSolid: bool}
379     */
380    private static function failure(string $reason): array
381    {
382        return [
383            'score' => 1.0,
384            'passed' => false,
385            'reason' => $reason,
386            'diffImage' => null,
387            'bothSolid' => false,
388        ];
389    }
390
391    /**
392     * Best-effort dimensions read via GD. Falls back to (0, 0) on
393     * error (treated as max-diff by the caller).
394     *
395     * @return array{w: int, h: int}
396     */
397    private static function dimensions(string $path): array
398    {
399        $info = @getimagesize($path);
400        if ($info === false) {
401            return ['w' => 0, 'h' => 0];
402        }
403        return ['w' => $info[0], 'h' => $info[1]];
404    }
405
406    public function pixelBudget(): int
407    {
408        return $this->pixelBudget;
409    }
410
411    public function compareBinary(): string
412    {
413        return $this->compareBinary;
414    }
415
416    /**
417     * Probe whether the configured `compare` binary is callable.
418     */
419    public function isAvailable(): bool
420    {
421        $cmd = sprintf(
422            '%s --version 2>/dev/null',
423            escapeshellcmd($this->compareBinary),
424        );
425        exec($cmd, $_, $status);
426        return $status === 0;
427    }
428}