From 142dddc647f123d0ad15b173377ac2155eb090c6 Mon Sep 17 00:00:00 2001 From: TheMockTv Date: Sat, 5 Sep 2026 17:35:37 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax --- app.py | 100 +++++++++++++++++++++++--------- bilder.py | 117 +++++++++++++++++++++++++++++++++++++ config.py | 13 +++++ einstellungen.py | 101 ++++++++++++++++++++++++-------- pdf_renderer.py | 121 +++++++++++++++++++++++++++++--------- pruef_bilder.py | 145 ++++++++++++++++++++++++++++++++++++++++++++++ pruef_vorschau.py | 117 +++++++++++++++++++++++++++++++++++++ 7 files changed, 636 insertions(+), 78 deletions(-) create mode 100644 bilder.py create mode 100644 pruef_bilder.py create mode 100644 pruef_vorschau.py diff --git a/app.py b/app.py index b42c7db..534c3e0 100644 --- a/app.py +++ b/app.py @@ -20,6 +20,7 @@ Aufgeteilt in: """ import os +import tempfile import sys import datetime import queue @@ -106,6 +107,7 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk): self._open_pop = None self._menus = { "Datei": [ + ("Vorschau ansehen", self.vorschau_zeigen), ("PDF erstellen", self.pdf_erstellen), ("Formular zurücksetzen", self.reset_formular), ("Ausgabeordner öffnen", self._ordner_oeffnen), @@ -524,6 +526,7 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk): links = ttk.Frame(f) 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="Storno", command=self.dlg_storno, 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, 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): montage = self.ist_montage() r = self.recalc() @@ -1145,14 +1218,6 @@ class RechnungsApp(ThemeMixin, KorrekturMixin, EinstellungenMixin, tk.Tk): # unangetastet, die naechste Rechnung behaelt ihre Nummer. 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. # Letzte Gelegenheit umzukehren: sagt er Nein, wird das Storno-PDF wieder # 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']}.") return - kopf = { - "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], - } + kopf = self._kopf_basis(nummer, r) if montage: # Das Blatt sieht anders aus (eine Zeile statt Tabelle), die Buchung # dahinter ist eine ganz normale: Zwischensumme + Beherbergungssteuer. diff --git a/bilder.py b/bilder.py new file mode 100644 index 0000000..535eed5 --- /dev/null +++ b/bilder.py @@ -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 diff --git a/config.py b/config.py index 90f6d5e..e3058b1 100644 --- a/config.py +++ b/config.py @@ -124,6 +124,11 @@ DEFAULT_FIRMA = { "email": "E-Mail kontakt@example.de", "telefon": "Telefon +49 (0) 000 0000000", "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). @@ -135,8 +140,16 @@ DEFAULT_TEXTE = { "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 = { "firma": DEFAULT_FIRMA, + "design": DEFAULT_DESIGN, "texte": DEFAULT_TEXTE, "katalog": DEFAULT_KATALOG, "montage": DEFAULT_MONTAGE, diff --git a/einstellungen.py b/einstellungen.py index ddc2b54..edc6b78 100644 --- a/einstellungen.py +++ b/einstellungen.py @@ -8,9 +8,10 @@ hart im Code: Steuersaetze, Preise, Firmendaten, Rechnungsnummernkreis. import datetime import os import tkinter as tk -from tkinter import ttk, messagebox, filedialog +from tkinter import ttk, messagebox, filedialog, colorchooser import berechnung +import bilder import config from helfer import parse_num @@ -333,7 +334,7 @@ class EinstellungenMixin: d.transient(self) d.grab_set() self._style_dialog(d) - d.geometry("640x640") + d.geometry("640x740") frm = ttk.Frame(d, padding=12) frm.pack(fill="both", expand=True) @@ -353,39 +354,91 @@ class EinstellungenMixin: frm.columnconfigure(1, weight=1) base = len(felder) - # Logo (Bilddatei) mit Durchsuchen / Entfernen - ttk.Label(frm, text="Logo (oben links)").grid(row=base, column=0, sticky="w", pady=2) - logo_row = ttk.Frame(frm) - logo_row.grid(row=base, column=1, sticky="ew", padx=(8, 0), pady=2) - v_logo = tk.StringVar(value=self.cfg["firma"].get("logo", "")) - ttk.Entry(logo_row, textvariable=v_logo).pack(side="left", fill="x", expand=True) + # Bilder auf der Rechnung: Kopf (oben links) und Fuss (Streifen unten). + # Beide funktionieren gleich, deshalb baut EINE Funktion beide Zeilen - + # sonst laufen sie beim naechsten Umbau auseinander. + bild_vars = {} - def logo_waehlen(): - p = filedialog.askopenfilename( - title="Logo auswählen", - filetypes=[("Bilder", "*.png *.jpg *.jpeg *.gif"), ("Alle Dateien", "*.*")]) - if p: - v_logo.set(p) + def bild_zeile(zeile, key, beschriftung): + ttk.Label(frm, text=beschriftung).grid(row=zeile, column=0, sticky="nw", pady=2) + kasten = ttk.Frame(frm) + kasten.grid(row=zeile, column=1, sticky="ew", padx=(8, 0), pady=2) + v = tk.StringVar(value=self.cfg["firma"].get(key, "")) + 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)) - ttk.Button(logo_row, text="Entfernen", command=lambda: v_logo.set("")).pack(side="left", padx=(4, 0)) + def waehlen(): + 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.insert("1.0", self.cfg["texte"].get("dank1", "")) - t1.grid(row=base + 1, 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) + t1.grid(row=base + 3, column=1, sticky="ew", padx=(8, 0), 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.insert("1.0", self.cfg["texte"].get("dank2", "")) - t2.grid(row=base + 2, column=1, sticky="ew", padx=(8, 0), pady=2) - ttk.Label(frm, text="Hinweiszeile").grid(row=base + 3, column=0, sticky="w", pady=2) + t2.grid(row=base + 4, column=1, sticky="ew", padx=(8, 0), 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", "")) - 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(): for key, v in vars_.items(): 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"]["dank2"] = t2.get("1.0", "end").strip() self.cfg["texte"]["hinweis"] = vh.get().strip() @@ -394,7 +447,7 @@ class EinstellungenMixin: d.destroy() 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="Abbrechen", command=d.destroy).pack(side="right") diff --git a/pdf_renderer.py b/pdf_renderer.py index 0a64664..28daf93 100644 --- a/pdf_renderer.py +++ b/pdf_renderer.py @@ -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) diff --git a/pruef_bilder.py b/pruef_bilder.py new file mode 100644 index 0000000..16bbe75 --- /dev/null +++ b/pruef_bilder.py @@ -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") diff --git a/pruef_vorschau.py b/pruef_vorschau.py new file mode 100644 index 0000000..374c1a6 --- /dev/null +++ b/pruef_vorschau.py @@ -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")