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.
287 lines
11 KiB
Python
Executable File
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())
|