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:
commit
db3725da4d
21 files changed
+1450
No files matched your search
Executable
+252
@@ -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())
|
||||
Reference in new issue
Block a user