1
//! A helper module to extract final, absolute glyph positions from a layout.
2
//! This is useful for renderers that work with simple lists of glyphs.
3

            
4
use azul_core::{
5
    dom::NodeId,
6
    geom::{LogicalPosition, LogicalSize},
7
    ui_solver::GlyphInstance,
8
};
9
use azul_css::props::basic::ColorU;
10
use azul_css::props::style::StyleBackgroundContent;
11

            
12
use crate::text3::cache::{
13
    get_item_vertical_metrics_approx, InlineBorderInfo, LoadedFonts, ParsedFontTrait, Point,
14
    ShapedGlyph, ShapedItem, UnifiedLayout,
15
};
16

            
17
/// Represents a single glyph ready for rendering, with an absolute position on the baseline.
18
#[derive(Debug, Copy, Clone, PartialEq)]
19
pub struct PositionedGlyph {
20
    pub glyph_id: u16,
21
    /// The absolute position of the glyph's origin on the baseline.
22
    pub position: Point,
23
    /// The advance width of the glyph, useful for caret placement.
24
    pub advance: f32,
25
}
26

            
27
/// A simple glyph run without font reference - used when fonts aren't available.
28
/// The font can be looked up later via `font_hash` if needed.
29
#[derive(Debug, Clone)]
30
pub struct SimpleGlyphRun {
31
    /// The glyphs in this run, with their positions relative to the start of the run.
32
    pub glyphs: Vec<GlyphInstance>,
33
    /// The color of the text in this glyph run.
34
    pub color: ColorU,
35
    /// Background color for this run (rendered behind text)
36
    pub background_color: Option<ColorU>,
37
    /// Full background content layers (for gradients, images, etc.)
38
    pub background_content: Vec<StyleBackgroundContent>,
39
    /// Border information for inline elements
40
    pub border: Option<InlineBorderInfo>,
41
    /// A hash of the font, useful for caching purposes.
42
    pub font_hash: u64,
43
    /// The font size in pixels.
44
    pub font_size_px: f32,
45
    /// Text decoration (underline, strikethrough, overline)
46
    pub text_decoration: crate::text3::cache::TextDecoration,
47
    /// Whether this is an IME composition preview (should be rendered with special styling)
48
    pub is_ime_preview: bool,
49
    /// The source DOM node that generated this text run (for hit-testing)
50
    pub source_node_id: Option<NodeId>,
51
}
52

            
53
/// #25: the STORED form of a paint run's glyphs — 8 B/glyph instead of the
54
/// 20 B `GlyphInstance`. Exploits two invariants of the simple-run contract:
55
/// `size` is never populated (both builders emit `LogicalSize::default()`),
56
/// and `point.y` is the run baseline for every glyph except detail glyphs
57
/// with vertical offsets. Anything that deviates goes in `exceptions` as a
58
/// FULL instance, so expansion is bit-exact BY CONSTRUCTION — there is no
59
/// lossy path, only a smaller encoding for the invariant-conforming bulk.
60
#[derive(Debug, Clone, PartialEq, Default)]
61
pub struct CompactGlyphs {
62
    /// `(glyph index, pen x)` per glyph, in paint order.
63
    pub xs: Vec<(u32, f32)>,
64
    /// The y (baseline) shared by every non-exception glyph. Bit pattern
65
    /// of the first instance's y (float identity — the NaN rule).
66
    pub y: f32,
67
    /// The size shared by every non-exception glyph (today always
68
    /// `LogicalSize::default()` — kept as data, not assumption).
69
    pub size: LogicalSize,
70
    /// `(position in xs, full original instance)` for glyphs whose y or
71
    /// size deviate from the shared values. Sorted by position.
72
    pub exceptions: Vec<(u32, GlyphInstance)>,
73
}
74

            
75
impl CompactGlyphs {
76
    /// Compacts a transient instance list. Exact: `expand()` of the result
77
    /// reproduces `v` bit for bit (floats compared as bit patterns when
78
    /// choosing the shared y/size — NaNs become exceptions, never a match).
79
    #[must_use]
80
176004
    pub fn from_instances(v: &[GlyphInstance]) -> Self {
81
176004
        let (y, size) =
82
176004
            v.first().map_or_else(|| (0.0, LogicalSize::default()), |g| (g.point.y, g.size));
83
176004
        let mut xs = Vec::with_capacity(v.len());
84
176004
        let mut exceptions = Vec::new();
85
2220729
        for (i, g) in v.iter().enumerate() {
86
2220729
            xs.push((g.index, g.point.x));
87
2220729
            let same_y = g.point.y.to_bits() == y.to_bits();
88
2220729
            let same_size = g.size.width.to_bits() == size.width.to_bits()
89
2220728
                && g.size.height.to_bits() == size.height.to_bits();
90
2220729
            if !(same_y && same_size) {
91
4
                exceptions.push((u32::try_from(i).unwrap_or(u32::MAX), *g));
92
2220725
            }
93
        }
94
176004
        Self {
95
176004
            xs,
96
176004
            y,
97
176004
            size,
98
176004
            exceptions,
99
176004
        }
100
176004
    }
101

            
102
    #[must_use]
103
1571208
    pub const fn len(&self) -> usize {
104
1571208
        self.xs.len()
105
1571208
    }
106

            
107
    #[must_use]
108
1
    pub const fn is_empty(&self) -> bool {
109
1
        self.xs.is_empty()
110
1
    }
111

            
112
    /// Reconstructs instance `i` exactly as it was compacted.
113
    #[must_use]
114
17491721
    pub fn get(&self, i: usize) -> Option<GlyphInstance> {
115
17491721
        let &(index, x) = self.xs.get(i)?;
116
17491719
        let iu = u32::try_from(i).unwrap_or(u32::MAX);
117
17491719
        if let Ok(e) = self.exceptions.binary_search_by_key(&iu, |&(p, _)| p) {
118
5
            return Some(self.exceptions[e].1);
119
17491714
        }
120
17491714
        Some(GlyphInstance {
121
17491714
            index,
122
17491714
            point: LogicalPosition { x, y: self.y },
123
17491714
            size: self.size,
124
17491714
        })
125
17491721
    }
126

            
127
    #[must_use]
128
1046727
    pub fn first(&self) -> Option<GlyphInstance> {
129
1046727
        self.get(0)
130
1046727
    }
131

            
132
    #[must_use]
133
1046727
    pub fn last(&self) -> Option<GlyphInstance> {
134
1046727
        self.get(self.len().wrapping_sub(1))
135
1046727
    }
136

            
137
    /// Exact expansion, in order.
138
524476
    pub fn iter(&self) -> impl Iterator<Item = GlyphInstance> + '_ {
139
15398267
        (0..self.len()).map(|i| self.get(i).expect("index in range"))
140
524476
    }
141

            
142
    /// The paint expansion: every instance translated by `(dx, dy)` — the
143
    /// same Vec the DL generator built by copy-then-offset before #25.
144
    #[must_use]
145
524470
    pub fn to_vec_offset(&self, dx: f32, dy: f32) -> Vec<GlyphInstance> {
146
524470
        self.iter()
147
15398253
            .map(|mut g| {
148
15398253
                g.point.x += dx;
149
15398253
                g.point.y += dy;
150
15398253
                g
151
15398253
            })
152
524470
            .collect()
153
524470
    }
154

            
155
    /// Heap bytes retained by this encoding (memory report).
156
    #[must_use]
157
5
    pub const fn retained_bytes(&self) -> usize {
158
5
        self.xs.capacity() * size_of::<(u32, f32)>()
159
5
            + self.exceptions.capacity() * size_of::<(u32, GlyphInstance)>()
160
5
    }
161
}
162

            
163
/// #25: the STORED paint run — [`SimpleGlyphRun`]'s header with the glyph
164
/// list compacted. `SimpleGlyphRun` remains the TRANSIENT form the two
165
/// builders produce and the equivalence gates compare; the store chokepoint
166
/// (`CachedInlineLayout` construction) converts once, the same shape as the
167
/// §3.2 dense retirement.
168
#[derive(Debug, Clone)]
169
pub struct CompactGlyphRun {
170
    pub glyphs: CompactGlyphs,
171
    pub color: ColorU,
172
    pub background_color: Option<ColorU>,
173
    pub background_content: Vec<StyleBackgroundContent>,
174
    pub border: Option<InlineBorderInfo>,
175
    pub font_hash: u64,
176
    pub font_size_px: f32,
177
    pub text_decoration: crate::text3::cache::TextDecoration,
178
    pub is_ime_preview: bool,
179
    pub source_node_id: Option<NodeId>,
180
}
181

            
182
impl From<SimpleGlyphRun> for CompactGlyphRun {
183
175998
    fn from(r: SimpleGlyphRun) -> Self {
184
175998
        Self {
185
175998
            glyphs: CompactGlyphs::from_instances(&r.glyphs),
186
175998
            color: r.color,
187
175998
            background_color: r.background_color,
188
175998
            background_content: r.background_content,
189
175998
            border: r.border,
190
175998
            font_hash: r.font_hash,
191
175998
            font_size_px: r.font_size_px,
192
175998
            text_decoration: r.text_decoration,
193
175998
            is_ime_preview: r.is_ime_preview,
194
175998
            source_node_id: r.source_node_id,
195
175998
        }
196
175998
    }
197
}
198

            
199
impl CompactGlyphRun {
200
    /// The transient form back — for gates and any reader that wants the
201
    /// instance list. Exact inverse of the `From` conversion.
202
    #[must_use]
203
1
    pub fn expand(&self) -> SimpleGlyphRun {
204
1
        SimpleGlyphRun {
205
1
            glyphs: self.glyphs.iter().collect(),
206
1
            color: self.color,
207
1
            background_color: self.background_color,
208
1
            background_content: self.background_content.clone(),
209
1
            border: self.border,
210
1
            font_hash: self.font_hash,
211
1
            font_size_px: self.font_size_px,
212
1
            text_decoration: self.text_decoration,
213
1
            is_ime_preview: self.is_ime_preview,
214
1
            source_node_id: self.source_node_id,
215
1
        }
216
1
    }
217
}
218

            
219
/// Bit-exact run equality for the #25 roundtrip gate. Floats compare as
220
/// BIT PATTERNS — a NaN that survives the compact/expand roundtrip must
221
/// count as equal (derived `PartialEq` would call that a divergence and
222
/// a lossy roundtrip that maps NaN to NaN would pass; both wrong ways).
223
#[must_use]
224
1
pub fn simple_runs_bit_equal(a: &SimpleGlyphRun, b: &SimpleGlyphRun) -> bool {
225
9
    let f = |x: f32, y: f32| x.to_bits() == y.to_bits();
226
1
    a.color == b.color
227
1
        && a.background_color == b.background_color
228
1
        && a.background_content == b.background_content
229
1
        && a.border == b.border
230
1
        && a.font_hash == b.font_hash
231
1
        && f(a.font_size_px, b.font_size_px)
232
1
        && a.text_decoration == b.text_decoration
233
1
        && a.is_ime_preview == b.is_ime_preview
234
1
        && a.source_node_id == b.source_node_id
235
1
        && a.glyphs.len() == b.glyphs.len()
236
2
        && a.glyphs.iter().zip(b.glyphs.iter()).all(|(g, h)| {
237
2
            g.index == h.index
238
2
                && f(g.point.x, h.point.x)
239
2
                && f(g.point.y, h.point.y)
240
2
                && f(g.size.width, h.size.width)
241
2
                && f(g.size.height, h.size.height)
242
2
        })
243
1
}
244

            
245
/// Groups glyphs into runs without requiring font references.
246
/// Use this when you only need glyph positions and don't need font references.
247
#[allow(clippy::float_cmp)] // intentional exact compare: change-detection / identity fast-path / cache-key match
248
#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
249
83
#[must_use] pub fn get_glyph_runs_simple(layout: &UnifiedLayout) -> Vec<SimpleGlyphRun> {
250
83
    let mut runs: Vec<SimpleGlyphRun> = Vec::new();
251
83
    let mut current_run: Option<SimpleGlyphRun> = None;
252
    // Baseline the open run sits on. Runs merge across layout items when
253
    // their style matches, and the style predicate below has no notion of
254
    // WHERE the glyphs are — so every line of a paragraph merged into one
255
    // run, hence one `DisplayListItem::Text`, hence one damage rect
256
    // covering all of them. Breaking on the baseline makes a line the unit
257
    // of damage, so editing line 3 of a paragraph does not repaint lines
258
    // 1 and 2.
259
83
    let mut current_baseline: Option<f32> = None;
260

            
261
1662
    for item in &layout.items {
262
1579
        let (item_ascent, _) = get_item_vertical_metrics_approx(&item.item);
263
1579
        let baseline_y = item.position.y + item_ascent;
264

            
265
1579
        let mut process_glyphs =
266
            |positioned_glyphs: &[ShapedGlyph],
267
             item_origin_x: f32,
268
             writing_mode: crate::text3::cache::WritingMode,
269
             source_node_id: Option<NodeId>,
270
1575
             style: &crate::text3::cache::StyleProperties| {
271
1575
                let mut pen_x = item_origin_x;
272

            
273
3157
                for glyph in positioned_glyphs {
274
1582
                    let glyph_color = style.color;
275
1582
                    let glyph_background = style.background_color;
276
1582
                    let glyph_background_content = style.background_content.clone();
277
1582
                    let glyph_border = style.border;
278
1582
                    let font_hash = glyph.font_hash;
279
1582
                    let font_size_px = style.font_size_px;
280
1582
                    let text_decoration = style.text_decoration;
281

            
282
1582
                    let absolute_position = LogicalPosition {
283
1582
                        x: pen_x + glyph.offset.x,
284
1582
                        y: baseline_y - glyph.offset.y,
285
1582
                    };
286

            
287
1582
                    let instance =
288
1582
                        glyph.into_glyph_instance_at_simple(writing_mode, absolute_position);
289

            
290
1582
                    if let Some(run) = current_run.as_mut() {
291
                        // changes (font, color, border, size). Per spec, text-decoration
292
                        // changes do not affect shaping (shaping is done upstream in
293
                        // default.rs), but we still break rendering runs for correct drawing.
294
                        // Border/margin/padding changes break both shaping and rendering runs.
295
1504
                        if current_baseline == Some(baseline_y)
296
1422
                            && run.font_hash == font_hash
297
1421
                            && run.color == glyph_color
298
1344
                            && run.background_color == glyph_background
299
1344
                            && run.background_content == glyph_background_content
300
1343
                            && run.border == glyph_border
301
1343
                            && run.font_size_px == font_size_px
302
1331
                            && run.text_decoration == text_decoration
303
1330
                            && run.source_node_id == source_node_id
304
1329
                        {
305
1329
                            run.glyphs.push(instance);
306
1399
                        } else {
307
175
                            runs.push(run.clone());
308
175
                            current_baseline = Some(baseline_y);
309
175
                            current_run = Some(SimpleGlyphRun {
310
175
                                glyphs: vec![instance],
311
175
                                color: glyph_color,
312
175
                                background_color: glyph_background,
313
175
                                background_content: glyph_background_content.clone(),
314
175
                                border: glyph_border,
315
175
                                font_hash,
316
175
                                font_size_px,
317
175
                                text_decoration,
318
175
                                is_ime_preview: false,
319
175
                                source_node_id,
320
175
                            });
321
175
                        }
322
78
                    } else {
323
78
                        current_baseline = Some(baseline_y);
324
78
                        current_run = Some(SimpleGlyphRun {
325
78
                            glyphs: vec![instance],
326
78
                            color: glyph_color,
327
78
                            background_color: glyph_background,
328
78
                            background_content: glyph_background_content.clone(),
329
78
                            border: glyph_border,
330
78
                            font_hash,
331
78
                            font_size_px,
332
78
                            text_decoration,
333
78
                            is_ime_preview: false,
334
78
                            source_node_id,
335
78
                        });
336
78
                    }
337

            
338
1582
                    pen_x += glyph.advance + glyph.kerning;
339
                }
340
1575
            };
341

            
342
1579
        match &item.item {
343
1573
            ShapedItem::Cluster(cluster) => {
344
1573
                let writing_mode = cluster.style.writing_mode;
345
1573
                process_glyphs(&cluster.glyphs, item.position.x, writing_mode, cluster.source_node_id, &cluster.style);
346
1573
            }
347
2
            ShapedItem::CombinedBlock { glyphs, style, .. } => {
348
2
                // CombinedBlock (tate-chu-yoko) carries raw per-glyph advances/GPOS
349
2
                // offsets, NOT pre-accumulated pen positions. Feed the WHOLE slice to
350
2
                // `process_glyphs` in ONE call so the pen advances between glyphs;
351
2
                // calling it once per glyph reset pen_x each time and stacked every
352
2
                // glyph at the same x (mirrors get_glyph_positions). Use None for
353
2
                // source_node_id (tate-chu-yoko has no single source node).
354
2
                let writing_mode = style.writing_mode;
355
2
                process_glyphs(glyphs, item.position.x, writing_mode, None, style);
356
2
            }
357
4
            _ => {}
358
        }
359
    }
360

            
361
83
    if let Some(run) = current_run {
362
78
        runs.push(run);
363
78
    }
364

            
365
83
    suppress_split_border_fragments(&mut runs);
366

            
367
83
    runs
368
83
}
369

            
370
/// +spec:box-model:6c62d3 - suppress margins/borders/padding at inline box split points
371
/// CSS 2.2 §9.4.2: When an inline box is split across lines, margins, borders,
372
/// and padding have no visible effect at the split points.
373
/// Post-process: for runs from the same `source_node_id` that have borders,
374
/// mark intermediate fragments so `left_inset()`/`right_inset()` suppress edges.
375
/// Shared by [`get_glyph_runs_simple`] and its dense twin
376
/// (`crate::text3::dense::get_glyph_runs_simple_dense`) so the fragment
377
/// semantics cannot drift between the two walkers.
378
150704
pub(crate) fn suppress_split_border_fragments(runs: &mut [SimpleGlyphRun]) {
379
150704
    if runs.len() > 1 {
380
19950
        let mut i = 0;
381
65769
        while i < runs.len() {
382
45819
            if let Some(node_id) = runs[i].source_node_id {
383
44471
                if runs[i].border.is_some() {
384
48
                    let start = i;
385
48
                    let mut end = i + 1;
386
186
                    while end < runs.len()
387
156
                        && runs[end].source_node_id == Some(node_id)
388
138
                        && runs[end].border.is_some()
389
138
                    {
390
138
                        end += 1;
391
138
                    }
392
48
                    if end - start > 1 {
393
21
                        if let Some(ref mut b) = runs[start].border {
394
21
                            b.is_last_fragment = false;
395
21
                        }
396
117
                        for run in &mut runs[start + 1..end - 1] {
397
117
                            if let Some(ref mut b) = run.border {
398
117
                                b.is_first_fragment = false;
399
117
                                b.is_last_fragment = false;
400
117
                            }
401
                        }
402
21
                        if let Some(ref mut b) = runs[end - 1].border {
403
21
                            b.is_first_fragment = false;
404
21
                        }
405
27
                    }
406
48
                    i = end;
407
48
                    continue;
408
44423
                }
409
1348
            }
410
45771
            i += 1;
411
        }
412
130754
    }
413
150704
}
414

            
415
/// A glyph run optimized for PDF rendering.
416
///
417
/// Groups glyphs by font, color, size, and style, while breaking at line boundaries.
418
/// This struct is used by the PDF renderer to efficiently render text with proper
419
/// styling, including inline background colors for `<span>` elements.
420
///
421
/// # Z-Order for Inline Backgrounds
422
///
423
/// The `background_color` field enables proper z-ordering of inline backgrounds:
424
/// - PDF renderers should iterate over all runs and render backgrounds FIRST
425
/// - Then iterate again and render all text SECOND
426
/// - This ensures backgrounds appear behind text, not on top of it
427
///
428
/// The display list (`paint_inline_content`) does NOT emit `push_rect()` for inline
429
/// backgrounds because that would cause double-rendering and z-order issues.
430
#[derive(Debug, Clone)]
431
pub struct PdfGlyphRun<T: ParsedFontTrait> {
432
    /// The glyphs in this run with their absolute positions
433
    pub glyphs: Vec<PdfPositionedGlyph>,
434
    /// The color of the text
435
    pub color: ColorU,
436
    /// Background color for inline elements (e.g., `<span style="background: yellow">`)
437
    ///
438
    /// This is rendered as a filled rectangle behind the text by the PDF renderer.
439
    /// The rectangle spans from ascent to descent and covers the full width of the run.
440
    pub background_color: Option<ColorU>,
441
    /// The font used for this run
442
    pub font: T,
443
    /// Font hash for identification
444
    pub font_hash: u64,
445
    /// Font size in pixels
446
    pub font_size_px: f32,
447
    /// Text decoration flags
448
    pub text_decoration: crate::text3::cache::TextDecoration,
449
    /// The line index this run belongs to (for breaking runs at line boundaries)
450
    pub line_index: usize,
451
    /// Text direction for this run
452
    pub direction: crate::text3::cache::BidiDirection,
453
    /// Writing mode for this run
454
    pub writing_mode: crate::text3::cache::WritingMode,
455
    /// The starting position (baseline) of this run - used for `SetTextMatrix`
456
    pub baseline_start: Point,
457
    /// Original cluster text for debugging/CID mapping
458
    pub cluster_texts: Vec<String>,
459
}
460

            
461
/// A glyph with its absolute position and cluster text for PDF rendering
462
#[derive(Debug, Clone)]
463
pub struct PdfPositionedGlyph {
464
    /// Glyph ID
465
    pub glyph_id: u16,
466
    /// Absolute position on the baseline (Y-down coordinate system)
467
    pub position: Point,
468
    /// The advance width of this glyph
469
    pub advance: f32,
470
    /// The Unicode character(s) this glyph represents (for PDF `ToUnicode` `CMap`)
471
    /// This is extracted from the cluster text using the glyph's `cluster_offset`
472
    pub unicode_codepoint: String,
473
}
474

            
475
/// Extract glyph runs optimized for PDF rendering.
476
/// This function:
477
/// - Groups consecutive glyphs by font, color, size, style, and line
478
/// - Breaks runs at line boundaries (different `line_index`)
479
/// - Preserves absolute positioning for each glyph (critical for RTL and complex scripts)
480
/// - Includes cluster text for proper CID/Unicode mapping
481
#[allow(clippy::float_cmp)] // intentional exact compare: change-detection / identity fast-path / cache-key match
482
24
#[must_use] pub fn get_glyph_runs_pdf<T: ParsedFontTrait>(
483
24
    layout: &UnifiedLayout,
484
24
    fonts: &LoadedFonts<T>,
485
24
) -> Vec<PdfGlyphRun<T>> {
486
24
    let mut runs: Vec<PdfGlyphRun<T>> = Vec::new();
487
24
    let mut current_run: Option<PdfGlyphRun<T>> = None;
488

            
489
160
    for positioned_item in &layout.items {
490
        // Only process text clusters
491
136
        let ShapedItem::Cluster(cluster) = &positioned_item.item else {
492
4
            continue; // Skip non-text items
493
        };
494

            
495
132
        if cluster.glyphs.is_empty() {
496
1
            continue;
497
131
        }
498

            
499
        // Calculate the baseline position for this cluster
500
131
        let (item_ascent, _) = get_item_vertical_metrics_approx(&positioned_item.item);
501
131
        let baseline_y = positioned_item.position.y + item_ascent;
502

            
503
        // Process each glyph in the cluster
504
131
        let mut pen_x = positioned_item.position.x;
505

            
506
        // For extracting the correct unicode codepoint per glyph, we need to track
507
        // which portion of the cluster text each glyph represents.
508
        // The cluster_offset in ShapedGlyph is the byte offset into cluster.text
509
131
        let cluster_text = cluster.text();
510
131
        let cluster_glyphs_count = cluster.glyphs.len();
511

            
512
143
        for (glyph_idx, glyph) in cluster.glyphs.iter().enumerate() {
513
143
            let glyph_color = cluster.style.color;
514
143
            let glyph_background = cluster.style.background_color;
515
143
            let font_hash = glyph.font_hash;
516
143
            let font_size_px = cluster.style.font_size_px;
517
143
            let text_decoration = cluster.style.text_decoration;
518
143
            let line_index = positioned_item.line_index;
519
143
            let direction = cluster.direction;
520
143
            let writing_mode = cluster.style.writing_mode;
521

            
522
            // Look up the font from the fonts container
523
143
            let font = match fonts.get_by_hash(font_hash) {
524
140
                Some(f) => f.clone(),
525
3
                None => continue, // Skip glyphs with unknown fonts
526
            };
527

            
528
            // Calculate absolute glyph position on baseline
529
140
            let glyph_position = Point {
530
140
                x: pen_x + glyph.offset.x,
531
140
                y: baseline_y - glyph.offset.y, // Y-down: subtract positive GPOS offset
532
140
            };
533

            
534
            // Extract the unicode codepoint for this specific glyph
535
            // For simple 1:1 mappings, each glyph gets one character
536
            // For complex scripts (ligatures, etc.), we may need to assign
537
            // the whole cluster text to the first glyph, or split it appropriately
538
140
            let unicode_codepoint = if cluster_glyphs_count == 1 {
539
                // Simple case: one glyph represents the entire cluster
540
122
                cluster_text.to_string()
541
            } else {
542
                // Multiple glyphs in cluster - try to extract the character at cluster_offset
543
                // cluster_offset is the byte offset into the cluster text
544
18
                let byte_offset = glyph.cluster_offset as usize;
545
18
                if byte_offset < cluster_text.len() {
546
                    // Get the character at this byte offset
547
12
                    cluster_text[byte_offset..]
548
12
                        .chars()
549
12
                        .next().map_or_else(|| cluster_text.to_string(), |c| c.to_string())
550
                } else {
551
                    // Fallback: if offset is out of range, use the whole cluster for first glyph
552
                    // or empty for subsequent glyphs (they share the same codepoint)
553
6
                    if glyph_idx == 0 {
554
3
                        cluster_text.to_string()
555
                    } else {
556
3
                        String::new()
557
                    }
558
                }
559
            };
560

            
561
140
            let pdf_glyph = PdfPositionedGlyph {
562
140
                glyph_id: glyph.glyph_id,
563
140
                position: glyph_position,
564
140
                advance: glyph.advance,
565
140
                unicode_codepoint,
566
140
            };
567

            
568
            // Font hash change = font change (shaping must break per spec).
569
            // Border/background change = margin/border/padding non-zero (shaping must break).
570
            // Text-decoration change = rendering-only break (shaping unaffected per spec).
571
140
            let should_break = current_run.as_ref().is_some_and(|run| run.font_hash != font_hash
572
118
                    || run.color != glyph_color
573
117
                    || run.background_color != glyph_background
574
116
                    || run.font_size_px != font_size_px
575
114
                    || run.text_decoration != text_decoration
576
114
                    || run.line_index != line_index
577
108
                    || run.direction != direction || run.writing_mode != writing_mode);
578

            
579
140
            if should_break {
580
                // Finalize the current run and start a new one
581
12
                if let Some(run) = current_run.take() {
582
12
                    runs.push(run);
583
12
                }
584
128
            }
585

            
586
140
            if let Some(run) = current_run.as_mut() {
587
107
                // Add to existing run
588
107
                run.glyphs.push(pdf_glyph);
589
107
                run.cluster_texts.push(cluster.text().to_string());
590
121
            } else {
591
33
                // Start a new run
592
33
                current_run = Some(PdfGlyphRun {
593
33
                    glyphs: vec![pdf_glyph],
594
33
                    color: glyph_color,
595
33
                    background_color: glyph_background,
596
33
                    font: font.clone(),
597
33
                    font_hash,
598
33
                    font_size_px,
599
33
                    text_decoration,
600
33
                    line_index,
601
33
                    direction,
602
33
                    writing_mode,
603
33
                    baseline_start: Point {
604
33
                        x: pen_x,
605
33
                        y: baseline_y,
606
33
                    },
607
33
                    cluster_texts: vec![cluster.text().to_string()],
608
33
                });
609
33
            }
610

            
611
            // Advance pen position - DON'T add kerning here because it's already
612
            // included in the positioned_item.position.x from the layout engine!
613
            // We only advance by the base advance to track our position within this cluster
614
140
            pen_x += glyph.advance + glyph.kerning;
615
        }
616
    }
617

            
618
    // Push the final run if any
619
24
    if let Some(run) = current_run {
620
21
        runs.push(run);
621
21
    }
622

            
623
24
    runs
624
24
}
625

            
626
/// Transforms the final layout into a simple list of glyphs and their absolute positions.
627
///
628
/// This function iterates through all positioned items in a layout, filtering for text clusters
629
/// and combined text blocks. It calculates the absolute baseline position for each glyph within
630
/// these items and returns a flat vector of `PositionedGlyph` structs. This is useful for
631
/// rendering or for clients that need a lower-level representation of the text layout.
632
///
633
/// # Arguments
634
///
635
/// - `layout` - A reference to the final `UnifiedLayout` produced by the pipeline.
636
///
637
/// # Returns
638
///
639
/// A `Vec<PositionedGlyph>` containing all glyphs from the layout with their
640
/// absolute baseline positions.
641
41
#[must_use] pub fn get_glyph_positions(layout: &UnifiedLayout) -> Vec<PositionedGlyph> {
642
41
    let mut final_glyphs = Vec::new();
643

            
644
887
    for item in &layout.items {
645
846
        let (item_ascent, _) = get_item_vertical_metrics_approx(&item.item);
646
846
        let baseline_y = item.position.y + item_ascent;
647

            
648
846
        let mut process_glyphs = |positioned_glyphs: &[ShapedGlyph], item_origin_x: f32| {
649
842
            let mut pen_x = item_origin_x;
650
11693
            for glyph in positioned_glyphs {
651
10851
                // The glyph's final position is its origin on the baseline.
652
10851
                // GPOS y-offsets shift the glyph up or down relative to the baseline.
653
10851
                // In a Y-down coordinate system, a positive GPOS offset (up) means
654
10851
                // subtracting from Y.
655
10851
                let glyph_pos = Point {
656
10851
                    x: pen_x + glyph.offset.x,
657
10851
                    y: baseline_y - glyph.offset.y,
658
10851
                };
659
10851

            
660
10851
                final_glyphs.push(PositionedGlyph {
661
10851
                    glyph_id: glyph.glyph_id,
662
10851
                    position: glyph_pos,
663
10851
                    advance: glyph.advance,
664
10851
                });
665
10851

            
666
10851
                // Advance the pen for the next glyph in the cluster/block.
667
10851
                pen_x += glyph.advance + glyph.kerning;
668
10851
            }
669
842
        };
670

            
671
846
        match &item.item {
672
839
            ShapedItem::Cluster(cluster) => {
673
839
                process_glyphs(&cluster.glyphs, item.position.x);
674
839
            }
675
3
            ShapedItem::CombinedBlock { glyphs, .. } => {
676
3
                // This assumes horizontal layout for the combined block's glyphs.
677
3
                process_glyphs(glyphs, item.position.x);
678
3
            }
679
4
            _ => {
680
4
                // Ignore non-text items like objects, breaks, etc.
681
4
            }
682
        }
683
    }
684

            
685
41
    final_glyphs
686
41
}
687

            
688
/// Adversarial unit tests generated for `layout/src/text3/glyphs.rs`.
689
///
690
/// The three public entry points are pure transforms over a `UnifiedLayout`, so the
691
/// interesting failure modes are all in the pen arithmetic (NaN / ±inf / negative
692
/// kerning), the run-splitting predicates (which use `==` on `f32`), the UTF-8 slicing
693
/// in the PDF codepoint extraction, and the inline-box fragment post-pass.
694
///
695
/// Fixtures use `units_per_em == 0` metrics on purpose: that makes
696
/// `get_item_vertical_metrics_approx` skip the glyph, so ascent is exactly `0.0` and the
697
/// baseline lands exactly on `item.position.y`. Every asserted coordinate is therefore
698
/// exact rather than approximate.
699
///
700
/// Where a function has surprising-but-real behaviour, the test PINS it and says so
701
/// (see `pdf_cluster_offset_inside_multibyte_char_panics` and
702
/// `pdf_unknown_font_glyph_does_not_advance_the_pen`, both of which are genuine bugs).
703
///
704
/// Note: `Point`'s `PartialEq` is `round_eq` (rounds to `isize`), so all coordinate
705
/// assertions compare the raw `f32` fields instead of whole `Point`s.
706
#[cfg(test)]
707
#[allow(
708
    clippy::float_cmp,
709
    clippy::too_many_lines,
710
    clippy::unreadable_literal,
711
    clippy::similar_names,
712
    clippy::cast_precision_loss,
713
    clippy::cast_possible_truncation
714
)]
715
mod autotest_generated {
716
    use std::sync::Arc;
717

            
718
    use azul_core::geom::LogicalSize;
719
    use rust_fontconfig::FontId;
720

            
721
    use super::*;
722
    use crate::text3::{
723
        cache::{
724
            BidiDirection, BreakType, ClearType, ContentIndex, Glyph, GlyphKind,
725
            GraphemeClusterId, InlineBreak, InlineContent, InlineSpace, LayoutError,
726
            LayoutFontMetrics, OverflowInfo, PositionedItem, Rect, ShallowClone, ShapedCluster,
727
            StyleProperties, TextDecoration, VerticalMetrics, WritingMode,
728
        },
729
        script::{Language, Script},
730
    };
731

            
732
    const FONT_A: u64 = 0xAAAA_AAAA;
733
    const FONT_B: u64 = 0xBBBB_BBBB;
734

            
735
    // ---------------------------------------------------------------------
736
    // Fixtures
737
    // ---------------------------------------------------------------------
738

            
739
    /// `units_per_em == 0` => `get_item_vertical_metrics_approx` returns `(0.0, 0.0)`,
740
    /// so `baseline_y == item.position.y` and every coordinate below is exact.
741
    fn zero_metrics() -> LayoutFontMetrics {
742
        LayoutFontMetrics {
743
            ascent: 0.0,
744
            descent: 0.0,
745
            line_gap: 0.0,
746
            units_per_em: 0,
747
            x_height: None,
748
            cap_height: None,
749
        }
750
    }
751

            
752
    fn style() -> Arc<StyleProperties> {
753
        Arc::new(StyleProperties::default())
754
    }
755

            
756
    fn styled(f: impl FnOnce(&mut StyleProperties)) -> Arc<StyleProperties> {
757
        let mut s = StyleProperties::default();
758
        f(&mut s);
759
        Arc::new(s)
760
    }
761

            
762
    fn rgba(r: u8, g: u8, b: u8, a: u8) -> ColorU {
763
        ColorU { r, g, b, a }
764
    }
765

            
766
    /// A glyph with the standard degenerate metrics, sitting at font `FONT_A`.
767
    fn glyph(glyph_id: u16, advance: f32, st: &Arc<StyleProperties>) -> ShapedGlyph {
768
        ShapedGlyph {
769
            kind: GlyphKind::Character,
770
            glyph_id,
771
            cluster_offset: 0,
772
            advance,
773
            kerning: 0.0,
774
            offset: Point { x: 0.0, y: 0.0 },
775
            vertical_advance: 0.0,
776
            vertical_offset: Point { x: 0.0, y: 0.0 },
777
            script: Script::Latin,
778
            font_hash: FONT_A,
779
            font_metrics: zero_metrics(),
780
        }
781
    }
782

            
783
    /// Returns the `ShapedCluster` (not the `ShapedItem`) so tests can override
784
    /// `direction` / `style` / `text` before wrapping it with [`item`].
785
    fn cluster(text: &str, glyphs: Vec<ShapedGlyph>, st: &Arc<StyleProperties>) -> ShapedCluster {
786
        ShapedCluster {
787
            flags: crate::text3::cache::ClusterFlags::classify(text),
788
            source_text: Arc::from(text), source_byte_len: text.len() as u16,
789
            source_cluster_id: GraphemeClusterId {
790
                source_run: 0,
791
                start_byte_in_run: 0,
792
            },
793
            source_content_index: ContentIndex {
794
                run_index: 0,
795
                item_index: 0,
796
            },
797
            source_node_id: None,
798
            glyphs: glyphs.into_iter().collect(),
799
            advance: 0.0,
800
            direction: BidiDirection::Ltr,
801
            style: st.clone(),
802
            marker_position_outside: None,
803
            is_first_fragment: true,
804
            is_last_fragment: true,
805
        }
806
    }
807

            
808
    fn item(c: ShapedCluster) -> ShapedItem {
809
        ShapedItem::Cluster(c)
810
    }
811

            
812
    fn combined(glyphs: Vec<ShapedGlyph>) -> ShapedItem {
813
        ShapedItem::CombinedBlock {
814
            style: Arc::new(StyleProperties::default()),
815
            source: ContentIndex {
816
                run_index: 0,
817
                item_index: 0,
818
            },
819
            glyphs: glyphs.into_iter().collect(),
820
            // height 0 keeps the fallback ascent (`0.8 * height`) at exactly 0.
821
            bounds: Rect {
822
                x: 0.0,
823
                y: 0.0,
824
                width: 0.0,
825
                height: 0.0,
826
            },
827
            baseline_offset: 0.0,
828
        }
829
    }
830

            
831
    fn tab() -> ShapedItem {
832
        ShapedItem::Tab {
833
            source: ContentIndex {
834
                run_index: 0,
835
                item_index: 0,
836
            },
837
            bounds: Rect {
838
                x: 0.0,
839
                y: 0.0,
840
                width: 8.0,
841
                height: 0.0,
842
            },
843
        }
844
    }
845

            
846
    fn hard_break() -> ShapedItem {
847
        ShapedItem::Break {
848
            source: ContentIndex {
849
                run_index: 0,
850
                item_index: 0,
851
            },
852
            break_info: InlineBreak {
853
                break_type: BreakType::Hard,
854
                clear: ClearType::None,
855
                content_index: 0,
856
            },
857
        }
858
    }
859

            
860
    fn object() -> ShapedItem {
861
        ShapedItem::Object {
862
            source: ContentIndex {
863
                run_index: 0,
864
                item_index: 0,
865
            },
866
            bounds: Rect {
867
                x: 0.0,
868
                y: 0.0,
869
                width: 10.0,
870
                height: 10.0,
871
            },
872
            baseline_offset: 0.0,
873
            content: InlineContent::Space(InlineSpace {
874
                width: 10.0,
875
                is_breaking: false,
876
                is_stretchy: false,
877
            }),
878
        }
879
    }
880

            
881
    fn at(i: ShapedItem, x: f32, y: f32, line_index: usize) -> PositionedItem {
882
        PositionedItem {
883
            item: i,
884
            position: Point { x, y },
885
            line_index,
886
        }
887
    }
888

            
889
    fn layout(items: Vec<PositionedItem>) -> UnifiedLayout {
890
        UnifiedLayout {
891
            items,
892
            overflow: OverflowInfo::default(),
893
        }
894
    }
895

            
896
    fn border() -> InlineBorderInfo {
897
        InlineBorderInfo {
898
            left: 2.0,
899
            right: 2.0,
900
            ..InlineBorderInfo::default()
901
        }
902
    }
903

            
904
    /// A minimal in-memory `ParsedFontTrait` so `LoadedFonts` can be populated
905
    /// without touching the filesystem or fontconfig.
906
    #[derive(Debug, Clone)]
907
    struct TestFont {
908
        hash: u64,
909
    }
910

            
911
    impl ShallowClone for TestFont {
912
        fn shallow_clone(&self) -> Self {
913
            self.clone()
914
        }
915
    }
916

            
917
    impl ParsedFontTrait for TestFont {
918
        fn shape_text(
919
            &self,
920
            _text: &str,
921
            _script: Script,
922
            _language: Language,
923
            _direction: BidiDirection,
924
            _style: &StyleProperties,
925
        ) -> Result<Vec<Glyph>, LayoutError> {
926
            Ok(Vec::new())
927
        }
928
        fn get_hash(&self) -> u64 {
929
            self.hash
930
        }
931
        fn get_glyph_size(&self, _glyph_id: u16, font_size: f32) -> Option<LogicalSize> {
932
            Some(LogicalSize {
933
                width: font_size,
934
                height: font_size,
935
            })
936
        }
937
        fn get_hyphen_glyph_and_advance(&self, font_size: f32) -> Option<(u16, f32)> {
938
            Some((1, font_size * 0.3))
939
        }
940
        fn get_kashida_glyph_and_advance(&self, font_size: f32) -> Option<(u16, f32)> {
941
            Some((2, font_size * 0.2))
942
        }
943
        fn has_glyph(&self, _codepoint: u32) -> bool {
944
            true
945
        }
946
        fn get_vertical_metrics(&self, _glyph_id: u16) -> Option<VerticalMetrics> {
947
            None
948
        }
949
        fn get_font_metrics(&self) -> LayoutFontMetrics {
950
            zero_metrics()
951
        }
952
        fn num_glyphs(&self) -> u16 {
953
            10
954
        }
955
        fn get_space_width(&self) -> Option<usize> {
956
            Some(500)
957
        }
958
    }
959

            
960
    fn fonts_with(hashes: &[u64]) -> LoadedFonts<TestFont> {
961
        let mut fonts = LoadedFonts::new();
962
        for &h in hashes {
963
            fonts.insert(FontId::new(), TestFont { hash: h });
964
        }
965
        fonts
966
    }
967

            
968
    fn no_fonts() -> LoadedFonts<TestFont> {
969
        LoadedFonts::new()
970
    }
971

            
972
    // =====================================================================
973
    // get_glyph_positions
974
    // =====================================================================
975

            
976
    #[test]
977
    fn positions_empty_layout_yields_no_glyphs() {
978
        assert!(get_glyph_positions(&layout(Vec::new())).is_empty());
979
    }
980

            
981
    #[test]
982
    fn positions_ignore_non_text_items() {
983
        // Object / Tab / Break carry no glyphs and must be skipped, not panicked on.
984
        let l = layout(vec![
985
            at(object(), 0.0, 0.0, 0),
986
            at(tab(), 10.0, 0.0, 0),
987
            at(hard_break(), 20.0, 0.0, 0),
988
        ]);
989
        assert!(get_glyph_positions(&l).is_empty());
990
    }
991

            
992
    #[test]
993
    fn positions_cluster_without_glyphs_yields_nothing() {
994
        let st = style();
995
        let l = layout(vec![at(item(cluster("abc", Vec::new(), &st)), 0.0, 0.0, 0)]);
996
        assert!(get_glyph_positions(&l).is_empty());
997
    }
998

            
999
    #[test]
    fn positions_pen_advances_by_advance_plus_kerning() {
        let st = style();
        let mut g0 = glyph(1, 10.0, &st);
        g0.kerning = 2.0;
        let mut g1 = glyph(2, 10.0, &st);
        g1.kerning = 2.0;
        let g2 = glyph(3, 10.0, &st);
        let l = layout(vec![at(
            item(cluster("abc", vec![g0, g1, g2], &st)),
            100.0,
            50.0,
            0,
        )]);
        let out = get_glyph_positions(&l);
        assert_eq!(out.len(), 3);
        assert_eq!(out[0].position.x, 100.0);
        assert_eq!(out[1].position.x, 112.0, "advance 10 + kerning 2");
        assert_eq!(out[2].position.x, 124.0);
        // upem == 0 => ascent == 0 => baseline == item.position.y, exactly.
        for g in &out {
            assert_eq!(g.position.y, 50.0);
        }
    }
    #[test]
    fn positions_negative_kerning_walks_the_pen_backwards() {
        // Kerning is unbounded below; a tighter-than-advance kern makes x decrease.
        let st = style();
        let mut g = glyph(1, 10.0, &st);
        g.kerning = -12.0;
        let l = layout(vec![at(
            item(cluster("aaa", vec![g.clone(), g.clone(), g], &st)),
            0.0,
            0.0,
            0,
        )]);
        let out = get_glyph_positions(&l);
        assert_eq!(out[0].position.x, 0.0);
        assert_eq!(out[1].position.x, -2.0);
        assert_eq!(out[2].position.x, -4.0);
    }
    #[test]
    fn positions_gpos_y_offset_is_subtracted_in_y_down_space() {
        let st = style();
        let mut g = glyph(1, 10.0, &st);
        g.offset = Point { x: 3.0, y: 7.0 };
        let l = layout(vec![at(item(cluster("a", vec![g], &st)), 0.0, 100.0, 0)]);
        let out = get_glyph_positions(&l);
        assert_eq!(out[0].position.x, 3.0, "x offset is added");
        assert_eq!(
            out[0].position.y, 93.0,
            "positive GPOS y (up) subtracts in Y-down space"
        );
    }
    #[test]
    fn positions_combined_block_glyphs_do_not_stack_at_one_x() {
        // Regression: CombinedBlock carries raw advances, so the pen must accumulate
        // across the whole slice instead of resetting per glyph.
        let st = style();
        let l = layout(vec![at(
            combined(vec![glyph(1, 10.0, &st), glyph(2, 10.0, &st)]),
            100.0,
            0.0,
            0,
        )]);
        let out = get_glyph_positions(&l);
        assert_eq!(out.len(), 2);
        assert_eq!(out[0].position.x, 100.0);
        assert_eq!(out[1].position.x, 110.0);
    }
    #[test]
    fn positions_round_trip_glyph_id_and_advance_verbatim() {
        let st = style();
        let mut g = glyph(u16::MAX, f32::MIN_POSITIVE, &st);
        g.advance = f32::MIN_POSITIVE;
        let l = layout(vec![at(item(cluster("\u{10FFFF}", vec![g], &st)), 0.0, 0.0, 0)]);
        let out = get_glyph_positions(&l);
        assert_eq!(out[0].glyph_id, u16::MAX, "glyph id is copied, not truncated");
        assert_eq!(out[0].advance, f32::MIN_POSITIVE, "advance is copied verbatim");
    }
    #[test]
    fn positions_nan_advance_poisons_later_glyphs_but_does_not_panic() {
        let st = style();
        let l = layout(vec![at(
            item(cluster(
                "ab",
                vec![glyph(1, f32::NAN, &st), glyph(2, 10.0, &st)],
                &st,
            )),
            0.0,
            0.0,
            0,
        )]);
        let out = get_glyph_positions(&l);
        assert_eq!(out.len(), 2);
        assert_eq!(out[0].position.x, 0.0, "first glyph is placed before the NaN advance");
        assert!(
            out[1].position.x.is_nan(),
            "NaN advance propagates into the pen — pinned, not fixed"
        );
    }
    #[test]
    fn positions_huge_advances_saturate_to_infinity_without_panicking() {
        let st = style();
        let l = layout(vec![at(
            item(cluster(
                "abc",
                vec![
                    glyph(1, f32::MAX, &st),
                    glyph(2, f32::MAX, &st),
                    glyph(3, 1.0, &st),
                ],
                &st,
            )),
            0.0,
            0.0,
            0,
        )]);
        let out = get_glyph_positions(&l);
        assert_eq!(out[0].position.x, 0.0);
        assert_eq!(out[1].position.x, f32::MAX);
        assert!(
            out[2].position.x.is_infinite() && out[2].position.x.is_sign_positive(),
            "f32 saturates on overflow instead of wrapping/panicking"
        );
    }
    #[test]
    fn positions_ten_thousand_glyphs_do_not_overflow() {
        let st = style();
        let glyphs: Vec<ShapedGlyph> = (0..10_000u32)
            .map(|i| glyph((i % 65536) as u16, 1.0, &st))
            .collect();
        let l = layout(vec![at(item(cluster("x", glyphs, &st)), 0.0, 0.0, 0)]);
        let out = get_glyph_positions(&l);
        assert_eq!(out.len(), 10_000);
        assert_eq!(out[9_999].position.x, 9_999.0, "integral f32 accumulation is exact here");
    }
    #[test]
    fn positions_pen_resets_at_each_item_origin() {
        let st = style();
        let l = layout(vec![
            at(item(cluster("a", vec![glyph(1, 10.0, &st)], &st)), 0.0, 0.0, 0),
            at(item(cluster("b", vec![glyph(2, 10.0, &st)], &st)), 200.0, 30.0, 1),
        ]);
        let out = get_glyph_positions(&l);
        assert_eq!(out[1].position.x, 200.0, "pen restarts from the item origin");
        assert_eq!(out[1].position.y, 30.0);
    }
    // =====================================================================
    // get_glyph_runs_simple
    // =====================================================================
    #[test]
    fn simple_empty_layout_yields_no_runs() {
        assert!(get_glyph_runs_simple(&layout(Vec::new())).is_empty());
    }
    #[test]
    fn simple_non_text_items_yield_no_runs() {
        let l = layout(vec![
            at(object(), 0.0, 0.0, 0),
            at(tab(), 0.0, 0.0, 0),
            at(hard_break(), 0.0, 0.0, 0),
        ]);
        assert!(get_glyph_runs_simple(&l).is_empty());
    }
    #[test]
    fn simple_uniform_glyphs_merge_into_one_run_across_items() {
        // The current run is NOT flushed at item boundaries — only on a style change.
        let st = style();
        let l = layout(vec![
            at(
                item(cluster("ab", vec![glyph(1, 10.0, &st), glyph(2, 10.0, &st)], &st)),
                0.0,
                0.0,
                0,
            ),
            at(
                item(cluster("cd", vec![glyph(3, 10.0, &st), glyph(4, 10.0, &st)], &st)),
                50.0,
                0.0,
                1,
            ),
        ]);
        let runs = get_glyph_runs_simple(&l);
        assert_eq!(runs.len(), 1, "identical style => one run, even across lines");
        assert_eq!(runs[0].glyphs.len(), 4);
        assert_eq!(runs[0].glyphs[2].point.x, 50.0, "pen still restarts per item");
    }
    #[test]
    fn simple_color_change_splits_the_run() {
        let a = styled(|s| s.color = rgba(255, 0, 0, 255));
        let b = styled(|s| s.color = rgba(0, 0, 255, 255));
        let l = layout(vec![
        // (2026-08-10, per-glyph style removed: a style change can only
        // occur at a CLUSTER boundary — the pipeline splits runs by style
        // BEFORE shaping — so this fixture uses one cluster per style,
        // the only shape the engine can actually produce.)
            at(item(cluster("a", vec![glyph(1, 10.0, &a)], &a)), 0.0, 0.0, 0),
            at(item(cluster("b", vec![glyph(2, 10.0, &b)], &b)), 10.0, 0.0, 0),
        ]);
        let runs = get_glyph_runs_simple(&l);
        assert_eq!(runs.len(), 2);
        assert_eq!(runs[0].color, rgba(255, 0, 0, 255));
        assert_eq!(runs[1].color, rgba(0, 0, 255, 255));
    }
    #[test]
    fn simple_font_hash_change_splits_the_run() {
        let st = style();
        let mut g1 = glyph(2, 10.0, &st);
        g1.font_hash = FONT_B;
        let l = layout(vec![at(
            item(cluster("ab", vec![glyph(1, 10.0, &st), g1], &st)),
            0.0,
            0.0,
            0,
        )]);
        let runs = get_glyph_runs_simple(&l);
        assert_eq!(runs.len(), 2);
        assert_eq!(runs[0].font_hash, FONT_A);
        assert_eq!(runs[1].font_hash, FONT_B);
    }
    #[test]
    fn simple_font_size_and_decoration_changes_split_the_run() {
        let a = style();
        let b = styled(|s| s.font_size_px = 24.0);
        let c = styled(|s| {
            s.font_size_px = 24.0;
            s.text_decoration = TextDecoration {
                underline: true,
                strikethrough: false,
                overline: false,
            };
        });
        let l = layout(vec![
        // (2026-08-10, per-glyph style removed: a style change can only
        // occur at a CLUSTER boundary — the pipeline splits runs by style
        // BEFORE shaping — so this fixture uses one cluster per style,
        // the only shape the engine can actually produce.)
            at(item(cluster("a", vec![glyph(1, 10.0, &a)], &a)), 0.0, 0.0, 0),
            at(item(cluster("b", vec![glyph(2, 10.0, &b)], &b)), 10.0, 0.0, 0),
            at(item(cluster("c", vec![glyph(3, 10.0, &c)], &c)), 20.0, 0.0, 0),
        ]);
        let runs = get_glyph_runs_simple(&l);
        assert_eq!(runs.len(), 3, "size change and decoration change both break");
        assert_eq!(runs[0].font_size_px, 16.0);
        assert_eq!(runs[1].font_size_px, 24.0);
        assert!(runs[2].text_decoration.underline);
    }
    #[test]
    fn simple_background_content_change_splits_the_run() {
        let a = style();
        let b = styled(|s| {
            s.background_content = vec![StyleBackgroundContent::Color(rgba(1, 2, 3, 4))];
        });
        let l = layout(vec![
        // (2026-08-10, per-glyph style removed: a style change can only
        // occur at a CLUSTER boundary — the pipeline splits runs by style
        // BEFORE shaping — so this fixture uses one cluster per style,
        // the only shape the engine can actually produce.)
            at(item(cluster("a", vec![glyph(1, 10.0, &a)], &a)), 0.0, 0.0, 0),
            at(item(cluster("b", vec![glyph(2, 10.0, &b)], &b)), 10.0, 0.0, 0),
        ]);
        let runs = get_glyph_runs_simple(&l);
        assert_eq!(runs.len(), 2);
        assert!(runs[0].background_content.is_empty());
        assert_eq!(runs[1].background_content.len(), 1);
    }
    #[test]
    fn simple_source_node_id_change_splits_the_run() {
        let st = style();
        let mut c0 = cluster("a", vec![glyph(1, 10.0, &st)], &st);
        c0.source_node_id = Some(NodeId::new(3));
        let mut c1 = cluster("b", vec![glyph(2, 10.0, &st)], &st);
        c1.source_node_id = Some(NodeId::new(4));
        let l = layout(vec![
            at(item(c0), 0.0, 0.0, 0),
            at(item(c1), 10.0, 0.0, 0),
        ]);
        let runs = get_glyph_runs_simple(&l);
        assert_eq!(runs.len(), 2, "hit-testing identity must not be merged away");
        assert_eq!(runs[0].source_node_id, Some(NodeId::new(3)));
        assert_eq!(runs[1].source_node_id, Some(NodeId::new(4)));
    }
    #[test]
    fn simple_nan_font_size_forces_one_run_per_glyph() {
        // The run predicate compares font sizes with `==`. NaN != NaN, so a NaN
        // font-size defeats run coalescing entirely: N glyphs => N runs.
        let nan = styled(|s| s.font_size_px = f32::NAN);
        let l = layout(vec![at(
            item(cluster(
                "abc",
                vec![
                    glyph(1, 10.0, &nan),
                    glyph(2, 10.0, &nan),
                    glyph(3, 10.0, &nan),
                ],
                &nan,
            )),
            0.0,
            0.0,
            0,
        )]);
        let runs = get_glyph_runs_simple(&l);
        assert_eq!(runs.len(), 3, "NaN never compares equal => no coalescing");
        assert!(runs.iter().all(|r| r.glyphs.len() == 1));
    }
    #[test]
    fn simple_combined_block_with_no_glyphs_is_a_no_op() {
        // `glyphs.first()` is None => the default writing mode is used and nothing
        // is emitted. Must not panic on the empty slice.
        let l = layout(vec![at(combined(Vec::new()), 0.0, 0.0, 0)]);
        assert!(get_glyph_runs_simple(&l).is_empty());
    }
    #[test]
    fn simple_never_marks_runs_as_ime_preview() {
        let st = style();
        let l = layout(vec![at(item(cluster("a", vec![glyph(1, 10.0, &st)], &st)), 0.0, 0.0, 0)]);
        let runs = get_glyph_runs_simple(&l);
        assert!(!runs[0].is_ime_preview, "this path never sets the IME flag");
    }
    #[test]
    fn simple_run_glyphs_agree_with_get_glyph_positions() {
        // Cross-function invariant: both walk Cluster + CombinedBlock with the same
        // pen arithmetic, so the flattened runs must match the positions 1:1.
        let st = style();
        let l = layout(vec![
            at(
                item(cluster("ab", vec![glyph(1, 10.0, &st), glyph(2, 7.5, &st)], &st)),
                12.0,
                40.0,
                0,
            ),
            at(tab(), 30.0, 40.0, 0),
            at(
                combined(vec![glyph(3, 5.0, &st), glyph(4, 5.0, &st)]),
                60.0,
                80.0,
                1,
            ),
        ]);
        let positions = get_glyph_positions(&l);
        let flat: Vec<GlyphInstance> = get_glyph_runs_simple(&l)
            .into_iter()
            .flat_map(|r| r.glyphs)
            .collect();
        assert_eq!(flat.len(), positions.len(), "same glyph count");
        assert_eq!(flat.len(), 4, "the Tab contributes nothing");
        for (inst, pos) in flat.iter().zip(positions.iter()) {
            assert_eq!(inst.index, u32::from(pos.glyph_id));
            assert_eq!(inst.point.x, pos.position.x);
            assert_eq!(inst.point.y, pos.position.y);
        }
    }
    // --- inline-box split post-pass (CSS 2.2 §9.4.2) ----------------------
    /// n same-node bordered clusters whose only difference is colour => n runs.
    fn bordered_fragments(n: usize) -> UnifiedLayout {
        let node = NodeId::new(7);
        let items = (0..n)
            .map(|i| {
                let st = styled(|s| {
                    s.border = Some(border());
                    s.color = rgba(u8::try_from(i % 256).unwrap_or(0), 0, 0, 255);
                });
                let mut c = cluster("x", vec![glyph(1, 10.0, &st)], &st);
                c.source_node_id = Some(node);
                at(item(c), i as f32 * 10.0, 0.0, i)
            })
            .collect();
        layout(items)
    }
    #[test]
    fn simple_two_fragment_split_suppresses_the_inner_edges() {
        let runs = get_glyph_runs_simple(&bordered_fragments(2));
        assert_eq!(runs.len(), 2);
        let first = runs[0].border.expect("border survives run splitting");
        let last = runs[1].border.expect("border survives run splitting");
        assert!(first.is_first_fragment && !first.is_last_fragment);
        assert!(!last.is_first_fragment && last.is_last_fragment);
    }
    #[test]
    fn simple_three_fragment_split_strips_both_edges_from_the_middle() {
        let runs = get_glyph_runs_simple(&bordered_fragments(3));
        assert_eq!(runs.len(), 3);
        let mid = runs[1].border.expect("border survives run splitting");
        assert!(
            !mid.is_first_fragment && !mid.is_last_fragment,
            "an intermediate fragment draws neither the start nor the end edge"
        );
        assert!(!runs[0].border.unwrap().is_last_fragment);
        assert!(!runs[2].border.unwrap().is_first_fragment);
    }
    #[test]
    fn simple_fragment_scan_terminates_on_a_long_run_chain() {
        // The post-pass advances `i = end` and `continue`s; guard against a
        // non-advancing scan by driving 64 consecutive same-node fragments.
        let runs = get_glyph_runs_simple(&bordered_fragments(64));
        assert_eq!(runs.len(), 64);
        assert!(!runs[0].border.unwrap().is_last_fragment);
        assert!(!runs[63].border.unwrap().is_first_fragment);
        for r in &runs[1..63] {
            let b = r.border.unwrap();
            assert!(!b.is_first_fragment && !b.is_last_fragment);
        }
    }
    #[test]
    fn simple_border_without_source_node_id_is_left_untouched() {
        // No node id => the fragments cannot be proven to belong to one inline box,
        // so both edges stay drawn.
        let a = styled(|s| {
            s.border = Some(border());
            s.color = rgba(255, 0, 0, 255);
        });
        let b = styled(|s| {
            s.border = Some(border());
            s.color = rgba(0, 255, 0, 255);
        });
        let l = layout(vec![
        // (2026-08-10, per-glyph style removed: a style change can only
        // occur at a CLUSTER boundary — the pipeline splits runs by style
        // BEFORE shaping — so this fixture uses one cluster per style,
        // the only shape the engine can actually produce.)
            at(item(cluster("a", vec![glyph(1, 10.0, &a)], &a)), 0.0, 0.0, 0),
            at(item(cluster("b", vec![glyph(2, 10.0, &b)], &b)), 10.0, 0.0, 0),
        ]);
        let runs = get_glyph_runs_simple(&l);
        assert_eq!(runs.len(), 2);
        for r in &runs {
            let bd = r.border.unwrap();
            assert!(bd.is_first_fragment && bd.is_last_fragment);
        }
    }
    #[test]
    fn simple_single_bordered_run_keeps_both_edges() {
        let runs = get_glyph_runs_simple(&bordered_fragments(1));
        assert_eq!(runs.len(), 1);
        let b = runs[0].border.unwrap();
        assert!(
            b.is_first_fragment && b.is_last_fragment,
            "an unsplit inline box draws both edges"
        );
    }
    // =====================================================================
    // get_glyph_runs_pdf
    // =====================================================================
    #[test]
    fn pdf_empty_layout_yields_no_runs() {
        let runs = get_glyph_runs_pdf(&layout(Vec::new()), &fonts_with(&[FONT_A]));
        assert!(runs.is_empty());
    }
    #[test]
    fn pdf_glyphs_with_unknown_fonts_are_dropped() {
        let st = style();
        let l = layout(vec![at(
            item(cluster("ab", vec![glyph(1, 10.0, &st), glyph(2, 10.0, &st)], &st)),
            0.0,
            0.0,
            0,
        )]);
        assert!(
            get_glyph_runs_pdf(&l, &no_fonts()).is_empty(),
            "no font => no run (glyphs are skipped, not defaulted)"
        );
    }
    #[test]
    fn pdf_ignores_combined_blocks_unlike_get_glyph_positions() {
        // Tate-chu-yoko blocks are silently dropped by the PDF path.
        let st = style();
        let l = layout(vec![at(
            combined(vec![glyph(1, 10.0, &st), glyph(2, 10.0, &st)]),
            0.0,
            0.0,
            0,
        )]);
        assert_eq!(get_glyph_positions(&l).len(), 2);
        assert!(
            get_glyph_runs_pdf(&l, &fonts_with(&[FONT_A])).is_empty(),
            "CombinedBlock never reaches the PDF run builder"
        );
    }
    #[test]
    fn pdf_empty_and_non_text_items_are_skipped() {
        let st = style();
        let l = layout(vec![
            at(item(cluster("", Vec::new(), &st)), 0.0, 0.0, 0),
            at(tab(), 0.0, 0.0, 0),
            at(hard_break(), 0.0, 0.0, 0),
            at(object(), 0.0, 0.0, 0),
            at(item(cluster("a", vec![glyph(1, 10.0, &st)], &st)), 5.0, 0.0, 0),
        ]);
        let runs = get_glyph_runs_pdf(&l, &fonts_with(&[FONT_A]));
        assert_eq!(runs.len(), 1, "only the one real cluster survives");
        assert_eq!(runs[0].glyphs.len(), 1);
    }
    #[test]
    fn pdf_single_glyph_cluster_maps_the_whole_cluster_text() {
        let st = style();
        let l = layout(vec![at(
            item(cluster("\u{1F600}", vec![glyph(1, 10.0, &st)], &st)),
            0.0,
            0.0,
            0,
        )]);
        let runs = get_glyph_runs_pdf(&l, &fonts_with(&[FONT_A]));
        assert_eq!(
            runs[0].glyphs[0].unicode_codepoint, "\u{1F600}",
            "a 1:1 cluster carries its full (astral) text for ToUnicode"
        );
    }
    #[test]
    fn pdf_multi_glyph_cluster_offset_past_the_end_falls_back() {
        // byte_offset >= len => whole text for glyph 0, empty for the rest.
        let st = style();
        let mut g0 = glyph(1, 10.0, &st);
        g0.cluster_offset = 99;
        let mut g1 = glyph(2, 10.0, &st);
        g1.cluster_offset = 99;
        let l = layout(vec![at(item(cluster("ab", vec![g0, g1], &st)), 0.0, 0.0, 0)]);
        let runs = get_glyph_runs_pdf(&l, &fonts_with(&[FONT_A]));
        assert_eq!(runs[0].glyphs[0].unicode_codepoint, "ab");
        assert_eq!(runs[0].glyphs[1].unicode_codepoint, "");
    }
    #[test]
    fn pdf_cluster_offset_u32_max_does_not_overflow() {
        let st = style();
        let mut g0 = glyph(1, 10.0, &st);
        g0.cluster_offset = u32::MAX;
        let mut g1 = glyph(2, 10.0, &st);
        g1.cluster_offset = u32::MAX;
        let l = layout(vec![at(item(cluster("ab", vec![g0, g1], &st)), 0.0, 0.0, 0)]);
        let runs = get_glyph_runs_pdf(&l, &fonts_with(&[FONT_A]));
        // `u32::MAX as usize` is not < 2, so both take the out-of-range fallback.
        assert_eq!(runs[0].glyphs[0].unicode_codepoint, "ab");
        assert_eq!(runs[0].glyphs[1].unicode_codepoint, "");
    }
    #[test]
    fn pdf_empty_cluster_text_with_several_glyphs_yields_empty_codepoints() {
        let st = style();
        let l = layout(vec![at(
            item(cluster("", vec![glyph(1, 10.0, &st), glyph(2, 10.0, &st)], &st)),
            0.0,
            0.0,
            0,
        )]);
        let runs = get_glyph_runs_pdf(&l, &fonts_with(&[FONT_A]));
        assert_eq!(runs[0].glyphs.len(), 2);
        assert!(runs[0]
            .glyphs
            .iter()
            .all(|g| g.unicode_codepoint.is_empty()));
    }
    /// BUG (pinned): the codepoint extractor slices `cluster_text[byte_offset..]`
    /// after only checking `byte_offset < len`, never that the offset is a UTF-8
    /// char boundary. A multi-glyph cluster over a multi-byte character (any
    /// decomposed/combining sequence where the shaper reports a mid-char offset)
    /// therefore panics inside `get_glyph_runs_pdf` instead of degrading.
    /// The correct behaviour would be `is_char_boundary()` + fallback.
    #[test]
    #[should_panic(expected = "char boundary")]
    fn pdf_cluster_offset_inside_multibyte_char_panics() {
        let st = style();
        let g0 = glyph(1, 10.0, &st); // cluster_offset 0 — fine
        let mut g1 = glyph(2, 10.0, &st);
        g1.cluster_offset = 1; // 1 < 2, but it is inside 'é'
        let l = layout(vec![at(item(cluster("é", vec![g0, g1], &st)), 0.0, 0.0, 0)]);
        let _ = get_glyph_runs_pdf(&l, &fonts_with(&[FONT_A]));
    }
    /// BUG (pinned): the `continue` that drops an unknown-font glyph happens BEFORE
    /// `pen_x += advance + kerning`, so every glyph after a dropped one is rendered
    /// one advance too far to the left. A font that fails to load silently shifts
    /// the rest of the cluster instead of just omitting a glyph.
    #[test]
    fn pdf_unknown_font_glyph_does_not_advance_the_pen() {
        let st = style();
        let mut g0 = glyph(1, 40.0, &st);
        g0.font_hash = FONT_B; // not in LoadedFonts
        let g1 = glyph(2, 10.0, &st); // FONT_A
        let l = layout(vec![at(item(cluster("ab", vec![g0, g1], &st)), 100.0, 0.0, 0)]);
        let runs = get_glyph_runs_pdf(&l, &fonts_with(&[FONT_A]));
        assert_eq!(runs.len(), 1);
        assert_eq!(runs[0].glyphs.len(), 1, "the unknown-font glyph is dropped");
        assert_eq!(
            runs[0].glyphs[0].position.x, 100.0,
            "pinned: the surviving glyph sits at the origin — the dropped glyph's \
             40px advance was never applied, so it renders 40px too far left"
        );
    }
    #[test]
    fn pdf_line_index_change_breaks_the_run() {
        let st = style();
        let l = layout(vec![
            at(item(cluster("a", vec![glyph(1, 10.0, &st)], &st)), 0.0, 0.0, 0),
            at(item(cluster("b", vec![glyph(2, 10.0, &st)], &st)), 0.0, 20.0, 1),
        ]);
        let runs = get_glyph_runs_pdf(&l, &fonts_with(&[FONT_A]));
        assert_eq!(runs.len(), 2, "runs must not straddle a line boundary");
        assert_eq!(runs[0].line_index, 0);
        assert_eq!(runs[1].line_index, 1);
    }
    #[test]
    fn pdf_direction_change_breaks_the_run() {
        let st = style();
        let ltr = cluster("a", vec![glyph(1, 10.0, &st)], &st);
        let mut rtl = cluster("\u{05D0}", vec![glyph(2, 10.0, &st)], &st);
        rtl.direction = BidiDirection::Rtl;
        let l = layout(vec![
            at(item(ltr), 0.0, 0.0, 0),
            at(item(rtl), 10.0, 0.0, 0),
        ]);
        let runs = get_glyph_runs_pdf(&l, &fonts_with(&[FONT_A]));
        assert_eq!(runs.len(), 2);
        assert_eq!(runs[0].direction, BidiDirection::Ltr);
        assert_eq!(runs[1].direction, BidiDirection::Rtl);
    }
    #[test]
    fn pdf_writing_mode_change_breaks_the_run() {
        // writing_mode is read from the CLUSTER style while size/colour come from the
        // GLYPH style — this pins that the cluster-level property still breaks runs.
        let st = style();
        let vertical = styled(|s| s.writing_mode = WritingMode::VerticalRl);
        let horiz = cluster("a", vec![glyph(1, 10.0, &st)], &st);
        let mut vert = cluster("b", vec![glyph(2, 10.0, &st)], &st);
        vert.style = vertical;
        let l = layout(vec![
            at(item(horiz), 0.0, 0.0, 0),
            at(item(vert), 10.0, 0.0, 0),
        ]);
        let runs = get_glyph_runs_pdf(&l, &fonts_with(&[FONT_A]));
        assert_eq!(runs.len(), 2);
        assert_eq!(runs[0].writing_mode, WritingMode::HorizontalTb);
        assert_eq!(runs[1].writing_mode, WritingMode::VerticalRl);
    }
    #[test]
    fn pdf_background_color_change_breaks_the_run() {
        let plain = style();
        let highlighted = styled(|s| s.background_color = Some(rgba(255, 255, 0, 255)));
        let l = layout(vec![
        // (2026-08-10, per-glyph style removed: a style change can only
        // occur at a CLUSTER boundary — the pipeline splits runs by style
        // BEFORE shaping — so this fixture uses one cluster per style,
        // the only shape the engine can actually produce.)
            at(item(cluster("a", vec![glyph(1, 10.0, &plain)], &plain)), 0.0, 0.0, 0),
            at(item(cluster("b", vec![glyph(2, 10.0, &highlighted)], &highlighted)), 10.0, 0.0, 0),
        ]);
        let runs = get_glyph_runs_pdf(&l, &fonts_with(&[FONT_A]));
        assert_eq!(runs.len(), 2, "inline <span> background must get its own run");
        assert_eq!(runs[0].background_color, None);
        assert_eq!(runs[1].background_color, Some(rgba(255, 255, 0, 255)));
    }
    #[test]
    fn pdf_nan_font_size_breaks_every_glyph_into_its_own_run() {
        // `run.font_size_px != font_size_px` is always TRUE for NaN, so coalescing
        // is defeated — the mirror image of `simple_nan_font_size_forces_one_run_per_glyph`.
        let nan = styled(|s| s.font_size_px = f32::NAN);
        let l = layout(vec![at(
            item(cluster(
                "abc",
                vec![
                    glyph(1, 10.0, &nan),
                    glyph(2, 10.0, &nan),
                    glyph(3, 10.0, &nan),
                ],
                &nan,
            )),
            0.0,
            0.0,
            0,
        )]);
        let runs = get_glyph_runs_pdf(&l, &fonts_with(&[FONT_A]));
        assert_eq!(runs.len(), 3);
    }
    #[test]
    fn pdf_cluster_texts_are_parallel_to_glyphs() {
        // Every run must be able to map glyph i back to its cluster text.
        let st = style();
        let l = layout(vec![
            at(
                item(cluster("ab", vec![glyph(1, 10.0, &st), glyph(2, 10.0, &st)], &st)),
                0.0,
                0.0,
                0,
            ),
            at(item(cluster("c", vec![glyph(3, 10.0, &st)], &st)), 20.0, 0.0, 0),
        ]);
        let runs = get_glyph_runs_pdf(&l, &fonts_with(&[FONT_A]));
        assert_eq!(runs.len(), 1, "same style + same line => one run");
        assert_eq!(
            runs[0].cluster_texts.len(),
            runs[0].glyphs.len(),
            "cluster_texts is per-glyph, not per-cluster"
        );
        assert_eq!(runs[0].cluster_texts, vec!["ab", "ab", "c"]);
    }
    #[test]
    fn pdf_baseline_start_is_the_pen_before_the_gpos_offset() {
        let st = style();
        let mut g = glyph(1, 10.0, &st);
        g.offset = Point { x: 5.0, y: 2.0 };
        let l = layout(vec![at(item(cluster("a", vec![g], &st)), 100.0, 20.0, 0)]);
        let runs = get_glyph_runs_pdf(&l, &fonts_with(&[FONT_A]));
        assert_eq!(runs[0].baseline_start.x, 100.0, "text matrix origin excludes GPOS x");
        assert_eq!(runs[0].baseline_start.y, 20.0, "baseline == item y (ascent 0)");
        assert_eq!(runs[0].glyphs[0].position.x, 105.0, "the glyph itself carries GPOS x");
        assert_eq!(runs[0].glyphs[0].position.y, 18.0, "GPOS y is subtracted (Y-down)");
    }
    #[test]
    fn pdf_pen_accumulates_advance_plus_kerning_within_a_cluster() {
        let st = style();
        let mut g0 = glyph(1, 10.0, &st);
        g0.kerning = -3.0;
        let g1 = glyph(2, 10.0, &st);
        let l = layout(vec![at(item(cluster("ab", vec![g0, g1], &st)), 0.0, 0.0, 0)]);
        let runs = get_glyph_runs_pdf(&l, &fonts_with(&[FONT_A]));
        assert_eq!(runs[0].glyphs[0].position.x, 0.0);
        assert_eq!(runs[0].glyphs[1].position.x, 7.0, "advance 10 + kerning -3");
    }
    #[test]
    fn pdf_infinite_advance_does_not_panic() {
        let st = style();
        let l = layout(vec![at(
            item(cluster(
                "ab",
                vec![glyph(1, f32::INFINITY, &st), glyph(2, 10.0, &st)],
                &st,
            )),
            0.0,
            0.0,
            0,
        )]);
        let runs = get_glyph_runs_pdf(&l, &fonts_with(&[FONT_A]));
        assert_eq!(runs[0].glyphs.len(), 2);
        assert!(runs[0].glyphs[1].position.x.is_infinite());
        assert!(runs[0].glyphs[0].advance.is_infinite(), "advance is copied verbatim");
    }
    // ==================================================================
    // #25: compact glyph-run roundtrip exactness
    // ==================================================================
    fn gi(index: u32, x: f32, y: f32, w: f32, h: f32) -> GlyphInstance {
        GlyphInstance {
            index,
            point: LogicalPosition { x, y },
            size: LogicalSize {
                width: w,
                height: h,
            },
        }
    }
    fn roundtrip(v: Vec<GlyphInstance>) -> (CompactGlyphs, Vec<GlyphInstance>) {
        let c = CompactGlyphs::from_instances(&v);
        let out: Vec<GlyphInstance> = c.iter().collect();
        (c, out)
    }
    /// Bit-exact instance equality (NaN-tolerant — the derived `==` is not).
    fn instances_bit_equal(a: &[GlyphInstance], b: &[GlyphInstance]) -> bool {
        let f = |x: f32, y: f32| x.to_bits() == y.to_bits();
        a.len() == b.len()
            && a.iter().zip(b.iter()).all(|(g, h)| {
                g.index == h.index
                    && f(g.point.x, h.point.x)
                    && f(g.point.y, h.point.y)
                    && f(g.size.width, h.size.width)
                    && f(g.size.height, h.size.height)
            })
    }
    #[test]
    fn compact_glyphs_roundtrips_a_uniform_run_with_no_exceptions() {
        let v = vec![
            gi(5, 0.0, 12.5, 0.0, 0.0),
            gi(9, 6.0, 12.5, 0.0, 0.0),
            gi(2, 11.0, 12.5, 0.0, 0.0),
        ];
        let (c, out) = roundtrip(v.clone());
        assert!(instances_bit_equal(&v, &out));
        assert!(
            c.exceptions.is_empty(),
            "uniform y/size must compact without exceptions — the whole point \
             of the encoding (got {} exceptions)",
            c.exceptions.len()
        );
        assert_eq!(c.len(), 3);
        assert!(instances_bit_equal(&[c.first().unwrap()], &v[..1]));
        assert!(instances_bit_equal(&[c.last().unwrap()], &v[2..]));
    }
    #[test]
    fn compact_glyphs_routes_y_deviants_through_exceptions_exactly() {
        // A combining mark with a vertical offset: y deviates mid-run.
        let v = vec![
            gi(1, 0.0, 10.0, 0.0, 0.0),
            gi(2, 6.0, 7.25, 0.0, 0.0), // mark, raised
            gi(3, 6.0, 10.0, 0.0, 0.0),
        ];
        let (c, out) = roundtrip(v.clone());
        assert!(instances_bit_equal(&v, &out));
        assert_eq!(c.exceptions.len(), 1);
        assert_eq!(c.exceptions[0].0, 1);
    }
    #[test]
    fn compact_glyphs_first_exception_covers_the_shared_slot_choice() {
        // The FIRST glyph defines the shared y — a later majority does not
        // re-vote. Glyph 0 deviating from the rest must still roundtrip.
        let v = vec![
            gi(1, 0.0, 3.0, 0.0, 0.0),
            gi(2, 5.0, 9.0, 0.0, 0.0),
            gi(3, 9.0, 9.0, 0.0, 0.0),
        ];
        let (c, out) = roundtrip(v.clone());
        assert!(instances_bit_equal(&v, &out));
        // shared y = 3.0 (first), so glyphs 1..3 are the exceptions.
        assert_eq!(c.exceptions.len(), 2);
        assert!(instances_bit_equal(&[c.first().unwrap()], &v[..1]));
        assert!(instances_bit_equal(&[c.last().unwrap()], &v[2..]));
    }
    #[test]
    fn compact_glyphs_roundtrips_nan_and_size_deviants() {
        // NaN y: to_bits comparison makes it an exception (NaN != NaN under
        // `==`, which would silently mark EVERY glyph exceptional or none).
        let v = vec![
            gi(1, 0.0, f32::NAN, 1.0, 2.0),
            gi(2, 4.0, f32::NAN, 1.0, 2.0),
            gi(3, 8.0, f32::NAN, 3.0, 2.0), // size deviates too
        ];
        let (c, out) = roundtrip(v.clone());
        assert!(instances_bit_equal(&v, &out));
        // Same-bit NaNs MATCH the shared slot (bit compare): only the size
        // deviant is an exception.
        assert_eq!(c.exceptions.len(), 1);
        assert_eq!(c.exceptions[0].0, 2);
    }
    #[test]
    fn compact_glyphs_empty_run_is_empty() {
        let (c, out) = roundtrip(Vec::new());
        assert!(c.is_empty());
        assert!(out.is_empty());
        assert!(c.first().is_none());
        assert!(c.last().is_none());
        assert_eq!(c.retained_bytes(), 0);
    }
    #[test]
    fn compact_glyphs_offset_expansion_matches_manual_offset() {
        let v = vec![gi(1, 1.0, 2.0, 0.0, 0.0), gi(2, 3.5, 2.0, 0.0, 0.0)];
        let c = CompactGlyphs::from_instances(&v);
        let shifted = c.to_vec_offset(10.0, 20.0);
        let manual: Vec<GlyphInstance> = v
            .iter()
            .map(|g| {
                let mut g = *g;
                g.point.x += 10.0;
                g.point.y += 20.0;
                g
            })
            .collect();
        assert!(instances_bit_equal(&shifted, &manual));
    }
    #[test]
    fn compact_run_roundtrip_preserves_the_header() {
        let style = styled(|s| {
            s.color = rgba(10, 20, 30, 255);
        });
        let l = layout(vec![at(
            item(cluster("ab", vec![glyph(4, 6.0, &style), glyph(7, 6.0, &style)], &style)),
            0.0,
            0.0,
            0,
        )]);
        let runs = get_glyph_runs_simple(&l);
        assert_eq!(runs.len(), 1);
        let compact = CompactGlyphRun::from(runs[0].clone());
        assert!(
            simple_runs_bit_equal(&compact.expand(), &runs[0]),
            "builder-produced run must roundtrip bit-exactly"
        );
    }
}