Rich Text Parsing & Rendering System

Texturge’s rich text system is a complete Parse → Build → Render pipeline that converts tagged rich text strings into a typed render tree capable of per-character animated rendering.

Pipeline Overview

RichText String                    FTagNode Tree                   FRenderNode Tree
┌──────────────────┐      ┌────────────────────┐      ┌──────────────────────────┐
│ "Hello <Wave     │      │ Root               │      │ Root (Unknown)           │
│  id="bounce">    │ ───▶ │ ├─ "Hello " (text) │ ───▶ │ ├─ TextContent           │
│  Texturge</>!"   │      │ └─ Wave            │      │ │   "Hello "              │
│                  │      │     └─ "Texturge"  │      │ └─ AnimationLayer        │
└──────────────────┘      └────────────────────┘      │    TagName="Wave"        │
        FTagParser                  Parse               │    ├─ TextContent        │
                                                       │    │   "Texturge"       │
                                                       └──────────────────────────┘
                                                           FRenderTreeBuilder

Tag Parsing

FTagParser is a single-pass, non-recursive iterative stack parser that converts tagged rich text into a FTagNode parse tree and the stripped PlainText.

Supported Syntax

SyntaxExampleDescription
Opening tag<tagname>Tag names: [A-Za-z0-9_-], max 32 chars
Tag with attributes<tagname attr1=val1 attr2="val2">Supports quoted and unquoted value formats
Auto-close</>Closes the most recently opened tag
Explicit close</tagname>Closes the named tag; warns on mismatch
Self-closing<tag />Used for decorators (e.g., image placeholders)
Escape sequences&lt; &gt; &amp;HTML/XML entity decoding

Error Tolerance

  • Malformed tags produce UE_LOG warnings, never crashes
  • Unclosed tags are auto-closed at end of input (each producing a warning)
  • Invalid < characters (not followed by a valid tag name) are treated as plain text

FTagNode Structure

struct FTagNode
{
    FString TagName;
    TMap<FString, FString> Attributes;  // Key-value attributes
    TArray<TSharedPtr<FTagNode>> Children;  // Child nodes
    TWeakPtr<FTagNode> Parent;
    int32 RangeBegin;  // Start index in plain text
    int32 RangeEnd;    // End index in plain text (exclusive)
    bool bIsSelfClosing;
};

Render Tree Construction

FRenderTreeBuilder converts the parse tree into a typed render tree FRenderNode. Its core task is node classification — distinguishing animation tags, rich text style tags, and plain text.

Node Classification

TypeIdentificationPurpose
AnimationLayerTag name matches an Entry in UTextAnimationDataAssetMarks text segments needing independent animation
StyleLayerTag name unmatched by DataAssetPassed through to engine rich text (style tags defined in RichTextStyleSheet, e.g. <b>), preserved in output
TextContentLeaf nodePlain text content
DecoratorSelf-closing tagInline decorators (images, etc.)

Entry Index Matching

FindEntryIndex() uses a two-tier matching strategy:

  1. Priority: Look up the id attribute (e.g., <anim id="Wave">), match by TagName in AnimationData->Entries[]
  2. Fallback: If no id attribute, match directly by tag name (legacy <Wave> format compatible)

Display Text Serialization

SerializeToDisplayText() converts the render tree to UE5-compatible display rich text:

  • AnimationLayer → skip tag markers, recurse into children (strip animation tags)
  • StyleLayer → output full tag wrapping (e.g., <b>text</>)
  • TextContent → output text directly
  • Decorator → output self-closing tag

SerializeSubstring() truncates the render tree by character index — the critical method for character-by-character reveal, dynamically generating properly style-wrapped partial text based on the current MaxCharIndex.

Slate Rendering

FTextRun — Per-Character Animation Rendering

FTextRun inherits FSlateTextRun and overrides OnPaint() for per-character animation rendering:

FTextRun::OnPaint()

  ├─ Animator present and playing?
  │   ├─ Yes → For each character:
  │   │        1. Get FAnimationFrameData from Animator
  │   │        2. Apply transforms: Translate → Rotate → Scale → Shear (centered on Pivot)
  │   │        3. Apply color: Color × InWidgetStyle tint
  │   │        4. Apply opacity: Opacity × original opacity
  │   │        5. Draw shadow (if ShadowColor is non-transparent)
  │   │        6. Draw glyph
  │   └─ No  → Base FSlateTextRun::OnPaint() (fast path, zero overhead)

FRichTextMarshaller — Injecting Animated Text Runs

FRichTextMarshaller inherits FRichTextLayoutMarshaller, replacing standard FSlateTextRun with FTextRun in AppendRunsForText(), giving every text run a UTextAnimator reference.

The Marshaller maintains AccumulatedTextLength and PrecedingDecoratorCount to correctly compute each Run’s offset in PlainText — the key to mapping ModelText line indices back to PlainText global indices for frame data lookup.

SAnimatedRichTextBlock

SAnimatedRichTextBlock inherits SRichTextBlock, calling Animator->TickAnimation() in Tick(), and dynamically updating the widget text when the animator state changes. It uses FRichTextMarshaller to implement a unified pipeline for rich text parsing and animation data injection.

SAnimatedTextBlock

SAnimatedTextBlock inherits STextBlock, the Slate widget for the plain text path. Its OnPaint() reads frame data from Animator->GetCurrentFrameDataArray() and renders glyph by glyph. Unlike the RichText path, this widget doesn’t need Marshaller intervention — it directly iterates STextBlock’s text layout lines, looking up frame data for each glyph.

images/rich-text-pipeline.png — Three-column pipeline diagram: rich text input → FTagNode parse tree (nested structure) → FRenderNode render tree (color-coded by node type)
images/serialize-substring-progression.gif — Four-frame sequence GIF: text at character indices 0/5/10/all, with MaxCharIndex and SerializeSubstring output annotated per frame