Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
52.89% |
229 / 433 |
|
15.62% |
5 / 32 |
CRAP | |
0.00% |
0 / 1 |
| HarnessRunner | |
52.89% |
229 / 433 |
|
15.62% |
5 / 32 |
3047.66 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| harnessDefaultFont | |
52.94% |
9 / 17 |
|
0.00% |
0 / 1 |
20.42 | |||
| run | |
90.91% |
10 / 11 |
|
0.00% |
0 / 1 |
6.03 | |||
| runOne | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
2 | |||
| renderDiffArtefacts | |
0.00% |
0 / 15 |
|
0.00% |
0 / 1 |
110 | |||
| renderOneArtefact | |
0.00% |
0 / 50 |
|
0.00% |
0 / 1 |
156 | |||
| runRendered | |
33.33% |
25 / 75 |
|
0.00% |
0 / 1 |
72.07 | |||
| compareAgainst | |
0.00% |
0 / 17 |
|
0.00% |
0 / 1 |
20 | |||
| fromVerdict | |
0.00% |
0 / 12 |
|
0.00% |
0 / 1 |
20 | |||
| unsatisfiedReason | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
6 | |||
| referenceRenderFailed | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
2 | |||
| discard | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
12 | |||
| relativeToRoot | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
12 | |||
| fuzzyFor | |
90.48% |
19 / 21 |
|
0.00% |
0 / 1 |
10.09 | |||
| resolveTestFile | |
80.00% |
4 / 5 |
|
0.00% |
0 / 1 |
3.07 | |||
| references | |
100.00% |
17 / 17 |
|
100.00% |
1 / 1 |
5 | |||
| locateReference | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
3 | |||
| declaredReftestReferences | |
88.24% |
15 / 17 |
|
0.00% |
0 / 1 |
9.13 | |||
| resolveHref | |
88.89% |
8 / 9 |
|
0.00% |
0 / 1 |
5.03 | |||
| elementsNamed | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 | |||
| attributeValue | |
80.00% |
4 / 5 |
|
0.00% |
0 / 1 |
2.03 | |||
| renderToPng | |
87.50% |
7 / 8 |
|
0.00% |
0 / 1 |
2.01 | |||
| renderHtmlToPdf | |
77.27% |
17 / 22 |
|
0.00% |
0 / 1 |
6.42 | |||
| renderSvgToPdf | |
90.91% |
20 / 22 |
|
0.00% |
0 / 1 |
5.02 | |||
| svgRootViewport | |
94.12% |
16 / 17 |
|
0.00% |
0 / 1 |
14.04 | |||
| parseSvgRootLength | |
70.00% |
14 / 20 |
|
0.00% |
0 / 1 |
17.56 | |||
| manifest | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| rasteriser | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| scorer | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| wptRoot | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| discoverTests | |
85.71% |
12 / 14 |
|
0.00% |
0 / 1 |
6.10 | |||
| testIdFromPath | |
84.62% |
11 / 13 |
|
0.00% |
0 / 1 |
5.09 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace Phpdftk\WptHarness; |
| 6 | |
| 7 | /** |
| 8 | * End-to-end WPT test runner — walks the corpus under `$wptRoot`, |
| 9 | * classifies each test via {@see Manifest}, renders the in-scope |
| 10 | * tests through `phpdftk/html-to-pdf` (or `phpdftk/svg-to-pdf` for |
| 11 | * SVG tests), rasterises via {@see Rasteriser}, diffs via |
| 12 | * {@see Scorer}, and emits a per-test {@see TestResult} ledger. |
| 13 | * |
| 14 | * Phase 4A.1 implements the walker + classification path. The |
| 15 | * actual rendering / rasterisation / scoring lands in 4A.2 + 4A.3; |
| 16 | * until those ship, in-scope tests are marked `Skipped` with a |
| 17 | * reason explaining the missing substrate so the dashboard can |
| 18 | * communicate progress accurately. |
| 19 | * |
| 20 | * Test discovery: recursive glob for HTML / XHT / SVG files under |
| 21 | * `$wptRoot`. Files matching `*-ref.*` are treated as reference |
| 22 | * renderings (skipped — they're the expected output for some |
| 23 | * other test, not a test themselves). |
| 24 | * |
| 25 | * @phpstan-import-type DiffResult from Scorer |
| 26 | */ |
| 27 | final class HarnessRunner |
| 28 | { |
| 29 | /** @var list<string> File extensions recognised as test files. */ |
| 30 | private const TEST_EXTENSIONS = ['html', 'xht', 'xhtml', 'htm', 'svg']; |
| 31 | |
| 32 | /** |
| 33 | * How much of a fixture to scan for `<link rel=match>` and |
| 34 | * `<meta name=fuzzy>` declarations. WPT parses the whole |
| 35 | * document; a bounded read keeps a pathological fixture from |
| 36 | * stalling the harness. Verified against the full corpus: no |
| 37 | * fixture declares either past this offset. |
| 38 | */ |
| 39 | private const MARKUP_SCAN_BYTES = 64 * 1024; |
| 40 | |
| 41 | public function __construct( |
| 42 | private readonly Manifest $manifest, |
| 43 | private readonly Rasteriser $rasteriser, |
| 44 | private readonly Scorer $scorer, |
| 45 | private readonly string $wptRoot, |
| 46 | /** |
| 47 | * Optional DOM settler. When present, fixtures carrying |
| 48 | * `class="reftest-wait"` are shelled through Playwright to |
| 49 | * settle their JavaScript before the PHP renderer sees them. |
| 50 | * Null skips settling entirely (the legacy behaviour) and |
| 51 | * lets tests with unrun setup-JS reflect their pre-settled |
| 52 | * state, matching how the harness behaved before this hook |
| 53 | * was added. |
| 54 | */ |
| 55 | private readonly ?DomSettler $domSettler = null, |
| 56 | ) {} |
| 57 | |
| 58 | /** |
| 59 | * Bundled UA default font for text emission. Browsers render unstyled |
| 60 | * text in their default serif (Times), so the harness ships a real |
| 61 | * serif (DejaVu Serif) as the default `font-family` fallback — without |
| 62 | * it, text has zero advance/height and text-dependent reftests pass |
| 63 | * BLANK (test and reference both empty), silently hiding real |
| 64 | * text-layout gaps. Test AND reference render with the same font, so |
| 65 | * the comparison stays self-consistent; refs that hard-code Times pixel |
| 66 | * metrics use Ahem (loaded from the corpus) and are unaffected. |
| 67 | * |
| 68 | * Override the file via the `WPT_DEFAULT_FONT` env var (a .ttf/.otf/ |
| 69 | * .woff path); set it to `none` to run font-less (the legacy mode). |
| 70 | * `false` = not yet resolved; `null` = resolved to "no font". |
| 71 | */ |
| 72 | private \Phpdftk\FontParser\FontFaceData|null|false $harnessDefaultFont = false; |
| 73 | |
| 74 | private function harnessDefaultFont(): ?\Phpdftk\FontParser\FontFaceData |
| 75 | { |
| 76 | if ($this->harnessDefaultFont !== false) { |
| 77 | return $this->harnessDefaultFont; |
| 78 | } |
| 79 | $env = getenv('WPT_DEFAULT_FONT'); |
| 80 | if ($env === 'none') { |
| 81 | return $this->harnessDefaultFont = null; |
| 82 | } |
| 83 | $path = ($env !== false && $env !== '') |
| 84 | ? $env |
| 85 | : __DIR__ . '/../resources/fonts/DejaVuSerif.ttf'; |
| 86 | if (!is_file($path)) { |
| 87 | return $this->harnessDefaultFont = null; |
| 88 | } |
| 89 | // Dispatch by container: .otf (CFF) / .woff / .woff2 have dedicated |
| 90 | // parsers; .ttf (TrueType glyf) is the default. |
| 91 | $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION)); |
| 92 | try { |
| 93 | return $this->harnessDefaultFont = match ($ext) { |
| 94 | 'otf' => (new \Phpdftk\FontParser\OpenTypeParser($path))->parse(), |
| 95 | 'woff' => (new \Phpdftk\FontParser\WoffParser($path))->parse(), |
| 96 | default => (new \Phpdftk\FontParser\TrueTypeParser($path))->parse(), |
| 97 | }; |
| 98 | } catch (\Throwable) { |
| 99 | return $this->harnessDefaultFont = null; |
| 100 | } |
| 101 | } |
| 102 | |
| 103 | /** |
| 104 | * Run the harness corpus. Returns one {@see TestResult} per |
| 105 | * test that was either rendered or classified. |
| 106 | * |
| 107 | * `$filter` accepts the same glob syntax as the manifest rule |
| 108 | * files (`*` matches within a segment, `**` matches across) — |
| 109 | * tests whose ID doesn't match are excluded from the run. |
| 110 | * |
| 111 | * @return list<TestResult> |
| 112 | */ |
| 113 | public function run(?string $filter = null): array |
| 114 | { |
| 115 | if (!is_dir($this->wptRoot)) { |
| 116 | return []; |
| 117 | } |
| 118 | |
| 119 | $results = []; |
| 120 | foreach ($this->discoverTests($this->wptRoot) as $absolutePath) { |
| 121 | $testId = $this->testIdFromPath($absolutePath); |
| 122 | if ($testId === null) { |
| 123 | continue; |
| 124 | } |
| 125 | if ($filter !== null && !Manifest::matches($filter, $testId)) { |
| 126 | continue; |
| 127 | } |
| 128 | $results[] = $this->runOne($testId); |
| 129 | } |
| 130 | return $results; |
| 131 | } |
| 132 | |
| 133 | /** |
| 134 | * Classify a single test ID without scanning the filesystem. |
| 135 | * Useful for `composer wpt classify <id>` and for tests of |
| 136 | * this class. |
| 137 | */ |
| 138 | public function runOne(string $testId): TestResult |
| 139 | { |
| 140 | $verdict = $this->manifest->classify($testId); |
| 141 | if ($verdict !== null) { |
| 142 | // Out-of-scope or pending-substrate — no render needed. |
| 143 | return new TestResult( |
| 144 | testId: $testId, |
| 145 | status: $verdict['status'], |
| 146 | diffScore: 0.0, |
| 147 | reason: $verdict['reason'], |
| 148 | diffArtefactPath: null, |
| 149 | renderMicros: 0.0, |
| 150 | ); |
| 151 | } |
| 152 | |
| 153 | // In-scope: render the test through phpdftk, rasterise via |
| 154 | // Ghostscript, visually-diff against the WPT reference. |
| 155 | return $this->runRendered($testId); |
| 156 | } |
| 157 | |
| 158 | /** |
| 159 | * Debug aid — write the visual-diff artefacts (the rendered PNG, the |
| 160 | * reference PNG, and the ImageMagick diff PNG) for every in-scope test |
| 161 | * matching `$filter`, up to `$limit`, into `$outDir`. Reuses the exact |
| 162 | * production render → rasterise → diff pipeline (same `Renderer` |
| 163 | * options, same `Rasteriser`, same `Scorer`) so the artefacts match |
| 164 | * what `run` scores — no divergence from a hand-rolled renderer. |
| 165 | * |
| 166 | * @return list<array{testId: string, score: float, passed: bool, relation: string, rendered: ?string, reference: ?string, diff: ?string, reason: ?string}> |
| 167 | */ |
| 168 | public function renderDiffArtefacts(?string $filter, string $outDir, int $limit = 20): array |
| 169 | { |
| 170 | if (!is_dir($outDir) && !@mkdir($outDir, 0o777, true) && !is_dir($outDir)) { |
| 171 | throw new \RuntimeException("cannot create artefact dir: $outDir"); |
| 172 | } |
| 173 | $rows = []; |
| 174 | foreach ($this->discoverTests($this->wptRoot) as $absolutePath) { |
| 175 | if (count($rows) >= $limit) { |
| 176 | break; |
| 177 | } |
| 178 | $testId = $this->testIdFromPath($absolutePath); |
| 179 | if ($testId === null) { |
| 180 | continue; |
| 181 | } |
| 182 | if ($filter !== null && !Manifest::matches($filter, $testId)) { |
| 183 | continue; |
| 184 | } |
| 185 | if ($this->manifest->classify($testId) !== null) { |
| 186 | // Out-of-scope / pending-substrate — nothing to render. |
| 187 | continue; |
| 188 | } |
| 189 | $rows[] = $this->renderOneArtefact($testId, $outDir); |
| 190 | } |
| 191 | |
| 192 | return $rows; |
| 193 | } |
| 194 | |
| 195 | /** |
| 196 | * @return array{testId: string, score: float, passed: bool, relation: string, rendered: ?string, reference: ?string, diff: ?string, reason: ?string} |
| 197 | */ |
| 198 | private function renderOneArtefact(string $testId, string $outDir): array |
| 199 | { |
| 200 | $row = [ |
| 201 | 'testId' => $testId, |
| 202 | 'score' => 1.0, |
| 203 | 'passed' => false, |
| 204 | 'relation' => ReferenceRelation::Match->value, |
| 205 | 'rendered' => null, |
| 206 | 'reference' => null, |
| 207 | 'diff' => null, |
| 208 | 'reason' => null, |
| 209 | ]; |
| 210 | $safe = preg_replace('/[^A-Za-z0-9._-]/', '_', $testId) ?? $testId; |
| 211 | $base = rtrim($outDir, '/') . '/' . $safe; |
| 212 | |
| 213 | $rootAbs = realpath($this->wptRoot); |
| 214 | $testPath = $rootAbs !== false ? $this->resolveTestFile($rootAbs, $testId) : null; |
| 215 | if ($testPath === null) { |
| 216 | $row['reason'] = 'test file not found'; |
| 217 | |
| 218 | return $row; |
| 219 | } |
| 220 | $references = $this->references($testPath); |
| 221 | if ($references === []) { |
| 222 | $row['reason'] = 'no -ref sibling / rel=match|mismatch reference'; |
| 223 | |
| 224 | return $row; |
| 225 | } |
| 226 | // Artefacts are a debugging aid, so show the first reference |
| 227 | // WPT would try rather than every one of them. |
| 228 | $reference = $references[0]; |
| 229 | $refPath = $reference->path; |
| 230 | $row['relation'] = $reference->relation->value; |
| 231 | try { |
| 232 | $renderedPng = $this->renderToPng($testPath); |
| 233 | } catch (\Throwable $e) { |
| 234 | $row['reason'] = 'render failed: ' . $e->getMessage(); |
| 235 | |
| 236 | return $row; |
| 237 | } |
| 238 | try { |
| 239 | $refPng = str_ends_with(strtolower($refPath), '.png') |
| 240 | ? $refPath |
| 241 | : $this->renderToPng($refPath); |
| 242 | } catch (\Throwable $e) { |
| 243 | @unlink($renderedPng); |
| 244 | $row['reason'] = 'reference render failed: ' . $e->getMessage(); |
| 245 | |
| 246 | return $row; |
| 247 | } |
| 248 | $diff = $this->scorer->diff($renderedPng, $refPng, $this->fuzzyFor($testPath, $refPath)); |
| 249 | |
| 250 | @copy($renderedPng, $base . '-rendered.png'); |
| 251 | @copy($refPng, $base . '-ref.png'); |
| 252 | if ($diff['diffImage'] !== null) { |
| 253 | @copy($diff['diffImage'], $base . '-diff.png'); |
| 254 | @unlink($diff['diffImage']); |
| 255 | } |
| 256 | @unlink($renderedPng); |
| 257 | if ($refPng !== $refPath) { |
| 258 | @unlink($refPng); |
| 259 | } |
| 260 | |
| 261 | $row['score'] = $diff['score']; |
| 262 | $row['passed'] = $reference->relation->satisfiedBy($diff['passed']); |
| 263 | $row['rendered'] = is_file($base . '-rendered.png') ? $base . '-rendered.png' : null; |
| 264 | $row['reference'] = is_file($base . '-ref.png') ? $base . '-ref.png' : null; |
| 265 | $row['diff'] = is_file($base . '-diff.png') ? $base . '-diff.png' : null; |
| 266 | |
| 267 | return $row; |
| 268 | } |
| 269 | |
| 270 | /** |
| 271 | * Render an in-scope test, rasterise the resulting PDF, and |
| 272 | * visually-diff it against its WPT references. Tests that declare |
| 273 | * no reference and have no `*-ref.{png,html,xht,svg}` sibling are |
| 274 | * reported as Skipped — the harness can't know what "pass" means |
| 275 | * without one. |
| 276 | * |
| 277 | * The verdict over a multi-reference fixture is the one in |
| 278 | * `docs/writing-tests/reftests.md`: |
| 279 | * |
| 280 | * If there are any match references, at least one must match, |
| 281 | * and if there are any mismatch references, all must mismatch. |
| 282 | * |
| 283 | * Read on its own, `executors/base.py::run_test` looks like a |
| 284 | * plain disjunction — it walks a stack of references and returns |
| 285 | * PASS at the first satisfied one. The conjunction over the |
| 286 | * mismatches comes from the shape of the tree it is walking, |
| 287 | * which `wpttest.py::ReftestTest.from_manifest` builds: mismatch |
| 288 | * references are chained BEHIND the match references rather than |
| 289 | * listed beside them, so reaching a leaf means clearing one match |
| 290 | * AND every mismatch. Porting the executor without that tree |
| 291 | * would turn "must not look like this" into an alternative way to |
| 292 | * pass — the exact opposite of the assertion the fixture makes. |
| 293 | * |
| 294 | * One deliberate simplification: WPT compares each mismatch |
| 295 | * reference against the match reference that just succeeded (its |
| 296 | * chain is `ref != mismatch`), because that screenshot is already |
| 297 | * in its cache. This compares the *test* against each mismatch |
| 298 | * reference instead. It is the same assertion — the test and the |
| 299 | * matched reference were just shown to be equal — and it is the |
| 300 | * one the documentation states. |
| 301 | * |
| 302 | * (wptrunner also recurses into a reference\'s own `rel=match` |
| 303 | * links, so a reference can have references. The harness does not |
| 304 | * follow those chains yet.) |
| 305 | */ |
| 306 | private function runRendered(string $testId): TestResult |
| 307 | { |
| 308 | $rootAbs = realpath($this->wptRoot); |
| 309 | if ($rootAbs === false) { |
| 310 | return new TestResult( |
| 311 | testId: $testId, |
| 312 | status: TestStatus::HarnessError, |
| 313 | diffScore: 0.0, |
| 314 | reason: "wpt root not accessible: $this->wptRoot", |
| 315 | diffArtefactPath: null, |
| 316 | renderMicros: 0.0, |
| 317 | ); |
| 318 | } |
| 319 | $testPath = $this->resolveTestFile($rootAbs, $testId); |
| 320 | if ($testPath === null) { |
| 321 | return new TestResult( |
| 322 | testId: $testId, |
| 323 | status: TestStatus::HarnessError, |
| 324 | diffScore: 0.0, |
| 325 | reason: 'test file not found for ID', |
| 326 | diffArtefactPath: null, |
| 327 | renderMicros: 0.0, |
| 328 | ); |
| 329 | } |
| 330 | $references = $this->references($testPath); |
| 331 | if ($references === []) { |
| 332 | return new TestResult( |
| 333 | testId: $testId, |
| 334 | status: TestStatus::Skipped, |
| 335 | diffScore: 0.0, |
| 336 | reason: 'no rel=match / rel=mismatch reference and no -ref.{png,html,xht,svg} sibling', |
| 337 | diffArtefactPath: null, |
| 338 | renderMicros: 0.0, |
| 339 | ); |
| 340 | } |
| 341 | |
| 342 | $start = hrtime(true); |
| 343 | try { |
| 344 | $renderedPng = $this->renderToPng($testPath); |
| 345 | } catch (\Throwable $e) { |
| 346 | return new TestResult( |
| 347 | testId: $testId, |
| 348 | status: TestStatus::Fail, |
| 349 | diffScore: 1.0, |
| 350 | reason: 'render failed: ' . $e->getMessage(), |
| 351 | diffArtefactPath: null, |
| 352 | renderMicros: (hrtime(true) - $start) / 1000.0, |
| 353 | ); |
| 354 | } |
| 355 | $renderMicros = (hrtime(true) - $start) / 1000.0; |
| 356 | |
| 357 | // "At least one match must match" and "all mismatches must |
| 358 | // mismatch" are two different quantifiers, so they are two |
| 359 | // separate walks. |
| 360 | $matches = array_values(array_filter( |
| 361 | $references, |
| 362 | static fn(ReftestReference $r) => $r->relation === ReferenceRelation::Match, |
| 363 | )); |
| 364 | $mismatches = array_values(array_filter( |
| 365 | $references, |
| 366 | static fn(ReftestReference $r) => $r->relation === ReferenceRelation::Mismatch, |
| 367 | )); |
| 368 | |
| 369 | try { |
| 370 | $verdict = null; |
| 371 | foreach ($matches as $reference) { |
| 372 | $comparison = $this->compareAgainst($testPath, $renderedPng, $reference); |
| 373 | if ($comparison === null) { |
| 374 | return $this->referenceRenderFailed($testId, $reference, $renderMicros, $verdict); |
| 375 | } |
| 376 | self::discard($verdict); |
| 377 | $verdict = $comparison; |
| 378 | if ($comparison['satisfied']) { |
| 379 | break; |
| 380 | } |
| 381 | } |
| 382 | // No match reference held. Nothing downstream can rescue |
| 383 | // the test: a mismatch reference is an extra condition, |
| 384 | // never an alternative one. |
| 385 | if ($verdict !== null && !$verdict['satisfied']) { |
| 386 | return $this->fromVerdict($testId, $verdict, $renderMicros); |
| 387 | } |
| 388 | |
| 389 | foreach ($mismatches as $reference) { |
| 390 | $comparison = $this->compareAgainst($testPath, $renderedPng, $reference); |
| 391 | if ($comparison === null) { |
| 392 | return $this->referenceRenderFailed($testId, $reference, $renderMicros, $verdict); |
| 393 | } |
| 394 | if (!$comparison['satisfied']) { |
| 395 | // Rendering the same as something the fixture says |
| 396 | // it must differ from settles the whole test. |
| 397 | self::discard($verdict); |
| 398 | $verdict = $comparison; |
| 399 | break; |
| 400 | } |
| 401 | // A satisfied mismatch only confirms a verdict the |
| 402 | // match walk already reached; keep it only when there |
| 403 | // was no match reference to reach one. |
| 404 | if ($verdict === null) { |
| 405 | $verdict = $comparison; |
| 406 | } else { |
| 407 | self::discard($comparison); |
| 408 | } |
| 409 | } |
| 410 | |
| 411 | assert($verdict !== null); |
| 412 | |
| 413 | return $this->fromVerdict($testId, $verdict, $renderMicros); |
| 414 | } finally { |
| 415 | @unlink($renderedPng); |
| 416 | } |
| 417 | } |
| 418 | |
| 419 | /** |
| 420 | * Compare the already-rendered test frame against one reference. |
| 421 | * Returns null when the reference itself could not be rendered. |
| 422 | * |
| 423 | * @return array{diff: DiffResult, reference: ReftestReference, satisfied: bool}|null |
| 424 | */ |
| 425 | private function compareAgainst( |
| 426 | string $testPath, |
| 427 | string $renderedPng, |
| 428 | ReftestReference $reference, |
| 429 | ): ?array { |
| 430 | try { |
| 431 | $refPng = str_ends_with(strtolower($reference->path), '.png') |
| 432 | ? $reference->path |
| 433 | : $this->renderToPng($reference->path); |
| 434 | } catch (\Throwable) { |
| 435 | return null; |
| 436 | } |
| 437 | $diff = $this->scorer->diff( |
| 438 | $renderedPng, |
| 439 | $refPng, |
| 440 | $this->fuzzyFor($testPath, $reference->path), |
| 441 | ); |
| 442 | if ($refPng !== $reference->path) { |
| 443 | @unlink($refPng); |
| 444 | } |
| 445 | |
| 446 | return [ |
| 447 | 'diff' => $diff, |
| 448 | 'reference' => $reference, |
| 449 | // `$diff['passed']` is "the two frames are equal within |
| 450 | // whatever tolerance applies"; the relation decides what |
| 451 | // that means for the verdict. |
| 452 | 'satisfied' => $reference->relation->satisfiedBy($diff['passed']), |
| 453 | ]; |
| 454 | } |
| 455 | |
| 456 | /** |
| 457 | * Turn the deciding comparison into the test's ledger row. |
| 458 | * |
| 459 | * @param array{diff: DiffResult, reference: ReftestReference, satisfied: bool} $verdict |
| 460 | */ |
| 461 | private function fromVerdict(string $testId, array $verdict, float $renderMicros): TestResult |
| 462 | { |
| 463 | $diff = $verdict['diff']; |
| 464 | |
| 465 | return new TestResult( |
| 466 | testId: $testId, |
| 467 | status: $verdict['satisfied'] ? TestStatus::Pass : TestStatus::Fail, |
| 468 | diffScore: $diff['score'], |
| 469 | reason: $verdict['satisfied'] ? null : self::unsatisfiedReason($verdict, $this->relativeToRoot( |
| 470 | $verdict['reference']->path, |
| 471 | )), |
| 472 | diffArtefactPath: $diff['diffImage'] ?? null, |
| 473 | renderMicros: $renderMicros, |
| 474 | // A mismatch reference that the test DID match is a |
| 475 | // failure whose two frames are, by definition, equal — |
| 476 | // and can therefore be blank. That is not a blank pass, |
| 477 | // it is a caught one, so the flag stays off unless the |
| 478 | // verdict it is attached to is a pass. |
| 479 | bothRendersSolid: $verdict['satisfied'] && $diff['bothSolid'], |
| 480 | ); |
| 481 | } |
| 482 | |
| 483 | /** |
| 484 | * Why an unsatisfied comparison failed. A match reference already |
| 485 | * carries the scorer's own reason (usually null — the score says |
| 486 | * it); a mismatch reference has to say out loud that matching *is* |
| 487 | * the failure, or the ledger reads as a test that failed with a |
| 488 | * perfect score. |
| 489 | * |
| 490 | * @param array{diff: DiffResult, reference: ReftestReference, satisfied: bool} $verdict |
| 491 | */ |
| 492 | private static function unsatisfiedReason(array $verdict, string $referenceId): ?string |
| 493 | { |
| 494 | if ($verdict['reference']->relation === ReferenceRelation::Match) { |
| 495 | return $verdict['diff']['reason']; |
| 496 | } |
| 497 | |
| 498 | return sprintf( |
| 499 | 'rendered the same as its rel=mismatch reference (%s), which it must not match', |
| 500 | $referenceId, |
| 501 | ); |
| 502 | } |
| 503 | |
| 504 | /** |
| 505 | * A reference the harness could not render at all. That is a |
| 506 | * harness fault, not a renderer one, so it lands in its own |
| 507 | * bucket rather than counting against the pass rate. |
| 508 | * |
| 509 | * @param array{diff: DiffResult, reference: ReftestReference, satisfied: bool}|null $pending |
| 510 | */ |
| 511 | private function referenceRenderFailed( |
| 512 | string $testId, |
| 513 | ReftestReference $reference, |
| 514 | float $renderMicros, |
| 515 | ?array $pending, |
| 516 | ): TestResult { |
| 517 | self::discard($pending); |
| 518 | |
| 519 | return new TestResult( |
| 520 | testId: $testId, |
| 521 | status: TestStatus::HarnessError, |
| 522 | diffScore: 1.0, |
| 523 | reason: 'reference render failed for ' . $this->relativeToRoot($reference->path), |
| 524 | diffArtefactPath: null, |
| 525 | renderMicros: $renderMicros, |
| 526 | ); |
| 527 | } |
| 528 | |
| 529 | /** |
| 530 | * Drop the diff artefact of a comparison the runner is no longer |
| 531 | * going to report. |
| 532 | * |
| 533 | * @param array{diff: DiffResult, reference: ReftestReference, satisfied: bool}|null $comparison |
| 534 | */ |
| 535 | private static function discard(?array $comparison): void |
| 536 | { |
| 537 | if ($comparison !== null && $comparison['diff']['diffImage'] !== null) { |
| 538 | @unlink($comparison['diff']['diffImage']); |
| 539 | } |
| 540 | } |
| 541 | |
| 542 | /** |
| 543 | * A corpus path as it reads in a test ID — relative to the WPT |
| 544 | * root — for failure messages. |
| 545 | */ |
| 546 | private function relativeToRoot(string $path): string |
| 547 | { |
| 548 | $rootAbs = realpath($this->wptRoot); |
| 549 | if ($rootAbs !== false && str_starts_with($path, $rootAbs . '/')) { |
| 550 | return substr($path, strlen($rootAbs) + 1); |
| 551 | } |
| 552 | |
| 553 | return $path; |
| 554 | } |
| 555 | |
| 556 | /** |
| 557 | * Resolve the `<meta name="fuzzy">` tolerance that applies when |
| 558 | * this test is compared against `$referencePath`. |
| 559 | * |
| 560 | * WPT selects fuzzy nodes exactly the way it selects reference |
| 561 | * links — `.//{http://www.w3.org/1999/xhtml}meta[@name='fuzzy']` — |
| 562 | * so a namespace prefix and unquoted attribute values are both |
| 563 | * normal, and 237 corpus fixtures do write `name=fuzzy` unquoted. |
| 564 | * A matcher that demands `name="fuzzy"` in quotes, and demands it |
| 565 | * before `content`, drops every one of those fixtures back onto |
| 566 | * the harness default threshold without saying so. |
| 567 | * |
| 568 | * A declaration may be keyed to one specific reference |
| 569 | * (`content="ref.html:0-5;0-100"` — WPT's `parse_ref_keyed_meta`, |
| 570 | * splitting on the last colon). A keyed declaration applies only |
| 571 | * to the reference it names; an unkeyed one is the default for |
| 572 | * every reference. |
| 573 | * |
| 574 | * @internal Exposed for {@see \Phpdftk\WptHarness\Tests\FuzzyMetaResolutionTest}. |
| 575 | */ |
| 576 | public function fuzzyFor(string $testPath, string $referencePath): ?FuzzyTolerance |
| 577 | { |
| 578 | $head = @file_get_contents($testPath, false, null, 0, self::MARKUP_SCAN_BYTES); |
| 579 | if ($head === false || $head === '') { |
| 580 | return null; |
| 581 | } |
| 582 | $unkeyed = null; |
| 583 | foreach (self::elementsNamed($head, 'meta') as $tag) { |
| 584 | if (self::attributeValue($tag, 'name') !== 'fuzzy') { |
| 585 | continue; |
| 586 | } |
| 587 | $content = self::attributeValue($tag, 'content'); |
| 588 | if ($content === null) { |
| 589 | continue; |
| 590 | } |
| 591 | $colon = strrpos($content, ':'); |
| 592 | if ($colon !== false) { |
| 593 | $keyedRef = $this->resolveHref($testPath, substr($content, 0, $colon)); |
| 594 | if ($keyedRef !== null) { |
| 595 | if ($keyedRef === $referencePath) { |
| 596 | $keyed = FuzzyTolerance::parse(substr($content, $colon + 1)); |
| 597 | if ($keyed !== null) { |
| 598 | return $keyed; |
| 599 | } |
| 600 | } |
| 601 | // Keyed at a different reference — not ours. |
| 602 | continue; |
| 603 | } |
| 604 | } |
| 605 | $unkeyed ??= FuzzyTolerance::parse($content); |
| 606 | } |
| 607 | return $unkeyed; |
| 608 | } |
| 609 | |
| 610 | private function resolveTestFile(string $rootAbs, string $testId): ?string |
| 611 | { |
| 612 | foreach (self::TEST_EXTENSIONS as $ext) { |
| 613 | $candidate = $rootAbs . '/' . $testId . '.' . $ext; |
| 614 | if (is_file($candidate)) { |
| 615 | return $candidate; |
| 616 | } |
| 617 | } |
| 618 | return null; |
| 619 | } |
| 620 | |
| 621 | /** |
| 622 | * The full reference list for a WPT reftest, in WPT's own order. |
| 623 | * |
| 624 | * WPT's manifest generator (`tools/manifest/sourcefile.py`) |
| 625 | * builds it from two `findall`s and one map: |
| 626 | * |
| 627 | * match_links = root.findall(".//{…xhtml}link[@rel='match']") |
| 628 | * mismatch_links = root.findall(".//{…xhtml}link[@rel='mismatch']") |
| 629 | * reftest_nodes = match_links + mismatch_links |
| 630 | * rel_map = {"match": "==", "mismatch": "!="} |
| 631 | * |
| 632 | * Four things follow, and this method honours all four: |
| 633 | * |
| 634 | * 1. The lookup is by **XHTML namespace**, not by literal tag |
| 635 | * spelling. SVG and XML fixtures declare their reference as |
| 636 | * `<html:link rel="match" href="…"/>` with `xmlns:html` bound |
| 637 | * to the XHTML namespace — still an XHTML `link`, still a |
| 638 | * reference. A matcher keyed on the literal string `<link` |
| 639 | * misses every one of them (266 fixtures in the current |
| 640 | * corpus, 112 of them under `css/css-masking/`), which drops |
| 641 | * them out of scope entirely instead of scoring them. |
| 642 | * 2. The declared link is what makes a file a reftest, so when a |
| 643 | * test declares one it **outranks** the legacy `<stem>-ref.*` |
| 644 | * filename sibling. 35 fixtures in the corpus carry both and |
| 645 | * disagree (e.g. `block-in-inline-remove-006.xht` declares |
| 646 | * `…-nosplit-ref.xht` while a `…-ref.xht` sibling also |
| 647 | * exists); WPT scores the declared one. `rel=mismatch` counts |
| 648 | * for this too — it is every bit as much a reftest link as |
| 649 | * `rel=match`, so a fixture declaring only mismatch |
| 650 | * references is scored against those, not against a `-ref` |
| 651 | * sibling it never pointed at (28 corpus fixtures). |
| 652 | * 3. `rel="mismatch"` is the negative relation: the test must |
| 653 | * render *differently* from that reference. It is carried |
| 654 | * here as {@see ReferenceRelation::Mismatch} rather than |
| 655 | * dropped, which is what used to leave 375 real reftests |
| 656 | * (286 css, 56 html, 4 svg, 29 mathml) unscored. |
| 657 | * 4. Every match link precedes every mismatch link regardless of |
| 658 | * document order, because WPT concatenates the two `findall` |
| 659 | * results in that order. Within a relation, document order is |
| 660 | * the order the references get tried. |
| 661 | * |
| 662 | * The `<stem>-ref.*` sibling remains the fallback for a fixture |
| 663 | * that declares no reftest link at all: it is a CSS-WG-era |
| 664 | * convention that predates `rel=match`, and 53 corpus fixtures |
| 665 | * still rely on it alone. PNG wins among siblings since it |
| 666 | * short-circuits a re-render. There is deliberately no |
| 667 | * `<stem>-notref.*` counterpart. WPT classifies those files as |
| 668 | * references (`sourcefile.py::name_is_reference`, which is why |
| 669 | * they are skipped as tests) but never links a test to one by |
| 670 | * filename, so inventing that edge could only manufacture passes |
| 671 | * — and it would buy almost nothing anyway: 5 in-scope fixtures |
| 672 | * corpus-wide have a `-notref` sibling and no `rel=mismatch`. |
| 673 | * |
| 674 | * @return list<ReftestReference> |
| 675 | * |
| 676 | * @internal Exposed for {@see \Phpdftk\WptHarness\Tests\ReferenceResolutionTest}. |
| 677 | */ |
| 678 | public function references(string $testPath): array |
| 679 | { |
| 680 | $declared = $this->declaredReftestReferences($testPath); |
| 681 | if ($declared !== []) { |
| 682 | return $declared; |
| 683 | } |
| 684 | $info = pathinfo($testPath); |
| 685 | $dir = $info['dirname'] ?? '.'; |
| 686 | $stem = $info['filename'] ?? ''; |
| 687 | $candidates = [ |
| 688 | $dir . '/' . $stem . '-ref.png', |
| 689 | $dir . '/' . $stem . '-ref.html', |
| 690 | $dir . '/' . $stem . '-ref.xht', |
| 691 | $dir . '/' . $stem . '-ref.svg', |
| 692 | ]; |
| 693 | foreach ($candidates as $cand) { |
| 694 | $real = realpath($cand); |
| 695 | if ($real !== false && is_file($real)) { |
| 696 | return [new ReftestReference($real, ReferenceRelation::Match)]; |
| 697 | } |
| 698 | } |
| 699 | |
| 700 | return []; |
| 701 | } |
| 702 | |
| 703 | /** |
| 704 | * The first `rel=match` reference, or null when the fixture |
| 705 | * declares none and has no `-ref` sibling. |
| 706 | * |
| 707 | * Kept for callers that want "the document this test is supposed |
| 708 | * to look like" and have no use for the negative relation — the |
| 709 | * gallery builder renders it as the reference panel. Scoring goes |
| 710 | * through {@see self::references()}, which is the whole list. |
| 711 | * |
| 712 | * @internal Exposed for {@see \Phpdftk\WptHarness\Tests\ReferenceResolutionTest}. |
| 713 | */ |
| 714 | public function locateReference(string $testPath): ?string |
| 715 | { |
| 716 | foreach ($this->references($testPath) as $reference) { |
| 717 | if ($reference->relation === ReferenceRelation::Match) { |
| 718 | return $reference->path; |
| 719 | } |
| 720 | } |
| 721 | |
| 722 | return null; |
| 723 | } |
| 724 | |
| 725 | /** |
| 726 | * Resolve every `<link rel="match">` / `<link rel="mismatch">` in |
| 727 | * a test file to an on-disk path, matches first. |
| 728 | * |
| 729 | * Tags are scanned in document order within each relation. A |
| 730 | * declaration whose href doesn't resolve to a file is dropped |
| 731 | * rather than allowed to poison the lookup — WPT has a served URL |
| 732 | * for every reference, we only have the checkout. |
| 733 | * |
| 734 | * Accepts any namespace prefix on the element name (`html:link`, |
| 735 | * `x:link`, …) and unquoted attribute values (`rel=match`), both |
| 736 | * of which occur in the corpus. |
| 737 | * |
| 738 | * Read is bounded to {@see self::MARKUP_SCAN_BYTES} so a |
| 739 | * malformed test can't stall the harness. Verified against the |
| 740 | * full corpus: zero fixtures declare their reference beyond that |
| 741 | * offset. |
| 742 | * |
| 743 | * @return list<ReftestReference> |
| 744 | */ |
| 745 | private function declaredReftestReferences(string $testPath): array |
| 746 | { |
| 747 | $head = @file_get_contents($testPath, false, null, 0, self::MARKUP_SCAN_BYTES); |
| 748 | if ($head === false || $head === '') { |
| 749 | return []; |
| 750 | } |
| 751 | $tags = self::elementsNamed($head, 'link'); |
| 752 | $references = []; |
| 753 | foreach ([ReferenceRelation::Match, ReferenceRelation::Mismatch] as $relation) { |
| 754 | $rel = $relation === ReferenceRelation::Match ? 'match' : 'mismatch'; |
| 755 | foreach ($tags as $tag) { |
| 756 | if (self::attributeValue($tag, 'rel') !== $rel) { |
| 757 | continue; |
| 758 | } |
| 759 | $href = self::attributeValue($tag, 'href'); |
| 760 | if ($href === null) { |
| 761 | continue; |
| 762 | } |
| 763 | $resolved = $this->resolveHref($testPath, $href); |
| 764 | if ($resolved !== null) { |
| 765 | $references[] = new ReftestReference($resolved, $relation); |
| 766 | } |
| 767 | } |
| 768 | } |
| 769 | |
| 770 | return $references; |
| 771 | } |
| 772 | |
| 773 | /** |
| 774 | * Resolve a reftest href to an absolute on-disk path. Root-relative |
| 775 | * hrefs resolve against the corpus root (wptserve's document root); |
| 776 | * everything else against the referring file's directory. |
| 777 | * |
| 778 | * Fragments and query strings are stripped: they are meaningful to |
| 779 | * the browser but never part of the filename on disk. |
| 780 | */ |
| 781 | private function resolveHref(string $fromPath, string $href): ?string |
| 782 | { |
| 783 | // WPT strips ASCII whitespace from the href before joining |
| 784 | // (`item.attrib["href"].strip(space_chars)`). |
| 785 | $href = trim($href, " \t\n\r\f\x0B"); |
| 786 | $href = (string) preg_replace('~[#?].*$~s', '', $href); |
| 787 | if ($href === '') { |
| 788 | return null; |
| 789 | } |
| 790 | $resolved = str_starts_with($href, '/') |
| 791 | ? rtrim($this->wptRoot, '/') . $href |
| 792 | : dirname($fromPath) . '/' . $href; |
| 793 | $real = realpath($resolved); |
| 794 | return ($real !== false && is_file($real)) ? $real : null; |
| 795 | } |
| 796 | |
| 797 | /** |
| 798 | * Yield every start tag named `$name`, allowing an optional XML |
| 799 | * namespace prefix (`html:link`). `[^>]` bounds each match to a |
| 800 | * single tag so attributes can never be read across a tag |
| 801 | * boundary. |
| 802 | * |
| 803 | * @return list<string> |
| 804 | */ |
| 805 | private static function elementsNamed(string $markup, string $name): array |
| 806 | { |
| 807 | $pattern = '~<(?:[A-Za-z_][\w.\-]*:)?' . preg_quote($name, '~') . '\b[^>]*>~i'; |
| 808 | if (preg_match_all($pattern, $markup, $matches) < 1) { |
| 809 | return []; |
| 810 | } |
| 811 | /** @var list<string> $tags */ |
| 812 | $tags = $matches[0]; |
| 813 | return $tags; |
| 814 | } |
| 815 | |
| 816 | /** |
| 817 | * Read one attribute out of a start tag. Handles double-quoted, |
| 818 | * single-quoted and unquoted values. The lookbehind stops |
| 819 | * `data-href` from being read as `href`. |
| 820 | */ |
| 821 | private static function attributeValue(string $tag, string $name): ?string |
| 822 | { |
| 823 | $pattern = '~(?<![\w:.\-])' . preg_quote($name, '~') |
| 824 | . '\s*=\s*(?:"([^"]*)"|\'([^\']*)\'|([^\s"\'=<>`]+))~i'; |
| 825 | if (preg_match($pattern, $tag, $m, PREG_UNMATCHED_AS_NULL) !== 1) { |
| 826 | return null; |
| 827 | } |
| 828 | return $m[1] ?? $m[2] ?? $m[3] ?? null; |
| 829 | } |
| 830 | |
| 831 | /** |
| 832 | * Render a single test file through the phpdftk pipeline and |
| 833 | * rasterise the first page of the resulting PDF. |
| 834 | */ |
| 835 | private function renderToPng(string $path): string |
| 836 | { |
| 837 | $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION)); |
| 838 | $pdfBytes = $ext === 'svg' |
| 839 | ? self::renderSvgToPdf($path) |
| 840 | : $this->renderHtmlToPdf($path); |
| 841 | $pdfPath = tempnam(sys_get_temp_dir(), 'wpt_pdf_') . '.pdf'; |
| 842 | file_put_contents($pdfPath, $pdfBytes); |
| 843 | try { |
| 844 | return $this->rasteriser->rasterise($pdfPath, 0); |
| 845 | } finally { |
| 846 | @unlink($pdfPath); |
| 847 | } |
| 848 | } |
| 849 | |
| 850 | private function renderHtmlToPdf(string $path): string |
| 851 | { |
| 852 | if (!class_exists('Phpdftk\\HtmlToPdf\\Renderer')) { |
| 853 | throw new \RuntimeException('phpdftk/html-to-pdf not installed'); |
| 854 | } |
| 855 | $html = file_get_contents($path); |
| 856 | if ($html === false) { |
| 857 | throw new \RuntimeException("could not read test file: $path"); |
| 858 | } |
| 859 | // Settle reftest-wait fixtures through Playwright so the |
| 860 | // PHP renderer sees the post-JS DOM (inline-style shifts |
| 861 | // from getBoundingClientRect, fonts marked loaded via |
| 862 | // FontFace API, etc.). When no settler is configured, the |
| 863 | // settler isn't available on this host, or settling fails, |
| 864 | // fall back to the pre-JS source - the harness still |
| 865 | // produces a result, just one that reflects an unrun test. |
| 866 | if ($this->domSettler !== null) { |
| 867 | $settled = $this->domSettler->maybeSettle($path, $html); |
| 868 | if ($settled !== null) { |
| 869 | $html = $settled; |
| 870 | } |
| 871 | } |
| 872 | // Sandbox to the WPT corpus root so refs in `reference/` |
| 873 | // subdirs can resolve `../support/img.png` siblings of the |
| 874 | // test directory. baseDir alone is too tight — the default |
| 875 | // ResourceLoader sandbox is the same as baseDir, which |
| 876 | // rejects any `..` walk. |
| 877 | $options = (new \Phpdftk\HtmlToPdf\RendererOptions()) |
| 878 | ->withBaseDir(dirname($path)) |
| 879 | ->withSandboxRoot($this->wptRoot) |
| 880 | // WPT test corpus is browser-targeted, so the vast |
| 881 | // majority of `@media` rules gate on `screen` rather |
| 882 | // than `print`. Match both so author CSS applies the |
| 883 | // way the test fixtures (and their references) expect. |
| 884 | ->withMatchingMediaTypes(['print', 'screen']); |
| 885 | $defaultFont = $this->harnessDefaultFont(); |
| 886 | if ($defaultFont !== null) { |
| 887 | $options = $options->withDefaultFont($defaultFont); |
| 888 | } |
| 889 | $result = (new \Phpdftk\HtmlToPdf\Renderer($options))->render($html); |
| 890 | return $result->writer->toBytes(); |
| 891 | } |
| 892 | |
| 893 | public static function renderSvgToPdf(string $path): string |
| 894 | { |
| 895 | if (!class_exists('Phpdftk\\SvgToPdf\\SvgRenderer') |
| 896 | || !class_exists('Phpdftk\\Svg\\Parser') |
| 897 | || !class_exists('Phpdftk\\Pdf\\Writer\\PdfWriter') |
| 898 | ) { |
| 899 | throw new \RuntimeException('svg-to-pdf renderer stack not installed'); |
| 900 | } |
| 901 | $svgSource = file_get_contents($path); |
| 902 | if ($svgSource === false) { |
| 903 | throw new \RuntimeException("could not read test file: $path"); |
| 904 | } |
| 905 | $writer = new \Phpdftk\Pdf\Writer\PdfWriter(); |
| 906 | $pageWidth = 612.0; |
| 907 | $pageHeight = 792.0; |
| 908 | $page = $writer->addPage($pageWidth, $pageHeight); |
| 909 | $svgDoc = (new \Phpdftk\Svg\Parser())->parse($svgSource); |
| 910 | $renderer = new \Phpdftk\SvgToPdf\SvgRenderer($page, $writer); |
| 911 | // SVG 2 §8.2 — `width` / `height` on the outermost `<svg>` are |
| 912 | // `auto`, which resolves to `100%`. For a STANDALONE document the |
| 913 | // viewport is the window, which here is the page: pass it as the |
| 914 | // destination so a sizeless root fills the page instead of |
| 915 | // collapsing to the renderer's finite-size fallback. Without this |
| 916 | // a `<svg>` with no width/height/viewBox renders blank, and its |
| 917 | // reftest passes only because the test side is equally blank. |
| 918 | // Only a root that supplies NO intrinsic size takes the page as |
| 919 | // its viewport; one declaring `width="200" height="200"` (or a |
| 920 | // `viewBox`, which gives a ratio) keeps its natural size, exactly |
| 921 | // as a browser shows a standalone SVG. |
| 922 | [$viewportWidth, $viewportHeight] = self::svgRootViewport($svgDoc, $pageWidth, $pageHeight); |
| 923 | // `draw()` anchors the destination rect by its BOTTOM-left in |
| 924 | // PDF user space, while a standalone document's viewport |
| 925 | // starts at the TOP-left of the canvas. They only coincide |
| 926 | // when the viewport is as tall as the page, so a shorter |
| 927 | // viewport has to be lifted by the difference or it renders |
| 928 | // flush with the bottom margin instead of the top. |
| 929 | $renderer->draw( |
| 930 | $svgDoc, |
| 931 | x: 0, |
| 932 | y: $pageHeight - $viewportHeight, |
| 933 | width: $viewportWidth, |
| 934 | height: $viewportHeight, |
| 935 | ); |
| 936 | return $writer->toBytes(); |
| 937 | } |
| 938 | |
| 939 | /** |
| 940 | * SVG 2 §8.2 — the viewport a STANDALONE outermost `<svg>` paints |
| 941 | * into, in CSS px. |
| 942 | * |
| 943 | * The `width` / `height` attributes are the viewport; a `viewBox` |
| 944 | * is a coordinate system mapped INTO that viewport, never the |
| 945 | * viewport itself. `<svg viewBox="0 0 3 3" width="200" |
| 946 | * height="200">` is a 200×200 box in which one user unit is 66⅔ |
| 947 | * px — passing no destination would instead render the whole |
| 948 | * document 3pt wide. |
| 949 | * |
| 950 | * When only one axis is fixed, a `viewBox` supplies the ratio for |
| 951 | * the other. When neither axis is fixed, `width` / `height` are |
| 952 | * `auto` → `100%` of the window, which for a standalone render is |
| 953 | * the page. A `viewBox`-only root keeps its viewBox extent as its |
| 954 | * natural size. |
| 955 | * |
| 956 | * @return array{0: float, 1: float} |
| 957 | */ |
| 958 | public static function svgRootViewport( |
| 959 | \Phpdftk\Svg\SvgDocument $svg, |
| 960 | float $pageWidth, |
| 961 | float $pageHeight, |
| 962 | ): array { |
| 963 | $viewBox = $svg->viewBox(); |
| 964 | $w = self::parseSvgRootLength($svg->widthAttribute()); |
| 965 | $h = self::parseSvgRootLength($svg->heightAttribute()); |
| 966 | $ratio = $viewBox !== null && $viewBox[2] > 0.0 && $viewBox[3] > 0.0 |
| 967 | ? $viewBox[2] / $viewBox[3] |
| 968 | : null; |
| 969 | if ($w !== null && $h === null && $ratio !== null) { |
| 970 | $h = $w / $ratio; |
| 971 | } elseif ($h !== null && $w === null && $ratio !== null) { |
| 972 | $w = $h * $ratio; |
| 973 | } |
| 974 | if ($w !== null && $h !== null) { |
| 975 | // Mirror `SvgRenderer::resolveSourceRect`'s near-integral |
| 976 | // snap (crbug.com/1392140) when there is no viewBox, so the |
| 977 | // destination it derives from the same attributes and the |
| 978 | // one passed here stay identical and the scale stays 1. |
| 979 | return $viewBox === null |
| 980 | ? [(float) round($w), (float) round($h)] |
| 981 | : [$w, $h]; |
| 982 | } |
| 983 | if ($viewBox !== null) { |
| 984 | return [$viewBox[2], $viewBox[3]]; |
| 985 | } |
| 986 | return [$pageWidth, $pageHeight]; |
| 987 | } |
| 988 | |
| 989 | /** |
| 990 | * A root `width` / `height` attribute as a fixed CSS-px length, or |
| 991 | * null when it is absent, a percentage, or otherwise not a |
| 992 | * parseable absolute length (all of which are `auto`-like and need |
| 993 | * a viewport to resolve). |
| 994 | */ |
| 995 | private static function parseSvgRootLength(?string $raw): ?float |
| 996 | { |
| 997 | if ($raw === null) { |
| 998 | return null; |
| 999 | } |
| 1000 | $trimmed = trim($raw); |
| 1001 | if ($trimmed === '' || str_contains($trimmed, '%')) { |
| 1002 | return null; |
| 1003 | } |
| 1004 | if (preg_match( |
| 1005 | '/^([+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)\s*(px|pt|pc|cm|mm|q|in)?$/i', |
| 1006 | $trimmed, |
| 1007 | $m, |
| 1008 | ) !== 1) { |
| 1009 | return null; |
| 1010 | } |
| 1011 | $value = (float) $m[1] * match (strtolower($m[2] ?? '')) { |
| 1012 | 'cm' => 96.0 / 2.54, |
| 1013 | 'mm' => 96.0 / 25.4, |
| 1014 | 'q' => 96.0 / 101.6, |
| 1015 | 'in' => 96.0, |
| 1016 | 'pt' => 96.0 / 72.0, |
| 1017 | 'pc' => 16.0, |
| 1018 | default => 1.0, |
| 1019 | }; |
| 1020 | return $value > 0.0 ? $value : null; |
| 1021 | } |
| 1022 | |
| 1023 | public function manifest(): Manifest |
| 1024 | { |
| 1025 | return $this->manifest; |
| 1026 | } |
| 1027 | |
| 1028 | public function rasteriser(): Rasteriser |
| 1029 | { |
| 1030 | return $this->rasteriser; |
| 1031 | } |
| 1032 | |
| 1033 | public function scorer(): Scorer |
| 1034 | { |
| 1035 | return $this->scorer; |
| 1036 | } |
| 1037 | |
| 1038 | public function wptRoot(): string |
| 1039 | { |
| 1040 | return $this->wptRoot; |
| 1041 | } |
| 1042 | |
| 1043 | /** |
| 1044 | * Recursively yield absolute paths to test files under the |
| 1045 | * given root. References (`*-ref.*`) are skipped — they're |
| 1046 | * targets for the diff scorer, not tests. |
| 1047 | * |
| 1048 | * @return iterable<string> |
| 1049 | */ |
| 1050 | private function discoverTests(string $root): iterable |
| 1051 | { |
| 1052 | $iterator = new \RecursiveIteratorIterator( |
| 1053 | new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS), |
| 1054 | ); |
| 1055 | foreach ($iterator as $info) { |
| 1056 | assert($info instanceof \SplFileInfo); |
| 1057 | if (!$info->isFile()) { |
| 1058 | continue; |
| 1059 | } |
| 1060 | $extension = strtolower($info->getExtension()); |
| 1061 | if (!in_array($extension, self::TEST_EXTENSIONS, true)) { |
| 1062 | continue; |
| 1063 | } |
| 1064 | $stem = $info->getBasename('.' . $info->getExtension()); |
| 1065 | // `*-ref.html`, `*-ref.xht`, `*-notref.html` are |
| 1066 | // expected-rendering / negative-match siblings, not |
| 1067 | // tests themselves. |
| 1068 | if (str_ends_with($stem, '-ref') || str_ends_with($stem, '-notref')) { |
| 1069 | continue; |
| 1070 | } |
| 1071 | yield $info->getPathname(); |
| 1072 | } |
| 1073 | } |
| 1074 | |
| 1075 | /** |
| 1076 | * Convert an absolute test-file path into a stable test ID — |
| 1077 | * the relative path under `$wptRoot`, minus the file |
| 1078 | * extension. POSIX-style separators so manifest globs match |
| 1079 | * cross-platform. |
| 1080 | */ |
| 1081 | private function testIdFromPath(string $absolutePath): ?string |
| 1082 | { |
| 1083 | $rootAbs = realpath($this->wptRoot); |
| 1084 | $pathAbs = realpath($absolutePath); |
| 1085 | if ($rootAbs === false || $pathAbs === false) { |
| 1086 | return null; |
| 1087 | } |
| 1088 | if (!str_starts_with($pathAbs, $rootAbs)) { |
| 1089 | return null; |
| 1090 | } |
| 1091 | $relative = substr($pathAbs, strlen($rootAbs)); |
| 1092 | $relative = ltrim($relative, '/\\'); |
| 1093 | // Normalise to POSIX separators. |
| 1094 | $relative = str_replace('\\', '/', $relative); |
| 1095 | // Strip the extension. |
| 1096 | $dotPos = strrpos($relative, '.'); |
| 1097 | if ($dotPos !== false) { |
| 1098 | $relative = substr($relative, 0, $dotPos); |
| 1099 | } |
| 1100 | return $relative; |
| 1101 | } |
| 1102 | } |