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
@@ -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
@@ -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
@@ -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()
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
Reference in new issue
Block a user