feat(cli): add shared PDF-to-image rendering and document usage
add shared render_pdf_pages helper for consistent rasterization reuse the same renderer in regression test and CLI flow add new render-image CLI command to export PDF pages as PNG support single-page render (--page) and full export (--all-pages) add configurable output path and DPI options document the new rendering command and options in README
This commit is contained in:
1 parent
34fe877bb2
commit
34e9aa49d6
8 files changed
+188
-55
No files matched your search
+20
-50
@@ -39,10 +39,13 @@ import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pymupdf
|
||||
import pytest
|
||||
from pypdf import PdfReader, PdfWriter
|
||||
from pypdf.generic import BooleanObject, NameObject
|
||||
from PIL import Image
|
||||
|
||||
from src.pdf_render import render_pdf_pages
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -50,13 +53,6 @@ FIXTURES_DIR = Path(__file__).parent / "fixtures"
|
||||
SIMILARITY_THRESHOLD = 1.00 # minimum fraction of matching pixels per page
|
||||
PIXEL_TOLERANCE = 5 # max per-channel delta still counted as "same"
|
||||
RENDER_DPI = 150 # rendering resolution for PDF → image
|
||||
PAGE_WIDTH_PTS = 595.2760009765625
|
||||
PAGE_HEIGHT_PTS = 841.8900146484375
|
||||
# Ignore rendering differences for two JS-driven button widgets (bottom-right).
|
||||
IGNORE_RECTS_PTS = [
|
||||
(457.8450012207031, 745.5402221679688, 563.4010009765625, 765.5402221679688),
|
||||
(457.8450012207031, 767.4678955078125, 563.4010009765625, 786.35693359375),
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -111,27 +107,6 @@ def _discover_fixture_params() -> list:
|
||||
return params
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PDF rendering via pymupdf
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _render_pdf_pages(pdf_path: Path, dpi: int = RENDER_DPI) -> list[np.ndarray]:
|
||||
"""Render all pages of a PDF to RGB numpy arrays using pymupdf."""
|
||||
zoom = dpi / 72.0
|
||||
mat = pymupdf.Matrix(zoom, zoom)
|
||||
doc = pymupdf.open(str(pdf_path))
|
||||
images = []
|
||||
for page in doc:
|
||||
pix = page.get_pixmap(matrix=mat)
|
||||
img = np.frombuffer(pix.samples, dtype=np.uint8).reshape(pix.h, pix.w, pix.n)
|
||||
# Convert RGBA → RGB if needed
|
||||
if pix.n == 4:
|
||||
img = img[:, :, :3]
|
||||
images.append(img.copy())
|
||||
doc.close()
|
||||
return images
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Comparison helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -143,22 +118,17 @@ def _resize_to_match(img: np.ndarray, target_shape: tuple) -> np.ndarray:
|
||||
return np.array(pil)
|
||||
|
||||
|
||||
def _mask_ignored_regions(img: np.ndarray) -> np.ndarray:
|
||||
"""Return a copy of *img* where ignored regions are blacked out."""
|
||||
masked = img.copy()
|
||||
h, w = masked.shape[:2]
|
||||
scale_x = w / PAGE_WIDTH_PTS
|
||||
scale_y = h / PAGE_HEIGHT_PTS
|
||||
def _set_need_appearances(pdf_path: Path, enabled: bool = True) -> None:
|
||||
"""Set /AcroForm /NeedAppearances on *pdf_path* in-place."""
|
||||
reader = PdfReader(str(pdf_path))
|
||||
writer = PdfWriter()
|
||||
writer.clone_document_from_reader(reader)
|
||||
|
||||
for x0_pt, y0_pt, x1_pt, y1_pt in IGNORE_RECTS_PTS:
|
||||
x0 = max(0, min(w, int(round(x0_pt * scale_x))))
|
||||
y0 = max(0, min(h, int(round(y0_pt * scale_y))))
|
||||
x1 = max(0, min(w, int(round(x1_pt * scale_x))))
|
||||
y1 = max(0, min(h, int(round(y1_pt * scale_y))))
|
||||
if x1 > x0 and y1 > y0:
|
||||
masked[y0:y1, x0:x1] = 0
|
||||
if "/AcroForm" in writer._root_object:
|
||||
writer._root_object["/AcroForm"][NameObject("/NeedAppearances")] = BooleanObject(enabled)
|
||||
|
||||
return masked
|
||||
with pdf_path.open("wb") as fh:
|
||||
writer.write(fh)
|
||||
|
||||
|
||||
def _compute_similarity(ref: np.ndarray, gen: np.ndarray) -> float:
|
||||
@@ -259,9 +229,14 @@ def test_pdf_matches_reference(
|
||||
failures.append(f" FEHLT: '{ref_pdf.name}' wurde nicht generiert.")
|
||||
continue
|
||||
|
||||
# Regression-specific render behavior: keep NeedAppearances disabled.
|
||||
# With pymupdf, NeedAppearances=True can hide widget AP content
|
||||
# (footer and bottom-right buttons) during rasterization.
|
||||
_set_need_appearances(generated_pdf, enabled=False)
|
||||
|
||||
# Render both PDFs to images
|
||||
ref_images = _render_pdf_pages(ref_pdf)
|
||||
gen_images = _render_pdf_pages(generated_pdf)
|
||||
ref_images = render_pdf_pages(ref_pdf, RENDER_DPI)
|
||||
gen_images = render_pdf_pages(generated_pdf, RENDER_DPI)
|
||||
page_count = max(len(ref_images), len(gen_images))
|
||||
|
||||
for page_idx in range(page_count):
|
||||
@@ -277,11 +252,6 @@ def test_pdf_matches_reference(
|
||||
ref_img = ref_images[page_idx]
|
||||
gen_img = gen_images[page_idx]
|
||||
|
||||
# Ignore the two bottom-right JavaScript button regions on page 1.
|
||||
if page_idx == 0:
|
||||
ref_img = _mask_ignored_regions(ref_img)
|
||||
gen_img = _mask_ignored_regions(gen_img)
|
||||
|
||||
similarity = _compute_similarity(ref_img, gen_img)
|
||||
|
||||
if similarity < SIMILARITY_THRESHOLD:
|
||||
|
||||
Reference in new issue
Block a user