From db3725da4d0711ba108755688649ce43391d4f69 Mon Sep 17 00:00:00 2001 From: mm Date: Fri, 23 Oct 2026 10:39:30 +0200 Subject: [PATCH] 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. --- .gitignore | 6 + CHANGELOG.md | 23 +++ README.md | 66 ++++++ SKILL.md | 89 ++++++++ assets/favicon/kalina-favicon-16.png | Bin 0 -> 867 bytes assets/favicon/kalina-favicon-180.png | Bin 0 -> 21460 bytes assets/favicon/kalina-favicon-32.png | Bin 0 -> 2149 bytes assets/favicon/kalina-favicon-512.png | Bin 0 -> 104291 bytes assets/kalina-logo-combined.svg | 37 ++++ assets/kalina-mark-inline.html | 1 + assets/kalina-mark.svg | 15 ++ assets/kalina-wordmark.svg | 22 ++ assets/logo.png | Bin 0 -> 37891 bytes css/kalina-ci.css | 190 +++++++++++++++++ docs/aenderungen.md | 41 ++++ scripts/stamp_existing_pdf.py | 252 +++++++++++++++++++++++ templates/bild-mit-prompt.html | 96 +++++++++ templates/page.html | 97 +++++++++ templates/report.html | 103 ++++++++++ tokens.json | 126 ++++++++++++ tools/kalina-pdf | 286 ++++++++++++++++++++++++++ 21 files changed, 1450 insertions(+) create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 README.md create mode 100644 SKILL.md create mode 100644 assets/favicon/kalina-favicon-16.png create mode 100644 assets/favicon/kalina-favicon-180.png create mode 100644 assets/favicon/kalina-favicon-32.png create mode 100644 assets/favicon/kalina-favicon-512.png create mode 100644 assets/kalina-logo-combined.svg create mode 100644 assets/kalina-mark-inline.html create mode 100644 assets/kalina-mark.svg create mode 100644 assets/kalina-wordmark.svg create mode 100644 assets/logo.png create mode 100644 css/kalina-ci.css create mode 100644 docs/aenderungen.md create mode 100755 scripts/stamp_existing_pdf.py create mode 100644 templates/bild-mit-prompt.html create mode 100644 templates/page.html create mode 100644 templates/report.html create mode 100644 tokens.json create mode 100755 tools/kalina-pdf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1a30eb7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +*.pdf +raster/ +__pycache__/ +*.pyc +.DS_Store +templates/kalina-ci.css diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..1978220 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,23 @@ +# Changelog + +Format: Datum — was — warum. Die CI beginnt versioniert am 2026-10-23; davor existierte +sie verstreut (Marke in `brand/`, Dokument-CSS im Open-WebUI-Skill) und wurde nicht +versioniert. + +## 1.0.0 — 2026-10-23 + +- **Konsolidiert:** Marke, Wortmarke, Favicon-Satz, Satzvorlage (`kalina-ci.css`), + drei Dokumentvorlagen und das Stempel-Skript aus den bisherigen Ablageorten in **ein** + Repository zusammengeführt. Grund: die CI lag an zwei Orten (Markendoku und + Open-WebUI-Skill), Agenten kannten nur den, den sie zufällig fanden. +- **Neu `tools/kalina-pdf`** mit `build`, `stamp`, `check`, `tokens`. Grund: die + Reihenfolge (Layout per weasyprint, Marke danach stempeln) war Erfahrungswissen und + wurde je Projekt neu entschieden; `check` ersetzt das „sieht wohl gut aus" durch + Seitenzahl und Füllgrad je Seite. +- **Neu `tokens.json`** — Farben, Schriften, Geometrie, Zonen maschinenlesbar, damit + Werkzeuge die CI lesen können, statt sie abzuschreiben. +- **Neu `docs/aenderungen.md`** — Änderungen laufen über Issue, Branch, PR. Grund: eine + Vorlage, die jeder lokal „schnell anders" macht, ist nach zwei Wochen keine Vorlage. +- **Regeln aus Fehlern** in `SKILL.md` übernommen (Wortmarke nur in DejaVu Sans, + `s >= 0.24`, Bone auf Bone unsichtbar, `break-inside` nur für kurze Abschnitte, + Füllgrad ohne Kopf-/Fußzone messen). diff --git a/README.md b/README.md new file mode 100644 index 0000000..ccf8698 --- /dev/null +++ b/README.md @@ -0,0 +1,66 @@ +# KALINA CI + +Corporate Identity der KALINA als versioniertes Artefakt: eine Satzvorlage (CSS), +drei Dokumentvorlagen, die Marke als SVG, ein Stempel-Skript für fremde PDFs und eine +CLI, die alle drei Wege bedient. **Ein Dokument, das nach KALINA aussehen soll, nimmt +diese Dateien — keine nachgebauten Farben, kein selbst gezeichnetes Logo.** + +``` +css/kalina-ci.css Satzvorlage: @page A4, Kopfleiste, Tabellen, Plakette, Fußlinie +templates/ report.html · page.html · bild-mit-prompt.html +assets/ kalina-mark.svg · kalina-wordmark.svg · kalina-logo-combined.svg · favicon/ +scripts/stamp_existing_pdf.py fremdes PDF mit Deckblatt/Marke/Fußzeile versehen +tools/kalina-pdf CLI: build · stamp · check · tokens +tokens.json Farben, Schriften, Geometrie, Zonen (maschinenlesbar) +SKILL.md Einstiegspunkt für Agenten (Frontmatter + Regeln + Fallen) +docs/aenderungen.md der verbindliche Änderungsweg +``` + +## Schnellstart + +```bash +git clone kalina-ci && cd kalina-ci + +cp templates/report.html bericht.html # «Platzhalter» füllen +tools/kalina-pdf build bericht.html --stamp --titel "Angebot 2026-17" + +tools/kalina-pdf stamp fremd.pdf -o fremd-kalina.pdf --titel "Rechnung 4711" +tools/kalina-pdf check bericht.pdf --raster raster/ +``` + +Voraussetzungen: `weasyprint` (Layout), `pymupdf` (Messung/Raster), +`matplotlib` + `pypdf` (Stempel), `pdftoppm` optional für Sichtprüfung. + +## Farben + +Ink `#0B0F14` · Bone `#F5F5EF` · Kalina-Rot `#C1121F` · Steel `#3A4250` · Muted `#8A94A2` + +## Wie Agenten das nutzen + +Zwei Wege, gleicher Inhalt: + +1. **Als Skill importiert** (Paperclip: `POST /api/companies//skills/import` mit + `{"source": ""}`). Der Import liest `SKILL.md` und alle Dateien + darunter, hält `ref` und `trackingRef` fest und meldet Aktualisierungen über + `update-status`. +2. **Als Arbeitskopie** (Hermes, Skripte, Cron): Repo klonen, `tools/kalina-pdf` aus + der Kopie aufrufen. Die CLI findet CSS, Vorlagen und Stempel relativ zu sich selbst. + +## Zugriff + +Das Repo ist **öffentlich lesbar** — nicht aus Bequemlichkeit: der Skill-Import der +Paperclip-Instanz holt Bäume und Rohdateien **ohne** Authentifizierung, ein privates +Repo wäre dort nicht importierbar. Deshalb gilt die Inhaltsregel: + +> Keine internen Hostnamen, keine IP-Adressen, keine Zugangsschlüssel, keine +> Kundeninhalte. Interne Verdrahtung gehört in die jeweilige Betriebsdoku, nicht hierher. + +Schreibzugriff haben `mm` (Owner) und das Agenten-Konto `kalina` (Collaborator, +`write`). Weitere Agenten werden als Collaborator mit `write` aufgenommen — die CI ist +kein Diskussionsgegenstand, sondern eine Vorlage, die Agenten anwenden. + +## Änderungen + +Nicht ad hoc. Der Weg steht in [`docs/aenderungen.md`](docs/aenderungen.md): +Issue im Repo → Branch `ci/` → PR → Review → Merge → `CHANGELOG.md` ergänzen. +Wer die CI in einem Projekt „schnell anders" braucht, ändert das Projekt, nicht die CI. diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..b8efb2a --- /dev/null +++ b/SKILL.md @@ -0,0 +1,89 @@ +--- +name: kalina-ci +description: Use when a PDF, page or slide must carry the KALINA corporate identity. One CSS, one CLI, one source. +metadata: + version: "1.0.0" + stand: "2026-10-23" +--- + +# KALINA CI — Corporate Identity für Dokumente und Seiten + +Diese Skill-Datei ist der Einstiegspunkt in die KALINA CI. Alles, was nach KALINA +aussehen soll — PDF, Bericht, Angebot, Folie, Landingpage — nimmt **diese** Dateien, +nicht selbst erfundene Farben und nicht ein nachgebautes Logo. + +**Die CI ist versioniert.** Änderungen laufen über das Repository (Issue → Branch → PR), +nicht ad hoc in einem Projekt. Siehe `docs/aenderungen.md`. + +## Was hier liegt + +| Pfad | Rolle | +|---|---| +| `css/kalina-ci.css` | Satzvorlage: `@page` A4, Kopfleiste, Tabellen, Plakette, Fußlinie | +| `templates/report.html` | Mehrseitiger Bericht (Deckblatt, Abschnitte, Tabellen) | +| `templates/page.html` | Einzelne Seite / Handout | +| `templates/bild-mit-prompt.html` | Generiertes Bild mit Prompt als Dokument | +| `assets/kalina-mark.svg` | Marke (Kreis + K + Beeren), schriftfrei, überall einsetzbar | +| `assets/kalina-wordmark.svg` | Wortmarke — nur verwenden, wo DejaVu Sans liegt | +| `assets/kalina-logo-combined.svg` | Marke + Wortmarke | +| `assets/favicon/` | 16 / 32 / 180 / 512 px, gerendert aus der Marke | +| `scripts/stamp_existing_pdf.py` | Fremd-PDF stempeln (Deckblatt, Fußzeile, optional Kopfb and) | +| `tools/kalina-pdf` | CLI: bauen, stempeln, prüfen | +| `tokens.json` | Farben, Schriften, Geometrie, Zonen — maschinenlesbar | + +## Drei Wege, ein Ergebnis + +```bash +# 1. Neues Dokument: Vorlage kopieren, «Platzhalter» füllen, bauen +cp templates/report.html bericht.html +tools/kalina-pdf build bericht.html --stamp --titel "Angebot 2026-17" + +# 2. Fremdes PDF (Anhang, Behördenpost, Kundenpdf) mit CI versehen +tools/kalina-pdf stamp eingang.pdf -o eingang-kalina.pdf --titel "Rechnung 4711" + +# 3. Nur prüfen: Seiten, Füllgrad je Seite, Seite 1 als PNG +tools/kalina-pdf check bericht.pdf --raster raster/ +``` + +`build` kopiert fehlendes `kalina-ci.css` neben die HTML-Datei, weil weasyprint +Stylesheets relativ zur HTML-Datei auflöst. `stamp` lässt das Original unangetastet +und schreibt immer eine neue Datei. `check` bricht mit Exit 1 ab, wenn eine Seite +unter 55 % Füllgrad liegt — eine Fast-Leerseite ist ein Layoutfehler, kein Inhalt. + +## Regeln, die aus Fehlern entstanden sind + +- **Marke ja, Wortmarke vorsichtig.** Die Marke (Kreis + K + Beeren) ist schriftfrei + und skaliert beliebig. Die Wortmarken-SVG setzt ihre Buchstaben absolut + (x = 10/80/150/212/242/319) und ist auf DejaVu Sans Bold vermessen — in einer + fremden Schrift stoßen L, i und N zusammen. In Dokumenten und Webseiten die + Wortmarke deshalb **als Text** setzen: `KAL` + rotes `i` + `NA`. +- **Auf helle Fläche Ink, auf Ink-Fläche Bone.** Bone auf Bone ist unsichtbar; im + gerasterten PDF stand dann nur das rote i. +- **Wortmarke nicht unter `s ≈ 0.2`.** Darunter ist der i-Schaft ~2 pt lang und der + Beeren-Cluster kleiner als 2 pt. In Kopfzeilen `s = 0.24`. +- **Marke im Band nie größer als das Band.** 44 mm Marke in 12 mm Band ragt heraus + und liegt auf dem fremden Titel. +- **Nach jedem Bau die erste Seite rastern und ansehen**, nicht auf Sichtbarkeit + schließen: `tools/kalina-pdf check x.pdf --raster raster/`. +- **`break-inside: avoid` nur für kurze Abschnitte.** Bei Berichten mit Abschnitten + länger als eine Seite erzwingt die Vorlage einen Seitenanfang je Abschnitt und + hinterlässt fast leere Vorderseiten (gemessen: 14 Seiten, eine bei 11,6 %; nach + Aufhebung 10 Seiten ohne Seite unter 89 %). Im eigenen ` + + + + +
+ + + + + + + + + + + + KALINA +
+ + +

«Dokumenttitel»

+

«Bildprompt und Bild — Fassung»

+

«Ein Satz: was oben steht und was unten zu sehen ist.»

+ + +

Prompt

+
+

«Prompt-Text, unverändert, wie an den Bildgenerator gegeben.»

+
+ + +
+ «Bildbeschreibung, kurz» +

«Bildunterschrift: Modell, Format, Pixelmaße.»

+
+ + +
+ + + + + + + + + + + + KALINA · «Dokumenttitel» · Stand «TT.MM.JJJJ» + ai.m-m.bio +
+ + + diff --git a/templates/page.html b/templates/page.html new file mode 100644 index 0000000..a583ab6 --- /dev/null +++ b/templates/page.html @@ -0,0 +1,97 @@ + + + + + +«Seitentitel» — KALINA + + + + + +
+ + + + + + + + + + + + KALINA +
+ +
+

«Rubrik, z. B. Bericht»

+

«Seitentitel»

+

«Ein Absatz, der den Inhalt in zwei Sätzen zusammenfasst.»

+ +

«Abschnitt»

+

«Text. Belegte Zahlen mit Quelle nennen; Schätzungen als Schätzung kennzeichnen.»

+
  • «Punkt»
  • «Punkt»
+ +

«Abschnitt mit Tabelle»

+ + + + + + +
PositionWert
«…»«…»
«…»«…»
+ + +
+ +
+ KALINA · «Seitentitel» + Stand «TT.MM.JJJJ» · ai.m-m.bio +
+ + + diff --git a/templates/report.html b/templates/report.html new file mode 100644 index 0000000..5569ac1 --- /dev/null +++ b/templates/report.html @@ -0,0 +1,103 @@ + + + + +«Dokumenttitel» — KALINA + + + + + + +
+ + + + + + + + + + + + KALINA +
+ + +

«Dokumenttitel»

+

«Untertitel oder Dokumentart»

+

«Ein Satz, der sagt, worum es geht und was der Leser nach dem Lesen weiß.»

+ + + + + + + + + + +
FeldWert
Erstellt«TT.MM.JJJJ»
Verfasser«Name»
Stand / Fassung«Fassung, z. B. 1.0»
Grundlage«Quelle, Vertrag, Messung — mit Pfad oder URL»
+ + +
+

«Erster Abschnitt»

+

«Text. Zahlen und Aussagen, die belegt sind, mit Quelle; Schätzungen als Schätzung kennzeichnen.»

+
    +
  • «Punkt»
  • +
  • «Punkt»
  • +
+
+ +
+

«Zweiter Abschnitt»

+ + + + + + +
PositionMengeBetrag
«…»10,00 €
«…»20,00 €
+

«Hinweis, Vorbehalt oder offene Frage.»

+
+ + + + + +
+ + + + + + + + + + + + KALINA · «Dokumenttitel» · Stand «TT.MM.JJJJ» + ai.m-m.bio +
+ + + diff --git a/tokens.json b/tokens.json new file mode 100644 index 0000000..145585d --- /dev/null +++ b/tokens.json @@ -0,0 +1,126 @@ +{ + "name": "KALINA CI", + "version": "1.0.0", + "stand": "2026-10-23", + "farben": { + "ink": { + "hex": "#0B0F14", + "rolle": "Text und dunkle Flaechen" + }, + "bone": { + "hex": "#F5F5EF", + "rolle": "Papier/Seitengrund" + }, + "rot": { + "hex": "#C1121F", + "rolle": "Akzent, das i der Wortmarke, Beeren" + }, + "rot_dunkel": { + "hex": "#A50F1A", + "rolle": "Akzent auf Bone, Hover" + }, + "steel": { + "hex": "#3A4250", + "rolle": "Linien, Rahmen, sekundaere Schrift" + }, + "muted": { + "hex": "#8A94A2", + "rolle": "Fusszeile, Bildunterschrift, Meta" + } + }, + "schrift": { + "sans": [ + "DejaVu Sans", + "Helvetica Neue", + "Helvetica", + "Arial", + "sans-serif" + ], + "mono": [ + "DejaVu Sans Mono", + "ui-monospace", + "monospace" + ], + "regel": "Wortmarke als TEXT in der Seitenschrift; SVG-Wortmarke nur, wo DejaVu Sans liegt" + }, + "geometrie": { + "mark_viewbox": [ + 0, + 0, + 240, + 240 + ], + "mark_kreis": { + "cx": 120, + "cy": 120, + "r": 112 + }, + "mark_k_striche": { + "width": 30, + "pfade": [ + "M84,72 L84,168", + "M100,120 L176,64", + "M100,124 L176,176" + ] + }, + "mark_kern": { + "cx": 100, + "cy": 122, + "r": 4.5 + }, + "wortmarke_viewbox": [ + 0, + 0, + 395, + 120 + ], + "wortmarke_buchstaben_x": { + "K": 10, + "A": 80, + "L": 150, + "N": 242, + "A2": 319 + }, + "wortmarke_font": "80 (DejaVuSans-Bold / Helvetica Neue 900)", + "i_schaft": { + "x": 212, + "width": 11, + "y": [ + 92, + 52 + ] + }, + "beeren_cluster": { + "cx": 212, + "cy": 30, + "R": 17, + "r": 4.6 + } + }, + "zonen": { + "kopfzone_pt": 62, + "fusszone_pt": 40, + "min_fuellgrad": 0.55, + "marke_min_px": 24, + "wortmarke_min_scale": 0.2 + }, + "dateien": { + "css": "css/kalina-ci.css", + "marke": "assets/kalina-mark.svg", + "wortmarke": "assets/kalina-wordmark.svg", + "logo_kombiniert": "assets/kalina-logo-combined.svg", + "favicon": [ + "assets/favicon/kalina-favicon-16.png", + "assets/favicon/kalina-favicon-32.png", + "assets/favicon/kalina-favicon-180.png", + "assets/favicon/kalina-favicon-512.png" + ], + "vorlagen": [ + "templates/report.html", + "templates/page.html", + "templates/bild-mit-prompt.html" + ], + "stempel": "scripts/stamp_existing_pdf.py", + "cli": "tools/kalina-pdf" + } +} diff --git a/tools/kalina-pdf b/tools/kalina-pdf new file mode 100755 index 0000000..6fa34ca --- /dev/null +++ b/tools/kalina-pdf @@ -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"]*>.*?", 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())