Files
kalina-ci/tools/kalina-pdf
T
mm db3725da4d init: KALINA CI 1.0.0 - Marke, Satzvorlage, Vorlagen, Stempel, CLI
Zusammenfuehrung der bisher verstreuten CI (Markendoku + Open-WebUI-Skill) in ein
versioniertes Repository. Neu: tools/kalina-pdf (build/stamp/check/tokens),
tokens.json, docs/aenderungen.md als verbindlicher Aenderungsweg.
2026-10-23 10:39:30 +02:00

287 lines
11 KiB
Python
Executable File

#!/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"<head[^>]*>.*?</head>", 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: <head> 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())