#!/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 datetime 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" # Autor ist immer Kalina (Beschluss 23.10.2026): Copyright-Vermerk auf jeder Seite. JAHR = datetime.date.today().year COPYRIGHT = f"© {JAHR} kalina.si" 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, hintergrund: str = BONE) -> PdfReader: """Seite mit Fußlinie + Fußtext. Das Band wird zuerst in der Seitenfarbe gefüllt, damit ein alter Fuß (z. B. „ai.m-m.bio") nicht unter dem neuen durchscheint.""" fig, ax = _figur(breite, hoehe) band = plt.Rectangle((RAND_L - 1.5 * MM, FUSS_LINIE - 7.0 * MM), breite - RAND_L - RAND_R + 3.0 * MM, 8.4 * MM, facecolor=hintergrund, edgecolor="none", zorder=2) ax.add_patch(band) 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 · " + COPYRIGHT, 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 · " + COPYRIGHT, 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 _bandfarben(quelle: Path, anzahl: int) -> list[str]: """Haeufigste Farbe im Fussband je Seite - der Seitenhintergrund. Ohne Messung wuerde ein helles Band auf dunklen Dokumenten (z. B. Ink-Seiten der CI) als weisser Streifen stehen. """ try: import pymupdf # type: ignore except ImportError: try: import fitz as pymupdf # type: ignore except ImportError: return [BONE] * anzahl farben: list[str] = [] dok = pymupdf.open(str(quelle)) for i in range(anzahl): if i >= dok.page_count: farben.append(BONE) continue seite = dok[i] h = seite.rect.height clip = pymupdf.Rect(RAND_L, FUSS_LINIE - 5 * MM, seite.rect.width - RAND_R, FUSS_LINIE - 1 * MM) try: pix = seite.get_pixmap(clip=clip, dpi=72) zaehler: dict[tuple, int] = {} for y in range(pix.height): for x in range(pix.width): px = pix.pixel(x, y)[:3] zaehler[px] = zaehler.get(px, 0) + 1 farbe = max(zaehler.items(), key=lambda kv: kv[1])[0] farben.append("#%02x%02x%02x" % farbe) except Exception: farben.append(BONE) dok.close() return farben 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") hintergruende = _bandfarben(quelle, len(leser.pages)) if mit_fuss else [] 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: farbe = hintergruende[i] if i < len(hintergruende) else BONE seite.merge_page(fussbogen(breite, hoehe, fuss, farbe).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())