freepdfeditor/ci/pixel-diff/run_pixel_diff.py

180 lines
6.7 KiB
Python
Executable File

#!/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 <dir> --gs <path> --pdfium <path> \
# --ours <path> [--zooms 0.5,1.0,2.0] [--threshold 0.001] \
# [--report <path>] [--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:]))