Inline Text

Overview

text3 is azul's text engine. It owns shaping, line breaking, BiDi reordering, vertical writing modes, hyphenation, selection, and editing. The older text2 path has been removed; text3 is the single live engine. WIP — a few CSS Inline Layout Module Level 3 features (initial-letter, text-box-trim, full ruby) are partially implemented; baseline alignment of non-baseline vertical-align values uses approximate offsets.

The central type is TextShapingCache (re-exported as TextLayoutCache for backward compatibility). The solver invokes it through layout_ifc, which collects the IFC's Vec<InlineContent>, builds UnifiedConstraints from CSS, and calls layout_flow. The result is wrapped in a CachedInlineLayout and stored on the IFC root's LayoutNode warm slab so the Layout solver can hit-test, select, and re-render without re-shaping.

The resource side — font discovery, parsing, fallback chain resolution — is covered in Text Pipeline. This page is the in-engine shaping and layout pipeline.

The 5-stage pipeline

TextShapingCache::layout_flow is the top-level entry. Each stage is independently cached:

InlineContent ──Stage 1─▶ LogicalItem
                          (per-char attribution)
                │
                ▼ Stage 2
            VisualItem  (BiDi reorder, UAX #9)
                │
                ▼ Stage 3
            ShapedItem  (HarfBuzz/allsorts; per-item cache)
                │
                ▼ Stage 4
            ShapedItem' (text-orientation rotate for vertical-rl/lr)
                │
                ▼ Stage 5
            PositionedItem in UnifiedLayout
            (Knuth–Plass lines + final placement)

Stages 1–4 are independent of geometry; stage 5 takes a flow_chain: &[LayoutFragment] so the same shaped content can re-flow across columns or pages without re-shaping.

pub fn layout_flow<T: ParsedFontTrait>(
    &mut self,
    content: &[InlineContent],
    style_overrides: &[StyleOverride],
    flow_chain: &[LayoutFragment],
    font_chain_cache: &HashMap<FontChainKey, FontFallbackChain>,
    fc_cache: &FcFontCache,
    loaded_fonts: &LoadedFonts<T>,
    debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
) -> Result<FlowLayout, LayoutError>;

Caching architecture

TextShapingCache holds four maps, one per stage:

  • logical_items. Caches Stage 1. Keyed by CacheId = u64 of &[InlineContent], value Arc<Vec<LogicalItem>>.
  • visual_items. Caches Stage 2. Keyed by (logical_items_id, base_direction), value Arc<Vec<VisualItem>>.
  • shaped_items. Caches Stage 3 (monolithic). Keyed by (visual_items_id, style_hash), value Arc<Vec<ShapedItem>>.
  • per_item_shaped. Caches Stage 3 (incremental). Keyed by hash(text, bidi_level, script, style.layout_hash()), value Arc<PerItemShapedEntry>.

Stage 3 has two levels: a fast monolithic cache hit returns the entire Vec<ShapedItem> if the visual-items + style hashes match. On a miss, shape_visual_items_with_per_item_cache reuses individual cached items per-key (keyed on text + bidi level + script + layout-affecting style) and only re-shapes new items. Eviction runs every layout pass via begin_generation:

pub fn begin_generation(&mut self) {
    if self.generation > 0 && !self.per_item_accessed.is_empty() {
        let accessed = &self.per_item_accessed;
        self.per_item_shaped.retain(|k, _| accessed.contains(k));
    }
    self.per_item_accessed.clear();
    self.generation += 1;
}

The cap is PER_ITEM_CACHE_MAX = 4096; exceeding it forces a generation flush early.

InlineContent and LogicalItem

InlineContent is the externally-visible inline-level „atom“:

pub enum InlineContent {
    Text(StyledRun),
    Image(InlineImage),
    Space(SpaceConfig),
    LineBreak(LineBreakConfig),
    Tab { style: Arc<StyleProperties> },
    Marker { run: StyledRun, position_outside: bool },
    Shape(InlineShape),
    Ruby { base: Vec<InlineContent>, text: Vec<InlineContent>, style: Arc<StyleProperties> },
}

StyledRun carries a String plus an Arc<StyleProperties> (font selectors, size, weight, decoration, color). Arc makes per-item cache entries cheap to share between similar runs.

Stage 1 (create_logical_items) splits Text runs by script boundaries, applies style_overrides (per-character style changes for selection, IME preedit, search highlighting), and tags each LogicalItem with the source span and style.

BiDi (Stage 2)

reorder_logical_items runs Unicode BiDi (UAX #9) using the unicode-bidi crate. The base direction comes from CSS direction, except when unicode-bidi: plaintext is set:

let base_direction = if unicode_bidi_val == UnicodeBidi::Plaintext {
    let has_strong = logical_items.iter().any(|item| {
        if let LogicalItem::Text { text, .. } = item {
            matches!(unicode_bidi::get_base_direction(text.as_str()),
                Direction::Ltr | Direction::Rtl)
        } else { false }
    });
    if has_strong { get_base_direction_from_logical(&logical_items) }
    else { first_constraints.direction.unwrap_or(BidiDirection::Ltr) }
} else {
    first_constraints.direction.unwrap_or(BidiDirection::Ltr)
};

CSS Writing Modes § 8.3: plaintext auto-detects from the first strong character; empty paragraphs fall back to the containing block's direction.

Shaping (Stage 3)

shape_visual_items and shape_visual_items_with_per_item_cache drive the shaper through the ParsedFontTrait abstraction. The default implementation uses allsorts for OpenType shaping with HarfBuzz-equivalent ligatures, kerning, contextual forms, and complex script support.

Font fallback: shaping a cluster goes through a FontFallbackChain resolved from the cluster's script + style. Each fallback level is checked for codepoint coverage; the first font that covers all codepoints in the cluster wins. The fallback chain is built once per (font-family, weight, style) stack by collect_and_resolve_font_chains_with_registration and cached on FontManager.font_chain_cache. The full resolution pipeline is described in Text Pipeline.

ShapedItem variants:

pub enum ShapedItem {
    Cluster(ShapedCluster),
    Object { ... },
    CombinedBlock { ... },
    Tab { ... },
    Break { ... },
}

ShapedCluster.source_node_id: Option<NodeId> lets selection and editing map glyph runs back to their source DOM node. Object and other generated items lack a direct source_node_id; the IFC's ContentIndex mapping recovers it.

Text-orientation transform (Stage 4)

For writing-mode: vertical-rl/vertical-lr and text-orientation: upright | sideways | mixed, glyph clusters are rotated and offset before line breaking. The transform uses constraints from the first fragment only; multi-fragment flows with mixed writing modes are noted as a TODO in text3/cache.rs.

Line breaking and flow (Stage 5)

text3/knuth_plass.rs implements Knuth–Plass total-fit line breaking. The breaker walks ShapedItems, accumulating „boxes“ (clusters) and „glue“ (spaces), then minimises a total-badness metric across all line-break combinations. Tightness, looseness, and text-wrap: balance are all knobs in the badness function.

perform_fragment_layout runs once per LayoutFragment (one fragment per column or per page). A BreakCursor tracks where the previous fragment stopped; the next fragment picks up from that cursor. This is how multi-column and paged inline layout works without re-shaping.

UnifiedLayout is the output:

pub struct UnifiedLayout {
    pub items: Vec<PositionedItem>,
    pub bounds: LogicalRect,
    pub line_count: usize,
    pub baseline_offsets: Vec<f32>,
    // ...
}

pub struct PositionedItem {
    pub item: ShapedItem,
    pub position: LogicalPosition,
    pub line_index: u32,
    pub bidi_level: u8,
    // ...
}

UnifiedLayout is wrapped in Arc and stored on the IFC root's LayoutNode.warm.inline_layout_result: Option<Arc<CachedInlineLayout>> (see Layout).

UnifiedConstraints

The full per-IFC layout input. Built by layout_ifc from CSS getters on the IFC root:

pub struct UnifiedConstraints {
    pub shape_boundaries: Vec<ShapeBoundary>,
    pub shape_exclusions: Vec<ShapeBoundary>,
    pub available_width: AvailableSpace,
    pub available_height: Option<f32>,
    pub writing_mode: Option<WritingMode>,
    pub direction: Option<BidiDirection>,
    pub text_orientation: TextOrientation,
    pub text_align: TextAlign,
    pub text_justify: JustifyContent,
    pub line_height: LineHeight,
    pub vertical_align: VerticalAlign,
    pub strut_ascent: f32,
    pub strut_descent: f32,
    pub strut_x_height: f32,
    pub ch_width: f32,
    pub overflow: OverflowBehavior,
    pub segment_alignment: SegmentAlignment,
    pub text_combine_upright: Option<TextCombineUpright>,
    pub exclusion_margin: f32,
    pub hyphenation: Hyphens,
    pub hyphenation_language: Option<Language>,
    pub text_indent: f32,
    pub text_indent_each_line: bool,
    pub text_indent_hanging: bool,
    pub initial_letter: Option<InitialLetter>,
    pub line_clamp: Option<NonZeroUsize>,
    pub text_wrap: TextWrap,
    pub columns: u32,
    pub column_gap: f32,
    pub hanging_punctuation: bool,
    pub overflow_wrap: OverflowWrap,
    pub text_align_last: TextAlign,
    pub word_break: WordBreak,
    pub white_space_mode: WhiteSpaceMode,
    pub line_break: LineBreakStrictness,
    pub unicode_bidi: UnicodeBidi,
}

available_width: AvailableSpace is the cache-validity key. A layout shaped under MinContent cannot be reused for Definite(actual_column_width) — the line breaks would be at the wrong positions. This was the root cause of the table-cell width bug fixed by storing constraints alongside the layout in CachedInlineLayout. AvailableSpace::default() returns MaxContent, never Definite(0.0) — a zero-width container would make every word overflow to its own line.

PartialEq on UnifiedConstraints uses round_eq for floats so jitter from CSS recomputation does not invalidate the cache. Hash uses f.round() as usize for the same reason.

FontManager and the font chain cache

FontManager<T> is parameterised over the parsed-font type (FontRef for production, MockFont for tests).

pub struct FontManager<T> {
    pub fc_cache: FcFontCache,
    pub parsed_fonts: Arc<Mutex<HashMap<FontId, T>>>,
    pub font_chain_cache: HashMap<FontChainKey, FontFallbackChain>,
    pub embedded_fonts: Mutex<HashMap<u64, FontRef>>,
    pub font_hash_to_families: HashMap<u64, StyleFontFamilyVec>,
    pub registry: Option<Arc<FcFontRegistry>>,
    pub last_resolved_font_stacks_sig: Option<u64>,
}

fc_cache is a rust-fontconfig v4.1 shared handle (internally Arc<RwLock>); cloning is cheap and builder-thread writes are immediately visible. registry is the optional scout-on-demand path: when present, chain resolution lazy-parses families the DOM needs, dropping peak RSS by the common-stack metadata size (~15 MiB on macOS) for headless renders that don't touch every system font.

last_resolved_font_stacks_sig is the rolling-hash signature of compact_cache.prev_font_hashes at the moment the chain cache was last populated. LayoutWindow.layout_dom_recursive reads this to skip the resolver when the DOM's font stacks haven't changed since the last successful resolution.

FontContext is the application-wide shared font state — owned by App. FontManager is the per-window one — owned by LayoutWindow. They share the same parsed_fonts Arc. FontContext::pre_resolve_chains_for_dom is the warmup hook: a headless renderer or PDF generator can pre-resolve all font chains for a DOM before the first layout, avoiding a layout-time spike. The function uses scripts_present_in_styled_dom to limit Unicode-fallback fonts to the scripts actually present — for an ASCII-only page, this skips the ~300 MiB Arial-Unicode / CJK / Arabic pull-in entirely.

Hyphenation

Behind feature = "text_layout_hyphenation". Uses the hyphenation crate with TeX patterns. Languages are loaded lazily; each UnifiedConstraints carries hyphenation: Hyphens (Auto/None/Manual) and hyphenation_language: Option<Language>. Stage 5 inserts soft-hyphen break opportunities into the Knuth–Plass break list before line breaking.

When the feature is off, text3::cache::Standard becomes a no-op stub returning empty breaks, so the rest of the pipeline compiles unchanged.

Selection

Selection types live alongside text3 and in azul-core:

  • TextCursor { cluster_id: GraphemeClusterId, affinity: CursorAffinity } — locates a cursor between two grapheme clusters, with affinity choosing the visual side at line breaks.
  • SelectionRange { anchor, focus } — same TextCursor type at both ends.
  • ContentIndex — a (run_index, cluster_offset) pair indexed against a UnifiedLayout. Maps cleanly to a (NodeId, byte_offset) via ShapedCluster.source_node_id.

hit_test_cursor_position(layout, point) returns the TextCursor at a screen position. cursor_to_pixel_position(layout, cursor) is the inverse, used to draw the caret. Both walk layout.items in source order.

Editing

text3/edit.rs operates directly against UnifiedLayout:

  • apply_text_changeset(&mut layout, changeset) mutates the items vec for a stream of inserts/deletes given as cluster-indexed operations.
  • recompute_line_breaks(&mut layout, available_width) reruns Knuth–Plass over the modified items without re-shaping unaffected clusters.

This is the fast path used by LayoutWindow::try_incremental_text_relayout for keystroke-by-keystroke text edits. It bypasses solver3::layout_document entirely when the IFC's height does not change. If the height changes (e.g. the line wraps), the path falls back to a normal layout_document call so the BFC parent can reposition siblings.

DirtyTextNode holds the in-progress Vec<InlineContent> for an edited text node before it's committed back into the DOM:

pub struct DirtyTextNode {
    pub content: Vec<InlineContent>,
    pub cursor: Option<TextCursor>,
    pub needs_ancestor_relayout: bool,
}

needs_ancestor_relayout = true means the IFC's height changed and the parent BFC needs to re-flow.

IME preedit injection

LayoutWindow.pre_preedit_content: Option<Vec<InlineContent>> stores a snapshot of the pre-edit inline content. When IME preedit text changes (e.g. during CJK composition), the renderer injects the preedit text into a clean copy of the original content, preserving the user's existing input. Without the snapshot, repeated setMarkedText calls would accumulate stale preedits.

LayoutContext.preedit_text: Option<String> is the per-render preedit string. cursor_locations: Vec<(DomId, NodeId, TextCursor)> carries multi-cursor positions for both visible cursors and preedit anchors.

Layout-vs-render style equivalence

StyleProperties::layout_eq compares only the fields that affect glyph positions (font, size, letter-spacing, word-spacing). Color, decoration, background, and shadow are not compared. TextShapingCache::use_old_layout uses this to decide whether a cached layout can be reused when constraints plus content match but rendering-only properties changed:

pub fn use_old_layout(
    old_constraints: &UnifiedConstraints,
    new_constraints: &UnifiedConstraints,
    old_content: &[InlineContent],
    new_content: &[InlineContent],
) -> bool;

A pure color change on a paragraph thus keeps the same UnifiedLayout and only triggers display-list regeneration.

The IFC call site

layout_ifc is the bridge from box layout to text layout. It:

  1. Resolves the IFC root's DOM ID (anonymous boxes inherit from parent or first child with a DOM id).
  2. Walks the IFC tree to collect Vec<InlineContent> and a child_map: BTreeMap<NodeIndex, ContentRange> so glyph clusters can be mapped back to layout nodes for hit-testing.
  3. Checks for a cached CachedInlineLayout with matching constraints. If present and available_width + has_floats match, returns it without re-running stages 1–5.
  4. Builds UnifiedConstraints from CSS and LayoutConstraints.
  5. Calls text_cache.layout_flow.
  6. Builds CachedInlineLayout::new_with_constraints and stores it on the IFC root's warm.inline_layout_result.
  7. Returns a LayoutOutput with the IFC's bounds and per-child positions for inline-blocks.

The first ~80 lines of layout_ifc are the cache-hit fast path; full execution starts at the „Phase 1: Collect and measure all inline-level children“ comment.

Known gaps vs CSS Inline Layout Module Level 3

  • § 3.3 initial-letter (drop caps) — types in place, layout not wired.
  • § 4 vertical-align — only baseline supported. top, middle, bottom, text-top, text-bottom, super, sub use approximate offsets; full table-cell/inline-block alignment is incomplete.
  • § 6 text-box-trim / leading-trim — not implemented.
  • Multi-fragment text orientation (mixed writing modes across columns) uses constraints from the first fragment only.
  • Ruby layout: the Ruby variant exists but baseline alignment of base+text is approximate.

Coming Up Next

  • Text Pipeline — font discovery, parsing, fallback chains
  • Fragmentation — page breaks, widows, orphans, paged media
  • Layout — solver3, formatting contexts, the per-frame relayout cycle

Back to guide index