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:
Rob committed 2026-04-17 13:48:27 +02:00
1 parent 34fe877bb2
commit 34e9aa49d6
8 files changed
+188 -55

No files matched your search

+20 -50
View File
@@ -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: