Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
55.09% |
119 / 216 |
|
17.65% |
3 / 17 |
CRAP | |
0.00% |
0 / 1 |
| HarnessRunner | |
55.09% |
119 / 216 |
|
17.65% |
3 / 17 |
473.54 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| run | |
90.91% |
10 / 11 |
|
0.00% |
0 / 1 |
6.03 | |||
| runOne | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
2 | |||
| runRendered | |
36.76% |
25 / 68 |
|
0.00% |
0 / 1 |
29.48 | |||
| parseFuzzyMeta | |
0.00% |
0 / 16 |
|
0.00% |
0 / 1 |
56 | |||
| resolveTestFile | |
80.00% |
4 / 5 |
|
0.00% |
0 / 1 |
3.07 | |||
| locateReference | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
3 | |||
| locateLinkRelMatchReference | |
50.00% |
9 / 18 |
|
0.00% |
0 / 1 |
19.12 | |||
| renderToPng | |
87.50% |
7 / 8 |
|
0.00% |
0 / 1 |
2.01 | |||
| renderHtmlToPdf | |
76.19% |
16 / 21 |
|
0.00% |
0 / 1 |
5.34 | |||
| renderSvgToPdf | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
30 | |||
| 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 | final class HarnessRunner |
| 26 | { |
| 27 | /** @var list<string> File extensions recognised as test files. */ |
| 28 | private const TEST_EXTENSIONS = ['html', 'xht', 'xhtml', 'htm', 'svg']; |
| 29 | |
| 30 | public function __construct( |
| 31 | private readonly Manifest $manifest, |
| 32 | private readonly Rasteriser $rasteriser, |
| 33 | private readonly Scorer $scorer, |
| 34 | private readonly string $wptRoot, |
| 35 | /** |
| 36 | * Optional DOM settler. When present, fixtures carrying |
| 37 | * `class="reftest-wait"` are shelled through Playwright to |
| 38 | * settle their JavaScript before the PHP renderer sees them. |
| 39 | * Null skips settling entirely (the legacy behaviour) and |
| 40 | * lets tests with unrun setup-JS reflect their pre-settled |
| 41 | * state, matching how the harness behaved before this hook |
| 42 | * was added. |
| 43 | */ |
| 44 | private readonly ?DomSettler $domSettler = null, |
| 45 | ) {} |
| 46 | |
| 47 | /** |
| 48 | * Run the harness corpus. Returns one {@see TestResult} per |
| 49 | * test that was either rendered or classified. |
| 50 | * |
| 51 | * `$filter` accepts the same glob syntax as the manifest rule |
| 52 | * files (`*` matches within a segment, `**` matches across) — |
| 53 | * tests whose ID doesn't match are excluded from the run. |
| 54 | * |
| 55 | * @return list<TestResult> |
| 56 | */ |
| 57 | public function run(?string $filter = null): array |
| 58 | { |
| 59 | if (!is_dir($this->wptRoot)) { |
| 60 | return []; |
| 61 | } |
| 62 | |
| 63 | $results = []; |
| 64 | foreach ($this->discoverTests($this->wptRoot) as $absolutePath) { |
| 65 | $testId = $this->testIdFromPath($absolutePath); |
| 66 | if ($testId === null) { |
| 67 | continue; |
| 68 | } |
| 69 | if ($filter !== null && !Manifest::matches($filter, $testId)) { |
| 70 | continue; |
| 71 | } |
| 72 | $results[] = $this->runOne($testId); |
| 73 | } |
| 74 | return $results; |
| 75 | } |
| 76 | |
| 77 | /** |
| 78 | * Classify a single test ID without scanning the filesystem. |
| 79 | * Useful for `composer wpt classify <id>` and for tests of |
| 80 | * this class. |
| 81 | */ |
| 82 | public function runOne(string $testId): TestResult |
| 83 | { |
| 84 | $verdict = $this->manifest->classify($testId); |
| 85 | if ($verdict !== null) { |
| 86 | // Out-of-scope or pending-substrate — no render needed. |
| 87 | return new TestResult( |
| 88 | testId: $testId, |
| 89 | status: $verdict['status'], |
| 90 | diffScore: 0.0, |
| 91 | reason: $verdict['reason'], |
| 92 | diffArtefactPath: null, |
| 93 | renderMicros: 0.0, |
| 94 | ); |
| 95 | } |
| 96 | |
| 97 | // In-scope: render the test through phpdftk, rasterise via |
| 98 | // Ghostscript, visually-diff against the WPT reference. |
| 99 | return $this->runRendered($testId); |
| 100 | } |
| 101 | |
| 102 | /** |
| 103 | * Render an in-scope test, rasterise the resulting PDF, and |
| 104 | * visually-diff against its WPT reference. Tests without a |
| 105 | * `*-ref.{png,html,xht,svg}` sibling are reported as Skipped |
| 106 | * — the harness can't know what "pass" means without one. |
| 107 | */ |
| 108 | private function runRendered(string $testId): TestResult |
| 109 | { |
| 110 | $rootAbs = realpath($this->wptRoot); |
| 111 | if ($rootAbs === false) { |
| 112 | return new TestResult( |
| 113 | testId: $testId, |
| 114 | status: TestStatus::HarnessError, |
| 115 | diffScore: 0.0, |
| 116 | reason: "wpt root not accessible: $this->wptRoot", |
| 117 | diffArtefactPath: null, |
| 118 | renderMicros: 0.0, |
| 119 | ); |
| 120 | } |
| 121 | $testPath = $this->resolveTestFile($rootAbs, $testId); |
| 122 | if ($testPath === null) { |
| 123 | return new TestResult( |
| 124 | testId: $testId, |
| 125 | status: TestStatus::HarnessError, |
| 126 | diffScore: 0.0, |
| 127 | reason: 'test file not found for ID', |
| 128 | diffArtefactPath: null, |
| 129 | renderMicros: 0.0, |
| 130 | ); |
| 131 | } |
| 132 | $refPath = $this->locateReference($testPath); |
| 133 | if ($refPath === null) { |
| 134 | return new TestResult( |
| 135 | testId: $testId, |
| 136 | status: TestStatus::Skipped, |
| 137 | diffScore: 0.0, |
| 138 | reason: 'no -ref.{png,html,xht,svg} sibling found', |
| 139 | diffArtefactPath: null, |
| 140 | renderMicros: 0.0, |
| 141 | ); |
| 142 | } |
| 143 | |
| 144 | $start = hrtime(true); |
| 145 | try { |
| 146 | $renderedPng = $this->renderToPng($testPath); |
| 147 | } catch (\Throwable $e) { |
| 148 | return new TestResult( |
| 149 | testId: $testId, |
| 150 | status: TestStatus::Fail, |
| 151 | diffScore: 1.0, |
| 152 | reason: 'render failed: ' . $e->getMessage(), |
| 153 | diffArtefactPath: null, |
| 154 | renderMicros: (hrtime(true) - $start) / 1000.0, |
| 155 | ); |
| 156 | } |
| 157 | $renderMicros = (hrtime(true) - $start) / 1000.0; |
| 158 | |
| 159 | try { |
| 160 | $refPng = str_ends_with(strtolower($refPath), '.png') |
| 161 | ? $refPath |
| 162 | : $this->renderToPng($refPath); |
| 163 | } catch (\Throwable $e) { |
| 164 | @unlink($renderedPng); |
| 165 | return new TestResult( |
| 166 | testId: $testId, |
| 167 | status: TestStatus::HarnessError, |
| 168 | diffScore: 1.0, |
| 169 | reason: 'reference render failed: ' . $e->getMessage(), |
| 170 | diffArtefactPath: null, |
| 171 | renderMicros: $renderMicros, |
| 172 | ); |
| 173 | } |
| 174 | |
| 175 | $fuzzy = $this->parseFuzzyMeta($testPath); |
| 176 | $diff = $this->scorer->diff($renderedPng, $refPng, $fuzzy['maxPixels']); |
| 177 | @unlink($renderedPng); |
| 178 | if ($refPng !== $refPath) { |
| 179 | @unlink($refPng); |
| 180 | } |
| 181 | |
| 182 | return new TestResult( |
| 183 | testId: $testId, |
| 184 | status: $diff['passed'] ? TestStatus::Pass : TestStatus::Fail, |
| 185 | diffScore: $diff['score'], |
| 186 | reason: $diff['reason'], |
| 187 | diffArtefactPath: $diff['diffImage'] ?? null, |
| 188 | renderMicros: $renderMicros, |
| 189 | ); |
| 190 | } |
| 191 | |
| 192 | /** |
| 193 | * Parse the WPT `<meta name="fuzzy">` annotation from a test file. |
| 194 | * |
| 195 | * Format (CSS-WG convention): |
| 196 | * maxDifference=A-B; totalPixels=C-D |
| 197 | * maxDifference=A-B;totalPixels=C-D |
| 198 | * A-B;C-D (positional shorthand) |
| 199 | * |
| 200 | * Both ranges are inclusive bounds; the *upper* bound is the one |
| 201 | * the renderer must respect. We surface the upper-bound pixel |
| 202 | * count to the Scorer so tests with relaxed tolerances (e.g. |
| 203 | * `totalPixels=0-127500`) pass when our renderer is within |
| 204 | * spec-allowed difference but not pixel-perfect. |
| 205 | * |
| 206 | * Returns `['maxPixels' => null]` when no annotation is present; |
| 207 | * `null` tells the Scorer to use its default threshold. |
| 208 | * |
| 209 | * @return array{maxPixels: int|null} |
| 210 | */ |
| 211 | private function parseFuzzyMeta(string $testPath): array |
| 212 | { |
| 213 | $head = @file_get_contents($testPath, false, null, 0, 64 * 1024); |
| 214 | if ($head === false || $head === '') { |
| 215 | return ['maxPixels' => null]; |
| 216 | } |
| 217 | if (preg_match( |
| 218 | '~<meta\s+[^>]*?name\s*=\s*["\']fuzzy["\']\s+[^>]*?content\s*=\s*["\']([^"\']+)["\']~i', |
| 219 | $head, |
| 220 | $m, |
| 221 | ) !== 1) { |
| 222 | return ['maxPixels' => null]; |
| 223 | } |
| 224 | $content = trim($m[1]); |
| 225 | // Look for `totalPixels=<lo>-<hi>` first; fall back to the last |
| 226 | // semicolon-separated range in positional shorthand. |
| 227 | if (preg_match('~totalPixels\s*=\s*\d+\s*-\s*(\d+)~i', $content, $m2) === 1) { |
| 228 | return ['maxPixels' => (int) $m2[1]]; |
| 229 | } |
| 230 | $parts = array_map('trim', explode(';', $content)); |
| 231 | if (count($parts) >= 2 && preg_match('~^\d+\s*-\s*(\d+)$~', $parts[1], $m3) === 1) { |
| 232 | return ['maxPixels' => (int) $m3[1]]; |
| 233 | } |
| 234 | return ['maxPixels' => null]; |
| 235 | } |
| 236 | |
| 237 | private function resolveTestFile(string $rootAbs, string $testId): ?string |
| 238 | { |
| 239 | foreach (self::TEST_EXTENSIONS as $ext) { |
| 240 | $candidate = $rootAbs . '/' . $testId . '.' . $ext; |
| 241 | if (is_file($candidate)) { |
| 242 | return $candidate; |
| 243 | } |
| 244 | } |
| 245 | return null; |
| 246 | } |
| 247 | |
| 248 | /** |
| 249 | * Locate the reference rendering for a WPT reftest. WPT supports |
| 250 | * two conventions: |
| 251 | * |
| 252 | * 1. **Filename**: a sibling `<stem>-ref.{png,html,xht,svg}` |
| 253 | * file. Simple and unambiguous; PNG wins when both exist |
| 254 | * since it short-circuits a re-render. |
| 255 | * |
| 256 | * 2. **`<link rel="match" href="…">`** inside the test's |
| 257 | * `<head>`. The actual WPT corpus uses this for the |
| 258 | * majority of tests — the reference often lives in a |
| 259 | * sibling directory, sometimes with a name unrelated to |
| 260 | * the test stem. |
| 261 | * |
| 262 | * `rel="mismatch"` is the negative variant and is skipped here |
| 263 | * — the harness doesn't yet implement "must not match" |
| 264 | * semantics (a follow-up). |
| 265 | */ |
| 266 | private function locateReference(string $testPath): ?string |
| 267 | { |
| 268 | $info = pathinfo($testPath); |
| 269 | $dir = $info['dirname'] ?? '.'; |
| 270 | $stem = $info['filename'] ?? ''; |
| 271 | $candidates = [ |
| 272 | $dir . '/' . $stem . '-ref.png', |
| 273 | $dir . '/' . $stem . '-ref.html', |
| 274 | $dir . '/' . $stem . '-ref.xht', |
| 275 | $dir . '/' . $stem . '-ref.svg', |
| 276 | ]; |
| 277 | foreach ($candidates as $cand) { |
| 278 | if (is_file($cand)) { |
| 279 | return $cand; |
| 280 | } |
| 281 | } |
| 282 | return $this->locateLinkRelMatchReference($testPath); |
| 283 | } |
| 284 | |
| 285 | /** |
| 286 | * Parse `<link rel="match" href="…">` from the head of a |
| 287 | * `.html` / `.xht` / `.svg` test file and resolve the href to |
| 288 | * an on-disk path relative to the test. Returns the first |
| 289 | * matching reference; `rel="mismatch"` is intentionally |
| 290 | * ignored. |
| 291 | * |
| 292 | * Read is bounded to the first 64 KB so a malformed test |
| 293 | * can't stall the harness — WPT's `<head>` always sits in |
| 294 | * the first few hundred bytes anyway. |
| 295 | */ |
| 296 | private function locateLinkRelMatchReference(string $testPath): ?string |
| 297 | { |
| 298 | $head = @file_get_contents($testPath, false, null, 0, 64 * 1024); |
| 299 | if ($head === false || $head === '') { |
| 300 | return null; |
| 301 | } |
| 302 | // Match either attribute order (rel-first or href-first) by trying |
| 303 | // two patterns rather than alternation — keeps PHPStan happy and |
| 304 | // makes the failure mode obvious. |
| 305 | $relFirst = '~<link\s+[^>]*?rel\s*=\s*["\']match["\']\s+[^>]*?href\s*=\s*["\']([^"\']+)["\']~i'; |
| 306 | $hrefFirst = '~<link\s+[^>]*?href\s*=\s*["\']([^"\']+)["\']\s+[^>]*?rel\s*=\s*["\']match["\']~i'; |
| 307 | $href = null; |
| 308 | if (preg_match($relFirst, $head, $matches) === 1) { |
| 309 | $href = $matches[1]; |
| 310 | } elseif (preg_match($hrefFirst, $head, $matches) === 1) { |
| 311 | $href = $matches[1]; |
| 312 | } |
| 313 | if ($href === null) { |
| 314 | return null; |
| 315 | } |
| 316 | $dir = dirname($testPath); |
| 317 | $resolved = str_starts_with($href, '/') |
| 318 | ? $this->wptRoot . $href |
| 319 | : $dir . '/' . $href; |
| 320 | $real = realpath($resolved); |
| 321 | return ($real !== false && is_file($real)) ? $real : null; |
| 322 | } |
| 323 | |
| 324 | /** |
| 325 | * Render a single test file through the phpdftk pipeline and |
| 326 | * rasterise the first page of the resulting PDF. |
| 327 | */ |
| 328 | private function renderToPng(string $path): string |
| 329 | { |
| 330 | $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION)); |
| 331 | $pdfBytes = $ext === 'svg' |
| 332 | ? $this->renderSvgToPdf($path) |
| 333 | : $this->renderHtmlToPdf($path); |
| 334 | $pdfPath = tempnam(sys_get_temp_dir(), 'wpt_pdf_') . '.pdf'; |
| 335 | file_put_contents($pdfPath, $pdfBytes); |
| 336 | try { |
| 337 | return $this->rasteriser->rasterise($pdfPath, 0); |
| 338 | } finally { |
| 339 | @unlink($pdfPath); |
| 340 | } |
| 341 | } |
| 342 | |
| 343 | private function renderHtmlToPdf(string $path): string |
| 344 | { |
| 345 | if (!class_exists('Phpdftk\\HtmlToPdf\\Renderer')) { |
| 346 | throw new \RuntimeException('phpdftk/html-to-pdf not installed'); |
| 347 | } |
| 348 | $html = file_get_contents($path); |
| 349 | if ($html === false) { |
| 350 | throw new \RuntimeException("could not read test file: $path"); |
| 351 | } |
| 352 | // Settle reftest-wait fixtures through Playwright so the |
| 353 | // PHP renderer sees the post-JS DOM (inline-style shifts |
| 354 | // from getBoundingClientRect, fonts marked loaded via |
| 355 | // FontFace API, etc.). When no settler is configured, the |
| 356 | // settler isn't available on this host, or settling fails, |
| 357 | // fall back to the pre-JS source - the harness still |
| 358 | // produces a result, just one that reflects an unrun test. |
| 359 | if ($this->domSettler !== null) { |
| 360 | $settled = $this->domSettler->maybeSettle($path, $html); |
| 361 | if ($settled !== null) { |
| 362 | $html = $settled; |
| 363 | } |
| 364 | } |
| 365 | // Sandbox to the WPT corpus root so refs in `reference/` |
| 366 | // subdirs can resolve `../support/img.png` siblings of the |
| 367 | // test directory. baseDir alone is too tight — the default |
| 368 | // ResourceLoader sandbox is the same as baseDir, which |
| 369 | // rejects any `..` walk. |
| 370 | $renderer = new \Phpdftk\HtmlToPdf\Renderer( |
| 371 | (new \Phpdftk\HtmlToPdf\RendererOptions()) |
| 372 | ->withBaseDir(dirname($path)) |
| 373 | ->withSandboxRoot($this->wptRoot) |
| 374 | // WPT test corpus is browser-targeted, so the vast |
| 375 | // majority of `@media` rules gate on `screen` rather |
| 376 | // than `print`. Match both so author CSS applies the |
| 377 | // way the test fixtures (and their references) expect. |
| 378 | ->withMatchingMediaTypes(['print', 'screen']), |
| 379 | ); |
| 380 | $result = $renderer->render($html); |
| 381 | return $result->writer->toBytes(); |
| 382 | } |
| 383 | |
| 384 | private function renderSvgToPdf(string $path): string |
| 385 | { |
| 386 | if (!class_exists('Phpdftk\\SvgToPdf\\SvgRenderer') |
| 387 | || !class_exists('Phpdftk\\Svg\\Parser') |
| 388 | || !class_exists('Phpdftk\\Pdf\\Writer\\PdfWriter') |
| 389 | ) { |
| 390 | throw new \RuntimeException('svg-to-pdf renderer stack not installed'); |
| 391 | } |
| 392 | $svgSource = file_get_contents($path); |
| 393 | if ($svgSource === false) { |
| 394 | throw new \RuntimeException("could not read test file: $path"); |
| 395 | } |
| 396 | $writer = new \Phpdftk\Pdf\Writer\PdfWriter(); |
| 397 | $page = $writer->addPage(); |
| 398 | $svgDoc = (new \Phpdftk\Svg\Parser())->parse($svgSource); |
| 399 | $renderer = new \Phpdftk\SvgToPdf\SvgRenderer($page, $writer); |
| 400 | $renderer->draw($svgDoc, x: 0, y: 0); |
| 401 | return $writer->toBytes(); |
| 402 | } |
| 403 | |
| 404 | public function manifest(): Manifest |
| 405 | { |
| 406 | return $this->manifest; |
| 407 | } |
| 408 | |
| 409 | public function rasteriser(): Rasteriser |
| 410 | { |
| 411 | return $this->rasteriser; |
| 412 | } |
| 413 | |
| 414 | public function scorer(): Scorer |
| 415 | { |
| 416 | return $this->scorer; |
| 417 | } |
| 418 | |
| 419 | public function wptRoot(): string |
| 420 | { |
| 421 | return $this->wptRoot; |
| 422 | } |
| 423 | |
| 424 | /** |
| 425 | * Recursively yield absolute paths to test files under the |
| 426 | * given root. References (`*-ref.*`) are skipped — they're |
| 427 | * targets for the diff scorer, not tests. |
| 428 | * |
| 429 | * @return iterable<string> |
| 430 | */ |
| 431 | private function discoverTests(string $root): iterable |
| 432 | { |
| 433 | $iterator = new \RecursiveIteratorIterator( |
| 434 | new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS), |
| 435 | ); |
| 436 | foreach ($iterator as $info) { |
| 437 | assert($info instanceof \SplFileInfo); |
| 438 | if (!$info->isFile()) { |
| 439 | continue; |
| 440 | } |
| 441 | $extension = strtolower($info->getExtension()); |
| 442 | if (!in_array($extension, self::TEST_EXTENSIONS, true)) { |
| 443 | continue; |
| 444 | } |
| 445 | $stem = $info->getBasename('.' . $info->getExtension()); |
| 446 | // `*-ref.html`, `*-ref.xht`, `*-notref.html` are |
| 447 | // expected-rendering / negative-match siblings, not |
| 448 | // tests themselves. |
| 449 | if (str_ends_with($stem, '-ref') || str_ends_with($stem, '-notref')) { |
| 450 | continue; |
| 451 | } |
| 452 | yield $info->getPathname(); |
| 453 | } |
| 454 | } |
| 455 | |
| 456 | /** |
| 457 | * Convert an absolute test-file path into a stable test ID — |
| 458 | * the relative path under `$wptRoot`, minus the file |
| 459 | * extension. POSIX-style separators so manifest globs match |
| 460 | * cross-platform. |
| 461 | */ |
| 462 | private function testIdFromPath(string $absolutePath): ?string |
| 463 | { |
| 464 | $rootAbs = realpath($this->wptRoot); |
| 465 | $pathAbs = realpath($absolutePath); |
| 466 | if ($rootAbs === false || $pathAbs === false) { |
| 467 | return null; |
| 468 | } |
| 469 | if (!str_starts_with($pathAbs, $rootAbs)) { |
| 470 | return null; |
| 471 | } |
| 472 | $relative = substr($pathAbs, strlen($rootAbs)); |
| 473 | $relative = ltrim($relative, '/\\'); |
| 474 | // Normalise to POSIX separators. |
| 475 | $relative = str_replace('\\', '/', $relative); |
| 476 | // Strip the extension. |
| 477 | $dotPos = strrpos($relative, '.'); |
| 478 | if ($dotPos !== false) { |
| 479 | $relative = substr($relative, 0, $dotPos); |
| 480 | } |
| 481 | return $relative; |
| 482 | } |
| 483 | } |