Status: Accepted
Date: 2024-2025 (Design Phase)
After deciding on a single RenderObject architecture (ADR 0002), we needed to decide how to render text and inline content.
Text rendering in Flutter has two main approaches:
- Widget-based:
Text,RichTextwidgets - Span-based:
TextSpan,WidgetSpantree
For HyperRender's custom RenderObject, we needed to choose how to represent and paint inline content (text + inline images + links).
Implementation:
void paint(PaintingContext context, Offset offset) {
for (final fragment in textFragments) {
// Manually paint each character
final textPainter = TextPainter(
text: TextSpan(text: fragment.text, style: fragment.style),
);
textPainter.paint(context.canvas, fragment.offset);
}
}Pros:
- Full control over every pixel
- No dependencies on Flutter internals
Cons:
- Must implement font rendering
- Must implement text shaping (complex scripts)
- Must implement bidirectional text (RTL)
- Must implement grapheme clustering
- Months of work to match Flutter quality
- Reinventing the wheel
Verdict: Rejected - too much work, too risky
Implementation:
final List<TextPainter> _painters = [];
void paint(PaintingContext context, Offset offset) {
for (final painter in _painters) {
painter.paint(context.canvas, offset);
offset += Offset(painter.width, 0);
}
}Pros:
- Uses Flutter's text rendering
- Simpler than custom painting
Cons:
- Selection breaks across painters
- Can't measure text across fragments
- Baseline alignment complex
- Poor performance (many TextPainter instances)
Verdict: Rejected - selection and performance issues
Implementation:
// Build InlineSpan tree from UDT
InlineSpan _buildInlineSpan(UDTNode node) {
if (node is TextNode) {
return TextSpan(text: node.text, style: node.computedStyle);
} else if (node is BlockNode) {
return TextSpan(
children: node.children.map(_buildInlineSpan).toList(),
);
} else if (node is AtomicNode && node.isImage) {
return WidgetSpan(child: Image.network(node.src!));
}
}
// Single TextPainter for entire document (or per paragraph)
final textPainter = TextPainter(
text: _buildInlineSpan(documentRoot),
textDirection: TextDirection.ltr,
);
// Layout
textPainter.layout(maxWidth: constraints.maxWidth);
// Paint
textPainter.paint(context.canvas, offset);Pros:
- Leverages Flutter's text engine
- Continuous selection (single TextPainter)
- Proper baseline alignment
- Handles complex scripts, RTL, grapheme clustering
- WidgetSpan for inline images
- Performance (single paint call)
Cons:
- Less control than custom painting
- Tied to Flutter's TextPainter API
Verdict: Accepted - best balance of power and simplicity
We use InlineSpan (TextSpan/WidgetSpan) tree for inline content rendering.
// Fragment → InlineSpan conversion
class Fragment {
final String text;
final ComputedStyle style;
final TapGestureRecognizer? recognizer; // For links
InlineSpan toInlineSpan() {
return TextSpan(
text: text,
style: TextStyle(
color: style.color,
fontSize: style.fontSize,
fontWeight: style.fontWeight,
// ... map all CSS properties to TextStyle
),
recognizer: recognizer,
);
}
}
// Build tree for entire line
InlineSpan _buildLineSpan(Line line) {
return TextSpan(
children: line.fragments.map((f) => f.toInlineSpan()).toList(),
);
}
// Paint line
void _paintLine(Line line, Canvas canvas, Offset offset) {
final textPainter = TextPainter(
text: _buildLineSpan(line),
textDirection: TextDirection.ltr,
);
textPainter.layout(maxWidth: line.width);
textPainter.paint(canvas, offset);
}1. Continuous Selection
// User can select across styled text seamlessly
"This is <b>bold</b> and <i>italic</i> text"
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Single selection across styles!2. Baseline Alignment
// TextPainter handles baseline alignment automatically
"Normal <sub>subscript</sub> <sup>superscript</sup>"
↓ Aligned correctly by TextPainter3. Complex Scripts
// Automatic support for:
// - Arabic (RTL + shaping)
// - Hindi (Devanagari ligatures)
// - Thai (no spaces, complex breaks)
// - Emoji (grapheme clustering)
"مرحبا" // RTL, shaped correctly
"हिन्दी" // Ligatures handled
"👨👩👧👦" // Family emoji (single grapheme)4. Performance
// Single TextPainter per line (vs 100+ TextPainters per line)
// Benchmark: 1000 styled fragments
// Multiple TextPainters: 50ms paint time
// Single InlineSpan tree: 8ms paint time
// 6x faster!TextStyle _cssToTextStyle(ComputedStyle css) {
return TextStyle(
// Colors
color: css.color,
backgroundColor: css.backgroundColor,
// Font
fontSize: css.fontSize,
fontFamily: css.fontFamily,
fontWeight: _cssFontWeight(css.fontWeight), // 100-900 → FontWeight
fontStyle: css.fontStyle == 'italic' ? FontStyle.italic : FontStyle.normal,
// Text decoration
decoration: _cssTextDecoration(css.textDecoration), // underline, etc.
decorationColor: css.textDecorationColor,
decorationStyle: _cssDecorationStyle(css.textDecorationStyle), // solid, dashed
// Spacing
letterSpacing: css.letterSpacing,
wordSpacing: css.wordSpacing,
height: css.lineHeight, // line-height → TextStyle.height
// Shadows
shadows: _cssTextShadow(css.textShadow),
);
}// Links are TextSpans with recognizers
InlineSpan _buildLinkSpan(Fragment fragment) {
final recognizer = TapGestureRecognizer()
..onTap = () {
onLinkTap?.call(fragment.href);
};
// Store for disposal later
_linkRecognizers[fragment.href] = recognizer;
return TextSpan(
text: fragment.text,
style: _linkStyle,
recognizer: recognizer,
);
}// Images embedded in text
InlineSpan _buildImageSpan(Fragment fragment) {
return WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: Image.network(
fragment.src!,
width: fragment.width,
height: fragment.height,
),
);
}Leverage Flutter's Text Engine
- No need to implement font rendering
- No need to implement text shaping
- No need to implement bidirectional text
- Saved months of development time
Selection Works Perfectly
// User can select across:
// - Different styles (bold, italic)
// - Different colors
// - Links
// - All seamlessly in one gesture
"Normal <b>bold</b> <a href="#">link</a> text"
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Single continuous selection!Performance
// Benchmark: Rendering 1000 styled text fragments
TextPainter (InlineSpan): 8ms ✅
Multiple TextPainters: 50ms ❌
Custom char-by-char paint: 120ms ❌Maintainability
- Simple code (~100 lines for text rendering)
- Easy to understand
- Uses familiar Flutter APIs
Future-Proof
- Automatically benefits from Flutter text improvements
- Example: Flutter 3.x improved emoji rendering → we got it for free
Less Control
// Can't customize:
// - Exact glyph positioning
// - Custom font fallback logic
// - Low-level shaping
// But: 99% of use cases don't need this!TextPainter Limitations
// TextPainter doesn't support:
// - Multi-column layout (we work around this)
// - Custom line-breaking (we work around this with float support)
// - Vertical text (writing-mode: vertical-rl) - not supported yet
// All limitations have workarounds except vertical textWidgetSpan Constraints
// Inline images via WidgetSpan have limitations:
// - Baseline alignment sometimes off by 1-2px
// - Can't select across WidgetSpan boundaries
// - We work around this by using child RenderBoxes for images- Cache TextPainters (LRU)
// Avoid creating TextPainter every frame
final _textPainterCache = LruCache<String, TextPainter>(maxSize: 100);
TextPainter _getTextPainter(InlineSpan span) {
final key = span.hashCode.toString();
return _textPainterCache.get(key) ?? TextPainter(text: span);
}- Custom Image Positioning
// Don't use WidgetSpan for images (baseline issues)
// Instead, use child RenderBoxes positioned manually
void _layoutInlineImages() {
for (final imageFragment in inlineImages) {
final child = imageFragment.renderBox;
child.layout(imageFragment.constraints);
child.offset = imageFragment.offset; // Exact positioning
}
}- Vertical Text Fallback
// For vertical text (future):
// Option 1: Rotate entire RenderObject
// Option 2: Use custom paint for vertical (lose InlineSpan benefits)
// Option 3: Wait for Flutter to add vertical TextPainter supportProblem: Creating TextPainter per frame is expensive
Solution: LRU cache with automatic disposal
class _LruCache<K, V> {
final int _maxSize;
final LinkedHashMap<K, V> _cache = LinkedHashMap();
final void Function(V)? _onEvict;
V? get(K key) {
final value = _cache.remove(key);
if (value != null) {
_cache[key] = value; // Move to end (most recent)
}
return value;
}
void put(K key, V value) {
_cache.remove(key);
_cache[key] = value;
// Evict oldest if over capacity
if (_cache.length > _maxSize) {
final oldest = _cache.keys.first;
final evicted = _cache.remove(oldest);
_onEvict?.call(evicted!);
}
}
}
// Usage:
final _textPainters = _LruCache<int, TextPainter>(
maxSize: 100,
onEvict: (painter) => painter.dispose(), // Clean up
);Result:
- First render: 20ms (create TextPainters)
- Subsequent renders: 2ms (cache hit)
- 10x faster after cache warm-up
<p>
This is <b>bold</b>, <i>italic</i>, and <u>underlined</u> text.
It also has <a href="#">links</a> and <span style="color: red;">colors</span>.
</p>InlineSpan tree:
TextSpan(
children: [
TextSpan(text: 'This is '),
TextSpan(text: 'bold', style: TextStyle(fontWeight: FontWeight.bold)),
TextSpan(text: ', '),
TextSpan(text: 'italic', style: TextStyle(fontStyle: FontStyle.italic)),
TextSpan(text: ', and '),
TextSpan(text: 'underlined', style: TextStyle(decoration: TextDecoration.underline)),
TextSpan(text: '. It also has '),
TextSpan(text: 'links', style: linkStyle, recognizer: tapRecognizer),
TextSpan(text: ' and '),
TextSpan(text: 'colors', style: TextStyle(color: Colors.red)),
TextSpan(text: '.'),
],
)Rendered by single TextPainter → continuous selection ✅
<code class="language-dart">
<span class="keyword">final</span>
<span class="type">String</span>
<span class="variable">name</span> =
<span class="string">'HyperRender'</span>;
</code>InlineSpan tree:
TextSpan(
style: monoStyle, // Base monospace style
children: [
TextSpan(text: 'final', style: keywordStyle),
TextSpan(text: ' '),
TextSpan(text: 'String', style: typeStyle),
TextSpan(text: ' '),
TextSpan(text: 'name', style: variableStyle),
TextSpan(text: ' = '),
TextSpan(text: "'HyperRender'", style: stringStyle),
TextSpan(text: ';'),
],
)Each color/style in one TextPainter → fast rendering ✅
<p>
Click the icon <img src="icon.png" style="width: 16px; height: 16px;"> to save.
</p>InlineSpan tree (using child RenderBox, not WidgetSpan):
// Text fragments as InlineSpan
TextSpan(
children: [
TextSpan(text: 'Click the icon '),
TextSpan(text: '\uFFFC'), // Object replacement character (placeholder)
TextSpan(text: ' to save.'),
],
)
// Image as child RenderBox, positioned at placeholder offset
_layoutInlineImage(imageChild, placeholderOffset);Result: Perfect baseline alignment ✅
Right abstraction level
- InlineSpan gives enough control
- But doesn't require reimplementing text engine
- Sweet spot!
Cache made huge difference
- Initial implementation: 20ms per frame
- With LRU cache: 2ms per frame
- 10x improvement
Selection just works
- Spent 0 hours on selection across styles
- Flutter's TextPainter handles it perfectly
CSS → TextStyle mapping
- Some CSS properties don't map 1:1
- Example:
text-decoration-style: wavy→ no Flutter equivalent - Had to pick closest match or skip
Inline image alignment
- WidgetSpan baseline alignment was off
- Switched to child RenderBox approach
- Now perfect!
TextPainter memory
- Initially leaked TextPainters
- Added LRU cache with disposal
- Fixed!
- Vertical Text (low priority)
.vertical {
writing-mode: vertical-rl;
}Waiting for Flutter TextPainter support.
- Custom Font Fallback (low priority)
// More control over which fonts to try
fontFamilyFallback: ['Roboto', 'Noto Sans', 'Arial']- Sub-pixel Positioning (very low priority)
- TextPainter rounds to pixel boundaries
- For ultra-high DPI, may want sub-pixel
- Not noticeable in practice
- ADR 0002: Single RenderObject - Why custom RenderObject
- ADR 0001: UDT Model - How we build InlineSpan from UDT
- Implementation:
packages/hyper_render_core/lib/src/core/render_hyper_box.dart(lines 800-1200) - Flutter TextPainter: https://api.flutter.dev/flutter/painting/TextPainter-class.html
- InlineSpan: https://api.flutter.dev/flutter/painting/InlineSpan-class.html
- Tests:
test/text_rendering_test.dart
Decision makers: vietnguyen (Lead Developer)
Last updated: February 2026