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

+24
View File
@@ -86,6 +86,20 @@ python generate.py validate --file "input/Anwesenheitsliste 2026.xlsx" --month F
Führt alle Prüfungen durch und zeigt die Vorschau — ohne Dateien zu schreiben.
### PDF als Bild rendern
```bash
python generate.py render-image --pdf "output/(1) Mustermann, Max.pdf" --page 1 --out "preview.png"
```
Dieser Befehl rendert eine PDF-Seite als PNG-Bild. Er nutzt dieselbe Render-Funktion wie der Regressionstest, damit CLI-Vorschau und Testvergleich technisch identisch arbeiten.
Alle Seiten auf einmal rendern:
```bash
python generate.py render-image --pdf "output/(1) Mustermann, Max.pdf" --all-pages --out "render_out"
```
---
## Optionen
@@ -105,6 +119,16 @@ Führt alle Prüfungen durch und zeigt die Vorschau — ohne Dateien zu schreibe
| `--file` | ja | Pfad zur Excel-Datei |
| `--month` | ja | Name des Monats-Sheets |
### `render-image`
| Option | Pflicht | Beschreibung |
|---|---|---|
| `--pdf` | ja | Pfad zur PDF-Datei |
| `--out` | nein | Zieldatei (Einzelseite) oder Zielordner (`--all-pages`) |
| `--dpi` | nein | Render-Auflösung in DPI (Standard: `150`) |
| `--page` | nein | 1-basierte Seitennummer (Standard: `1`) |
| `--all-pages` | nein | Rendert alle Seiten statt nur `--page` |
---
## Sheet-Namen
+2 -1
View File
@@ -30,5 +30,6 @@
"pdf_field_erstellungsdatum": "unterschrift.datum",
"pdf_pauschale_prefix": "pauschale_5",
"pauschale_present_value": "1",
"pdf_use_display_values": true
"pdf_use_display_values": true,
"pdf_need_appearances": false
}
+85
View File
@@ -10,6 +10,7 @@ import sys
from pathlib import Path
import click
from PIL import Image
# Add project root to sys.path so 'src' is importable
sys.path.insert(0, str(Path(__file__).parent))
@@ -17,6 +18,7 @@ sys.path.insert(0, str(Path(__file__).parent))
from src.config_loader import load_config
from src.excel_reader import extract_attendance, extract_persons, extract_year_from_filename
from src.pdf_filler import fill_pdf
from src.pdf_render import render_pdf_pages
from src.validators import show_preview_and_confirm, validate
@@ -145,5 +147,88 @@ def generate_cmd(excel_file: str, month: str, output_dir: str | None) -> None:
sys.exit(1)
@cli.command("render-image")
@click.option(
"--pdf",
"pdf_file",
required=True,
type=click.Path(exists=True, file_okay=True, dir_okay=False),
help="Pfad zur PDF-Datei, die als Bild gerendert werden soll.",
)
@click.option(
"--out",
"output_path",
required=False,
default=None,
type=click.Path(file_okay=True, dir_okay=True),
help="Ausgabepfad (Datei für Einzelseite oder Ordner für --all-pages).",
)
@click.option(
"--dpi",
required=False,
default=150,
show_default=True,
type=int,
help="Render-Auflösung in DPI.",
)
@click.option(
"--page",
required=False,
default=1,
show_default=True,
type=int,
help="1-basierter Seitenindex für Einzelseiten-Render.",
)
@click.option(
"--all-pages",
is_flag=True,
default=False,
help="Alle Seiten rendern (ignoriert --page).",
)
def render_image_cmd(
pdf_file: str,
output_path: str | None,
dpi: int,
page: int,
all_pages: bool,
) -> None:
"""Rendert eine PDF direkt als PNG-Bild(er) über die Regression-Renderfunktion."""
pdf_path = Path(pdf_file).resolve()
images = render_pdf_pages(pdf_path, dpi=dpi)
if not images:
click.echo("Keine Seiten in der PDF gefunden.", err=True)
sys.exit(1)
if all_pages:
if output_path:
out_dir = Path(output_path).resolve()
else:
out_dir = pdf_path.parent / f"{pdf_path.stem}_images"
out_dir.mkdir(parents=True, exist_ok=True)
for idx, img in enumerate(images, start=1):
out_file = out_dir / f"{pdf_path.stem}_seite_{idx:02d}.png"
Image.fromarray(img).save(out_file)
click.echo(f"[OK] {out_file}")
return
if page < 1 or page > len(images):
click.echo(
f"Ungültige Seite: {page}. PDF hat {len(images)} Seite(n).",
err=True,
)
sys.exit(1)
if output_path:
out_file = Path(output_path).resolve()
else:
out_file = pdf_path.parent / f"{pdf_path.stem}_seite_{page:02d}.png"
out_file.parent.mkdir(parents=True, exist_ok=True)
Image.fromarray(images[page - 1]).save(out_file)
click.echo(f"[OK] {out_file}")
if __name__ == "__main__":
cli()
+5
View File
@@ -85,6 +85,11 @@
"type": "boolean",
"description": "true = Dropdown-Anzeigewerte ins PDF schreiben (z.B. 'X = anwesend'); false = rohe Export-Werte (z.B. 'X')",
"default": true
},
"pdf_need_appearances": {
"type": "boolean",
"description": "Steuert AcroForm /NeedAppearances beim Schreiben der PDF. false wird fuer stabile PyMuPDF-Render bevorzugt (Footer/Buttons sichtbar).",
"default": false
}
}
}
+3
View File
@@ -64,6 +64,9 @@ def load_config(config_path: Path | None = None) -> Dict[str, Any]:
f"config.json fehlen Pflichtfelder: {', '.join(missing)}"
)
# Optional settings with backward-compatible defaults
data.setdefault("pdf_need_appearances", False)
_config_cache = data
_config_path_cache = config_path
return data
+25 -4
View File
@@ -247,6 +247,14 @@ def fill_pdf(
_apply_opt_mappings(fields, opt_maps)
SUMMARY_FIELDS_FOR_AP = (
"anwesend_sum",
"anwesend_gessum",
"pauschale_5_sum",
"pauschale_25_sum",
"pauschale_sum",
)
writer = PdfWriter()
writer.clone_document_from_reader(reader)
@@ -255,11 +263,24 @@ def fill_pdf(
_update_widget_visibility(writer, fields)
# Tell the PDF viewer to regenerate appearance streams from field values.
# Without this flag, dropdown fields (plan, pauschale) display stale text
# instead of their actual selected option (e.g. "8,60" for pauschale).
# Second pass: regenerate AP only for summary text fields.
# This keeps footer/buttons visible with NeedAppearances=False while still
# rendering summary values in image-based comparisons.
summary_fields = {
name: fields[name]
for name in SUMMARY_FIELDS_FOR_AP
if name in fields
}
if summary_fields:
for page in writer.pages:
writer.update_page_form_field_values(page, summary_fields, auto_regenerate=True)
# Configure viewer-side appearance regeneration via config switch.
# Default is False for stable pymupdf rendering (footer/buttons visible).
if "/AcroForm" in writer._root_object:
writer._root_object["/AcroForm"][NameObject("/NeedAppearances")] = BooleanObject(True)
writer._root_object["/AcroForm"][NameObject("/NeedAppearances")] = BooleanObject(
bool(config.get("pdf_need_appearances", False))
)
output_path.parent.mkdir(parents=True, exist_ok=True)
with output_path.open("wb") as fh:
+24
View File
@@ -0,0 +1,24 @@
"""Shared PDF rasterization helpers used by tests and CLI."""
from __future__ import annotations
from pathlib import Path
import numpy as np
import pymupdf
def render_pdf_pages(pdf_path: Path, dpi: int = 150) -> list[np.ndarray]:
"""Render all pages of *pdf_path* to RGB numpy arrays using pymupdf."""
zoom = dpi / 72.0
mat = pymupdf.Matrix(zoom, zoom)
doc = pymupdf.open(str(pdf_path))
images: list[np.ndarray] = []
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)
if pix.n == 4:
img = img[:, :, :3]
images.append(img.copy())
doc.close()
return images
+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: