RTF: Two-Pass fonttbl + Leerzeilen-Fix + wrap-Diagnose

Log-Analyse vom User: RTF preview zeigte
  '{\\fonttbl{\\f0\\fnil\\fcharset0 Georgia;}}'
ABER der Body nutzt
  '\\f1\\cf1\\fs20\\b ... Lorem ...'
\\f1 ist NICHT in der fonttbl! → Rhino fallback auf Default
\\cf1 ist NICHT in der colortbl (es gibt keine)! → ignoriert

Root cause: ich hatte die fonttbl/colortbl eagerly geschrieben BEVOR
die Runs ueberhaupt verarbeitet waren. fonts/colors-Listen wurden waehrend
des Body-Loops um neue Eintraege erweitert, aber der schon-emittierte
Header sah die nie.

Fix: Two-Pass.
PASS 1: Body bauen, dabei font_idx/color_idx aufrufen → fonts/colors-
Listen werden komplett gefuellt.
PASS 2: RTF-Header schreiben mit JETZT vollstaendigen Tables, dann Body
anhaengen.

Plus Leerzeilen: aufeinanderfolgende \\n in den Runs erzeugen jetzt
ein leeres Paragraph mit Space (" "), damit Rhinos Parser den \\par
nicht mit dem naechsten kollabiert. Resultat:
  Lorem...\\par
   \\par         ← Leerzeile (echtes Space im leeren Paragraph)
  Consectetur...

Plus Frame-Wrap-Diagnostic: "[TEXT-EDITOR] wrap: width=... applied_attr=
FormatWidth/TextWidth/None" damit ich sehen kann ob die Wrap-Property
ueberhaupt gesetzt wird in dieser Rhino-Version.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-21 08:15:26 +02:00
parent 2d48a6ed3a
commit 91bc03184e
+37 -24
View File
@@ -198,15 +198,21 @@ class TextEditorBridge(panel_base.BaseBridge):
st.get("underline"))
# 3. Text-Wrap im Frame — NACH dem Content damit es nicht
# durch RichText-Set zurueckgesetzt wird
for attr in ("FormatWidth", "TextWidth"):
# durch RichText-Set zurueckgesetzt wird. Beide Setter
# versuchen (verschiedene Rhino-Versions-APIs).
applied_w = None
for attr in ("FormatWidth", "TextWidth", "MaskWidth"):
try:
setattr(te, attr, width); break
setattr(te, attr, width)
applied_w = attr
break
except Exception: pass
try: te.TextIsWrapped = True
except Exception:
try: te.TextWrap = True
except Exception: pass
print("[TEXT-EDITOR] wrap: width={} applied_attr={}".format(
width, applied_w))
# 4. Frame um den Text + Mask-Margin
frame_kind = (st.get("frame") or "none").lower()
@@ -339,9 +345,11 @@ def _runs_to_rtf(runs, default_font, base_size_m=0.20):
nontrivial = True; break
if not nontrivial: return None
# Font-Tabelle + Color-Tabelle
# ────────────────────────────────────────────────────────────────
# PASS 1: Runs verarbeiten + Fonts/Colors sammeln + RTF-Bodies bauen
# ────────────────────────────────────────────────────────────────
fonts = [default_font]
colors = [] # nur explizit gesetzte (Index 1+)
colors = []
def font_idx(f):
if not f: return 0
if f not in fonts: fonts.append(f)
@@ -353,19 +361,6 @@ def _runs_to_rtf(runs, default_font, base_size_m=0.20):
colors.append(rgb)
return len(colors)
parts = ["{\\rtf1\\ansi\\ansicpg1252\\deff0"]
parts.append("{\\fonttbl")
for i, f in enumerate(fonts):
parts.append("{{\\f{}\\fnil\\fcharset0 {};}}".format(i, f))
parts.append("}")
if colors:
parts.append("{\\colortbl;")
for r, g, b in colors:
parts.append("\\red{}\\green{}\\blue{};".format(r, g, b))
parts.append("}")
parts.append("\\pard")
# Frontend rendert 1m = 100px; Standard-Run-Size ist base_size_m * 100
BASE_PX = max(1.0, base_size_m * 100.0)
def _escape_no_par(s):
@@ -382,16 +377,19 @@ def _runs_to_rtf(runs, default_font, base_size_m=0.20):
out.append("\\u{}?".format(v))
return "".join(out)
# State-Tracking — alle Codes werden pro Run IMMER emittiert
# (inkl. Reset-Codes wie \\b0 \\i0). Kein {}-Grouping. Klassisches
# Inline-RTF wie Word/Wordpad es ausgibt.
body_parts = []
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\n")
if not seg: continue
body_parts.append("\\par\n")
if not seg:
# Leere Section (aufeinanderfolgende \\n) → leerer
# Paragraph mit einem Space, damit Rhinos Parser eine
# echte Leerzeile rendert
body_parts.append(" ")
continue
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
@@ -408,8 +406,23 @@ def _runs_to_rtf(runs, default_font, base_size_m=0.20):
if run.get("sup"): codes.append("\\super")
elif run.get("sub"): codes.append("\\sub")
else: codes.append("\\nosupersub")
parts.append("{} {}".format("".join(codes), _escape_no_par(seg)))
body_parts.append("{} {}".format("".join(codes), _escape_no_par(seg)))
# ────────────────────────────────────────────────────────────────
# PASS 2: RTF-Header mit JETZT vollstaendigen Tables + Body
# ────────────────────────────────────────────────────────────────
parts = ["{\\rtf1\\ansi\\ansicpg1252\\deff0"]
parts.append("{\\fonttbl")
for i, f in enumerate(fonts):
parts.append("{{\\f{}\\fnil\\fcharset0 {};}}".format(i, f))
parts.append("}")
if colors:
parts.append("{\\colortbl;")
for r, g, b in colors:
parts.append("\\red{}\\green{}\\blue{};".format(r, g, b))
parts.append("}")
parts.append("\\pard")
parts.extend(body_parts)
parts.append("}")
return "".join(parts)