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.
No files matched your search
@@ -0,0 +1,6 @@
|
||||
*.pdf
|
||||
raster/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.DS_Store
|
||||
templates/kalina-ci.css
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
After Width: | Height: | Size: 867 B |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 102 KiB |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
|
After Width: | Height: | Size: 37 KiB |
@@ -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; }
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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())
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||