#!/usr/bin/env python3 """kalina-pdf — Kalina CI auf Dokumente anwenden (bauen, stempeln, pruefen). Drei Unterbefehle, einer fuer jeden Weg, auf dem ein PDF entsteht: kalina-pdf build bericht.html -> bericht.pdf (weasyprint + CI-CSS) kalina-pdf build bericht.html --stamp -> zusaetzlich Marke/Deckblatt stempeln kalina-pdf stamp fremd.pdf -> fremd-kalina.pdf (Original unberuehrt) kalina-pdf check bericht.pdf -> Seiten, Fuellgrad, Seitenzahl je Seite Warum ein eigenes Werkzeug: die CI besteht aus CSS, Vorlagen und einem Stempel-Skript, und jeder Aufrufer soll nicht selbst herausfinden muessen, welche Reihenfolge stimmt (Layout mit weasyprint, Marke/Wortmarke danach stempeln - reine CSS-Zeichnung der Marke ist Handarbeit und geht schief). Rueckgabewerte: 0 = in Ordnung, 1 = Pruefung fehlgeschlagen, 2 = Aufruffehler. """ from __future__ import annotations import argparse import json import os import re import shutil import subprocess import sys from pathlib import Path HIER = Path(__file__).resolve().parent.parent # Repo-Wurzel CSS = HIER / "css" / "kalina-ci.css" STEMPEL = HIER / "scripts" / "stamp_existing_pdf.py" TOKENS = HIER / "tokens.json" # Kopf- und Fusszone werden bei der Fuellgradmessung ausgespart, sonst meldet jede # Seite denselben Wert (die Fusszeile ist immer der unterste Block). KOPFZONE_PT = 60 FUSSZONE_PT = 800 def fehler(text: str, code: int = 2) -> "NoReturn": # type: ignore[name-defined] print(f"kalina-pdf: {text}", file=sys.stderr) raise SystemExit(code) # ---------------------------------------------------------------- build def cmd_build(args) -> int: quelle = Path(args.input).expanduser().resolve() if not quelle.is_file(): fehler(f"HTML nicht gefunden: {quelle}") ziel = Path(args.output).expanduser().resolve() if args.output else quelle.with_suffix(".pdf") # Vorlage holt ihr CSS relativ zur HTML-Datei. Fehlt es dort, wird die kanonische # Datei aus dem Repo daneben kopiert - sonst rendert weasyprint ungestylt und # niemand merkt es, weil die Seite trotzdem entsteht. css_lokal = quelle.parent / "kalina-ci.css" if not css_lokal.exists() and CSS.exists(): shutil.copyfile(CSS, css_lokal) print(f" CSS ergaenzt: {css_lokal}") kopf = re.search(r"]*>.*?", quelle.read_text(encoding="utf-8", errors="replace"), re.S | re.I) if kopf and "kalina-ci.css" not in kopf.group(0): print(" Warnung: verlinkt kalina-ci.css nicht - Layout wird generisch.") weasy = shutil.which("weasyprint") if weasy: lauf = subprocess.run([weasy, str(quelle), str(ziel)], capture_output=True, text=True) if lauf.returncode != 0: fehler(f"weasyprint fehlgeschlagen:\n{lauf.stderr[-1500:]}", 1) else: try: from weasyprint import HTML # type: ignore except ImportError: fehler("weasyprint fehlt (weder Kommando noch Python-Modul)") HTML(filename=str(quelle)).write_pdf(str(ziel)) print(f" PDF: {ziel} ({ziel.stat().st_size // 1024} KB)") if args.stamp: gestempelt = ziel.with_name(ziel.stem + "-kalina.pdf") rc = _stempeln(ziel, gestempelt, args.titel or quelle.stem, args.fuss, ohne_deckblatt=False) if rc != 0: return rc ziel = gestempelt print(f" gestempelt: {ziel}") return cmd_check(argparse.Namespace(**{**vars(args), "pdf": str(ziel), "min_fill": args.min_fill, "raster": args.raster})) # ---------------------------------------------------------------- stamp def _stempeln(eingabe: Path, ausgabe: Path, titel: str, fuss: str | None, ohne_deckblatt: bool) -> int: if not STEMPEL.exists(): fehler(f"Stempel-Skript fehlt: {STEMPEL}") cmd = [sys.executable, str(STEMPEL), str(eingabe), str(ausgabe), "--titel", titel] if ohne_deckblatt: cmd.append("--ohne-deckblatt") if fuss: cmd += ["--fuss", fuss] lauf = subprocess.run(cmd, capture_output=True, text=True) if lauf.returncode != 0: print(lauf.stdout[-800:], lauf.stderr[-1500:], sep="\n", file=sys.stderr) return 1 return 0 def cmd_stamp(args) -> int: quelle = Path(args.pdf).expanduser().resolve() if not quelle.is_file(): fehler(f"PDF nicht gefunden: {quelle}") ziel = (Path(args.output).expanduser().resolve() if args.output else quelle.with_name(quelle.stem + "-kalina.pdf")) if ziel == quelle: fehler("Ausgabe darf nicht die Eingabe sein - das Original bleibt unangetastet") titel = args.titel or quelle.stem.replace("-", " ").replace("_", " ") rc = _stempeln(quelle, ziel, titel, args.fuss, args.ohne_deckblatt) if rc: return rc print(f" gestempelt: {ziel}") return cmd_check(argparse.Namespace(**{**vars(args), "pdf": str(ziel), "min_fill": args.min_fill, "raster": args.raster})) # ---------------------------------------------------------------- check def _fuellgrad(seite) -> float: """Anteil der Seitenflaeche, den Text- und Bildbloecke in der Inhaltszone belegen. Nur Bloecke zwischen Kopf- und Fusszone zaehlen. Bilder muessen mit: ein Bericht mit Zeichnungen liegt nach Textbloecken allein bei 20-37 % und ist trotzdem dicht. """ hoehe = seite.rect.height flaeche = 0.0 for block in seite.get_text("blocks"): x0, y0, x1, y1 = block[:4] if y0 > KOPFZONE_PT and y1 < FUSSZONE_PT: flaeche += max(0.0, (x1 - x0)) * max(0.0, (y1 - y0)) try: for info in seite.get_image_info(): x0, y0, x1, y1 = info["bbox"] if y0 > KOPFZONE_PT and y1 < FUSSZONE_PT: flaeche += max(0.0, (x1 - x0)) * max(0.0, (y1 - y0)) except Exception: pass nutz = max(1.0, (seite.rect.width) * min(hoehe, FUSSZONE_PT - KOPFZONE_PT)) return min(1.0, flaeche / nutz) def cmd_check(args) -> int: pfad = Path(args.pdf).expanduser().resolve() if not pfad.is_file(): fehler(f"PDF nicht gefunden: {pfad}") try: import pymupdf # type: ignore except ImportError: try: import fitz as pymupdf # type: ignore except ImportError: fehler("pymupdf fehlt - Fuellgrad nicht pruefbar") dok = pymupdf.open(pfad) if dok.page_count == 0: fehler("PDF hat keine Seiten", 1) # Zwei Schwellen, zwei Bedeutungen: unter `minimum` ist die Seite ein # Layoutfehler (Fast-Leerseite durch Umbruch), unter `ziel` ist sie nur duenn. # Die 55 % aus der Erfahrung gelten fuer dichte Berichte; reiner Fliesstext # erreicht sie selten und ist trotzdem richtig gesetzt. minimum = args.min_fill ziel = args.ziel deckblatt = getattr(args, "deckblatt", True) schwach, duenn = [], [] print(f" Seiten: {dok.page_count} Groesse: {pfad.stat().st_size // 1024} KB") for i, seite in enumerate(dok, start=1): f = _fuellgrad(seite) worte = len(seite.get_text("words")) # Ein Deckblatt ist absichtlich duenn: Titel, Marke, Fusszeile. Es wird # berichtet, aber nicht bewertet - sonst schlaegt jede gestempelte Datei fehl. geschuetzt = deckblatt and i == 1 if geschuetzt: marke = " (Deckblatt, nicht bewertet)" elif f < minimum: marke = " <- FEHLER: Fast-Leerseite" elif f < ziel: marke = " (duenn, unter Ziel)" else: marke = "" print(f" Seite {i:3}: Fuellgrad {f * 100:5.1f} % {worte:5} Woerter{marke}") if geschuetzt: if args.raster and i == 1: # Deckblatt-Raster vor dem continue bild = Path(args.raster).expanduser().resolve() bild.mkdir(parents=True, exist_ok=True) pix = seite.get_pixmap(dpi=150) pix.save(str(bild / (pfad.stem + "-s1.png"))) print(f" Raster: {bild / (pfad.stem + '-s1.png')}") continue if f < minimum: schwach.append(i) elif f < ziel: duenn.append(i) if args.raster and i == 1: bild = Path(args.raster).expanduser().resolve() bild.mkdir(parents=True, exist_ok=True) ziel = bild / (pfad.stem + "-s1.png") pix = seite.get_pixmap(dpi=150) pix.save(str(ziel)) print(f" Raster: {ziel}") dok.close() if schwach: print(f" FEHLGESCHLAGEN: Fast-Leerseite(n) unter {minimum * 100:.0f} %: {schwach}") return 1 hinweis = f" duenn (unter {ziel * 100:.0f} %): {duenn}" if duenn else "" print(f" OK: keine Fast-Leerseite (Minimum {minimum * 100:.0f} %){hinweis}") return 0 # ---------------------------------------------------------------- tokens def cmd_tokens(args) -> int: if not TOKENS.exists(): fehler(f"tokens.json fehlt: {TOKENS}") daten = json.loads(TOKENS.read_text(encoding="utf-8")) if args.feld: wert = daten for teil in args.feld.split("."): if not isinstance(wert, dict) or teil not in wert: fehler(f"Feld nicht vorhanden: {args.feld}") wert = wert[teil] print(wert) else: print(json.dumps(daten, indent=2, ensure_ascii=False)) return 0 # ---------------------------------------------------------------- main def main() -> int: p = argparse.ArgumentParser(prog="kalina-pdf", description=__doc__.splitlines()[0]) sub = p.add_subparsers(dest="cmd", required=True) def pruef_args(sp): sp.add_argument("--min-fill", type=float, default=0.10, help="Untergrenze je Seite; darunter gilt die Seite als Layoutfehler (0.10)") sp.add_argument("--ziel", type=float, default=0.55, help="Zielwert fuer dichte Seiten (0.55) - unterschritten wird nur berichtet") sp.add_argument("--raster", metavar="DIR", help="Seite 1 als PNG dorthin schreiben") sp.add_argument("--kein-deckblatt-bonus", dest="deckblatt", action="store_false", help="Seite 1 mitbewerten (Standard: Deckblatt wird nur berichtet)") b = sub.add_parser("build", help="HTML mit CI-CSS zu PDF") b.add_argument("input") b.add_argument("-o", "--output") b.add_argument("--stamp", action="store_true", help="Deckblatt/Marke zusaetzlich stempeln") b.add_argument("--titel", help="Titel fuer das Deckblatt") b.add_argument("--fuss", help="Fusstext") pruef_args(b) b.set_defaults(func=cmd_build) s = sub.add_parser("stamp", help="bestehendes PDF mit CI versehen (Original bleibt)") s.add_argument("pdf") s.add_argument("-o", "--output") s.add_argument("--titel") s.add_argument("--fuss") s.add_argument("--ohne-deckblatt", action="store_true") pruef_args(s) s.set_defaults(func=cmd_stamp) c = sub.add_parser("check", help="Seiten, Fuellgrad, Seitenzahl pruefen") c.add_argument("pdf") pruef_args(c) c.set_defaults(func=cmd_check) t = sub.add_parser("tokens", help="CI-Token (Farben, Geometrie) ausgeben") t.add_argument("feld", nargs="?") t.set_defaults(func=cmd_tokens) args = p.parse_args() for name, wert in (("min_fill", 0.10), ("ziel", 0.55), ("deckblatt", True)): if not hasattr(args, name): setattr(args, name, wert) return args.func(args) if __name__ == "__main__": raise SystemExit(main())