Files
jobtrackingapp/scripts/verify-cv-fidelity.py

118 lines
7.0 KiB
Python

"""Compare recorded real live-preview frames with native exported PDFs.
Requires pdfplumber, pypdfium2 and Pillow (available in Codex's bundled runtime).
Run the cv-readiness browser corpus first; this separate gate fails honestly on
material differences and never modifies the application or its PDF output.
"""
import argparse
import json
import re
from pathlib import Path
import pdfplumber
import pypdfium2
from PIL import Image, ImageDraw
MM_PER_POINT = 25.4 / 72
POINTS_PER_PIXEL = 72 / 96
POSITION_TOLERANCE_MM = 2
PAGE_TOLERANCE_MM = 0.5
FONT_TOLERANCE_PT = 0.5
COLOR_TOLERANCE = 1 / 255
def measure(root: Path, fixture: dict) -> dict:
directory = root / fixture["fixture"]
settings, preview = fixture["settings"], fixture["measured"]
width_mm, height_mm = (215.9, 279.4) if settings.get("pageSize") == "letter" else (210, 297)
failures, pdf_tokens, pages, fonts = [], [], [], set()
with pdfplumber.open(directory / "export.pdf") as pdf:
if len(pdf.pages) != fixture["previewPages"]:
failures.append(f"page count: preview {fixture['previewPages']}, PDF {len(pdf.pages)}")
for number, page in enumerate(pdf.pages):
dimensions = [page.width * MM_PER_POINT, page.height * MM_PER_POINT]
if any(abs(actual - expected) > PAGE_TOLERANCE_MM for actual, expected in zip(dimensions, [width_mm, height_mm])):
failures.append(f"page {number + 1}: unexpected physical dimensions {dimensions}")
text = page.extract_text() or ""
if not text.strip():
failures.append(f"page {number + 1}: blank page")
if "HIDDEN-MARKER" in text:
failures.append(f"page {number + 1}: hidden section leaked")
for word in page.extract_words(extra_attrs=["fontname", "size", "non_stroking_color"]):
fonts.add(word["fontname"])
token = re.fullmatch(r"[SEBPR]\d{2}", word["text"])
if token:
pdf_tokens.append({"token": token[0], "page": number + 1, "x": word["x0"], "y": word["top"], "fontSize": word["size"], "font": word["fontname"], "color": word["non_stroking_color"]})
margin_mm = float(preview["effectiveMargin"].strip().removesuffix("mm"))
outside = [c for c in page.chars if c["text"].strip() and (c["top"] * MM_PER_POINT < margin_mm - 1 or c["bottom"] * MM_PER_POINT > height_mm - margin_mm + 1)]
if outside:
failures.append(f"page {number + 1}: {len(outside)} glyphs outside vertical safe area")
pages.append({"page": number + 1, "dimensionsMm": dimensions, "text": text, "glyphBoundsPt": [min((c["x0"] for c in page.chars), default=None), min((c["top"] for c in page.chars), default=None), max((c["x1"] for c in page.chars), default=None), max((c["bottom"] for c in page.chars), default=None)]})
remaining = list(pdf_tokens)
comparisons = []
height_px = height_mm * 96 / 25.4
for token in preview["tokens"]:
match = next((candidate for candidate in remaining if candidate["token"] == token["token"]), None)
if match is None:
failures.append(f"missing PDF marker {token['token']}")
continue
remaining.remove(match)
preview_page = int(token["y"] // height_px) + 1
dx = abs(token["x"] * POINTS_PER_PIXEL - match["x"]) * MM_PER_POINT
dy = abs((token["y"] % height_px) * POINTS_PER_PIXEL - match["y"]) * MM_PER_POINT
font_delta = abs(float(token["fontSize"].removesuffix("px")) * POINTS_PER_PIXEL - match["fontSize"])
preview_color = [int(value) / 255 for value in re.findall(r"\d+", token["color"])[:3]]
pdf_color = match["color"]
color_delta = max(abs(a - b) for a, b in zip(preview_color, pdf_color)) if isinstance(pdf_color, (list, tuple)) and len(pdf_color) == 3 else None
comparisons.append({"token": token["token"], "previewPage": preview_page, "pdfPage": match["page"], "deltaXmm": dx, "deltaYmm": dy, "fontDeltaPt": font_delta, "colorDelta": color_delta})
if preview_page != match["page"]:
failures.append(f"{token['token']}: preview page {preview_page}, PDF page {match['page']}")
elif max(dx, dy) > POSITION_TOLERANCE_MM:
failures.append(f"{token['token']}: position delta x={dx:.2f}mm y={dy:.2f}mm")
if font_delta > FONT_TOLERANCE_PT:
failures.append(f"{token['token']}: font-size delta {font_delta:.2f}pt")
if color_delta is not None and color_delta > COLOR_TOLERANCE:
failures.append(f"{token['token']}: RGB component delta {color_delta:.4f}")
if remaining:
failures.append(f"extra PDF markers: {[word['token'] for word in remaining]}")
# Visual evidence uses actual exported bytes, rendered independently of Chromium.
document = pypdfium2.PdfDocument(str(directory / "export.pdf"))
for index in range(len(document)):
pdf_page = document[index]
bitmap = pdf_page.render(scale=96 / 72)
image = bitmap.to_pil()
image.save(directory / f"pdf-{index + 1}.png")
preview_path = directory / f"preview-{index + 1}.png"
if preview_path.exists():
preview_image = Image.open(preview_path).convert("RGB")
side_by_side = Image.new("RGB", (preview_image.width + image.width, max(preview_image.height, image.height) + 28), "#ffffff")
side_by_side.paste(preview_image, (0, 28))
side_by_side.paste(image, (preview_image.width, 28))
draw = ImageDraw.Draw(side_by_side)
draw.text((8, 6), "Live preview", fill="black")
draw.text((preview_image.width + 8, 6), "Native exported PDF", fill="black")
side_by_side.save(directory / f"comparison-{index + 1}.png")
bitmap.close()
pdf_page.close()
document.close()
return {"fixture": fixture["fixture"], "pass": not failures, "failures": failures, "previewPages": fixture["previewPages"], "pdfPages": len(pages), "fontNames": sorted(fonts), "comparisons": comparisons, "pages": pages}
def main():
parser = argparse.ArgumentParser()
parser.add_argument("directory", type=Path)
args = parser.parse_args()
inputs = json.loads((args.directory / "measurements.json").read_text(encoding="utf-8"))
results = [measure(args.directory, fixture) for fixture in inputs["fixtures"]]
report = {"tolerances": {"pageDimensionMm": PAGE_TOLERANCE_MM, "positionMm": POSITION_TOLERANCE_MM, "fontPt": FONT_TOLERANCE_PT, "rgbComponent": COLOR_TOLERANCE, "pageCountAndMembership": "exact"}, "browserVersion": inputs["browserVersion"], "passed": sum(result["pass"] for result in results), "total": len(results), "fixtures": results}
(args.directory / "fidelity-results.json").write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
for result in results:
print(f"{'PASS' if result['pass'] else 'FAIL'} {result['fixture']}: preview={result['previewPages']} PDF={result['pdfPages']}; {len(result['failures'])} differences")
raise SystemExit(0 if report["passed"] == report["total"] else 1)
if __name__ == "__main__":
main()