Skip to main content

azul_layout/text3/
cache.rs

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
18use 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
30pub use azul_core::selection::{ContentIndex, GraphemeClusterId};
31use azul_core::{
32    dom::NodeId,
33    geom::{LogicalPosition, LogicalRect, LogicalSize},
34    resources::ImageRef,
35    selection::{CursorAffinity, SelectionRange, TextCursor},
36    ui_solver::GlyphInstance,
37};
38use azul_css::{
39    corety::LayoutDebugMessage, props::basic::ColorU, props::style::StyleBackgroundContent,
40};
41#[cfg(feature = "text_layout_hyphenation")]
42use hyphenation::{Hyphenator, Language as HyphenationLanguage, Load, Standard};
43use rust_fontconfig::{FcFontCache, FcPattern, FcStretch, FcWeight, FontId, PatternMatch, UnicodeRange};
44use smallvec::{smallvec, SmallVec};
45use unicode_bidi::{BidiInfo, Level, TextSource};
46use 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.
52const FALLBACK_ASCENT_RATIO: f32 = 0.8;
53const 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`)
59const DEFAULT_STRUT_ASCENT: f32 = 12.8;
60/// Default strut descent: `FALLBACK_DESCENT_RATIO` * (16px * `DEFAULT_LINE_HEIGHT_FACTOR`)
61const DEFAULT_STRUT_DESCENT: f32 = 3.2;
62
63/// Default x-height approximation: 0.5 * 16px (CSS spec fallback).
64const DEFAULT_X_HEIGHT: f32 = 8.0;
65/// Default ch-width (advance of '0'): 0.5 * 16px.
66const DEFAULT_CH_WIDTH: f32 = 8.0;
67
68/// Approximate space character width as a fraction of `font_size`.
69const SPACE_WIDTH_RATIO: f32 = 0.5;
70
71/// CSS subscript baseline offset as fraction of line ascent (CSS Inline §3).
72const SUBSCRIPT_OFFSET_RATIO: f32 = 0.3;
73/// CSS superscript baseline offset as fraction of line ascent (CSS Inline §3).
74const SUPERSCRIPT_OFFSET_RATIO: f32 = 0.4;
75
76/// Ruby annotation font size relative to the base, per the CSS UA stylesheet
77/// (`rt { font-size: 50% }`). Used to reserve placeholder width for the
78/// annotation so a long annotation is not clipped by a short base.
79const RUBY_ANNOTATION_FONT_SCALE: f32 = 0.5;
80
81/// Computes the reserved box size for a ruby pair (CSS Ruby Layout §3): the inline-size is
82/// the wider of the base and annotation runs (the narrower is centered over the wider), and
83/// the block-size stacks the annotation line above the base line so the base reserves
84/// vertical space for the annotation. Both inputs are REAL shaped advances / resolved line
85/// heights — no magic per-character ratio.
86fn ruby_reserved_box(
87    base_width: f32,
88    annotation_width: f32,
89    base_line_height: f32,
90    annotation_line_height: f32,
91) -> (f32, f32) {
92    (
93        base_width.max(annotation_width),
94        base_line_height + annotation_line_height,
95    )
96}
97
98/// Glyph storage for a single shaped cluster.
99///
100/// Inline one glyph (the
101/// common case for Latin text), spill to heap for ligatures / combining
102/// marks / multi-glyph clusters. The `union` feature of smallvec packs
103/// the inline buffer and the heap pointer into the same bytes, so sizeof
104/// stays `sizeof(ShapedGlyph) + 2*usize` regardless of inline/heap state.
105pub type ShapedGlyphVec = SmallVec<[ShapedGlyph; 1]>;
106
107/// CSS `line-height` value.
108///
109/// `Normal` defers resolution to the point where font metrics are available,
110/// computing `(ascent + |descent| + lineGap) / upem * fontSize`.
111/// `Px` is an already-resolved pixel value from an explicit CSS declaration
112/// (e.g. `line-height: 1.5` → `Px(fontSize * 1.5)`).
113#[derive(Debug, Clone, Copy)]
114#[derive(Default)]
115pub enum LineHeight {
116    /// `line-height: normal` — resolve from font metrics at layout time
117    #[default]
118    Normal,
119    /// Pre-resolved pixel value (from CSS `line-height: <number|length|percentage>`)
120    Px(f32),
121}
122
123
124impl LineHeight {
125    /// Resolve to a pixel value, using font metrics when `Normal`.
126    ///
127    /// `ascent`, `descent` (negative in OpenType convention), `line_gap` are in font units.
128    /// `font_size_px` and `units_per_em` are used to scale.
129    #[must_use] pub fn resolve(&self, font_size_px: f32, ascent: f32, descent: f32, line_gap: f32, units_per_em: u16) -> f32 {
130        match self {
131            Self::Px(px) => *px,
132            Self::Normal => {
133                if units_per_em == 0 {
134                    return font_size_px * 1.2; // fallback
135                }
136                let scale = font_size_px / f32::from(units_per_em);
137                (ascent - descent + line_gap) * scale
138            }
139        }
140    }
141
142    /// Resolve using a `LayoutFontMetrics` struct for convenience.
143    #[must_use] pub fn resolve_with_metrics(&self, font_size_px: f32, metrics: &LayoutFontMetrics) -> f32 {
144        self.resolve(font_size_px, metrics.ascent, metrics.descent, metrics.line_gap, metrics.units_per_em)
145    }
146}
147
148impl PartialEq for LineHeight {
149    fn eq(&self, other: &Self) -> bool {
150        match (self, other) {
151            (Self::Normal, Self::Normal) => true,
152            (Self::Px(a), Self::Px(b)) => a.to_bits() == b.to_bits(),
153            _ => false,
154        }
155    }
156}
157
158impl Eq for LineHeight {}
159
160impl Hash for LineHeight {
161    fn hash<H: Hasher>(&self, state: &mut H) {
162        discriminant(self).hash(state);
163        if let Self::Px(v) = self {
164            v.to_bits().hash(state);
165        }
166    }
167}
168
169// Stub type when hyphenation is disabled
170#[cfg(not(feature = "text_layout_hyphenation"))]
171pub struct Standard;
172
173#[cfg(not(feature = "text_layout_hyphenation"))]
174impl Standard {
175    /// Stub hyphenate method that returns no breaks
176    pub fn hyphenate<'a>(&'a self, _word: &'a str) -> StubHyphenationBreaks {
177        StubHyphenationBreaks { breaks: Vec::new() }
178    }
179}
180
181/// Result of hyphenation (stub when feature is disabled)
182#[cfg(not(feature = "text_layout_hyphenation"))]
183pub struct StubHyphenationBreaks {
184    pub breaks: Vec<usize>,
185}
186
187// Always import Language from script module
188use crate::text3::script::{script_to_language, Language, Script};
189
190/// Available space for layout, similar to Taffy's `AvailableSpace`.
191///
192/// This type explicitly represents the three possible states for available space:
193///
194/// - `Definite(f32)`: A specific pixel width is available
195/// - `MinContent`: Layout should use minimum content width (shrink-wrap)
196/// - `MaxContent`: Layout should use maximum content width (no line breaks unless necessary)
197///
198/// This is critical for proper handling of intrinsic sizing in Flexbox/Grid
199/// where the available space may be indefinite during the measure phase.
200#[derive(Debug, Clone, Copy, PartialEq)]
201pub enum AvailableSpace {
202    /// A specific amount of space is available (in pixels).
203    /// Must be >= 0.  A value of 0.0 means "genuinely zero-width container"
204    /// (e.g. `width: 0px`), NOT "unresolved".
205    Definite(f32),
206    /// The node should be laid out under a min-content constraint
207    MinContent,
208    /// The node should be laid out under a max-content constraint.
209    /// This is the correct default: "lay out to natural width, no constraint".
210    MaxContent,
211}
212
213impl Default for AvailableSpace {
214    /// Default is `MaxContent` — the absence of a width constraint.
215    /// Never `Definite(0.0)`, which would make every word overflow.
216    fn default() -> Self {
217        Self::MaxContent
218    }
219}
220
221impl AvailableSpace {
222    /// Returns true if this is a definite (finite, known) amount of space
223    #[must_use] pub const fn is_definite(&self) -> bool {
224        matches!(self, Self::Definite(_))
225    }
226
227    /// Returns true if this is an indefinite (min-content or max-content) constraint
228    #[must_use] pub const fn is_indefinite(&self) -> bool {
229        !self.is_definite()
230    }
231
232    /// Returns the definite value if available, or a fallback for indefinite constraints
233    #[must_use] pub const fn unwrap_or(self, fallback: f32) -> f32 {
234        match self {
235            Self::Definite(v) => v,
236            _ => fallback,
237        }
238    }
239
240    /// Returns the definite value, or a large value for both min-content and max-content.
241    /// 
242    /// For intrinsic sizing, we use a large value to let text lay out fully,
243    /// then measure the result. The distinction between min/max-content is handled
244    /// by the line breaking algorithm, not by constraining the available width.
245    #[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
246    #[must_use] pub fn to_f32_for_layout(self) -> f32 {
247        match self {
248            Self::Definite(v) => v,
249            Self::MinContent => f32::MAX / 2.0,
250            Self::MaxContent => f32::MAX / 2.0,
251        }
252    }
253
254    /// Create from an f32 value, recognizing special sentinel values.
255    ///
256    /// This function provides backwards compatibility with code that uses f32 for constraints:
257    /// - `f32::INFINITY` or `f32::MAX` → `MaxContent` (no line wrapping)
258    /// - `0.0` → `MinContent` (maximum line wrapping, return longest word width)
259    /// - Other values → `Definite(value)`
260    ///
261    /// Note: Using sentinel values like 0.0 for `MinContent` is fragile. Prefer using
262    /// `AvailableSpace::MinContent` directly when possible.
263    #[must_use] pub fn from_f32(value: f32) -> Self {
264        if value.is_infinite() || value >= f32::MAX / 2.0 {
265            // Treat very large values (including f32::MAX) as MaxContent
266            Self::MaxContent
267        } else if value <= 0.0 {
268            // Treat zero or negative as MinContent (shrink-wrap)
269            Self::MinContent
270        } else {
271            Self::Definite(value)
272        }
273    }
274}
275
276impl Hash for AvailableSpace {
277    fn hash<H: Hasher>(&self, state: &mut H) {
278        discriminant(self).hash(state);
279        if let Self::Definite(v) = self {
280            // Hash the full f32 bit pattern, NOT the integer-rounded value. The
281            // derived `PartialEq` compares `Definite` widths exactly, so rounding
282            // here both (a) broke sub-pixel precision — a 100.1px vs 100.4px
283            // constraint can wrap lines differently yet collided in the same hash
284            // bucket — and (b) was inconsistent with the exact equality used as the
285            // cache key. `-0.0` is normalized to `+0.0` so the `+0.0 == -0.0`
286            // PartialEq pair still hashes identically (Hash/Eq contract).
287            let normalized = if *v == 0.0 { 0.0f32 } else { *v };
288            normalized.to_bits().hash(state);
289        }
290    }
291}
292
293// Re-export traits for backwards compatibility
294pub use crate::font_traits::{ParsedFontTrait, ShallowClone};
295
296// --- Core Data Structures for the New Architecture ---
297
298/// Key for caching font chains - based only on CSS properties, not text content
299#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
300pub struct FontChainKey {
301    pub font_families: Vec<String>,
302    pub weight: FcWeight,
303    pub italic: bool,
304    pub oblique: bool,
305}
306
307/// Either a `FontChainKey` (resolved via fontconfig) or a direct `FontRef` hash.
308/// 
309/// This enum cleanly separates:
310/// - `Chain`: Fonts resolved through fontconfig with fallback support
311/// - `Ref`: Direct `FontRef` that bypasses fontconfig entirely (e.g., embedded icon fonts)
312#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
313pub enum FontChainKeyOrRef {
314    /// Regular font chain resolved via fontconfig
315    Chain(FontChainKey),
316    /// Direct `FontRef` identified by pointer address (covers entire Unicode range, no fallbacks)
317    Ref(usize),
318}
319
320impl FontChainKeyOrRef {
321    /// Create from a `FontStack` enum
322    #[must_use] pub fn from_font_stack(font_stack: &FontStack) -> Self {
323        match font_stack {
324            FontStack::Stack(selectors) => Self::Chain(FontChainKey::from_selectors(selectors)),
325            FontStack::Ref(font_ref) => Self::Ref(font_ref.parsed as usize),
326        }
327    }
328    
329    /// Returns true if this is a direct `FontRef`
330    #[must_use] pub const fn is_ref(&self) -> bool {
331        matches!(self, Self::Ref(_))
332    }
333    
334    /// Returns the `FontRef` pointer if this is a Ref variant
335    #[must_use] pub const fn as_ref_ptr(&self) -> Option<usize> {
336        match self {
337            Self::Ref(ptr) => Some(*ptr),
338            Self::Chain(_) => None,
339        }
340    }
341    
342    /// Returns the `FontChainKey` if this is a Chain variant
343    #[must_use] pub const fn as_chain(&self) -> Option<&FontChainKey> {
344        match self {
345            Self::Chain(key) => Some(key),
346            Self::Ref(_) => None,
347        }
348    }
349}
350
351impl FontChainKey {
352    /// Create a `FontChainKey` from a slice of font selectors
353    #[must_use] pub fn from_selectors(font_stack: &[FontSelector]) -> Self {
354        // (2026-06-10) FIRST-WINS DEDUP: cascaded font stacks can carry duplicate
355        // families (e.g. [serif, sans-serif, serif, monospace] when the UA fallback
356        // list is appended to a stack already naming serif). The pre-resolve
357        // collector dedupes its stacks, so without deduping HERE the shaping-time
358        // key never matched the stored key (the g121/g122 chain-lookup misses).
359        // This is THE canonical FontChainKey constructor — every key-build site
360        // must go through it so lookups match by construction.
361        let mut font_families: Vec<String> = Vec::new();
362        for sel in font_stack {
363            if sel.family.is_empty() || font_families.contains(&sel.family) {
364                continue;
365            }
366            font_families.push(sel.family.clone());
367        }
368
369        let font_families = if font_families.is_empty() {
370            vec!["serif".to_string()]
371        } else {
372            font_families
373        };
374
375        let weight = font_stack
376            .first()
377            .map_or(FcWeight::Normal, |s| s.weight);
378        let is_italic = font_stack
379            .first()
380            .is_some_and(|s| s.style == FontStyle::Italic);
381        let is_oblique = font_stack
382            .first()
383            .is_some_and(|s| s.style == FontStyle::Oblique);
384
385        Self {
386            font_families,
387            weight,
388            italic: is_italic,
389            oblique: is_oblique,
390        }
391    }
392}
393
394/// A map of pre-loaded fonts, keyed by `FontId` (from rust-fontconfig)
395///
396/// This is passed to the shaper - no font loading happens during shaping
397/// The fonts are loaded BEFORE layout based on the font chains and text content.
398///
399/// Provides both `FontId` and hash-based lookup for efficient glyph operations.
400#[derive(Debug, Clone)]
401pub struct LoadedFonts<T> {
402    /// Primary storage: `FontId` -> Font
403    pub fonts: HashMap<FontId, T>,
404    /// Reverse index: `font_hash` -> `FontId` for fast hash-based lookups
405    hash_to_id: HashMap<u64, FontId>,
406}
407
408impl<T: ParsedFontTrait> LoadedFonts<T> {
409    #[must_use] pub fn new() -> Self {
410        Self {
411            fonts: HashMap::new(),
412            hash_to_id: HashMap::new(),
413        }
414    }
415
416    /// Insert a font with its `FontId`
417    pub fn insert(&mut self, font_id: FontId, font: T) {
418        let hash = font.get_hash();
419        self.hash_to_id.insert(hash, font_id);
420        self.fonts.insert(font_id, font);
421    }
422
423    /// Get a font by `FontId`
424    #[must_use] pub fn get(&self, font_id: &FontId) -> Option<&T> {
425        self.fonts.get(font_id)
426    }
427
428    /// Get a font by its hash
429    #[must_use] pub fn get_by_hash(&self, hash: u64) -> Option<&T> {
430        self.hash_to_id.get(&hash).and_then(|id| self.fonts.get(id))
431    }
432
433    /// Get the `FontId` for a hash
434    #[must_use] pub fn get_font_id_by_hash(&self, hash: u64) -> Option<&FontId> {
435        self.hash_to_id.get(&hash)
436    }
437
438    /// Check if a `FontId` is present
439    #[must_use] pub fn contains_key(&self, font_id: &FontId) -> bool {
440        self.fonts.contains_key(font_id)
441    }
442
443    /// Check if a hash is present
444    #[must_use] pub fn contains_hash(&self, hash: u64) -> bool {
445        self.hash_to_id.contains_key(&hash)
446    }
447
448    /// Iterate over all fonts
449    pub fn iter(&self) -> impl Iterator<Item = (&FontId, &T)> {
450        self.fonts.iter()
451    }
452
453    /// Get the number of loaded fonts
454    #[must_use] pub fn len(&self) -> usize {
455        self.fonts.len()
456    }
457
458    /// Check if empty
459    #[must_use] pub fn is_empty(&self) -> bool {
460        self.fonts.is_empty()
461    }
462}
463
464impl<T: ParsedFontTrait> Default for LoadedFonts<T> {
465    fn default() -> Self {
466        Self::new()
467    }
468}
469
470impl<T: ParsedFontTrait> FromIterator<(FontId, T)> for LoadedFonts<T> {
471    fn from_iter<I: IntoIterator<Item = (FontId, T)>>(iter: I) -> Self {
472        let mut loaded = Self::new();
473        for (id, font) in iter {
474            loaded.insert(id, font);
475        }
476        loaded
477    }
478}
479
480/// Enum that wraps either a fontconfig-resolved font (T) or a direct `FontRef`.
481///
482/// This allows the shaping code to handle both fontconfig-resolved fonts
483/// and embedded fonts (`FontRef`) uniformly through the `ParsedFontTrait` interface.
484#[derive(Debug, Clone)]
485pub enum FontOrRef<T> {
486    /// A font loaded via fontconfig
487    Font(T),
488    /// A direct `FontRef` (embedded font, bypasses fontconfig)
489    Ref(azul_css::props::basic::FontRef),
490}
491
492impl<T: ParsedFontTrait> ShallowClone for FontOrRef<T> {
493    fn shallow_clone(&self) -> Self {
494        match self {
495            Self::Font(f) => Self::Font(f.shallow_clone()),
496            Self::Ref(r) => Self::Ref(r.clone()),
497        }
498    }
499}
500
501impl<T: ParsedFontTrait> ParsedFontTrait for FontOrRef<T> {
502    fn shape_text(
503        &self,
504        text: &str,
505        script: Script,
506        language: Language,
507        direction: BidiDirection,
508        style: &StyleProperties,
509    ) -> Result<Vec<Glyph>, LayoutError> {
510        match self {
511            Self::Font(f) => f.shape_text(text, script, language, direction, style),
512            Self::Ref(r) => r.shape_text(text, script, language, direction, style),
513        }
514    }
515
516    fn get_hash(&self) -> u64 {
517        match self {
518            Self::Font(f) => f.get_hash(),
519            Self::Ref(r) => r.get_hash(),
520        }
521    }
522
523    fn get_glyph_size(&self, glyph_id: u16, font_size: f32) -> Option<LogicalSize> {
524        match self {
525            Self::Font(f) => f.get_glyph_size(glyph_id, font_size),
526            Self::Ref(r) => r.get_glyph_size(glyph_id, font_size),
527        }
528    }
529
530    fn get_hyphen_glyph_and_advance(&self, font_size: f32) -> Option<(u16, f32)> {
531        match self {
532            Self::Font(f) => f.get_hyphen_glyph_and_advance(font_size),
533            Self::Ref(r) => r.get_hyphen_glyph_and_advance(font_size),
534        }
535    }
536
537    fn get_kashida_glyph_and_advance(&self, font_size: f32) -> Option<(u16, f32)> {
538        match self {
539            Self::Font(f) => f.get_kashida_glyph_and_advance(font_size),
540            Self::Ref(r) => r.get_kashida_glyph_and_advance(font_size),
541        }
542    }
543
544    fn has_glyph(&self, codepoint: u32) -> bool {
545        match self {
546            Self::Font(f) => f.has_glyph(codepoint),
547            Self::Ref(r) => r.has_glyph(codepoint),
548        }
549    }
550
551    fn get_vertical_metrics(&self, glyph_id: u16) -> Option<VerticalMetrics> {
552        match self {
553            Self::Font(f) => f.get_vertical_metrics(glyph_id),
554            Self::Ref(r) => r.get_vertical_metrics(glyph_id),
555        }
556    }
557
558    fn get_font_metrics(&self) -> LayoutFontMetrics {
559        match self {
560            Self::Font(f) => f.get_font_metrics(),
561            Self::Ref(r) => r.get_font_metrics(),
562        }
563    }
564
565    fn num_glyphs(&self) -> u16 {
566        match self {
567            Self::Font(f) => f.num_glyphs(),
568            Self::Ref(r) => r.num_glyphs(),
569        }
570    }
571
572    fn get_space_width(&self) -> Option<usize> {
573        match self {
574            Self::Font(f) => f.get_space_width(),
575            Self::Ref(r) => r.get_space_width(),
576        }
577    }
578}
579
580/// Bundles all font-related state that can be shared across layout passes.
581///
582/// Separates font concerns from layout/rendering state (`LayoutWindow`).
583/// Each test/render creates a fresh `LayoutWindow` from a shared `FontContext`,
584/// avoiding stale layout cache reuse while keeping parsed fonts warm.
585///
586/// Usage:
587/// ```ignore
588/// let ctx = FontContext::from_fc_cache(fc_cache);
589/// ctx.pre_resolve_chains(&styled_dom, &platform);
590/// ctx.load_fonts_for_chains();
591///
592/// // Per-test: create fresh LayoutWindow from context
593/// let mut window = LayoutWindow::from_font_context(&ctx)?;
594/// window.layout_and_generate_display_list(styled_dom, ...)?;
595/// ```
596#[derive(Debug, Clone)]
597pub struct FontContext {
598    /// The shared font cache. As of rust-fontconfig 4.1 this type is
599    /// itself backed by `Arc<RwLock<_>>`, so cloning is cheap and all
600    /// clones see builder-thread writes immediately — no more `Arc<T>`
601    /// wrapping is needed and no more stale-snapshot refresh dance.
602    pub fc_cache: FcFontCache,
603    pub parsed_fonts: Arc<Mutex<HashMap<FontId, azul_css::props::basic::FontRef>>>,
604    pub font_chain_cache: HashMap<FontChainKey, rust_fontconfig::FontFallbackChain>,
605    pub embedded_fonts: HashMap<u64, azul_css::props::basic::FontRef>,
606    /// Reverse map: `font_family_hash` → actual `StyleFontFamilyVec`.
607    /// Accumulated across DOMs for persistence. Copied to `FontManager` on `LayoutWindow` creation.
608    pub font_hash_to_families: HashMap<u64, azul_css::props::basic::font::StyleFontFamilyVec>,
609    /// Optional link back to the live `FcFontRegistry`. Present iff the
610    /// caller wants the scout-on-demand path
611    /// ([`rust_fontconfig::registry::FcFontRegistry::request_and_resolve_with_scripts`]),
612    /// which priority-bumps the builder for not-yet-parsed families
613    /// rather than falling back to the empty-snapshot response.
614    pub registry: Option<Arc<rust_fontconfig::registry::FcFontRegistry>>,
615}
616
617impl FontContext {
618    /// Create from an `FcFontCache`. Parsed fonts, font chains, and
619    /// embedded fonts start empty.
620    ///
621    /// The resulting `FontContext` has `registry = None`, so font
622    /// chain resolution only sees what's already in the cache. For
623    /// the scout-on-demand path, use [`FontContext::from_registry`]
624    /// instead, which keeps a handle to the registry so that chain
625    /// resolution can lazy-parse families the DOM needs.
626    #[must_use] pub fn from_fc_cache(fc_cache: FcFontCache) -> Self {
627        Self {
628            fc_cache,
629            parsed_fonts: Arc::new(Mutex::new(HashMap::new())),
630            font_chain_cache: HashMap::new(),
631            embedded_fonts: HashMap::new(),
632            font_hash_to_families: HashMap::new(),
633            registry: None,
634        }
635    }
636
637    /// Create from a live `FcFontRegistry`. The `fc_cache` field gets
638    /// a *shared* handle to the registry's cache (cheap `Arc::clone`
639    /// on the v4.1 shared-state cache) — writes by builder threads
640    /// show up immediately in every reader. Chain resolution goes
641    /// through
642    /// [`rust_fontconfig::registry::FcFontRegistry::request_and_resolve_with_scripts`]
643    /// which priority-bumps the builder for unparsed families and
644    /// waits for them. This is the "scout-on-demand" path: a
645    /// headless renderer can skip the eager common-stack parse and
646    /// pay only the per-family cost on first use, dropping peak RSS
647    /// by the common-stack metadata size (~15 MiB on macOS).
648    pub fn from_registry(
649        registry: Arc<rust_fontconfig::registry::FcFontRegistry>,
650    ) -> Self {
651        let fc_cache = registry.shared_cache();
652        Self {
653            fc_cache,
654            parsed_fonts: Arc::new(Mutex::new(HashMap::new())),
655            font_chain_cache: HashMap::new(),
656            embedded_fonts: HashMap::new(),
657            font_hash_to_families: HashMap::new(),
658            registry: Some(registry),
659        }
660    }
661
662    /// Pre-resolve font chains for a `StyledDom`'s CSS font stacks.
663    /// Call this before layout so text rendering doesn't skip glyphs.
664    ///
665    /// Unicode-fallback fonts are limited to the scripts actually
666    /// present in the document's text content — for an ASCII-only
667    /// page, this skips the ~300 MiB Arial-Unicode / CJK / Arabic
668    /// pull-in entirely. See
669    /// [`crate::solver3::getters::scripts_present_in_styled_dom`].
670    pub fn pre_resolve_chains_for_dom(
671        &mut self,
672        styled_dom: &azul_core::styled_dom::StyledDom,
673        platform: &azul_css::system::Platform,
674    ) {
675        use crate::solver3::getters::{
676            collect_font_stacks_from_styled_dom, collect_used_codepoints,
677            prune_chain_to_used_chars, resolve_font_chains, scripts_present_in_styled_dom,
678        };
679        let collected = collect_font_stacks_from_styled_dom(styled_dom, platform);
680        let scripts = scripts_present_in_styled_dom(styled_dom);
681        let mut chains = resolve_font_chains(&collected, &self.fc_cache, Some(&scripts));
682        // Coverage-based prune (matches `collect_and_resolve_font_chains_with_registration`).
683        let used_chars = collect_used_codepoints(styled_dom);
684        for chain in chains.chains.values_mut() {
685            prune_chain_to_used_chars(chain, &used_chars);
686        }
687        // WEB-LIFT last resort (after prune, so it survives — prune drops the registered
688        // fallback because its cmap isn't parsed yet): if a chain ended up with no fonts,
689        // append the first registered font so load_missing_for_chains finds it and text
690        // shapes instead of measuring 0. (Done in azul-layout, NOT rust-fontconfig, so the
691        // lift-fragile with_memory_fonts isn't re-codegen'd into a trapping shape.)
692        for chain in chains.chains.values_mut() {
693            let total = chain.css_fallbacks.iter().map(|g| g.fonts.len()).sum::<usize>()
694                + chain.unicode_fallbacks.len();
695            if total == 0 {
696                if let Some((pattern, id)) = self.fc_cache.list().first() {
697                    chain.unicode_fallbacks.push(rust_fontconfig::FontMatch {
698                        id: *id,
699                        unicode_ranges: pattern.unicode_ranges.clone(),
700                        fallbacks: Vec::new(),
701                    });
702                }
703            }
704        }
705        self.font_chain_cache = chains.into_fontconfig_chains();
706    }
707
708    /// Load parsed font bytes from disk for all fonts referenced in `font_chain_cache`.
709    ///
710    /// Thin wrapper that materialises a `ResolvedFontChains` from the
711    /// cached chain map and delegates the actual disk-load to the
712    /// shared `FontManager::load_missing_for_chains` helper, so the
713    /// "collect → diff → load → insert" sequence lives in exactly
714    /// one place. Failures are silently dropped here (the caller is
715    /// the warmup path which has no good place to log them); use
716    /// `FontManager::load_missing_for_chains` directly for diagnostics.
717    pub fn load_fonts_for_chains(&self) {
718        use crate::solver3::getters::ResolvedFontChains;
719        use crate::text3::default::PathLoader;
720
721        let chains_map: HashMap<FontChainKeyOrRef, _> = self
722            .font_chain_cache
723            .iter()
724            .map(|(k, v)| (FontChainKeyOrRef::Chain(k.clone()), v.clone()))
725            .collect();
726        let resolved = ResolvedFontChains {
727            chains: chains_map,
728            ..Default::default()
729        };
730
731        // Borrow our shared `parsed_fonts` Arc as a transient
732        // FontManager so we can use the helper. `from_arc_shared`
733        // returns a manager that mutates the same underlying pool.
734        let Ok(manager) = FontManager::<azul_css::props::basic::FontRef>::from_arc_shared(
735            self.fc_cache.clone(),
736            self.parsed_fonts.clone(),
737        ) else {
738            return;
739        };
740        let loader = PathLoader::new();
741        let _failed = manager
742            .load_missing_for_chains(&resolved, |bytes, idx| loader.load_font_shared(bytes, idx));
743    }
744
745    /// Convert into a `FontManager` with all data populated.
746    /// Carries the `registry` forward so the resulting manager also
747    /// has the scout-on-demand path available.
748    #[must_use] pub fn to_font_manager(&self) -> FontManager<azul_css::props::basic::FontRef> {
749        let mut fm = FontManager {
750            fc_cache: self.fc_cache.clone(),
751            parsed_fonts: self.parsed_fonts.clone(),
752            font_chain_cache: self.font_chain_cache.clone(),
753            embedded_fonts: Mutex::new(self.embedded_fonts.clone()),
754            font_hash_to_families: self.font_hash_to_families.clone(),
755            registry: self.registry.clone(),
756            last_resolved_font_stacks_sig: None,
757            memory_families: HashMap::new(),
758            vf_bake_cache: HashMap::new(),
759        };
760        // Idempotent: reuses the FontIds already in the shared fc_cache.
761        fm.register_builtin_mock_fonts();
762        fm
763    }
764}
765
766/// One in-memory face registered under a family name, with the style attributes
767/// needed to choose the right face for a CSS `(weight, italic/oblique)` query.
768///
769/// [`FontManager::register_named_font`] registers several faces under the *same*
770/// family name (e.g. `Helvetica` regular, bold, oblique). Keying
771/// [`FontManager::memory_families`] by family alone therefore collapsed them —
772/// the last registration won and `font-weight: bold` silently rendered in the
773/// regular face. Each face now records its own weight/style so resolution can
774/// pick the closest one (see `getters::split_memory_matches`).
775#[derive(Debug, Clone)]
776pub struct MemoryFace {
777    /// The `FontMatch` the resolver emits when this face is chosen.
778    pub font_match: rust_fontconfig::FontMatch,
779    /// OS/2 weight of this face (static fonts). For a variable font this is the
780    /// default-instance weight; `weight_axis` carries the selectable range.
781    pub weight: FcWeight,
782    /// `head`/OS-2 italic bit.
783    pub italic: bool,
784    /// OS/2 oblique bit.
785    pub oblique: bool,
786    /// OS/2 width class.
787    pub stretch: FcStretch,
788    /// For a variable font, the `wght` axis `(min, max)` in user units; `None`
789    /// for a static face. Lets a single VF satisfy any requested weight.
790    pub weight_axis: Option<(f32, f32)>,
791}
792
793/// Style attributes parsed from a font's bytes (OS/2 + `head`), used to index a
794/// registered face in [`FontManager::memory_families`].
795#[derive(Debug, Clone, Copy)]
796struct FaceStyle {
797    weight: FcWeight,
798    italic: bool,
799    oblique: bool,
800    stretch: FcStretch,
801    weight_axis: Option<(f32, f32)>,
802}
803
804impl Default for FaceStyle {
805    fn default() -> Self {
806        Self {
807            weight: FcWeight::Normal,
808            italic: false,
809            oblique: false,
810            stretch: FcStretch::Normal,
811            weight_axis: None,
812        }
813    }
814}
815
816/// Parse a face's weight / italic / oblique / stretch from its bytes via
817/// rust-fontconfig (which reads OS/2 `usWeightClass`/`usWidthClass` and the
818/// `head` italic bit). Falls back to upright Normal when the font can't be
819/// parsed, so registration never fails on a malformed face.
820fn parse_face_style(bytes: &[u8], family: &str) -> FaceStyle {
821    let Some(faces) = rust_fontconfig::FcParseFontBytes(bytes, family) else {
822        return FaceStyle::default();
823    };
824    let Some((pat, _)) = faces.into_iter().next() else {
825        return FaceStyle::default();
826    };
827    FaceStyle {
828        weight: pat.weight,
829        italic: pat.italic == PatternMatch::True,
830        oblique: pat.oblique == PatternMatch::True,
831        stretch: pat.stretch,
832        weight_axis: None,
833    }
834}
835
836#[derive(Debug)]
837pub struct FontManager<T> {
838    /// The font-path cache. `FcFontCache` in rust-fontconfig 4.1 is
839    /// already a shared handle internally (`Arc<RwLock<_>>`), so no
840    /// further `Arc<...>` wrapping is needed — clones are cheap and
841    /// all clones see builder writes instantly.
842    pub fc_cache: FcFontCache,
843    /// Holds the actual parsed font (usually with the font bytes attached).
844    /// Wrapped in Arc so multiple `FontManager` instances can share the same
845    /// pool of already-parsed fonts (avoids re-reading from disk).
846    pub parsed_fonts: Arc<Mutex<HashMap<FontId, T>>>,
847    // Cache for font chains - populated by resolve_all_font_chains() before layout
848    // This is read-only during layout - no locking needed for reads
849    pub font_chain_cache: HashMap<FontChainKey, rust_fontconfig::FontFallbackChain>,
850    /// Cache for direct `FontRefs` (embedded fonts like Material Icons)
851    /// These are fonts referenced via `FontStack::Ref` that bypass fontconfig
852    pub embedded_fonts: Mutex<HashMap<u64, azul_css::props::basic::FontRef>>,
853    /// Reverse map: `font_family_hash` → actual `StyleFontFamilyVec`.
854    /// Accumulated across DOMs. Used by font collection and text shaping to
855    /// resolve compact cache hashes without `get_property_slow`.
856    pub font_hash_to_families: HashMap<u64, azul_css::props::basic::font::StyleFontFamilyVec>,
857    /// Optional link back to the live `FcFontRegistry`. When present,
858    /// chain resolution uses
859    /// [`rust_fontconfig::registry::FcFontRegistry::request_and_resolve_with_scripts`]
860    /// which lazy-parses system fonts as the DOM requests them
861    /// (scout-on-demand). `None` falls back to querying whatever is
862    /// already in the shared cache.
863    pub registry: Option<Arc<rust_fontconfig::registry::FcFontRegistry>>,
864    /// `FxHash` of the `prev_font_hashes` slice at the moment the last
865    /// successful `collect_and_resolve_font_chains_with_registration`
866    /// call populated `font_chain_cache`. Lets repeated layouts of the
867    /// same DOM skip the ~1.5 ms (cold) / ~0.9 ms (warm) chain resolver
868    /// when the set of font-family hashes has not changed. Cleared
869    /// whenever `font_chain_cache` is explicitly emptied.
870    pub last_resolved_font_stacks_sig: Option<u64>,
871    /// Index of every font registered by FAMILY NAME into `fc_cache`'s
872    /// in-memory font table (bundled fonts, embedder fonts, the built-in
873    /// mock test fonts): normalized family name → the `FontMatch` the
874    /// resolver should emit for it.
875    ///
876    /// WHY THIS EXISTS (architectural, see `resolve_font_chains_fast`):
877    /// the fast chain resolver in rust-fontconfig 4.4
878    /// (`FcFontRegistry::request_fonts_fast`) resolves families purely
879    /// against `known_paths` — i.e. fonts that exist as FILES ON DISK.
880    /// In-memory fonts are invisible to it, so a family registered with
881    /// `FcFontCache::with_memory_fonts` could never be matched by name
882    /// on the production path (which always has a live registry): it
883    /// silently fell back to a system font. This index is consulted
884    /// FIRST, before the disk probe, so a memory-registered family wins
885    /// exactly as CSS says it should.
886    pub memory_families: HashMap<String, Vec<MemoryFace>>,
887    /// Baked static instances of variable fonts, keyed by a hash of the original
888    /// VF bytes. A variable font is expanded into one static face per weight
889    /// bucket (see `register_named_font`); this caches the minted faces so the
890    /// several spelling registrations of the same VF don't re-bake it.
891    vf_bake_cache: HashMap<u64, Vec<(FontId, FaceStyle)>>,
892}
893
894impl<T: ParsedFontTrait> FontManager<T> {
895    /// # Errors
896    ///
897    /// Returns a `LayoutError` if the font cache cannot be initialized.
898    pub fn new(fc_cache: FcFontCache) -> Result<Self, LayoutError> {
899        let mut fm = Self {
900            fc_cache,
901            parsed_fonts: Arc::new(Mutex::new(HashMap::new())),
902            font_chain_cache: HashMap::new(),
903            embedded_fonts: Mutex::new(HashMap::new()),
904            font_hash_to_families: HashMap::new(),
905            registry: None,
906            last_resolved_font_stacks_sig: None,
907            memory_families: HashMap::new(),
908            vf_bake_cache: HashMap::new(),
909        };
910        fm.register_builtin_mock_fonts();
911        Ok(fm)
912    }
913
914    /// Register a font by FAMILY NAME from raw bytes, as an in-memory font
915    /// in the shared `FcFontCache`.
916    ///
917    /// This is the ONE hook an embedder (or a test) uses to make a font
918    /// resolvable by `font-family: "<family>"`. It mints one `FontId` for
919    /// the font, inserts it into the fontconfig cache's memory-font table
920    /// (so `get_font_bytes` / `load_fonts_from_disk` find it with no
921    /// special-casing) and indexes it in [`Self::memory_families`] so the
922    /// fast chain resolver can match it by name.
923    ///
924    /// `coverage` are the codepoint ranges the font actually covers.
925    /// Passing the true ranges matters: `FontFallbackChain::resolve_char`
926    /// skips any font that reports no coverage, and a font claiming
927    /// coverage it doesn't have would render .notdef instead of falling
928    /// back.
929    ///
930    /// Returns the `FontId` the family now resolves to.
931    pub fn register_named_font(
932        &mut self,
933        family: &str,
934        bytes: &[u8],
935        coverage: Vec<UnicodeRange>,
936    ) -> FontId {
937        let norm = rust_fontconfig::utils::normalize_family_name(family);
938
939        // Variable fonts: expand into one STATIC instance per weight bucket so the
940        // ordinary static weight-selection path (see `split_memory_matches` /
941        // `pick_memory_face`) picks the right one, with NO changes to shaping,
942        // glyph decode, or PDF embedding — each baked instance is an ordinary
943        // static font. Falls through to the static path below if the font is not a
944        // bakeable variable font (baking failed / no glyf variations).
945        if let Some((min, def, max)) = crate::font::parsed::read_wght_axis(bytes, 0) {
946            if let Some(id) =
947                self.register_variable_instances(&norm, family, bytes, &coverage, min, def, max)
948            {
949                return id;
950            }
951        }
952
953        // The weight/style come from the font BYTES (OS/2), not the registration
954        // name: registering `Helvetica-Bold.ttf` under either "Helvetica-Bold" or
955        // its internal family "Helvetica" must both yield weight=Bold. A font can
956        // (and Helvetica does) reuse the same family name across faces, so faces
957        // are distinguished by (weight, italic, oblique), never by name alone.
958        let style = parse_face_style(bytes, family);
959
960        // IDEMPOTENT: several `FontManager`s (one per window, plus the PDF
961        // writer) share one `FcFontCache`. Registering the same face twice would
962        // mint a second `FontId` for the same bytes, orphan the first in the
963        // cache's metadata table and make the id non-deterministic. Reuse an
964        // existing memory font ONLY when family AND (weight, italic, oblique)
965        // match — a bold face must not be deduplicated against the regular one.
966        let mut existing: Vec<(FontId, Vec<UnicodeRange>)> = Vec::new();
967        self.fc_cache.for_each_pattern(|pattern, id| {
968            let fam_hit = pattern
969                .family
970                .as_deref()
971                .is_some_and(|f| rust_fontconfig::utils::normalize_family_name(f) == norm);
972            let style_hit = pattern.weight == style.weight
973                && (pattern.italic == PatternMatch::True) == style.italic
974                && (pattern.oblique == PatternMatch::True) == style.oblique;
975            if fam_hit && style_hit {
976                existing.push((*id, pattern.unicode_ranges.clone()));
977            }
978        });
979        let id = if let Some((id, ranges)) = existing
980            .into_iter()
981            .find(|(id, _)| self.fc_cache.is_memory_font(id))
982        {
983            self.index_memory_face(&norm, id, ranges, &style);
984            id
985        } else {
986            let pattern = rust_fontconfig::FcPattern {
987                name: Some(family.to_string()),
988                family: Some(family.to_string()),
989                italic: if style.italic { PatternMatch::True } else { PatternMatch::False },
990                oblique: if style.oblique { PatternMatch::True } else { PatternMatch::False },
991                bold: if style.weight >= FcWeight::Bold { PatternMatch::True } else { PatternMatch::False },
992                weight: style.weight,
993                stretch: style.stretch,
994                unicode_ranges: coverage.clone(),
995                ..Default::default()
996            };
997            let id = FontId::new();
998            self.fc_cache.with_memory_font_with_id(
999                id,
1000                pattern,
1001                rust_fontconfig::FcFont {
1002                    bytes: bytes.to_vec(),
1003                    font_index: 0,
1004                    id: family.to_string(),
1005                },
1006            );
1007            self.index_memory_face(&norm, id, coverage, &style);
1008            id
1009        };
1010        id
1011    }
1012
1013    /// Append (or refresh) a face in [`Self::memory_families`] under `norm`,
1014    /// de-duplicating by `FontId` so repeated registrations don't grow the list.
1015    fn index_memory_face(
1016        &mut self,
1017        norm: &str,
1018        id: FontId,
1019        unicode_ranges: Vec<UnicodeRange>,
1020        style: &FaceStyle,
1021    ) {
1022        let face = MemoryFace {
1023            font_match: rust_fontconfig::FontMatch {
1024                id,
1025                unicode_ranges,
1026                fallbacks: Vec::new(),
1027            },
1028            weight: style.weight,
1029            italic: style.italic,
1030            oblique: style.oblique,
1031            stretch: style.stretch,
1032            weight_axis: style.weight_axis,
1033        };
1034        let faces = self.memory_families.entry(norm.to_string()).or_default();
1035        if let Some(slot) = faces.iter_mut().find(|f| f.font_match.id == id) {
1036            *slot = face;
1037        } else {
1038            faces.push(face);
1039        }
1040    }
1041
1042    /// Expand a variable font (with a `wght` axis over `[min, max]`, default
1043    /// `def`) into one baked STATIC instance per standard weight bucket and
1044    /// register each as an in-memory face under `norm`. Returns the face nearest
1045    /// the fvar default, or `None` if no instance could be baked (caller then
1046    /// falls back to registering the raw bytes as a single static face).
1047    ///
1048    /// Baking is done once per unique VF bytes and cached (`vf_bake_cache`) so the
1049    /// several spelling registrations of the same font don't re-bake it.
1050    // Weight axis values are clamped to [1, 1000] and rounded before the cast, so
1051    // the f32 -> u16 conversion is bounded and sign-safe.
1052    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
1053    fn register_variable_instances(
1054        &mut self,
1055        norm: &str,
1056        family: &str,
1057        bytes: &[u8],
1058        coverage: &[UnicodeRange],
1059        min: f32,
1060        def: f32,
1061        max: f32,
1062    ) -> Option<FontId> {
1063        let base = parse_face_style(bytes, family);
1064        let hash = {
1065            use core::hash::Hasher;
1066            let mut h = DefaultHasher::new();
1067            h.write(bytes);
1068            h.finish()
1069        };
1070        let def_bucket = FcWeight::from_u16(def.round().clamp(1.0, 1000.0) as u16);
1071
1072        // Same VF already baked under another spelling: re-index, don't re-bake.
1073        if let Some(cached) = self.vf_bake_cache.get(&hash).cloned() {
1074            for (id, style) in &cached {
1075                self.index_memory_face(norm, *id, coverage.to_vec(), style);
1076            }
1077            return cached
1078                .iter()
1079                .find(|(_, s)| s.weight == def_bucket)
1080                .or_else(|| cached.first())
1081                .map(|(id, _)| *id);
1082        }
1083
1084        let lo = min.round().clamp(1.0, 1000.0) as u16;
1085        let hi = max.round().clamp(1.0, 1000.0) as u16;
1086        let mut baked: Vec<(FontId, FaceStyle)> = Vec::new();
1087        for w in [100u16, 200, 300, 400, 500, 600, 700, 800, 900] {
1088            if w < lo || w > hi {
1089                continue;
1090            }
1091            let Some(inst_bytes) = crate::font::parsed::bake_weight_instance(bytes, 0, f32::from(w))
1092            else {
1093                continue;
1094            };
1095            let style = FaceStyle {
1096                weight: FcWeight::from_u16(w),
1097                italic: base.italic,
1098                oblique: base.oblique,
1099                stretch: base.stretch,
1100                weight_axis: None,
1101            };
1102            let pattern = rust_fontconfig::FcPattern {
1103                name: Some(family.to_string()),
1104                family: Some(family.to_string()),
1105                italic: if style.italic { PatternMatch::True } else { PatternMatch::False },
1106                oblique: if style.oblique { PatternMatch::True } else { PatternMatch::False },
1107                bold: if style.weight >= FcWeight::Bold { PatternMatch::True } else { PatternMatch::False },
1108                weight: style.weight,
1109                stretch: style.stretch,
1110                unicode_ranges: coverage.to_vec(),
1111                ..Default::default()
1112            };
1113            let id = FontId::new();
1114            self.fc_cache.with_memory_font_with_id(
1115                id,
1116                pattern,
1117                rust_fontconfig::FcFont {
1118                    bytes: inst_bytes,
1119                    font_index: 0,
1120                    id: family.to_string(),
1121                },
1122            );
1123            self.index_memory_face(norm, id, coverage.to_vec(), &style);
1124            baked.push((id, style));
1125        }
1126
1127        if baked.is_empty() {
1128            return None;
1129        }
1130        let default_id = baked
1131            .iter()
1132            .find(|(_, s)| s.weight == def_bucket)
1133            .or_else(|| baked.first())
1134            .map(|(id, _)| *id)
1135            .unwrap();
1136        self.vf_bake_cache.insert(hash, baked);
1137        Some(default_id)
1138    }
1139
1140    /// Register the built-in mock test fonts (see
1141    /// [`crate::text3::mock_fonts`]). Called from every constructor: the
1142    /// mock families are only reachable if a stylesheet names them, and
1143    /// having them always present means tests exercise the *same* font
1144    /// path as production instead of a test-only bypass.
1145    pub fn register_builtin_mock_fonts(&mut self) {
1146        for (family, bytes) in crate::text3::mock_fonts::BUILTIN_MOCK_FONTS {
1147            self.register_named_font(
1148                family,
1149                bytes,
1150                crate::text3::mock_fonts::mock_font_ranges(),
1151            );
1152        }
1153    }
1154
1155    /// Create a `FontManager` sharing the font-path cache handle.
1156    ///
1157    /// The `parsed_fonts` pool starts empty. Fonts loaded during the first
1158    /// layout pass are cached and will be available on subsequent calls
1159    /// if you clone the `parsed_fonts` Arc before creating the next instance.
1160    /// For full sharing, prefer `from_arc_shared()`.
1161    /// # Errors
1162    ///
1163    /// Returns a `LayoutError` if the font cache cannot be initialized.
1164    pub fn from_shared(fc_cache: FcFontCache) -> Result<Self, LayoutError> {
1165        Self::new(fc_cache)
1166    }
1167
1168    /// Create a `FontManager` sharing both the font-path cache and the
1169    /// already-parsed font data with another `FontManager`.
1170    ///
1171    /// This avoids re-reading and re-parsing font files from disk when
1172    /// rendering multiple documents that use the same fonts.
1173    /// # Errors
1174    ///
1175    /// Returns a `LayoutError` if the font cache cannot be initialized.
1176    pub fn from_arc_shared(
1177        fc_cache: FcFontCache,
1178        parsed_fonts: Arc<Mutex<HashMap<FontId, T>>>,
1179    ) -> Result<Self, LayoutError> {
1180        let mut fm = Self {
1181            fc_cache,
1182            parsed_fonts,
1183            font_chain_cache: HashMap::new(),
1184            embedded_fonts: Mutex::new(HashMap::new()),
1185            font_hash_to_families: HashMap::new(),
1186            registry: None,
1187            last_resolved_font_stacks_sig: None,
1188            memory_families: HashMap::new(),
1189            vf_bake_cache: HashMap::new(),
1190        };
1191        fm.register_builtin_mock_fonts();
1192        Ok(fm)
1193    }
1194
1195    /// Attach a `FcFontRegistry` to this `FontManager` so subsequent
1196    /// chain-resolution calls use the on-demand path
1197    /// ([`rust_fontconfig::registry::FcFontRegistry::request_and_resolve_with_scripts`]).
1198    #[must_use]
1199    pub fn with_registry(
1200        mut self,
1201        registry: Arc<rust_fontconfig::registry::FcFontRegistry>,
1202    ) -> Self {
1203        self.registry = Some(registry);
1204        self
1205    }
1206
1207    /// Get a shareable handle to the parsed-font pool.
1208    ///
1209    /// Pass this to `from_arc_shared()` to create a new `FontManager` that
1210    /// reuses already-parsed fonts.
1211    pub fn shared_parsed_fonts(&self) -> Arc<Mutex<HashMap<FontId, T>>> {
1212        Arc::clone(&self.parsed_fonts)
1213    }
1214
1215    /// Set the font chain cache from externally resolved chains
1216    ///
1217    /// This should be called with the result of `resolve_font_chains()` or
1218    /// `collect_and_resolve_font_chains()` from `solver3::getters`.
1219    pub fn set_font_chain_cache(
1220        &mut self,
1221        chains: HashMap<FontChainKey, rust_fontconfig::FontFallbackChain>,
1222    ) {
1223        self.font_chain_cache = chains;
1224        self.last_resolved_font_stacks_sig = None;
1225    }
1226
1227    /// Set the font chain cache and record the input signature so
1228    /// subsequent layouts with the same `prev_font_hashes` skip the
1229    /// resolver. Pass `sig = None` if the caller cannot compute a
1230    /// reliable signature — equivalent to the single-arg
1231    /// `set_font_chain_cache`.
1232    pub fn set_font_chain_cache_with_sig(
1233        &mut self,
1234        chains: HashMap<FontChainKey, rust_fontconfig::FontFallbackChain>,
1235        sig: Option<u64>,
1236    ) {
1237        // (2026-06-10: reverted to HashMap — the empty-map RawIter hang behind the 2026-06-05
1238        // BTreeMap migration was the un-mirrored hashbrown EMPTY_GROUP static, fixed
1239        // transpiler-side.)
1240        self.font_chain_cache = chains;
1241        self.last_resolved_font_stacks_sig = sig;
1242    }
1243
1244    /// Merge additional font chains into the existing cache
1245    ///
1246    /// Useful when processing multiple DOMs that may have different font requirements.
1247    pub fn merge_font_chain_cache(
1248        &mut self,
1249        chains: HashMap<FontChainKey, rust_fontconfig::FontFallbackChain>,
1250    ) {
1251        self.font_chain_cache.extend(chains);
1252    }
1253
1254    /// Get a reference to the font chain cache
1255    pub const fn get_font_chain_cache(
1256        &self,
1257    ) -> &HashMap<FontChainKey, rust_fontconfig::FontFallbackChain> {
1258        &self.font_chain_cache
1259    }
1260
1261    /// Get an embedded font by its hash (used for `WebRender` registration)
1262    /// Returns the `FontRef` if it exists in the `embedded_fonts` cache.
1263    /// # Panics
1264    ///
1265    /// Panics if the internal font-cache mutex is poisoned.
1266    pub fn get_embedded_font_by_hash(&self, font_hash: u64) -> Option<azul_css::props::basic::FontRef> {
1267        let embedded = self.embedded_fonts.lock().unwrap();
1268        embedded.get(&font_hash).cloned()
1269    }
1270
1271    /// Get a parsed font by its hash (used for `WebRender` registration)
1272    /// Returns the parsed font if it exists in the `parsed_fonts` cache.
1273    /// # Panics
1274    ///
1275    /// Panics if the internal font-cache mutex is poisoned.
1276    pub fn get_font_by_hash(&self, font_hash: u64) -> Option<T> {
1277        let parsed = self.parsed_fonts.lock().unwrap();
1278        // Linear search through all cached fonts to find one with matching hash
1279        let found = parsed
1280            .iter()
1281            .find(|(_, font)| font.get_hash() == font_hash)
1282            .map(|(_, font)| font.clone());
1283        drop(parsed);
1284        found
1285    }
1286
1287    /// Register an embedded `FontRef` for later lookup by hash
1288    /// This is called when using `FontStack::Ref` during shaping
1289    /// # Panics
1290    ///
1291    /// Panics if the internal font-cache mutex is poisoned.
1292    pub fn register_embedded_font(&self, font_ref: &azul_css::props::basic::FontRef) {
1293        let hash = font_ref.get_hash();
1294        let mut embedded = self.embedded_fonts.lock().unwrap();
1295        embedded.insert(hash, font_ref.clone());
1296    }
1297
1298    /// Get a snapshot of all currently loaded fonts
1299    ///
1300    /// This returns a copy of all parsed fonts, which can be passed to the shaper.
1301    /// No locking is required after this call - the returned `HashMap` is independent.
1302    ///
1303    /// NOTE: This should be called AFTER loading all required fonts for a layout pass.
1304    /// # Panics
1305    ///
1306    /// Panics if the internal font-cache mutex is poisoned.
1307    pub fn get_loaded_fonts(&self) -> LoadedFonts<T> {
1308        let parsed = self.parsed_fonts.lock().unwrap();
1309        parsed
1310            .iter()
1311            .map(|(id, font)| (*id, font.shallow_clone()))
1312            .collect()
1313    }
1314
1315    /// Get the set of `FontIds` that are currently loaded
1316    ///
1317    /// This is useful for computing which fonts need to be loaded
1318    /// (diff with required fonts).
1319    /// # Panics
1320    ///
1321    /// Panics if the internal font-cache mutex is poisoned.
1322    pub fn get_loaded_font_ids(&self) -> HashSet<FontId> {
1323        let parsed = self.parsed_fonts.lock().unwrap();
1324        // M12.7: skip hashbrown's RawIterRange on an empty map — its NEON
1325        // control-byte group-scan mis-lifts to wasm and iterates forever
1326        // (the headless web layout uses an empty font cache → parsed is
1327        // empty here). is_empty() is len-based (no iteration), so it is safe.
1328        if parsed.is_empty() {
1329            return HashSet::new();
1330        }
1331        unsafe { crate::az_mark(0x60788, 0xA1) };
1332        let out = parsed.keys().copied().collect();
1333        drop(parsed);
1334        unsafe { crate::az_mark(0x6078C, 0xA2) };
1335        out
1336    }
1337
1338    /// Insert a loaded font into the cache
1339    ///
1340    /// Returns the old font if one was already present for this `FontId`.
1341    /// # Panics
1342    ///
1343    /// Panics if the internal font-cache mutex is poisoned.
1344    pub fn insert_font(&self, font_id: FontId, font: T) -> Option<T> {
1345        let mut parsed = self.parsed_fonts.lock().unwrap();
1346        parsed.insert(font_id, font)
1347    }
1348
1349    /// Insert multiple loaded fonts into the cache
1350    ///
1351    /// This is more efficient than calling `insert_font` multiple times
1352    /// because it only acquires the lock once.
1353    /// # Panics
1354    ///
1355    /// Panics if the internal font-cache mutex is poisoned.
1356    pub fn insert_fonts(&self, fonts: impl IntoIterator<Item = (FontId, T)>) {
1357        let mut parsed = self.parsed_fonts.lock().unwrap();
1358        for (font_id, font) in fonts {
1359            parsed.insert(font_id, font);
1360        }
1361    }
1362
1363    /// One-shot helper that resolves "what fonts does `chains` need
1364    /// that this manager hasn't loaded yet" and loads them via the
1365    /// supplied `load_fn` closure (typically
1366    /// `PathLoader::load_font_shared` for the production lazy-decode
1367    /// path). Updates `parsed_fonts` in place and returns any failures
1368    /// for the caller to log.
1369    ///
1370    /// Replaces the same four-step `collect → compute_diff →
1371    /// load_from_disk → insert_fonts` dance previously inlined in
1372    /// `LayoutWindow::layout_document`, the CPU rasterizer pre-fill
1373    /// in `cpurender.rs`, and `FontContext::load_fonts_for_chains`.
1374    pub fn load_missing_for_chains<F>(
1375        &self,
1376        chains: &crate::solver3::getters::ResolvedFontChains,
1377        load_fn: F,
1378    ) -> Vec<(FontId, String)>
1379    where
1380        F: Fn(Arc<rust_fontconfig::FontBytes>, usize) -> Result<T, LayoutError>,
1381    {
1382        use crate::solver3::getters::{
1383            collect_font_ids_from_chains, compute_fonts_to_load, load_fonts_from_disk,
1384        };
1385        let required = collect_font_ids_from_chains(chains);
1386        let already = self.get_loaded_font_ids();
1387        let to_load = compute_fonts_to_load(&required, &already);
1388        if to_load.is_empty() {
1389            return Vec::new();
1390        }
1391        let result = load_fonts_from_disk(&to_load, &self.fc_cache, load_fn);
1392        self.insert_fonts(result.loaded);
1393        result.failed
1394    }
1395
1396    /// Replace the backing `FcFontCache` and re-register the built-in memory fonts.
1397    ///
1398    /// Memory fonts (the mock test fonts, and any `register_named_font` bytes) live
1399    /// ONLY inside the cache. A bare `self.fc_cache = new` therefore strands them: their
1400    /// `FontId`s stay in `memory_families` but their bytes are gone with the old cache,
1401    /// so chain resolution matches them yet loading fails and text silently falls back
1402    /// (e.g. `font-family: "Azul Mock Mono"` measuring with the fallback font's metrics).
1403    /// Use this whenever the cache is swapped for a fresh snapshot (registry handle,
1404    /// rebuilt system cache) instead of assigning the field directly.
1405    pub fn replace_fc_cache(&mut self, fc_cache: FcFontCache) {
1406        self.fc_cache = fc_cache;
1407        self.register_builtin_mock_fonts();
1408    }
1409
1410    /// Remove a font from the cache
1411    ///
1412    /// Returns the removed font if it was present.
1413    /// # Panics
1414    ///
1415    /// Panics if the internal font-cache mutex is poisoned.
1416    pub fn remove_font(&self, font_id: &FontId) -> Option<T> {
1417        let mut parsed = self.parsed_fonts.lock().unwrap();
1418        parsed.remove(font_id)
1419    }
1420
1421    /// FONT GC — evict everything the CURRENT document no longer references.
1422    ///
1423    /// `keep_ids` are the `FontId`s reachable from the font chains just resolved
1424    /// for this document; `keep_hashes` are the font-family hashes present in its
1425    /// CSS property cache. Anything else belonged to a node that is gone.
1426    ///
1427    /// Without this, `parsed_fonts` / `font_hash_to_families` only ever GREW: a
1428    /// font loaded for one node stayed resident for the life of the window even
1429    /// after the node (and every other user of that family) disappeared — an app
1430    /// that cycles fonts (font picker, editor, live CSS) leaked every font it ever
1431    /// touched.
1432    ///
1433    /// Eviction is always safe: `load_missing_for_chains` re-loads any font a
1434    /// later layout turns out to need. The cost of a wrong guess is one re-parse,
1435    /// never a missing glyph.
1436    ///
1437    /// Returns the number of parsed fonts evicted.
1438    /// # Panics
1439    ///
1440    /// Panics if the internal font-cache mutex is poisoned.
1441    pub fn garbage_collect_fonts(
1442        &mut self,
1443        keep_ids: &HashSet<FontId>,
1444        keep_hashes: &HashSet<u64>,
1445    ) -> usize {
1446        let evicted = {
1447            let mut parsed = self.parsed_fonts.lock().unwrap();
1448            let before = parsed.len();
1449            parsed.retain(|id, _| keep_ids.contains(id));
1450            before.saturating_sub(parsed.len())
1451        };
1452        self.font_hash_to_families
1453            .retain(|h, _| keep_hashes.contains(h));
1454        evicted
1455    }
1456}
1457
1458// Error handling
1459// [g119 az-web-lift FIX] `#[repr(C, u8)]` (was repr(Rust)): the String/FontSelector payloads give
1460// `Result<T, LayoutError>` (e.g. measure_intrinsic_widths' return + reorder/shape/orientation `?`)
1461// a POINTER-niche disc the web lift mis-reads → Ok→Err. Explicit u8 tag = simple-compare niche the
1462// lift handles. Also nested in solver3::LayoutError::Text (so both must be repr(C,u8)). Not FFI-exposed.
1463#[derive(Debug, thiserror::Error)]
1464#[repr(C, u8)]
1465pub enum LayoutError {
1466    #[error("Bidi analysis failed: {0}")]
1467    BidiError(String),
1468    #[error("Shaping failed: {0}")]
1469    ShapingError(String),
1470    #[error("Font not found: {0:?}")]
1471    FontNotFound(FontSelector),
1472    #[error("Invalid text input: {0}")]
1473    InvalidText(String),
1474    #[error("Hyphenation failed: {0}")]
1475    HyphenationError(String),
1476}
1477
1478/// Text boundary types for cursor movement
1479#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1480pub enum TextBoundary {
1481    /// Reached top of text (first line)
1482    Top,
1483    /// Reached bottom of text (last line)
1484    Bottom,
1485    /// Reached start of text (first character)
1486    Start,
1487    /// Reached end of text (last character)
1488    End,
1489}
1490
1491/// Error returned when cursor movement hits a boundary
1492#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1493pub(crate) struct CursorBoundsError {
1494    pub(crate) boundary: TextBoundary,
1495    pub(crate) cursor: TextCursor,
1496}
1497
1498/// Unified constraints combining all layout features
1499///
1500/// # CSS Inline Layout Module Level 3: Constraint Mapping
1501///
1502/// This structure maps CSS properties to layout constraints:
1503///
1504/// ## \u00a7 2.1 Layout of Line Boxes
1505/// - `available_width`: \u26a0\ufe0f CRITICAL - Should equal containing block's inner width
1506///   * Currently defaults to 0.0 which causes immediate line breaking
1507///   * Per spec: "logical width of a line box is equal to the inner logical width of its containing
1508///     block"
1509/// - `available_height`: For block-axis constraints (max-height)
1510///
1511/// ## \u00a7 2.2 Layout Within Line Boxes
1512/// - `text_align`: \u2705 Horizontal alignment (start, end, center, justify)
1513/// - `vertical_align`: \u26a0\ufe0f PARTIAL - Only baseline supported, missing:
1514///   * top, bottom, middle, text-top, text-bottom
1515///   * <length>, <percentage> values
1516///   * sub, super positions
1517/// - `line_height`: \u2705 Distance between baselines
1518///
1519/// ## \u00a7 3 Baselines and Alignment Metrics
1520/// - `text_orientation`: \u2705 For vertical writing (sideways, upright)
1521/// - `writing_mode`: \u2705 horizontal-tb, vertical-rl, vertical-lr
1522/// - `direction`: \u2705 ltr, rtl for `BiDi`
1523///
1524/// ## \u00a7 4 Baseline Alignment (vertical-align property)
1525/// \u26a0\ufe0f INCOMPLETE: Only basic baseline alignment implemented
1526///
1527/// ## \u00a7 5 Line Spacing (line-height property)
1528/// - `line_height`: \u2705 Implemented
1529/// - \u274c MISSING: line-fit-edge for controlling which edges contribute to line height
1530///   +spec:box-model:51342f - inline box margins/borders/padding do not affect line box height (default leading mode)
1531///   +spec:font-metrics:618776 - line-fit-edge (cap, ex, ideographic, alphabetic edge selection) not yet implemented
1532///
1533/// ## \u00a7 6 Trimming Leading (text-box-trim)
1534/// - \u274c NOT IMPLEMENTED: text-box-trim property
1535/// - \u274c NOT IMPLEMENTED: text-box-edge property
1536///   +spec:box-model:c09331 - text-box-trim trims block container first/last line to font metrics
1537///   // +spec:overflow:dc2196 - text-box-trim overflow handled as normal overflow (no special handling needed)
1538///
1539/// ## CSS Text Module Level 3
1540/// - `text_indent`: \u2705 First line indentation
1541/// - `text_justify`: \u2705 Justification algorithm (auto, inter-word, inter-character)
1542/// - `hyphenation`: \u2705 Hyphens property (none / manual / auto)
1543/// - `hanging_punctuation`: \u2705 Hanging punctuation at line edges
1544///
1545/// ## CSS Text Level 4
1546/// - `text_wrap`: \u2705 balance, pretty, stable
1547/// - `line_clamp`: \u2705 Max number of lines
1548///
1549/// ## CSS Writing Modes Level 4
1550/// - `text_combine_upright`: \u2705 Tate-chu-yoko for vertical text
1551///
1552/// ## CSS Shapes Module
1553/// - `shape_boundaries`: \u2705 Custom line box shapes
1554/// - `shape_exclusions`: \u2705 Exclusion areas (float-like behavior)
1555/// - `exclusion_margin`: \u2705 Margin around exclusions
1556///
1557/// ## Multi-column Layout
1558/// - `columns`: \u2705 Number of columns
1559/// - `column_gap`: \u2705 Gap between columns
1560///
1561/// # Known Issues:
1562/// 1. [ISSUE] `available_width` defaults to Definite(0.0) instead of containing block width
1563/// 2. [ISSUE] `vertical_align` only supports baseline
1564/// 3. [TODO] initial-letter (drop caps) not implemented
1565// +spec:box-model:415ef3 - initial letters use standard margin/padding/border box model; exclusion area = margin box
1566// +spec:box-model:d53ea3 - when block-start padding+border are zero, content edge coincides with over alignment point
1567///    +spec:positioning:fb233a - initial letter block-axis: if size < sink, use over alignment
1568#[derive(Debug, Clone)]
1569pub struct UnifiedConstraints {
1570    // Shape definition
1571    pub shape_boundaries: Vec<ShapeBoundary>,
1572    pub shape_exclusions: Vec<ShapeBoundary>,
1573
1574    // Basic layout - using AvailableSpace for proper indefinite handling
1575    pub available_width: AvailableSpace,
1576    pub available_height: Option<f32>,
1577
1578    // Text layout
1579    pub writing_mode: Option<WritingMode>,
1580    // +spec:writing-modes:6c5ab9 - blocks inherit base direction from parent via CSS direction property
1581    // Base direction from CSS, overrides auto-detection
1582    pub direction: Option<BidiDirection>,
1583    pub text_orientation: TextOrientation,
1584    pub text_align: TextAlign,
1585    pub text_justify: JustifyContent,
1586    // +spec:display-property:3bcac8 - inline boxes sized in block axis based on font metrics (ascent/descent)
1587    pub line_height: LineHeight,
1588    pub vertical_align: VerticalAlign,
1589    // block container's first available font, used for minimum line box height
1590    pub strut_ascent: f32,
1591    pub strut_descent: f32,
1592    // x-height of the strut font (scaled to font_size), for vertical-align: middle
1593    pub strut_x_height: f32,
1594
1595    // Width of '0' (zero) character in px, used for ch unit and tab-size.
1596    // Approximated as space_width from the first available font, or 0.5 * font_size fallback.
1597    pub ch_width: f32,
1598
1599    // Overflow handling
1600    pub overflow: OverflowBehavior,
1601    pub segment_alignment: SegmentAlignment,
1602
1603    // Advanced features
1604    pub text_combine_upright: Option<TextCombineUpright>,
1605    pub exclusion_margin: f32,
1606    pub hyphenation: Hyphens,
1607    pub hyphenation_language: Option<Language>,
1608    pub text_indent: f32,
1609    pub text_indent_each_line: bool,
1610    pub text_indent_hanging: bool,
1611    pub initial_letter: Option<InitialLetter>,
1612    pub line_clamp: Option<NonZeroUsize>,
1613
1614    // text-wrap: balance
1615    pub text_wrap: TextWrap,
1616    pub columns: u32,
1617    pub column_gap: f32,
1618    pub hanging_punctuation: bool,
1619    pub overflow_wrap: OverflowWrap,
1620    pub text_align_last: TextAlign,
1621    // §5.2 word-break property on constraints
1622    pub word_break: WordBreak,
1623    pub white_space_mode: WhiteSpaceMode,
1624    pub line_break: LineBreakStrictness,
1625    // CSS unicode-bidi property; Plaintext causes per-paragraph auto-detection
1626    pub unicode_bidi: UnicodeBidi,
1627}
1628
1629impl Default for UnifiedConstraints {
1630    fn default() -> Self {
1631        Self {
1632            shape_boundaries: Vec::new(),
1633            shape_exclusions: Vec::new(),
1634
1635            // Use MaxContent as default to avoid premature line breaking.
1636            // MaxContent means "use intrinsic width" which is appropriate when
1637            // the containing block's width is not yet known.
1638            // Previously this was Definite(0.0) which caused each character to
1639            // wrap to its own line. The actual width should be passed from the 
1640            // box layout solver (fc.rs) when creating UnifiedConstraints.
1641            available_width: AvailableSpace::MaxContent,
1642            available_height: None,
1643            writing_mode: None,
1644            direction: None, // Will default to LTR if not specified
1645            text_orientation: TextOrientation::default(),
1646            text_align: TextAlign::default(),
1647            text_justify: JustifyContent::default(),
1648            line_height: LineHeight::Normal,
1649            vertical_align: VerticalAlign::default(),
1650            strut_ascent: DEFAULT_STRUT_ASCENT,
1651            strut_descent: DEFAULT_STRUT_DESCENT,
1652            strut_x_height: DEFAULT_X_HEIGHT,
1653            ch_width: DEFAULT_CH_WIDTH,
1654            overflow: OverflowBehavior::default(),
1655            segment_alignment: SegmentAlignment::default(),
1656            text_combine_upright: None,
1657            exclusion_margin: 0.0,
1658            hyphenation: Hyphens::default(),
1659            hyphenation_language: None,
1660            columns: 1,
1661            column_gap: 0.0,
1662            hanging_punctuation: false,
1663            text_indent: 0.0,
1664            text_indent_each_line: false,
1665            text_indent_hanging: false,
1666            initial_letter: None,
1667            line_clamp: None,
1668            text_wrap: TextWrap::default(),
1669            overflow_wrap: OverflowWrap::default(),
1670            text_align_last: TextAlign::default(),
1671            word_break: WordBreak::default(),
1672            white_space_mode: WhiteSpaceMode::default(),
1673            line_break: LineBreakStrictness::default(),
1674            unicode_bidi: UnicodeBidi::default(),
1675        }
1676    }
1677}
1678
1679// UnifiedConstraints
1680impl Hash for UnifiedConstraints {
1681    #[allow(clippy::cast_possible_truncation)] // bounded pixel/coord/colour/glyph cast
1682    fn hash<H: Hasher>(&self, state: &mut H) {
1683        self.shape_boundaries.hash(state);
1684        self.shape_exclusions.hash(state);
1685        self.available_width.hash(state);
1686        self.available_height
1687            .map(|h| h.round() as isize)
1688            .hash(state);
1689        self.writing_mode.hash(state);
1690        self.direction.hash(state);
1691        self.text_orientation.hash(state);
1692        self.text_align.hash(state);
1693        self.text_justify.hash(state);
1694        self.line_height.hash(state);
1695        self.vertical_align.hash(state);
1696        (self.strut_ascent.round() as isize).hash(state);
1697        (self.strut_descent.round() as isize).hash(state);
1698        (self.strut_x_height.round() as isize).hash(state);
1699        (self.ch_width.round() as isize).hash(state);
1700        self.overflow.hash(state);
1701        self.segment_alignment.hash(state);
1702        self.text_combine_upright.hash(state);
1703        (self.exclusion_margin.round() as isize).hash(state);
1704        self.hyphenation.hash(state);
1705        self.hyphenation_language.hash(state);
1706        (self.text_indent.round() as isize).hash(state);
1707        self.text_indent_each_line.hash(state);
1708        self.text_indent_hanging.hash(state);
1709        self.initial_letter.hash(state);
1710        self.line_clamp.hash(state);
1711        self.columns.hash(state);
1712        (self.column_gap.round() as isize).hash(state);
1713        self.hanging_punctuation.hash(state);
1714        self.overflow_wrap.hash(state);
1715        self.text_align_last.hash(state);
1716        self.word_break.hash(state);
1717        self.white_space_mode.hash(state);
1718        self.line_break.hash(state);
1719        self.unicode_bidi.hash(state);
1720    }
1721}
1722
1723impl PartialEq for UnifiedConstraints {
1724    fn eq(&self, other: &Self) -> bool {
1725        self.shape_boundaries == other.shape_boundaries
1726            && self.shape_exclusions == other.shape_exclusions
1727            && self.available_width == other.available_width
1728            && match (self.available_height, other.available_height) {
1729                (None, None) => true,
1730                (Some(h1), Some(h2)) => round_eq(h1, h2),
1731                _ => false,
1732            }
1733            && self.writing_mode == other.writing_mode
1734            && self.direction == other.direction
1735            && self.text_orientation == other.text_orientation
1736            && self.text_align == other.text_align
1737            && self.text_justify == other.text_justify
1738            && self.line_height == other.line_height
1739            && self.vertical_align == other.vertical_align
1740            && round_eq(self.strut_ascent, other.strut_ascent)
1741            && round_eq(self.strut_descent, other.strut_descent)
1742            && round_eq(self.strut_x_height, other.strut_x_height)
1743            && round_eq(self.ch_width, other.ch_width)
1744            && self.overflow == other.overflow
1745            && self.segment_alignment == other.segment_alignment
1746            && self.text_combine_upright == other.text_combine_upright
1747            && round_eq(self.exclusion_margin, other.exclusion_margin)
1748            && self.hyphenation == other.hyphenation
1749            && self.hyphenation_language == other.hyphenation_language
1750            && round_eq(self.text_indent, other.text_indent)
1751            && self.text_indent_each_line == other.text_indent_each_line
1752            && self.text_indent_hanging == other.text_indent_hanging
1753            && self.initial_letter == other.initial_letter
1754            && self.line_clamp == other.line_clamp
1755            && self.columns == other.columns
1756            && round_eq(self.column_gap, other.column_gap)
1757            && self.hanging_punctuation == other.hanging_punctuation
1758            && self.overflow_wrap == other.overflow_wrap
1759            && self.text_align_last == other.text_align_last
1760            && self.word_break == other.word_break
1761            && self.white_space_mode == other.white_space_mode
1762            && self.line_break == other.line_break
1763            && self.unicode_bidi == other.unicode_bidi
1764    }
1765}
1766
1767impl Eq for UnifiedConstraints {}
1768
1769impl UnifiedConstraints {
1770    /// Resolve `line_height` to a pixel value using the strut metrics as a font-size proxy.
1771    /// `strut_ascent + strut_descent` approximates `font_size` (the block container's font).
1772    #[must_use] pub fn resolved_line_height(&self) -> f32 {
1773        match self.line_height {
1774            // `line-height: normal` — the minimum line-box height is the block's
1775            // first-available-font metrics, approximated here by the strut's
1776            // ascent + descent. Resolving `Normal` with no real metrics fell back
1777            // to `font_size * 1.2`, which inflated every non-last line's advance
1778            // ~20% (block auto-heights came out too tall). The real per-line box
1779            // height (from each run's actual glyph metrics) is folded in via
1780            // `.max()` at the call sites, so this strut value is the correct floor.
1781            LineHeight::Normal => self.strut_ascent + self.strut_descent,
1782            LineHeight::Px(px) => px,
1783        }
1784    }
1785    fn direction(&self, fallback: BidiDirection) -> BidiDirection {
1786        self.writing_mode.map_or(fallback, |s| s.get_direction().unwrap_or(fallback))
1787    }
1788    const fn is_vertical(&self) -> bool {
1789        matches!(
1790            self.writing_mode,
1791            Some(WritingMode::VerticalRl | WritingMode::VerticalLr)
1792        )
1793    }
1794}
1795
1796/// Line constraints with multi-segment support
1797#[derive(Debug, Clone)]
1798pub struct LineConstraints {
1799    pub segments: Vec<LineSegment>,
1800    pub total_available: f32,
1801    /// True when measuring min-content: the breaker must break at EVERY soft-wrap
1802    /// opportunity (each word on its own line) rather than filling `total_available`
1803    /// (which is a sentinel `f32::MAX / 2` for intrinsic sizing and never overflows).
1804    pub is_min_content: bool,
1805}
1806
1807impl WritingMode {
1808    #[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)
1809    #[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
1810    const fn get_direction(&self) -> Option<BidiDirection> {
1811        match self {
1812            // determined by text content
1813            Self::HorizontalTb => None,
1814            Self::VerticalRl => Some(BidiDirection::Rtl),
1815            Self::VerticalLr => Some(BidiDirection::Ltr),
1816            Self::SidewaysRl => Some(BidiDirection::Rtl),
1817            Self::SidewaysLr => Some(BidiDirection::Ltr),
1818        }
1819    }
1820}
1821
1822// Stage 1: Collection - Styled runs from DOM traversal
1823#[derive(Debug, Clone, Hash)]
1824pub struct StyledRun {
1825    pub text: String,
1826    pub style: Arc<StyleProperties>,
1827    /// Byte index in the original logical paragraph text
1828    pub logical_start_byte: usize,
1829    /// The DOM `NodeId` of the Text node this run came from.
1830    /// None for generated content (e.g., list markers, `::before/::after`).
1831    pub source_node_id: Option<NodeId>,
1832}
1833
1834// Stage 2: Bidi Analysis - Visual runs in display order
1835#[derive(Debug, Clone)]
1836pub struct VisualRun<'a> {
1837    pub text_slice: &'a str,
1838    pub style: Arc<StyleProperties>,
1839    pub logical_start_byte: usize,
1840    pub bidi_level: BidiLevel,
1841    pub script: Script,
1842    pub language: Language,
1843}
1844
1845// Font and styling types
1846
1847/// A selector for loading fonts from the font cache.
1848/// Used by `FontManager` to query fontconfig and load font files.
1849#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
1850pub struct FontSelector {
1851    pub family: String,
1852    pub weight: FcWeight,
1853    pub style: FontStyle,
1854    pub unicode_ranges: Vec<UnicodeRange>,
1855}
1856
1857impl Default for FontSelector {
1858    fn default() -> Self {
1859        Self {
1860            family: "serif".to_string(),
1861            weight: FcWeight::Normal,
1862            style: FontStyle::Normal,
1863            unicode_ranges: Vec::new(),
1864        }
1865    }
1866}
1867
1868/// Font stack that can be either a list of font selectors (resolved via fontconfig)
1869/// or a direct `FontRef` (bypasses fontconfig entirely).
1870///
1871/// When a `FontRef` is used, it bypasses fontconfig resolution entirely
1872/// and uses the pre-parsed font data directly. This is used for embedded
1873/// fonts like Material Icons.
1874// [g121 az-web-lift] `#[repr(C, u8)]` — same disc-mis-lift guard as the other text3 enums; matched in
1875// shape_visual_items (`match &style.font_stack { Ref => shape, Stack => resolve }`). repr(Rust) niche
1876// (from the Vec/FontRef payloads) could mis-route. Explicit u8 tag = simple load. Internal to text3.
1877#[derive(Debug, Clone)]
1878#[repr(C, u8)]
1879pub enum FontStack {
1880    /// A stack of font selectors to be resolved via fontconfig
1881    /// First font is primary, rest are fallbacks
1882    Stack(Vec<FontSelector>),
1883    /// A direct reference to a pre-parsed font (e.g., embedded icon fonts)
1884    /// This font covers the entire Unicode range and has no fallbacks.
1885    Ref(azul_css::props::basic::font::FontRef),
1886}
1887
1888impl Default for FontStack {
1889    fn default() -> Self {
1890        Self::Stack(vec![FontSelector::default()])
1891    }
1892}
1893
1894impl FontStack {
1895    /// Returns true if this is a direct `FontRef`
1896    #[must_use] pub const fn is_ref(&self) -> bool {
1897        matches!(self, Self::Ref(_))
1898    }
1899
1900    /// Returns the `FontRef` if this is a Ref variant
1901    #[must_use] pub const fn as_ref(&self) -> Option<&azul_css::props::basic::font::FontRef> {
1902        match self {
1903            Self::Ref(r) => Some(r),
1904            Self::Stack(_) => None,
1905        }
1906    }
1907
1908    /// Returns the font selectors if this is a Stack variant
1909    #[must_use] pub fn as_stack(&self) -> Option<&[FontSelector]> {
1910        match self {
1911            Self::Stack(s) => Some(s),
1912            Self::Ref(_) => None,
1913        }
1914    }
1915
1916    /// Returns the first `FontSelector` if this is a Stack variant, None if Ref
1917    #[must_use] pub fn first_selector(&self) -> Option<&FontSelector> {
1918        match self {
1919            Self::Stack(s) => s.first(),
1920            Self::Ref(_) => None,
1921        }
1922    }
1923
1924    /// Returns the first font family name (for Stack) or a placeholder (for Ref)
1925    #[must_use] pub fn first_family(&self) -> &str {
1926        match self {
1927            Self::Stack(s) => s.first().map_or("serif", |f| f.family.as_str()),
1928            Self::Ref(_) => "<embedded-font>",
1929        }
1930    }
1931}
1932
1933impl PartialEq for FontStack {
1934    fn eq(&self, other: &Self) -> bool {
1935        match (self, other) {
1936            (Self::Stack(a), Self::Stack(b)) => a == b,
1937            (Self::Ref(a), Self::Ref(b)) => a.parsed == b.parsed,
1938            _ => false,
1939        }
1940    }
1941}
1942
1943impl Eq for FontStack {}
1944
1945impl Hash for FontStack {
1946    fn hash<H: Hasher>(&self, state: &mut H) {
1947        discriminant(self).hash(state);
1948        match self {
1949            Self::Stack(s) => s.hash(state),
1950            Self::Ref(r) => (r.parsed as usize).hash(state),
1951        }
1952    }
1953}
1954
1955/// A reference to a font for rendering, identified by its hash.
1956/// This hash corresponds to `ParsedFont::hash` and is used to look up
1957/// the actual font data in the renderer's font cache.
1958#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
1959pub struct FontHash {
1960    /// The hash of the `ParsedFont`. 0 means invalid/unknown font.
1961    pub font_hash: u64,
1962}
1963
1964impl FontHash {
1965    #[must_use] pub const fn invalid() -> Self {
1966        Self { font_hash: 0 }
1967    }
1968
1969    #[must_use] pub const fn from_hash(font_hash: u64) -> Self {
1970        Self { font_hash }
1971    }
1972}
1973
1974#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
1975pub enum FontStyle {
1976    Normal,
1977    Italic,
1978    Oblique,
1979}
1980
1981/// Defines how text should be aligned when a line contains multiple disjoint segments.
1982#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
1983pub enum SegmentAlignment {
1984    /// Align text within the first available segment on the line.
1985    #[default]
1986    First,
1987    /// Align text relative to the total available width of all
1988    /// segments on the line combined.
1989    Total,
1990}
1991
1992#[derive(Copy, Debug, Clone)]
1993pub struct VerticalMetrics {
1994    pub advance: f32,
1995    pub bearing_x: f32,
1996    pub bearing_y: f32,
1997    pub origin_y: f32,
1998}
1999
2000// +spec:font-metrics:df51b1 - font metrics (ascent, descent, line_gap) used as baselines for inline layout alignment and box sizing
2001/// Layout-specific font metrics extracted from `FontMetrics`
2002/// Contains only the metrics needed for text layout and rendering
2003// +spec:box-model:a2f1c1 - inline box content area sized from first available font metrics (ascent/descent)
2004// +spec:font-metrics:9c2ca5 - ascent and descent metrics per font for inline layout
2005// +spec:font-metrics:797593 - font metrics (ascent, descent, line-gap) used for baseline calculations
2006// +spec:font-metrics:842d6a - font metrics (ascent, descent) used for precise spacing control
2007// +spec:font-metrics:eb97e0 - Font baseline metrics (ascent/descent) from font tables used for baseline alignment
2008// +spec:font-metrics:f2cd75 - em-over/em-under baselines intentionally not included (not used by CSS per spec)
2009// +spec:inline-formatting-context:76cd57 - ascent/descent font metrics for inline formatting context layout
2010// +spec:font-metrics:207e6b - ascent/descent metrics used for baseline calculations
2011#[derive(Copy, Debug, Clone)]
2012pub struct LayoutFontMetrics {
2013    pub ascent: f32,
2014    pub descent: f32,
2015    pub line_gap: f32,
2016    pub units_per_em: u16,
2017    /// OS/2 sxHeight: distance from baseline to top of lowercase 'x' (in font units).
2018    /// Used for `vertical-align: middle` per CSS Inline 3 §4.1.
2019    pub x_height: Option<f32>,
2020    /// OS/2 sCapHeight: height of capital letters from baseline (in font units).
2021    /// Used for drop cap / initial-letter alignment per CSS Inline 3 §7.1.1.
2022    pub cap_height: Option<f32>,
2023}
2024
2025impl LayoutFontMetrics {
2026    // +spec:font-metrics:006bd8 - baseline position from font design coordinates, scaled with font size
2027    // +spec:font-metrics:910c0a - dominant-baseline: auto resolves to alphabetic for horizontal text
2028    // +spec:writing-modes:098958 - baseline is along the inline axis, used to align glyphs
2029    #[must_use] pub fn baseline_scaled(&self, font_size: f32) -> f32 {
2030        let scale = font_size / f32::from(self.units_per_em);
2031        self.ascent * scale
2032    }
2033
2034    /// Returns the x-height scaled to the given font size in px.
2035    /// Falls back to 0.5em when the font doesn't provide sxHeight.
2036    #[must_use] pub fn x_height_scaled(&self, font_size: f32) -> f32 {
2037        let scale = font_size / f32::from(self.units_per_em);
2038        self.x_height.map_or(font_size * 0.5, |xh| xh * scale)
2039    }
2040
2041    /// Returns the cap height scaled to the given font size in px.
2042    /// Falls back to ascent when the font doesn't provide sCapHeight.
2043    #[must_use] pub fn cap_height_scaled(&self, font_size: f32) -> f32 {
2044        let scale = font_size / f32::from(self.units_per_em);
2045        self.cap_height.unwrap_or(self.ascent) * scale
2046    }
2047
2048    // +spec:line-height:471816 - line gap metric extracted from font for optional use when line-height is normal
2049    /// Convert from full `FontMetrics` to layout-specific metrics.
2050    ///
2051    // +spec:font-metrics:05193a - prefer OS/2 sTypoAscender/sTypoDescender, fall back to HHEA
2052    // +spec:font-metrics:17a71c - prefer OS/2 sTypoAscender/sTypoDescender, fall back to HHEA
2053    // +spec:font-metrics:62c659 - prefer OS/2 sTypoAscender/sTypoDescender, fall back to HHEA
2054    // +spec:writing-modes:451a3e - ascent/descent/line-gap metrics: prefer OS/2, fallback HHEA, floor line_gap at 0
2055    /// Per CSS 2.2 §10.8.1: prefer OS/2 sTypoAscender/sTypoDescender,
2056    /// fall back to HHEA Ascent/Descent if OS/2 metrics are absent.
2057    // +spec:font-metrics:3dc8c1 - text-over/text-under baselines from font ascent/descent metrics
2058    // +spec:font-metrics:332c16 - text-over/text-under baseline metrics derived from font ascent/descent
2059    // +spec:font-metrics:9895e2 - baseline table is a font-level property; metrics apply uniformly to all glyphs
2060    // +spec:font-metrics:e05c40 - font ascent/descent metric extraction (text edge metrics)
2061    // +spec:font-metrics:21a3de - ascent/descent used as basis for em-over/em-under normalization
2062    // +spec:font-metrics:1257b7 - font ascent/descent ensure text fits within line box
2063    // +spec:table-layout:6bbd10 - use sTypoAscender/sTypoDescender as ascent/descent metrics per spec recommendation
2064    // +spec:font-metrics:5346d2 - prefer OS/2 sTypoAscender/sTypoDescender, fall back to HHEA
2065    // +spec:font-metrics:e16941 - line gap metric floored at zero per spec
2066    // +spec:font-metrics:a55c05 - metrics taken from font, synthesized if missing (prefers OS/2, falls back to HHEA)
2067    #[must_use] pub fn from_font_metrics(metrics: &azul_css::props::basic::FontMetrics) -> Self {
2068        let ascent = metrics.s_typo_ascender
2069            .as_option()
2070            .map_or_else(|| f32::from(metrics.ascender), |v| f32::from(*v));
2071        let descent = metrics.s_typo_descender
2072            .as_option()
2073            .map_or_else(|| f32::from(metrics.descender), |v| f32::from(*v));
2074        // UAs must floor the line gap metric at zero (css-inline-3 §3.2.2)
2075        // Spec: "UAs must floor the line gap metric at zero."
2076        let line_gap = metrics.s_typo_line_gap
2077            .as_option()
2078            .map_or_else(|| f32::from(metrics.line_gap), |v| f32::from(*v))
2079            .max(0.0);
2080        let x_height = metrics.sx_height
2081            .as_option()
2082            .map(|v| f32::from(*v));
2083        let cap_height = metrics.s_cap_height
2084            .as_option()
2085            .map(|v| f32::from(*v));
2086        Self {
2087            ascent,
2088            descent,
2089            line_gap,
2090            units_per_em: metrics.units_per_em,
2091            x_height,
2092            cap_height,
2093        }
2094    }
2095
2096    // +spec:font-metrics:1eda6b - em-over is 0.5em over central baseline, em-under is 0.5em under
2097    /// Synthesize em-over baseline offset (in font units).
2098    /// Per CSS Inline 3 Appendix A.1: em-over = central baseline + 0.5em.
2099    /// Central baseline is synthesized as midpoint of ascent and descent.
2100    #[must_use] pub fn em_over(&self) -> f32 {
2101        let central = self.central_baseline();
2102        central + (f32::from(self.units_per_em) / 2.0)
2103    }
2104
2105    /// Synthesize em-under baseline offset (in font units).
2106    /// Per CSS Inline 3 Appendix A.1: em-under = central baseline - 0.5em.
2107    #[must_use] pub fn em_under(&self) -> f32 {
2108        let central = self.central_baseline();
2109        central - (f32::from(self.units_per_em) / 2.0)
2110    }
2111
2112    /// Synthesize central baseline (in font units).
2113    /// Midpoint between ascent and descent when not provided by the font.
2114    #[must_use] pub const fn central_baseline(&self) -> f32 {
2115        f32::midpoint(self.ascent, self.descent)
2116    }
2117}
2118
2119#[derive(Copy, Debug, Clone)]
2120pub struct LineSegment {
2121    pub start_x: f32,
2122    pub width: f32,
2123    // For choosing best segment when multiple available
2124    pub priority: u8,
2125}
2126
2127#[derive(Debug, Clone, Copy, PartialEq, Hash, Eq, PartialOrd, Ord, Default)]
2128pub enum TextWrap {
2129    #[default]
2130    Wrap,
2131    Balance,
2132    NoWrap,
2133}
2134
2135/// CSS `overflow-wrap` (aka `word-wrap`) property.
2136///
2137/// Controls whether an otherwise unbreakable sequence of characters
2138/// may be broken at an arbitrary point to prevent overflow.
2139#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
2140pub enum OverflowWrap {
2141    /// No special break opportunities are introduced.
2142    #[default]
2143    Normal,
2144    /// Break at arbitrary points if no other break points exist.
2145    /// Soft wrap opportunities from `anywhere` ARE considered
2146    /// when calculating min-content intrinsic sizes.
2147    Anywhere,
2148    /// Same as `anywhere` except soft wrap opportunities introduced
2149    /// by `break-word` are NOT considered when calculating
2150    /// min-content intrinsic sizes.
2151    BreakWord,
2152}
2153
2154// +spec:line-breaking:841a87 - hyphens property: manual (U+00AD/U+2010 only) and auto (language-aware automatic hyphenation)
2155// +spec:line-breaking:68c6ad - hyphens property controls hyphenation opportunities (none/manual/auto)
2156/// Controls whether hyphenation is allowed to create soft wrap opportunities.
2157#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
2158pub enum Hyphens {
2159    /// No hyphenation: U+00AD soft hyphens are not treated as break points.
2160    None,
2161    /// Only break at manually-inserted soft hyphens (U+00AD) or explicit hyphens.
2162    #[default]
2163    Manual,
2164    /// The UA may automatically hyphenate words in addition to manual opportunities.
2165    Auto,
2166}
2167
2168// +spec:line-breaking:ce5258 - white-space property controls collapsing, wrapping, and forced breaks
2169// +spec:line-breaking:35817b - normal/pre/nowrap/pre-wrap/break-spaces/pre-line behaviors
2170// +spec:white-space-processing:dec7aa - White space not removed/collapsed is "preserved white space"
2171#[derive(Debug, Clone, Copy, PartialEq, Hash, Eq, PartialOrd, Ord, Default)]
2172pub enum WhiteSpaceMode {
2173    #[default]
2174    Normal,
2175    Nowrap,
2176    Pre,
2177    PreWrap,
2178    PreLine,
2179    BreakSpaces,
2180}
2181
2182// CSS Text Level 3 §5.3: The line-break property controls strictness of line breaking rules.
2183// - Auto: UA-dependent, typically normal for CJK, loose for non-CJK
2184// - Loose: least restrictive, allows breaks before small kana, CJK hyphens, etc.
2185// - Normal: default CJK rules, allows breaks before CJK hyphen-like chars for CJK text
2186// - Strict: most restrictive, forbids breaks before small kana and CJK punctuation
2187// - Anywhere: allows soft wrap opportunities around every typographic character unit
2188#[derive(Debug, Clone, Copy, PartialEq, Hash, Eq, PartialOrd, Ord, Default)]
2189pub enum LineBreakStrictness {
2190    #[default]
2191    Auto,
2192    Loose,
2193    Normal,
2194    Strict,
2195    /// Soft wrap opportunity around every typographic character unit.
2196    /// Hyphenation is not applied.
2197    Anywhere,
2198}
2199
2200// §5.2 word-break property: normal, break-all, keep-all
2201#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
2202pub enum WordBreak {
2203    /// Normal break rules: CJK characters break between each other,
2204    /// non-CJK text only breaks at spaces/hyphens.
2205    #[default]
2206    Normal,
2207    /// Allow breaks between any two characters, including within Latin words.
2208    BreakAll,
2209    /// Suppress breaks between CJK characters (treat them like Latin words,
2210    /// only breaking at spaces). Sequences of CJK characters do not break.
2211    KeepAll,
2212}
2213
2214// +spec:display-property:162c99 - Initial letter box: in-flow inline-level box with special layout behavior
2215// +spec:display-property:72a797 - Initial letter handled like inline-level content in originating line box
2216// initial-letter
2217// +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
2218// +spec:font-metrics:1e5325 - drop initial cap-height = (N-1)*line_height + surrounding cap-height
2219// +spec:font-metrics:3aa518 - initial-letter-align: cap-height/ideographic/hanging/leading/border-box baseline alignment
2220// +spec:writing-modes:9698b0 - Han-derived scripts: initial letter extends from block-start to block-end of Nth line
2221#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
2222pub struct InitialLetter {
2223    /// How many lines tall the initial letter should be.
2224    pub size: f32,
2225    // +spec:font-metrics:dc0632 - raised initial "sinks" to first text baseline (sink=1)
2226    /// How many lines the letter should sink into.
2227    pub sink: u32,
2228    /// How many characters to apply this styling to.
2229    pub count: NonZeroUsize,
2230    // +spec:display-property:4c69bf - alignment points for sizing/positioning initial letter
2231    /// Alignment mode for the initial letter (over/under alignment points
2232    /// matched to corresponding points of the root inline box).
2233    pub align: InitialLetterAlign,
2234}
2235
2236/// Alignment mode for initial letters, controlling which alignment points
2237/// are used to size and position the letter relative to the root inline box.
2238#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
2239pub enum InitialLetterAlign {
2240    /// UA chooses based on script
2241    Auto,
2242    /// Alphabetic baseline alignment
2243    Alphabetic,
2244    /// Hanging baseline alignment
2245    Hanging,
2246    /// Ideographic baseline alignment
2247    Ideographic,
2248}
2249
2250// A type that implements `Hash` must also implement `Eq`.
2251// Since f32 does not implement `Eq`, we provide a manual implementation.
2252// This is a marker trait, indicating that `a == b` is a true equivalence
2253// relation. The derived `PartialEq` already satisfies this.
2254impl Eq for InitialLetter {}
2255
2256impl Hash for InitialLetter {
2257    #[allow(clippy::cast_possible_truncation)] // bounded pixel/coord/colour/glyph cast
2258    fn hash<H: Hasher>(&self, state: &mut H) {
2259        // Per the request, round the f32 to a usize for hashing.
2260        // This is a lossy conversion; values like 2.3 and 2.4 will produce
2261        // the same hash value for this field. This is acceptable as long as
2262        // the `PartialEq` implementation correctly distinguishes them.
2263        (self.size.round() as isize).hash(state);
2264        self.sink.hash(state);
2265        self.count.hash(state);
2266        self.align.hash(state);
2267    }
2268}
2269
2270// Path and shape definitions
2271#[derive(Copy, Debug, Clone, PartialOrd)]
2272pub enum PathSegment {
2273    MoveTo(Point),
2274    LineTo(Point),
2275    CurveTo {
2276        control1: Point,
2277        control2: Point,
2278        end: Point,
2279    },
2280    QuadTo {
2281        control: Point,
2282        end: Point,
2283    },
2284    Arc {
2285        center: Point,
2286        radius: f32,
2287        start_angle: f32,
2288        end_angle: f32,
2289    },
2290    Close,
2291}
2292
2293// PathSegment
2294impl Hash for PathSegment {
2295    #[allow(clippy::cast_possible_truncation)] // bounded pixel/coord/colour/glyph cast
2296    #[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
2297    fn hash<H: Hasher>(&self, state: &mut H) {
2298        // Hash the enum variant's discriminant first to distinguish them
2299        discriminant(self).hash(state);
2300
2301        match self {
2302            Self::MoveTo(p) => p.hash(state),
2303            Self::LineTo(p) => p.hash(state),
2304            Self::CurveTo {
2305                control1,
2306                control2,
2307                end,
2308            } => {
2309                control1.hash(state);
2310                control2.hash(state);
2311                end.hash(state);
2312            }
2313            Self::QuadTo { control, end } => {
2314                control.hash(state);
2315                end.hash(state);
2316            }
2317            Self::Arc {
2318                center,
2319                radius,
2320                start_angle,
2321                end_angle,
2322            } => {
2323                center.hash(state);
2324                (radius.round() as isize).hash(state);
2325                (start_angle.round() as isize).hash(state);
2326                (end_angle.round() as isize).hash(state);
2327            }
2328            Self::Close => {} // No data to hash
2329        }
2330    }
2331}
2332
2333impl PartialEq for PathSegment {
2334    #[allow(clippy::similar_names)] // domain-standard coordinate/geometry/short-lived names
2335    #[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
2336    fn eq(&self, other: &Self) -> bool {
2337        match (self, other) {
2338            (Self::MoveTo(a), Self::MoveTo(b)) => a == b,
2339            (Self::LineTo(a), Self::LineTo(b)) => a == b,
2340            (
2341                Self::CurveTo {
2342                    control1: c1a,
2343                    control2: c2a,
2344                    end: ea,
2345                },
2346                Self::CurveTo {
2347                    control1: c1b,
2348                    control2: c2b,
2349                    end: eb,
2350                },
2351            ) => c1a == c1b && c2a == c2b && ea == eb,
2352            (
2353                Self::QuadTo {
2354                    control: ca,
2355                    end: ea,
2356                },
2357                Self::QuadTo {
2358                    control: cb,
2359                    end: eb,
2360                },
2361            ) => ca == cb && ea == eb,
2362            (
2363                Self::Arc {
2364                    center: ca,
2365                    radius: ra,
2366                    start_angle: sa_a,
2367                    end_angle: ea_a,
2368                },
2369                Self::Arc {
2370                    center: cb,
2371                    radius: rb,
2372                    start_angle: sa_b,
2373                    end_angle: ea_b,
2374                },
2375            ) => ca == cb && round_eq(*ra, *rb) && round_eq(*sa_a, *sa_b) && round_eq(*ea_a, *ea_b),
2376            (Self::Close, Self::Close) => true,
2377            _ => false, // Variants are different
2378        }
2379    }
2380}
2381
2382impl Eq for PathSegment {}
2383
2384// Enhanced content model supporting mixed inline content
2385// [g117 az-web-lift FIX] `#[repr(C, u8)]` (was repr(Rust)): the web lift MIS-READS a repr(Rust)
2386// niche/compiler-placed discriminant — `<InlineContent as Clone>::clone` and create_logical_items'
2387// match both mis-route a Text(disc 0) to a Vec-bearing variant → clone reads a heap ptr as a Vec len
2388// → ~789MB alloc → OOB (g111/g115/g116 named stack = InlineContent::clone ← create_logical_items;
2389// content is CLEAN: len=1, ptr ok, disc-at-0=0). An explicit u8 tag at offset 0 (no niche) lowers to
2390// a simple load the lift handles correctly — the layout other (repr(C,u8)) enums use. Not FFI-exposed
2391// (internal to text3; only native shell code matches it), so the repr change is layout-safe.
2392#[derive(Debug, Clone, Hash)]
2393#[repr(C, u8)]
2394pub enum InlineContent {
2395    Text(StyledRun),
2396    Image(InlineImage),
2397    Shape(InlineShape),
2398    Space(InlineSpace),
2399    LineBreak(InlineBreak),
2400    /// Tab character - rendered with width based on tab-size CSS property
2401    Tab {
2402        style: Arc<StyleProperties>,
2403    },
2404    /// List marker (`::marker` pseudo-element)
2405    /// Markers with list-style-position: outside are positioned
2406    /// in the padding gutter of the list container
2407    Marker {
2408        run: StyledRun,
2409        /// Whether marker is positioned outside (in padding) or inside (inline)
2410        position_outside: bool,
2411    },
2412    // Ruby annotation
2413    Ruby {
2414        base: Vec<InlineContent>,
2415        text: Vec<InlineContent>,
2416        // Style for the ruby text itself
2417        style: Arc<StyleProperties>,
2418    },
2419}
2420
2421#[derive(Debug, Clone)]
2422pub struct InlineImage {
2423    pub source: ImageSource,
2424    pub intrinsic_size: Size,
2425    pub display_size: Option<Size>,
2426    // How much to shift baseline
2427    pub baseline_offset: f32,
2428    pub alignment: VerticalAlign,
2429    pub object_fit: ObjectFit,
2430}
2431
2432impl PartialEq for InlineImage {
2433    fn eq(&self, other: &Self) -> bool {
2434        self.baseline_offset.to_bits() == other.baseline_offset.to_bits()
2435            && self.source == other.source
2436            && self.intrinsic_size == other.intrinsic_size
2437            && self.display_size == other.display_size
2438            && self.alignment == other.alignment
2439            && self.object_fit == other.object_fit
2440    }
2441}
2442
2443impl Eq for InlineImage {}
2444
2445impl Hash for InlineImage {
2446    fn hash<H: Hasher>(&self, state: &mut H) {
2447        self.source.hash(state);
2448        self.intrinsic_size.hash(state);
2449        self.display_size.hash(state);
2450        self.baseline_offset.to_bits().hash(state);
2451        self.alignment.hash(state);
2452        self.object_fit.hash(state);
2453    }
2454}
2455
2456impl PartialOrd for InlineImage {
2457    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
2458        Some(self.cmp(other))
2459    }
2460}
2461
2462impl Ord for InlineImage {
2463    fn cmp(&self, other: &Self) -> Ordering {
2464        self.source
2465            .cmp(&other.source)
2466            .then_with(|| self.intrinsic_size.cmp(&other.intrinsic_size))
2467            .then_with(|| self.display_size.cmp(&other.display_size))
2468            .then_with(|| self.baseline_offset.total_cmp(&other.baseline_offset))
2469            .then_with(|| self.alignment.cmp(&other.alignment))
2470            .then_with(|| self.object_fit.cmp(&other.object_fit))
2471    }
2472}
2473
2474/// Enhanced glyph with all features
2475#[derive(Debug, Clone)]
2476pub struct Glyph {
2477    // Core glyph data
2478    pub glyph_id: u16,
2479    pub codepoint: char,
2480    /// Hash of the font - use `LoadedFonts` to look up the actual font when needed
2481    pub font_hash: u64,
2482    /// Cached font metrics to avoid font lookup for common operations
2483    pub font_metrics: LayoutFontMetrics,
2484    pub style: Arc<StyleProperties>,
2485    pub source: GlyphSource,
2486
2487    // Text mapping
2488    pub logical_byte_index: usize,
2489    pub logical_byte_len: usize,
2490    pub content_index: usize,
2491    pub cluster: u32,
2492
2493    // Metrics
2494    pub advance: f32,
2495    pub kerning: f32,
2496    pub offset: Point,
2497
2498    // Vertical text support
2499    pub vertical_advance: f32,
2500    pub vertical_origin_y: f32, // from VORG
2501    pub vertical_bearing: Point,
2502    pub orientation: GlyphOrientation,
2503
2504    // Layout properties
2505    pub script: Script,
2506    pub bidi_level: BidiLevel,
2507}
2508
2509impl Glyph {
2510    #[inline]
2511    fn bounds(&self) -> Rect {
2512        Rect {
2513            x: 0.0,
2514            y: 0.0,
2515            width: self.advance,
2516            height: self.style.line_height.resolve_with_metrics(self.style.font_size_px, &self.font_metrics),
2517        }
2518    }
2519
2520    #[inline]
2521    const fn character_class(&self) -> CharacterClass {
2522        classify_character(self.codepoint as u32)
2523    }
2524
2525    #[inline]
2526    fn is_whitespace(&self) -> bool {
2527        self.character_class() == CharacterClass::Space
2528    }
2529
2530    #[inline]
2531    fn can_justify(&self) -> bool {
2532        !self.codepoint.is_whitespace() && self.character_class() != CharacterClass::Combining
2533    }
2534
2535    #[inline]
2536    const fn justification_priority(&self) -> u8 {
2537        get_justification_priority(self.character_class())
2538    }
2539
2540    #[inline]
2541    const fn break_opportunity_after(&self) -> bool {
2542        let is_whitespace = self.codepoint.is_whitespace();
2543        let is_soft_hyphen = self.codepoint == '\u{00AD}';
2544        let is_hyphen_minus = self.codepoint == '\u{002D}';
2545        let is_hyphen = self.codepoint == '\u{2010}';
2546        is_whitespace || is_soft_hyphen || is_hyphen_minus || is_hyphen
2547    }
2548}
2549
2550// Information about text runs after initial analysis
2551#[derive(Debug, Clone)]
2552pub(crate) struct TextRunInfo<'a> {
2553    pub(crate) text: &'a str,
2554    pub(crate) style: Arc<StyleProperties>,
2555    pub(crate) logical_start: usize,
2556    pub(crate) content_index: usize,
2557}
2558
2559#[derive(Debug, Clone)]
2560pub enum ImageSource {
2561    /// Direct reference to decoded image (from DOM `NodeType::Image`)
2562    Ref(ImageRef),
2563    /// CSS url reference (from background-image, needs `ImageCache` lookup)
2564    Url(String),
2565    /// Raw image data
2566    Data(Arc<[u8]>),
2567    /// SVG source
2568    Svg(Arc<str>),
2569    /// Placeholder for layout without actual image
2570    Placeholder(Size),
2571}
2572
2573impl PartialEq for ImageSource {
2574    fn eq(&self, other: &Self) -> bool {
2575        match (self, other) {
2576            (Self::Ref(a), Self::Ref(b)) => a.get_hash() == b.get_hash(),
2577            (Self::Url(a), Self::Url(b)) => a == b,
2578            (Self::Data(a), Self::Data(b)) => Arc::ptr_eq(a, b),
2579            (Self::Svg(a), Self::Svg(b)) => Arc::ptr_eq(a, b),
2580            (Self::Placeholder(a), Self::Placeholder(b)) => {
2581                a.width.to_bits() == b.width.to_bits() && a.height.to_bits() == b.height.to_bits()
2582            }
2583            _ => false,
2584        }
2585    }
2586}
2587
2588impl Eq for ImageSource {}
2589
2590impl Hash for ImageSource {
2591    fn hash<H: Hasher>(&self, state: &mut H) {
2592        discriminant(self).hash(state);
2593        match self {
2594            Self::Ref(r) => r.get_hash().hash(state),
2595            Self::Url(s) => s.hash(state),
2596            Self::Data(d) => (Arc::as_ptr(d).cast::<u8>() as usize).hash(state),
2597            Self::Svg(s) => (Arc::as_ptr(s).cast::<u8>() as usize).hash(state),
2598            Self::Placeholder(sz) => {
2599                sz.width.to_bits().hash(state);
2600                sz.height.to_bits().hash(state);
2601            }
2602        }
2603    }
2604}
2605
2606impl PartialOrd for ImageSource {
2607    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
2608        Some(self.cmp(other))
2609    }
2610}
2611
2612impl Ord for ImageSource {
2613    fn cmp(&self, other: &Self) -> Ordering {
2614        const fn variant_index(s: &ImageSource) -> u8 {
2615            match s {
2616                ImageSource::Ref(_) => 0,
2617                ImageSource::Url(_) => 1,
2618                ImageSource::Data(_) => 2,
2619                ImageSource::Svg(_) => 3,
2620                ImageSource::Placeholder(_) => 4,
2621            }
2622        }
2623        match (self, other) {
2624            (Self::Ref(a), Self::Ref(b)) => a.get_hash().cmp(&b.get_hash()),
2625            (Self::Url(a), Self::Url(b)) => a.cmp(b),
2626            (Self::Data(a), Self::Data(b)) => {
2627                (Arc::as_ptr(a).cast::<u8>() as usize).cmp(&(Arc::as_ptr(b).cast::<u8>() as usize))
2628            }
2629            (Self::Svg(a), Self::Svg(b)) => {
2630                (Arc::as_ptr(a).cast::<u8>() as usize).cmp(&(Arc::as_ptr(b).cast::<u8>() as usize))
2631            }
2632            (Self::Placeholder(a), Self::Placeholder(b)) => {
2633                (a.width.to_bits(), a.height.to_bits())
2634                    .cmp(&(b.width.to_bits(), b.height.to_bits()))
2635            }
2636            // Different variants: compare by variant index
2637            _ => variant_index(self).cmp(&variant_index(other)),
2638        }
2639    }
2640}
2641
2642// +spec:font-metrics:fa104e - vertical-align values; baseline-source defaults to auto (first baseline)
2643// +spec:inline-formatting-context:340729 - alignment-baseline values for IFC baseline alignment (only baseline/top/bottom/middle implemented)
2644// CSS 2.2 §10.8.1 vertical-align property values
2645// +spec:display-property:0b1deb - inline boxes use dominant baseline to align text and inline-level children
2646// +spec:inline-formatting-context:3996a6 - dominant-baseline defaults to alphabetic in horizontal mode; vertical-align handles baseline alignment and super/sub shifting
2647#[derive(Default, Debug, Clone, Copy, PartialEq, PartialOrd)]
2648pub enum VerticalAlign {
2649    // Align baseline of box with baseline of parent box
2650    #[default]
2651    Baseline,
2652    // Align bottom of aligned subtree with bottom of line box
2653    Bottom,
2654    // Align top of aligned subtree with top of line box
2655    Top,
2656    // Align vertical midpoint of box with baseline of parent plus half x-height
2657    Middle,
2658    // Align top of box with top of parent's content area (§10.6.1)
2659    TextTop,
2660    // Align bottom of box with bottom of parent's content area (§10.6.1)
2661    TextBottom,
2662    // Lower baseline to proper subscript position
2663    Sub,
2664    // Raise baseline to proper superscript position
2665    Super,
2666    // +spec:font-metrics:152df3 - Raise (positive) or lower (negative) by this distance; 0 = baseline
2667    Offset(f32),
2668}
2669
2670impl Hash for VerticalAlign {
2671    fn hash<H: Hasher>(&self, state: &mut H) {
2672        discriminant(self).hash(state);
2673        if let Self::Offset(f) = self {
2674            f.to_bits().hash(state);
2675        }
2676    }
2677}
2678
2679impl Eq for VerticalAlign {}
2680
2681// cmp delegates to the derived PartialOrd (unwrap_or(Equal)), so Ord and PartialOrd are
2682// consistent; Ord can't be derived because of the f32 `Offset` variant.
2683#[allow(clippy::derive_ord_xor_partial_ord)]
2684impl Ord for VerticalAlign {
2685    fn cmp(&self, other: &Self) -> Ordering {
2686        self.partial_cmp(other).unwrap_or(Ordering::Equal)
2687    }
2688}
2689
2690#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, PartialOrd, Ord)]
2691pub enum ObjectFit {
2692    // Stretch to fit display size
2693    Fill,
2694    // Scale to fit within display size
2695    Contain,
2696    // Scale to cover display size
2697    Cover,
2698    // Use intrinsic size
2699    None,
2700    // Like contain but never scale up
2701    ScaleDown,
2702}
2703
2704/// Border information for inline elements (display: inline, inline-block)
2705///
2706/// This stores the resolved border properties needed for rendering inline element borders.
2707/// Unlike block elements which render borders via `paint_node_background_and_border()`,
2708/// inline element borders must be rendered per glyph-run to handle line breaks correctly.
2709#[derive(Copy, Debug, Clone, PartialEq)]
2710pub struct InlineBorderInfo {
2711    /// Border widths in pixels for each side
2712    pub top: f32,
2713    pub right: f32,
2714    pub bottom: f32,
2715    pub left: f32,
2716    /// Border colors for each side
2717    pub top_color: ColorU,
2718    pub right_color: ColorU,
2719    pub bottom_color: ColorU,
2720    pub left_color: ColorU,
2721    /// Border radius (if any)
2722    pub radius: Option<f32>,
2723    /// Padding widths in pixels for each side (needed to expand background rect)
2724    pub padding_top: f32,
2725    pub padding_right: f32,
2726    pub padding_bottom: f32,
2727    pub padding_left: f32,
2728    // +spec:box-model:c5723b - inline box split: suppress margin/border/padding at split points
2729    /// CSS 2.2 §9.4.2 / §8.6: when an inline box is split across line boxes,
2730    /// margins, borders, and padding have no visible effect at the split points.
2731    /// True if this is the first fragment of the inline box.
2732    pub is_first_fragment: bool,
2733    /// True if this is the last fragment of the inline box.
2734    pub is_last_fragment: bool,
2735    /// CSS 2.2 §8.6: direction flag for visual-order rendering in bidi context.
2736    /// LTR: first fragment gets left edge, last gets right edge.
2737    /// RTL: first fragment gets right edge, last gets left edge.
2738    pub is_rtl: bool,
2739}
2740
2741impl Default for InlineBorderInfo {
2742    fn default() -> Self {
2743        Self {
2744            top: 0.0,
2745            right: 0.0,
2746            bottom: 0.0,
2747            left: 0.0,
2748            top_color: ColorU::TRANSPARENT,
2749            right_color: ColorU::TRANSPARENT,
2750            bottom_color: ColorU::TRANSPARENT,
2751            left_color: ColorU::TRANSPARENT,
2752            radius: None,
2753            padding_top: 0.0,
2754            padding_right: 0.0,
2755            padding_bottom: 0.0,
2756            padding_left: 0.0,
2757            is_first_fragment: true,
2758            is_last_fragment: true,
2759            is_rtl: false,
2760        }
2761    }
2762}
2763
2764impl InlineBorderInfo {
2765    /// Returns true if any border has a non-zero width
2766    #[must_use] pub fn has_border(&self) -> bool {
2767        self.top > 0.0 || self.right > 0.0 || self.bottom > 0.0 || self.left > 0.0
2768    }
2769
2770    /// Returns true if any border or padding is present
2771    #[must_use] pub fn has_chrome(&self) -> bool {
2772        self.has_border()
2773            || self.padding_top > 0.0
2774            || self.padding_right > 0.0
2775            || self.padding_bottom > 0.0
2776            || self.padding_left > 0.0
2777    }
2778
2779    // +spec:box-model:da0ba2 - RTL bidi inline box split: left/right edges assigned to correct fragments
2780    // +spec:box-model:e9144f - visual-order margin/border/padding for inline boxes in bidi context
2781    // +spec:box-model:fac66f - Assigns margins/borders/padding in visual order for bidi inline fragments
2782    // +spec:box-model:720688 - LTR: left on first, right on last; RTL: right on first, left on last
2783    // +spec:positioning:1fcad6 - bidi-aware margin/border/padding on inline box fragments per visual order
2784    /// Total left inset (border + padding), suppressed at split points per §8.6.
2785    /// In LTR: left edge drawn on first fragment. In RTL: left edge drawn on last fragment.
2786    // +spec:box-model:bae97f - visual-order margin/border/padding assignment for bidi inline fragments
2787    #[must_use] pub fn left_inset(&self) -> f32 {
2788        let show = if self.is_rtl { self.is_last_fragment } else { self.is_first_fragment };
2789        if show { self.left + self.padding_left } else { 0.0 }
2790    }
2791    /// Total right inset (border + padding), suppressed at split points per §8.6.
2792    /// In LTR: right edge drawn on last fragment. In RTL: right edge drawn on first fragment.
2793    #[must_use] pub fn right_inset(&self) -> f32 {
2794        let show = if self.is_rtl { self.is_first_fragment } else { self.is_last_fragment };
2795        if show { self.right + self.padding_right } else { 0.0 }
2796    }
2797    /// Total top inset (border + padding)
2798    #[must_use] pub fn top_inset(&self) -> f32 { self.top + self.padding_top }
2799    /// Total bottom inset (border + padding)
2800    #[must_use] pub fn bottom_inset(&self) -> f32 { self.bottom + self.padding_bottom }
2801}
2802
2803#[derive(Debug, Clone)]
2804pub struct InlineShape {
2805    pub shape_def: ShapeDefinition,
2806    pub fill: Option<ColorU>,
2807    pub stroke: Option<Stroke>,
2808    pub baseline_offset: f32,
2809    /// Per-item vertical alignment (CSS `vertical-align` on the inline-block element).
2810    /// This overrides the global `TextStyleOptions::vertical_align` for this shape.
2811    pub alignment: VerticalAlign,
2812    /// The `NodeId` of the element that created this shape
2813    /// (e.g., inline-block) - this allows us to look up
2814    /// styling information (background, border) when rendering
2815    pub source_node_id: Option<NodeId>,
2816}
2817
2818#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
2819pub enum OverflowBehavior {
2820    // Content extends outside shape
2821    Visible,
2822    // Content is clipped to shape
2823    Hidden,
2824    // Scrollable overflow
2825    Scroll,
2826    // Browser/system decides
2827    #[default]
2828    Auto,
2829    // Break into next shape/page
2830    Break,
2831}
2832
2833#[derive(Debug, Clone)]
2834pub(crate) struct MeasuredImage {
2835    pub(crate) source: ImageSource,
2836    pub(crate) size: Size,
2837    pub(crate) baseline_offset: f32,
2838    pub(crate) alignment: VerticalAlign,
2839    pub(crate) content_index: usize,
2840}
2841
2842#[derive(Debug, Clone)]
2843pub(crate) struct MeasuredShape {
2844    pub(crate) shape_def: ShapeDefinition,
2845    pub(crate) size: Size,
2846    pub(crate) baseline_offset: f32,
2847    pub(crate) alignment: VerticalAlign,
2848    pub(crate) content_index: usize,
2849}
2850
2851#[derive(Copy, Debug, Clone)]
2852pub struct InlineSpace {
2853    pub width: f32,
2854    pub is_breaking: bool, // Can line break here
2855    pub is_stretchy: bool, // Can be expanded for justification
2856}
2857
2858impl PartialEq for InlineSpace {
2859    fn eq(&self, other: &Self) -> bool {
2860        self.width.to_bits() == other.width.to_bits()
2861            && self.is_breaking == other.is_breaking
2862            && self.is_stretchy == other.is_stretchy
2863    }
2864}
2865
2866impl Eq for InlineSpace {}
2867
2868impl Hash for InlineSpace {
2869    fn hash<H: Hasher>(&self, state: &mut H) {
2870        self.width.to_bits().hash(state);
2871        self.is_breaking.hash(state);
2872        self.is_stretchy.hash(state);
2873    }
2874}
2875
2876impl PartialOrd for InlineSpace {
2877    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
2878        Some(self.cmp(other))
2879    }
2880}
2881
2882impl Ord for InlineSpace {
2883    fn cmp(&self, other: &Self) -> Ordering {
2884        self.width
2885            .total_cmp(&other.width)
2886            .then_with(|| self.is_breaking.cmp(&other.is_breaking))
2887            .then_with(|| self.is_stretchy.cmp(&other.is_stretchy))
2888    }
2889}
2890
2891impl PartialEq for InlineShape {
2892    fn eq(&self, other: &Self) -> bool {
2893        self.baseline_offset.to_bits() == other.baseline_offset.to_bits()
2894            && self.shape_def == other.shape_def
2895            && self.fill == other.fill
2896            && self.stroke == other.stroke
2897            && self.alignment == other.alignment
2898            && self.source_node_id == other.source_node_id
2899    }
2900}
2901
2902impl Eq for InlineShape {}
2903
2904impl Hash for InlineShape {
2905    fn hash<H: Hasher>(&self, state: &mut H) {
2906        self.shape_def.hash(state);
2907        self.fill.hash(state);
2908        self.stroke.hash(state);
2909        self.baseline_offset.to_bits().hash(state);
2910        self.alignment.hash(state);
2911        self.source_node_id.hash(state);
2912    }
2913}
2914
2915impl PartialOrd for InlineShape {
2916    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
2917        Some(
2918            self.shape_def
2919                .partial_cmp(&other.shape_def)?
2920                .then_with(|| self.fill.cmp(&other.fill))
2921                .then_with(|| {
2922                    self.stroke
2923                        .partial_cmp(&other.stroke)
2924                        .unwrap_or(Ordering::Equal)
2925                })
2926                .then_with(|| self.baseline_offset.total_cmp(&other.baseline_offset))
2927                .then_with(|| self.alignment.cmp(&other.alignment))
2928                .then_with(|| self.source_node_id.cmp(&other.source_node_id)),
2929        )
2930    }
2931}
2932
2933#[derive(Debug, Default, Clone, Copy)]
2934pub struct Rect {
2935    pub x: f32,
2936    pub y: f32,
2937    pub width: f32,
2938    pub height: f32,
2939}
2940
2941impl PartialEq for Rect {
2942    fn eq(&self, other: &Self) -> bool {
2943        round_eq(self.x, other.x)
2944            && round_eq(self.y, other.y)
2945            && round_eq(self.width, other.width)
2946            && round_eq(self.height, other.height)
2947    }
2948}
2949impl Eq for Rect {}
2950
2951impl Hash for Rect {
2952    #[allow(clippy::cast_possible_truncation)] // bounded pixel/coord/colour/glyph cast
2953    fn hash<H: Hasher>(&self, state: &mut H) {
2954        // The order in which you hash the fields matters.
2955        // A consistent order is crucial.
2956        (self.x.round() as isize).hash(state);
2957        (self.y.round() as isize).hash(state);
2958        (self.width.round() as isize).hash(state);
2959        (self.height.round() as isize).hash(state);
2960    }
2961}
2962
2963#[derive(Debug, Default, Clone, Copy)]
2964pub struct Size {
2965    pub width: f32,
2966    pub height: f32,
2967}
2968
2969impl PartialOrd for Size {
2970    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
2971        Some(self.cmp(other))
2972    }
2973}
2974
2975impl Ord for Size {
2976    #[allow(clippy::cast_possible_truncation)] // bounded pixel/coord/colour/glyph cast
2977    fn cmp(&self, other: &Self) -> Ordering {
2978        (self.width.round() as isize)
2979            .cmp(&(other.width.round() as isize))
2980            .then_with(|| (self.height.round() as isize).cmp(&(other.height.round() as isize)))
2981    }
2982}
2983
2984// Size
2985impl Hash for Size {
2986    #[allow(clippy::cast_possible_truncation)] // bounded pixel/coord/colour/glyph cast
2987    fn hash<H: Hasher>(&self, state: &mut H) {
2988        (self.width.round() as isize).hash(state);
2989        (self.height.round() as isize).hash(state);
2990    }
2991}
2992impl PartialEq for Size {
2993    fn eq(&self, other: &Self) -> bool {
2994        round_eq(self.width, other.width) && round_eq(self.height, other.height)
2995    }
2996}
2997impl Eq for Size {}
2998
2999impl Size {
3000    #[must_use] pub const fn zero() -> Self {
3001        Self::new(0.0, 0.0)
3002    }
3003    #[must_use] pub const fn new(width: f32, height: f32) -> Self {
3004        Self { width, height }
3005    }
3006}
3007
3008#[derive(Debug, Default, Clone, Copy, PartialOrd)]
3009pub struct Point {
3010    pub x: f32,
3011    pub y: f32,
3012}
3013
3014// Point
3015impl Hash for Point {
3016    #[allow(clippy::cast_possible_truncation)] // bounded pixel/coord/colour/glyph cast
3017    fn hash<H: Hasher>(&self, state: &mut H) {
3018        (self.x.round() as isize).hash(state);
3019        (self.y.round() as isize).hash(state);
3020    }
3021}
3022
3023impl PartialEq for Point {
3024    fn eq(&self, other: &Self) -> bool {
3025        round_eq(self.x, other.x) && round_eq(self.y, other.y)
3026    }
3027}
3028
3029impl Eq for Point {}
3030
3031#[derive(Debug, Clone, PartialOrd)]
3032pub enum ShapeDefinition {
3033    Rectangle {
3034        size: Size,
3035        corner_radius: Option<f32>,
3036    },
3037    Circle {
3038        radius: f32,
3039    },
3040    Ellipse {
3041        radii: Size,
3042    },
3043    Polygon {
3044        points: Vec<Point>,
3045    },
3046    Path {
3047        segments: Vec<PathSegment>,
3048    },
3049}
3050
3051// ShapeDefinition
3052impl Hash for ShapeDefinition {
3053    #[allow(clippy::cast_possible_truncation)] // bounded pixel/coord/colour/glyph cast
3054    fn hash<H: Hasher>(&self, state: &mut H) {
3055        discriminant(self).hash(state);
3056        match self {
3057            Self::Rectangle {
3058                size,
3059                corner_radius,
3060            } => {
3061                size.hash(state);
3062                corner_radius.map(|r| r.round() as isize).hash(state);
3063            }
3064            Self::Circle { radius } => {
3065                (radius.round() as isize).hash(state);
3066            }
3067            Self::Ellipse { radii } => {
3068                radii.hash(state);
3069            }
3070            Self::Polygon { points } => {
3071                // Since Point implements Hash, we can hash the Vec directly.
3072                points.hash(state);
3073            }
3074            Self::Path { segments } => {
3075                // Same for Vec<PathSegment>
3076                segments.hash(state);
3077            }
3078        }
3079    }
3080}
3081
3082impl PartialEq for ShapeDefinition {
3083    fn eq(&self, other: &Self) -> bool {
3084        match (self, other) {
3085            (
3086                Self::Rectangle {
3087                    size: s1,
3088                    corner_radius: r1,
3089                },
3090                Self::Rectangle {
3091                    size: s2,
3092                    corner_radius: r2,
3093                },
3094            ) => {
3095                s1 == s2
3096                    && match (r1, r2) {
3097                        (None, None) => true,
3098                        (Some(v1), Some(v2)) => round_eq(*v1, *v2),
3099                        _ => false,
3100                    }
3101            }
3102            (Self::Circle { radius: r1 }, Self::Circle { radius: r2 }) => {
3103                round_eq(*r1, *r2)
3104            }
3105            (Self::Ellipse { radii: r1 }, Self::Ellipse { radii: r2 }) => {
3106                r1 == r2
3107            }
3108            (Self::Polygon { points: p1 }, Self::Polygon { points: p2 }) => {
3109                p1 == p2
3110            }
3111            (Self::Path { segments: s1 }, Self::Path { segments: s2 }) => {
3112                s1 == s2
3113            }
3114            _ => false,
3115        }
3116    }
3117}
3118impl Eq for ShapeDefinition {}
3119
3120impl ShapeDefinition {
3121    /// Calculates the bounding box size for the shape.
3122    #[must_use] pub fn get_size(&self) -> Size {
3123        match self {
3124            // The size is explicitly defined.
3125            Self::Rectangle { size, .. } => *size,
3126
3127            // The bounding box of a circle is a square with sides equal to the diameter.
3128            Self::Circle { radius } => {
3129                let diameter = radius * 2.0;
3130                Size::new(diameter, diameter)
3131            }
3132
3133            // The bounding box of an ellipse has width and height equal to twice its radii.
3134            Self::Ellipse { radii } => Size::new(radii.width * 2.0, radii.height * 2.0),
3135
3136            // For a polygon, we must find the min/max coordinates to get the bounds.
3137            Self::Polygon { points } => calculate_bounding_box_size(points),
3138
3139            // For a path, we find the bounding box of all its anchor and control points.
3140            //
3141            // NOTE: This is a common and fast approximation. The true bounding box of
3142            // bezier curves can be slightly smaller than the box containing their control
3143            // points. For pixel-perfect results, one would need to calculate the
3144            // curve's extrema.
3145            Self::Path { segments } => {
3146                let mut points = Vec::new();
3147                let mut current_pos = Point { x: 0.0, y: 0.0 };
3148
3149                for segment in segments {
3150                    match segment {
3151                        PathSegment::MoveTo(p) | PathSegment::LineTo(p) => {
3152                            points.push(*p);
3153                            current_pos = *p;
3154                        }
3155                        PathSegment::QuadTo { control, end } => {
3156                            points.push(current_pos);
3157                            points.push(*control);
3158                            points.push(*end);
3159                            current_pos = *end;
3160                        }
3161                        PathSegment::CurveTo {
3162                            control1,
3163                            control2,
3164                            end,
3165                        } => {
3166                            points.push(current_pos);
3167                            points.push(*control1);
3168                            points.push(*control2);
3169                            points.push(*end);
3170                            current_pos = *end;
3171                        }
3172                        PathSegment::Arc {
3173                            center,
3174                            radius,
3175                            start_angle,
3176                            end_angle,
3177                        } => {
3178                            // 1. Calculate and add the arc's start and end points to the list.
3179                            let start_point = Point {
3180                                x: center.x + radius * start_angle.cos(),
3181                                y: center.y + radius * start_angle.sin(),
3182                            };
3183                            let end_point = Point {
3184                                x: center.x + radius * end_angle.cos(),
3185                                y: center.y + radius * end_angle.sin(),
3186                            };
3187                            points.push(start_point);
3188                            points.push(end_point);
3189
3190                            // 2. Normalize the angles to handle cases where the arc crosses the
3191                            //    0-radian line.
3192                            // This ensures we can iterate forward from a start to an end angle.
3193                            let mut normalized_end = *end_angle;
3194                            #[allow(clippy::while_float)] // intentional bounded float loop (angle-wrap / pixel-step); an integer counter would be artificial
3195                            while normalized_end < *start_angle {
3196                                normalized_end += 2.0 * std::f32::consts::PI;
3197                            }
3198
3199                            // 3. Find the first cardinal point (multiples of PI/2) at or after the
3200                            //    start angle.
3201                            let mut check_angle = (*start_angle / std::f32::consts::FRAC_PI_2)
3202                                .ceil()
3203                                * std::f32::consts::FRAC_PI_2;
3204
3205                            // 4. Iterate through all cardinal points that fall within the arc's
3206                            //    sweep and add them.
3207                            // These points define the maximum extent of the arc's bounding box.
3208                            #[allow(clippy::while_float)] // intentional bounded float loop (angle-wrap / pixel-step); an integer counter would be artificial
3209                            while check_angle < normalized_end {
3210                                points.push(Point {
3211                                    x: center.x + radius * check_angle.cos(),
3212                                    y: center.y + radius * check_angle.sin(),
3213                                });
3214                                check_angle += std::f32::consts::FRAC_PI_2;
3215                            }
3216
3217                            // 5. The end of the arc is the new current position for subsequent path
3218                            //    segments.
3219                            current_pos = end_point;
3220                        }
3221                        PathSegment::Close => {
3222                            // No new points are added for closing the path
3223                        }
3224                    }
3225                }
3226                calculate_bounding_box_size(&points)
3227            }
3228        }
3229    }
3230}
3231
3232// +spec:text-alignment-spacing:25e82a - text-align shorthand resolves text-align-all / text-align-last
3233/// Resolve effective text alignment for a line, handling text-align-last per CSS Text §6.3.
3234/// For the last line (or lines before forced breaks), text-align-last overrides text-align.
3235/// When text-align-last is auto (default), justify falls back to start; others use text-align.
3236// +spec:text-alignment-spacing:bca77d - text-align-last auto falls back to text-align-all, justify→start
3237// +spec:line-breaking:9b10d2 - text-align-last applies to last line and lines before forced breaks
3238/// +spec:text-alignment-spacing:8d88ce - text-align-last overrides justify on last line/forced break
3239pub(crate) fn resolve_effective_alignment(
3240    text_align: TextAlign,
3241    text_align_last: TextAlign,
3242    is_last_or_forced: bool,
3243) -> TextAlign {
3244    if is_last_or_forced {
3245        if text_align_last == TextAlign::default() {
3246            if text_align == TextAlign::Justify { TextAlign::Start } else { text_align }
3247        } else {
3248            text_align_last
3249        }
3250    } else {
3251        text_align
3252    }
3253}
3254
3255/// Helper function to calculate the size of the bounding box enclosing a set of points.
3256fn calculate_bounding_box_size(points: &[Point]) -> Size {
3257    if points.is_empty() {
3258        return Size::zero();
3259    }
3260
3261    let mut min_x = f32::MAX;
3262    let mut max_x = f32::MIN;
3263    let mut min_y = f32::MAX;
3264    let mut max_y = f32::MIN;
3265
3266    for point in points {
3267        min_x = min_x.min(point.x);
3268        max_x = max_x.max(point.x);
3269        min_y = min_y.min(point.y);
3270        max_y = max_y.max(point.y);
3271    }
3272
3273    // Handle case where points might be collinear or a single point
3274    if min_x > max_x || min_y > max_y {
3275        return Size::zero();
3276    }
3277
3278    Size::new(max_x - min_x, max_y - min_y)
3279}
3280
3281#[derive(Debug, Clone, PartialOrd)]
3282pub struct Stroke {
3283    pub color: ColorU,
3284    pub width: f32,
3285    pub dash_pattern: Option<Vec<f32>>,
3286}
3287
3288// Stroke
3289impl Hash for Stroke {
3290    #[allow(clippy::cast_possible_truncation)] // bounded pixel/coord/colour/glyph cast
3291    fn hash<H: Hasher>(&self, state: &mut H) {
3292        self.color.hash(state);
3293        (self.width.round() as isize).hash(state);
3294
3295        // Manual hashing for Option<Vec<f32>>
3296        match &self.dash_pattern {
3297            None => 0u8.hash(state), // Hash a discriminant for None
3298            Some(pattern) => {
3299                1u8.hash(state); // Hash a discriminant for Some
3300                pattern.len().hash(state); // Hash the length
3301                for &val in pattern {
3302                    (val.round() as isize).hash(state); // Hash each rounded value
3303                }
3304            }
3305        }
3306    }
3307}
3308
3309impl PartialEq for Stroke {
3310    fn eq(&self, other: &Self) -> bool {
3311        if self.color != other.color || !round_eq(self.width, other.width) {
3312            return false;
3313        }
3314        match (&self.dash_pattern, &other.dash_pattern) {
3315            (None, None) => true,
3316            (Some(p1), Some(p2)) => {
3317                p1.len() == p2.len() && p1.iter().zip(p2.iter()).all(|(a, b)| round_eq(*a, *b))
3318            }
3319            _ => false,
3320        }
3321    }
3322}
3323
3324impl Eq for Stroke {}
3325
3326// Helper function to round f32 for comparison
3327#[allow(clippy::cast_possible_truncation)] // bounded pixel/coord/colour/glyph cast
3328fn round_eq(a: f32, b: f32) -> bool {
3329    (a.round() as isize) == (b.round() as isize)
3330}
3331
3332#[derive(Debug, Clone)]
3333pub enum ShapeBoundary {
3334    Rectangle(Rect),
3335    Circle { center: Point, radius: f32 },
3336    Ellipse { center: Point, radii: Size },
3337    Polygon { points: Vec<Point> },
3338    Path { segments: Vec<PathSegment> },
3339}
3340
3341impl ShapeBoundary {
3342    #[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
3343    #[must_use] pub fn inflate(&self, margin: f32) -> Self {
3344        if margin == 0.0 {
3345            return self.clone();
3346        }
3347        match self {
3348            Self::Rectangle(rect) => Self::Rectangle(Rect {
3349                x: rect.x - margin,
3350                y: rect.y - margin,
3351                width: (rect.width + margin * 2.0).max(0.0),
3352                height: (rect.height + margin * 2.0).max(0.0),
3353            }),
3354            Self::Circle { center, radius } => Self::Circle {
3355                center: *center,
3356                radius: radius + margin,
3357            },
3358            // For simplicity, Polygon and Path inflation is not implemented here.
3359            // A full implementation would require a geometry library to offset the path.
3360            _ => self.clone(),
3361        }
3362    }
3363}
3364
3365// ShapeBoundary
3366impl Hash for ShapeBoundary {
3367    #[allow(clippy::cast_possible_truncation)] // bounded pixel/coord/colour/glyph cast
3368    fn hash<H: Hasher>(&self, state: &mut H) {
3369        discriminant(self).hash(state);
3370        match self {
3371            Self::Rectangle(rect) => rect.hash(state),
3372            Self::Circle { center, radius } => {
3373                center.hash(state);
3374                (radius.round() as isize).hash(state);
3375            }
3376            Self::Ellipse { center, radii } => {
3377                center.hash(state);
3378                radii.hash(state);
3379            }
3380            Self::Polygon { points } => points.hash(state),
3381            Self::Path { segments } => segments.hash(state),
3382        }
3383    }
3384}
3385impl PartialEq for ShapeBoundary {
3386    fn eq(&self, other: &Self) -> bool {
3387        match (self, other) {
3388            (Self::Rectangle(r1), Self::Rectangle(r2)) => r1 == r2,
3389            (
3390                Self::Circle {
3391                    center: c1,
3392                    radius: r1,
3393                },
3394                Self::Circle {
3395                    center: c2,
3396                    radius: r2,
3397                },
3398            ) => c1 == c2 && round_eq(*r1, *r2),
3399            (
3400                Self::Ellipse {
3401                    center: c1,
3402                    radii: r1,
3403                },
3404                Self::Ellipse {
3405                    center: c2,
3406                    radii: r2,
3407                },
3408            ) => c1 == c2 && r1 == r2,
3409            (Self::Polygon { points: p1 }, Self::Polygon { points: p2 }) => {
3410                p1 == p2
3411            }
3412            (Self::Path { segments: s1 }, Self::Path { segments: s2 }) => {
3413                s1 == s2
3414            }
3415            _ => false,
3416        }
3417    }
3418}
3419impl Eq for ShapeBoundary {}
3420
3421impl ShapeBoundary {
3422    /// Converts a CSS shape (from azul-css) to a layout engine `ShapeBoundary`
3423    ///
3424    /// # Arguments
3425    /// * `css_shape` - The parsed CSS shape from azul-css
3426    /// * `reference_box` - The containing box for resolving coordinates (from layout solver)
3427    ///
3428    /// # Returns
3429    /// A `ShapeBoundary` ready for use in the text layout engine
3430    #[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
3431    pub fn from_css_shape(
3432        css_shape: &azul_css::shape::CssShape,
3433        reference_box: Rect,
3434        debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
3435    ) -> Self {
3436        use azul_css::shape::CssShape;
3437
3438        if let Some(msgs) = debug_messages {
3439            msgs.push(LayoutDebugMessage::info(format!(
3440                "[ShapeBoundary::from_css_shape] Input CSS shape: {css_shape:?}"
3441            )));
3442            msgs.push(LayoutDebugMessage::info(format!(
3443                "[ShapeBoundary::from_css_shape] Reference box: {reference_box:?}"
3444            )));
3445        }
3446
3447        let result = match css_shape {
3448            CssShape::Circle(circle) => {
3449                let center = Point {
3450                    x: reference_box.x + circle.center.x,
3451                    y: reference_box.y + circle.center.y,
3452                };
3453                if let Some(msgs) = debug_messages {
3454                    msgs.push(LayoutDebugMessage::info(format!(
3455                        "[ShapeBoundary::from_css_shape] Circle - CSS center: ({}, {}), radius: {}",
3456                        circle.center.x, circle.center.y, circle.radius
3457                    )));
3458                    msgs.push(LayoutDebugMessage::info(format!(
3459                        "[ShapeBoundary::from_css_shape] Circle - Absolute center: ({}, {}), \
3460                         radius: {}",
3461                        center.x, center.y, circle.radius
3462                    )));
3463                }
3464                Self::Circle {
3465                    center,
3466                    radius: circle.radius,
3467                }
3468            }
3469
3470            CssShape::Ellipse(ellipse) => {
3471                let center = Point {
3472                    x: reference_box.x + ellipse.center.x,
3473                    y: reference_box.y + ellipse.center.y,
3474                };
3475                let radii = Size {
3476                    width: ellipse.radius_x,
3477                    height: ellipse.radius_y,
3478                };
3479                if let Some(msgs) = debug_messages {
3480                    msgs.push(LayoutDebugMessage::info(format!(
3481                        "[ShapeBoundary::from_css_shape] Ellipse - center: ({}, {}), radii: ({}, \
3482                         {})",
3483                        center.x, center.y, radii.width, radii.height
3484                    )));
3485                }
3486                Self::Ellipse { center, radii }
3487            }
3488
3489            CssShape::Polygon(polygon) => {
3490                let points = polygon
3491                    .points
3492                    .as_ref()
3493                    .iter()
3494                    .map(|pt| Point {
3495                        x: reference_box.x + pt.x,
3496                        y: reference_box.y + pt.y,
3497                    })
3498                    .collect();
3499                if let Some(msgs) = debug_messages {
3500                    msgs.push(LayoutDebugMessage::info(format!(
3501                        "[ShapeBoundary::from_css_shape] Polygon - {} points",
3502                        polygon.points.as_ref().len()
3503                    )));
3504                }
3505                Self::Polygon { points }
3506            }
3507
3508            CssShape::Inset(inset) => {
3509                // Inset defines distances from reference box edges
3510                let x = reference_box.x + inset.inset_left;
3511                let y = reference_box.y + inset.inset_top;
3512                let width = reference_box.width - inset.inset_left - inset.inset_right;
3513                let height = reference_box.height - inset.inset_top - inset.inset_bottom;
3514
3515                if let Some(msgs) = debug_messages {
3516                    msgs.push(LayoutDebugMessage::info(format!(
3517                        "[ShapeBoundary::from_css_shape] Inset - insets: ({}, {}, {}, {})",
3518                        inset.inset_top, inset.inset_right, inset.inset_bottom, inset.inset_left
3519                    )));
3520                    msgs.push(LayoutDebugMessage::info(format!(
3521                        "[ShapeBoundary::from_css_shape] Inset - resulting rect: x={x}, y={y}, \
3522                         w={width}, h={height}"
3523                    )));
3524                }
3525
3526                Self::Rectangle(Rect {
3527                    x,
3528                    y,
3529                    width: width.max(0.0),
3530                    height: height.max(0.0),
3531                })
3532            }
3533
3534            CssShape::Path(path) => {
3535                // CSS `path()` value: `path.data` is a raw SVG path `d=""` string in the
3536                // reference-box coordinate system (origin at the reference box's top-left).
3537                // Parse + flatten it into `Vec<PathSegment>` (curves sampled to line
3538                // segments) so the scanline code in `get_shape_horizontal_spans` can
3539                // intersect it per line, exactly like `polygon`.
3540                let segments = azul_core::path_parser::parse_svg_path_d(path.data.as_str())
3541                    .map_or_else(|_| Vec::new(), |multipolygon| {
3542                        flatten_svg_to_path_segments(&multipolygon, reference_box)
3543                    });
3544                if let Some(msgs) = debug_messages {
3545                    msgs.push(LayoutDebugMessage::info(format!(
3546                        "[ShapeBoundary::from_css_shape] Path - parsed {} flattened segments",
3547                        segments.len()
3548                    )));
3549                }
3550                if segments.is_empty() {
3551                    // Unparseable / empty path: fall back to the reference rectangle so a
3552                    // shape-inside container does not collapse to zero usable space.
3553                    Self::Rectangle(reference_box)
3554                } else {
3555                    Self::Path { segments }
3556                }
3557            }
3558        };
3559
3560        if let Some(msgs) = debug_messages {
3561            msgs.push(LayoutDebugMessage::info(format!(
3562                "[ShapeBoundary::from_css_shape] Result: {result:?}"
3563            )));
3564        }
3565        result
3566    }
3567}
3568
3569#[derive(Copy, Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
3570pub struct InlineBreak {
3571    pub break_type: BreakType,
3572    pub clear: ClearType,
3573    pub content_index: usize,
3574}
3575
3576// +spec:line-breaking:d70ffd - Defines forced line break (Hard) vs soft wrap break (Soft) types
3577#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
3578pub enum BreakType {
3579    Soft,   // Soft wrap break: UA creates unforced line breaks to fit content within the measure
3580    Hard,   // Forced line break: explicit line-breaking controls (preserved newline, <br>)
3581    Page,   // Page break
3582    Column, // Column break
3583}
3584
3585#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
3586pub enum ClearType {
3587    None,
3588    Left,
3589    Right,
3590    Both,
3591}
3592
3593// Complex shape constraints for non-rectangular text flow
3594#[derive(Debug, Clone)]
3595pub(crate) struct ShapeConstraints {
3596    pub(crate) boundaries: Vec<ShapeBoundary>,
3597    pub(crate) exclusions: Vec<ShapeBoundary>,
3598    pub(crate) writing_mode: WritingMode,
3599    pub(crate) text_align: TextAlign,
3600    pub(crate) line_height: LineHeight,
3601}
3602
3603#[derive(Debug, Clone, Copy, PartialEq, Default, Hash, Eq, PartialOrd, Ord)]
3604pub enum WritingMode {
3605    #[default]
3606    HorizontalTb, // horizontal-tb (normal horizontal)
3607    VerticalRl, // +spec:writing-modes:6e22a7 - vertical-rl (vertical right-to-left, commonly used in East Asia)
3608    VerticalLr, // vertical-lr (vertical left-to-right)
3609    SidewaysRl, // sideways-rl (rotated horizontal in vertical context)
3610    SidewaysLr, // sideways-lr (rotated horizontal in vertical context)
3611}
3612
3613impl WritingMode {
3614    /// Necessary to determine if the glyphs are advancing in a horizontal direction
3615    #[must_use] pub const fn is_advance_horizontal(&self) -> bool {
3616        matches!(
3617            self,
3618            Self::HorizontalTb | Self::SidewaysRl | Self::SidewaysLr
3619        )
3620    }
3621}
3622
3623#[derive(Debug, Clone, Copy, PartialEq, Default, Hash, Eq, PartialOrd, Ord)]
3624pub enum JustifyContent {
3625    #[default]
3626    None,
3627    InterWord,      // Expand spaces between words
3628    InterCharacter, // Expand spaces between all characters (for CJK)
3629    Distribute,     // Distribute space evenly including start/end
3630    Kashida,        // Stretch Arabic text using kashidas
3631}
3632
3633// Enhanced text alignment with logical directions
3634#[derive(Debug, Clone, Copy, PartialEq, Default, Hash, Eq, PartialOrd, Ord)]
3635pub enum TextAlign {
3636    #[default]
3637    Left,
3638    Right,
3639    Center,
3640    Justify,
3641    Start,
3642    End,        // Logical start/end
3643    JustifyAll, // Justify including last line
3644}
3645
3646// +spec:block-formatting-context:458d31 - vertical text orientation: upright for horizontal scripts, intrinsic for vertical scripts
3647// Vertical text orientation for individual characters
3648#[derive(Debug, Clone, Copy, PartialEq, Default, Eq, PartialOrd, Ord, Hash)]
3649pub enum TextOrientation {
3650    #[default]
3651    Mixed, // Default: upright for scripts, rotated for others
3652    Upright,  // All characters upright
3653    Sideways, // All characters rotated 90 degrees
3654}
3655
3656#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
3657#[derive(Default)]
3658pub struct TextDecoration {
3659    pub underline: bool,
3660    pub strikethrough: bool,
3661    pub overline: bool,
3662}
3663
3664
3665impl TextDecoration {
3666    /// Convert from CSS `StyleTextDecoration` enum to our internal representation.
3667    /// 
3668    /// Note: CSS text-decoration can have multiple values (underline line-through),
3669    /// but the current azul-css parser only supports single values. This can be
3670    /// extended in the future if CSS parsing is updated.
3671    #[must_use] pub fn from_css(css: azul_css::props::style::text::StyleTextDecoration) -> Self {
3672        use azul_css::props::style::text::StyleTextDecoration;
3673        match css {
3674            StyleTextDecoration::None => Self::default(),
3675            StyleTextDecoration::Underline => Self {
3676                underline: true,
3677                strikethrough: false,
3678                overline: false,
3679            },
3680            StyleTextDecoration::Overline => Self {
3681                underline: false,
3682                strikethrough: false,
3683                overline: true,
3684            },
3685            StyleTextDecoration::LineThrough => Self {
3686                underline: false,
3687                strikethrough: true,
3688                overline: false,
3689            },
3690        }
3691    }
3692}
3693
3694#[derive(Debug, Clone, Copy, PartialEq, Hash, Eq, PartialOrd, Ord, Default)]
3695pub enum TextTransform {
3696    #[default]
3697    None,
3698    Uppercase,
3699    Lowercase,
3700    Capitalize,
3701    // only within preserved white space (non-preserved spaces already collapsed in Phase I)
3702    FullWidth,
3703}
3704
3705// Type alias for OpenType feature tags
3706pub type FourCc = [u8; 4];
3707
3708// Enum for relative or absolute spacing
3709#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
3710pub enum Spacing {
3711    Px(i32), // Whole-pixel spacing (kept for hashing/equality convenience)
3712    /// Sub-pixel resolved pixel spacing. `letter-spacing`/`word-spacing` accumulate
3713    /// once per glyph, so quantizing to whole pixels (the `Px(i32)` variant) multiplies
3714    /// the rounding error across a run. The CSS resolution path emits this variant to
3715    /// preserve the exact sub-pixel value (e.g. `letter-spacing: 0.4px`).
3716    PxF(f32),
3717    Em(f32),
3718}
3719
3720// A type that implements `Hash` must also implement `Eq`.
3721// Since f32 does not implement `Eq`, we provide a manual implementation.
3722// The derived `PartialEq` is sufficient for this marker trait.
3723impl Eq for Spacing {}
3724
3725impl Hash for Spacing {
3726    fn hash<H: Hasher>(&self, state: &mut H) {
3727        // First, hash the enum variant to distinguish between Px and Em.
3728        discriminant(self).hash(state);
3729        match self {
3730            Self::Px(val) => val.hash(state),
3731            // For hashing floats, convert them to their raw bit representation.
3732            // This ensures that identical float values produce identical hashes.
3733            Self::PxF(val) | Self::Em(val) => val.to_bits().hash(state),
3734        }
3735    }
3736}
3737
3738impl Default for Spacing {
3739    fn default() -> Self {
3740        Self::Px(0)
3741    }
3742}
3743
3744impl Spacing {
3745    /// Resolve this spacing to pixels given the element's font size (for `Em`).
3746    #[allow(clippy::cast_precision_loss)] // small integer px values; f32 mantissa is ample
3747    #[must_use]
3748    pub fn resolve_px(self, font_size_px: f32) -> f32 {
3749        match self {
3750            Self::Px(px) => px as f32,
3751            Self::PxF(px) => px,
3752            Self::Em(em) => em * font_size_px,
3753        }
3754    }
3755}
3756
3757impl Default for FontHash {
3758    fn default() -> Self {
3759        Self::invalid()
3760    }
3761}
3762
3763/// Style properties with vertical text support
3764#[derive(Debug, Clone, PartialEq)]
3765pub struct StyleProperties {
3766    /// Font stack for fallback support (priority order)
3767    /// Can be either a list of `FontSelectors` (resolved via fontconfig)
3768    /// or a direct `FontRef` (bypasses fontconfig entirely).
3769    pub font_stack: FontStack,
3770    pub font_size_px: f32,
3771    pub color: ColorU,
3772    /// Background color for inline elements (e.g., `<span style="background-color: yellow">`)
3773    ///
3774    /// This is propagated from CSS through the style system and eventually used by
3775    /// the PDF renderer to draw filled rectangles behind text. The value is `None`
3776    /// for transparent backgrounds (the default).
3777    ///
3778    /// The propagation chain is:
3779    /// CSS -> `get_style_properties()` -> `StyleProperties` -> `ShapedGlyph` -> `PdfGlyphRun`
3780    ///
3781    /// See `PdfGlyphRun::background_color` for how this is used in PDF rendering.
3782    pub background_color: Option<ColorU>,
3783    /// Full background content layers (for gradients, images, etc.)
3784    /// This extends `background_color` to support CSS gradients on inline elements.
3785    pub background_content: Vec<StyleBackgroundContent>,
3786    /// Border information for inline elements
3787    pub border: Option<InlineBorderInfo>,
3788    // +spec:text-alignment-spacing:b39a04 - word-spacing and letter-spacing control text spacing
3789    pub letter_spacing: Spacing,
3790    pub word_spacing: Spacing,
3791
3792    pub line_height: LineHeight,
3793    pub text_decoration: TextDecoration,
3794
3795    // Represents CSS font-feature-settings like `"liga"`, `"smcp=1"`.
3796    pub font_features: Vec<String>,
3797
3798    // Variable fonts
3799    pub font_variations: Vec<(FourCc, f32)>,
3800    // Multiplier of the space width
3801    pub tab_size: f32,
3802    // text-transform
3803    pub text_transform: TextTransform,
3804    // Vertical text properties
3805    pub writing_mode: WritingMode,
3806    pub text_orientation: TextOrientation,
3807    // Tate-chu-yoko
3808    pub text_combine_upright: Option<TextCombineUpright>,
3809
3810    // Variant handling
3811    pub font_variant_caps: FontVariantCaps,
3812    pub font_variant_numeric: FontVariantNumeric,
3813    pub font_variant_ligatures: FontVariantLigatures,
3814    pub font_variant_east_asian: FontVariantEastAsian,
3815
3816    /// The element's own `vertical-align` (baseline / sub / super / length / percentage).
3817    /// Read per shaped cluster by `get_item_vertical_align` so an inline `<span>` shifts
3818    /// its text relative to the line baseline. `Baseline` (the default) leaves the cluster
3819    /// on the line's default alignment.
3820    pub vertical_align: VerticalAlign,
3821}
3822
3823impl Default for StyleProperties {
3824    fn default() -> Self {
3825        const FONT_SIZE: f32 = 16.0;
3826        const TAB_SIZE: f32 = 8.0;
3827        Self {
3828            font_stack: FontStack::default(),
3829            font_size_px: FONT_SIZE,
3830            color: ColorU::default(),
3831            background_color: None,
3832            background_content: Vec::new(),
3833            border: None,
3834            letter_spacing: Spacing::default(), // Px(0)
3835            word_spacing: Spacing::default(),   // Px(0)
3836            line_height: LineHeight::Normal,
3837            text_decoration: TextDecoration::default(),
3838            font_features: Vec::new(),
3839            font_variations: Vec::new(),
3840            tab_size: TAB_SIZE, // CSS default
3841            text_transform: TextTransform::default(),
3842            writing_mode: WritingMode::default(),
3843            text_orientation: TextOrientation::default(),
3844            text_combine_upright: None,
3845            font_variant_caps: FontVariantCaps::default(),
3846            font_variant_numeric: FontVariantNumeric::default(),
3847            font_variant_ligatures: FontVariantLigatures::default(),
3848            font_variant_east_asian: FontVariantEastAsian::default(),
3849            vertical_align: VerticalAlign::Baseline,
3850        }
3851    }
3852}
3853
3854impl Hash for StyleProperties {
3855    #[allow(clippy::cast_possible_truncation)] // bounded pixel/coord/colour/glyph cast
3856    fn hash<H: Hasher>(&self, state: &mut H) {
3857        self.font_stack.hash(state);
3858        self.color.hash(state);
3859        self.background_color.hash(state);
3860        self.text_decoration.hash(state);
3861        self.font_features.hash(state);
3862        self.writing_mode.hash(state);
3863        self.text_orientation.hash(state);
3864        self.text_combine_upright.hash(state);
3865        self.vertical_align.hash(state);
3866        self.letter_spacing.hash(state);
3867        self.word_spacing.hash(state);
3868
3869        // For f32 fields, round and cast to usize before hashing.
3870        (self.font_size_px.round() as isize).hash(state);
3871        self.line_height.hash(state);
3872    }
3873}
3874
3875impl StyleProperties {
3876    /// Returns a hash that only includes properties that affect text layout.
3877    /// 
3878    /// Properties that DON'T affect layout (only rendering):
3879    /// - color, `background_color`, `background_content`
3880    /// - `text_decoration` (underline, etc.)
3881    /// - border (for inline elements)
3882    ///
3883    /// Properties that DO affect layout:
3884    /// - `font_stack`, `font_size_px`, `font_features`, `font_variations`
3885    /// - `letter_spacing`, `word_spacing`, `line_height`, `tab_size`
3886    /// - `writing_mode`, `text_orientation`, `text_combine_upright`
3887    /// - `text_transform`
3888    /// - `font_variant`_* (affects glyph selection)
3889    ///
3890    /// This allows the layout cache to reuse layouts when only rendering
3891    /// properties change (e.g., color changes on hover).
3892    // (family, weight, style) so that shaping runs break at element boundaries where font
3893    // properties differ, preventing impossible cross-boundary ligatures (e.g. "and" → "&").
3894    #[allow(clippy::cast_possible_truncation)] // bounded pixel/coord/colour/glyph cast
3895    #[must_use] pub fn layout_hash(&self) -> u64 {
3896        use std::hash::Hasher;
3897        let mut hasher = DefaultHasher::new();
3898
3899        // Font selection (affects shaping and metrics)
3900        self.font_stack.hash(&mut hasher);
3901        // Hash the EXACT font size bits, not a rounded integer: two styles differing
3902        // by <0.5px must not share a shaping-cache entry / coalesce, or one run gets
3903        // shaped at the other's size (wrong advances/metrics).
3904        self.font_size_px.to_bits().hash(&mut hasher);
3905        self.font_features.hash(&mut hasher);
3906        // font_variations affects glyph outlines
3907        for (tag, value) in &self.font_variations {
3908            tag.hash(&mut hasher);
3909            (value.round() as i32).hash(&mut hasher);
3910        }
3911        
3912        // Spacing (affects glyph positions)
3913        self.letter_spacing.hash(&mut hasher);
3914        self.word_spacing.hash(&mut hasher);
3915        self.line_height.hash(&mut hasher);
3916        (self.tab_size.round() as isize).hash(&mut hasher);
3917        
3918        // Writing mode (affects layout direction)
3919        self.writing_mode.hash(&mut hasher);
3920        self.text_orientation.hash(&mut hasher);
3921        self.text_combine_upright.hash(&mut hasher);
3922        
3923        // Text transform (affects which characters are used)
3924        self.text_transform.hash(&mut hasher);
3925        
3926        // Font variants (affect glyph selection)
3927        self.font_variant_caps.hash(&mut hasher);
3928        self.font_variant_numeric.hash(&mut hasher);
3929        self.font_variant_ligatures.hash(&mut hasher);
3930        self.font_variant_east_asian.hash(&mut hasher);
3931        
3932        hasher.finish()
3933    }
3934    
3935    /// Check if two `StyleProperties` have the same layout-affecting properties.
3936    ///
3937    /// Returns true if the layouts would be identical (only rendering differs).
3938    ///
3939    /// **Note:** This is a fast-path comparison using 64-bit hashes.  Hash
3940    /// collisions are theoretically possible, which could cause the cache to
3941    /// serve a stale layout.  In practice the probability is negligible for
3942    /// the number of distinct `StyleProperties` values in a single document.
3943    #[must_use] pub fn layout_eq(&self, other: &Self) -> bool {
3944        self.layout_hash() == other.layout_hash()
3945    }
3946}
3947
3948#[derive(Copy, Debug, Clone, PartialEq, Hash, Eq, PartialOrd, Ord)]
3949pub enum TextCombineUpright {
3950    None,
3951    All,        // Combine all characters in horizontal layout
3952    Digits(u8), // Combine up to N digits
3953}
3954
3955#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
3956pub enum GlyphSource {
3957    /// Glyph generated from a character in the source text.
3958    Char,
3959    /// Glyph inserted dynamically by the layout engine (e.g., a hyphen).
3960    Hyphen,
3961}
3962
3963#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3964pub enum CharacterClass {
3965    Space,       // Regular spaces - highest justification priority
3966    Punctuation, // Can sometimes be adjusted
3967    Letter,      // Normal letters
3968    Ideograph,   // CJK characters - can be justified between
3969    Symbol,      // Symbols, emojis
3970    Combining,   // Combining marks - never justified
3971}
3972
3973#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3974pub enum GlyphOrientation {
3975    Horizontal, // Keep horizontal (normal in horizontal text)
3976    Vertical,   // Rotate to vertical (normal in vertical text)
3977    Upright,    // Keep upright regardless of writing mode
3978    Mixed,      // Use script-specific default orientation
3979}
3980
3981// Bidi and script detection
3982#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
3983pub enum BidiDirection {
3984    Ltr,
3985    Rtl,
3986}
3987
3988impl BidiDirection {
3989    #[must_use] pub const fn is_rtl(&self) -> bool {
3990        matches!(self, Self::Rtl)
3991    }
3992}
3993
3994/// CSS `unicode-bidi` property values relevant to layout.
3995///
3996/// When `Plaintext`, the bidi algorithm uses P2/P3 heuristics to auto-detect
3997/// paragraph direction from text content, instead of the HL1 override from
3998/// the CSS `direction` property.
3999#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
4000#[derive(Default)]
4001pub enum UnicodeBidi {
4002    #[default]
4003    Normal,
4004    Embed,
4005    Isolate,
4006    BidiOverride,
4007    IsolateOverride,
4008    Plaintext,
4009}
4010
4011
4012#[derive(Debug, Clone, Copy, PartialEq, Hash, Eq, PartialOrd, Ord, Default)]
4013pub enum FontVariantCaps {
4014    #[default]
4015    Normal,
4016    SmallCaps,
4017    AllSmallCaps,
4018    PetiteCaps,
4019    AllPetiteCaps,
4020    Unicase,
4021    TitlingCaps,
4022}
4023
4024#[derive(Debug, Clone, Copy, PartialEq, Hash, Eq, PartialOrd, Ord, Default)]
4025pub enum FontVariantNumeric {
4026    #[default]
4027    Normal,
4028    LiningNums,
4029    OldstyleNums,
4030    ProportionalNums,
4031    TabularNums,
4032    DiagonalFractions,
4033    StackedFractions,
4034    Ordinal,
4035    SlashedZero,
4036}
4037
4038#[derive(Debug, Clone, Copy, PartialEq, Hash, Eq, PartialOrd, Ord, Default)]
4039pub enum FontVariantLigatures {
4040    #[default]
4041    Normal,
4042    None,
4043    Common,
4044    NoCommon,
4045    Discretionary,
4046    NoDiscretionary,
4047    Historical,
4048    NoHistorical,
4049    Contextual,
4050    NoContextual,
4051}
4052
4053#[derive(Debug, Clone, Copy, PartialEq, Hash, Eq, PartialOrd, Ord, Default)]
4054pub enum FontVariantEastAsian {
4055    #[default]
4056    Normal,
4057    Jis78,
4058    Jis83,
4059    Jis90,
4060    Jis04,
4061    Simplified,
4062    Traditional,
4063    FullWidth,
4064    ProportionalWidth,
4065    Ruby,
4066}
4067
4068#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
4069pub struct BidiLevel(u8);
4070
4071impl BidiLevel {
4072    #[must_use] pub const fn new(level: u8) -> Self {
4073        Self(level)
4074    }
4075    #[must_use] pub const fn is_rtl(&self) -> bool {
4076        self.0 % 2 == 1
4077    }
4078    #[must_use] pub const fn level(&self) -> u8 {
4079        self.0
4080    }
4081}
4082
4083// Add this new struct for style overrides
4084#[derive(Debug, Clone)]
4085pub struct StyleOverride {
4086    /// The specific character this override applies to.
4087    pub target: ContentIndex,
4088    /// The style properties to apply.
4089    /// Any `None` value means "inherit from the base style".
4090    pub style: PartialStyleProperties,
4091}
4092
4093#[derive(Debug, Clone, Default)]
4094pub struct PartialStyleProperties {
4095    pub font_stack: Option<FontStack>,
4096    pub font_size_px: Option<f32>,
4097    pub color: Option<ColorU>,
4098    pub letter_spacing: Option<Spacing>,
4099    pub word_spacing: Option<Spacing>,
4100    pub line_height: Option<LineHeight>,
4101    pub text_decoration: Option<TextDecoration>,
4102    pub font_features: Option<Vec<String>>,
4103    pub font_variations: Option<Vec<(FourCc, f32)>>,
4104    pub tab_size: Option<f32>,
4105    pub text_transform: Option<TextTransform>,
4106    pub writing_mode: Option<WritingMode>,
4107    pub text_orientation: Option<TextOrientation>,
4108    pub text_combine_upright: Option<Option<TextCombineUpright>>,
4109    pub font_variant_caps: Option<FontVariantCaps>,
4110    pub font_variant_numeric: Option<FontVariantNumeric>,
4111    pub font_variant_ligatures: Option<FontVariantLigatures>,
4112    pub font_variant_east_asian: Option<FontVariantEastAsian>,
4113}
4114
4115impl Hash for PartialStyleProperties {
4116    fn hash<H: Hasher>(&self, state: &mut H) {
4117        self.font_stack.hash(state);
4118        self.font_size_px.map(f32::to_bits).hash(state);
4119        self.color.hash(state);
4120        self.letter_spacing.hash(state);
4121        self.word_spacing.hash(state);
4122        self.line_height.hash(state);
4123        self.text_decoration.hash(state);
4124        self.font_features.hash(state);
4125
4126        // Manual hashing for Vec<(FourCc, f32)>
4127        if let Some(v) = self.font_variations.as_ref() {
4128            for (tag, val) in v {
4129                tag.hash(state);
4130                val.to_bits().hash(state);
4131            }
4132        }
4133
4134        self.tab_size.map(f32::to_bits).hash(state);
4135        self.text_transform.hash(state);
4136        self.writing_mode.hash(state);
4137        self.text_orientation.hash(state);
4138        self.text_combine_upright.hash(state);
4139        self.font_variant_caps.hash(state);
4140        self.font_variant_numeric.hash(state);
4141        self.font_variant_ligatures.hash(state);
4142        self.font_variant_east_asian.hash(state);
4143    }
4144}
4145
4146impl PartialEq for PartialStyleProperties {
4147    fn eq(&self, other: &Self) -> bool {
4148        self.font_stack == other.font_stack &&
4149        self.font_size_px.map(f32::to_bits) == other.font_size_px.map(f32::to_bits) &&
4150        self.color == other.color &&
4151        self.letter_spacing == other.letter_spacing &&
4152        self.word_spacing == other.word_spacing &&
4153        self.line_height == other.line_height &&
4154        self.text_decoration == other.text_decoration &&
4155        self.font_features == other.font_features &&
4156        self.font_variations == other.font_variations && // Vec<(FourCc, f32)> is PartialEq
4157        self.tab_size.map(f32::to_bits) == other.tab_size.map(f32::to_bits) &&
4158        self.text_transform == other.text_transform &&
4159        self.writing_mode == other.writing_mode &&
4160        self.text_orientation == other.text_orientation &&
4161        self.text_combine_upright == other.text_combine_upright &&
4162        self.font_variant_caps == other.font_variant_caps &&
4163        self.font_variant_numeric == other.font_variant_numeric &&
4164        self.font_variant_ligatures == other.font_variant_ligatures &&
4165        self.font_variant_east_asian == other.font_variant_east_asian
4166    }
4167}
4168
4169impl Eq for PartialStyleProperties {}
4170
4171impl StyleProperties {
4172    fn apply_override(&self, partial: &PartialStyleProperties) -> Self {
4173        let mut new_style = self.clone();
4174        if let Some(val) = &partial.font_stack {
4175            new_style.font_stack = val.clone();
4176        }
4177        if let Some(val) = partial.font_size_px {
4178            new_style.font_size_px = val;
4179        }
4180        if let Some(val) = &partial.color {
4181            new_style.color = *val;
4182        }
4183        if let Some(val) = partial.letter_spacing {
4184            new_style.letter_spacing = val;
4185        }
4186        if let Some(val) = partial.word_spacing {
4187            new_style.word_spacing = val;
4188        }
4189        if let Some(val) = partial.line_height {
4190            new_style.line_height = val;
4191        }
4192        if let Some(val) = &partial.text_decoration {
4193            new_style.text_decoration = *val;
4194        }
4195        if let Some(val) = &partial.font_features {
4196            new_style.font_features.clone_from(val);
4197        }
4198        if let Some(val) = &partial.font_variations {
4199            new_style.font_variations.clone_from(val);
4200        }
4201        if let Some(val) = partial.tab_size {
4202            new_style.tab_size = val;
4203        }
4204        if let Some(val) = partial.text_transform {
4205            new_style.text_transform = val;
4206        }
4207        if let Some(val) = partial.writing_mode {
4208            new_style.writing_mode = val;
4209        }
4210        if let Some(val) = partial.text_orientation {
4211            new_style.text_orientation = val;
4212        }
4213        if let Some(val) = &partial.text_combine_upright {
4214            new_style.text_combine_upright.clone_from(val);
4215        }
4216        if let Some(val) = partial.font_variant_caps {
4217            new_style.font_variant_caps = val;
4218        }
4219        if let Some(val) = partial.font_variant_numeric {
4220            new_style.font_variant_numeric = val;
4221        }
4222        if let Some(val) = partial.font_variant_ligatures {
4223            new_style.font_variant_ligatures = val;
4224        }
4225        if let Some(val) = partial.font_variant_east_asian {
4226            new_style.font_variant_east_asian = val;
4227        }
4228        new_style
4229    }
4230}
4231
4232/// The kind of a glyph, used to distinguish characters from layout-inserted items.
4233#[derive(Debug, Clone, Copy, PartialEq)]
4234pub enum GlyphKind {
4235    /// A standard glyph representing one or more characters from the source text.
4236    Character,
4237    /// A hyphen glyph inserted by the line breaking algorithm.
4238    Hyphen,
4239    /// A `.notdef` glyph, indicating a character that could not be found in any font.
4240    NotDef,
4241    /// A Kashida justification glyph, inserted to stretch Arabic text.
4242    Kashida {
4243        /// The target width of the kashida.
4244        width: f32,
4245    },
4246}
4247
4248// --- Stage 1: Logical Representation ---
4249
4250// [g117 az-web-lift FIX] `#[repr(C, u8)]` (was repr(Rust)) — same disc-mis-lift class as InlineContent
4251// above. LogicalItem is matched in measure Stage-2 (`if let LogicalItem::Text`) + reorder_logical_items;
4252// a repr(Rust) niche disc mis-lifts on the web. Explicit u8 tag at offset 0 = a simple load the lift
4253// reads correctly. Internal to text3 (not FFI-exposed). LogicalItem::Object embeds InlineContent inline.
4254#[derive(Debug, Clone)]
4255#[repr(C, u8)]
4256pub enum LogicalItem {
4257    Text {
4258        /// A stable ID pointing back to the original source character.
4259        source: ContentIndex,
4260        /// The text of this specific logical item (often a single grapheme cluster).
4261        text: String,
4262        style: Arc<StyleProperties>,
4263        /// If this text is a list marker: whether it should be positioned outside
4264        /// (in the padding gutter) or inside (inline with content).
4265        /// None for non-marker content.
4266        marker_position_outside: Option<bool>,
4267        /// The DOM `NodeId` of the Text node this item originated from.
4268        /// None for generated content (list markers, `::before/::after`, etc.)
4269        source_node_id: Option<NodeId>,
4270    },
4271    // +spec:display-property:b1533f - text-combine-upright tate-chu-yoko horizontal-in-vertical composition
4272    /// Tate-chu-yoko: Run of text to be laid out horizontally within a vertical context.
4273    CombinedText {
4274        source: ContentIndex,
4275        text: String,
4276        style: Arc<StyleProperties>,
4277    },
4278    Ruby {
4279        source: ContentIndex,
4280        // For the stub, we simplify to strings. A full implementation
4281        // would need to handle Vec<LogicalItem> for both.
4282        base_text: String,
4283        ruby_text: String,
4284        style: Arc<StyleProperties>,
4285    },
4286    Object {
4287        /// A stable ID pointing back to the original source object.
4288        source: ContentIndex,
4289        /// The original non-text object.
4290        content: InlineContent,
4291    },
4292    Tab {
4293        source: ContentIndex,
4294        style: Arc<StyleProperties>,
4295    },
4296    Break {
4297        source: ContentIndex,
4298        break_info: InlineBreak,
4299    },
4300}
4301
4302impl Hash for LogicalItem {
4303    fn hash<H: Hasher>(&self, state: &mut H) {
4304        discriminant(self).hash(state);
4305        match self {
4306            Self::Text {
4307                source,
4308                text,
4309                style,
4310                marker_position_outside,
4311                source_node_id,
4312            } => {
4313                source.hash(state);
4314                text.hash(state);
4315                style.as_ref().hash(state); // Hash the content, not the Arc pointer
4316                marker_position_outside.hash(state);
4317                source_node_id.hash(state);
4318            }
4319            Self::CombinedText {
4320                source,
4321                text,
4322                style,
4323            } => {
4324                source.hash(state);
4325                text.hash(state);
4326                style.as_ref().hash(state);
4327            }
4328            Self::Ruby {
4329                source,
4330                base_text,
4331                ruby_text,
4332                style,
4333            } => {
4334                source.hash(state);
4335                base_text.hash(state);
4336                ruby_text.hash(state);
4337                style.as_ref().hash(state);
4338            }
4339            Self::Object { source, content } => {
4340                source.hash(state);
4341                content.hash(state);
4342            }
4343            Self::Tab { source, style } => {
4344                source.hash(state);
4345                style.as_ref().hash(state);
4346            }
4347            Self::Break { source, break_info } => {
4348                source.hash(state);
4349                break_info.hash(state);
4350            }
4351        }
4352    }
4353}
4354
4355// --- Stage 2: Visual Representation ---
4356
4357#[derive(Debug, Clone)]
4358pub struct VisualItem {
4359    /// A reference to the logical item this visual item originated from.
4360    /// A single `LogicalItem` can be split into multiple `VisualItems`.
4361    pub logical_source: LogicalItem,
4362    /// The Bidi embedding level for this item.
4363    pub bidi_level: BidiLevel,
4364    /// The script detected for this run, crucial for shaping.
4365    pub script: Script,
4366    /// The text content for this specific visual run.
4367    pub text: String,
4368    /// Byte offset of this visual run's `text` within its source logical run's
4369    /// text. When bidi splits one logical run into several visual runs, each
4370    /// shaped cluster's `start_byte_in_run` is produced relative to this visual
4371    /// run's `text`; adding `run_byte_offset` re-bases it to the logical run so
4372    /// cluster IDs stay unique and match caret/selection byte positions.
4373    pub run_byte_offset: usize,
4374}
4375
4376// --- Stage 3: Shaped Representation ---
4377
4378// [g118 az-web-lift FIX] `#[repr(C, u8)]` (was repr(Rust)) — same disc-mis-lift class as InlineContent
4379// + LogicalItem (g117). ShapedItem is matched in measure Stage-5 (`match item { ShapedItem::Cluster ..}`)
4380// + cloned/matched throughout shaping; a repr(Rust) niche disc mis-lifts on the web. Explicit u8 tag at
4381// offset 0 = a simple load the lift reads correctly. Internal to text3 (not FFI-exposed).
4382#[derive(Debug, Clone)]
4383#[repr(C, u8)]
4384pub enum ShapedItem {
4385    Cluster(ShapedCluster),
4386    /// A block of combined text (tate-chu-yoko) that is laid out
4387    // as a single unbreakable object.
4388    CombinedBlock {
4389        source: ContentIndex,
4390        /// The glyphs to be rendered horizontally within the vertical line.
4391        glyphs: ShapedGlyphVec,
4392        bounds: Rect,
4393        baseline_offset: f32,
4394    },
4395    Object {
4396        source: ContentIndex,
4397        bounds: Rect,
4398        baseline_offset: f32,
4399        // Store original object for rendering
4400        content: InlineContent,
4401    },
4402    Tab {
4403        source: ContentIndex,
4404        bounds: Rect,
4405    },
4406    Break {
4407        source: ContentIndex,
4408        break_info: InlineBreak,
4409    },
4410}
4411
4412impl ShapedItem {
4413    #[must_use] pub const fn as_cluster(&self) -> Option<&ShapedCluster> {
4414        match self {
4415            Self::Cluster(c) => Some(c),
4416            _ => None,
4417        }
4418    }
4419    /// Returns the bounding box of the item, relative to its own origin.
4420    ///
4421    /// The origin of the returned `Rect` is `(0,0)`, representing the top-left corner
4422    /// of the item's layout space before final positioning. The size represents the
4423    /// item's total advance (width in horizontal mode) and its line height (ascent + descent).
4424    #[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
4425    #[must_use] pub fn bounds(&self) -> Rect {
4426        match self {
4427            Self::Cluster(cluster) => {
4428                // The width of a text cluster is its total advance.
4429                let width = cluster.advance;
4430
4431                // The height is the sum of its ascent and descent, which defines its line box.
4432                // We use the existing helper function which correctly calculates this from font
4433                // metrics.
4434                let (ascent, descent) = get_item_vertical_metrics_approx(self);
4435                let height = ascent + descent;
4436
4437                Rect {
4438                    x: 0.0,
4439                    y: 0.0,
4440                    width,
4441                    height,
4442                }
4443            }
4444            // For atomic inline items like objects, combined blocks, and tabs,
4445            // their bounds have already been calculated during the shaping or measurement phase.
4446            Self::CombinedBlock { bounds, .. } => *bounds,
4447            Self::Object { bounds, .. } => *bounds,
4448            Self::Tab { bounds, .. } => *bounds,
4449
4450            // Breaks are control characters and have no visual geometry.
4451            Self::Break { .. } => Rect::default(), // A zero-sized rectangle.
4452        }
4453    }
4454}
4455
4456/// A group of glyphs that corresponds to one or more source characters (a cluster).
4457#[derive(Debug, Clone)]
4458pub struct ShapedCluster {
4459    /// The original text that this cluster was shaped from.
4460    /// This is crucial for correct hyphenation.
4461    pub text: String,
4462    /// The ID of the grapheme cluster this glyph cluster represents.
4463    pub source_cluster_id: GraphemeClusterId,
4464    /// The source `ContentIndex` for mapping back to logical items.
4465    pub source_content_index: ContentIndex,
4466    /// The DOM `NodeId` of the Text node this cluster originated from.
4467    /// None for generated content (list markers, `::before/::after`, etc.)
4468    pub source_node_id: Option<NodeId>,
4469    /// The glyphs that make up this cluster. `SmallVec<[T; 1]>` — inline
4470    /// single-glyph clusters (the common case for Latin text), spill to
4471    /// heap only for ligatures / combining marks.
4472    pub glyphs: ShapedGlyphVec,
4473    /// The total advance width (horizontal) or height (vertical) of the cluster.
4474    pub advance: f32,
4475    /// The direction of this cluster, inherited from its `VisualItem`.
4476    pub direction: BidiDirection,
4477    /// Font style of this cluster
4478    pub style: Arc<StyleProperties>,
4479    /// If this cluster is a list marker: whether it should be positioned outside
4480    /// (in the padding gutter) or inside (inline with content).
4481    /// None for non-marker content.
4482    pub marker_position_outside: Option<bool>,
4483    /// True if this is the first visual fragment of its inline box.
4484    /// Used for `box-decoration-break` and split inline border/padding.
4485    /// When an inline element wraps across lines, only the first fragment
4486    /// gets the start-edge border/padding.
4487    pub is_first_fragment: bool,
4488    /// True if this is the last visual fragment of its inline box.
4489    /// Only the last fragment gets the end-edge border/padding.
4490    pub is_last_fragment: bool,
4491}
4492
4493/// A single, shaped glyph with its essential metrics.
4494#[derive(Debug, Clone)]
4495pub struct ShapedGlyph {
4496    /// The kind of glyph this is (character, hyphen, etc.).
4497    pub kind: GlyphKind,
4498    /// Glyph ID inside of the font
4499    pub glyph_id: u16,
4500    /// The byte offset of this glyph's source character(s) within its cluster text.
4501    pub cluster_offset: u32,
4502    /// The horizontal advance for this glyph (for horizontal text) - this is the BASE advance
4503    /// from the font metrics, WITHOUT kerning applied
4504    pub advance: f32,
4505    /// The kerning adjustment for this glyph (positive = more space, negative = less space)
4506    /// This is separate from advance so we can position glyphs absolutely
4507    pub kerning: f32,
4508    /// The horizontal offset/bearing for this glyph
4509    pub offset: Point,
4510    /// The vertical advance for this glyph (for vertical text).
4511    pub vertical_advance: f32,
4512    /// The vertical offset/bearing for this glyph.
4513    pub vertical_offset: Point,
4514    pub script: Script,
4515    pub style: Arc<StyleProperties>,
4516    /// Hash of the font - use `LoadedFonts` to look up the actual font when needed
4517    pub font_hash: u64,
4518    /// Cached font metrics to avoid font lookup for common operations
4519    pub font_metrics: LayoutFontMetrics,
4520}
4521
4522impl ShapedGlyph {
4523    #[must_use] pub fn into_glyph_instance<T: ParsedFontTrait>(
4524        &self,
4525        writing_mode: WritingMode,
4526        loaded_fonts: &LoadedFonts<T>,
4527    ) -> GlyphInstance {
4528        let size = loaded_fonts
4529            .get_by_hash(self.font_hash)
4530            .and_then(|font| font.get_glyph_size(self.glyph_id, self.style.font_size_px))
4531            .unwrap_or_default();
4532
4533        let position = if writing_mode.is_advance_horizontal() {
4534            LogicalPosition {
4535                x: self.offset.x,
4536                y: self.offset.y,
4537            }
4538        } else {
4539            LogicalPosition {
4540                x: self.vertical_offset.x,
4541                y: self.vertical_offset.y,
4542            }
4543        };
4544
4545        GlyphInstance {
4546            index: u32::from(self.glyph_id),
4547            point: position,
4548            size,
4549        }
4550    }
4551
4552    /// Convert this `ShapedGlyph` into a `GlyphInstance` with an absolute position.
4553    /// This is used for display list generation where glyphs need their final page coordinates.
4554    #[must_use] pub fn into_glyph_instance_at<T: ParsedFontTrait>(
4555        &self,
4556        writing_mode: WritingMode,
4557        absolute_position: LogicalPosition,
4558        loaded_fonts: &LoadedFonts<T>,
4559    ) -> GlyphInstance {
4560        let size = loaded_fonts
4561            .get_by_hash(self.font_hash)
4562            .and_then(|font| font.get_glyph_size(self.glyph_id, self.style.font_size_px))
4563            .unwrap_or_default();
4564
4565        GlyphInstance {
4566            index: u32::from(self.glyph_id),
4567            point: absolute_position,
4568            size,
4569        }
4570    }
4571
4572    /// Convert this `ShapedGlyph` into a `GlyphInstance` with an absolute position.
4573    /// This version doesn't require fonts - it uses a default size.
4574    /// Use this when you don't need precise glyph bounds (e.g., display list generation).
4575    #[must_use] pub fn into_glyph_instance_at_simple(
4576        &self,
4577        _writing_mode: WritingMode,
4578        absolute_position: LogicalPosition,
4579    ) -> GlyphInstance {
4580        // Use font metrics to estimate size, or default to zero
4581        // The actual rendering will use the font directly
4582        GlyphInstance {
4583            index: u32::from(self.glyph_id),
4584            point: absolute_position,
4585            size: LogicalSize::default(),
4586        }
4587    }
4588}
4589
4590// --- Stage 4: Positioned Representation (Final Layout) ---
4591
4592#[derive(Debug, Clone)]
4593pub struct PositionedItem {
4594    pub item: ShapedItem,
4595    pub position: Point,
4596    pub line_index: usize,
4597}
4598
4599#[derive(Debug, Clone)]
4600pub struct UnifiedLayout {
4601    pub items: Vec<PositionedItem>,
4602    /// Information about content that did not fit.
4603    pub overflow: OverflowInfo,
4604}
4605
4606impl UnifiedLayout {
4607    /// Calculate the bounding box of all positioned items.
4608    /// This is computed on-demand rather than cached.
4609    #[must_use] pub fn bounds(&self) -> Rect {
4610        if self.items.is_empty() {
4611            return Rect::default();
4612        }
4613
4614        let mut min_x = f32::MAX;
4615        let mut min_y = f32::MAX;
4616        let mut max_x = f32::MIN;
4617        let mut max_y = f32::MIN;
4618
4619        for item in &self.items {
4620            let item_x = item.position.x;
4621            let item_y = item.position.y;
4622
4623            // Get item dimensions
4624            let item_bounds = item.item.bounds();
4625            let item_width = item_bounds.width;
4626            let item_height = item_bounds.height;
4627
4628            min_x = min_x.min(item_x);
4629            min_y = min_y.min(item_y);
4630            max_x = max_x.max(item_x + item_width);
4631            max_y = max_y.max(item_y + item_height);
4632        }
4633
4634        Rect {
4635            x: min_x,
4636            y: min_y,
4637            width: max_x - min_x,
4638            height: max_y - min_y,
4639        }
4640    }
4641
4642    #[must_use] pub const fn is_empty(&self) -> bool {
4643        self.items.is_empty()
4644    }
4645    #[must_use] pub fn first_baseline(&self) -> Option<f32> {
4646        self.items
4647            .iter()
4648            .find_map(|item| get_baseline_for_item(&item.item))
4649    }
4650
4651    #[must_use] pub fn last_baseline(&self) -> Option<f32> {
4652        self.items
4653            .iter()
4654            .rev()
4655            .find_map(|item| get_baseline_for_item(&item.item))
4656    }
4657
4658    /// Takes a point relative to the layout's origin and returns the closest
4659    /// logical cursor position.
4660    ///
4661    /// This is the unified hit-testing implementation. The old `hit_test_to_cursor`
4662    /// method is deprecated in favor of this one.
4663    #[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
4664    #[must_use] pub fn hittest_cursor(&self, point: LogicalPosition) -> Option<TextCursor> {
4665        if self.items.is_empty() {
4666            return None;
4667        }
4668
4669        // Find the closest cluster vertically and horizontally
4670        let mut closest_item_idx = 0;
4671        let mut closest_distance = f32::MAX;
4672
4673        for (idx, item) in self.items.iter().enumerate() {
4674            // Only consider cluster items for cursor placement
4675            if !matches!(item.item, ShapedItem::Cluster(_)) {
4676                continue;
4677            }
4678
4679            let item_bounds = item.item.bounds();
4680            let item_center_y = item.position.y + item_bounds.height / 2.0;
4681
4682            // Distance from click position to item center
4683            let vertical_distance = (point.y - item_center_y).abs();
4684
4685            // For horizontal distance, check if we're within the cluster bounds
4686            let horizontal_distance = if point.x < item.position.x {
4687                item.position.x - point.x
4688            } else if point.x > item.position.x + item_bounds.width {
4689                point.x - (item.position.x + item_bounds.width)
4690            } else {
4691                0.0 // Inside the cluster horizontally
4692            };
4693
4694            // Combined distance (prioritize vertical proximity)
4695            let distance = vertical_distance * 2.0 + horizontal_distance;
4696
4697            if distance < closest_distance {
4698                closest_distance = distance;
4699                closest_item_idx = idx;
4700            }
4701        }
4702
4703        // Get the closest cluster
4704        let closest_item = &self.items[closest_item_idx];
4705        let cluster = match &closest_item.item {
4706            ShapedItem::Cluster(c) => c,
4707            // Objects are treated as a single cluster for selection
4708            ShapedItem::Object { source, .. } | ShapedItem::CombinedBlock { source, .. } => {
4709                return Some(TextCursor {
4710                    cluster_id: GraphemeClusterId {
4711                        source_run: source.run_index,
4712                        start_byte_in_run: source.item_index,
4713                    },
4714                    affinity: if point.x
4715                        < closest_item.position.x + (closest_item.item.bounds().width / 2.0)
4716                    {
4717                        CursorAffinity::Leading
4718                    } else {
4719                        CursorAffinity::Trailing
4720                    },
4721                });
4722            }
4723            _ => return None,
4724        };
4725
4726        // Determine affinity based on which half of the cluster was clicked
4727        let cluster_mid_x = closest_item.position.x + cluster.advance / 2.0;
4728        let affinity = if point.x < cluster_mid_x {
4729            CursorAffinity::Leading
4730        } else {
4731            CursorAffinity::Trailing
4732        };
4733
4734        Some(TextCursor {
4735            cluster_id: cluster.source_cluster_id,
4736            affinity,
4737        })
4738    }
4739
4740    /// Given a logical selection range, returns a vector of visual rectangles
4741    /// that cover the selected text, in the layout's coordinate space.
4742    #[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
4743    #[must_use] pub fn get_selection_rects(&self, range: &SelectionRange) -> Vec<LogicalRect> {
4744        // 1. Build a map from the logical cluster ID to the visual PositionedItem for fast lookups.
4745        let mut cluster_map: HashMap<GraphemeClusterId, &PositionedItem> = HashMap::new();
4746        for item in &self.items {
4747            if let Some(cluster) = item.item.as_cluster() {
4748                cluster_map.insert(cluster.source_cluster_id, item);
4749            }
4750        }
4751
4752        // 2. Normalize the range to ensure start always logically precedes end.
4753        let (start_cursor, end_cursor) = if range.start.cluster_id > range.end.cluster_id
4754            || (range.start.cluster_id == range.end.cluster_id
4755                && range.start.affinity > range.end.affinity)
4756        {
4757            (range.end, range.start)
4758        } else {
4759            (range.start, range.end)
4760        };
4761
4762        // 3. Find the positioned items corresponding to the start and end of the selection.
4763        let Some(start_item) = cluster_map.get(&start_cursor.cluster_id) else {
4764            return Vec::new();
4765        };
4766        let Some(end_item) = cluster_map.get(&end_cursor.cluster_id) else {
4767            return Vec::new();
4768        };
4769
4770        let mut rects = Vec::new();
4771
4772        // Helper to get the absolute visual X coordinate of a cursor. The logical
4773        // start (Leading) edge is the cluster's LEFT for LTR but its RIGHT for RTL;
4774        // Trailing is the mirror.
4775        let get_cursor_x = |item: &PositionedItem, affinity: CursorAffinity| -> f32 {
4776            let left = item.position.x;
4777            let right = item.position.x + get_item_measure(&item.item, false);
4778            let rtl = item.item.as_cluster().is_some_and(|c| c.direction.is_rtl());
4779            match (affinity, rtl) {
4780                (CursorAffinity::Leading, false) | (CursorAffinity::Trailing, true) => left,
4781                (CursorAffinity::Trailing, false) | (CursorAffinity::Leading, true) => right,
4782            }
4783        };
4784
4785        // Helper to get the visual bounding box of all content on a specific line index.
4786        let get_line_bounds = |line_index: usize| -> Option<LogicalRect> {
4787            let items_on_line = self.items.iter().filter(|i| i.line_index == line_index);
4788
4789            let mut min_x: Option<f32> = None;
4790            let mut max_x: Option<f32> = None;
4791            let mut min_y: Option<f32> = None;
4792            let mut max_y: Option<f32> = None;
4793
4794            for item in items_on_line {
4795                // Skip items that don't take up space (like hard breaks)
4796                let item_bounds = item.item.bounds();
4797                if item_bounds.width <= 0.0 && item_bounds.height <= 0.0 {
4798                    continue;
4799                }
4800
4801                let item_x_end = item.position.x + item_bounds.width;
4802                let item_y_end = item.position.y + item_bounds.height;
4803
4804                min_x = Some(min_x.map_or(item.position.x, |mx| mx.min(item.position.x)));
4805                max_x = Some(max_x.map_or(item_x_end, |mx| mx.max(item_x_end)));
4806                min_y = Some(min_y.map_or(item.position.y, |my| my.min(item.position.y)));
4807                max_y = Some(max_y.map_or(item_y_end, |my| my.max(item_y_end)));
4808            }
4809
4810            if let (Some(min_x), Some(max_x), Some(min_y), Some(max_y)) =
4811                (min_x, max_x, min_y, max_y)
4812            {
4813                Some(LogicalRect {
4814                    origin: LogicalPosition { x: min_x, y: min_y },
4815                    size: LogicalSize {
4816                        width: max_x - min_x,
4817                        height: max_y - min_y,
4818                    },
4819                })
4820            } else {
4821                None
4822            }
4823        };
4824
4825        // 4. Handle single-line selection.
4826        if start_item.line_index == end_item.line_index {
4827            if let Some(line_bounds) = get_line_bounds(start_item.line_index) {
4828                // Walk the selected clusters in VISUAL order and group them into
4829                // segments by bidi direction + visual contiguity, emitting one rect
4830                // per segment. A single endpoint-to-endpoint span over-covers (and can
4831                // under-cover) bidi selections, whose logically-contiguous clusters are
4832                // NOT visually contiguous. Pure-LTR/RTL contiguous runs collapse to a
4833                // single rect, matching browser/CoreText behavior.
4834                let mut segments: Vec<(f32, f32, BidiDirection)> = Vec::new();
4835                for item in &self.items {
4836                    if item.line_index != start_item.line_index {
4837                        continue;
4838                    }
4839                    let Some(c) = item.item.as_cluster() else {
4840                        continue;
4841                    };
4842                    let id = c.source_cluster_id;
4843                    // A cluster is selected when it lies within the (affinity-aware)
4844                    // logical range: the start cluster is included only if the start
4845                    // cursor sits on its leading edge; the end cluster only if the end
4846                    // cursor sits on its trailing edge.
4847                    let after_start = id > start_cursor.cluster_id
4848                        || (id == start_cursor.cluster_id
4849                            && start_cursor.affinity == CursorAffinity::Leading);
4850                    let before_end = id < end_cursor.cluster_id
4851                        || (id == end_cursor.cluster_id
4852                            && end_cursor.affinity == CursorAffinity::Trailing);
4853                    if !(after_start && before_end) {
4854                        continue;
4855                    }
4856                    let x0 = item.position.x;
4857                    let x1 = item.position.x + get_item_measure(&item.item, false);
4858                    let (lo, hi) = (x0.min(x1), x0.max(x1));
4859                    if let Some(last) = segments.last_mut() {
4860                        let contiguous = lo <= last.1 + 0.5 && hi >= last.0 - 0.5;
4861                        if last.2 == c.direction && contiguous {
4862                            last.0 = last.0.min(lo);
4863                            last.1 = last.1.max(hi);
4864                            continue;
4865                        }
4866                    }
4867                    segments.push((lo, hi, c.direction));
4868                }
4869
4870                if segments.is_empty() {
4871                    // No glyph-bearing clusters (e.g. zero-advance selection):
4872                    // fall back to the endpoint span so a caret-width rect still shows.
4873                    let start_x = get_cursor_x(start_item, start_cursor.affinity);
4874                    let end_x = get_cursor_x(end_item, end_cursor.affinity);
4875                    rects.push(LogicalRect {
4876                        origin: LogicalPosition {
4877                            x: start_x.min(end_x),
4878                            y: line_bounds.origin.y,
4879                        },
4880                        size: LogicalSize {
4881                            width: (end_x - start_x).abs(),
4882                            height: line_bounds.size.height,
4883                        },
4884                    });
4885                } else {
4886                    for (lo, hi, _dir) in segments {
4887                        rects.push(LogicalRect {
4888                            origin: LogicalPosition {
4889                                x: lo,
4890                                y: line_bounds.origin.y,
4891                            },
4892                            size: LogicalSize {
4893                                width: hi - lo,
4894                                height: line_bounds.size.height,
4895                            },
4896                        });
4897                    }
4898                }
4899            }
4900        }
4901        // 5. Handle multi-line selection.
4902        else {
4903            // Rectangle for the start line (from the start cursor to the line's end
4904            // in READING order). For an LTR line that is rightward (to the line's
4905            // right content edge); for an RTL line it is leftward (to the left edge).
4906            if let Some(start_line_bounds) = get_line_bounds(start_item.line_index) {
4907                let start_x = get_cursor_x(start_item, start_cursor.affinity);
4908                let line_left = start_line_bounds.origin.x;
4909                let line_right = start_line_bounds.origin.x + start_line_bounds.size.width;
4910                let rtl = start_item.item.as_cluster().is_some_and(|c| c.direction.is_rtl());
4911                let (lo, hi) = if rtl { (line_left, start_x) } else { (start_x, line_right) };
4912                rects.push(LogicalRect {
4913                    origin: LogicalPosition {
4914                        x: lo,
4915                        y: start_line_bounds.origin.y,
4916                    },
4917                    size: LogicalSize {
4918                        width: hi - lo,
4919                        height: start_line_bounds.size.height,
4920                    },
4921                });
4922            }
4923
4924            // Rectangles for all full lines in between.
4925            for line_idx in (start_item.line_index + 1)..end_item.line_index {
4926                if let Some(line_bounds) = get_line_bounds(line_idx) {
4927                    rects.push(line_bounds);
4928                }
4929            }
4930
4931            // Rectangle for the end line (from the line's start in READING order to
4932            // the end cursor). For an LTR line that starts at the left content edge;
4933            // for an RTL line it starts at the right edge.
4934            if let Some(end_line_bounds) = get_line_bounds(end_item.line_index) {
4935                let end_x = get_cursor_x(end_item, end_cursor.affinity);
4936                let line_left = end_line_bounds.origin.x;
4937                let line_right = end_line_bounds.origin.x + end_line_bounds.size.width;
4938                let rtl = end_item.item.as_cluster().is_some_and(|c| c.direction.is_rtl());
4939                let (lo, hi) = if rtl { (end_x, line_right) } else { (line_left, end_x) };
4940                rects.push(LogicalRect {
4941                    origin: LogicalPosition {
4942                        x: lo,
4943                        y: end_line_bounds.origin.y,
4944                    },
4945                    size: LogicalSize {
4946                        width: hi - lo,
4947                        height: end_line_bounds.size.height,
4948                    },
4949                });
4950            }
4951        }
4952
4953        rects
4954    }
4955
4956    /// Calculates the visual rectangle for a cursor at a given logical position.
4957    #[must_use] pub fn get_cursor_rect(&self, cursor: &TextCursor) -> Option<LogicalRect> {
4958        // Find the item and glyph corresponding to the cursor's cluster ID.
4959        let mut last_cluster: Option<(&PositionedItem, &ShapedCluster)> = None;
4960        for item in &self.items {
4961            if let ShapedItem::Cluster(cluster) = &item.item {
4962                if cluster.source_cluster_id == cursor.cluster_id {
4963                    // Exact match
4964                    let line_height = item.item.bounds().height;
4965                    // The logical-start (Leading) caret edge is the glyph's LEFT side for
4966                    // an LTR cluster but its RIGHT side for an RTL cluster; Trailing is the
4967                    // mirror. Resolve the edges from the cluster's own bidi direction.
4968                    let (lead_x, trail_x) = if cluster.direction.is_rtl() {
4969                        (item.position.x + cluster.advance, item.position.x)
4970                    } else {
4971                        (item.position.x, item.position.x + cluster.advance)
4972                    };
4973                    let cursor_x = match cursor.affinity {
4974                        CursorAffinity::Leading => lead_x,
4975                        CursorAffinity::Trailing => trail_x,
4976                    };
4977                    return Some(LogicalRect {
4978                        origin: LogicalPosition {
4979                            x: cursor_x,
4980                            y: item.position.y,
4981                        },
4982                        size: LogicalSize {
4983                            width: 1.0,
4984                            height: line_height,
4985                        },
4986                    });
4987                }
4988                last_cluster = Some((item, cluster));
4989            }
4990        }
4991        // Cursor past end of text: position after the last cluster
4992        if let Some((item, cluster)) = last_cluster {
4993            if cursor.cluster_id.source_run == cluster.source_cluster_id.source_run
4994                && cursor.cluster_id.start_byte_in_run >= cluster.source_cluster_id.start_byte_in_run
4995            {
4996                let line_height = item.item.bounds().height;
4997                // Past the logical end of the run: the caret sits after the last cluster,
4998                // which is its RIGHT edge for LTR but its LEFT edge for RTL.
4999                let past_end_x = if cluster.direction.is_rtl() {
5000                    item.position.x
5001                } else {
5002                    item.position.x + cluster.advance
5003                };
5004                return Some(LogicalRect {
5005                    origin: LogicalPosition {
5006                        x: past_end_x,
5007                        y: item.position.y,
5008                    },
5009                    size: LogicalSize {
5010                        width: 1.0,
5011                        height: line_height,
5012                    },
5013                });
5014            }
5015        }
5016        None
5017    }
5018
5019    /// Get a cursor at the first cluster (leading edge) in the layout.
5020    #[must_use] pub fn get_first_cluster_cursor(&self) -> Option<TextCursor> {
5021        for item in &self.items {
5022            if let ShapedItem::Cluster(cluster) = &item.item {
5023                return Some(TextCursor {
5024                    cluster_id: cluster.source_cluster_id,
5025                    affinity: CursorAffinity::Leading,
5026                });
5027            }
5028        }
5029        None
5030    }
5031
5032    /// Get a cursor at the last cluster (trailing edge) in the layout.
5033    #[must_use] pub fn get_last_cluster_cursor(&self) -> Option<TextCursor> {
5034        for item in self.items.iter().rev() {
5035            if let ShapedItem::Cluster(cluster) = &item.item {
5036                return Some(TextCursor {
5037                    cluster_id: cluster.source_cluster_id,
5038                    affinity: CursorAffinity::Trailing,
5039                });
5040            }
5041        }
5042        None
5043    }
5044
5045    /// Logical sequence of caret-stop grapheme clusters, sorted by
5046    /// `(source_run, start_byte_in_run)` and de-duplicated, with combining-mark
5047    /// continuations folded into their base (UAX#29). Left/right caret motion
5048    /// advances over THIS sequence so a base and its combining marks move as one
5049    /// unit, and so the document start/end are always reachable.
5050    fn grapheme_stops(&self) -> Vec<GraphemeClusterId> {
5051        let mut stops: Vec<(GraphemeClusterId, &str)> = self
5052            .items
5053            .iter()
5054            .filter_map(|it| {
5055                it.item
5056                    .as_cluster()
5057                    .map(|c| (c.source_cluster_id, c.text.as_str()))
5058            })
5059            .collect();
5060        stops.sort_by(|a, b| {
5061            (a.0.source_run, a.0.start_byte_in_run).cmp(&(b.0.source_run, b.0.start_byte_in_run))
5062        });
5063        stops.dedup_by_key(|(id, _)| *id);
5064        stops
5065            .into_iter()
5066            .filter(|(_, text)| !Self::cluster_is_grapheme_continuation(text))
5067            .map(|(id, _)| id)
5068            .collect()
5069    }
5070
5071    /// True if `text`'s leading char is a grapheme extender (combining mark,
5072    /// variation selector, …) — a cluster that merges into a preceding base and
5073    /// therefore must not be a standalone caret stop (UAX#29).
5074    fn cluster_is_grapheme_continuation(text: &str) -> bool {
5075        let Some(first) = text.chars().next() else {
5076            return false;
5077        };
5078        // Probe with a dummy base letter: if `x` + first collapses to a single
5079        // grapheme, `first` extends the preceding grapheme.
5080        let mut probe = String::with_capacity(1 + first.len_utf8());
5081        probe.push('x');
5082        probe.push(first);
5083        probe.graphemes(true).count() == 1
5084    }
5085
5086    /// Caret offset of `cursor` within `stops` (0..=len): the index of its
5087    /// grapheme, plus 1 for a Trailing affinity. A cursor addressing a folded
5088    /// combining mark (or otherwise between stops) maps to the nearest preceding
5089    /// stop.
5090    fn grapheme_caret_offset(stops: &[GraphemeClusterId], cursor: &TextCursor) -> Option<usize> {
5091        let trailing = usize::from(cursor.affinity == CursorAffinity::Trailing);
5092        if let Some(idx) = stops.iter().position(|id| *id == cursor.cluster_id) {
5093            return Some(idx + trailing);
5094        }
5095        let key = (cursor.cluster_id.source_run, cursor.cluster_id.start_byte_in_run);
5096        let idx = stops
5097            .iter()
5098            .rposition(|id| (id.source_run, id.start_byte_in_run) <= key)?;
5099        Some(idx + trailing)
5100    }
5101
5102    /// Canonical cursor for a grapheme-stop `offset` (0..=len): interior/first
5103    /// offsets are the Leading edge of the stop that begins there; `len` is the
5104    /// Trailing edge of the last stop (the document end).
5105    fn cursor_from_grapheme_offset(stops: &[GraphemeClusterId], offset: usize) -> TextCursor {
5106        let n = stops.len();
5107        if offset >= n {
5108            TextCursor { cluster_id: stops[n - 1], affinity: CursorAffinity::Trailing }
5109        } else {
5110            TextCursor { cluster_id: stops[offset], affinity: CursorAffinity::Leading }
5111        }
5112    }
5113
5114    /// Moves a cursor one visible position to the left (the previous grapheme
5115    /// boundary). Affinity is consulted so each press moves exactly one stop and
5116    /// the document start (first grapheme, Leading) is reachable; combining marks
5117    /// move together with their base.
5118    pub fn move_cursor_left(
5119        &self,
5120        cursor: TextCursor,
5121        debug: &mut Option<Vec<String>>,
5122    ) -> TextCursor {
5123        let stops = self.grapheme_stops();
5124        if stops.is_empty() {
5125            return cursor;
5126        }
5127        let Some(offset) = Self::grapheme_caret_offset(&stops, &cursor) else {
5128            return cursor;
5129        };
5130        let moved = Self::cursor_from_grapheme_offset(&stops, offset.saturating_sub(1));
5131        if let Some(d) = debug {
5132            d.push(format!(
5133                "[Cursor] move_cursor_left: byte {} -> byte {}",
5134                cursor.cluster_id.start_byte_in_run, moved.cluster_id.start_byte_in_run
5135            ));
5136        }
5137        moved
5138    }
5139
5140    /// Moves a cursor one visible position to the right (the next grapheme
5141    /// boundary). Affinity is consulted so each press moves exactly one stop and
5142    /// the document end (last grapheme, Trailing) is reachable; combining marks
5143    /// move together with their base.
5144    pub fn move_cursor_right(
5145        &self,
5146        cursor: TextCursor,
5147        debug: &mut Option<Vec<String>>,
5148    ) -> TextCursor {
5149        let stops = self.grapheme_stops();
5150        if stops.is_empty() {
5151            return cursor;
5152        }
5153        let Some(offset) = Self::grapheme_caret_offset(&stops, &cursor) else {
5154            return cursor;
5155        };
5156        let moved = Self::cursor_from_grapheme_offset(&stops, (offset + 1).min(stops.len()));
5157        if let Some(d) = debug {
5158            d.push(format!(
5159                "[Cursor] move_cursor_right: byte {} -> byte {}",
5160                cursor.cluster_id.start_byte_in_run, moved.cluster_id.start_byte_in_run
5161            ));
5162        }
5163        moved
5164    }
5165
5166    /// Moves a cursor up one line, attempting to preserve the horizontal column.
5167    pub fn move_cursor_up(
5168        &self,
5169        cursor: TextCursor,
5170        goal_x: &mut Option<f32>,
5171        debug: &mut Option<Vec<String>>,
5172    ) -> TextCursor {
5173        if let Some(d) = debug {
5174            d.push(format!(
5175                "[Cursor] move_cursor_up: from byte {} (affinity {:?})",
5176                cursor.cluster_id.start_byte_in_run, cursor.affinity
5177            ));
5178        }
5179
5180        let Some(current_item) = self.items.iter().find(|i| {
5181            i.item
5182                .as_cluster()
5183                .is_some_and(|c| c.source_cluster_id == cursor.cluster_id)
5184        }) else {
5185            if let Some(d) = debug {
5186                d.push(format!(
5187                    "[Cursor] move_cursor_up: cursor not found in items, staying at byte {}",
5188                    cursor.cluster_id.start_byte_in_run
5189                ));
5190            }
5191            return cursor;
5192        };
5193
5194        if let Some(d) = debug {
5195            d.push(format!(
5196                "[Cursor] move_cursor_up: current line {}, position ({}, {})",
5197                current_item.line_index, current_item.position.x, current_item.position.y
5198            ));
5199        }
5200
5201        let target_line_idx = current_item.line_index.saturating_sub(1);
5202        if current_item.line_index == target_line_idx {
5203            if let Some(d) = debug {
5204                d.push(format!(
5205                    "[Cursor] move_cursor_up: already at top line {}, staying put",
5206                    current_item.line_index
5207                ));
5208            }
5209            return cursor;
5210        }
5211
5212        let current_x = goal_x.unwrap_or_else(|| {
5213            let x = match cursor.affinity {
5214                CursorAffinity::Leading => current_item.position.x,
5215                CursorAffinity::Trailing => {
5216                    current_item.position.x + get_item_measure(&current_item.item, false)
5217                }
5218            };
5219            *goal_x = Some(x);
5220            x
5221        });
5222
5223        // Find the Y coordinate of the middle of the target line
5224        let target_y = self
5225            .items
5226            .iter()
5227            .find(|i| i.line_index == target_line_idx)
5228            .map_or(current_item.position.y, |i| i.position.y + (i.item.bounds().height / 2.0));
5229
5230        if let Some(d) = debug {
5231            d.push(format!(
5232                "[Cursor] move_cursor_up: target line {target_line_idx}, hittesting at ({current_x}, {target_y})"
5233            ));
5234        }
5235
5236        let result = self
5237            .hittest_cursor(LogicalPosition {
5238                x: current_x,
5239                y: target_y,
5240            })
5241            .unwrap_or(cursor);
5242
5243        if let Some(d) = debug {
5244            d.push(format!(
5245                "[Cursor] move_cursor_up: result byte {} (affinity {:?})",
5246                result.cluster_id.start_byte_in_run, result.affinity
5247            ));
5248        }
5249
5250        result
5251    }
5252
5253    /// Moves a cursor down one line, attempting to preserve the horizontal column.
5254    pub fn move_cursor_down(
5255        &self,
5256        cursor: TextCursor,
5257        goal_x: &mut Option<f32>,
5258        debug: &mut Option<Vec<String>>,
5259    ) -> TextCursor {
5260        if let Some(d) = debug {
5261            d.push(format!(
5262                "[Cursor] move_cursor_down: from byte {} (affinity {:?})",
5263                cursor.cluster_id.start_byte_in_run, cursor.affinity
5264            ));
5265        }
5266
5267        let Some(current_item) = self.items.iter().find(|i| {
5268            i.item
5269                .as_cluster()
5270                .is_some_and(|c| c.source_cluster_id == cursor.cluster_id)
5271        }) else {
5272            if let Some(d) = debug {
5273                d.push(format!(
5274                    "[Cursor] move_cursor_down: cursor not found in items, staying at byte {}",
5275                    cursor.cluster_id.start_byte_in_run
5276                ));
5277            }
5278            return cursor;
5279        };
5280
5281        if let Some(d) = debug {
5282            d.push(format!(
5283                "[Cursor] move_cursor_down: current line {}, position ({}, {})",
5284                current_item.line_index, current_item.position.x, current_item.position.y
5285            ));
5286        }
5287
5288        let max_line = self.items.iter().map(|i| i.line_index).max().unwrap_or(0);
5289        let target_line_idx = (current_item.line_index + 1).min(max_line);
5290        if current_item.line_index == target_line_idx {
5291            if let Some(d) = debug {
5292                d.push(format!(
5293                    "[Cursor] move_cursor_down: already at bottom line {}, staying put",
5294                    current_item.line_index
5295                ));
5296            }
5297            return cursor;
5298        }
5299
5300        let current_x = goal_x.unwrap_or_else(|| {
5301            let x = match cursor.affinity {
5302                CursorAffinity::Leading => current_item.position.x,
5303                CursorAffinity::Trailing => {
5304                    current_item.position.x + get_item_measure(&current_item.item, false)
5305                }
5306            };
5307            *goal_x = Some(x);
5308            x
5309        });
5310
5311        let target_y = self
5312            .items
5313            .iter()
5314            .find(|i| i.line_index == target_line_idx)
5315            .map_or(current_item.position.y, |i| i.position.y + (i.item.bounds().height / 2.0));
5316
5317        if let Some(d) = debug {
5318            d.push(format!(
5319                "[Cursor] move_cursor_down: hit testing at ({current_x}, {target_y})"
5320            ));
5321        }
5322
5323        let result = self
5324            .hittest_cursor(LogicalPosition {
5325                x: current_x,
5326                y: target_y,
5327            })
5328            .unwrap_or(cursor);
5329
5330        if let Some(d) = debug {
5331            d.push(format!(
5332                "[Cursor] move_cursor_down: result byte {}, affinity {:?}",
5333                result.cluster_id.start_byte_in_run, result.affinity
5334            ));
5335        }
5336
5337        result
5338    }
5339
5340    /// Moves a cursor to the visual start of its current line.
5341    pub fn move_cursor_to_line_start(
5342        &self,
5343        cursor: TextCursor,
5344        debug: &mut Option<Vec<String>>,
5345    ) -> TextCursor {
5346        if let Some(d) = debug {
5347            d.push(format!(
5348                "[Cursor] move_cursor_to_line_start: starting at byte {}, affinity {:?}",
5349                cursor.cluster_id.start_byte_in_run, cursor.affinity
5350            ));
5351        }
5352
5353        let Some(current_item) = self.items.iter().find(|i| {
5354            i.item
5355                .as_cluster()
5356                .is_some_and(|c| c.source_cluster_id == cursor.cluster_id)
5357        }) else {
5358            if let Some(d) = debug {
5359                d.push(format!(
5360                    "[Cursor] move_cursor_to_line_start: cursor not found, staying at byte {}",
5361                    cursor.cluster_id.start_byte_in_run
5362                ));
5363            }
5364            return cursor;
5365        };
5366
5367        if let Some(d) = debug {
5368            d.push(format!(
5369                "[Cursor] move_cursor_to_line_start: current line {}, position ({}, {})",
5370                current_item.line_index, current_item.position.x, current_item.position.y
5371            ));
5372        }
5373
5374        let first_item_on_line = self
5375            .items
5376            .iter()
5377            .filter(|i| i.line_index == current_item.line_index)
5378            .min_by(|a, b| {
5379                a.position
5380                    .x
5381                    .partial_cmp(&b.position.x)
5382                    .unwrap_or(Ordering::Equal)
5383            });
5384
5385        if let Some(item) = first_item_on_line {
5386            if let ShapedItem::Cluster(c) = &item.item {
5387                let result = TextCursor {
5388                    cluster_id: c.source_cluster_id,
5389                    affinity: CursorAffinity::Leading,
5390                };
5391                if let Some(d) = debug {
5392                    d.push(format!(
5393                        "[Cursor] move_cursor_to_line_start: result byte {}, affinity {:?}",
5394                        result.cluster_id.start_byte_in_run, result.affinity
5395                    ));
5396                }
5397                return result;
5398            }
5399        }
5400
5401        if let Some(d) = debug {
5402            d.push(format!(
5403                "[Cursor] move_cursor_to_line_start: no first item found, staying at byte {}",
5404                cursor.cluster_id.start_byte_in_run
5405            ));
5406        }
5407        cursor
5408    }
5409
5410    /// Moves a cursor to the visual end of its current line.
5411    pub fn move_cursor_to_line_end(
5412        &self,
5413        cursor: TextCursor,
5414        debug: &mut Option<Vec<String>>,
5415    ) -> TextCursor {
5416        if let Some(d) = debug {
5417            d.push(format!(
5418                "[Cursor] move_cursor_to_line_end: starting at byte {}, affinity {:?}",
5419                cursor.cluster_id.start_byte_in_run, cursor.affinity
5420            ));
5421        }
5422
5423        let Some(current_item) = self.items.iter().find(|i| {
5424            i.item
5425                .as_cluster()
5426                .is_some_and(|c| c.source_cluster_id == cursor.cluster_id)
5427        }) else {
5428            if let Some(d) = debug {
5429                d.push(format!(
5430                    "[Cursor] move_cursor_to_line_end: cursor not found, staying at byte {}",
5431                    cursor.cluster_id.start_byte_in_run
5432                ));
5433            }
5434            return cursor;
5435        };
5436
5437        if let Some(d) = debug {
5438            d.push(format!(
5439                "[Cursor] move_cursor_to_line_end: current line {}, position ({}, {})",
5440                current_item.line_index, current_item.position.x, current_item.position.y
5441            ));
5442        }
5443
5444        let last_item_on_line = self
5445            .items
5446            .iter()
5447            .filter(|i| i.line_index == current_item.line_index)
5448            .max_by(|a, b| {
5449                a.position
5450                    .x
5451                    .partial_cmp(&b.position.x)
5452                    .unwrap_or(Ordering::Equal)
5453            });
5454
5455        if let Some(item) = last_item_on_line {
5456            if let ShapedItem::Cluster(c) = &item.item {
5457                let result = TextCursor {
5458                    cluster_id: c.source_cluster_id,
5459                    affinity: CursorAffinity::Trailing,
5460                };
5461                if let Some(d) = debug {
5462                    d.push(format!(
5463                        "[Cursor] move_cursor_to_line_end: result byte {}, affinity {:?}",
5464                        result.cluster_id.start_byte_in_run, result.affinity
5465                    ));
5466                }
5467                return result;
5468            }
5469        }
5470
5471        if let Some(d) = debug {
5472            d.push(format!(
5473                "[Cursor] move_cursor_to_line_end: no last item found, staying at byte {}",
5474                cursor.cluster_id.start_byte_in_run
5475            ));
5476        }
5477        cursor
5478    }
5479
5480    /// Moves a cursor one word to the left (Ctrl+Left / Option+Left).
5481    ///
5482    /// Word boundaries use the shared [`is_word_char`] predicate (alphanumeric or
5483    /// underscore are word characters; whitespace AND punctuation are boundaries),
5484    /// so this agrees with double-click word selection. The cursor moves past any
5485    /// boundary clusters to the left, then past word clusters until the next
5486    /// boundary or start of text.
5487    pub fn move_cursor_to_prev_word(
5488        &self,
5489        cursor: TextCursor,
5490        debug: &mut Option<Vec<String>>,
5491    ) -> TextCursor {
5492        if let Some(d) = debug {
5493            d.push(format!(
5494                "[Cursor] move_cursor_to_prev_word: starting at byte {}, affinity {:?}",
5495                cursor.cluster_id.start_byte_in_run, cursor.affinity
5496            ));
5497        }
5498
5499        let Some(current_pos) = self.items.iter().position(|i| {
5500            i.item
5501                .as_cluster()
5502                .is_some_and(|c| c.source_cluster_id == cursor.cluster_id)
5503        }) else {
5504            return cursor;
5505        };
5506
5507        // Phase 1: Skip whitespace going left
5508        let mut pos = if cursor.affinity == CursorAffinity::Leading {
5509            // Already at leading edge, start from previous item
5510            current_pos.checked_sub(1)
5511        } else {
5512            // At trailing edge, start from current item
5513            Some(current_pos)
5514        };
5515
5516        // Skip boundary clusters (whitespace + punctuation)
5517        while let Some(p) = pos {
5518            if let Some(cluster) = self.items[p].item.as_cluster() {
5519                if !cluster_is_word_boundary(cluster) {
5520                    break;
5521                }
5522            }
5523            pos = p.checked_sub(1);
5524        }
5525
5526        // Phase 2: Skip word clusters going left (the word itself)
5527        while let Some(p) = pos {
5528            if let Some(cluster) = self.items[p].item.as_cluster() {
5529                if cluster_is_word_boundary(cluster) {
5530                    // We've reached a boundary before the word — stop at next cluster
5531                    if p + 1 < self.items.len() {
5532                        if let Some(c) = self.items[p + 1].item.as_cluster() {
5533                            return TextCursor {
5534                                cluster_id: c.source_cluster_id,
5535                                affinity: CursorAffinity::Leading,
5536                            };
5537                        }
5538                    }
5539                    break;
5540                }
5541            }
5542            if p == 0 {
5543                // Reached start of text — return first cluster
5544                if let Some(c) = self.items[0].item.as_cluster() {
5545                    return TextCursor {
5546                        cluster_id: c.source_cluster_id,
5547                        affinity: CursorAffinity::Leading,
5548                    };
5549                }
5550                break;
5551            }
5552            pos = p.checked_sub(1);
5553        }
5554
5555        // If we exhausted the search, go to first cluster
5556        if pos.is_none() {
5557            if let Some(first) = self.get_first_cluster_cursor() {
5558                return first;
5559            }
5560        }
5561
5562        cursor
5563    }
5564
5565    /// Moves a cursor one word to the right (Ctrl+Right / Option+Right).
5566    ///
5567    /// Word boundaries use the shared [`is_word_char`] predicate (alphanumeric or
5568    /// underscore are word characters; whitespace AND punctuation are boundaries),
5569    /// so this agrees with double-click word selection. The cursor moves past any
5570    /// word clusters, then past boundary clusters until the next word or end of text.
5571    pub fn move_cursor_to_next_word(
5572        &self,
5573        cursor: TextCursor,
5574        debug: &mut Option<Vec<String>>,
5575    ) -> TextCursor {
5576        if let Some(d) = debug {
5577            d.push(format!(
5578                "[Cursor] move_cursor_to_next_word: starting at byte {}, affinity {:?}",
5579                cursor.cluster_id.start_byte_in_run, cursor.affinity
5580            ));
5581        }
5582
5583        let Some(current_pos) = self.items.iter().position(|i| {
5584            i.item
5585                .as_cluster()
5586                .is_some_and(|c| c.source_cluster_id == cursor.cluster_id)
5587        }) else {
5588            return cursor;
5589        };
5590
5591        let len = self.items.len();
5592
5593        // Start position: if at leading edge, start from current; if trailing, start from next
5594        let start = if cursor.affinity == CursorAffinity::Trailing {
5595            current_pos + 1
5596        } else {
5597            current_pos
5598        };
5599
5600        if start >= len {
5601            return cursor;
5602        }
5603
5604        let mut pos = start;
5605
5606        // Phase 1: Skip word clusters (current word)
5607        while pos < len {
5608            if let Some(cluster) = self.items[pos].item.as_cluster() {
5609                if cluster_is_word_boundary(cluster) {
5610                    break;
5611                }
5612            }
5613            pos += 1;
5614        }
5615
5616        // Phase 2: Skip boundary clusters (whitespace + punctuation) after word
5617        while pos < len {
5618            if let Some(cluster) = self.items[pos].item.as_cluster() {
5619                if !cluster_is_word_boundary(cluster) {
5620                    // Found start of next word
5621                    return TextCursor {
5622                        cluster_id: cluster.source_cluster_id,
5623                        affinity: CursorAffinity::Leading,
5624                    };
5625                }
5626            }
5627            pos += 1;
5628        }
5629
5630        // Reached end of text
5631        if let Some(last) = self.get_last_cluster_cursor() {
5632            return last;
5633        }
5634
5635        cursor
5636    }
5637}
5638
5639#[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
5640fn get_baseline_for_item(item: &ShapedItem) -> Option<f32> {
5641    match item {
5642        ShapedItem::CombinedBlock {
5643            baseline_offset, ..
5644        } => Some(*baseline_offset),
5645        ShapedItem::Object {
5646            baseline_offset, ..
5647        } => Some(*baseline_offset),
5648        // We have to get the clusters font from the last glyph
5649        ShapedItem::Cluster(ref cluster) => {
5650            cluster.glyphs.last().map(|last_glyph| last_glyph
5651                        .font_metrics
5652                        .baseline_scaled(last_glyph.style.font_size_px))
5653        }
5654        ShapedItem::Break { source, break_info } => {
5655            // Breaks do not contribute to baseline
5656            None
5657        }
5658        ShapedItem::Tab { source, bounds } => {
5659            // Tabs do not contribute to baseline
5660            None
5661        }
5662    }
5663}
5664
5665/// Stores information about content that exceeded the available layout space.
5666#[derive(Debug, Clone, Default)]
5667pub struct OverflowInfo {
5668    /// The items that did not fit within the constraints.
5669    ///
5670    /// Currently always empty: the positioners place every item (visual overflow
5671    /// is clipped at paint time) rather than dropping content, so nothing is ever
5672    /// recorded here. The `window.rs` incremental-patch guard reads
5673    /// `overflow_items.is_empty()` to stay future-proof against a positioning path
5674    /// that *does* drop items. TODO(superplan): populate this if such a path lands.
5675    pub overflow_items: Vec<ShapedItem>,
5676    /// The total bounds of all positioned content, including any that overflows
5677    /// the constraints. Populated by both positioners (greedy + Knuth-Plass) from
5678    /// [`UnifiedLayout::bounds`]; useful for `OverflowBehavior::Visible`/`Scroll`.
5679    pub unclipped_bounds: Rect,
5680}
5681
5682impl OverflowInfo {
5683    #[must_use] pub const fn has_overflow(&self) -> bool {
5684        !self.overflow_items.is_empty()
5685    }
5686}
5687
5688/// Intermediate structure carrying information from the line breaker to the positioner.
5689#[derive(Debug, Clone)]
5690pub struct UnifiedLine {
5691    pub items: Vec<ShapedItem>,
5692    /// The y-position (for horizontal) or x-position (for vertical) of the line's baseline.
5693    pub cross_axis_position: f32,
5694    /// The geometric segments this line must fit into.
5695    pub constraints: LineConstraints,
5696    pub is_last: bool,
5697}
5698
5699// --- Caching Infrastructure ---
5700
5701pub type CacheId = u64;
5702
5703/// Defines a single area for layout, with its own shape and properties.
5704#[derive(Debug, Clone)]
5705pub struct LayoutFragment {
5706    /// A unique identifier for this fragment (e.g., "main-content", "sidebar").
5707    pub id: String,
5708    /// The geometric and style constraints for this specific fragment.
5709    pub constraints: UnifiedConstraints,
5710}
5711
5712/// Represents the final layout distributed across multiple fragments.
5713#[derive(Debug, Clone)]
5714pub(crate) struct FlowLayout {
5715    /// A map from a fragment's unique ID to the layout it contains.
5716    pub(crate) fragment_layouts: HashMap<String, Arc<UnifiedLayout>>,
5717    /// Any items that did not fit into the last fragment in the flow chain.
5718    /// This is useful for pagination or determining if more layout space is needed.
5719    pub(crate) remaining_items: Vec<ShapedItem>,
5720}
5721
5722/// Inline-axis intrinsic contributions derived from shaped text, without running
5723/// the line-breaking stage of the pipeline.
5724///
5725/// Callers that only need min/max-content widths for sizing (see
5726/// `calculate_ifc_root_intrinsic_sizes`) should prefer this over invoking
5727/// `layout_flow` twice with `AvailableSpace::MinContent`/`MaxContent`. The
5728/// latter runs the full flow loop — including `BreakCursor::peek_next_unit`,
5729/// which clones every `ShapedCluster` it inspects — even though no constraint
5730/// actually limits the line width.
5731#[derive(Copy, Debug, Clone, Default)]
5732pub struct IntrinsicTextSizes {
5733    /// CSS min-content = widest unbreakable unit (word) along the inline axis.
5734    pub min_content_width: f32,
5735    /// CSS max-content = sum of all advances along the inline axis (single line).
5736    pub max_content_width: f32,
5737    /// Height of a single line box: max(ascent + descent) across all items.
5738    pub max_content_height: f32,
5739}
5740
5741/// Cached line break boundaries from a previous layout pass.
5742///
5743/// Enables incremental relayout: when a word changes width,
5744/// we can check if it still fits on the same line without
5745/// re-running the full line-breaking algorithm.
5746#[derive(Clone, Debug)]
5747pub struct CachedLineBreaks {
5748    /// Per-line: (`first_item_idx`, `last_item_idx_exclusive`) into positioned items.
5749    pub line_ranges: Vec<(usize, usize)>,
5750    /// Per-line total width (sum of item advances on that line).
5751    pub line_widths: Vec<f32>,
5752    /// The available width constraint used when these breaks were computed.
5753    pub available_width: f32,
5754}
5755
5756/// Result of an incremental relayout attempt.
5757#[derive(Copy, Clone, Debug)]
5758pub enum IncrementalRelayoutResult {
5759    /// Glyphs changed but advance widths identical — swap in place, no repositioning.
5760    GlyphSwap,
5761    /// Width changed but still fits on same line — shift `x_offsets` of subsequent items.
5762    LineShift {
5763        /// Index of the first affected item.
5764        affected_item: usize,
5765        /// Width delta (`new_advance` - `old_advance`).
5766        delta: f32,
5767    },
5768    /// Line breaks changed — need to reflow from this line onward.
5769    PartialReflow {
5770        /// The line index from which to start reflowing.
5771        reflow_from_line: usize,
5772    },
5773    /// Cannot do incremental — fall back to full relayout.
5774    FullRelayout,
5775}
5776
5777/// Extract line break boundaries from a positioned items list.
5778#[must_use] pub fn extract_line_breaks(
5779    items: &[PositionedItem],
5780    available_width: f32,
5781) -> CachedLineBreaks {
5782    let mut line_ranges = Vec::new();
5783    let mut line_widths = Vec::new();
5784
5785    if items.is_empty() {
5786        return CachedLineBreaks { line_ranges, line_widths, available_width };
5787    }
5788
5789    let mut line_start = 0usize;
5790    let mut current_line = items[0].line_index;
5791    let mut line_width = 0.0f32;
5792
5793    for (i, item) in items.iter().enumerate() {
5794        if item.line_index != current_line {
5795            line_ranges.push((line_start, i));
5796            line_widths.push(line_width);
5797            line_start = i;
5798            current_line = item.line_index;
5799            line_width = 0.0;
5800        }
5801        line_width += get_item_measure(&item.item, false);
5802    }
5803
5804    // Final line
5805    line_ranges.push((line_start, items.len()));
5806    line_widths.push(line_width);
5807
5808    CachedLineBreaks { line_ranges, line_widths, available_width }
5809}
5810
5811/// Attempt incremental relayout given old metrics and new per-item advance widths.
5812///
5813/// `dirty_item_indices`: which items in the shaped list changed.
5814/// `old_advances`: per-item advance widths from the previous layout.
5815/// `new_advances`: per-item advance widths after reshaping.
5816/// `line_breaks`: cached line boundaries from previous layout.
5817#[must_use] pub fn try_incremental_relayout(
5818    dirty_item_indices: &[usize],
5819    old_advances: &[f32],
5820    new_advances: &[f32],
5821    line_breaks: &CachedLineBreaks,
5822) -> IncrementalRelayoutResult {
5823    if dirty_item_indices.is_empty() {
5824        return IncrementalRelayoutResult::GlyphSwap;
5825    }
5826
5827    // Check each dirty item
5828    for &dirty_idx in dirty_item_indices {
5829        if dirty_idx >= old_advances.len() || dirty_idx >= new_advances.len() {
5830            return IncrementalRelayoutResult::FullRelayout;
5831        }
5832
5833        let old_adv = old_advances[dirty_idx];
5834        let new_adv = new_advances[dirty_idx];
5835        let delta = new_adv - old_adv;
5836
5837        if delta.abs() < 0.001 {
5838            // Same width — just swap glyphs (GlyphSwap for this item)
5839            continue;
5840        }
5841
5842        // Width changed — find which line this item is on
5843        let line_idx = line_breaks.line_ranges.iter()
5844            .position(|&(start, end)| dirty_idx >= start && dirty_idx < end);
5845
5846        let Some(line_idx) = line_idx else {
5847            return IncrementalRelayoutResult::FullRelayout;
5848        };
5849
5850        let old_line_width = line_breaks.line_widths[line_idx];
5851        let new_line_width = old_line_width + delta;
5852
5853        if new_line_width <= line_breaks.available_width {
5854            // Still fits on same line — shift subsequent items
5855            return IncrementalRelayoutResult::LineShift {
5856                affected_item: dirty_idx,
5857                delta,
5858            };
5859        }
5860        // Overflows line — need to reflow from this line
5861        return IncrementalRelayoutResult::PartialReflow {
5862            reflow_from_line: line_idx,
5863        };
5864    }
5865
5866    // All dirty items had same width
5867    IncrementalRelayoutResult::GlyphSwap
5868}
5869
5870/// Cached shaped result for a single visual item (or coalesced group).
5871/// Enables per-item cache hits when only one word changes in a paragraph.
5872#[derive(Debug)]
5873pub(crate) struct PerItemShapedEntry {
5874    /// The shaped clusters for this single item/group.
5875    pub(crate) clusters: Vec<ShapedItem>,
5876    /// Sum of advance widths — for fast same-width detection during incremental relayout.
5877    pub(crate) total_advance: f32,
5878}
5879
5880#[derive(Debug)]
5881pub struct TextShapingCache {
5882    // Stage 1 Cache: InlineContent -> LogicalItems
5883    logical_items: HashMap<CacheId, Arc<Vec<LogicalItem>>>,
5884    // Stage 2 Cache: LogicalItems -> VisualItems
5885    visual_items: HashMap<CacheId, Arc<Vec<VisualItem>>>,
5886    // Stage 3 Cache: VisualItems -> ShapedItems (monolithic, for backward compat)
5887    shaped_items: HashMap<CacheId, Arc<Vec<ShapedItem>>>,
5888    // Stage 3b Cache: Per-item/coalesce-group shaped results
5889    // Key: hash(text, bidi_level, script, style.layout_hash())
5890    per_item_shaped: HashMap<u64, Arc<PerItemShapedEntry>>,
5891    /// Tracks which `per_item_shaped` keys were accessed in the current generation.
5892    per_item_accessed: HashSet<u64>,
5893    /// Current generation counter, incremented each layout pass.
5894    generation: u64,
5895}
5896
5897/// Approximate heap bytes retained by a [`TextShapingCache`].
5898#[derive(Copy, Debug, Clone, Default)]
5899pub struct TextCacheMemoryReport {
5900    pub logical_items_entries: usize,
5901    pub logical_items_bytes: usize,
5902    pub visual_items_entries: usize,
5903    pub visual_items_bytes: usize,
5904    pub shaped_items_entries: usize,
5905    pub shaped_items_bytes: usize,
5906    pub shaped_glyph_bytes: usize,
5907    pub shaped_cluster_text_bytes: usize,
5908    pub per_item_shaped_entries: usize,
5909    pub per_item_shaped_bytes: usize,
5910}
5911
5912impl TextCacheMemoryReport {
5913    #[must_use] pub const fn total_bytes(&self) -> usize {
5914        self.logical_items_bytes
5915            + self.visual_items_bytes
5916            + self.shaped_items_bytes
5917            + self.shaped_glyph_bytes
5918            + self.shaped_cluster_text_bytes
5919            + self.per_item_shaped_bytes
5920    }
5921}
5922
5923impl TextShapingCache {
5924    #[must_use] pub fn new() -> Self {
5925        Self {
5926            logical_items: HashMap::new(),
5927            visual_items: HashMap::new(),
5928            shaped_items: HashMap::new(),
5929            per_item_shaped: HashMap::new(),
5930            per_item_accessed: HashSet::new(),
5931            generation: 0,
5932        }
5933    }
5934
5935    /// Approximate per-stage heap-byte breakdown.
5936    #[allow(clippy::field_reassign_with_default)] // struct built incrementally / test setup; a struct literal is not clearer here
5937    #[must_use] pub fn memory_report(&self) -> TextCacheMemoryReport {
5938        let mut r = TextCacheMemoryReport::default();
5939        r.logical_items_entries = self.logical_items.len();
5940        for arc in self.logical_items.values() {
5941            r.logical_items_bytes += arc.capacity() * size_of::<LogicalItem>();
5942        }
5943        r.visual_items_entries = self.visual_items.len();
5944        for arc in self.visual_items.values() {
5945            r.visual_items_bytes += arc.capacity() * size_of::<VisualItem>();
5946        }
5947        r.shaped_items_entries = self.shaped_items.len();
5948        for arc in self.shaped_items.values() {
5949            r.shaped_items_bytes += arc.capacity() * size_of::<ShapedItem>();
5950            for item in arc.iter() {
5951                if let ShapedItem::Cluster(c) = item {
5952                    r.shaped_glyph_bytes += c.glyphs.capacity() * size_of::<ShapedGlyph>();
5953                    r.shaped_cluster_text_bytes += c.text.capacity();
5954                }
5955            }
5956        }
5957        r.per_item_shaped_entries = self.per_item_shaped.len();
5958        for arc in self.per_item_shaped.values() {
5959            r.per_item_shaped_bytes += arc.clusters.capacity() * size_of::<ShapedItem>();
5960            for item in &arc.clusters {
5961                if let ShapedItem::Cluster(c) = item {
5962                    r.per_item_shaped_bytes += c.glyphs.capacity() * size_of::<ShapedGlyph>();
5963                    r.per_item_shaped_bytes += c.text.capacity();
5964                }
5965            }
5966        }
5967        r
5968    }
5969
5970    /// Call at the start of each layout pass. Evicts per-item shaped entries
5971    /// not accessed in the previous generation to prevent unbounded growth.
5972    pub fn begin_generation(&mut self) {
5973        if self.generation > 0 && !self.per_item_accessed.is_empty() {
5974            // Evict entries not accessed in this generation
5975            let accessed = &self.per_item_accessed;
5976            self.per_item_shaped.retain(|k, _| accessed.contains(k));
5977        }
5978        self.per_item_accessed.clear();
5979        self.generation += 1;
5980    }
5981
5982    /// Check if we can reuse an old layout based on layout-affecting parameters.
5983    /// 
5984    /// This function compares only the parameters that affect glyph positions,
5985    /// not rendering-only parameters like color or text-decoration.
5986    /// 
5987    /// # Parameters
5988    /// - `old_constraints`: The constraints used for the cached layout
5989    /// - `new_constraints`: The constraints for the new layout request
5990    /// - `old_content`: The content used for the cached layout
5991    /// - `new_content`: The new content to layout
5992    /// 
5993    /// # Returns
5994    /// - `true` if the old layout can be reused (only rendering changed)
5995    /// - `false` if a new layout is needed (layout-affecting params changed)
5996    #[must_use] pub fn use_old_layout(
5997        old_constraints: &UnifiedConstraints,
5998        new_constraints: &UnifiedConstraints,
5999        old_content: &[InlineContent],
6000        new_content: &[InlineContent],
6001    ) -> bool {
6002        // First check: constraints must match exactly for layout purposes
6003        if old_constraints != new_constraints {
6004            return false;
6005        }
6006        
6007        // Second check: content length must match
6008        if old_content.len() != new_content.len() {
6009            return false;
6010        }
6011        
6012        // Third check: each content item must have same layout properties
6013        for (old, new) in old_content.iter().zip(new_content.iter()) {
6014            if !Self::inline_content_layout_eq(old, new) {
6015                return false;
6016            }
6017        }
6018        
6019        true
6020    }
6021    
6022    /// Compare two `InlineContent` items for layout equality.
6023    /// 
6024    /// Returns true if the layouts would be identical (only rendering differs).
6025    fn inline_content_layout_eq(old: &InlineContent, new: &InlineContent) -> bool {
6026        use InlineContent::{Text, Image, Space, LineBreak, Tab, Marker, Shape, Ruby};
6027        match (old, new) {
6028            (Text(old_run), Text(new_run)) => {
6029                // Text must match exactly, but style only needs layout_eq
6030                old_run.text == new_run.text 
6031                    && old_run.style.layout_eq(&new_run.style)
6032            }
6033            (Image(old_img), Image(new_img)) => {
6034                // Images: size affects layout, but not visual properties
6035                old_img.intrinsic_size == new_img.intrinsic_size
6036                    && old_img.display_size == new_img.display_size
6037                    && old_img.baseline_offset == new_img.baseline_offset
6038                    && old_img.alignment == new_img.alignment
6039            }
6040            (Space(old_sp), Space(new_sp)) => old_sp == new_sp,
6041            (LineBreak(old_br), LineBreak(new_br)) => old_br == new_br,
6042            (Tab { style: old_style }, Tab { style: new_style }) => old_style.layout_eq(new_style),
6043            (Marker { run: old_run, position_outside: old_pos },
6044             Marker { run: new_run, position_outside: new_pos }) => {
6045                old_pos == new_pos
6046                    && old_run.text == new_run.text
6047                    && old_run.style.layout_eq(&new_run.style)
6048            }
6049            (Shape(old_shape), Shape(new_shape)) => {
6050                // Shapes: shape_def affects layout, not fill/stroke
6051                old_shape.shape_def == new_shape.shape_def
6052                    && old_shape.baseline_offset == new_shape.baseline_offset
6053            }
6054            (Ruby { base: old_base, text: old_text, style: old_style },
6055             Ruby { base: new_base, text: new_text, style: new_style }) => {
6056                old_style.layout_eq(new_style)
6057                    && old_base.len() == new_base.len()
6058                    && old_text.len() == new_text.len()
6059                    && old_base.iter().zip(new_base.iter())
6060                        .all(|(o, n)| Self::inline_content_layout_eq(o, n))
6061                    && old_text.iter().zip(new_text.iter())
6062                        .all(|(o, n)| Self::inline_content_layout_eq(o, n))
6063            }
6064            // Different variants cannot have same layout
6065            _ => false,
6066        }
6067    }
6068}
6069
6070impl Default for TextShapingCache {
6071    fn default() -> Self {
6072        Self::new()
6073    }
6074}
6075
6076/// Key for caching the conversion from `InlineContent` to `LogicalItem`s.
6077#[derive(Debug, Clone, Eq, PartialEq, Hash)]
6078pub(crate) struct LogicalItemsKey<'a> {
6079    pub(crate) inline_content_hash: u64,
6080    pub(crate) default_font_size: u32,
6081    pub(crate) _marker: std::marker::PhantomData<&'a ()>,
6082}
6083
6084/// Key for caching the Bidi reordering stage.
6085#[derive(Debug, Clone, Eq, PartialEq, Hash)]
6086pub(crate) struct VisualItemsKey {
6087    pub(crate) logical_items_id: CacheId,
6088    pub(crate) base_direction: BidiDirection,
6089}
6090
6091/// Key for caching the shaping stage.
6092#[derive(Debug, Clone, Eq, PartialEq, Hash)]
6093pub(crate) struct ShapedItemsKey {
6094    pub(crate) visual_items_id: CacheId,
6095    pub(crate) style_hash: u64,
6096}
6097
6098impl ShapedItemsKey {
6099    pub(crate) fn new(visual_items_id: CacheId, visual_items: &[VisualItem]) -> Self {
6100        let style_hash = {
6101            let mut hasher = DefaultHasher::new();
6102            for item in visual_items {
6103                // Hash the style from the logical source, as this is what determines the font.
6104                match &item.logical_source {
6105                    LogicalItem::Text { style, .. } | LogicalItem::CombinedText { style, .. } => {
6106                        style.as_ref().hash(&mut hasher);
6107                    }
6108                    _ => {}
6109                }
6110            }
6111            hasher.finish()
6112        };
6113
6114        Self {
6115            visual_items_id,
6116            style_hash,
6117        }
6118    }
6119}
6120
6121/// Key for the final layout stage.
6122#[derive(Debug, Clone, Eq, PartialEq, Hash)]
6123pub(crate) struct LayoutKey {
6124    pub(crate) shaped_items_id: CacheId,
6125    pub(crate) constraints: UnifiedConstraints,
6126}
6127
6128/// Helper to create a `CacheId` from any `Hash`able type.
6129fn calculate_id<T: Hash>(item: &T) -> CacheId {
6130    let mut hasher = DefaultHasher::new();
6131    item.hash(&mut hasher);
6132    hasher.finish()
6133}
6134
6135// --- Main Layout Pipeline Implementation ---
6136
6137impl TextShapingCache {
6138    /// New top-level entry point for flowing layout across multiple regions.
6139    ///
6140    /// This function orchestrates the entire layout pipeline, but instead of fitting
6141    /// content into a single set of constraints, it flows the content through an
6142    /// ordered sequence of `LayoutFragment`s.
6143    ///
6144    /// # CSS Inline Layout Module Level 3: Pipeline Implementation
6145    ///
6146    /// This implements the inline formatting context with 5 stages:
6147    ///
6148    /// ## Stage 1: Logical Analysis (`InlineContent` -> `LogicalItem`)
6149    /// \u2705 IMPLEMENTED: Parses raw content into logical units
6150    /// - Handles text runs, inline-blocks, replaced elements
6151    /// - Applies style overrides at character level
6152    /// - Implements \u00a7 2.2: Content size contribution calculation
6153    ///
6154    /// ## Stage 2: `BiDi` Reordering (`LogicalItem` -> `VisualItem`)
6155    /// \u2705 IMPLEMENTED: Uses CSS 'direction' property per CSS Writing Modes
6156    /// - Reorders items for right-to-left text (Arabic, Hebrew)
6157    /// - Respects containing block direction (not auto-detection)
6158    /// - Conforms to Unicode `BiDi` Algorithm (UAX #9)
6159    ///
6160    /// ## Stage 3: Shaping (`VisualItem` -> `ShapedItem`)
6161    /// \u2705 IMPLEMENTED: Converts text to glyphs
6162    /// - Uses `HarfBuzz` for OpenType shaping
6163    /// - Handles ligatures, kerning, contextual forms
6164    /// - Caches shaped results for performance
6165    ///
6166    /// ## Stage 4: Text Orientation Transformations
6167    /// \u26a0\ufe0f PARTIAL: Applies text-orientation for vertical text
6168    /// - Uses constraints from *first* fragment only
6169    /// - \u274c TODO: Should re-orient if fragments have different writing modes
6170    ///
6171    /// ## Stage 5: Flow Loop (`ShapedItem` -> `PositionedItem`)
6172    /// \u2705 IMPLEMENTED: Breaks lines and positions content
6173    /// - Calls `perform_fragment_layout` for each fragment
6174    /// - Uses `BreakCursor` to flow content across fragments
6175    /// - Implements \u00a7 5: Line breaking and hyphenation
6176    ///
6177    /// # Missing Features from CSS Inline-3:
6178    /// - \u00a7 3.3: initial-letter (drop caps)
6179    /// - \u00a7 4: vertical-align (only baseline supported)
6180    /// - \u00a7 6: text-box-trim (leading trim)
6181    /// - \u00a7 7: inline-sizing (aspect-ratio for inline-blocks)
6182    ///
6183    /// # Arguments
6184    /// * `content` - The raw `InlineContent` to be laid out.
6185    /// * `style_overrides` - Character-level style changes.
6186    /// * `flow_chain` - An ordered slice of `LayoutFragment` defining the regions (e.g., columns,
6187    ///   pages) that the content should flow through.
6188    /// * `font_chain_cache` - Pre-resolved font chains (from `FontManager.font_chain_cache`)
6189    /// * `fc_cache` - The fontconfig cache for font lookups
6190    /// * `loaded_fonts` - Pre-loaded fonts, keyed by `FontId`
6191    ///
6192    /// # Returns
6193    /// A `FlowLayout` struct containing the positioned items for each fragment that
6194    /// was filled, and any content that did not fit in the final fragment.
6195    #[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
6196    /// # Panics
6197    ///
6198    /// Panics if bidi reordering of the logical items fails (an internal invariant).
6199    /// # Errors
6200    ///
6201    /// Returns a `LayoutError` if text flow layout fails.
6202    pub fn layout_flow<T: ParsedFontTrait>(
6203        &mut self,
6204        content: &[InlineContent],
6205        style_overrides: &[StyleOverride],
6206        flow_chain: &[LayoutFragment],
6207        font_chain_cache: &HashMap<FontChainKey, rust_fontconfig::FontFallbackChain>,
6208        fc_cache: &FcFontCache,
6209        loaded_fonts: &LoadedFonts<T>,
6210        debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
6211    ) -> Result<FlowLayout, LayoutError> {
6212        // [g150 az-web-lift DIAG] content data ptr (0x60BD0) + len (0x60BD4) at layout_flow ENTRY.
6213        #[cfg(feature = "web_lift")]
6214        unsafe {
6215            crate::az_mark((0x60BD0) as u32, (content.as_ptr() as usize as u32) as u32);
6216            crate::az_mark((0x60BD4) as u32, (content.len() as u32 | 0xC0DE0000) as u32);
6217        }
6218        // [g218 2026-06-09] The g158 `content.len()` force-materialize (a volatile read of content+16) is
6219        // DELETED: the within-fn SROA-to-0 of content.len() it worked around is now fixed (NEON-decoder +
6220        // volatile-guest-load transpiler work). VERIFIED: hello-world lays out without it — counter "5"
6221        // (label_wrapper 8,16,784,40) + button shape correctly, same rects as before. (The cross-FN Vec-*return*-
6222        // len mis-lift is a separate, still-present issue handled by the g127/g129/g130 out-param hacks — see
6223        // g134 marker: callee content.len=1 but the caller's return-read sees 0.)
6224        // --- Stages 1-3: Preparation ---
6225        // These stages are independent of the final geometry. We perform them once
6226        // on the entire content block before flowing. Caching is used at each stage.
6227
6228        // Cap per-item shaped cache to prevent unbounded growth.
6229        // When threshold is exceeded, evict entries not accessed this generation.
6230        const PER_ITEM_CACHE_MAX: usize = 4096;
6231        if self.per_item_shaped.len() > PER_ITEM_CACHE_MAX {
6232            self.begin_generation();
6233        }
6234
6235        // Stage 1: Logical Analysis (InlineContent -> LogicalItem)
6236        // [g213 2026-06-09] The web lift uses the real `self.logical_items` HashMap cache (NO bypass).
6237        // This entry() find-probe USED to spin forever on the lift (g178-g210 mis-diagnosed it many ways).
6238        // TRUE root cause: hashbrown's portable WIDTH=8 `Group::static_empty()` — `[0xFF; 8]` in libazul's
6239        // `__TEXT.__const` — was not mirrored into the wasm, so the empty-map ctrl-scan read 0x00, looked
6240        // ALL-FULL (EMPTY=0xFF), and the probe never terminated. FIXED entirely transpiler-side in
6241        // `dll/src/web/symbol_table.rs::compute_hashbrown_empty_group_ranges` (signature-scans `__const`
6242        // for >=8-byte 8-aligned 0xFF runs and mirrors them). Verified: web-nested-text lays out
6243        // ("Hello" at 8,16,800,20), __remill_error=0. No azul-source workaround needed here.
6244        let logical_items_id = calculate_id(&content);
6245        let logical_items = self
6246            .logical_items
6247            .entry(logical_items_id)
6248            .or_insert_with(|| {
6249                Arc::new(create_logical_items(content, style_overrides, debug_messages))
6250            })
6251            .clone();
6252
6253        // Get the first fragment's constraints to extract the CSS direction property.
6254        // This is used for BiDi reordering in Stage 2.
6255        let default_constraints = UnifiedConstraints::default();
6256        let first_constraints = flow_chain
6257            .first()
6258            .map_or(&default_constraints, |f| &f.constraints);
6259
6260        // +spec:containing-block:e7a271 - paragraph embedding level set from containing block's 'direction' property
6261        // +spec:display-property:7665cb - inline boxes split into multiple visual runs due to bidi text processing
6262        // +spec:display-property:929d6b - applies Unicode bidi algorithm to inline-level box sequences
6263        // +spec:display-property:e8584a - Apply Unicode bidi algorithm to inline-level box sequences per CSS Writing Modes §2.4
6264        // Stage 2: Bidi Reordering (LogicalItem -> VisualItem)
6265        // +spec:containing-block:961e3c - bidi paragraph level from containing block direction, not UAX9 heuristic
6266        // +spec:writing-modes:0a5368 - unicode-bidi: plaintext auto-detects direction from text content
6267        // Per CSS Writing Modes §8.3: when unicode-bidi is plaintext, the paragraph's
6268        // base direction is determined from text content (first strong character), ignoring
6269        // the containing block's direction property. Empty paragraphs fall back to
6270        // the containing block's direction.
6271        let unicode_bidi_val = first_constraints.unicode_bidi;
6272        let base_direction = if unicode_bidi_val == UnicodeBidi::Plaintext {
6273            // Auto-detect from text content; fall back to containing block direction
6274            let has_strong = logical_items.iter().any(|item| {
6275                if let LogicalItem::Text { text, .. } = item {
6276                    matches!(unicode_bidi::get_base_direction(text.as_str()),
6277                        unicode_bidi::Direction::Ltr | unicode_bidi::Direction::Rtl)
6278                } else {
6279                    false
6280                }
6281            });
6282            if has_strong {
6283                get_base_direction_from_logical(&logical_items)
6284            } else {
6285                // Empty paragraph: use containing block's direction
6286                first_constraints.direction.unwrap_or(BidiDirection::Ltr)
6287            }
6288        } else {
6289            // Normal case: use CSS direction property
6290            first_constraints.direction.unwrap_or(BidiDirection::Ltr)
6291        };
6292        let visual_key = VisualItemsKey {
6293            logical_items_id,
6294            base_direction,
6295        };
6296        let visual_items_id = calculate_id(&visual_key);
6297        // [g213] web lift uses the real visual_items HashMap cache (g180 bypass deleted; WIDTH=8
6298        // EMPTY_GROUP now mirrored — see Stage-1 note + symbol_table.rs).
6299        let visual_items = self
6300            .visual_items
6301            .entry(visual_items_id)
6302            .or_insert_with(|| {
6303                Arc::new(
6304                    reorder_logical_items(&logical_items, base_direction, unicode_bidi_val, debug_messages).unwrap(),
6305                )
6306            })
6307            .clone();
6308
6309        // Stage 3: Shaping (VisualItem -> ShapedItem)
6310        // Two-level cache: monolithic (fast path) + per-item (incremental path).
6311        let shaped_key = ShapedItemsKey::new(visual_items_id, &visual_items);
6312        let shaped_items_id = calculate_id(&shaped_key);
6313        // [g213] web lift uses the real shaped_items HashMap cache (g180 bypass deleted).
6314        let shaped_items = if let Some(cached) = self.shaped_items.get(&shaped_items_id) {
6315            // Monolithic cache hit — all visual items unchanged
6316            cached.clone()
6317        } else {
6318            // Monolithic miss — use per-item cache for incremental reshaping.
6319            // Items not in per-item cache are shaped; cached items are reused.
6320            let items = Arc::new(shape_visual_items_with_per_item_cache(
6321                &visual_items,
6322                &mut self.per_item_shaped,
6323                &mut self.per_item_accessed,
6324                font_chain_cache,
6325                fc_cache,
6326                loaded_fonts,
6327                debug_messages,
6328            )?);
6329            self.shaped_items.insert(shaped_items_id, items.clone());
6330            items
6331        };
6332
6333        // --- Stage 4: Apply Vertical Text Transformations ---
6334
6335        // Note: first_constraints was already extracted above for BiDi reordering (Stage 2).
6336        // This orients all text based on the constraints of the *first* fragment.
6337        // A more advanced system could defer orientation until inside the loop if
6338        // fragments can have different writing modes.
6339        let oriented_items = apply_text_orientation(shaped_items, first_constraints);
6340
6341        // --- Stage 5: The Flow Loop ---
6342        let mut fragment_layouts = HashMap::new();
6343        // The cursor now manages the stream of items for the entire flow.
6344        // §5.2 word-break: pass word_break from constraints to cursor
6345        let mut cursor = BreakCursor::with_word_break(&oriented_items, first_constraints.word_break);
6346        cursor.hyphens = first_constraints.hyphenation;
6347        cursor.line_break = first_constraints.line_break;
6348
6349        // [g147 az-web-lift] Hard safety bound on the Stage-5 flow loop. On the remill lift this
6350        // `for fragment in flow_chain` (or the `cursor.is_done()` break) mis-lifts for the NESTED IFC
6351        // and iterates without terminating → solveLayoutReal HANGS (fuel trap in layout_flow). The text
6352        // is fully laid out on the first iteration(s); cap the iterations so the loop always converges.
6353        // (native is unaffected — the cap is far above any real fragment count.)
6354        #[allow(clippy::no_effect_underscore_binding)] // web_lift-gated debug iteration counter
6355        let mut _az_flow_iters: usize = 0;
6356        for fragment in flow_chain {
6357            #[cfg(feature = "web_lift")]
6358            {
6359                _az_flow_iters += 1;
6360                unsafe { crate::az_mark((0x60BC0) as u32, (_az_flow_iters as u32 | 0xC0DE0000) as u32); }
6361                if _az_flow_iters > 256 {
6362                    break;
6363                }
6364            }
6365            // Perform layout for this single fragment, consuming items from the cursor.
6366            let fragment_layout = perform_fragment_layout(
6367                &mut cursor,
6368                &logical_items,
6369                &fragment.constraints,
6370                debug_messages,
6371                loaded_fonts,
6372            )?;
6373
6374            fragment_layouts.insert(fragment.id.clone(), Arc::new(fragment_layout));
6375            if cursor.is_done() {
6376                break; // All content has been laid out.
6377            }
6378        }
6379
6380        Ok(FlowLayout {
6381            fragment_layouts,
6382            remaining_items: cursor.drain_remaining(),
6383        })
6384    }
6385
6386    /// Runs stages 1–4 of the layout pipeline (logical analysis, `BiDi`, shaping,
6387    /// text orientation) and derives min/max-content widths by scanning the
6388    /// resulting `ShapedItem`s directly — without running stage 5's line-breaking
6389    /// `BreakCursor` loop.
6390    ///
6391    /// Used by `calculate_ifc_root_intrinsic_sizes` to avoid the 24% CPU spent
6392    /// cloning `ShapedCluster`s inside `BreakCursor::peek_next_unit` on every
6393    /// sizing pass. Since stages 1–3 hit the same `per_item_shaped` cache as
6394    /// `layout_flow`, a subsequent `layout_flow` call for the same content at
6395    /// a real container width is a pure cache hit for the shaping work.
6396    ///
6397    /// The item walk uses the same break-opportunity predicate that the
6398    /// `BreakCursor` would — min-content accumulates advances between break
6399    /// opportunities and tracks the maximum; max-content is the sum of all
6400    /// advances (as if the flow were laid out on a single infinitely-wide line).
6401    #[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
6402    /// # Panics
6403    ///
6404    /// Panics if bidi reordering of the logical items fails (an internal invariant).
6405    /// # Errors
6406    ///
6407    /// Returns a `LayoutError` if measuring intrinsic widths fails.
6408    pub fn measure_intrinsic_widths<T: ParsedFontTrait>(
6409        &mut self,
6410        content: &[InlineContent],
6411        style_overrides: &[StyleOverride],
6412        constraints: &UnifiedConstraints,
6413        font_chain_cache: &HashMap<FontChainKey, rust_fontconfig::FontFallbackChain>,
6414        fc_cache: &FcFontCache,
6415        loaded_fonts: &LoadedFonts<T>,
6416        debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
6417    ) -> Result<IntrinsicTextSizes, LayoutError> {
6418        const PER_ITEM_CACHE_MAX: usize = 4096;
6419        if self.per_item_shaped.len() > PER_ITEM_CACHE_MAX {
6420            self.begin_generation();
6421        }
6422
6423        // Stage 1: Logical Analysis (cached, same as layout_flow — the historic web-lift
6424        // bypass here was rooted in the un-mirrored hashbrown EMPTY_GROUP, fixed transpiler-side
6425        // in symbol_table.rs::compute_hashbrown_empty_group_ranges).
6426        let logical_items_id = calculate_id(&content);
6427        let logical_items = self
6428            .logical_items
6429            .entry(logical_items_id)
6430            .or_insert_with(|| {
6431                Arc::new(create_logical_items(content, style_overrides, debug_messages))
6432            })
6433            .clone();
6434
6435        // Stage 2: BiDi (same derivation as layout_flow)
6436        let unicode_bidi_val = constraints.unicode_bidi;
6437        let base_direction = if unicode_bidi_val == UnicodeBidi::Plaintext {
6438            let has_strong = logical_items.iter().any(|item| {
6439                if let LogicalItem::Text { text, .. } = item {
6440                    matches!(unicode_bidi::get_base_direction(text.as_str()),
6441                        unicode_bidi::Direction::Ltr | unicode_bidi::Direction::Rtl)
6442                } else {
6443                    false
6444                }
6445            });
6446            if has_strong {
6447                get_base_direction_from_logical(&logical_items)
6448            } else {
6449                constraints.direction.unwrap_or(BidiDirection::Ltr)
6450            }
6451        } else {
6452            constraints.direction.unwrap_or(BidiDirection::Ltr)
6453        };
6454        let visual_key = VisualItemsKey {
6455            logical_items_id,
6456            base_direction,
6457        };
6458        let visual_items_id = calculate_id(&visual_key);
6459        let visual_items = self
6460            .visual_items
6461            .entry(visual_items_id)
6462            .or_insert_with(|| {
6463                Arc::new(
6464                    reorder_logical_items(&logical_items, base_direction, unicode_bidi_val, debug_messages).unwrap(),
6465                )
6466            })
6467            .clone();
6468
6469        // Stage 3: Shaping (two-level cache, same as layout_flow)
6470        let shaped_key = ShapedItemsKey::new(visual_items_id, &visual_items);
6471        let shaped_items_id = calculate_id(&shaped_key);
6472        let shaped_items = if let Some(cached) = self.shaped_items.get(&shaped_items_id) { cached.clone() } else {
6473            let items = Arc::new(shape_visual_items_with_per_item_cache(
6474                &visual_items,
6475                &mut self.per_item_shaped,
6476                &mut self.per_item_accessed,
6477                font_chain_cache,
6478                fc_cache,
6479                loaded_fonts,
6480                debug_messages,
6481            )?);
6482            self.shaped_items.insert(shaped_items_id, items.clone());
6483            items
6484        };
6485
6486        // Stage 4: Text orientation
6487        let oriented_items = apply_text_orientation(shaped_items, constraints);
6488
6489        // Stage 5 bypass: scan items for min/max contributions.
6490        let word_break = constraints.word_break;
6491        let hyphens = constraints.hyphenation;
6492
6493        let mut total = 0.0f32;      // running width of the current line
6494        let mut max_line = 0.0f32;   // widest line between forced breaks = max-content
6495        let mut max_word = 0.0f32;
6496        let mut cur_word = 0.0f32;
6497        let mut max_line_height = 0.0f32;
6498
6499        for item in oriented_items.iter() {
6500            // A forced break (preserved LF, <br>) ends the current line. max-content
6501            // is the widest line BETWEEN forced breaks, not the running sum across
6502            // them — otherwise a white-space:pre block with newlines (or any <br>
6503            // content) over-measures its max-content as the concatenation of all
6504            // lines. Reset the line accumulators here.
6505            if let ShapedItem::Break { .. } = item {
6506                if total > max_line { max_line = total; }
6507                if cur_word > max_word { max_word = cur_word; }
6508                total = 0.0;
6509                cur_word = 0.0;
6510                continue;
6511            }
6512            // Must match get_item_measure() exactly: a cluster's inline advance
6513            // INCLUDES per-glyph kerning. Omitting kerning here under-measures
6514            // max-content, so a shrink-to-fit box (e.g. a flex item sized to its
6515            // text's max-content) ends up narrower than the kerned text the line
6516            // breaker lays out — the word then "overflows" its own box and, with
6517            // overflow-wrap:normal, gets force-broken to its first cluster
6518            // (the menubar "View" → "V" clip). Summing (advance + kerning) here,
6519            // in the same order as the breaker, makes the box exactly fit.
6520            let advance = match item {
6521                ShapedItem::Cluster(c) => {
6522                    let total_kerning: f32 = c.glyphs.iter().map(|g| g.kerning).sum();
6523                    let mut a = c.advance + total_kerning;
6524                    // Match position_line_items exactly: letter-spacing is added after
6525                    // every non-cursive cluster and word-spacing on word separators.
6526                    // Omitting them here under-measures a shrink-to-fit box, so the
6527                    // laid-out (spaced) text overflows its own min/max-content width.
6528                    if !is_cursive_script_cluster(c) {
6529                        a += c.style.letter_spacing.resolve_px(c.style.font_size_px);
6530                    }
6531                    if is_word_separator(item) {
6532                        a += c.style.word_spacing.resolve_px(c.style.font_size_px);
6533                    }
6534                    a
6535                }
6536                ShapedItem::CombinedBlock { bounds, .. }
6537                | ShapedItem::Object { bounds, .. }
6538                | ShapedItem::Tab { bounds, .. } => bounds.width,
6539                ShapedItem::Break { .. } => 0.0,
6540            };
6541            let adv = advance.max(0.0);
6542            total += adv;
6543
6544            let (asc, desc) = get_item_vertical_metrics_approx(item);
6545            let h = (asc + desc).max(item.bounds().height);
6546            if h > max_line_height {
6547                max_line_height = h;
6548            }
6549
6550            if is_break_opportunity_with_word_break(item, word_break, hyphens) {
6551                if cur_word > max_word {
6552                    max_word = cur_word;
6553                }
6554                // A break opportunity that is itself a rendered unit (a CJK
6555                // ideograph in normal mode, or any cluster under break-all /
6556                // overflow-wrap:anywhere) still forms a minimal unbreakable unit
6557                // of its own advance; only true separators (spaces) contribute 0.
6558                // Without this, pure-CJK / break-all text measures min-content = 0
6559                // and the box collapses to zero inline width.
6560                if !is_word_separator(item) && adv > max_word {
6561                    max_word = adv;
6562                }
6563                cur_word = 0.0;
6564            } else {
6565                cur_word += adv;
6566            }
6567        }
6568        if cur_word > max_word {
6569            max_word = cur_word;
6570        }
6571        if total > max_line {
6572            max_line = total;
6573        }
6574
6575        // white-space:nowrap forbids soft-wrap opportunities entirely, so the
6576        // min-content width equals the max-content width (one unbreakable line).
6577        // Without this the scan resets cur_word at each space and reports a
6578        // too-small min-content, letting flex/shrink-to-fit clip the text.
6579        let min_content_width = if matches!(constraints.white_space_mode, WhiteSpaceMode::Nowrap) {
6580            max_line
6581        } else {
6582            max_word
6583        };
6584
6585        Ok(IntrinsicTextSizes {
6586            min_content_width,
6587            max_content_width: max_line,
6588            max_content_height: max_line_height,
6589        })
6590    }
6591}
6592
6593// --- Stage 1 Implementation ---
6594#[allow(clippy::cast_possible_truncation)] // bounded pixel/coord/colour/glyph cast
6595#[allow(clippy::too_many_lines, clippy::cognitive_complexity)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
6596/// # Panics
6597///
6598/// Panics if the scan cursor advances past the end of `text` (an internal invariant).
6599pub fn create_logical_items(
6600    content: &[InlineContent],
6601    style_overrides: &[StyleOverride],
6602    debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
6603) -> Vec<LogicalItem> {
6604    if let Some(msgs) = debug_messages {
6605        msgs.push(LayoutDebugMessage::info(
6606            "\n--- Entering create_logical_items (Refactored) ---".to_string(),
6607        ));
6608        msgs.push(LayoutDebugMessage::info(format!(
6609            "Input content length: {}",
6610            content.len()
6611        )));
6612        msgs.push(LayoutDebugMessage::info(format!(
6613            "Input overrides length: {}",
6614            style_overrides.len()
6615        )));
6616    }
6617
6618    let mut items: Vec<LogicalItem> = Vec::new();
6619    let mut style_cache: HashMap<u64, Arc<StyleProperties>> = HashMap::new();
6620
6621    // 1. Organize overrides for fast lookup per run.
6622    let mut run_overrides: HashMap<u32, HashMap<u32, &PartialStyleProperties>> = HashMap::new();
6623    for override_item in style_overrides {
6624        run_overrides
6625            .entry(override_item.target.run_index)
6626            .or_default()
6627            .insert(override_item.target.item_index, &override_item.style);
6628    }
6629
6630    for (run_idx, inline_item) in content.iter().enumerate() {
6631        if let Some(msgs) = debug_messages {
6632            msgs.push(LayoutDebugMessage::info(format!(
6633                "Processing content run #{run_idx}"
6634            )));
6635        }
6636
6637        // Extract marker information if this is a marker
6638        let marker_position_outside = match inline_item {
6639            InlineContent::Marker {
6640                position_outside, ..
6641            } => Some(*position_outside),
6642            _ => None,
6643        };
6644
6645        // [az-web-lift FIX 2026-06-06] Handle the common Text/Marker case via a STANDALONE `if let`
6646        // (a simple discriminant compare) instead of the first arm of the multi-way `match` below.
6647        // The remill lift mis-routes that multi-way InlineContent switch (LLVM's `subs/csel`-clamp
6648        // lowering): a Text(disc 0) variant lands in the `_`/Object arm → `inline_item.clone()` →
6649        // `<InlineContent as Clone>::clone` ALSO mis-routes to its Vec-clone arm → reads a heap ptr
6650        // as a Vec len → ×8 → ~789 MB alloc → BumpAlloc memset OOB. A standalone if-let lowers to a
6651        // single cmp/beq the lift handles correctly, so Text reaches its real body. Native unaffected.
6652        if let InlineContent::Text(run) | InlineContent::Marker { run, .. } = inline_item {
6653                let text = &run.text;
6654                if text.is_empty() {
6655                    if let Some(msgs) = debug_messages {
6656                        msgs.push(LayoutDebugMessage::info(
6657                            "  Run is empty, skipping.".to_string(),
6658                        ));
6659                    }
6660                    continue;
6661                }
6662                if let Some(msgs) = debug_messages {
6663                    msgs.push(LayoutDebugMessage::info(format!("  Run text: '{text}'")));
6664                }
6665
6666                let current_run_overrides = run_overrides.get(&(run_idx as u32));
6667                let mut boundaries = BTreeSet::new();
6668                boundaries.insert(0);
6669                boundaries.insert(text.len());
6670
6671                // --- Stateful Boundary Generation ---
6672                // web-lift FIX + perf: this scan_cursor walk ONLY inserts boundaries for
6673                // per-char style overrides (Rule 2) or text-combine-upright digit runs (Rule 1).
6674                // For plain text (no overrides AND no combine-upright) it inserts NOTHING and just
6675                // walks char-by-char via `scan_cursor += current_char.len_utf8()` — which the web
6676                // lift mis-advances (overshoot → slice_start_index_len_fail OOB; stall → infinite
6677                // loop). Skip the whole walk in that common case so `boundaries` stays {0, len}.
6678                let needs_scan = current_run_overrides.is_some()
6679                    || run.style.text_combine_upright.is_some();
6680                let mut scan_cursor = 0;
6681                while needs_scan && scan_cursor < text.len() {
6682                    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));
6683
6684                    let current_char = text[scan_cursor..].chars().next().unwrap();
6685
6686                    // +spec:containing-block:e4d9de - text-combine-upright digit run rules: digits sharing an ancestor with same value form one sequence across box boundaries
6687                    // +spec:inline-formatting-context:f65029 - text-combine-upright text run rules: combine consecutive digits not interrupted by box boundary
6688                    // Rule 1: Multi-character features take precedence.
6689                    // +spec:containing-block:9a26bd - text-combine-upright digit runs scoped by ancestor style boundaries
6690                    if let Some(TextCombineUpright::Digits(max_digits)) =
6691                        style_at_cursor.text_combine_upright
6692                    {
6693                        if max_digits > 0 && current_char.is_ascii_digit() {
6694                            let digit_chunk: String = text[scan_cursor..]
6695                                .chars()
6696                                .take(max_digits as usize)
6697                                .take_while(char::is_ascii_digit)
6698                                .collect();
6699
6700                            let end_of_chunk = scan_cursor + digit_chunk.len();
6701                            boundaries.insert(scan_cursor);
6702                            boundaries.insert(end_of_chunk);
6703                            scan_cursor = end_of_chunk; // Jump past the entire sequence
6704                            continue;
6705                        }
6706                    }
6707
6708                    // Rule 2: If no multi-char feature, check for a normal single-grapheme
6709                    // override.
6710                    if current_run_overrides
6711                        .and_then(|o| o.get(&(scan_cursor as u32)))
6712                        .is_some()
6713                    {
6714                        let grapheme_len = text[scan_cursor..]
6715                            .graphemes(true)
6716                            .next()
6717                            .unwrap_or("")
6718                            .len();
6719                        boundaries.insert(scan_cursor);
6720                        boundaries.insert(scan_cursor + grapheme_len);
6721                        scan_cursor += grapheme_len;
6722                        continue;
6723                    }
6724
6725                    // Rule 3: No special features or overrides at this point, just advance one
6726                    // char.
6727                    scan_cursor += current_char.len_utf8();
6728                }
6729
6730                if let Some(msgs) = debug_messages {
6731                    msgs.push(LayoutDebugMessage::info(format!(
6732                        "  Boundaries: {boundaries:?}"
6733                    )));
6734                }
6735
6736                // --- Chunk Processing ---
6737                for (start, end) in boundaries.iter().zip(boundaries.iter().skip(1)) {
6738                    let (start, end) = (*start, *end);
6739                    if start >= end {
6740                        continue;
6741                    }
6742
6743                    let text_slice = &text[start..end];
6744                    if let Some(msgs) = debug_messages {
6745                        msgs.push(LayoutDebugMessage::info(format!(
6746                            "  Processing chunk from {start} to {end}: '{text_slice}'"
6747                        )));
6748                    }
6749
6750                    let style_to_use = current_run_overrides.and_then(|o| o.get(&(start as u32))).map_or_else(|| run.style.clone(), |partial_style| {
6751                        if let Some(msgs) = debug_messages {
6752                            msgs.push(LayoutDebugMessage::info(format!(
6753                                "  -> Applying override at byte {start}"
6754                            )));
6755                        }
6756                        let mut hasher = DefaultHasher::new();
6757                        Arc::as_ptr(&run.style).hash(&mut hasher);
6758                        partial_style.hash(&mut hasher);
6759                        style_cache
6760                            .entry(hasher.finish())
6761                            .or_insert_with(|| Arc::new(run.style.apply_override(partial_style)))
6762                            .clone()
6763                    });
6764
6765                    // +spec:block-formatting-context:9e7c79 - text-combine-upright combines multiple characters into 1em in vertical writing
6766                    // +spec:containing-block:2b399b - text-combine-upright digits: combine ASCII digit sequences within max_digits limit; box boundaries implicitly prevent cross-box combination
6767                    // +spec:display-contents:644c78 - text-combine-upright run boundary check:
6768                    // if a combinable run boundary is due only to inline box boundaries,
6769                    // and adjacent chars would form a longer combinable sequence, do not combine
6770                    // +spec:white-space-processing:409d90 - text-combine-upright combined text: white space at start/end processed as in inline-block
6771                    let is_combinable_chunk = match &style_to_use.text_combine_upright {
6772                        Some(TextCombineUpright::All) => !text_slice.is_empty(),
6773                        Some(TextCombineUpright::Digits(max_digits)) => {
6774                            *max_digits > 0
6775                                && !text_slice.is_empty()
6776                                && text_slice.chars().all(|c| c.is_ascii_digit())
6777                                && text_slice.chars().count() <= *max_digits as usize
6778                        }
6779                        _ => false,
6780                    };
6781
6782                    if is_combinable_chunk {
6783                        // Trim leading/trailing white space like an inline-block
6784                        let trimmed = text_slice.trim();
6785                        let combined_text = if trimmed.is_empty() {
6786                            text_slice.to_string()
6787                        } else {
6788                            trimmed.to_string()
6789                        };
6790                        items.push(LogicalItem::CombinedText {
6791                            source: ContentIndex {
6792                                run_index: run_idx as u32,
6793                                item_index: start as u32,
6794                            },
6795                            text: combined_text,
6796                            style: style_to_use,
6797                        });
6798                    } else {
6799                        items.push(LogicalItem::Text {
6800                            source: ContentIndex {
6801                                run_index: run_idx as u32,
6802                                item_index: start as u32,
6803                            },
6804                            text: text_slice.to_string(),
6805                            style: style_to_use,
6806                            marker_position_outside,
6807                            source_node_id: run.source_node_id,
6808                        });
6809                    }
6810                }
6811        } else {
6812            match inline_item {
6813            // line breaking class characters must be treated as forced line breaks
6814            InlineContent::LineBreak(break_info) => {
6815                if let Some(msgs) = debug_messages {
6816                    msgs.push(LayoutDebugMessage::info(format!(
6817                        "  LineBreak: {break_info:?}"
6818                    )));
6819                }
6820                items.push(LogicalItem::Break {
6821                    source: ContentIndex {
6822                        run_index: run_idx as u32,
6823                        item_index: 0,
6824                    },
6825                    break_info: *break_info,
6826                });
6827            }
6828            // Handle tab characters
6829            InlineContent::Tab { style } => {
6830                if let Some(msgs) = debug_messages {
6831                    msgs.push(LayoutDebugMessage::info("  Tab character".to_string()));
6832                }
6833                items.push(LogicalItem::Tab {
6834                    source: ContentIndex {
6835                        run_index: run_idx as u32,
6836                        item_index: 0,
6837                    },
6838                    style: style.clone(),
6839                });
6840            }
6841            // Other cases (Image, Shape, Space, Ruby). Text/Marker are handled by the `if let`
6842            // above (so they never reach here at runtime); `_` keeps this inner match exhaustive.
6843            _ => {
6844                if let Some(msgs) = debug_messages {
6845                    msgs.push(LayoutDebugMessage::info(
6846                        "  Run is not text, creating generic LogicalItem.".to_string(),
6847                    ));
6848                }
6849                items.push(LogicalItem::Object {
6850                    source: ContentIndex {
6851                        run_index: run_idx as u32,
6852                        item_index: 0,
6853                    },
6854                    content: inline_item.clone(),
6855                });
6856            }
6857            }
6858        }
6859    }
6860    if let Some(msgs) = debug_messages {
6861        msgs.push(LayoutDebugMessage::info(format!(
6862            "--- Exiting create_logical_items, created {} items ---",
6863            items.len()
6864        )));
6865    }
6866    items
6867}
6868
6869// --- Stage 2 Implementation ---
6870
6871// +spec:inline-block:d47971 - unicode-bidi:plaintext uses P2/P3 heuristic for base direction (implemented via get_base_direction)
6872// +spec:writing-modes:287491 - BiDi reordering and base direction detection (Appendix A text processing order)
6873// when determining base direction, consistent with their neutral bidi treatment
6874#[must_use] pub fn get_base_direction_from_logical(logical_items: &[LogicalItem]) -> BidiDirection {
6875    let first_strong = logical_items.iter().find_map(|item| {
6876        if let LogicalItem::Text { text, .. } = item {
6877            Some(unicode_bidi::get_base_direction(text.as_str()))
6878        } else {
6879            None
6880        }
6881    });
6882
6883    match first_strong {
6884        Some(unicode_bidi::Direction::Rtl) => BidiDirection::Rtl,
6885        _ => BidiDirection::Ltr,
6886    }
6887}
6888
6889// +spec:containing-block:149255 - bidi reordering produces inline box fragments that may separate in wide containing blocks
6890// +spec:containing-block:c7c08f - bidi reordering produces inline box fragments that may be adjacent in narrow containing blocks
6891// +spec:containing-block:2936ae - bidi reordering splits inline boxes into visual fragments (CSS Writing Modes 4 §2.4.5)
6892// +spec:display-property:0cdbd3 - bidi reordering splits inline boxes into visual runs; each run is shaped/formatted independently
6893// +spec:display-property:0d62a2 - bidi reordering of inline content respects block direction and unicode-bidi embedding
6894// +spec:display-property:10f9cd - bidi reordering splits and reorders inline box fragments
6895// +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
6896// +spec:display-property:ecd935 - inline boxes split and reordered for uniform bidi flow
6897// +spec:writing-modes:330b8f - text ordered according to Unicode bidi algorithm after white-space processing
6898// +spec:writing-modes:7a9e7d - bidi control translation: text passed to unicode_bidi for reordering
6899// +spec:writing-modes:8e7281 - unicode-bidi property: bidi control codes inserted via BidiInfo
6900#[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
6901#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
6902/// # Errors
6903///
6904/// Returns a `LayoutError` if bidi reordering fails.
6905pub fn reorder_logical_items(
6906    logical_items: &[LogicalItem],
6907    base_direction: BidiDirection,
6908    unicode_bidi: UnicodeBidi,
6909    debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
6910) -> Result<Vec<VisualItem>, LayoutError> {
6911    if let Some(msgs) = debug_messages {
6912        msgs.push(LayoutDebugMessage::info(
6913            "\n--- Entering reorder_logical_items ---".to_string(),
6914        ));
6915        msgs.push(LayoutDebugMessage::info(format!(
6916            "Input logical items count: {}",
6917            logical_items.len()
6918        )));
6919        msgs.push(LayoutDebugMessage::info(format!(
6920            "Base direction: {base_direction:?}"
6921        )));
6922    }
6923
6924    // +spec:writing-modes:809513 - bidi string built across inline element boundaries; unicode-bidi:normal adds no extra embedding levels
6925    let mut bidi_str = String::new();
6926    let mut item_map = Vec::new();
6927    // Byte offset in `bidi_str` where each logical item's text begins, indexed
6928    // by logical item index. Used to re-base each visual run's byte offset to be
6929    // relative to its own logical run (see `run_byte_offset`).
6930    let mut logical_item_starts = Vec::with_capacity(logical_items.len());
6931    for (idx, item) in logical_items.iter().enumerate() {
6932        // +spec:containing-block:1fdc31 - inline boxes with unicode-bidi:normal are transparent to bidi algorithm
6933        // +spec:display-property:074abf - inline boxes transparent to bidi when unicode-bidi:normal
6934        // +spec:display-property:354966 - unicode-bidi control code injection for inline boxes
6935        // +spec:display-property:8409d3 - inline-level elements with unicode-bidi:normal have no effect on bidi ordering; embed creates an embedding
6936        // +spec:display-property:89464a - inline boxes with unicode-bidi:normal don't open embedding levels, so direction has no effect on bidi reordering
6937        // +spec:display-property:d47971 - bidi control codes should be injected at inline box boundaries based on unicode-bidi + direction
6938        // +spec:display-property:de657b - bidi control codes injected for display:inline boxes per unicode-bidi value
6939        // +spec:display-property:f01a81 - bidi-override should prepend LRO/RLO and append PDF per unicode-bidi CSS property (not yet implemented)
6940        // are treated as neutral characters in the bidi algorithm. Replaced elements with
6941        // +spec:display-property:fcb011 - unicode-bidi values on inline boxes insert bidi control codes
6942        // +spec:display-property:89095f - isolate/bidi-override/isolate-override/plaintext semantics
6943        // +spec:writing-modes:d490bf - direction only affects reordering when unicode-bidi is embed/override (not yet enforced for inline elements)
6944        // display:inline are also neutral unless unicode-bidi != normal (not yet implemented).
6945        // +spec:display-property:b4756e - replaced inline elements treated as neutral bidi chars;
6946        // embed/bidi-override exception not yet implemented (would make them strong chars).
6947        // U+FFFC (OBJECT REPLACEMENT CHARACTER) is a neutral bidi character.
6948        // +spec:display-property:df11ef - atomic inlines treated as neutral bidi characters (U+FFFC)
6949        // Replaced elements with display:inline are also neutral unless unicode-bidi != normal.
6950        let text = match item {
6951            LogicalItem::Text { text, .. } => text.as_str(),
6952            LogicalItem::CombinedText { text, .. } => text.as_str(),
6953            _ => "\u{FFFC}",
6954        };
6955        let start_byte = bidi_str.len();
6956        logical_item_starts.push(start_byte);
6957        bidi_str.push_str(text);
6958        for _ in start_byte..bidi_str.len() {
6959            item_map.push(idx);
6960        }
6961    }
6962
6963    if bidi_str.is_empty() {
6964        if let Some(msgs) = debug_messages {
6965            msgs.push(LayoutDebugMessage::info(
6966                "Bidi string is empty, returning.".to_string(),
6967            ));
6968        }
6969        return Ok(Vec::new());
6970    }
6971    if let Some(msgs) = debug_messages {
6972        msgs.push(LayoutDebugMessage::info(format!(
6973            "Constructed bidi string: '{bidi_str}'"
6974        )));
6975    }
6976
6977    // +spec:display-property:1a6075 - paragraph embedding level set from direction property per UAX9 HL1
6978    // +spec:containing-block:0d4914 - unicode-bidi: plaintext exception
6979    // When the containing block has unicode-bidi: plaintext, use None so the
6980    // Unicode bidi algorithm applies P2/P3 heuristics instead of the HL1 override
6981    let bidi_level = if unicode_bidi == UnicodeBidi::Plaintext {
6982        None
6983    } else if base_direction == BidiDirection::Rtl {
6984        Some(Level::rtl())
6985    } else {
6986        Some(Level::ltr())
6987    };
6988    // +spec:writing-modes:15bf17 - bidi isolation handled by unicode_bidi UAX #9 implementation
6989    let bidi_info = BidiInfo::new(&bidi_str, bidi_level);
6990    let para = &bidi_info.paragraphs[0];
6991    let (levels, visual_runs) = bidi_info.visual_runs(para, para.range.clone());
6992
6993    if let Some(msgs) = debug_messages {
6994        msgs.push(LayoutDebugMessage::info(
6995            "Bidi visual runs generated:".to_string(),
6996        ));
6997        for (i, run_range) in visual_runs.iter().enumerate() {
6998            let level = levels[run_range.start].number();
6999            let slice = &bidi_str[run_range.start..run_range.end];
7000            msgs.push(LayoutDebugMessage::info(format!(
7001                "  Run {i}: range={run_range:?}, level={level}, text='{slice}'"
7002            )));
7003        }
7004    }
7005
7006    // TODO(text3-review): RTL glyph-level visual reversal is NOT applied.
7007    // `visual_runs` orders the RUNS visually (left-to-right), but the loop below
7008    // emits each run's content in LOGICAL byte order, and shaping/positioning then
7009    // place clusters left-to-right in that logical order. For an RTL run this is
7010    // wrong: the first logical character must land at the LARGEST visual x. The
7011    // shaped clusters of each RTL run therefore need to be reversed (UBA rule L2,
7012    // applied per run at the glyph level AFTER shaping — a single logical Text item
7013    // shapes into multiple clusters, so it cannot be reversed here at the item
7014    // level). This must compose with the run-level ordering already done here
7015    // (naively re-running full L2 on top would double-reverse RTL-base paragraphs),
7016    // and `UnifiedLayout::get_selection_rects` must additionally split a selection
7017    // into one visual rect per directional segment. Deferred as a coherent
7018    // cross-cutting change; see failing tests text3_brutal_shaping::
7019    // {hebrew_run_is_rtl_reversed_and_33px_wide, bidi_mixed_run_is_80px_and_reverses_hebrew}
7020    // and text3_brutal_selection::bidi_selection_over_rtl_run_splits_into_multiple_rects.
7021    let mut visual_items = Vec::new();
7022    for run_range in visual_runs {
7023        let bidi_level = BidiLevel::new(levels[run_range.start].number());
7024        let mut sub_run_start = run_range.start;
7025
7026        for i in (run_range.start + 1)..run_range.end {
7027            if item_map[i] != item_map[sub_run_start] {
7028                let logical_idx = item_map[sub_run_start];
7029                let logical_item = &logical_items[logical_idx];
7030                let text_slice = &bidi_str[sub_run_start..i];
7031                visual_items.push(VisualItem {
7032                    logical_source: logical_item.clone(),
7033                    bidi_level,
7034                    script: crate::text3::script::detect_script(text_slice)
7035                        .unwrap_or(Script::Latin),
7036                    text: text_slice.to_string(),
7037                    run_byte_offset: sub_run_start - logical_item_starts[logical_idx],
7038                });
7039                sub_run_start = i;
7040            }
7041        }
7042
7043        let logical_idx = item_map[sub_run_start];
7044        let logical_item = &logical_items[logical_idx];
7045        let text_slice = &bidi_str[sub_run_start..run_range.end];
7046        visual_items.push(VisualItem {
7047            logical_source: logical_item.clone(),
7048            bidi_level,
7049            script: crate::text3::script::detect_script(text_slice).unwrap_or(Script::Latin),
7050            text: text_slice.to_string(),
7051            run_byte_offset: sub_run_start - logical_item_starts[logical_idx],
7052        });
7053    }
7054
7055    if let Some(msgs) = debug_messages {
7056        msgs.push(LayoutDebugMessage::info(
7057            "Final visual items produced:".to_string(),
7058        ));
7059        for (i, item) in visual_items.iter().enumerate() {
7060            msgs.push(LayoutDebugMessage::info(format!(
7061                "  Item {}: level={}, text='{}'",
7062                i,
7063                item.bidi_level.level(),
7064                item.text
7065            )));
7066        }
7067        msgs.push(LayoutDebugMessage::info(
7068            "--- Exiting reorder_logical_items ---".to_string(),
7069        ));
7070    }
7071    Ok(visual_items)
7072}
7073
7074// --- Stage 3 Implementation ---
7075
7076/// Shape visual items into `ShapedItems` using pre-loaded fonts.
7077///
7078/// This function does NOT load any fonts - all fonts must be pre-loaded and passed in.
7079/// If a required font is not in `loaded_fonts`, the text will be skipped with a warning.
7080///
7081/// **Optimization: Inline Run Coalescing**
7082///
7083/// // +spec:display-property:9c6d59 - text shaping not broken across inline box boundaries when no effective formatting change
7084/// // +spec:display-property:cf8917 - text shaping not broken across inline box boundaries
7085/// When consecutive text `VisualItem`s share the same layout-affecting properties
7086/// (font, size, spacing, etc.) but differ only in rendering properties (color,
7087/// background), they are coalesced into a single shaping call. This dramatically
7088/// reduces the number of `font.shape_text()` invocations for syntax-highlighted
7089/// code where hundreds of `<span>` elements use the same monospace font but
7090/// different colors. After shaping, the original per-span styles are restored
7091/// to each `ShapedCluster` based on byte-range mapping.
7092/// Shape visual items with per-item caching. For each item (or coalesced group),
7093/// compute a cache key from (text, `bidi_level`, script, `style_layout_hash`). On cache
7094/// hit, reuse the previously shaped clusters. On miss, shape and store.
7095///
7096/// This is the incremental shaping path: when one word changes in a paragraph,
7097/// only that word's item misses the per-item cache; all other items hit.
7098#[allow(clippy::implicit_hasher)] // internal helper; only ever called with the default-hasher HashMap/HashSet
7099/// # Errors
7100///
7101/// Returns a `LayoutError` if shaping the visual items fails.
7102pub fn shape_visual_items_with_per_item_cache<T: ParsedFontTrait>(
7103    visual_items: &[VisualItem],
7104    per_item_cache: &mut HashMap<u64, Arc<PerItemShapedEntry>>,
7105    per_item_accessed: &mut HashSet<u64>,
7106    font_chain_cache: &HashMap<FontChainKey, rust_fontconfig::FontFallbackChain>,
7107    fc_cache: &FcFontCache,
7108    loaded_fonts: &LoadedFonts<T>,
7109    debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
7110) -> Result<Vec<ShapedItem>, LayoutError> {
7111    use std::hash::{Hash, Hasher};
7112    // Delegate to the existing shaping logic, but for each coalesce group,
7113    // check the per-item cache first.
7114    //
7115    // Strategy: Identify coalesce groups (adjacent items with same layout_hash,
7116    // bidi_level, script). For each group, compute a key from the concatenated
7117    // text + shared properties. Check cache. On miss, shape the group and cache it.
7118    let mut shaped = Vec::new();
7119    let mut idx = 0;
7120
7121    while idx < visual_items.len() {
7122        let item = &visual_items[idx];
7123
7124        // Determine coalesce group boundaries (same logic as shape_visual_items)
7125        let (layout_hash, bidi_level, script) = match &item.logical_source {
7126            LogicalItem::Text { style, .. } | LogicalItem::CombinedText { style, .. } => {
7127                (style.layout_hash(), item.bidi_level, item.script)
7128            }
7129            _ => {
7130                // Non-text items: shape individually (no coalescing)
7131                let single = shape_visual_items(
7132                    &visual_items[idx..=idx],
7133                    font_chain_cache, fc_cache, loaded_fonts, debug_messages,
7134                )?;
7135                shaped.extend(single);
7136                idx += 1;
7137                continue;
7138            }
7139        };
7140
7141        let mut coalesce_end = idx + 1;
7142        while coalesce_end < visual_items.len() {
7143            let next = &visual_items[coalesce_end];
7144            let next_layout_hash = match &next.logical_source {
7145                LogicalItem::Text { style, .. } | LogicalItem::CombinedText { style, .. } => {
7146                    Some(style.layout_hash())
7147                }
7148                _ => None,
7149            };
7150            if let Some(nlh) = next_layout_hash {
7151                if nlh == layout_hash
7152                    && next.bidi_level == bidi_level
7153                    && next.script == script
7154                {
7155                    coalesce_end += 1;
7156                } else {
7157                    break;
7158                }
7159            } else {
7160                break;
7161            }
7162        }
7163
7164        // Compute per-group cache key
7165        let mut hasher = DefaultHasher::new();
7166        for item in &visual_items[idx..coalesce_end] {
7167            item.text.hash(&mut hasher);
7168        }
7169        layout_hash.hash(&mut hasher);
7170        bidi_level.hash(&mut hasher);
7171        (script as u32).hash(&mut hasher);
7172        let group_key = hasher.finish();
7173
7174        // Check per-item cache
7175        per_item_accessed.insert(group_key);
7176        if let Some(cached) = per_item_cache.get(&group_key) {
7177            shaped.extend(cached.clusters.iter().cloned());
7178        } else {
7179            // Cache miss — shape this group
7180            let group_items = shape_visual_items(
7181                &visual_items[idx..coalesce_end],
7182                font_chain_cache, fc_cache, loaded_fonts, debug_messages,
7183            )?;
7184            let total_advance: f32 = group_items.iter().map(|item| {
7185                match item {
7186                    ShapedItem::Cluster(c) => c.advance,
7187                    _ => 0.0,
7188                }
7189            }).sum();
7190            per_item_cache.insert(group_key, Arc::new(PerItemShapedEntry {
7191                clusters: group_items.clone(),
7192                total_advance,
7193            }));
7194            shaped.extend(group_items);
7195        }
7196
7197        idx = coalesce_end;
7198    }
7199
7200    Ok(shaped)
7201}
7202
7203/// Split text into segments where consecutive characters resolve to the same font
7204/// in the fallback chain. Returns Vec<(`byte_start`, `byte_end`, `FontId`)>.
7205///
7206/// Characters that can't be resolved to any font are skipped (gap in coverage).
7207fn split_text_by_font_coverage<T: ParsedFontTrait>(
7208    text: &str,
7209    font_chain: &rust_fontconfig::FontFallbackChain,
7210    fc_cache: &FcFontCache,
7211    loaded_fonts: &LoadedFonts<T>,
7212) -> Vec<(usize, usize, FontId)> {
7213    let mut segments: Vec<(usize, usize, FontId)> = Vec::new();
7214
7215    // Deterministic "last resort" face for characters no font covers: the lowest
7216    // FontId among the loaded fonts. Used so an uncovered codepoint still emits a
7217    // .notdef (tofu) segment instead of being silently dropped (zero glyphs/advance).
7218    let notdef_font_id = loaded_fonts.iter().map(|(id, _)| *id).min();
7219
7220    for (byte_idx, ch) in text.char_indices() {
7221        let char_end = byte_idx + ch.len_utf8();
7222        // Primary: the resolved fallback chain. Its coverage comes from
7223        // rust-fontconfig's OS/2-derived `unicode_ranges`, which can MISS
7224        // codepoints a font actually has in its cmap — e.g. Noto Sans CJK's
7225        // JP face does not advertise the Hangul OS/2 block, so 한국어 resolves
7226        // to None here even though that face's cmap covers it.
7227        let font_id = font_chain
7228            .resolve_char(fc_cache, ch)
7229            .map(|(id, _)| id)
7230            // Fallback: probe the actually-loaded fonts by REAL glyph coverage
7231            // so OS/2-vs-cmap gaps render instead of being silently dropped.
7232            // The covering CJK face is already loaded (Han/Kana resolved to it),
7233            // so this reuses it for Hangul rather than mixing in another font.
7234            // Iterate in a STABLE order (lowest FontId first) so the chosen face is
7235            // deterministic across processes — a raw HashMap `.find` is seeded per
7236            // process and would pick different faces run-to-run.
7237            .or_else(|| {
7238                loaded_fonts
7239                    .iter()
7240                    .filter(|(_, font)| font.has_glyph(ch as u32))
7241                    .map(|(id, _)| *id)
7242                    .min()
7243            })
7244            // Last resort: no font advertises OR covers this codepoint. Assign it to
7245            // the primary loaded face so the shaper emits a visible .notdef box and
7246            // the byte range is preserved (following text is not shifted).
7247            .or(notdef_font_id);
7248        if let Some(font_id) = font_id {
7249            match segments.last_mut() {
7250                Some(last) if last.2 == font_id && last.1 == byte_idx => {
7251                    // Extend current segment (same font, contiguous)
7252                    last.1 = char_end;
7253                }
7254                _ => {
7255                    // New segment (different font or gap)
7256                    segments.push((byte_idx, char_end, font_id));
7257                }
7258            }
7259        }
7260    }
7261
7262    segments
7263}
7264
7265/// Measures the total inline advance (width in horizontal mode) of `text` shaped at
7266/// `style`, using the same font-resolution path as the main shaper. Returns `None` if the
7267/// font chain is not resolved / shaping fails, so callers can fall back to an estimate.
7268///
7269/// Used by ruby layout to size the base and annotation runs from REAL shaped advances
7270/// (instead of a `chars * font_size * magic_ratio` fudge).
7271fn measure_run_advance<T: ParsedFontTrait>(
7272    text: &str,
7273    style: &Arc<StyleProperties>,
7274    script: Script,
7275    source: ContentIndex,
7276    font_chain_cache: &HashMap<FontChainKey, rust_fontconfig::FontFallbackChain>,
7277    fc_cache: &FcFontCache,
7278    loaded_fonts: &LoadedFonts<T>,
7279) -> Option<f32> {
7280    if text.is_empty() {
7281        return Some(0.0);
7282    }
7283    let language = script_to_language(script, text);
7284    match &style.font_stack {
7285        FontStack::Ref(font_ref) => {
7286            let glyphs = font_ref
7287                .shape_text(text, script, language, BidiDirection::Ltr, style.as_ref())
7288                .ok()?;
7289            Some(glyphs.iter().map(|g| g.advance + g.kerning).sum())
7290        }
7291        FontStack::Stack(selectors) => {
7292            let cache_key = FontChainKey::from_selectors(selectors);
7293            let font_chain = font_chain_cache.get(&cache_key)?;
7294            let clusters = shape_with_font_fallback(
7295                text, script, language, BidiDirection::Ltr, style, source, None, font_chain,
7296                fc_cache, loaded_fonts,
7297            )
7298            .ok()?;
7299            Some(clusters.iter().map(|c| c.advance).sum())
7300        }
7301    }
7302}
7303
7304/// Shape text with per-character font fallback.
7305///
7306/// Splits the text into segments by font coverage, shapes each segment with
7307/// its resolved font, and fixes byte offsets so they're relative to the
7308/// original `text` (not the segment substring).
7309#[allow(clippy::cast_possible_truncation)] // bounded pixel/coord/colour/glyph cast
7310fn shape_with_font_fallback<T: ParsedFontTrait>(
7311    text: &str,
7312    script: Script,
7313    language: Language,
7314    direction: BidiDirection,
7315    style: &Arc<StyleProperties>,
7316    source_index: ContentIndex,
7317    source_node_id: Option<NodeId>,
7318    font_chain: &rust_fontconfig::FontFallbackChain,
7319    fc_cache: &FcFontCache,
7320    loaded_fonts: &LoadedFonts<T>,
7321) -> Result<Vec<ShapedCluster>, LayoutError> {
7322    // Cache the debug flag in a `OnceLock<bool>` — reading it per-shape
7323    // (this function fires once per text segment, ~hundreds of times
7324    // per render of a real DOM) costs ~100 ns per `std::env::var_os`
7325    // call on macOS (env-lock + hashmap lookup), and even before the
7326    // lookup finishes the `eprintln!` machinery takes a stderr lock
7327    // and allocates the formatted string. Both are invisible in
7328    // release unless `AZ_FONT_FALLBACK_DEBUG=1` is set.
7329    static FONT_FB_DEBUG: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
7330    let dbg = *FONT_FB_DEBUG.get_or_init(|| {
7331        std::env::var_os("AZ_FONT_FALLBACK_DEBUG").is_some()
7332    });
7333
7334    let segments = split_text_by_font_coverage(text, font_chain, fc_cache, loaded_fonts);
7335
7336    if dbg && segments.len() > 1 {
7337        eprintln!(
7338            "[FONT FALLBACK] text needs {} font segments for '{}' ({}..{} bytes)",
7339            segments.len(),
7340            text.chars().take(40).collect::<String>(),
7341            0, text.len()
7342        );
7343    }
7344
7345    unsafe { crate::az_mark(0x60850_u32, segments.len() as u32); } // [g123] segments count (split_text_by_font_coverage)
7346    if segments.len() <= 1 {
7347        // Fast path: all characters use the same font (common case)
7348        let (seg_start, seg_end, font_id) = if let Some(s) = segments.first() { unsafe { crate::az_mark(0x60854_u32, 0x0000_0001_u32); } s } else {
7349            unsafe { crate::az_mark(0x60854_u32, 0x0000_00EE_u32); } // [g123] split→0 segments (resolve_char failed all)
7350            if dbg {
7351                eprintln!("[FONT FALLBACK] no font could render any char in '{}'", text.chars().take(20).collect::<String>());
7352            }
7353            return Ok(Vec::new());
7354        };
7355        let font = if let Some(f) = loaded_fonts.get(font_id) { unsafe { crate::az_mark(0x60858_u32, 0x0000_0001_u32); } f } else {
7356            unsafe { crate::az_mark(0x60858_u32, 0x0000_00EE_u32); } // [g123] loaded_fonts.get MISS
7357            if dbg {
7358                eprintln!("[FONT FALLBACK] font {:?} not in loaded_fonts for '{}'", font_id, text.chars().take(20).collect::<String>());
7359            }
7360            return Ok(Vec::new());
7361        };
7362        // If segment covers the full text (overwhelmingly common), skip substr+fixup
7363        if *seg_start == 0 && *seg_end == text.len() {
7364            unsafe { crate::az_mark(0x60860_u32, 0xC0DE_0860_u32); } // [g123] reached shape_text_correctly (full-text)
7365            return shape_text_correctly(
7366                text, script, language, direction,
7367                font, style, source_index, source_node_id,
7368            );
7369        }
7370        let mut clusters = shape_text_correctly(
7371            &text[*seg_start..*seg_end], script, language, direction,
7372            font, style, source_index, source_node_id,
7373        )?;
7374        if *seg_start > 0 {
7375            for cluster in &mut clusters {
7376                cluster.source_cluster_id.start_byte_in_run += *seg_start as u32;
7377            }
7378        }
7379        return Ok(clusters);
7380    }
7381
7382    // Multiple fonts needed — shape each segment separately
7383    let mut all_clusters = Vec::new();
7384    for (seg_start, seg_end, font_id) in &segments {
7385        let Some(font) = loaded_fonts.get(font_id) else {
7386            if dbg {
7387                eprintln!("[FONT FALLBACK] font {font_id:?} NOT loaded, skipping segment bytes {seg_start}..{seg_end}");
7388            }
7389            continue;
7390        };
7391        let segment_text = &text[*seg_start..*seg_end];
7392        if dbg {
7393            eprintln!(
7394                "[FONT FALLBACK] text='{segment_text}' uses font {font_id:?} (bytes {seg_start}..{seg_end})"
7395            );
7396        }
7397        let mut seg_clusters = shape_text_correctly(
7398            segment_text, script, language, direction,
7399            font, style, source_index, source_node_id,
7400        )?;
7401        // Fix byte offsets: shape_text_correctly produces offsets relative to
7402        // segment_text, but callers expect offsets relative to the full text.
7403        if *seg_start > 0 {
7404            for cluster in &mut seg_clusters {
7405                cluster.source_cluster_id.start_byte_in_run += *seg_start as u32;
7406            }
7407        }
7408        all_clusters.extend(seg_clusters);
7409    }
7410    Ok(all_clusters)
7411}
7412
7413#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)] // bounded pixel/coord/colour/glyph cast
7414#[allow(clippy::implicit_hasher)] // internal helper; only ever called with the default-hasher HashMap/HashSet
7415#[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
7416#[allow(clippy::too_many_lines, clippy::cognitive_complexity)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
7417/// # Errors
7418///
7419/// Returns a `LayoutError` if shaping the visual items fails.
7420pub fn shape_visual_items<T: ParsedFontTrait>(
7421    visual_items: &[VisualItem],
7422    font_chain_cache: &HashMap<FontChainKey, rust_fontconfig::FontFallbackChain>,
7423    fc_cache: &FcFontCache,
7424    loaded_fonts: &LoadedFonts<T>,
7425    debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
7426) -> Result<Vec<ShapedItem>, LayoutError> {
7427    let mut shaped = Vec::new();
7428    let mut idx = 0;
7429    let mut _coalesced_runs = 0usize;
7430    let mut _total_runs = 0usize;
7431    let mut _shape_calls = 0usize;
7432
7433    // Log count of visual items for debugging coalescing
7434
7435    while idx < visual_items.len() {
7436        let item = &visual_items[idx];
7437        match &item.logical_source {
7438            LogicalItem::Text {
7439                style,
7440                source,
7441                marker_position_outside,
7442                source_node_id,
7443                ..
7444            } => {
7445                let layout_hash = style.layout_hash();
7446                let bidi_level = item.bidi_level;
7447                let script = item.script;
7448
7449                // +spec:display-property:ca95f6 - text shaping breaks at inline box boundaries when layout-affecting properties differ
7450                // when layout-affecting properties (font weight, family, size, etc.) change
7451                // across element boundaries, preventing ligatures from forming across such changes.
7452                // Look ahead: find consecutive text items with the same layout-affecting
7453                // properties (font, size, spacing) that can be shaped as one merged run.
7454                let mut coalesce_end = idx + 1;
7455                while coalesce_end < visual_items.len() {
7456                    let next = &visual_items[coalesce_end];
7457                    if let LogicalItem::Text { style: next_style, .. } = &next.logical_source {
7458                        if next_style.layout_hash() == layout_hash
7459                            && next.bidi_level == bidi_level
7460                            && next.script == script
7461                        {
7462                            coalesce_end += 1;
7463                        } else {
7464                            break;
7465                        }
7466                    } else {
7467                        break;
7468                    }
7469                }
7470
7471                let coalesce_count = coalesce_end - idx;
7472
7473                if coalesce_count > 1 {
7474                    _coalesced_runs += coalesce_count;
7475                    _shape_calls += 1;
7476                    // ── COALESCED PATH ──
7477                    // Merge N text items into one shaping call, then split results
7478                    // back per original run to preserve per-span rendering styles.
7479
7480                    // Build merged text and record byte ranges → original style
7481                    let total_text_len: usize = visual_items[idx..coalesce_end]
7482                        .iter()
7483                        .map(|v| v.text.len())
7484                        .sum();
7485                    let mut merged_text = String::with_capacity(total_text_len);
7486                    // (byte_start, byte_end, style, source, source_node_id, marker_outside, run_byte_offset)
7487                    let mut byte_ranges: Vec<(
7488                        usize, usize,
7489                        Arc<StyleProperties>,
7490                        ContentIndex,
7491                        Option<NodeId>,
7492                        Option<bool>,
7493                        usize,
7494                    )> = Vec::with_capacity(coalesce_count);
7495
7496                    for item in &visual_items[idx..coalesce_end] {
7497                        let start = merged_text.len();
7498                        merged_text.push_str(&item.text);
7499                        let end = merged_text.len();
7500                        if let LogicalItem::Text {
7501                            style: s, source: src, source_node_id: nid,
7502                            marker_position_outside: mpo, ..
7503                        } = &item.logical_source {
7504                            byte_ranges.push((start, end, s.clone(), *src, *nid, *mpo, item.run_byte_offset));
7505                        }
7506                    }
7507
7508                    if let Some(msgs) = debug_messages {
7509                        msgs.push(LayoutDebugMessage::info(format!(
7510                            "[TextLayout] Coalescing {} text runs ({} bytes) into single shaping call",
7511                            coalesce_count, merged_text.len()
7512                        )));
7513                    }
7514
7515                    let direction = if bidi_level.is_rtl() {
7516                        BidiDirection::Rtl
7517                    } else {
7518                        BidiDirection::Ltr
7519                    };
7520                    let language = script_to_language(script, &merged_text);
7521
7522                    // Shape the merged text using the first item's font (layout is identical
7523                    // for all coalesced items since layout_hash matches).
7524                    let shaped_clusters_result: Result<Vec<ShapedCluster>, LayoutError> = match &style.font_stack {
7525                        FontStack::Ref(font_ref) => {
7526                            shape_text_correctly(
7527                                &merged_text, script, language, direction,
7528                                font_ref, style, *source, *source_node_id,
7529                            )
7530                        }
7531                        FontStack::Stack(selectors) => {
7532                            let cache_key = FontChainKey::from_selectors(selectors);
7533                            let Some(font_chain) = font_chain_cache.get(&cache_key) else { idx = coalesce_end; continue; };
7534                            // Per-character font fallback: split text by font coverage
7535                            shape_with_font_fallback(
7536                                &merged_text, script, language, direction,
7537                                style, *source, *source_node_id,
7538                                font_chain, fc_cache, loaded_fonts,
7539                            )
7540                        }
7541                    };
7542
7543                    let shaped_clusters = shaped_clusters_result?;
7544
7545                    // Restore original per-span styles to each cluster based on byte position.
7546                    // Each ShapedCluster's source_cluster_id.start_byte_in_run is the byte
7547                    // offset within the merged text — we use byte_ranges to find which
7548                    // original run it belongs to and reassign its style, source info, etc.
7549                    for cluster in shaped_clusters {
7550                        let byte_pos = cluster.source_cluster_id.start_byte_in_run as usize;
7551                        // Find the original run this cluster's first byte falls into
7552                        let orig = byte_ranges.iter().find(|(start, end, ..)| {
7553                            byte_pos >= *start && byte_pos < *end
7554                        });
7555                        let mut cluster = cluster;
7556                        if let Some((range_start, _, orig_style, orig_source, orig_nid, orig_mpo, orig_run_offset)) = orig {
7557                            // Reassign rendering-affecting style (color, background, etc.)
7558                            cluster.style = orig_style.clone();
7559                            cluster.source_content_index = *orig_source;
7560                            cluster.source_node_id = *orig_nid;
7561                            // Fix the byte offset to be relative to the original logical run:
7562                            // (position within the merged text - this run's start in the merge)
7563                            // + this visual run's offset within its logical run (bidi split).
7564                            cluster.source_cluster_id.source_run = orig_source.run_index;
7565                            cluster.source_cluster_id.start_byte_in_run = (byte_pos - range_start + *orig_run_offset) as u32;
7566                            // Update glyph styles
7567                            for glyph in &mut cluster.glyphs {
7568                                glyph.style = orig_style.clone();
7569                            }
7570                            if let Some(is_outside) = orig_mpo {
7571                                cluster.marker_position_outside = Some(*is_outside);
7572                            }
7573                        }
7574                        shaped.push(ShapedItem::Cluster(cluster));
7575                    }
7576
7577                    idx = coalesce_end;
7578                    continue;
7579                }
7580
7581                // ── SINGLE ITEM PATH (no coalescing) ──
7582                _total_runs += 1;
7583                _shape_calls += 1;
7584                let direction = if item.bidi_level.is_rtl() {
7585                    BidiDirection::Rtl
7586                } else {
7587                    BidiDirection::Ltr
7588                };
7589
7590                let language = script_to_language(item.script, &item.text);
7591
7592                // Shape text using either FontRef directly or fontconfig-resolved font
7593                let shaped_clusters_result: Result<Vec<ShapedCluster>, LayoutError> = match &style.font_stack {
7594                    FontStack::Ref(font_ref) => {
7595                        unsafe { crate::az_mark(0x60820_u32, 0x0000_0001_u32); } // [g121] Ref arm
7596                        // For FontRef, use the font directly without fontconfig
7597                        if let Some(msgs) = debug_messages {
7598                            msgs.push(LayoutDebugMessage::info(format!(
7599                                "[TextLayout] Using direct FontRef for text: '{}'",
7600                                item.text.chars().take(30).collect::<String>()
7601                            )));
7602                        }
7603                        shape_text_correctly(
7604                            &item.text,
7605                            item.script,
7606                            language,
7607                            direction,
7608                            font_ref,
7609                            style,
7610                            *source,
7611                            *source_node_id,
7612                        )
7613                    }
7614                    FontStack::Stack(selectors) => {
7615                        unsafe { crate::az_mark(0x60820_u32, 0x0000_0002_u32); } // [g121] Stack arm
7616                        // Build FontChainKey and resolve through fontconfig
7617                        let cache_key = FontChainKey::from_selectors(selectors);
7618                        unsafe { crate::az_mark(0x60824_u32, font_chain_cache.len() as u32); } // [g121] chain map len
7619
7620                        // Look up the pre-resolved font chain. (2026-06-10: the g122
7621                        // by_find/by_only fallback chain is GONE — the historic miss was a
7622                        // KEY-CONSTRUCTION divergence (duplicated families on the query side,
7623                        // deduped on the store side), fixed by routing every key build through
7624                        // FontChainKey::from_selectors. Verified lifted: lookup path = get.)
7625                        let Some(font_chain) = font_chain_cache.get(&cache_key) else {
7626                            if let Some(msgs) = debug_messages {
7627                                msgs.push(LayoutDebugMessage::warning(format!(
7628                                    "[TextLayout] Font chain not pre-resolved for {:?} - text will \
7629                                     not be rendered",
7630                                    cache_key.font_families
7631                                )));
7632                            }
7633                            idx += 1;
7634                            continue;
7635                        };
7636
7637                        // Per-character font fallback: split text by font coverage
7638                        shape_with_font_fallback(
7639                            &item.text, item.script, language, direction,
7640                            style, *source, *source_node_id,
7641                            font_chain, fc_cache, loaded_fonts,
7642                        )
7643                    }
7644                };
7645
7646                let mut shaped_clusters = shaped_clusters_result?;
7647
7648                // Re-base cluster byte offsets to the logical run. Shaping produced
7649                // `start_byte_in_run` relative to this visual run's `text`; when bidi
7650                // split the logical run into several visual runs, add the visual run's
7651                // offset so every cluster ID is unique + matches caret byte positions.
7652                let run_byte_offset = item.run_byte_offset as u32;
7653                if run_byte_offset != 0 {
7654                    for cluster in &mut shaped_clusters {
7655                        cluster.source_cluster_id.start_byte_in_run = cluster
7656                            .source_cluster_id
7657                            .start_byte_in_run
7658                            .saturating_add(run_byte_offset);
7659                    }
7660                }
7661
7662                // Set marker flag on all clusters if this is a marker
7663                if let Some(is_outside) = marker_position_outside {
7664                    for cluster in &mut shaped_clusters {
7665                        cluster.marker_position_outside = Some(*is_outside);
7666                    }
7667                }
7668
7669                shaped.extend(shaped_clusters.into_iter().map(ShapedItem::Cluster));
7670            }
7671            // +spec:display-property:df076b - tab-size rendering and inline-level line breaking
7672            // "If the tab size is zero, preserved tabs are not rendered."
7673            // "Otherwise, each preserved tab is rendered as a horizontal shift that lines up
7674            //  the start edge of the next glyph with the next tab stop."
7675            // "Tab stops occur at points that are multiples of the tab size from the starting
7676            //  content edge of the preserved tab's nearest block container ancestor."
7677            LogicalItem::Tab { source, style } => {
7678                if style.tab_size == 0.0 {
7679                    // Tab size zero: tab is not rendered (zero width)
7680                    shaped.push(ShapedItem::Tab {
7681                        source: *source,
7682                        bounds: Rect {
7683                            x: 0.0,
7684                            y: 0.0,
7685                            width: 0.0,
7686                            height: 0.0,
7687                        },
7688                    });
7689                } else {
7690                    // TODO: use actual font's space_width via ParsedFontTrait::get_space_width()
7691                    // once we thread font resolution into the shaping phase for tab stops.
7692                    // For now, approximate space advance as 0.5 * font_size (typical for Latin fonts).
7693                    let space_advance_approx = style.font_size_px * SPACE_WIDTH_RATIO;
7694                    // +spec:text-alignment-spacing:5a5efd - tab-size includes letter-spacing and word-spacing
7695                    let ls = style.letter_spacing.resolve_px(style.font_size_px);
7696                    let ws = style.word_spacing.resolve_px(style.font_size_px);
7697                    // Tab stop interval: tab_size * (space advance + letter-spacing + word-spacing)
7698                    let tab_interval = style.tab_size * (space_advance_approx + ls + ws);
7699                    // Calculate current advance to find next tab stop
7700                    let current_advance: f32 = shaped.iter().map(|item| {
7701                        match item {
7702                            ShapedItem::Cluster(c) => c.advance,
7703                            ShapedItem::Tab { bounds, .. } => bounds.width,
7704                            ShapedItem::Object { bounds, .. } => bounds.width,
7705                            _ => 0.0,
7706                        }
7707                    }).sum();
7708                    // Next tab stop = next multiple of tab_interval from content edge
7709                    let next_tab_stop = ((current_advance / tab_interval).floor() + 1.0) * tab_interval;
7710                    let mut tab_width = next_tab_stop - current_advance;
7711                    // "If this distance is less than 0.5ch, then the subsequent tab stop is used instead."
7712                    let half_ch = space_advance_approx * 0.5;
7713                    if tab_width < half_ch {
7714                        tab_width += tab_interval;
7715                    }
7716                    shaped.push(ShapedItem::Tab {
7717                        source: *source,
7718                        bounds: Rect {
7719                            x: 0.0,
7720                            y: 0.0,
7721                            width: tab_width,
7722                            height: 0.0,
7723                        },
7724                    });
7725                }
7726            }
7727            LogicalItem::Ruby {
7728                source,
7729                base_text,
7730                ruby_text,
7731                style,
7732            } => {
7733                // CSS Ruby Layout (§3): the annotation (ruby-text) is laid out at its used
7734                // `font-size` — the UA default is `RUBY_ANNOTATION_FONT_SCALE` of the base —
7735                // and centered over the base, with the ruby box reserving the WIDER of the
7736                // two inline-sizes and stacking the annotation line above the base line.
7737                //
7738                // Both the base and the annotation are shaped to obtain their REAL inline
7739                // advances (no `chars * font_size * 0.6` fudge). The annotation is shaped at
7740                // the scaled style so its width reflects the smaller glyphs.
7741                let base_font_size = style.font_size_px;
7742                let annotation_font_size = base_font_size * RUBY_ANNOTATION_FONT_SCALE;
7743
7744                let mut annotation_props = (**style).clone();
7745                annotation_props.font_size_px = annotation_font_size;
7746                let annotation_style = Arc::new(annotation_props);
7747
7748                // Fallback estimate (only when shaping fails / no font chain): 1em per char
7749                // is a closer CJK approximation than the old 0.6 ratio.
7750                let base_width = measure_run_advance(
7751                    base_text, style, item.script, *source, font_chain_cache, fc_cache,
7752                    loaded_fonts,
7753                )
7754                .unwrap_or_else(|| base_text.chars().count() as f32 * base_font_size);
7755                let annotation_width = measure_run_advance(
7756                    ruby_text, &annotation_style, item.script, *source, font_chain_cache,
7757                    fc_cache, loaded_fonts,
7758                )
7759                .unwrap_or_else(|| ruby_text.chars().count() as f32 * annotation_font_size);
7760
7761                let base_line_height =
7762                    style.line_height.resolve(base_font_size, 0.0, 0.0, 0.0, 0);
7763                let annotation_line_height = annotation_style.line_height.resolve(
7764                    annotation_font_size, 0.0, 0.0, 0.0, 0,
7765                );
7766                // The ruby box reserves the wider inline-size, and stacks the annotation
7767                // line (at its smaller font-size) above the base line.
7768                let (reserved_width, reserved_height) = ruby_reserved_box(
7769                    base_width,
7770                    annotation_width,
7771                    base_line_height,
7772                    annotation_line_height,
7773                );
7774
7775                // TODO2: the annotation glyphs are now correctly sized + reserve vertical
7776                // space above the base, but are not yet emitted as a separately positioned
7777                // (centered) run — `ShapedItem::Object` carries only the base `StyledRun`.
7778                // Rendering the centered annotation needs a ruby-aware `ShapedItem` variant
7779                // (rendering-structural change); deferred to keep this change layout-safe.
7780                shaped.push(ShapedItem::Object {
7781                    source: *source,
7782                    bounds: Rect {
7783                        x: 0.0,
7784                        y: 0.0,
7785                        width: reserved_width,
7786                        height: reserved_height,
7787                    },
7788                    baseline_offset: 0.0,
7789                    content: InlineContent::Text(StyledRun {
7790                        text: base_text.clone(),
7791                        style: style.clone(),
7792                        logical_start_byte: 0,
7793                        source_node_id: None,
7794                    }),
7795                });
7796            }
7797            LogicalItem::CombinedText {
7798                style,
7799                source,
7800                text,
7801            } => {
7802                let language = script_to_language(item.script, &item.text);
7803
7804                // +spec:width-calculation:657f75 - convert full-width chars to non-full-width before compression
7805                // +spec:width-calculation:d0a295 - full-width digit conversion example (e.g. "23" stays narrow)
7806                // When combined text has more than one typographic character unit,
7807                // full-width characters (U+FF01..U+FF5E) are converted to their
7808                // ASCII equivalents (U+0021..U+007E) before compression.
7809                let text = if text.chars().count() > 1 {
7810                    let converted: String = text.chars().map(|c| {
7811                        let cp = c as u32;
7812                        if (0xFF01..=0xFF5E).contains(&cp) {
7813                            // Reverse of text-transform: full-width
7814                            char::from_u32(cp - 0xFF01 + 0x0021).unwrap_or(c)
7815                        } else {
7816                            c
7817                        }
7818                    }).collect();
7819                    converted
7820                } else {
7821                    text.clone()
7822                };
7823
7824                // +spec:width-calculation:1ed84d - OpenType compression (half-width/third-width substitution)
7825                // is delegated to the font shaping layer via shape_text()
7826
7827                // Shape CombinedText using either FontRef directly or fontconfig-resolved font
7828                let glyphs: Vec<Glyph> = match &style.font_stack {
7829                    FontStack::Ref(font_ref) => {
7830                        // For FontRef, use the font directly without fontconfig
7831                        if let Some(msgs) = debug_messages {
7832                            msgs.push(LayoutDebugMessage::info(format!(
7833                                "[TextLayout] Using direct FontRef for CombinedText: '{}'",
7834                                text.chars().take(30).collect::<String>()
7835                            )));
7836                        }
7837                        font_ref.shape_text(
7838                            &text,
7839                            item.script,
7840                            language,
7841                            BidiDirection::Ltr,
7842                            style.as_ref(),
7843                        )?
7844                    }
7845                    FontStack::Stack(selectors) => {
7846                        // Build FontChainKey and resolve through fontconfig
7847                        let cache_key = FontChainKey::from_selectors(selectors);
7848
7849                        let Some(font_chain) = font_chain_cache.get(&cache_key) else {
7850                            if let Some(msgs) = debug_messages {
7851                                msgs.push(LayoutDebugMessage::warning(format!(
7852                                    "[TextLayout] Font chain not pre-resolved for CombinedText {:?}",
7853                                    cache_key.font_families
7854                                )));
7855                            }
7856                            idx += 1;
7857                            continue;
7858                        };
7859
7860                        // Per-character font fallback for CombinedText
7861                        let segments = split_text_by_font_coverage(&text, font_chain, fc_cache, loaded_fonts);
7862                        let mut all_glyphs = Vec::new();
7863                        for (seg_start, seg_end, font_id) in &segments {
7864                            let Some(font) = loaded_fonts.get(font_id) else { continue; };
7865                            let segment_text = &text[*seg_start..*seg_end];
7866                            let mut seg_glyphs = font.shape_text(
7867                                segment_text,
7868                                item.script,
7869                                language,
7870                                BidiDirection::Ltr,
7871                                style.as_ref(),
7872                            )?;
7873                            // Fix byte offsets for glyphs
7874                            if *seg_start > 0 {
7875                                for g in &mut seg_glyphs {
7876                                    g.logical_byte_index += *seg_start;
7877                                    g.cluster += *seg_start as u32;
7878                                }
7879                            }
7880                            all_glyphs.extend(seg_glyphs);
7881                        }
7882                        if all_glyphs.is_empty() {
7883                            idx += 1;
7884                            continue;
7885                        }
7886                        all_glyphs
7887                    }
7888                };
7889
7890                let shaped_glyphs: ShapedGlyphVec = glyphs
7891                    .into_iter()
7892                    .map(|g| ShapedGlyph {
7893                        kind: GlyphKind::Character,
7894                        glyph_id: g.glyph_id,
7895                        script: g.script,
7896                        font_hash: g.font_hash,
7897                        font_metrics: g.font_metrics,
7898                        style: g.style,
7899                        cluster_offset: 0,
7900                        advance: g.advance,
7901                        kerning: g.kerning,
7902                        offset: g.offset,
7903                        vertical_advance: g.vertical_advance,
7904                        vertical_offset: g.vertical_bearing,
7905                    })
7906                    .collect();
7907
7908                // +spec:block-formatting-context:dc4549 - text-combine-upright compression: UA may scale composition to match 水 advance height
7909                let total_width: f32 = shaped_glyphs.iter().map(|g| g.advance + g.kerning).sum();
7910                // +spec:inline-formatting-context:8c5969 - text-combine-upright baseline centering
7911                // The composition forms a 1em square. Per spec, its baseline must be
7912                // chosen so the square is centered between the text-over and text-under
7913                // baselines of the parent inline box. We approximate by using font_size
7914                // as the square height and centering it (baseline_offset = em_size / 2).
7915                let em_size = shaped_glyphs.first()
7916                    .map_or(style.font_size_px, |g| g.style.font_size_px);
7917                let bounds = Rect {
7918                    x: 0.0,
7919                    y: 0.0,
7920                    width: total_width,
7921                    height: em_size,
7922                };
7923
7924                shaped.push(ShapedItem::CombinedBlock {
7925                    source: *source,
7926                    glyphs: shaped_glyphs,
7927                    bounds,
7928                    baseline_offset: em_size / 2.0,
7929                });
7930            }
7931            LogicalItem::Object {
7932                content, source, ..
7933            } => {
7934                let (bounds, baseline) = measure_inline_object(content)?;
7935                shaped.push(ShapedItem::Object {
7936                    source: *source,
7937                    bounds,
7938                    baseline_offset: baseline,
7939                    content: content.clone(),
7940                });
7941            }
7942            LogicalItem::Break { source, break_info } => {
7943                shaped.push(ShapedItem::Break {
7944                    source: *source,
7945                    break_info: *break_info,
7946                });
7947            }
7948        }
7949        idx += 1;
7950    }
7951
7952    Ok(shaped)
7953}
7954
7955/// Returns true if `c` is a hanging punctuation stop or comma per CSS Text 3 §8.2.1.
7956// +spec:hanging-punctuation - full stop/comma character list per CSS Text 3 §8.2.1
7957const fn is_hanging_punctuation_char(c: char) -> bool {
7958    matches!(c,
7959        ','      | // U+002C COMMA
7960        '.'      | // U+002E FULL STOP
7961        '\u{060C}' | // ARABIC COMMA
7962        '\u{06D4}' | // ARABIC FULL STOP
7963        '\u{3001}' | // IDEOGRAPHIC COMMA
7964        '\u{3002}' | // IDEOGRAPHIC FULL STOP
7965        '\u{FF0C}' | // FULLWIDTH COMMA
7966        '\u{FF0E}' | // FULLWIDTH FULL STOP
7967        '\u{FE50}' | // SMALL COMMA
7968        '\u{FE51}' | // SMALL IDEOGRAPHIC COMMA
7969        '\u{FE52}' | // SMALL FULL STOP
7970        '\u{FF61}' | // HALFWIDTH IDEOGRAPHIC FULL STOP
7971        '\u{FF64}'   // HALFWIDTH IDEOGRAPHIC COMMA
7972    )
7973}
7974
7975/// Helper to check if a cluster contains only hanging punctuation.
7976// +spec:box-model:8bbcd1 - non-zero inline-axis borders/padding between hangable glyph and line edge prevent hanging
7977/// +spec:inline-formatting-context:135be2 - hanging punctuation placed outside the line box
7978/// +spec:intrinsic-sizing:407d8b - hanging glyphs not counted in intrinsic size computation
7979fn is_hanging_punctuation(item: &ShapedItem) -> bool {
7980    if let ShapedItem::Cluster(c) = item {
7981        if c.glyphs.len() == 1 {
7982            c.text.chars().next().is_some_and(is_hanging_punctuation_char)
7983        } else {
7984            false
7985        }
7986    } else {
7987        false
7988    }
7989}
7990
7991#[allow(clippy::cast_possible_truncation)] // bounded pixel/coord/colour/glyph cast
7992#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
7993fn shape_text_correctly<T: ParsedFontTrait>(
7994    text: &str,
7995    script: Script,
7996    language: Language,
7997    direction: BidiDirection,
7998    font: &T, // Changed from &Arc<T>
7999    style: &Arc<StyleProperties>,
8000    source_index: ContentIndex,
8001    source_node_id: Option<NodeId>,
8002) -> Result<Vec<ShapedCluster>, LayoutError> {
8003    unsafe { crate::az_mark(0x60864_u32, 0xC0DE_0864_u32); } // [g123] shape_text_correctly ENTERED
8004    let glyphs = font.shape_text(text, script, language, direction, style.as_ref())?;
8005    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
8006
8007    if glyphs.is_empty() {
8008        return Ok(Vec::new());
8009    }
8010
8011    let mut clusters = Vec::new();
8012
8013    // Group glyphs by cluster ID from the shaper.
8014    let mut current_cluster_glyphs = Vec::new();
8015    let mut cluster_id = glyphs[0].cluster;
8016    let mut cluster_start_byte_in_text = glyphs[0].logical_byte_index;
8017
8018    for glyph in glyphs {
8019        if glyph.cluster != cluster_id {
8020            // Finalize previous cluster
8021            let advance = current_cluster_glyphs
8022                .iter()
8023                .map(|g: &Glyph| g.advance)
8024                .sum();
8025
8026            // Safely extract cluster text - handle cases where byte indices may be out of order
8027            // (can happen with RTL text or complex GSUB reordering)
8028            let (start, end) = if cluster_start_byte_in_text <= glyph.logical_byte_index {
8029                (cluster_start_byte_in_text, glyph.logical_byte_index)
8030            } else {
8031                (glyph.logical_byte_index, cluster_start_byte_in_text)
8032            };
8033            let cluster_text = text.get(start..end).unwrap_or("");
8034
8035            clusters.push(ShapedCluster {
8036                text: cluster_text.to_string(), // Store original text for hyphenation
8037                source_cluster_id: GraphemeClusterId {
8038                    source_run: source_index.run_index,
8039                    start_byte_in_run: cluster_id,
8040                },
8041                source_content_index: source_index,
8042                source_node_id,
8043                glyphs: current_cluster_glyphs
8044                    .iter()
8045                    .map(|g| {
8046                        // Calculate cluster_offset safely
8047                        let cluster_offset = if g.logical_byte_index >= cluster_start_byte_in_text {
8048                            (g.logical_byte_index - cluster_start_byte_in_text) as u32
8049                        } else {
8050                            0
8051                        };
8052                        ShapedGlyph {
8053                            kind: if g.glyph_id == 0 {
8054                                GlyphKind::NotDef
8055                            } else {
8056                                GlyphKind::Character
8057                            },
8058                            glyph_id: g.glyph_id,
8059                            script: g.script,
8060                            font_hash: g.font_hash,
8061                            font_metrics: g.font_metrics,
8062                            style: g.style.clone(),
8063                            cluster_offset,
8064                            advance: g.advance,
8065                            kerning: g.kerning,
8066                            vertical_advance: g.vertical_advance,
8067                            vertical_offset: g.vertical_bearing,
8068                            offset: g.offset,
8069                        }
8070                    })
8071                    .collect(),
8072                advance,
8073                direction,
8074                style: style.clone(),
8075                marker_position_outside: None,
8076                is_first_fragment: true,
8077                is_last_fragment: true,
8078            });
8079            current_cluster_glyphs.clear();
8080            cluster_id = glyph.cluster;
8081            cluster_start_byte_in_text = glyph.logical_byte_index;
8082        }
8083        current_cluster_glyphs.push(glyph);
8084    }
8085
8086    // Finalize the last cluster
8087    if !current_cluster_glyphs.is_empty() {
8088        let advance = current_cluster_glyphs
8089            .iter()
8090            .map(|g: &Glyph| g.advance)
8091            .sum();
8092        let cluster_text = text.get(cluster_start_byte_in_text..).unwrap_or("");
8093        clusters.push(ShapedCluster {
8094            text: cluster_text.to_string(), // Store original text
8095            source_cluster_id: GraphemeClusterId {
8096                source_run: source_index.run_index,
8097                start_byte_in_run: cluster_id,
8098            },
8099            source_content_index: source_index,
8100            source_node_id,
8101            glyphs: current_cluster_glyphs
8102                .iter()
8103                .map(|g| {
8104                    // Calculate cluster_offset safely
8105                    let cluster_offset = if g.logical_byte_index >= cluster_start_byte_in_text {
8106                        (g.logical_byte_index - cluster_start_byte_in_text) as u32
8107                    } else {
8108                        0
8109                    };
8110                    ShapedGlyph {
8111                        kind: if g.glyph_id == 0 {
8112                            GlyphKind::NotDef
8113                        } else {
8114                            GlyphKind::Character
8115                        },
8116                        glyph_id: g.glyph_id,
8117                        font_hash: g.font_hash,
8118                        font_metrics: g.font_metrics,
8119                        style: g.style.clone(),
8120                        script: g.script,
8121                        vertical_advance: g.vertical_advance,
8122                        vertical_offset: g.vertical_bearing,
8123                        cluster_offset,
8124                        advance: g.advance,
8125                        kerning: g.kerning,
8126                        offset: g.offset,
8127                    }
8128                })
8129                .collect(),
8130            advance,
8131            direction,
8132            style: style.clone(),
8133            marker_position_outside: None,
8134            is_first_fragment: true,
8135            is_last_fragment: true,
8136        });
8137    }
8138
8139    Ok(clusters)
8140}
8141
8142/// Measures a non-text object, returning its bounds and baseline offset.
8143fn measure_inline_object(item: &InlineContent) -> Result<(Rect, f32), LayoutError> {
8144    match item {
8145        InlineContent::Image(img) => {
8146            let size = img.display_size.unwrap_or(img.intrinsic_size);
8147            Ok((
8148                Rect {
8149                    x: 0.0,
8150                    y: 0.0,
8151                    width: size.width,
8152                    height: size.height,
8153                },
8154                img.baseline_offset,
8155            ))
8156        }
8157        InlineContent::Shape(shape) => Ok({
8158            let size = shape.shape_def.get_size();
8159            (
8160                Rect {
8161                    x: 0.0,
8162                    y: 0.0,
8163                    width: size.width,
8164                    height: size.height,
8165                },
8166                shape.baseline_offset,
8167            )
8168        }),
8169        InlineContent::Space(space) => Ok((
8170            Rect {
8171                x: 0.0,
8172                y: 0.0,
8173                width: space.width,
8174                height: 0.0,
8175            },
8176            0.0,
8177        )),
8178        InlineContent::Marker { .. } => {
8179            // Markers are treated as text content, not measurable objects
8180            Err(LayoutError::InvalidText(
8181                "Marker is text content, not a measurable object".into(),
8182            ))
8183        }
8184        _ => Err(LayoutError::InvalidText("Not a measurable object".into())),
8185    }
8186}
8187
8188// --- Stage 4 Implementation: Vertical Text ---
8189
8190/// Applies orientation and vertical metrics to glyphs if the writing mode is vertical.
8191// +spec:block-formatting-context:227171 - vertical glyph orientation with fallback vertical metrics
8192// +spec:block-formatting-context:df20a5 - mixed vertical orientation dispatch (TextOrientation::Mixed)
8193fn apply_text_orientation(
8194    items: Arc<Vec<ShapedItem>>,
8195    constraints: &UnifiedConstraints,
8196) -> Arc<Vec<ShapedItem>> {
8197    if !constraints.is_vertical() {
8198        return items;
8199    }
8200
8201    let mut oriented_items = Vec::with_capacity(items.len());
8202    let writing_mode = constraints.writing_mode.unwrap_or_default();
8203
8204    for item in items.iter() {
8205        match item {
8206            ShapedItem::Cluster(cluster) => {
8207                let mut new_cluster = cluster.clone();
8208                let mut total_vertical_advance = 0.0;
8209
8210                for glyph in &mut new_cluster.glyphs {
8211                    // Use the vertical metrics already computed during shaping
8212                    // If they're zero, use fallback values
8213                    if glyph.vertical_advance > 0.0 {
8214                        total_vertical_advance += glyph.vertical_advance;
8215                    } else {
8216                        // Fallback: use line height for vertical advance
8217                        let fallback_advance = cluster.style.line_height.resolve_with_metrics(cluster.style.font_size_px, &glyph.font_metrics);
8218                        glyph.vertical_advance = fallback_advance;
8219                        // Center the glyph horizontally as a fallback
8220                        glyph.vertical_offset = Point {
8221                            x: -glyph.advance / 2.0,
8222                            y: 0.0,
8223                        };
8224                        total_vertical_advance += fallback_advance;
8225                    }
8226                }
8227                // The cluster's `advance` now represents vertical advance.
8228                new_cluster.advance = total_vertical_advance;
8229                oriented_items.push(ShapedItem::Cluster(new_cluster));
8230            }
8231            // Non-text objects also need their advance axis swapped.
8232            ShapedItem::Object {
8233                source,
8234                bounds,
8235                baseline_offset,
8236                content,
8237            } => {
8238                let mut new_bounds = *bounds;
8239                std::mem::swap(&mut new_bounds.width, &mut new_bounds.height);
8240                oriented_items.push(ShapedItem::Object {
8241                    source: *source,
8242                    bounds: new_bounds,
8243                    baseline_offset: *baseline_offset,
8244                    content: content.clone(),
8245                });
8246            }
8247            _ => oriented_items.push(item.clone()),
8248        }
8249    }
8250
8251    Arc::new(oriented_items)
8252}
8253
8254// --- Stage 5 & 6 Implementation: Combined Layout Pass ---
8255// This section replaces the previous simple line breaking and positioning logic.
8256
8257/// Extracts the per-item vertical-align from a `ShapedItem`.
8258///
8259/// For `Object` items (inline-blocks, images), this returns the alignment stored
8260/// in the original `InlineContent`. For text clusters and other items, returns `None`
8261/// to indicate the global `constraints.vertical_align` should be used.
8262fn get_item_vertical_align(item: &ShapedItem) -> Option<VerticalAlign> {
8263    match item {
8264        ShapedItem::Object { content, .. } => match content {
8265            InlineContent::Image(img) => Some(img.alignment),
8266            InlineContent::Shape(shape) => Some(shape.alignment),
8267            _ => None,
8268        },
8269        // A text cluster carries its span's vertical-align on its style. A non-baseline
8270        // value (sub / super / length / percentage on an inline <span>) overrides the
8271        // line's default alignment so the cluster is shifted; baseline yields None so the
8272        // cluster keeps the line/IFC default.
8273        ShapedItem::Cluster(c) => match c.style.vertical_align {
8274            VerticalAlign::Baseline => None,
8275            va => Some(va),
8276        },
8277        _ => None,
8278    }
8279}
8280
8281/// Approximate version of `get_item_vertical_metrics` for use without constraints (e.g. `bounds()`).
8282/// Uses 80/20 ascent/descent ratio as fallback for empty-glyph strut case.
8283#[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
8284#[must_use] pub fn get_item_vertical_metrics_approx(item: &ShapedItem) -> (f32, f32) {
8285    // For non-empty clusters, delegate to the font-metrics-based calculation
8286    if let ShapedItem::Cluster(c) = item {
8287        if !c.glyphs.is_empty() {
8288            // Reuse the glyph-based calculation (same as get_item_vertical_metrics)
8289            let (asc, desc) = c.glyphs
8290                .iter()
8291                .fold((0.0f32, 0.0f32), |(max_asc, max_desc), glyph| {
8292                    let metrics = &glyph.font_metrics;
8293                    if metrics.units_per_em == 0 {
8294                        return (max_asc, max_desc);
8295                    }
8296                    let scale = glyph.style.font_size_px / f32::from(metrics.units_per_em);
8297                    let font_ascent = metrics.ascent * scale;
8298                    let font_descent = (-metrics.descent * scale).max(0.0);
8299                    let ad = font_ascent + font_descent;
8300                    let resolved_lh = c.style.line_height.resolve_with_metrics(glyph.style.font_size_px, &glyph.font_metrics);
8301                    let half_leading = (resolved_lh - ad) / 2.0;
8302                    (max_asc.max(font_ascent + half_leading), max_desc.max(font_descent + half_leading))
8303                });
8304            return (asc, desc);
8305        }
8306    }
8307    // Fallback for empty glyphs or non-cluster items
8308    match item {
8309        ShapedItem::Cluster(c) => {
8310            let lh = c.style.line_height.resolve(c.style.font_size_px, 0.0, 0.0, 0.0, 0);
8311            (lh * FALLBACK_ASCENT_RATIO, lh * FALLBACK_DESCENT_RATIO)
8312        }
8313        ShapedItem::CombinedBlock { bounds, .. } => {
8314            (bounds.height * FALLBACK_ASCENT_RATIO, bounds.height * FALLBACK_DESCENT_RATIO)
8315        }
8316        ShapedItem::Object { bounds, .. } => (bounds.height, 0.0),
8317        ShapedItem::Tab { bounds, .. } => {
8318            (bounds.height * FALLBACK_ASCENT_RATIO, bounds.height * FALLBACK_DESCENT_RATIO)
8319        }
8320        ShapedItem::Break { .. } => (0.0, 0.0),
8321    }
8322}
8323
8324/// Gets the ascent (distance from baseline to top) and descent (distance from baseline to bottom)
8325/// for a single item, incorporating half-leading from line-height.
8326///
8327// +spec:box-model:37aeb2 - inline box margins/borders/padding do not affect line box height (leading model)
8328// +spec:display-property:184f0d - Inline box baseline derives from first available font metrics
8329// +spec:display-property:238bf5 - Inline box layout bounds from own text metrics, not child boxes
8330// +spec:display-property:29b194 - baseline determination for inline boxes (CSS Box Alignment 3 §9.1)
8331// +spec:display-property:2987db - per-glyph font metrics impact inline box layout bounds (line-height: normal caveat not yet distinguished)
8332/// +spec:display-property:fd42a9 - line-height affects line box contribution, not inline box size
8333// +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
8334// +spec:font-metrics:773029 - ascent/descent font metrics used for baseline calculations (visual centering depends on these)
8335// +spec:font-metrics:f42870 - half-leading model: leading = line-height - (ascent + descent), distributed equally above/below
8336// +spec:writing-modes:531c2e - UAs should use vertical baseline tables in vertical typographic modes
8337#[must_use] pub fn get_item_vertical_metrics(item: &ShapedItem, constraints: &UnifiedConstraints) -> (f32, f32) {
8338    // (ascent, descent)
8339    match item {
8340        ShapedItem::Cluster(c) => {
8341            if c.glyphs.is_empty() {
8342                // +spec:display-property:626c86 - strut for inline box with no glyphs uses first available font metrics
8343                // +spec:line-height:0078fa - strut: zero-width inline box with element's font/line-height
8344                // §10.8.1 strut: if inline box contains no glyphs, it is considered to
8345                // contain a strut with A and D of the element's first available font.
8346                // Half-leading: L = line-height - (A + D), A' = A + L/2, D' = D + L/2
8347                let ad = constraints.strut_ascent + constraints.strut_descent;
8348                let resolved_lh = c.style.line_height.resolve(c.style.font_size_px, 0.0, 0.0, 0.0, 0);
8349                let half_leading = (resolved_lh - ad) / 2.0;
8350                return (constraints.strut_ascent + half_leading, constraints.strut_descent + half_leading);
8351            }
8352            // +spec:box-model:0b3e1f - inline non-replaced box height uses only line-height, not vertical padding/border/margin
8353            // +spec:display-property:80b900 - fallback glyphs affect line box size via per-glyph metrics
8354            // +spec:display-property:d52f26 - layout bounds enclose all glyphs from highest A to deepest D
8355            // +spec:font-metrics:387751 - content area uses max ascenders/descenders across all fonts
8356            // +spec:font-metrics:790fd2 - half-leading: L = line-height - (A+D), A' = A + L/2, D' = D + L/2
8357            // +spec:line-height:1ae6f5 - line-height on non-replaced inline: half-leading model
8358            // +spec:line-height:0078fa - half-leading: L = line-height - (A+D), distributed equally above/below
8359            // +spec:line-height:32b3da - half-leading: L = line-height - AD, A' = A + L/2, D' = D + L/2
8360            // §10.8.1: for each glyph determine A, D from font metrics,
8361            // then L = line-height - (A + D), and adjust: A' = A + L/2, D' = D + L/2.
8362            // Note: L may be negative.
8363            // +spec:height-calculation:eb98b5 - multi-font normal line-height uses max across glyph metrics
8364            c.glyphs
8365                .iter()
8366                .fold((0.0f32, 0.0f32), |(max_asc, max_desc), glyph| {
8367                    let metrics = &glyph.font_metrics;
8368                    if metrics.units_per_em == 0 {
8369                        return (max_asc, max_desc);
8370                    }
8371                    let scale = glyph.style.font_size_px / f32::from(metrics.units_per_em);
8372                    let a = metrics.ascent * scale;
8373                    // Descent in OpenType is typically negative, so we negate it to get a positive
8374                    // distance.
8375                    let d = (-metrics.descent * scale).max(0.0);
8376                    let ad = a + d;
8377                    let resolved_lh = glyph.style.line_height.resolve_with_metrics(glyph.style.font_size_px, &glyph.font_metrics);
8378                    let leading = resolved_lh - ad;
8379                    let half_leading = leading / 2.0;
8380                    let item_asc = a + half_leading;
8381                    let item_desc = d + half_leading;
8382                    (max_asc.max(item_asc), max_desc.max(item_desc))
8383                })
8384        }
8385        ShapedItem::Object {
8386            bounds,
8387            baseline_offset,
8388            ..
8389        } => {
8390            // Per analysis, `baseline_offset` is the distance from the bottom.
8391            // bounds.height already includes margins (set from margin_box_height in fc.rs)
8392            let ascent = bounds.height - *baseline_offset;
8393            let descent = *baseline_offset;
8394            (ascent.max(0.0), descent.max(0.0))
8395        }
8396        ShapedItem::CombinedBlock {
8397            bounds,
8398            baseline_offset,
8399            ..
8400        } => {
8401            // CORRECTED: Treat baseline_offset consistently as distance from the bottom (descent).
8402            let ascent = bounds.height - *baseline_offset;
8403            let descent = *baseline_offset;
8404            (ascent.max(0.0), descent.max(0.0))
8405        }
8406        _ => (0.0, 0.0), // Breaks and other non-visible items don't affect line height.
8407    }
8408}
8409
8410// +spec:block-formatting-context:861155 - vertical-align affects vertical positioning inside line box for inline-level elements
8411/// Calculates the maximum ascent and descent for an entire line of items.
8412/// This determines the "line box" used for vertical alignment.
8413/// // +spec:display-contents:66d910 - line box height fitted to contents, controlled by line-height
8414// +spec:inline-formatting-context:c3fc54 - line box tall enough for all boxes, vertical-align determines alignment within line box
8415///
8416/// Per CSS 2.2 §10.8: Inline-level boxes aligned 'top' or 'bottom' must be aligned
8417/// so as to minimize the line box height. The algorithm is:
8418/// 1. First pass: compute line box height from baseline-aligned items only
8419///    (baseline, sub, super, middle, text-top, text-bottom, offset).
8420/// 2. Second pass: check if any top/bottom-aligned items are taller than the
8421///    line box from pass 1, and expand if necessary.
8422// +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)
8423fn calculate_line_metrics(
8424    items: &[ShapedItem],
8425    default_vertical_align: VerticalAlign,
8426    constraints: &UnifiedConstraints,
8427) -> (f32, f32) {
8428    // +spec:font-metrics:95152b - baseline alignment: items with different font sizes aligned by matching alphabetic baselines
8429    // Pass 1: Compute ascent/descent from baseline-aligned items only
8430    // (i.e., items that are NOT vertical-align: top or bottom).
8431    let (mut max_asc, mut max_desc) = items
8432        .iter()
8433        .fold((0.0f32, 0.0f32), |(max_asc, max_desc), item| {
8434            let effective_align = get_item_vertical_align(item)
8435                .unwrap_or(default_vertical_align);
8436            match effective_align {
8437                VerticalAlign::Top | VerticalAlign::Bottom => {
8438                    // Skip top/bottom items in first pass
8439                    (max_asc, max_desc)
8440                }
8441                _ => {
8442                    let (item_asc, item_desc) = get_item_vertical_metrics(item, constraints);
8443                    (max_asc.max(item_asc), max_desc.max(item_desc))
8444                }
8445            }
8446        });
8447
8448    let baseline_line_height = max_asc + max_desc;
8449
8450    // Pass 2: Check top/bottom aligned items. If any of them is taller
8451    // than the current line box, expand the line box to fit.
8452    for item in items {
8453        let effective_align = get_item_vertical_align(item)
8454            .unwrap_or(default_vertical_align);
8455        match effective_align {
8456            VerticalAlign::Top | VerticalAlign::Bottom => {
8457                let (item_asc, item_desc) = get_item_vertical_metrics(item, constraints);
8458                let item_height = item_asc + item_desc;
8459                if item_height > baseline_line_height {
8460                    // To minimize height, expand in the direction the item is aligned to
8461                    if effective_align == VerticalAlign::Top {
8462                        // Top-aligned item extends downward from line top
8463                        max_desc = max_desc.max(item_height - max_asc);
8464                    } else {
8465                        // Bottom-aligned item extends upward from line bottom
8466                        max_asc = max_asc.max(item_height - max_desc);
8467                    }
8468                }
8469            }
8470            _ => {} // Already handled in first pass
8471        }
8472    }
8473
8474    (max_asc, max_desc)
8475}
8476
8477/// Unicode Bidi Algorithm rule L2, applied at the glyph/cluster level for one line.
8478///
8479/// `reorder_logical_items` already placed the level RUNS of the paragraph in
8480/// visual order (rule L2 at the run level, via `unicode_bidi::visual_runs`), but
8481/// left the clusters *within* each run in LOGICAL order. To finish L2 the clusters
8482/// of every RTL (odd-level) run must be reversed so the run reads right-to-left.
8483///
8484/// We reverse each maximal contiguous run of clusters that share the same
8485/// direction, flipping only the RTL ones. Re-running full L2 over the whole line
8486/// instead would double-reverse the run order that is already correct. Grouping
8487/// by direction is exact here: under implicit bidi (no explicit embedding
8488/// controls, which azul does not inject) two runs of the same direction are never
8489/// visually adjacent — a higher even level nests inside its odd parent and a lower
8490/// level separates two same-parity runs — so a "same-direction" group is always a
8491/// single real level run. Non-cluster items (breaks/objects/tabs) act as run
8492/// boundaries. Applied per line, so a wrapped RTL run reorders correctly per line.
8493fn apply_l2_visual_reversal(line_items: &mut [ShapedItem]) {
8494    let dir_of = |it: &ShapedItem| it.as_cluster().map(|c| c.direction);
8495    let mut i = 0;
8496    while i < line_items.len() {
8497        let Some(dir) = dir_of(&line_items[i]) else {
8498            i += 1;
8499            continue;
8500        };
8501        let mut j = i + 1;
8502        while j < line_items.len() && dir_of(&line_items[j]) == Some(dir) {
8503            j += 1;
8504        }
8505        if dir == BidiDirection::Rtl {
8506            line_items[i..j].reverse();
8507        }
8508        i = j;
8509    }
8510}
8511
8512/// Performs layout for a single fragment, consuming items from a `BreakCursor`.
8513///
8514/// This function contains the core line-breaking and positioning logic, but is
8515/// designed to operate on a portion of a larger content stream and within the
8516/// constraints of a single geometric area (a fragment).
8517///
8518/// The loop terminates when either the fragment is filled (e.g., runs out of
8519/// vertical space) or the content stream managed by the `cursor` is exhausted.
8520///
8521/// # CSS Inline Layout Module Level 3 Implementation
8522///
8523/// This function implements the inline formatting context as described in:
8524/// <https://www.w3.org/TR/css-inline-3/#inline-formatting-context>
8525///
8526/// ## § 2.1 Layout of Line Boxes
8527/// "In general, the line-left edge of a line box touches the line-left edge of its
8528/// containing block and the line-right edge touches the line-right edge of its
8529/// containing block, and thus the logical width of a line box is equal to the inner
8530/// logical width of its containing block."
8531///
8532/// [ISSUE] `available_width` should be set to the containing block's inner width,
8533/// but is currently defaulting to 0.0 in `UnifiedConstraints::default()`.
8534/// This causes premature line breaking.
8535///
8536/// ## § 2.2 Layout Within Line Boxes
8537/// The layout process follows these steps:
8538/// 1. Baseline Alignment: All inline-level boxes are aligned by their baselines
8539/// 2. Content Size Contribution: Calculate layout bounds for each box
8540/// 3. Line Box Sizing: Size line box to fit aligned layout bounds
8541/// 4. Content Positioning: Position boxes within the line box
8542///
8543/// ## Missing Features:
8544/// - § 3 Baselines and Alignment Metrics: Only basic baseline alignment implemented
8545/// - § 4 Baseline Alignment: vertical-align property not fully supported
8546/// - § 5 Line Spacing: line-height implemented, but line-fit-edge missing
8547/// - § 6 Trimming Leading: text-box-trim not implemented
8548#[allow(clippy::cast_precision_loss)] // bounded pixel/coord/colour/glyph cast
8549#[allow(clippy::too_many_lines, clippy::cognitive_complexity)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
8550/// # Errors
8551///
8552/// Returns a `LayoutError` if fragment layout fails.
8553pub fn perform_fragment_layout<T: ParsedFontTrait>(
8554    cursor: &mut BreakCursor<'_>,
8555    logical_items: &[LogicalItem],
8556    fragment_constraints: &UnifiedConstraints,
8557    debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
8558    fonts: &LoadedFonts<T>,
8559) -> Result<UnifiedLayout, LayoutError> {
8560    const MAX_EMPTY_SEGMENTS: usize = 1000; // Maximum allowed consecutive empty segments
8561    if let Some(msgs) = debug_messages {
8562        msgs.push(LayoutDebugMessage::info(
8563            "\n--- Entering perform_fragment_layout ---".to_string(),
8564        ));
8565        msgs.push(LayoutDebugMessage::info(format!(
8566            "Constraints: available_width={:?}, available_height={:?}, columns={}, text_wrap={:?}",
8567            fragment_constraints.available_width,
8568            fragment_constraints.available_height,
8569            fragment_constraints.columns,
8570            fragment_constraints.text_wrap
8571        )));
8572    }
8573
8574    // For TextWrap::Balance, use Knuth-Plass algorithm for optimal line breaking
8575    // This produces more visually balanced lines at the cost of more computation
8576    if fragment_constraints.text_wrap == TextWrap::Balance {
8577        if let Some(msgs) = debug_messages {
8578            msgs.push(LayoutDebugMessage::info(
8579                "Using Knuth-Plass algorithm for text-wrap: balance".to_string(),
8580            ));
8581        }
8582
8583        // Get the shaped items from the cursor
8584        let shaped_items: Vec<ShapedItem> = cursor.drain_remaining();
8585
8586        // +spec:line-breaking:90c1bd - only auto-hyphenate when language is known and hyphenation resource available
8587        let hyphenator = if fragment_constraints.hyphenation == Hyphens::Auto {
8588            fragment_constraints
8589                .hyphenation_language
8590                .and_then(|lang| get_hyphenator(lang).ok())
8591        } else {
8592            None
8593        };
8594
8595        // Use the Knuth-Plass algorithm for optimal line breaking
8596        return Ok(crate::text3::knuth_plass::kp_layout(
8597            &shaped_items,
8598            logical_items,
8599            fragment_constraints,
8600            hyphenator.as_ref(),
8601            fonts,
8602        ));
8603    }
8604
8605    // +spec:intrinsic-sizing:57e02d - hyphenation opportunities considered in min-content sizing
8606    let hyphenator = if fragment_constraints.hyphenation == Hyphens::Auto {
8607        fragment_constraints
8608            .hyphenation_language
8609            .and_then(|lang| get_hyphenator(lang).ok())
8610    } else {
8611        None
8612    };
8613
8614    let mut positioned_items = Vec::new();
8615    let mut layout_bounds = Rect::default();
8616
8617    let num_columns = fragment_constraints.columns.max(1);
8618    let total_column_gap = fragment_constraints.column_gap * (num_columns - 1) as f32;
8619
8620    // CSS Inline Layout § 2.1: "the logical width of a line box is equal to the inner
8621    // logical width of its containing block"
8622    //
8623    // Handle the different available space modes:
8624    // - Definite(width): Use the specified width for column calculation
8625    // - MinContent: Force line breaks at word boundaries, return widest word width
8626    // - MaxContent: Use a large value to allow content to expand naturally
8627    //
8628    // IMPORTANT: For MinContent, we do NOT use 0.0 (which would break after every character).
8629    // Instead, we use a large width but track the is_min_content flag to force word-level
8630    // line breaks in the line breaker. The actual min-content width is the width of the
8631    // widest resulting line (typically the widest word).
8632    let is_min_content = matches!(fragment_constraints.available_width, AvailableSpace::MinContent);
8633    let is_max_content = matches!(fragment_constraints.available_width, AvailableSpace::MaxContent);
8634    
8635    let column_width = match fragment_constraints.available_width {
8636        AvailableSpace::Definite(width) => (width - total_column_gap) / num_columns as f32,
8637        AvailableSpace::MinContent | AvailableSpace::MaxContent => {
8638            // For intrinsic sizing, use a large width to measure actual content width.
8639            // The line breaker will handle MinContent specially by breaking after each word.
8640            f32::MAX / 2.0
8641        }
8642    };
8643    let mut current_column = 0;
8644    if let Some(msgs) = debug_messages {
8645        msgs.push(LayoutDebugMessage::info(format!(
8646            "Column width calculated: {column_width}"
8647        )));
8648    }
8649
8650    // Use the CSS direction from constraints instead of auto-detecting from text
8651    // This ensures that mixed-direction text (e.g., "مرحبا - Hello") uses the
8652    // correct paragraph-level direction for alignment purposes.
8653    // With unicode-bidi: plaintext, direction is auto-detected from text content
8654    // per CSS Writing Modes §8.3.
8655    let base_direction = if fragment_constraints.unicode_bidi == UnicodeBidi::Plaintext {
8656        // Auto-detect from remaining shaped items' text content
8657        let remaining = &cursor.items[cursor.next_item_index..];
8658        let text: String = remaining.iter()
8659            .filter_map(|i| i.as_cluster())
8660            .map(|c| c.text.as_str())
8661            .collect();
8662        match unicode_bidi::get_base_direction(text.as_str()) {
8663            unicode_bidi::Direction::Ltr => BidiDirection::Ltr,
8664            unicode_bidi::Direction::Rtl => BidiDirection::Rtl,
8665            // No strong character: fall back to containing block direction
8666            unicode_bidi::Direction::Mixed => fragment_constraints.direction.unwrap_or(BidiDirection::Ltr),
8667        }
8668    } else {
8669        fragment_constraints.direction.unwrap_or(BidiDirection::Ltr)
8670    };
8671
8672    if let Some(msgs) = debug_messages {
8673        msgs.push(LayoutDebugMessage::info(format!(
8674            "[PFLayout] Base direction: {:?} (from CSS), Text align: {:?}",
8675            base_direction, fragment_constraints.text_align
8676        )));
8677    }
8678
8679    // +spec:multi-column - column-fill:balance (the initial/default value): content is
8680    // balanced so the columns are as short and as equal in height as possible. The column loop
8681    // below only advances to the next column once a column reaches `available_height` — but a
8682    // block on a page is handed the whole page height as available space, which the short
8683    // content never reaches, so every line lands in column 0 (a single visual column). Fix:
8684    // measure the total line count up front (a cheap dry run of the line breaker over a CLONED
8685    // cursor at the column width) and give each column an equal share of lines; the balanced
8686    // budget (content_lines / N) is far below the page-height threshold so it takes precedence.
8687    // Gated on num_columns>1 with no shape boundaries and non-intrinsic sizing — exactly the
8688    // otherwise-broken case — so single-column and shaped/intrinsic layouts are untouched
8689    // (zero blast radius). column-fill:auto (fill-then-advance) is rare and not modelled here.
8690    let balanced_lines_per_column: Option<usize> = if num_columns > 1
8691        && fragment_constraints.shape_boundaries.is_empty()
8692        && !is_min_content
8693        && !is_max_content
8694    {
8695        let mut probe = cursor.clone();
8696        let mut probe_col_constraints = fragment_constraints.clone();
8697        probe_col_constraints.available_width = AvailableSpace::Definite(column_width);
8698        let probe_line_height = fragment_constraints.resolved_line_height();
8699        // A line consumes at least one shaped item, so the item count bounds the loop.
8700        let iter_cap = probe.items.len().saturating_mul(4).max(64);
8701        let mut total_lines = 0usize;
8702        let mut probe_y = 0.0_f32;
8703        let mut probe_guard = 0usize;
8704        while !probe.is_done() && probe_guard < iter_cap {
8705            probe_guard += 1;
8706            let lc = get_line_constraints(probe_y, probe_line_height, &probe_col_constraints, &mut None);
8707            if lc.segments.is_empty() {
8708                break;
8709            }
8710            let (probe_line, _) = break_one_line(
8711                &mut probe,
8712                &lc,
8713                false,
8714                hyphenator.as_ref(),
8715                fonts,
8716                fragment_constraints.line_break,
8717                fragment_constraints.white_space_mode,
8718                fragment_constraints.overflow_wrap,
8719            );
8720            if probe_line.is_empty() {
8721                break;
8722            }
8723            total_lines += 1;
8724            probe_y += probe_line_height;
8725        }
8726        (total_lines > 0).then(|| total_lines.div_ceil(num_columns as usize).max(1))
8727    } else {
8728        None
8729    };
8730
8731    'column_loop: while current_column < num_columns {
8732        if let Some(msgs) = debug_messages {
8733            msgs.push(LayoutDebugMessage::info(format!(
8734                "\n-- Starting Column {current_column} --"
8735            )));
8736        }
8737        let column_start_x =
8738            (column_width + fragment_constraints.column_gap) * current_column as f32;
8739        let mut line_top_y = 0.0;
8740        let mut line_index = 0;
8741        let mut empty_segment_count = 0; // Failsafe counter for infinite loops
8742        let mut is_after_forced_break = false;
8743        // +spec:writing-modes:6e22a7 - vertical-rl advances columns (lines) right-to-left.
8744        // The positioner lays every line out at an increasing block-axis (x) offset from 0,
8745        // i.e. left-to-right. For vertical-rl we record each line's block band here so we can
8746        // mirror the block axis once the column's total extent is known (see after the loop).
8747        let column_item_start = positioned_items.len();
8748        let mut line_bands: Vec<(usize, f32, f32)> = Vec::new();
8749
8750        // [g147 az-web-lift] Hard total-iteration cap on the line-build loop. On the remill lift,
8751        // `cursor.is_done()` (or the empty-segment failsafe) mis-lifts for the NESTED IFC (content.len
8752        // reads 0 → the cursor is starved but never reports done) → this `while !cursor.is_done()` spins
8753        // forever → solveLayoutReal HANGS inside perform_fragment_layout. Cap total iterations so the loop
8754        // always converges (the harness can then read the markers). native is unaffected (far above real
8755        // line counts). The 0x60BC4 marker exposes the iteration count.
8756        #[allow(clippy::no_effect_underscore_binding)] // web_lift-gated debug iteration counter
8757        let mut _az_line_iters: usize = 0;
8758        while !cursor.is_done() {
8759            #[cfg(feature = "web_lift")]
8760            {
8761                _az_line_iters += 1;
8762                unsafe { crate::az_mark((0x60BC4) as u32, (_az_line_iters as u32 | 0xC0DE0000) as u32); }
8763                if _az_line_iters > 4096 {
8764                    break;
8765                }
8766            }
8767            if let Some(max_height) = fragment_constraints.available_height {
8768                if line_top_y >= max_height {
8769                    if let Some(msgs) = debug_messages {
8770                        msgs.push(LayoutDebugMessage::info(format!(
8771                            "  Column full (pen {line_top_y} >= height {max_height}), breaking to next column."
8772                        )));
8773                    }
8774                    break;
8775                }
8776            }
8777
8778            if let Some(clamp) = fragment_constraints.line_clamp {
8779                if line_index >= clamp.get() {
8780                    break;
8781                }
8782            }
8783
8784            // +spec:multi-column - column-fill:balance: cap this column at its balanced share of
8785            // lines so content distributes across columns. The LAST column takes whatever remains
8786            // (so integer rounding of the per-column budget never drops content).
8787            if let Some(budget) = balanced_lines_per_column {
8788                if current_column + 1 < num_columns && line_index >= budget {
8789                    break;
8790                }
8791            }
8792
8793            // Create constraints specific to the current column for the line breaker.
8794            let mut column_constraints = fragment_constraints.clone();
8795            // For MinContent/MaxContent, preserve the semantic type so the line breaker
8796            // can handle word-level breaking correctly. Only use Definite for actual widths.
8797            if is_min_content {
8798                column_constraints.available_width = AvailableSpace::MinContent;
8799            } else if is_max_content {
8800                column_constraints.available_width = AvailableSpace::MaxContent;
8801            } else {
8802                column_constraints.available_width = AvailableSpace::Definite(column_width);
8803            }
8804            let line_constraints = get_line_constraints(
8805                line_top_y,
8806                fragment_constraints.resolved_line_height(),
8807                &column_constraints,
8808                debug_messages,
8809            );
8810
8811            if line_constraints.segments.is_empty() {
8812                empty_segment_count += 1;
8813                if let Some(msgs) = debug_messages {
8814                    msgs.push(LayoutDebugMessage::info(format!(
8815                        "  No available segments at y={line_top_y}, skipping to next line. (empty count: \
8816                         {empty_segment_count}/{MAX_EMPTY_SEGMENTS})"
8817                    )));
8818                }
8819
8820                // Failsafe: If we've skipped too many lines without content, break out
8821                if empty_segment_count >= MAX_EMPTY_SEGMENTS {
8822                    if let Some(msgs) = debug_messages {
8823                        msgs.push(LayoutDebugMessage::warning(format!(
8824                            "  [WARN] Reached maximum empty segment count ({MAX_EMPTY_SEGMENTS}). Breaking to \
8825                             prevent infinite loop."
8826                        )));
8827                        msgs.push(LayoutDebugMessage::warning(
8828                            "  This likely means the shape constraints are too restrictive or \
8829                             positioned incorrectly."
8830                                .to_string(),
8831                        ));
8832                        msgs.push(LayoutDebugMessage::warning(format!(
8833                            "  Current y={line_top_y}, shape boundaries might be outside this range."
8834                        )));
8835                    }
8836                    break;
8837                }
8838
8839                // Additional check: If we have shapes and are far beyond the expected height,
8840                // also break to avoid infinite loops
8841                if !fragment_constraints.shape_boundaries.is_empty() && empty_segment_count > 50 {
8842                    // Calculate maximum shape height
8843                    let max_shape_y: f32 = fragment_constraints
8844                        .shape_boundaries
8845                        .iter()
8846                        .map(|shape| {
8847                            match shape {
8848                                ShapeBoundary::Circle { center, radius } => center.y + radius,
8849                                ShapeBoundary::Ellipse { center, radii } => center.y + radii.height,
8850                                ShapeBoundary::Polygon { points } => {
8851                                    points.iter().map(|p| p.y).fold(0.0, f32::max)
8852                                }
8853                                ShapeBoundary::Rectangle(rect) => rect.y + rect.height,
8854                                ShapeBoundary::Path { segments } => segments
8855                                    .iter()
8856                                    .filter_map(|s| match s {
8857                                        PathSegment::MoveTo(p) | PathSegment::LineTo(p) => Some(p.y),
8858                                        PathSegment::CurveTo { end, .. }
8859                                        | PathSegment::QuadTo { end, .. } => Some(end.y),
8860                                        PathSegment::Arc { center, radius, .. } => {
8861                                            Some(center.y + radius)
8862                                        }
8863                                        PathSegment::Close => None,
8864                                    })
8865                                    .fold(0.0, f32::max),
8866                            }
8867                        })
8868                        .fold(0.0, f32::max);
8869
8870                    if line_top_y > max_shape_y + 100.0 {
8871                        if let Some(msgs) = debug_messages {
8872                            msgs.push(LayoutDebugMessage::info(format!(
8873                                "  [INFO] Current y={line_top_y} is far beyond maximum shape extent y={max_shape_y}. \
8874                                 Breaking layout."
8875                            )));
8876                            msgs.push(LayoutDebugMessage::info(
8877                                "  Shape boundaries exist but no segments available - text cannot \
8878                                 fit in shape."
8879                                    .to_string(),
8880                            ));
8881                        }
8882                        break;
8883                    }
8884                }
8885
8886                line_top_y += fragment_constraints.resolved_line_height();
8887                continue;
8888            }
8889
8890            // Reset counter when we find valid segments
8891            empty_segment_count = 0;
8892
8893            // +spec:line-breaking:3bb032 - break-word not considered for min-content intrinsic sizes
8894            // +spec:overflow:b932c4 - overflow-wrap/word-wrap (normal/break-word/anywhere) and hyphens interaction
8895            // `anywhere` introduces soft wrap opportunities (min-content = widest cluster),
8896            // but `break-word` does NOT (min-content = widest unbreakable word).
8897            let effective_overflow_wrap = if is_min_content && fragment_constraints.overflow_wrap == OverflowWrap::Anywhere {
8898                OverflowWrap::Anywhere
8899            } else if is_min_content && fragment_constraints.overflow_wrap == OverflowWrap::BreakWord {
8900                OverflowWrap::Normal
8901            } else {
8902                fragment_constraints.overflow_wrap
8903            };
8904
8905            // CSS Text Module Level 3 § 5 Line Breaking and Word Boundaries
8906            // https://www.w3.org/TR/css-text-3/#line-breaking
8907            // +spec:display-property:2608cc - inline box splitting across line boxes, overflow for unsplittable boxes
8908            // +spec:display-property:ea615c - inline boxes split and distributed across line boxes
8909            // "When an inline box exceeds the logical width of a line box, it is split
8910            // into several fragments, which are partitioned across multiple line boxes."
8911            let (mut line_items, was_hyphenated) =
8912                break_one_line(cursor, &line_constraints, false, hyphenator.as_ref(), fonts, fragment_constraints.line_break, fragment_constraints.white_space_mode, effective_overflow_wrap);
8913            if line_items.is_empty() {
8914                if let Some(msgs) = debug_messages {
8915                    msgs.push(LayoutDebugMessage::info(
8916                        "  Break returned no items. Ending column.".to_string(),
8917                    ));
8918                }
8919                break;
8920            }
8921
8922            let line_text_before_rev: String = line_items
8923                .iter()
8924                .filter_map(|i| i.as_cluster())
8925                .map(|c| c.text.as_str())
8926                .collect();
8927            if let Some(msgs) = debug_messages {
8928                msgs.push(LayoutDebugMessage::info(format!(
8929                    // FIX: The log message was misleading. Items are in visual order.
8930                    "[PFLayout] Line items from breaker (visual order): [{line_text_before_rev}]"
8931                )));
8932            }
8933
8934            // Unicode Bidi rule L2 (glyph-level reversal). `reorder_logical_items`
8935            // already ordered the level RUNS visually; here we reverse the clusters
8936            // within each RTL run so an RTL run reads right-to-left. Applied per line
8937            // (after line breaking) so a wrapped RTL run reorders correctly per line.
8938            apply_l2_visual_reversal(&mut line_items);
8939
8940            if let Some(msgs) = debug_messages {
8941                let after: String = line_items
8942                    .iter()
8943                    .filter_map(|i| i.as_cluster())
8944                    .map(|c| c.text.as_str())
8945                    .collect();
8946                if after != line_text_before_rev {
8947                    msgs.push(LayoutDebugMessage::info(format!(
8948                        "[PFLayout] Line items after L2 reversal: [{after}]"
8949                    )));
8950                }
8951            }
8952
8953            // +spec:line-breaking:c59944 - forced line breaks detected for bidi-aware alignment
8954            let line_ends_with_forced_break = line_items.iter().any(|item| matches!(item, ShapedItem::Break { .. }));
8955
8956            // uses text-align-last (last line of block, or line right before forced break)
8957            let is_last_line = cursor.is_done() && !was_hyphenated;
8958            let effective_align = resolve_effective_alignment(
8959                fragment_constraints.text_align,
8960                fragment_constraints.text_align_last,
8961                is_last_line || line_ends_with_forced_break,
8962            );
8963
8964            let (mut line_pos_items, line_height) = position_one_line(
8965                &line_items,
8966                &line_constraints,
8967                line_top_y,
8968                line_index,
8969                effective_align,
8970                base_direction,
8971                is_last_line,
8972                fragment_constraints,
8973                debug_messages,
8974                fonts,
8975                is_after_forced_break,
8976            );
8977
8978            // Track whether the next line follows a forced break
8979            is_after_forced_break = line_ends_with_forced_break;
8980
8981            for item in &mut line_pos_items {
8982                item.position.x += column_start_x;
8983            }
8984
8985            // +spec:display-property:6c4978 - line-height on block container establishes minimum line box height
8986            let band_height = line_height.max(fragment_constraints.resolved_line_height());
8987            line_bands.push((line_index, line_top_y, band_height));
8988            line_top_y += band_height;
8989            line_index += 1;
8990            positioned_items.extend(line_pos_items);
8991        }
8992
8993        // +spec:writing-modes:6e22a7 - vertical-rl column order: mirror the block axis so the
8994        // FIRST line becomes the RIGHTMOST column and successive lines advance leftward. Each
8995        // line occupies the block band [t, t + h]; after mirroring within the column's total
8996        // block extent `block_extent` the band moves to [block_extent - t - h, block_extent - t],
8997        // which is x += block_extent - 2t - h for every item on that line. The inline (y) axis
8998        // and within-column glyph stacking are untouched. vertical-lr keeps left-to-right order.
8999        if fragment_constraints.writing_mode == Some(WritingMode::VerticalRl) {
9000            let block_extent = line_top_y;
9001            for item in &mut positioned_items[column_item_start..] {
9002                if let Some(&(_, t, h)) =
9003                    line_bands.iter().find(|(li, _, _)| *li == item.line_index)
9004                {
9005                    // delta = block_extent - 2t - h (written without a `2.0 * t`
9006                    // product so the flops lint stays quiet).
9007                    item.position.x += block_extent - t - t - h;
9008                }
9009            }
9010        }
9011        current_column += 1;
9012    }
9013
9014    if let Some(msgs) = debug_messages {
9015        msgs.push(LayoutDebugMessage::info(format!(
9016            "--- Exiting perform_fragment_layout, positioned {} items ---",
9017            positioned_items.len()
9018        )));
9019    }
9020
9021    let mut layout = UnifiedLayout {
9022        items: positioned_items,
9023        overflow: OverflowInfo::default(),
9024    };
9025
9026    // Calculate bounds on demand via the bounds() method
9027    let calculated_bounds = layout.bounds();
9028
9029    // Record the unclipped content bounds. `overflow_items` stays empty by
9030    // design: this positioner places *every* item, so visual overflow is handled
9031    // at paint time via clipping rather than by dropping items here.
9032    // TODO(superplan): only populate `overflow_items` if a future positioning
9033    // path actually discards content that does not fit.
9034    layout.overflow.unclipped_bounds = calculated_bounds;
9035
9036    if let Some(msgs) = debug_messages {
9037        msgs.push(LayoutDebugMessage::info(format!(
9038            "--- Calculated bounds: width={}, height={} ---",
9039            calculated_bounds.width, calculated_bounds.height
9040        )));
9041    }
9042
9043    Ok(layout)
9044}
9045
9046/// Breaks a single line of items to fit within the given geometric constraints,
9047/// handling multi-segment lines and hyphenation.
9048/// Break a single line from the current cursor position.
9049///
9050/// # CSS Text Module Level 3 \u00a7 5 Line Breaking and Word Boundaries
9051/// <https://www.w3.org/TR/css-text-3/#line-breaking>
9052///
9053/// Implements the line breaking algorithm:
9054/// 1. "When an inline box exceeds the logical width of a line box, it is split into several
9055///    fragments, which are partitioned across multiple line boxes."
9056///
9057/// ## \u2705 Implemented Features:
9058/// - **Break Opportunities**: Identifies word boundaries and break points
9059/// - **Soft Wraps**: Wraps at spaces between words
9060/// - **Hard Breaks**: Handles explicit line breaks (\\n)
9061/// - **Overflow**: If a word is too long, places it anyway to avoid infinite loop
9062/// - **Hyphenation**: Tries to break long words at hyphenation points (\u00a7 5.4)
9063///
9064/// ## \u26a0\ufe0f Known Issues:
9065/// - If `line_constraints.total_available` is 0.0 (from `available_width: 0.0` bug), every word
9066///   will overflow, causing single-word lines
9067/// - This is the symptom visible in the PDF: "List items break extremely early"
9068///
9069/// ## \u00a7 5.2 Breaking Rules for Letters
9070/// \u2705 IMPLEMENTED: Uses Unicode line breaking algorithm
9071/// - Relies on UAX #14 for break opportunities
9072/// - Respects non-breaking spaces and zero-width joiners
9073///
9074/// ## \u00a7 5.3 Breaking Rules for Punctuation
9075/// \u26a0\ufe0f PARTIAL: Basic punctuation handling
9076/// - \u274c TODO: hanging-punctuation is declared in `UnifiedConstraints` but not used here
9077/// - \u274c TODO: Should implement punctuation trimming at line edges
9078///   // +spec:intrinsic-sizing:6085cf - hanging glyphs must be excluded from intrinsic size computation
9079///
9080/// ## \u00a7 5.4 Hyphenation
9081/// \u2705 IMPLEMENTED: Automatic hyphenation with hyphenator library
9082/// - Tries to hyphenate words that overflow
9083/// - Inserts hyphen glyph at break point
9084/// - Carries remainder to next line
9085///
9086/// ## \u00a7 5.5 Overflow Wrapping
9087/// \u2705 IMPLEMENTED: Emergency breaking
9088/// - If line is empty and word doesn't fit, forces at least one item
9089/// - Prevents infinite loop
9090/// - This is "overflow-wrap: break-word" behavior
9091///
9092/// # Missing Features:
9093/// - word-break property (normal, break-all, keep-all) - IMPLEMENTED via `BreakCursor.word_break`
9094/// - \u26a0\ufe0f line-break property: anywhere implemented; loose/normal/strict CJK strictness
9095///   filtering added via `is_cjk_break_allowed_by_strictness` (§5.3)
9096/// - \u274c overflow-wrap: anywhere vs break-word distinction
9097/// - \u2705 white-space: break-spaces handling
9098// around every typographic character unit including preserved white spaces; with break-spaces
9099// it allows breaking before the first space of a sequence
9100// +spec:line-breaking:722f3b - wrapping only at soft wrap opportunities, minimizing overflow
9101#[allow(clippy::cognitive_complexity)] // cohesive line-break state machine: one branch per CSS line-break case
9102#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
9103/// # Panics
9104///
9105/// Panics if a break unit is unexpectedly empty (an internal invariant).
9106pub fn break_one_line<T: ParsedFontTrait>(
9107    cursor: &mut BreakCursor<'_>,
9108    line_constraints: &LineConstraints,
9109    is_vertical: bool,
9110    hyphenator: Option<&Standard>,
9111    fonts: &LoadedFonts<T>,
9112    line_break: LineBreakStrictness,
9113    white_space_mode: WhiteSpaceMode,
9114    overflow_wrap: OverflowWrap,
9115) -> (Vec<ShapedItem>, bool) {
9116    let mut line_items = Vec::new();
9117    let mut current_width = 0.0;
9118
9119    if cursor.is_done() {
9120        return (Vec::new(), false);
9121    }
9122
9123    // +spec:white-space-processing:c83dbd - Phase II: collapsible spaces at line start removed, trailing spaces removed, tab stops
9124    // CSS Text Module Level 3 § 4.1.2: At the beginning of a line, white space
9125    // is collapsed away. Skip leading whitespace at line start.
9126    // https://www.w3.org/TR/css-text-3/#white-space-phase-2
9127    // Per CSS Text 3 §4.1.1/§4.1.2, leading white space at line start is collapsed
9128    // ONLY for the collapsing white-space modes. Pre / pre-wrap / break-spaces must
9129    // preserve leading indentation, so only strip for Normal / Nowrap / Pre-line.
9130    let strip_leading = matches!(
9131        white_space_mode,
9132        WhiteSpaceMode::Normal | WhiteSpaceMode::Nowrap | WhiteSpaceMode::PreLine
9133    );
9134    if strip_leading {
9135        while !cursor.is_done() {
9136            let next_unit = cursor.peek_next_unit();
9137            if next_unit.is_empty() {
9138                break;
9139            }
9140            if next_unit.len() == 1 && is_collapsible_whitespace(&next_unit[0]) {
9141                cursor.consume(1);
9142            } else {
9143                break;
9144            }
9145        }
9146    }
9147
9148    // +spec:line-breaking:35817b - white-space: nowrap/pre prevent soft wrap opportunities
9149    // CSS Text Level 3 § 3: For nowrap and pre, wrapping is suppressed. All content
9150    // stays on a single line, overflowing if necessary.
9151    let no_wrap = matches!(white_space_mode, WhiteSpaceMode::Nowrap | WhiteSpaceMode::Pre);
9152
9153    if no_wrap {
9154        // No soft wrapping — consume everything onto one line.
9155        // Only explicit <br>/newline breaks are honored.
9156        loop {
9157            let next_unit = cursor.peek_next_unit();
9158            if next_unit.is_empty() {
9159                break;
9160            }
9161            if let Some(ShapedItem::Break { .. }) = next_unit.first() {
9162                line_items.push(next_unit[0].clone());
9163                cursor.consume(1);
9164                return (line_items, false);
9165            }
9166            line_items.extend_from_slice(&next_unit);
9167            cursor.consume(next_unit.len());
9168        }
9169    } else {
9170
9171    loop {
9172        // typographic character unit as a soft wrap opportunity; hyphenation is not applied
9173        let next_unit = if line_break == LineBreakStrictness::Anywhere {
9174            cursor.peek_next_single_item()
9175        } else {
9176            cursor.peek_next_unit()
9177        };
9178        if next_unit.is_empty() {
9179            break; // End of content
9180        }
9181
9182        if let Some(ShapedItem::Break { .. }) = next_unit.first() {
9183            line_items.push(next_unit[0].clone());
9184            cursor.consume(1);
9185            return (line_items, false);
9186        }
9187
9188        // Min-content: break at EVERY soft-wrap opportunity so each word forms its
9189        // own line (min-content = widest unbreakable unit). `total_available` is a
9190        // sentinel (f32::MAX/2) during intrinsic sizing and never overflows, so
9191        // without this the run would collapse onto one line and min-content would
9192        // wrongly equal max-content. Once the line holds content and the next unit
9193        // is a break opportunity (a space, CJK ideograph, hyphen, …), finish here;
9194        // a leading space is stripped at the next line's start (collapsing modes).
9195        if line_constraints.is_min_content
9196            && !line_items.is_empty()
9197            && next_unit.len() == 1
9198            && is_break_opportunity_with_word_break(&next_unit[0], cursor.word_break, cursor.hyphens)
9199        {
9200            break;
9201        }
9202
9203        let unit_width: f32 = next_unit
9204            .iter()
9205            .map(|item| get_item_measure_with_spacing(item, is_vertical))
9206            .sum();
9207        let available_width = line_constraints.total_available - current_width;
9208
9209        // 2. Can the whole unit fit on the current line?
9210        if unit_width <= available_width {
9211            line_items.extend_from_slice(&next_unit);
9212            current_width += unit_width;
9213            cursor.consume(next_unit.len());
9214        } else {
9215            // 3. The unit overflows. Can we hyphenate it?
9216            if line_break != LineBreakStrictness::Anywhere {
9217                if let Some(hyphenator) = hyphenator {
9218                    if !is_break_opportunity(next_unit.last().unwrap()) {
9219                        if let Some(hyphenation_result) = try_hyphenate_word_cluster(
9220                            &next_unit,
9221                            available_width,
9222                            is_vertical,
9223                            hyphenator,
9224                            fonts,
9225                        ) {
9226                            line_items.extend(hyphenation_result.line_part);
9227                            cursor.consume(next_unit.len());
9228                            cursor.partial_remainder = hyphenation_result.remainder_part;
9229                            return (line_items, true);
9230                        }
9231                    }
9232                }
9233            }
9234
9235            // an otherwise unbreakable sequence at an arbitrary point when no other
9236            // break points exist. Grapheme clusters stay together; no hyphen inserted.
9237            // 4. Cannot hyphenate or fit. The line is finished.
9238            // If the line is empty, we must force at least one item to avoid an infinite loop.
9239            // With overflow-wrap: anywhere or break-word, we break the unbreakable
9240            // unit at an arbitrary cluster boundary. With normal, we only force one
9241            // item to prevent infinite loops (content will overflow).
9242            if line_items.is_empty() {
9243                match overflow_wrap {
9244                    OverflowWrap::Anywhere | OverflowWrap::BreakWord => {
9245                        // Emergency break: fit as many clusters as possible on
9246                        // this line.  Grapheme clusters stay together.
9247                        //
9248                        // Per CSS Text 3 §5.5: "an otherwise unbreakable sequence
9249                        // of characters may be broken at an arbitrary point" when
9250                        // overflow-wrap is anywhere/break-word.
9251                        let avail = line_constraints.total_available;
9252                        for item in &next_unit {
9253                            let item_w = get_item_measure_with_spacing(item, is_vertical);
9254                            // Break BEFORE this item if adding it would overflow,
9255                            // but only if we already have at least one item on the
9256                            // line (must always make progress).
9257                            if !line_items.is_empty() && avail > 0.0 && current_width + item_w > avail {
9258                                break;
9259                            }
9260                            line_items.push(item.clone());
9261                            current_width += item_w;
9262                            // When the container is zero-width (avail <= 0), the
9263                            // break-before check above is skipped (it requires
9264                            // avail > 0), so every item lands on this one line —
9265                            // there's nowhere to break TO, content just overflows.
9266                            // This matches browser behavior for `width: 0`
9267                            // containers.
9268                        }
9269                        let consumed = line_items.len().max(1);
9270                        if line_items.is_empty() {
9271                            line_items.push(next_unit[0].clone());
9272                        }
9273                        cursor.consume(consumed);
9274                    }
9275                    OverflowWrap::Normal => {
9276                        // overflow-wrap:normal keeps an unbreakable word intact and
9277                        // lets it overflow the line box — it must NOT be shredded one
9278                        // grapheme per line. Place the whole unit on this (empty) line.
9279                        line_items.extend_from_slice(&next_unit);
9280                        cursor.consume(next_unit.len());
9281                    }
9282                }
9283            }
9284            break;
9285        }
9286    }
9287
9288    } // end !no_wrap
9289
9290    // +spec:white-space-processing:fef250 - Phase II: trailing collapsible spaces and U+1680 removed at line end
9291    // as well as any trailing U+1680 OGHAM SPACE MARK whose white-space is normal/nowrap/pre-line.
9292    // Note: pre-wrap and break-spaces have different handling (hanging/preserving)
9293    // which is not yet implemented here.
9294    // Trailing collapsible white space is trimmed only for the collapsing modes.
9295    // Pre keeps significant trailing spaces; pre-wrap hangs them (handled in
9296    // position_one_line); break-spaces must never drop them.
9297    let strip_trailing = matches!(
9298        white_space_mode,
9299        WhiteSpaceMode::Normal | WhiteSpaceMode::Nowrap | WhiteSpaceMode::PreLine
9300    );
9301    if strip_trailing {
9302        while let Some(last) = line_items.last() {
9303            if is_collapsible_whitespace(last) {
9304                line_items.pop();
9305            } else {
9306                break;
9307            }
9308        }
9309    }
9310
9311    (line_items, false)
9312}
9313
9314/// Represents a single valid hyphenation point within a word.
9315#[derive(Debug, Clone)]
9316pub struct HyphenationBreak {
9317    /// The number of characters from the original word string included on the line.
9318    pub char_len_on_line: usize,
9319    /// The total advance width of the line part + the hyphen.
9320    pub width_on_line: f32,
9321    /// The cluster(s) that will remain on the current line.
9322    pub line_part: Vec<ShapedItem>,
9323    /// The cluster that represents the hyphen character itself.
9324    pub hyphen_item: ShapedItem,
9325    /// The cluster(s) that will be carried over to the next line.
9326    /// CRITICAL FIX: Changed from `ShapedItem` to Vec<ShapedItem>
9327    pub remainder_part: Vec<ShapedItem>,
9328}
9329
9330/// A "word" is defined as a sequence of one or more adjacent `ShapedClusters`.
9331#[allow(clippy::cast_precision_loss)] // bounded pixel/coord/colour/glyph cast
9332/// # Panics
9333///
9334/// Panics if a word's cluster or glyph list is unexpectedly empty (an internal invariant).
9335#[must_use] pub fn find_all_hyphenation_breaks<T: ParsedFontTrait>(
9336    word_clusters: &[ShapedCluster],
9337    hyphenator: &Standard,
9338    is_vertical: bool, // Pass this in to use correct metrics
9339    fonts: &LoadedFonts<T>,
9340) -> Option<Vec<HyphenationBreak>> {
9341    if word_clusters.is_empty() {
9342        return None;
9343    }
9344
9345    // --- 1. Concatenate the TRUE text and build a robust map ---
9346    let mut word_string = String::new();
9347    let mut char_map = Vec::new();
9348    let mut current_width = 0.0;
9349
9350    for (cluster_idx, cluster) in word_clusters.iter().enumerate() {
9351        for (char_byte_offset, _ch) in cluster.text.char_indices() {
9352            let glyph_idx = cluster
9353                .glyphs
9354                .iter()
9355                .rposition(|g| g.cluster_offset as usize <= char_byte_offset)
9356                .unwrap_or(0);
9357            let glyph = &cluster.glyphs[glyph_idx];
9358
9359            let num_chars_in_glyph = cluster.text[glyph.cluster_offset as usize..]
9360                .chars()
9361                .count();
9362            let advance_per_char = if is_vertical {
9363                glyph.vertical_advance
9364            } else {
9365                glyph.advance
9366            } / (num_chars_in_glyph as f32).max(1.0);
9367
9368            current_width += advance_per_char;
9369            char_map.push((cluster_idx, glyph_idx, current_width));
9370        }
9371        word_string.push_str(&cluster.text);
9372    }
9373
9374    // +spec:line-breaking:d7ed93 - language-specific hyphenation rules apply to both auto and explicit (soft hyphen) opportunities
9375    // --- 2. Get hyphenation opportunities ---
9376    let opportunities = hyphenator.hyphenate(&word_string);
9377    if opportunities.breaks.is_empty() {
9378        return None;
9379    }
9380
9381    let last_cluster = word_clusters.last().unwrap();
9382    let last_glyph = last_cluster.glyphs.last().unwrap();
9383    let style = last_cluster.style.clone();
9384
9385    // Look up font from hash
9386    let font = fonts.get_by_hash(last_glyph.font_hash)?;
9387    let (hyphen_glyph_id, hyphen_advance) =
9388        font.get_hyphen_glyph_and_advance(style.font_size_px)?;
9389
9390    let mut possible_breaks = Vec::new();
9391
9392    // --- 3. Generate a HyphenationBreak for each valid opportunity ---
9393    for &break_char_idx in &opportunities.breaks {
9394        // The break is *before* the character at this index.
9395        // So the last character on the line is at `break_char_idx - 1`.
9396        if break_char_idx == 0 || break_char_idx > char_map.len() {
9397            continue;
9398        }
9399
9400        let (_, _, width_at_break) = char_map[break_char_idx - 1];
9401
9402        // The line part is all clusters *before* the break index.
9403        let line_part: Vec<ShapedItem> = word_clusters[..break_char_idx]
9404            .iter()
9405            .map(|c| ShapedItem::Cluster(c.clone()))
9406            .collect();
9407
9408        // The remainder is all clusters *from* the break index onward.
9409        let remainder_part: Vec<ShapedItem> = word_clusters[break_char_idx..]
9410            .iter()
9411            .map(|c| ShapedItem::Cluster(c.clone()))
9412            .collect();
9413
9414        let hyphen_item = ShapedItem::Cluster(ShapedCluster {
9415            text: "-".to_string(),
9416            source_cluster_id: GraphemeClusterId {
9417                source_run: u32::MAX,
9418                start_byte_in_run: u32::MAX,
9419            },
9420            source_content_index: ContentIndex {
9421                run_index: u32::MAX,
9422                item_index: u32::MAX,
9423            },
9424            source_node_id: None, // Hyphen is generated, not from DOM
9425            glyphs: smallvec![ShapedGlyph {
9426                kind: GlyphKind::Hyphen,
9427                glyph_id: hyphen_glyph_id,
9428                font_hash: last_glyph.font_hash,
9429                font_metrics: last_glyph.font_metrics,
9430                cluster_offset: 0,
9431                script: Script::Latin,
9432                advance: hyphen_advance,
9433                kerning: 0.0,
9434                offset: Point::default(),
9435                style: style.clone(),
9436                vertical_advance: hyphen_advance,
9437                vertical_offset: Point::default(),
9438            }],
9439            advance: hyphen_advance,
9440            direction: BidiDirection::Ltr,
9441            style: style.clone(),
9442            marker_position_outside: None,
9443            is_first_fragment: true,
9444            is_last_fragment: true,
9445        });
9446
9447        possible_breaks.push(HyphenationBreak {
9448            char_len_on_line: break_char_idx,
9449            width_on_line: width_at_break + hyphen_advance,
9450            line_part,
9451            hyphen_item,
9452            remainder_part,
9453        });
9454    }
9455
9456    Some(possible_breaks)
9457}
9458
9459/// Tries to find a hyphenation point within a word, returning the line part and remainder.
9460fn try_hyphenate_word_cluster<T: ParsedFontTrait>(
9461    word_items: &[ShapedItem],
9462    remaining_width: f32,
9463    is_vertical: bool,
9464    hyphenator: &Standard,
9465    fonts: &LoadedFonts<T>,
9466) -> Option<HyphenationResult> {
9467    let word_clusters: Vec<ShapedCluster> = word_items
9468        .iter()
9469        .filter_map(|item| item.as_cluster().cloned())
9470        .collect();
9471
9472    if word_clusters.is_empty() {
9473        return None;
9474    }
9475
9476    let all_breaks = find_all_hyphenation_breaks(&word_clusters, hyphenator, is_vertical, fonts)?;
9477
9478    if let Some(best_break) = all_breaks
9479        .into_iter()
9480        .rfind(|b| b.width_on_line <= remaining_width)
9481    {
9482        let mut line_part = best_break.line_part;
9483        line_part.push(best_break.hyphen_item);
9484
9485        return Some(HyphenationResult {
9486            line_part,
9487            remainder_part: best_break.remainder_part,
9488        });
9489    }
9490
9491    None
9492}
9493
9494/// Positions a single line of items, handling alignment and justification within segments.
9495///
9496/// This function is architecturally critical for cache safety. It does not mutate the
9497/// `advance` or `bounds` of the input `ShapedItem`s. Instead, it applies justification
9498/// spacing by adjusting the drawing pen's position (`main_axis_pen`).
9499///
9500/// # Returns
9501/// A tuple containing the `Vec` of positioned items and the calculated height of the line box.
9502/// Position items on a single line after breaking.
9503///
9504/// # CSS Inline Layout Module Level 3 \u00a7 2.2 Layout Within Line Boxes
9505/// <https://www.w3.org/TR/css-inline-3/#layout-within-line-boxes>
9506///
9507/// Implements the positioning algorithm:
9508/// 1. "All inline-level boxes are aligned by their baselines"
9509/// 2. "Calculate layout bounds for each inline box"
9510/// 3. "Size the line box to fit the aligned layout bounds"
9511/// 4. "Position all inline boxes within the line box"
9512///
9513/// ## \u2705 Implemented Features:
9514///
9515/// ### \u00a7 4 Baseline Alignment (vertical-align)
9516/// \u26a0\ufe0f PARTIAL IMPLEMENTATION:
9517/// - \u2705 `baseline`: Aligns box baseline with parent baseline (default)
9518/// - \u2705 `top`: Aligns top of box with top of line box
9519/// - \u2705 `middle`: Centers box within line box
9520/// - \u2705 `bottom`: Aligns bottom of box with bottom of line box
9521/// - \u274c MISSING: `text-top`, `text-bottom`, `sub`, `super`
9522/// - \u274c MISSING: `<length>`, `<percentage>` values for custom offset
9523///
9524/// ### \u00a7 2.2.1 Text Alignment (text-align)
9525/// +spec:containing-block:8d5146 - text-align aligns within line box, not viewport/containing block
9526/// \u2705 IMPLEMENTED:
9527/// - `left`, `right`, `center`: Physical alignment
9528/// - `start`, `end`: Logical alignment (respects direction: ltr/rtl)
9529/// - `justify`: Distributes space between words/characters
9530/// - `justify-all`: Justifies last line too
9531///
9532/// ### \u00a7 7.3 Text Justification (text-justify)
9533/// \u2705 IMPLEMENTED:
9534/// - `inter-word`: Adds space between words
9535/// - `inter-character`: Adds space between characters
9536/// - `kashida`: Arabic kashida elongation
9537/// - \u274c MISSING: `distribute` (CJK justification)
9538///
9539/// ### CSS Text \u00a7 8.1 Text Indentation (text-indent)
9540/// \u2705 IMPLEMENTED: First line indentation
9541///
9542/// ### CSS Text \u00a7 4.1 Word Spacing (word-spacing)
9543/// \u2705 IMPLEMENTED: Additional space between words
9544///
9545/// ### CSS Text \u00a7 4.2 Letter Spacing (letter-spacing)
9546/// \u2705 IMPLEMENTED: Additional space between characters
9547///
9548/// ## Segment-Aware Layout:
9549/// \u2705 Handles CSS Shapes and multi-column layouts
9550/// - Breaks line into segments (for shape boundaries)
9551/// - Calculates justification per segment
9552/// - Applies alignment within each segment's bounds
9553///
9554/// ## Known Issues:
9555/// - \u26a0\ufe0f If segment.width is infinite (from intrinsic sizing), sets `alignment_offset=0` to
9556///   avoid infinite positioning. This is correct for measurement but documented for clarity.
9557/// - The function assumes `line_index == 0` means first line for text-indent. A more robust system
9558///   would track paragraph boundaries.
9559///
9560/// # Missing Features:
9561/// - \u274c \u00a7 6 Trimming Leading (text-box-trim, text-box-edge)
9562/// - \u274c \u00a7 3.3 Initial Letters (drop caps)
9563///   // +spec:display-property:265c04 - initial letter exclusion area must continue into subsequent blocks when paragraph is shorter than drop cap
9564/// - \u274c Full vertical-align support (sub, super, lengths, percentages)
9565/// - \u274c white-space: break-spaces alignment behavior
9566// +spec:text-alignment-spacing:c8a926 - order of operations: shaping → letter/word-spacing → justification → alignment
9567#[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
9568#[allow(clippy::cast_precision_loss)] // bounded pixel/coord/colour/glyph cast
9569#[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
9570#[allow(clippy::too_many_lines, clippy::cognitive_complexity)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
9571pub fn position_one_line<T: ParsedFontTrait>(
9572    line_items: &[ShapedItem],
9573    line_constraints: &LineConstraints,
9574    line_top_y: f32,
9575    line_index: usize,
9576    text_align: TextAlign,
9577    base_direction: BidiDirection,
9578    is_last_line: bool,
9579    constraints: &UnifiedConstraints,
9580    debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
9581    fonts: &LoadedFonts<T>,
9582    is_after_forced_break: bool,
9583) -> (Vec<PositionedItem>, f32) {
9584    let line_text: String = line_items
9585        .iter()
9586        .filter_map(|i| i.as_cluster())
9587        .map(|c| c.text.as_str())
9588        .collect();
9589    if let Some(msgs) = debug_messages {
9590        msgs.push(LayoutDebugMessage::info(format!(
9591            "\n--- Entering position_one_line for line: [{line_text}] ---"
9592        )));
9593    }
9594    // +spec:text-alignment-spacing:13b72d - line box start/end determined by inline base direction
9595    // +spec:text-alignment-spacing:d497af - line box inline base direction affects text-align resolution
9596    // +spec:text-alignment-spacing:68332e - bidi direction determines start/end to left/right mapping
9597    let physical_align = match (text_align, base_direction) {
9598        (TextAlign::Start, BidiDirection::Ltr) => TextAlign::Left,
9599        (TextAlign::Start, BidiDirection::Rtl) => TextAlign::Right,
9600        (TextAlign::End, BidiDirection::Ltr) => TextAlign::Right,
9601        (TextAlign::End, BidiDirection::Rtl) => TextAlign::Left,
9602        // Physical alignments are returned as-is, regardless of direction.
9603        (other, _) => other,
9604    };
9605    if let Some(msgs) = debug_messages {
9606        msgs.push(LayoutDebugMessage::info(format!(
9607            "[Pos1Line] Physical align: {physical_align:?}"
9608        )));
9609    }
9610
9611    // +spec:box-model:847003 - Phantom line boxes: empty lines treated as zero-height
9612    // +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
9613    // +spec:display-property:90d782 - Phantom line boxes (containing only empty inline boxes, out-of-flow items, or collapsed whitespace) are ignored
9614    if line_items.is_empty() {
9615        return (Vec::new(), 0.0);
9616    }
9617    let mut positioned = Vec::new();
9618    let is_vertical = constraints.is_vertical();
9619
9620    // +spec:line-height:9ca9d9 - line box height = distance from uppermost box top to lowermost box bottom, including strut
9621    // The line box is calculated once for all items on the line, regardless of segment.
9622    // Per CSS 2.2 §10.8, top/bottom aligned items are handled in a second pass to
9623    // minimize line box height; baseline-aligned items determine the initial height.
9624    let (content_ascent, content_descent) = calculate_line_metrics(line_items, constraints.vertical_align, constraints);
9625
9626    // +spec:box-model:e99f7d - strut: each line box starts with zero-width inline box with block container's font/line-height
9627    // +spec:line-height:29c478 - strut: zero-width inline box with block container's font/line-height
9628    // inline box with the block container's font and line-height. The strut has A (ascent) and
9629    // D (descent) from the block container's first available font. Half-leading L/2 is applied:
9630    // L = line-height - (A + D), strut_above = A + L/2, strut_below = D + L/2.
9631    // +spec:height-calculation:8e91b2 - specified line-height used in line box height calculation
9632    let strut_ad = constraints.strut_ascent + constraints.strut_descent;
9633    let strut_leading_half = (constraints.resolved_line_height() - strut_ad) / 2.0;
9634    let strut_above = constraints.strut_ascent + strut_leading_half;
9635    let strut_below = constraints.strut_descent + strut_leading_half;
9636    let line_ascent = content_ascent.max(strut_above);
9637    let line_descent = content_descent.max(strut_below);
9638    let line_box_height = line_ascent + line_descent;
9639
9640    // The baseline for the entire line is determined by its tallest item.
9641    let line_baseline_y = line_top_y + line_ascent;
9642
9643    // --- Segment-Aware Positioning ---
9644    let mut item_cursor = 0;
9645    let is_first_line_of_para = line_index == 0; // Simplified assumption
9646
9647    for (segment_idx, segment) in line_constraints.segments.iter().enumerate() {
9648        if item_cursor >= line_items.len() {
9649            break;
9650        }
9651
9652        // 1. Collect all items that fit into the current segment.
9653        let mut segment_items = Vec::new();
9654        let mut current_segment_width = 0.0;
9655        while item_cursor < line_items.len() {
9656            let item = &line_items[item_cursor];
9657            let item_measure = get_item_measure(item, is_vertical);
9658            // Put at least one item in the segment to avoid getting stuck.
9659            if current_segment_width + item_measure > segment.width && !segment_items.is_empty() {
9660                break;
9661            }
9662            segment_items.push(item.clone());
9663            current_segment_width += item_measure;
9664            item_cursor += 1;
9665        }
9666
9667        if segment_items.is_empty() {
9668            continue;
9669        }
9670
9671        // +spec:text-alignment-spacing:b9d88e - justify stretches inline boxes via text-justify; non-collapsible WS may skip justification
9672        // 2. Calculate justification spacing *for this segment only*.
9673        // +spec:text-alignment-spacing:30d322 - justify lines with justification opportunities when text-align is justify
9674        // CSS Text 3 §6: text-justify controls HOW to justify, but only applies
9675        // when text-align is justify/justify-all. Without this check, ALL text
9676        // gets justified because text-justify defaults to auto (→ InterWord).
9677        let (extra_word_spacing, extra_char_spacing) = if (constraints.text_align == TextAlign::Justify
9678            || constraints.text_align == TextAlign::JustifyAll)
9679            && constraints.text_justify != JustifyContent::None
9680            && (!is_last_line || constraints.text_align == TextAlign::JustifyAll)
9681            && constraints.text_justify != JustifyContent::Kashida
9682        {
9683            let segment_line_constraints = LineConstraints {
9684                segments: vec![*segment],
9685                total_available: segment.width,
9686                is_min_content: false,
9687            };
9688            calculate_justification_spacing(
9689                &segment_items,
9690                &segment_line_constraints,
9691                constraints.text_justify,
9692                is_vertical,
9693            )
9694        } else {
9695            (0.0, 0.0)
9696        };
9697
9698        // Kashida justification needs to be segment-aware if used.
9699        let justified_segment_items = if constraints.text_justify == JustifyContent::Kashida
9700            && (!is_last_line || constraints.text_align == TextAlign::JustifyAll)
9701        {
9702            let segment_line_constraints = LineConstraints {
9703                segments: vec![*segment],
9704                total_available: segment.width,
9705                is_min_content: false,
9706            };
9707            justify_kashida_and_rebuild(
9708                segment_items,
9709                &segment_line_constraints,
9710                is_vertical,
9711                debug_messages,
9712                fonts,
9713            )
9714        } else {
9715            segment_items
9716        };
9717
9718        // Recalculate width in case kashida changed the item list
9719        let final_segment_width: f32 = justified_segment_items
9720            .iter()
9721            .map(|item| get_item_measure(item, is_vertical))
9722            .sum();
9723
9724        // +spec:line-breaking:155a96 - pre-wrap hanging spaces: unconditionally hang without forced break, conditionally hang with forced break
9725        // +spec:white-space-processing:68af09 - Phase II: trailing whitespace hanging/conditional hanging per white-space mode
9726        // +spec:white-space-processing:75d91e - preserved white space hangs at line end, affecting intrinsic sizing
9727        // +spec:overflow:a68394 - Hanging trailing whitespace: unconditionally hang (not considered
9728        // during alignment, may overflow) for lines without forced break; conditionally hang for
9729        // lines ending with forced break (only hang if would overflow).
9730        // For normal/nowrap/pre-line: unconditionally hang trailing WS.
9731        // For pre-wrap: unconditionally hang, unless before forced break (then conditionally hang).
9732        // For break-spaces: trailing spaces cannot hang.
9733        // For pre: no hanging (whitespace preserved as-is).
9734        // +spec:intrinsic-sizing:1db683 - conditionally hanging glyphs excluded from min-content, included in max-content
9735        let trailing_ws_width = match constraints.white_space_mode {
9736            WhiteSpaceMode::BreakSpaces | WhiteSpaceMode::Pre => 0.0,
9737            WhiteSpaceMode::Normal | WhiteSpaceMode::Nowrap | WhiteSpaceMode::PreLine => {
9738                measure_trailing_whitespace(&justified_segment_items, is_vertical)
9739            }
9740            // +spec:line-breaking:8aa426 - space before forced break does not hang if it doesn't overflow
9741            WhiteSpaceMode::PreWrap => {
9742                let has_forced_break = justified_segment_items.last()
9743                    .is_some_and(|item| matches!(item, ShapedItem::Break { .. }));
9744                let ws_width = measure_trailing_whitespace(&justified_segment_items, is_vertical);
9745                if has_forced_break {
9746                    // +spec:display-contents:2704a2 - conditionally hanging chars not considered when measuring line fit
9747                    // Conditionally hang: only hang if it would overflow
9748                    let content_width = final_segment_width - ws_width;
9749                    if content_width + ws_width > segment.width {
9750                        ws_width
9751                    } else {
9752                        0.0
9753                    }
9754                } else {
9755                    ws_width // unconditionally hang
9756                }
9757            }
9758        };
9759        let effective_segment_width = final_segment_width - trailing_ws_width;
9760
9761        // +spec:text-alignment-spacing:287316 - overflow content is start-aligned; alignment offset within line box
9762        // 3. Calculate alignment offset *within this segment*.
9763        let remaining_space = segment.width - effective_segment_width;
9764
9765        // Handle MaxContent/indefinite width: when available_width is MaxContent (for intrinsic
9766        // sizing), segment.width will be f32::MAX / 2.0. Alignment calculations would
9767        // produce huge offsets. In this case, treat as left-aligned (offset = 0) since
9768        // we're measuring natural content width. We check for both infinite AND very large
9769        // values (> 1e30) to catch the MaxContent case.
9770        let is_indefinite_width = segment.width.is_infinite() || segment.width > 1e30;
9771        // +spec:text-alignment-spacing:ab1d4f - unexpandable justify text aligns as center
9772        let alignment_offset = if is_indefinite_width {
9773            0.0 // No alignment offset for indefinite width
9774        } else {
9775            match physical_align {
9776                TextAlign::Center => remaining_space / 2.0,
9777                TextAlign::Right => remaining_space,
9778                TextAlign::Justify | TextAlign::JustifyAll
9779                    if remaining_space > 0.0
9780                        && extra_word_spacing == 0.0
9781                        && extra_char_spacing == 0.0 =>
9782                {
9783                    // CSS Text §6.4.3: If text cannot be stretched to full width
9784                    // and text-align-last is justify, align as center.
9785                    remaining_space / 2.0
9786                }
9787                _ => 0.0, // Left, Justify (when justification succeeded)
9788            }
9789        };
9790
9791        let mut main_axis_pen = segment.start_x + alignment_offset;
9792        if let Some(msgs) = debug_messages {
9793            msgs.push(LayoutDebugMessage::info(format!(
9794                "[Pos1Line] Segment width: {}, Item width: {}, Remaining space: {}, Initial pen: \
9795                 {}",
9796                segment.width, final_segment_width, remaining_space, main_axis_pen
9797            )));
9798        }
9799
9800        // Default: indent first line only. each-line: also indent after forced breaks.
9801        // hanging: invert which lines get the indent.
9802        if segment_idx == 0 {
9803            let is_indent_target = if constraints.text_indent_each_line {
9804                // each-line: first line AND each line after a forced break
9805                is_first_line_of_para || is_after_forced_break
9806            } else {
9807                // Default: only the first line of the block
9808                is_first_line_of_para
9809            };
9810            // hanging: inverts which lines are affected
9811            let should_indent = if constraints.text_indent_hanging {
9812                !is_indent_target
9813            } else {
9814                is_indent_target
9815            };
9816            if should_indent {
9817                main_axis_pen += constraints.text_indent;
9818            }
9819        }
9820
9821        // Calculate total marker width for proper outside marker positioning
9822        // We need to position all marker clusters together in the padding gutter
9823        let total_marker_width: f32 = justified_segment_items
9824            .iter()
9825            .filter_map(|item| {
9826                if let ShapedItem::Cluster(c) = item {
9827                    if c.marker_position_outside == Some(true) {
9828                        return Some(get_item_measure(item, is_vertical));
9829                    }
9830                }
9831                None
9832            })
9833            .sum();
9834
9835        // Track marker pen separately - starts at negative position for outside markers
9836        let marker_spacing = 4.0; // Small gap between marker and content
9837        let mut marker_pen = if total_marker_width > 0.0 {
9838            -(total_marker_width + marker_spacing)
9839        } else {
9840            0.0
9841        };
9842
9843        // 4. Position the items belonging to this segment.
9844        //
9845        // +spec:inline-formatting-context:267438 - Content positioning: position aligned subtree and baseline-shift values within line box
9846        //
9847        // Vertical alignment positioning (CSS vertical-align)
9848        //
9849        // +spec:font-metrics:cae541 - dominant baseline used for inline alignment
9850        // Per CSS Inline Layout Level 3 § 4 (Baseline Alignment), each inline
9851        // element can specify its own `vertical-align`. For Object items
9852        // (inline-blocks, images), we use their per-item alignment stored in
9853        // `InlineContent::Shape.alignment` or `InlineContent::Image.alignment`.
9854        // For text clusters or items without a per-item override, we fall back
9855        // to the global `constraints.vertical_align` from the containing block.
9856        //
9857        // +spec:font-metrics:f29b61 - baseline alignment matches corresponding baseline types (only alphabetic implemented)
9858        // Reference: https://www.w3.org/TR/css-inline-3/#baseline-alignment
9859        // +spec:block-formatting-context:26b535 - In vertical typographic mode, central baseline is dominant when text-orientation is mixed/upright; otherwise alphabetic
9860        // +spec:inline-formatting-context:eb735b - alignment-baseline: inline-level boxes aligned to parent's baseline via vertical-align
9861        // +spec:inline-formatting-context:da3f34 - baseline alignment of in-flow inline-level boxes in block axis per dominant-baseline/vertical-align
9862        // +spec:line-height:e2253a - vertical-align positioning within line boxes
9863
9864        // Pre-compute inline border/padding offsets at span boundaries.
9865        // Only the FIRST cluster of each inline span gets left_inset, and only
9866        // the LAST cluster gets right_inset. We detect span boundaries by comparing
9867        // Arc<StyleProperties> pointers between consecutive clusters.
9868        let inline_offsets: Vec<(f32, f32)> = {
9869            let items_slice: &[ShapedItem] = &justified_segment_items;
9870            items_slice.iter().enumerate().map(|(idx, item)| {
9871                if let ShapedItem::Cluster(c) = item {
9872                    if let Some(border) = c.style.border.as_ref() {
9873                        if border.has_chrome() {
9874                            let style_ptr = Arc::as_ptr(&c.style);
9875                            let prev_same_span = idx > 0 && items_slice[idx - 1]
9876                                .as_cluster()
9877                                .is_some_and(|pc| Arc::as_ptr(&pc.style) == style_ptr);
9878                            let next_same_span = idx + 1 < items_slice.len() && items_slice[idx + 1]
9879                                .as_cluster()
9880                                .is_some_and(|nc| Arc::as_ptr(&nc.style) == style_ptr);
9881                            let left = if prev_same_span { 0.0 } else { border.left_inset() };
9882                            let right = if next_same_span { 0.0 } else { border.right_inset() };
9883                            return (left, right);
9884                        }
9885                    }
9886                }
9887                (0.0, 0.0)
9888            }).collect()
9889        };
9890        for (inline_offset_idx, item) in justified_segment_items.into_iter().enumerate() {
9891            let (item_ascent, item_descent) = get_item_vertical_metrics(&item, constraints);
9892            // Use per-item alignment if available, otherwise fall back to global
9893            let effective_align = get_item_vertical_align(&item)
9894                .unwrap_or(constraints.vertical_align);
9895            // +spec:display-property:328cfc - baseline-shift / aligned subtree vertical alignment (sub, super, top, bottom, center)
9896            // §10.8.1 vertical-align positioning
9897            // +spec:line-height:0fcfab - vertical-align property values (baseline, top, middle, bottom, sub, super, text-top, text-bottom, percentage, length)
9898            let item_baseline_pos = match effective_align {
9899                // +spec:display-property:8e018d - aligned subtree edges used for top/bottom line box alignment
9900                // +spec:inline-formatting-context:495672 - line-relative vertical-align (top/center/bottom) and aligned subtree positioning
9901                // top: align top of aligned subtree with top of line box
9902                VerticalAlign::Top => line_top_y + item_ascent,
9903                // +spec:font-metrics:70000d - align vertical midpoint of box with baseline + half x-height of parent
9904                VerticalAlign::Middle => {
9905                    let half_x_height = constraints.strut_x_height / 2.0;
9906                    line_baseline_y + half_x_height - f32::midpoint(item_ascent, item_descent) + item_ascent
9907                }
9908                // bottom: align bottom of aligned subtree with bottom of line box
9909                VerticalAlign::Bottom => line_top_y + line_box_height - item_descent,
9910                // +spec:font-metrics:aa21f7 - sub: lower baseline to proper subscript position
9911                VerticalAlign::Sub => line_baseline_y + line_ascent * SUBSCRIPT_OFFSET_RATIO,
9912                // +spec:display-property:3b0e76 - baseline-shift super raises by ~1/3 font-size; top/bottom align to line box edges
9913                // super: raise baseline to proper superscript position (~0.4em)
9914                VerticalAlign::Super => line_baseline_y - line_ascent * SUPERSCRIPT_OFFSET_RATIO,
9915                // text-top: align top of box with top of parent's content area (§10.6.1)
9916                // Parent's content area top = baseline - strut_ascent
9917                VerticalAlign::TextTop => (line_baseline_y - constraints.strut_ascent) + item_ascent,
9918                // text-bottom: align bottom of box with bottom of parent's content area (§10.6.1)
9919                // Parent's content area bottom = baseline + strut_descent
9920                VerticalAlign::TextBottom => (line_baseline_y + constraints.strut_descent) - item_descent,
9921                // <length>/<percentage>: raise (positive) or lower (negative); 0 = baseline
9922                VerticalAlign::Offset(offset) => line_baseline_y - offset,
9923                // +spec:display-property:8bf37e - dominant-baseline defaults to alphabetic; baseline alignment matches parent
9924                // baseline: align baseline of box with baseline of parent box
9925                // +spec:font-metrics:96bbd3 - baseline: align alphabetic baseline of box with parent's alphabetic baseline
9926                VerticalAlign::Baseline => line_baseline_y,
9927            };
9928
9929            // Calculate item measure (needed for both positioning and pen advance)
9930            let item_measure = get_item_measure(&item, is_vertical);
9931
9932            // Advance pen by inline left_inset at span entry (before positioning glyphs)
9933            let (left_inset, right_inset) = if inline_offset_idx < inline_offsets.len() {
9934                inline_offsets[inline_offset_idx]
9935            } else {
9936                (0.0, 0.0)
9937            };
9938            main_axis_pen += left_inset;
9939
9940            let position = if is_vertical {
9941                Point {
9942                    x: item_baseline_pos - item_ascent,
9943                    y: main_axis_pen,
9944                }
9945            } else {
9946                if let Some(msgs) = debug_messages {
9947                    msgs.push(LayoutDebugMessage::info(format!(
9948                        "[Pos1Line] is_vertical=false, main_axis_pen={main_axis_pen}, item_baseline_pos={item_baseline_pos}, \
9949                         item_ascent={item_ascent}"
9950                    )));
9951                }
9952
9953                // Check if this is an outside marker - if so, position it in the padding gutter
9954                let x_position = if let ShapedItem::Cluster(cluster) = &item {
9955                    if cluster.marker_position_outside == Some(true) {
9956                        // Use marker_pen for sequential marker positioning
9957                        let marker_width = item_measure;
9958                        if let Some(msgs) = debug_messages {
9959                            msgs.push(LayoutDebugMessage::info(format!(
9960                                "[Pos1Line] Outside marker detected! width={marker_width}, positioning at \
9961                                 marker_pen={marker_pen}"
9962                            )));
9963                        }
9964                        let pos = marker_pen;
9965                        marker_pen += marker_width; // Advance marker pen for next marker cluster
9966                        pos
9967                    } else {
9968                        main_axis_pen
9969                    }
9970                } else {
9971                    main_axis_pen
9972                };
9973
9974                Point {
9975                    y: item_baseline_pos - item_ascent,
9976                    x: x_position,
9977                }
9978            };
9979
9980            // item_measure is calculated above for marker positioning
9981            let item_text = item
9982                .as_cluster()
9983                .map_or("[OBJ]", |c| c.text.as_str());
9984            if let Some(msgs) = debug_messages {
9985                msgs.push(LayoutDebugMessage::info(format!(
9986                    "[Pos1Line] Positioning item '{item_text}' at pen_x={main_axis_pen}"
9987                )));
9988            }
9989            positioned.push(PositionedItem {
9990                item: item.clone(),
9991                position,
9992                line_index,
9993            });
9994
9995            // Outside markers don't advance the pen - they're positioned in the padding gutter
9996            let is_outside_marker = if let ShapedItem::Cluster(c) = &item {
9997                c.marker_position_outside == Some(true)
9998            } else {
9999                false
10000            };
10001
10002            if !is_outside_marker {
10003                main_axis_pen += item_measure;
10004                // Advance pen by inline right_inset at span exit (after glyph advance)
10005                main_axis_pen += right_inset;
10006            }
10007
10008            // +spec:text-alignment-spacing:e09bd1 - justification space added on top of letter-spacing/word-spacing
10009            // +spec:text-alignment-spacing:456643 - cursive scripts don't admit inter-character gaps
10010            let is_cursive = if let ShapedItem::Cluster(c) = &item { is_cursive_script_cluster(c) } else { false };
10011            if !is_outside_marker && extra_char_spacing > 0.0 && can_justify_after(&item) && !is_cursive {
10012                main_axis_pen += extra_char_spacing;
10013            }
10014            // +spec:display-property:3a833c - consecutive atomic inlines treated as single unit for letter-spacing
10015            // +spec:display-property:49f04f - letter-spacing applied per innermost inline element
10016            // +spec:text-alignment-spacing:22bea4 - letter-spacing applied after bidi reordering, additive with kerning and word-spacing; justification may further adjust
10017            if let ShapedItem::Cluster(c) = &item {
10018                if !is_outside_marker {
10019                    // +spec:display-property:756454 - letter-spacing applied between typographic character units
10020                    // +spec:overflow:e63bc0 - letter-spacing ignores zero-width formatting chars (Cf); handled by shaper merging them into clusters
10021                    // +spec:text-alignment-spacing:80f9ec - letter-spacing applied per-cluster using innermost element's style (UA-allowed attachment)
10022                    // +spec:text-alignment-spacing:bdd704 - letter-spacing applied after each cluster, not at line start
10023                    // +spec:text-alignment-spacing:d3ef6e - single-char element: only trailing space, no inter-char effect
10024                    // +spec:text-alignment-spacing:d668fc - letter-spacing only affects characters within the element (per-cluster style)
10025                    // +spec:text-alignment-spacing:8dbb78 - zero letter-spacing behaves as normal (Px(0) adds no spacing)
10026                    // +spec:text-alignment-spacing:456643 - skip letter-spacing for cursive scripts
10027                    if !is_cursive_script_cluster(c) {
10028                    let letter_spacing_px = c.style.letter_spacing.resolve_px(c.style.font_size_px);
10029                    main_axis_pen += letter_spacing_px;
10030                    }
10031                    // +spec:width-calculation:9447d1 - word-spacing only applied to word separators; zero-width chars like U+200B are excluded
10032                    if is_word_separator(&item) {
10033                        let word_spacing_px = c.style.word_spacing.resolve_px(c.style.font_size_px);
10034                        main_axis_pen += word_spacing_px;
10035                        main_axis_pen += extra_word_spacing;
10036                    }
10037                }
10038            }
10039        }
10040    }
10041
10042    (positioned, line_box_height)
10043}
10044
10045/// Calculates the starting pen offset to achieve the desired text alignment.
10046fn calculate_alignment_offset(
10047    items: &[ShapedItem],
10048    line_constraints: &LineConstraints,
10049    align: TextAlign,
10050    is_vertical: bool,
10051    constraints: &UnifiedConstraints,
10052) -> f32 {
10053    // Simplified to use the first segment for alignment.
10054    if let Some(segment) = line_constraints.segments.first() {
10055        // Include letter/word-spacing so center/right alignment matches the width the
10056        // text is actually positioned at (position_one_line adds the spacing).
10057        let total_width: f32 = items
10058            .iter()
10059            .map(|item| get_item_measure_with_spacing(item, is_vertical))
10060            .sum();
10061
10062        let available_width = if constraints.segment_alignment == SegmentAlignment::Total {
10063            line_constraints.total_available
10064        } else {
10065            segment.width
10066        };
10067
10068        if total_width >= available_width {
10069            return 0.0; // No alignment needed if line is full or overflows
10070        }
10071
10072        let remaining_space = available_width - total_width;
10073
10074        match align {
10075            TextAlign::Center => remaining_space / 2.0,
10076            TextAlign::Right => remaining_space,
10077            _ => 0.0, // Left, Justify, Start, End
10078        }
10079    } else {
10080        0.0
10081    }
10082}
10083
10084/// Calculates the extra spacing needed for justification without modifying the items.
10085///
10086/// This function is pure and does not mutate any state, making it safe to use
10087/// with cached `ShapedItem` data.
10088///
10089/// # Arguments
10090/// * `items` - A slice of items on the line.
10091/// * `line_constraints` - The geometric constraints for the line.
10092/// * `text_justify` - The type of justification to calculate.
10093/// * `is_vertical` - Whether the layout is vertical.
10094///
10095/// # Returns
10096/// A tuple `(extra_per_word, extra_per_char)` containing the extra space in pixels
10097/// to add at each word or character justification opportunity.
10098// +spec:display-contents:654278 - distributes remaining space to fill line box when justifying
10099// +spec:text-alignment-spacing:56c7f4 - equal distribution of justification space within priority level
10100// +spec:text-alignment-spacing:f17bbc - justification opportunities controlled by text-justify value (inter-word = word separators, inter-character = character juxtaposition)
10101#[allow(clippy::cast_precision_loss)] // bounded pixel/coord/colour/glyph cast
10102fn calculate_justification_spacing(
10103    items: &[ShapedItem],
10104    line_constraints: &LineConstraints,
10105    text_justify: JustifyContent,
10106    is_vertical: bool,
10107) -> (f32, f32) {
10108    // (extra_per_word, extra_per_char)
10109    let total_width: f32 = items
10110        .iter()
10111        .map(|item| get_item_measure(item, is_vertical))
10112        .sum();
10113    let available_width = line_constraints.total_available;
10114
10115    if total_width >= available_width || available_width <= 0.0 {
10116        return (0.0, 0.0);
10117    }
10118
10119    let extra_space = available_width - total_width;
10120
10121    // +spec:text-alignment-spacing:71314a - script categories for justification: inter-word for clustered, kashida for cursive (Arabic), inter-character for block (CJK)
10122    match text_justify {
10123        JustifyContent::InterWord => {
10124            // Count justification opportunities (spaces).
10125            let space_count = items.iter().filter(|item| is_word_separator(item)).count();
10126            if space_count > 0 {
10127                (extra_space / space_count as f32, 0.0)
10128            } else {
10129                (0.0, 0.0) // No spaces to expand, do nothing.
10130            }
10131        }
10132        JustifyContent::InterCharacter | JustifyContent::Distribute => {
10133            // Count justification opportunities (between non-combining characters).
10134            let gap_count = items
10135                .iter()
10136                .enumerate()
10137                .filter(|(i, item)| *i < items.len() - 1 && can_justify_after(item))
10138                .count();
10139            if gap_count > 0 {
10140                (0.0, extra_space / gap_count as f32)
10141            } else {
10142                (0.0, 0.0) // No gaps to expand, do nothing.
10143            }
10144        }
10145        // Kashida justification modifies the item list and is handled by a separate function.
10146        _ => (0.0, 0.0),
10147    }
10148}
10149
10150/// Rebuilds a line of items, inserting Kashida glyphs for justification.
10151///
10152/// This function is non-mutating with respect to its inputs. It takes ownership of the
10153/// original items and returns a completely new `Vec`. This is necessary because Kashida
10154/// justification changes the number of items on the line, and must not modify cached data.
10155#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] // bounded pixel/coord/colour/glyph cast
10156#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
10157pub fn justify_kashida_and_rebuild<T: ParsedFontTrait>(
10158    items: Vec<ShapedItem>,
10159    line_constraints: &LineConstraints,
10160    is_vertical: bool,
10161    debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
10162    fonts: &LoadedFonts<T>,
10163) -> Vec<ShapedItem> {
10164    if let Some(msgs) = debug_messages {
10165        msgs.push(LayoutDebugMessage::info(
10166            "\n--- Entering justify_kashida_and_rebuild ---".to_string(),
10167        ));
10168    }
10169    let total_width: f32 = items
10170        .iter()
10171        .map(|item| get_item_measure(item, is_vertical))
10172        .sum();
10173    let available_width = line_constraints.total_available;
10174    if let Some(msgs) = debug_messages {
10175        msgs.push(LayoutDebugMessage::info(format!(
10176            "Total item width: {total_width}, Available width: {available_width}"
10177        )));
10178    }
10179
10180    if total_width >= available_width || available_width <= 0.0 {
10181        if let Some(msgs) = debug_messages {
10182            msgs.push(LayoutDebugMessage::info(
10183                "No justification needed (line is full or invalid).".to_string(),
10184            ));
10185        }
10186        return items;
10187    }
10188
10189    let extra_space = available_width - total_width;
10190    if let Some(msgs) = debug_messages {
10191        msgs.push(LayoutDebugMessage::info(format!(
10192            "Extra space to fill: {extra_space}"
10193        )));
10194    }
10195
10196    let font_info = items.iter().find_map(|item| {
10197        if let ShapedItem::Cluster(c) = item {
10198            if let Some(glyph) = c.glyphs.first() {
10199                if glyph.script == Script::Arabic {
10200                    // Look up font from hash
10201                    if let Some(font) = fonts.get_by_hash(glyph.font_hash) {
10202                        return Some((
10203                            font.clone(),
10204                            glyph.font_hash,
10205                            glyph.font_metrics,
10206                            glyph.style.clone(),
10207                        ));
10208                    }
10209                }
10210            }
10211        }
10212        None
10213    });
10214
10215    let (font, font_hash, font_metrics, style) = if let Some(info) = font_info {
10216        if let Some(msgs) = debug_messages {
10217            msgs.push(LayoutDebugMessage::info(
10218                "Found Arabic font for kashida.".to_string(),
10219            ));
10220        }
10221        info
10222    } else {
10223        if let Some(msgs) = debug_messages {
10224            msgs.push(LayoutDebugMessage::info(
10225                "No Arabic font found on line. Cannot insert kashidas.".to_string(),
10226            ));
10227        }
10228        return items;
10229    };
10230
10231    let (kashida_glyph_id, kashida_advance) =
10232        match font.get_kashida_glyph_and_advance(style.font_size_px) {
10233            Some((id, adv)) if adv > 0.0 => {
10234                if let Some(msgs) = debug_messages {
10235                    msgs.push(LayoutDebugMessage::info(format!(
10236                        "Font provides kashida glyph with advance {adv}"
10237                    )));
10238                }
10239                (id, adv)
10240            }
10241            _ => {
10242                if let Some(msgs) = debug_messages {
10243                    msgs.push(LayoutDebugMessage::info(
10244                        "Font does not support kashida justification.".to_string(),
10245                    ));
10246                }
10247                return items;
10248            }
10249        };
10250
10251    let opportunity_indices: Vec<usize> = items
10252        .windows(2)
10253        .enumerate()
10254        .filter_map(|(i, window)| {
10255            if let (ShapedItem::Cluster(cur), ShapedItem::Cluster(next)) = (&window[0], &window[1])
10256            {
10257                if is_arabic_cluster(cur)
10258                    && is_arabic_cluster(next)
10259                    && !is_word_separator(&window[1])
10260                {
10261                    return Some(i + 1);
10262                }
10263            }
10264            None
10265        })
10266        .collect();
10267
10268    if let Some(msgs) = debug_messages {
10269        msgs.push(LayoutDebugMessage::info(format!(
10270            "Found {} kashida insertion opportunities at indices: {:?}",
10271            opportunity_indices.len(),
10272            opportunity_indices
10273        )));
10274    }
10275
10276    if opportunity_indices.is_empty() {
10277        if let Some(msgs) = debug_messages {
10278            msgs.push(LayoutDebugMessage::info(
10279                "No opportunities found. Exiting.".to_string(),
10280            ));
10281        }
10282        return items;
10283    }
10284
10285    let num_kashidas_to_insert = (extra_space / kashida_advance).floor() as usize;
10286    if let Some(msgs) = debug_messages {
10287        msgs.push(LayoutDebugMessage::info(format!(
10288            "Calculated number of kashidas to insert: {num_kashidas_to_insert}"
10289        )));
10290    }
10291
10292    if num_kashidas_to_insert == 0 {
10293        return items;
10294    }
10295
10296    let kashidas_per_point = num_kashidas_to_insert / opportunity_indices.len();
10297    let mut remainder = num_kashidas_to_insert % opportunity_indices.len();
10298    if let Some(msgs) = debug_messages {
10299        msgs.push(LayoutDebugMessage::info(format!(
10300            "Distributing kashidas: {kashidas_per_point} per point, with {remainder} remainder."
10301        )));
10302    }
10303
10304    let kashida_item = {
10305        /* ... as before ... */
10306        let kashida_glyph = ShapedGlyph {
10307            kind: GlyphKind::Kashida {
10308                width: kashida_advance,
10309            },
10310            glyph_id: kashida_glyph_id,
10311            font_hash,
10312            font_metrics,
10313            style: style.clone(),
10314            script: Script::Arabic,
10315            advance: kashida_advance,
10316            kerning: 0.0,
10317            cluster_offset: 0,
10318            offset: Point::default(),
10319            vertical_advance: 0.0,
10320            vertical_offset: Point::default(),
10321        };
10322        ShapedItem::Cluster(ShapedCluster {
10323            text: "\u{0640}".to_string(),
10324            source_cluster_id: GraphemeClusterId {
10325                source_run: u32::MAX,
10326                start_byte_in_run: u32::MAX,
10327            },
10328            source_content_index: ContentIndex {
10329                run_index: u32::MAX,
10330                item_index: u32::MAX,
10331            },
10332            source_node_id: None, // Kashida is generated, not from DOM
10333            glyphs: smallvec![kashida_glyph],
10334            advance: kashida_advance,
10335            direction: BidiDirection::Ltr,
10336            style,
10337            marker_position_outside: None,
10338            is_first_fragment: true,
10339            is_last_fragment: true,
10340        })
10341    };
10342
10343    let mut new_items = Vec::with_capacity(items.len() + num_kashidas_to_insert);
10344    let mut last_copy_idx = 0;
10345    for &point in &opportunity_indices {
10346        new_items.extend_from_slice(&items[last_copy_idx..point]);
10347        let mut num_to_insert = kashidas_per_point;
10348        if remainder > 0 {
10349            num_to_insert += 1;
10350            remainder -= 1;
10351        }
10352        for _ in 0..num_to_insert {
10353            new_items.push(kashida_item.clone());
10354        }
10355        last_copy_idx = point;
10356    }
10357    new_items.extend_from_slice(&items[last_copy_idx..]);
10358
10359    if let Some(msgs) = debug_messages {
10360        msgs.push(LayoutDebugMessage::info(format!(
10361            "--- Exiting justify_kashida_and_rebuild, new item count: {} ---",
10362            new_items.len()
10363        )));
10364    }
10365    new_items
10366}
10367
10368/// Helper to determine if a cluster belongs to the Arabic script.
10369fn is_arabic_cluster(cluster: &ShapedCluster) -> bool {
10370    // A cluster is considered Arabic if its first non-NotDef glyph is from the Arabic script.
10371    // This is a robust heuristic for mixed-script lines.
10372    cluster.glyphs.iter().any(|g| g.script == Script::Arabic)
10373}
10374
10375/// Helper to identify if an item is a word separator (like a space).
10376fn measure_trailing_whitespace(items: &[ShapedItem], is_vertical: bool) -> f32 {
10377    let mut trailing_ws = 0.0;
10378    for item in items.iter().rev() {
10379        if is_collapsible_whitespace(item) {
10380            trailing_ws += get_item_measure(item, is_vertical);
10381        } else {
10382            break;
10383        }
10384    }
10385    trailing_ws
10386}
10387
10388/// Returns true if the item is collapsible whitespace per CSS Text 3 §4.1.2 Phase II.
10389///
10390/// This is used for stripping leading/trailing whitespace at line edges —
10391/// distinct from `is_word_separator` which is for word-spacing per §7.1.
10392#[must_use] pub fn is_collapsible_whitespace(item: &ShapedItem) -> bool {
10393    if let ShapedItem::Cluster(c) = item {
10394        c.text.chars().all(|ch| matches!(ch,
10395            ' ' | '\t' | '\u{1680}' // Ogham space mark (collapsible per spec)
10396        ))
10397    } else {
10398        false
10399    }
10400}
10401
10402// +spec:text-alignment-spacing:456643 - cursive scripts do not admit letter-spacing gaps
10403/// Returns true if the cluster's first character belongs to a cursive script
10404/// (Arabic, Syriac, Mongolian, N'Ko, Mandaic, Phags Pa, Hanifi Rohingya)
10405/// per CSS Text 3 Appendix D.
10406///
10407/// These scripts should not have letter-spacing applied.
10408pub fn is_cursive_script_cluster(c: &ShapedCluster) -> bool {
10409    c.text.chars().next().is_some_and(is_cursive_script_char)
10410}
10411
10412fn is_cursive_script_char(ch: char) -> bool {
10413    let cp = ch as u32;
10414    // Arabic (U+0600–U+06FF, U+0750–U+077F, U+08A0–U+08FF, U+FB50–U+FDFF, U+FE70–U+FEFF)
10415    if (0x0600..=0x06FF).contains(&cp) { return true; }
10416    if (0x0750..=0x077F).contains(&cp) { return true; }
10417    if (0x08A0..=0x08FF).contains(&cp) { return true; }
10418    if (0xFB50..=0xFDFF).contains(&cp) { return true; }
10419    if (0xFE70..=0xFEFF).contains(&cp) { return true; }
10420    // Syriac (U+0700–U+074F)
10421    if (0x0700..=0x074F).contains(&cp) { return true; }
10422    // Mongolian (U+1800–U+18AF)
10423    if (0x1800..=0x18AF).contains(&cp) { return true; }
10424    // N'Ko (U+07C0–U+07FF)
10425    if (0x07C0..=0x07FF).contains(&cp) { return true; }
10426    // Mandaic (U+0840–U+085F)
10427    if (0x0840..=0x085F).contains(&cp) { return true; }
10428    // Phags Pa (U+A840–U+A87F)
10429    if (0xA840..=0xA87F).contains(&cp) { return true; }
10430    // Hanifi Rohingya (U+10D00–U+10D3F)
10431    if (0x10D00..=0x10D3F).contains(&cp) { return true; }
10432    false
10433}
10434
10435/// Word-segmentation predicate shared by word selection (double-click) and word
10436/// cursor motion (Ctrl/Alt+Arrow) so they agree on what a "word" is.
10437///
10438/// A word character is alphanumeric or underscore; everything else — whitespace
10439/// AND punctuation — is a word boundary. This is deliberately distinct from
10440/// [`is_word_separator`] (which classifies *spacing* characters for word-spacing
10441/// justification per CSS Text §7.1, and treats punctuation as non-separator).
10442/// Used by `selection::find_word_boundaries` and `UnifiedLayout::move_cursor_to_*_word`.
10443pub(crate) fn is_word_char(ch: char) -> bool {
10444    ch.is_alphanumeric() || ch == '_'
10445}
10446
10447/// True when a shaped cluster is a word-segmentation boundary (whitespace or
10448/// punctuation), i.e. it contains no word characters. Keeps cursor word-motion
10449/// consistent with `selection::find_word_boundaries`.
10450fn cluster_is_word_boundary(cluster: &ShapedCluster) -> bool {
10451    !cluster.text.chars().any(is_word_char)
10452}
10453
10454// exclude punctuation and fixed-width spaces (U+3000, U+2000..U+200A)
10455pub fn is_word_separator(item: &ShapedItem) -> bool {
10456    if let ShapedItem::Cluster(c) = item {
10457        c.text.chars().any(is_word_separator_char)
10458    } else {
10459        false
10460    }
10461}
10462
10463/// True for separators that add word-spacing but must NOT offer a soft-wrap opportunity.
10464///
10465/// (UAX#14 class GL/WJ): NBSP, NARROW NO-BREAK SPACE, WORD JOINER, ZWNBSP. These are a
10466/// subset of `is_word_separator` — they still contribute Glue, but no break Penalty.
10467#[must_use] pub fn is_no_break_space(item: &ShapedItem) -> bool {
10468    if let ShapedItem::Cluster(c) = item {
10469        c.text
10470            .chars()
10471            .any(|ch| matches!(ch, '\u{00A0}' | '\u{202F}' | '\u{2060}' | '\u{FEFF}'))
10472    } else {
10473        false
10474    }
10475}
10476
10477// +spec:margin-collapsing:6706c1 - fixed-width spaces (U+2000–U+200A, U+3000) excluded from word separators
10478/// Returns true if the character is a word-separator character per CSS Text §7.1.
10479/// Punctuation and fixed-width spaces (U+3000, U+2000 through U+200A) are NOT
10480/// word-separator characters even though they may visually separate words.
10481// +spec:text-alignment-spacing:3e0655 - word-separator characters for word-spacing
10482#[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
10483const fn is_word_separator_char(c: char) -> bool {
10484    match c {
10485        // Standard ASCII space
10486        '\u{0020}' => true,
10487        // NO-BREAK SPACE
10488        '\u{00A0}' => true,
10489        // OGHAM SPACE MARK
10490        '\u{1680}' => true,
10491        // ETHIOPIC WORDSPACE (spec §7.1)
10492        '\u{1361}' => true,
10493        // Fixed-width spaces: NOT word separators per spec
10494        '\u{2000}'..='\u{200A}' => false,
10495        // NARROW NO-BREAK SPACE
10496        '\u{202F}' => true,
10497        // MEDIUM MATHEMATICAL SPACE
10498        '\u{205F}' => true,
10499        // IDEOGRAPHIC SPACE: NOT a word separator per spec
10500        '\u{3000}' => false,
10501        // AEGEAN WORD SEPARATOR LINE (spec §7.1)
10502        '\u{10100}' => true,
10503        // AEGEAN WORD SEPARATOR DOT (spec §7.1)
10504        '\u{10101}' => true,
10505        // UGARITIC WORD DIVIDER (spec §7.1)
10506        '\u{1039F}' => true,
10507        // PHOENICIAN WORD SEPARATOR (spec §7.1)
10508        '\u{1091F}' => true,
10509        // Other Unicode whitespace not listed above
10510        _ => false,
10511    }
10512}
10513
10514/// Helper to identify if an item is a zero-width space (U+200B),
10515/// which provides a soft wrap opportunity with no visible width.
10516///
10517/// Used in scripts like Thai, Lao, and Khmer that don't use spaces between words.
10518// +spec:line-breaking:fd3164 - U+200B as explicit word delimiter for scripts without space-separated words
10519#[must_use] pub fn is_zero_width_space(item: &ShapedItem) -> bool {
10520    if let ShapedItem::Cluster(c) = item {
10521        c.text.contains('\u{200B}')
10522    } else {
10523        false
10524    }
10525}
10526
10527/// Helper to identify if space can be added after an item.
10528fn can_justify_after(item: &ShapedItem) -> bool {
10529    if let ShapedItem::Cluster(c) = item {
10530        c.text.chars().last().is_some_and(|g| {
10531            !g.is_whitespace() && classify_character(g as u32) != CharacterClass::Combining
10532        })
10533    } else {
10534        // Per CSS 2.2 §9.4.2, justification must NOT stretch inline-table and
10535        // inline-block boxes. Object items represent these atomic inline-level
10536        // boxes, so we return false to prevent adding justification space after them.
10537        false
10538    }
10539}
10540
10541// +spec:font-metrics:b8eb97 - Script group classification for justification/letter-spacing behavior
10542/// Classifies a character for layout purposes (e.g., justification behavior).
10543/// Copied from `mod.rs`.
10544#[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
10545const fn classify_character(codepoint: u32) -> CharacterClass {
10546    match codepoint {
10547        0x0020 | 0x00A0 | 0x3000 => CharacterClass::Space,
10548        0x0021..=0x002F | 0x003A..=0x0040 | 0x005B..=0x0060 | 0x007B..=0x007E => {
10549            CharacterClass::Punctuation
10550        }
10551        0x4E00..=0x9FFF | 0x3400..=0x4DBF => CharacterClass::Ideograph,
10552        0x0300..=0x036F | 0x1AB0..=0x1AFF => CharacterClass::Combining,
10553        // Mongolian script range
10554        0x1800..=0x18AF => CharacterClass::Letter,
10555        _ => CharacterClass::Letter,
10556    }
10557}
10558
10559/// Helper to get the primary measure (width or height) of a shaped item.
10560#[must_use] pub fn get_item_measure(item: &ShapedItem, is_vertical: bool) -> f32 {
10561    match item {
10562        ShapedItem::Cluster(c) => {
10563            // Total width = base advance + kerning adjustments
10564            // Kerning is stored separately in glyphs for inspection, but the total
10565            // cluster width must include it for correct layout positioning
10566            let total_kerning: f32 = c.glyphs.iter().map(|g| g.kerning).sum();
10567            c.advance + total_kerning
10568        }
10569        ShapedItem::Object { bounds, .. }
10570        | ShapedItem::CombinedBlock { bounds, .. }
10571        | ShapedItem::Tab { bounds, .. } => {
10572            if is_vertical {
10573                bounds.height
10574            } else {
10575                bounds.width
10576            }
10577        }
10578        ShapedItem::Break { .. } => 0.0,
10579    }
10580}
10581
10582/// Like [`get_item_measure`] but ALSO includes the per-cluster letter-spacing and
10583/// per-separator word-spacing that `position_one_line` adds after each cluster.
10584///
10585/// Line breaking and center/right alignment must measure the SAME width the text is
10586/// finally positioned at; `get_item_measure` alone omits letter/word-spacing, so a run
10587/// that "just fits" without spacing overflows its box (or mis-aligns) once the spacing
10588/// is applied. Selection/caret geometry must NOT include the trailing spacing, so those
10589/// callers keep using the bare `get_item_measure`.
10590#[must_use]
10591pub fn get_item_measure_with_spacing(item: &ShapedItem, is_vertical: bool) -> f32 {
10592    let base = get_item_measure(item, is_vertical);
10593    if let ShapedItem::Cluster(c) = item {
10594        let mut extra = 0.0;
10595        if !is_cursive_script_cluster(c) {
10596            extra += c.style.letter_spacing.resolve_px(c.style.font_size_px);
10597        }
10598        if is_word_separator(item) {
10599            extra += c.style.word_spacing.resolve_px(c.style.font_size_px);
10600        }
10601        base + extra
10602    } else {
10603        base
10604    }
10605}
10606
10607/// Calculates the available horizontal segments for a line at a given vertical position,
10608/// considering both shape boundaries and exclusions.
10609#[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
10610fn get_line_constraints(
10611    line_y: f32,
10612    line_height: f32,
10613    constraints: &UnifiedConstraints,
10614    debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
10615) -> LineConstraints {
10616    if let Some(msgs) = debug_messages {
10617        msgs.push(LayoutDebugMessage::info(format!(
10618            "\n--- Entering get_line_constraints for y={line_y} ---"
10619        )));
10620    }
10621
10622    let mut available_segments = Vec::new();
10623    if constraints.shape_boundaries.is_empty() {
10624        // The segment_width is determined by available_width, NOT by TextWrap.
10625        // TextWrap::NoWrap only affects whether the LineBreaker can insert soft breaks,
10626        // it should NOT override a definite width constraint from CSS.
10627        // +spec:overflow:b06c3e - text overflows when wrapping is prevented (e.g. white-space: nowrap)
10628        // CSS Text Level 3: For 'white-space: pre/nowrap', text overflows horizontally
10629        // if it doesn't fit, rather than expanding the container.
10630        //
10631        // For MinContent/MaxContent intrinsic sizing: use a large value to let text 
10632        // lay out fully. The line breaker handles min-content by breaking at word 
10633        // boundaries. The actual content width is measured from the laid-out lines.
10634        let segment_width = match constraints.available_width {
10635            AvailableSpace::Definite(w) => w, // Respect definite width from CSS
10636            AvailableSpace::MaxContent => f32::MAX / 2.0, // For intrinsic max-content sizing
10637            AvailableSpace::MinContent => f32::MAX / 2.0, // For intrinsic min-content sizing
10638        };
10639        // Note: TextWrap::NoWrap is handled by the LineBreaker in break_one_line()
10640        // to prevent soft wraps. The text will simply overflow if it exceeds segment_width.
10641        available_segments.push(LineSegment {
10642            start_x: 0.0,
10643            width: segment_width,
10644            priority: 0,
10645        });
10646    } else {
10647        // ... complex boundary logic ...
10648    }
10649
10650    if let Some(msgs) = debug_messages {
10651        msgs.push(LayoutDebugMessage::info(format!(
10652            "Initial available segments: {available_segments:?}"
10653        )));
10654    }
10655
10656    for (idx, exclusion) in constraints.shape_exclusions.iter().enumerate() {
10657        if let Some(msgs) = debug_messages {
10658            msgs.push(LayoutDebugMessage::info(format!(
10659                "Applying exclusion #{idx}: {exclusion:?}"
10660            )));
10661        }
10662        let exclusion_spans =
10663            get_shape_horizontal_spans(exclusion, line_y, line_height);
10664        if let Some(msgs) = debug_messages {
10665            msgs.push(LayoutDebugMessage::info(format!(
10666                "  Exclusion spans at y={line_y}: {exclusion_spans:?}"
10667            )));
10668        }
10669
10670        if exclusion_spans.is_empty() {
10671            continue;
10672        }
10673
10674        let mut next_segments = Vec::new();
10675        for (excl_start, excl_end) in exclusion_spans {
10676            for segment in &available_segments {
10677                let seg_start = segment.start_x;
10678                let seg_end = segment.start_x + segment.width;
10679
10680                // Create new segments by subtracting the exclusion
10681                if seg_end > excl_start && seg_start < excl_end {
10682                    if seg_start < excl_start {
10683                        // Left part
10684                        next_segments.push(LineSegment {
10685                            start_x: seg_start,
10686                            width: excl_start - seg_start,
10687                            priority: segment.priority,
10688                        });
10689                    }
10690                    if seg_end > excl_end {
10691                        // Right part
10692                        next_segments.push(LineSegment {
10693                            start_x: excl_end,
10694                            width: seg_end - excl_end,
10695                            priority: segment.priority,
10696                        });
10697                    }
10698                } else {
10699                    next_segments.push(*segment); // No overlap
10700                }
10701            }
10702            available_segments = merge_segments(next_segments);
10703            next_segments = Vec::new();
10704        }
10705        if let Some(msgs) = debug_messages {
10706            msgs.push(LayoutDebugMessage::info(format!(
10707                "  Segments after exclusion #{idx}: {available_segments:?}"
10708            )));
10709        }
10710    }
10711
10712    let total_width = available_segments.iter().map(|s| s.width).sum();
10713    if let Some(msgs) = debug_messages {
10714        msgs.push(LayoutDebugMessage::info(format!(
10715            "Final segments: {available_segments:?}, total available width: {total_width}"
10716        )));
10717        msgs.push(LayoutDebugMessage::info(
10718            "--- Exiting get_line_constraints ---".to_string(),
10719        ));
10720    }
10721
10722    LineConstraints {
10723        segments: available_segments,
10724        total_available: total_width,
10725        is_min_content: matches!(constraints.available_width, AvailableSpace::MinContent),
10726    }
10727}
10728
10729/// Flattens a parsed SVG multipolygon (from a CSS `path()` shape) into a flat list of
10730/// `PathSegment`s in absolute coordinates (offset by the reference box origin). Each ring
10731/// becomes a `MoveTo` + a run of `LineTo`s + `Close`; curve elements are sampled into line
10732/// segments (~one segment per 4px of arc length, capped) so the scanline intersection can
10733/// treat each subpath as a polygon.
10734// bounded curve-sampling geometry casts (step count / arc-length parameter / coords)
10735#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss)]
10736fn flatten_svg_to_path_segments(
10737    multipolygon: &azul_core::svg::SvgMultiPolygon,
10738    reference_box: Rect,
10739) -> Vec<PathSegment> {
10740    use azul_core::svg::SvgPathElement;
10741
10742    let mut out: Vec<PathSegment> = Vec::new();
10743
10744    for ring in multipolygon.rings.as_ref() {
10745        let elements = ring.items.as_ref();
10746        if elements.is_empty() {
10747            continue;
10748        }
10749        let start = elements[0].get_start();
10750        out.push(PathSegment::MoveTo(Point {
10751            x: reference_box.x + start.x,
10752            y: reference_box.y + start.y,
10753        }));
10754        for el in elements {
10755            match el {
10756                SvgPathElement::Line(l) => {
10757                    out.push(PathSegment::LineTo(Point {
10758                        x: reference_box.x + l.end.x,
10759                        y: reference_box.y + l.end.y,
10760                    }));
10761                }
10762                curve => {
10763                    // Sample the curve by arc length into line segments.
10764                    let len = curve.get_length();
10765                    let steps = ((len / 4.0).ceil() as usize).clamp(1, 64);
10766                    for i in 1..=steps {
10767                        let offset = len * (i as f64) / (steps as f64);
10768                        let t = curve.get_t_at_offset(offset);
10769                        out.push(PathSegment::LineTo(Point {
10770                            x: reference_box.x + curve.get_x_at_t(t) as f32,
10771                            y: reference_box.y + curve.get_y_at_t(t) as f32,
10772                        }));
10773                    }
10774                }
10775            }
10776        }
10777        out.push(PathSegment::Close);
10778    }
10779
10780    out
10781}
10782
10783/// Computes horizontal line segments where a flattened `path()` shape (a set of
10784/// `MoveTo`/`LineTo`/`Close` subpaths) intersects a scanline at the given y range. Uses an
10785/// even-odd fill rule over the union of all subpaths so reversed rings (holes) carve out
10786/// space. Curves are assumed already flattened to `LineTo`s by `flatten_svg_to_path_segments`.
10787fn path_segments_line_intersection(
10788    segments: &[PathSegment],
10789    y: f32,
10790    line_height: f32,
10791) -> Vec<(f32, f32)> {
10792    let line_center_y = y + line_height / 2.0;
10793    let mut crossings: Vec<f32> = Vec::new();
10794
10795    // Walk the segments, reconstructing each subpath's vertices and intersecting its
10796    // (closing) edges with the scanline.
10797    let mut subpath: Vec<Point> = Vec::new();
10798    let flush = |subpath: &mut Vec<Point>, crossings: &mut Vec<f32>| {
10799        if subpath.len() >= 2 {
10800            for i in 0..subpath.len() {
10801                let p1 = subpath[i];
10802                let p2 = subpath[(i + 1) % subpath.len()];
10803                if (p2.y - p1.y).abs() < f32::EPSILON {
10804                    continue;
10805                }
10806                let crosses = (p1.y <= line_center_y && p2.y > line_center_y)
10807                    || (p1.y > line_center_y && p2.y <= line_center_y);
10808                if crosses {
10809                    let t = (line_center_y - p1.y) / (p2.y - p1.y);
10810                    crossings.push(t.mul_add(p2.x - p1.x, p1.x));
10811                }
10812            }
10813        }
10814        subpath.clear();
10815    };
10816
10817    for seg in segments {
10818        match seg {
10819            PathSegment::MoveTo(p) => {
10820                flush(&mut subpath, &mut crossings);
10821                subpath.push(*p);
10822            }
10823            PathSegment::LineTo(p) => subpath.push(*p),
10824            PathSegment::Close => flush(&mut subpath, &mut crossings),
10825            // CurveTo/QuadTo/Arc should have been flattened to LineTo already; sample the
10826            // end point as a fallback so an unflattened path still produces a polygon.
10827            PathSegment::CurveTo { end, .. } | PathSegment::QuadTo { end, .. } => {
10828                subpath.push(*end);
10829            }
10830            PathSegment::Arc { center, .. } => subpath.push(*center),
10831        }
10832    }
10833    flush(&mut subpath, &mut crossings);
10834
10835    crossings.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
10836    let mut spans = Vec::new();
10837    for chunk in crossings.chunks_exact(2) {
10838        if chunk[1] > chunk[0] {
10839            spans.push((chunk[0], chunk[1]));
10840        }
10841    }
10842    spans
10843}
10844
10845/// Helper function to get the horizontal spans of any shape at a given y-coordinate.
10846/// Returns a list of (`start_x`, `end_x`) tuples.
10847#[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
10848fn get_shape_horizontal_spans(
10849    shape: &ShapeBoundary,
10850    y: f32,
10851    line_height: f32,
10852) -> Vec<(f32, f32)> {
10853    match shape {
10854        ShapeBoundary::Rectangle(rect) => {
10855            // Check for any overlap between the line box [y, y + line_height]
10856            // and the rectangle's vertical span [rect.y, rect.y + rect.height].
10857            let line_start = y;
10858            let line_end = y + line_height;
10859            let rect_start = rect.y;
10860            let rect_end = rect.y + rect.height;
10861
10862            if line_start < rect_end && line_end > rect_start {
10863                vec![(rect.x, rect.x + rect.width)]
10864            } else {
10865                vec![]
10866            }
10867        }
10868        ShapeBoundary::Circle { center, radius } => {
10869            let line_center_y = y + line_height / 2.0;
10870            let dy = (line_center_y - center.y).abs();
10871            if dy <= *radius {
10872                let dx = (radius.powi(2) - dy.powi(2)).sqrt();
10873                vec![(center.x - dx, center.x + dx)]
10874            } else {
10875                vec![]
10876            }
10877        }
10878        ShapeBoundary::Ellipse { center, radii } => {
10879            let line_center_y = y + line_height / 2.0;
10880            let dy = line_center_y - center.y;
10881            if dy.abs() <= radii.height {
10882                // Formula: (x-h)^2/a^2 + (y-k)^2/b^2 = 1
10883                let y_term = dy / radii.height;
10884                let x_term_squared = 1.0 - y_term.powi(2);
10885                if x_term_squared >= 0.0 {
10886                    let dx = radii.width * x_term_squared.sqrt();
10887                    vec![(center.x - dx, center.x + dx)]
10888                } else {
10889                    vec![]
10890                }
10891            } else {
10892                vec![]
10893            }
10894        }
10895        ShapeBoundary::Polygon { points } => {
10896            let segments = polygon_line_intersection(points, y, line_height);
10897            segments
10898                .iter()
10899                .map(|s| (s.start_x, s.start_x + s.width))
10900                .collect()
10901        }
10902        // Scanline intersection for `path()` shapes. `segments` is the flattened
10903        // (Close-terminated, curves pre-sampled) output of `flatten_svg_to_path_segments`;
10904        // intersect each subpath polygon with this scanline under an even-odd fill rule so
10905        // reversed rings (holes) carve out space.
10906        ShapeBoundary::Path { segments } => {
10907            path_segments_line_intersection(segments, y, line_height)
10908        }
10909    }
10910}
10911
10912/// Merges overlapping or adjacent line segments into larger ones.
10913fn merge_segments(mut segments: Vec<LineSegment>) -> Vec<LineSegment> {
10914    if segments.len() <= 1 {
10915        return segments;
10916    }
10917    segments.sort_by(|a, b| a.start_x.partial_cmp(&b.start_x).unwrap_or(Ordering::Equal));
10918    let mut merged = vec![segments[0]];
10919    for next_seg in segments.iter().skip(1) {
10920        let last = merged.last_mut().unwrap();
10921        if next_seg.start_x <= last.start_x + last.width {
10922            let new_width = (next_seg.start_x + next_seg.width) - last.start_x;
10923            last.width = last.width.max(new_width);
10924        } else {
10925            merged.push(*next_seg);
10926        }
10927    }
10928    merged
10929}
10930
10931/// Computes horizontal line segments where a polygon intersects a scanline at the given y range.
10932#[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
10933fn polygon_line_intersection(
10934    points: &[Point],
10935    y: f32,
10936    line_height: f32,
10937) -> Vec<LineSegment> {
10938    if points.len() < 3 {
10939        return vec![];
10940    }
10941
10942    let line_center_y = y + line_height / 2.0;
10943    let mut intersections = Vec::new();
10944
10945    // Use winding number algorithm for robustness with complex polygons.
10946    for i in 0..points.len() {
10947        let p1 = points[i];
10948        let p2 = points[(i + 1) % points.len()];
10949
10950        // Skip horizontal edges as they don't intersect a horizontal scanline in a meaningful way.
10951        if (p2.y - p1.y).abs() < f32::EPSILON {
10952            continue;
10953        }
10954
10955        // Check if our horizontal scanline at `line_center_y` crosses this polygon edge.
10956        let crosses = (p1.y <= line_center_y && p2.y > line_center_y)
10957            || (p1.y > line_center_y && p2.y <= line_center_y);
10958
10959        if crosses {
10960            // Calculate intersection x-coordinate using linear interpolation.
10961            let t = (line_center_y - p1.y) / (p2.y - p1.y);
10962            let x = p1.x + t * (p2.x - p1.x);
10963            intersections.push(x);
10964        }
10965    }
10966
10967    // Sort intersections by x-coordinate to form spans.
10968    intersections.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
10969
10970    // Build segments from paired intersection points.
10971    let mut segments = Vec::new();
10972    for chunk in intersections.chunks_exact(2) {
10973        let start_x = chunk[0];
10974        let end_x = chunk[1];
10975        if end_x > start_x {
10976            segments.push(LineSegment {
10977                start_x,
10978                width: end_x - start_x,
10979                priority: 0,
10980            });
10981        }
10982    }
10983
10984    segments
10985}
10986
10987// ADDITION: A helper function to get a hyphenator.
10988/// Helper to get a hyphenator for a given language.
10989/// TODO: In a real app, this would be cached.
10990#[cfg(feature = "text_layout_hyphenation")]
10991fn get_hyphenator(language: HyphenationLanguage) -> Result<Standard, LayoutError> {
10992    Standard::from_embedded(language).map_err(|e| LayoutError::HyphenationError(e.to_string()))
10993}
10994
10995/// Stub when hyphenation is disabled - always returns an error
10996#[cfg(not(feature = "text_layout_hyphenation"))]
10997fn get_hyphenator(_language: Language) -> Result<Standard, LayoutError> {
10998    Err(LayoutError::HyphenationError("Hyphenation feature not enabled".to_string()))
10999}
11000
11001// +spec:inline-block:6e7dd9 - Non-tailorable Unicode line breaking controls take precedence over atomic inline rules (CSS-TEXT-3 recent changes, issue 8972)
11002
11003const fn is_break_suppressing_control(ch: char) -> bool {
11004    matches!(ch,
11005        '\u{200D}' | // ZERO WIDTH JOINER
11006        '\u{2060}' | // WORD JOINER
11007        '\u{FEFF}'   // ZERO WIDTH NO-BREAK SPACE
11008    )
11009}
11010
11011const fn is_break_forcing_control(ch: char) -> bool {
11012    matches!(ch,
11013        '\u{200B}' | // ZERO WIDTH SPACE (already handled but included for completeness)
11014        '\u{2028}' | // LINE SEPARATOR
11015        '\u{2029}'   // PARAGRAPH SEPARATOR
11016    )
11017}
11018
11019// +spec:line-breaking:495247 - CJK/syllabic writing systems allow breaks between typographic letter units with varying strictness
11020// §5.2 word-break: determines if a character is CJK ideograph/kana
11021const fn is_cjk_character(ch: char) -> bool {
11022    let cp = ch as u32;
11023    matches!(cp,
11024        // CJK Unified Ideographs
11025        0x4E00..=0x9FFF |
11026        // CJK Unified Ideographs Extension A
11027        0x3400..=0x4DBF |
11028        // CJK Unified Ideographs Extension B
11029        0x20000..=0x2A6DF |
11030        // CJK Compatibility Ideographs
11031        0xF900..=0xFAFF |
11032        // Hiragana
11033        0x3040..=0x309F |
11034        // Katakana
11035        0x30A0..=0x30FF |
11036        // Katakana Phonetic Extensions
11037        0x31F0..=0x31FF |
11038        // CJK Symbols and Punctuation
11039        0x3000..=0x303F |
11040        // Halfwidth and Fullwidth Forms
11041        0xFF00..=0xFFEF |
11042        // Hangul Syllables
11043        0xAC00..=0xD7AF
11044    )
11045}
11046
11047// §5.2 word-break: checks if a cluster contains CJK characters
11048fn is_cjk_cluster(cluster: &ShapedCluster) -> bool {
11049    cluster.text.chars().any(is_cjk_character)
11050}
11051
11052// +spec:line-breaking:e1fc9d - word-break normal/break-all/keep-all break opportunity rules
11053// +spec:line-breaking:73d5fe - word-break break-point determination for CJK and Latin text
11054// +spec:line-breaking:31ef1a - word-break property controls soft wrap opportunities between letters (NU/AL/AI/ID classes as letter units)
11055// +spec:line-breaking:798252 - word-break property affects break opportunities (normal/break-all/keep-all)
11056// +spec:line-breaking:8fed57 - word-break: break-all treats all clusters as break opportunities, keep-all suppresses CJK breaks
11057// +spec:line-breaking:e2b374 - word-break: normal (only at separators) vs break-all (between all letters incl. Ethiopic)
11058// +spec:overflow:53a97f - word-break (normal/break-all/keep-all) and line-break strictness rules
11059// +spec:line-breaking:1c830a - word-break: normal/break-all/keep-all break opportunity rules
11060// §5.2 word-break property: break opportunity logic
11061// +spec:line-breaking:a75147 - word-break property: normal (CJK breaks), break-all (every cluster), keep-all (suppress CJK breaks)
11062// +spec:line-breaking:65ab41 - word-break: normal/break-all/keep-all break opportunity rules
11063// +spec:line-breaking:7eca16 - U+200B ZERO WIDTH SPACE is always a break opportunity, even with keep-all
11064fn is_break_opportunity_with_word_break(item: &ShapedItem, word_break: WordBreak, hyphens: Hyphens) -> bool {
11065    // No-break spaces (UAX#14 class GL/WJ) are word separators for word-spacing
11066    // purposes but must NOT offer a soft-wrap opportunity. This is the segmentation
11067    // path used by BreakCursor::peek_next_unit, so it must suppress them the same way
11068    // the dedicated is_break_opportunity() does; otherwise "10\u{00A0}km" wrongly wraps.
11069    if let ShapedItem::Cluster(c) = item {
11070        if c.text
11071            .chars()
11072            .any(|ch| matches!(ch, '\u{00A0}' | '\u{202F}' | '\u{2060}' | '\u{FEFF}'))
11073        {
11074            return false;
11075        }
11076    }
11077    // Break after spaces or explicit break items (always, regardless of word-break).
11078    if is_word_separator(item) {
11079        return true;
11080    }
11081    if let ShapedItem::Break { .. } = item {
11082        return true;
11083    }
11084    // +spec:line-breaking:432d5b - hyphens property controls soft wrap opportunities via hyphenation
11085    // +spec:line-breaking:5a32a1 - soft hyphen (U+00AD) creates break opportunity; glyph styled per surrounding text properties
11086    // U+200B ZERO WIDTH SPACE is always a soft wrap opportunity regardless of word-break.
11087    // This allows authors to mark explicit wrap points (e.g. with <wbr> or &#x200B;)
11088    // even when using word-break: keep-all to suppress other breaks.
11089    if is_zero_width_space(item) {
11090        return true;
11091    }
11092    // only when hyphens != none. With hyphens:none, soft hyphens do not create break points.
11093    if hyphens != Hyphens::None {
11094        if let ShapedItem::Cluster(c) = item {
11095            if c.text.starts_with('\u{00AD}') {
11096                return true;
11097            }
11098        }
11099    }
11100
11101    // +spec:line-breaking:05e09a - U+002D HYPHEN-MINUS / U+2010 HYPHEN always create a
11102    // soft-wrap opportunity AFTER them (UAX#14 class HY/BA), independent of the hyphens
11103    // property (they are NOT hyphenation opportunities — no extra glyph is inserted).
11104    // U+002F SOLIDUS (UAX#14 class SY) likewise offers a break AFTER it (URLs/paths),
11105    // matching browser practice. Mirrors is_break_opportunity(); this predicate drives
11106    // the greedy BreakCursor path, which previously never broke after a plain hyphen/slash.
11107    if let ShapedItem::Cluster(c) = item {
11108        if c.text.ends_with('\u{002D}') || c.text.ends_with('\u{2010}') || c.text.ends_with('\u{002F}') {
11109            return true;
11110        }
11111    }
11112
11113    // +spec:line-breaking:2bbda0 - word-break does not affect soft wrap opportunities around punctuation
11114    match word_break {
11115        WordBreak::Normal => {
11116            // CJK characters are implicit break opportunities in normal mode.
11117            if let ShapedItem::Cluster(c) = item {
11118                if is_cjk_cluster(c) {
11119                    return true;
11120                }
11121            }
11122            false
11123        }
11124        WordBreak::BreakAll => {
11125            // Every typographic letter unit is a break opportunity.
11126            if let ShapedItem::Cluster(_) = item {
11127                return true;
11128            }
11129            false
11130        }
11131        WordBreak::KeepAll => {
11132            // +spec:line-breaking:aa3044 - keep-all suppresses CJK (incl. Korean) inter-character breaks
11133            // Only break at spaces/hyphens (already handled above).
11134            false
11135        }
11136    }
11137}
11138
11139// +spec:line-breaking:db0289 - line-break strictness: anywhere allows soft wrap around every typographic character unit
11140// +spec:line-breaking:7d242b - line-break strictness levels: loose/normal/strict/anywhere with CJK punctuation rules
11141// +spec:line-breaking:67bfe8 - line-break strictness (auto/loose/normal/strict/anywhere) controls
11142// CSS Text Level 3 §5.3: Determines whether a break opportunity before a character is
11143// allowed based on the line-break strictness level. The spec defines:
11144// - strict: forbids breaks before small kana (class CJ), CJK hyphens, and certain punctuation
11145// - normal: allows breaks before small kana (CJ); allows CJK hyphen breaks for CJK writing systems
11146// - loose: additionally allows breaks before hyphens U+2010/U+2013 after ID-class chars
11147// - anywhere: allows soft wrap around every typographic character unit
11148#[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
11149const fn is_cjk_break_allowed_by_strictness(
11150    ch: char,
11151    _prev_ch: Option<char>,
11152    strictness: LineBreakStrictness,
11153) -> bool {
11154    match strictness {
11155        LineBreakStrictness::Anywhere => true,
11156        LineBreakStrictness::Loose => {
11157            // Loose allows breaks before hyphens U+2010, U+2013 when preceded by ID-class chars
11158            // Also allows breaks before small kana (CJ class) and CJK hyphens
11159            true
11160        }
11161        LineBreakStrictness::Normal | LineBreakStrictness::Auto => {
11162            // Normal forbids breaks before hyphens U+2010/U+2013 for non-CJK text
11163            // but allows breaks before small kana (CJ) and CJK hyphen-like chars
11164            // (〜 U+301C, ゠ U+30A0) for CJK writing systems
11165            match ch {
11166                '\u{2010}' | '\u{2013}' => false, // hyphens forbidden in normal
11167                _ => true,
11168            }
11169        }
11170        LineBreakStrictness::Strict => {
11171            // Strict forbids breaks before:
11172            // - Small kana and prolonged sound mark (Unicode line break class CJ)
11173            // - CJK hyphen-like characters: 〜 U+301C, ゠ U+30A0
11174            // - Hyphens: ‐ U+2010, – U+2013
11175            match ch {
11176                '\u{301C}' | '\u{30A0}' => false, // CJK hyphen-like
11177                '\u{2010}' | '\u{2013}' => false,  // hyphens
11178                c if is_small_kana(c) => false,
11179                _ => true,
11180            }
11181        }
11182    }
11183}
11184
11185/// Returns true if the character is a Japanese small kana or Katakana-Hiragana prolonged sound mark
11186/// (Unicode line break class CJ). These are forbidden break points in strict line breaking.
11187const fn is_small_kana(ch: char) -> bool {
11188    matches!(ch,
11189        '\u{3041}' | // ぁ HIRAGANA LETTER SMALL A
11190        '\u{3043}' | // ぃ HIRAGANA LETTER SMALL I
11191        '\u{3045}' | // ぅ HIRAGANA LETTER SMALL U
11192        '\u{3047}' | // ぇ HIRAGANA LETTER SMALL E
11193        '\u{3049}' | // ぉ HIRAGANA LETTER SMALL O
11194        '\u{3063}' | // っ HIRAGANA LETTER SMALL TU
11195        '\u{3083}' | // ゃ HIRAGANA LETTER SMALL YA
11196        '\u{3085}' | // ゅ HIRAGANA LETTER SMALL YU
11197        '\u{3087}' | // ょ HIRAGANA LETTER SMALL YO
11198        '\u{308E}' | // ゎ HIRAGANA LETTER SMALL WA
11199        '\u{3095}' | // ゕ HIRAGANA LETTER SMALL KA
11200        '\u{3096}' | // ゖ HIRAGANA LETTER SMALL KE
11201        '\u{30A1}' | // ァ KATAKANA LETTER SMALL A
11202        '\u{30A3}' | // ィ KATAKANA LETTER SMALL I
11203        '\u{30A5}' | // ゥ KATAKANA LETTER SMALL U
11204        '\u{30A7}' | // ェ KATAKANA LETTER SMALL E
11205        '\u{30A9}' | // ォ KATAKANA LETTER SMALL O
11206        '\u{30C3}' | // ッ KATAKANA LETTER SMALL TU
11207        '\u{30E3}' | // ャ KATAKANA LETTER SMALL YA
11208        '\u{30E5}' | // ュ KATAKANA LETTER SMALL YU
11209        '\u{30E7}' | // ョ KATAKANA LETTER SMALL YO
11210        '\u{30EE}' | // ヮ KATAKANA LETTER SMALL WA
11211        '\u{30F5}' | // ヵ KATAKANA LETTER SMALL KA
11212        '\u{30F6}' | // ヶ KATAKANA LETTER SMALL KE
11213        '\u{30FC}'   // ー KATAKANA-HIRAGANA PROLONGED SOUND MARK
11214    )
11215}
11216
11217// for every typographic character unit, disregarding GL/WJ/ZWJ line breaking classes
11218// replaced element or other atomic inline for web-compat
11219fn is_break_opportunity(item: &ShapedItem) -> bool {
11220    // Per CSS Text 3 §5.1: "there is a soft wrap opportunity before and
11221    // after each replaced element or other atomic inline"
11222    if matches!(item, ShapedItem::Object { .. } | ShapedItem::CombinedBlock { .. }) {
11223        return true;
11224    }
11225    // over atomic inline rules: break-forcing controls (ZWSP, LS, PS) create break opportunities
11226    // even adjacent to atomic inlines, while break-suppressing controls (WJ, ZWJ, ZWNBSP)
11227    // prevent breaks
11228    if let ShapedItem::Cluster(c) = item {
11229        // ZW (zero-width space U+200B) is always a break opportunity
11230        if c.text.contains('\u{200B}') {
11231            return true;
11232        }
11233        // Break-forcing Unicode controls (LS, PS) create break opportunities
11234        if c.text.chars().any(is_break_forcing_control) {
11235            return true;
11236        }
11237        // WJ (word joiner U+2060), ZWJ (U+200D), and GL (NBSP U+00A0) suppress breaks
11238        if c.text.chars().any(|ch| matches!(ch, '\u{2060}' | '\u{200D}' | '\u{00A0}')) {
11239            return false;
11240        }
11241        // +spec:line-breaking:05e09a - U+002D/U+2010 always create soft wrap opportunities regardless of hyphens property
11242        // are always visible and create a soft wrap opportunity after them, but are NOT
11243        // hyphenation opportunities (no extra glyph is inserted at the break).
11244        if c.text.ends_with('\u{002D}') || c.text.ends_with('\u{2010}') {
11245            return true;
11246        }
11247    }
11248    is_break_opportunity_with_word_break(item, WordBreak::Normal, Hyphens::Manual)
11249}
11250
11251// A cursor to manage the state of the line breaking process.
11252// This allows us to handle items that are partially consumed by hyphenation.
11253// `Clone` is used to take a cheap snapshot for the multi-column balancing dry run
11254// (measuring total line count without consuming the real cursor).
11255#[derive(Debug, Clone)]
11256pub struct BreakCursor<'a> {
11257    /// A reference to the complete list of shaped items.
11258    pub items: &'a [ShapedItem],
11259    /// The index of the next *full* item to be processed from the `items` slice.
11260    pub next_item_index: usize,
11261    /// The remainder of an item that was split by hyphenation on the previous line.
11262    /// This will be the very first piece of content considered for the next line.
11263    pub partial_remainder: Vec<ShapedItem>,
11264    // §5.2 word-break property stored on cursor
11265    pub word_break: WordBreak,
11266    pub hyphens: Hyphens,
11267    pub line_break: LineBreakStrictness,
11268}
11269
11270impl<'a> BreakCursor<'a> {
11271    #[must_use] pub fn new(items: &'a [ShapedItem]) -> Self {
11272        Self {
11273            items,
11274            next_item_index: 0,
11275            partial_remainder: Vec::new(),
11276            word_break: WordBreak::Normal,
11277            hyphens: Hyphens::default(),
11278            line_break: LineBreakStrictness::default(),
11279        }
11280    }
11281
11282    #[must_use] pub fn with_word_break(items: &'a [ShapedItem], word_break: WordBreak) -> Self {
11283        Self {
11284            items,
11285            next_item_index: 0,
11286            partial_remainder: Vec::new(),
11287            word_break,
11288            hyphens: Hyphens::default(),
11289            line_break: LineBreakStrictness::default(),
11290        }
11291    }
11292
11293    /// Checks if the cursor is at the very beginning of the content stream.
11294    #[must_use] pub const fn is_at_start(&self) -> bool {
11295        self.next_item_index == 0 && self.partial_remainder.is_empty()
11296    }
11297
11298    /// Consumes the cursor and returns all remaining items as a `Vec`.
11299    pub fn drain_remaining(&mut self) -> Vec<ShapedItem> {
11300        let mut remaining = std::mem::take(&mut self.partial_remainder);
11301        if self.next_item_index < self.items.len() {
11302            remaining.extend_from_slice(&self.items[self.next_item_index..]);
11303        }
11304        self.next_item_index = self.items.len();
11305        remaining
11306    }
11307
11308    /// Checks if all content, including any partial remainders, has been processed.
11309    #[must_use] pub const fn is_done(&self) -> bool {
11310        self.next_item_index >= self.items.len() && self.partial_remainder.is_empty()
11311    }
11312
11313    /// Consumes a number of items from the cursor's stream.
11314    pub fn consume(&mut self, count: usize) {
11315        if count == 0 {
11316            return;
11317        }
11318
11319        let remainder_len = self.partial_remainder.len();
11320        if count <= remainder_len {
11321            // Consuming only from the remainder.
11322            self.partial_remainder.drain(..count);
11323        } else {
11324            // Consuming all of the remainder and some from the main list.
11325            let from_main_list = count - remainder_len;
11326            self.partial_remainder.clear();
11327            self.next_item_index += from_main_list;
11328        }
11329    }
11330
11331    /// Looks ahead and returns the next "unbreakable" unit of content.
11332    /// This is typically a word (a series of non-space clusters) followed by a
11333    /// space, or just a single space if that's next.
11334    /// The definition of "unbreakable unit" depends on the word-break property.
11335    // a single typographic character unit (every character is a soft wrap opportunity), including
11336    // punctuation and preserved white spaces; currently handled via peek_next_single_item
11337    pub fn peek_next_unit(&self) -> Vec<ShapedItem> {
11338        let mut unit = Vec::new();
11339        let mut source_items = self.partial_remainder.clone();
11340        source_items.extend_from_slice(&self.items[self.next_item_index..]);
11341
11342        if source_items.is_empty() {
11343            return unit;
11344        }
11345
11346        // If the first item is a break opportunity (like a space), it's a unit on its own.
11347        if is_break_opportunity_with_word_break(&source_items[0], self.word_break, self.hyphens) {
11348            unit.push(source_items[0].clone());
11349            return unit;
11350        }
11351
11352        // Otherwise, collect all items until the next break opportunity.
11353        // For break-all: each cluster is its own unit.
11354        // For keep-all: CJK sequences are NOT break opportunities.
11355        // For normal: CJK characters are individual break opportunities.
11356        // glue items together: if the last cluster ends with a break-suppressing control,
11357        // the next item cannot be separated from it.
11358        let mut suppress_next_break = false;
11359        for (i, item) in source_items.iter().enumerate() {
11360            // Also suppress break if this item starts with a break-suppressing control
11361            // (WJ/ZWJ/ZWNBSP suppress breaks on both sides per Unicode line breaking)
11362            let starts_with_suppress = if let ShapedItem::Cluster(c) = item {
11363                c.text.chars().next().is_some_and(is_break_suppressing_control)
11364            } else {
11365                false
11366            };
11367            // If the item is a CJK cluster, check if the break is allowed by strictness
11368            let cjk_strictness_suppressed = if let ShapedItem::Cluster(c) = item {
11369                c.text.chars().next().is_some_and(|ch| {
11370                    !is_cjk_break_allowed_by_strictness(ch, None, self.line_break)
11371                })
11372            } else {
11373                false
11374            };
11375            if i > 0 && !suppress_next_break && !starts_with_suppress && !cjk_strictness_suppressed && is_break_opportunity_with_word_break(item, self.word_break, self.hyphens) {
11376                break;
11377            }
11378            suppress_next_break = false;
11379            unit.push(item.clone());
11380
11381            // Check if this item ends with a break-suppressing control character
11382            if let ShapedItem::Cluster(c) = item {
11383                if let Some(last_ch) = c.text.chars().last() {
11384                    if is_break_suppressing_control(last_ch) {
11385                        suppress_next_break = true;
11386                    }
11387                }
11388            }
11389
11390            // For break-all, each non-space cluster is a unit on its own
11391            if self.word_break == WordBreak::BreakAll {
11392                if let ShapedItem::Cluster(_) = item {
11393                    break;
11394                }
11395            }
11396        }
11397        unit
11398    }
11399
11400    #[must_use] pub fn peek_next_single_item(&self) -> Vec<ShapedItem> {
11401        if !self.partial_remainder.is_empty() {
11402            return vec![self.partial_remainder[0].clone()];
11403        }
11404        if self.next_item_index < self.items.len() {
11405            return vec![self.items[self.next_item_index].clone()];
11406        }
11407        Vec::new()
11408    }
11409}
11410
11411// A structured result from a hyphenation attempt.
11412struct HyphenationResult {
11413    /// The items that fit on the current line, including the new hyphen.
11414    line_part: Vec<ShapedItem>,
11415    /// The remainder of the split item to be carried over to the next line.
11416    remainder_part: Vec<ShapedItem>,
11417}
11418
11419fn perform_bidi_analysis<'a>(
11420    styled_runs: &'a [TextRunInfo<'_>],
11421    full_text: &'a str,
11422    force_lang: Option<Language>,
11423) -> (Vec<VisualRun<'a>>, BidiDirection) {
11424    if full_text.is_empty() {
11425        return (Vec::new(), BidiDirection::Ltr);
11426    }
11427
11428    let bidi_info = BidiInfo::new(full_text, None);
11429    let para = &bidi_info.paragraphs[0];
11430    let base_direction = if para.level.is_rtl() {
11431        BidiDirection::Rtl
11432    } else {
11433        BidiDirection::Ltr
11434    };
11435
11436    // Create a map from each byte index to its original styled run.
11437    let mut byte_to_run_index: Vec<usize> = vec![0; full_text.len()];
11438    for (run_idx, run) in styled_runs.iter().enumerate() {
11439        let start = run.logical_start;
11440        let end = start + run.text.len();
11441        for slot in &mut byte_to_run_index[start..end] {
11442            *slot = run_idx;
11443        }
11444    }
11445
11446    let mut final_visual_runs = Vec::new();
11447    let (levels, visual_run_ranges) = bidi_info.visual_runs(para, para.range.clone());
11448
11449    for range in visual_run_ranges {
11450        let bidi_level = levels[range.start];
11451        let mut sub_run_start = range.start;
11452
11453        // Iterate through the bytes of the visual run to detect style changes.
11454        for i in (range.start + 1)..range.end {
11455            if byte_to_run_index[i] != byte_to_run_index[sub_run_start] {
11456                // Style boundary found. Finalize the previous sub-run.
11457                let original_run_idx = byte_to_run_index[sub_run_start];
11458                let script = crate::text3::script::detect_script(&full_text[sub_run_start..i])
11459                    .unwrap_or(Script::Latin);
11460                final_visual_runs.push(VisualRun {
11461                    text_slice: &full_text[sub_run_start..i],
11462                    style: styled_runs[original_run_idx].style.clone(),
11463                    logical_start_byte: sub_run_start,
11464                    bidi_level: BidiLevel::new(bidi_level.number()),
11465                    language: force_lang.unwrap_or_else(|| {
11466                        script_to_language(
11467                            script,
11468                            &full_text[sub_run_start..i],
11469                        )
11470                    }),
11471                    script,
11472                });
11473                // Start a new sub-run.
11474                sub_run_start = i;
11475            }
11476        }
11477
11478        // Add the last sub-run (or the only one if no style change occurred).
11479        let original_run_idx = byte_to_run_index[sub_run_start];
11480        let script = crate::text3::script::detect_script(&full_text[sub_run_start..range.end])
11481            .unwrap_or(Script::Latin);
11482
11483        final_visual_runs.push(VisualRun {
11484            text_slice: &full_text[sub_run_start..range.end],
11485            style: styled_runs[original_run_idx].style.clone(),
11486            logical_start_byte: sub_run_start,
11487            bidi_level: BidiLevel::new(bidi_level.number()),
11488            script,
11489            language: force_lang.unwrap_or_else(|| {
11490                script_to_language(
11491                    script,
11492                    &full_text[sub_run_start..range.end],
11493                )
11494            }),
11495        });
11496    }
11497
11498    (final_visual_runs, base_direction)
11499}
11500
11501const fn get_justification_priority(class: CharacterClass) -> u8 {
11502    match class {
11503        CharacterClass::Space => 0,
11504        CharacterClass::Punctuation => 64,
11505        CharacterClass::Ideograph => 128,
11506        CharacterClass::Letter => 192,
11507        CharacterClass::Symbol => 224,
11508        CharacterClass::Combining => 255,
11509    }
11510}
11511
11512#[cfg(test)]
11513mod shape_outside_and_ruby_tests {
11514    use super::*;
11515    use azul_css::shape::{CssShape, ShapePath};
11516
11517    fn path_shape(d: &str) -> CssShape {
11518        CssShape::Path(ShapePath {
11519            data: d.into(),
11520        })
11521    }
11522
11523    // --- shape-outside: path() ----------------------------------------------
11524
11525    #[test]
11526    fn css_path_shape_builds_path_boundary_not_rect_fallback() {
11527        // A right triangle (0,0)-(100,0)-(0,100).
11528        let shape = path_shape("M 0 0 L 100 0 L 0 100 Z");
11529        let rbox = Rect { x: 0.0, y: 0.0, width: 100.0, height: 100.0 };
11530        let boundary = ShapeBoundary::from_css_shape(&shape, rbox, &mut None);
11531        match boundary {
11532            ShapeBoundary::Path { segments } => {
11533                assert!(!segments.is_empty(), "path() must flatten to real segments");
11534                assert!(matches!(segments[0], PathSegment::MoveTo(_)));
11535                assert!(segments.iter().any(|s| matches!(s, PathSegment::Close)));
11536            }
11537            other => panic!("expected ShapeBoundary::Path, got {other:?}"),
11538        }
11539    }
11540
11541    #[test]
11542    fn empty_or_garbage_path_falls_back_to_rectangle() {
11543        let rbox = Rect { x: 0.0, y: 0.0, width: 50.0, height: 50.0 };
11544        let boundary = ShapeBoundary::from_css_shape(&path_shape("   "), rbox, &mut None);
11545        assert!(matches!(boundary, ShapeBoundary::Rectangle(_)),
11546            "unparseable path() should fall back to the reference rectangle");
11547    }
11548
11549    #[test]
11550    fn path_triangle_narrows_line_box_per_scanline() {
11551        // Right triangle with the hypotenuse running (100,0) -> (0,100).
11552        // At scanline y, the shape spans x in [0, 100 - y]. So the available band
11553        // must NARROW as y increases — the proof that real path geometry (not a
11554        // full-width rect) drives the per-line exclusion.
11555        let shape = path_shape("M 0 0 L 100 0 L 0 100 Z");
11556        let rbox = Rect { x: 0.0, y: 0.0, width: 100.0, height: 100.0 };
11557        let boundary = ShapeBoundary::from_css_shape(&shape, rbox, &mut None);
11558
11559        let spans_top = get_shape_horizontal_spans(&boundary, 10.0, 1.0);
11560        let spans_bot = get_shape_horizontal_spans(&boundary, 80.0, 1.0);
11561
11562        assert_eq!(spans_top.len(), 1, "single span expected near the top");
11563        assert_eq!(spans_bot.len(), 1, "single span expected near the bottom");
11564
11565        let width_top = spans_top[0].1 - spans_top[0].0;
11566        let width_bot = spans_bot[0].1 - spans_bot[0].0;
11567
11568        // Geometry check: width ~= 100 - y (line center is y + 0.5).
11569        assert!((width_top - 89.5).abs() < 1.5, "top width {width_top} != ~89.5");
11570        assert!((width_bot - 19.5).abs() < 1.5, "bottom width {width_bot} != ~19.5");
11571        assert!(width_top > width_bot,
11572            "path() exclusion band must narrow with y ({width_top} !> {width_bot})");
11573
11574        // And it must differ from a plain full-width rectangle (which would be 0..100
11575        // at every scanline) — i.e. this is not the old rect/empty stub.
11576        assert!(width_bot < 50.0, "rect fallback would give full width here");
11577    }
11578
11579    #[test]
11580    fn path_with_hole_carves_out_interior_via_even_odd() {
11581        // Outer square 0..100 with an inner reversed square 30..70 (a hole). At a
11582        // scanline through the hole, even-odd fill yields two spans straddling the hole.
11583        let shape = path_shape(
11584            "M 0 0 L 100 0 L 100 100 L 0 100 Z \
11585             M 30 30 L 30 70 L 70 70 L 70 30 Z",
11586        );
11587        let rbox = Rect { x: 0.0, y: 0.0, width: 100.0, height: 100.0 };
11588        let boundary = ShapeBoundary::from_css_shape(&shape, rbox, &mut None);
11589        let spans = get_shape_horizontal_spans(&boundary, 50.0, 1.0);
11590        assert_eq!(spans.len(), 2, "hole should split the band into two spans: {spans:?}");
11591    }
11592
11593    // --- ruby ----------------------------------------------------------------
11594
11595    #[test]
11596    #[allow(clippy::float_cmp)] // exact, representable expected values
11597    fn ruby_annotation_font_scale_is_real_not_06_fudge() {
11598        // The annotation is sized at the used font-size of the ruby-text run, which the
11599        // UA stylesheet sets to 50% of the base — NOT a 0.6 per-character fudge.
11600        let base_font_size = 20.0_f32;
11601        let annotation_font_size = base_font_size * RUBY_ANNOTATION_FONT_SCALE;
11602        assert_eq!(annotation_font_size, 10.0);
11603        assert!((RUBY_ANNOTATION_FONT_SCALE - 0.6).abs() > f32::EPSILON,
11604            "annotation scale must not be the old 0.6 magic ratio");
11605    }
11606
11607    #[test]
11608    #[allow(clippy::float_cmp)] // exact, representable expected values
11609    fn ruby_box_reserves_max_width_and_stacks_annotation_above_base() {
11610        // Wider base, narrower annotation: reserved inline-size = base width.
11611        let (w, h) = ruby_reserved_box(80.0, 30.0, 24.0, 12.0);
11612        assert_eq!(w, 80.0, "reserved width is the wider of base/annotation");
11613        // Block-size stacks the annotation line above the base line => base reserves
11614        // vertical space for the annotation.
11615        assert_eq!(h, 36.0, "block-size = base line + annotation line");
11616        assert!(h > 24.0, "ruby box must reserve extra vertical space for the annotation");
11617
11618        // Narrower base, wider annotation: reserved inline-size = annotation width.
11619        let (w2, _) = ruby_reserved_box(20.0, 50.0, 24.0, 12.0);
11620        assert_eq!(w2, 50.0, "a long annotation widens the reserved box");
11621    }
11622}
11623
11624/// Adversarial unit tests generated for `layout/src/text3/cache.rs`.
11625///
11626/// These probe the boundaries the production code never sees: NaN / ±inf floats,
11627/// `u16::MAX` units-per-em, empty slices, `usize::MAX` counts, degenerate geometry
11628/// and sentinel-value round trips. Where a function has a surprising-but-real
11629/// behaviour (e.g. `round_eq(NaN, 0.0) == true`), the test PINS that behaviour and
11630/// says so, rather than pretending it is safe.
11631#[cfg(test)]
11632#[allow(
11633    clippy::float_cmp,
11634    clippy::too_many_lines,
11635    clippy::unreadable_literal,
11636    clippy::cast_precision_loss,
11637    clippy::similar_names
11638)]
11639mod autotest_generated {
11640    use super::*;
11641
11642    // ---------------------------------------------------------------------
11643    // Fixtures
11644    // ---------------------------------------------------------------------
11645
11646    fn metrics(upem: u16, ascent: f32, descent: f32, line_gap: f32) -> LayoutFontMetrics {
11647        LayoutFontMetrics {
11648            ascent,
11649            descent,
11650            line_gap,
11651            units_per_em: upem,
11652            x_height: None,
11653            cap_height: None,
11654        }
11655    }
11656
11657    /// 1000 upem, 800 asc, -200 desc, 0 gap → `line-height: normal` == 1.0em.
11658    fn std_metrics() -> LayoutFontMetrics {
11659        metrics(1000, 800.0, -200.0, 0.0)
11660    }
11661
11662    fn style() -> Arc<StyleProperties> {
11663        Arc::new(StyleProperties::default())
11664    }
11665
11666    fn styled(f: impl FnOnce(&mut StyleProperties)) -> Arc<StyleProperties> {
11667        let mut s = StyleProperties::default();
11668        f(&mut s);
11669        Arc::new(s)
11670    }
11671
11672    const fn ci(run: u32, item: u32) -> ContentIndex {
11673        ContentIndex {
11674            run_index: run,
11675            item_index: item,
11676        }
11677    }
11678
11679    const fn gid(run: u32, byte: u32) -> GraphemeClusterId {
11680        GraphemeClusterId {
11681            source_run: run,
11682            start_byte_in_run: byte,
11683        }
11684    }
11685
11686    fn shaped_glyph(st: Arc<StyleProperties>, fm: LayoutFontMetrics, advance: f32) -> ShapedGlyph {
11687        ShapedGlyph {
11688            kind: GlyphKind::Character,
11689            glyph_id: 42,
11690            cluster_offset: 0,
11691            advance,
11692            kerning: 0.0,
11693            offset: Point { x: 0.0, y: 0.0 },
11694            vertical_advance: advance,
11695            vertical_offset: Point { x: 0.0, y: 0.0 },
11696            script: Script::Latin,
11697            style: st,
11698            font_hash: 0xABCD_u64,
11699            font_metrics: fm,
11700        }
11701    }
11702
11703    fn make_cluster(
11704        text: &str,
11705        advance: f32,
11706        st: Arc<StyleProperties>,
11707        glyphs: ShapedGlyphVec,
11708        id: GraphemeClusterId,
11709    ) -> ShapedItem {
11710        ShapedItem::Cluster(ShapedCluster {
11711            text: text.to_string(),
11712            source_cluster_id: id,
11713            source_content_index: ci(id.source_run, id.start_byte_in_run),
11714            source_node_id: None,
11715            glyphs,
11716            advance,
11717            direction: BidiDirection::Ltr,
11718            style: st,
11719            marker_position_outside: None,
11720            is_first_fragment: true,
11721            is_last_fragment: true,
11722        })
11723    }
11724
11725    /// Single-glyph cluster with the standard 1000-upem metrics.
11726    fn cl(text: &str, advance: f32) -> ShapedItem {
11727        let st = style();
11728        let g = shaped_glyph(st.clone(), std_metrics(), advance);
11729        make_cluster(text, advance, st, smallvec![g], gid(0, 0))
11730    }
11731
11732    /// Single-glyph cluster with an explicit grapheme id (for caret tests).
11733    fn cl_at(text: &str, advance: f32, run: u32, byte: u32) -> ShapedItem {
11734        let st = style();
11735        let g = shaped_glyph(st.clone(), std_metrics(), advance);
11736        make_cluster(text, advance, st, smallvec![g], gid(run, byte))
11737    }
11738
11739    /// Cluster carrying an explicit style (letter/word-spacing tests).
11740    fn cl_styled(text: &str, advance: f32, st: Arc<StyleProperties>) -> ShapedItem {
11741        let g = shaped_glyph(st.clone(), std_metrics(), advance);
11742        make_cluster(text, advance, st, smallvec![g], gid(0, 0))
11743    }
11744
11745    /// Cluster with NO glyphs — the CSS "strut" case.
11746    fn cl_no_glyphs(text: &str, advance: f32) -> ShapedItem {
11747        make_cluster(text, advance, style(), ShapedGlyphVec::new(), gid(0, 0))
11748    }
11749
11750    fn obj(width: f32, height: f32, baseline_offset: f32) -> ShapedItem {
11751        ShapedItem::Object {
11752            source: ci(0, 0),
11753            bounds: Rect {
11754                x: 0.0,
11755                y: 0.0,
11756                width,
11757                height,
11758            },
11759            baseline_offset,
11760            content: InlineContent::Space(InlineSpace {
11761                width,
11762                is_breaking: false,
11763                is_stretchy: false,
11764            }),
11765        }
11766    }
11767
11768    fn brk() -> ShapedItem {
11769        ShapedItem::Break {
11770            source: ci(0, 0),
11771            break_info: InlineBreak {
11772                break_type: BreakType::Hard,
11773                clear: ClearType::None,
11774                content_index: 0,
11775            },
11776        }
11777    }
11778
11779    fn tab(width: f32, height: f32) -> ShapedItem {
11780        ShapedItem::Tab {
11781            source: ci(0, 0),
11782            bounds: Rect {
11783                x: 0.0,
11784                y: 0.0,
11785                width,
11786                height,
11787            },
11788        }
11789    }
11790
11791    fn pos(item: ShapedItem, x: f32, y: f32, line_index: usize) -> PositionedItem {
11792        PositionedItem {
11793            item,
11794            position: Point { x, y },
11795            line_index,
11796        }
11797    }
11798
11799    fn text_content(t: &str, st: Arc<StyleProperties>) -> InlineContent {
11800        InlineContent::Text(StyledRun {
11801            text: t.to_string(),
11802            style: st,
11803            logical_start_byte: 0,
11804            source_node_id: None,
11805        })
11806    }
11807
11808    fn sel(family: &str) -> FontSelector {
11809        FontSelector {
11810            family: family.to_string(),
11811            ..FontSelector::default()
11812        }
11813    }
11814
11815    /// A minimal in-memory `ParsedFontTrait` so `LoadedFonts` / `FontManager`
11816    /// can be exercised without touching the filesystem or fontconfig.
11817    #[derive(Debug, Clone)]
11818    struct TestFont {
11819        hash: u64,
11820    }
11821
11822    impl ShallowClone for TestFont {
11823        fn shallow_clone(&self) -> Self {
11824            self.clone()
11825        }
11826    }
11827
11828    impl ParsedFontTrait for TestFont {
11829        fn shape_text(
11830            &self,
11831            _text: &str,
11832            _script: Script,
11833            _language: Language,
11834            _direction: BidiDirection,
11835            _style: &StyleProperties,
11836        ) -> Result<Vec<Glyph>, LayoutError> {
11837            Ok(Vec::new())
11838        }
11839        fn get_hash(&self) -> u64 {
11840            self.hash
11841        }
11842        fn get_glyph_size(&self, _glyph_id: u16, font_size: f32) -> Option<LogicalSize> {
11843            Some(LogicalSize {
11844                width: font_size,
11845                height: font_size,
11846            })
11847        }
11848        fn get_hyphen_glyph_and_advance(&self, font_size: f32) -> Option<(u16, f32)> {
11849            Some((1, font_size * 0.3))
11850        }
11851        fn get_kashida_glyph_and_advance(&self, font_size: f32) -> Option<(u16, f32)> {
11852            Some((2, font_size * 0.2))
11853        }
11854        fn has_glyph(&self, _codepoint: u32) -> bool {
11855            true
11856        }
11857        fn get_vertical_metrics(&self, _glyph_id: u16) -> Option<VerticalMetrics> {
11858            None
11859        }
11860        fn get_font_metrics(&self) -> LayoutFontMetrics {
11861            std_metrics()
11862        }
11863        fn num_glyphs(&self) -> u16 {
11864            10
11865        }
11866        fn get_space_width(&self) -> Option<usize> {
11867            Some(500)
11868        }
11869    }
11870
11871    fn hash_of<T: Hash>(v: &T) -> u64 {
11872        let mut h = DefaultHasher::new();
11873        v.hash(&mut h);
11874        h.finish()
11875    }
11876
11877    /// Compare two derived f32s. Used wherever the expected value comes out of a
11878    /// divide-then-multiply chain, whose last-ulp rounding is not worth pinning.
11879    #[track_caller]
11880    fn approx(actual: f32, expected: f32) {
11881        assert!(
11882            (actual - expected).abs() < 1e-4,
11883            "expected ~{expected}, got {actual}"
11884        );
11885    }
11886
11887    // =====================================================================
11888    // numeric: ruby_reserved_box
11889    // =====================================================================
11890
11891    #[test]
11892    fn ruby_reserved_box_zero_and_negative_are_deterministic() {
11893        assert_eq!(ruby_reserved_box(0.0, 0.0, 0.0, 0.0), (0.0, 0.0));
11894        // max() of two negatives is the one closer to zero; the block-size sums.
11895        let (w, h) = ruby_reserved_box(-10.0, -4.0, -3.0, -2.0);
11896        assert_eq!(w, -4.0);
11897        assert_eq!(h, -5.0);
11898    }
11899
11900    #[test]
11901    fn ruby_reserved_box_nan_width_is_ignored_by_max_but_poisons_height() {
11902        // f32::max propagates the NON-NaN operand, so a NaN advance silently
11903        // yields the other run's width instead of NaN.
11904        let (w, h) = ruby_reserved_box(f32::NAN, 30.0, 24.0, f32::NAN);
11905        assert_eq!(w, 30.0, "f32::max discards the NaN operand");
11906        assert!(h.is_nan(), "but the additive block-size does propagate NaN");
11907    }
11908
11909    #[test]
11910    fn ruby_reserved_box_infinities_do_not_panic() {
11911        let (w, h) = ruby_reserved_box(f32::INFINITY, 10.0, f32::INFINITY, f32::NEG_INFINITY);
11912        assert!(w.is_infinite() && w.is_sign_positive());
11913        assert!(h.is_nan(), "inf + -inf is NaN, not a panic");
11914    }
11915
11916    #[test]
11917    fn ruby_reserved_box_saturates_to_infinity_at_f32_max() {
11918        let (w, h) = ruby_reserved_box(f32::MAX, f32::MAX, f32::MAX, f32::MAX);
11919        assert_eq!(w, f32::MAX);
11920        assert!(h.is_infinite(), "f32 addition saturates, it does not panic");
11921    }
11922
11923    // =====================================================================
11924    // numeric: LineHeight::resolve / resolve_with_metrics
11925    // =====================================================================
11926
11927    #[test]
11928    fn line_height_px_ignores_every_font_metric() {
11929        let lh = LineHeight::Px(7.5);
11930        assert_eq!(lh.resolve(16.0, 800.0, -200.0, 0.0, 1000), 7.5);
11931        // Even garbage metrics cannot perturb an explicit px value.
11932        assert_eq!(lh.resolve(f32::NAN, f32::NAN, f32::NAN, f32::NAN, 0), 7.5);
11933    }
11934
11935    #[test]
11936    fn line_height_normal_zero_upem_falls_back_to_1_2_em() {
11937        let lh = LineHeight::Normal;
11938        assert_eq!(lh.resolve(16.0, 800.0, -200.0, 0.0, 0), 19.2);
11939        assert_eq!(lh.resolve(0.0, 800.0, -200.0, 0.0, 0), 0.0);
11940    }
11941
11942    #[test]
11943    fn line_height_normal_scales_ascent_minus_descent_plus_gap() {
11944        // (800 - (-200) + 0) / 1000 * 16 == 16.0
11945        approx(LineHeight::Normal.resolve(16.0, 800.0, -200.0, 0.0, 1000), 16.0);
11946        // line_gap widens the line box.
11947        approx(
11948            LineHeight::Normal.resolve(16.0, 800.0, -200.0, 250.0, 1000),
11949            20.0,
11950        );
11951        // A descent given with the WRONG (positive) sign shrinks the line box —
11952        // the formula subtracts it unconditionally.
11953        approx(LineHeight::Normal.resolve(16.0, 800.0, 200.0, 0.0, 1000), 9.6);
11954    }
11955
11956    #[test]
11957    fn line_height_normal_at_u16_max_upem_does_not_panic() {
11958        let v = LineHeight::Normal.resolve(16.0, 800.0, -200.0, 0.0, u16::MAX);
11959        assert!(v.is_finite() && v > 0.0, "got {v}");
11960        assert!(v < 1.0, "a 65535-upem font must produce a tiny scale, got {v}");
11961    }
11962
11963    #[test]
11964    fn line_height_normal_nan_and_inf_inputs_are_defined_not_panics() {
11965        assert!(LineHeight::Normal
11966            .resolve(f32::NAN, 800.0, -200.0, 0.0, 1000)
11967            .is_nan());
11968        assert!(LineHeight::Normal
11969            .resolve(f32::INFINITY, 800.0, -200.0, 0.0, 1000)
11970            .is_infinite());
11971        // ascent == descent == inf → inf - inf == NaN
11972        assert!(LineHeight::Normal
11973            .resolve(16.0, f32::INFINITY, f32::INFINITY, 0.0, 1000)
11974            .is_nan());
11975    }
11976
11977    #[test]
11978    fn line_height_resolve_with_metrics_matches_resolve() {
11979        let fm = metrics(2048, 1600.0, -400.0, 100.0);
11980        let lh = LineHeight::Normal;
11981        assert_eq!(
11982            lh.resolve_with_metrics(24.0, &fm),
11983            lh.resolve(24.0, fm.ascent, fm.descent, fm.line_gap, fm.units_per_em)
11984        );
11985        // Px path is metric-independent.
11986        assert_eq!(LineHeight::Px(3.0).resolve_with_metrics(24.0, &fm), 3.0);
11987    }
11988
11989    #[test]
11990    fn line_height_px_nan_is_self_equal_under_the_manual_partialeq() {
11991        // The manual PartialEq compares raw bits, so Px(NaN) == Px(NaN) even
11992        // though NaN != NaN — required for Hash/Eq consistency of the cache key.
11993        assert_eq!(LineHeight::Px(f32::NAN), LineHeight::Px(f32::NAN));
11994        assert_eq!(
11995            hash_of(&LineHeight::Px(f32::NAN)),
11996            hash_of(&LineHeight::Px(f32::NAN))
11997        );
11998        assert_ne!(LineHeight::Normal, LineHeight::Px(0.0));
11999    }
12000
12001    // =====================================================================
12002    // AvailableSpace (predicate / numeric / constructor)
12003    // =====================================================================
12004
12005    #[test]
12006    fn available_space_definite_and_indefinite_are_exact_complements() {
12007        for v in [
12008            AvailableSpace::Definite(0.0),
12009            AvailableSpace::Definite(-1.0),
12010            AvailableSpace::Definite(f32::NAN),
12011            AvailableSpace::MinContent,
12012            AvailableSpace::MaxContent,
12013        ] {
12014            assert_ne!(v.is_definite(), v.is_indefinite(), "{v:?}");
12015        }
12016        assert!(AvailableSpace::Definite(f32::NAN).is_definite());
12017        assert!(AvailableSpace::default().is_indefinite());
12018        assert_eq!(AvailableSpace::default(), AvailableSpace::MaxContent);
12019    }
12020
12021    #[test]
12022    fn available_space_unwrap_or_returns_definite_even_when_nan_or_inf() {
12023        assert_eq!(AvailableSpace::Definite(0.0).unwrap_or(99.0), 0.0);
12024        assert_eq!(AvailableSpace::Definite(-5.0).unwrap_or(99.0), -5.0);
12025        assert!(AvailableSpace::Definite(f32::NAN).unwrap_or(99.0).is_nan());
12026        assert!(AvailableSpace::Definite(f32::INFINITY)
12027            .unwrap_or(99.0)
12028            .is_infinite());
12029        // Indefinite variants hand back the fallback verbatim, NaN included.
12030        assert_eq!(AvailableSpace::MinContent.unwrap_or(99.0), 99.0);
12031        assert_eq!(AvailableSpace::MaxContent.unwrap_or(-0.0), -0.0);
12032        assert!(AvailableSpace::MaxContent.unwrap_or(f32::NAN).is_nan());
12033    }
12034
12035    #[test]
12036    fn available_space_to_f32_for_layout_uses_half_max_for_both_intrinsic_modes() {
12037        assert_eq!(AvailableSpace::MinContent.to_f32_for_layout(), f32::MAX / 2.0);
12038        assert_eq!(AvailableSpace::MaxContent.to_f32_for_layout(), f32::MAX / 2.0);
12039        assert_eq!(AvailableSpace::Definite(12.5).to_f32_for_layout(), 12.5);
12040        assert!(AvailableSpace::Definite(f32::NAN)
12041            .to_f32_for_layout()
12042            .is_nan());
12043    }
12044
12045    #[test]
12046    fn available_space_from_f32_sentinels() {
12047        assert_eq!(AvailableSpace::from_f32(f32::INFINITY), AvailableSpace::MaxContent);
12048        assert_eq!(AvailableSpace::from_f32(f32::MAX), AvailableSpace::MaxContent);
12049        // The documented cut-over point is exactly MAX/2 (inclusive).
12050        assert_eq!(
12051            AvailableSpace::from_f32(f32::MAX / 2.0),
12052            AvailableSpace::MaxContent
12053        );
12054        assert_eq!(AvailableSpace::from_f32(0.0), AvailableSpace::MinContent);
12055        assert_eq!(AvailableSpace::from_f32(-0.0), AvailableSpace::MinContent);
12056        assert_eq!(AvailableSpace::from_f32(-1.0), AvailableSpace::MinContent);
12057        assert_eq!(AvailableSpace::from_f32(100.0), AvailableSpace::Definite(100.0));
12058    }
12059
12060    #[test]
12061    fn available_space_from_f32_negative_infinity_becomes_max_content() {
12062        // QUIRK worth pinning: the `is_infinite()` guard runs FIRST, so -inf —
12063        // a nonsensical width — resolves to MaxContent ("no wrapping"), not the
12064        // MinContent that every other negative value maps to.
12065        assert_eq!(
12066            AvailableSpace::from_f32(f32::NEG_INFINITY),
12067            AvailableSpace::MaxContent
12068        );
12069    }
12070
12071    #[test]
12072    fn available_space_from_f32_nan_falls_through_to_definite_nan() {
12073        // NaN fails is_infinite(), fails `>= MAX/2`, and fails `<= 0.0`, so it
12074        // lands in the Definite arm and a NaN width is smuggled into layout.
12075        let got = AvailableSpace::from_f32(f32::NAN);
12076        match got {
12077            AvailableSpace::Definite(v) => assert!(v.is_nan(), "expected Definite(NaN)"),
12078            other => panic!("NaN should fall through to Definite, got {other:?}"),
12079        }
12080        // ...and Definite(NaN) is not even equal to itself under the derived PartialEq.
12081        assert_ne!(got, AvailableSpace::from_f32(f32::NAN));
12082    }
12083
12084    #[test]
12085    fn available_space_hash_eq_contract_holds_for_signed_zero() {
12086        // +0.0 == -0.0 under PartialEq, so their hashes MUST agree.
12087        assert_eq!(
12088            AvailableSpace::Definite(0.0),
12089            AvailableSpace::Definite(-0.0)
12090        );
12091        assert_eq!(
12092            hash_of(&AvailableSpace::Definite(0.0)),
12093            hash_of(&AvailableSpace::Definite(-0.0))
12094        );
12095        // Sub-pixel widths must NOT collide (they wrap lines differently).
12096        assert_ne!(
12097            hash_of(&AvailableSpace::Definite(100.1)),
12098            hash_of(&AvailableSpace::Definite(100.4))
12099        );
12100        assert_ne!(
12101            hash_of(&AvailableSpace::MinContent),
12102            hash_of(&AvailableSpace::MaxContent)
12103        );
12104    }
12105
12106    // =====================================================================
12107    // constructor: FontChainKey / FontChainKeyOrRef / FontStack / FontHash
12108    // =====================================================================
12109
12110    #[test]
12111    fn font_chain_key_from_empty_selectors_defaults_to_serif() {
12112        let k = FontChainKey::from_selectors(&[]);
12113        assert_eq!(k.font_families, vec!["serif".to_string()]);
12114        assert_eq!(k.weight, FcWeight::Normal);
12115        assert!(!k.italic && !k.oblique);
12116    }
12117
12118    #[test]
12119    fn font_chain_key_dedups_first_wins_and_skips_empty_families() {
12120        let stack = [sel("Arial"), sel("Times"), sel("Arial"), sel("")];
12121        let k = FontChainKey::from_selectors(&stack);
12122        assert_eq!(
12123            k.font_families,
12124            vec!["Arial".to_string(), "Times".to_string()],
12125            "duplicate families must collapse first-wins, empty names dropped"
12126        );
12127    }
12128
12129    #[test]
12130    fn font_chain_key_all_empty_families_still_yields_serif() {
12131        let stack = [sel(""), sel(""), sel("")];
12132        let k = FontChainKey::from_selectors(&stack);
12133        assert_eq!(k.font_families, vec!["serif".to_string()]);
12134    }
12135
12136    #[test]
12137    fn font_chain_key_weight_and_style_come_from_the_first_selector_even_if_it_is_dropped() {
12138        // QUIRK: the first selector's family is skipped (empty), but its weight
12139        // and italic flag still win — the key describes a family it does not list.
12140        let mut first = sel("");
12141        first.style = FontStyle::Italic;
12142        first.weight = FcWeight::Bold;
12143        let stack = [first, sel("Arial")];
12144        let k = FontChainKey::from_selectors(&stack);
12145        assert_eq!(k.font_families, vec!["Arial".to_string()]);
12146        assert_eq!(k.weight, FcWeight::Bold);
12147        assert!(k.italic, "italic taken from the dropped first selector");
12148        assert!(!k.oblique);
12149    }
12150
12151    #[test]
12152    fn font_chain_key_oblique_is_exclusive_of_italic() {
12153        let mut s = sel("Arial");
12154        s.style = FontStyle::Oblique;
12155        let k = FontChainKey::from_selectors(&[s]);
12156        assert!(k.oblique && !k.italic);
12157    }
12158
12159    #[test]
12160    fn font_chain_key_huge_duplicate_stack_does_not_hang() {
12161        let stack: Vec<FontSelector> = (0..5000).map(|_| sel("Arial")).collect();
12162        let k = FontChainKey::from_selectors(&stack);
12163        assert_eq!(k.font_families.len(), 1, "5000 dupes collapse to one entry");
12164    }
12165
12166    #[test]
12167    fn font_chain_key_is_a_stable_hash_map_key() {
12168        let a = FontChainKey::from_selectors(&[sel("Arial"), sel("Times")]);
12169        let b = FontChainKey::from_selectors(&[sel("Arial"), sel("Arial"), sel("Times")]);
12170        assert_eq!(a, b, "dedup makes the two stacks resolve to the same key");
12171        assert_eq!(hash_of(&a), hash_of(&b));
12172    }
12173
12174    #[test]
12175    fn font_chain_key_or_ref_from_stack_is_a_chain() {
12176        let fs = FontStack::Stack(vec![sel("Arial")]);
12177        let k = FontChainKeyOrRef::from_font_stack(&fs);
12178        assert!(!k.is_ref());
12179        assert_eq!(k.as_ref_ptr(), None);
12180        assert_eq!(
12181            k.as_chain().map(|c| c.font_families.clone()),
12182            Some(vec!["Arial".to_string()])
12183        );
12184    }
12185
12186    #[test]
12187    fn font_chain_key_or_ref_ref_variant_accessors_at_boundaries() {
12188        for ptr in [0_usize, 1, usize::MAX] {
12189            let k = FontChainKeyOrRef::Ref(ptr);
12190            assert!(k.is_ref());
12191            assert_eq!(k.as_ref_ptr(), Some(ptr));
12192            assert!(k.as_chain().is_none());
12193        }
12194        // A null-pointer Ref is still distinguishable from a Chain.
12195        assert_ne!(
12196            FontChainKeyOrRef::Ref(0),
12197            FontChainKeyOrRef::Chain(FontChainKey::from_selectors(&[]))
12198        );
12199    }
12200
12201    #[test]
12202    fn font_stack_default_is_a_single_serif_selector() {
12203        let fs = FontStack::default();
12204        assert!(!fs.is_ref());
12205        assert!(fs.as_ref().is_none());
12206        assert_eq!(fs.as_stack().map(<[FontSelector]>::len), Some(1));
12207        assert_eq!(fs.first_selector().map(|s| s.family.as_str()), Some("serif"));
12208        assert_eq!(fs.first_family(), "serif");
12209    }
12210
12211    #[test]
12212    fn font_stack_empty_stack_reports_serif_placeholder_but_no_first_selector() {
12213        let fs = FontStack::Stack(Vec::new());
12214        assert_eq!(fs.as_stack().map(<[FontSelector]>::len), Some(0));
12215        assert!(fs.first_selector().is_none());
12216        assert_eq!(
12217            fs.first_family(),
12218            "serif",
12219            "an EMPTY stack must not panic; it reports the serif fallback"
12220        );
12221    }
12222
12223    #[test]
12224    fn font_hash_invalid_is_zero_and_is_the_default() {
12225        assert_eq!(FontHash::invalid().font_hash, 0);
12226        assert_eq!(FontHash::default(), FontHash::invalid());
12227        assert_eq!(FontHash::from_hash(0), FontHash::invalid());
12228        assert_eq!(FontHash::from_hash(u64::MAX).font_hash, u64::MAX);
12229        assert_ne!(FontHash::from_hash(u64::MAX), FontHash::invalid());
12230    }
12231
12232    // =====================================================================
12233    // numeric/getter: LayoutFontMetrics
12234    // =====================================================================
12235
12236    #[test]
12237    fn layout_font_metrics_baseline_scaled_typical_and_zero_font_size() {
12238        let fm = std_metrics();
12239        approx(fm.baseline_scaled(16.0), 12.8); // 800/1000 * 16
12240        assert_eq!(fm.baseline_scaled(0.0), 0.0);
12241        approx(fm.baseline_scaled(-16.0), -12.8);
12242    }
12243
12244    #[test]
12245    fn layout_font_metrics_zero_upem_divides_by_zero_instead_of_guarding() {
12246        // NOTE: `LineHeight::resolve` explicitly guards `units_per_em == 0`, but the
12247        // *_scaled helpers do not — they divide by zero. Pin the actual behaviour so
12248        // a future guard shows up as a deliberate change rather than a silent one.
12249        let fm = metrics(0, 800.0, -200.0, 0.0);
12250        assert!(fm.baseline_scaled(16.0).is_infinite());
12251        assert!(fm.cap_height_scaled(16.0).is_infinite());
12252
12253        // ascent == 0 turns 0/0 into NaN rather than inf.
12254        let zero = metrics(0, 0.0, 0.0, 0.0);
12255        assert!(zero.baseline_scaled(16.0).is_nan());
12256    }
12257
12258    #[test]
12259    fn layout_font_metrics_x_height_falls_back_to_half_em() {
12260        let fm = std_metrics(); // x_height: None
12261        assert_eq!(fm.x_height_scaled(16.0), 8.0, "fallback is 0.5em");
12262        assert_eq!(fm.x_height_scaled(0.0), 0.0);
12263
12264        let mut with_xh = std_metrics();
12265        with_xh.x_height = Some(500.0);
12266        approx(with_xh.x_height_scaled(16.0), 8.0);
12267        with_xh.x_height = Some(0.0);
12268        assert_eq!(
12269            with_xh.x_height_scaled(16.0),
12270            0.0,
12271            "an explicit sxHeight of 0 must NOT re-trigger the 0.5em fallback"
12272        );
12273    }
12274
12275    #[test]
12276    fn layout_font_metrics_cap_height_falls_back_to_ascent() {
12277        let fm = std_metrics(); // cap_height: None
12278        assert_eq!(fm.cap_height_scaled(16.0), fm.baseline_scaled(16.0));
12279
12280        let mut with_cap = std_metrics();
12281        with_cap.cap_height = Some(700.0);
12282        approx(with_cap.cap_height_scaled(16.0), 11.2);
12283    }
12284
12285    #[test]
12286    fn layout_font_metrics_nan_font_size_propagates_without_panicking() {
12287        let fm = std_metrics();
12288        assert!(fm.baseline_scaled(f32::NAN).is_nan());
12289        assert!(fm.x_height_scaled(f32::NAN).is_nan());
12290        assert!(fm.cap_height_scaled(f32::NAN).is_nan());
12291        assert!(fm.baseline_scaled(f32::INFINITY).is_infinite());
12292    }
12293
12294    #[test]
12295    fn layout_font_metrics_synthesized_baselines_span_exactly_one_em() {
12296        let fm = std_metrics();
12297        assert_eq!(fm.central_baseline(), 300.0); // midpoint(800, -200)
12298        assert_eq!(fm.em_over(), 800.0); // 300 + 1000/2
12299        assert_eq!(fm.em_under(), -200.0); // 300 - 1000/2
12300        assert_eq!(
12301            fm.em_over() - fm.em_under(),
12302            f32::from(fm.units_per_em),
12303            "em-over minus em-under is by definition 1em"
12304        );
12305    }
12306
12307    #[test]
12308    fn layout_font_metrics_baselines_at_u16_max_upem_and_zero_upem() {
12309        let big = metrics(u16::MAX, 0.0, 0.0, 0.0);
12310        assert_eq!(big.central_baseline(), 0.0);
12311        assert_eq!(big.em_over(), f32::from(u16::MAX) / 2.0);
12312        assert_eq!(big.em_under(), -f32::from(u16::MAX) / 2.0);
12313
12314        let zero = metrics(0, 100.0, -50.0, 0.0);
12315        assert_eq!(zero.em_over(), zero.central_baseline());
12316        assert_eq!(zero.em_under(), zero.central_baseline());
12317    }
12318
12319    #[test]
12320    fn layout_font_metrics_central_baseline_with_infinite_extents_is_nan() {
12321        let fm = metrics(1000, f32::INFINITY, f32::NEG_INFINITY, 0.0);
12322        assert!(fm.central_baseline().is_nan(), "midpoint(inf, -inf) is NaN");
12323        assert!(fm.em_over().is_nan());
12324    }
12325
12326    // =====================================================================
12327    // numeric: round_eq (the equality primitive under Rect/Size/Point/Stroke)
12328    // =====================================================================
12329
12330    #[test]
12331    fn round_eq_rounds_half_away_from_zero() {
12332        assert!(round_eq(0.4, -0.4), "both round to 0");
12333        assert!(round_eq(1.5, 2.4), "1.5 rounds away from zero to 2");
12334        assert!(!round_eq(1.4, 1.5));
12335        assert!(round_eq(-1.5, -2.0));
12336    }
12337
12338    #[test]
12339    fn round_eq_treats_nan_as_equal_to_everything_rounding_to_zero() {
12340        // `NaN.round() as isize` is a SATURATING cast that yields 0, so NaN
12341        // compares equal to 0.0 (and to itself). Any Rect/Size/Point carrying a
12342        // NaN coordinate therefore compares "equal" to a zeroed one — a real
12343        // cache-key hazard, pinned here.
12344        assert!(round_eq(f32::NAN, f32::NAN));
12345        assert!(round_eq(f32::NAN, 0.0));
12346        assert!(round_eq(f32::NAN, 0.49));
12347        assert!(!round_eq(f32::NAN, 1.0));
12348
12349        assert_eq!(
12350            Rect {
12351                x: f32::NAN,
12352                y: 0.0,
12353                width: 0.0,
12354                height: 0.0
12355            },
12356            Rect::default(),
12357            "a NaN-x Rect compares equal to the zero Rect"
12358        );
12359    }
12360
12361    #[test]
12362    fn round_eq_saturates_infinity_and_f32_max_to_the_same_isize() {
12363        // Both +inf and f32::MAX saturate to isize::MAX, so they are "equal".
12364        assert!(round_eq(f32::INFINITY, f32::MAX));
12365        assert!(round_eq(f32::NEG_INFINITY, f32::MIN));
12366        assert!(!round_eq(f32::INFINITY, f32::NEG_INFINITY));
12367
12368        assert_eq!(
12369            Size::new(f32::INFINITY, 0.0),
12370            Size::new(f32::MAX, 0.0),
12371            "saturating cast collapses inf and f32::MAX into one bucket"
12372        );
12373    }
12374
12375    // =====================================================================
12376    // numeric/getter: Size, calculate_bounding_box_size, ShapeDefinition
12377    // =====================================================================
12378
12379    #[test]
12380    fn size_zero_is_the_neutral_element_and_new_preserves_bits() {
12381        assert_eq!(Size::zero(), Size::new(0.0, 0.0));
12382        assert_eq!(Size::zero().width, 0.0);
12383        assert_eq!(Size::zero(), Size::default());
12384
12385        let weird = Size::new(f32::NAN, f32::INFINITY);
12386        assert!(weird.width.is_nan(), "the constructor must not sanitize");
12387        assert!(weird.height.is_infinite());
12388    }
12389
12390    #[test]
12391    fn bounding_box_of_empty_and_single_point_is_zero() {
12392        assert_eq!(calculate_bounding_box_size(&[]), Size::zero());
12393        assert_eq!(
12394            calculate_bounding_box_size(&[Point { x: 5.0, y: -5.0 }]),
12395            Size::zero()
12396        );
12397    }
12398
12399    #[test]
12400    fn bounding_box_spans_negative_coordinates() {
12401        let pts = [
12402            Point { x: -10.0, y: -20.0 },
12403            Point { x: 30.0, y: 5.0 },
12404            Point { x: 0.0, y: 0.0 },
12405        ];
12406        assert_eq!(calculate_bounding_box_size(&pts), Size::new(40.0, 25.0));
12407    }
12408
12409    #[test]
12410    fn bounding_box_of_all_nan_points_collapses_to_zero() {
12411        // min()/max() discard NaN, leaving min > max, which the guard catches.
12412        let pts = [Point {
12413            x: f32::NAN,
12414            y: f32::NAN,
12415        }];
12416        assert_eq!(calculate_bounding_box_size(&pts), Size::zero());
12417    }
12418
12419    #[test]
12420    fn bounding_box_of_extreme_points_overflows_to_infinity_without_panicking() {
12421        let pts = [
12422            Point {
12423                x: f32::MIN,
12424                y: f32::MIN,
12425            },
12426            Point {
12427                x: f32::MAX,
12428                y: f32::MAX,
12429            },
12430        ];
12431        let s = calculate_bounding_box_size(&pts);
12432        assert!(s.width.is_infinite() && s.height.is_infinite());
12433    }
12434
12435    #[test]
12436    fn shape_definition_get_size_for_each_variant() {
12437        assert_eq!(
12438            ShapeDefinition::Rectangle {
12439                size: Size::new(3.0, 4.0),
12440                corner_radius: None
12441            }
12442            .get_size(),
12443            Size::new(3.0, 4.0)
12444        );
12445        assert_eq!(
12446            ShapeDefinition::Circle { radius: 10.0 }.get_size(),
12447            Size::new(20.0, 20.0)
12448        );
12449        assert_eq!(
12450            ShapeDefinition::Ellipse {
12451                radii: Size::new(5.0, 2.0)
12452            }
12453            .get_size(),
12454            Size::new(10.0, 4.0)
12455        );
12456        assert_eq!(
12457            ShapeDefinition::Polygon { points: Vec::new() }.get_size(),
12458            Size::zero()
12459        );
12460        assert_eq!(
12461            ShapeDefinition::Path {
12462                segments: Vec::new()
12463            }
12464            .get_size(),
12465            Size::zero()
12466        );
12467    }
12468
12469    #[test]
12470    fn shape_definition_negative_circle_radius_yields_a_negative_size() {
12471        // Pinned, not endorsed: the constructor never validates the radius, so a
12472        // negative CSS radius propagates a negative bounding box into layout.
12473        let s = ShapeDefinition::Circle { radius: -10.0 }.get_size();
12474        assert_eq!(s.width, -20.0);
12475        assert_eq!(s.height, -20.0);
12476    }
12477
12478    #[test]
12479    fn shape_definition_path_of_only_close_segments_is_zero_sized() {
12480        let s = ShapeDefinition::Path {
12481            segments: vec![PathSegment::Close, PathSegment::Close],
12482        }
12483        .get_size();
12484        assert_eq!(s, Size::zero(), "Close contributes no points");
12485    }
12486
12487    #[test]
12488    fn shape_definition_path_bounding_box_includes_control_points() {
12489        let s = ShapeDefinition::Path {
12490            segments: vec![
12491                PathSegment::MoveTo(Point { x: 0.0, y: 0.0 }),
12492                PathSegment::QuadTo {
12493                    control: Point { x: 50.0, y: 100.0 },
12494                    end: Point { x: 100.0, y: 0.0 },
12495                },
12496            ],
12497        }
12498        .get_size();
12499        assert_eq!(
12500            s,
12501            Size::new(100.0, 100.0),
12502            "the control point (not the true curve extremum) sets the height"
12503        );
12504    }
12505
12506    // =====================================================================
12507    // numeric: ShapeBoundary::inflate
12508    // =====================================================================
12509
12510    #[test]
12511    fn shape_boundary_inflate_by_zero_is_identity() {
12512        let r = ShapeBoundary::Rectangle(Rect {
12513            x: 1.0,
12514            y: 2.0,
12515            width: 3.0,
12516            height: 4.0,
12517        });
12518        assert_eq!(r.inflate(0.0), r);
12519        let c = ShapeBoundary::Circle {
12520            center: Point { x: 0.0, y: 0.0 },
12521            radius: 5.0,
12522        };
12523        assert_eq!(c.inflate(0.0), c);
12524    }
12525
12526    #[test]
12527    fn shape_boundary_inflate_rectangle_clamps_negative_dimensions_to_zero() {
12528        let r = ShapeBoundary::Rectangle(Rect {
12529            x: 0.0,
12530            y: 0.0,
12531            width: 10.0,
12532            height: 10.0,
12533        });
12534        match r.inflate(-100.0) {
12535            ShapeBoundary::Rectangle(out) => {
12536                assert_eq!(out.width, 0.0, "over-deflation must clamp, not go negative");
12537                assert_eq!(out.height, 0.0);
12538                assert_eq!(out.x, 100.0, "the origin is NOT clamped");
12539            }
12540            other => panic!("expected Rectangle, got {other:?}"),
12541        }
12542    }
12543
12544    #[test]
12545    fn shape_boundary_inflate_nan_margin_zeroes_the_rectangle_extent() {
12546        // `margin == 0.0` is false for NaN, so we take the inflate path; then
12547        // `NaN.max(0.0)` returns 0.0 (f32::max discards NaN) — the box silently
12548        // collapses to zero width/height with a NaN origin.
12549        let r = ShapeBoundary::Rectangle(Rect {
12550            x: 0.0,
12551            y: 0.0,
12552            width: 10.0,
12553            height: 10.0,
12554        });
12555        match r.inflate(f32::NAN) {
12556            ShapeBoundary::Rectangle(out) => {
12557                assert_eq!(out.width, 0.0);
12558                assert_eq!(out.height, 0.0);
12559                assert!(out.x.is_nan());
12560            }
12561            other => panic!("expected Rectangle, got {other:?}"),
12562        }
12563    }
12564
12565    #[test]
12566    fn shape_boundary_inflate_circle_radius_is_unclamped() {
12567        let c = ShapeBoundary::Circle {
12568            center: Point { x: 1.0, y: 2.0 },
12569            radius: 5.0,
12570        };
12571        match c.inflate(-50.0) {
12572            ShapeBoundary::Circle { center, radius } => {
12573                assert_eq!(center, Point { x: 1.0, y: 2.0 });
12574                assert_eq!(radius, -45.0, "circle radius is NOT clamped at 0 (unlike Rect)");
12575            }
12576            other => panic!("expected Circle, got {other:?}"),
12577        }
12578        match c.inflate(f32::INFINITY) {
12579            ShapeBoundary::Circle { radius, .. } => assert!(radius.is_infinite()),
12580            other => panic!("expected Circle, got {other:?}"),
12581        }
12582    }
12583
12584    #[test]
12585    fn shape_boundary_inflate_is_a_documented_no_op_for_polygon_and_path() {
12586        let p = ShapeBoundary::Polygon {
12587            points: vec![Point { x: 0.0, y: 0.0 }, Point { x: 1.0, y: 1.0 }],
12588        };
12589        assert_eq!(p.inflate(10.0), p, "polygon inflation is not implemented");
12590        let path = ShapeBoundary::Path {
12591            segments: vec![PathSegment::MoveTo(Point { x: 0.0, y: 0.0 })],
12592        };
12593        assert_eq!(path.inflate(10.0), path, "path inflation is not implemented");
12594    }
12595
12596    // =====================================================================
12597    // other: resolve_effective_alignment
12598    // =====================================================================
12599
12600    #[test]
12601    fn resolve_effective_alignment_passes_through_for_non_last_lines() {
12602        for ta in [
12603            TextAlign::Left,
12604            TextAlign::Right,
12605            TextAlign::Center,
12606            TextAlign::Justify,
12607            TextAlign::Start,
12608            TextAlign::End,
12609            TextAlign::JustifyAll,
12610        ] {
12611            assert_eq!(
12612                resolve_effective_alignment(ta, TextAlign::Right, false),
12613                ta,
12614                "text-align-last must not touch a non-last line"
12615            );
12616        }
12617    }
12618
12619    #[test]
12620    fn resolve_effective_alignment_last_line_justify_degrades_to_start() {
12621        assert_eq!(
12622            resolve_effective_alignment(TextAlign::Justify, TextAlign::default(), true),
12623            TextAlign::Start
12624        );
12625        assert_eq!(
12626            resolve_effective_alignment(TextAlign::Center, TextAlign::default(), true),
12627            TextAlign::Center,
12628            "non-justify alignments survive onto the last line"
12629        );
12630    }
12631
12632    #[test]
12633    fn resolve_effective_alignment_explicit_text_align_last_left_is_indistinguishable_from_auto() {
12634        // QUIRK: "auto" is encoded as TextAlign::default() == Left, so an author
12635        // writing `text-align-last: left` on `text-align: center` gets CENTER, not
12636        // left — the explicit value is swallowed by the auto check.
12637        assert_eq!(
12638            resolve_effective_alignment(TextAlign::Center, TextAlign::Left, true),
12639            TextAlign::Center
12640        );
12641        // Any other explicit value does win.
12642        assert_eq!(
12643            resolve_effective_alignment(TextAlign::Center, TextAlign::Right, true),
12644            TextAlign::Right
12645        );
12646        assert_eq!(
12647            resolve_effective_alignment(TextAlign::Justify, TextAlign::Justify, true),
12648            TextAlign::Justify
12649        );
12650    }
12651
12652    // =====================================================================
12653    // numeric: Spacing::resolve_px
12654    // =====================================================================
12655
12656    #[test]
12657    fn spacing_resolve_px_default_is_zero_and_font_size_independent() {
12658        assert_eq!(Spacing::default(), Spacing::Px(0));
12659        assert_eq!(Spacing::default().resolve_px(16.0), 0.0);
12660        assert_eq!(Spacing::Px(0).resolve_px(f32::NAN), 0.0);
12661    }
12662
12663    #[test]
12664    fn spacing_resolve_px_at_i32_extremes_stays_finite() {
12665        let hi = Spacing::Px(i32::MAX).resolve_px(16.0);
12666        let lo = Spacing::Px(i32::MIN).resolve_px(16.0);
12667        assert!(hi.is_finite() && hi > 2.0e9, "got {hi}");
12668        assert!(lo.is_finite() && lo < -2.0e9, "got {lo}");
12669        assert_eq!(lo, -2147483648.0);
12670    }
12671
12672    #[test]
12673    fn spacing_resolve_px_em_scales_with_font_size() {
12674        assert_eq!(Spacing::Em(2.0).resolve_px(16.0), 32.0);
12675        assert_eq!(Spacing::Em(2.0).resolve_px(0.0), 0.0);
12676        assert_eq!(Spacing::Em(-0.5).resolve_px(16.0), -8.0);
12677        assert_eq!(Spacing::PxF(0.4).resolve_px(999.0), 0.4, "PxF ignores font size");
12678    }
12679
12680    #[test]
12681    fn spacing_resolve_px_nan_and_overflow_are_defined() {
12682        assert!(Spacing::Em(f32::NAN).resolve_px(16.0).is_nan());
12683        assert!(Spacing::Em(1.0).resolve_px(f32::NAN).is_nan());
12684        assert!(Spacing::PxF(f32::NAN).resolve_px(16.0).is_nan());
12685        assert!(Spacing::Em(f32::MAX).resolve_px(2.0).is_infinite());
12686        // 0 * inf is NaN, not 0.
12687        assert!(Spacing::Em(0.0).resolve_px(f32::INFINITY).is_nan());
12688    }
12689
12690    #[test]
12691    fn spacing_px_and_pxf_of_the_same_value_are_distinct_cache_keys() {
12692        assert_ne!(Spacing::Px(1), Spacing::PxF(1.0));
12693        assert_ne!(hash_of(&Spacing::Px(1)), hash_of(&Spacing::PxF(1.0)));
12694        assert_eq!(Spacing::Px(1).resolve_px(16.0), Spacing::PxF(1.0).resolve_px(16.0));
12695    }
12696
12697    // =====================================================================
12698    // predicate/getter: BidiDirection, BidiLevel, WritingMode
12699    // =====================================================================
12700
12701    #[test]
12702    fn bidi_direction_is_rtl() {
12703        assert!(!BidiDirection::Ltr.is_rtl());
12704        assert!(BidiDirection::Rtl.is_rtl());
12705    }
12706
12707    #[test]
12708    fn bidi_level_parity_defines_rtl_across_the_whole_u8_range() {
12709        for lvl in [0_u8, 1, 2, 3, 126, 127, 254, u8::MAX] {
12710            let b = BidiLevel::new(lvl);
12711            assert_eq!(b.level(), lvl, "level() must round-trip new()");
12712            assert_eq!(b.is_rtl(), lvl % 2 == 1, "odd embedding levels are RTL");
12713        }
12714    }
12715
12716    #[test]
12717    fn writing_mode_is_advance_horizontal_for_every_variant() {
12718        assert!(WritingMode::HorizontalTb.is_advance_horizontal());
12719        assert!(WritingMode::SidewaysRl.is_advance_horizontal());
12720        assert!(WritingMode::SidewaysLr.is_advance_horizontal());
12721        assert!(!WritingMode::VerticalRl.is_advance_horizontal());
12722        assert!(!WritingMode::VerticalLr.is_advance_horizontal());
12723        assert_eq!(WritingMode::default(), WritingMode::HorizontalTb);
12724    }
12725
12726    #[test]
12727    fn writing_mode_get_direction_only_horizontal_defers_to_content() {
12728        assert_eq!(WritingMode::HorizontalTb.get_direction(), None);
12729        assert_eq!(WritingMode::VerticalRl.get_direction(), Some(BidiDirection::Rtl));
12730        assert_eq!(WritingMode::VerticalLr.get_direction(), Some(BidiDirection::Ltr));
12731        assert_eq!(WritingMode::SidewaysRl.get_direction(), Some(BidiDirection::Rtl));
12732        assert_eq!(WritingMode::SidewaysLr.get_direction(), Some(BidiDirection::Ltr));
12733    }
12734
12735    // =====================================================================
12736    // UnifiedConstraints
12737    // =====================================================================
12738
12739    #[test]
12740    fn unified_constraints_default_is_horizontal_max_content() {
12741        let c = UnifiedConstraints::default();
12742        assert!(!c.is_vertical());
12743        assert_eq!(c.available_width, AvailableSpace::MaxContent);
12744        assert_eq!(c.columns, 1);
12745        assert_eq!(c, UnifiedConstraints::default());
12746        assert_eq!(
12747            hash_of(&c),
12748            hash_of(&UnifiedConstraints::default()),
12749            "Hash/Eq must agree for the default constraints"
12750        );
12751    }
12752
12753    #[test]
12754    fn unified_constraints_is_vertical_only_for_the_two_vertical_modes() {
12755        let mut c = UnifiedConstraints::default();
12756        for (wm, want) in [
12757            (WritingMode::HorizontalTb, false),
12758            (WritingMode::VerticalRl, true),
12759            (WritingMode::VerticalLr, true),
12760            (WritingMode::SidewaysRl, false),
12761            (WritingMode::SidewaysLr, false),
12762        ] {
12763            c.writing_mode = Some(wm);
12764            assert_eq!(c.is_vertical(), want, "{wm:?}");
12765        }
12766        c.writing_mode = None;
12767        assert!(!c.is_vertical());
12768    }
12769
12770    #[test]
12771    fn unified_constraints_direction_uses_fallback_unless_the_writing_mode_forces_one() {
12772        let mut c = UnifiedConstraints::default();
12773        // No writing mode → fallback wins.
12774        assert_eq!(c.direction(BidiDirection::Rtl), BidiDirection::Rtl);
12775        // horizontal-tb → still content-determined → fallback wins.
12776        c.writing_mode = Some(WritingMode::HorizontalTb);
12777        assert_eq!(c.direction(BidiDirection::Rtl), BidiDirection::Rtl);
12778        // vertical-rl OVERRIDES the fallback.
12779        c.writing_mode = Some(WritingMode::VerticalRl);
12780        assert_eq!(c.direction(BidiDirection::Ltr), BidiDirection::Rtl);
12781    }
12782
12783    #[test]
12784    fn unified_constraints_resolved_line_height_uses_the_strut_for_normal() {
12785        let mut c = UnifiedConstraints::default();
12786        assert_eq!(
12787            c.resolved_line_height(),
12788            DEFAULT_STRUT_ASCENT + DEFAULT_STRUT_DESCENT
12789        );
12790        assert_eq!(c.resolved_line_height(), 16.0);
12791
12792        c.line_height = LineHeight::Px(0.0);
12793        assert_eq!(c.resolved_line_height(), 0.0, "an explicit 0 is honoured");
12794
12795        // Pinned: a negative / NaN px line-height is passed straight through.
12796        c.line_height = LineHeight::Px(-5.0);
12797        assert_eq!(c.resolved_line_height(), -5.0);
12798        c.line_height = LineHeight::Px(f32::NAN);
12799        assert!(c.resolved_line_height().is_nan());
12800    }
12801
12802    #[test]
12803    fn unified_constraints_partial_eq_is_rounding_tolerant() {
12804        // PartialEq rounds f32 fields, so sub-pixel strut differences compare EQUAL.
12805        let mut a = UnifiedConstraints::default();
12806        let mut b = UnifiedConstraints::default();
12807        a.strut_ascent = 12.8;
12808        b.strut_ascent = 12.9;
12809        assert_eq!(a, b, "12.8 and 12.9 both round to 13");
12810        b.strut_ascent = 14.0;
12811        assert_ne!(a, b);
12812    }
12813
12814    // =====================================================================
12815    // constructor: TextDecoration::from_css
12816    // =====================================================================
12817
12818    #[test]
12819    fn text_decoration_from_css_maps_each_variant_exclusively() {
12820        use azul_css::props::style::text::StyleTextDecoration;
12821        let none = TextDecoration::from_css(StyleTextDecoration::None);
12822        assert_eq!(none, TextDecoration::default());
12823        assert!(!none.underline && !none.strikethrough && !none.overline);
12824
12825        let u = TextDecoration::from_css(StyleTextDecoration::Underline);
12826        assert!(u.underline && !u.strikethrough && !u.overline);
12827
12828        let o = TextDecoration::from_css(StyleTextDecoration::Overline);
12829        assert!(!o.underline && !o.strikethrough && o.overline);
12830
12831        let lt = TextDecoration::from_css(StyleTextDecoration::LineThrough);
12832        assert!(!lt.underline && lt.strikethrough && !lt.overline);
12833    }
12834
12835    // =====================================================================
12836    // predicate/getter: InlineBorderInfo
12837    // =====================================================================
12838
12839    #[test]
12840    fn inline_border_info_default_has_no_border_and_no_chrome() {
12841        let b = InlineBorderInfo::default();
12842        assert!(!b.has_border());
12843        assert!(!b.has_chrome());
12844        assert_eq!(b.left_inset(), 0.0);
12845        assert_eq!(b.right_inset(), 0.0);
12846        assert_eq!(b.top_inset(), 0.0);
12847        assert_eq!(b.bottom_inset(), 0.0);
12848    }
12849
12850    #[test]
12851    fn inline_border_info_negative_widths_do_not_count_as_a_border() {
12852        let b = InlineBorderInfo {
12853            top: -1.0,
12854            right: -1.0,
12855            bottom: -1.0,
12856            left: -1.0,
12857            ..InlineBorderInfo::default()
12858        };
12859        assert!(!b.has_border(), "the predicate is strictly `> 0.0`");
12860        assert!(!b.has_chrome());
12861        // ...but the inset arithmetic still returns the negative value.
12862        assert_eq!(b.left_inset(), -1.0);
12863    }
12864
12865    #[test]
12866    fn inline_border_info_padding_alone_is_chrome_but_not_a_border() {
12867        let b = InlineBorderInfo {
12868            padding_left: 4.0,
12869            ..InlineBorderInfo::default()
12870        };
12871        assert!(!b.has_border());
12872        assert!(b.has_chrome());
12873        assert_eq!(b.left_inset(), 4.0);
12874    }
12875
12876    #[test]
12877    fn inline_border_info_nan_border_width_is_not_a_border() {
12878        let b = InlineBorderInfo {
12879            top: f32::NAN,
12880            ..InlineBorderInfo::default()
12881        };
12882        assert!(!b.has_border(), "NaN > 0.0 is false");
12883        assert!(b.top_inset().is_nan(), "but the inset still carries the NaN");
12884    }
12885
12886    #[test]
12887    fn inline_border_info_split_insets_swap_edges_in_rtl() {
12888        let base = InlineBorderInfo {
12889            left: 2.0,
12890            right: 3.0,
12891            padding_left: 1.0,
12892            padding_right: 1.0,
12893            ..InlineBorderInfo::default()
12894        };
12895
12896        // LTR: left edge on the FIRST fragment, right edge on the LAST.
12897        let ltr_first = InlineBorderInfo {
12898            is_first_fragment: true,
12899            is_last_fragment: false,
12900            ..base
12901        };
12902        assert_eq!(ltr_first.left_inset(), 3.0);
12903        assert_eq!(ltr_first.right_inset(), 0.0);
12904
12905        let ltr_last = InlineBorderInfo {
12906            is_first_fragment: false,
12907            is_last_fragment: true,
12908            ..base
12909        };
12910        assert_eq!(ltr_last.left_inset(), 0.0);
12911        assert_eq!(ltr_last.right_inset(), 4.0);
12912
12913        // RTL: mirrored.
12914        let rtl_first = InlineBorderInfo {
12915            is_first_fragment: true,
12916            is_last_fragment: false,
12917            is_rtl: true,
12918            ..base
12919        };
12920        assert_eq!(rtl_first.left_inset(), 0.0);
12921        assert_eq!(rtl_first.right_inset(), 4.0);
12922
12923        let rtl_last = InlineBorderInfo {
12924            is_first_fragment: false,
12925            is_last_fragment: true,
12926            is_rtl: true,
12927            ..base
12928        };
12929        assert_eq!(rtl_last.left_inset(), 3.0);
12930        assert_eq!(rtl_last.right_inset(), 0.0);
12931
12932        // A middle fragment (neither first nor last) draws NO horizontal edge.
12933        let middle = InlineBorderInfo {
12934            is_first_fragment: false,
12935            is_last_fragment: false,
12936            ..base
12937        };
12938        assert_eq!(middle.left_inset(), 0.0);
12939        assert_eq!(middle.right_inset(), 0.0);
12940        // Vertical insets are never suppressed.
12941        let tall = InlineBorderInfo {
12942            top: 1.0,
12943            bottom: 2.0,
12944            padding_top: 3.0,
12945            padding_bottom: 4.0,
12946            is_first_fragment: false,
12947            is_last_fragment: false,
12948            ..InlineBorderInfo::default()
12949        };
12950        assert_eq!(tall.top_inset(), 4.0);
12951        assert_eq!(tall.bottom_inset(), 6.0);
12952    }
12953
12954    // =====================================================================
12955    // getter/other: StyleProperties::layout_hash / layout_eq / apply_override
12956    // =====================================================================
12957
12958    #[test]
12959    fn style_layout_eq_ignores_render_only_properties() {
12960        let a = StyleProperties::default();
12961        let b = StyleProperties {
12962            color: ColorU {
12963                r: 255,
12964                g: 0,
12965                b: 0,
12966                a: 255,
12967            },
12968            background_color: Some(ColorU::TRANSPARENT),
12969            text_decoration: TextDecoration {
12970                underline: true,
12971                strikethrough: false,
12972                overline: false,
12973            },
12974            border: Some(InlineBorderInfo::default()),
12975            ..StyleProperties::default()
12976        };
12977        assert_ne!(a, b, "the full PartialEq DOES see the colour change");
12978        assert!(
12979            a.layout_eq(&b),
12980            "but layout_eq must ignore colour/decoration/border"
12981        );
12982        assert_eq!(a.layout_hash(), b.layout_hash());
12983    }
12984
12985    #[test]
12986    fn style_layout_eq_sees_sub_pixel_font_size_changes() {
12987        let a = StyleProperties::default();
12988        let b = StyleProperties {
12989            font_size_px: 16.4,
12990            ..StyleProperties::default()
12991        };
12992        assert!(
12993            !a.layout_eq(&b),
12994            "16.0 vs 16.4 must NOT share a shaping-cache entry"
12995        );
12996    }
12997
12998    #[test]
12999    fn style_layout_eq_sees_spacing_and_font_stack_changes() {
13000        let base = StyleProperties::default();
13001
13002        let spaced = StyleProperties {
13003            letter_spacing: Spacing::PxF(0.5),
13004            ..StyleProperties::default()
13005        };
13006        assert!(!base.layout_eq(&spaced));
13007
13008        let worded = StyleProperties {
13009            word_spacing: Spacing::Em(0.1),
13010            ..StyleProperties::default()
13011        };
13012        assert!(!base.layout_eq(&worded));
13013
13014        let other_font = StyleProperties {
13015            font_stack: FontStack::Stack(vec![sel("Arial")]),
13016            ..StyleProperties::default()
13017        };
13018        assert!(!base.layout_eq(&other_font));
13019
13020        let vertical = StyleProperties {
13021            writing_mode: WritingMode::VerticalRl,
13022            ..StyleProperties::default()
13023        };
13024        assert!(!base.layout_eq(&vertical));
13025    }
13026
13027    #[test]
13028    fn style_layout_hash_is_stable_across_repeated_calls() {
13029        let s = StyleProperties::default();
13030        assert_eq!(s.layout_hash(), s.layout_hash());
13031        assert!(s.layout_eq(&StyleProperties::default()));
13032    }
13033
13034    #[test]
13035    fn style_layout_eq_treats_two_nan_font_sizes_as_equal() {
13036        // layout_hash hashes the raw bits, so NaN == NaN here (unlike `==` on f32).
13037        let a = StyleProperties {
13038            font_size_px: f32::NAN,
13039            ..StyleProperties::default()
13040        };
13041        let b = StyleProperties {
13042            font_size_px: f32::NAN,
13043            ..StyleProperties::default()
13044        };
13045        assert!(a.layout_eq(&b));
13046        assert!(!a.layout_eq(&StyleProperties::default()));
13047    }
13048
13049    #[test]
13050    fn style_apply_override_with_an_empty_partial_changes_nothing() {
13051        let base = StyleProperties::default();
13052        let out = base.apply_override(&PartialStyleProperties::default());
13053        assert_eq!(out, base);
13054    }
13055
13056    #[test]
13057    fn style_apply_override_applies_only_the_some_fields() {
13058        let base = StyleProperties::default();
13059        let partial = PartialStyleProperties {
13060            font_size_px: Some(32.0),
13061            letter_spacing: Some(Spacing::PxF(1.5)),
13062            ..PartialStyleProperties::default()
13063        };
13064        let out = base.apply_override(&partial);
13065        assert_eq!(out.font_size_px, 32.0);
13066        assert_eq!(out.letter_spacing, Spacing::PxF(1.5));
13067        // Untouched fields are inherited verbatim.
13068        assert_eq!(out.word_spacing, base.word_spacing);
13069        assert_eq!(out.tab_size, base.tab_size);
13070        assert_eq!(out.font_stack, base.font_stack);
13071        assert!(!out.layout_eq(&base));
13072    }
13073
13074    #[test]
13075    fn style_apply_override_can_inject_nan_font_size() {
13076        let base = StyleProperties::default();
13077        let partial = PartialStyleProperties {
13078            font_size_px: Some(f32::NAN),
13079            ..PartialStyleProperties::default()
13080        };
13081        let out = base.apply_override(&partial);
13082        assert!(out.font_size_px.is_nan(), "no validation happens here");
13083    }
13084
13085    // =====================================================================
13086    // numeric: classify_character / get_justification_priority
13087    // =====================================================================
13088
13089    #[test]
13090    fn classify_character_covers_each_class() {
13091        assert_eq!(classify_character(0x0020), CharacterClass::Space);
13092        assert_eq!(classify_character(0x00A0), CharacterClass::Space);
13093        assert_eq!(classify_character(0x3000), CharacterClass::Space);
13094        assert_eq!(classify_character('.' as u32), CharacterClass::Punctuation);
13095        assert_eq!(classify_character('~' as u32), CharacterClass::Punctuation);
13096        assert_eq!(classify_character('a' as u32), CharacterClass::Letter);
13097        assert_eq!(classify_character(0x4E00), CharacterClass::Ideograph);
13098        assert_eq!(classify_character(0x9FFF), CharacterClass::Ideograph);
13099        assert_eq!(classify_character(0x0301), CharacterClass::Combining);
13100    }
13101
13102    #[test]
13103    fn classify_character_at_u32_extremes_defaults_to_letter() {
13104        assert_eq!(classify_character(0), CharacterClass::Letter);
13105        assert_eq!(classify_character(u32::MAX), CharacterClass::Letter);
13106        // Boundary walk around the ideograph range.
13107        assert_eq!(classify_character(0x4DFF), CharacterClass::Letter);
13108        assert_eq!(classify_character(0xA000), CharacterClass::Letter);
13109    }
13110
13111    #[test]
13112    fn get_justification_priority_is_strictly_ordered_space_to_combining() {
13113        let p = |c| get_justification_priority(c);
13114        assert_eq!(p(CharacterClass::Space), 0);
13115        assert_eq!(p(CharacterClass::Combining), 255);
13116        assert!(p(CharacterClass::Space) < p(CharacterClass::Punctuation));
13117        assert!(p(CharacterClass::Punctuation) < p(CharacterClass::Ideograph));
13118        assert!(p(CharacterClass::Ideograph) < p(CharacterClass::Letter));
13119        assert!(p(CharacterClass::Letter) < p(CharacterClass::Symbol));
13120        assert!(p(CharacterClass::Symbol) < p(CharacterClass::Combining));
13121    }
13122
13123    // =====================================================================
13124    // predicate: char-level classifiers
13125    // =====================================================================
13126
13127    #[test]
13128    fn is_hanging_punctuation_char_only_stops_and_commas() {
13129        assert!(is_hanging_punctuation_char(','));
13130        assert!(is_hanging_punctuation_char('.'));
13131        assert!(is_hanging_punctuation_char('\u{3001}'));
13132        assert!(is_hanging_punctuation_char('\u{FF0E}'));
13133        assert!(!is_hanging_punctuation_char(';'));
13134        assert!(!is_hanging_punctuation_char(' '));
13135        assert!(!is_hanging_punctuation_char('\0'));
13136        assert!(!is_hanging_punctuation_char(char::MAX));
13137    }
13138
13139    #[test]
13140    fn is_word_char_is_alphanumeric_or_underscore() {
13141        assert!(is_word_char('a'));
13142        assert!(is_word_char('Z'));
13143        assert!(is_word_char('9'));
13144        assert!(is_word_char('_'));
13145        assert!(is_word_char('é'), "non-ASCII letters are word chars");
13146        assert!(is_word_char('中'), "ideographs are alphanumeric");
13147        assert!(!is_word_char(' '));
13148        assert!(!is_word_char('-'));
13149        assert!(!is_word_char('.'));
13150        assert!(!is_word_char('\u{00A0}'));
13151        assert!(!is_word_char('\0'));
13152    }
13153
13154    #[test]
13155    fn is_word_separator_char_excludes_tabs_and_fixed_width_spaces() {
13156        assert!(is_word_separator_char(' '));
13157        assert!(is_word_separator_char('\u{00A0}'), "NBSP IS a word separator");
13158        assert!(is_word_separator_char('\u{1680}'));
13159        assert!(is_word_separator_char('\u{202F}'));
13160        assert!(is_word_separator_char('\u{10100}'));
13161
13162        // Per CSS Text §7.1 these are NOT word separators, despite looking like spaces.
13163        assert!(!is_word_separator_char('\u{2000}'));
13164        assert!(!is_word_separator_char('\u{200A}'));
13165        assert!(!is_word_separator_char('\u{3000}'), "ideographic space excluded");
13166        // Nor are tab/newline (they are handled by white-space processing instead).
13167        assert!(!is_word_separator_char('\t'));
13168        assert!(!is_word_separator_char('\n'));
13169        assert!(!is_word_separator_char('.'));
13170        assert!(!is_word_separator_char(char::MAX));
13171    }
13172
13173    #[test]
13174    fn is_cursive_script_char_boundaries() {
13175        assert!(!is_cursive_script_char('\u{05FF}'), "one below Arabic");
13176        assert!(is_cursive_script_char('\u{0600}'), "Arabic block start");
13177        assert!(is_cursive_script_char('\u{06FF}'), "Arabic block end");
13178        assert!(is_cursive_script_char('\u{0700}'), "Syriac");
13179        assert!(is_cursive_script_char('\u{1800}'), "Mongolian");
13180        assert!(is_cursive_script_char('\u{10D00}'), "Hanifi Rohingya (astral)");
13181        assert!(!is_cursive_script_char('a'));
13182        assert!(!is_cursive_script_char('中'));
13183        assert!(!is_cursive_script_char('\0'));
13184        assert!(!is_cursive_script_char(char::MAX));
13185    }
13186
13187    #[test]
13188    fn is_cjk_character_boundaries() {
13189        assert!(is_cjk_character('中')); // U+4E2D
13190        assert!(is_cjk_character('\u{4E00}'));
13191        assert!(is_cjk_character('\u{9FFF}'));
13192        assert!(is_cjk_character('\u{3040}'), "hiragana block");
13193        assert!(is_cjk_character('\u{30FF}'), "katakana block");
13194        assert!(is_cjk_character('\u{AC00}'), "hangul syllables");
13195        assert!(is_cjk_character('\u{FF01}'), "fullwidth forms");
13196        assert!(!is_cjk_character('\u{4DFF}'), "one below the ideograph block");
13197        assert!(!is_cjk_character('a'));
13198        assert!(!is_cjk_character('\0'));
13199        assert!(!is_cjk_character(char::MAX));
13200    }
13201
13202    #[test]
13203    fn break_control_predicates_are_disjoint() {
13204        assert!(is_break_suppressing_control('\u{200D}'));
13205        assert!(is_break_suppressing_control('\u{2060}'));
13206        assert!(is_break_suppressing_control('\u{FEFF}'));
13207        assert!(!is_break_suppressing_control(' '));
13208        assert!(!is_break_suppressing_control('\u{200B}'));
13209
13210        assert!(is_break_forcing_control('\u{200B}'));
13211        assert!(is_break_forcing_control('\u{2028}'));
13212        assert!(is_break_forcing_control('\u{2029}'));
13213        assert!(!is_break_forcing_control(' '));
13214        assert!(!is_break_forcing_control('\u{200D}'));
13215
13216        for ch in ['\u{200D}', '\u{2060}', '\u{FEFF}', '\u{200B}', '\u{2028}'] {
13217            assert!(
13218                !(is_break_suppressing_control(ch) && is_break_forcing_control(ch)),
13219                "{ch:?} cannot both force and suppress a break"
13220            );
13221        }
13222    }
13223
13224    #[test]
13225    fn is_small_kana_matches_only_the_cj_class() {
13226        assert!(is_small_kana('っ'));
13227        assert!(is_small_kana('ゃ'));
13228        assert!(is_small_kana('ッ'));
13229        assert!(is_small_kana('ー'), "prolonged sound mark is class CJ");
13230        assert!(!is_small_kana('つ'), "the FULL-size kana is not CJ");
13231        assert!(!is_small_kana('中'));
13232        assert!(!is_small_kana('a'));
13233        assert!(!is_small_kana('\0'));
13234    }
13235
13236    #[test]
13237    fn is_cjk_break_allowed_by_strictness_per_level() {
13238        use LineBreakStrictness::{Anywhere, Auto, Loose, Normal, Strict};
13239
13240        // Anywhere / Loose: everything is breakable.
13241        for ch in ['っ', '\u{301C}', '\u{2010}', '中'] {
13242            assert!(is_cjk_break_allowed_by_strictness(ch, None, Anywhere), "{ch:?}");
13243            assert!(is_cjk_break_allowed_by_strictness(ch, None, Loose), "{ch:?}");
13244        }
13245
13246        // Normal/Auto: hyphens forbidden, small kana allowed.
13247        for level in [Normal, Auto] {
13248            assert!(!is_cjk_break_allowed_by_strictness('\u{2010}', None, level));
13249            assert!(!is_cjk_break_allowed_by_strictness('\u{2013}', None, level));
13250            assert!(is_cjk_break_allowed_by_strictness('っ', None, level));
13251            assert!(is_cjk_break_allowed_by_strictness('中', None, level));
13252        }
13253
13254        // Strict: small kana and CJK hyphen-likes are forbidden too.
13255        assert!(!is_cjk_break_allowed_by_strictness('っ', None, Strict));
13256        assert!(!is_cjk_break_allowed_by_strictness('ー', None, Strict));
13257        assert!(!is_cjk_break_allowed_by_strictness('\u{301C}', None, Strict));
13258        assert!(!is_cjk_break_allowed_by_strictness('\u{30A0}', None, Strict));
13259        assert!(is_cjk_break_allowed_by_strictness('中', None, Strict));
13260
13261        // prev_ch is currently ignored — pin that so a future use is a deliberate change.
13262        assert_eq!(
13263            is_cjk_break_allowed_by_strictness('中', Some('x'), Strict),
13264            is_cjk_break_allowed_by_strictness('中', None, Strict)
13265        );
13266    }
13267
13268    // =====================================================================
13269    // getter/predicate: Glyph
13270    // =====================================================================
13271
13272    fn plain_glyph(codepoint: char, advance: f32) -> Glyph {
13273        Glyph {
13274            glyph_id: 1,
13275            codepoint,
13276            font_hash: 7,
13277            font_metrics: std_metrics(),
13278            style: style(),
13279            source: GlyphSource::Char,
13280            logical_byte_index: 0,
13281            logical_byte_len: codepoint.len_utf8(),
13282            content_index: 0,
13283            cluster: 0,
13284            advance,
13285            kerning: 0.0,
13286            offset: Point { x: 0.0, y: 0.0 },
13287            vertical_advance: advance,
13288            vertical_origin_y: 0.0,
13289            vertical_bearing: Point { x: 0.0, y: 0.0 },
13290            orientation: GlyphOrientation::Horizontal,
13291            script: Script::Latin,
13292            bidi_level: BidiLevel::new(0),
13293        }
13294    }
13295
13296    #[test]
13297    fn glyph_bounds_is_advance_by_resolved_line_height() {
13298        let g = plain_glyph('a', 9.5);
13299        let b = g.bounds();
13300        assert_eq!(b.x, 0.0);
13301        assert_eq!(b.y, 0.0);
13302        assert_eq!(b.width, 9.5);
13303        approx(b.height, 16.0); // normal line-height on a 1000/800/-200 font @16px
13304    }
13305
13306    #[test]
13307    fn glyph_bounds_with_zero_advance_and_zero_upem_does_not_panic() {
13308        let mut g = plain_glyph('a', 0.0);
13309        g.font_metrics = metrics(0, 0.0, 0.0, 0.0);
13310        let b = g.bounds();
13311        assert_eq!(b.width, 0.0);
13312        approx(b.height, 19.2); // zero-upem falls back to 1.2em
13313    }
13314
13315    #[test]
13316    fn glyph_whitespace_and_justification_predicates() {
13317        let space = plain_glyph(' ', 4.0);
13318        assert!(space.is_whitespace());
13319        assert_eq!(space.character_class(), CharacterClass::Space);
13320        assert!(!space.can_justify(), "whitespace is never itself justified");
13321        assert_eq!(space.justification_priority(), 0);
13322        assert!(space.break_opportunity_after());
13323
13324        let letter = plain_glyph('a', 8.0);
13325        assert!(!letter.is_whitespace());
13326        assert!(letter.can_justify());
13327        assert_eq!(letter.justification_priority(), 192);
13328        assert!(!letter.break_opportunity_after());
13329
13330        let combining = plain_glyph('\u{0301}', 0.0);
13331        assert!(!combining.is_whitespace());
13332        assert!(!combining.can_justify(), "combining marks are never justified");
13333        assert_eq!(combining.justification_priority(), 255);
13334    }
13335
13336    #[test]
13337    fn glyph_break_opportunity_after_covers_every_hyphen_form() {
13338        assert!(plain_glyph('\u{00AD}', 0.0).break_opportunity_after(), "soft hyphen");
13339        assert!(plain_glyph('\u{002D}', 4.0).break_opportunity_after(), "hyphen-minus");
13340        assert!(plain_glyph('\u{2010}', 4.0).break_opportunity_after(), "U+2010");
13341        assert!(plain_glyph('\t', 8.0).break_opportunity_after(), "tab is whitespace");
13342        assert!(!plain_glyph('\u{2011}', 4.0).break_opportunity_after(), "NON-BREAKING hyphen");
13343        assert!(!plain_glyph('/', 4.0).break_opportunity_after());
13344    }
13345
13346    // =====================================================================
13347    // getter/predicate: ShapedItem + item helpers
13348    // =====================================================================
13349
13350    #[test]
13351    fn shaped_item_as_cluster_only_matches_clusters() {
13352        assert!(cl("a", 8.0).as_cluster().is_some());
13353        assert!(obj(10.0, 10.0, 0.0).as_cluster().is_none());
13354        assert!(brk().as_cluster().is_none());
13355        assert!(tab(8.0, 16.0).as_cluster().is_none());
13356    }
13357
13358    #[test]
13359    fn shaped_item_bounds_of_a_break_is_the_zero_rect() {
13360        assert_eq!(brk().bounds(), Rect::default());
13361        assert_eq!(obj(10.0, 20.0, 0.0).bounds().width, 10.0);
13362        assert_eq!(tab(8.0, 16.0).bounds().height, 16.0);
13363        let c = cl("a", 9.5);
13364        assert_eq!(c.bounds().width, 9.5, "a cluster's width is its advance");
13365        approx(c.bounds().height, 16.0); // ascent + descent of the fixture font
13366    }
13367
13368    #[test]
13369    fn get_item_measure_sums_advance_and_kerning() {
13370        let st = style();
13371        let mut g1 = shaped_glyph(st.clone(), std_metrics(), 8.0);
13372        g1.kerning = -1.5;
13373        let mut g2 = shaped_glyph(st.clone(), std_metrics(), 8.0);
13374        g2.kerning = 0.5;
13375        let item = make_cluster("ab", 16.0, st, smallvec![g1, g2], gid(0, 0));
13376        assert_eq!(get_item_measure(&item, false), 15.0, "16 + (-1.5) + 0.5");
13377        assert_eq!(
13378            get_item_measure(&item, true),
13379            15.0,
13380            "clusters ignore the is_vertical flag (advance is already axis-relative)"
13381        );
13382    }
13383
13384    #[test]
13385    fn get_item_measure_of_a_break_is_zero_and_objects_switch_axis() {
13386        assert_eq!(get_item_measure(&brk(), false), 0.0);
13387        assert_eq!(get_item_measure(&brk(), true), 0.0);
13388        let o = obj(30.0, 20.0, 0.0);
13389        assert_eq!(get_item_measure(&o, false), 30.0);
13390        assert_eq!(get_item_measure(&o, true), 20.0);
13391    }
13392
13393    #[test]
13394    fn get_item_measure_with_spacing_adds_letter_spacing_but_not_for_cursive() {
13395        let st = styled(|s| s.letter_spacing = Spacing::PxF(2.0));
13396        let latin = cl_styled("a", 10.0, st.clone());
13397        assert_eq!(get_item_measure(&latin, false), 10.0);
13398        assert_eq!(get_item_measure_with_spacing(&latin, false), 12.0);
13399
13400        // Cursive (Arabic) clusters must never receive letter-spacing.
13401        let arabic = cl_styled("\u{0627}", 10.0, st);
13402        assert_eq!(
13403            get_item_measure_with_spacing(&arabic, false),
13404            10.0,
13405            "letter-spacing is suppressed for cursive scripts (CSS Text 3 App. D)"
13406        );
13407    }
13408
13409    #[test]
13410    fn get_item_measure_with_spacing_adds_word_spacing_only_on_separators() {
13411        let st = styled(|s| {
13412            s.word_spacing = Spacing::PxF(5.0);
13413            s.letter_spacing = Spacing::PxF(1.0);
13414        });
13415        let space = cl_styled(" ", 4.0, st.clone());
13416        assert_eq!(
13417            get_item_measure_with_spacing(&space, false),
13418            10.0,
13419            "4 + letter(1) + word(5)"
13420        );
13421        let letter = cl_styled("a", 8.0, st);
13422        assert_eq!(
13423            get_item_measure_with_spacing(&letter, false),
13424            9.0,
13425            "no word-spacing on a non-separator"
13426        );
13427        // Non-cluster items get no spacing at all.
13428        assert_eq!(get_item_measure_with_spacing(&brk(), false), 0.0);
13429    }
13430
13431    #[test]
13432    fn is_collapsible_whitespace_is_vacuously_true_for_an_empty_cluster() {
13433        assert!(is_collapsible_whitespace(&cl(" ", 4.0)));
13434        assert!(is_collapsible_whitespace(&cl("\t", 8.0)));
13435        assert!(is_collapsible_whitespace(&cl("\u{1680}", 4.0)));
13436        assert!(is_collapsible_whitespace(&cl("  \t ", 16.0)));
13437        assert!(!is_collapsible_whitespace(&cl("\n", 0.0)), "newline is NOT collapsible here");
13438        assert!(!is_collapsible_whitespace(&cl("a", 8.0)));
13439        assert!(!is_collapsible_whitespace(&cl("a ", 12.0)), "all() — mixed is false");
13440        assert!(!is_collapsible_whitespace(&obj(1.0, 1.0, 0.0)));
13441        // QUIRK: `chars().all(..)` on an empty string is vacuously TRUE, so a
13442        // zero-text cluster is treated as strippable whitespace at line edges.
13443        assert!(
13444            is_collapsible_whitespace(&cl("", 0.0)),
13445            "an empty cluster counts as collapsible whitespace"
13446        );
13447    }
13448
13449    #[test]
13450    fn is_word_separator_and_zero_width_space_on_items() {
13451        assert!(is_word_separator(&cl(" ", 4.0)));
13452        assert!(is_word_separator(&cl("a b", 20.0)), "any() — one space suffices");
13453        assert!(!is_word_separator(&cl("", 0.0)), "any() on empty is false");
13454        assert!(!is_word_separator(&cl("\u{3000}", 16.0)));
13455        assert!(!is_word_separator(&brk()));
13456        assert!(!is_word_separator(&obj(1.0, 1.0, 0.0)));
13457
13458        assert!(is_zero_width_space(&cl("\u{200B}", 0.0)));
13459        assert!(is_zero_width_space(&cl("a\u{200B}", 8.0)), "contains(), not equals()");
13460        assert!(!is_zero_width_space(&cl(" ", 4.0)));
13461        assert!(!is_zero_width_space(&obj(1.0, 1.0, 0.0)));
13462    }
13463
13464    #[test]
13465    fn can_justify_after_rejects_objects_empty_clusters_and_combining_marks() {
13466        assert!(can_justify_after(&cl("a", 8.0)));
13467        assert!(!can_justify_after(&cl(" ", 4.0)));
13468        assert!(!can_justify_after(&cl("a\u{0301}", 8.0)), "trailing combining mark");
13469        assert!(!can_justify_after(&cl("", 0.0)), "no last char → false");
13470        assert!(
13471            !can_justify_after(&obj(10.0, 10.0, 0.0)),
13472            "CSS 2.2 §9.4.2: never stretch after an atomic inline"
13473        );
13474        assert!(!can_justify_after(&brk()));
13475    }
13476
13477    #[test]
13478    fn is_hanging_punctuation_requires_a_single_glyph_cluster() {
13479        assert!(is_hanging_punctuation(&cl(".", 4.0)));
13480        assert!(is_hanging_punctuation(&cl(",", 4.0)));
13481        assert!(!is_hanging_punctuation(&cl("a", 8.0)));
13482        assert!(!is_hanging_punctuation(&cl("", 0.0)), "no first char");
13483        assert!(!is_hanging_punctuation(&obj(1.0, 1.0, 0.0)));
13484
13485        // A two-glyph cluster is rejected even if it starts with a full stop.
13486        let st = style();
13487        let g1 = shaped_glyph(st.clone(), std_metrics(), 4.0);
13488        let g2 = shaped_glyph(st.clone(), std_metrics(), 4.0);
13489        let two = make_cluster(".", 8.0, st, smallvec![g1, g2], gid(0, 0));
13490        assert!(!is_hanging_punctuation(&two));
13491    }
13492
13493    #[test]
13494    fn cluster_script_predicates() {
13495        let arabic = cl("\u{0627}", 10.0);
13496        let latin = cl("a", 8.0);
13497        let cjk = cl("中", 16.0);
13498
13499        assert!(is_cursive_script_cluster(arabic.as_cluster().unwrap()));
13500        assert!(!is_cursive_script_cluster(latin.as_cluster().unwrap()));
13501        assert!(
13502            !is_cursive_script_cluster(cl("", 0.0).as_cluster().unwrap()),
13503            "empty cluster has no first char"
13504        );
13505
13506        assert!(is_cjk_cluster(cjk.as_cluster().unwrap()));
13507        assert!(!is_cjk_cluster(latin.as_cluster().unwrap()));
13508
13509        // is_arabic_cluster keys off the GLYPH script, not the text.
13510        assert!(
13511            !is_arabic_cluster(arabic.as_cluster().unwrap()),
13512            "the fixture's glyph carries Script::Latin, so the text alone is not enough"
13513        );
13514        let st = style();
13515        let mut g = shaped_glyph(st.clone(), std_metrics(), 10.0);
13516        g.script = Script::Arabic;
13517        let real_arabic = make_cluster("\u{0627}", 10.0, st, smallvec![g], gid(0, 0));
13518        assert!(is_arabic_cluster(real_arabic.as_cluster().unwrap()));
13519    }
13520
13521    #[test]
13522    fn cluster_is_word_boundary_for_punctuation_and_whitespace() {
13523        assert!(cluster_is_word_boundary(cl(" ", 4.0).as_cluster().unwrap()));
13524        assert!(cluster_is_word_boundary(cl(".", 4.0).as_cluster().unwrap()));
13525        assert!(cluster_is_word_boundary(cl("", 0.0).as_cluster().unwrap()), "vacuous");
13526        assert!(!cluster_is_word_boundary(cl("a", 8.0).as_cluster().unwrap()));
13527        assert!(!cluster_is_word_boundary(cl("_", 8.0).as_cluster().unwrap()));
13528    }
13529
13530    #[test]
13531    fn get_baseline_for_item_only_defined_for_clusters_and_boxes() {
13532        assert_eq!(get_baseline_for_item(&brk()), None);
13533        assert_eq!(get_baseline_for_item(&tab(8.0, 16.0)), None);
13534        assert_eq!(get_baseline_for_item(&obj(10.0, 20.0, 3.0)), Some(3.0));
13535        // Cluster: baseline of the LAST glyph, scaled to font size (800/1000*16).
13536        approx(
13537            get_baseline_for_item(&cl("a", 8.0)).expect("a glyph-bearing cluster has a baseline"),
13538            12.8,
13539        );
13540        assert_eq!(
13541            get_baseline_for_item(&cl_no_glyphs("", 0.0)),
13542            None,
13543            "a glyph-less cluster has no baseline"
13544        );
13545    }
13546
13547    #[test]
13548    fn get_item_vertical_metrics_approx_for_every_variant() {
13549        // Cluster with real glyphs: ascent 12.8, descent 3.2, no leading (lh == a+d).
13550        let (a, d) = get_item_vertical_metrics_approx(&cl("a", 8.0));
13551        approx(a, 12.8);
13552        approx(d, 3.2);
13553
13554        // Glyph-less cluster → 80/20 split of the fallback 1.2em line box.
13555        let (a, d) = get_item_vertical_metrics_approx(&cl_no_glyphs("", 0.0));
13556        approx(a, 19.2 * FALLBACK_ASCENT_RATIO);
13557        approx(d, 19.2 * FALLBACK_DESCENT_RATIO);
13558
13559        // Object → all ascent, no descent.
13560        assert_eq!(get_item_vertical_metrics_approx(&obj(10.0, 20.0, 5.0)), (20.0, 0.0));
13561        // Break → nothing.
13562        assert_eq!(get_item_vertical_metrics_approx(&brk()), (0.0, 0.0));
13563        // Tab → 80/20 of its box height.
13564        let (a, d) = get_item_vertical_metrics_approx(&tab(8.0, 10.0));
13565        approx(a, 8.0);
13566        approx(d, 2.0);
13567    }
13568
13569    #[test]
13570    fn get_item_vertical_metrics_approx_skips_zero_upem_glyphs() {
13571        let st = style();
13572        let g = shaped_glyph(st.clone(), metrics(0, 800.0, -200.0, 0.0), 8.0);
13573        let item = make_cluster("a", 8.0, st, smallvec![g], gid(0, 0));
13574        assert_eq!(
13575            get_item_vertical_metrics_approx(&item),
13576            (0.0, 0.0),
13577            "a zero-upem glyph is skipped rather than producing inf/NaN metrics"
13578        );
13579    }
13580
13581    #[test]
13582    fn get_item_vertical_metrics_uses_the_strut_for_glyphless_clusters() {
13583        let c = UnifiedConstraints::default();
13584        let (a, d) = get_item_vertical_metrics(&cl_no_glyphs("", 0.0), &c);
13585        // resolved lh = 1.2 * 16 = 19.2; a+d = 16.0; half-leading = 1.6
13586        approx(a, DEFAULT_STRUT_ASCENT + 1.6);
13587        approx(d, DEFAULT_STRUT_DESCENT + 1.6);
13588
13589        assert_eq!(get_item_vertical_metrics(&brk(), &c), (0.0, 0.0));
13590        // Objects clamp negative ascent/descent at 0.
13591        let (a, d) = get_item_vertical_metrics(&obj(10.0, 10.0, 30.0), &c);
13592        assert_eq!(a, 0.0, "baseline_offset > height must clamp the ascent at 0");
13593        assert_eq!(d, 30.0);
13594    }
13595
13596    #[test]
13597    fn get_item_vertical_align_only_for_objects_with_image_or_shape_content() {
13598        assert_eq!(get_item_vertical_align(&cl("a", 8.0)), None);
13599        assert_eq!(get_item_vertical_align(&brk()), None);
13600        // Our fixture Object carries a Space, which has no alignment.
13601        assert_eq!(get_item_vertical_align(&obj(1.0, 1.0, 0.0)), None);
13602
13603        let img = ShapedItem::Object {
13604            source: ci(0, 0),
13605            bounds: Rect::default(),
13606            baseline_offset: 0.0,
13607            content: InlineContent::Image(InlineImage {
13608                source: ImageSource::Placeholder(Size::new(10.0, 10.0)),
13609                intrinsic_size: Size::new(10.0, 10.0),
13610                display_size: None,
13611                baseline_offset: 0.0,
13612                alignment: VerticalAlign::Top,
13613                object_fit: ObjectFit::Fill,
13614            }),
13615        };
13616        assert_eq!(get_item_vertical_align(&img), Some(VerticalAlign::Top));
13617    }
13618
13619    // =====================================================================
13620    // predicate: break-opportunity logic
13621    // =====================================================================
13622
13623    #[test]
13624    fn no_break_space_is_a_word_separator_but_never_a_break_opportunity() {
13625        let nbsp = cl("\u{00A0}", 4.0);
13626        assert!(is_word_separator(&nbsp), "NBSP participates in word-spacing");
13627        assert!(
13628            !is_break_opportunity(&nbsp),
13629            "...but must NOT offer a soft wrap (10\\u{{00A0}}km must not wrap)"
13630        );
13631        assert!(!is_break_opportunity_with_word_break(
13632            &nbsp,
13633            WordBreak::BreakAll,
13634            Hyphens::Auto
13635        ));
13636        // Same for NNBSP / word joiner / ZWNBSP.
13637        for ch in ['\u{202F}', '\u{2060}', '\u{FEFF}'] {
13638            let item = cl(&ch.to_string(), 4.0);
13639            assert!(
13640                !is_break_opportunity_with_word_break(&item, WordBreak::BreakAll, Hyphens::Auto),
13641                "{ch:?} must suppress breaks"
13642            );
13643        }
13644    }
13645
13646    #[test]
13647    fn zero_width_space_breaks_even_under_keep_all() {
13648        let zwsp = cl("\u{200B}", 0.0);
13649        assert!(is_break_opportunity(&zwsp));
13650        for wb in [WordBreak::Normal, WordBreak::BreakAll, WordBreak::KeepAll] {
13651            assert!(
13652                is_break_opportunity_with_word_break(&zwsp, wb, Hyphens::None),
13653                "ZWSP must always break ({wb:?})"
13654            );
13655        }
13656    }
13657
13658    #[test]
13659    fn word_break_modes_change_cjk_break_opportunities() {
13660        let cjk = cl("中", 16.0);
13661        assert!(is_break_opportunity_with_word_break(&cjk, WordBreak::Normal, Hyphens::Manual));
13662        assert!(is_break_opportunity_with_word_break(&cjk, WordBreak::BreakAll, Hyphens::Manual));
13663        assert!(
13664            !is_break_opportunity_with_word_break(&cjk, WordBreak::KeepAll, Hyphens::Manual),
13665            "keep-all suppresses inter-ideograph breaks"
13666        );
13667
13668        let latin = cl("a", 8.0);
13669        assert!(!is_break_opportunity_with_word_break(&latin, WordBreak::Normal, Hyphens::Manual));
13670        assert!(
13671            is_break_opportunity_with_word_break(&latin, WordBreak::BreakAll, Hyphens::Manual),
13672            "break-all makes every cluster breakable"
13673        );
13674        assert!(!is_break_opportunity_with_word_break(&latin, WordBreak::KeepAll, Hyphens::Manual));
13675    }
13676
13677    #[test]
13678    fn soft_hyphen_break_depends_on_the_hyphens_property() {
13679        let shy = cl("\u{00AD}", 0.0);
13680        assert!(!is_break_opportunity_with_word_break(
13681            &shy,
13682            WordBreak::Normal,
13683            Hyphens::None
13684        ));
13685        assert!(is_break_opportunity_with_word_break(
13686            &shy,
13687            WordBreak::Normal,
13688            Hyphens::Manual
13689        ));
13690        assert!(is_break_opportunity_with_word_break(
13691            &shy,
13692            WordBreak::Normal,
13693            Hyphens::Auto
13694        ));
13695    }
13696
13697    #[test]
13698    fn trailing_hyphen_and_slash_always_break_regardless_of_hyphens() {
13699        for text in ["co-", "co\u{2010}", "a/"] {
13700            let item = cl(text, 10.0);
13701            assert!(
13702                is_break_opportunity_with_word_break(&item, WordBreak::KeepAll, Hyphens::None),
13703                "{text:?} must offer a break after it even with hyphens:none"
13704            );
13705        }
13706        // A LEADING hyphen is not a break opportunity after the cluster.
13707        assert!(!is_break_opportunity_with_word_break(
13708            &cl("-a", 10.0),
13709            WordBreak::Normal,
13710            Hyphens::None
13711        ));
13712    }
13713
13714    #[test]
13715    fn atomic_inlines_are_break_opportunities_but_breaks_and_tabs_differ() {
13716        assert!(is_break_opportunity(&obj(10.0, 10.0, 0.0)), "CSS Text 3 §5.1");
13717        assert!(is_break_opportunity(&brk()));
13718        assert!(!is_break_opportunity(&tab(8.0, 16.0)), "a Tab is not itself a wrap point");
13719        assert!(is_break_opportunity(&cl(" ", 4.0)));
13720        assert!(!is_break_opportunity(&cl("a", 8.0)));
13721    }
13722
13723    // =====================================================================
13724    // numeric: geometry scanline helpers
13725    // =====================================================================
13726
13727    #[test]
13728    fn merge_segments_of_zero_or_one_segment_is_identity() {
13729        assert!(merge_segments(Vec::new()).is_empty());
13730        let one = vec![LineSegment {
13731            start_x: 5.0,
13732            width: 3.0,
13733            priority: 0,
13734        }];
13735        let out = merge_segments(one);
13736        assert_eq!(out.len(), 1);
13737        assert_eq!(out[0].start_x, 5.0);
13738    }
13739
13740    #[test]
13741    fn merge_segments_joins_overlapping_and_touching_spans() {
13742        let segs = vec![
13743            LineSegment {
13744                start_x: 0.0,
13745                width: 10.0,
13746                priority: 0,
13747            },
13748            LineSegment {
13749                start_x: 5.0,
13750                width: 10.0,
13751                priority: 0,
13752            }, // overlaps
13753            LineSegment {
13754                start_x: 15.0,
13755                width: 5.0,
13756                priority: 0,
13757            }, // exactly adjacent
13758            LineSegment {
13759                start_x: 100.0,
13760                width: 5.0,
13761                priority: 0,
13762            }, // disjoint
13763        ];
13764        let out = merge_segments(segs);
13765        assert_eq!(out.len(), 2, "three touching spans collapse into one");
13766        assert_eq!(out[0].start_x, 0.0);
13767        assert_eq!(out[0].width, 20.0);
13768        assert_eq!(out[1].start_x, 100.0);
13769    }
13770
13771    #[test]
13772    fn merge_segments_sorts_unordered_input() {
13773        let segs = vec![
13774            LineSegment {
13775                start_x: 50.0,
13776                width: 5.0,
13777                priority: 0,
13778            },
13779            LineSegment {
13780                start_x: 0.0,
13781                width: 5.0,
13782                priority: 0,
13783            },
13784        ];
13785        let out = merge_segments(segs);
13786        assert_eq!(out.len(), 2);
13787        assert_eq!(out[0].start_x, 0.0);
13788        assert_eq!(out[1].start_x, 50.0);
13789    }
13790
13791    #[test]
13792    fn merge_segments_is_nan_tolerant() {
13793        // A NaN start_x used to abort layout via `partial_cmp(...).unwrap()`; the
13794        // sort now falls back to Equal (like every other float compare in this
13795        // module), so the merge completes without panicking.
13796        let segs = vec![
13797            LineSegment {
13798                start_x: 0.0,
13799                width: 10.0,
13800                priority: 0,
13801            },
13802            LineSegment {
13803                start_x: f32::NAN,
13804                width: 10.0,
13805                priority: 0,
13806            },
13807        ];
13808        let out = merge_segments(segs);
13809        assert!(!out.is_empty(), "NaN input must not abort the merge");
13810    }
13811
13812    #[test]
13813    fn polygon_line_intersection_needs_at_least_three_points() {
13814        assert!(polygon_line_intersection(&[], 0.0, 1.0).is_empty());
13815        assert!(polygon_line_intersection(&[Point { x: 0.0, y: 0.0 }], 0.0, 1.0).is_empty());
13816        assert!(polygon_line_intersection(
13817            &[Point { x: 0.0, y: 0.0 }, Point { x: 1.0, y: 1.0 }],
13818            0.0,
13819            1.0
13820        )
13821        .is_empty());
13822    }
13823
13824    #[test]
13825    fn polygon_line_intersection_narrows_across_a_triangle() {
13826        // Right triangle (0,0) - (100,0) - (0,100): span width ≈ 100 - y.
13827        let tri = [
13828            Point { x: 0.0, y: 0.0 },
13829            Point { x: 100.0, y: 0.0 },
13830            Point { x: 0.0, y: 100.0 },
13831        ];
13832        let top = polygon_line_intersection(&tri, 10.0, 1.0);
13833        let bot = polygon_line_intersection(&tri, 80.0, 1.0);
13834        assert_eq!(top.len(), 1);
13835        assert_eq!(bot.len(), 1);
13836        assert!(
13837            top[0].width > bot[0].width,
13838            "the band must narrow with y ({} !> {})",
13839            top[0].width,
13840            bot[0].width
13841        );
13842        assert!((top[0].width - 89.5).abs() < 1.0);
13843    }
13844
13845    #[test]
13846    fn polygon_line_intersection_outside_the_shape_and_on_nan_scanlines_is_empty() {
13847        let tri = [
13848            Point { x: 0.0, y: 0.0 },
13849            Point { x: 100.0, y: 0.0 },
13850            Point { x: 0.0, y: 100.0 },
13851        ];
13852        assert!(
13853            polygon_line_intersection(&tri, 500.0, 1.0).is_empty(),
13854            "a scanline below the shape yields no spans"
13855        );
13856        assert!(
13857            polygon_line_intersection(&tri, f32::NAN, 1.0).is_empty(),
13858            "a NaN scanline must not panic — every crossing test is false"
13859        );
13860        assert!(polygon_line_intersection(&tri, f32::INFINITY, 1.0).is_empty());
13861    }
13862
13863    #[test]
13864    fn polygon_line_intersection_of_a_degenerate_flat_polygon_is_empty() {
13865        // All edges horizontal → every edge is skipped.
13866        let flat = [
13867            Point { x: 0.0, y: 5.0 },
13868            Point { x: 10.0, y: 5.0 },
13869            Point { x: 20.0, y: 5.0 },
13870        ];
13871        assert!(polygon_line_intersection(&flat, 4.5, 1.0).is_empty());
13872    }
13873
13874    #[test]
13875    fn path_segments_line_intersection_on_empty_and_degenerate_input() {
13876        assert!(path_segments_line_intersection(&[], 0.0, 1.0).is_empty());
13877        // A lone MoveTo cannot form a subpath.
13878        assert!(path_segments_line_intersection(
13879            &[PathSegment::MoveTo(Point { x: 0.0, y: 0.0 })],
13880            0.0,
13881            1.0
13882        )
13883        .is_empty());
13884    }
13885
13886    #[test]
13887    fn path_segments_line_intersection_of_a_square() {
13888        let sq = vec![
13889            PathSegment::MoveTo(Point { x: 0.0, y: 0.0 }),
13890            PathSegment::LineTo(Point { x: 100.0, y: 0.0 }),
13891            PathSegment::LineTo(Point {
13892                x: 100.0,
13893                y: 100.0,
13894            }),
13895            PathSegment::LineTo(Point { x: 0.0, y: 100.0 }),
13896            PathSegment::Close,
13897        ];
13898        let spans = path_segments_line_intersection(&sq, 50.0, 1.0);
13899        assert_eq!(spans.len(), 1);
13900        assert!((spans[0].0 - 0.0).abs() < 0.01);
13901        assert!((spans[0].1 - 100.0).abs() < 0.01);
13902        // Outside the square vertically → nothing.
13903        assert!(path_segments_line_intersection(&sq, 500.0, 1.0).is_empty());
13904    }
13905
13906    #[test]
13907    fn get_shape_horizontal_spans_rectangle_only_when_the_line_box_overlaps() {
13908        let r = ShapeBoundary::Rectangle(Rect {
13909            x: 10.0,
13910            y: 20.0,
13911            width: 30.0,
13912            height: 40.0,
13913        });
13914        assert_eq!(get_shape_horizontal_spans(&r, 30.0, 10.0), vec![(10.0, 40.0)]);
13915        assert!(get_shape_horizontal_spans(&r, 0.0, 10.0).is_empty(), "above");
13916        assert!(get_shape_horizontal_spans(&r, 100.0, 10.0).is_empty(), "below");
13917        // Exactly touching the top edge: line [10,20) vs rect [20,60) → no overlap.
13918        assert!(get_shape_horizontal_spans(&r, 10.0, 10.0).is_empty());
13919        // A zero-height rect can never overlap.
13920        let flat = ShapeBoundary::Rectangle(Rect {
13921            x: 0.0,
13922            y: 0.0,
13923            width: 10.0,
13924            height: 0.0,
13925        });
13926        assert!(get_shape_horizontal_spans(&flat, 0.0, 10.0).is_empty());
13927    }
13928
13929    #[test]
13930    fn get_shape_horizontal_spans_circle_edges_and_zero_radius() {
13931        let c = ShapeBoundary::Circle {
13932            center: Point { x: 50.0, y: 50.0 },
13933            radius: 10.0,
13934        };
13935        let mid = get_shape_horizontal_spans(&c, 49.5, 1.0); // line centre == 50.0
13936        assert_eq!(mid.len(), 1);
13937        assert!((mid[0].0 - 40.0).abs() < 0.01);
13938        assert!((mid[0].1 - 60.0).abs() < 0.01);
13939        assert!(get_shape_horizontal_spans(&c, 1000.0, 1.0).is_empty());
13940
13941        // Zero radius: the scanline exactly through the centre yields a zero-width span.
13942        let dot = ShapeBoundary::Circle {
13943            center: Point { x: 5.0, y: 5.0 },
13944            radius: 0.0,
13945        };
13946        let spans = get_shape_horizontal_spans(&dot, 4.5, 1.0);
13947        assert_eq!(spans, vec![(5.0, 5.0)], "degenerate but not a panic");
13948    }
13949
13950    #[test]
13951    fn get_shape_horizontal_spans_ellipse_with_zero_radii_is_empty_not_a_panic() {
13952        // radii.height == 0 → dy/0 → NaN → `NaN.abs() <= 0.0` is false → no spans.
13953        let e = ShapeBoundary::Ellipse {
13954            center: Point { x: 0.0, y: 0.0 },
13955            radii: Size::zero(),
13956        };
13957        assert!(
13958            get_shape_horizontal_spans(&e, 0.0, 1.0).is_empty(),
13959            "a zero-sized ellipse divides by zero but must not panic"
13960        );
13961    }
13962
13963    #[test]
13964    fn get_shape_horizontal_spans_polygon_delegates_to_the_scanline() {
13965        let p = ShapeBoundary::Polygon {
13966            points: vec![
13967                Point { x: 0.0, y: 0.0 },
13968                Point { x: 100.0, y: 0.0 },
13969                Point { x: 0.0, y: 100.0 },
13970            ],
13971        };
13972        let spans = get_shape_horizontal_spans(&p, 10.0, 1.0);
13973        assert_eq!(spans.len(), 1);
13974        assert!(spans[0].1 > spans[0].0);
13975    }
13976
13977    // =====================================================================
13978    // numeric/other: extract_line_breaks + try_incremental_relayout
13979    // =====================================================================
13980
13981    #[test]
13982    fn extract_line_breaks_of_no_items_is_empty_but_keeps_the_width() {
13983        let lb = extract_line_breaks(&[], 640.0);
13984        assert!(lb.line_ranges.is_empty());
13985        assert!(lb.line_widths.is_empty());
13986        assert_eq!(lb.available_width, 640.0);
13987        // Even a NaN constraint round-trips untouched.
13988        let nan = extract_line_breaks(&[], f32::NAN);
13989        assert!(nan.available_width.is_nan());
13990    }
13991
13992    #[test]
13993    fn extract_line_breaks_groups_items_by_line_index() {
13994        let items = vec![
13995            pos(cl("a", 10.0), 0.0, 0.0, 0),
13996            pos(cl("b", 10.0), 10.0, 0.0, 0),
13997            pos(cl("c", 10.0), 0.0, 20.0, 1),
13998        ];
13999        let lb = extract_line_breaks(&items, 100.0);
14000        assert_eq!(lb.line_ranges, vec![(0, 2), (2, 3)]);
14001        assert_eq!(lb.line_widths, vec![20.0, 10.0]);
14002        assert_eq!(lb.line_ranges.len(), lb.line_widths.len());
14003    }
14004
14005    #[test]
14006    fn extract_line_breaks_splits_on_every_line_index_change_even_going_backwards() {
14007        // The scanner is purely edge-triggered: a non-monotonic line_index sequence
14008        // produces THREE ranges, not two. Pinned so a reorder-tolerant rewrite is visible.
14009        let items = vec![
14010            pos(cl("a", 10.0), 0.0, 0.0, 0),
14011            pos(cl("b", 10.0), 0.0, 20.0, 1),
14012            pos(cl("c", 10.0), 0.0, 0.0, 0),
14013        ];
14014        let lb = extract_line_breaks(&items, 100.0);
14015        assert_eq!(lb.line_ranges.len(), 3);
14016        assert_eq!(lb.line_widths, vec![10.0, 10.0, 10.0]);
14017    }
14018
14019    #[test]
14020    fn try_incremental_relayout_no_dirty_items_is_a_glyph_swap() {
14021        let lb = CachedLineBreaks {
14022            line_ranges: vec![(0, 2)],
14023            line_widths: vec![20.0],
14024            available_width: 100.0,
14025        };
14026        assert!(matches!(
14027            try_incremental_relayout(&[], &[10.0, 10.0], &[10.0, 10.0], &lb),
14028            IncrementalRelayoutResult::GlyphSwap
14029        ));
14030    }
14031
14032    #[test]
14033    fn try_incremental_relayout_out_of_range_dirty_index_falls_back_to_full() {
14034        let lb = CachedLineBreaks {
14035            line_ranges: vec![(0, 2)],
14036            line_widths: vec![20.0],
14037            available_width: 100.0,
14038        };
14039        assert!(matches!(
14040            try_incremental_relayout(&[99], &[10.0, 10.0], &[10.0, 10.0], &lb),
14041            IncrementalRelayoutResult::FullRelayout
14042        ));
14043        assert!(
14044            matches!(
14045                try_incremental_relayout(&[usize::MAX], &[10.0], &[10.0], &lb),
14046                IncrementalRelayoutResult::FullRelayout
14047            ),
14048            "usize::MAX must not index-panic"
14049        );
14050        // Mismatched advance vectors are also caught by the bounds check.
14051        assert!(matches!(
14052            try_incremental_relayout(&[1], &[10.0, 10.0], &[10.0], &lb),
14053            IncrementalRelayoutResult::FullRelayout
14054        ));
14055    }
14056
14057    #[test]
14058    fn try_incremental_relayout_same_width_is_a_glyph_swap() {
14059        let lb = CachedLineBreaks {
14060            line_ranges: vec![(0, 2)],
14061            line_widths: vec![20.0],
14062            available_width: 100.0,
14063        };
14064        // Below the 0.001 epsilon → treated as unchanged.
14065        assert!(matches!(
14066            try_incremental_relayout(&[0], &[10.0, 10.0], &[10.0005, 10.0], &lb),
14067            IncrementalRelayoutResult::GlyphSwap
14068        ));
14069    }
14070
14071    #[test]
14072    fn try_incremental_relayout_shifts_when_it_still_fits_and_reflows_when_it_does_not() {
14073        let lb = CachedLineBreaks {
14074            line_ranges: vec![(0, 2), (2, 4)],
14075            line_widths: vec![20.0, 20.0],
14076            available_width: 100.0,
14077        };
14078        let old = [10.0, 10.0, 10.0, 10.0];
14079
14080        let grew = [10.0, 30.0, 10.0, 10.0]; // line 0 → 40 ≤ 100
14081        match try_incremental_relayout(&[1], &old, &grew, &lb) {
14082            IncrementalRelayoutResult::LineShift {
14083                affected_item,
14084                delta,
14085            } => {
14086                assert_eq!(affected_item, 1);
14087                assert_eq!(delta, 20.0);
14088            }
14089            other => panic!("expected LineShift, got {other:?}"),
14090        }
14091
14092        let exploded = [10.0, 10.0, 10.0, 500.0]; // line 1 → 510 > 100
14093        match try_incremental_relayout(&[3], &old, &exploded, &lb) {
14094            IncrementalRelayoutResult::PartialReflow { reflow_from_line } => {
14095                assert_eq!(reflow_from_line, 1);
14096            }
14097            other => panic!("expected PartialReflow, got {other:?}"),
14098        }
14099    }
14100
14101    #[test]
14102    fn try_incremental_relayout_dirty_item_outside_every_line_range_is_a_full_relayout() {
14103        let lb = CachedLineBreaks {
14104            line_ranges: vec![(0, 1)],
14105            line_widths: vec![10.0],
14106            available_width: 100.0,
14107        };
14108        // Item 1 exists in the advance arrays but is on no known line.
14109        assert!(matches!(
14110            try_incremental_relayout(&[1], &[10.0, 10.0], &[10.0, 50.0], &lb),
14111            IncrementalRelayoutResult::FullRelayout
14112        ));
14113    }
14114
14115    #[test]
14116    fn try_incremental_relayout_with_nan_advances_reflows_rather_than_shifting() {
14117        let lb = CachedLineBreaks {
14118            line_ranges: vec![(0, 1)],
14119            line_widths: vec![10.0],
14120            available_width: 100.0,
14121        };
14122        // delta = NaN: `NaN.abs() < 0.001` is false, and `NaN <= width` is false,
14123        // so we land in PartialReflow — a defined outcome, not a panic.
14124        match try_incremental_relayout(&[0], &[10.0], &[f32::NAN], &lb) {
14125            IncrementalRelayoutResult::PartialReflow { reflow_from_line } => {
14126                assert_eq!(reflow_from_line, 0);
14127            }
14128            other => panic!("NaN advance should reflow, got {other:?}"),
14129        }
14130    }
14131
14132    // =====================================================================
14133    // getter/other: TextShapingCache + TextCacheMemoryReport + calculate_id
14134    // =====================================================================
14135
14136    #[test]
14137    fn text_cache_memory_report_total_bytes_sums_only_the_byte_fields() {
14138        let r = TextCacheMemoryReport::default();
14139        assert_eq!(r.total_bytes(), 0);
14140
14141        let full = TextCacheMemoryReport {
14142            logical_items_entries: 1_000_000, // must NOT be counted
14143            logical_items_bytes: 1,
14144            visual_items_entries: 1_000_000, // must NOT be counted
14145            visual_items_bytes: 2,
14146            shaped_items_entries: 1_000_000, // must NOT be counted
14147            shaped_items_bytes: 4,
14148            shaped_glyph_bytes: 8,
14149            shaped_cluster_text_bytes: 16,
14150            per_item_shaped_entries: 1_000_000, // must NOT be counted
14151            per_item_shaped_bytes: 32,
14152        };
14153        assert_eq!(full.total_bytes(), 63, "1+2+4+8+16+32");
14154    }
14155
14156    #[test]
14157    fn text_shaping_cache_new_is_empty_and_reports_zero_bytes() {
14158        let c = TextShapingCache::new();
14159        let r = c.memory_report();
14160        assert_eq!(r.total_bytes(), 0);
14161        assert_eq!(r.logical_items_entries, 0);
14162        assert_eq!(r.per_item_shaped_entries, 0);
14163        assert_eq!(c.generation, 0);
14164        // Default must agree with new().
14165        let d = TextShapingCache::default();
14166        assert_eq!(d.memory_report().total_bytes(), 0);
14167    }
14168
14169    #[test]
14170    fn text_shaping_cache_begin_generation_is_idempotent_on_an_empty_cache() {
14171        let mut c = TextShapingCache::new();
14172        for expect in 1..=5_u64 {
14173            c.begin_generation();
14174            assert_eq!(c.generation, expect);
14175        }
14176        assert!(c.per_item_accessed.is_empty());
14177        assert!(c.per_item_shaped.is_empty());
14178    }
14179
14180    #[test]
14181    fn text_shaping_cache_begin_generation_evicts_unaccessed_per_item_entries() {
14182        let mut c = TextShapingCache::new();
14183        c.per_item_shaped.insert(
14184            1,
14185            Arc::new(PerItemShapedEntry {
14186                clusters: vec![cl("a", 8.0)],
14187                total_advance: 8.0,
14188            }),
14189        );
14190        c.per_item_shaped.insert(
14191            2,
14192            Arc::new(PerItemShapedEntry {
14193                clusters: Vec::new(),
14194                total_advance: 0.0,
14195            }),
14196        );
14197        // Generation 0 → the eviction guard is skipped entirely.
14198        c.begin_generation();
14199        assert_eq!(c.per_item_shaped.len(), 2, "gen 0 never evicts");
14200
14201        // Touch only key 1, then roll the generation: key 2 must be dropped.
14202        c.per_item_accessed.insert(1);
14203        c.begin_generation();
14204        assert_eq!(c.per_item_shaped.len(), 1);
14205        assert!(c.per_item_shaped.contains_key(&1));
14206
14207        // Nothing accessed this generation → the retain is skipped (NOT a full flush).
14208        c.begin_generation();
14209        assert_eq!(
14210            c.per_item_shaped.len(),
14211            1,
14212            "an empty access-set must not wipe the cache"
14213        );
14214    }
14215
14216    #[test]
14217    fn use_old_layout_accepts_a_render_only_change_and_rejects_layout_changes() {
14218        let c = UnifiedConstraints::default();
14219        let red = styled(|s| {
14220            s.color = ColorU {
14221                r: 255,
14222                g: 0,
14223                b: 0,
14224                a: 255,
14225            };
14226        });
14227        let old = [text_content("hi", style())];
14228        let new_colour = [text_content("hi", red)];
14229        assert!(
14230            TextShapingCache::use_old_layout(&c, &c, &old, &new_colour),
14231            "a colour-only change must reuse the cached layout"
14232        );
14233
14234        // Different text → no reuse.
14235        let new_text = [text_content("ho", style())];
14236        assert!(!TextShapingCache::use_old_layout(&c, &c, &old, &new_text));
14237
14238        // Different font size → no reuse.
14239        let bigger = [text_content("hi", styled(|s| s.font_size_px = 32.0))];
14240        assert!(!TextShapingCache::use_old_layout(&c, &c, &old, &bigger));
14241
14242        // Different constraints → no reuse.
14243        let c2 = UnifiedConstraints {
14244            available_width: AvailableSpace::Definite(100.0),
14245            ..Default::default()
14246        };
14247        assert!(!TextShapingCache::use_old_layout(&c, &c2, &old, &old));
14248    }
14249
14250    #[test]
14251    fn use_old_layout_on_empty_content_and_length_or_variant_mismatch() {
14252        let c = UnifiedConstraints::default();
14253        assert!(
14254            TextShapingCache::use_old_layout(&c, &c, &[], &[]),
14255            "empty vs empty is trivially reusable"
14256        );
14257        let one = [text_content("a", style())];
14258        assert!(!TextShapingCache::use_old_layout(&c, &c, &[], &one));
14259        assert!(!TextShapingCache::use_old_layout(&c, &c, &one, &[]));
14260
14261        // Same length, different variant.
14262        let space = [InlineContent::Space(InlineSpace {
14263            width: 4.0,
14264            is_breaking: true,
14265            is_stretchy: true,
14266        })];
14267        assert!(!TextShapingCache::use_old_layout(&c, &c, &one, &space));
14268        assert!(TextShapingCache::use_old_layout(&c, &c, &space, &space));
14269    }
14270
14271    #[test]
14272    fn inline_content_layout_eq_recurses_into_ruby() {
14273        let ruby = |base: &str| InlineContent::Ruby {
14274            base: vec![text_content(base, style())],
14275            text: vec![text_content("ふり", style())],
14276            style: style(),
14277        };
14278        assert!(TextShapingCache::inline_content_layout_eq(
14279            &ruby("漢"),
14280            &ruby("漢")
14281        ));
14282        assert!(!TextShapingCache::inline_content_layout_eq(
14283            &ruby("漢"),
14284            &ruby("字")
14285        ));
14286    }
14287
14288    #[test]
14289    fn calculate_id_is_deterministic_and_discriminating() {
14290        assert_eq!(calculate_id(&"abc"), calculate_id(&"abc"));
14291        assert_ne!(calculate_id(&"abc"), calculate_id(&"abd"));
14292        assert_eq!(calculate_id(&0_u64), calculate_id(&0_u64));
14293        assert_ne!(calculate_id(&0_u64), calculate_id(&u64::MAX));
14294        // Empty input must still produce a stable id (not a panic / not zero-by-accident).
14295        let e: Vec<u8> = Vec::new();
14296        assert_eq!(calculate_id(&e), calculate_id(&Vec::<u8>::new()));
14297    }
14298
14299    #[test]
14300    fn shaped_items_key_new_on_empty_visual_items_is_stable() {
14301        let a = ShapedItemsKey::new(7, &[]);
14302        let b = ShapedItemsKey::new(7, &[]);
14303        assert_eq!(a, b);
14304        assert_eq!(hash_of(&a), hash_of(&b));
14305        // The cache id participates in identity.
14306        assert_ne!(a, ShapedItemsKey::new(8, &[]));
14307    }
14308
14309    #[test]
14310    fn shaped_items_key_new_hashes_the_text_styles() {
14311        let vi = |st: Arc<StyleProperties>| VisualItem {
14312            logical_source: LogicalItem::Text {
14313                source: ci(0, 0),
14314                text: "a".to_string(),
14315                style: st,
14316                marker_position_outside: None,
14317                source_node_id: None,
14318            },
14319            bidi_level: BidiLevel::new(0),
14320            script: Script::Latin,
14321            text: "a".to_string(),
14322            run_byte_offset: 0,
14323        };
14324        let base = ShapedItemsKey::new(1, &[vi(style())]);
14325        let same = ShapedItemsKey::new(1, &[vi(style())]);
14326        let other = ShapedItemsKey::new(1, &[vi(styled(|s| s.font_size_px = 32.0))]);
14327        assert_eq!(base, same);
14328        assert_ne!(base.style_hash, other.style_hash, "font size must change the key");
14329    }
14330
14331    // =====================================================================
14332    // getter/predicate: OverflowInfo + UnifiedLayout
14333    // =====================================================================
14334
14335    #[test]
14336    fn overflow_info_default_has_no_overflow() {
14337        let o = OverflowInfo::default();
14338        assert!(!o.has_overflow());
14339        assert_eq!(o.unclipped_bounds, Rect::default());
14340
14341        let with = OverflowInfo {
14342            overflow_items: vec![cl("a", 8.0)],
14343            unclipped_bounds: Rect::default(),
14344        };
14345        assert!(with.has_overflow());
14346    }
14347
14348    fn layout_of(items: Vec<PositionedItem>) -> UnifiedLayout {
14349        UnifiedLayout {
14350            items,
14351            overflow: OverflowInfo::default(),
14352        }
14353    }
14354
14355    #[test]
14356    fn unified_layout_empty_is_inert_across_every_accessor() {
14357        let l = layout_of(Vec::new());
14358        assert!(l.is_empty());
14359        assert_eq!(l.bounds(), Rect::default());
14360        assert_eq!(l.first_baseline(), None);
14361        assert_eq!(l.last_baseline(), None);
14362        assert_eq!(l.get_first_cluster_cursor(), None);
14363        assert_eq!(l.get_last_cluster_cursor(), None);
14364        assert!(l.grapheme_stops().is_empty());
14365        assert_eq!(
14366            l.hittest_cursor(LogicalPosition { x: 0.0, y: 0.0 }),
14367            None,
14368            "hit-testing an empty layout must return None, not index [0]"
14369        );
14370        assert_eq!(
14371            l.hittest_cursor(LogicalPosition {
14372                x: f32::NAN,
14373                y: f32::NAN
14374            }),
14375            None
14376        );
14377    }
14378
14379    #[test]
14380    fn unified_layout_bounds_spans_all_items() {
14381        let l = layout_of(vec![
14382            pos(cl("a", 10.0), 0.0, 0.0, 0),
14383            pos(cl("b", 10.0), 90.0, 20.0, 1),
14384        ]);
14385        let b = l.bounds();
14386        assert_eq!(b.x, 0.0);
14387        assert_eq!(b.y, 0.0);
14388        assert_eq!(b.width, 100.0, "0 → 90+10");
14389        approx(b.height, 36.0); // 0 → 20 + the 16px line box
14390        assert!(!l.is_empty());
14391    }
14392
14393    #[test]
14394    fn unified_layout_baselines_skip_breaks_and_tabs() {
14395        let l = layout_of(vec![
14396            pos(brk(), 0.0, 0.0, 0),
14397            pos(cl("a", 10.0), 0.0, 0.0, 0),
14398            pos(obj(10.0, 20.0, 5.0), 10.0, 0.0, 0),
14399            pos(tab(8.0, 16.0), 20.0, 0.0, 0),
14400        ]);
14401        approx(
14402            l.first_baseline().expect("the cluster, not the break"),
14403            12.8,
14404        );
14405        assert_eq!(l.last_baseline(), Some(5.0), "the object, not the tab");
14406    }
14407
14408    #[test]
14409    fn unified_layout_cluster_cursors_skip_non_clusters() {
14410        let l = layout_of(vec![
14411            pos(brk(), 0.0, 0.0, 0),
14412            pos(cl_at("a", 10.0, 0, 0), 0.0, 0.0, 0),
14413            pos(cl_at("b", 10.0, 0, 1), 10.0, 0.0, 0),
14414            pos(tab(8.0, 16.0), 20.0, 0.0, 0),
14415        ]);
14416        assert_eq!(
14417            l.get_first_cluster_cursor(),
14418            Some(TextCursor {
14419                cluster_id: gid(0, 0),
14420                affinity: CursorAffinity::Leading
14421            })
14422        );
14423        assert_eq!(
14424            l.get_last_cluster_cursor(),
14425            Some(TextCursor {
14426                cluster_id: gid(0, 1),
14427                affinity: CursorAffinity::Trailing
14428            })
14429        );
14430
14431        // A layout with no clusters at all has no cursors.
14432        let no_clusters = layout_of(vec![pos(brk(), 0.0, 0.0, 0)]);
14433        assert_eq!(no_clusters.get_first_cluster_cursor(), None);
14434        assert_eq!(no_clusters.get_last_cluster_cursor(), None);
14435    }
14436
14437    #[test]
14438    fn unified_layout_grapheme_stops_sorts_dedups_and_folds_combining_marks() {
14439        // Deliberately out of order, with a duplicate id and a combining mark.
14440        let l = layout_of(vec![
14441            pos(cl_at("b", 10.0, 0, 1), 10.0, 0.0, 0),
14442            pos(cl_at("a", 10.0, 0, 0), 0.0, 0.0, 0),
14443            pos(cl_at("a", 10.0, 0, 0), 0.0, 0.0, 0), // duplicate id
14444            pos(cl_at("\u{0301}", 0.0, 0, 2), 20.0, 0.0, 0), // combining acute
14445        ]);
14446        let stops = l.grapheme_stops();
14447        assert_eq!(
14448            stops,
14449            vec![gid(0, 0), gid(0, 1)],
14450            "sorted, de-duplicated, with the combining mark folded away"
14451        );
14452    }
14453
14454    #[test]
14455    fn unified_layout_cluster_is_grapheme_continuation() {
14456        assert!(UnifiedLayout::cluster_is_grapheme_continuation("\u{0301}"));
14457        assert!(UnifiedLayout::cluster_is_grapheme_continuation("\u{FE0F}"), "VS-16");
14458        assert!(!UnifiedLayout::cluster_is_grapheme_continuation("a"));
14459        assert!(!UnifiedLayout::cluster_is_grapheme_continuation("中"));
14460        assert!(
14461            !UnifiedLayout::cluster_is_grapheme_continuation(""),
14462            "an empty cluster must return false, not panic"
14463        );
14464    }
14465
14466    #[test]
14467    fn unified_layout_grapheme_caret_offset_maps_affinity_and_gaps() {
14468        let stops = [gid(0, 0), gid(0, 1), gid(0, 2)];
14469        assert_eq!(
14470            UnifiedLayout::grapheme_caret_offset(
14471                &stops,
14472                &TextCursor {
14473                    cluster_id: gid(0, 0),
14474                    affinity: CursorAffinity::Leading
14475                }
14476            ),
14477            Some(0)
14478        );
14479        assert_eq!(
14480            UnifiedLayout::grapheme_caret_offset(
14481                &stops,
14482                &TextCursor {
14483                    cluster_id: gid(0, 2),
14484                    affinity: CursorAffinity::Trailing
14485                }
14486            ),
14487            Some(3),
14488            "the document end is len, i.e. one past the last stop"
14489        );
14490        // A cursor addressing a folded mark snaps back to the preceding stop.
14491        assert_eq!(
14492            UnifiedLayout::grapheme_caret_offset(
14493                &stops,
14494                &TextCursor {
14495                    cluster_id: gid(0, 99),
14496                    affinity: CursorAffinity::Leading
14497                }
14498            ),
14499            Some(2)
14500        );
14501        // A cursor before every stop has no offset.
14502        assert_eq!(
14503            UnifiedLayout::grapheme_caret_offset(
14504                &[gid(5, 5)],
14505                &TextCursor {
14506                    cluster_id: gid(0, 0),
14507                    affinity: CursorAffinity::Leading
14508                }
14509            ),
14510            None
14511        );
14512        // Empty stop list → None, no panic.
14513        assert_eq!(
14514            UnifiedLayout::grapheme_caret_offset(
14515                &[],
14516                &TextCursor {
14517                    cluster_id: gid(0, 0),
14518                    affinity: CursorAffinity::Leading
14519                }
14520            ),
14521            None
14522        );
14523    }
14524
14525    #[test]
14526    fn unified_layout_cursor_from_grapheme_offset_clamps_past_the_end() {
14527        let stops = [gid(0, 0), gid(0, 1)];
14528        assert_eq!(
14529            UnifiedLayout::cursor_from_grapheme_offset(&stops, 0),
14530            TextCursor {
14531                cluster_id: gid(0, 0),
14532                affinity: CursorAffinity::Leading
14533            }
14534        );
14535        assert_eq!(
14536            UnifiedLayout::cursor_from_grapheme_offset(&stops, 1),
14537            TextCursor {
14538                cluster_id: gid(0, 1),
14539                affinity: CursorAffinity::Leading
14540            }
14541        );
14542        // offset == len and anything beyond clamp to Trailing-on-last.
14543        let end = TextCursor {
14544            cluster_id: gid(0, 1),
14545            affinity: CursorAffinity::Trailing,
14546        };
14547        assert_eq!(UnifiedLayout::cursor_from_grapheme_offset(&stops, 2), end);
14548        assert_eq!(
14549            UnifiedLayout::cursor_from_grapheme_offset(&stops, usize::MAX),
14550            end,
14551            "usize::MAX must clamp, not overflow"
14552        );
14553    }
14554
14555    #[test]
14556    #[should_panic]
14557    fn unified_layout_cursor_from_grapheme_offset_panics_on_an_empty_stop_list() {
14558        // FINDING: with `stops == []`, `offset >= n` is true for offset 0 and the
14559        // function indexes `stops[n - 1]` → `0usize - 1`. Every public caller happens
14560        // to guard `stops.is_empty()` first, so this is latent, not live — but the
14561        // helper itself has no guard.
14562        let _ = UnifiedLayout::cursor_from_grapheme_offset(&[], 0);
14563    }
14564
14565    #[test]
14566    fn unified_layout_cursor_motion_on_an_empty_layout_returns_the_cursor_unchanged() {
14567        let l = layout_of(Vec::new());
14568        let c = TextCursor {
14569            cluster_id: gid(0, 0),
14570            affinity: CursorAffinity::Leading,
14571        };
14572        let mut dbg = None;
14573        assert_eq!(l.move_cursor_left(c, &mut dbg), c);
14574        assert_eq!(l.move_cursor_right(c, &mut dbg), c);
14575        assert_eq!(l.move_cursor_to_line_start(c, &mut dbg), c);
14576        assert_eq!(l.move_cursor_to_line_end(c, &mut dbg), c);
14577        assert_eq!(l.move_cursor_to_prev_word(c, &mut dbg), c);
14578        assert_eq!(l.move_cursor_to_next_word(c, &mut dbg), c);
14579        let mut goal = None;
14580        assert_eq!(l.move_cursor_up(c, &mut goal, &mut dbg), c);
14581        assert_eq!(l.move_cursor_down(c, &mut goal, &mut dbg), c);
14582    }
14583
14584    #[test]
14585    fn unified_layout_move_cursor_left_right_walk_one_grapheme_at_a_time() {
14586        let l = layout_of(vec![
14587            pos(cl_at("a", 10.0, 0, 0), 0.0, 0.0, 0),
14588            pos(cl_at("b", 10.0, 0, 1), 10.0, 0.0, 0),
14589            pos(cl_at("c", 10.0, 0, 2), 20.0, 0.0, 0),
14590        ]);
14591        let mut dbg = None;
14592        let start = TextCursor {
14593            cluster_id: gid(0, 0),
14594            affinity: CursorAffinity::Leading,
14595        };
14596
14597        // Left at the document start is a fixed point (saturating_sub).
14598        assert_eq!(l.move_cursor_left(start, &mut dbg), start);
14599
14600        // Right advances one stop at a time and reaches the document end.
14601        let c1 = l.move_cursor_right(start, &mut dbg);
14602        assert_eq!(c1.cluster_id, gid(0, 1));
14603        let c2 = l.move_cursor_right(c1, &mut dbg);
14604        assert_eq!(c2.cluster_id, gid(0, 2));
14605        let end = l.move_cursor_right(c2, &mut dbg);
14606        assert_eq!(end.cluster_id, gid(0, 2));
14607        assert_eq!(end.affinity, CursorAffinity::Trailing, "document end");
14608        // ...and is a fixed point there.
14609        assert_eq!(l.move_cursor_right(end, &mut dbg), end);
14610
14611        // Left from the end walks back symmetrically.
14612        assert_eq!(l.move_cursor_left(end, &mut dbg).cluster_id, gid(0, 2));
14613    }
14614
14615    #[test]
14616    fn unified_layout_hittest_cursor_picks_the_nearest_cluster_and_its_half() {
14617        let l = layout_of(vec![
14618            pos(cl_at("a", 10.0, 0, 0), 0.0, 0.0, 0),
14619            pos(cl_at("b", 10.0, 0, 1), 10.0, 0.0, 0),
14620        ]);
14621        let hit = |x: f32| l.hittest_cursor(LogicalPosition { x, y: 5.0 }).unwrap();
14622        assert_eq!(hit(1.0).cluster_id, gid(0, 0));
14623        assert_eq!(hit(1.0).affinity, CursorAffinity::Leading);
14624        assert_eq!(hit(9.0).affinity, CursorAffinity::Trailing, "right half of 'a'");
14625        assert_eq!(hit(11.0).cluster_id, gid(0, 1));
14626        // Far outside the layout still resolves to the nearest cluster, not None.
14627        assert_eq!(hit(-1000.0).cluster_id, gid(0, 0));
14628        assert_eq!(hit(1000.0).cluster_id, gid(0, 1));
14629    }
14630
14631    #[test]
14632    fn unified_layout_get_selection_rects_on_an_unknown_range_is_empty() {
14633        let l = layout_of(vec![pos(cl_at("a", 10.0, 0, 0), 0.0, 0.0, 0)]);
14634        let unknown = SelectionRange {
14635            start: TextCursor {
14636                cluster_id: gid(9, 9),
14637                affinity: CursorAffinity::Leading,
14638            },
14639            end: TextCursor {
14640                cluster_id: gid(9, 9),
14641                affinity: CursorAffinity::Trailing,
14642            },
14643        };
14644        assert!(l.get_selection_rects(&unknown).is_empty());
14645
14646        // A degenerate (collapsed) range over a real cluster must not panic.
14647        let collapsed = SelectionRange {
14648            start: TextCursor {
14649                cluster_id: gid(0, 0),
14650                affinity: CursorAffinity::Leading,
14651            },
14652            end: TextCursor {
14653                cluster_id: gid(0, 0),
14654                affinity: CursorAffinity::Leading,
14655            },
14656        };
14657        let _ = l.get_selection_rects(&collapsed);
14658    }
14659
14660    #[test]
14661    fn unified_layout_get_cursor_rect_for_known_and_unknown_cursors() {
14662        let l = layout_of(vec![pos(cl_at("a", 10.0, 0, 0), 5.0, 7.0, 0)]);
14663        let leading = l.get_cursor_rect(&TextCursor {
14664            cluster_id: gid(0, 0),
14665            affinity: CursorAffinity::Leading,
14666        });
14667        let r = leading.expect("the leading edge of a placed cluster must have a rect");
14668        assert_eq!(r.origin.x, 5.0);
14669        assert_eq!(r.origin.y, 7.0);
14670        assert_eq!(r.size.width, 1.0, "the caret is a 1px sliver");
14671
14672        // A cursor in a run that was never laid out has no rect.
14673        assert_eq!(
14674            l.get_cursor_rect(&TextCursor {
14675                cluster_id: gid(9, 0),
14676                affinity: CursorAffinity::Leading
14677            }),
14678            None
14679        );
14680        // An empty layout has no rect for anything.
14681        assert_eq!(
14682            layout_of(Vec::new()).get_cursor_rect(&TextCursor {
14683                cluster_id: gid(0, 0),
14684                affinity: CursorAffinity::Leading
14685            }),
14686            None
14687        );
14688    }
14689
14690    // =====================================================================
14691    // constructor/getter: BreakCursor
14692    // =====================================================================
14693
14694    #[test]
14695    fn break_cursor_new_on_an_empty_slice_is_both_at_start_and_done() {
14696        let items: Vec<ShapedItem> = Vec::new();
14697        let mut c = BreakCursor::new(&items);
14698        assert!(c.is_at_start());
14699        assert!(c.is_done());
14700        assert_eq!(c.word_break, WordBreak::Normal);
14701        assert_eq!(c.hyphens, Hyphens::default());
14702        assert_eq!(c.line_break, LineBreakStrictness::default());
14703        assert!(c.peek_next_unit().is_empty());
14704        assert!(c.peek_next_single_item().is_empty());
14705        assert!(c.drain_remaining().is_empty());
14706    }
14707
14708    #[test]
14709    fn break_cursor_with_word_break_stores_the_mode() {
14710        let items = vec![cl("a", 8.0)];
14711        let c = BreakCursor::with_word_break(&items, WordBreak::BreakAll);
14712        assert_eq!(c.word_break, WordBreak::BreakAll);
14713        assert!(c.is_at_start());
14714        assert!(!c.is_done());
14715    }
14716
14717    #[test]
14718    fn break_cursor_consume_zero_is_a_no_op() {
14719        let items = vec![cl("a", 8.0), cl("b", 8.0)];
14720        let mut c = BreakCursor::new(&items);
14721        c.consume(0);
14722        assert!(c.is_at_start());
14723        assert_eq!(c.next_item_index, 0);
14724    }
14725
14726    #[test]
14727    fn break_cursor_consume_advances_and_ends_the_stream() {
14728        let items = vec![cl("a", 8.0), cl("b", 8.0)];
14729        let mut c = BreakCursor::new(&items);
14730        c.consume(1);
14731        assert!(!c.is_at_start());
14732        assert!(!c.is_done());
14733        assert_eq!(c.peek_next_single_item().len(), 1);
14734        c.consume(1);
14735        assert!(c.is_done());
14736        assert!(c.peek_next_single_item().is_empty());
14737    }
14738
14739    #[test]
14740    fn break_cursor_over_consuming_past_the_end_still_reports_done() {
14741        let items = vec![cl("a", 8.0)];
14742        let mut c = BreakCursor::new(&items);
14743        c.consume(usize::MAX);
14744        assert!(c.is_done(), "an over-consume must not wrap around to not-done");
14745        assert!(
14746            c.drain_remaining().is_empty(),
14747            "drain_remaining is bounds-guarded"
14748        );
14749    }
14750
14751    #[test]
14752    #[should_panic]
14753    fn break_cursor_peek_next_unit_after_over_consuming_slices_out_of_bounds() {
14754        // FINDING: `consume()` does not clamp `next_item_index` to `items.len()`, and
14755        // `peek_next_unit` slices `self.items[self.next_item_index..]` unguarded (unlike
14756        // `peek_next_single_item` / `drain_remaining`, which both test `<  len`). A caller
14757        // that over-consumes and then peeks gets a slice-index panic instead of an empty unit.
14758        let items = vec![cl("a", 8.0)];
14759        let mut c = BreakCursor::new(&items);
14760        c.consume(5);
14761        let _ = c.peek_next_unit();
14762    }
14763
14764    #[test]
14765    fn break_cursor_drains_the_remainder_before_the_main_list() {
14766        let items = vec![cl("a", 8.0), cl("b", 8.0)];
14767        let mut c = BreakCursor::new(&items);
14768        c.partial_remainder = vec![cl("R", 8.0)];
14769        assert!(!c.is_at_start(), "a pending remainder means we are mid-stream");
14770        assert!(!c.is_done());
14771
14772        assert_eq!(
14773            c.peek_next_single_item()[0].as_cluster().unwrap().text,
14774            "R",
14775            "the remainder is served first"
14776        );
14777
14778        let drained = c.drain_remaining();
14779        assert_eq!(drained.len(), 3, "remainder + both queued items");
14780        assert_eq!(drained[0].as_cluster().unwrap().text, "R");
14781        assert!(c.is_done());
14782    }
14783
14784    #[test]
14785    fn break_cursor_is_done_is_false_while_a_remainder_is_pending() {
14786        let items: Vec<ShapedItem> = Vec::new();
14787        let mut c = BreakCursor::new(&items);
14788        c.partial_remainder = vec![cl("x", 8.0)];
14789        assert!(!c.is_done(), "the main list is exhausted but the remainder is not");
14790        c.consume(1);
14791        assert!(c.is_done());
14792    }
14793
14794    #[test]
14795    fn break_cursor_consume_spanning_remainder_and_main_list() {
14796        let items = vec![cl("a", 8.0), cl("b", 8.0), cl("c", 8.0)];
14797        let mut c = BreakCursor::new(&items);
14798        c.partial_remainder = vec![cl("R1", 8.0), cl("R2", 8.0)];
14799        // Eat both remainder items + one from the main list.
14800        c.consume(3);
14801        assert!(c.partial_remainder.is_empty());
14802        assert_eq!(c.next_item_index, 1);
14803        assert_eq!(c.peek_next_single_item()[0].as_cluster().unwrap().text, "b");
14804    }
14805
14806    #[test]
14807    fn break_cursor_peek_next_unit_returns_a_whole_word_then_the_space() {
14808        let items = vec![
14809            cl("h", 8.0),
14810            cl("i", 8.0),
14811            cl(" ", 4.0),
14812            cl("y", 8.0),
14813            cl("o", 8.0),
14814        ];
14815        let mut c = BreakCursor::new(&items);
14816        let word = c.peek_next_unit();
14817        assert_eq!(word.len(), 2, "the word stops at the space");
14818        assert_eq!(word[0].as_cluster().unwrap().text, "h");
14819
14820        c.consume(word.len());
14821        let space = c.peek_next_unit();
14822        assert_eq!(space.len(), 1, "a leading break opportunity is a unit on its own");
14823        assert_eq!(space[0].as_cluster().unwrap().text, " ");
14824
14825        c.consume(1);
14826        assert_eq!(c.peek_next_unit().len(), 2, "the trailing word");
14827    }
14828
14829    #[test]
14830    fn break_cursor_peek_next_unit_honours_break_all_and_keep_all() {
14831        let items = vec![cl("中", 16.0), cl("文", 16.0), cl("字", 16.0)];
14832
14833        let normal = BreakCursor::new(&items);
14834        assert_eq!(
14835            normal.peek_next_unit().len(),
14836            1,
14837            "word-break:normal — each ideograph is its own unit"
14838        );
14839
14840        let all = BreakCursor::with_word_break(&items, WordBreak::BreakAll);
14841        assert_eq!(all.peek_next_unit().len(), 1, "break-all — one cluster per unit");
14842
14843        let keep = BreakCursor::with_word_break(&items, WordBreak::KeepAll);
14844        assert_eq!(
14845            keep.peek_next_unit().len(),
14846            3,
14847            "keep-all — the whole CJK run is unbreakable"
14848        );
14849
14850        let latin = vec![cl("a", 8.0), cl("b", 8.0)];
14851        let latin_all = BreakCursor::with_word_break(&latin, WordBreak::BreakAll);
14852        assert_eq!(latin_all.peek_next_unit().len(), 1, "break-all splits Latin too");
14853    }
14854
14855    #[test]
14856    fn break_cursor_peek_next_unit_glues_across_a_word_joiner() {
14857        // Control: without a joiner the unit ends at the space.
14858        let plain = vec![cl("a", 8.0), cl(" ", 4.0), cl("b", 8.0)];
14859        let control = BreakCursor::new(&plain);
14860        assert_eq!(
14861            control.peek_next_unit().len(),
14862            1,
14863            "the unit normally stops before the space"
14864        );
14865
14866        // With a WORD JOINER (U+2060) in between, the break after it is suppressed,
14867        // so the space is pulled into the same unbreakable unit.
14868        let glued = vec![
14869            cl("a", 8.0),
14870            cl("\u{2060}", 0.0),
14871            cl(" ", 4.0),
14872            cl("b", 8.0),
14873        ];
14874        let c = BreakCursor::new(&glued);
14875        let unit = c.peek_next_unit();
14876        assert!(
14877            unit.len() > 1,
14878            "a word joiner must suppress the following break, got {} item(s)",
14879            unit.len()
14880        );
14881    }
14882
14883    #[test]
14884    fn break_cursor_peek_next_single_item_prefers_the_remainder() {
14885        let items = vec![cl("a", 8.0)];
14886        let mut c = BreakCursor::new(&items);
14887        assert_eq!(c.peek_next_single_item()[0].as_cluster().unwrap().text, "a");
14888        c.partial_remainder = vec![cl("R", 8.0)];
14889        assert_eq!(c.peek_next_single_item()[0].as_cluster().unwrap().text, "R");
14890        assert_eq!(
14891            c.peek_next_single_item().len(),
14892            1,
14893            "peek must never return more than one item"
14894        );
14895    }
14896
14897    // =====================================================================
14898    // constructor/getter: LoadedFonts
14899    // =====================================================================
14900
14901    fn tf(hash: u64) -> TestFont {
14902        TestFont { hash }
14903    }
14904
14905    #[test]
14906    fn loaded_fonts_new_is_empty_and_misses_every_lookup() {
14907        let lf: LoadedFonts<TestFont> = LoadedFonts::new();
14908        assert!(lf.is_empty());
14909        assert_eq!(lf.len(), 0);
14910        assert_eq!(lf.iter().count(), 0);
14911        assert!(lf.get(&FontId(0)).is_none());
14912        assert!(!lf.contains_key(&FontId(u128::MAX)));
14913        // Hash lookups at the numeric boundaries must miss, not panic.
14914        for h in [0_u64, 1, u64::MAX] {
14915            assert!(lf.get_by_hash(h).is_none());
14916            assert!(lf.get_font_id_by_hash(h).is_none());
14917            assert!(!lf.contains_hash(h));
14918        }
14919        // Default agrees with new().
14920        let d: LoadedFonts<TestFont> = LoadedFonts::default();
14921        assert!(d.is_empty());
14922    }
14923
14924    #[test]
14925    fn loaded_fonts_insert_indexes_by_id_and_by_hash() {
14926        let mut lf: LoadedFonts<TestFont> = LoadedFonts::new();
14927        lf.insert(FontId(1), tf(0xDEAD));
14928        assert_eq!(lf.len(), 1);
14929        assert!(!lf.is_empty());
14930        assert!(lf.contains_key(&FontId(1)));
14931        assert!(lf.contains_hash(0xDEAD));
14932        assert_eq!(lf.get(&FontId(1)).map(TestFont::get_hash), Some(0xDEAD));
14933        assert_eq!(lf.get_by_hash(0xDEAD).map(TestFont::get_hash), Some(0xDEAD));
14934        assert_eq!(lf.get_font_id_by_hash(0xDEAD), Some(&FontId(1)));
14935        assert!(lf.get_by_hash(0).is_none());
14936    }
14937
14938    #[test]
14939    fn loaded_fonts_zero_hash_is_a_valid_key_not_a_sentinel() {
14940        let mut lf: LoadedFonts<TestFont> = LoadedFonts::new();
14941        lf.insert(FontId(1), tf(0));
14942        assert!(lf.contains_hash(0), "hash 0 must be storable and findable");
14943        assert_eq!(lf.get_by_hash(0).map(TestFont::get_hash), Some(0));
14944    }
14945
14946    #[test]
14947    fn loaded_fonts_two_ids_sharing_a_hash_keep_only_the_last_reverse_mapping() {
14948        let mut lf: LoadedFonts<TestFont> = LoadedFonts::new();
14949        lf.insert(FontId(1), tf(7));
14950        lf.insert(FontId(2), tf(7));
14951        assert_eq!(lf.len(), 2, "both fonts are stored by id");
14952        assert_eq!(
14953            lf.get_font_id_by_hash(7),
14954            Some(&FontId(2)),
14955            "the reverse index keeps only the LAST id for a colliding hash"
14956        );
14957    }
14958
14959    #[test]
14960    fn loaded_fonts_replacing_a_font_id_leaves_a_stale_hash_mapping() {
14961        // FINDING (staleness, not a crash): `insert` never removes the OLD hash of a
14962        // replaced FontId, so the reverse index keeps pointing at that id forever.
14963        // A by-hash lookup for the evicted font therefore succeeds and returns the
14964        // WRONG font instead of None.
14965        let mut lf: LoadedFonts<TestFont> = LoadedFonts::new();
14966        lf.insert(FontId(1), tf(100));
14967        lf.insert(FontId(1), tf(200)); // same id, new hash
14968        assert_eq!(lf.len(), 1, "the font map correctly holds one entry");
14969
14970        assert!(
14971            lf.contains_hash(100),
14972            "the old hash is still in the reverse index"
14973        );
14974        let stale = lf.get_by_hash(100).expect("stale mapping resolves");
14975        assert_eq!(
14976            stale.get_hash(),
14977            200,
14978            "looking up the OLD hash hands back the NEW font"
14979        );
14980    }
14981
14982    #[test]
14983    fn loaded_fonts_from_iterator_matches_repeated_inserts() {
14984        let lf: LoadedFonts<TestFont> =
14985            vec![(FontId(1), tf(10)), (FontId(2), tf(20))].into_iter().collect();
14986        assert_eq!(lf.len(), 2);
14987        assert!(lf.contains_hash(10) && lf.contains_hash(20));
14988        assert_eq!(lf.iter().count(), 2);
14989    }
14990
14991    // =====================================================================
14992    // constructor/other: FontManager + FontContext
14993    // =====================================================================
14994
14995    fn manager() -> FontManager<TestFont> {
14996        FontManager::new(FcFontCache::default()).expect("FontManager::new must not fail")
14997    }
14998
14999    #[test]
15000    fn font_manager_constructors_start_empty() {
15001        for m in [
15002            manager(),
15003            FontManager::from_shared(FcFontCache::default()).unwrap(),
15004            FontManager::from_arc_shared(
15005                FcFontCache::default(),
15006                Arc::new(Mutex::new(HashMap::new())),
15007            )
15008            .unwrap(),
15009        ] {
15010            assert!(m.get_font_chain_cache().is_empty());
15011            assert!(m.get_loaded_fonts().is_empty());
15012            assert!(m.get_loaded_font_ids().is_empty());
15013            assert!(m.registry.is_none());
15014            assert_eq!(m.last_resolved_font_stacks_sig, None);
15015            assert!(m.get_font_by_hash(0).is_none());
15016            assert!(m.get_embedded_font_by_hash(u64::MAX).is_none());
15017        }
15018    }
15019
15020    #[test]
15021    fn font_manager_from_arc_shared_sees_writes_through_the_shared_pool() {
15022        let pool: Arc<Mutex<HashMap<FontId, TestFont>>> = Arc::new(Mutex::new(HashMap::new()));
15023        let a = FontManager::from_arc_shared(FcFontCache::default(), pool.clone()).unwrap();
15024        let b = FontManager::from_arc_shared(FcFontCache::default(), pool).unwrap();
15025
15026        assert!(a.insert_font(FontId(1), tf(5)).is_none(), "no previous font");
15027        assert_eq!(
15028            b.get_loaded_fonts().len(),
15029            1,
15030            "the second manager must observe the first's insert"
15031        );
15032        assert_eq!(b.get_font_by_hash(5).map(|f| f.get_hash()), Some(5));
15033
15034        // shared_parsed_fonts hands back the same Arc.
15035        assert!(Arc::ptr_eq(&a.shared_parsed_fonts(), &b.shared_parsed_fonts()));
15036    }
15037
15038    #[test]
15039    fn font_manager_insert_font_returns_the_replaced_font() {
15040        let m = manager();
15041        assert!(m.insert_font(FontId(1), tf(1)).is_none());
15042        let old = m.insert_font(FontId(1), tf(2)).expect("must return the old font");
15043        assert_eq!(old.get_hash(), 1);
15044        assert_eq!(m.get_loaded_fonts().len(), 1);
15045    }
15046
15047    #[test]
15048    fn font_manager_insert_fonts_and_remove_font() {
15049        let m = manager();
15050        m.insert_fonts(vec![(FontId(1), tf(1)), (FontId(2), tf(2))]);
15051        assert_eq!(m.get_loaded_font_ids().len(), 2);
15052
15053        assert_eq!(m.remove_font(&FontId(1)).map(|f| f.get_hash()), Some(1));
15054        assert!(m.remove_font(&FontId(1)).is_none(), "double-remove is a no-op");
15055        assert!(
15056            m.remove_font(&FontId(u128::MAX)).is_none(),
15057            "removing an unknown id must not panic"
15058        );
15059        assert_eq!(m.get_loaded_fonts().len(), 1);
15060
15061        // Inserting an empty iterator is a no-op.
15062        m.insert_fonts(Vec::new());
15063        assert_eq!(m.get_loaded_fonts().len(), 1);
15064    }
15065
15066    #[test]
15067    fn font_manager_get_font_by_hash_scans_linearly_and_misses_cleanly() {
15068        let m = manager();
15069        m.insert_fonts(vec![(FontId(1), tf(11)), (FontId(2), tf(22))]);
15070        assert_eq!(m.get_font_by_hash(22).map(|f| f.get_hash()), Some(22));
15071        assert!(m.get_font_by_hash(33).is_none());
15072        assert!(m.get_font_by_hash(u64::MAX).is_none());
15073        assert!(m.get_font_by_hash(0).is_none());
15074    }
15075
15076    #[test]
15077    fn font_manager_chain_cache_set_merge_and_signature() {
15078        let mut m = manager();
15079        assert!(m.get_font_chain_cache().is_empty());
15080
15081        // set_font_chain_cache_with_sig records the signature...
15082        m.set_font_chain_cache_with_sig(HashMap::new(), Some(42));
15083        assert_eq!(m.last_resolved_font_stacks_sig, Some(42));
15084
15085        // ...and the single-arg setter clears it again.
15086        m.set_font_chain_cache(HashMap::new());
15087        assert_eq!(
15088            m.last_resolved_font_stacks_sig, None,
15089            "a signature-less set must invalidate the recorded signature"
15090        );
15091
15092        // merge on an empty cache is a no-op that does not panic.
15093        m.merge_font_chain_cache(HashMap::new());
15094        assert!(m.get_font_chain_cache().is_empty());
15095    }
15096
15097    #[test]
15098    fn font_manager_garbage_collect_evicts_everything_not_in_the_keep_set() {
15099        let mut m = manager();
15100        m.insert_fonts(vec![
15101            (FontId(1), tf(1)),
15102            (FontId(2), tf(2)),
15103            (FontId(3), tf(3)),
15104        ]);
15105
15106        let mut keep = HashSet::new();
15107        keep.insert(FontId(2));
15108        let evicted = m.garbage_collect_fonts(&keep, &HashSet::new());
15109        assert_eq!(evicted, 2);
15110        assert_eq!(m.get_loaded_font_ids(), keep);
15111
15112        // GC-ing again evicts nothing (saturating_sub must not underflow).
15113        assert_eq!(m.garbage_collect_fonts(&keep, &HashSet::new()), 0);
15114
15115        // An empty keep-set flushes the pool entirely.
15116        assert_eq!(m.garbage_collect_fonts(&HashSet::new(), &HashSet::new()), 1);
15117        assert!(m.get_loaded_fonts().is_empty());
15118        // ...and a GC on an already-empty pool is still 0, not a panic.
15119        assert_eq!(m.garbage_collect_fonts(&HashSet::new(), &HashSet::new()), 0);
15120    }
15121
15122    #[test]
15123    fn font_manager_load_missing_for_chains_with_no_chains_loads_nothing() {
15124        use crate::solver3::getters::ResolvedFontChains;
15125        let m = manager();
15126        let empty = ResolvedFontChains {
15127            chains: HashMap::new(),
15128            ..Default::default()
15129        };
15130        let failed = m.load_missing_for_chains(
15131            &empty,
15132            |_bytes, _idx| -> Result<TestFont, LayoutError> {
15133                panic!("the loader must never be invoked when there is nothing to load")
15134            },
15135        );
15136        assert!(failed.is_empty());
15137        assert!(m.get_loaded_fonts().is_empty());
15138    }
15139
15140    #[test]
15141    fn font_context_from_fc_cache_starts_empty_and_converts_to_a_manager() {
15142        let ctx = FontContext::from_fc_cache(FcFontCache::default());
15143        assert!(ctx.font_chain_cache.is_empty());
15144        assert!(ctx.embedded_fonts.is_empty());
15145        assert!(ctx.font_hash_to_families.is_empty());
15146        assert!(ctx.registry.is_none());
15147        assert!(ctx.parsed_fonts.lock().unwrap().is_empty());
15148
15149        // Warming an empty chain set must be a no-op (and must not hit the disk).
15150        ctx.load_fonts_for_chains();
15151        assert!(ctx.parsed_fonts.lock().unwrap().is_empty());
15152
15153        let mgr = ctx.to_font_manager();
15154        assert!(mgr.get_font_chain_cache().is_empty());
15155        assert!(mgr.registry.is_none());
15156        assert_eq!(mgr.last_resolved_font_stacks_sig, None);
15157        assert!(
15158            Arc::ptr_eq(&mgr.parsed_fonts, &ctx.parsed_fonts),
15159            "the manager must share (not copy) the parsed-font pool"
15160        );
15161    }
15162
15163    // =====================================================================
15164    // other: create_logical_items / bidi entry points
15165    // =====================================================================
15166
15167    #[test]
15168    fn create_logical_items_on_empty_and_whitespace_only_content() {
15169        let mut dbg = None;
15170        assert!(create_logical_items(&[], &[], &mut dbg).is_empty());
15171
15172        // An empty text run is skipped entirely.
15173        let empty_run = [text_content("", style())];
15174        assert!(create_logical_items(&empty_run, &[], &mut dbg).is_empty());
15175
15176        // Whitespace-only text still produces items.
15177        let ws = [text_content("   \t\n", style())];
15178        assert!(!create_logical_items(&ws, &[], &mut dbg).is_empty());
15179    }
15180
15181    #[test]
15182    fn create_logical_items_handles_multibyte_and_astral_text_without_panicking() {
15183        let mut dbg = None;
15184        for s in ["\u{1F600}", "é\u{0301}", "中文", "a\u{200B}b", "\u{FEFF}"] {
15185            let content = [text_content(s, style())];
15186            let items = create_logical_items(&content, &[], &mut dbg);
15187            assert!(!items.is_empty(), "{s:?} produced no logical items");
15188        }
15189    }
15190
15191    #[test]
15192    fn create_logical_items_on_a_long_run_does_not_hang() {
15193        let mut dbg = None;
15194        let long = "a".repeat(100_000);
15195        let content = [text_content(&long, style())];
15196        let items = create_logical_items(&content, &[], &mut dbg);
15197        assert!(!items.is_empty());
15198    }
15199
15200    #[test]
15201    fn create_logical_items_debug_messages_are_recorded_when_requested() {
15202        let mut dbg = Some(Vec::new());
15203        let content = [text_content("hi", style())];
15204        let _ = create_logical_items(&content, &[], &mut dbg);
15205        assert!(
15206            !dbg.expect("Some(..) in → Some(..) out").is_empty(),
15207            "the debug sink must be populated when it is Some"
15208        );
15209    }
15210
15211    #[test]
15212    fn get_base_direction_from_logical_defaults_to_ltr_on_empty_input() {
15213        assert_eq!(get_base_direction_from_logical(&[]), BidiDirection::Ltr);
15214
15215        let mut dbg = None;
15216        let ltr = create_logical_items(&[text_content("hello", style())], &[], &mut dbg);
15217        assert_eq!(get_base_direction_from_logical(&ltr), BidiDirection::Ltr);
15218
15219        // A Hebrew run must be detected as RTL.
15220        let rtl = create_logical_items(&[text_content("\u{05D0}\u{05D1}", style())], &[], &mut dbg);
15221        assert_eq!(get_base_direction_from_logical(&rtl), BidiDirection::Rtl);
15222    }
15223
15224    #[test]
15225    fn reorder_logical_items_on_empty_input_is_ok_and_empty() {
15226        let mut dbg = None;
15227        let out = reorder_logical_items(&[], BidiDirection::Ltr, UnicodeBidi::Normal, &mut dbg)
15228            .expect("reordering nothing must succeed");
15229        assert!(out.is_empty());
15230    }
15231
15232    #[test]
15233    fn reorder_logical_items_preserves_content_for_pure_ltr_text() {
15234        let mut dbg = None;
15235        let logical = create_logical_items(&[text_content("abc", style())], &[], &mut dbg);
15236        let visual = reorder_logical_items(
15237            &logical,
15238            BidiDirection::Ltr,
15239            UnicodeBidi::Normal,
15240            &mut dbg,
15241        )
15242        .expect("LTR reordering must succeed");
15243        let joined: String = visual.iter().map(|v| v.text.as_str()).collect();
15244        assert_eq!(joined, "abc", "pure LTR text must survive bidi unchanged");
15245        assert!(visual.iter().all(|v| !v.bidi_level.is_rtl()));
15246    }
15247
15248    // =====================================================================
15249    // other: hyphenation stubs (feature-gated)
15250    // =====================================================================
15251
15252    #[cfg(not(feature = "text_layout_hyphenation"))]
15253    #[test]
15254    fn stub_hyphenate_never_reports_a_break() {
15255        let s = Standard;
15256        assert!(s.hyphenate("").breaks.is_empty());
15257        assert!(s.hyphenate("hyphenation").breaks.is_empty());
15258        assert!(s.hyphenate(&"a".repeat(10_000)).breaks.is_empty());
15259        assert!(s.hyphenate("\u{1F600}\u{0301}").breaks.is_empty());
15260    }
15261
15262    #[cfg(not(feature = "text_layout_hyphenation"))]
15263    #[test]
15264    fn stub_get_hyphenator_always_errors() {
15265        assert!(matches!(
15266            get_hyphenator(Language::EnglishUS),
15267            Err(LayoutError::HyphenationError(_))
15268        ));
15269    }
15270
15271    #[cfg(feature = "text_layout_hyphenation")]
15272    #[test]
15273    fn get_hyphenator_loads_an_embedded_language_and_hyphenates() {
15274        let h =
15275            get_hyphenator(HyphenationLanguage::EnglishUS).expect("en-US dictionaries are embedded");
15276
15277        // Empty / single-char words have no interior break points.
15278        let empty = h.hyphenate("");
15279        assert!(empty.breaks.is_empty(), "an empty word must not panic");
15280        let one = h.hyphenate("a");
15281        assert!(one.breaks.is_empty());
15282
15283        // Every reported break must be a valid INTERIOR char boundary.
15284        let word = "hyphenation";
15285        let opps = h.hyphenate(word);
15286        for &b in &opps.breaks {
15287            assert!(b > 0 && b < word.len(), "break {b} is outside {word:?}");
15288            assert!(word.is_char_boundary(b), "break {b} splits a char");
15289        }
15290
15291        // Astral + combining input must not panic.
15292        let weird = h.hyphenate("\u{1F600}\u{0301}");
15293        assert!(weird.breaks.len() < 8, "no runaway break list");
15294    }
15295}