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

+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())