From cc81d7092f7a0bba874e43dd497fc2628e6c9b70 Mon Sep 17 00:00:00 2001 From: lbaumberger Date: Wed, 28 Oct 2026 15:43:17 +0100 Subject: [PATCH 01/18] Fix stray stitches after trims and several UI state bugs Engine: - After a trim + jump inside a colour block, _connect kept splitting stitches from the old needle position, sewing stray stitches (up to 50+ mm) across the design. Continue from run[0] after a jump. - Prune stitch cache entries of objects that no longer exist. UI: - Undo/redo snapshots keep object ids (cache hits, no full regen) and restore the selection; spinbox drags coalesce into one undo step, no-op edits no longer snapshot; visibility toggle is undoable. - Deferred rebuild no longer recreates the inspector while typing. - Hoop/machine combos sync with loaded/imported designs; simulator state resets on new designs. - Auto-digitize no longer reuses a plan built for the default hoop. - Node editor freezes node indices during a drag, keeps other polygon parts, and is disabled for text/stitch objects. - Fit-to-hoop / scale keep text rotation and handle scale. - Atomic project save, BOM-tolerant load, error dialogs for save/PDF/ PNG and non-image files in the image dialogs. Adds regression tests for each fix. Co-Authored-By: Claude --- openstitch/core/design.py | 17 ++++-- openstitch/io/project.py | 8 ++- openstitch/ui/canvas.py | 51 ++++++++++-------- openstitch/ui/main_window.py | 100 ++++++++++++++++++++++++++++------- tests/test_openstitch.py | 78 +++++++++++++++++++++++++++ 5 files changed, 209 insertions(+), 45 deletions(-) diff --git a/openstitch/core/design.py b/openstitch/core/design.py index fb5f3d4..358b6ab 100644 --- a/openstitch/core/design.py +++ b/openstitch/core/design.py @@ -102,6 +102,10 @@ def clone(self): c.id = _new_id() return c + def snapshot(self): + """Kopie mit gleicher id (für Undo/Redo) – so bleibt der Stich-Cache gültig.""" + return copy.deepcopy(self) + @dataclass class FillObject(EmbObject): @@ -399,6 +403,10 @@ def build_plan(self, progress=None) -> StitchPlan: block: ColorBlock | None = None s = self.settings vis = [o for o in self.objects if o.visible] + # Cache-Einträge gelöschter/ersetzter Objekte verwerfen (sonst wächst er mit jedem Undo) + live = {o.id for o in self.objects} + for k in [k for k in self._cache if k[0] not in live]: + del self._cache[k] for i, obj in enumerate(vis): if progress: progress(i, len(vis)) @@ -430,14 +438,14 @@ def build_plan(self, progress=None) -> StitchPlan: def _connect(self, block: ColorBlock, cur: Pt | None, run: list[Pt]) -> Pt: s = self.settings st = block.stitches + joined = cur is not None and dist(cur, run[0]) <= s.min_trim if cur is None: st.append(Stitch(run[0][0], run[0][1], Cmd.JUMP)) if s.tie_in: for p in tie_stitches(run[0], run[1] if len(run) > 1 else None)[1:]: st.append(Stitch(p[0], p[1])) else: - d = dist(cur, run[0]) - if d <= s.min_trim: + if joined: pass # direkt weitersticken else: if s.tie_off and len(st) > 2: @@ -449,8 +457,9 @@ def _connect(self, block: ColorBlock, cur: Pt | None, run: list[Pt]) -> Pt: if s.tie_in: for p in tie_stitches(run[0], run[1] if len(run) > 1 else None)[1:]: st.append(Stitch(p[0], p[1])) - prev = cur if cur is not None else run[0] - for p in run[(0 if cur is not None and dist(cur, run[0]) <= s.min_trim else 1):]: + # Nach Trim + Sprung steht die Nadel auf run[0], nicht mehr auf `cur` + prev = cur if joined else run[0] + for p in run[(0 if joined else 1):]: # Stiche länger als Maschinengrenze teilen dd = dist(prev, p) if dd > s.max_stitch: diff --git a/openstitch/io/project.py b/openstitch/io/project.py index 0fd839a..ec316a0 100644 --- a/openstitch/io/project.py +++ b/openstitch/io/project.py @@ -3,6 +3,7 @@ import datetime as _dt import json +import os from PySide6.QtCore import QRectF, Qt from PySide6.QtGui import QColor, QFont, QImage, QPageLayout, QPageSize, QPainter, QPdfWriter, QPen @@ -29,12 +30,15 @@ def save_project(design: Design, path: str) -> None: "settings": design.settings.__dict__, "objects": [o.to_dict() for o in design.objects], } - with open(path, "w", encoding="utf-8") as f: + # Erst in eine Temp-Datei schreiben: ein Fehler mitten im Schreiben darf das alte Projekt nicht zerstören + tmp = f"{path}.tmp" + with open(tmp, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2) + os.replace(tmp, path) def load_project(path: str) -> Design: - with open(path, "r", encoding="utf-8") as f: + with open(path, "r", encoding="utf-8-sig") as f: data = json.load(f) h = data.get("hoop", {}) hoop = Hoop( diff --git a/openstitch/ui/canvas.py b/openstitch/ui/canvas.py index d1cc218..6df48ed 100644 --- a/openstitch/ui/canvas.py +++ b/openstitch/ui/canvas.py @@ -19,7 +19,8 @@ ) from PySide6.QtOpenGLWidgets import QOpenGLWidget from PySide6.QtWidgets import QWidget -from shapely.geometry import LineString, Point, Polygon +from shapely.geometry import LineString, MultiLineString, Point, Polygon +from shapely.ops import unary_union from ..core.design import Design, EmbObject, FillObject, RunObject, SatinObject, compose from ..core.stitchplan import Cmd, StitchPlan, Thread @@ -379,6 +380,10 @@ def mousePressEvent(self, event): if math.hypot(mm[0] - nx, mm[1] - ny) <= 8.0 / self.px_per_mm: self.about_to_modify.emit("Knoten verschieben") self._dragging_node_idx = idx + # Knotenliste für die ganze Ziehbewegung einfrieren, sonst verschieben sich + # die Indizes durch simplify()/buffer(0) bei jedem Mausschritt + self._node_drag_geom = self.selected.world_geom() + self._node_drag_coords = nodes return if self.tool not in ("select", "edit_nodes"): @@ -476,6 +481,8 @@ def mouseReleaseEvent(self, event): self.design_modified.emit("Stichwinkel drehen") if self._dragging_node_idx is not None: self._dragging_node_idx = None + self._node_drag_geom = None + self._node_drag_coords = None self.design_modified.emit("Knoten verschieben") if self._dragging_obj: self._dragging_obj = False @@ -483,6 +490,9 @@ def mouseReleaseEvent(self, event): self.design_modified.emit("Objekt transformieren") def _get_editable_nodes(self, obj: EmbObject) -> list[tuple[float, float]]: + # Nur Objekte mit eigener Vektor-Geometrie (Text/Stiche werden anders erzeugt) + if obj.kind not in ("fill", "satin", "run"): + return [] g = obj.world_geom() if g is None or g.is_empty: return [] @@ -502,31 +512,30 @@ def _get_editable_nodes(self, obj: EmbObject) -> list[tuple[float, float]]: def _move_editable_node(self, obj: EmbObject, idx: int, new_mm: tuple[float, float]): from ..core.design import IDENTITY from ..engine.geom import polygons_of, lines_of - g = obj.world_geom() + g = getattr(self, "_node_drag_geom", None) or obj.world_geom() + coords = list(getattr(self, "_node_drag_coords", None) or self._get_editable_nodes(obj)) + if not (0 <= idx < len(coords)): + return + coords[idx] = new_mm polys = polygons_of(g) if polys: - simp = polys[0].simplify(0.45, preserve_topology=True) - base_poly = simp if (isinstance(simp, Polygon) and not simp.is_empty) else polys[0] - coords = list(base_poly.exterior.coords) - if 0 <= idx < len(coords): - coords[idx] = new_mm - if idx == 0 or idx == len(coords) - 1: - coords[0] = coords[-1] = new_mm - try: - new_p = Polygon(coords, list(base_poly.interiors)).buffer(0) - if not new_p.is_empty: - obj.geom = new_p - obj.transform = IDENTITY - except Exception: - pass + if idx == 0 or idx == len(coords) - 1: + coords[0] = coords[-1] = new_mm + try: + new_p = Polygon(coords, list(polys[0].interiors)).buffer(0) + except Exception: + return + if new_p.is_empty: + return + # Weitere Teilflächen eines MultiPolygons unverändert übernehmen + obj.geom = unary_union([new_p] + polys[1:]) if len(polys) > 1 else new_p + obj.transform = IDENTITY return lns = lines_of(g) if lns: - coords = list(lns[0].coords) - if 0 <= idx < len(coords): - coords[idx] = new_mm - obj.geom = LineString(coords) - obj.transform = IDENTITY + rest = lns[1:] + obj.geom = MultiLineString([LineString(coords)] + rest) if rest else LineString(coords) + obj.transform = IDENTITY def keyPressEvent(self, event): if event.key() in (Qt.Key_Return, Qt.Key_Enter) and self._draw_pts: diff --git a/openstitch/ui/main_window.py b/openstitch/ui/main_window.py index fc618ad..d354607 100644 --- a/openstitch/ui/main_window.py +++ b/openstitch/ui/main_window.py @@ -313,7 +313,8 @@ def __init__(self, defer_starter: bool = False, show_tour_on_init: bool = True): self._rebuild_timer = QTimer(self) self._rebuild_timer.setSingleShot(True) self._rebuild_timer.setInterval(120) - self._rebuild_timer.timeout.connect(self.rebuild_plan) + self._rebuild_timer.timeout.connect(lambda: self.rebuild_plan(refresh_props=False)) + self._last_edit_key = None self.sim_timer = QTimer(self) self.sim_timer.setInterval(25) @@ -760,18 +761,46 @@ def action_show_tour(self): def set_design(self, d: Design, reset_undo: bool = False, prebuilt_plan=None): self.canvas.design = d self.canvas.selected = d.objects[0] if d.objects else None + self.canvas.sim_limit = None + if hasattr(self, "btn_play"): + self.btn_play.setChecked(False) if reset_undo: self._undo_stack.clear() self._redo_stack.clear() + self._last_edit_key = None + self._sync_hoop_machine_ui() self.rebuild_plan(prebuilt_plan=prebuilt_plan) + def _sync_hoop_machine_ui(self): + """Rahmen-/Maschinen-Auswahl an das (neu geladene) Design anpassen, ohne Signale auszulösen.""" + d = self.canvas.design + if hasattr(self, "cmb_hoop"): + idx = next((i for i in range(self.cmb_hoop.count()) + if self.cmb_hoop.itemData(i).name == d.hoop.name), -1) + if idx >= 0: + # gleiche Instanz verwenden wie die Auswahlliste + d.hoop = self.cmb_hoop.itemData(idx) + self.cmb_hoop.blockSignals(True) + self.cmb_hoop.setCurrentIndex(idx) + self.cmb_hoop.blockSignals(False) + if hasattr(self, "lbl_hoop_strategy"): + self.lbl_hoop_strategy.setText(f"{tr('lbl_strategy')}: {d.hoop.strategy_description}") + if hasattr(self, "cmb_machine"): + idx = next((i for i in range(self.cmb_machine.count()) + if self.cmb_machine.itemData(i).name == d.settings.machine_name), -1) + if idx >= 0: + self.cmb_machine.blockSignals(True) + self.cmb_machine.setCurrentIndex(idx) + self.cmb_machine.blockSignals(False) + def _snapshot(self, desc: str): - self._undo_stack.append((desc, [o.clone() for o in self.canvas.design.objects])) + self._last_edit_key = None + self._undo_stack.append((desc, [o.snapshot() for o in self.canvas.design.objects])) if len(self._undo_stack) > 30: self._undo_stack.pop(0) self._redo_stack.clear() - def rebuild_plan(self, prebuilt_plan=None): + def rebuild_plan(self, prebuilt_plan=None, refresh_props: bool = True): self.canvas.plan = prebuilt_plan if prebuilt_plan is not None else self.canvas.design.build_plan() n = max(1, self.canvas.plan.stitch_count) self.slider_sim.blockSignals(True) @@ -784,7 +813,8 @@ def rebuild_plan(self, prebuilt_plan=None): self._refresh_object_list() self._refresh_needle_list() self._refresh_stats() - self._populate_properties(self.canvas.selected) + if refresh_props: + self._populate_properties(self.canvas.selected) self.canvas.invalidate_render_cache() def _refresh_stats(self): @@ -898,9 +928,12 @@ def _populate_properties(self, obj: EmbObject | None): self._updating_props = False def _set_obj_attr(self, obj: EmbObject, attr: str, value): - if self._updating_props: + if self._updating_props or getattr(obj, attr, None) == value: return - self._snapshot(f"{attr} ändern") + key = (obj.id, attr) + if self._last_edit_key != key: + self._snapshot(f"{attr} ändern") + self._last_edit_key = key setattr(obj, attr, value) self._rebuild_timer.start() @@ -910,6 +943,10 @@ def _edit_text_object(self, obj: TextObject): self._snapshot("Text bearbeiten") new_o = dlg.created_object new_o.transform = obj.transform + new_o.name = obj.name or new_o.name + new_o.visible = obj.visible + if hasattr(obj, "pull_comp") and hasattr(new_o, "pull_comp"): + new_o.pull_comp = obj.pull_comp idx = self.canvas.design.objects.index(obj) self.canvas.design.objects[idx] = new_o self.canvas.selected = new_o @@ -990,7 +1027,7 @@ def action_fit_to_hoop(self): o.height *= s o.letter_spacing *= s o.arc_radius *= s - o.transform = (1, 0, 0, 1, nx, ny) + o.transform = (a, bb, d, e, nx, ny) self.rebuild_plan() self.canvas.zoom_to_fit() @@ -1050,6 +1087,7 @@ def _move_selected_object(self, delta: int): def _toggle_selected_visibility(self): obj = self.canvas.selected if obj is not None: + self._snapshot("Sichtbarkeit") obj.visible = not obj.visible self.rebuild_plan() @@ -1182,7 +1220,12 @@ def action_save_project(self): ) if not path: return - save_project(self.canvas.design, path) + try: + save_project(self.canvas.design, path) + except Exception as exc: + QMessageBox.critical(self, "Speicherfehler", str(exc)) + return + self.status.showMessage(f"Gespeichert: {path}", 5000) def action_import_embroidery(self): exts = " ".join(f"*.{e} *.{e.upper()}" for e in READ_EXT) @@ -1269,7 +1312,11 @@ def action_export_pdf(self): ) if not path: return - export_worksheet_pdf(self.canvas.design, self.canvas.plan, path) + try: + export_worksheet_pdf(self.canvas.design, self.canvas.plan, path) + except Exception as exc: + QMessageBox.critical(self, "PDF-Fehler", str(exc)) + return QMessageBox.information(self, "PDF erstellt", f"Produktionsblatt gespeichert:\n{path}") def action_export_png(self): @@ -1278,7 +1325,8 @@ def action_export_png(self): ) if not path: return - render_image(self.canvas.plan, px_per_mm=10.0).save(path) + if not render_image(self.canvas.plan, px_per_mm=10.0).save(path): + QMessageBox.critical(self, "PNG-Fehler", f"Bild konnte nicht gespeichert werden:\n{path}") # ---------------------------------------------- Digitalisier-Assistenten def action_auto_digitize(self): @@ -1303,12 +1351,19 @@ def action_auto_digitize(self): self._open_auto_digitize_for_path(path) def _open_auto_digitize_for_path(self, path: str): - dlg = AutoDigitizeDialog(path, self) + try: + dlg = AutoDigitizeDialog(path, self) + except Exception as exc: + QMessageBox.critical(self, "Bild-Fehler", str(exc)) + return if dlg.exec() == AutoDigitizeDialog.Accepted and dlg.result_data is not None: self._snapshot("Auto-Digitalisieren") res_d = dlg.result_data.design + same_mode = res_d.hoop.mode == self.canvas.design.hoop.mode res_d.hoop = self.canvas.design.hoop - self.set_design(res_d, prebuilt_plan=dlg.result_plan) + res_d.settings = self.canvas.design.settings + # Vorberechneter Plan gilt nur, wenn die Rahmen-Strategie identisch ist + self.set_design(res_d, prebuilt_plan=dlg.result_plan if same_mode else None) self.canvas.zoom_to_fit() def action_photostitch(self): @@ -1318,11 +1373,16 @@ def action_photostitch(self): ) if not path: return - dlg = PhotoStitchDialog(path, self) + try: + dlg = PhotoStitchDialog(path, self) + except Exception as exc: + QMessageBox.critical(self, "Bild-Fehler", str(exc)) + return if dlg.exec() == PhotoStitchDialog.Accepted and dlg.result_design is not None: self._snapshot("PhotoStitch") res_d = dlg.result_design res_d.hoop = self.canvas.design.hoop + res_d.settings = self.canvas.design.settings self.set_design(res_d) self.canvas.zoom_to_fit() @@ -1373,18 +1433,22 @@ def action_undo(self): if not self._undo_stack: return desc, prev_objs = self._undo_stack.pop() - self._redo_stack.append((desc, [o.clone() for o in self.canvas.design.objects])) + self._redo_stack.append((desc, [o.snapshot() for o in self.canvas.design.objects])) + self._last_edit_key = None + sel_id = self.canvas.selected.id if self.canvas.selected is not None else None self.canvas.design.objects = prev_objs - self.canvas.selected = prev_objs[0] if prev_objs else None + self.canvas.selected = next((o for o in prev_objs if o.id == sel_id), prev_objs[0] if prev_objs else None) self.rebuild_plan() def action_redo(self): if not self._redo_stack: return desc, next_objs = self._redo_stack.pop() - self._undo_stack.append((desc, [o.clone() for o in self.canvas.design.objects])) + self._undo_stack.append((desc, [o.snapshot() for o in self.canvas.design.objects])) + self._last_edit_key = None + sel_id = self.canvas.selected.id if self.canvas.selected is not None else None self.canvas.design.objects = next_objs - self.canvas.selected = next_objs[0] if next_objs else None + self.canvas.selected = next((o for o in next_objs if o.id == sel_id), next_objs[0] if next_objs else None) self.rebuild_plan() def action_duplicate_selected(self): @@ -1432,7 +1496,7 @@ def action_scale_design(self): o.height *= s o.letter_spacing *= s o.arc_radius *= s - o.transform = (1, 0, 0, 1, x * s, y * s) + o.transform = (a, b, d, e, x * s, y * s) self.rebuild_plan() # ------------------------------------------------------------ Simulator diff --git a/tests/test_openstitch.py b/tests/test_openstitch.py index 176dd05..5b63df8 100644 --- a/tests/test_openstitch.py +++ b/tests/test_openstitch.py @@ -181,3 +181,81 @@ def test_language_switch_and_thread_catalogs(): assert win.swatch_grid.count() >= 25 set_lang("de") win.close() + + +def _max_stitch_len(plan): + import math + from openstitch.core.stitchplan import Cmd + longest = 0.0 + for b in plan.blocks: + prev = None + for s in b.stitches: + if s.cmd == Cmd.STITCH and prev is not None: + longest = max(longest, math.hypot(s.x - prev[0], s.y - prev[1])) + if s.cmd in (Cmd.STITCH, Cmd.JUMP): + prev = (s.x, s.y) + return longest + + +def test_no_stray_stitches_after_trim(): + """Nach Trim + Sprung darf die Nadel nicht von der alten Position aus weiternähen.""" + t = Thread((200, 30, 30), "Rot") + for obj in (SatinObject(t, geom=LineString([(-30, 0), (30, 0)]).buffer(2.5)), + FillObject(t, geom=Point(0, 0).buffer(30), angle=30)): + d = Design() + d.objects.append(obj) + plan = d.build_plan() + assert plan.trim_count >= 1 + assert _max_stitch_len(plan) <= d.settings.max_stitch + 1e-6 + + +def test_undo_keeps_ids_and_coalesces_spinbox_edits(): + win = MainWindow(show_tour_on_init=False) + d = win.canvas.design + obj = next(o for o in d.objects if o.kind == "fill") + win.canvas.selected = obj + ids_before = [o.id for o in d.objects] + depth = len(win._undo_stack) + # Spinbox-Ziehen: viele valueChanged-Signale → genau ein Undo-Schritt + for v in (0.45, 0.46, 0.47, 0.48): + win._set_obj_attr(obj, "spacing", v) + win._set_obj_attr(obj, "spacing", 0.48) # unverändert → kein Snapshot + assert len(win._undo_stack) == depth + 1 + win.action_undo() + assert [o.id for o in d.objects] == ids_before + assert win.canvas.selected.id == obj.id + restored = next(o for o in d.objects if o.id == obj.id) + assert restored.spacing != 0.48 + win.close() + + +def test_loaded_design_syncs_hoop_combo(tmp_path): + from openstitch.core.design import HOOPS + win = MainWindow(show_tour_on_init=False) + d = Design(name="Jacke") + d.hoop = HOOPS[8] + d.objects.append(FillObject(Thread((10, 10, 10)), geom=Point(0, 0).buffer(10))) + path = str(tmp_path / "jacke.ostitch") + save_project(d, path) + win.set_design(load_project(path), reset_undo=True) + assert win.cmb_hoop.currentData() is win.canvas.design.hoop + assert win.canvas.design.hoop.mode == "jacket" + win.close() + + +def test_fit_to_hoop_keeps_text_scale(): + win = MainWindow(show_tour_on_init=False) + t = TextObject(Thread((0, 0, 0)), text="AB", height=10.0) + t.transform = (2.0, 0.0, 0.0, 2.0, 0.0, 0.0) # per Griff auf 200 % gezogen + win.set_design(Design(objects=[t]), reset_undo=True) + from openstitch.core.design import m_scale + h_before = t.height + w_before = t.bounds()[2] - t.bounds()[0] + win.action_fit_to_hoop() + obj = win.canvas.design.objects[0] + s = obj.height / h_before + # Der Einpass-Faktor landet in height, die per Griff gezogenen 200 % bleiben im Transform + assert abs(m_scale(obj.transform) - 2.0) < 1e-6 + w_after = obj.bounds()[2] - obj.bounds()[0] + assert abs(w_after - w_before * s) < 0.05 * w_after + win.close() From a424cc0bc845ea13d1960b5f676c24143480b869 Mon Sep 17 00:00:00 2001 From: lbaumberger Date: Wed, 28 Oct 2026 15:46:17 +0100 Subject: [PATCH 02/18] Join underlay and satin end caps with hidden travel instead of trims Underlay pieces, the travel to the top-fill entry and the leftover satin end-cap fills are stitched over afterwards, so they are now connected with running stitches inside the shape. A single fill or satin object goes from 3 trims to 1. Co-Authored-By: Claude --- openstitch/core/design.py | 17 ++++++++++++++--- openstitch/engine/fill.py | 28 ++++++++++++++++++++++++++++ tests/test_openstitch.py | 12 ++++++++++++ 3 files changed, 54 insertions(+), 3 deletions(-) diff --git a/openstitch/core/design.py b/openstitch/core/design.py index 358b6ab..767a947 100644 --- a/openstitch/core/design.py +++ b/openstitch/core/design.py @@ -11,7 +11,7 @@ from shapely.geometry import LineString, MultiLineString, MultiPolygon, Point, Polygon, box from shapely.ops import unary_union -from ..engine.fill import fill_runs +from ..engine.fill import fill_runs, join_covered_runs from ..engine.geom import Pt, clean, dist, lines_of, main_axis_angle, polygons_of, directional_grow from ..engine.running import bean_stitch, running_stitch, tie_stitches from ..engine.satin import satin_runs @@ -168,7 +168,13 @@ def runs(self, start=None): # Rahmen-abhängiger Zuschlag auf die Zugkompensation ("Trampolin-Effekt" in grossen Rahmen / Kappen) hoop_pc_boost = getattr(self, "_hoop_pc_boost", 1.0) top_geom = directional_grow(g, self.pull_comp * hoop_pc_boost, ang) - out.extend(fill_runs(top_geom, ang, self.spacing, self.stitch_len, self.pattern, start=cur)) + top = fill_runs(top_geom, ang, self.spacing, self.stitch_len, self.pattern, start=cur) + # Unterlage und Weg zum Deckstich-Einstieg liegen unter dem Deckstich → verbinden statt trimmen + under = [r for r in out if len(r) >= 2] + if under and top: + out = join_covered_runs(under + top[:1], g) + top[1:] + else: + out = under + top return [r for r in out if len(r) >= 2] @@ -204,8 +210,13 @@ def runs(self, start=None): if not self.puff_3d and res.leftover is not None and not res.leftover.is_empty: ang = main_axis_angle(res.leftover) + 90 out.extend(fill_runs(res.leftover.buffer(0.15), ang, 0.42, 3.0, "standard", start=start)) + n_left = len(out) out.extend(res.runs) - return [r for r in out if len(r) >= 2] + out = [r for r in out if len(r) >= 2] + if n_left and len(out) > n_left: + # Wege zwischen Füll-Resten (z. B. Säulen-Enden) und Satinstart werden vom Satin verdeckt + out = join_covered_runs(out[:n_left + 1], g) + out[n_left + 1:] + return out @dataclass diff --git a/openstitch/engine/fill.py b/openstitch/engine/fill.py index bc0803b..1da36c6 100644 --- a/openstitch/engine/fill.py +++ b/openstitch/engine/fill.py @@ -211,6 +211,34 @@ def _travel(poly: Polygon, prepared, rings, a: Pt, b: Pt, stitch_len: float) -> return resample_path(path, stitch_len)[1:] +def join_covered_runs(runs: list[list[Pt]], geom, stitch_len: float = 2.5) -> list[list[Pt]]: + """Verbindet aufeinanderfolgende Läufe mit Laufstichen innerhalb von `geom` statt Trim + Sprung. + + Nur für Läufe, die anschliessend noch überstickt werden (Unterlage → Deckstich, + Satin-Endkappen → Satinsäule): die Wege liegen dann verdeckt unter dem Deckstich.""" + if len(runs) < 2 or geom is None or geom.is_empty: + return runs + area = geom.buffer(0.05) + prepared = prep(area) + rings = [] + for p in polygons_of(geom.buffer(-0.3)) or polygons_of(geom): + rings.append(p.exterior) + rings.extend(p.interiors) + out = [list(runs[0])] + for r in runs[1:]: + a, b = out[-1][-1], r[0] + if dist(a, b) < 1e-6 or prepared.contains(LineString([a, b])): + tr = subdivide(a, b, stitch_len) + else: + tr = _travel(area, prepared, rings, a, b, stitch_len) + if tr is None: + out.append(list(r)) + else: + out[-1].extend(tr) + out[-1].extend(r[1:]) + return out + + def fill_runs(geom, angle: float = 45.0, spacing: float = 0.4, stitch_len: float = 3.5, pattern: str = "standard", start: Pt | None = None, min_len: float = 0.6) -> list[list[Pt]]: diff --git a/tests/test_openstitch.py b/tests/test_openstitch.py index 5b63df8..d952a6f 100644 --- a/tests/test_openstitch.py +++ b/tests/test_openstitch.py @@ -259,3 +259,15 @@ def test_fit_to_hoop_keeps_text_scale(): w_after = obj.bounds()[2] - obj.bounds()[0] assert abs(w_after - w_before * s) < 0.05 * w_after win.close() + + +def test_single_objects_need_no_internal_trims(): + """Unterlage → Deckstich und Satin-Endkappen → Säule laufen verdeckt, ohne Trim.""" + t = Thread((200, 30, 30), "Rot") + for obj in (FillObject(t, geom=Point(0, 0).buffer(30), angle=30), + SatinObject(t, geom=LineString([(-30, 0), (30, 0)]).buffer(2.5))): + d = Design() + d.objects.append(obj) + plan = d.build_plan() + assert plan.trim_count == 1 # nur der Schlussschnitt + assert _max_stitch_len(plan) <= d.settings.max_stitch + 1e-6 From f4d7f22a1c39aa7ac0ca1f7ed1c9c75fbb71b760 Mon Sep 17 00:00:00 2001 From: lbaumberger Date: Wed, 28 Oct 2026 15:49:19 +0100 Subject: [PATCH 03/18] Build stitch plans in a background thread PlanBuilder runs Design.build_plan on a worker thread with at most one build in flight; edits made during a build are coalesced into a single follow-up build with the newest state. The object list and inspector update immediately, needle list/stats/canvas when the plan arrives. Export, PDF, PNG, quality doctor and floppy writer wait for the current plan first. Generator exceptions show in the status bar instead of crashing the app. Co-Authored-By: Claude --- openstitch/i18n.py | 8 ++++ openstitch/ui/main_window.py | 45 +++++++++++++++++-- openstitch/ui/plan_worker.py | 84 ++++++++++++++++++++++++++++++++++++ tests/test_openstitch.py | 24 ++++++++++- 4 files changed, 156 insertions(+), 5 deletions(-) create mode 100644 openstitch/ui/plan_worker.py diff --git a/openstitch/i18n.py b/openstitch/i18n.py index 56bde4a..aec9c30 100644 --- a/openstitch/i18n.py +++ b/openstitch/i18n.py @@ -199,5 +199,13 @@ def key_for(german_text: str) -> str | None: return None +_TABLE.update({ + "status_computing": ("Stiche werden berechnet …", "Computing stitches …", "Calcul des points …", + "Calculando puntadas …"), + "status_plan_error": ("Fehler bei der Stichberechnung", "Stitch generation failed", + "Erreur lors du calcul des points", "Error al calcular las puntadas"), +}) + + def tr_all(key: str) -> list[str]: return list(_TABLE.get(key, (key,) * 4)) diff --git a/openstitch/ui/main_window.py b/openstitch/ui/main_window.py index d354607..df7ae40 100644 --- a/openstitch/ui/main_window.py +++ b/openstitch/ui/main_window.py @@ -50,6 +50,7 @@ from .canvas import StitchCanvas from .dialogs import AutoDigitizeDialog, MelcoDiskDialog, PhotoStitchDialog, TextDialog from .onboarding import InAppTourOverlay, OnboardingTourDialog, create_openstitch_logo_design, studio_icon +from .plan_worker import PlanBuilder STUDIO_STYLE = """ QMainWindow, QDialog, QDockWidget, QWidget { @@ -228,6 +229,9 @@ def __init__(self, defer_starter: bool = False, show_tour_on_init: bool = True): self._tour_overlay: InAppTourOverlay | None = None self.canvas = StitchCanvas(self) + self._planner = PlanBuilder(self) + self._planner.finished.connect(self._on_plan_finished) + self._applied_gen = 0 central = QWidget(self) c_lay = QVBoxLayout(central) c_lay.setContentsMargins(0, 0, 0, 0) @@ -801,7 +805,38 @@ def _snapshot(self, desc: str): self._redo_stack.clear() def rebuild_plan(self, prebuilt_plan=None, refresh_props: bool = True): - self.canvas.plan = prebuilt_plan if prebuilt_plan is not None else self.canvas.design.build_plan() + """Objektliste/Inspektor sofort aktualisieren, Stiche im Hintergrund neu berechnen.""" + self._refresh_object_list() + if refresh_props: + self._populate_properties(self.canvas.selected) + if prebuilt_plan is not None: + # laufende Berechnungen sind damit überholt + self._applied_gen = self._planner.supersede() + self._apply_plan(prebuilt_plan) + return + self._planner.request(self.canvas.design) + if hasattr(self, "status"): + self.status.showMessage(tr("status_computing")) + + def _on_plan_finished(self, gen: int, plan, err: str): + if gen != self._planner.generation or gen <= self._applied_gen: + return # veraltet – eine neuere Berechnung läuft schon + self._applied_gen = gen + if plan is None: + self.status.showMessage(f"{tr('status_plan_error')}: {err}", 12000) + return + self.status.clearMessage() + self._apply_plan(plan) + + def wait_for_plan(self): + """Wartet auf die laufende Berechnung (vor Export, Prüfung & in Tests).""" + res = self._planner.wait() + if res is not None: + self._on_plan_finished(*res) + return self.canvas.plan + + def _apply_plan(self, plan): + self.canvas.plan = plan n = max(1, self.canvas.plan.stitch_count) self.slider_sim.blockSignals(True) self.slider_sim.setRange(0, n) @@ -810,11 +845,8 @@ def rebuild_plan(self, prebuilt_plan=None, refresh_props: bool = True): self.lbl_sim.setText(f"{n:,} / {n:,}".replace(",", "'")) self.slider_sim.blockSignals(False) - self._refresh_object_list() self._refresh_needle_list() self._refresh_stats() - if refresh_props: - self._populate_properties(self.canvas.selected) self.canvas.invalidate_render_cache() def _refresh_stats(self): @@ -1268,6 +1300,7 @@ def action_make_applique(self): self.rebuild_plan() def action_quality_doctor(self): + self.wait_for_plan() issues = run_quality_doctor( self.canvas.design, self.canvas.plan, max_needles=self.canvas.design.settings.needles ) @@ -1286,6 +1319,7 @@ def action_export_embroidery(self): ) if not path: return + self.wait_for_plan() try: export_plan(self.canvas.plan, path) ext = os.path.splitext(path)[1].lower().lstrip(".") @@ -1312,6 +1346,7 @@ def action_export_pdf(self): ) if not path: return + self.wait_for_plan() try: export_worksheet_pdf(self.canvas.design, self.canvas.plan, path) except Exception as exc: @@ -1325,6 +1360,7 @@ def action_export_png(self): ) if not path: return + self.wait_for_plan() if not render_image(self.canvas.plan, px_per_mm=10.0).save(path): QMessageBox.critical(self, "PNG-Fehler", f"Bild konnte nicht gespeichert werden:\n{path}") @@ -1412,6 +1448,7 @@ def action_add_text(self): self.rebuild_plan() def action_melco_disk(self): + self.wait_for_plan() dlg = MelcoDiskDialog(self.canvas.plan, self.canvas.design.name, self) dlg.exec() diff --git a/openstitch/ui/plan_worker.py b/openstitch/ui/plan_worker.py new file mode 100644 index 0000000..f0fb6c8 --- /dev/null +++ b/openstitch/ui/plan_worker.py @@ -0,0 +1,84 @@ +"""Stichplan-Berechnung im Hintergrund-Thread, damit die Oberfläche beim Editieren flüssig bleibt. + +Es läuft höchstens ein Rechen-Thread gleichzeitig (der Objekt-Cache des Designs wird so nie +parallel benutzt). Kommen während einer Berechnung neue Änderungen, wird danach genau einmal +mit dem neuesten Stand nachgerechnet – Zwischenstände werden übersprungen. +""" +from __future__ import annotations + +import copy +import threading + +from PySide6.QtCore import QObject, Signal + +from ..core.design import Design + + +class PlanBuilder(QObject): + # (Generation, StitchPlan | None, Fehlertext) + finished = Signal(int, object, str) + + def __init__(self, parent=None): + super().__init__(parent) + self._lock = threading.Lock() + self._thread: threading.Thread | None = None + self._pending: tuple[int, Design] | None = None + self._result: tuple[int, object, str] | None = None + self.generation = 0 + + @staticmethod + def _detach(design: Design) -> Design: + """Flache Kopien der Objekte: Parameter/Geometrien sind unveränderlich bzw. werden nur + ersetzt, so kann der Haupt-Thread weiter editieren, ohne die Berechnung zu stören.""" + return Design(objects=[copy.copy(o) for o in design.objects], hoop=design.hoop, + settings=copy.copy(design.settings), name=design.name, _cache=design._cache) + + def request(self, design: Design) -> int: + with self._lock: + self.generation += 1 + self._pending = (self.generation, self._detach(design)) + if self._thread is None: + self._start_locked() + return self.generation + + def supersede(self) -> int: + """Markiert alle laufenden/angeforderten Berechnungen als veraltet.""" + with self._lock: + self.generation += 1 + self._pending = None + return self.generation + + def busy(self) -> bool: + with self._lock: + return self._thread is not None + + def _start_locked(self): + gen, d = self._pending + self._pending = None + self._thread = threading.Thread(target=self._run, args=(gen, d), daemon=True, + name="openstitch-plan") + self._thread.start() + + def _run(self, gen: int, d: Design): + try: + plan, err = d.build_plan(), "" + except Exception as exc: # Fehler im Stichgenerator dürfen die App nicht abstürzen lassen + plan, err = None, f"{type(exc).__name__}: {exc}" + with self._lock: + self._result = (gen, plan, err) + self._thread = None + if self._pending is not None: + self._start_locked() + self.finished.emit(gen, plan, err) + + def wait(self, timeout: float | None = None) -> tuple[int, object, str] | None: + """Blockiert, bis alle angeforderten Berechnungen fertig sind (für Export & Tests).""" + while True: + with self._lock: + t = self._thread + if t is None and self._pending is None: + return self._result + if t is not None: + t.join(timeout) + if timeout is not None and t.is_alive(): + return None diff --git a/tests/test_openstitch.py b/tests/test_openstitch.py index d952a6f..db0c2d7 100644 --- a/tests/test_openstitch.py +++ b/tests/test_openstitch.py @@ -81,6 +81,7 @@ def test_auto_digitizer_and_gui(): assert len(exp_bytes(plan)) > 600 win = MainWindow() + win.wait_for_plan() assert win.canvas.plan.stitch_count > 500 issues = run_quality_doctor(win.canvas.design, win.canvas.plan, max_needles=10) assert len(issues) >= 1 @@ -101,7 +102,7 @@ def test_auto_digitizer_and_gui(): def test_all_machine_formats_binary_verification(tmp_path): """Exportiert ein komplexes Design in alle wichtigen Binärformate und verifiziert sie unabhängig.""" win = MainWindow(show_tour_on_init=False) - plan = win.canvas.plan + plan = win.wait_for_plan() assert plan.stitch_count > 4000 for ext in ("exp", "dst", "pes", "jef", "vp3", "xxx", "u01"): @@ -271,3 +272,24 @@ def test_single_objects_need_no_internal_trims(): plan = d.build_plan() assert plan.trim_count == 1 # nur der Schlussschnitt assert _max_stitch_len(plan) <= d.settings.max_stitch + 1e-6 + + +def test_plan_is_built_in_background(): + """Editieren blockiert nicht: der Plan wird im Hintergrund gerechnet und danach übernommen.""" + win = MainWindow(show_tour_on_init=False) + win.wait_for_plan() + before = win.canvas.plan + obj = next(o for o in win.canvas.design.objects if o.kind == "fill") + win._snapshot("test") + obj.spacing = 0.8 + win.rebuild_plan() + plan = win.wait_for_plan() + assert plan is not before and plan.stitch_count < before.stitch_count + # mehrere schnelle Änderungen: nur der neueste Stand wird übernommen + for sp in (0.5, 0.6, 0.7): + obj.spacing = sp + win.rebuild_plan() + final = win.wait_for_plan() + d = Design(objects=[o.snapshot() for o in win.canvas.design.objects], hoop=win.canvas.design.hoop) + assert final.stitch_count == d.build_plan().stitch_count + win.close() From 2153d870dd1f9a1abd3a45215d6a8131091e4c66 Mon Sep 17 00:00:00 2001 From: lbaumberger Date: Wed, 28 Oct 2026 15:52:33 +0100 Subject: [PATCH 04/18] Cancelable auto-digitize, unsaved-changes prompt, faster simulator and heatmap - Auto-digitize: Cancel/Esc/close now stops the worker at the next progress point; the plan is computed for the main window's hoop. - New/Open/Import/Drop/Close ask before discarding unsaved changes. - Simulator draws only the newly reached stitches onto the cached image instead of re-rendering the whole plan every tick (demo design: ~108 ms -> ~15 ms per tick). - Heatmap cell counts are cached per plan version (~28 -> ~12 ms per repaint). Co-Authored-By: Claude --- .gitignore | 3 +++ openstitch/i18n.py | 6 +++++ openstitch/render.py | 14 ++++++----- openstitch/ui/canvas.py | 46 +++++++++++++++++++++++----------- openstitch/ui/dialogs.py | 48 ++++++++++++++++++++++++++++++------ openstitch/ui/main_window.py | 47 +++++++++++++++++++++++++++++++---- 6 files changed, 132 insertions(+), 32 deletions(-) diff --git a/.gitignore b/.gitignore index 9653cec..7b79ede 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,6 @@ test_out* build_*/ *.spec dist/ + +# Claude Code worktrees / local settings +.claude/ diff --git a/openstitch/i18n.py b/openstitch/i18n.py index aec9c30..6059962 100644 --- a/openstitch/i18n.py +++ b/openstitch/i18n.py @@ -202,6 +202,12 @@ def key_for(german_text: str) -> str | None: _TABLE.update({ "status_computing": ("Stiche werden berechnet …", "Computing stitches …", "Calcul des points …", "Calculando puntadas …"), + "dlg_unsaved_title": ("Ungespeicherte Änderungen", "Unsaved changes", "Modifications non enregistrées", + "Cambios sin guardar"), + "dlg_unsaved_text": ("Das aktuelle Design hat ungespeicherte Änderungen. Jetzt speichern?", + "The current design has unsaved changes. Save them now?", + "Le motif actuel contient des modifications non enregistrées. Les enregistrer ?", + "El diseño actual tiene cambios sin guardar. ¿Guardarlos ahora?"), "status_plan_error": ("Fehler bei der Stichberechnung", "Stitch generation failed", "Erreur lors du calcul des points", "Error al calcular las puntadas"), }) diff --git a/openstitch/render.py b/openstitch/render.py index 7c607c6..0f7afe8 100644 --- a/openstitch/render.py +++ b/openstitch/render.py @@ -17,8 +17,10 @@ def _shade(c: QColor, f: float) -> QColor: int(c.blue() + (255 - c.blue()) * t)) -def iter_segments(plan: StitchPlan, upto: int | None = None): - """Liefert je Farbblock die genähten Segmente: (block, [(x0,y0,x1,y1), ...], jumps).""" +def iter_segments(plan: StitchPlan, upto: int | None = None, start: int = 0): + """Liefert je Farbblock die genähten Segmente: (block, [(x0,y0,x1,y1), ...], jumps). + + `start`: nur Segmente nach dem start-ten Stich (für inkrementelles Weiterzeichnen im Simulator).""" n = 0 for block in plan.blocks: segs, jumps = [], [] @@ -28,11 +30,11 @@ def iter_segments(plan: StitchPlan, upto: int | None = None): break if s.cmd == Cmd.STITCH: n += 1 - if prev is not None: + if prev is not None and n > start: segs.append((prev[0], prev[1], s.x, s.y)) prev = (s.x, s.y) elif s.cmd == Cmd.JUMP: - if prev is not None: + if prev is not None and n >= start: jumps.append((prev[0], prev[1], s.x, s.y)) prev = (s.x, s.y) yield block, segs, jumps @@ -41,11 +43,11 @@ def iter_segments(plan: StitchPlan, upto: int | None = None): def draw_plan(p: QPainter, plan: StitchPlan, upto: int | None = None, realistic: bool = True, - thread_w: float = 0.38, show_jumps: bool = False, px_per_mm: float = 4.0): + thread_w: float = 0.38, show_jumps: bool = False, px_per_mm: float = 4.0, start: int = 0): """Zeichnet in mm-Koordinaten (Painter muss bereits skaliert sein).""" p.setRenderHint(QPainter.Antialiasing, True) last_pt = None - for block, segs, jumps in iter_segments(plan, upto): + for block, segs, jumps in iter_segments(plan, upto, start): if not segs: continue col = QColor(*block.thread.color) diff --git a/openstitch/ui/canvas.py b/openstitch/ui/canvas.py index 6df48ed..c18dfd1 100644 --- a/openstitch/ui/canvas.py +++ b/openstitch/ui/canvas.py @@ -111,6 +111,21 @@ def _ensure_stitch_cache(self): key = (self._plan_version, q_ppm, self.realistic, self.show_jumps, self.sim_limit) if self._cache_key == key and self._cache_img is not None: return + # Simulator läuft vorwärts: nur die neuen Stiche auf das vorhandene Bild zeichnen, + # statt bei jedem Timer-Tick den ganzen Plan neu zu rendern + old = self._cache_key + if (old is not None and self._cache_img is not None and old[:4] == key[:4] + and old[4] is not None and (key[4] is None or key[4] > old[4])): + sp = QPainter(self._cache_img) + sp.scale(self._cache_px_per_mm, self._cache_px_per_mm) + sp.translate(-self._cache_origin_mm[0], -self._cache_origin_mm[1]) + pt = draw_plan(sp, self.plan, upto=key[4], realistic=self.realistic, show_jumps=self.show_jumps, + px_per_mm=self._cache_px_per_mm, start=old[4]) + sp.end() + if pt is not None: + self._last_sim_pt = pt + self._cache_key = key + return x0, y0, x1, y1 = self.plan.bounds() pad = 6.0 w_mm = max(10.0, (x1 - x0) + 2 * pad) @@ -138,21 +153,24 @@ def _ensure_stitch_cache(self): self._cache_key = key def _draw_heatmap(self, p: QPainter): - counts: dict[tuple[int, int], int] = defaultdict(int) - for b in self.plan.blocks: - for s in b.stitches: - if s.cmd == Cmd.STITCH: - counts[(int(math.floor(s.x)), int(math.floor(s.y)))] += 1 + # Zellzählung nur einmal pro Plan-Version, nicht bei jedem Repaint (Mausbewegung!) + if getattr(self, "_heat_key", None) != self._plan_version: + counts: dict[tuple[int, int], int] = defaultdict(int) + for b in self.plan.blocks: + for s in b.stitches: + if s.cmd == Cmd.STITCH: + counts[(int(math.floor(s.x)), int(math.floor(s.y)))] += 1 + buckets: list[list[QRectF]] = [[], [], []] + for (gx, gy), c in counts.items(): + buckets[0 if c <= 6 else 1 if c <= 12 else 2].append(QRectF(gx, gy, 1.0, 1.0)) + self._heat_rects = buckets + self._heat_key = self._plan_version p.setPen(Qt.NoPen) - for (gx, gy), c in counts.items(): - if c <= 6: - col = QColor(40, 200, 90, 95) - elif c <= 12: - col = QColor(255, 195, 0, 145) - else: - col = QColor(255, 35, 35, 190) - p.setBrush(col) - p.drawRect(QRectF(gx, gy, 1.0, 1.0)) + for col, rects in zip((QColor(40, 200, 90, 95), QColor(255, 195, 0, 145), QColor(255, 35, 35, 190)), + self._heat_rects): + if rects: + p.setBrush(col) + p.drawRects(rects) # ----------------------------------------------------------- Zeichnen def paintEvent(self, event): diff --git a/openstitch/ui/dialogs.py b/openstitch/ui/dialogs.py index 7542dd9..93c70eb 100644 --- a/openstitch/ui/dialogs.py +++ b/openstitch/ui/dialogs.py @@ -46,32 +46,50 @@ def _np_to_pixmap(rgb: np.ndarray, max_side: int = 360) -> QPixmap: return QPixmap.fromImage(qimg).scaled(max_side, max_side, Qt.KeepAspectRatio, Qt.SmoothTransformation) +class _Cancelled(Exception): + pass + + class _DigitizeWorker(QThread): progress = Signal(str, int) finished_ok = Signal(object, object) # AutoResult, StitchPlan failed = Signal(str) - def __init__(self, rgba: np.ndarray, params: AutoParams): + def __init__(self, rgba: np.ndarray, params: AutoParams, hoop=None): super().__init__() self.rgba = rgba self.params = params + self.hoop = hoop + self.cancelled = False + + def _tick(self, msg: str, val: int): + # Abbruch wird an jedem Fortschrittspunkt geprüft (Bildschritte bzw. je Objekt) + if self.cancelled: + raise _Cancelled() + self.progress.emit(msg, val) def run(self): try: res = auto_digitize( self.rgba, self.params, - progress=lambda msg, v: self.progress.emit(msg, int(v * 0.55)), + progress=lambda msg, v: self._tick(msg, int(v * 0.55)), ) - total = max(1, len(res.design.objects)) + if self.hoop is not None: + # Plan gleich für den Rahmen des Hauptfensters rechnen + res.design.hoop = self.hoop plan = res.design.build_plan( - progress=lambda i, n: self.progress.emit( + progress=lambda i, n: self._tick( f"Stiche berechnen ({i}/{n}) …", 55 + int(45 * i / max(1, n)) ) ) - self.finished_ok.emit(res, plan) + if not self.cancelled: + self.finished_ok.emit(res, plan) + except _Cancelled: + pass except Exception as exc: - self.failed.emit(str(exc)) + if not self.cancelled: + self.failed.emit(str(exc)) # =========================================================================== @@ -214,7 +232,9 @@ def _run(self): use_palette=self.cb_palette.isChecked(), ) self.btn_ok.setEnabled(False) - self._worker = _DigitizeWorker(self.rgba, p) + parent = self.parent() + hoop = parent.canvas.design.hoop if parent is not None and hasattr(parent, "canvas") else None + self._worker = _DigitizeWorker(self.rgba, p, hoop) self._worker.progress.connect(self._on_prog) self._worker.finished_ok.connect(self._on_done) self._worker.failed.connect(self._on_fail) @@ -234,6 +254,20 @@ def _on_fail(self, err: str): self.btn_ok.setEnabled(True) QMessageBox.critical(self, "Fehler beim Digitalisieren", err) + def reject(self): + # Abbrechen/Esc/Schliessen stoppt auch die laufende Berechnung + w = self._worker + if w is not None and w.isRunning(): + w.cancelled = True + for sig in (w.progress, w.finished_ok, w.failed): + try: + sig.disconnect() + except (RuntimeError, TypeError): + pass + w.wait(15000) + self._worker = None + super().reject() + # =========================================================================== # 2. Schrift & Monogramme diff --git a/openstitch/ui/main_window.py b/openstitch/ui/main_window.py index df7ae40..f54ba1a 100644 --- a/openstitch/ui/main_window.py +++ b/openstitch/ui/main_window.py @@ -232,6 +232,7 @@ def __init__(self, defer_starter: bool = False, show_tour_on_init: bool = True): self._planner = PlanBuilder(self) self._planner.finished.connect(self._on_plan_finished) self._applied_gen = 0 + self._dirty = False central = QWidget(self) c_lay = QVBoxLayout(central) c_lay.setContentsMargins(0, 0, 0, 0) @@ -771,6 +772,9 @@ def set_design(self, d: Design, reset_undo: bool = False, prebuilt_plan=None): if reset_undo: self._undo_stack.clear() self._redo_stack.clear() + self._dirty = False + else: + self._dirty = True self._last_edit_key = None self._sync_hoop_machine_ui() self.rebuild_plan(prebuilt_plan=prebuilt_plan) @@ -798,6 +802,7 @@ def _sync_hoop_machine_ui(self): self.cmb_machine.blockSignals(False) def _snapshot(self, desc: str): + self._dirty = True self._last_edit_key = None self._undo_stack.append((desc, [o.snapshot() for o in self.canvas.design.objects])) if len(self._undo_stack) > 30: @@ -1099,8 +1104,28 @@ def _on_object_row_changed(self, row: int): self.canvas.update() def _on_design_modified(self, desc: str): + self._dirty = True self.rebuild_plan() + def _confirm_discard(self) -> bool: + """Fragt nach, bevor ungespeicherte Änderungen verworfen werden. True = weitermachen.""" + if not self._dirty or not self.isVisible(): + return True + ans = QMessageBox.question( + self, tr("dlg_unsaved_title"), tr("dlg_unsaved_text"), + QMessageBox.Save | QMessageBox.Discard | QMessageBox.Cancel, QMessageBox.Save, + ) + if ans == QMessageBox.Save: + return self.action_save_project() + return ans == QMessageBox.Discard + + def closeEvent(self, event): + if not self._confirm_discard(): + event.ignore() + return + self._planner.wait(5.0) + super().closeEvent(event) + def _on_cursor_moved(self, x: float, y: float): self.lbl_cursor.setText(f"X: {x:6.1f} mm, Y: {y:6.1f} mm") @@ -1208,6 +1233,8 @@ def dropEvent(self, event: QDropEvent): return self._remember_dir(path) ext = os.path.splitext(path)[1].lower().lstrip(".") + if (ext == "ostitch" or ext == "svg" or ext in READ_EXT) and not self._confirm_discard(): + return if ext == "ostitch": try: self.set_design(load_project(path), reset_undo=True) @@ -1234,9 +1261,13 @@ def dropEvent(self, event: QDropEvent): # -------------------------------------------------------- Datei-Aktionen def action_new(self): + if not self._confirm_discard(): + return self.set_design(Design(name="Neues_Design"), reset_undo=True) def action_open_project(self): + if not self._confirm_discard(): + return path = self._ask_open_file("Projekt öffnen", "OpenStitch-Projekt (*.ostitch);;Alle Dateien (*.*)") if not path: return @@ -1251,15 +1282,19 @@ def action_save_project(self): "Projekt speichern", f"{self.canvas.design.name}.ostitch", "OpenStitch-Projekt (*.ostitch)" ) if not path: - return + return False try: save_project(self.canvas.design, path) except Exception as exc: QMessageBox.critical(self, "Speicherfehler", str(exc)) - return + return False + self._dirty = False self.status.showMessage(f"Gespeichert: {path}", 5000) + return True def action_import_embroidery(self): + if not self._confirm_discard(): + return exts = " ".join(f"*.{e} *.{e.upper()}" for e in READ_EXT) path = self._ask_open_file("Stickdatei importieren", f"Stickdateien ({exts});;Alle Dateien (*.*)") if not path: @@ -1272,6 +1307,8 @@ def action_import_embroidery(self): QMessageBox.critical(self, "Import-Fehler", str(exc)) def action_import_svg(self): + if not self._confirm_discard(): + return path = self._ask_open_file("SVG-Vektorgrafik importieren", "SVG-Vektordatei (*.svg);;Alle Dateien (*.*)") if not path: return @@ -1560,7 +1597,7 @@ def _sim_step(self): self.slider_sim.setValue(cur) self.slider_sim.blockSignals(False) self.lbl_sim.setText(f"{cur:,} / {total:,}".replace(",", "'")) - self.canvas.invalidate_render_cache() + self.canvas.update() # sim_limit ist Teil des Cache-Schlüssels def _on_sim_slider(self, val: int): total = self.canvas.plan.stitch_count @@ -1570,10 +1607,10 @@ def _on_sim_slider(self, val: int): else: self.canvas.sim_limit = val self.lbl_sim.setText(f"{val:,} / {total:,}".replace(",", "'")) - self.canvas.invalidate_render_cache() + self.canvas.update() # sim_limit ist Teil des Cache-Schlüssels def _reset_sim(self): self.btn_play.setChecked(False) self.canvas.sim_limit = None self.slider_sim.setValue(self.canvas.plan.stitch_count) - self.canvas.invalidate_render_cache() + self.canvas.update() # sim_limit ist Teil des Cache-Schlüssels From 8d22d74ef19cab1847a3547e0c26f0081af5fc06 Mon Sep 17 00:00:00 2001 From: lbaumberger Date: Wed, 28 Oct 2026 15:55:12 +0100 Subject: [PATCH 05/18] Density guard: keep row-turn points instead of dropping them as micro-stitches Every stitch the guard removed from a filled circle sat on the outline: tatami row turns are ~spacing apart, below the 0.45 mm micro-stitch limit, so the edge became ragged. Direction changes > 60 degrees are now always kept; real micro-stitches on straight runs are still filtered. Co-Authored-By: Claude --- openstitch/engine/density_guard.py | 11 ++++++++++- tests/test_openstitch.py | 18 ++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/openstitch/engine/density_guard.py b/openstitch/engine/density_guard.py index 5a9a19f..f056a87 100644 --- a/openstitch/engine/density_guard.py +++ b/openstitch/engine/density_guard.py @@ -20,6 +20,15 @@ from ..core.stitchplan import Cmd, Stitch, StitchPlan +def _is_turn(a: tuple[float, float], s: Stitch, nxt: Stitch) -> bool: + """Wendepunkt (> 60° Richtungswechsel), z. B. Reihenwechsel im Tatami oder Satin-Kante. + + Solche Einstiche definieren die Kontur – wer sie als „Mikrostich“ entfernt, franst die Kante aus.""" + ux, uy = s.x - a[0], s.y - a[1] + vx, vy = nxt.x - s.x, nxt.y - s.y + return ux * vx + uy * vy < 0.5 * math.hypot(ux, uy) * math.hypot(vx, vy) + + def optimize_stitches_for_melco( stitches: list[Stitch], min_stitch_mm: float = 0.45, @@ -46,7 +55,7 @@ def optimize_stitches_for_melco( if last_xy is not None: d = math.hypot(s.x - last_xy[0], s.y - last_xy[1]) - if d < min_stitch_mm and not is_last_of_run: + if d < min_stitch_mm and not is_last_of_run and not _is_turn(last_xy, s, stitches[i + 1]): continue cell = (int(math.floor(s.x / cell_size_mm)), int(math.floor(s.y / cell_size_mm))) diff --git a/tests/test_openstitch.py b/tests/test_openstitch.py index db0c2d7..76f9541 100644 --- a/tests/test_openstitch.py +++ b/tests/test_openstitch.py @@ -293,3 +293,21 @@ def test_plan_is_built_in_background(): d = Design(objects=[o.snapshot() for o in win.canvas.design.objects], hoop=win.canvas.design.hoop) assert final.stitch_count == d.build_plan().stitch_count win.close() + + +def test_density_guard_keeps_fill_turn_points(): + """Reihenwende-Punkte am Rand sind keine Mikrostiche – sonst franst die Kontur aus.""" + import math + from openstitch.core.stitchplan import Cmd, Stitch + from openstitch.engine.density_guard import optimize_stitches_for_melco + zig = [Stitch(0, -1, Cmd.JUMP)] + for k in range(20): # Tatami-artige Reihen im Abstand 0.4 mm + y = k * 0.4 + xs = (0.0, 10.0) if k % 2 == 0 else (10.0, 0.0) + zig += [Stitch(xs[0], y), Stitch(5.0, y), Stitch(xs[1], y)] + out = optimize_stitches_for_melco(zig) + ends = [s for s in out if s.cmd == Cmd.STITCH and s.x in (0.0, 10.0)] + assert len(ends) == 40 + # echte Mikrostiche auf gerader Strecke werden weiterhin entfernt + line = [Stitch(0, 0, Cmd.JUMP), Stitch(1, 0), Stitch(1.1, 0), Stitch(2, 0), Stitch(3, 0)] + assert len(optimize_stitches_for_melco(line)) == 4 From 605da91f160e6889088d385732087cf7ed917310 Mon Sep 17 00:00:00 2001 From: lbaumberger Date: Wed, 28 Oct 2026 15:56:31 +0100 Subject: [PATCH 06/18] Satin underlay styles: centre walk, edge walk, zigzag, edge+zigzag New SatinObject.underlay_style (default 'auto'): <= 2 mm centre walk, <= 4.5 mm edge walk, wider edge walk + zigzag - like commercial auto-underlay. Underlay now starts at 1.2 mm column width (was 1.6). Old projects without the field load with 'auto'. Co-Authored-By: Claude --- openstitch/core/design.py | 7 ++++-- openstitch/engine/satin.py | 46 ++++++++++++++++++++++++++++++++------ tests/test_openstitch.py | 22 ++++++++++++++++++ 3 files changed, 66 insertions(+), 9 deletions(-) diff --git a/openstitch/core/design.py b/openstitch/core/design.py index 767a947..ca35ae6 100644 --- a/openstitch/core/design.py +++ b/openstitch/core/design.py @@ -14,7 +14,7 @@ from ..engine.fill import fill_runs, join_covered_runs from ..engine.geom import Pt, clean, dist, lines_of, main_axis_angle, polygons_of, directional_grow from ..engine.running import bean_stitch, running_stitch, tie_stitches -from ..engine.satin import satin_runs +from ..engine.satin import UNDERLAY_STYLES, satin_runs from .stitchplan import Cmd, ColorBlock, Stitch, StitchPlan, Thread IDENTITY = (1.0, 0.0, 0.0, 1.0, 0.0, 0.0) @@ -186,12 +186,14 @@ class SatinObject(EmbObject): max_stitch: float = 7.0 underlay: bool = True puff_3d: bool = False + underlay_style: str = "auto" kind: str = "satin" PARAMS = (("spacing", "Dichte (mm)", float, 0.2, 1.2, 0.05), ("pull_comp", "Zugkompensation (mm)", float, 0.0, 0.8, 0.05), ("max_stitch", "Max. Stich / Split (mm)", float, 4.0, 12.0, 0.5), ("underlay", "Unterlage", bool, 0, 0, 0), + ("underlay_style", "Unterlage-Art", UNDERLAY_STYLES, 0, 0, 0), ("puff_3d", "3D-Puff (Cap-Modus)", bool, 0, 0, 0)) def local_geom(self): @@ -204,7 +206,8 @@ def runs(self, start=None): eff_spacing = min(self.spacing, 0.25) if self.puff_3d else self.spacing eff_pc = max(self.pull_comp, 0.32) if self.puff_3d else self.pull_comp res = satin_runs( - g, eff_spacing, eff_pc, self.max_stitch, self.underlay, start, puff_3d=self.puff_3d + g, eff_spacing, eff_pc, self.max_stitch, self.underlay, start, puff_3d=self.puff_3d, + underlay_style=self.underlay_style, ) out = [] if not self.puff_3d and res.leftover is not None and not res.leftover.is_empty: diff --git a/openstitch/engine/satin.py b/openstitch/engine/satin.py index 9e68750..5de6dcc 100644 --- a/openstitch/engine/satin.py +++ b/openstitch/engine/satin.py @@ -298,11 +298,42 @@ def _zigzag(cross, max_stitch: float, short_stitch: bool = True) -> list[Pt]: return pts -def _underlay(cross, width_avg: float, spacing: float) -> list[Pt]: - """Unterlage: Mittellauf hin, bei breiteren Säulen Zickzack zurück.""" +UNDERLAY_STYLES = ["auto", "mitte", "kante", "zickzack", "kante+zickzack"] + + +def auto_underlay_style(width_avg: float) -> str: + """Wie kommerzielle Software: schmal → Mittellauf, mittel → Kantenlauf, breit → Kante + Zickzack.""" + if width_avg <= 2.0: + return "mitte" + if width_avg <= 4.5: + return "kante" + return "kante+zickzack" + + +def _edge_walk(cross, inset: float = 0.45) -> list[Pt]: + """Kantenlauf: entlang der linken Kante hin, entlang der rechten zurück (stabilisiert beide Ränder).""" + def ins(c, p): + d = dist(c, p) + if d < inset * 2: + return c + return (c[0] + (p[0] - c[0]) * (d - inset) / d, c[1] + (p[1] - c[1]) * (d - inset) / d) + left = [ins(c, L) for c, L, _ in cross] + right = [ins(c, R) for c, _, R in reversed(cross)] + return resample_path(left + right, 2.0) + + +def _underlay(cross, width_avg: float, spacing: float, style: str = "auto") -> list[Pt]: + """Unterlage je nach Stil: Mittellauf, Kantenlauf, Zickzack oder Kante + Zickzack.""" + if style == "auto": + style = auto_underlay_style(width_avg) + if style == "kante": + return _edge_walk(cross) centers = [c for c, _, _ in cross] - out = resample_path(centers, 2.0) - if width_avg > 3.0: + out = _edge_walk(cross) if style == "kante+zickzack" else resample_path(centers, 2.0) + if style == "kante+zickzack": + # Kantenlauf endet am Start → Zickzack läuft hin (Satin kommt dann zurück) + cross = list(reversed(cross)) + if style in ("zickzack", "kante+zickzack"): back = list(reversed(cross)) zz = [] step = max(1, int(round(2.2 / (spacing / 2)))) @@ -356,7 +387,8 @@ def _puff_perforation_underlay(cross) -> list[Pt]: def satin_runs(geom, spacing: float = 0.4, pull_comp: float = 0.15, max_stitch: float = 7.0, - underlay: bool = True, start: Pt | None = None, puff_3d: bool = False) -> SatinResult: + underlay: bool = True, start: Pt | None = None, puff_3d: bool = False, + underlay_style: str = "auto") -> SatinResult: res = SatinResult() leftovers = [] from .fill import _travel # lokale Abhängigkeit vermeiden @@ -412,8 +444,8 @@ def satin_runs(geom, spacing: float = 0.4, pull_comp: float = 0.15, max_stitch: seq.extend(_puff_perforation_underlay(cross)) seq.extend(_zigzag(cross, max_stitch=max(max_stitch, 10.0), short_stitch=False)) else: - if underlay and wavg > 1.6: - ul = _underlay(cross, wavg, spacing) + if underlay and wavg > 1.2: + ul = _underlay(cross, wavg, spacing, underlay_style) seq.extend(ul) # Satin startet dort, wo die Unterlage endet if dist(ul[-1], cross[0][0]) > dist(ul[-1], cross[-1][0]): diff --git a/tests/test_openstitch.py b/tests/test_openstitch.py index 76f9541..b0accdb 100644 --- a/tests/test_openstitch.py +++ b/tests/test_openstitch.py @@ -311,3 +311,25 @@ def test_density_guard_keeps_fill_turn_points(): # echte Mikrostiche auf gerader Strecke werden weiterhin entfernt line = [Stitch(0, 0, Cmd.JUMP), Stitch(1, 0), Stitch(1.1, 0), Stitch(2, 0), Stitch(3, 0)] assert len(optimize_stitches_for_melco(line)) == 4 + + +def test_satin_underlay_styles(): + from openstitch.engine.satin import auto_underlay_style + assert auto_underlay_style(1.5) == "mitte" + assert auto_underlay_style(3.5) == "kante" + assert auto_underlay_style(6.0) == "kante+zickzack" + t = Thread((20, 40, 200), "Blau") + counts = {} + for style in ("mitte", "kante", "zickzack", "kante+zickzack"): + o = SatinObject(t, geom=LineString([(-20, 0), (20, 0)]).buffer(3.0), underlay_style=style) + d = Design(objects=[o]) + plan = d.build_plan() + counts[style] = plan.stitch_count + assert plan.trim_count == 1 and _max_stitch_len(plan) <= d.settings.max_stitch + # Kantenlauf läuft hin und zurück → mehr Stiche als nur die Mitte + assert counts["kante"] > counts["mitte"] and counts["kante+zickzack"] > counts["kante"] + # alte Projekte ohne underlay_style laden weiter (Default "auto") + from openstitch.core.design import object_from_dict + dct = SatinObject(t, geom=Point(0, 0).buffer(2)).to_dict() + dct.pop("underlay_style") + assert object_from_dict(dct).underlay_style == "auto" From 4ec9381a944479815821e91aeef8b1c437615ccd Mon Sep 17 00:00:00 2001 From: lbaumberger Date: Wed, 28 Oct 2026 15:57:19 +0100 Subject: [PATCH 07/18] CI: test the oldest supported (3.10) and a current Python (3.12) Co-Authored-By: Claude --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ffe3ab1..8155c8c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, windows-latest, macos-latest] - python-version: ["3.11"] + python-version: ["3.10", "3.12"] steps: - name: Checkout repository From 50680f7922d2227936c4437e2857661a78fa6e83 Mon Sep 17 00:00:00 2001 From: lbaumberger Date: Wed, 28 Oct 2026 15:58:38 +0100 Subject: [PATCH 08/18] JEF export: write trims (pyembroidery drops them by default) pyembroidery's JEF writer skips TRIM commands unless settings trims=True, so Janome machines never cut between objects. Trims are now written as three zero-length jumps (Janome convention). Co-Authored-By: Claude --- openstitch/io/formats.py | 5 ++++- tests/test_openstitch.py | 13 +++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/openstitch/io/formats.py b/openstitch/io/formats.py index c4a9990..cba46cf 100644 --- a/openstitch/io/formats.py +++ b/openstitch/io/formats.py @@ -62,7 +62,10 @@ def export_plan(plan: StitchPlan, path: str, **kw) -> None: write_exp(plan, f, **kw) return pat = plan_to_pattern(plan) - pe.write(pat, path) + # pyembroidery lässt Trims im JEF-Writer standardmässig weg → Janome würde keinen Faden schneiden. + # Mit trims=True werden sie als 3 Null-Sprünge geschrieben (Janome-Konvention). + settings = {"trims": True} if ext == "jef" else None + pe.write(pat, path, settings) def import_file(path: str) -> Design: diff --git a/tests/test_openstitch.py b/tests/test_openstitch.py index b0accdb..b8f324f 100644 --- a/tests/test_openstitch.py +++ b/tests/test_openstitch.py @@ -333,3 +333,16 @@ def test_satin_underlay_styles(): dct = SatinObject(t, geom=Point(0, 0).buffer(2)).to_dict() dct.pop("underlay_style") assert object_from_dict(dct).underlay_style == "auto" + + +def test_jef_export_keeps_trims(tmp_path): + import pyembroidery as pe + d = Design() + t = Thread((200, 30, 30), "Rot") + for x in (-30, 0, 30): # drei getrennte Flächen gleicher Farbe → Trims dazwischen + d.objects.append(FillObject(t, geom=Point(x, 0).buffer(6))) + plan = d.build_plan() + path = str(tmp_path / "t.jef") + export_plan(plan, path) + raw = open(path, "rb").read() + assert raw.count(b"\x80\x02\x00\x00\x80\x02\x00\x00\x80\x02\x00\x00") >= plan.trim_count - 1 From f67512e8f1dbf38a979b13247a021194f2e36df7 Mon Sep 17 00:00:00 2001 From: lbaumberger Date: Wed, 28 Oct 2026 16:00:26 +0100 Subject: [PATCH 09/18] SVG importer fidelity and robust FAT12 floppy images SVG import (engine/pro_tools.py): - transform= on elements and ancestor groups (matrix/translate/scale/rotate/skewX/skewY, composed) - path parser: S/T/A (proper endpoint->center arcs), implicit repeats, "1.5.5"/"-1-2", compact arc flags; unknown commands end the path instead of desyncing tokens - style inheritance (fill/stroke/display/visibility/opacity, style= wins), default fill black, fill="none", gradient url() -> first stop colour; full CSS colour names, rgb(%), #rgba/#rrggbbaa - skip defs/clipPath/mask/symbol/marker/pattern/style/metadata; support (+symbol) - ellipse, line, rounded rect, polyline; fill-rule evenodd/nonzero (winding-based holes) - thin strokes -> bean run over all subpaths, wide strokes (>=1.2 mm) -> satin band - target_width_mm=None uses physical width/height/viewBox size FAT12 (io/fat12.py): - add_file is transactional (checks space + root slots first, ValueError, image unchanged) - Windows-style unique short names (NAME~1.EXT) with VFAT long-name entries - only the last dot of a real extension counts ("Logo v1.5" -> LOGOV15.EXP) - delete/read accept exact 8.3 names from list_files(), long names still work - 0-byte files allocate no cluster; cyclic/corrupt FAT chains raise ValueError Co-Authored-By: Claude --- openstitch/engine/pro_tools.py | 662 +++++++++++++++++++++++++-------- openstitch/io/fat12.py | 278 ++++++++++---- tests/test_fat12.py | 119 ++++++ tests/test_svg_import.py | 211 +++++++++++ 4 files changed, 1053 insertions(+), 217 deletions(-) create mode 100644 tests/test_fat12.py create mode 100644 tests/test_svg_import.py diff --git a/openstitch/engine/pro_tools.py b/openstitch/engine/pro_tools.py index 6ddf88f..95aee4f 100644 --- a/openstitch/engine/pro_tools.py +++ b/openstitch/engine/pro_tools.py @@ -46,32 +46,132 @@ def create_applique_objects(base_obj: EmbObject, border_width_mm: float = 3.2) - # =========================================================================== -# 2. SVG-Vektor-Importer (ohne Zusatzabhängigkeiten, liest path/rect/circle/ellipse/polygon) +# 2. SVG-Vektor-Importer (ohne Zusatzabhängigkeiten) +# path/rect/circle/ellipse/line/polyline/polygon/use, transform=, Stil-Vererbung, Einheiten # =========================================================================== -def _parse_svg_color(val: str | None) -> tuple[int, int, int] | None: - if not val or val.strip().lower() == "none": +# Vollständige CSS-Farbnamen (CSS Color Module Level 4) +_CSS_COLORS = {k: (int(v[0:2], 16), int(v[2:4], 16), int(v[4:6], 16)) for k, v in (p.split(":") for p in """ +aliceblue:f0f8ff antiquewhite:faebd7 aqua:00ffff aquamarine:7fffd4 azure:f0ffff beige:f5f5dc bisque:ffe4c4 +black:000000 blanchedalmond:ffebcd blue:0000ff blueviolet:8a2be2 brown:a52a2a burlywood:deb887 cadetblue:5f9ea0 +chartreuse:7fff00 chocolate:d2691e coral:ff7f50 cornflowerblue:6495ed cornsilk:fff8dc crimson:dc143c cyan:00ffff +darkblue:00008b darkcyan:008b8b darkgoldenrod:b8860b darkgray:a9a9a9 darkgreen:006400 darkgrey:a9a9a9 +darkkhaki:bdb76b darkmagenta:8b008b darkolivegreen:556b2f darkorange:ff8c00 darkorchid:9932cc darkred:8b0000 +darksalmon:e9967a darkseagreen:8fbc8f darkslateblue:483d8b darkslategray:2f4f4f darkslategrey:2f4f4f +darkturquoise:00ced1 darkviolet:9400d3 deeppink:ff1493 deepskyblue:00bfff dimgray:696969 dimgrey:696969 +dodgerblue:1e90ff firebrick:b22222 floralwhite:fffaf0 forestgreen:228b22 fuchsia:ff00ff gainsboro:dcdcdc +ghostwhite:f8f8ff gold:ffd700 goldenrod:daa520 gray:808080 green:008000 greenyellow:adff2f grey:808080 +honeydew:f0fff0 hotpink:ff69b4 indianred:cd5c5c indigo:4b0082 ivory:fffff0 khaki:f0e68c lavender:e6e6fa +lavenderblush:fff0f5 lawngreen:7cfc00 lemonchiffon:fffacd lightblue:add8e6 lightcoral:f08080 lightcyan:e0ffff +lightgoldenrodyellow:fafad2 lightgray:d3d3d3 lightgreen:90ee90 lightgrey:d3d3d3 lightpink:ffb6c1 +lightsalmon:ffa07a lightseagreen:20b2aa lightskyblue:87cefa lightslategray:778899 lightslategrey:778899 +lightsteelblue:b0c4de lightyellow:ffffe0 lime:00ff00 limegreen:32cd32 linen:faf0e6 magenta:ff00ff maroon:800000 +mediumaquamarine:66cdaa mediumblue:0000cd mediumorchid:ba55d3 mediumpurple:9370db mediumseagreen:3cb371 +mediumslateblue:7b68ee mediumspringgreen:00fa9a mediumturquoise:48d1cc mediumvioletred:c71585 +midnightblue:191970 mintcream:f5fffa mistyrose:ffe4e1 moccasin:ffe4b5 navajowhite:ffdead navy:000080 +oldlace:fdf5e6 olive:808000 olivedrab:6b8e23 orange:ffa500 orangered:ff4500 orchid:da70d6 palegoldenrod:eee8aa +palegreen:98fb98 paleturquoise:afeeee palevioletred:db7093 papayawhip:ffefd5 peachpuff:ffdab9 peru:cd853f +pink:ffc0cb plum:dda0dd powderblue:b0e0e6 purple:800080 rebeccapurple:663399 red:ff0000 rosybrown:bc8f8f +royalblue:4169e1 saddlebrown:8b4513 salmon:fa8072 sandybrown:f4a460 seagreen:2e8b57 seashell:fff5ee +sienna:a0522d silver:c0c0c0 skyblue:87ceeb slateblue:6a5acd slategray:708090 slategrey:708090 snow:fffafa +springgreen:00ff7f steelblue:4682b4 tan:d2b48c teal:008080 thistle:d8bfd8 tomato:ff6347 turquoise:40e0d0 +violet:ee82ee wheat:f5deb3 white:ffffff whitesmoke:f5f5f5 yellow:ffff00 yellowgreen:9acd32 +""".split())} + +_NUM_RE = re.compile(r"[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?") +_GRADIENT_GREY = (128, 128, 128) # Verlauf ohne lesbare Stopp-Farbe -> neutrales Grau +_IDENT = (1.0, 0.0, 0.0, 1.0, 0.0, 0.0) # SVG-Matrix (a, b, c, d, e, f) + + +def _parse_svg_color(val: str | None, gradients: dict | None = None, + current: tuple[int, int, int] | None = None) -> tuple[int, int, int] | None: + """Liest eine SVG/CSS-Farbe. None = keine Farbe ("none", "transparent"). + url(#verlauf) -> erste Stopp-Farbe des Verlaufs (sonst Grau), currentColor -> `current`.""" + if val is None: return None - v = val.strip().lower() - named = { - "black": (15, 15, 15), "white": (255, 255, 255), "red": (210, 25, 25), - "blue": (25, 75, 205), "green": (30, 150, 55), "yellow": (245, 205, 20), - "orange": (245, 125, 20), "purple": (130, 45, 165), "gray": (128, 128, 128), - "grey": (128, 128, 128), "navy": (20, 36, 77), "gold": (225, 175, 30), - } - if v in named: - return named[v] + raw = val.replace("!important", "").strip() + v = raw.lower() + if not v or v in ("none", "transparent"): + return None + if v.startswith("url("): + m = re.match(r"url\(\s*['\"]?#?([^)'\"]*)['\"]?\s*\)\s*(.*)", raw) + if m and gradients and m.group(1) in gradients: + return gradients[m.group(1)] + if m and m.group(2).strip(): # Fallback-Farbe hinter url(...) + return _parse_svg_color(m.group(2), None, current) + return _GRADIENT_GREY + if v == "currentcolor": + return current if current is not None else (0, 0, 0) + if v in _CSS_COLORS: + return _CSS_COLORS[v] if v.startswith("#"): h = v[1:] - if len(h) == 3: - h = "".join(c * 2 for c in h) - if len(h) >= 6: + if len(h) in (3, 4) and all(c in "0123456789abcdef" for c in h): + h = "".join(c * 2 for c in h[:3]) + if len(h) in (6, 8) and all(c in "0123456789abcdef" for c in h): return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)) - m = re.match(r"rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)", v) + m = re.match(r"rgba?\(([^)]*)\)", v) + if m: + parts = [p for p in re.split(r"[\s,/]+", m.group(1).strip()) if p][:3] + if len(parts) == 3: + try: + comps = [float(p[:-1]) * 255.0 / 100.0 if p.endswith("%") else float(p) for p in parts] + return tuple(int(round(max(0.0, min(255.0, c)))) for c in comps) + except ValueError: + pass + m = re.match(r"hsla?\(([^)]*)\)", v) if m: - return (int(m.group(1)), int(m.group(2)), int(m.group(3))) - return (30, 30, 30) + nums = [float(x) for x in _NUM_RE.findall(m.group(1))[:3]] + if len(nums) == 3: + import colorsys + r, g, b = colorsys.hls_to_rgb((nums[0] % 360) / 360.0, nums[2] / 100.0, nums[1] / 100.0) + return (int(round(r * 255)), int(round(g * 255)), int(round(b * 255))) + return (30, 30, 30) # unbekannte Angabe -> dunkelgrau statt Abbruch +# --------------------------------------------------------------- Transformationen +def _mat_mul(m, n): + """Matrixprodukt m·n (SVG-Reihenfolge: n wird zuerst angewendet).""" + a1, b1, c1, d1, e1, f1 = m + a2, b2, c2, d2, e2, f2 = n + return (a1 * a2 + c1 * b2, b1 * a2 + d1 * b2, a1 * c2 + c1 * d2, b1 * c2 + d1 * d2, + a1 * e2 + c1 * f2 + e1, b1 * e2 + d1 * f2 + f1) + + +def _parse_transform(s: str | None): + """transform="…" -> Matrix; matrix/translate/scale/rotate(a [cx cy])/skewX/skewY, auch mehrere.""" + m = _IDENT + for name, args in re.findall(r"([A-Za-z]+)\s*\(([^)]*)\)", s or ""): + v = [float(x) for x in _NUM_RE.findall(args)] + name = name.lower() + if not v: + continue + if name == "matrix" and len(v) >= 6: + t = tuple(v[:6]) + elif name == "translate": + t = (1.0, 0.0, 0.0, 1.0, v[0], v[1] if len(v) > 1 else 0.0) + elif name == "scale": + t = (v[0], 0.0, 0.0, v[1] if len(v) > 1 else v[0], 0.0, 0.0) + elif name == "rotate": + a = math.radians(v[0]) + t = (math.cos(a), math.sin(a), -math.sin(a), math.cos(a), 0.0, 0.0) + if len(v) >= 3: # um (cx, cy) drehen + t = _mat_mul(_mat_mul((1.0, 0.0, 0.0, 1.0, v[1], v[2]), t), (1.0, 0.0, 0.0, 1.0, -v[1], -v[2])) + elif name == "skewx": + t = (1.0, 0.0, math.tan(math.radians(v[0])), 1.0, 0.0, 0.0) + elif name == "skewy": + t = (1.0, math.tan(math.radians(v[0])), 0.0, 1.0, 0.0, 0.0) + else: + continue + m = _mat_mul(m, t) + return m + + +def _apply_mat(m, pts): + a, b, c, d, e, f = m + return [(a * x + c * y + e, b * x + d * y + f) for x, y in pts] + + +# --------------------------------------------------------------- Pfad-Parser def _sample_cubic(p0, p1, p2, p3, steps=10): out = [] for i in range(1, steps + 1): @@ -84,158 +184,417 @@ def _sample_cubic(p0, p1, p2, p3, steps=10): return out -def _parse_svg_path_d(d: str) -> list[tuple[list[tuple[float, float]], bool]]: - """Parst SVG-Pfadbefehle (M, L, H, V, C, Q, Z, absolut & relativ) in Punktlisten.""" - tokens = re.findall(r"[A-Za-z]|[-+]?\d*\.?\d+(?:[eE][-+]?\d+)?", d) - subpaths: list[tuple[list[tuple[float, float]], bool]] = [] - cur: list[tuple[float, float]] = [] - cx = cy = sx = sy = 0.0 - i = 0 - cmd = "M" - while i < len(tokens): - if tokens[i].isalpha(): - cmd = tokens[i] +def _sample_quad(p0, p1, p2, steps=12): + out = [] + for k in range(1, steps + 1): + t = k / steps + mt = 1.0 - t + out.append((mt * mt * p0[0] + 2 * mt * t * p1[0] + t * t * p2[0], + mt * mt * p0[1] + 2 * mt * t * p1[1] + t * t * p2[1])) + return out + + +def _sample_arc(x1, y1, rx, ry, phi_deg, large, sweep, x2, y2): + """Elliptischer Bogen (SVG-Endpunkt-Form) -> Punkte; Umrechnung in Mittelpunkt-Form nach SVG F.6.5/F.6.6.""" + if abs(x1 - x2) < 1e-12 and abs(y1 - y2) < 1e-12: + return [] + rx, ry = abs(rx), abs(ry) + if rx < 1e-12 or ry < 1e-12: + return [(x2, y2)] # Radius 0 -> gerade Linie + phi = math.radians(phi_deg % 360.0) + cp, sp = math.cos(phi), math.sin(phi) + dx, dy = (x1 - x2) / 2.0, (y1 - y2) / 2.0 + x1p, y1p = cp * dx + sp * dy, -sp * dx + cp * dy + lam = (x1p / rx) ** 2 + (y1p / ry) ** 2 + if lam > 1.0: # Radien zu klein -> hochskalieren + s = math.sqrt(lam) + rx, ry = rx * s, ry * s + num = rx * rx * ry * ry - rx * rx * y1p * y1p - ry * ry * x1p * x1p + den = rx * rx * y1p * y1p + ry * ry * x1p * x1p + coef = math.sqrt(max(0.0, num / den)) if den > 0 else 0.0 + if bool(large) == bool(sweep): + coef = -coef + cxp, cyp = coef * rx * y1p / ry, -coef * ry * x1p / rx + ccx = cp * cxp - sp * cyp + (x1 + x2) / 2.0 + ccy = sp * cxp + cp * cyp + (y1 + y2) / 2.0 + ux, uy = (x1p - cxp) / rx, (y1p - cyp) / ry + vx, vy = (-x1p - cxp) / rx, (-y1p - cyp) / ry + th1 = math.atan2(uy, ux) + dth = math.atan2(ux * vy - uy * vx, ux * vx + uy * vy) + if not sweep and dth > 0: + dth -= 2 * math.pi + elif sweep and dth < 0: + dth += 2 * math.pi + n = max(2, int(math.ceil(abs(dth) / (2 * math.pi / 72)))) # 5°-Schritte + out = [] + for k in range(1, n + 1): + t = th1 + dth * k / n + ex, ey = rx * math.cos(t), ry * math.sin(t) + out.append((cp * ex - sp * ey + ccx, sp * ex + cp * ey + ccy)) + out[-1] = (x2, y2) + return out + + +_PATH_ARGC = {"M": 2, "L": 2, "H": 1, "V": 1, "C": 6, "S": 4, "Q": 4, "T": 2, "A": 7, "Z": 0} + + +def _path_tokens(d: str) -> list[tuple[str, list[float]]]: + """Zerlegt path-d in (Befehl, Zahlen) inkl. impliziter Wiederholungen, '1.5.5', '-1-2' und + Bogen-Flags ohne Trenner ('a1 1 0 011 1'). Bei einem Syntaxfehler endet der Pfad dort (wie im Browser).""" + out: list[tuple[str, list[float]]] = [] + i, n = 0, len(d) + cmd = None + while True: + while i < n and d[i] in " \t\r\n,": + i += 1 + if i >= n: + break + if d[i].isalpha(): + if d[i].upper() not in _PATH_ARGC: + break # unbekannter Befehl -> Rest verwerfen statt Token-Strom zu verschieben + cmd = d[i] i += 1 - if cmd in ("Z", "z"): - if len(cur) >= 2: - subpaths.append((cur, True)) - cur = [] - cx, cy = sx, sy + if cmd in "Zz": + out.append((cmd, [])) continue - rel = cmd.islower() - c_up = cmd.upper() + elif cmd is None or cmd in "Zz": + break + args: list[float] = [] + for k in range(_PATH_ARGC[cmd.upper()]): + while i < n and d[i] in " \t\r\n,": + i += 1 + if cmd in "Aa" and k in (3, 4): # Flags: genau ein Zeichen 0/1 + if i < n and d[i] in "01": + args.append(float(d[i])) + i += 1 + continue + return out + m = _NUM_RE.match(d, i) + if not m: + return out + args.append(float(m.group())) + i = m.end() + out.append((cmd, args)) + if cmd in "Mm": # weitere Koordinaten nach M sind implizite L + cmd = "l" if cmd == "m" else "L" + return out - def nf(): - nonlocal i - v = float(tokens[i]) - i += 1 - return v - if c_up == "M": - x, y = nf(), nf() - if rel: - x += cx - y += cy +def _parse_svg_path_d(d: str) -> list[tuple[list[tuple[float, float]], bool]]: + """Parst SVG-Pfadbefehle (M L H V C S Q T A Z, absolut & relativ) in Punktlisten (Teilpfade).""" + subpaths: list[tuple[list[tuple[float, float]], bool]] = [] + cur: list[tuple[float, float]] = [] + cx = cy = sx = sy = 0.0 + ctrl = (0.0, 0.0) # letzter Kontrollpunkt (für S/T-Spiegelung) + prev = "" + for cmd, a in _path_tokens(d): + up = cmd.upper() + ox, oy = (cx, cy) if cmd != up else (0.0, 0.0) + if up == "Z": + if len(cur) >= 2: + subpaths.append((cur, True)) + cur = [] + cx, cy = sx, sy + prev = up + continue + if up == "M": if len(cur) >= 2: subpaths.append((cur, False)) - cx, cy = sx, sy = x, y + cx, cy = sx, sy = a[0] + ox, a[1] + oy cur = [(cx, cy)] - cmd = "l" if rel else "L" - elif c_up == "L": - x, y = nf(), nf() - if rel: - x += cx - y += cy - cx, cy = x, y - cur.append((cx, cy)) - elif c_up == "H": - x = nf() - cx = cx + x if rel else x - cur.append((cx, cy)) - elif c_up == "V": - y = nf() - cy = cy + y if rel else y - cur.append((cx, cy)) - elif c_up == "C": - x1, y1, x2, y2, x, y = nf(), nf(), nf(), nf(), nf(), nf() - if rel: - x1, y1, x2, y2, x, y = x1 + cx, y1 + cy, x2 + cx, y2 + cy, x + cx, y + cy - cur.extend(_sample_cubic((cx, cy), (x1, y1), (x2, y2), (x, y), 10)) - cx, cy = x, y - elif c_up == "Q": - x1, y1, x, y = nf(), nf(), nf(), nf() - if rel: - x1, y1, x, y = x1 + cx, y1 + cy, x + cx, y + cy - p0 = (cx, cy) - for k in range(1, 9): - t = k / 8.0 - mt = 1.0 - t - cur.append((mt * mt * p0[0] + 2 * mt * t * x1 + t * t * x, - mt * mt * p0[1] + 2 * mt * t * y1 + t * t * y)) - cx, cy = x, y - else: - i += 1 + prev = up + continue + if not cur: # Zeichenbefehl direkt nach Z: neuer Teilpfad am Startpunkt + cur = [(cx, cy)] + sx, sy = cx, cy + if up == "L": + x, y = a[0] + ox, a[1] + oy + cur.append((x, y)) + elif up == "H": + x, y = a[0] + ox, cy + cur.append((x, y)) + elif up == "V": + x, y = cx, a[0] + oy + cur.append((x, y)) + elif up in ("C", "S"): + if up == "C": + p1 = (a[0] + ox, a[1] + oy) + a = a[2:] + else: + p1 = (2 * cx - ctrl[0], 2 * cy - ctrl[1]) if prev in ("C", "S") else (cx, cy) + ctrl = (a[0] + ox, a[1] + oy) + x, y = a[2] + ox, a[3] + oy + cur.extend(_sample_cubic((cx, cy), p1, ctrl, (x, y), 16)) + elif up in ("Q", "T"): + if up == "Q": + ctrl = (a[0] + ox, a[1] + oy) + a = a[2:] + else: + ctrl = (2 * cx - ctrl[0], 2 * cy - ctrl[1]) if prev in ("Q", "T") else (cx, cy) + x, y = a[0] + ox, a[1] + oy + cur.extend(_sample_quad((cx, cy), ctrl, (x, y))) + else: # A + x, y = a[5] + ox, a[6] + oy + cur.extend(_sample_arc(cx, cy, a[0], a[1], a[2], a[3], a[4], x, y)) + cx, cy = x, y + prev = up if len(cur) >= 2: subpaths.append((cur, False)) return subpaths -def import_svg(path: str, target_width_mm: float = 95.0) -> Design: - """Importiert eine SVG-Vektordatei direkt als editierbare Stick-Objekte (Satin/Tatami/Run).""" +# --------------------------------------------------------------- Formen & Stil +_SVG_SKIP = {"defs", "clipPath", "mask", "symbol", "marker", "pattern", "style", "metadata", "title", + "desc", "script", "linearGradient", "radialGradient", "filter", "font", "foreignObject"} +_SVG_INHERITED = ("fill", "stroke", "stroke-width", "fill-rule", "visibility", "color", "fill-opacity", + "stroke-opacity", "stroke-linejoin", "stroke-linecap") +_UNIT_PX = {"": 1.0, "px": 1.0, "mm": 96 / 25.4, "cm": 96 / 2.54, "in": 96.0, "pt": 96 / 72, "pc": 16.0, + "em": 16.0, "ex": 8.0} +_XLINK_HREF = "{http://www.w3.org/1999/xlink}href" + + +def _local(tag) -> str: + return tag.split("}")[-1] if isinstance(tag, str) else "" + + +def _svg_len(val, default: float = 0.0) -> float: + """Längenangabe in SVG-Benutzereinheiten (px@96 dpi); % wird ignoriert.""" + m = re.match(r"\s*([-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?)\s*([a-z%]*)", str(val or "").lower()) + if not m or m.group(2) not in _UNIT_PX: + return default + return float(m.group(1)) * _UNIT_PX[m.group(2)] + + +def _svg_style(el) -> dict: + """Präsentationsattribute + style="…" eines Elements (style gewinnt).""" + st = {k: el.attrib[k].strip() for k in (*_SVG_INHERITED, "display", "opacity") if k in el.attrib} + for part in el.attrib.get("style", "").split(";"): + if ":" in part: + k, v = part.split(":", 1) + st[k.strip().lower()] = v.replace("!important", "").strip() + return st + + +def _shape_subpaths(tag: str, el): + """Element -> (Teilpfade, füllbar) in lokalen Koordinaten.""" + g = lambda k, dflt=0.0: _svg_len(el.attrib.get(k), dflt) # noqa: E731 + if tag == "path": + return _parse_svg_path_d(el.attrib.get("d", "")), True + if tag == "rect": + x, y, w, h = g("x"), g("y"), g("width"), g("height") + if w <= 0 or h <= 0: + return [], True + rx, ry = el.attrib.get("rx"), el.attrib.get("ry") + rx = _svg_len(rx if rx not in (None, "auto") else ry) + ry = _svg_len(ry if ry not in (None, "auto") else el.attrib.get("rx")) + rx, ry = min(max(rx, 0.0), w / 2), min(max(ry, 0.0), h / 2) + if rx <= 0 or ry <= 0: + return [([(x, y), (x + w, y), (x + w, y + h), (x, y + h)], True)], True + d = (f"M{x + rx},{y} H{x + w - rx} A{rx},{ry} 0 0 1 {x + w},{y + ry} V{y + h - ry} " + f"A{rx},{ry} 0 0 1 {x + w - rx},{y + h} H{x + rx} A{rx},{ry} 0 0 1 {x},{y + h - ry} " + f"V{y + ry} A{rx},{ry} 0 0 1 {x + rx},{y} Z") + return _parse_svg_path_d(d), True + if tag in ("circle", "ellipse"): + cx, cy = g("cx"), g("cy") + rx, ry = (g("r"), g("r")) if tag == "circle" else (g("rx"), g("ry")) + if tag == "ellipse": # fehlender Radius = der andere (SVG 2 "auto") + rx, ry = rx or ry, ry or rx + if rx <= 0 or ry <= 0: + return [], True + pts = [(cx + rx * math.cos(2 * math.pi * k / 72), cy + ry * math.sin(2 * math.pi * k / 72)) for k in range(72)] + return [(pts, True)], True + if tag == "line": + return [([(g("x1"), g("y1")), (g("x2"), g("y2"))], False)], False + if tag in ("polygon", "polyline"): + nums = [float(v) for v in _NUM_RE.findall(el.attrib.get("points", ""))] + pts = list(zip(nums[0::2], nums[1::2])) + return ([(pts, tag == "polygon")] if len(pts) >= 2 else []), True + return [], False + + +def _signed_area(pts) -> float: + return 0.5 * sum(x0 * y1 - x1 * y0 for (x0, y0), (x1, y1) in zip(pts, pts[1:] + pts[:1])) + + +def _fill_geometry(rings: list, rule: str): + """Füllfläche aus Teilpfaden. evenodd: XOR; nonzero: Vereinigung, aber ein vollständig enthaltener + Teilpfad mit entgegengesetztem Umlaufsinn (Windungszahl 0) wird zum Loch.""" + polys = [] + for pts in rings: + try: + p = Polygon(pts) + p = p if p.is_valid else p.buffer(0) + except Exception: + continue + if not p.is_empty and p.area > 0: + polys.append((p, 1 if _signed_area(pts) >= 0 else -1)) + if not polys: + return None + if rule == "evenodd": + acc = polys[0][0] + for p, _ in polys[1:]: + acc = acc.symmetric_difference(p) + return acc + polys.sort(key=lambda t: -t[0].area) # Eltern vor Kindern + acc = None + for i, (p, s) in enumerate(polys): + w_out = sum(s2 for p2, s2 in polys[:i] if p2.covers(p)) + w_in = w_out + s + if w_in != 0 and w_out == 0: + acc = p if acc is None else acc.union(p) + elif w_in == 0 and w_out != 0 and acc is not None: + acc = acc.difference(p) + return acc + + +def _svg_gradients(root) -> dict: + """id -> erste Stopp-Farbe von linear-/radialGradient (folgt href auf andere Verläufe).""" + by_id = {el.get("id"): el for el in root.iter() if _local(el.tag) in ("linearGradient", "radialGradient") + and el.get("id")} + out = {} + for gid, el in by_id.items(): + seen = set() + while el is not None and id(el) not in seen: + seen.add(id(el)) + stops = [s for s in el if _local(s.tag) == "stop"] + if stops: + st = _svg_style(stops[0]) + st.setdefault("stop-color", stops[0].get("stop-color", "black")) + col = _parse_svg_color(st["stop-color"]) + if col is not None: + out[gid] = col + break + ref = (el.get("href") or el.get(_XLINK_HREF) or "").lstrip("#") + el = by_id.get(ref) + return out + + +def _svg_mm_per_unit(root) -> float | None: + """Physische Grösse einer Benutzereinheit in mm aus width/height (+ viewBox), sonst None.""" + def mm(val): + m = re.match(r"\s*([-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?)\s*([a-z]*)\s*$", str(val or "").lower()) + if not m or m.group(2) not in _UNIT_PX or float(m.group(1)) <= 0: + return None + return float(m.group(1)) * _UNIT_PX[m.group(2)] * 25.4 / 96.0 + w_mm, h_mm = mm(root.get("width")), mm(root.get("height")) + vb = [float(v) for v in _NUM_RE.findall(root.get("viewBox", ""))] + if len(vb) == 4 and vb[2] > 0 and vb[3] > 0: + if w_mm: + return w_mm / vb[2] + if h_mm: + return h_mm / vb[3] + return None + return 25.4 / 96.0 if (w_mm or h_mm) else None + + +_CAPS = {"butt": 2, "square": 3, "round": 1} +_JOINS = {"miter": 2, "miter-clip": 2, "arcs": 1, "bevel": 3, "round": 1} + + +def import_svg(path: str, target_width_mm: float | None = 95.0) -> Design: + """Importiert eine SVG-Vektordatei direkt als editierbare Stick-Objekte (Satin/Tatami/Run). + target_width_mm=None: echte physische Grösse aus width/height/viewBox (falls angegeben, sonst 95 mm).""" tree = ET.parse(path) root = tree.getroot() - raw_items = [] # (fill_rgb, stroke_rgb, geom_or_line, is_closed) - - for el in root.iter(): - tag = el.tag.split("}")[-1] - style_dict = {} - if "style" in el.attrib: - for part in el.attrib["style"].split(";"): - if ":" in part: - k, v = part.split(":", 1) - style_dict[k.strip()] = v.strip() - fill_str = style_dict.get("fill", el.attrib.get("fill", "#222222")) - stroke_str = style_dict.get("stroke", el.attrib.get("stroke", "none")) - fill_rgb = _parse_svg_color(fill_str) - stroke_rgb = _parse_svg_color(stroke_str) - - if tag == "path" and "d" in el.attrib: - subs = _parse_svg_path_d(el.attrib["d"]) - poly_accum = None - for pts, closed in subs: - if (closed or (fill_rgb is not None and len(pts) >= 3)) and len(pts) >= 3: - try: - p = Polygon(pts).buffer(0) - poly_accum = p if poly_accum is None else poly_accum.symmetric_difference(p) - except Exception: - pass - elif len(pts) >= 2: - raw_items.append((None, stroke_rgb or fill_rgb or (20, 20, 20), LineString(pts), False)) - if poly_accum is not None and not poly_accum.is_empty: - raw_items.append((fill_rgb, stroke_rgb, poly_accum, True)) - elif tag == "rect": - x = float(el.attrib.get("x", 0)) - y = float(el.attrib.get("y", 0)) - w = float(el.attrib.get("width", 0)) - h = float(el.attrib.get("height", 0)) - if w > 0 and h > 0: - from shapely.geometry import box - raw_items.append((fill_rgb, stroke_rgb, box(x, y, x + w, y + h), True)) - elif tag == "circle": - cx = float(el.attrib.get("cx", 0)) - cy = float(el.attrib.get("cy", 0)) - r = float(el.attrib.get("r", 0)) - if r > 0: - from shapely.geometry import Point - raw_items.append((fill_rgb, stroke_rgb, Point(cx, cy).buffer(r), True)) - elif tag in ("polygon", "polyline") and "points" in el.attrib: - nums = [float(v) for v in re.findall(r"[-+]?\d*\.?\d+", el.attrib["points"])] - pts = list(zip(nums[0::2], nums[1::2])) - if tag == "polygon" and len(pts) >= 3: - raw_items.append((fill_rgb, stroke_rgb, Polygon(pts).buffer(0), True)) - elif len(pts) >= 2: - raw_items.append((None, stroke_rgb or fill_rgb or (20, 20, 20), LineString(pts), False)) + ids = {el.get("id"): el for el in root.iter() if isinstance(el.tag, str) and el.get("id")} + gradients = _svg_gradients(root) + raw_items = [] # (fill_rgb, fill_geom, stroke_rgb, [LineString], stroke_w, cap, join) in Dokumentreihenfolge + + def emit(subs, fillable, m, style, opacity): + a, b, c, dd, _, _ = m + det = a * dd - b * c + if abs(det) < 1e-12 or not subs: + return + tsubs = [(_apply_mat(m, pts), closed) for pts, closed in subs] + current = _parse_svg_color(style.get("color", "black")) + + def op(key): + try: + return float(style.get(key, "1").rstrip("%")) / (100.0 if style.get(key, "").endswith("%") else 1.0) + except ValueError: + return 1.0 + fill_rgb = _parse_svg_color(style.get("fill", "black"), gradients, current) if fillable else None + if opacity * op("fill-opacity") <= 0: + fill_rgb = None + stroke_rgb = _parse_svg_color(style.get("stroke", "none"), gradients, current) + sw = _svg_len(style.get("stroke-width", "1"), 1.0) * math.sqrt(abs(det)) + if opacity * op("stroke-opacity") <= 0 or sw <= 0: + stroke_rgb = None + fill_geom = None + if fill_rgb is not None: + fill_geom = _fill_geometry([p for p, _ in tsubs if len(p) >= 3], + style.get("fill-rule", "nonzero").strip().lower()) + if fill_geom is None or fill_geom.is_empty: + fill_rgb, fill_geom = None, None + lines = [] + if stroke_rgb is not None: + for pts, closed in tsubs: + pts = pts + [pts[0]] if closed and pts[0] != pts[-1] else pts + if len(pts) >= 2 and LineString(pts).length > 0: + lines.append(LineString(pts)) + if fill_rgb is None and not lines: + return + raw_items.append((fill_rgb, fill_geom, stroke_rgb if lines else None, lines, sw, + _CAPS.get(style.get("stroke-linecap", "butt"), 2), + _JOINS.get(style.get("stroke-linejoin", "miter"), 2))) + + def walk(el, ctm, inherited, opacity, active, as_group=False): + tag = _local(el.tag) + if not tag or (tag in _SVG_SKIP and not as_group) or len(active) > 32: + return + st = _svg_style(el) + if st.get("display", "").lower() == "none": + return + style = dict(inherited) + style.update({k: v for k, v in st.items() if k in _SVG_INHERITED and v != "inherit"}) + try: + opacity *= float(st.get("opacity", "1")) + except ValueError: + pass + m = _mat_mul(ctm, _parse_transform(el.get("transform"))) + if tag == "use": + ref = ids.get((el.get("href") or el.get(_XLINK_HREF) or "").strip().lstrip("#")) + if ref is None or id(ref) in active: + return + m = _mat_mul(m, (1.0, 0.0, 0.0, 1.0, _svg_len(el.get("x")), _svg_len(el.get("y")))) + walk(ref, m, style, opacity, active | {id(ref)}, as_group=_local(ref.tag) == "symbol") + return + if el is not root and tag == "svg": # verschachteltes : nur x/y-Versatz + m = _mat_mul(m, (1.0, 0.0, 0.0, 1.0, _svg_len(el.get("x")), _svg_len(el.get("y")))) + if tag in ("svg", "g", "a", "switch", "symbol"): + for ch in el: + walk(ch, m, style, opacity, active) + return + if style.get("visibility", "visible").lower() in ("hidden", "collapse") or opacity <= 0: + return + subs, fillable = _shape_subpaths(tag, el) + emit(subs, fillable, m, style, opacity) + + walk(root, _IDENT, {}, 1.0, frozenset()) if not raw_items: raise ValueError("Keine unterstützten Vektorpfade in der SVG-Datei gefunden.") - # Auf Zielbreite (mm) skalieren & zentrieren - all_geoms = [g for _, _, g, _ in raw_items if g is not None and not g.is_empty] - u = unary_union(all_geoms) - x0, y0, x1, y1 = u.bounds - scale = target_width_mm / max(x1 - x0, 1e-3) + # Auf Zielbreite (mm) bzw. physische Grösse skalieren & zentrieren + bnds = [g.bounds for it in raw_items for g in ([it[1]] if it[1] is not None else []) + it[3]] + x0, y0 = min(b[0] for b in bnds), min(b[1] for b in bnds) + x1, y1 = max(b[2] for b in bnds), max(b[3] for b in bnds) + mpu = _svg_mm_per_unit(root) if target_width_mm is None else None + scale = mpu if mpu else (target_width_mm or 95.0) / max(x1 - x0, 1e-3) cx, cy = (x0 + x1) / 2.0, (y0 + y1) / 2.0 from shapely import affinity + from shapely.geometry import MultiLineString from ..auto.digitize import _fast_stroke_width + def to_mm(geom): + return affinity.scale(affinity.translate(geom, -cx, -cy), xfact=scale, yfact=scale, origin=(0, 0)) + d = Design(name="SVG_Import") - for fill_rgb, stroke_rgb, geom, is_closed in raw_items: - g = affinity.translate(geom, -cx, -cy) - g = affinity.scale(g, xfact=scale, yfact=scale, origin=(0, 0)) - if is_closed and fill_rgb is not None: + for fill_rgb, fill_geom, stroke_rgb, lines, sw, cap, join in raw_items: + if fill_rgb is not None: # SVG malt erst die Füllung, dann die Kontur th = nearest_thread(fill_rgb) - for pg in polygons_of(clean(g, 0.2)): + for pg in polygons_of(clean(to_mm(fill_geom), 0.2)): typ, maxw = _fast_stroke_width(pg) if maxw <= 6.2 and pg.area < 450.0: d.objects.append(SatinObject(th, name="SVG-Satin", geom=pg)) @@ -244,9 +603,16 @@ def import_svg(path: str, target_width_mm: float = 95.0) -> Design: d.objects.append(FillObject(th, name="SVG-Fläche", geom=pg, angle=round(ang, 1))) if stroke_rgb is not None: th = nearest_thread(stroke_rgb) - line = g if isinstance(g, LineString) else LineString(polygons_of(g)[0].exterior.coords) if polygons_of(g) else None - if line is not None and line.length > 0.8: - d.objects.append(RunObject(th, name="SVG-Kontur", geom=line, repeats=1)) + mm_lines = [to_mm(l) for l in lines] + w_mm = sw * scale + if w_mm < 1.2: # dünne Kontur -> Bohnenstich + geom = mm_lines[0] if len(mm_lines) == 1 else MultiLineString(mm_lines) + if geom.length > 0.8: + d.objects.append(RunObject(th, name="SVG-Kontur", geom=geom, repeats=1)) + else: # breite Kontur -> Satinband entlang der Linie + band = clean(unary_union([l.buffer(w_mm / 2.0, cap_style=cap, join_style=join) for l in mm_lines]), 0.05) + if not band.is_empty: + d.objects.append(SatinObject(th, name="SVG-Kontur (Satin)", geom=band)) return d diff --git a/openstitch/io/fat12.py b/openstitch/io/fat12.py index bd25b30..c1dd1c2 100644 --- a/openstitch/io/fat12.py +++ b/openstitch/io/fat12.py @@ -2,6 +2,8 @@ Damit lassen sich .IMG-Dateien für USB-Disketten-Emulatoren (GoTek mit FlashFloppy, HxC) erzeugen oder bestehende Abbilder ergänzen, ohne Admin-Rechte oder Zusatztools. +Lange Dateinamen werden wie unter Windows als 8.3-Name (ggf. NAME~1.EXT) plus +VFAT-Langname abgelegt; die Maschine sieht nur den 8.3-Namen. """ from __future__ import annotations @@ -21,19 +23,41 @@ CLUSTERS = TOTAL_SECTORS - DATA_START # 2847 IMAGE_SIZE = TOTAL_SECTORS * SECTOR # 1 474 560 +_REAL_EXT = re.compile(r"(?=.*[A-Za-z])[A-Za-z0-9]{1,3}") # "exp", "u01" – aber nicht "5" aus "v1.5" +_LFN_BAD = re.compile(r'[\x00-\x1f\\/:*?"<>|]') + + +def _split_name(name: str, default_ext: str) -> tuple[str, str]: + """Trennt Basis und Endung: nur der LETZTE Punkt zählt, und nur bei einer echten Endung.""" + name = name.strip() + base, dot, ext = name.rpartition(".") + if not dot or not base.strip(". ") or not _REAL_EXT.fullmatch(ext): + return name, default_ext + return base, ext + + +def _clean83(s: str) -> str: + s = s.upper() + for k, v in {"Ä": "AE", "Ö": "OE", "Ü": "UE", "ß": "SS", "ẞ": "SS"}.items(): + s = s.replace(k, v) + return re.sub(r"[^A-Z0-9_\-]", "", s) + def dos_name(name: str, default_ext: str = "EXP") -> str: - """Erzeugt einen gültigen 8.3-Namen (Grossbuchstaben).""" - base, _, ext = name.rpartition(".") - if not base: - base, ext = ext, default_ext - repl = {"Ä": "AE", "Ö": "OE", "Ü": "UE", "ß": "SS"} - base = base.upper() - for k, v in repl.items(): - base = base.replace(k, v) - base = re.sub(r"[^A-Z0-9_\-]", "", base)[:8] or "DESIGN" - ext = re.sub(r"[^A-Z0-9]", "", ext.upper())[:3] or default_ext - return f"{base}.{ext}" + """Erzeugt einen gültigen 8.3-Namen (Grossbuchstaben, nur A-Z 0-9 _ -).""" + base, ext = _split_name(name, default_ext) + base = _clean83(base)[:8] or "DESIGN" + ext = _clean83(ext)[:3] or _clean83(default_ext)[:3] + return f"{base}.{ext}" if ext else base + + +def _long_name(name: str, default_ext: str = "EXP") -> str: + """Langer (VFAT-)Name wie vom Aufrufer gemeint; ohne echte Endung wird die Standardendung angehängt.""" + n = _LFN_BAD.sub("", name).strip().rstrip(". ") + base, _ = _split_name(n, default_ext) + if base == n and default_ext: + n = f"{n}.{default_ext.lower()}" + return n[:255] or dos_name(name, default_ext) def _83(name: str) -> bytes: @@ -41,6 +65,36 @@ def _83(name: str) -> bytes: return (b.ljust(8)[:8] + e.ljust(3)[:3]).encode("ascii") +def _lfn_checksum(short11: bytes) -> int: + s = 0 + for c in short11: + s = (((s & 1) << 7) + (s >> 1) + c) & 0xFF + return s + + +def _lfn_entries(long: str, short11: bytes) -> list[bytes]: + """VFAT-Langnamen-Einträge in Disk-Reihenfolge (letzter Teil zuerst), wie Windows sie schreibt.""" + raw = long.encode("utf-16-le") + units = [raw[i:i + 2] for i in range(0, len(raw), 2)] + n = -(-len(units) // 13) + if len(units) % 13: + units.append(b"\x00\x00") + units += [b"\xFF\xFF"] * (n * 13 - len(units)) + chk = _lfn_checksum(short11) + out = [] + for seq in range(n, 0, -1): + part = units[(seq - 1) * 13:seq * 13] + e = bytearray(32) + e[0] = seq | (0x40 if seq == n else 0) + e[1:11] = b"".join(part[0:5]) + e[11] = 0x0F + e[13] = chk + e[14:26] = b"".join(part[5:11]) + e[28:32] = b"".join(part[11:13]) + out.append(bytes(e)) + return out + + def _dos_datetime(t: _dt.datetime): date = ((t.year - 1980) << 9) | (t.month << 5) | t.day time = (t.hour << 11) | (t.minute << 5) | (t.second // 2) @@ -100,6 +154,19 @@ def _set_fat(self, n: int, val: int): self.data[off] = v & 0xFF self.data[off + 1] = (v >> 8) & 0xFF + def _chain(self, start: int, strict: bool = True) -> list[int]: + """Clusterkette ab `start`; begrenzt gegen Schleifen/ungültige Cluster aus fremden .IMG-Dateien.""" + out, seen, cl = [], set(), start + while 2 <= cl < 0xFF8: + if cl >= CLUSTERS + 2 or cl in seen: + if strict: + raise ValueError("Beschädigtes Abbild: FAT-Kette enthält eine Schleife oder einen ungültigen Cluster.") + break + seen.add(cl) + out.append(cl) + cl = self._get_fat(cl) + return out + def free_bytes(self) -> int: return sum(1 for c in range(2, CLUSTERS + 2) if self._get_fat(c) == 0) * SECTOR @@ -110,79 +177,152 @@ def _entries(self): off = base + i * 32 yield i, off, self.data[off:off + 32] - def list_files(self) -> list[tuple[str, int]]: - out = [] - for _, _, e in self._entries(): + def _scan(self) -> list[dict]: + """Alle Dateieinträge des Stammverzeichnisses inkl. VFAT-Langname und belegter Slots.""" + files, parts, lfn_slots, chk = [], {}, [], None + for i, off, e in self._entries(): if e[0] == 0: break - if e[0] == 0xE5 or e[11] & 0x08 or e[11] == 0x0F: + if e[0] == 0xE5: + parts, lfn_slots = {}, [] continue - name = e[0:8].decode("ascii", "replace").rstrip() - ext = e[8:11].decode("ascii", "replace").rstrip() - size = struct.unpack_from(" list[tuple[str, int]]: + """(8.3-Name, Grösse) – genau diese Namen akzeptieren read_file/delete_file unverändert.""" + return [(f["short"], f["size"]) for f in self._scan()] + + def _lookup(self, name: str, default_ext: str = "EXP", legacy: bool = True, files=None): + """Sucht einen Eintrag: 1. exakter 8.3-Name wie gelistet, 2. Langname (VFAT), + 3. (legacy) umgewandelter 8.3-Name bei Einträgen ohne Langname. -> (Eintrag, exakt?)""" + files = self._scan() if files is None else files + up = name.strip().upper() + for f in files: + if f["short"] == up: + return f, True + lng = _long_name(name, default_ext).casefold() + for f in files: + if f["long"] and f["long"].casefold() == lng: + return f, False + if legacy: + short = dos_name(name, default_ext) + for f in files: + if f["short"] == short and not f["long"]: + return f, False + return None, False def read_file(self, name: str) -> bytes: - key = _83(dos_name(name)) - for _, _, e in self._entries(): - if e[0] == 0: - break - if bytes(e[0:11]) == key and e[0] != 0xE5: - cl = struct.unpack_from(" bool: - key = _83(dos_name(name)) - for _, off, e in self._entries(): - if e[0] == 0: - break - if bytes(e[0:11]) == key: - cl = struct.unpack_from(" str: - name = dos_name(name) - self.delete_file(name) - need = max(1, -(-len(content) // SECTOR)) - free = [c for c in range(2, CLUSTERS + 2) if self._get_fat(c) == 0] - if len(free) < need: - raise IOError("Nicht genug Platz auf der Diskette (1.44 MB).") + """Löscht per 8.3-Name (wie von list_files geliefert) oder per Langname.""" + f, _ = self._lookup(name) + if f is None: + return False + self._remove(f) + return True + + def _unique_short(self, long: str, default_ext: str, files: list[dict]) -> str: + """8.3-Name wie bei Windows: gekürzter Name, bei Kollision NAME~1.EXT, ~2, …""" + taken = {f["short"] for f in files} + short = dos_name(long, default_ext) + if short not in taken: + return short + base, ext = _split_name(long, default_ext) + base = _clean83(base) or "DESIGN" + ext = _clean83(ext)[:3] + for n in range(1, 100000): + tail = f"~{n}" + cand = base[:8 - len(tail)] + tail + (f".{ext}" if ext else "") + if cand not in taken: + return cand + raise ValueError("Kein freier 8.3-Name mehr verfügbar.") + + def add_file(self, name: str, content: bytes, default_ext: str = "EXP") -> str: + """Legt eine Datei an (gleicher Name ersetzt die alte Datei) und liefert den 8.3-Namen. + Transaktional: reicht Platz oder Verzeichnis nicht, bleibt das Abbild unverändert (ValueError).""" + content = bytes(content) + files = self._scan() + old, exact = self._lookup(name, default_ext, legacy=False, files=files) + if old is not None: + short = old["short"] + long = short if exact else old["long"] + else: + long = _long_name(name, default_ext) + short = self._unique_short(long, default_ext, files) + lfn = _lfn_entries(long, _83(short)) if long.upper() != short else [] + + # 1. Platz prüfen (Cluster der ersetzten Datei zählen als frei) + old_chain = self._chain(old["cluster"], strict=False) if old else [] + free = sorted({c for c in range(2, CLUSTERS + 2) if self._get_fat(c) == 0} | set(old_chain)) + need = -(-len(content) // SECTOR) + if need > len(free): + raise ValueError(f"Nicht genug Platz auf der Diskette: {short} braucht {need * SECTOR:,} Bytes, " + f"frei sind nur {len(free) * SECTOR:,} Bytes.".replace(",", "'")) + # 2. zusammenhängende freie Verzeichnis-Slots suchen (Langname-Teile + 8.3-Eintrag) + old_slots = set(old["slots"]) if old else set() + usable, ended = [], False + for i, _, e in self._entries(): + ended = ended or e[0] == 0 + usable.append(ended or e[0] == 0xE5 or i in old_slots) + want = len(lfn) + 1 + first = next((i for i in range(ROOT_ENTRIES - want + 1) if all(usable[i:i + want])), None) + if first is None: + raise ValueError(f"Stammverzeichnis voll (max. {ROOT_ENTRIES} Einträge): {short} wurde nicht gespeichert.") + + # 3. ab hier kann nichts mehr scheitern: alte Datei entfernen, Daten schreiben + if old: + self._remove(old) chain = free[:need] for i, c in enumerate(chain): self._set_fat(c, chain[i + 1] if i + 1 < len(chain) else 0xFFF) s = (DATA_START + c - 2) * SECTOR - chunk = content[i * SECTOR:(i + 1) * SECTOR] - self.data[s:s + SECTOR] = chunk.ljust(SECTOR, b"\x00") - slot = None - for _, off, e in self._entries(): - if e[0] in (0x00, 0xE5): - slot = off - break - if slot is None: - raise IOError("Stammverzeichnis voll (max. 224 Dateien).") + self.data[s:s + SECTOR] = content[i * SECTOR:(i + 1) * SECTOR].ljust(SECTOR, b"\x00") e = bytearray(32) - e[0:11] = _83(name) + e[0:11] = _83(short) e[11] = 0x20 # Archiv date, time = _dos_datetime(_dt.datetime.now()) - e[13] = 0 - struct.pack_into(" bytes: return bytes(self.data) diff --git a/tests/test_fat12.py b/tests/test_fat12.py new file mode 100644 index 0000000..097586e --- /dev/null +++ b/tests/test_fat12.py @@ -0,0 +1,119 @@ +"""Tests für das FAT12-Diskettenabbild (8.3-Namen, Transaktionen, defekte Abbilder).""" +import struct + +import pytest + +from openstitch.io.fat12 import CLUSTERS, IMAGE_SIZE, ROOT_ENTRIES, ROOT_START, SECTOR, Fat12Image, dos_name + + +def test_dos_name_extension_and_chars(): + assert dos_name("mein_logo.exp") == "MEIN_LOG.EXP" + assert dos_name("Logo v1.5") == "LOGOV15.EXP" # ".5" ist keine Endung + assert dos_name("Logo v1.5.dst") == "LOGOV15.DST" # nur der letzte Punkt zählt + assert dos_name("Größe:*?.exp") == "GROESSE.EXP" + assert dos_name("design") == "DESIGN.EXP" + assert dos_name("x.pes", "DST") == "X.PES" + assert dos_name("noext", "DST") == "NOEXT.DST" + assert dos_name("???.exp") == "DESIGN.EXP" + + +def test_unique_short_names_and_replace(): + img = Fat12Image() + a = img.add_file("Logo_Front_A.exp", b"A" * 700) + b = img.add_file("Logo_Front_B.exp", b"B" * 700) + c = img.add_file("Logo_Front_C.exp", b"C" * 10) + assert (a, b, c) == ("LOGO_FRO.EXP", "LOGO_F~1.EXP", "LOGO_F~2.EXP") + assert [n for n, _ in img.list_files()] == ["LOGO_FRO.EXP", "LOGO_F~1.EXP", "LOGO_F~2.EXP"] + # gleicher Langname ersetzt (auch nach Neuladen des Abbilds) + img = Fat12Image(img.to_bytes()) + free_before = img.free_bytes() + assert img.add_file("Logo_Front_B.exp", b"b" * 100) == "LOGO_F~1.EXP" + assert len(img.list_files()) == 3 + assert img.free_bytes() == free_before + SECTOR # 2 Cluster -> 1 Cluster + assert img.read_file("Logo_Front_B.exp") == b"b" * 100 + assert img.read_file("LOGO_F~1.EXP") == b"b" * 100 + assert img.read_file("Logo_Front_A.exp") == b"A" * 700 + # gleicher 8.3-Name ersetzt ebenfalls + assert img.add_file("LOGO_FRO.EXP", b"x") == "LOGO_FRO.EXP" + assert img.read_file("LOGO_FRO.EXP") == b"x" + assert len(img.list_files()) == 3 + + +def test_delete_accepts_listed_names(): + img = Fat12Image() + img.add_file("Logo_Front_A.exp", b"1") + img.add_file("Logo_Front_B.exp", b"2") + # Eintrag ohne Endung (z. B. von anderem Gerät geschrieben) direkt anlegen + e = bytearray(32) + e[0:11] = b"README " + e[11] = 0x20 + slot = next(i for i in range(ROOT_ENTRIES) if img.data[ROOT_START * SECTOR + 32 * i] == 0) + off = ROOT_START * SECTOR + 32 * slot + img.data[off:off + 32] = e + names = [n for n, _ in img.list_files()] + assert "README" in names + for n in names: + assert img.delete_file(n) is True, n + assert img.list_files() == [] + assert img.free_bytes() == CLUSTERS * SECTOR + # Langname funktioniert weiterhin + img.add_file("Mein langer Name.exp", b"z") + assert img.delete_file("Mein langer Name.exp") is True + assert img.delete_file("gibts_nicht.exp") is False + + +def test_add_file_is_transactional_when_full(): + img = Fat12Image() + img.add_file("keep.exp", b"K" * 1000) + big = b"\x00" * (CLUSTERS * SECTOR + 1) + snap = img.to_bytes() + with pytest.raises(ValueError, match="Nicht genug Platz"): + img.add_file("keep.exp", big) # Ersetzen scheitert -> alte Datei bleibt + assert img.to_bytes() == snap + assert img.read_file("KEEP.EXP") == b"K" * 1000 + + # Verzeichnis voll: 222 weitere Einträge (Slot 0 = Volume-Label, 1 = KEEP.EXP) + for i in range(ROOT_ENTRIES - 2): + img.add_file(f"F{i}.EXP", b"") + snap = img.to_bytes() + with pytest.raises(ValueError, match="Stammverzeichnis voll"): + img.add_file("NEU.EXP", b"neu") + assert img.to_bytes() == snap + # Ersetzen derselben Datei geht weiter (Slot wird wiederverwendet) + img.add_file("F5.EXP", b"neu") + assert img.read_file("F5.EXP") == b"neu" + + +def test_zero_byte_file_allocates_no_cluster(): + img = Fat12Image() + free = img.free_bytes() + assert img.add_file("leer.exp", b"") == "LEER.EXP" + assert img.free_bytes() == free + e = img.data[ROOT_START * SECTOR + 32:ROOT_START * SECTOR + 64] + assert struct.unpack_from(" 3 -> 4 -> 2 … + with pytest.raises(ValueError, match="Schleife"): + img2.read_file("LOOP.EXP") + img2._set_fat(4, 0xEEE) # ungültiger Cluster ausserhalb des Datenbereichs + with pytest.raises(ValueError): + img2.read_file("LOOP.EXP") + assert img2.delete_file("LOOP.EXP") is True # Löschen bleibt robust + assert len(img2.to_bytes()) == IMAGE_SIZE + + +def test_lfn_entries_are_hidden_from_listing(): + img = Fat12Image() + img.add_file("ein sehr langer Dateiname für die Stickmaschine.exp", b"data") + assert img.list_files() == [("EINSEHRL.EXP", 4)] + # Langname-Einträge tragen Attribut 0x0F (für DOS/Melco unsichtbar) + first = ROOT_START * SECTOR + 32 + assert img.data[first + 11] == 0x0F diff --git a/tests/test_svg_import.py b/tests/test_svg_import.py new file mode 100644 index 0000000..9421e53 --- /dev/null +++ b/tests/test_svg_import.py @@ -0,0 +1,211 @@ +"""Tests für den SVG-Importer (transform, Pfadbefehle, Stil-Vererbung, Füllregeln, Einheiten).""" +import math + +import pytest +from shapely.ops import unary_union + +from openstitch.auto.palette import nearest_thread +from openstitch.core.design import FillObject, RunObject, SatinObject +from openstitch.engine.pro_tools import _parse_svg_color, _parse_svg_path_d, import_svg + +# 1 Benutzereinheit = 1 mm, damit Flächen/Grössen direkt prüfbar sind +MM_ROOT = 'width="200mm" height="200mm" viewBox="0 0 200 200"' + + +def _svg(tmp_path, body, root_attrs=MM_ROOT, name="t.svg"): + p = tmp_path / name + p.write_text( + f'' + f"{body}", encoding="utf-8") + return str(p) + + +def _load(tmp_path, body, **kw): + kw.setdefault("target_width_mm", None) + return import_svg(_svg(tmp_path, body), **kw) + + +def _areas(d): + return [o.geom for o in d.objects if isinstance(o, (FillObject, SatinObject))] + + +def _wh(g): + x0, y0, x1, y1 = g.bounds + return x1 - x0, y1 - y0 + + +def test_translated_and_nested_groups(tmp_path): + d = _load(tmp_path, '' + '' + '') + a, b, c = _areas(d) + assert b.centroid.x - a.centroid.x == pytest.approx(50, abs=1e-6) + assert b.centroid.y - a.centroid.y == pytest.approx(20, abs=1e-6) + assert _wh(c) == pytest.approx((20, 20), abs=1e-6) + assert c.centroid.y - a.centroid.y == pytest.approx(65, abs=1e-6) + + +def test_rotated_rect_and_default_scaling(tmp_path): + body = '' + (g,) = _areas(_load(tmp_path, body)) + assert _wh(g) == pytest.approx((10, 40), abs=1e-6) + assert g.area == pytest.approx(400, rel=1e-6) + # Standard: auf 95 mm Breite skaliert (bisheriges Verhalten) + (g95,) = _areas(import_svg(_svg(tmp_path, body, name="d.svg"))) + assert _wh(g95) == pytest.approx((95, 380), rel=1e-6) + + +def test_arc_circle_area(tmp_path): + d = _load(tmp_path, '') + (g,) = _areas(d) + assert g.area == pytest.approx(math.pi * 100, rel=0.03) + assert _wh(g) == pytest.approx((20, 20), abs=0.05) + # kompakte Flags ohne Trenner + relative Bögen: Halbkreis-Paar + subs = _parse_svg_path_d("M0 0a10 10 0 0120 0a10 10 0 01-20 0z") + from shapely.geometry import Polygon + assert Polygon(subs[0][0]).area == pytest.approx(math.pi * 100, rel=0.03) + + +def test_smooth_curves_s_and_t(): + s = _parse_svg_path_d("M0 0 C0 20 20 20 20 0 S40 -20 40 0") + c = _parse_svg_path_d("M0 0 C0 20 20 20 20 0 C20 -20 40 -20 40 0") + assert s[0][0] == pytest.approx(c[0][0]) + t = _parse_svg_path_d("M0 0 Q10 20 20 0 T40 0 t20 0") + q = _parse_svg_path_d("M0 0 Q10 20 20 0 Q30 -20 40 0 Q50 20 60 0") + assert t[0][0] == pytest.approx(q[0][0]) + # S ohne vorheriges C: erster Kontrollpunkt = aktueller Punkt + s2 = _parse_svg_path_d("M0 0 S10 10 20 0") + c2 = _parse_svg_path_d("M0 0 C0 0 10 10 20 0") + assert s2[0][0] == pytest.approx(c2[0][0]) + + +def test_path_number_syntax_and_unknown_command(): + (pts, closed), = _parse_svg_path_d("M1.5.5L-1-2 3e1,0z") + assert closed and pts == pytest.approx([(1.5, 0.5), (-1, -2), (30, 0)]) + # implizit wiederholte Koordinaten bei allen Befehlen + (pts, _), = _parse_svg_path_d("m0 0 10 0 0 10 h5 5 v5 5") + assert pts == pytest.approx([(0, 0), (10, 0), (10, 10), (15, 10), (20, 10), (20, 15), (20, 20)]) + # unbekannter Befehl beendet den Pfad statt den Token-Strom zu verschieben + (pts, _), = _parse_svg_path_d("M0 0 L10 0 X 99 99 L5 5") + assert pts == pytest.approx([(0, 0), (10, 0)]) + + +def test_fill_none_with_stroke_run_and_satin(tmp_path): + d = _load(tmp_path, '') + assert len(d.objects) == 1 + run = d.objects[0] + assert isinstance(run, RunObject) + assert run.geom.length == pytest.approx(400, rel=1e-6) # beide Teilpfade, nicht nur der erste + assert d.build_plan().stitch_count > 100 + d2 = _load(tmp_path, '') + (sat,) = d2.objects + assert isinstance(sat, SatinObject) + assert _wh(sat.geom) == pytest.approx((50, 4), abs=1e-6) + assert d2.build_plan().stitch_count > 50 + + +def test_display_none_and_visibility(tmp_path): + d = _load(tmp_path, '' + '' + '' + '' + '') + (g,) = _areas(d) + assert _wh(g) == pytest.approx((10, 10)) + + +def test_defs_and_non_rendered_containers_skipped(tmp_path): + hidden = ('' + '' + '' + '') + with pytest.raises(ValueError): + _load(tmp_path, hidden) + d = _load(tmp_path, hidden + '') + (g,) = _areas(d) + assert g.area == pytest.approx(math.pi * 225, rel=0.02) + + +def test_use_href_and_symbol(tmp_path): + d = _load(tmp_path, '' + '' + '' + '' + '') + a, b, c = _areas(d) + assert _wh(a) == pytest.approx((10, 10)) and _wh(b) == pytest.approx((10, 10)) + assert b.centroid.x - a.centroid.x == pytest.approx(-30) + assert b.centroid.y - a.centroid.y == pytest.approx(40) + assert c.centroid.x - a.centroid.x == pytest.approx(30, abs=0.05) + assert d.objects[0].thread == nearest_thread((0, 128, 0)) + + +def test_fill_rules(tmp_path): + outer = "M0 0 H40 V40 H0 Z" + inner_same = "M10 10 H30 V30 H10 Z" + inner_rev = "M10 10 V30 H30 V10 Z" + # evenodd: gleicher Umlaufsinn -> Loch + (g,) = _areas(_load(tmp_path, f'')) + assert g.area == pytest.approx(1200) + # nonzero, gleicher Umlaufsinn -> kein Loch + (g,) = _areas(_load(tmp_path, f'')) + assert g.area == pytest.approx(1600) + # nonzero, entgegengesetzter Umlaufsinn -> Loch; Insel im Loch wieder gefüllt + (g,) = _areas(_load(tmp_path, f'')) + assert g.area == pytest.approx(1200) + assert len(g.interiors) == 1 + gs = _areas(_load(tmp_path, f'')) + assert sum(x.area for x in gs) == pytest.approx(1300) + # nonzero: überlappende Teilpfade werden vereinigt (nicht XOR) + (g,) = _areas(_load(tmp_path, '')) + assert g.area == pytest.approx(600) + + +def test_colours_and_style_inheritance(tmp_path): + assert _parse_svg_color("cornflowerblue") == (100, 149, 237) + assert _parse_svg_color("RebeccaPurple") == (102, 51, 153) + assert _parse_svg_color("darkslategrey") == (47, 79, 79) + assert _parse_svg_color("#0f0") == (0, 255, 0) + assert _parse_svg_color("#11223380") == (0x11, 0x22, 0x33) + assert _parse_svg_color("rgb(100%, 50%, 0%)") == (255, 128, 0) + assert _parse_svg_color("rgb(10 20 30)") == (10, 20, 30) + assert _parse_svg_color("none") is None + assert _parse_svg_color("url(#nix)") == (128, 128, 128) + d = _load(tmp_path, + '' + '' + '' + '' + '' + '' + '') + cols = [o.thread for o in d.objects] + assert cols == [nearest_thread(c) for c in [(255, 165, 0), (0, 0, 128), (0, 128, 128), (0, 0, 0), (255, 0, 0)]] + + +def test_more_shapes(tmp_path): + d = _load(tmp_path, '' + '' + '' + '') + ell, rr = _areas(d) + assert ell.area == pytest.approx(math.pi * 300, rel=0.01) + assert rr.area == pytest.approx(40 * 30 - (4 - math.pi) * 25, rel=0.01) + runs = [o for o in d.objects if isinstance(o, RunObject)] + assert [r.geom.length for r in runs] == pytest.approx([80, 60]) + + +def test_physical_size_and_document_order(tmp_path): + body = '' + p = _svg(tmp_path, body, root_attrs='width="50mm" height="20mm" viewBox="0 0 250 100"') + d = import_svg(p, target_width_mm=None) + g = unary_union(_areas(d)) + assert _wh(g) == pytest.approx((50, 20), rel=1e-6) + assert [o.thread for o in d.objects][0] == nearest_thread((255, 0, 0)) + assert d.objects[-1].thread == nearest_thread((0, 0, 255)) # später = obenauf + assert _wh(unary_union(_areas(import_svg(p)))) == pytest.approx((95, 38), rel=1e-6) + # Zoll / Pixel ohne viewBox + p2 = _svg(tmp_path, '', root_attrs='width="96" height="96"', name="px.svg") + assert _wh(unary_union(_areas(import_svg(p2, target_width_mm=None)))) == pytest.approx((25.4, 12.7), rel=1e-6) + p3 = _svg(tmp_path, '', root_attrs='width="2in" height="1in" viewBox="0 0 2 1"', name="in.svg") + assert _wh(unary_union(_areas(import_svg(p3, target_width_mm=None)))) == pytest.approx((50.8, 25.4), rel=1e-6) From 2cc13a68e6acc3c9c3afce47570534929dfdb9d3 Mon Sep 17 00:00:00 2001 From: lbaumberger Date: Wed, 28 Oct 2026 16:03:06 +0100 Subject: [PATCH 10/18] SVG import: cap element expansion against nested bombs A crafted SVG with nested references doubles at every level and would hang the import. The tree walk now stops with a ValueError after 50k visited elements. Co-Authored-By: Claude --- openstitch/engine/pro_tools.py | 9 +++++++++ tests/test_svg_import.py | 19 +++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/openstitch/engine/pro_tools.py b/openstitch/engine/pro_tools.py index 95aee4f..bb11f81 100644 --- a/openstitch/engine/pro_tools.py +++ b/openstitch/engine/pro_tools.py @@ -491,6 +491,9 @@ def mm(val): _JOINS = {"miter": 2, "miter-clip": 2, "arcs": 1, "bevel": 3, "round": 1} +_SVG_MAX_ELEMENTS = 50_000 + + def import_svg(path: str, target_width_mm: float | None = 95.0) -> Design: """Importiert eine SVG-Vektordatei direkt als editierbare Stick-Objekte (Satin/Tatami/Run). target_width_mm=None: echte physische Grösse aus width/height/viewBox (falls angegeben, sonst 95 mm).""" @@ -538,10 +541,16 @@ def op(key): _CAPS.get(style.get("stroke-linecap", "butt"), 2), _JOINS.get(style.get("stroke-linejoin", "miter"), 2))) + # Budget gegen „Billion Laughs“-SVGs: verschachtelte können sich exponentiell vervielfachen + visited = [0] + def walk(el, ctm, inherited, opacity, active, as_group=False): tag = _local(el.tag) if not tag or (tag in _SVG_SKIP and not as_group) or len(active) > 32: return + visited[0] += 1 + if visited[0] > _SVG_MAX_ELEMENTS: + raise ValueError("SVG ist zu komplex (zu viele verschachtelte Elemente / -Kopien).") st = _svg_style(el) if st.get("display", "").lower() == "none": return diff --git a/tests/test_svg_import.py b/tests/test_svg_import.py index 9421e53..867bfb1 100644 --- a/tests/test_svg_import.py +++ b/tests/test_svg_import.py @@ -209,3 +209,22 @@ def test_physical_size_and_document_order(tmp_path): assert _wh(unary_union(_areas(import_svg(p2, target_width_mm=None)))) == pytest.approx((25.4, 12.7), rel=1e-6) p3 = _svg(tmp_path, '', root_attrs='width="2in" height="1in" viewBox="0 0 2 1"', name="in.svg") assert _wh(unary_union(_areas(import_svg(p3, target_width_mm=None)))) == pytest.approx((50.8, 25.4), rel=1e-6) + + +def test_use_bomb_is_rejected(tmp_path): + """Exponentiell verschachtelte -Referenzen dürfen den Import nicht aufhängen.""" + import time + from openstitch.engine.pro_tools import import_svg + parts = ['', + ''] + for i in range(1, 30): + parts.append(f'') + parts.append('') + f = tmp_path / "bomb.svg" + f.write_text("".join(parts), encoding="utf-8") + t = time.perf_counter() + try: + import_svg(str(f)) + except ValueError: + pass + assert time.perf_counter() - t < 10 From 849d86744f1866219a687f8403b663b6bd085280 Mon Sep 17 00:00:00 2001 From: lbaumberger Date: Wed, 28 Oct 2026 16:13:37 +0100 Subject: [PATCH 11/18] =?UTF-8?q?=EF=BB=BFTranslate=20remaining=20UI=20str?= =?UTF-8?q?ings=20(dialogs,=20main=20window,=20inspector,=20PDF)=20into=20?= =?UTF-8?q?DE/EN/FR/ES?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - dialogs.py: all titles, labels, buttons, combo items, message boxes and auto-digitize progress messages (the engine's German messages are mapped to keys in the dialog, so the engine is untouched) - main_window.py: message boxes, status bar, file dialogs/filters, tooltips, object-list kind labels, needle list, unsaved-changes and scale dialogs; the rail zoom tool now says "fit to hoop" in all four languages - Inspector: PARAMS labels and list option values translated at display time (param__ / param_, opt_); stored data values unchanged - Hoop strategy descriptions, hoop and machine display names translatable; Hoop.name / MachineProfile.name stay as stored keys - PDF production sheet translated - fmt_int()/fmt_float() replace the hard-coded Swiss separators - tests/test_i18n_coverage.py: key existence scan, table completeness and placeholder consistency, no German text in EN/FR/ES main window, inspector and dialogs Co-Authored-By: Claude --- openstitch/core/design.py | 35 ++- openstitch/i18n.py | 585 ++++++++++++++++++++++++++++++++++- openstitch/io/project.py | 31 +- openstitch/ui/dialogs.py | 210 +++++++------ openstitch/ui/main_window.py | 261 +++++++++------- tests/test_i18n_coverage.py | 226 ++++++++++++++ 6 files changed, 1114 insertions(+), 234 deletions(-) create mode 100644 tests/test_i18n_coverage.py diff --git a/openstitch/core/design.py b/openstitch/core/design.py index ca35ae6..304d213 100644 --- a/openstitch/core/design.py +++ b/openstitch/core/design.py @@ -343,13 +343,20 @@ def pc_boost(self) -> float: @property def strategy_description(self) -> str: - return { - "small": "Straffe Spannung · feine Details (-10% Zugkompensation, Standard-Unterlage)", - "medium": "Ausgewogen · Standard-Brust-/Ärmelrahmen (100% Zugkompensation)", - "large": "Anti-Trampolin · verstärkte Unterlage (+20% Zugkompensation + Kreuz-Gitter bei grossen Flächen)", - "jacket": "Jackenrücken-Stabilisierung (+35% Zugkompensation, Doppel-Gitter-Unterlage, max. 750 SPM)", - "cap": "Kappen-Modus (Center-Out & Bottom-Up vom Schirm nach oben, +30% Zugkompensation)", - }.get(self.mode, "Standard") + """Beschreibung der Rahmen-Strategie in der aktuellen UI-Sprache.""" + from ..i18n import tr # spät importieren: vermeidet Import-Zyklen + if self.mode in ("small", "medium", "large", "jacket", "cap"): + return tr(f"hoop_strat_{self.mode}") + return tr("hoop_strat_default") + + @property + def display_name(self) -> str: + """Übersetzter Anzeigename. `name` selbst bleibt unverändert (Projektdateien & Abgleich).""" + key = HOOP_NAME_KEYS.get(self.name) + if key is None: + return self.name + from ..i18n import tr + return tr(key) HOOPS = [ @@ -365,6 +372,20 @@ def strategy_description(self) -> str: Hoop("Grossfeld 40 × 30 cm (Industrie)", 400, 300, False, "jacket", 6.0), ] +# Übersetzungsschlüssel für die Anzeige der Rahmennamen (Hoop.name ist der gespeicherte Schlüssel) +HOOP_NAME_KEYS: dict[str, str] = { + "Rund 9 cm (Klein/Brust)": "hoop_round9", + "Rund 12 cm (Brustlogo)": "hoop_round12", + "Rund 15 cm (Standard)": "hoop_round15", + "Rund 18 cm (Gross)": "hoop_round18", + "Rund 21 cm (XL)": "hoop_round21", + "Kappe / Cap 13 × 5 cm": "hoop_cap", + "Wide-Cap 30 × 7 cm": "hoop_widecap", + "Rechteck 13 × 18 cm (Haushalt)": "hoop_rect13x18", + "Jackenrücken 30 × 30 cm": "hoop_jacket", + "Grossfeld 40 × 30 cm (Industrie)": "hoop_field40x30", +} + @dataclass class Settings: diff --git a/openstitch/i18n.py b/openstitch/i18n.py index 6059962..02ba073 100644 --- a/openstitch/i18n.py +++ b/openstitch/i18n.py @@ -74,7 +74,7 @@ def set_lang(lang: str) -> None: "tool_satin": ("Satin (S)", "Satin (S)", "Satin (S)", "Satén (S)"), "tool_run": ("Stepp (R)", "Run (R)", "Point droit (R)", "Pespunte (R)"), "tool_heatmap": ("Heatmap", "Heatmap", "Carte densité", "Mapa calor"), - "tool_zoom": ("Zoom 100%", "Zoom Fit", "Zoom 100%", "Zoom 100%"), + "tool_zoom": ("Rahmen einpassen", "Fit to hoop", "Ajuster au cadre", "Ajustar al bastidor"), # HUD auf der Leinwand "hud_select": ( @@ -215,3 +215,586 @@ def key_for(german_text: str) -> str | None: def tr_all(key: str) -> list[str]: return list(_TABLE.get(key, (key,) * 4)) + + +def tr_opt(key: str, fallback: str) -> str: + """Wie tr(), aber mit eigenem Rückfallwert, falls der Schlüssel fehlt.""" + return tr(key) if key in _TABLE else fallback + + +# Zahlenformat je Sprache: Tausendertrennzeichen und Dezimalzeichen +# (de: Schweizer Apostroph, fr: schmales geschütztes Leerzeichen) +_GROUP_SEP = {"de": "’", "en": ",", "fr": " ", "es": "."} +_DEC_SEP = {"de": ".", "en": ".", "fr": ",", "es": ","} + + +def fmt_int(n) -> str: + """Ganzzahl mit sprachüblichem Tausendertrennzeichen (de 1’234, en 1,234, fr 1 234, es 1.234).""" + return f"{int(round(n)):,}".replace(",", _GROUP_SEP.get(_cur_lang[0], ",")) + + +def fmt_float(x: float, decimals: int = 1) -> str: + """Dezimalzahl mit sprachüblichen Trennzeichen (z. B. fr 1 423,5).""" + int_part, _, frac = f"{x:,.{decimals}f}".partition(".") + lang = _cur_lang[0] + out = int_part.replace(",", _GROUP_SEP.get(lang, ",")) + return out + _DEC_SEP.get(lang, ".") + frac if frac else out + + +# --------------------------------------------------------------------------- +# Allgemeine Schaltflächen, Dateifilter & Fehlertitel +# --------------------------------------------------------------------------- +_TABLE.update({ + "label_colon": (":", ":", " :", ":"), + "btn_cancel": ("Abbrechen", "Cancel", "Annuler", "Cancelar"), + "btn_close": ("Schliessen", "Close", "Fermer", "Cerrar"), + "btn_apply": ("Übernehmen", "Apply", "Appliquer", "Aplicar"), + "btn_save": ("Speichern", "Save", "Enregistrer", "Guardar"), + "btn_discard": ("Verwerfen", "Discard", "Ne pas enregistrer", "Descartar"), + "dlg_error": ("Fehler", "Error", "Erreur", "Error"), + "dlg_notice": ("Hinweis", "Notice", "Remarque", "Aviso"), + "filter_all_files": ("Alle Dateien", "All files", "Tous les fichiers", "Todos los archivos"), + "filter_all_images": ("Alle Bilder", "All images", "Toutes les images", "Todas las imágenes"), + "filter_images": ("Bilder", "Images", "Images", "Imágenes"), + "filter_all_img_vec": ("Alle Bild- & Vektordateien", "All image & vector files", + "Tous les fichiers image et vectoriels", "Todos los archivos de imagen y vectoriales"), + "filter_raster": ("Rasterbilder", "Raster images", "Images matricielles", "Imágenes de mapa de bits"), + "filter_svg": ("SVG-Vektorgrafik", "SVG vector graphic", "Graphique vectoriel SVG", "Gráfico vectorial SVG"), + "filter_photos": ("Fotos & Bilder", "Photos & images", "Photos et images", "Fotos e imágenes"), + "filter_project": ("OpenStitch-Projekt", "OpenStitch project", "Projet OpenStitch", "Proyecto OpenStitch"), + "filter_emb": ("Stickdateien", "Embroidery files", "Fichiers de broderie", "Archivos de bordado"), + "filter_png": ("PNG-Bild", "PNG image", "Image PNG", "Imagen PNG"), + "filter_floppy": ("Diskettenabbild", "Floppy image", "Image de disquette", "Imagen de disquete"), + "err_open_title": ("Fehler beim Öffnen", "Error opening file", "Erreur d'ouverture", "Error al abrir"), + "err_load_title": ("Fehler beim Laden", "Error loading file", "Erreur de chargement", "Error al cargar"), + "err_svg_title": ("SVG-Import-Fehler", "SVG import error", "Erreur d'import SVG", "Error al importar SVG"), + "err_import_title": ("Import-Fehler", "Import error", "Erreur d'importation", "Error de importación"), + "err_save_title": ("Speicherfehler", "Save error", "Erreur d'enregistrement", "Error al guardar"), + "err_export_title": ("Export-Fehler", "Export error", "Erreur d'exportation", "Error de exportación"), + "err_pdf_title": ("PDF-Fehler", "PDF error", "Erreur PDF", "Error de PDF"), + "err_png_title": ("PNG-Fehler", "PNG error", "Erreur PNG", "Error de PNG"), + "err_png_text": ("Bild konnte nicht gespeichert werden:\n{}", "Could not save the image:\n{}", + "Impossible d'enregistrer l'image :\n{}", "No se pudo guardar la imagen:\n{}"), + "err_image_title": ("Bild-Fehler", "Image error", "Erreur d'image", "Error de imagen"), +}) + + +# --------------------------------------------------------------------------- +# Hauptfenster: Tooltips, Objektliste, Statusleiste, Datei-Dialoge, Meldungen +# --------------------------------------------------------------------------- +_TABLE.update({ + "app_tagline": ("Universelle Open-Source-Stick- & Digitalisier-Suite", + "Universal Open-Source Embroidery & Digitizing Suite", + "Suite universelle open source de broderie et de numérisation", + "Suite universal de bordado y digitalización de código abierto"), + "tb_title_studio": ("Studio-Leiste", "Studio toolbar", "Barre du studio", "Barra del estudio"), + "tb_title_tools": ("Zeichenwerkzeuge", "Drawing tools", "Outils de dessin", "Herramientas de dibujo"), + "tip_autofit": ("Design optimal in den gewählten Rahmen einpassen (Ctrl+F)", + "Fit the design optimally into the selected hoop (Ctrl+F)", + "Ajuster le motif de façon optimale au cadre choisi (Ctrl+F)", + "Ajustar el diseño de forma óptima al bastidor elegido (Ctrl+F)"), + "tip_fabric": ("Stoffhintergrund im Stickrahmen umschalten (Dunkel-Schiefer / Warm-Sand / Hell)", + "Switch the fabric background in the hoop (dark slate / warm sand / light)", + "Changer le fond de tissu dans le cadre (ardoise sombre / sable chaud / clair)", + "Cambiar el fondo de tela en el bastidor (pizarra oscura / arena cálida / clara)"), + "tip_tool_select": ("Objekte auswählen, verschieben & skalieren (Taste V)", + "Select, move & scale objects (key V)", + "Sélectionner, déplacer et redimensionner des objets (touche V)", + "Seleccionar, mover y escalar objetos (tecla V)"), + "tip_tool_nodes": ("Konturknoten & Stichwinkel-Achse direkt ziehen (Taste N)", + "Drag contour nodes & the stitch angle axis directly (key N)", + "Déplacer directement les nœuds du contour et l'axe d'angle de point (touche N)", + "Arrastrar directamente los nodos del contorno y el eje del ángulo de puntada (tecla N)"), + "tip_tool_fill": ("Neue Tatami-Füllfläche auf der Leinwand zeichnen (Taste F)", + "Draw a new tatami fill area on the canvas (key F)", + "Dessiner une nouvelle zone de remplissage tatami sur le canevas (touche F)", + "Dibujar una nueva área de relleno tatami en el lienzo (tecla F)"), + "tip_tool_satin": ("Neue Satin-Säule auf der Leinwand zeichnen (Taste S)", + "Draw a new satin column on the canvas (key S)", + "Dessiner une nouvelle colonne de point satin sur le canevas (touche S)", + "Dibujar una nueva columna de puntada satén en el lienzo (tecla S)"), + "tip_tool_run": ("Neuen Steppstich-Pfad zeichnen (Taste R)", + "Draw a new running stitch path (key R)", + "Dessiner un nouveau tracé au point droit (touche R)", + "Dibujar un nuevo trazado de puntada recta (tecla R)"), + "tip_tool_heatmap": ("Stichdichte-Heatmap (Grün/Gelb/Rot) ein-/ausblenden (Ctrl+H)", + "Show/hide the stitch density heatmap (green/yellow/red) (Ctrl+H)", + "Afficher/masquer la carte de densité (vert/jaune/rouge) (Ctrl+H)", + "Mostrar/ocultar el mapa de densidad (verde/amarillo/rojo) (Ctrl+H)"), + "tip_tool_zoom": ("Ansicht in den Stickrahmen einpassen (Ctrl+0)", "Fit the view to the hoop (Ctrl+0)", + "Ajuster la vue au cadre (Ctrl+0)", "Ajustar la vista al bastidor (Ctrl+0)"), + "tip_obj_up": ("Objekt in der Stickreihenfolge nach oben", "Move object up in the stitch order", + "Monter l'objet dans l'ordre de broderie", "Subir el objeto en el orden de bordado"), + "tip_obj_down": ("Objekt in der Stickreihenfolge nach unten", "Move object down in the stitch order", + "Descendre l'objet dans l'ordre de broderie", "Bajar el objeto en el orden de bordado"), + "tip_obj_delete": ("Ausgewähltes Objekt löschen (Entf)", "Delete selected object (Del)", + "Supprimer l'objet sélectionné (Suppr)", "Eliminar el objeto seleccionado (Supr)"), + "sim_all": ("Alle", "All", "Tout", "Todo"), + "sim_speed_suffix": (" St./Tick", " st./tick", " pts/tick", " punt./tick"), + "abbr_stitches": ("St.", "st.", "pts", "punt."), + "stats_trims": ("Fadenschnitte", "Trims", "Coupes", "Cortes"), + "kind_fill": ("Tatami", "Fill", "Remplissage", "Relleno"), + "kind_satin": ("Satin", "Satin", "Satin", "Satén"), + "kind_run": ("Stepp", "Running stitch", "Point droit", "Pespunte"), + "kind_text": ("Schrift", "Lettering", "Lettrage", "Texto"), + "kind_stitches": ("Stiche", "Stitches", "Points", "Puntadas"), + "obj_hidden": ("[Aus]", "[Hidden]", "[Masqué]", "[Oculto]"), + "btn_font_arc": ("Schriftart & Bogen anpassen …", "Adjust font & arc …", + "Modifier la police et l'arc …", "Ajustar fuente y arco …"), + "status_hoop_strategy": ("Rahmen-Strategie aktiv: {}", "Hoop strategy active: {}", + "Stratégie de cadre active : {}", "Estrategia de bastidor activa: {}"), + "status_saved": ("Gespeichert: {}", "Saved: {}", "Enregistré : {}", "Guardado: {}"), + "color_dialog_title": ("Garnfarbe wählen", "Choose thread color", "Choisir la couleur du fil", + "Elegir color de hilo"), + "default_design_name": ("Neues_Design", "New_Design", "Nouveau_motif", "Nuevo_diseño"), + "fd_open_project": ("Projekt öffnen", "Open project", "Ouvrir un projet", "Abrir proyecto"), + "fd_save_project": ("Projekt speichern", "Save project", "Enregistrer le projet", "Guardar proyecto"), + "fd_import_emb": ("Stickdatei importieren", "Import embroidery file", "Importer un fichier de broderie", + "Importar archivo de bordado"), + "fd_import_svg": ("SVG-Vektorgrafik importieren", "Import SVG vector graphic", + "Importer un graphique vectoriel SVG", "Importar gráfico vectorial SVG"), + "fd_export_emb": ("Stickdatei exportieren", "Export embroidery file", "Exporter le fichier de broderie", + "Exportar archivo de bordado"), + "fd_save_pdf": ("Produktionsblatt (PDF) speichern", "Save production sheet (PDF)", + "Enregistrer la fiche de production (PDF)", "Guardar hoja de producción (PDF)"), + "pdf_file_suffix": ("Produktionsblatt", "production_sheet", "fiche_production", "hoja_produccion"), + "fd_save_png": ("3D-Stichvorschau speichern", "Save 3D stitch preview", "Enregistrer l'aperçu 3D des points", + "Guardar vista previa 3D de puntadas"), + "fd_pick_image": ("Bild, Grafik oder Logo wählen (Downloads / Bilder / Desktop)", + "Choose an image, graphic or logo (Downloads / Pictures / Desktop)", + "Choisir une image, un graphique ou un logo (Téléchargements / Images / Bureau)", + "Elegir una imagen, gráfico o logotipo (Descargas / Imágenes / Escritorio)"), + "fd_pick_photo": ("Porträt- oder Landschaftsfoto wählen (Downloads / Bilder / Desktop)", + "Choose a portrait or landscape photo (Downloads / Pictures / Desktop)", + "Choisir une photo de portrait ou de paysage (Téléchargements / Images / Bureau)", + "Elegir una foto de retrato o paisaje (Descargas / Imágenes / Escritorio)"), + "msg_applique_title": ("Applikation (Appliqué)", "Appliqué", "Appliqué", "Aplique"), + "msg_applique_text": ("Bitte zuerst eine Fläche auf der Leinwand auswählen.", + "Please select a fill or satin shape on the canvas first.", + "Veuillez d'abord sélectionner une forme sur le canevas.", + "Primero selecciona una forma en el lienzo."), + "msg_doctor_title": ("Qualitäts- & Maschinen-Doktor", "Quality & Machine Doctor", + "Docteur qualité & machine", "Doctor de calidad y máquina"), + "msg_export_verified_title": ("Export & Binär-Verifikation erfolgreich", "Export & binary verification successful", + "Export et vérification binaire réussis", + "Exportación y verificación binaria correctas"), + "msg_export_verified_text": ( + "✅ Stickdatei gespeichert und im Simulator verifiziert:\n{path}\n\n" + "• Dateigrösse: {size} Bytes\n• Verifizierte Nähstiche: {stitches}\n" + "• Abmessung: {w:.1f} × {h:.1f} mm\n• Max. Schrittweite: {step:.1f} mm\n" + "• Farbwechsel: {colors} · Sprünge: {jumps}", + "✅ Embroidery file saved and verified in the simulator:\n{path}\n\n" + "• File size: {size} bytes\n• Verified stitches: {stitches}\n" + "• Dimensions: {w:.1f} × {h:.1f} mm\n• Max. step length: {step:.1f} mm\n" + "• Color changes: {colors} · Jumps: {jumps}", + "✅ Fichier de broderie enregistré et vérifié dans le simulateur :\n{path}\n\n" + "• Taille du fichier : {size} octets\n• Points vérifiés : {stitches}\n" + "• Dimensions : {w:.1f} × {h:.1f} mm\n• Pas max. : {step:.1f} mm\n" + "• Changements de couleur : {colors} · Sauts : {jumps}", + "✅ Archivo de bordado guardado y verificado en el simulador:\n{path}\n\n" + "• Tamaño del archivo: {size} bytes\n• Puntadas verificadas: {stitches}\n" + "• Dimensiones: {w:.1f} × {h:.1f} mm\n• Paso máx.: {step:.1f} mm\n" + "• Cambios de color: {colors} · Saltos: {jumps}", + ), + "msg_export_ok_title": ("Export erfolgreich", "Export successful", "Export réussi", "Exportación correcta"), + "msg_export_ok_text": ("Datei wurde gespeichert:\n{}", "File saved:\n{}", "Fichier enregistré :\n{}", + "Archivo guardado:\n{}"), + "msg_pdf_ok_title": ("PDF erstellt", "PDF created", "PDF créé", "PDF creado"), + "msg_pdf_ok_text": ("Produktionsblatt gespeichert:\n{}", "Production sheet saved:\n{}", + "Fiche de production enregistrée :\n{}", "Hoja de producción guardada:\n{}"), + "msg_puff_title": ("3D-Puff-Schaumstoff", "3D puff foam", "Mousse 3D (puff)", "Espuma 3D (puff)"), + "msg_puff_text": ("Bitte wähle ein Satin-Objekt aus.", "Please select a satin object.", + "Veuillez sélectionner un objet en point satin.", "Selecciona un objeto de puntada satén."), + "dlg_scale_title": ("Design skalieren (Stiche werden neu berechnet!)", + "Scale design (stitches will be recalculated!)", + "Redimensionner le motif (les points seront recalculés !)", + "Escalar diseño (¡las puntadas se recalcularán!)"), + "dlg_scale_label": ("Skalierungsfaktor (%):", "Scale factor (%):", "Facteur d'échelle (%) :", + "Factor de escala (%):"), +}) + + +# --------------------------------------------------------------------------- +# Stickrahmen: Namen (nur Anzeige – Hoop.name bleibt unverändert) & Strategien +# --------------------------------------------------------------------------- +_TABLE.update({ + "hoop_round9": ("Rund 9 cm (Klein/Brust)", "Round 9 cm (small/chest)", "Rond 9 cm (petit/poitrine)", + "Redondo 9 cm (pequeño/pecho)"), + "hoop_round12": ("Rund 12 cm (Brustlogo)", "Round 12 cm (chest logo)", "Rond 12 cm (logo poitrine)", + "Redondo 12 cm (logo de pecho)"), + "hoop_round15": ("Rund 15 cm (Standard)", "Round 15 cm (standard)", "Rond 15 cm (standard)", + "Redondo 15 cm (estándar)"), + "hoop_round18": ("Rund 18 cm (Gross)", "Round 18 cm (large)", "Rond 18 cm (grand)", "Redondo 18 cm (grande)"), + "hoop_round21": ("Rund 21 cm (XL)", "Round 21 cm (XL)", "Rond 21 cm (XL)", "Redondo 21 cm (XL)"), + "hoop_cap": ("Kappe / Cap 13 × 5 cm", "Cap 13 × 5 cm", "Casquette 13 × 5 cm", "Gorra 13 × 5 cm"), + "hoop_widecap": ("Wide-Cap 30 × 7 cm", "Wide cap 30 × 7 cm", "Casquette large 30 × 7 cm", "Gorra ancha 30 × 7 cm"), + "hoop_rect13x18": ("Rechteck 13 × 18 cm (Haushalt)", "Rectangle 13 × 18 cm (home)", + "Rectangle 13 × 18 cm (domestique)", "Rectangular 13 × 18 cm (doméstico)"), + "hoop_jacket": ("Jackenrücken 30 × 30 cm", "Jacket back 30 × 30 cm", "Dos de veste 30 × 30 cm", + "Espalda de chaqueta 30 × 30 cm"), + "hoop_field40x30": ("Grossfeld 40 × 30 cm (Industrie)", "Large field 40 × 30 cm (industrial)", + "Grand champ 40 × 30 cm (industriel)", "Campo grande 40 × 30 cm (industrial)"), + "hoop_strat_small": ( + "Straffe Spannung · feine Details (-10% Zugkompensation, Standard-Unterlage)", + "Firm tension · fine details (-10% pull compensation, standard underlay)", + "Tension ferme · détails fins (-10 % de compensation de traction, sous-couche standard)", + "Tensión firme · detalles finos (-10 % de compensación de tirón, refuerzo estándar)", + ), + "hoop_strat_medium": ( + "Ausgewogen · Standard-Brust-/Ärmelrahmen (100% Zugkompensation)", + "Balanced · standard chest/sleeve hoop (100% pull compensation)", + "Équilibré · cadre standard poitrine/manche (100 % de compensation de traction)", + "Equilibrado · bastidor estándar de pecho/manga (100 % de compensación de tirón)", + ), + "hoop_strat_large": ( + "Anti-Trampolin · verstärkte Unterlage (+20% Zugkompensation + Kreuz-Gitter bei grossen Flächen)", + "Anti-trampoline · reinforced underlay (+20% pull compensation + cross grid on large areas)", + "Anti-trampoline · sous-couche renforcée (+20 % de compensation de traction + grille croisée " + "sur les grandes surfaces)", + "Antitrampolín · refuerzo reforzado (+20 % de compensación de tirón + rejilla cruzada en áreas grandes)", + ), + "hoop_strat_jacket": ( + "Jackenrücken-Stabilisierung (+35% Zugkompensation, Doppel-Gitter-Unterlage, max. 750 SPM)", + "Jacket-back stabilization (+35% pull compensation, double grid underlay, max. 750 SPM)", + "Stabilisation dos de veste (+35 % de compensation de traction, sous-couche en double grille, max. 750 SPM)", + "Estabilización para espalda de chaqueta (+35 % de compensación de tirón, refuerzo de doble rejilla, " + "máx. 750 SPM)", + ), + "hoop_strat_cap": ( + "Kappen-Modus (Center-Out & Bottom-Up vom Schirm nach oben, +30% Zugkompensation)", + "Cap mode (center-out & bottom-up from the brim upward, +30% pull compensation)", + "Mode casquette (du centre vers l'extérieur et de la visière vers le haut, +30 % de compensation de traction)", + "Modo gorra (del centro hacia fuera y desde la visera hacia arriba, +30 % de compensación de tirón)", + ), + "hoop_strat_default": ("Standard", "Standard", "Standard", "Estándar"), +}) + + +# --------------------------------------------------------------------------- +# Maschinenprofile: Anzeigenamen (machine_; MachineProfile.name bleibt der gespeicherte Wert) +# --------------------------------------------------------------------------- +_TABLE.update({ + "machine_universal_multi": ("Universal Standard (DST / PES / EXP · 15 Nadeln)", + "Universal standard (DST / PES / EXP · 15 needles)", + "Standard universel (DST / PES / EXP · 15 aiguilles)", + "Estándar universal (DST / PES / EXP · 15 agujas)"), + "machine_tajima_tmez": ("Tajima TMEZ / TFMX / TMBR (15 Nadeln)", "Tajima TMEZ / TFMX / TMBR (15 needles)", + "Tajima TMEZ / TFMX / TMBR (15 aiguilles)", "Tajima TMEZ / TFMX / TMBR (15 agujas)"), + "machine_brother_pr": ("Brother PR-Serie / Persona (6–10 Nadeln)", "Brother PR series / Persona (6–10 needles)", + "Brother série PR / Persona (6–10 aiguilles)", "Brother serie PR / Persona (6–10 agujas)"), + "machine_brother_home": ("Brother / Baby Lock Haushalt (1 Nadel · PES)", "Brother / Baby Lock home (1 needle · PES)", + "Brother / Baby Lock domestique (1 aiguille · PES)", + "Brother / Baby Lock doméstica (1 aguja · PES)"), + "machine_barudan_bext": ("Barudan BEKT / BEXT (15 Nadeln)", "Barudan BEKT / BEXT (15 needles)", + "Barudan BEKT / BEXT (15 aiguilles)", "Barudan BEKT / BEXT (15 agujas)"), + "machine_ricoma_happy_zsk": ("Ricoma / HappyJapan / SWF / ZSK (12–15 Nadeln)", + "Ricoma / HappyJapan / SWF / ZSK (12–15 needles)", + "Ricoma / HappyJapan / SWF / ZSK (12–15 aiguilles)", + "Ricoma / HappyJapan / SWF / ZSK (12–15 agujas)"), + "machine_janome_elna": ("Janome / Elna / Bernina Haushalt (JEF / EXP)", "Janome / Elna / Bernina home (JEF / EXP)", + "Janome / Elna / Bernina domestique (JEF / EXP)", + "Janome / Elna / Bernina doméstica (JEF / EXP)"), + "machine_pfaff_husqvarna": ("Pfaff / Husqvarna Viking (VP3)", "Pfaff / Husqvarna Viking (VP3)", + "Pfaff / Husqvarna Viking (VP3)", "Pfaff / Husqvarna Viking (VP3)"), + "machine_melco_emt10_4": ("Melco EMT10/4 (4-Kopf / 10 Nadeln)", "Melco EMT10/4 (4-head / 10 needles)", + "Melco EMT10/4 (4 têtes / 10 aiguilles)", "Melco EMT10/4 (4 cabezales / 10 agujas)"), + "machine_melco_amaya_bravo": ("Melco Amaya / EMT16X / Bravo (16 Nadeln)", "Melco Amaya / EMT16X / Bravo (16 needles)", + "Melco Amaya / EMT16X / Bravo (16 aiguilles)", + "Melco Amaya / EMT16X / Bravo (16 agujas)"), +}) + + +def machine_display_name(name: str) -> str: + """Übersetzter Anzeigename eines Maschinenprofils (anhand des gespeicherten deutschen Namens).""" + from .core.machines import MACHINE_PROFILES # spät importieren: vermeidet Import-Zyklen + mp = next((m for m in MACHINE_PROFILES if m.name == name), None) + return tr_opt(f"machine_{mp.id}", name) if mp else name + + +# --------------------------------------------------------------------------- +# Objekt-Inspektor: Parameter-Labels (param_ bzw. param__) & Optionswerte (opt_) +# --------------------------------------------------------------------------- +_TABLE.update({ + "param_angle": ("Stichwinkel (°)", "Stitch angle (°)", "Angle de point (°)", "Ángulo de puntada (°)"), + "param_fill_spacing": ("Reihenabstand (mm)", "Row spacing (mm)", "Espacement des rangs (mm)", + "Separación de filas (mm)"), + "param_satin_spacing": ("Dichte (mm)", "Density (mm)", "Densité (mm)", "Densidad (mm)"), + "param_stitch_len": ("Stichlänge (mm)", "Stitch length (mm)", "Longueur de point (mm)", + "Longitud de puntada (mm)"), + "param_pattern": ("Muster", "Pattern", "Motif de remplissage", "Patrón"), + "param_underlay": ("Unterlage", "Underlay", "Sous-couche", "Refuerzo"), + "param_pull_comp": ("Zugkompensation (mm)", "Pull compensation (mm)", "Compensation de traction (mm)", + "Compensación de tirón (mm)"), + "param_max_stitch": ("Max. Stich / Split (mm)", "Max. stitch / split (mm)", "Point max. / division (mm)", + "Puntada máx. / división (mm)"), + "param_underlay_style": ("Unterlage-Art", "Underlay type", "Type de sous-couche", "Tipo de refuerzo"), + "param_puff_3d": ("3D-Puff (Cap-Modus)", "3D puff (cap mode)", "Puff 3D (mode casquette)", "Puff 3D (modo gorra)"), + "param_repeats": ("Wiederholungen (0=einfach, 1=dreifach)", "Repeats (0 = single, 1 = triple)", + "Répétitions (0 = simple, 1 = triple)", "Repeticiones (0 = simple, 1 = triple)"), + "param_text": ("Text", "Text", "Texte", "Texto"), + "param_font": ("Schrift", "Font", "Police", "Fuente"), + "param_height": ("Buchstabenhöhe (mm)", "Letter height (mm)", "Hauteur des lettres (mm)", "Altura de letra (mm)"), + "param_letter_spacing": ("Zeichenabstand (mm)", "Letter spacing (mm)", "Espacement des lettres (mm)", + "Espaciado entre letras (mm)"), + "param_arc_radius": ("Bogenradius (mm, 0 = gerade)", "Arc radius (mm, 0 = straight)", + "Rayon de l'arc (mm, 0 = droit)", "Radio del arco (mm, 0 = recto)"), + "param_mode": ("Stichart", "Stitch type", "Type de point", "Tipo de puntada"), + "param_density": ("Dichte (mm)", "Density (mm)", "Densité (mm)", "Densidad (mm)"), + # Füllmuster + "opt_standard": ("Standard", "Standard", "Standard", "Estándar"), + "opt_diagonal": ("Diagonal", "Diagonal", "Diagonal", "Diagonal"), + "opt_ziegel": ("Ziegel", "Brick", "Brique", "Ladrillo"), + "opt_wellen": ("Wellen", "Waves", "Vagues", "Ondas"), + "opt_kontur": ("Kontur", "Contour", "Contour", "Contorno"), + # Satin-Unterlagen + "opt_auto": ("Automatisch", "Automatic", "Automatique", "Automático"), + "opt_mitte": ("Mittellauf", "Center walk", "Passage central", "Pasada central"), + "opt_kante": ("Kantenlauf", "Edge walk", "Passage de bord", "Pasada de borde"), + "opt_zickzack": ("Zickzack", "Zigzag", "Zigzag", "Zigzag"), + "opt_kante+zickzack": ("Kantenlauf + Zickzack", "Edge walk + zigzag", "Passage de bord + zigzag", + "Pasada de borde + zigzag"), + # Schrift-Stichart + "opt_satin": ("Satinstich", "Satin stitch", "Point satin", "Puntada satén"), + "opt_fill": ("Tatami-Füllstich", "Tatami fill", "Remplissage tatami", "Relleno tatami"), +}) + + +# --------------------------------------------------------------------------- +# Dialoge: Auto-Digitizer, Schrift, Maschinen-Transfer, PhotoStitch +# --------------------------------------------------------------------------- +_TABLE.update({ + # Auto-Digitizer + "dlg_auto_title": ("Bild automatisch digitalisieren (Auto-Digitizer)", "Auto-digitize image (Auto-Digitizer)", + "Numériser automatiquement une image (Auto-Numériseur)", + "Digitalizar imagen automáticamente (Auto-Digitalizador)"), + "lbl_orig_image": ("Originalbild ({})", "Original image ({})", "Image d'origine ({})", "Imagen original ({})"), + "btn_other_image": ("📂 Anderes Bild wählen (Downloads / Desktop / Bilder …)", + "📂 Choose another image (Downloads / Desktop / Pictures …)", + "📂 Choisir une autre image (Téléchargements / Bureau / Images …)", + "📂 Elegir otra imagen (Descargas / Escritorio / Imágenes …)"), + "grp_auto_settings": ("Stick-Einstellungen & Automatik", "Embroidery settings & automation", + "Réglages de broderie et automatisme", "Ajustes de bordado y automatización"), + "lbl_target_width_motif": ("Zielbreite des Motivs:", "Target design width:", "Largeur cible du motif :", + "Ancho objetivo del diseño:"), + "lbl_max_colors": ("Max. Garnfarben (Nadeln):", "Max. thread colors (needles):", + "Couleurs de fil max. (aiguilles) :", "Máx. colores de hilo (agujas):"), + "cb_remove_bg": ("Bildhintergrund automatisch entfernen", "Remove image background automatically", + "Supprimer automatiquement l'arrière-plan", "Eliminar el fondo de la imagen automáticamente"), + "fabric_opt_normal": ("Standard (Baumwolle / Twill / Universal)", "Standard (cotton / twill / universal)", + "Standard (coton / sergé / universel)", "Estándar (algodón / sarga / universal)"), + "fabric_opt_strick": ("Piqué / Polo / Strick (mehr Zugkompensation)", + "Piqué / polo / knit (more pull compensation)", + "Piqué / polo / maille (plus de compensation de traction)", + "Piqué / polo / punto (más compensación de tirón)"), + "fabric_opt_frottee": ("Frottee / Handtuch (dichter + starke Unterlage)", + "Terry / towel (denser + strong underlay)", + "Éponge / serviette (plus dense + sous-couche renforcée)", + "Rizo / toalla (más denso + refuerzo fuerte)"), + "fabric_opt_cap": ("Kappe / Cap (hohe Zugkompensation)", "Cap (high pull compensation)", + "Casquette (forte compensation de traction)", "Gorra (alta compensación de tirón)"), + "fabric_opt_leicht": ("Leichter Stoff / Hemd (geringere Dichte)", "Lightweight fabric / shirt (lower density)", + "Tissu léger / chemise (densité réduite)", "Tela ligera / camisa (menor densidad)"), + "lbl_fabric_type": ("Stoffart / Material:", "Fabric / material:", "Type de tissu / matière :", + "Tipo de tela / material:"), + "lbl_density_row": ("Stichdichte (Reihenabstand):", "Stitch density (row spacing):", + "Densité de points (espacement des rangs) :", "Densidad de puntada (separación de filas):"), + "lbl_satin_limit": ("Satin-Grenze (schmaler = Satin):", "Satin limit (narrower = satin):", + "Limite satin (plus étroit = satin) :", "Límite satén (más estrecho = satén):"), + "lbl_min_area": ("Kleinstteile filtern unter:", "Filter out tiny areas below:", "Filtrer les petites zones sous :", + "Filtrar zonas diminutas por debajo de:"), + "cb_outline": ("Zusätzliche Satinkontur um das Motiv legen", "Add a satin outline around the design", + "Ajouter un contour en point satin autour du motif", + "Añadir un contorno de puntada satén alrededor del diseño"), + "cb_palette": ("Farben an Stickgarn-Palette anpassen", "Match colors to the thread palette", + "Adapter les couleurs à la palette de fils", "Ajustar los colores a la paleta de hilos"), + "status_ready": ("Bereit.", "Ready.", "Prêt.", "Listo."), + "btn_digitize_apply": ("Jetzt digitalisieren && übernehmen", "Digitize && apply now", + "Numériser && appliquer", "Digitalizar && aplicar ahora"), + "fd_pick_logo": ("Bild oder Logo wählen (Downloads / Bilder / Desktop)", + "Choose an image or logo (Downloads / Pictures / Desktop)", + "Choisir une image ou un logo (Téléchargements / Images / Bureau)", + "Elegir una imagen o logotipo (Descargas / Imágenes / Escritorio)"), + "fd_pick_logo_short": ("Bild oder Logo wählen", "Choose an image or logo", "Choisir une image ou un logo", + "Elegir una imagen o logotipo"), + "err_digitize_title": ("Fehler beim Digitalisieren", "Digitizing failed", "Échec de la numérisation", + "Error al digitalizar"), + "prog_computing_stitches": ("Stiche berechnen ({}/{}) …", "Computing stitches ({}/{}) …", + "Calcul des points ({}/{}) …", "Calculando puntadas ({}/{}) …"), + # Fortschrittsmeldungen des Auto-Digitizers (Engine meldet deutsch, Dialog übersetzt) + "prog_prepare": ("Bild vorbereiten …", "Preparing image …", "Préparation de l'image …", "Preparando imagen …"), + "prog_reduce": ("Farben reduzieren …", "Reducing colors …", "Réduction des couleurs …", "Reduciendo colores …"), + "prog_clean": ("Flächen bereinigen …", "Cleaning up areas …", "Nettoyage des zones …", "Limpiando áreas …"), + "prog_vectorize": ("Konturen vektorisieren …", "Vectorizing contours …", "Vectorisation des contours …", + "Vectorizando contornos …"), + "prog_done": ("Fertig", "Done", "Terminé", "Listo"), + "prog_classify": ("Fläche {}/{} klassifizieren …", "Classifying area {}/{} …", + "Classification de la zone {}/{} …", "Clasificando área {}/{} …"), + # Schrift / Monogramm + "dlg_text_title": ("Schrift / Monogramm erstellen", "Create lettering / monogram", + "Créer un lettrage / monogramme", "Crear texto / monograma"), + "lbl_text": ("Text:", "Text:", "Texte :", "Texto:"), + "lbl_font_file": ("Schriftart (TTF/OTF):", "Font (TTF/OTF):", "Police (TTF/OTF) :", "Fuente (TTF/OTF):"), + "lbl_letter_height": ("Buchstabenhöhe (Versalhöhe):", "Letter height (cap height):", + "Hauteur des lettres (hauteur de capitale) :", "Altura de letra (altura de mayúscula):"), + "lbl_letter_spacing": ("Zeichenabstand:", "Letter spacing:", "Espacement des lettres :", + "Espaciado entre letras:"), + "sfx_arc": (" mm (0 = gerade)", " mm (0 = straight)", " mm (0 = droit)", " mm (0 = recto)"), + "lbl_arc_radius": ("Bogenradius:", "Arc radius:", "Rayon de l'arc :", "Radio del arco:"), + "text_mode_auto": ("Automatisch (Satin bis 7 mm, darüber Tatami)", "Automatic (satin up to 7 mm, tatami above)", + "Automatique (satin jusqu'à 7 mm, tatami au-delà)", + "Automático (satén hasta 7 mm, tatami por encima)"), + "text_mode_satin": ("Immer Satinstich (Glanz-Schrift)", "Always satin stitch (glossy lettering)", + "Toujours point satin (lettrage brillant)", "Siempre puntada satén (texto brillante)"), + "text_mode_fill": ("Immer Tatami-Füllstich", "Always tatami fill", "Toujours remplissage tatami", + "Siempre relleno tatami"), + "lbl_stitch_type": ("Stichart:", "Stitch type:", "Type de point :", "Tipo de puntada:"), + "lbl_stitch_density": ("Stichdichte:", "Stitch density:", "Densité de points :", "Densidad de puntada:"), + "msg_enter_text": ("Bitte einen Text eingeben.", "Please enter some text.", "Veuillez saisir un texte.", + "Introduce un texto."), + "text_obj_name": ("Text „{}“", "Text “{}”", "Texte « {} »", "Texto «{}»"), + # Maschinen-Transfer + "dlg_transfer_title": ("Maschinen-Transfer (USB / 1.44 MB Disketten-Emulator / WLAN NetBridge)", + "Machine transfer (USB / 1.44 MB floppy emulator / Wi-Fi NetBridge)", + "Transfert machine (USB / émulateur de disquette 1,44 Mo / NetBridge Wi-Fi)", + "Transferencia a máquina (USB / emulador de disquete de 1,44 MB / NetBridge Wi-Fi)"), + "grp_transfer_1": ("1. Maschinen- & Trim-Einstellungen (EXP / Industrie-Format)", + "1. Machine & trim settings (EXP / industrial format)", + "1. Réglages machine et coupe de fil (EXP / format industriel)", + "1. Ajustes de máquina y corte (EXP / formato industrial)"), + "lbl_dos_name": ("DOS-Dateiname (8.3-Format):", "DOS file name (8.3 format):", "Nom de fichier DOS (format 8.3) :", + "Nombre de archivo DOS (formato 8.3):"), + "trim_both": ("Kombi: EXP-Trim-Code + 3 Sprünge (höchste Kompatibilität)", + "Combined: EXP trim code + 3 jumps (highest compatibility)", + "Combiné : code de coupe EXP + 3 sauts (compatibilité maximale)", + "Combinado: código de corte EXP + 3 saltos (máxima compatibilidad)"), + "trim_code": ("Standard EXP-Trim-Code (80 80 07 00)", "Standard EXP trim code (80 80 07 00)", + "Code de coupe EXP standard (80 80 07 00)", "Código de corte EXP estándar (80 80 07 00)"), + "trim_jumps": ("Nur Sprungfolge (für ältere EMT-Firmware)", "Jump sequence only (for older EMT firmware)", + "Séquence de sauts uniquement (anciens firmwares EMT)", + "Solo secuencia de saltos (para firmware EMT antiguo)"), + "lbl_trim_mode": ("Fadenschnitt (Trim)-Modus:", "Trim mode:", "Mode de coupe de fil :", "Modo de corte:"), + "grp_transfer_2": ("2. Direkt auf USB-Stick / Laufwerk oder über WLAN-NetBridge senden", + "2. Send directly to a USB stick / drive or via Wi-Fi NetBridge", + "2. Envoyer directement sur clé USB / lecteur ou via NetBridge Wi-Fi", + "2. Enviar directamente a memoria USB / unidad o mediante NetBridge Wi-Fi"), + "btn_save_exp_usb": ("EXP direkt auf USB-Stick / Ordner speichern …", "Save EXP directly to USB stick / folder …", + "Enregistrer l'EXP directement sur clé USB / dossier …", + "Guardar EXP directamente en memoria USB / carpeta …"), + "lbl_netbridge_url": ("NetBridge-URL:", "NetBridge URL:", "URL NetBridge :", "URL de NetBridge:"), + "btn_send_netbridge": ("Über WLAN an Melco NetBridge senden", "Send to Melco NetBridge via Wi-Fi", + "Envoyer à Melco NetBridge via Wi-Fi", "Enviar a Melco NetBridge por Wi-Fi"), + "grp_transfer_3": ("3. 1.44 MB FAT12-Diskettenabbild (.IMG) für USB-Emulator (GoTek / HxC)", + "3. 1.44 MB FAT12 floppy image (.IMG) for USB emulator (GoTek / HxC)", + "3. Image de disquette FAT12 1,44 Mo (.IMG) pour émulateur USB (GoTek / HxC)", + "3. Imagen de disquete FAT12 de 1,44 MB (.IMG) para emulador USB (GoTek / HxC)"), + "lbl_img_initial": ("Aktuelles Abbild: Neues leeres 1.44-MB-Abbild (FAT12)", + "Current image: new empty 1.44 MB image (FAT12)", + "Image actuelle : nouvelle image vide de 1,44 Mo (FAT12)", + "Imagen actual: nueva imagen vacía de 1,44 MB (FAT12)"), + "btn_open_img": ("Bestehendes .IMG öffnen …", "Open existing .IMG …", "Ouvrir un .IMG existant …", + "Abrir .IMG existente …"), + "btn_add_cur_img": ("Aktuelles Design ins Abbild einfügen", "Add current design to image", + "Ajouter le motif actuel à l'image", "Añadir el diseño actual a la imagen"), + "btn_del_file": ("Ausgewählte Datei löschen", "Delete selected file", "Supprimer le fichier sélectionné", + "Eliminar el archivo seleccionado"), + "btn_save_img": (".IMG-Datei speichern …", "Save .IMG file …", "Enregistrer le fichier .IMG …", + "Guardar archivo .IMG …"), + "unit_bytes": ("Bytes", "bytes", "octets", "bytes"), + "img_new": ("Neues 1.44-MB-Abbild", "New 1.44 MB image", "Nouvelle image 1,44 Mo", "Nueva imagen de 1,44 MB"), + "img_free": ("{src} — Freier Speicher: {free} KB von {total} KB", "{src} — Free space: {free} KB of {total} KB", + "{src} — Espace libre : {free} Ko sur {total} Ko", + "{src} — Espacio libre: {free} KB de {total} KB"), + "fd_save_exp": ("Melco EXP speichern", "Save Melco EXP", "Enregistrer Melco EXP", "Guardar Melco EXP"), + "msg_saved_title": ("Gespeichert", "Saved", "Enregistré", "Guardado"), + "msg_exp_saved": ("Melco-Stickdatei gespeichert:\n{path}\n({size} Bytes)", + "Melco embroidery file saved:\n{path}\n({size} bytes)", + "Fichier de broderie Melco enregistré :\n{path}\n({size} octets)", + "Archivo de bordado Melco guardado:\n{path}\n({size} bytes)"), + "msg_wifi_ok_title": ("WLAN-Übertragung erfolgreich", "Wi-Fi transfer successful", "Transfert Wi-Fi réussi", + "Transferencia Wi-Fi correcta"), + "msg_wifi_ok_text": ("Gesendet an {url}:\n{body}", "Sent to {url}:\n{body}", "Envoyé à {url} :\n{body}", + "Enviado a {url}:\n{body}"), + "msg_netbridge_fail_title": ("NetBridge nicht erreichbar", "NetBridge unreachable", "NetBridge injoignable", + "NetBridge no accesible"), + "msg_netbridge_fail_text": ("Konnte {url} nicht erreichen:\n{err}", "Could not reach {url}:\n{err}", + "Impossible de joindre {url} :\n{err}", "No se pudo acceder a {url}:\n{err}"), + "fd_open_img": ("FAT12-Diskettenabbild öffnen", "Open FAT12 floppy image", "Ouvrir une image de disquette FAT12", + "Abrir imagen de disquete FAT12"), + "fd_save_img": ("1.44 MB FAT12-Diskettenabbild speichern", "Save 1.44 MB FAT12 floppy image", + "Enregistrer l'image de disquette FAT12 1,44 Mo", "Guardar imagen de disquete FAT12 de 1,44 MB"), + "msg_img_saved_title": ("Diskettenabbild gespeichert", "Floppy image saved", "Image de disquette enregistrée", + "Imagen de disquete guardada"), + "msg_img_saved_text": ( + "1.44-MB-FAT12-Abbild gespeichert:\n{path}\n\n" + "Tipp für GoTek/FlashFloppy: Benenne die Bilder auf dem USB-Stick z. B. DSKA0000.IMG, DSKA0001.IMG " + "oder kopiere die .EXP-Datei direkt, falls FlashFloppy im Direct-File-Modus läuft.", + "1.44 MB FAT12 image saved:\n{path}\n\n" + "Tip for GoTek/FlashFloppy: name the images on the USB stick e.g. DSKA0000.IMG, DSKA0001.IMG, " + "or copy the .EXP file directly if FlashFloppy runs in direct-file mode.", + "Image FAT12 de 1,44 Mo enregistrée :\n{path}\n\n" + "Astuce GoTek/FlashFloppy : nommez les images sur la clé USB p. ex. DSKA0000.IMG, DSKA0001.IMG, " + "ou copiez directement le fichier .EXP si FlashFloppy fonctionne en mode fichier direct.", + "Imagen FAT12 de 1,44 MB guardada:\n{path}\n\n" + "Consejo para GoTek/FlashFloppy: nombra las imágenes en la memoria USB p. ej. DSKA0000.IMG, DSKA0001.IMG, " + "o copia directamente el archivo .EXP si FlashFloppy funciona en modo de archivo directo.", + ), + # PhotoStitch + "dlg_photo_title": ("PhotoStitch — Porträt- & Foto-Stickerei erzeugen", + "PhotoStitch — create portrait & photo embroidery", + "PhotoStitch — créer une broderie de portrait et de photo", + "PhotoStitch — crear bordado de retrato y foto"), + "lbl_orig_photo": ("Originalfoto ({})", "Original photo ({})", "Photo d'origine ({})", "Foto original ({})"), + "btn_other_photo": ("📂 Anderes Foto wählen (Downloads / Desktop / Bilder …)", + "📂 Choose another photo (Downloads / Desktop / Pictures …)", + "📂 Choisir une autre photo (Téléchargements / Bureau / Images …)", + "📂 Elegir otra foto (Descargas / Escritorio / Imágenes …)"), + "grp_photo_settings": ("PhotoStitch-Einstellungen", "PhotoStitch settings", "Réglages PhotoStitch", + "Ajustes de PhotoStitch"), + "lbl_target_width": ("Zielbreite:", "Target width:", "Largeur cible :", "Ancho objetivo:"), + "photo_mode_mono": ("Monochrom-Gravur (1 Garnfarbe auf hellem Stoff)", + "Monochrome engraving (1 thread color on light fabric)", + "Gravure monochrome (1 couleur de fil sur tissu clair)", + "Grabado monocromo (1 color de hilo sobre tela clara)"), + "photo_mode_sepia": ("Sepia-Porträt (2 Garnfarben: Hellbraun + Dunkelbraun)", + "Sepia portrait (2 thread colors: light brown + dark brown)", + "Portrait sépia (2 couleurs de fil : brun clair + brun foncé)", + "Retrato sepia (2 colores de hilo: marrón claro + marrón oscuro)"), + "photo_mode_color4": ("4-Farb CMYK-Halbton (Gelb, Magenta, Cyan, Schwarz)", + "4-color CMYK halftone (yellow, magenta, cyan, black)", + "Demi-teinte CMJN 4 couleurs (jaune, magenta, cyan, noir)", + "Semitono CMYK de 4 colores (amarillo, magenta, cian, negro)"), + "lbl_color_mode": ("Farbmodus:", "Color mode:", "Mode couleur :", "Modo de color:"), + "lbl_row_spacing_fine": ("Zeilenabstand (Feinheit):", "Row spacing (fineness):", + "Espacement des lignes (finesse) :", "Separación de líneas (finura):"), + "lbl_contrast": ("Kontrast-Verstärkung:", "Contrast boost:", "Renforcement du contraste :", + "Refuerzo de contraste:"), + "btn_photo_apply": ("PhotoStitch berechnen && übernehmen", "Compute && apply PhotoStitch", + "Calculer && appliquer PhotoStitch", "Calcular && aplicar PhotoStitch"), +}) + + +# --------------------------------------------------------------------------- +# PDF-Produktionsblatt +# --------------------------------------------------------------------------- +_TABLE.update({ + "pdf_title": ("{} — Produktions- & Einrichteblatt", "{} — Production & setup sheet", + "{} — Fiche de production et de réglage", "{} — Hoja de producción y preparación"), + "pdf_created": ("Erstellt mit {app} am {date}", "Created with {app} on {date}", "Créé avec {app} le {date}", + "Creado con {app} el {date}"), + "pdf_date_fmt": ("%d.%m.%Y %H:%M", "%Y-%m-%d %H:%M", "%d/%m/%Y %H:%M", "%d/%m/%Y %H:%M"), + "pdf_total_stitches": ("Stiche gesamt", "Total stitches", "Points au total", "Puntadas totales"), + "pdf_size": ("Grösse (B × H)", "Size (W × H)", "Taille (L × H)", "Tamaño (an. × al.)"), + "pdf_color_changes": ("Farbwechsel / Blöcke", "Color changes / blocks", "Changements de couleur / blocs", + "Cambios de color / bloques"), + "pdf_blocks_val": ("{} ({} Blöcke)", "{} ({} blocks)", "{} ({} blocs)", "{} ({} bloques)"), + "pdf_trims": ("Fadenschnitte (Trims)", "Thread trims", "Coupes de fil", "Cortes de hilo"), + "pdf_hoop": ("Stickrahmen", "Hoop", "Cadre", "Bastidor"), + "pdf_runtime": ("Laufzeit (ca. {} SPM)", "Run time (approx. {} SPM)", "Durée (env. {} SPM)", + "Duración (aprox. {} SPM)"), + "pdf_thread_usage": ("Oberfadenbedarf", "Top thread usage", "Consommation de fil supérieur", + "Consumo de hilo superior"), + "pdf_machine": ("Maschinenprofil", "Machine profile", "Profil machine", "Perfil de máquina"), + "pdf_sequence": ("Farbfolge & Nadelzuordnung ({} Nadeln)", "Color sequence & needle assignment ({} needles)", + "Séquence de couleurs et attribution des aiguilles ({} aiguilles)", + "Secuencia de colores y asignación de agujas ({} agujas)"), +}) diff --git a/openstitch/io/project.py b/openstitch/io/project.py index ec316a0..1699423 100644 --- a/openstitch/io/project.py +++ b/openstitch/io/project.py @@ -11,6 +11,7 @@ from .. import APP_NAME, __version__ from ..core.design import HOOPS, Design, Hoop, Settings, object_from_dict from ..core.stitchplan import StitchPlan +from ..i18n import fmt_int, machine_display_name, tr from ..render import render_image @@ -57,6 +58,7 @@ def load_project(path: str) -> Design: def export_worksheet_pdf(design: Design, plan: StitchPlan, path: str, machine_name: str | None = None) -> None: """Erzeugt ein druckfertiges Produktionsblatt (PDF) für alle Stickmaschinen.""" mach_str = machine_name or getattr(design.settings, "machine_name", "Universal Standard (15 Nadeln)") + mach_str = machine_display_name(mach_str) max_nd = max(1, getattr(design.settings, "needles", 15)) pdf = QPdfWriter(path) pdf.setPageSize(QPageSize(QPageSize.A4)) @@ -68,11 +70,11 @@ def export_worksheet_pdf(design: Design, plan: StitchPlan, path: str, machine_na # Titelzeile p.setPen(QColor(25, 28, 34)) p.setFont(QFont("Segoe UI", 16, QFont.Bold)) - p.drawText(m, m + 60, f"{design.name} — Produktions- & Einrichteblatt") + p.drawText(m, m + 60, tr("pdf_title").format(design.name)) p.setFont(QFont("Segoe UI", 9)) p.setPen(QColor(100, 105, 115)) - now_str = _dt.datetime.now().strftime("%d.%m.%Y %H:%M") - p.drawText(m, m + 115, f"Erstellt mit {APP_NAME} Studio {__version__} am {now_str}") + now_str = _dt.datetime.now().strftime(tr("pdf_date_fmt")) + p.drawText(m, m + 115, tr("pdf_created").format(app=f"{APP_NAME} Studio {__version__}", date=now_str)) # Kennzahlen-Box x0, y0, x1, y1 = plan.bounds() @@ -84,14 +86,15 @@ def export_worksheet_pdf(design: Design, plan: StitchPlan, path: str, machine_na p.drawRoundedRect(m, box_y, W - 2 * m, box_h, 18, 18) stats = [ - ("Stiche gesamt", f"{plan.stitch_count:,}".replace(",", "'")), - ("Grösse (B × H)", f"{bw:.1f} × {bh:.1f} mm"), - ("Farbwechsel / Blöcke", f"{plan.color_changes} ({len(plan.blocks)} Blöcke)"), - ("Fadenschnitte (Trims)", f"{plan.trim_count}"), - ("Stickrahmen", f"{design.hoop.name} ({design.hoop.width:.0f}×{design.hoop.height:.0f} mm)"), - (f"Laufzeit (ca. {design.settings.machine_spm} SPM)", f"{plan.estimated_minutes(design.settings.machine_spm):.1f} Min."), - ("Oberfadenbedarf", f"{plan.thread_length_m():.1f} m"), - ("Maschinenprofil", mach_str[:28]), + (tr("pdf_total_stitches"), fmt_int(plan.stitch_count)), + (tr("pdf_size"), f"{bw:.1f} × {bh:.1f} mm"), + (tr("pdf_color_changes"), tr("pdf_blocks_val").format(plan.color_changes, len(plan.blocks))), + (tr("pdf_trims"), f"{plan.trim_count}"), + (tr("pdf_hoop"), f"{design.hoop.display_name} ({design.hoop.width:.0f}×{design.hoop.height:.0f} mm)"), + (tr("pdf_runtime").format(design.settings.machine_spm), + f"{plan.estimated_minutes(design.settings.machine_spm):.1f} {tr('stats_min')}"), + (tr("pdf_thread_usage"), f"{plan.thread_length_m():.1f} m"), + (tr("pdf_machine"), mach_str[:28]), ] col_w = (W - 2 * m - 60) // 4 for idx, (k, v) in enumerate(stats): @@ -122,7 +125,7 @@ def export_worksheet_pdf(design: Design, plan: StitchPlan, path: str, machine_na tw = W - m - tx p.setFont(QFont("Segoe UI", 11, QFont.Bold)) p.setPen(QColor(25, 28, 34)) - p.drawText(tx, prev_y + 40, f"Farbfolge & Nadelzuordnung ({max_nd} Nadeln)") + p.drawText(tx, prev_y + 40, tr("pdf_sequence").format(max_nd)) # eindeutige Farben → Nadeln 1..max_nd needle_map: dict[str, int] = {} @@ -146,10 +149,10 @@ def export_worksheet_pdf(design: Design, plan: StitchPlan, path: str, machine_na p.setFont(QFont("Segoe UI", 9, QFont.Bold)) label = b.thread.name or b.thread.hex code = f" ({b.thread.code})" if b.thread.code else "" - p.drawText(tx + 70, ry + 24, f"{bi + 1}. Nadel {needle}: {label}{code}") + p.drawText(tx + 70, ry + 24, f"{bi + 1}. {tr('needle')} {needle}: {label}{code}") p.setFont(QFont("Segoe UI", 8)) p.setPen(QColor(100, 105, 115)) - p.drawText(tx + 70, ry + 50, f"{n_st} Stiche · {b.thread.hex.upper()}") + p.drawText(tx + 70, ry + 50, f"{fmt_int(n_st)} {tr('stats_stitches')} · {b.thread.hex.upper()}") ry += row_h p.end() diff --git a/openstitch/ui/dialogs.py b/openstitch/ui/dialogs.py index 93c70eb..3e883e3 100644 --- a/openstitch/ui/dialogs.py +++ b/openstitch/ui/dialogs.py @@ -2,6 +2,7 @@ from __future__ import annotations import os +import re import numpy as np from PySide6.QtCore import Qt, QThread, Signal @@ -32,6 +33,7 @@ from ..core.stitchplan import StitchPlan, Thread from ..io.exp import exp_bytes from ..io.fat12 import Fat12Image, dos_name +from ..i18n import fmt_float, fmt_int, key_for, tr from ..text import TextObject, default_font, list_fonts @@ -50,6 +52,23 @@ class _Cancelled(Exception): pass +_CLASSIFY_RE = re.compile(r"^Fläche (\d+)/(\d+) klassifizieren …$") + + +def _tr_progress(msg: str) -> str: + """Übersetzt die (deutschen) Fortschrittsmeldungen der Auto-Digitizer-Engine in die UI-Sprache.""" + m = _CLASSIFY_RE.match(msg) + if m: + return tr("prog_classify").format(m.group(1), m.group(2)) + key = key_for(msg) + return tr(key) if key else msg + + +def _image_filter(all_key: str = "filter_all_images") -> str: + exts = "*.png *.jpg *.jpeg *.webp *.bmp *.gif *.tif *.tiff" + return f"{tr(all_key)} ({exts});;{tr('filter_all_files')} (*.*)" + + class _DigitizeWorker(QThread): progress = Signal(str, int) finished_ok = Signal(object, object) # AutoResult, StitchPlan @@ -66,7 +85,7 @@ def _tick(self, msg: str, val: int): # Abbruch wird an jedem Fortschrittspunkt geprüft (Bildschritte bzw. je Objekt) if self.cancelled: raise _Cancelled() - self.progress.emit(msg, val) + self.progress.emit(_tr_progress(msg), val) def run(self): try: @@ -80,7 +99,7 @@ def run(self): res.design.hoop = self.hoop plan = res.design.build_plan( progress=lambda i, n: self._tick( - f"Stiche berechnen ({i}/{n}) …", 55 + int(45 * i / max(1, n)) + tr("prog_computing_stitches").format(i, n), 55 + int(45 * i / max(1, n)) ) ) if not self.cancelled: @@ -98,7 +117,7 @@ def run(self): class AutoDigitizeDialog(QDialog): def __init__(self, image_path: str, parent=None): super().__init__(parent) - self.setWindowTitle("Bild automatisch digitalisieren (Auto-Digitizer)") + self.setWindowTitle(tr("dlg_auto_title")) self.resize(860, 560) self.rgba = load_image(image_path) self.result_data: AutoResult | None = None @@ -110,14 +129,14 @@ def __init__(self, image_path: str, parent=None): top = QHBoxLayout() # Vorschau Original v1 = QVBoxLayout() - lbl_t1 = QLabel(f"Originalbild ({os.path.basename(image_path)})") + lbl_t1 = QLabel(tr("lbl_orig_image").format(os.path.basename(image_path))) lbl_t1.setAlignment(Qt.AlignCenter) self.lbl_orig_title = lbl_t1 self.lbl_orig = QLabel() self.lbl_orig.setAlignment(Qt.AlignCenter) self.lbl_orig.setMinimumSize(320, 320) self.lbl_orig.setPixmap(_np_to_pixmap(self.rgba, 340)) - btn_change_img = QPushButton("📂 Anderes Bild wählen (Downloads / Desktop / Bilder …)") + btn_change_img = QPushButton(tr("btn_other_image")) btn_change_img.clicked.connect(self._choose_other_image) v1.addWidget(lbl_t1) v1.addWidget(self.lbl_orig, 1) @@ -125,57 +144,54 @@ def __init__(self, image_path: str, parent=None): top.addLayout(v1, 1) # Einstellungen rechts - grp = QGroupBox("Stick-Einstellungen & Automatik") + grp = QGroupBox(tr("grp_auto_settings")) form = QFormLayout(grp) self.sp_width = QDoubleSpinBox() self.sp_width.setRange(15.0, 350.0) self.sp_width.setValue(95.0) self.sp_width.setSuffix(" mm") - form.addRow("Zielbreite des Motivs:", self.sp_width) + form.addRow(tr("lbl_target_width_motif"), self.sp_width) self.sp_colors = QSpinBox() self.sp_colors.setRange(1, 16) self.sp_colors.setValue(6) - form.addRow("Max. Garnfarben (Nadeln):", self.sp_colors) + form.addRow(tr("lbl_max_colors"), self.sp_colors) - self.cb_bg = QCheckBox("Bildhintergrund automatisch entfernen") + self.cb_bg = QCheckBox(tr("cb_remove_bg")) self.cb_bg.setChecked(True) form.addRow("", self.cb_bg) self.cmb_fabric = QComboBox() - self.cmb_fabric.addItem("Standard (Baumwolle / Twill / Universal)", "normal") - self.cmb_fabric.addItem("Piqué / Polo / Strick (mehr Zugkompensation)", "strick") - self.cmb_fabric.addItem("Frottee / Handtuch (dichter + starke Unterlage)", "frottee") - self.cmb_fabric.addItem("Kappe / Cap (hohe Zugkompensation)", "cap") - self.cmb_fabric.addItem("Leichter Stoff / Hemd (geringere Dichte)", "leicht") - form.addRow("Stoffart / Material:", self.cmb_fabric) + for fab in ("normal", "strick", "frottee", "cap", "leicht"): + self.cmb_fabric.addItem(tr(f"fabric_opt_{fab}"), fab) + form.addRow(tr("lbl_fabric_type"), self.cmb_fabric) self.sp_density = QDoubleSpinBox() self.sp_density.setRange(0.28, 1.0) self.sp_density.setSingleStep(0.02) self.sp_density.setValue(0.40) self.sp_density.setSuffix(" mm") - form.addRow("Stichdichte (Reihenabstand):", self.sp_density) + form.addRow(tr("lbl_density_row"), self.sp_density) self.sp_satin = QDoubleSpinBox() self.sp_satin.setRange(2.0, 10.0) self.sp_satin.setSingleStep(0.5) self.sp_satin.setValue(6.5) self.sp_satin.setSuffix(" mm") - form.addRow("Satin-Grenze (schmaler = Satin):", self.sp_satin) + form.addRow(tr("lbl_satin_limit"), self.sp_satin) self.sp_min_area = QDoubleSpinBox() self.sp_min_area.setRange(0.3, 15.0) self.sp_min_area.setSingleStep(0.5) self.sp_min_area.setValue(1.5) self.sp_min_area.setSuffix(" mm²") - form.addRow("Kleinstteile filtern unter:", self.sp_min_area) + form.addRow(tr("lbl_min_area"), self.sp_min_area) - self.cb_outline = QCheckBox("Zusätzliche Satinkontur um das Motiv legen") + self.cb_outline = QCheckBox(tr("cb_outline")) form.addRow("", self.cb_outline) - self.cb_palette = QCheckBox("Farben an Stickgarn-Palette anpassen") + self.cb_palette = QCheckBox(tr("cb_palette")) self.cb_palette.setChecked(True) form.addRow("", self.cb_palette) @@ -185,14 +201,14 @@ def __init__(self, image_path: str, parent=None): self.progress = QProgressBar() self.progress.setRange(0, 100) self.progress.setValue(0) - self.lbl_status = QLabel("Bereit.") + self.lbl_status = QLabel(tr("status_ready")) root.addWidget(self.lbl_status) root.addWidget(self.progress) self.btns = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) self.btn_ok = self.btns.button(QDialogButtonBox.Ok) - self.btn_ok.setText("Jetzt digitalisieren && übernehmen") - self.btns.button(QDialogButtonBox.Cancel).setText("Abbrechen") + self.btn_ok.setText(tr("btn_digitize_apply")) + self.btns.button(QDialogButtonBox.Cancel).setText(tr("btn_cancel")) self.btns.accepted.connect(self._run) self.btns.rejected.connect(self.reject) root.addWidget(self.btns) @@ -200,22 +216,20 @@ def __init__(self, image_path: str, parent=None): def _choose_other_image(self): p = self.parent() if p is not None and hasattr(p, "_ask_open_file"): - path = p._ask_open_file( - "Bild oder Logo wählen (Downloads / Bilder / Desktop)", - "Alle Bilder (*.png *.jpg *.jpeg *.webp *.bmp *.gif *.tif *.tiff);;Alle Dateien (*.*)", - ) + path = p._ask_open_file(tr("fd_pick_logo"), _image_filter()) else: path, _ = QFileDialog.getOpenFileName( - self, "Bild oder Logo wählen", "", "Bilder (*.png *.jpg *.jpeg *.webp *.bmp *.gif *.tif *.tiff)" + self, tr("fd_pick_logo_short"), "", + f"{tr('filter_images')} (*.png *.jpg *.jpeg *.webp *.bmp *.gif *.tif *.tiff)", ) if not path: return try: self.rgba = load_image(path) - self.lbl_orig_title.setText(f"Originalbild ({os.path.basename(path)})") + self.lbl_orig_title.setText(tr("lbl_orig_image").format(os.path.basename(path))) self.lbl_orig.setPixmap(_np_to_pixmap(self.rgba, 340)) except Exception as exc: - QMessageBox.critical(self, "Fehler beim Laden", str(exc)) + QMessageBox.critical(self, tr("err_load_title"), str(exc)) def _run(self): if self._worker is not None and self._worker.isRunning(): @@ -252,7 +266,7 @@ def _on_done(self, res: AutoResult, plan: StitchPlan): def _on_fail(self, err: str): self.btn_ok.setEnabled(True) - QMessageBox.critical(self, "Fehler beim Digitalisieren", err) + QMessageBox.critical(self, tr("err_digitize_title"), err) def reject(self): # Abbrechen/Esc/Schliessen stoppt auch die laufende Berechnung @@ -275,7 +289,7 @@ def reject(self): class TextDialog(QDialog): def __init__(self, thread: Thread, existing: TextObject | None = None, parent=None): super().__init__(parent) - self.setWindowTitle("Schrift / Monogramm erstellen") + self.setWindowTitle(tr("dlg_text_title")) self.resize(520, 380) self.thread = thread self.existing = existing @@ -285,7 +299,7 @@ def __init__(self, thread: Thread, existing: TextObject | None = None, parent=No form = QFormLayout() self.ed_text = QLineEdit(existing.text if existing else "OPENSTITCH") - form.addRow("Text:", self.ed_text) + form.addRow(tr("lbl_text"), self.ed_text) self.cmb_font = QComboBox() self._fonts = list_fonts() @@ -296,50 +310,49 @@ def __init__(self, thread: Thread, existing: TextObject | None = None, parent=No if spec == cur_font: cur_idx = idx self.cmb_font.setCurrentIndex(cur_idx) - form.addRow("Schriftart (TTF/OTF):", self.cmb_font) + form.addRow(tr("lbl_font_file"), self.cmb_font) self.sp_height = QDoubleSpinBox() self.sp_height.setRange(4.0, 150.0) self.sp_height.setValue(existing.height if existing else 16.0) self.sp_height.setSuffix(" mm") - form.addRow("Buchstabenhöhe (Versalhöhe):", self.sp_height) + form.addRow(tr("lbl_letter_height"), self.sp_height) self.sp_spacing = QDoubleSpinBox() self.sp_spacing.setRange(-4.0, 20.0) self.sp_spacing.setSingleStep(0.2) self.sp_spacing.setValue(existing.letter_spacing if existing else 0.6) self.sp_spacing.setSuffix(" mm") - form.addRow("Zeichenabstand:", self.sp_spacing) + form.addRow(tr("lbl_letter_spacing"), self.sp_spacing) self.sp_arc = QDoubleSpinBox() self.sp_arc.setRange(-400.0, 400.0) self.sp_arc.setSingleStep(5.0) self.sp_arc.setValue(existing.arc_radius if existing else 0.0) - self.sp_arc.setSuffix(" mm (0 = gerade)") - form.addRow("Bogenradius:", self.sp_arc) + self.sp_arc.setSuffix(tr("sfx_arc")) + form.addRow(tr("lbl_arc_radius"), self.sp_arc) self.cmb_mode = QComboBox() - self.cmb_mode.addItem("Automatisch (Satin bis 7 mm, darüber Tatami)", "auto") - self.cmb_mode.addItem("Immer Satinstich (Glanz-Schrift)", "satin") - self.cmb_mode.addItem("Immer Tatami-Füllstich", "fill") + for mode in ("auto", "satin", "fill"): + self.cmb_mode.addItem(tr(f"text_mode_{mode}"), mode) if existing: idx = self.cmb_mode.findData(existing.mode) if idx >= 0: self.cmb_mode.setCurrentIndex(idx) - form.addRow("Stichart:", self.cmb_mode) + form.addRow(tr("lbl_stitch_type"), self.cmb_mode) self.sp_density = QDoubleSpinBox() self.sp_density.setRange(0.25, 0.9) self.sp_density.setSingleStep(0.02) self.sp_density.setValue(existing.density if existing else 0.38) self.sp_density.setSuffix(" mm") - form.addRow("Stichdichte:", self.sp_density) + form.addRow(tr("lbl_stitch_density"), self.sp_density) root.addLayout(form) btns = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) - btns.button(QDialogButtonBox.Ok).setText("Übernehmen") - btns.button(QDialogButtonBox.Cancel).setText("Abbrechen") + btns.button(QDialogButtonBox.Ok).setText(tr("btn_apply")) + btns.button(QDialogButtonBox.Cancel).setText(tr("btn_cancel")) btns.accepted.connect(self._apply) btns.rejected.connect(self.reject) root.addWidget(btns) @@ -347,11 +360,11 @@ def __init__(self, thread: Thread, existing: TextObject | None = None, parent=No def _apply(self): txt = self.ed_text.text().strip() if not txt: - QMessageBox.warning(self, "Hinweis", "Bitte einen Text eingeben.") + QMessageBox.warning(self, tr("dlg_notice"), tr("msg_enter_text")) return self.created_object = TextObject( thread=self.thread, - name=f'Text „{txt[:18]}“', + name=tr("text_obj_name").format(txt[:18]), text=txt, font=self.cmb_font.currentData(), height=self.sp_height.value(), @@ -369,7 +382,7 @@ def _apply(self): class MelcoDiskDialog(QDialog): def __init__(self, plan: StitchPlan, design_name: str, parent=None): super().__init__(parent) - self.setWindowTitle("Maschinen-Transfer (USB / 1.44 MB Disketten-Emulator / WLAN NetBridge)") + self.setWindowTitle(tr("dlg_transfer_title")) self.resize(680, 520) self.plan = plan self.image = Fat12Image() @@ -378,51 +391,50 @@ def __init__(self, plan: StitchPlan, design_name: str, parent=None): root = QVBoxLayout(self) # Abschnitt 1: Dateiname & Trim-Modus - g1 = QGroupBox("1. Maschinen- & Trim-Einstellungen (EXP / Industrie-Format)") + g1 = QGroupBox(tr("grp_transfer_1")) f1 = QFormLayout(g1) self.ed_name = QLineEdit(dos_name(design_name, "EXP")) - f1.addRow("DOS-Dateiname (8.3-Format):", self.ed_name) + f1.addRow(tr("lbl_dos_name"), self.ed_name) self.cmb_trim = QComboBox() - self.cmb_trim.addItem("Kombi: EXP-Trim-Code + 3 Sprünge (höchste Kompatibilität)", "both") - self.cmb_trim.addItem("Standard EXP-Trim-Code (80 80 07 00)", "code") - self.cmb_trim.addItem("Nur Sprungfolge (für ältere EMT-Firmware)", "jumps") - f1.addRow("Fadenschnitt (Trim)-Modus:", self.cmb_trim) + for mode in ("both", "code", "jumps"): + self.cmb_trim.addItem(tr(f"trim_{mode}"), mode) + f1.addRow(tr("lbl_trim_mode"), self.cmb_trim) root.addWidget(g1) # Abschnitt 2: Direkt auf USB-Stick / Diskettenlaufwerk oder per WLAN-NetBridge senden - g2 = QGroupBox("2. Direkt auf USB-Stick / Laufwerk oder über WLAN-NetBridge senden") + g2 = QGroupBox(tr("grp_transfer_2")) v2 = QVBoxLayout(g2) - btn_folder = QPushButton("EXP direkt auf USB-Stick / Ordner speichern …") + btn_folder = QPushButton(tr("btn_save_exp_usb")) btn_folder.clicked.connect(self._save_direct_exp) v2.addWidget(btn_folder) hb_net = QHBoxLayout() - hb_net.addWidget(QLabel("NetBridge-URL:")) + hb_net.addWidget(QLabel(tr("lbl_netbridge_url"))) self.ed_net_url = QLineEdit("http://raspberrypi.local:8070") hb_net.addWidget(self.ed_net_url, 1) - btn_net = QPushButton("Über WLAN an Melco NetBridge senden") + btn_net = QPushButton(tr("btn_send_netbridge")) btn_net.clicked.connect(self._send_netbridge) hb_net.addWidget(btn_net) v2.addLayout(hb_net) root.addWidget(g2) # Abschnitt 3: 1.44 MB FAT12 Diskettenabbild (.IMG) für GoTek / FlashFloppy / HxC - g3 = QGroupBox("3. 1.44 MB FAT12-Diskettenabbild (.IMG) für USB-Emulator (GoTek / HxC)") + g3 = QGroupBox(tr("grp_transfer_3")) v3 = QVBoxLayout(g3) - self.lbl_img = QLabel("Aktuelles Abbild: Neues leeres 1.44-MB-Abbild (FAT12)") + self.lbl_img = QLabel(tr("lbl_img_initial")) v3.addWidget(self.lbl_img) self.list_files = QListWidget() v3.addWidget(self.list_files, 1) hb = QHBoxLayout() - btn_open_img = QPushButton("Bestehendes .IMG öffnen …") + btn_open_img = QPushButton(tr("btn_open_img")) btn_open_img.clicked.connect(self._open_img) - btn_add_cur = QPushButton("Aktuelles Design ins Abbild einfügen") + btn_add_cur = QPushButton(tr("btn_add_cur_img")) btn_add_cur.clicked.connect(self._add_current_to_img) - btn_del = QPushButton("Ausgewählte Datei löschen") + btn_del = QPushButton(tr("btn_del_file")) btn_del.clicked.connect(self._delete_selected) - btn_save_img = QPushButton(".IMG-Datei speichern …") + btn_save_img = QPushButton(tr("btn_save_img")) btn_save_img.clicked.connect(self._save_img) hb.addWidget(btn_open_img) hb.addWidget(btn_add_cur) @@ -432,6 +444,7 @@ def __init__(self, plan: StitchPlan, design_name: str, parent=None): root.addWidget(g3, 1) close_box = QDialogButtonBox(QDialogButtonBox.Close) + close_box.button(QDialogButtonBox.Close).setText(tr("btn_close")) close_box.rejected.connect(self.reject) root.addWidget(close_box) @@ -447,19 +460,20 @@ def _current_exp_bytes(self) -> tuple[str, bytes]: def _refresh_list(self): self.list_files.clear() for fname, size in self.image.list_files(): - self.list_files.addItem(f"{fname:<14} ({size:,} Bytes)".replace(",", "'")) + self.list_files.addItem(f"{fname:<14} ({fmt_int(size)} {tr('unit_bytes')})") free_kb = self.image.free_bytes() / 1024 - src = self.img_path or "Neues 1.44-MB-Abbild" - self.lbl_img.setText(f"{src} — Freier Speicher: {free_kb:.1f} KB von 1'423.5 KB") + src = self.img_path or tr("img_new") + self.lbl_img.setText(tr("img_free").format(src=src, free=fmt_float(free_kb), total=fmt_float(1423.5))) def _save_direct_exp(self): name, data = self._current_exp_bytes() - path, _ = QFileDialog.getSaveFileName(self, "Melco EXP speichern", name, "Melco EXP (*.EXP *.exp)") + path, _ = QFileDialog.getSaveFileName(self, tr("fd_save_exp"), name, "Melco EXP (*.EXP *.exp)") if not path: return with open(path, "wb") as f: f.write(data) - QMessageBox.information(self, "Gespeichert", f"Melco-Stickdatei gespeichert:\n{path}\n({len(data)} Bytes)") + QMessageBox.information(self, tr("msg_saved_title"), + tr("msg_exp_saved").format(path=path, size=fmt_int(len(data)))) def _send_netbridge(self): import urllib.request @@ -469,13 +483,14 @@ def _send_netbridge(self): try: with urllib.request.urlopen(req, timeout=5) as resp: body = resp.read().decode("utf-8", "replace") - QMessageBox.information(self, "WLAN-Übertragung erfolgreich", f"Gesendet an {url}:\n{body}") + QMessageBox.information(self, tr("msg_wifi_ok_title"), tr("msg_wifi_ok_text").format(url=url, body=body)) except Exception as exc: - QMessageBox.critical(self, "NetBridge nicht erreichbar", f"Konnte {url} nicht erreichen:\n{exc}") + QMessageBox.critical(self, tr("msg_netbridge_fail_title"), + tr("msg_netbridge_fail_text").format(url=url, err=exc)) def _open_img(self): path, _ = QFileDialog.getOpenFileName( - self, "FAT12-Diskettenabbild öffnen", "", "Diskettenabbild (*.img *.IMG *.ima *.dsk)" + self, tr("fd_open_img"), "", f"{tr('filter_floppy')} (*.img *.IMG *.ima *.dsk)" ) if not path: return @@ -485,7 +500,7 @@ def _open_img(self): self.img_path = path self._refresh_list() except Exception as exc: - QMessageBox.critical(self, "Fehler", str(exc)) + QMessageBox.critical(self, tr("dlg_error"), str(exc)) def _add_current_to_img(self): try: @@ -493,7 +508,7 @@ def _add_current_to_img(self): self.image.add_file(name, data) self._refresh_list() except Exception as exc: - QMessageBox.critical(self, "Fehler", str(exc)) + QMessageBox.critical(self, tr("dlg_error"), str(exc)) def _delete_selected(self): item = self.list_files.currentItem() @@ -506,7 +521,7 @@ def _delete_selected(self): def _save_img(self): default = self.img_path or "DSKA0000.IMG" path, _ = QFileDialog.getSaveFileName( - self, "1.44 MB FAT12-Diskettenabbild speichern", default, "Diskettenabbild (*.IMG *.img *.ima)" + self, tr("fd_save_img"), default, f"{tr('filter_floppy')} (*.IMG *.img *.ima)" ) if not path: return @@ -516,11 +531,8 @@ def _save_img(self): self._refresh_list() QMessageBox.information( self, - "Diskettenabbild gespeichert", - f"1.44-MB-FAT12-Abbild gespeichert:\n{path}\n\n" - "Tipp für GoTek/FlashFloppy: Benenne die Bilder auf dem USB-Stick z. B. " - "DSKA0000.IMG, DSKA0001.IMG oder kopiere die .EXP-Datei direkt, falls FlashFloppy " - "im Direct-File-Modus läuft.", + tr("msg_img_saved_title"), + tr("msg_img_saved_text").format(path=path), ) @@ -530,7 +542,7 @@ def _save_img(self): class PhotoStitchDialog(QDialog): def __init__(self, image_path: str, parent=None): super().__init__(parent) - self.setWindowTitle("PhotoStitch — Porträt- & Foto-Stickerei erzeugen") + self.setWindowTitle(tr("dlg_photo_title")) self.resize(740, 440) self.rgba = load_image(image_path) self.result_design: Design | None = None @@ -539,51 +551,51 @@ def __init__(self, image_path: str, parent=None): top = QHBoxLayout() v1 = QVBoxLayout() - self.lbl_orig_title = QLabel(f"Originalfoto ({os.path.basename(image_path)})") + self.lbl_orig_title = QLabel(tr("lbl_orig_photo").format(os.path.basename(image_path))) self.lbl_orig_title.setAlignment(Qt.AlignCenter) self.lbl_orig = QLabel() self.lbl_orig.setAlignment(Qt.AlignCenter) self.lbl_orig.setPixmap(_np_to_pixmap(self.rgba, 300)) - btn_change_img = QPushButton("📂 Anderes Foto wählen (Downloads / Desktop / Bilder …)") + btn_change_img = QPushButton(tr("btn_other_photo")) btn_change_img.clicked.connect(self._choose_other_image) v1.addWidget(self.lbl_orig_title) v1.addWidget(self.lbl_orig, 1) v1.addWidget(btn_change_img) top.addLayout(v1, 1) - grp = QGroupBox("PhotoStitch-Einstellungen") + grp = QGroupBox(tr("grp_photo_settings")) form = QFormLayout(grp) self.sp_width = QDoubleSpinBox() self.sp_width.setRange(40.0, 280.0) self.sp_width.setValue(110.0) self.sp_width.setSuffix(" mm") - form.addRow("Zielbreite:", self.sp_width) + form.addRow(tr("lbl_target_width"), self.sp_width) self.cmb_mode = QComboBox() - self.cmb_mode.addItem("Monochrom-Gravur (1 Garnfarbe auf hellem Stoff)", "mono") - self.cmb_mode.addItem("Sepia-Porträt (2 Garnfarben: Hellbraun + Dunkelbraun)", "sepia") - self.cmb_mode.addItem("4-Farb CMYK-Halbton (Gelb, Magenta, Cyan, Schwarz)", "color4") - form.addRow("Farbmodus:", self.cmb_mode) + for mode in ("mono", "sepia", "color4"): + self.cmb_mode.addItem(tr(f"photo_mode_{mode}"), mode) + form.addRow(tr("lbl_color_mode"), self.cmb_mode) self.sp_row = QDoubleSpinBox() self.sp_row.setRange(0.9, 3.0) self.sp_row.setSingleStep(0.1) self.sp_row.setValue(1.5) self.sp_row.setSuffix(" mm") - form.addRow("Zeilenabstand (Feinheit):", self.sp_row) + form.addRow(tr("lbl_row_spacing_fine"), self.sp_row) self.sp_contrast = QDoubleSpinBox() self.sp_contrast.setRange(0.7, 2.2) self.sp_contrast.setSingleStep(0.1) self.sp_contrast.setValue(1.25) - form.addRow("Kontrast-Verstärkung:", self.sp_contrast) + form.addRow(tr("lbl_contrast"), self.sp_contrast) top.addWidget(grp, 1) root.addLayout(top, 1) btns = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) - btns.button(QDialogButtonBox.Ok).setText("PhotoStitch berechnen && übernehmen") + btns.button(QDialogButtonBox.Ok).setText(tr("btn_photo_apply")) + btns.button(QDialogButtonBox.Cancel).setText(tr("btn_cancel")) btns.accepted.connect(self._run) btns.rejected.connect(self.reject) root.addWidget(btns) @@ -591,22 +603,20 @@ def __init__(self, image_path: str, parent=None): def _choose_other_image(self): p = self.parent() if p is not None and hasattr(p, "_ask_open_file"): - path = p._ask_open_file( - "Bild oder Logo wählen (Downloads / Bilder / Desktop)", - "Alle Bilder (*.png *.jpg *.jpeg *.webp *.bmp *.gif *.tif *.tiff);;Alle Dateien (*.*)", - ) + path = p._ask_open_file(tr("fd_pick_logo"), _image_filter()) else: path, _ = QFileDialog.getOpenFileName( - self, "Bild oder Logo wählen", "", "Bilder (*.png *.jpg *.jpeg *.webp *.bmp *.gif *.tif *.tiff)" + self, tr("fd_pick_logo_short"), "", + f"{tr('filter_images')} (*.png *.jpg *.jpeg *.webp *.bmp *.gif *.tif *.tiff)", ) if not path: return try: self.rgba = load_image(path) - self.lbl_orig_title.setText(f"Originalbild ({os.path.basename(path)})") + self.lbl_orig_title.setText(tr("lbl_orig_photo").format(os.path.basename(path))) self.lbl_orig.setPixmap(_np_to_pixmap(self.rgba, 340)) except Exception as exc: - QMessageBox.critical(self, "Fehler beim Laden", str(exc)) + QMessageBox.critical(self, tr("err_load_title"), str(exc)) def _run(self): p = PhotoStitchParams( @@ -619,5 +629,5 @@ def _run(self): self.result_design = generate_photostitch(self.rgba, p) self.accept() except Exception as exc: - QMessageBox.critical(self, "Fehler", str(exc)) + QMessageBox.critical(self, tr("dlg_error"), str(exc)) diff --git a/openstitch/ui/main_window.py b/openstitch/ui/main_window.py index f54ba1a..d682e05 100644 --- a/openstitch/ui/main_window.py +++ b/openstitch/ui/main_window.py @@ -38,7 +38,9 @@ from .. import APP_NAME, __version__ from ..auto.palette import CATALOG_PALETTES, active_palette, nearest_thread, set_active_palette -from ..i18n import LANGUAGES, current_lang, init_lang, key_for, set_lang, tr +from ..i18n import ( + LANGUAGES, current_lang, fmt_int, init_lang, key_for, machine_display_name, set_lang, tr, tr_opt, +) from ..core.design import HOOPS, Design, EmbObject, FillObject, Hoop, RunObject, SatinObject from ..core.machines import MACHINE_PROFILES, MachineProfile from ..core.stitchplan import Thread @@ -221,7 +223,7 @@ def __init__(self, defer_starter: bool = False, show_tour_on_init: bool = True): super().__init__() init_lang() self._tr_bindings: list[tuple] = [] - self.setWindowTitle(f"{APP_NAME} Studio {__version__} — Universal Open-Source Embroidery & Digitizing Suite") + self._bind(self.setWindowTitle, "app_tagline", fmt=lambda s: f"{APP_NAME} Studio {__version__} — {s}") self.resize(1540, 920) self.setStyleSheet(STUDIO_STYLE) self.setAcceptDrops(True) @@ -255,7 +257,7 @@ def __init__(self, defer_starter: bool = False, show_tour_on_init: bool = True): self.cmb_machine = QComboBox() self.cmb_machine.setMinimumWidth(225) for mp in MACHINE_PROFILES: - self.cmb_machine.addItem(mp.name, mp) + self.cmb_machine.addItem(machine_display_name(mp.name), mp) self.cmb_machine.currentIndexChanged.connect(self._on_machine_changed) ctx_hb.addWidget(self.cmb_machine) @@ -267,7 +269,7 @@ def __init__(self, defer_starter: bool = False, show_tour_on_init: bool = True): self.cmb_hoop = QComboBox() self.cmb_hoop.setMinimumWidth(200) for h in HOOPS: - self.cmb_hoop.addItem(f"{h.name} ({h.width:.0f}×{h.height:.0f} mm)", h) + self.cmb_hoop.addItem(self._hoop_label(h), h) self.cmb_hoop.setCurrentIndex(2) # Rund 15 cm Standard self.cmb_hoop.currentIndexChanged.connect(self._on_hoop_changed) ctx_hb.addWidget(self.cmb_hoop) @@ -277,13 +279,13 @@ def __init__(self, defer_starter: bool = False, show_tour_on_init: bool = True): btn_fit.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) btn_fit.setText("Auto-Fit") self._bind(btn_fit.setText, "btn_autofit") - btn_fit.setToolTip("Design optimal in den gewählten Rahmen einpassen (Ctrl+F)") + self._bind(btn_fit.setToolTip, "tip_autofit") btn_fit.clicked.connect(self.action_fit_to_hoop) ctx_hb.addWidget(btn_fit) self.btn_fabric = QToolButton() - self.btn_fabric.setText("Stoff: Dunkel") - self.btn_fabric.setToolTip("Stoffhintergrund im Stickrahmen umschalten (Dunkel-Schiefer / Warm-Sand / Hell)") + self.btn_fabric.setText(tr("fabric_slate")) + self._bind(self.btn_fabric.setToolTip, "tip_fabric") self.btn_fabric.clicked.connect(self.cycle_fabric_theme) ctx_hb.addWidget(self.btn_fabric) @@ -300,7 +302,7 @@ def __init__(self, defer_starter: bool = False, show_tour_on_init: bool = True): self.cmb_lang.currentIndexChanged.connect(self._on_language_changed) ctx_hb.addWidget(self.cmb_lang) ctx_hb.addSpacing(6) - self.lbl_hoop_strategy = QLabel("Strategie: Ausgewogen · 100% Zugkompensation") + self.lbl_hoop_strategy = QLabel() # Text setzt retranslate_ui() self.lbl_hoop_strategy.setStyleSheet( "color: #81c995; background-color: rgba(129, 201, 149, 0.12); " "border: 1px solid rgba(129, 201, 149, 0.28); border-radius: 5px; padding: 3px 8px; font-size: 8.5pt;" @@ -356,18 +358,34 @@ def _apply_binding(setter, key, escape, fmt): txt = fmt(txt) setter(txt) + def _update_strategy_label(self): + if hasattr(self, "lbl_hoop_strategy"): + self.lbl_hoop_strategy.setText( + f"{tr('lbl_strategy')}{tr('label_colon')} {self.canvas.design.hoop.strategy_description}" + ) + + @staticmethod + def _hoop_label(h: Hoop) -> str: + return f"{h.display_name} ({h.width:.0f}×{h.height:.0f} mm)" + def retranslate_ui(self): for setter, key, escape, fmt in self._tr_bindings: self._apply_binding(setter, key, escape, fmt) + for i in range(self.cmb_hoop.count()): + self.cmb_hoop.setItemText(i, self._hoop_label(self.cmb_hoop.itemData(i))) + for i in range(self.cmb_machine.count()): + self.cmb_machine.setItemText(i, machine_display_name(self.cmb_machine.itemData(i).name)) + self._refresh_object_list() + total = self.canvas.plan.stitch_count + if total: # Zahlenformat des Simulator-Zählers an die Sprache anpassen + cur = total if self.canvas.sim_limit is None else self.canvas.sim_limit + self.lbl_sim.setText(f"{fmt_int(cur)} / {fmt_int(total)}") lbl = {"slate": "fabric_slate", "sand": "fabric_sand", "light": "fabric_light"} self.btn_fabric.setText(tr(lbl.get(self.canvas.fabric_theme, "fabric_slate"))) self._refresh_stats() self._refresh_needle_list() self._populate_properties(self.canvas.selected) - if hasattr(self, "lbl_hoop_strategy"): - self.lbl_hoop_strategy.setText( - f"{tr('lbl_strategy')}: {self.canvas.design.hoop.strategy_description}" - ) + self._update_strategy_label() self.btn_play.setText(tr("btn_pause") if self.btn_play.isChecked() else tr("btn_play")) self.canvas.update() @@ -432,7 +450,8 @@ def _build_menus_and_header(self): (m_create, "menu_create"), (m_machine, "menu_machine"), (m_help, "menu_help")): self._bind(_m.setTitle, _k, fmt=lambda s: s.replace("&&", "&&")) - tb = QToolBar("Studio-Leiste", self) + tb = QToolBar(tr("tb_title_studio"), self) + self._bind(tb.setWindowTitle, "tb_title_studio") self.top_toolbar = tb tb.setMovable(False) tb.setIconSize(QSize(16, 16)) @@ -520,7 +539,8 @@ def add_act(menu, text, slot, shortcut=None, toolbar_label=None, icon_kind=None, # ================================= 2. Linke Vertikal-Werkzeugleiste (Tool-Rail) def _build_left_tool_rail(self): - rail = QToolBar("Zeichenwerkzeuge", self) + rail = QToolBar(tr("tb_title_tools"), self) + self._bind(rail.setWindowTitle, "tb_title_tools") self.left_rail = rail rail.setMovable(False) rail.setOrientation(Qt.Vertical) @@ -528,12 +548,10 @@ def _build_left_tool_rail(self): rail.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) self.addToolBar(Qt.LeftToolBarArea, rail) - def rail_btn(label: str, icon_kind: str, tip: str, slot, checkable: bool = True) -> QAction: - a = QAction(studio_icon(icon_kind, 17), label, self) - _k = key_for(label) - if _k: - self._bind(a.setText, _k, escape=True) - a.setToolTip(tip) + def rail_btn(key: str, icon_kind: str, tip_key: str, slot, checkable: bool = True) -> QAction: + a = QAction(studio_icon(icon_kind, 17), tr(key), self) + self._bind(a.setText, key, escape=True) + self._bind(a.setToolTip, tip_key) a.setCheckable(checkable) a.triggered.connect(slot) rail.addAction(a) @@ -542,15 +560,15 @@ def rail_btn(label: str, icon_kind: str, tip: str, slot, checkable: bool = True) w_btn.setMinimumWidth(116) return a - self.act_tool_select = rail_btn("Auswahl (V)", "select", "Objekte auswählen, verschieben & skalieren (Taste V)", lambda: self._set_tool("select")) - self.act_tool_nodes = rail_btn("Knoten (N)", "nodes", "Konturknoten & Stichwinkel-Achse direkt ziehen (Taste N)", lambda: self._set_tool("edit_nodes")) + self.act_tool_select = rail_btn("tool_select", "select", "tip_tool_select", lambda: self._set_tool("select")) + self.act_tool_nodes = rail_btn("tool_nodes", "nodes", "tip_tool_nodes", lambda: self._set_tool("edit_nodes")) rail.addSeparator() - self.act_tool_fill = rail_btn("Tatami (F)", "fill", "Neue Tatami-Füllfläche auf der Leinwand zeichnen (Taste F)", lambda: self._set_tool("draw_fill")) - self.act_tool_satin = rail_btn("Satin (S)", "satin", "Neue Satin-Säule auf der Leinwand zeichnen (Taste S)", lambda: self._set_tool("draw_satin")) - self.act_tool_run = rail_btn("Stepp (R)", "run", "Neuen Steppstich-Pfad zeichnen (Taste R)", lambda: self._set_tool("draw_run")) + self.act_tool_fill = rail_btn("tool_fill", "fill", "tip_tool_fill", lambda: self._set_tool("draw_fill")) + self.act_tool_satin = rail_btn("tool_satin", "satin", "tip_tool_satin", lambda: self._set_tool("draw_satin")) + self.act_tool_run = rail_btn("tool_run", "run", "tip_tool_run", lambda: self._set_tool("draw_run")) rail.addSeparator() - self.act_rail_heatmap = rail_btn("Heatmap", "heatmap", "Stichdichte-Heatmap (Grün/Gelb/Rot) ein-/ausblenden (Ctrl+H)", self._toggle_heatmap_from_rail) - rail_btn("Zoom 100%", "zoom", "Auf Rahmengrösse zoomen (Ctrl+0)", self.canvas.zoom_to_fit, checkable=False) + self.act_rail_heatmap = rail_btn("tool_heatmap", "heatmap", "tip_tool_heatmap", self._toggle_heatmap_from_rail) + rail_btn("tool_zoom", "zoom", "tip_tool_zoom", self.canvas.zoom_to_fit, checkable=False) self.act_tool_select.setChecked(True) def _toggle_heatmap_from_rail(self, checked: bool): @@ -650,10 +668,10 @@ def _build_right_dock(self): hb = QHBoxLayout() btn_up = QPushButton("▲") - btn_up.setToolTip("Objekt in der Stickreihenfolge nach oben") + self._bind(btn_up.setToolTip, "tip_obj_up") btn_up.clicked.connect(lambda: self._move_selected_object(-1)) btn_dn = QPushButton("▼") - btn_dn.setToolTip("Objekt in der Stickreihenfolge nach unten") + self._bind(btn_dn.setToolTip, "tip_obj_down") btn_dn.clicked.connect(lambda: self._move_selected_object(1)) btn_vis = QPushButton("👁 Sichtbar") self._bind(btn_vis.setText, "btn_visible") @@ -662,7 +680,7 @@ def _build_right_dock(self): self._bind(btn_dup.setText, "btn_copy") btn_dup.clicked.connect(self.action_duplicate_selected) btn_del = QPushButton("🗑") - btn_del.setToolTip("Ausgewähltes Objekt löschen (Entf)") + self._bind(btn_del.setToolTip, "tip_obj_delete") btn_del.clicked.connect(self.action_delete_selected) for b in (btn_up, btn_dn, btn_vis, btn_dup, btn_del): hb.addWidget(b) @@ -719,7 +737,7 @@ def _build_bottom_bar(self): self.slider_sim.valueChanged.connect(self._on_sim_slider) hb.addWidget(self.slider_sim, 1) - self.lbl_sim = QLabel("Alle") + self.lbl_sim = QLabel(tr("sim_all")) self.lbl_sim.setMinimumWidth(115) hb.addWidget(self.lbl_sim) @@ -729,7 +747,7 @@ def _build_bottom_bar(self): self.sp_sim_speed = QSpinBox() self.sp_sim_speed.setRange(5, 500) self.sp_sim_speed.setValue(45) - self.sp_sim_speed.setSuffix(" St./Tick") + self._bind(self.sp_sim_speed.setSuffix, "sim_speed_suffix") hb.addWidget(self.sp_sim_speed) dock.setWidget(w) @@ -791,8 +809,7 @@ def _sync_hoop_machine_ui(self): self.cmb_hoop.blockSignals(True) self.cmb_hoop.setCurrentIndex(idx) self.cmb_hoop.blockSignals(False) - if hasattr(self, "lbl_hoop_strategy"): - self.lbl_hoop_strategy.setText(f"{tr('lbl_strategy')}: {d.hoop.strategy_description}") + self._update_strategy_label() if hasattr(self, "cmb_machine"): idx = next((i for i in range(self.cmb_machine.count()) if self.cmb_machine.itemData(i).name == d.settings.machine_name), -1) @@ -847,7 +864,7 @@ def _apply_plan(self, plan): self.slider_sim.setRange(0, n) if self.canvas.sim_limit is None: self.slider_sim.setValue(n) - self.lbl_sim.setText(f"{n:,} / {n:,}".replace(",", "'")) + self.lbl_sim.setText(f"{fmt_int(n)} / {fmt_int(n)}") self.slider_sim.blockSignals(False) self._refresh_needle_list() @@ -861,24 +878,23 @@ def _refresh_stats(self): w, h = x1 - x0, y1 - y0 kin = analyze_kinematics(p, self.canvas.design.settings.machine_spm) self.lbl_stats.setText( - f"{tr('stats_stitches')}: {p.stitch_count:,} · " - f"{tr('stats_size')}: {w:.1f} × {h:.1f} mm · " - f"{tr('stats_avg')}: {kin['avg_len']:.1f} mm · " - f"{tr('stats_spm')}: ~{kin['eff_spm']} SPM · " - f"{tr('stats_peak')}: {kin['max_per_mm2']}/mm² · " - f"{tr('stats_colors')}: {p.color_changes} · " - f"Trims: {p.trim_count} · " - f"{tr('stats_runtime')} {kin['real_minutes']:.1f} {tr('stats_min')}".replace(",", "'") + f"{tr('stats_stitches')}{tr('label_colon')} {fmt_int(p.stitch_count)} · " + f"{tr('stats_size')}{tr('label_colon')} {w:.1f} × {h:.1f} mm · " + f"{tr('stats_avg')}{tr('label_colon')} {kin['avg_len']:.1f} mm · " + f"{tr('stats_spm')}{tr('label_colon')} ~{kin['eff_spm']} SPM · " + f"{tr('stats_peak')}{tr('label_colon')} {kin['max_per_mm2']}/mm² · " + f"{tr('stats_colors')}{tr('label_colon')} {p.color_changes} · " + f"{tr('stats_trims')}{tr('label_colon')} {p.trim_count} · " + f"{tr('stats_runtime')} {kin['real_minutes']:.1f} {tr('stats_min')}" ) def _refresh_object_list(self): self.list_objects.blockSignals(True) self.list_objects.clear() - kind_label = {"fill": "Tatami", "satin": "Satin", "run": "Stepp", "text": "Schrift", "stitches": "Stiche"} sel_row = -1 for i, o in enumerate(self.canvas.design.objects): - vis = "" if o.visible else "[Aus] " - kl = kind_label.get(o.kind, o.kind) + vis = "" if o.visible else f"{tr('obj_hidden')} " + kl = tr_opt(f"kind_{o.kind}", o.kind) it = QListWidgetItem(_color_icon(o.thread.color), f"{i + 1}. {vis}{kl} · {o.name or o.thread.name}") self.list_objects.addItem(it) if o is self.canvas.selected: @@ -900,7 +916,8 @@ def _refresh_needle_list(self): cnt = sum(1 for s in b.stitches if s.cmd == 0) it = QListWidgetItem( _color_icon(b.thread.color), - f"#{bi + 1} ({tr('needle')} {nd}): {b.thread.name or b.thread.hex} · {cnt} St.{warn}", + f"#{bi + 1} ({tr('needle')} {nd}): {b.thread.name or b.thread.hex} · " + f"{fmt_int(cnt)} {tr('abbr_stitches')}{warn}", ) self.list_needles.addItem(it) @@ -921,22 +938,26 @@ def _populate_properties(self, obj: EmbObject | None): ed_name = QLineEdit(obj.name) ed_name.setCursorPosition(0) ed_name.editingFinished.connect(lambda: self._set_obj_attr(obj, "name", ed_name.text())) - self.prop_layout.addRow(tr("lbl_obj_name") + ":", ed_name) + colon = tr("label_colon") # fr: geschütztes Leerzeichen vor dem Doppelpunkt + self.prop_layout.addRow(tr("lbl_obj_name") + colon, ed_name) + kind = getattr(obj, "kind", "") for attr, label, typ, vmin, vmax, step in getattr(obj, "PARAMS", ()): val = getattr(obj, attr) + # Labels werden erst bei der Anzeige übersetzt (PARAMS enthält das deutsche Original) + label = tr_opt(f"param_{kind}_{attr}", tr_opt(f"param_{attr}", label)) if typ is bool: cb = QCheckBox() cb.setChecked(bool(val)) cb.toggled.connect(lambda v, a=attr: self._set_obj_attr(obj, a, v)) - self.prop_layout.addRow(label + ":", cb) + self.prop_layout.addRow(label + colon, cb) elif typ is int: sp = QSpinBox() sp.setRange(int(vmin), int(vmax)) sp.setSingleStep(int(step or 1)) sp.setValue(int(val)) sp.valueChanged.connect(lambda v, a=attr: self._set_obj_attr(obj, a, int(v))) - self.prop_layout.addRow(label + ":", sp) + self.prop_layout.addRow(label + colon, sp) elif typ is float: sp = QDoubleSpinBox() sp.setDecimals(2) @@ -944,23 +965,24 @@ def _populate_properties(self, obj: EmbObject | None): sp.setSingleStep(float(step or 0.1)) sp.setValue(float(val)) sp.valueChanged.connect(lambda v, a=attr: self._set_obj_attr(obj, a, float(v))) - self.prop_layout.addRow(label + ":", sp) + self.prop_layout.addRow(label + colon, sp) elif typ is str: ed = QLineEdit(str(val)) ed.editingFinished.connect(lambda e=ed, a=attr: self._set_obj_attr(obj, a, e.text())) - self.prop_layout.addRow(label + ":", ed) + self.prop_layout.addRow(label + colon, ed) elif isinstance(typ, list): cmb = QComboBox() for opt in typ: - cmb.addItem(str(opt), opt) + # Datenwert bleibt unverändert (wird gespeichert), nur der Anzeigetext ist übersetzt + cmb.addItem(tr_opt(f"opt_{opt}", str(opt)), opt) if val in typ: cmb.setCurrentIndex(typ.index(val)) cmb.currentIndexChanged.connect(lambda _, c=cmb, a=attr: self._set_obj_attr(obj, a, c.currentData())) - self.prop_layout.addRow(label + ":", cmb) + self.prop_layout.addRow(label + colon, cmb) elif typ == "font": - btn_font = QPushButton("Schriftart & Bogen anpassen …") + btn_font = QPushButton(tr("btn_font_arc")) btn_font.clicked.connect(lambda: self._edit_text_object(obj)) - self.prop_layout.addRow(label + ":", btn_font) + self.prop_layout.addRow(label + colon, btn_font) self._updating_props = False @@ -1012,12 +1034,11 @@ def _on_hoop_changed(self): self.canvas.design.hoop = self.cmb_hoop.currentData() if self.canvas.design.hoop.mode == "cap": self._apply_cap_center_out_order() - if hasattr(self, "lbl_hoop_strategy"): - self.lbl_hoop_strategy.setText(f"{tr('lbl_strategy')}: {self.canvas.design.hoop.strategy_description}") + self._update_strategy_label() self.rebuild_plan() self.canvas.zoom_to_fit() self.status.showMessage( - f"Rahmen-Strategie aktiv: {self.canvas.design.hoop.strategy_description}", 7000 + tr("status_hoop_strategy").format(self.canvas.design.hoop.strategy_description), 7000 ) def _apply_cap_center_out_order(self): @@ -1086,7 +1107,7 @@ def _apply_thread(self, t: Thread): self.rebuild_plan() def _pick_custom_color(self): - col = QColorDialog.getColor(QColor(*self.canvas.active_thread.color), self, "Garnfarbe wählen") + col = QColorDialog.getColor(QColor(*self.canvas.active_thread.color), self, tr("color_dialog_title")) if not col.isValid(): return t = nearest_thread((col.red(), col.green(), col.blue())) @@ -1111,10 +1132,16 @@ def _confirm_discard(self) -> bool: """Fragt nach, bevor ungespeicherte Änderungen verworfen werden. True = weitermachen.""" if not self._dirty or not self.isVisible(): return True - ans = QMessageBox.question( - self, tr("dlg_unsaved_title"), tr("dlg_unsaved_text"), - QMessageBox.Save | QMessageBox.Discard | QMessageBox.Cancel, QMessageBox.Save, + box = QMessageBox( + QMessageBox.Question, tr("dlg_unsaved_title"), tr("dlg_unsaved_text"), + QMessageBox.Save | QMessageBox.Discard | QMessageBox.Cancel, self, ) + box.setDefaultButton(QMessageBox.Save) + for std, key in ((QMessageBox.Save, "btn_save"), (QMessageBox.Discard, "btn_discard"), + (QMessageBox.Cancel, "btn_cancel")): + box.button(std).setText(tr(key)) + box.exec() + ans = box.standardButton(box.clickedButton()) if ans == QMessageBox.Save: return self.action_save_project() return ans == QMessageBox.Discard @@ -1240,7 +1267,7 @@ def dropEvent(self, event: QDropEvent): self.set_design(load_project(path), reset_undo=True) self.canvas.zoom_to_fit() except Exception as exc: - QMessageBox.critical(self, "Fehler beim Öffnen", str(exc)) + QMessageBox.critical(self, tr("err_open_title"), str(exc)) elif ext == "svg": try: d = import_svg(path, target_width_mm=95.0) @@ -1248,14 +1275,14 @@ def dropEvent(self, event: QDropEvent): self.set_design(d, reset_undo=True) self.canvas.zoom_to_fit() except Exception as exc: - QMessageBox.critical(self, "SVG-Import-Fehler", str(exc)) + QMessageBox.critical(self, tr("err_svg_title"), str(exc)) elif ext in READ_EXT: try: d = import_file(path) self.set_design(d, reset_undo=True) self.canvas.zoom_to_fit() except Exception as exc: - QMessageBox.critical(self, "Import-Fehler", str(exc)) + QMessageBox.critical(self, tr("err_import_title"), str(exc)) elif ext in ("png", "jpg", "jpeg", "bmp", "webp", "tif", "tiff", "gif"): self._open_auto_digitize_for_path(path) @@ -1263,40 +1290,44 @@ def dropEvent(self, event: QDropEvent): def action_new(self): if not self._confirm_discard(): return - self.set_design(Design(name="Neues_Design"), reset_undo=True) + self.set_design(Design(name=tr("default_design_name")), reset_undo=True) def action_open_project(self): if not self._confirm_discard(): return - path = self._ask_open_file("Projekt öffnen", "OpenStitch-Projekt (*.ostitch);;Alle Dateien (*.*)") + path = self._ask_open_file( + tr("fd_open_project"), f"{tr('filter_project')} (*.ostitch);;{tr('filter_all_files')} (*.*)" + ) if not path: return try: self.set_design(load_project(path), reset_undo=True) self.canvas.zoom_to_fit() except Exception as exc: - QMessageBox.critical(self, "Fehler beim Öffnen", str(exc)) + QMessageBox.critical(self, tr("err_open_title"), str(exc)) def action_save_project(self): path = self._ask_save_file( - "Projekt speichern", f"{self.canvas.design.name}.ostitch", "OpenStitch-Projekt (*.ostitch)" + tr("fd_save_project"), f"{self.canvas.design.name}.ostitch", f"{tr('filter_project')} (*.ostitch)" ) if not path: return False try: save_project(self.canvas.design, path) except Exception as exc: - QMessageBox.critical(self, "Speicherfehler", str(exc)) + QMessageBox.critical(self, tr("err_save_title"), str(exc)) return False self._dirty = False - self.status.showMessage(f"Gespeichert: {path}", 5000) + self.status.showMessage(tr("status_saved").format(path), 5000) return True def action_import_embroidery(self): if not self._confirm_discard(): return exts = " ".join(f"*.{e} *.{e.upper()}" for e in READ_EXT) - path = self._ask_open_file("Stickdatei importieren", f"Stickdateien ({exts});;Alle Dateien (*.*)") + path = self._ask_open_file( + tr("fd_import_emb"), f"{tr('filter_emb')} ({exts});;{tr('filter_all_files')} (*.*)" + ) if not path: return try: @@ -1304,12 +1335,14 @@ def action_import_embroidery(self): self.set_design(d, reset_undo=True) self.canvas.zoom_to_fit() except Exception as exc: - QMessageBox.critical(self, "Import-Fehler", str(exc)) + QMessageBox.critical(self, tr("err_import_title"), str(exc)) def action_import_svg(self): if not self._confirm_discard(): return - path = self._ask_open_file("SVG-Vektorgrafik importieren", "SVG-Vektordatei (*.svg);;Alle Dateien (*.*)") + path = self._ask_open_file( + tr("fd_import_svg"), f"{tr('filter_svg')} (*.svg);;{tr('filter_all_files')} (*.*)" + ) if not path: return try: @@ -1318,14 +1351,12 @@ def action_import_svg(self): self.set_design(d, reset_undo=True) self.canvas.zoom_to_fit() except Exception as exc: - QMessageBox.critical(self, "SVG-Import-Fehler", str(exc)) + QMessageBox.critical(self, tr("err_svg_title"), str(exc)) def action_make_applique(self): obj = self.canvas.selected if obj is None or obj.kind not in ("fill", "satin"): - QMessageBox.information( - self, "Applikation (Appliqué)", "Bitte zuerst eine Fläche auf der Leinwand auswählen." - ) + QMessageBox.information(self, tr("msg_applique_title"), tr("msg_applique_text")) return app_objs = create_applique_objects(obj) if not app_objs: @@ -1343,7 +1374,7 @@ def action_quality_doctor(self): ) QMessageBox.information( self, - "Qualitäts- & Maschinen-Doktor", + tr("msg_doctor_title"), "\n\n".join(issues), ) @@ -1352,7 +1383,7 @@ def action_export_embroidery(self): ext_default = mp.native_ext.upper() if mp else "EXP" filters = ";;".join(f"{FORMAT_NAMES.get(e, e.upper())} (*.{e})" for e in WRITE_EXT) path = self._ask_save_file( - "Stickdatei exportieren", f"{self.canvas.design.name}.{ext_default}", filters + tr("fd_export_emb"), f"{self.canvas.design.name}.{ext_default}", filters ) if not path: return @@ -1364,22 +1395,21 @@ def action_export_embroidery(self): rep = verify_exported_file(path, self.canvas.plan) QMessageBox.information( self, - "Export & Binär-Verifikation erfolgreich", - f"✅ Stickdatei gespeichert und im Simulator verifiziert:\n{path}\n\n" - f"• Dateigrösse: {rep['bytes']:,} Bytes\n" - f"• Verifizierte Nähstiche: {rep['stitches']:,}\n" - f"• Abmessung: {rep['width_mm']:.1f} × {rep['height_mm']:.1f} mm\n" - f"• Max. Schrittweite: {rep['max_step_mm']:.1f} mm\n" - f"• Farbwechsel: {rep['color_changes']} · Sprünge: {rep['jumps']}".replace(",", "'"), + tr("msg_export_verified_title"), + tr("msg_export_verified_text").format( + path=path, size=fmt_int(rep["bytes"]), stitches=fmt_int(rep["stitches"]), + w=rep["width_mm"], h=rep["height_mm"], step=rep["max_step_mm"], + colors=rep["color_changes"], jumps=rep["jumps"], + ), ) else: - QMessageBox.information(self, "Export erfolgreich", f"Datei wurde gespeichert:\n{path}") + QMessageBox.information(self, tr("msg_export_ok_title"), tr("msg_export_ok_text").format(path)) except Exception as exc: - QMessageBox.critical(self, "Export-Fehler", str(exc)) + QMessageBox.critical(self, tr("err_export_title"), str(exc)) def action_export_pdf(self): path = self._ask_save_file( - "Produktionsblatt (PDF) speichern", f"{self.canvas.design.name}_Produktionsblatt.pdf", "PDF (*.pdf)" + tr("fd_save_pdf"), f"{self.canvas.design.name}_{tr('pdf_file_suffix')}.pdf", "PDF (*.pdf)" ) if not path: return @@ -1387,28 +1417,28 @@ def action_export_pdf(self): try: export_worksheet_pdf(self.canvas.design, self.canvas.plan, path) except Exception as exc: - QMessageBox.critical(self, "PDF-Fehler", str(exc)) + QMessageBox.critical(self, tr("err_pdf_title"), str(exc)) return - QMessageBox.information(self, "PDF erstellt", f"Produktionsblatt gespeichert:\n{path}") + QMessageBox.information(self, tr("msg_pdf_ok_title"), tr("msg_pdf_ok_text").format(path)) def action_export_png(self): path = self._ask_save_file( - "3D-Stichvorschau speichern", f"{self.canvas.design.name}.png", "PNG-Bild (*.png)" + tr("fd_save_png"), f"{self.canvas.design.name}.png", f"{tr('filter_png')} (*.png)" ) if not path: return self.wait_for_plan() if not render_image(self.canvas.plan, px_per_mm=10.0).save(path): - QMessageBox.critical(self, "PNG-Fehler", f"Bild konnte nicht gespeichert werden:\n{path}") + QMessageBox.critical(self, tr("err_png_title"), tr("err_png_text").format(path)) # ---------------------------------------------- Digitalisier-Assistenten def action_auto_digitize(self): path = self._ask_open_file( - "Bild, Grafik oder Logo wählen (Downloads / Bilder / Desktop)", - "Alle Bild- & Vektordateien (*.png *.jpg *.jpeg *.webp *.bmp *.gif *.tif *.tiff *.svg);;" - "Rasterbilder (*.png *.jpg *.jpeg *.webp *.bmp *.gif *.tif *.tiff);;" - "SVG-Vektorgrafik (*.svg);;" - "Alle Dateien (*.*)", + tr("fd_pick_image"), + f"{tr('filter_all_img_vec')} (*.png *.jpg *.jpeg *.webp *.bmp *.gif *.tif *.tiff *.svg);;" + f"{tr('filter_raster')} (*.png *.jpg *.jpeg *.webp *.bmp *.gif *.tif *.tiff);;" + f"{tr('filter_svg')} (*.svg);;" + f"{tr('filter_all_files')} (*.*)", ) if not path: return @@ -1419,7 +1449,7 @@ def action_auto_digitize(self): self.set_design(d, reset_undo=True) self.canvas.zoom_to_fit() except Exception as exc: - QMessageBox.critical(self, "SVG-Import-Fehler", str(exc)) + QMessageBox.critical(self, tr("err_svg_title"), str(exc)) return self._open_auto_digitize_for_path(path) @@ -1427,7 +1457,7 @@ def _open_auto_digitize_for_path(self, path: str): try: dlg = AutoDigitizeDialog(path, self) except Exception as exc: - QMessageBox.critical(self, "Bild-Fehler", str(exc)) + QMessageBox.critical(self, tr("err_image_title"), str(exc)) return if dlg.exec() == AutoDigitizeDialog.Accepted and dlg.result_data is not None: self._snapshot("Auto-Digitalisieren") @@ -1441,15 +1471,15 @@ def _open_auto_digitize_for_path(self, path: str): def action_photostitch(self): path = self._ask_open_file( - "Porträt- oder Landschaftsfoto wählen (Downloads / Bilder / Desktop)", - "Fotos & Bilder (*.png *.jpg *.jpeg *.webp *.bmp *.tif *.tiff);;Alle Dateien (*.*)", + tr("fd_pick_photo"), + f"{tr('filter_photos')} (*.png *.jpg *.jpeg *.webp *.bmp *.tif *.tiff);;{tr('filter_all_files')} (*.*)", ) if not path: return try: dlg = PhotoStitchDialog(path, self) except Exception as exc: - QMessageBox.critical(self, "Bild-Fehler", str(exc)) + QMessageBox.critical(self, tr("err_image_title"), str(exc)) return if dlg.exec() == PhotoStitchDialog.Accepted and dlg.result_design is not None: self._snapshot("PhotoStitch") @@ -1468,7 +1498,7 @@ def action_toggle_3d_puff(self): return satins = [o for o in self.canvas.design.objects if isinstance(o, SatinObject)] if not satins: - QMessageBox.information(self, "3D-Puff-Schaumstoff", "Bitte wähle ein Satin-Objekt aus.") + QMessageBox.information(self, tr("msg_puff_title"), tr("msg_puff_text")) return self._snapshot("3D-Puff für alle Satin-Objekte") new_state = not satins[0].puff_3d @@ -1556,9 +1586,16 @@ def action_center_design(self): def action_scale_design(self): from PySide6.QtWidgets import QInputDialog - pct, ok = QInputDialog.getDouble( - self, "Design skalieren (Stiche werden neu berechnet!)", "Skalierungsfaktor (%):", 100.0, 20.0, 400.0, 1 - ) + dlg = QInputDialog(self) + dlg.setInputMode(QInputDialog.DoubleInput) + dlg.setWindowTitle(tr("dlg_scale_title")) + dlg.setLabelText(tr("dlg_scale_label")) + dlg.setDoubleDecimals(1) + dlg.setDoubleRange(20.0, 400.0) + dlg.setDoubleValue(100.0) + dlg.setCancelButtonText(tr("btn_cancel")) + ok = dlg.exec() == QInputDialog.Accepted + pct = dlg.doubleValue() if not ok or abs(pct - 100.0) < 1e-3: return self._snapshot("Design skalieren") @@ -1596,17 +1633,17 @@ def _sim_step(self): self.slider_sim.blockSignals(True) self.slider_sim.setValue(cur) self.slider_sim.blockSignals(False) - self.lbl_sim.setText(f"{cur:,} / {total:,}".replace(",", "'")) + self.lbl_sim.setText(f"{fmt_int(cur)} / {fmt_int(total)}") self.canvas.update() # sim_limit ist Teil des Cache-Schlüssels def _on_sim_slider(self, val: int): total = self.canvas.plan.stitch_count if val >= total: self.canvas.sim_limit = None - self.lbl_sim.setText(f"{total:,} / {total:,}".replace(",", "'")) + self.lbl_sim.setText(f"{fmt_int(total)} / {fmt_int(total)}") else: self.canvas.sim_limit = val - self.lbl_sim.setText(f"{val:,} / {total:,}".replace(",", "'")) + self.lbl_sim.setText(f"{fmt_int(val)} / {fmt_int(total)}") self.canvas.update() # sim_limit ist Teil des Cache-Schlüssels def _reset_sim(self): diff --git a/tests/test_i18n_coverage.py b/tests/test_i18n_coverage.py new file mode 100644 index 0000000..0ddb2bc --- /dev/null +++ b/tests/test_i18n_coverage.py @@ -0,0 +1,226 @@ +"""Prüft, dass die Oberfläche vollständig übersetzbar ist (DE/EN/FR/ES).""" +from __future__ import annotations + +import re +import string +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from PySide6.QtGui import QAction +from PySide6.QtWidgets import QAbstractButton, QApplication, QComboBox, QDockWidget, QGroupBox, QLabel +from shapely.geometry import LineString, box + +from openstitch import i18n +from openstitch.core.design import HOOP_NAME_KEYS, HOOPS, FillObject, Hoop, RunObject, SatinObject +from openstitch.core.stitchplan import Thread +from openstitch.i18n import _TABLE, fmt_int, set_lang, tr +from openstitch.text import TextObject +from openstitch.ui.main_window import MainWindow + +_app = QApplication.instance() or QApplication(sys.argv) + +SRC = Path(__file__).resolve().parent.parent / "openstitch" + +# Wörter, die nur im deutschen UI vorkommen dürfen (keine Fremd-/Fachwörter, die auch englisch sind) +GERMAN_ONLY = ( + "Stichwinkel", "Unterlage", "Abbrechen", "Rahmen", "Speichern", "Fehler", "Füllung", "Zugkompensation", + "Reihenabstand", "Stichlänge", "Muster", "Dichte", "Bezeichnung", "Buchstabenhöhe", "Zeichenabstand", + "Bogenradius", "Stichart", "Wiederholungen", "Schriftart", "Strategie", "Ausgewogen", "Stiche", + "Fadenschnitte", "Sichtbar", "Kopie", "Fortschritt", "Tempo", "Garnkatalog", "Nadel", "Maschine", + "Stickrahmen", "Sprache", "Auswahl", "Knoten", "Stepp", "Ebenen", "Objekt", "Mittellauf", "Kantenlauf", + "Ziegel", "Wellen", "Automatisch", "Rund ", "Schliessen", "Übernehmen", "Taste", +) + + +def _source_files(): + return [p for p in SRC.rglob("*.py") if "__pycache__" not in p.parts] + + +def test_all_literal_tr_keys_exist(): + tr_re = re.compile(r"""\btr\(\s*(['"])([A-Za-z0-9_+]+)\1\s*\)""") + bind_re = re.compile(r"""\._bind\(\s*[\w.]+\s*,\s*(['"])([A-Za-z0-9_+]+)\1""") + missing = [] + for path in _source_files(): + src = path.read_text(encoding="utf-8") + for rx in (tr_re, bind_re): + for m in rx.finditer(src): + if m.group(2) not in _TABLE: + missing.append(f"{path.relative_to(SRC.parent)}: {m.group(2)}") + assert not missing, "Fehlende Übersetzungsschlüssel:\n" + "\n".join(missing) + + +def test_table_rows_complete_and_consistent(): + fmt = string.Formatter() + for key, row in _TABLE.items(): + assert isinstance(row, tuple) and len(row) == 4, key + assert all(isinstance(s, str) and s.strip() for s in row), key + if key == "pdf_date_fmt": + continue + # Platzhalter müssen in allen Sprachen identisch sein, sonst schlägt .format() fehl + fields = [sorted((f or "#") for _, f, _, _ in fmt.parse(s) if f is not None) for s in row] + assert all(f == fields[0] for f in fields), f"{key}: {fields}" + + +def test_dynamic_keys_exist(): + # Inspektor-Labels & Optionswerte + for cls in (FillObject, SatinObject, RunObject, TextObject): + kind = cls.kind + for attr, _label, typ, *_ in cls.PARAMS: + assert f"param_{kind}_{attr}" in _TABLE or f"param_{attr}" in _TABLE, (cls.__name__, attr) + if isinstance(typ, list): + for opt in typ: + assert f"opt_{opt}" in _TABLE, (cls.__name__, attr, opt) + for kind in ("fill", "satin", "run", "text", "stitches"): + assert f"kind_{kind}" in _TABLE + # Rahmen: jeder Name hat eine Übersetzung, jede Strategie eine Beschreibung + assert {h.name for h in HOOPS} == set(HOOP_NAME_KEYS) + for key in HOOP_NAME_KEYS.values(): + assert key in _TABLE + for h in HOOPS: + assert _TABLE[HOOP_NAME_KEYS[h.name]][0] == h.name # deutscher Anzeigename == gespeicherter Name + assert f"hoop_strat_{h.mode}" in _TABLE + from openstitch.core.machines import MACHINE_PROFILES + for mp in MACHINE_PROFILES: + assert _TABLE[f"machine_{mp.id}"][0] == mp.name + # Fortschrittsmeldungen der Auto-Digitizer-Engine werden im Dialog übersetzt + engine_src = (SRC / "auto" / "digitize.py").read_text(encoding="utf-8") + for msg in re.findall(r'step\("([^"]+)"', engine_src): + assert i18n.key_for(msg) is not None, msg + + +def test_hoop_strategy_and_names_follow_language(): + h = HOOPS[2] + try: + set_lang("en") + assert "pull compensation" in h.strategy_description + assert h.display_name == "Round 15 cm (standard)" + assert Hoop("Eigener Rahmen", 100, 100).display_name == "Eigener Rahmen" + assert Hoop("x", 100, 100, mode="unknown").strategy_description == "Standard" + set_lang("fr") + assert "compensation de traction" in h.strategy_description + finally: + set_lang("de") + assert h.name == "Rund 15 cm (Standard)" + assert "Zugkompensation" in h.strategy_description + + +def test_fmt_int_per_language(): + expected = {"de": "1’234’567", "en": "1,234,567", "fr": "1 234 567", "es": "1.234.567"} + try: + for lang, exp in expected.items(): + set_lang(lang) + assert fmt_int(1234567) == exp + assert fmt_int(999) == "999" + finally: + set_lang("de") + + +def _visible_texts(win: MainWindow) -> list[str]: + texts = [win.windowTitle()] + for w in win.findChildren(QLabel): + texts.append(w.text()) + for w in win.findChildren(QAbstractButton): # QPushButton, QCheckBox, QToolButton + texts.append(w.text()) + texts.append(w.toolTip()) + for w in win.findChildren(QComboBox): + texts.extend(w.itemText(i) for i in range(w.count())) + for w in win.findChildren(QDockWidget): + texts.append(w.windowTitle()) + for a in win.findChildren(QAction): + texts.append(a.text()) + texts.append(a.toolTip()) + for m in win.menuBar().actions(): + texts.append(m.text()) + texts.append(win.sp_sim_speed.suffix()) + texts.extend(win.list_objects.item(i).text() for i in range(win.list_objects.count())) + texts.extend(win.list_needles.item(i).text() for i in range(win.list_needles.count())) + return [t for t in texts if t] + + +def _assert_no_german(texts: list[str], where: str): + hits = sorted({f"{w!r} in {t!r}" for t in texts for w in GERMAN_ONLY if w in t}) + assert not hits, f"Deutscher Text in {where}:\n" + "\n".join(hits) + + +def test_main_window_and_inspector_fully_translated(): + win = MainWindow(show_tour_on_init=False, defer_starter=True) + th = Thread((200, 30, 40), "Red", "1147", "Madeira") + fill = FillObject(thread=th, name="F1", geom=box(-20, -20, 20, 20)) + satin = SatinObject(thread=th, name="S1", geom=LineString([(-20, 30), (20, 30)]).buffer(2.0)) + run = RunObject(thread=th, name="R1", geom=LineString([(-20, -30), (20, -30)])) + text = TextObject(thread=th, name="T1", text="AB", height=12.0) + run.visible = False # "[Aus]"-Markierung in der Objektliste prüfen + win.canvas.design.objects[:] = [fill, satin, run, text] + win.rebuild_plan() + win.wait_for_plan() + try: + idx = next(i for i in range(win.cmb_lang.count()) if win.cmb_lang.itemData(i) == "en") + win.cmb_lang.setCurrentIndex(idx) + assert i18n.current_lang() == "en" + assert win.lbl_hoop_strategy.text().startswith("Strategy: ") + assert "Fit to hoop" in [a.text() for a in win.left_rail.actions()] + for obj in (fill, satin, run, text, None): + win.canvas.selected = obj + win._populate_properties(obj) + _assert_no_german(_visible_texts(win), f"Inspektor ({type(obj).__name__})") + # Optionswerte: Anzeige übersetzt, Daten unverändert + win._populate_properties(satin) + combos = win.prop_container.findChildren(QComboBox) + assert combos and combos[0].itemData(1) == "mitte" and combos[0].itemText(1) == "Center walk" + assert not any("Stitch angle" in t for t in _visible_texts(win)) # Satin hat keinen Stichwinkel + win._populate_properties(fill) + assert any(t.startswith("Stitch angle") for t in _visible_texts(win)) + # Zurück auf Deutsch: Texte wechseln zurück + idx = next(i for i in range(win.cmb_lang.count()) if win.cmb_lang.itemData(i) == "de") + win.cmb_lang.setCurrentIndex(idx) + win._populate_properties(fill) + assert any(t.startswith("Stichwinkel") for t in _visible_texts(win)) + assert win.cmb_hoop.currentText().startswith("Rund 15 cm") + finally: + set_lang("de") + win._dirty = False + win.close() + + +def _dialog_texts(dlg) -> list[str]: + texts = [dlg.windowTitle()] + texts += [w.text() for w in dlg.findChildren(QLabel)] + texts += [w.text() for w in dlg.findChildren(QAbstractButton)] + texts += [w.title() for w in dlg.findChildren(QGroupBox)] + for c in dlg.findChildren(QComboBox): + texts += [c.itemText(i) for i in range(c.count())] + return [t for t in texts if t] + + +def test_dialogs_and_pdf_translated(tmp_path): + import cv2 + import numpy as np + + from openstitch.core.design import Design + from openstitch.io.project import export_worksheet_pdf + from openstitch.ui.dialogs import AutoDigitizeDialog, MelcoDiskDialog, PhotoStitchDialog, TextDialog, _tr_progress + + img = np.full((80, 100, 3), 255, dtype=np.uint8) + cv2.circle(img, (50, 40), 30, (40, 40, 210), -1) + img_path = str(tmp_path / "logo.png") + cv2.imwrite(img_path, img) + th = Thread((200, 30, 40), "Red") + d = Design(objects=[FillObject(thread=th, geom=box(-10, -10, 10, 10))]) + plan = d.build_plan() + try: + for lang in ("en", "fr", "es"): + set_lang(lang) + for dlg in (AutoDigitizeDialog(img_path), PhotoStitchDialog(img_path), TextDialog(th), + MelcoDiskDialog(plan, "TEST")): + _assert_no_german(_dialog_texts(dlg), f"{type(dlg).__name__} ({lang})") + dlg.deleteLater() + # Fortschrittsmeldungen der Engine werden übersetzt + assert _tr_progress("Fläche 3/7 klassifizieren …") == tr("prog_classify").format(3, 7) + assert _tr_progress("Farben reduzieren …") == tr("prog_reduce") + out = tmp_path / f"sheet_{lang}.pdf" + export_worksheet_pdf(d, plan, str(out)) + assert out.stat().st_size > 1000 + finally: + set_lang("de") From 317343f87e1fb67e90a9126ea197e4f61e525fa8 Mon Sep 17 00:00:00 2001 From: lbaumberger Date: Wed, 28 Oct 2026 16:16:13 +0100 Subject: [PATCH 12/18] Overlap-aware colour sort; confirm before overwriting floppy files - 'Optimise colour order' only moves an object forward to an earlier block of the same colour when it overlaps none of the objects it skips, so stacking order stays visually identical. - The Melco floppy dialog asks before replacing a file with the same 8.3 name (two long design names easily map to the same short name). - ColumnObject (manual satin column: centre line + width) data model; the drawing tool is wired up once the column stitch engine lands. Co-Authored-By: Claude --- openstitch/core/design.py | 81 +++++++++++++++++++++++++++++++++++- openstitch/i18n.py | 13 ++++++ openstitch/ui/dialogs.py | 6 +++ openstitch/ui/main_window.py | 15 +++---- tests/test_openstitch.py | 12 ++++++ 5 files changed, 116 insertions(+), 11 deletions(-) diff --git a/openstitch/core/design.py b/openstitch/core/design.py index 304d213..1af7bae 100644 --- a/openstitch/core/design.py +++ b/openstitch/core/design.py @@ -262,6 +262,53 @@ def runs(self, start=None): return out +@dataclass +class ColumnObject(EmbObject): + """Manuelle Satinsäule: gezeichnete Mittellinie + Breite. + + Die Stiche stehen exakt senkrecht zur gezeichneten Linie (wie „Column A/B“ in kommerziellen + Programmen) – kein Rückraten der Mittellinie per Skelett wie beim Flächen-Satin.""" + geom: Any = None # LineString / MultiLineString (Mittellinie) + width: float = 3.0 + spacing: float = 0.4 + pull_comp: float = 0.15 + underlay: bool = True + kind: str = "column" + + PARAMS = (("width", "Säulenbreite (mm)", float, 0.6, 12.0, 0.1), + ("spacing", "Dichte (mm)", float, 0.2, 1.2, 0.05), + ("pull_comp", "Zugkompensation (mm)", float, 0.0, 0.8, 0.05), + ("underlay", "Unterlage", bool, 0, 0, 0)) + + def local_geom(self): + return self.geom + + def bounds(self): + g = self.world_geom() + if g is None or g.is_empty: + return (0, 0, 0, 0) + return g.buffer(self.width * m_scale(self.transform) / 2.0).bounds + + def runs(self, start=None): + from ..engine.column import column_runs + g = self.world_geom() + w = self.width * m_scale(self.transform) + out: list[list[Pt]] = [] + cur = start + lines = lines_of(g) + while lines: + if cur is not None: + lines.sort(key=lambda l: min(dist(cur, l.coords[0]), dist(cur, l.coords[-1]))) + l = lines.pop(0) + rs = column_runs(list(l.coords), w, spacing=self.spacing, pull_comp=self.pull_comp, + underlay="center" if self.underlay else "none", start=cur) + rs = [r for r in rs if len(r) >= 2] + if rs: + out.extend(rs) + cur = rs[-1][-1] + return out + + @dataclass class StitchesObject(EmbObject): """Importierte Stiche (z. B. aus DST/EXP). Stiche sind fix, können aber transformiert werden.""" @@ -282,7 +329,7 @@ def to_dict(self): # Textobjekt ist in text.py definiert (benötigt Font-Engine), Registrierung dort. -OBJECT_TYPES: dict[str, type] = {"fill": FillObject, "satin": SatinObject, "run": RunObject, +OBJECT_TYPES: dict[str, type] = {"fill": FillObject, "satin": SatinObject, "run": RunObject, "column": ColumnObject, "stitches": StitchesObject} @@ -310,7 +357,7 @@ def _geom_dict(obj: EmbObject, d: dict) -> dict: return d -for _cls in (FillObject, SatinObject, RunObject): +for _cls in (FillObject, SatinObject, RunObject, ColumnObject): _orig = _cls.to_dict def _to_dict(self, _orig=_orig): @@ -318,6 +365,36 @@ def _to_dict(self, _orig=_orig): _cls.to_dict = _to_dict +def color_sorted(objects: list[EmbObject]) -> list[EmbObject]: + """Farbwechsel minimieren, ohne die Überdeckung zu verändern. + + Ein Objekt darf nur dann nach vorne zu einem früheren Block gleicher Farbe rutschen, wenn es + keines der übersprungenen Objekte berührt – sonst würde es plötzlich darunter statt darüber + gestickt (das naive Gruppieren nach Farbe zerstört genau das).""" + geoms = {} + + def footprint(o): + if o.id not in geoms: + g = o.world_geom() + geoms[o.id] = None if g is None or g.is_empty else g.buffer(0.3) + return geoms[o.id] + + out: list[EmbObject] = [] + for o in objects: + pos = len(out) + # rückwärts nach dem letzten Objekt gleicher Farbe suchen, solange nichts überlappt + for k in range(len(out) - 1, -1, -1): + prev = out[k] + if prev.thread.hex == o.thread.hex: + pos = k + 1 + break + fo, fp = footprint(o), footprint(prev) + if fo is None or fp is None or fo.intersects(fp): + break + out.insert(pos, o) + return out + + # --------------------------------------------------------------------------- # Design # --------------------------------------------------------------------------- diff --git a/openstitch/i18n.py b/openstitch/i18n.py index 02ba073..9239edc 100644 --- a/openstitch/i18n.py +++ b/openstitch/i18n.py @@ -213,6 +213,19 @@ def key_for(german_text: str) -> str | None: }) +# Satinsäule & Diskette +_TABLE.update({ + "kind_column": ("Satinsäule", "Satin column", "Colonne satin", "Columna satén"), + "param_column_width": ("Säulenbreite (mm)", "Column width (mm)", "Largeur de colonne (mm)", + "Ancho de columna (mm)"), + "msg_overwrite_title": ("Datei ersetzen?", "Replace file?", "Remplacer le fichier ?", "¿Reemplazar archivo?"), + "msg_overwrite_text": ("Auf der Diskette gibt es bereits {name}. Ersetzen?", + "{name} already exists on the floppy image. Replace it?", + "{name} existe déjà sur la disquette. Le remplacer ?", + "{name} ya existe en el disquete. ¿Reemplazarlo?"), +}) + + def tr_all(key: str) -> list[str]: return list(_TABLE.get(key, (key,) * 4)) diff --git a/openstitch/ui/dialogs.py b/openstitch/ui/dialogs.py index 3e883e3..d8d4654 100644 --- a/openstitch/ui/dialogs.py +++ b/openstitch/ui/dialogs.py @@ -505,6 +505,12 @@ def _open_img(self): def _add_current_to_img(self): try: name, data = self._current_exp_bytes() + # 8.3-Namen sind kurz: zwei Designs landen leicht auf demselben Namen → nachfragen + if any(f == name for f, _ in self.image.list_files()): + ans = QMessageBox.question(self, tr("msg_overwrite_title"), + tr("msg_overwrite_text").format(name=name)) + if ans != QMessageBox.Yes: + return self.image.add_file(name, data) self._refresh_list() except Exception as exc: diff --git a/openstitch/ui/main_window.py b/openstitch/ui/main_window.py index d682e05..8d09101 100644 --- a/openstitch/ui/main_window.py +++ b/openstitch/ui/main_window.py @@ -41,7 +41,7 @@ from ..i18n import ( LANGUAGES, current_lang, fmt_int, init_lang, key_for, machine_display_name, set_lang, tr, tr_opt, ) -from ..core.design import HOOPS, Design, EmbObject, FillObject, Hoop, RunObject, SatinObject +from ..core.design import HOOPS, Design, EmbObject, FillObject, Hoop, RunObject, SatinObject, color_sorted from ..core.machines import MACHINE_PROFILES, MachineProfile from ..core.stitchplan import Thread from ..engine.pro_tools import create_applique_objects, import_svg, run_quality_doctor @@ -1520,15 +1520,12 @@ def action_melco_disk(self): dlg.exec() def action_optimize_color_order(self): - self._snapshot("Farbwechsel optimieren") objs = self.canvas.design.objects - grouped: list[EmbObject] = [] - seen_hex: list[str] = [] - for o in objs: - if o.thread.hex not in seen_hex: - seen_hex.append(o.thread.hex) - for hx in seen_hex: - grouped.extend([o for o in objs if o.thread.hex == hx]) + # Nur umsortieren, wo sich nichts überdeckt – die Stapelreihenfolge bleibt sichtbar gleich + grouped = color_sorted(objs) + if [o.id for o in grouped] == [o.id for o in objs]: + return + self._snapshot("Farbwechsel optimieren") self.canvas.design.objects = grouped self.rebuild_plan() diff --git a/tests/test_openstitch.py b/tests/test_openstitch.py index b8f324f..381ba2b 100644 --- a/tests/test_openstitch.py +++ b/tests/test_openstitch.py @@ -346,3 +346,15 @@ def test_jef_export_keeps_trims(tmp_path): export_plan(plan, path) raw = open(path, "rb").read() assert raw.count(b"\x80\x02\x00\x00\x80\x02\x00\x00\x80\x02\x00\x00") >= plan.trim_count - 1 + + +def test_color_sort_respects_overlaps(): + from openstitch.core.design import color_sorted + red, blue = Thread((200, 0, 0)), Thread((0, 0, 200)) + a = FillObject(red, geom=Point(-30, 0).buffer(5)) + b = FillObject(blue, geom=Point(0, 0).buffer(5)) + c = FillObject(red, geom=Point(30, 0).buffer(5)) + assert [o.id for o in color_sorted([a, b, c])] == [a.id, c.id, b.id] + # c liegt jetzt auf b → darf nicht unter b rutschen + c2 = FillObject(red, geom=Point(4, 0).buffer(5)) + assert [o.id for o in color_sorted([a, b, c2])] == [a.id, b.id, c2.id] From 9a05c0d9af2c5867f6c70905de4c92c4c42634ca Mon Sep 17 00:00:00 2001 From: lbaumberger Date: Wed, 28 Oct 2026 16:19:18 +0100 Subject: [PATCH 13/18] Translate Quality Doctor; measure real radius for round-hoop checks - Quality Doctor messages go through tr() in all four languages and suggest the OpenStitch Micro font for small lettering. - Round-hoop collision check and auto hoop selection used the bounding box corners, overestimating round designs by up to 41 % (a 64 mm circle was reported as 90 mm in a 75 mm hoop). They now use the actual needle positions. Co-Authored-By: Claude --- openstitch/engine/pro_tools.py | 47 +++++++++------------------ openstitch/i18n.py | 59 ++++++++++++++++++++++++++++++++++ openstitch/ui/canvas.py | 9 ++++-- openstitch/ui/main_window.py | 5 ++- tests/test_openstitch.py | 8 +++++ 5 files changed, 93 insertions(+), 35 deletions(-) diff --git a/openstitch/engine/pro_tools.py b/openstitch/engine/pro_tools.py index bb11f81..bc4c969 100644 --- a/openstitch/engine/pro_tools.py +++ b/openstitch/engine/pro_tools.py @@ -13,6 +13,7 @@ from ..core.design import Design, EmbObject, FillObject, RunObject, SatinObject from ..core.stitchplan import Cmd, StitchPlan, Thread from ..engine.geom import clean, main_axis_angle, polygons_of +from ..i18n import fmt_float, fmt_int, tr # =========================================================================== @@ -635,46 +636,35 @@ def run_quality_doctor(design: Design, plan: StitchPlan, max_needles: int = 10) x0, y0, x1, y1 = plan.bounds() if plan.stitch_count == 0: - return ["Das Design enthält noch keine Stiche."] + return [tr("doc_empty")] # 1. Rahmengrenzen-Check (Kollisionsgefahr Nadel/Presserfuss mit dem Rahmen!) if design.hoop.round: - r_max = max(math.hypot(x0, y0), math.hypot(x1, y1), math.hypot(x0, y1), math.hypot(x1, y0)) + # tatsächliche Nadelpositionen statt Bounding-Box-Ecken (die überschätzen runde Motive um bis zu 41 %) + r_max = max((math.hypot(s.x, s.y) for b in plan.blocks for s in b.stitches + if s.cmd in (Cmd.STITCH, Cmd.JUMP)), default=0.0) if r_max > hw - 2.0: - issues.append( - f"⚠ RAHMEN-KOLLISIONSGEFAHR: Das Motiv (Radius {r_max:.1f} mm) ragt an den Rand des Rundrahmens " - f"({design.hoop.name}, Radius {hw:.0f} mm). Wähle einen grösseren Rahmen oder zentriere/verkleinere das Motiv!" - ) + issues.append(tr("doc_hoop_round").format(r=fmt_float(r_max), hoop=design.hoop.display_name, + hr=fmt_float(hw, 0))) else: if x0 < -hw + 2 or x1 > hw - 2 or y0 < -hh + 2 or y1 > hh - 2: - issues.append( - f"⚠ RAHMEN-KOLLISIONSGEFAHR: Das Motiv ({x1 - x0:.1f}×{y1 - y0:.1f} mm) überschreitet das sichere Stickfeld " - f"des Rahmens ({design.hoop.width:.0f}×{design.hoop.height:.0f} mm)." - ) + issues.append(tr("doc_hoop_rect").format(w=fmt_float(x1 - x0), h=fmt_float(y1 - y0), + hw=fmt_float(design.hoop.width, 0), + hh=fmt_float(design.hoop.height, 0))) # 2. Nadelanzahl-Check unique_colors = len({b.thread.hex for b in plan.blocks}) if unique_colors > max_needles: - issues.append( - f"⚠ ZU VIELE FARBEN: Das Design verwendet {unique_colors} Garnfarben, deine Maschine hat aber " - f"{max_needles} Nadeln. Reduziere die Farben oder plane einen manuellen Garnwechsel ein." - ) + issues.append(tr("doc_colors").format(n=unique_colors, needles=max_needles)) # 3. Objekt-Checks (zu kleine Schrift / zu schmale Satin-Säulen) for o in design.objects: if not o.visible: continue if o.kind == "text" and getattr(o, "height", 10.0) < 4.5: - issues.append( - f"⚠ KLEINE SCHRIFT ({o.name}, {o.height:.1f} mm): Normale TrueType-Schriften unter 4.5 mm " - f"laufen auf Stoff oft zu. Tipp: Nutze unter 5 mm eine kräftige serifenlose Schrift mit " - f"Dichte 0.45 mm oder Dreifachstich!" - ) + issues.append(tr("doc_small_text").format(name=o.name, h=fmt_float(o.height))) if o.kind == "satin" and getattr(o, "spacing", 0.4) < 0.28: - issues.append( - f"⚠ HOHE SATIN-DICHTE ({o.name}, {o.spacing:.2f} mm): Dichte unter 0.28 mm kann zu " - f"Fadenbruch führen (empfohlen: 0.36–0.42 mm)." - ) + issues.append(tr("doc_satin_density").format(name=o.name, sp=fmt_float(o.spacing, 2))) # 4. Lokale Stichdichte prüfen cells: dict[tuple[int, int], int] = defaultdict(int) @@ -684,14 +674,9 @@ def run_quality_doctor(design: Design, plan: StitchPlan, max_needles: int = 10) cells[(int(math.floor(s.x)), int(math.floor(s.y)))] += 1 hot_cells = sum(1 for c in cells.values() if c > 14) if hot_cells > 5: - issues.append( - f"⚠ DICHTE-SPITZEN: An {hot_cells} Stellen (1×1 mm) liegen mehr als 14 Stiche übereinander " - f"(Schalte mit Strg+H die Heatmap ein, um sie rot zu sehen)." - ) + issues.append(tr("doc_density").format(n=hot_cells)) if not issues: - issues.append( - f"✅ PERFEKT: Keine Probleme gefunden! ({plan.stitch_count:,} Stiche, {unique_colors} von {max_needles} Nadeln, " - f"liegt sicher im Rahmen {design.hoop.name}).".replace(",", "'") - ) + issues.append(tr("doc_ok").format(st=fmt_int(plan.stitch_count), n=unique_colors, needles=max_needles, + hoop=design.hoop.display_name)) return issues diff --git a/openstitch/i18n.py b/openstitch/i18n.py index 9239edc..2dca76e 100644 --- a/openstitch/i18n.py +++ b/openstitch/i18n.py @@ -226,6 +226,65 @@ def key_for(german_text: str) -> str | None: }) +# Qualitäts-Doktor +_TABLE.update({ + "doc_empty": ("Das Design enthält noch keine Stiche.", "The design has no stitches yet.", + "Le motif ne contient encore aucun point.", "El diseño aún no tiene puntadas."), + "doc_hoop_round": ( + "⚠ RAHMEN-KOLLISIONSGEFAHR: Das Motiv (Radius {r} mm) reicht an den Rand des Rundrahmens ({hoop}, Radius {hr} mm). " + "Wähle einen grösseren Rahmen oder zentriere/verkleinere das Motiv.", + "⚠ HOOP COLLISION RISK: the design (radius {r} mm) reaches the edge of the round hoop ({hoop}, radius {hr} mm). " + "Pick a larger hoop or centre/shrink the design.", + "⚠ RISQUE DE COLLISION AVEC LE CADRE : le motif (rayon {r} mm) atteint le bord du cadre rond ({hoop}, rayon {hr} mm). " + "Choisissez un cadre plus grand ou centrez/réduisez le motif.", + "⚠ RIESGO DE CHOQUE CON EL BASTIDOR: el diseño (radio {r} mm) llega al borde del bastidor redondo ({hoop}, radio {hr} mm). " + "Elige un bastidor mayor o centra/reduce el diseño."), + "doc_hoop_rect": ( + "⚠ RAHMEN-KOLLISIONSGEFAHR: Das Motiv ({w}×{h} mm) überschreitet das sichere Stickfeld des Rahmens ({hw}×{hh} mm).", + "⚠ HOOP COLLISION RISK: the design ({w}×{h} mm) exceeds the hoop's safe sewing field ({hw}×{hh} mm).", + "⚠ RISQUE DE COLLISION AVEC LE CADRE : le motif ({w}×{h} mm) dépasse la zone de broderie sûre du cadre ({hw}×{hh} mm).", + "⚠ RIESGO DE CHOQUE CON EL BASTIDOR: el diseño ({w}×{h} mm) supera el área segura del bastidor ({hw}×{hh} mm)."), + "doc_colors": ( + "⚠ ZU VIELE FARBEN: Das Design verwendet {n} Garnfarben, die Maschine hat {needles} Nadeln. " + "Reduziere die Farben oder plane einen manuellen Garnwechsel ein.", + "⚠ TOO MANY COLOURS: the design uses {n} thread colours but the machine has {needles} needles. " + "Reduce colours or plan a manual thread change.", + "⚠ TROP DE COULEURS : le motif utilise {n} couleurs de fil, la machine a {needles} aiguilles. " + "Réduisez les couleurs ou prévoyez un changement de fil manuel.", + "⚠ DEMASIADOS COLORES: el diseño usa {n} colores de hilo y la máquina tiene {needles} agujas. " + "Reduce los colores o planifica un cambio de hilo manual."), + "doc_small_text": ( + "⚠ KLEINE SCHRIFT ({name}, {h} mm): Umriss-Schriften unter 4.5 mm laufen auf Stoff oft zu. " + "Tipp: Für kleine Schrift die Schriftart „OpenStitch Micro“ verwenden.", + "⚠ SMALL LETTERING ({name}, {h} mm): outline fonts below 4.5 mm tend to fill in on fabric. " + "Tip: use the “OpenStitch Micro” font for small lettering.", + "⚠ PETIT TEXTE ({name}, {h} mm) : sous 4,5 mm, les polices à contour se bouchent souvent sur le tissu. " + "Astuce : utilisez la police « OpenStitch Micro » pour les petits textes.", + "⚠ TEXTO PEQUEÑO ({name}, {h} mm): las fuentes de contorno por debajo de 4,5 mm suelen cerrarse en la tela. " + "Consejo: usa la fuente «OpenStitch Micro» para texto pequeño."), + "doc_satin_density": ( + "⚠ HOHE SATIN-DICHTE ({name}, {sp} mm): Dichte unter 0.28 mm kann zu Fadenbruch führen (empfohlen: 0.36–0.42 mm).", + "⚠ HIGH SATIN DENSITY ({name}, {sp} mm): spacing below 0.28 mm can break threads (recommended: 0.36–0.42 mm).", + "⚠ DENSITÉ SATIN ÉLEVÉE ({name}, {sp} mm) : un espacement sous 0,28 mm peut casser le fil (conseillé : 0,36–0,42 mm).", + "⚠ DENSIDAD DE SATÉN ALTA ({name}, {sp} mm): un espaciado inferior a 0,28 mm puede romper el hilo " + "(recomendado: 0,36–0,42 mm)."), + "doc_density": ( + "⚠ DICHTE-SPITZEN: An {n} Stellen (1×1 mm) liegen mehr als 14 Stiche übereinander " + "(Strg+H zeigt die Heatmap).", + "⚠ DENSITY HOT SPOTS: {n} places (1×1 mm) have more than 14 stitches on top of each other " + "(Ctrl+H shows the heatmap).", + "⚠ PICS DE DENSITÉ : {n} zones (1×1 mm) comptent plus de 14 points superposés " + "(Ctrl+H affiche la carte de densité).", + "⚠ PICOS DE DENSIDAD: en {n} puntos (1×1 mm) hay más de 14 puntadas superpuestas " + "(Ctrl+H muestra el mapa de calor)."), + "doc_ok": ( + "✅ Keine Probleme gefunden ({st} Stiche, {n} von {needles} Nadeln, sicher im Rahmen {hoop}).", + "✅ No problems found ({st} stitches, {n} of {needles} needles, safely inside {hoop}).", + "✅ Aucun problème détecté ({st} points, {n} aiguilles sur {needles}, bien dans le cadre {hoop}).", + "✅ No se encontraron problemas ({st} puntadas, {n} de {needles} agujas, dentro del bastidor {hoop})."), +}) + + def tr_all(key: str) -> list[str]: return list(_TABLE.get(key, (key,) * 4)) diff --git a/openstitch/ui/canvas.py b/openstitch/ui/canvas.py index c18dfd1..576834d 100644 --- a/openstitch/ui/canvas.py +++ b/openstitch/ui/canvas.py @@ -22,7 +22,7 @@ from shapely.geometry import LineString, MultiLineString, Point, Polygon from shapely.ops import unary_union -from ..core.design import Design, EmbObject, FillObject, RunObject, SatinObject, compose +from ..core.design import ColumnObject, Design, EmbObject, FillObject, RunObject, SatinObject, compose, m_scale from ..core.stitchplan import Cmd, StitchPlan, Thread from ..render import draw_plan @@ -509,7 +509,7 @@ def mouseReleaseEvent(self, event): def _get_editable_nodes(self, obj: EmbObject) -> list[tuple[float, float]]: # Nur Objekte mit eigener Vektor-Geometrie (Text/Stiche werden anders erzeugt) - if obj.kind not in ("fill", "satin", "run"): + if obj.kind not in ("fill", "satin", "run", "column"): return [] g = obj.world_geom() if g is None or g.is_empty: @@ -551,6 +551,8 @@ def _move_editable_node(self, obj: EmbObject, idx: int, new_mm: tuple[float, flo return lns = lines_of(g) if lns: + if obj.kind == "column": + obj.width *= m_scale(obj.transform) # Skalierung in die Breite übernehmen rest = lns[1:] obj.geom = MultiLineString([LineString(coords)] + rest) if rest else LineString(coords) obj.transform = IDENTITY @@ -602,7 +604,8 @@ def _finish_drawing(self): line = LineString(pts) if line.length >= 1.0: self.about_to_modify.emit("Satin zeichnen") - obj = SatinObject(self.active_thread, name="Freihand-Satin", geom=line.buffer(1.6, cap_style=2)) + # Gezeichnete Linie = Säulen-Mittellinie; Stiche stehen senkrecht dazu + obj = ColumnObject(self.active_thread, name="Satinsäule", geom=line, width=3.2) self.design.objects.append(obj) self.selected = obj self.selection_changed.emit(obj) diff --git a/openstitch/ui/main_window.py b/openstitch/ui/main_window.py index 8d09101..de46c5c 100644 --- a/openstitch/ui/main_window.py +++ b/openstitch/ui/main_window.py @@ -1050,7 +1050,10 @@ def _apply_cap_center_out_order(self): def action_auto_select_hoop(self): x0, y0, x1, y1 = self.canvas.design.bounds() w, h = x1 - x0, y1 - y0 - r_need = max(math.hypot(x0, y0), math.hypot(x1, y1), math.hypot(x0, y1), math.hypot(x1, y0)) + # Rundrahmen: echter Radius der Nadelpositionen (Bounding-Box-Ecken überschätzen runde Motive) + plan = self.wait_for_plan() + r_need = max((math.hypot(s.x, s.y) for b in plan.blocks for s in b.stitches if s.cmd in (0, 1)), + default=max(math.hypot(x0, y0), math.hypot(x1, y1), math.hypot(x0, y1), math.hypot(x1, y0))) for idx, hp in enumerate(HOOPS): mg = hp.margin_mm if hp.round and r_need <= (hp.width / 2.0 - mg): diff --git a/tests/test_openstitch.py b/tests/test_openstitch.py index 381ba2b..f482112 100644 --- a/tests/test_openstitch.py +++ b/tests/test_openstitch.py @@ -358,3 +358,11 @@ def test_color_sort_respects_overlaps(): # c liegt jetzt auf b → darf nicht unter b rutschen c2 = FillObject(red, geom=Point(4, 0).buffer(5)) assert [o.id for o in color_sorted([a, b, c2])] == [a.id, b.id, c2.id] + + +def test_quality_doctor_round_hoop_uses_real_radius(): + from openstitch.core.design import HOOPS + d = Design(objects=[FillObject(Thread((1, 1, 1)), geom=Point(0, 0).buffer(64))]) + d.hoop = HOOPS[2] # rund 15 cm → Radius 75 mm; Kreis r=64 passt (Bounding-Box-Ecke wäre 90 mm) + issues = run_quality_doctor(d, d.build_plan(), max_needles=10) + assert len(issues) == 1 and issues[0].startswith("✅") From 508e99bc2e1349ef0f2456a31cb47ad3b7decdeb Mon Sep 17 00:00:00 2001 From: lbaumberger Date: Wed, 28 Oct 2026 16:28:04 +0100 Subject: [PATCH 14/18] Add OpenStitch Micro single-stroke small-lettering font and satin column generator - engine/column.py: column_runs() - satin column along a centre line with smoothed stitch direction, inner-side short stitching, soft mitre / clipped-mitre corner handling, centre-walk underlay (>= 1.0 mm), bean fallback (< 0.7 mm) - fonts_micro.py: "OpenStitch Micro" centre-line glyphs (A-Z, a-z, 0-9, punctuation, DE/FR/ES accents), layout, per-glyph travel runs along centre lines and stitch-order optimisation that avoids short visible connector stitches - text.py: builtin:micro listed first in list_fonts(), TextObject lays out and stitches micro glyphs (height, letter spacing, arc, transform); TTF path unchanged - tests/test_micro_font.py Co-Authored-By: Claude --- openstitch/engine/column.py | 533 ++++++++++++++++++++++++++++++++++ openstitch/fonts_micro.py | 552 ++++++++++++++++++++++++++++++++++++ openstitch/text.py | 81 +++++- tests/test_micro_font.py | 147 ++++++++++ 4 files changed, 1306 insertions(+), 7 deletions(-) create mode 100644 openstitch/engine/column.py create mode 100644 openstitch/fonts_micro.py create mode 100644 tests/test_micro_font.py diff --git a/openstitch/engine/column.py b/openstitch/engine/column.py new file mode 100644 index 0000000..f2031c0 --- /dev/null +++ b/openstitch/engine/column.py @@ -0,0 +1,533 @@ +"""Satinsäule entlang einer Mittellinie (wiederverwendbarer Säulen-Generator). + +Wird von der Kleinschrift „OpenStitch Micro“ benutzt und ist als Grundlage für ein +manuelles Satinsäulen-Werkzeug gedacht: aus einer Mittellinie und einer (optional +variablen) Breite entsteht ein echter Satin-Zickzack mit + +* Querschnitten im Abstand spacing/2 (Einstiche auf derselben Kante → exakt `spacing`, + gleiche Konvention wie ``satin._zigzag``), +* geglätteter Stichrichtung (senkrecht zur lokalen Tangente), +* Kurzstichen auf der Innenseite enger Kurven, +* Eckbehandlung wie in kommerziellen Programmen: Gehrung (Mitre) mit weich drehenden + Querschnitten bei mittleren Ecken, beschnittene Gehrung bei sehr spitzen Ecken + (beide Schenkel werden senkrecht gestickt und an der Winkelhalbierenden abgeschnitten, + Spitze auf MITRE_LIMIT gekappt) – nie hunderte Einstiche in ein Loch, +* Mittellauf-Unterlage (hin), Satin zurück, +* Rückfall auf Dreifachstich (Bean) bei sehr schmalen Säulen. + +Koordinaten in mm, Y zeigt nach unten (Bildschirm-Konvention); die Geometrie selbst ist +orientierungsunabhängig. +""" +from __future__ import annotations + +import math + +from .geom import Pt, dist, resample_path +from .running import bean_stitch + +CORNER_DEG = 40.0 # ab dieser Richtungsänderung gilt ein Stützpunkt als Ecke + # (Gehrung schon ab 40° – sauberer als reine Glättung) +MITRE_MAX_DEG = 130.0 # bis hierhin weiche Gehrung, darüber beschnittene Gehrung +MITRE_LIMIT = 1.45 # max. Gehrungslänge (× halbe Breite) auf der Aussenseite +MIN_SATIN_W = 0.7 # schmaler → Dreifachstich statt Satin +UNDERLAY_MIN_W = 1.0 # schmaler → keine Unterlage (würde nur auftragen) +FINE = 0.04 # Feinraster der Mittellinie (mm) +SAME_HOLE = 0.12 # Einstiche näher als das gelten als „selbes Loch“ + + +# --------------------------------------------------------------------------- +# Öffentliche API +# --------------------------------------------------------------------------- +def column_runs(centerline: list[Pt], width: float | list[float], spacing: float = 0.4, + pull_comp: float = 0.1, underlay: str = "center", start: Pt | None = None, + max_stitch: float = 7.0, run_len: float = 1.0) -> list[list[Pt]]: + """Satinsäule entlang `centerline` (Polylinie in mm). + + width Säulenbreite (mm) – Zahl oder Liste je Stützpunkt der Mittellinie. + spacing Abstand zweier Einstiche auf derselben Kante (Dichte). + pull_comp Zugkompensation je Seite (mm). + underlay "center" (Mittellauf hin, Satin zurück) oder "none"; unter 1.0 mm Breite + wird automatisch keine Unterlage genäht. + start Nadelposition davor – die Säule beginnt am näheren Ende (bei geschlossenen + Linien am nächsten Stützpunkt). + max_stitch längere Satinstiche werden geteilt (Split-Satin). + run_len Stichlänge des Dreifachstichs, falls die Säule schmaler als 0.7 mm ist. + + Liefert eine Liste zusammenhängender Läufe (normalerweise genau einen).""" + pts, ws = _prepare(centerline, width) + if len(pts) < 2: + return [] + closed = len(pts) > 3 and dist(pts[0], pts[-1]) < 1e-6 + if start is not None: + if closed: + k = min(range(len(pts) - 1), key=lambda i: dist(pts[i], start)) + pts = pts[k:-1] + pts[:k + 1] + ws = ws[k:-1] + ws[:k + 1] + elif dist(start, pts[-1]) < dist(start, pts[0]): + pts.reverse() + ws.reverse() + if max(ws) < MIN_SATIN_W: + return [bean_run(pts, run_len, closed)] + cross = _cross_sections(pts, ws, spacing, pull_comp, closed) + if len(cross) < 2: + return [bean_run(pts, run_len, closed)] + wavg = sum(ws) / len(ws) + if underlay == "center" and wavg >= UNDERLAY_MIN_W: + hw0 = wavg / 2 + walk = _even(_trim_ends(pts, 0.0 if closed else min(0.4, hw0)), 1.6) + zz = _zigzag(list(reversed(cross)), spacing, max_stitch) + run = walk + zz + else: + run = _zigzag(cross, spacing, max_stitch) + return [run] if len(run) >= 2 else [] + + +def bean_run(pts: list[Pt], run_len: float = 1.0, closed: bool = False) -> list[Pt]: + """Dreifachstich entlang einer Linie mit gleichmässigen Stichen (Ecken bleiben erhalten).""" + ev = _even(pts, run_len) + if len(ev) < 2: + return ev + # Stiche sind bereits ≤ run_len → bean_stitch unterteilt nicht erneut + return bean_stitch(ev, stitch_len=run_len * 1.6, repeats=1) + + +# --------------------------------------------------------------------------- +# Hilfen +# --------------------------------------------------------------------------- +def _prepare(centerline, width): + pts = [(float(p[0]), float(p[1])) for p in centerline] + if isinstance(width, (int, float)): + ws = [float(width)] * len(pts) + else: + ws = [float(w) for w in width] + if len(ws) != len(pts): + raise ValueError("width-Liste muss so lang wie die Mittellinie sein") + out_p, out_w = [], [] + for p, w in zip(pts, ws): + if not (math.isfinite(p[0]) and math.isfinite(p[1])): + continue + if out_p and dist(out_p[-1], p) < 1e-6: + continue + out_p.append(p) + out_w.append(max(0.0, w)) + # geschlossene Linie: Endpunkt exakt auf den Anfang legen + if len(out_p) > 3 and dist(out_p[0], out_p[-1]) < 1e-4: + out_p[-1] = out_p[0] + return out_p, out_w + + +def _unit(dx, dy): + L = math.hypot(dx, dy) + if L < 1e-12: + return (0.0, 0.0) + return (dx / L, dy / L) + + +def _turn(a: Pt, b: Pt, c: Pt) -> float: + """Vorzeichenbehaftete Richtungsänderung bei b (Grad, + = gegen den Uhrzeigersinn in x/y).""" + u = _unit(b[0] - a[0], b[1] - a[1]) + v = _unit(c[0] - b[0], c[1] - b[1]) + cr = u[0] * v[1] - u[1] * v[0] + dt = u[0] * v[0] + u[1] * v[1] + return math.degrees(math.atan2(cr, dt)) + + +def _cum(pts): + out = [0.0] + for a, b in zip(pts, pts[1:]): + out.append(out[-1] + dist(a, b)) + return out + + +def _at(pts, cum, s): + """Punkt bei Bogenlänge s (geklemmt).""" + if s <= 0: + return pts[0] + if s >= cum[-1]: + return pts[-1] + lo, hi = 0, len(cum) - 1 + while hi - lo > 1: + mid = (lo + hi) // 2 + if cum[mid] <= s: + lo = mid + else: + hi = mid + seg = cum[hi] - cum[lo] + t = 0.0 if seg < 1e-12 else (s - cum[lo]) / seg + a, b = pts[lo], pts[hi] + return (a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t) + + +def _interp_w(cum, ws, s): + if s <= 0: + return ws[0] + if s >= cum[-1]: + return ws[-1] + for i in range(1, len(cum)): + if cum[i] >= s: + seg = cum[i] - cum[i - 1] + t = 0.0 if seg < 1e-12 else (s - cum[i - 1]) / seg + return ws[i - 1] + (ws[i] - ws[i - 1]) * t + return ws[-1] + + +def _even(pts: list[Pt], step: float, corner_deg: float = 35.0) -> list[Pt]: + """Gleichmässige Teilung je Abschnitt zwischen Ecken (ohne Mikrostiche an dichten Kurvenpunkten).""" + if len(pts) < 2: + return list(pts) + parts, cur = [], [pts[0]] + for i in range(1, len(pts) - 1): + cur.append(pts[i]) + if abs(_turn(pts[i - 1], pts[i], pts[i + 1])) > corner_deg: + parts.append(cur) + cur = [pts[i]] + cur.append(pts[-1]) + parts.append(cur) + out = [pts[0]] + for part in parts: + cum = _cum(part) + L = cum[-1] + if L < 1e-9: + continue + n = max(1, int(math.ceil(L / step - 1e-9))) + for k in range(1, n + 1): + out.append(_at(part, cum, L * k / n)) + return out + + +def _trim_ends(pts, d): + """Kürzt eine offene Linie an beiden Enden um d (für versteckte Unterlage).""" + cum = _cum(pts) + L = cum[-1] + if d <= 0 or L <= 2.5 * d: + return pts + a, b = d, L - d + out = [_at(pts, cum, a)] + out += [p for p, s in zip(pts, cum) if a < s < b] + out.append(_at(pts, cum, b)) + return out + + +# --------------------------------------------------------------------------- +# Querschnitte +# --------------------------------------------------------------------------- +class _Corner: + """Ecke der Mittellinie. kind: "mitre" (weich drehende Querschnitte) oder "clip" + (senkrechte Querschnitte, an der Winkelhalbierenden beschnitten).""" + __slots__ = ("idx", "theta", "sign", "kind", "lam", "r", "u", "m", "hw", "K") + + def __init__(self, idx, theta, sign): + self.idx, self.theta, self.sign = idx, theta, sign + self.kind, self.lam, self.r, self.m, self.hw = "clip", 0.0, (0.0, 0.0), 1.0, 0.0 + self.u, self.K = (1.0, 0.0), (0.0, 0.0) + + +def _dir_near(pts, cum, s, d, forward): + """Mittlere Richtung der Linie über eine kurze Strecke d ab Bogenlänge s.""" + if forward: + a, b = _at(pts, cum, s), _at(pts, cum, s + d) + else: + a, b = _at(pts, cum, s - d), _at(pts, cum, s) + return _unit(b[0] - a[0], b[1] - a[1]) + + +def _cross_sections(pts, ws, spacing, pc, closed): + """Liste von (Mitte, L, R) im Abstand spacing/2 entlang der ganzen Säule.""" + cum = _cum(pts) + hws = [w / 2 + pc for w in ws] + n = len(pts) + # --- Ecken finden -------------------------------------------------------- + corners = [] + for i in range(1, n - 1): + t = _turn(pts[i - 1], pts[i], pts[i + 1]) + if abs(t) > CORNER_DEG: + corners.append(_Corner(i, abs(t), 1 if t > 0 else -1)) + if closed: + t = _turn(pts[-2], pts[0], pts[1]) + if abs(t) > CORNER_DEG: + corners.insert(0, _Corner(0, abs(t), 1 if t > 0 else -1)) + if closed and corners: + # Naht auf die erste Ecke legen → wie offene Linie mit Ecke am Anfang/Ende + k = corners[0].idx + pts = pts[k:-1] + pts[:k + 1] + hws = hws[k:-1] + hws[:k + 1] + for c in corners: + c.idx = (c.idx - k) % (n - 1) + seam = corners[0] + corners = corners[1:] + cum = _cum(pts) + bounds = [0] + [c.idx for c in corners] + [len(pts) - 1] + joints = [seam] + corners + [seam] + else: + bounds = [0] + [c.idx for c in corners] + [n - 1] + joints = [None] + corners + [None] + loop_smooth = closed and not corners + + # --- Eckart bestimmen (weiche oder beschnittene Gehrung) -------------------- + seg_len = [cum[bounds[k + 1]] - cum[bounds[k]] for k in range(len(bounds) - 1)] + for j, c in enumerate(joints): + if c is None: + continue + k_in = j - 1 if j > 0 else len(seg_len) - 1 # Abschnitt vor der Ecke + k_out = j if j < len(seg_len) else 0 # Abschnitt nach der Ecke + s_k = cum[bounds[j]] + hw = hws[bounds[j]] + half = math.radians(c.theta) / 2 + s_off = hw * math.tan(half) + lam = min(2.0 * s_off + 0.5 * hw, 0.5 * min(seg_len[k_in], seg_len[k_out])) + c.hw = hw + c.K = pts[bounds[j]] + d = max(1e-3, min(hw, 0.5 * seg_len[k_in], 0.5 * seg_len[k_out])) + if j == 0 or j == len(joints) - 1: # Naht einer geschlossenen Linie + ta = _dir_near(pts, cum, cum[-1], d, False) + tb = _dir_near(pts, cum, 0.0, d, True) + else: + ta = _dir_near(pts, cum, s_k, d, False) + tb = _dir_near(pts, cum, s_k, d, True) + na = (-ta[1], ta[0]) + nb = (-tb[1], tb[0]) + # Winkelhalbierende der Normalen (Gehrungslinie) und Normale dazu (Richtung a→b) + r = _unit(na[0] + nb[0], na[1] + nb[1]) + c.r = r if r != (0.0, 0.0) else ta # Haarnadel: Achse = Laufrichtung + u = _unit(ta[0] + tb[0], ta[1] + tb[1]) + c.u = u if u != (0.0, 0.0) else na + c.m = 1.0 / max(math.cos(half), 1e-3) + if c.theta <= MITRE_MAX_DEG and lam >= 1.3 * s_off + 0.05: + c.kind = "mitre" + c.lam = lam + else: + c.kind = "clip" + + # --- Abschnitte ------------------------------------------------------------ + out = [] + for k in range(len(bounds) - 1): + i0, i1 = bounds[k], bounds[k + 1] + piece = pts[i0:i1 + 1] + phw = hws[i0:i1 + 1] + cs = joints[k] # Ecke am Anfang + ce = joints[k + 1] # Ecke am Ende + cross = _piece_cross(piece, phw, spacing, cs, ce, loop_smooth) + if not cross: + continue + if cs is not None and cs.kind == "mitre" and out and k > 0: + cross = cross[1:] # gemeinsamer Gehrungs-Querschnitt nur einmal + out.extend(cross) + return out + + +def _piece_cross(piece, phw, spacing, cs, ce, loop_smooth): + cum = _cum(piece) + Lp = cum[-1] + if Lp < 1e-6: + return [] + hwm = max(phw) + win = min(0.8, max(0.2, 0.6 * hwm)) + # Feinraster der Mittellinie + nfine = max(2, int(math.ceil(Lp / FINE)) + 1) + ss = [Lp * j / (nfine - 1) for j in range(nfine)] + C, Lr, Rr, T = [], [], [], [] + for s in ss: + c = _at(piece, cum, s) + if loop_smooth: + a = _at(piece, cum, (s - win) % Lp) + b = _at(piece, cum, (s + win) % Lp) + else: + a = _at(piece, cum, max(0.0, s - win)) + b = _at(piece, cum, min(Lp, s + win)) + # an Ecken nicht über die Ecke hinweg glätten: Richtung des eigenen Abschnitts + t = _unit(b[0] - a[0], b[1] - a[1]) + if t == (0.0, 0.0): + t = T[-1] if T else (1.0, 0.0) + h = _interp_w(cum, phw, s) + nx, ny = -t[1], t[0] + C.append(c) + T.append(t) + Lr.append((c[0] + nx * h, c[1] + ny * h)) + Rr.append((c[0] - nx * h, c[1] - ny * h)) + + # Beschnittene Gehrung: Säule geradlinig über die Ecke hinaus verlängern; die Querschnitte + # werden danach an der Winkelhalbierenden (und der Spitzenkappung) abgeschnitten. + if ce is not None and ce.kind == "clip": + _extend(C, Lr, Rr, ss, T[-1], piece[-1], phw[-1], ce, at_end=True, Lp=Lp) + if cs is not None and cs.kind == "clip": + _extend(C, Lr, Rr, ss, T[0], piece[0], phw[0], cs, at_end=False, Lp=Lp) + + # Gehrung: Querschnitte drehen weich zur Winkelhalbierenden, Innenkante wird linear + # auf den Gehrungspunkt geführt → keine Stichhäufung in der Ecke + if ce is not None and ce.kind == "mitre": + _blend(C, Lr, Rr, ss, ce, at_end=True, Lp=Lp) + if cs is not None and cs.kind == "mitre": + _blend(C, Lr, Rr, ss, cs, at_end=False, Lp=Lp) + + # Querschnitte nach Fortschritt der längeren Kante setzen (Aussenkante = spacing) + prog = [0.0] + for j in range(1, len(C)): + prog.append(prog[-1] + max(dist(Lr[j - 1], Lr[j]), dist(Rr[j - 1], Rr[j]), 1e-9)) + M = prog[-1] + step = spacing / 2.0 + N = max(1, int(round(M / step))) + out = [] + j = 0 + last = N - 1 if loop_smooth else N + for k in range(last + 1): + m = M * k / N + while j < len(prog) - 2 and prog[j + 1] < m: + j += 1 + seg = prog[j + 1] - prog[j] + t = 0.0 if seg < 1e-12 else min(1.0, max(0.0, (m - prog[j]) / seg)) + + def lerp(A, B): + return (A[0] + (B[0] - A[0]) * t, A[1] + (B[1] - A[1]) * t) + out.append((lerp(C[j], C[j + 1]), lerp(Lr[j], Lr[j + 1]), lerp(Rr[j], Rr[j + 1]))) + if ce is not None and ce.kind == "clip": + out = _clip(out, ce, keep_before=True) + if cs is not None and cs.kind == "clip": + out = _clip(out, cs, keep_before=False) + return out + + +def _extend(C, Lr, Rr, ss, t, P, h, corner, at_end, Lp): + """Verlängert die Feinpunkte geradlinig über die Ecke hinaus (bzw. vor den Anfang).""" + ext = corner.hw * min(4.0, math.tan(math.radians(corner.theta) / 2)) + 0.1 + nx, ny = -t[1], t[0] + steps = max(1, int(math.ceil(ext / FINE))) + sg = 1.0 if at_end else -1.0 + add = [] + for j in range(1, steps + 1): + e = ext * j / steps + c = (P[0] + sg * t[0] * e, P[1] + sg * t[1] * e) + add.append((c, (c[0] + nx * h, c[1] + ny * h), (c[0] - nx * h, c[1] - ny * h), + Lp + e if at_end else -e)) + if not at_end: + add.reverse() + cs_, ls_, rs_, ss_ = (list(v) for v in zip(*add)) + if at_end: + C.extend(cs_) + Lr.extend(ls_) + Rr.extend(rs_) + ss.extend(ss_) + else: + C[:0] = cs_ + Lr[:0] = ls_ + Rr[:0] = rs_ + ss[:0] = ss_ + + +def _clip(cross, corner, keep_before: bool): + """Querschnitte auf die eigene Seite der Winkelhalbierenden beschneiden und die + Aussenspitze auf MITRE_LIMIT × halbe Breite kappen. Zu kurze Reste entfallen.""" + K, (ux, uy), (rx, ry) = corner.K, corner.u, corner.r + ox, oy = -corner.sign * rx, -corner.sign * ry # Aussenrichtung der Ecke + lim = corner.hw * MITRE_LIMIT + min_len = max(0.3, 0.35 * corner.hw) + planes = ((ux, uy, 0.0, 1.0 if keep_before else -1.0), (ox, oy, lim, 1.0)) + # nur lokal an der Ecke beschneiden (Halbebenen sind unendlich – ein Bogen, der später + # wieder nahe an die Ecke kommt, darf nicht mitbeschnitten werden) + reach = corner.hw * (min(4.0, math.tan(math.radians(corner.theta) / 2)) + 2.0) + 0.2 + seq = list(reversed(cross)) if keep_before else list(cross) + n_local = 0 + for c, _, _ in seq: + if dist(c, K) > reach: + break + n_local += 1 + rest = seq[n_local:] + out = [] + for c, L, R in seq[:n_local]: + lo, hi = 0.0, 1.0 + ok = True + for ax, ay, bound, sgn in planes: + # Bedingung: sgn · (p − K)·a ≤ bound für p = L + t (R − L) + f0 = sgn * ((L[0] - K[0]) * ax + (L[1] - K[1]) * ay) - bound + f1 = sgn * ((R[0] - K[0]) * ax + (R[1] - K[1]) * ay) - bound + if f0 <= 0 and f1 <= 0: + continue + if f0 > 0 and f1 > 0: + ok = False + break + tz = f0 / (f0 - f1) + if f0 > 0: + lo = max(lo, tz) + else: + hi = min(hi, tz) + if not ok or hi - lo <= 1e-9: + continue + L2 = (L[0] + (R[0] - L[0]) * lo, L[1] + (R[1] - L[1]) * lo) + R2 = (L[0] + (R[0] - L[0]) * hi, L[1] + (R[1] - L[1]) * hi) + if dist(L2, R2) < min_len: + continue + out.append(((0.5 * (L2[0] + R2[0]), 0.5 * (L2[1] + R2[1])), L2, R2)) + out += rest + return list(reversed(out)) if keep_before else out + + +def _blend(C, Lr, Rr, ss, corner, at_end, Lp): + lam = corner.lam + K = C[-1] if at_end else C[0] + h = corner.hw + rx, ry = corner.r + inner = h * corner.m + outer = h * min(corner.m, MITRE_LIMIT) + # r zeigt in Richtung der Normalen (+n = L-Seite); Innenseite = Seite der Drehung + if corner.sign > 0: + L_end = (K[0] + rx * inner, K[1] + ry * inner) + R_end = (K[0] - rx * outer, K[1] - ry * outer) + else: + L_end = (K[0] + rx * outer, K[1] + ry * outer) + R_end = (K[0] - rx * inner, K[1] - ry * inner) + if at_end: + idx = [j for j, s in enumerate(ss) if s >= Lp - lam] + if not idx: + return + j0 = max(0, idx[0] - 1) + L0, R0, s0 = Lr[j0], Rr[j0], ss[j0] + span = max(Lp - s0, 1e-9) + for j in idx: + u = min(1.0, (ss[j] - s0) / span) + Lr[j] = (L0[0] + (L_end[0] - L0[0]) * u, L0[1] + (L_end[1] - L0[1]) * u) + Rr[j] = (R0[0] + (R_end[0] - R0[0]) * u, R0[1] + (R_end[1] - R0[1]) * u) + else: + idx = [j for j, s in enumerate(ss) if s <= lam] + if not idx: + return + j1 = min(len(ss) - 1, idx[-1] + 1) + L1, R1, s1 = Lr[j1], Rr[j1], ss[j1] + span = max(s1, 1e-9) + for j in idx: + u = min(1.0, (s1 - ss[j]) / span) + Lr[j] = (L1[0] + (L_end[0] - L1[0]) * u, L1[1] + (L_end[1] - L1[1]) * u) + Rr[j] = (R1[0] + (R_end[0] - R1[0]) * u, R1[1] + (R_end[1] - R1[1]) * u) + + +# --------------------------------------------------------------------------- +# Zickzack mit Kurzstichen +# --------------------------------------------------------------------------- +def _zigzag(cross, spacing: float, max_stitch: float) -> list[Pt]: + """Wechselt je Querschnitt die Seite L ↔ R. Ist der Abstand zum letzten vollen Einstich + derselben Kante kleiner als spacing/2 (Innenseite enger Kurven/Ecken), wird der Einstich + Richtung Mitte gezogen (Kurzstich). Einstiche ins selbe Loch werden ausgelassen.""" + pts: list[Pt] = [] + last_full: list[Pt | None] = [None, None] + last_any: list[Pt | None] = [None, None] + for i, (c, L, R) in enumerate(cross): + side = i % 2 + tgt, opp = (L, R) if side == 0 else (R, L) + lf = last_full[side] + if lf is not None and dist(lf, tgt) < 0.5 * spacing: + tgt = (tgt[0] + (opp[0] - tgt[0]) * 0.45, tgt[1] + (opp[1] - tgt[1]) * 0.45) + else: + last_full[side] = tgt + la = last_any[side] + if la is not None and dist(la, tgt) < SAME_HOLE: + continue + last_any[side] = tgt + if pts: + prev = pts[-1] + w = dist(prev, tgt) + if w > max_stitch: + nseg = int(math.ceil(w / max_stitch)) + shift = 0.35 if (i % 2) else -0.35 + for k in range(1, nseg): + t = max(0.15, min(0.85, (k + shift * 0.5) / nseg)) + pts.append((prev[0] + (tgt[0] - prev[0]) * t, prev[1] + (tgt[1] - prev[1]) * t)) + pts.append(tgt) + return pts diff --git a/openstitch/fonts_micro.py b/openstitch/fonts_micro.py new file mode 100644 index 0000000..e68c605 --- /dev/null +++ b/openstitch/fonts_micro.py @@ -0,0 +1,552 @@ +"""OpenStitch Micro – eingebaute Einstrich-Kleinschrift (2.5 – 12 mm Versalhöhe). + +TrueType-Umrisse sind unter ~6 mm zu dünn für Satin, die Buchstaben laufen zu. Wie die +Kleinschriften kommerzieller Programme ist „OpenStitch Micro“ deshalb als *Mittellinien* +digitalisiert: jedes Zeichen besteht aus wenigen Strichen (Polylinien), die als schmale +Satinsäule (``engine.column.column_runs``) oder – ganz klein – als Dreifachstich gestickt +werden. + +Datenformat (Einheits-Box): Versalhöhe = 1.0, Grundlinie y = 0, y zeigt nach OBEN, +x-Höhe 0.70, Unterlänge −0.27. Je Zeichen: (Vorschub, [Strich, Strich, …]); ein Strich +ist eine Liste von Punkten in Stickreihenfolge, ein Strich mit nur einem Punkt ist ein +Punkt (i-Punkt, Satzzeichen). Die Reihenfolge der Striche ist so gewählt, dass möglichst +ohne Sprung gestickt werden kann; Lücken innerhalb eines Zeichens werden – wenn möglich – +mit einem Verbindungslauf entlang der Mittellinien überbrückt (bevorzugt unter noch nicht +gestickten Strichen, wie die „Unterlauf“-Verbindungen kommerzieller Schriften). +""" +from __future__ import annotations + +import heapq +import math +import unicodedata + +from .engine.column import MIN_SATIN_W, _even, bean_run, column_runs +from .engine.geom import Pt, dist + +MICRO_SPEC = "builtin:micro" +MICRO_NAME = "OpenStitch Micro" + +XH = 0.70 # x-Höhe +DESC = -0.27 # Unterlänge + + +# --------------------------------------------------------------------------- +# Zeichen-Hilfen (Einheits-Box, y nach oben) +# --------------------------------------------------------------------------- +def _arc(cx, cy, rx, ry, a0, a1, step=10.0): + """Ellipsenbogen von Winkel a0 nach a1 (Grad, mathematisch positiv = gegen Uhrzeiger).""" + n = max(2, int(math.ceil(abs(a1 - a0) / step)) + 1) + out = [] + for i in range(n): + a = math.radians(a0 + (a1 - a0) * i / (n - 1)) + out.append((cx + rx * math.cos(a), cy + ry * math.sin(a))) + return out + + +def _cat(*parts): + out = [] + for part in parts: + for p in part: + p = (round(p[0], 4), round(p[1], 4)) + if out and abs(out[-1][0] - p[0]) < 1e-4 and abs(out[-1][1] - p[1]) < 1e-4: + continue + out.append(p) + return out + + +def _mirror_x(strokes, adv): + return [[(adv - x, y) for x, y in s] for s in strokes] + + +def _rot180(strokes, adv, cy): + """180°-Drehung um (adv/2, cy) – z. B. ? → ¿ und ! → ¡.""" + return [[(adv - x, 2 * cy - y) for x, y in s] for s in strokes] + + +def _shift(strokes, dx=0.0, dy=0.0): + return [[(x + dx, y + dy) for x, y in s] for s in strokes] + + +# --------------------------------------------------------------------------- +# Glyphen +# --------------------------------------------------------------------------- +def _build() -> dict[str, tuple[float, list[list[Pt]]]]: + G: dict[str, tuple[float, list]] = {} + + def g(ch, adv, *strokes): + G[ch] = (adv, [_cat(s) for s in strokes]) + + # --- Versalien ----------------------------------------------------------- + g("A", 0.76, [(0.06, 0), (0.38, 1), (0.70, 0)], [(0.604, 0.30), (0.156, 0.30)]) + g("B", 0.66, _cat([(0.10, 0), (0.10, 1), (0.33, 1)], _arc(0.33, 0.765, 0.235, 0.235, 90, -90)), + _cat([(0.10, 0.53), (0.36, 0.53)], _arc(0.36, 0.265, 0.265, 0.265, 90, -90), [(0.10, 0)])) + g("C", 0.72, _arc(0.40, 0.5, 0.31, 0.5, 45, 315)) + g("D", 0.74, _cat([(0.10, 0), (0.10, 1), (0.30, 1)], _arc(0.30, 0.5, 0.34, 0.5, 90, -90), [(0.10, 0)])) + g("E", 0.62, [(0.54, 1), (0.10, 1), (0.10, 0.52)], [(0.47, 0.52), (0.10, 0.52), (0.10, 0), (0.54, 0)]) + g("F", 0.58, [(0.52, 1), (0.10, 1), (0.10, 0.52)], [(0.45, 0.52), (0.10, 0.52), (0.10, 0)]) + g("G", 0.76, _cat(_arc(0.41, 0.5, 0.33, 0.5, 45, 360), [(0.46, 0.5)])) + g("H", 0.74, [(0.10, 0), (0.10, 1)], [(0.10, 0.52), (0.64, 0.52)], [(0.64, 1), (0.64, 0)]) + g("I", 0.26, [(0.13, 1), (0.13, 0)]) + g("J", 0.52, _cat([(0.42, 1), (0.42, 0.28)], _arc(0.25, 0.28, 0.17, 0.28, 0, -165))) + g("K", 0.68, [(0.10, 0), (0.10, 1)], [(0.60, 1), (0.11, 0.40), (0.62, 0)]) + g("L", 0.56, [(0.10, 1), (0.10, 0), (0.52, 0)]) + g("M", 0.88, [(0.10, 0), (0.10, 1), (0.44, 0.28), (0.78, 1), (0.78, 0)]) + g("N", 0.76, [(0.10, 0), (0.10, 1), (0.66, 0), (0.66, 1)]) + g("O", 0.82, _arc(0.41, 0.5, 0.33, 0.5, 180, 540)) + g("P", 0.64, _cat([(0.10, 0), (0.10, 1), (0.34, 1)], _arc(0.34, 0.75, 0.25, 0.25, 90, -90), [(0.10, 0.5)])) + g("Q", 0.82, _arc(0.41, 0.5, 0.33, 0.5, 180, 540), [(0.50, 0.24), (0.76, -0.06)]) + g("R", 0.66, _cat([(0.10, 0), (0.10, 1), (0.34, 1)], _arc(0.34, 0.755, 0.245, 0.245, 90, -90), + [(0.10, 0.51)]), [(0.32, 0.51), (0.62, 0)]) + g("S", 0.66, _cat(_arc(0.33, 0.75, 0.22, 0.25, 25, 270), _arc(0.33, 0.25, 0.25, 0.25, 90, -155))) + g("T", 0.66, [(0.33, 0), (0.33, 1)], [(0.05, 1), (0.61, 1)]) + g("U", 0.74, _cat([(0.10, 1), (0.10, 0.32)], _arc(0.37, 0.32, 0.27, 0.32, 180, 360), [(0.64, 1)])) + g("V", 0.72, [(0.06, 1), (0.36, 0), (0.66, 1)]) + g("W", 1.00, [(0.05, 1), (0.27, 0), (0.50, 0.72), (0.73, 0), (0.95, 1)]) + g("X", 0.70, [(0.06, 0), (0.64, 1)], [(0.06, 1), (0.64, 0)]) + g("Y", 0.70, [(0.06, 1), (0.35, 0.48), (0.64, 1)], [(0.35, 0.48), (0.35, 0)]) + g("Z", 0.66, [(0.08, 1), (0.58, 1), (0.08, 0), (0.60, 0)]) + + # --- Gemeine (x-Höhe 0.70) ----------------------------------------------- + bowl_r = _cat([(0.54, 0.56)], _arc(0.31, 0.35, 0.23, 0.35, 40, 320), [(0.54, 0.14)]) # Bauch links vom Stamm + bowl_l = _cat([(0.10, 0.14)], _arc(0.33, 0.35, 0.23, 0.35, 220, 500), [(0.10, 0.56)]) # Bauch rechts vom Stamm + g("a", 0.64, [(0.54, 0), (0.54, XH)], bowl_r) + g("b", 0.64, [(0.10, 1), (0.10, 0)], bowl_l) + g("c", 0.58, _arc(0.32, 0.35, 0.24, 0.35, 42, 318)) + g("d", 0.64, bowl_r, [(0.54, 1), (0.54, 0)]) + g("e", 0.62, _cat([(0.10, 0.37), (0.54, 0.37)], _arc(0.32, 0.35, 0.22, 0.35, 5, 318))) + g("f", 0.42, _cat([(0.20, 0), (0.20, 0.78)], _arc(0.34, 0.78, 0.14, 0.20, 180, 40)), + [(0.04, 0.66), (0.38, 0.66)]) + g("g", 0.64, bowl_r, _cat([(0.54, XH), (0.54, -0.06)], _arc(0.32, -0.06, 0.22, 0.21, 0, -160))) + g("h", 0.64, [(0.10, 0), (0.10, 1)], _cat([(0.10, 0.47)], _arc(0.31, 0.47, 0.21, 0.23, 180, 0), [(0.52, 0)])) + g("i", 0.26, [(0.13, XH), (0.13, 0)], [(0.13, 1.0)]) + g("j", 0.32, _cat([(0.20, XH), (0.20, -0.10)], _arc(0.07, -0.10, 0.13, 0.16, 0, -160)), [(0.20, 1.0)]) + g("k", 0.60, [(0.10, 0), (0.10, 1)], [(0.50, XH), (0.11, 0.30), (0.53, 0)]) + g("l", 0.26, [(0.13, 1), (0.13, 0)]) + g("m", 0.92, _cat([(0.10, 0), (0.10, 0.47)], _arc(0.255, 0.47, 0.155, 0.23, 180, 0)), + _cat([(0.41, 0), (0.41, 0.47)], _arc(0.565, 0.47, 0.155, 0.23, 180, 0), [(0.72, 0)])) + g("n", 0.62, _cat([(0.10, 0), (0.10, 0.47)], _arc(0.31, 0.47, 0.21, 0.23, 180, 0), [(0.52, 0)])) + g("o", 0.64, _arc(0.32, 0.35, 0.23, 0.355, 180, 540)) + g("p", 0.64, [(0.10, DESC), (0.10, XH)], _cat([(0.10, 0.56)], _arc(0.33, 0.35, 0.23, 0.35, 140, -140), [(0.10, 0.14)])) + g("q", 0.64, bowl_r, [(0.54, XH), (0.54, DESC)]) + g("r", 0.44, _cat([(0.10, 0), (0.10, 0.45)], _arc(0.30, 0.45, 0.20, 0.25, 180, 50))) + g("s", 0.56, _cat(_arc(0.28, 0.525, 0.18, 0.175, 25, 270), _arc(0.28, 0.175, 0.20, 0.175, 90, -155))) + g("t", 0.40, [(0.04, 0.68), (0.36, 0.68)], _cat([(0.18, 0.92), (0.18, 0.12)], _arc(0.30, 0.12, 0.12, 0.12, 180, 310))) + g("u", 0.62, _cat([(0.10, XH), (0.10, 0.23)], _arc(0.31, 0.23, 0.21, 0.23, 180, 360), [(0.52, XH)])) + g("v", 0.58, [(0.06, XH), (0.29, 0), (0.52, XH)]) + g("w", 0.84, [(0.05, XH), (0.22, 0), (0.42, 0.55), (0.62, 0), (0.79, XH)]) + g("x", 0.58, [(0.07, 0), (0.51, XH)], [(0.07, XH), (0.51, 0)]) + g("y", 0.58, [(0.06, XH), (0.29, 0.05)], [(0.52, XH), (0.18, DESC)]) + g("z", 0.56, [(0.08, XH), (0.48, XH), (0.08, 0), (0.50, 0)]) + + # --- Ziffern (Tabellenziffern, Breite 0.62) ------------------------------ + six = _cat(_arc(0.33, 0.31, 0.25, 0.69, 70, 180), _arc(0.31, 0.31, 0.23, 0.31, 180, 540)) + g("0", 0.62, _arc(0.31, 0.5, 0.23, 0.5, 90, 450)) + g("1", 0.62, [(0.12, 0.78), (0.36, 1), (0.36, 0)]) + g("2", 0.62, _cat(_arc(0.31, 0.72, 0.23, 0.26, 155, -35), [(0.08, 0), (0.56, 0)])) + g("3", 0.62, _cat(_arc(0.30, 0.76, 0.21, 0.24, 150, -90), _arc(0.30, 0.26, 0.25, 0.26, 90, -150))) + g("4", 0.62, [(0.44, 0), (0.44, 1), (0.04, 0.30), (0.58, 0.30)]) + g("5", 0.62, _cat([(0.53, 1), (0.13, 1), (0.12, 0.56)], _arc(0.31, 0.33, 0.25, 0.33, 135, -150))) + g("6", 0.62, six) + g("7", 0.62, [(0.06, 1), (0.56, 1), (0.24, 0)]) + g("8", 0.62, _cat(_arc(0.31, 0.765, 0.19, 0.235, 270, 630), _arc(0.31, 0.265, 0.235, 0.265, 90, -270))) + g("9", 0.62, *_rot180([six], 0.62, 0.5)) + + # --- Satz- und Sonderzeichen --------------------------------------------- + G[" "] = (0.30, []) + g(".", 0.24, [(0.12, 0.06)]) + g(",", 0.24, [(0.14, 0.08), (0.08, -0.15)]) + g(":", 0.24, [(0.12, 0.60)], [(0.12, 0.06)]) + g(";", 0.24, [(0.12, 0.60)], [(0.14, 0.08), (0.08, -0.15)]) + g("!", 0.26, [(0.13, 1), (0.13, 0.36)], [(0.13, 0.06)]) + qm = [_cat(_arc(0.28, 0.74, 0.21, 0.24, 155, -70), [(0.28, 0.44), (0.28, 0.36)]), [(0.28, 0.06)]] + g("?", 0.56, *qm) + g("-", 0.44, [(0.08, 0.38), (0.36, 0.38)]) + g("_", 0.58, [(0.04, -0.12), (0.54, -0.12)]) + g("'", 0.20, [(0.10, 1), (0.10, 0.72)]) + g('"', 0.36, [(0.10, 1), (0.10, 0.72)], [(0.26, 1), (0.26, 0.72)]) + paren = _arc(0.46, 0.42, 0.30, 0.66, 118, 242) + g("(", 0.38, paren) + g(")", 0.38, *_mirror_x([list(reversed(paren))], 0.38)) + g("/", 0.46, [(0.06, -0.05), (0.40, 1.0)]) + g("&", 0.74, _cat([(0.66, 0), (0.24, 0.58)], _arc(0.31, 0.80, 0.14, 0.19, 215, -40), + _arc(0.30, 0.24, 0.20, 0.24, 160, 380), [(0.66, 0.44)])) + g("+", 0.58, [(0.29, 0.14), (0.29, 0.62)], [(0.06, 0.38), (0.52, 0.38)]) + g("@", 1.00, _arc(0.46, 0.40, 0.15, 0.20, 0, 360), + _cat([(0.61, 0.60), (0.61, 0.28)], _arc(0.71, 0.30, 0.10, 0.10, 180, 360), _arc(0.48, 0.42, 0.38, 0.53, 10, 300))) + g("#", 0.70, [(0.22, 0), (0.30, 0.92)], [(0.06, 0.62), (0.64, 0.62)], [(0.52, 0.92), (0.44, 0)], + [(0.62, 0.32), (0.04, 0.32)]) + g("%", 0.80, _arc(0.18, 0.78, 0.12, 0.18, 270, 630), [(0.66, 1.0), (0.14, 0)], + _arc(0.62, 0.22, 0.12, 0.18, 90, 450)) + g("*", 0.56, [(0.28, 0.94), (0.28, 0.50)], [(0.09, 0.83), (0.47, 0.61)], [(0.09, 0.61), (0.47, 0.83)]) + g("¿", 0.56, *_rot180(qm, 0.56, 0.40)) + g("¡", 0.26, *_rot180([[(0.13, 1), (0.13, 0.36)], [(0.13, 0.06)]], 0.26, 0.46)) + g("ß", 0.66, _cat([(0.10, 0), (0.10, 0.76)], _arc(0.29, 0.76, 0.19, 0.24, 180, -60), + _arc(0.33, 0.28, 0.24, 0.28, 80, -125))) + # punktloses i als Basis für î ï + g("ı", 0.26, [(0.13, XH), (0.13, 0)]) + + # --- Akzente (aus Grundzeichen + Zeichen zusammengesetzt) ----------------- + def acute(cx, y0): + return [[(cx - 0.05, y0), (cx + 0.08, y0 + 0.15)]] + + def grave(cx, y0): + return [[(cx - 0.08, y0 + 0.15), (cx + 0.05, y0)]] + + def circ(cx, y0): + return [[(cx - 0.12, y0), (cx, y0 + 0.14), (cx + 0.12, y0)]] + + def tilde(cx, y0): + return [[(cx + 0.18 * t / 4, y0 + 0.065 + 0.055 * math.sin(math.pi * t / 4)) for t in range(-4, 5)]] + + def cedilla(cx, y0): + return [_cat([(cx, 0.0), (cx, -0.07)], _arc(cx, -0.14, 0.07, 0.07, 90, -150))] + + def compose(ch, base, mark, *, cx=None, upper=None, adv=None, dots=False): + b_adv, b_strokes = G[base] + if upper is None: + upper = base.isupper() + y0 = 1.15 if upper else 0.82 + if cx is None: + cx = b_adv / 2 + if dots: # Trema: ein Punkt vor, einer nach dem Grundzeichen → lange, getrimmte Wege + d, yd = 0.14, (1.28 if upper else 0.99) # hoch genug für Kleinstgrössen + strokes = [[(cx - d, yd)]] + b_strokes + [[(cx + d, yd)]] + else: + strokes = b_strokes + mark(cx, y0) + MARKS[ch] = set(range(len(b_strokes), len(strokes))) + G[ch] = (adv or b_adv, strokes) + + compose("Ä", "A", None, dots=True) + compose("Ö", "O", None, dots=True) + compose("Ü", "U", None, dots=True) + compose("ä", "a", None, dots=True, cx=0.32) + compose("ö", "o", None, dots=True) + compose("ü", "u", None, dots=True, cx=0.31) + compose("É", "E", acute, cx=0.32) + compose("È", "E", grave, cx=0.32) + compose("Ê", "E", circ, cx=0.32) + compose("À", "A", grave) + compose("Ç", "C", cedilla, cx=0.40) + compose("Ñ", "N", tilde) + compose("é", "e", acute, cx=0.32) + compose("è", "e", grave, cx=0.32) + compose("ê", "e", circ, cx=0.32) + compose("à", "a", grave, cx=0.32) + compose("â", "a", circ, cx=0.32) + compose("ç", "c", cedilla, cx=0.32) + compose("ô", "o", circ) + compose("î", "ı", circ, adv=0.34, cx=0.13) + compose("ï", "ı", None, dots=True, adv=0.34, cx=0.13) + compose("ñ", "n", tilde, cx=0.31) + # î / ï etwas breiter: Grundstrich mittig setzen + for ch in ("î", "ï"): + adv, st = G[ch] + G[ch] = (adv, _shift(st, 0.04)) + # Kleinschrift braucht mehr Luft: Vorbreite/Nachbreite je +SIDE + return {ch: (adv + 2 * SIDE, _shift(st, SIDE)) for ch, (adv, st) in G.items()} + + +SIDE = 0.04 # zusätzliche Vor-/Nachbreite (Einheiten der Versalhöhe) + + +MARKS: dict[str, set[int]] = {} # Akzent-Striche je Zeichen (werden schmaler gestickt) +GLYPHS: dict[str, tuple[float, list[list[Pt]]]] = _build() + + +def mark_strokes(ch: str) -> set[int]: + """Indizes der Akzent-Striche eines Zeichens (Strich-Reihenfolge wie in ``glyph``).""" + return MARKS.get(ch, set()) if ch in GLYPHS else set() + +#: Alle Zeichen, die die Schrift garantiert enthält +CHARSET = ("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 .,:;!?-_'\"()/&+@#%*" + "ÄÖÜäöüßÉÈÊÀÇéèêàâçôîïñÑ¿¡") + + +def glyph(ch: str): + """(Vorschub, Striche) eines Zeichens; unbekannte Akzentbuchstaben fallen auf den + Grundbuchstaben zurück, sonst auf '?'.""" + if ch in GLYPHS: + return GLYPHS[ch] + base = unicodedata.normalize("NFD", ch)[:1] + if base and base in GLYPHS: + return GLYPHS[base] + return GLYPHS["?"] + + +# --------------------------------------------------------------------------- +# Satz +# --------------------------------------------------------------------------- +BEAN_BELOW_MM = 5.5 # unter dieser Versalhöhe: Dreifachstich statt Satin + + +def stroke_width(cap_mm: float) -> float: + """Säulenbreite (mm) für eine Versalhöhe. Unter 5.5 mm wird mit Dreifachstich gestickt + (Rückgabe < 0.7 mm → ``column_runs`` fällt auf Bean zurück), darüber Satin mit + 0.7 – 2.2 mm (≈ 11 % der Versalhöhe). Nach Augenmass abgestimmt: schmalere Satinsäulen + sind nicht stickbar, breitere lassen die Innenräume bei 6 mm zulaufen.""" + if cap_mm < BEAN_BELOW_MM: + return min(0.6, 0.12 * cap_mm) + return min(2.2, max(0.7, 0.11 * cap_mm)) + + +def pull_comp_for(width: float, pull_comp: float) -> float: + """Schmale Säulen ziehen kaum ein → Zugkompensation höchstens 8 % der Breite je Seite.""" + return min(pull_comp, 0.08 * width) + + +def auto_tracking(cap_mm: float) -> float: + """Zusätzlicher Zeichenabstand (mm), damit kräftige Satinsäulen nicht zusammenlaufen.""" + w = stroke_width(cap_mm) + vis = 0.45 if w < MIN_SATIN_W else w * 1.16 + 0.35 # sichtbare Strichbreite inkl. Faden + return max(0.0, 0.6 * (vis - 0.12 * cap_mm)) + + +def run_length(cap_mm: float) -> float: + """Stichlänge für Dreifachstich/Verbindungsläufe – kurz genug für enge Bögen.""" + return min(1.2, max(0.55, 0.22 * cap_mm)) + + +def layout(text: str, height: float, letter_spacing: float = 0.0, line_spacing: float = 1.25): + """Liefert [(zeichen, [Striche in mm, Y nach unten], Mittelpunkt-x)] – Zeile zentriert, + Grundlinie der ersten Zeile bei y = 0 (wie ``text.glyph_shapes``). Zum Zeichenabstand + kommt die automatische Laufweite für kräftige Säulen (``auto_tracking``).""" + letter_spacing = letter_spacing + auto_tracking(height) + out = [] + for li, line in enumerate(text.split("\n")): + x = 0.0 + y_off = li * height * line_spacing * 1.35 + row = [] + for ch in line: + adv, strokes = glyph(ch) + if strokes: + st = [[(x + px * height, y_off - py * height) for px, py in s] for s in strokes] + row.append((ch, st, x + adv * height / 2)) + x += adv * height + letter_spacing + shift = -x / 2 + for ch, st, cx in row: + out.append((ch, [[(px + shift, py) for px, py in s] for s in st], cx + shift)) + return out + + +# --------------------------------------------------------------------------- +# Sticken eines Zeichens +# --------------------------------------------------------------------------- +class _Network: + """Graph aus den Mittellinien eines Zeichens für Verbindungsläufe.""" + + def __init__(self, strokes: list[list[Pt]], step: float, tol: float): + self.nodes: list[Pt] = [] + self.sid: list[int] = [] + self.adj: list[list[tuple[int, float]]] = [] + self.first: dict[int, int] = {} + for si, s in enumerate(strokes): + if len(s) < 2: + continue + pts = _even(s, step, corner_deg=20.0) + base = len(self.nodes) + self.first[si] = base + for k, p in enumerate(pts): + self.nodes.append(p) + self.sid.append(si) + self.adj.append([]) + if k: + i, j = base + k - 1, base + k + d = dist(self.nodes[i], self.nodes[j]) + self.adj[i].append((j, d)) + self.adj[j].append((i, d)) + n = len(self.nodes) + for i in range(n): + for j in range(i + 1, n): + if self.sid[i] != self.sid[j]: + d = dist(self.nodes[i], self.nodes[j]) + if d <= tol: + self.adj[i].append((j, d)) + self.adj[j].append((i, d)) + + def nearest(self, p: Pt, only: int | None = None): + best, bi = None, -1 + for i, q in enumerate(self.nodes): + if only is not None and self.sid[i] != only: + continue + d = dist(p, q) + if best is None or d < best: + best, bi = d, i + return bi, (best if best is not None else 1e9) + + def path(self, a: int, b: int, done: set[int], over_w: float = 2.5): + """Dijkstra; Wege über bereits gestickte Striche kosten `over_w`-fach.""" + INF = float("inf") + cost = [INF] * len(self.nodes) + prev = [-1] * len(self.nodes) + cost[a] = 0.0 + pq = [(0.0, a)] + while pq: + c, i = heapq.heappop(pq) + if c > cost[i]: + continue + if i == b: + break + for j, d in self.adj[i]: + same = self.sid[i] == self.sid[j] + w = d * (over_w if (same and self.sid[i] in done) else 1.0) + if c + w < cost[j]: + cost[j] = c + w + prev[j] = i + heapq.heappush(pq, (c + w, j)) + if cost[b] == INF: + return None, INF + seq = [b] + while seq[-1] != a: + seq.append(prev[seq[-1]]) + seq.reverse() + return [self.nodes[i] for i in seq], cost[b] + + +def _dot_run(p: Pt, width: float, spacing: float, pull_comp: float, upright: bool = False) -> list[Pt]: + """Punkt (i-Punkt, Trema, Satzzeichen). Klein: Stiche ≥ 0.48 mm (kürzere filtert der + Dichte-Wächter) – als runder Sternchen-Punkt bzw. als senkrechter Mini-Dreifachstich + (upright), wenn ein Nachbarpunkt daneben liegt (Trema bleibt so zweigeteilt).""" + if width < MIN_SATIN_W: + if upright: + h = 0.24 + a, b = (p[0], p[1] - h), (p[0], p[1] + h) + return [a, b, a, b] + # Sternchen aus drei Durchmessern (alle Stiche ≥ 0.47 mm) → gefüllter, runder Punkt + r = 0.27 + return [(p[0] + r * math.cos(math.radians(a)), p[1] + r * math.sin(math.radians(a))) + for a in (90, 270, 150, 330, 30, 210)] + h = max(0.5, 0.85 * width) / 2 + rs = column_runs([(p[0], p[1] - h), (p[0], p[1] + h)], width, spacing, pull_comp, underlay="none") + return rs[0] if rs else [p] + + +def glyph_runs(strokes: list[list[Pt]], width: float, spacing: float = 0.4, pull_comp: float = 0.08, + run_len: float = 0.8, min_trim: float = 1.6, thin: set[int] | None = None) -> list[list[Pt]]: + """Stickt die Striche eines Zeichens (Welt-Koordinaten, mm) in Datenreihenfolge. + + Zwischen zwei Strichen wird – falls die Striche über das Zeichen verbunden sind – ein + Verbindungslauf entlang der Mittellinien genäht (bevorzugt unter noch nicht gestickten + Strichen, die ihn danach verdecken); sonst beginnt ein neuer Lauf (Sprung/Trim macht + ``Design``). Striche mit Index in `thin` (Akzente) werden schmaler gestickt – kurze + Akzente als volle Satinsäule wären nur ein Klumpen.""" + thin = thin or set() + keep = [i for i, s in enumerate(strokes) if s] + thin = {k for k, i in enumerate(keep) if i in thin} + strokes = [strokes[i] for i in keep] + if not strokes: + return [] + net = _Network(strokes, step=max(0.25, min(0.6, width * 0.6)), tol=max(0.3, 0.6 * max(width, 0.5))) + travel_len = max(0.6, min(1.6, run_len * 1.4)) + runs: list[list[Pt]] = [] + run: list[Pt] = [] + done: set[int] = set() + for si, s in enumerate(strokes): + if len(s) == 1: + p = s[0] + twin = any(len(o) == 1 and o is not s and abs(o[0][1] - p[1]) < 0.3 and abs(o[0][0] - p[0]) < 1.6 + for o in strokes) + seq = _dot_run(p, width, spacing, pull_comp, upright=twin) + else: + w, rl = (width * 0.75, min(run_len, 0.6)) if si in thin else (width, run_len) + rs = column_runs(s, w, spacing, pull_comp, underlay="center", run_len=rl) + seq = [p for r in rs for p in r] + if not seq: + continue + if not run: + run = list(seq) + else: + cur = run[-1] + gap = dist(cur, seq[0]) + joined = False + if gap <= 0.35: + run.extend(seq) + joined = True + elif len(s) >= 2 and si in net.first and si > 0 and len(strokes[si - 1]) >= 2: + a, da = net.nearest(cur) + b = net.first[si] + if a >= 0 and da <= max(0.35, 0.75 * width): + path, cost = net.path(a, b, done) + if path is not None: + plen = sum(dist(p, q) for p, q in zip(path, path[1:])) + ok = cost <= 1.8 * gap + 0.4 or (gap <= min_trim + 0.2 and plen <= 4 * gap + 1.0) + if ok: + tr = _even([cur] + path, travel_len)[1:] + run.extend(tr) + run.extend(seq) + joined = True + if not joined: + runs.append(run) + run = list(seq) + done.add(si) + if run: + runs.append(run) + return [r for r in runs if len(r) >= 2] + + +def _gap_cost(g: float, min_trim: float) -> float: + """Kosten einer Verbindung: sichtbarer Verbindungsstich (zwischen ~0.35 mm und der + Trimm-Schwelle) ist am schlechtesten, ein Trim kostet nur Zeit.""" + if g <= 0.35: + return 0.0 + if g <= min_trim + 0.15: + return 5.0 + g + # Trim: kürzere Sprünge bevorzugen; über ~12 mm teilt Design._connect den Sprung zudem + # in Stiche auf (Maschinengrenze) → deutlich teurer + return 2.5 + 0.02 * g + (4.0 if g > 11.5 else 0.0) + + +def _variants(strokes, thin: set[int]): + """Alternative Stickreihenfolgen eines Zeichens: Datenreihenfolge, komplett rückwärts, + und bei Trema (Punkt zuerst und zuletzt) mit vertauschten Punkten. + Liefert [(Striche, Indizes der schmalen Striche)].""" + n = len(strokes) + fwd = list(range(n)) + orders = [(fwd, False), (fwd[::-1], True)] + if n >= 3 and len(strokes[0]) == 1 and len(strokes[-1]) == 1: + sw = [n - 1] + fwd[1:-1] + [0] + orders += [(sw, False), (sw[::-1], True)] + out = [] + for order, rev in orders: + st = [list(reversed(strokes[i])) if rev else strokes[i] for i in order] + out.append((st, {k for k, i in enumerate(order) if i in thin})) + return out + + +def text_runs(glyph_strokes: list[list[list[Pt]]], width: float, spacing: float = 0.4, + pull_comp: float = 0.08, run_len: float = 0.8, min_trim: float = 1.6, + glyph_thin: list[set[int]] | None = None) -> list[list[Pt]]: + """Stickt eine ganze Zeichenfolge (je Zeichen die Striche in Welt-Koordinaten). + + Je Zeichen wird die Reihenfolge (vorwärts/rückwärts, Trema-Punkte vertauscht) so + gewählt, dass möglichst keine kurzen, sichtbaren Verbindungsstiche entstehen – weder + innerhalb des Zeichens noch zum vorigen/nächsten Zeichen (Ausblick ein Zeichen). + `glyph_thin`: je Zeichen die Indizes schmal zu stickender Striche (siehe ``mark_strokes``).""" + out: list[list[Pt]] = [] + thins = glyph_thin or [set()] * len(glyph_strokes) + pairs = [(st, th) for st, th in zip(glyph_strokes, thins) if any(st)] + glyphs = [st for st, _ in pairs] + for gi, (strokes, thin) in enumerate(pairs): + cur = out[-1][-1] if out else None + nxt = glyphs[gi + 1] if gi + 1 < len(glyphs) else None + best = None + for cand, cthin in _variants(strokes, thin): + rs = glyph_runs(cand, width, spacing, pull_comp, run_len, min_trim, thin=cthin) + if not rs: + continue + cost = 0.0 if cur is None else _gap_cost(dist(cur, rs[0][0]), min_trim) + for a, b in zip(rs, rs[1:]): + cost += _gap_cost(dist(a[-1], b[0]), min_trim) + if nxt is not None: + end = rs[-1][-1] + cost += 0.8 * min(_gap_cost(dist(end, nxt[0][0]), min_trim), + _gap_cost(dist(end, nxt[-1][-1]), min_trim)) + if best is None or cost < best[0] - 1e-9: + best = (cost, rs) + if best is None: + continue + for r in best[1]: + if out and dist(out[-1][-1], r[0]) <= 0.35: + out[-1].extend(r[1:]) + else: + out.append(r) + return out diff --git a/openstitch/text.py b/openstitch/text.py index 5065e2b..74ba962 100644 --- a/openstitch/text.py +++ b/openstitch/text.py @@ -2,6 +2,12 @@ Die Glyphen-Umrisse werden mit fontTools gelesen, Kurven geglättet und je Buchstabe automatisch als Satin (schmale Striche) oder Tatami (breite Striche) gestickt. + +Zusätzlich gibt es die eingebaute Einstrich-Kleinschrift „OpenStitch Micro“ +(Spezifikation ``builtin:micro``, siehe ``fonts_micro``): Mittellinien-Glyphen, die als +schmale Satinsäulen bzw. Dreifachstich gestickt werden. Empfehlung: unter ~6 mm +Versalhöhe liefert sie deutlich lesbarere Schrift als TrueType-Umrisse (die Standard- +Schrift bleibt trotzdem die System-Schrift – die Micro-Schrift wird nur angeboten). """ from __future__ import annotations @@ -15,12 +21,24 @@ from fontTools.pens.basePen import BasePen from fontTools.ttLib import TTCollection, TTFont from shapely import affinity -from shapely.geometry import MultiPolygon, Polygon +from shapely.geometry import GeometryCollection, LineString, MultiPolygon, Point, Polygon from shapely.ops import unary_union -from .core.design import OBJECT_TYPES, EmbObject, FillObject, SatinObject +from .core.design import OBJECT_TYPES, EmbObject, FillObject, SatinObject, m_scale from .core.stitchplan import Thread from .engine.geom import clean, dist, main_axis_angle, polygons_of +from .fonts_micro import MICRO_NAME, MICRO_SPEC +from .fonts_micro import layout as micro_layout +from .fonts_micro import mark_strokes as micro_mark_strokes +from .fonts_micro import pull_comp_for as micro_pull_comp +from .fonts_micro import text_runs as micro_text_runs +from .fonts_micro import run_length as micro_run_length +from .fonts_micro import stroke_width as micro_stroke_width + + +def is_builtin_font(spec: str | None) -> bool: + """Eingebaute Schrift (kein TTF-Pfad), z. B. ``builtin:micro``.""" + return bool(spec) and str(spec).startswith("builtin:") class _FlattenPen(BasePen): @@ -73,7 +91,10 @@ def font_dirs() -> list[str]: @lru_cache(maxsize=1) def list_fonts() -> dict[str, str]: - """Anzeigename → Pfad (Index bei TTC: 'pfad#index').""" + """Anzeigename → Pfad (Index bei TTC: 'pfad#index'). + + Die eingebaute Kleinschrift „OpenStitch Micro“ steht immer an erster Stelle + (Spezifikation ``builtin:micro`` statt Dateipfad).""" out: dict[str, str] = {} bundled = os.path.join(os.path.dirname(__file__), "fonts") for d in [bundled] + font_dirs(): @@ -91,7 +112,8 @@ def list_fonts() -> dict[str, str]: out.setdefault(_font_name(f), f"{path}#{i}") except Exception: continue - return dict(sorted(out.items(), key=lambda kv: kv[0].lower())) + out.pop(MICRO_NAME, None) + return {MICRO_NAME: MICRO_SPEC, **dict(sorted(out.items(), key=lambda kv: kv[0].lower()))} def _font_name(f: TTFont) -> str: @@ -103,6 +125,8 @@ def _font_name(f: TTFont) -> str: @lru_cache(maxsize=16) def _load_font(spec: str) -> TTFont: + if is_builtin_font(spec): + raise ValueError(f"{spec!r} ist eine eingebaute Schrift, keine TTF/OTF-Datei") if "#" in spec: p, i = spec.rsplit("#", 1) return TTFont(p, fontNumber=int(i)) @@ -127,12 +151,18 @@ def default_font() -> str: "Verdana Bold", "Arial", "Helvetica", "DejaVu Sans Bold", "DejaVu Sans"): if pref in fonts: return fonts[pref] - return next(iter(fonts.values()), "") + # Eingebaute Micro-Schrift nie stillschweigend zum Standard machen + return next((v for v in fonts.values() if not is_builtin_font(v)), "") def glyph_shapes(text: str, font_spec: str, height_mm: float, letter_spacing: float = 0.0, line_spacing: float = 1.25): """Liefert Liste (zeichen, Polygon-Geometrie, Mittelpunkt-x) in mm; Grundlinie bei y=0.""" + if is_builtin_font(font_spec): + # Micro-Schrift: Striche mit typischer Säulenbreite als Flächen + hw = max(micro_stroke_width(height_mm), 0.4) / 2 + return [(ch, unary_union([_stroke_geom(s).buffer(hw) for s in st]), cx) + for ch, st, cx in micro_layout(text, height_mm, letter_spacing, line_spacing)] font = _load_font(font_spec) gs = font.getGlyphSet() cmap = font.getBestCmap() or {} @@ -187,6 +217,16 @@ def glyph_shapes(text: str, font_spec: str, height_mm: float, letter_spacing: fl return out +def _stroke_geom(stroke): + return Point(stroke[0]) if len(stroke) == 1 else LineString(stroke) + + +def micro_glyph_geoms(text: str, height_mm: float, letter_spacing: float = 0.0): + """Micro-Schrift: Liste (zeichen, GeometryCollection der Striche in Stickreihenfolge, cx).""" + return [(ch, GeometryCollection([_stroke_geom(s) for s in st]), cx) + for ch, st, cx in micro_layout(text, height_mm, letter_spacing)] + + def arc_layout(glyphs, radius: float): """Buchstaben entlang eines Bogens anordnen. radius>0: Bogen oben, <0: unten.""" if not radius: @@ -222,7 +262,7 @@ class TextObject(EmbObject): PARAMS = (("text", "Text", str, 0, 0, 0), ("font", "Schrift", "font", 0, 0, 0), - ("height", "Buchstabenhöhe (mm)", float, 3.0, 200.0, 0.5), + ("height", "Buchstabenhöhe (mm)", float, 2.0, 200.0, 0.5), ("letter_spacing", "Zeichenabstand (mm)", float, -5.0, 20.0, 0.1), ("arc_radius", "Bogenradius (mm, 0 = gerade)", float, -500.0, 500.0, 5.0), ("mode", "Stichart", ["auto", "satin", "fill"], 0, 0, 0), @@ -233,19 +273,46 @@ def __post_init__(self): if not self.font: self.font = default_font() + @property + def is_micro(self) -> bool: + return is_builtin_font(self.font) + def _glyphs(self): key = (self.text, self.font, self.height, self.letter_spacing, self.arc_radius) if getattr(self, "_gkey", None) != key: - g = glyph_shapes(self.text, self.font, self.height, self.letter_spacing) if self.font else [] + if self.is_micro: + g = micro_glyph_geoms(self.text, self.height, self.letter_spacing) + else: + g = glyph_shapes(self.text, self.font, self.height, self.letter_spacing) if self.font else [] self._gcache = arc_layout(g, self.arc_radius) self._gkey = key return self._gcache def local_geom(self): + if self.is_micro: + # Striche mit Säulenbreite gepuffert → Auswahlrahmen & Trefferprüfung + hw = max(micro_stroke_width(self.height), 0.4) / 2 + gs = [g.buffer(hw) for _, g, _ in self._glyphs() if not g.is_empty] + return unary_union(gs) if gs else MultiPolygon() gs = [g for _, g, _ in self._glyphs()] return unary_union(gs) if gs else MultiPolygon() + def _micro_runs(self, start=None): + """Micro-Schrift: Mittellinien je Zeichen als schmale Satinsäulen / Dreifachstich.""" + cap = self.height * m_scale(self.transform) # wirksame Versalhöhe in mm + w = micro_stroke_width(cap) + pc = micro_pull_comp(w, self.pull_comp) # schmale Säulen: wenig Zugkompensation + glyph_strokes, thin = [], [] + for ch, g, _ in self._glyphs(): + wg = affinity.affine_transform(g, self.transform) + glyph_strokes.append([[tuple(c) for c in p.coords] for p in wg.geoms]) + thin.append(micro_mark_strokes(ch)) + return micro_text_runs(glyph_strokes, w, spacing=self.density, pull_comp=pc, + run_len=micro_run_length(cap), glyph_thin=thin) + def runs(self, start=None): + if self.is_micro: + return self._micro_runs(start) out = [] cur = start for ch, g, _ in self._glyphs(): diff --git a/tests/test_micro_font.py b/tests/test_micro_font.py new file mode 100644 index 0000000..2a497da --- /dev/null +++ b/tests/test_micro_font.py @@ -0,0 +1,147 @@ +"""Tests für die Einstrich-Kleinschrift „OpenStitch Micro“ und den Säulen-Generator.""" +from __future__ import annotations + +import math +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +import pytest + +from openstitch.core.design import Design, object_from_dict +from openstitch.core.stitchplan import Cmd, Thread +from openstitch.engine.column import column_runs +from openstitch.fonts_micro import CHARSET, GLYPHS, MICRO_SPEC, glyph +from openstitch.text import TextObject, default_font, list_fonts + +SAMPLE = "Micro 2.5: Äpfel & Öl für 12 € – ß?" + + +def _plan_stats(plan): + longest, nan, prev = 0.0, 0, None + for b in plan.blocks: + prev = None + for s in b.stitches: + if not (math.isfinite(s.x) and math.isfinite(s.y)): + nan += 1 + continue + if s.cmd == Cmd.STITCH: + if prev is not None: + longest = max(longest, math.hypot(s.x - prev[0], s.y - prev[1])) + prev = (s.x, s.y) + elif s.cmd == Cmd.JUMP: + prev = (s.x, s.y) + return longest, nan + + +def test_every_listed_character_has_glyph_data(): + required = ("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" + " .,:;!?-_'\"()/&+@#%*ÄÖÜäöüßÉÈÊÀÇéèêàâçôîïñÑ¿¡") + for ch in required: + assert ch in CHARSET + assert ch in GLYPHS, f"Glyphe fehlt: {ch!r}" + adv, strokes = GLYPHS[ch] + assert adv > 0 + if ch != " ": + assert strokes, f"keine Striche: {ch!r}" + for s in strokes: + assert s and all(math.isfinite(x) and math.isfinite(y) for x, y in s) + # Einheits-Box: Versalhöhe 1.0, kleine Über-/Unterlängen erlaubt + assert all(-0.45 <= y <= 1.45 and -0.2 <= x <= adv + 0.2 for x, y in s), ch + # unbekannte Akzentbuchstaben fallen auf den Grundbuchstaben zurück + assert glyph("ú") is GLYPHS["u"] + + +def test_micro_font_listed_first_and_not_default(): + fonts = list_fonts() + first_name, first_spec = next(iter(fonts.items())) + assert first_name == "OpenStitch Micro" and first_spec == MICRO_SPEC + assert default_font() != MICRO_SPEC + + +@pytest.mark.parametrize("height", [2.5, 8.0]) +def test_micro_text_builds_clean_plan(height): + d = Design() + t = TextObject(Thread((20, 30, 90), "Blau"), text=SAMPLE.replace("€", "EUR"), font=MICRO_SPEC, + height=height, letter_spacing=0.0) + d.objects.append(t) + plan = d.build_plan() + longest, nan = _plan_stats(plan) + assert nan == 0 + assert longest <= 7.0, f"Stich zu lang: {longest:.2f} mm" + n = plan.stitch_count + n_chars = len(SAMPLE.replace(" ", "")) + # grob: 20 – 400 Stiche je Zeichen (Dreifachstich klein, Satin gross) + assert 20 * n_chars < n < 400 * n_chars + # Auswahlrahmen/Trefferprüfung: Geometrie deckt die Schrift ab + x0, y0, x1, y1 = t.bounds() + assert x1 - x0 > height * 5 and y1 - y0 >= height * 0.9 + + +def test_micro_text_respects_transform_and_arc(): + t = TextObject(Thread((0, 0, 0)), text="ARC", font=MICRO_SPEC, height=6.0, arc_radius=30.0) + runs = t.runs() + assert runs and all(len(r) >= 2 for r in runs) + w0 = max(p[0] for r in runs for p in r) - min(p[0] for r in runs for p in r) + t.transform = (2.0, 0.0, 0.0, 2.0, 10.0, 5.0) + xs = [p[0] for r in t.runs() for p in r] + assert abs((max(xs) + min(xs)) / 2 - 10.0) < 1.5 # zentriert um die Verschiebung + assert 1.8 * w0 < max(xs) - min(xs) < 2.3 * w0 # skaliert (inkl. breiterer Säule) + + +def test_column_straight_line_rail_spacing(): + run = column_runs([(0.0, 0.0), (10.0, 0.0)], 1.0, spacing=0.4, pull_comp=0.1, underlay="none")[0] + # Einstiche wechseln die Seite; auf derselben Kante liegen sie `spacing` auseinander + left = [p for p in run if p[1] > 0] + right = [p for p in run if p[1] < 0] + assert abs(len(left) - len(right)) <= 1 + for rail in (left, right): + gaps = [b[0] - a[0] for a, b in zip(rail, rail[1:])] + assert all(abs(g - 0.4) < 0.02 for g in gaps) + # Breite inkl. Zugkompensation + assert all(abs(abs(p[1]) - 0.6) < 1e-6 for p in run) + + +def test_column_underlay_and_bean_fallback(): + with_ul = column_runs([(0, 0), (8, 0)], 1.6, underlay="center")[0] + no_ul = column_runs([(0, 0), (8, 0)], 1.6, underlay="none")[0] + assert len(with_ul) > len(no_ul) + # Unterlage = Mittellauf: beginnt auf der Mittellinie + assert abs(with_ul[0][1]) < 1e-6 + narrow = column_runs([(0, 0), (8, 0)], 0.9, underlay="center")[0] + assert all(abs(abs(p[1]) - 0.55) < 1e-6 for p in narrow) # < 1.0 mm: keine Unterlage + bean = column_runs([(0, 0), (8, 0)], 0.5)[0] + assert all(abs(p[1]) < 1e-9 for p in bean) # < 0.7 mm: Dreifachstich + assert len(bean) > 3 * 8 / 1.0 + + +def test_column_start_chooses_direction(): + run = column_runs([(0, 0), (8, 0)], 1.0, underlay="none", start=(9, 0))[0] + assert run[0][0] > 7.5 and run[-1][0] < 0.5 + + +@pytest.mark.parametrize("opening", [30, 50, 70, 100]) +@pytest.mark.parametrize("width", [0.8, 1.5]) +def test_sharp_v_corner_has_no_needle_pileup(opening, width): + a = math.radians(opening / 2) + V = [(-5 * math.sin(a), -5 * math.cos(a)), (0.0, 0.0), (5 * math.sin(a), -5 * math.cos(a))] + pts = [p for r in column_runs(V, width, 0.4, 0.1, "center") for p in r] + assert pts + # in keinem Kreis von 0.5 mm Durchmesser mehr als 6 Einstiche + worst = max(sum(1 for q in pts if math.dist(p, q) <= 0.25) for p in pts) + assert worst <= 6 + # und die Spitze ist überhaupt gestickt + assert min(math.dist(p, (0, 0)) for p in pts) < width + + +def test_micro_text_save_load_roundtrip(): + t = TextObject(Thread((200, 10, 10), "Rot"), text="Grüße", font=MICRO_SPEC, height=4.0, + letter_spacing=0.2, arc_radius=-40.0, density=0.38) + d = t.to_dict() + assert d["font"] == MICRO_SPEC + t2 = object_from_dict(d) + assert isinstance(t2, TextObject) + assert (t2.text, t2.font, t2.height, t2.letter_spacing, t2.arc_radius, t2.density) == \ + ("Grüße", MICRO_SPEC, 4.0, 0.2, -40.0, 0.38) + assert t2.runs() == t.runs() From e40ac502075c6fd45311b3b2d681b24d98c0250a Mon Sep 17 00:00:00 2001 From: lbaumberger Date: Wed, 28 Oct 2026 16:31:16 +0100 Subject: [PATCH 15/18] Satin column tool on the column engine; micro-font fixes; changelog - Tests for ColumnObject (perpendicular stitches, bounds, save/load). - Quality Doctor no longer warns about small text set in OpenStitch Micro, and falls back to the text when the object has no name. - Newly drawn objects get translated default names. - CHANGELOG.md for the upcoming v0.3.0. Co-Authored-By: Claude --- CHANGELOG.md | 48 ++++++++++++++++++++++++++++++++++ openstitch/engine/pro_tools.py | 5 ++-- openstitch/ui/canvas.py | 7 ++--- tests/test_openstitch.py | 21 +++++++++++++++ 4 files changed, 76 insertions(+), 5 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b705f43 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,48 @@ +# Changelog + +## Unreleased (v0.3.0) + +### New +- **OpenStitch Micro: an embroidery font for small lettering from 2.5 mm.** Single-stroke glyphs are stitched as a triple run below 5.5 mm and as narrow satin columns above, the way commercial small-text fonts work. Outline fonts converted from TrueType fill in below about 4.5 mm. It covers A–Z, a–z, 0–9, punctuation and German/French/Spanish accents. Pick it in the text dialog; it is listed first. +- **Real satin columns.** The satin tool (`S`) now stitches exactly perpendicular to the line you draw, with adjustable width, density, pull compensation and underlay. Short stitches on tight curves and corner handling are built in. Previously the line was thickened to a fixed 3.2 mm polygon and its centre line re-guessed from pixels. + +### Stitch quality fixes +- **No more stray stitches across the design.** After a trim inside one colour, the next run was split from the *old* needle position, which sewed stitches of up to 50 mm straight across finished areas. This affected every export with more than one run per colour. +- **Far fewer trims.** Underlay, the travel to the top-fill entry point, and satin end caps are now joined with running stitches hidden under the later stitching. A single fill or satin object needs 1 trim instead of 3. +- **Clean fill edges.** The density guard removed tatami row-turn points as "micro-stitches", which made fill outlines ragged. Turn points are now always kept. +- **JEF (Janome) export now cuts threads.** pyembroidery drops trims in JEF files by default; they are now written as three zero-length jumps (the Janome convention). +- **Satin underlay types**, chosen automatically by column width like commercial software: centre walk (≤ 2 mm), edge walk (≤ 4.5 mm), edge walk + zigzag (wider). Each style can be picked per object. + +### Editor +- Stitch plans are built in a **background thread**, so editing stays responsive on large designs. +- The simulator draws only new stitches each frame (about 7× faster), and the heatmap is cached. +- Undo/redo keeps object identity and selection, so undo no longer regenerates the whole design. Dragging a spin box counts as one undo step, and show/hide can be undone. +- The inspector no longer loses your input while you type. +- Node editing no longer jumps between nodes, drops parts of multi-part shapes, or moves text to the origin. +- Fit-to-hoop and Scale keep text rotation and size. +- Hoop and machine selection follow the loaded design. Auto-digitize uses the current hoop and can be cancelled. +- Unsaved-changes prompt on New/Open/Import/Drop/Close. Project saves are atomic, and save/export errors are reported. + +- **Colour sort that respects stacking:** "Optimise colour order" only moves an object to an earlier block of the same colour when nothing it skips overlaps it. +- **Round-hoop checks use the real stitch radius.** Bounding-box corners overestimated round designs by up to 41 %. + +### SVG import (rewritten) +- `transform` on elements and groups (matrix, translate, scale, rotate, skew). +- Full path syntax, including arcs (`A`), smooth curves (`S`/`T`) and compact number notation. +- Style inheritance, `fill="none"`, gradients (first stop colour), the full CSS colour-name list, `display:none`. +- ``/``, ellipse, line, rounded rect and polyline. ``, `` and `` content is no longer stitched. +- evenodd and nonzero fill rules. Strokes become a bean run (< 1.2 mm) or a satin outline (wider). +- Optional real-world size from width/height/viewBox. Protection against `` expansion bombs. + +### Melco floppy images (FAT12) +- Adding a file is transactional: if it fails, the image is left unchanged. +- Long names get Windows-style `~1` short names with VFAT long-name entries. +- Zero-byte files work, and corrupt or cyclic FAT chains are detected. +- The floppy dialog asks before replacing a file with the same 8.3 name. + +### Languages +- All dialogs, message boxes, inspector parameters, hoop and machine names, the Quality Doctor and the PDF production sheet are now translated (DE/EN/FR/ES). +- Numbers use each language's own thousands separator. + +### Project +- CI tests on Python 3.10 and 3.12. diff --git a/openstitch/engine/pro_tools.py b/openstitch/engine/pro_tools.py index bc4c969..18ae46e 100644 --- a/openstitch/engine/pro_tools.py +++ b/openstitch/engine/pro_tools.py @@ -661,8 +661,9 @@ def run_quality_doctor(design: Design, plan: StitchPlan, max_needles: int = 10) for o in design.objects: if not o.visible: continue - if o.kind == "text" and getattr(o, "height", 10.0) < 4.5: - issues.append(tr("doc_small_text").format(name=o.name, h=fmt_float(o.height))) + # Die Micro-Schrift ist genau für kleine Höhen digitalisiert → keine Warnung + if o.kind == "text" and getattr(o, "height", 10.0) < 4.5 and getattr(o, "font", "") != "builtin:micro": + issues.append(tr("doc_small_text").format(name=o.name or getattr(o, "text", ""), h=fmt_float(o.height))) if o.kind == "satin" and getattr(o, "spacing", 0.4) < 0.28: issues.append(tr("doc_satin_density").format(name=o.name, sp=fmt_float(o.spacing, 2))) diff --git a/openstitch/ui/canvas.py b/openstitch/ui/canvas.py index 576834d..09c3e95 100644 --- a/openstitch/ui/canvas.py +++ b/openstitch/ui/canvas.py @@ -24,6 +24,7 @@ from ..core.design import ColumnObject, Design, EmbObject, FillObject, RunObject, SatinObject, compose, m_scale from ..core.stitchplan import Cmd, StitchPlan, Thread +from ..i18n import tr from ..render import draw_plan @@ -595,7 +596,7 @@ def _finish_drawing(self): poly = Polygon(pts).buffer(0) if not poly.is_empty and poly.area >= 1.0: self.about_to_modify.emit("Fläche zeichnen") - obj = FillObject(self.active_thread, name="Freihand-Fläche", geom=poly) + obj = FillObject(self.active_thread, name=tr("kind_fill"), geom=poly) self.design.objects.append(obj) self.selected = obj self.selection_changed.emit(obj) @@ -605,7 +606,7 @@ def _finish_drawing(self): if line.length >= 1.0: self.about_to_modify.emit("Satin zeichnen") # Gezeichnete Linie = Säulen-Mittellinie; Stiche stehen senkrecht dazu - obj = ColumnObject(self.active_thread, name="Satinsäule", geom=line, width=3.2) + obj = ColumnObject(self.active_thread, name=tr("kind_column"), geom=line, width=3.2) self.design.objects.append(obj) self.selected = obj self.selection_changed.emit(obj) @@ -614,7 +615,7 @@ def _finish_drawing(self): line = LineString(pts) if line.length >= 1.0: self.about_to_modify.emit("Steppstich zeichnen") - obj = RunObject(self.active_thread, name="Steppstich-Pfad", geom=line, repeats=1) + obj = RunObject(self.active_thread, name=tr("kind_run"), geom=line, repeats=1) self.design.objects.append(obj) self.selected = obj self.selection_changed.emit(obj) diff --git a/tests/test_openstitch.py b/tests/test_openstitch.py index f482112..46489d1 100644 --- a/tests/test_openstitch.py +++ b/tests/test_openstitch.py @@ -366,3 +366,24 @@ def test_quality_doctor_round_hoop_uses_real_radius(): d.hoop = HOOPS[2] # rund 15 cm → Radius 75 mm; Kreis r=64 passt (Bounding-Box-Ecke wäre 90 mm) issues = run_quality_doctor(d, d.build_plan(), max_needles=10) assert len(issues) == 1 and issues[0].startswith("✅") + + +def test_column_object_stitches_perpendicular_and_round_trips(tmp_path): + import math + from openstitch.core.design import ColumnObject + from openstitch.core.stitchplan import Cmd + line = LineString([(-20, 0), (0, 0), (15, 10)]) + col = ColumnObject(Thread((10, 120, 60), "Grün"), geom=line, width=3.0) + d = Design(objects=[col]) + plan = d.build_plan() + assert plan.stitch_count > 100 and _max_stitch_len(plan) <= d.settings.max_stitch + 1e-6 + # Auf dem geraden Stück (x < -3) stehen die Satinstiche senkrecht zur Linie (also vertikal) + pts = [(s.x, s.y) for s in plan.blocks[0].stitches if s.cmd == Cmd.STITCH] + vertical = [abs(b[1] - a[1]) / max(1e-9, math.dist(a, b)) for a, b in zip(pts, pts[1:]) + if -17 < a[0] < -3 and -17 < b[0] < -3 and math.dist(a, b) > 2.5] + assert vertical and sum(vertical) / len(vertical) > 0.95 + assert col.bounds()[3] - col.bounds()[1] > 3.0 * 0.9 + 10 # Breite fliesst in die Bounds ein + path = str(tmp_path / "col.ostitch") + save_project(d, path) + c2 = load_project(path).objects[0] + assert c2.kind == "column" and abs(c2.width - 3.0) < 1e-9 From 227b9e9e0d2cf5db3a6bfdc32f4f295ecbfc9048 Mon Sep 17 00:00:00 2001 From: lbaumberger Date: Wed, 28 Oct 2026 16:33:19 +0100 Subject: [PATCH 16/18] README: satin columns, OpenStitch Micro with comparison image Co-Authored-By: Claude --- README.de.md | 6 +++++- README.es.md | 2 +- README.fr.md | 2 +- README.md | 6 +++++- assets/micro_font_comparison.png | Bin 0 -> 291806 bytes 5 files changed, 12 insertions(+), 4 deletions(-) create mode 100644 assets/micro_font_comparison.png diff --git a/README.de.md b/README.de.md index fdd33d4..22fc9f0 100644 --- a/README.de.md +++ b/README.de.md @@ -45,7 +45,11 @@ * **3D-Puff-Schaumstoff-Modus (für Caps):** Erzeugt hochdichte Satin-Säulen (0.24 mm) mit speziell perforierenden **Pinch-End-Caps** zum sauberen Durchtrennen von 2–3 mm 3D-Stick-Schaumstoff. * **1-Klick Applikation (Appliqué):** Erzeugt automatisch Platzierungslinie (mit Maschinen-Stopp), Stoff-Fixiernaht (Tack-Down) und breite Satin-Deckkante. * **PhotoStitch:** Verwandelt Porträt- und Landschaftsfotos in Monochrom-, Sepia- oder 4-Farb-CMYK-Wellenstickerei. -* **SVG-Vektor-Import & TTF/OTF-Schrift-Generator** (inkl. Bogentext & automatischer Satin/Tatami-Wahl). +* **Satinsäulen entlang der gezeichneten Mittellinie** (`S`): Stiche stehen senkrecht zur Linie, mit einstellbarer Breite, Kurzstich in engen Kurven, Eckenbehandlung und automatischer Unterlage (Mittellauf / Kantenlauf / Zickzack je nach Säulenbreite). +* **OpenStitch Micro – kleine Schrift ab 2.5 mm:** Eingebaute Einlinien-Stickschrift, gestickt wie die Kleinschriften kommerzieller Programme. TrueType-Umrisse laufen unter ~4.5 mm zu, Micro bleibt lesbar: + + ![OpenStitch Micro vs. TrueType bei 2.5 und 3 mm](./assets/micro_font_comparison.png) +* **SVG-Vektor-Import** (Transformationen, Bögen, Stile, ``, Füllregeln) & **TTF/OTF-Schrift-Generator** (inkl. Bogentext & automatischer Satin/Tatami-Wahl). ### 3. 🩺 Stichdichte-Wächter, Heatmap & Qualitäts-Doktor (`F7`) * **Automatischer Anti-Fadenbruch-Filter (`density_guard`):** Verhindert Nadelkollisionen, Fadennester („Birdnesting“) und Stofflöcher. diff --git a/README.es.md b/README.es.md index 9c59f6e..89f400a 100644 --- a/README.es.md +++ b/README.es.md @@ -20,7 +20,7 @@ ## ✨ Características * 🪄 **Auto-Digitalizador (imagen / logotipo → archivo de bordado):** reducción de color en CIE-Lab, correspondencia con catálogos reales (**Madeira Polyneon 40, Isacord 40, Gunold Poly 40, Brother**), eliminación automática del fondo, detección del tipo de puntada (pespunte triple, **satén automático**, **tatami** / relleno de contorno), compensación de tirantez, entretela y solape de 0,7 mm entre colores. -* 🎨 **Digitalización manual:** herramientas Tatami (`F`), Satén (`S`), Pespunte (`R`), editor de nodos (`N`), inspector paramétrico en vivo, texto TTF/OTF con arco, importación SVG y arrastrar y soltar. +* 🎨 **Digitalización manual:** herramientas Tatami (`F`), Satén (`S`), Pespunte (`R`), editor de nodos (`N`), inspector paramétrico en vivo, columnas de satén a lo largo de la línea central dibujada, fuente de bordado **OpenStitch Micro** legible desde 2,5 mm, texto TTF/OTF con arco, importación SVG y arrastrar y soltar. * 🧵 **Herramientas pro:** modo espuma 3D (gorras), **aplique en 1 clic**, **PhotoStitch** (foto → bordado monocromo, sepia o CMYK). * 🩺 **Control de calidad:** filtro anti-rotura de hilo, mapa de densidad en vivo, **doctor de calidad (`F7`)** y **verificación binaria** de cada archivo exportado. * 🏭 **Neutral respecto a fabricantes:** DST, PES, EXP, JEF, VP3, U01; perfiles para Tajima, Brother, Barudan, Ricoma/ZSK, Janome/Bernina, Pfaff/Husqvarna y Melco (gestor de disquete FAT12 `.IMG` incluido). diff --git a/README.fr.md b/README.fr.md index 2d9b282..5c0b475 100644 --- a/README.fr.md +++ b/README.fr.md @@ -20,7 +20,7 @@ ## ✨ Fonctionnalités * 🪄 **Auto-Numériseur (image / logo → fichier de broderie) :** réduction des couleurs en CIE-Lab, correspondance avec de vrais nuanciers (**Madeira Polyneon 40, Isacord 40, Gunold Poly 40, Brother**), suppression automatique du fond, détection du type de point (point droit triple, **satin automatique**, **tatami** / remplissage de contour), compensation de tirage, sous-couche et recouvrement de 0,7 mm entre couleurs. -* 🎨 **Numérisation manuelle :** outils Tatami (`F`), Satin (`S`), Point droit (`R`), éditeur de nœuds (`N`), inspecteur paramétrique en direct, lettrage TTF/OTF avec texte en arc, import SVG, glisser-déposer. +* 🎨 **Numérisation manuelle :** outils Tatami (`F`), Satin (`S`), Point droit (`R`), éditeur de nœuds (`N`), inspecteur paramétrique en direct, colonnes satin le long de la ligne centrale dessinée, police de broderie **OpenStitch Micro** lisible dès 2,5 mm, lettrage TTF/OTF avec texte en arc, import SVG, glisser-déposer. * 🧵 **Outils pro :** mode mousse 3D (casquettes), **appliqué en 1 clic**, **PhotoStitch** (photo → broderie monochrome, sépia ou CMJN). * 🩺 **Contrôle qualité :** filtre anti-casse de fil, carte de densité en direct, **docteur qualité (`F7`)** et **vérification binaire** de chaque fichier exporté. * 🏭 **Neutre vis-à-vis des fabricants :** DST, PES, EXP, JEF, VP3, U01 ; profils pour Tajima, Brother, Barudan, Ricoma/ZSK, Janome/Bernina, Pfaff/Husqvarna et Melco (gestionnaire de disquette FAT12 `.IMG` inclus). diff --git a/README.md b/README.md index 498829c..2898f39 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,11 @@ * **3D puff foam mode (caps):** dense satin (0.24 mm) with perforating pinch end-caps for 2–3 mm foam. * **1-click appliqué:** placement line (with stop), tack-down and satin cover border. * **PhotoStitch:** portrait/landscape photos → monochrome, sepia or 4-colour CMYK wave embroidery. -* **SVG import & TTF/OTF lettering** (arc text, automatic satin/tatami choice). +* **Satin columns along your drawn centre line** (`S`): stitches stay perpendicular to the line, with adjustable width, short stitches on tight curves, corner handling and automatic underlay (centre walk / edge walk / zigzag, chosen by column width). +* **OpenStitch Micro: small lettering from 2.5 mm.** A built-in single-stroke embroidery font, stitched like commercial small-text fonts. TrueType outlines fill in below ~4.5 mm; Micro stays readable: + + ![OpenStitch Micro vs. TrueType at 2.5 and 3 mm](./assets/micro_font_comparison.png) +* **SVG import** (transforms, arcs, styles, ``, fill rules) & **TTF/OTF lettering** (arc text, automatic satin/tatami choice). ### 3. 🩺 Density guard, heatmap & quality doctor (`F7`) * **Anti-thread-break filter** prevents needle collisions, bird-nesting and fabric holes. diff --git a/assets/micro_font_comparison.png b/assets/micro_font_comparison.png new file mode 100644 index 0000000000000000000000000000000000000000..7f4e6752861d7404999fcbd68b9cd98b137f47c1 GIT binary patch literal 291806 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 do#&kc6u z>)U_YChZgOF3M_}SN7pF3DwZV_FRH>L*4Q*)ku`O5b2jE+9oFbvUw7S{K1-s$3{fx zirw6MQo}fh6$R9`p^~amQ4b7^Rjz_)6WY{qq?wth;6>Z1Wf^e0bdU{Om%Fu_|H)87 zK;^9UcGSlf`KBS{REI0xP*3G)OtIOq;Mri(`G_=jPXkK~fOa(pdHY6)hPi$WA7LtO zUB8Nj!`c318}+UEj6lpO1D$`1wK(h)zdcE_gNDrqoT>C^y-iK{QCL@uzvHjGD z!)^m>iCtq6pVf&z^IL#tzNv-*rRnU7U#gWY@5e5%)=0(y9$w%sEBEjdBM;6Yz`)PD zIE85wZn$g0?xuJ3_j7Z>`JCpNZ_gZBy{ZK$0(; zqQVK}Us$mDmV zGA?p*d;~-9&GHN%jvo=mss?VqClT{4XX;T6Ji>!Y%=n>Ih@|$BRP(JNdj}b4srEK0 zlG*o*6VxuSi@CGx*A*c?4ixXrK2uX~+L|y0It`12IE_2w|9wI5qcG5yMYF>#jxkzX zoCqomjm|5k5|{c6fkFVXRFxVnCncJV*BRPYZA_w5BH%U4M&=OQ`XZwQyzQMVPaU8L z0d3-_|A3l;o|faO0}X;eQkP}?r*WB~lQ_A2mB04OVQWqHRH_Pc)57h7ZAs}hxxV}| zLWWQ#i5iF~?0-j{bUAR94!IZGv}5Vj{s;C0ppI2e2K_D&)9I5h;3Z# zcI6z+7cR~~J4H$fm+bQMeCXNtLrJmrKTOq-qrHfb5rd~7;&xc<_8M^6?x4UyR&kR) zPFfOGIT#E>C+TW&=oIlGk=Dm?lCCh|AWx`j{!fzbvMng-V66IQ`kyyOR+N@7%vaq) zzPQI-s%6`Z#0+S+cVpZ}_n1L=9@WBShcF1+A_xtuC4Q+h0!GYu)%bw@*8R4CIWH1O z+x8RMrn@!8ll%+96K8=R-!G^95lk`RZ85n6bR=NTDp3$x;sybq!osh3Rx>gbj?d15 z_T^lR%#?;}JT8ja9026|F0EO^^Me^Z%o5i;kcsW*@*MSE%hj4L?+mJIlZX5-yQNm% zW2*z-24Kvwjgh7zg-R@(zkB;(>!Z7-WD&3p@%3)OK4#~Q_;lkzm#rYKl)&tMNja}oR9DIhvEh@nUSqNeHD%r>QN-r z2%jsI(PhP+E7XjFf);Ut2H~~~f`8qJ;L(DYIPe@L5Qnv-VQ=@mC5Vgy8tLr*9`Pf1 zIYae!kS_26WI=M`V?GJQbl^Y%CKZ!X${K4)KQLV1@MT9&VhTVHdXGA}5YK7QMutT? zTTdaO7Kf8B=cUf(2kvh~U>(w&&8jiCc<$wn;@p_Q(CV|b8Y%M0 z8?onjkAnbBaz9B~%KmS*k(BG7rG|Uq?S5ndT+AF zR~Wl(tXO=D$kvl*XnD8cyu7}nvy9<;=2LAQ(cgT z1>u{b(Uoe#?OjcIR=%U-#$JKe8)XwgK|zXr(dH062VQ@rAyW$a`cV#rWKtOkvM8_> zK$vXp^}SiU-Hhr3V0;J{^-u-uX#r~A{my!z$PF`@@Vk!teKgZd+vMQ(THC>Kyo!jIGvVa|9FVJfJ!oz4nt45?5?Tc`$$Fp#HB{3joI36`n_a#3?tNBr)RcHLC z%dN%Hq9TEi);Z6cBQ%~NQ-|?LOXF(G=KfUrr9;^Ey#Xbng!8yxZc-O!3f#p zm5qb8U*9Vlrgjd3h5Jv@lI>pw$jE@v0*WxR?c^G?CY3CIkLH@?E!9_$0n}QF>MtAfUT?8Z+ zIv5_}jPB{Uv~ZLZuN@vR%NLJk07^Oelg7nRO$A{8Hb^p3gKj%yOGfKblxp_-yuVbh zj{}Vmz9F|ZTgrB`jjX=p()6qXc-AcGC}kxgY~}h$M2%cBuw7DXevJ6v)A)_M^uV8e zvd%)t;7+{wH7WLV*oOuEqV$w0Y)tU>QTB~mtf7t2-}#$PlJR7JJPSZf%TJ8oB7I=> z415_?YYiMS4@K3*H80-~Nq*>@3ZQLrv$hs~N}T=rYJE0Zt&t^vNG6$HBi$UlgLe+zMOb4!VFA6KSQnZM_IGRnoq#8l~ptczHI3<^3M2~c|}A`qwq;_l-$IR)r5H)`S1arEc}wr|A6B-k4#4NvipWq^1Kw( zHgBcgqeOks`S}j#?(Z=ff~~I3R%!kUwQY zNU4lqhuh5d;w58n4CB2p8d%(eQ#pKa;>ryZ!O+P?;RPAs82+G7D*4OIS;#St$u)UK z)_cnLZ`YF5?1-($=+}^g6^FY^pOB9Q%rG=G4BEm>3nHjer3TYDlB_0UiiHrd8!W}v zWHBdugp3EjRK^op)@RQ^37RP*3Y8RmGZ1Z_<2d=2jcTsi26WAy*6Lc5$pBRy$wR^0 zUWXTm?4I@eX@<_#MVX@2ft_B%F!TWLbAeia6%HM67=NMo0OXHYp|24hR_6afbW1tt zM1sv6YeGB)$LL6|q|NM}$JZPi`JipKoHnhlfBrBmx&tk`8}ZLJ5x9=z-ilUcpsnMx znSM;TbS$ObJMZ4P=sKcAD@tU+HKSgdW2kKCIx9F{!|4st_aMH)u&H;$#0u{$1{XHu z#eg>9lf{dtqjg`C3S5iqGDYVF-3rP!-}WQh^))CPbou*Qua?L`3i`I$^GAc3zhC$C zam+isjwzT#^mUEOP5e$riOc1RMu50JP7FoSjVV#;ZIRxwRw?>pO*7Q^1t z{d}B{N2UN)uMpV#P$wVzJT-Y>vu#FaRfQ?^uF@FE-AJ9Y7Pr&K382F0Y^IQT3kEtp)x4 z*#|S{dRdL;%_bltZZW1MNCGzD1*po$KdT-4Q5vNN?%>&G1zyIJgd8v+Z|U%Df$I4t zPwZMJBKp>ug;0Vt0d=>yT|P}7dlnTQc6YFU*Vn!Ad@N#q|E^l>;>I_G4E;*|ICwKQ zTQR@7I9HvQayM2QfY?3ivs{9RWx-QclM9d!a4KypOuGokx{IlCRCQx|mR$yA^bX10 z0)`D;=d-F#b^=%|)c%~%zz;=B;eN5(K{=O}Ra`yiDp-d6aYn)GSE3zUfD1<-ZkJ69 z2nwn(fAZ`!wQVZrghfX-rCp_=&R-It1I-KdL6qe3Lugp*-hwd`%b*+aK5BbRvNF=l zw}ulIFRO3!5dU2CW-~$5^=|eA9%=De=lH=f`rN6N2#974Qp z53*a&%0z}nrLrF=xV(lv)KG;FPix-bnWVR0q z#UdIx)c6dN%X6JXKYvhFji-HsWKR`(b^`zQ+8<@`modM4>{fyY$RvJ%PM-CgBb)*!-YJ!PTsHFiVy#j5UWE}bMc zMdjSjWVY(bo$k}A?9C#>du|kG$Ptl}fYj+MJ&}@G9{oDDMkl?uiex;`Uy9x`%GFCpr8$$03 z-UoC18c+76uV&!ob-sD6^^PaWPA1io-)~-aBY{~NnNkjZ>Re}fWV<%?#I}pooN$U2 zrK4|~$N9qfM^)h!YkTEkUX|u@ij)ZksSmmM&;gaQ=;Wc`NFTvq4_mH9>jHXcihXu| zIWtRCAm5|I<}nHbab{$kV`(Sx?2ZaAJ>+-Z&hg5J=A*_7Z9R*)tanM9J?)|Mv5!HC z3&#tKqXqg%Qr%Qgv}z+WOY>AQw`>!!yyBj(COD8-PZL=9x3VPO90`)(q0ySqy*X>J z@RxNNsXQBT>YOym%&4BKXfnqz;BUYp7E&FV!s+XX*)fAKblAWgOJ9SW74o!01NRsr zshpakQHfOS3N-G&-Q4KCba$z8erAwr$wx)}D-SD~=^?a1HH@jqVKqE=BXhOt?YXct z7c)$>L67A#5|&OuS3HWWbHc-xZ0#Ys+~}k$8PBlc^cJ$JkzU>UQ*5#@TkymuMM$Jk zVYz>e^(Z#osAlQ6?8oqg@=<+I4&?Xz1!n7)Zp`xMpaA%5gt6XkUR$eQI+y1iir`;F za+u@tQvPJus>c3DZI)3bGYL{(nL%lFZEclgzD_QcVQ(0^ull`I_`;DV!ATgq_K&yj zq6(&!__MZ7TUdNlLj~#h5J+e$^LA4Y*C#+gA`l2BhRCX1qn|&&*u4XiOD==$mMd9E}66{``w>PTPC4=Oc!)vH!Dg;S=4mSdkd_p5KBKzCcmT zGUZ?JeFqjmfSsNN101HS%{LsJXp{3Li@pL&3b^Wl8q*|Y0C^h!-^|uj%z91)mOSJ` zIAVf2{#-;DQ^l*W1UHMxtqRzAPO!7F*gowd1O-OUOP+*~pU*`-ZZ#_P=U<<`b@^?q zBEY9YE|ahCz?YG1rr%X%>@?OipNl6M;-r@IS&QNz*_kLD5IRT|KM_rPUTKA~OI%lMx!>4sg+G3s;p%7_OU!Qd*;K<%voXlzeY^NVn zjLyZ9Sa=A1MKsKJ_uF-(vl?VpONN6G@Lubt$92yvGJ;mcsox-4v>Uu(n7Zml#qvEI zsQBW*xro-A3=`#F&zw1@-#>l4EM@U*u(ChNWWZ_=eK2vPtnVJcz_{W5XSUr;Dg){FUX{?NS z8ppxFAz=5Sgjg0Sgqsp1!qdD1M@zRn>^7uP3HzH1eljqG~pXCZU$Q$R= zZe9OXiJ}YxQ@Q*ve-k`lP7>kgpE+Y!83xkp@x7M)GGE~-pNND5)n7hYmu5x9XQv8H zEBBySWj(nPADciSf<_C&KUlLusq{mrg#05Mh#={h{ZbLn_)rmu)YWK(99p#TF0Qf? zqMgN>F#OtkI*ZS;)fpyTOj8gQMq<-Gu>3HYPS61%b5<@*1530dyeBJ zDfUODK4n!zwXezDZiars(1Xr;ZB;*v3m}oEBCO@5R%iRuj-0*g7@<;HoAoh=TF{`F z5w5`;V~&q7XOqLb7LGX;0D}?%R6w%?ctq3aw>mO)U17^dW@Z%%=T%N}+9#>ZvAILG zy`%collEu5??enwM>Z$tZ~xis3kUPSbC?vadpSi4?B-Ff`9s=vZm>dq2W^Ma#;6nV zwu}!^(6F|m0#J4s3-r)){UeoXDG@FML%MWxr`E3Y;tKm+6%et4-sl*)858~ zFhiI?ugv$&qIM8(m2x`jIn3#M9gf#E;8@0Zs@ZzvJ{0{};C(07nNq^Mo7RC}$Ue!P zuBN5l_FscWUE{v4T#3{iTlk9K?I@sr3>W3&M_doSgzx?NC5RfHM7&X-3L&P6O(z|wOhts=-x4nEs{!>_DsY_J*L8v)}gqn&vHiWI*@k?t> zQ`2D3J_G(Ef~(gEe)efzb`>5&VjVAxrF2p)s`4II=sG@`O#heWeLH`TvlZ`9{S4AH1&Vqq2g5*($6w{LV6U>#Rd-NFi8(-~8xg*b z^kt(z7ZyhQix{#{I`ifmqN-5pTTYyX-Oi==$z-H*W_82BdO|f#_&m^I9-E4`0L{z4 zhl{iT*B90GXGrmZxcGvuiG(5ytr1;;?)+`hE<)N{^{#kid@Qy|dScn?1sbnIVsRwh zq2<*_i%Nrp-vx!T0gXIjb{kEotlh^?_+~e#UK&P*qYf*zxE7*FK@?Jv95;|8QlA#_ zU=qq~ru`qc9z@B79Q-@!t#hciMWzN2PS)iyQ+28p7to1v{XJ4Hd2WJgf+gUcDHDU- zSW>|-;LSaKTkkmSxBjPg?zu|kkCuZWYLM$YLA(RHFNZjVha7J@(|rh=DtF)6q3155 z;{Uf;@7-%(UZk9hZ>efZdg)||39xQ@0qkl>Ua28D5iLgK^*2IWXG}m9Ib^i3k{;eySyy7to@dx(-~A~y(?>MHJmrF zBNYEMA`4u#wlJP%Cp>9f49V8V`5nny^tR*1@XG@VUW~KH>$SE9ud4}%nUPrL-@ zm<4$=`rg*`mWL^{C*--5y|7(0kHX|J&e-TS! zO$GI`EFKx)%1J=S{t_0{ddatOi2_QYcM8hM>e983wtjpumYbX1?~22YAMf==mIf@v z+uufkYgB;w@IFkUxI-ij#R;uUx+HgR9yzsanmn)52i+`1zC!kagL!7FRU`$_K)Yu&<;;VCkN>~mFE`JcMh ziv=nvnvR_!RaEiS6FItWv`|f>cZ`sB=l`&m8hR`WxA%T5q+deNVl5dM$D17N%mxkS z!jacUKb{Krhgau{-0#~WUg!=@!IB9F$2vaMr|NG$v=7nnh*@klSyMx(i!I!r*?|w< zC=CT#c=7Y`4F(03QscT=pEGqxCY2OU_nPs2P`yg2r}Ez@bN_(G%T2v4%D{6&j4mj- zlGF}R%pmQ5<^`m2%Ue^8j-kj$XD)nUXu+*2a$q61@UVCH*E=AU;KIY&)zc`5Jjro7 zHmPgPqt11OV}gn$;xJP3h*QcJ(zxjiw*@G$ZsU$U$1Ls`5j7R)%BqV82L)@AO|s1d z7t2I7OAQr;pP&lH45u(ooNSiQjqIK+DuU2ezEw6;E@18G^7!8I`S+|35(J?5S>`m={W&N! z%nk3;! zC2{h<`LSbBz<3!Jtp2vjR92=BcfjcW#?TZ(jYo^wy(q6G? z--(@)Bkcxx?B`(QgVii^JNtEZkp;$f&D&7q-59hkuU+iGM=jt-g*G-3RoAGow<6x0IddiN`w~k>jy^?};6NPd7191C74I}l)ZX(4 zQ$)zN_P+3LCKcob5#nJ`>)|02>?8|Tg{QcvAHVJWoDzMY8pj5x4?bC>j@tmJ+CsY$M~5j+M5MHa|UzQ z8%6=APcqp>I&VWWTn_EKp8Pk;Z&gLk2vq{lw-ikfowXNAB3-_&{ggjVNCwx=;zH{Z zZ6s>H%pjLf>`(eMR#jWBo>s!0vI^JMi%g=5Aq@+n=80@B=`aZq#3#}ol|T1jnMF>| z1pzf~C3b=)Jt1|9BeHImIfuxo8r<` zr9Oz&S(ZKx4*^)=XFdjkCXi2SVg|gs?y5Y9^NRS(pkIjH9|AzA`eL`|6AMUT#_4 zqcRqeZNUWnWUPJAjSobDgL8&=1d}5EIme^A4n8&n64!A{-aCIA?T%KzZh=^U%Ym8K>sBKM>!k9hwiAz)d-zk#7i%VVhj>SZhb^0{F(9903|?!ck3}pg zk=YMWI~LKPfYuQ9l$&fWjVhPMJvRUJf|22}Ay#?#Od-HC@yr&c!7vA__b{tGSzBQ* zjo>gZ#7Rbw_#^xMU19l9c~P;!P9s;jKyW!(LMeCp+xQ;hbc`qdC)!_)nfjcQDDA1y zO>A{8ViGOG!-3o(;3678mSxi zB#UjIb%@b1Dl#ZHrDfIyUIz7y++X`NW6vNTtz?I2GsJ6~d^*-Bj2IVjn+Xyfii?AcC z2H;rhl$2Me*GjTW*{_60s=~1pzc<(B1r@RN`i1$Z%+{$@|v+ z){k;(=-mz4d&g$-C>q$_dsMJHrJK(nwSmUQ*gX5WBHo|TsYLblVd7y)m&#!QkAc^d zaNnkX8B`eHBk%t8mHAim7gH#*5T(P>`Bw!q6@oIA;^#q@c3jDWT5JY7yF+|r1)Ob_ zhDB2bI<~0dcy9uPOa78FKt9r{HTl@nlfRga>lv^KFy}sETiNASRdq)d-`wIYD17o1 zx!%5yi`CRt>6c9ti*0r3tEhrsx=WVN`@XK^pjJ;xqQ1Nv$bRh7D@3>@9_Vt~^%`Q> z${G+zz9Fcat*WO--1aQGjjx?J9eGbK^1J>U2Rk}Lb8G!r(iA7X0WbGLt-Y^8Pyi?N zf1kH~9ACo8f%S4rWkTtUOubA4g4^E3v3R;!#0)@te6pVL7lx)H@*5jBeBgx8>wq)f z)=|4C$M3qsFVxGrN3hSQu_J)Fn{(P9BqcDE>;}eB0T4=+5X2xg@NZ(59`hFOwEMSR#%U(+z8#eNv)L}F_#Y&f_lgGf60Tni{NqU{<$kf1X#7?D~hKms5_q zHJ(1d*JcQ~;_2!%iEJ+l=&YlP4`?c4)_%vM^ly8h{8w4J_@O9=EWbPlSDLqO6)n4u zn>iHcUG=O@e}OS+LFgfenawOi8aT-JQML+ZKOX*(s2QtaL5E!5XXSK=j4R=Ee#jrI zPexRK@5$$$6VYT!8fj}De~n;jR)3fTTDwep~lkKc6R&Y8S=EE;;bcDnwD1-b_M+J722 zRiOr3_7H<37jyxf-Sk#9lApR|gVm%MH^P)D1yzRfbOh2LA~{Al%MH?QQ^BQpI*Ex> zshORqUY8ekuEtN^*LKeQ9&#MVQZs9pR&8R+v??}@$ExK7l98ZFU0E8H>S#9+LAEMW zpkBmvYj~c+#Vf)?jB$em;iX!5{L6WCe(ye`pJx2q>35&&pKEO!C+b8NBWK${Tb8Ct#ywKb@y*yK-w=FYwd4$>n3H?X4^bbA)lxt zMh1;D>U2WrB0UB$92k|vAt_pC0Ucd$35HY(etjfvSGnqJ(sb1u8h{|-bf|W91uxx= zZ^cr~?L+l+2D3!!r*8?qf`~y%tjt~WA6w>05zXpcGx=)Z2-m;;Lx$QlYMK{}(ytI> zL6ytir4o)EgI$nEC;!)mwcZfXk1x+40gnumQEL*YX|l8#U0dmuNgzubPcC8R`|Ru0 zyS2f!uy1^wm)@@f!^b+fED6lzbFmr$hdPI$rSk*#S>@m) ze+&VlMKuh*WUF%t4q}7k0H6!bg_!o1oR=m{0s;wNLhixi#GW%-TdND_ywprDbBJn= z)`izqCcX5Cfa6Fg@%m`+=&Rde%BP?pQLSu#gzZV!VANUqFyXzJ^%GFyl#+Kwt8t`5 zn*pm=R-or+hNpIFCR39>VsrKfBKVMLHme)7x&gZ}vBu_&pUF7OJYrI(*k zpf!!-^AZerm(A%cl-QDv{RQBx!qd<=^z+_hViQ>mm5=XpBES(qq;Uwq7atFoVqf(S zL@=Z2pLj{yI3c&Vxq=h5?$Bt?$@F>dWS2KQ=}{q$f9;7Rw3((n>isC?sy!((1bo)- zt42gVmX>X0d9SSh$!$<0vPZ{>B?>s-odZHm-~1!5-aQeNDN3sAL7PhjJDM!bL^jwy z@GELU6z2-g#rnv?)x?1KBk?tf0+(M%-0=t&s`%mk^g%^-%j>8GJLK3r^oc6D-k1oYUWh-iOiUv$mdZ zX9%7xr|nUKD#>Rx+Qo)xGuvcHs;E2PhxlS?<%>&!%Zen-Tz5+O?CCQtw-W3Obwo_H z!w=|(G9DzzjyjO#d?W!B(B6BS*;~c&A}+Gg&phb8UP>lAp#K9mae;N?1fWmc@+ggR z`B=p{?O#fw%$cXjhNJh&;HUX1fTqck_D<(>C0T}rsnOwvKUK9QAkghbKD`3>q)@O$ zU=pEaGvb{P4%jS7Nfzn1x!9wLe-hkSpfgO@Y+h;r24F1l_6&gMjr1?Y6vL*PcK?-P zt~ACqQOi2GC~sdoHXnbmgqE`N{sch927iEsL`sN{Ct;P3wC_z@wybLHgxMGx$o(L% zu}B`RhbiIljl8LoVa_PqH!MGr=EHLn{M&kaPc_W?*QMuB-i(EGy3O(DL|Iq0od$xn zpGQ#v@x>)S=Ym!X1@XmRVT~g*_HpEKbMWyf`~ILup(fXiTi1f2LYL>0|wEo_4W8Yg2WX!^==x@-Dwqe8zbRAlxV3UNP(`)zmwdXcE28&dj%dONl8(6A%}U z86M&!_l^8Lg3N@?s7y1XIybFg?M$Tu(my(_B- ztpl@f9047g9G`?mQ)zwL*u$*h6CqBMuA&<%-knUdVkn3Kuiwc#%?Ba-G!0dW` zDHKO8TZm_@`hm^R@LeHozn7O8-%IG-{GCkE{Pe51Coj{7CIJMz`B6XuSK#XkQ7Boh zZ@coL!xM5wumX6l+8c~!O0kIjI%FZO5?74ea{QfV&*!DbQ&MjyDEV>i?t7SaEDdpS$J80c%QhA<&4WzTz=lW zsO@CO(qDz~*)-s@EaY3J(NQG)S7Dwb=*bA$t~q`J!J!*=RfMxyxNck#`r>{H2@1d- z5r?*&3>6v)Ha674>>K)=|LhqHHeXt$|Fj-w_mYygZ|0u|)UYCtQGAdSB8{kKIXbCi zk@OsYqdzWHlE3fv_}6BLUF6?!vaJ!L(@)2#W_At?cS2!%P|2}2i-7{U3n~=;)fjA0 zL(hnB+|h>uk=O50c@Fu=|I<7kr*?-E8bnR03liQwgBjA^P=Mor!>eDQeyOvNdi=Hm%Q)h)(S}u_qDRu?CW=I92h|K&x;qMqtkDy zp0|1jHCg#Z@znJM`uLmCajD+2<#KB&&FIB~2j85B`p^%#&ZDipp-q{BQJZ)6BxQ#c zA*0S8mkJs|2j@GR1AW0DYAz)SC@zFtme9Pe;wNZPmS)XH=aDwMjRs_D)L_c60w&;7 zWLv(Hp$#x9+qf~Ag-z5eIWrbgrD90nT#E^$D@zN^R2Eq8G^rNBqVjdOTqp#7(CZYK zrxi@{(}`Yn+E%o@5v*tO$a;d@XC1ZwY{22^aLgv1{Q1Cd`}^sk!0o0J-oUH7owG*2 zk)SEGFcTI@?(`3AMsV?sLZ?rpB=2&VY;<_$KXZfe1Ng&zm4l6$q~l>INXY30rA)KR zvXswM{Edz}k3ZUQ9AxWw9rX+Mjpxxun$dh&BaWi(_>^k?%ZsIeRKt0Wo;H5oR(im!rBYJt)X!0i|uug;x?9jx$YS#n@JM+^Pf zu2oXcrmwHAA#5~$N*_ZvCUcfvp4N6N*!JgazkB+4OtN1w;H08?ToZj7I^){p{#Qb z%zu9{ZBFr!DZNT$ZL-ZL>_n=84iI2Ue*;>OQV0<3pXg|B9P8|HJ+S_ zaO;8@3s9gzf2pU}xZ2M6UF8D>4S&sg4yKnoZqpxX!cBUKj?x-@+dmT;a~HdU>^wLq zDh&;~G6IbRoUVh8Swg-SvnGu;rRGT~RxhE|o<2-C#~zD!F$GB@T2UKM0P7YjW&*@x z?~8g0(1^71+a5M+E_Ba2&<{}|qan!cm^~#t;Cc%;EC)AH-l!RU^TL;3a#XWu{V2cF zs77@k3bkHEjtEh^xq*-!CdE(QG%N@Xz5C!Q5&+p7gCzM-!S^8z5B8BK_q=AfZ+UnK zo>tWrAa(fG7PyiN3$&2<;-Wqc^&@yNO zW>^ISN}OZgMXi0M?rm+B{WI_4y50d@4`GDxR7v{FLDIo-heKdaRpM3bN+7HotC(2t zT{&|BP~FTSqo~}d1e!r`7)iHadgtlV665qcTe;^*09K*SU;kXb@HuMi2{|V09ry1k zQ4S1yOSuKV@VTZ?Vo5(=zK1kXG_rBze&_MGeZ66UFWyhc zUO)13DFxnUDwL$zo7y+ik6+k1)~#Q}*YqG_m9ULu23gG%K7{fk^+MwF7)wu z6&1j>)hf3qP)N?i!$fLx^yBh4v6(d9}X-(zI2W;r18YFl36n-zYN9Tz77eaVHf zw_Y?DRlV>Oy9yT-_Ve)N*R5~Cx3WcLodxDIb-K(r$2h2lOjE^^7J08OE)`H99xj=` zCc&P}`b;DiGtxmYK&-QFvet!vu!KM~ zxaokh3+BHG?f-3s`<@zP`n+EYm=MKfY_Hir(z623Y`tMou}pGtTR&bIefcpPCYZ&QSRc>(5Sa3NB+CD z#?Utr6INTT`O&J{)vDFcoPYUhJ4~mn?oJ|<+IgF=t_cbe@?8|i>88i!z~B}l2G{}e zzK=}cGhj0^a1fQP3p?2*Bck+MW_y1FpNyA$8OS6?=z~hiGT7>7ZS4;S@&IxGwxpU+ z-*t;V9m{sQ4$D`Thv(iC#_uVe#dyB6O|#7QUa2dJG;V1lA9VnJ4d8C}GYoh%3~-``HGD5q zPcN``W=T=z5MjrCc3hnoXuwEzz=m(Ggy3BDF#>&{ zd>VlLy1mkKp-|(3474Y&B%a>DA)ofDc#TM@sK_b5r6wOvXAGr!XUQ{2o_KMPzev$? zB_rD~Ea_3Eh7yCDFcsP3-@L0_rlzKpA)?oCDWQp4HhtY?ZCzePNnRu|WTcU6?boDF zxk&NCOw%n06fPF;NQ0)CR;RIA6Y(wcUX_&*sbCaMN~wMz?70{}*u1}>yqx9`<$ptt zg)VY24c>70-Ax@Ek<%QEP@{(B?`LAA_&nODQ4GMxBaggfXZ#5V^0hz7ZRitZYL7-7 z&*7xN5FZILsk^s6r=_k0w(Ci8H{lE%)OfRtyU>3g@8ncLzvpRlss)vD=JZBo;4awJ zhDL+}6LQmiMviUflr8I1GhT~>(=10txO;eT-GxK=vV+*SAy7WVSrFQKeM-2=ywrL- zjM8=`_(+2r=lAO^J5DFSHUM{NzU`TN5S`kqz{dG~#p8=ifs~ToX!Z&M1*7|=N5m2- zx!g~+Dt}+uVg28i#hT5qI)%X*{Z{fgG+|0MoZ}l5pcR|7stm0UoH3scgtgStw7NPt za8TU*S-XmfDL_G5k#tY-=)0BU;vy{*y$@`KH8i-4M6QilU9a|(1U?WgsirbWtEVwq35<`sYcSH$2dpCMEKbA>(Mf?MXV zjEQ4o@oulH7Pabz%}IV&>$35BWdST!BUaF16k6Bl;tq94goP8Fw!wNP-J;=-4K&AI zF#jEeI4&?I*zBc=Fu1aD{0+k4tIWW+((z!>W78sKk^JY^vb^SJm_<_&{i?WlacGS~<_8ajSA@ATFhvRPG~B=J``v=;=B> z_N%C<$L)mzt9R8sXJh_n(YBVu`i)-na|xx3U2f!-4i0ej;`gzIC`d=(ts}?VB;o|U zT;gJUVoybo-TgP}!_+Y(4;#$yqO$|FkG%oCQGOX!CjQr=sWpJsghoFn?{gv_s;UjQ z`v+qc5x0B%-i+n0t}i1 zv4s$+9AlRPb7SVXA`FE{>)jT+e#TcY!6DM(SvMf~Q9bkQ69kY@+vV{X<$`FO$@?hK zH%})QpTu00R@WxOj(Y^_#mhKp54Q&Johz?eGER_`lMq_JK5cR%bL1B0~}t577u)6x}>mUzc)UOBe8a;H}C;pG0dnBWVk z)VHRALZ9#;nxH`+iSc%``8=2;8n+J7O{*xlkwrL!9#_c}O6CXzUP4@-lNj(~P84hE z#T2X?x{>#R^Sqy-a?;=Ay1Qw)aaiWR9>pcxj_T!s^y+x)mW$yg<99edxaa;{`Igli zK9Y)sG$=*Cme;Tkslbjg$lNHS_FBUYS$2lbz3(9WuyJoH^9H8>B?fEW&+POcl_9rJ zIx0ncUDy8Qk@+$`)pd}pSbv5#LII338|q9!N1N#Y{c%6qyJW2 z%03@f7!6j?ERUHYz9bG{US<$0G}?5RrRcxrbr2AcS49n?F0l>eU2HP5!^c5`#iSPh zZhOqD$ZptMX5~FF($^JrzZuUjX-t{sz3c>r$PeDkm$3yB;>I}?zQIY$2l&2@ZRY!1 zqL05uwvZq=e65Ll#m3rUFOdKXpG;0xHf+@)aHbVbOB~kpWCf0_$hCv>&2EM%B*MlC zUPLwL!7_JJ%@X^n$zh*sVjDH+liT++%DC?hx06s`+S% z!?^Ve3b^wY{Tg;Zm$`v(-}u7%?q(T*J-_Au9QMz=lW8?{_53aS$LEVSU7gVXjUwPO zCu>@1VM8D|ow{X{+zY6nLdJq9>Ys$S-99|Igg^FQ47;%RBOKHgk-ax}k=B82Y5h#G zdMfgel2HIf_B^VuyI7r-Sy78y?05ZQF9;S0#A3rQcO&I)kKYnAEE)=Ry>p^o|5|d> z5u3WO8q=Z3wQVPDP(sA28rc^V(6+U`66u%IRGU!3&7C5i=R(~FKzyR zTmTK)r+3@&z!uT4zz(X$UXzeetXdHDET(1T?uJ>*Cj{a1$*-~~p&>7-jBW+Y!wwdo zWjXL^S?oT4ZVQa4sC`v7Wvb<`bLahir(v#}`z%2;v1<(&o*0sheG^@h&Ki>oHgTor zs+{iR9QAadp{%95s(aPOwQz3Y29J5f&eol8S?Y4hO*={~5@3Q)}z7 zYud!P31C6KeJ2Km7JkdEYY+&!z(2o#wvQLS&j(HNq@`#(AB4LIl$y#esaYo9Mnolr)UgT3s!K2A*b|uFulM0*H1iUDY1G|8Gaep=>2s#!$|d_ z^&zwfH8U0j5blN@1C2+{C%Ct$|0s7k6GxR4Y!rkTy3j!+8cS zF6?0|bn;R(okP4zDa=*CWR^eM;^pnUbPhT-!7~bW(Z+uV7M{3n<7XQ8Eb&Ri1Wk9LZnay2(u5NWDQ7N2THw=H4a} z%EZ*E&IOdn>+8iJagptpT_ zt`*Ox_RsCrzGtiBHYHOWcOo>nmME=6iT$JJe~-821n{tY;NQcyep6D-U!nCm8+sp{ z*RW@fq}JvDrFS}7X?6{F)z+}J$79>JQ&)%l-LAMR=jON= ztjCha2ShA6|FVG1u~4cq{fifxH&a74b-noth_EMWz6Ff)_`D(_Z@Kvcc24Uo)Chd^ zZ!{4+hF&Ylpq_{$o87QUXl?qk^?m|$h{&ZRYZ(fs4H5<+JpyZ8w+Sw!{onJ{ku3kK z=h=V{JjjVCAHqisn6sBJyuH zbwpupNdnAE`UAi}2YjGTT$GsVHmo{R)cd&cheT^EgIQ??MD*@9Ce(OOmjR!m-f-^) zB{~F>8cco7H&|^pq_4xge!8LlcDC7^M@!eR|9rd>sHNedA1ehV?dD>cd2;r84PtwU09#e{>D({tHF9 zEsVOWO`f;9(i0$MntUCziZJI$>G1fl=X}2y{>0R9#A12M-POIU^7%V;{F-V?i+{fU}Re3VOi;;)Rd!N{+$ z9aQ8++Hi*XnMTlYOqO34)46{AX->_@-s9lUOxlC30a$rsQ7m3aK^B{7dGSu-;tI&o zi;FGhctRRVrdBRofH)bWpFhRL?H`#N>3i6;@p}ahPYj!L;J+?AYK=8sptDcOFJMG} z74;Kf`EsxOpJktY0v23ayD0({Q_-$&c(P~+`m6+bJEClD*Hd~kDS z+BVQ({xjluemSF1?S5iI(G_p_t~lC@1t;za7+U)p6fmIsiv$F`naD|R?IOoQ5|T}% zICUfrb0J58d`mO9*>e;*o9)lnS5=?~wQts``N%xp@;qH$R-~onL>@m=zt1$-&gWF2TUijf|3FZ-h{HMab{k#E)kT^$Q%qrF_x!Ki z(l=LH=(YbrUn4%aPCJQFQpkTR(5Q$mum&!pWxj$P8S5hDuiav=SInUIb@P zOw9PY6loU+s(ATuX%;_kb7B;$S6h{5DdQ!U6kNo?oIbl>Ygb8aA{Fd0V7d#Bg^>5n zJKyF^SZ-AeM;nB^B>-*q_`y3&T`C9B%u%O41>IF{0)(0A#7tT~1|_)HhC2PsT3Wfd zfp2kF`7NwCQWQgW-K*5gx1zbV_!W-vict4f08;)uCwQV?3dB7l%L7OV#6i;rntF2Fyo(XZ%(bxO&Q z0ZW(1liZzc=8CKt;V}S!!!`=oE81F2GfKheLV^$s_~evwx<$i31pK@3#*SlL1%o}h z7nltA@t;3;iwUgUSN+CfE=jY@5^Zc@aAx7;B6fBBxdPhUY$k1(Gch>1LaCwBiGZ+@ zv6n~UcC!F9HyjhlzFd1+<{UNj&!nO`$w0pGsgUGygSBpn?OeJFHR?>uO|M>!Cxub# zJ*-O<-~9F}-b$_zf5#2k?zLzJS=#)@AZIYjtB$4cjSpp0%#*VxJ9=|9B(bLWk<+W= z??jBYmAI0kk}b1u#mh8}40YD{%4h6^uQ~_ggm2nASKq-we=VI6Q|ooG)&t1o*HdD* z)wbO1yNZI`3p4W`hNM4h91vGhSVm@HjicQ-M z88XzFloVNbdWb;&+K@*Kl-`q(~=(3l)jE*MXH(yp<09Z`g87d z21z6;rNm4+%j{IaN=GSzWD5lli-e`{`)zYeoRxyI;beLsF$bQ)?smHfJwa)gT%oP*$FeceQY$e!D@Al zR6=`NH3tP#bsL#{i}k2tls0VY>uKTodoQ&rC%$jW(uq}*cE)5C9o74xp?|W9UZyk9 z;s)~VBi!@GzP?C#TU-FPPumO4qWQ0M+~hCrklCRv1-m^T;40uRZyk$AJu(I|8SB9h zwww&Qkt*K9q6hrYr6W_TXW|~Vf#NuHnNeRb=s7TgaeRbJ*Z4wB?49`q-=sea4Y5#I zcJlgftS0rR*yYg=^!d(v|S+o4pWHn;WV<2>v@I}^WO zX90Yk4>s_eS>ZK{68hBFF>Y_?7r)W zg&RJA3#ezXUDCU{2d%)fdrQNnsg!mcR4}*!;QR!in|YR4Q*fJlg1o5Ty4q`)ojPam zd-F*=yE;ia#$`18_9`aggWzQrzrV+dsVCLlJU;f*PMGCCU~DXz?5$z{E_CLgqsdbL zaqpsXLM=Gw$4Cqub`X)<63@JKrU5h)o&+hJuY+1kYQ1&r>8y?P1E5!f@_DbRcuIb! z@k@f!m>33y4H?i3fk^t0|MEJ*XJYsva35~@)ojL_!t3aa$H+%dCwZf}cy`$B%IixP zF<%0;#j-?`g9=uaubDKIZjNax;1GbXa z@TzGL8MALDJ?28))eH2RcvrHPKLJH`24pB6B0k7WRFU}7yD%OCkN-uY~OROoB zeD9({(w*Sxmc{kld)Bftwg=?YMgNn$)thJlK53dH_)$>}_ zH)krhsh5{I{$wjdXbg$e@N`wXRq#S^Oi7}0<*ZdvkR1 z0jKy>m)^o_Mrs7oDTLWuO@?S8qZ@dN6Z`L916H@&!u0$*K$yH)9bff(+jw>KaQ}Ew z*LJrF7}(e7ZIAr1=4~g4rH8?az_aCC7YY`WqjXS=C899Q!g)cH@XmbKV&IoNU|mO99eMv z?KI4w2*$(1S)H5NXis9|#I9ij^U3`kiL7wKGEy@ZF#;B@u^WGL4i&+LfC|Q|+X1g9 z5>Rzpd+AAprUi1q$nLwKt#9jwEA3_$bBOxsncQ(ZcLHe&+0k*-RSt7JBX5L8 zWIAuht#BlwXkJAn(RCc9o`%jg$aQp2Jx$3WB3iwJqIXo=Nl!MxKU?brwfCdAQ@;;+ zWMu5NsA;ZB-tZ2~c@@U+3Ru>n802E};f6%9#sO`A?JjziDt-M8cpawGgwB^UcNCEV zJ>dEpdYS%WnYj`F8mHB{+DGZ8iMT8GE|lRj&dni-eIQnjYr;W%u411%c^J3Y$7^&T z39Now-WJX;0}eYq*o=mVY;xOu=5v?W|Fz&LC8n4YD%}1=8I*}hd6*3)mB07%@Yr2H zq3v>r=z8eBRAyWDbY(e!3aSyT;D}ZK8_2kBLi0aAT|M93HRF#v@j8eZ9BKW=qE*3e zXnS#)e36uEHL06nx>J`Nf>;Z}ndLcckj4mJIQ*FV1Hl?PvS&hUW6RvEfR_+`bxW6x z*l;<)QuY2FS!b`}xLc-NHsMcVGQAaW8>vdz8(_qm!r0KZ-35g zt`sJoFIDFTv@EZQ>bGewOzY;vHrHXtLcVtP!+S;ap3m|3(uBgPjNF$zN36p^BWp$A zn(up2*Tc?a-4fHUbSul$|J6Xa{bK71!lKql)`0DQq-lwrG$(66SQXO zhyvXah4g#aXCBzxlGkn@wy@XMkkN0ekHmNFw*Xa;hIp;fRqIH-h#<{GjM!%h!-I^! zv4GA^P>G)ObFc(30;xwv&*L@@7lZ(j# zX$OeN7Vgy(QI(J&k@{|Q|8pq<%C1lL!5`aeYWcVcvMO0@JuAKrBpJ{gT|_lG)Xu?+ zegWMr5^}EeM1Gk-T0kDmry8Yd=g92rw>%<)*71ZW$Bq((4*e&6BE(}&Uu+eQ?^Ijo z+8GWLt@~9q!xJCJj{^3)!2@L!jrCat2f5#@9Fa&8=4Qwr2L4MwUnLaCWtrjtd?_%n zk9n&h4l>;yleO5hh5hdsLWM?*roAqE4pg6hd6y3?U&U6*y3wX-b(8+V*dbs0SXYVn z-5#Ph7Gug-gZldJ$j{A<50uovnyyy|xBMyJO|V$5 z5yf1S14OM@q2O<#;@BTw+G`Y^J4ewBCd=HgCL?2GhqT}6vk;tvnpYj3|5n#G7{V0T z(zFj2C0b>5r=hxKV_&-uLz&InuF?dR*@f>$GcDg!QRRv{es8`>t_awQc@By@pFkSu zjkEb9RPtxvEkv0j+W_>t`8J)s#dR zi|FqkHVXp}82H)SO0Br&L<4R@i42=6(@NcaEeb7vNA!G*bEncJ1OgH5Z22F zJDsqs_sWV0%kNg?c!8-x_ma6d_t!%C{6SHfM;k)x24#W6ZlO|Xf=BUZv1Ywx^?$yh z@{H!9@qyDX<}<~`BSSJzksW!O`C^|UBifR60?vb;B7;ZoLQY~LVff3(x!*8WNPEVg zPH2wdwKp;Sgjas;ZVtv%k=D}8M#RC zV&LOKUtF046fu?3werJ$2Hb2uA=YG|nL<^U@T13{DlJ)ABA`MiEgr3)tg#U*@(jkpZN$GrW$`Es@R9=`pZrkx;cx)DY%?qNRAP_3eg6bkeL+wz4XPM zff$QIM54wJ2Zn0|!@19CXms)4K$lLOo9i2WOj&C1M3`#jC&xud}~v0%iWp)S;D zaep^a{e*PLp@MNl@#solJLQ1OS5UM#T}1WSnDq}~uTd^S9XJ*j_0MA6diE>dcg+jg zN>WVe=M6pbsmmC5!Tf!-r8afas;GVAxxb$xGd>erCPD6dO3nz?pNNTcJG7XaUsML~ zjh|QdmU|mOiP_l|V0JlpUa?|WTGF^iLc@%)?(1guEvh#HjC8)mg?53E`}Z3?ztk=7(aR%GAJGF!t}XUo$!XMxoHyCwt!I}- zf#+mYt@68d#WqE5t&0sSM3=WwYEfI>n=mznVPpcxCu0@fKN96xg9xu!RrR6j{jS;# z?B|~?fy2+v*H~oJIhroAkdshjvZM|+aZK{eTRLlDhB=mJ1>i6gFSGm)S9jD(>J8RT zXw0adR28KI%`>Zy7P#oFm_876xMHkd8TlIUHrgv1c*chg}W4dO#ySux&`(PM`xoQ}OjbS<` zrt|9S>hA9Ny}y6_{&t*my}j=H`FcLmnoPAwA(R{c zrzG!21N@Ap{&&t$`}x2WAn3)v^Uw8gjABw!?d_sTauUrfpPmQJ(Zuq+r9@j!`$5v% zNqbZK(M_~gH7vH|=h-Xc-{V$k?!T;LqEi35Bv zdi&Ag+7{=ltl_MS0^YYO2>i-kpZ)EHoYWw4{Ea3ur79I4w(ZoN1I74RY z+_6w8B{WZ!(>Kac&U%C|BkITG_GjAq+-)^f{@CmL?AH*HHopJ>DG^8ommSxe}8 zeA-ui*&uK9preCS&cBrdFgfVV;Cs0Wi11Pi;MdG#uuYM7;oXPU7k); zJUsEsw{mft51yml)Xk&a(OwDcT>c_jGy)uR;O}0ZS(!c& z^SUd2bz*qvLry>ULEP-;DNtGkw|VMpquC6gX8_nNVQgYLsu|IyYGA{k`=dEQe`eb{-;`2N`-S|R8*h|BGdIdFyy8# zRb?*N#;)G|EK5{woJmRFNC^abIV>P+0gx;EskBdHXzbSqw!k&e5SqI z$Ge{D^BrW;F_o7T<4}MvB&w_|3ufTt4P%l3)61q7+Yr*$Gx~U5Q^vbfQJVxg^^&V) zWy=D`Uqvh$g1G=B%%;0Qb%8CUE)dv47AKykJ*t;I-LuJo&@jV`xw_s>^V{TyBy-^b zMgVjm&YQWcZ?_Q_DIkNHs70F30%!4*s8m6G(r)s;pKV!dql&*}n@T?`5Rn?nS{I4z zuv?`ENxe2&37F0&%4z=`oQghS&&AiqCHbI!;trGrp>-_-OTu+qFD+|^Q`pH=`^{P9 zecH@k=<;K8ZyQV+k4E2Wt`5KB{ho^qQ}LR*au3iV@hM#CXMLH8TY<-DNBiwv{o4;8GXKZnNmPilp+ zZQkp+1Tp6g>bXz~tJ4JT2U|oVGky$Y7`u82fUpnmki50_en2!pW z8;gFFJIyh^Sh4@@PxetD<7vyD&LI6@%!&E2*K}!NVF2IBK_wo?Bm-xN>wxkzbw<3T zPw@DPmxzKY;$@#B5EA)X3iV(2MK|fyPLgEsQ_#rbr~O6}crg%f5K9ZFFRUF5n~LS4 zA!XBsna)Lv+w4OX?g!;8(Ds%Lia&_l28jei=u zmckPuJa{#8yVRGbMgodkBK@c#h&>P+^8Gkg{sa_mSSbFPEWJoLG5MV-X`?3Wx6RzZ z*AILkI~>-Sz<;DpU;tQ zMyVW<52U?L{JY~1k(+ zRVs~r9uGWlalNYYBY&3d&Kc{ZV8ZeV5UF`kb`?S_*3qiayg$`qUN+)B0flcKwtKq0 z&$q{mvhH6+c6j{cx81<8hZ~Eg9N!g1Rhxnhii_+6+d1nENkrPImYPk&j&U)72o6-h zW%uc-cd@&BI7?YuYpNt84iu)zv~t!2MB`kn59(d@HP^E#2=}{&1+BTjEAI@S z%XJnBuT1unS*E1UaK5rFwgSouQ_UU+>N(u@kC4JxTl&G`b2ZNE#0DSztQaTly{82y z2r70xPeSvok-lNyUfVJNy{OhU4%`)#o729B>E2Rcbh{VxTxzi5^o&&~R?UrLz3A~B z3iqt)j3TwzfgV#8;n|&qn5$-S=Tl28Nmd{A->J>HlE{96j<1lu#gt5As7#0v<$lqp z%C(&T27R#sUhiV0$!9SBuXZUL%Ce~z2-KZaHD`$LmnE^Q5o%(Ezb~2|ux3rLwX={Q zPfFNaJbm{o^Tga>cSKYdi2C!ksYFs@q95Kg>etSHn0#13+WQig%NYp_kOxv`H5W>C zC54@?syF$3TPxLdYrh|8O4zp$C-3?_H}_-d2nI=ES|M)CN2^<3Ez}GvpW5@q{&=z4 zV-+FQaR4eDNj|XVF#B;L#ydZD=sk!5g2)ucqW*j?)ps*?eQ)+~XyTXK&Ap##XBMy~ zQzh9T!Nd6}Kk%MJEuyax zog;b%q;Htedtboh!L{?^2Gc;5*Ryn>W!jFxA`ijr|M;PLMhm7fR^@*dU}9Ps|5_=s zTPcN3DVqT&x+6wKl%NNMw)`pnX6{E#?I&0WW<*JijSfO74g5OAucO@l- zNB_0q9OHuUM+9g>^ZvC@6R4^85z}RzrEcsU7A%R#3Ok{ERm$}4g~BA z8*D+peBG8_jWCJ~NdYhC#42ZG^;;c&ye@B*&Z^o*Q(z-ZuFy520`&GA8Sz=U&jpOc z}u5}$;jHE?1Kyb;Oi8{ zCMF98DpM9+Uth0ZBY%6?qQkdlHPwpcYY?ti&Emks@#*@ldiNP7f=Mp}rr~>#VacjY zXT`_sqNXL+$lb_4+S#o@;ZZy-66sgpPzOY4HtC$c9E+z<~*mNNYw( zS90#b9SqVf)EA$$nBXei3Q2(evXR16X~vZuqEj}U*X~Y4|E;X4<}uayeqxnRdKsLr zTVCImb`fAgs&w8vnZG2apD0l$ppeqQUe2c$BWtS03qW^nb7l#ylMV|D>YAi>^VovB zeeyzy_){ZnwYr7lX*N2V7ap;|;Eq5-yRRmCNP2@1C$S>mNn{`my!EsXe>MDiC99L) z4#Hg+!P522fSS{?)D@b};tpiwFyUnM0okUBg~)?83_hVETQ%N)8S@Ys_-iy!or_9} z`|5S)p{X>@kCqM&4%J_7#crFMenEoHnsm`;H7DUm143iV7yp&0v#$by*|TG|J=M;G z)zlJlt&1=8Hr!>BbxdYHzAOTIynvh5GWODwRKE5@`_uK_sF)rV3m<6gEly}&w8d{g zKaX3xuD)o%h8<;^(N_+yV42Uc)78-lJ4q`SjI~G`9Cjbu^E^?BKk=df5*d+dWL`0b zLhCuX&!3|!K!lWty=sUC>*`Y*t;?32_nMNQ=4vW|V8GG;!sUuSw0rYcmZhTkrRS$p ztR-r^4F}*=r{Of4~>_-W<5LBdO2t;>vc@f!m%q(SJ-zikkWzTdg;^F9<2; z^1}(kaTJYfr$;u*y=pY`M&%$4#V?{FcDKJeJKOklLHwuph{S1zkZ` z6Yuw}x_4~0zi?53rs-j^+3o2r`z&m(!vlbiHl9}U9$Z;-+Wj5aa^5ic;cju%QxBsY zf7PT7{VRy@Y|I?^lC8s!wW2+#krK^bKK@@)aldk{PPtzqZ4R$0Tk0F&AB_iOthYfp zd%2-NJ}`k>pSKwFxVb(xKyG|Gk!gX(!bvoKZC0DTflE?O(^|xcTyEqjwb(L3wa(M3 zol@i{GmwG!%W0;Yksf3oVpWgOL87XLL6|i4+n)#qt8YV!P#&*`Bg$S9yjkXIV4_+# z*ess4Mju6mEoD-e-CbTQsAIgA0T{ryR|0rlq|XFio{(T365r@gj(Vt;qx`~QSp%)) zz!d1Nx)61(tLZRNn#~&%_>W%c6IkK8uzQhr0Zp(D2YpVK@#}untx4O=P7bTRqBBZVv z&ElETHyEe;5>asIoe*esJWx(?Bo=hqNWme{tE&to=m3GP0z?E@msxFUgS+*2J<5nJ zpUh$)s@koMlqmkzuDEN2eaLI5s?aNgaPJu9V!ekqltVP>$U{ zM+f_4_TT3B%GUMUrH^jGiN6ACMe51OlZMY>?f$sJV8 zAHvgPyu1E!(>-55jl4TiILggSi1Jfh=!}27-J^IpeA#-Hy-_)Z{Z8|VczaKTf*gOY4{%sIuK5`fM;jaFK``+P8FnsquK5#kv0-3l<9kCCD=q zpZW%QdNMCj(nkTed@4gyOeswtyw2t$A^1qTpD%9yXN44hwWYi+Tf5Gh*bt*=<2Kz2 zzD>Krlt0mH5N0q?azsH(5JVqc6RTLd5DscE76Sna3A`u;J(7aH_WRu_3mbV-#fYuf z74t^FMh;DFTHJ=~G}}3m0xyX>O&BzizYq45$5l8s6*!_Q#74x6-}H?~Rj_{|0VFTQ z4%qhvOegHJHF!Nh(lrDC$N$`h7$6e-#q@vGD%7*tJL9grD;&>Md3QOdB*f8(2`@Hg zC@E_y+bt{h6L~mURK%Mf#C_Z->fZiK`*glR|H5~=Bq>ID z^#FzD-kj+r|k~j$=z##5k9-=ryY=w9kFmI!{lZTwRfnVpB2T8S#ze#^t{WBR8n0w~m zJgExHNPOA5%%RE%yc8yV*Z{>I`=C%W4_=q$IFLe@g`A@>>;FAe-*!C5Y+Jl!Xepk> zRv)w}G#2h`B&b^A=z<(^Oj#G6<~x}Fiq~uZPIlkK@8`XEP`+FQ1t!3ntGjhpRBOA^ z7-bc*e9-)3T;a%=WwX;bXV10$hSaf9=GO-oX>d+4S4Lkf19PK)b_eg=K*LeflFE`t z;E7h=^)k{08=y>P_AUca3Nz`CcVUn1ZinZv5s5k%X20hL_(l3Z zIx4&`!@qvA{D#6_H2w1!+D&#q{K?S~>Us|hd!YaywoG0%+}vBMC5b>j$CUF0 zz&a7}l0O@Xct%a(C2bBsLJ?a)!ad_7KNL&Mb>%INy=l_dT;T*XFIyuBr&0xAALVEna_wz#?7Y#c@4NgFTg0nSkdYs1(pM z=^KANy<>ut=TrjHAe||{tLGbKbuBC*tI0u-YDDI0T6w9VR{op)ORX~iio0zKQg4r{ z>!|0-%V~Ds_$V3tnkK2Df*r8v$N)*pbB;L@A(r=p5#gLK@k@Ab0t#Zl}+TL?0|*E_}_L?R6IL>FqLxWxpWtyY;ZaYT%>r zsR0IkEhf3-T{W8uk+TVMTbT zNqntkXe6CTPG~bi0W6K@wXg#t;T>F-SXy5+5qeSn3>27Rg-IuSd}7n50Ji{A`};5Pzn7}K67ZQ zIin_RE}TxUw6KyRvMnmprJ=+_p&N{9G$l)-faSu@=;i@L(2*llSbq zz3zB(02BZa%mVcuhn<%$%o$7>Kx!x*2vdojU6mhh0z*UNMD1>4%uOUjwcw|O*C295 zM~wA0#_(kYhjy8Yntbid4F!Dg)2s~OOgKqK+Wf7O@3AmyXHtz07KIWlCFuVZ(4Wd5 zipXpqwcR0TCA}QJnY0(hmE|>m_9p!e!+w!ck$;pc+GE^n4F=zu0UeVu(yx8C%@EW0Ig69lR4m;`T9!T;kLz zsjc6vC9{KTKjJF;<_34KReH3JFn7sYOfe-IPyx58esWI7%r!Co8YllSXI!i8if8RZ zSH7W~O6Q)^L64qT|(-0zNtvR>hw46-Te^S74!)0U*-AeZ*fWgDKa^Z(p z?%NPadP`mPe7Ety^mtM1DjGfLT2*w>bQ$Hj?G0YY{#Qz? z8RW%yUELnep&awhKFs(}vQY)@?MoPR#{t6WJLOh~olFrL8qacupUl7gbhB}+6-cyT zP8`Jm?Ot4d?&{?F>cD`+Vp7^}+QAFXh!?FB5&S&mThcB>3*^QF9NU&1a-ahjk+{Yr1`%z%mzM2VYGP;>3v8s>=C`7g2%v<~-@>`&;S{RiaSc-8Dy$RFCS zsy*l<6P(a<5Xcz`6hSZm1)cen0Oe|@LDj`zF*?kbw&3e|k$1ny+iMttfJ54g5RglBDK9^|y}QfXhjZyr{y`(*Leg_h$70kDh}D3%mL=qC>dPJ3R0 z%p1C8z#F)CbO2Qiv#@}As{oEBbkGMArv};$N%Kzs}T3?YmXt zN;hYHn0w>vpq>CRKwai9cDF1fK!z^_aV}l>p=;EdE4E-P%%dWdn3TbRUEPCg8iC>J zAhVbxADl@19FW57tC!@Bq?-lWJQV3obDF8W@;ifsUu(OcuXEd5xXWC0)g7q&y)WfE zi-CIub_E?~)5w4m^s*uBx(S94SivEz z>6KPz$LB;xD{|Tnz;XxW=k=l?R9#!PR*@Z^Vrv%-Lg@g_4t#$PE&%jWIQ=BRL_v=@ z#pp-9cZPYM*scfSa#EPl7zs=&>CN$}r#II**gn!>Nn)tc5s$|JXchKdZVsG9jMDN#$9}4yd;N&s)=1k zE#m$&nPaQj%N}N7SN=4TO$_3<$C>Xe9sMDuqFpDz>>d9N^!LQ9ztr`w}V&^MPf-oiVOd4_rB&Dy`R(bBXJ*Xc8Yr5-2RKnsK}}f zY&LYF4Oe8xJ+Qq6pltVAzO1hzKEaE7xm+uba8oyLs__IBJ>NTw9=^9q>p3)RAHRJ! zS(9u=bz(32%`g(8)J@7qmw~VMQ0Kyzjw(Fa7}-VJ@~hjMU&d{_*<^`pUNz&H{tfT7 zLvQytX`gn?^qdlK>H`7(P*W+J%io34#%l_Io`F3~w`OnOA;;Fu;sePGwK6Y{eJ$G7 zHQc-q&rpuXE3?lzZ4cXwggIb;c03v;esjn!Y~1;RRIb;<`9F3H5EmZFGgp^a9-Ego zB@`4Ob9j2eZuL(!=a9f4A;UHUgs znHbCAMr?Qm;RY+L(zR$FpvQy~0rDCvWU4N?mvLFh7U|1s($tj8qdK_fL&QWtj~64B z{JV0T2l_JXc(MDA)@_jW%Srdm(vaZpEtUU=pri$tUb63(K|aD_*Tt^LtwHJAKN(M7Nm4X&6KBisbTrqqEd*-&(--Ot&~Cul6fEcfkCVfQD==w;&#T*#$$9mmLfgH)U*-Qeo;ur*d_p5f)q?^fo_WxyS9GAyib|^@LOG;$a+nHg10}I!1y0?+wVLZ-o?fhe5uU=27sc+yyC#lMy^~?bY^6%jvZH~ zfva6(1L0FMbx0y4AxOckrZR&MFMb!{BQZZW-0nd?XYZTsMF%JpPG<#XRj!^6n=2ot zmhQs~`V@sA30Q^W-L*uS7H5EohmI6NZq0#{2*+O9YsH`)sw40ZTH9d1vT%zC=y z5ruT8=QR`@#v$j)*i=gQKxCZP$?B9{ZotXK3@1A1w)HPCNsfnhQlGdePF-6l+x_V% zZO#%-njRwDn|qe|t3ATL?eCMbBVWJiEPV@H7Qp!*itW!K6553q2-Zf=U6`$wIIxfRyrjd%CabT#Hp)YeTx| zK<)xW5fFlm@N}Cwfohj8oYSvAJltW(YBkk~qs;@3bS^dY-kjPDKy7Fo>7bwJqSH9x z*xh7W=_EkZSe;wVPAQXRr=wG{&yDWMK$DvlzbaI>q!jjW?#%31TPLt74F-+ji{`v( z*=pnMVx$dx!Z7OhEG(jBv0D-P!`^*1G+~*|goBj7k#U(_?(o7>+OHhqnTxA=9R>Ql zF43_s#uELWRV(gYF#6~}hBj%l6aRNg#PDCHjT2wUTxnBU- z?Z>bU@dWw$xtDa93&eNVg;xt6o7XIhBK%~wFCwjuAN7}dt7?VwW@1d*1@G>YEto9; zUF-EeD}TUjrx)+v>hlo4rOTSF`k*=4tTTELG!a;9KgLHu%CwCNl?$MW!jxse6hg(Y zPT2;tvOK5i25vl-C}b ze|yjO;<$P7Zfp*)|Jt2@0Rwp+tW@R$wNbbPVCiy{7QfoMaAF~TD%mwZ`?|JC@RX!~wsNOHj(np1uD<@qsWWm>C9SOX^1UoBGgn^khVxO*5lh_s z0Ra=m5pe*JIZ0erW@e(n&G$;Fr3U+JP6D82g~%jkEoZ%}bQ7|9vGr0e`RT$e$l9** z`d^Q7&95ZlL3~70buJ*_{Q;MlbdG;_xq+9>Y}72@zbP!Ro#8D@r_?zv@|M(*7eSxV zCz^ocTHjXY=7*Yw#pN7q^B3hoQ-1lafX!j6OTJZRe#4Gl5@mDiM8 z*i)J7_*zTJ6eI87@Sq>v>)(y}l+uRZ#!nVc=DWIkq*#R(>z`?adRUb?-;@CY& zEnhD<|3Mv=oJGAu58lD0-NVNkZFdi~VoMzsL^QTlFoHTJvYkSw?~+2-x*|IWp9VVb zRV`nC9`W(&i3%X>THaS>;wJg$aSx{hgnU5^&(P7LA$^-=Y06d>p_}P63~E|{0Q`0* z&*hSkBHPc?jcK3qG7?}Moqd_EEWdgQVpWU$bO)T74-116z^P<1fPK&op(_U-6E!v+ zbYZl0{71XCtL>>L3tjMuSNo!)UtY*zf^sd2_6MID*8K!9twYvVq70p#%Fi@HuhCAC zqj~KU-e0~bqQfxolsdK5V|(xax2lZs{Q`)a0^ipWYqR|P(JOzIe*vi$YN;dF@zCc8Hvrr<8Wkbz}A(XU(^BU!AXQ8rD|@q>O@2b`2>swgy2Cw(Oo z4*ICB6fS7&06(ti-@Wl9nl}`7+RzhNSd+m~KmSlWWsuQl1;xW$DCiJ?qcWCQEW%D_ z%x+hElPWmcLgd9qYk(c#bVILx#H1pLBNm5hRp|R0RZ_*Itjhp{2s<%X3lW;tfXki? z&cpAxrb*lBms-&da6rZ)wBDiWE3_^R29gb~wa0GIqoojdgVivVXH*|KlAi&lPfLTk zH4~OW1mnRgt5bia0L++KgC-~O2txo0Yu#!SrP#E~bV^Srm3ufmdfaTi zTC8f!d6h z<^`|H7WjwM`7f;zDsgzQOj5(>Y<%z)jLb75yDT}`%>vIGGIEvveAh1BxCGE6_1tP$82`Cc#W+!0DOLSALE2W1Ga?=0A%ycILtA zI}DpKy+DjcLl;`uZIByZglMSq4MtK9bsy(2HB>HDI+AZ z(L&l^=*qkKLD{F>apljh@RPvsgWGOX=0ZQMq{z`(Gr!J9NT8UdfL;}QGm<*H{kOOz z1utGX@*qPQA9x=T&qY3-Yna~AKFv{#WJ?H~5Q9LR?VkAvtzJzma0H9{q`q|X`2H6* zHG^qBl+e&@q@7#iF!I26SiM;*H5>~MBOin~c)`?v9WnuN0_2tFL~?v79Xa?l7I4{X z+4Ne=sy0vS*Do(AVM>sO!5-m)*d|xgrj}+Q$qgy>bS4YzYkw|$I9 zt#(18wOIoJ;uEDLfz6PDEaE`E26Z%8 z*crz@_?w-#ijx8a%1^KYp;@w$yXR)O6k1m0j(ma0fJEHMApU?rMrZ0{3JW;mLlIHl za}ldlIu8JMx6i0J%oVF!{*NCJD9YD{c`3I$KA;jT~jD*TB+y8gLqq@VM-$7b7tV*NPx3Xq?D0CgwndABWPFJGc+QZMwRqh6x;;ula6X+o z?QKU?*cB?T)uWWV%&TMvHoe^v8oIZ=>nybHGyQEYxMxf)N#Q)uIFS(}am`gp$io?+ z5JXWISw?e138s>KF>i;SGbeIBg{WW9iW~}g-D^A|{Oq42{;7T`vC6i|4^~>y|JS(E zn%mNt<2B}s*99cN0hw1(kTSuSg>9e%r!My|lC#bifN`rc*ja?;gAQuv#CI7d`hC^p z82;Jp?oT>*0$Syi>ko|{Hm%-=tN$uPwpUVn^#WV}j^>5^n%kdrR8t}@Dem;k64YQKx6{MW=Fo_1~5 zzZHR5TrGsi#{V?}7@93xvCLqHdqzAAdP&3qC8me$A?oF5D+d9{++e0Q6}#GyeOfn> zsemZY;VY(6gK-N2RJ8N2hpib{UVSm-_%)b^F!;wM1W=2gO7ThFHW*M)XT}phf z0DbMir`G_{=3K*hw3KK6^=?*yCUCvgFDk^#;L0xS4!{b`LN5l=hUKe* zL&CPd#K0tpF%PelpfLWIGrXUeK7*tpJ96fm5l!fz-!6Y@^!}3%mL*%UpWTA=!hbF9 zZPk%)TA{T!k8cZw6`;6H1Akc?nLRMKUzJX!#nj_WgXqPAZm$(2Kaj;x2>R{9ZLNg5 zxwn0^VjZ508HmAwQD|mN#NcNS5|x-B|JxGI4P0?G1vUj42-0lqut>SJt%Zv=3KEBo zOYdC!Kjl`aYuG4I3u?ot!w=J9m9@H%-z#-{RMh>W+id3MYxqmLvIP2?2M@{RqE?TO zvC~vUshGFkpJ?IE`V#z1+_iqz(Qhx|(6os({yNxn=Tb`{MHJ9p11)%9^iy`}l+VBm z+Y*yN={cjvCmS41b8*+j4a;PgR$qylMnug)2s7gyJmz!Z>7KD$<9x6ezmpEaPphXk z?H$XurVh&CXCFlc#SN*>((I6R4VhZ|A)2${_O9*~W!Rmu{czsTEWvkc#XzLd63^2@DM$^4Q5(ROH|l!R5-=0LCRPO*%pGL_jzrM%WLM4z z8(tKt90(+yBBg2FII&MyG5TR$VixD6RpR;GX4lMuX$}L99Fe;j2@&I)36DbSN_sy` zG#qq4>JM5Ie65CgW1n({^>EN}vBVPZAIEya>A?N38s16R2QXMHopGAd^ak<|PpD1H zW8b>9IQ7Q0m@bW0pZ_SN zkzP)T_lq?iI|YWpO}r@f!ZIDEgdkZgraWOB!_zuRQar=cP>tI;HjwC7Y*QyH-&h84 zoSpQvpl?fh(}KK(q_L)ErTikMN&4)9^3-*Il+vT83;|3^u2W)bC2#>2Tz}3znvY*3 zlaOmCKY3j3JBFdLFKZ^-#5Wct6%|=V{l@joS;fE}pR*fSRUmP9pVo7;qd?ne=i=kx z;&bxbtIlQeVBkh+V5^`{jTC*~8>=4`+|Ictc=+dIgvIBrIgI6ujxzMw1`qo0Jx2_{6Qpd(iJM&~nLQ$(Zpw=Rlppd{(4z#iEMCzjJ) z7EZLXwPWb96mLxZ_&Gi)70R8tOK4yvL||uW%pzrmIz$HntNu@bz}9EyhXnyzF<5vhknA1kbWVU*!=bdg zJpbMoY7L~QZ9x&$Xds#-Fg?|X)8_09b0Mr~e>#F0+0e3h7ay>$`dTUOg{YARf8Ikzt9;X7MDb1!7!9gIo97 z%j}Hj_8+gAIjK#IQV8DQsD3JRif2w7es%TFvoii`!Wkq|UWLSjPdp{48AbVJmx^TU zWGS0-(vC!PzP}Yv9JSPVqp2UFMtnqpWS4~L(8IT1rtI?CTL81htkH%kGZY?p93eVA zO^%Hb;3RrczjWX4L}3KO0gMEmR8~meeElU98^IBA@9H3~OoFU~hzx0QW#CR022K%RgDbxs$1o(Iwye@ZiGvlwjX74X~qK1F%uO%%~;9vtOaX#2l z5y1iCnZxOT0ZLbo+_t!|@N=umy5w1@nU3|VtRvM+-!@+#(?Y6VitRNqnkh0**w%ltoE+a!*%-HW%Lr6>Ze3QAc6R=Lb%s;!pn?aVgnVmV#zn&dxk?*D#ZhT*SV_=b&8NdyE2VU(W7*O_w*yla!K4HxSD*T`(`RgP zS`y-6otRv?YHQD$7aa>>09$i3+v>@s=MO*YYiZ&tUw8o%Ku&6r0*~{R`9ZAG=aC!0l>-9|8OC$4@F-sYZgAXzW`%_bE{(eISY-Jie9uEah78t$2f@=)KzB?In z`-r-|F6^akc{0~nI>z3rY`eUT8IS#ma40XwO=hb4Oj2n|%T85kLlf+_)PQh!J8&?R z^m0Z2b-NSL9p170z4w4?^aeAR7u)*l5u`F=uHs#~_O-|4KWcH-d1^6YBS zSBoF>@b{s|cDV+-?W*W>y~GE@bs<3ldU`SG_xaU=>$7&bO>UtQK9`-x+V70}93rKf zsL#{!$uJ{EO}?g(F-qhe?os*)v6pK5ihMBkaT7>acoln8z?77vKuK8!Mh6&Bz%WgL zS5`(Qeg?NSZWad0#*YVJ_|$0ac9~Y`HU^rL6Pk>`GS8Ydyy>6I_FPbiD*Yd9pnj>f zX)*-HhM702buP~czMouUHVb-K7=8>e*f>SeLzT~)K6kAgQV$rg&#G>Y#(uaOPDYMn zei|O2YgPl;C|3?0=<*mJ>L-UZSo2v>iI-O?rJ%yJ_oN5=n&&+HC2OT0A@b+MJi$_W z0=}Ij^edd`Ojev*Ud?CP1c^+fYAXDLNgvJaJL>|6t*4l5i={9bb7Td0W-D`k`lL=F zKxA$(&pu1!FYxq1Pv=*dd*_zdZ@eh(%R7@0jI|rqPZOD@u!w&po?8;zp_~22bEIST z-iM=4d0{Q?uU=|w-{?Wqw9@D+hdH#PTT~kj@N661=Avgaf6O98ZFIXhE;{sN1V8yL zwq8#R9^oh0R5g-k+zV)?1O@g^`wC0|L2=qbRPK$=ArEU^g~==9{~;`X{XB+~{0GES zuzbZ0hcGBDEINm+uqhDGE_fvEJ95Mn{J=hvR5ZUwaW$AZ556b|LvqVg)qr$O3>qp%MSo6Y0E#~U;`ds>rhJ$)=vWD4xHE+|vZ>!=~4 zcbnUFhJfT(6bY7;IwyHD5aYOxyZ~>Bry$Ik&OR?DSI8%BBNJp{i=uN?+BkRrttTW zQ4vW{KKo86_I<85{4VTivqaBz=%%tnUxzWL{4{Qrn4C9uc^xUdpWZY-HoTbZMIj${KUx{G zx0f{MqT4(i2XS<*FxvbroNgiPdlP8@FpFo-tZBpbpT8;Y`_vSY7{H2vbPyZ4)*6!C z$@A>dJusFS)GEG1n0ngSm0PX)M@_?i;RKopDldf^n0G1gm-IGY4Io{Qn}VpuUXd(yXO_7 z_!!JQUyFsMzuChrp>Y=WiA679wg&Ug4X(OB@bNX4wqLvvA+8@JKW@fdO2L!{KkG>W zYG@)Cy>0Wu%sm6;fkq(XH_rOV`+Z(Qb_CSYlIO$aIn?UQs1H0hks{q-cP1F zH)=;x49O`(GJipOMmAG?F1EVWbC{Jg7P^#|gsSU)=3EhHP60pV>Oy;!nFvr<& zFl+0q=}+(I@0Zv>VVM}VheMw}$?1wvzRbV5IbS}u_OS^se&Z$H10}-r6+WncF8#S; zug8uMs}s`>${-)C=@k$kzo6$gDVqIe9f~O9O0h8C;lRcr0N*Qi$KA z81cRAMOj(zT7N-HNwX#NU*C3j*u%-*jO&O8)yzEAh^@FxobpZ<&Dn?jO)UbhhU+lVgO7YzYdttzP2wW>zZ=VL83$;t7-H}jhEG%^MlZW@0?@-L$SB#Lo#klW6v@0!THAO_a_=* zK9~2O1AdgAcXo_n4s!kP`_L0@^WU_5@6Mf{8)`vVzNffa@E5wz1>bq`+nc$dzovaBmK)SvIcB8rRca8jhOUN+YdBo_1q55Ml^ zj>*!fnzMEmAbQ?(;47`eUxIj<@XwIaC6sna@Jrl?vW*!FQ>^4l_U0%B=1ah@PM>H) z+i)ah;B8Bf-`7v2KPa4=e+0raDT#mo?D-0dU~p>x~r>pt-bcz>ER2T zQTPm;L%&ghl&3+q(MRm+OR;!vP2@0d$6szv+b8F!>csKcz1tPMIL;I+Sul~l4xN6Y zm3CrsvOFmeJXe5=t%`%KijQ7k2dp>|1tjTtr0G^n=>?dvAbNdE63`Pay__1ve(^8` zqj?^kn*1h7`XKuU)GAO3n4k9J|HWqpYwB+b99ifXL zLLSiCxM5$YC_6)zdTR5SEGAcjhQtE9|9J9n|8{Mkle>@;@F=;btr*?GU_y12UTDb2 zre4p2sDU)C<|_~Vl;o%ZCMmslf;L%dmc#lgR)>YO{ma1ti_(P&lhay3zsl8PT{*XJ z=CKk2_QMS^oJl0ER^b(kneWK^HGHz#tI)gfHmwVMleKlY)8dAQ1tw21X-@e-Roggq zDF>}*8o_AB@?v747#B{)o1YcHWzd(?TF*n@TJ53@<6g-8df;SxdWmkY=8xJyIh*rdH96ES1sb zB=SDG3H|-)QdRZ3w6sRL(&)0H^!iC=Q3W$_x03Pa@-TCf#I(7#yPH&YlPJsT>xlC@ z@d_UPjDJ2w@oS~^-1JnSCnN0HD-+pfz?)mvEb$pjCY z8u13-tkd@rdn_T4c%t~}MRKPLs->)ew8UPoEQS;ZQpj`~HN36&v%a2|%H4C;&R8IN zG~mTJl@R~h5{N`j4Zc!G_Lhe$sz5Nd`Lmo^NbwV<^L8@O@Sk3pKS%|FGs>}Q;^NB|z^xJU)`mF)gL6)w6FFm<(Y_ijyy1?FR`$bz053*=SFcpoy!f~{vg#S;(q zscdXnvIf!$ZNYe`E*^pxsQ}i{U zy(xUz@t%!6qtAC^7f1jUE`&uKQi5gZd`7HmLZk?(v0i5~G{UL6vW)0@%*B|-!|fSr zTeSL2o?B=@8Pt>?n**9E=uNi@(%pHU!!)Q1#YyjL8^$@sMDwimNuWabHfe88A>^nP^g7Ve1Gww4$3~*bLj%0BvrZoJG5I#s z+Ty{l@>d8#eoD#X?g!inP<3x@$SCS>?nzYa*}ae^%qkx(%n_|C$oEAN>7-c2Qz1b2 z2+mlEYuyKPVHWnetZ^v=bbImQEyAy~<32kH%q9GyZ%A`uX~H}fD-D^o`G}p*V@N5p zRT;Q}lrjjJ;#$9BgR`9A3#3c%+Hs(NN=zrw?w+lHSqL)rN-1(A3i?cTpMls9%vOH}C@sbssXfhL6oJE2{g zR*@@5Xs}A^6H$5fpCb(Grq)nHp_gypPUM3&cq;Ma!bi&6KG7*~kjeM_otk*KxYANM zOm?kE40I;H4Dx}F3%~XZh|?+jkj(7-^`psWcm7;5%vJR}c%Tf51efv6%zgOsBbJTr ze0pf(=PR9&@4XxJ3aBGR2^GyeE$G@pHA#f6GbW$FyVTk?H=CvAAIwp$X`FI(+EUh% zN)YWZY}xVXX%kr7pg=GMXzLJv*biJ4gU=0JD5WF`e>56nUd2}}1Lhh`s<#XcP*>TCDmS5fz zW7QQHfoH^2)h91Gj9Qimr7@YKz3weI;5BnkxpFLt(xN!lWg-e*yMgufIR!=ea{7zX ziQmn|80g_u7?J(+L1ThXW$Ld+hRV!=#duDRTA_x%Co4ZX-y)259x{XYJFJ2T$5$cQka5vU1$0iBUP4LQ+Kpa_rX{V=J-;);Uko7 zN;H_i9JYE7mO5f|;Twu*INssrU*}nJ>xFswn>cw>j0>9mH$F&}7M*C&^P&CSzVJFV zX$;V^De+F}QwgxT4LQbWwMm^ItDY(u!~Ff$B!2_LL664YGdzvhL}Dfl1kyya-U9rfQTjDaeVMpPOWJ0ZmV$g5|N z*N68#x(_Iuik5R{7g6T7VMMYVVmXG{6RIwVX0NSyoP|ZB)ZJ#vtdVsxdZJamyt)p4 z9#-b)fVIuaF6)8KpJ|3&vs`j)N<_rM8)|x1vWK=+m)Ji?l&DoEV(jO){`^jBaH;1F zJ{k*+)b%Oyf}I08o}JD$M$HQoB6t%XZ&x-*(tR!iP#ZCH--aaNqpT4jem8v@#j#K_ zP=kTp)k4g&6ei?1R`D0|I3OW`?mNdlx5aH0dW{ASKBjuJ*UB_1_-oQ(fOJ`~Puv@wvGiJSnYcIfY9|2=Jy91FYj2ZQJxEgaE())^ewe z`g(f3Fp%iB56}^goEp^hwr(asCq_@9{xVP{l?Dt-gE-i37{# zHGlCI`jL1H-F1_2RnG>@yk30Bsod6XUDO)Gd+-2w@bq)YrBC>q-cW!-|28ef{U@ zQ-=yVkUm%I$$D;T(DA_>R$^XVsugBx=v<@u$Y6qdfSY)l$%%@AaR-&q?D5_N?z*8q zl|(agu&zo*gP9G`Kw22LP_n7*81KRTp51z!-RgR}j4lT*b1^Wq!p{@%BT%v$qto17 zIT}uD7|jtiX??4a2w)TQjwzc0aN{QNl+hA)45tQ-Ko11XWw}Ex+ zCnLjjoRm?0ny85`3cvu}83v!$@F)d4g!8HhB)#Zk$P?`=N;mmo zWyC-D8Bc@gErD_M$&fvMw*RJNyt&EENcizUSpiu@h@vhwp$bTriilY`NGqyN9=XdBj2aY4g(`2-;m{1b%gDzZ z7Bj|sm)qU@zyZe|8qTBdi6iX4{11Bf0#I`yn7M=065`tYVQq~{HM@~v_+D&cy$hB# z`kmUj*OhfJj=Evl64C~~KI3n|w(tw+c?5%PdOOKndF|pTrBLJX*gZwXuZxSc26i~3 zjJv6(f>}aFt^!(8 zKg&mUM(_2N&3t*US8+kSi+iM*=>zXxy(qL>_-*FDw;GC>ixksLtzdj15y(W)Fo}rh ztFeC6xezrPEU>+{ndCMn<*h%ywV9mM=M5xYF>UptNzV1mlF{2DX=<6fxmyk{*vn17 zI9R&<0}n=eS?iqC;$2vVC0` z09}2@q%oG)R6iaze634q`yP`E`|d31@^G$YE;E*ghv%|E!pE+7-n=bgg9PNY^d6Hj zX|7Y~ACest+;Ur{L^gN3M8Y~fqt=IRxkC3w7c*xcwoH zIp0MTv+G8N-sbiD#2;JC@ieyS)R-~^vpZ&@?Du4*{*6t=7i&{^JIz5C3y~7O$HVO( zpjhiDbn)6}nXGiC8*ru4k5?{z$M;+mnjou-Zb$r=(HUIp8Z4XQ$1#Xg1rjllpS~eoS9>^2e8^{rbae^}z zs%a6in-jPj(wUisgPd0*U;K%D?R-W*LY7G9>{MG}YKU0m;UXPK-jv>XMnErSL;*M< zG?QyuLI)tx;viR7bMt|-Xj(djJwy$p4kG@>5WDGqDj1bm@LIs}#5Z$w_ffANkeBj1 zv75z1yQR8#cwdhO-2oYk1de@xaD$yR$SOQwf79piy}@_hXj=Qy@X`7e-R$Nz+uhyQ zoxobdBf`O<8PFU}tftQ;g~`R+QAr0UAea8lOQSfy@QY5d$2`nP;WEYQK$Jv}J-82; zo&bkNy%W-6{0hKQ(;7I`;NN%5?1{}(Rbwy#W7<$JUs6*ha2Bk{I0$K@*6!NkxEN;C z&?)`&Y58Khpc%JfLG*Om*OJ8F%jICt*4`F#c`fiuDn`h&M}t>KZVhyHg9JI2$&#ao zR!R;3NN7Kk8ihElsF1A5Qwj_7QSgmzL=^*1+#(h|dtDDq$0ci?!ax$SA~fi{M*gzH zdBcf6du_HSq#bZGIO+^#FJ?$fQuST|FX7(gaZ5#;uZ}t~9LT$!YN<~|uMXxR-zM521QtL*RyaRGvTiRN>6(e4t z)z#PFudV7CvFxjtkTIoBEcssk5oo<;8se^K3A*@`Y^OiX{6b(_Sd|=czj7C;BhJyj z@71^%e~z3#6sZ%m(w{hMSQ3}uto3;eBIz9-Ug+PpWGIbQ**vMhi=eqUC<|UcOWZ7& ze{~N9{IHAc9cy`~Qy9MD9R9YZH|tu;+C!;Zv*lWx?a4U2)L|^Fw^{=Zr&|xiW6Faix!dNt~TBFt3$1kdMQOOk2Mno<@%YTAZCu##&$*c&_(6c1J>PV zgHZ-7$#<6z$V(H{l%f0aPqrcAn6U*dmZ z3x*05-i84{Y>X!>Yk+56?vO5qb>8Vjq98&Z0Hq(dVZEJ12Hl&)1pUS{bD z-@6^I`5qk0C#b26WQ$3N;twc*Rl&0x{|JuUQ|ERnGKvx3{FXv`fAIEs~|F=^6i zGhY_aw&NruqrPbqy?v9Bpxk%gbD3ka)Alfec*4Uq{)@NHpeS8&b)^;B;;%Dl+HPGF zfJEXVuM;?oqJG3-pR%FZ)qRv1FOBau!CmvL;#+-D(Ki6-uGWeBFN}iZy;8G^PO8M* zuE)A3V;~MLfpOx*FPOeDe5LrWAMp1&c@L#_e@Ya$ZHwed>CBeZm0!YZ=E9U2ba~C^JClMq1SnojZ1{0~1&a&v9v?n5 z94>Dk98!^tSi6hDuLH6{k=}ulLj@K!Q4szM2}&M&W%VCkLZDut+0+>PPAh}%B&IcGv?S#d7F!w&SWYFs7NibiB5>J$p`~+`+6Lb8h31#z z*U=~#c%S#;si>qLJk~=Ww9dt6InDe=_n|$9NjB@ zfTocMK>h2POc2d9Ertw0*>+&~s#|%veRh2k&>GcF&c;xs2tHc{Q8rBMyWQSm2nmzPn%P&J3c2E!x(%{x=rE% z$2O4tK#=I9Vzx0BJbNu%V}r`gUa zn%#**>Ts^5>L&MPC|RC|yCb(ZGNy>7+i6J;2D*vxhMSnMq=wTTjZz?evtdgjrt{>K zG~b7rEL3GMDa3xk>L4_4GGb)coEy3}ss;F_xX-{s)U1-=?)yN)%jI4AVq~vTK#&`Y z{emz~T2>aoP_YV^AUV?33K$-qBJA_RP3R>k)-rb-V)=#?M#Ek3K5_{@I^83npH{_C z(G2VB`L1K~qoWRkN{~0_1Ec04ZUV=5c6r`73G>(X6@5)mAUob=q0*&NptJSbb-{pX zrYuS7y%QI|VD?fg#K~f}D{YS31s%vSn78gRpX0OlGq0#R4^y-Oyub0}rk*+a`uC6x zLxFQDf={0~lyqy{8-+}#J7nz&yP zGL$B}IK3>?n9E&dmNZ*r_jgdzOG!q_$brAFChiOr11U}*Ec04J?uWM~ZtpE@j_PzSHyWh_Z zJyX!A!4gV9pq2wp2X=RP+Je@;Fxg?!6ovxU_Doe5Oeg4wVm~hUti|TOLgKDt{fvT3 zGKBGIHXUYO33sJ?-?N09cr#bo{zH#>yL5vAtQ!!b-fFRxRZOVrX0$GKxsm@D=;pd% z8D`6tcrUHq4c#a6$%c)SaUhPlvb2Biut{xSjf?F&#f{Gzu@QU2$k|i#i>s{DZebee zA%HO^CVXrm>MFn5B1A}}hmg>8I^5>0*u@H6BMrN_LR z9QO@{^I573kk?Nxsfg&AlL9MhlhCA?h$`zEQi_X>au;C&EkT>s<7edR;E_Zs@;*$_ zA*?;eG9O7ZlDyIyJHJhP?_gIcbgBL^99ea{bm7L}n^rMHH=%*Oz^kf+QR9`k$rtV7kJ@M^XZck&_b;d1k%W+>p} z9@yE2CnQzeW$`VS4JKKw-q*KpZ*N0vAPxr6bh&^-FOtB$X|B2q6aqr`g`{*<=IV0 z3z>N1Sd8nhGqlh7ud!v623c|h!E_gXv#+uhM9AwWaLI-5+G&huT!S{(ODLWXoUXqh z3>vD@tS9FT+=mLKWuwg)kN@cPA@?NsxT7%OIYpn%O?_cboB z3ZKB#eNWCk9B}D*SZ*}j)uMhKNZjA+Qd|T#kMoFf67^vUOvC08yR8?u`NUXiiFE7@ zfimD(^TN=_%f07UdjKIL;3LDwp*3w_iR(Hn*VTB| zZM&VnRMuzrIBCFg*QV{4I3a6mHZH2Epf_60ZCw;zzHg7Hh2HFi+nV3U_l-C%Yu&X6 zVwn1`p~+dX2yk*c>Ksf)wE(zHMXQjGA4PxFdb|@7Pipf+C#>04$j@hHybJa>C#sl4 z_*RYgzFctYx2?ATTkc#?N>Yt!oq{3;k3=M^-qTox%4CO!%yMueU7j`!%y zWaZ1Z8hsdiiIU*N*4H^nT~>wNrM3wxfunvC)f5ODT7`2EWc^5cSM%E<%)T?(BnNpt z^5Nl=>ojg}Ke9?dVA>*9nr0cW8n;j3+1c6g+`Rjzz6V|}$yFvsMm&Qrf*;hjmkj+| zOlGKp4xdJ@_07N{pGEv3$&K*Sl{dW8&GGNIuluMJ;0LDjnzPG+xA=CY*BpAc-AJbK zmlB6m8~D_|ap35^YQJ{^vk?YA49;q)67hk%`C02)dCrmx)yfSK^^-WyzhFKMl+Dkd zN4yacDS@7Qr1~16(|%9}hsC{Wy=Ms8Sk`jg|Q9&s07*{E%)3} z4z?rG)~*AriTdP6y0Dv7c42VkY?P5AFE}+)e3J*42 z^IvC+P{i~s3yXjy27WRdnL5lhSKaTSIv~#4giuEphrOK#B+5-Fo;SQpy8|X1%kjZL59?7jV`(VAEWJGA_q={<*>9{Y z^ewDtYR-ByD{Vu>nmN1HLyBxvn^)iY*OB(67kiE>R?ycM9mGD>H6y10Vw#e}{F%#3 zc>kSBc7Q~QX!hHEo!yoD{T+`BIW zFkZ~nF#*1HzftOf^HL=VeW@98ca)M%36rJ)aSUK2D$2d1#+mG8qh_Re^@D!5+u3S9N5BFK`JLu?0r{Yu z`D0X_4hqHFwI_#5+?aW4a}s&PE6XIRix%O6S_%sCTA|IQHLG%mj_OZI;YmSM4UX7iNti?zZRNi#qyflfL zp3D86@r@2zary%7^^pLtfR53AHisXY-3vtd*?scJXQSs4xoVWlNVj?TFVB1HLng_U z&>Q~4r8?IQvL4^uZ*ZjNvtN!oo7>{;)jOe>=M_g(@buN)X-l5OnNII5^nP$&MS6&8_-F9j$uiN_OZZ}J|VM}9NSD7hddTt!p zj*-2g3_gY|V`Iy|(w4_`WortC&jS=xNXc%uWw)}}C*l2bC1pv22*kz}V8Q6t1#gpm zP0yksNuRB8A9;&Q(Gsx#zyuSs!|#9JJ~6McJf)5ry}?&$dwZp7R$knptB!wlm2kTx zcaQsU*LM9Y;qhmGbte@_fpyY=uRBp?kJPhYd-ulY!ACcy$G(dc6mb(tZ%?BfRSjYn zo5?+NVE`dEK_qK$!HAa8-kL6%&egcC($4s)g8k00MTS*PwD!ceIaDm5UOw=Vj$fB4 z7JrI)Kk3mU{5=I3NiA9PQ)ezYXU3MGxJzIsCNLRXW!M?OFBos!27T9nCoiX}&KJTM z8sz){O)tz5ta87(PQY%x3UF2j!WbC{1`x)8lBcaxt;FToIIvBBL)Mv;1R4N{ZYIO+ zAd)=pekzgL&3jFZ;k;N5s%Y9kac?Wxf2wI$E}InGj)lkUUqwVddt2pU{`wiX`yI{& zBY^X|N@Mg@14M-2eRlw1G zD7xeiN#VjS;2HlI>84|jIDH+pwBesOo66eEd8|IM!7<@En?jEJK^Gh1w$v z(v3yt9}q4Qq%U~Q_^37)cx#v`tLpo@Dz=H)+)LcT!CM`&D zb`ckH7=Q#8?Q4#+oT=5ymh%fpldmr-q*xU0Q9Gp*_G==ssWrqknql87W~wtI%0Lu8 zzn^jypr}4*TI#c?tm}Vtm1u0by=6~IvQ|HRoz`#5oyLdv8%KIYp(0}uRxu_VBT+s5GZNscvdI7$pw}Or?+QVge$`AELlzWX~)}^Jv@m7u7HC4#ghmX9-Pm zMP^LYg7!TH9YaI!c&e1m&k1G!Ov~MXaH(b+U2*!Vh_NKbmQzPrG&<%DHR&@&>yP_$ zDM}k(N#e-}8dZ5;ZUTGptFb2-GZhZruq>*zd#+yXa=%GG3^=X2yQw!o4{~M~7RP@_-VlaI;Nu35-wnN~pPJUv zL2pTAH&%quFY0B!QXx=u;zMyf95s#djhIg3qG9qr`j|b}URKfxVO0o`_cXt$QKJNm z+}!AIR3OJXOck6}4;Jg~yqTV70gg)fNHPx5N|(cwi+Z^b9&nU?L(;MT+_xM=UCR!t zLUg-wd|)=vG&c{lnZkl+ZR!z&jPyxo8*}sXTU;l4`ED+q%S+2yeqqE` zb(uRjl^^aFIXPXpEPVv@Q;p?u4-Lwb2;9>TBRuy3N2b%_!c{TCgzzl!)euGOe5w<+_iEK{Jtn=t@WFxLUd8_x(FfwAXa;*_=8sGzjArO$N>%_S1L!_at%vep7n^X zgVNomN|wV+y;r%NL0f%p?zBULMf~_4KM>gjgv)_H^uqzxN$dH!JjF{gNxXAU^PHKI zQoHouXeRZgVn=5ndB81mBkR(mfetdlKbe`$-52-8eJ>Y+?c0zOqz#F|PhX(v8fxkrQ&^B# zze0ZH>P7EWjVv#(Yc0-=*b<86l3W@5Xy#`h;4Gd5r>QpRk0-vtf-kiNIf+QFlnk0_ zhkG+{bLD(h|Jlv0=pS>L(0Vg`-(qh3>_mc9Mu( zWbf?NB;3UB328(R3;AqAIkcYpLBWh9q5!}VXCqTx+{kN!jA27|4+O!gY;exl+}z{W zruQneX({wQJGGv*0+b7F<&}?NP%<#x-R5vpM~7?SP|&C4db2Ty14MAV{Jk9ywlsL1 z>TZ1sx8e*tuG?dz2V;Hn_yIe&2rnDGzIfcOkH0f zmgsna2RD69^+X=B$kM^cc`U@QqbD0-{>%HIOXBX7s?D1wor52kWCSL4i%#_jWV6v2JXqSgX0vK>ME!(KA;MF6m zr+{PY(W9L2<|OX!QJGBFzYaJY?)v-kk=IB4>mGFWBoTf4_D;n&Xjx{(CH(y2VtiuK zW_k7X+$2XqAusQf`q|g-*MhG*B<}XQZuP#ED4K4i`>B72xy0AT;c5U0jjvRDRIsinVu*0ig#5b zaK;Mn%Z!JYy1J8Ly19tXLn32=KgTkv-vdlum$l?PHl8VbTUAvB2*{Fq8TuQ7#4ld7 zTacS`Ti{evv9eTQ=uz4GpML zoCi+@3g8X$a0_C#^AT0gqVfs29u|LSb0Ge}TU)B!E>(3AaMd#6m zarRCni>KUeM_`T5c3yR#6?zN0!)hdF78xRASW!ZAfY!6Yv65 z&=I70kD7Fm3{_i@FfrOE7BxB;2Fi;(bcBb5B=(T$gonK&LuUJ)&?$6siUL)c2K{ee zL+J+MkO&b&33AFIrsxnn0$J2;k4a4UP?Q^YGGDMV_*M70oQQ<{)G^D`7sN&?)PH7C)5*lakKa!P54m z$KLvJ3(U)rNH)rxL~;caZC4xU+b#4n{BN@!eLi7|7OnJl@C=Uw9sc>w%+E*`5zj@8 zzO)a>eW1yZ0;k-nh9rpndoKOcCrgVW!W8^|8v^)(w%x$N74J8IkLGVlkpZt4s8GBE z1h1EWHjVFucwqxagCZsW`S)I$Dg50DL|oK!8bbZPFvv#po;)al8|wiDkD|FpAV3rT z(e2An>u>+oHB_blE<*t7`w*}kw^UoUjjC{G!#{C-*}y7c#3rTyP1!~e=>?I-{9>i+w^>(p6%)a~| Date: Wed, 28 Oct 2026 16:35:09 +0100 Subject: [PATCH 17/18] Tests: compare flat coordinate lists (pytest < 9 rejects nested approx) Co-Authored-By: Claude --- tests/test_svg_import.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/tests/test_svg_import.py b/tests/test_svg_import.py index 867bfb1..0a4f6a8 100644 --- a/tests/test_svg_import.py +++ b/tests/test_svg_import.py @@ -66,28 +66,33 @@ def test_arc_circle_area(tmp_path): assert Polygon(subs[0][0]).area == pytest.approx(math.pi * 100, rel=0.03) +def _flat(pts): + """Punktliste → flache Zahlenliste (pytest.approx unterstützt vor 9.x keine verschachtelten Tupel).""" + return [v for p in pts for v in p] + + def test_smooth_curves_s_and_t(): s = _parse_svg_path_d("M0 0 C0 20 20 20 20 0 S40 -20 40 0") c = _parse_svg_path_d("M0 0 C0 20 20 20 20 0 C20 -20 40 -20 40 0") - assert s[0][0] == pytest.approx(c[0][0]) + assert _flat(s[0][0]) == pytest.approx(_flat(c[0][0])) t = _parse_svg_path_d("M0 0 Q10 20 20 0 T40 0 t20 0") q = _parse_svg_path_d("M0 0 Q10 20 20 0 Q30 -20 40 0 Q50 20 60 0") - assert t[0][0] == pytest.approx(q[0][0]) + assert _flat(t[0][0]) == pytest.approx(_flat(q[0][0])) # S ohne vorheriges C: erster Kontrollpunkt = aktueller Punkt s2 = _parse_svg_path_d("M0 0 S10 10 20 0") c2 = _parse_svg_path_d("M0 0 C0 0 10 10 20 0") - assert s2[0][0] == pytest.approx(c2[0][0]) + assert _flat(s2[0][0]) == pytest.approx(_flat(c2[0][0])) def test_path_number_syntax_and_unknown_command(): (pts, closed), = _parse_svg_path_d("M1.5.5L-1-2 3e1,0z") - assert closed and pts == pytest.approx([(1.5, 0.5), (-1, -2), (30, 0)]) + assert closed and _flat(pts) == pytest.approx([1.5, 0.5, -1, -2, 30, 0]) # implizit wiederholte Koordinaten bei allen Befehlen (pts, _), = _parse_svg_path_d("m0 0 10 0 0 10 h5 5 v5 5") - assert pts == pytest.approx([(0, 0), (10, 0), (10, 10), (15, 10), (20, 10), (20, 15), (20, 20)]) + assert _flat(pts) == pytest.approx(_flat([(0, 0), (10, 0), (10, 10), (15, 10), (20, 10), (20, 15), (20, 20)])) # unbekannter Befehl beendet den Pfad statt den Token-Strom zu verschieben (pts, _), = _parse_svg_path_d("M0 0 L10 0 X 99 99 L5 5") - assert pts == pytest.approx([(0, 0), (10, 0)]) + assert _flat(pts) == pytest.approx([0, 0, 10, 0]) def test_fill_none_with_stroke_run_and_satin(tmp_path): From fba92db09808cb7d8e65fedb5d276ef87a1c82c9 Mon Sep 17 00:00:00 2001 From: lbaumberger Date: Wed, 28 Oct 2026 16:39:00 +0100 Subject: [PATCH 18/18] Release v0.3.0; README downloads always point at the latest release Co-Authored-By: Claude --- CHANGELOG.md | 2 +- README.de.md | 8 ++++---- README.es.md | 8 ++++---- README.fr.md | 8 ++++---- README.md | 8 ++++---- openstitch/__init__.py | 2 +- pyproject.toml | 2 +- 7 files changed, 19 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b705f43..5e741d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased (v0.3.0) +## v0.3.0 (2026-10-28) ### New - **OpenStitch Micro: an embroidery font for small lettering from 2.5 mm.** Single-stroke glyphs are stitched as a triple run below 5.5 mm and as narrow satin columns above, the way commercial small-text fonts work. Outline fonts converted from TrueType fill in below about 4.5 mm. It covers A–Z, a–z, 0–9, punctuation and German/French/Spanish accents. Pick it in the text dialog; it is listed first. diff --git a/README.de.md b/README.de.md index 22fc9f0..e0e60b9 100644 --- a/README.de.md +++ b/README.de.md @@ -74,10 +74,10 @@ ## 🚀 Quickstart & Installation ### Option A: 1-Klick Direkt-Download (ohne Python-Installation!) -Lade dir auf der **[GitHub Release-Seite (v0.2.0)](https://github.com/lb2002/openstitch/releases/tag/v0.2.0)** direkt die fertige Anwendung für dein System herunter: -* 🪟 **Windows (64-Bit Einzeldatei):** **[`OpenStitch-Studio-Windows-x64.exe`](https://github.com/lb2002/openstitch/releases/download/v0.2.0/OpenStitch-Studio-Windows-x64.exe)** *(einfach herunterladen & doppelklicken, kein Entpacken nötig!)* -* 🍏 **macOS (Apple Silicon M1 / M2 / M3 / M4):** **[`OpenStitch-Studio-macOS-AppleSilicon-M-Chip-arm64.zip`](https://github.com/lb2002/openstitch/releases/download/v0.2.0/OpenStitch-Studio-macOS-AppleSilicon-M-Chip-arm64.zip)** *(`OpenStitch Studio.app`)* -* 🍎 **macOS (Intel x64):** **[`OpenStitch-Studio-macOS-Intel-x64.zip`](https://github.com/lb2002/openstitch/releases/download/v0.2.0/OpenStitch-Studio-macOS-Intel-x64.zip)** *(`OpenStitch Studio.app`)* +Lade dir auf der **[GitHub Release-Seite (latest)](https://github.com/lb2002/openstitch/releases/latest)** direkt die fertige Anwendung für dein System herunter: +* 🪟 **Windows (64-Bit Einzeldatei):** **[`OpenStitch-Studio-Windows-x64.exe`](https://github.com/lb2002/openstitch/releases/latest/download/OpenStitch-Studio-Windows-x64.exe)** *(einfach herunterladen & doppelklicken, kein Entpacken nötig!)* +* 🍏 **macOS (Apple Silicon M1 / M2 / M3 / M4):** **[`OpenStitch-Studio-macOS-AppleSilicon-M-Chip-arm64.zip`](https://github.com/lb2002/openstitch/releases/latest/download/OpenStitch-Studio-macOS-AppleSilicon-M-Chip-arm64.zip)** *(`OpenStitch Studio.app`)* +* 🍎 **macOS (Intel x64):** **[`OpenStitch-Studio-macOS-Intel-x64.zip`](https://github.com/lb2002/openstitch/releases/latest/download/OpenStitch-Studio-macOS-Intel-x64.zip)** *(`OpenStitch Studio.app`)* --- diff --git a/README.es.md b/README.es.md index 89f400a..7fff0fd 100644 --- a/README.es.md +++ b/README.es.md @@ -32,10 +32,10 @@ ## 🚀 Inicio rápido & instalación ### Opción A: descarga con 1 clic (sin Python) -Desde la **[página de releases (v0.2.0)](https://github.com/lb2002/openstitch/releases/tag/v0.2.0)**: -* 🪟 **Windows (64 bits, archivo único):** [`OpenStitch-Studio-Windows-x64.exe`](https://github.com/lb2002/openstitch/releases/download/v0.2.0/OpenStitch-Studio-Windows-x64.exe) -* 🍏 **macOS (Apple Silicon M1–M4):** [`OpenStitch-Studio-macOS-AppleSilicon-M-Chip-arm64.zip`](https://github.com/lb2002/openstitch/releases/download/v0.2.0/OpenStitch-Studio-macOS-AppleSilicon-M-Chip-arm64.zip) -* 🍎 **macOS (Intel x64):** [`OpenStitch-Studio-macOS-Intel-x64.zip`](https://github.com/lb2002/openstitch/releases/download/v0.2.0/OpenStitch-Studio-macOS-Intel-x64.zip) +Desde la **[página de releases (latest)](https://github.com/lb2002/openstitch/releases/latest)**: +* 🪟 **Windows (64 bits, archivo único):** [`OpenStitch-Studio-Windows-x64.exe`](https://github.com/lb2002/openstitch/releases/latest/download/OpenStitch-Studio-Windows-x64.exe) +* 🍏 **macOS (Apple Silicon M1–M4):** [`OpenStitch-Studio-macOS-AppleSilicon-M-Chip-arm64.zip`](https://github.com/lb2002/openstitch/releases/latest/download/OpenStitch-Studio-macOS-AppleSilicon-M-Chip-arm64.zip) +* 🍎 **macOS (Intel x64):** [`OpenStitch-Studio-macOS-Intel-x64.zip`](https://github.com/lb2002/openstitch/releases/latest/download/OpenStitch-Studio-macOS-Intel-x64.zip) ### Opción B: desde el código fuente Requiere **Python 3.10+**. diff --git a/README.fr.md b/README.fr.md index 5c0b475..561fed1 100644 --- a/README.fr.md +++ b/README.fr.md @@ -32,10 +32,10 @@ ## 🚀 Démarrage rapide & installation ### Option A : téléchargement en 1 clic (sans Python) -Depuis la **[page des releases (v0.2.0)](https://github.com/lb2002/openstitch/releases/tag/v0.2.0)** : -* 🪟 **Windows (64 bits, fichier unique) :** [`OpenStitch-Studio-Windows-x64.exe`](https://github.com/lb2002/openstitch/releases/download/v0.2.0/OpenStitch-Studio-Windows-x64.exe) -* 🍏 **macOS (Apple Silicon M1–M4) :** [`OpenStitch-Studio-macOS-AppleSilicon-M-Chip-arm64.zip`](https://github.com/lb2002/openstitch/releases/download/v0.2.0/OpenStitch-Studio-macOS-AppleSilicon-M-Chip-arm64.zip) -* 🍎 **macOS (Intel x64) :** [`OpenStitch-Studio-macOS-Intel-x64.zip`](https://github.com/lb2002/openstitch/releases/download/v0.2.0/OpenStitch-Studio-macOS-Intel-x64.zip) +Depuis la **[page des releases (latest)](https://github.com/lb2002/openstitch/releases/latest)** : +* 🪟 **Windows (64 bits, fichier unique) :** [`OpenStitch-Studio-Windows-x64.exe`](https://github.com/lb2002/openstitch/releases/latest/download/OpenStitch-Studio-Windows-x64.exe) +* 🍏 **macOS (Apple Silicon M1–M4) :** [`OpenStitch-Studio-macOS-AppleSilicon-M-Chip-arm64.zip`](https://github.com/lb2002/openstitch/releases/latest/download/OpenStitch-Studio-macOS-AppleSilicon-M-Chip-arm64.zip) +* 🍎 **macOS (Intel x64) :** [`OpenStitch-Studio-macOS-Intel-x64.zip`](https://github.com/lb2002/openstitch/releases/latest/download/OpenStitch-Studio-macOS-Intel-x64.zip) ### Option B : depuis les sources Nécessite **Python 3.10+**. diff --git a/README.md b/README.md index 2898f39..0a4e53b 100644 --- a/README.md +++ b/README.md @@ -71,10 +71,10 @@ ## 🚀 Quickstart & Installation ### Option A: 1-click download (no Python required) -Get the ready-made app from the **[GitHub release page (v0.2.0)](https://github.com/lb2002/openstitch/releases/tag/v0.2.0)**: -* 🪟 **Windows (64-bit, single file):** [`OpenStitch-Studio-Windows-x64.exe`](https://github.com/lb2002/openstitch/releases/download/v0.2.0/OpenStitch-Studio-Windows-x64.exe) *(download & double-click, nothing to unpack)* -* 🍏 **macOS (Apple Silicon M1 / M2 / M3 / M4):** [`OpenStitch-Studio-macOS-AppleSilicon-M-Chip-arm64.zip`](https://github.com/lb2002/openstitch/releases/download/v0.2.0/OpenStitch-Studio-macOS-AppleSilicon-M-Chip-arm64.zip) -* 🍎 **macOS (Intel x64):** [`OpenStitch-Studio-macOS-Intel-x64.zip`](https://github.com/lb2002/openstitch/releases/download/v0.2.0/OpenStitch-Studio-macOS-Intel-x64.zip) +Get the ready-made app from the **[GitHub release page (latest)](https://github.com/lb2002/openstitch/releases/latest)**: +* 🪟 **Windows (64-bit, single file):** [`OpenStitch-Studio-Windows-x64.exe`](https://github.com/lb2002/openstitch/releases/latest/download/OpenStitch-Studio-Windows-x64.exe) *(download & double-click, nothing to unpack)* +* 🍏 **macOS (Apple Silicon M1 / M2 / M3 / M4):** [`OpenStitch-Studio-macOS-AppleSilicon-M-Chip-arm64.zip`](https://github.com/lb2002/openstitch/releases/latest/download/OpenStitch-Studio-macOS-AppleSilicon-M-Chip-arm64.zip) +* 🍎 **macOS (Intel x64):** [`OpenStitch-Studio-macOS-Intel-x64.zip`](https://github.com/lb2002/openstitch/releases/latest/download/OpenStitch-Studio-macOS-Intel-x64.zip) ### Option B: run from source (Windows, macOS & Linux) Requires **Python 3.10+**. diff --git a/openstitch/__init__.py b/openstitch/__init__.py index 31de333..9891e2f 100644 --- a/openstitch/__init__.py +++ b/openstitch/__init__.py @@ -1,6 +1,6 @@ """OpenStitch Studio – Universal Open-Source Embroidery & Digitizing Suite.""" -__version__ = "0.2.0" +__version__ = "0.3.0" __author__ = "Linus Baumberger (@lb2002)" __license__ = "AGPL-3.0-or-later" APP_NAME = "OpenStitch" diff --git a/pyproject.toml b/pyproject.toml index e46deb0..10b6200 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "openstitch" -version = "0.2.0" +version = "0.3.0" description = "OpenStitch Studio — Universal Open-Source Embroidery & Digitizing Suite (DST, PES, EXP, JEF, VP3, U01)" readme = "README.md" license = {text = "AGPL-3.0-or-later"}