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. 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 ## 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 | | `--file` | ja | Pfad zur Excel-Datei |
| `--month` | ja | Name des Monats-Sheets | | `--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 ## Sheet-Namen
+2 -1
View File
@@ -30,5 +30,6 @@
"pdf_field_erstellungsdatum": "unterschrift.datum", "pdf_field_erstellungsdatum": "unterschrift.datum",
"pdf_pauschale_prefix": "pauschale_5", "pdf_pauschale_prefix": "pauschale_5",
"pauschale_present_value": "1", "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 from pathlib import Path
import click import click
from PIL import Image
# Add project root to sys.path so 'src' is importable # Add project root to sys.path so 'src' is importable
sys.path.insert(0, str(Path(__file__).parent)) 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.config_loader import load_config
from src.excel_reader import extract_attendance, extract_persons, extract_year_from_filename from src.excel_reader import extract_attendance, extract_persons, extract_year_from_filename
from src.pdf_filler import fill_pdf from src.pdf_filler import fill_pdf
from src.pdf_render import render_pdf_pages
from src.validators import show_preview_and_confirm, validate 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) 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__": if __name__ == "__main__":
cli() cli()
+5
View File
@@ -85,6 +85,11 @@
"type": "boolean", "type": "boolean",
"description": "true = Dropdown-Anzeigewerte ins PDF schreiben (z.B. 'X = anwesend'); false = rohe Export-Werte (z.B. 'X')", "description": "true = Dropdown-Anzeigewerte ins PDF schreiben (z.B. 'X = anwesend'); false = rohe Export-Werte (z.B. 'X')",
"default": true "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)}" f"config.json fehlen Pflichtfelder: {', '.join(missing)}"
) )
# Optional settings with backward-compatible defaults
data.setdefault("pdf_need_appearances", False)
_config_cache = data _config_cache = data
_config_path_cache = config_path _config_path_cache = config_path
return data return data
+25 -4
View File
@@ -247,6 +247,14 @@ def fill_pdf(
_apply_opt_mappings(fields, opt_maps) _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 = PdfWriter()
writer.clone_document_from_reader(reader) writer.clone_document_from_reader(reader)
@@ -255,11 +263,24 @@ def fill_pdf(
_update_widget_visibility(writer, fields) _update_widget_visibility(writer, fields)
# Tell the PDF viewer to regenerate appearance streams from field values. # Second pass: regenerate AP only for summary text fields.
# Without this flag, dropdown fields (plan, pauschale) display stale text # This keeps footer/buttons visible with NeedAppearances=False while still
# instead of their actual selected option (e.g. "8,60" for pauschale). # 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: 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) output_path.parent.mkdir(parents=True, exist_ok=True)
with output_path.open("wb") as fh: 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 from pathlib import Path
import numpy as np import numpy as np
import pymupdf
import pytest import pytest
from pypdf import PdfReader, PdfWriter
from pypdf.generic import BooleanObject, NameObject
from PIL import Image from PIL import Image
from src.pdf_render import render_pdf_pages
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Constants # Constants
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -50,13 +53,6 @@ FIXTURES_DIR = Path(__file__).parent / "fixtures"
SIMILARITY_THRESHOLD = 1.00 # minimum fraction of matching pixels per page SIMILARITY_THRESHOLD = 1.00 # minimum fraction of matching pixels per page
PIXEL_TOLERANCE = 5 # max per-channel delta still counted as "same" PIXEL_TOLERANCE = 5 # max per-channel delta still counted as "same"
RENDER_DPI = 150 # rendering resolution for PDF → image 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 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 # Comparison helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -143,22 +118,17 @@ def _resize_to_match(img: np.ndarray, target_shape: tuple) -> np.ndarray:
return np.array(pil) return np.array(pil)
def _mask_ignored_regions(img: np.ndarray) -> np.ndarray: def _set_need_appearances(pdf_path: Path, enabled: bool = True) -> None:
"""Return a copy of *img* where ignored regions are blacked out.""" """Set /AcroForm /NeedAppearances on *pdf_path* in-place."""
masked = img.copy() reader = PdfReader(str(pdf_path))
h, w = masked.shape[:2] writer = PdfWriter()
scale_x = w / PAGE_WIDTH_PTS writer.clone_document_from_reader(reader)
scale_y = h / PAGE_HEIGHT_PTS
for x0_pt, y0_pt, x1_pt, y1_pt in IGNORE_RECTS_PTS: if "/AcroForm" in writer._root_object:
x0 = max(0, min(w, int(round(x0_pt * scale_x)))) writer._root_object["/AcroForm"][NameObject("/NeedAppearances")] = BooleanObject(enabled)
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
return masked with pdf_path.open("wb") as fh:
writer.write(fh)
def _compute_similarity(ref: np.ndarray, gen: np.ndarray) -> float: 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.") failures.append(f" FEHLT: '{ref_pdf.name}' wurde nicht generiert.")
continue 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 # Render both PDFs to images
ref_images = _render_pdf_pages(ref_pdf) ref_images = render_pdf_pages(ref_pdf, RENDER_DPI)
gen_images = _render_pdf_pages(generated_pdf) gen_images = render_pdf_pages(generated_pdf, RENDER_DPI)
page_count = max(len(ref_images), len(gen_images)) page_count = max(len(ref_images), len(gen_images))
for page_idx in range(page_count): for page_idx in range(page_count):
@@ -277,11 +252,6 @@ def test_pdf_matches_reference(
ref_img = ref_images[page_idx] ref_img = ref_images[page_idx]
gen_img = gen_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) similarity = _compute_similarity(ref_img, gen_img)
if similarity < SIMILARITY_THRESHOLD: if similarity < SIMILARITY_THRESHOLD: