diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index db5b4e4..c081d67 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,6 +28,8 @@ jobs: php-version: ${{ matrix.php }} tools: composer:v2 coverage: none + # GD does the pixel comparison of the visual check (tests/Visual) + extensions: gd - name: Install the bundled Linux binary's runtime libraries (README apt line) # Since 1.1.0 the suite tests the BUNDLED x64/linux/wkhtmltopdf, not an upstream .deb @@ -69,7 +71,27 @@ jobs: # right on a developer machine without one but would let CI pass green with nothing # rendered. Here the bundled binary is checked above, so a skip is a failure. # --fail-on-empty-test-suite: a run that discovers no tests exits non-zero instead of 0. - run: vendor/bin/phpunit --fail-on-skipped --fail-on-empty-test-suite + # The visual group runs in its own step below, so its failures are told apart from these. + run: vendor/bin/phpunit --fail-on-skipped --fail-on-empty-test-suite --exclude-group visual + + - name: Visual check (bundled binary against the committed linux baselines) + # Renders tests/Visual/fixture with the binary resolved above and compares page count, page + # sizes and pixels with tests/Visual/baselines/linux (README "Visual check"). A skip is a + # failure here too: it would mean xpdf, GD or the binary went missing and nothing was + # compared. Without linux baselines this fails and proposes the render as baselines. + env: + WKHTMLTOPDF_VISUAL_OUT: ${{ runner.temp }}/visual-out + run: vendor/bin/phpunit --group visual --fail-on-skipped --fail-on-empty-test-suite + + - name: Upload visual-check renders, diff images and proposed baselines + # Only on failure: the PDFs, page PNGs, red diff images, and under proposed-baselines/ + # a ready-to-copy baselines tree for a platform that has none yet. + if: failure() + uses: actions/upload-artifact@v7 + with: + name: visual-out-php${{ matrix.php }} + path: ${{ runner.temp }}/visual-out + if-no-files-found: ignore lint: name: Lint PHP ${{ matrix.php }} diff --git a/.gitignore b/.gitignore index 1fc8738..bcf97a6 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ /composer.lock /.phpunit.cache/ /.phpunit.result.cache +/tests/Visual/out/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 5083d60..16d05ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## Unreleased + +### Added + +- A visual check (`tests/Visual/`, PHPUnit group `visual`, plus the CLI + `tests/Visual/visual-check.php`). It renders a fixed mm-exact fixture and compares page count and + page sizes (pdfinfo) exactly and every page's pixels (pdftopng at 100 dpi, GD) against committed + per-platform baselines, so a change of wkhtmltopdf version, strategy or host fonts that changes + the output fails with a diff image. The CLI also compares two strategies or binaries directly + (e.g. `compare bundled docker`). Must-fail controls (smart shrinking on, `--zoom 1.02`, a 4 px + margin, Letter paper) are part of the group. See the README section "Visual check". +- `restruct/xpdf-static` as a dev dependency, for the visual check. +- CI runs the visual group on Ubuntu 24.04 in its own step and uploads renders, diff images and + proposed baselines when it fails. Baselines are committed for macOS (bundled binary) and Linux + (bundled binary on Ubuntu 24.04, taken from the first CI run). + ## 1.1.0 (2026-10-12) ### Changed diff --git a/README.md b/README.md index 129a4e3..b4e8cac 100644 --- a/README.md +++ b/README.md @@ -227,7 +227,7 @@ src/ WkhtmltoPdfResult.php output/errorOutput/exitCode value object x64/mac/, x64/linux/ bundled 0.12.6.1 patched-Qt binaries build/ Dockerfile + multi-arch build script -tests/ PHPUnit suite (not in dist installs) +tests/ PHPUnit suite and the visual check (not in dist installs) ``` ## Running the tests @@ -240,6 +240,107 @@ vendor/bin/phpunit The render tests need a working wkhtmltopdf (the bundled binary or the Docker image) and are skipped without one. +## Visual check + +A change of wkhtmltopdf version, of strategy (bundled binary, native install, Docker) or of the +fonts on a host can change the size and placement of everything on a page without any error. The +visual check renders a fixed fixture and compares the result, so such a change shows up as a red +test with a diff image instead of in a client's documents. It lives in `tests/Visual/` (dev only, +not in dist installs) and needs `restruct/xpdf-static` (require-dev, for `pdfinfo` and +`pdftopng`) and ext-gd. + +What it compares, per render: + +- **Geometry**: page count and the size of every page in points (`pdfinfo`), exactly. +- **Pixels**: every page rasterised at 100 dpi (`pdftopng`) and compared with GD. A pixel differs + when one RGB channel differs by more than 64; a page fails when more than 0.1 % of its pixels + differ (about 970 px on A4, a few glyphs). On failure it writes the PDF, the page PNGs and a diff + image (differing pixels red over a washed-out expected page) to `tests/Visual/out/` (or + `$WKHTMLTOPDF_VISUAL_OUT`). + +### The fixture + +`tests/Visual/fixture/` holds plain HTML and CSS with everything sized in mm: a frame exactly on +the paper edge and one 10 mm in, rulers with a tick per mm, a 50 x 50 mm box, a generated PNG +(`make-image.php`, no SVG) shown at 50 x 25 mm, a monospace block with known line wraps (five +10-digit groups per line), host-font samples in several sizes, a table with mm column widths, +forced and avoided page breaks, and an HTML header and footer. The cases use the option sets that +real document pipelines need: + +| Case | Options | Pages | +|---|---|---| +| `fullbleed` | margins `0px`, `--enable-local-file-access`, `--disable-smart-shrinking`; two exact 210 x 297 mm page boxes, the second with its text pushed 210 mm down by padding | 2 x A4 | +| `lowquality` | `fullbleed` plus `--lowquality` (knp-snappy passes it by default, this wrapper does not) | 2 x A4 | +| `flow` | margins 16 mm top, 20 mm bottom, 0 sides; flowing content with `page-break-before/after/inside`, an element pulled 16 mm up into the margin | 3 x A4 | +| `header-footer` | margins 25 / 20 mm, `--header-html`, `--footer-html` (page X / Y), spacing 5 mm | 2 x A4 | + +Fonts are host fonts only: `'DejaVu Sans Mono', monospace` for the fixed text (DejaVu comes with +the apt line above; macOS falls back to its own monospace font), and `sans-serif`, +`Arial, Helvetica, sans-serif` and `serif` for the samples, as most templates use. Line heights +are fixed in mm wherever text flows, so the page count does not depend on the fonts; the pixels +do, which is how a host font change is caught. An `@font-face` webfont was tried and dropped: the +bundled macOS binary applied it in only 30 of 40 renders of the same files (loaded by file URL, +as a `data:` URI, and with `--javascript-delay 1000`) and used a system font in the others, which +no fixed baseline can absorb. Keep that in mind for real templates too. + +### Baselines per platform + +Fonts and the macOS and Linux builds of wkhtmltopdf render text differently, so the baseline PNGs +are per platform: `tests/Visual/baselines/darwin/` (bundled macOS binary) and +`tests/Visual/baselines/linux/` (bundled Linux binary on Ubuntu 24.04 with the apt line above). +The platform is where the rendering happens, so the Docker strategy compares against `linux` even +on a Mac. Between the two, every mm-placed element (frames, rulers, box, image, table) lands on the +same pixel; only the text differs. On the CI runner the generic `serif` and `monospace` families +render in a sans-serif font, so name a real font family when a template needs one. Docker on Apple Silicon runs the arm64 image, which is a different build again. + +### Running it + +```bash +vendor/bin/phpunit --group visual # strategy "auto" +WKHTMLTOPDF_VISUAL_STRATEGY=docker vendor/bin/phpunit --group visual +``` + +The tests are skipped when ext-gd, xpdf or the renderer is missing (CI treats a skip as a +failure). Strategies: `auto` (whatever this package resolves, through the wrapper), `bundled`, +`native` (`/usr/local/bin/wkhtmltopdf`), `docker` or `docker:`, or a path to any +wkhtmltopdf binary. A platform without baselines fails rather than passes, and proposes its render +as baselines under `out/proposed-baselines/`. + +The group also holds must-fail controls: the `fullbleed` case rendered with smart shrinking back +on, `--zoom 1.02`, a 4 px left margin and `--page-size Letter` must each be reported as different. +If one of those ever passes, the check has gone blind to that kind of change. + +The same check without PHPUnit, plus a direct comparison of two strategies or binaries (no +baselines involved), for use before switching a host: + +```bash +php tests/Visual/visual-check.php check --strategy=bundled +php tests/Visual/visual-check.php compare bundled docker +php tests/Visual/visual-check.php compare bundled /usr/local/bin/wkhtmltopdf --case=fullbleed +php tests/Visual/visual-check.php compare bundled bundled --b-opt=zoom=1.02 # shows red +php tests/Visual/visual-check.php baseline --strategy=bundled # rewrite baselines +``` + +Exit code 0 is a pass, 1 differences, 2 a usage error or missing prerequisite. Look at new +baseline PNGs before committing them: a baseline is a claim that this output is right. + +### What it showed on the bundled macOS binary (0.12.6) + +Measured with `compare bundled bundled --b-opt=...` against the `fullbleed` case: + +- Smart shrinking on (the default) scales the page content to roughly 80 %: 9.3 % of page 1's + pixels differ, while page count and page size stay the same. Only the pixel comparison sees it. +- `--zoom 1.02` and even `--zoom 1.005` push the 297 mm page boxes over the page edge: 4 pages + instead of 2. +- `--dpi 300` changes the layout (7.2 % of page 1 differs). It is not a must-fail control because + its effect on Linux is not measured yet. +- `--lowquality` gives pixel-identical pages for this fixture at 100 dpi. +- wkhtmltopdf refuses mixed margin units (`--margin-top 0px --margin-left 1mm` exits 1 with + "Currently all margin units must be the same!"). +- QtWebKit floors border widths to whole CSS px: a 0.2 mm border (0.76 px) is not drawn at all. +- `page-break-after: avoid` on the line after a heading is not honoured; `page-break-inside: + avoid` on a block is. + ## License - Wrapper code (`bootstrap.php`, `src/`, `build/`): MIT (Copyright Restruct). Up to 1.0.1 the diff --git a/composer.json b/composer.json index 4231efc..90bf948 100644 --- a/composer.json +++ b/composer.json @@ -7,7 +7,8 @@ "symfony/process": "^6.0 || ^7.0" }, "require-dev": { - "phpunit/phpunit": "^10.5 || ^11.5" + "phpunit/phpunit": "^10.5 || ^11.5", + "restruct/xpdf-static": "^1.0.2" }, "autoload": { "psr-4": { diff --git a/tests/Visual/VisualCheck.php b/tests/Visual/VisualCheck.php new file mode 100644 index 0000000..e2b60ff --- /dev/null +++ b/tests/Visual/VisualCheck.php @@ -0,0 +1,589 @@ + value, true for a flag, false to leave it out), + * plus the geometry it must have. The option sets are the ones real document pipelines use; + * see the README section "Visual check" for where each comes from. + * + * "{dir}" in an option value is replaced with the render's working directory. + * + * @return array, pages: int, size: string, about: string}> + */ + public static function cases(): array + { + # The flags almost every 0.12.6 template needs: local file access for the CSS, font and + # image next to the HTML (off by default since 0.12.6), and no smart shrinking (on by + # default, it scales the page and broke mm-exact layouts on the 0.12.4 to 0.12.6 move). + $base = [ + 'enable-local-file-access' => true, + 'disable-smart-shrinking' => true, + ]; + $noMargins = [ + 'margin-top' => '0px', + 'margin-right' => '0px', + 'margin-bottom' => '0px', + 'margin-left' => '0px', + ]; + + return [ + 'fullbleed' => [ + 'about' => 'two exact A4 page boxes, zero page margins, everything placed in mm', + 'html' => 'cover.html', + 'options' => $noMargins + $base, + 'pages' => 2, + 'size' => '595 x 842', + ], + 'lowquality' => [ + # knp-snappy passes --lowquality by default, so code migrated from it to this + # wrapper (which adds no defaults) silently loses it. This case pins what it does. + 'about' => 'fullbleed plus --lowquality, the knp-snappy default', + 'html' => 'cover.html', + 'options' => $noMargins + $base + ['lowquality' => true], + 'pages' => 2, + 'size' => '595 x 842', + ], + 'flow' => [ + 'about' => 'flowing content with 16 mm top and 20 mm bottom page margins, page-break-* CSS', + 'html' => 'flow.html', + 'options' => [ + 'margin-top' => '16mm', + 'margin-right' => '0mm', + 'margin-bottom' => '20mm', + 'margin-left' => '0mm', + ] + $base, + 'pages' => 3, + 'size' => '595 x 842', + ], + 'header-footer' => [ + 'about' => 'flowing content between --header-html and --footer-html with spacing', + 'html' => 'header-footer.html', + 'options' => [ + 'margin-top' => '25mm', + 'margin-right' => '0mm', + 'margin-bottom' => '20mm', + 'margin-left' => '0mm', + 'header-html' => '{dir}/header.html', + 'footer-html' => '{dir}/footer.html', + 'header-spacing' => 5, + 'footer-spacing' => 5, + ] + $base, + 'pages' => 2, + 'size' => '595 x 842', + ], + ]; + } + + /** + * Option changes the check must catch, each applied to the "fullbleed" case. The test suite + * renders each and asserts the comparison against the fullbleed baseline FAILS: proof that + * the check is sensitive to the kind of change it exists for, not just green. + * + * @return array> + */ + public static function mutations(): array + { + return [ + # Smart shrinking back on (the 0.12.6 default) + 'smart shrinking on' => ['disable-smart-shrinking' => false], + # A 2 % scale change, smaller than any version jump has caused + 'zoom 1.02' => ['zoom' => 1.02], + # A 1 mm offset of the whole page content. In px because wkhtmltopdf refuses mixed + # margin units ("Currently all margin units must be the same!") and the case uses px. + 'margin-left 4px (about 1 mm)' => ['margin-left' => '4px'], + # Another paper size: caught by the geometry check alone + 'page-size Letter' => ['page-size' => 'Letter'], + ]; + } + + # --- Paths --- + + public static function fixtureDir(): string + { + return __DIR__ . '/fixture'; + } + + /** + * @param string|null $root Another baselines root than the committed one (see writeBaselines()) + */ + public static function baselineDir(string $platform, string $case, ?string $root = null): string + { + return ($root ?? __DIR__ . '/baselines') . '/' . $platform . '/' . $case; + } + + /** + * Where failing renders, their pages and diff images are written. WKHTMLTOPDF_VISUAL_OUT + * overrides it (CI points it at a directory it uploads). + */ + public static function outDir(): string + { + $env = getenv('WKHTMLTOPDF_VISUAL_OUT'); + + return ($env !== false && $env !== '') ? rtrim($env, '/') : __DIR__ . '/out'; + } + + # --- Prerequisites --- + + /** + * Why the check cannot run here, or null when it can. + */ + public static function missingPrerequisite(string $strategy): ?string + { + if (!extension_loaded('gd')) { + return 'ext-gd is not loaded (needed for the pixel comparison)'; + } + if (!class_exists(Xpdf::class) || !Xpdf::isAvailable()) { + return 'xpdf (restruct/xpdf-static, require-dev) is not available for pdfinfo/pdftopng'; + } + + return self::rendererProblem($strategy); + } + + /** + * Why a strategy cannot render here, or null when it can. + */ + public static function rendererProblem(string $strategy): ?string + { + if ($strategy === 'auto') { + return WkhtmltoPdfStatic::isAvailable() ? null : 'wkhtmltopdf is not available (no binary or Docker image)'; + } + if (self::isDocker($strategy)) { + $process = new Process(['docker', 'info']); + $process->setTimeout(10); + try { + $process->run(); + } catch (\Exception $e) { + return 'docker is not available'; + } + + return $process->isSuccessful() ? null : 'docker is not running'; + } + $binary = self::binaryFor($strategy); + + return WkhtmltoPdfStatic::probeBinary($binary) ? null : "no runnable wkhtmltopdf at $binary"; + } + + # --- Strategies --- + + /** + * Human-readable description of what a strategy resolves to. + * + * Strategies: "auto" (whatever this package resolves, through the WkhtmltoPdf wrapper, so + * the wrapper itself is under test too), "bundled" (x64//wkhtmltopdf), "native" + * (/usr/local/bin/wkhtmltopdf, the official .deb location), "docker" or "docker:", + * or an absolute path to any wkhtmltopdf binary. + */ + public static function describe(string $strategy): string + { + if ($strategy === 'auto') { + $resolved = WkhtmltoPdfStatic::getStrategy(); + + return 'auto (' . ($resolved === 'docker' ? 'docker ' . WkhtmltoPdfStatic::getDockerImage() : $resolved . ' ' . WkhtmltoPdfStatic::getPath()) . ')'; + } + if (self::isDocker($strategy)) { + return 'docker ' . self::dockerImage($strategy); + } + + return $strategy . ' (' . self::binaryFor($strategy) . ')'; + } + + /** + * The platform whose baselines a strategy is compared against: where the rendering actually + * happens, so Docker on a Mac is "linux". Baselines are per platform because fonts and the + * macOS and Linux builds of wkhtmltopdf render text differently. + */ + public static function platformFor(string $strategy): string + { + if (self::isDocker($strategy) || ($strategy === 'auto' && WkhtmltoPdfStatic::getStrategy() === 'docker')) { + return 'linux'; + } + + return PHP_OS_FAMILY === 'Darwin' ? 'darwin' : 'linux'; + } + + private static function isDocker(string $strategy): bool + { + return $strategy === 'docker' || str_starts_with($strategy, 'docker:'); + } + + private static function dockerImage(string $strategy): string + { + if (str_starts_with($strategy, 'docker:')) { + return substr($strategy, 7); + } + $env = getenv('WKHTMLTOPDF_DOCKER_IMAGE'); + + return ($env !== false && $env !== '') ? $env : 'ghcr.io/restruct/wkhtmltopdf:0.12.6'; + } + + private static function binaryFor(string $strategy): string + { + return match ($strategy) { + 'bundled' => dirname(__DIR__, 2) . '/x64/' . (PHP_OS_FAMILY === 'Darwin' ? 'mac' : 'linux') . '/wkhtmltopdf', + 'native' => '/usr/local/bin/wkhtmltopdf', + default => $strategy, + }; + } + + # --- Rendering --- + + /** + * Render one case into $workDir and return the PDF path. + * + * The fixture is copied into $workDir first, and $workDir must be under sys_get_temp_dir(): + * that is the directory the wrapper's Docker strategy mounts into the container, so all + * strategies see the same files at the same paths. + * + * @param array $overrides Options merged over the case's (false removes one) + * @throws \RuntimeException when wkhtmltopdf fails or writes no PDF + */ + public static function render(string $strategy, string $case, string $workDir, array $overrides = []): string + { + $def = self::cases()[$case] ?? throw new \InvalidArgumentException("Unknown case: $case"); + if (!is_dir($workDir) && !mkdir($workDir, 0777, true) && !is_dir($workDir)) { + throw new \RuntimeException("Could not create $workDir"); + } + foreach (glob(self::fixtureDir() . '/*') ?: [] as $file) { + if (is_file($file)) { + copy($file, $workDir . '/' . basename($file)); + } + } + + $options = array_merge($def['options'], $overrides); + foreach ($options as $name => $value) { + if (is_string($value)) { + $options[$name] = str_replace('{dir}', $workDir, $value); + } + } + $input = $workDir . '/' . $def['html']; + $pdf = $workDir . '/' . $case . '.pdf'; + + if ($strategy === 'auto') { + # Through the public wrapper API; a local path works where a URL is expected + $result = WkhtmltoPdf::create()->setOptions($options)->timeout(self::RENDER_TIMEOUT)->generateFromUrl($input, $pdf); + $exit = $result->exitCode; + $stderr = $result->errorOutput; + } else { + $prefix = self::isDocker($strategy) + ? ['docker', 'run', '--rm', '-v', "$workDir:$workDir", self::dockerImage($strategy)] + : [self::binaryFor($strategy)]; + $process = new Process(array_merge($prefix, self::buildArgs($options), [$input, $pdf])); + $process->setTimeout(self::RENDER_TIMEOUT); + $process->run(); + $exit = $process->getExitCode(); + $stderr = $process->getErrorOutput(); + } + + # wkhtmltopdf writes progress to stderr even on success, so the exit code and the file decide + if ($exit !== 0 || !is_file($pdf) || filesize($pdf) === 0) { + throw new \RuntimeException("Render of '$case' with $strategy failed (exit $exit): " . trim($stderr)); + } + + return $pdf; + } + + /** + * Same mapping as WkhtmltoPdf::buildArgs(): true is a bare flag, false leaves the option out. + * + * @param array $options + * @return string[] + */ + private static function buildArgs(array $options): array + { + $args = []; + foreach ($options as $name => $value) { + if ($value === true) { + $args[] = '--' . $name; + } elseif ($value !== false) { + $args[] = '--' . $name; + $args[] = (string) $value; + } + } + + return $args; + } + + # --- Geometry --- + + /** + * Page count and per-page size ("W x H" in points, as pdfinfo prints them) of a PDF. + * + * @return array{pages: int, sizes: array} + */ + public static function geometry(string $pdf): array + { + $info = Xpdf::run('pdfinfo', [$pdf], 30); + if (!$info->isSuccessful() || !preg_match('/^Pages:\s+(\d+)/m', $info->output, $m)) { + throw new \RuntimeException("pdfinfo failed on $pdf: " . trim($info->errorOutput)); + } + $pages = (int) $m[1]; + + # -f/-l make pdfinfo print one "Page N size:" line per page instead of only the first + $sizes = []; + $perPage = Xpdf::run('pdfinfo', ['-f', '1', '-l', (string) $pages, $pdf], 30); + preg_match_all('/^Page\s+(\d+) size:\s+([\d.]+ x [\d.]+) pts/m', $perPage->output, $all, PREG_SET_ORDER); + foreach ($all as $row) { + $sizes[(int) $row[1]] = $row[2]; + } + + return ['pages' => $pages, 'sizes' => $sizes]; + } + + /** + * Differences between a PDF's geometry and a case's expected geometry, empty when it matches. + * + * @param array{pages: int, sizes: array} $geometry + * @return string[] + */ + public static function geometryProblems(array $geometry, int $pages, string $size): array + { + $problems = []; + if ($geometry['pages'] !== $pages) { + $problems[] = "page count {$geometry['pages']}, expected $pages"; + } + foreach ($geometry['sizes'] as $page => $actual) { + if ($actual !== $size) { + $problems[] = "page $page size $actual pts, expected $size pts"; + } + } + + return $problems; + } + + # --- Pixels --- + + /** + * Rasterise every page of a PDF at DPI. Returns the PNG paths in page order. + * + * @return string[] + */ + public static function rasterise(string $pdf, string $pngRoot): array + { + $result = Xpdf::run('pdftopng', ['-r', (string) self::DPI, $pdf, $pngRoot], 120); + if (!$result->isSuccessful()) { + throw new \RuntimeException("pdftopng failed on $pdf: " . trim($result->errorOutput)); + } + $files = glob($pngRoot . '-*.png') ?: []; + sort($files); + + return $files; + } + + /** + * Compare two page images pixel by pixel. + * + * Returns the share of differing pixels in percent (100 when the image sizes differ, since + * then the page itself changed size). When $diffPng is given and anything differs, writes a + * diff image there: differing pixels red, the rest the expected page washed out to light grey. + * + * @return array{percent: float, pixels: int, reason: ?string} + */ + public static function compareImages(string $expectedPng, string $actualPng, ?string $diffPng = null): array + { + $a = imagecreatefrompng($expectedPng); + $b = imagecreatefrompng($actualPng); + if ($a === false || $b === false) { + throw new \RuntimeException("Could not read $expectedPng or $actualPng"); + } + $w = imagesx($a); + $h = imagesy($a); + if ($w !== imagesx($b) || $h !== imagesy($b)) { + return ['percent' => 100.0, 'pixels' => $w * $h, 'reason' => sprintf('image size %dx%d, expected %dx%d', imagesx($b), imagesy($b), $w, $h)]; + } + + # Palette PNGs would make imagecolorat() return indexes, not RGB + imagepalettetotruecolor($a); + imagepalettetotruecolor($b); + + $diff = $diffPng !== null ? imagecreatetruecolor($w, $h) : null; + $red = $diff !== null ? imagecolorallocate($diff, 255, 0, 0) : 0; + $count = 0; + for ($y = 0; $y < $h; $y++) { + for ($x = 0; $x < $w; $x++) { + $pa = imagecolorat($a, $x, $y); + $pb = imagecolorat($b, $x, $y); + $different = $pa !== $pb && max( + abs((($pa >> 16) & 0xFF) - (($pb >> 16) & 0xFF)), + abs((($pa >> 8) & 0xFF) - (($pb >> 8) & 0xFF)), + abs(($pa & 0xFF) - ($pb & 0xFF)) + ) > self::PIXEL_THRESHOLD; + if ($different) { + $count++; + } + if ($diff !== null) { + if ($different) { + imagesetpixel($diff, $x, $y, $red); + } else { + # Expected page at a quarter of its contrast, so the red stands out + $grey = 191 + intdiv((($pa >> 16) & 0xFF) + (($pa >> 8) & 0xFF) + ($pa & 0xFF), 12); + imagesetpixel($diff, $x, $y, ($grey << 16) | ($grey << 8) | $grey); + } + } + } + } + if ($diff !== null && $count > 0) { + if (!is_dir(dirname($diffPng))) { + mkdir(dirname($diffPng), 0777, true); + } + imagepng($diff, $diffPng); + } + + return ['percent' => $count * 100 / ($w * $h), 'pixels' => $count, 'reason' => null]; + } + + /** + * Compare a set of page images with another, page by page. + * + * @param string[] $expected PNG paths in page order + * @param string[] $actual PNG paths in page order + * @param string|null $diffRoot Diff images are written as "$diffRoot-page-N.diff.png" + * @return array keyed by page number + */ + public static function comparePages(array $expected, array $actual, ?string $diffRoot = null): array + { + $report = []; + $count = max(count($expected), count($actual)); + for ($i = 0; $i < $count; $i++) { + $page = $i + 1; + if (!isset($expected[$i], $actual[$i])) { + $report[$page] = ['percent' => 100.0, 'pixels' => 0, 'reason' => isset($expected[$i]) ? 'page missing' : 'extra page', 'pass' => false, 'diff' => null]; + continue; + } + $diffPng = $diffRoot !== null ? "$diffRoot-page-$page.diff.png" : null; + $r = self::compareImages($expected[$i], $actual[$i], $diffPng); + $r['pass'] = $r['reason'] === null && $r['percent'] <= self::TOLERANCE_PERCENT; + $r['diff'] = ($diffPng !== null && is_file($diffPng)) ? $diffPng : null; + $report[$page] = $r; + } + + return $report; + } + + /** + * Committed baseline PNGs of a case for a platform, in page order (empty if none). + * + * @return string[] + */ + public static function baselinePages(string $platform, string $case): array + { + $files = glob(self::baselineDir($platform, $case) . '/page-*.png') ?: []; + natsort($files); + + return array_values($files); + } + + /** + * Replace a case's baselines with the given page images. + * + * With $root, writes them in the same layout under another directory instead: a run without + * baselines proposes its render that way (CI uploads it), so adopting it is one copy into + * tests/Visual/baselines after looking at it. + * + * @param string[] $pages PNG paths in page order + */ + public static function writeBaselines(string $platform, string $case, array $pages, ?string $root = null): void + { + $dir = self::baselineDir($platform, $case, $root); + if (!is_dir($dir) && !mkdir($dir, 0777, true) && !is_dir($dir)) { + throw new \RuntimeException("Could not create $dir"); + } + foreach (glob($dir . '/page-*.png') ?: [] as $old) { + unlink($old); + } + foreach (array_values($pages) as $i => $png) { + copy($png, $dir . '/page-' . ($i + 1) . '.png'); + } + } + + # --- Scratch space --- + + /** + * A fresh working directory under sys_get_temp_dir() (see render() for why there). + */ + public static function makeWorkDir(string $label): string + { + $dir = sys_get_temp_dir() . '/wkhtmltopdf-visual-' . preg_replace('/[^a-z0-9]+/i', '-', $label) . '-' . bin2hex(random_bytes(4)); + mkdir($dir, 0777, true); + + return $dir; + } + + public static function removeTree(string $path): void + { + if (is_dir($path) && !is_link($path)) { + foreach (scandir($path) as $entry) { + if ($entry !== '.' && $entry !== '..') { + self::removeTree($path . '/' . $entry); + } + } + rmdir($path); + } elseif (file_exists($path) || is_link($path)) { + unlink($path); + } + } + + /** + * Copy a failing render (PDF and page PNGs; diff images are already written there) into + * outDir()/$label, so it survives the work dir's removal. Returns that directory. + * + * @param array $files Paths; a string key is used as the copy's file name + */ + public static function keep(string $label, array $files): string + { + $dir = self::outDir() . '/' . preg_replace('/[^a-z0-9._-]+/i', '-', $label); + if (!is_dir($dir)) { + mkdir($dir, 0777, true); + } + foreach ($files as $name => $file) { + if (is_file($file)) { + copy($file, $dir . '/' . (is_string($name) ? $name : basename($file))); + } + } + + return $dir; + } +} diff --git a/tests/Visual/VisualRegressionTest.php b/tests/Visual/VisualRegressionTest.php new file mode 100644 index 0000000..b15a34b --- /dev/null +++ b/tests/Visual/VisualRegressionTest.php @@ -0,0 +1,177 @@ + + */ + private static array $renders = []; + + public static function tearDownAfterClass(): void + { + foreach (self::$renders as $render) { + VisualCheck::removeTree($render['work']); + } + self::$renders = []; + } + + /** + * @return array + */ + public static function caseProvider(): array + { + $cases = []; + foreach (array_keys(VisualCheck::cases()) as $case) { + $cases[$case] = [$case]; + } + + return $cases; + } + + /** + * @return array}> + */ + public static function mutationProvider(): array + { + $mutations = []; + foreach (VisualCheck::mutations() as $label => $overrides) { + $mutations[$label] = [$overrides]; + } + + return $mutations; + } + + #[DataProvider('caseProvider')] + public function testGeometry(string $case): void + { + $render = $this->render($case); + $def = VisualCheck::cases()[$case]; + + $geometry = VisualCheck::geometry($render['pdf']); + + # Exact: wkhtmltopdf writes whole points for A4 (595 x 842), and a page count that is off + # by one is the classic symptom of a box that no longer fits its page + $this->assertSame($def['pages'], $geometry['pages'], "$case: page count"); + $this->assertSame(array_fill(1, $def['pages'], $def['size']), $geometry['sizes'], "$case: page sizes in pts"); + } + + #[DataProvider('caseProvider')] + public function testPixelsMatchBaseline(string $case): void + { + $render = $this->render($case); + $platform = VisualCheck::platformFor($this->strategy()); + $baseline = VisualCheck::baselinePages($platform, $case); + + if ($baseline === []) { + # A failure, not a skip: a platform without baselines would otherwise pass green forever + $proposed = VisualCheck::outDir() . '/proposed-baselines'; + VisualCheck::writeBaselines($platform, $case, $render['pages'], $proposed); + $this->fail("No $platform baselines for '$case'. This render is proposed as one in $proposed/$platform/$case; look at it, then copy $proposed/$platform into tests/Visual/baselines/ (or write baselines locally with: php tests/Visual/visual-check.php baseline --strategy=" . $this->strategy() . ')'); + } + + $label = "phpunit-$platform"; + $report = VisualCheck::comparePages($baseline, $render['pages'], VisualCheck::outDir() . "/$label/$case"); + $failures = array_filter($report, static fn(array $r): bool => !$r['pass']); + if ($failures !== []) { + VisualCheck::keep($label, array_merge([$render['pdf']], $render['pages'])); + } + + $this->assertSame([], array_map( + static fn(array $r): string => $r['reason'] ?? sprintf('%.4f %% of pixels differ, diff image: %s', $r['percent'], $r['diff'] ?? '(none)'), + $failures + ), "$case vs $platform baseline with " . VisualCheck::describe($this->strategy()) . ' (tolerance ' . VisualCheck::TOLERANCE_PERCENT . ' %)'); + } + + /** + * Must-fail control, kept as a permanent test: each mutation of the fullbleed options is a + * change the check exists to catch, so comparing its render with the fullbleed baseline must + * NOT pass. If one of these ever goes green, the check has gone blind to that kind of change. + * + * @param array $overrides + */ + #[DataProvider('mutationProvider')] + public function testCheckDetectsMutation(array $overrides): void + { + $this->requirePrerequisites(); + $strategy = $this->strategy(); + $platform = VisualCheck::platformFor($strategy); + $baseline = VisualCheck::baselinePages($platform, 'fullbleed'); + if ($baseline === []) { + $this->fail("No $platform baselines for 'fullbleed' to test the mutation against"); + } + + $work = VisualCheck::makeWorkDir('mutation'); + try { + $pdf = VisualCheck::render($strategy, 'fullbleed', $work, $overrides); + $def = VisualCheck::cases()['fullbleed']; + $geometryProblems = VisualCheck::geometryProblems(VisualCheck::geometry($pdf), $def['pages'], $def['size']); + # No diff images: these failures are expected and nobody needs to look at them + $report = VisualCheck::comparePages($baseline, VisualCheck::rasterise($pdf, "$work/fullbleed")); + $pixelFailures = array_filter($report, static fn(array $r): bool => !$r['pass']); + } finally { + VisualCheck::removeTree($work); + } + + $worst = max(array_column($report, 'percent')); + $this->assertTrue( + $geometryProblems !== [] || $pixelFailures !== [], + sprintf('The check did not notice %s: geometry matched and the worst page differed by only %.4f %%', json_encode($overrides), $worst) + ); + } + + /** + * Render a case once per process with the configured strategy. + * + * @return array{pdf: string, pages: string[], work: string} + */ + private function render(string $case): array + { + $this->requirePrerequisites(); + if (!isset(self::$renders[$case])) { + $work = VisualCheck::makeWorkDir($case); + $pdf = VisualCheck::render($this->strategy(), $case, $work); + self::$renders[$case] = [ + 'pdf' => $pdf, + 'pages' => VisualCheck::rasterise($pdf, "$work/$case"), + 'work' => $work, + ]; + } + + return self::$renders[$case]; + } + + private function requirePrerequisites(): void + { + $problem = VisualCheck::missingPrerequisite($this->strategy()); + if ($problem !== null) { + $this->markTestSkipped($problem); + } + } + + private function strategy(): string + { + $env = getenv('WKHTMLTOPDF_VISUAL_STRATEGY'); + + return ($env !== false && $env !== '') ? $env : 'auto'; + } +} diff --git a/tests/Visual/baselines/darwin/flow/page-1.png b/tests/Visual/baselines/darwin/flow/page-1.png new file mode 100644 index 0000000..89f1af5 Binary files /dev/null and b/tests/Visual/baselines/darwin/flow/page-1.png differ diff --git a/tests/Visual/baselines/darwin/flow/page-2.png b/tests/Visual/baselines/darwin/flow/page-2.png new file mode 100644 index 0000000..3155eb2 Binary files /dev/null and b/tests/Visual/baselines/darwin/flow/page-2.png differ diff --git a/tests/Visual/baselines/darwin/flow/page-3.png b/tests/Visual/baselines/darwin/flow/page-3.png new file mode 100644 index 0000000..52fb053 Binary files /dev/null and b/tests/Visual/baselines/darwin/flow/page-3.png differ diff --git a/tests/Visual/baselines/darwin/fullbleed/page-1.png b/tests/Visual/baselines/darwin/fullbleed/page-1.png new file mode 100644 index 0000000..7f211f1 Binary files /dev/null and b/tests/Visual/baselines/darwin/fullbleed/page-1.png differ diff --git a/tests/Visual/baselines/darwin/fullbleed/page-2.png b/tests/Visual/baselines/darwin/fullbleed/page-2.png new file mode 100644 index 0000000..cdeaa46 Binary files /dev/null and b/tests/Visual/baselines/darwin/fullbleed/page-2.png differ diff --git a/tests/Visual/baselines/darwin/header-footer/page-1.png b/tests/Visual/baselines/darwin/header-footer/page-1.png new file mode 100644 index 0000000..b2506a8 Binary files /dev/null and b/tests/Visual/baselines/darwin/header-footer/page-1.png differ diff --git a/tests/Visual/baselines/darwin/header-footer/page-2.png b/tests/Visual/baselines/darwin/header-footer/page-2.png new file mode 100644 index 0000000..5dc7fc2 Binary files /dev/null and b/tests/Visual/baselines/darwin/header-footer/page-2.png differ diff --git a/tests/Visual/baselines/darwin/lowquality/page-1.png b/tests/Visual/baselines/darwin/lowquality/page-1.png new file mode 100644 index 0000000..7f211f1 Binary files /dev/null and b/tests/Visual/baselines/darwin/lowquality/page-1.png differ diff --git a/tests/Visual/baselines/darwin/lowquality/page-2.png b/tests/Visual/baselines/darwin/lowquality/page-2.png new file mode 100644 index 0000000..cdeaa46 Binary files /dev/null and b/tests/Visual/baselines/darwin/lowquality/page-2.png differ diff --git a/tests/Visual/baselines/linux/flow/page-1.png b/tests/Visual/baselines/linux/flow/page-1.png new file mode 100644 index 0000000..4dec767 Binary files /dev/null and b/tests/Visual/baselines/linux/flow/page-1.png differ diff --git a/tests/Visual/baselines/linux/flow/page-2.png b/tests/Visual/baselines/linux/flow/page-2.png new file mode 100644 index 0000000..c27737f Binary files /dev/null and b/tests/Visual/baselines/linux/flow/page-2.png differ diff --git a/tests/Visual/baselines/linux/flow/page-3.png b/tests/Visual/baselines/linux/flow/page-3.png new file mode 100644 index 0000000..53df1d7 Binary files /dev/null and b/tests/Visual/baselines/linux/flow/page-3.png differ diff --git a/tests/Visual/baselines/linux/fullbleed/page-1.png b/tests/Visual/baselines/linux/fullbleed/page-1.png new file mode 100644 index 0000000..3c3f172 Binary files /dev/null and b/tests/Visual/baselines/linux/fullbleed/page-1.png differ diff --git a/tests/Visual/baselines/linux/fullbleed/page-2.png b/tests/Visual/baselines/linux/fullbleed/page-2.png new file mode 100644 index 0000000..820b99b Binary files /dev/null and b/tests/Visual/baselines/linux/fullbleed/page-2.png differ diff --git a/tests/Visual/baselines/linux/header-footer/page-1.png b/tests/Visual/baselines/linux/header-footer/page-1.png new file mode 100644 index 0000000..c6c9c97 Binary files /dev/null and b/tests/Visual/baselines/linux/header-footer/page-1.png differ diff --git a/tests/Visual/baselines/linux/header-footer/page-2.png b/tests/Visual/baselines/linux/header-footer/page-2.png new file mode 100644 index 0000000..2b5f839 Binary files /dev/null and b/tests/Visual/baselines/linux/header-footer/page-2.png differ diff --git a/tests/Visual/baselines/linux/lowquality/page-1.png b/tests/Visual/baselines/linux/lowquality/page-1.png new file mode 100644 index 0000000..3c3f172 Binary files /dev/null and b/tests/Visual/baselines/linux/lowquality/page-1.png differ diff --git a/tests/Visual/baselines/linux/lowquality/page-2.png b/tests/Visual/baselines/linux/lowquality/page-2.png new file mode 100644 index 0000000..820b99b Binary files /dev/null and b/tests/Visual/baselines/linux/lowquality/page-2.png differ diff --git a/tests/Visual/fixture/cover.html b/tests/Visual/fixture/cover.html new file mode 100644 index 0000000..86a997b --- /dev/null +++ b/tests/Visual/fixture/cover.html @@ -0,0 +1,52 @@ + + + + +wkhtmltopdf visual check + + + + +
+
+
+
+
+
+

Sans 36px bold heading

+

Arial/Helvetica 28px subtitle

+
+
50 x 50 mm box
left 20mm
top 50mm
+ +
PNG 400x200 px at 50x25 mm
+
0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789
+
+

sans-serif 16pt: The quick brown fox jumps over the lazy dog, 0123456789.

+

Arial stack 16pt bold: The quick brown fox jumps over the lazy dog.

+

serif 16pt: The quick brown fox jumps over the lazy dog, 0123456789.

+

monospace 10pt: The quick brown fox jumps over the lazy dog, 0123456789.

+

sans-serif 10pt: nowrap inline-block 80% Sphinx of black quartz, judge my vow. Pack my box with five dozen liquor jugs.

+
+ + + + + + +
#40 mm column30 mm30 mm
1alpha10.00x
2bravo200.50xx
3charlie delta echo3000.75xxx
+
page 1 of 2
+
+
+
+
+
+ +
+
0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789
+

Text that starts 210 mm below the top of the page via padding-top.

+
+
page 2 of 2
+
+ + \ No newline at end of file diff --git a/tests/Visual/fixture/fixture.css b/tests/Visual/fixture/fixture.css new file mode 100644 index 0000000..4c21e6f --- /dev/null +++ b/tests/Visual/fixture/fixture.css @@ -0,0 +1,162 @@ +/* + * Visual-check fixture styles. Everything that should land at a known place is sized in mm, so a + * scale or offset change in the renderer (version, smart shrinking, zoom, dpi) moves it by a + * measurable amount against the committed baseline PNGs. + * + * Fonts: host fonts only, no @font-face. A webfont was tried first (DejaVu Sans Mono from a file + * next to this CSS) and dropped: the bundled macOS binary applied it in only 30 of 40 renders + * of the same input, falling back to a system font in the others, which no fixed baseline can + * absorb. A data: URI and --javascript-delay did not help. + * - 'DejaVu Sans Mono', monospace: the "fixed" text. DejaVu is installed by the README's apt line + * (fonts-dejavu-core), so on Linux it is the same font everywhere; macOS falls back to its + * monospace font. Any monospace font is about 0.6 em per glyph, so the line wraps below hold on + * both (see .mono-wrap). + * - Generic and system stacks (sans-serif; Arial, Helvetica, sans-serif; serif; monospace), as + * most real templates use: these come from the host via fontconfig, so text in them moves when + * the host's fonts change, which is one of the things this check is meant to catch. + * Line heights are fixed in mm wherever text flows across pages, so the page count does not + * depend on which font a host picks; only the pixels do. + * + * Plain CSS, so comments are block comments only. + */ + +html, body { + margin: 0; + padding: 0; + background: #fff; + color: #000; +} + +body { + font-family: 'DejaVu Sans Mono', monospace; + font-size: 10pt; + line-height: 5mm; +} + +/* One exact A4 page. Without --disable-smart-shrinking, 0.12.6 scales this box and it no longer + lines up with the paper; 0.12.4-era templates shrank it to 209 x 295.5 mm to compensate. */ +.page { + position: relative; + width: 210mm; + height: 297mm; + overflow: hidden; + page-break-after: always; +} +.page.last { + page-break-after: auto; +} + +/* Frame exactly on the paper edge, and a second one 10 mm in. Any shift or scale shows as the + outer frame being cut off or leaving a white strip. */ +.frame { + position: absolute; + box-sizing: border-box; + border: 0.5mm solid #000; +} +.frame.edge { left: 0; top: 0; width: 210mm; height: 297mm; } +.frame.inset { left: 10mm; top: 10mm; width: 190mm; height: 277mm; border-style: dashed; border-color: #c00; } + +/* Rulers: one tick per mm, a long tick every 10 mm. Each tick is absolutely positioned at its own + mm offset rather than floated: QtWebKit lays out in whole px, so 1 mm (3.78 px) floats would + each round to 4 px and the error would add up along the ruler, making the ruler itself wrong. + Tick borders are 0.3 mm, not thinner: QtWebKit floors border widths to whole CSS px (96 dpi), + so 0.2 mm (0.76 px) renders as no border at all. */ +.ruler { + position: absolute; + overflow: hidden; +} +.ruler.h { height: 5mm; } +.ruler.v { width: 5mm; } +.ruler span { + position: absolute; + display: block; + box-sizing: border-box; +} +.ruler.h span { top: 0; width: 1mm; height: 2mm; border-left: 0.3mm solid #000; } +.ruler.h span.c { height: 5mm; } +.ruler.v span { left: 0; height: 1mm; width: 2mm; border-top: 0.3mm solid #000; } +.ruler.v span.c { width: 5mm; } + +.box-50 { + position: absolute; + box-sizing: border-box; + width: 50mm; + height: 50mm; + border: 0.3mm solid #00c; + background: #eef; + padding: 2mm; +} + +.img-50x25 { + position: absolute; + width: 50mm; + height: 25mm; +} + +/* 60 monospace glyphs at 10pt are about 60 x 0.6 x 10pt = 360pt = 127 mm wide, so this box + holds exactly five "0123456789 " groups per line: the sixth would end at column 65. */ +.mono-wrap { + position: absolute; + width: 128mm; + font-family: 'DejaVu Sans Mono', monospace; + font-size: 10pt; +} + +.host-fonts { + position: absolute; + width: 120mm; +} +.host-fonts p { margin: 0 0 1mm 0; } +.f-sans { font-family: sans-serif; } +.f-arial { font-family: Arial, Helvetica, sans-serif; } +.f-serif { font-family: serif; } +.f-mono { font-family: monospace; } +.s-36px { font-size: 36px; line-height: 1.1; font-weight: bold; } +.s-28px { font-size: 28px; line-height: 1.1; } +.s-16pt { font-size: 16pt; line-height: 1.2; } +.s-10pt { font-size: 10pt; line-height: 1.2; } +.nowrap { white-space: nowrap; display: inline-block; } + +table.grid { + position: absolute; + border-collapse: collapse; + table-layout: fixed; + width: 120mm; +} +table.grid td, table.grid th { + border: 0.3mm solid #000; + height: 8mm; + padding: 0 1mm; + font-family: 'DejaVu Sans Mono', monospace; + font-size: 9pt; + text-align: left; + overflow: hidden; +} +table.grid th { background: #ddd; } + +/* Flowing content (flow.html, header-footer.html): page breaks decided by wkhtmltopdf. */ +.flow h2 { + font-family: 'DejaVu Sans Mono', monospace; + font-size: 14pt; + line-height: 8mm; + margin: 6mm 0 2mm 0; + page-break-after: avoid; +} +.flow h2 + .line { page-break-before: avoid; page-break-after: avoid; } +.flow .keep { page-break-inside: avoid; border-left: 1mm solid #888; padding-left: 2mm; margin-bottom: 3mm; } +.flow .line { height: 5mm; overflow: hidden; } +.flow .break-before { page-break-before: always; } +.flow { margin: 0 15mm; } + +/* An element pulled up into the top margin by exactly that margin, a pattern used to cancel a + --margin-top for a full-page background. wkhtmltopdf clips it at the margin, which is part of + what the baseline records: only the lower 10 mm of this 26 mm band should be visible. + It must end above the first heading, positioned elements paint over in-flow text. */ +.into-margin { + position: absolute; + left: 0; + top: -16mm; + width: 210mm; + height: 26mm; + background: #fd8; +} diff --git a/tests/Visual/fixture/flow.html b/tests/Visual/fixture/flow.html new file mode 100644 index 0000000..20cca9e --- /dev/null +++ b/tests/Visual/fixture/flow.html @@ -0,0 +1,132 @@ + + + + +wkhtmltopdf visual check + + + + +
+
+ +
+

Flowing content

+
Headings avoid a break after them; each block avoids a break inside.
+

Section 1

+
Line directly after heading 1, kept with it
+
+
Section 1 keep-together line 1 of 4 ....................
+
Section 1 keep-together line 2 of 4 ....................
+
Section 1 keep-together line 3 of 4 ....................
+
Section 1 keep-together line 4 of 4 ....................
+
+

Section 2

+
Line directly after heading 2, kept with it
+
+
Section 2 keep-together line 1 of 4 ....................
+
Section 2 keep-together line 2 of 4 ....................
+
Section 2 keep-together line 3 of 4 ....................
+
Section 2 keep-together line 4 of 4 ....................
+
+

Section 3

+
Line directly after heading 3, kept with it
+
+
Section 3 keep-together line 1 of 4 ....................
+
Section 3 keep-together line 2 of 4 ....................
+
Section 3 keep-together line 3 of 4 ....................
+
Section 3 keep-together line 4 of 4 ....................
+
+

Section 4

+
Line directly after heading 4, kept with it
+
+
Section 4 keep-together line 1 of 4 ....................
+
Section 4 keep-together line 2 of 4 ....................
+
Section 4 keep-together line 3 of 4 ....................
+
Section 4 keep-together line 4 of 4 ....................
+
+

Section 5

+
Line directly after heading 5, kept with it
+
+
Section 5 keep-together line 1 of 4 ....................
+
Section 5 keep-together line 2 of 4 ....................
+
Section 5 keep-together line 3 of 4 ....................
+
Section 5 keep-together line 4 of 4 ....................
+
+

Section 6

+
Line directly after heading 6, kept with it
+
+
Section 6 keep-together line 1 of 4 ....................
+
Section 6 keep-together line 2 of 4 ....................
+
Section 6 keep-together line 3 of 4 ....................
+
Section 6 keep-together line 4 of 4 ....................
+
+

Section 7

+
Line directly after heading 7, kept with it
+
+
Section 7 keep-together line 1 of 4 ....................
+
Section 7 keep-together line 2 of 4 ....................
+
Section 7 keep-together line 3 of 4 ....................
+
Section 7 keep-together line 4 of 4 ....................
+
+

Section 8

+
Line directly after heading 8, kept with it
+
+
Section 8 keep-together line 1 of 4 ....................
+
Section 8 keep-together line 2 of 4 ....................
+
Section 8 keep-together line 3 of 4 ....................
+
Section 8 keep-together line 4 of 4 ....................
+
+

Section 9

+
Line directly after heading 9, kept with it
+
+
Section 9 keep-together line 1 of 4 ....................
+
Section 9 keep-together line 2 of 4 ....................
+
Section 9 keep-together line 3 of 4 ....................
+
Section 9 keep-together line 4 of 4 ....................
+
+

Section 10 (forced page break before)

+
Line directly after heading 10, kept with it
+
+
Section 10 keep-together line 1 of 4 ....................
+
Section 10 keep-together line 2 of 4 ....................
+
Section 10 keep-together line 3 of 4 ....................
+
Section 10 keep-together line 4 of 4 ....................
+
+

Section 11

+
Line directly after heading 11, kept with it
+
+
Section 11 keep-together line 1 of 4 ....................
+
Section 11 keep-together line 2 of 4 ....................
+
Section 11 keep-together line 3 of 4 ....................
+
Section 11 keep-together line 4 of 4 ....................
+
+

Section 12

+
Line directly after heading 12, kept with it
+
+
Section 12 keep-together line 1 of 4 ....................
+
Section 12 keep-together line 2 of 4 ....................
+
Section 12 keep-together line 3 of 4 ....................
+
Section 12 keep-together line 4 of 4 ....................
+
+

Section 13

+
Line directly after heading 13, kept with it
+
+
Section 13 keep-together line 1 of 4 ....................
+
Section 13 keep-together line 2 of 4 ....................
+
Section 13 keep-together line 3 of 4 ....................
+
Section 13 keep-together line 4 of 4 ....................
+
+

Section 14

+
Line directly after heading 14, kept with it
+
+
Section 14 keep-together line 1 of 4 ....................
+
Section 14 keep-together line 2 of 4 ....................
+
Section 14 keep-together line 3 of 4 ....................
+
Section 14 keep-together line 4 of 4 ....................
+
+ +
+ + \ No newline at end of file diff --git a/tests/Visual/fixture/footer.html b/tests/Visual/fixture/footer.html new file mode 100644 index 0000000..103ad28 --- /dev/null +++ b/tests/Visual/fixture/footer.html @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + +
footer: 8 mm, border on the toppage /
+ + \ No newline at end of file diff --git a/tests/Visual/fixture/header-footer.html b/tests/Visual/fixture/header-footer.html new file mode 100644 index 0000000..00168aa --- /dev/null +++ b/tests/Visual/fixture/header-footer.html @@ -0,0 +1,18 @@ + + + + +wkhtmltopdf visual check + + + + +
+

Header and footer

+
0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789
+

Host sans-serif 10pt between header and footer.

+

Second page

+
0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789
+
+ + \ No newline at end of file diff --git a/tests/Visual/fixture/header.html b/tests/Visual/fixture/header.html new file mode 100644 index 0000000..7c8f6ee --- /dev/null +++ b/tests/Visual/fixture/header.html @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + +
header: 8 mm, border on the bottompage /
+ + \ No newline at end of file diff --git a/tests/Visual/fixture/image.png b/tests/Visual/fixture/image.png new file mode 100644 index 0000000..c7fc536 Binary files /dev/null and b/tests/Visual/fixture/image.png differ diff --git a/tests/Visual/fixture/make-image.php b/tests/Visual/fixture/make-image.php new file mode 100644 index 0000000..5b2cca3 --- /dev/null +++ b/tests/Visual/fixture/make-image.php @@ -0,0 +1,44 @@ + [$r, $g, $b]) { + $c = imagecolorallocate($img, $r, $g, $b); + imagefilledrectangle($img, $i * 50, 0, $i * 50 + 49, 99, $c); +} + +# 10 px checkerboard in the bottom-left half +for ($y = 100; $y < 200; $y += 10) { + for ($x = 0; $x < 200; $x += 10) { + if ((($x + $y) / 10) % 2 === 0) { + imagefilledrectangle($img, $x, $y, $x + 9, $y + 9, $black); + } + } +} + +# Diagonal in the bottom-right half, and a 1 px frame around everything +imageline($img, 200, 100, 399, 199, $black); +imagerectangle($img, 0, 0, $w - 1, $h - 1, $black); + +imagepng($img, __DIR__ . '/image.png', 9); diff --git a/tests/Visual/visual-check.php b/tests/Visual/visual-check.php new file mode 100644 index 0000000..ae6e34f --- /dev/null +++ b/tests/Visual/visual-check.php @@ -0,0 +1,196 @@ + | /path/to/wkhtmltopdf + * Exit codes: 0 pass, 1 differences found, 2 usage error or prerequisite missing. + * Failing renders, their pages and red diff images go to tests/Visual/out/ (or $WKHTMLTOPDF_VISUAL_OUT). + */ + +use Restruct\WkhtmltoPdf\Tests\Visual\VisualCheck; + +require dirname(__DIR__, 2) . '/vendor/autoload.php'; + +$argv = $_SERVER['argv']; +array_shift($argv); +$command = array_shift($argv) ?? ''; + +$positional = []; +$opts = ['strategy' => 'auto', 'case' => '', 'a-opt' => [], 'b-opt' => []]; +foreach ($argv as $arg) { + if (preg_match('/^--(strategy|case)=(.*)$/', $arg, $m)) { + $opts[$m[1]] = $m[2]; + } elseif (preg_match('/^--([ab]-opt)=([^=]+)=(.*)$/', $arg, $m)) { + $opts[$m[1]][$m[2]] = match ($m[3]) { + 'true' => true, + 'false' => false, + default => $m[3], + }; + } elseif (str_starts_with($arg, '--')) { + fwrite(STDERR, "Unknown option: $arg\n"); + exit(2); + } else { + $positional[] = $arg; + } +} + +$allCases = array_keys(VisualCheck::cases()); +$cases = $opts['case'] === '' ? $allCases : explode(',', $opts['case']); +foreach ($cases as $case) { + if (!in_array($case, $allCases, true)) { + fwrite(STDERR, "Unknown case: $case (cases: " . implode(', ', $allCases) . ")\n"); + exit(2); + } +} + +/** + * Print one page comparison table and return whether every page passed. + * + * @param array $report + */ +$printPages = static function (array $report): bool { + $ok = true; + foreach ($report as $page => $r) { + $ok = $ok && $r['pass']; + printf( + " page %d: %s %.4f %% differing (%d px)%s%s\n", + $page, + $r['pass'] ? 'PASS' : 'FAIL', + $r['percent'], + $r['pixels'], + $r['reason'] !== null ? ' ' . $r['reason'] : '', + $r['diff'] !== null ? ' diff: ' . $r['diff'] : '' + ); + } + + return $ok; +}; + +$requireStrategy = static function (string $strategy): void { + $problem = VisualCheck::missingPrerequisite($strategy); + if ($problem !== null) { + fwrite(STDERR, "Cannot run with '$strategy': $problem\n"); + exit(2); + } +}; + +$allOk = true; + +switch ($command) { + case 'check': + case 'baseline': + $strategy = $opts['strategy']; + $requireStrategy($strategy); + $platform = VisualCheck::platformFor($strategy); + printf("%s with %s, %s baselines, %d dpi, tolerance %.2f %%\n", $command, VisualCheck::describe($strategy), $platform, VisualCheck::DPI, VisualCheck::TOLERANCE_PERCENT); + + foreach ($cases as $case) { + $def = VisualCheck::cases()[$case]; + $work = VisualCheck::makeWorkDir($case); + try { + $pdf = VisualCheck::render($strategy, $case, $work); + $geometry = VisualCheck::geometry($pdf); + $problems = VisualCheck::geometryProblems($geometry, $def['pages'], $def['size']); + printf(" %s: %d page(s), %s\n", $case, $geometry['pages'], $problems === [] ? 'geometry OK' : 'GEOMETRY FAIL: ' . implode('; ', $problems)); + $pages = VisualCheck::rasterise($pdf, "$work/$case"); + + if ($command === 'baseline') { + if ($problems !== []) { + $allOk = false; + echo " not written: geometry must match the case definition first\n"; + continue; + } + VisualCheck::writeBaselines($platform, $case, $pages); + echo ' written to ' . VisualCheck::baselineDir($platform, $case) . "\n"; + continue; + } + + $baseline = VisualCheck::baselinePages($platform, $case); + if ($baseline === []) { + $allOk = false; + echo " FAIL: no $platform baselines (write them with: baseline --strategy=$strategy --case=$case)\n"; + VisualCheck::keep("check-$platform", array_merge([$pdf], $pages)); + continue; + } + $label = "check-$platform"; + $report = VisualCheck::comparePages($baseline, $pages, VisualCheck::outDir() . "/$label/$case"); + $caseOk = $printPages($report) && $problems === []; + if (!$caseOk) { + echo ' kept in ' . VisualCheck::keep($label, array_merge([$pdf], $pages)) . "\n"; + } + $allOk = $allOk && $caseOk; + } catch (\RuntimeException $e) { + $allOk = false; + echo " $case: ERROR " . $e->getMessage() . "\n"; + } finally { + VisualCheck::removeTree($work); + } + } + break; + + case 'compare': + if (count($positional) !== 2) { + fwrite(STDERR, "compare needs two strategies, e.g.: compare bundled docker\n"); + exit(2); + } + [$a, $b] = $positional; + $requireStrategy($a); + $requireStrategy($b); + printf("compare A = %s%s\n with B = %s%s\n", VisualCheck::describe($a), $opts['a-opt'] ? ' ' . json_encode($opts['a-opt']) : '', VisualCheck::describe($b), $opts['b-opt'] ? ' ' . json_encode($opts['b-opt']) : ''); + + foreach ($cases as $case) { + $workA = VisualCheck::makeWorkDir("$case-a"); + $workB = VisualCheck::makeWorkDir("$case-b"); + try { + $pdfA = VisualCheck::render($a, $case, $workA, $opts['a-opt']); + $pdfB = VisualCheck::render($b, $case, $workB, $opts['b-opt']); + $geoA = VisualCheck::geometry($pdfA); + $geoB = VisualCheck::geometry($pdfB); + $geoOk = $geoA === $geoB; + printf(" %s: A %d page(s) %s, B %d page(s) %s: %s\n", $case, $geoA['pages'], implode(', ', array_unique($geoA['sizes'])), $geoB['pages'], implode(', ', array_unique($geoB['sizes'])), $geoOk ? 'geometry same' : 'GEOMETRY DIFFERS'); + $label = 'compare'; + $report = VisualCheck::comparePages( + VisualCheck::rasterise($pdfA, "$workA/$case-A"), + $pagesB = VisualCheck::rasterise($pdfB, "$workB/$case-B"), + VisualCheck::outDir() . "/$label/$case" + ); + $caseOk = $printPages($report) && $geoOk; + if (!$caseOk) { + echo ' kept in ' . VisualCheck::keep($label, array_merge(["$case-A.pdf" => $pdfA, "$case-B.pdf" => $pdfB], glob("$workA/$case-A-*.png") ?: [], $pagesB)) . "\n"; + } + $allOk = $allOk && $caseOk; + } catch (\RuntimeException $e) { + $allOk = false; + echo " $case: ERROR " . $e->getMessage() . "\n"; + } finally { + VisualCheck::removeTree($workA); + VisualCheck::removeTree($workB); + } + } + break; + + default: + fwrite(STDERR, "Usage: php tests/Visual/visual-check.php check|compare|baseline [...] (see the header of this file)\n"); + exit(2); +} + +echo $allOk ? "PASS\n" : "FAIL\n"; +exit($allOk ? 0 : 1);