Text-Editor: Rhino RTF-Dialekt + Round-Trip + Oberleiste-Sync

Lange Iteration mit dem Rhino TextEntity-RTF-Parser (siehe MEMORY:
rhino_textentity_rtf_limits.md). Finale Form:

- RTF-Body: per-Segment {\fN\cfN\b\i\ulnone seg}-Groups, \par
  zwischen Groups als Linebreak, { }-Space-Group fuer Leerzeilen
  (Rhino collapsed sonst aufeinanderfolgende \par). \fs (Font-Size)
  ist NICHT unterstuetzt → eine Size pro TextEntity (global).
- htmlToRuns: emittiert \n VOR Block-Elementen wenn schon Content
  davor — fixt nested <div>A<div>B</div></div> die sonst A+B ohne
  Trenner als ein Run liefern.
- Round-Trip-Erhaltung: editor.innerHTML 1:1 als UserString
  "dossier_text_html" persistiert, beim Reopen direkt gesetzt
  (kein runsToHtml-Konvertieren das Zeilen verlieren kann).
- Oberleiste-Editing: in-place modify von obj.Geometry + Commit-
  Changes statt Duplicate+Replace (Mac Rhino gibt False zurueck
  bei RichText-Klonen). Plus _patch_rtf_b_i_ul: regex-flippt
  \b/\b0, \i/\i0, \ul/\ulnone global in der RTF damit Bold/Italic/
  Underline OFF in der Oberleiste auch wirklich auf DOSSIER-Texte
  greift (per-Segment-Codes wuerden te.Font-Aenderung sonst
  uebersteuern).
- Stil-ID am Text persistiert + von read_selection_settings
  zurueckgelesen → Stil-Dropdown spiegelt Selektion.
- Editor neu: V-Align (Top/Middle/Bottom), Mask-Type (None/Viewport/
  Solid) mit Farb-Picker, Case-Transform (upper/lower/capitalize/
  invert), Masstaeblich-Toggle (AnnotationScalingEnabled),
  Symbol-Popover, Frame-Optionen.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-21 23:56:16 +02:00
parent ae80185064
commit 26c7d9e67d
3 changed files with 210 additions and 131 deletions
+81 -20
View File
@@ -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)
+67 -61
View File
@@ -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(