RTF: pro-Run-Groups + Newlines als \par zwischen Groups

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 <noreply@anthropic.com>
This commit is contained in:
2026-05-21 02:07:46 +02:00
parent 1596bbd941
commit 3bd949e590
+49 -23
View File
@@ -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")
# 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")
else: codes.append("\\nosupersub")
body = _rtf_escape(run.get("text") or "")
parts.append("{} {}".format("".join(codes), body))
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)