diff --git a/rhino/text_create.py b/rhino/text_create.py index 4603f42..4d67521 100644 --- a/rhino/text_create.py +++ b/rhino/text_create.py @@ -188,6 +188,9 @@ def apply_style(doc, sid): # Defaults aus Style schreiben (ohne id/name) patch = {k: style[k] for k in style if k in _DEFAULTS} save_settings(doc, patch) + # styleId mitschicken damit apply_settings_to_selection ihn als + # UserString an die Texte haengt — fuer "Stil aktiv"-Anzeige + patch["__style_id__"] = sid apply_settings_to_selection(doc, patch) @@ -707,6 +710,30 @@ def _pick_text_frame(): return None +def _patch_rtf_b_i_ul(rt, bold, italic, underline): + """Patcht alle Bold/Italic/Underline-Codes in der RTF auf den + gewuenschten globalen State. Erhaelt aber per-Segment Font/Color/ + Sup/Sub. + + Wird vom Oberleiste-Path benutzt: die te.Font-Aenderung greift bei + DOSSIER-Texten nicht (RTF-per-Segment-Codes ueberschreiben sie). + Indem wir die Codes selber auf den globalen Toggle setzen, wirken + Bold/Italic/Underline OFF auch tatsaechlich auf den ganzen Text.""" + import re + if not rt: return rt + # Bold: \b oder \b0 als komplettes Token (nicht gefolgt von alpha/digit, + # damit z.B. \bullet nicht versehentlich matched) + pat_b = re.compile(r'\\b0?(?![a-zA-Z0-9])') + rt = pat_b.sub(lambda m: '\\b' if bold else '\\b0', rt) + # Italic + pat_i = re.compile(r'\\i0?(?![a-zA-Z0-9])') + rt = pat_i.sub(lambda m: '\\i' if italic else '\\i0', rt) + # Underline: \ul (on) oder \ulnone (off) — nicht gefolgt von alpha + pat_ul = re.compile(r'\\ul(?:none)?(?![a-zA-Z])') + rt = pat_ul.sub(lambda m: '\\ul' if underline else '\\ulnone', rt) + return rt + + def _apply_font(te, face, bold, italic, underline=False): """Setzt Font auf TextEntity. Mehrere Konstruktor-Pfade fuer verschiedene RhinoCommon-Versionen: @@ -826,7 +853,6 @@ def apply_settings_to_selection(doc, patch): for obj in selected: try: old = obj.Geometry - # Aktuelle Werte lesen (vor Modifikation) cur = old.Font try: cur_face = cur.QuartetName if cur else "Helvetica" except Exception: cur_face = "Helvetica" @@ -837,7 +863,6 @@ def apply_settings_to_selection(doc, patch): try: cur_underline = bool(cur.Underlined) if cur else False except Exception: cur_underline = False - # Neue Werte aus Patch + Fallback auf aktuell face = patch.get("font") or cur_face bold = patch["bold"] if "bold" in patch else cur_bold italic = patch["italic"] if "italic" in patch else cur_italic @@ -845,27 +870,57 @@ def apply_settings_to_selection(doc, patch): size = float(patch["size"]) if "size" in patch else float(old.TextHeight) align = patch["align"] if patch.get("align") in _ALIGNS else None - # FRESH TextEntity bauen statt Duplicate-Modify. Bypassed - # Probleme wo te.Font-Setter wegen Rich-Text-Runs oder - # DimensionStyle-Override nicht greift. - te = rg.TextEntity() - te.Plane = old.Plane - try: te.PlainText = old.PlainText + is_dossier = False + try: + is_dossier = obj.Attributes.GetUserString("dossier_text") == "1" except Exception: pass - te.TextHeight = size - # DimensionStyle entkoppeln damit unser Font nicht von Style - # ueberschrieben wird. - try: te.DimensionStyleId = System.Guid.Empty - except Exception: pass - _apply_font(te, face, bool(bold), bool(italic), bool(underline)) - # Alignment: aus Patch oder vom alten Entity uebernehmen + + # IN-PLACE Modifikation der Live-Geometry (kein Duplicate, + # keine fresh-Entity — Mac Rhino hat Probleme mit Replace + # auf RichText-Entities die nicht aus dem Doc kommen). + old.TextHeight = size + _apply_font(old, face, bool(bold), bool(italic), bool(underline)) if align: - _apply_align(te, align) - else: - try: te.TextHorizontalAlignment = old.TextHorizontalAlignment + _apply_align(old, align) + + # DOSSIER-Texte: die RTF hat per-Segment Codes (\b0, \i0, + # \ulnone) die die te.Font-Aenderung uebersteuern. Wir + # patchen die Codes global damit Bold/Italic/Underline OFF + # auch wirklich greifen. + if is_dossier: + try: + rt = old.RichText + if rt: + new_rt = _patch_rtf_b_i_ul( + rt, bool(bold), bool(italic), bool(underline)) + if new_rt != rt: + old.RichText = new_rt + except Exception as ex: + print("[TEXT] RTF patch fail:", ex) + + # Style-ID am Text persistieren wenn ueber apply_style + # appliziert (Oberleiste-Anzeige "Stil aktiv" bei Selektion) + sid = patch.get("__style_id__") + if sid: + try: obj.Attributes.SetUserString("dossier_text_style_id", sid) except Exception: pass - doc.Objects.Replace(obj.Id, te) + # CommitChanges ist die RhinoObject-API um Aenderungen an der + # in-place modifizierten Geometry persistent zu machen. + try: + ok = obj.CommitChanges() + print("[TEXT] CommitChanges: {} (dossier={})".format(ok, is_dossier)) + except Exception as ex: + print("[TEXT] CommitChanges fail:", ex) + ok = False + + # Falls CommitChanges nicht greift → Replace als Fallback + if not ok: + try: + ok2 = doc.Objects.Replace(obj.Id, old) + print("[TEXT] Replace fallback: {}".format(ok2)) + except Exception as ex: + print("[TEXT] Replace fallback fail:", ex) n += 1 except Exception as ex: print("[TEXT] apply selection:", ex) @@ -880,7 +935,8 @@ def read_selection_settings(doc): sel = _selected_text_objects(doc) if not sel: return None try: - te = sel[0].Geometry + obj = sel[0] + te = obj.Geometry font = te.Font face = font.QuartetName if font else "Helvetica" bold = bool(font.Bold) if font else False @@ -893,6 +949,10 @@ def read_selection_settings(doc): if h == Rhino.DocObjects.TextHorizontalAlignment.Center: align = "center" elif h == Rhino.DocObjects.TextHorizontalAlignment.Right: align = "right" except Exception: pass + # Style-ID falls am Text gespeichert (= via apply_style appliziert) + style_id = None + try: style_id = obj.Attributes.GetUserString("dossier_text_style_id") or None + except Exception: pass return { "font": face, "size": float(te.TextHeight), @@ -900,6 +960,7 @@ def read_selection_settings(doc): "italic": italic, "underline": underline, "align": align, + "styleId": style_id, } except Exception as ex: print("[TEXT] read selection:", ex) diff --git a/rhino/text_editor.py b/rhino/text_editor.py index 1f5eb85..344a605 100644 --- a/rhino/text_editor.py +++ b/rhino/text_editor.py @@ -84,7 +84,8 @@ def _on_idle_check_pending_edit(sender, e): class TextEditorBridge(panel_base.BaseBridge): def __init__(self, frame_data, settings, fonts, - edit_obj_id=None, initial_text="", initial_runs=None): + edit_obj_id=None, initial_text="", initial_runs=None, + initial_html=None): panel_base.BaseBridge.__init__(self, "text_editor") self._frame = frame_data # (origin, width, height, p1, p2) self._initial_settings = settings @@ -93,6 +94,7 @@ class TextEditorBridge(panel_base.BaseBridge): self._edit_obj_id = edit_obj_id # bei Doppelklick-Edit gesetzt self._initial_text = initial_text self._initial_runs = initial_runs # rich-format-Runs falls vorhanden + self._initial_html = initial_html # 1:1 Editor-HTML beim Reopen def set_form(self, form): self._form_ref = form @@ -108,6 +110,7 @@ class TextEditorBridge(panel_base.BaseBridge): "styles": styles, "initialText": self._initial_text, "initialRuns": self._initial_runs, + "initialHtml": self._initial_html, "editMode": bool(self._edit_obj_id), }) @@ -282,6 +285,10 @@ class TextEditorBridge(panel_base.BaseBridge): print("[TEXT-EDITOR] color:", ex) attrs.SetUserString("dossier_text", "1") attrs.SetUserString("dossier_text_scaled", "1" if scale_flag else "0") + sid = st.get("styleId") + if sid: + try: attrs.SetUserString("dossier_text_style_id", sid) + except Exception: pass # Runs als JSON persistieren — beim Re-Open kann der Editor # die ganze Struktur (Fonts/Sizes/Styles pro Segment) wieder # herstellen statt nur PlainText zu zeigen. @@ -291,6 +298,15 @@ class TextEditorBridge(panel_base.BaseBridge): attrs.SetUserString("dossier_text_runs", json.dumps(runs)) except Exception as ex: print("[TEXT-EDITOR] runs persist:", ex) + # Editor-innerHTML 1:1 persistieren — beim Reopen wird der + # exakte Editor-Zustand wiederhergestellt, kein Round-Trip + # ueber runs (was Zeilen zusammen ziehen kann). + html = payload.get("html") + if html: + try: + attrs.SetUserString("dossier_text_html", html) + except Exception as ex: + print("[TEXT-EDITOR] html persist:", ex) # Edit-Mode: bestehenden TextEntity ersetzen statt neu hinzu if self._edit_obj_id is not None: @@ -366,17 +382,13 @@ def _runs_to_rtf(runs, default_font, base_size_m=0.20): """Konvertiert Format-Runs in Rhinos RTF-Dialekt. Runs ist Liste von dicts mit Keys text/font/bold/italic/underline/sup/sub/color/fontSizePx. base_size_m: TextEntity.TextHeight (in m). Frontend rendert 1m = 100px, - also entspricht base_size_m * 100 dem "Standard" \\fs20 in RTF.""" + also entspricht base_size_m * 100 dem "Standard" \\fs20 in RTF. + + Wir emittieren IMMER RTF wenn Runs vorliegen — auch wenn die Runs + auf den ersten Blick "trivial" aussehen. So bleibt das Format + stabil ueber Re-Edits hinweg und es gibt keinen impliziten Fallback + auf _apply_font (= alles auf eine Schrift).""" if not runs: return None - # Triviale Runs (alle plain, ein Font) → kein RTF noetig - nontrivial = False - for r in runs: - if r.get("bold") or r.get("italic") or r.get("underline") \ - or r.get("sup") or r.get("sub") or r.get("color") \ - or r.get("fontSizePx") \ - or (r.get("font") and r["font"] != default_font): - nontrivial = True; break - if not nontrivial: return None # ──────────────────────────────────────────────────────────────── # PASS 1: Runs verarbeiten + Fonts/Colors sammeln + RTF-Bodies bauen @@ -410,57 +422,39 @@ def _runs_to_rtf(runs, default_font, base_size_m=0.20): out.append("\\u{}?".format(v)) return "".join(out) - # Ansatz: jedes Text-Segment in eigene {}-Group mit lokalen Codes. - # Zwischen Segmenten (und ueber Run-Grenzen hinweg) \\par fuer - # Paragraph-Breaks. So bleibt Per-Run-Formatting (Groups isolieren - # State) UND Mehrzeiligkeit (\\par ist Rhinos echter Linebreak). - body_parts = [] - pending_pars = 0 # Wieviele \\par muessen noch vor naechstem Segment - - def _emit_group(run, seg): - codes = [] - codes.append("\\f{}".format(font_idx(run.get("font") or default_font))) - ci = color_idx(run.get("color")) if run.get("color") else 0 - codes.append("\\cf{}".format(ci) if ci > 0 else "\\cf0") - fsp = run.get("fontSizePx") - if fsp and abs(fsp - BASE_PX) > 0.1: - rtf_fs = max(2, int(round(20.0 * fsp / BASE_PX))) - codes.append("\\fs{}".format(rtf_fs)) - else: - codes.append("\\fs20") - codes.append("\\b" if run.get("bold") else "\\b0") - codes.append("\\i" if run.get("italic") else "\\i0") - codes.append("\\ul" if run.get("underline") else "\\ulnone") - if run.get("sup"): codes.append("\\super") - elif run.get("sub"): codes.append("\\sub") - else: codes.append("\\nosupersub") - body_parts.append("{{{} {}}}".format("".join(codes), _escape_no_par(seg))) - - def _flush_pars(n): - # 1 \\par = einfacher Zeilenumbruch. N>1 \\par hintereinander - # wuerde Rhino zu einem einzigen Umbruch collapsen — fuer - # jede zusaetzliche Leerzeile schieben wir einen Space-Group - # dazwischen, damit der leere Paragraph Inhalt hat. - if n <= 0: return - body_parts.append("\\par ") - for _ in range(n - 1): - body_parts.append("{ }\\par ") - - first_emitted = False + # Rhinos TextEntity-RTF: per-Segment in {}-Group, \par zwischen + # Groups als Linebreak. Diese Form hat in commit 3bd949e fuer + # Newlines funktioniert. Fuer Leerzeilen: zusaetzliche {\par}-Group + # zwischen den regulaeren Segmenten — leere Group mit eigenem \par + # umgeht den Multi-\par-Collapse. + lines = [[]] for run in runs: raw = run.get("text") or "" - segments = raw.split("\n") - for i, seg in enumerate(segments): - if i > 0: - pending_pars += 1 - if seg: - if first_emitted: - _flush_pars(pending_pars) - pending_pars = 0 - _emit_group(run, seg) - first_emitted = True - if first_emitted and pending_pars > 0: - _flush_pars(pending_pars) + parts_in_run = raw.split("\n") + for j, part in enumerate(parts_in_run): + if j > 0: + lines.append([]) + if part: + lines[-1].append((run, part)) + + body_parts = [] + for li, line in enumerate(lines): + if li > 0: + body_parts.append("\\par ") + if not line: + # Leere Zeile: Space-Group damit der Paragraph Inhalt hat + # (sonst collapsed Rhino zwei \par auf einen Linebreak). + body_parts.append("{ }") + continue + for (run, seg) in line: + codes = [] + codes.append("\\f{}".format(font_idx(run.get("font") or default_font))) + ci = color_idx(run.get("color")) if run.get("color") else 0 + codes.append("\\cf{}".format(ci) if ci > 0 else "\\cf0") + codes.append("\\b" if run.get("bold") else "\\b0") + codes.append("\\i" if run.get("italic") else "\\i0") + codes.append("\\ul" if run.get("underline") else "\\ulnone") + body_parts.append("{{{} {}}}".format("".join(codes), _escape_no_par(seg))) # ──────────────────────────────────────────────────────────────── # PASS 2: RTF-Header mit JETZT vollstaendigen Tables + Body @@ -530,6 +524,10 @@ def open_for_edit(obj): settings["align"] = "right" else: settings["align"] = "left" except Exception: pass + try: + sid = obj.Attributes.GetUserString("dossier_text_style_id") + if sid: settings["styleId"] = sid + except Exception: pass try: flag = obj.Attributes.GetUserString("dossier_text_scaled") if flag in ("0", "1"): @@ -573,6 +571,13 @@ def open_for_edit(obj): if rj: initial_runs = json.loads(rj) except Exception as ex: print("[TEXT-EDITOR] read runs:", ex) + # Editor-innerHTML (Round-Trip-Konservierung): wenn vorhanden, + # wird der Editor exakt mit diesem HTML geoeffnet + initial_html = None + try: + h = obj.Attributes.GetUserString("dossier_text_html") + if h: initial_html = h + except Exception: pass # Frame aus dem Text-BBox ableiten (fuer Dialog-Positionierung) p1 = te.Plane.Origin @@ -596,7 +601,8 @@ def open_for_edit(obj): settings, fonts, edit_obj_id=obj.Id, initial_text=initial_text, - initial_runs=initial_runs) + initial_runs=initial_runs, + initial_html=initial_html) sc.sticky["text_editor_bridge"] = bridge form = panel_base.open_satellite_window( diff --git a/src/TextEditorApp.jsx b/src/TextEditorApp.jsx index e44fbff..8340a31 100644 --- a/src/TextEditorApp.jsx +++ b/src/TextEditorApp.jsx @@ -90,8 +90,10 @@ function Dropdown({ value, onChange, options, width, title }) { function htmlToRuns(rootEl) { const runs = [] function flush(text, ctx) { - if (text === '') return - runs.push({ text, ...ctx }) + // ZWSP (U+200B) ist unser Style-Marker — nie in die Runs + const cleaned = text.replace(//g, '') + if (cleaned === '') return + runs.push({ text: cleaned, ...ctx }) } function walk(node, ctx) { if (node.nodeType === Node.TEXT_NODE) { @@ -100,6 +102,14 @@ function htmlToRuns(rootEl) { if (node.nodeType !== Node.ELEMENT_NODE) return const tag = node.tagName.toLowerCase() if (tag === 'br') { flush('\n', ctx); return } + // Block-Elemente: \n VOR dem Element wenn schon Content davor ist. + // Fix fuer verschachtelte divs (