1
//! Core types and layout pipeline for the text/inline formatting context.
2
//!
3
//! This module defines the central data structures (`UnifiedConstraints`,
4
//! `LayoutCache`, `FontManager`, `UnifiedLayout`, etc.) and implements the
5
//! 5-stage inline layout pipeline:
6
//!
7
//! 1. **Logical Analysis** — `InlineContent` → `LogicalItem`
8
//! 2. **`BiDi` Reordering** — `LogicalItem` → `VisualItem`
9
//! 3. **Shaping** — `VisualItem` → `ShapedItem`
10
//! 4. **Text Orientation** — vertical writing-mode transforms
11
//! 5. **Flow / Positioning** — line breaking + final `PositionedItem` placement
12
//!
13
//! The module also contains cursor movement helpers, caching infrastructure
14
//! (per-item and monolithic), and font management (`FontContext`, `FontManager`,
15
//! `LoadedFonts`).  Integration with the box layout solver lives in
16
//! `solver3/fc.rs`.
17

            
18
use std::{
19
    cmp::Ordering,
20
    collections::{
21
        hash_map::{DefaultHasher, HashMap},
22
        BTreeSet, HashSet,
23
    },
24
    hash::{Hash, Hasher},
25
    mem::discriminant,
26
    num::NonZeroUsize,
27
    sync::{Arc, Mutex},
28
};
29

            
30
pub use azul_core::selection::{ContentIndex, GraphemeClusterId};
31
use azul_core::{
32
    dom::NodeId,
33
    geom::{LogicalPosition, LogicalRect, LogicalSize},
34
    resources::ImageRef,
35
    selection::{CursorAffinity, SelectionRange, TextCursor},
36
    ui_solver::GlyphInstance,
37
};
38
use azul_css::{
39
    corety::LayoutDebugMessage, props::basic::ColorU, props::style::StyleBackgroundContent,
40
};
41
#[cfg(feature = "text_layout_hyphenation")]
42
use hyphenation::{Hyphenator, Language as HyphenationLanguage, Load, Standard};
43
use rust_fontconfig::{FcFontCache, FcPattern, FcStretch, FcWeight, FontId, PatternMatch, UnicodeRange};
44
use smallvec::{smallvec, SmallVec};
45
use unicode_bidi::{BidiInfo, Level, TextSource};
46
use unicode_segmentation::UnicodeSegmentation;
47

            
48
// --- Named constants for layout heuristics ---
49

            
50
/// Fraction of line-height used as ascent when no font metrics are available.
51
/// Matches the typical 80/20 ascent/descent ratio found in Latin fonts.
52
const FALLBACK_ASCENT_RATIO: f32 = 0.8;
53
const FALLBACK_DESCENT_RATIO: f32 = 1.0 - FALLBACK_ASCENT_RATIO;
54

            
55
// Strut/metric fallbacks below assume the CSS-initial 16px font size when no
56
// explicit size is set.
57

            
58
/// Default strut ascent: `FALLBACK_ASCENT_RATIO` * (16px * `DEFAULT_LINE_HEIGHT_FACTOR`)
59
const DEFAULT_STRUT_ASCENT: f32 = 12.8;
60
/// Default strut descent: `FALLBACK_DESCENT_RATIO` * (16px * `DEFAULT_LINE_HEIGHT_FACTOR`)
61
const DEFAULT_STRUT_DESCENT: f32 = 3.2;
62

            
63
/// Default x-height approximation: 0.5 * 16px (CSS spec fallback).
64
const DEFAULT_X_HEIGHT: f32 = 8.0;
65
/// Cap height of the default strut (0.7 x the 16px default font size), the
66
/// same typical-Latin-ratio approximation the rest of the strut block uses.
67
const DEFAULT_CAP_HEIGHT: f32 = 11.2;
68
/// Default ch-width (advance of '0'): 0.5 * 16px.
69
const DEFAULT_CH_WIDTH: f32 = 8.0;
70

            
71
/// Approximate space character width as a fraction of `font_size`.
72
const SPACE_WIDTH_RATIO: f32 = 0.5;
73

            
74
/// CSS subscript baseline offset as fraction of line ascent (CSS Inline §3).
75
const SUBSCRIPT_OFFSET_RATIO: f32 = 0.3;
76
/// CSS superscript baseline offset as fraction of line ascent (CSS Inline §3).
77
const SUPERSCRIPT_OFFSET_RATIO: f32 = 0.4;
78

            
79
/// Ruby annotation font size relative to the base, per the CSS UA stylesheet
80
/// (`rt { font-size: 50% }`). Used to reserve placeholder width for the
81
/// annotation so a long annotation is not clipped by a short base.
82
const RUBY_ANNOTATION_FONT_SCALE: f32 = 0.5;
83

            
84
/// Computes the reserved box size for a ruby pair (CSS Ruby Layout §3): the inline-size is
85
/// the wider of the base and annotation runs (the narrower is centered over the wider), and
86
/// the block-size stacks the annotation line above the base line so the base reserves
87
/// vertical space for the annotation. Both inputs are REAL shaped advances / resolved line
88
/// heights — no magic per-character ratio.
89
7
fn ruby_reserved_box(
90
7
    base_width: f32,
91
7
    annotation_width: f32,
92
7
    base_line_height: f32,
93
7
    annotation_line_height: f32,
94
7
) -> (f32, f32) {
95
7
    (
96
7
        base_width.max(annotation_width),
97
7
        base_line_height + annotation_line_height,
98
7
    )
99
7
}
100

            
101
/// Glyph storage for a single shaped cluster.
102
///
103
/// Inline one glyph (the
104
/// common case for Latin text), spill to heap for ligatures / combining
105
/// marks / multi-glyph clusters. The `union` feature of smallvec packs
106
/// the inline buffer and the heap pointer into the same bytes, so sizeof
107
/// stays `sizeof(ShapedGlyph) + 2*usize` regardless of inline/heap state.
108
pub type ShapedGlyphVec = SmallVec<[ShapedGlyph; 1]>;
109

            
110
/// CSS `line-height` value.
111
///
112
/// `Normal` defers resolution to the point where font metrics are available,
113
/// computing `(ascent + |descent| + lineGap) / upem * fontSize`.
114
/// `Px` is an already-resolved pixel value from an explicit CSS declaration
115
/// (e.g. `line-height: 1.5` → `Px(fontSize * 1.5)`).
116
#[derive(Debug, Clone, Copy)]
117
#[derive(Default)]
118
pub enum LineHeight {
119
    /// `line-height: normal` — resolve from font metrics at layout time
120
    #[default]
121
    Normal,
122
    /// Pre-resolved pixel value (from CSS `line-height: <number|length|percentage>`)
123
    Px(f32),
124
}
125

            
126

            
127
impl LineHeight {
128
    /// Resolve to a pixel value, using font metrics when `Normal`.
129
    ///
130
    /// `ascent`, `descent` (negative in OpenType convention), `line_gap` are in font units.
131
    /// `font_size_px` and `units_per_em` are used to scale.
132
19686396
    #[must_use] pub fn resolve(&self, font_size_px: f32, ascent: f32, descent: f32, line_gap: f32, units_per_em: u16) -> f32 {
133
19686396
        match self {
134
7708098
            Self::Px(px) => *px,
135
            Self::Normal => {
136
11978298
                if units_per_em == 0 {
137
1534
                    return font_size_px * 1.2; // fallback
138
11976764
                }
139
11976764
                let scale = font_size_px / f32::from(units_per_em);
140
11976764
                (ascent - descent + line_gap) * scale
141
            }
142
        }
143
19686396
    }
144

            
145
    /// Resolve using a `LayoutFontMetrics` struct for convenience.
146
19684853
    #[must_use] pub fn resolve_with_metrics(&self, font_size_px: f32, metrics: &LayoutFontMetrics) -> f32 {
147
19684853
        self.resolve(font_size_px, metrics.ascent, metrics.descent, metrics.line_gap, metrics.units_per_em)
148
19684853
    }
149
}
150

            
151
impl PartialEq for LineHeight {
152
43730
    fn eq(&self, other: &Self) -> bool {
153
43730
        match (self, other) {
154
40416
            (Self::Normal, Self::Normal) => true,
155
3313
            (Self::Px(a), Self::Px(b)) => a.to_bits() == b.to_bits(),
156
1
            _ => false,
157
        }
158
43730
    }
159
}
160

            
161
impl Eq for LineHeight {}
162

            
163
impl Hash for LineHeight {
164
622785
    fn hash<H: Hasher>(&self, state: &mut H) {
165
622785
        discriminant(self).hash(state);
166
622785
        if let Self::Px(v) = self {
167
25753
            v.to_bits().hash(state);
168
597032
        }
169
622785
    }
170
}
171

            
172
// Stub type when hyphenation is disabled
173
#[cfg(not(feature = "text_layout_hyphenation"))]
174
pub struct Standard;
175

            
176
#[cfg(not(feature = "text_layout_hyphenation"))]
177
impl Standard {
178
    /// Stub hyphenate method that returns no breaks
179
    pub fn hyphenate<'a>(&'a self, _word: &'a str) -> StubHyphenationBreaks {
180
        StubHyphenationBreaks { breaks: Vec::new() }
181
    }
182
}
183

            
184
/// Result of hyphenation (stub when feature is disabled)
185
#[cfg(not(feature = "text_layout_hyphenation"))]
186
pub struct StubHyphenationBreaks {
187
    pub breaks: Vec<usize>,
188
}
189

            
190
// Always import Language from script module
191
use crate::text3::script::{script_to_language, Language, Script};
192

            
193
/// Available space for layout, similar to Taffy's `AvailableSpace`.
194
///
195
/// This type explicitly represents the three possible states for available space:
196
///
197
/// - `Definite(f32)`: A specific pixel width is available
198
/// - `MinContent`: Layout should use minimum content width (shrink-wrap)
199
/// - `MaxContent`: Layout should use maximum content width (no line breaks unless necessary)
200
///
201
/// This is critical for proper handling of intrinsic sizing in Flexbox/Grid
202
/// where the available space may be indefinite during the measure phase.
203
#[derive(Debug, Clone, Copy, PartialEq)]
204
pub enum AvailableSpace {
205
    /// A specific amount of space is available (in pixels).
206
    /// Must be >= 0.  A value of 0.0 means "genuinely zero-width container"
207
    /// (e.g. `width: 0px`), NOT "unresolved".
208
    Definite(f32),
209
    /// The node should be laid out under a min-content constraint
210
    MinContent,
211
    /// The node should be laid out under a max-content constraint.
212
    /// This is the correct default: "lay out to natural width, no constraint".
213
    MaxContent,
214
}
215

            
216
impl Default for AvailableSpace {
217
    /// Default is `MaxContent` — the absence of a width constraint.
218
    /// Never `Definite(0.0)`, which would make every word overflow.
219
2
    fn default() -> Self {
220
2
        Self::MaxContent
221
2
    }
222
}
223

            
224
impl AvailableSpace {
225
    /// Returns true if this is a definite (finite, known) amount of space
226
12
    #[must_use] pub const fn is_definite(&self) -> bool {
227
12
        matches!(self, Self::Definite(_))
228
12
    }
229

            
230
    /// Returns true if this is an indefinite (min-content or max-content) constraint
231
6
    #[must_use] pub const fn is_indefinite(&self) -> bool {
232
6
        !self.is_definite()
233
6
    }
234

            
235
    /// Returns the definite value if available, or a fallback for indefinite constraints
236
7
    #[must_use] pub const fn unwrap_or(self, fallback: f32) -> f32 {
237
7
        match self {
238
4
            Self::Definite(v) => v,
239
3
            _ => fallback,
240
        }
241
7
    }
242

            
243
    /// Returns the definite value, or a large value for both min-content and max-content.
244
    /// 
245
    /// For intrinsic sizing, we use a large value to let text lay out fully,
246
    /// then measure the result. The distinction between min/max-content is handled
247
    /// by the line breaking algorithm, not by constraining the available width.
248
    #[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
249
1696
    #[must_use] pub fn to_f32_for_layout(self) -> f32 {
250
1696
        match self {
251
2
            Self::Definite(v) => v,
252
847
            Self::MinContent => f32::MAX / 2.0,
253
847
            Self::MaxContent => f32::MAX / 2.0,
254
        }
255
1696
    }
256

            
257
    /// Create from an f32 value, recognizing special sentinel values.
258
    ///
259
    /// This function provides backwards compatibility with code that uses f32 for constraints:
260
    /// - `f32::INFINITY` or `f32::MAX` → `MaxContent` (no line wrapping)
261
    /// - `0.0` → `MinContent` (maximum line wrapping, return longest word width)
262
    /// - Other values → `Definite(value)`
263
    ///
264
    /// Note: Using sentinel values like 0.0 for `MinContent` is fragile. Prefer using
265
    /// `AvailableSpace::MinContent` directly when possible.
266
10
    #[must_use] pub fn from_f32(value: f32) -> Self {
267
10
        if value.is_infinite() || value >= f32::MAX / 2.0 {
268
            // Treat very large values (including f32::MAX) as MaxContent
269
4
            Self::MaxContent
270
6
        } else if value <= 0.0 {
271
            // Treat zero or negative as MinContent (shrink-wrap)
272
3
            Self::MinContent
273
        } else {
274
3
            Self::Definite(value)
275
        }
276
10
    }
277
}
278

            
279
impl Hash for AvailableSpace {
280
8
    fn hash<H: Hasher>(&self, state: &mut H) {
281
8
        discriminant(self).hash(state);
282
8
        if let Self::Definite(v) = self {
283
            // Hash the full f32 bit pattern, NOT the integer-rounded value. The
284
            // derived `PartialEq` compares `Definite` widths exactly, so rounding
285
            // here both (a) broke sub-pixel precision — a 100.1px vs 100.4px
286
            // constraint can wrap lines differently yet collided in the same hash
287
            // bucket — and (b) was inconsistent with the exact equality used as the
288
            // cache key. `-0.0` is normalized to `+0.0` so the `+0.0 == -0.0`
289
            // PartialEq pair still hashes identically (Hash/Eq contract).
290
4
            let normalized = if *v == 0.0 { 0.0f32 } else { *v };
291
4
            normalized.to_bits().hash(state);
292
4
        }
293
8
    }
294
}
295

            
296
// Re-export traits for backwards compatibility
297
pub use crate::font_traits::{ParsedFontTrait, ShallowClone};
298

            
299
// --- Core Data Structures for the New Architecture ---
300

            
301
/// Key for caching font chains - based only on CSS properties, not text content
302
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
303

            
304
pub struct FontChainKey {
305
    pub font_families: Vec<String>,
306
    pub weight: FcWeight,
307
    pub italic: bool,
308
    pub oblique: bool,
309
}
310

            
311
/// Either a `FontChainKey` (resolved via fontconfig) or a direct `FontRef` hash.
312
/// 
313
/// This enum cleanly separates:
314
/// - `Chain`: Fonts resolved through fontconfig with fallback support
315
/// - `Ref`: Direct `FontRef` that bypasses fontconfig entirely (e.g., embedded icon fonts)
316
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
317
pub enum FontChainKeyOrRef {
318
    /// Regular font chain resolved via fontconfig
319
    Chain(FontChainKey),
320
    /// Direct `FontRef` identified by pointer address (covers entire Unicode range, no fallbacks)
321
    Ref(usize),
322
}
323

            
324
impl FontChainKeyOrRef {
325
    /// Create from a `FontStack` enum
326
1
    #[must_use] pub fn from_font_stack(font_stack: &FontStack) -> Self {
327
1
        match font_stack {
328
1
            FontStack::Stack(selectors) => Self::Chain(FontChainKey::from_selectors(selectors)),
329
            FontStack::Ref(font_ref) => Self::Ref(font_ref.parsed as usize),
330
        }
331
1
    }
332
    
333
    /// Returns true if this is a direct `FontRef`
334
7
    #[must_use] pub const fn is_ref(&self) -> bool {
335
7
        matches!(self, Self::Ref(_))
336
7
    }
337
    
338
    /// Returns the `FontRef` pointer if this is a Ref variant
339
4
    #[must_use] pub const fn as_ref_ptr(&self) -> Option<usize> {
340
4
        match self {
341
3
            Self::Ref(ptr) => Some(*ptr),
342
1
            Self::Chain(_) => None,
343
        }
344
4
    }
345
    
346
    /// Returns the `FontChainKey` if this is a Chain variant
347
4
    #[must_use] pub const fn as_chain(&self) -> Option<&FontChainKey> {
348
4
        match self {
349
1
            Self::Chain(key) => Some(key),
350
3
            Self::Ref(_) => None,
351
        }
352
4
    }
353
}
354

            
355
impl FontChainKey {
356
    /// Create a `FontChainKey` from a slice of font selectors
357
57385
    #[must_use] pub fn from_selectors(font_stack: &[FontSelector]) -> Self {
358
        // (2026-06-10) FIRST-WINS DEDUP: cascaded font stacks can carry duplicate
359
        // families (e.g. [serif, sans-serif, serif, monospace] when the UA fallback
360
        // list is appended to a stack already naming serif). The pre-resolve
361
        // collector dedupes its stacks, so without deduping HERE the shaping-time
362
        // key never matched the stored key (the g121/g122 chain-lookup misses).
363
        // This is THE canonical FontChainKey constructor — every key-build site
364
        // must go through it so lookups match by construction.
365
57385
        let mut font_families: Vec<String> = Vec::new();
366
8275701
        for sel in font_stack {
367
8218316
            if sel.family.is_empty() || font_families.contains(&sel.family) {
368
5006
                continue;
369
8213310
            }
370
8213310
            font_families.push(sel.family.clone());
371
        }
372

            
373
57385
        let font_families = if font_families.is_empty() {
374
5
            vec!["serif".to_string()]
375
        } else {
376
57380
            font_families
377
        };
378

            
379
57385
        let weight = font_stack
380
57385
            .first()
381
57385
            .map_or(FcWeight::Normal, |s| s.weight);
382
57385
        let is_italic = font_stack
383
57385
            .first()
384
57385
            .is_some_and(|s| s.style == FontStyle::Italic);
385
57385
        let is_oblique = font_stack
386
57385
            .first()
387
57385
            .is_some_and(|s| s.style == FontStyle::Oblique);
388

            
389
57385
        Self {
390
57385
            font_families,
391
57385
            weight,
392
57385
            italic: is_italic,
393
57385
            oblique: is_oblique,
394
57385
        }
395
57385
    }
396
}
397

            
398
/// On-demand chain resolution for a shaping-time cache miss.
399
///
400
/// `font_chain_cache` is an OPTIMIZATION, not a gate: the pre-resolve
401
/// collector dedupes text nodes by compact-cache (family-hash, weight,
402
/// style) bits, and when those bits under-represent the real cascade
403
/// (e.g. every text node reads 0 and ONE representative decides the only
404
/// chain — its UA-bold h1 weight), whole runs asked for a key that was
405
/// never stored. The old behavior silently SKIPPED such runs: zero shaped
406
/// items, zero lines, zero height (miniword: multi-line paragraphs
407
/// measured 0.0 depending on which text node came first). Resolving at
408
/// miss time uses the same `fc_cache` query the pre-pass would have used,
409
/// so the result is identical — just later.
410
282
fn resolve_chain_on_miss(
411
282
    key: &FontChainKey,
412
282
    fc_cache: &FcFontCache,
413
282
) -> rust_fontconfig::FontFallbackChain {
414
282
    let mut trace = Vec::new();
415
282
    fc_cache.resolve_font_chain_with_scripts(
416
282
        &key.font_families,
417
282
        key.weight,
418
282
        if key.italic {
419
            PatternMatch::True
420
        } else {
421
282
            PatternMatch::False
422
        },
423
282
        if key.oblique {
424
            PatternMatch::True
425
        } else {
426
282
            PatternMatch::False
427
        },
428
282
        None,
429
282
        &mut trace,
430
    )
431
282
}
432

            
433

            
434
/// A map of pre-loaded fonts, keyed by `FontId` (from rust-fontconfig)
435
///
436
/// This is passed to the shaper - no font loading happens during shaping
437
/// The fonts are loaded BEFORE layout based on the font chains and text content.
438
///
439
/// Provides both `FontId` and hash-based lookup for efficient glyph operations.
440
#[derive(Debug, Clone)]
441
pub struct LoadedFonts<T> {
442
    /// Primary storage: `FontId` -> Font
443
    pub fonts: HashMap<FontId, T>,
444
    /// Reverse index: `font_hash` -> `FontId` for fast hash-based lookups
445
    hash_to_id: HashMap<u64, FontId>,
446
}
447

            
448
impl<T: ParsedFontTrait> LoadedFonts<T> {
449
234949
    #[must_use] pub fn new() -> Self {
450
234949
        Self {
451
234949
            fonts: HashMap::new(),
452
234949
            hash_to_id: HashMap::new(),
453
234949
        }
454
234949
    }
455

            
456
    /// Insert a font with its `FontId`
457
1046550
    pub fn insert(&mut self, font_id: FontId, font: T) {
458
1046550
        let hash = font.get_hash();
459
1046550
        self.hash_to_id.insert(hash, font_id);
460
1046550
        self.fonts.insert(font_id, font);
461
1046550
    }
462

            
463
    /// Get a font by `FontId`
464
43985
    #[must_use] pub fn get(&self, font_id: &FontId) -> Option<&T> {
465
43985
        self.fonts.get(font_id)
466
43985
    }
467

            
468
    /// Get a font by its hash
469
267
    #[must_use] pub fn get_by_hash(&self, hash: u64) -> Option<&T> {
470
267
        self.hash_to_id.get(&hash).and_then(|id| self.fonts.get(id))
471
267
    }
472

            
473
    /// Get the `FontId` for a hash
474
5
    #[must_use] pub fn get_font_id_by_hash(&self, hash: u64) -> Option<&FontId> {
475
5
        self.hash_to_id.get(&hash)
476
5
    }
477

            
478
    /// Check if a `FontId` is present
479
2
    #[must_use] pub fn contains_key(&self, font_id: &FontId) -> bool {
480
2
        self.fonts.contains_key(font_id)
481
2
    }
482

            
483
    /// Check if a hash is present
484
8
    #[must_use] pub fn contains_hash(&self, hash: u64) -> bool {
485
8
        self.hash_to_id.contains_key(&hash)
486
8
    }
487

            
488
    /// Iterate over all fonts
489
44227
    pub fn iter(&self) -> impl Iterator<Item = (&FontId, &T)> {
490
44227
        self.fonts.iter()
491
44227
    }
492

            
493
    /// Get the number of loaded fonts
494
9
    #[must_use] pub fn len(&self) -> usize {
495
9
        self.fonts.len()
496
9
    }
497

            
498
    /// Check if empty
499
8
    #[must_use] pub fn is_empty(&self) -> bool {
500
8
        self.fonts.is_empty()
501
8
    }
502
}
503

            
504
impl<T: ParsedFontTrait> Default for LoadedFonts<T> {
505
1
    fn default() -> Self {
506
1
        Self::new()
507
1
    }
508
}
509

            
510
impl<T: ParsedFontTrait> FromIterator<(FontId, T)> for LoadedFonts<T> {
511
234606
    fn from_iter<I: IntoIterator<Item = (FontId, T)>>(iter: I) -> Self {
512
234606
        let mut loaded = Self::new();
513
1280941
        for (id, font) in iter {
514
1046335
            loaded.insert(id, font);
515
1046335
        }
516
234606
        loaded
517
234606
    }
518
}
519

            
520
/// Enum that wraps either a fontconfig-resolved font (T) or a direct `FontRef`.
521
///
522
/// This allows the shaping code to handle both fontconfig-resolved fonts
523
/// and embedded fonts (`FontRef`) uniformly through the `ParsedFontTrait` interface.
524
#[derive(Debug, Clone)]
525
pub enum FontOrRef<T> {
526
    /// A font loaded via fontconfig
527
    Font(T),
528
    /// A direct `FontRef` (embedded font, bypasses fontconfig)
529
    Ref(azul_css::props::basic::FontRef),
530
}
531

            
532
impl<T: ParsedFontTrait> ShallowClone for FontOrRef<T> {
533
    fn shallow_clone(&self) -> Self {
534
        match self {
535
            Self::Font(f) => Self::Font(f.shallow_clone()),
536
            Self::Ref(r) => Self::Ref(r.clone()),
537
        }
538
    }
539
}
540

            
541
impl<T: ParsedFontTrait> ParsedFontTrait for FontOrRef<T> {
542
    fn shape_text(
543
        &self,
544
        text: &str,
545
        script: Script,
546
        language: Language,
547
        direction: BidiDirection,
548
        style: &StyleProperties,
549
    ) -> Result<Vec<Glyph>, LayoutError> {
550
        match self {
551
            Self::Font(f) => f.shape_text(text, script, language, direction, style),
552
            Self::Ref(r) => r.shape_text(text, script, language, direction, style),
553
        }
554
    }
555

            
556
    fn get_hash(&self) -> u64 {
557
        match self {
558
            Self::Font(f) => f.get_hash(),
559
            Self::Ref(r) => r.get_hash(),
560
        }
561
    }
562

            
563
    fn get_glyph_size(&self, glyph_id: u16, font_size: f32) -> Option<LogicalSize> {
564
        match self {
565
            Self::Font(f) => f.get_glyph_size(glyph_id, font_size),
566
            Self::Ref(r) => r.get_glyph_size(glyph_id, font_size),
567
        }
568
    }
569

            
570
    fn get_hyphen_glyph_and_advance(&self, font_size: f32) -> Option<(u16, f32)> {
571
        match self {
572
            Self::Font(f) => f.get_hyphen_glyph_and_advance(font_size),
573
            Self::Ref(r) => r.get_hyphen_glyph_and_advance(font_size),
574
        }
575
    }
576

            
577
    fn get_kashida_glyph_and_advance(&self, font_size: f32) -> Option<(u16, f32)> {
578
        match self {
579
            Self::Font(f) => f.get_kashida_glyph_and_advance(font_size),
580
            Self::Ref(r) => r.get_kashida_glyph_and_advance(font_size),
581
        }
582
    }
583

            
584
    fn has_glyph(&self, codepoint: u32) -> bool {
585
        match self {
586
            Self::Font(f) => f.has_glyph(codepoint),
587
            Self::Ref(r) => r.has_glyph(codepoint),
588
        }
589
    }
590

            
591
    fn get_vertical_metrics(&self, glyph_id: u16) -> Option<VerticalMetrics> {
592
        match self {
593
            Self::Font(f) => f.get_vertical_metrics(glyph_id),
594
            Self::Ref(r) => r.get_vertical_metrics(glyph_id),
595
        }
596
    }
597

            
598
    fn get_font_metrics(&self) -> LayoutFontMetrics {
599
        match self {
600
            Self::Font(f) => f.get_font_metrics(),
601
            Self::Ref(r) => r.get_font_metrics(),
602
        }
603
    }
604

            
605
    fn num_glyphs(&self) -> u16 {
606
        match self {
607
            Self::Font(f) => f.num_glyphs(),
608
            Self::Ref(r) => r.num_glyphs(),
609
        }
610
    }
611

            
612
    fn get_space_width(&self) -> Option<usize> {
613
        match self {
614
            Self::Font(f) => f.get_space_width(),
615
            Self::Ref(r) => r.get_space_width(),
616
        }
617
    }
618
}
619

            
620
/// Bundles all font-related state that can be shared across layout passes.
621
///
622
/// Separates font concerns from layout/rendering state (`LayoutWindow`).
623
/// Each test/render creates a fresh `LayoutWindow` from a shared `FontContext`,
624
/// avoiding stale layout cache reuse while keeping parsed fonts warm.
625
///
626
/// Usage:
627
/// ```ignore
628
/// let ctx = FontContext::from_fc_cache(fc_cache);
629
/// ctx.pre_resolve_chains(&styled_dom, &platform);
630
/// ctx.load_fonts_for_chains();
631
///
632
/// // Per-test: create fresh LayoutWindow from context
633
/// let mut window = LayoutWindow::from_font_context(&ctx)?;
634
/// window.layout_and_generate_display_list(styled_dom, ...)?;
635
/// ```
636
#[derive(Debug, Clone)]
637
pub struct FontContext {
638
    /// The shared font cache. As of rust-fontconfig 4.1 this type is
639
    /// itself backed by `Arc<RwLock<_>>`, so cloning is cheap and all
640
    /// clones see builder-thread writes immediately — no more `Arc<T>`
641
    /// wrapping is needed and no more stale-snapshot refresh dance.
642
    pub fc_cache: FcFontCache,
643
    pub parsed_fonts: Arc<Mutex<HashMap<FontId, azul_css::props::basic::FontRef>>>,
644
    pub font_chain_cache: HashMap<FontChainKey, rust_fontconfig::FontFallbackChain>,
645
    pub embedded_fonts: HashMap<u64, azul_css::props::basic::FontRef>,
646
    /// Reverse map: `font_family_hash` → actual `StyleFontFamilyVec`.
647
    /// Accumulated across DOMs for persistence. Copied to `FontManager` on `LayoutWindow` creation.
648
    pub font_hash_to_families: HashMap<u64, azul_css::props::basic::font::StyleFontFamilyVec>,
649
    /// Optional link back to the live `FcFontRegistry`. Present iff the
650
    /// caller wants the scout-on-demand path
651
    /// ([`rust_fontconfig::registry::FcFontRegistry::request_and_resolve_with_scripts`]),
652
    /// which priority-bumps the builder for not-yet-parsed families
653
    /// rather than falling back to the empty-snapshot response.
654
    pub registry: Option<Arc<rust_fontconfig::registry::FcFontRegistry>>,
655
}
656

            
657
impl FontContext {
658
    /// Create from an `FcFontCache`. Parsed fonts, font chains, and
659
    /// embedded fonts start empty.
660
    ///
661
    /// The resulting `FontContext` has `registry = None`, so font
662
    /// chain resolution only sees what's already in the cache. For
663
    /// the scout-on-demand path, use [`FontContext::from_registry`]
664
    /// instead, which keeps a handle to the registry so that chain
665
    /// resolution can lazy-parse families the DOM needs.
666
1
    #[must_use] pub fn from_fc_cache(fc_cache: FcFontCache) -> Self {
667
1
        Self {
668
1
            fc_cache,
669
1
            parsed_fonts: Arc::new(Mutex::new(HashMap::new())),
670
1
            font_chain_cache: HashMap::new(),
671
1
            embedded_fonts: HashMap::new(),
672
1
            font_hash_to_families: HashMap::new(),
673
1
            registry: None,
674
1
        }
675
1
    }
676

            
677
    /// Create from a live `FcFontRegistry`. The `fc_cache` field gets
678
    /// a *shared* handle to the registry's cache (cheap `Arc::clone`
679
    /// on the v4.1 shared-state cache) — writes by builder threads
680
    /// show up immediately in every reader. Chain resolution goes
681
    /// through
682
    /// [`rust_fontconfig::registry::FcFontRegistry::request_and_resolve_with_scripts`]
683
    /// which priority-bumps the builder for unparsed families and
684
    /// waits for them. This is the "scout-on-demand" path: a
685
    /// headless renderer can skip the eager common-stack parse and
686
    /// pay only the per-family cost on first use, dropping peak RSS
687
    /// by the common-stack metadata size (~15 MiB on macOS).
688
    pub fn from_registry(
689
        registry: Arc<rust_fontconfig::registry::FcFontRegistry>,
690
    ) -> Self {
691
        let fc_cache = registry.shared_cache();
692
        Self {
693
            fc_cache,
694
            parsed_fonts: Arc::new(Mutex::new(HashMap::new())),
695
            font_chain_cache: HashMap::new(),
696
            embedded_fonts: HashMap::new(),
697
            font_hash_to_families: HashMap::new(),
698
            registry: Some(registry),
699
        }
700
    }
701

            
702
    /// Pre-resolve font chains for a `StyledDom`'s CSS font stacks.
703
    /// Call this before layout so text rendering doesn't skip glyphs.
704
    ///
705
    /// Unicode-fallback fonts are limited to the scripts actually
706
    /// present in the document's text content — for an ASCII-only
707
    /// page, this skips the ~300 MiB Arial-Unicode / CJK / Arabic
708
    /// pull-in entirely. See
709
    /// [`crate::solver3::getters::scripts_present_in_styled_dom`].
710
    pub fn pre_resolve_chains_for_dom(
711
        &mut self,
712
        styled_dom: &azul_core::styled_dom::StyledDom,
713
        platform: &azul_css::system::Platform,
714
    ) {
715
        use crate::solver3::getters::{
716
            collect_font_stacks_from_styled_dom, collect_used_codepoints,
717
            prune_chain_to_used_chars, resolve_font_chains, scripts_present_in_styled_dom,
718
        };
719
        let collected = collect_font_stacks_from_styled_dom(styled_dom, platform);
720
        let scripts = scripts_present_in_styled_dom(styled_dom);
721
        let mut chains = resolve_font_chains(&collected, &self.fc_cache, Some(&scripts));
722
        // Coverage-based prune (matches `collect_and_resolve_font_chains_with_registration`).
723
        let used_chars = collect_used_codepoints(styled_dom);
724
        for chain in chains.chains.values_mut() {
725
            prune_chain_to_used_chars(chain, &used_chars);
726
        }
727
        // WEB-LIFT last resort (after prune, so it survives — prune drops the registered
728
        // fallback because its cmap isn't parsed yet): if a chain ended up with no fonts,
729
        // append the first registered font so load_missing_for_chains finds it and text
730
        // shapes instead of measuring 0. (Done in azul-layout, NOT rust-fontconfig, so the
731
        // lift-fragile with_memory_fonts isn't re-codegen'd into a trapping shape.)
732
        for chain in chains.chains.values_mut() {
733
            let total = chain.css_fallbacks.iter().map(|g| g.fonts.len()).sum::<usize>()
734
                + chain.unicode_fallbacks.len();
735
            if total == 0 {
736
                // `list()` deep-copies the ENTIRE font database to read one entry;
737
                // see `first_font_in_cache` in solver3::getters for the
738
                // measurement (717k String clones, 2.3 MB retained).
739
                let __first = {
740
                    let mut f = None;
741
                    self.fc_cache.for_each_pattern(|p, id| {
742
                        if f.is_none() { f = Some((p.clone(), *id)); }
743
                    });
744
                    f
745
                };
746
                if let Some((pattern, id)) = __first.as_ref() {
747
                    chain.unicode_fallbacks.push(rust_fontconfig::FontMatch {
748
                        id: *id,
749
                        unicode_ranges: pattern.unicode_ranges.clone(),
750
                        fallbacks: Vec::new(),
751
                    });
752
                }
753
            }
754
        }
755
        self.font_chain_cache = chains.into_fontconfig_chains();
756
    }
757

            
758
    /// Load parsed font bytes from disk for all fonts referenced in `font_chain_cache`.
759
    ///
760
    /// Thin wrapper that materialises a `ResolvedFontChains` from the
761
    /// cached chain map and delegates the actual disk-load to the
762
    /// shared `FontManager::load_missing_for_chains` helper, so the
763
    /// "collect → diff → load → insert" sequence lives in exactly
764
    /// one place. Failures are silently dropped here (the caller is
765
    /// the warmup path which has no good place to log them); use
766
    /// `FontManager::load_missing_for_chains` directly for diagnostics.
767
1
    pub fn load_fonts_for_chains(&self) {
768
        use crate::solver3::getters::ResolvedFontChains;
769
        use crate::text3::default::PathLoader;
770

            
771
1
        let chains_map: HashMap<FontChainKeyOrRef, _> = self
772
1
            .font_chain_cache
773
1
            .iter()
774
1
            .map(|(k, v)| (FontChainKeyOrRef::Chain(k.clone()), v.clone()))
775
1
            .collect();
776
1
        let resolved = ResolvedFontChains {
777
1
            chains: chains_map,
778
1
            ..Default::default()
779
1
        };
780

            
781
        // Borrow our shared `parsed_fonts` Arc as a transient
782
        // FontManager so we can use the helper. `from_arc_shared`
783
        // returns a manager that mutates the same underlying pool.
784
1
        let Ok(manager) = FontManager::<azul_css::props::basic::FontRef>::from_arc_shared(
785
1
            self.fc_cache.clone(),
786
1
            self.parsed_fonts.clone(),
787
1
        ) else {
788
            return;
789
        };
790
1
        let loader = PathLoader::new();
791
1
        let _failed = manager
792
1
            .load_missing_for_chains(&resolved, |bytes, idx| loader.load_font_shared(bytes, idx));
793
1
    }
794

            
795
    /// Convert into a `FontManager` with all data populated.
796
    /// Carries the `registry` forward so the resulting manager also
797
    /// has the scout-on-demand path available.
798
1
    #[must_use] pub fn to_font_manager(&self) -> FontManager<azul_css::props::basic::FontRef> {
799
1
        let mut fm = FontManager {
800
1
            fc_cache: self.fc_cache.clone(),
801
1
            parsed_fonts: self.parsed_fonts.clone(),
802
1
            condemned_fonts: Arc::new(Mutex::new(CondemnedFonts::default())),
803
1
            font_chain_cache: self.font_chain_cache.clone(),
804
1
            embedded_fonts: Mutex::new(self.embedded_fonts.clone()),
805
1
            font_hash_to_families: self.font_hash_to_families.clone(),
806
1
            registry: self.registry.clone(),
807
1
            last_resolved_font_stacks_sig: None,
808
1
            memory_families: HashMap::new(),
809
1
            vf_bake_cache: HashMap::new(),
810
1
        };
811
        // Idempotent: reuses the FontIds already in the shared fc_cache.
812
1
        fm.register_builtin_mock_fonts();
813
1
        fm
814
1
    }
815
}
816

            
817
/// How a registered in-memory face ranks against fonts on disk.
818
///
819
/// The distinction exists because "register a font by name" means two different
820
/// things. A font the caller explicitly supplied for a family *is* that family
821
/// and must beat anything installed, exactly as CSS says. A font offered as a
822
/// stand-in for a generic family - the 14 standard PDF fonts answering
823
/// `sans-serif`, say - must not, or a Win-1252 subset would displace the
824
/// system's full Unicode faces on every desktop.
825
///
826
/// Without the second tier the choice is all-or-nothing: claim `sans-serif` and
827
/// wreck desktop, or leave it alone and have nothing at all on a target with no
828
/// fonts on disk, such as wasm.
829
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
830
pub enum MemoryFontTier {
831
    /// Wins over anything on disk. The right tier for a font the caller named.
832
    Primary,
833
    /// Used only after disk resolution has had its turn. The right tier for a
834
    /// last-resort face standing in for a generic family.
835
    Fallback,
836
}
837

            
838
/// One in-memory face registered under a family name, with the style attributes
839
/// needed to choose the right face for a CSS `(weight, italic/oblique)` query.
840
///
841
/// [`FontManager::register_named_font`] registers several faces under the *same*
842
/// family name (e.g. `Helvetica` regular, bold, oblique). Keying
843
/// [`FontManager::memory_families`] by family alone therefore collapsed them —
844
/// the last registration won and `font-weight: bold` silently rendered in the
845
/// regular face. Each face now records its own weight/style so resolution can
846
/// pick the closest one (see `getters::split_memory_matches`).
847
#[derive(Debug, Clone)]
848
pub struct MemoryFace {
849
    /// Whether this face outranks the disk or only backstops it.
850
    pub tier: MemoryFontTier,
851
    /// The `FontMatch` the resolver emits when this face is chosen.
852
    pub font_match: rust_fontconfig::FontMatch,
853
    /// OS/2 weight of this face (static fonts). For a variable font this is the
854
    /// default-instance weight; `weight_axis` carries the selectable range.
855
    pub weight: FcWeight,
856
    /// `head`/OS-2 italic bit.
857
    pub italic: bool,
858
    /// OS/2 oblique bit.
859
    pub oblique: bool,
860
    /// OS/2 width class.
861
    pub stretch: FcStretch,
862
    /// For a variable font, the `wght` axis `(min, max)` in user units; `None`
863
    /// for a static face. Lets a single VF satisfy any requested weight.
864
    pub weight_axis: Option<(f32, f32)>,
865
}
866

            
867
/// Style attributes parsed from a font's bytes (OS/2 + `head`), used to index a
868
/// registered face in [`FontManager::memory_families`].
869
#[derive(Debug, Clone, Copy)]
870
struct FaceStyle {
871
    weight: FcWeight,
872
    italic: bool,
873
    oblique: bool,
874
    stretch: FcStretch,
875
    weight_axis: Option<(f32, f32)>,
876
}
877

            
878
impl Default for FaceStyle {
879
    fn default() -> Self {
880
        Self {
881
            weight: FcWeight::Normal,
882
            italic: false,
883
            oblique: false,
884
            stretch: FcStretch::Normal,
885
            weight_axis: None,
886
        }
887
    }
888
}
889

            
890
/// Parse a face's weight / italic / oblique / stretch from its bytes via
891
/// rust-fontconfig (which reads OS/2 `usWeightClass`/`usWidthClass` and the
892
/// `head` italic bit). Falls back to upright Normal when the font can't be
893
/// parsed, so registration never fails on a malformed face.
894
16640
fn parse_face_style(bytes: &[u8], family: &str) -> FaceStyle {
895
16640
    let Some(faces) = rust_fontconfig::FcParseFontBytes(bytes, family) else {
896
        return FaceStyle::default();
897
    };
898
16640
    let Some((pat, _)) = faces.into_iter().next() else {
899
        return FaceStyle::default();
900
    };
901
16640
    FaceStyle {
902
16640
        weight: pat.weight,
903
16640
        italic: pat.italic == PatternMatch::True,
904
16640
        oblique: pat.oblique == PatternMatch::True,
905
16640
        stretch: pat.stretch,
906
16640
        weight_axis: None,
907
16640
    }
908
16640
}
909

            
910
/// The font GC's grace pool: faces evicted from `parsed_fonts` wait here for
911
/// one resurrection window (see `FontManager::condemned_fonts`).
912
#[derive(Debug)]
913
pub struct CondemnedFonts<T> {
914
    /// Evicted faces, each stamped with the GC generation that evicted it.
915
    pub faces: HashMap<FontId, (T, u64)>,
916
    /// Monotonic GC pass counter.
917
    pub generation: u64,
918
}
919

            
920
impl<T> Default for CondemnedFonts<T> {
921
6532
    fn default() -> Self {
922
6532
        Self {
923
6532
            faces: HashMap::new(),
924
6532
            generation: 0,
925
6532
        }
926
6532
    }
927
}
928

            
929
#[derive(Debug)]
930
pub struct FontManager<T> {
931
    /// The font-path cache. `FcFontCache` in rust-fontconfig 4.1 is
932
    /// already a shared handle internally (`Arc<RwLock<_>>`), so no
933
    /// further `Arc<...>` wrapping is needed — clones are cheap and
934
    /// all clones see builder writes instantly.
935
    pub fc_cache: FcFontCache,
936
    /// Holds the actual parsed font (usually with the font bytes attached).
937
    /// Wrapped in Arc so multiple `FontManager` instances can share the same
938
    /// pool of already-parsed fonts (avoids re-reading from disk).
939
    pub parsed_fonts: Arc<Mutex<HashMap<FontId, T>>>,
940
    /// Faces the font GC evicted, kept for a RESURRECTION window instead of
941
    /// dropped: any hash resolution against a condemned face moves it back
942
    /// into `parsed_fonts`, and only a face nobody resolved for two GC
943
    /// generations is truly dropped.
944
    ///
945
    /// This exists because the GC's original safety argument — "eviction is
946
    /// always safe, the next layout re-loads what it needs" — is FALSE when
947
    /// a shaping cache serves cached glyphs without re-loading: the display
948
    /// list then carries a `font_hash` whose face is gone and the renderer
949
    /// silently loses the text (the azwriter textless-window bug; the
950
    /// failing hash appeared verbatim in the GC's eviction trace).
951
    pub condemned_fonts: Arc<Mutex<CondemnedFonts<T>>>,
952
    // Cache for font chains - populated by resolve_all_font_chains() before layout
953
    // This is read-only during layout - no locking needed for reads
954
    pub font_chain_cache: HashMap<FontChainKey, rust_fontconfig::FontFallbackChain>,
955
    /// Cache for direct `FontRefs` (embedded fonts like Material Icons)
956
    /// These are fonts referenced via `FontStack::Ref` that bypass fontconfig
957
    pub embedded_fonts: Mutex<HashMap<u64, azul_css::props::basic::FontRef>>,
958
    /// Reverse map: `font_family_hash` → actual `StyleFontFamilyVec`.
959
    /// Accumulated across DOMs. Used by font collection and text shaping to
960
    /// resolve compact cache hashes without `get_property_slow`.
961
    pub font_hash_to_families: HashMap<u64, azul_css::props::basic::font::StyleFontFamilyVec>,
962
    /// Optional link back to the live `FcFontRegistry`. When present,
963
    /// chain resolution uses
964
    /// [`rust_fontconfig::registry::FcFontRegistry::request_and_resolve_with_scripts`]
965
    /// which lazy-parses system fonts as the DOM requests them
966
    /// (scout-on-demand). `None` falls back to querying whatever is
967
    /// already in the shared cache.
968
    pub registry: Option<Arc<rust_fontconfig::registry::FcFontRegistry>>,
969
    /// `FxHash` of the `prev_font_hashes` slice at the moment the last
970
    /// successful `collect_and_resolve_font_chains_with_registration`
971
    /// call populated `font_chain_cache`. Lets repeated layouts of the
972
    /// same DOM skip the ~1.5 ms (cold) / ~0.9 ms (warm) chain resolver
973
    /// when the set of font-family hashes has not changed. Cleared
974
    /// whenever `font_chain_cache` is explicitly emptied.
975
    pub last_resolved_font_stacks_sig: Option<u64>,
976
    /// Index of every font registered by FAMILY NAME into `fc_cache`'s
977
    /// in-memory font table (bundled fonts, embedder fonts, the built-in
978
    /// mock test fonts): normalized family name → the `FontMatch` the
979
    /// resolver should emit for it.
980
    ///
981
    /// WHY THIS EXISTS (architectural, see `resolve_font_chains_fast`):
982
    /// the fast chain resolver in rust-fontconfig 4.4
983
    /// (`FcFontRegistry::request_fonts_fast`) resolves families purely
984
    /// against `known_paths` — i.e. fonts that exist as FILES ON DISK.
985
    /// In-memory fonts are invisible to it, so a family registered with
986
    /// `FcFontCache::with_memory_fonts` could never be matched by name
987
    /// on the production path (which always has a live registry): it
988
    /// silently fell back to a system font. This index is consulted
989
    /// FIRST, before the disk probe, so a memory-registered family wins
990
    /// exactly as CSS says it should.
991
    pub memory_families: HashMap<String, Vec<MemoryFace>>,
992
    /// Baked static instances of variable fonts, keyed by a hash of the original
993
    /// VF bytes. A variable font is expanded into one static face per weight
994
    /// bucket (see `register_named_font`); this caches the minted faces so the
995
    /// several spelling registrations of the same VF don't re-bake it.
996
    vf_bake_cache: HashMap<u64, Vec<(FontId, FaceStyle)>>,
997
}
998

            
999
impl<T: ParsedFontTrait> FontManager<T> {
    /// A second manager sharing this one's font pool.
    ///
    /// `fc_cache`, `parsed_fonts` and `registry` are shared handles — a font
    /// parsed through either manager is visible to both — and the resolved
    /// chain/name caches are copied. Use this to lay out the same content
    /// OUTSIDE the window pipeline (e.g. DOM→PDF from a callback) with
    /// exactly the fonts the window resolved on screen: same fallback
    /// chains, same embedded fonts, no re-parse from disk.
    #[must_use] pub fn clone_shared(&self) -> Self {
        Self {
            fc_cache: self.fc_cache.clone(),
            parsed_fonts: Arc::clone(&self.parsed_fonts),
            condemned_fonts: Arc::clone(&self.condemned_fonts),
            font_chain_cache: self.font_chain_cache.clone(),
            embedded_fonts: Mutex::new(
                self.embedded_fonts
                    .lock()
                    .map(|m| m.clone())
                    .unwrap_or_default(),
            ),
            font_hash_to_families: self.font_hash_to_families.clone(),
            registry: self.registry.clone(),
            // Deliberately reset: the sig gates a chain-resolver skip that is
            // only valid against THIS manager's font_chain_cache history.
            last_resolved_font_stacks_sig: None,
            memory_families: self.memory_families.clone(),
            vf_bake_cache: self.vf_bake_cache.clone(),
        }
    }
    /// # Errors
    ///
    /// Returns a `LayoutError` if the font cache cannot be initialized.
6243
    pub fn new(fc_cache: FcFontCache) -> Result<Self, LayoutError> {
6243
        let mut fm = Self {
6243
            fc_cache,
6243
            parsed_fonts: Arc::new(Mutex::new(HashMap::new())),
6243
            condemned_fonts: Arc::new(Mutex::new(CondemnedFonts::default())),
6243
            font_chain_cache: HashMap::new(),
6243
            embedded_fonts: Mutex::new(HashMap::new()),
6243
            font_hash_to_families: HashMap::new(),
6243
            registry: None,
6243
            last_resolved_font_stacks_sig: None,
6243
            memory_families: HashMap::new(),
6243
            vf_bake_cache: HashMap::new(),
6243
        };
6243
        fm.register_builtin_mock_fonts();
6243
        Ok(fm)
6243
    }
    /// Register a font by FAMILY NAME from raw bytes, as an in-memory font
    /// in the shared `FcFontCache`.
    ///
    /// This is the ONE hook an embedder (or a test) uses to make a font
    /// resolvable by `font-family: "<family>"`. It mints one `FontId` for
    /// the font, inserts it into the fontconfig cache's memory-font table
    /// (so `get_font_bytes` / `load_fonts_from_disk` find it with no
    /// special-casing) and indexes it in [`Self::memory_families`] so the
    /// fast chain resolver can match it by name.
    ///
    /// `coverage` are the codepoint ranges the font actually covers.
    /// Passing the true ranges matters: `FontFallbackChain::resolve_char`
    /// skips any font that reports no coverage, and a font claiming
    /// coverage it doesn't have would render .notdef instead of falling
    /// back.
    ///
    /// Returns the `FontId` the family now resolves to.
13456
    pub fn register_named_font(
13456
        &mut self,
13456
        family: &str,
13456
        bytes: &[u8],
13456
        coverage: Vec<UnicodeRange>,
13456
    ) -> FontId {
13456
        self.register_named_font_in_tier(family, bytes, coverage, MemoryFontTier::Primary)
13456
    }
    /// Register an in-memory face under `family` at an explicit
    /// [`MemoryFontTier`].
    ///
    /// [`Self::register_named_font`] is this with [`MemoryFontTier::Primary`].
    /// Use [`MemoryFontTier::Fallback`] to offer a face for a family without
    /// displacing whatever is installed - a caller that ships stand-in fonts for
    /// `serif`/`sans-serif`/`monospace` wants the system's faces to win on a
    /// desktop and its own to be there on wasm, and that is the tier that does
    /// both.
13456
    pub fn register_named_font_in_tier(
13456
        &mut self,
13456
        family: &str,
13456
        bytes: &[u8],
13456
        coverage: Vec<UnicodeRange>,
13456
        tier: MemoryFontTier,
13456
    ) -> FontId {
13456
        let norm = rust_fontconfig::utils::normalize_family_name(family);
        // Variable fonts: expand into one STATIC instance per weight bucket so the
        // ordinary static weight-selection path (see `split_memory_matches` /
        // `pick_memory_face`) picks the right one, with NO changes to shaping,
        // glyph decode, or PDF embedding — each baked instance is an ordinary
        // static font. Falls through to the static path below if the font is not a
        // bakeable variable font (baking failed / no glyf variations).
13456
        if let Some((min, def, max)) = crate::font::parsed::read_wght_axis(bytes, 0) {
            if let Some(id) =
                self.register_variable_instances(&norm, family, bytes, &coverage, min, def, max, tier)
            {
                return id;
            }
13456
        }
        // The weight/style come from the font BYTES (OS/2), not the registration
        // name: registering `Helvetica-Bold.ttf` under either "Helvetica-Bold" or
        // its internal family "Helvetica" must both yield weight=Bold. A font can
        // (and Helvetica does) reuse the same family name across faces, so faces
        // are distinguished by (weight, italic, oblique), never by name alone.
13456
        let style = parse_face_style(bytes, family);
        // IDEMPOTENT: several `FontManager`s (one per window, plus the PDF
        // writer) share one `FcFontCache`. Registering the same face twice would
        // mint a second `FontId` for the same bytes, orphan the first in the
        // cache's metadata table and make the id non-deterministic. Reuse an
        // existing memory font ONLY when family AND (weight, italic, oblique)
        // match — a bold face must not be deduplicated against the regular one.
13456
        let mut existing: Vec<(FontId, Vec<UnicodeRange>)> = Vec::new();
501477
        self.fc_cache.for_each_pattern(|pattern, id| {
499951
            let fam_hit = pattern
499951
                .family
499951
                .as_deref()
499951
                .is_some_and(|f| rust_fontconfig::utils::normalize_family_name(f) == norm);
499951
            let style_hit = pattern.weight == style.weight
200566
                && (pattern.italic == PatternMatch::True) == style.italic
115006
                && (pattern.oblique == PatternMatch::True) == style.oblique;
499951
            if fam_hit && style_hit {
2975
                existing.push((*id, pattern.unicode_ranges.clone()));
496976
            }
499951
        });
13456
        let id = if let Some((id, ranges)) = existing
13456
            .into_iter()
13456
            .find(|(id, _)| self.fc_cache.is_memory_font(id))
        {
2975
            self.index_memory_face(&norm, id, ranges, &style, tier);
2975
            id
        } else {
10481
            let pattern = rust_fontconfig::FcPattern {
10481
                name: Some(family.to_string()),
10481
                family: Some(family.to_string()),
10481
                italic: if style.italic { PatternMatch::True } else { PatternMatch::False },
10481
                oblique: if style.oblique { PatternMatch::True } else { PatternMatch::False },
10481
                bold: if style.weight >= FcWeight::Bold { PatternMatch::True } else { PatternMatch::False },
10481
                weight: style.weight,
10481
                stretch: style.stretch,
10481
                unicode_ranges: coverage.clone(),
10481
                ..Default::default()
            };
10481
            let id = FontId::new();
10481
            self.fc_cache.with_memory_font_with_id(
10481
                id,
10481
                pattern,
10481
                rust_fontconfig::FcFont {
10481
                    bytes: bytes.to_vec(),
10481
                    font_index: 0,
10481
                    id: family.to_string(),
10481
                },
            );
10481
            self.index_memory_face(&norm, id, coverage, &style, tier);
10481
            id
        };
13456
        id
13456
    }
    /// Append (or refresh) a face in [`Self::memory_families`] under `norm`,
    /// de-duplicating by `FontId` so repeated registrations don't grow the list.
13456
    fn index_memory_face(
13456
        &mut self,
13456
        norm: &str,
13456
        id: FontId,
13456
        unicode_ranges: Vec<UnicodeRange>,
13456
        style: &FaceStyle,
13456
        tier: MemoryFontTier,
13456
    ) {
13456
        let face = MemoryFace {
13456
            tier,
13456
            font_match: rust_fontconfig::FontMatch {
13456
                id,
13456
                unicode_ranges,
13456
                fallbacks: Vec::new(),
13456
            },
13456
            weight: style.weight,
13456
            italic: style.italic,
13456
            oblique: style.oblique,
13456
            stretch: style.stretch,
13456
            weight_axis: style.weight_axis,
13456
        };
13456
        let faces = self.memory_families.entry(norm.to_string()).or_default();
13456
        if let Some(slot) = faces.iter_mut().find(|f| f.font_match.id == id) {
203
            *slot = face;
13253
        } else {
13253
            faces.push(face);
13253
        }
13456
    }
    /// Expand a variable font (with a `wght` axis over `[min, max]`, default
    /// `def`) into one baked STATIC instance per standard weight bucket and
    /// register each as an in-memory face under `norm`. Returns the face nearest
    /// the fvar default, or `None` if no instance could be baked (caller then
    /// falls back to registering the raw bytes as a single static face).
    ///
    /// Baking is done once per unique VF bytes and cached (`vf_bake_cache`) so the
    /// several spelling registrations of the same font don't re-bake it.
    // Weight axis values are clamped to [1, 1000] and rounded before the cast, so
    // the f32 -> u16 conversion is bounded and sign-safe.
    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
    fn register_variable_instances(
        &mut self,
        norm: &str,
        family: &str,
        bytes: &[u8],
        coverage: &[UnicodeRange],
        min: f32,
        def: f32,
        max: f32,
        tier: MemoryFontTier,
    ) -> Option<FontId> {
        let base = parse_face_style(bytes, family);
        let hash = {
            use core::hash::Hasher;
            let mut h = DefaultHasher::new();
            h.write(bytes);
            h.finish()
        };
        let def_bucket = FcWeight::from_u16(def.round().clamp(1.0, 1000.0) as u16);
        // Same VF already baked under another spelling: re-index, don't re-bake.
        if let Some(cached) = self.vf_bake_cache.get(&hash).cloned() {
            for (id, style) in &cached {
                self.index_memory_face(norm, *id, coverage.to_vec(), style, tier);
            }
            return cached
                .iter()
                .find(|(_, s)| s.weight == def_bucket)
                .or_else(|| cached.first())
                .map(|(id, _)| *id);
        }
        let lo = min.round().clamp(1.0, 1000.0) as u16;
        let hi = max.round().clamp(1.0, 1000.0) as u16;
        let mut baked: Vec<(FontId, FaceStyle)> = Vec::new();
        for w in [100u16, 200, 300, 400, 500, 600, 700, 800, 900] {
            if w < lo || w > hi {
                continue;
            }
            let Some(inst_bytes) = crate::font::parsed::bake_weight_instance(bytes, 0, f32::from(w))
            else {
                continue;
            };
            let style = FaceStyle {
                weight: FcWeight::from_u16(w),
                italic: base.italic,
                oblique: base.oblique,
                stretch: base.stretch,
                weight_axis: None,
            };
            let pattern = rust_fontconfig::FcPattern {
                name: Some(family.to_string()),
                family: Some(family.to_string()),
                italic: if style.italic { PatternMatch::True } else { PatternMatch::False },
                oblique: if style.oblique { PatternMatch::True } else { PatternMatch::False },
                bold: if style.weight >= FcWeight::Bold { PatternMatch::True } else { PatternMatch::False },
                weight: style.weight,
                stretch: style.stretch,
                unicode_ranges: coverage.to_vec(),
                ..Default::default()
            };
            let id = FontId::new();
            self.fc_cache.with_memory_font_with_id(
                id,
                pattern,
                rust_fontconfig::FcFont {
                    bytes: inst_bytes,
                    font_index: 0,
                    id: family.to_string(),
                },
            );
            self.index_memory_face(norm, id, coverage.to_vec(), &style, tier);
            baked.push((id, style));
        }
        if baked.is_empty() {
            return None;
        }
        let default_id = baked
            .iter()
            .find(|(_, s)| s.weight == def_bucket)
            .or_else(|| baked.first())
            .map(|(id, _)| *id)
            .unwrap();
        self.vf_bake_cache.insert(hash, baked);
        Some(default_id)
    }
    /// Register the built-in mock test fonts (see
    /// [`crate::text3::mock_fonts`]). Called from every constructor: the
    /// mock families are only reachable if a stylesheet names them, and
    /// having them always present means tests exercise the *same* font
    /// path as production instead of a test-only bypass.
6693
    pub fn register_builtin_mock_fonts(&mut self) {
20079
        for (family, bytes) in crate::text3::mock_fonts::BUILTIN_MOCK_FONTS {
13386
            self.register_named_font(
13386
                family,
13386
                bytes,
13386
                crate::text3::mock_fonts::mock_font_ranges(),
13386
            );
13386
        }
6693
    }
    /// Create a `FontManager` sharing the font-path cache handle.
    ///
    /// The `parsed_fonts` pool starts empty. Fonts loaded during the first
    /// layout pass are cached and will be available on subsequent calls
    /// if you clone the `parsed_fonts` Arc before creating the next instance.
    /// For full sharing, prefer `from_arc_shared()`.
    /// # Errors
    ///
    /// Returns a `LayoutError` if the font cache cannot be initialized.
1
    pub fn from_shared(fc_cache: FcFontCache) -> Result<Self, LayoutError> {
1
        Self::new(fc_cache)
1
    }
    /// Create a `FontManager` sharing both the font-path cache and the
    /// already-parsed font data with another `FontManager`.
    ///
    /// This avoids re-reading and re-parsing font files from disk when
    /// rendering multiple documents that use the same fonts.
    /// # Errors
    ///
    /// Returns a `LayoutError` if the font cache cannot be initialized.
288
    pub fn from_arc_shared(
288
        fc_cache: FcFontCache,
288
        parsed_fonts: Arc<Mutex<HashMap<FontId, T>>>,
288
    ) -> Result<Self, LayoutError> {
288
        let mut fm = Self {
288
            fc_cache,
288
            parsed_fonts,
288
            condemned_fonts: Arc::new(Mutex::new(CondemnedFonts::default())),
288
            font_chain_cache: HashMap::new(),
288
            embedded_fonts: Mutex::new(HashMap::new()),
288
            font_hash_to_families: HashMap::new(),
288
            registry: None,
288
            last_resolved_font_stacks_sig: None,
288
            memory_families: HashMap::new(),
288
            vf_bake_cache: HashMap::new(),
288
        };
288
        fm.register_builtin_mock_fonts();
288
        Ok(fm)
288
    }
    /// Attach a `FcFontRegistry` to this `FontManager` so subsequent
    /// chain-resolution calls use the on-demand path
    /// ([`rust_fontconfig::registry::FcFontRegistry::request_and_resolve_with_scripts`]).
    #[must_use]
5
    pub fn with_registry(
5
        mut self,
5
        registry: Arc<rust_fontconfig::registry::FcFontRegistry>,
5
    ) -> Self {
5
        self.registry = Some(registry);
5
        self
5
    }
    /// Get a shareable handle to the parsed-font pool.
    ///
    /// Pass this to `from_arc_shared()` to create a new `FontManager` that
    /// reuses already-parsed fonts.
2
    pub fn shared_parsed_fonts(&self) -> Arc<Mutex<HashMap<FontId, T>>> {
2
        Arc::clone(&self.parsed_fonts)
2
    }
    /// Set the font chain cache from externally resolved chains
    ///
    /// This should be called with the result of `resolve_font_chains()` or
    /// `collect_and_resolve_font_chains()` from `solver3::getters`.
285
    pub fn set_font_chain_cache(
285
        &mut self,
285
        chains: HashMap<FontChainKey, rust_fontconfig::FontFallbackChain>,
285
    ) {
285
        self.font_chain_cache = chains;
285
        self.last_resolved_font_stacks_sig = None;
285
    }
    /// Set the font chain cache and record the input signature so
    /// subsequent layouts with the same `prev_font_hashes` skip the
    /// resolver. Pass `sig = None` if the caller cannot compute a
    /// reliable signature — equivalent to the single-arg
    /// `set_font_chain_cache`.
4124
    pub fn set_font_chain_cache_with_sig(
4124
        &mut self,
4124
        chains: HashMap<FontChainKey, rust_fontconfig::FontFallbackChain>,
4124
        sig: Option<u64>,
4124
    ) {
        // (2026-06-10: reverted to HashMap — the empty-map RawIter hang behind the 2026-06-05
        // BTreeMap migration was the un-mirrored hashbrown EMPTY_GROUP static, fixed
        // transpiler-side.)
4124
        self.font_chain_cache = chains;
4124
        self.last_resolved_font_stacks_sig = sig;
4124
    }
    /// Merge additional font chains into the existing cache
    ///
    /// Useful when processing multiple DOMs that may have different font requirements.
1
    pub fn merge_font_chain_cache(
1
        &mut self,
1
        chains: HashMap<FontChainKey, rust_fontconfig::FontFallbackChain>,
1
    ) {
1
        self.font_chain_cache.extend(chains);
1
    }
    /// Get a reference to the font chain cache
1977
    pub const fn get_font_chain_cache(
1977
        &self,
1977
    ) -> &HashMap<FontChainKey, rust_fontconfig::FontFallbackChain> {
1977
        &self.font_chain_cache
1977
    }
    /// Get an embedded font by its hash (used for `WebRender` registration)
    /// Returns the `FontRef` if it exists in the `embedded_fonts` cache.
    /// # Panics
    ///
    /// Panics if the internal font-cache mutex is poisoned.
470682
    pub fn get_embedded_font_by_hash(&self, font_hash: u64) -> Option<azul_css::props::basic::FontRef> {
470682
        let embedded = self.embedded_fonts.lock().unwrap();
470682
        embedded.get(&font_hash).cloned()
470682
    }
    /// Get a parsed font by its hash (used for `WebRender` registration)
    /// Returns the parsed font if it exists in the `parsed_fonts` cache.
    /// # Panics
    ///
    /// Panics if the internal font-cache mutex is poisoned.
470683
    pub fn get_font_by_hash(&self, font_hash: u64) -> Option<T> {
470683
        let parsed = self.parsed_fonts.lock().unwrap();
        // Linear search through all cached fonts to find one with matching hash
470683
        let found = parsed
470683
            .iter()
930563
            .find(|(_, font)| font.get_hash() == font_hash)
470683
            .map(|(_, font)| font.clone());
470683
        drop(parsed);
470683
        if found.is_some() {
470668
            return found;
15
        }
        // RESURRECTION: the hash is still referenced (someone is resolving
        // it), so a GC-condemned face moves back into the live pool. This is
        // what makes the font GC safe against shaping caches that serve
        // cached glyphs without re-loading their face.
15
        let mut condemned = self.condemned_fonts.lock().unwrap();
15
        let id = condemned
15
            .faces
15
            .iter()
15
            .find(|(_, (font, _))| font.get_hash() == font_hash)
15
            .map(|(id, _)| *id)?;
1
        let (font, _) = condemned.faces.remove(&id)?;
1
        drop(condemned);
1
        if std::env::var_os("AZ_FONT_GC_TRACE").is_some() {
            eprintln!("[azul][font][gc] RESURRECT id={id} face_hash={font_hash}");
1
        }
1
        self.parsed_fonts.lock().unwrap().insert(id, font.clone());
1
        Some(font)
470683
    }
    /// THE font lookup: resolve a `font_hash` — the value layout stamps onto every
    /// shaped glyph and carries in `DisplayListItem::Text` — back to the face that
    /// produced it.
    ///
    /// A `FontManager` shapes with faces from TWO pools: `parsed_fonts` (loaded from
    /// the resolved font chains) and `embedded_fonts` (handed to it directly by the
    /// DOM as `StyleFontFamily::Ref` — Material Icons and every other
    /// `FontStack::Ref`). Both can put a hash in the display list, so a renderer that
    /// consults only one of them silently drops user-visible text. That is exactly
    /// what shipped in 0.2.0: the CPU renderer searched `parsed_fonts` alone, so
    /// every widget icon vanished with `[cpurender] Font hash … not found in
    /// FontManager` while layout had happily measured and positioned it.
    ///
    /// Every renderer resolves through this one function, so "layout produced this
    /// hash" and "the renderer can draw this hash" cannot disagree.
    ///
    /// # Panics
    ///
    /// Panics if the internal font-cache mutex is poisoned.
    #[must_use]
470679
    pub fn resolve_font_by_hash(&self, font_hash: u64) -> Option<azul_css::props::basic::FontRef>
470679
    where
470679
        T: Into<azul_css::props::basic::FontRef> + Clone,
    {
470679
        if let Some(embedded) = self.get_embedded_font_by_hash(font_hash) {
10
            return Some(embedded);
470669
        }
470669
        self.get_font_by_hash(font_hash).map(Into::into)
470679
    }
    /// Register an embedded `FontRef` for later lookup by hash
    /// This is called when using `FontStack::Ref` during shaping
    /// # Panics
    ///
    /// Panics if the internal font-cache mutex is poisoned.
10
    pub fn register_embedded_font(&self, font_ref: &azul_css::props::basic::FontRef) {
10
        let hash = font_ref.get_hash();
10
        let mut embedded = self.embedded_fonts.lock().unwrap();
10
        embedded.insert(hash, font_ref.clone());
10
    }
    /// Get a snapshot of all currently loaded fonts
    ///
    /// This returns a copy of all parsed fonts, which can be passed to the shaper.
    /// No locking is required after this call - the returned `HashMap` is independent.
    ///
    /// NOTE: This should be called AFTER loading all required fonts for a layout pass.
    /// # Panics
    ///
    /// Panics if the internal font-cache mutex is poisoned.
234549
    pub fn get_loaded_fonts(&self) -> LoadedFonts<T> {
234549
        let parsed = self.parsed_fonts.lock().unwrap();
234549
        parsed
234549
            .iter()
1046277
            .map(|(id, font)| (*id, font.shallow_clone()))
234549
            .collect()
234549
    }
    /// Get the set of `FontIds` that are currently loaded
    ///
    /// This is useful for computing which fonts need to be loaded
    /// (diff with required fonts).
    /// # Panics
    ///
    /// Panics if the internal font-cache mutex is poisoned.
4414
    pub fn get_loaded_font_ids(&self) -> HashSet<FontId> {
4414
        let parsed = self.parsed_fonts.lock().unwrap();
        // M12.7: skip hashbrown's RawIterRange on an empty map — its NEON
        // control-byte group-scan mis-lifts to wasm and iterates forever
        // (the headless web layout uses an empty font cache → parsed is
        // empty here). is_empty() is len-based (no iteration), so it is safe.
4414
        if parsed.is_empty() {
4208
            return HashSet::new();
206
        }
206
        unsafe { crate::az_mark(0x60788, 0xA1) };
206
        let out = parsed.keys().copied().collect();
206
        drop(parsed);
206
        unsafe { crate::az_mark(0x6078C, 0xA2) };
206
        out
4414
    }
    /// Insert a loaded font into the cache
    ///
    /// Returns the old font if one was already present for this `FontId`.
    /// # Panics
    ///
    /// Panics if the internal font-cache mutex is poisoned.
16
    pub fn insert_font(&self, font_id: FontId, font: T) -> Option<T> {
16
        let mut parsed = self.parsed_fonts.lock().unwrap();
16
        parsed.insert(font_id, font)
16
    }
    /// Insert multiple loaded fonts into the cache
    ///
    /// This is more efficient than calling `insert_font` multiple times
    /// because it only acquires the lock once.
    /// # Panics
    ///
    /// Panics if the internal font-cache mutex is poisoned.
3183
    pub fn insert_fonts(&self, fonts: impl IntoIterator<Item = (FontId, T)>) {
3183
        let mut parsed = self.parsed_fonts.lock().unwrap();
16772
        for (font_id, font) in fonts {
13589
            parsed.insert(font_id, font);
13589
        }
3183
    }
    /// One-shot helper that resolves "what fonts does `chains` need
    /// that this manager hasn't loaded yet" and loads them via the
    /// supplied `load_fn` closure (typically
    /// `PathLoader::load_font_shared` for the production lazy-decode
    /// path). Updates `parsed_fonts` in place and returns any failures
    /// for the caller to log.
    ///
    /// Replaces the same four-step `collect → compute_diff →
    /// load_from_disk → insert_fonts` dance previously inlined in
    /// `LayoutWindow::layout_document`, the CPU rasterizer pre-fill
    /// in `cpurender.rs`, and `FontContext::load_fonts_for_chains`.
4229
    pub fn load_missing_for_chains<F>(
4229
        &self,
4229
        chains: &crate::solver3::getters::ResolvedFontChains,
4229
        load_fn: F,
4229
    ) -> Vec<(FontId, String)>
4229
    where
4229
        F: Fn(Arc<rust_fontconfig::FontBytes>, usize) -> Result<T, LayoutError>,
    {
        use crate::solver3::getters::{
            collect_font_ids_from_chains, compute_fonts_to_load, load_fonts_from_disk,
        };
4229
        let required = collect_font_ids_from_chains(chains);
4229
        let already = self.get_loaded_font_ids();
4229
        let to_load = compute_fonts_to_load(&required, &already);
4229
        if to_load.is_empty() {
1192
            return Vec::new();
3037
        }
3037
        let result = load_fonts_from_disk(&to_load, &self.fc_cache, load_fn);
3037
        self.insert_fonts(result.loaded);
3037
        result.failed
4229
    }
    /// Replace the backing `FcFontCache` and re-register the built-in memory fonts.
    ///
    /// Memory fonts (the mock test fonts, and any `register_named_font` bytes) live
    /// ONLY inside the cache. A bare `self.fc_cache = new` therefore strands them: their
    /// `FontId`s stay in `memory_families` but their bytes are gone with the old cache,
    /// so chain resolution matches them yet loading fails and text silently falls back
    /// (e.g. `font-family: "Azul Mock Mono"` measuring with the fallback font's metrics).
    /// Use this whenever the cache is swapped for a fresh snapshot (registry handle,
    /// rebuilt system cache) instead of assigning the field directly.
161
    pub fn replace_fc_cache(&mut self, fc_cache: FcFontCache) {
161
        self.fc_cache = fc_cache;
161
        self.drop_dangling_memory_faces();
161
        self.register_builtin_mock_fonts();
161
    }
    /// Evict every entry of the memory-font INDEX (`memory_families`,
    /// `vf_bake_cache`) whose `FontId` the *current* `fc_cache` does not know.
    ///
    /// `memory_families` is not a font store, it is an index INTO the cache: the
    /// bytes live in `fc_cache`, the index only remembers which `FontId` a
    /// (family, weight, slant) resolves to. Swapping the cache therefore
    /// invalidates the whole index at once, and leaving it in place is worse
    /// than losing it — a dangling id still MATCHES during chain resolution, so
    /// `font-family: "X"` resolves "successfully" to an id that
    /// `load_missing_for_chains` can no longer load, and the text silently
    /// re-measures with the fallback font's metrics (line-height 1.2 instead of
    /// the face's own ascent/descent).
    ///
    /// Re-registering the built-in mock fonts does NOT repair this by itself:
    /// `register_named_font` cannot find the family in the fresh cache, so it
    /// mints a NEW `FontId`; `index_memory_face` de-duplicates by `FontId`, so
    /// the new face is APPENDED next to the dead one; and `pick_memory_face`
    /// returns the FIRST face of the best weight — i.e. the dead one, forever.
    /// (It also grew the index by one dead face per cache swap, and the DLL
    /// swaps on every `regenerate_layout`.)
161
    fn drop_dangling_memory_faces(&mut self) {
161
        let fc_cache = &self.fc_cache;
322
        self.memory_families.retain(|_, faces| {
322
            faces.retain(|f| fc_cache.is_memory_font(&f.font_match.id));
322
            !faces.is_empty()
322
        });
        // Same reasoning for the variable-font bake cache: its ids are handed
        // straight back to `index_memory_face` on the "already baked" path.
161
        self.vf_bake_cache
161
            .retain(|_, baked| baked.iter().all(|(id, _)| fc_cache.is_memory_font(id)));
161
    }
    /// Remove a font from the cache
    ///
    /// Returns the removed font if it was present.
    /// # Panics
    ///
    /// Panics if the internal font-cache mutex is poisoned.
3
    pub fn remove_font(&self, font_id: &FontId) -> Option<T> {
3
        let mut parsed = self.parsed_fonts.lock().unwrap();
3
        parsed.remove(font_id)
3
    }
    /// FONT GC — evict everything the CURRENT document no longer references.
    ///
    /// `keep_ids` are the `FontId`s reachable from the font chains just resolved
    /// for this document; `keep_hashes` are the font-family hashes present in its
    /// CSS property cache. Anything else belonged to a node that is gone.
    ///
    /// Without this, `parsed_fonts` / `font_hash_to_families` only ever GREW: a
    /// font loaded for one node stayed resident for the life of the window even
    /// after the node (and every other user of that family) disappeared — an app
    /// that cycles fonts (font picker, editor, live CSS) leaked every font it ever
    /// touched.
    ///
    /// Eviction is always safe: `load_missing_for_chains` re-loads any font a
    /// later layout turns out to need. The cost of a wrong guess is one re-parse,
    /// never a missing glyph.
    ///
    /// Returns the number of parsed fonts evicted.
    /// # Panics
    ///
    /// Panics if the internal font-cache mutex is poisoned.
3954
    pub fn garbage_collect_fonts(
3954
        &mut self,
3954
        keep_ids: &HashSet<FontId>,
3954
        keep_hashes: &HashSet<u64>,
3954
    ) -> usize {
3954
        let trace = std::env::var_os("AZ_FONT_GC_TRACE").is_some();
3954
        let mut condemned = self.condemned_fonts.lock().unwrap();
3954
        condemned.generation = condemned.generation.saturating_add(1);
3954
        let generation = condemned.generation;
        // Phase 1: CONDEMN (not drop) everything outside the keep-set. A
        // condemned face stays resolvable by hash — `get_font_by_hash`
        // resurrects it — because shaping caches legitimately keep serving
        // glyphs stamped with its hash without re-loading the face.
3954
        let evicted = {
3954
            let mut parsed = self.parsed_fonts.lock().unwrap();
3954
            let before = parsed.len();
3954
            let goners: Vec<FontId> = parsed
3954
                .keys()
13701
                .filter(|id| !keep_ids.contains(*id))
3954
                .copied()
3954
                .collect();
4436
            for id in goners {
482
                if let Some(font) = parsed.remove(&id) {
482
                    if trace {
                        eprintln!(
                            "[azul][font][gc] CONDEMN id={id} face_hash={} gen={generation}",
                            font.get_hash()
                        );
482
                    }
482
                    condemned.faces.insert(id, (font, generation));
                }
            }
3954
            before.saturating_sub(parsed.len())
        };
        // Phase 2: truly drop faces nobody resolved for two generations —
        // the leak the GC exists for (font-cycling apps retained every font
        // they ever touched) stays fixed.
3954
        condemned.faces.retain(|id, (font, gen)| {
831
            let live = generation.saturating_sub(*gen) < 2;
831
            if !live && trace {
                eprintln!(
                    "[azul][font][gc] DROP id={id} face_hash={} (condemned at gen {gen}, now {generation})",
                    font.get_hash()
                );
831
            }
831
            live
831
        });
3954
        drop(condemned);
3954
        self.font_hash_to_families
3954
            .retain(|h, _| keep_hashes.contains(h));
3954
        evicted
3954
    }
}
// Error handling
// [g119 az-web-lift FIX] `#[repr(C, u8)]` (was repr(Rust)): the String/FontSelector payloads give
// `Result<T, LayoutError>` (e.g. measure_intrinsic_widths' return + reorder/shape/orientation `?`)
// a POINTER-niche disc the web lift mis-reads → Ok→Err. Explicit u8 tag = simple-compare niche the
// lift handles. Also nested in solver3::LayoutError::Text (so both must be repr(C,u8)). Not FFI-exposed.
#[derive(Debug, thiserror::Error)]
#[repr(C, u8)]
pub enum LayoutError {
    #[error("Bidi analysis failed: {0}")]
    BidiError(String),
    #[error("Shaping failed: {0}")]
    ShapingError(String),
    #[error("Font not found: {0:?}")]
    FontNotFound(FontSelector),
    #[error("Invalid text input: {0}")]
    InvalidText(String),
    #[error("Hyphenation failed: {0}")]
    HyphenationError(String),
}
/// Text boundary types for cursor movement
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TextBoundary {
    /// Reached top of text (first line)
    Top,
    /// Reached bottom of text (last line)
    Bottom,
    /// Reached start of text (first character)
    Start,
    /// Reached end of text (last character)
    End,
}
/// Error returned when cursor movement hits a boundary
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct CursorBoundsError {
    pub(crate) boundary: TextBoundary,
    pub(crate) cursor: TextCursor,
}
/// Unified constraints combining all layout features
///
/// # CSS Inline Layout Module Level 3: Constraint Mapping
///
/// This structure maps CSS properties to layout constraints:
///
/// ## \u00a7 2.1 Layout of Line Boxes
/// - `available_width`: \u26a0\ufe0f CRITICAL - Should equal containing block's inner width
///   * Currently defaults to 0.0 which causes immediate line breaking
///   * Per spec: "logical width of a line box is equal to the inner logical width of its containing
///     block"
/// - `available_height`: For block-axis constraints (max-height)
///
/// ## \u00a7 2.2 Layout Within Line Boxes
/// - `text_align`: \u2705 Horizontal alignment (start, end, center, justify)
/// - `vertical_align`: \u26a0\ufe0f PARTIAL - Only baseline supported, missing:
///   * top, bottom, middle, text-top, text-bottom
///   * <length>, <percentage> values
///   * sub, super positions
/// - `line_height`: \u2705 Distance between baselines
///
/// ## \u00a7 3 Baselines and Alignment Metrics
/// - `text_orientation`: \u2705 For vertical writing (sideways, upright)
/// - `writing_mode`: \u2705 horizontal-tb, vertical-rl, vertical-lr
/// - `direction`: \u2705 ltr, rtl for `BiDi`
///
/// ## \u00a7 4 Baseline Alignment (vertical-align property)
/// \u26a0\ufe0f INCOMPLETE: Only basic baseline alignment implemented
///
/// ## \u00a7 5 Line Spacing (line-height property)
/// - `line_height`: \u2705 Implemented
/// - \u274c MISSING: line-fit-edge for controlling which edges contribute to line height
///   +spec:box-model:51342f - inline box margins/borders/padding do not affect line box height (default leading mode)
///   +spec:font-metrics:618776 - line-fit-edge (cap, ex, ideographic, alphabetic edge selection) not yet implemented
///
/// ## \u00a7 6 Trimming Leading (text-box-trim)
/// - \u274c NOT IMPLEMENTED: text-box-trim property
/// - \u274c NOT IMPLEMENTED: text-box-edge property
///   +spec:box-model:c09331 - text-box-trim trims block container first/last line to font metrics
///   // +spec:overflow:dc2196 - text-box-trim overflow handled as normal overflow (no special handling needed)
///
/// ## CSS Text Module Level 3
/// - `text_indent`: \u2705 First line indentation
/// - `text_justify`: \u2705 Justification algorithm (auto, inter-word, inter-character)
/// - `hyphenation`: \u2705 Hyphens property (none / manual / auto)
/// - `hanging_punctuation`: \u2705 Hanging punctuation at line edges
///
/// ## CSS Text Level 4
/// - `text_wrap`: \u2705 balance, pretty, stable
/// - `line_clamp`: \u2705 Max number of lines
///
/// ## CSS Writing Modes Level 4
/// - `text_combine_upright`: \u2705 Tate-chu-yoko for vertical text
///
/// ## CSS Shapes Module
/// - `shape_boundaries`: \u2705 Custom line box shapes
/// - `shape_exclusions`: \u2705 Exclusion areas (float-like behavior)
/// - `exclusion_margin`: \u2705 Margin around exclusions
///
/// ## Multi-column Layout
/// - `columns`: \u2705 Number of columns
/// - `column_gap`: \u2705 Gap between columns
///
/// # Known Issues:
/// 1. [ISSUE] `available_width` defaults to Definite(0.0) instead of containing block width
/// 2. [ISSUE] `vertical_align` only supports baseline
/// 3. [TODO] initial-letter (drop caps) not implemented
// +spec:box-model:415ef3 - initial letters use standard margin/padding/border box model; exclusion area = margin box
// +spec:box-model:d53ea3 - when block-start padding+border are zero, content edge coincides with over alignment point
///    +spec:positioning:fb233a - initial letter block-axis: if size < sink, use over alignment
#[derive(Debug, Clone)]
pub struct UnifiedConstraints {
    // Shape definition
    pub shape_boundaries: Vec<ShapeBoundary>,
    pub shape_exclusions: Vec<ShapeBoundary>,
    // Basic layout - using AvailableSpace for proper indefinite handling
    pub available_width: AvailableSpace,
    pub available_height: Option<f32>,
    // Text layout
    pub writing_mode: Option<WritingMode>,
    // +spec:writing-modes:6c5ab9 - blocks inherit base direction from parent via CSS direction property
    // Base direction from CSS, overrides auto-detection
    pub direction: Option<BidiDirection>,
    pub text_orientation: TextOrientation,
    pub text_align: TextAlign,
    pub text_justify: JustifyContent,
    // +spec:display-property:3bcac8 - inline boxes sized in block axis based on font metrics (ascent/descent)
    pub line_height: LineHeight,
    pub vertical_align: VerticalAlign,
    // block container's first available font, used for minimum line box height
    pub strut_ascent: f32,
    pub strut_descent: f32,
    // x-height of the strut font (scaled to font_size), for vertical-align: middle
    pub strut_x_height: f32,
    // cap-height of the strut font (scaled to font_size), for
    // text-box-edge: cap trimming (CSS Inline 3 §6.1).
    pub strut_cap_height: f32,
    // Width of '0' (zero) character in px, used for ch unit and tab-size.
    // Approximated as space_width from the first available font, or 0.5 * font_size fallback.
    pub ch_width: f32,
    // Overflow handling
    pub overflow: OverflowBehavior,
    pub segment_alignment: SegmentAlignment,
    // Advanced features
    pub text_combine_upright: Option<TextCombineUpright>,
    pub exclusion_margin: f32,
    pub hyphenation: Hyphens,
    pub hyphenation_language: Option<Language>,
    pub text_indent: f32,
    pub text_indent_each_line: bool,
    pub text_indent_hanging: bool,
    pub initial_letter: Option<InitialLetter>,
    pub line_clamp: Option<NonZeroUsize>,
    // text-wrap: balance
    pub text_wrap: TextWrap,
    pub columns: u32,
    pub column_gap: f32,
    pub hanging_punctuation: bool,
    pub overflow_wrap: OverflowWrap,
    pub text_align_last: TextAlign,
    // §5.2 word-break property on constraints
    pub word_break: WordBreak,
    pub white_space_mode: WhiteSpaceMode,
    pub line_break: LineBreakStrictness,
    // CSS unicode-bidi property; Plaintext causes per-paragraph auto-detection
    pub unicode_bidi: UnicodeBidi,
}
impl Default for UnifiedConstraints {
238853
    fn default() -> Self {
238853
        Self {
238853
            shape_boundaries: Vec::new(),
238853
            shape_exclusions: Vec::new(),
238853

            
238853
            // Use MaxContent as default to avoid premature line breaking.
238853
            // MaxContent means "use intrinsic width" which is appropriate when
238853
            // the containing block's width is not yet known.
238853
            // Previously this was Definite(0.0) which caused each character to
238853
            // wrap to its own line. The actual width should be passed from the 
238853
            // box layout solver (fc.rs) when creating UnifiedConstraints.
238853
            available_width: AvailableSpace::MaxContent,
238853
            available_height: None,
238853
            writing_mode: None,
238853
            direction: None, // Will default to LTR if not specified
238853
            text_orientation: TextOrientation::default(),
238853
            text_align: TextAlign::default(),
238853
            text_justify: JustifyContent::default(),
238853
            line_height: LineHeight::Normal,
238853
            vertical_align: VerticalAlign::default(),
238853
            strut_ascent: DEFAULT_STRUT_ASCENT,
238853
            strut_descent: DEFAULT_STRUT_DESCENT,
238853
            strut_x_height: DEFAULT_X_HEIGHT,
238853
            strut_cap_height: DEFAULT_CAP_HEIGHT,
238853
            ch_width: DEFAULT_CH_WIDTH,
238853
            overflow: OverflowBehavior::default(),
238853
            segment_alignment: SegmentAlignment::default(),
238853
            text_combine_upright: None,
238853
            exclusion_margin: 0.0,
238853
            hyphenation: Hyphens::default(),
238853
            hyphenation_language: None,
238853
            columns: 1,
238853
            column_gap: 0.0,
238853
            hanging_punctuation: false,
238853
            text_indent: 0.0,
238853
            text_indent_each_line: false,
238853
            text_indent_hanging: false,
238853
            initial_letter: None,
238853
            line_clamp: None,
238853
            text_wrap: TextWrap::default(),
238853
            overflow_wrap: OverflowWrap::default(),
238853
            text_align_last: TextAlign::default(),
238853
            word_break: WordBreak::default(),
238853
            white_space_mode: WhiteSpaceMode::default(),
238853
            line_break: LineBreakStrictness::default(),
238853
            unicode_bidi: UnicodeBidi::default(),
238853
        }
238853
    }
}
// UnifiedConstraints
impl Hash for UnifiedConstraints {
    #[allow(clippy::cast_possible_truncation)] // bounded pixel/coord/colour/glyph cast
2
    fn hash<H: Hasher>(&self, state: &mut H) {
2
        self.shape_boundaries.hash(state);
2
        self.shape_exclusions.hash(state);
2
        self.available_width.hash(state);
2
        self.available_height
2
            .map(|h| h.round() as isize)
2
            .hash(state);
2
        self.writing_mode.hash(state);
2
        self.direction.hash(state);
2
        self.text_orientation.hash(state);
2
        self.text_align.hash(state);
2
        self.text_justify.hash(state);
2
        self.line_height.hash(state);
2
        self.vertical_align.hash(state);
2
        (self.strut_ascent.round() as isize).hash(state);
2
        (self.strut_descent.round() as isize).hash(state);
2
        (self.strut_x_height.round() as isize).hash(state);
2
        (self.ch_width.round() as isize).hash(state);
2
        self.overflow.hash(state);
2
        self.segment_alignment.hash(state);
2
        self.text_combine_upright.hash(state);
2
        (self.exclusion_margin.round() as isize).hash(state);
2
        self.hyphenation.hash(state);
2
        self.hyphenation_language.hash(state);
2
        (self.text_indent.round() as isize).hash(state);
2
        self.text_indent_each_line.hash(state);
2
        self.text_indent_hanging.hash(state);
2
        self.initial_letter.hash(state);
2
        self.line_clamp.hash(state);
2
        self.columns.hash(state);
2
        (self.column_gap.round() as isize).hash(state);
2
        self.hanging_punctuation.hash(state);
2
        self.overflow_wrap.hash(state);
2
        self.text_align_last.hash(state);
2
        self.word_break.hash(state);
2
        self.white_space_mode.hash(state);
2
        self.line_break.hash(state);
2
        self.unicode_bidi.hash(state);
2
    }
}
impl PartialEq for UnifiedConstraints {
12
    fn eq(&self, other: &Self) -> bool {
12
        self.shape_boundaries == other.shape_boundaries
12
            && self.shape_exclusions == other.shape_exclusions
12
            && self.available_width == other.available_width
11
            && match (self.available_height, other.available_height) {
11
                (None, None) => true,
                (Some(h1), Some(h2)) => round_eq(h1, h2),
                _ => false,
            }
11
            && self.writing_mode == other.writing_mode
11
            && self.direction == other.direction
11
            && self.text_orientation == other.text_orientation
11
            && self.text_align == other.text_align
11
            && self.text_justify == other.text_justify
11
            && self.line_height == other.line_height
11
            && self.vertical_align == other.vertical_align
11
            && round_eq(self.strut_ascent, other.strut_ascent)
10
            && round_eq(self.strut_descent, other.strut_descent)
10
            && round_eq(self.strut_x_height, other.strut_x_height)
10
            && round_eq(self.ch_width, other.ch_width)
10
            && self.overflow == other.overflow
10
            && self.segment_alignment == other.segment_alignment
10
            && self.text_combine_upright == other.text_combine_upright
10
            && round_eq(self.exclusion_margin, other.exclusion_margin)
10
            && self.hyphenation == other.hyphenation
10
            && self.hyphenation_language == other.hyphenation_language
10
            && round_eq(self.text_indent, other.text_indent)
10
            && self.text_indent_each_line == other.text_indent_each_line
10
            && self.text_indent_hanging == other.text_indent_hanging
10
            && self.initial_letter == other.initial_letter
10
            && self.line_clamp == other.line_clamp
10
            && self.columns == other.columns
10
            && round_eq(self.column_gap, other.column_gap)
10
            && self.hanging_punctuation == other.hanging_punctuation
10
            && self.overflow_wrap == other.overflow_wrap
10
            && self.text_align_last == other.text_align_last
10
            && self.word_break == other.word_break
10
            && self.white_space_mode == other.white_space_mode
10
            && self.line_break == other.line_break
10
            && self.unicode_bidi == other.unicode_bidi
12
    }
}
impl Eq for UnifiedConstraints {}
impl UnifiedConstraints {
    /// Resolve `line_height` to a pixel value using the strut metrics as a font-size proxy.
    /// `strut_ascent + strut_descent` approximates `font_size` (the block container's font).
532036
    #[must_use] pub fn resolved_line_height(&self) -> f32 {
532036
        match self.line_height {
            // `line-height: normal` — the minimum line-box height is the block's
            // first-available-font metrics, approximated here by the strut's
            // ascent + descent. Resolving `Normal` with no real metrics fell back
            // to `font_size * 1.2`, which inflated every non-last line's advance
            // ~20% (block auto-heights came out too tall). The real per-line box
            // height (from each run's actual glyph metrics) is folded in via
            // `.max()` at the call sites, so this strut value is the correct floor.
497180
            LineHeight::Normal => self.strut_ascent + self.strut_descent,
34856
            LineHeight::Px(px) => px,
        }
532036
    }
3
    fn direction(&self, fallback: BidiDirection) -> BidiDirection {
3
        self.writing_mode.map_or(fallback, |s| s.get_direction().unwrap_or(fallback))
3
    }
502016
    const fn is_vertical(&self) -> bool {
501987
        matches!(
322419
            self.writing_mode,
            Some(WritingMode::VerticalRl | WritingMode::VerticalLr)
        )
502016
    }
}
/// Line constraints with multi-segment support
#[derive(Debug, Clone)]
pub struct LineConstraints {
    pub segments: Vec<LineSegment>,
    pub total_available: f32,
    /// True when measuring min-content: the breaker must break at EVERY soft-wrap
    /// opportunity (each word on its own line) rather than filling `total_available`
    /// (which is a sentinel `f32::MAX / 2` for intrinsic sizing and never overflows).
    pub is_min_content: bool,
}
impl WritingMode {
    #[allow(clippy::trivially_copy_pass_by_ref)] // <=8B Copy param kept by-ref intentionally (hot pixel/coord path or to avoid churning call sites for a perf-neutral change)
    #[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
7
    const fn get_direction(&self) -> Option<BidiDirection> {
7
        match self {
            // determined by text content
2
            Self::HorizontalTb => None,
2
            Self::VerticalRl => Some(BidiDirection::Rtl),
1
            Self::VerticalLr => Some(BidiDirection::Ltr),
1
            Self::SidewaysRl => Some(BidiDirection::Rtl),
1
            Self::SidewaysLr => Some(BidiDirection::Ltr),
        }
7
    }
}
// Stage 1: Collection - Styled runs from DOM traversal
#[derive(Debug, Clone, Hash, PartialEq)]
pub struct StyledRun {
    /// The run's source text. `Arc<str>` since the §3.2 campaign step 2:
    /// this is THE single copy of a style run's text — logical items
    /// fragment it, shaping consumes it, and the dense model's
    /// `DenseRun.text` shares it, replacing every per-cluster `String`
    /// once the compact model takes over. (printpdf constructs zero
    /// `StyledRuns` — verified — so the type change is boundary-safe.)
    pub text: Arc<str>,
    pub style: Arc<StyleProperties>,
    /// Byte index in the original logical paragraph text
    pub logical_start_byte: usize,
    /// The DOM `NodeId` of the Text node this run came from.
    /// None for generated content (e.g., list markers, `::before/::after`).
    pub source_node_id: Option<NodeId>,
}
// Stage 2: Bidi Analysis - Visual runs in display order
#[derive(Debug, Clone)]
pub struct VisualRun<'a> {
    pub text_slice: &'a str,
    pub style: Arc<StyleProperties>,
    pub logical_start_byte: usize,
    pub bidi_level: BidiLevel,
    pub script: Script,
    pub language: Language,
}
// Font and styling types
/// A selector for loading fonts from the font cache.
/// Used by `FontManager` to query fontconfig and load font files.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct FontSelector {
    pub family: String,
    pub weight: FcWeight,
    pub style: FontStyle,
    pub unicode_ranges: Vec<UnicodeRange>,
}
impl Default for FontSelector {
70591
    fn default() -> Self {
70591
        Self {
70591
            family: "serif".to_string(),
70591
            weight: FcWeight::Normal,
70591
            style: FontStyle::Normal,
70591
            unicode_ranges: Vec::new(),
70591
        }
70591
    }
}
/// Font stack that can be either a list of font selectors (resolved via fontconfig)
/// or a direct `FontRef` (bypasses fontconfig entirely).
///
/// When a `FontRef` is used, it bypasses fontconfig resolution entirely
/// and uses the pre-parsed font data directly. This is used for embedded
/// fonts like Material Icons.
// [g121 az-web-lift] `#[repr(C, u8)]` — same disc-mis-lift guard as the other text3 enums; matched in
// shape_visual_items (`match &style.font_stack { Ref => shape, Stack => resolve }`). repr(Rust) niche
// (from the Vec/FontRef payloads) could mis-route. Explicit u8 tag = simple load. Internal to text3.
#[derive(Debug, Clone)]
#[repr(C, u8)]
pub enum FontStack {
    /// A stack of font selectors to be resolved via fontconfig
    /// First font is primary, rest are fallbacks
    Stack(Vec<FontSelector>),
    /// A direct reference to a pre-parsed font (e.g., embedded icon fonts)
    /// This font covers the entire Unicode range and has no fallbacks.
    Ref(azul_css::props::basic::font::FontRef),
}
impl Default for FontStack {
65087
    fn default() -> Self {
65087
        Self::Stack(vec![FontSelector::default()])
65087
    }
}
impl FontStack {
    /// Returns true if this is a direct `FontRef`
1
    #[must_use] pub const fn is_ref(&self) -> bool {
1
        matches!(self, Self::Ref(_))
1
    }
    /// Returns the `FontRef` if this is a Ref variant
1
    #[must_use] pub const fn as_ref(&self) -> Option<&azul_css::props::basic::font::FontRef> {
1
        match self {
            Self::Ref(r) => Some(r),
1
            Self::Stack(_) => None,
        }
1
    }
    /// Returns the font selectors if this is a Stack variant
2
    #[must_use] pub fn as_stack(&self) -> Option<&[FontSelector]> {
2
        match self {
2
            Self::Stack(s) => Some(s),
            Self::Ref(_) => None,
        }
2
    }
    /// Returns the first `FontSelector` if this is a Stack variant, None if Ref
2
    #[must_use] pub fn first_selector(&self) -> Option<&FontSelector> {
2
        match self {
2
            Self::Stack(s) => s.first(),
            Self::Ref(_) => None,
        }
2
    }
    /// Returns the first font family name (for Stack) or a placeholder (for Ref)
2
    #[must_use] pub fn first_family(&self) -> &str {
2
        match self {
2
            Self::Stack(s) => s.first().map_or("serif", |f| f.family.as_str()),
            Self::Ref(_) => "<embedded-font>",
        }
2
    }
}
impl PartialEq for FontStack {
43723
    fn eq(&self, other: &Self) -> bool {
43723
        match (self, other) {
42346
            (Self::Stack(a), Self::Stack(b)) => a == b,
1377
            (Self::Ref(a), Self::Ref(b)) => a.parsed == b.parsed,
            _ => false,
        }
43723
    }
}
impl Eq for FontStack {}
impl Hash for FontStack {
622781
    fn hash<H: Hasher>(&self, state: &mut H) {
622781
        discriminant(self).hash(state);
622781
        match self {
620270
            Self::Stack(s) => s.hash(state),
2511
            Self::Ref(r) => (r.parsed as usize).hash(state),
        }
622781
    }
}
/// A reference to a font for rendering, identified by its hash.
/// This hash corresponds to `ParsedFont::hash` and is used to look up
/// the actual font data in the renderer's font cache.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct FontHash {
    /// The hash of the `ParsedFont`. 0 means invalid/unknown font.
    pub font_hash: u64,
}
impl FontHash {
10
    #[must_use] pub const fn invalid() -> Self {
10
        Self { font_hash: 0 }
10
    }
533129
    #[must_use] pub const fn from_hash(font_hash: u64) -> Self {
533129
        Self { font_hash }
533129
    }
}
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum FontStyle {
    Normal,
    Italic,
    Oblique,
}
/// Defines how text should be aligned when a line contains multiple disjoint segments.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum SegmentAlignment {
    /// Align text within the first available segment on the line.
    #[default]
    First,
    /// Align text relative to the total available width of all
    /// segments on the line combined.
    Total,
}
#[derive(Copy, Debug, Clone)]
pub struct VerticalMetrics {
    pub advance: f32,
    pub bearing_x: f32,
    pub bearing_y: f32,
    pub origin_y: f32,
}
// +spec:font-metrics:df51b1 - font metrics (ascent, descent, line_gap) used as baselines for inline layout alignment and box sizing
/// Layout-specific font metrics extracted from `FontMetrics`
/// Contains only the metrics needed for text layout and rendering
// +spec:box-model:a2f1c1 - inline box content area sized from first available font metrics (ascent/descent)
// +spec:font-metrics:9c2ca5 - ascent and descent metrics per font for inline layout
// +spec:font-metrics:797593 - font metrics (ascent, descent, line-gap) used for baseline calculations
// +spec:font-metrics:842d6a - font metrics (ascent, descent) used for precise spacing control
// +spec:font-metrics:eb97e0 - Font baseline metrics (ascent/descent) from font tables used for baseline alignment
// +spec:font-metrics:f2cd75 - em-over/em-under baselines intentionally not included (not used by CSS per spec)
// +spec:inline-formatting-context:76cd57 - ascent/descent font metrics for inline formatting context layout
// +spec:font-metrics:207e6b - ascent/descent metrics used for baseline calculations
#[derive(Copy, Debug, Clone, PartialEq)]
pub struct LayoutFontMetrics {
    pub ascent: f32,
    pub descent: f32,
    pub line_gap: f32,
    pub units_per_em: u16,
    /// OS/2 sxHeight: distance from baseline to top of lowercase 'x' (in font units).
    /// Used for `vertical-align: middle` per CSS Inline 3 §4.1.
    pub x_height: Option<f32>,
    /// OS/2 sCapHeight: height of capital letters from baseline (in font units).
    /// Used for drop cap / initial-letter alignment per CSS Inline 3 §7.1.1.
    pub cap_height: Option<f32>,
}
impl LayoutFontMetrics {
    // +spec:font-metrics:006bd8 - baseline position from font design coordinates, scaled with font size
    // +spec:font-metrics:910c0a - dominant-baseline: auto resolves to alphabetic for horizontal text
    // +spec:writing-modes:098958 - baseline is along the inline axis, used to align glyphs
238031
    #[must_use] pub fn baseline_scaled(&self, font_size: f32) -> f32 {
238031
        let scale = font_size / f32::from(self.units_per_em);
238031
        self.ascent * scale
238031
    }
    /// Returns the x-height scaled to the given font size in px.
    /// Falls back to 0.5em when the font doesn't provide sxHeight.
5
    #[must_use] pub fn x_height_scaled(&self, font_size: f32) -> f32 {
5
        let scale = font_size / f32::from(self.units_per_em);
5
        self.x_height.map_or(font_size * 0.5, |xh| xh * scale)
5
    }
    /// Returns the cap height scaled to the given font size in px.
    /// Falls back to ascent when the font doesn't provide sCapHeight.
4
    #[must_use] pub fn cap_height_scaled(&self, font_size: f32) -> f32 {
4
        let scale = font_size / f32::from(self.units_per_em);
4
        self.cap_height.unwrap_or(self.ascent) * scale
4
    }
    // +spec:line-height:471816 - line gap metric extracted from font for optional use when line-height is normal
    /// Convert from full `FontMetrics` to layout-specific metrics.
    ///
    // +spec:font-metrics:05193a - prefer OS/2 sTypoAscender/sTypoDescender, fall back to HHEA
    // +spec:font-metrics:17a71c - prefer OS/2 sTypoAscender/sTypoDescender, fall back to HHEA
    // +spec:font-metrics:62c659 - prefer OS/2 sTypoAscender/sTypoDescender, fall back to HHEA
    // +spec:writing-modes:451a3e - ascent/descent/line-gap metrics: prefer OS/2, fallback HHEA, floor line_gap at 0
    /// Per CSS 2.2 §10.8.1: prefer OS/2 sTypoAscender/sTypoDescender,
    /// fall back to HHEA Ascent/Descent if OS/2 metrics are absent.
    // +spec:font-metrics:3dc8c1 - text-over/text-under baselines from font ascent/descent metrics
    // +spec:font-metrics:332c16 - text-over/text-under baseline metrics derived from font ascent/descent
    // +spec:font-metrics:9895e2 - baseline table is a font-level property; metrics apply uniformly to all glyphs
    // +spec:font-metrics:e05c40 - font ascent/descent metric extraction (text edge metrics)
    // +spec:font-metrics:21a3de - ascent/descent used as basis for em-over/em-under normalization
    // +spec:font-metrics:1257b7 - font ascent/descent ensure text fits within line box
    // +spec:table-layout:6bbd10 - use sTypoAscender/sTypoDescender as ascent/descent metrics per spec recommendation
    // +spec:font-metrics:5346d2 - prefer OS/2 sTypoAscender/sTypoDescender, fall back to HHEA
    // +spec:font-metrics:e16941 - line gap metric floored at zero per spec
    // +spec:font-metrics:a55c05 - metrics taken from font, synthesized if missing (prefers OS/2, falls back to HHEA)
    #[must_use] pub fn from_font_metrics(metrics: &azul_css::props::basic::FontMetrics) -> Self {
        let ascent = metrics.s_typo_ascender
            .as_option()
            .map_or_else(|| f32::from(metrics.ascender), |v| f32::from(*v));
        let descent = metrics.s_typo_descender
            .as_option()
            .map_or_else(|| f32::from(metrics.descender), |v| f32::from(*v));
        // UAs must floor the line gap metric at zero (css-inline-3 §3.2.2)
        // Spec: "UAs must floor the line gap metric at zero."
        let line_gap = metrics.s_typo_line_gap
            .as_option()
            .map_or_else(|| f32::from(metrics.line_gap), |v| f32::from(*v))
            .max(0.0);
        let x_height = metrics.sx_height
            .as_option()
            .map(|v| f32::from(*v));
        let cap_height = metrics.s_cap_height
            .as_option()
            .map(|v| f32::from(*v));
        Self {
            ascent,
            descent,
            line_gap,
            units_per_em: metrics.units_per_em,
            x_height,
            cap_height,
        }
    }
    // +spec:font-metrics:1eda6b - em-over is 0.5em over central baseline, em-under is 0.5em under
    /// Synthesize em-over baseline offset (in font units).
    /// Per CSS Inline 3 Appendix A.1: em-over = central baseline + 0.5em.
    /// Central baseline is synthesized as midpoint of ascent and descent.
5
    #[must_use] pub fn em_over(&self) -> f32 {
5
        let central = self.central_baseline();
5
        central + (f32::from(self.units_per_em) / 2.0)
5
    }
    /// Synthesize em-under baseline offset (in font units).
    /// Per CSS Inline 3 Appendix A.1: em-under = central baseline - 0.5em.
4
    #[must_use] pub fn em_under(&self) -> f32 {
4
        let central = self.central_baseline();
4
        central - (f32::from(self.units_per_em) / 2.0)
4
    }
    /// Synthesize central baseline (in font units).
    /// Midpoint between ascent and descent when not provided by the font.
14
    #[must_use] pub const fn central_baseline(&self) -> f32 {
14
        f32::midpoint(self.ascent, self.descent)
14
    }
}
#[derive(Copy, Debug, Clone)]
pub struct LineSegment {
    pub start_x: f32,
    pub width: f32,
    // For choosing best segment when multiple available
    pub priority: u8,
}
#[derive(Debug, Clone, Copy, PartialEq, Hash, Eq, PartialOrd, Ord, Default)]
pub enum TextWrap {
    #[default]
    Wrap,
    Balance,
    NoWrap,
}
/// CSS `overflow-wrap` (aka `word-wrap`) property.
///
/// Controls whether an otherwise unbreakable sequence of characters
/// may be broken at an arbitrary point to prevent overflow.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum OverflowWrap {
    /// No special break opportunities are introduced.
    #[default]
    Normal,
    /// Break at arbitrary points if no other break points exist.
    /// Soft wrap opportunities from `anywhere` ARE considered
    /// when calculating min-content intrinsic sizes.
    Anywhere,
    /// Same as `anywhere` except soft wrap opportunities introduced
    /// by `break-word` are NOT considered when calculating
    /// min-content intrinsic sizes.
    BreakWord,
}
// +spec:line-breaking:841a87 - hyphens property: manual (U+00AD/U+2010 only) and auto (language-aware automatic hyphenation)
// +spec:line-breaking:68c6ad - hyphens property controls hyphenation opportunities (none/manual/auto)
/// Controls whether hyphenation is allowed to create soft wrap opportunities.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum Hyphens {
    /// No hyphenation: U+00AD soft hyphens are not treated as break points.
    None,
    /// Only break at manually-inserted soft hyphens (U+00AD) or explicit hyphens.
    #[default]
    Manual,
    /// The UA may automatically hyphenate words in addition to manual opportunities.
    Auto,
}
// +spec:line-breaking:ce5258 - white-space property controls collapsing, wrapping, and forced breaks
// +spec:line-breaking:35817b - normal/pre/nowrap/pre-wrap/break-spaces/pre-line behaviors
// +spec:white-space-processing:dec7aa - White space not removed/collapsed is "preserved white space"
#[derive(Debug, Clone, Copy, PartialEq, Hash, Eq, PartialOrd, Ord, Default)]
pub enum WhiteSpaceMode {
    #[default]
    Normal,
    Nowrap,
    Pre,
    PreWrap,
    PreLine,
    BreakSpaces,
}
// CSS Text Level 3 §5.3: The line-break property controls strictness of line breaking rules.
// - Auto: UA-dependent, typically normal for CJK, loose for non-CJK
// - Loose: least restrictive, allows breaks before small kana, CJK hyphens, etc.
// - Normal: default CJK rules, allows breaks before CJK hyphen-like chars for CJK text
// - Strict: most restrictive, forbids breaks before small kana and CJK punctuation
// - Anywhere: allows soft wrap opportunities around every typographic character unit
#[derive(Debug, Clone, Copy, PartialEq, Hash, Eq, PartialOrd, Ord, Default)]
pub enum LineBreakStrictness {
    #[default]
    Auto,
    Loose,
    Normal,
    Strict,
    /// Soft wrap opportunity around every typographic character unit.
    /// Hyphenation is not applied.
    Anywhere,
}
// §5.2 word-break property: normal, break-all, keep-all
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
pub enum WordBreak {
    /// Normal break rules: CJK characters break between each other,
    /// non-CJK text only breaks at spaces/hyphens.
    #[default]
    Normal,
    /// Allow breaks between any two characters, including within Latin words.
    BreakAll,
    /// Suppress breaks between CJK characters (treat them like Latin words,
    /// only breaking at spaces). Sequences of CJK characters do not break.
    KeepAll,
}
// +spec:display-property:162c99 - Initial letter box: in-flow inline-level box with special layout behavior
// +spec:display-property:72a797 - Initial letter handled like inline-level content in originating line box
// initial-letter
// +spec:containing-block:46a499 - subsequent block must clear previous block's initial letter if it starts with its own initial letter, establishes independent FC, or specifies clear in initial letter's CB start direction
// +spec:font-metrics:1e5325 - drop initial cap-height = (N-1)*line_height + surrounding cap-height
// +spec:font-metrics:3aa518 - initial-letter-align: cap-height/ideographic/hanging/leading/border-box baseline alignment
// +spec:writing-modes:9698b0 - Han-derived scripts: initial letter extends from block-start to block-end of Nth line
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
pub struct InitialLetter {
    /// How many lines tall the initial letter should be.
    pub size: f32,
    // +spec:font-metrics:dc0632 - raised initial "sinks" to first text baseline (sink=1)
    /// How many lines the letter should sink into.
    pub sink: u32,
    /// How many characters to apply this styling to.
    pub count: NonZeroUsize,
    // +spec:display-property:4c69bf - alignment points for sizing/positioning initial letter
    /// Alignment mode for the initial letter (over/under alignment points
    /// matched to corresponding points of the root inline box).
    pub align: InitialLetterAlign,
}
/// Alignment mode for initial letters, controlling which alignment points
/// are used to size and position the letter relative to the root inline box.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum InitialLetterAlign {
    /// UA chooses based on script
    Auto,
    /// Alphabetic baseline alignment
    Alphabetic,
    /// Hanging baseline alignment
    Hanging,
    /// Ideographic baseline alignment
    Ideographic,
}
// A type that implements `Hash` must also implement `Eq`.
// Since f32 does not implement `Eq`, we provide a manual implementation.
// This is a marker trait, indicating that `a == b` is a true equivalence
// relation. The derived `PartialEq` already satisfies this.
impl Eq for InitialLetter {}
impl Hash for InitialLetter {
    #[allow(clippy::cast_possible_truncation)] // bounded pixel/coord/colour/glyph cast
    fn hash<H: Hasher>(&self, state: &mut H) {
        // Per the request, round the f32 to a usize for hashing.
        // This is a lossy conversion; values like 2.3 and 2.4 will produce
        // the same hash value for this field. This is acceptable as long as
        // the `PartialEq` implementation correctly distinguishes them.
        (self.size.round() as isize).hash(state);
        self.sink.hash(state);
        self.count.hash(state);
        self.align.hash(state);
    }
}
// Path and shape definitions
#[derive(Copy, Debug, Clone, PartialOrd)]
pub enum PathSegment {
    MoveTo(Point),
    LineTo(Point),
    CurveTo {
        control1: Point,
        control2: Point,
        end: Point,
    },
    QuadTo {
        control: Point,
        end: Point,
    },
    Arc {
        center: Point,
        radius: f32,
        start_angle: f32,
        end_angle: f32,
    },
    Close,
}
// PathSegment
impl Hash for PathSegment {
    #[allow(clippy::cast_possible_truncation)] // bounded pixel/coord/colour/glyph cast
    #[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
    fn hash<H: Hasher>(&self, state: &mut H) {
        // Hash the enum variant's discriminant first to distinguish them
        discriminant(self).hash(state);
        match self {
            Self::MoveTo(p) => p.hash(state),
            Self::LineTo(p) => p.hash(state),
            Self::CurveTo {
                control1,
                control2,
                end,
            } => {
                control1.hash(state);
                control2.hash(state);
                end.hash(state);
            }
            Self::QuadTo { control, end } => {
                control.hash(state);
                end.hash(state);
            }
            Self::Arc {
                center,
                radius,
                start_angle,
                end_angle,
            } => {
                center.hash(state);
                (radius.round() as isize).hash(state);
                (start_angle.round() as isize).hash(state);
                (end_angle.round() as isize).hash(state);
            }
            Self::Close => {} // No data to hash
        }
    }
}
impl PartialEq for PathSegment {
    #[allow(clippy::similar_names)] // domain-standard coordinate/geometry/short-lived names
    #[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
1
    fn eq(&self, other: &Self) -> bool {
1
        match (self, other) {
1
            (Self::MoveTo(a), Self::MoveTo(b)) => a == b,
            (Self::LineTo(a), Self::LineTo(b)) => a == b,
            (
                Self::CurveTo {
                    control1: c1a,
                    control2: c2a,
                    end: ea,
                },
                Self::CurveTo {
                    control1: c1b,
                    control2: c2b,
                    end: eb,
                },
            ) => c1a == c1b && c2a == c2b && ea == eb,
            (
                Self::QuadTo {
                    control: ca,
                    end: ea,
                },
                Self::QuadTo {
                    control: cb,
                    end: eb,
                },
            ) => ca == cb && ea == eb,
            (
                Self::Arc {
                    center: ca,
                    radius: ra,
                    start_angle: sa_a,
                    end_angle: ea_a,
                },
                Self::Arc {
                    center: cb,
                    radius: rb,
                    start_angle: sa_b,
                    end_angle: ea_b,
                },
            ) => ca == cb && round_eq(*ra, *rb) && round_eq(*sa_a, *sa_b) && round_eq(*ea_a, *ea_b),
            (Self::Close, Self::Close) => true,
            _ => false, // Variants are different
        }
1
    }
}
impl Eq for PathSegment {}
// Enhanced content model supporting mixed inline content
// [g117 az-web-lift FIX] `#[repr(C, u8)]` (was repr(Rust)): the web lift MIS-READS a repr(Rust)
// niche/compiler-placed discriminant — `<InlineContent as Clone>::clone` and create_logical_items'
// match both mis-route a Text(disc 0) to a Vec-bearing variant → clone reads a heap ptr as a Vec len
// → ~789MB alloc → OOB (g111/g115/g116 named stack = InlineContent::clone ← create_logical_items;
// content is CLEAN: len=1, ptr ok, disc-at-0=0). An explicit u8 tag at offset 0 (no niche) lowers to
// a simple load the lift handles correctly — the layout other (repr(C,u8)) enums use. Not FFI-exposed
// (internal to text3; only native shell code matches it), so the repr change is layout-safe.
#[derive(Debug, Clone, Hash, PartialEq)]
#[repr(C, u8)]
pub enum InlineContent {
    Text(StyledRun),
    Image(InlineImage),
    Shape(InlineShape),
    Space(InlineSpace),
    LineBreak(InlineBreak),
    /// Tab character - rendered with width based on tab-size CSS property
    Tab {
        style: Arc<StyleProperties>,
    },
    /// List marker (`::marker` pseudo-element)
    /// Markers with list-style-position: outside are positioned
    /// in the padding gutter of the list container
    Marker {
        run: StyledRun,
        /// Whether marker is positioned outside (in padding) or inside (inline)
        position_outside: bool,
    },
    // Ruby annotation
    Ruby {
        base: Vec<InlineContent>,
        text: Vec<InlineContent>,
        // Style for the ruby text itself
        style: Arc<StyleProperties>,
    },
}
#[derive(Debug, Clone)]
pub struct InlineImage {
    pub source: ImageSource,
    pub intrinsic_size: Size,
    pub display_size: Option<Size>,
    // How much to shift baseline
    pub baseline_offset: f32,
    pub alignment: VerticalAlign,
    pub object_fit: ObjectFit,
}
impl PartialEq for InlineImage {
    fn eq(&self, other: &Self) -> bool {
        self.baseline_offset.to_bits() == other.baseline_offset.to_bits()
            && self.source == other.source
            && self.intrinsic_size == other.intrinsic_size
            && self.display_size == other.display_size
            && self.alignment == other.alignment
            && self.object_fit == other.object_fit
    }
}
impl Eq for InlineImage {}
impl Hash for InlineImage {
    fn hash<H: Hasher>(&self, state: &mut H) {
        self.source.hash(state);
        self.intrinsic_size.hash(state);
        self.display_size.hash(state);
        self.baseline_offset.to_bits().hash(state);
        self.alignment.hash(state);
        self.object_fit.hash(state);
    }
}
impl PartialOrd for InlineImage {
    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
        Some(self.cmp(other))
    }
}
impl Ord for InlineImage {
    fn cmp(&self, other: &Self) -> Ordering {
        self.source
            .cmp(&other.source)
            .then_with(|| self.intrinsic_size.cmp(&other.intrinsic_size))
            .then_with(|| self.display_size.cmp(&other.display_size))
            .then_with(|| self.baseline_offset.total_cmp(&other.baseline_offset))
            .then_with(|| self.alignment.cmp(&other.alignment))
            .then_with(|| self.object_fit.cmp(&other.object_fit))
    }
}
/// Enhanced glyph with all features
#[derive(Debug, Clone)]
pub struct Glyph {
    // Core glyph data
    pub glyph_id: u16,
    pub codepoint: char,
    /// Hash of the font - use `LoadedFonts` to look up the actual font when needed
    pub font_hash: u64,
    /// Cached font metrics to avoid font lookup for common operations
    pub font_metrics: LayoutFontMetrics,
    pub style: Arc<StyleProperties>,
    pub source: GlyphSource,
    // Text mapping
    pub logical_byte_index: usize,
    pub logical_byte_len: usize,
    pub content_index: usize,
    pub cluster: u32,
    // Metrics
    pub advance: f32,
    pub kerning: f32,
    pub offset: Point,
    // Vertical text support
    pub vertical_advance: f32,
    pub vertical_origin_y: f32, // from VORG
    pub vertical_bearing: Point,
    pub orientation: GlyphOrientation,
    // Layout properties
    pub script: Script,
    pub bidi_level: BidiLevel,
}
impl Glyph {
    #[inline]
2
    fn bounds(&self) -> Rect {
2
        Rect {
2
            x: 0.0,
2
            y: 0.0,
2
            width: self.advance,
2
            height: self.style.line_height.resolve_with_metrics(self.style.font_size_px, &self.font_metrics),
2
        }
2
    }
    #[inline]
9
    const fn character_class(&self) -> CharacterClass {
9
        classify_character(self.codepoint as u32)
9
    }
    #[inline]
3
    fn is_whitespace(&self) -> bool {
3
        self.character_class() == CharacterClass::Space
3
    }
    #[inline]
3
    fn can_justify(&self) -> bool {
3
        !self.codepoint.is_whitespace() && self.character_class() != CharacterClass::Combining
3
    }
    #[inline]
3
    const fn justification_priority(&self) -> u8 {
3
        get_justification_priority(self.character_class())
3
    }
    #[inline]
8
    const fn break_opportunity_after(&self) -> bool {
8
        let is_whitespace = self.codepoint.is_whitespace();
8
        let is_soft_hyphen = self.codepoint == '\u{00AD}';
8
        let is_hyphen_minus = self.codepoint == '\u{002D}';
8
        let is_hyphen = self.codepoint == '\u{2010}';
8
        is_whitespace || is_soft_hyphen || is_hyphen_minus || is_hyphen
8
    }
}
// Information about text runs after initial analysis
#[derive(Debug, Clone)]
pub(crate) struct TextRunInfo<'a> {
    pub(crate) text: &'a str,
    pub(crate) style: Arc<StyleProperties>,
    pub(crate) logical_start: usize,
    pub(crate) content_index: usize,
}
#[derive(Debug, Clone)]
pub enum ImageSource {
    /// Direct reference to decoded image (from DOM `NodeType::Image`)
    Ref(ImageRef),
    /// The image content of a DOM node, resolved LIVE at paint time through
    /// the content overlay (overlay→DOM). Identity is the NODE, not the
    /// pixels — swapping the node's image repaints without invalidating the
    /// IFC this item is cached in. This is what `fc.rs` snapshots for
    /// `NodeType::Image` inline items (the old `Ref` snapshot froze the
    /// `ImageRef` at IFC-build time, so inline image swaps were invisible
    /// until a full relayout).
    Node(NodeId),
    /// CSS url reference (from background-image, needs `ImageCache` lookup)
    Url(String),
    /// Raw image data
    Data(Arc<[u8]>),
    /// SVG source
    Svg(Arc<str>),
    /// Placeholder for layout without actual image
    Placeholder(Size),
}
impl PartialEq for ImageSource {
    fn eq(&self, other: &Self) -> bool {
        match (self, other) {
            (Self::Ref(a), Self::Ref(b)) => a.get_hash() == b.get_hash(),
            (Self::Node(a), Self::Node(b)) => a == b,
            (Self::Url(a), Self::Url(b)) => a == b,
            (Self::Data(a), Self::Data(b)) => Arc::ptr_eq(a, b),
            (Self::Svg(a), Self::Svg(b)) => Arc::ptr_eq(a, b),
            (Self::Placeholder(a), Self::Placeholder(b)) => {
                a.width.to_bits() == b.width.to_bits() && a.height.to_bits() == b.height.to_bits()
            }
            _ => false,
        }
    }
}
impl Eq for ImageSource {}
impl Hash for ImageSource {
    fn hash<H: Hasher>(&self, state: &mut H) {
        discriminant(self).hash(state);
        match self {
            Self::Ref(r) => r.get_hash().hash(state),
            Self::Node(n) => n.hash(state),
            Self::Url(s) => s.hash(state),
            Self::Data(d) => (Arc::as_ptr(d).cast::<u8>() as usize).hash(state),
            Self::Svg(s) => (Arc::as_ptr(s).cast::<u8>() as usize).hash(state),
            Self::Placeholder(sz) => {
                sz.width.to_bits().hash(state);
                sz.height.to_bits().hash(state);
            }
        }
    }
}
impl PartialOrd for ImageSource {
    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
        Some(self.cmp(other))
    }
}
impl Ord for ImageSource {
    fn cmp(&self, other: &Self) -> Ordering {
        const fn variant_index(s: &ImageSource) -> u8 {
            match s {
                ImageSource::Ref(_) => 0,
                ImageSource::Node(_) => 5,
                ImageSource::Url(_) => 1,
                ImageSource::Data(_) => 2,
                ImageSource::Svg(_) => 3,
                ImageSource::Placeholder(_) => 4,
            }
        }
        match (self, other) {
            (Self::Ref(a), Self::Ref(b)) => a.get_hash().cmp(&b.get_hash()),
            (Self::Node(a), Self::Node(b)) => a.cmp(b),
            (Self::Url(a), Self::Url(b)) => a.cmp(b),
            (Self::Data(a), Self::Data(b)) => {
                (Arc::as_ptr(a).cast::<u8>() as usize).cmp(&(Arc::as_ptr(b).cast::<u8>() as usize))
            }
            (Self::Svg(a), Self::Svg(b)) => {
                (Arc::as_ptr(a).cast::<u8>() as usize).cmp(&(Arc::as_ptr(b).cast::<u8>() as usize))
            }
            (Self::Placeholder(a), Self::Placeholder(b)) => {
                (a.width.to_bits(), a.height.to_bits())
                    .cmp(&(b.width.to_bits(), b.height.to_bits()))
            }
            // Different variants: compare by variant index
            _ => variant_index(self).cmp(&variant_index(other)),
        }
    }
}
// +spec:font-metrics:fa104e - vertical-align values; baseline-source defaults to auto (first baseline)
// +spec:inline-formatting-context:340729 - alignment-baseline values for IFC baseline alignment (only baseline/top/bottom/middle implemented)
// CSS 2.2 §10.8.1 vertical-align property values
// +spec:display-property:0b1deb - inline boxes use dominant baseline to align text and inline-level children
// +spec:inline-formatting-context:3996a6 - dominant-baseline defaults to alphabetic in horizontal mode; vertical-align handles baseline alignment and super/sub shifting
#[derive(Default, Debug, Clone, Copy, PartialEq, PartialOrd)]
pub enum VerticalAlign {
    // Align baseline of box with baseline of parent box
    #[default]
    Baseline,
    // Align bottom of aligned subtree with bottom of line box
    Bottom,
    // Align top of aligned subtree with top of line box
    Top,
    // Align vertical midpoint of box with baseline of parent plus half x-height
    Middle,
    // Align top of box with top of parent's content area (§10.6.1)
    TextTop,
    // Align bottom of box with bottom of parent's content area (§10.6.1)
    TextBottom,
    // Lower baseline to proper subscript position
    Sub,
    // Raise baseline to proper superscript position
    Super,
    // +spec:font-metrics:152df3 - Raise (positive) or lower (negative) by this distance; 0 = baseline
    Offset(f32),
}
impl Hash for VerticalAlign {
332663
    fn hash<H: Hasher>(&self, state: &mut H) {
332663
        discriminant(self).hash(state);
332663
        if let Self::Offset(f) = self {
            f.to_bits().hash(state);
332663
        }
332663
    }
}
impl Eq for VerticalAlign {}
// cmp delegates to the derived PartialOrd (unwrap_or(Equal)), so Ord and PartialOrd are
// consistent; Ord can't be derived because of the f32 `Offset` variant.
#[allow(clippy::derive_ord_xor_partial_ord)]
impl Ord for VerticalAlign {
    fn cmp(&self, other: &Self) -> Ordering {
        self.partial_cmp(other).unwrap_or(Ordering::Equal)
    }
}
#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, PartialOrd, Ord)]
pub enum ObjectFit {
    // Stretch to fit display size
    Fill,
    // Scale to fit within display size
    Contain,
    // Scale to cover display size
    Cover,
    // Use intrinsic size
    None,
    // Like contain but never scale up
    ScaleDown,
}
/// Border information for inline elements (display: inline, inline-block)
///
/// This stores the resolved border properties needed for rendering inline element borders.
/// Unlike block elements which render borders via `paint_node_background_and_border()`,
/// inline element borders must be rendered per glyph-run to handle line breaks correctly.
#[derive(Copy, Debug, Clone, PartialEq)]
pub struct InlineBorderInfo {
    /// Border widths in pixels for each side
    pub top: f32,
    pub right: f32,
    pub bottom: f32,
    pub left: f32,
    /// Border colors for each side
    pub top_color: ColorU,
    pub right_color: ColorU,
    pub bottom_color: ColorU,
    pub left_color: ColorU,
    /// Border radius (if any)
    pub radius: Option<f32>,
    /// Padding widths in pixels for each side (needed to expand background rect)
    pub padding_top: f32,
    pub padding_right: f32,
    pub padding_bottom: f32,
    pub padding_left: f32,
    // +spec:box-model:c5723b - inline box split: suppress margin/border/padding at split points
    /// CSS 2.2 §9.4.2 / §8.6: when an inline box is split across line boxes,
    /// margins, borders, and padding have no visible effect at the split points.
    /// True if this is the first fragment of the inline box.
    pub is_first_fragment: bool,
    /// True if this is the last fragment of the inline box.
    pub is_last_fragment: bool,
    /// CSS 2.2 §8.6: direction flag for visual-order rendering in bidi context.
    /// LTR: first fragment gets left edge, last gets right edge.
    /// RTL: first fragment gets right edge, last gets left edge.
    pub is_rtl: bool,
}
impl Default for InlineBorderInfo {
80
    fn default() -> Self {
80
        Self {
80
            top: 0.0,
80
            right: 0.0,
80
            bottom: 0.0,
80
            left: 0.0,
80
            top_color: ColorU::TRANSPARENT,
80
            right_color: ColorU::TRANSPARENT,
80
            bottom_color: ColorU::TRANSPARENT,
80
            left_color: ColorU::TRANSPARENT,
80
            radius: None,
80
            padding_top: 0.0,
80
            padding_right: 0.0,
80
            padding_bottom: 0.0,
80
            padding_left: 0.0,
80
            is_first_fragment: true,
80
            is_last_fragment: true,
80
            is_rtl: false,
80
        }
80
    }
}
impl InlineBorderInfo {
    /// Returns true if any border has a non-zero width
826
    #[must_use] pub fn has_border(&self) -> bool {
826
        self.top > 0.0 || self.right > 0.0 || self.bottom > 0.0 || self.left > 0.0
826
    }
    /// Returns true if any border or padding is present
822
    #[must_use] pub fn has_chrome(&self) -> bool {
822
        self.has_border()
291
            || self.padding_top > 0.0
3
            || self.padding_right > 0.0
3
            || self.padding_bottom > 0.0
3
            || self.padding_left > 0.0
822
    }
    // +spec:box-model:da0ba2 - RTL bidi inline box split: left/right edges assigned to correct fragments
    // +spec:box-model:e9144f - visual-order margin/border/padding for inline boxes in bidi context
    // +spec:box-model:fac66f - Assigns margins/borders/padding in visual order for bidi inline fragments
    // +spec:box-model:720688 - LTR: left on first, right on last; RTL: right on first, left on last
    // +spec:positioning:1fcad6 - bidi-aware margin/border/padding on inline box fragments per visual order
    /// Total left inset (border + padding), suppressed at split points per §8.6.
    /// In LTR: left edge drawn on first fragment. In RTL: left edge drawn on last fragment.
    // +spec:box-model:bae97f - visual-order margin/border/padding assignment for bidi inline fragments
134
    #[must_use] pub fn left_inset(&self) -> f32 {
134
        let show = if self.is_rtl { self.is_last_fragment } else { self.is_first_fragment };
134
        if show { self.left + self.padding_left } else { 0.0 }
134
    }
    /// Total right inset (border + padding), suppressed at split points per §8.6.
    /// In LTR: right edge drawn on last fragment. In RTL: right edge drawn on first fragment.
132
    #[must_use] pub fn right_inset(&self) -> f32 {
132
        let show = if self.is_rtl { self.is_first_fragment } else { self.is_last_fragment };
132
        if show { self.right + self.padding_right } else { 0.0 }
132
    }
    /// Total top inset (border + padding)
30
    #[must_use] pub fn top_inset(&self) -> f32 { self.top + self.padding_top }
    /// Total bottom inset (border + padding)
29
    #[must_use] pub fn bottom_inset(&self) -> f32 { self.bottom + self.padding_bottom }
}
#[derive(Debug, Clone)]
pub struct InlineShape {
    pub shape_def: ShapeDefinition,
    pub fill: Option<ColorU>,
    pub stroke: Option<Stroke>,
    pub baseline_offset: f32,
    /// Per-item vertical alignment (CSS `vertical-align` on the inline-block element).
    /// This overrides the global `TextStyleOptions::vertical_align` for this shape.
    pub alignment: VerticalAlign,
    /// The `NodeId` of the element that created this shape
    /// (e.g., inline-block) - this allows us to look up
    /// styling information (background, border) when rendering
    pub source_node_id: Option<NodeId>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum OverflowBehavior {
    // Content extends outside shape
    Visible,
    // Content is clipped to shape
    Hidden,
    // Scrollable overflow
    Scroll,
    // Browser/system decides
    #[default]
    Auto,
    // Break into next shape/page
    Break,
}
#[derive(Debug, Clone)]
pub(crate) struct MeasuredImage {
    pub(crate) source: ImageSource,
    pub(crate) size: Size,
    pub(crate) baseline_offset: f32,
    pub(crate) alignment: VerticalAlign,
    pub(crate) content_index: usize,
}
#[derive(Debug, Clone)]
pub(crate) struct MeasuredShape {
    pub(crate) shape_def: ShapeDefinition,
    pub(crate) size: Size,
    pub(crate) baseline_offset: f32,
    pub(crate) alignment: VerticalAlign,
    pub(crate) content_index: usize,
}
#[derive(Copy, Debug, Clone)]
pub struct InlineSpace {
    pub width: f32,
    pub is_breaking: bool, // Can line break here
    pub is_stretchy: bool, // Can be expanded for justification
}
impl PartialEq for InlineSpace {
1
    fn eq(&self, other: &Self) -> bool {
1
        self.width.to_bits() == other.width.to_bits()
1
            && self.is_breaking == other.is_breaking
1
            && self.is_stretchy == other.is_stretchy
1
    }
}
impl Eq for InlineSpace {}
impl Hash for InlineSpace {
    fn hash<H: Hasher>(&self, state: &mut H) {
        self.width.to_bits().hash(state);
        self.is_breaking.hash(state);
        self.is_stretchy.hash(state);
    }
}
impl PartialOrd for InlineSpace {
    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
        Some(self.cmp(other))
    }
}
impl Ord for InlineSpace {
    fn cmp(&self, other: &Self) -> Ordering {
        self.width
            .total_cmp(&other.width)
            .then_with(|| self.is_breaking.cmp(&other.is_breaking))
            .then_with(|| self.is_stretchy.cmp(&other.is_stretchy))
    }
}
impl PartialEq for InlineShape {
    fn eq(&self, other: &Self) -> bool {
        self.baseline_offset.to_bits() == other.baseline_offset.to_bits()
            && self.shape_def == other.shape_def
            && self.fill == other.fill
            && self.stroke == other.stroke
            && self.alignment == other.alignment
            && self.source_node_id == other.source_node_id
    }
}
impl Eq for InlineShape {}
impl Hash for InlineShape {
685
    fn hash<H: Hasher>(&self, state: &mut H) {
685
        self.shape_def.hash(state);
685
        self.fill.hash(state);
685
        self.stroke.hash(state);
685
        self.baseline_offset.to_bits().hash(state);
685
        self.alignment.hash(state);
685
        self.source_node_id.hash(state);
685
    }
}
impl PartialOrd for InlineShape {
    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
        Some(
            self.shape_def
                .partial_cmp(&other.shape_def)?
                .then_with(|| self.fill.cmp(&other.fill))
                .then_with(|| {
                    self.stroke
                        .partial_cmp(&other.stroke)
                        .unwrap_or(Ordering::Equal)
                })
                .then_with(|| self.baseline_offset.total_cmp(&other.baseline_offset))
                .then_with(|| self.alignment.cmp(&other.alignment))
                .then_with(|| self.source_node_id.cmp(&other.source_node_id)),
        )
    }
}
#[derive(Debug, Default, Clone, Copy)]
pub struct Rect {
    pub x: f32,
    pub y: f32,
    pub width: f32,
    pub height: f32,
}
impl PartialEq for Rect {
9
    fn eq(&self, other: &Self) -> bool {
9
        round_eq(self.x, other.x)
9
            && round_eq(self.y, other.y)
9
            && round_eq(self.width, other.width)
9
            && round_eq(self.height, other.height)
9
    }
}
impl Eq for Rect {}
impl Hash for Rect {
    #[allow(clippy::cast_possible_truncation)] // bounded pixel/coord/colour/glyph cast
    fn hash<H: Hasher>(&self, state: &mut H) {
        // The order in which you hash the fields matters.
        // A consistent order is crucial.
        (self.x.round() as isize).hash(state);
        (self.y.round() as isize).hash(state);
        (self.width.round() as isize).hash(state);
        (self.height.round() as isize).hash(state);
    }
}
#[derive(Debug, Default, Clone, Copy)]
pub struct Size {
    pub width: f32,
    pub height: f32,
}
impl PartialOrd for Size {
    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
        Some(self.cmp(other))
    }
}
impl Ord for Size {
    #[allow(clippy::cast_possible_truncation)] // bounded pixel/coord/colour/glyph cast
    fn cmp(&self, other: &Self) -> Ordering {
        (self.width.round() as isize)
            .cmp(&(other.width.round() as isize))
            .then_with(|| (self.height.round() as isize).cmp(&(other.height.round() as isize)))
    }
}
// Size
impl Hash for Size {
    #[allow(clippy::cast_possible_truncation)] // bounded pixel/coord/colour/glyph cast
685
    fn hash<H: Hasher>(&self, state: &mut H) {
685
        (self.width.round() as isize).hash(state);
685
        (self.height.round() as isize).hash(state);
685
    }
}
impl PartialEq for Size {
14
    fn eq(&self, other: &Self) -> bool {
14
        round_eq(self.width, other.width) && round_eq(self.height, other.height)
14
    }
}
impl Eq for Size {}
impl Size {
15
    #[must_use] pub const fn zero() -> Self {
15
        Self::new(0.0, 0.0)
15
    }
35
    #[must_use] pub const fn new(width: f32, height: f32) -> Self {
35
        Self { width, height }
35
    }
}
#[derive(Debug, Default, Clone, Copy, PartialOrd)]
pub struct Point {
    pub x: f32,
    pub y: f32,
}
// Point
impl Hash for Point {
    #[allow(clippy::cast_possible_truncation)] // bounded pixel/coord/colour/glyph cast
    fn hash<H: Hasher>(&self, state: &mut H) {
        (self.x.round() as isize).hash(state);
        (self.y.round() as isize).hash(state);
    }
}
impl PartialEq for Point {
4152
    fn eq(&self, other: &Self) -> bool {
4152
        round_eq(self.x, other.x) && round_eq(self.y, other.y)
4152
    }
}
impl Eq for Point {}
#[derive(Debug, Clone, PartialOrd)]
pub enum ShapeDefinition {
    Rectangle {
        size: Size,
        corner_radius: Option<f32>,
    },
    Circle {
        radius: f32,
    },
    Ellipse {
        radii: Size,
    },
    Polygon {
        points: Vec<Point>,
    },
    Path {
        segments: Vec<PathSegment>,
    },
}
// ShapeDefinition
impl Hash for ShapeDefinition {
    #[allow(clippy::cast_possible_truncation)] // bounded pixel/coord/colour/glyph cast
685
    fn hash<H: Hasher>(&self, state: &mut H) {
685
        discriminant(self).hash(state);
685
        match self {
            Self::Rectangle {
685
                size,
685
                corner_radius,
            } => {
685
                size.hash(state);
685
                corner_radius.map(|r| r.round() as isize).hash(state);
            }
            Self::Circle { radius } => {
                (radius.round() as isize).hash(state);
            }
            Self::Ellipse { radii } => {
                radii.hash(state);
            }
            Self::Polygon { points } => {
                // Since Point implements Hash, we can hash the Vec directly.
                points.hash(state);
            }
            Self::Path { segments } => {
                // Same for Vec<PathSegment>
                segments.hash(state);
            }
        }
685
    }
}
impl PartialEq for ShapeDefinition {
    fn eq(&self, other: &Self) -> bool {
        match (self, other) {
            (
                Self::Rectangle {
                    size: s1,
                    corner_radius: r1,
                },
                Self::Rectangle {
                    size: s2,
                    corner_radius: r2,
                },
            ) => {
                s1 == s2
                    && match (r1, r2) {
                        (None, None) => true,
                        (Some(v1), Some(v2)) => round_eq(*v1, *v2),
                        _ => false,
                    }
            }
            (Self::Circle { radius: r1 }, Self::Circle { radius: r2 }) => {
                round_eq(*r1, *r2)
            }
            (Self::Ellipse { radii: r1 }, Self::Ellipse { radii: r2 }) => {
                r1 == r2
            }
            (Self::Polygon { points: p1 }, Self::Polygon { points: p2 }) => {
                p1 == p2
            }
            (Self::Path { segments: s1 }, Self::Path { segments: s2 }) => {
                s1 == s2
            }
            _ => false,
        }
    }
}
impl Eq for ShapeDefinition {}
impl ShapeDefinition {
    /// Calculates the bounding box size for the shape.
791
    #[must_use] pub fn get_size(&self) -> Size {
791
        match self {
            // The size is explicitly defined.
784
            Self::Rectangle { size, .. } => *size,
            // The bounding box of a circle is a square with sides equal to the diameter.
2
            Self::Circle { radius } => {
2
                let diameter = radius * 2.0;
2
                Size::new(diameter, diameter)
            }
            // The bounding box of an ellipse has width and height equal to twice its radii.
1
            Self::Ellipse { radii } => Size::new(radii.width * 2.0, radii.height * 2.0),
            // For a polygon, we must find the min/max coordinates to get the bounds.
1
            Self::Polygon { points } => calculate_bounding_box_size(points),
            // For a path, we find the bounding box of all its anchor and control points.
            //
            // NOTE: This is a common and fast approximation. The true bounding box of
            // bezier curves can be slightly smaller than the box containing their control
            // points. For pixel-perfect results, one would need to calculate the
            // curve's extrema.
3
            Self::Path { segments } => {
3
                let mut points = Vec::new();
3
                let mut current_pos = Point { x: 0.0, y: 0.0 };
7
                for segment in segments {
4
                    match segment {
1
                        PathSegment::MoveTo(p) | PathSegment::LineTo(p) => {
1
                            points.push(*p);
1
                            current_pos = *p;
1
                        }
1
                        PathSegment::QuadTo { control, end } => {
1
                            points.push(current_pos);
1
                            points.push(*control);
1
                            points.push(*end);
1
                            current_pos = *end;
1
                        }
                        PathSegment::CurveTo {
                            control1,
                            control2,
                            end,
                        } => {
                            points.push(current_pos);
                            points.push(*control1);
                            points.push(*control2);
                            points.push(*end);
                            current_pos = *end;
                        }
                        PathSegment::Arc {
                            center,
                            radius,
                            start_angle,
                            end_angle,
                        } => {
                            // 1. Calculate and add the arc's start and end points to the list.
                            let start_point = Point {
                                x: center.x + radius * start_angle.cos(),
                                y: center.y + radius * start_angle.sin(),
                            };
                            let end_point = Point {
                                x: center.x + radius * end_angle.cos(),
                                y: center.y + radius * end_angle.sin(),
                            };
                            points.push(start_point);
                            points.push(end_point);
                            // 2. Normalize the angles to handle cases where the arc crosses the
                            //    0-radian line.
                            // This ensures we can iterate forward from a start to an end angle.
                            let mut normalized_end = *end_angle;
                            #[allow(clippy::while_float)] // intentional bounded float loop (angle-wrap / pixel-step); an integer counter would be artificial
                            while normalized_end < *start_angle {
                                normalized_end += 2.0 * std::f32::consts::PI;
                            }
                            // 3. Find the first cardinal point (multiples of PI/2) at or after the
                            //    start angle.
                            let mut check_angle = (*start_angle / std::f32::consts::FRAC_PI_2)
                                .ceil()
                                * std::f32::consts::FRAC_PI_2;
                            // 4. Iterate through all cardinal points that fall within the arc's
                            //    sweep and add them.
                            // These points define the maximum extent of the arc's bounding box.
                            #[allow(clippy::while_float)] // intentional bounded float loop (angle-wrap / pixel-step); an integer counter would be artificial
                            while check_angle < normalized_end {
                                points.push(Point {
                                    x: center.x + radius * check_angle.cos(),
                                    y: center.y + radius * check_angle.sin(),
                                });
                                check_angle += std::f32::consts::FRAC_PI_2;
                            }
                            // 5. The end of the arc is the new current position for subsequent path
                            //    segments.
                            current_pos = end_point;
                        }
2
                        PathSegment::Close => {
2
                            // No new points are added for closing the path
2
                        }
                    }
                }
3
                calculate_bounding_box_size(&points)
            }
        }
791
    }
}
// +spec:text-alignment-spacing:25e82a - text-align shorthand resolves text-align-all / text-align-last
/// Resolve effective text alignment for a line, handling text-align-last per CSS Text §6.3.
/// For the last line (or lines before forced breaks), text-align-last overrides text-align.
/// When text-align-last is auto (default), justify falls back to start; others use text-align.
// +spec:text-alignment-spacing:bca77d - text-align-last auto falls back to text-align-all, justify→start
// +spec:line-breaking:9b10d2 - text-align-last applies to last line and lines before forced breaks
/// +spec:text-alignment-spacing:8d88ce - text-align-last overrides justify on last line/forced break
177291
pub(crate) fn resolve_effective_alignment(
177291
    text_align: TextAlign,
177291
    text_align_last: TextAlign,
177291
    is_last_or_forced: bool,
177291
) -> TextAlign {
177291
    if is_last_or_forced {
151667
        if text_align_last == TextAlign::default() {
151652
            if text_align == TextAlign::Justify { TextAlign::Start } else { text_align }
        } else {
15
            text_align_last
        }
    } else {
25624
        text_align
    }
177291
}
/// Helper function to calculate the size of the bounding box enclosing a set of points.
9
fn calculate_bounding_box_size(points: &[Point]) -> Size {
9
    if points.is_empty() {
4
        return Size::zero();
5
    }
5
    let mut min_x = f32::MAX;
5
    let mut max_x = f32::MIN;
5
    let mut min_y = f32::MAX;
5
    let mut max_y = f32::MIN;
16
    for point in points {
11
        min_x = min_x.min(point.x);
11
        max_x = max_x.max(point.x);
11
        min_y = min_y.min(point.y);
11
        max_y = max_y.max(point.y);
11
    }
    // Handle case where points might be collinear or a single point
5
    if min_x > max_x || min_y > max_y {
1
        return Size::zero();
4
    }
4
    Size::new(max_x - min_x, max_y - min_y)
9
}
#[derive(Debug, Clone, PartialOrd)]
pub struct Stroke {
    pub color: ColorU,
    pub width: f32,
    pub dash_pattern: Option<Vec<f32>>,
}
// Stroke
impl Hash for Stroke {
    #[allow(clippy::cast_possible_truncation)] // bounded pixel/coord/colour/glyph cast
    fn hash<H: Hasher>(&self, state: &mut H) {
        self.color.hash(state);
        (self.width.round() as isize).hash(state);
        // Manual hashing for Option<Vec<f32>>
        match &self.dash_pattern {
            None => 0u8.hash(state), // Hash a discriminant for None
            Some(pattern) => {
                1u8.hash(state); // Hash a discriminant for Some
                pattern.len().hash(state); // Hash the length
                for &val in pattern {
                    (val.round() as isize).hash(state); // Hash each rounded value
                }
            }
        }
    }
}
impl PartialEq for Stroke {
    fn eq(&self, other: &Self) -> bool {
        if self.color != other.color || !round_eq(self.width, other.width) {
            return false;
        }
        match (&self.dash_pattern, &other.dash_pattern) {
            (None, None) => true,
            (Some(p1), Some(p2)) => {
                p1.len() == p2.len() && p1.iter().zip(p2.iter()).all(|(a, b)| round_eq(*a, *b))
            }
            _ => false,
        }
    }
}
impl Eq for Stroke {}
// Helper function to round f32 for comparison
#[allow(clippy::cast_possible_truncation)] // bounded pixel/coord/colour/glyph cast
8451
fn round_eq(a: f32, b: f32) -> bool {
8451
    (a.round() as isize) == (b.round() as isize)
8451
}
#[derive(Debug, Clone)]
pub enum ShapeBoundary {
    Rectangle(Rect),
    Circle { center: Point, radius: f32 },
    Ellipse { center: Point, radii: Size },
    Polygon { points: Vec<Point> },
    Path { segments: Vec<PathSegment> },
}
impl ShapeBoundary {
    #[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
8
    #[must_use] pub fn inflate(&self, margin: f32) -> Self {
8
        if margin == 0.0 {
2
            return self.clone();
6
        }
6
        match self {
2
            Self::Rectangle(rect) => Self::Rectangle(Rect {
2
                x: rect.x - margin,
2
                y: rect.y - margin,
2
                width: (rect.width + margin * 2.0).max(0.0),
2
                height: (rect.height + margin * 2.0).max(0.0),
2
            }),
2
            Self::Circle { center, radius } => Self::Circle {
2
                center: *center,
2
                radius: radius + margin,
2
            },
            // For simplicity, Polygon and Path inflation is not implemented here.
            // A full implementation would require a geometry library to offset the path.
2
            _ => self.clone(),
        }
8
    }
}
// ShapeBoundary
impl Hash for ShapeBoundary {
    #[allow(clippy::cast_possible_truncation)] // bounded pixel/coord/colour/glyph cast
    fn hash<H: Hasher>(&self, state: &mut H) {
        discriminant(self).hash(state);
        match self {
            Self::Rectangle(rect) => rect.hash(state),
            Self::Circle { center, radius } => {
                center.hash(state);
                (radius.round() as isize).hash(state);
            }
            Self::Ellipse { center, radii } => {
                center.hash(state);
                radii.hash(state);
            }
            Self::Polygon { points } => points.hash(state),
            Self::Path { segments } => segments.hash(state),
        }
    }
}
impl PartialEq for ShapeBoundary {
4
    fn eq(&self, other: &Self) -> bool {
4
        match (self, other) {
1
            (Self::Rectangle(r1), Self::Rectangle(r2)) => r1 == r2,
            (
                Self::Circle {
1
                    center: c1,
1
                    radius: r1,
                },
                Self::Circle {
1
                    center: c2,
1
                    radius: r2,
                },
1
            ) => c1 == c2 && round_eq(*r1, *r2),
            (
                Self::Ellipse {
                    center: c1,
                    radii: r1,
                },
                Self::Ellipse {
                    center: c2,
                    radii: r2,
                },
            ) => c1 == c2 && r1 == r2,
1
            (Self::Polygon { points: p1 }, Self::Polygon { points: p2 }) => {
1
                p1 == p2
            }
1
            (Self::Path { segments: s1 }, Self::Path { segments: s2 }) => {
1
                s1 == s2
            }
            _ => false,
        }
4
    }
}
impl Eq for ShapeBoundary {}
impl ShapeBoundary {
    /// Converts a CSS shape (from azul-css) to a layout engine `ShapeBoundary`
    ///
    /// # Arguments
    /// * `css_shape` - The parsed CSS shape from azul-css
    /// * `reference_box` - The containing box for resolving coordinates (from layout solver)
    ///
    /// # Returns
    /// A `ShapeBoundary` ready for use in the text layout engine
    #[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
4
    pub fn from_css_shape(
4
        css_shape: &azul_css::shape::CssShape,
4
        reference_box: Rect,
4
        debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
4
    ) -> Self {
        use azul_css::shape::CssShape;
4
        if let Some(msgs) = debug_messages {
            msgs.push(LayoutDebugMessage::info(format!(
                "[ShapeBoundary::from_css_shape] Input CSS shape: {css_shape:?}"
            )));
            msgs.push(LayoutDebugMessage::info(format!(
                "[ShapeBoundary::from_css_shape] Reference box: {reference_box:?}"
            )));
4
        }
4
        let result = match css_shape {
            CssShape::Circle(circle) => {
                let center = Point {
                    x: reference_box.x + circle.center.x,
                    y: reference_box.y + circle.center.y,
                };
                if let Some(msgs) = debug_messages {
                    msgs.push(LayoutDebugMessage::info(format!(
                        "[ShapeBoundary::from_css_shape] Circle - CSS center: ({}, {}), radius: {}",
                        circle.center.x, circle.center.y, circle.radius
                    )));
                    msgs.push(LayoutDebugMessage::info(format!(
                        "[ShapeBoundary::from_css_shape] Circle - Absolute center: ({}, {}), \
                         radius: {}",
                        center.x, center.y, circle.radius
                    )));
                }
                Self::Circle {
                    center,
                    radius: circle.radius,
                }
            }
            CssShape::Ellipse(ellipse) => {
                let center = Point {
                    x: reference_box.x + ellipse.center.x,
                    y: reference_box.y + ellipse.center.y,
                };
                let radii = Size {
                    width: ellipse.radius_x,
                    height: ellipse.radius_y,
                };
                if let Some(msgs) = debug_messages {
                    msgs.push(LayoutDebugMessage::info(format!(
                        "[ShapeBoundary::from_css_shape] Ellipse - center: ({}, {}), radii: ({}, \
                         {})",
                        center.x, center.y, radii.width, radii.height
                    )));
                }
                Self::Ellipse { center, radii }
            }
            CssShape::Polygon(polygon) => {
                let points = polygon
                    .points
                    .as_ref()
                    .iter()
                    .map(|pt| Point {
                        x: reference_box.x + pt.x,
                        y: reference_box.y + pt.y,
                    })
                    .collect();
                if let Some(msgs) = debug_messages {
                    msgs.push(LayoutDebugMessage::info(format!(
                        "[ShapeBoundary::from_css_shape] Polygon - {} points",
                        polygon.points.as_ref().len()
                    )));
                }
                Self::Polygon { points }
            }
            CssShape::Inset(inset) => {
                // Inset defines distances from reference box edges
                let x = reference_box.x + inset.inset_left;
                let y = reference_box.y + inset.inset_top;
                let width = reference_box.width - inset.inset_left - inset.inset_right;
                let height = reference_box.height - inset.inset_top - inset.inset_bottom;
                if let Some(msgs) = debug_messages {
                    msgs.push(LayoutDebugMessage::info(format!(
                        "[ShapeBoundary::from_css_shape] Inset - insets: ({}, {}, {}, {})",
                        inset.inset_top, inset.inset_right, inset.inset_bottom, inset.inset_left
                    )));
                    msgs.push(LayoutDebugMessage::info(format!(
                        "[ShapeBoundary::from_css_shape] Inset - resulting rect: x={x}, y={y}, \
                         w={width}, h={height}"
                    )));
                }
                Self::Rectangle(Rect {
                    x,
                    y,
                    width: width.max(0.0),
                    height: height.max(0.0),
                })
            }
4
            CssShape::Path(path) => {
                // CSS `path()` value: `path.data` is a raw SVG path `d=""` string in the
                // reference-box coordinate system (origin at the reference box's top-left).
                // Parse + flatten it into `Vec<PathSegment>` (curves sampled to line
                // segments) so the scanline code in `get_shape_horizontal_spans` can
                // intersect it per line, exactly like `polygon`.
4
                let segments = azul_core::path_parser::parse_svg_path_d(path.data.as_str())
4
                    .map_or_else(|_| Vec::new(), |multipolygon| {
3
                        flatten_svg_to_path_segments(&multipolygon, reference_box)
3
                    });
4
                if let Some(msgs) = debug_messages {
                    msgs.push(LayoutDebugMessage::info(format!(
                        "[ShapeBoundary::from_css_shape] Path - parsed {} flattened segments",
                        segments.len()
                    )));
4
                }
4
                if segments.is_empty() {
                    // Unparseable / empty path: fall back to the reference rectangle so a
                    // shape-inside container does not collapse to zero usable space.
1
                    Self::Rectangle(reference_box)
                } else {
3
                    Self::Path { segments }
                }
            }
        };
4
        if let Some(msgs) = debug_messages {
            msgs.push(LayoutDebugMessage::info(format!(
                "[ShapeBoundary::from_css_shape] Result: {result:?}"
            )));
4
        }
4
        result
4
    }
}
#[derive(Copy, Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct InlineBreak {
    pub break_type: BreakType,
    pub clear: ClearType,
    pub content_index: usize,
}
// +spec:line-breaking:d70ffd - Defines forced line break (Hard) vs soft wrap break (Soft) types
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum BreakType {
    Soft,   // Soft wrap break: UA creates unforced line breaks to fit content within the measure
    Hard,   // Forced line break: explicit line-breaking controls (preserved newline, <br>)
    Page,   // Page break
    Column, // Column break
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum ClearType {
    None,
    Left,
    Right,
    Both,
}
// Complex shape constraints for non-rectangular text flow
#[derive(Debug, Clone)]
pub(crate) struct ShapeConstraints {
    pub(crate) boundaries: Vec<ShapeBoundary>,
    pub(crate) exclusions: Vec<ShapeBoundary>,
    pub(crate) writing_mode: WritingMode,
    pub(crate) text_align: TextAlign,
    pub(crate) line_height: LineHeight,
}
#[derive(Debug, Clone, Copy, PartialEq, Default, Hash, Eq, PartialOrd, Ord)]
pub enum WritingMode {
    #[default]
    HorizontalTb, // horizontal-tb (normal horizontal)
    VerticalRl, // +spec:writing-modes:6e22a7 - vertical-rl (vertical right-to-left, commonly used in East Asia)
    VerticalLr, // vertical-lr (vertical left-to-right)
    SidewaysRl, // sideways-rl (rotated horizontal in vertical context)
    SidewaysLr, // sideways-lr (rotated horizontal in vertical context)
}
impl WritingMode {
    /// Necessary to determine if the glyphs are advancing in a horizontal direction
5
    #[must_use] pub const fn is_advance_horizontal(&self) -> bool {
2
        matches!(
5
            self,
            Self::HorizontalTb | Self::SidewaysRl | Self::SidewaysLr
        )
5
    }
}
#[derive(Debug, Clone, Copy, PartialEq, Default, Hash, Eq, PartialOrd, Ord)]
pub enum JustifyContent {
    #[default]
    None,
    InterWord,      // Expand spaces between words
    InterCharacter, // Expand spaces between all characters (for CJK)
    Distribute,     // Distribute space evenly including start/end
    Kashida,        // Stretch Arabic text using kashidas
}
// Enhanced text alignment with logical directions
#[derive(Debug, Clone, Copy, PartialEq, Default, Hash, Eq, PartialOrd, Ord)]
pub enum TextAlign {
    #[default]
    Left,
    Right,
    Center,
    Justify,
    Start,
    End,        // Logical start/end
    JustifyAll, // Justify including last line
}
// +spec:block-formatting-context:458d31 - vertical text orientation: upright for horizontal scripts, intrinsic for vertical scripts
// Vertical text orientation for individual characters
#[derive(Debug, Clone, Copy, PartialEq, Default, Eq, PartialOrd, Ord, Hash)]
pub enum TextOrientation {
    #[default]
    Mixed, // Default: upright for scripts, rotated for others
    Upright,  // All characters upright
    Sideways, // All characters rotated 90 degrees
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[derive(Default)]
pub struct TextDecoration {
    pub underline: bool,
    pub strikethrough: bool,
    pub overline: bool,
}
impl TextDecoration {
    /// Convert from CSS `StyleTextDecoration` enum to our internal representation.
    /// 
    /// Note: CSS text-decoration can have multiple values (underline line-through),
    /// but the current azul-css parser only supports single values. This can be
    /// extended in the future if CSS parsing is updated.
382
    #[must_use] pub fn from_css(css: azul_css::props::style::text::StyleTextDecoration) -> Self {
        use azul_css::props::style::text::StyleTextDecoration;
382
        match css {
1
            StyleTextDecoration::None => Self::default(),
379
            StyleTextDecoration::Underline => Self {
379
                underline: true,
379
                strikethrough: false,
379
                overline: false,
379
            },
1
            StyleTextDecoration::Overline => Self {
1
                underline: false,
1
                strikethrough: false,
1
                overline: true,
1
            },
1
            StyleTextDecoration::LineThrough => Self {
1
                underline: false,
1
                strikethrough: true,
1
                overline: false,
1
            },
        }
382
    }
}
#[derive(Debug, Clone, Copy, PartialEq, Hash, Eq, PartialOrd, Ord, Default)]
pub enum TextTransform {
    #[default]
    None,
    Uppercase,
    Lowercase,
    Capitalize,
    // only within preserved white space (non-preserved spaces already collapsed in Phase I)
    FullWidth,
}
// Type alias for OpenType feature tags
pub type FourCc = [u8; 4];
// Enum for relative or absolute spacing
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
pub enum Spacing {
    Px(i32), // Whole-pixel spacing (kept for hashing/equality convenience)
    /// Sub-pixel resolved pixel spacing. `letter-spacing`/`word-spacing` accumulate
    /// once per glyph, so quantizing to whole pixels (the `Px(i32)` variant) multiplies
    /// the rounding error across a run. The CSS resolution path emits this variant to
    /// preserve the exact sub-pixel value (e.g. `letter-spacing: 0.4px`).
    PxF(f32),
    Em(f32),
}
// A type that implements `Hash` must also implement `Eq`.
// Since f32 does not implement `Eq`, we provide a manual implementation.
// The derived `PartialEq` is sufficient for this marker trait.
impl Eq for Spacing {}
impl Hash for Spacing {
1245564
    fn hash<H: Hasher>(&self, state: &mut H) {
        // First, hash the enum variant to distinguish between Px and Em.
1245564
        discriminant(self).hash(state);
1245564
        match self {
5654
            Self::Px(val) => val.hash(state),
            // For hashing floats, convert them to their raw bit representation.
            // This ensures that identical float values produce identical hashes.
1239910
            Self::PxF(val) | Self::Em(val) => val.to_bits().hash(state),
        }
1245564
    }
}
impl Default for Spacing {
130174
    fn default() -> Self {
130174
        Self::Px(0)
130174
    }
}
impl Spacing {
    /// Resolve this spacing to pixels given the element's font size (for `Em`).
    #[allow(clippy::cast_precision_loss)] // small integer px values; f32 mantissa is ample
    #[must_use]
7860781
    pub fn resolve_px(self, font_size_px: f32) -> f32 {
7860781
        match self {
59513
            Self::Px(px) => px as f32,
7801261
            Self::PxF(px) => px,
7
            Self::Em(em) => em * font_size_px,
        }
7860781
    }
}
impl Default for FontHash {
1
    fn default() -> Self {
1
        Self::invalid()
1
    }
}
/// Style properties with vertical text support
#[derive(Debug, Clone, PartialEq)]
pub struct StyleProperties {
    /// Font stack for fallback support (priority order)
    /// Can be either a list of `FontSelectors` (resolved via fontconfig)
    /// or a direct `FontRef` (bypasses fontconfig entirely).
    pub font_stack: FontStack,
    pub font_size_px: f32,
    pub color: ColorU,
    /// Background color for inline elements (e.g., `<span style="background-color: yellow">`)
    ///
    /// This is propagated from CSS through the style system and eventually used by
    /// the PDF renderer to draw filled rectangles behind text. The value is `None`
    /// for transparent backgrounds (the default).
    ///
    /// The propagation chain is:
    /// CSS -> `get_style_properties()` -> `StyleProperties` -> `ShapedGlyph` -> `PdfGlyphRun`
    ///
    /// See `PdfGlyphRun::background_color` for how this is used in PDF rendering.
    pub background_color: Option<ColorU>,
    /// Full background content layers (for gradients, images, etc.)
    /// This extends `background_color` to support CSS gradients on inline elements.
    pub background_content: Vec<StyleBackgroundContent>,
    /// Border information for inline elements
    pub border: Option<InlineBorderInfo>,
    // +spec:text-alignment-spacing:b39a04 - word-spacing and letter-spacing control text spacing
    pub letter_spacing: Spacing,
    pub word_spacing: Spacing,
    pub line_height: LineHeight,
    pub text_decoration: TextDecoration,
    // Represents CSS font-feature-settings like `"liga"`, `"smcp=1"`.
    pub font_features: Vec<String>,
    // Variable fonts
    pub font_variations: Vec<(FourCc, f32)>,
    // Multiplier of the space width
    pub tab_size: f32,
    // text-transform
    pub text_transform: TextTransform,
    // Vertical text properties
    pub writing_mode: WritingMode,
    pub text_orientation: TextOrientation,
    // Tate-chu-yoko
    pub text_combine_upright: Option<TextCombineUpright>,
    // Variant handling
    pub font_variant_caps: FontVariantCaps,
    pub font_variant_numeric: FontVariantNumeric,
    pub font_variant_ligatures: FontVariantLigatures,
    pub font_variant_east_asian: FontVariantEastAsian,
    /// The element's own `vertical-align` (baseline / sub / super / length / percentage).
    /// Read per shaped cluster by `get_item_vertical_align` so an inline `<span>` shifts
    /// its text relative to the line baseline. `Baseline` (the default) leaves the cluster
    /// on the line's default alignment.
    pub vertical_align: VerticalAlign,
}
impl Default for StyleProperties {
65086
    fn default() -> Self {
        const FONT_SIZE: f32 = 16.0;
        const TAB_SIZE: f32 = 8.0;
65086
        Self {
65086
            font_stack: FontStack::default(),
65086
            font_size_px: FONT_SIZE,
65086
            color: ColorU::default(),
65086
            background_color: None,
65086
            background_content: Vec::new(),
65086
            border: None,
65086
            letter_spacing: Spacing::default(), // Px(0)
65086
            word_spacing: Spacing::default(),   // Px(0)
65086
            line_height: LineHeight::Normal,
65086
            text_decoration: TextDecoration::default(),
65086
            font_features: Vec::new(),
65086
            font_variations: Vec::new(),
65086
            tab_size: TAB_SIZE, // CSS default
65086
            text_transform: TextTransform::default(),
65086
            writing_mode: WritingMode::default(),
65086
            text_orientation: TextOrientation::default(),
65086
            text_combine_upright: None,
65086
            font_variant_caps: FontVariantCaps::default(),
65086
            font_variant_numeric: FontVariantNumeric::default(),
65086
            font_variant_ligatures: FontVariantLigatures::default(),
65086
            font_variant_east_asian: FontVariantEastAsian::default(),
65086
            vertical_align: VerticalAlign::Baseline,
65086
        }
65086
    }
}
impl Hash for StyleProperties {
    #[allow(clippy::cast_possible_truncation)] // bounded pixel/coord/colour/glyph cast
331976
    fn hash<H: Hasher>(&self, state: &mut H) {
331976
        self.font_stack.hash(state);
331976
        self.color.hash(state);
331976
        self.background_color.hash(state);
331976
        self.text_decoration.hash(state);
331976
        self.font_features.hash(state);
331976
        self.writing_mode.hash(state);
331976
        self.text_orientation.hash(state);
331976
        self.text_combine_upright.hash(state);
331976
        self.vertical_align.hash(state);
331976
        self.letter_spacing.hash(state);
331976
        self.word_spacing.hash(state);
        // For f32 fields, round and cast to usize before hashing.
331976
        (self.font_size_px.round() as isize).hash(state);
331976
        self.line_height.hash(state);
331976
    }
}
impl StyleProperties {
    /// Returns a hash that only includes properties that affect text layout.
    /// 
    /// Properties that DON'T affect layout (only rendering):
    /// - color, `background_color`, `background_content`
    /// - `text_decoration` (underline, etc.)
    /// - border (for inline elements)
    ///
    /// Properties that DO affect layout:
    /// - `font_stack`, `font_size_px`, `font_features`, `font_variations`
    /// - `letter_spacing`, `word_spacing`, `line_height`, `tab_size`
    /// - `writing_mode`, `text_orientation`, `text_combine_upright`
    /// - `text_transform`
    /// - `font_variant`_* (affects glyph selection)
    ///
    /// This allows the layout cache to reuse layouts when only rendering
    /// properties change (e.g., color changes on hover).
    // (family, weight, style) so that shaping runs break at element boundaries where font
    // properties differ, preventing impossible cross-boundary ligatures (e.g. "and" → "&").
    #[allow(clippy::cast_possible_truncation)] // bounded pixel/coord/colour/glyph cast
290805
    #[must_use] pub fn layout_hash(&self) -> u64 {
        use std::hash::Hasher;
290805
        let mut hasher = DefaultHasher::new();
        // Font selection (affects shaping and metrics)
290805
        self.font_stack.hash(&mut hasher);
        // Hash the EXACT font size bits, not a rounded integer: two styles differing
        // by <0.5px must not share a shaping-cache entry / coalesce, or one run gets
        // shaped at the other's size (wrong advances/metrics).
290805
        self.font_size_px.to_bits().hash(&mut hasher);
290805
        self.font_features.hash(&mut hasher);
        // font_variations affects glyph outlines
290805
        for (tag, value) in &self.font_variations {
            tag.hash(&mut hasher);
            (value.round() as i32).hash(&mut hasher);
        }
        // Spacing (affects glyph positions)
290805
        self.letter_spacing.hash(&mut hasher);
290805
        self.word_spacing.hash(&mut hasher);
290805
        self.line_height.hash(&mut hasher);
290805
        (self.tab_size.round() as isize).hash(&mut hasher);
        // Writing mode (affects layout direction)
290805
        self.writing_mode.hash(&mut hasher);
290805
        self.text_orientation.hash(&mut hasher);
290805
        self.text_combine_upright.hash(&mut hasher);
        // Text transform (affects which characters are used)
290805
        self.text_transform.hash(&mut hasher);
        // Font variants (affect glyph selection)
290805
        self.font_variant_caps.hash(&mut hasher);
290805
        self.font_variant_numeric.hash(&mut hasher);
290805
        self.font_variant_ligatures.hash(&mut hasher);
290805
        self.font_variant_east_asian.hash(&mut hasher);
290805
        hasher.finish()
290805
    }
    /// Check if two `StyleProperties` have the same layout-affecting properties.
    ///
    /// Returns true if the layouts would be identical (only rendering differs).
    ///
    /// **Note:** This is a fast-path comparison using 64-bit hashes.  Hash
    /// collisions are theoretically possible, which could cause the cache to
    /// serve a stale layout.  In practice the probability is negligible for
    /// the number of distinct `StyleProperties` values in a single document.
16
    #[must_use] pub fn layout_eq(&self, other: &Self) -> bool {
16
        self.layout_hash() == other.layout_hash()
16
    }
}
#[derive(Copy, Debug, Clone, PartialEq, Hash, Eq, PartialOrd, Ord)]
pub enum TextCombineUpright {
    None,
    All,        // Combine all characters in horizontal layout
    Digits(u8), // Combine up to N digits
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum GlyphSource {
    /// Glyph generated from a character in the source text.
    Char,
    /// Glyph inserted dynamically by the layout engine (e.g., a hyphen).
    Hyphen,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CharacterClass {
    Space,       // Regular spaces - highest justification priority
    Punctuation, // Can sometimes be adjusted
    Letter,      // Normal letters
    Ideograph,   // CJK characters - can be justified between
    Symbol,      // Symbols, emojis
    Combining,   // Combining marks - never justified
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GlyphOrientation {
    Horizontal, // Keep horizontal (normal in horizontal text)
    Vertical,   // Rotate to vertical (normal in vertical text)
    Upright,    // Keep upright regardless of writing mode
    Mixed,      // Use script-specific default orientation
}
// Bidi and script detection
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum BidiDirection {
    Ltr,
    Rtl,
}
impl BidiDirection {
55891
    #[must_use] pub const fn is_rtl(&self) -> bool {
55891
        matches!(self, Self::Rtl)
55891
    }
}
/// CSS `unicode-bidi` property values relevant to layout.
///
/// When `Plaintext`, the bidi algorithm uses P2/P3 heuristics to auto-detect
/// paragraph direction from text content, instead of the HL1 override from
/// the CSS `direction` property.
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[derive(Default)]
pub enum UnicodeBidi {
    #[default]
    Normal,
    Embed,
    Isolate,
    BidiOverride,
    IsolateOverride,
    Plaintext,
}
#[derive(Debug, Clone, Copy, PartialEq, Hash, Eq, PartialOrd, Ord, Default)]
pub enum FontVariantCaps {
    #[default]
    Normal,
    SmallCaps,
    AllSmallCaps,
    PetiteCaps,
    AllPetiteCaps,
    Unicase,
    TitlingCaps,
}
#[derive(Debug, Clone, Copy, PartialEq, Hash, Eq, PartialOrd, Ord, Default)]
pub enum FontVariantNumeric {
    #[default]
    Normal,
    LiningNums,
    OldstyleNums,
    ProportionalNums,
    TabularNums,
    DiagonalFractions,
    StackedFractions,
    Ordinal,
    SlashedZero,
}
#[derive(Debug, Clone, Copy, PartialEq, Hash, Eq, PartialOrd, Ord, Default)]
pub enum FontVariantLigatures {
    #[default]
    Normal,
    None,
    Common,
    NoCommon,
    Discretionary,
    NoDiscretionary,
    Historical,
    NoHistorical,
    Contextual,
    NoContextual,
}
#[derive(Debug, Clone, Copy, PartialEq, Hash, Eq, PartialOrd, Ord, Default)]
pub enum FontVariantEastAsian {
    #[default]
    Normal,
    Jis78,
    Jis83,
    Jis90,
    Jis04,
    Simplified,
    Traditional,
    FullWidth,
    ProportionalWidth,
    Ruby,
}
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct BidiLevel(u8);
impl BidiLevel {
110523
    #[must_use] pub const fn new(level: u8) -> Self {
110523
        Self(level)
110523
    }
49879
    #[must_use] pub const fn is_rtl(&self) -> bool {
49879
        self.0 % 2 == 1
49879
    }
48966
    #[must_use] pub const fn level(&self) -> u8 {
48966
        self.0
48966
    }
}
// Add this new struct for style overrides
#[derive(Debug, Clone)]
pub struct StyleOverride {
    /// The specific character this override applies to.
    pub target: ContentIndex,
    /// The style properties to apply.
    /// Any `None` value means "inherit from the base style".
    pub style: PartialStyleProperties,
}
#[derive(Debug, Clone, Default)]
pub struct PartialStyleProperties {
    pub font_stack: Option<FontStack>,
    pub font_size_px: Option<f32>,
    pub color: Option<ColorU>,
    pub letter_spacing: Option<Spacing>,
    pub word_spacing: Option<Spacing>,
    pub line_height: Option<LineHeight>,
    pub text_decoration: Option<TextDecoration>,
    pub font_features: Option<Vec<String>>,
    pub font_variations: Option<Vec<(FourCc, f32)>>,
    pub tab_size: Option<f32>,
    pub text_transform: Option<TextTransform>,
    pub writing_mode: Option<WritingMode>,
    pub text_orientation: Option<TextOrientation>,
    pub text_combine_upright: Option<Option<TextCombineUpright>>,
    pub font_variant_caps: Option<FontVariantCaps>,
    pub font_variant_numeric: Option<FontVariantNumeric>,
    pub font_variant_ligatures: Option<FontVariantLigatures>,
    pub font_variant_east_asian: Option<FontVariantEastAsian>,
}
impl Hash for PartialStyleProperties {
81
    fn hash<H: Hasher>(&self, state: &mut H) {
81
        self.font_stack.hash(state);
81
        self.font_size_px.map(f32::to_bits).hash(state);
81
        self.color.hash(state);
81
        self.letter_spacing.hash(state);
81
        self.word_spacing.hash(state);
81
        self.line_height.hash(state);
81
        self.text_decoration.hash(state);
81
        self.font_features.hash(state);
        // Manual hashing for Vec<(FourCc, f32)>
81
        if let Some(v) = self.font_variations.as_ref() {
            for (tag, val) in v {
                tag.hash(state);
                val.to_bits().hash(state);
            }
81
        }
81
        self.tab_size.map(f32::to_bits).hash(state);
81
        self.text_transform.hash(state);
81
        self.writing_mode.hash(state);
81
        self.text_orientation.hash(state);
81
        self.text_combine_upright.hash(state);
81
        self.font_variant_caps.hash(state);
81
        self.font_variant_numeric.hash(state);
81
        self.font_variant_ligatures.hash(state);
81
        self.font_variant_east_asian.hash(state);
81
    }
}
impl PartialEq for PartialStyleProperties {
    fn eq(&self, other: &Self) -> bool {
        self.font_stack == other.font_stack &&
        self.font_size_px.map(f32::to_bits) == other.font_size_px.map(f32::to_bits) &&
        self.color == other.color &&
        self.letter_spacing == other.letter_spacing &&
        self.word_spacing == other.word_spacing &&
        self.line_height == other.line_height &&
        self.text_decoration == other.text_decoration &&
        self.font_features == other.font_features &&
        self.font_variations == other.font_variations && // Vec<(FourCc, f32)> is PartialEq
        self.tab_size.map(f32::to_bits) == other.tab_size.map(f32::to_bits) &&
        self.text_transform == other.text_transform &&
        self.writing_mode == other.writing_mode &&
        self.text_orientation == other.text_orientation &&
        self.text_combine_upright == other.text_combine_upright &&
        self.font_variant_caps == other.font_variant_caps &&
        self.font_variant_numeric == other.font_variant_numeric &&
        self.font_variant_ligatures == other.font_variant_ligatures &&
        self.font_variant_east_asian == other.font_variant_east_asian
    }
}
impl Eq for PartialStyleProperties {}
impl StyleProperties {
138
    fn apply_override(&self, partial: &PartialStyleProperties) -> Self {
138
        let mut new_style = self.clone();
138
        if let Some(val) = &partial.font_stack {
            new_style.font_stack = val.clone();
138
        }
138
        if let Some(val) = partial.font_size_px {
2
            new_style.font_size_px = val;
137
        }
138
        if let Some(val) = &partial.color {
54
            new_style.color = *val;
84
        }
138
        if let Some(val) = partial.letter_spacing {
1
            new_style.letter_spacing = val;
137
        }
138
        if let Some(val) = partial.word_spacing {
            new_style.word_spacing = val;
138
        }
138
        if let Some(val) = partial.line_height {
            new_style.line_height = val;
138
        }
138
        if let Some(val) = &partial.text_decoration {
            new_style.text_decoration = *val;
138
        }
138
        if let Some(val) = &partial.font_features {
            new_style.font_features.clone_from(val);
138
        }
138
        if let Some(val) = &partial.font_variations {
            new_style.font_variations.clone_from(val);
138
        }
138
        if let Some(val) = partial.tab_size {
            new_style.tab_size = val;
138
        }
138
        if let Some(val) = partial.text_transform {
            new_style.text_transform = val;
138
        }
138
        if let Some(val) = partial.writing_mode {
            new_style.writing_mode = val;
138
        }
138
        if let Some(val) = partial.text_orientation {
            new_style.text_orientation = val;
138
        }
138
        if let Some(val) = &partial.text_combine_upright {
81
            new_style.text_combine_upright.clone_from(val);
84
        }
138
        if let Some(val) = partial.font_variant_caps {
            new_style.font_variant_caps = val;
138
        }
138
        if let Some(val) = partial.font_variant_numeric {
            new_style.font_variant_numeric = val;
138
        }
138
        if let Some(val) = partial.font_variant_ligatures {
            new_style.font_variant_ligatures = val;
138
        }
138
        if let Some(val) = partial.font_variant_east_asian {
            new_style.font_variant_east_asian = val;
138
        }
138
        new_style
138
    }
}
/// The kind of a glyph, used to distinguish characters from layout-inserted items.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum GlyphKind {
    /// A standard glyph representing one or more characters from the source text.
    Character,
    /// A hyphen glyph inserted by the line breaking algorithm.
    Hyphen,
    /// A `.notdef` glyph, indicating a character that could not be found in any font.
    NotDef,
    /// A Kashida justification glyph, inserted to stretch Arabic text.
    Kashida {
        /// The target width of the kashida.
        width: f32,
    },
}
// --- Stage 1: Logical Representation ---
// [g117 az-web-lift FIX] `#[repr(C, u8)]` (was repr(Rust)) — same disc-mis-lift class as InlineContent
// above. LogicalItem is matched in measure Stage-2 (`if let LogicalItem::Text`) + reorder_logical_items;
// a repr(Rust) niche disc mis-lifts on the web. Explicit u8 tag at offset 0 = a simple load the lift
// reads correctly. Internal to text3 (not FFI-exposed). LogicalItem::Object embeds InlineContent inline.
#[derive(Debug, Clone)]
#[repr(C, u8)]
pub enum LogicalItem {
    Text {
        /// A stable ID pointing back to the original source character.
        source: ContentIndex,
        /// The text of this specific logical item. §3.2 3c: an `Arc` so
        /// shaped clusters can share it (`ShapedCluster::source_text`).
        /// For override-free runs this IS the `StyledRun`'s Arc; override
        /// / combine-upright segments mint one Arc per segment.
        text: Arc<str>,
        style: Arc<StyleProperties>,
        /// If this text is a list marker: whether it should be positioned outside
        /// (in the padding gutter) or inside (inline with content).
        /// None for non-marker content.
        marker_position_outside: Option<bool>,
        /// The DOM `NodeId` of the Text node this item originated from.
        /// None for generated content (list markers, `::before/::after`, etc.)
        source_node_id: Option<NodeId>,
    },
    // +spec:display-property:b1533f - text-combine-upright tate-chu-yoko horizontal-in-vertical composition
    /// Tate-chu-yoko: Run of text to be laid out horizontally within a vertical context.
    CombinedText {
        source: ContentIndex,
        text: String,
        style: Arc<StyleProperties>,
    },
    Ruby {
        source: ContentIndex,
        // For the stub, we simplify to strings. A full implementation
        // would need to handle Vec<LogicalItem> for both.
        base_text: String,
        ruby_text: String,
        style: Arc<StyleProperties>,
    },
    Object {
        /// A stable ID pointing back to the original source object.
        source: ContentIndex,
        /// The original non-text object.
        content: InlineContent,
    },
    Tab {
        source: ContentIndex,
        style: Arc<StyleProperties>,
    },
    Break {
        source: ContentIndex,
        break_info: InlineBreak,
    },
}
impl Hash for LogicalItem {
    fn hash<H: Hasher>(&self, state: &mut H) {
        discriminant(self).hash(state);
        match self {
            Self::Text {
                source,
                text,
                style,
                marker_position_outside,
                source_node_id,
            } => {
                source.hash(state);
                text.hash(state);
                style.as_ref().hash(state); // Hash the content, not the Arc pointer
                marker_position_outside.hash(state);
                source_node_id.hash(state);
            }
            Self::CombinedText {
                source,
                text,
                style,
            } => {
                source.hash(state);
                text.hash(state);
                style.as_ref().hash(state);
            }
            Self::Ruby {
                source,
                base_text,
                ruby_text,
                style,
            } => {
                source.hash(state);
                base_text.hash(state);
                ruby_text.hash(state);
                style.as_ref().hash(state);
            }
            Self::Object { source, content } => {
                source.hash(state);
                content.hash(state);
            }
            Self::Tab { source, style } => {
                source.hash(state);
                style.as_ref().hash(state);
            }
            Self::Break { source, break_info } => {
                source.hash(state);
                break_info.hash(state);
            }
        }
    }
}
// --- Stage 2: Visual Representation ---
#[derive(Debug, Clone)]
pub struct VisualItem {
    /// A reference to the logical item this visual item originated from.
    /// A single `LogicalItem` can be split into multiple `VisualItems`.
    pub logical_source: LogicalItem,
    /// The Bidi embedding level for this item.
    pub bidi_level: BidiLevel,
    /// The script detected for this run, crucial for shaping.
    pub script: Script,
    /// The text content for this specific visual run.
    pub text: String,
    /// Byte offset of this visual run's `text` within its source logical run's
    /// text. When bidi splits one logical run into several visual runs, each
    /// shaped cluster's `start_byte_in_run` is produced relative to this visual
    /// run's `text`; adding `run_byte_offset` re-bases it to the logical run so
    /// cluster IDs stay unique and match caret/selection byte positions.
    pub run_byte_offset: usize,
}
// --- Stage 3: Shaped Representation ---
// [g118 az-web-lift FIX] `#[repr(C, u8)]` (was repr(Rust)) — same disc-mis-lift class as InlineContent
// + LogicalItem (g117). ShapedItem is matched in measure Stage-5 (`match item { ShapedItem::Cluster ..}`)
// + cloned/matched throughout shaping; a repr(Rust) niche disc mis-lifts on the web. Explicit u8 tag at
// offset 0 = a simple load the lift reads correctly. Internal to text3 (not FFI-exposed).
#[derive(Debug, Clone, PartialEq)]
#[repr(C, u8)]
pub enum ShapedItem {
    Cluster(ShapedCluster),
    /// A block of combined text (tate-chu-yoko) that is laid out
    // as a single unbreakable object.
    CombinedBlock {
        source: ContentIndex,
        /// The glyphs to be rendered horizontally within the vertical line.
        glyphs: ShapedGlyphVec,
        /// Uniform style of the combined run (tate-chu-yoko is one style;
        /// glyphs no longer carry per-glyph style — see `ShapedGlyph`).
        style: Arc<StyleProperties>,
        bounds: Rect,
        baseline_offset: f32,
    },
    Object {
        source: ContentIndex,
        bounds: Rect,
        baseline_offset: f32,
        // Store original object for rendering
        content: InlineContent,
    },
    Tab {
        source: ContentIndex,
        bounds: Rect,
    },
    Break {
        source: ContentIndex,
        break_info: InlineBreak,
    },
}
impl ShapedItem {
10797790
    #[must_use] pub const fn as_cluster(&self) -> Option<&ShapedCluster> {
10797790
        match self {
10795401
            Self::Cluster(c) => Some(c),
2389
            _ => None,
        }
10797790
    }
    /// Returns the bounding box of the item, relative to its own origin.
    ///
    /// The origin of the returned `Rect` is `(0,0)`, representing the top-left corner
    /// of the item's layout space before final positioning. The size represents the
    /// item's total advance (width in horizontal mode) and its line height (ascent + descent).
    #[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
8716991
    #[must_use] pub fn bounds(&self) -> Rect {
8716991
        match self {
8714015
            Self::Cluster(cluster) => {
                // The width of a text cluster is its total advance.
8714015
                let width = cluster.advance;
                // The height is the sum of its ascent and descent, which defines its line box.
                // We use the existing helper function which correctly calculates this from font
                // metrics.
8714015
                let (ascent, descent) = get_item_vertical_metrics_approx(self);
8714015
                let height = ascent + descent;
8714015
                Rect {
8714015
                    x: 0.0,
8714015
                    y: 0.0,
8714015
                    width,
8714015
                    height,
8714015
                }
            }
            // For atomic inline items like objects, combined blocks, and tabs,
            // their bounds have already been calculated during the shaping or measurement phase.
            Self::CombinedBlock { bounds, .. } => *bounds,
1972
            Self::Object { bounds, .. } => *bounds,
201
            Self::Tab { bounds, .. } => *bounds,
            // Breaks are control characters and have no visual geometry.
803
            Self::Break { .. } => Rect::default(), // A zero-sized rectangle.
        }
8716991
    }
}
/// Precomputed classification of a cluster's text — the flags word the
/// compact-record plan (§1.6) calls for. Computed ONCE at shaping, while
/// the cluster text is in hand; the line breaker's hot predicates then
/// read one bit instead of re-decoding UTF-8 on every probe (those scans
/// ran ~25x per cluster per line-break pass). Also the prerequisite for
/// deleting `ShapedCluster::text`: classification consumers stop needing
/// the bytes at all.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Hash)]
pub struct ClusterFlags(pub u16);
impl ClusterFlags {
    /// Any `is_word_separator_char` (space family) in the text.
    pub const WORD_SEPARATOR: u16 = 1 << 0;
    /// NBSP / NNBSP / WORD JOINER / ZWNBSP anywhere (UAX#14 GL/WJ):
    /// word-spacing glue that must NOT offer a soft-wrap opportunity.
    pub const NO_BREAK_SPACE: u16 = 1 << 1;
    /// U+200B ZERO WIDTH SPACE anywhere: always a wrap opportunity.
    pub const ZERO_WIDTH_SPACE: u16 = 1 << 2;
    /// Text starts with U+00AD SOFT HYPHEN.
    pub const SOFT_HYPHEN_START: u16 = 1 << 3;
    /// Ends with '-', U+2010 HYPHEN or '/' (UAX#14 HY/BA/SY: break AFTER).
    pub const ENDS_BREAKABLE: u16 = 1 << 4;
    /// Any CJK character (implicit break opportunity in word-break:normal).
    pub const HAS_CJK: u16 = 1 << 5;
    /// Leading char is a grapheme extender (UAX#29): the cluster merges
    /// into the preceding base and is not a standalone caret stop.
    pub const GRAPHEME_CONTINUATION: u16 = 1 << 6;
    /// (d6h) Everything below is DENSE-SIDE ONLY: set by
    /// `DenseText::from_unified` to pack `ShapedCluster` fields the 16 B
    /// compact record has no room for; `classify()` never sets them and
    /// sparse↔dense flag comparisons must mask with
    /// [`Self::CLASSIFY_MASK`].
    pub const CLASSIFY_MASK: u16 = (1 << 7) - 1;
    /// `ShapedCluster::is_first_fragment` (dense packing).
    pub const DENSE_IS_FIRST_FRAGMENT: u16 = 1 << 7;
    /// `ShapedCluster::is_last_fragment` (dense packing).
    pub const DENSE_IS_LAST_FRAGMENT: u16 = 1 << 8;
    /// `ShapedCluster::marker_position_outside.is_some()` (dense packing).
    pub const DENSE_MARKER_SOME: u16 = 1 << 9;
    /// `marker_position_outside == Some(true)` (dense packing).
    pub const DENSE_MARKER_OUTSIDE: u16 = 1 << 10;
    #[must_use]
843655
    pub fn classify(text: &str) -> Self {
843655
        let mut bits = 0u16;
844700
        for (i, ch) in text.chars().enumerate() {
844700
            if is_word_separator_char(ch) {
93380
                bits |= Self::WORD_SEPARATOR;
751320
            }
844700
            if matches!(ch, '\u{00A0}' | '\u{202F}' | '\u{2060}' | '\u{FEFF}') {
19
                bits |= Self::NO_BREAK_SPACE;
844681
            }
844700
            if ch == '\u{200B}' {
423
                bits |= Self::ZERO_WIDTH_SPACE;
844277
            }
844700
            if is_cjk_character(ch) {
340
                bits |= Self::HAS_CJK;
844360
            }
844700
            if i == 0 {
843640
                if ch == '\u{00AD}' {
37
                    bits |= Self::SOFT_HYPHEN_START;
843603
                }
                // Grapheme-extender probe: 'x' + ch collapsing to one
                // grapheme means ch extends the preceding base.
843640
                let mut probe = String::with_capacity(1 + ch.len_utf8());
843640
                probe.push('x');
843640
                probe.push(ch);
843640
                if probe.graphemes(true).count() == 1 {
94
                    bits |= Self::GRAPHEME_CONTINUATION;
843546
                }
1060
            }
        }
843655
        if text.ends_with('\u{002D}') || text.ends_with('\u{2010}') || text.ends_with('\u{002F}') {
2198
            bits |= Self::ENDS_BREAKABLE;
841457
        }
843655
        Self(bits)
843655
    }
    #[must_use]
35328780
    pub const fn has(self, bit: u16) -> bool {
35328780
        self.0 & bit != 0
35328780
    }
}
/// A group of glyphs that corresponds to one or more source characters (a cluster).
#[derive(Debug, Clone, PartialEq)]
pub struct ShapedCluster {
    /// §3.2 step 3c: the text this cluster was shaped FROM, as a shared
    /// `Arc` of the whole LOGICAL ITEM's text (for override-free runs
    /// that is the `StyledRun`'s own Arc — zero extra allocations). The
    /// per-cluster `String` copy this replaces was the single largest
    /// retained-text duplication (one heap alloc per cluster); the
    /// cluster's own text is the `source_byte_len`-long slice at
    /// `source_cluster_id.start_byte_in_run` — see [`Self::text`].
    ///
    /// NOTE `start_byte_in_run` is relative to the LOGICAL ITEM (the
    /// bidi re-base adds only the visual fragment's offset within the
    /// item; a style-override segment's run offset lives in
    /// `source_content_index.item_index`) — which is exactly why this
    /// field holds the ITEM text, not unconditionally the run text.
    pub source_text: Arc<str>,
    /// Byte length of this cluster's slice in `source_text`. Stored, not
    /// re-derived: ligature-fused clusters span MULTIPLE graphemes, so
    /// "next grapheme boundary" cannot reconstruct the slice in general.
    pub source_byte_len: u16,
    /// The ID of the grapheme cluster this glyph cluster represents.
    pub source_cluster_id: GraphemeClusterId,
    /// The source `ContentIndex` for mapping back to logical items.
    pub source_content_index: ContentIndex,
    /// The DOM `NodeId` of the Text node this cluster originated from.
    /// None for generated content (list markers, `::before/::after`, etc.)
    pub source_node_id: Option<NodeId>,
    /// The glyphs that make up this cluster. `SmallVec<[T; 1]>` — inline
    /// single-glyph clusters (the common case for Latin text), spill to
    /// heap only for ligatures / combining marks.
    pub glyphs: ShapedGlyphVec,
    /// Precomputed text classification — see [`ClusterFlags`].
    pub flags: ClusterFlags,
    /// The total advance width (horizontal) or height (vertical) of the cluster.
    pub advance: f32,
    /// The direction of this cluster, inherited from its `VisualItem`.
    pub direction: BidiDirection,
    /// Font style of this cluster
    pub style: Arc<StyleProperties>,
    /// If this cluster is a list marker: whether it should be positioned outside
    /// (in the padding gutter) or inside (inline with content).
    /// None for non-marker content.
    pub marker_position_outside: Option<bool>,
    /// True if this is the first visual fragment of its inline box.
    /// Used for `box-decoration-break` and split inline border/padding.
    /// When an inline element wraps across lines, only the first fragment
    /// gets the start-edge border/padding.
    pub is_first_fragment: bool,
    /// True if this is the last visual fragment of its inline box.
    /// Only the last fragment gets the end-edge border/padding.
    pub is_last_fragment: bool,
}
impl ShapedCluster {
    /// The cluster's source text: the `source_byte_len`-long slice of the
    /// shared item text at `start_byte_in_run`. Replaces the deleted
    /// per-cluster `String` (§3.2 step 3c); T1 pins slice==shaped-text.
    /// Out-of-range indices (defensive; ids are shaper-produced) yield "".
    #[must_use]
28342533
    pub fn text(&self) -> &str {
28342533
        if self.source_cluster_id.start_byte_in_run == u32::MAX {
            // Synthesized clusters (hyphenation hyphen, kashida) carry the
            // sentinel id and their own tiny Arc — the whole buffer IS the
            // cluster text.
            return &self.source_text;
28342533
        }
28342533
        let start = self.source_cluster_id.start_byte_in_run as usize;
28342533
        self.source_text
28342533
            .get(start..start + self.source_byte_len as usize)
28342533
            .unwrap_or("")
28342533
    }
}
/// Shared empty `Arc<str>`: the pre-stamp placeholder for
/// `ShapedCluster::source_text`. `shape_text_correctly` cannot see the
/// logical item's Arc (it receives a visual-fragment `&str`), so it
/// stamps this and the shaping loop overwrites it with the real item Arc
/// right after the bidi re-base — the same site that finalizes
/// `start_byte_in_run`, which `text()` slices with.
#[must_use]
836929
pub fn empty_arc_str() -> Arc<str> {
    static EMPTY: std::sync::OnceLock<Arc<str>> = std::sync::OnceLock::new();
836929
    EMPTY.get_or_init(|| Arc::from("")).clone()
836929
}
/// A single, shaped glyph with its essential metrics.
// Deliberately NOT `Copy`: this is ~60 bytes on the hottest path in the
// engine, and an implicit copy is exactly the kind of silent cost the
// memory campaign spent weeks removing. Callers clone explicitly.
#[allow(missing_copy_implementations)]
#[derive(Debug, Clone, PartialEq)]
pub struct ShapedGlyph {
    /// The kind of glyph this is (character, hyphen, etc.).
    pub kind: GlyphKind,
    /// Glyph ID inside of the font
    pub glyph_id: u16,
    /// The byte offset of this glyph's source character(s) within its cluster text.
    pub cluster_offset: u32,
    /// The horizontal advance for this glyph (for horizontal text) - this is the BASE advance
    /// from the font metrics, WITHOUT kerning applied
    pub advance: f32,
    /// The kerning adjustment for this glyph (positive = more space, negative = less space)
    /// This is separate from advance so we can position glyphs absolutely
    pub kerning: f32,
    /// The horizontal offset/bearing for this glyph
    pub offset: Point,
    /// The vertical advance for this glyph (for vertical text).
    pub vertical_advance: f32,
    /// The vertical offset/bearing for this glyph.
    pub vertical_offset: Point,
    pub script: Script,
    // NOTE (§3.3 field disposition, 2026-08-10): `style` was REMOVED from
    // the per-glyph record — style is uniform within a cluster by
    // construction (shaping runs are style-coalesced), so the enclosing
    // `ShapedCluster::style` is the single source. This deletes one
    // Arc<StyleProperties> per glyph (~94k retained across the holders),
    // kills the per-glyph Arc-clone loop in the shaping-cache hit
    // re-stamp, and unblocks sharing whole glyph arrays behind Arc.
    /// Hash of the font - use `LoadedFonts` to look up the actual font when needed
    pub font_hash: u64,
    /// Cached font metrics to avoid font lookup for common operations
    pub font_metrics: LayoutFontMetrics,
}
impl ShapedGlyph {
    #[must_use] pub fn into_glyph_instance<T: ParsedFontTrait>(
        &self,
        style: &StyleProperties,
        writing_mode: WritingMode,
        loaded_fonts: &LoadedFonts<T>,
    ) -> GlyphInstance {
        let size = loaded_fonts
            .get_by_hash(self.font_hash)
            .and_then(|font| font.get_glyph_size(self.glyph_id, style.font_size_px))
            .unwrap_or_default();
        let position = if writing_mode.is_advance_horizontal() {
            LogicalPosition {
                x: self.offset.x,
                y: self.offset.y,
            }
        } else {
            LogicalPosition {
                x: self.vertical_offset.x,
                y: self.vertical_offset.y,
            }
        };
        GlyphInstance {
            index: u32::from(self.glyph_id),
            point: position,
            size,
        }
    }
    /// Convert this `ShapedGlyph` into a `GlyphInstance` with an absolute position.
    /// This is used for display list generation where glyphs need their final page coordinates.
    #[must_use] pub fn into_glyph_instance_at<T: ParsedFontTrait>(
        &self,
        writing_mode: WritingMode,
        absolute_position: LogicalPosition,
        style: &StyleProperties,
        loaded_fonts: &LoadedFonts<T>,
    ) -> GlyphInstance {
        let size = loaded_fonts
            .get_by_hash(self.font_hash)
            .and_then(|font| font.get_glyph_size(self.glyph_id, style.font_size_px))
            .unwrap_or_default();
        GlyphInstance {
            index: u32::from(self.glyph_id),
            point: absolute_position,
            size,
        }
    }
    /// Convert this `ShapedGlyph` into a `GlyphInstance` with an absolute position.
    /// This version doesn't require fonts - it uses a default size.
    /// Use this when you don't need precise glyph bounds (e.g., display list generation).
1582
    #[must_use] pub fn into_glyph_instance_at_simple(
1582
        &self,
1582
        _writing_mode: WritingMode,
1582
        absolute_position: LogicalPosition,
1582
    ) -> GlyphInstance {
        // Use font metrics to estimate size, or default to zero
        // The actual rendering will use the font directly
1582
        GlyphInstance {
1582
            index: u32::from(self.glyph_id),
1582
            point: absolute_position,
1582
            size: LogicalSize::default(),
1582
        }
1582
    }
}
// --- Stage 4: Positioned Representation (Final Layout) ---
#[derive(Debug, Clone, PartialEq)]
pub struct PositionedItem {
    pub item: ShapedItem,
    pub position: Point,
    pub line_index: usize,
}
#[derive(Debug, Clone)]
pub struct UnifiedLayout {
    pub items: Vec<PositionedItem>,
    /// Information about content that did not fit.
    pub overflow: OverflowInfo,
}
impl UnifiedLayout {
    /// The cursor AFTER the last text cluster (Trailing on the final
    /// grapheme) — the end-of-text position selections and Ctrl+End use.
    /// `None` for layouts with no text clusters.
    #[must_use]
    pub fn end_cursor(&self) -> Option<TextCursor> {
        use azul_core::selection::CursorAffinity;
        let mut best: Option<GraphemeClusterId> = None;
        for item in &self.items {
            if let ShapedItem::Cluster(c) = &item.item {
                let id = c.source_cluster_id;
                let better = best.is_none_or(|b| {
                    (id.source_run, id.start_byte_in_run) > (b.source_run, b.start_byte_in_run)
                });
                if better {
                    best = Some(id);
                }
            }
        }
        Some(TextCursor {
            cluster_id: best?,
            affinity: CursorAffinity::Trailing,
        })
    }
    /// Calculate the bounding box of all positioned items.
    /// This is computed on-demand rather than cached.
393536
    #[must_use] pub fn bounds(&self) -> Rect {
393536
        if self.items.is_empty() {
166
            return Rect::default();
393370
        }
393370
        let mut min_x = f32::MAX;
393370
        let mut min_y = f32::MAX;
393370
        let mut max_x = f32::MIN;
393370
        let mut max_y = f32::MIN;
7491322
        for item in &self.items {
7097952
            let item_x = item.position.x;
7097952
            let item_y = item.position.y;
7097952

            
7097952
            // Get item dimensions
7097952
            let item_bounds = item.item.bounds();
7097952
            let item_width = item_bounds.width;
7097952
            let item_height = item_bounds.height;
7097952

            
7097952
            min_x = min_x.min(item_x);
7097952
            min_y = min_y.min(item_y);
7097952
            max_x = max_x.max(item_x + item_width);
7097952
            max_y = max_y.max(item_y + item_height);
7097952
        }
393370
        Rect {
393370
            x: min_x,
393370
            y: min_y,
393370
            width: max_x - min_x,
393370
            height: max_y - min_y,
393370
        }
393536
    }
2
    #[must_use] pub const fn is_empty(&self) -> bool {
2
        self.items.is_empty()
2
    }
29
    #[must_use] pub fn first_baseline(&self) -> Option<f32> {
29
        self.items
29
            .iter()
29
            .find_map(|item| get_baseline_for_item(&item.item))
29
    }
238464
    #[must_use] pub fn last_baseline(&self) -> Option<f32> {
238464
        self.items
238464
            .iter()
238464
            .rev()
238464
            .find_map(|item| get_baseline_for_item(&item.item))
238464
    }
    /// Takes a point relative to the layout's origin and returns the closest
    /// logical cursor position.
    ///
    /// This is the unified hit-testing implementation. The old `hit_test_to_cursor`
    /// method is deprecated in favor of this one.
    #[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
5399
    #[must_use] pub fn hittest_cursor(&self, point: LogicalPosition) -> Option<TextCursor> {
5399
        if self.items.is_empty() {
2
            return None;
5397
        }
        // Find the closest cluster vertically and horizontally
5397
        let mut closest_item_idx = 0;
5397
        let mut closest_distance = f32::MAX;
256872
        for (idx, item) in self.items.iter().enumerate() {
            // Only consider cluster items for cursor placement
256872
            if !matches!(item.item, ShapedItem::Cluster(_)) {
                continue;
256872
            }
256872
            let item_bounds = item.item.bounds();
256872
            let item_center_y = item.position.y + item_bounds.height / 2.0;
            // Distance from click position to item center
256872
            let vertical_distance = (point.y - item_center_y).abs();
            // For horizontal distance, check if we're within the cluster bounds
256872
            let horizontal_distance = if point.x < item.position.x {
109688
                item.position.x - point.x
147184
            } else if point.x > item.position.x + item_bounds.width {
106842
                point.x - (item.position.x + item_bounds.width)
            } else {
40342
                0.0 // Inside the cluster horizontally
            };
            // Combined distance (prioritize vertical proximity)
256872
            let distance = vertical_distance * 2.0 + horizontal_distance;
256872
            if distance < closest_distance {
67913
                closest_distance = distance;
67913
                closest_item_idx = idx;
188963
            }
        }
        // Get the closest cluster
5397
        let closest_item = &self.items[closest_item_idx];
5397
        let cluster = match &closest_item.item {
5397
            ShapedItem::Cluster(c) => c,
            // Objects are treated as a single cluster for selection
            ShapedItem::Object { source, .. } | ShapedItem::CombinedBlock { source, .. } => {
                return Some(TextCursor {
                    cluster_id: GraphemeClusterId {
                        source_run: source.run_index,
                        start_byte_in_run: source.item_index,
                    },
                    affinity: if point.x
                        < closest_item.position.x + (closest_item.item.bounds().width / 2.0)
                    {
                        CursorAffinity::Leading
                    } else {
                        CursorAffinity::Trailing
                    },
                });
            }
            _ => return None,
        };
        // Determine affinity based on which half of the cluster was clicked
5397
        let cluster_mid_x = closest_item.position.x + cluster.advance / 2.0;
5397
        let affinity = if point.x < cluster_mid_x {
1723
            CursorAffinity::Leading
        } else {
3674
            CursorAffinity::Trailing
        };
5397
        Some(TextCursor {
5397
            cluster_id: cluster.source_cluster_id,
5397
            affinity,
5397
        })
5399
    }
    /// Given a logical selection range, returns a vector of visual rectangles
    /// that cover the selected text, in the layout's coordinate space.
    #[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
733
    #[must_use] pub fn get_selection_rects(&self, range: &SelectionRange) -> Vec<LogicalRect> {
        // 1. Build a map from the logical cluster ID to the visual PositionedItem for fast lookups.
733
        let mut cluster_map: HashMap<GraphemeClusterId, &PositionedItem> = HashMap::new();
14913
        for item in &self.items {
14180
            if let Some(cluster) = item.item.as_cluster() {
14180
                cluster_map.insert(cluster.source_cluster_id, item);
14180
            }
        }
        // 2. Normalize the range to ensure start always logically precedes end.
733
        let (start_cursor, end_cursor) = if range.start.cluster_id > range.end.cluster_id
715
            || (range.start.cluster_id == range.end.cluster_id
20
                && range.start.affinity > range.end.affinity)
        {
18
            (range.end, range.start)
        } else {
715
            (range.start, range.end)
        };
        // 3. Find the positioned items corresponding to the start and end of the selection.
733
        let Some(start_item) = cluster_map.get(&start_cursor.cluster_id) else {
1
            return Vec::new();
        };
732
        let Some(end_item) = cluster_map.get(&end_cursor.cluster_id) else {
            return Vec::new();
        };
732
        let mut rects = Vec::new();
        // Helper to get the absolute visual X coordinate of a cursor. The logical
        // start (Leading) edge is the cluster's LEFT for LTR but its RIGHT for RTL;
        // Trailing is the mirror.
732
        let get_cursor_x = |item: &PositionedItem, affinity: CursorAffinity| -> f32 {
74
            let left = item.position.x;
74
            let right = item.position.x + get_item_measure(&item.item, false);
74
            let rtl = item.item.as_cluster().is_some_and(|c| c.direction.is_rtl());
74
            match (affinity, rtl) {
47
                (CursorAffinity::Leading, false) | (CursorAffinity::Trailing, true) => left,
27
                (CursorAffinity::Trailing, false) | (CursorAffinity::Leading, true) => right,
            }
74
        };
        // Helper to get the visual bounding box of all content on a specific line index.
759
        let get_line_bounds = |line_index: usize| -> Option<LogicalRect> {
14431
            let items_on_line = self.items.iter().filter(|i| i.line_index == line_index);
759
            let mut min_x: Option<f32> = None;
759
            let mut max_x: Option<f32> = None;
759
            let mut min_y: Option<f32> = None;
759
            let mut max_y: Option<f32> = None;
14902
            for item in items_on_line {
                // Skip items that don't take up space (like hard breaks)
14143
                let item_bounds = item.item.bounds();
14143
                if item_bounds.width <= 0.0 && item_bounds.height <= 0.0 {
                    continue;
14143
                }
14143
                let item_x_end = item.position.x + item_bounds.width;
14143
                let item_y_end = item.position.y + item_bounds.height;
14143
                min_x = Some(min_x.map_or(item.position.x, |mx| mx.min(item.position.x)));
14143
                max_x = Some(max_x.map_or(item_x_end, |mx| mx.max(item_x_end)));
14143
                min_y = Some(min_y.map_or(item.position.y, |my| my.min(item.position.y)));
14143
                max_y = Some(max_y.map_or(item_y_end, |my| my.max(item_y_end)));
            }
759
            if let (Some(min_x), Some(max_x), Some(min_y), Some(max_y)) =
759
                (min_x, max_x, min_y, max_y)
            {
759
                Some(LogicalRect {
759
                    origin: LogicalPosition { x: min_x, y: min_y },
759
                    size: LogicalSize {
759
                        width: max_x - min_x,
759
                        height: max_y - min_y,
759
                    },
759
                })
            } else {
                None
            }
759
        };
        // 4. Handle single-line selection.
732
        if start_item.line_index == end_item.line_index {
705
            if let Some(line_bounds) = get_line_bounds(start_item.line_index) {
                // Walk the selected clusters in VISUAL order and group them into
                // segments by bidi direction + visual contiguity, emitting one rect
                // per segment. A single endpoint-to-endpoint span over-covers (and can
                // under-cover) bidi selections, whose logically-contiguous clusters are
                // NOT visually contiguous. Pure-LTR/RTL contiguous runs collapse to a
                // single rect, matching browser/CoreText behavior.
705
                let mut segments: Vec<(f32, f32, BidiDirection)> = Vec::new();
14632
                for item in &self.items {
13927
                    if item.line_index != start_item.line_index {
                        continue;
13927
                    }
13927
                    let Some(c) = item.item.as_cluster() else {
                        continue;
                    };
13927
                    let id = c.source_cluster_id;
                    // A cluster is selected when it lies within the (affinity-aware)
                    // logical range: the start cluster is included only if the start
                    // cursor sits on its leading edge; the end cluster only if the end
                    // cursor sits on its trailing edge.
13927
                    let after_start = id > start_cursor.cluster_id
1358
                        || (id == start_cursor.cluster_id
705
                            && start_cursor.affinity == CursorAffinity::Leading);
13927
                    let before_end = id < end_cursor.cluster_id
2955
                        || (id == end_cursor.cluster_id
705
                            && end_cursor.affinity == CursorAffinity::Trailing);
13927
                    if !(after_start && before_end) {
3094
                        continue;
10833
                    }
10833
                    let x0 = item.position.x;
10833
                    let x1 = item.position.x + get_item_measure(&item.item, false);
10833
                    let (lo, hi) = (x0.min(x1), x0.max(x1));
10833
                    if let Some(last) = segments.last_mut() {
10138
                        let contiguous = lo <= last.1 + 0.5 && hi >= last.0 - 0.5;
10138
                        if last.2 == c.direction && contiguous {
10120
                            last.0 = last.0.min(lo);
10120
                            last.1 = last.1.max(hi);
10120
                            continue;
18
                        }
695
                    }
713
                    segments.push((lo, hi, c.direction));
                }
705
                if segments.is_empty() {
10
                    // No glyph-bearing clusters (e.g. zero-advance selection):
10
                    // fall back to the endpoint span so a caret-width rect still shows.
10
                    let start_x = get_cursor_x(start_item, start_cursor.affinity);
10
                    let end_x = get_cursor_x(end_item, end_cursor.affinity);
10
                    rects.push(LogicalRect {
10
                        origin: LogicalPosition {
10
                            x: start_x.min(end_x),
10
                            y: line_bounds.origin.y,
10
                        },
10
                        size: LogicalSize {
10
                            width: (end_x - start_x).abs(),
10
                            height: line_bounds.size.height,
10
                        },
10
                    });
10
                } else {
1408
                    for (lo, hi, _dir) in segments {
713
                        rects.push(LogicalRect {
713
                            origin: LogicalPosition {
713
                                x: lo,
713
                                y: line_bounds.origin.y,
713
                            },
713
                            size: LogicalSize {
713
                                width: hi - lo,
713
                                height: line_bounds.size.height,
713
                            },
713
                        });
713
                    }
                }
            }
        }
        // 5. Handle multi-line selection.
        else {
            // Rectangle for the start line (from the start cursor to the line's end
            // in READING order). For an LTR line that is rightward (to the line's
            // right content edge); for an RTL line it is leftward (to the left edge).
27
            if let Some(start_line_bounds) = get_line_bounds(start_item.line_index) {
27
                let start_x = get_cursor_x(start_item, start_cursor.affinity);
27
                let line_left = start_line_bounds.origin.x;
27
                let line_right = start_line_bounds.origin.x + start_line_bounds.size.width;
27
                let rtl = start_item.item.as_cluster().is_some_and(|c| c.direction.is_rtl());
27
                let (lo, hi) = if rtl { (line_left, start_x) } else { (start_x, line_right) };
27
                rects.push(LogicalRect {
27
                    origin: LogicalPosition {
27
                        x: lo,
27
                        y: start_line_bounds.origin.y,
27
                    },
27
                    size: LogicalSize {
27
                        width: hi - lo,
27
                        height: start_line_bounds.size.height,
27
                    },
27
                });
            }
            // Rectangles for all full lines in between.
27
            for line_idx in (start_item.line_index + 1)..end_item.line_index {
                if let Some(line_bounds) = get_line_bounds(line_idx) {
                    rects.push(line_bounds);
                }
            }
            // Rectangle for the end line (from the line's start in READING order to
            // the end cursor). For an LTR line that starts at the left content edge;
            // for an RTL line it starts at the right edge.
27
            if let Some(end_line_bounds) = get_line_bounds(end_item.line_index) {
27
                let end_x = get_cursor_x(end_item, end_cursor.affinity);
27
                let line_left = end_line_bounds.origin.x;
27
                let line_right = end_line_bounds.origin.x + end_line_bounds.size.width;
27
                let rtl = end_item.item.as_cluster().is_some_and(|c| c.direction.is_rtl());
27
                let (lo, hi) = if rtl { (end_x, line_right) } else { (line_left, end_x) };
27
                rects.push(LogicalRect {
27
                    origin: LogicalPosition {
27
                        x: lo,
27
                        y: end_line_bounds.origin.y,
27
                    },
27
                    size: LogicalSize {
27
                        width: hi - lo,
27
                        height: end_line_bounds.size.height,
27
                    },
27
                });
            }
        }
732
        rects
733
    }
    /// Calculates the visual rectangle for a cursor at a given logical position.
6604
    #[must_use] pub fn get_cursor_rect(&self, cursor: &TextCursor) -> Option<LogicalRect> {
        // Find the item and glyph corresponding to the cursor's cluster ID.
6604
        let mut last_cluster: Option<(&PositionedItem, &ShapedCluster)> = None;
263676
        for item in &self.items {
260695
            if let ShapedItem::Cluster(cluster) = &item.item {
260695
                if cluster.source_cluster_id == cursor.cluster_id {
                    // Exact match
3623
                    let line_height = item.item.bounds().height;
                    // The logical-start (Leading) caret edge is the glyph's LEFT side for
                    // an LTR cluster but its RIGHT side for an RTL cluster; Trailing is the
                    // mirror. Resolve the edges from the cluster's own bidi direction.
3623
                    let (lead_x, trail_x) = if cluster.direction.is_rtl() {
18
                        (item.position.x + cluster.advance, item.position.x)
                    } else {
3605
                        (item.position.x, item.position.x + cluster.advance)
                    };
3623
                    let cursor_x = match cursor.affinity {
3442
                        CursorAffinity::Leading => lead_x,
181
                        CursorAffinity::Trailing => trail_x,
                    };
3623
                    return Some(LogicalRect {
3623
                        origin: LogicalPosition {
3623
                            x: cursor_x,
3623
                            y: item.position.y,
3623
                        },
3623
                        size: LogicalSize {
3623
                            width: 1.0,
3623
                            height: line_height,
3623
                        },
3623
                    });
257072
                }
257072
                last_cluster = Some((item, cluster));
            }
        }
        // Cursor past end of text: position after the last cluster
2981
        if let Some((item, cluster)) = last_cluster {
2980
            if cursor.cluster_id.source_run == cluster.source_cluster_id.source_run
2979
                && cursor.cluster_id.start_byte_in_run >= cluster.source_cluster_id.start_byte_in_run
            {
2241
                let line_height = item.item.bounds().height;
                // Past the logical end of the run: the caret sits after the last cluster,
                // which is its RIGHT edge for LTR but its LEFT edge for RTL.
2241
                let past_end_x = if cluster.direction.is_rtl() {
                    item.position.x
                } else {
2241
                    item.position.x + cluster.advance
                };
2241
                return Some(LogicalRect {
2241
                    origin: LogicalPosition {
2241
                        x: past_end_x,
2241
                        y: item.position.y,
2241
                    },
2241
                    size: LogicalSize {
2241
                        width: 1.0,
2241
                        height: line_height,
2241
                    },
2241
                });
739
            }
1
        }
740
        None
6604
    }
    /// Get a cursor at the first cluster (leading edge) in the layout.
21
    #[must_use] pub fn get_first_cluster_cursor(&self) -> Option<TextCursor> {
23
        for item in &self.items {
21
            if let ShapedItem::Cluster(cluster) = &item.item {
19
                return Some(TextCursor {
19
                    cluster_id: cluster.source_cluster_id,
19
                    affinity: CursorAffinity::Leading,
19
                });
2
            }
        }
2
        None
21
    }
    /// Get a cursor at the last cluster (trailing edge) in the layout.
219
    #[must_use] pub fn get_last_cluster_cursor(&self) -> Option<TextCursor> {
219
        for item in self.items.iter().rev() {
219
            if let ShapedItem::Cluster(cluster) = &item.item {
217
                return Some(TextCursor {
217
                    cluster_id: cluster.source_cluster_id,
217
                    affinity: CursorAffinity::Trailing,
217
                });
2
            }
        }
2
        None
219
    }
    /// Logical sequence of caret-stop grapheme clusters, sorted by
    /// `(source_run, start_byte_in_run)` and de-duplicated, with combining-mark
    /// continuations folded into their base (UAX#29). Left/right caret motion
    /// advances over THIS sequence so a base and its combining marks move as one
    /// unit, and so the document start/end are always reachable.
    #[doc(hidden)] // pub for the dense-equivalence gate only
3817
    #[must_use] pub fn grapheme_stops(&self) -> Vec<GraphemeClusterId> {
3817
        let mut stops: Vec<(GraphemeClusterId, &str)> = self
3817
            .items
3817
            .iter()
144274
            .filter_map(|it| {
144274
                it.item
144274
                    .as_cluster()
144274
                    .map(|c| (c.source_cluster_id, c.text()))
144274
            })
3817
            .collect();
140461
        stops.sort_by(|a, b| {
140461
            (a.0.source_run, a.0.start_byte_in_run).cmp(&(b.0.source_run, b.0.start_byte_in_run))
140461
        });
3817
        stops.dedup_by_key(|(id, _)| *id);
3817
        stops
3817
            .into_iter()
144273
            .filter(|(_, text)| !Self::cluster_is_grapheme_continuation(text))
3817
            .map(|(id, _)| id)
3817
            .collect()
3817
    }
    /// True if `text`'s leading char is a grapheme extender (combining mark,
    /// variation selector, …) — a cluster that merges into a preceding base and
    /// therefore must not be a standalone caret stop (UAX#29).
144278
    fn cluster_is_grapheme_continuation(text: &str) -> bool {
144278
        let Some(first) = text.chars().next() else {
1
            return false;
        };
        // Probe with a dummy base letter: if `x` + first collapses to a single
        // grapheme, `first` extends the preceding grapheme.
144277
        let mut probe = String::with_capacity(1 + first.len_utf8());
144277
        probe.push('x');
144277
        probe.push(first);
144277
        probe.graphemes(true).count() == 1
144278
    }
    /// Caret offset of `cursor` within `stops` (0..=len): the index of its
    /// grapheme, plus 1 for a Trailing affinity. A cursor addressing a folded
    /// combining mark (or otherwise between stops) maps to the nearest preceding
    /// stop.
    #[doc(hidden)] // pub for the dense movement twins (stops-only logic)
11099
    #[must_use] pub fn grapheme_caret_offset(stops: &[GraphemeClusterId], cursor: &TextCursor) -> Option<usize> {
11099
        let trailing = usize::from(cursor.affinity == CursorAffinity::Trailing);
267870
        if let Some(idx) = stops.iter().position(|id| *id == cursor.cluster_id) {
11096
            return Some(idx + trailing);
3
        }
3
        let key = (cursor.cluster_id.source_run, cursor.cluster_id.start_byte_in_run);
3
        let idx = stops
3
            .iter()
3
            .rposition(|id| (id.source_run, id.start_byte_in_run) <= key)?;
1
        Some(idx + trailing)
11099
    }
    /// Canonical cursor for a grapheme-stop `offset` (0..=len): interior/first
    /// offsets are the Leading edge of the stop that begins there; `len` is the
    /// Trailing edge of the last stop (the document end).
    #[doc(hidden)] // pub for the dense movement twins (stops-only logic)
11099
    #[must_use] pub fn cursor_from_grapheme_offset(stops: &[GraphemeClusterId], offset: usize) -> TextCursor {
11099
        let n = stops.len();
11099
        if offset >= n {
563
            TextCursor { cluster_id: stops[n - 1], affinity: CursorAffinity::Trailing }
        } else {
10536
            TextCursor { cluster_id: stops[offset], affinity: CursorAffinity::Leading }
        }
11099
    }
    /// Moves a cursor one visible position to the left (the previous grapheme
    /// boundary). Affinity is consulted so each press moves exactly one stop and
    /// the document start (first grapheme, Leading) is reachable; combining marks
    /// move together with their base.
1632
    pub fn move_cursor_left(
1632
        &self,
1632
        cursor: TextCursor,
1632
        debug: &mut Option<Vec<String>>,
1632
    ) -> TextCursor {
1632
        let stops = self.grapheme_stops();
1632
        if stops.is_empty() {
1
            return cursor;
1631
        }
1631
        let Some(offset) = Self::grapheme_caret_offset(&stops, &cursor) else {
            return cursor;
        };
1631
        let moved = Self::cursor_from_grapheme_offset(&stops, offset.saturating_sub(1));
1631
        if let Some(d) = debug {
            d.push(format!(
                "[Cursor] move_cursor_left: byte {} -> byte {}",
                cursor.cluster_id.start_byte_in_run, moved.cluster_id.start_byte_in_run
            ));
1631
        }
1631
        moved
1632
    }
    /// Moves a cursor one visible position to the right (the next grapheme
    /// boundary). Affinity is consulted so each press moves exactly one stop and
    /// the document end (last grapheme, Trailing) is reachable; combining marks
    /// move together with their base.
2147
    pub fn move_cursor_right(
2147
        &self,
2147
        cursor: TextCursor,
2147
        debug: &mut Option<Vec<String>>,
2147
    ) -> TextCursor {
2147
        let stops = self.grapheme_stops();
2147
        if stops.is_empty() {
1
            return cursor;
2146
        }
2146
        let Some(offset) = Self::grapheme_caret_offset(&stops, &cursor) else {
            return cursor;
        };
2146
        let moved = Self::cursor_from_grapheme_offset(&stops, (offset + 1).min(stops.len()));
2146
        if let Some(d) = debug {
            d.push(format!(
                "[Cursor] move_cursor_right: byte {} -> byte {}",
                cursor.cluster_id.start_byte_in_run, moved.cluster_id.start_byte_in_run
            ));
2146
        }
2146
        moved
2147
    }
    /// Moves a cursor up one line, attempting to preserve the horizontal column.
2125
    pub fn move_cursor_up(
2125
        &self,
2125
        cursor: TextCursor,
2125
        goal_x: &mut Option<f32>,
2125
        debug: &mut Option<Vec<String>>,
2125
    ) -> TextCursor {
2125
        if let Some(d) = debug {
            d.push(format!(
                "[Cursor] move_cursor_up: from byte {} (affinity {:?})",
                cursor.cluster_id.start_byte_in_run, cursor.affinity
            ));
2125
        }
50590
        let Some(current_item) = self.items.iter().find(|i| {
50589
            i.item
50589
                .as_cluster()
50589
                .is_some_and(|c| c.source_cluster_id == cursor.cluster_id)
50589
        }) else {
1
            if let Some(d) = debug {
                d.push(format!(
                    "[Cursor] move_cursor_up: cursor not found in items, staying at byte {}",
                    cursor.cluster_id.start_byte_in_run
                ));
1
            }
1
            return cursor;
        };
2124
        if let Some(d) = debug {
            d.push(format!(
                "[Cursor] move_cursor_up: current line {}, position ({}, {})",
                current_item.line_index, current_item.position.x, current_item.position.y
            ));
2124
        }
2124
        let target_line_idx = current_item.line_index.saturating_sub(1);
2124
        if current_item.line_index == target_line_idx {
585
            if let Some(d) = debug {
                d.push(format!(
                    "[Cursor] move_cursor_up: already at top line {}, staying put",
                    current_item.line_index
                ));
585
            }
585
            return cursor;
1539
        }
1539
        let current_x = goal_x.unwrap_or_else(|| {
1539
            let x = match cursor.affinity {
765
                CursorAffinity::Leading => current_item.position.x,
                CursorAffinity::Trailing => {
774
                    current_item.position.x + get_item_measure(&current_item.item, false)
                }
            };
1539
            *goal_x = Some(x);
1539
            x
1539
        });
        // Find the Y coordinate of the middle of the target line
1539
        let target_y = self
1539
            .items
1539
            .iter()
29079
            .find(|i| i.line_index == target_line_idx)
1539
            .map_or(current_item.position.y, |i| i.position.y + (i.item.bounds().height / 2.0));
1539
        if let Some(d) = debug {
            d.push(format!(
                "[Cursor] move_cursor_up: target line {target_line_idx}, hittesting at ({current_x}, {target_y})"
            ));
1539
        }
1539
        let result = self
1539
            .hittest_cursor(LogicalPosition {
1539
                x: current_x,
1539
                y: target_y,
1539
            })
1539
            .unwrap_or(cursor);
1539
        if let Some(d) = debug {
            d.push(format!(
                "[Cursor] move_cursor_up: result byte {} (affinity {:?})",
                result.cluster_id.start_byte_in_run, result.affinity
            ));
1539
        }
1539
        result
2125
    }
    /// Moves a cursor down one line, attempting to preserve the horizontal column.
2134
    pub fn move_cursor_down(
2134
        &self,
2134
        cursor: TextCursor,
2134
        goal_x: &mut Option<f32>,
2134
        debug: &mut Option<Vec<String>>,
2134
    ) -> TextCursor {
2134
        if let Some(d) = debug {
            d.push(format!(
                "[Cursor] move_cursor_down: from byte {} (affinity {:?})",
                cursor.cluster_id.start_byte_in_run, cursor.affinity
            ));
2134
        }
50554
        let Some(current_item) = self.items.iter().find(|i| {
50553
            i.item
50553
                .as_cluster()
50553
                .is_some_and(|c| c.source_cluster_id == cursor.cluster_id)
50553
        }) else {
1
            if let Some(d) = debug {
                d.push(format!(
                    "[Cursor] move_cursor_down: cursor not found in items, staying at byte {}",
                    cursor.cluster_id.start_byte_in_run
                ));
1
            }
1
            return cursor;
        };
2133
        if let Some(d) = debug {
            d.push(format!(
                "[Cursor] move_cursor_down: current line {}, position ({}, {})",
                current_item.line_index, current_item.position.x, current_item.position.y
            ));
2133
        }
2133
        let max_line = self.items.iter().map(|i| i.line_index).max().unwrap_or(0);
2133
        let target_line_idx = (current_item.line_index + 1).min(max_line);
2133
        if current_item.line_index == target_line_idx {
648
            if let Some(d) = debug {
                d.push(format!(
                    "[Cursor] move_cursor_down: already at bottom line {}, staying put",
                    current_item.line_index
                ));
648
            }
648
            return cursor;
1485
        }
1485
        let current_x = goal_x.unwrap_or_else(|| {
1485
            let x = match cursor.affinity {
756
                CursorAffinity::Leading => current_item.position.x,
                CursorAffinity::Trailing => {
729
                    current_item.position.x + get_item_measure(&current_item.item, false)
                }
            };
1485
            *goal_x = Some(x);
1485
            x
1485
        });
1485
        let target_y = self
1485
            .items
1485
            .iter()
37971
            .find(|i| i.line_index == target_line_idx)
1485
            .map_or(current_item.position.y, |i| i.position.y + (i.item.bounds().height / 2.0));
1485
        if let Some(d) = debug {
            d.push(format!(
                "[Cursor] move_cursor_down: hit testing at ({current_x}, {target_y})"
            ));
1485
        }
1485
        let result = self
1485
            .hittest_cursor(LogicalPosition {
1485
                x: current_x,
1485
                y: target_y,
1485
            })
1485
            .unwrap_or(cursor);
1485
        if let Some(d) = debug {
            d.push(format!(
                "[Cursor] move_cursor_down: result byte {}, affinity {:?}",
                result.cluster_id.start_byte_in_run, result.affinity
            ));
1485
        }
1485
        result
2134
    }
    /// Moves a cursor to the visual start of its current line.
1837
    pub fn move_cursor_to_line_start(
1837
        &self,
1837
        cursor: TextCursor,
1837
        debug: &mut Option<Vec<String>>,
1837
    ) -> TextCursor {
1837
        if let Some(d) = debug {
            d.push(format!(
                "[Cursor] move_cursor_to_line_start: starting at byte {}, affinity {:?}",
                cursor.cluster_id.start_byte_in_run, cursor.affinity
            ));
1837
        }
47908
        let Some(current_item) = self.items.iter().find(|i| {
47907
            i.item
47907
                .as_cluster()
47907
                .is_some_and(|c| c.source_cluster_id == cursor.cluster_id)
47907
        }) else {
1
            if let Some(d) = debug {
                d.push(format!(
                    "[Cursor] move_cursor_to_line_start: cursor not found, staying at byte {}",
                    cursor.cluster_id.start_byte_in_run
                ));
1
            }
1
            return cursor;
        };
1836
        if let Some(d) = debug {
            d.push(format!(
                "[Cursor] move_cursor_to_line_start: current line {}, position ({}, {})",
                current_item.line_index, current_item.position.x, current_item.position.y
            ));
1836
        }
1836
        let first_item_on_line = self
1836
            .items
1836
            .iter()
94041
            .filter(|i| i.line_index == current_item.line_index)
42021
            .min_by(|a, b| {
42021
                a.position
42021
                    .x
42021
                    .partial_cmp(&b.position.x)
42021
                    .unwrap_or(Ordering::Equal)
42021
            });
1836
        if let Some(item) = first_item_on_line {
1836
            if let ShapedItem::Cluster(c) = &item.item {
1836
                let result = TextCursor {
1836
                    cluster_id: c.source_cluster_id,
1836
                    affinity: CursorAffinity::Leading,
1836
                };
1836
                if let Some(d) = debug {
                    d.push(format!(
                        "[Cursor] move_cursor_to_line_start: result byte {}, affinity {:?}",
                        result.cluster_id.start_byte_in_run, result.affinity
                    ));
1836
                }
1836
                return result;
            }
        }
        if let Some(d) = debug {
            d.push(format!(
                "[Cursor] move_cursor_to_line_start: no first item found, staying at byte {}",
                cursor.cluster_id.start_byte_in_run
            ));
        }
        cursor
1837
    }
    /// Moves a cursor to the visual end of its current line.
1837
    pub fn move_cursor_to_line_end(
1837
        &self,
1837
        cursor: TextCursor,
1837
        debug: &mut Option<Vec<String>>,
1837
    ) -> TextCursor {
1837
        if let Some(d) = debug {
            d.push(format!(
                "[Cursor] move_cursor_to_line_end: starting at byte {}, affinity {:?}",
                cursor.cluster_id.start_byte_in_run, cursor.affinity
            ));
1837
        }
47908
        let Some(current_item) = self.items.iter().find(|i| {
47907
            i.item
47907
                .as_cluster()
47907
                .is_some_and(|c| c.source_cluster_id == cursor.cluster_id)
47907
        }) else {
1
            if let Some(d) = debug {
                d.push(format!(
                    "[Cursor] move_cursor_to_line_end: cursor not found, staying at byte {}",
                    cursor.cluster_id.start_byte_in_run
                ));
1
            }
1
            return cursor;
        };
1836
        if let Some(d) = debug {
            d.push(format!(
                "[Cursor] move_cursor_to_line_end: current line {}, position ({}, {})",
                current_item.line_index, current_item.position.x, current_item.position.y
            ));
1836
        }
1836
        let last_item_on_line = self
1836
            .items
1836
            .iter()
94041
            .filter(|i| i.line_index == current_item.line_index)
42021
            .max_by(|a, b| {
42021
                a.position
42021
                    .x
42021
                    .partial_cmp(&b.position.x)
42021
                    .unwrap_or(Ordering::Equal)
42021
            });
1836
        if let Some(item) = last_item_on_line {
1836
            if let ShapedItem::Cluster(c) = &item.item {
1836
                let result = TextCursor {
1836
                    cluster_id: c.source_cluster_id,
1836
                    affinity: CursorAffinity::Trailing,
1836
                };
1836
                if let Some(d) = debug {
                    d.push(format!(
                        "[Cursor] move_cursor_to_line_end: result byte {}, affinity {:?}",
                        result.cluster_id.start_byte_in_run, result.affinity
                    ));
1836
                }
1836
                return result;
            }
        }
        if let Some(d) = debug {
            d.push(format!(
                "[Cursor] move_cursor_to_line_end: no last item found, staying at byte {}",
                cursor.cluster_id.start_byte_in_run
            ));
        }
        cursor
1837
    }
    /// Moves a cursor one word to the left (Ctrl+Left / Option+Left).
    ///
    /// Word boundaries use the shared [`is_word_char`] predicate (alphanumeric or
    /// underscore are word characters; whitespace AND punctuation are boundaries),
    /// so this agrees with double-click word selection. The cursor moves past any
    /// boundary clusters to the left, then past word clusters until the next
    /// boundary or start of text.
1846
    pub fn move_cursor_to_prev_word(
1846
        &self,
1846
        cursor: TextCursor,
1846
        debug: &mut Option<Vec<String>>,
1846
    ) -> TextCursor {
1846
        if let Some(d) = debug {
            d.push(format!(
                "[Cursor] move_cursor_to_prev_word: starting at byte {}, affinity {:?}",
                cursor.cluster_id.start_byte_in_run, cursor.affinity
            ));
1846
        }
48052
        let Some(current_pos) = self.items.iter().position(|i| {
48051
            i.item
48051
                .as_cluster()
48051
                .is_some_and(|c| c.source_cluster_id == cursor.cluster_id)
48051
        }) else {
1
            return cursor;
        };
        // Phase 1: Skip whitespace going left
1845
        let mut pos = if cursor.affinity == CursorAffinity::Leading {
            // Already at leading edge, start from previous item
927
            current_pos.checked_sub(1)
        } else {
            // At trailing edge, start from current item
918
            Some(current_pos)
        };
        // Skip boundary clusters (whitespace + punctuation)
2124
        while let Some(p) = pos {
2106
            if let Some(cluster) = self.items[p].item.as_cluster() {
2106
                if !cluster_is_word_boundary(cluster) {
1827
                    break;
279
                }
            }
279
            pos = p.checked_sub(1);
        }
        // Phase 2: Skip word clusters going left (the word itself)
13383
        while let Some(p) = pos {
13365
            if let Some(cluster) = self.items[p].item.as_cluster() {
13365
                if cluster_is_word_boundary(cluster) {
                    // We've reached a boundary before the word — stop at next cluster
1674
                    if p + 1 < self.items.len() {
1674
                        if let Some(c) = self.items[p + 1].item.as_cluster() {
1674
                            return TextCursor {
1674
                                cluster_id: c.source_cluster_id,
1674
                                affinity: CursorAffinity::Leading,
1674
                            };
                        }
                    }
                    break;
11691
                }
            }
11691
            if p == 0 {
                // Reached start of text — return first cluster
153
                if let Some(c) = self.items[0].item.as_cluster() {
153
                    return TextCursor {
153
                        cluster_id: c.source_cluster_id,
153
                        affinity: CursorAffinity::Leading,
153
                    };
                }
                break;
11538
            }
11538
            pos = p.checked_sub(1);
        }
        // If we exhausted the search, go to first cluster
18
        if pos.is_none() {
18
            if let Some(first) = self.get_first_cluster_cursor() {
18
                return first;
            }
        }
        cursor
1846
    }
    /// Moves a cursor one word to the right (Ctrl+Right / Option+Right).
    ///
    /// Word boundaries use the shared [`is_word_char`] predicate (alphanumeric or
    /// underscore are word characters; whitespace AND punctuation are boundaries),
    /// so this agrees with double-click word selection. The cursor moves past any
    /// word clusters, then past boundary clusters until the next word or end of text.
1837
    pub fn move_cursor_to_next_word(
1837
        &self,
1837
        cursor: TextCursor,
1837
        debug: &mut Option<Vec<String>>,
1837
    ) -> TextCursor {
1837
        if let Some(d) = debug {
            d.push(format!(
                "[Cursor] move_cursor_to_next_word: starting at byte {}, affinity {:?}",
                cursor.cluster_id.start_byte_in_run, cursor.affinity
            ));
1837
        }
47881
        let Some(current_pos) = self.items.iter().position(|i| {
47880
            i.item
47880
                .as_cluster()
47880
                .is_some_and(|c| c.source_cluster_id == cursor.cluster_id)
47880
        }) else {
1
            return cursor;
        };
1836
        let len = self.items.len();
        // Start position: if at leading edge, start from current; if trailing, start from next
1836
        let start = if cursor.affinity == CursorAffinity::Trailing {
909
            current_pos + 1
        } else {
927
            current_pos
        };
1836
        if start >= len {
18
            return cursor;
1818
        }
1818
        let mut pos = start;
        // Phase 1: Skip word clusters (current word)
11826
        while pos < len {
11754
            if let Some(cluster) = self.items[pos].item.as_cluster() {
11754
                if cluster_is_word_boundary(cluster) {
1746
                    break;
10008
                }
            }
10008
            pos += 1;
        }
        // Phase 2: Skip boundary clusters (whitespace + punctuation) after word
3780
        while pos < len {
3582
            if let Some(cluster) = self.items[pos].item.as_cluster() {
3582
                if !cluster_is_word_boundary(cluster) {
                    // Found start of next word
1620
                    return TextCursor {
1620
                        cluster_id: cluster.source_cluster_id,
1620
                        affinity: CursorAffinity::Leading,
1620
                    };
1962
                }
            }
1962
            pos += 1;
        }
        // Reached end of text
198
        if let Some(last) = self.get_last_cluster_cursor() {
198
            return last;
        }
        cursor
1837
    }
}
#[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
238462
fn get_baseline_for_item(item: &ShapedItem) -> Option<f32> {
238462
    match item {
        ShapedItem::CombinedBlock {
            baseline_offset, ..
        } => Some(*baseline_offset),
        ShapedItem::Object {
434
            baseline_offset, ..
434
        } => Some(*baseline_offset),
        // We have to get the clusters font from the last glyph
238024
        ShapedItem::Cluster(ref cluster) => {
238024
            cluster.glyphs.last().map(|last_glyph| last_glyph
238023
                        .font_metrics
238023
                        .baseline_scaled(cluster.style.font_size_px))
        }
2
        ShapedItem::Break { source, break_info } => {
            // Breaks do not contribute to baseline
2
            None
        }
2
        ShapedItem::Tab { source, bounds } => {
            // Tabs do not contribute to baseline
2
            None
        }
    }
238462
}
/// Stores information about content that exceeded the available layout space.
#[derive(Debug, Clone, Default)]
pub struct OverflowInfo {
    /// The items that did not fit within the constraints.
    ///
    /// Currently always empty: the positioners place every item (visual overflow
    /// is clipped at paint time) rather than dropping content, so nothing is ever
    /// recorded here. The `window.rs` incremental-patch guard reads
    /// `overflow_items.is_empty()` to stay future-proof against a positioning path
    /// that *does* drop items. TODO(superplan): populate this if such a path lands.
    pub overflow_items: Vec<ShapedItem>,
    /// The total bounds of all positioned content, including any that overflows
    /// the constraints. Populated by both positioners (greedy + Knuth-Plass) from
    /// [`UnifiedLayout::bounds`]; useful for `OverflowBehavior::Visible`/`Scroll`.
    pub unclipped_bounds: Rect,
}
impl OverflowInfo {
2
    #[must_use] pub const fn has_overflow(&self) -> bool {
2
        !self.overflow_items.is_empty()
2
    }
}
/// Intermediate structure carrying information from the line breaker to the positioner.
#[derive(Debug, Clone)]
pub struct UnifiedLine {
    pub items: Vec<ShapedItem>,
    /// The y-position (for horizontal) or x-position (for vertical) of the line's baseline.
    pub cross_axis_position: f32,
    /// The geometric segments this line must fit into.
    pub constraints: LineConstraints,
    pub is_last: bool,
}
// --- Caching Infrastructure ---
pub type CacheId = u64;
/// Defines a single area for layout, with its own shape and properties.
#[derive(Debug, Clone)]
pub struct LayoutFragment {
    /// A unique identifier for this fragment (e.g., "main-content", "sidebar").
    pub id: String,
    /// The geometric and style constraints for this specific fragment.
    pub constraints: UnifiedConstraints,
}
/// Represents the final layout distributed across multiple fragments.
#[derive(Debug, Clone)]
pub struct FlowLayout {
    /// A map from a fragment's unique ID to the layout it contains.
    pub fragment_layouts: HashMap<String, Arc<UnifiedLayout>>,
    /// Any items that did not fit into the last fragment in the flow chain.
    /// This is useful for pagination or determining if more layout space is needed.
    pub remaining_items: Vec<ShapedItem>,
}
/// Inline-axis intrinsic contributions derived from shaped text, without running
/// the line-breaking stage of the pipeline.
///
/// Callers that only need min/max-content widths for sizing (see
/// `calculate_ifc_root_intrinsic_sizes`) should prefer this over invoking
/// `layout_flow` twice with `AvailableSpace::MinContent`/`MaxContent`. The
/// latter runs the full flow loop — including `BreakCursor::peek_next_unit`,
/// which clones every `ShapedCluster` it inspects — even though no constraint
/// actually limits the line width.
#[derive(Copy, Debug, Clone, Default)]
pub struct IntrinsicTextSizes {
    /// CSS min-content = widest unbreakable unit (word) along the inline axis.
    pub min_content_width: f32,
    /// CSS max-content = sum of all advances along the inline axis (single line).
    pub max_content_width: f32,
    /// Height of a single line box: max(ascent + descent) across all items.
    pub max_content_height: f32,
}
/// Cached line break boundaries from a previous layout pass.
///
/// Enables incremental relayout: when a word changes width,
/// we can check if it still fits on the same line without
/// re-running the full line-breaking algorithm.
#[derive(Clone, Debug)]
pub struct CachedLineBreaks {
    /// Per-line: (`first_item_idx`, `last_item_idx_exclusive`) into positioned items.
    pub line_ranges: Vec<(usize, usize)>,
    /// Per-line total width (sum of item advances on that line).
    pub line_widths: Vec<f32>,
    /// The available width constraint used when these breaks were computed.
    pub available_width: f32,
}
/// Result of an incremental relayout attempt.
#[derive(Copy, Clone, Debug)]
pub enum IncrementalRelayoutResult {
    /// Glyphs changed but advance widths identical — swap in place, no repositioning.
    GlyphSwap,
    /// Width changed but still fits on same line — shift `x_offsets` of subsequent items.
    LineShift {
        /// Index of the first affected item.
        affected_item: usize,
        /// Width delta (`new_advance` - `old_advance`).
        delta: f32,
    },
    /// Line breaks changed — need to reflow from this line onward.
    PartialReflow {
        /// The line index from which to start reflowing.
        reflow_from_line: usize,
    },
    /// Cannot do incremental — fall back to full relayout.
    FullRelayout,
}
/// Extract line break boundaries from a positioned items list.
150457
#[must_use] pub fn extract_line_breaks(
150457
    items: &[PositionedItem],
150457
    available_width: f32,
150457
) -> CachedLineBreaks {
150457
    let mut line_ranges = Vec::new();
150457
    let mut line_widths = Vec::new();
150457
    if items.is_empty() {
31
        return CachedLineBreaks { line_ranges, line_widths, available_width };
150426
    }
150426
    let mut line_start = 0usize;
150426
    let mut current_line = items[0].line_index;
150426
    let mut line_width = 0.0f32;
2220868
    for (i, item) in items.iter().enumerate() {
2220868
        if item.line_index != current_line {
23522
            line_ranges.push((line_start, i));
23522
            line_widths.push(line_width);
23522
            line_start = i;
23522
            current_line = item.line_index;
23522
            line_width = 0.0;
2197346
        }
2220868
        line_width += get_item_measure(&item.item, false);
    }
    // Final line
150426
    line_ranges.push((line_start, items.len()));
150426
    line_widths.push(line_width);
150426
    CachedLineBreaks { line_ranges, line_widths, available_width }
150457
}
/// Attempt incremental relayout given old metrics and new per-item advance widths.
///
/// `dirty_item_indices`: which items in the shaped list changed.
/// `old_advances`: per-item advance widths from the previous layout.
/// `new_advances`: per-item advance widths after reshaping.
/// `line_breaks`: cached line boundaries from previous layout.
90001
#[must_use] pub fn try_incremental_relayout(
90001
    dirty_item_indices: &[usize],
90001
    old_advances: &[f32],
90001
    new_advances: &[f32],
90001
    line_breaks: &CachedLineBreaks,
90001
) -> IncrementalRelayoutResult {
90001
    if dirty_item_indices.is_empty() {
89993
        return IncrementalRelayoutResult::GlyphSwap;
8
    }
    // Check each dirty item
9
    for &dirty_idx in dirty_item_indices {
8
        if dirty_idx >= old_advances.len() || dirty_idx >= new_advances.len() {
3
            return IncrementalRelayoutResult::FullRelayout;
5
        }
5
        let old_adv = old_advances[dirty_idx];
5
        let new_adv = new_advances[dirty_idx];
5
        let delta = new_adv - old_adv;
5
        if delta.abs() < 0.001 {
            // Same width — just swap glyphs (GlyphSwap for this item)
1
            continue;
4
        }
        // Width changed — find which line this item is on
4
        let line_idx = line_breaks.line_ranges.iter()
5
            .position(|&(start, end)| dirty_idx >= start && dirty_idx < end);
4
        let Some(line_idx) = line_idx else {
1
            return IncrementalRelayoutResult::FullRelayout;
        };
3
        let old_line_width = line_breaks.line_widths[line_idx];
3
        let new_line_width = old_line_width + delta;
3
        if new_line_width <= line_breaks.available_width {
            // Still fits on same line — shift subsequent items
1
            return IncrementalRelayoutResult::LineShift {
1
                affected_item: dirty_idx,
1
                delta,
1
            };
2
        }
        // Overflows line — need to reflow from this line
2
        return IncrementalRelayoutResult::PartialReflow {
2
            reflow_from_line: line_idx,
2
        };
    }
    // All dirty items had same width
1
    IncrementalRelayoutResult::GlyphSwap
90001
}
/// (d7, segmented rework) The compact stored form of a per-item shaped
/// entry. A coalesce GROUP spans multiple logical items, so amortized
/// fields change mid-entry — the first single-header design atomized
/// every cluster after the first text-Arc change and retained ~300
/// B/cluster on the real corpus (measured 9.1 MiB; the whole point
/// missed). Headers are now per-SEGMENT (the `DenseRun` pattern): a new
/// segment starts whenever any amortized field changes; clusters
/// compact to 16 B within their segment; glyph irregularities go to
/// the shared detail tables; only non-cluster items and genuinely
/// irregular clusters (multi-font glyphs, markers, cleared fragment
/// flags) stay verbatim in `atoms`. `expand()` reproduces the input
/// EXACTLY (the d7 roundtrip gate pins it, including a multi-segment
/// case); the per-hit re-stamp then runs unchanged.
#[derive(Debug)]
pub(crate) struct CompactSegment {
    style: Arc<StyleProperties>,
    source_text: Arc<str>,
    font_hash: u64,
    font_metrics: LayoutFontMetrics,
    script: Script,
    direction: BidiDirection,
    source_run: u32,
    source_node: Option<NodeId>,
    /// The segment's `source_content_index.item_index` VERBATIM: at the
    /// shaping stage it is constant per item-fragment (the split-trace
    /// showed the linear `item_base` model drifting on EVERY cluster —
    /// 31k segments; post-layout dense uses the linear model, this
    /// stage does not).
    item_index: u32,
    /// Range into the entry-wide `clusters` array.
    clusters: core::ops::Range<u32>,
}
#[derive(Debug)]
pub(crate) struct CompactShapedEntry {
    segments: Vec<CompactSegment>,
    clusters: Vec<super::dense::ClusterCompact>,
    details: Vec<super::dense::ClusterDetail>,
    detail_glyphs: Vec<super::dense::DetailGlyph>,
    /// (`expanded_index`, verbatim item) — non-clusters and irregulars.
    atoms: Vec<(u32, ShapedItem)>,
    /// Expanded sequence length.
    total: u32,
}
impl CompactShapedEntry {
    /// Compact `items`. Total: every item lands in a segment's compact
    /// arrays or verbatim in `atoms`; `expand()` is exact either way.
45684
    pub(crate) fn build(items: &[ShapedItem]) -> Self {
        use super::dense::{ClusterCompact, ClusterDetail, DetailGlyph};
45684
        let mut out = Self {
45684
            segments: Vec::new(),
45684
            clusters: Vec::new(),
45684
            details: Vec::new(),
45684
            detail_glyphs: Vec::new(),
45684
            atoms: Vec::new(),
45684
            total: u32::try_from(items.len()).unwrap_or(u32::MAX),
45684
        };
683641
        for (i, item) in items.iter().enumerate() {
683641
            let idx = u32::try_from(i).unwrap_or(u32::MAX);
683641
            let ShapedItem::Cluster(c) = item else {
1
                out.atoms.push((idx, item.clone()));
1
                continue;
            };
683640
            let first_glyph = c.glyphs.first();
683640
            let font_hash = first_glyph.map_or(0, |g| g.font_hash);
683640
            let font_metrics = first_glyph.map_or(
683640
                LayoutFontMetrics {
683640
                    ascent: 0.0,
683640
                    descent: 0.0,
683640
                    cap_height: None,
683640
                    x_height: None,
683640
                    line_gap: 0.0,
683640
                    units_per_em: 0,
683640
                },
                |g| g.font_metrics,
            );
683640
            let script = first_glyph.map_or(Script::Latin, |g| g.script);
683640
            let item_index = c.source_content_index.item_index;
            // Irregular clusters stay verbatim: mixed fonts WITHIN one
            // cluster, markers, or fragment flags off their shaping-
            // stage default (true, true) — the line breaker owns those.
683640
            let irregular = c.marker_position_outside.is_some()
683243
                || !c.is_first_fragment
683242
                || !c.is_last_fragment
                // font_hash IS the font identity (metrics are derived
                // from it); comparing LayoutFontMetrics by PartialEq
                // split a segment on EVERY cluster when a metric was
                // NaN (NaN != NaN) — 31,174 segments for 31k clusters.
683243
                || c.glyphs.iter().any(|g| {
683243
                    g.font_hash != font_hash || g.script != script
683243
                })
683242
                || (!Self::needs_detail(c)
672262
                    && Self::grapheme_len_at(
672262
                        &c.source_text,
672262
                        c.source_cluster_id.start_byte_in_run,
672262
                    ) != Some(usize::from(c.source_byte_len)));
683640
            if irregular {
1386
                out.atoms.push((idx, item.clone()));
1386
                continue;
682254
            }
            // Segment split on any amortized-field change.
682254
            let ci = u32::try_from(out.clusters.len()).unwrap_or(u32::MAX);
682254
            let fits = out.segments.last().is_some_and(|seg| {
636575
                Arc::ptr_eq(&seg.style, &c.style)
636066
                    && Arc::ptr_eq(&seg.source_text, &c.source_text)
635794
                    && seg.font_hash == font_hash
635785
                    && seg.script == script
635785
                    && seg.direction == c.direction
635785
                    && seg.source_run == c.source_cluster_id.source_run
635785
                    && seg.source_node == c.source_node_id
635785
                    && seg.item_index == item_index
                    // Segments must stay contiguous in the cluster
                    // array; an intervening atom ends the segment.
635785
                    && seg.clusters.end == ci
636575
            });
682254
            if fits {
635785
                if let Some(seg) = out.segments.last_mut() {
635785
                    seg.clusters.end = ci + 1;
635785
                }
46469
            } else {
46469
                out.segments.push(CompactSegment {
46469
                    style: c.style.clone(),
46469
                    source_text: c.source_text.clone(),
46469
                    font_hash,
46469
                    font_metrics,
46469
                    script,
46469
                    direction: c.direction,
46469
                    source_run: c.source_cluster_id.source_run,
46469
                    source_node: c.source_node_id,
46469
                    item_index,
46469
                    // `ci..ci + 1`, NOT `ci..=ci`: the field is a half-open
46469
                    // `Range<u32>`, so clippy::range_plus_one's rewrite does
46469
                    // not typecheck. The allow keeps `--fix` from re-breaking it.
46469
                    #[allow(clippy::range_plus_one)]
46469
                    clusters: ci..ci + 1,
46469
                });
46469
            }
682254
            if Self::needs_detail(c) {
10980
                let start = u32::try_from(out.detail_glyphs.len()).unwrap_or(u32::MAX);
21961
                for g in &c.glyphs {
10981
                    out.detail_glyphs.push(DetailGlyph {
10981
                        glyph_id: g.glyph_id,
10981
                        cluster_offset: u16::try_from(g.cluster_offset).unwrap_or(u16::MAX),
10981
                        advance: g.advance + g.kerning,
10981
                        offset_x: g.offset.x,
10981
                        offset_y: g.offset.y,
10981
                        kerning: g.kerning,
10981
                        kind: g.kind,
10981
                        vertical_advance: g.vertical_advance,
10981
                        vertical_offset_x: g.vertical_offset.x,
10981
                        vertical_offset_y: g.vertical_offset.y,
10981
                    });
10981
                }
10980
                let end = u32::try_from(out.detail_glyphs.len()).unwrap_or(u32::MAX);
10980
                out.details.push(ClusterDetail {
10980
                    cluster: ci,
10980
                    glyphs: (start, end),
10980
                    byte_len: u32::from(c.source_byte_len),
10980
                });
671274
            }
682254
            out.clusters.push(ClusterCompact {
682254
                glyph_id: first_glyph.map_or(0, |g| g.glyph_id),
682254
                flags: c.flags,
682254
                advance: c.advance,
682254
                start_byte: c.source_cluster_id.start_byte_in_run,
                x: 0.0,
            });
        }
45684
        out
45684
    }
1365496
    fn needs_detail(c: &ShapedCluster) -> bool {
        // Vertical metrics come from the font's vmtx table — per-glyph
        // and nonzero for CJK-class fonts even in horizontal text — so
        // they route to the DETAIL table rather than atomizing.
1365496
        c.glyphs.len() != 1
1365494
            || c.glyphs.first().is_some_and(|g| {
1365494
                g.offset.x != 0.0
1365494
                    || g.offset.y != 0.0
1365494
                    || g.kerning != 0.0
1343756
                    || g.kind != GlyphKind::Character
1343756
                    || g.vertical_advance != 0.0
1343536
                    || g.vertical_offset.x != 0.0
1343536
                    || g.vertical_offset.y != 0.0
1365494
            })
1365496
    }
3370071
    fn grapheme_len_at(text: &str, start: u32) -> Option<usize> {
        use unicode_segmentation::UnicodeSegmentation;
3370071
        text.get(start as usize..)
3370071
            .and_then(|s| s.graphemes(true).next())
3370071
            .map(str::len)
3370071
    }
    /// Exact reconstruction of the original item vec.
191293
    pub(crate) fn expand(&self) -> Vec<ShapedItem> {
191293
        let mut out = Vec::with_capacity(self.total as usize);
191293
        let mut atom_cursor = 0usize;
191293
        let mut detail_cursor = 0usize;
191293
        let mut seg_cursor = 0usize;
191293
        let mut ci = 0u32;
2743636
        for idx in 0..self.total {
2743636
            if let Some((ai, item)) = self.atoms.get(atom_cursor) {
20861
                if *ai == idx {
2356
                    out.push(item.clone());
2356
                    atom_cursor += 1;
2356
                    continue;
18505
                }
2722775
            }
2742202
            while seg_cursor < self.segments.len()
2742202
                && self.segments[seg_cursor].clusters.end <= ci
922
            {
922
                seg_cursor += 1;
922
            }
2741280
            let seg = &self.segments[seg_cursor];
2741280
            let c = &self.clusters[ci as usize];
2784723
            while detail_cursor < self.details.len()
358166
                && self.details[detail_cursor].cluster < ci
43443
            {
43443
                detail_cursor += 1;
43443
            }
2741280
            let detail = self.details.get(detail_cursor).filter(|d| d.cluster == ci);
2741280
            let glyphs: ShapedGlyphVec = match detail {
43471
                Some(d) => (d.glyphs.0..d.glyphs.1)
43472
                    .map(|gi| {
43472
                        let dg = &self.detail_glyphs[gi as usize];
43472
                        ShapedGlyph {
43472
                            kind: dg.kind,
43472
                            glyph_id: dg.glyph_id,
43472
                            cluster_offset: u32::from(dg.cluster_offset),
43472
                            advance: dg.advance - dg.kerning,
43472
                            kerning: dg.kerning,
43472
                            offset: Point { x: dg.offset_x, y: dg.offset_y },
43472
                            vertical_advance: dg.vertical_advance,
43472
                            vertical_offset: Point {
43472
                                x: dg.vertical_offset_x,
43472
                                y: dg.vertical_offset_y,
43472
                            },
43472
                            script: seg.script,
43472
                            font_hash: seg.font_hash,
43472
                            font_metrics: seg.font_metrics,
43472
                        }
43472
                    })
43471
                    .collect(),
2697809
                None => core::iter::once(ShapedGlyph {
2697809
                    kind: GlyphKind::Character,
2697809
                    glyph_id: c.glyph_id,
2697809
                    cluster_offset: 0,
2697809
                    advance: c.advance,
2697809
                    kerning: 0.0,
2697809
                    offset: Point { x: 0.0, y: 0.0 },
2697809
                    vertical_advance: 0.0,
2697809
                    vertical_offset: Point { x: 0.0, y: 0.0 },
2697809
                    script: seg.script,
2697809
                    font_hash: seg.font_hash,
2697809
                    font_metrics: seg.font_metrics,
2697809
                })
2697809
                .collect(),
            };
2741280
            let byte_len = detail.map_or_else(
2697809
                || {
2697809
                    Self::grapheme_len_at(&seg.source_text, c.start_byte).unwrap_or(0) as u32
2697809
                },
                |d| d.byte_len,
            );
2741280
            out.push(ShapedItem::Cluster(ShapedCluster {
2741280
                source_text: seg.source_text.clone(),
2741280
                source_byte_len: u16::try_from(byte_len).unwrap_or(u16::MAX),
2741280
                source_cluster_id: GraphemeClusterId {
2741280
                    source_run: seg.source_run,
2741280
                    start_byte_in_run: c.start_byte,
2741280
                },
2741280
                source_content_index: ContentIndex {
2741280
                    run_index: seg.source_run,
2741280
                    item_index: seg.item_index,
2741280
                },
2741280
                source_node_id: seg.source_node,
2741280
                glyphs,
2741280
                flags: c.flags,
2741280
                advance: c.advance,
2741280
                direction: seg.direction,
2741280
                style: seg.style.clone(),
2741280
                marker_position_outside: None,
2741280
                is_first_fragment: true,
2741280
                is_last_fragment: true,
2741280
            }));
2741280
            ci += 1;
        }
191293
        out
191293
    }
    /// Approximate retained bytes, for the memory report.
    pub(crate) const fn retained_bytes(&self) -> usize {
        use core::mem::size_of;
        self.segments.capacity() * size_of::<CompactSegment>()
            + self.clusters.capacity() * size_of::<super::dense::ClusterCompact>()
            + self.details.capacity() * size_of::<super::dense::ClusterDetail>()
            + self.detail_glyphs.capacity() * size_of::<super::dense::DetailGlyph>()
            + self.atoms.capacity() * (size_of::<(u32, ShapedItem)>())
    }
    #[cfg(test)]
2
    pub(crate) fn atom_count(&self) -> usize {
2
        self.atoms.len()
2
    }
    #[cfg(test)]
2
    pub(crate) fn segment_count(&self) -> usize {
2
        self.segments.len()
2
    }
}
/// Cached shaped result for a single visual item (or coalesced group).
/// Enables per-item cache hits when only one word changes in a paragraph.
/// (d7) Stores the COMPACT form; `expand()` materializes on hit, where
/// the old form cloned every item anyway.
#[derive(Debug)]
pub(crate) struct PerItemShapedEntry {
    /// The compacted shaped clusters for this single item/group.
    pub(crate) compact: CompactShapedEntry,
    /// Sum of advance widths — for fast same-width detection during incremental relayout.
    pub(crate) total_advance: f32,
}
#[derive(Debug)]
pub struct TextShapingCache {
    // Stage 1 Cache: InlineContent -> LogicalItems
    logical_items: HashMap<CacheId, Arc<Vec<LogicalItem>>>,
    // Stage 2 Cache: LogicalItems -> VisualItems
    visual_items: HashMap<CacheId, Arc<Vec<VisualItem>>>,
    // (d7) The monolithic Stage-3 cache (VisualItems -> ShapedItems) is
    // DELETED: it duplicated every cluster held by `per_item_shaped`
    // (6.1 MB on the 960-line corpus at fat-struct sizes), text edits
    // always missed it (the key hashes the text), and post-R1/R2 resize
    // rarely re-enters layout at all. Assembly from the per-item cache
    // runs each pass; the per-item entries are the single cache copy.
    // Stage 3b Cache: Per-item/coalesce-group shaped results
    // Key: hash(text, bidi_level, script, style.layout_hash())
    per_item_shaped: HashMap<u64, Arc<PerItemShapedEntry>>,
    /// Tracks which `per_item_shaped` keys were accessed in the current generation.
    per_item_accessed: HashSet<u64>,
    /// Same, for the three STAGE caches above.
    ///
    /// Those three had no cap and no sweep of any kind: every distinct piece
    /// of text ever laid out stayed in them for the life of the process, so
    /// they grew monotonically with editing. Measured on one 960-line
    /// markdown: 792 entries each, 7.0 MB (logical 396 KiB, visual 519 KiB,
    /// shaped 6140 KiB) — on top of the 13.5 MB of the SAME shaped text
    /// already retained per layout node as `warm.inline`.
    stage_accessed: HashSet<CacheId>,
    /// Current generation counter, incremented each layout pass.
    generation: u64,
}
/// Bytes an `Arc` allocation adds in front of its payload: strong + weak
/// refcount.
const ARC_HEADER: usize = 2 * size_of::<usize>();
/// Approximate heap bytes of a `HashMap`'s own table.
///
/// hashbrown sizes for `entries / 0.875` buckets rounded up to a power of two,
/// each holding a `(K, V)` pair plus one control byte. An ESTIMATE, labelled as
/// such where it is printed — but far closer than the zero charged before.
15
const fn hashmap_bytes<K, V>(entries: usize) -> usize {
15
    if entries == 0 {
12
        return 0;
3
    }
3
    let buckets = ((entries * 8) / 7).next_power_of_two();
3
    buckets * (size_of::<K>() + size_of::<V>() + 1)
15
}
/// Approximate heap bytes retained by a [`TextShapingCache`].
#[derive(Copy, Debug, Clone, Default)]
pub struct TextCacheMemoryReport {
    pub logical_items_entries: usize,
    pub logical_items_bytes: usize,
    pub visual_items_entries: usize,
    pub visual_items_bytes: usize,
    pub shaped_items_entries: usize,
    pub shaped_items_bytes: usize,
    pub shaped_glyph_bytes: usize,
    pub shaped_cluster_text_bytes: usize,
    pub per_item_shaped_entries: usize,
    pub per_item_shaped_bytes: usize,
    pub per_item_atoms: usize,
    pub per_item_segments: usize,
    pub per_item_detail_glyphs: usize,
    /// `HashMap` table allocations plus one `Arc` header per entry.
    ///
    /// Previously uncounted, which is why the itemised lines did not add up
    /// to the total anyone read off this report: on a 960-line document the
    /// four maps hold ~3 000 entries and the gap was ~3.4 MB.
    pub map_overhead_bytes: usize,
    /// Distinct `Arc<StyleProperties>` reachable from cached clusters, and
    /// the bytes they hold. Counted once per allocation, not per referent —
    /// the whole point of the `Arc` is that glyphs share it.
    pub distinct_style_arcs: usize,
    pub style_arc_bytes: usize,
    /// Glyphs in `ShapedItem::CombinedBlock` (tate-chu-yoko). Zero on Latin;
    /// non-zero on vertical CJK, where the old walk silently skipped them
    /// because it only matched the `Cluster` arm.
    pub combined_block_glyph_bytes: usize,
    /// Bytes the NAIVE per-key walk would have charged twice, because several
    /// cache keys point at one shared `Arc`. Not part of any total — this is
    /// the size of the error that per-key counting used to make, kept in the
    /// output so the correction is auditable rather than invisible.
    pub shared_bytes_avoided: usize,
    /// Cluster count, so a reader can derive bytes-per-cluster without
    /// having to find it in another section of the report.
    pub cluster_count: usize,
}
impl TextCacheMemoryReport {
5
    #[must_use] pub const fn total_bytes(&self) -> usize {
5
        self.logical_items_bytes
5
            + self.visual_items_bytes
5
            + self.shaped_items_bytes
5
            + self.shaped_glyph_bytes
5
            + self.shaped_cluster_text_bytes
5
            + self.per_item_shaped_bytes
5
            + self.map_overhead_bytes
5
            + self.style_arc_bytes
5
            + self.combined_block_glyph_bytes
5
    }
    /// Bytes per shaped cluster, the figure worth comparing against other
    /// engines. `None` when nothing is cached.
    #[must_use]
2
    pub const fn bytes_per_cluster(&self) -> Option<usize> {
2
        if self.cluster_count == 0 {
1
            None
        } else {
1
            Some(self.total_bytes() / self.cluster_count)
        }
2
    }
}
impl TextShapingCache {
7541
    #[must_use] pub fn new() -> Self {
7541
        Self {
7541
            logical_items: HashMap::new(),
7541
            visual_items: HashMap::new(),
7541
            per_item_shaped: HashMap::new(),
7541
            per_item_accessed: HashSet::new(),
7541
            stage_accessed: HashSet::new(),
7541
            generation: 0,
7541
        }
7541
    }
    /// #28: fork this cache for a SPECULATIVE layout query (`LayoutWindow::
    /// query_pagination`). The three stage maps hold `Arc`'d entries keyed by
    /// CONTENT (the per-item key hashes text/bidi/script/style — never a
    /// width), so cloning the maps is refcount bumps only: the fork re-shapes
    /// nothing that the live cache already shaped. Entries the query adds
    /// (its own constraint's line breaks, new memoizations) land in the fork
    /// and die with it — the window's cache is never polluted with
    /// query-constraint entries, which is why this takes `&self` and works
    /// from read-only callback contexts.
    #[must_use] pub fn fork_shared(&self) -> Self {
        Self {
            logical_items: self.logical_items.clone(),
            visual_items: self.visual_items.clone(),
            per_item_shaped: self.per_item_shaped.clone(),
            per_item_accessed: HashSet::new(),
            stage_accessed: HashSet::new(),
            generation: self.generation,
        }
    }
    /// Test/pin hook (#28): whether `key` maps to the SAME allocation as in
    /// `other` — proves a fork shares (not copies) a shaped entry.
    #[must_use] pub fn per_item_entry_ptr_eq(&self, other: &Self, key: u64) -> bool {
        match (self.per_item_shaped.get(&key), other.per_item_shaped.get(&key)) {
            (Some(a), Some(b)) => Arc::ptr_eq(a, b),
            _ => false,
        }
    }
    /// Test/pin hook (#28): the per-item keys currently cached.
    #[must_use] pub fn per_item_keys(&self) -> Vec<u64> {
        self.per_item_shaped.keys().copied().collect()
    }
    /// Approximate per-stage heap-byte breakdown.
    #[allow(clippy::field_reassign_with_default)] // struct built incrementally / test setup; a struct literal is not clearer here
5
    #[must_use] pub fn memory_report(&self) -> TextCacheMemoryReport {
5
        let mut r = TextCacheMemoryReport::default();
        // COUNT EACH ALLOCATION ONCE, NOT EACH KEY.
        //
        // All four stage maps are `HashMap<_, Arc<..>>`, and sharing one `Arc`
        // between several keys is the entire point of the cache. Charging
        // `capacity()` per VALUE therefore charged shared allocations once per
        // key that referenced them, and the report claimed bytes that do not
        // exist in the process. `shared_bytes_avoided` records what the naive
        // walk would have double-charged, so the correction is visible in the
        // output instead of appearing as an unexplained drop.
5
        let mut counted: BTreeSet<usize> =
5
            BTreeSet::new();
5
        r.logical_items_entries = self.logical_items.len();
8
        for arc in self.logical_items.values() {
8
            let bytes = arc.capacity() * size_of::<LogicalItem>();
8
            if counted.insert(Arc::as_ptr(arc).cast::<u8>() as usize) {
4
                r.logical_items_bytes += bytes;
4
            } else {
4
                r.shared_bytes_avoided += bytes;
4
            }
        }
5
        r.visual_items_entries = self.visual_items.len();
5
        for arc in self.visual_items.values() {
            let bytes = arc.capacity() * size_of::<VisualItem>();
            if counted.insert(Arc::as_ptr(arc).cast::<u8>() as usize) {
                r.visual_items_bytes += bytes;
            } else {
                r.shared_bytes_avoided += bytes;
            }
        }
5
        let mut text_arcs: BTreeSet<usize> =
5
            BTreeSet::new();
5
        let mut style_arcs: BTreeSet<usize> =
5
            BTreeSet::new();
        // ONE glyph lives INLINE in the cluster's `SmallVec<[ShapedGlyph; 1]>`
        // and is already inside the `size_of::<ShapedItem>()` charged above.
        // Counting full `capacity()` charged it twice — on Latin text, where
        // every cluster has exactly one glyph, that DOUBLED the reported glyph
        // bytes. Fixed in `solver3/layout_tree.rs:952` (d41a15dbe); never
        // applied here until now.
        fn glyph_spill_bytes(c: &ShapedCluster) -> usize {
            c.glyphs.capacity().saturating_sub(1) * size_of::<ShapedGlyph>()
        }
        // (d7) The monolithic stage-3 map is DELETED; these fields stay
        // in the report as tombstones (0) so historical dumps compare.
5
        r.shaped_items_entries = 0;
        // (the walk below is the single shaped-store walk now)
5
        r.per_item_shaped_entries = self.per_item_shaped.len();
5
        for arc in self.per_item_shaped.values() {
            if !counted.insert(Arc::as_ptr(arc).cast::<u8>() as usize) {
                r.shared_bytes_avoided += arc.compact.retained_bytes();
                continue;
            }
            // (d7) The compact arrays, plus each segment's shared source
            // text once.
            r.per_item_shaped_bytes += arc.compact.retained_bytes();
            r.per_item_atoms += arc.compact.atoms.len();
            r.per_item_segments += arc.compact.segments.len();
            r.per_item_detail_glyphs += arc.compact.detail_glyphs.len();
            r.cluster_count += arc.compact.clusters.len();
            for seg in &arc.compact.segments {
                if text_arcs.insert(Arc::as_ptr(&seg.source_text).cast::<u8>() as usize) {
                    r.per_item_shaped_bytes += seg.source_text.len();
                }
                style_arcs.insert(Arc::as_ptr(&seg.style) as usize);
            }
            for (_, item) in &arc.compact.atoms {
                match item {
                    ShapedItem::Cluster(c) => {
                        r.per_item_shaped_bytes += glyph_spill_bytes(c);
                        // 3c: shared Arc slice — count each source buffer once.
                        if text_arcs.insert(Arc::as_ptr(&c.source_text).cast::<u8>() as usize) {
                            r.per_item_shaped_bytes += c.source_text.len();
                        }
                        r.cluster_count += 1;
                        style_arcs.insert(Arc::as_ptr(&c.style) as usize);
                    }
                    ShapedItem::CombinedBlock { glyphs, style, .. } => {
                        r.combined_block_glyph_bytes +=
                            glyphs.capacity() * size_of::<ShapedGlyph>();
                        style_arcs.insert(Arc::as_ptr(style) as usize);
                    }
                    // No heap beyond the `size_of::<ShapedItem>()` already
                    // charged for the slot. Listed explicitly rather than
                    // caught by a wildcard so that adding a heap-owning arm
                    // later fails to compile instead of silently going
                    // uncounted — which is exactly how `CombinedBlock` was
                    // missed by the old `if let Cluster`.
                    ShapedItem::Object { .. }
                    | ShapedItem::Tab { .. }
                    | ShapedItem::Break { .. } => {}
                }
            }
        }
5
        r.distinct_style_arcs = style_arcs.len();
5
        r.style_arc_bytes = style_arcs.len() * (size_of::<StyleProperties>() + ARC_HEADER);
        // The maps themselves. Every line above measures what an entry POINTS
        // AT; none measured the table holding the pointers or the `Arc` header
        // in front of each payload. That is why the itemised lines never summed
        // to the printed total — a ~3.4 MB gap on a 960-line document.
5
        r.map_overhead_bytes = hashmap_bytes::<CacheId, Arc<Vec<LogicalItem>>>(
5
            self.logical_items.len(),
5
        ) + hashmap_bytes::<CacheId, Arc<Vec<VisualItem>>>(self.visual_items.len())
5
            + hashmap_bytes::<u64, Arc<PerItemShapedEntry>>(self.per_item_shaped.len())
5
            + ARC_HEADER
5
                * (self.logical_items.len()
5
                    + self.visual_items.len()
5
                    + self.per_item_shaped.len());
5
        r
5
    }
    /// Call at the start of each layout pass. Evicts per-item shaped entries
    /// not accessed in the previous generation to prevent unbounded growth.
11
    pub fn begin_generation(&mut self) {
11
        if self.generation > 0 && !self.per_item_accessed.is_empty() {
            // Evict entries not accessed in this generation
1
            let accessed = &self.per_item_accessed;
2
            self.per_item_shaped.retain(|k, _| accessed.contains(k));
10
        }
        // The three STAGE caches get the same policy. They had none at all,
        // so text that is edited away was never released — the entry for the
        // old wording stayed shaped and resident forever. Same rule as
        // per-item: anything touched this generation survives.
11
        if self.generation > 0 && !self.stage_accessed.is_empty() {
1
            let accessed = &self.stage_accessed;
2
            self.logical_items.retain(|k, _| accessed.contains(k));
2
            self.visual_items.retain(|k, _| accessed.contains(k));
10
        }
11
        self.per_item_accessed.clear();
11
        self.stage_accessed.clear();
11
        self.generation += 1;
11
    }
    /// Entry counts of the stage caches, for tests and the memory
    /// report. The third slot is the PER-ITEM shaped store since d7
    /// (the monolithic stage-3 map is deleted).
    #[must_use]
236819
    pub fn stage_entry_counts(&self) -> (usize, usize, usize) {
236819
        (
236819
            self.logical_items.len(),
236819
            self.visual_items.len(),
236819
            self.per_item_shaped.len(),
236819
        )
236819
    }
    /// Mark a stage-cache id as used this generation (see `stage_accessed`).
473633
    fn touch_stage(&mut self, id: CacheId) {
473633
        self.stage_accessed.insert(id);
473633
    }
    /// Check if we can reuse an old layout based on layout-affecting parameters.
    /// 
    /// This function compares only the parameters that affect glyph positions,
    /// not rendering-only parameters like color or text-decoration.
    /// 
    /// # Parameters
    /// - `old_constraints`: The constraints used for the cached layout
    /// - `new_constraints`: The constraints for the new layout request
    /// - `old_content`: The content used for the cached layout
    /// - `new_content`: The new content to layout
    /// 
    /// # Returns
    /// - `true` if the old layout can be reused (only rendering changed)
    /// - `false` if a new layout is needed (layout-affecting params changed)
9
    #[must_use] pub fn use_old_layout(
9
        old_constraints: &UnifiedConstraints,
9
        new_constraints: &UnifiedConstraints,
9
        old_content: &[InlineContent],
9
        new_content: &[InlineContent],
9
    ) -> bool {
        // First check: constraints must match exactly for layout purposes
9
        if old_constraints != new_constraints {
1
            return false;
8
        }
        // Second check: content length must match
8
        if old_content.len() != new_content.len() {
2
            return false;
6
        }
        // Third check: each content item must have same layout properties
6
        for (old, new) in old_content.iter().zip(new_content.iter()) {
5
            if !Self::inline_content_layout_eq(old, new) {
3
                return false;
2
            }
        }
3
        true
9
    }
    /// Compare two `InlineContent` items for layout equality.
    /// 
    /// Returns true if the layouts would be identical (only rendering differs).
10
    fn inline_content_layout_eq(old: &InlineContent, new: &InlineContent) -> bool {
        use InlineContent::{Text, Image, Space, LineBreak, Tab, Marker, Shape, Ruby};
10
        match (old, new) {
6
            (Text(old_run), Text(new_run)) => {
                // Text must match exactly, but style only needs layout_eq
6
                old_run.text == new_run.text 
4
                    && old_run.style.layout_eq(&new_run.style)
            }
            (Image(old_img), Image(new_img)) => {
                // Images: size affects layout, but not visual properties
                old_img.intrinsic_size == new_img.intrinsic_size
                    && old_img.display_size == new_img.display_size
                    && old_img.baseline_offset == new_img.baseline_offset
                    && old_img.alignment == new_img.alignment
            }
1
            (Space(old_sp), Space(new_sp)) => old_sp == new_sp,
            (LineBreak(old_br), LineBreak(new_br)) => old_br == new_br,
            (Tab { style: old_style }, Tab { style: new_style }) => old_style.layout_eq(new_style),
            (Marker { run: old_run, position_outside: old_pos },
             Marker { run: new_run, position_outside: new_pos }) => {
                old_pos == new_pos
                    && old_run.text == new_run.text
                    && old_run.style.layout_eq(&new_run.style)
            }
            (Shape(old_shape), Shape(new_shape)) => {
                // Shapes: shape_def affects layout, not fill/stroke
                old_shape.shape_def == new_shape.shape_def
                    && old_shape.baseline_offset == new_shape.baseline_offset
            }
2
            (Ruby { base: old_base, text: old_text, style: old_style },
2
             Ruby { base: new_base, text: new_text, style: new_style }) => {
2
                old_style.layout_eq(new_style)
2
                    && old_base.len() == new_base.len()
2
                    && old_text.len() == new_text.len()
2
                    && old_base.iter().zip(new_base.iter())
2
                        .all(|(o, n)| Self::inline_content_layout_eq(o, n))
1
                    && old_text.iter().zip(new_text.iter())
1
                        .all(|(o, n)| Self::inline_content_layout_eq(o, n))
            }
            // Different variants cannot have same layout
1
            _ => false,
        }
10
    }
}
impl Default for TextShapingCache {
86
    fn default() -> Self {
86
        Self::new()
86
    }
}
/// Key for caching the conversion from `InlineContent` to `LogicalItem`s.
#[derive(Debug, Clone, Eq, PartialEq, Hash)]
pub(crate) struct LogicalItemsKey<'a> {
    pub(crate) inline_content_hash: u64,
    pub(crate) default_font_size: u32,
    pub(crate) _marker: std::marker::PhantomData<&'a ()>,
}
/// Key for caching the Bidi reordering stage.
#[derive(Debug, Clone, Eq, PartialEq, Hash)]
pub(crate) struct VisualItemsKey {
    pub(crate) logical_items_id: CacheId,
    pub(crate) base_direction: BidiDirection,
}
/// Key for caching the shaping stage.
#[derive(Debug, Clone, Eq, PartialEq, Hash)]
pub(crate) struct ShapedItemsKey {
    pub(crate) visual_items_id: CacheId,
    pub(crate) style_hash: u64,
}
impl ShapedItemsKey {
6
    pub(crate) fn new(visual_items_id: CacheId, visual_items: &[VisualItem]) -> Self {
6
        let style_hash = {
6
            let mut hasher = DefaultHasher::new();
9
            for item in visual_items {
                // Hash the style from the logical source, as this is what determines the font.
3
                match &item.logical_source {
3
                    LogicalItem::Text { style, .. } | LogicalItem::CombinedText { style, .. } => {
3
                        style.as_ref().hash(&mut hasher);
3
                    }
                    _ => {}
                }
            }
6
            hasher.finish()
        };
6
        Self {
6
            visual_items_id,
6
            style_hash,
6
        }
6
    }
}
/// Key for the final layout stage.
#[derive(Debug, Clone, Eq, PartialEq, Hash)]
pub(crate) struct LayoutKey {
    pub(crate) shaped_items_id: CacheId,
    pub(crate) constraints: UnifiedConstraints,
}
/// Helper to create a `CacheId` from any `Hash`able type.
461226
fn calculate_id<T: Hash>(item: &T) -> CacheId {
461226
    let mut hasher = DefaultHasher::new();
461226
    item.hash(&mut hasher);
461226
    hasher.finish()
461226
}
// --- Main Layout Pipeline Implementation ---
impl TextShapingCache {
    /// New top-level entry point for flowing layout across multiple regions.
    ///
    /// This function orchestrates the entire layout pipeline, but instead of fitting
    /// content into a single set of constraints, it flows the content through an
    /// ordered sequence of `LayoutFragment`s.
    ///
    /// # CSS Inline Layout Module Level 3: Pipeline Implementation
    ///
    /// This implements the inline formatting context with 5 stages:
    ///
    /// ## Stage 1: Logical Analysis (`InlineContent` -> `LogicalItem`)
    /// \u2705 IMPLEMENTED: Parses raw content into logical units
    /// - Handles text runs, inline-blocks, replaced elements
    /// - Applies style overrides at character level
    /// - Implements \u00a7 2.2: Content size contribution calculation
    ///
    /// ## Stage 2: `BiDi` Reordering (`LogicalItem` -> `VisualItem`)
    /// \u2705 IMPLEMENTED: Uses CSS 'direction' property per CSS Writing Modes
    /// - Reorders items for right-to-left text (Arabic, Hebrew)
    /// - Respects containing block direction (not auto-detection)
    /// - Conforms to Unicode `BiDi` Algorithm (UAX #9)
    ///
    /// ## Stage 3: Shaping (`VisualItem` -> `ShapedItem`)
    /// \u2705 IMPLEMENTED: Converts text to glyphs
    /// - Uses `HarfBuzz` for OpenType shaping
    /// - Handles ligatures, kerning, contextual forms
    /// - Caches shaped results for performance
    ///
    /// ## Stage 4: Text Orientation Transformations
    /// \u26a0\ufe0f PARTIAL: Applies text-orientation for vertical text
    /// - Uses constraints from *first* fragment only
    /// - \u274c TODO: Should re-orient if fragments have different writing modes
    ///
    /// ## Stage 5: Flow Loop (`ShapedItem` -> `PositionedItem`)
    /// \u2705 IMPLEMENTED: Breaks lines and positions content
    /// - Calls `perform_fragment_layout` for each fragment
    /// - Uses `BreakCursor` to flow content across fragments
    /// - Implements \u00a7 5: Line breaking and hyphenation
    ///
    /// # Missing Features from CSS Inline-3:
    /// - \u00a7 3.3: initial-letter (drop caps)
    /// - \u00a7 4: vertical-align (only baseline supported)
    /// - \u00a7 6: text-box-trim (leading trim)
    /// - \u00a7 7: inline-sizing (aspect-ratio for inline-blocks)
    ///
    /// # Arguments
    /// * `content` - The raw `InlineContent` to be laid out.
    /// * `style_overrides` - Character-level style changes.
    /// * `flow_chain` - An ordered slice of `LayoutFragment` defining the regions (e.g., columns,
    ///   pages) that the content should flow through.
    /// * `font_chain_cache` - Pre-resolved font chains (from `FontManager.font_chain_cache`)
    /// * `fc_cache` - The fontconfig cache for font lookups
    /// * `loaded_fonts` - Pre-loaded fonts, keyed by `FontId`
    ///
    /// # Returns
    /// A `FlowLayout` struct containing the positioned items for each fragment that
    /// was filled, and any content that did not fit in the final fragment.
    #[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
    /// # Panics
    ///
    /// Panics if bidi reordering of the logical items fails (an internal invariant).
    /// # Errors
    ///
    /// Returns a `LayoutError` if text flow layout fails.
142354
    pub fn layout_flow<T: ParsedFontTrait>(
142354
        &mut self,
142354
        content: &[InlineContent],
142354
        style_overrides: &[StyleOverride],
142354
        flow_chain: &[LayoutFragment],
142354
        font_chain_cache: &HashMap<FontChainKey, rust_fontconfig::FontFallbackChain>,
142354
        fc_cache: &FcFontCache,
142354
        loaded_fonts: &LoadedFonts<T>,
142354
        debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
142354
    ) -> Result<FlowLayout, LayoutError> {
        // [g150 az-web-lift DIAG] content data ptr (0x60BD0) + len (0x60BD4) at layout_flow ENTRY.
        #[cfg(feature = "web_lift")]
        unsafe {
            crate::az_mark((0x60BD0) as u32, (content.as_ptr() as usize as u32) as u32);
            crate::az_mark((0x60BD4) as u32, (content.len() as u32 | 0xC0DE0000) as u32);
        }
        // [g218 2026-06-09] The g158 `content.len()` force-materialize (a volatile read of content+16) is
        // DELETED: the within-fn SROA-to-0 of content.len() it worked around is now fixed (NEON-decoder +
        // volatile-guest-load transpiler work). VERIFIED: hello-world lays out without it — counter "5"
        // (label_wrapper 8,16,784,40) + button shape correctly, same rects as before. (The cross-FN Vec-*return*-
        // len mis-lift is a separate, still-present issue handled by the g127/g129/g130 out-param hacks — see
        // g134 marker: callee content.len=1 but the caller's return-read sees 0.)
        // --- Stages 1-3: Preparation ---
        // These stages are independent of the final geometry. We perform them once
        // on the entire content block before flowing. Caching is used at each stage.
142354
        let _probe_flow = crate::probe::Probe::span("text_layout_flow");
        // Cap per-item shaped cache to prevent unbounded growth.
        // When threshold is exceeded, evict entries not accessed this generation.
        const PER_ITEM_CACHE_MAX: usize = 4096;
        // The stage caches need a trigger too — they were the unbounded
        // ones. A document's worth of distinct runs is ~800 entries, so
        // 4096 leaves several documents' worth resident before any sweep.
        const STAGE_CACHE_MAX: usize = 4096;
142354
        let (l, v, sh) = self.stage_entry_counts();
142354
        if self.per_item_shaped.len() > PER_ITEM_CACHE_MAX
142354
            || l.max(v).max(sh) > STAGE_CACHE_MAX
        {
            self.begin_generation();
142354
        }
        // Stage 1: Logical Analysis (InlineContent -> LogicalItem)
        // [g213 2026-06-09] The web lift uses the real `self.logical_items` HashMap cache (NO bypass).
        // This entry() find-probe USED to spin forever on the lift (g178-g210 mis-diagnosed it many ways).
        // TRUE root cause: hashbrown's portable WIDTH=8 `Group::static_empty()` — `[0xFF; 8]` in libazul's
        // `__TEXT.__const` — was not mirrored into the wasm, so the empty-map ctrl-scan read 0x00, looked
        // ALL-FULL (EMPTY=0xFF), and the probe never terminated. FIXED entirely transpiler-side in
        // `dll/src/web/symbol_table.rs::compute_hashbrown_empty_group_ranges` (signature-scans `__const`
        // for >=8-byte 8-aligned 0xFF runs and mirrors them). Verified: web-nested-text lays out
        // ("Hello" at 8,16,800,20), __remill_error=0. No azul-source workaround needed here.
142354
        let logical_items_id = calculate_id(&content);
142354
        self.touch_stage(logical_items_id);
142354
        let logical_items = self
142354
            .logical_items
142354
            .entry(logical_items_id)
142354
            .or_insert_with(|| {
1037
                Arc::new(create_logical_items(content, style_overrides, debug_messages))
1037
            })
142354
            .clone();
        // Get the first fragment's constraints to extract the CSS direction property.
        // This is used for BiDi reordering in Stage 2.
142354
        let default_constraints = UnifiedConstraints::default();
142354
        let first_constraints = flow_chain
142354
            .first()
142354
            .map_or(&default_constraints, |f| &f.constraints);
        // +spec:containing-block:e7a271 - paragraph embedding level set from containing block's 'direction' property
        // +spec:display-property:7665cb - inline boxes split into multiple visual runs due to bidi text processing
        // +spec:display-property:929d6b - applies Unicode bidi algorithm to inline-level box sequences
        // +spec:display-property:e8584a - Apply Unicode bidi algorithm to inline-level box sequences per CSS Writing Modes §2.4
        // Stage 2: Bidi Reordering (LogicalItem -> VisualItem)
        // +spec:containing-block:961e3c - bidi paragraph level from containing block direction, not UAX9 heuristic
        // +spec:writing-modes:0a5368 - unicode-bidi: plaintext auto-detects direction from text content
        // Per CSS Writing Modes §8.3: when unicode-bidi is plaintext, the paragraph's
        // base direction is determined from text content (first strong character), ignoring
        // the containing block's direction property. Empty paragraphs fall back to
        // the containing block's direction.
142354
        let unicode_bidi_val = first_constraints.unicode_bidi;
142354
        let base_direction = if unicode_bidi_val == UnicodeBidi::Plaintext {
            // Auto-detect from text content; fall back to containing block direction
            let has_strong = logical_items.iter().any(|item| {
                if let LogicalItem::Text { text, .. } = item {
                    matches!(unicode_bidi::get_base_direction(&**text),
                        unicode_bidi::Direction::Ltr | unicode_bidi::Direction::Rtl)
                } else {
                    false
                }
            });
            if has_strong {
                get_base_direction_from_logical(&logical_items)
            } else {
                // Empty paragraph: use containing block's direction
                first_constraints.direction.unwrap_or(BidiDirection::Ltr)
            }
        } else {
            // Normal case: use CSS direction property
142354
            first_constraints.direction.unwrap_or(BidiDirection::Ltr)
        };
142354
        let visual_key = VisualItemsKey {
142354
            logical_items_id,
142354
            base_direction,
142354
        };
142354
        let visual_items_id = calculate_id(&visual_key);
142354
        self.touch_stage(visual_items_id);
        // [g213] web lift uses the real visual_items HashMap cache (g180 bypass deleted; WIDTH=8
        // EMPTY_GROUP now mirrored — see Stage-1 note + symbol_table.rs).
142354
        let visual_items = self
142354
            .visual_items
142354
            .entry(visual_items_id)
142354
            .or_insert_with(|| {
1046
                Arc::new(
1046
                    reorder_logical_items(&logical_items, base_direction, unicode_bidi_val, debug_messages).unwrap(),
                )
1046
            })
142354
            .clone();
        // Stage 3: Shaping (VisualItem -> ShapedItem)
        // Two-level cache: monolithic (fast path) + per-item (incremental path).
142354
        let _probe_shape = crate::probe::Probe::span("text_shape_stage");
        // (d7) Per-item assembly every pass — the monolithic map is gone
        // (see the field comment). Hits come from `per_item_shaped`.
142354
        let shaped_items = Arc::new(shape_visual_items_with_per_item_cache(
142354
            &visual_items,
142354
            &mut self.per_item_shaped,
142354
            &mut self.per_item_accessed,
142354
            font_chain_cache,
142354
            fc_cache,
142354
            loaded_fonts,
142354
            debug_messages,
        )?);
        // --- Stage 4: Apply Vertical Text Transformations ---
        // Note: first_constraints was already extracted above for BiDi reordering (Stage 2).
        // This orients all text based on the constraints of the *first* fragment.
        // A more advanced system could defer orientation until inside the loop if
        // fragments can have different writing modes.
142354
        let oriented_items = apply_text_orientation(shaped_items, first_constraints);
        // --- Stage 5: The Flow Loop ---
142354
        let mut fragment_layouts = HashMap::new();
        // The cursor now manages the stream of items for the entire flow.
        // §5.2 word-break: pass word_break from constraints to cursor
142354
        let mut cursor = BreakCursor::with_word_break(&oriented_items, first_constraints.word_break);
142354
        cursor.hyphens = first_constraints.hyphenation;
142354
        cursor.line_break = first_constraints.line_break;
        // [g147 az-web-lift] Hard safety bound on the Stage-5 flow loop. On the remill lift this
        // `for fragment in flow_chain` (or the `cursor.is_done()` break) mis-lifts for the NESTED IFC
        // and iterates without terminating → solveLayoutReal HANGS (fuel trap in layout_flow). The text
        // is fully laid out on the first iteration(s); cap the iterations so the loop always converges.
        // (native is unaffected — the cap is far above any real fragment count.)
        #[allow(clippy::no_effect_underscore_binding)] // web_lift-gated debug iteration counter
142354
        let mut _az_flow_iters: usize = 0;
142354
        let _probe_break = crate::probe::Probe::span("text_line_break");
142357
        for fragment in flow_chain {
            #[cfg(feature = "web_lift")]
            {
                _az_flow_iters += 1;
                unsafe { crate::az_mark((0x60BC0) as u32, (_az_flow_iters as u32 | 0xC0DE0000) as u32); }
                if _az_flow_iters > 256 {
                    break;
                }
            }
            // Perform layout for this single fragment, consuming items from the cursor.
142357
            let fragment_layout = perform_fragment_layout(
142357
                &mut cursor,
142357
                &logical_items,
142357
                &fragment.constraints,
142357
                debug_messages,
142357
                loaded_fonts,
            )?;
142357
            fragment_layouts.insert(fragment.id.clone(), Arc::new(fragment_layout));
142357
            if cursor.is_done() {
142354
                break; // All content has been laid out.
3
            }
        }
142354
        if std::env::var("TEXTDBG").is_ok() {
            let total_items: usize = fragment_layouts.values().map(|f| f.items.len()).sum();
            let text_preview: String = content
                .iter()
                .filter_map(|c| match c {
                    InlineContent::Text(r) => Some(r.text.chars().take(24).collect::<String>()),
                    _ => None,
                })
                .collect::<Vec<_>>()
                .join("|");
            eprintln!(
                "[TEXTDBG] layout_flow: content={} logical={} shaped={} frags={} placed_items={total_items} avail_h={:?} text='{text_preview}'",
                content.len(),
                logical_items.len(),
                oriented_items.len(),
                fragment_layouts.len(),
                first_constraints.available_height,
            );
142354
        }
142354
        Ok(FlowLayout {
142354
            fragment_layouts,
142354
            remaining_items: cursor.drain_remaining(),
142354
        })
142354
    }
    /// Runs stages 1–4 of the layout pipeline (logical analysis, `BiDi`, shaping,
    /// text orientation) and derives min/max-content widths by scanning the
    /// resulting `ShapedItem`s directly — without running stage 5's line-breaking
    /// `BreakCursor` loop.
    ///
    /// Used by `calculate_ifc_root_intrinsic_sizes` to avoid the 24% CPU spent
    /// cloning `ShapedCluster`s inside `BreakCursor::peek_next_unit` on every
    /// sizing pass. Since stages 1–3 hit the same `per_item_shaped` cache as
    /// `layout_flow`, a subsequent `layout_flow` call for the same content at
    /// a real container width is a pure cache hit for the shaping work.
    ///
    /// The item walk uses the same break-opportunity predicate that the
    /// `BreakCursor` would — min-content accumulates advances between break
    /// opportunities and tracks the maximum; max-content is the sum of all
    /// advances (as if the flow were laid out on a single infinitely-wide line).
    #[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
    /// # Panics
    ///
    /// Panics if bidi reordering of the logical items fails (an internal invariant).
    /// # Errors
    ///
    /// Returns a `LayoutError` if measuring intrinsic widths fails.
88254
    pub fn measure_intrinsic_widths<T: ParsedFontTrait>(
88254
        &mut self,
88254
        content: &[InlineContent],
88254
        style_overrides: &[StyleOverride],
88254
        constraints: &UnifiedConstraints,
88254
        font_chain_cache: &HashMap<FontChainKey, rust_fontconfig::FontFallbackChain>,
88254
        fc_cache: &FcFontCache,
88254
        loaded_fonts: &LoadedFonts<T>,
88254
        debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
88254
    ) -> Result<IntrinsicTextSizes, LayoutError> {
        const PER_ITEM_CACHE_MAX: usize = 4096;
        // The stage caches need a trigger too — they were the unbounded
        // ones. A document's worth of distinct runs is ~800 entries, so
        // 4096 leaves several documents' worth resident before any sweep.
        const STAGE_CACHE_MAX: usize = 4096;
88254
        let (l, v, sh) = self.stage_entry_counts();
88254
        if self.per_item_shaped.len() > PER_ITEM_CACHE_MAX
88254
            || l.max(v).max(sh) > STAGE_CACHE_MAX
        {
            self.begin_generation();
88254
        }
        // Stage 1: Logical Analysis (cached, same as layout_flow — the historic web-lift
        // bypass here was rooted in the un-mirrored hashbrown EMPTY_GROUP, fixed transpiler-side
        // in symbol_table.rs::compute_hashbrown_empty_group_ranges).
88254
        let logical_items_id = calculate_id(&content);
88254
        self.touch_stage(logical_items_id);
88254
        let logical_items = self
88254
            .logical_items
88254
            .entry(logical_items_id)
88254
            .or_insert_with(|| {
45395
                Arc::new(create_logical_items(content, style_overrides, debug_messages))
45395
            })
88254
            .clone();
        // Stage 2: BiDi (same derivation as layout_flow)
88254
        let unicode_bidi_val = constraints.unicode_bidi;
88254
        let base_direction = if unicode_bidi_val == UnicodeBidi::Plaintext {
            let has_strong = logical_items.iter().any(|item| {
                if let LogicalItem::Text { text, .. } = item {
                    matches!(unicode_bidi::get_base_direction(&**text),
                        unicode_bidi::Direction::Ltr | unicode_bidi::Direction::Rtl)
                } else {
                    false
                }
            });
            if has_strong {
                get_base_direction_from_logical(&logical_items)
            } else {
                constraints.direction.unwrap_or(BidiDirection::Ltr)
            }
        } else {
88254
            constraints.direction.unwrap_or(BidiDirection::Ltr)
        };
88254
        let visual_key = VisualItemsKey {
88254
            logical_items_id,
88254
            base_direction,
88254
        };
88254
        let visual_items_id = calculate_id(&visual_key);
88254
        self.touch_stage(visual_items_id);
88254
        let visual_items = self
88254
            .visual_items
88254
            .entry(visual_items_id)
88254
            .or_insert_with(|| {
45395
                Arc::new(
45395
                    reorder_logical_items(&logical_items, base_direction, unicode_bidi_val, debug_messages).unwrap(),
                )
45395
            })
88254
            .clone();
        // Stage 3: Shaping (two-level cache, same as layout_flow)
        // (d7) Per-item assembly every pass (monolithic map deleted).
88254
        let shaped_items = Arc::new(shape_visual_items_with_per_item_cache(
88254
            &visual_items,
88254
            &mut self.per_item_shaped,
88254
            &mut self.per_item_accessed,
88254
            font_chain_cache,
88254
            fc_cache,
88254
            loaded_fonts,
88254
            debug_messages,
        )?);
        // Stage 4: Text orientation
88254
        let oriented_items = apply_text_orientation(shaped_items, constraints);
        // Stage 5 bypass: scan items for min/max contributions.
88254
        let word_break = constraints.word_break;
88254
        let hyphens = constraints.hyphenation;
88254
        let scan_is_vertical = constraints.is_vertical();
88254
        let mut total = 0.0f32;      // running width of the current line
88254
        let mut max_line = 0.0f32;   // widest line between forced breaks = max-content
88254
        let mut max_word = 0.0f32;
88254
        let mut cur_word = 0.0f32;
88254
        let mut max_line_height = 0.0f32;
1269715
        for item in oriented_items.iter() {
            // A forced break (preserved LF, <br>) ends the current line. max-content
            // is the widest line BETWEEN forced breaks, not the running sum across
            // them — otherwise a white-space:pre block with newlines (or any <br>
            // content) over-measures its max-content as the concatenation of all
            // lines. Reset the line accumulators here.
1269715
            if let ShapedItem::Break { .. } = item {
18
                if total > max_line { max_line = total; }
18
                if cur_word > max_word { max_word = cur_word; }
18
                total = 0.0;
18
                cur_word = 0.0;
18
                continue;
1269697
            }
            // The scan MUST fold the same per-item measure, in the same order,
            // onto the same running total as the line breaker - shared via
            // fold_line_width / get_item_measure_with_spacing (kerning,
            // letter-spacing, word-spacing included; see that function's doc
            // for why any other grouping re-introduces the one-word-wrap bug).
1269697
            let adv = get_item_measure_with_spacing(item, scan_is_vertical).max(0.0);
1269697
            total = fold_line_width(total, item, scan_is_vertical);
1269697
            let (asc, desc) = get_item_vertical_metrics_approx(item);
1269697
            let h = (asc + desc).max(item.bounds().height);
1269697
            if h > max_line_height {
88324
                max_line_height = h;
1181373
            }
1269697
            if is_break_opportunity_with_word_break(item, word_break, hyphens) {
148049
                if cur_word > max_word {
44601
                    max_word = cur_word;
103484
                }
                // A break opportunity that is itself a rendered unit (a CJK
                // ideograph in normal mode, or any cluster under break-all /
                // overflow-wrap:anywhere) still forms a minimal unbreakable unit
                // of its own advance; only true separators (spaces) contribute 0.
                // Without this, pure-CJK / break-all text measures min-content = 0
                // and the box collapses to zero inline width.
148049
                if !is_word_separator(item) && adv > max_word {
90
                    max_word = adv;
147959
                }
148049
                cur_word = 0.0;
1121648
            } else {
1121648
                cur_word += adv;
1121648
            }
        }
88254
        if cur_word > max_word {
65364
            max_word = cur_word;
65364
        }
88254
        if total > max_line {
88234
            max_line = total;
88234
        }
        // white-space:nowrap forbids soft-wrap opportunities entirely, so the
        // min-content width equals the max-content width (one unbreakable line).
        // Without this the scan resets cur_word at each space and reports a
        // too-small min-content, letting flex/shrink-to-fit clip the text.
88254
        let min_content_width = if matches!(constraints.white_space_mode, WhiteSpaceMode::Nowrap) {
18
            max_line
        } else {
88236
            max_word
        };
        // CEIL to a fixed 1/64px sub-pixel grid before reporting - the same
        // reason browsers ceil preferred widths (Chromium's LayoutUnit):
        // an intrinsic width is a PROMISE that content of exactly this width
        // fits, but the scan and the breaker fold separately-shaped item
        // lists whose sums can differ by a few ULP (observed live: the
        // breaker's fold came out one bit ABOVE the reported max-content and
        // a shrink-to-fit "Decrease Indent" wrapped under Noto Sans, even
        // with both passes sharing fold_line_width). 1/64px is invisible on
        // screen and orders of magnitude above float noise; values already
        // on the grid (mock/test fonts with integral advances) round-trip
        // bit-identically through ceil.
        const SUBPIXEL_GRID: f32 = 64.0;
176508
        let ceil_grid = |w: f32| -> f32 {
176508
            if w.is_finite() && w > 0.0 {
176312
                (w * SUBPIXEL_GRID).ceil() / SUBPIXEL_GRID
            } else {
196
                w
            }
176508
        };
88254
        Ok(IntrinsicTextSizes {
88254
            min_content_width: ceil_grid(min_content_width),
88254
            max_content_width: ceil_grid(max_line),
88254
            max_content_height: max_line_height,
88254
        })
88254
    }
}
// --- Stage 1 Implementation ---
#[allow(clippy::cast_possible_truncation)] // bounded pixel/coord/colour/glyph cast
#[allow(clippy::too_many_lines, clippy::cognitive_complexity)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
/// # Panics
///
/// Panics if the scan cursor advances past the end of `text` (an internal invariant).
54232
pub fn create_logical_items(
54232
    content: &[InlineContent],
54232
    style_overrides: &[StyleOverride],
54232
    debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
54232
) -> Vec<LogicalItem> {
54232
    if let Some(msgs) = debug_messages {
47664
        msgs.push(LayoutDebugMessage::info(
47664
            "\n--- Entering create_logical_items (Refactored) ---".to_string(),
47664
        ));
47664
        msgs.push(LayoutDebugMessage::info(format!(
47664
            "Input content length: {}",
47664
            content.len()
47664
        )));
47664
        msgs.push(LayoutDebugMessage::info(format!(
47664
            "Input overrides length: {}",
47664
            style_overrides.len()
47664
        )));
47676
    }
54232
    let mut items: Vec<LogicalItem> = Vec::new();
54232
    let mut style_cache: HashMap<u64, Arc<StyleProperties>> = HashMap::new();
    // 1. Organize overrides for fast lookup per run.
54232
    let mut run_overrides: HashMap<u32, HashMap<u32, &PartialStyleProperties>> = HashMap::new();
54313
    for override_item in style_overrides {
81
        run_overrides
81
            .entry(override_item.target.run_index)
81
            .or_default()
81
            .insert(override_item.target.item_index, &override_item.style);
81
    }
56549
    for (run_idx, inline_item) in content.iter().enumerate() {
56549
        if let Some(msgs) = debug_messages {
48872
            msgs.push(LayoutDebugMessage::info(format!(
48872
                "Processing content run #{run_idx}"
48872
            )));
48884
        }
        // Extract marker information if this is a marker
56549
        let marker_position_outside = match inline_item {
            InlineContent::Marker {
207
                position_outside, ..
207
            } => Some(*position_outside),
56342
            _ => None,
        };
        // [az-web-lift FIX 2026-06-06] Handle the common Text/Marker case via a STANDALONE `if let`
        // (a simple discriminant compare) instead of the first arm of the multi-way `match` below.
        // The remill lift mis-routes that multi-way InlineContent switch (LLVM's `subs/csel`-clamp
        // lowering): a Text(disc 0) variant lands in the `_`/Object arm → `inline_item.clone()` →
        // `<InlineContent as Clone>::clone` ALSO mis-routes to its Vec-clone arm → reads a heap ptr
        // as a Vec len → ×8 → ~789 MB alloc → BumpAlloc memset OOB. A standalone if-let lowers to a
        // single cmp/beq the lift handles correctly, so Text reaches its real body. Native unaffected.
56549
        if let InlineContent::Text(run) | InlineContent::Marker { run, .. } = inline_item {
55676
                let text = &run.text;
55676
                if text.is_empty() {
10
                    if let Some(msgs) = debug_messages {
                        msgs.push(LayoutDebugMessage::info(
                            "  Run is empty, skipping.".to_string(),
                        ));
10
                    }
10
                    continue;
55666
                }
55666
                if let Some(msgs) = debug_messages {
48107
                    msgs.push(LayoutDebugMessage::info(format!("  Run text: '{text}'")));
48118
                }
55666
                let current_run_overrides = run_overrides.get(&(run_idx as u32));
55666
                let mut boundaries = BTreeSet::new();
55666
                boundaries.insert(0);
55666
                boundaries.insert(text.len());
                // --- Stateful Boundary Generation ---
                // web-lift FIX + perf: this scan_cursor walk ONLY inserts boundaries for
                // per-char style overrides (Rule 2) or text-combine-upright digit runs (Rule 1).
                // For plain text (no overrides AND no combine-upright) it inserts NOTHING and just
                // walks char-by-char via `scan_cursor += current_char.len_utf8()` — which the web
                // lift mis-advances (overshoot → slice_start_index_len_fail OOB; stall → infinite
                // loop). Skip the whole walk in that common case so `boundaries` stays {0, len}.
55666
                let needs_scan = current_run_overrides.is_some()
55612
                    || run.style.text_combine_upright.is_some();
55666
                let mut scan_cursor = 0;
56296
                while needs_scan && scan_cursor < text.len() {
630
                    let style_at_cursor = current_run_overrides.and_then(|o| o.get(&(scan_cursor as u32))).map_or_else(|| (*run.style).clone(), |partial| run.style.apply_override(partial));
630
                    let current_char = text[scan_cursor..].chars().next().unwrap();
                    // +spec:containing-block:e4d9de - text-combine-upright digit run rules: digits sharing an ancestor with same value form one sequence across box boundaries
                    // +spec:inline-formatting-context:f65029 - text-combine-upright text run rules: combine consecutive digits not interrupted by box boundary
                    // Rule 1: Multi-character features take precedence.
                    // +spec:containing-block:9a26bd - text-combine-upright digit runs scoped by ancestor style boundaries
216
                    if let Some(TextCombineUpright::Digits(max_digits)) =
216
                        style_at_cursor.text_combine_upright
                    {
216
                        if max_digits > 0 && current_char.is_ascii_digit() {
135
                            let digit_chunk: String = text[scan_cursor..]
135
                                .chars()
135
                                .take(max_digits as usize)
135
                                .take_while(char::is_ascii_digit)
135
                                .collect();
135
                            let end_of_chunk = scan_cursor + digit_chunk.len();
135
                            boundaries.insert(scan_cursor);
135
                            boundaries.insert(end_of_chunk);
135
                            scan_cursor = end_of_chunk; // Jump past the entire sequence
135
                            continue;
81
                        }
414
                    }
                    // Rule 2: If no multi-char feature, check for a normal single-grapheme
                    // override.
495
                    if current_run_overrides
495
                        .and_then(|o| o.get(&(scan_cursor as u32)))
495
                        .is_some()
                    {
27
                        let grapheme_len = text[scan_cursor..]
27
                            .graphemes(true)
27
                            .next()
27
                            .unwrap_or("")
27
                            .len();
27
                        boundaries.insert(scan_cursor);
27
                        boundaries.insert(scan_cursor + grapheme_len);
27
                        scan_cursor += grapheme_len;
27
                        continue;
468
                    }
                    // Rule 3: No special features or overrides at this point, just advance one
                    // char.
468
                    scan_cursor += current_char.len_utf8();
                }
55666
                if let Some(msgs) = debug_messages {
48107
                    msgs.push(LayoutDebugMessage::info(format!(
48107
                        "  Boundaries: {boundaries:?}"
48107
                    )));
48118
                }
                // --- Chunk Processing ---
55909
                for (start, end) in boundaries.iter().zip(boundaries.iter().skip(1)) {
55909
                    let (start, end) = (*start, *end);
55909
                    if start >= end {
                        continue;
55909
                    }
55909
                    let text_slice = &text[start..end];
55909
                    if let Some(msgs) = debug_messages {
48107
                        msgs.push(LayoutDebugMessage::info(format!(
48107
                            "  Processing chunk from {start} to {end}: '{text_slice}'"
48107
                        )));
48118
                    }
55909
                    let style_to_use = current_run_overrides.and_then(|o| o.get(&(start as u32))).map_or_else(|| run.style.clone(), |partial_style| {
81
                        if let Some(msgs) = debug_messages {
                            msgs.push(LayoutDebugMessage::info(format!(
                                "  -> Applying override at byte {start}"
                            )));
81
                        }
81
                        let mut hasher = DefaultHasher::new();
81
                        Arc::as_ptr(&run.style).hash(&mut hasher);
81
                        partial_style.hash(&mut hasher);
81
                        style_cache
81
                            .entry(hasher.finish())
81
                            .or_insert_with(|| Arc::new(run.style.apply_override(partial_style)))
81
                            .clone()
81
                    });
                    // +spec:block-formatting-context:9e7c79 - text-combine-upright combines multiple characters into 1em in vertical writing
                    // +spec:containing-block:2b399b - text-combine-upright digits: combine ASCII digit sequences within max_digits limit; box boundaries implicitly prevent cross-box combination
                    // +spec:display-contents:644c78 - text-combine-upright run boundary check:
                    // if a combinable run boundary is due only to inline box boundaries,
                    // and adjacent chars would form a longer combinable sequence, do not combine
                    // +spec:white-space-processing:409d90 - text-combine-upright combined text: white space at start/end processed as in inline-block
55909
                    let is_combinable_chunk = match &style_to_use.text_combine_upright {
                        Some(TextCombineUpright::All) => !text_slice.is_empty(),
189
                        Some(TextCombineUpright::Digits(max_digits)) => {
189
                            *max_digits > 0
189
                                && !text_slice.is_empty()
297
                                && text_slice.chars().all(|c| c.is_ascii_digit())
135
                                && text_slice.chars().count() <= *max_digits as usize
                        }
55720
                        _ => false,
                    };
55909
                    if is_combinable_chunk {
                        // Trim leading/trailing white space like an inline-block
135
                        let trimmed = text_slice.trim();
135
                        let combined_text = if trimmed.is_empty() {
                            text_slice.to_string()
                        } else {
135
                            trimmed.to_string()
                        };
135
                        items.push(LogicalItem::CombinedText {
135
                            source: ContentIndex {
135
                                run_index: run_idx as u32,
135
                                item_index: start as u32,
135
                            },
135
                            text: combined_text,
135
                            style: style_to_use,
135
                        });
                    } else {
55774
                        items.push(LogicalItem::Text {
55774
                            source: ContentIndex {
55774
                                run_index: run_idx as u32,
55774
                                item_index: start as u32,
55774
                            },
                            // §3.2 3c: the item text is an Arc so shaped
                            // clusters can SHARE it. The whole-run chunk
                            // (the overwhelmingly common case — overrides
                            // and combine-upright are the only splitters)
                            // aliases the StyledRun's own Arc: zero new
                            // allocations; override segments mint one Arc
                            // per segment, same cost as the old String.
55774
                            text: if start == 0 && end == text.len() {
55585
                                run.text.clone()
                            } else {
189
                                Arc::from(text_slice)
                            },
55774
                            style: style_to_use,
55774
                            marker_position_outside,
55774
                            source_node_id: run.source_node_id,
                        });
                    }
                }
        } else {
873
            match inline_item {
            // line breaking class characters must be treated as forced line breaks
135
            InlineContent::LineBreak(break_info) => {
135
                if let Some(msgs) = debug_messages {
108
                    msgs.push(LayoutDebugMessage::info(format!(
108
                        "  LineBreak: {break_info:?}"
108
                    )));
108
                }
135
                items.push(LogicalItem::Break {
135
                    source: ContentIndex {
135
                        run_index: run_idx as u32,
135
                        item_index: 0,
135
                    },
135
                    break_info: *break_info,
135
                });
            }
            // Handle tab characters
45
            InlineContent::Tab { style } => {
45
                if let Some(msgs) = debug_messages {
18
                    msgs.push(LayoutDebugMessage::info("  Tab character".to_string()));
27
                }
45
                items.push(LogicalItem::Tab {
45
                    source: ContentIndex {
45
                        run_index: run_idx as u32,
45
                        item_index: 0,
45
                    },
45
                    style: style.clone(),
45
                });
            }
            // Other cases (Image, Shape, Space, Ruby). Text/Marker are handled by the `if let`
            // above (so they never reach here at runtime); `_` keeps this inner match exhaustive.
            _ => {
693
                if let Some(msgs) = debug_messages {
639
                    msgs.push(LayoutDebugMessage::info(
639
                        "  Run is not text, creating generic LogicalItem.".to_string(),
639
                    ));
639
                }
693
                items.push(LogicalItem::Object {
693
                    source: ContentIndex {
693
                        run_index: run_idx as u32,
693
                        item_index: 0,
693
                    },
693
                    content: inline_item.clone(),
693
                });
            }
            }
        }
    }
54232
    if let Some(msgs) = debug_messages {
47664
        msgs.push(LayoutDebugMessage::info(format!(
47664
            "--- Exiting create_logical_items, created {} items ---",
47664
            items.len()
47664
        )));
47676
    }
54232
    items
54232
}
// --- Stage 2 Implementation ---
// +spec:inline-block:d47971 - unicode-bidi:plaintext uses P2/P3 heuristic for base direction (implemented via get_base_direction)
// +spec:writing-modes:287491 - BiDi reordering and base direction detection (Appendix A text processing order)
// when determining base direction, consistent with their neutral bidi treatment
208
#[must_use] pub fn get_base_direction_from_logical(logical_items: &[LogicalItem]) -> BidiDirection {
208
    let first_strong = logical_items.iter().find_map(|item| {
5
        if let LogicalItem::Text { text, .. } = item {
5
            Some(unicode_bidi::get_base_direction(&**text))
        } else {
            None
        }
5
    });
5
    match first_strong {
4
        Some(unicode_bidi::Direction::Rtl) => BidiDirection::Rtl,
204
        _ => BidiDirection::Ltr,
    }
208
}
// +spec:containing-block:149255 - bidi reordering produces inline box fragments that may separate in wide containing blocks
// +spec:containing-block:c7c08f - bidi reordering produces inline box fragments that may be adjacent in narrow containing blocks
// +spec:containing-block:2936ae - bidi reordering splits inline boxes into visual fragments (CSS Writing Modes 4 §2.4.5)
// +spec:display-property:0cdbd3 - bidi reordering splits inline boxes into visual runs; each run is shaped/formatted independently
// +spec:display-property:0d62a2 - bidi reordering of inline content respects block direction and unicode-bidi embedding
// +spec:display-property:10f9cd - bidi reordering splits and reorders inline box fragments
// +spec:display-property:58b30a - bidi paragraph breaks within inline boxes: each IFC does independent bidi analysis, so splitting an inline box at a paragraph boundary naturally closes/reopens bidi embeddings
// +spec:display-property:ecd935 - inline boxes split and reordered for uniform bidi flow
// +spec:writing-modes:330b8f - text ordered according to Unicode bidi algorithm after white-space processing
// +spec:writing-modes:7a9e7d - bidi control translation: text passed to unicode_bidi for reordering
// +spec:writing-modes:8e7281 - unicode-bidi property: bidi control codes inserted via BidiInfo
#[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
/// # Errors
///
/// Returns a `LayoutError` if bidi reordering fails.
54140
pub fn reorder_logical_items(
54140
    logical_items: &[LogicalItem],
54140
    base_direction: BidiDirection,
54140
    unicode_bidi: UnicodeBidi,
54140
    debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
54140
) -> Result<Vec<VisualItem>, LayoutError> {
54140
    if let Some(msgs) = debug_messages {
47663
        msgs.push(LayoutDebugMessage::info(
47663
            "\n--- Entering reorder_logical_items ---".to_string(),
47663
        ));
47663
        msgs.push(LayoutDebugMessage::info(format!(
47663
            "Input logical items count: {}",
47663
            logical_items.len()
47663
        )));
47663
        msgs.push(LayoutDebugMessage::info(format!(
47663
            "Base direction: {base_direction:?}"
47663
        )));
47666
    }
    // +spec:writing-modes:809513 - bidi string built across inline element boundaries; unicode-bidi:normal adds no extra embedding levels
54140
    let mut bidi_str = String::new();
54140
    let mut item_map = Vec::new();
    // Byte offset in `bidi_str` where each logical item's text begins, indexed
    // by logical item index. Used to re-base each visual run's byte offset to be
    // relative to its own logical run (see `run_byte_offset`).
54140
    let mut logical_item_starts = Vec::with_capacity(logical_items.len());
56475
    for (idx, item) in logical_items.iter().enumerate() {
        // +spec:containing-block:1fdc31 - inline boxes with unicode-bidi:normal are transparent to bidi algorithm
        // +spec:display-property:074abf - inline boxes transparent to bidi when unicode-bidi:normal
        // +spec:display-property:354966 - unicode-bidi control code injection for inline boxes
        // +spec:display-property:8409d3 - inline-level elements with unicode-bidi:normal have no effect on bidi ordering; embed creates an embedding
        // +spec:display-property:89464a - inline boxes with unicode-bidi:normal don't open embedding levels, so direction has no effect on bidi reordering
        // +spec:display-property:d47971 - bidi control codes should be injected at inline box boundaries based on unicode-bidi + direction
        // +spec:display-property:de657b - bidi control codes injected for display:inline boxes per unicode-bidi value
        // +spec:display-property:f01a81 - bidi-override should prepend LRO/RLO and append PDF per unicode-bidi CSS property (not yet implemented)
        // are treated as neutral characters in the bidi algorithm. Replaced elements with
        // +spec:display-property:fcb011 - unicode-bidi values on inline boxes insert bidi control codes
        // +spec:display-property:89095f - isolate/bidi-override/isolate-override/plaintext semantics
        // +spec:writing-modes:d490bf - direction only affects reordering when unicode-bidi is embed/override (not yet enforced for inline elements)
        // display:inline are also neutral unless unicode-bidi != normal (not yet implemented).
        // +spec:display-property:b4756e - replaced inline elements treated as neutral bidi chars;
        // embed/bidi-override exception not yet implemented (would make them strong chars).
        // U+FFFC (OBJECT REPLACEMENT CHARACTER) is a neutral bidi character.
        // +spec:display-property:df11ef - atomic inlines treated as neutral bidi characters (U+FFFC)
        // Replaced elements with display:inline are also neutral unless unicode-bidi != normal.
56475
        let text = match item {
55602
            LogicalItem::Text { text, .. } => text,
            LogicalItem::CombinedText { text, .. } => text.as_str(),
873
            _ => "\u{FFFC}",
        };
56475
        let start_byte = bidi_str.len();
56475
        logical_item_starts.push(start_byte);
56475
        bidi_str.push_str(text);
895107
        for _ in start_byte..bidi_str.len() {
895107
            item_map.push(idx);
895107
        }
    }
54140
    if bidi_str.is_empty() {
19
        if let Some(msgs) = debug_messages {
            msgs.push(LayoutDebugMessage::info(
                "Bidi string is empty, returning.".to_string(),
            ));
19
        }
19
        return Ok(Vec::new());
54121
    }
54121
    if let Some(msgs) = debug_messages {
47663
        msgs.push(LayoutDebugMessage::info(format!(
47663
            "Constructed bidi string: '{bidi_str}'"
47663
        )));
47665
    }
    // +spec:display-property:1a6075 - paragraph embedding level set from direction property per UAX9 HL1
    // +spec:containing-block:0d4914 - unicode-bidi: plaintext exception
    // When the containing block has unicode-bidi: plaintext, use None so the
    // Unicode bidi algorithm applies P2/P3 heuristics instead of the HL1 override
54121
    let bidi_level = if unicode_bidi == UnicodeBidi::Plaintext {
        None
54121
    } else if base_direction == BidiDirection::Rtl {
135
        Some(Level::rtl())
    } else {
53986
        Some(Level::ltr())
    };
    // +spec:writing-modes:15bf17 - bidi isolation handled by unicode_bidi UAX #9 implementation
54121
    let bidi_info = BidiInfo::new(&bidi_str, bidi_level);
54121
    let para = &bidi_info.paragraphs[0];
54121
    let (levels, visual_runs) = bidi_info.visual_runs(para, para.range.clone());
54121
    if let Some(msgs) = debug_messages {
47663
        msgs.push(LayoutDebugMessage::info(
47663
            "Bidi visual runs generated:".to_string(),
        ));
47663
        for (i, run_range) in visual_runs.iter().enumerate() {
47663
            let level = levels[run_range.start].number();
47663
            let slice = &bidi_str[run_range.start..run_range.end];
47663
            msgs.push(LayoutDebugMessage::info(format!(
47663
                "  Run {i}: range={run_range:?}, level={level}, text='{slice}'"
47663
            )));
47663
        }
6458
    }
    // TODO(text3-review): RTL glyph-level visual reversal is NOT applied.
    // `visual_runs` orders the RUNS visually (left-to-right), but the loop below
    // emits each run's content in LOGICAL byte order, and shaping/positioning then
    // place clusters left-to-right in that logical order. For an RTL run this is
    // wrong: the first logical character must land at the LARGEST visual x. The
    // shaped clusters of each RTL run therefore need to be reversed (UBA rule L2,
    // applied per run at the glyph level AFTER shaping — a single logical Text item
    // shapes into multiple clusters, so it cannot be reversed here at the item
    // level). This must compose with the run-level ordering already done here
    // (naively re-running full L2 on top would double-reverse RTL-base paragraphs),
    // and `UnifiedLayout::get_selection_rects` must additionally split a selection
    // into one visual rect per directional segment. Deferred as a coherent
    // cross-cutting change; see failing tests text3_brutal_shaping::
    // {hebrew_run_is_rtl_reversed_and_33px_wide, bidi_mixed_run_is_80px_and_reverses_hebrew}
    // and text3_brutal_selection::bidi_selection_over_rtl_run_splits_into_multiple_rects.
54121
    let mut visual_items = Vec::new();
108485
    for run_range in visual_runs {
54364
        let bidi_level = BidiLevel::new(levels[run_range.start].number());
54364
        let mut sub_run_start = run_range.start;
840743
        for i in (run_range.start + 1)..run_range.end {
840743
            if item_map[i] != item_map[sub_run_start] {
2300
                let logical_idx = item_map[sub_run_start];
2300
                let logical_item = &logical_items[logical_idx];
2300
                let text_slice = &bidi_str[sub_run_start..i];
2300
                visual_items.push(VisualItem {
2300
                    logical_source: logical_item.clone(),
2300
                    bidi_level,
2300
                    script: crate::text3::script::detect_script(text_slice)
2300
                        .unwrap_or(Script::Latin),
2300
                    text: text_slice.to_string(),
2300
                    run_byte_offset: sub_run_start - logical_item_starts[logical_idx],
2300
                });
2300
                sub_run_start = i;
838443
            }
        }
54364
        let logical_idx = item_map[sub_run_start];
54364
        let logical_item = &logical_items[logical_idx];
54364
        let text_slice = &bidi_str[sub_run_start..run_range.end];
54364
        visual_items.push(VisualItem {
54364
            logical_source: logical_item.clone(),
54364
            bidi_level,
54364
            script: crate::text3::script::detect_script(text_slice).unwrap_or(Script::Latin),
54364
            text: text_slice.to_string(),
54364
            run_byte_offset: sub_run_start - logical_item_starts[logical_idx],
54364
        });
    }
54121
    if let Some(msgs) = debug_messages {
47663
        msgs.push(LayoutDebugMessage::info(
47663
            "Final visual items produced:".to_string(),
        ));
48871
        for (i, item) in visual_items.iter().enumerate() {
48871
            msgs.push(LayoutDebugMessage::info(format!(
48871
                "  Item {}: level={}, text='{}'",
48871
                i,
48871
                item.bidi_level.level(),
48871
                item.text
48871
            )));
48871
        }
47663
        msgs.push(LayoutDebugMessage::info(
47663
            "--- Exiting reorder_logical_items ---".to_string(),
        ));
6458
    }
54121
    Ok(visual_items)
54140
}
// --- Stage 3 Implementation ---
/// Shape visual items into `ShapedItems` using pre-loaded fonts.
///
/// This function does NOT load any fonts - all fonts must be pre-loaded and passed in.
/// If a required font is not in `loaded_fonts`, the text will be skipped with a warning.
///
/// **Optimization: Inline Run Coalescing**
///
/// // +spec:display-property:9c6d59 - text shaping not broken across inline box boundaries when no effective formatting change
/// // +spec:display-property:cf8917 - text shaping not broken across inline box boundaries
/// When consecutive text `VisualItem`s share the same layout-affecting properties
/// (font, size, spacing, etc.) but differ only in rendering properties (color,
/// background), they are coalesced into a single shaping call. This dramatically
/// reduces the number of `font.shape_text()` invocations for syntax-highlighted
/// code where hundreds of `<span>` elements use the same monospace font but
/// different colors. After shaping, the original per-span styles are restored
/// to each `ShapedCluster` based on byte-range mapping.
/// Shape visual items with per-item caching. For each item (or coalesced group),
/// compute a cache key from (text, `bidi_level`, script, `style_layout_hash`). On cache
/// hit, reuse the previously shaped clusters. On miss, shape and store.
///
/// This is the incremental shaping path: when one word changes in a paragraph,
/// only that word's item misses the per-item cache; all other items hit.
#[allow(clippy::implicit_hasher)] // internal helper; only ever called with the default-hasher HashMap/HashSet
/// # Errors
///
/// Returns a `LayoutError` if shaping the visual items fails.
230608
pub fn shape_visual_items_with_per_item_cache<T: ParsedFontTrait>(
230608
    visual_items: &[VisualItem],
230608
    per_item_cache: &mut HashMap<u64, Arc<PerItemShapedEntry>>,
230608
    per_item_accessed: &mut HashSet<u64>,
230608
    font_chain_cache: &HashMap<FontChainKey, rust_fontconfig::FontFallbackChain>,
230608
    fc_cache: &FcFontCache,
230608
    loaded_fonts: &LoadedFonts<T>,
230608
    debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
230608
) -> Result<Vec<ShapedItem>, LayoutError> {
    use std::hash::{Hash, Hasher};
    // Delegate to the existing shaping logic, but for each coalesce group,
    // check the per-item cache first.
    //
    // Strategy: Identify coalesce groups (adjacent items with same layout_hash,
    // bidi_level, script). For each group, compute a key from the concatenated
    // text + shared properties. Check cache. On miss, shape the group and cache it.
230608
    let mut shaped = Vec::new();
230608
    let mut idx = 0;
461832
    while idx < visual_items.len() {
231224
        let item = &visual_items[idx];
        // Determine coalesce group boundaries (same logic as shape_visual_items)
231224
        let (layout_hash, bidi_level, script) = match &item.logical_source {
230593
            LogicalItem::Text { style, .. } | LogicalItem::CombinedText { style, .. } => {
230593
                (style.layout_hash(), item.bidi_level, item.script)
            }
            _ => {
                // Non-text items: shape individually (no coalescing)
631
                let single = shape_visual_items(
631
                    &visual_items[idx..=idx],
631
                    font_chain_cache, fc_cache, loaded_fonts, debug_messages,
                )?;
631
                shaped.extend(single);
631
                idx += 1;
631
                continue;
            }
        };
230593
        let mut coalesce_end = idx + 1;
232680
        while coalesce_end < visual_items.len() {
2319
            let next = &visual_items[coalesce_end];
2319
            let next_layout_hash = match &next.logical_source {
2152
                LogicalItem::Text { style, .. } | LogicalItem::CombinedText { style, .. } => {
2152
                    Some(style.layout_hash())
                }
167
                _ => None,
            };
2319
            if let Some(nlh) = next_layout_hash {
2152
                if nlh == layout_hash
2087
                    && next.bidi_level == bidi_level
2087
                    && next.script == script
2087
                {
2087
                    coalesce_end += 1;
2087
                } else {
65
                    break;
                }
            } else {
167
                break;
            }
        }
        // Compute per-group cache key
230593
        let mut hasher = DefaultHasher::new();
232680
        for item in &visual_items[idx..coalesce_end] {
232680
            item.text.hash(&mut hasher);
232680
        }
230593
        layout_hash.hash(&mut hasher);
230593
        bidi_level.hash(&mut hasher);
230593
        (script as u32).hash(&mut hasher);
230593
        let group_key = hasher.finish();
        // Check per-item cache
230593
        per_item_accessed.insert(group_key);
230593
        if let Some(cached) = per_item_cache.get(&group_key) {
            // The key is `layout_hash`, which EXCLUDES paint-only properties
            // (colour, background, text-decoration) BY DESIGN — that is what
            // lets a hover recolour reuse the shaping instead of re-running
            // it. But the cached clusters carry a WHOLE `Arc<StyleProperties>`
            // per glyph, and `get_glyph_runs_simple` reads `glyph.style.color`
            // when it builds the display list. Handing back the cached glyphs
            // unchanged therefore hands back the colour of whichever run
            // happened to shape this text first.
            //
            // Same for identity: `source_node_id` rides along in the cluster,
            // and `DisplayListItem::Text.source_node_index` — which the damage
            // system attributes rects by — is taken from it. Two nodes with
            // the same text and the same layout_hash (three ribbon tab
            // headers, a column of identical labels) share one entry, so the
            // second one's text was reported as belonging to the first.
            //
            // Geometry is identical by construction (that IS the key), so
            // re-stamping the paint-and-identity fields from the CURRENT items
            // is sound and keeps the reuse. Clusters map back to their item
            // through `source_content_index`.
188634
            let group = &visual_items[idx..coalesce_end];
            // NEGATIVE-CONTROL KNOB (T2, plan §2.3): AZ_T2_SKIP_RESTAMP=1
            // hands back the cached entry UNMODIFIED — the exact defect
            // 8ec9f387d fixed. The identity gate
            // (tests/text3_shaping_cache_identity.rs) runs once with this
            // set and requires itself to FAIL; production never sets it.
188634
            let t2_skip_restamp = std::env::var_os("AZ_T2_SKIP_RESTAMP").is_some();
            // (d7) Materialize from the compact store — the fat path
            // cloned every item here anyway, so this is cost-neutral.
2482804
            shaped.extend(cached.compact.expand().into_iter().map(|c| {
2482804
                if t2_skip_restamp {
                    return c;
2482804
                }
2482804
                let mut c = c;
2482804
                if let ShapedItem::Cluster(ref mut sc) = c {
2647028
                    let current = group.iter().find_map(|it| match &it.logical_source {
                        LogicalItem::Text {
2647028
                            source,
2647028
                            style,
2647028
                            source_node_id,
                            ..
2647028
                        } if *source == sc.source_content_index => {
2480996
                            Some((style.clone(), *source_node_id))
                        }
                        LogicalItem::CombinedText { source, style, .. }
                            if *source == sc.source_content_index =>
                        {
                            Some((style.clone(), None))
                        }
166032
                        _ => None,
2647028
                    });
2482804
                    if let Some((style, source_node_id)) = current {
2480996
                        // Cluster-level re-stamp: style no longer lives on
2480996
                        // glyphs, so a cache hit is TWO writes per cluster
2480996
                        // instead of an Arc clone per glyph (T2 pins this).
2480996
                        // `source_text` is deliberately NOT re-stamped: the
2480996
                        // cache key includes the text, so the cached Arc is
2480996
                        // content-equal to the hitting item's — keeping it
2480996
                        // SHARES one allocation across all equal-text nodes.
2480996
                        sc.source_node_id = source_node_id;
2480996
                        sc.style = style;
2480996
                    }
                }
2482804
                c
2482804
            }));
        } else {
            // Cache miss — shape this group
41959
            let group_items = shape_visual_items(
41959
                &visual_items[idx..coalesce_end],
41959
                font_chain_cache, fc_cache, loaded_fonts, debug_messages,
            )?;
552936
            let total_advance: f32 = group_items.iter().map(|item| {
552936
                match item {
552936
                    ShapedItem::Cluster(c) => c.advance,
                    _ => 0.0,
                }
552936
            }).sum();
41959
            per_item_cache.insert(group_key, Arc::new(PerItemShapedEntry {
41959
                compact: CompactShapedEntry::build(&group_items),
41959
                total_advance,
41959
            }));
41959
            shaped.extend(group_items);
        }
230593
        idx = coalesce_end;
    }
230608
    Ok(shaped)
230608
}
/// Split text into segments where consecutive characters resolve to the same font
/// in the fallback chain. Returns Vec<(`byte_start`, `byte_end`, `FontId`)>.
///
/// Characters that can't be resolved to any font are skipped (gap in coverage).
43944
fn split_text_by_font_coverage<T: ParsedFontTrait>(
43944
    text: &str,
43944
    font_chain: &rust_fontconfig::FontFallbackChain,
43944
    fc_cache: &FcFontCache,
43944
    loaded_fonts: &LoadedFonts<T>,
43944
) -> Vec<(usize, usize, FontId)> {
43944
    let mut segments: Vec<(usize, usize, FontId)> = Vec::new();
    // Deterministic "last resort" face for characters no font covers: the lowest
    // FontId among the loaded fonts. Used so an uncovered codepoint still emits a
    // .notdef (tofu) segment instead of being silently dropped (zero glyphs/advance).
43944
    let notdef_font_id = loaded_fonts.iter().map(|(id, _)| *id).min();
    // Per-character resolution is memoised for the duration of this call.
    // `resolve_char` walks every fallback group's unicode ranges linearly AND
    // clones a String for the matched css_name, and the loop below runs it
    // once per CHARACTER — so a paragraph paid it ~200 times to answer ~40
    // distinct questions. A `perf` profile of a steady-state frame put the
    // cmap/range scanning at 7.8% of the whole frame, third behind the pixel
    // blend and the scanline sweep, plus a share of the malloc traffic from
    // those per-character String clones.
    //
    // The memo is call-scoped on purpose: `font_chain`, `fc_cache` and
    // `loaded_fonts` are all fixed for the duration, so a character's answer
    // cannot change within one call. A longer-lived cache would have to key
    // on all three and is a different, riskier change.
43944
    let mut resolved: alloc::collections::BTreeMap<char, Option<FontId>> =
43944
        alloc::collections::BTreeMap::new();
681291
    for (byte_idx, ch) in text.char_indices() {
681291
        let char_end = byte_idx + ch.len_utf8();
681291
        if let Some(&memo) = resolved.get(&ch) {
328334
            if let Some(font_id) = memo {
328334
                match segments.last_mut() {
328334
                    Some(last) if last.2 == font_id && last.1 == byte_idx => {
328334
                        last.1 = char_end;
328334
                    }
                    _ => segments.push((byte_idx, char_end, font_id)),
                }
            }
328334
            continue;
352957
        }
        // Primary: the resolved fallback chain. Its coverage comes from
        // rust-fontconfig's OS/2-derived `unicode_ranges`, which can MISS
        // codepoints a font actually has in its cmap — e.g. Noto Sans CJK's
        // JP face does not advertise the Hangul OS/2 block, so 한국어 resolves
        // to None here even though that face's cmap covers it.
352957
        let font_id = font_chain
352957
            .resolve_char(fc_cache, ch)
352957
            .map(|(id, _)| id)
            // Fallback: probe the actually-loaded fonts by REAL glyph coverage
            // so OS/2-vs-cmap gaps render instead of being silently dropped.
            // The covering CJK face is already loaded (Han/Kana resolved to it),
            // so this reuses it for Hangul rather than mixing in another font.
            // Iterate in a STABLE order (lowest FontId first) so the chosen face is
            // deterministic across processes — a raw HashMap `.find` is seeded per
            // process and would pick different faces run-to-run.
352957
            .or_else(|| {
281
                loaded_fonts
281
                    .iter()
326
                    .filter(|(_, font)| font.has_glyph(ch as u32))
281
                    .map(|(id, _)| *id)
281
                    .min()
281
            })
            // Last resort: no font advertises OR covers this codepoint. Assign it to
            // the primary loaded face so the shaper emits a visible .notdef box and
            // the byte range is preserved (following text is not shifted).
352957
            .or(notdef_font_id);
352957
        resolved.insert(ch, font_id);
352957
        if let Some(font_id) = font_id {
352957
            match segments.last_mut() {
309013
                Some(last) if last.2 == font_id && last.1 == byte_idx => {
308974
                    // Extend current segment (same font, contiguous)
308974
                    last.1 = char_end;
308974
                }
43983
                _ => {
43983
                    // New segment (different font or gap)
43983
                    segments.push((byte_idx, char_end, font_id));
43983
                }
            }
        }
    }
43944
    segments
43944
}
/// Measures the total inline advance (width in horizontal mode) of `text` shaped at
/// `style`, using the same font-resolution path as the main shaper. Returns `None` if the
/// font chain is not resolved / shaping fails, so callers can fall back to an estimate.
///
/// Used by ruby layout to size the base and annotation runs from REAL shaped advances
/// (instead of a `chars * font_size * magic_ratio` fudge).
fn measure_run_advance<T: ParsedFontTrait>(
    text: &str,
    style: &Arc<StyleProperties>,
    script: Script,
    source: ContentIndex,
    font_chain_cache: &HashMap<FontChainKey, rust_fontconfig::FontFallbackChain>,
    fc_cache: &FcFontCache,
    loaded_fonts: &LoadedFonts<T>,
) -> Option<f32> {
    if text.is_empty() {
        return Some(0.0);
    }
    let language = script_to_language(script, text);
    match &style.font_stack {
        FontStack::Ref(font_ref) => {
            let glyphs = font_ref
                .shape_text(text, script, language, BidiDirection::Ltr, style.as_ref())
                .ok()?;
            Some(glyphs.iter().map(|g| g.advance + g.kerning).sum())
        }
        FontStack::Stack(selectors) => {
            let cache_key = FontChainKey::from_selectors(selectors);
            let font_chain = font_chain_cache.get(&cache_key)?;
            let clusters = shape_with_font_fallback(
                text, script, language, BidiDirection::Ltr, style, source, None, font_chain,
                fc_cache, loaded_fonts,
            )
            .ok()?;
            Some(clusters.iter().map(|c| c.advance).sum())
        }
    }
}
/// Shape text with per-character font fallback.
///
/// Splits the text into segments by font coverage, shapes each segment with
/// its resolved font, and fixes byte offsets so they're relative to the
/// original `text` (not the segment substring).
#[allow(clippy::cast_possible_truncation)] // bounded pixel/coord/colour/glyph cast
43944
fn shape_with_font_fallback<T: ParsedFontTrait>(
43944
    text: &str,
43944
    script: Script,
43944
    language: Language,
43944
    direction: BidiDirection,
43944
    style: &Arc<StyleProperties>,
43944
    source_index: ContentIndex,
43944
    source_node_id: Option<NodeId>,
43944
    font_chain: &rust_fontconfig::FontFallbackChain,
43944
    fc_cache: &FcFontCache,
43944
    loaded_fonts: &LoadedFonts<T>,
43944
) -> Result<Vec<ShapedCluster>, LayoutError> {
    // Cache the debug flag in a `OnceLock<bool>` — reading it per-shape
    // (this function fires once per text segment, ~hundreds of times
    // per render of a real DOM) costs ~100 ns per `std::env::var_os`
    // call on macOS (env-lock + hashmap lookup), and even before the
    // lookup finishes the `eprintln!` machinery takes a stderr lock
    // and allocates the formatted string. Both are invisible in
    // release unless `AZ_FONT_FALLBACK_DEBUG=1` is set.
    static FONT_FB_DEBUG: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
43944
    let dbg = *FONT_FB_DEBUG.get_or_init(|| {
21
        std::env::var_os("AZ_FONT_FALLBACK_DEBUG").is_some()
21
    });
43944
    let segments = split_text_by_font_coverage(text, font_chain, fc_cache, loaded_fonts);
43944
    if dbg && segments.len() > 1 {
        eprintln!(
            "[FONT FALLBACK] text needs {} font segments for '{}' ({}..{} bytes)",
            segments.len(),
            text.chars().take(40).collect::<String>(),
            0, text.len()
        );
43944
    }
43944
    unsafe { crate::az_mark(0x60850_u32, segments.len() as u32); } // [g123] segments count (split_text_by_font_coverage)
43944
    if segments.len() <= 1 {
        // Fast path: all characters use the same font (common case)
43905
        let (seg_start, seg_end, font_id) = if let Some(s) = segments.first() { unsafe { crate::az_mark(0x60854_u32, 0x0000_0001_u32); } s } else {
            unsafe { crate::az_mark(0x60854_u32, 0x0000_00EE_u32); } // [g123] split→0 segments (resolve_char failed all)
            if dbg {
                eprintln!("[FONT FALLBACK] no font could render any char in '{}'", text.chars().take(20).collect::<String>());
            }
            return Ok(Vec::new());
        };
43905
        let font = if let Some(f) = loaded_fonts.get(font_id) { unsafe { crate::az_mark(0x60858_u32, 0x0000_0001_u32); } f } else {
3
            unsafe { crate::az_mark(0x60858_u32, 0x0000_00EE_u32); } // [g123] loaded_fonts.get MISS
3
            if dbg {
                eprintln!("[FONT FALLBACK] font {:?} not in loaded_fonts for '{}'", font_id, text.chars().take(20).collect::<String>());
3
            }
3
            return Ok(Vec::new());
        };
        // If segment covers the full text (overwhelmingly common), skip substr+fixup
43902
        if *seg_start == 0 && *seg_end == text.len() {
43902
            unsafe { crate::az_mark(0x60860_u32, 0xC0DE_0860_u32); } // [g123] reached shape_text_correctly (full-text)
43902
            return shape_text_correctly(
43902
                text, script, language, direction,
43902
                font, style, source_index, source_node_id,
            );
        }
        let mut clusters = shape_text_correctly(
            &text[*seg_start..*seg_end], script, language, direction,
            font, style, source_index, source_node_id,
        )?;
        if *seg_start > 0 {
            for cluster in &mut clusters {
                cluster.source_cluster_id.start_byte_in_run += *seg_start as u32;
            }
        }
        return Ok(clusters);
39
    }
    // Multiple fonts needed — shape each segment separately
39
    let mut all_clusters = Vec::new();
117
    for (seg_start, seg_end, font_id) in &segments {
78
        let Some(font) = loaded_fonts.get(font_id) else {
            if dbg {
                eprintln!("[FONT FALLBACK] font {font_id:?} NOT loaded, skipping segment bytes {seg_start}..{seg_end}");
            }
            continue;
        };
78
        let segment_text = &text[*seg_start..*seg_end];
78
        if dbg {
            eprintln!(
                "[FONT FALLBACK] text='{segment_text}' uses font {font_id:?} (bytes {seg_start}..{seg_end})"
            );
78
        }
78
        let mut seg_clusters = shape_text_correctly(
78
            segment_text, script, language, direction,
78
            font, style, source_index, source_node_id,
        )?;
        // Fix byte offsets: shape_text_correctly produces offsets relative to
        // segment_text, but callers expect offsets relative to the full text.
78
        if *seg_start > 0 {
368
            for cluster in &mut seg_clusters {
329
                cluster.source_cluster_id.start_byte_in_run += *seg_start as u32;
329
            }
39
        }
78
        all_clusters.extend(seg_clusters);
    }
39
    Ok(all_clusters)
43944
}
#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)] // bounded pixel/coord/colour/glyph cast
#[allow(clippy::implicit_hasher)] // internal helper; only ever called with the default-hasher HashMap/HashSet
#[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
#[allow(clippy::too_many_lines, clippy::cognitive_complexity)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
/// # Errors
///
/// Returns a `LayoutError` if shaping the visual items fails.
44774
pub fn shape_visual_items<T: ParsedFontTrait>(
44774
    visual_items: &[VisualItem],
44774
    font_chain_cache: &HashMap<FontChainKey, rust_fontconfig::FontFallbackChain>,
44774
    fc_cache: &FcFontCache,
44774
    loaded_fonts: &LoadedFonts<T>,
44774
    debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
44774
) -> Result<Vec<ShapedItem>, LayoutError> {
44774
    let mut shaped = Vec::new();
44774
    let mut idx = 0;
44774
    let mut _coalesced_runs = 0usize;
44774
    let mut _total_runs = 0usize;
44774
    let mut _shape_calls = 0usize;
    // Log count of visual items for debugging coalescing
89587
    while idx < visual_items.len() {
44813
        let item = &visual_items[idx];
44813
        match &item.logical_source {
            LogicalItem::Text {
44176
                style,
44176
                source,
44176
                marker_position_outside,
44176
                source_node_id,
                ..
            } => {
44176
                let layout_hash = style.layout_hash();
44176
                let bidi_level = item.bidi_level;
44176
                let script = item.script;
                // +spec:display-property:ca95f6 - text shaping breaks at inline box boundaries when layout-affecting properties differ
                // when layout-affecting properties (font weight, family, size, etc.) change
                // across element boundaries, preventing ligatures from forming across such changes.
                // Look ahead: find consecutive text items with the same layout-affecting
                // properties (font, size, spacing) that can be shaped as one merged run.
44176
                let mut coalesce_end = idx + 1;
45019
                while coalesce_end < visual_items.len() {
876
                    let next = &visual_items[coalesce_end];
876
                    if let LogicalItem::Text { style: next_style, .. } = &next.logical_source {
872
                        if next_style.layout_hash() == layout_hash
864
                            && next.bidi_level == bidi_level
843
                            && next.script == script
843
                        {
843
                            coalesce_end += 1;
843
                        } else {
29
                            break;
                        }
                    } else {
4
                        break;
                    }
                }
44176
                let coalesce_count = coalesce_end - idx;
44176
                if coalesce_count > 1 {
259
                    _coalesced_runs += coalesce_count;
259
                    _shape_calls += 1;
                    // ── COALESCED PATH ──
                    // Merge N text items into one shaping call, then split results
                    // back per original run to preserve per-span rendering styles.
                    // Build merged text and record byte ranges → original style
259
                    let total_text_len: usize = visual_items[idx..coalesce_end]
259
                        .iter()
1102
                        .map(|v| v.text.len())
259
                        .sum();
259
                    let mut merged_text = String::with_capacity(total_text_len);
                    // (byte_start, byte_end, style, source, source_node_id, marker_outside,
                    //  run_byte_offset, item_text — the logical item's shared Arc,
                    //  stamped onto each re-attributed cluster as `source_text`)
259
                    let mut byte_ranges: Vec<(
259
                        usize, usize,
259
                        Arc<StyleProperties>,
259
                        ContentIndex,
259
                        Option<NodeId>,
259
                        Option<bool>,
259
                        usize,
259
                        Arc<str>,
259
                    )> = Vec::with_capacity(coalesce_count);
1102
                    for item in &visual_items[idx..coalesce_end] {
1102
                        let start = merged_text.len();
1102
                        merged_text.push_str(&item.text);
1102
                        let end = merged_text.len();
                        if let LogicalItem::Text {
1102
                            style: s, source: src, source_node_id: nid,
1102
                            marker_position_outside: mpo, text: itext, ..
1102
                        } = &item.logical_source {
1102
                            byte_ranges.push((start, end, s.clone(), *src, *nid, *mpo, item.run_byte_offset, itext.clone()));
1102
                        }
                    }
259
                    if let Some(msgs) = debug_messages {
123
                        msgs.push(LayoutDebugMessage::info(format!(
123
                            "[TextLayout] Coalescing {} text runs ({} bytes) into single shaping call",
123
                            coalesce_count, merged_text.len()
123
                        )));
145
                    }
259
                    let direction = if bidi_level.is_rtl() {
                        BidiDirection::Rtl
                    } else {
259
                        BidiDirection::Ltr
                    };
259
                    let language = script_to_language(script, &merged_text);
                    // Shape the merged text using the first item's font (layout is identical
                    // for all coalesced items since layout_hash matches).
259
                    let shaped_clusters_result: Result<Vec<ShapedCluster>, LayoutError> = match &style.font_stack {
15
                        FontStack::Ref(font_ref) => {
15
                            shape_text_correctly(
15
                                &merged_text, script, language, direction,
15
                                font_ref, style, *source, *source_node_id,
                            )
                        }
244
                        FontStack::Stack(selectors) => {
244
                            let cache_key = FontChainKey::from_selectors(selectors);
                            let resolved_on_miss;
244
                            let font_chain = if let Some(c) = font_chain_cache.get(&cache_key) { c } else {
1
                                resolved_on_miss = resolve_chain_on_miss(&cache_key, fc_cache);
1
                                &resolved_on_miss
                            };
                            // Per-character font fallback: split text by font coverage
244
                            shape_with_font_fallback(
244
                                &merged_text, script, language, direction,
244
                                style, *source, *source_node_id,
244
                                font_chain, fc_cache, loaded_fonts,
                            )
                        }
                    };
259
                    let shaped_clusters = shaped_clusters_result?;
                    // Restore original per-span styles to each cluster based on byte position.
                    // Each ShapedCluster's source_cluster_id.start_byte_in_run is the byte
                    // offset within the merged text — we use byte_ranges to find which
                    // original run it belongs to and reassign its style, source info, etc.
11771
                    for cluster in shaped_clusters {
11512
                        let byte_pos = cluster.source_cluster_id.start_byte_in_run as usize;
                        // Find the original run this cluster's first byte falls into
99315
                        let orig = byte_ranges.iter().find(|(start, end, ..)| {
99315
                            byte_pos >= *start && byte_pos < *end
99315
                        });
11512
                        let mut cluster = cluster;
11512
                        if let Some((range_start, _, orig_style, orig_source, orig_nid, orig_mpo, orig_run_offset, orig_text)) = orig {
                            // Reassign rendering-affecting style (color, background, etc.)
11512
                            cluster.style = orig_style.clone();
11512
                            cluster.source_content_index = *orig_source;
11512
                            cluster.source_node_id = *orig_nid;
                            // Fix the byte offset to be relative to the original logical run:
                            // (position within the merged text - this run's start in the merge)
                            // + this visual run's offset within its logical run (bidi split).
11512
                            cluster.source_cluster_id.source_run = orig_source.run_index;
11512
                            cluster.source_cluster_id.start_byte_in_run = (byte_pos - range_start + *orig_run_offset) as u32;
11512
                            cluster.style = orig_style.clone();
                            // §3.2 3c: the finalized offset is item-relative, so
                            // stamp the item's shared text Arc it slices into.
11512
                            cluster.source_text = orig_text.clone();
11512
                            if let Some(is_outside) = orig_mpo {
220
                                cluster.marker_position_outside = Some(*is_outside);
11292
                            }
                        }
11512
                        shaped.push(ShapedItem::Cluster(cluster));
                    }
259
                    idx = coalesce_end;
259
                    continue;
43917
                }
                // ── SINGLE ITEM PATH (no coalescing) ──
43917
                _total_runs += 1;
43917
                _shape_calls += 1;
43917
                let direction = if item.bidi_level.is_rtl() {
39
                    BidiDirection::Rtl
                } else {
43878
                    BidiDirection::Ltr
                };
43917
                let language = script_to_language(item.script, &item.text);
                // Shape text using either FontRef directly or fontconfig-resolved font
43917
                let shaped_clusters_result: Result<Vec<ShapedCluster>, LayoutError> = match &style.font_stack {
217
                    FontStack::Ref(font_ref) => {
217
                        unsafe { crate::az_mark(0x60820_u32, 0x0000_0001_u32); } // [g121] Ref arm
                        // For FontRef, use the font directly without fontconfig
217
                        if let Some(msgs) = debug_messages {
                            msgs.push(LayoutDebugMessage::info(format!(
                                "[TextLayout] Using direct FontRef for text: '{}'",
                                item.text.chars().take(30).collect::<String>()
                            )));
217
                        }
217
                        shape_text_correctly(
217
                            &item.text,
217
                            item.script,
217
                            language,
217
                            direction,
217
                            font_ref,
217
                            style,
217
                            *source,
217
                            *source_node_id,
                        )
                    }
43700
                    FontStack::Stack(selectors) => {
43700
                        unsafe { crate::az_mark(0x60820_u32, 0x0000_0002_u32); } // [g121] Stack arm
                        // Build FontChainKey and resolve through fontconfig
43700
                        let cache_key = FontChainKey::from_selectors(selectors);
43700
                        unsafe { crate::az_mark(0x60824_u32, font_chain_cache.len() as u32); } // [g121] chain map len
                        // Look up the pre-resolved font chain. (2026-06-10: the g122
                        // by_find/by_only fallback chain is GONE — the historic miss was a
                        // KEY-CONSTRUCTION divergence (duplicated families on the query side,
                        // deduped on the store side), fixed by routing every key build through
                        // FontChainKey::from_selectors. Verified lifted: lookup path = get.)
                        let resolved_on_miss;
43700
                        let font_chain = if let Some(c) = font_chain_cache.get(&cache_key) { c } else {
33
                            if let Some(msgs) = debug_messages {
                                msgs.push(LayoutDebugMessage::info(format!(
                                    "[TextLayout] Font chain not pre-resolved for {:?} - \
                                     resolving on demand",
                                    cache_key.font_families
                                )));
33
                            }
33
                            resolved_on_miss = resolve_chain_on_miss(&cache_key, fc_cache);
33
                            &resolved_on_miss
                        };
                        // Per-character font fallback: split text by font coverage
43700
                        shape_with_font_fallback(
43700
                            &item.text, item.script, language, direction,
43700
                            style, *source, *source_node_id,
43700
                            font_chain, fc_cache, loaded_fonts,
                        )
                    }
                };
43917
                let mut shaped_clusters = shaped_clusters_result?;
                // Re-base cluster byte offsets to the logical run. Shaping produced
                // `start_byte_in_run` relative to this visual run's `text`; when bidi
                // split the logical run into several visual runs, add the visual run's
                // offset so every cluster ID is unique + matches caret byte positions.
43917
                let run_byte_offset = item.run_byte_offset as u32;
43917
                if run_byte_offset != 0 {
79
                    for cluster in &mut shaped_clusters {
58
                        cluster.source_cluster_id.start_byte_in_run = cluster
58
                            .source_cluster_id
58
                            .start_byte_in_run
58
                            .saturating_add(run_byte_offset);
58
                    }
43896
                }
                // §3.2 3c: offsets are item-relative from here on — stamp
                // the logical item's shared text Arc that `text()` slices.
43917
                if let LogicalItem::Text { text: item_text, .. } = &item.logical_source {
715198
                    for cluster in &mut shaped_clusters {
671281
                        cluster.source_text = item_text.clone();
671281
                    }
                }
                // Set marker flag on all clusters if this is a marker
43917
                if let Some(is_outside) = marker_position_outside {
                    for cluster in &mut shaped_clusters {
                        cluster.marker_position_outside = Some(*is_outside);
                    }
43917
                }
43917
                shaped.extend(shaped_clusters.into_iter().map(ShapedItem::Cluster));
            }
            // +spec:display-property:df076b - tab-size rendering and inline-level line breaking
            // "If the tab size is zero, preserved tabs are not rendered."
            // "Otherwise, each preserved tab is rendered as a horizontal shift that lines up
            //  the start edge of the next glyph with the next tab stop."
            // "Tab stops occur at points that are multiples of the tab size from the starting
            //  content edge of the preserved tab's nearest block container ancestor."
5
            LogicalItem::Tab { source, style } => {
5
                if style.tab_size == 0.0 {
1
                    // Tab size zero: tab is not rendered (zero width)
1
                    shaped.push(ShapedItem::Tab {
1
                        source: *source,
1
                        bounds: Rect {
1
                            x: 0.0,
1
                            y: 0.0,
1
                            width: 0.0,
1
                            height: 0.0,
1
                        },
1
                    });
1
                } else {
                    // TODO: use actual font's space_width via ParsedFontTrait::get_space_width()
                    // once we thread font resolution into the shaping phase for tab stops.
                    // For now, approximate space advance as 0.5 * font_size (typical for Latin fonts).
4
                    let space_advance_approx = style.font_size_px * SPACE_WIDTH_RATIO;
                    // +spec:text-alignment-spacing:5a5efd - tab-size includes letter-spacing and word-spacing
4
                    let ls = style.letter_spacing.resolve_px(style.font_size_px);
4
                    let ws = style.word_spacing.resolve_px(style.font_size_px);
                    // Tab stop interval: tab_size * (space advance + letter-spacing + word-spacing)
4
                    let tab_interval = style.tab_size * (space_advance_approx + ls + ws);
                    // Calculate current advance to find next tab stop
4
                    let current_advance: f32 = shaped.iter().map(|item| {
1
                        match item {
1
                            ShapedItem::Cluster(c) => c.advance,
                            ShapedItem::Tab { bounds, .. } => bounds.width,
                            ShapedItem::Object { bounds, .. } => bounds.width,
                            _ => 0.0,
                        }
4
                    }).sum();
                    // Next tab stop = next multiple of tab_interval from content edge
4
                    let next_tab_stop = ((current_advance / tab_interval).floor() + 1.0) * tab_interval;
4
                    let mut tab_width = next_tab_stop - current_advance;
                    // "If this distance is less than 0.5ch, then the subsequent tab stop is used instead."
4
                    let half_ch = space_advance_approx * 0.5;
4
                    if tab_width < half_ch {
                        tab_width += tab_interval;
4
                    }
4
                    shaped.push(ShapedItem::Tab {
4
                        source: *source,
4
                        bounds: Rect {
4
                            x: 0.0,
4
                            y: 0.0,
4
                            width: tab_width,
4
                            height: 0.0,
4
                        },
4
                    });
                }
            }
            LogicalItem::Ruby {
                source,
                base_text,
                ruby_text,
                style,
            } => {
                // CSS Ruby Layout (§3): the annotation (ruby-text) is laid out at its used
                // `font-size` — the UA default is `RUBY_ANNOTATION_FONT_SCALE` of the base —
                // and centered over the base, with the ruby box reserving the WIDER of the
                // two inline-sizes and stacking the annotation line above the base line.
                //
                // Both the base and the annotation are shaped to obtain their REAL inline
                // advances (no `chars * font_size * 0.6` fudge). The annotation is shaped at
                // the scaled style so its width reflects the smaller glyphs.
                let base_font_size = style.font_size_px;
                let annotation_font_size = base_font_size * RUBY_ANNOTATION_FONT_SCALE;
                let mut annotation_props = (**style).clone();
                annotation_props.font_size_px = annotation_font_size;
                let annotation_style = Arc::new(annotation_props);
                // Fallback estimate (only when shaping fails / no font chain): 1em per char
                // is a closer CJK approximation than the old 0.6 ratio.
                let base_width = measure_run_advance(
                    base_text, style, item.script, *source, font_chain_cache, fc_cache,
                    loaded_fonts,
                )
                .unwrap_or_else(|| base_text.chars().count() as f32 * base_font_size);
                let annotation_width = measure_run_advance(
                    ruby_text, &annotation_style, item.script, *source, font_chain_cache,
                    fc_cache, loaded_fonts,
                )
                .unwrap_or_else(|| ruby_text.chars().count() as f32 * annotation_font_size);
                let base_line_height =
                    style.line_height.resolve(base_font_size, 0.0, 0.0, 0.0, 0);
                let annotation_line_height = annotation_style.line_height.resolve(
                    annotation_font_size, 0.0, 0.0, 0.0, 0,
                );
                // The ruby box reserves the wider inline-size, and stacks the annotation
                // line (at its smaller font-size) above the base line.
                let (reserved_width, reserved_height) = ruby_reserved_box(
                    base_width,
                    annotation_width,
                    base_line_height,
                    annotation_line_height,
                );
                // TODO2: the annotation glyphs are now correctly sized + reserve vertical
                // space above the base, but are not yet emitted as a separately positioned
                // (centered) run — `ShapedItem::Object` carries only the base `StyledRun`.
                // Rendering the centered annotation needs a ruby-aware `ShapedItem` variant
                // (rendering-structural change); deferred to keep this change layout-safe.
                shaped.push(ShapedItem::Object {
                    source: *source,
                    bounds: Rect {
                        x: 0.0,
                        y: 0.0,
                        width: reserved_width,
                        height: reserved_height,
                    },
                    baseline_offset: 0.0,
                    content: InlineContent::Text(StyledRun {
                        text: Arc::from(base_text.as_str()),
                        style: style.clone(),
                        logical_start_byte: 0,
                        source_node_id: None,
                    }),
                });
            }
            LogicalItem::CombinedText {
                style,
                source,
                text,
            } => {
                let language = script_to_language(item.script, &item.text);
                // +spec:width-calculation:657f75 - convert full-width chars to non-full-width before compression
                // +spec:width-calculation:d0a295 - full-width digit conversion example (e.g. "23" stays narrow)
                // When combined text has more than one typographic character unit,
                // full-width characters (U+FF01..U+FF5E) are converted to their
                // ASCII equivalents (U+0021..U+007E) before compression.
                let text = if text.chars().count() > 1 {
                    let converted: String = text.chars().map(|c| {
                        let cp = c as u32;
                        if (0xFF01..=0xFF5E).contains(&cp) {
                            // Reverse of text-transform: full-width
                            char::from_u32(cp - 0xFF01 + 0x0021).unwrap_or(c)
                        } else {
                            c
                        }
                    }).collect();
                    converted
                } else {
                    text.clone()
                };
                // +spec:width-calculation:1ed84d - OpenType compression (half-width/third-width substitution)
                // is delegated to the font shaping layer via shape_text()
                // Shape CombinedText using either FontRef directly or fontconfig-resolved font
                let glyphs: Vec<Glyph> = match &style.font_stack {
                    FontStack::Ref(font_ref) => {
                        // For FontRef, use the font directly without fontconfig
                        if let Some(msgs) = debug_messages {
                            msgs.push(LayoutDebugMessage::info(format!(
                                "[TextLayout] Using direct FontRef for CombinedText: '{}'",
                                text.chars().take(30).collect::<String>()
                            )));
                        }
                        font_ref.shape_text(
                            &text,
                            item.script,
                            language,
                            BidiDirection::Ltr,
                            style.as_ref(),
                        )?
                    }
                    FontStack::Stack(selectors) => {
                        // Build FontChainKey and resolve through fontconfig
                        let cache_key = FontChainKey::from_selectors(selectors);
                        let resolved_on_miss;
                        let font_chain = if let Some(c) = font_chain_cache.get(&cache_key) { c } else {
                            resolved_on_miss = resolve_chain_on_miss(&cache_key, fc_cache);
                            &resolved_on_miss
                        };
                        // Per-character font fallback for CombinedText
                        let segments = split_text_by_font_coverage(&text, font_chain, fc_cache, loaded_fonts);
                        let mut all_glyphs = Vec::new();
                        for (seg_start, seg_end, font_id) in &segments {
                            let Some(font) = loaded_fonts.get(font_id) else { continue; };
                            let segment_text = &text[*seg_start..*seg_end];
                            let mut seg_glyphs = font.shape_text(
                                segment_text,
                                item.script,
                                language,
                                BidiDirection::Ltr,
                                style.as_ref(),
                            )?;
                            // Fix byte offsets for glyphs
                            if *seg_start > 0 {
                                for g in &mut seg_glyphs {
                                    g.logical_byte_index += *seg_start;
                                    g.cluster += *seg_start as u32;
                                }
                            }
                            all_glyphs.extend(seg_glyphs);
                        }
                        if all_glyphs.is_empty() {
                            idx += 1;
                            continue;
                        }
                        all_glyphs
                    }
                };
                let shaped_glyphs: ShapedGlyphVec = glyphs
                    .into_iter()
                    .map(|g| ShapedGlyph {
                        kind: GlyphKind::Character,
                        glyph_id: g.glyph_id,
                        script: g.script,
                        font_hash: g.font_hash,
                        font_metrics: g.font_metrics,
                        cluster_offset: 0,
                        advance: g.advance,
                        kerning: g.kerning,
                        offset: g.offset,
                        vertical_advance: g.vertical_advance,
                        vertical_offset: g.vertical_bearing,
                    })
                    .collect();
                // +spec:block-formatting-context:dc4549 - text-combine-upright compression: UA may scale composition to match 水 advance height
                let total_width: f32 = shaped_glyphs.iter().map(|g| g.advance + g.kerning).sum();
                // +spec:inline-formatting-context:8c5969 - text-combine-upright baseline centering
                // The composition forms a 1em square. Per spec, its baseline must be
                // chosen so the square is centered between the text-over and text-under
                // baselines of the parent inline box. We approximate by using font_size
                // as the square height and centering it (baseline_offset = em_size / 2).
                let em_size = style.font_size_px;
                let bounds = Rect {
                    x: 0.0,
                    y: 0.0,
                    width: total_width,
                    height: em_size,
                };
                shaped.push(ShapedItem::CombinedBlock {
                    style: style.clone(),
                    source: *source,
                    glyphs: shaped_glyphs,
                    bounds,
                    baseline_offset: em_size / 2.0,
                });
            }
            LogicalItem::Object {
599
                content, source, ..
            } => {
599
                let (bounds, baseline) = measure_inline_object(content)?;
599
                shaped.push(ShapedItem::Object {
599
                    source: *source,
599
                    bounds,
599
                    baseline_offset: baseline,
599
                    content: content.clone(),
599
                });
            }
33
            LogicalItem::Break { source, break_info } => {
33
                shaped.push(ShapedItem::Break {
33
                    source: *source,
33
                    break_info: *break_info,
33
                });
33
            }
        }
44554
        idx += 1;
    }
44774
    Ok(shaped)
44774
}
/// Returns true if `c` is a hanging punctuation stop or comma per CSS Text 3 §8.2.1.
// +spec:hanging-punctuation - full stop/comma character list per CSS Text 3 §8.2.1
11
const fn is_hanging_punctuation_char(c: char) -> bool {
11
    matches!(c,
        ','      | // U+002C COMMA
        '.'      | // U+002E FULL STOP
        '\u{060C}' | // ARABIC COMMA
        '\u{06D4}' | // ARABIC FULL STOP
        '\u{3001}' | // IDEOGRAPHIC COMMA
        '\u{3002}' | // IDEOGRAPHIC FULL STOP
        '\u{FF0C}' | // FULLWIDTH COMMA
        '\u{FF0E}' | // FULLWIDTH FULL STOP
        '\u{FE50}' | // SMALL COMMA
        '\u{FE51}' | // SMALL IDEOGRAPHIC COMMA
        '\u{FE52}' | // SMALL FULL STOP
        '\u{FF61}' | // HALFWIDTH IDEOGRAPHIC FULL STOP
        '\u{FF64}'   // HALFWIDTH IDEOGRAPHIC COMMA
    )
11
}
/// Helper to check if a cluster contains only hanging punctuation.
// +spec:box-model:8bbcd1 - non-zero inline-axis borders/padding between hangable glyph and line edge prevent hanging
/// +spec:inline-formatting-context:135be2 - hanging punctuation placed outside the line box
/// +spec:intrinsic-sizing:407d8b - hanging glyphs not counted in intrinsic size computation
6
fn is_hanging_punctuation(item: &ShapedItem) -> bool {
6
    if let ShapedItem::Cluster(c) = item {
5
        if c.glyphs.len() == 1 {
4
            c.text().chars().next().is_some_and(is_hanging_punctuation_char)
        } else {
1
            false
        }
    } else {
1
        false
    }
6
}
#[allow(clippy::cast_possible_truncation)] // bounded pixel/coord/colour/glyph cast
#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
44212
fn shape_text_correctly<T: ParsedFontTrait>(
44212
    text: &str,
44212
    script: Script,
44212
    language: Language,
44212
    direction: BidiDirection,
44212
    font: &T, // Changed from &Arc<T>
44212
    style: &Arc<StyleProperties>,
44212
    source_index: ContentIndex,
44212
    source_node_id: Option<NodeId>,
44212
) -> Result<Vec<ShapedCluster>, LayoutError> {
44212
    unsafe { crate::az_mark(0x60864_u32, 0xC0DE_0864_u32); } // [g123] shape_text_correctly ENTERED
44212
    let glyphs = font.shape_text(text, script, language, direction, style.as_ref())?;
44212
    unsafe { crate::az_mark(0x60868_u32, (glyphs.len() as u32) | 0x8000_0000_u32); } // [g123] font.shape_text returned (high bit set); low bits = glyph count
44212
    if glyphs.is_empty() {
        return Ok(Vec::new());
44212
    }
44212
    let mut clusters = Vec::new();
    // Group glyphs by cluster ID from the shaper.
44212
    let mut current_cluster_glyphs = Vec::new();
44212
    let mut cluster_id = glyphs[0].cluster;
44212
    let mut cluster_start_byte_in_text = glyphs[0].logical_byte_index;
727005
    for glyph in glyphs {
682793
        if glyph.cluster != cluster_id {
            // Finalize previous cluster
638581
            let advance = current_cluster_glyphs
638581
                .iter()
638581
                .map(|g: &Glyph| g.advance)
638581
                .sum();
            // Safely extract cluster text - handle cases where byte indices may be out of order
            // (can happen with RTL text or complex GSUB reordering)
638581
            let (start, end) = if cluster_start_byte_in_text <= glyph.logical_byte_index {
638581
                (cluster_start_byte_in_text, glyph.logical_byte_index)
            } else {
                (glyph.logical_byte_index, cluster_start_byte_in_text)
            };
638581
            let cluster_text = text.get(start..end).unwrap_or("");
638581
            clusters.push(ShapedCluster {
638581
                flags: ClusterFlags::classify(cluster_text),
                // §3.2 3c: placeholder — the shaping loop stamps the real
                // item Arc after the bidi re-base; the LENGTH is final now.
638581
                source_text: empty_arc_str(),
638581
                source_byte_len: u16::try_from(cluster_text.len()).unwrap_or(u16::MAX),
638581
                source_cluster_id: GraphemeClusterId {
638581
                    source_run: source_index.run_index,
638581
                    start_byte_in_run: cluster_id,
638581
                },
638581
                source_content_index: source_index,
638581
                source_node_id,
638581
                glyphs: current_cluster_glyphs
638581
                    .iter()
638581
                    .map(|g| {
                        // Calculate cluster_offset safely
638581
                        let cluster_offset = if g.logical_byte_index >= cluster_start_byte_in_text {
638581
                            (g.logical_byte_index - cluster_start_byte_in_text) as u32
                        } else {
                            0
                        };
                        ShapedGlyph {
638581
                            kind: if g.glyph_id == 0 {
28
                                GlyphKind::NotDef
                            } else {
638553
                                GlyphKind::Character
                            },
638581
                            glyph_id: g.glyph_id,
638581
                            script: g.script,
638581
                            font_hash: g.font_hash,
638581
                            font_metrics: g.font_metrics,
638581
                            cluster_offset,
638581
                            advance: g.advance,
638581
                            kerning: g.kerning,
638581
                            vertical_advance: g.vertical_advance,
638581
                            vertical_offset: g.vertical_bearing,
638581
                            offset: g.offset,
                        }
638581
                    })
638581
                    .collect(),
638581
                advance,
638581
                direction,
638581
                style: style.clone(),
638581
                marker_position_outside: None,
                is_first_fragment: true,
                is_last_fragment: true,
            });
638581
            current_cluster_glyphs.clear();
638581
            cluster_id = glyph.cluster;
638581
            cluster_start_byte_in_text = glyph.logical_byte_index;
44212
        }
682793
        current_cluster_glyphs.push(glyph);
    }
    // Finalize the last cluster
44212
    if !current_cluster_glyphs.is_empty() {
44212
        let advance = current_cluster_glyphs
44212
            .iter()
44212
            .map(|g: &Glyph| g.advance)
44212
            .sum();
44212
        let cluster_text = text.get(cluster_start_byte_in_text..).unwrap_or("");
44212
        clusters.push(ShapedCluster {
44212
            flags: ClusterFlags::classify(cluster_text),
            // §3.2 3c: placeholder — stamped by the shaping loop (see above).
44212
            source_text: empty_arc_str(),
44212
            source_byte_len: u16::try_from(cluster_text.len()).unwrap_or(u16::MAX),
44212
            source_cluster_id: GraphemeClusterId {
44212
                source_run: source_index.run_index,
44212
                start_byte_in_run: cluster_id,
44212
            },
44212
            source_content_index: source_index,
44212
            source_node_id,
44212
            glyphs: current_cluster_glyphs
44212
                .iter()
44212
                .map(|g| {
                    // Calculate cluster_offset safely
44212
                    let cluster_offset = if g.logical_byte_index >= cluster_start_byte_in_text {
44212
                        (g.logical_byte_index - cluster_start_byte_in_text) as u32
                    } else {
                        0
                    };
                    ShapedGlyph {
44212
                        kind: if g.glyph_id == 0 {
3
                            GlyphKind::NotDef
                        } else {
44209
                            GlyphKind::Character
                        },
44212
                        glyph_id: g.glyph_id,
44212
                        font_hash: g.font_hash,
44212
                        font_metrics: g.font_metrics,
44212
                        script: g.script,
44212
                        vertical_advance: g.vertical_advance,
44212
                        vertical_offset: g.vertical_bearing,
44212
                        cluster_offset,
44212
                        advance: g.advance,
44212
                        kerning: g.kerning,
44212
                        offset: g.offset,
                    }
44212
                })
44212
                .collect(),
44212
            advance,
44212
            direction,
44212
            style: style.clone(),
44212
            marker_position_outside: None,
            is_first_fragment: true,
            is_last_fragment: true,
        });
    }
44212
    Ok(clusters)
44212
}
/// Measures a non-text object, returning its bounds and baseline offset.
783
fn measure_inline_object(item: &InlineContent) -> Result<(Rect, f32), LayoutError> {
783
    match item {
        InlineContent::Image(img) => {
            let size = img.display_size.unwrap_or(img.intrinsic_size);
            Ok((
                Rect {
                    x: 0.0,
                    y: 0.0,
                    width: size.width,
                    height: size.height,
                },
                img.baseline_offset,
            ))
        }
783
        InlineContent::Shape(shape) => Ok({
783
            let size = shape.shape_def.get_size();
783
            (
783
                Rect {
783
                    x: 0.0,
783
                    y: 0.0,
783
                    width: size.width,
783
                    height: size.height,
783
                },
783
                shape.baseline_offset,
783
            )
783
        }),
        InlineContent::Space(space) => Ok((
            Rect {
                x: 0.0,
                y: 0.0,
                width: space.width,
                height: 0.0,
            },
            0.0,
        )),
        InlineContent::Marker { .. } => {
            // Markers are treated as text content, not measurable objects
            Err(LayoutError::InvalidText(
                "Marker is text content, not a measurable object".into(),
            ))
        }
        _ => Err(LayoutError::InvalidText("Not a measurable object".into())),
    }
783
}
// --- Stage 4 Implementation: Vertical Text ---
/// Applies orientation and vertical metrics to glyphs if the writing mode is vertical.
// +spec:block-formatting-context:227171 - vertical glyph orientation with fallback vertical metrics
// +spec:block-formatting-context:df20a5 - mixed vertical orientation dispatch (TextOrientation::Mixed)
236816
fn apply_text_orientation(
236816
    items: Arc<Vec<ShapedItem>>,
236816
    constraints: &UnifiedConstraints,
236816
) -> Arc<Vec<ShapedItem>> {
236816
    if !constraints.is_vertical() {
236807
        return items;
9
    }
9
    let mut oriented_items = Vec::with_capacity(items.len());
9
    let writing_mode = constraints.writing_mode.unwrap_or_default();
54
    for item in items.iter() {
54
        match item {
54
            ShapedItem::Cluster(cluster) => {
54
                let mut new_cluster = cluster.clone();
54
                let mut total_vertical_advance = 0.0;
108
                for glyph in &mut new_cluster.glyphs {
                    // Use the vertical metrics already computed during shaping
                    // If they're zero, use fallback values
54
                    if glyph.vertical_advance > 0.0 {
54
                        total_vertical_advance += glyph.vertical_advance;
54
                    } else {
                        // Fallback: use line height for vertical advance
                        let fallback_advance = cluster.style.line_height.resolve_with_metrics(cluster.style.font_size_px, &glyph.font_metrics);
                        glyph.vertical_advance = fallback_advance;
                        // Center the glyph horizontally as a fallback
                        glyph.vertical_offset = Point {
                            x: -glyph.advance / 2.0,
                            y: 0.0,
                        };
                        total_vertical_advance += fallback_advance;
                    }
                }
                // The cluster's `advance` now represents vertical advance.
54
                new_cluster.advance = total_vertical_advance;
54
                oriented_items.push(ShapedItem::Cluster(new_cluster));
            }
            // Non-text objects also need their advance axis swapped.
            ShapedItem::Object {
                source,
                bounds,
                baseline_offset,
                content,
            } => {
                let mut new_bounds = *bounds;
                std::mem::swap(&mut new_bounds.width, &mut new_bounds.height);
                oriented_items.push(ShapedItem::Object {
                    source: *source,
                    bounds: new_bounds,
                    baseline_offset: *baseline_offset,
                    content: content.clone(),
                });
            }
            _ => oriented_items.push(item.clone()),
        }
    }
9
    Arc::new(oriented_items)
236816
}
// --- Stage 5 & 6 Implementation: Combined Layout Pass ---
// This section replaces the previous simple line breaking and positioning logic.
/// Extracts the per-item vertical-align from a `ShapedItem`.
///
/// For `Object` items (inline-blocks, images), this returns the alignment stored
/// in the original `InlineContent`. For text clusters and other items, returns `None`
/// to indicate the global `constraints.vertical_align` should be used.
6810130
fn get_item_vertical_align(item: &ShapedItem) -> Option<VerticalAlign> {
6810130
    match item {
812
        ShapedItem::Object { content, .. } => match content {
1
            InlineContent::Image(img) => Some(img.alignment),
810
            InlineContent::Shape(shape) => Some(shape.alignment),
1
            _ => None,
        },
        // A text cluster carries its span's vertical-align on its style. A non-baseline
        // value (sub / super / length / percentage on an inline <span>) overrides the
        // line's default alignment so the cluster is shifted; baseline yields None so the
        // cluster keeps the line/IFC default.
6808777
        ShapedItem::Cluster(c) => match c.style.vertical_align {
6808561
            VerticalAlign::Baseline => None,
216
            va => Some(va),
        },
541
        _ => None,
    }
6810130
}
/// Approximate version of `get_item_vertical_metrics` for use without constraints (e.g. `bounds()`).
/// Uses 80/20 ascent/descent ratio as fallback for empty-glyph strut case.
#[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
9990496
#[must_use] pub fn get_item_vertical_metrics_approx(item: &ShapedItem) -> (f32, f32) {
    // For non-empty clusters, delegate to the font-metrics-based calculation
9990496
    if let ShapedItem::Cluster(c) = item {
9989560
        if !c.glyphs.is_empty() {
            // Reuse the glyph-based calculation (same as get_item_vertical_metrics)
9988030
            let (asc, desc) = c.glyphs
9988030
                .iter()
9998055
                .fold((0.0f32, 0.0f32), |(max_asc, max_desc), glyph| {
9998055
                    let metrics = &glyph.font_metrics;
9998055
                    if metrics.units_per_em == 0 {
10146
                        return (max_asc, max_desc);
9987909
                    }
9987909
                    let scale = c.style.font_size_px / f32::from(metrics.units_per_em);
9987909
                    let font_ascent = metrics.ascent * scale;
9987909
                    let font_descent = (-metrics.descent * scale).max(0.0);
9987909
                    let ad = font_ascent + font_descent;
9987909
                    let resolved_lh = c.style.line_height.resolve_with_metrics(c.style.font_size_px, &glyph.font_metrics);
9987909
                    let half_leading = (resolved_lh - ad) / 2.0;
9987909
                    (max_asc.max(font_ascent + half_leading), max_desc.max(font_descent + half_leading))
9998055
                });
9988030
            return (asc, desc);
1530
        }
936
    }
    // Fallback for empty glyphs or non-cluster items
2466
    match item {
1530
        ShapedItem::Cluster(c) => {
1530
            let lh = c.style.line_height.resolve(c.style.font_size_px, 0.0, 0.0, 0.0, 0);
1530
            (lh * FALLBACK_ASCENT_RATIO, lh * FALLBACK_DESCENT_RATIO)
        }
5
        ShapedItem::CombinedBlock { bounds, .. } => {
5
            (bounds.height * FALLBACK_ASCENT_RATIO, bounds.height * FALLBACK_DESCENT_RATIO)
        }
786
        ShapedItem::Object { bounds, .. } => (bounds.height, 0.0),
33
        ShapedItem::Tab { bounds, .. } => {
33
            (bounds.height * FALLBACK_ASCENT_RATIO, bounds.height * FALLBACK_DESCENT_RATIO)
        }
112
        ShapedItem::Break { .. } => (0.0, 0.0),
    }
9990496
}
/// Gets the ascent (distance from baseline to top) and descent (distance from baseline to bottom)
/// for a single item, incorporating half-leading from line-height.
///
// +spec:box-model:37aeb2 - inline box margins/borders/padding do not affect line box height (leading model)
// +spec:display-property:184f0d - Inline box baseline derives from first available font metrics
// +spec:display-property:238bf5 - Inline box layout bounds from own text metrics, not child boxes
// +spec:display-property:29b194 - baseline determination for inline boxes (CSS Box Alignment 3 §9.1)
// +spec:display-property:2987db - per-glyph font metrics impact inline box layout bounds (line-height: normal caveat not yet distinguished)
/// +spec:display-property:fd42a9 - line-height affects line box contribution, not inline box size
// +spec:font-metrics:506abb - A/D from font metrics with half-leading: L = line-height - (A+D), A' = A + L/2, D' = D + L/2
// +spec:font-metrics:773029 - ascent/descent font metrics used for baseline calculations (visual centering depends on these)
// +spec:font-metrics:f42870 - half-leading model: leading = line-height - (ascent + descent), distributed equally above/below
// +spec:writing-modes:531c2e - UAs should use vertical baseline tables in vertical typographic modes
4527030
#[must_use] pub fn get_item_vertical_metrics(item: &ShapedItem, constraints: &UnifiedConstraints) -> (f32, f32) {
    // (ascent, descent)
4527030
    match item {
4526128
        ShapedItem::Cluster(c) => {
4526128
            if c.glyphs.is_empty() {
                // +spec:display-property:626c86 - strut for inline box with no glyphs uses first available font metrics
                // +spec:line-height:0078fa - strut: zero-width inline box with element's font/line-height
                // §10.8.1 strut: if inline box contains no glyphs, it is considered to
                // contain a strut with A and D of the element's first available font.
                // Half-leading: L = line-height - (A + D), A' = A + L/2, D' = D + L/2
1
                let ad = constraints.strut_ascent + constraints.strut_descent;
1
                let resolved_lh = c.style.line_height.resolve(c.style.font_size_px, 0.0, 0.0, 0.0, 0);
1
                let half_leading = (resolved_lh - ad) / 2.0;
1
                return (constraints.strut_ascent + half_leading, constraints.strut_descent + half_leading);
4526127
            }
            // +spec:box-model:0b3e1f - inline non-replaced box height uses only line-height, not vertical padding/border/margin
            // +spec:display-property:80b900 - fallback glyphs affect line box size via per-glyph metrics
            // +spec:display-property:d52f26 - layout bounds enclose all glyphs from highest A to deepest D
            // +spec:font-metrics:387751 - content area uses max ascenders/descenders across all fonts
            // +spec:font-metrics:790fd2 - half-leading: L = line-height - (A+D), A' = A + L/2, D' = D + L/2
            // +spec:line-height:1ae6f5 - line-height on non-replaced inline: half-leading model
            // +spec:line-height:0078fa - half-leading: L = line-height - (A+D), distributed equally above/below
            // +spec:line-height:32b3da - half-leading: L = line-height - AD, A' = A + L/2, D' = D + L/2
            // §10.8.1: for each glyph determine A, D from font metrics,
            // then L = line-height - (A + D), and adjust: A' = A + L/2, D' = D + L/2.
            // Note: L may be negative.
            // +spec:height-calculation:eb98b5 - multi-font normal line-height uses max across glyph metrics
4526127
            c.glyphs
4526127
                .iter()
4526127
                .fold((0.0f32, 0.0f32), |(max_asc, max_desc), glyph| {
4526127
                    let metrics = &glyph.font_metrics;
4526127
                    if metrics.units_per_em == 0 {
                        return (max_asc, max_desc);
4526127
                    }
4526127
                    let scale = c.style.font_size_px / f32::from(metrics.units_per_em);
4526127
                    let a = metrics.ascent * scale;
                    // Descent in OpenType is typically negative, so we negate it to get a positive
                    // distance.
4526127
                    let d = (-metrics.descent * scale).max(0.0);
4526127
                    let ad = a + d;
4526127
                    let resolved_lh = c.style.line_height.resolve_with_metrics(c.style.font_size_px, &glyph.font_metrics);
4526127
                    let leading = resolved_lh - ad;
4526127
                    let half_leading = leading / 2.0;
4526127
                    let item_asc = a + half_leading;
4526127
                    let item_desc = d + half_leading;
4526127
                    (max_asc.max(item_asc), max_desc.max(item_desc))
4526127
                })
        }
        ShapedItem::Object {
541
            bounds,
541
            baseline_offset,
            ..
        } => {
            // Per analysis, `baseline_offset` is the distance from the bottom.
            // bounds.height already includes margins (set from margin_box_height in fc.rs)
541
            let ascent = bounds.height - *baseline_offset;
541
            let descent = *baseline_offset;
541
            (ascent.max(0.0), descent.max(0.0))
        }
        ShapedItem::CombinedBlock {
            bounds,
            baseline_offset,
            ..
        } => {
            // CORRECTED: Treat baseline_offset consistently as distance from the bottom (descent).
            let ascent = bounds.height - *baseline_offset;
            let descent = *baseline_offset;
            (ascent.max(0.0), descent.max(0.0))
        }
361
        _ => (0.0, 0.0), // Breaks and other non-visible items don't affect line height.
    }
4527030
}
// +spec:block-formatting-context:861155 - vertical-align affects vertical positioning inside line box for inline-level elements
/// Calculates the maximum ascent and descent for an entire line of items.
/// This determines the "line box" used for vertical alignment.
/// // +spec:display-contents:66d910 - line box height fitted to contents, controlled by line-height
// +spec:inline-formatting-context:c3fc54 - line box tall enough for all boxes, vertical-align determines alignment within line box
///
/// Per CSS 2.2 §10.8: Inline-level boxes aligned 'top' or 'bottom' must be aligned
/// so as to minimize the line box height. The algorithm is:
/// 1. First pass: compute line box height from baseline-aligned items only
///    (baseline, sub, super, middle, text-top, text-bottom, offset).
/// 2. Second pass: check if any top/bottom-aligned items are taller than the
///    line box from pass 1, and expand if necessary.
// +spec:box-model:c9bcd7 - when line-fit-edge is not leading, layout bounds inflated by margin+border+padding (not yet implemented; default leading behavior is correct)
176939
fn calculate_line_metrics(
176939
    items: &[ShapedItem],
176939
    default_vertical_align: VerticalAlign,
176939
    constraints: &UnifiedConstraints,
176939
) -> (f32, f32) {
    // +spec:font-metrics:95152b - baseline alignment: items with different font sizes aligned by matching alphabetic baselines
    // Pass 1: Compute ascent/descent from baseline-aligned items only
    // (i.e., items that are NOT vertical-align: top or bottom).
176939
    let (mut max_asc, mut max_desc) = items
176939
        .iter()
2283099
        .fold((0.0f32, 0.0f32), |(max_asc, max_desc), item| {
2283099
            let effective_align = get_item_vertical_align(item)
2283099
                .unwrap_or(default_vertical_align);
2283099
            match effective_align {
                VerticalAlign::Top | VerticalAlign::Bottom => {
                    // Skip top/bottom items in first pass
                    (max_asc, max_desc)
                }
                _ => {
2283099
                    let (item_asc, item_desc) = get_item_vertical_metrics(item, constraints);
2283099
                    (max_asc.max(item_asc), max_desc.max(item_desc))
                }
            }
2283099
        });
176939
    let baseline_line_height = max_asc + max_desc;
    // Pass 2: Check top/bottom aligned items. If any of them is taller
    // than the current line box, expand the line box to fit.
2460038
    for item in items {
2283099
        let effective_align = get_item_vertical_align(item)
2283099
            .unwrap_or(default_vertical_align);
2283099
        match effective_align {
            VerticalAlign::Top | VerticalAlign::Bottom => {
                let (item_asc, item_desc) = get_item_vertical_metrics(item, constraints);
                let item_height = item_asc + item_desc;
                if item_height > baseline_line_height {
                    // To minimize height, expand in the direction the item is aligned to
                    if effective_align == VerticalAlign::Top {
                        // Top-aligned item extends downward from line top
                        max_desc = max_desc.max(item_height - max_asc);
                    } else {
                        // Bottom-aligned item extends upward from line bottom
                        max_asc = max_asc.max(item_height - max_desc);
                    }
                }
            }
2283099
            _ => {} // Already handled in first pass
        }
    }
176939
    (max_asc, max_desc)
176939
}
/// Unicode Bidi Algorithm rule L2, applied at the glyph/cluster level for one line.
///
/// `reorder_logical_items` already placed the level RUNS of the paragraph in
/// visual order (rule L2 at the run level, via `unicode_bidi::visual_runs`), but
/// left the clusters *within* each run in LOGICAL order. To finish L2 the clusters
/// of every RTL (odd-level) run must be reversed so the run reads right-to-left.
///
/// We reverse each maximal contiguous run of clusters that share the same
/// direction, flipping only the RTL ones. Re-running full L2 over the whole line
/// instead would double-reverse the run order that is already correct. Grouping
/// by direction is exact here: under implicit bidi (no explicit embedding
/// controls, which azul does not inject) two runs of the same direction are never
/// visually adjacent — a higher even level nests inside its odd parent and a lower
/// level separates two same-parity runs — so a "same-direction" group is always a
/// single real level run. Non-cluster items (breaks/objects/tabs) act as run
/// boundaries. Applied per line, so a wrapped RTL run reorders correctly per line.
176930
fn apply_l2_visual_reversal(line_items: &mut [ShapedItem]) {
2283387
    let dir_of = |it: &ShapedItem| it.as_cluster().map(|c| c.direction);
176930
    let mut i = 0;
354265
    while i < line_items.len() {
177335
        let Some(dir) = dir_of(&line_items[i]) else {
450
            i += 1;
450
            continue;
        };
176885
        let mut j = i + 1;
2282622
        while j < line_items.len() && dir_of(&line_items[j]) == Some(dir) {
2105737
            j += 1;
2105737
        }
176885
        if dir == BidiDirection::Rtl {
234
            line_items[i..j].reverse();
176651
        }
176885
        i = j;
    }
176930
}
/// Performs layout for a single fragment, consuming items from a `BreakCursor`.
///
/// This function contains the core line-breaking and positioning logic, but is
/// designed to operate on a portion of a larger content stream and within the
/// constraints of a single geometric area (a fragment).
///
/// The loop terminates when either the fragment is filled (e.g., runs out of
/// vertical space) or the content stream managed by the `cursor` is exhausted.
///
/// # CSS Inline Layout Module Level 3 Implementation
///
/// This function implements the inline formatting context as described in:
/// <https://www.w3.org/TR/css-inline-3/#inline-formatting-context>
///
/// ## § 2.1 Layout of Line Boxes
/// "In general, the line-left edge of a line box touches the line-left edge of its
/// containing block and the line-right edge touches the line-right edge of its
/// containing block, and thus the logical width of a line box is equal to the inner
/// logical width of its containing block."
///
/// [ISSUE] `available_width` should be set to the containing block's inner width,
/// but is currently defaulting to 0.0 in `UnifiedConstraints::default()`.
/// This causes premature line breaking.
///
/// ## § 2.2 Layout Within Line Boxes
/// The layout process follows these steps:
/// 1. Baseline Alignment: All inline-level boxes are aligned by their baselines
/// 2. Content Size Contribution: Calculate layout bounds for each box
/// 3. Line Box Sizing: Size line box to fit aligned layout bounds
/// 4. Content Positioning: Position boxes within the line box
///
/// ## Missing Features:
/// - § 3 Baselines and Alignment Metrics: Only basic baseline alignment implemented
/// - § 4 Baseline Alignment: vertical-align property not fully supported
/// - § 5 Line Spacing: line-height implemented, but line-fit-edge missing
/// - § 6 Trimming Leading: text-box-trim not implemented
#[allow(clippy::cast_precision_loss)] // bounded pixel/coord/colour/glyph cast
#[allow(clippy::too_many_lines, clippy::cognitive_complexity)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
/// # Errors
///
/// Returns a `LayoutError` if fragment layout fails.
144518
pub fn perform_fragment_layout<T: ParsedFontTrait>(
144518
    cursor: &mut BreakCursor<'_>,
144518
    logical_items: &[LogicalItem],
144518
    fragment_constraints: &UnifiedConstraints,
144518
    debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
144518
    fonts: &LoadedFonts<T>,
144518
) -> Result<UnifiedLayout, LayoutError> {
    const MAX_EMPTY_SEGMENTS: usize = 1000; // Maximum allowed consecutive empty segments
144518
    if let Some(msgs) = debug_messages {
138012
        msgs.push(LayoutDebugMessage::info(
138012
            "\n--- Entering perform_fragment_layout ---".to_string(),
138012
        ));
138012
        msgs.push(LayoutDebugMessage::info(format!(
138012
            "Constraints: available_width={:?}, available_height={:?}, columns={}, text_wrap={:?}",
138012
            fragment_constraints.available_width,
138012
            fragment_constraints.available_height,
138012
            fragment_constraints.columns,
138012
            fragment_constraints.text_wrap
138012
        )));
138012
    }
    // For TextWrap::Balance, use Knuth-Plass algorithm for optimal line breaking
    // This produces more visually balanced lines at the cost of more computation
144518
    if fragment_constraints.text_wrap == TextWrap::Balance {
        if let Some(msgs) = debug_messages {
            msgs.push(LayoutDebugMessage::info(
                "Using Knuth-Plass algorithm for text-wrap: balance".to_string(),
            ));
        }
        // Get the shaped items from the cursor
        let shaped_items: Vec<ShapedItem> = cursor.drain_remaining();
        // +spec:line-breaking:90c1bd - only auto-hyphenate when language is known and hyphenation resource available
        let hyphenator = if fragment_constraints.hyphenation == Hyphens::Auto {
            fragment_constraints
                .hyphenation_language
                .and_then(|lang| get_hyphenator(lang).ok())
        } else {
            None
        };
        // Use the Knuth-Plass algorithm for optimal line breaking
        return Ok(crate::text3::knuth_plass::kp_layout(
            &shaped_items,
            logical_items,
            fragment_constraints,
            hyphenator.as_ref(),
            fonts,
        ));
144518
    }
    // +spec:intrinsic-sizing:57e02d - hyphenation opportunities considered in min-content sizing
144518
    let hyphenator = if fragment_constraints.hyphenation == Hyphens::Auto {
        fragment_constraints
            .hyphenation_language
            .and_then(|lang| get_hyphenator(lang).ok())
    } else {
144518
        None
    };
144518
    let mut positioned_items = Vec::new();
144518
    let mut layout_bounds = Rect::default();
144518
    let num_columns = fragment_constraints.columns.max(1);
144518
    let total_column_gap = fragment_constraints.column_gap * (num_columns - 1) as f32;
    // CSS Inline Layout § 2.1: "the logical width of a line box is equal to the inner
    // logical width of its containing block"
    //
    // Handle the different available space modes:
    // - Definite(width): Use the specified width for column calculation
    // - MinContent: Force line breaks at word boundaries, return widest word width
    // - MaxContent: Use a large value to allow content to expand naturally
    //
    // IMPORTANT: For MinContent, we do NOT use 0.0 (which would break after every character).
    // Instead, we use a large width but track the is_min_content flag to force word-level
    // line breaks in the line breaker. The actual min-content width is the width of the
    // widest resulting line (typically the widest word).
144518
    let is_min_content = matches!(fragment_constraints.available_width, AvailableSpace::MinContent);
144518
    let is_max_content = matches!(fragment_constraints.available_width, AvailableSpace::MaxContent);
144518
    let column_width = match fragment_constraints.available_width {
77714
        AvailableSpace::Definite(width) => (width - total_column_gap) / num_columns as f32,
        AvailableSpace::MinContent | AvailableSpace::MaxContent => {
            // For intrinsic sizing, use a large width to measure actual content width.
            // The line breaker will handle MinContent specially by breaking after each word.
66804
            f32::MAX / 2.0
        }
    };
144518
    let mut current_column = 0;
144518
    if let Some(msgs) = debug_messages {
138012
        msgs.push(LayoutDebugMessage::info(format!(
138012
            "Column width calculated: {column_width}"
138012
        )));
138012
    }
    // Use the CSS direction from constraints instead of auto-detecting from text
    // This ensures that mixed-direction text (e.g., "مرحبا - Hello") uses the
    // correct paragraph-level direction for alignment purposes.
    // With unicode-bidi: plaintext, direction is auto-detected from text content
    // per CSS Writing Modes §8.3.
144518
    let base_direction = if fragment_constraints.unicode_bidi == UnicodeBidi::Plaintext {
        // Auto-detect from remaining shaped items' text content
        let remaining = &cursor.items[cursor.next_item_index..];
        let text: String = remaining.iter()
            .filter_map(|i| i.as_cluster())
            .map(ShapedCluster::text)
            .collect();
        match unicode_bidi::get_base_direction(text.as_str()) {
            unicode_bidi::Direction::Ltr => BidiDirection::Ltr,
            unicode_bidi::Direction::Rtl => BidiDirection::Rtl,
            // No strong character: fall back to containing block direction
            unicode_bidi::Direction::Mixed => fragment_constraints.direction.unwrap_or(BidiDirection::Ltr),
        }
    } else {
144518
        fragment_constraints.direction.unwrap_or(BidiDirection::Ltr)
    };
144518
    if let Some(msgs) = debug_messages {
138012
        msgs.push(LayoutDebugMessage::info(format!(
138012
            "[PFLayout] Base direction: {:?} (from CSS), Text align: {:?}",
138012
            base_direction, fragment_constraints.text_align
138012
        )));
138012
    }
    // +spec:multi-column - column-fill:balance (the initial/default value): content is
    // balanced so the columns are as short and as equal in height as possible. The column loop
    // below only advances to the next column once a column reaches `available_height` — but a
    // block on a page is handed the whole page height as available space, which the short
    // content never reaches, so every line lands in column 0 (a single visual column). Fix:
    // measure the total line count up front (a cheap dry run of the line breaker over a CLONED
    // cursor at the column width) and give each column an equal share of lines; the balanced
    // budget (content_lines / N) is far below the page-height threshold so it takes precedence.
    // Gated on num_columns>1 with no shape boundaries and non-intrinsic sizing — exactly the
    // otherwise-broken case — so single-column and shaped/intrinsic layouts are untouched
    // (zero blast radius). column-fill:auto (fill-then-advance) is rare and not modelled here.
144518
    let balanced_lines_per_column: Option<usize> = if num_columns > 1
1
        && fragment_constraints.shape_boundaries.is_empty()
1
        && !is_min_content
1
        && !is_max_content
    {
1
        let mut probe = cursor.clone();
1
        let mut probe_col_constraints = fragment_constraints.clone();
1
        probe_col_constraints.available_width = AvailableSpace::Definite(column_width);
1
        let probe_line_height = fragment_constraints.resolved_line_height();
        // A line consumes at least one shaped item, so the item count bounds the loop.
1
        let iter_cap = probe.items.len().saturating_mul(4).max(64);
1
        let mut total_lines = 0usize;
1
        let mut probe_y = 0.0_f32;
1
        let mut probe_guard = 0usize;
4
        while !probe.is_done() && probe_guard < iter_cap {
3
            probe_guard += 1;
3
            let lc = get_line_constraints(probe_y, probe_line_height, &probe_col_constraints, &mut None);
3
            if lc.segments.is_empty() {
                break;
3
            }
3
            let (probe_line, _) = break_one_line(
3
                &mut probe,
3
                &lc,
3
                false,
3
                hyphenator.as_ref(),
3
                fonts,
3
                fragment_constraints.line_break,
3
                fragment_constraints.white_space_mode,
3
                fragment_constraints.overflow_wrap,
3
            );
3
            if probe_line.is_empty() {
                break;
3
            }
3
            total_lines += 1;
3
            probe_y += probe_line_height;
        }
1
        (total_lines > 0).then(|| total_lines.div_ceil(num_columns as usize).max(1))
    } else {
144517
        None
    };
289037
    'column_loop: while current_column < num_columns {
144519
        if let Some(msgs) = debug_messages {
138012
            msgs.push(LayoutDebugMessage::info(format!(
138012
                "\n-- Starting Column {current_column} --"
138012
            )));
138012
        }
144519
        let column_start_x =
144519
            (column_width + fragment_constraints.column_gap) * current_column as f32;
144519
        let mut line_top_y = 0.0;
144519
        let mut line_index = 0;
144519
        let mut empty_segment_count = 0; // Failsafe counter for infinite loops
144519
        let mut is_after_forced_break = false;
        // +spec:writing-modes:6e22a7 - vertical-rl advances columns (lines) right-to-left.
        // The positioner lays every line out at an increasing block-axis (x) offset from 0,
        // i.e. left-to-right. For vertical-rl we record each line's block band here so we can
        // mirror the block axis once the column's total extent is known (see after the loop).
144519
        let column_item_start = positioned_items.len();
144519
        let mut line_bands: Vec<(usize, f32, f32)> = Vec::new();
        // [g147 az-web-lift] Hard total-iteration cap on the line-build loop. On the remill lift,
        // `cursor.is_done()` (or the empty-segment failsafe) mis-lifts for the NESTED IFC (content.len
        // reads 0 → the cursor is starved but never reports done) → this `while !cursor.is_done()` spins
        // forever → solveLayoutReal HANGS inside perform_fragment_layout. Cap total iterations so the loop
        // always converges (the harness can then read the markers). native is unaffected (far above real
        // line counts). The 0x60BC4 marker exposes the iteration count.
        #[allow(clippy::no_effect_underscore_binding)] // web_lift-gated debug iteration counter
144519
        let mut _az_line_iters: usize = 0;
309361
        while !cursor.is_done() {
            #[cfg(feature = "web_lift")]
            {
                _az_line_iters += 1;
                unsafe { crate::az_mark((0x60BC4) as u32, (_az_line_iters as u32 | 0xC0DE0000) as u32); }
                if _az_line_iters > 4096 {
                    break;
                }
            }
165626
            if let Some(max_height) = fragment_constraints.available_height {
31
                if line_top_y >= max_height {
4
                    if let Some(msgs) = debug_messages {
                        msgs.push(LayoutDebugMessage::info(format!(
                            "  Column full (pen {line_top_y} >= height {max_height}), breaking to next column."
                        )));
4
                    }
4
                    break;
27
                }
165595
            }
165622
            if let Some(clamp) = fragment_constraints.line_clamp {
9
                if line_index >= clamp.get() {
3
                    break;
6
                }
165613
            }
            // +spec:multi-column - column-fill:balance: cap this column at its balanced share of
            // lines so content distributes across columns. The LAST column takes whatever remains
            // (so integer rounding of the per-column budget never drops content).
165619
            if let Some(budget) = balanced_lines_per_column {
3
                if current_column + 1 < num_columns && line_index >= budget {
                    break;
3
                }
165616
            }
            // Create constraints specific to the current column for the line breaker.
165619
            let mut column_constraints = fragment_constraints.clone();
            // For MinContent/MaxContent, preserve the semantic type so the line breaker
            // can handle word-level breaking correctly. Only use Definite for actual widths.
165619
            if is_min_content {
46094
                column_constraints.available_width = AvailableSpace::MinContent;
119525
            } else if is_max_content {
28346
                column_constraints.available_width = AvailableSpace::MaxContent;
91179
            } else {
91179
                column_constraints.available_width = AvailableSpace::Definite(column_width);
91179
            }
165619
            let line_constraints = get_line_constraints(
165619
                line_top_y,
165619
                fragment_constraints.resolved_line_height(),
165619
                &column_constraints,
165619
                debug_messages,
            );
165619
            if line_constraints.segments.is_empty() {
                empty_segment_count += 1;
                if let Some(msgs) = debug_messages {
                    msgs.push(LayoutDebugMessage::info(format!(
                        "  No available segments at y={line_top_y}, skipping to next line. (empty count: \
                         {empty_segment_count}/{MAX_EMPTY_SEGMENTS})"
                    )));
                }
                // Failsafe: If we've skipped too many lines without content, break out
                if empty_segment_count >= MAX_EMPTY_SEGMENTS {
                    if let Some(msgs) = debug_messages {
                        msgs.push(LayoutDebugMessage::warning(format!(
                            "  [WARN] Reached maximum empty segment count ({MAX_EMPTY_SEGMENTS}). Breaking to \
                             prevent infinite loop."
                        )));
                        msgs.push(LayoutDebugMessage::warning(
                            "  This likely means the shape constraints are too restrictive or \
                             positioned incorrectly."
                                .to_string(),
                        ));
                        msgs.push(LayoutDebugMessage::warning(format!(
                            "  Current y={line_top_y}, shape boundaries might be outside this range."
                        )));
                    }
                    break;
                }
                // Additional check: If we have shapes and are far beyond the expected height,
                // also break to avoid infinite loops
                if !fragment_constraints.shape_boundaries.is_empty() && empty_segment_count > 50 {
                    // Calculate maximum shape height
                    let max_shape_y: f32 = fragment_constraints
                        .shape_boundaries
                        .iter()
                        .map(|shape| {
                            match shape {
                                ShapeBoundary::Circle { center, radius } => center.y + radius,
                                ShapeBoundary::Ellipse { center, radii } => center.y + radii.height,
                                ShapeBoundary::Polygon { points } => {
                                    points.iter().map(|p| p.y).fold(0.0, f32::max)
                                }
                                ShapeBoundary::Rectangle(rect) => rect.y + rect.height,
                                ShapeBoundary::Path { segments } => segments
                                    .iter()
                                    .filter_map(|s| match s {
                                        PathSegment::MoveTo(p) | PathSegment::LineTo(p) => Some(p.y),
                                        PathSegment::CurveTo { end, .. }
                                        | PathSegment::QuadTo { end, .. } => Some(end.y),
                                        PathSegment::Arc { center, radius, .. } => {
                                            Some(center.y + radius)
                                        }
                                        PathSegment::Close => None,
                                    })
                                    .fold(0.0, f32::max),
                            }
                        })
                        .fold(0.0, f32::max);
                    if line_top_y > max_shape_y + 100.0 {
                        if let Some(msgs) = debug_messages {
                            msgs.push(LayoutDebugMessage::info(format!(
                                "  [INFO] Current y={line_top_y} is far beyond maximum shape extent y={max_shape_y}. \
                                 Breaking layout."
                            )));
                            msgs.push(LayoutDebugMessage::info(
                                "  Shape boundaries exist but no segments available - text cannot \
                                 fit in shape."
                                    .to_string(),
                            ));
                        }
                        break;
                    }
                }
                line_top_y += fragment_constraints.resolved_line_height();
                continue;
165619
            }
            // Reset counter when we find valid segments
165619
            empty_segment_count = 0;
            // +spec:line-breaking:3bb032 - break-word not considered for min-content intrinsic sizes
            // +spec:overflow:b932c4 - overflow-wrap/word-wrap (normal/break-word/anywhere) and hyphens interaction
            // `anywhere` introduces soft wrap opportunities (min-content = widest cluster),
            // but `break-word` does NOT (min-content = widest unbreakable word).
165619
            let effective_overflow_wrap = if is_min_content && fragment_constraints.overflow_wrap == OverflowWrap::Anywhere {
                OverflowWrap::Anywhere
165619
            } else if is_min_content && fragment_constraints.overflow_wrap == OverflowWrap::BreakWord {
                OverflowWrap::Normal
            } else {
165619
                fragment_constraints.overflow_wrap
            };
            // CSS Text Module Level 3 § 5 Line Breaking and Word Boundaries
            // https://www.w3.org/TR/css-text-3/#line-breaking
            // +spec:display-property:2608cc - inline box splitting across line boxes, overflow for unsplittable boxes
            // +spec:display-property:ea615c - inline boxes split and distributed across line boxes
            // "When an inline box exceeds the logical width of a line box, it is split
            // into several fragments, which are partitioned across multiple line boxes."
165619
            let (mut line_items, was_hyphenated) =
165619
                break_one_line(cursor, &line_constraints, false, hyphenator.as_ref(), fonts, fragment_constraints.line_break, fragment_constraints.white_space_mode, effective_overflow_wrap);
165619
            if line_items.is_empty() {
777
                if let Some(msgs) = debug_messages {
2
                    msgs.push(LayoutDebugMessage::info(
2
                        "  Break returned no items. Ending column.".to_string(),
2
                    ));
775
                }
777
                break;
164842
            }
164842
            let line_text_before_rev: String = line_items
164842
                .iter()
1874768
                .filter_map(|i| i.as_cluster())
164842
                .map(ShapedCluster::text)
164842
                .collect();
164842
            if let Some(msgs) = debug_messages {
153357
                msgs.push(LayoutDebugMessage::info(format!(
153357
                    // FIX: The log message was misleading. Items are in visual order.
153357
                    "[PFLayout] Line items from breaker (visual order): [{line_text_before_rev}]"
153357
                )));
153357
            }
            // Unicode Bidi rule L2 (glyph-level reversal). `reorder_logical_items`
            // already ordered the level RUNS visually; here we reverse the clusters
            // within each RTL run so an RTL run reads right-to-left. Applied per line
            // (after line breaking) so a wrapped RTL run reorders correctly per line.
164842
            apply_l2_visual_reversal(&mut line_items);
164842
            if let Some(msgs) = debug_messages {
153357
                let after: String = line_items
153357
                    .iter()
1146084
                    .filter_map(|i| i.as_cluster())
153357
                    .map(ShapedCluster::text)
153357
                    .collect();
153357
                if after != line_text_before_rev {
                    msgs.push(LayoutDebugMessage::info(format!(
                        "[PFLayout] Line items after L2 reversal: [{after}]"
                    )));
153357
                }
11485
            }
            // +spec:line-breaking:c59944 - forced line breaks detected for bidi-aware alignment
1874768
            let line_ends_with_forced_break = line_items.iter().any(|item| matches!(item, ShapedItem::Break { .. }));
            // uses text-align-last (last line of block, or line right before forced break)
164842
            let is_last_line = cursor.is_done() && !was_hyphenated;
164842
            let effective_align = resolve_effective_alignment(
164842
                fragment_constraints.text_align,
164842
                fragment_constraints.text_align_last,
164842
                is_last_line || line_ends_with_forced_break,
            );
164842
            let (mut line_pos_items, line_height) = position_one_line(
164842
                &line_items,
164842
                &line_constraints,
164842
                line_top_y,
164842
                line_index,
164842
                effective_align,
164842
                base_direction,
164842
                is_last_line,
164842
                fragment_constraints,
164842
                debug_messages,
164842
                fonts,
164842
                is_after_forced_break,
164842
            );
            // Track whether the next line follows a forced break
164842
            is_after_forced_break = line_ends_with_forced_break;
2001511
            for item in &mut line_pos_items {
1836669
                item.position.x += column_start_x;
1836669
            }
            // +spec:display-property:6c4978 - line-height on block container establishes minimum line box height
164842
            let band_height = line_height.max(fragment_constraints.resolved_line_height());
164842
            line_bands.push((line_index, line_top_y, band_height));
164842
            line_top_y += band_height;
164842
            line_index += 1;
164842
            positioned_items.extend(line_pos_items);
        }
        // +spec:writing-modes:6e22a7 - vertical-rl column order: mirror the block axis so the
        // FIRST line becomes the RIGHTMOST column and successive lines advance leftward. Each
        // line occupies the block band [t, t + h]; after mirroring within the column's total
        // block extent `block_extent` the band moves to [block_extent - t - h, block_extent - t],
        // which is x += block_extent - 2t - h for every item on that line. The inline (y) axis
        // and within-column glyph stacking are untouched. vertical-lr keeps left-to-right order.
144519
        if fragment_constraints.writing_mode == Some(WritingMode::VerticalRl) {
9
            let block_extent = line_top_y;
54
            for item in &mut positioned_items[column_item_start..] {
54
                if let Some(&(_, t, h)) =
81
                    line_bands.iter().find(|(li, _, _)| *li == item.line_index)
54
                {
54
                    // delta = block_extent - 2t - h (written without a `2.0 * t`
54
                    // product so the flops lint stays quiet).
54
                    item.position.x += block_extent - t - t - h;
54
                }
            }
144510
        }
144519
        current_column += 1;
    }
144518
    if let Some(msgs) = debug_messages {
138012
        msgs.push(LayoutDebugMessage::info(format!(
138012
            "--- Exiting perform_fragment_layout, positioned {} items ---",
138012
            positioned_items.len()
138012
        )));
138012
    }
144518
    let mut layout = UnifiedLayout {
144518
        items: positioned_items,
144518
        overflow: OverflowInfo::default(),
144518
    };
    // Calculate bounds on demand via the bounds() method
144518
    let calculated_bounds = layout.bounds();
    // Record the unclipped content bounds. `overflow_items` stays empty by
    // design: this positioner places *every* item, so visual overflow is handled
    // at paint time via clipping rather than by dropping items here.
    // TODO(superplan): only populate `overflow_items` if a future positioning
    // path actually discards content that does not fit.
144518
    layout.overflow.unclipped_bounds = calculated_bounds;
144518
    if let Some(msgs) = debug_messages {
138012
        msgs.push(LayoutDebugMessage::info(format!(
138012
            "--- Calculated bounds: width={}, height={} ---",
138012
            calculated_bounds.width, calculated_bounds.height
138012
        )));
138012
    }
144518
    Ok(layout)
144518
}
/// Breaks a single line of items to fit within the given geometric constraints,
/// handling multi-segment lines and hyphenation.
/// Break a single line from the current cursor position.
///
/// # CSS Text Module Level 3 \u00a7 5 Line Breaking and Word Boundaries
/// <https://www.w3.org/TR/css-text-3/#line-breaking>
///
/// Implements the line breaking algorithm:
/// 1. "When an inline box exceeds the logical width of a line box, it is split into several
///    fragments, which are partitioned across multiple line boxes."
///
/// ## \u2705 Implemented Features:
/// - **Break Opportunities**: Identifies word boundaries and break points
/// - **Soft Wraps**: Wraps at spaces between words
/// - **Hard Breaks**: Handles explicit line breaks (\\n)
/// - **Overflow**: If a word is too long, places it anyway to avoid infinite loop
/// - **Hyphenation**: Tries to break long words at hyphenation points (\u00a7 5.4)
///
/// ## \u26a0\ufe0f Known Issues:
/// - If `line_constraints.total_available` is 0.0 (from `available_width: 0.0` bug), every word
///   will overflow, causing single-word lines
/// - This is the symptom visible in the PDF: "List items break extremely early"
///
/// ## \u00a7 5.2 Breaking Rules for Letters
/// \u2705 IMPLEMENTED: Uses Unicode line breaking algorithm
/// - Relies on UAX #14 for break opportunities
/// - Respects non-breaking spaces and zero-width joiners
///
/// ## \u00a7 5.3 Breaking Rules for Punctuation
/// \u26a0\ufe0f PARTIAL: Basic punctuation handling
/// - \u274c TODO: hanging-punctuation is declared in `UnifiedConstraints` but not used here
/// - \u274c TODO: Should implement punctuation trimming at line edges
///   // +spec:intrinsic-sizing:6085cf - hanging glyphs must be excluded from intrinsic size computation
///
/// ## \u00a7 5.4 Hyphenation
/// \u2705 IMPLEMENTED: Automatic hyphenation with hyphenator library
/// - Tries to hyphenate words that overflow
/// - Inserts hyphen glyph at break point
/// - Carries remainder to next line
///
/// ## \u00a7 5.5 Overflow Wrapping
/// \u2705 IMPLEMENTED: Emergency breaking
/// - If line is empty and word doesn't fit, forces at least one item
/// - Prevents infinite loop
/// - This is "overflow-wrap: break-word" behavior
///
/// # Missing Features:
/// - word-break property (normal, break-all, keep-all) - IMPLEMENTED via `BreakCursor.word_break`
/// - \u26a0\ufe0f line-break property: anywhere implemented; loose/normal/strict CJK strictness
///   filtering added via `is_cjk_break_allowed_by_strictness` (§5.3)
/// - \u274c overflow-wrap: anywhere vs break-word distinction
/// - \u2705 white-space: break-spaces handling
// around every typographic character unit including preserved white spaces; with break-spaces
// it allows breaking before the first space of a sequence
// +spec:line-breaking:722f3b - wrapping only at soft wrap opportunities, minimizing overflow
#[allow(clippy::cognitive_complexity)] // cohesive line-break state machine: one branch per CSS line-break case
#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
/// # Panics
///
/// Panics if a break unit is unexpectedly empty (an internal invariant).
165629
pub fn break_one_line<T: ParsedFontTrait>(
165629
    cursor: &mut BreakCursor<'_>,
165629
    line_constraints: &LineConstraints,
165629
    is_vertical: bool,
165629
    hyphenator: Option<&Standard>,
165629
    fonts: &LoadedFonts<T>,
165629
    line_break: LineBreakStrictness,
165629
    white_space_mode: WhiteSpaceMode,
165629
    overflow_wrap: OverflowWrap,
165629
) -> (Vec<ShapedItem>, bool) {
165629
    let mut line_items = Vec::new();
165629
    let mut current_width = 0.0;
165629
    if cursor.is_done() {
        return (Vec::new(), false);
165629
    }
    // +spec:white-space-processing:c83dbd - Phase II: collapsible spaces at line start removed, trailing spaces removed, tab stops
    // CSS Text Module Level 3 § 4.1.2: At the beginning of a line, white space
    // is collapsed away. Skip leading whitespace at line start.
    // https://www.w3.org/TR/css-text-3/#white-space-phase-2
    // Per CSS Text 3 §4.1.1/§4.1.2, leading white space at line start is collapsed
    // ONLY for the collapsing white-space modes. Pre / pre-wrap / break-spaces must
    // preserve leading indentation, so only strip for Normal / Nowrap / Pre-line.
165629
    let strip_leading = matches!(
165629
        white_space_mode,
        WhiteSpaceMode::Normal | WhiteSpaceMode::Nowrap | WhiteSpaceMode::PreLine
    );
165629
    if strip_leading {
176247
        while !cursor.is_done() {
175470
            let next_unit = cursor.peek_next_unit();
175470
            if next_unit.is_empty() {
                break;
175470
            }
175470
            if next_unit.len() == 1 && is_collapsible_whitespace(&next_unit[0]) {
10651
                cursor.consume(1);
10651
            } else {
164819
                break;
            }
        }
33
    }
    // +spec:line-breaking:35817b - white-space: nowrap/pre prevent soft wrap opportunities
    // CSS Text Level 3 § 3: For nowrap and pre, wrapping is suppressed. All content
    // stays on a single line, overflowing if necessary.
165629
    let no_wrap = matches!(white_space_mode, WhiteSpaceMode::Nowrap | WhiteSpaceMode::Pre);
165629
    if no_wrap {
        // No soft wrapping — consume everything onto one line.
        // Only explicit <br>/newline breaks are honored.
        loop {
436
            let next_unit = cursor.peek_next_unit();
436
            if next_unit.is_empty() {
29
                break;
407
            }
407
            if let Some(ShapedItem::Break { .. }) = next_unit.first() {
9
                line_items.push(next_unit[0].clone());
9
                cursor.consume(1);
9
                return (line_items, false);
398
            }
398
            line_items.extend_from_slice(&next_unit);
398
            cursor.consume(next_unit.len());
        }
    } else {
    loop {
        // typographic character unit as a soft wrap opportunity; hyphenation is not applied
635566
        let next_unit = if line_break == LineBreakStrictness::Anywhere {
9
            cursor.peek_next_single_item()
        } else {
635557
            cursor.peek_next_unit()
        };
635566
        if next_unit.is_empty() {
144450
            break; // End of content
491116
        }
491116
        if let Some(ShapedItem::Break { .. }) = next_unit.first() {
6
            line_items.push(next_unit[0].clone());
6
            cursor.consume(1);
6
            return (line_items, false);
491110
        }
        // Min-content: break at EVERY soft-wrap opportunity so each word forms its
        // own line (min-content = widest unbreakable unit). `total_available` is a
        // sentinel (f32::MAX/2) during intrinsic sizing and never overflows, so
        // without this the run would collapse onto one line and min-content would
        // wrongly equal max-content. Once the line holds content and the next unit
        // is a break opportunity (a space, CJK ideograph, hyphen, …), finish here;
        // a leading space is stripped at the next line's start (collapsing modes).
491110
        if line_constraints.is_min_content
53790
            && !line_items.is_empty()
7696
            && next_unit.len() == 1
7633
            && is_break_opportunity_with_word_break(&next_unit[0], cursor.word_break, cursor.hyphens)
        {
7633
            break;
483477
        }
        // Fold the unit onto the line item-by-item in document order - the
        // exact fold the intrinsic max-content scan uses - and compare on the
        // ADDITION side. A per-unit subtotal or a subtract-based test
        // (`unit <= available - current`) re-associates the f32 sum and can
        // wrap a line that fits its own measured max-content by 1 ULP; this
        // form is bit-identical to the measurement, so a box sized to its
        // measurement never wraps. See fold_line_width.
483477
        let width_with_unit = next_unit
483477
            .iter()
1945190
            .fold(current_width, |w, item| fold_line_width(w, item, is_vertical));
        // 2. Can the whole unit fit on the current line?
483477
        if width_with_unit <= line_constraints.total_available {
469975
            line_items.extend_from_slice(&next_unit);
469975
            current_width = width_with_unit;
469975
            cursor.consume(next_unit.len());
469975
        } else {
13502
            let available_width = line_constraints.total_available - current_width;
            // 3. The unit overflows. Can we hyphenate it?
13502
            if line_break != LineBreakStrictness::Anywhere {
13500
                if let Some(hyphenator) = hyphenator {
4
                    if !is_break_opportunity(next_unit.last().unwrap()) {
4
                        if let Some(hyphenation_result) = try_hyphenate_word_cluster(
4
                            &next_unit,
4
                            available_width,
4
                            is_vertical,
4
                            hyphenator,
4
                            fonts,
4
                        ) {
4
                            line_items.extend(hyphenation_result.line_part);
4
                            cursor.consume(next_unit.len());
4
                            cursor.partial_remainder = hyphenation_result.remainder_part;
4
                            return (line_items, true);
                        }
                    }
13496
                }
2
            }
            // an otherwise unbreakable sequence at an arbitrary point when no other
            // break points exist. Grapheme clusters stay together; no hyphen inserted.
            // 4. Cannot hyphenate or fit. The line is finished.
            // If the line is empty, we must force at least one item to avoid an infinite loop.
            // With overflow-wrap: anywhere or break-word, we break the unbreakable
            // unit at an arbitrary cluster boundary. With normal, we only force one
            // item to prevent infinite loops (content will overflow).
13498
            if line_items.is_empty() {
1109
                match overflow_wrap {
                    OverflowWrap::Anywhere | OverflowWrap::BreakWord => {
                        // Emergency break: fit as many clusters as possible on
                        // this line.  Grapheme clusters stay together.
                        //
                        // Per CSS Text 3 §5.5: "an otherwise unbreakable sequence
                        // of characters may be broken at an arbitrary point" when
                        // overflow-wrap is anywhere/break-word.
47
                        let avail = line_constraints.total_available;
478
                        for item in &next_unit {
                            // Same fold as the fit test and the intrinsic scan.
478
                            let width_with_item = fold_line_width(current_width, item, is_vertical);
                            // Break BEFORE this item if adding it would overflow,
                            // but only if we already have at least one item on the
                            // line (must always make progress).
478
                            if !line_items.is_empty() && avail > 0.0 && width_with_item > avail {
47
                                break;
431
                            }
431
                            line_items.push(item.clone());
431
                            current_width = width_with_item;
                            // When the container is zero-width (avail <= 0), the
                            // break-before check above is skipped (it requires
                            // avail > 0), so every item lands on this one line —
                            // there's nowhere to break TO, content just overflows.
                            // This matches browser behavior for `width: 0`
                            // containers.
                        }
47
                        let consumed = line_items.len().max(1);
47
                        if line_items.is_empty() {
                            line_items.push(next_unit[0].clone());
47
                        }
47
                        cursor.consume(consumed);
                    }
1062
                    OverflowWrap::Normal => {
1062
                        // overflow-wrap:normal keeps an unbreakable word intact and
1062
                        // lets it overflow the line box — it must NOT be shredded one
1062
                        // grapheme per line. Place the whole unit on this (empty) line.
1062
                        line_items.extend_from_slice(&next_unit);
1062
                        cursor.consume(next_unit.len());
1062
                    }
                }
12389
            }
13498
            break;
        }
    }
    } // end !no_wrap
    // +spec:white-space-processing:fef250 - Phase II: trailing collapsible spaces and U+1680 removed at line end
    // as well as any trailing U+1680 OGHAM SPACE MARK whose white-space is normal/nowrap/pre-line.
    // Note: pre-wrap and break-spaces have different handling (hanging/preserving)
    // which is not yet implemented here.
    // Trailing collapsible white space is trimmed only for the collapsing modes.
    // Pre keeps significant trailing spaces; pre-wrap hangs them (handled in
    // position_one_line); break-spaces must never drop them.
165610
    let strip_trailing = matches!(
165610
        white_space_mode,
        WhiteSpaceMode::Normal | WhiteSpaceMode::Nowrap | WhiteSpaceMode::PreLine
    );
165610
    if strip_trailing {
176360
        while let Some(last) = line_items.last() {
175583
            if is_collapsible_whitespace(last) {
10773
                line_items.pop();
10773
            } else {
164810
                break;
            }
        }
23
    }
165610
    (line_items, false)
165629
}
/// Represents a single valid hyphenation point within a word.
#[derive(Debug, Clone)]
pub struct HyphenationBreak {
    /// The number of characters from the original word string included on the line.
    pub char_len_on_line: usize,
    /// The total advance width of the line part + the hyphen.
    pub width_on_line: f32,
    /// The cluster(s) that will remain on the current line.
    pub line_part: Vec<ShapedItem>,
    /// The cluster that represents the hyphen character itself.
    pub hyphen_item: ShapedItem,
    /// The cluster(s) that will be carried over to the next line.
    /// CRITICAL FIX: Changed from `ShapedItem` to Vec<ShapedItem>
    pub remainder_part: Vec<ShapedItem>,
}
/// A "word" is defined as a sequence of one or more adjacent `ShapedClusters`.
#[allow(clippy::cast_precision_loss)] // bounded pixel/coord/colour/glyph cast
/// # Panics
///
/// Panics if a word's cluster or glyph list is unexpectedly empty (an internal invariant).
4
#[must_use] pub fn find_all_hyphenation_breaks<T: ParsedFontTrait>(
4
    word_clusters: &[ShapedCluster],
4
    hyphenator: &Standard,
4
    is_vertical: bool, // Pass this in to use correct metrics
4
    fonts: &LoadedFonts<T>,
4
) -> Option<Vec<HyphenationBreak>> {
4
    if word_clusters.is_empty() {
        return None;
4
    }
    // --- 1. Concatenate the TRUE text and build a robust map ---
4
    let mut word_string = String::new();
4
    let mut char_map = Vec::new();
4
    let mut current_width = 0.0;
38
    for (cluster_idx, cluster) in word_clusters.iter().enumerate() {
38
        for (char_byte_offset, _ch) in cluster.text().char_indices() {
38
            let glyph_idx = cluster
38
                .glyphs
38
                .iter()
38
                .rposition(|g| g.cluster_offset as usize <= char_byte_offset)
38
                .unwrap_or(0);
38
            let glyph = &cluster.glyphs[glyph_idx];
38
            let num_chars_in_glyph = cluster.text()[glyph.cluster_offset as usize..]
38
                .chars()
38
                .count();
38
            let advance_per_char = if is_vertical {
                glyph.vertical_advance
            } else {
38
                glyph.advance
38
            } / (num_chars_in_glyph as f32).max(1.0);
38
            current_width += advance_per_char;
38
            char_map.push((cluster_idx, glyph_idx, current_width));
        }
38
        word_string.push_str(cluster.text());
    }
    // +spec:line-breaking:d7ed93 - language-specific hyphenation rules apply to both auto and explicit (soft hyphen) opportunities
    // --- 2. Get hyphenation opportunities ---
4
    let opportunities = hyphenator.hyphenate(&word_string);
4
    if opportunities.breaks.is_empty() {
        return None;
4
    }
4
    let last_cluster = word_clusters.last().unwrap();
4
    let last_glyph = last_cluster.glyphs.last().unwrap();
4
    let style = last_cluster.style.clone();
    // Look up font from hash
4
    let font = fonts.get_by_hash(last_glyph.font_hash)?;
4
    let (hyphen_glyph_id, hyphen_advance) =
4
        font.get_hyphen_glyph_and_advance(style.font_size_px)?;
4
    let mut possible_breaks = Vec::new();
    // --- 3. Generate a HyphenationBreak for each valid opportunity ---
12
    for &break_char_idx in &opportunities.breaks {
        // The break is *before* the character at this index.
        // So the last character on the line is at `break_char_idx - 1`.
8
        if break_char_idx == 0 || break_char_idx > char_map.len() {
            continue;
8
        }
8
        let (_, _, width_at_break) = char_map[break_char_idx - 1];
        // The line part is all clusters *before* the break index.
8
        let line_part: Vec<ShapedItem> = word_clusters[..break_char_idx]
8
            .iter()
40
            .map(|c| ShapedItem::Cluster(c.clone()))
8
            .collect();
        // The remainder is all clusters *from* the break index onward.
8
        let remainder_part: Vec<ShapedItem> = word_clusters[break_char_idx..]
8
            .iter()
42
            .map(|c| ShapedItem::Cluster(c.clone()))
8
            .collect();
8
        let hyphen_item = ShapedItem::Cluster(ShapedCluster {
8
            flags: ClusterFlags::classify("-"),
8
            source_text: Arc::from("-"),
            source_byte_len: 1,
8
            source_cluster_id: GraphemeClusterId {
8
                source_run: u32::MAX,
8
                start_byte_in_run: u32::MAX,
8
            },
8
            source_content_index: ContentIndex {
8
                run_index: u32::MAX,
8
                item_index: u32::MAX,
8
            },
8
            source_node_id: None, // Hyphen is generated, not from DOM
8
            glyphs: smallvec![ShapedGlyph {
                kind: GlyphKind::Hyphen,
                glyph_id: hyphen_glyph_id,
                font_hash: last_glyph.font_hash,
                font_metrics: last_glyph.font_metrics,
                cluster_offset: 0,
                script: Script::Latin,
                advance: hyphen_advance,
                kerning: 0.0,
                offset: Point::default(),
                vertical_advance: hyphen_advance,
                vertical_offset: Point::default(),
            }],
8
            advance: hyphen_advance,
8
            direction: BidiDirection::Ltr,
8
            style: style.clone(),
8
            marker_position_outside: None,
            is_first_fragment: true,
            is_last_fragment: true,
        });
8
        possible_breaks.push(HyphenationBreak {
8
            char_len_on_line: break_char_idx,
8
            width_on_line: width_at_break + hyphen_advance,
8
            line_part,
8
            hyphen_item,
8
            remainder_part,
8
        });
    }
4
    Some(possible_breaks)
4
}
/// Tries to find a hyphenation point within a word, returning the line part and remainder.
4
fn try_hyphenate_word_cluster<T: ParsedFontTrait>(
4
    word_items: &[ShapedItem],
4
    remaining_width: f32,
4
    is_vertical: bool,
4
    hyphenator: &Standard,
4
    fonts: &LoadedFonts<T>,
4
) -> Option<HyphenationResult> {
4
    let word_clusters: Vec<ShapedCluster> = word_items
4
        .iter()
38
        .filter_map(|item| item.as_cluster().cloned())
4
        .collect();
4
    if word_clusters.is_empty() {
        return None;
4
    }
4
    let all_breaks = find_all_hyphenation_breaks(&word_clusters, hyphenator, is_vertical, fonts)?;
4
    if let Some(best_break) = all_breaks
4
        .into_iter()
6
        .rfind(|b| b.width_on_line <= remaining_width)
    {
4
        let mut line_part = best_break.line_part;
4
        line_part.push(best_break.hyphen_item);
4
        return Some(HyphenationResult {
4
            line_part,
4
            remainder_part: best_break.remainder_part,
4
        });
    }
    None
4
}
/// Positions a single line of items, handling alignment and justification within segments.
///
/// This function is architecturally critical for cache safety. It does not mutate the
/// `advance` or `bounds` of the input `ShapedItem`s. Instead, it applies justification
/// spacing by adjusting the drawing pen's position (`main_axis_pen`).
///
/// # Returns
/// A tuple containing the `Vec` of positioned items and the calculated height of the line box.
/// Position items on a single line after breaking.
///
/// # CSS Inline Layout Module Level 3 \u00a7 2.2 Layout Within Line Boxes
/// <https://www.w3.org/TR/css-inline-3/#layout-within-line-boxes>
///
/// Implements the positioning algorithm:
/// 1. "All inline-level boxes are aligned by their baselines"
/// 2. "Calculate layout bounds for each inline box"
/// 3. "Size the line box to fit the aligned layout bounds"
/// 4. "Position all inline boxes within the line box"
///
/// ## \u2705 Implemented Features:
///
/// ### \u00a7 4 Baseline Alignment (vertical-align)
/// \u26a0\ufe0f PARTIAL IMPLEMENTATION:
/// - \u2705 `baseline`: Aligns box baseline with parent baseline (default)
/// - \u2705 `top`: Aligns top of box with top of line box
/// - \u2705 `middle`: Centers box within line box
/// - \u2705 `bottom`: Aligns bottom of box with bottom of line box
/// - \u274c MISSING: `text-top`, `text-bottom`, `sub`, `super`
/// - \u274c MISSING: `<length>`, `<percentage>` values for custom offset
///
/// ### \u00a7 2.2.1 Text Alignment (text-align)
/// +spec:containing-block:8d5146 - text-align aligns within line box, not viewport/containing block
/// \u2705 IMPLEMENTED:
/// - `left`, `right`, `center`: Physical alignment
/// - `start`, `end`: Logical alignment (respects direction: ltr/rtl)
/// - `justify`: Distributes space between words/characters
/// - `justify-all`: Justifies last line too
///
/// ### \u00a7 7.3 Text Justification (text-justify)
/// \u2705 IMPLEMENTED:
/// - `inter-word`: Adds space between words
/// - `inter-character`: Adds space between characters
/// - `kashida`: Arabic kashida elongation
/// - \u274c MISSING: `distribute` (CJK justification)
///
/// ### CSS Text \u00a7 8.1 Text Indentation (text-indent)
/// \u2705 IMPLEMENTED: First line indentation
///
/// ### CSS Text \u00a7 4.1 Word Spacing (word-spacing)
/// \u2705 IMPLEMENTED: Additional space between words
///
/// ### CSS Text \u00a7 4.2 Letter Spacing (letter-spacing)
/// \u2705 IMPLEMENTED: Additional space between characters
///
/// ## Segment-Aware Layout:
/// \u2705 Handles CSS Shapes and multi-column layouts
/// - Breaks line into segments (for shape boundaries)
/// - Calculates justification per segment
/// - Applies alignment within each segment's bounds
///
/// ## Known Issues:
/// - \u26a0\ufe0f If segment.width is infinite (from intrinsic sizing), sets `alignment_offset=0` to
///   avoid infinite positioning. This is correct for measurement but documented for clarity.
/// - The function assumes `line_index == 0` means first line for text-indent. A more robust system
///   would track paragraph boundaries.
///
/// # Missing Features:
/// - \u274c \u00a7 6 Trimming Leading (text-box-trim, text-box-edge)
/// - \u274c \u00a7 3.3 Initial Letters (drop caps)
///   // +spec:display-property:265c04 - initial letter exclusion area must continue into subsequent blocks when paragraph is shorter than drop cap
/// - \u274c Full vertical-align support (sub, super, lengths, percentages)
/// - \u274c white-space: break-spaces alignment behavior
// +spec:text-alignment-spacing:c8a926 - order of operations: shaping → letter/word-spacing → justification → alignment
#[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
#[allow(clippy::cast_precision_loss)] // bounded pixel/coord/colour/glyph cast
#[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
#[allow(clippy::too_many_lines, clippy::cognitive_complexity)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
164843
pub fn position_one_line<T: ParsedFontTrait>(
164843
    line_items: &[ShapedItem],
164843
    line_constraints: &LineConstraints,
164843
    line_top_y: f32,
164843
    line_index: usize,
164843
    text_align: TextAlign,
164843
    base_direction: BidiDirection,
164843
    is_last_line: bool,
164843
    constraints: &UnifiedConstraints,
164843
    debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
164843
    fonts: &LoadedFonts<T>,
164843
    is_after_forced_break: bool,
164843
) -> (Vec<PositionedItem>, f32) {
164843
    let line_text: String = line_items
164843
        .iter()
1874771
        .filter_map(|i| i.as_cluster())
164843
        .map(ShapedCluster::text)
164843
        .collect();
164843
    if let Some(msgs) = debug_messages {
153357
        msgs.push(LayoutDebugMessage::info(format!(
153357
            "\n--- Entering position_one_line for line: [{line_text}] ---"
153357
        )));
153357
    }
    // +spec:text-alignment-spacing:13b72d - line box start/end determined by inline base direction
    // +spec:text-alignment-spacing:d497af - line box inline base direction affects text-align resolution
    // +spec:text-alignment-spacing:68332e - bidi direction determines start/end to left/right mapping
164843
    let physical_align = match (text_align, base_direction) {
137834
        (TextAlign::Start, BidiDirection::Ltr) => TextAlign::Left,
9
        (TextAlign::Start, BidiDirection::Rtl) => TextAlign::Right,
        (TextAlign::End, BidiDirection::Ltr) => TextAlign::Right,
        (TextAlign::End, BidiDirection::Rtl) => TextAlign::Left,
        // Physical alignments are returned as-is, regardless of direction.
27000
        (other, _) => other,
    };
164843
    if let Some(msgs) = debug_messages {
153357
        msgs.push(LayoutDebugMessage::info(format!(
153357
            "[Pos1Line] Physical align: {physical_align:?}"
153357
        )));
153357
    }
    // +spec:box-model:847003 - Phantom line boxes: empty lines treated as zero-height
    // +spec:box-model:d781f3 - empty line boxes (no text, no preserved whitespace, no inline elements with non-zero margins/padding/borders, no in-flow content) are treated as zero-height
    // +spec:display-property:90d782 - Phantom line boxes (containing only empty inline boxes, out-of-flow items, or collapsed whitespace) are ignored
164843
    if line_items.is_empty() {
        return (Vec::new(), 0.0);
164843
    }
164843
    let mut positioned = Vec::new();
164843
    let is_vertical = constraints.is_vertical();
    // +spec:line-height:9ca9d9 - line box height = distance from uppermost box top to lowermost box bottom, including strut
    // The line box is calculated once for all items on the line, regardless of segment.
    // Per CSS 2.2 §10.8, top/bottom aligned items are handled in a second pass to
    // minimize line box height; baseline-aligned items determine the initial height.
164843
    let (content_ascent, content_descent) = calculate_line_metrics(line_items, constraints.vertical_align, constraints);
    // +spec:box-model:e99f7d - strut: each line box starts with zero-width inline box with block container's font/line-height
    // +spec:line-height:29c478 - strut: zero-width inline box with block container's font/line-height
    // inline box with the block container's font and line-height. The strut has A (ascent) and
    // D (descent) from the block container's first available font. Half-leading L/2 is applied:
    // L = line-height - (A + D), strut_above = A + L/2, strut_below = D + L/2.
    // +spec:height-calculation:8e91b2 - specified line-height used in line box height calculation
164843
    let strut_ad = constraints.strut_ascent + constraints.strut_descent;
164843
    let strut_leading_half = (constraints.resolved_line_height() - strut_ad) / 2.0;
164843
    let strut_above = constraints.strut_ascent + strut_leading_half;
164843
    let strut_below = constraints.strut_descent + strut_leading_half;
164843
    let line_ascent = content_ascent.max(strut_above);
164843
    let line_descent = content_descent.max(strut_below);
164843
    let line_box_height = line_ascent + line_descent;
    // The baseline for the entire line is determined by its tallest item.
164843
    let line_baseline_y = line_top_y + line_ascent;
    // --- Segment-Aware Positioning ---
164843
    let mut item_cursor = 0;
164843
    let is_first_line_of_para = line_index == 0; // Simplified assumption
164854
    for (segment_idx, segment) in line_constraints.segments.iter().enumerate() {
164854
        if item_cursor >= line_items.len() {
            break;
164854
        }
        // 1. Collect all items that fit into the current segment.
164854
        let mut segment_items = Vec::new();
164854
        let mut current_segment_width = 0.0;
2001526
        while item_cursor < line_items.len() {
1837758
            let item = &line_items[item_cursor];
1837758
            let item_measure = get_item_measure(item, is_vertical);
            // Put at least one item in the segment to avoid getting stuck.
1837758
            if current_segment_width + item_measure > segment.width && !segment_items.is_empty() {
1086
                break;
1836672
            }
1836672
            segment_items.push(item.clone());
1836672
            current_segment_width += item_measure;
1836672
            item_cursor += 1;
        }
164854
        if segment_items.is_empty() {
            continue;
164854
        }
        // +spec:text-alignment-spacing:b9d88e - justify stretches inline boxes via text-justify; non-collapsible WS may skip justification
        // 2. Calculate justification spacing *for this segment only*.
        // +spec:text-alignment-spacing:30d322 - justify lines with justification opportunities when text-align is justify
        // CSS Text 3 §6: text-justify controls HOW to justify, but only applies
        // when text-align is justify/justify-all. Without this check, ALL text
        // gets justified because text-justify defaults to auto (→ InterWord).
164854
        let (extra_word_spacing, extra_char_spacing) = if (constraints.text_align == TextAlign::Justify
164826
            || constraints.text_align == TextAlign::JustifyAll)
29
            && constraints.text_justify != JustifyContent::None
29
            && (!is_last_line || constraints.text_align == TextAlign::JustifyAll)
20
            && constraints.text_justify != JustifyContent::Kashida
        {
20
            let segment_line_constraints = LineConstraints {
20
                segments: vec![*segment],
20
                total_available: segment.width,
20
                is_min_content: false,
20
            };
20
            calculate_justification_spacing(
20
                &segment_items,
20
                &segment_line_constraints,
20
                constraints.text_justify,
20
                is_vertical,
            )
        } else {
164834
            (0.0, 0.0)
        };
        // Kashida justification needs to be segment-aware if used.
164854
        let justified_segment_items = if constraints.text_justify == JustifyContent::Kashida
            && (!is_last_line || constraints.text_align == TextAlign::JustifyAll)
        {
            let segment_line_constraints = LineConstraints {
                segments: vec![*segment],
                total_available: segment.width,
                is_min_content: false,
            };
            justify_kashida_and_rebuild(
                segment_items,
                &segment_line_constraints,
                is_vertical,
                debug_messages,
                fonts,
            )
        } else {
164854
            segment_items
        };
        // Recalculate width in case kashida changed the item list
164854
        let final_segment_width: f32 = justified_segment_items
164854
            .iter()
1836672
            .map(|item| get_item_measure(item, is_vertical))
164854
            .sum();
        // +spec:line-breaking:155a96 - pre-wrap hanging spaces: unconditionally hang without forced break, conditionally hang with forced break
        // +spec:white-space-processing:68af09 - Phase II: trailing whitespace hanging/conditional hanging per white-space mode
        // +spec:white-space-processing:75d91e - preserved white space hangs at line end, affecting intrinsic sizing
        // +spec:overflow:a68394 - Hanging trailing whitespace: unconditionally hang (not considered
        // during alignment, may overflow) for lines without forced break; conditionally hang for
        // lines ending with forced break (only hang if would overflow).
        // For normal/nowrap/pre-line: unconditionally hang trailing WS.
        // For pre-wrap: unconditionally hang, unless before forced break (then conditionally hang).
        // For break-spaces: trailing spaces cannot hang.
        // For pre: no hanging (whitespace preserved as-is).
        // +spec:intrinsic-sizing:1db683 - conditionally hanging glyphs excluded from min-content, included in max-content
164854
        let trailing_ws_width = match constraints.white_space_mode {
26
            WhiteSpaceMode::BreakSpaces | WhiteSpaceMode::Pre => 0.0,
            WhiteSpaceMode::Normal | WhiteSpaceMode::Nowrap | WhiteSpaceMode::PreLine => {
164821
                measure_trailing_whitespace(&justified_segment_items, is_vertical)
            }
            // +spec:line-breaking:8aa426 - space before forced break does not hang if it doesn't overflow
            WhiteSpaceMode::PreWrap => {
7
                let has_forced_break = justified_segment_items.last()
7
                    .is_some_and(|item| matches!(item, ShapedItem::Break { .. }));
7
                let ws_width = measure_trailing_whitespace(&justified_segment_items, is_vertical);
7
                if has_forced_break {
                    // +spec:display-contents:2704a2 - conditionally hanging chars not considered when measuring line fit
                    // Conditionally hang: only hang if it would overflow
1
                    let content_width = final_segment_width - ws_width;
1
                    if content_width + ws_width > segment.width {
                        ws_width
                    } else {
1
                        0.0
                    }
                } else {
6
                    ws_width // unconditionally hang
                }
            }
        };
164854
        let effective_segment_width = final_segment_width - trailing_ws_width;
        // +spec:text-alignment-spacing:287316 - overflow content is start-aligned; alignment offset within line box
        // 3. Calculate alignment offset *within this segment*.
164854
        let remaining_space = segment.width - effective_segment_width;
        // Handle MaxContent/indefinite width: when available_width is MaxContent (for intrinsic
        // sizing), segment.width will be f32::MAX / 2.0. Alignment calculations would
        // produce huge offsets. In this case, treat as left-aligned (offset = 0) since
        // we're measuring natural content width. We check for both infinite AND very large
        // values (> 1e30) to catch the MaxContent case.
164854
        let is_indefinite_width = segment.width.is_infinite() || segment.width > 1e30;
        // +spec:text-alignment-spacing:ab1d4f - unexpandable justify text aligns as center
164854
        let alignment_offset = if is_indefinite_width {
74457
            0.0 // No alignment offset for indefinite width
        } else {
19
            match physical_align {
14031
                TextAlign::Center => remaining_space / 2.0,
10
                TextAlign::Right => remaining_space,
                TextAlign::Justify | TextAlign::JustifyAll
19
                    if remaining_space > 0.0
19
                        && extra_word_spacing == 0.0
                        && extra_char_spacing == 0.0 =>
                {
                    // CSS Text §6.4.3: If text cannot be stretched to full width
                    // and text-align-last is justify, align as center.
                    remaining_space / 2.0
                }
76356
                _ => 0.0, // Left, Justify (when justification succeeded)
            }
        };
164854
        let mut main_axis_pen = segment.start_x + alignment_offset;
164854
        if let Some(msgs) = debug_messages {
153359
            msgs.push(LayoutDebugMessage::info(format!(
153359
                "[Pos1Line] Segment width: {}, Item width: {}, Remaining space: {}, Initial pen: \
153359
                 {}",
153359
                segment.width, final_segment_width, remaining_space, main_axis_pen
153359
            )));
153359
        }
        // Default: indent first line only. each-line: also indent after forced breaks.
        // hanging: invert which lines get the indent.
164854
        if segment_idx == 0 {
164843
            let is_indent_target = if constraints.text_indent_each_line {
                // each-line: first line AND each line after a forced break
                is_first_line_of_para || is_after_forced_break
            } else {
                // Default: only the first line of the block
164843
                is_first_line_of_para
            };
            // hanging: inverts which lines are affected
164843
            let should_indent = if constraints.text_indent_hanging {
                !is_indent_target
            } else {
164843
                is_indent_target
            };
164843
            if should_indent {
144514
                main_axis_pen += constraints.text_indent;
144514
            }
11
        }
        // Calculate total marker width for proper outside marker positioning
        // We need to position all marker clusters together in the padding gutter
164854
        let total_marker_width: f32 = justified_segment_items
164854
            .iter()
1836672
            .filter_map(|item| {
1836672
                if let ShapedItem::Cluster(c) = item {
1836566
                    if c.marker_position_outside == Some(true) {
940
                        return Some(get_item_measure(item, is_vertical));
1835626
                    }
106
                }
1835732
                None
1836672
            })
164854
            .sum();
        // Track marker pen separately - starts at negative position for outside markers
164854
        let marker_spacing = 4.0; // Small gap between marker and content
164854
        let mut marker_pen = if total_marker_width > 0.0 {
470
            -(total_marker_width + marker_spacing)
        } else {
164384
            0.0
        };
        // 4. Position the items belonging to this segment.
        //
        // +spec:inline-formatting-context:267438 - Content positioning: position aligned subtree and baseline-shift values within line box
        //
        // Vertical alignment positioning (CSS vertical-align)
        //
        // +spec:font-metrics:cae541 - dominant baseline used for inline alignment
        // Per CSS Inline Layout Level 3 § 4 (Baseline Alignment), each inline
        // element can specify its own `vertical-align`. For Object items
        // (inline-blocks, images), we use their per-item alignment stored in
        // `InlineContent::Shape.alignment` or `InlineContent::Image.alignment`.
        // For text clusters or items without a per-item override, we fall back
        // to the global `constraints.vertical_align` from the containing block.
        //
        // +spec:font-metrics:f29b61 - baseline alignment matches corresponding baseline types (only alphabetic implemented)
        // Reference: https://www.w3.org/TR/css-inline-3/#baseline-alignment
        // +spec:block-formatting-context:26b535 - In vertical typographic mode, central baseline is dominant when text-orientation is mixed/upright; otherwise alphabetic
        // +spec:inline-formatting-context:eb735b - alignment-baseline: inline-level boxes aligned to parent's baseline via vertical-align
        // +spec:inline-formatting-context:da3f34 - baseline alignment of in-flow inline-level boxes in block axis per dominant-baseline/vertical-align
        // +spec:line-height:e2253a - vertical-align positioning within line boxes
        // Pre-compute inline border/padding offsets at span boundaries.
        // Only the FIRST cluster of each inline span gets left_inset, and only
        // the LAST cluster gets right_inset. We detect span boundaries by comparing
        // Arc<StyleProperties> pointers between consecutive clusters.
164854
        let inline_offsets: Vec<(f32, f32)> = {
164854
            let items_slice: &[ShapedItem] = &justified_segment_items;
1836672
            items_slice.iter().enumerate().map(|(idx, item)| {
1836672
                if let ShapedItem::Cluster(c) = item {
1836566
                    if let Some(border) = c.style.border.as_ref() {
91
                        if border.has_chrome() {
91
                            let style_ptr = Arc::as_ptr(&c.style);
91
                            let prev_same_span = idx > 0 && items_slice[idx - 1]
86
                                .as_cluster()
86
                                .is_some_and(|pc| Arc::as_ptr(&pc.style) == style_ptr);
91
                            let next_same_span = idx + 1 < items_slice.len() && items_slice[idx + 1]
85
                                .as_cluster()
85
                                .is_some_and(|nc| Arc::as_ptr(&nc.style) == style_ptr);
91
                            let left = if prev_same_span { 0.0 } else { border.left_inset() };
91
                            let right = if next_same_span { 0.0 } else { border.right_inset() };
91
                            return (left, right);
                        }
1836475
                    }
106
                }
1836581
                (0.0, 0.0)
1836672
            }).collect()
        };
1836672
        for (inline_offset_idx, item) in justified_segment_items.into_iter().enumerate() {
1836672
            let (item_ascent, item_descent) = get_item_vertical_metrics(&item, constraints);
            // Use per-item alignment if available, otherwise fall back to global
1836672
            let effective_align = get_item_vertical_align(&item)
1836672
                .unwrap_or(constraints.vertical_align);
            // +spec:display-property:328cfc - baseline-shift / aligned subtree vertical alignment (sub, super, top, bottom, center)
            // §10.8.1 vertical-align positioning
            // +spec:line-height:0fcfab - vertical-align property values (baseline, top, middle, bottom, sub, super, text-top, text-bottom, percentage, length)
1836672
            let item_baseline_pos = match effective_align {
                // +spec:display-property:8e018d - aligned subtree edges used for top/bottom line box alignment
                // +spec:inline-formatting-context:495672 - line-relative vertical-align (top/center/bottom) and aligned subtree positioning
                // top: align top of aligned subtree with top of line box
                VerticalAlign::Top => line_top_y + item_ascent,
                // +spec:font-metrics:70000d - align vertical midpoint of box with baseline + half x-height of parent
                VerticalAlign::Middle => {
1
                    let half_x_height = constraints.strut_x_height / 2.0;
1
                    line_baseline_y + half_x_height - f32::midpoint(item_ascent, item_descent) + item_ascent
                }
                // bottom: align bottom of aligned subtree with bottom of line box
                VerticalAlign::Bottom => line_top_y + line_box_height - item_descent,
                // +spec:font-metrics:aa21f7 - sub: lower baseline to proper subscript position
11
                VerticalAlign::Sub => line_baseline_y + line_ascent * SUBSCRIPT_OFFSET_RATIO,
                // +spec:display-property:3b0e76 - baseline-shift super raises by ~1/3 font-size; top/bottom align to line box edges
                // super: raise baseline to proper superscript position (~0.4em)
11
                VerticalAlign::Super => line_baseline_y - line_ascent * SUPERSCRIPT_OFFSET_RATIO,
                // text-top: align top of box with top of parent's content area (§10.6.1)
                // Parent's content area top = baseline - strut_ascent
2
                VerticalAlign::TextTop => (line_baseline_y - constraints.strut_ascent) + item_ascent,
                // text-bottom: align bottom of box with bottom of parent's content area (§10.6.1)
                // Parent's content area bottom = baseline + strut_descent
1
                VerticalAlign::TextBottom => (line_baseline_y + constraints.strut_descent) - item_descent,
                // <length>/<percentage>: raise (positive) or lower (negative); 0 = baseline
                VerticalAlign::Offset(offset) => line_baseline_y - offset,
                // +spec:display-property:8bf37e - dominant-baseline defaults to alphabetic; baseline alignment matches parent
                // baseline: align baseline of box with baseline of parent box
                // +spec:font-metrics:96bbd3 - baseline: align alphabetic baseline of box with parent's alphabetic baseline
1836646
                VerticalAlign::Baseline => line_baseline_y,
            };
            // Calculate item measure (needed for both positioning and pen advance)
1836672
            let item_measure = get_item_measure(&item, is_vertical);
            // Advance pen by inline left_inset at span entry (before positioning glyphs)
1836672
            let (left_inset, right_inset) = if inline_offset_idx < inline_offsets.len() {
1836672
                inline_offsets[inline_offset_idx]
            } else {
                (0.0, 0.0)
            };
1836672
            main_axis_pen += left_inset;
1836672
            let position = if is_vertical {
54
                Point {
54
                    x: item_baseline_pos - item_ascent,
54
                    y: main_axis_pen,
54
                }
            } else {
1836618
                if let Some(msgs) = debug_messages {
1145346
                    msgs.push(LayoutDebugMessage::info(format!(
1145346
                        "[Pos1Line] is_vertical=false, main_axis_pen={main_axis_pen}, item_baseline_pos={item_baseline_pos}, \
1145346
                         item_ascent={item_ascent}"
1145346
                    )));
1145346
                }
                // Check if this is an outside marker - if so, position it in the padding gutter
1836618
                let x_position = if let ShapedItem::Cluster(cluster) = &item {
1836512
                    if cluster.marker_position_outside == Some(true) {
                        // Use marker_pen for sequential marker positioning
940
                        let marker_width = item_measure;
940
                        if let Some(msgs) = debug_messages {
288
                            msgs.push(LayoutDebugMessage::info(format!(
288
                                "[Pos1Line] Outside marker detected! width={marker_width}, positioning at \
288
                                 marker_pen={marker_pen}"
288
                            )));
652
                        }
940
                        let pos = marker_pen;
940
                        marker_pen += marker_width; // Advance marker pen for next marker cluster
940
                        pos
                    } else {
1835572
                        main_axis_pen
                    }
                } else {
106
                    main_axis_pen
                };
1836618
                Point {
1836618
                    y: item_baseline_pos - item_ascent,
1836618
                    x: x_position,
1836618
                }
            };
            // item_measure is calculated above for marker positioning
1836672
            let item_text = item
1836672
                .as_cluster()
1836672
                .map_or("[OBJ]", |c| c.text());
1836672
            if let Some(msgs) = debug_messages {
1145346
                msgs.push(LayoutDebugMessage::info(format!(
1145346
                    "[Pos1Line] Positioning item '{item_text}' at pen_x={main_axis_pen}"
1145346
                )));
1145346
            }
1836672
            positioned.push(PositionedItem {
1836672
                item: item.clone(),
1836672
                position,
1836672
                line_index,
1836672
            });
            // Outside markers don't advance the pen - they're positioned in the padding gutter
1836672
            let is_outside_marker = if let ShapedItem::Cluster(c) = &item {
1836566
                c.marker_position_outside == Some(true)
            } else {
106
                false
            };
1836672
            if !is_outside_marker {
1835732
                main_axis_pen += item_measure;
1835732
                // Advance pen by inline right_inset at span exit (after glyph advance)
1835732
                main_axis_pen += right_inset;
1835732
            }
            // +spec:text-alignment-spacing:e09bd1 - justification space added on top of letter-spacing/word-spacing
            // +spec:text-alignment-spacing:456643 - cursive scripts don't admit inter-character gaps
1836672
            let is_cursive = if let ShapedItem::Cluster(c) = &item { is_cursive_script_cluster(c) } else { false };
1836672
            if !is_outside_marker && extra_char_spacing > 0.0 && can_justify_after(&item) && !is_cursive {
                main_axis_pen += extra_char_spacing;
1836672
            }
            // +spec:display-property:3a833c - consecutive atomic inlines treated as single unit for letter-spacing
            // +spec:display-property:49f04f - letter-spacing applied per innermost inline element
            // +spec:text-alignment-spacing:22bea4 - letter-spacing applied after bidi reordering, additive with kerning and word-spacing; justification may further adjust
1836672
            if let ShapedItem::Cluster(c) = &item {
1836566
                if !is_outside_marker {
                    // +spec:display-property:756454 - letter-spacing applied between typographic character units
                    // +spec:overflow:e63bc0 - letter-spacing ignores zero-width formatting chars (Cf); handled by shaper merging them into clusters
                    // +spec:text-alignment-spacing:80f9ec - letter-spacing applied per-cluster using innermost element's style (UA-allowed attachment)
                    // +spec:text-alignment-spacing:bdd704 - letter-spacing applied after each cluster, not at line start
                    // +spec:text-alignment-spacing:d3ef6e - single-char element: only trailing space, no inter-char effect
                    // +spec:text-alignment-spacing:d668fc - letter-spacing only affects characters within the element (per-cluster style)
                    // +spec:text-alignment-spacing:8dbb78 - zero letter-spacing behaves as normal (Px(0) adds no spacing)
                    // +spec:text-alignment-spacing:456643 - skip letter-spacing for cursive scripts
1835626
                    if !is_cursive_script_cluster(c) {
1835569
                    let letter_spacing_px = c.style.letter_spacing.resolve_px(c.style.font_size_px);
1835569
                    main_axis_pen += letter_spacing_px;
1835569
                    }
                    // +spec:width-calculation:9447d1 - word-spacing only applied to word separators; zero-width chars like U+200B are excluded
1835626
                    if is_word_separator(&item) {
144516
                        let word_spacing_px = c.style.word_spacing.resolve_px(c.style.font_size_px);
144516
                        main_axis_pen += word_spacing_px;
144516
                        main_axis_pen += extra_word_spacing;
1691110
                    }
940
                }
106
            }
        }
    }
164843
    (positioned, line_box_height)
164843
}
/// Calculates the starting pen offset to achieve the desired text alignment.
fn calculate_alignment_offset(
    items: &[ShapedItem],
    line_constraints: &LineConstraints,
    align: TextAlign,
    is_vertical: bool,
    constraints: &UnifiedConstraints,
) -> f32 {
    // Simplified to use the first segment for alignment.
    if let Some(segment) = line_constraints.segments.first() {
        // Include letter/word-spacing so center/right alignment matches the width the
        // text is actually positioned at (position_one_line adds the spacing).
        let total_width: f32 = items
            .iter()
            .map(|item| get_item_measure_with_spacing(item, is_vertical))
            .sum();
        let available_width = if constraints.segment_alignment == SegmentAlignment::Total {
            line_constraints.total_available
        } else {
            segment.width
        };
        if total_width >= available_width {
            return 0.0; // No alignment needed if line is full or overflows
        }
        let remaining_space = available_width - total_width;
        match align {
            TextAlign::Center => remaining_space / 2.0,
            TextAlign::Right => remaining_space,
            _ => 0.0, // Left, Justify, Start, End
        }
    } else {
        0.0
    }
}
/// Calculates the extra spacing needed for justification without modifying the items.
///
/// This function is pure and does not mutate any state, making it safe to use
/// with cached `ShapedItem` data.
///
/// # Arguments
/// * `items` - A slice of items on the line.
/// * `line_constraints` - The geometric constraints for the line.
/// * `text_justify` - The type of justification to calculate.
/// * `is_vertical` - Whether the layout is vertical.
///
/// # Returns
/// A tuple `(extra_per_word, extra_per_char)` containing the extra space in pixels
/// to add at each word or character justification opportunity.
// +spec:display-contents:654278 - distributes remaining space to fill line box when justifying
// +spec:text-alignment-spacing:56c7f4 - equal distribution of justification space within priority level
// +spec:text-alignment-spacing:f17bbc - justification opportunities controlled by text-justify value (inter-word = word separators, inter-character = character juxtaposition)
#[allow(clippy::cast_precision_loss)] // bounded pixel/coord/colour/glyph cast
36
fn calculate_justification_spacing(
36
    items: &[ShapedItem],
36
    line_constraints: &LineConstraints,
36
    text_justify: JustifyContent,
36
    is_vertical: bool,
36
) -> (f32, f32) {
    // (extra_per_word, extra_per_char)
36
    let total_width: f32 = items
36
        .iter()
405
        .map(|item| get_item_measure(item, is_vertical))
36
        .sum();
36
    let available_width = line_constraints.total_available;
36
    if total_width >= available_width || available_width <= 0.0 {
        return (0.0, 0.0);
36
    }
36
    let extra_space = available_width - total_width;
    // +spec:text-alignment-spacing:71314a - script categories for justification: inter-word for clustered, kashida for cursive (Arabic), inter-character for block (CJK)
36
    match text_justify {
        JustifyContent::InterWord => {
            // Count justification opportunities (spaces).
405
            let space_count = items.iter().filter(|item| is_word_separator(item)).count();
36
            if space_count > 0 {
36
                (extra_space / space_count as f32, 0.0)
            } else {
                (0.0, 0.0) // No spaces to expand, do nothing.
            }
        }
        JustifyContent::InterCharacter | JustifyContent::Distribute => {
            // Count justification opportunities (between non-combining characters).
            let gap_count = items
                .iter()
                .enumerate()
                .filter(|(i, item)| *i < items.len() - 1 && can_justify_after(item))
                .count();
            if gap_count > 0 {
                (0.0, extra_space / gap_count as f32)
            } else {
                (0.0, 0.0) // No gaps to expand, do nothing.
            }
        }
        // Kashida justification modifies the item list and is handled by a separate function.
        _ => (0.0, 0.0),
    }
36
}
/// Rebuilds a line of items, inserting Kashida glyphs for justification.
///
/// This function is non-mutating with respect to its inputs. It takes ownership of the
/// original items and returns a completely new `Vec`. This is necessary because Kashida
/// justification changes the number of items on the line, and must not modify cached data.
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] // bounded pixel/coord/colour/glyph cast
#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
3
pub fn justify_kashida_and_rebuild<T: ParsedFontTrait>(
3
    items: Vec<ShapedItem>,
3
    line_constraints: &LineConstraints,
3
    is_vertical: bool,
3
    debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
3
    fonts: &LoadedFonts<T>,
3
) -> Vec<ShapedItem> {
3
    if let Some(msgs) = debug_messages {
        msgs.push(LayoutDebugMessage::info(
            "\n--- Entering justify_kashida_and_rebuild ---".to_string(),
        ));
3
    }
3
    let total_width: f32 = items
3
        .iter()
15
        .map(|item| get_item_measure(item, is_vertical))
3
        .sum();
3
    let available_width = line_constraints.total_available;
3
    if let Some(msgs) = debug_messages {
        msgs.push(LayoutDebugMessage::info(format!(
            "Total item width: {total_width}, Available width: {available_width}"
        )));
3
    }
3
    if total_width >= available_width || available_width <= 0.0 {
        if let Some(msgs) = debug_messages {
            msgs.push(LayoutDebugMessage::info(
                "No justification needed (line is full or invalid).".to_string(),
            ));
        }
        return items;
3
    }
3
    let extra_space = available_width - total_width;
3
    if let Some(msgs) = debug_messages {
        msgs.push(LayoutDebugMessage::info(format!(
            "Extra space to fill: {extra_space}"
        )));
3
    }
3
    let font_info = items.iter().find_map(|item| {
3
        if let ShapedItem::Cluster(c) = item {
3
            if let Some(glyph) = c.glyphs.first() {
3
                if glyph.script == Script::Arabic {
                    // Look up font from hash
3
                    if let Some(font) = fonts.get_by_hash(glyph.font_hash) {
3
                        return Some((
3
                            font.clone(),
3
                            glyph.font_hash,
3
                            glyph.font_metrics,
3
                            c.style.clone(),
3
                        ));
                    }
                }
            }
        }
        None
3
    });
3
    let (font, font_hash, font_metrics, style) = if let Some(info) = font_info {
3
        if let Some(msgs) = debug_messages {
            msgs.push(LayoutDebugMessage::info(
                "Found Arabic font for kashida.".to_string(),
            ));
3
        }
3
        info
    } else {
        if let Some(msgs) = debug_messages {
            msgs.push(LayoutDebugMessage::info(
                "No Arabic font found on line. Cannot insert kashidas.".to_string(),
            ));
        }
        return items;
    };
3
    let (kashida_glyph_id, kashida_advance) =
3
        match font.get_kashida_glyph_and_advance(style.font_size_px) {
3
            Some((id, adv)) if adv > 0.0 => {
3
                if let Some(msgs) = debug_messages {
                    msgs.push(LayoutDebugMessage::info(format!(
                        "Font provides kashida glyph with advance {adv}"
                    )));
3
                }
3
                (id, adv)
            }
            _ => {
                if let Some(msgs) = debug_messages {
                    msgs.push(LayoutDebugMessage::info(
                        "Font does not support kashida justification.".to_string(),
                    ));
                }
                return items;
            }
        };
3
    let opportunity_indices: Vec<usize> = items
3
        .windows(2)
3
        .enumerate()
12
        .filter_map(|(i, window)| {
12
            if let (ShapedItem::Cluster(cur), ShapedItem::Cluster(next)) = (&window[0], &window[1])
            {
12
                if is_arabic_cluster(cur)
12
                    && is_arabic_cluster(next)
12
                    && !is_word_separator(&window[1])
                {
12
                    return Some(i + 1);
                }
            }
            None
12
        })
3
        .collect();
3
    if let Some(msgs) = debug_messages {
        msgs.push(LayoutDebugMessage::info(format!(
            "Found {} kashida insertion opportunities at indices: {:?}",
            opportunity_indices.len(),
            opportunity_indices
        )));
3
    }
3
    if opportunity_indices.is_empty() {
        if let Some(msgs) = debug_messages {
            msgs.push(LayoutDebugMessage::info(
                "No opportunities found. Exiting.".to_string(),
            ));
        }
        return items;
3
    }
3
    let num_kashidas_to_insert = (extra_space / kashida_advance).floor() as usize;
3
    if let Some(msgs) = debug_messages {
        msgs.push(LayoutDebugMessage::info(format!(
            "Calculated number of kashidas to insert: {num_kashidas_to_insert}"
        )));
3
    }
3
    if num_kashidas_to_insert == 0 {
        return items;
3
    }
3
    let kashidas_per_point = num_kashidas_to_insert / opportunity_indices.len();
3
    let mut remainder = num_kashidas_to_insert % opportunity_indices.len();
3
    if let Some(msgs) = debug_messages {
        msgs.push(LayoutDebugMessage::info(format!(
            "Distributing kashidas: {kashidas_per_point} per point, with {remainder} remainder."
        )));
3
    }
3
    let kashida_item = {
        /* ... as before ... */
3
        let kashida_glyph = ShapedGlyph {
3
            kind: GlyphKind::Kashida {
3
                width: kashida_advance,
3
            },
3
            glyph_id: kashida_glyph_id,
3
            font_hash,
3
            font_metrics,
3
            script: Script::Arabic,
3
            advance: kashida_advance,
3
            kerning: 0.0,
3
            cluster_offset: 0,
3
            offset: Point::default(),
3
            vertical_advance: 0.0,
3
            vertical_offset: Point::default(),
3
        };
        ShapedItem::Cluster(ShapedCluster {
3
            flags: ClusterFlags::classify("\u{0640}"),
3
            source_text: Arc::from("\u{0640}"),
            source_byte_len: 2,
3
            source_cluster_id: GraphemeClusterId {
3
                source_run: u32::MAX,
3
                start_byte_in_run: u32::MAX,
3
            },
3
            source_content_index: ContentIndex {
3
                run_index: u32::MAX,
3
                item_index: u32::MAX,
3
            },
3
            source_node_id: None, // Kashida is generated, not from DOM
3
            glyphs: smallvec![kashida_glyph],
3
            advance: kashida_advance,
3
            direction: BidiDirection::Ltr,
3
            style,
3
            marker_position_outside: None,
            is_first_fragment: true,
            is_last_fragment: true,
        })
    };
3
    let mut new_items = Vec::with_capacity(items.len() + num_kashidas_to_insert);
3
    let mut last_copy_idx = 0;
15
    for &point in &opportunity_indices {
12
        new_items.extend_from_slice(&items[last_copy_idx..point]);
12
        let mut num_to_insert = kashidas_per_point;
12
        if remainder > 0 {
6
            num_to_insert += 1;
6
            remainder -= 1;
6
        }
18
        for _ in 0..num_to_insert {
18
            new_items.push(kashida_item.clone());
18
        }
12
        last_copy_idx = point;
    }
3
    new_items.extend_from_slice(&items[last_copy_idx..]);
3
    if let Some(msgs) = debug_messages {
        msgs.push(LayoutDebugMessage::info(format!(
            "--- Exiting justify_kashida_and_rebuild, new item count: {} ---",
            new_items.len()
        )));
3
    }
3
    new_items
3
}
/// Helper to determine if a cluster belongs to the Arabic script.
218
fn is_arabic_cluster(cluster: &ShapedCluster) -> bool {
    // A cluster is considered Arabic if its first non-NotDef glyph is from the Arabic script.
    // This is a robust heuristic for mixed-script lines.
218
    cluster.glyphs.iter().any(|g| g.script == Script::Arabic)
218
}
/// Helper to identify if an item is a word separator (like a space).
176804
fn measure_trailing_whitespace(items: &[ShapedItem], is_vertical: bool) -> f32 {
176804
    let mut trailing_ws = 0.0;
176894
    for item in items.iter().rev() {
176894
        if is_collapsible_whitespace(item) {
90
            trailing_ws += get_item_measure(item, is_vertical);
90
        } else {
176804
            break;
        }
    }
176804
    trailing_ws
176804
}
/// Returns true if the item is collapsible whitespace per CSS Text 3 §4.1.2 Phase II.
///
/// This is used for stripping leading/trailing whitespace at line edges —
/// distinct from `is_word_separator` which is for word-spacing per §7.1.
391063
#[must_use] pub fn is_collapsible_whitespace(item: &ShapedItem) -> bool {
391063
    if let ShapedItem::Cluster(c) = item {
390344
        c.text().chars().all(|ch| matches!(ch,
            ' ' | '\t' | '\u{1680}' // Ogham space mark (collapsible per spec)
        ))
    } else {
721
        false
    }
391063
}
// +spec:text-alignment-spacing:456643 - cursive scripts do not admit letter-spacing gaps
/// Returns true if the cluster's first character belongs to a cursive script
/// (Arabic, Syriac, Mongolian, N'Ko, Mandaic, Phags Pa, Hanifi Rohingya)
/// per CSS Text 3 Appendix D.
///
/// These scripts should not have letter-spacing applied.
9405293
pub fn is_cursive_script_cluster(c: &ShapedCluster) -> bool {
9405293
    c.text().chars().next().is_some_and(is_cursive_script_char)
9405293
}
9405302
fn is_cursive_script_char(ch: char) -> bool {
9405302
    let cp = ch as u32;
    // Arabic (U+0600–U+06FF, U+0750–U+077F, U+08A0–U+08FF, U+FB50–U+FDFF, U+FE70–U+FEFF)
9405302
    if (0x0600..=0x06FF).contains(&cp) { return true; }
9404839
    if (0x0750..=0x077F).contains(&cp) { return true; }
9404839
    if (0x08A0..=0x08FF).contains(&cp) { return true; }
9404839
    if (0xFB50..=0xFDFF).contains(&cp) { return true; }
9404839
    if (0xFE70..=0xFEFF).contains(&cp) { return true; }
    // Syriac (U+0700–U+074F)
9404839
    if (0x0700..=0x074F).contains(&cp) { return true; }
    // Mongolian (U+1800–U+18AF)
9404838
    if (0x1800..=0x18AF).contains(&cp) { return true; }
    // N'Ko (U+07C0–U+07FF)
9404837
    if (0x07C0..=0x07FF).contains(&cp) { return true; }
    // Mandaic (U+0840–U+085F)
9404837
    if (0x0840..=0x085F).contains(&cp) { return true; }
    // Phags Pa (U+A840–U+A87F)
9404837
    if (0xA840..=0xA87F).contains(&cp) { return true; }
    // Hanifi Rohingya (U+10D00–U+10D3F)
9404837
    if (0x10D00..=0x10D3F).contains(&cp) { return true; }
9404836
    false
9405302
}
/// Word-segmentation predicate shared by word selection (double-click) and word
/// cursor motion (Ctrl/Alt+Arrow) so they agree on what a "word" is.
///
/// A word character is alphanumeric or underscore; everything else — whitespace
/// AND punctuation — is a word boundary. This is deliberately distinct from
/// [`is_word_separator`] (which classifies *spacing* characters for word-spacing
/// justification per CSS Text §7.1, and treats punctuation as non-separator).
/// Used by `selection::find_word_boundaries` and `UnifiedLayout::move_cursor_to_*_word`.
172315
pub(crate) fn is_word_char(ch: char) -> bool {
172315
    ch.is_alphanumeric() || ch == '_'
172315
}
/// True when a shaped cluster is a word-segmentation boundary (whitespace or
/// punctuation), i.e. it contains no word characters. Keeps cursor word-motion
/// consistent with `selection::find_word_boundaries`.
30812
fn cluster_is_word_boundary(cluster: &ShapedCluster) -> bool {
30812
    !cluster.text().chars().any(is_word_char)
30812
}
// exclude punctuation and fixed-width spaces (U+3000, U+2000..U+200A)
#[must_use]
12378509
pub const fn is_word_separator(item: &ShapedItem) -> bool {
12378509
    if let ShapedItem::Cluster(c) = item {
        // Precomputed at shaping — see ClusterFlags.
12377040
        c.flags.has(ClusterFlags::WORD_SEPARATOR)
    } else {
1469
        false
    }
12378509
}
/// True for separators that add word-spacing but must NOT offer a soft-wrap opportunity.
///
/// (UAX#14 class GL/WJ): NBSP, NARROW NO-BREAK SPACE, WORD JOINER, ZWNBSP. These are a
/// subset of `is_word_separator` — they still contribute Glue, but no break Penalty.
384
#[must_use] pub const fn is_no_break_space(item: &ShapedItem) -> bool {
384
    if let ShapedItem::Cluster(c) = item {
384
        c.flags.has(ClusterFlags::NO_BREAK_SPACE)
    } else {
        false
    }
384
}
// +spec:margin-collapsing:6706c1 - fixed-width spaces (U+2000–U+200A, U+3000) excluded from word separators
/// Returns true if the character is a word-separator character per CSS Text §7.1.
/// Punctuation and fixed-width spaces (U+3000, U+2000 through U+200A) are NOT
/// word-separator characters even though they may visually separate words.
// +spec:text-alignment-spacing:3e0655 - word-separator characters for word-spacing
#[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
844712
const fn is_word_separator_char(c: char) -> bool {
844712
    match c {
        // Standard ASCII space
93364
        '\u{0020}' => true,
        // NO-BREAK SPACE
15
        '\u{00A0}' => true,
        // OGHAM SPACE MARK
2
        '\u{1680}' => true,
        // ETHIOPIC WORDSPACE (spec §7.1)
        '\u{1361}' => true,
        // Fixed-width spaces: NOT word separators per spec
2175
        '\u{2000}'..='\u{200A}' => false,
        // NARROW NO-BREAK SPACE
3
        '\u{202F}' => true,
        // MEDIUM MATHEMATICAL SPACE
        '\u{205F}' => true,
        // IDEOGRAPHIC SPACE: NOT a word separator per spec
3
        '\u{3000}' => false,
        // AEGEAN WORD SEPARATOR LINE (spec §7.1)
1
        '\u{10100}' => true,
        // AEGEAN WORD SEPARATOR DOT (spec §7.1)
        '\u{10101}' => true,
        // UGARITIC WORD DIVIDER (spec §7.1)
        '\u{1039F}' => true,
        // PHOENICIAN WORD SEPARATOR (spec §7.1)
        '\u{1091F}' => true,
        // Other Unicode whitespace not listed above
751322
        _ => false,
    }
844712
}
/// Helper to identify if an item is a zero-width space (U+200B),
/// which provides a soft wrap opportunity with no visible width.
///
/// Used in scripts like Thai, Lao, and Khmer that don't use spaces between words.
// +spec:line-breaking:fd3164 - U+200B as explicit word delimiter for scripts without space-separated words
4382417
#[must_use] pub const fn is_zero_width_space(item: &ShapedItem) -> bool {
4382417
    if let ShapedItem::Cluster(c) = item {
4381310
        c.flags.has(ClusterFlags::ZERO_WIDTH_SPACE)
    } else {
1107
        false
    }
4382417
}
/// Helper to identify if space can be added after an item.
6
fn can_justify_after(item: &ShapedItem) -> bool {
6
    if let ShapedItem::Cluster(c) = item {
4
        c.text().chars().last().is_some_and(|g| {
3
            !g.is_whitespace() && classify_character(g as u32) != CharacterClass::Combining
3
        })
    } else {
        // Per CSS 2.2 §9.4.2, justification must NOT stretch inline-table and
        // inline-block boxes. Object items represent these atomic inline-level
        // boxes, so we return false to prevent adding justification space after them.
2
        false
    }
6
}
// +spec:font-metrics:b8eb97 - Script group classification for justification/letter-spacing behavior
/// Classifies a character for layout purposes (e.g., justification behavior).
/// Copied from `mod.rs`.
#[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
24
const fn classify_character(codepoint: u32) -> CharacterClass {
24
    match codepoint {
6
        0x0020 | 0x00A0 | 0x3000 => CharacterClass::Space,
17
        0x0021..=0x002F | 0x003A..=0x0040 | 0x005B..=0x0060 | 0x007B..=0x007E => {
2
            CharacterClass::Punctuation
        }
4
        0x4E00..=0x9FFF | 0x3400..=0x4DBF => CharacterClass::Ideograph,
8
        0x0300..=0x036F | 0x1AB0..=0x1AFF => CharacterClass::Combining,
        // Mongolian script range
3
        0x1800..=0x18AF => CharacterClass::Letter,
9
        _ => CharacterClass::Letter,
    }
24
}
/// Helper to get the primary measure (width or height) of a shaped item.
13894949
#[must_use] pub fn get_item_measure(item: &ShapedItem, is_vertical: bool) -> f32 {
13894949
    match item {
13891774
        ShapedItem::Cluster(c) => {
            // Total width = base advance + kerning adjustments
            // Kerning is stored separately in glyphs for inspection, but the total
            // cluster width must include it for correct layout positioning
13891774
            let total_kerning: f32 = c.glyphs.iter().map(|g| g.kerning).sum();
13891774
            c.advance + total_kerning
        }
2451
        ShapedItem::Object { bounds, .. }
        | ShapedItem::CombinedBlock { bounds, .. }
208
        | ShapedItem::Tab { bounds, .. } => {
2659
            if is_vertical {
1
                bounds.height
            } else {
2658
                bounds.width
            }
        }
516
        ShapedItem::Break { .. } => 0.0,
    }
13894949
}
/// Like [`get_item_measure`] but ALSO includes the per-cluster letter-spacing and
/// per-separator word-spacing that `position_one_line` adds after each cluster.
///
/// Line breaking and center/right alignment must measure the SAME width the text is
/// finally positioned at; `get_item_measure` alone omits letter/word-spacing, so a run
/// that "just fits" without spacing overflows its box (or mis-aligns) once the spacing
/// is applied. Selection/caret geometry must NOT include the trailing spacing, so those
/// callers keep using the bare `get_item_measure`.
#[must_use]
4921035
pub fn get_item_measure_with_spacing(item: &ShapedItem, is_vertical: bool) -> f32 {
4921035
    let base = get_item_measure(item, is_vertical);
4921035
    if let ShapedItem::Cluster(c) = item {
4919738
        let mut extra = 0.0;
4919738
        if !is_cursive_script_cluster(c) {
4919440
            extra += c.style.letter_spacing.resolve_px(c.style.font_size_px);
4919440
        }
4919738
        if is_word_separator(item) {
500769
            extra += c.style.word_spacing.resolve_px(c.style.font_size_px);
4418969
        }
4919738
        base + extra
    } else {
1297
        base
    }
4921035
}
/// The single fold that BOTH the intrinsic-size scan and the line breaker use
/// to accumulate a line's width.
///
/// f32 addition is not associative, so "measure a box at max-content, then lay
/// its text out at exactly that width" only guarantees a single line when both
/// passes fold the SAME per-item values in the SAME order onto the SAME running
/// total. Any other grouping - per-word subtotals, or a subtract-based fit test
/// like `unit <= available - current` - rounds differently by a few ULP, and a
/// box sized from its own measurement then wraps its last word (the ribbon's
/// "Format Painter" truncating to "Format").
///
/// Negative advances (pathological kerning) are clamped here so every consumer
/// agrees that a cluster cannot rewind the caret.
#[inline]
#[must_use]
3651333
pub fn fold_line_width(current: f32, item: &ShapedItem, is_vertical: bool) -> f32 {
3651333
    current + get_item_measure_with_spacing(item, is_vertical).max(0.0)
3651333
}
/// Calculates the available horizontal segments for a line at a given vertical position,
/// considering both shape boundaries and exclusions.
#[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
177758
fn get_line_constraints(
177758
    line_y: f32,
177758
    line_height: f32,
177758
    constraints: &UnifiedConstraints,
177758
    debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
177758
) -> LineConstraints {
177758
    if let Some(msgs) = debug_messages {
157615
        msgs.push(LayoutDebugMessage::info(format!(
157615
            "\n--- Entering get_line_constraints for y={line_y} ---"
157615
        )));
157615
    }
177758
    let mut available_segments = Vec::new();
177758
    if constraints.shape_boundaries.is_empty() {
        // The segment_width is determined by available_width, NOT by TextWrap.
        // TextWrap::NoWrap only affects whether the LineBreaker can insert soft breaks,
        // it should NOT override a definite width constraint from CSS.
        // +spec:overflow:b06c3e - text overflows when wrapping is prevented (e.g. white-space: nowrap)
        // CSS Text Level 3: For 'white-space: pre/nowrap', text overflows horizontally
        // if it doesn't fit, rather than expanding the container.
        //
        // For MinContent/MaxContent intrinsic sizing: use a large value to let text 
        // lay out fully. The line breaker handles min-content by breaking at word 
        // boundaries. The actual content width is measured from the laid-out lines.
177758
        let segment_width = match constraints.available_width {
102206
            AvailableSpace::Definite(w) => w, // Respect definite width from CSS
29234
            AvailableSpace::MaxContent => f32::MAX / 2.0, // For intrinsic max-content sizing
46318
            AvailableSpace::MinContent => f32::MAX / 2.0, // For intrinsic min-content sizing
        };
        // Note: TextWrap::NoWrap is handled by the LineBreaker in break_one_line()
        // to prevent soft wraps. The text will simply overflow if it exceeds segment_width.
177758
        available_segments.push(LineSegment {
177758
            start_x: 0.0,
177758
            width: segment_width,
177758
            priority: 0,
177758
        });
    } else {
        // ... complex boundary logic ...
    }
177758
    if let Some(msgs) = debug_messages {
157615
        msgs.push(LayoutDebugMessage::info(format!(
157615
            "Initial available segments: {available_segments:?}"
157615
        )));
157615
    }
177758
    for (idx, exclusion) in constraints.shape_exclusions.iter().enumerate() {
540
        if let Some(msgs) = debug_messages {
261
            msgs.push(LayoutDebugMessage::info(format!(
261
                "Applying exclusion #{idx}: {exclusion:?}"
261
            )));
279
        }
540
        let exclusion_spans =
540
            get_shape_horizontal_spans(exclusion, line_y, line_height);
540
        if let Some(msgs) = debug_messages {
261
            msgs.push(LayoutDebugMessage::info(format!(
261
                "  Exclusion spans at y={line_y}: {exclusion_spans:?}"
261
            )));
279
        }
540
        if exclusion_spans.is_empty() {
135
            continue;
405
        }
405
        let mut next_segments = Vec::new();
810
        for (excl_start, excl_end) in exclusion_spans {
819
            for segment in &available_segments {
414
                let seg_start = segment.start_x;
414
                let seg_end = segment.start_x + segment.width;
                // Create new segments by subtracting the exclusion
414
                if seg_end > excl_start && seg_start < excl_end {
405
                    if seg_start < excl_start {
144
                        // Left part
144
                        next_segments.push(LineSegment {
144
                            start_x: seg_start,
144
                            width: excl_start - seg_start,
144
                            priority: segment.priority,
144
                        });
261
                    }
405
                    if seg_end > excl_end {
360
                        // Right part
360
                        next_segments.push(LineSegment {
360
                            start_x: excl_end,
360
                            width: seg_end - excl_end,
360
                            priority: segment.priority,
360
                        });
360
                    }
9
                } else {
9
                    next_segments.push(*segment); // No overlap
9
                }
            }
405
            available_segments = merge_segments(next_segments);
405
            next_segments = Vec::new();
        }
405
        if let Some(msgs) = debug_messages {
234
            msgs.push(LayoutDebugMessage::info(format!(
234
                "  Segments after exclusion #{idx}: {available_segments:?}"
234
            )));
234
        }
    }
177758
    let total_width = available_segments.iter().map(|s| s.width).sum();
177758
    if let Some(msgs) = debug_messages {
157615
        msgs.push(LayoutDebugMessage::info(format!(
157615
            "Final segments: {available_segments:?}, total available width: {total_width}"
157615
        )));
157615
        msgs.push(LayoutDebugMessage::info(
157615
            "--- Exiting get_line_constraints ---".to_string(),
157615
        ));
157615
    }
    LineConstraints {
177758
        segments: available_segments,
177758
        total_available: total_width,
177758
        is_min_content: matches!(constraints.available_width, AvailableSpace::MinContent),
    }
177758
}
/// Flattens a parsed SVG multipolygon (from a CSS `path()` shape) into a flat list of
/// `PathSegment`s in absolute coordinates (offset by the reference box origin). Each ring
/// becomes a `MoveTo` + a run of `LineTo`s + `Close`; curve elements are sampled into line
/// segments (~one segment per 4px of arc length, capped) so the scanline intersection can
/// treat each subpath as a polygon.
// bounded curve-sampling geometry casts (step count / arc-length parameter / coords)
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss)]
3
fn flatten_svg_to_path_segments(
3
    multipolygon: &azul_core::svg::SvgMultiPolygon,
3
    reference_box: Rect,
3
) -> Vec<PathSegment> {
    use azul_core::svg::SvgPathElement;
3
    let mut out: Vec<PathSegment> = Vec::new();
4
    for ring in multipolygon.rings.as_ref() {
4
        let elements = ring.items.as_ref();
4
        if elements.is_empty() {
            continue;
4
        }
4
        let start = elements[0].get_start();
4
        out.push(PathSegment::MoveTo(Point {
4
            x: reference_box.x + start.x,
4
            y: reference_box.y + start.y,
4
        }));
18
        for el in elements {
14
            match el {
14
                SvgPathElement::Line(l) => {
14
                    out.push(PathSegment::LineTo(Point {
14
                        x: reference_box.x + l.end.x,
14
                        y: reference_box.y + l.end.y,
14
                    }));
14
                }
                curve => {
                    // Sample the curve by arc length into line segments.
                    let len = curve.get_length();
                    let steps = ((len / 4.0).ceil() as usize).clamp(1, 64);
                    for i in 1..=steps {
                        let offset = len * (i as f64) / (steps as f64);
                        let t = curve.get_t_at_offset(offset);
                        out.push(PathSegment::LineTo(Point {
                            x: reference_box.x + curve.get_x_at_t(t) as f32,
                            y: reference_box.y + curve.get_y_at_t(t) as f32,
                        }));
                    }
                }
            }
        }
4
        out.push(PathSegment::Close);
    }
3
    out
3
}
/// Computes horizontal line segments where a flattened `path()` shape (a set of
/// `MoveTo`/`LineTo`/`Close` subpaths) intersects a scanline at the given y range. Uses an
/// even-odd fill rule over the union of all subpaths so reversed rings (holes) carve out
/// space. Curves are assumed already flattened to `LineTo`s by `flatten_svg_to_path_segments`.
7
fn path_segments_line_intersection(
7
    segments: &[PathSegment],
7
    y: f32,
7
    line_height: f32,
7
) -> Vec<(f32, f32)> {
7
    let line_center_y = y + line_height / 2.0;
7
    let mut crossings: Vec<f32> = Vec::new();
    // Walk the segments, reconstructing each subpath's vertices and intersecting its
    // (closing) edges with the scanline.
7
    let mut subpath: Vec<Point> = Vec::new();
20
    let flush = |subpath: &mut Vec<Point>, crossings: &mut Vec<f32>| {
20
        if subpath.len() >= 2 {
26
            for i in 0..subpath.len() {
26
                let p1 = subpath[i];
26
                let p2 = subpath[(i + 1) % subpath.len()];
26
                if (p2.y - p1.y).abs() < f32::EPSILON {
14
                    continue;
12
                }
12
                let crosses = (p1.y <= line_center_y && p2.y > line_center_y)
7
                    || (p1.y > line_center_y && p2.y <= line_center_y);
12
                if crosses {
10
                    let t = (line_center_y - p1.y) / (p2.y - p1.y);
10
                    crossings.push(t.mul_add(p2.x - p1.x, p1.x));
10
                }
            }
14
        }
20
        subpath.clear();
20
    };
40
    for seg in segments {
33
        match seg {
7
            PathSegment::MoveTo(p) => {
7
                flush(&mut subpath, &mut crossings);
7
                subpath.push(*p);
7
            }
20
            PathSegment::LineTo(p) => subpath.push(*p),
6
            PathSegment::Close => flush(&mut subpath, &mut crossings),
            // CurveTo/QuadTo/Arc should have been flattened to LineTo already; sample the
            // end point as a fallback so an unflattened path still produces a polygon.
            PathSegment::CurveTo { end, .. } | PathSegment::QuadTo { end, .. } => {
                subpath.push(*end);
            }
            PathSegment::Arc { center, .. } => subpath.push(*center),
        }
    }
7
    flush(&mut subpath, &mut crossings);
8
    crossings.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
7
    let mut spans = Vec::new();
7
    for chunk in crossings.chunks_exact(2) {
5
        if chunk[1] > chunk[0] {
5
            spans.push((chunk[0], chunk[1]));
5
        }
    }
7
    spans
7
}
/// Helper function to get the horizontal spans of any shape at a given y-coordinate.
/// Returns a list of (`start_x`, `end_x`) tuples.
#[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
553
fn get_shape_horizontal_spans(
553
    shape: &ShapeBoundary,
553
    y: f32,
553
    line_height: f32,
553
) -> Vec<(f32, f32)> {
553
    match shape {
545
        ShapeBoundary::Rectangle(rect) => {
            // Check for any overlap between the line box [y, y + line_height]
            // and the rectangle's vertical span [rect.y, rect.y + rect.height].
545
            let line_start = y;
545
            let line_end = y + line_height;
545
            let rect_start = rect.y;
545
            let rect_end = rect.y + rect.height;
545
            if line_start < rect_end && line_end > rect_start {
406
                vec![(rect.x, rect.x + rect.width)]
            } else {
139
                vec![]
            }
        }
3
        ShapeBoundary::Circle { center, radius } => {
3
            let line_center_y = y + line_height / 2.0;
3
            let dy = (line_center_y - center.y).abs();
3
            if dy <= *radius {
2
                let dx = (radius.powi(2) - dy.powi(2)).sqrt();
2
                vec![(center.x - dx, center.x + dx)]
            } else {
1
                vec![]
            }
        }
1
        ShapeBoundary::Ellipse { center, radii } => {
1
            let line_center_y = y + line_height / 2.0;
1
            let dy = line_center_y - center.y;
1
            if dy.abs() <= radii.height {
                // Formula: (x-h)^2/a^2 + (y-k)^2/b^2 = 1
                let y_term = dy / radii.height;
                let x_term_squared = 1.0 - y_term.powi(2);
                if x_term_squared >= 0.0 {
                    let dx = radii.width * x_term_squared.sqrt();
                    vec![(center.x - dx, center.x + dx)]
                } else {
                    vec![]
                }
            } else {
1
                vec![]
            }
        }
1
        ShapeBoundary::Polygon { points } => {
1
            let segments = polygon_line_intersection(points, y, line_height);
1
            segments
1
                .iter()
1
                .map(|s| (s.start_x, s.start_x + s.width))
1
                .collect()
        }
        // Scanline intersection for `path()` shapes. `segments` is the flattened
        // (Close-terminated, curves pre-sampled) output of `flatten_svg_to_path_segments`;
        // intersect each subpath polygon with this scanline under an even-odd fill rule so
        // reversed rings (holes) carve out space.
3
        ShapeBoundary::Path { segments } => {
3
            path_segments_line_intersection(segments, y, line_height)
        }
    }
553
}
/// Merges overlapping or adjacent line segments into larger ones.
410
fn merge_segments(mut segments: Vec<LineSegment>) -> Vec<LineSegment> {
410
    if segments.len() <= 1 {
299
        return segments;
111
    }
113
    segments.sort_by(|a, b| a.start_x.partial_cmp(&b.start_x).unwrap_or(Ordering::Equal));
111
    let mut merged = vec![segments[0]];
113
    for next_seg in segments.iter().skip(1) {
113
        let last = merged.last_mut().unwrap();
113
        if next_seg.start_x <= last.start_x + last.width {
2
            let new_width = (next_seg.start_x + next_seg.width) - last.start_x;
2
            last.width = last.width.max(new_width);
111
        } else {
111
            merged.push(*next_seg);
111
        }
    }
111
    merged
410
}
/// Computes horizontal line segments where a polygon intersects a scanline at the given y range.
#[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
10
fn polygon_line_intersection(
10
    points: &[Point],
10
    y: f32,
10
    line_height: f32,
10
) -> Vec<LineSegment> {
10
    if points.len() < 3 {
3
        return vec![];
7
    }
7
    let line_center_y = y + line_height / 2.0;
7
    let mut intersections = Vec::new();
    // Use winding number algorithm for robustness with complex polygons.
21
    for i in 0..points.len() {
21
        let p1 = points[i];
21
        let p2 = points[(i + 1) % points.len()];
        // Skip horizontal edges as they don't intersect a horizontal scanline in a meaningful way.
21
        if (p2.y - p1.y).abs() < f32::EPSILON {
9
            continue;
12
        }
        // Check if our horizontal scanline at `line_center_y` crosses this polygon edge.
12
        let crosses = (p1.y <= line_center_y && p2.y > line_center_y)
9
            || (p1.y > line_center_y && p2.y <= line_center_y);
12
        if crosses {
6
            // Calculate intersection x-coordinate using linear interpolation.
6
            let t = (line_center_y - p1.y) / (p2.y - p1.y);
6
            let x = p1.x + t * (p2.x - p1.x);
6
            intersections.push(x);
6
        }
    }
    // Sort intersections by x-coordinate to form spans.
7
    intersections.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
    // Build segments from paired intersection points.
7
    let mut segments = Vec::new();
7
    for chunk in intersections.chunks_exact(2) {
3
        let start_x = chunk[0];
3
        let end_x = chunk[1];
3
        if end_x > start_x {
3
            segments.push(LineSegment {
3
                start_x,
3
                width: end_x - start_x,
3
                priority: 0,
3
            });
3
        }
    }
7
    segments
10
}
// ADDITION: A helper function to get a hyphenator.
/// Helper to get a hyphenator for a given language.
/// TODO: In a real app, this would be cached.
#[cfg(feature = "text_layout_hyphenation")]
1
fn get_hyphenator(language: HyphenationLanguage) -> Result<Standard, LayoutError> {
1
    Standard::from_embedded(language).map_err(|e| LayoutError::HyphenationError(e.to_string()))
1
}
/// Stub when hyphenation is disabled - always returns an error
#[cfg(not(feature = "text_layout_hyphenation"))]
fn get_hyphenator(_language: Language) -> Result<Standard, LayoutError> {
    Err(LayoutError::HyphenationError("Hyphenation feature not enabled".to_string()))
}
// +spec:inline-block:6e7dd9 - Non-tailorable Unicode line breaking controls take precedence over atomic inline rules (CSS-TEXT-3 recent changes, issue 8972)
6789594
const fn is_break_suppressing_control(ch: char) -> bool {
6789594
    matches!(ch,
        '\u{200D}' | // ZERO WIDTH JOINER
        '\u{2060}' | // WORD JOINER
        '\u{FEFF}'   // ZERO WIDTH NO-BREAK SPACE
    )
6789594
}
47
const fn is_break_forcing_control(ch: char) -> bool {
47
    matches!(ch,
        '\u{200B}' | // ZERO WIDTH SPACE (already handled but included for completeness)
        '\u{2028}' | // LINE SEPARATOR
        '\u{2029}'   // PARAGRAPH SEPARATOR
    )
47
}
// +spec:line-breaking:495247 - CJK/syllabic writing systems allow breaks between typographic letter units with varying strictness
// §5.2 word-break: determines if a character is CJK ideograph/kana
844711
const fn is_cjk_character(ch: char) -> bool {
844711
    let cp = ch as u32;
844711
    matches!(cp,
        // CJK Unified Ideographs
348
        0x4E00..=0x9FFF |
        // CJK Unified Ideographs Extension A
23
        0x3400..=0x4DBF |
        // CJK Unified Ideographs Extension B
3
        0x20000..=0x2A6DF |
        // CJK Compatibility Ideographs
21
        0xF900..=0xFAFF |
        // Hiragana
40
        0x3040..=0x309F |
        // Katakana
36
        0x30A0..=0x30FF |
        // Katakana Phonetic Extensions
23
        0x31F0..=0x31FF |
        // CJK Symbols and Punctuation
25
        0x3000..=0x303F |
        // Halfwidth and Fullwidth Forms
20
        0xFF00..=0xFFEF |
        // Hangul Syllables
21
        0xAC00..=0xD7AF
    )
844711
}
// §5.2 word-break: checks if a cluster contains CJK characters
4366920
const fn is_cjk_cluster(cluster: &ShapedCluster) -> bool {
4366920
    cluster.flags.has(ClusterFlags::HAS_CJK)
4366920
}
// +spec:line-breaking:e1fc9d - word-break normal/break-all/keep-all break opportunity rules
// +spec:line-breaking:73d5fe - word-break break-point determination for CJK and Latin text
// +spec:line-breaking:31ef1a - word-break property controls soft wrap opportunities between letters (NU/AL/AI/ID classes as letter units)
// +spec:line-breaking:798252 - word-break property affects break opportunities (normal/break-all/keep-all)
// +spec:line-breaking:8fed57 - word-break: break-all treats all clusters as break opportunities, keep-all suppresses CJK breaks
// +spec:line-breaking:e2b374 - word-break: normal (only at separators) vs break-all (between all letters incl. Ethiopic)
// +spec:overflow:53a97f - word-break (normal/break-all/keep-all) and line-break strictness rules
// +spec:line-breaking:1c830a - word-break: normal/break-all/keep-all break opportunity rules
// §5.2 word-break property: break opportunity logic
// +spec:line-breaking:a75147 - word-break property: normal (CJK breaks), break-all (every cluster), keep-all (suppress CJK breaks)
// +spec:line-breaking:65ab41 - word-break: normal/break-all/keep-all break opportunity rules
// +spec:line-breaking:7eca16 - U+200B ZERO WIDTH SPACE is always a break opportunity, even with keep-all
5062538
pub(crate) fn is_break_opportunity_with_word_break(item: &ShapedItem, word_break: WordBreak, hyphens: Hyphens) -> bool {
    // No-break spaces (UAX#14 class GL/WJ) are word separators for word-spacing
    // purposes but must NOT offer a soft-wrap opportunity. This is the segmentation
    // path used by BreakCursor::peek_next_unit, so it must suppress them the same way
    // the dedicated is_break_opportunity() does; otherwise "10\u{00A0}km" wrongly wraps.
5062538
    if let ShapedItem::Cluster(c) = item {
5061213
        if c.flags.has(ClusterFlags::NO_BREAK_SPACE) {
22
            return false;
5061191
        }
1325
    }
    // Break after spaces or explicit break items (always, regardless of word-break).
5062516
    if is_word_separator(item) {
681910
        return true;
4380606
    }
4380606
    if let ShapedItem::Break { .. } = item {
271
        return true;
4380335
    }
    // +spec:line-breaking:432d5b - hyphens property controls soft wrap opportunities via hyphenation
    // +spec:line-breaking:5a32a1 - soft hyphen (U+00AD) creates break opportunity; glyph styled per surrounding text properties
    // U+200B ZERO WIDTH SPACE is always a soft wrap opportunity regardless of word-break.
    // This allows authors to mark explicit wrap points (e.g. with <wbr> or &#x200B;)
    // even when using word-break: keep-all to suppress other breaks.
4380335
    if is_zero_width_space(item) {
57
        return true;
4380278
    }
    // only when hyphens != none. With hyphens:none, soft hyphens do not create break points.
4380278
    if hyphens != Hyphens::None {
4380111
        if let ShapedItem::Cluster(c) = item {
4379057
            if c.flags.has(ClusterFlags::SOFT_HYPHEN_START) {
29
                return true;
4379028
            }
1054
        }
167
    }
    // +spec:line-breaking:05e09a - U+002D HYPHEN-MINUS / U+2010 HYPHEN always create a
    // soft-wrap opportunity AFTER them (UAX#14 class HY/BA), independent of the hyphens
    // property (they are NOT hyphenation opportunities — no extra glyph is inserted).
    // U+002F SOLIDUS (UAX#14 class SY) likewise offers a break AFTER it (URLs/paths),
    // matching browser practice. Mirrors is_break_opportunity(); this predicate drives
    // the greedy BreakCursor path, which previously never broke after a plain hyphen/slash.
4380249
    if let ShapedItem::Cluster(c) = item {
4379195
        if c.flags.has(ClusterFlags::ENDS_BREAKABLE) {
11626
            return true;
4367569
        }
1054
    }
    // +spec:line-breaking:2bbda0 - word-break does not affect soft wrap opportunities around punctuation
4368623
    match word_break {
        WordBreak::Normal => {
            // CJK characters are implicit break opportunities in normal mode.
4367972
            if let ShapedItem::Cluster(c) = item {
4366918
                if is_cjk_cluster(c) {
517
                    return true;
4366401
                }
1054
            }
4367455
            false
        }
        WordBreak::BreakAll => {
            // Every typographic letter unit is a break opportunity.
214
            if let ShapedItem::Cluster(_) = item {
214
                return true;
            }
            false
        }
        WordBreak::KeepAll => {
            // +spec:line-breaking:aa3044 - keep-all suppresses CJK (incl. Korean) inter-character breaks
            // Only break at spaces/hyphens (already handled above).
437
            false
        }
    }
5062538
}
// +spec:line-breaking:db0289 - line-break strictness: anywhere allows soft wrap around every typographic character unit
// +spec:line-breaking:7d242b - line-break strictness levels: loose/normal/strict/anywhere with CJK punctuation rules
// +spec:line-breaking:67bfe8 - line-break strictness (auto/loose/normal/strict/anywhere) controls
// CSS Text Level 3 §5.3: Determines whether a break opportunity before a character is
// allowed based on the line-break strictness level. The spec defines:
// - strict: forbids breaks before small kana (class CJ), CJK hyphens, and certain punctuation
// - normal: allows breaks before small kana (CJ); allows CJK hyphen breaks for CJK writing systems
// - loose: additionally allows breaks before hyphens U+2010/U+2013 after ID-class chars
// - anywhere: allows soft wrap around every typographic character unit
#[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
3544692
const fn is_cjk_break_allowed_by_strictness(
3544692
    ch: char,
3544692
    _prev_ch: Option<char>,
3544692
    strictness: LineBreakStrictness,
3544692
) -> bool {
3544692
    match strictness {
112
        LineBreakStrictness::Anywhere => true,
        LineBreakStrictness::Loose => {
            // Loose allows breaks before hyphens U+2010, U+2013 when preceded by ID-class chars
            // Also allows breaks before small kana (CJ class) and CJK hyphens
4
            true
        }
        LineBreakStrictness::Normal | LineBreakStrictness::Auto => {
            // Normal forbids breaks before hyphens U+2010/U+2013 for non-CJK text
            // but allows breaks before small kana (CJ) and CJK hyphen-like chars
            // (〜 U+301C, ゠ U+30A0) for CJK writing systems
3544569
            match ch {
4
                '\u{2010}' | '\u{2013}' => false, // hyphens forbidden in normal
3544565
                _ => true,
            }
        }
        LineBreakStrictness::Strict => {
            // Strict forbids breaks before:
            // - Small kana and prolonged sound mark (Unicode line break class CJ)
            // - CJK hyphen-like characters: 〜 U+301C, ゠ U+30A0
            // - Hyphens: ‐ U+2010, – U+2013
5
            match ch {
2
                '\u{301C}' | '\u{30A0}' => false, // CJK hyphen-like
                '\u{2010}' | '\u{2013}' => false,  // hyphens
5
                c if is_small_kana(c) => false,
3
                _ => true,
            }
        }
    }
3544692
}
/// Returns true if the character is a Japanese small kana or Katakana-Hiragana prolonged sound mark
/// (Unicode line break class CJ). These are forbidden break points in strict line breaking.
13
const fn is_small_kana(ch: char) -> bool {
13
    matches!(ch,
        '\u{3041}' | // ぁ HIRAGANA LETTER SMALL A
        '\u{3043}' | // ぃ HIRAGANA LETTER SMALL I
        '\u{3045}' | // ぅ HIRAGANA LETTER SMALL U
        '\u{3047}' | // ぇ HIRAGANA LETTER SMALL E
        '\u{3049}' | // ぉ HIRAGANA LETTER SMALL O
        '\u{3063}' | // っ HIRAGANA LETTER SMALL TU
        '\u{3083}' | // ゃ HIRAGANA LETTER SMALL YA
        '\u{3085}' | // ゅ HIRAGANA LETTER SMALL YU
        '\u{3087}' | // ょ HIRAGANA LETTER SMALL YO
        '\u{308E}' | // ゎ HIRAGANA LETTER SMALL WA
        '\u{3095}' | // ゕ HIRAGANA LETTER SMALL KA
        '\u{3096}' | // ゖ HIRAGANA LETTER SMALL KE
        '\u{30A1}' | // ァ KATAKANA LETTER SMALL A
        '\u{30A3}' | // ィ KATAKANA LETTER SMALL I
        '\u{30A5}' | // ゥ KATAKANA LETTER SMALL U
        '\u{30A7}' | // ェ KATAKANA LETTER SMALL E
        '\u{30A9}' | // ォ KATAKANA LETTER SMALL O
        '\u{30C3}' | // ッ KATAKANA LETTER SMALL TU
        '\u{30E3}' | // ャ KATAKANA LETTER SMALL YA
        '\u{30E5}' | // ュ KATAKANA LETTER SMALL YU
        '\u{30E7}' | // ョ KATAKANA LETTER SMALL YO
        '\u{30EE}' | // ヮ KATAKANA LETTER SMALL WA
        '\u{30F5}' | // ヵ KATAKANA LETTER SMALL KA
        '\u{30F6}' | // ヶ KATAKANA LETTER SMALL KE
        '\u{30FC}'   // ー KATAKANA-HIRAGANA PROLONGED SOUND MARK
    )
13
}
// for every typographic character unit, disregarding GL/WJ/ZWJ line breaking classes
// replaced element or other atomic inline for web-compat
43
fn is_break_opportunity(item: &ShapedItem) -> bool {
    // Per CSS Text 3 §5.1: "there is a soft wrap opportunity before and
    // after each replaced element or other atomic inline"
43
    if matches!(item, ShapedItem::Object { .. } | ShapedItem::CombinedBlock { .. }) {
1
        return true;
42
    }
    // over atomic inline rules: break-forcing controls (ZWSP, LS, PS) create break opportunities
    // even adjacent to atomic inlines, while break-suppressing controls (WJ, ZWJ, ZWNBSP)
    // prevent breaks
42
    if let ShapedItem::Cluster(c) = item {
        // ZW (zero-width space U+200B) is always a break opportunity
40
        if c.text().contains('\u{200B}') {
1
            return true;
39
        }
        // Break-forcing Unicode controls (LS, PS) create break opportunities
39
        if c.text().chars().any(is_break_forcing_control) {
            return true;
39
        }
        // WJ (word joiner U+2060), ZWJ (U+200D), and GL (NBSP U+00A0) suppress breaks
39
        if c.text().chars().any(|ch| matches!(ch, '\u{2060}' | '\u{200D}' | '\u{00A0}')) {
1
            return false;
38
        }
        // +spec:line-breaking:05e09a - U+002D/U+2010 always create soft wrap opportunities regardless of hyphens property
        // are always visible and create a soft wrap opportunity after them, but are NOT
        // hyphenation opportunities (no extra glyph is inserted at the break).
38
        if c.text().ends_with('\u{002D}') || c.text().ends_with('\u{2010}') {
            return true;
38
        }
2
    }
40
    is_break_opportunity_with_word_break(item, WordBreak::Normal, Hyphens::Manual)
43
}
// A cursor to manage the state of the line breaking process.
// This allows us to handle items that are partially consumed by hyphenation.
// `Clone` is used to take a cheap snapshot for the multi-column balancing dry run
// (measuring total line count without consuming the real cursor).
#[derive(Debug, Clone)]
pub struct BreakCursor<'a> {
    /// A reference to the complete list of shaped items.
    pub items: &'a [ShapedItem],
    /// The index of the next *full* item to be processed from the `items` slice.
    pub next_item_index: usize,
    /// The remainder of an item that was split by hyphenation on the previous line.
    /// This will be the very first piece of content considered for the next line.
    pub partial_remainder: Vec<ShapedItem>,
    // §5.2 word-break property stored on cursor
    pub word_break: WordBreak,
    pub hyphens: Hyphens,
    pub line_break: LineBreakStrictness,
}
impl<'a> BreakCursor<'a> {
3758
    #[must_use] pub fn new(items: &'a [ShapedItem]) -> Self {
3758
        Self {
3758
            items,
3758
            next_item_index: 0,
3758
            partial_remainder: Vec::new(),
3758
            word_break: WordBreak::Normal,
3758
            hyphens: Hyphens::default(),
3758
            line_break: LineBreakStrictness::default(),
3758
        }
3758
    }
148566
    #[must_use] pub fn with_word_break(items: &'a [ShapedItem], word_break: WordBreak) -> Self {
148566
        Self {
148566
            items,
148566
            next_item_index: 0,
148566
            partial_remainder: Vec::new(),
148566
            word_break,
148566
            hyphens: Hyphens::default(),
148566
            line_break: LineBreakStrictness::default(),
148566
        }
148566
    }
    /// Checks if the cursor is at the very beginning of the content stream.
6
    #[must_use] pub const fn is_at_start(&self) -> bool {
6
        self.next_item_index == 0 && self.partial_remainder.is_empty()
6
    }
    /// Consumes the cursor and returns all remaining items as a `Vec`.
148601
    pub fn drain_remaining(&mut self) -> Vec<ShapedItem> {
148601
        let mut remaining = std::mem::take(&mut self.partial_remainder);
148601
        if self.next_item_index < self.items.len() {
1
            remaining.extend_from_slice(&self.items[self.next_item_index..]);
148600
        }
148601
        self.next_item_index = self.items.len();
148601
        remaining
148601
    }
    /// Checks if all content, including any partial remainders, has been processed.
1021132
    #[must_use] pub const fn is_done(&self) -> bool {
1021132
        self.next_item_index >= self.items.len() && self.partial_remainder.is_empty()
1021132
    }
    /// Consumes a number of items from the cursor's stream.
607401
    pub fn consume(&mut self, count: usize) {
607401
        if count == 0 {
1
            return;
607400
        }
607400
        let remainder_len = self.partial_remainder.len();
607400
        if count <= remainder_len {
1
            // Consuming only from the remainder.
1
            self.partial_remainder.drain(..count);
607399
        } else {
607399
            // Consuming all of the remainder and some from the main list.
607399
            let from_main_list = count - remainder_len;
607399
            self.partial_remainder.clear();
607399
            self.next_item_index += from_main_list;
607399
        }
607401
    }
    /// Looks ahead and returns the next "unbreakable" unit of content.
    /// This is typically a word (a series of non-space clusters) followed by a
    /// space, or just a single space if that's next.
    /// The definition of "unbreakable unit" depends on the word-break property.
    // a single typographic character unit (every character is a soft wrap opportunity), including
    // punctuation and preserved white spaces; currently handled via peek_next_single_item
960442
    pub fn peek_next_unit(&self) -> Vec<ShapedItem> {
960442
        let mut unit = Vec::new();
        // The remaining stream, WITHOUT materializing it. This used to be
        // `partial_remainder.clone()` + `extend_from_slice(rest)` — a deep
        // clone of every remaining ShapedItem (each carrying a String and a
        // Vec<Glyph>) on EVERY call. The line breaker calls this once per
        // word, so laying out an N-cluster paragraph performed O(N²) deep
        // clones: ~90% of line-breaking time on an ordinary document, and
        // line breaking was ~40% of a full pagination. Only the items that
        // actually enter `unit` (one word) are cloned now.
1529840
        let source_items = || {
1529840
            self.partial_remainder
1529840
                .iter()
1529840
                .chain(self.items[self.next_item_index..].iter())
1529840
        };
960442
        let Some(first) = source_items().next() else {
152079
            return unit;
        };
        // If the first item is a break opportunity (like a space), it's a unit on its own.
808363
        if is_break_opportunity_with_word_break(first, self.word_break, self.hyphens) {
238964
            unit.push(first.clone());
238964
            return unit;
569399
        }
        // Otherwise, collect all items until the next break opportunity.
        // For break-all: each cluster is its own unit.
        // For keep-all: CJK sequences are NOT break opportunities.
        // For normal: CJK characters are individual break opportunities.
        // glue items together: if the last cluster ends with a break-suppressing control,
        // the next item cannot be separated from it.
569399
        let mut suppress_next_break = false;
3545335
        for (i, item) in source_items().enumerate() {
            // Also suppress break if this item starts with a break-suppressing control
            // (WJ/ZWJ/ZWNBSP suppress breaks on both sides per Unicode line breaking)
3545335
            let starts_with_suppress = if let ShapedItem::Cluster(c) = item {
3544669
                c.text().chars().next().is_some_and(is_break_suppressing_control)
            } else {
666
                false
            };
            // If the item is a CJK cluster, check if the break is allowed by strictness
3545335
            let cjk_strictness_suppressed = if let ShapedItem::Cluster(c) = item {
3544669
                c.text().chars().next().is_some_and(|ch| {
3544669
                    !is_cjk_break_allowed_by_strictness(ch, None, self.line_break)
3544669
                })
            } else {
666
                false
            };
3545335
            if i > 0 && !suppress_next_break && !starts_with_suppress && !cjk_strictness_suppressed && is_break_opportunity_with_word_break(item, self.word_break, self.hyphens) {
299880
                break;
3245455
            }
3245455
            suppress_next_break = false;
3245455
            unit.push(item.clone());
            // Check if this item ends with a break-suppressing control character
3245455
            if let ShapedItem::Cluster(c) = item {
3244915
                if let Some(last_ch) = c.text().chars().last() {
3244915
                    if is_break_suppressing_control(last_ch) {
1
                        suppress_next_break = true;
3244914
                    }
                }
540
            }
            // For break-all, each non-space cluster is a unit on its own
3245455
            if self.word_break == WordBreak::BreakAll {
                if let ShapedItem::Cluster(_) = item {
1
                    break;
                }
3245455
            }
        }
569399
        unit
960442
    }
89
    #[must_use] pub fn peek_next_single_item(&self) -> Vec<ShapedItem> {
89
        if !self.partial_remainder.is_empty() {
3
            return vec![self.partial_remainder[0].clone()];
86
        }
86
        if self.next_item_index < self.items.len() {
75
            return vec![self.items[self.next_item_index].clone()];
11
        }
11
        Vec::new()
89
    }
}
// A structured result from a hyphenation attempt.
struct HyphenationResult {
    /// The items that fit on the current line, including the new hyphen.
    line_part: Vec<ShapedItem>,
    /// The remainder of the split item to be carried over to the next line.
    remainder_part: Vec<ShapedItem>,
}
fn perform_bidi_analysis<'a>(
    styled_runs: &'a [TextRunInfo<'_>],
    full_text: &'a str,
    force_lang: Option<Language>,
) -> (Vec<VisualRun<'a>>, BidiDirection) {
    if full_text.is_empty() {
        return (Vec::new(), BidiDirection::Ltr);
    }
    let bidi_info = BidiInfo::new(full_text, None);
    let para = &bidi_info.paragraphs[0];
    let base_direction = if para.level.is_rtl() {
        BidiDirection::Rtl
    } else {
        BidiDirection::Ltr
    };
    // Create a map from each byte index to its original styled run.
    let mut byte_to_run_index: Vec<usize> = vec![0; full_text.len()];
    for (run_idx, run) in styled_runs.iter().enumerate() {
        let start = run.logical_start;
        let end = start + run.text.len();
        for slot in &mut byte_to_run_index[start..end] {
            *slot = run_idx;
        }
    }
    let mut final_visual_runs = Vec::new();
    let (levels, visual_run_ranges) = bidi_info.visual_runs(para, para.range.clone());
    for range in visual_run_ranges {
        let bidi_level = levels[range.start];
        let mut sub_run_start = range.start;
        // Iterate through the bytes of the visual run to detect style changes.
        for i in (range.start + 1)..range.end {
            if byte_to_run_index[i] != byte_to_run_index[sub_run_start] {
                // Style boundary found. Finalize the previous sub-run.
                let original_run_idx = byte_to_run_index[sub_run_start];
                let script = crate::text3::script::detect_script(&full_text[sub_run_start..i])
                    .unwrap_or(Script::Latin);
                final_visual_runs.push(VisualRun {
                    text_slice: &full_text[sub_run_start..i],
                    style: styled_runs[original_run_idx].style.clone(),
                    logical_start_byte: sub_run_start,
                    bidi_level: BidiLevel::new(bidi_level.number()),
                    language: force_lang.unwrap_or_else(|| {
                        script_to_language(
                            script,
                            &full_text[sub_run_start..i],
                        )
                    }),
                    script,
                });
                // Start a new sub-run.
                sub_run_start = i;
            }
        }
        // Add the last sub-run (or the only one if no style change occurred).
        let original_run_idx = byte_to_run_index[sub_run_start];
        let script = crate::text3::script::detect_script(&full_text[sub_run_start..range.end])
            .unwrap_or(Script::Latin);
        final_visual_runs.push(VisualRun {
            text_slice: &full_text[sub_run_start..range.end],
            style: styled_runs[original_run_idx].style.clone(),
            logical_start_byte: sub_run_start,
            bidi_level: BidiLevel::new(bidi_level.number()),
            script,
            language: force_lang.unwrap_or_else(|| {
                script_to_language(
                    script,
                    &full_text[sub_run_start..range.end],
                )
            }),
        });
    }
    (final_visual_runs, base_direction)
}
15
const fn get_justification_priority(class: CharacterClass) -> u8 {
15
    match class {
3
        CharacterClass::Space => 0,
2
        CharacterClass::Punctuation => 64,
2
        CharacterClass::Ideograph => 128,
3
        CharacterClass::Letter => 192,
2
        CharacterClass::Symbol => 224,
3
        CharacterClass::Combining => 255,
    }
15
}
#[cfg(test)]
mod shape_outside_and_ruby_tests {
    use super::*;
    use azul_css::shape::{CssShape, ShapePath};
4
    fn path_shape(d: &str) -> CssShape {
4
        CssShape::Path(ShapePath {
4
            data: d.into(),
4
        })
4
    }
    // --- shape-outside: path() ----------------------------------------------
    #[test]
1
    fn css_path_shape_builds_path_boundary_not_rect_fallback() {
        // A right triangle (0,0)-(100,0)-(0,100).
1
        let shape = path_shape("M 0 0 L 100 0 L 0 100 Z");
1
        let rbox = Rect { x: 0.0, y: 0.0, width: 100.0, height: 100.0 };
1
        let boundary = ShapeBoundary::from_css_shape(&shape, rbox, &mut None);
1
        match boundary {
1
            ShapeBoundary::Path { segments } => {
1
                assert!(!segments.is_empty(), "path() must flatten to real segments");
1
                assert!(matches!(segments[0], PathSegment::MoveTo(_)));
5
                assert!(segments.iter().any(|s| matches!(s, PathSegment::Close)));
            }
            other => panic!("expected ShapeBoundary::Path, got {other:?}"),
        }
1
    }
    #[test]
1
    fn empty_or_garbage_path_falls_back_to_rectangle() {
1
        let rbox = Rect { x: 0.0, y: 0.0, width: 50.0, height: 50.0 };
1
        let boundary = ShapeBoundary::from_css_shape(&path_shape("   "), rbox, &mut None);
1
        assert!(matches!(boundary, ShapeBoundary::Rectangle(_)),
            "unparseable path() should fall back to the reference rectangle");
1
    }
    #[test]
1
    fn path_triangle_narrows_line_box_per_scanline() {
        // Right triangle with the hypotenuse running (100,0) -> (0,100).
        // At scanline y, the shape spans x in [0, 100 - y]. So the available band
        // must NARROW as y increases — the proof that real path geometry (not a
        // full-width rect) drives the per-line exclusion.
1
        let shape = path_shape("M 0 0 L 100 0 L 0 100 Z");
1
        let rbox = Rect { x: 0.0, y: 0.0, width: 100.0, height: 100.0 };
1
        let boundary = ShapeBoundary::from_css_shape(&shape, rbox, &mut None);
1
        let spans_top = get_shape_horizontal_spans(&boundary, 10.0, 1.0);
1
        let spans_bot = get_shape_horizontal_spans(&boundary, 80.0, 1.0);
1
        assert_eq!(spans_top.len(), 1, "single span expected near the top");
1
        assert_eq!(spans_bot.len(), 1, "single span expected near the bottom");
1
        let width_top = spans_top[0].1 - spans_top[0].0;
1
        let width_bot = spans_bot[0].1 - spans_bot[0].0;
        // Geometry check: width ~= 100 - y (line center is y + 0.5).
1
        assert!((width_top - 89.5).abs() < 1.5, "top width {width_top} != ~89.5");
1
        assert!((width_bot - 19.5).abs() < 1.5, "bottom width {width_bot} != ~19.5");
1
        assert!(width_top > width_bot,
            "path() exclusion band must narrow with y ({width_top} !> {width_bot})");
        // And it must differ from a plain full-width rectangle (which would be 0..100
        // at every scanline) — i.e. this is not the old rect/empty stub.
1
        assert!(width_bot < 50.0, "rect fallback would give full width here");
1
    }
    #[test]
1
    fn path_with_hole_carves_out_interior_via_even_odd() {
        // Outer square 0..100 with an inner reversed square 30..70 (a hole). At a
        // scanline through the hole, even-odd fill yields two spans straddling the hole.
1
        let shape = path_shape(
1
            "M 0 0 L 100 0 L 100 100 L 0 100 Z \
1
             M 30 30 L 30 70 L 70 70 L 70 30 Z",
        );
1
        let rbox = Rect { x: 0.0, y: 0.0, width: 100.0, height: 100.0 };
1
        let boundary = ShapeBoundary::from_css_shape(&shape, rbox, &mut None);
1
        let spans = get_shape_horizontal_spans(&boundary, 50.0, 1.0);
1
        assert_eq!(spans.len(), 2, "hole should split the band into two spans: {spans:?}");
1
    }
    // --- ruby ----------------------------------------------------------------
    #[test]
    #[allow(clippy::float_cmp)] // exact, representable expected values
1
    fn ruby_annotation_font_scale_is_real_not_06_fudge() {
        // The annotation is sized at the used font-size of the ruby-text run, which the
        // UA stylesheet sets to 50% of the base — NOT a 0.6 per-character fudge.
1
        let base_font_size = 20.0_f32;
1
        let annotation_font_size = base_font_size * RUBY_ANNOTATION_FONT_SCALE;
1
        assert_eq!(annotation_font_size, 10.0);
1
        assert!((RUBY_ANNOTATION_FONT_SCALE - 0.6).abs() > f32::EPSILON,
            "annotation scale must not be the old 0.6 magic ratio");
1
    }
    #[test]
    #[allow(clippy::float_cmp)] // exact, representable expected values
1
    fn ruby_box_reserves_max_width_and_stacks_annotation_above_base() {
        // Wider base, narrower annotation: reserved inline-size = base width.
1
        let (w, h) = ruby_reserved_box(80.0, 30.0, 24.0, 12.0);
1
        assert_eq!(w, 80.0, "reserved width is the wider of base/annotation");
        // Block-size stacks the annotation line above the base line => base reserves
        // vertical space for the annotation.
1
        assert_eq!(h, 36.0, "block-size = base line + annotation line");
1
        assert!(h > 24.0, "ruby box must reserve extra vertical space for the annotation");
        // Narrower base, wider annotation: reserved inline-size = annotation width.
1
        let (w2, _) = ruby_reserved_box(20.0, 50.0, 24.0, 12.0);
1
        assert_eq!(w2, 50.0, "a long annotation widens the reserved box");
1
    }
}
#[cfg(test)]
mod font_cache_swap_tests {
    use azul_css::props::basic::FontRef;
    use super::*;
    /// The invariant `memory_families` exists to uphold: it is an INDEX into
    /// `fc_cache`, so every `FontId` it hands to chain resolution must still be
    /// loadable from the cache that is live *right now*. A dangling id does not
    /// fail loudly — it resolves, then fails to load, and the text silently
    /// re-measures with the fallback font's metrics.
4
    fn assert_index_is_live(m: &FontManager<FontRef>) {
12
        for (family, faces) in &m.memory_families {
16
            for f in faces {
8
                assert!(
8
                    m.fc_cache.is_memory_font(&f.font_match.id),
                    "`{family}` is indexed with {:?}, which the live fc_cache cannot load",
                    f.font_match.id
                );
            }
        }
4
    }
    #[test]
1
    fn swapping_the_fc_cache_does_not_strand_a_dead_memory_font_id() {
1
        let mut m: FontManager<FontRef> =
1
            FontManager::new(FcFontCache::default()).expect("FontManager::new must not fail");
1
        let norm = rust_fontconfig::utils::normalize_family_name("Azul Mock Mono");
1
        let before = m
1
            .memory_families
1
            .get(&norm)
1
            .cloned()
1
            .expect("the built-in mock fonts are registered by every constructor");
1
        assert_eq!(before.len(), 1);
1
        assert_index_is_live(&m);
        // Exactly what the DLL does at the top of EVERY `regenerate_layout`.
4
        for swap in 1..=3 {
3
            m.replace_fc_cache(FcFontCache::default());
3
            assert_index_is_live(&m);
3
            let faces = m
3
                .memory_families
3
                .get(&norm)
3
                .expect("the mock fonts are re-registered into the new cache");
3
            assert_eq!(
3
                faces.len(),
                1,
                "swap {swap} appended a face instead of replacing it: the index grows by one \
                 dead face per cache swap and `pick_memory_face` keeps returning the first \
                 (dead) one"
            );
        }
        // Non-vacuity: the fresh caches really were empty, so the face WAS
        // re-minted under a new id — the old id is exactly the one that used to
        // be stranded at the head of the list.
1
        let after = &m.memory_families[&norm];
1
        assert_ne!(
1
            after[0].font_match.id, before[0].font_match.id,
            "a fresh FcFontCache cannot already contain the mock font"
        );
1
        assert!(
1
            !m.fc_cache.is_memory_font(&before[0].font_match.id),
            "the pre-swap id must be dead — otherwise this test proves nothing"
        );
1
    }
}
/// Adversarial unit tests generated for `layout/src/text3/cache.rs`.
///
/// These probe the boundaries the production code never sees: NaN / ±inf floats,
/// `u16::MAX` units-per-em, empty slices, `usize::MAX` counts, degenerate geometry
/// and sentinel-value round trips. Where a function has a surprising-but-real
/// behaviour (e.g. `round_eq(NaN, 0.0) == true`), the test PINS that behaviour and
/// says so, rather than pretending it is safe.
#[cfg(test)]
#[allow(
    clippy::float_cmp,
    clippy::too_many_lines,
    clippy::unreadable_literal,
    clippy::cast_precision_loss,
    clippy::similar_names
)]
mod autotest_generated {
    use super::*;
    // ---------------------------------------------------------------------
    // Fixtures
    // ---------------------------------------------------------------------
    fn metrics(upem: u16, ascent: f32, descent: f32, line_gap: f32) -> LayoutFontMetrics {
        LayoutFontMetrics {
            ascent,
            descent,
            line_gap,
            units_per_em: upem,
            x_height: None,
            cap_height: None,
        }
    }
    /// 1000 upem, 800 asc, -200 desc, 0 gap → `line-height: normal` == 1.0em.
    fn std_metrics() -> LayoutFontMetrics {
        metrics(1000, 800.0, -200.0, 0.0)
    }
    fn style() -> Arc<StyleProperties> {
        Arc::new(StyleProperties::default())
    }
    fn styled(f: impl FnOnce(&mut StyleProperties)) -> Arc<StyleProperties> {
        let mut s = StyleProperties::default();
        f(&mut s);
        Arc::new(s)
    }
    const fn ci(run: u32, item: u32) -> ContentIndex {
        ContentIndex {
            run_index: run,
            item_index: item,
        }
    }
    const fn gid(run: u32, byte: u32) -> GraphemeClusterId {
        GraphemeClusterId {
            source_run: run,
            start_byte_in_run: byte,
        }
    }
    fn shaped_glyph(st: Arc<StyleProperties>, fm: LayoutFontMetrics, advance: f32) -> ShapedGlyph {
        ShapedGlyph {
            kind: GlyphKind::Character,
            glyph_id: 42,
            cluster_offset: 0,
            advance,
            kerning: 0.0,
            offset: Point { x: 0.0, y: 0.0 },
            vertical_advance: advance,
            vertical_offset: Point { x: 0.0, y: 0.0 },
            script: Script::Latin,
            font_hash: 0xABCD_u64,
            font_metrics: fm,
        }
    }
    fn make_cluster(
        text: &str,
        advance: f32,
        st: Arc<StyleProperties>,
        glyphs: ShapedGlyphVec,
        id: GraphemeClusterId,
    ) -> ShapedItem {
        ShapedItem::Cluster(ShapedCluster {
            flags: ClusterFlags::classify(text),
            source_text: {
                // Test helper: pad so the slice at the given id's offset
                // yields exactly `text` (production stamps a shared Arc
                // whose offsets are real; tests mint ids freely).
                let mut s = String::new();
                for _ in 0..id.start_byte_in_run { s.push(' '); }
                s.push_str(text);
                Arc::from(s.as_str())
            },
            source_byte_len: text.len() as u16,
            source_cluster_id: id,
            source_content_index: ci(id.source_run, id.start_byte_in_run),
            source_node_id: None,
            glyphs,
            advance,
            direction: BidiDirection::Ltr,
            style: st,
            marker_position_outside: None,
            is_first_fragment: true,
            is_last_fragment: true,
        })
    }
    /// Single-glyph cluster with the standard 1000-upem metrics.
    fn cl(text: &str, advance: f32) -> ShapedItem {
        let st = style();
        let g = shaped_glyph(st.clone(), std_metrics(), advance);
        make_cluster(text, advance, st, smallvec![g], gid(0, 0))
    }
    /// Single-glyph cluster with an explicit grapheme id (for caret tests).
    fn cl_at(text: &str, advance: f32, run: u32, byte: u32) -> ShapedItem {
        let st = style();
        let g = shaped_glyph(st.clone(), std_metrics(), advance);
        make_cluster(text, advance, st, smallvec![g], gid(run, byte))
    }
    /// Cluster carrying an explicit style (letter/word-spacing tests).
    fn cl_styled(text: &str, advance: f32, st: Arc<StyleProperties>) -> ShapedItem {
        let g = shaped_glyph(st.clone(), std_metrics(), advance);
        make_cluster(text, advance, st, smallvec![g], gid(0, 0))
    }
    /// Cluster with NO glyphs — the CSS "strut" case.
    fn cl_no_glyphs(text: &str, advance: f32) -> ShapedItem {
        make_cluster(text, advance, style(), ShapedGlyphVec::new(), gid(0, 0))
    }
    fn obj(width: f32, height: f32, baseline_offset: f32) -> ShapedItem {
        ShapedItem::Object {
            source: ci(0, 0),
            bounds: Rect {
                x: 0.0,
                y: 0.0,
                width,
                height,
            },
            baseline_offset,
            content: InlineContent::Space(InlineSpace {
                width,
                is_breaking: false,
                is_stretchy: false,
            }),
        }
    }
    fn brk() -> ShapedItem {
        ShapedItem::Break {
            source: ci(0, 0),
            break_info: InlineBreak {
                break_type: BreakType::Hard,
                clear: ClearType::None,
                content_index: 0,
            },
        }
    }
    fn tab(width: f32, height: f32) -> ShapedItem {
        ShapedItem::Tab {
            source: ci(0, 0),
            bounds: Rect {
                x: 0.0,
                y: 0.0,
                width,
                height,
            },
        }
    }
    fn pos(item: ShapedItem, x: f32, y: f32, line_index: usize) -> PositionedItem {
        PositionedItem {
            item,
            position: Point { x, y },
            line_index,
        }
    }
    fn text_content(t: &str, st: Arc<StyleProperties>) -> InlineContent {
        InlineContent::Text(StyledRun {
            text: Arc::from(t),
            style: st,
            logical_start_byte: 0,
            source_node_id: None,
        })
    }
    fn sel(family: &str) -> FontSelector {
        FontSelector {
            family: family.to_string(),
            ..FontSelector::default()
        }
    }
    /// A minimal in-memory `ParsedFontTrait` so `LoadedFonts` / `FontManager`
    /// can be exercised without touching the filesystem or fontconfig.
    #[derive(Debug, Clone)]
    struct TestFont {
        hash: u64,
    }
    impl ShallowClone for TestFont {
        fn shallow_clone(&self) -> Self {
            self.clone()
        }
    }
    impl ParsedFontTrait for TestFont {
        fn shape_text(
            &self,
            _text: &str,
            _script: Script,
            _language: Language,
            _direction: BidiDirection,
            _style: &StyleProperties,
        ) -> Result<Vec<Glyph>, LayoutError> {
            Ok(Vec::new())
        }
        fn get_hash(&self) -> u64 {
            self.hash
        }
        fn get_glyph_size(&self, _glyph_id: u16, font_size: f32) -> Option<LogicalSize> {
            Some(LogicalSize {
                width: font_size,
                height: font_size,
            })
        }
        fn get_hyphen_glyph_and_advance(&self, font_size: f32) -> Option<(u16, f32)> {
            Some((1, font_size * 0.3))
        }
        fn get_kashida_glyph_and_advance(&self, font_size: f32) -> Option<(u16, f32)> {
            Some((2, font_size * 0.2))
        }
        fn has_glyph(&self, _codepoint: u32) -> bool {
            true
        }
        fn get_vertical_metrics(&self, _glyph_id: u16) -> Option<VerticalMetrics> {
            None
        }
        fn get_font_metrics(&self) -> LayoutFontMetrics {
            std_metrics()
        }
        fn num_glyphs(&self) -> u16 {
            10
        }
        fn get_space_width(&self) -> Option<usize> {
            Some(500)
        }
    }
    fn hash_of<T: Hash>(v: &T) -> u64 {
        let mut h = DefaultHasher::new();
        v.hash(&mut h);
        h.finish()
    }
    /// Compare two derived f32s. Used wherever the expected value comes out of a
    /// divide-then-multiply chain, whose last-ulp rounding is not worth pinning.
    #[track_caller]
    fn approx(actual: f32, expected: f32) {
        assert!(
            (actual - expected).abs() < 1e-4,
            "expected ~{expected}, got {actual}"
        );
    }
    // =====================================================================
    // numeric: ruby_reserved_box
    // =====================================================================
    #[test]
    fn ruby_reserved_box_zero_and_negative_are_deterministic() {
        assert_eq!(ruby_reserved_box(0.0, 0.0, 0.0, 0.0), (0.0, 0.0));
        // max() of two negatives is the one closer to zero; the block-size sums.
        let (w, h) = ruby_reserved_box(-10.0, -4.0, -3.0, -2.0);
        assert_eq!(w, -4.0);
        assert_eq!(h, -5.0);
    }
    #[test]
    fn ruby_reserved_box_nan_width_is_ignored_by_max_but_poisons_height() {
        // f32::max propagates the NON-NaN operand, so a NaN advance silently
        // yields the other run's width instead of NaN.
        let (w, h) = ruby_reserved_box(f32::NAN, 30.0, 24.0, f32::NAN);
        assert_eq!(w, 30.0, "f32::max discards the NaN operand");
        assert!(h.is_nan(), "but the additive block-size does propagate NaN");
    }
    #[test]
    fn ruby_reserved_box_infinities_do_not_panic() {
        let (w, h) = ruby_reserved_box(f32::INFINITY, 10.0, f32::INFINITY, f32::NEG_INFINITY);
        assert!(w.is_infinite() && w.is_sign_positive());
        assert!(h.is_nan(), "inf + -inf is NaN, not a panic");
    }
    #[test]
    fn ruby_reserved_box_saturates_to_infinity_at_f32_max() {
        let (w, h) = ruby_reserved_box(f32::MAX, f32::MAX, f32::MAX, f32::MAX);
        assert_eq!(w, f32::MAX);
        assert!(h.is_infinite(), "f32 addition saturates, it does not panic");
    }
    // =====================================================================
    // numeric: LineHeight::resolve / resolve_with_metrics
    // =====================================================================
    #[test]
    fn line_height_px_ignores_every_font_metric() {
        let lh = LineHeight::Px(7.5);
        assert_eq!(lh.resolve(16.0, 800.0, -200.0, 0.0, 1000), 7.5);
        // Even garbage metrics cannot perturb an explicit px value.
        assert_eq!(lh.resolve(f32::NAN, f32::NAN, f32::NAN, f32::NAN, 0), 7.5);
    }
    #[test]
    fn line_height_normal_zero_upem_falls_back_to_1_2_em() {
        let lh = LineHeight::Normal;
        assert_eq!(lh.resolve(16.0, 800.0, -200.0, 0.0, 0), 19.2);
        assert_eq!(lh.resolve(0.0, 800.0, -200.0, 0.0, 0), 0.0);
    }
    #[test]
    fn line_height_normal_scales_ascent_minus_descent_plus_gap() {
        // (800 - (-200) + 0) / 1000 * 16 == 16.0
        approx(LineHeight::Normal.resolve(16.0, 800.0, -200.0, 0.0, 1000), 16.0);
        // line_gap widens the line box.
        approx(
            LineHeight::Normal.resolve(16.0, 800.0, -200.0, 250.0, 1000),
            20.0,
        );
        // A descent given with the WRONG (positive) sign shrinks the line box —
        // the formula subtracts it unconditionally.
        approx(LineHeight::Normal.resolve(16.0, 800.0, 200.0, 0.0, 1000), 9.6);
    }
    #[test]
    fn line_height_normal_at_u16_max_upem_does_not_panic() {
        let v = LineHeight::Normal.resolve(16.0, 800.0, -200.0, 0.0, u16::MAX);
        assert!(v.is_finite() && v > 0.0, "got {v}");
        assert!(v < 1.0, "a 65535-upem font must produce a tiny scale, got {v}");
    }
    #[test]
    fn line_height_normal_nan_and_inf_inputs_are_defined_not_panics() {
        assert!(LineHeight::Normal
            .resolve(f32::NAN, 800.0, -200.0, 0.0, 1000)
            .is_nan());
        assert!(LineHeight::Normal
            .resolve(f32::INFINITY, 800.0, -200.0, 0.0, 1000)
            .is_infinite());
        // ascent == descent == inf → inf - inf == NaN
        assert!(LineHeight::Normal
            .resolve(16.0, f32::INFINITY, f32::INFINITY, 0.0, 1000)
            .is_nan());
    }
    #[test]
    fn line_height_resolve_with_metrics_matches_resolve() {
        let fm = metrics(2048, 1600.0, -400.0, 100.0);
        let lh = LineHeight::Normal;
        assert_eq!(
            lh.resolve_with_metrics(24.0, &fm),
            lh.resolve(24.0, fm.ascent, fm.descent, fm.line_gap, fm.units_per_em)
        );
        // Px path is metric-independent.
        assert_eq!(LineHeight::Px(3.0).resolve_with_metrics(24.0, &fm), 3.0);
    }
    #[test]
    fn line_height_px_nan_is_self_equal_under_the_manual_partialeq() {
        // The manual PartialEq compares raw bits, so Px(NaN) == Px(NaN) even
        // though NaN != NaN — required for Hash/Eq consistency of the cache key.
        assert_eq!(LineHeight::Px(f32::NAN), LineHeight::Px(f32::NAN));
        assert_eq!(
            hash_of(&LineHeight::Px(f32::NAN)),
            hash_of(&LineHeight::Px(f32::NAN))
        );
        assert_ne!(LineHeight::Normal, LineHeight::Px(0.0));
    }
    // =====================================================================
    // AvailableSpace (predicate / numeric / constructor)
    // =====================================================================
    #[test]
    fn available_space_definite_and_indefinite_are_exact_complements() {
        for v in [
            AvailableSpace::Definite(0.0),
            AvailableSpace::Definite(-1.0),
            AvailableSpace::Definite(f32::NAN),
            AvailableSpace::MinContent,
            AvailableSpace::MaxContent,
        ] {
            assert_ne!(v.is_definite(), v.is_indefinite(), "{v:?}");
        }
        assert!(AvailableSpace::Definite(f32::NAN).is_definite());
        assert!(AvailableSpace::default().is_indefinite());
        assert_eq!(AvailableSpace::default(), AvailableSpace::MaxContent);
    }
    #[test]
    fn available_space_unwrap_or_returns_definite_even_when_nan_or_inf() {
        assert_eq!(AvailableSpace::Definite(0.0).unwrap_or(99.0), 0.0);
        assert_eq!(AvailableSpace::Definite(-5.0).unwrap_or(99.0), -5.0);
        assert!(AvailableSpace::Definite(f32::NAN).unwrap_or(99.0).is_nan());
        assert!(AvailableSpace::Definite(f32::INFINITY)
            .unwrap_or(99.0)
            .is_infinite());
        // Indefinite variants hand back the fallback verbatim, NaN included.
        assert_eq!(AvailableSpace::MinContent.unwrap_or(99.0), 99.0);
        assert_eq!(AvailableSpace::MaxContent.unwrap_or(-0.0), -0.0);
        assert!(AvailableSpace::MaxContent.unwrap_or(f32::NAN).is_nan());
    }
    #[test]
    fn available_space_to_f32_for_layout_uses_half_max_for_both_intrinsic_modes() {
        assert_eq!(AvailableSpace::MinContent.to_f32_for_layout(), f32::MAX / 2.0);
        assert_eq!(AvailableSpace::MaxContent.to_f32_for_layout(), f32::MAX / 2.0);
        assert_eq!(AvailableSpace::Definite(12.5).to_f32_for_layout(), 12.5);
        assert!(AvailableSpace::Definite(f32::NAN)
            .to_f32_for_layout()
            .is_nan());
    }
    #[test]
    fn available_space_from_f32_sentinels() {
        assert_eq!(AvailableSpace::from_f32(f32::INFINITY), AvailableSpace::MaxContent);
        assert_eq!(AvailableSpace::from_f32(f32::MAX), AvailableSpace::MaxContent);
        // The documented cut-over point is exactly MAX/2 (inclusive).
        assert_eq!(
            AvailableSpace::from_f32(f32::MAX / 2.0),
            AvailableSpace::MaxContent
        );
        assert_eq!(AvailableSpace::from_f32(0.0), AvailableSpace::MinContent);
        assert_eq!(AvailableSpace::from_f32(-0.0), AvailableSpace::MinContent);
        assert_eq!(AvailableSpace::from_f32(-1.0), AvailableSpace::MinContent);
        assert_eq!(AvailableSpace::from_f32(100.0), AvailableSpace::Definite(100.0));
    }
    #[test]
    fn available_space_from_f32_negative_infinity_becomes_max_content() {
        // QUIRK worth pinning: the `is_infinite()` guard runs FIRST, so -inf —
        // a nonsensical width — resolves to MaxContent ("no wrapping"), not the
        // MinContent that every other negative value maps to.
        assert_eq!(
            AvailableSpace::from_f32(f32::NEG_INFINITY),
            AvailableSpace::MaxContent
        );
    }
    #[test]
    fn available_space_from_f32_nan_falls_through_to_definite_nan() {
        // NaN fails is_infinite(), fails `>= MAX/2`, and fails `<= 0.0`, so it
        // lands in the Definite arm and a NaN width is smuggled into layout.
        let got = AvailableSpace::from_f32(f32::NAN);
        match got {
            AvailableSpace::Definite(v) => assert!(v.is_nan(), "expected Definite(NaN)"),
            other => panic!("NaN should fall through to Definite, got {other:?}"),
        }
        // ...and Definite(NaN) is not even equal to itself under the derived PartialEq.
        assert_ne!(got, AvailableSpace::from_f32(f32::NAN));
    }
    #[test]
    fn available_space_hash_eq_contract_holds_for_signed_zero() {
        // +0.0 == -0.0 under PartialEq, so their hashes MUST agree.
        assert_eq!(
            AvailableSpace::Definite(0.0),
            AvailableSpace::Definite(-0.0)
        );
        assert_eq!(
            hash_of(&AvailableSpace::Definite(0.0)),
            hash_of(&AvailableSpace::Definite(-0.0))
        );
        // Sub-pixel widths must NOT collide (they wrap lines differently).
        assert_ne!(
            hash_of(&AvailableSpace::Definite(100.1)),
            hash_of(&AvailableSpace::Definite(100.4))
        );
        assert_ne!(
            hash_of(&AvailableSpace::MinContent),
            hash_of(&AvailableSpace::MaxContent)
        );
    }
    // =====================================================================
    // constructor: FontChainKey / FontChainKeyOrRef / FontStack / FontHash
    // =====================================================================
    #[test]
    fn font_chain_key_from_empty_selectors_defaults_to_serif() {
        let k = FontChainKey::from_selectors(&[]);
        assert_eq!(k.font_families, vec!["serif".to_string()]);
        assert_eq!(k.weight, FcWeight::Normal);
        assert!(!k.italic && !k.oblique);
    }
    #[test]
    fn font_chain_key_dedups_first_wins_and_skips_empty_families() {
        let stack = [sel("Arial"), sel("Times"), sel("Arial"), sel("")];
        let k = FontChainKey::from_selectors(&stack);
        assert_eq!(
            k.font_families,
            vec!["Arial".to_string(), "Times".to_string()],
            "duplicate families must collapse first-wins, empty names dropped"
        );
    }
    #[test]
    fn font_chain_key_all_empty_families_still_yields_serif() {
        let stack = [sel(""), sel(""), sel("")];
        let k = FontChainKey::from_selectors(&stack);
        assert_eq!(k.font_families, vec!["serif".to_string()]);
    }
    #[test]
    fn font_chain_key_weight_and_style_come_from_the_first_selector_even_if_it_is_dropped() {
        // QUIRK: the first selector's family is skipped (empty), but its weight
        // and italic flag still win — the key describes a family it does not list.
        let mut first = sel("");
        first.style = FontStyle::Italic;
        first.weight = FcWeight::Bold;
        let stack = [first, sel("Arial")];
        let k = FontChainKey::from_selectors(&stack);
        assert_eq!(k.font_families, vec!["Arial".to_string()]);
        assert_eq!(k.weight, FcWeight::Bold);
        assert!(k.italic, "italic taken from the dropped first selector");
        assert!(!k.oblique);
    }
    #[test]
    fn font_chain_key_oblique_is_exclusive_of_italic() {
        let mut s = sel("Arial");
        s.style = FontStyle::Oblique;
        let k = FontChainKey::from_selectors(&[s]);
        assert!(k.oblique && !k.italic);
    }
    #[test]
    fn font_chain_key_huge_duplicate_stack_does_not_hang() {
        let stack: Vec<FontSelector> = (0..5000).map(|_| sel("Arial")).collect();
        let k = FontChainKey::from_selectors(&stack);
        assert_eq!(k.font_families.len(), 1, "5000 dupes collapse to one entry");
    }
    #[test]
    fn font_chain_key_is_a_stable_hash_map_key() {
        let a = FontChainKey::from_selectors(&[sel("Arial"), sel("Times")]);
        let b = FontChainKey::from_selectors(&[sel("Arial"), sel("Arial"), sel("Times")]);
        assert_eq!(a, b, "dedup makes the two stacks resolve to the same key");
        assert_eq!(hash_of(&a), hash_of(&b));
    }
    #[test]
    fn font_chain_key_or_ref_from_stack_is_a_chain() {
        let fs = FontStack::Stack(vec![sel("Arial")]);
        let k = FontChainKeyOrRef::from_font_stack(&fs);
        assert!(!k.is_ref());
        assert_eq!(k.as_ref_ptr(), None);
        assert_eq!(
            k.as_chain().map(|c| c.font_families.clone()),
            Some(vec!["Arial".to_string()])
        );
    }
    #[test]
    fn font_chain_key_or_ref_ref_variant_accessors_at_boundaries() {
        for ptr in [0_usize, 1, usize::MAX] {
            let k = FontChainKeyOrRef::Ref(ptr);
            assert!(k.is_ref());
            assert_eq!(k.as_ref_ptr(), Some(ptr));
            assert!(k.as_chain().is_none());
        }
        // A null-pointer Ref is still distinguishable from a Chain.
        assert_ne!(
            FontChainKeyOrRef::Ref(0),
            FontChainKeyOrRef::Chain(FontChainKey::from_selectors(&[]))
        );
    }
    #[test]
    fn font_stack_default_is_a_single_serif_selector() {
        let fs = FontStack::default();
        assert!(!fs.is_ref());
        assert!(fs.as_ref().is_none());
        assert_eq!(fs.as_stack().map(<[FontSelector]>::len), Some(1));
        assert_eq!(fs.first_selector().map(|s| s.family.as_str()), Some("serif"));
        assert_eq!(fs.first_family(), "serif");
    }
    #[test]
    fn font_stack_empty_stack_reports_serif_placeholder_but_no_first_selector() {
        let fs = FontStack::Stack(Vec::new());
        assert_eq!(fs.as_stack().map(<[FontSelector]>::len), Some(0));
        assert!(fs.first_selector().is_none());
        assert_eq!(
            fs.first_family(),
            "serif",
            "an EMPTY stack must not panic; it reports the serif fallback"
        );
    }
    #[test]
    fn font_hash_invalid_is_zero_and_is_the_default() {
        assert_eq!(FontHash::invalid().font_hash, 0);
        assert_eq!(FontHash::default(), FontHash::invalid());
        assert_eq!(FontHash::from_hash(0), FontHash::invalid());
        assert_eq!(FontHash::from_hash(u64::MAX).font_hash, u64::MAX);
        assert_ne!(FontHash::from_hash(u64::MAX), FontHash::invalid());
    }
    // =====================================================================
    // numeric/getter: LayoutFontMetrics
    // =====================================================================
    #[test]
    fn layout_font_metrics_baseline_scaled_typical_and_zero_font_size() {
        let fm = std_metrics();
        approx(fm.baseline_scaled(16.0), 12.8); // 800/1000 * 16
        assert_eq!(fm.baseline_scaled(0.0), 0.0);
        approx(fm.baseline_scaled(-16.0), -12.8);
    }
    #[test]
    fn layout_font_metrics_zero_upem_divides_by_zero_instead_of_guarding() {
        // NOTE: `LineHeight::resolve` explicitly guards `units_per_em == 0`, but the
        // *_scaled helpers do not — they divide by zero. Pin the actual behaviour so
        // a future guard shows up as a deliberate change rather than a silent one.
        let fm = metrics(0, 800.0, -200.0, 0.0);
        assert!(fm.baseline_scaled(16.0).is_infinite());
        assert!(fm.cap_height_scaled(16.0).is_infinite());
        // ascent == 0 turns 0/0 into NaN rather than inf.
        let zero = metrics(0, 0.0, 0.0, 0.0);
        assert!(zero.baseline_scaled(16.0).is_nan());
    }
    #[test]
    fn layout_font_metrics_x_height_falls_back_to_half_em() {
        let fm = std_metrics(); // x_height: None
        assert_eq!(fm.x_height_scaled(16.0), 8.0, "fallback is 0.5em");
        assert_eq!(fm.x_height_scaled(0.0), 0.0);
        let mut with_xh = std_metrics();
        with_xh.x_height = Some(500.0);
        approx(with_xh.x_height_scaled(16.0), 8.0);
        with_xh.x_height = Some(0.0);
        assert_eq!(
            with_xh.x_height_scaled(16.0),
            0.0,
            "an explicit sxHeight of 0 must NOT re-trigger the 0.5em fallback"
        );
    }
    #[test]
    fn layout_font_metrics_cap_height_falls_back_to_ascent() {
        let fm = std_metrics(); // cap_height: None
        assert_eq!(fm.cap_height_scaled(16.0), fm.baseline_scaled(16.0));
        let mut with_cap = std_metrics();
        with_cap.cap_height = Some(700.0);
        approx(with_cap.cap_height_scaled(16.0), 11.2);
    }
    #[test]
    fn layout_font_metrics_nan_font_size_propagates_without_panicking() {
        let fm = std_metrics();
        assert!(fm.baseline_scaled(f32::NAN).is_nan());
        assert!(fm.x_height_scaled(f32::NAN).is_nan());
        assert!(fm.cap_height_scaled(f32::NAN).is_nan());
        assert!(fm.baseline_scaled(f32::INFINITY).is_infinite());
    }
    #[test]
    fn layout_font_metrics_synthesized_baselines_span_exactly_one_em() {
        let fm = std_metrics();
        assert_eq!(fm.central_baseline(), 300.0); // midpoint(800, -200)
        assert_eq!(fm.em_over(), 800.0); // 300 + 1000/2
        assert_eq!(fm.em_under(), -200.0); // 300 - 1000/2
        assert_eq!(
            fm.em_over() - fm.em_under(),
            f32::from(fm.units_per_em),
            "em-over minus em-under is by definition 1em"
        );
    }
    #[test]
    fn layout_font_metrics_baselines_at_u16_max_upem_and_zero_upem() {
        let big = metrics(u16::MAX, 0.0, 0.0, 0.0);
        assert_eq!(big.central_baseline(), 0.0);
        assert_eq!(big.em_over(), f32::from(u16::MAX) / 2.0);
        assert_eq!(big.em_under(), -f32::from(u16::MAX) / 2.0);
        let zero = metrics(0, 100.0, -50.0, 0.0);
        assert_eq!(zero.em_over(), zero.central_baseline());
        assert_eq!(zero.em_under(), zero.central_baseline());
    }
    #[test]
    fn layout_font_metrics_central_baseline_with_infinite_extents_is_nan() {
        let fm = metrics(1000, f32::INFINITY, f32::NEG_INFINITY, 0.0);
        assert!(fm.central_baseline().is_nan(), "midpoint(inf, -inf) is NaN");
        assert!(fm.em_over().is_nan());
    }
    // =====================================================================
    // numeric: round_eq (the equality primitive under Rect/Size/Point/Stroke)
    // =====================================================================
    #[test]
    fn round_eq_rounds_half_away_from_zero() {
        assert!(round_eq(0.4, -0.4), "both round to 0");
        assert!(round_eq(1.5, 2.4), "1.5 rounds away from zero to 2");
        assert!(!round_eq(1.4, 1.5));
        assert!(round_eq(-1.5, -2.0));
    }
    #[test]
    fn round_eq_treats_nan_as_equal_to_everything_rounding_to_zero() {
        // `NaN.round() as isize` is a SATURATING cast that yields 0, so NaN
        // compares equal to 0.0 (and to itself). Any Rect/Size/Point carrying a
        // NaN coordinate therefore compares "equal" to a zeroed one — a real
        // cache-key hazard, pinned here.
        assert!(round_eq(f32::NAN, f32::NAN));
        assert!(round_eq(f32::NAN, 0.0));
        assert!(round_eq(f32::NAN, 0.49));
        assert!(!round_eq(f32::NAN, 1.0));
        assert_eq!(
            Rect {
                x: f32::NAN,
                y: 0.0,
                width: 0.0,
                height: 0.0
            },
            Rect::default(),
            "a NaN-x Rect compares equal to the zero Rect"
        );
    }
    #[test]
    fn round_eq_saturates_infinity_and_f32_max_to_the_same_isize() {
        // Both +inf and f32::MAX saturate to isize::MAX, so they are "equal".
        assert!(round_eq(f32::INFINITY, f32::MAX));
        assert!(round_eq(f32::NEG_INFINITY, f32::MIN));
        assert!(!round_eq(f32::INFINITY, f32::NEG_INFINITY));
        assert_eq!(
            Size::new(f32::INFINITY, 0.0),
            Size::new(f32::MAX, 0.0),
            "saturating cast collapses inf and f32::MAX into one bucket"
        );
    }
    // =====================================================================
    // numeric/getter: Size, calculate_bounding_box_size, ShapeDefinition
    // =====================================================================
    #[test]
    fn size_zero_is_the_neutral_element_and_new_preserves_bits() {
        assert_eq!(Size::zero(), Size::new(0.0, 0.0));
        assert_eq!(Size::zero().width, 0.0);
        assert_eq!(Size::zero(), Size::default());
        let weird = Size::new(f32::NAN, f32::INFINITY);
        assert!(weird.width.is_nan(), "the constructor must not sanitize");
        assert!(weird.height.is_infinite());
    }
    #[test]
    fn bounding_box_of_empty_and_single_point_is_zero() {
        assert_eq!(calculate_bounding_box_size(&[]), Size::zero());
        assert_eq!(
            calculate_bounding_box_size(&[Point { x: 5.0, y: -5.0 }]),
            Size::zero()
        );
    }
    #[test]
    fn bounding_box_spans_negative_coordinates() {
        let pts = [
            Point { x: -10.0, y: -20.0 },
            Point { x: 30.0, y: 5.0 },
            Point { x: 0.0, y: 0.0 },
        ];
        assert_eq!(calculate_bounding_box_size(&pts), Size::new(40.0, 25.0));
    }
    #[test]
    fn bounding_box_of_all_nan_points_collapses_to_zero() {
        // min()/max() discard NaN, leaving min > max, which the guard catches.
        let pts = [Point {
            x: f32::NAN,
            y: f32::NAN,
        }];
        assert_eq!(calculate_bounding_box_size(&pts), Size::zero());
    }
    #[test]
    fn bounding_box_of_extreme_points_overflows_to_infinity_without_panicking() {
        let pts = [
            Point {
                x: f32::MIN,
                y: f32::MIN,
            },
            Point {
                x: f32::MAX,
                y: f32::MAX,
            },
        ];
        let s = calculate_bounding_box_size(&pts);
        assert!(s.width.is_infinite() && s.height.is_infinite());
    }
    #[test]
    fn shape_definition_get_size_for_each_variant() {
        assert_eq!(
            ShapeDefinition::Rectangle {
                size: Size::new(3.0, 4.0),
                corner_radius: None
            }
            .get_size(),
            Size::new(3.0, 4.0)
        );
        assert_eq!(
            ShapeDefinition::Circle { radius: 10.0 }.get_size(),
            Size::new(20.0, 20.0)
        );
        assert_eq!(
            ShapeDefinition::Ellipse {
                radii: Size::new(5.0, 2.0)
            }
            .get_size(),
            Size::new(10.0, 4.0)
        );
        assert_eq!(
            ShapeDefinition::Polygon { points: Vec::new() }.get_size(),
            Size::zero()
        );
        assert_eq!(
            ShapeDefinition::Path {
                segments: Vec::new()
            }
            .get_size(),
            Size::zero()
        );
    }
    #[test]
    fn shape_definition_negative_circle_radius_yields_a_negative_size() {
        // Pinned, not endorsed: the constructor never validates the radius, so a
        // negative CSS radius propagates a negative bounding box into layout.
        let s = ShapeDefinition::Circle { radius: -10.0 }.get_size();
        assert_eq!(s.width, -20.0);
        assert_eq!(s.height, -20.0);
    }
    #[test]
    fn shape_definition_path_of_only_close_segments_is_zero_sized() {
        let s = ShapeDefinition::Path {
            segments: vec![PathSegment::Close, PathSegment::Close],
        }
        .get_size();
        assert_eq!(s, Size::zero(), "Close contributes no points");
    }
    #[test]
    fn shape_definition_path_bounding_box_includes_control_points() {
        let s = ShapeDefinition::Path {
            segments: vec![
                PathSegment::MoveTo(Point { x: 0.0, y: 0.0 }),
                PathSegment::QuadTo {
                    control: Point { x: 50.0, y: 100.0 },
                    end: Point { x: 100.0, y: 0.0 },
                },
            ],
        }
        .get_size();
        assert_eq!(
            s,
            Size::new(100.0, 100.0),
            "the control point (not the true curve extremum) sets the height"
        );
    }
    // =====================================================================
    // numeric: ShapeBoundary::inflate
    // =====================================================================
    #[test]
    fn shape_boundary_inflate_by_zero_is_identity() {
        let r = ShapeBoundary::Rectangle(Rect {
            x: 1.0,
            y: 2.0,
            width: 3.0,
            height: 4.0,
        });
        assert_eq!(r.inflate(0.0), r);
        let c = ShapeBoundary::Circle {
            center: Point { x: 0.0, y: 0.0 },
            radius: 5.0,
        };
        assert_eq!(c.inflate(0.0), c);
    }
    #[test]
    fn shape_boundary_inflate_rectangle_clamps_negative_dimensions_to_zero() {
        let r = ShapeBoundary::Rectangle(Rect {
            x: 0.0,
            y: 0.0,
            width: 10.0,
            height: 10.0,
        });
        match r.inflate(-100.0) {
            ShapeBoundary::Rectangle(out) => {
                assert_eq!(out.width, 0.0, "over-deflation must clamp, not go negative");
                assert_eq!(out.height, 0.0);
                assert_eq!(out.x, 100.0, "the origin is NOT clamped");
            }
            other => panic!("expected Rectangle, got {other:?}"),
        }
    }
    #[test]
    fn shape_boundary_inflate_nan_margin_zeroes_the_rectangle_extent() {
        // `margin == 0.0` is false for NaN, so we take the inflate path; then
        // `NaN.max(0.0)` returns 0.0 (f32::max discards NaN) — the box silently
        // collapses to zero width/height with a NaN origin.
        let r = ShapeBoundary::Rectangle(Rect {
            x: 0.0,
            y: 0.0,
            width: 10.0,
            height: 10.0,
        });
        match r.inflate(f32::NAN) {
            ShapeBoundary::Rectangle(out) => {
                assert_eq!(out.width, 0.0);
                assert_eq!(out.height, 0.0);
                assert!(out.x.is_nan());
            }
            other => panic!("expected Rectangle, got {other:?}"),
        }
    }
    #[test]
    fn shape_boundary_inflate_circle_radius_is_unclamped() {
        let c = ShapeBoundary::Circle {
            center: Point { x: 1.0, y: 2.0 },
            radius: 5.0,
        };
        match c.inflate(-50.0) {
            ShapeBoundary::Circle { center, radius } => {
                assert_eq!(center, Point { x: 1.0, y: 2.0 });
                assert_eq!(radius, -45.0, "circle radius is NOT clamped at 0 (unlike Rect)");
            }
            other => panic!("expected Circle, got {other:?}"),
        }
        match c.inflate(f32::INFINITY) {
            ShapeBoundary::Circle { radius, .. } => assert!(radius.is_infinite()),
            other => panic!("expected Circle, got {other:?}"),
        }
    }
    #[test]
    fn shape_boundary_inflate_is_a_documented_no_op_for_polygon_and_path() {
        let p = ShapeBoundary::Polygon {
            points: vec![Point { x: 0.0, y: 0.0 }, Point { x: 1.0, y: 1.0 }],
        };
        assert_eq!(p.inflate(10.0), p, "polygon inflation is not implemented");
        let path = ShapeBoundary::Path {
            segments: vec![PathSegment::MoveTo(Point { x: 0.0, y: 0.0 })],
        };
        assert_eq!(path.inflate(10.0), path, "path inflation is not implemented");
    }
    // =====================================================================
    // other: resolve_effective_alignment
    // =====================================================================
    #[test]
    fn resolve_effective_alignment_passes_through_for_non_last_lines() {
        for ta in [
            TextAlign::Left,
            TextAlign::Right,
            TextAlign::Center,
            TextAlign::Justify,
            TextAlign::Start,
            TextAlign::End,
            TextAlign::JustifyAll,
        ] {
            assert_eq!(
                resolve_effective_alignment(ta, TextAlign::Right, false),
                ta,
                "text-align-last must not touch a non-last line"
            );
        }
    }
    #[test]
    fn resolve_effective_alignment_last_line_justify_degrades_to_start() {
        assert_eq!(
            resolve_effective_alignment(TextAlign::Justify, TextAlign::default(), true),
            TextAlign::Start
        );
        assert_eq!(
            resolve_effective_alignment(TextAlign::Center, TextAlign::default(), true),
            TextAlign::Center,
            "non-justify alignments survive onto the last line"
        );
    }
    #[test]
    fn resolve_effective_alignment_explicit_text_align_last_left_is_indistinguishable_from_auto() {
        // QUIRK: "auto" is encoded as TextAlign::default() == Left, so an author
        // writing `text-align-last: left` on `text-align: center` gets CENTER, not
        // left — the explicit value is swallowed by the auto check.
        assert_eq!(
            resolve_effective_alignment(TextAlign::Center, TextAlign::Left, true),
            TextAlign::Center
        );
        // Any other explicit value does win.
        assert_eq!(
            resolve_effective_alignment(TextAlign::Center, TextAlign::Right, true),
            TextAlign::Right
        );
        assert_eq!(
            resolve_effective_alignment(TextAlign::Justify, TextAlign::Justify, true),
            TextAlign::Justify
        );
    }
    // =====================================================================
    // numeric: Spacing::resolve_px
    // =====================================================================
    #[test]
    fn spacing_resolve_px_default_is_zero_and_font_size_independent() {
        assert_eq!(Spacing::default(), Spacing::Px(0));
        assert_eq!(Spacing::default().resolve_px(16.0), 0.0);
        assert_eq!(Spacing::Px(0).resolve_px(f32::NAN), 0.0);
    }
    #[test]
    fn spacing_resolve_px_at_i32_extremes_stays_finite() {
        let hi = Spacing::Px(i32::MAX).resolve_px(16.0);
        let lo = Spacing::Px(i32::MIN).resolve_px(16.0);
        assert!(hi.is_finite() && hi > 2.0e9, "got {hi}");
        assert!(lo.is_finite() && lo < -2.0e9, "got {lo}");
        assert_eq!(lo, -2147483648.0);
    }
    #[test]
    fn spacing_resolve_px_em_scales_with_font_size() {
        assert_eq!(Spacing::Em(2.0).resolve_px(16.0), 32.0);
        assert_eq!(Spacing::Em(2.0).resolve_px(0.0), 0.0);
        assert_eq!(Spacing::Em(-0.5).resolve_px(16.0), -8.0);
        assert_eq!(Spacing::PxF(0.4).resolve_px(999.0), 0.4, "PxF ignores font size");
    }
    #[test]
    fn spacing_resolve_px_nan_and_overflow_are_defined() {
        assert!(Spacing::Em(f32::NAN).resolve_px(16.0).is_nan());
        assert!(Spacing::Em(1.0).resolve_px(f32::NAN).is_nan());
        assert!(Spacing::PxF(f32::NAN).resolve_px(16.0).is_nan());
        assert!(Spacing::Em(f32::MAX).resolve_px(2.0).is_infinite());
        // 0 * inf is NaN, not 0.
        assert!(Spacing::Em(0.0).resolve_px(f32::INFINITY).is_nan());
    }
    #[test]
    fn spacing_px_and_pxf_of_the_same_value_are_distinct_cache_keys() {
        assert_ne!(Spacing::Px(1), Spacing::PxF(1.0));
        assert_ne!(hash_of(&Spacing::Px(1)), hash_of(&Spacing::PxF(1.0)));
        assert_eq!(Spacing::Px(1).resolve_px(16.0), Spacing::PxF(1.0).resolve_px(16.0));
    }
    // =====================================================================
    // predicate/getter: BidiDirection, BidiLevel, WritingMode
    // =====================================================================
    #[test]
    fn bidi_direction_is_rtl() {
        assert!(!BidiDirection::Ltr.is_rtl());
        assert!(BidiDirection::Rtl.is_rtl());
    }
    #[test]
    fn bidi_level_parity_defines_rtl_across_the_whole_u8_range() {
        for lvl in [0_u8, 1, 2, 3, 126, 127, 254, u8::MAX] {
            let b = BidiLevel::new(lvl);
            assert_eq!(b.level(), lvl, "level() must round-trip new()");
            assert_eq!(b.is_rtl(), lvl % 2 == 1, "odd embedding levels are RTL");
        }
    }
    #[test]
    fn writing_mode_is_advance_horizontal_for_every_variant() {
        assert!(WritingMode::HorizontalTb.is_advance_horizontal());
        assert!(WritingMode::SidewaysRl.is_advance_horizontal());
        assert!(WritingMode::SidewaysLr.is_advance_horizontal());
        assert!(!WritingMode::VerticalRl.is_advance_horizontal());
        assert!(!WritingMode::VerticalLr.is_advance_horizontal());
        assert_eq!(WritingMode::default(), WritingMode::HorizontalTb);
    }
    #[test]
    fn writing_mode_get_direction_only_horizontal_defers_to_content() {
        assert_eq!(WritingMode::HorizontalTb.get_direction(), None);
        assert_eq!(WritingMode::VerticalRl.get_direction(), Some(BidiDirection::Rtl));
        assert_eq!(WritingMode::VerticalLr.get_direction(), Some(BidiDirection::Ltr));
        assert_eq!(WritingMode::SidewaysRl.get_direction(), Some(BidiDirection::Rtl));
        assert_eq!(WritingMode::SidewaysLr.get_direction(), Some(BidiDirection::Ltr));
    }
    // =====================================================================
    // UnifiedConstraints
    // =====================================================================
    #[test]
    fn unified_constraints_default_is_horizontal_max_content() {
        let c = UnifiedConstraints::default();
        assert!(!c.is_vertical());
        assert_eq!(c.available_width, AvailableSpace::MaxContent);
        assert_eq!(c.columns, 1);
        assert_eq!(c, UnifiedConstraints::default());
        assert_eq!(
            hash_of(&c),
            hash_of(&UnifiedConstraints::default()),
            "Hash/Eq must agree for the default constraints"
        );
    }
    #[test]
    fn unified_constraints_is_vertical_only_for_the_two_vertical_modes() {
        let mut c = UnifiedConstraints::default();
        for (wm, want) in [
            (WritingMode::HorizontalTb, false),
            (WritingMode::VerticalRl, true),
            (WritingMode::VerticalLr, true),
            (WritingMode::SidewaysRl, false),
            (WritingMode::SidewaysLr, false),
        ] {
            c.writing_mode = Some(wm);
            assert_eq!(c.is_vertical(), want, "{wm:?}");
        }
        c.writing_mode = None;
        assert!(!c.is_vertical());
    }
    #[test]
    fn unified_constraints_direction_uses_fallback_unless_the_writing_mode_forces_one() {
        let mut c = UnifiedConstraints::default();
        // No writing mode → fallback wins.
        assert_eq!(c.direction(BidiDirection::Rtl), BidiDirection::Rtl);
        // horizontal-tb → still content-determined → fallback wins.
        c.writing_mode = Some(WritingMode::HorizontalTb);
        assert_eq!(c.direction(BidiDirection::Rtl), BidiDirection::Rtl);
        // vertical-rl OVERRIDES the fallback.
        c.writing_mode = Some(WritingMode::VerticalRl);
        assert_eq!(c.direction(BidiDirection::Ltr), BidiDirection::Rtl);
    }
    #[test]
    fn unified_constraints_resolved_line_height_uses_the_strut_for_normal() {
        let mut c = UnifiedConstraints::default();
        assert_eq!(
            c.resolved_line_height(),
            DEFAULT_STRUT_ASCENT + DEFAULT_STRUT_DESCENT
        );
        assert_eq!(c.resolved_line_height(), 16.0);
        c.line_height = LineHeight::Px(0.0);
        assert_eq!(c.resolved_line_height(), 0.0, "an explicit 0 is honoured");
        // Pinned: a negative / NaN px line-height is passed straight through.
        c.line_height = LineHeight::Px(-5.0);
        assert_eq!(c.resolved_line_height(), -5.0);
        c.line_height = LineHeight::Px(f32::NAN);
        assert!(c.resolved_line_height().is_nan());
    }
    #[test]
    fn unified_constraints_partial_eq_is_rounding_tolerant() {
        // PartialEq rounds f32 fields, so sub-pixel strut differences compare EQUAL.
        let mut a = UnifiedConstraints::default();
        let mut b = UnifiedConstraints::default();
        a.strut_ascent = 12.8;
        b.strut_ascent = 12.9;
        assert_eq!(a, b, "12.8 and 12.9 both round to 13");
        b.strut_ascent = 14.0;
        assert_ne!(a, b);
    }
    // =====================================================================
    // constructor: TextDecoration::from_css
    // =====================================================================
    #[test]
    fn text_decoration_from_css_maps_each_variant_exclusively() {
        use azul_css::props::style::text::StyleTextDecoration;
        let none = TextDecoration::from_css(StyleTextDecoration::None);
        assert_eq!(none, TextDecoration::default());
        assert!(!none.underline && !none.strikethrough && !none.overline);
        let u = TextDecoration::from_css(StyleTextDecoration::Underline);
        assert!(u.underline && !u.strikethrough && !u.overline);
        let o = TextDecoration::from_css(StyleTextDecoration::Overline);
        assert!(!o.underline && !o.strikethrough && o.overline);
        let lt = TextDecoration::from_css(StyleTextDecoration::LineThrough);
        assert!(!lt.underline && lt.strikethrough && !lt.overline);
    }
    // =====================================================================
    // predicate/getter: InlineBorderInfo
    // =====================================================================
    #[test]
    fn inline_border_info_default_has_no_border_and_no_chrome() {
        let b = InlineBorderInfo::default();
        assert!(!b.has_border());
        assert!(!b.has_chrome());
        assert_eq!(b.left_inset(), 0.0);
        assert_eq!(b.right_inset(), 0.0);
        assert_eq!(b.top_inset(), 0.0);
        assert_eq!(b.bottom_inset(), 0.0);
    }
    #[test]
    fn inline_border_info_negative_widths_do_not_count_as_a_border() {
        let b = InlineBorderInfo {
            top: -1.0,
            right: -1.0,
            bottom: -1.0,
            left: -1.0,
            ..InlineBorderInfo::default()
        };
        assert!(!b.has_border(), "the predicate is strictly `> 0.0`");
        assert!(!b.has_chrome());
        // ...but the inset arithmetic still returns the negative value.
        assert_eq!(b.left_inset(), -1.0);
    }
    #[test]
    fn inline_border_info_padding_alone_is_chrome_but_not_a_border() {
        let b = InlineBorderInfo {
            padding_left: 4.0,
            ..InlineBorderInfo::default()
        };
        assert!(!b.has_border());
        assert!(b.has_chrome());
        assert_eq!(b.left_inset(), 4.0);
    }
    #[test]
    fn inline_border_info_nan_border_width_is_not_a_border() {
        let b = InlineBorderInfo {
            top: f32::NAN,
            ..InlineBorderInfo::default()
        };
        assert!(!b.has_border(), "NaN > 0.0 is false");
        assert!(b.top_inset().is_nan(), "but the inset still carries the NaN");
    }
    #[test]
    fn inline_border_info_split_insets_swap_edges_in_rtl() {
        let base = InlineBorderInfo {
            left: 2.0,
            right: 3.0,
            padding_left: 1.0,
            padding_right: 1.0,
            ..InlineBorderInfo::default()
        };
        // LTR: left edge on the FIRST fragment, right edge on the LAST.
        let ltr_first = InlineBorderInfo {
            is_first_fragment: true,
            is_last_fragment: false,
            ..base
        };
        assert_eq!(ltr_first.left_inset(), 3.0);
        assert_eq!(ltr_first.right_inset(), 0.0);
        let ltr_last = InlineBorderInfo {
            is_first_fragment: false,
            is_last_fragment: true,
            ..base
        };
        assert_eq!(ltr_last.left_inset(), 0.0);
        assert_eq!(ltr_last.right_inset(), 4.0);
        // RTL: mirrored.
        let rtl_first = InlineBorderInfo {
            is_first_fragment: true,
            is_last_fragment: false,
            is_rtl: true,
            ..base
        };
        assert_eq!(rtl_first.left_inset(), 0.0);
        assert_eq!(rtl_first.right_inset(), 4.0);
        let rtl_last = InlineBorderInfo {
            is_first_fragment: false,
            is_last_fragment: true,
            is_rtl: true,
            ..base
        };
        assert_eq!(rtl_last.left_inset(), 3.0);
        assert_eq!(rtl_last.right_inset(), 0.0);
        // A middle fragment (neither first nor last) draws NO horizontal edge.
        let middle = InlineBorderInfo {
            is_first_fragment: false,
            is_last_fragment: false,
            ..base
        };
        assert_eq!(middle.left_inset(), 0.0);
        assert_eq!(middle.right_inset(), 0.0);
        // Vertical insets are never suppressed.
        let tall = InlineBorderInfo {
            top: 1.0,
            bottom: 2.0,
            padding_top: 3.0,
            padding_bottom: 4.0,
            is_first_fragment: false,
            is_last_fragment: false,
            ..InlineBorderInfo::default()
        };
        assert_eq!(tall.top_inset(), 4.0);
        assert_eq!(tall.bottom_inset(), 6.0);
    }
    // =====================================================================
    // getter/other: StyleProperties::layout_hash / layout_eq / apply_override
    // =====================================================================
    #[test]
    fn style_layout_eq_ignores_render_only_properties() {
        let a = StyleProperties::default();
        let b = StyleProperties {
            color: ColorU {
                r: 255,
                g: 0,
                b: 0,
                a: 255,
            },
            background_color: Some(ColorU::TRANSPARENT),
            text_decoration: TextDecoration {
                underline: true,
                strikethrough: false,
                overline: false,
            },
            border: Some(InlineBorderInfo::default()),
            ..StyleProperties::default()
        };
        assert_ne!(a, b, "the full PartialEq DOES see the colour change");
        assert!(
            a.layout_eq(&b),
            "but layout_eq must ignore colour/decoration/border"
        );
        assert_eq!(a.layout_hash(), b.layout_hash());
    }
    #[test]
    fn style_layout_eq_sees_sub_pixel_font_size_changes() {
        let a = StyleProperties::default();
        let b = StyleProperties {
            font_size_px: 16.4,
            ..StyleProperties::default()
        };
        assert!(
            !a.layout_eq(&b),
            "16.0 vs 16.4 must NOT share a shaping-cache entry"
        );
    }
    #[test]
    fn style_layout_eq_sees_spacing_and_font_stack_changes() {
        let base = StyleProperties::default();
        let spaced = StyleProperties {
            letter_spacing: Spacing::PxF(0.5),
            ..StyleProperties::default()
        };
        assert!(!base.layout_eq(&spaced));
        let worded = StyleProperties {
            word_spacing: Spacing::Em(0.1),
            ..StyleProperties::default()
        };
        assert!(!base.layout_eq(&worded));
        let other_font = StyleProperties {
            font_stack: FontStack::Stack(vec![sel("Arial")]),
            ..StyleProperties::default()
        };
        assert!(!base.layout_eq(&other_font));
        let vertical = StyleProperties {
            writing_mode: WritingMode::VerticalRl,
            ..StyleProperties::default()
        };
        assert!(!base.layout_eq(&vertical));
    }
    #[test]
    fn style_layout_hash_is_stable_across_repeated_calls() {
        let s = StyleProperties::default();
        assert_eq!(s.layout_hash(), s.layout_hash());
        assert!(s.layout_eq(&StyleProperties::default()));
    }
    #[test]
    fn style_layout_eq_treats_two_nan_font_sizes_as_equal() {
        // layout_hash hashes the raw bits, so NaN == NaN here (unlike `==` on f32).
        let a = StyleProperties {
            font_size_px: f32::NAN,
            ..StyleProperties::default()
        };
        let b = StyleProperties {
            font_size_px: f32::NAN,
            ..StyleProperties::default()
        };
        assert!(a.layout_eq(&b));
        assert!(!a.layout_eq(&StyleProperties::default()));
    }
    #[test]
    fn style_apply_override_with_an_empty_partial_changes_nothing() {
        let base = StyleProperties::default();
        let out = base.apply_override(&PartialStyleProperties::default());
        assert_eq!(out, base);
    }
    #[test]
    fn style_apply_override_applies_only_the_some_fields() {
        let base = StyleProperties::default();
        let partial = PartialStyleProperties {
            font_size_px: Some(32.0),
            letter_spacing: Some(Spacing::PxF(1.5)),
            ..PartialStyleProperties::default()
        };
        let out = base.apply_override(&partial);
        assert_eq!(out.font_size_px, 32.0);
        assert_eq!(out.letter_spacing, Spacing::PxF(1.5));
        // Untouched fields are inherited verbatim.
        assert_eq!(out.word_spacing, base.word_spacing);
        assert_eq!(out.tab_size, base.tab_size);
        assert_eq!(out.font_stack, base.font_stack);
        assert!(!out.layout_eq(&base));
    }
    #[test]
    fn style_apply_override_can_inject_nan_font_size() {
        let base = StyleProperties::default();
        let partial = PartialStyleProperties {
            font_size_px: Some(f32::NAN),
            ..PartialStyleProperties::default()
        };
        let out = base.apply_override(&partial);
        assert!(out.font_size_px.is_nan(), "no validation happens here");
    }
    // =====================================================================
    // numeric: classify_character / get_justification_priority
    // =====================================================================
    #[test]
    fn classify_character_covers_each_class() {
        assert_eq!(classify_character(0x0020), CharacterClass::Space);
        assert_eq!(classify_character(0x00A0), CharacterClass::Space);
        assert_eq!(classify_character(0x3000), CharacterClass::Space);
        assert_eq!(classify_character('.' as u32), CharacterClass::Punctuation);
        assert_eq!(classify_character('~' as u32), CharacterClass::Punctuation);
        assert_eq!(classify_character('a' as u32), CharacterClass::Letter);
        assert_eq!(classify_character(0x4E00), CharacterClass::Ideograph);
        assert_eq!(classify_character(0x9FFF), CharacterClass::Ideograph);
        assert_eq!(classify_character(0x0301), CharacterClass::Combining);
    }
    #[test]
    fn classify_character_at_u32_extremes_defaults_to_letter() {
        assert_eq!(classify_character(0), CharacterClass::Letter);
        assert_eq!(classify_character(u32::MAX), CharacterClass::Letter);
        // Boundary walk around the ideograph range.
        assert_eq!(classify_character(0x4DFF), CharacterClass::Letter);
        assert_eq!(classify_character(0xA000), CharacterClass::Letter);
    }
    #[test]
    fn get_justification_priority_is_strictly_ordered_space_to_combining() {
        let p = |c| get_justification_priority(c);
        assert_eq!(p(CharacterClass::Space), 0);
        assert_eq!(p(CharacterClass::Combining), 255);
        assert!(p(CharacterClass::Space) < p(CharacterClass::Punctuation));
        assert!(p(CharacterClass::Punctuation) < p(CharacterClass::Ideograph));
        assert!(p(CharacterClass::Ideograph) < p(CharacterClass::Letter));
        assert!(p(CharacterClass::Letter) < p(CharacterClass::Symbol));
        assert!(p(CharacterClass::Symbol) < p(CharacterClass::Combining));
    }
    // =====================================================================
    // predicate: char-level classifiers
    // =====================================================================
    #[test]
    fn is_hanging_punctuation_char_only_stops_and_commas() {
        assert!(is_hanging_punctuation_char(','));
        assert!(is_hanging_punctuation_char('.'));
        assert!(is_hanging_punctuation_char('\u{3001}'));
        assert!(is_hanging_punctuation_char('\u{FF0E}'));
        assert!(!is_hanging_punctuation_char(';'));
        assert!(!is_hanging_punctuation_char(' '));
        assert!(!is_hanging_punctuation_char('\0'));
        assert!(!is_hanging_punctuation_char(char::MAX));
    }
    #[test]
    fn is_word_char_is_alphanumeric_or_underscore() {
        assert!(is_word_char('a'));
        assert!(is_word_char('Z'));
        assert!(is_word_char('9'));
        assert!(is_word_char('_'));
        assert!(is_word_char('é'), "non-ASCII letters are word chars");
        assert!(is_word_char('中'), "ideographs are alphanumeric");
        assert!(!is_word_char(' '));
        assert!(!is_word_char('-'));
        assert!(!is_word_char('.'));
        assert!(!is_word_char('\u{00A0}'));
        assert!(!is_word_char('\0'));
    }
    #[test]
    fn is_word_separator_char_excludes_tabs_and_fixed_width_spaces() {
        assert!(is_word_separator_char(' '));
        assert!(is_word_separator_char('\u{00A0}'), "NBSP IS a word separator");
        assert!(is_word_separator_char('\u{1680}'));
        assert!(is_word_separator_char('\u{202F}'));
        assert!(is_word_separator_char('\u{10100}'));
        // Per CSS Text §7.1 these are NOT word separators, despite looking like spaces.
        assert!(!is_word_separator_char('\u{2000}'));
        assert!(!is_word_separator_char('\u{200A}'));
        assert!(!is_word_separator_char('\u{3000}'), "ideographic space excluded");
        // Nor are tab/newline (they are handled by white-space processing instead).
        assert!(!is_word_separator_char('\t'));
        assert!(!is_word_separator_char('\n'));
        assert!(!is_word_separator_char('.'));
        assert!(!is_word_separator_char(char::MAX));
    }
    #[test]
    fn is_cursive_script_char_boundaries() {
        assert!(!is_cursive_script_char('\u{05FF}'), "one below Arabic");
        assert!(is_cursive_script_char('\u{0600}'), "Arabic block start");
        assert!(is_cursive_script_char('\u{06FF}'), "Arabic block end");
        assert!(is_cursive_script_char('\u{0700}'), "Syriac");
        assert!(is_cursive_script_char('\u{1800}'), "Mongolian");
        assert!(is_cursive_script_char('\u{10D00}'), "Hanifi Rohingya (astral)");
        assert!(!is_cursive_script_char('a'));
        assert!(!is_cursive_script_char('中'));
        assert!(!is_cursive_script_char('\0'));
        assert!(!is_cursive_script_char(char::MAX));
    }
    #[test]
    fn is_cjk_character_boundaries() {
        assert!(is_cjk_character('中')); // U+4E2D
        assert!(is_cjk_character('\u{4E00}'));
        assert!(is_cjk_character('\u{9FFF}'));
        assert!(is_cjk_character('\u{3040}'), "hiragana block");
        assert!(is_cjk_character('\u{30FF}'), "katakana block");
        assert!(is_cjk_character('\u{AC00}'), "hangul syllables");
        assert!(is_cjk_character('\u{FF01}'), "fullwidth forms");
        assert!(!is_cjk_character('\u{4DFF}'), "one below the ideograph block");
        assert!(!is_cjk_character('a'));
        assert!(!is_cjk_character('\0'));
        assert!(!is_cjk_character(char::MAX));
    }
    #[test]
    fn break_control_predicates_are_disjoint() {
        assert!(is_break_suppressing_control('\u{200D}'));
        assert!(is_break_suppressing_control('\u{2060}'));
        assert!(is_break_suppressing_control('\u{FEFF}'));
        assert!(!is_break_suppressing_control(' '));
        assert!(!is_break_suppressing_control('\u{200B}'));
        assert!(is_break_forcing_control('\u{200B}'));
        assert!(is_break_forcing_control('\u{2028}'));
        assert!(is_break_forcing_control('\u{2029}'));
        assert!(!is_break_forcing_control(' '));
        assert!(!is_break_forcing_control('\u{200D}'));
        for ch in ['\u{200D}', '\u{2060}', '\u{FEFF}', '\u{200B}', '\u{2028}'] {
            assert!(
                !(is_break_suppressing_control(ch) && is_break_forcing_control(ch)),
                "{ch:?} cannot both force and suppress a break"
            );
        }
    }
    #[test]
    fn is_small_kana_matches_only_the_cj_class() {
        assert!(is_small_kana('っ'));
        assert!(is_small_kana('ゃ'));
        assert!(is_small_kana('ッ'));
        assert!(is_small_kana('ー'), "prolonged sound mark is class CJ");
        assert!(!is_small_kana('つ'), "the FULL-size kana is not CJ");
        assert!(!is_small_kana('中'));
        assert!(!is_small_kana('a'));
        assert!(!is_small_kana('\0'));
    }
    #[test]
    fn is_cjk_break_allowed_by_strictness_per_level() {
        use LineBreakStrictness::{Anywhere, Auto, Loose, Normal, Strict};
        // Anywhere / Loose: everything is breakable.
        for ch in ['っ', '\u{301C}', '\u{2010}', '中'] {
            assert!(is_cjk_break_allowed_by_strictness(ch, None, Anywhere), "{ch:?}");
            assert!(is_cjk_break_allowed_by_strictness(ch, None, Loose), "{ch:?}");
        }
        // Normal/Auto: hyphens forbidden, small kana allowed.
        for level in [Normal, Auto] {
            assert!(!is_cjk_break_allowed_by_strictness('\u{2010}', None, level));
            assert!(!is_cjk_break_allowed_by_strictness('\u{2013}', None, level));
            assert!(is_cjk_break_allowed_by_strictness('っ', None, level));
            assert!(is_cjk_break_allowed_by_strictness('中', None, level));
        }
        // Strict: small kana and CJK hyphen-likes are forbidden too.
        assert!(!is_cjk_break_allowed_by_strictness('っ', None, Strict));
        assert!(!is_cjk_break_allowed_by_strictness('ー', None, Strict));
        assert!(!is_cjk_break_allowed_by_strictness('\u{301C}', None, Strict));
        assert!(!is_cjk_break_allowed_by_strictness('\u{30A0}', None, Strict));
        assert!(is_cjk_break_allowed_by_strictness('中', None, Strict));
        // prev_ch is currently ignored — pin that so a future use is a deliberate change.
        assert_eq!(
            is_cjk_break_allowed_by_strictness('中', Some('x'), Strict),
            is_cjk_break_allowed_by_strictness('中', None, Strict)
        );
    }
    // =====================================================================
    // getter/predicate: Glyph
    // =====================================================================
    fn plain_glyph(codepoint: char, advance: f32) -> Glyph {
        Glyph {
            glyph_id: 1,
            codepoint,
            font_hash: 7,
            font_metrics: std_metrics(),
            style: style(),
            source: GlyphSource::Char,
            logical_byte_index: 0,
            logical_byte_len: codepoint.len_utf8(),
            content_index: 0,
            cluster: 0,
            advance,
            kerning: 0.0,
            offset: Point { x: 0.0, y: 0.0 },
            vertical_advance: advance,
            vertical_origin_y: 0.0,
            vertical_bearing: Point { x: 0.0, y: 0.0 },
            orientation: GlyphOrientation::Horizontal,
            script: Script::Latin,
            bidi_level: BidiLevel::new(0),
        }
    }
    #[test]
    fn glyph_bounds_is_advance_by_resolved_line_height() {
        let g = plain_glyph('a', 9.5);
        let b = g.bounds();
        assert_eq!(b.x, 0.0);
        assert_eq!(b.y, 0.0);
        assert_eq!(b.width, 9.5);
        approx(b.height, 16.0); // normal line-height on a 1000/800/-200 font @16px
    }
    #[test]
    fn glyph_bounds_with_zero_advance_and_zero_upem_does_not_panic() {
        let mut g = plain_glyph('a', 0.0);
        g.font_metrics = metrics(0, 0.0, 0.0, 0.0);
        let b = g.bounds();
        assert_eq!(b.width, 0.0);
        approx(b.height, 19.2); // zero-upem falls back to 1.2em
    }
    #[test]
    fn glyph_whitespace_and_justification_predicates() {
        let space = plain_glyph(' ', 4.0);
        assert!(space.is_whitespace());
        assert_eq!(space.character_class(), CharacterClass::Space);
        assert!(!space.can_justify(), "whitespace is never itself justified");
        assert_eq!(space.justification_priority(), 0);
        assert!(space.break_opportunity_after());
        let letter = plain_glyph('a', 8.0);
        assert!(!letter.is_whitespace());
        assert!(letter.can_justify());
        assert_eq!(letter.justification_priority(), 192);
        assert!(!letter.break_opportunity_after());
        let combining = plain_glyph('\u{0301}', 0.0);
        assert!(!combining.is_whitespace());
        assert!(!combining.can_justify(), "combining marks are never justified");
        assert_eq!(combining.justification_priority(), 255);
    }
    #[test]
    fn glyph_break_opportunity_after_covers_every_hyphen_form() {
        assert!(plain_glyph('\u{00AD}', 0.0).break_opportunity_after(), "soft hyphen");
        assert!(plain_glyph('\u{002D}', 4.0).break_opportunity_after(), "hyphen-minus");
        assert!(plain_glyph('\u{2010}', 4.0).break_opportunity_after(), "U+2010");
        assert!(plain_glyph('\t', 8.0).break_opportunity_after(), "tab is whitespace");
        assert!(!plain_glyph('\u{2011}', 4.0).break_opportunity_after(), "NON-BREAKING hyphen");
        assert!(!plain_glyph('/', 4.0).break_opportunity_after());
    }
    // =====================================================================
    // getter/predicate: ShapedItem + item helpers
    // =====================================================================
    #[test]
    fn shaped_item_as_cluster_only_matches_clusters() {
        assert!(cl("a", 8.0).as_cluster().is_some());
        assert!(obj(10.0, 10.0, 0.0).as_cluster().is_none());
        assert!(brk().as_cluster().is_none());
        assert!(tab(8.0, 16.0).as_cluster().is_none());
    }
    #[test]
    fn shaped_item_bounds_of_a_break_is_the_zero_rect() {
        assert_eq!(brk().bounds(), Rect::default());
        assert_eq!(obj(10.0, 20.0, 0.0).bounds().width, 10.0);
        assert_eq!(tab(8.0, 16.0).bounds().height, 16.0);
        let c = cl("a", 9.5);
        assert_eq!(c.bounds().width, 9.5, "a cluster's width is its advance");
        approx(c.bounds().height, 16.0); // ascent + descent of the fixture font
    }
    #[test]
    fn get_item_measure_sums_advance_and_kerning() {
        let st = style();
        let mut g1 = shaped_glyph(st.clone(), std_metrics(), 8.0);
        g1.kerning = -1.5;
        let mut g2 = shaped_glyph(st.clone(), std_metrics(), 8.0);
        g2.kerning = 0.5;
        let item = make_cluster("ab", 16.0, st, smallvec![g1, g2], gid(0, 0));
        assert_eq!(get_item_measure(&item, false), 15.0, "16 + (-1.5) + 0.5");
        assert_eq!(
            get_item_measure(&item, true),
            15.0,
            "clusters ignore the is_vertical flag (advance is already axis-relative)"
        );
    }
    #[test]
    fn get_item_measure_of_a_break_is_zero_and_objects_switch_axis() {
        assert_eq!(get_item_measure(&brk(), false), 0.0);
        assert_eq!(get_item_measure(&brk(), true), 0.0);
        let o = obj(30.0, 20.0, 0.0);
        assert_eq!(get_item_measure(&o, false), 30.0);
        assert_eq!(get_item_measure(&o, true), 20.0);
    }
    #[test]
    fn get_item_measure_with_spacing_adds_letter_spacing_but_not_for_cursive() {
        let st = styled(|s| s.letter_spacing = Spacing::PxF(2.0));
        let latin = cl_styled("a", 10.0, st.clone());
        assert_eq!(get_item_measure(&latin, false), 10.0);
        assert_eq!(get_item_measure_with_spacing(&latin, false), 12.0);
        // Cursive (Arabic) clusters must never receive letter-spacing.
        let arabic = cl_styled("\u{0627}", 10.0, st);
        assert_eq!(
            get_item_measure_with_spacing(&arabic, false),
            10.0,
            "letter-spacing is suppressed for cursive scripts (CSS Text 3 App. D)"
        );
    }
    #[test]
    fn get_item_measure_with_spacing_adds_word_spacing_only_on_separators() {
        let st = styled(|s| {
            s.word_spacing = Spacing::PxF(5.0);
            s.letter_spacing = Spacing::PxF(1.0);
        });
        let space = cl_styled(" ", 4.0, st.clone());
        assert_eq!(
            get_item_measure_with_spacing(&space, false),
            10.0,
            "4 + letter(1) + word(5)"
        );
        let letter = cl_styled("a", 8.0, st);
        assert_eq!(
            get_item_measure_with_spacing(&letter, false),
            9.0,
            "no word-spacing on a non-separator"
        );
        // Non-cluster items get no spacing at all.
        assert_eq!(get_item_measure_with_spacing(&brk(), false), 0.0);
    }
    #[test]
    fn is_collapsible_whitespace_is_vacuously_true_for_an_empty_cluster() {
        assert!(is_collapsible_whitespace(&cl(" ", 4.0)));
        assert!(is_collapsible_whitespace(&cl("\t", 8.0)));
        assert!(is_collapsible_whitespace(&cl("\u{1680}", 4.0)));
        assert!(is_collapsible_whitespace(&cl("  \t ", 16.0)));
        assert!(!is_collapsible_whitespace(&cl("\n", 0.0)), "newline is NOT collapsible here");
        assert!(!is_collapsible_whitespace(&cl("a", 8.0)));
        assert!(!is_collapsible_whitespace(&cl("a ", 12.0)), "all() — mixed is false");
        assert!(!is_collapsible_whitespace(&obj(1.0, 1.0, 0.0)));
        // QUIRK: `chars().all(..)` on an empty string is vacuously TRUE, so a
        // zero-text cluster is treated as strippable whitespace at line edges.
        assert!(
            is_collapsible_whitespace(&cl("", 0.0)),
            "an empty cluster counts as collapsible whitespace"
        );
    }
    #[test]
    fn is_word_separator_and_zero_width_space_on_items() {
        assert!(is_word_separator(&cl(" ", 4.0)));
        assert!(is_word_separator(&cl("a b", 20.0)), "any() — one space suffices");
        assert!(!is_word_separator(&cl("", 0.0)), "any() on empty is false");
        assert!(!is_word_separator(&cl("\u{3000}", 16.0)));
        assert!(!is_word_separator(&brk()));
        assert!(!is_word_separator(&obj(1.0, 1.0, 0.0)));
        assert!(is_zero_width_space(&cl("\u{200B}", 0.0)));
        assert!(is_zero_width_space(&cl("a\u{200B}", 8.0)), "contains(), not equals()");
        assert!(!is_zero_width_space(&cl(" ", 4.0)));
        assert!(!is_zero_width_space(&obj(1.0, 1.0, 0.0)));
    }
    #[test]
    fn can_justify_after_rejects_objects_empty_clusters_and_combining_marks() {
        assert!(can_justify_after(&cl("a", 8.0)));
        assert!(!can_justify_after(&cl(" ", 4.0)));
        assert!(!can_justify_after(&cl("a\u{0301}", 8.0)), "trailing combining mark");
        assert!(!can_justify_after(&cl("", 0.0)), "no last char → false");
        assert!(
            !can_justify_after(&obj(10.0, 10.0, 0.0)),
            "CSS 2.2 §9.4.2: never stretch after an atomic inline"
        );
        assert!(!can_justify_after(&brk()));
    }
    #[test]
    fn is_hanging_punctuation_requires_a_single_glyph_cluster() {
        assert!(is_hanging_punctuation(&cl(".", 4.0)));
        assert!(is_hanging_punctuation(&cl(",", 4.0)));
        assert!(!is_hanging_punctuation(&cl("a", 8.0)));
        assert!(!is_hanging_punctuation(&cl("", 0.0)), "no first char");
        assert!(!is_hanging_punctuation(&obj(1.0, 1.0, 0.0)));
        // A two-glyph cluster is rejected even if it starts with a full stop.
        let st = style();
        let g1 = shaped_glyph(st.clone(), std_metrics(), 4.0);
        let g2 = shaped_glyph(st.clone(), std_metrics(), 4.0);
        let two = make_cluster(".", 8.0, st, smallvec![g1, g2], gid(0, 0));
        assert!(!is_hanging_punctuation(&two));
    }
    #[test]
    fn cluster_script_predicates() {
        let arabic = cl("\u{0627}", 10.0);
        let latin = cl("a", 8.0);
        let cjk = cl("中", 16.0);
        assert!(is_cursive_script_cluster(arabic.as_cluster().unwrap()));
        assert!(!is_cursive_script_cluster(latin.as_cluster().unwrap()));
        assert!(
            !is_cursive_script_cluster(cl("", 0.0).as_cluster().unwrap()),
            "empty cluster has no first char"
        );
        assert!(is_cjk_cluster(cjk.as_cluster().unwrap()));
        assert!(!is_cjk_cluster(latin.as_cluster().unwrap()));
        // is_arabic_cluster keys off the GLYPH script, not the text.
        assert!(
            !is_arabic_cluster(arabic.as_cluster().unwrap()),
            "the fixture's glyph carries Script::Latin, so the text alone is not enough"
        );
        let st = style();
        let mut g = shaped_glyph(st.clone(), std_metrics(), 10.0);
        g.script = Script::Arabic;
        let real_arabic = make_cluster("\u{0627}", 10.0, st, smallvec![g], gid(0, 0));
        assert!(is_arabic_cluster(real_arabic.as_cluster().unwrap()));
    }
    #[test]
    fn cluster_is_word_boundary_for_punctuation_and_whitespace() {
        assert!(cluster_is_word_boundary(cl(" ", 4.0).as_cluster().unwrap()));
        assert!(cluster_is_word_boundary(cl(".", 4.0).as_cluster().unwrap()));
        assert!(cluster_is_word_boundary(cl("", 0.0).as_cluster().unwrap()), "vacuous");
        assert!(!cluster_is_word_boundary(cl("a", 8.0).as_cluster().unwrap()));
        assert!(!cluster_is_word_boundary(cl("_", 8.0).as_cluster().unwrap()));
    }
    #[test]
    fn get_baseline_for_item_only_defined_for_clusters_and_boxes() {
        assert_eq!(get_baseline_for_item(&brk()), None);
        assert_eq!(get_baseline_for_item(&tab(8.0, 16.0)), None);
        assert_eq!(get_baseline_for_item(&obj(10.0, 20.0, 3.0)), Some(3.0));
        // Cluster: baseline of the LAST glyph, scaled to font size (800/1000*16).
        approx(
            get_baseline_for_item(&cl("a", 8.0)).expect("a glyph-bearing cluster has a baseline"),
            12.8,
        );
        assert_eq!(
            get_baseline_for_item(&cl_no_glyphs("", 0.0)),
            None,
            "a glyph-less cluster has no baseline"
        );
    }
    #[test]
    fn get_item_vertical_metrics_approx_for_every_variant() {
        // Cluster with real glyphs: ascent 12.8, descent 3.2, no leading (lh == a+d).
        let (a, d) = get_item_vertical_metrics_approx(&cl("a", 8.0));
        approx(a, 12.8);
        approx(d, 3.2);
        // Glyph-less cluster → 80/20 split of the fallback 1.2em line box.
        let (a, d) = get_item_vertical_metrics_approx(&cl_no_glyphs("", 0.0));
        approx(a, 19.2 * FALLBACK_ASCENT_RATIO);
        approx(d, 19.2 * FALLBACK_DESCENT_RATIO);
        // Object → all ascent, no descent.
        assert_eq!(get_item_vertical_metrics_approx(&obj(10.0, 20.0, 5.0)), (20.0, 0.0));
        // Break → nothing.
        assert_eq!(get_item_vertical_metrics_approx(&brk()), (0.0, 0.0));
        // Tab → 80/20 of its box height.
        let (a, d) = get_item_vertical_metrics_approx(&tab(8.0, 10.0));
        approx(a, 8.0);
        approx(d, 2.0);
    }
    #[test]
    fn get_item_vertical_metrics_approx_skips_zero_upem_glyphs() {
        let st = style();
        let g = shaped_glyph(st.clone(), metrics(0, 800.0, -200.0, 0.0), 8.0);
        let item = make_cluster("a", 8.0, st, smallvec![g], gid(0, 0));
        assert_eq!(
            get_item_vertical_metrics_approx(&item),
            (0.0, 0.0),
            "a zero-upem glyph is skipped rather than producing inf/NaN metrics"
        );
    }
    #[test]
    fn get_item_vertical_metrics_uses_the_strut_for_glyphless_clusters() {
        let c = UnifiedConstraints::default();
        let (a, d) = get_item_vertical_metrics(&cl_no_glyphs("", 0.0), &c);
        // resolved lh = 1.2 * 16 = 19.2; a+d = 16.0; half-leading = 1.6
        approx(a, DEFAULT_STRUT_ASCENT + 1.6);
        approx(d, DEFAULT_STRUT_DESCENT + 1.6);
        assert_eq!(get_item_vertical_metrics(&brk(), &c), (0.0, 0.0));
        // Objects clamp negative ascent/descent at 0.
        let (a, d) = get_item_vertical_metrics(&obj(10.0, 10.0, 30.0), &c);
        assert_eq!(a, 0.0, "baseline_offset > height must clamp the ascent at 0");
        assert_eq!(d, 30.0);
    }
    #[test]
    fn get_item_vertical_align_only_for_objects_with_image_or_shape_content() {
        assert_eq!(get_item_vertical_align(&cl("a", 8.0)), None);
        assert_eq!(get_item_vertical_align(&brk()), None);
        // Our fixture Object carries a Space, which has no alignment.
        assert_eq!(get_item_vertical_align(&obj(1.0, 1.0, 0.0)), None);
        let img = ShapedItem::Object {
            source: ci(0, 0),
            bounds: Rect::default(),
            baseline_offset: 0.0,
            content: InlineContent::Image(InlineImage {
                source: ImageSource::Placeholder(Size::new(10.0, 10.0)),
                intrinsic_size: Size::new(10.0, 10.0),
                display_size: None,
                baseline_offset: 0.0,
                alignment: VerticalAlign::Top,
                object_fit: ObjectFit::Fill,
            }),
        };
        assert_eq!(get_item_vertical_align(&img), Some(VerticalAlign::Top));
    }
    // =====================================================================
    // predicate: break-opportunity logic
    // =====================================================================
    #[test]
    fn no_break_space_is_a_word_separator_but_never_a_break_opportunity() {
        let nbsp = cl("\u{00A0}", 4.0);
        assert!(is_word_separator(&nbsp), "NBSP participates in word-spacing");
        assert!(
            !is_break_opportunity(&nbsp),
            "...but must NOT offer a soft wrap (10\\u{{00A0}}km must not wrap)"
        );
        assert!(!is_break_opportunity_with_word_break(
            &nbsp,
            WordBreak::BreakAll,
            Hyphens::Auto
        ));
        // Same for NNBSP / word joiner / ZWNBSP.
        for ch in ['\u{202F}', '\u{2060}', '\u{FEFF}'] {
            let item = cl(&ch.to_string(), 4.0);
            assert!(
                !is_break_opportunity_with_word_break(&item, WordBreak::BreakAll, Hyphens::Auto),
                "{ch:?} must suppress breaks"
            );
        }
    }
    #[test]
    fn zero_width_space_breaks_even_under_keep_all() {
        let zwsp = cl("\u{200B}", 0.0);
        assert!(is_break_opportunity(&zwsp));
        for wb in [WordBreak::Normal, WordBreak::BreakAll, WordBreak::KeepAll] {
            assert!(
                is_break_opportunity_with_word_break(&zwsp, wb, Hyphens::None),
                "ZWSP must always break ({wb:?})"
            );
        }
    }
    #[test]
    fn word_break_modes_change_cjk_break_opportunities() {
        let cjk = cl("中", 16.0);
        assert!(is_break_opportunity_with_word_break(&cjk, WordBreak::Normal, Hyphens::Manual));
        assert!(is_break_opportunity_with_word_break(&cjk, WordBreak::BreakAll, Hyphens::Manual));
        assert!(
            !is_break_opportunity_with_word_break(&cjk, WordBreak::KeepAll, Hyphens::Manual),
            "keep-all suppresses inter-ideograph breaks"
        );
        let latin = cl("a", 8.0);
        assert!(!is_break_opportunity_with_word_break(&latin, WordBreak::Normal, Hyphens::Manual));
        assert!(
            is_break_opportunity_with_word_break(&latin, WordBreak::BreakAll, Hyphens::Manual),
            "break-all makes every cluster breakable"
        );
        assert!(!is_break_opportunity_with_word_break(&latin, WordBreak::KeepAll, Hyphens::Manual));
    }
    #[test]
    fn soft_hyphen_break_depends_on_the_hyphens_property() {
        let shy = cl("\u{00AD}", 0.0);
        assert!(!is_break_opportunity_with_word_break(
            &shy,
            WordBreak::Normal,
            Hyphens::None
        ));
        assert!(is_break_opportunity_with_word_break(
            &shy,
            WordBreak::Normal,
            Hyphens::Manual
        ));
        assert!(is_break_opportunity_with_word_break(
            &shy,
            WordBreak::Normal,
            Hyphens::Auto
        ));
    }
    #[test]
    fn trailing_hyphen_and_slash_always_break_regardless_of_hyphens() {
        for text in ["co-", "co\u{2010}", "a/"] {
            let item = cl(text, 10.0);
            assert!(
                is_break_opportunity_with_word_break(&item, WordBreak::KeepAll, Hyphens::None),
                "{text:?} must offer a break after it even with hyphens:none"
            );
        }
        // A LEADING hyphen is not a break opportunity after the cluster.
        assert!(!is_break_opportunity_with_word_break(
            &cl("-a", 10.0),
            WordBreak::Normal,
            Hyphens::None
        ));
    }
    #[test]
    fn atomic_inlines_are_break_opportunities_but_breaks_and_tabs_differ() {
        assert!(is_break_opportunity(&obj(10.0, 10.0, 0.0)), "CSS Text 3 §5.1");
        assert!(is_break_opportunity(&brk()));
        assert!(!is_break_opportunity(&tab(8.0, 16.0)), "a Tab is not itself a wrap point");
        assert!(is_break_opportunity(&cl(" ", 4.0)));
        assert!(!is_break_opportunity(&cl("a", 8.0)));
    }
    // =====================================================================
    // numeric: geometry scanline helpers
    // =====================================================================
    #[test]
    fn merge_segments_of_zero_or_one_segment_is_identity() {
        assert!(merge_segments(Vec::new()).is_empty());
        let one = vec![LineSegment {
            start_x: 5.0,
            width: 3.0,
            priority: 0,
        }];
        let out = merge_segments(one);
        assert_eq!(out.len(), 1);
        assert_eq!(out[0].start_x, 5.0);
    }
    #[test]
    fn merge_segments_joins_overlapping_and_touching_spans() {
        let segs = vec![
            LineSegment {
                start_x: 0.0,
                width: 10.0,
                priority: 0,
            },
            LineSegment {
                start_x: 5.0,
                width: 10.0,
                priority: 0,
            }, // overlaps
            LineSegment {
                start_x: 15.0,
                width: 5.0,
                priority: 0,
            }, // exactly adjacent
            LineSegment {
                start_x: 100.0,
                width: 5.0,
                priority: 0,
            }, // disjoint
        ];
        let out = merge_segments(segs);
        assert_eq!(out.len(), 2, "three touching spans collapse into one");
        assert_eq!(out[0].start_x, 0.0);
        assert_eq!(out[0].width, 20.0);
        assert_eq!(out[1].start_x, 100.0);
    }
    #[test]
    fn merge_segments_sorts_unordered_input() {
        let segs = vec![
            LineSegment {
                start_x: 50.0,
                width: 5.0,
                priority: 0,
            },
            LineSegment {
                start_x: 0.0,
                width: 5.0,
                priority: 0,
            },
        ];
        let out = merge_segments(segs);
        assert_eq!(out.len(), 2);
        assert_eq!(out[0].start_x, 0.0);
        assert_eq!(out[1].start_x, 50.0);
    }
    #[test]
    fn merge_segments_is_nan_tolerant() {
        // A NaN start_x used to abort layout via `partial_cmp(...).unwrap()`; the
        // sort now falls back to Equal (like every other float compare in this
        // module), so the merge completes without panicking.
        let segs = vec![
            LineSegment {
                start_x: 0.0,
                width: 10.0,
                priority: 0,
            },
            LineSegment {
                start_x: f32::NAN,
                width: 10.0,
                priority: 0,
            },
        ];
        let out = merge_segments(segs);
        assert!(!out.is_empty(), "NaN input must not abort the merge");
    }
    #[test]
    fn polygon_line_intersection_needs_at_least_three_points() {
        assert!(polygon_line_intersection(&[], 0.0, 1.0).is_empty());
        assert!(polygon_line_intersection(&[Point { x: 0.0, y: 0.0 }], 0.0, 1.0).is_empty());
        assert!(polygon_line_intersection(
            &[Point { x: 0.0, y: 0.0 }, Point { x: 1.0, y: 1.0 }],
            0.0,
            1.0
        )
        .is_empty());
    }
    #[test]
    fn polygon_line_intersection_narrows_across_a_triangle() {
        // Right triangle (0,0) - (100,0) - (0,100): span width ≈ 100 - y.
        let tri = [
            Point { x: 0.0, y: 0.0 },
            Point { x: 100.0, y: 0.0 },
            Point { x: 0.0, y: 100.0 },
        ];
        let top = polygon_line_intersection(&tri, 10.0, 1.0);
        let bot = polygon_line_intersection(&tri, 80.0, 1.0);
        assert_eq!(top.len(), 1);
        assert_eq!(bot.len(), 1);
        assert!(
            top[0].width > bot[0].width,
            "the band must narrow with y ({} !> {})",
            top[0].width,
            bot[0].width
        );
        assert!((top[0].width - 89.5).abs() < 1.0);
    }
    #[test]
    fn polygon_line_intersection_outside_the_shape_and_on_nan_scanlines_is_empty() {
        let tri = [
            Point { x: 0.0, y: 0.0 },
            Point { x: 100.0, y: 0.0 },
            Point { x: 0.0, y: 100.0 },
        ];
        assert!(
            polygon_line_intersection(&tri, 500.0, 1.0).is_empty(),
            "a scanline below the shape yields no spans"
        );
        assert!(
            polygon_line_intersection(&tri, f32::NAN, 1.0).is_empty(),
            "a NaN scanline must not panic — every crossing test is false"
        );
        assert!(polygon_line_intersection(&tri, f32::INFINITY, 1.0).is_empty());
    }
    #[test]
    fn polygon_line_intersection_of_a_degenerate_flat_polygon_is_empty() {
        // All edges horizontal → every edge is skipped.
        let flat = [
            Point { x: 0.0, y: 5.0 },
            Point { x: 10.0, y: 5.0 },
            Point { x: 20.0, y: 5.0 },
        ];
        assert!(polygon_line_intersection(&flat, 4.5, 1.0).is_empty());
    }
    #[test]
    fn path_segments_line_intersection_on_empty_and_degenerate_input() {
        assert!(path_segments_line_intersection(&[], 0.0, 1.0).is_empty());
        // A lone MoveTo cannot form a subpath.
        assert!(path_segments_line_intersection(
            &[PathSegment::MoveTo(Point { x: 0.0, y: 0.0 })],
            0.0,
            1.0
        )
        .is_empty());
    }
    #[test]
    fn path_segments_line_intersection_of_a_square() {
        let sq = vec![
            PathSegment::MoveTo(Point { x: 0.0, y: 0.0 }),
            PathSegment::LineTo(Point { x: 100.0, y: 0.0 }),
            PathSegment::LineTo(Point {
                x: 100.0,
                y: 100.0,
            }),
            PathSegment::LineTo(Point { x: 0.0, y: 100.0 }),
            PathSegment::Close,
        ];
        let spans = path_segments_line_intersection(&sq, 50.0, 1.0);
        assert_eq!(spans.len(), 1);
        assert!((spans[0].0 - 0.0).abs() < 0.01);
        assert!((spans[0].1 - 100.0).abs() < 0.01);
        // Outside the square vertically → nothing.
        assert!(path_segments_line_intersection(&sq, 500.0, 1.0).is_empty());
    }
    #[test]
    fn get_shape_horizontal_spans_rectangle_only_when_the_line_box_overlaps() {
        let r = ShapeBoundary::Rectangle(Rect {
            x: 10.0,
            y: 20.0,
            width: 30.0,
            height: 40.0,
        });
        assert_eq!(get_shape_horizontal_spans(&r, 30.0, 10.0), vec![(10.0, 40.0)]);
        assert!(get_shape_horizontal_spans(&r, 0.0, 10.0).is_empty(), "above");
        assert!(get_shape_horizontal_spans(&r, 100.0, 10.0).is_empty(), "below");
        // Exactly touching the top edge: line [10,20) vs rect [20,60) → no overlap.
        assert!(get_shape_horizontal_spans(&r, 10.0, 10.0).is_empty());
        // A zero-height rect can never overlap.
        let flat = ShapeBoundary::Rectangle(Rect {
            x: 0.0,
            y: 0.0,
            width: 10.0,
            height: 0.0,
        });
        assert!(get_shape_horizontal_spans(&flat, 0.0, 10.0).is_empty());
    }
    #[test]
    fn get_shape_horizontal_spans_circle_edges_and_zero_radius() {
        let c = ShapeBoundary::Circle {
            center: Point { x: 50.0, y: 50.0 },
            radius: 10.0,
        };
        let mid = get_shape_horizontal_spans(&c, 49.5, 1.0); // line centre == 50.0
        assert_eq!(mid.len(), 1);
        assert!((mid[0].0 - 40.0).abs() < 0.01);
        assert!((mid[0].1 - 60.0).abs() < 0.01);
        assert!(get_shape_horizontal_spans(&c, 1000.0, 1.0).is_empty());
        // Zero radius: the scanline exactly through the centre yields a zero-width span.
        let dot = ShapeBoundary::Circle {
            center: Point { x: 5.0, y: 5.0 },
            radius: 0.0,
        };
        let spans = get_shape_horizontal_spans(&dot, 4.5, 1.0);
        assert_eq!(spans, vec![(5.0, 5.0)], "degenerate but not a panic");
    }
    #[test]
    fn get_shape_horizontal_spans_ellipse_with_zero_radii_is_empty_not_a_panic() {
        // radii.height == 0 → dy/0 → NaN → `NaN.abs() <= 0.0` is false → no spans.
        let e = ShapeBoundary::Ellipse {
            center: Point { x: 0.0, y: 0.0 },
            radii: Size::zero(),
        };
        assert!(
            get_shape_horizontal_spans(&e, 0.0, 1.0).is_empty(),
            "a zero-sized ellipse divides by zero but must not panic"
        );
    }
    #[test]
    fn get_shape_horizontal_spans_polygon_delegates_to_the_scanline() {
        let p = ShapeBoundary::Polygon {
            points: vec![
                Point { x: 0.0, y: 0.0 },
                Point { x: 100.0, y: 0.0 },
                Point { x: 0.0, y: 100.0 },
            ],
        };
        let spans = get_shape_horizontal_spans(&p, 10.0, 1.0);
        assert_eq!(spans.len(), 1);
        assert!(spans[0].1 > spans[0].0);
    }
    // =====================================================================
    // numeric/other: extract_line_breaks + try_incremental_relayout
    // =====================================================================
    #[test]
    fn extract_line_breaks_of_no_items_is_empty_but_keeps_the_width() {
        let lb = extract_line_breaks(&[], 640.0);
        assert!(lb.line_ranges.is_empty());
        assert!(lb.line_widths.is_empty());
        assert_eq!(lb.available_width, 640.0);
        // Even a NaN constraint round-trips untouched.
        let nan = extract_line_breaks(&[], f32::NAN);
        assert!(nan.available_width.is_nan());
    }
    #[test]
    fn extract_line_breaks_groups_items_by_line_index() {
        let items = vec![
            pos(cl("a", 10.0), 0.0, 0.0, 0),
            pos(cl("b", 10.0), 10.0, 0.0, 0),
            pos(cl("c", 10.0), 0.0, 20.0, 1),
        ];
        let lb = extract_line_breaks(&items, 100.0);
        assert_eq!(lb.line_ranges, vec![(0, 2), (2, 3)]);
        assert_eq!(lb.line_widths, vec![20.0, 10.0]);
        assert_eq!(lb.line_ranges.len(), lb.line_widths.len());
    }
    #[test]
    fn extract_line_breaks_splits_on_every_line_index_change_even_going_backwards() {
        // The scanner is purely edge-triggered: a non-monotonic line_index sequence
        // produces THREE ranges, not two. Pinned so a reorder-tolerant rewrite is visible.
        let items = vec![
            pos(cl("a", 10.0), 0.0, 0.0, 0),
            pos(cl("b", 10.0), 0.0, 20.0, 1),
            pos(cl("c", 10.0), 0.0, 0.0, 0),
        ];
        let lb = extract_line_breaks(&items, 100.0);
        assert_eq!(lb.line_ranges.len(), 3);
        assert_eq!(lb.line_widths, vec![10.0, 10.0, 10.0]);
    }
    #[test]
    fn try_incremental_relayout_no_dirty_items_is_a_glyph_swap() {
        let lb = CachedLineBreaks {
            line_ranges: vec![(0, 2)],
            line_widths: vec![20.0],
            available_width: 100.0,
        };
        assert!(matches!(
            try_incremental_relayout(&[], &[10.0, 10.0], &[10.0, 10.0], &lb),
            IncrementalRelayoutResult::GlyphSwap
        ));
    }
    #[test]
    fn try_incremental_relayout_out_of_range_dirty_index_falls_back_to_full() {
        let lb = CachedLineBreaks {
            line_ranges: vec![(0, 2)],
            line_widths: vec![20.0],
            available_width: 100.0,
        };
        assert!(matches!(
            try_incremental_relayout(&[99], &[10.0, 10.0], &[10.0, 10.0], &lb),
            IncrementalRelayoutResult::FullRelayout
        ));
        assert!(
            matches!(
                try_incremental_relayout(&[usize::MAX], &[10.0], &[10.0], &lb),
                IncrementalRelayoutResult::FullRelayout
            ),
            "usize::MAX must not index-panic"
        );
        // Mismatched advance vectors are also caught by the bounds check.
        assert!(matches!(
            try_incremental_relayout(&[1], &[10.0, 10.0], &[10.0], &lb),
            IncrementalRelayoutResult::FullRelayout
        ));
    }
    #[test]
    fn try_incremental_relayout_same_width_is_a_glyph_swap() {
        let lb = CachedLineBreaks {
            line_ranges: vec![(0, 2)],
            line_widths: vec![20.0],
            available_width: 100.0,
        };
        // Below the 0.001 epsilon → treated as unchanged.
        assert!(matches!(
            try_incremental_relayout(&[0], &[10.0, 10.0], &[10.0005, 10.0], &lb),
            IncrementalRelayoutResult::GlyphSwap
        ));
    }
    #[test]
    fn try_incremental_relayout_shifts_when_it_still_fits_and_reflows_when_it_does_not() {
        let lb = CachedLineBreaks {
            line_ranges: vec![(0, 2), (2, 4)],
            line_widths: vec![20.0, 20.0],
            available_width: 100.0,
        };
        let old = [10.0, 10.0, 10.0, 10.0];
        let grew = [10.0, 30.0, 10.0, 10.0]; // line 0 → 40 ≤ 100
        match try_incremental_relayout(&[1], &old, &grew, &lb) {
            IncrementalRelayoutResult::LineShift {
                affected_item,
                delta,
            } => {
                assert_eq!(affected_item, 1);
                assert_eq!(delta, 20.0);
            }
            other => panic!("expected LineShift, got {other:?}"),
        }
        let exploded = [10.0, 10.0, 10.0, 500.0]; // line 1 → 510 > 100
        match try_incremental_relayout(&[3], &old, &exploded, &lb) {
            IncrementalRelayoutResult::PartialReflow { reflow_from_line } => {
                assert_eq!(reflow_from_line, 1);
            }
            other => panic!("expected PartialReflow, got {other:?}"),
        }
    }
    #[test]
    fn try_incremental_relayout_dirty_item_outside_every_line_range_is_a_full_relayout() {
        let lb = CachedLineBreaks {
            line_ranges: vec![(0, 1)],
            line_widths: vec![10.0],
            available_width: 100.0,
        };
        // Item 1 exists in the advance arrays but is on no known line.
        assert!(matches!(
            try_incremental_relayout(&[1], &[10.0, 10.0], &[10.0, 50.0], &lb),
            IncrementalRelayoutResult::FullRelayout
        ));
    }
    #[test]
    fn try_incremental_relayout_with_nan_advances_reflows_rather_than_shifting() {
        let lb = CachedLineBreaks {
            line_ranges: vec![(0, 1)],
            line_widths: vec![10.0],
            available_width: 100.0,
        };
        // delta = NaN: `NaN.abs() < 0.001` is false, and `NaN <= width` is false,
        // so we land in PartialReflow — a defined outcome, not a panic.
        match try_incremental_relayout(&[0], &[10.0], &[f32::NAN], &lb) {
            IncrementalRelayoutResult::PartialReflow { reflow_from_line } => {
                assert_eq!(reflow_from_line, 0);
            }
            other => panic!("NaN advance should reflow, got {other:?}"),
        }
    }
    // =====================================================================
    // getter/other: TextShapingCache + TextCacheMemoryReport + calculate_id
    // =====================================================================
    /// NEGATIVE CONTROL for the Arc dedup in `memory_report`.
    ///
    /// Sharing one `Arc` across several cache keys is the point of the cache,
    /// so a walk that charges `capacity()` per KEY reports bytes the process
    /// does not have. Delete the `counted.insert(..)` guards in
    /// `memory_report` and this test fails on the first assertion: the shared
    /// vec gets charged three times.
    #[test]
    fn shared_arcs_are_counted_once_not_once_per_key() {
        let mut cache = TextShapingCache::new();
        let shared: Arc<Vec<LogicalItem>> = Arc::new(Vec::with_capacity(64));
        let one_copy = 64 * size_of::<LogicalItem>();
        cache.logical_items.insert(1, Arc::clone(&shared));
        let single = cache.memory_report();
        assert_eq!(single.logical_items_bytes, one_copy);
        assert_eq!(single.shared_bytes_avoided, 0);
        // Two MORE keys, same allocation. Nothing was allocated, so nothing
        // reported may grow.
        cache.logical_items.insert(2, Arc::clone(&shared));
        cache.logical_items.insert(3, Arc::clone(&shared));
        let shared_thrice = cache.memory_report();
        assert_eq!(
            shared_thrice.logical_items_bytes, one_copy,
            "three keys, one allocation: bytes must not triple"
        );
        assert_eq!(
            shared_thrice.shared_bytes_avoided,
            2 * one_copy,
            "the two extra keys are exactly the error per-key counting made"
        );
        // Entry COUNT is a count of keys and legitimately does rise; only the
        // BYTES are a claim about memory.
        assert_eq!(shared_thrice.logical_items_entries, 3);
        // POSITIVE control, so the test cannot pass by reporting zero for
        // everything: a genuinely distinct allocation must still be added.
        cache
            .logical_items
            .insert(4, Arc::new(Vec::with_capacity(64)));
        let distinct = cache.memory_report();
        assert_eq!(distinct.logical_items_bytes, 2 * one_copy);
    }
    #[test]
    fn text_cache_memory_report_total_bytes_sums_only_the_byte_fields() {
        let r = TextCacheMemoryReport::default();
        assert_eq!(r.total_bytes(), 0);
        // Powers of two, so a wrong total names the offending field.
        let full = TextCacheMemoryReport {
            per_item_atoms: 0,
            per_item_segments: 0,
            per_item_detail_glyphs: 0,
            logical_items_entries: 1_000_000, // must NOT be counted
            logical_items_bytes: 1,
            visual_items_entries: 1_000_000, // must NOT be counted
            visual_items_bytes: 2,
            shaped_items_entries: 1_000_000, // must NOT be counted
            shaped_items_bytes: 4,
            shaped_glyph_bytes: 8,
            shaped_cluster_text_bytes: 16,
            per_item_shaped_entries: 1_000_000, // must NOT be counted
            per_item_shaped_bytes: 32,
            map_overhead_bytes: 64,
            style_arc_bytes: 128,
            combined_block_glyph_bytes: 256,
            // NOT summed by total_bytes(): it is the size of an error the
            // walk avoided, not memory the process holds.
            shared_bytes_avoided: 1 << 30,
            distinct_style_arcs: 1_000_000, // a COUNT — must NOT be counted
            cluster_count: 1_000_000,       // a COUNT — must NOT be counted
        };
        assert_eq!(full.total_bytes(), 511, "1+2+4+8+16+32+64+128+256");
        // `bytes_per_cluster` divides by the count, so it must not divide by
        // zero and must use the count rather than any byte field.
        assert_eq!(
            TextCacheMemoryReport::default().bytes_per_cluster(),
            None,
            "no clusters cached => no per-cluster figure, not a division by zero"
        );
        assert_eq!(full.bytes_per_cluster(), Some(0), "511 / 1_000_000 truncates to 0");
    }
    #[test]
    fn text_shaping_cache_new_is_empty_and_reports_zero_bytes() {
        let c = TextShapingCache::new();
        let r = c.memory_report();
        assert_eq!(r.total_bytes(), 0);
        assert_eq!(r.logical_items_entries, 0);
        assert_eq!(r.per_item_shaped_entries, 0);
        assert_eq!(c.generation, 0);
        // Default must agree with new().
        let d = TextShapingCache::default();
        assert_eq!(d.memory_report().total_bytes(), 0);
    }
    #[test]
    fn text_shaping_cache_begin_generation_is_idempotent_on_an_empty_cache() {
        let mut c = TextShapingCache::new();
        for expect in 1..=5_u64 {
            c.begin_generation();
            assert_eq!(c.generation, expect);
        }
        assert!(c.per_item_accessed.is_empty());
        assert!(c.per_item_shaped.is_empty());
    }
    /// The three STAGE caches must be swept, not just `per_item_shaped`.
    ///
    /// They had no cap and no sweep of any kind: every distinct piece of
    /// text ever laid out stayed resident for the life of the process, so
    /// editing grew them monotonically — the entry for a wording you deleted
    /// stayed shaped forever. Measured on one 960-line markdown: 792 entries
    /// each, 7.0 MB, ON TOP of the 13.5 MB of the same shaped text already
    /// held per layout node as `warm.inline`.
    ///
    /// Same policy as per-item: touched this generation survives, untouched
    /// goes. Generation 0 never evicts, so a first pass cannot throw away
    /// what it just built.
    ///
    /// NEGATIVE CONTROL: dropping the three `retain` calls from
    /// `begin_generation` makes the eviction assertions fail — run and seen.
    #[test]
    fn text_shaping_cache_begin_generation_evicts_unaccessed_stage_entries() {
        let mut c = TextShapingCache::new();
        c.logical_items.insert(1, Arc::new(Vec::new()));
        c.logical_items.insert(2, Arc::new(Vec::new()));
        c.visual_items.insert(1, Arc::new(Vec::new()));
        c.visual_items.insert(2, Arc::new(Vec::new()));
        // (d7) The monolithic shaped_items stage is deleted; the third
        // stage_entry_counts slot now reports the per-item store, which
        // has its own sweep (covered by the per-item eviction test).
        // Generation 0 never evicts — otherwise the very first layout pass
        // would discard the entries it had just produced.
        c.begin_generation();
        assert_eq!(
            c.stage_entry_counts(),
            (2, 2, 0),
            "gen 0 must keep everything"
        );
        // Touch only id 1. Rolling the generation drops id 2 from both.
        c.touch_stage(1);
        c.begin_generation();
        assert_eq!(
            c.stage_entry_counts(),
            (1, 1, 0),
            "an id not touched this generation must be evicted from every \
             stage cache - these were the unbounded ones"
        );
        assert!(c.logical_items.contains_key(&1));
        assert!(c.visual_items.contains_key(&1));
        // The accessed set resets, so surviving entries must be re-touched
        // to survive again — otherwise nothing would ever be released.
        assert!(c.stage_accessed.is_empty());
        c.begin_generation();
        assert_eq!(
            c.stage_entry_counts(),
            (1, 1, 0),
            "an empty accessed set skips the sweep rather than clearing all"
        );
    }
    /// (d7) The compaction roundtrip gate: expand(build(v)) == v
    /// EXACTLY, across every irregularity class — multi-glyph ligature
    /// details, kerned glyphs, non-Character kinds, fallback-font
    /// clusters, marker/fragment flags, and non-cluster atoms — on
    /// synthetic items where every field is controlled.
    #[test]
    fn d7_compact_shaped_entry_roundtrip_is_exact() {
        let mut base = cl("a", 8.0);
        if let ShapedItem::Cluster(ref mut c) = base {
            // The helper models a vmtx font; zero it so THIS cluster
            // takes the SIMPLE (no-detail) reconstruction arm — the
            // all-atoms vacuity this gate exists to prevent.
            c.glyphs[0].vertical_advance = 0.0;
        }
        let mut items: Vec<ShapedItem> = vec![base.clone(), cl("b", 9.0)];
        // Ligature detail: two glyphs in one cluster.
        if let ShapedItem::Cluster(ref mut c) = items[1] {
            let mut g2 = c.glyphs[0].clone();
            g2.cluster_offset = 1;
            g2.kerning = -0.5;
            c.glyphs.push(g2);
        }
        // Fallback font: same style Arc, different font_hash → atom.
        let mut fallback = cl("c", 7.0);
        if let ShapedItem::Cluster(ref mut c) = fallback {
            c.glyphs[0].font_hash = c.glyphs[0].font_hash.wrapping_add(1);
        }
        items.push(fallback);
        // Marker cluster → atom.
        let mut marker = cl("d", 6.0);
        if let ShapedItem::Cluster(ref mut c) = marker {
            c.marker_position_outside = Some(true);
        }
        items.push(marker);
        // Fragment flags OFF their shaping-stage (true, true) default →
        // atom (the line breaker owns cleared flags).
        let mut frag = cl("e", 5.0);
        if let ShapedItem::Cluster(ref mut c) = frag {
            c.is_first_fragment = false;
        }
        items.push(frag);
        // Non-cluster atom.
        items.push(ShapedItem::Break {
            source: ContentIndex { run_index: 0, item_index: 0 },
            break_info: InlineBreak {
                break_type: BreakType::Hard,
                clear: ClearType::None,
                content_index: 0,
            },
        });
        let compact = CompactShapedEntry::build(&items);
        assert!(
            !compact.clusters.is_empty(),
            "at least the plain 'a' cluster must COMPACT (not atomize) — \
             an all-atoms entry makes this gate vacuous for the simple-\
             cluster reconstruction arm"
        );
        // (d7 segmented) The whole-cluster fallback FONT now starts a
        // new SEGMENT (font is a segment field); only marker, cleared
        // fragment flags, and the Break remain atoms.
        assert_eq!(
            compact.atom_count(),
            3,
            "marker+fragment(false)+break are atoms; the fallback-font \
             cluster must SEGMENT"
        );
        assert!(compact.segment_count() >= 2, "fallback font segments");
        let expanded = compact.expand();
        assert_eq!(expanded.len(), items.len());
        for (i, (e, o)) in expanded.iter().zip(items.iter()).enumerate() {
            assert_eq!(e, o, "roundtrip diverges at item {i}");
        }
        // Empty entry roundtrips too.
        assert!(CompactShapedEntry::build(&[]).expand().is_empty());
        // (d7 segmented) A coalesce-group-like sequence: clusters from
        // TWO different text Arcs must land in TWO SEGMENTS with zero
        // extra atoms — the single-header design atomized everything
        // after the first Arc change (~300 B/cluster on the real
        // corpus, measured 9.1 MiB; the regression this pin prevents).
        let mut a1 = cl("x", 4.0);
        let mut a2 = cl("y", 5.0);
        for it in [&mut a1, &mut a2] {
            if let ShapedItem::Cluster(ref mut c) = it {
                c.glyphs[0].vertical_advance = 0.0;
            }
        }
        let two = vec![a1.clone(), a2.clone()];
        let compact2 = CompactShapedEntry::build(&two);
        assert_eq!(compact2.atom_count(), 0, "distinct text Arcs must SEGMENT, not atomize");
        assert_eq!(compact2.segment_count(), 2);
        let exp2 = compact2.expand();
        assert_eq!(exp2.len(), 2);
        for (i, (e, o)) in exp2.iter().zip(two.iter()).enumerate() {
            assert_eq!(e, o, "segmented roundtrip diverges at {i}");
        }
    }
    #[test]
    fn text_shaping_cache_begin_generation_evicts_unaccessed_per_item_entries() {
        let mut c = TextShapingCache::new();
        c.per_item_shaped.insert(
            1,
            Arc::new(PerItemShapedEntry {
                compact: CompactShapedEntry::build(&[cl("a", 8.0)]),
                total_advance: 8.0,
            }),
        );
        c.per_item_shaped.insert(
            2,
            Arc::new(PerItemShapedEntry {
                compact: CompactShapedEntry::build(&[]),
                total_advance: 0.0,
            }),
        );
        // Generation 0 → the eviction guard is skipped entirely.
        c.begin_generation();
        assert_eq!(c.per_item_shaped.len(), 2, "gen 0 never evicts");
        // Touch only key 1, then roll the generation: key 2 must be dropped.
        c.per_item_accessed.insert(1);
        c.begin_generation();
        assert_eq!(c.per_item_shaped.len(), 1);
        assert!(c.per_item_shaped.contains_key(&1));
        // Nothing accessed this generation → the retain is skipped (NOT a full flush).
        c.begin_generation();
        assert_eq!(
            c.per_item_shaped.len(),
            1,
            "an empty access-set must not wipe the cache"
        );
    }
    #[test]
    fn use_old_layout_accepts_a_render_only_change_and_rejects_layout_changes() {
        let c = UnifiedConstraints::default();
        let red = styled(|s| {
            s.color = ColorU {
                r: 255,
                g: 0,
                b: 0,
                a: 255,
            };
        });
        let old = [text_content("hi", style())];
        let new_colour = [text_content("hi", red)];
        assert!(
            TextShapingCache::use_old_layout(&c, &c, &old, &new_colour),
            "a colour-only change must reuse the cached layout"
        );
        // Different text → no reuse.
        let new_text = [text_content("ho", style())];
        assert!(!TextShapingCache::use_old_layout(&c, &c, &old, &new_text));
        // Different font size → no reuse.
        let bigger = [text_content("hi", styled(|s| s.font_size_px = 32.0))];
        assert!(!TextShapingCache::use_old_layout(&c, &c, &old, &bigger));
        // Different constraints → no reuse.
        let c2 = UnifiedConstraints {
            available_width: AvailableSpace::Definite(100.0),
            ..Default::default()
        };
        assert!(!TextShapingCache::use_old_layout(&c, &c2, &old, &old));
    }
    #[test]
    fn use_old_layout_on_empty_content_and_length_or_variant_mismatch() {
        let c = UnifiedConstraints::default();
        assert!(
            TextShapingCache::use_old_layout(&c, &c, &[], &[]),
            "empty vs empty is trivially reusable"
        );
        let one = [text_content("a", style())];
        assert!(!TextShapingCache::use_old_layout(&c, &c, &[], &one));
        assert!(!TextShapingCache::use_old_layout(&c, &c, &one, &[]));
        // Same length, different variant.
        let space = [InlineContent::Space(InlineSpace {
            width: 4.0,
            is_breaking: true,
            is_stretchy: true,
        })];
        assert!(!TextShapingCache::use_old_layout(&c, &c, &one, &space));
        assert!(TextShapingCache::use_old_layout(&c, &c, &space, &space));
    }
    #[test]
    fn inline_content_layout_eq_recurses_into_ruby() {
        let ruby = |base: &str| InlineContent::Ruby {
            base: vec![text_content(base, style())],
            text: vec![text_content("ふり", style())],
            style: style(),
        };
        assert!(TextShapingCache::inline_content_layout_eq(
            &ruby("漢"),
            &ruby("漢")
        ));
        assert!(!TextShapingCache::inline_content_layout_eq(
            &ruby("漢"),
            &ruby("字")
        ));
    }
    #[test]
    fn calculate_id_is_deterministic_and_discriminating() {
        assert_eq!(calculate_id(&"abc"), calculate_id(&"abc"));
        assert_ne!(calculate_id(&"abc"), calculate_id(&"abd"));
        assert_eq!(calculate_id(&0_u64), calculate_id(&0_u64));
        assert_ne!(calculate_id(&0_u64), calculate_id(&u64::MAX));
        // Empty input must still produce a stable id (not a panic / not zero-by-accident).
        let e: Vec<u8> = Vec::new();
        assert_eq!(calculate_id(&e), calculate_id(&Vec::<u8>::new()));
    }
    #[test]
    fn shaped_items_key_new_on_empty_visual_items_is_stable() {
        let a = ShapedItemsKey::new(7, &[]);
        let b = ShapedItemsKey::new(7, &[]);
        assert_eq!(a, b);
        assert_eq!(hash_of(&a), hash_of(&b));
        // The cache id participates in identity.
        assert_ne!(a, ShapedItemsKey::new(8, &[]));
    }
    #[test]
    fn shaped_items_key_new_hashes_the_text_styles() {
        let vi = |st: Arc<StyleProperties>| VisualItem {
            logical_source: LogicalItem::Text {
                source: ci(0, 0),
                text: Arc::from("a"),
                style: st,
                marker_position_outside: None,
                source_node_id: None,
            },
            bidi_level: BidiLevel::new(0),
            script: Script::Latin,
            text: "a".to_string(),
            run_byte_offset: 0,
        };
        let base = ShapedItemsKey::new(1, &[vi(style())]);
        let same = ShapedItemsKey::new(1, &[vi(style())]);
        let other = ShapedItemsKey::new(1, &[vi(styled(|s| s.font_size_px = 32.0))]);
        assert_eq!(base, same);
        assert_ne!(base.style_hash, other.style_hash, "font size must change the key");
    }
    // =====================================================================
    // getter/predicate: OverflowInfo + UnifiedLayout
    // =====================================================================
    #[test]
    fn overflow_info_default_has_no_overflow() {
        let o = OverflowInfo::default();
        assert!(!o.has_overflow());
        assert_eq!(o.unclipped_bounds, Rect::default());
        let with = OverflowInfo {
            overflow_items: vec![cl("a", 8.0)],
            unclipped_bounds: Rect::default(),
        };
        assert!(with.has_overflow());
    }
    fn layout_of(items: Vec<PositionedItem>) -> UnifiedLayout {
        UnifiedLayout {
            items,
            overflow: OverflowInfo::default(),
        }
    }
    #[test]
    fn unified_layout_empty_is_inert_across_every_accessor() {
        let l = layout_of(Vec::new());
        assert!(l.is_empty());
        assert_eq!(l.bounds(), Rect::default());
        assert_eq!(l.first_baseline(), None);
        assert_eq!(l.last_baseline(), None);
        assert_eq!(l.get_first_cluster_cursor(), None);
        assert_eq!(l.get_last_cluster_cursor(), None);
        assert!(l.grapheme_stops().is_empty());
        assert_eq!(
            l.hittest_cursor(LogicalPosition { x: 0.0, y: 0.0 }),
            None,
            "hit-testing an empty layout must return None, not index [0]"
        );
        assert_eq!(
            l.hittest_cursor(LogicalPosition {
                x: f32::NAN,
                y: f32::NAN
            }),
            None
        );
    }
    #[test]
    fn unified_layout_bounds_spans_all_items() {
        let l = layout_of(vec![
            pos(cl("a", 10.0), 0.0, 0.0, 0),
            pos(cl("b", 10.0), 90.0, 20.0, 1),
        ]);
        let b = l.bounds();
        assert_eq!(b.x, 0.0);
        assert_eq!(b.y, 0.0);
        assert_eq!(b.width, 100.0, "0 → 90+10");
        approx(b.height, 36.0); // 0 → 20 + the 16px line box
        assert!(!l.is_empty());
    }
    #[test]
    fn unified_layout_baselines_skip_breaks_and_tabs() {
        let l = layout_of(vec![
            pos(brk(), 0.0, 0.0, 0),
            pos(cl("a", 10.0), 0.0, 0.0, 0),
            pos(obj(10.0, 20.0, 5.0), 10.0, 0.0, 0),
            pos(tab(8.0, 16.0), 20.0, 0.0, 0),
        ]);
        approx(
            l.first_baseline().expect("the cluster, not the break"),
            12.8,
        );
        assert_eq!(l.last_baseline(), Some(5.0), "the object, not the tab");
    }
    #[test]
    fn unified_layout_cluster_cursors_skip_non_clusters() {
        let l = layout_of(vec![
            pos(brk(), 0.0, 0.0, 0),
            pos(cl_at("a", 10.0, 0, 0), 0.0, 0.0, 0),
            pos(cl_at("b", 10.0, 0, 1), 10.0, 0.0, 0),
            pos(tab(8.0, 16.0), 20.0, 0.0, 0),
        ]);
        assert_eq!(
            l.get_first_cluster_cursor(),
            Some(TextCursor {
                cluster_id: gid(0, 0),
                affinity: CursorAffinity::Leading
            })
        );
        assert_eq!(
            l.get_last_cluster_cursor(),
            Some(TextCursor {
                cluster_id: gid(0, 1),
                affinity: CursorAffinity::Trailing
            })
        );
        // A layout with no clusters at all has no cursors.
        let no_clusters = layout_of(vec![pos(brk(), 0.0, 0.0, 0)]);
        assert_eq!(no_clusters.get_first_cluster_cursor(), None);
        assert_eq!(no_clusters.get_last_cluster_cursor(), None);
    }
    #[test]
    fn unified_layout_grapheme_stops_sorts_dedups_and_folds_combining_marks() {
        // Deliberately out of order, with a duplicate id and a combining mark.
        let l = layout_of(vec![
            pos(cl_at("b", 10.0, 0, 1), 10.0, 0.0, 0),
            pos(cl_at("a", 10.0, 0, 0), 0.0, 0.0, 0),
            pos(cl_at("a", 10.0, 0, 0), 0.0, 0.0, 0), // duplicate id
            pos(cl_at("\u{0301}", 0.0, 0, 2), 20.0, 0.0, 0), // combining acute
        ]);
        let stops = l.grapheme_stops();
        assert_eq!(
            stops,
            vec![gid(0, 0), gid(0, 1)],
            "sorted, de-duplicated, with the combining mark folded away"
        );
    }
    #[test]
    fn unified_layout_cluster_is_grapheme_continuation() {
        assert!(UnifiedLayout::cluster_is_grapheme_continuation("\u{0301}"));
        assert!(UnifiedLayout::cluster_is_grapheme_continuation("\u{FE0F}"), "VS-16");
        assert!(!UnifiedLayout::cluster_is_grapheme_continuation("a"));
        assert!(!UnifiedLayout::cluster_is_grapheme_continuation("中"));
        assert!(
            !UnifiedLayout::cluster_is_grapheme_continuation(""),
            "an empty cluster must return false, not panic"
        );
    }
    #[test]
    fn unified_layout_grapheme_caret_offset_maps_affinity_and_gaps() {
        let stops = [gid(0, 0), gid(0, 1), gid(0, 2)];
        assert_eq!(
            UnifiedLayout::grapheme_caret_offset(
                &stops,
                &TextCursor {
                    cluster_id: gid(0, 0),
                    affinity: CursorAffinity::Leading
                }
            ),
            Some(0)
        );
        assert_eq!(
            UnifiedLayout::grapheme_caret_offset(
                &stops,
                &TextCursor {
                    cluster_id: gid(0, 2),
                    affinity: CursorAffinity::Trailing
                }
            ),
            Some(3),
            "the document end is len, i.e. one past the last stop"
        );
        // A cursor addressing a folded mark snaps back to the preceding stop.
        assert_eq!(
            UnifiedLayout::grapheme_caret_offset(
                &stops,
                &TextCursor {
                    cluster_id: gid(0, 99),
                    affinity: CursorAffinity::Leading
                }
            ),
            Some(2)
        );
        // A cursor before every stop has no offset.
        assert_eq!(
            UnifiedLayout::grapheme_caret_offset(
                &[gid(5, 5)],
                &TextCursor {
                    cluster_id: gid(0, 0),
                    affinity: CursorAffinity::Leading
                }
            ),
            None
        );
        // Empty stop list → None, no panic.
        assert_eq!(
            UnifiedLayout::grapheme_caret_offset(
                &[],
                &TextCursor {
                    cluster_id: gid(0, 0),
                    affinity: CursorAffinity::Leading
                }
            ),
            None
        );
    }
    #[test]
    fn unified_layout_cursor_from_grapheme_offset_clamps_past_the_end() {
        let stops = [gid(0, 0), gid(0, 1)];
        assert_eq!(
            UnifiedLayout::cursor_from_grapheme_offset(&stops, 0),
            TextCursor {
                cluster_id: gid(0, 0),
                affinity: CursorAffinity::Leading
            }
        );
        assert_eq!(
            UnifiedLayout::cursor_from_grapheme_offset(&stops, 1),
            TextCursor {
                cluster_id: gid(0, 1),
                affinity: CursorAffinity::Leading
            }
        );
        // offset == len and anything beyond clamp to Trailing-on-last.
        let end = TextCursor {
            cluster_id: gid(0, 1),
            affinity: CursorAffinity::Trailing,
        };
        assert_eq!(UnifiedLayout::cursor_from_grapheme_offset(&stops, 2), end);
        assert_eq!(
            UnifiedLayout::cursor_from_grapheme_offset(&stops, usize::MAX),
            end,
            "usize::MAX must clamp, not overflow"
        );
    }
    #[test]
    #[should_panic]
    fn unified_layout_cursor_from_grapheme_offset_panics_on_an_empty_stop_list() {
        // FINDING: with `stops == []`, `offset >= n` is true for offset 0 and the
        // function indexes `stops[n - 1]` → `0usize - 1`. Every public caller happens
        // to guard `stops.is_empty()` first, so this is latent, not live — but the
        // helper itself has no guard.
        let _ = UnifiedLayout::cursor_from_grapheme_offset(&[], 0);
    }
    #[test]
    fn unified_layout_cursor_motion_on_an_empty_layout_returns_the_cursor_unchanged() {
        let l = layout_of(Vec::new());
        let c = TextCursor {
            cluster_id: gid(0, 0),
            affinity: CursorAffinity::Leading,
        };
        let mut dbg = None;
        assert_eq!(l.move_cursor_left(c, &mut dbg), c);
        assert_eq!(l.move_cursor_right(c, &mut dbg), c);
        assert_eq!(l.move_cursor_to_line_start(c, &mut dbg), c);
        assert_eq!(l.move_cursor_to_line_end(c, &mut dbg), c);
        assert_eq!(l.move_cursor_to_prev_word(c, &mut dbg), c);
        assert_eq!(l.move_cursor_to_next_word(c, &mut dbg), c);
        let mut goal = None;
        assert_eq!(l.move_cursor_up(c, &mut goal, &mut dbg), c);
        assert_eq!(l.move_cursor_down(c, &mut goal, &mut dbg), c);
    }
    #[test]
    fn unified_layout_move_cursor_left_right_walk_one_grapheme_at_a_time() {
        let l = layout_of(vec![
            pos(cl_at("a", 10.0, 0, 0), 0.0, 0.0, 0),
            pos(cl_at("b", 10.0, 0, 1), 10.0, 0.0, 0),
            pos(cl_at("c", 10.0, 0, 2), 20.0, 0.0, 0),
        ]);
        let mut dbg = None;
        let start = TextCursor {
            cluster_id: gid(0, 0),
            affinity: CursorAffinity::Leading,
        };
        // Left at the document start is a fixed point (saturating_sub).
        assert_eq!(l.move_cursor_left(start, &mut dbg), start);
        // Right advances one stop at a time and reaches the document end.
        let c1 = l.move_cursor_right(start, &mut dbg);
        assert_eq!(c1.cluster_id, gid(0, 1));
        let c2 = l.move_cursor_right(c1, &mut dbg);
        assert_eq!(c2.cluster_id, gid(0, 2));
        let end = l.move_cursor_right(c2, &mut dbg);
        assert_eq!(end.cluster_id, gid(0, 2));
        assert_eq!(end.affinity, CursorAffinity::Trailing, "document end");
        // ...and is a fixed point there.
        assert_eq!(l.move_cursor_right(end, &mut dbg), end);
        // Left from the end walks back symmetrically.
        assert_eq!(l.move_cursor_left(end, &mut dbg).cluster_id, gid(0, 2));
    }
    #[test]
    fn unified_layout_hittest_cursor_picks_the_nearest_cluster_and_its_half() {
        let l = layout_of(vec![
            pos(cl_at("a", 10.0, 0, 0), 0.0, 0.0, 0),
            pos(cl_at("b", 10.0, 0, 1), 10.0, 0.0, 0),
        ]);
        let hit = |x: f32| l.hittest_cursor(LogicalPosition { x, y: 5.0 }).unwrap();
        assert_eq!(hit(1.0).cluster_id, gid(0, 0));
        assert_eq!(hit(1.0).affinity, CursorAffinity::Leading);
        assert_eq!(hit(9.0).affinity, CursorAffinity::Trailing, "right half of 'a'");
        assert_eq!(hit(11.0).cluster_id, gid(0, 1));
        // Far outside the layout still resolves to the nearest cluster, not None.
        assert_eq!(hit(-1000.0).cluster_id, gid(0, 0));
        assert_eq!(hit(1000.0).cluster_id, gid(0, 1));
    }
    #[test]
    fn unified_layout_get_selection_rects_on_an_unknown_range_is_empty() {
        let l = layout_of(vec![pos(cl_at("a", 10.0, 0, 0), 0.0, 0.0, 0)]);
        let unknown = SelectionRange {
            start: TextCursor {
                cluster_id: gid(9, 9),
                affinity: CursorAffinity::Leading,
            },
            end: TextCursor {
                cluster_id: gid(9, 9),
                affinity: CursorAffinity::Trailing,
            },
        };
        assert!(l.get_selection_rects(&unknown).is_empty());
        // A degenerate (collapsed) range over a real cluster must not panic.
        let collapsed = SelectionRange {
            start: TextCursor {
                cluster_id: gid(0, 0),
                affinity: CursorAffinity::Leading,
            },
            end: TextCursor {
                cluster_id: gid(0, 0),
                affinity: CursorAffinity::Leading,
            },
        };
        let _ = l.get_selection_rects(&collapsed);
    }
    #[test]
    fn unified_layout_get_cursor_rect_for_known_and_unknown_cursors() {
        let l = layout_of(vec![pos(cl_at("a", 10.0, 0, 0), 5.0, 7.0, 0)]);
        let leading = l.get_cursor_rect(&TextCursor {
            cluster_id: gid(0, 0),
            affinity: CursorAffinity::Leading,
        });
        let r = leading.expect("the leading edge of a placed cluster must have a rect");
        assert_eq!(r.origin.x, 5.0);
        assert_eq!(r.origin.y, 7.0);
        assert_eq!(r.size.width, 1.0, "the caret is a 1px sliver");
        // A cursor in a run that was never laid out has no rect.
        assert_eq!(
            l.get_cursor_rect(&TextCursor {
                cluster_id: gid(9, 0),
                affinity: CursorAffinity::Leading
            }),
            None
        );
        // An empty layout has no rect for anything.
        assert_eq!(
            layout_of(Vec::new()).get_cursor_rect(&TextCursor {
                cluster_id: gid(0, 0),
                affinity: CursorAffinity::Leading
            }),
            None
        );
    }
    // =====================================================================
    // constructor/getter: BreakCursor
    // =====================================================================
    #[test]
    fn break_cursor_new_on_an_empty_slice_is_both_at_start_and_done() {
        let items: Vec<ShapedItem> = Vec::new();
        let mut c = BreakCursor::new(&items);
        assert!(c.is_at_start());
        assert!(c.is_done());
        assert_eq!(c.word_break, WordBreak::Normal);
        assert_eq!(c.hyphens, Hyphens::default());
        assert_eq!(c.line_break, LineBreakStrictness::default());
        assert!(c.peek_next_unit().is_empty());
        assert!(c.peek_next_single_item().is_empty());
        assert!(c.drain_remaining().is_empty());
    }
    #[test]
    fn break_cursor_with_word_break_stores_the_mode() {
        let items = vec![cl("a", 8.0)];
        let c = BreakCursor::with_word_break(&items, WordBreak::BreakAll);
        assert_eq!(c.word_break, WordBreak::BreakAll);
        assert!(c.is_at_start());
        assert!(!c.is_done());
    }
    #[test]
    fn break_cursor_consume_zero_is_a_no_op() {
        let items = vec![cl("a", 8.0), cl("b", 8.0)];
        let mut c = BreakCursor::new(&items);
        c.consume(0);
        assert!(c.is_at_start());
        assert_eq!(c.next_item_index, 0);
    }
    #[test]
    fn break_cursor_consume_advances_and_ends_the_stream() {
        let items = vec![cl("a", 8.0), cl("b", 8.0)];
        let mut c = BreakCursor::new(&items);
        c.consume(1);
        assert!(!c.is_at_start());
        assert!(!c.is_done());
        assert_eq!(c.peek_next_single_item().len(), 1);
        c.consume(1);
        assert!(c.is_done());
        assert!(c.peek_next_single_item().is_empty());
    }
    #[test]
    fn break_cursor_over_consuming_past_the_end_still_reports_done() {
        let items = vec![cl("a", 8.0)];
        let mut c = BreakCursor::new(&items);
        c.consume(usize::MAX);
        assert!(c.is_done(), "an over-consume must not wrap around to not-done");
        assert!(
            c.drain_remaining().is_empty(),
            "drain_remaining is bounds-guarded"
        );
    }
    #[test]
    #[should_panic]
    fn break_cursor_peek_next_unit_after_over_consuming_slices_out_of_bounds() {
        // FINDING: `consume()` does not clamp `next_item_index` to `items.len()`, and
        // `peek_next_unit` slices `self.items[self.next_item_index..]` unguarded (unlike
        // `peek_next_single_item` / `drain_remaining`, which both test `<  len`). A caller
        // that over-consumes and then peeks gets a slice-index panic instead of an empty unit.
        let items = vec![cl("a", 8.0)];
        let mut c = BreakCursor::new(&items);
        c.consume(5);
        let _ = c.peek_next_unit();
    }
    #[test]
    fn break_cursor_drains_the_remainder_before_the_main_list() {
        let items = vec![cl("a", 8.0), cl("b", 8.0)];
        let mut c = BreakCursor::new(&items);
        c.partial_remainder = vec![cl("R", 8.0)];
        assert!(!c.is_at_start(), "a pending remainder means we are mid-stream");
        assert!(!c.is_done());
        assert_eq!(
            c.peek_next_single_item()[0].as_cluster().unwrap().text(),
            "R",
            "the remainder is served first"
        );
        let drained = c.drain_remaining();
        assert_eq!(drained.len(), 3, "remainder + both queued items");
        assert_eq!(drained[0].as_cluster().unwrap().text(), "R");
        assert!(c.is_done());
    }
    #[test]
    fn break_cursor_is_done_is_false_while_a_remainder_is_pending() {
        let items: Vec<ShapedItem> = Vec::new();
        let mut c = BreakCursor::new(&items);
        c.partial_remainder = vec![cl("x", 8.0)];
        assert!(!c.is_done(), "the main list is exhausted but the remainder is not");
        c.consume(1);
        assert!(c.is_done());
    }
    #[test]
    fn break_cursor_consume_spanning_remainder_and_main_list() {
        let items = vec![cl("a", 8.0), cl("b", 8.0), cl("c", 8.0)];
        let mut c = BreakCursor::new(&items);
        c.partial_remainder = vec![cl("R1", 8.0), cl("R2", 8.0)];
        // Eat both remainder items + one from the main list.
        c.consume(3);
        assert!(c.partial_remainder.is_empty());
        assert_eq!(c.next_item_index, 1);
        assert_eq!(c.peek_next_single_item()[0].as_cluster().unwrap().text(), "b");
    }
    #[test]
    fn break_cursor_peek_next_unit_returns_a_whole_word_then_the_space() {
        let items = vec![
            cl("h", 8.0),
            cl("i", 8.0),
            cl(" ", 4.0),
            cl("y", 8.0),
            cl("o", 8.0),
        ];
        let mut c = BreakCursor::new(&items);
        let word = c.peek_next_unit();
        assert_eq!(word.len(), 2, "the word stops at the space");
        assert_eq!(word[0].as_cluster().unwrap().text(), "h");
        c.consume(word.len());
        let space = c.peek_next_unit();
        assert_eq!(space.len(), 1, "a leading break opportunity is a unit on its own");
        assert_eq!(space[0].as_cluster().unwrap().text(), " ");
        c.consume(1);
        assert_eq!(c.peek_next_unit().len(), 2, "the trailing word");
    }
    #[test]
    fn break_cursor_peek_next_unit_honours_break_all_and_keep_all() {
        let items = vec![cl("中", 16.0), cl("文", 16.0), cl("字", 16.0)];
        let normal = BreakCursor::new(&items);
        assert_eq!(
            normal.peek_next_unit().len(),
            1,
            "word-break:normal — each ideograph is its own unit"
        );
        let all = BreakCursor::with_word_break(&items, WordBreak::BreakAll);
        assert_eq!(all.peek_next_unit().len(), 1, "break-all — one cluster per unit");
        let keep = BreakCursor::with_word_break(&items, WordBreak::KeepAll);
        assert_eq!(
            keep.peek_next_unit().len(),
            3,
            "keep-all — the whole CJK run is unbreakable"
        );
        let latin = vec![cl("a", 8.0), cl("b", 8.0)];
        let latin_all = BreakCursor::with_word_break(&latin, WordBreak::BreakAll);
        assert_eq!(latin_all.peek_next_unit().len(), 1, "break-all splits Latin too");
    }
    #[test]
    fn break_cursor_peek_next_unit_glues_across_a_word_joiner() {
        // Control: without a joiner the unit ends at the space.
        let plain = vec![cl("a", 8.0), cl(" ", 4.0), cl("b", 8.0)];
        let control = BreakCursor::new(&plain);
        assert_eq!(
            control.peek_next_unit().len(),
            1,
            "the unit normally stops before the space"
        );
        // With a WORD JOINER (U+2060) in between, the break after it is suppressed,
        // so the space is pulled into the same unbreakable unit.
        let glued = vec![
            cl("a", 8.0),
            cl("\u{2060}", 0.0),
            cl(" ", 4.0),
            cl("b", 8.0),
        ];
        let c = BreakCursor::new(&glued);
        let unit = c.peek_next_unit();
        assert!(
            unit.len() > 1,
            "a word joiner must suppress the following break, got {} item(s)",
            unit.len()
        );
    }
    #[test]
    fn break_cursor_peek_next_single_item_prefers_the_remainder() {
        let items = vec![cl("a", 8.0)];
        let mut c = BreakCursor::new(&items);
        assert_eq!(c.peek_next_single_item()[0].as_cluster().unwrap().text(), "a");
        c.partial_remainder = vec![cl("R", 8.0)];
        assert_eq!(c.peek_next_single_item()[0].as_cluster().unwrap().text(), "R");
        assert_eq!(
            c.peek_next_single_item().len(),
            1,
            "peek must never return more than one item"
        );
    }
    // =====================================================================
    // constructor/getter: LoadedFonts
    // =====================================================================
    fn tf(hash: u64) -> TestFont {
        TestFont { hash }
    }
    #[test]
    fn loaded_fonts_new_is_empty_and_misses_every_lookup() {
        let lf: LoadedFonts<TestFont> = LoadedFonts::new();
        assert!(lf.is_empty());
        assert_eq!(lf.len(), 0);
        assert_eq!(lf.iter().count(), 0);
        assert!(lf.get(&FontId(0)).is_none());
        assert!(!lf.contains_key(&FontId(u128::MAX)));
        // Hash lookups at the numeric boundaries must miss, not panic.
        for h in [0_u64, 1, u64::MAX] {
            assert!(lf.get_by_hash(h).is_none());
            assert!(lf.get_font_id_by_hash(h).is_none());
            assert!(!lf.contains_hash(h));
        }
        // Default agrees with new().
        let d: LoadedFonts<TestFont> = LoadedFonts::default();
        assert!(d.is_empty());
    }
    #[test]
    fn loaded_fonts_insert_indexes_by_id_and_by_hash() {
        let mut lf: LoadedFonts<TestFont> = LoadedFonts::new();
        lf.insert(FontId(1), tf(0xDEAD));
        assert_eq!(lf.len(), 1);
        assert!(!lf.is_empty());
        assert!(lf.contains_key(&FontId(1)));
        assert!(lf.contains_hash(0xDEAD));
        assert_eq!(lf.get(&FontId(1)).map(TestFont::get_hash), Some(0xDEAD));
        assert_eq!(lf.get_by_hash(0xDEAD).map(TestFont::get_hash), Some(0xDEAD));
        assert_eq!(lf.get_font_id_by_hash(0xDEAD), Some(&FontId(1)));
        assert!(lf.get_by_hash(0).is_none());
    }
    #[test]
    fn loaded_fonts_zero_hash_is_a_valid_key_not_a_sentinel() {
        let mut lf: LoadedFonts<TestFont> = LoadedFonts::new();
        lf.insert(FontId(1), tf(0));
        assert!(lf.contains_hash(0), "hash 0 must be storable and findable");
        assert_eq!(lf.get_by_hash(0).map(TestFont::get_hash), Some(0));
    }
    #[test]
    fn loaded_fonts_two_ids_sharing_a_hash_keep_only_the_last_reverse_mapping() {
        let mut lf: LoadedFonts<TestFont> = LoadedFonts::new();
        lf.insert(FontId(1), tf(7));
        lf.insert(FontId(2), tf(7));
        assert_eq!(lf.len(), 2, "both fonts are stored by id");
        assert_eq!(
            lf.get_font_id_by_hash(7),
            Some(&FontId(2)),
            "the reverse index keeps only the LAST id for a colliding hash"
        );
    }
    #[test]
    fn loaded_fonts_replacing_a_font_id_leaves_a_stale_hash_mapping() {
        // FINDING (staleness, not a crash): `insert` never removes the OLD hash of a
        // replaced FontId, so the reverse index keeps pointing at that id forever.
        // A by-hash lookup for the evicted font therefore succeeds and returns the
        // WRONG font instead of None.
        let mut lf: LoadedFonts<TestFont> = LoadedFonts::new();
        lf.insert(FontId(1), tf(100));
        lf.insert(FontId(1), tf(200)); // same id, new hash
        assert_eq!(lf.len(), 1, "the font map correctly holds one entry");
        assert!(
            lf.contains_hash(100),
            "the old hash is still in the reverse index"
        );
        let stale = lf.get_by_hash(100).expect("stale mapping resolves");
        assert_eq!(
            stale.get_hash(),
            200,
            "looking up the OLD hash hands back the NEW font"
        );
    }
    #[test]
    fn loaded_fonts_from_iterator_matches_repeated_inserts() {
        let lf: LoadedFonts<TestFont> =
            vec![(FontId(1), tf(10)), (FontId(2), tf(20))].into_iter().collect();
        assert_eq!(lf.len(), 2);
        assert!(lf.contains_hash(10) && lf.contains_hash(20));
        assert_eq!(lf.iter().count(), 2);
    }
    // =====================================================================
    // constructor/other: FontManager + FontContext
    // =====================================================================
    fn manager() -> FontManager<TestFont> {
        FontManager::new(FcFontCache::default()).expect("FontManager::new must not fail")
    }
    #[test]
    fn font_manager_constructors_start_empty() {
        for m in [
            manager(),
            FontManager::from_shared(FcFontCache::default()).unwrap(),
            FontManager::from_arc_shared(
                FcFontCache::default(),
                Arc::new(Mutex::new(HashMap::new())),
            )
            .unwrap(),
        ] {
            assert!(m.get_font_chain_cache().is_empty());
            assert!(m.get_loaded_fonts().is_empty());
            assert!(m.get_loaded_font_ids().is_empty());
            assert!(m.registry.is_none());
            assert_eq!(m.last_resolved_font_stacks_sig, None);
            assert!(m.get_font_by_hash(0).is_none());
            assert!(m.get_embedded_font_by_hash(u64::MAX).is_none());
        }
    }
    #[test]
    fn font_manager_from_arc_shared_sees_writes_through_the_shared_pool() {
        let pool: Arc<Mutex<HashMap<FontId, TestFont>>> = Arc::new(Mutex::new(HashMap::new()));
        let a = FontManager::from_arc_shared(FcFontCache::default(), pool.clone()).unwrap();
        let b = FontManager::from_arc_shared(FcFontCache::default(), pool).unwrap();
        assert!(a.insert_font(FontId(1), tf(5)).is_none(), "no previous font");
        assert_eq!(
            b.get_loaded_fonts().len(),
            1,
            "the second manager must observe the first's insert"
        );
        assert_eq!(b.get_font_by_hash(5).map(|f| f.get_hash()), Some(5));
        // shared_parsed_fonts hands back the same Arc.
        assert!(Arc::ptr_eq(&a.shared_parsed_fonts(), &b.shared_parsed_fonts()));
    }
    #[test]
    fn font_manager_insert_font_returns_the_replaced_font() {
        let m = manager();
        assert!(m.insert_font(FontId(1), tf(1)).is_none());
        let old = m.insert_font(FontId(1), tf(2)).expect("must return the old font");
        assert_eq!(old.get_hash(), 1);
        assert_eq!(m.get_loaded_fonts().len(), 1);
    }
    #[test]
    fn font_manager_insert_fonts_and_remove_font() {
        let m = manager();
        m.insert_fonts(vec![(FontId(1), tf(1)), (FontId(2), tf(2))]);
        assert_eq!(m.get_loaded_font_ids().len(), 2);
        assert_eq!(m.remove_font(&FontId(1)).map(|f| f.get_hash()), Some(1));
        assert!(m.remove_font(&FontId(1)).is_none(), "double-remove is a no-op");
        assert!(
            m.remove_font(&FontId(u128::MAX)).is_none(),
            "removing an unknown id must not panic"
        );
        assert_eq!(m.get_loaded_fonts().len(), 1);
        // Inserting an empty iterator is a no-op.
        m.insert_fonts(Vec::new());
        assert_eq!(m.get_loaded_fonts().len(), 1);
    }
    #[test]
    fn font_manager_get_font_by_hash_scans_linearly_and_misses_cleanly() {
        let m = manager();
        m.insert_fonts(vec![(FontId(1), tf(11)), (FontId(2), tf(22))]);
        assert_eq!(m.get_font_by_hash(22).map(|f| f.get_hash()), Some(22));
        assert!(m.get_font_by_hash(33).is_none());
        assert!(m.get_font_by_hash(u64::MAX).is_none());
        assert!(m.get_font_by_hash(0).is_none());
    }
    #[test]
    fn font_manager_chain_cache_set_merge_and_signature() {
        let mut m = manager();
        assert!(m.get_font_chain_cache().is_empty());
        // set_font_chain_cache_with_sig records the signature...
        m.set_font_chain_cache_with_sig(HashMap::new(), Some(42));
        assert_eq!(m.last_resolved_font_stacks_sig, Some(42));
        // ...and the single-arg setter clears it again.
        m.set_font_chain_cache(HashMap::new());
        assert_eq!(
            m.last_resolved_font_stacks_sig, None,
            "a signature-less set must invalidate the recorded signature"
        );
        // merge on an empty cache is a no-op that does not panic.
        m.merge_font_chain_cache(HashMap::new());
        assert!(m.get_font_chain_cache().is_empty());
    }
    #[test]
    fn font_manager_garbage_collect_evicts_everything_not_in_the_keep_set() {
        let mut m = manager();
        m.insert_fonts(vec![
            (FontId(1), tf(1)),
            (FontId(2), tf(2)),
            (FontId(3), tf(3)),
        ]);
        let mut keep = HashSet::new();
        keep.insert(FontId(2));
        let evicted = m.garbage_collect_fonts(&keep, &HashSet::new());
        assert_eq!(evicted, 2);
        assert_eq!(m.get_loaded_font_ids(), keep);
        // GC-ing again evicts nothing (saturating_sub must not underflow).
        assert_eq!(m.garbage_collect_fonts(&keep, &HashSet::new()), 0);
        // An empty keep-set flushes the pool entirely.
        assert_eq!(m.garbage_collect_fonts(&HashSet::new(), &HashSet::new()), 1);
        assert!(m.get_loaded_fonts().is_empty());
        // ...and a GC on an already-empty pool is still 0, not a panic.
        assert_eq!(m.garbage_collect_fonts(&HashSet::new(), &HashSet::new()), 0);
    }
    #[test]
    fn font_manager_load_missing_for_chains_with_no_chains_loads_nothing() {
        use crate::solver3::getters::ResolvedFontChains;
        let m = manager();
        let empty = ResolvedFontChains {
            chains: HashMap::new(),
            ..Default::default()
        };
        let failed = m.load_missing_for_chains(
            &empty,
            |_bytes, _idx| -> Result<TestFont, LayoutError> {
                panic!("the loader must never be invoked when there is nothing to load")
            },
        );
        assert!(failed.is_empty());
        assert!(m.get_loaded_fonts().is_empty());
    }
    #[test]
    fn font_context_from_fc_cache_starts_empty_and_converts_to_a_manager() {
        let ctx = FontContext::from_fc_cache(FcFontCache::default());
        assert!(ctx.font_chain_cache.is_empty());
        assert!(ctx.embedded_fonts.is_empty());
        assert!(ctx.font_hash_to_families.is_empty());
        assert!(ctx.registry.is_none());
        assert!(ctx.parsed_fonts.lock().unwrap().is_empty());
        // Warming an empty chain set must be a no-op (and must not hit the disk).
        ctx.load_fonts_for_chains();
        assert!(ctx.parsed_fonts.lock().unwrap().is_empty());
        let mgr = ctx.to_font_manager();
        assert!(mgr.get_font_chain_cache().is_empty());
        assert!(mgr.registry.is_none());
        assert_eq!(mgr.last_resolved_font_stacks_sig, None);
        assert!(
            Arc::ptr_eq(&mgr.parsed_fonts, &ctx.parsed_fonts),
            "the manager must share (not copy) the parsed-font pool"
        );
    }
    // =====================================================================
    // other: create_logical_items / bidi entry points
    // =====================================================================
    #[test]
    fn create_logical_items_on_empty_and_whitespace_only_content() {
        let mut dbg = None;
        assert!(create_logical_items(&[], &[], &mut dbg).is_empty());
        // An empty text run is skipped entirely.
        let empty_run = [text_content("", style())];
        assert!(create_logical_items(&empty_run, &[], &mut dbg).is_empty());
        // Whitespace-only text still produces items.
        let ws = [text_content("   \t\n", style())];
        assert!(!create_logical_items(&ws, &[], &mut dbg).is_empty());
    }
    #[test]
    fn create_logical_items_handles_multibyte_and_astral_text_without_panicking() {
        let mut dbg = None;
        for s in ["\u{1F600}", "é\u{0301}", "中文", "a\u{200B}b", "\u{FEFF}"] {
            let content = [text_content(s, style())];
            let items = create_logical_items(&content, &[], &mut dbg);
            assert!(!items.is_empty(), "{s:?} produced no logical items");
        }
    }
    #[test]
    fn create_logical_items_on_a_long_run_does_not_hang() {
        let mut dbg = None;
        let long = "a".repeat(100_000);
        let content = [text_content(&long, style())];
        let items = create_logical_items(&content, &[], &mut dbg);
        assert!(!items.is_empty());
    }
    #[test]
    fn create_logical_items_debug_messages_are_recorded_when_requested() {
        let mut dbg = Some(Vec::new());
        let content = [text_content("hi", style())];
        let _ = create_logical_items(&content, &[], &mut dbg);
        assert!(
            !dbg.expect("Some(..) in → Some(..) out").is_empty(),
            "the debug sink must be populated when it is Some"
        );
    }
    #[test]
    fn get_base_direction_from_logical_defaults_to_ltr_on_empty_input() {
        assert_eq!(get_base_direction_from_logical(&[]), BidiDirection::Ltr);
        let mut dbg = None;
        let ltr = create_logical_items(&[text_content("hello", style())], &[], &mut dbg);
        assert_eq!(get_base_direction_from_logical(&ltr), BidiDirection::Ltr);
        // A Hebrew run must be detected as RTL.
        let rtl = create_logical_items(&[text_content("\u{05D0}\u{05D1}", style())], &[], &mut dbg);
        assert_eq!(get_base_direction_from_logical(&rtl), BidiDirection::Rtl);
    }
    #[test]
    fn reorder_logical_items_on_empty_input_is_ok_and_empty() {
        let mut dbg = None;
        let out = reorder_logical_items(&[], BidiDirection::Ltr, UnicodeBidi::Normal, &mut dbg)
            .expect("reordering nothing must succeed");
        assert!(out.is_empty());
    }
    #[test]
    fn reorder_logical_items_preserves_content_for_pure_ltr_text() {
        let mut dbg = None;
        let logical = create_logical_items(&[text_content("abc", style())], &[], &mut dbg);
        let visual = reorder_logical_items(
            &logical,
            BidiDirection::Ltr,
            UnicodeBidi::Normal,
            &mut dbg,
        )
        .expect("LTR reordering must succeed");
        let joined: String = visual.iter().map(|v| v.text.as_str()).collect();
        assert_eq!(joined, "abc", "pure LTR text must survive bidi unchanged");
        assert!(visual.iter().all(|v| !v.bidi_level.is_rtl()));
    }
    // =====================================================================
    // other: hyphenation stubs (feature-gated)
    // =====================================================================
    #[cfg(not(feature = "text_layout_hyphenation"))]
    #[test]
    fn stub_hyphenate_never_reports_a_break() {
        let s = Standard;
        assert!(s.hyphenate("").breaks.is_empty());
        assert!(s.hyphenate("hyphenation").breaks.is_empty());
        assert!(s.hyphenate(&"a".repeat(10_000)).breaks.is_empty());
        assert!(s.hyphenate("\u{1F600}\u{0301}").breaks.is_empty());
    }
    #[cfg(not(feature = "text_layout_hyphenation"))]
    #[test]
    fn stub_get_hyphenator_always_errors() {
        assert!(matches!(
            get_hyphenator(Language::EnglishUS),
            Err(LayoutError::HyphenationError(_))
        ));
    }
    #[cfg(feature = "text_layout_hyphenation")]
    #[test]
    fn get_hyphenator_loads_an_embedded_language_and_hyphenates() {
        let h =
            get_hyphenator(HyphenationLanguage::EnglishUS).expect("en-US dictionaries are embedded");
        // Empty / single-char words have no interior break points.
        let empty = h.hyphenate("");
        assert!(empty.breaks.is_empty(), "an empty word must not panic");
        let one = h.hyphenate("a");
        assert!(one.breaks.is_empty());
        // Every reported break must be a valid INTERIOR char boundary.
        let word = "hyphenation";
        let opps = h.hyphenate(word);
        for &b in &opps.breaks {
            assert!(b > 0 && b < word.len(), "break {b} is outside {word:?}");
            assert!(word.is_char_boundary(b), "break {b} splits a char");
        }
        // Astral + combining input must not panic.
        let weird = h.hyphenate("\u{1F600}\u{0301}");
        assert!(weird.breaks.len() < 8, "no runaway break list");
    }
    // =====================================================================
    // FONT GC: the eviction-resurrection contract
    // =====================================================================
    /// THE LAW the azwriter textless-window bug broke: the GC's safety
    /// argument ("eviction is always safe — the next layout re-loads what it
    /// needs") is FALSE when a shaping cache serves cached glyphs without
    /// re-loading, so a face whose hash is still referenced by live shaped
    /// output must remain RESOLVABLE after the GC condemned it. Verified
    /// live: the exact hash the renderer reported as unresolvable
    /// (14253617078591575638) appeared in the GC's eviction trace.
    #[test]
    fn gc_condemned_face_still_resolves_by_hash_and_resurrects() {
        let mut m: FontManager<TestFont> =
            FontManager::new(FcFontCache::default()).expect("FontManager::new");
        let id = FontId(7);
        m.insert_font(id, tf(0xF00D));
        // GC with a keep-set that does NOT contain the face — the state the
        // azwriter run produced (UI chains resolved, fallback face outside).
        let keep_ids = std::collections::HashSet::new();
        let keep_hashes = std::collections::HashSet::new();
        let _ = m.garbage_collect_fonts(&keep_ids, &keep_hashes);
        // A display list / shaping cache still carries hash 0xF00D. It MUST
        // resolve — a dropped face here is user-visible text loss.
        let resurrected = m.get_font_by_hash(0xF00D);
        assert!(
            resurrected.is_some(),
            "a GC pass must not make a still-referenced font hash unresolvable"
        );
        // Resurrection is real: the face is back in the live pool, so a
        // SECOND resolution does not depend on the condemned window.
        let _ = m.garbage_collect_fonts(
            &[id].into_iter().collect(),
            &keep_hashes,
        );
        assert!(m.get_font_by_hash(0xF00D).is_some(), "resurrected face is live again");
    }
    /// The NEGATIVE CONTROL guarding the leak fix the GC exists for: a face
    /// whose hash nobody resolves for TWO consecutive GC generations is
    /// genuinely dropped. Without this, "keep everything forever" would also
    /// pass the law above.
    #[test]
    fn gc_drops_a_face_no_one_referenced_for_two_generations() {
        let mut m: FontManager<TestFont> =
            FontManager::new(FcFontCache::default()).expect("FontManager::new");
        m.insert_font(FontId(9), tf(0xDEAD));
        let keep_ids = std::collections::HashSet::new();
        let keep_hashes = std::collections::HashSet::new();
        // Three GC passes, never resolved in between: condemned -> aged -> gone.
        let _ = m.garbage_collect_fonts(&keep_ids, &keep_hashes);
        let _ = m.garbage_collect_fonts(&keep_ids, &keep_hashes);
        let _ = m.garbage_collect_fonts(&keep_ids, &keep_hashes);
        assert!(
            m.get_font_by_hash(0xDEAD).is_none(),
            "an unreferenced face must eventually be dropped — the GC exists \
             because font-cycling apps leaked every font they ever touched"
        );
    }
}