#!/usr/bin/env python3 # SPDX-License-Identifier: GPL-3.0-or-later # SPDX-FileCopyrightText: 2025 ai-ad4 and the FreePDFEditor contributors # # Pixel-diff harness scaffolding (engineering plan §8.2 and §14 step 3). # # Gate: render every page of every corpus PDF at 5 zoom levels with our # renderer and compare against the Ghostscript + PDFium reference renderers. # Threshold: ≤ 0.1% differing pixels and no new failures. # # This is the *scaffold* for M0: it defines the CLI contract, the corpus # enumeration, the per-page invocation shape, and the JSON report format that # matches spike_runner contract, but it does not yet invoke a renderer (our # renderer does not exist yet — it lands with the L3 display-list renderer in # M2). CI wires this in as a no-op gate now and fills in the renderer call in # M2; running it today exits 0 only when `--noop` is passed, so a missing # renderer is a loud "not implemented yet" rather than a silent pass. # # Usage: # run_pixel_diff.py --corpus --gs --pdfium \ # --ours [--zooms 0.5,1.0,2.0] [--threshold 0.001] \ # [--report ] [--noop] # # --report writes a single-line JSON document on stdout compatible with the # spike runner contract so CI parses one format across spikes and gates. from __future__ import annotations import argparse import dataclasses import json import os import pathlib import sys import tempfile from typing import Iterable @dataclasses.dataclass class PageResult: pdf: str page: int zoom: float differing_pixels: int total_pixels: int failed: bool # renderer crashed / refused error: str = "" def iter_pdfs(corpus: pathlib.Path) -> Iterable[pathlib.Path]: if not corpus.exists(): return for root, _dirs, files in os.walk(corpus): for f in files: if f.lower().endswith(".pdf"): yield pathlib.Path(root) / f def render_page(_pdf: pathlib.Path, _page: int, _zoom: float, _renderer: str, _out: pathlib.Path) -> tuple[bool, str]: """Stub: the real renderer call lands in M2 with the L3 display-list renderer. Returns (ok, error). Today it always reports 'not implemented' unless invoked with --noop, in which case the caller short-circuits.""" return False, "renderer not implemented (M2)" def compare_png(_a: pathlib.Path, _b: pathlib.Path) -> tuple[int, int]: """Stub: per-pixel diff. Returns (differing, total). Real impl in M2 uses Pillow + numpy; pinned here so the harness shape is testable.""" return 0, 0 def run(args: argparse.Namespace) -> int: zooms = [float(z) for z in args.zooms.split(",")] pdfs = list(iter_pdfs(pathlib.Path(args.corpus))) if not pdfs: report = { "spike": "GATE", "name": "pixel-diff vs Ghostscript+PDFium", "total": 0, "passed": 0, "failed": 0, "errored": 0, "metric": "max_differing_pixel_rate", "value": 0.0, "target": args.threshold, "gate_met": False, "samples": [], "notes": "no PDFs found in corpus directory", } print(json.dumps(report, separators=(",", ":"))) return 1 if args.noop: report = { "spike": "GATE", "name": "pixel-diff vs Ghostscript+PDFium", "total": len(pdfs), "passed": len(pdfs), "failed": 0, "errored": 0, "metric": "max_differing_pixel_rate", "value": 0.0, "target": args.threshold, "gate_met": True, "samples": [], "notes": "noop mode — renderer not implemented until M2", } print(json.dumps(report, separators=(",", ":"))) return 0 # Real path (M2): for each pdf/page/zoom, render with ours, gs and pdfium, # diff, accumulate the max differing rate. Today this errors per page. with tempfile.TemporaryDirectory(prefix="freepdfeditor-pixeldiff-") as tmpd: tmp = pathlib.Path(tmpd) total_pages = 0 failed = 0 errored = 0 worst = 0.0 samples: list[str] = [] for pdf in pdfs: # Page count unknown until a renderer exists; defer to M2. for page in (1,): # placeholder for zoom in zooms: total_pages += 1 ours = tmp / f"ours.png" ok, err = render_page(pdf, page, zoom, args.ours, ours) if not ok: errored += 1 if len(samples) < 16: samples.append(f"{pdf.name}@{page}@{zoom}: {err}") continue # Reference renders would go here in M2. diff, total = compare_png(ours, ours) rate = diff / total if total else 0.0 worst = max(worst, rate) if rate > args.threshold: failed += 1 if len(samples) < 16: samples.append(f"{pdf.name}@{page}@{zoom}: {rate:.4f}") gate = (failed == 0 and errored == 0 and worst <= args.threshold) report = { "spike": "GATE", "name": "pixel-diff vs Ghostscript+PDFium", "total": total_pages, "passed": total_pages - failed - errored, "failed": failed, "errored": errored, "metric": "max_differing_pixel_rate", "value": worst, "target": args.threshold, "gate_met": gate, "samples": samples, "notes": "M0 scaffold — real comparison lands with the L3 renderer in M2", } print(json.dumps(report, separators=(",", ":"))) return 0 if gate else 1 def main(argv: list[str]) -> int: p = argparse.ArgumentParser(description=__doc__.splitlines()[0] if __doc__ else "") p.add_argument("--corpus", required=True, help="corpus directory") p.add_argument("--gs", required=True, help="path to gs (Ghostscript)") p.add_argument("--pdfium", required=True, help="path to pdfium test driver") p.add_argument("--ours", required=True, help="path to our renderer driver") p.add_argument("--zooms", default="0.5,1.0,1.5,2.0,3.0", help="comma-separated zoom levels") p.add_argument("--threshold", type=float, default=0.001, help="max allowed differing-pixel rate (default 0.1%%)") p.add_argument("--report", help="write JSON report to this path too") p.add_argument("--noop", action="store_true", help="skip rendering — used until M2 wires the renderer") args = p.parse_args(argv) rc = run(args) return rc if __name__ == "__main__": sys.exit(main(sys.argv[1:]))