Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
0.78% |
1 / 129 |
|
9.09% |
1 / 11 |
CRAP | |
0.00% |
0 / 1 |
| Scorer | |
0.78% |
1 / 129 |
|
9.09% |
1 / 11 |
1849.33 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| diff | |
0.00% |
0 / 37 |
|
0.00% |
0 / 1 |
90 | |||
| withSolidEvidence | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
12 | |||
| isSolidColour | |
0.00% |
0 / 20 |
|
0.00% |
0 / 1 |
156 | |||
| diffAgainstDeclaredTolerance | |
0.00% |
0 / 21 |
|
0.00% |
0 / 1 |
30 | |||
| runCompare | |
0.00% |
0 / 26 |
|
0.00% |
0 / 1 |
56 | |||
| failure | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
2 | |||
| dimensions | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
| pixelBudget | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| compareBinary | |
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 | * 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 | */ |
| 81 | final 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 | } |