180 lines
6.7 KiB
Python
Executable File
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:])) |