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

100
app.py
View file

@ -20,6 +20,7 @@ Aufgeteilt in:
""" """
import os import os
import tempfile
import sys import sys
import datetime import datetime
import queue import queue
@ -106,6 +107,7 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
self._open_pop = None self._open_pop = None
self._menus = { self._menus = {
"Datei": [ "Datei": [
("Vorschau ansehen", self.vorschau_zeigen),
("PDF erstellen", self.pdf_erstellen), ("PDF erstellen", self.pdf_erstellen),
("Formular zurücksetzen", self.reset_formular), ("Formular zurücksetzen", self.reset_formular),
("Ausgabeordner öffnen", self._ordner_oeffnen), ("Ausgabeordner öffnen", self._ordner_oeffnen),
@ -524,6 +526,7 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
links = ttk.Frame(f) links = ttk.Frame(f)
links.pack(side="left") links.pack(side="left")
ttk.Button(links, text="Vorschau", command=self.vorschau_zeigen, width=12).pack(side="left", padx=4)
ttk.Button(links, text="PDF erstellen", command=self.pdf_erstellen, width=18).pack(side="left", padx=4) ttk.Button(links, text="PDF erstellen", command=self.pdf_erstellen, width=18).pack(side="left", padx=4)
ttk.Button(links, text="Storno", command=self.dlg_storno, ttk.Button(links, text="Storno", command=self.dlg_storno,
width=12).pack(side="left", padx=4) width=12).pack(side="left", padx=4)
@ -1074,6 +1077,76 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
return jahr, zaehler, nummer return jahr, zaehler, nummer
return jahr, zaehler, f"{jahr}-A{zaehler:03d}" return jahr, zaehler, f"{jahr}-A{zaehler:03d}"
def _kopf_basis(self, nummer, r):
"""Die Kopfdaten, die JEDES Blatt braucht - Rechnung wie Vorschau.
Steht nur hier: sonst tippt die Vorschau denselben Adressblock ein
zweites Mal ab, und beim naechsten neuen Feld zeigt sie ein Blatt, das
es so gar nicht gibt.
Auf der Rechnung stehen Vor- und Nachname wie gewohnt in einer Zeile;
der Nachname geht zusaetzlich getrennt mit, damit das Steuerjournal ihn
nicht aus dem Adressblock raten muss.
"""
nachname = self.k_name.get().strip()
vorname = self.k_vorname.get().strip()
namenszeile = " ".join(t for t in (vorname, nachname) if t)
return {
"rechnungsnummer": nummer,
"datum": self.r_datum.get().strip() or datetime.date.today().strftime("%d.%m.%Y"),
"anreise": self.r_anreise.get().strip(),
"abreise": self.r_abreise.get().strip(),
"naechte": self.r_naechte.get() if self.r_naechte.get() != "–" else "",
"kunde_zeilen": [self.k_anrede.get(), namenszeile, self.k_zusatz.get(),
self.k_strasse.get(), self.k_plzort.get()],
"nachname": nachname,
"vorname": vorname,
# Fuer Version 3: kompletter Formularstand, damit die Rechnung spaeter
# wieder ins Formular geladen werden kann statt abgetippt zu werden.
"kunde": {"anrede": self.k_anrede.get().strip(), "vorname": vorname,
"nachname": nachname, "zusatz": self.k_zusatz.get().strip(),
"strasse": self.k_strasse.get().strip(),
"plzort": self.k_plzort.get().strip()},
"positionen": [{"nr": z.nr, "anzahl": z.anzahl, "personen": z.personen}
for z in r.zeilen],
}
def vorschau_zeigen(self):
"""Zeigt das Blatt, BEVOR es eine Rechnung wird.
Eine ausgestellte Rechnung laesst sich nur noch ueber einen Storno
geradeziehen - Papier, Porto und ein Weg zum Gast. Deshalb dieser Knopf:
ansehen, aendern, ansehen. Die Vorschau
- verbraucht KEINE Rechnungsnummer und traegt nichts ins Nummernbuch,
- landet NICHT im Rechnungsordner, sondern im Temp-Ordner,
- traegt quer ueber jede Seite "VORSCHAU - keine Rechnung".
Gepruefte Pflichtfelder gibt es hier bewusst nicht: man will ja gerade
sehen, was noch fehlt.
"""
montage = self.ist_montage()
r = self.recalc()
if not r.zeilen:
messagebox.showinfo(
"Noch nichts einzutragen",
"Bitte die Anzahl der Monate eintragen." if montage else
"Trage erst bei einer Leistung eine Anzahl ein - dann zeigt die "
"Vorschau, wie die Rechnung aussehen wird.")
return
kopf = self._kopf_basis(self.r_nummer.get().strip() or "(noch keine Nummer)", r)
if montage:
kopf["vorlage"] = "montage"
kopf["montage_text"] = self._montage_text()
pfad = os.path.join(tempfile.gettempdir(), "Vorschau - so wird die Rechnung.pdf")
try:
pdf_renderer.render_rechnung(pfad, self.cfg, r, kopf, vorschau=True)
print(f"[vorschau] geschrieben -> {pfad}")
os.startfile(pfad) # noqa: S606
except OSError as e:
messagebox.showerror("Vorschau nicht möglich",
f"Die Vorschau konnte nicht geöffnet werden:\n{e}")
def pdf_erstellen(self, angebot=False): def pdf_erstellen(self, angebot=False):
montage = self.ist_montage() montage = self.ist_montage()
r = self.recalc() r = self.recalc()
@ -1145,14 +1218,6 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
# unangetastet, die naechste Rechnung behaelt ihre Nummer. # unangetastet, die naechste Rechnung behaelt ihre Nummer.
a_jahr, a_zaehler, nummer = self._naechstes_angebot() a_jahr, a_zaehler, nummer = self._naechstes_angebot()
# Auf der Rechnung stehen Vor- und Nachname wie gewohnt in einer Zeile;
# der Nachname geht zusätzlich getrennt mit, damit das Steuerjournal ihn
# nicht aus dem Adressblock raten muss.
nachname = self.k_name.get().strip()
vorname = self.k_vorname.get().strip()
namenszeile = " ".join(t for t in (vorname, nachname) if t)
kunde_zeilen = [self.k_anrede.get(), namenszeile, self.k_zusatz.get(),
self.k_strasse.get(), self.k_plzort.get()]
# Liegt ein Storno im Zwischenspeicher, ist das hier die berichtigte Rechnung. # Liegt ein Storno im Zwischenspeicher, ist das hier die berichtigte Rechnung.
# Letzte Gelegenheit umzukehren: sagt er Nein, wird das Storno-PDF wieder # Letzte Gelegenheit umzukehren: sagt er Nein, wird das Storno-PDF wieder
# geloescht und es bleibt allein die urspruengliche Rechnung stehen. # geloescht und es bleibt allein die urspruengliche Rechnung stehen.
@ -1173,24 +1238,7 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk):
f"Der Storno wurde gelöscht. Es bleibt allein die Rechnung {stand['zu']}.") f"Der Storno wurde gelöscht. Es bleibt allein die Rechnung {stand['zu']}.")
return return
kopf = { kopf = self._kopf_basis(nummer, r)
"rechnungsnummer": nummer,
"datum": self.r_datum.get().strip() or datetime.date.today().strftime("%d.%m.%Y"),
"anreise": self.r_anreise.get().strip(),
"abreise": self.r_abreise.get().strip(),
"naechte": self.r_naechte.get() if self.r_naechte.get() != "–" else "",
"kunde_zeilen": kunde_zeilen,
"nachname": nachname,
"vorname": vorname,
# Fuer Version 3: kompletter Formularstand, damit die Rechnung spaeter
# wieder ins Formular geladen werden kann statt abgetippt zu werden.
"kunde": {"anrede": self.k_anrede.get().strip(), "vorname": vorname,
"nachname": nachname, "zusatz": self.k_zusatz.get().strip(),
"strasse": self.k_strasse.get().strip(),
"plzort": self.k_plzort.get().strip()},
"positionen": [{"nr": z.nr, "anzahl": z.anzahl, "personen": z.personen}
for z in r.zeilen],
}
if montage: if montage:
# Das Blatt sieht anders aus (eine Zeile statt Tabelle), die Buchung # Das Blatt sieht anders aus (eine Zeile statt Tabelle), die Buchung
# dahinter ist eine ganz normale: Zwischensumme + Beherbergungssteuer. # dahinter ist eine ganz normale: Zwischensumme + Beherbergungssteuer.

117
bilder.py Normal file
View file

@ -0,0 +1,117 @@
# -*- coding: utf-8 -*-
"""Bilder fuer Kopf- und Fusszeile der Rechnung: ablegen, wiederfinden, laden.
Warum eine eigene Datei: Kopfbild und Fussbild brauchen dieselben drei Schritte
(uebernehmen -> aufloesen -> laden), und der PDF-Renderer, der Einstellungsdialog
und die Pruefstaende brauchen sie alle. Stuende die Logik im Renderer, muesste der
Dialog sie nachbauen - und beim naechsten Bild laufen die beiden auseinander.
⭐ Der wichtigste Teil ist `uebernehmen`: das gewaehlte Bild wird in den Ordner
`bilder/` neben der config.json KOPIERT, statt nur seinen Pfad zu merken. Sonst
zeigt die Einstellung auf den Desktop des Nutzers - und sobald er dort aufraeumt,
faellt das Logo still von der Rechnung. Gemerkt wird nur der kurze Name
("bilder/logo.png"), damit die Einstellung auch nach einem Umzug des Ordners passt.
"""
import os
import shutil
import config
# Erlaubte Formate. reportlab liest PNG/JPG/GIF direkt; SVG kann es nicht -
# deshalb steht es hier bewusst NICHT.
FORMATE = (".png", ".jpg", ".jpeg", ".gif")
ORDNER = "bilder"
def bilder_dir():
"""Ordner fuer die uebernommenen Bilder (neben der config.json). Wird angelegt."""
d = os.path.join(config.BASE_DIR, ORDNER)
os.makedirs(d, exist_ok=True)
return d
def uebernehmen(quelle):
"""Bild in den Bilderordner kopieren -> gibt den zu speichernden Pfad zurueck.
Liegt die Datei schon im Bilderordner, wird nichts kopiert. Ein Name, der
bereits vergeben ist, bekommt eine Nummer angehaengt - ein bestehendes Bild
wird NIE ueberschrieben, sonst waere das alte Logo mit einem Klick weg.
"""
quelle = (quelle or "").strip()
if not quelle:
return ""
if not os.path.exists(quelle):
print(f"[bilder] Quelle gibt es nicht: {quelle}")
return ""
ziel_dir = bilder_dir()
name = os.path.basename(quelle)
if os.path.dirname(os.path.abspath(quelle)) == os.path.abspath(ziel_dir):
return f"{ORDNER}/{name}"
stamm, endung = os.path.splitext(name)
ziel = os.path.join(ziel_dir, name)
n = 2
while os.path.exists(ziel) and not _gleich(quelle, ziel):
ziel = os.path.join(ziel_dir, f"{stamm}-{n}{endung}")
n += 1
try:
shutil.copyfile(quelle, ziel)
print(f"[bilder] uebernommen: {quelle} -> {ziel}")
except OSError as e:
print(f"[bilder] FEHLER beim Kopieren ({e}) - merke den Originalpfad")
return quelle
return f"{ORDNER}/{os.path.basename(ziel)}"
def _gleich(a, b):
"""Zwei Dateien byte-gleich? Verhindert 'logo-2.png' beim zweiten Waehlen desselben Bilds."""
try:
if os.path.getsize(a) != os.path.getsize(b):
return False
with open(a, "rb") as fa, open(b, "rb") as fb:
return fa.read() == fb.read()
except OSError:
return False
def aufloesen(pfad):
"""Gespeicherten Pfad -> existierender absoluter Pfad, sonst "".
Ein relativer Pfad wird erst neben dem Programm gesucht (BASE_DIR, dort liegt
der Bilderordner), dann im gebuendelten Ressourcenordner der .exe - so findet
die exe ihr mitgeliefertes Standardlogo, der Nutzer aber sein eigenes.
"""
pfad = (pfad or "").strip()
if not pfad:
return ""
if os.path.isabs(pfad):
return pfad if os.path.exists(pfad) else ""
for basis in (config.BASE_DIR, config.RESOURCE_DIR):
kandidat = os.path.join(basis, pfad)
if os.path.exists(kandidat):
return kandidat
return ""
def laden(pfad):
"""(ImageReader, breite, hoehe) oder (None, 0, 0). Meldet, warum es nicht ging."""
voll = aufloesen(pfad)
if not voll:
if (pfad or "").strip():
print(f"[bilder] nicht gefunden: {pfad}")
return None, 0, 0
try:
from reportlab.lib.utils import ImageReader
img = ImageReader(voll)
iw, ih = img.getSize()
return img, iw, ih
except Exception as e: # noqa: BLE001
print(f"[bilder] '{voll}' laesst sich nicht lesen: {e}")
return None, 0, 0
def einpassen(iw, ih, box_w, box_h):
"""Groesse im Kasten, Seitenverhaeltnis bleibt. Nie groesser als das Original."""
if not iw or not ih:
return 0, 0
skala = min(box_w / iw, box_h / ih)
return iw * skala, ih * skala

View file

@ -124,6 +124,11 @@ DEFAULT_FIRMA = {
"email": "E-Mail kontakt@example.de", "email": "E-Mail kontakt@example.de",
"telefon": "Telefon +49 (0) 000 0000000", "telefon": "Telefon +49 (0) 000 0000000",
"logo": "assets/logo.png", # Bilddatei -> oben links auf der Rechnung (relativ zum Tool-Ordner) "logo": "assets/logo.png", # Bilddatei -> oben links auf der Rechnung (relativ zum Tool-Ordner)
# Bild fuer die Fusszeile: ein schmaler Streifen UNTER den Fusstexten. Firma,
# Anschrift, Bank und Steuernummer bleiben immer gezeichnet - sie kommen aus
# den Einstellungen und duerfen nicht in einem Bild verschwinden, das niemand
# nachlesen kann. Leer = die Rechnung sieht aus wie bisher.
"fussbild": "",
} }
# Festtexte unten auf der Rechnung (aus der Vorlage). # Festtexte unten auf der Rechnung (aus der Vorlage).
@ -135,8 +140,16 @@ DEFAULT_TEXTE = {
"hinweis": "* Bei Zahlung per Überweisung bitte die Rechnungsnummer angeben *", "hinweis": "* Bei Zahlung per Überweisung bitte die Rechnungsnummer angeben *",
} }
# Aussehen der Rechnung. EINE Farbe - die helle Flaeche im Tabellenkopf wird im
# Renderer daraus gemischt. Jede Firma faerbt ihr Blatt selbst; ein Handwerker
# will kein Camping-Gruen.
DEFAULT_DESIGN = {
"akzent": "#1f5c3d",
}
DEFAULT_CONFIG = { DEFAULT_CONFIG = {
"firma": DEFAULT_FIRMA, "firma": DEFAULT_FIRMA,
"design": DEFAULT_DESIGN,
"texte": DEFAULT_TEXTE, "texte": DEFAULT_TEXTE,
"katalog": DEFAULT_KATALOG, "katalog": DEFAULT_KATALOG,
"montage": DEFAULT_MONTAGE, "montage": DEFAULT_MONTAGE,

View file

@ -8,9 +8,10 @@ hart im Code: Steuersaetze, Preise, Firmendaten, Rechnungsnummernkreis.
import datetime import datetime
import os import os
import tkinter as tk import tkinter as tk
from tkinter import ttk, messagebox, filedialog from tkinter import ttk, messagebox, filedialog, colorchooser
import berechnung import berechnung
import bilder
import config import config
from helfer import parse_num from helfer import parse_num
@ -333,7 +334,7 @@ class EinstellungenMixin:
d.transient(self) d.transient(self)
d.grab_set() d.grab_set()
self._style_dialog(d) self._style_dialog(d)
d.geometry("640x640") d.geometry("640x740")
frm = ttk.Frame(d, padding=12) frm = ttk.Frame(d, padding=12)
frm.pack(fill="both", expand=True) frm.pack(fill="both", expand=True)
@ -353,39 +354,91 @@ class EinstellungenMixin:
frm.columnconfigure(1, weight=1) frm.columnconfigure(1, weight=1)
base = len(felder) base = len(felder)
# Logo (Bilddatei) mit Durchsuchen / Entfernen # Bilder auf der Rechnung: Kopf (oben links) und Fuss (Streifen unten).
ttk.Label(frm, text="Logo (oben links)").grid(row=base, column=0, sticky="w", pady=2) # Beide funktionieren gleich, deshalb baut EINE Funktion beide Zeilen -
logo_row = ttk.Frame(frm) # sonst laufen sie beim naechsten Umbau auseinander.
logo_row.grid(row=base, column=1, sticky="ew", padx=(8, 0), pady=2) bild_vars = {}
v_logo = tk.StringVar(value=self.cfg["firma"].get("logo", ""))
ttk.Entry(logo_row, textvariable=v_logo).pack(side="left", fill="x", expand=True)
def logo_waehlen(): def bild_zeile(zeile, key, beschriftung):
p = filedialog.askopenfilename( ttk.Label(frm, text=beschriftung).grid(row=zeile, column=0, sticky="nw", pady=2)
title="Logo auswählen", kasten = ttk.Frame(frm)
filetypes=[("Bilder", "*.png *.jpg *.jpeg *.gif"), ("Alle Dateien", "*.*")]) kasten.grid(row=zeile, column=1, sticky="ew", padx=(8, 0), pady=2)
if p: v = tk.StringVar(value=self.cfg["firma"].get(key, ""))
v_logo.set(p) reihe = ttk.Frame(kasten)
reihe.pack(fill="x")
ttk.Entry(reihe, textvariable=v).pack(side="left", fill="x", expand=True)
ttk.Button(logo_row, text="Durchsuchen…", command=logo_waehlen).pack(side="left", padx=(6, 0)) def waehlen():
ttk.Button(logo_row, text="Entfernen", command=lambda: v_logo.set("")).pack(side="left", padx=(4, 0)) p = filedialog.askopenfilename(
title=beschriftung + " auswählen",
filetypes=[("Bilder", "*.png *.jpg *.jpeg *.gif"), ("Alle Dateien", "*.*")],
parent=d)
if p:
# Das Bild wird in den Ordner "bilder" neben das Programm
# KOPIERT. Der Pfad zum Desktop waere morgen tot - und das
# faellt erst beim Drucken auf.
v.set(bilder.uebernehmen(p))
ttk.Label(frm, text="Danktext 1").grid(row=base + 1, column=0, sticky="nw", pady=2) ttk.Button(reihe, text="Durchsuchen…", command=waehlen).pack(side="left", padx=(6, 0))
ttk.Button(reihe, text="Entfernen", command=lambda: v.set("")).pack(side="left", padx=(4, 0))
bild_vars[key] = v
# Ausgetauscht wird nur das BILD. Firmenname, Anschrift, Bank und
# Steuernummer stehen weiter gezeichnet auf dem Blatt - sie kommen aus den
# Feldern darueber und duerfen in keinem Bild verschwinden.
bild_zeile(base, "logo", "Bild oben (Logo)")
bild_zeile(base + 1, "fussbild", "Bild unten (Streifen)")
# Farbe der Rechnung: Ueberschriften, Linie unter dem Kopf, Tabellenkopf.
# EINE Farbe - die helle Flaeche wird im Renderer daraus gemischt.
ttk.Label(frm, text="Farbe der Rechnung").grid(row=base + 2, column=0, sticky="w", pady=2)
farb_reihe = ttk.Frame(frm)
farb_reihe.grid(row=base + 2, column=1, sticky="ew", padx=(8, 0), pady=2)
v_farbe = tk.StringVar(value=(self.cfg.get("design", {}) or {}).get(
"akzent", config.DEFAULT_DESIGN["akzent"]))
probe = tk.Label(farb_reihe, width=6, relief="solid", borderwidth=1)
def probe_zeigen(*_):
wert = v_farbe.get().strip()
try:
probe.configure(background=wert)
except tk.TclError:
# Unlesbare Farbe: das Feld bleibt grau, gespeichert wird sie nicht.
probe.configure(background=self.col_field)
def farbe_waehlen():
_, hexwert = colorchooser.askcolor(color=v_farbe.get().strip() or None,
title="Farbe der Rechnung", parent=d)
if hexwert:
v_farbe.set(hexwert)
probe.pack(side="left")
ttk.Entry(farb_reihe, textvariable=v_farbe, width=12).pack(side="left", padx=(6, 0))
ttk.Button(farb_reihe, text="Wählen…", command=farbe_waehlen).pack(side="left", padx=(6, 0))
ttk.Button(farb_reihe, text="Zurücksetzen",
command=lambda: v_farbe.set(config.DEFAULT_DESIGN["akzent"])).pack(side="left", padx=(4, 0))
v_farbe.trace_add("write", probe_zeigen)
probe_zeigen()
ttk.Label(frm, text="Danktext 1").grid(row=base + 3, column=0, sticky="nw", pady=2)
t1 = tk.Text(frm, width=58, height=3, wrap="word", bg=self.col_field, fg=self.col_fg, insertbackground=self.col_fg) t1 = tk.Text(frm, width=58, height=3, wrap="word", bg=self.col_field, fg=self.col_fg, insertbackground=self.col_fg)
t1.insert("1.0", self.cfg["texte"].get("dank1", "")) t1.insert("1.0", self.cfg["texte"].get("dank1", ""))
t1.grid(row=base + 1, column=1, sticky="ew", padx=(8, 0), pady=2) t1.grid(row=base + 3, column=1, sticky="ew", padx=(8, 0), pady=2)
ttk.Label(frm, text="Danktext 2").grid(row=base + 2, column=0, sticky="nw", pady=2) ttk.Label(frm, text="Danktext 2").grid(row=base + 4, column=0, sticky="nw", pady=2)
t2 = tk.Text(frm, width=58, height=3, wrap="word", bg=self.col_field, fg=self.col_fg, insertbackground=self.col_fg) t2 = tk.Text(frm, width=58, height=3, wrap="word", bg=self.col_field, fg=self.col_fg, insertbackground=self.col_fg)
t2.insert("1.0", self.cfg["texte"].get("dank2", "")) t2.insert("1.0", self.cfg["texte"].get("dank2", ""))
t2.grid(row=base + 2, column=1, sticky="ew", padx=(8, 0), pady=2) t2.grid(row=base + 4, column=1, sticky="ew", padx=(8, 0), pady=2)
ttk.Label(frm, text="Hinweiszeile").grid(row=base + 3, column=0, sticky="w", pady=2) ttk.Label(frm, text="Hinweiszeile").grid(row=base + 5, column=0, sticky="w", pady=2)
vh = tk.StringVar(value=self.cfg["texte"].get("hinweis", "")) vh = tk.StringVar(value=self.cfg["texte"].get("hinweis", ""))
ttk.Entry(frm, textvariable=vh, width=58).grid(row=base + 3, column=1, sticky="ew", padx=(8, 0), pady=2) ttk.Entry(frm, textvariable=vh, width=58).grid(row=base + 5, column=1, sticky="ew", padx=(8, 0), pady=2)
def speichern(): def speichern():
for key, v in vars_.items(): for key, v in vars_.items():
self.cfg["firma"][key] = v.get() self.cfg["firma"][key] = v.get()
self.cfg["firma"]["logo"] = v_logo.get().strip() for key, v in bild_vars.items():
wert = v.get()
self.cfg["firma"][key] = wert.strip() if isinstance(wert, str) else bool(wert)
self.cfg.setdefault("design", {})["akzent"] = v_farbe.get().strip() or config.DEFAULT_DESIGN["akzent"]
self.cfg["texte"]["dank1"] = t1.get("1.0", "end").strip() self.cfg["texte"]["dank1"] = t1.get("1.0", "end").strip()
self.cfg["texte"]["dank2"] = t2.get("1.0", "end").strip() self.cfg["texte"]["dank2"] = t2.get("1.0", "end").strip()
self.cfg["texte"]["hinweis"] = vh.get().strip() self.cfg["texte"]["hinweis"] = vh.get().strip()
@ -394,7 +447,7 @@ class EinstellungenMixin:
d.destroy() d.destroy()
btns = ttk.Frame(frm) btns = ttk.Frame(frm)
btns.grid(row=base + 4, column=0, columnspan=2, pady=(12, 0), sticky="e") btns.grid(row=base + 6, column=0, columnspan=2, pady=(12, 0), sticky="e")
ttk.Button(btns, text="Speichern", command=speichern).pack(side="right", padx=4) ttk.Button(btns, text="Speichern", command=speichern).pack(side="right", padx=4)
ttk.Button(btns, text="Abbrechen", command=d.destroy).pack(side="right") ttk.Button(btns, text="Abbrechen", command=d.destroy).pack(side="right")

View file

@ -15,7 +15,6 @@ import json
from reportlab.lib.pagesizes import A4 from reportlab.lib.pagesizes import A4
from reportlab.lib.units import mm from reportlab.lib.units import mm
from reportlab.lib.utils import ImageReader
from reportlab.lib import colors from reportlab.lib import colors
from reportlab.lib.enums import TA_RIGHT, TA_LEFT, TA_CENTER from reportlab.lib.enums import TA_RIGHT, TA_LEFT, TA_CENTER
from reportlab.lib.styles import ParagraphStyle from reportlab.lib.styles import ParagraphStyle
@ -24,6 +23,7 @@ from reportlab.platypus import (
Flowable, Flowable,
) )
import bilder
import config import config
from berechnung import eur, eur_kurz, runde from berechnung import eur, eur_kurz, runde
@ -46,11 +46,49 @@ class _AmtMarke(Flowable):
self._merker["seite"] = self.canv.getPageNumber() self._merker["seite"] = self.canv.getPageNumber()
# ---- Farben / Masse --------------------------------------------------------- # ---- 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") GREY_BG = colors.HexColor("#eef1ee")
LINE = colors.HexColor("#9aa39c") LINE = colors.HexColor("#9aa39c")
DARK = colors.HexColor("#222222") 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_L = 18 * mm
MARGIN_R = 18 * mm MARGIN_R = 18 * mm
MARGIN_TOP = 40 * mm # Platz fuer den Kopf MARGIN_TOP = 40 * mm # Platz fuer den Kopf
@ -100,36 +138,46 @@ def _styles():
# ---- Kopf + Fusszeile (auf jeder Seite gezeichnet) -------------------------- # ---- 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).""" """Kopf und Fuss einer Seite. amt=True -> Blatt fuer das Amt (siehe Fusszeile)."""
firma = cfg["firma"] firma = cfg["firma"]
canvas.saveState() canvas.saveState()
if vorschau:
_vorschau_marke(canvas)
# --- Kopf --- # --- Kopf ---
logo = (firma.get("logo") or "").strip() # Das Kopfbild liegt oben links, Firmenname und Untertitel stehen rechts
# relativen Pfad (z.B. "assets/logo.png") portabel aufloesen: # daneben - genau wie bisher. Austauschbar ist nur das BILD; die Texte
# erst neben Tool/exe, dann im gebuendelten Ressourcen-Ordner (.exe) # kommen aus den Einstellungen und bleiben gezeichnet.
if logo and not os.path.isabs(logo): img, iw, ih = bilder.laden(firma.get("logo"))
for _base in (config.BASE_DIR, config.RESOURCE_DIR): line_y = PAGE_H - 25.5 * mm # Hoehe der Trennlinie ohne Bild (wie bisher)
_cand = os.path.join(_base, logo)
if os.path.exists(_cand):
logo = _cand
break
logo_ok = False logo_ok = False
if logo and os.path.exists(logo): if img:
try: dw, dh = bilder.einpassen(iw, ih, 60 * mm, 20 * mm) # Kasten oben links
img = ImageReader(logo) ly = PAGE_H - 9 * mm - dh # Oberkante ~9mm vom Blattrand
iw, ih = img.getSize() canvas.drawImage(img, MARGIN_L, ly, width=dw, height=dh,
box_w, box_h = 60 * mm, 20 * mm # max. Logo-Box oben links preserveAspectRatio=True, mask="auto")
scale = min(box_w / iw, box_h / ih) logo_ok = True
dw, dh = iw * scale, ih * scale line_y = ly - 3 * mm
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 logo_ok: if logo_ok:
# Firmenname + Untertitel rechtsbuendig neben dem Logo # 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_m = MARGIN_L + CONTENT_W * 0.40
col_r = PAGE_W - MARGIN_R 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_l = [] if amt else [firma.get("bank", ""), firma.get("iban", ""), firma.get("bic", "")]
rows_m = [firma.get("inhaber", "")] if amt else [firma.get("inhaber", ""), rows_m = [firma.get("inhaber", "")] if amt else [firma.get("inhaber", ""),
firma.get("steuernummer", "")] firma.get("steuernummer", "")]
@ -667,7 +729,7 @@ def _kenndaten(rechnung, kopf):
return json.dumps(daten, ensure_ascii=False) 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 pfad : Zielpfad der PDF
cfg : geladene Konfiguration 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), Letzte Seite = Blatt fuers Amt: KEINE Beherbergungssteuer (auch nicht im Gesamt),
keine Zwischensumme, keine Bankverbindung, keine Steuernummer. keine Zwischensumme, keine Bankverbindung, keine Steuernummer.
""" """
_design_anwenden(cfg) # Firmenfarbe VOR den Stilen - sie lesen sie mit
st = _styles() st = _styles()
def bauen(ziel, amt_seite): 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 _seite_inhalt(story, st, cfg, rechnung, kopf, gesamtrechnung_betont=True) # Amt
def seite(c, d): 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) doc.build(story, onFirstPage=seite, onLaterPages=seite)

145
pruef_bilder.py Normal file
View file

@ -0,0 +1,145 @@
# -*- coding: utf-8 -*-
"""Pruefstand: Kopf- und Fussbild auf der Rechnung.
Drei Dinge muessen stimmen, sonst faellt es erst beim Drucken auf:
1. Ein gewaehltes Bild wird KOPIERT (Ordner "bilder"), nicht nur verlinkt -
sonst ist das Logo weg, sobald der Nutzer seinen Desktop aufraeumt.
2. Das Fussbild steht auf der Kundenrechnung, aber NICHT auf dem Amt-Blatt:
ein Briefpapier-Band traegt Bank und Steuernummer, und die haben auf dem
Blatt fuers Amt nichts zu suchen.
3. Der Haken "Angaben stehen im Bild" laesst die gezeichneten Texte weg -
und zwar nur dort, wo er gilt.
"""
import json
import os
import shutil
import sys
import tempfile
QUELLE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, QUELLE)
import config # noqa: E402
tmp = tempfile.mkdtemp(prefix="bilder_pruef_")
config.BASE_DIR = tmp
config.CONFIG_PATH = os.path.join(tmp, "config.json")
shutil.copy(os.path.join(QUELLE, "config.json"), config.CONFIG_PATH)
import bilder # noqa: E402
import pdf_renderer # noqa: E402
from berechnung import berechne # noqa: E402
fehler = []
def pruefe(bedingung, text):
print(("OK | " if bedingung else "FEHL | ") + text)
if not bedingung:
fehler.append(text)
def testbild(pfad, farbe):
"""Kleines PNG erzeugen - der Pruefstand braucht keine echten Kundenbilder."""
from PIL import Image
Image.new("RGB", (600, 120), farbe).save(pfad)
return pfad
# ---- 1. Uebernehmen: kopieren, nicht verlinken -----------------------------
fremd = os.path.join(tmp, "vom_desktop.png")
testbild(fremd, (10, 90, 60))
gespeichert = bilder.uebernehmen(fremd)
pruefe(gespeichert == "bilder/vom_desktop.png", f"Pfad wird kurz gemerkt: {gespeichert}")
pruefe(os.path.exists(os.path.join(tmp, "bilder", "vom_desktop.png")), "Bild liegt im Ordner bilder/")
# Das Original loeschen - die Rechnung muss trotzdem ein Bild haben.
os.remove(fremd)
pruefe(bool(bilder.aufloesen(gespeichert)), "Bild bleibt da, wenn das Original geloescht wird")
# Dasselbe Bild zweimal waehlen -> kein zweites Exemplar.
shutil.copy(os.path.join(tmp, "bilder", "vom_desktop.png"), fremd)
pruefe(bilder.uebernehmen(fremd) == "bilder/vom_desktop.png", "gleiches Bild zweimal -> kein Duplikat")
# Anderer Inhalt, gleicher Name -> das alte Bild wird NICHT ueberschrieben.
testbild(fremd, (200, 30, 30))
zweit = bilder.uebernehmen(fremd)
pruefe(zweit == "bilder/vom_desktop-2.png", f"gleicher Name, anderes Bild -> {zweit}")
# ---- 2. Rendern: Kunde vs. Amt --------------------------------------------
cfg = config.load_config()
cfg["firma"]["logo"] = bilder.uebernehmen(testbild(os.path.join(tmp, "kopf.png"), (20, 60, 120)))
cfg["firma"]["fussbild"] = bilder.uebernehmen(testbild(os.path.join(tmp, "fuss.png"), (120, 60, 20)))
cfg["firma"]["bank"] = "Bank Testbank"
mengen = {1: {"anzahl": 3, "personen": 0}}
r = berechne(cfg["katalog"], mengen, cfg)
kopf = {"art": "rechnung", "rechnungsnummer": "2026-999", "datum": "05.09.2026",
"kunde_zeilen": ["Herr", "Max Muster", "Musterweg 1", "04416 Markkleeberg"],
"anreise": "01.09.2026", "abreise": "04.09.2026", "naechte": 3}
pfad = os.path.join(tmp, "probe.pdf")
pdf_renderer.render_rechnung(pfad, cfg, r, kopf)
pruefe(os.path.exists(pfad), "PDF mit beiden Bildern entsteht")
def seiten_bilder_und_text(pdf):
"""Je Seite: (Anzahl eingebetteter Bilder, Text)."""
from pypdf import PdfReader
raus = []
for seite in PdfReader(pdf).pages:
xo = seite.get("/Resources", {}).get("/XObject", {})
try:
xo = xo.get_object()
except AttributeError:
pass
anzahl = sum(1 for k in xo if xo[k].get_object().get("/Subtype") == "/Image")
raus.append((anzahl, seite.extract_text() or ""))
return raus
seiten = seiten_bilder_und_text(pfad)
pruefe(len(seiten) >= 2, f"Kundenblatt + Amt-Blatt: {len(seiten)} Seiten")
kunde_bilder, kunde_text = seiten[0]
amt_bilder, amt_text = seiten[-1]
pruefe(kunde_bilder == 2, f"Kundenblatt traegt Kopf- UND Fussbild ({kunde_bilder})")
pruefe(amt_bilder == 1, f"Amt-Blatt traegt NUR das Kopfbild ({amt_bilder})")
pruefe("Testbank" in kunde_text, "Bankverbindung steht auf der Kundenrechnung")
pruefe("Testbank" not in amt_text, "Bankverbindung steht NICHT auf dem Amt-Blatt")
# ---- 3. Die Firmenfarbe faerbt das Blatt ----------------------------------
# Jede Firma faerbt ihre Rechnung selbst. Geprueft wird, dass die gewaehlte
# Farbe wirklich im PDF landet - und dass eine kaputte Eingabe das Schreiben
# NICHT verhindert, sondern still auf die Vorgabe zurueckfaellt.
cfg["design"] = {"akzent": "#8a1f1f"}
pfad2 = os.path.join(tmp, "probe_rot.pdf")
pdf_renderer.render_rechnung(pfad2, cfg, r, kopf)
pruefe(pdf_renderer.ACCENT.hexval().endswith("8a1f1f"),
f"gewaehlte Farbe steht im Blatt: {pdf_renderer.ACCENT.hexval()}")
pruefe(pdf_renderer.GREY_BG.red > 0.9,
"helle Flaeche wird aus der Farbe gemischt (bleibt hell)")
cfg["design"] = {"akzent": "gruen bitte"}
pfad3 = os.path.join(tmp, "probe_kaputt.pdf")
pdf_renderer.render_rechnung(pfad3, cfg, r, kopf)
pruefe(os.path.exists(pfad3), "kaputte Farbe verhindert die Rechnung NICHT")
pruefe(pdf_renderer.ACCENT.hexval().endswith("1f5c3d"), "kaputte Farbe faellt auf die Vorgabe zurueck")
cfg["design"] = {"akzent": pdf_renderer.AKZENT_VORGABE}
# Ohne Bilder muss das Blatt aussehen wie immer.
cfg["firma"]["logo"] = ""
cfg["firma"]["fussbild"] = ""
pfad4 = os.path.join(tmp, "probe_ohne_bild.pdf")
pdf_renderer.render_rechnung(pfad4, cfg, r, kopf)
seiten3 = seiten_bilder_und_text(pfad4)
pruefe(seiten3[0][0] == 0, "ohne Bilder: keine Bilder im PDF")
pruefe(cfg["firma"]["name"] in seiten3[0][1], "ohne Bild steht der Firmenname gezeichnet oben")
print()
print(f"Ordner: {tmp}")
if fehler:
print(f"FEHLGESCHLAGEN: {len(fehler)}")
for f in fehler:
print(" -", f)
sys.exit(1)
print("ALLES GRUEN")

117
pruef_vorschau.py Normal file
View file

@ -0,0 +1,117 @@
# -*- coding: utf-8 -*-
"""Pruefstand: der Vorschau-Knopf.
Die Vorschau soll zeigen, wie das Blatt aussehen WIRD - und dabei nichts
anfassen. Drei Zusicherungen, die alle drei still brechen koennten:
1. Sie verbraucht KEINE Rechnungsnummer und traegt nichts ins Nummernbuch.
Sonst fehlt in der Buchhaltung eine Nummer, fuer die es kein Blatt gibt.
2. Sie legt nichts im Rechnungsordner ab - dort liegt die Buchhaltung.
3. Auf jeder Seite steht "VORSCHAU - keine Rechnung". Ein Blatt ohne diesen
Hinweis koennte ausgedruckt beim Gast landen und als Rechnung gelten.
"""
import json
import os
import shutil
import sys
import tempfile
import threading as _th
QUELLE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, QUELLE)
tmp = tempfile.mkdtemp(prefix="vorschau_pruef_")
ordner = os.path.join(tmp, "rechnungen")
os.makedirs(ordner)
import config # noqa: E402
shutil.copy(os.path.join(QUELLE, "config.json"), os.path.join(tmp, "config.json"))
config.BASE_DIR = tmp
config.CONFIG_PATH = os.path.join(tmp, "config.json")
roh = json.load(open(config.CONFIG_PATH, encoding="utf-8"))
roh["rechnung_jahr"], roh["rechnung_zaehler"] = 2026, 0
roh["output_dir"] = ordner
json.dump(roh, open(config.CONFIG_PATH, "w", encoding="utf-8"), ensure_ascii=False)
os.environ["CAMPINGHOF_TEST"] = "1"
import app as A # noqa: E402
from tkinter import messagebox # noqa: E402
def _reissleine():
print("ABBRUCH | Pruefstand haengt (Zeitgrenze) - vermutlich ein Dialog")
os._exit(2)
_wecker = _th.Timer(120, _reissleine)
_wecker.daemon = True
_wecker.start()
fehler = []
def pruefe(bedingung, text):
zeile = (" OK | " if bedingung else "FEHLER | ") + text
k = sys.stdout.encoding or "utf-8"
print(zeile.encode(k, "replace").decode(k))
if not bedingung:
fehler.append(text)
messagebox.showinfo = messagebox.showwarning = messagebox.showerror = lambda *a, **k: None
messagebox.askyesno = lambda *a, **k: True
# Die Vorschau oeffnet den PDF-Betrachter - im Pruefstand wird nur gemerkt, WAS
# sie oeffnen wollte.
geoeffnet = []
A.os.startfile = lambda p, *a, **k: geoeffnet.append(p)
f = A.RechnungsApp()
f.update()
f.cfg["output_dir"] = ordner
erste = sorted(f.anzahl_vars)[0]
f.k_vorname.set("Anna")
f.k_name.set("Beispiel")
f.k_strasse.set("Musterweg 1")
f.k_plzort.set("12345 Musterstadt")
f.anzahl_vars[erste].set("3")
f.update()
nummer_vorher = f.r_nummer.get()
zaehler_vorher = f.cfg.get("rechnung_zaehler")
f.vorschau_zeigen()
pruefe(len(geoeffnet) == 1, f"Vorschau oeffnet genau ein Blatt ({len(geoeffnet)})")
pfad = geoeffnet[0] if geoeffnet else ""
pruefe(os.path.exists(pfad), "Vorschau-PDF wurde geschrieben")
pruefe(os.path.abspath(ordner) not in os.path.abspath(pfad),
"Vorschau liegt NICHT im Rechnungsordner")
pruefe(not os.listdir(ordner), f"Rechnungsordner bleibt leer ({os.listdir(ordner)})")
pruefe(f.r_nummer.get() == nummer_vorher,
f"Rechnungsnummer unveraendert ({nummer_vorher} -> {f.r_nummer.get()})")
pruefe(f.cfg.get("rechnung_zaehler") == zaehler_vorher, "Zaehler unveraendert")
from pypdf import PdfReader # noqa: E402
seiten = [s.extract_text() or "" for s in PdfReader(pfad).pages]
pruefe(all("VORSCHAU" in t for t in seiten),
f"'VORSCHAU' steht auf jeder Seite ({len(seiten)} Seiten)")
pruefe(all("keine Rechnung" in t for t in seiten), "'keine Rechnung' steht auf jeder Seite")
# Und die echte Rechnung danach traegt die Marke NICHT.
f.pdf_erstellen()
echte = [os.path.join(ordner, n) for n in os.listdir(ordner) if n.lower().endswith(".pdf")]
pruefe(len(echte) == 1, f"danach entsteht genau eine echte Rechnung ({len(echte)})")
if echte:
text = "".join((s.extract_text() or "") for s in PdfReader(echte[0]).pages)
pruefe("VORSCHAU" not in text, "die echte Rechnung traegt KEINE Vorschau-Marke")
f.destroy()
print()
if fehler:
print(f"FEHLGESCHLAGEN: {len(fehler)}")
for x in fehler:
print(" -", x)
sys.exit(1)
print("ALLES GRUEN")