118 lines
7.0 KiB
Python
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()
|