#!/usr/bin/env python3 """ KIM Angebot Generator v2 Erzeugt V&S/KIM-Angebote basierend auf der Original-Vorlage. Ansatz: Die Original-.docx wird kopiert und nur der variable Inhalt wird ersetzt. So bleiben Briefkopf-Positionierung, Header/Footer, Textboxen und alle Styles exakt erhalten. Usage: python generate_angebot.py input.json [output.docx] """ import json import sys import os import copy import shutil from datetime import datetime from pathlib import Path from docx import Document from docx.shared import Pt, Emu, RGBColor, Cm, Inches from docx.enum.text import WD_ALIGN_PARAGRAPH from docx.oxml.ns import qn from docx.oxml import OxmlElement # Pfade SCRIPT_DIR = Path(__file__).parent ASSETS_DIR = SCRIPT_DIR.parent / "assets" TEMPLATE_PATH = ASSETS_DIR / "template.docx" # Original V&S Vorlage SIGNATURE_PATH = ASSETS_DIR / "unterschrift_benno.jpg" # Konstanten BLUE_HEADING = RGBColor(0x44, 0x72, 0xC4) BLACK = RGBColor(0x00, 0x00, 0x00) FONT_NAME = "Arial" BODY_SIZE = Pt(10) HEADING_SIZE = Pt(12) SMALL_SIZE = Pt(8) # Feste Kontaktdaten (Kirsten) KONTAKT = { "name": "Kirsten Meyer", "mobil": "+49 1511 4481468", "email": "meyer@v-und-s.de", } # Feste Rechtstexte SCHLUSSBESTIMMUNGEN = [ "Der Klient erhält alle Rechte an den im Rahmen dieses Auftrages erstellten " "Analysen und Lösungen. Dazu zählen nicht die von V&S im Zuge der " "Leistungserbringung eingesetzten eigenen Erfahrungen, Erfindungen, " "EDV-Programme, Urheberrechte und Know-how.", "V&S verpflichtet sich über unternehmensbezogene Daten und Vorgänge, die ihr " "während der Bearbeitung des Projektes zur Kenntnis gelangt sind, auch über die " "Projektlaufzeit hinaus Stillschweigen zu wahren und Dritten auch nicht " "auszugsweise zugänglich zu machen.", "V&S schließt die vertragliche und außervertragliche Haftung außer im Falle von " "grober Fahrlässigkeit und Vorsatz aus und leistet keinen Schadensersatz.", "V&S gewährleistet eine termingerechte und professionelle Arbeit, nach aktuellem " "Stand der Technik. Gegenstand des Auftrages ist dabei stets die vereinbarte " "Leistung, nicht ein bestimmter wirtschaftlicher Erfolg.", ] def set_run_format(run, font_name=FONT_NAME, font_size=BODY_SIZE, color=BLACK, bold=False, italic=False): """Formatiert einen Run.""" run.font.name = font_name run.font.size = font_size run.font.color.rgb = color run.font.bold = bold run.font.italic = italic def make_paragraph(text, style_name="_Fließtext__standard_V+S", font_size=BODY_SIZE, color=BLACK, bold=False, italic=False, space_before=None, space_after=None, line_spacing=None): """Erzeugt ein neues Paragraph-Element (ohne es einzufügen).""" p = OxmlElement('w:p') # Style + Spacing pPr = OxmlElement('w:pPr') pStyle = OxmlElement('w:pStyle') pStyle.set(qn('w:val'), style_name) pPr.append(pStyle) # Paragraph Spacing if space_before is not None or space_after is not None or line_spacing is not None: spacing = OxmlElement('w:spacing') if space_before is not None: spacing.set(qn('w:before'), str(space_before)) if space_after is not None: spacing.set(qn('w:after'), str(space_after)) if line_spacing is not None: spacing.set(qn('w:line'), str(line_spacing)) spacing.set(qn('w:lineRule'), 'auto') pPr.append(spacing) p.append(pPr) # Run mit Text if text: r = OxmlElement('w:r') rPr = OxmlElement('w:rPr') # Font rFonts = OxmlElement('w:rFonts') rFonts.set(qn('w:ascii'), FONT_NAME) rFonts.set(qn('w:hAnsi'), FONT_NAME) rPr.append(rFonts) # Size sz = OxmlElement('w:sz') sz.set(qn('w:val'), str(int(font_size.pt * 2))) rPr.append(sz) szCs = OxmlElement('w:szCs') szCs.set(qn('w:val'), str(int(font_size.pt * 2))) rPr.append(szCs) # Color c = OxmlElement('w:color') c.set(qn('w:val'), f'{color}') rPr.append(c) # Bold if bold: b = OxmlElement('w:b') rPr.append(b) # Italic if italic: i = OxmlElement('w:i') rPr.append(i) r.append(rPr) # Text t = OxmlElement('w:t') t.set(qn('xml:space'), 'preserve') t.text = text r.append(t) p.append(r) return p # Spacing-Werte (in Twips: 1pt = 20 twips) # 120 twips = 6pt Abstand, 80 = 4pt, 200 = 10pt, 280 = 14pt SP_BODY_BEFORE = 80 # 4pt vor Body-Text SP_BODY_AFTER = 80 # 4pt nach Body-Text SP_HEADING_BEFORE = 280 # 14pt vor Überschrift SP_HEADING_AFTER = 120 # 6pt nach Überschrift SP_SUB_BEFORE = 200 # 10pt vor Sub-Überschrift SP_SUB_AFTER = 40 # 2pt nach Sub-Überschrift LINE_SPACING = 276 # 1.15x Zeilenabstand (240 = single, 276 ≈ 1.15) def make_heading(text): """Blaue Sektionsüberschrift mit Abstand.""" return make_paragraph(text, style_name="Heading 2", font_size=HEADING_SIZE, color=BLUE_HEADING, space_before=SP_HEADING_BEFORE, space_after=SP_HEADING_AFTER) def make_body(text, bold=False, italic=False): """Standard-Fließtext — Zeilenabstand kommt vom Style.""" return make_paragraph(text, bold=bold, italic=italic, space_before=SP_BODY_BEFORE, space_after=SP_BODY_AFTER) def make_empty(): """Leerer Absatz (halbe Höhe).""" return make_paragraph("", space_before=0, space_after=0) def make_subheading(text): """Heading 4 Subheading mit Abstand davor.""" return make_paragraph(text, style_name="Heading 4", font_size=BODY_SIZE, color=BLACK, bold=True, space_before=SP_SUB_BEFORE, space_after=SP_SUB_AFTER) def update_textbox_content(p_element, old_text_fragment, new_lines): """ Findet eine Textbox im Paragraphen die old_text_fragment enthält und ersetzt den kompletten Inhalt mit new_lines. """ ns_mc = 'http://schemas.openxmlformats.org/markup-compatibility/2006' for txbxContent in p_element.iter(qn('w:txbxContent')): # Check if this textbox contains the old text all_text = "" for t in txbxContent.iter(qn('w:t')): all_text += (t.text or "") if old_text_fragment.lower() in all_text.lower(): # Clear existing paragraphs in the textbox for old_p in list(txbxContent.findall(qn('w:p'))): txbxContent.remove(old_p) # Add new paragraphs for line in new_lines: p = OxmlElement('w:p') pPr = OxmlElement('w:pPr') # Right align for contact info if old_text_fragment.lower() in ["löffler", "meyer", "kirsten"]: jc = OxmlElement('w:jc') jc.set(qn('w:val'), 'right') pPr.append(jc) p.append(pPr) r = OxmlElement('w:r') rPr = OxmlElement('w:rPr') rFonts = OxmlElement('w:rFonts') rFonts.set(qn('w:ascii'), FONT_NAME) rFonts.set(qn('w:hAnsi'), FONT_NAME) rPr.append(rFonts) sz = OxmlElement('w:sz') sz.set(qn('w:val'), '20') # 10pt rPr.append(sz) r.append(rPr) t = OxmlElement('w:t') t.set(qn('xml:space'), 'preserve') t.text = line r.append(t) p.append(r) txbxContent.append(p) return True return False def update_paragraph_text(para, new_text): """Ersetzt den Text eines Paragraphen, behält die Formatierung des ersten Runs.""" if para.runs: # Keep first run's formatting, set its text para.runs[0].text = new_text # Remove remaining runs (but not non-run elements like drawings) for run in para.runs[1:]: # Only remove if it's a text-only run (no images) has_drawing = run._element.findall('.//' + qn('w:drawing')) has_pict = run._element.findall('.//' + qn('w:pict')) if not has_drawing and not has_pict: run._element.getparent().remove(run._element) else: run = para.add_run(new_text) set_run_format(run) def generate_angebot(data: dict, output_path: str): """Generiert ein KIM-Angebot.""" doc = Document(str(TEMPLATE_PATH)) body = doc.element.body kunde = data["kunde"] # === 1. TEXTBOXEN AKTUALISIEREN (Adresse + Kontakt) === p0 = doc.paragraphs[0] # Empfänger-Adresse aktualisieren addr_lines = [ kunde["firma"], kunde["ansprechpartner"], kunde["strasse"], kunde["plz_ort"], ] # Try multiple fragments to find the address textbox for fragment in ["Prälatur", "Prälat", "Albrecht", kunde.get("firma", "XXX")[:10]]: if update_textbox_content(p0._element, fragment, addr_lines): break # Kontaktdaten aktualisieren (Kirsten statt Benno) contact_lines = [ KONTAKT["name"], f"mobil: {KONTAKT['mobil']}", KONTAKT["email"], ] for fragment in ["Löffler", "loeffler", "löffler", "Benno"]: if update_textbox_content(p0._element, fragment, contact_lines): break # === 2. DATUM AKTUALISIEREN === # Paragraph 0 enthält den Datum-Text datum = data.get("datum") if not datum: now = datetime.now() monatsnamen = { 1: "Januar", 2: "Februar", 3: "März", 4: "April", 5: "Mai", 6: "Juni", 7: "Juli", 8: "August", 9: "September", 10: "Oktober", 11: "November", 12: "Dezember" } datum = f"{now.day}. {monatsnamen[now.month]} {now.year}" # Find the text run with "Hannover" in paragraph 0 and replace date # The date may span multiple runs, so clear all runs after "Hannover" too found_hannover = False for run in p0.runs: if "Hannover" in (run.text or ""): run.text = f" Hannover, {datum}" found_hannover = True continue if found_hannover and run.text and not run._element.findall('.//' + qn('w:drawing')): # Clear remaining text runs after Hannover (old date fragments) # but don't touch runs with images run.text = "" # === 3. AKTENZEICHEN AKTUALISIEREN === angebotsnummer = data.get("angebotsnummer") if not angebotsnummer: now = datetime.now() angebotsnummer = f"{now.strftime('%Y%m%d')}KIM01" p2 = doc.paragraphs[2] # Aktenzeichen update_paragraph_text(p2, angebotsnummer) # === 4. ANREDE AKTUALISIEREN === p5 = doc.paragraphs[5] # Anrede update_paragraph_text(p5, kunde["anrede"]) # === 5. EINLEITUNG AKTUALISIEREN === p7 = doc.paragraphs[7] # Einleitung einleitung = data.get("einleitung", "auf Basis unseres Vorgesprächs erhalten Sie hier unser Angebot:") update_paragraph_text(p7, einleitung) # === 6. VARIABLEN INHALT ERSETZEN === # Paragraphen 9 (Ausgangslage-Heading) bis VOR die Schlussbestimmungen entfernen # und durch neuen Inhalt ersetzen. # Finde den Startpunkt (Paragraph 9 = "Ausgangslage") # und den Endpunkt (wo Schlussbestimmungen beginnen) start_idx = 9 end_idx = None for i, p in enumerate(doc.paragraphs): if "Schlussbestimmungen" in p.text and i > 20: end_idx = i break if end_idx is None: # Fallback: Finde "Mit freundlichen Grüßen" for i, p in enumerate(doc.paragraphs): if "freundlichen Grüßen" in p.text: end_idx = i break # Alle Paragraphen zwischen start und end aus dem Body entfernen paras_to_remove = [] body_paras = [p for p in doc.paragraphs] for i in range(start_idx, end_idx): paras_to_remove.append(body_paras[i]._element) for p_elem in paras_to_remove: if p_elem.getparent() == body: body.remove(p_elem) # Neuen Inhalt einfügen an der Stelle wo start_idx war # Finde den Referenz-Paragraphen (der jetzt an Position start_idx-1 ist, also die Einleitung) remaining_paras = body.findall(qn('w:p')) # Der Einleitungs-Paragraph ist jetzt der letzte vor dem neuen Inhalt insert_after = remaining_paras[start_idx - 1] # Paragraph 8 (leer nach Einleitung) new_elements = [] # Sektionen for sektion in data.get("sektionen", []): new_elements.append(make_empty()) new_elements.append(make_heading(sektion["ueberschrift"])) new_elements.append(make_empty()) # Leerzeile nach Überschrift for block in sektion.get("inhalt", []): typ = block["typ"] if typ == "text": new_elements.append(make_body(block["text"])) elif typ == "text_bold": new_elements.append(make_body(block["text"], bold=True)) elif typ == "text_italic": new_elements.append(make_body(block["text"], italic=True)) elif typ == "text_small": new_elements.append(make_paragraph( block["text"], font_size=SMALL_SIZE, italic=True, space_before=SP_BODY_BEFORE, space_after=SP_BODY_AFTER)) elif typ == "subheading": new_elements.append(make_subheading(block["text"])) elif typ == "bullet": for item in block["items"]: new_elements.append(make_body(f"• {item}")) # Optionale Module if data.get("optionale_module"): new_elements.append(make_empty()) new_elements.append(make_heading("Optionale Module:")) new_elements.append(make_empty()) # Leerzeile nach Überschrift for modul in data["optionale_module"]: new_elements.append(make_body(f"• {modul}")) # Honorarvereinbarung if data.get("honorar"): honorar = data["honorar"] new_elements.append(make_empty()) new_elements.append(make_heading( honorar.get("ueberschrift", "Honorarvereinbarung"))) new_elements.append(make_empty()) for block in honorar.get("inhalt", []): typ = block["typ"] if typ == "text": new_elements.append(make_body(block["text"])) elif typ == "text_bold": new_elements.append(make_body(block["text"], bold=True)) elif typ == "text_italic" or typ == "text_small": new_elements.append(make_body(block["text"], italic=True)) new_elements.append(make_empty()) for line in honorar.get("zahlungsbedingungen", [ "Alle Preise verstehen sich zzgl. gesetzlicher Mehrwertsteuer.", "Reisekosten und Spesen sind in der Monatspauschale enthalten.", "Das Zahlungsziel für alle Rechnungen beträgt 30 Tage rein netto.", ]): new_elements.append(make_body(line)) # Erwartungshaltung & Investitionslogik (optional) if data.get("investitionslogik"): new_elements.append(make_empty()) il = data["investitionslogik"] new_elements.append(make_heading(il.get("ueberschrift", "Erwartungshaltung & Investitionslogik"))) new_elements.append(make_empty()) for block in il.get("inhalt", []): typ = block["typ"] if typ == "text": new_elements.append(make_body(block["text"])) elif typ == "text_bold": new_elements.append(make_body(block["text"], bold=True)) # Durchführung if data.get("berater"): new_elements.append(make_empty()) new_elements.append(make_heading("Durchführung")) new_elements.append(make_empty()) new_elements.append(make_body(data["berater"])) # V&S Qualitätsversprechen (Originaltext aus Referenz-Angeboten) kunde_formal = kunde.get("firma_formal", kunde["firma"]) kunde_firma = kunde["firma"] laufzeit = data.get("laufzeit", "12 Monate") new_elements.append(make_empty()) new_elements.append(make_heading("V&S Qualitätsversprechen")) new_elements.append(make_empty()) new_elements.append(make_body( f"Die Zufriedenheit der {kunde_firma} hat für uns höchste Priorität." )) new_elements.append(make_body( f"Sollte unser Klient mit den Ergebnissen der Zusammenarbeit nicht zufrieden sein, so " f"behält sie sich das Recht vor, das jeweilige Monats-Honorar rückwirkend anzupassen " f"– bis hin zu einem Betrag von 0 EUR. Das KIM-Programm ist auf eine Laufzeit von " f"zwölf Monaten ausgelegt. {kunde_firma} kann das Vorhaben jedoch jederzeit und ohne " f"Angabe von Gründen beenden. Dieses Qualitätsversprechen spiegelt unseren Anspruch " f"an Verbindlichkeit, Transparenz und partnerschaftliches Arbeiten wider." )) # Leere Zeilen vor Schlussbestimmungen new_elements.append(make_empty()) # Neuen Inhalt nach insert_after einfügen insert_point = insert_after for elem in new_elements: insert_point.addnext(elem) insert_point = elem # === 7. SCHLUSSBESTIMMUNGEN AKTUALISIEREN === # Schlussbestimmung-Heading formatieren (kommt aus Template als "Normal (Web)") for p in doc.paragraphs: if "Schlussbestimmung" in p.text and len(p.text.strip()) < 25: # Heading-Formatierung anwenden (blau, Heading 2, 12pt) for run in p.runs: run.font.name = FONT_NAME run.font.size = HEADING_SIZE run.font.color.rgb = BLUE_HEADING run.font.bold = True # Spacing anpassen pPr = p._element.find(qn('w:pPr')) if pPr is None: pPr = OxmlElement('w:pPr') p._element.insert(0, pPr) spacing = pPr.find(qn('w:spacing')) if spacing is None: spacing = OxmlElement('w:spacing') pPr.append(spacing) spacing.set(qn('w:before'), str(SP_HEADING_BEFORE)) spacing.set(qn('w:after'), str(SP_HEADING_AFTER)) break # "Der Klient" durch den Kundennamen ersetzen for p in doc.paragraphs: if p.text.startswith("Der Klient"): firma_ref = kunde_formal if firma_ref.startswith("die ") or firma_ref.startswith("der "): firma_ref = firma_ref[0].upper() + firma_ref[1:] new_text = p.text.replace("Der Klient", firma_ref, 1) update_paragraph_text(p, new_text) # === 8. ANGEBOTSDATUM IM ANNAHMEBLOCK AKTUALISIEREN === angebotsdatum = data.get("angebotsdatum_kurz") if not angebotsdatum: now = datetime.now() angebotsdatum = now.strftime("%d.%m.%Y") for p in doc.paragraphs: if "Angebot vom" in p.text and "angenommen" in p.text: update_paragraph_text(p, f"Angebot vom {angebotsdatum} angenommen:") # === 9. UNTERSCHRIFT-BILD PRÜFEN === # Die Unterschrift ist bereits im Template als eingebettetes Bild vorhanden. # Falls sie fehlt (z.B. wenn Template geändert wurde), hier einfügen: has_signature = False for p in doc.paragraphs: for r in p.runs: if r._element.findall('.//' + qn('a:blip')): has_signature = True break if not has_signature and SIGNATURE_PATH.exists(): # Finde "Mit freundlichen Grüßen" und füge Bild danach ein for i, p in enumerate(doc.paragraphs): if "freundlichen Grüßen" in p.text: # Nächster leerer Paragraph -> Bild einfügen if i + 1 < len(doc.paragraphs): next_p = doc.paragraphs[i + 1] run = next_p.add_run() run.add_picture(str(SIGNATURE_PATH), width=Inches(2.5)) break # Speichern doc.save(output_path) print(f"Angebot gespeichert: {output_path}") return output_path if __name__ == "__main__": if len(sys.argv) < 2: print("Usage: python generate_angebot.py input.json [output.docx]") sys.exit(1) input_path = sys.argv[1] with open(input_path, "r", encoding="utf-8") as f: data = json.load(f) output = sys.argv[2] if len(sys.argv) > 2 else "Angebot.docx" generate_angebot(data, output)