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 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 12:50:45 +02:00
parent 2ccfe005d9
commit 2842e1f1c9
5 changed files with 103 additions and 37 deletions
+30 -2
View File
@@ -20,6 +20,7 @@ inhaltlich Teilmengen der groben Lisp-Kategorien sind.
import configparser import configparser
import fnmatch import fnmatch
import functools
import os import os
@@ -32,6 +33,25 @@ def cfg_path_from_env(default_dir=None):
return os.path.join(base, "cfg", "export.cfg") 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): def load_patterns(cfg_path=None):
"""Liest [blockpattern] aus export.cfg. """Liest [blockpattern] aus export.cfg.
@@ -40,8 +60,7 @@ def load_patterns(cfg_path=None):
""" """
if cfg_path is None: if cfg_path is None:
cfg_path = cfg_path_from_env() cfg_path = cfg_path_from_env()
parser = configparser.ConfigParser() parser = load_export_cfg(cfg_path)
parser.read(cfg_path, encoding="utf-8")
patterns = {} patterns = {}
if parser.has_section("blockpattern"): if parser.has_section("blockpattern"):
for key, value in parser.items("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.""" """True, wenn bname (case-insensitiv) zu mindestens einem Muster passt."""
upper = bname.upper() upper = bname.upper()
return any(fnmatch.fnmatchcase(upper, p.upper()) for p in patterns) 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('"', '""')
+9 -9
View File
@@ -41,7 +41,7 @@ import json
import sys import sys
import os 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_planquadrat import load_planquadrat_config, resolve_origins, compute_planquadrat
from export_neighbors import ( from export_neighbors import (
load_neighbor_tolerance_mm, load_neighbor_tolerance_mm,
@@ -502,15 +502,15 @@ def format_csv_line(item):
merkmale_json = json.dumps(item["merkmale"], ensure_ascii=False) merkmale_json = json.dumps(item["merkmale"], ensure_ascii=False)
return ( return (
f'{item["nr"]}' f'{item["nr"]}'
f';"{item["teileart"]}"' f';"{csv_quote(item["teileart"])}"'
f';"{item["teileid"]}"' f';"{csv_quote(item["teileid"])}"'
f';"{item["bezeichnung"]}"' f';"{csv_quote(item["bezeichnung"])}"'
f';"{item["planquadrat"]}"' f';"{csv_quote(item["planquadrat"])}"'
f';1' f';1'
f';"{item["position"]}"' f';"{csv_quote(item["position"])}"'
f';"{item["boundingbox"]}"' f';"{csv_quote(item["boundingbox"])}"'
f';"{item["nachbarn"]}"' f';"{csv_quote(item["nachbarn"])}"'
f';"{item["fehler"]}"' f';"{csv_quote(item["fehler"])}"'
f';{merkmale_json}' f';{merkmale_json}'
) )
+3 -7
View File
@@ -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. Nur von export_csv.py genutzt (EXPORTCSV), nicht von export_sivas.py.
""" """
import configparser from export_blockpatterns import cfg_path_from_env, load_export_cfg
from export_blockpatterns import cfg_path_from_env
KREISEL_TEILEARTEN = {"ILS 2.0 Kreisel", "ILS 2.0 Eckrad"} 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"} 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.""" """Liest die Nachbarschafts-Toleranz (mm) aus export.cfg. Default: 0."""
if cfg_path is None: if cfg_path is None:
cfg_path = cfg_path_from_env() cfg_path = cfg_path_from_env()
parser = configparser.ConfigParser(inline_comment_prefixes=("#",)) parser = load_export_cfg(cfg_path)
parser.read(cfg_path, encoding="utf-8")
return parser.getfloat("Nachbarschaft", "toleranz_mm", fallback=0.0) 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: if cfg_path is None:
cfg_path = cfg_path_from_env() cfg_path = cfg_path_from_env()
parser = configparser.ConfigParser(inline_comment_prefixes=("#",)) parser = load_export_cfg(cfg_path)
parser.read(cfg_path, encoding="utf-8")
return parser.getfloat("Nachbarschaft", "omniflo_zellgroesse_mm", fallback=3000.0) return parser.getfloat("Nachbarschaft", "omniflo_zellgroesse_mm", fallback=3000.0)
+56 -14
View File
@@ -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) rundet die x/y-Koordinate eines Blocks (mm, wie im Roh-JSON von export.lsp)
je Achse auf die konfigurierte Schrittweite (in Metern) herunter. 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. Nur von export_csv.py genutzt (EXPORTCSV), nicht von export_sivas.py.
""" """
import configparser from export_blockpatterns import cfg_path_from_env, load_export_cfg
from export_blockpatterns import cfg_path_from_env
def load_planquadrat_config(cfg_path=None): def load_planquadrat_config(cfg_path=None):
"""Liest die aktive Planquadrat-Konfiguration aus export.cfg. """Liest die aktive Planquadrat-Konfiguration aus export.cfg.
Rueckgabe: dict mit x_step_mm, x_alpha, y_step_mm, y_alpha, format, Rueckgabe: dict mit x_step_mm, x_alpha, y_step_mm, y_alpha,
oder None, wenn [Planquadrate] bzw. die referenzierte Sub-Sektion fehlt. 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: if cfg_path is None:
cfg_path = cfg_path_from_env() cfg_path = cfg_path_from_env()
# inline_comment_prefixes noetig, da export.cfg Werte wie parser = load_export_cfg(cfg_path)
# "numerisch # zahlen, von links nach rechts, 2m pro Spalte" nutzt.
parser = configparser.ConfigParser(inline_comment_prefixes=("#",))
parser.read(cfg_path, encoding="utf-8")
if not parser.has_section("Planquadrate"): if not parser.has_section("Planquadrate"):
return None return None
@@ -36,15 +38,48 @@ def load_planquadrat_config(cfg_path=None):
return None return None
sec = parser[section_name] 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 { return {
"x_step_mm": float(sec.get("x_step", "1")) * 1000.0, "x_step_mm": float(sec.get("x_step", "1")) * 1000.0,
"x_alpha": sec.get("x_description", "numerisch").strip().lower().startswith("alpha"), "x_alpha": sec.get("x_description", "numerisch").strip().lower().startswith("alpha"),
"y_step_mm": float(sec.get("y_step", "1")) * 1000.0, "y_step_mm": float(sec.get("y_step", "1")) * 1000.0,
"y_alpha": sec.get("y_description", "numerisch").strip().lower().startswith("alpha"), "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(), "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): def _index_to_alpha(n):
"""Wandelt einen 1-basierten Index in eine Buchstaben-Bezeichnung um. """Wandelt einen 1-basierten Index in eine Buchstaben-Bezeichnung um.
@@ -57,15 +92,22 @@ def _index_to_alpha(n):
return result return result
def _index_for(coord_mm, step_mm): def _index_for(coord_mm, step_mm, origin_mm=0.0):
"""1-basierter Planquadrat-Index einer Koordinate fuer eine Schrittweite.""" """1-basierter Planquadrat-Index einer Koordinate fuer eine Schrittweite.
return int(coord_mm // step_mm) + 1
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): def compute_planquadrat(x_mm, y_mm, cfg):
"""Berechnet die Planquadrat-Bezeichnung fuer eine x/y-Koordinate (mm).""" """Berechnet die Planquadrat-Bezeichnung fuer eine x/y-Koordinate (mm)."""
x_idx = _index_for(x_mm, cfg["x_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"]) 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) 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) 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) return cfg["format"].format(x=x_label, y=y_label)
+5 -5
View File
@@ -33,7 +33,7 @@ import json
import sys import sys
import os import os
from export_blockpatterns import load_patterns, matches_any from export_blockpatterns import load_patterns, matches_any, csv_quote
BLOCKPATTERNS = load_patterns() BLOCKPATTERNS = load_patterns()
@@ -654,17 +654,17 @@ def process_blocks(blocks, lookup):
def format_csv_line(item): def format_csv_line(item):
"""Formatiert eine CSV-Zeile im neuen Sivas-Format.""" """Formatiert eine CSV-Zeile im neuen Sivas-Format."""
details_json = json.dumps(item["details"], ensure_ascii=False) 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) ids_str = ", ".join(str(i) for i in item.get("ids", []) if i)
return ( return (
f'{item["nr"]}' f'{item["nr"]}'
f';"{item["teileart"]}"' f';"{csv_quote(item["teileart"])}"'
f';{sivasnr}' f';{sivasnr}'
f';"{item["bezeichnung"]}"' f';"{csv_quote(item["bezeichnung"])}"'
f';{item["dispgruppe"]}' f';{item["dispgruppe"]}'
f';{item["anzahl"]}' f';{item["anzahl"]}'
f';{item["laenge_mm"]}' f';{item["laenge_mm"]}'
f';"{ids_str}"' f';"{csv_quote(ids_str)}"'
f';{details_json}' f';{details_json}'
) )