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:
mm committed 2026-10-23 10:39:30 +02:00
commit db3725da4d
21 files changed
+1450

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
*.pdf
raster/
__pycache__/
*.pyc
.DS_Store
templates/kalina-ci.css
+23
View File
@@ -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).
+66
View File
@@ -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 <dieses-repo> 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/<id>/skills/import` mit
`{"source": "<URL dieses Repos>"}`). 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/<thema>` → PR → Review → Merge → `CHANGELOG.md` ergänzen.
Wer die CI in einem Projekt „schnell anders" braucht, ändert das Projekt, nicht die CI.
+89
View File
@@ -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 `<style>`:
`@media print { .abschnitt { break-inside: auto; } }`, `h2, h3 { break-after: avoid }`.
- **Füllgrad ohne Kopf- und Fußzone messen** — sonst zählt die Fußzeile als unterster
Block und jede Seite meldet dieselben ~105 %. `kalina-pdf check` macht das richtig.
- **Überschrift und lange Tabelle gehören zusammen.** Reißt eine 12-Zeilen-Tabelle
mit einer Zeile auf die Folgeseite, ist das ein Satzfehler: `break-before: page`
an die Überschrift oder Tabelle kürzen.
## Farben
Ink `#0B0F14` · Bone `#F5F5EF` · Kalina-Rot `#C1121F` · Steel `#3A4250` · Muted `#8A94A2`
## Wenn ein anderer Agent das Design macht
Werkzeuge und Agenten, die eine Seite bauen (Bildgeneratoren, externe Design-Dienste),
sehen diese Dateien nicht und erfinden sonst ein eigenes Logo — markenfremd und meist
als schweres Raster-PNG. Also die Marke **mitgeben**: SVG inline einbetten oder als
Datei beilegen, nie einen Fremdanbieter die Marke neu zeichnen lassen. Danach
nachsehen, dass das Ergebnis keine Bilder von fremden Hosts zieht.
Binary file not shown.

After

Width:  |  Height:  |  Size: 867 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

+37
View File
@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 680 240" role="img" aria-label="KALINA Logo kombiniert">
<title>KALINA Logo — kombiniert</title>
<g>
<title>KALINA Mark</title>
<circle cx="120" cy="120" r="112" fill="#0e1419" stroke="#2a323c" stroke-width="2"/>
<g fill="none" stroke="#f5f5ef" stroke-width="30" stroke-linecap="square" stroke-linejoin="miter">
<path d="M84,72 L84,168"/>
<path d="M100,120 L176,64"/>
<path d="M100,124 L176,176"/>
</g>
<g fill="#c1121f">
<circle cx="176" cy="64" r="2.2"/><circle cx="189" cy="64" r="2.2"/><circle cx="182.5" cy="52.7" r="2.2"/><circle cx="169.5" cy="52.7" r="2.2"/><circle cx="169.5" cy="75.3" r="2.2"/><circle cx="182.5" cy="75.3" r="2.2"/>
<circle cx="176" cy="176" r="2.2"/><circle cx="189" cy="176" r="2.2"/><circle cx="182.5" cy="164.7" r="2.2"/><circle cx="169.5" cy="164.7" r="2.2"/><circle cx="169.5" cy="187.3" r="2.2"/><circle cx="182.5" cy="187.3" r="2.2"/>
</g>
<circle cx="100" cy="122" r="4.5" fill="#c1121f"/>
</g>
<g transform="translate(290,60)">
<g font-family="Helvetica Neue, Arial, Segoe UI, sans-serif" font-weight="900" font-size="80" fill="#f5f5ef">
<text x="10" y="92">K</text>
<text x="80" y="92">A</text>
<text x="150" y="92">L</text>
<text x="242" y="92">N</text>
<text x="319" y="92">A</text>
</g>
<path d="M212,92 L212,52" stroke="#c1121f" stroke-width="11" stroke-linecap="round"/>
<g fill="#c1121f">
<circle cx="212.0" cy="30.0" r="4.6"/>
<circle cx="229.0" cy="30.0" r="4.6"/>
<circle cx="220.5" cy="44.7" r="4.6"/>
<circle cx="203.5" cy="44.7" r="4.6"/>
<circle cx="195.0" cy="30.0" r="4.6"/>
<circle cx="203.5" cy="15.3" r="4.6"/>
<circle cx="220.5" cy="15.3" r="4.6"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 240"> <circle cx="120" cy="120" r="112" fill="#0e1419" stroke="#2a323c" stroke-width="2"/> <g fill="none" stroke="#f5f5ef" stroke-width="30" stroke-linecap="square" stroke-linejoin="miter"> <path d="M84,72 L84,168"/> <path d="M100,120 L176,64"/> <path d="M100,124 L176,176"/> </g> <g fill="#c1121f"> <circle cx="176" cy="64" r="2.2"/><circle cx="189" cy="64" r="2.2"/><circle cx="182.5" cy="52.7" r="2.2"/><circle cx="169.5" cy="52.7" r="2.2"/><circle cx="169.5" cy="75.3" r="2.2"/><circle cx="182.5" cy="75.3" r="2.2"/> <circle cx="176" cy="176" r="2.2"/><circle cx="189" cy="176" r="2.2"/><circle cx="182.5" cy="164.7" r="2.2"/><circle cx="169.5" cy="164.7" r="2.2"/><circle cx="169.5" cy="187.3" r="2.2"/><circle cx="182.5" cy="187.3" r="2.2"/> </g> <circle cx="100" cy="122" r="4.5" fill="#c1121f"/> </svg>

After

Width:  |  Height:  |  Size: 868 B

+15
View File
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 240" role="img" aria-label="KALINA Mark">
<title>KALINA Mark</title>
<circle cx="120" cy="120" r="112" fill="#0e1419" stroke="#2a323c" stroke-width="2"/>
<g fill="none" stroke="#f5f5ef" stroke-width="30" stroke-linecap="square" stroke-linejoin="miter">
<path d="M84,72 L84,168"/>
<path d="M100,120 L176,64"/>
<path d="M100,124 L176,176"/>
</g>
<g fill="#c1121f">
<circle cx="176" cy="64" r="2.2"/><circle cx="189" cy="64" r="2.2"/><circle cx="182.5" cy="52.7" r="2.2"/><circle cx="169.5" cy="52.7" r="2.2"/><circle cx="169.5" cy="75.3" r="2.2"/><circle cx="182.5" cy="75.3" r="2.2"/>
<circle cx="176" cy="176" r="2.2"/><circle cx="189" cy="176" r="2.2"/><circle cx="182.5" cy="164.7" r="2.2"/><circle cx="169.5" cy="164.7" r="2.2"/><circle cx="169.5" cy="187.3" r="2.2"/><circle cx="182.5" cy="187.3" r="2.2"/>
</g>
<circle cx="100" cy="122" r="4.5" fill="#c1121f"/>
</svg>

After

Width:  |  Height:  |  Size: 1004 B

+22
View File
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 395 120" role="img" aria-label="KALINA Wortmarke">
<title>KALINA Wortmarke</title>
<g font-family="Helvetica Neue, Arial, Segoe UI, sans-serif" font-weight="900" font-size="80" fill="#f5f5ef">
<text x="10" y="92">K</text>
<text x="80" y="92">A</text>
<text x="150" y="92">L</text>
<text x="242" y="92">N</text>
<text x="319" y="92">A</text>
</g>
<!-- dominante i: roter Zweig + Beeren-Cluster -->
<path d="M212,92 L212,52" stroke="#c1121f" stroke-width="11" stroke-linecap="round"/>
<g fill="#c1121f">
<circle cx="212.0" cy="30.0" r="4.6"/>
<circle cx="229.0" cy="30.0" r="4.6"/>
<circle cx="220.5" cy="44.7" r="4.6"/>
<circle cx="203.5" cy="44.7" r="4.6"/>
<circle cx="195.0" cy="30.0" r="4.6"/>
<circle cx="203.5" cy="15.3" r="4.6"/>
<circle cx="220.5" cy="15.3" r="4.6"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 941 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

+190
View File
@@ -0,0 +1,190 @@
/* KALINA CI — Satzvorlage für PDF (weasyprint) und eigenständige Webseiten
Farben: Ink #0B0F14 · Bone #F5F5EF · Kalina-Rot #C1121F · Steel #3A4250 · Muted #8A94A2
Schrift: DejaVu Sans (in open-terminal installiert; identisch auf Schirm und Druck)
Regeln: Marke = SVG (schriftfrei, überall einsetzbar).
Wortmarke = TEXT in der Seitenschrift, das i in Kalina-Rot.
Die Wortmarken-SVG nie in fremder Schrift rendern — sie setzt ihre
Buchstaben absolut (x = 10/80/150/212/242/319). */
:root {
--ink: #0b0f14;
--bone: #f5f5ef;
--rot: #c1121f;
--rot-dunkel: #a50f1a;
--steel: #3a4250;
--muted: #8a94a2;
--sans: "DejaVu Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
--mono: "DejaVu Sans Mono", ui-monospace, monospace;
}
/* ---------- Seite (weasyprint / Druck) ---------- */
@page {
size: A4;
margin: 26mm 18mm 20mm 18mm;
background: #f5f5ef;
@bottom-left {
content: "KALINA";
font: 700 7pt var(--sans);
letter-spacing: 0.22em;
color: #8a94a2;
}
@bottom-right {
content: counter(page) " / " counter(pages);
font: 400 7.5pt var(--sans);
color: #8a94a2;
}
}
@page :first { margin-top: 16mm; }
/* ---------- Grundlage ---------- */
* { box-sizing: border-box; }
html { background: var(--bone); }
body {
margin: 0;
padding: 0;
background: var(--bone);
color: var(--ink);
font: 400 10.5pt/1.55 var(--sans);
hyphens: auto;
}
/* ---------- Kopfleiste (klebend am Schirm, Band im Druck) ---------- */
.kopf {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 0 12px;
border-bottom: 1px solid var(--steel);
background: var(--bone);
}
.kopf img, .kopf svg { display: block; height: 34px; width: 34px; }
.wortmarke {
font: 900 20pt/1 var(--sans);
letter-spacing: 0.04em;
color: var(--ink);
text-transform: none;
}
.wortmarke i {
font-style: normal;
color: var(--rot);
}
.kopf .wortmarke { margin-left: 2px; }
/* ---------- Titelzone ---------- */
.titel {
font: 900 26pt/1.15 var(--sans);
letter-spacing: -0.01em;
margin: 18px 0 6px;
}
.untertitel {
font: 400 12pt/1.4 var(--sans);
color: var(--steel);
margin: 0 0 14px;
}
.lead {
font-size: 11.5pt;
color: var(--ink);
border-left: 3px solid var(--rot);
padding-left: 12px;
margin: 14px 0 20px;
}
/* ---------- Abschnitte ---------- */
h2 {
font: 700 14pt/1.3 var(--sans);
margin: 22px 0 8px;
padding-bottom: 5px;
border-bottom: 1px solid var(--steel);
}
h2::after {
content: "";
display: block;
width: 42px;
height: 3px;
background: var(--rot);
margin-top: -2px;
}
h3 { font: 700 11pt/1.3 var(--sans); margin: 14px 0 4px; }
p { margin: 0 0 8px; }
ul, ol { margin: 0 0 10px; padding-left: 18px; }
li { margin: 0 0 3px; }
a { color: var(--rot-dunkel); text-decoration: none; }
code, kbd { font: 400 9.5pt var(--mono); background: #e7e8e2; padding: 0 3px; }
/* ---------- Tabellen ---------- */
table {
width: 100%;
border-collapse: collapse;
margin: 10px 0 14px;
font-size: 9.5pt;
}
thead th {
background: var(--ink);
color: var(--bone);
text-align: left;
font-weight: 700;
padding: 6px 8px;
}
td, tbody th {
border-bottom: 1px solid var(--steel);
padding: 6px 8px;
text-align: left;
vertical-align: top;
}
tbody tr:nth-child(even) td { background: #eceded; }
td.zahl, th.zahl { text-align: right; font-variant-numeric: tabular-nums; }
/* ---------- Marken-Plakette ---------- */
.plakette {
border: 1px solid var(--steel);
border-top: 3px solid var(--rot);
padding: 14px 16px;
margin: 16px 0;
background: #ffffff;
}
.plakette img, .plakette svg { height: 116px; width: 116px; display: block; margin-bottom: 8px; }
.plakette .plakette-titel { font: 700 11pt var(--sans); }
.plakette p { color: var(--steel); font-size: 9.5pt; }
/* ---------- Hinweis ---------- */
.hinweis {
border-left: 3px solid var(--steel);
color: var(--muted);
font-size: 9.5pt;
padding: 4px 0 4px 12px;
margin: 12px 0;
}
/* ---------- Fußmarke (Band am Dokumentende) ---------- */
.fuss {
margin-top: 26px;
padding-top: 10px;
border-top: 1px solid var(--steel);
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
color: var(--muted);
font-size: 8.5pt;
}
.fuss img, .fuss svg { height: 26px; width: 26px; }
/* ---------- Umbruchkontrolle (nur Druck) ---------- */
@media print {
h1, h2, h3 { break-after: avoid; }
.abschnitt, .plakette, .hinweis { break-inside: avoid; }
table { break-inside: auto; }
thead { display: table-header-group; }
a { color: var(--ink); }
}
/* ---------- Nur am Schirm: klebende Kopfleiste ---------- */
@media screen {
.kopf { position: sticky; top: 0; z-index: 10; }
body { padding: 0 24px 40px; max-width: 62rem; margin: 0 auto; }
}
+41
View File
@@ -0,0 +1,41 @@
# Änderungen an der CI
Die CI ist eine Vorlage mit vielen Nutzern (Menschen, Hermes, Paperclip-Agenten). Wer sie
ändern will, ändert deshalb **eine Quelle** und nicht sieben Projekte.
## Der Weg (verbindlich)
1. **Issue im Repo** — was ist falsch, wo tritt es auf, welches Dokument zeigt es?
Ein Screenshot/Raster oder ein Seitenzahl-Füllgrad ist ein besserer Beleg als ein
Adjektiv.
2. **Branch** `ci/<thema>` vom aktuellen `main`.
3. **Änderung + Beleg**: betroffene Datei, ein gebautes Beispiel-PDF (nicht committen,
nur beschreiben oder als Kommentar) und `tools/kalina-pdf check` für das Beispiel.
4. **PR** gegen `main`, im PR-Text: Issue-Referenz, Vorher/Nachher, geprüfte Seiten.
5. **Review** durch `mm` oder das Agenten-Konto `kalina`. Bei Farben, Geometrie und
Marke gilt: nur mit Sichtprüfung eines gerasterten PDFs.
6. **Merge** → `CHANGELOG.md` im selben PR ergänzen (Datum, was, warum).
## Was danach passiert
- **Hermes/Arbeitskopien:** `git -C <klon> pull --ff-only` (die CLI liest aus der Kopie).
- **Paperclip-Skill:** `GET .../skills/<id>/update-status` zeigt, ob `main` weiter ist;
`POST .../skills/<id>/install-update` zieht die neue Fassung. Der Skill merkt sich
`ref` und `trackingRef`, ein Update ist damit nachvollziehbar statt „irgendwann".
- **Bereits ausgelieferte PDFs** werden nicht rückwirkend geändert. Ein Neubau braucht
die Quelle (HTML) oder ein `kalina-pdf stamp` auf das alte PDF.
## Was NICHT über die CI läuft
- Projektspezifische Layouts (Deckblatttext, Kapitelstruktur, Anhänge). Die CI liefert
Satz und Marke, nicht den Inhalt.
- Interne Verdrahtung: Hostnamen, Pfade, Schlüssel. Dafür gibt es die Betriebsdoku des
jeweiligen Systems.
- Schnellschüsse wie „im Projekt die Farbe überschreiben". Wenn die CI nicht passt, ist
das ein Issue, kein lokaler `!important`.
## Wenn es schnell gehen muss
Notfall (ein Termin, ein Dokument): Änderung im Projekt, **im selben Tag** ein Issue mit
der Abweichung und dem Grund. Eine Abweichung ohne Issue ist eine stille zweite CI — und
die kostet beim nächsten Dokument mehr Zeit, als sie gespart hat.
+252
View File
@@ -0,0 +1,252 @@
#!/usr/bin/env python3
"""KALINA CI auf BESTEHENDE PDFs anwenden. Das Original bleibt Byte für Byte unangetastet.
Zwei Eingriffe, getrennt schaltbar:
* Deckblatt (Standard an) — eine eigene A4-Seite vor das Dokument: Marke, Wortmarke,
Titel, Untertitel, Fußzeile. Kein Inhalt des Originals wird berührt.
* Fußzeile (Standard an) — 0,6 pt Linie + Text im unteren Rand (12 mm).
Sie kann Text im unteren Rand des Originals überdecken.
* Kopfb band (Standard AUS, `--kopf`) — vollflächiges Ink-Band über die obersten 14 mm.
**Überdeckt Inhalt**, deshalb nur auf Wunsch und nach Sichtprüfung.
Warum so: pypdf kann keine Inhalte zeichnen, weasyprint keine transparenten Overlays.
matplotlib zeichnet die Seiten (dpi 300, transparent), pypdf legt sie mit merge_page
über die Originalseite. Der Text des Originals bleibt durchsuchbar, Schriften bleiben.
Aufruf im Terminal (cwd /home/user):
python3 kalina-ci/stamp_existing_pdf.py eingabe.pdf ausgabe.pdf --titel "Angebot 2026-17"
python3 kalina-ci/stamp_existing_pdf.py eingabe.pdf ausgabe.pdf --ohne-deckblatt --fuss "Fassung 1.0"
python3 kalina-ci/stamp_existing_pdf.py eingabe.pdf ausgabe.pdf --kopf --fuss "vertraulich"
Danach `display_file` auf die AUSGABE, nie auf das Original.
"""
from __future__ import annotations
import argparse
import io
import sys
import textwrap
from pathlib import Path
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt # noqa: E402
from matplotlib.font_manager import FontProperties # noqa: E402
from matplotlib.patches import Circle, Rectangle # noqa: E402
from matplotlib.textpath import TextPath # noqa: E402
from pypdf import PdfReader, PdfWriter # noqa: E402
MM = 72.0 / 25.4 # Punkte je Millimeter
A4 = (210 * MM, 297 * MM)
INK = "#0b0f14"
BONE = "#f5f5ef"
ROT = "#c1121f"
STEEL = "#3a4250"
MUTED = "#8a94a2"
FONT = FontProperties(family="DejaVu Sans")
FONT_BOLD = FontProperties(family="DejaVu Sans", weight="bold") # Black (900) fehlt in DejaVu
TRACKING = 0.06 # Buchstabenabstand der Wortmarke, in Vielfachen der Schriftgröße
BAND_H = 14 * MM # Höhe des Ink-Kopfbandes (--kopf)
MARKE_IM_BAND = 9 * MM
FUSS_LINIE = 12 * MM # Höhe der Fußzeile über der Blattunterkante
RAND_L, RAND_R = 18 * MM, 18 * MM
def text_breite(text: str, size: float, bold: bool = True) -> float:
"""Ink-Breite einer Zeichenkette in Punkten — gerechnet, nicht geraten."""
return float(TextPath((0, 0), text, size=size, prop=FONT_BOLD if bold else FONT).get_extents().width)
def wortmarke(ax, x: float, y: float, size: float, farbe: str = BONE) -> float:
"""KALINA als Text setzen: 'KAL' + rotes 'I' + 'NA'. Gibt die Gesamtbreite zurück.
Die Wortmarken-SVG wird hier bewusst NICHT benutzt: sie setzt ihre Buchstaben
absolut (x = 10/80/150/212/242/319) und reißt in jeder anderen Schrift auf.
"""
x0 = x
for teil, farbe_teil in (("KAL", farbe), ("I", ROT), ("NA", farbe)):
ax.text(x, y, teil, fontsize=size, color=farbe_teil, fontproperties=FONT_BOLD,
ha="left", va="baseline", zorder=5)
x += text_breite(teil, size) + TRACKING * size
return x - x0
def marke(ax, links: float, oben: float, s: float) -> None:
"""KALINA-Marke (Siegel + K + Beeren) zeichnen. s = Durchmesser in Punkten.
Geometrie identisch zur SVG (viewBox 0 0 240 240): Kreis r=112 @ (120,120),
K-Striche width=30, Beerenpaare an (176,64) und (176,176), roter Kern @ (100,122).
(links, oben) = linke obere Ecke des Quadrats, y wird nach unten gezählt.
"""
k = s / 240.0
def p(x: float, y_svg: float) -> tuple[float, float]: # SVG-y (oben) → Zeichen-y (unten)
return links + x * k, oben + (240 - y_svg) * k
ax.add_patch(Circle(p(120, 120), 112 * k, facecolor="#0e1419",
edgecolor="#2a323c", linewidth=max(0.4, 2 * k), zorder=3))
for x1, y1, x2, y2 in ((84, 72, 84, 168), (100, 120, 176, 64), (100, 124, 176, 176)):
(ax1, ay1), (ax2, ay2) = p(x1, y1), p(x2, y2)
ax.plot([ax1, ax2], [ay1, ay2], color=BONE, linewidth=30 * k,
solid_capstyle="butt", zorder=4)
for bx, by in ((176, 64), (189, 64), (182.5, 52.7), (169.5, 52.7), (169.5, 75.3), (182.5, 75.3),
(176, 176), (189, 176), (182.5, 164.7), (169.5, 164.7), (169.5, 187.3), (182.5, 187.3)):
ax.add_patch(Circle(p(bx, by), 2.2 * k, facecolor=ROT, edgecolor="none", zorder=5))
ax.add_patch(Circle(p(100, 122), 4.5 * k, facecolor=ROT, edgecolor="none", zorder=6))
def _figur(breite: float, hoehe: float):
fig = plt.figure(figsize=(breite / 72.0, hoehe / 72.0), dpi=300)
ax = fig.add_axes([0, 0, 1, 1])
ax.set_xlim(0, breite)
ax.set_ylim(0, hoehe)
ax.axis("off")
ax.set_facecolor("none")
return fig, ax
def _als_reader(fig) -> PdfReader:
buf = io.BytesIO()
fig.savefig(buf, format="pdf", transparent=True)
plt.close(fig)
buf.seek(0)
return PdfReader(buf)
def kopfbogen(breite: float, hoehe: float, rechts_text: str) -> PdfReader:
"""Transparente Seite mit vollflächigem Ink-Kopfband + roter Kante (--kopf)."""
fig, ax = _figur(breite, hoehe)
ax.add_patch(Rectangle((0, hoehe - BAND_H), breite, BAND_H, facecolor=INK, edgecolor="none", zorder=2))
ax.add_patch(Rectangle((0, hoehe - BAND_H - 1.5), breite, 1.5, facecolor=ROT, edgecolor="none", zorder=2))
marke(ax, RAND_L, hoehe - (BAND_H + MARKE_IM_BAND) / 2, MARKE_IM_BAND)
wortmarke(ax, RAND_L + MARKE_IM_BAND + 4 * MM, hoehe - BAND_H / 2 - 3.4, 13)
if rechts_text:
ax.text(breite - RAND_R, hoehe - BAND_H / 2 - 2.4, rechts_text, fontsize=6.5,
color=MUTED, fontproperties=FONT, ha="right", va="baseline", zorder=5)
return _als_reader(fig)
def fussbogen(breite: float, hoehe: float, fuss_text: str) -> PdfReader:
"""Transparente Seite mit Fußlinie + Fußtext (überdeckt nur den unteren Rand)."""
fig, ax = _figur(breite, hoehe)
ax.plot([RAND_L, breite - RAND_R], [FUSS_LINIE, FUSS_LINIE], color=STEEL, linewidth=0.6, zorder=3)
ax.text(RAND_L, FUSS_LINIE - 4.4 * MM, "KALINA · ai.m-m.bio", fontsize=6.5, color=MUTED,
fontproperties=FONT, ha="left", va="baseline", zorder=4)
if fuss_text:
ax.text(breite / 2, FUSS_LINIE - 4.4 * MM, fuss_text, fontsize=6.5, color=MUTED,
fontproperties=FONT, ha="center", va="baseline", zorder=4)
return _als_reader(fig)
def deckblatt(breite: float, hoehe: float, titel: str, untertitel: str, fuss_text: str) -> PdfReader:
"""Eigene CI-Titelseite: Bone, Marke, Wortmarke, Titel, Untertitel, Fußzeile.
Die Wortmarke steht hier in INK — auf der Bondefläche wäre Bone-weiß unsichtbar.
"""
fig, ax = _figur(breite, hoehe)
ax.add_patch(Rectangle((0, 0), breite, hoehe, facecolor=BONE, edgecolor="none", zorder=1))
m = 44 * MM
marke(ax, RAND_L, hoehe - 32 * MM - m, m)
wortmarke(ax, RAND_L + m + 8 * MM, hoehe - 32 * MM - m * 0.72, 30, farbe=INK)
ax.plot([RAND_L, breite - RAND_R], [hoehe - 122 * MM, hoehe - 122 * MM],
color=ROT, linewidth=3, zorder=3)
y = hoehe - 140 * MM
for zeile in textwrap.wrap(titel, width=26)[:4]:
ax.text(RAND_L, y, zeile, fontsize=25, color=INK, fontproperties=FONT_BOLD,
ha="left", va="baseline", zorder=4)
y -= 14 * MM
if untertitel:
y -= 2 * MM
for zeile in textwrap.wrap(untertitel, width=52)[:3]:
ax.text(RAND_L, y, zeile, fontsize=12.5, color=STEEL, fontproperties=FONT,
ha="left", va="baseline", zorder=4)
y -= 7.5 * MM
ax.plot([RAND_L, breite - RAND_R], [FUSS_LINIE, FUSS_LINIE], color=STEEL, linewidth=0.6, zorder=3)
ax.text(RAND_L, FUSS_LINIE - 4.4 * MM, "KALINA · ai.m-m.bio", fontsize=6.5, color=MUTED,
fontproperties=FONT, ha="left", va="baseline", zorder=4)
if fuss_text:
ax.text(breite - RAND_R, FUSS_LINIE - 4.4 * MM, fuss_text, fontsize=6.5, color=MUTED,
fontproperties=FONT, ha="right", va="baseline", zorder=4)
return _als_reader(fig)
def verarbeiten(quelle: Path, ziel: Path, *, titel: str, untertitel: str, fuss: str,
mit_deckblatt: bool, mit_fuss: bool, mit_kopf: bool) -> dict:
leser = PdfReader(str(quelle))
box = leser.pages[0].mediabox
breite, hoehe = float(box.width), float(box.height)
if breite < 100 or hoehe < 100:
raise SystemExit(f"FEHLER: unerwartetes Seitenformat {breite:.0f}×{hoehe:.0f} pt")
schreiber = PdfWriter()
seiten = 0
if mit_deckblatt:
schreiber.add_page(deckblatt(breite, hoehe, titel, untertitel, fuss).pages[0])
for i, seite in enumerate(leser.pages):
if abs(float(seite.mediabox.width) - breite) > 1 or abs(float(seite.mediabox.height) - hoehe) > 1:
seite.scale_to(breite, hoehe)
if mit_fuss:
seite.merge_page(fussbogen(breite, hoehe, fuss).pages[0])
if mit_kopf and i == 0:
seite.merge_page(kopfbogen(breite, hoehe, "KALINA" if not fuss else fuss.upper()).pages[0])
schreiber.add_page(seite)
seiten += 1
if leser.metadata:
schreiber.add_metadata({k: str(v) for k, v in leser.metadata.items() if v})
schreiber.add_metadata({"/Producer": "KALINA CI (stamp_existing_pdf.py)"})
if titel:
schreiber.add_metadata({"/Title": titel})
ziel.parent.mkdir(parents=True, exist_ok=True)
with open(ziel, "wb") as fh:
schreiber.write(fh)
return {"original_seiten": seiten, "deckblatt": mit_deckblatt, "kopf": mit_kopf,
"fuss": mit_fuss, "bytes": ziel.stat().st_size, "ziel": str(ziel)}
def main() -> int:
p = argparse.ArgumentParser(description="KALINA CI auf bestehendes PDF anwenden")
p.add_argument("quelle", type=Path)
p.add_argument("ziel", type=Path)
p.add_argument("--titel", default="", help="Titel auf dem Deckblatt (leer = Dateiname)")
p.add_argument("--untertitel", default="")
p.add_argument("--fuss", default="", help="Text rechts/mittig in der Fußzeile (z. B. Fassung, Aktenzeichen)")
p.add_argument("--ohne-deckblatt", action="store_true")
p.add_argument("--ohne-fuss", action="store_true")
p.add_argument("--kopf", action="store_true",
help="Ink-Kopfband über die obersten 14 mm — überdeckt Originalinhalt, vorher prüfen")
a = p.parse_args()
if not a.quelle.is_file():
print(f"FEHLER: {a.quelle} nicht gefunden", file=sys.stderr)
return 2
if a.quelle.resolve() == a.ziel.resolve():
print("FEHLER: Quelle und Ziel sind dieselbe Datei — das Original muss unangetastet bleiben",
file=sys.stderr)
return 2
titel = a.titel.strip() or a.quelle.stem
ergebnis = verarbeiten(
a.quelle, a.ziel, titel=titel, untertitel=a.untertitel.strip(), fuss=a.fuss.strip(),
mit_deckblatt=not a.ohne_deckblatt, mit_fuss=not a.ohne_fuss, mit_kopf=a.kopf,
)
gesamt = ergebnis["original_seiten"] + (1 if ergebnis["deckblatt"] else 0)
print(f"OK: {a.quelle.name} ({ergebnis['original_seiten']} Seiten) → {ergebnis['ziel']} "
f"({gesamt} Seiten, {ergebnis['bytes'] / 1024:.0f} KiB)")
if ergebnis["kopf"]:
print("HINWEIS: --kopf gesetzt — die obersten 14 mm jeder Seite sind jetzt Ink-Fläche.")
print("Gegenprobe: Seitenzahl in beiden Dateien vergleichen (`pdfinfo original.pdf ausgabe.pdf`), "
"dann `display_file` auf die Ausgabe.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+96
View File
@@ -0,0 +1,96 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>«Dokumenttitel» — KALINA</title>
<link rel="stylesheet" href="kalina-ci.css">
<style>
/* Prompt oben, Bild unten — ein Blatt. Reihenfolge und Größe nicht umbauen:
der Prompt füllt die erste Seite, das Bild folgt darunter (break-inside: avoid).
Die Werte (Prompt 8 pt, Bild max. 74 mm hoch) sind auf ein A4-Blatt gerechnet: Prompt bis
~2 000 Zeichen plus Bild plus Fussleiste passen zusammen auf Seite 1. Bei deutlich laengerem
Prompt rutscht das Bild auf Seite 2 - auch recht, dann nicht nachbessern.
Nach dem Bau GENAU EINE Gegenprobe (pdfinfo + pdftotext), dann ausliefern. */
.prompt-block {
border: 1px solid var(--steel);
border-top: 3px solid var(--rot);
background: #ffffff;
padding: 10px 12px;
margin: 8px 0 0;
font-size: 8pt;
line-height: 1.38;
}
.prompt-block p { margin: 0 0 6px; }
.prompt-block p:last-child { margin-bottom: 0; }
.bild-block { break-inside: avoid; margin: 12px 0 0; }
.bild-block img {
max-height: 74mm; /* gemessen: groesste Hoehe, bei der Prompt + Bild + Fussleiste
auf EIN A4-Blatt passen. Breite folgt der Hoehe (16:9 -> 132 mm). */
max-width: 100%;
width: auto;
display: block;
border: 1px solid var(--steel);
margin: 0 auto;
}
.bild-block .bildunterschrift {
width: 70%;
margin: 4px auto 0;
font-size: 8.5pt;
color: var(--muted);
}
</style>
</head>
<body class="dokument">
<!-- KOPFLEISTE: Marke (SVG, schriftfrei) + Wortmarke als TEXT (das i in Kalina-Rot) -->
<header class="kopf">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 240" width="34" height="34" aria-label="KALINA Mark">
<circle cx="120" cy="120" r="112" fill="#0e1419" stroke="#2a323c" stroke-width="2"/>
<g fill="none" stroke="#f5f5ef" stroke-width="30" stroke-linecap="square" stroke-linejoin="miter">
<path d="M84,72 L84,168"/><path d="M100,120 L176,64"/><path d="M100,124 L176,176"/>
</g>
<g fill="#c1121f">
<circle cx="176" cy="64" r="2.2"/><circle cx="189" cy="64" r="2.2"/><circle cx="182.5" cy="52.7" r="2.2"/><circle cx="169.5" cy="52.7" r="2.2"/><circle cx="169.5" cy="75.3" r="2.2"/><circle cx="182.5" cy="75.3" r="2.2"/>
<circle cx="176" cy="176" r="2.2"/><circle cx="189" cy="176" r="2.2"/><circle cx="182.5" cy="164.7" r="2.2"/><circle cx="169.5" cy="164.7" r="2.2"/><circle cx="169.5" cy="187.3" r="2.2"/><circle cx="182.5" cy="187.3" r="2.2"/>
</g>
<circle cx="100" cy="122" r="4.5" fill="#c1121f"/>
</svg>
<span class="wortmarke">KAL<i>I</i>NA</span>
</header>
<!-- TITELZONE -->
<h1 class="titel">«Dokumenttitel»</h1>
<p class="untertitel">«Bildprompt und Bild — Fassung»</p>
<p class="lead">«Ein Satz: was oben steht und was unten zu sehen ist.»</p>
<!-- PROMPT: den Prompt-TEXT 1:1 einsetzen, gekürzt oder umgeschrieben wird nicht -->
<h2>Prompt</h2>
<div class="prompt-block">
<p>«Prompt-Text, unverändert, wie an den Bildgenerator gegeben.»</p>
</div>
<!-- BILD: Datei liegt neben dem HTML (bei Bedarf vorher kopieren) -->
<div class="bild-block">
<img src="«BILDDATEI, z. B. bild.png»" alt="«Bildbeschreibung, kurz»">
<p class="bildunterschrift">«Bildunterschrift: Modell, Format, Pixelmaße.»</p>
</div>
<!-- FUSSMARKE -->
<footer class="fuss">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 240" width="26" height="26" aria-label="KALINA Mark">
<circle cx="120" cy="120" r="112" fill="#0e1419" stroke="#2a323c" stroke-width="2"/>
<g fill="none" stroke="#f5f5ef" stroke-width="30" stroke-linecap="square" stroke-linejoin="miter">
<path d="M84,72 L84,168"/><path d="M100,120 L176,64"/><path d="M100,124 L176,176"/>
</g>
<g fill="#c1121f">
<circle cx="176" cy="64" r="2.2"/><circle cx="189" cy="64" r="2.2"/><circle cx="182.5" cy="52.7" r="2.2"/><circle cx="169.5" cy="52.7" r="2.2"/><circle cx="169.5" cy="75.3" r="2.2"/><circle cx="182.5" cy="75.3" r="2.2"/>
<circle cx="176" cy="176" r="2.2"/><circle cx="189" cy="176" r="2.2"/><circle cx="182.5" cy="164.7" r="2.2"/><circle cx="169.5" cy="164.7" r="2.2"/><circle cx="169.5" cy="187.3" r="2.2"/><circle cx="182.5" cy="187.3" r="2.2"/>
</g>
<circle cx="100" cy="122" r="4.5" fill="#c1121f"/>
</svg>
<span>KALINA · «Dokumenttitel» · Stand «TT.MM.JJJJ»</span>
<span>ai.m-m.bio</span>
</footer>
</body>
</html>
+97
View File
@@ -0,0 +1,97 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>«Seitentitel» — KALINA</title>
<!-- Einzelne Datei, keine fremden Hosts: keine CDN-Links, keine externen Schriften,
keine Bilder von außen. Der Stil steht inline, damit die Seite überall gleich aussieht. -->
<style>
:root{--ink:#0b0f14;--bone:#f5f5ef;--rot:#c1121f;--rot-dunkel:#a50f1a;--steel:#3a4250;
--muted:#8a94a2;--sans:"DejaVu Sans","Helvetica Neue",Helvetica,Arial,sans-serif}
*{box-sizing:border-box}
body{margin:0;background:var(--bone);color:var(--ink);font:400 17px/1.6 var(--sans)}
.kopf{position:sticky;top:0;z-index:10;display:flex;align-items:center;gap:10px;
padding:10px 24px;background:var(--bone);border-bottom:1px solid var(--steel)}
.kopf svg{display:block;width:34px;height:34px}
.wortmarke{font-weight:900;font-size:21px;letter-spacing:.04em}
.wortmarke i{font-style:normal;color:var(--rot)}
main{max-width:52rem;margin:0 auto;padding:40px 24px 64px}
.kicker{font:700 11px/1 var(--sans);letter-spacing:.2em;text-transform:uppercase;color:var(--rot)}
h1{font-size:clamp(28px,5vw,44px);line-height:1.1;letter-spacing:-.01em;margin:14px 0 8px}
.lead{font-size:19px;color:var(--steel);margin:0 0 28px}
h2{font-size:20px;margin:36px 0 8px;padding-bottom:6px;border-bottom:1px solid var(--steel)}
h2::after{content:"";display:block;width:42px;height:3px;background:var(--rot);margin-top:-2px}
ul{padding-left:20px}
table{width:100%;border-collapse:collapse;font-size:15px;margin:14px 0}
thead th{background:var(--ink);color:var(--bone);text-align:left;padding:8px 10px}
td{border-bottom:1px solid var(--steel);padding:8px 10px;vertical-align:top}
.plakette{border:1px solid var(--steel);border-top:3px solid var(--rot);background:#fff;
padding:20px;margin:28px 0;display:flex;gap:18px;align-items:flex-start}
.plakette svg{width:96px;height:96px;flex:0 0 auto}
footer{border-top:1px solid var(--steel);margin-top:48px;padding:16px 24px;color:var(--muted);
font-size:13px;display:flex;justify-content:space-between;gap:12px}
</style>
</head>
<body>
<header class="kopf">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 240" aria-label="KALINA Mark">
<circle cx="120" cy="120" r="112" fill="#0e1419" stroke="#2a323c" stroke-width="2"/>
<g fill="none" stroke="#f5f5ef" stroke-width="30" stroke-linecap="square" stroke-linejoin="miter">
<path d="M84,72 L84,168"/><path d="M100,120 L176,64"/><path d="M100,124 L176,176"/>
</g>
<g fill="#c1121f">
<circle cx="176" cy="64" r="2.2"/><circle cx="189" cy="64" r="2.2"/><circle cx="182.5" cy="52.7" r="2.2"/><circle cx="169.5" cy="52.7" r="2.2"/><circle cx="169.5" cy="75.3" r="2.2"/><circle cx="182.5" cy="75.3" r="2.2"/>
<circle cx="176" cy="176" r="2.2"/><circle cx="189" cy="176" r="2.2"/><circle cx="182.5" cy="164.7" r="2.2"/><circle cx="169.5" cy="164.7" r="2.2"/><circle cx="169.5" cy="187.3" r="2.2"/><circle cx="182.5" cy="187.3" r="2.2"/>
</g>
<circle cx="100" cy="122" r="4.5" fill="#c1121f"/>
</svg>
<span class="wortmarke">KAL<i>I</i>NA</span>
</header>
<main>
<p class="kicker">«Rubrik, z. B. Bericht»</p>
<h1>«Seitentitel»</h1>
<p class="lead">«Ein Absatz, der den Inhalt in zwei Sätzen zusammenfasst.»</p>
<h2>«Abschnitt»</h2>
<p>«Text. Belegte Zahlen mit Quelle nennen; Schätzungen als Schätzung kennzeichnen.»</p>
<ul><li>«Punkt»</li><li>«Punkt»</li></ul>
<h2>«Abschnitt mit Tabelle»</h2>
<table>
<thead><tr><th>Position</th><th>Wert</th></tr></thead>
<tbody>
<tr><td>«…»</td><td>«…»</td></tr>
<tr><td>«…»</td><td>«…»</td></tr>
</tbody>
</table>
<aside class="plakette">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 240" aria-label="KALINA Mark">
<circle cx="120" cy="120" r="112" fill="#0e1419" stroke="#2a323c" stroke-width="2"/>
<g fill="none" stroke="#f5f5ef" stroke-width="30" stroke-linecap="square" stroke-linejoin="miter">
<path d="M84,72 L84,168"/><path d="M100,120 L176,64"/><path d="M100,124 L176,176"/>
</g>
<g fill="#c1121f">
<circle cx="176" cy="64" r="2.2"/><circle cx="189" cy="64" r="2.2"/><circle cx="182.5" cy="52.7" r="2.2"/><circle cx="169.5" cy="52.7" r="2.2"/><circle cx="169.5" cy="75.3" r="2.2"/><circle cx="182.5" cy="75.3" r="2.2"/>
<circle cx="176" cy="176" r="2.2"/><circle cx="189" cy="176" r="2.2"/><circle cx="182.5" cy="164.7" r="2.2"/><circle cx="169.5" cy="164.7" r="2.2"/><circle cx="169.5" cy="187.3" r="2.2"/><circle cx="182.5" cy="187.3" r="2.2"/>
</g>
<circle cx="100" cy="122" r="4.5" fill="#c1121f"/>
</svg>
<div>
<strong>«Was die Marke bedeutet»</strong>
<p>Kalina = Vogelbeere (Viburnum): Ausdauer, Lebenskraft, Jugend. Das K im Siegel ist der Name,
das i der Wortmarke ist die Beere. «Nur setzen, wenn die Seite die Marke erklären soll.»</p>
</div>
</aside>
</main>
<footer>
<span>KALINA · «Seitentitel»</span>
<span>Stand «TT.MM.JJJJ» · ai.m-m.bio</span>
</footer>
</body>
</html>
+103
View File
@@ -0,0 +1,103 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>«Dokumenttitel» — KALINA</title>
<link rel="stylesheet" href="kalina-ci.css">
<style>
/* Nur falls kalina-ci.css nicht per <link> erreichbar ist: Inhalt hier einsetzen. */
</style>
</head>
<body class="dokument">
<!-- KOPFLEISTE: Marke (SVG, schriftfrei) + Wortmarke als TEXT (das i in Kalina-Rot) -->
<header class="kopf">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 240" width="34" height="34" aria-label="KALINA Mark">
<circle cx="120" cy="120" r="112" fill="#0e1419" stroke="#2a323c" stroke-width="2"/>
<g fill="none" stroke="#f5f5ef" stroke-width="30" stroke-linecap="square" stroke-linejoin="miter">
<path d="M84,72 L84,168"/><path d="M100,120 L176,64"/><path d="M100,124 L176,176"/>
</g>
<g fill="#c1121f">
<circle cx="176" cy="64" r="2.2"/><circle cx="189" cy="64" r="2.2"/><circle cx="182.5" cy="52.7" r="2.2"/><circle cx="169.5" cy="52.7" r="2.2"/><circle cx="169.5" cy="75.3" r="2.2"/><circle cx="182.5" cy="75.3" r="2.2"/>
<circle cx="176" cy="176" r="2.2"/><circle cx="189" cy="176" r="2.2"/><circle cx="182.5" cy="164.7" r="2.2"/><circle cx="169.5" cy="164.7" r="2.2"/><circle cx="169.5" cy="187.3" r="2.2"/><circle cx="182.5" cy="187.3" r="2.2"/>
</g>
<circle cx="100" cy="122" r="4.5" fill="#c1121f"/>
</svg>
<span class="wortmarke">KAL<i>I</i>NA</span>
</header>
<!-- TITELZONE -->
<h1 class="titel">«Dokumenttitel»</h1>
<p class="untertitel">«Untertitel oder Dokumentart»</p>
<p class="lead">«Ein Satz, der sagt, worum es geht und was der Leser nach dem Lesen weiß.»</p>
<!-- METADATEN -->
<table>
<thead><tr><th style="width:34%">Feld</th><th>Wert</th></tr></thead>
<tbody>
<tr><td>Erstellt</td><td>«TT.MM.JJJJ»</td></tr>
<tr><td>Verfasser</td><td>«Name»</td></tr>
<tr><td>Stand / Fassung</td><td>«Fassung, z. B. 1.0»</td></tr>
<tr><td>Grundlage</td><td>«Quelle, Vertrag, Messung — mit Pfad oder URL»</td></tr>
</tbody>
</table>
<!-- ABSCHNITTE -->
<section class="abschnitt">
<h2>«Erster Abschnitt»</h2>
<p>«Text. Zahlen und Aussagen, die belegt sind, mit Quelle; Schätzungen als Schätzung kennzeichnen.»</p>
<ul>
<li>«Punkt»</li>
<li>«Punkt»</li>
</ul>
</section>
<section class="abschnitt">
<h2>«Zweiter Abschnitt»</h2>
<table>
<thead><tr><th>Position</th><th class="zahl">Menge</th><th class="zahl">Betrag</th></tr></thead>
<tbody>
<tr><td>«…»</td><td class="zahl">1</td><td class="zahl">0,00 €</td></tr>
<tr><td>«…»</td><td class="zahl">2</td><td class="zahl">0,00 €</td></tr>
</tbody>
</table>
<p class="hinweis">«Hinweis, Vorbehalt oder offene Frage.»</p>
</section>
<!-- MARKEN-PLAKETTE: dort, wo der Text die Marke erklärt (optional) -->
<aside class="plakette">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 240" width="116" height="116" aria-label="KALINA Mark">
<circle cx="120" cy="120" r="112" fill="#0e1419" stroke="#2a323c" stroke-width="2"/>
<g fill="none" stroke="#f5f5ef" stroke-width="30" stroke-linecap="square" stroke-linejoin="miter">
<path d="M84,72 L84,168"/><path d="M100,120 L176,64"/><path d="M100,124 L176,176"/>
</g>
<g fill="#c1121f">
<circle cx="176" cy="64" r="2.2"/><circle cx="189" cy="64" r="2.2"/><circle cx="182.5" cy="52.7" r="2.2"/><circle cx="169.5" cy="52.7" r="2.2"/><circle cx="169.5" cy="75.3" r="2.2"/><circle cx="182.5" cy="75.3" r="2.2"/>
<circle cx="176" cy="176" r="2.2"/><circle cx="189" cy="176" r="2.2"/><circle cx="182.5" cy="164.7" r="2.2"/><circle cx="169.5" cy="164.7" r="2.2"/><circle cx="169.5" cy="187.3" r="2.2"/><circle cx="182.5" cy="187.3" r="2.2"/>
</g>
<circle cx="100" cy="122" r="4.5" fill="#c1121f"/>
</svg>
<div class="plakette-titel">«Was die Marke bedeutet»</div>
<p>Kalina = Vogelbeere (Viburnum): Ausdauer, Lebenskraft, Jugend. K-Monogramm im Siegel,
das i der Wortmarke ist die Beere. «Nur einsetzen, wenn das Dokument die Marke erklären soll.»</p>
</aside>
<!-- FUSSMARKE -->
<footer class="fuss">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 240" width="26" height="26" aria-label="KALINA Mark">
<circle cx="120" cy="120" r="112" fill="#0e1419" stroke="#2a323c" stroke-width="2"/>
<g fill="none" stroke="#f5f5ef" stroke-width="30" stroke-linecap="square" stroke-linejoin="miter">
<path d="M84,72 L84,168"/><path d="M100,120 L176,64"/><path d="M100,124 L176,176"/>
</g>
<g fill="#c1121f">
<circle cx="176" cy="64" r="2.2"/><circle cx="189" cy="64" r="2.2"/><circle cx="182.5" cy="52.7" r="2.2"/><circle cx="169.5" cy="52.7" r="2.2"/><circle cx="169.5" cy="75.3" r="2.2"/><circle cx="182.5" cy="75.3" r="2.2"/>
<circle cx="176" cy="176" r="2.2"/><circle cx="189" cy="176" r="2.2"/><circle cx="182.5" cy="164.7" r="2.2"/><circle cx="169.5" cy="164.7" r="2.2"/><circle cx="169.5" cy="187.3" r="2.2"/><circle cx="182.5" cy="187.3" r="2.2"/>
</g>
<circle cx="100" cy="122" r="4.5" fill="#c1121f"/>
</svg>
<span>KALINA · «Dokumenttitel» · Stand «TT.MM.JJJJ»</span>
<span>ai.m-m.bio</span>
</footer>
</body>
</html>
+126
View File
@@ -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"
}
}
+286
View File
@@ -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())