From 3bd949e5909bb76a5dc6386656322f78a7845d23 Mon Sep 17 00:00:00 2001 From: karim Date: Thu, 21 May 2026 02:07:46 +0200 Subject: [PATCH] RTF: pro-Run-Groups + Newlines als \par zwischen Groups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User-Bug: Heading (Helvetica Bold) + Paragraph (Georgia Regular) als 2 Zeilen ergibt in Rhino: alle Text in einer Zeile, mit Bold vom Heading aber Font+Size vom LETZTEN definierten Paragraph (Georgia). Root cause: Mein RTF-Generator emitted alle Codes hintereinander ohne {}-Grouping. RTF-Parser ohne Scope nimmt das letzte \\f und \\fs als globalen State und appliziert auf ALLES. Fix _runs_to_rtf: - Pro Run: text per \\n splitten, zwischen Segmenten \\par emitten - Jedes nicht-leere Segment wird in {} Group gewrapped mit eigenen Codes (\\fN \\cfN \\fsN \\b \\i \\ul \\super \\sub) - Nur AKTIVE Toggles in der Group (kein \\b0/\\i0/\\ulnone mehr — Default ausserhalb der Group ist plain) - Resultat: {\\f0\\b Lorem ...}\\par {\\f1 Consectetur ...} Jede Group ist isoliert, kein Cross-Run-State-Leak _escape_no_par: \\n bleibt als Literal-Newline durchgelassen (splitting geschieht im outer loop, nicht im escape). _commit-Reihenfolge nochmal aufgeraeumt: 1. Plane 2. Defaults (Height, Font, Align) — gilt fuer alles 3. Content (RichText wenn nontrivial, sonst PlainText) 4. Wrap (FormatWidth + TextIsWrapped) — NACH RichText damit nicht zurueckgesetzt 5. Frame/Mask/DrawForward Co-Authored-By: Claude Opus 4.7 --- rhino/text_editor.py | 76 +++++++++++++++++++++++++++++--------------- 1 file changed, 51 insertions(+), 25 deletions(-) diff --git a/rhino/text_editor.py b/rhino/text_editor.py index 0a4254d..fe15332 100644 --- a/rhino/text_editor.py +++ b/rhino/text_editor.py @@ -147,13 +147,7 @@ class TextEditorBridge(panel_base.BaseBridge): except Exception: pass te.Plane = plane - # REIHENFOLGE WICHTIG: - # 1. Plain-Text setzen (Initial-Content) - # 2. Default-Font / TextHeight / Align setzen (gilt fuer alles) - # 3. RichText setzen (ZULETZT — die per-Run-Format-Codes - # ueberschreiben dann die Default-Properties) - # Andere Reihenfolge → Defaults schiessen die RTF-Runs weg. - te.PlainText = text + # 1. Defaults (Font, Height, Align) — gilt fuer ALLES try: te.TextHeight = float(st.get("size") or 0.2) except Exception: pass text_create._apply_font( @@ -163,8 +157,9 @@ class TextEditorBridge(panel_base.BaseBridge): st.get("underline")) text_create._apply_align(te, st.get("align") or "left") - # RichText (Phase 2) — ZULETZT, ueberschreibt te.Font fuer - # alle Runs die eine eigene Schrift haben. + # 2. Content — RichText wenn vorhanden, sonst PlainText. + # RichText-Runs ueberschreiben Defaults fuer Texte mit + # eigener Formatierung. rtf = _runs_to_rtf( runs, st.get("font") or "Helvetica", @@ -174,8 +169,13 @@ class TextEditorBridge(panel_base.BaseBridge): te.RichText = rtf except Exception as ex: print("[TEXT-EDITOR] RichText set fail:", ex) + te.PlainText = text + else: + te.PlainText = text - for attr in ("FormatWidth", "TextWidth", "MaskWidth"): + # 3. Text-Wrap im Frame — NACH dem Content damit es nicht + # durch RichText-Set zurueckgesetzt wird + for attr in ("FormatWidth", "TextWidth"): try: setattr(te, attr, width); break except Exception: pass @@ -184,7 +184,7 @@ class TextEditorBridge(panel_base.BaseBridge): try: te.TextWrap = True except Exception: pass - # Frame um den Text + Mask-Margin + # 4. Frame um den Text + Mask-Margin frame_kind = (st.get("frame") or "none").lower() try: MF = Rhino.DocObjects.TextMaskFrame @@ -204,7 +204,7 @@ class TextEditorBridge(panel_base.BaseBridge): except Exception as ex: print("[TEXT-EDITOR] mask:", ex) - # Horizontal-to-view (DrawForward = text steht immer zur Kamera) + # 5. Horizontal-to-view (DrawForward) try: te.DrawForward = bool(st.get("horizontalToView")) except Exception: pass @@ -323,8 +323,6 @@ def _runs_to_rtf(runs, default_font, base_size_m=0.20): parts = ["{\\rtf1\\ansi\\ansicpg1252\\deff0"] parts.append("{\\fonttbl") for i, f in enumerate(fonts): - # Rhinos RTF-Parser will fnil/fcharset0 — sonst kann es passieren - # dass der font-Eintrag ignoriert wird und ein Default verwendet parts.append("{{\\f{}\\fnil\\fcharset0 {};}}".format(i, f)) parts.append("}") if colors: @@ -336,25 +334,53 @@ def _runs_to_rtf(runs, default_font, base_size_m=0.20): # Frontend rendert 1m = 100px; Standard-Run-Size ist base_size_m * 100 BASE_PX = max(1.0, base_size_m * 100.0) - for run in runs: + + def _escape_no_par(s): + """Wie _rtf_escape aber OHNE \\par-Umwandlung von \\n + (Newlines werden separat als \\par ZWISCHEN Run-Groups emitted).""" + out = [] + for ch in s: + cp = ord(ch) + if ch == "\\": out.append("\\\\") + elif ch == "{": out.append("\\{") + elif ch == "}": out.append("\\}") + elif ch == "\n": out.append("\n") # passthrough, wird gesplittet + elif cp < 128: out.append(ch) + else: + v = cp if cp < 0x8000 else cp - 0x10000 + out.append("\\u{}?".format(v)) + return "".join(out) + + def _emit_run_group(run, segment): + """Emittiert {codes segment} wenn segment nicht leer.""" + if not segment: return 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 if ci > 0: codes.append("\\cf{}".format(ci)) - # Font-Size: \fs in Halb-Punkten relativ zur TextEntity.TextHeight. - # Annahme: \fs20 = 1.0× base. Scaling: \fs = 20 * (run_px / base_px). 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)) - 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 = _rtf_escape(run.get("text") or "") - parts.append("{} {}".format("".join(codes), body)) + # Nur AKTIVE Format-Toggles innerhalb der Group — Default ausserhalb + if run.get("bold"): codes.append("\\b") + if run.get("italic"): codes.append("\\i") + if run.get("underline"): codes.append("\\ul") + if run.get("sup"): codes.append("\\super") + elif run.get("sub"): codes.append("\\sub") + parts.append("{{{} {}}}".format("".join(codes), segment)) + + # Pro Run: nach Newlines splitten. Jedes Segment ist eine eigene + # Group, dazwischen \par fuer den Zeilenumbruch. + for run in runs: + raw = run.get("text") or "" + segments = raw.split("\n") + for i, seg in enumerate(segments): + if i > 0: + parts.append("\\par ") + esc = _escape_no_par(seg) + if esc: + _emit_run_group(run, esc) parts.append("}") return "".join(parts)