Files
kalina-ci/scripts/stamp_existing_pdf.py
T
mm db3725da4d 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.
2026-10-23 10:39:30 +02:00

253 lines
11 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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())