Bilder und Farbe gehoeren der Firma, dazu ein Vorschau-Knopf

Logo und ein Fussstreifen sind austauschbar (bilder.py: das gewaehlte Bild wird
KOPIERT, ein Pfad auf den Desktop waere morgen tot), die Akzentfarbe kommt aus
den Einstellungen - Hex-Feld und Farbwaehler. Firmenname, Anschrift, Bank und
Steuernummer bleiben gezeichnet; kein Bild kann eine Pflichtangabe verdecken.

Der Vorschau-Knopf zeigt das Blatt, bevor es eine Rechnung wird: kein
Nummernverbrauch, nichts im Rechnungsordner, "VORSCHAU - keine Rechnung" quer
ueber jeder Seite. Sonst kostet jeder Tippfehler einen Storno.

Neue Pruefstaende: pruef_bilder.py, pruef_vorschau.py

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax
This commit is contained in:
TheMockTv 2026-09-05 17:35:37 +02:00
parent 37b5c60266
commit 142dddc647
7 changed files with 636 additions and 78 deletions

View file

@ -15,7 +15,6 @@ import json
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import mm
from reportlab.lib.utils import ImageReader
from reportlab.lib import colors
from reportlab.lib.enums import TA_RIGHT, TA_LEFT, TA_CENTER
from reportlab.lib.styles import ParagraphStyle
@ -24,6 +23,7 @@ from reportlab.platypus import (
Flowable,
)
import bilder
import config
from berechnung import eur, eur_kurz, runde
@ -46,11 +46,49 @@ class _AmtMarke(Flowable):
self._merker["seite"] = self.canv.getPageNumber()
# ---- Farben / Masse ---------------------------------------------------------
ACCENT = colors.HexColor("#1f5c3d") # gedecktes Gruen (Camping)
# Die Akzentfarbe gehoert der Firma, nicht dem Programm: Ueberschriften, die
# Linie unter dem Kopf und der Tabellenkopf tragen sie. Ein Handwerker will kein
# Camping-Gruen auf seiner Rechnung. Eingestellt wird EINE Farbe - die helle
# Flaeche im Tabellenkopf wird daraus gemischt, damit niemand zwei Farben
# aufeinander abstimmen muss.
AKZENT_VORGABE = "#1f5c3d" # gedecktes Gruen (Camping)
ACCENT = colors.HexColor(AKZENT_VORGABE)
GREY_BG = colors.HexColor("#eef1ee")
LINE = colors.HexColor("#9aa39c")
DARK = colors.HexColor("#222222")
def _farbe(wert, vorgabe):
"""'#1f5c3d' -> Farbe. Alles Unlesbare faellt auf die Vorgabe zurueck.
Eine kaputte Farbe in der config.json darf niemals das Schreiben der
Rechnung verhindern - dann steht der Gast an der Schranke.
"""
try:
return colors.HexColor(str(wert).strip())
except (ValueError, AttributeError):
if str(wert).strip():
print(f"[pdf] Farbe '{wert}' nicht lesbar - nehme {vorgabe}")
return colors.HexColor(vorgabe)
def _design_anwenden(cfg):
"""Firmenfarbe uebernehmen, bevor die Stile gebaut werden.
Die Farben stehen als Modulwerte da, weil 13 Stellen im Layout sie lesen.
Sie werden hier EINMAL je Rechnung gesetzt - das Programm rendert nie zwei
Rechnungen gleichzeitig.
"""
global ACCENT, GREY_BG
design = (cfg or {}).get("design") or {}
ACCENT = _farbe(design.get("akzent", AKZENT_VORGABE), AKZENT_VORGABE)
# Helle Flaeche = 8 % Akzent auf Weiss. So passt der Tabellenkopf zu jeder
# gewaehlten Farbe, ohne dass jemand einen zweiten Wert pflegen muss.
GREY_BG = colors.Color(1 - 0.08 * (1 - ACCENT.red),
1 - 0.08 * (1 - ACCENT.green),
1 - 0.08 * (1 - ACCENT.blue))
MARGIN_L = 18 * mm
MARGIN_R = 18 * mm
MARGIN_TOP = 40 * mm # Platz fuer den Kopf
@ -100,36 +138,46 @@ def _styles():
# ---- Kopf + Fusszeile (auf jeder Seite gezeichnet) --------------------------
def _kopf_fuss(canvas, doc, cfg, amt=False):
def _vorschau_marke(canvas):
"""Schraeg ueber das Blatt: das hier ist KEINE Rechnung.
Die Vorschau soll zeigen, wie das Blatt aussehen wird - ausgedruckt oder
weitergereicht darf sie nie mit der Rechnung verwechselt werden. Sonst haette
der Gast ein Blatt in der Hand, das keine Nummer verbraucht hat und in keiner
Buchhaltung steht.
"""
canvas.saveState()
canvas.setFont("Helvetica-Bold", 62)
canvas.setFillColor(colors.Color(0.55, 0.55, 0.55, alpha=0.22))
canvas.translate(PAGE_W / 2, PAGE_H / 2)
canvas.rotate(38)
canvas.drawCentredString(0, 0, "VORSCHAU")
canvas.setFont("Helvetica-Bold", 15)
canvas.drawCentredString(0, -22 * mm, "keine Rechnung")
canvas.restoreState()
def _kopf_fuss(canvas, doc, cfg, amt=False, vorschau=False):
"""Kopf und Fuss einer Seite. amt=True -> Blatt fuer das Amt (siehe Fusszeile)."""
firma = cfg["firma"]
canvas.saveState()
if vorschau:
_vorschau_marke(canvas)
# --- Kopf ---
logo = (firma.get("logo") or "").strip()
# relativen Pfad (z.B. "assets/logo.png") portabel aufloesen:
# erst neben Tool/exe, dann im gebuendelten Ressourcen-Ordner (.exe)
if logo and not os.path.isabs(logo):
for _base in (config.BASE_DIR, config.RESOURCE_DIR):
_cand = os.path.join(_base, logo)
if os.path.exists(_cand):
logo = _cand
break
# Das Kopfbild liegt oben links, Firmenname und Untertitel stehen rechts
# daneben - genau wie bisher. Austauschbar ist nur das BILD; die Texte
# kommen aus den Einstellungen und bleiben gezeichnet.
img, iw, ih = bilder.laden(firma.get("logo"))
line_y = PAGE_H - 25.5 * mm # Hoehe der Trennlinie ohne Bild (wie bisher)
logo_ok = False
if logo and os.path.exists(logo):
try:
img = ImageReader(logo)
iw, ih = img.getSize()
box_w, box_h = 60 * mm, 20 * mm # max. Logo-Box oben links
scale = min(box_w / iw, box_h / ih)
dw, dh = iw * scale, ih * scale
ly = PAGE_H - 9 * mm - dh # Oberkante ~9mm vom Blattrand
canvas.drawImage(img, MARGIN_L, ly, width=dw, height=dh,
preserveAspectRatio=True, mask="auto")
logo_ok = True
line_y = ly - 3 * mm
except Exception as e: # noqa: BLE001
print("[pdf] Logo konnte nicht geladen werden:", e)
if img:
dw, dh = bilder.einpassen(iw, ih, 60 * mm, 20 * mm) # Kasten oben links
ly = PAGE_H - 9 * mm - dh # Oberkante ~9mm vom Blattrand
canvas.drawImage(img, MARGIN_L, ly, width=dw, height=dh,
preserveAspectRatio=True, mask="auto")
logo_ok = True
line_y = ly - 3 * mm
if logo_ok:
# Firmenname + Untertitel rechtsbuendig neben dem Logo
@ -172,6 +220,20 @@ def _kopf_fuss(canvas, doc, cfg, amt=False):
col_m = MARGIN_L + CONTENT_W * 0.40
col_r = PAGE_W - MARGIN_R
# Das Fusszeilen-Bild steht auf dem AMT-BLATT NICHT. Ein fertiges
# Briefpapier-Band traegt fast immer Bank und Steuernummer - und genau die
# duerfen dort nicht auftauchen (siehe Trennung oben). Das Amt bekommt
# deshalb immer die gezeichnete Fusszeile, auch wenn der Nutzer den Haken
# "Firmenangaben stehen im Bild" gesetzt hat.
fuss_img, fiw, fih = (None, 0, 0) if amt else bilder.laden(firma.get("fussbild"))
if fuss_img:
# Schmaler Streifen UNTER den Fusstexten. Die Texte bleiben, wo sie sind -
# Firma, Anschrift, Bank und Steuernummer stehen auf jeder Rechnung, egal
# welches Bild jemand einstellt.
dw, dh = bilder.einpassen(fiw, fih, CONTENT_W, 17 * mm)
canvas.drawImage(fuss_img, MARGIN_L + (CONTENT_W - dw) / 2, 7 * mm,
width=dw, height=dh, preserveAspectRatio=True, mask="auto")
rows_l = [] if amt else [firma.get("bank", ""), firma.get("iban", ""), firma.get("bic", "")]
rows_m = [firma.get("inhaber", "")] if amt else [firma.get("inhaber", ""),
firma.get("steuernummer", "")]
@ -667,7 +729,7 @@ def _kenndaten(rechnung, kopf):
return json.dumps(daten, ensure_ascii=False)
def render_rechnung(pfad, cfg, rechnung, kopf):
def render_rechnung(pfad, cfg, rechnung, kopf, vorschau=False):
"""
pfad : Zielpfad der PDF
cfg : geladene Konfiguration
@ -679,6 +741,7 @@ def render_rechnung(pfad, cfg, rechnung, kopf):
Letzte Seite = Blatt fuers Amt: KEINE Beherbergungssteuer (auch nicht im Gesamt),
keine Zwischensumme, keine Bankverbindung, keine Steuernummer.
"""
_design_anwenden(cfg) # Firmenfarbe VOR den Stilen - sie lesen sie mit
st = _styles()
def bauen(ziel, amt_seite):
@ -702,7 +765,9 @@ def render_rechnung(pfad, cfg, rechnung, kopf):
_seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont=True) # Amt
def seite(c, d):
_kopf_fuss(c, d, cfg, amt=(amt_seite is not None and c.getPageNumber() >= amt_seite))
_kopf_fuss(c, d, cfg,
amt=(amt_seite is not None and c.getPageNumber() >= amt_seite),
vorschau=vorschau)
doc.build(story, onFirstPage=seite, onLaterPages=seite)