From 2842e1f1c9389f6d0bfe1c0de697471788e56cac Mon Sep 17 00:00:00 2001 From: Michael Stangl Date: Thu, 23 Jul 2026 12:50:45 +0200 Subject: [PATCH] CSV-Escaping-Bug behoben, export.cfg-Mehrfachparsing beseitigt format_csv_line() in export_csv.py/export_sivas.py hat Feldwerte per f-String direkt in doppelte Anfuehrungszeichen gesetzt, ohne ein eingebettetes '"' zu escapen (z.B. eine Bezeichnung mit Anfuehrungszeichen haette die Spaltengrenze der Zeile kaputt gemacht). Neuer gemeinsamer Helper csv_quote() (export_blockpatterns.py) verdoppelt eingebettete Anfuehrungszeichen nach RFC 4180. Ausserdem: export.cfg wurde bisher von vier unabhaengigen load_*-Funktionen (load_patterns, load_neighbor_tolerance_mm, load_omniflo_cell_size_mm, load_planquadrat_config) separat von der Platte gelesen und geparst. Neuer gemeinsamer, gecachter load_export_cfg() in export_blockpatterns.py sorgt dafuer, dass export.cfg pro Exportlauf nur noch einmal gelesen wird. Dabei auch die in export_csv.py bereits genutzte, aber in export_planquadrat.py fehlende resolve_origins()-Funktion (automatische Planquadrat-Ursprungs- Ermittlung) ergaenzt - ohne sie war der Import von export_csv.py gebrochen. Co-Authored-By: Claude Sonnet 5 --- lib/export_blockpatterns.py | 32 +++++++++++++++-- lib/export_csv.py | 18 +++++----- lib/export_neighbors.py | 10 ++---- lib/export_planquadrat.py | 70 +++++++++++++++++++++++++++++-------- lib/export_sivas.py | 10 +++--- 5 files changed, 103 insertions(+), 37 deletions(-) diff --git a/lib/export_blockpatterns.py b/lib/export_blockpatterns.py index fc99762..92a2999 100644 --- a/lib/export_blockpatterns.py +++ b/lib/export_blockpatterns.py @@ -20,6 +20,7 @@ inhaltlich Teilmengen der groben Lisp-Kategorien sind. import configparser import fnmatch +import functools import os @@ -32,6 +33,25 @@ def cfg_path_from_env(default_dir=None): return os.path.join(base, "cfg", "export.cfg") +@functools.lru_cache(maxsize=None) +def load_export_cfg(cfg_path): + """Liest und parst export.cfg genau einmal je Pfad (Prozess-Cache). + + Gemeinsam genutzt von export_blockpatterns/export_neighbors/ + export_planquadrat, damit export.cfg pro Exportlauf nur einmal von der + Platte gelesen/geparst wird statt bei jedem der bisher unabhaengigen + load_*-Aufrufe erneut. inline_comment_prefixes=("#",) ist noetig, da + export.cfg Werte wie "numerisch # zahlen, von links nach rechts" mit + Inline-Kommentaren nutzt (siehe [Planquadrate_*]-Sektionen). + cfg_path muss bereits aufgeloest sein (siehe cfg_path_from_env) - der + Cache ist sonst nicht treffsicher (ein "None"-Schluessel wuerde nie den + tatsaechlichen Pfad abbilden). + """ + parser = configparser.ConfigParser(inline_comment_prefixes=("#",)) + parser.read(cfg_path, encoding="utf-8") + return parser + + def load_patterns(cfg_path=None): """Liest [blockpattern] aus export.cfg. @@ -40,8 +60,7 @@ def load_patterns(cfg_path=None): """ if cfg_path is None: cfg_path = cfg_path_from_env() - parser = configparser.ConfigParser() - parser.read(cfg_path, encoding="utf-8") + parser = load_export_cfg(cfg_path) patterns = {} if parser.has_section("blockpattern"): for key, value in parser.items("blockpattern"): @@ -53,3 +72,12 @@ def matches_any(bname, patterns): """True, wenn bname (case-insensitiv) zu mindestens einem Muster passt.""" upper = bname.upper() return any(fnmatch.fnmatchcase(upper, p.upper()) for p in patterns) + + +def csv_quote(value): + """Escapt einen Wert fuer ein doppelt-angefuehrtes, semikolon-getrenntes CSV-Feld. + + Verdoppelt eingebettete Anfuehrungszeichen (RFC 4180), damit z.B. eine + Bezeichnung mit einem '"' das Feldende nicht vortaeuscht. + """ + return str(value).replace('"', '""') diff --git a/lib/export_csv.py b/lib/export_csv.py index 3b91c67..6502015 100644 --- a/lib/export_csv.py +++ b/lib/export_csv.py @@ -41,7 +41,7 @@ import json import sys import os -from export_blockpatterns import load_patterns, matches_any +from export_blockpatterns import load_patterns, matches_any, csv_quote from export_planquadrat import load_planquadrat_config, resolve_origins, compute_planquadrat from export_neighbors import ( load_neighbor_tolerance_mm, @@ -502,15 +502,15 @@ def format_csv_line(item): merkmale_json = json.dumps(item["merkmale"], ensure_ascii=False) return ( f'{item["nr"]}' - f';"{item["teileart"]}"' - f';"{item["teileid"]}"' - f';"{item["bezeichnung"]}"' - f';"{item["planquadrat"]}"' + f';"{csv_quote(item["teileart"])}"' + f';"{csv_quote(item["teileid"])}"' + f';"{csv_quote(item["bezeichnung"])}"' + f';"{csv_quote(item["planquadrat"])}"' f';1' - f';"{item["position"]}"' - f';"{item["boundingbox"]}"' - f';"{item["nachbarn"]}"' - f';"{item["fehler"]}"' + f';"{csv_quote(item["position"])}"' + f';"{csv_quote(item["boundingbox"])}"' + f';"{csv_quote(item["nachbarn"])}"' + f';"{csv_quote(item["fehler"])}"' f';{merkmale_json}' ) diff --git a/lib/export_neighbors.py b/lib/export_neighbors.py index d66ab48..76a84d0 100644 --- a/lib/export_neighbors.py +++ b/lib/export_neighbors.py @@ -33,9 +33,7 @@ MIN_PARTNER) - Ergebnis ist die CSV-Spalte "Fehler" in export_csv.py. Nur von export_csv.py genutzt (EXPORTCSV), nicht von export_sivas.py. """ -import configparser - -from export_blockpatterns import cfg_path_from_env +from export_blockpatterns import cfg_path_from_env, load_export_cfg KREISEL_TEILEARTEN = {"ILS 2.0 Kreisel", "ILS 2.0 Eckrad"} STRECKEN_TEILEARTEN = {"ILS 2.0 Gefaellestrecke", "ILS 2.0 Strecke", "ILS 2.0 Strecke - Modul"} @@ -57,8 +55,7 @@ def load_neighbor_tolerance_mm(cfg_path=None): """Liest die Nachbarschafts-Toleranz (mm) aus export.cfg. Default: 0.""" if cfg_path is None: cfg_path = cfg_path_from_env() - parser = configparser.ConfigParser(inline_comment_prefixes=("#",)) - parser.read(cfg_path, encoding="utf-8") + parser = load_export_cfg(cfg_path) return parser.getfloat("Nachbarschaft", "toleranz_mm", fallback=0.0) @@ -71,8 +68,7 @@ def load_omniflo_cell_size_mm(cfg_path=None): """ if cfg_path is None: cfg_path = cfg_path_from_env() - parser = configparser.ConfigParser(inline_comment_prefixes=("#",)) - parser.read(cfg_path, encoding="utf-8") + parser = load_export_cfg(cfg_path) return parser.getfloat("Nachbarschaft", "omniflo_zellgroesse_mm", fallback=3000.0) diff --git a/lib/export_planquadrat.py b/lib/export_planquadrat.py index ccf3040..1e73d15 100644 --- a/lib/export_planquadrat.py +++ b/lib/export_planquadrat.py @@ -8,26 +8,28 @@ Liest die aktive Planquadrat-Konfiguration aus cfg/export.cfg rundet die x/y-Koordinate eines Blocks (mm, wie im Roh-JSON von export.lsp) je Achse auf die konfigurierte Schrittweite (in Metern) herunter. +x_origin/y_origin ([Planquadrate], in m) legen die DXF-Koordinate der +Planquadrat-Ecke A/1 fest. Sind sie leer/nicht gesetzt, ermittelt +resolve_origins() sie automatisch je Exportlauf aus den zu exportierenden +Bloecken (am weitesten links/unten liegendes Element = Ursprung) - siehe dort. + Nur von export_csv.py genutzt (EXPORTCSV), nicht von export_sivas.py. """ -import configparser - -from export_blockpatterns import cfg_path_from_env +from export_blockpatterns import cfg_path_from_env, load_export_cfg def load_planquadrat_config(cfg_path=None): """Liest die aktive Planquadrat-Konfiguration aus export.cfg. - Rueckgabe: dict mit x_step_mm, x_alpha, y_step_mm, y_alpha, format, - oder None, wenn [Planquadrate] bzw. die referenzierte Sub-Sektion fehlt. + Rueckgabe: dict mit x_step_mm, x_alpha, y_step_mm, y_alpha, + x_origin_mm/y_origin_mm (None, falls in [Planquadrate] nicht gesetzt - + siehe resolve_origins()) und format, oder None, wenn [Planquadrate] + bzw. die referenzierte Sub-Sektion fehlt. """ if cfg_path is None: cfg_path = cfg_path_from_env() - # inline_comment_prefixes noetig, da export.cfg Werte wie - # "numerisch # zahlen, von links nach rechts, 2m pro Spalte" nutzt. - parser = configparser.ConfigParser(inline_comment_prefixes=("#",)) - parser.read(cfg_path, encoding="utf-8") + parser = load_export_cfg(cfg_path) if not parser.has_section("Planquadrate"): return None @@ -36,15 +38,48 @@ def load_planquadrat_config(cfg_path=None): return None sec = parser[section_name] + + def _origin_mm(key): + raw = parser.get("Planquadrate", key, fallback="").strip() + return float(raw) * 1000.0 if raw else None + return { "x_step_mm": float(sec.get("x_step", "1")) * 1000.0, "x_alpha": sec.get("x_description", "numerisch").strip().lower().startswith("alpha"), "y_step_mm": float(sec.get("y_step", "1")) * 1000.0, "y_alpha": sec.get("y_description", "numerisch").strip().lower().startswith("alpha"), + "x_origin_mm": _origin_mm("x_origin"), + "y_origin_mm": _origin_mm("y_origin"), "format": sec.get("format", "{x}/{y}").strip(), } +def resolve_origins(cfg, blocks): + """Loest x_origin_mm/y_origin_mm auf, falls in export.cfg nicht gesetzt. + + cfg = Rueckgabe von load_planquadrat_config() (kann None sein). + blocks = alle Bloecke des aktuellen Exportlaufs (Liste von dicts mit + "x"/"y", wie im Roh-JSON von export.lsp). + + Automatik: das am weitesten links liegende Element (min x) setzt + x_origin, das am weitesten unten liegende (min y) setzt y_origin - + beide landen damit exakt in Planquadrat-Index 1 auf ihrer Achse. + Explizit in export.cfg gesetzte Werte werden nicht ueberschrieben. + + Rueckgabe: neues dict (cfg bleibt unveraendert) oder None, wenn cfg None ist. + """ + if cfg is None: + return None + resolved = dict(cfg) + if resolved.get("x_origin_mm") is None: + xs = [float(b["x"]) for b in blocks if b.get("x") is not None] + resolved["x_origin_mm"] = min(xs) if xs else 0.0 + if resolved.get("y_origin_mm") is None: + ys = [float(b["y"]) for b in blocks if b.get("y") is not None] + resolved["y_origin_mm"] = min(ys) if ys else 0.0 + return resolved + + def _index_to_alpha(n): """Wandelt einen 1-basierten Index in eine Buchstaben-Bezeichnung um. @@ -57,15 +92,22 @@ def _index_to_alpha(n): return result -def _index_for(coord_mm, step_mm): - """1-basierter Planquadrat-Index einer Koordinate fuer eine Schrittweite.""" - return int(coord_mm // step_mm) + 1 +def _index_for(coord_mm, step_mm, origin_mm=0.0): + """1-basierter Planquadrat-Index einer Koordinate fuer eine Schrittweite. + + origin_mm ist die Koordinate der Planquadrat-Ecke A/1 (x_origin/y_origin + aus cfg/export.cfg) - Koordinaten werden vor der Index-Berechnung um + diesen Nullpunkt verschoben, damit reale Layouts mit negativen + DXF-Koordinaten (Zeichnungsursprung liegt nicht in der Gebaeudeecke) + trotzdem ab Index 1 gezaehlt werden. + """ + return int((coord_mm - origin_mm) // step_mm) + 1 def compute_planquadrat(x_mm, y_mm, cfg): """Berechnet die Planquadrat-Bezeichnung fuer eine x/y-Koordinate (mm).""" - x_idx = _index_for(x_mm, cfg["x_step_mm"]) - y_idx = _index_for(y_mm, cfg["y_step_mm"]) + x_idx = _index_for(x_mm, cfg["x_step_mm"], cfg.get("x_origin_mm", 0.0)) + y_idx = _index_for(y_mm, cfg["y_step_mm"], cfg.get("y_origin_mm", 0.0)) x_label = _index_to_alpha(x_idx) if cfg["x_alpha"] else str(x_idx) y_label = _index_to_alpha(y_idx) if cfg["y_alpha"] else str(y_idx) return cfg["format"].format(x=x_label, y=y_label) diff --git a/lib/export_sivas.py b/lib/export_sivas.py index acadcb1..9d40404 100644 --- a/lib/export_sivas.py +++ b/lib/export_sivas.py @@ -33,7 +33,7 @@ import json import sys import os -from export_blockpatterns import load_patterns, matches_any +from export_blockpatterns import load_patterns, matches_any, csv_quote BLOCKPATTERNS = load_patterns() @@ -654,17 +654,17 @@ def process_blocks(blocks, lookup): def format_csv_line(item): """Formatiert eine CSV-Zeile im neuen Sivas-Format.""" details_json = json.dumps(item["details"], ensure_ascii=False) - sivasnr = f'"{item["sivasnr"]}"' if item["sivasnr_quoted"] else item["sivasnr"] + sivasnr = f'"{csv_quote(item["sivasnr"])}"' if item["sivasnr_quoted"] else item["sivasnr"] ids_str = ", ".join(str(i) for i in item.get("ids", []) if i) return ( f'{item["nr"]}' - f';"{item["teileart"]}"' + f';"{csv_quote(item["teileart"])}"' f';{sivasnr}' - f';"{item["bezeichnung"]}"' + f';"{csv_quote(item["bezeichnung"])}"' f';{item["dispgruppe"]}' f';{item["anzahl"]}' f';{item["laenge_mm"]}' - f';"{ids_str}"' + f';"{csv_quote(ids_str)}"' f';{details_json}' )