1
//! §3.2 DENSE TEXT MODEL — the compact-record types of
2
//! `scripts/SHAPED_TEXT_REFACTOR_PLAN.md`, introduced ALONGSIDE the
3
//! current `PositionedItem`/`ShapedCluster` model (campaign step 1).
4
//!
5
//! Nothing consumes these yet: this module stakes the types, their size
6
//! pins live in `tests/struct_sizes.rs`, and [`DenseText::from_unified`]
7
//! is the bridge that lets consumers migrate one at a time while the
8
//! equivalence test (`text3_dense_equivalence.rs`) proves the conversion
9
//! loses nothing the current model knows. The plan's own gates (T1
10
//! source-reproduction + id-integrity, T2 cache identity, the shaping
11
//! goldens) all pin the semantics this model must preserve.
12
//!
13
//! Per-record budget vs the current model, from the plan's §3.5/§3.6
14
//! arithmetic: `ClusterCompact` is 16 B against today's ~200 B
15
//! `PositionedItem` chain, with run-level data amortised over ~42
16
//! clusters/run and per-glyph detail only where ligatures / marks /
17
//! GPOS offsets actually occur.
18

            
19
use alloc::sync::Arc;
20
use alloc::vec::Vec;
21

            
22
use azul_core::{
23
    dom::NodeId,
24
    geom::{LogicalPosition, LogicalSize},
25
    ui_solver::GlyphInstance,
26
};
27

            
28
use super::cache::{
29
    BidiDirection, ClusterFlags, LayoutFontMetrics, LoadedFonts, ParsedFontTrait, PositionedItem,
30
    ShapedItem, StyleProperties, UnifiedLayout,
31
};
32
use super::glyphs::{PdfGlyphRun, PdfPositionedGlyph, PositionedGlyph, SimpleGlyphRun};
33
use super::cache::Point;
34
use crate::text3::script::Script;
35

            
36
/// One per shaped cluster. Dense, POD, no Drop glue, no owned heap.
37
#[repr(C)]
38
#[derive(Debug, Copy, Clone, PartialEq)]
39
pub struct ClusterCompact {
40
    /// Glyph to draw when the cluster has no detail entry.
41
    pub glyph_id: u16,
42
    /// Precomputed classification — the same word the retained cluster
43
    /// carries since 48c9bbcdf.
44
    pub flags: ClusterFlags,
45
    /// The cluster's BASE advance — equal to the sparse cluster's
46
    /// `advance` and to `ShapedItem::bounds().width` (d2 redefinition;
47
    /// was kerning-folded). Sound because a kerned cluster ALWAYS has a
48
    /// detail entry (`needs_detail` includes kerning != 0), and every
49
    /// walker derives detail-cluster pens from `DetailGlyph.advance`
50
    /// (kerning-folded there), never from this field.
51
    pub advance: f32,
52
    /// == `GraphemeClusterId::start_byte_in_run`; the run supplies
53
    /// `source_run`, so the id reconstructs exactly.
54
    pub start_byte: u32,
55
    /// Inline-axis position within the IFC; `y` comes from the line.
56
    pub x: f32,
57
}
58

            
59
/// One per shaped RUN (~one per 42 clusters on the measured corpus):
60
/// everything that is uniform across a run, amortised.
61
#[derive(Debug, Clone)]
62
pub struct DenseRun {
63
    pub style: Arc<StyleProperties>,
64
    pub font_hash: u64,
65
    /// ONE copy per run (today: one 32-B copy per GLYPH).
66
    pub font_metrics: LayoutFontMetrics,
67
    pub source_run: u32,
68
    /// Dense index into the DOM node table; `u32::MAX` = none.
69
    pub source_node: u32,
70
    /// The run's source text, shared — the single copy that replaces
71
    /// every per-cluster `String`.
72
    pub text: Arc<str>,
73
    /// Range into [`DenseText::clusters`].
74
    pub clusters: core::ops::Range<u32>,
75
    /// (d6h) Reconstructs a cluster's `source_content_index.item_index`:
76
    /// `item_base + start_byte` when [`Self::item_linear`], else
77
    /// `item_base` verbatim. (0/linear for plain runs; the segment offset
78
    /// for override-segmented runs, closing the §10 "item-index-blind"
79
    /// gap.)
80
    pub item_base: u32,
81
    /// (#25b) Which item-index model this run follows. `true` = the
82
    /// linear post-layout model (`item_base + start_byte`); `false` =
83
    /// CONSTANT `item_base` for every cluster — the shape produced by
84
    /// paths that never restamp `item_index` per cluster. Before this
85
    /// flag, a constant-index run DEGENERATED into one run per cluster
86
    /// (the linear delta changed every cluster), ~25 KB/IFC of `DenseRun`
87
    /// headers for zero information.
88
    pub item_linear: bool,
89
    pub script: Script,
90
    /// Bidi direction — uniform per run BY CONSTRUCTION (the builder
91
    /// splits on it): mixed-bidi text in one styled run must not share a
92
    /// dense run, both for the PDF walker's run predicate and for RTL
93
    /// border-fragment assignment.
94
    pub direction: BidiDirection,
95
    /// The item's own solved block-axis position (`PositionedItem.position.y`),
96
    /// amortised per run because the builder splits on it.
97
    ///
98
    /// This exists because "y comes from the line" is FALSE. `LineRecord`
99
    /// stores one y, frozen from whichever cluster opened the line, and every
100
    /// walker that reconstructed a baseline as `line.top_y + ascent` silently
101
    /// assumed every cluster on the line shared that y. A line mixing font
102
    /// sizes breaks it: the taller run sits on a different baseline, and its
103
    /// glyphs were emitted 12.8px off at 16px/32px (and 1.98px off on the
104
    /// real-world mixed-font line that exposed this). The sparse reference
105
    /// always used the ITEM's own `position.y`; this is that value, kept at
106
    /// run granularity so the amortisation still holds — runs are ~1 per 42
107
    /// clusters, and y changes exactly where the run already splits (style,
108
    /// font metrics), so in practice this costs no extra runs at all.
109
    pub y: f32,
110
}
111

            
112
/// One per line: replaces per-cluster `line_index` + `position.y`.
113
#[derive(Debug, Clone, Copy, PartialEq)]
114
pub struct LineRecord {
115
    pub clusters: (u32, u32),
116
    pub baseline_y: f32,
117
    pub top_y: f32,
118
    pub height: f32,
119
    /// The source `PositionedItem::line_index` — kept because record
120
    /// ORDINALS diverge from it when a line carries no clusters (only
121
    /// objects), and the PDF walker reports/breaks on the source index.
122
    pub source_index: u32,
123
}
124

            
125
/// Detail entry for clusters that need more than one glyph or non-zero
126
/// GPOS offsets (ligatures, combining marks, kashida).
127
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
128
pub struct ClusterDetail {
129
    pub cluster: u32,
130
    pub glyphs: (u32, u32),
131
    /// (d4) The cluster's SOURCE byte length. Needed because a
132
    /// ligature-fused cluster spans multiple graphemes, so
133
    /// "next grapheme boundary" under-measures it; simple clusters
134
    /// reconstruct their length grapheme-exactly (T1) and stay 16 B.
135
    pub byte_len: u32,
136
}
137

            
138
#[repr(C)]
139
#[derive(Debug, Copy, Clone, PartialEq)]
140
pub struct DetailGlyph {
141
    pub glyph_id: u16,
142
    pub cluster_offset: u16,
143
    /// Advance incl. kerning.
144
    pub advance: f32,
145
    pub offset_x: f32,
146
    pub offset_y: f32,
147
    /// (d6h) The kerning HALF of `advance` — walkers keep consuming the
148
    /// folded `advance`; the sparse expander un-folds via this.
149
    pub kerning: f32,
150
    /// (d6h) `ShapedGlyph::kind` — non-Character kinds (hyphen …) force
151
    /// a detail entry so the expander can reproduce them.
152
    pub kind: super::cache::GlyphKind,
153
    /// (d6h) Vertical-text pair, exact-roundtrip completeness.
154
    pub vertical_advance: f32,
155
    pub vertical_offset_x: f32,
156
    pub vertical_offset_y: f32,
157
}
158

            
159
/// The dense view of a laid-out IFC. Step 1: derived FROM the current
160
/// `UnifiedLayout`; later steps make this the source of truth.
161
#[derive(Debug, Clone, Default)]
162
pub struct DenseText {
163
    pub clusters: Vec<ClusterCompact>,
164
    pub runs: Vec<DenseRun>,
165
    pub lines: Vec<LineRecord>,
166
    pub details: Vec<ClusterDetail>,
167
    pub detail_glyphs: Vec<DetailGlyph>,
168
}
169

            
170
impl Default for LineRecord {
171
    fn default() -> Self {
172
        Self { clusters: (0, 0), baseline_y: 0.0, top_y: 0.0, height: 0.0, source_index: 0 }
173
    }
174
}
175

            
176
/// (#25b) Finalize a closing run's item-index model from the surviving
177
/// viability bases. Linear preferred when both hold (single-cluster runs
178
/// then keep the pre-#25b encoding byte-for-byte). At least one base is
179
/// always `Some` — the split above opens a fresh run (both seeded) the
180
/// moment neither model fits.
181
177170
fn close_item_model(r: &mut DenseRun, linear: Option<u32>, constant: Option<u32>) {
182
177170
    match (linear, constant) {
183
10284
        (Some(b), _) => {
184
10284
            r.item_base = b;
185
10284
            r.item_linear = true;
186
10284
        }
187
166886
        (None, Some(b)) => {
188
166886
            r.item_base = b;
189
166886
            r.item_linear = false;
190
166886
        }
191
        (None, None) => {
192
            debug_assert!(false, "a run closed with no surviving item model");
193
            r.item_base = 0;
194
            r.item_linear = true;
195
        }
196
    }
197
177170
}
198

            
199
impl DenseText {
200
    /// Build the dense view from the current model. Clusters keep their
201
    /// item order; runs split where (style Arc identity, `font_hash` of the
202
    /// first glyph, `source_run`, `source_node`) change; lines come from the
203
    /// items' `line_index`. Non-cluster items (objects, breaks, combined
204
    /// blocks, tabs) are SKIPPED here — they stay on the sparse side per
205
    /// the plan's `AtomicItem` design and migrate in a later step.
206
    #[must_use]
207
150578
    pub fn from_unified(layout: &UnifiedLayout) -> Self {
208
150578
        Self::from_unified_with_content(layout, &[])
209
150578
    }
210

            
211
    /// Identical to [`Self::from_unified`] — the `content` parameter is
212
    /// IGNORED since 3c: every cluster carries its logical item's shared
213
    /// text Arc (`ShapedCluster::source_text`), which is the text its
214
    /// `start_byte` actually indexes (correct for override-segmented
215
    /// runs, where the old `content.get(source_run)` mapping was not).
216
    /// Kept for signature compatibility with the gate tests.
217
    #[must_use]
218
150983
    pub fn from_unified_with_content(
219
150983
        layout: &UnifiedLayout,
220
150983
        content: &[super::cache::InlineContent],
221
150983
    ) -> Self {
222
150983
        let _ = content;
223
150983
        let mut dense = Self::default();
224
150983
        let mut current_run: Option<DenseRun> = None;
225
150983
        let mut current_line: Option<(usize, LineRecord)> = None;
226
        // The current run's resolved line height — the d3 fill for
227
        // `LineRecord.height` (max over the line's clusters).
228
150983
        let mut current_run_lh = 0.0f32;
229
        // (#25b) The open run's still-viable item-index models: LINEAR
230
        // base (item_index − start_byte of the seed cluster) and CONSTANT
231
        // base (the seed's item_index). `None` = that model already broke
232
        // mid-run. Finalized into the run at close by `close_item_model`.
233
150983
        let mut item_run_linear_base: Option<u32> = None;
234
150983
        let mut item_run_const_base: Option<u32> = None;
235

            
236
2384104
        for item in &layout.items {
237
2233121
            let PositionedItem { item: shaped, position, line_index } = item;
238
2233121
            let ShapedItem::Cluster(c) = shaped else {
239
401
                continue;
240
            };
241
2232720
            let first_glyph = c.glyphs.first();
242
2232720
            let font_hash = first_glyph.map_or(0, |g| g.font_hash);
243
2232720
            let font_metrics = first_glyph.map_or(
244
2232720
                LayoutFontMetrics {
245
2232720
                    ascent: 0.0,
246
2232720
                    descent: 0.0,
247
2232720
                    cap_height: None,
248
2232720
                    x_height: None,
249
2232720
                    line_gap: 0.0,
250
2232720
                    units_per_em: 0,
251
2232720
                },
252
                |g| g.font_metrics,
253
            );
254
2232720
            let source_node = c
255
2232720
                .source_node_id
256
2232720
                .map_or(u32::MAX, |n| u32::try_from(n.index()).unwrap_or(u32::MAX));
257
2232720
            let script = first_glyph.map_or(Script::Latin, |g| g.script);
258
2232720
            let cluster_index = u32::try_from(dense.clusters.len()).unwrap_or(u32::MAX);
259

            
260
            // Run split on any amortised-field change. The source-text
261
            // Arc identity is part of the predicate since 3c: clusters
262
            // carry their LOGICAL ITEM's shared Arc (offset-correct for
263
            // override-segmented runs, where the old content.get(run)
264
            // mapping was wrong — §10 finding 1), and one item's clusters
265
            // all share one Arc by construction.
266
            // (d6h/#25b) Item-index model viability for the OPEN run.
267
            // Two models can reconstruct item_index: LINEAR
268
            // (item_base + start_byte — the post-layout restamped shape)
269
            // and CONSTANT (item_base verbatim — paths that never restamp
270
            // per cluster). The run stays open while EITHER still holds;
271
            // close picks linear when both do (single-cluster runs keep
272
            // today's encoding). Before this, a constant-index run split
273
            // on EVERY cluster (the linear delta changed each time) —
274
            // one DenseRun header per cluster for zero information.
275
            // wrapping arithmetic keeps a malformed (item_index <
276
            // start_byte) pair from panicking — the split still isolates
277
            // it when neither model fits.
278
2232720
            let item_index = c.source_content_index.item_index;
279
2232720
            let start_byte = c.source_cluster_id.start_byte_in_run;
280
2232720
            let fits_linear = item_run_linear_base
281
2232720
                .is_some_and(|b| item_index == b.wrapping_add(start_byte));
282
2232720
            let fits_const = item_run_const_base.is_some_and(|b| item_index == b);
283
2232720
            let split = match &current_run {
284
150698
                None => true,
285
2082022
                Some(r) => {
286
2082022
                    !Arc::ptr_eq(&r.style, &c.style)
287
2081355
                        || !Arc::ptr_eq(&r.text, &c.source_text)
288
2080086
                        || r.font_hash != font_hash
289
2079591
                        || r.source_run != c.source_cluster_id.source_run
290
2079591
                        || r.source_node != source_node
291
2079591
                        || r.direction != c.direction
292
                        // The item's own y is amortised on the run, so a
293
                        // change in it MUST open a new run — otherwise the
294
                        // run's y would silently describe only its first
295
                        // cluster, which is the per-line freeze one level down.
296
2079591
                        || (r.y - position.y).abs() > 0.001
297
2055550
                        || (!fits_linear && !fits_const)
298
                }
299
            };
300
2232720
            if split {
301
177170
                if let Some(mut r) = current_run.take() {
302
26472
                    r.clusters.end = cluster_index;
303
26472
                    close_item_model(
304
26472
                        &mut r,
305
26472
                        item_run_linear_base,
306
26472
                        item_run_const_base,
307
26472
                    );
308
26472
                    dense.runs.push(r);
309
150698
                }
310
                // Fresh run: both models start viable, seeded from this
311
                // cluster.
312
177170
                item_run_linear_base = Some(item_index.wrapping_sub(start_byte));
313
177170
                item_run_const_base = Some(item_index);
314
177170
                current_run_lh = if font_metrics.units_per_em == 0 {
315
22
                    0.0
316
                } else {
317
177148
                    c.style
318
177148
                        .line_height
319
177148
                        .resolve_with_metrics(c.style.font_size_px, &font_metrics)
320
                };
321
177170
                current_run = Some(DenseRun {
322
177170
                    style: c.style.clone(),
323
177170
                    font_hash,
324
177170
                    font_metrics,
325
177170
                    source_run: c.source_cluster_id.source_run,
326
177170
                    source_node,
327
177170
                    // The cluster's own shared source Arc (3c) — the text
328
177170
                    // `ClusterCompact.start_byte` actually indexes into,
329
177170
                    // for EVERY case including override segments.
330
177170
                    text: c.source_text.clone(),
331
177170
                    clusters: cluster_index..cluster_index,
332
177170
                    // Placeholders — `close_item_model` writes the real
333
177170
                    // values from the surviving model at run close.
334
177170
                    item_base: 0,
335
177170
                    item_linear: true,
336
177170
                    script,
337
177170
                    direction: c.direction,
338
177170
                    y: position.y,
339
177170
                });
340
            } else {
341
                // Staying in the run: a cluster that fits only one model
342
                // permanently kills the other (viability is monotonic —
343
                // a broken model cannot come back later in the run).
344
2055550
                if !fits_linear {
345
2055523
                    item_run_linear_base = None;
346
2055523
                }
347
2055550
                if !fits_const {
348
18
                    item_run_const_base = None;
349
2055532
                }
350
            }
351

            
352
            // Line records from line_index transitions.
353
2082022
            match &mut current_line {
354
2082022
                Some((idx, rec)) if *idx == *line_index => {
355
2057900
                    rec.clusters.1 = cluster_index + 1;
356
2057900
                    rec.height = rec.height.max(current_run_lh);
357
2057900
                }
358
                _ => {
359
174820
                    if let Some((_, rec)) = current_line.take() {
360
24122
                        dense.lines.push(rec);
361
150698
                    }
362
174820
                    current_line = Some((
363
174820
                        *line_index,
364
174820
                        LineRecord {
365
174820
                            clusters: (cluster_index, cluster_index + 1),
366
174820
                            baseline_y: position.y,
367
174820
                            top_y: position.y,
368
174820
                            // d3: filled with the max resolved line height of
369
174820
                            // the line's clusters (was always 0.0).
370
174820
                            height: current_run_lh,
371
174820
                            source_index: u32::try_from(*line_index).unwrap_or(u32::MAX),
372
174820
                        },
373
174820
                    ));
374
                }
375
            }
376

            
377
            // Detail side table for multi-glyph / offset clusters —
378
            // (d6h) non-Character kinds and vertical metrics also force
379
            // an entry so the sparse expander loses nothing.
380
            //
381
            // A cluster without a detail entry is reconstructed ENTIRELY from
382
            // the compact record, and the compact record has no byte length —
383
            // `cluster_byte_len` falls back to "the next grapheme at
384
            // start_byte". So a detail is also required whenever the cluster's
385
            // true `source_byte_len` is NOT that grapheme length. The case
386
            // that makes this reachable is a LIGATURE: "fi" fused into
387
            // exactly one glyph with no offsets, no kerning, kind Character —
388
            // satisfying none of the other clauses — while spanning TWO
389
            // graphemes. Without this clause every such cluster silently
390
            // shrank to its first grapheme on the way through the dense
391
            // model, and pdftotext read "Confgure" out of documents that
392
            // said "Configure". `ShapedCluster::source_byte_len`'s doc states
393
            // the invariant: "Stored, not re-derived: ligature-fused clusters
394
            // span MULTIPLE graphemes, so 'next grapheme boundary' cannot
395
            // reconstruct the slice in general." This mirrors the READ-side
396
            // fallback exactly, so predicate and fallback cannot disagree.
397
2232720
            let compact_len_reconstructible = {
398
                use unicode_segmentation::UnicodeSegmentation;
399
2232720
                let start = c.source_cluster_id.start_byte_in_run as usize;
400
2232720
                let grapheme_len = c
401
2232720
                    .source_text
402
2232720
                    .get(start..)
403
2232720
                    .and_then(|s| s.graphemes(true).next())
404
2232720
                    .map_or(0, str::len);
405
2232720
                usize::from(c.source_byte_len) == grapheme_len
406
            };
407
2232720
            let needs_detail = c.glyphs.len() != 1
408
2232710
                || !compact_len_reconstructible
409
2231601
                || c.glyphs.first().is_some_and(|g| {
410
2231601
                    g.offset.x != 0.0
411
2231601
                        || g.offset.y != 0.0
412
2231601
                        || g.kerning != 0.0
413
2196460
                        || g.kind != super::cache::GlyphKind::Character
414
2196424
                        || g.vertical_advance != 0.0
415
2196370
                        || g.vertical_offset.x != 0.0
416
2196370
                        || g.vertical_offset.y != 0.0
417
2231601
                });
418
2232720
            if needs_detail {
419
36350
                let start = u32::try_from(dense.detail_glyphs.len()).unwrap_or(u32::MAX);
420
72692
                for g in &c.glyphs {
421
36342
                    dense.detail_glyphs.push(DetailGlyph {
422
36342
                        glyph_id: g.glyph_id,
423
36342
                        cluster_offset: u16::try_from(g.cluster_offset).unwrap_or(u16::MAX),
424
36342
                        advance: g.advance + g.kerning,
425
36342
                        offset_x: g.offset.x,
426
36342
                        offset_y: g.offset.y,
427
36342
                        kerning: g.kerning,
428
36342
                        kind: g.kind,
429
36342
                        vertical_advance: g.vertical_advance,
430
36342
                        vertical_offset_x: g.vertical_offset.x,
431
36342
                        vertical_offset_y: g.vertical_offset.y,
432
36342
                    });
433
36342
                }
434
36350
                let end = u32::try_from(dense.detail_glyphs.len()).unwrap_or(u32::MAX);
435
36350
                dense.details.push(ClusterDetail {
436
36350
                    cluster: cluster_index,
437
36350
                    glyphs: (start, end),
438
36350
                    byte_len: u32::from(c.source_byte_len),
439
36350
                });
440
2196370
            }
441

            
442
            // (d6h) Bits 7-10 pack the ShapedCluster fields the compact
443
            // record has no room for — DENSE-SIDE ONLY (classify() never
444
            // sets them; equivalence pins mask them off).
445
2232720
            let mut packed = c.flags.0;
446
2232720
            if c.is_first_fragment {
447
2232693
                packed |= ClusterFlags::DENSE_IS_FIRST_FRAGMENT;
448
2232693
            }
449
2232720
            if c.is_last_fragment {
450
2232693
                packed |= ClusterFlags::DENSE_IS_LAST_FRAGMENT;
451
2232693
            }
452
2232720
            if let Some(outside) = c.marker_position_outside {
453
1404
                packed |= ClusterFlags::DENSE_MARKER_SOME;
454
1404
                if outside {
455
1404
                    packed |= ClusterFlags::DENSE_MARKER_OUTSIDE;
456
1404
                }
457
2231316
            }
458
2232720
            dense.clusters.push(ClusterCompact {
459
2232720
                glyph_id: first_glyph.map_or(0, |g| g.glyph_id),
460
2232720
                flags: ClusterFlags(packed),
461
2232720
                advance: c.advance,
462
2232720
                start_byte: c.source_cluster_id.start_byte_in_run,
463
2232720
                x: position.x,
464
            });
465
        }
466
150983
        if let Some(mut r) = current_run.take() {
467
150698
            r.clusters.end = u32::try_from(dense.clusters.len()).unwrap_or(u32::MAX);
468
150698
            close_item_model(&mut r, item_run_linear_base, item_run_const_base);
469
150698
            dense.runs.push(r);
470
150732
        }
471
150983
        if let Some((_, rec)) = current_line.take() {
472
150698
            dense.lines.push(rec);
473
150732
        }
474
150983
        dense
475
150983
    }
476

            
477
    /// (d4) The source byte length of cluster `ci`: the detail table's
478
    /// stored length when present (ligature clusters span multiple
479
    /// graphemes), else the grapheme at `start_byte` in the run text —
480
    /// exact for simple clusters by T1.
481
    #[must_use]
482
17847993
    pub fn cluster_byte_len(&self, ci: u32) -> u32 {
483
        use unicode_segmentation::UnicodeSegmentation;
484
17847993
        if let Ok(i) = self.details.binary_search_by_key(&ci, |d| d.cluster) {
485
486
            return self.details[i].byte_len;
486
17847507
        }
487
17847507
        let c = &self.clusters[ci as usize];
488
17847507
        let run = self
489
17847507
            .runs
490
17847507
            .iter()
491
28171135
            .find(|r| r.clusters.contains(&ci))
492
17847507
            .expect("cluster belongs to a run by construction");
493
17847507
        run.text
494
17847507
            .get(c.start_byte as usize..)
495
17847507
            .and_then(|s| s.graphemes(true).next())
496
17847507
            .map_or(0, |g| g.len() as u32)
497
17847993
    }
498

            
499
    /// (d6b) The caret-stop list — the PRIMITIVE the whole cursor-movement
500
    /// library reduces to (left/right/home/end are offset arithmetic over
501
    /// it; selection ranges bound by it). Same output as the sparse
502
    /// `UnifiedLayout::grapheme_stops`: cluster ids sorted by
503
    /// (run, byte), deduped, grapheme-continuation clusters excluded —
504
    /// via the precomputed flag instead of the text probe (the flags are
505
    /// pinned equal to the sparse classification by the base gate).
506
    #[must_use]
507
7434
    pub fn grapheme_stops(&self) -> Vec<azul_core::selection::GraphemeClusterId> {
508
        use azul_core::selection::GraphemeClusterId;
509
7434
        let mut stops: Vec<GraphemeClusterId> = self
510
7434
            .runs
511
7434
            .iter()
512
383661
            .flat_map(|r| (r.clusters.start..r.clusters.end).map(move |ci| (ci, r)))
513
383661
            .filter(|(ci, _)| {
514
383661
                !self.clusters[*ci as usize]
515
383661
                    .flags
516
383661
                    .has(ClusterFlags::GRAPHEME_CONTINUATION)
517
383661
            })
518
7434
            .map(|(ci, r)| GraphemeClusterId {
519
383202
                source_run: r.source_run,
520
383202
                start_byte_in_run: self.clusters[ci as usize].start_byte,
521
383202
            })
522
7434
            .collect();
523
751536
        stops.sort_by_key(|id| (id.source_run, id.start_byte_in_run));
524
7434
        stops.dedup();
525
7434
        stops
526
7434
    }
527

            
528
    /// (d6e) The cluster's source text slice (run text at `start_byte` for
529
    /// `cluster_byte_len` bytes) — the word-boundary predicate's input.
530
73215
    fn cluster_text_slice(&self, ci: u32) -> &str {
531
73215
        let c = &self.clusters[ci as usize];
532
73215
        let run = self
533
73215
            .runs
534
73215
            .iter()
535
239967
            .find(|r| r.clusters.contains(&ci))
536
73215
            .expect("cluster belongs to a run by construction");
537
73215
        let start = c.start_byte as usize;
538
73215
        let len = self.cluster_byte_len(ci) as usize;
539
73215
        run.text.get(start..start + len).unwrap_or("")
540
73215
    }
541

            
542
    /// (d6e) Word-boundary predicate — same as the sparse
543
    /// `cluster_is_word_boundary`: no word character in the cluster text
544
    /// (whitespace AND punctuation are boundaries).
545
73215
    fn cluster_is_word_boundary(&self, ci: u32) -> bool {
546
73215
        !self
547
73215
            .cluster_text_slice(ci)
548
73215
            .chars()
549
73215
            .any(super::cache::is_word_char)
550
73215
    }
551

            
552
    /// (d6e) Visual line start — cluster by id, min-x cluster on its
553
    /// line, Leading affinity (mirrors the sparse flow).
554
    #[must_use]
555
3915
    pub fn move_cursor_to_line_start(
556
3915
        &self,
557
3915
        cursor: azul_core::selection::TextCursor,
558
3915
    ) -> azul_core::selection::TextCursor {
559
        use azul_core::selection::{CursorAffinity, GraphemeClusterId, TextCursor};
560
3915
        let Some((_, line_ord)) = self.find_cursor_cluster(&cursor) else {
561
            return cursor;
562
        };
563
3915
        let l = &self.lines[line_ord];
564
3915
        let best = (l.clusters.0..l.clusters.1)
565
60786
            .min_by(|a, b| {
566
60786
                self.clusters[*a as usize]
567
60786
                    .x
568
60786
                    .partial_cmp(&self.clusters[*b as usize].x)
569
60786
                    .unwrap_or(core::cmp::Ordering::Equal)
570
60786
            });
571
3915
        let Some(ci) = best else { return cursor };
572
12393
        let run = self.runs.iter().find(|r| r.clusters.contains(&ci));
573
3915
        let Some(run) = run else { return cursor };
574
3915
        TextCursor {
575
3915
            cluster_id: GraphemeClusterId {
576
3915
                source_run: run.source_run,
577
3915
                start_byte_in_run: self.clusters[ci as usize].start_byte,
578
3915
            },
579
3915
            affinity: CursorAffinity::Leading,
580
3915
        }
581
3915
    }
582

            
583
    /// (d6e) Visual line end — max-x cluster on the line, Trailing.
584
    #[must_use]
585
3906
    pub fn move_cursor_to_line_end(
586
3906
        &self,
587
3906
        cursor: azul_core::selection::TextCursor,
588
3906
    ) -> azul_core::selection::TextCursor {
589
        use azul_core::selection::{CursorAffinity, GraphemeClusterId, TextCursor};
590
3906
        let Some((_, line_ord)) = self.find_cursor_cluster(&cursor) else {
591
            return cursor;
592
        };
593
3906
        let l = &self.lines[line_ord];
594
3906
        let best = (l.clusters.0..l.clusters.1)
595
60696
            .max_by(|a, b| {
596
60696
                self.clusters[*a as usize]
597
60696
                    .x
598
60696
                    .partial_cmp(&self.clusters[*b as usize].x)
599
60696
                    .unwrap_or(core::cmp::Ordering::Equal)
600
60696
            });
601
3906
        let Some(ci) = best else { return cursor };
602
12384
        let run = self.runs.iter().find(|r| r.clusters.contains(&ci));
603
3906
        let Some(run) = run else { return cursor };
604
3906
        TextCursor {
605
3906
            cluster_id: GraphemeClusterId {
606
3906
                source_run: run.source_run,
607
3906
                start_byte_in_run: self.clusters[ci as usize].start_byte,
608
3906
            },
609
3906
            affinity: CursorAffinity::Trailing,
610
3906
        }
611
3906
    }
612

            
613
    /// (d6e) Cursor at index `ci`, given affinity.
614
9522
    fn cursor_at_index(
615
9522
        &self,
616
9522
        ci: u32,
617
9522
        affinity: azul_core::selection::CursorAffinity,
618
9522
    ) -> Option<azul_core::selection::TextCursor> {
619
        use azul_core::selection::{GraphemeClusterId, TextCursor};
620
9522
        let c = self.clusters.get(ci as usize)?;
621
25317
        let run = self.runs.iter().find(|r| r.clusters.contains(&ci))?;
622
9522
        Some(TextCursor {
623
9522
            cluster_id: GraphemeClusterId {
624
9522
                source_run: run.source_run,
625
9522
                start_byte_in_run: c.start_byte,
626
9522
            },
627
9522
            affinity,
628
9522
        })
629
9522
    }
630

            
631
    /// (d6e) One word left — mirrors the sparse two-phase flow (skip
632
    /// boundary clusters, then skip the word, land Leading on its first
633
    /// cluster). Identical for pure-cluster layouts (the dense domain).
634
    #[must_use]
635
3906
    pub fn move_cursor_to_prev_word(
636
3906
        &self,
637
3906
        cursor: azul_core::selection::TextCursor,
638
3906
    ) -> azul_core::selection::TextCursor {
639
        use azul_core::selection::CursorAffinity;
640
3906
        let Some((current, _)) = self.find_cursor_cluster(&cursor) else {
641
            return cursor;
642
        };
643
3906
        let mut pos = if cursor.affinity == CursorAffinity::Leading {
644
1953
            current.checked_sub(1)
645
        } else {
646
1953
            Some(current)
647
        };
648
4347
        while let Some(p) = pos {
649
4311
            if !self.cluster_is_word_boundary(p) {
650
3870
                break;
651
441
            }
652
441
            pos = p.checked_sub(1);
653
        }
654
32472
        while let Some(p) = pos {
655
32436
            if self.cluster_is_word_boundary(p) {
656
3654
                if p + 1 < self.clusters.len() as u32 {
657
3654
                    if let Some(c) = self.cursor_at_index(p + 1, CursorAffinity::Leading) {
658
3654
                        return c;
659
                    }
660
                }
661
                break;
662
28782
            }
663
28782
            if p == 0 {
664
216
                if let Some(c) = self.cursor_at_index(0, CursorAffinity::Leading) {
665
216
                    return c;
666
                }
667
                break;
668
28566
            }
669
28566
            pos = p.checked_sub(1);
670
        }
671
36
        if pos.is_none() {
672
36
            if let Some(c) = self.cursor_at_index(0, CursorAffinity::Leading) {
673
36
                return c;
674
            }
675
        }
676
        cursor
677
3906
    }
678

            
679
    /// (d6h) The per-run resolved ascent — the baseline distance from an
680
    /// item's TOP (metrics + half-leading), the same math the walkers
681
    /// derive inline. What makes per-item `position.y` reconstructible
682
    /// on MIXED-SIZE lines: the d6h expansion gate caught that sparse
683
    /// `position.y` is per-item (baseline-aligned tops differ when font
684
    /// sizes mix), while the line record's y is only the FIRST item's.
685
    #[must_use]
686
98802
    pub fn resolved_run_ascent(run: &DenseRun) -> f32 {
687
98802
        let m = &run.font_metrics;
688
98802
        if m.units_per_em == 0 {
689
            return 0.0;
690
98802
        }
691
98802
        let scale = run.style.font_size_px / f32::from(m.units_per_em);
692
98802
        let font_ascent = m.ascent * scale;
693
98802
        let font_descent = (-m.descent * scale).max(0.0);
694
98802
        let ad = font_ascent + font_descent;
695
98802
        let lh = run
696
98802
            .style
697
98802
            .line_height
698
98802
            .resolve_with_metrics(run.style.font_size_px, m);
699
98802
        font_ascent + (lh - ad) / 2.0
700
98802
    }
701

            
702
    /// The run containing cluster `ci` (runs partition clusters in
703
    /// order, so this is a binary search).
704
    #[must_use]
705
18458822
    pub fn run_of(&self, ci: u32) -> Option<&DenseRun> {
706
37571870
        let idx = self.runs.partition_point(|r| r.clusters.end <= ci);
707
18458822
        self.runs.get(idx).filter(|r| r.clusters.contains(&ci))
708
18458822
    }
709

            
710
    /// (d6g, y-semantics fixed in d6h) The sparse `PositionedItem`
711
    /// fields for cluster `i`: `(x, y, line_index)`. `y` is the ITEM's
712
    /// top — the line record's recorded first-item top, baseline-aligned
713
    /// across mixed-size runs via the run ascents (same-run clusters
714
    /// reduce to the recorded value). `line_index` is the line's
715
    /// `source_index`. `None` when `i` is out of range.
716
    #[must_use]
717
1008
    pub fn positioned_cluster(&self, i: u32) -> Option<(f32, f32, usize)> {
718
1008
        let c = self.clusters.get(i as usize)?;
719
999
        let li = self
720
999
            .lines
721
2565
            .partition_point(|l| l.clusters.1 <= i)
722
999
            .min(self.lines.len().checked_sub(1)?);
723
999
        let line = &self.lines[li];
724
999
        if i < line.clusters.0 || i >= line.clusters.1 {
725
            return None;
726
999
        }
727
999
        let first_run = self.run_of(line.clusters.0)?;
728
999
        let my_run = self.run_of(i)?;
729
        // Same run ⟹ bit-exact recorded value (no float round-trip).
730
999
        let y = if core::ptr::eq(first_run, my_run) {
731
972
            line.baseline_y
732
        } else {
733
27
            line.baseline_y + Self::resolved_run_ascent(first_run)
734
27
                - Self::resolved_run_ascent(my_run)
735
        };
736
999
        Some((c.x, y, line.source_index as usize))
737
1008
    }
738

            
739
    /// (d6h) FULL sparse materialization: rebuild the `PositionedItem`
740
    /// vec these arrays were built from — exact (`PartialEq`) for
741
    /// pure-cluster layouts, pinned by the equivalence gate. Transient:
742
    /// the page clipper (print/PDF) expands on demand once the retained
743
    /// sparse form retires; nothing stores the result.
744
    #[must_use]
745
387697
    pub fn to_unified_items(&self) -> Vec<PositionedItem> {
746
        use super::cache::{
747
            ClusterFlags, ContentIndex, GlyphKind, GraphemeClusterId, Point, ShapedCluster,
748
            ShapedGlyph, ShapedGlyphVec, ShapedItem,
749
        };
750
387697
        let mut out = Vec::with_capacity(self.clusters.len());
751
387697
        let mut line_cursor = 0usize;
752
387697
        let mut detail_cursor = 0usize;
753
1038916
        for run in &self.runs {
754
651219
            let source_node_id =
755
651219
                (run.source_node != u32::MAX).then(|| NodeId::new(run.source_node as usize));
756
18439886
            for ci in run.clusters.clone() {
757
18439886
                let c = &self.clusters[ci as usize];
758
18697783
                while line_cursor < self.lines.len() && self.lines[line_cursor].clusters.1 <= ci {
759
257897
                    line_cursor += 1;
760
257897
                }
761
18439886
                let line = &self.lines[line_cursor.min(self.lines.len().saturating_sub(1))];
762
19124965
                while detail_cursor < self.details.len()
763
12107913
                    && self.details[detail_cursor].cluster < ci
764
685079
                {
765
685079
                    detail_cursor += 1;
766
685079
                }
767
18439886
                let detail = self.details.get(detail_cursor).filter(|d| d.cluster == ci);
768
18439886
                let glyphs: ShapedGlyphVec = match detail {
769
685106
                    Some(d) => (d.glyphs.0..d.glyphs.1)
770
685106
                        .map(|gi| {
771
685106
                            let dg = &self.detail_glyphs[gi as usize];
772
685106
                            ShapedGlyph {
773
685106
                                kind: dg.kind,
774
685106
                                glyph_id: dg.glyph_id,
775
685106
                                cluster_offset: u32::from(dg.cluster_offset),
776
685106
                                advance: dg.advance - dg.kerning,
777
685106
                                kerning: dg.kerning,
778
685106
                                offset: Point { x: dg.offset_x, y: dg.offset_y },
779
685106
                                vertical_advance: dg.vertical_advance,
780
685106
                                vertical_offset: Point {
781
685106
                                    x: dg.vertical_offset_x,
782
685106
                                    y: dg.vertical_offset_y,
783
685106
                                },
784
685106
                                script: run.script,
785
685106
                                font_hash: run.font_hash,
786
685106
                                font_metrics: run.font_metrics,
787
685106
                            }
788
685106
                        })
789
685106
                        .collect(),
790
17754780
                    None => core::iter::once(ShapedGlyph {
791
17754780
                        kind: GlyphKind::Character,
792
17754780
                        glyph_id: c.glyph_id,
793
17754780
                        cluster_offset: 0,
794
17754780
                        advance: c.advance,
795
17754780
                        kerning: 0.0,
796
17754780
                        offset: Point { x: 0.0, y: 0.0 },
797
17754780
                        vertical_advance: 0.0,
798
17754780
                        vertical_offset: Point { x: 0.0, y: 0.0 },
799
17754780
                        script: run.script,
800
17754780
                        font_hash: run.font_hash,
801
17754780
                        font_metrics: run.font_metrics,
802
17754780
                    })
803
17754780
                    .collect(),
804
                };
805
18439886
                let byte_len = detail.map_or_else(|| self.cluster_byte_len(ci), |d| d.byte_len);
806
18439886
                let f = c.flags.0;
807
18439886
                out.push(PositionedItem {
808
                    item: ShapedItem::Cluster(ShapedCluster {
809
18439886
                        source_text: run.text.clone(),
810
18439886
                        source_byte_len: u16::try_from(byte_len).unwrap_or(u16::MAX),
811
18439886
                        source_cluster_id: GraphemeClusterId {
812
18439886
                            source_run: run.source_run,
813
18439886
                            start_byte_in_run: c.start_byte,
814
18439886
                        },
815
                        source_content_index: ContentIndex {
816
18439886
                            run_index: run.source_run,
817
                            // (#25b) Two reconstruction models — see
818
                            // `DenseRun::item_linear`.
819
18439886
                            item_index: if run.item_linear {
820
6471
                                run.item_base.wrapping_add(c.start_byte)
821
                            } else {
822
18433415
                                run.item_base
823
                            },
824
                        },
825
18439886
                        source_node_id,
826
18439886
                        glyphs,
827
18439886
                        flags: ClusterFlags(f & ClusterFlags::CLASSIFY_MASK),
828
18439886
                        advance: c.advance,
829
18439886
                        direction: run.direction,
830
18439886
                        style: run.style.clone(),
831
18439886
                        marker_position_outside: (f & ClusterFlags::DENSE_MARKER_SOME != 0)
832
18439886
                            .then_some(f & ClusterFlags::DENSE_MARKER_OUTSIDE != 0),
833
18439886
                        is_first_fragment: f & ClusterFlags::DENSE_IS_FIRST_FRAGMENT != 0,
834
18439886
                        is_last_fragment: f & ClusterFlags::DENSE_IS_LAST_FRAGMENT != 0,
835
                    }),
836
                    position: Point {
837
18439886
                        x: c.x,
838
                        // (d6h) Per-item y on mixed-size lines: the
839
                        // record holds the line's FIRST item top;
840
                        // baseline-align via run ascents. Same run ⟹
841
                        // the recorded value bit-exactly.
842
18439886
                        y: match self.run_of(line.clusters.0) {
843
18439886
                            Some(fr) if !core::ptr::eq(fr, run) => {
844
41688
                                line.baseline_y + Self::resolved_run_ascent(fr)
845
41688
                                    - Self::resolved_run_ascent(run)
846
                            }
847
18398198
                            _ => line.baseline_y,
848
                        },
849
                    },
850
18439886
                    line_index: line.source_index as usize,
851
                });
852
            }
853
        }
854
387697
        out
855
387697
    }
856

            
857
    /// (d6f) The single (direction, step) dispatch over the dense
858
    /// movement library — twin of the window's `resolve_step_static`.
859
    #[must_use]
860
10458
    pub fn resolve_step(
861
10458
        &self,
862
10458
        cursor: &azul_core::selection::TextCursor,
863
10458
        direction: azul_core::events::SelectionDirection,
864
10458
        step: azul_core::events::SelectionStep,
865
10458
    ) -> azul_core::selection::TextCursor {
866
        use azul_core::events::{SelectionDirection as D, SelectionStep as S};
867
10458
        match (direction, step) {
868
1053
            (D::Backward, S::Character) => self.move_cursor_left(*cursor),
869
1044
            (D::Forward, S::Character) => self.move_cursor_right(*cursor),
870
1044
            (D::Backward, S::Word) => self.move_cursor_to_prev_word(*cursor),
871
1044
            (D::Forward, S::Word) => self.move_cursor_to_next_word(*cursor),
872
1044
            (D::Backward, S::VisualLine) => self.move_cursor_up(*cursor, &mut None),
873
1044
            (D::Forward, S::VisualLine) => self.move_cursor_down(*cursor, &mut None),
874
1053
            (D::Backward, S::Line) => self.move_cursor_to_line_start(*cursor),
875
1044
            (D::Forward, S::Line) => self.move_cursor_to_line_end(*cursor),
876
1044
            (D::Backward, S::Document) => self.first_cluster_cursor().unwrap_or(*cursor),
877
1044
            (D::Forward, S::Document) => self.last_cluster_cursor().unwrap_or(*cursor),
878
        }
879
10458
    }
880

            
881
    /// (d6e) One word right — mirrors the sparse flow (skip the current
882
    /// word, then boundary clusters, land Leading on the next word; end
883
    /// of text falls to the last cluster Trailing).
884
    #[must_use]
885
3906
    pub fn move_cursor_to_next_word(
886
3906
        &self,
887
3906
        cursor: azul_core::selection::TextCursor,
888
3906
    ) -> azul_core::selection::TextCursor {
889
        use azul_core::selection::CursorAffinity;
890
3906
        let Some((current, _)) = self.find_cursor_cluster(&cursor) else {
891
            return cursor;
892
        };
893
3906
        let len = self.clusters.len() as u32;
894
3906
        let start = if cursor.affinity == CursorAffinity::Trailing {
895
1953
            current + 1
896
        } else {
897
1953
            current
898
        };
899
3906
        if start >= len {
900
36
            return cursor;
901
3870
        }
902
3870
        let mut pos = start;
903
29286
        while pos < len && !self.cluster_is_word_boundary(pos) {
904
25416
            pos += 1;
905
25416
        }
906
7740
        while pos < len {
907
7398
            if !self.cluster_is_word_boundary(pos) {
908
3528
                if let Some(c) = self.cursor_at_index(pos, CursorAffinity::Leading) {
909
3528
                    return c;
910
                }
911
3870
            }
912
3870
            pos += 1;
913
        }
914
342
        self.last_cluster_cursor().unwrap_or(cursor)
915
3906
    }
916

            
917
    /// (d6d) Point -> cursor hit test — the SAME weighted-distance scan
918
    /// as the sparse `hittest_cursor` (vertical distance x2 + horizontal
919
    /// outside-distance; closest cluster wins; affinity by midpoint).
920
    /// Cluster geometry from the dense arrays: x/width from
921
    /// `ClusterCompact` (base advance == bounds().width), y/height from the
922
    /// line record + the run's resolved line height. Also the
923
    /// click-to-position primitive.
924
    #[must_use]
925
8532
    pub fn hittest_cursor(
926
8532
        &self,
927
8532
        point: Point,
928
8532
    ) -> Option<azul_core::selection::TextCursor> {
929
        use azul_core::selection::{CursorAffinity, GraphemeClusterId, TextCursor};
930
8532
        if self.clusters.is_empty() {
931
            return None;
932
8532
        }
933
8532
        let mut best: Option<(f32, u32, &DenseRun, f32)> = None; // (dist, ci, run, x)
934
8532
        let mut line_iter = self.lines.iter().peekable();
935
451530
        for (ci, run) in self
936
8532
            .runs
937
8532
            .iter()
938
451530
            .flat_map(|r| (r.clusters.start..r.clusters.end).map(move |i| (i, r)))
939
        {
940
451530
            let c = &self.clusters[ci as usize];
941
494712
            while let Some(l) = line_iter.peek() {
942
494712
                if ci >= l.clusters.1 {
943
43182
                    line_iter.next();
944
43182
                } else {
945
451530
                    break;
946
                }
947
            }
948
451530
            let (top_y, line_h) = line_iter.peek().map_or((0.0, 0.0), |l| (l.top_y, l.height));
949
451530
            let m = &run.font_metrics;
950
451530
            let h = if m.units_per_em == 0 {
951
                line_h
952
            } else {
953
451530
                run.style
954
451530
                    .line_height
955
451530
                    .resolve_with_metrics(run.style.font_size_px, m)
956
            };
957
451530
            let center_y = top_y + h / 2.0;
958
451530
            let vertical = (point.y - center_y).abs();
959
451530
            let horizontal = if point.x < c.x {
960
192150
                c.x - point.x
961
259380
            } else if point.x > c.x + c.advance {
962
188784
                point.x - (c.x + c.advance)
963
            } else {
964
70596
                0.0
965
            };
966
451530
            let dist = vertical.mul_add(2.0, horizontal);
967
451530
            if best.is_none_or(|(d, ..)| dist < d) {
968
113256
                best = Some((dist, ci, run, c.x));
969
338274
            }
970
        }
971
8532
        let (_, ci, run, x) = best?;
972
8532
        let c = &self.clusters[ci as usize];
973
8532
        let affinity = if point.x < x + c.advance / 2.0 {
974
2475
            CursorAffinity::Leading
975
        } else {
976
6057
            CursorAffinity::Trailing
977
        };
978
8532
        Some(TextCursor {
979
8532
            cluster_id: GraphemeClusterId {
980
8532
                source_run: run.source_run,
981
8532
                start_byte_in_run: c.start_byte,
982
8532
            },
983
8532
            affinity,
984
8532
        })
985
8532
    }
986

            
987
    /// (d6d) Locate a cursor's cluster index + its line ordinal.
988
24021
    fn find_cursor_cluster(
989
24021
        &self,
990
24021
        cursor: &azul_core::selection::TextCursor,
991
24021
    ) -> Option<(u32, usize)> {
992
79353
        for r in &self.runs {
993
79353
            if r.source_run != cursor.cluster_id.source_run {
994
                continue;
995
79353
            }
996
661941
            for ci in r.clusters.start..r.clusters.end {
997
661941
                if self.clusters[ci as usize].start_byte == cursor.cluster_id.start_byte_in_run {
998
24021
                    let line = self
999
24021
                        .lines
24021
                        .iter()
79353
                        .position(|l| ci >= l.clusters.0 && ci < l.clusters.1)?;
24021
                    return Some((ci, line));
637920
                }
            }
        }
        None
24021
    }
    /// (d6d) One line up, preserving the horizontal goal column — mirrors
    /// the sparse `move_cursor_up` flow: current cluster by id, `goal_x`
    /// seeded from affinity (Trailing = x + advance), target line's
    /// mid-height, then the weighted hit test.
    #[must_use]
4194
    pub fn move_cursor_up(
4194
        &self,
4194
        cursor: azul_core::selection::TextCursor,
4194
        goal_x: &mut Option<f32>,
4194
    ) -> azul_core::selection::TextCursor {
        use azul_core::selection::CursorAffinity;
4194
        let Some((ci, line_ord)) = self.find_cursor_cluster(&cursor) else {
            return cursor;
        };
4194
        if line_ord == 0 {
864
            return cursor;
3330
        }
3330
        let c = &self.clusters[ci as usize];
3330
        let current_x = goal_x.unwrap_or_else(|| {
3330
            let x = match cursor.affinity {
1665
                CursorAffinity::Leading => c.x,
1665
                CursorAffinity::Trailing => c.x + c.advance,
            };
3330
            *goal_x = Some(x);
3330
            x
3330
        });
3330
        let target = &self.lines[line_ord - 1];
3330
        let target_y = target.top_y + target.height / 2.0;
3330
        self.hittest_cursor(Point { x: current_x, y: target_y })
3330
            .unwrap_or(cursor)
4194
    }
    /// (d6d) One line down — see [`Self::move_cursor_up`].
    #[must_use]
4194
    pub fn move_cursor_down(
4194
        &self,
4194
        cursor: azul_core::selection::TextCursor,
4194
        goal_x: &mut Option<f32>,
4194
    ) -> azul_core::selection::TextCursor {
        use azul_core::selection::CursorAffinity;
4194
        let Some((ci, line_ord)) = self.find_cursor_cluster(&cursor) else {
            return cursor;
        };
4194
        if line_ord + 1 >= self.lines.len() {
1116
            return cursor;
3078
        }
3078
        let c = &self.clusters[ci as usize];
3078
        let current_x = goal_x.unwrap_or_else(|| {
3078
            let x = match cursor.affinity {
1539
                CursorAffinity::Leading => c.x,
1539
                CursorAffinity::Trailing => c.x + c.advance,
            };
3078
            *goal_x = Some(x);
3078
            x
3078
        });
3078
        let target = &self.lines[line_ord + 1];
3078
        let target_y = target.top_y + target.height / 2.0;
3078
        self.hittest_cursor(Point { x: current_x, y: target_y })
3078
            .unwrap_or(cursor)
4194
    }
    /// (d6c) One caret stop left — IDENTICAL to the sparse
    /// `move_cursor_left` by construction: the stops list is pinned equal
    /// (`grapheme_stops` gate) and the offset arithmetic is the SAME static
    /// helper the sparse implementation uses.
    #[must_use]
3663
    pub fn move_cursor_left(
3663
        &self,
3663
        cursor: azul_core::selection::TextCursor,
3663
    ) -> azul_core::selection::TextCursor {
        use super::cache::UnifiedLayout;
3663
        let stops = self.grapheme_stops();
3663
        if stops.is_empty() {
            return cursor;
3663
        }
3663
        let Some(offset) = UnifiedLayout::grapheme_caret_offset(&stops, &cursor) else {
            return cursor;
        };
3663
        UnifiedLayout::cursor_from_grapheme_offset(&stops, offset.saturating_sub(1))
3663
    }
    /// (d6c) One caret stop right — see [`Self::move_cursor_left`].
    #[must_use]
3654
    pub fn move_cursor_right(
3654
        &self,
3654
        cursor: azul_core::selection::TextCursor,
3654
    ) -> azul_core::selection::TextCursor {
        use super::cache::UnifiedLayout;
3654
        let stops = self.grapheme_stops();
3654
        if stops.is_empty() {
            return cursor;
3654
        }
3654
        let Some(offset) = UnifiedLayout::grapheme_caret_offset(&stops, &cursor) else {
            return cursor;
        };
3654
        UnifiedLayout::cursor_from_grapheme_offset(&stops, (offset + 1).min(stops.len()))
3654
    }
    /// (d6f) Leading cursor on the FIRST cluster — sparse
    /// `get_first_cluster_cursor` twin.
    #[must_use]
2088
    pub fn first_cluster_cursor(&self) -> Option<azul_core::selection::TextCursor> {
2088
        self.cursor_at_index(0, azul_core::selection::CursorAffinity::Leading)
2088
    }
    /// (d4) The trailing cursor on the LAST cluster — the dense twin of
    /// the sparse `items.iter().rev().find_map(Cluster)` scans (which
    /// skip trailing non-clusters, exactly as taking the last dense
    /// cluster does). `None` when the layout has no clusters.
    #[must_use]
2964
    pub fn last_cluster_cursor(&self) -> Option<azul_core::selection::TextCursor> {
2964
        let last_ci = u32::try_from(self.clusters.len()).ok()?.checked_sub(1)?;
2964
        let c = self.clusters.last()?;
2964
        let run = self.runs.iter().rev().find(|r| r.clusters.contains(&last_ci))?;
2964
        Some(azul_core::selection::TextCursor {
2964
            cluster_id: azul_core::selection::GraphemeClusterId {
2964
                source_run: run.source_run,
2964
                start_byte_in_run: c.start_byte,
2964
            },
2964
            affinity: azul_core::selection::CursorAffinity::Trailing,
2964
        })
2964
    }
    /// (d4) Cursor for an IFC-wide byte offset — the dense twin of the
    /// sparse accumulation walk: clusters in item order, each
    /// contributing `cluster_byte_len`, first cluster whose span
    /// contains the offset wins; past-the-end falls to the last cluster.
    #[must_use]
882
    pub fn byte_offset_to_cursor(&self, byte_offset: u32) -> Option<azul_core::selection::TextCursor> {
        use azul_core::selection::{CursorAffinity, GraphemeClusterId, TextCursor};
882
        let cursor_at = |ci: u32| -> Option<TextCursor> {
882
            let c = self.clusters.get(ci as usize)?;
2340
            let run = self.runs.iter().find(|r| r.clusters.contains(&ci))?;
882
            Some(TextCursor {
882
                cluster_id: GraphemeClusterId {
882
                    source_run: run.source_run,
882
                    start_byte_in_run: c.start_byte,
882
                },
882
                affinity: CursorAffinity::Trailing,
882
            })
882
        };
882
        if self.clusters.is_empty() {
            return None;
882
        }
882
        if byte_offset == 0 {
27
            return cursor_at(0);
855
        }
855
        let mut acc = 0u32;
18981
        for ci in 0..self.clusters.len() as u32 {
18981
            let len = self.cluster_byte_len(ci);
18981
            let end = acc + len;
18981
            if byte_offset >= acc && byte_offset <= end {
828
                return cursor_at(ci);
18153
            }
18153
            acc = end;
        }
27
        cursor_at(self.clusters.len() as u32 - 1)
882
    }
}
/// §3.2 step 3: the dense twin of [`super::glyphs::get_glyph_positions`]
/// (the reference walker the other two consumers agree with). Walks the
/// dense arrays only. Positions agree EXACTLY with the reference for
/// uniform-font clusters (the run's metrics reproduce the per-item
/// ascent math); multi-font fallback clusters would need the per-glyph
/// metrics the detail table deliberately does not carry — the atomics /
/// combined blocks stay on the sparse side and are not walked here.
///
/// `PositionedGlyph.advance` reports the PAINTED advance (incl. kerning,
/// as the dense model folds it) — the reference reports the base advance
/// and advances its pen by base+kerning; positions are identical either
/// way, which is what the agreement gate compares.
#[must_use]
27
pub fn get_glyph_positions_dense(dense: &DenseText) -> Vec<PositionedGlyph> {
27
    let mut out = Vec::with_capacity(dense.clusters.len());
27
    let mut line_iter = dense.lines.iter().peekable();
27
    let mut detail_iter = dense.details.iter().peekable();
828
    for (ci, run) in dense
27
        .runs
27
        .iter()
828
        .flat_map(|r| (r.clusters.start..r.clusters.end).map(move |i| (i, r)))
    {
828
        let c = &dense.clusters[ci as usize];
        // Advance the line cursor to the record containing this cluster.
873
        while let Some(l) = line_iter.peek() {
873
            if ci >= l.clusters.1 {
45
                line_iter.next();
45
            } else {
828
                break;
            }
        }
828
        let top_y = line_iter.peek().map_or(0.0, |l| l.top_y);
        // Per-run ascent: the same math the reference derives per item
        // (metrics + half-leading), amortised — run metrics are uniform.
828
        let m = &run.font_metrics;
828
        let ascent = if m.units_per_em == 0 {
            0.0
        } else {
828
            let scale = run.style.font_size_px / f32::from(m.units_per_em);
828
            let font_ascent = m.ascent * scale;
828
            let font_descent = (-m.descent * scale).max(0.0);
828
            let ad = font_ascent + font_descent;
828
            let lh = run
828
                .style
828
                .line_height
828
                .resolve_with_metrics(run.style.font_size_px, m);
828
            font_ascent + (lh - ad) / 2.0
        };
        // The RUN's own solved y, not the line's: a line mixing sizes puts
        // its taller run on a different baseline (see DenseRun::y).
828
        let baseline_y = run.y + ascent;
        // Detail cluster? (details are in cluster order.)
828
        let detail = loop {
828
            match detail_iter.peek() {
                Some(d) if d.cluster < ci => {
                    detail_iter.next();
                }
                Some(d) if d.cluster == ci => break Some(**d),
828
                _ => break None,
            }
        };
828
        match detail {
            Some(d) => {
                let mut pen_x = c.x;
                for dg in &dense.detail_glyphs[d.glyphs.0 as usize..d.glyphs.1 as usize] {
                    out.push(PositionedGlyph {
                        glyph_id: dg.glyph_id,
                        position: Point {
                            x: pen_x + dg.offset_x,
                            y: baseline_y - dg.offset_y,
                        },
                        advance: dg.advance,
                    });
                    pen_x += dg.advance;
                }
            }
828
            None => {
828
                out.push(PositionedGlyph {
828
                    glyph_id: c.glyph_id,
828
                    position: Point { x: c.x, y: baseline_y },
828
                    advance: c.advance,
828
                });
828
            }
        }
    }
27
    out
27
}
/// §3.2 step 4: the dense twin of [`super::glyphs::get_glyph_runs_simple`]
/// (the paint-path consumer). Same walk as [`get_glyph_positions_dense`];
/// the run-merge predicate is the REFERENCE's — painted VALUES on the same
/// baseline, not Arc identity — so two dense runs that split only on style
/// Arc identity or `source_run` merge back into one paint run exactly as
/// the reference merges glyphs from different shaping runs. The border
/// fragment post-process is literally shared
/// ([`super::glyphs::suppress_split_border_fragments`]), so CSS 2.2 §9.4.2
/// split-point suppression cannot drift between the walkers.
///
/// Same documented limits as the position walker: combined blocks
/// (tate-chu-yoko) stay on the sparse side per the plan's `AtomicItem`
/// design, and a multi-font-fallback CLUSTER would split mid-cluster in
/// the reference but not here (the detail table carries no per-glyph
/// font hash) — both migrate in a later step; the agreement gate covers
/// the dense-expressible subset.
#[allow(clippy::float_cmp)] // intentional exact compare: same predicate as the reference walker
#[must_use]
150621
pub fn get_glyph_runs_simple_dense(dense: &DenseText) -> Vec<SimpleGlyphRun> {
150621
    let mut runs: Vec<SimpleGlyphRun> = Vec::new();
150621
    let mut current_run: Option<SimpleGlyphRun> = None;
150621
    let mut current_baseline: Option<f32> = None;
150621
    let mut line_iter = dense.lines.iter().peekable();
150621
    let mut detail_iter = dense.details.iter().peekable();
2222206
    for (ci, run) in dense
150621
        .runs
150621
        .iter()
2222206
        .flat_map(|r| (r.clusters.start..r.clusters.end).map(move |i| (i, r)))
    {
2222206
        let c = &dense.clusters[ci as usize];
        // Detail cluster? (details are in cluster order.) Resolved FIRST:
        // a zero-glyph detail cluster contributes nothing and must not
        // open a run (the reference's per-glyph loop never runs there).
2222206
        let detail = loop {
2258457
            match detail_iter.peek() {
325552
                Some(d) if d.cluster < ci => {
36251
                    detail_iter.next();
36251
                }
289301
                Some(d) if d.cluster == ci => break Some(**d),
2185928
                _ => break None,
            }
        };
2222206
        if detail.is_some_and(|d| d.glyphs.0 == d.glyphs.1) {
9
            continue;
2222197
        }
        // Line cursor + per-run ascent: identical to the position walker.
2245770
        while let Some(l) = line_iter.peek() {
2245770
            if ci >= l.clusters.1 {
23573
                line_iter.next();
23573
            } else {
2222197
                break;
            }
        }
2222197
        let top_y = line_iter.peek().map_or(0.0, |l| l.top_y);
2222197
        let m = &run.font_metrics;
2222197
        let ascent = if m.units_per_em == 0 {
4
            0.0
        } else {
2222193
            let scale = run.style.font_size_px / f32::from(m.units_per_em);
2222193
            let font_ascent = m.ascent * scale;
2222193
            let font_descent = (-m.descent * scale).max(0.0);
2222193
            let ad = font_ascent + font_descent;
2222193
            let lh = run
2222193
                .style
2222193
                .line_height
2222193
                .resolve_with_metrics(run.style.font_size_px, m);
2222193
            font_ascent + (lh - ad) / 2.0
        };
        // The RUN's own solved y, not the line's: a line mixing sizes puts
        // its taller run on a different baseline (see DenseRun::y).
2222197
        let baseline_y = run.y + ascent;
2222197
        let style = &run.style;
2222197
        let source_node_id =
2222197
            (run.source_node != u32::MAX).then(|| NodeId::new(run.source_node as usize));
        // The reference predicate, evaluated per CLUSTER — every compared
        // field is uniform within a cluster there, so boundaries are
        // identical.
2222197
        let merges = current_run.as_ref().is_some_and(|r| {
2071870
            current_baseline == Some(baseline_y)
2048225
                && r.font_hash == run.font_hash
2047658
                && r.color == style.color
2047316
                && r.background_color == style.background_color
2047307
                && r.background_content == style.background_content
2047289
                && r.border == style.border
2047271
                && r.font_size_px == style.font_size_px
2047253
                && r.text_decoration == style.text_decoration
2047253
                && r.source_node_id == source_node_id
2071870
        });
2222197
        if !merges {
176159
            if let Some(prev) = current_run.take() {
25832
                runs.push(prev);
150327
            }
176159
            current_baseline = Some(baseline_y);
176159
            current_run = Some(SimpleGlyphRun {
176159
                glyphs: Vec::new(),
176159
                color: style.color,
176159
                background_color: style.background_color,
176159
                background_content: style.background_content.clone(),
176159
                border: style.border,
176159
                font_hash: run.font_hash,
176159
                font_size_px: style.font_size_px,
176159
                text_decoration: style.text_decoration,
176159
                is_ime_preview: false,
176159
                source_node_id,
176159
            });
2046038
        }
2222197
        let out = &mut current_run
2222197
            .as_mut()
2222197
            .expect("opened above when absent")
2222197
            .glyphs;
2222197
        match detail {
36269
            Some(d) => {
36269
                let mut pen_x = c.x;
36270
                for dg in &dense.detail_glyphs[d.glyphs.0 as usize..d.glyphs.1 as usize] {
36270
                    out.push(GlyphInstance {
36270
                        index: u32::from(dg.glyph_id),
36270
                        point: LogicalPosition {
36270
                            x: pen_x + dg.offset_x,
36270
                            y: baseline_y - dg.offset_y,
36270
                        },
36270
                        size: LogicalSize::default(),
36270
                    });
36270
                    pen_x += dg.advance;
36270
                }
            }
2185928
            None => {
2185928
                out.push(GlyphInstance {
2185928
                    index: u32::from(c.glyph_id),
2185928
                    point: LogicalPosition { x: c.x, y: baseline_y },
2185928
                    size: LogicalSize::default(),
2185928
                });
2185928
            }
        }
    }
150621
    if let Some(r) = current_run {
150327
        runs.push(r);
150363
    }
150621
    super::glyphs::suppress_split_border_fragments(&mut runs);
150621
    runs
150621
}
/// §3.2 step 5: the dense twin of [`super::glyphs::get_glyph_runs_pdf`]
/// (printpdf's frozen contract: `cluster.glyphs` iteration +
/// `glyph.font_hash`). Same walk as the other dense twins; the run
/// predicate is the reference's (font, colour, background, size,
/// decoration, LINE index, direction, writing mode — note: no border, no
/// background layers, no source node).
///
/// The per-cluster TEXT — which the reference reads from
/// `ShapedCluster::text` — is reconstructed here as the grapheme cluster
/// at `start_byte` in the run's shared source text: the same
/// segmentation shaping used to build the cluster, so the two agree
/// byte-for-byte (the agreement gate pins it, and 3c deletes the
/// per-cluster copy on the strength of exactly this equivalence).
/// Styles that rewrite text between source and shaping (text-transform)
/// keep the sparse walker until the transform story lands.
///
/// `PdfPositionedGlyph::advance` reports the PAINTED advance for detail
/// glyphs (kerning folded, as the dense model stores it) where the
/// reference reports the base advance — positions are identical either
/// way, same documented divergence as the position walker.
#[allow(clippy::float_cmp)] // intentional exact compare: same predicate as the reference walker
#[allow(clippy::too_many_lines)] // one cohesive walk, mirrors the reference's structure
#[must_use]
5
pub fn get_glyph_runs_pdf_dense<T: ParsedFontTrait>(
5
    dense: &DenseText,
5
    fonts: &LoadedFonts<T>,
5
) -> Vec<PdfGlyphRun<T>> {
    use unicode_segmentation::UnicodeSegmentation;
5
    let mut runs: Vec<PdfGlyphRun<T>> = Vec::new();
5
    let mut current_run: Option<PdfGlyphRun<T>> = None;
5
    let mut line_iter = dense.lines.iter().peekable();
5
    let mut detail_iter = dense.details.iter().peekable();
110
    for (ci, run) in dense
5
        .runs
5
        .iter()
110
        .flat_map(|r| (r.clusters.start..r.clusters.end).map(move |i| (i, r)))
    {
110
        let c = &dense.clusters[ci as usize];
        // Detail resolution first: a zero-glyph cluster contributes
        // nothing (the reference skips `cluster.glyphs.is_empty()`).
110
        let detail = loop {
110
            match detail_iter.peek() {
                Some(d) if d.cluster < ci => {
                    detail_iter.next();
                }
                Some(d) if d.cluster == ci => break Some(**d),
110
                _ => break None,
            }
        };
110
        if detail.is_some_and(|d| d.glyphs.0 == d.glyphs.1) {
            continue;
110
        }
        // A glyph whose font is not loaded is skipped WITHOUT breaking
        // the open run, exactly as the reference's per-glyph `continue`.
110
        let Some(font) = fonts.get_by_hash(run.font_hash) else {
            continue;
        };
        // Line cursor: source line index + per-run ascent → baseline.
115
        while let Some(l) = line_iter.peek() {
115
            if ci >= l.clusters.1 {
5
                line_iter.next();
5
            } else {
110
                break;
            }
        }
110
        let (top_y, line_index) = line_iter
110
            .peek()
110
            .map_or((0.0, 0usize), |l| (l.top_y, l.source_index as usize));
110
        let m = &run.font_metrics;
110
        let ascent = if m.units_per_em == 0 {
            0.0
        } else {
110
            let scale = run.style.font_size_px / f32::from(m.units_per_em);
110
            let font_ascent = m.ascent * scale;
110
            let font_descent = (-m.descent * scale).max(0.0);
110
            let ad = font_ascent + font_descent;
110
            let lh = run
110
                .style
110
                .line_height
110
                .resolve_with_metrics(run.style.font_size_px, m);
110
            font_ascent + (lh - ad) / 2.0
        };
        // The RUN's own solved y, not the line's: a line mixing sizes puts
        // its taller run on a different baseline (see DenseRun::y).
110
        let baseline_y = run.y + ascent;
110
        let style = &run.style;
        // The cluster's source text: `cluster_byte_len` bytes at start_byte in
        // the shared run text.
        //
        // NOT "the next grapheme". A LIGATURE-FUSED cluster spans several
        // graphemes — "fi" is two — so a grapheme walk returns "f" and the
        // second character is lost. That is what `ClusterDetail.byte_len`
        // exists to record, in its own words: "a ligature-fused cluster spans
        // multiple graphemes, so 'next grapheme boundary' under-measures it".
        // The sparse expander and the word-boundary predicate both ask
        // `cluster_byte_len`; this walker asked the graphemes, and the
        // difference reached users as PDF text: every ligated word came out of
        // pdftotext with its second letter missing — "Configure" -> "Confgure",
        // "filter" -> "flter", "offline" -> "offine" — because the ToUnicode
        // entry for the ligature glyph said "f".
110
        let start = c.start_byte as usize;
110
        let len = dense.cluster_byte_len(ci) as usize;
110
        let cluster_text: &str = run
110
            .text
110
            .get(start..start.saturating_add(len))
            // A byte length that is not a char boundary would slice-panic;
            // fall back to the old grapheme walk rather than take the process
            // down over a malformed record.
110
            .or_else(|| {
                run.text
                    .get(start..)
                    .and_then(|s| s.graphemes(true).next())
            })
110
            .unwrap_or("");
        // The reference predicate, evaluated per CLUSTER (all compared
        // fields are uniform within a cluster in dense scope).
110
        let merges = current_run.as_ref().is_some_and(|r| {
105
            r.font_hash == run.font_hash
105
                && r.color == style.color
104
                && r.background_color == style.background_color
104
                && r.font_size_px == style.font_size_px
104
                && r.text_decoration == style.text_decoration
104
                && r.line_index == line_index
99
                && r.direction == run.direction
99
                && r.writing_mode == style.writing_mode
105
        });
110
        if !merges {
11
            if let Some(prev) = current_run.take() {
6
                runs.push(prev);
6
            }
11
            current_run = Some(PdfGlyphRun {
11
                glyphs: Vec::new(),
11
                color: style.color,
11
                background_color: style.background_color,
11
                font: font.clone(),
11
                font_hash: run.font_hash,
11
                font_size_px: style.font_size_px,
11
                text_decoration: style.text_decoration,
11
                line_index,
11
                direction: run.direction,
11
                writing_mode: style.writing_mode,
11
                baseline_start: Point { x: c.x, y: baseline_y },
11
                cluster_texts: Vec::new(),
11
            });
99
        }
110
        let open = current_run.as_mut().expect("opened above when absent");
110
        if let Some(d) = detail {
            let dgs = &dense.detail_glyphs[d.glyphs.0 as usize..d.glyphs.1 as usize];
            let count = dgs.len();
            let mut pen_x = c.x;
            for (glyph_idx, dg) in dgs.iter().enumerate() {
                // The reference's per-glyph codepoint split, verbatim.
                let unicode_codepoint = if count == 1 {
                    cluster_text.to_string()
                } else {
                    let byte_offset = dg.cluster_offset as usize;
                    if byte_offset < cluster_text.len() {
                        cluster_text[byte_offset..].chars().next().map_or_else(
                            || cluster_text.to_string(),
                            |ch| ch.to_string(),
                        )
                    } else if glyph_idx == 0 {
                        cluster_text.to_string()
                    } else {
                        String::new()
                    }
                };
                open.glyphs.push(PdfPositionedGlyph {
                    glyph_id: dg.glyph_id,
                    position: Point {
                        x: pen_x + dg.offset_x,
                        y: baseline_y - dg.offset_y,
                    },
                    advance: dg.advance,
                    unicode_codepoint,
                });
                open.cluster_texts.push(cluster_text.to_string());
                pen_x += dg.advance;
            }
110
        } else {
110
            open.glyphs.push(PdfPositionedGlyph {
110
                glyph_id: c.glyph_id,
110
                position: Point { x: c.x, y: baseline_y },
110
                advance: c.advance,
110
                unicode_codepoint: cluster_text.to_string(),
110
            });
110
            open.cluster_texts.push(cluster_text.to_string());
110
        }
    }
5
    if let Some(r) = current_run {
5
        runs.push(r);
5
    }
5
    runs
5
}