Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
1.56% covered (danger)
1.56%
1 / 64
16.67% covered (danger)
16.67%
1 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
Scorer
1.56% covered (danger)
1.56%
1 / 64
16.67% covered (danger)
16.67%
1 / 6
174.20
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 / 51
0.00% covered (danger)
0.00%
0 / 1
56
 dimensions
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 passThreshold
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 * The default pass threshold `0.01` matches the WPT reftest
21 * convention — small anti-aliasing differences between rendering
22 * engines are expected and tolerated.
23 */
24final class Scorer
25{
26    public function __construct(
27        private readonly float $passThreshold = 0.01,
28        private readonly string $compareBinary = 'compare',
29    ) {}
30
31    /**
32     * Compute the perceptual diff between two image files. Both must
33     * exist; if either is missing the scorer returns `1.0` (max diff)
34     * with a non-zero `$reason`.
35     *
36     * `$maxAllowedPixels` overrides the per-test pass threshold when
37     * the test carries a WPT `<meta name="fuzzy">` annotation — the
38     * upper bound from `totalPixels=lo-hi` is passed straight through.
39     * Anything `<=` that count passes; `null` falls back to the
40     * scorer's configured fractional threshold.
41     *
42     * @return array{score: float, passed: bool, reason: string|null,
43     *               diffImage: string|null}
44     */
45    public function diff(string $renderedPath, string $referencePath, ?int $maxAllowedPixels = null): array
46    {
47        if (!is_file($renderedPath)) {
48            return [
49                'score' => 1.0,
50                'passed' => false,
51                'reason' => "rendered image not found: $renderedPath",
52                'diffImage' => null,
53            ];
54        }
55        if (!is_file($referencePath)) {
56            return [
57                'score' => 1.0,
58                'passed' => false,
59                'reason' => "reference image not found: $referencePath",
60                'diffImage' => null,
61            ];
62        }
63
64        $diffImage = tempnam(sys_get_temp_dir(), 'wpt_diff_') . '.png';
65        // `compare -metric AE` writes the absolute-error pixel count
66        // to stderr. Exit status: 0 = images match (no diff above
67        // fuzz), 1 = images differ, 2 = error.
68        $cmd = sprintf(
69            '%s -metric AE -fuzz 1%% %s %s %s 2>&1',
70            escapeshellcmd($this->compareBinary),
71            escapeshellarg($renderedPath),
72            escapeshellarg($referencePath),
73            escapeshellarg($diffImage),
74        );
75        exec($cmd, $output, $status);
76        if ($status === 2) {
77            $err = implode("\n", $output);
78            return [
79                'score' => 1.0,
80                'passed' => false,
81                'reason' => "compare error: $err",
82                'diffImage' => null,
83            ];
84        }
85        $errorPixels = (int) trim(implode("\n", $output));
86        $dim = self::dimensions($renderedPath);
87        $totalPixels = $dim['w'] * $dim['h'];
88        if ($totalPixels <= 0) {
89            return [
90                'score' => 1.0,
91                'passed' => false,
92                'reason' => 'rendered image has zero area',
93                'diffImage' => null,
94            ];
95        }
96        $score = min(1.0, $errorPixels / $totalPixels);
97        $passed = $maxAllowedPixels !== null
98            ? $errorPixels <= $maxAllowedPixels
99            : $score <= $this->passThreshold;
100        return [
101            'score' => $score,
102            'passed' => $passed,
103            'reason' => null,
104            'diffImage' => is_file($diffImage) ? $diffImage : null,
105        ];
106    }
107
108    /**
109     * Best-effort dimensions read via GD. Falls back to (0, 0) on
110     * error (treated as max-diff by the caller).
111     *
112     * @return array{w: int, h: int}
113     */
114    private static function dimensions(string $path): array
115    {
116        $info = @getimagesize($path);
117        if ($info === false) {
118            return ['w' => 0, 'h' => 0];
119        }
120        return ['w' => $info[0], 'h' => $info[1]];
121    }
122
123    public function passThreshold(): float
124    {
125        return $this->passThreshold;
126    }
127
128    public function compareBinary(): string
129    {
130        return $this->compareBinary;
131    }
132
133    /**
134     * Probe whether the configured `compare` binary is callable.
135     */
136    public function isAvailable(): bool
137    {
138        $cmd = sprintf(
139            '%s --version 2>/dev/null',
140            escapeshellcmd($this->compareBinary),
141        );
142        exec($cmd, $_, $status);
143        return $status === 0;
144    }
145}