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.
This commit is contained in:
commit
db3725da4d
21 files changed
+1450
No files matched your search
Executable
+286
@@ -0,0 +1,286 @@
|
||||
#!/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())
|
||||
Reference in new issue
Block a user