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/// How a registered in-memory face ranks against fonts on disk.
767///
768/// The distinction exists because "register a font by name" means two different
769/// things. A font the caller explicitly supplied for a family *is* that family
770/// and must beat anything installed, exactly as CSS says. A font offered as a
771/// stand-in for a generic family - the 14 standard PDF fonts answering
772/// `sans-serif`, say - must not, or a Win-1252 subset would displace the
773/// system's full Unicode faces on every desktop.
774///
775/// Without the second tier the choice is all-or-nothing: claim `sans-serif` and
776/// wreck desktop, or leave it alone and have nothing at all on a target with no
777/// fonts on disk, such as wasm.
778#[derive(Debug, Clone, Copy, PartialEq, Eq)]
779pub enum MemoryFontTier {
780    /// Wins over anything on disk. The right tier for a font the caller named.
781    Primary,
782    /// Used only after disk resolution has had its turn. The right tier for a
783    /// last-resort face standing in for a generic family.
784    Fallback,
785}
786
787/// One in-memory face registered under a family name, with the style attributes
788/// needed to choose the right face for a CSS `(weight, italic/oblique)` query.
789///
790/// [`FontManager::register_named_font`] registers several faces under the *same*
791/// family name (e.g. `Helvetica` regular, bold, oblique). Keying
792/// [`FontManager::memory_families`] by family alone therefore collapsed them —
793/// the last registration won and `font-weight: bold` silently rendered in the
794/// regular face. Each face now records its own weight/style so resolution can
795/// pick the closest one (see `getters::split_memory_matches`).
796#[derive(Debug, Clone)]
797pub struct MemoryFace {
798    /// Whether this face outranks the disk or only backstops it.
799    pub tier: MemoryFontTier,
800    /// The `FontMatch` the resolver emits when this face is chosen.
801    pub font_match: rust_fontconfig::FontMatch,
802    /// OS/2 weight of this face (static fonts). For a variable font this is the
803    /// default-instance weight; `weight_axis` carries the selectable range.
804    pub weight: FcWeight,
805    /// `head`/OS-2 italic bit.
806    pub italic: bool,
807    /// OS/2 oblique bit.
808    pub oblique: bool,
809    /// OS/2 width class.
810    pub stretch: FcStretch,
811    /// For a variable font, the `wght` axis `(min, max)` in user units; `None`
812    /// for a static face. Lets a single VF satisfy any requested weight.
813    pub weight_axis: Option<(f32, f32)>,
814}
815
816/// Style attributes parsed from a font's bytes (OS/2 + `head`), used to index a
817/// registered face in [`FontManager::memory_families`].
818#[derive(Debug, Clone, Copy)]
819struct FaceStyle {
820    weight: FcWeight,
821    italic: bool,
822    oblique: bool,
823    stretch: FcStretch,
824    weight_axis: Option<(f32, f32)>,
825}
826
827impl Default for FaceStyle {
828    fn default() -> Self {
829        Self {
830            weight: FcWeight::Normal,
831            italic: false,
832            oblique: false,
833            stretch: FcStretch::Normal,
834            weight_axis: None,
835        }
836    }
837}
838
839/// Parse a face's weight / italic / oblique / stretch from its bytes via
840/// rust-fontconfig (which reads OS/2 `usWeightClass`/`usWidthClass` and the
841/// `head` italic bit). Falls back to upright Normal when the font can't be
842/// parsed, so registration never fails on a malformed face.
843fn parse_face_style(bytes: &[u8], family: &str) -> FaceStyle {
844    let Some(faces) = rust_fontconfig::FcParseFontBytes(bytes, family) else {
845        return FaceStyle::default();
846    };
847    let Some((pat, _)) = faces.into_iter().next() else {
848        return FaceStyle::default();
849    };
850    FaceStyle {
851        weight: pat.weight,
852        italic: pat.italic == PatternMatch::True,
853        oblique: pat.oblique == PatternMatch::True,
854        stretch: pat.stretch,
855        weight_axis: None,
856    }
857}
858
859#[derive(Debug)]
860pub struct FontManager<T> {
861    /// The font-path cache. `FcFontCache` in rust-fontconfig 4.1 is
862    /// already a shared handle internally (`Arc<RwLock<_>>`), so no
863    /// further `Arc<...>` wrapping is needed — clones are cheap and
864    /// all clones see builder writes instantly.
865    pub fc_cache: FcFontCache,
866    /// Holds the actual parsed font (usually with the font bytes attached).
867    /// Wrapped in Arc so multiple `FontManager` instances can share the same
868    /// pool of already-parsed fonts (avoids re-reading from disk).
869    pub parsed_fonts: Arc<Mutex<HashMap<FontId, T>>>,
870    // Cache for font chains - populated by resolve_all_font_chains() before layout
871    // This is read-only during layout - no locking needed for reads
872    pub font_chain_cache: HashMap<FontChainKey, rust_fontconfig::FontFallbackChain>,
873    /// Cache for direct `FontRefs` (embedded fonts like Material Icons)
874    /// These are fonts referenced via `FontStack::Ref` that bypass fontconfig
875    pub embedded_fonts: Mutex<HashMap<u64, azul_css::props::basic::FontRef>>,
876    /// Reverse map: `font_family_hash` → actual `StyleFontFamilyVec`.
877    /// Accumulated across DOMs. Used by font collection and text shaping to
878    /// resolve compact cache hashes without `get_property_slow`.
879    pub font_hash_to_families: HashMap<u64, azul_css::props::basic::font::StyleFontFamilyVec>,
880    /// Optional link back to the live `FcFontRegistry`. When present,
881    /// chain resolution uses
882    /// [`rust_fontconfig::registry::FcFontRegistry::request_and_resolve_with_scripts`]
883    /// which lazy-parses system fonts as the DOM requests them
884    /// (scout-on-demand). `None` falls back to querying whatever is
885    /// already in the shared cache.
886    pub registry: Option<Arc<rust_fontconfig::registry::FcFontRegistry>>,
887    /// `FxHash` of the `prev_font_hashes` slice at the moment the last
888    /// successful `collect_and_resolve_font_chains_with_registration`
889    /// call populated `font_chain_cache`. Lets repeated layouts of the
890    /// same DOM skip the ~1.5 ms (cold) / ~0.9 ms (warm) chain resolver
891    /// when the set of font-family hashes has not changed. Cleared
892    /// whenever `font_chain_cache` is explicitly emptied.
893    pub last_resolved_font_stacks_sig: Option<u64>,
894    /// Index of every font registered by FAMILY NAME into `fc_cache`'s
895    /// in-memory font table (bundled fonts, embedder fonts, the built-in
896    /// mock test fonts): normalized family name → the `FontMatch` the
897    /// resolver should emit for it.
898    ///
899    /// WHY THIS EXISTS (architectural, see `resolve_font_chains_fast`):
900    /// the fast chain resolver in rust-fontconfig 4.4
901    /// (`FcFontRegistry::request_fonts_fast`) resolves families purely
902    /// against `known_paths` — i.e. fonts that exist as FILES ON DISK.
903    /// In-memory fonts are invisible to it, so a family registered with
904    /// `FcFontCache::with_memory_fonts` could never be matched by name
905    /// on the production path (which always has a live registry): it
906    /// silently fell back to a system font. This index is consulted
907    /// FIRST, before the disk probe, so a memory-registered family wins
908    /// exactly as CSS says it should.
909    pub memory_families: HashMap<String, Vec<MemoryFace>>,
910    /// Baked static instances of variable fonts, keyed by a hash of the original
911    /// VF bytes. A variable font is expanded into one static face per weight
912    /// bucket (see `register_named_font`); this caches the minted faces so the
913    /// several spelling registrations of the same VF don't re-bake it.
914    vf_bake_cache: HashMap<u64, Vec<(FontId, FaceStyle)>>,
915}
916
917impl<T: ParsedFontTrait> FontManager<T> {
918    /// # Errors
919    ///
920    /// Returns a `LayoutError` if the font cache cannot be initialized.
921    pub fn new(fc_cache: FcFontCache) -> Result<Self, LayoutError> {
922        let mut fm = Self {
923            fc_cache,
924            parsed_fonts: Arc::new(Mutex::new(HashMap::new())),
925            font_chain_cache: HashMap::new(),
926            embedded_fonts: Mutex::new(HashMap::new()),
927            font_hash_to_families: HashMap::new(),
928            registry: None,
929            last_resolved_font_stacks_sig: None,
930            memory_families: HashMap::new(),
931            vf_bake_cache: HashMap::new(),
932        };
933        fm.register_builtin_mock_fonts();
934        Ok(fm)
935    }
936
937    /// Register a font by FAMILY NAME from raw bytes, as an in-memory font
938    /// in the shared `FcFontCache`.
939    ///
940    /// This is the ONE hook an embedder (or a test) uses to make a font
941    /// resolvable by `font-family: "<family>"`. It mints one `FontId` for
942    /// the font, inserts it into the fontconfig cache's memory-font table
943    /// (so `get_font_bytes` / `load_fonts_from_disk` find it with no
944    /// special-casing) and indexes it in [`Self::memory_families`] so the
945    /// fast chain resolver can match it by name.
946    ///
947    /// `coverage` are the codepoint ranges the font actually covers.
948    /// Passing the true ranges matters: `FontFallbackChain::resolve_char`
949    /// skips any font that reports no coverage, and a font claiming
950    /// coverage it doesn't have would render .notdef instead of falling
951    /// back.
952    ///
953    /// Returns the `FontId` the family now resolves to.
954    pub fn register_named_font(
955        &mut self,
956        family: &str,
957        bytes: &[u8],
958        coverage: Vec<UnicodeRange>,
959    ) -> FontId {
960        self.register_named_font_in_tier(family, bytes, coverage, MemoryFontTier::Primary)
961    }
962
963    /// Register an in-memory face under `family` at an explicit
964    /// [`MemoryFontTier`].
965    ///
966    /// [`Self::register_named_font`] is this with [`MemoryFontTier::Primary`].
967    /// Use [`MemoryFontTier::Fallback`] to offer a face for a family without
968    /// displacing whatever is installed - a caller that ships stand-in fonts for
969    /// `serif`/`sans-serif`/`monospace` wants the system's faces to win on a
970    /// desktop and its own to be there on wasm, and that is the tier that does
971    /// both.
972    pub fn register_named_font_in_tier(
973        &mut self,
974        family: &str,
975        bytes: &[u8],
976        coverage: Vec<UnicodeRange>,
977        tier: MemoryFontTier,
978    ) -> FontId {
979        let norm = rust_fontconfig::utils::normalize_family_name(family);
980
981        // Variable fonts: expand into one STATIC instance per weight bucket so the
982        // ordinary static weight-selection path (see `split_memory_matches` /
983        // `pick_memory_face`) picks the right one, with NO changes to shaping,
984        // glyph decode, or PDF embedding — each baked instance is an ordinary
985        // static font. Falls through to the static path below if the font is not a
986        // bakeable variable font (baking failed / no glyf variations).
987        if let Some((min, def, max)) = crate::font::parsed::read_wght_axis(bytes, 0) {
988            if let Some(id) =
989                self.register_variable_instances(&norm, family, bytes, &coverage, min, def, max, tier)
990            {
991                return id;
992            }
993        }
994
995        // The weight/style come from the font BYTES (OS/2), not the registration
996        // name: registering `Helvetica-Bold.ttf` under either "Helvetica-Bold" or
997        // its internal family "Helvetica" must both yield weight=Bold. A font can
998        // (and Helvetica does) reuse the same family name across faces, so faces
999        // are distinguished by (weight, italic, oblique), never by name alone.
1000        let style = parse_face_style(bytes, family);
1001
1002        // IDEMPOTENT: several `FontManager`s (one per window, plus the PDF
1003        // writer) share one `FcFontCache`. Registering the same face twice would
1004        // mint a second `FontId` for the same bytes, orphan the first in the
1005        // cache's metadata table and make the id non-deterministic. Reuse an
1006        // existing memory font ONLY when family AND (weight, italic, oblique)
1007        // match — a bold face must not be deduplicated against the regular one.
1008        let mut existing: Vec<(FontId, Vec<UnicodeRange>)> = Vec::new();
1009        self.fc_cache.for_each_pattern(|pattern, id| {
1010            let fam_hit = pattern
1011                .family
1012                .as_deref()
1013                .is_some_and(|f| rust_fontconfig::utils::normalize_family_name(f) == norm);
1014            let style_hit = pattern.weight == style.weight
1015                && (pattern.italic == PatternMatch::True) == style.italic
1016                && (pattern.oblique == PatternMatch::True) == style.oblique;
1017            if fam_hit && style_hit {
1018                existing.push((*id, pattern.unicode_ranges.clone()));
1019            }
1020        });
1021        let id = if let Some((id, ranges)) = existing
1022            .into_iter()
1023            .find(|(id, _)| self.fc_cache.is_memory_font(id))
1024        {
1025            self.index_memory_face(&norm, id, ranges, &style, tier);
1026            id
1027        } else {
1028            let pattern = rust_fontconfig::FcPattern {
1029                name: Some(family.to_string()),
1030                family: Some(family.to_string()),
1031                italic: if style.italic { PatternMatch::True } else { PatternMatch::False },
1032                oblique: if style.oblique { PatternMatch::True } else { PatternMatch::False },
1033                bold: if style.weight >= FcWeight::Bold { PatternMatch::True } else { PatternMatch::False },
1034                weight: style.weight,
1035                stretch: style.stretch,
1036                unicode_ranges: coverage.clone(),
1037                ..Default::default()
1038            };
1039            let id = FontId::new();
1040            self.fc_cache.with_memory_font_with_id(
1041                id,
1042                pattern,
1043                rust_fontconfig::FcFont {
1044                    bytes: bytes.to_vec(),
1045                    font_index: 0,
1046                    id: family.to_string(),
1047                },
1048            );
1049            self.index_memory_face(&norm, id, coverage, &style, tier);
1050            id
1051        };
1052        id
1053    }
1054
1055    /// Append (or refresh) a face in [`Self::memory_families`] under `norm`,
1056    /// de-duplicating by `FontId` so repeated registrations don't grow the list.
1057    fn index_memory_face(
1058        &mut self,
1059        norm: &str,
1060        id: FontId,
1061        unicode_ranges: Vec<UnicodeRange>,
1062        style: &FaceStyle,
1063        tier: MemoryFontTier,
1064    ) {
1065        let face = MemoryFace {
1066            tier,
1067            font_match: rust_fontconfig::FontMatch {
1068                id,
1069                unicode_ranges,
1070                fallbacks: Vec::new(),
1071            },
1072            weight: style.weight,
1073            italic: style.italic,
1074            oblique: style.oblique,
1075            stretch: style.stretch,
1076            weight_axis: style.weight_axis,
1077        };
1078        let faces = self.memory_families.entry(norm.to_string()).or_default();
1079        if let Some(slot) = faces.iter_mut().find(|f| f.font_match.id == id) {
1080            *slot = face;
1081        } else {
1082            faces.push(face);
1083        }
1084    }
1085
1086    /// Expand a variable font (with a `wght` axis over `[min, max]`, default
1087    /// `def`) into one baked STATIC instance per standard weight bucket and
1088    /// register each as an in-memory face under `norm`. Returns the face nearest
1089    /// the fvar default, or `None` if no instance could be baked (caller then
1090    /// falls back to registering the raw bytes as a single static face).
1091    ///
1092    /// Baking is done once per unique VF bytes and cached (`vf_bake_cache`) so the
1093    /// several spelling registrations of the same font don't re-bake it.
1094    // Weight axis values are clamped to [1, 1000] and rounded before the cast, so
1095    // the f32 -> u16 conversion is bounded and sign-safe.
1096    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
1097    fn register_variable_instances(
1098        &mut self,
1099        norm: &str,
1100        family: &str,
1101        bytes: &[u8],
1102        coverage: &[UnicodeRange],
1103        min: f32,
1104        def: f32,
1105        max: f32,
1106        tier: MemoryFontTier,
1107    ) -> Option<FontId> {
1108        let base = parse_face_style(bytes, family);
1109        let hash = {
1110            use core::hash::Hasher;
1111            let mut h = DefaultHasher::new();
1112            h.write(bytes);
1113            h.finish()
1114        };
1115        let def_bucket = FcWeight::from_u16(def.round().clamp(1.0, 1000.0) as u16);
1116
1117        // Same VF already baked under another spelling: re-index, don't re-bake.
1118        if let Some(cached) = self.vf_bake_cache.get(&hash).cloned() {
1119            for (id, style) in &cached {
1120                self.index_memory_face(norm, *id, coverage.to_vec(), style, tier);
1121            }
1122            return cached
1123                .iter()
1124                .find(|(_, s)| s.weight == def_bucket)
1125                .or_else(|| cached.first())
1126                .map(|(id, _)| *id);
1127        }
1128
1129        let lo = min.round().clamp(1.0, 1000.0) as u16;
1130        let hi = max.round().clamp(1.0, 1000.0) as u16;
1131        let mut baked: Vec<(FontId, FaceStyle)> = Vec::new();
1132        for w in [100u16, 200, 300, 400, 500, 600, 700, 800, 900] {
1133            if w < lo || w > hi {
1134                continue;
1135            }
1136            let Some(inst_bytes) = crate::font::parsed::bake_weight_instance(bytes, 0, f32::from(w))
1137            else {
1138                continue;
1139            };
1140            let style = FaceStyle {
1141                weight: FcWeight::from_u16(w),
1142                italic: base.italic,
1143                oblique: base.oblique,
1144                stretch: base.stretch,
1145                weight_axis: None,
1146            };
1147            let pattern = rust_fontconfig::FcPattern {
1148                name: Some(family.to_string()),
1149                family: Some(family.to_string()),
1150                italic: if style.italic { PatternMatch::True } else { PatternMatch::False },
1151                oblique: if style.oblique { PatternMatch::True } else { PatternMatch::False },
1152                bold: if style.weight >= FcWeight::Bold { PatternMatch::True } else { PatternMatch::False },
1153                weight: style.weight,
1154                stretch: style.stretch,
1155                unicode_ranges: coverage.to_vec(),
1156                ..Default::default()
1157            };
1158            let id = FontId::new();
1159            self.fc_cache.with_memory_font_with_id(
1160                id,
1161                pattern,
1162                rust_fontconfig::FcFont {
1163                    bytes: inst_bytes,
1164                    font_index: 0,
1165                    id: family.to_string(),
1166                },
1167            );
1168            self.index_memory_face(norm, id, coverage.to_vec(), &style, tier);
1169            baked.push((id, style));
1170        }
1171
1172        if baked.is_empty() {
1173            return None;
1174        }
1175        let default_id = baked
1176            .iter()
1177            .find(|(_, s)| s.weight == def_bucket)
1178            .or_else(|| baked.first())
1179            .map(|(id, _)| *id)
1180            .unwrap();
1181        self.vf_bake_cache.insert(hash, baked);
1182        Some(default_id)
1183    }
1184
1185    /// Register the built-in mock test fonts (see
1186    /// [`crate::text3::mock_fonts`]). Called from every constructor: the
1187    /// mock families are only reachable if a stylesheet names them, and
1188    /// having them always present means tests exercise the *same* font
1189    /// path as production instead of a test-only bypass.
1190    pub fn register_builtin_mock_fonts(&mut self) {
1191        for (family, bytes) in crate::text3::mock_fonts::BUILTIN_MOCK_FONTS {
1192            self.register_named_font(
1193                family,
1194                bytes,
1195                crate::text3::mock_fonts::mock_font_ranges(),
1196            );
1197        }
1198    }
1199
1200    /// Create a `FontManager` sharing the font-path cache handle.
1201    ///
1202    /// The `parsed_fonts` pool starts empty. Fonts loaded during the first
1203    /// layout pass are cached and will be available on subsequent calls
1204    /// if you clone the `parsed_fonts` Arc before creating the next instance.
1205    /// For full sharing, prefer `from_arc_shared()`.
1206    /// # Errors
1207    ///
1208    /// Returns a `LayoutError` if the font cache cannot be initialized.
1209    pub fn from_shared(fc_cache: FcFontCache) -> Result<Self, LayoutError> {
1210        Self::new(fc_cache)
1211    }
1212
1213    /// Create a `FontManager` sharing both the font-path cache and the
1214    /// already-parsed font data with another `FontManager`.
1215    ///
1216    /// This avoids re-reading and re-parsing font files from disk when
1217    /// rendering multiple documents that use the same fonts.
1218    /// # Errors
1219    ///
1220    /// Returns a `LayoutError` if the font cache cannot be initialized.
1221    pub fn from_arc_shared(
1222        fc_cache: FcFontCache,
1223        parsed_fonts: Arc<Mutex<HashMap<FontId, T>>>,
1224    ) -> Result<Self, LayoutError> {
1225        let mut fm = Self {
1226            fc_cache,
1227            parsed_fonts,
1228            font_chain_cache: HashMap::new(),
1229            embedded_fonts: Mutex::new(HashMap::new()),
1230            font_hash_to_families: HashMap::new(),
1231            registry: None,
1232            last_resolved_font_stacks_sig: None,
1233            memory_families: HashMap::new(),
1234            vf_bake_cache: HashMap::new(),
1235        };
1236        fm.register_builtin_mock_fonts();
1237        Ok(fm)
1238    }
1239
1240    /// Attach a `FcFontRegistry` to this `FontManager` so subsequent
1241    /// chain-resolution calls use the on-demand path
1242    /// ([`rust_fontconfig::registry::FcFontRegistry::request_and_resolve_with_scripts`]).
1243    #[must_use]
1244    pub fn with_registry(
1245        mut self,
1246        registry: Arc<rust_fontconfig::registry::FcFontRegistry>,
1247    ) -> Self {
1248        self.registry = Some(registry);
1249        self
1250    }
1251
1252    /// Get a shareable handle to the parsed-font pool.
1253    ///
1254    /// Pass this to `from_arc_shared()` to create a new `FontManager` that
1255    /// reuses already-parsed fonts.
1256    pub fn shared_parsed_fonts(&self) -> Arc<Mutex<HashMap<FontId, T>>> {
1257        Arc::clone(&self.parsed_fonts)
1258    }
1259
1260    /// Set the font chain cache from externally resolved chains
1261    ///
1262    /// This should be called with the result of `resolve_font_chains()` or
1263    /// `collect_and_resolve_font_chains()` from `solver3::getters`.
1264    pub fn set_font_chain_cache(
1265        &mut self,
1266        chains: HashMap<FontChainKey, rust_fontconfig::FontFallbackChain>,
1267    ) {
1268        self.font_chain_cache = chains;
1269        self.last_resolved_font_stacks_sig = None;
1270    }
1271
1272    /// Set the font chain cache and record the input signature so
1273    /// subsequent layouts with the same `prev_font_hashes` skip the
1274    /// resolver. Pass `sig = None` if the caller cannot compute a
1275    /// reliable signature — equivalent to the single-arg
1276    /// `set_font_chain_cache`.
1277    pub fn set_font_chain_cache_with_sig(
1278        &mut self,
1279        chains: HashMap<FontChainKey, rust_fontconfig::FontFallbackChain>,
1280        sig: Option<u64>,
1281    ) {
1282        // (2026-06-10: reverted to HashMap — the empty-map RawIter hang behind the 2026-06-05
1283        // BTreeMap migration was the un-mirrored hashbrown EMPTY_GROUP static, fixed
1284        // transpiler-side.)
1285        self.font_chain_cache = chains;
1286        self.last_resolved_font_stacks_sig = sig;
1287    }
1288
1289    /// Merge additional font chains into the existing cache
1290    ///
1291    /// Useful when processing multiple DOMs that may have different font requirements.
1292    pub fn merge_font_chain_cache(
1293        &mut self,
1294        chains: HashMap<FontChainKey, rust_fontconfig::FontFallbackChain>,
1295    ) {
1296        self.font_chain_cache.extend(chains);
1297    }
1298
1299    /// Get a reference to the font chain cache
1300    pub const fn get_font_chain_cache(
1301        &self,
1302    ) -> &HashMap<FontChainKey, rust_fontconfig::FontFallbackChain> {
1303        &self.font_chain_cache
1304    }
1305
1306    /// Get an embedded font by its hash (used for `WebRender` registration)
1307    /// Returns the `FontRef` if it exists in the `embedded_fonts` cache.
1308    /// # Panics
1309    ///
1310    /// Panics if the internal font-cache mutex is poisoned.
1311    pub fn get_embedded_font_by_hash(&self, font_hash: u64) -> Option<azul_css::props::basic::FontRef> {
1312        let embedded = self.embedded_fonts.lock().unwrap();
1313        embedded.get(&font_hash).cloned()
1314    }
1315
1316    /// Get a parsed font by its hash (used for `WebRender` registration)
1317    /// Returns the parsed font if it exists in the `parsed_fonts` cache.
1318    /// # Panics
1319    ///
1320    /// Panics if the internal font-cache mutex is poisoned.
1321    pub fn get_font_by_hash(&self, font_hash: u64) -> Option<T> {
1322        let parsed = self.parsed_fonts.lock().unwrap();
1323        // Linear search through all cached fonts to find one with matching hash
1324        let found = parsed
1325            .iter()
1326            .find(|(_, font)| font.get_hash() == font_hash)
1327            .map(|(_, font)| font.clone());
1328        drop(parsed);
1329        found
1330    }
1331
1332    /// THE font lookup: resolve a `font_hash` — the value layout stamps onto every
1333    /// shaped glyph and carries in `DisplayListItem::Text` — back to the face that
1334    /// produced it.
1335    ///
1336    /// A `FontManager` shapes with faces from TWO pools: `parsed_fonts` (loaded from
1337    /// the resolved font chains) and `embedded_fonts` (handed to it directly by the
1338    /// DOM as `StyleFontFamily::Ref` — Material Icons and every other
1339    /// `FontStack::Ref`). Both can put a hash in the display list, so a renderer that
1340    /// consults only one of them silently drops user-visible text. That is exactly
1341    /// what shipped in 0.2.0: the CPU renderer searched `parsed_fonts` alone, so
1342    /// every widget icon vanished with `[cpurender] Font hash … not found in
1343    /// FontManager` while layout had happily measured and positioned it.
1344    ///
1345    /// Every renderer resolves through this one function, so "layout produced this
1346    /// hash" and "the renderer can draw this hash" cannot disagree.
1347    ///
1348    /// # Panics
1349    ///
1350    /// Panics if the internal font-cache mutex is poisoned.
1351    #[must_use]
1352    pub fn resolve_font_by_hash(&self, font_hash: u64) -> Option<azul_css::props::basic::FontRef>
1353    where
1354        T: Into<azul_css::props::basic::FontRef> + Clone,
1355    {
1356        if let Some(embedded) = self.get_embedded_font_by_hash(font_hash) {
1357            return Some(embedded);
1358        }
1359        self.get_font_by_hash(font_hash).map(Into::into)
1360    }
1361
1362    /// Register an embedded `FontRef` for later lookup by hash
1363    /// This is called when using `FontStack::Ref` during shaping
1364    /// # Panics
1365    ///
1366    /// Panics if the internal font-cache mutex is poisoned.
1367    pub fn register_embedded_font(&self, font_ref: &azul_css::props::basic::FontRef) {
1368        let hash = font_ref.get_hash();
1369        let mut embedded = self.embedded_fonts.lock().unwrap();
1370        embedded.insert(hash, font_ref.clone());
1371    }
1372
1373    /// Get a snapshot of all currently loaded fonts
1374    ///
1375    /// This returns a copy of all parsed fonts, which can be passed to the shaper.
1376    /// No locking is required after this call - the returned `HashMap` is independent.
1377    ///
1378    /// NOTE: This should be called AFTER loading all required fonts for a layout pass.
1379    /// # Panics
1380    ///
1381    /// Panics if the internal font-cache mutex is poisoned.
1382    pub fn get_loaded_fonts(&self) -> LoadedFonts<T> {
1383        let parsed = self.parsed_fonts.lock().unwrap();
1384        parsed
1385            .iter()
1386            .map(|(id, font)| (*id, font.shallow_clone()))
1387            .collect()
1388    }
1389
1390    /// Get the set of `FontIds` that are currently loaded
1391    ///
1392    /// This is useful for computing which fonts need to be loaded
1393    /// (diff with required fonts).
1394    /// # Panics
1395    ///
1396    /// Panics if the internal font-cache mutex is poisoned.
1397    pub fn get_loaded_font_ids(&self) -> HashSet<FontId> {
1398        let parsed = self.parsed_fonts.lock().unwrap();
1399        // M12.7: skip hashbrown's RawIterRange on an empty map — its NEON
1400        // control-byte group-scan mis-lifts to wasm and iterates forever
1401        // (the headless web layout uses an empty font cache → parsed is
1402        // empty here). is_empty() is len-based (no iteration), so it is safe.
1403        if parsed.is_empty() {
1404            return HashSet::new();
1405        }
1406        unsafe { crate::az_mark(0x60788, 0xA1) };
1407        let out = parsed.keys().copied().collect();
1408        drop(parsed);
1409        unsafe { crate::az_mark(0x6078C, 0xA2) };
1410        out
1411    }
1412
1413    /// Insert a loaded font into the cache
1414    ///
1415    /// Returns the old font if one was already present for this `FontId`.
1416    /// # Panics
1417    ///
1418    /// Panics if the internal font-cache mutex is poisoned.
1419    pub fn insert_font(&self, font_id: FontId, font: T) -> Option<T> {
1420        let mut parsed = self.parsed_fonts.lock().unwrap();
1421        parsed.insert(font_id, font)
1422    }
1423
1424    /// Insert multiple loaded fonts into the cache
1425    ///
1426    /// This is more efficient than calling `insert_font` multiple times
1427    /// because it only acquires the lock once.
1428    /// # Panics
1429    ///
1430    /// Panics if the internal font-cache mutex is poisoned.
1431    pub fn insert_fonts(&self, fonts: impl IntoIterator<Item = (FontId, T)>) {
1432        let mut parsed = self.parsed_fonts.lock().unwrap();
1433        for (font_id, font) in fonts {
1434            parsed.insert(font_id, font);
1435        }
1436    }
1437
1438    /// One-shot helper that resolves "what fonts does `chains` need
1439    /// that this manager hasn't loaded yet" and loads them via the
1440    /// supplied `load_fn` closure (typically
1441    /// `PathLoader::load_font_shared` for the production lazy-decode
1442    /// path). Updates `parsed_fonts` in place and returns any failures
1443    /// for the caller to log.
1444    ///
1445    /// Replaces the same four-step `collect → compute_diff →
1446    /// load_from_disk → insert_fonts` dance previously inlined in
1447    /// `LayoutWindow::layout_document`, the CPU rasterizer pre-fill
1448    /// in `cpurender.rs`, and `FontContext::load_fonts_for_chains`.
1449    pub fn load_missing_for_chains<F>(
1450        &self,
1451        chains: &crate::solver3::getters::ResolvedFontChains,
1452        load_fn: F,
1453    ) -> Vec<(FontId, String)>
1454    where
1455        F: Fn(Arc<rust_fontconfig::FontBytes>, usize) -> Result<T, LayoutError>,
1456    {
1457        use crate::solver3::getters::{
1458            collect_font_ids_from_chains, compute_fonts_to_load, load_fonts_from_disk,
1459        };
1460        let required = collect_font_ids_from_chains(chains);
1461        let already = self.get_loaded_font_ids();
1462        let to_load = compute_fonts_to_load(&required, &already);
1463        if to_load.is_empty() {
1464            return Vec::new();
1465        }
1466        let result = load_fonts_from_disk(&to_load, &self.fc_cache, load_fn);
1467        self.insert_fonts(result.loaded);
1468        result.failed
1469    }
1470
1471    /// Replace the backing `FcFontCache` and re-register the built-in memory fonts.
1472    ///
1473    /// Memory fonts (the mock test fonts, and any `register_named_font` bytes) live
1474    /// ONLY inside the cache. A bare `self.fc_cache = new` therefore strands them: their
1475    /// `FontId`s stay in `memory_families` but their bytes are gone with the old cache,
1476    /// so chain resolution matches them yet loading fails and text silently falls back
1477    /// (e.g. `font-family: "Azul Mock Mono"` measuring with the fallback font's metrics).
1478    /// Use this whenever the cache is swapped for a fresh snapshot (registry handle,
1479    /// rebuilt system cache) instead of assigning the field directly.
1480    pub fn replace_fc_cache(&mut self, fc_cache: FcFontCache) {
1481        self.fc_cache = fc_cache;
1482        self.drop_dangling_memory_faces();
1483        self.register_builtin_mock_fonts();
1484    }
1485
1486    /// Evict every entry of the memory-font INDEX (`memory_families`,
1487    /// `vf_bake_cache`) whose `FontId` the *current* `fc_cache` does not know.
1488    ///
1489    /// `memory_families` is not a font store, it is an index INTO the cache: the
1490    /// bytes live in `fc_cache`, the index only remembers which `FontId` a
1491    /// (family, weight, slant) resolves to. Swapping the cache therefore
1492    /// invalidates the whole index at once, and leaving it in place is worse
1493    /// than losing it — a dangling id still MATCHES during chain resolution, so
1494    /// `font-family: "X"` resolves "successfully" to an id that
1495    /// `load_missing_for_chains` can no longer load, and the text silently
1496    /// re-measures with the fallback font's metrics (line-height 1.2 instead of
1497    /// the face's own ascent/descent).
1498    ///
1499    /// Re-registering the built-in mock fonts does NOT repair this by itself:
1500    /// `register_named_font` cannot find the family in the fresh cache, so it
1501    /// mints a NEW `FontId`; `index_memory_face` de-duplicates by `FontId`, so
1502    /// the new face is APPENDED next to the dead one; and `pick_memory_face`
1503    /// returns the FIRST face of the best weight — i.e. the dead one, forever.
1504    /// (It also grew the index by one dead face per cache swap, and the DLL
1505    /// swaps on every `regenerate_layout`.)
1506    fn drop_dangling_memory_faces(&mut self) {
1507        let fc_cache = &self.fc_cache;
1508        self.memory_families.retain(|_, faces| {
1509            faces.retain(|f| fc_cache.is_memory_font(&f.font_match.id));
1510            !faces.is_empty()
1511        });
1512        // Same reasoning for the variable-font bake cache: its ids are handed
1513        // straight back to `index_memory_face` on the "already baked" path.
1514        self.vf_bake_cache
1515            .retain(|_, baked| baked.iter().all(|(id, _)| fc_cache.is_memory_font(id)));
1516    }
1517
1518    /// Remove a font from the cache
1519    ///
1520    /// Returns the removed font if it was present.
1521    /// # Panics
1522    ///
1523    /// Panics if the internal font-cache mutex is poisoned.
1524    pub fn remove_font(&self, font_id: &FontId) -> Option<T> {
1525        let mut parsed = self.parsed_fonts.lock().unwrap();
1526        parsed.remove(font_id)
1527    }
1528
1529    /// FONT GC — evict everything the CURRENT document no longer references.
1530    ///
1531    /// `keep_ids` are the `FontId`s reachable from the font chains just resolved
1532    /// for this document; `keep_hashes` are the font-family hashes present in its
1533    /// CSS property cache. Anything else belonged to a node that is gone.
1534    ///
1535    /// Without this, `parsed_fonts` / `font_hash_to_families` only ever GREW: a
1536    /// font loaded for one node stayed resident for the life of the window even
1537    /// after the node (and every other user of that family) disappeared — an app
1538    /// that cycles fonts (font picker, editor, live CSS) leaked every font it ever
1539    /// touched.
1540    ///
1541    /// Eviction is always safe: `load_missing_for_chains` re-loads any font a
1542    /// later layout turns out to need. The cost of a wrong guess is one re-parse,
1543    /// never a missing glyph.
1544    ///
1545    /// Returns the number of parsed fonts evicted.
1546    /// # Panics
1547    ///
1548    /// Panics if the internal font-cache mutex is poisoned.
1549    pub fn garbage_collect_fonts(
1550        &mut self,
1551        keep_ids: &HashSet<FontId>,
1552        keep_hashes: &HashSet<u64>,
1553    ) -> usize {
1554        let evicted = {
1555            let mut parsed = self.parsed_fonts.lock().unwrap();
1556            let before = parsed.len();
1557            parsed.retain(|id, _| keep_ids.contains(id));
1558            before.saturating_sub(parsed.len())
1559        };
1560        self.font_hash_to_families
1561            .retain(|h, _| keep_hashes.contains(h));
1562        evicted
1563    }
1564}
1565
1566// Error handling
1567// [g119 az-web-lift FIX] `#[repr(C, u8)]` (was repr(Rust)): the String/FontSelector payloads give
1568// `Result<T, LayoutError>` (e.g. measure_intrinsic_widths' return + reorder/shape/orientation `?`)
1569// a POINTER-niche disc the web lift mis-reads → Ok→Err. Explicit u8 tag = simple-compare niche the
1570// lift handles. Also nested in solver3::LayoutError::Text (so both must be repr(C,u8)). Not FFI-exposed.
1571#[derive(Debug, thiserror::Error)]
1572#[repr(C, u8)]
1573pub enum LayoutError {
1574    #[error("Bidi analysis failed: {0}")]
1575    BidiError(String),
1576    #[error("Shaping failed: {0}")]
1577    ShapingError(String),
1578    #[error("Font not found: {0:?}")]
1579    FontNotFound(FontSelector),
1580    #[error("Invalid text input: {0}")]
1581    InvalidText(String),
1582    #[error("Hyphenation failed: {0}")]
1583    HyphenationError(String),
1584}
1585
1586/// Text boundary types for cursor movement
1587#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1588pub enum TextBoundary {
1589    /// Reached top of text (first line)
1590    Top,
1591    /// Reached bottom of text (last line)
1592    Bottom,
1593    /// Reached start of text (first character)
1594    Start,
1595    /// Reached end of text (last character)
1596    End,
1597}
1598
1599/// Error returned when cursor movement hits a boundary
1600#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1601pub(crate) struct CursorBoundsError {
1602    pub(crate) boundary: TextBoundary,
1603    pub(crate) cursor: TextCursor,
1604}
1605
1606/// Unified constraints combining all layout features
1607///
1608/// # CSS Inline Layout Module Level 3: Constraint Mapping
1609///
1610/// This structure maps CSS properties to layout constraints:
1611///
1612/// ## \u00a7 2.1 Layout of Line Boxes
1613/// - `available_width`: \u26a0\ufe0f CRITICAL - Should equal containing block's inner width
1614///   * Currently defaults to 0.0 which causes immediate line breaking
1615///   * Per spec: "logical width of a line box is equal to the inner logical width of its containing
1616///     block"
1617/// - `available_height`: For block-axis constraints (max-height)
1618///
1619/// ## \u00a7 2.2 Layout Within Line Boxes
1620/// - `text_align`: \u2705 Horizontal alignment (start, end, center, justify)
1621/// - `vertical_align`: \u26a0\ufe0f PARTIAL - Only baseline supported, missing:
1622///   * top, bottom, middle, text-top, text-bottom
1623///   * <length>, <percentage> values
1624///   * sub, super positions
1625/// - `line_height`: \u2705 Distance between baselines
1626///
1627/// ## \u00a7 3 Baselines and Alignment Metrics
1628/// - `text_orientation`: \u2705 For vertical writing (sideways, upright)
1629/// - `writing_mode`: \u2705 horizontal-tb, vertical-rl, vertical-lr
1630/// - `direction`: \u2705 ltr, rtl for `BiDi`
1631///
1632/// ## \u00a7 4 Baseline Alignment (vertical-align property)
1633/// \u26a0\ufe0f INCOMPLETE: Only basic baseline alignment implemented
1634///
1635/// ## \u00a7 5 Line Spacing (line-height property)
1636/// - `line_height`: \u2705 Implemented
1637/// - \u274c MISSING: line-fit-edge for controlling which edges contribute to line height
1638///   +spec:box-model:51342f - inline box margins/borders/padding do not affect line box height (default leading mode)
1639///   +spec:font-metrics:618776 - line-fit-edge (cap, ex, ideographic, alphabetic edge selection) not yet implemented
1640///
1641/// ## \u00a7 6 Trimming Leading (text-box-trim)
1642/// - \u274c NOT IMPLEMENTED: text-box-trim property
1643/// - \u274c NOT IMPLEMENTED: text-box-edge property
1644///   +spec:box-model:c09331 - text-box-trim trims block container first/last line to font metrics
1645///   // +spec:overflow:dc2196 - text-box-trim overflow handled as normal overflow (no special handling needed)
1646///
1647/// ## CSS Text Module Level 3
1648/// - `text_indent`: \u2705 First line indentation
1649/// - `text_justify`: \u2705 Justification algorithm (auto, inter-word, inter-character)
1650/// - `hyphenation`: \u2705 Hyphens property (none / manual / auto)
1651/// - `hanging_punctuation`: \u2705 Hanging punctuation at line edges
1652///
1653/// ## CSS Text Level 4
1654/// - `text_wrap`: \u2705 balance, pretty, stable
1655/// - `line_clamp`: \u2705 Max number of lines
1656///
1657/// ## CSS Writing Modes Level 4
1658/// - `text_combine_upright`: \u2705 Tate-chu-yoko for vertical text
1659///
1660/// ## CSS Shapes Module
1661/// - `shape_boundaries`: \u2705 Custom line box shapes
1662/// - `shape_exclusions`: \u2705 Exclusion areas (float-like behavior)
1663/// - `exclusion_margin`: \u2705 Margin around exclusions
1664///
1665/// ## Multi-column Layout
1666/// - `columns`: \u2705 Number of columns
1667/// - `column_gap`: \u2705 Gap between columns
1668///
1669/// # Known Issues:
1670/// 1. [ISSUE] `available_width` defaults to Definite(0.0) instead of containing block width
1671/// 2. [ISSUE] `vertical_align` only supports baseline
1672/// 3. [TODO] initial-letter (drop caps) not implemented
1673// +spec:box-model:415ef3 - initial letters use standard margin/padding/border box model; exclusion area = margin box
1674// +spec:box-model:d53ea3 - when block-start padding+border are zero, content edge coincides with over alignment point
1675///    +spec:positioning:fb233a - initial letter block-axis: if size < sink, use over alignment
1676#[derive(Debug, Clone)]
1677pub struct UnifiedConstraints {
1678    // Shape definition
1679    pub shape_boundaries: Vec<ShapeBoundary>,
1680    pub shape_exclusions: Vec<ShapeBoundary>,
1681
1682    // Basic layout - using AvailableSpace for proper indefinite handling
1683    pub available_width: AvailableSpace,
1684    pub available_height: Option<f32>,
1685
1686    // Text layout
1687    pub writing_mode: Option<WritingMode>,
1688    // +spec:writing-modes:6c5ab9 - blocks inherit base direction from parent via CSS direction property
1689    // Base direction from CSS, overrides auto-detection
1690    pub direction: Option<BidiDirection>,
1691    pub text_orientation: TextOrientation,
1692    pub text_align: TextAlign,
1693    pub text_justify: JustifyContent,
1694    // +spec:display-property:3bcac8 - inline boxes sized in block axis based on font metrics (ascent/descent)
1695    pub line_height: LineHeight,
1696    pub vertical_align: VerticalAlign,
1697    // block container's first available font, used for minimum line box height
1698    pub strut_ascent: f32,
1699    pub strut_descent: f32,
1700    // x-height of the strut font (scaled to font_size), for vertical-align: middle
1701    pub strut_x_height: f32,
1702
1703    // Width of '0' (zero) character in px, used for ch unit and tab-size.
1704    // Approximated as space_width from the first available font, or 0.5 * font_size fallback.
1705    pub ch_width: f32,
1706
1707    // Overflow handling
1708    pub overflow: OverflowBehavior,
1709    pub segment_alignment: SegmentAlignment,
1710
1711    // Advanced features
1712    pub text_combine_upright: Option<TextCombineUpright>,
1713    pub exclusion_margin: f32,
1714    pub hyphenation: Hyphens,
1715    pub hyphenation_language: Option<Language>,
1716    pub text_indent: f32,
1717    pub text_indent_each_line: bool,
1718    pub text_indent_hanging: bool,
1719    pub initial_letter: Option<InitialLetter>,
1720    pub line_clamp: Option<NonZeroUsize>,
1721
1722    // text-wrap: balance
1723    pub text_wrap: TextWrap,
1724    pub columns: u32,
1725    pub column_gap: f32,
1726    pub hanging_punctuation: bool,
1727    pub overflow_wrap: OverflowWrap,
1728    pub text_align_last: TextAlign,
1729    // §5.2 word-break property on constraints
1730    pub word_break: WordBreak,
1731    pub white_space_mode: WhiteSpaceMode,
1732    pub line_break: LineBreakStrictness,
1733    // CSS unicode-bidi property; Plaintext causes per-paragraph auto-detection
1734    pub unicode_bidi: UnicodeBidi,
1735}
1736
1737impl Default for UnifiedConstraints {
1738    fn default() -> Self {
1739        Self {
1740            shape_boundaries: Vec::new(),
1741            shape_exclusions: Vec::new(),
1742
1743            // Use MaxContent as default to avoid premature line breaking.
1744            // MaxContent means "use intrinsic width" which is appropriate when
1745            // the containing block's width is not yet known.
1746            // Previously this was Definite(0.0) which caused each character to
1747            // wrap to its own line. The actual width should be passed from the 
1748            // box layout solver (fc.rs) when creating UnifiedConstraints.
1749            available_width: AvailableSpace::MaxContent,
1750            available_height: None,
1751            writing_mode: None,
1752            direction: None, // Will default to LTR if not specified
1753            text_orientation: TextOrientation::default(),
1754            text_align: TextAlign::default(),
1755            text_justify: JustifyContent::default(),
1756            line_height: LineHeight::Normal,
1757            vertical_align: VerticalAlign::default(),
1758            strut_ascent: DEFAULT_STRUT_ASCENT,
1759            strut_descent: DEFAULT_STRUT_DESCENT,
1760            strut_x_height: DEFAULT_X_HEIGHT,
1761            ch_width: DEFAULT_CH_WIDTH,
1762            overflow: OverflowBehavior::default(),
1763            segment_alignment: SegmentAlignment::default(),
1764            text_combine_upright: None,
1765            exclusion_margin: 0.0,
1766            hyphenation: Hyphens::default(),
1767            hyphenation_language: None,
1768            columns: 1,
1769            column_gap: 0.0,
1770            hanging_punctuation: false,
1771            text_indent: 0.0,
1772            text_indent_each_line: false,
1773            text_indent_hanging: false,
1774            initial_letter: None,
1775            line_clamp: None,
1776            text_wrap: TextWrap::default(),
1777            overflow_wrap: OverflowWrap::default(),
1778            text_align_last: TextAlign::default(),
1779            word_break: WordBreak::default(),
1780            white_space_mode: WhiteSpaceMode::default(),
1781            line_break: LineBreakStrictness::default(),
1782            unicode_bidi: UnicodeBidi::default(),
1783        }
1784    }
1785}
1786
1787// UnifiedConstraints
1788impl Hash for UnifiedConstraints {
1789    #[allow(clippy::cast_possible_truncation)] // bounded pixel/coord/colour/glyph cast
1790    fn hash<H: Hasher>(&self, state: &mut H) {
1791        self.shape_boundaries.hash(state);
1792        self.shape_exclusions.hash(state);
1793        self.available_width.hash(state);
1794        self.available_height
1795            .map(|h| h.round() as isize)
1796            .hash(state);
1797        self.writing_mode.hash(state);
1798        self.direction.hash(state);
1799        self.text_orientation.hash(state);
1800        self.text_align.hash(state);
1801        self.text_justify.hash(state);
1802        self.line_height.hash(state);
1803        self.vertical_align.hash(state);
1804        (self.strut_ascent.round() as isize).hash(state);
1805        (self.strut_descent.round() as isize).hash(state);
1806        (self.strut_x_height.round() as isize).hash(state);
1807        (self.ch_width.round() as isize).hash(state);
1808        self.overflow.hash(state);
1809        self.segment_alignment.hash(state);
1810        self.text_combine_upright.hash(state);
1811        (self.exclusion_margin.round() as isize).hash(state);
1812        self.hyphenation.hash(state);
1813        self.hyphenation_language.hash(state);
1814        (self.text_indent.round() as isize).hash(state);
1815        self.text_indent_each_line.hash(state);
1816        self.text_indent_hanging.hash(state);
1817        self.initial_letter.hash(state);
1818        self.line_clamp.hash(state);
1819        self.columns.hash(state);
1820        (self.column_gap.round() as isize).hash(state);
1821        self.hanging_punctuation.hash(state);
1822        self.overflow_wrap.hash(state);
1823        self.text_align_last.hash(state);
1824        self.word_break.hash(state);
1825        self.white_space_mode.hash(state);
1826        self.line_break.hash(state);
1827        self.unicode_bidi.hash(state);
1828    }
1829}
1830
1831impl PartialEq for UnifiedConstraints {
1832    fn eq(&self, other: &Self) -> bool {
1833        self.shape_boundaries == other.shape_boundaries
1834            && self.shape_exclusions == other.shape_exclusions
1835            && self.available_width == other.available_width
1836            && match (self.available_height, other.available_height) {
1837                (None, None) => true,
1838                (Some(h1), Some(h2)) => round_eq(h1, h2),
1839                _ => false,
1840            }
1841            && self.writing_mode == other.writing_mode
1842            && self.direction == other.direction
1843            && self.text_orientation == other.text_orientation
1844            && self.text_align == other.text_align
1845            && self.text_justify == other.text_justify
1846            && self.line_height == other.line_height
1847            && self.vertical_align == other.vertical_align
1848            && round_eq(self.strut_ascent, other.strut_ascent)
1849            && round_eq(self.strut_descent, other.strut_descent)
1850            && round_eq(self.strut_x_height, other.strut_x_height)
1851            && round_eq(self.ch_width, other.ch_width)
1852            && self.overflow == other.overflow
1853            && self.segment_alignment == other.segment_alignment
1854            && self.text_combine_upright == other.text_combine_upright
1855            && round_eq(self.exclusion_margin, other.exclusion_margin)
1856            && self.hyphenation == other.hyphenation
1857            && self.hyphenation_language == other.hyphenation_language
1858            && round_eq(self.text_indent, other.text_indent)
1859            && self.text_indent_each_line == other.text_indent_each_line
1860            && self.text_indent_hanging == other.text_indent_hanging
1861            && self.initial_letter == other.initial_letter
1862            && self.line_clamp == other.line_clamp
1863            && self.columns == other.columns
1864            && round_eq(self.column_gap, other.column_gap)
1865            && self.hanging_punctuation == other.hanging_punctuation
1866            && self.overflow_wrap == other.overflow_wrap
1867            && self.text_align_last == other.text_align_last
1868            && self.word_break == other.word_break
1869            && self.white_space_mode == other.white_space_mode
1870            && self.line_break == other.line_break
1871            && self.unicode_bidi == other.unicode_bidi
1872    }
1873}
1874
1875impl Eq for UnifiedConstraints {}
1876
1877impl UnifiedConstraints {
1878    /// Resolve `line_height` to a pixel value using the strut metrics as a font-size proxy.
1879    /// `strut_ascent + strut_descent` approximates `font_size` (the block container's font).
1880    #[must_use] pub fn resolved_line_height(&self) -> f32 {
1881        match self.line_height {
1882            // `line-height: normal` — the minimum line-box height is the block's
1883            // first-available-font metrics, approximated here by the strut's
1884            // ascent + descent. Resolving `Normal` with no real metrics fell back
1885            // to `font_size * 1.2`, which inflated every non-last line's advance
1886            // ~20% (block auto-heights came out too tall). The real per-line box
1887            // height (from each run's actual glyph metrics) is folded in via
1888            // `.max()` at the call sites, so this strut value is the correct floor.
1889            LineHeight::Normal => self.strut_ascent + self.strut_descent,
1890            LineHeight::Px(px) => px,
1891        }
1892    }
1893    fn direction(&self, fallback: BidiDirection) -> BidiDirection {
1894        self.writing_mode.map_or(fallback, |s| s.get_direction().unwrap_or(fallback))
1895    }
1896    const fn is_vertical(&self) -> bool {
1897        matches!(
1898            self.writing_mode,
1899            Some(WritingMode::VerticalRl | WritingMode::VerticalLr)
1900        )
1901    }
1902}
1903
1904/// Line constraints with multi-segment support
1905#[derive(Debug, Clone)]
1906pub struct LineConstraints {
1907    pub segments: Vec<LineSegment>,
1908    pub total_available: f32,
1909    /// True when measuring min-content: the breaker must break at EVERY soft-wrap
1910    /// opportunity (each word on its own line) rather than filling `total_available`
1911    /// (which is a sentinel `f32::MAX / 2` for intrinsic sizing and never overflows).
1912    pub is_min_content: bool,
1913}
1914
1915impl WritingMode {
1916    #[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)
1917    #[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
1918    const fn get_direction(&self) -> Option<BidiDirection> {
1919        match self {
1920            // determined by text content
1921            Self::HorizontalTb => None,
1922            Self::VerticalRl => Some(BidiDirection::Rtl),
1923            Self::VerticalLr => Some(BidiDirection::Ltr),
1924            Self::SidewaysRl => Some(BidiDirection::Rtl),
1925            Self::SidewaysLr => Some(BidiDirection::Ltr),
1926        }
1927    }
1928}
1929
1930// Stage 1: Collection - Styled runs from DOM traversal
1931#[derive(Debug, Clone, Hash)]
1932pub struct StyledRun {
1933    pub text: String,
1934    pub style: Arc<StyleProperties>,
1935    /// Byte index in the original logical paragraph text
1936    pub logical_start_byte: usize,
1937    /// The DOM `NodeId` of the Text node this run came from.
1938    /// None for generated content (e.g., list markers, `::before/::after`).
1939    pub source_node_id: Option<NodeId>,
1940}
1941
1942// Stage 2: Bidi Analysis - Visual runs in display order
1943#[derive(Debug, Clone)]
1944pub struct VisualRun<'a> {
1945    pub text_slice: &'a str,
1946    pub style: Arc<StyleProperties>,
1947    pub logical_start_byte: usize,
1948    pub bidi_level: BidiLevel,
1949    pub script: Script,
1950    pub language: Language,
1951}
1952
1953// Font and styling types
1954
1955/// A selector for loading fonts from the font cache.
1956/// Used by `FontManager` to query fontconfig and load font files.
1957#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
1958pub struct FontSelector {
1959    pub family: String,
1960    pub weight: FcWeight,
1961    pub style: FontStyle,
1962    pub unicode_ranges: Vec<UnicodeRange>,
1963}
1964
1965impl Default for FontSelector {
1966    fn default() -> Self {
1967        Self {
1968            family: "serif".to_string(),
1969            weight: FcWeight::Normal,
1970            style: FontStyle::Normal,
1971            unicode_ranges: Vec::new(),
1972        }
1973    }
1974}
1975
1976/// Font stack that can be either a list of font selectors (resolved via fontconfig)
1977/// or a direct `FontRef` (bypasses fontconfig entirely).
1978///
1979/// When a `FontRef` is used, it bypasses fontconfig resolution entirely
1980/// and uses the pre-parsed font data directly. This is used for embedded
1981/// fonts like Material Icons.
1982// [g121 az-web-lift] `#[repr(C, u8)]` — same disc-mis-lift guard as the other text3 enums; matched in
1983// shape_visual_items (`match &style.font_stack { Ref => shape, Stack => resolve }`). repr(Rust) niche
1984// (from the Vec/FontRef payloads) could mis-route. Explicit u8 tag = simple load. Internal to text3.
1985#[derive(Debug, Clone)]
1986#[repr(C, u8)]
1987pub enum FontStack {
1988    /// A stack of font selectors to be resolved via fontconfig
1989    /// First font is primary, rest are fallbacks
1990    Stack(Vec<FontSelector>),
1991    /// A direct reference to a pre-parsed font (e.g., embedded icon fonts)
1992    /// This font covers the entire Unicode range and has no fallbacks.
1993    Ref(azul_css::props::basic::font::FontRef),
1994}
1995
1996impl Default for FontStack {
1997    fn default() -> Self {
1998        Self::Stack(vec![FontSelector::default()])
1999    }
2000}
2001
2002impl FontStack {
2003    /// Returns true if this is a direct `FontRef`
2004    #[must_use] pub const fn is_ref(&self) -> bool {
2005        matches!(self, Self::Ref(_))
2006    }
2007
2008    /// Returns the `FontRef` if this is a Ref variant
2009    #[must_use] pub const fn as_ref(&self) -> Option<&azul_css::props::basic::font::FontRef> {
2010        match self {
2011            Self::Ref(r) => Some(r),
2012            Self::Stack(_) => None,
2013        }
2014    }
2015
2016    /// Returns the font selectors if this is a Stack variant
2017    #[must_use] pub fn as_stack(&self) -> Option<&[FontSelector]> {
2018        match self {
2019            Self::Stack(s) => Some(s),
2020            Self::Ref(_) => None,
2021        }
2022    }
2023
2024    /// Returns the first `FontSelector` if this is a Stack variant, None if Ref
2025    #[must_use] pub fn first_selector(&self) -> Option<&FontSelector> {
2026        match self {
2027            Self::Stack(s) => s.first(),
2028            Self::Ref(_) => None,
2029        }
2030    }
2031
2032    /// Returns the first font family name (for Stack) or a placeholder (for Ref)
2033    #[must_use] pub fn first_family(&self) -> &str {
2034        match self {
2035            Self::Stack(s) => s.first().map_or("serif", |f| f.family.as_str()),
2036            Self::Ref(_) => "<embedded-font>",
2037        }
2038    }
2039}
2040
2041impl PartialEq for FontStack {
2042    fn eq(&self, other: &Self) -> bool {
2043        match (self, other) {
2044            (Self::Stack(a), Self::Stack(b)) => a == b,
2045            (Self::Ref(a), Self::Ref(b)) => a.parsed == b.parsed,
2046            _ => false,
2047        }
2048    }
2049}
2050
2051impl Eq for FontStack {}
2052
2053impl Hash for FontStack {
2054    fn hash<H: Hasher>(&self, state: &mut H) {
2055        discriminant(self).hash(state);
2056        match self {
2057            Self::Stack(s) => s.hash(state),
2058            Self::Ref(r) => (r.parsed as usize).hash(state),
2059        }
2060    }
2061}
2062
2063/// A reference to a font for rendering, identified by its hash.
2064/// This hash corresponds to `ParsedFont::hash` and is used to look up
2065/// the actual font data in the renderer's font cache.
2066#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
2067pub struct FontHash {
2068    /// The hash of the `ParsedFont`. 0 means invalid/unknown font.
2069    pub font_hash: u64,
2070}
2071
2072impl FontHash {
2073    #[must_use] pub const fn invalid() -> Self {
2074        Self { font_hash: 0 }
2075    }
2076
2077    #[must_use] pub const fn from_hash(font_hash: u64) -> Self {
2078        Self { font_hash }
2079    }
2080}
2081
2082#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
2083pub enum FontStyle {
2084    Normal,
2085    Italic,
2086    Oblique,
2087}
2088
2089/// Defines how text should be aligned when a line contains multiple disjoint segments.
2090#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
2091pub enum SegmentAlignment {
2092    /// Align text within the first available segment on the line.
2093    #[default]
2094    First,
2095    /// Align text relative to the total available width of all
2096    /// segments on the line combined.
2097    Total,
2098}
2099
2100#[derive(Copy, Debug, Clone)]
2101pub struct VerticalMetrics {
2102    pub advance: f32,
2103    pub bearing_x: f32,
2104    pub bearing_y: f32,
2105    pub origin_y: f32,
2106}
2107
2108// +spec:font-metrics:df51b1 - font metrics (ascent, descent, line_gap) used as baselines for inline layout alignment and box sizing
2109/// Layout-specific font metrics extracted from `FontMetrics`
2110/// Contains only the metrics needed for text layout and rendering
2111// +spec:box-model:a2f1c1 - inline box content area sized from first available font metrics (ascent/descent)
2112// +spec:font-metrics:9c2ca5 - ascent and descent metrics per font for inline layout
2113// +spec:font-metrics:797593 - font metrics (ascent, descent, line-gap) used for baseline calculations
2114// +spec:font-metrics:842d6a - font metrics (ascent, descent) used for precise spacing control
2115// +spec:font-metrics:eb97e0 - Font baseline metrics (ascent/descent) from font tables used for baseline alignment
2116// +spec:font-metrics:f2cd75 - em-over/em-under baselines intentionally not included (not used by CSS per spec)
2117// +spec:inline-formatting-context:76cd57 - ascent/descent font metrics for inline formatting context layout
2118// +spec:font-metrics:207e6b - ascent/descent metrics used for baseline calculations
2119#[derive(Copy, Debug, Clone)]
2120pub struct LayoutFontMetrics {
2121    pub ascent: f32,
2122    pub descent: f32,
2123    pub line_gap: f32,
2124    pub units_per_em: u16,
2125    /// OS/2 sxHeight: distance from baseline to top of lowercase 'x' (in font units).
2126    /// Used for `vertical-align: middle` per CSS Inline 3 §4.1.
2127    pub x_height: Option<f32>,
2128    /// OS/2 sCapHeight: height of capital letters from baseline (in font units).
2129    /// Used for drop cap / initial-letter alignment per CSS Inline 3 §7.1.1.
2130    pub cap_height: Option<f32>,
2131}
2132
2133impl LayoutFontMetrics {
2134    // +spec:font-metrics:006bd8 - baseline position from font design coordinates, scaled with font size
2135    // +spec:font-metrics:910c0a - dominant-baseline: auto resolves to alphabetic for horizontal text
2136    // +spec:writing-modes:098958 - baseline is along the inline axis, used to align glyphs
2137    #[must_use] pub fn baseline_scaled(&self, font_size: f32) -> f32 {
2138        let scale = font_size / f32::from(self.units_per_em);
2139        self.ascent * scale
2140    }
2141
2142    /// Returns the x-height scaled to the given font size in px.
2143    /// Falls back to 0.5em when the font doesn't provide sxHeight.
2144    #[must_use] pub fn x_height_scaled(&self, font_size: f32) -> f32 {
2145        let scale = font_size / f32::from(self.units_per_em);
2146        self.x_height.map_or(font_size * 0.5, |xh| xh * scale)
2147    }
2148
2149    /// Returns the cap height scaled to the given font size in px.
2150    /// Falls back to ascent when the font doesn't provide sCapHeight.
2151    #[must_use] pub fn cap_height_scaled(&self, font_size: f32) -> f32 {
2152        let scale = font_size / f32::from(self.units_per_em);
2153        self.cap_height.unwrap_or(self.ascent) * scale
2154    }
2155
2156    // +spec:line-height:471816 - line gap metric extracted from font for optional use when line-height is normal
2157    /// Convert from full `FontMetrics` to layout-specific metrics.
2158    ///
2159    // +spec:font-metrics:05193a - prefer OS/2 sTypoAscender/sTypoDescender, fall back to HHEA
2160    // +spec:font-metrics:17a71c - prefer OS/2 sTypoAscender/sTypoDescender, fall back to HHEA
2161    // +spec:font-metrics:62c659 - prefer OS/2 sTypoAscender/sTypoDescender, fall back to HHEA
2162    // +spec:writing-modes:451a3e - ascent/descent/line-gap metrics: prefer OS/2, fallback HHEA, floor line_gap at 0
2163    /// Per CSS 2.2 §10.8.1: prefer OS/2 sTypoAscender/sTypoDescender,
2164    /// fall back to HHEA Ascent/Descent if OS/2 metrics are absent.
2165    // +spec:font-metrics:3dc8c1 - text-over/text-under baselines from font ascent/descent metrics
2166    // +spec:font-metrics:332c16 - text-over/text-under baseline metrics derived from font ascent/descent
2167    // +spec:font-metrics:9895e2 - baseline table is a font-level property; metrics apply uniformly to all glyphs
2168    // +spec:font-metrics:e05c40 - font ascent/descent metric extraction (text edge metrics)
2169    // +spec:font-metrics:21a3de - ascent/descent used as basis for em-over/em-under normalization
2170    // +spec:font-metrics:1257b7 - font ascent/descent ensure text fits within line box
2171    // +spec:table-layout:6bbd10 - use sTypoAscender/sTypoDescender as ascent/descent metrics per spec recommendation
2172    // +spec:font-metrics:5346d2 - prefer OS/2 sTypoAscender/sTypoDescender, fall back to HHEA
2173    // +spec:font-metrics:e16941 - line gap metric floored at zero per spec
2174    // +spec:font-metrics:a55c05 - metrics taken from font, synthesized if missing (prefers OS/2, falls back to HHEA)
2175    #[must_use] pub fn from_font_metrics(metrics: &azul_css::props::basic::FontMetrics) -> Self {
2176        let ascent = metrics.s_typo_ascender
2177            .as_option()
2178            .map_or_else(|| f32::from(metrics.ascender), |v| f32::from(*v));
2179        let descent = metrics.s_typo_descender
2180            .as_option()
2181            .map_or_else(|| f32::from(metrics.descender), |v| f32::from(*v));
2182        // UAs must floor the line gap metric at zero (css-inline-3 §3.2.2)
2183        // Spec: "UAs must floor the line gap metric at zero."
2184        let line_gap = metrics.s_typo_line_gap
2185            .as_option()
2186            .map_or_else(|| f32::from(metrics.line_gap), |v| f32::from(*v))
2187            .max(0.0);
2188        let x_height = metrics.sx_height
2189            .as_option()
2190            .map(|v| f32::from(*v));
2191        let cap_height = metrics.s_cap_height
2192            .as_option()
2193            .map(|v| f32::from(*v));
2194        Self {
2195            ascent,
2196            descent,
2197            line_gap,
2198            units_per_em: metrics.units_per_em,
2199            x_height,
2200            cap_height,
2201        }
2202    }
2203
2204    // +spec:font-metrics:1eda6b - em-over is 0.5em over central baseline, em-under is 0.5em under
2205    /// Synthesize em-over baseline offset (in font units).
2206    /// Per CSS Inline 3 Appendix A.1: em-over = central baseline + 0.5em.
2207    /// Central baseline is synthesized as midpoint of ascent and descent.
2208    #[must_use] pub fn em_over(&self) -> f32 {
2209        let central = self.central_baseline();
2210        central + (f32::from(self.units_per_em) / 2.0)
2211    }
2212
2213    /// Synthesize em-under baseline offset (in font units).
2214    /// Per CSS Inline 3 Appendix A.1: em-under = central baseline - 0.5em.
2215    #[must_use] pub fn em_under(&self) -> f32 {
2216        let central = self.central_baseline();
2217        central - (f32::from(self.units_per_em) / 2.0)
2218    }
2219
2220    /// Synthesize central baseline (in font units).
2221    /// Midpoint between ascent and descent when not provided by the font.
2222    #[must_use] pub const fn central_baseline(&self) -> f32 {
2223        f32::midpoint(self.ascent, self.descent)
2224    }
2225}
2226
2227#[derive(Copy, Debug, Clone)]
2228pub struct LineSegment {
2229    pub start_x: f32,
2230    pub width: f32,
2231    // For choosing best segment when multiple available
2232    pub priority: u8,
2233}
2234
2235#[derive(Debug, Clone, Copy, PartialEq, Hash, Eq, PartialOrd, Ord, Default)]
2236pub enum TextWrap {
2237    #[default]
2238    Wrap,
2239    Balance,
2240    NoWrap,
2241}
2242
2243/// CSS `overflow-wrap` (aka `word-wrap`) property.
2244///
2245/// Controls whether an otherwise unbreakable sequence of characters
2246/// may be broken at an arbitrary point to prevent overflow.
2247#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
2248pub enum OverflowWrap {
2249    /// No special break opportunities are introduced.
2250    #[default]
2251    Normal,
2252    /// Break at arbitrary points if no other break points exist.
2253    /// Soft wrap opportunities from `anywhere` ARE considered
2254    /// when calculating min-content intrinsic sizes.
2255    Anywhere,
2256    /// Same as `anywhere` except soft wrap opportunities introduced
2257    /// by `break-word` are NOT considered when calculating
2258    /// min-content intrinsic sizes.
2259    BreakWord,
2260}
2261
2262// +spec:line-breaking:841a87 - hyphens property: manual (U+00AD/U+2010 only) and auto (language-aware automatic hyphenation)
2263// +spec:line-breaking:68c6ad - hyphens property controls hyphenation opportunities (none/manual/auto)
2264/// Controls whether hyphenation is allowed to create soft wrap opportunities.
2265#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
2266pub enum Hyphens {
2267    /// No hyphenation: U+00AD soft hyphens are not treated as break points.
2268    None,
2269    /// Only break at manually-inserted soft hyphens (U+00AD) or explicit hyphens.
2270    #[default]
2271    Manual,
2272    /// The UA may automatically hyphenate words in addition to manual opportunities.
2273    Auto,
2274}
2275
2276// +spec:line-breaking:ce5258 - white-space property controls collapsing, wrapping, and forced breaks
2277// +spec:line-breaking:35817b - normal/pre/nowrap/pre-wrap/break-spaces/pre-line behaviors
2278// +spec:white-space-processing:dec7aa - White space not removed/collapsed is "preserved white space"
2279#[derive(Debug, Clone, Copy, PartialEq, Hash, Eq, PartialOrd, Ord, Default)]
2280pub enum WhiteSpaceMode {
2281    #[default]
2282    Normal,
2283    Nowrap,
2284    Pre,
2285    PreWrap,
2286    PreLine,
2287    BreakSpaces,
2288}
2289
2290// CSS Text Level 3 §5.3: The line-break property controls strictness of line breaking rules.
2291// - Auto: UA-dependent, typically normal for CJK, loose for non-CJK
2292// - Loose: least restrictive, allows breaks before small kana, CJK hyphens, etc.
2293// - Normal: default CJK rules, allows breaks before CJK hyphen-like chars for CJK text
2294// - Strict: most restrictive, forbids breaks before small kana and CJK punctuation
2295// - Anywhere: allows soft wrap opportunities around every typographic character unit
2296#[derive(Debug, Clone, Copy, PartialEq, Hash, Eq, PartialOrd, Ord, Default)]
2297pub enum LineBreakStrictness {
2298    #[default]
2299    Auto,
2300    Loose,
2301    Normal,
2302    Strict,
2303    /// Soft wrap opportunity around every typographic character unit.
2304    /// Hyphenation is not applied.
2305    Anywhere,
2306}
2307
2308// §5.2 word-break property: normal, break-all, keep-all
2309#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
2310pub enum WordBreak {
2311    /// Normal break rules: CJK characters break between each other,
2312    /// non-CJK text only breaks at spaces/hyphens.
2313    #[default]
2314    Normal,
2315    /// Allow breaks between any two characters, including within Latin words.
2316    BreakAll,
2317    /// Suppress breaks between CJK characters (treat them like Latin words,
2318    /// only breaking at spaces). Sequences of CJK characters do not break.
2319    KeepAll,
2320}
2321
2322// +spec:display-property:162c99 - Initial letter box: in-flow inline-level box with special layout behavior
2323// +spec:display-property:72a797 - Initial letter handled like inline-level content in originating line box
2324// initial-letter
2325// +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
2326// +spec:font-metrics:1e5325 - drop initial cap-height = (N-1)*line_height + surrounding cap-height
2327// +spec:font-metrics:3aa518 - initial-letter-align: cap-height/ideographic/hanging/leading/border-box baseline alignment
2328// +spec:writing-modes:9698b0 - Han-derived scripts: initial letter extends from block-start to block-end of Nth line
2329#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
2330pub struct InitialLetter {
2331    /// How many lines tall the initial letter should be.
2332    pub size: f32,
2333    // +spec:font-metrics:dc0632 - raised initial "sinks" to first text baseline (sink=1)
2334    /// How many lines the letter should sink into.
2335    pub sink: u32,
2336    /// How many characters to apply this styling to.
2337    pub count: NonZeroUsize,
2338    // +spec:display-property:4c69bf - alignment points for sizing/positioning initial letter
2339    /// Alignment mode for the initial letter (over/under alignment points
2340    /// matched to corresponding points of the root inline box).
2341    pub align: InitialLetterAlign,
2342}
2343
2344/// Alignment mode for initial letters, controlling which alignment points
2345/// are used to size and position the letter relative to the root inline box.
2346#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
2347pub enum InitialLetterAlign {
2348    /// UA chooses based on script
2349    Auto,
2350    /// Alphabetic baseline alignment
2351    Alphabetic,
2352    /// Hanging baseline alignment
2353    Hanging,
2354    /// Ideographic baseline alignment
2355    Ideographic,
2356}
2357
2358// A type that implements `Hash` must also implement `Eq`.
2359// Since f32 does not implement `Eq`, we provide a manual implementation.
2360// This is a marker trait, indicating that `a == b` is a true equivalence
2361// relation. The derived `PartialEq` already satisfies this.
2362impl Eq for InitialLetter {}
2363
2364impl Hash for InitialLetter {
2365    #[allow(clippy::cast_possible_truncation)] // bounded pixel/coord/colour/glyph cast
2366    fn hash<H: Hasher>(&self, state: &mut H) {
2367        // Per the request, round the f32 to a usize for hashing.
2368        // This is a lossy conversion; values like 2.3 and 2.4 will produce
2369        // the same hash value for this field. This is acceptable as long as
2370        // the `PartialEq` implementation correctly distinguishes them.
2371        (self.size.round() as isize).hash(state);
2372        self.sink.hash(state);
2373        self.count.hash(state);
2374        self.align.hash(state);
2375    }
2376}
2377
2378// Path and shape definitions
2379#[derive(Copy, Debug, Clone, PartialOrd)]
2380pub enum PathSegment {
2381    MoveTo(Point),
2382    LineTo(Point),
2383    CurveTo {
2384        control1: Point,
2385        control2: Point,
2386        end: Point,
2387    },
2388    QuadTo {
2389        control: Point,
2390        end: Point,
2391    },
2392    Arc {
2393        center: Point,
2394        radius: f32,
2395        start_angle: f32,
2396        end_angle: f32,
2397    },
2398    Close,
2399}
2400
2401// PathSegment
2402impl Hash for PathSegment {
2403    #[allow(clippy::cast_possible_truncation)] // bounded pixel/coord/colour/glyph cast
2404    #[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
2405    fn hash<H: Hasher>(&self, state: &mut H) {
2406        // Hash the enum variant's discriminant first to distinguish them
2407        discriminant(self).hash(state);
2408
2409        match self {
2410            Self::MoveTo(p) => p.hash(state),
2411            Self::LineTo(p) => p.hash(state),
2412            Self::CurveTo {
2413                control1,
2414                control2,
2415                end,
2416            } => {
2417                control1.hash(state);
2418                control2.hash(state);
2419                end.hash(state);
2420            }
2421            Self::QuadTo { control, end } => {
2422                control.hash(state);
2423                end.hash(state);
2424            }
2425            Self::Arc {
2426                center,
2427                radius,
2428                start_angle,
2429                end_angle,
2430            } => {
2431                center.hash(state);
2432                (radius.round() as isize).hash(state);
2433                (start_angle.round() as isize).hash(state);
2434                (end_angle.round() as isize).hash(state);
2435            }
2436            Self::Close => {} // No data to hash
2437        }
2438    }
2439}
2440
2441impl PartialEq for PathSegment {
2442    #[allow(clippy::similar_names)] // domain-standard coordinate/geometry/short-lived names
2443    #[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
2444    fn eq(&self, other: &Self) -> bool {
2445        match (self, other) {
2446            (Self::MoveTo(a), Self::MoveTo(b)) => a == b,
2447            (Self::LineTo(a), Self::LineTo(b)) => a == b,
2448            (
2449                Self::CurveTo {
2450                    control1: c1a,
2451                    control2: c2a,
2452                    end: ea,
2453                },
2454                Self::CurveTo {
2455                    control1: c1b,
2456                    control2: c2b,
2457                    end: eb,
2458                },
2459            ) => c1a == c1b && c2a == c2b && ea == eb,
2460            (
2461                Self::QuadTo {
2462                    control: ca,
2463                    end: ea,
2464                },
2465                Self::QuadTo {
2466                    control: cb,
2467                    end: eb,
2468                },
2469            ) => ca == cb && ea == eb,
2470            (
2471                Self::Arc {
2472                    center: ca,
2473                    radius: ra,
2474                    start_angle: sa_a,
2475                    end_angle: ea_a,
2476                },
2477                Self::Arc {
2478                    center: cb,
2479                    radius: rb,
2480                    start_angle: sa_b,
2481                    end_angle: ea_b,
2482                },
2483            ) => ca == cb && round_eq(*ra, *rb) && round_eq(*sa_a, *sa_b) && round_eq(*ea_a, *ea_b),
2484            (Self::Close, Self::Close) => true,
2485            _ => false, // Variants are different
2486        }
2487    }
2488}
2489
2490impl Eq for PathSegment {}
2491
2492// Enhanced content model supporting mixed inline content
2493// [g117 az-web-lift FIX] `#[repr(C, u8)]` (was repr(Rust)): the web lift MIS-READS a repr(Rust)
2494// niche/compiler-placed discriminant — `<InlineContent as Clone>::clone` and create_logical_items'
2495// match both mis-route a Text(disc 0) to a Vec-bearing variant → clone reads a heap ptr as a Vec len
2496// → ~789MB alloc → OOB (g111/g115/g116 named stack = InlineContent::clone ← create_logical_items;
2497// content is CLEAN: len=1, ptr ok, disc-at-0=0). An explicit u8 tag at offset 0 (no niche) lowers to
2498// a simple load the lift handles correctly — the layout other (repr(C,u8)) enums use. Not FFI-exposed
2499// (internal to text3; only native shell code matches it), so the repr change is layout-safe.
2500#[derive(Debug, Clone, Hash)]
2501#[repr(C, u8)]
2502pub enum InlineContent {
2503    Text(StyledRun),
2504    Image(InlineImage),
2505    Shape(InlineShape),
2506    Space(InlineSpace),
2507    LineBreak(InlineBreak),
2508    /// Tab character - rendered with width based on tab-size CSS property
2509    Tab {
2510        style: Arc<StyleProperties>,
2511    },
2512    /// List marker (`::marker` pseudo-element)
2513    /// Markers with list-style-position: outside are positioned
2514    /// in the padding gutter of the list container
2515    Marker {
2516        run: StyledRun,
2517        /// Whether marker is positioned outside (in padding) or inside (inline)
2518        position_outside: bool,
2519    },
2520    // Ruby annotation
2521    Ruby {
2522        base: Vec<InlineContent>,
2523        text: Vec<InlineContent>,
2524        // Style for the ruby text itself
2525        style: Arc<StyleProperties>,
2526    },
2527}
2528
2529#[derive(Debug, Clone)]
2530pub struct InlineImage {
2531    pub source: ImageSource,
2532    pub intrinsic_size: Size,
2533    pub display_size: Option<Size>,
2534    // How much to shift baseline
2535    pub baseline_offset: f32,
2536    pub alignment: VerticalAlign,
2537    pub object_fit: ObjectFit,
2538}
2539
2540impl PartialEq for InlineImage {
2541    fn eq(&self, other: &Self) -> bool {
2542        self.baseline_offset.to_bits() == other.baseline_offset.to_bits()
2543            && self.source == other.source
2544            && self.intrinsic_size == other.intrinsic_size
2545            && self.display_size == other.display_size
2546            && self.alignment == other.alignment
2547            && self.object_fit == other.object_fit
2548    }
2549}
2550
2551impl Eq for InlineImage {}
2552
2553impl Hash for InlineImage {
2554    fn hash<H: Hasher>(&self, state: &mut H) {
2555        self.source.hash(state);
2556        self.intrinsic_size.hash(state);
2557        self.display_size.hash(state);
2558        self.baseline_offset.to_bits().hash(state);
2559        self.alignment.hash(state);
2560        self.object_fit.hash(state);
2561    }
2562}
2563
2564impl PartialOrd for InlineImage {
2565    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
2566        Some(self.cmp(other))
2567    }
2568}
2569
2570impl Ord for InlineImage {
2571    fn cmp(&self, other: &Self) -> Ordering {
2572        self.source
2573            .cmp(&other.source)
2574            .then_with(|| self.intrinsic_size.cmp(&other.intrinsic_size))
2575            .then_with(|| self.display_size.cmp(&other.display_size))
2576            .then_with(|| self.baseline_offset.total_cmp(&other.baseline_offset))
2577            .then_with(|| self.alignment.cmp(&other.alignment))
2578            .then_with(|| self.object_fit.cmp(&other.object_fit))
2579    }
2580}
2581
2582/// Enhanced glyph with all features
2583#[derive(Debug, Clone)]
2584pub struct Glyph {
2585    // Core glyph data
2586    pub glyph_id: u16,
2587    pub codepoint: char,
2588    /// Hash of the font - use `LoadedFonts` to look up the actual font when needed
2589    pub font_hash: u64,
2590    /// Cached font metrics to avoid font lookup for common operations
2591    pub font_metrics: LayoutFontMetrics,
2592    pub style: Arc<StyleProperties>,
2593    pub source: GlyphSource,
2594
2595    // Text mapping
2596    pub logical_byte_index: usize,
2597    pub logical_byte_len: usize,
2598    pub content_index: usize,
2599    pub cluster: u32,
2600
2601    // Metrics
2602    pub advance: f32,
2603    pub kerning: f32,
2604    pub offset: Point,
2605
2606    // Vertical text support
2607    pub vertical_advance: f32,
2608    pub vertical_origin_y: f32, // from VORG
2609    pub vertical_bearing: Point,
2610    pub orientation: GlyphOrientation,
2611
2612    // Layout properties
2613    pub script: Script,
2614    pub bidi_level: BidiLevel,
2615}
2616
2617impl Glyph {
2618    #[inline]
2619    fn bounds(&self) -> Rect {
2620        Rect {
2621            x: 0.0,
2622            y: 0.0,
2623            width: self.advance,
2624            height: self.style.line_height.resolve_with_metrics(self.style.font_size_px, &self.font_metrics),
2625        }
2626    }
2627
2628    #[inline]
2629    const fn character_class(&self) -> CharacterClass {
2630        classify_character(self.codepoint as u32)
2631    }
2632
2633    #[inline]
2634    fn is_whitespace(&self) -> bool {
2635        self.character_class() == CharacterClass::Space
2636    }
2637
2638    #[inline]
2639    fn can_justify(&self) -> bool {
2640        !self.codepoint.is_whitespace() && self.character_class() != CharacterClass::Combining
2641    }
2642
2643    #[inline]
2644    const fn justification_priority(&self) -> u8 {
2645        get_justification_priority(self.character_class())
2646    }
2647
2648    #[inline]
2649    const fn break_opportunity_after(&self) -> bool {
2650        let is_whitespace = self.codepoint.is_whitespace();
2651        let is_soft_hyphen = self.codepoint == '\u{00AD}';
2652        let is_hyphen_minus = self.codepoint == '\u{002D}';
2653        let is_hyphen = self.codepoint == '\u{2010}';
2654        is_whitespace || is_soft_hyphen || is_hyphen_minus || is_hyphen
2655    }
2656}
2657
2658// Information about text runs after initial analysis
2659#[derive(Debug, Clone)]
2660pub(crate) struct TextRunInfo<'a> {
2661    pub(crate) text: &'a str,
2662    pub(crate) style: Arc<StyleProperties>,
2663    pub(crate) logical_start: usize,
2664    pub(crate) content_index: usize,
2665}
2666
2667#[derive(Debug, Clone)]
2668pub enum ImageSource {
2669    /// Direct reference to decoded image (from DOM `NodeType::Image`)
2670    Ref(ImageRef),
2671    /// CSS url reference (from background-image, needs `ImageCache` lookup)
2672    Url(String),
2673    /// Raw image data
2674    Data(Arc<[u8]>),
2675    /// SVG source
2676    Svg(Arc<str>),
2677    /// Placeholder for layout without actual image
2678    Placeholder(Size),
2679}
2680
2681impl PartialEq for ImageSource {
2682    fn eq(&self, other: &Self) -> bool {
2683        match (self, other) {
2684            (Self::Ref(a), Self::Ref(b)) => a.get_hash() == b.get_hash(),
2685            (Self::Url(a), Self::Url(b)) => a == b,
2686            (Self::Data(a), Self::Data(b)) => Arc::ptr_eq(a, b),
2687            (Self::Svg(a), Self::Svg(b)) => Arc::ptr_eq(a, b),
2688            (Self::Placeholder(a), Self::Placeholder(b)) => {
2689                a.width.to_bits() == b.width.to_bits() && a.height.to_bits() == b.height.to_bits()
2690            }
2691            _ => false,
2692        }
2693    }
2694}
2695
2696impl Eq for ImageSource {}
2697
2698impl Hash for ImageSource {
2699    fn hash<H: Hasher>(&self, state: &mut H) {
2700        discriminant(self).hash(state);
2701        match self {
2702            Self::Ref(r) => r.get_hash().hash(state),
2703            Self::Url(s) => s.hash(state),
2704            Self::Data(d) => (Arc::as_ptr(d).cast::<u8>() as usize).hash(state),
2705            Self::Svg(s) => (Arc::as_ptr(s).cast::<u8>() as usize).hash(state),
2706            Self::Placeholder(sz) => {
2707                sz.width.to_bits().hash(state);
2708                sz.height.to_bits().hash(state);
2709            }
2710        }
2711    }
2712}
2713
2714impl PartialOrd for ImageSource {
2715    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
2716        Some(self.cmp(other))
2717    }
2718}
2719
2720impl Ord for ImageSource {
2721    fn cmp(&self, other: &Self) -> Ordering {
2722        const fn variant_index(s: &ImageSource) -> u8 {
2723            match s {
2724                ImageSource::Ref(_) => 0,
2725                ImageSource::Url(_) => 1,
2726                ImageSource::Data(_) => 2,
2727                ImageSource::Svg(_) => 3,
2728                ImageSource::Placeholder(_) => 4,
2729            }
2730        }
2731        match (self, other) {
2732            (Self::Ref(a), Self::Ref(b)) => a.get_hash().cmp(&b.get_hash()),
2733            (Self::Url(a), Self::Url(b)) => a.cmp(b),
2734            (Self::Data(a), Self::Data(b)) => {
2735                (Arc::as_ptr(a).cast::<u8>() as usize).cmp(&(Arc::as_ptr(b).cast::<u8>() as usize))
2736            }
2737            (Self::Svg(a), Self::Svg(b)) => {
2738                (Arc::as_ptr(a).cast::<u8>() as usize).cmp(&(Arc::as_ptr(b).cast::<u8>() as usize))
2739            }
2740            (Self::Placeholder(a), Self::Placeholder(b)) => {
2741                (a.width.to_bits(), a.height.to_bits())
2742                    .cmp(&(b.width.to_bits(), b.height.to_bits()))
2743            }
2744            // Different variants: compare by variant index
2745            _ => variant_index(self).cmp(&variant_index(other)),
2746        }
2747    }
2748}
2749
2750// +spec:font-metrics:fa104e - vertical-align values; baseline-source defaults to auto (first baseline)
2751// +spec:inline-formatting-context:340729 - alignment-baseline values for IFC baseline alignment (only baseline/top/bottom/middle implemented)
2752// CSS 2.2 §10.8.1 vertical-align property values
2753// +spec:display-property:0b1deb - inline boxes use dominant baseline to align text and inline-level children
2754// +spec:inline-formatting-context:3996a6 - dominant-baseline defaults to alphabetic in horizontal mode; vertical-align handles baseline alignment and super/sub shifting
2755#[derive(Default, Debug, Clone, Copy, PartialEq, PartialOrd)]
2756pub enum VerticalAlign {
2757    // Align baseline of box with baseline of parent box
2758    #[default]
2759    Baseline,
2760    // Align bottom of aligned subtree with bottom of line box
2761    Bottom,
2762    // Align top of aligned subtree with top of line box
2763    Top,
2764    // Align vertical midpoint of box with baseline of parent plus half x-height
2765    Middle,
2766    // Align top of box with top of parent's content area (§10.6.1)
2767    TextTop,
2768    // Align bottom of box with bottom of parent's content area (§10.6.1)
2769    TextBottom,
2770    // Lower baseline to proper subscript position
2771    Sub,
2772    // Raise baseline to proper superscript position
2773    Super,
2774    // +spec:font-metrics:152df3 - Raise (positive) or lower (negative) by this distance; 0 = baseline
2775    Offset(f32),
2776}
2777
2778impl Hash for VerticalAlign {
2779    fn hash<H: Hasher>(&self, state: &mut H) {
2780        discriminant(self).hash(state);
2781        if let Self::Offset(f) = self {
2782            f.to_bits().hash(state);
2783        }
2784    }
2785}
2786
2787impl Eq for VerticalAlign {}
2788
2789// cmp delegates to the derived PartialOrd (unwrap_or(Equal)), so Ord and PartialOrd are
2790// consistent; Ord can't be derived because of the f32 `Offset` variant.
2791#[allow(clippy::derive_ord_xor_partial_ord)]
2792impl Ord for VerticalAlign {
2793    fn cmp(&self, other: &Self) -> Ordering {
2794        self.partial_cmp(other).unwrap_or(Ordering::Equal)
2795    }
2796}
2797
2798#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, PartialOrd, Ord)]
2799pub enum ObjectFit {
2800    // Stretch to fit display size
2801    Fill,
2802    // Scale to fit within display size
2803    Contain,
2804    // Scale to cover display size
2805    Cover,
2806    // Use intrinsic size
2807    None,
2808    // Like contain but never scale up
2809    ScaleDown,
2810}
2811
2812/// Border information for inline elements (display: inline, inline-block)
2813///
2814/// This stores the resolved border properties needed for rendering inline element borders.
2815/// Unlike block elements which render borders via `paint_node_background_and_border()`,
2816/// inline element borders must be rendered per glyph-run to handle line breaks correctly.
2817#[derive(Copy, Debug, Clone, PartialEq)]
2818pub struct InlineBorderInfo {
2819    /// Border widths in pixels for each side
2820    pub top: f32,
2821    pub right: f32,
2822    pub bottom: f32,
2823    pub left: f32,
2824    /// Border colors for each side
2825    pub top_color: ColorU,
2826    pub right_color: ColorU,
2827    pub bottom_color: ColorU,
2828    pub left_color: ColorU,
2829    /// Border radius (if any)
2830    pub radius: Option<f32>,
2831    /// Padding widths in pixels for each side (needed to expand background rect)
2832    pub padding_top: f32,
2833    pub padding_right: f32,
2834    pub padding_bottom: f32,
2835    pub padding_left: f32,
2836    // +spec:box-model:c5723b - inline box split: suppress margin/border/padding at split points
2837    /// CSS 2.2 §9.4.2 / §8.6: when an inline box is split across line boxes,
2838    /// margins, borders, and padding have no visible effect at the split points.
2839    /// True if this is the first fragment of the inline box.
2840    pub is_first_fragment: bool,
2841    /// True if this is the last fragment of the inline box.
2842    pub is_last_fragment: bool,
2843    /// CSS 2.2 §8.6: direction flag for visual-order rendering in bidi context.
2844    /// LTR: first fragment gets left edge, last gets right edge.
2845    /// RTL: first fragment gets right edge, last gets left edge.
2846    pub is_rtl: bool,
2847}
2848
2849impl Default for InlineBorderInfo {
2850    fn default() -> Self {
2851        Self {
2852            top: 0.0,
2853            right: 0.0,
2854            bottom: 0.0,
2855            left: 0.0,
2856            top_color: ColorU::TRANSPARENT,
2857            right_color: ColorU::TRANSPARENT,
2858            bottom_color: ColorU::TRANSPARENT,
2859            left_color: ColorU::TRANSPARENT,
2860            radius: None,
2861            padding_top: 0.0,
2862            padding_right: 0.0,
2863            padding_bottom: 0.0,
2864            padding_left: 0.0,
2865            is_first_fragment: true,
2866            is_last_fragment: true,
2867            is_rtl: false,
2868        }
2869    }
2870}
2871
2872impl InlineBorderInfo {
2873    /// Returns true if any border has a non-zero width
2874    #[must_use] pub fn has_border(&self) -> bool {
2875        self.top > 0.0 || self.right > 0.0 || self.bottom > 0.0 || self.left > 0.0
2876    }
2877
2878    /// Returns true if any border or padding is present
2879    #[must_use] pub fn has_chrome(&self) -> bool {
2880        self.has_border()
2881            || self.padding_top > 0.0
2882            || self.padding_right > 0.0
2883            || self.padding_bottom > 0.0
2884            || self.padding_left > 0.0
2885    }
2886
2887    // +spec:box-model:da0ba2 - RTL bidi inline box split: left/right edges assigned to correct fragments
2888    // +spec:box-model:e9144f - visual-order margin/border/padding for inline boxes in bidi context
2889    // +spec:box-model:fac66f - Assigns margins/borders/padding in visual order for bidi inline fragments
2890    // +spec:box-model:720688 - LTR: left on first, right on last; RTL: right on first, left on last
2891    // +spec:positioning:1fcad6 - bidi-aware margin/border/padding on inline box fragments per visual order
2892    /// Total left inset (border + padding), suppressed at split points per §8.6.
2893    /// In LTR: left edge drawn on first fragment. In RTL: left edge drawn on last fragment.
2894    // +spec:box-model:bae97f - visual-order margin/border/padding assignment for bidi inline fragments
2895    #[must_use] pub fn left_inset(&self) -> f32 {
2896        let show = if self.is_rtl { self.is_last_fragment } else { self.is_first_fragment };
2897        if show { self.left + self.padding_left } else { 0.0 }
2898    }
2899    /// Total right inset (border + padding), suppressed at split points per §8.6.
2900    /// In LTR: right edge drawn on last fragment. In RTL: right edge drawn on first fragment.
2901    #[must_use] pub fn right_inset(&self) -> f32 {
2902        let show = if self.is_rtl { self.is_first_fragment } else { self.is_last_fragment };
2903        if show { self.right + self.padding_right } else { 0.0 }
2904    }
2905    /// Total top inset (border + padding)
2906    #[must_use] pub fn top_inset(&self) -> f32 { self.top + self.padding_top }
2907    /// Total bottom inset (border + padding)
2908    #[must_use] pub fn bottom_inset(&self) -> f32 { self.bottom + self.padding_bottom }
2909}
2910
2911#[derive(Debug, Clone)]
2912pub struct InlineShape {
2913    pub shape_def: ShapeDefinition,
2914    pub fill: Option<ColorU>,
2915    pub stroke: Option<Stroke>,
2916    pub baseline_offset: f32,
2917    /// Per-item vertical alignment (CSS `vertical-align` on the inline-block element).
2918    /// This overrides the global `TextStyleOptions::vertical_align` for this shape.
2919    pub alignment: VerticalAlign,
2920    /// The `NodeId` of the element that created this shape
2921    /// (e.g., inline-block) - this allows us to look up
2922    /// styling information (background, border) when rendering
2923    pub source_node_id: Option<NodeId>,
2924}
2925
2926#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
2927pub enum OverflowBehavior {
2928    // Content extends outside shape
2929    Visible,
2930    // Content is clipped to shape
2931    Hidden,
2932    // Scrollable overflow
2933    Scroll,
2934    // Browser/system decides
2935    #[default]
2936    Auto,
2937    // Break into next shape/page
2938    Break,
2939}
2940
2941#[derive(Debug, Clone)]
2942pub(crate) struct MeasuredImage {
2943    pub(crate) source: ImageSource,
2944    pub(crate) size: Size,
2945    pub(crate) baseline_offset: f32,
2946    pub(crate) alignment: VerticalAlign,
2947    pub(crate) content_index: usize,
2948}
2949
2950#[derive(Debug, Clone)]
2951pub(crate) struct MeasuredShape {
2952    pub(crate) shape_def: ShapeDefinition,
2953    pub(crate) size: Size,
2954    pub(crate) baseline_offset: f32,
2955    pub(crate) alignment: VerticalAlign,
2956    pub(crate) content_index: usize,
2957}
2958
2959#[derive(Copy, Debug, Clone)]
2960pub struct InlineSpace {
2961    pub width: f32,
2962    pub is_breaking: bool, // Can line break here
2963    pub is_stretchy: bool, // Can be expanded for justification
2964}
2965
2966impl PartialEq for InlineSpace {
2967    fn eq(&self, other: &Self) -> bool {
2968        self.width.to_bits() == other.width.to_bits()
2969            && self.is_breaking == other.is_breaking
2970            && self.is_stretchy == other.is_stretchy
2971    }
2972}
2973
2974impl Eq for InlineSpace {}
2975
2976impl Hash for InlineSpace {
2977    fn hash<H: Hasher>(&self, state: &mut H) {
2978        self.width.to_bits().hash(state);
2979        self.is_breaking.hash(state);
2980        self.is_stretchy.hash(state);
2981    }
2982}
2983
2984impl PartialOrd for InlineSpace {
2985    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
2986        Some(self.cmp(other))
2987    }
2988}
2989
2990impl Ord for InlineSpace {
2991    fn cmp(&self, other: &Self) -> Ordering {
2992        self.width
2993            .total_cmp(&other.width)
2994            .then_with(|| self.is_breaking.cmp(&other.is_breaking))
2995            .then_with(|| self.is_stretchy.cmp(&other.is_stretchy))
2996    }
2997}
2998
2999impl PartialEq for InlineShape {
3000    fn eq(&self, other: &Self) -> bool {
3001        self.baseline_offset.to_bits() == other.baseline_offset.to_bits()
3002            && self.shape_def == other.shape_def
3003            && self.fill == other.fill
3004            && self.stroke == other.stroke
3005            && self.alignment == other.alignment
3006            && self.source_node_id == other.source_node_id
3007    }
3008}
3009
3010impl Eq for InlineShape {}
3011
3012impl Hash for InlineShape {
3013    fn hash<H: Hasher>(&self, state: &mut H) {
3014        self.shape_def.hash(state);
3015        self.fill.hash(state);
3016        self.stroke.hash(state);
3017        self.baseline_offset.to_bits().hash(state);
3018        self.alignment.hash(state);
3019        self.source_node_id.hash(state);
3020    }
3021}
3022
3023impl PartialOrd for InlineShape {
3024    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
3025        Some(
3026            self.shape_def
3027                .partial_cmp(&other.shape_def)?
3028                .then_with(|| self.fill.cmp(&other.fill))
3029                .then_with(|| {
3030                    self.stroke
3031                        .partial_cmp(&other.stroke)
3032                        .unwrap_or(Ordering::Equal)
3033                })
3034                .then_with(|| self.baseline_offset.total_cmp(&other.baseline_offset))
3035                .then_with(|| self.alignment.cmp(&other.alignment))
3036                .then_with(|| self.source_node_id.cmp(&other.source_node_id)),
3037        )
3038    }
3039}
3040
3041#[derive(Debug, Default, Clone, Copy)]
3042pub struct Rect {
3043    pub x: f32,
3044    pub y: f32,
3045    pub width: f32,
3046    pub height: f32,
3047}
3048
3049impl PartialEq for Rect {
3050    fn eq(&self, other: &Self) -> bool {
3051        round_eq(self.x, other.x)
3052            && round_eq(self.y, other.y)
3053            && round_eq(self.width, other.width)
3054            && round_eq(self.height, other.height)
3055    }
3056}
3057impl Eq for Rect {}
3058
3059impl Hash for Rect {
3060    #[allow(clippy::cast_possible_truncation)] // bounded pixel/coord/colour/glyph cast
3061    fn hash<H: Hasher>(&self, state: &mut H) {
3062        // The order in which you hash the fields matters.
3063        // A consistent order is crucial.
3064        (self.x.round() as isize).hash(state);
3065        (self.y.round() as isize).hash(state);
3066        (self.width.round() as isize).hash(state);
3067        (self.height.round() as isize).hash(state);
3068    }
3069}
3070
3071#[derive(Debug, Default, Clone, Copy)]
3072pub struct Size {
3073    pub width: f32,
3074    pub height: f32,
3075}
3076
3077impl PartialOrd for Size {
3078    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
3079        Some(self.cmp(other))
3080    }
3081}
3082
3083impl Ord for Size {
3084    #[allow(clippy::cast_possible_truncation)] // bounded pixel/coord/colour/glyph cast
3085    fn cmp(&self, other: &Self) -> Ordering {
3086        (self.width.round() as isize)
3087            .cmp(&(other.width.round() as isize))
3088            .then_with(|| (self.height.round() as isize).cmp(&(other.height.round() as isize)))
3089    }
3090}
3091
3092// Size
3093impl Hash for Size {
3094    #[allow(clippy::cast_possible_truncation)] // bounded pixel/coord/colour/glyph cast
3095    fn hash<H: Hasher>(&self, state: &mut H) {
3096        (self.width.round() as isize).hash(state);
3097        (self.height.round() as isize).hash(state);
3098    }
3099}
3100impl PartialEq for Size {
3101    fn eq(&self, other: &Self) -> bool {
3102        round_eq(self.width, other.width) && round_eq(self.height, other.height)
3103    }
3104}
3105impl Eq for Size {}
3106
3107impl Size {
3108    #[must_use] pub const fn zero() -> Self {
3109        Self::new(0.0, 0.0)
3110    }
3111    #[must_use] pub const fn new(width: f32, height: f32) -> Self {
3112        Self { width, height }
3113    }
3114}
3115
3116#[derive(Debug, Default, Clone, Copy, PartialOrd)]
3117pub struct Point {
3118    pub x: f32,
3119    pub y: f32,
3120}
3121
3122// Point
3123impl Hash for Point {
3124    #[allow(clippy::cast_possible_truncation)] // bounded pixel/coord/colour/glyph cast
3125    fn hash<H: Hasher>(&self, state: &mut H) {
3126        (self.x.round() as isize).hash(state);
3127        (self.y.round() as isize).hash(state);
3128    }
3129}
3130
3131impl PartialEq for Point {
3132    fn eq(&self, other: &Self) -> bool {
3133        round_eq(self.x, other.x) && round_eq(self.y, other.y)
3134    }
3135}
3136
3137impl Eq for Point {}
3138
3139#[derive(Debug, Clone, PartialOrd)]
3140pub enum ShapeDefinition {
3141    Rectangle {
3142        size: Size,
3143        corner_radius: Option<f32>,
3144    },
3145    Circle {
3146        radius: f32,
3147    },
3148    Ellipse {
3149        radii: Size,
3150    },
3151    Polygon {
3152        points: Vec<Point>,
3153    },
3154    Path {
3155        segments: Vec<PathSegment>,
3156    },
3157}
3158
3159// ShapeDefinition
3160impl Hash for ShapeDefinition {
3161    #[allow(clippy::cast_possible_truncation)] // bounded pixel/coord/colour/glyph cast
3162    fn hash<H: Hasher>(&self, state: &mut H) {
3163        discriminant(self).hash(state);
3164        match self {
3165            Self::Rectangle {
3166                size,
3167                corner_radius,
3168            } => {
3169                size.hash(state);
3170                corner_radius.map(|r| r.round() as isize).hash(state);
3171            }
3172            Self::Circle { radius } => {
3173                (radius.round() as isize).hash(state);
3174            }
3175            Self::Ellipse { radii } => {
3176                radii.hash(state);
3177            }
3178            Self::Polygon { points } => {
3179                // Since Point implements Hash, we can hash the Vec directly.
3180                points.hash(state);
3181            }
3182            Self::Path { segments } => {
3183                // Same for Vec<PathSegment>
3184                segments.hash(state);
3185            }
3186        }
3187    }
3188}
3189
3190impl PartialEq for ShapeDefinition {
3191    fn eq(&self, other: &Self) -> bool {
3192        match (self, other) {
3193            (
3194                Self::Rectangle {
3195                    size: s1,
3196                    corner_radius: r1,
3197                },
3198                Self::Rectangle {
3199                    size: s2,
3200                    corner_radius: r2,
3201                },
3202            ) => {
3203                s1 == s2
3204                    && match (r1, r2) {
3205                        (None, None) => true,
3206                        (Some(v1), Some(v2)) => round_eq(*v1, *v2),
3207                        _ => false,
3208                    }
3209            }
3210            (Self::Circle { radius: r1 }, Self::Circle { radius: r2 }) => {
3211                round_eq(*r1, *r2)
3212            }
3213            (Self::Ellipse { radii: r1 }, Self::Ellipse { radii: r2 }) => {
3214                r1 == r2
3215            }
3216            (Self::Polygon { points: p1 }, Self::Polygon { points: p2 }) => {
3217                p1 == p2
3218            }
3219            (Self::Path { segments: s1 }, Self::Path { segments: s2 }) => {
3220                s1 == s2
3221            }
3222            _ => false,
3223        }
3224    }
3225}
3226impl Eq for ShapeDefinition {}
3227
3228impl ShapeDefinition {
3229    /// Calculates the bounding box size for the shape.
3230    #[must_use] pub fn get_size(&self) -> Size {
3231        match self {
3232            // The size is explicitly defined.
3233            Self::Rectangle { size, .. } => *size,
3234
3235            // The bounding box of a circle is a square with sides equal to the diameter.
3236            Self::Circle { radius } => {
3237                let diameter = radius * 2.0;
3238                Size::new(diameter, diameter)
3239            }
3240
3241            // The bounding box of an ellipse has width and height equal to twice its radii.
3242            Self::Ellipse { radii } => Size::new(radii.width * 2.0, radii.height * 2.0),
3243
3244            // For a polygon, we must find the min/max coordinates to get the bounds.
3245            Self::Polygon { points } => calculate_bounding_box_size(points),
3246
3247            // For a path, we find the bounding box of all its anchor and control points.
3248            //
3249            // NOTE: This is a common and fast approximation. The true bounding box of
3250            // bezier curves can be slightly smaller than the box containing their control
3251            // points. For pixel-perfect results, one would need to calculate the
3252            // curve's extrema.
3253            Self::Path { segments } => {
3254                let mut points = Vec::new();
3255                let mut current_pos = Point { x: 0.0, y: 0.0 };
3256
3257                for segment in segments {
3258                    match segment {
3259                        PathSegment::MoveTo(p) | PathSegment::LineTo(p) => {
3260                            points.push(*p);
3261                            current_pos = *p;
3262                        }
3263                        PathSegment::QuadTo { control, end } => {
3264                            points.push(current_pos);
3265                            points.push(*control);
3266                            points.push(*end);
3267                            current_pos = *end;
3268                        }
3269                        PathSegment::CurveTo {
3270                            control1,
3271                            control2,
3272                            end,
3273                        } => {
3274                            points.push(current_pos);
3275                            points.push(*control1);
3276                            points.push(*control2);
3277                            points.push(*end);
3278                            current_pos = *end;
3279                        }
3280                        PathSegment::Arc {
3281                            center,
3282                            radius,
3283                            start_angle,
3284                            end_angle,
3285                        } => {
3286                            // 1. Calculate and add the arc's start and end points to the list.
3287                            let start_point = Point {
3288                                x: center.x + radius * start_angle.cos(),
3289                                y: center.y + radius * start_angle.sin(),
3290                            };
3291                            let end_point = Point {
3292                                x: center.x + radius * end_angle.cos(),
3293                                y: center.y + radius * end_angle.sin(),
3294                            };
3295                            points.push(start_point);
3296                            points.push(end_point);
3297
3298                            // 2. Normalize the angles to handle cases where the arc crosses the
3299                            //    0-radian line.
3300                            // This ensures we can iterate forward from a start to an end angle.
3301                            let mut normalized_end = *end_angle;
3302                            #[allow(clippy::while_float)] // intentional bounded float loop (angle-wrap / pixel-step); an integer counter would be artificial
3303                            while normalized_end < *start_angle {
3304                                normalized_end += 2.0 * std::f32::consts::PI;
3305                            }
3306
3307                            // 3. Find the first cardinal point (multiples of PI/2) at or after the
3308                            //    start angle.
3309                            let mut check_angle = (*start_angle / std::f32::consts::FRAC_PI_2)
3310                                .ceil()
3311                                * std::f32::consts::FRAC_PI_2;
3312
3313                            // 4. Iterate through all cardinal points that fall within the arc's
3314                            //    sweep and add them.
3315                            // These points define the maximum extent of the arc's bounding box.
3316                            #[allow(clippy::while_float)] // intentional bounded float loop (angle-wrap / pixel-step); an integer counter would be artificial
3317                            while check_angle < normalized_end {
3318                                points.push(Point {
3319                                    x: center.x + radius * check_angle.cos(),
3320                                    y: center.y + radius * check_angle.sin(),
3321                                });
3322                                check_angle += std::f32::consts::FRAC_PI_2;
3323                            }
3324
3325                            // 5. The end of the arc is the new current position for subsequent path
3326                            //    segments.
3327                            current_pos = end_point;
3328                        }
3329                        PathSegment::Close => {
3330                            // No new points are added for closing the path
3331                        }
3332                    }
3333                }
3334                calculate_bounding_box_size(&points)
3335            }
3336        }
3337    }
3338}
3339
3340// +spec:text-alignment-spacing:25e82a - text-align shorthand resolves text-align-all / text-align-last
3341/// Resolve effective text alignment for a line, handling text-align-last per CSS Text §6.3.
3342/// For the last line (or lines before forced breaks), text-align-last overrides text-align.
3343/// When text-align-last is auto (default), justify falls back to start; others use text-align.
3344// +spec:text-alignment-spacing:bca77d - text-align-last auto falls back to text-align-all, justify→start
3345// +spec:line-breaking:9b10d2 - text-align-last applies to last line and lines before forced breaks
3346/// +spec:text-alignment-spacing:8d88ce - text-align-last overrides justify on last line/forced break
3347pub(crate) fn resolve_effective_alignment(
3348    text_align: TextAlign,
3349    text_align_last: TextAlign,
3350    is_last_or_forced: bool,
3351) -> TextAlign {
3352    if is_last_or_forced {
3353        if text_align_last == TextAlign::default() {
3354            if text_align == TextAlign::Justify { TextAlign::Start } else { text_align }
3355        } else {
3356            text_align_last
3357        }
3358    } else {
3359        text_align
3360    }
3361}
3362
3363/// Helper function to calculate the size of the bounding box enclosing a set of points.
3364fn calculate_bounding_box_size(points: &[Point]) -> Size {
3365    if points.is_empty() {
3366        return Size::zero();
3367    }
3368
3369    let mut min_x = f32::MAX;
3370    let mut max_x = f32::MIN;
3371    let mut min_y = f32::MAX;
3372    let mut max_y = f32::MIN;
3373
3374    for point in points {
3375        min_x = min_x.min(point.x);
3376        max_x = max_x.max(point.x);
3377        min_y = min_y.min(point.y);
3378        max_y = max_y.max(point.y);
3379    }
3380
3381    // Handle case where points might be collinear or a single point
3382    if min_x > max_x || min_y > max_y {
3383        return Size::zero();
3384    }
3385
3386    Size::new(max_x - min_x, max_y - min_y)
3387}
3388
3389#[derive(Debug, Clone, PartialOrd)]
3390pub struct Stroke {
3391    pub color: ColorU,
3392    pub width: f32,
3393    pub dash_pattern: Option<Vec<f32>>,
3394}
3395
3396// Stroke
3397impl Hash for Stroke {
3398    #[allow(clippy::cast_possible_truncation)] // bounded pixel/coord/colour/glyph cast
3399    fn hash<H: Hasher>(&self, state: &mut H) {
3400        self.color.hash(state);
3401        (self.width.round() as isize).hash(state);
3402
3403        // Manual hashing for Option<Vec<f32>>
3404        match &self.dash_pattern {
3405            None => 0u8.hash(state), // Hash a discriminant for None
3406            Some(pattern) => {
3407                1u8.hash(state); // Hash a discriminant for Some
3408                pattern.len().hash(state); // Hash the length
3409                for &val in pattern {
3410                    (val.round() as isize).hash(state); // Hash each rounded value
3411                }
3412            }
3413        }
3414    }
3415}
3416
3417impl PartialEq for Stroke {
3418    fn eq(&self, other: &Self) -> bool {
3419        if self.color != other.color || !round_eq(self.width, other.width) {
3420            return false;
3421        }
3422        match (&self.dash_pattern, &other.dash_pattern) {
3423            (None, None) => true,
3424            (Some(p1), Some(p2)) => {
3425                p1.len() == p2.len() && p1.iter().zip(p2.iter()).all(|(a, b)| round_eq(*a, *b))
3426            }
3427            _ => false,
3428        }
3429    }
3430}
3431
3432impl Eq for Stroke {}
3433
3434// Helper function to round f32 for comparison
3435#[allow(clippy::cast_possible_truncation)] // bounded pixel/coord/colour/glyph cast
3436fn round_eq(a: f32, b: f32) -> bool {
3437    (a.round() as isize) == (b.round() as isize)
3438}
3439
3440#[derive(Debug, Clone)]
3441pub enum ShapeBoundary {
3442    Rectangle(Rect),
3443    Circle { center: Point, radius: f32 },
3444    Ellipse { center: Point, radii: Size },
3445    Polygon { points: Vec<Point> },
3446    Path { segments: Vec<PathSegment> },
3447}
3448
3449impl ShapeBoundary {
3450    #[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
3451    #[must_use] pub fn inflate(&self, margin: f32) -> Self {
3452        if margin == 0.0 {
3453            return self.clone();
3454        }
3455        match self {
3456            Self::Rectangle(rect) => Self::Rectangle(Rect {
3457                x: rect.x - margin,
3458                y: rect.y - margin,
3459                width: (rect.width + margin * 2.0).max(0.0),
3460                height: (rect.height + margin * 2.0).max(0.0),
3461            }),
3462            Self::Circle { center, radius } => Self::Circle {
3463                center: *center,
3464                radius: radius + margin,
3465            },
3466            // For simplicity, Polygon and Path inflation is not implemented here.
3467            // A full implementation would require a geometry library to offset the path.
3468            _ => self.clone(),
3469        }
3470    }
3471}
3472
3473// ShapeBoundary
3474impl Hash for ShapeBoundary {
3475    #[allow(clippy::cast_possible_truncation)] // bounded pixel/coord/colour/glyph cast
3476    fn hash<H: Hasher>(&self, state: &mut H) {
3477        discriminant(self).hash(state);
3478        match self {
3479            Self::Rectangle(rect) => rect.hash(state),
3480            Self::Circle { center, radius } => {
3481                center.hash(state);
3482                (radius.round() as isize).hash(state);
3483            }
3484            Self::Ellipse { center, radii } => {
3485                center.hash(state);
3486                radii.hash(state);
3487            }
3488            Self::Polygon { points } => points.hash(state),
3489            Self::Path { segments } => segments.hash(state),
3490        }
3491    }
3492}
3493impl PartialEq for ShapeBoundary {
3494    fn eq(&self, other: &Self) -> bool {
3495        match (self, other) {
3496            (Self::Rectangle(r1), Self::Rectangle(r2)) => r1 == r2,
3497            (
3498                Self::Circle {
3499                    center: c1,
3500                    radius: r1,
3501                },
3502                Self::Circle {
3503                    center: c2,
3504                    radius: r2,
3505                },
3506            ) => c1 == c2 && round_eq(*r1, *r2),
3507            (
3508                Self::Ellipse {
3509                    center: c1,
3510                    radii: r1,
3511                },
3512                Self::Ellipse {
3513                    center: c2,
3514                    radii: r2,
3515                },
3516            ) => c1 == c2 && r1 == r2,
3517            (Self::Polygon { points: p1 }, Self::Polygon { points: p2 }) => {
3518                p1 == p2
3519            }
3520            (Self::Path { segments: s1 }, Self::Path { segments: s2 }) => {
3521                s1 == s2
3522            }
3523            _ => false,
3524        }
3525    }
3526}
3527impl Eq for ShapeBoundary {}
3528
3529impl ShapeBoundary {
3530    /// Converts a CSS shape (from azul-css) to a layout engine `ShapeBoundary`
3531    ///
3532    /// # Arguments
3533    /// * `css_shape` - The parsed CSS shape from azul-css
3534    /// * `reference_box` - The containing box for resolving coordinates (from layout solver)
3535    ///
3536    /// # Returns
3537    /// A `ShapeBoundary` ready for use in the text layout engine
3538    #[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
3539    pub fn from_css_shape(
3540        css_shape: &azul_css::shape::CssShape,
3541        reference_box: Rect,
3542        debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
3543    ) -> Self {
3544        use azul_css::shape::CssShape;
3545
3546        if let Some(msgs) = debug_messages {
3547            msgs.push(LayoutDebugMessage::info(format!(
3548                "[ShapeBoundary::from_css_shape] Input CSS shape: {css_shape:?}"
3549            )));
3550            msgs.push(LayoutDebugMessage::info(format!(
3551                "[ShapeBoundary::from_css_shape] Reference box: {reference_box:?}"
3552            )));
3553        }
3554
3555        let result = match css_shape {
3556            CssShape::Circle(circle) => {
3557                let center = Point {
3558                    x: reference_box.x + circle.center.x,
3559                    y: reference_box.y + circle.center.y,
3560                };
3561                if let Some(msgs) = debug_messages {
3562                    msgs.push(LayoutDebugMessage::info(format!(
3563                        "[ShapeBoundary::from_css_shape] Circle - CSS center: ({}, {}), radius: {}",
3564                        circle.center.x, circle.center.y, circle.radius
3565                    )));
3566                    msgs.push(LayoutDebugMessage::info(format!(
3567                        "[ShapeBoundary::from_css_shape] Circle - Absolute center: ({}, {}), \
3568                         radius: {}",
3569                        center.x, center.y, circle.radius
3570                    )));
3571                }
3572                Self::Circle {
3573                    center,
3574                    radius: circle.radius,
3575                }
3576            }
3577
3578            CssShape::Ellipse(ellipse) => {
3579                let center = Point {
3580                    x: reference_box.x + ellipse.center.x,
3581                    y: reference_box.y + ellipse.center.y,
3582                };
3583                let radii = Size {
3584                    width: ellipse.radius_x,
3585                    height: ellipse.radius_y,
3586                };
3587                if let Some(msgs) = debug_messages {
3588                    msgs.push(LayoutDebugMessage::info(format!(
3589                        "[ShapeBoundary::from_css_shape] Ellipse - center: ({}, {}), radii: ({}, \
3590                         {})",
3591                        center.x, center.y, radii.width, radii.height
3592                    )));
3593                }
3594                Self::Ellipse { center, radii }
3595            }
3596
3597            CssShape::Polygon(polygon) => {
3598                let points = polygon
3599                    .points
3600                    .as_ref()
3601                    .iter()
3602                    .map(|pt| Point {
3603                        x: reference_box.x + pt.x,
3604                        y: reference_box.y + pt.y,
3605                    })
3606                    .collect();
3607                if let Some(msgs) = debug_messages {
3608                    msgs.push(LayoutDebugMessage::info(format!(
3609                        "[ShapeBoundary::from_css_shape] Polygon - {} points",
3610                        polygon.points.as_ref().len()
3611                    )));
3612                }
3613                Self::Polygon { points }
3614            }
3615
3616            CssShape::Inset(inset) => {
3617                // Inset defines distances from reference box edges
3618                let x = reference_box.x + inset.inset_left;
3619                let y = reference_box.y + inset.inset_top;
3620                let width = reference_box.width - inset.inset_left - inset.inset_right;
3621                let height = reference_box.height - inset.inset_top - inset.inset_bottom;
3622
3623                if let Some(msgs) = debug_messages {
3624                    msgs.push(LayoutDebugMessage::info(format!(
3625                        "[ShapeBoundary::from_css_shape] Inset - insets: ({}, {}, {}, {})",
3626                        inset.inset_top, inset.inset_right, inset.inset_bottom, inset.inset_left
3627                    )));
3628                    msgs.push(LayoutDebugMessage::info(format!(
3629                        "[ShapeBoundary::from_css_shape] Inset - resulting rect: x={x}, y={y}, \
3630                         w={width}, h={height}"
3631                    )));
3632                }
3633
3634                Self::Rectangle(Rect {
3635                    x,
3636                    y,
3637                    width: width.max(0.0),
3638                    height: height.max(0.0),
3639                })
3640            }
3641
3642            CssShape::Path(path) => {
3643                // CSS `path()` value: `path.data` is a raw SVG path `d=""` string in the
3644                // reference-box coordinate system (origin at the reference box's top-left).
3645                // Parse + flatten it into `Vec<PathSegment>` (curves sampled to line
3646                // segments) so the scanline code in `get_shape_horizontal_spans` can
3647                // intersect it per line, exactly like `polygon`.
3648                let segments = azul_core::path_parser::parse_svg_path_d(path.data.as_str())
3649                    .map_or_else(|_| Vec::new(), |multipolygon| {
3650                        flatten_svg_to_path_segments(&multipolygon, reference_box)
3651                    });
3652                if let Some(msgs) = debug_messages {
3653                    msgs.push(LayoutDebugMessage::info(format!(
3654                        "[ShapeBoundary::from_css_shape] Path - parsed {} flattened segments",
3655                        segments.len()
3656                    )));
3657                }
3658                if segments.is_empty() {
3659                    // Unparseable / empty path: fall back to the reference rectangle so a
3660                    // shape-inside container does not collapse to zero usable space.
3661                    Self::Rectangle(reference_box)
3662                } else {
3663                    Self::Path { segments }
3664                }
3665            }
3666        };
3667
3668        if let Some(msgs) = debug_messages {
3669            msgs.push(LayoutDebugMessage::info(format!(
3670                "[ShapeBoundary::from_css_shape] Result: {result:?}"
3671            )));
3672        }
3673        result
3674    }
3675}
3676
3677#[derive(Copy, Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
3678pub struct InlineBreak {
3679    pub break_type: BreakType,
3680    pub clear: ClearType,
3681    pub content_index: usize,
3682}
3683
3684// +spec:line-breaking:d70ffd - Defines forced line break (Hard) vs soft wrap break (Soft) types
3685#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
3686pub enum BreakType {
3687    Soft,   // Soft wrap break: UA creates unforced line breaks to fit content within the measure
3688    Hard,   // Forced line break: explicit line-breaking controls (preserved newline, <br>)
3689    Page,   // Page break
3690    Column, // Column break
3691}
3692
3693#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
3694pub enum ClearType {
3695    None,
3696    Left,
3697    Right,
3698    Both,
3699}
3700
3701// Complex shape constraints for non-rectangular text flow
3702#[derive(Debug, Clone)]
3703pub(crate) struct ShapeConstraints {
3704    pub(crate) boundaries: Vec<ShapeBoundary>,
3705    pub(crate) exclusions: Vec<ShapeBoundary>,
3706    pub(crate) writing_mode: WritingMode,
3707    pub(crate) text_align: TextAlign,
3708    pub(crate) line_height: LineHeight,
3709}
3710
3711#[derive(Debug, Clone, Copy, PartialEq, Default, Hash, Eq, PartialOrd, Ord)]
3712pub enum WritingMode {
3713    #[default]
3714    HorizontalTb, // horizontal-tb (normal horizontal)
3715    VerticalRl, // +spec:writing-modes:6e22a7 - vertical-rl (vertical right-to-left, commonly used in East Asia)
3716    VerticalLr, // vertical-lr (vertical left-to-right)
3717    SidewaysRl, // sideways-rl (rotated horizontal in vertical context)
3718    SidewaysLr, // sideways-lr (rotated horizontal in vertical context)
3719}
3720
3721impl WritingMode {
3722    /// Necessary to determine if the glyphs are advancing in a horizontal direction
3723    #[must_use] pub const fn is_advance_horizontal(&self) -> bool {
3724        matches!(
3725            self,
3726            Self::HorizontalTb | Self::SidewaysRl | Self::SidewaysLr
3727        )
3728    }
3729}
3730
3731#[derive(Debug, Clone, Copy, PartialEq, Default, Hash, Eq, PartialOrd, Ord)]
3732pub enum JustifyContent {
3733    #[default]
3734    None,
3735    InterWord,      // Expand spaces between words
3736    InterCharacter, // Expand spaces between all characters (for CJK)
3737    Distribute,     // Distribute space evenly including start/end
3738    Kashida,        // Stretch Arabic text using kashidas
3739}
3740
3741// Enhanced text alignment with logical directions
3742#[derive(Debug, Clone, Copy, PartialEq, Default, Hash, Eq, PartialOrd, Ord)]
3743pub enum TextAlign {
3744    #[default]
3745    Left,
3746    Right,
3747    Center,
3748    Justify,
3749    Start,
3750    End,        // Logical start/end
3751    JustifyAll, // Justify including last line
3752}
3753
3754// +spec:block-formatting-context:458d31 - vertical text orientation: upright for horizontal scripts, intrinsic for vertical scripts
3755// Vertical text orientation for individual characters
3756#[derive(Debug, Clone, Copy, PartialEq, Default, Eq, PartialOrd, Ord, Hash)]
3757pub enum TextOrientation {
3758    #[default]
3759    Mixed, // Default: upright for scripts, rotated for others
3760    Upright,  // All characters upright
3761    Sideways, // All characters rotated 90 degrees
3762}
3763
3764#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
3765#[derive(Default)]
3766pub struct TextDecoration {
3767    pub underline: bool,
3768    pub strikethrough: bool,
3769    pub overline: bool,
3770}
3771
3772
3773impl TextDecoration {
3774    /// Convert from CSS `StyleTextDecoration` enum to our internal representation.
3775    /// 
3776    /// Note: CSS text-decoration can have multiple values (underline line-through),
3777    /// but the current azul-css parser only supports single values. This can be
3778    /// extended in the future if CSS parsing is updated.
3779    #[must_use] pub fn from_css(css: azul_css::props::style::text::StyleTextDecoration) -> Self {
3780        use azul_css::props::style::text::StyleTextDecoration;
3781        match css {
3782            StyleTextDecoration::None => Self::default(),
3783            StyleTextDecoration::Underline => Self {
3784                underline: true,
3785                strikethrough: false,
3786                overline: false,
3787            },
3788            StyleTextDecoration::Overline => Self {
3789                underline: false,
3790                strikethrough: false,
3791                overline: true,
3792            },
3793            StyleTextDecoration::LineThrough => Self {
3794                underline: false,
3795                strikethrough: true,
3796                overline: false,
3797            },
3798        }
3799    }
3800}
3801
3802#[derive(Debug, Clone, Copy, PartialEq, Hash, Eq, PartialOrd, Ord, Default)]
3803pub enum TextTransform {
3804    #[default]
3805    None,
3806    Uppercase,
3807    Lowercase,
3808    Capitalize,
3809    // only within preserved white space (non-preserved spaces already collapsed in Phase I)
3810    FullWidth,
3811}
3812
3813// Type alias for OpenType feature tags
3814pub type FourCc = [u8; 4];
3815
3816// Enum for relative or absolute spacing
3817#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
3818pub enum Spacing {
3819    Px(i32), // Whole-pixel spacing (kept for hashing/equality convenience)
3820    /// Sub-pixel resolved pixel spacing. `letter-spacing`/`word-spacing` accumulate
3821    /// once per glyph, so quantizing to whole pixels (the `Px(i32)` variant) multiplies
3822    /// the rounding error across a run. The CSS resolution path emits this variant to
3823    /// preserve the exact sub-pixel value (e.g. `letter-spacing: 0.4px`).
3824    PxF(f32),
3825    Em(f32),
3826}
3827
3828// A type that implements `Hash` must also implement `Eq`.
3829// Since f32 does not implement `Eq`, we provide a manual implementation.
3830// The derived `PartialEq` is sufficient for this marker trait.
3831impl Eq for Spacing {}
3832
3833impl Hash for Spacing {
3834    fn hash<H: Hasher>(&self, state: &mut H) {
3835        // First, hash the enum variant to distinguish between Px and Em.
3836        discriminant(self).hash(state);
3837        match self {
3838            Self::Px(val) => val.hash(state),
3839            // For hashing floats, convert them to their raw bit representation.
3840            // This ensures that identical float values produce identical hashes.
3841            Self::PxF(val) | Self::Em(val) => val.to_bits().hash(state),
3842        }
3843    }
3844}
3845
3846impl Default for Spacing {
3847    fn default() -> Self {
3848        Self::Px(0)
3849    }
3850}
3851
3852impl Spacing {
3853    /// Resolve this spacing to pixels given the element's font size (for `Em`).
3854    #[allow(clippy::cast_precision_loss)] // small integer px values; f32 mantissa is ample
3855    #[must_use]
3856    pub fn resolve_px(self, font_size_px: f32) -> f32 {
3857        match self {
3858            Self::Px(px) => px as f32,
3859            Self::PxF(px) => px,
3860            Self::Em(em) => em * font_size_px,
3861        }
3862    }
3863}
3864
3865impl Default for FontHash {
3866    fn default() -> Self {
3867        Self::invalid()
3868    }
3869}
3870
3871/// Style properties with vertical text support
3872#[derive(Debug, Clone, PartialEq)]
3873pub struct StyleProperties {
3874    /// Font stack for fallback support (priority order)
3875    /// Can be either a list of `FontSelectors` (resolved via fontconfig)
3876    /// or a direct `FontRef` (bypasses fontconfig entirely).
3877    pub font_stack: FontStack,
3878    pub font_size_px: f32,
3879    pub color: ColorU,
3880    /// Background color for inline elements (e.g., `<span style="background-color: yellow">`)
3881    ///
3882    /// This is propagated from CSS through the style system and eventually used by
3883    /// the PDF renderer to draw filled rectangles behind text. The value is `None`
3884    /// for transparent backgrounds (the default).
3885    ///
3886    /// The propagation chain is:
3887    /// CSS -> `get_style_properties()` -> `StyleProperties` -> `ShapedGlyph` -> `PdfGlyphRun`
3888    ///
3889    /// See `PdfGlyphRun::background_color` for how this is used in PDF rendering.
3890    pub background_color: Option<ColorU>,
3891    /// Full background content layers (for gradients, images, etc.)
3892    /// This extends `background_color` to support CSS gradients on inline elements.
3893    pub background_content: Vec<StyleBackgroundContent>,
3894    /// Border information for inline elements
3895    pub border: Option<InlineBorderInfo>,
3896    // +spec:text-alignment-spacing:b39a04 - word-spacing and letter-spacing control text spacing
3897    pub letter_spacing: Spacing,
3898    pub word_spacing: Spacing,
3899
3900    pub line_height: LineHeight,
3901    pub text_decoration: TextDecoration,
3902
3903    // Represents CSS font-feature-settings like `"liga"`, `"smcp=1"`.
3904    pub font_features: Vec<String>,
3905
3906    // Variable fonts
3907    pub font_variations: Vec<(FourCc, f32)>,
3908    // Multiplier of the space width
3909    pub tab_size: f32,
3910    // text-transform
3911    pub text_transform: TextTransform,
3912    // Vertical text properties
3913    pub writing_mode: WritingMode,
3914    pub text_orientation: TextOrientation,
3915    // Tate-chu-yoko
3916    pub text_combine_upright: Option<TextCombineUpright>,
3917
3918    // Variant handling
3919    pub font_variant_caps: FontVariantCaps,
3920    pub font_variant_numeric: FontVariantNumeric,
3921    pub font_variant_ligatures: FontVariantLigatures,
3922    pub font_variant_east_asian: FontVariantEastAsian,
3923
3924    /// The element's own `vertical-align` (baseline / sub / super / length / percentage).
3925    /// Read per shaped cluster by `get_item_vertical_align` so an inline `<span>` shifts
3926    /// its text relative to the line baseline. `Baseline` (the default) leaves the cluster
3927    /// on the line's default alignment.
3928    pub vertical_align: VerticalAlign,
3929}
3930
3931impl Default for StyleProperties {
3932    fn default() -> Self {
3933        const FONT_SIZE: f32 = 16.0;
3934        const TAB_SIZE: f32 = 8.0;
3935        Self {
3936            font_stack: FontStack::default(),
3937            font_size_px: FONT_SIZE,
3938            color: ColorU::default(),
3939            background_color: None,
3940            background_content: Vec::new(),
3941            border: None,
3942            letter_spacing: Spacing::default(), // Px(0)
3943            word_spacing: Spacing::default(),   // Px(0)
3944            line_height: LineHeight::Normal,
3945            text_decoration: TextDecoration::default(),
3946            font_features: Vec::new(),
3947            font_variations: Vec::new(),
3948            tab_size: TAB_SIZE, // CSS default
3949            text_transform: TextTransform::default(),
3950            writing_mode: WritingMode::default(),
3951            text_orientation: TextOrientation::default(),
3952            text_combine_upright: None,
3953            font_variant_caps: FontVariantCaps::default(),
3954            font_variant_numeric: FontVariantNumeric::default(),
3955            font_variant_ligatures: FontVariantLigatures::default(),
3956            font_variant_east_asian: FontVariantEastAsian::default(),
3957            vertical_align: VerticalAlign::Baseline,
3958        }
3959    }
3960}
3961
3962impl Hash for StyleProperties {
3963    #[allow(clippy::cast_possible_truncation)] // bounded pixel/coord/colour/glyph cast
3964    fn hash<H: Hasher>(&self, state: &mut H) {
3965        self.font_stack.hash(state);
3966        self.color.hash(state);
3967        self.background_color.hash(state);
3968        self.text_decoration.hash(state);
3969        self.font_features.hash(state);
3970        self.writing_mode.hash(state);
3971        self.text_orientation.hash(state);
3972        self.text_combine_upright.hash(state);
3973        self.vertical_align.hash(state);
3974        self.letter_spacing.hash(state);
3975        self.word_spacing.hash(state);
3976
3977        // For f32 fields, round and cast to usize before hashing.
3978        (self.font_size_px.round() as isize).hash(state);
3979        self.line_height.hash(state);
3980    }
3981}
3982
3983impl StyleProperties {
3984    /// Returns a hash that only includes properties that affect text layout.
3985    /// 
3986    /// Properties that DON'T affect layout (only rendering):
3987    /// - color, `background_color`, `background_content`
3988    /// - `text_decoration` (underline, etc.)
3989    /// - border (for inline elements)
3990    ///
3991    /// Properties that DO affect layout:
3992    /// - `font_stack`, `font_size_px`, `font_features`, `font_variations`
3993    /// - `letter_spacing`, `word_spacing`, `line_height`, `tab_size`
3994    /// - `writing_mode`, `text_orientation`, `text_combine_upright`
3995    /// - `text_transform`
3996    /// - `font_variant`_* (affects glyph selection)
3997    ///
3998    /// This allows the layout cache to reuse layouts when only rendering
3999    /// properties change (e.g., color changes on hover).
4000    // (family, weight, style) so that shaping runs break at element boundaries where font
4001    // properties differ, preventing impossible cross-boundary ligatures (e.g. "and" → "&").
4002    #[allow(clippy::cast_possible_truncation)] // bounded pixel/coord/colour/glyph cast
4003    #[must_use] pub fn layout_hash(&self) -> u64 {
4004        use std::hash::Hasher;
4005        let mut hasher = DefaultHasher::new();
4006
4007        // Font selection (affects shaping and metrics)
4008        self.font_stack.hash(&mut hasher);
4009        // Hash the EXACT font size bits, not a rounded integer: two styles differing
4010        // by <0.5px must not share a shaping-cache entry / coalesce, or one run gets
4011        // shaped at the other's size (wrong advances/metrics).
4012        self.font_size_px.to_bits().hash(&mut hasher);
4013        self.font_features.hash(&mut hasher);
4014        // font_variations affects glyph outlines
4015        for (tag, value) in &self.font_variations {
4016            tag.hash(&mut hasher);
4017            (value.round() as i32).hash(&mut hasher);
4018        }
4019        
4020        // Spacing (affects glyph positions)
4021        self.letter_spacing.hash(&mut hasher);
4022        self.word_spacing.hash(&mut hasher);
4023        self.line_height.hash(&mut hasher);
4024        (self.tab_size.round() as isize).hash(&mut hasher);
4025        
4026        // Writing mode (affects layout direction)
4027        self.writing_mode.hash(&mut hasher);
4028        self.text_orientation.hash(&mut hasher);
4029        self.text_combine_upright.hash(&mut hasher);
4030        
4031        // Text transform (affects which characters are used)
4032        self.text_transform.hash(&mut hasher);
4033        
4034        // Font variants (affect glyph selection)
4035        self.font_variant_caps.hash(&mut hasher);
4036        self.font_variant_numeric.hash(&mut hasher);
4037        self.font_variant_ligatures.hash(&mut hasher);
4038        self.font_variant_east_asian.hash(&mut hasher);
4039        
4040        hasher.finish()
4041    }
4042    
4043    /// Check if two `StyleProperties` have the same layout-affecting properties.
4044    ///
4045    /// Returns true if the layouts would be identical (only rendering differs).
4046    ///
4047    /// **Note:** This is a fast-path comparison using 64-bit hashes.  Hash
4048    /// collisions are theoretically possible, which could cause the cache to
4049    /// serve a stale layout.  In practice the probability is negligible for
4050    /// the number of distinct `StyleProperties` values in a single document.
4051    #[must_use] pub fn layout_eq(&self, other: &Self) -> bool {
4052        self.layout_hash() == other.layout_hash()
4053    }
4054}
4055
4056#[derive(Copy, Debug, Clone, PartialEq, Hash, Eq, PartialOrd, Ord)]
4057pub enum TextCombineUpright {
4058    None,
4059    All,        // Combine all characters in horizontal layout
4060    Digits(u8), // Combine up to N digits
4061}
4062
4063#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
4064pub enum GlyphSource {
4065    /// Glyph generated from a character in the source text.
4066    Char,
4067    /// Glyph inserted dynamically by the layout engine (e.g., a hyphen).
4068    Hyphen,
4069}
4070
4071#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4072pub enum CharacterClass {
4073    Space,       // Regular spaces - highest justification priority
4074    Punctuation, // Can sometimes be adjusted
4075    Letter,      // Normal letters
4076    Ideograph,   // CJK characters - can be justified between
4077    Symbol,      // Symbols, emojis
4078    Combining,   // Combining marks - never justified
4079}
4080
4081#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4082pub enum GlyphOrientation {
4083    Horizontal, // Keep horizontal (normal in horizontal text)
4084    Vertical,   // Rotate to vertical (normal in vertical text)
4085    Upright,    // Keep upright regardless of writing mode
4086    Mixed,      // Use script-specific default orientation
4087}
4088
4089// Bidi and script detection
4090#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
4091pub enum BidiDirection {
4092    Ltr,
4093    Rtl,
4094}
4095
4096impl BidiDirection {
4097    #[must_use] pub const fn is_rtl(&self) -> bool {
4098        matches!(self, Self::Rtl)
4099    }
4100}
4101
4102/// CSS `unicode-bidi` property values relevant to layout.
4103///
4104/// When `Plaintext`, the bidi algorithm uses P2/P3 heuristics to auto-detect
4105/// paragraph direction from text content, instead of the HL1 override from
4106/// the CSS `direction` property.
4107#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
4108#[derive(Default)]
4109pub enum UnicodeBidi {
4110    #[default]
4111    Normal,
4112    Embed,
4113    Isolate,
4114    BidiOverride,
4115    IsolateOverride,
4116    Plaintext,
4117}
4118
4119
4120#[derive(Debug, Clone, Copy, PartialEq, Hash, Eq, PartialOrd, Ord, Default)]
4121pub enum FontVariantCaps {
4122    #[default]
4123    Normal,
4124    SmallCaps,
4125    AllSmallCaps,
4126    PetiteCaps,
4127    AllPetiteCaps,
4128    Unicase,
4129    TitlingCaps,
4130}
4131
4132#[derive(Debug, Clone, Copy, PartialEq, Hash, Eq, PartialOrd, Ord, Default)]
4133pub enum FontVariantNumeric {
4134    #[default]
4135    Normal,
4136    LiningNums,
4137    OldstyleNums,
4138    ProportionalNums,
4139    TabularNums,
4140    DiagonalFractions,
4141    StackedFractions,
4142    Ordinal,
4143    SlashedZero,
4144}
4145
4146#[derive(Debug, Clone, Copy, PartialEq, Hash, Eq, PartialOrd, Ord, Default)]
4147pub enum FontVariantLigatures {
4148    #[default]
4149    Normal,
4150    None,
4151    Common,
4152    NoCommon,
4153    Discretionary,
4154    NoDiscretionary,
4155    Historical,
4156    NoHistorical,
4157    Contextual,
4158    NoContextual,
4159}
4160
4161#[derive(Debug, Clone, Copy, PartialEq, Hash, Eq, PartialOrd, Ord, Default)]
4162pub enum FontVariantEastAsian {
4163    #[default]
4164    Normal,
4165    Jis78,
4166    Jis83,
4167    Jis90,
4168    Jis04,
4169    Simplified,
4170    Traditional,
4171    FullWidth,
4172    ProportionalWidth,
4173    Ruby,
4174}
4175
4176#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
4177pub struct BidiLevel(u8);
4178
4179impl BidiLevel {
4180    #[must_use] pub const fn new(level: u8) -> Self {
4181        Self(level)
4182    }
4183    #[must_use] pub const fn is_rtl(&self) -> bool {
4184        self.0 % 2 == 1
4185    }
4186    #[must_use] pub const fn level(&self) -> u8 {
4187        self.0
4188    }
4189}
4190
4191// Add this new struct for style overrides
4192#[derive(Debug, Clone)]
4193pub struct StyleOverride {
4194    /// The specific character this override applies to.
4195    pub target: ContentIndex,
4196    /// The style properties to apply.
4197    /// Any `None` value means "inherit from the base style".
4198    pub style: PartialStyleProperties,
4199}
4200
4201#[derive(Debug, Clone, Default)]
4202pub struct PartialStyleProperties {
4203    pub font_stack: Option<FontStack>,
4204    pub font_size_px: Option<f32>,
4205    pub color: Option<ColorU>,
4206    pub letter_spacing: Option<Spacing>,
4207    pub word_spacing: Option<Spacing>,
4208    pub line_height: Option<LineHeight>,
4209    pub text_decoration: Option<TextDecoration>,
4210    pub font_features: Option<Vec<String>>,
4211    pub font_variations: Option<Vec<(FourCc, f32)>>,
4212    pub tab_size: Option<f32>,
4213    pub text_transform: Option<TextTransform>,
4214    pub writing_mode: Option<WritingMode>,
4215    pub text_orientation: Option<TextOrientation>,
4216    pub text_combine_upright: Option<Option<TextCombineUpright>>,
4217    pub font_variant_caps: Option<FontVariantCaps>,
4218    pub font_variant_numeric: Option<FontVariantNumeric>,
4219    pub font_variant_ligatures: Option<FontVariantLigatures>,
4220    pub font_variant_east_asian: Option<FontVariantEastAsian>,
4221}
4222
4223impl Hash for PartialStyleProperties {
4224    fn hash<H: Hasher>(&self, state: &mut H) {
4225        self.font_stack.hash(state);
4226        self.font_size_px.map(f32::to_bits).hash(state);
4227        self.color.hash(state);
4228        self.letter_spacing.hash(state);
4229        self.word_spacing.hash(state);
4230        self.line_height.hash(state);
4231        self.text_decoration.hash(state);
4232        self.font_features.hash(state);
4233
4234        // Manual hashing for Vec<(FourCc, f32)>
4235        if let Some(v) = self.font_variations.as_ref() {
4236            for (tag, val) in v {
4237                tag.hash(state);
4238                val.to_bits().hash(state);
4239            }
4240        }
4241
4242        self.tab_size.map(f32::to_bits).hash(state);
4243        self.text_transform.hash(state);
4244        self.writing_mode.hash(state);
4245        self.text_orientation.hash(state);
4246        self.text_combine_upright.hash(state);
4247        self.font_variant_caps.hash(state);
4248        self.font_variant_numeric.hash(state);
4249        self.font_variant_ligatures.hash(state);
4250        self.font_variant_east_asian.hash(state);
4251    }
4252}
4253
4254impl PartialEq for PartialStyleProperties {
4255    fn eq(&self, other: &Self) -> bool {
4256        self.font_stack == other.font_stack &&
4257        self.font_size_px.map(f32::to_bits) == other.font_size_px.map(f32::to_bits) &&
4258        self.color == other.color &&
4259        self.letter_spacing == other.letter_spacing &&
4260        self.word_spacing == other.word_spacing &&
4261        self.line_height == other.line_height &&
4262        self.text_decoration == other.text_decoration &&
4263        self.font_features == other.font_features &&
4264        self.font_variations == other.font_variations && // Vec<(FourCc, f32)> is PartialEq
4265        self.tab_size.map(f32::to_bits) == other.tab_size.map(f32::to_bits) &&
4266        self.text_transform == other.text_transform &&
4267        self.writing_mode == other.writing_mode &&
4268        self.text_orientation == other.text_orientation &&
4269        self.text_combine_upright == other.text_combine_upright &&
4270        self.font_variant_caps == other.font_variant_caps &&
4271        self.font_variant_numeric == other.font_variant_numeric &&
4272        self.font_variant_ligatures == other.font_variant_ligatures &&
4273        self.font_variant_east_asian == other.font_variant_east_asian
4274    }
4275}
4276
4277impl Eq for PartialStyleProperties {}
4278
4279impl StyleProperties {
4280    fn apply_override(&self, partial: &PartialStyleProperties) -> Self {
4281        let mut new_style = self.clone();
4282        if let Some(val) = &partial.font_stack {
4283            new_style.font_stack = val.clone();
4284        }
4285        if let Some(val) = partial.font_size_px {
4286            new_style.font_size_px = val;
4287        }
4288        if let Some(val) = &partial.color {
4289            new_style.color = *val;
4290        }
4291        if let Some(val) = partial.letter_spacing {
4292            new_style.letter_spacing = val;
4293        }
4294        if let Some(val) = partial.word_spacing {
4295            new_style.word_spacing = val;
4296        }
4297        if let Some(val) = partial.line_height {
4298            new_style.line_height = val;
4299        }
4300        if let Some(val) = &partial.text_decoration {
4301            new_style.text_decoration = *val;
4302        }
4303        if let Some(val) = &partial.font_features {
4304            new_style.font_features.clone_from(val);
4305        }
4306        if let Some(val) = &partial.font_variations {
4307            new_style.font_variations.clone_from(val);
4308        }
4309        if let Some(val) = partial.tab_size {
4310            new_style.tab_size = val;
4311        }
4312        if let Some(val) = partial.text_transform {
4313            new_style.text_transform = val;
4314        }
4315        if let Some(val) = partial.writing_mode {
4316            new_style.writing_mode = val;
4317        }
4318        if let Some(val) = partial.text_orientation {
4319            new_style.text_orientation = val;
4320        }
4321        if let Some(val) = &partial.text_combine_upright {
4322            new_style.text_combine_upright.clone_from(val);
4323        }
4324        if let Some(val) = partial.font_variant_caps {
4325            new_style.font_variant_caps = val;
4326        }
4327        if let Some(val) = partial.font_variant_numeric {
4328            new_style.font_variant_numeric = val;
4329        }
4330        if let Some(val) = partial.font_variant_ligatures {
4331            new_style.font_variant_ligatures = val;
4332        }
4333        if let Some(val) = partial.font_variant_east_asian {
4334            new_style.font_variant_east_asian = val;
4335        }
4336        new_style
4337    }
4338}
4339
4340/// The kind of a glyph, used to distinguish characters from layout-inserted items.
4341#[derive(Debug, Clone, Copy, PartialEq)]
4342pub enum GlyphKind {
4343    /// A standard glyph representing one or more characters from the source text.
4344    Character,
4345    /// A hyphen glyph inserted by the line breaking algorithm.
4346    Hyphen,
4347    /// A `.notdef` glyph, indicating a character that could not be found in any font.
4348    NotDef,
4349    /// A Kashida justification glyph, inserted to stretch Arabic text.
4350    Kashida {
4351        /// The target width of the kashida.
4352        width: f32,
4353    },
4354}
4355
4356// --- Stage 1: Logical Representation ---
4357
4358// [g117 az-web-lift FIX] `#[repr(C, u8)]` (was repr(Rust)) — same disc-mis-lift class as InlineContent
4359// above. LogicalItem is matched in measure Stage-2 (`if let LogicalItem::Text`) + reorder_logical_items;
4360// a repr(Rust) niche disc mis-lifts on the web. Explicit u8 tag at offset 0 = a simple load the lift
4361// reads correctly. Internal to text3 (not FFI-exposed). LogicalItem::Object embeds InlineContent inline.
4362#[derive(Debug, Clone)]
4363#[repr(C, u8)]
4364pub enum LogicalItem {
4365    Text {
4366        /// A stable ID pointing back to the original source character.
4367        source: ContentIndex,
4368        /// The text of this specific logical item (often a single grapheme cluster).
4369        text: String,
4370        style: Arc<StyleProperties>,
4371        /// If this text is a list marker: whether it should be positioned outside
4372        /// (in the padding gutter) or inside (inline with content).
4373        /// None for non-marker content.
4374        marker_position_outside: Option<bool>,
4375        /// The DOM `NodeId` of the Text node this item originated from.
4376        /// None for generated content (list markers, `::before/::after`, etc.)
4377        source_node_id: Option<NodeId>,
4378    },
4379    // +spec:display-property:b1533f - text-combine-upright tate-chu-yoko horizontal-in-vertical composition
4380    /// Tate-chu-yoko: Run of text to be laid out horizontally within a vertical context.
4381    CombinedText {
4382        source: ContentIndex,
4383        text: String,
4384        style: Arc<StyleProperties>,
4385    },
4386    Ruby {
4387        source: ContentIndex,
4388        // For the stub, we simplify to strings. A full implementation
4389        // would need to handle Vec<LogicalItem> for both.
4390        base_text: String,
4391        ruby_text: String,
4392        style: Arc<StyleProperties>,
4393    },
4394    Object {
4395        /// A stable ID pointing back to the original source object.
4396        source: ContentIndex,
4397        /// The original non-text object.
4398        content: InlineContent,
4399    },
4400    Tab {
4401        source: ContentIndex,
4402        style: Arc<StyleProperties>,
4403    },
4404    Break {
4405        source: ContentIndex,
4406        break_info: InlineBreak,
4407    },
4408}
4409
4410impl Hash for LogicalItem {
4411    fn hash<H: Hasher>(&self, state: &mut H) {
4412        discriminant(self).hash(state);
4413        match self {
4414            Self::Text {
4415                source,
4416                text,
4417                style,
4418                marker_position_outside,
4419                source_node_id,
4420            } => {
4421                source.hash(state);
4422                text.hash(state);
4423                style.as_ref().hash(state); // Hash the content, not the Arc pointer
4424                marker_position_outside.hash(state);
4425                source_node_id.hash(state);
4426            }
4427            Self::CombinedText {
4428                source,
4429                text,
4430                style,
4431            } => {
4432                source.hash(state);
4433                text.hash(state);
4434                style.as_ref().hash(state);
4435            }
4436            Self::Ruby {
4437                source,
4438                base_text,
4439                ruby_text,
4440                style,
4441            } => {
4442                source.hash(state);
4443                base_text.hash(state);
4444                ruby_text.hash(state);
4445                style.as_ref().hash(state);
4446            }
4447            Self::Object { source, content } => {
4448                source.hash(state);
4449                content.hash(state);
4450            }
4451            Self::Tab { source, style } => {
4452                source.hash(state);
4453                style.as_ref().hash(state);
4454            }
4455            Self::Break { source, break_info } => {
4456                source.hash(state);
4457                break_info.hash(state);
4458            }
4459        }
4460    }
4461}
4462
4463// --- Stage 2: Visual Representation ---
4464
4465#[derive(Debug, Clone)]
4466pub struct VisualItem {
4467    /// A reference to the logical item this visual item originated from.
4468    /// A single `LogicalItem` can be split into multiple `VisualItems`.
4469    pub logical_source: LogicalItem,
4470    /// The Bidi embedding level for this item.
4471    pub bidi_level: BidiLevel,
4472    /// The script detected for this run, crucial for shaping.
4473    pub script: Script,
4474    /// The text content for this specific visual run.
4475    pub text: String,
4476    /// Byte offset of this visual run's `text` within its source logical run's
4477    /// text. When bidi splits one logical run into several visual runs, each
4478    /// shaped cluster's `start_byte_in_run` is produced relative to this visual
4479    /// run's `text`; adding `run_byte_offset` re-bases it to the logical run so
4480    /// cluster IDs stay unique and match caret/selection byte positions.
4481    pub run_byte_offset: usize,
4482}
4483
4484// --- Stage 3: Shaped Representation ---
4485
4486// [g118 az-web-lift FIX] `#[repr(C, u8)]` (was repr(Rust)) — same disc-mis-lift class as InlineContent
4487// + LogicalItem (g117). ShapedItem is matched in measure Stage-5 (`match item { ShapedItem::Cluster ..}`)
4488// + cloned/matched throughout shaping; a repr(Rust) niche disc mis-lifts on the web. Explicit u8 tag at
4489// offset 0 = a simple load the lift reads correctly. Internal to text3 (not FFI-exposed).
4490#[derive(Debug, Clone)]
4491#[repr(C, u8)]
4492pub enum ShapedItem {
4493    Cluster(ShapedCluster),
4494    /// A block of combined text (tate-chu-yoko) that is laid out
4495    // as a single unbreakable object.
4496    CombinedBlock {
4497        source: ContentIndex,
4498        /// The glyphs to be rendered horizontally within the vertical line.
4499        glyphs: ShapedGlyphVec,
4500        bounds: Rect,
4501        baseline_offset: f32,
4502    },
4503    Object {
4504        source: ContentIndex,
4505        bounds: Rect,
4506        baseline_offset: f32,
4507        // Store original object for rendering
4508        content: InlineContent,
4509    },
4510    Tab {
4511        source: ContentIndex,
4512        bounds: Rect,
4513    },
4514    Break {
4515        source: ContentIndex,
4516        break_info: InlineBreak,
4517    },
4518}
4519
4520impl ShapedItem {
4521    #[must_use] pub const fn as_cluster(&self) -> Option<&ShapedCluster> {
4522        match self {
4523            Self::Cluster(c) => Some(c),
4524            _ => None,
4525        }
4526    }
4527    /// Returns the bounding box of the item, relative to its own origin.
4528    ///
4529    /// The origin of the returned `Rect` is `(0,0)`, representing the top-left corner
4530    /// of the item's layout space before final positioning. The size represents the
4531    /// item's total advance (width in horizontal mode) and its line height (ascent + descent).
4532    #[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
4533    #[must_use] pub fn bounds(&self) -> Rect {
4534        match self {
4535            Self::Cluster(cluster) => {
4536                // The width of a text cluster is its total advance.
4537                let width = cluster.advance;
4538
4539                // The height is the sum of its ascent and descent, which defines its line box.
4540                // We use the existing helper function which correctly calculates this from font
4541                // metrics.
4542                let (ascent, descent) = get_item_vertical_metrics_approx(self);
4543                let height = ascent + descent;
4544
4545                Rect {
4546                    x: 0.0,
4547                    y: 0.0,
4548                    width,
4549                    height,
4550                }
4551            }
4552            // For atomic inline items like objects, combined blocks, and tabs,
4553            // their bounds have already been calculated during the shaping or measurement phase.
4554            Self::CombinedBlock { bounds, .. } => *bounds,
4555            Self::Object { bounds, .. } => *bounds,
4556            Self::Tab { bounds, .. } => *bounds,
4557
4558            // Breaks are control characters and have no visual geometry.
4559            Self::Break { .. } => Rect::default(), // A zero-sized rectangle.
4560        }
4561    }
4562}
4563
4564/// A group of glyphs that corresponds to one or more source characters (a cluster).
4565#[derive(Debug, Clone)]
4566pub struct ShapedCluster {
4567    /// The original text that this cluster was shaped from.
4568    /// This is crucial for correct hyphenation.
4569    pub text: String,
4570    /// The ID of the grapheme cluster this glyph cluster represents.
4571    pub source_cluster_id: GraphemeClusterId,
4572    /// The source `ContentIndex` for mapping back to logical items.
4573    pub source_content_index: ContentIndex,
4574    /// The DOM `NodeId` of the Text node this cluster originated from.
4575    /// None for generated content (list markers, `::before/::after`, etc.)
4576    pub source_node_id: Option<NodeId>,
4577    /// The glyphs that make up this cluster. `SmallVec<[T; 1]>` — inline
4578    /// single-glyph clusters (the common case for Latin text), spill to
4579    /// heap only for ligatures / combining marks.
4580    pub glyphs: ShapedGlyphVec,
4581    /// The total advance width (horizontal) or height (vertical) of the cluster.
4582    pub advance: f32,
4583    /// The direction of this cluster, inherited from its `VisualItem`.
4584    pub direction: BidiDirection,
4585    /// Font style of this cluster
4586    pub style: Arc<StyleProperties>,
4587    /// If this cluster is a list marker: whether it should be positioned outside
4588    /// (in the padding gutter) or inside (inline with content).
4589    /// None for non-marker content.
4590    pub marker_position_outside: Option<bool>,
4591    /// True if this is the first visual fragment of its inline box.
4592    /// Used for `box-decoration-break` and split inline border/padding.
4593    /// When an inline element wraps across lines, only the first fragment
4594    /// gets the start-edge border/padding.
4595    pub is_first_fragment: bool,
4596    /// True if this is the last visual fragment of its inline box.
4597    /// Only the last fragment gets the end-edge border/padding.
4598    pub is_last_fragment: bool,
4599}
4600
4601/// A single, shaped glyph with its essential metrics.
4602#[derive(Debug, Clone)]
4603pub struct ShapedGlyph {
4604    /// The kind of glyph this is (character, hyphen, etc.).
4605    pub kind: GlyphKind,
4606    /// Glyph ID inside of the font
4607    pub glyph_id: u16,
4608    /// The byte offset of this glyph's source character(s) within its cluster text.
4609    pub cluster_offset: u32,
4610    /// The horizontal advance for this glyph (for horizontal text) - this is the BASE advance
4611    /// from the font metrics, WITHOUT kerning applied
4612    pub advance: f32,
4613    /// The kerning adjustment for this glyph (positive = more space, negative = less space)
4614    /// This is separate from advance so we can position glyphs absolutely
4615    pub kerning: f32,
4616    /// The horizontal offset/bearing for this glyph
4617    pub offset: Point,
4618    /// The vertical advance for this glyph (for vertical text).
4619    pub vertical_advance: f32,
4620    /// The vertical offset/bearing for this glyph.
4621    pub vertical_offset: Point,
4622    pub script: Script,
4623    pub style: Arc<StyleProperties>,
4624    /// Hash of the font - use `LoadedFonts` to look up the actual font when needed
4625    pub font_hash: u64,
4626    /// Cached font metrics to avoid font lookup for common operations
4627    pub font_metrics: LayoutFontMetrics,
4628}
4629
4630impl ShapedGlyph {
4631    #[must_use] pub fn into_glyph_instance<T: ParsedFontTrait>(
4632        &self,
4633        writing_mode: WritingMode,
4634        loaded_fonts: &LoadedFonts<T>,
4635    ) -> GlyphInstance {
4636        let size = loaded_fonts
4637            .get_by_hash(self.font_hash)
4638            .and_then(|font| font.get_glyph_size(self.glyph_id, self.style.font_size_px))
4639            .unwrap_or_default();
4640
4641        let position = if writing_mode.is_advance_horizontal() {
4642            LogicalPosition {
4643                x: self.offset.x,
4644                y: self.offset.y,
4645            }
4646        } else {
4647            LogicalPosition {
4648                x: self.vertical_offset.x,
4649                y: self.vertical_offset.y,
4650            }
4651        };
4652
4653        GlyphInstance {
4654            index: u32::from(self.glyph_id),
4655            point: position,
4656            size,
4657        }
4658    }
4659
4660    /// Convert this `ShapedGlyph` into a `GlyphInstance` with an absolute position.
4661    /// This is used for display list generation where glyphs need their final page coordinates.
4662    #[must_use] pub fn into_glyph_instance_at<T: ParsedFontTrait>(
4663        &self,
4664        writing_mode: WritingMode,
4665        absolute_position: LogicalPosition,
4666        loaded_fonts: &LoadedFonts<T>,
4667    ) -> GlyphInstance {
4668        let size = loaded_fonts
4669            .get_by_hash(self.font_hash)
4670            .and_then(|font| font.get_glyph_size(self.glyph_id, self.style.font_size_px))
4671            .unwrap_or_default();
4672
4673        GlyphInstance {
4674            index: u32::from(self.glyph_id),
4675            point: absolute_position,
4676            size,
4677        }
4678    }
4679
4680    /// Convert this `ShapedGlyph` into a `GlyphInstance` with an absolute position.
4681    /// This version doesn't require fonts - it uses a default size.
4682    /// Use this when you don't need precise glyph bounds (e.g., display list generation).
4683    #[must_use] pub fn into_glyph_instance_at_simple(
4684        &self,
4685        _writing_mode: WritingMode,
4686        absolute_position: LogicalPosition,
4687    ) -> GlyphInstance {
4688        // Use font metrics to estimate size, or default to zero
4689        // The actual rendering will use the font directly
4690        GlyphInstance {
4691            index: u32::from(self.glyph_id),
4692            point: absolute_position,
4693            size: LogicalSize::default(),
4694        }
4695    }
4696}
4697
4698// --- Stage 4: Positioned Representation (Final Layout) ---
4699
4700#[derive(Debug, Clone)]
4701pub struct PositionedItem {
4702    pub item: ShapedItem,
4703    pub position: Point,
4704    pub line_index: usize,
4705}
4706
4707#[derive(Debug, Clone)]
4708pub struct UnifiedLayout {
4709    pub items: Vec<PositionedItem>,
4710    /// Information about content that did not fit.
4711    pub overflow: OverflowInfo,
4712}
4713
4714impl UnifiedLayout {
4715    /// Calculate the bounding box of all positioned items.
4716    /// This is computed on-demand rather than cached.
4717    #[must_use] pub fn bounds(&self) -> Rect {
4718        if self.items.is_empty() {
4719            return Rect::default();
4720        }
4721
4722        let mut min_x = f32::MAX;
4723        let mut min_y = f32::MAX;
4724        let mut max_x = f32::MIN;
4725        let mut max_y = f32::MIN;
4726
4727        for item in &self.items {
4728            let item_x = item.position.x;
4729            let item_y = item.position.y;
4730
4731            // Get item dimensions
4732            let item_bounds = item.item.bounds();
4733            let item_width = item_bounds.width;
4734            let item_height = item_bounds.height;
4735
4736            min_x = min_x.min(item_x);
4737            min_y = min_y.min(item_y);
4738            max_x = max_x.max(item_x + item_width);
4739            max_y = max_y.max(item_y + item_height);
4740        }
4741
4742        Rect {
4743            x: min_x,
4744            y: min_y,
4745            width: max_x - min_x,
4746            height: max_y - min_y,
4747        }
4748    }
4749
4750    #[must_use] pub const fn is_empty(&self) -> bool {
4751        self.items.is_empty()
4752    }
4753    #[must_use] pub fn first_baseline(&self) -> Option<f32> {
4754        self.items
4755            .iter()
4756            .find_map(|item| get_baseline_for_item(&item.item))
4757    }
4758
4759    #[must_use] pub fn last_baseline(&self) -> Option<f32> {
4760        self.items
4761            .iter()
4762            .rev()
4763            .find_map(|item| get_baseline_for_item(&item.item))
4764    }
4765
4766    /// Takes a point relative to the layout's origin and returns the closest
4767    /// logical cursor position.
4768    ///
4769    /// This is the unified hit-testing implementation. The old `hit_test_to_cursor`
4770    /// method is deprecated in favor of this one.
4771    #[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
4772    #[must_use] pub fn hittest_cursor(&self, point: LogicalPosition) -> Option<TextCursor> {
4773        if self.items.is_empty() {
4774            return None;
4775        }
4776
4777        // Find the closest cluster vertically and horizontally
4778        let mut closest_item_idx = 0;
4779        let mut closest_distance = f32::MAX;
4780
4781        for (idx, item) in self.items.iter().enumerate() {
4782            // Only consider cluster items for cursor placement
4783            if !matches!(item.item, ShapedItem::Cluster(_)) {
4784                continue;
4785            }
4786
4787            let item_bounds = item.item.bounds();
4788            let item_center_y = item.position.y + item_bounds.height / 2.0;
4789
4790            // Distance from click position to item center
4791            let vertical_distance = (point.y - item_center_y).abs();
4792
4793            // For horizontal distance, check if we're within the cluster bounds
4794            let horizontal_distance = if point.x < item.position.x {
4795                item.position.x - point.x
4796            } else if point.x > item.position.x + item_bounds.width {
4797                point.x - (item.position.x + item_bounds.width)
4798            } else {
4799                0.0 // Inside the cluster horizontally
4800            };
4801
4802            // Combined distance (prioritize vertical proximity)
4803            let distance = vertical_distance * 2.0 + horizontal_distance;
4804
4805            if distance < closest_distance {
4806                closest_distance = distance;
4807                closest_item_idx = idx;
4808            }
4809        }
4810
4811        // Get the closest cluster
4812        let closest_item = &self.items[closest_item_idx];
4813        let cluster = match &closest_item.item {
4814            ShapedItem::Cluster(c) => c,
4815            // Objects are treated as a single cluster for selection
4816            ShapedItem::Object { source, .. } | ShapedItem::CombinedBlock { source, .. } => {
4817                return Some(TextCursor {
4818                    cluster_id: GraphemeClusterId {
4819                        source_run: source.run_index,
4820                        start_byte_in_run: source.item_index,
4821                    },
4822                    affinity: if point.x
4823                        < closest_item.position.x + (closest_item.item.bounds().width / 2.0)
4824                    {
4825                        CursorAffinity::Leading
4826                    } else {
4827                        CursorAffinity::Trailing
4828                    },
4829                });
4830            }
4831            _ => return None,
4832        };
4833
4834        // Determine affinity based on which half of the cluster was clicked
4835        let cluster_mid_x = closest_item.position.x + cluster.advance / 2.0;
4836        let affinity = if point.x < cluster_mid_x {
4837            CursorAffinity::Leading
4838        } else {
4839            CursorAffinity::Trailing
4840        };
4841
4842        Some(TextCursor {
4843            cluster_id: cluster.source_cluster_id,
4844            affinity,
4845        })
4846    }
4847
4848    /// Given a logical selection range, returns a vector of visual rectangles
4849    /// that cover the selected text, in the layout's coordinate space.
4850    #[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
4851    #[must_use] pub fn get_selection_rects(&self, range: &SelectionRange) -> Vec<LogicalRect> {
4852        // 1. Build a map from the logical cluster ID to the visual PositionedItem for fast lookups.
4853        let mut cluster_map: HashMap<GraphemeClusterId, &PositionedItem> = HashMap::new();
4854        for item in &self.items {
4855            if let Some(cluster) = item.item.as_cluster() {
4856                cluster_map.insert(cluster.source_cluster_id, item);
4857            }
4858        }
4859
4860        // 2. Normalize the range to ensure start always logically precedes end.
4861        let (start_cursor, end_cursor) = if range.start.cluster_id > range.end.cluster_id
4862            || (range.start.cluster_id == range.end.cluster_id
4863                && range.start.affinity > range.end.affinity)
4864        {
4865            (range.end, range.start)
4866        } else {
4867            (range.start, range.end)
4868        };
4869
4870        // 3. Find the positioned items corresponding to the start and end of the selection.
4871        let Some(start_item) = cluster_map.get(&start_cursor.cluster_id) else {
4872            return Vec::new();
4873        };
4874        let Some(end_item) = cluster_map.get(&end_cursor.cluster_id) else {
4875            return Vec::new();
4876        };
4877
4878        let mut rects = Vec::new();
4879
4880        // Helper to get the absolute visual X coordinate of a cursor. The logical
4881        // start (Leading) edge is the cluster's LEFT for LTR but its RIGHT for RTL;
4882        // Trailing is the mirror.
4883        let get_cursor_x = |item: &PositionedItem, affinity: CursorAffinity| -> f32 {
4884            let left = item.position.x;
4885            let right = item.position.x + get_item_measure(&item.item, false);
4886            let rtl = item.item.as_cluster().is_some_and(|c| c.direction.is_rtl());
4887            match (affinity, rtl) {
4888                (CursorAffinity::Leading, false) | (CursorAffinity::Trailing, true) => left,
4889                (CursorAffinity::Trailing, false) | (CursorAffinity::Leading, true) => right,
4890            }
4891        };
4892
4893        // Helper to get the visual bounding box of all content on a specific line index.
4894        let get_line_bounds = |line_index: usize| -> Option<LogicalRect> {
4895            let items_on_line = self.items.iter().filter(|i| i.line_index == line_index);
4896
4897            let mut min_x: Option<f32> = None;
4898            let mut max_x: Option<f32> = None;
4899            let mut min_y: Option<f32> = None;
4900            let mut max_y: Option<f32> = None;
4901
4902            for item in items_on_line {
4903                // Skip items that don't take up space (like hard breaks)
4904                let item_bounds = item.item.bounds();
4905                if item_bounds.width <= 0.0 && item_bounds.height <= 0.0 {
4906                    continue;
4907                }
4908
4909                let item_x_end = item.position.x + item_bounds.width;
4910                let item_y_end = item.position.y + item_bounds.height;
4911
4912                min_x = Some(min_x.map_or(item.position.x, |mx| mx.min(item.position.x)));
4913                max_x = Some(max_x.map_or(item_x_end, |mx| mx.max(item_x_end)));
4914                min_y = Some(min_y.map_or(item.position.y, |my| my.min(item.position.y)));
4915                max_y = Some(max_y.map_or(item_y_end, |my| my.max(item_y_end)));
4916            }
4917
4918            if let (Some(min_x), Some(max_x), Some(min_y), Some(max_y)) =
4919                (min_x, max_x, min_y, max_y)
4920            {
4921                Some(LogicalRect {
4922                    origin: LogicalPosition { x: min_x, y: min_y },
4923                    size: LogicalSize {
4924                        width: max_x - min_x,
4925                        height: max_y - min_y,
4926                    },
4927                })
4928            } else {
4929                None
4930            }
4931        };
4932
4933        // 4. Handle single-line selection.
4934        if start_item.line_index == end_item.line_index {
4935            if let Some(line_bounds) = get_line_bounds(start_item.line_index) {
4936                // Walk the selected clusters in VISUAL order and group them into
4937                // segments by bidi direction + visual contiguity, emitting one rect
4938                // per segment. A single endpoint-to-endpoint span over-covers (and can
4939                // under-cover) bidi selections, whose logically-contiguous clusters are
4940                // NOT visually contiguous. Pure-LTR/RTL contiguous runs collapse to a
4941                // single rect, matching browser/CoreText behavior.
4942                let mut segments: Vec<(f32, f32, BidiDirection)> = Vec::new();
4943                for item in &self.items {
4944                    if item.line_index != start_item.line_index {
4945                        continue;
4946                    }
4947                    let Some(c) = item.item.as_cluster() else {
4948                        continue;
4949                    };
4950                    let id = c.source_cluster_id;
4951                    // A cluster is selected when it lies within the (affinity-aware)
4952                    // logical range: the start cluster is included only if the start
4953                    // cursor sits on its leading edge; the end cluster only if the end
4954                    // cursor sits on its trailing edge.
4955                    let after_start = id > start_cursor.cluster_id
4956                        || (id == start_cursor.cluster_id
4957                            && start_cursor.affinity == CursorAffinity::Leading);
4958                    let before_end = id < end_cursor.cluster_id
4959                        || (id == end_cursor.cluster_id
4960                            && end_cursor.affinity == CursorAffinity::Trailing);
4961                    if !(after_start && before_end) {
4962                        continue;
4963                    }
4964                    let x0 = item.position.x;
4965                    let x1 = item.position.x + get_item_measure(&item.item, false);
4966                    let (lo, hi) = (x0.min(x1), x0.max(x1));
4967                    if let Some(last) = segments.last_mut() {
4968                        let contiguous = lo <= last.1 + 0.5 && hi >= last.0 - 0.5;
4969                        if last.2 == c.direction && contiguous {
4970                            last.0 = last.0.min(lo);
4971                            last.1 = last.1.max(hi);
4972                            continue;
4973                        }
4974                    }
4975                    segments.push((lo, hi, c.direction));
4976                }
4977
4978                if segments.is_empty() {
4979                    // No glyph-bearing clusters (e.g. zero-advance selection):
4980                    // fall back to the endpoint span so a caret-width rect still shows.
4981                    let start_x = get_cursor_x(start_item, start_cursor.affinity);
4982                    let end_x = get_cursor_x(end_item, end_cursor.affinity);
4983                    rects.push(LogicalRect {
4984                        origin: LogicalPosition {
4985                            x: start_x.min(end_x),
4986                            y: line_bounds.origin.y,
4987                        },
4988                        size: LogicalSize {
4989                            width: (end_x - start_x).abs(),
4990                            height: line_bounds.size.height,
4991                        },
4992                    });
4993                } else {
4994                    for (lo, hi, _dir) in segments {
4995                        rects.push(LogicalRect {
4996                            origin: LogicalPosition {
4997                                x: lo,
4998                                y: line_bounds.origin.y,
4999                            },
5000                            size: LogicalSize {
5001                                width: hi - lo,
5002                                height: line_bounds.size.height,
5003                            },
5004                        });
5005                    }
5006                }
5007            }
5008        }
5009        // 5. Handle multi-line selection.
5010        else {
5011            // Rectangle for the start line (from the start cursor to the line's end
5012            // in READING order). For an LTR line that is rightward (to the line's
5013            // right content edge); for an RTL line it is leftward (to the left edge).
5014            if let Some(start_line_bounds) = get_line_bounds(start_item.line_index) {
5015                let start_x = get_cursor_x(start_item, start_cursor.affinity);
5016                let line_left = start_line_bounds.origin.x;
5017                let line_right = start_line_bounds.origin.x + start_line_bounds.size.width;
5018                let rtl = start_item.item.as_cluster().is_some_and(|c| c.direction.is_rtl());
5019                let (lo, hi) = if rtl { (line_left, start_x) } else { (start_x, line_right) };
5020                rects.push(LogicalRect {
5021                    origin: LogicalPosition {
5022                        x: lo,
5023                        y: start_line_bounds.origin.y,
5024                    },
5025                    size: LogicalSize {
5026                        width: hi - lo,
5027                        height: start_line_bounds.size.height,
5028                    },
5029                });
5030            }
5031
5032            // Rectangles for all full lines in between.
5033            for line_idx in (start_item.line_index + 1)..end_item.line_index {
5034                if let Some(line_bounds) = get_line_bounds(line_idx) {
5035                    rects.push(line_bounds);
5036                }
5037            }
5038
5039            // Rectangle for the end line (from the line's start in READING order to
5040            // the end cursor). For an LTR line that starts at the left content edge;
5041            // for an RTL line it starts at the right edge.
5042            if let Some(end_line_bounds) = get_line_bounds(end_item.line_index) {
5043                let end_x = get_cursor_x(end_item, end_cursor.affinity);
5044                let line_left = end_line_bounds.origin.x;
5045                let line_right = end_line_bounds.origin.x + end_line_bounds.size.width;
5046                let rtl = end_item.item.as_cluster().is_some_and(|c| c.direction.is_rtl());
5047                let (lo, hi) = if rtl { (end_x, line_right) } else { (line_left, end_x) };
5048                rects.push(LogicalRect {
5049                    origin: LogicalPosition {
5050                        x: lo,
5051                        y: end_line_bounds.origin.y,
5052                    },
5053                    size: LogicalSize {
5054                        width: hi - lo,
5055                        height: end_line_bounds.size.height,
5056                    },
5057                });
5058            }
5059        }
5060
5061        rects
5062    }
5063
5064    /// Calculates the visual rectangle for a cursor at a given logical position.
5065    #[must_use] pub fn get_cursor_rect(&self, cursor: &TextCursor) -> Option<LogicalRect> {
5066        // Find the item and glyph corresponding to the cursor's cluster ID.
5067        let mut last_cluster: Option<(&PositionedItem, &ShapedCluster)> = None;
5068        for item in &self.items {
5069            if let ShapedItem::Cluster(cluster) = &item.item {
5070                if cluster.source_cluster_id == cursor.cluster_id {
5071                    // Exact match
5072                    let line_height = item.item.bounds().height;
5073                    // The logical-start (Leading) caret edge is the glyph's LEFT side for
5074                    // an LTR cluster but its RIGHT side for an RTL cluster; Trailing is the
5075                    // mirror. Resolve the edges from the cluster's own bidi direction.
5076                    let (lead_x, trail_x) = if cluster.direction.is_rtl() {
5077                        (item.position.x + cluster.advance, item.position.x)
5078                    } else {
5079                        (item.position.x, item.position.x + cluster.advance)
5080                    };
5081                    let cursor_x = match cursor.affinity {
5082                        CursorAffinity::Leading => lead_x,
5083                        CursorAffinity::Trailing => trail_x,
5084                    };
5085                    return Some(LogicalRect {
5086                        origin: LogicalPosition {
5087                            x: cursor_x,
5088                            y: item.position.y,
5089                        },
5090                        size: LogicalSize {
5091                            width: 1.0,
5092                            height: line_height,
5093                        },
5094                    });
5095                }
5096                last_cluster = Some((item, cluster));
5097            }
5098        }
5099        // Cursor past end of text: position after the last cluster
5100        if let Some((item, cluster)) = last_cluster {
5101            if cursor.cluster_id.source_run == cluster.source_cluster_id.source_run
5102                && cursor.cluster_id.start_byte_in_run >= cluster.source_cluster_id.start_byte_in_run
5103            {
5104                let line_height = item.item.bounds().height;
5105                // Past the logical end of the run: the caret sits after the last cluster,
5106                // which is its RIGHT edge for LTR but its LEFT edge for RTL.
5107                let past_end_x = if cluster.direction.is_rtl() {
5108                    item.position.x
5109                } else {
5110                    item.position.x + cluster.advance
5111                };
5112                return Some(LogicalRect {
5113                    origin: LogicalPosition {
5114                        x: past_end_x,
5115                        y: item.position.y,
5116                    },
5117                    size: LogicalSize {
5118                        width: 1.0,
5119                        height: line_height,
5120                    },
5121                });
5122            }
5123        }
5124        None
5125    }
5126
5127    /// Get a cursor at the first cluster (leading edge) in the layout.
5128    #[must_use] pub fn get_first_cluster_cursor(&self) -> Option<TextCursor> {
5129        for item in &self.items {
5130            if let ShapedItem::Cluster(cluster) = &item.item {
5131                return Some(TextCursor {
5132                    cluster_id: cluster.source_cluster_id,
5133                    affinity: CursorAffinity::Leading,
5134                });
5135            }
5136        }
5137        None
5138    }
5139
5140    /// Get a cursor at the last cluster (trailing edge) in the layout.
5141    #[must_use] pub fn get_last_cluster_cursor(&self) -> Option<TextCursor> {
5142        for item in self.items.iter().rev() {
5143            if let ShapedItem::Cluster(cluster) = &item.item {
5144                return Some(TextCursor {
5145                    cluster_id: cluster.source_cluster_id,
5146                    affinity: CursorAffinity::Trailing,
5147                });
5148            }
5149        }
5150        None
5151    }
5152
5153    /// Logical sequence of caret-stop grapheme clusters, sorted by
5154    /// `(source_run, start_byte_in_run)` and de-duplicated, with combining-mark
5155    /// continuations folded into their base (UAX#29). Left/right caret motion
5156    /// advances over THIS sequence so a base and its combining marks move as one
5157    /// unit, and so the document start/end are always reachable.
5158    fn grapheme_stops(&self) -> Vec<GraphemeClusterId> {
5159        let mut stops: Vec<(GraphemeClusterId, &str)> = self
5160            .items
5161            .iter()
5162            .filter_map(|it| {
5163                it.item
5164                    .as_cluster()
5165                    .map(|c| (c.source_cluster_id, c.text.as_str()))
5166            })
5167            .collect();
5168        stops.sort_by(|a, b| {
5169            (a.0.source_run, a.0.start_byte_in_run).cmp(&(b.0.source_run, b.0.start_byte_in_run))
5170        });
5171        stops.dedup_by_key(|(id, _)| *id);
5172        stops
5173            .into_iter()
5174            .filter(|(_, text)| !Self::cluster_is_grapheme_continuation(text))
5175            .map(|(id, _)| id)
5176            .collect()
5177    }
5178
5179    /// True if `text`'s leading char is a grapheme extender (combining mark,
5180    /// variation selector, …) — a cluster that merges into a preceding base and
5181    /// therefore must not be a standalone caret stop (UAX#29).
5182    fn cluster_is_grapheme_continuation(text: &str) -> bool {
5183        let Some(first) = text.chars().next() else {
5184            return false;
5185        };
5186        // Probe with a dummy base letter: if `x` + first collapses to a single
5187        // grapheme, `first` extends the preceding grapheme.
5188        let mut probe = String::with_capacity(1 + first.len_utf8());
5189        probe.push('x');
5190        probe.push(first);
5191        probe.graphemes(true).count() == 1
5192    }
5193
5194    /// Caret offset of `cursor` within `stops` (0..=len): the index of its
5195    /// grapheme, plus 1 for a Trailing affinity. A cursor addressing a folded
5196    /// combining mark (or otherwise between stops) maps to the nearest preceding
5197    /// stop.
5198    fn grapheme_caret_offset(stops: &[GraphemeClusterId], cursor: &TextCursor) -> Option<usize> {
5199        let trailing = usize::from(cursor.affinity == CursorAffinity::Trailing);
5200        if let Some(idx) = stops.iter().position(|id| *id == cursor.cluster_id) {
5201            return Some(idx + trailing);
5202        }
5203        let key = (cursor.cluster_id.source_run, cursor.cluster_id.start_byte_in_run);
5204        let idx = stops
5205            .iter()
5206            .rposition(|id| (id.source_run, id.start_byte_in_run) <= key)?;
5207        Some(idx + trailing)
5208    }
5209
5210    /// Canonical cursor for a grapheme-stop `offset` (0..=len): interior/first
5211    /// offsets are the Leading edge of the stop that begins there; `len` is the
5212    /// Trailing edge of the last stop (the document end).
5213    fn cursor_from_grapheme_offset(stops: &[GraphemeClusterId], offset: usize) -> TextCursor {
5214        let n = stops.len();
5215        if offset >= n {
5216            TextCursor { cluster_id: stops[n - 1], affinity: CursorAffinity::Trailing }
5217        } else {
5218            TextCursor { cluster_id: stops[offset], affinity: CursorAffinity::Leading }
5219        }
5220    }
5221
5222    /// Moves a cursor one visible position to the left (the previous grapheme
5223    /// boundary). Affinity is consulted so each press moves exactly one stop and
5224    /// the document start (first grapheme, Leading) is reachable; combining marks
5225    /// move together with their base.
5226    pub fn move_cursor_left(
5227        &self,
5228        cursor: TextCursor,
5229        debug: &mut Option<Vec<String>>,
5230    ) -> TextCursor {
5231        let stops = self.grapheme_stops();
5232        if stops.is_empty() {
5233            return cursor;
5234        }
5235        let Some(offset) = Self::grapheme_caret_offset(&stops, &cursor) else {
5236            return cursor;
5237        };
5238        let moved = Self::cursor_from_grapheme_offset(&stops, offset.saturating_sub(1));
5239        if let Some(d) = debug {
5240            d.push(format!(
5241                "[Cursor] move_cursor_left: byte {} -> byte {}",
5242                cursor.cluster_id.start_byte_in_run, moved.cluster_id.start_byte_in_run
5243            ));
5244        }
5245        moved
5246    }
5247
5248    /// Moves a cursor one visible position to the right (the next grapheme
5249    /// boundary). Affinity is consulted so each press moves exactly one stop and
5250    /// the document end (last grapheme, Trailing) is reachable; combining marks
5251    /// move together with their base.
5252    pub fn move_cursor_right(
5253        &self,
5254        cursor: TextCursor,
5255        debug: &mut Option<Vec<String>>,
5256    ) -> TextCursor {
5257        let stops = self.grapheme_stops();
5258        if stops.is_empty() {
5259            return cursor;
5260        }
5261        let Some(offset) = Self::grapheme_caret_offset(&stops, &cursor) else {
5262            return cursor;
5263        };
5264        let moved = Self::cursor_from_grapheme_offset(&stops, (offset + 1).min(stops.len()));
5265        if let Some(d) = debug {
5266            d.push(format!(
5267                "[Cursor] move_cursor_right: byte {} -> byte {}",
5268                cursor.cluster_id.start_byte_in_run, moved.cluster_id.start_byte_in_run
5269            ));
5270        }
5271        moved
5272    }
5273
5274    /// Moves a cursor up one line, attempting to preserve the horizontal column.
5275    pub fn move_cursor_up(
5276        &self,
5277        cursor: TextCursor,
5278        goal_x: &mut Option<f32>,
5279        debug: &mut Option<Vec<String>>,
5280    ) -> TextCursor {
5281        if let Some(d) = debug {
5282            d.push(format!(
5283                "[Cursor] move_cursor_up: from byte {} (affinity {:?})",
5284                cursor.cluster_id.start_byte_in_run, cursor.affinity
5285            ));
5286        }
5287
5288        let Some(current_item) = self.items.iter().find(|i| {
5289            i.item
5290                .as_cluster()
5291                .is_some_and(|c| c.source_cluster_id == cursor.cluster_id)
5292        }) else {
5293            if let Some(d) = debug {
5294                d.push(format!(
5295                    "[Cursor] move_cursor_up: cursor not found in items, staying at byte {}",
5296                    cursor.cluster_id.start_byte_in_run
5297                ));
5298            }
5299            return cursor;
5300        };
5301
5302        if let Some(d) = debug {
5303            d.push(format!(
5304                "[Cursor] move_cursor_up: current line {}, position ({}, {})",
5305                current_item.line_index, current_item.position.x, current_item.position.y
5306            ));
5307        }
5308
5309        let target_line_idx = current_item.line_index.saturating_sub(1);
5310        if current_item.line_index == target_line_idx {
5311            if let Some(d) = debug {
5312                d.push(format!(
5313                    "[Cursor] move_cursor_up: already at top line {}, staying put",
5314                    current_item.line_index
5315                ));
5316            }
5317            return cursor;
5318        }
5319
5320        let current_x = goal_x.unwrap_or_else(|| {
5321            let x = match cursor.affinity {
5322                CursorAffinity::Leading => current_item.position.x,
5323                CursorAffinity::Trailing => {
5324                    current_item.position.x + get_item_measure(&current_item.item, false)
5325                }
5326            };
5327            *goal_x = Some(x);
5328            x
5329        });
5330
5331        // Find the Y coordinate of the middle of the target line
5332        let target_y = self
5333            .items
5334            .iter()
5335            .find(|i| i.line_index == target_line_idx)
5336            .map_or(current_item.position.y, |i| i.position.y + (i.item.bounds().height / 2.0));
5337
5338        if let Some(d) = debug {
5339            d.push(format!(
5340                "[Cursor] move_cursor_up: target line {target_line_idx}, hittesting at ({current_x}, {target_y})"
5341            ));
5342        }
5343
5344        let result = self
5345            .hittest_cursor(LogicalPosition {
5346                x: current_x,
5347                y: target_y,
5348            })
5349            .unwrap_or(cursor);
5350
5351        if let Some(d) = debug {
5352            d.push(format!(
5353                "[Cursor] move_cursor_up: result byte {} (affinity {:?})",
5354                result.cluster_id.start_byte_in_run, result.affinity
5355            ));
5356        }
5357
5358        result
5359    }
5360
5361    /// Moves a cursor down one line, attempting to preserve the horizontal column.
5362    pub fn move_cursor_down(
5363        &self,
5364        cursor: TextCursor,
5365        goal_x: &mut Option<f32>,
5366        debug: &mut Option<Vec<String>>,
5367    ) -> TextCursor {
5368        if let Some(d) = debug {
5369            d.push(format!(
5370                "[Cursor] move_cursor_down: from byte {} (affinity {:?})",
5371                cursor.cluster_id.start_byte_in_run, cursor.affinity
5372            ));
5373        }
5374
5375        let Some(current_item) = self.items.iter().find(|i| {
5376            i.item
5377                .as_cluster()
5378                .is_some_and(|c| c.source_cluster_id == cursor.cluster_id)
5379        }) else {
5380            if let Some(d) = debug {
5381                d.push(format!(
5382                    "[Cursor] move_cursor_down: cursor not found in items, staying at byte {}",
5383                    cursor.cluster_id.start_byte_in_run
5384                ));
5385            }
5386            return cursor;
5387        };
5388
5389        if let Some(d) = debug {
5390            d.push(format!(
5391                "[Cursor] move_cursor_down: current line {}, position ({}, {})",
5392                current_item.line_index, current_item.position.x, current_item.position.y
5393            ));
5394        }
5395
5396        let max_line = self.items.iter().map(|i| i.line_index).max().unwrap_or(0);
5397        let target_line_idx = (current_item.line_index + 1).min(max_line);
5398        if current_item.line_index == target_line_idx {
5399            if let Some(d) = debug {
5400                d.push(format!(
5401                    "[Cursor] move_cursor_down: already at bottom line {}, staying put",
5402                    current_item.line_index
5403                ));
5404            }
5405            return cursor;
5406        }
5407
5408        let current_x = goal_x.unwrap_or_else(|| {
5409            let x = match cursor.affinity {
5410                CursorAffinity::Leading => current_item.position.x,
5411                CursorAffinity::Trailing => {
5412                    current_item.position.x + get_item_measure(&current_item.item, false)
5413                }
5414            };
5415            *goal_x = Some(x);
5416            x
5417        });
5418
5419        let target_y = self
5420            .items
5421            .iter()
5422            .find(|i| i.line_index == target_line_idx)
5423            .map_or(current_item.position.y, |i| i.position.y + (i.item.bounds().height / 2.0));
5424
5425        if let Some(d) = debug {
5426            d.push(format!(
5427                "[Cursor] move_cursor_down: hit testing at ({current_x}, {target_y})"
5428            ));
5429        }
5430
5431        let result = self
5432            .hittest_cursor(LogicalPosition {
5433                x: current_x,
5434                y: target_y,
5435            })
5436            .unwrap_or(cursor);
5437
5438        if let Some(d) = debug {
5439            d.push(format!(
5440                "[Cursor] move_cursor_down: result byte {}, affinity {:?}",
5441                result.cluster_id.start_byte_in_run, result.affinity
5442            ));
5443        }
5444
5445        result
5446    }
5447
5448    /// Moves a cursor to the visual start of its current line.
5449    pub fn move_cursor_to_line_start(
5450        &self,
5451        cursor: TextCursor,
5452        debug: &mut Option<Vec<String>>,
5453    ) -> TextCursor {
5454        if let Some(d) = debug {
5455            d.push(format!(
5456                "[Cursor] move_cursor_to_line_start: starting at byte {}, affinity {:?}",
5457                cursor.cluster_id.start_byte_in_run, cursor.affinity
5458            ));
5459        }
5460
5461        let Some(current_item) = self.items.iter().find(|i| {
5462            i.item
5463                .as_cluster()
5464                .is_some_and(|c| c.source_cluster_id == cursor.cluster_id)
5465        }) else {
5466            if let Some(d) = debug {
5467                d.push(format!(
5468                    "[Cursor] move_cursor_to_line_start: cursor not found, staying at byte {}",
5469                    cursor.cluster_id.start_byte_in_run
5470                ));
5471            }
5472            return cursor;
5473        };
5474
5475        if let Some(d) = debug {
5476            d.push(format!(
5477                "[Cursor] move_cursor_to_line_start: current line {}, position ({}, {})",
5478                current_item.line_index, current_item.position.x, current_item.position.y
5479            ));
5480        }
5481
5482        let first_item_on_line = self
5483            .items
5484            .iter()
5485            .filter(|i| i.line_index == current_item.line_index)
5486            .min_by(|a, b| {
5487                a.position
5488                    .x
5489                    .partial_cmp(&b.position.x)
5490                    .unwrap_or(Ordering::Equal)
5491            });
5492
5493        if let Some(item) = first_item_on_line {
5494            if let ShapedItem::Cluster(c) = &item.item {
5495                let result = TextCursor {
5496                    cluster_id: c.source_cluster_id,
5497                    affinity: CursorAffinity::Leading,
5498                };
5499                if let Some(d) = debug {
5500                    d.push(format!(
5501                        "[Cursor] move_cursor_to_line_start: result byte {}, affinity {:?}",
5502                        result.cluster_id.start_byte_in_run, result.affinity
5503                    ));
5504                }
5505                return result;
5506            }
5507        }
5508
5509        if let Some(d) = debug {
5510            d.push(format!(
5511                "[Cursor] move_cursor_to_line_start: no first item found, staying at byte {}",
5512                cursor.cluster_id.start_byte_in_run
5513            ));
5514        }
5515        cursor
5516    }
5517
5518    /// Moves a cursor to the visual end of its current line.
5519    pub fn move_cursor_to_line_end(
5520        &self,
5521        cursor: TextCursor,
5522        debug: &mut Option<Vec<String>>,
5523    ) -> TextCursor {
5524        if let Some(d) = debug {
5525            d.push(format!(
5526                "[Cursor] move_cursor_to_line_end: starting at byte {}, affinity {:?}",
5527                cursor.cluster_id.start_byte_in_run, cursor.affinity
5528            ));
5529        }
5530
5531        let Some(current_item) = self.items.iter().find(|i| {
5532            i.item
5533                .as_cluster()
5534                .is_some_and(|c| c.source_cluster_id == cursor.cluster_id)
5535        }) else {
5536            if let Some(d) = debug {
5537                d.push(format!(
5538                    "[Cursor] move_cursor_to_line_end: cursor not found, staying at byte {}",
5539                    cursor.cluster_id.start_byte_in_run
5540                ));
5541            }
5542            return cursor;
5543        };
5544
5545        if let Some(d) = debug {
5546            d.push(format!(
5547                "[Cursor] move_cursor_to_line_end: current line {}, position ({}, {})",
5548                current_item.line_index, current_item.position.x, current_item.position.y
5549            ));
5550        }
5551
5552        let last_item_on_line = self
5553            .items
5554            .iter()
5555            .filter(|i| i.line_index == current_item.line_index)
5556            .max_by(|a, b| {
5557                a.position
5558                    .x
5559                    .partial_cmp(&b.position.x)
5560                    .unwrap_or(Ordering::Equal)
5561            });
5562
5563        if let Some(item) = last_item_on_line {
5564            if let ShapedItem::Cluster(c) = &item.item {
5565                let result = TextCursor {
5566                    cluster_id: c.source_cluster_id,
5567                    affinity: CursorAffinity::Trailing,
5568                };
5569                if let Some(d) = debug {
5570                    d.push(format!(
5571                        "[Cursor] move_cursor_to_line_end: result byte {}, affinity {:?}",
5572                        result.cluster_id.start_byte_in_run, result.affinity
5573                    ));
5574                }
5575                return result;
5576            }
5577        }
5578
5579        if let Some(d) = debug {
5580            d.push(format!(
5581                "[Cursor] move_cursor_to_line_end: no last item found, staying at byte {}",
5582                cursor.cluster_id.start_byte_in_run
5583            ));
5584        }
5585        cursor
5586    }
5587
5588    /// Moves a cursor one word to the left (Ctrl+Left / Option+Left).
5589    ///
5590    /// Word boundaries use the shared [`is_word_char`] predicate (alphanumeric or
5591    /// underscore are word characters; whitespace AND punctuation are boundaries),
5592    /// so this agrees with double-click word selection. The cursor moves past any
5593    /// boundary clusters to the left, then past word clusters until the next
5594    /// boundary or start of text.
5595    pub fn move_cursor_to_prev_word(
5596        &self,
5597        cursor: TextCursor,
5598        debug: &mut Option<Vec<String>>,
5599    ) -> TextCursor {
5600        if let Some(d) = debug {
5601            d.push(format!(
5602                "[Cursor] move_cursor_to_prev_word: starting at byte {}, affinity {:?}",
5603                cursor.cluster_id.start_byte_in_run, cursor.affinity
5604            ));
5605        }
5606
5607        let Some(current_pos) = self.items.iter().position(|i| {
5608            i.item
5609                .as_cluster()
5610                .is_some_and(|c| c.source_cluster_id == cursor.cluster_id)
5611        }) else {
5612            return cursor;
5613        };
5614
5615        // Phase 1: Skip whitespace going left
5616        let mut pos = if cursor.affinity == CursorAffinity::Leading {
5617            // Already at leading edge, start from previous item
5618            current_pos.checked_sub(1)
5619        } else {
5620            // At trailing edge, start from current item
5621            Some(current_pos)
5622        };
5623
5624        // Skip boundary clusters (whitespace + punctuation)
5625        while let Some(p) = pos {
5626            if let Some(cluster) = self.items[p].item.as_cluster() {
5627                if !cluster_is_word_boundary(cluster) {
5628                    break;
5629                }
5630            }
5631            pos = p.checked_sub(1);
5632        }
5633
5634        // Phase 2: Skip word clusters going left (the word itself)
5635        while let Some(p) = pos {
5636            if let Some(cluster) = self.items[p].item.as_cluster() {
5637                if cluster_is_word_boundary(cluster) {
5638                    // We've reached a boundary before the word — stop at next cluster
5639                    if p + 1 < self.items.len() {
5640                        if let Some(c) = self.items[p + 1].item.as_cluster() {
5641                            return TextCursor {
5642                                cluster_id: c.source_cluster_id,
5643                                affinity: CursorAffinity::Leading,
5644                            };
5645                        }
5646                    }
5647                    break;
5648                }
5649            }
5650            if p == 0 {
5651                // Reached start of text — return first cluster
5652                if let Some(c) = self.items[0].item.as_cluster() {
5653                    return TextCursor {
5654                        cluster_id: c.source_cluster_id,
5655                        affinity: CursorAffinity::Leading,
5656                    };
5657                }
5658                break;
5659            }
5660            pos = p.checked_sub(1);
5661        }
5662
5663        // If we exhausted the search, go to first cluster
5664        if pos.is_none() {
5665            if let Some(first) = self.get_first_cluster_cursor() {
5666                return first;
5667            }
5668        }
5669
5670        cursor
5671    }
5672
5673    /// Moves a cursor one word to the right (Ctrl+Right / Option+Right).
5674    ///
5675    /// Word boundaries use the shared [`is_word_char`] predicate (alphanumeric or
5676    /// underscore are word characters; whitespace AND punctuation are boundaries),
5677    /// so this agrees with double-click word selection. The cursor moves past any
5678    /// word clusters, then past boundary clusters until the next word or end of text.
5679    pub fn move_cursor_to_next_word(
5680        &self,
5681        cursor: TextCursor,
5682        debug: &mut Option<Vec<String>>,
5683    ) -> TextCursor {
5684        if let Some(d) = debug {
5685            d.push(format!(
5686                "[Cursor] move_cursor_to_next_word: starting at byte {}, affinity {:?}",
5687                cursor.cluster_id.start_byte_in_run, cursor.affinity
5688            ));
5689        }
5690
5691        let Some(current_pos) = self.items.iter().position(|i| {
5692            i.item
5693                .as_cluster()
5694                .is_some_and(|c| c.source_cluster_id == cursor.cluster_id)
5695        }) else {
5696            return cursor;
5697        };
5698
5699        let len = self.items.len();
5700
5701        // Start position: if at leading edge, start from current; if trailing, start from next
5702        let start = if cursor.affinity == CursorAffinity::Trailing {
5703            current_pos + 1
5704        } else {
5705            current_pos
5706        };
5707
5708        if start >= len {
5709            return cursor;
5710        }
5711
5712        let mut pos = start;
5713
5714        // Phase 1: Skip word clusters (current word)
5715        while pos < len {
5716            if let Some(cluster) = self.items[pos].item.as_cluster() {
5717                if cluster_is_word_boundary(cluster) {
5718                    break;
5719                }
5720            }
5721            pos += 1;
5722        }
5723
5724        // Phase 2: Skip boundary clusters (whitespace + punctuation) after word
5725        while pos < len {
5726            if let Some(cluster) = self.items[pos].item.as_cluster() {
5727                if !cluster_is_word_boundary(cluster) {
5728                    // Found start of next word
5729                    return TextCursor {
5730                        cluster_id: cluster.source_cluster_id,
5731                        affinity: CursorAffinity::Leading,
5732                    };
5733                }
5734            }
5735            pos += 1;
5736        }
5737
5738        // Reached end of text
5739        if let Some(last) = self.get_last_cluster_cursor() {
5740            return last;
5741        }
5742
5743        cursor
5744    }
5745}
5746
5747#[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
5748fn get_baseline_for_item(item: &ShapedItem) -> Option<f32> {
5749    match item {
5750        ShapedItem::CombinedBlock {
5751            baseline_offset, ..
5752        } => Some(*baseline_offset),
5753        ShapedItem::Object {
5754            baseline_offset, ..
5755        } => Some(*baseline_offset),
5756        // We have to get the clusters font from the last glyph
5757        ShapedItem::Cluster(ref cluster) => {
5758            cluster.glyphs.last().map(|last_glyph| last_glyph
5759                        .font_metrics
5760                        .baseline_scaled(last_glyph.style.font_size_px))
5761        }
5762        ShapedItem::Break { source, break_info } => {
5763            // Breaks do not contribute to baseline
5764            None
5765        }
5766        ShapedItem::Tab { source, bounds } => {
5767            // Tabs do not contribute to baseline
5768            None
5769        }
5770    }
5771}
5772
5773/// Stores information about content that exceeded the available layout space.
5774#[derive(Debug, Clone, Default)]
5775pub struct OverflowInfo {
5776    /// The items that did not fit within the constraints.
5777    ///
5778    /// Currently always empty: the positioners place every item (visual overflow
5779    /// is clipped at paint time) rather than dropping content, so nothing is ever
5780    /// recorded here. The `window.rs` incremental-patch guard reads
5781    /// `overflow_items.is_empty()` to stay future-proof against a positioning path
5782    /// that *does* drop items. TODO(superplan): populate this if such a path lands.
5783    pub overflow_items: Vec<ShapedItem>,
5784    /// The total bounds of all positioned content, including any that overflows
5785    /// the constraints. Populated by both positioners (greedy + Knuth-Plass) from
5786    /// [`UnifiedLayout::bounds`]; useful for `OverflowBehavior::Visible`/`Scroll`.
5787    pub unclipped_bounds: Rect,
5788}
5789
5790impl OverflowInfo {
5791    #[must_use] pub const fn has_overflow(&self) -> bool {
5792        !self.overflow_items.is_empty()
5793    }
5794}
5795
5796/// Intermediate structure carrying information from the line breaker to the positioner.
5797#[derive(Debug, Clone)]
5798pub struct UnifiedLine {
5799    pub items: Vec<ShapedItem>,
5800    /// The y-position (for horizontal) or x-position (for vertical) of the line's baseline.
5801    pub cross_axis_position: f32,
5802    /// The geometric segments this line must fit into.
5803    pub constraints: LineConstraints,
5804    pub is_last: bool,
5805}
5806
5807// --- Caching Infrastructure ---
5808
5809pub type CacheId = u64;
5810
5811/// Defines a single area for layout, with its own shape and properties.
5812#[derive(Debug, Clone)]
5813pub struct LayoutFragment {
5814    /// A unique identifier for this fragment (e.g., "main-content", "sidebar").
5815    pub id: String,
5816    /// The geometric and style constraints for this specific fragment.
5817    pub constraints: UnifiedConstraints,
5818}
5819
5820/// Represents the final layout distributed across multiple fragments.
5821#[derive(Debug, Clone)]
5822pub(crate) struct FlowLayout {
5823    /// A map from a fragment's unique ID to the layout it contains.
5824    pub(crate) fragment_layouts: HashMap<String, Arc<UnifiedLayout>>,
5825    /// Any items that did not fit into the last fragment in the flow chain.
5826    /// This is useful for pagination or determining if more layout space is needed.
5827    pub(crate) remaining_items: Vec<ShapedItem>,
5828}
5829
5830/// Inline-axis intrinsic contributions derived from shaped text, without running
5831/// the line-breaking stage of the pipeline.
5832///
5833/// Callers that only need min/max-content widths for sizing (see
5834/// `calculate_ifc_root_intrinsic_sizes`) should prefer this over invoking
5835/// `layout_flow` twice with `AvailableSpace::MinContent`/`MaxContent`. The
5836/// latter runs the full flow loop — including `BreakCursor::peek_next_unit`,
5837/// which clones every `ShapedCluster` it inspects — even though no constraint
5838/// actually limits the line width.
5839#[derive(Copy, Debug, Clone, Default)]
5840pub struct IntrinsicTextSizes {
5841    /// CSS min-content = widest unbreakable unit (word) along the inline axis.
5842    pub min_content_width: f32,
5843    /// CSS max-content = sum of all advances along the inline axis (single line).
5844    pub max_content_width: f32,
5845    /// Height of a single line box: max(ascent + descent) across all items.
5846    pub max_content_height: f32,
5847}
5848
5849/// Cached line break boundaries from a previous layout pass.
5850///
5851/// Enables incremental relayout: when a word changes width,
5852/// we can check if it still fits on the same line without
5853/// re-running the full line-breaking algorithm.
5854#[derive(Clone, Debug)]
5855pub struct CachedLineBreaks {
5856    /// Per-line: (`first_item_idx`, `last_item_idx_exclusive`) into positioned items.
5857    pub line_ranges: Vec<(usize, usize)>,
5858    /// Per-line total width (sum of item advances on that line).
5859    pub line_widths: Vec<f32>,
5860    /// The available width constraint used when these breaks were computed.
5861    pub available_width: f32,
5862}
5863
5864/// Result of an incremental relayout attempt.
5865#[derive(Copy, Clone, Debug)]
5866pub enum IncrementalRelayoutResult {
5867    /// Glyphs changed but advance widths identical — swap in place, no repositioning.
5868    GlyphSwap,
5869    /// Width changed but still fits on same line — shift `x_offsets` of subsequent items.
5870    LineShift {
5871        /// Index of the first affected item.
5872        affected_item: usize,
5873        /// Width delta (`new_advance` - `old_advance`).
5874        delta: f32,
5875    },
5876    /// Line breaks changed — need to reflow from this line onward.
5877    PartialReflow {
5878        /// The line index from which to start reflowing.
5879        reflow_from_line: usize,
5880    },
5881    /// Cannot do incremental — fall back to full relayout.
5882    FullRelayout,
5883}
5884
5885/// Extract line break boundaries from a positioned items list.
5886#[must_use] pub fn extract_line_breaks(
5887    items: &[PositionedItem],
5888    available_width: f32,
5889) -> CachedLineBreaks {
5890    let mut line_ranges = Vec::new();
5891    let mut line_widths = Vec::new();
5892
5893    if items.is_empty() {
5894        return CachedLineBreaks { line_ranges, line_widths, available_width };
5895    }
5896
5897    let mut line_start = 0usize;
5898    let mut current_line = items[0].line_index;
5899    let mut line_width = 0.0f32;
5900
5901    for (i, item) in items.iter().enumerate() {
5902        if item.line_index != current_line {
5903            line_ranges.push((line_start, i));
5904            line_widths.push(line_width);
5905            line_start = i;
5906            current_line = item.line_index;
5907            line_width = 0.0;
5908        }
5909        line_width += get_item_measure(&item.item, false);
5910    }
5911
5912    // Final line
5913    line_ranges.push((line_start, items.len()));
5914    line_widths.push(line_width);
5915
5916    CachedLineBreaks { line_ranges, line_widths, available_width }
5917}
5918
5919/// Attempt incremental relayout given old metrics and new per-item advance widths.
5920///
5921/// `dirty_item_indices`: which items in the shaped list changed.
5922/// `old_advances`: per-item advance widths from the previous layout.
5923/// `new_advances`: per-item advance widths after reshaping.
5924/// `line_breaks`: cached line boundaries from previous layout.
5925#[must_use] pub fn try_incremental_relayout(
5926    dirty_item_indices: &[usize],
5927    old_advances: &[f32],
5928    new_advances: &[f32],
5929    line_breaks: &CachedLineBreaks,
5930) -> IncrementalRelayoutResult {
5931    if dirty_item_indices.is_empty() {
5932        return IncrementalRelayoutResult::GlyphSwap;
5933    }
5934
5935    // Check each dirty item
5936    for &dirty_idx in dirty_item_indices {
5937        if dirty_idx >= old_advances.len() || dirty_idx >= new_advances.len() {
5938            return IncrementalRelayoutResult::FullRelayout;
5939        }
5940
5941        let old_adv = old_advances[dirty_idx];
5942        let new_adv = new_advances[dirty_idx];
5943        let delta = new_adv - old_adv;
5944
5945        if delta.abs() < 0.001 {
5946            // Same width — just swap glyphs (GlyphSwap for this item)
5947            continue;
5948        }
5949
5950        // Width changed — find which line this item is on
5951        let line_idx = line_breaks.line_ranges.iter()
5952            .position(|&(start, end)| dirty_idx >= start && dirty_idx < end);
5953
5954        let Some(line_idx) = line_idx else {
5955            return IncrementalRelayoutResult::FullRelayout;
5956        };
5957
5958        let old_line_width = line_breaks.line_widths[line_idx];
5959        let new_line_width = old_line_width + delta;
5960
5961        if new_line_width <= line_breaks.available_width {
5962            // Still fits on same line — shift subsequent items
5963            return IncrementalRelayoutResult::LineShift {
5964                affected_item: dirty_idx,
5965                delta,
5966            };
5967        }
5968        // Overflows line — need to reflow from this line
5969        return IncrementalRelayoutResult::PartialReflow {
5970            reflow_from_line: line_idx,
5971        };
5972    }
5973
5974    // All dirty items had same width
5975    IncrementalRelayoutResult::GlyphSwap
5976}
5977
5978/// Cached shaped result for a single visual item (or coalesced group).
5979/// Enables per-item cache hits when only one word changes in a paragraph.
5980#[derive(Debug)]
5981pub(crate) struct PerItemShapedEntry {
5982    /// The shaped clusters for this single item/group.
5983    pub(crate) clusters: Vec<ShapedItem>,
5984    /// Sum of advance widths — for fast same-width detection during incremental relayout.
5985    pub(crate) total_advance: f32,
5986}
5987
5988#[derive(Debug)]
5989pub struct TextShapingCache {
5990    // Stage 1 Cache: InlineContent -> LogicalItems
5991    logical_items: HashMap<CacheId, Arc<Vec<LogicalItem>>>,
5992    // Stage 2 Cache: LogicalItems -> VisualItems
5993    visual_items: HashMap<CacheId, Arc<Vec<VisualItem>>>,
5994    // Stage 3 Cache: VisualItems -> ShapedItems (monolithic, for backward compat)
5995    shaped_items: HashMap<CacheId, Arc<Vec<ShapedItem>>>,
5996    // Stage 3b Cache: Per-item/coalesce-group shaped results
5997    // Key: hash(text, bidi_level, script, style.layout_hash())
5998    per_item_shaped: HashMap<u64, Arc<PerItemShapedEntry>>,
5999    /// Tracks which `per_item_shaped` keys were accessed in the current generation.
6000    per_item_accessed: HashSet<u64>,
6001    /// Current generation counter, incremented each layout pass.
6002    generation: u64,
6003}
6004
6005/// Approximate heap bytes retained by a [`TextShapingCache`].
6006#[derive(Copy, Debug, Clone, Default)]
6007pub struct TextCacheMemoryReport {
6008    pub logical_items_entries: usize,
6009    pub logical_items_bytes: usize,
6010    pub visual_items_entries: usize,
6011    pub visual_items_bytes: usize,
6012    pub shaped_items_entries: usize,
6013    pub shaped_items_bytes: usize,
6014    pub shaped_glyph_bytes: usize,
6015    pub shaped_cluster_text_bytes: usize,
6016    pub per_item_shaped_entries: usize,
6017    pub per_item_shaped_bytes: usize,
6018}
6019
6020impl TextCacheMemoryReport {
6021    #[must_use] pub const fn total_bytes(&self) -> usize {
6022        self.logical_items_bytes
6023            + self.visual_items_bytes
6024            + self.shaped_items_bytes
6025            + self.shaped_glyph_bytes
6026            + self.shaped_cluster_text_bytes
6027            + self.per_item_shaped_bytes
6028    }
6029}
6030
6031impl TextShapingCache {
6032    #[must_use] pub fn new() -> Self {
6033        Self {
6034            logical_items: HashMap::new(),
6035            visual_items: HashMap::new(),
6036            shaped_items: HashMap::new(),
6037            per_item_shaped: HashMap::new(),
6038            per_item_accessed: HashSet::new(),
6039            generation: 0,
6040        }
6041    }
6042
6043    /// Approximate per-stage heap-byte breakdown.
6044    #[allow(clippy::field_reassign_with_default)] // struct built incrementally / test setup; a struct literal is not clearer here
6045    #[must_use] pub fn memory_report(&self) -> TextCacheMemoryReport {
6046        let mut r = TextCacheMemoryReport::default();
6047        r.logical_items_entries = self.logical_items.len();
6048        for arc in self.logical_items.values() {
6049            r.logical_items_bytes += arc.capacity() * size_of::<LogicalItem>();
6050        }
6051        r.visual_items_entries = self.visual_items.len();
6052        for arc in self.visual_items.values() {
6053            r.visual_items_bytes += arc.capacity() * size_of::<VisualItem>();
6054        }
6055        r.shaped_items_entries = self.shaped_items.len();
6056        for arc in self.shaped_items.values() {
6057            r.shaped_items_bytes += arc.capacity() * size_of::<ShapedItem>();
6058            for item in arc.iter() {
6059                if let ShapedItem::Cluster(c) = item {
6060                    r.shaped_glyph_bytes += c.glyphs.capacity() * size_of::<ShapedGlyph>();
6061                    r.shaped_cluster_text_bytes += c.text.capacity();
6062                }
6063            }
6064        }
6065        r.per_item_shaped_entries = self.per_item_shaped.len();
6066        for arc in self.per_item_shaped.values() {
6067            r.per_item_shaped_bytes += arc.clusters.capacity() * size_of::<ShapedItem>();
6068            for item in &arc.clusters {
6069                if let ShapedItem::Cluster(c) = item {
6070                    r.per_item_shaped_bytes += c.glyphs.capacity() * size_of::<ShapedGlyph>();
6071                    r.per_item_shaped_bytes += c.text.capacity();
6072                }
6073            }
6074        }
6075        r
6076    }
6077
6078    /// Call at the start of each layout pass. Evicts per-item shaped entries
6079    /// not accessed in the previous generation to prevent unbounded growth.
6080    pub fn begin_generation(&mut self) {
6081        if self.generation > 0 && !self.per_item_accessed.is_empty() {
6082            // Evict entries not accessed in this generation
6083            let accessed = &self.per_item_accessed;
6084            self.per_item_shaped.retain(|k, _| accessed.contains(k));
6085        }
6086        self.per_item_accessed.clear();
6087        self.generation += 1;
6088    }
6089
6090    /// Check if we can reuse an old layout based on layout-affecting parameters.
6091    /// 
6092    /// This function compares only the parameters that affect glyph positions,
6093    /// not rendering-only parameters like color or text-decoration.
6094    /// 
6095    /// # Parameters
6096    /// - `old_constraints`: The constraints used for the cached layout
6097    /// - `new_constraints`: The constraints for the new layout request
6098    /// - `old_content`: The content used for the cached layout
6099    /// - `new_content`: The new content to layout
6100    /// 
6101    /// # Returns
6102    /// - `true` if the old layout can be reused (only rendering changed)
6103    /// - `false` if a new layout is needed (layout-affecting params changed)
6104    #[must_use] pub fn use_old_layout(
6105        old_constraints: &UnifiedConstraints,
6106        new_constraints: &UnifiedConstraints,
6107        old_content: &[InlineContent],
6108        new_content: &[InlineContent],
6109    ) -> bool {
6110        // First check: constraints must match exactly for layout purposes
6111        if old_constraints != new_constraints {
6112            return false;
6113        }
6114        
6115        // Second check: content length must match
6116        if old_content.len() != new_content.len() {
6117            return false;
6118        }
6119        
6120        // Third check: each content item must have same layout properties
6121        for (old, new) in old_content.iter().zip(new_content.iter()) {
6122            if !Self::inline_content_layout_eq(old, new) {
6123                return false;
6124            }
6125        }
6126        
6127        true
6128    }
6129    
6130    /// Compare two `InlineContent` items for layout equality.
6131    /// 
6132    /// Returns true if the layouts would be identical (only rendering differs).
6133    fn inline_content_layout_eq(old: &InlineContent, new: &InlineContent) -> bool {
6134        use InlineContent::{Text, Image, Space, LineBreak, Tab, Marker, Shape, Ruby};
6135        match (old, new) {
6136            (Text(old_run), Text(new_run)) => {
6137                // Text must match exactly, but style only needs layout_eq
6138                old_run.text == new_run.text 
6139                    && old_run.style.layout_eq(&new_run.style)
6140            }
6141            (Image(old_img), Image(new_img)) => {
6142                // Images: size affects layout, but not visual properties
6143                old_img.intrinsic_size == new_img.intrinsic_size
6144                    && old_img.display_size == new_img.display_size
6145                    && old_img.baseline_offset == new_img.baseline_offset
6146                    && old_img.alignment == new_img.alignment
6147            }
6148            (Space(old_sp), Space(new_sp)) => old_sp == new_sp,
6149            (LineBreak(old_br), LineBreak(new_br)) => old_br == new_br,
6150            (Tab { style: old_style }, Tab { style: new_style }) => old_style.layout_eq(new_style),
6151            (Marker { run: old_run, position_outside: old_pos },
6152             Marker { run: new_run, position_outside: new_pos }) => {
6153                old_pos == new_pos
6154                    && old_run.text == new_run.text
6155                    && old_run.style.layout_eq(&new_run.style)
6156            }
6157            (Shape(old_shape), Shape(new_shape)) => {
6158                // Shapes: shape_def affects layout, not fill/stroke
6159                old_shape.shape_def == new_shape.shape_def
6160                    && old_shape.baseline_offset == new_shape.baseline_offset
6161            }
6162            (Ruby { base: old_base, text: old_text, style: old_style },
6163             Ruby { base: new_base, text: new_text, style: new_style }) => {
6164                old_style.layout_eq(new_style)
6165                    && old_base.len() == new_base.len()
6166                    && old_text.len() == new_text.len()
6167                    && old_base.iter().zip(new_base.iter())
6168                        .all(|(o, n)| Self::inline_content_layout_eq(o, n))
6169                    && old_text.iter().zip(new_text.iter())
6170                        .all(|(o, n)| Self::inline_content_layout_eq(o, n))
6171            }
6172            // Different variants cannot have same layout
6173            _ => false,
6174        }
6175    }
6176}
6177
6178impl Default for TextShapingCache {
6179    fn default() -> Self {
6180        Self::new()
6181    }
6182}
6183
6184/// Key for caching the conversion from `InlineContent` to `LogicalItem`s.
6185#[derive(Debug, Clone, Eq, PartialEq, Hash)]
6186pub(crate) struct LogicalItemsKey<'a> {
6187    pub(crate) inline_content_hash: u64,
6188    pub(crate) default_font_size: u32,
6189    pub(crate) _marker: std::marker::PhantomData<&'a ()>,
6190}
6191
6192/// Key for caching the Bidi reordering stage.
6193#[derive(Debug, Clone, Eq, PartialEq, Hash)]
6194pub(crate) struct VisualItemsKey {
6195    pub(crate) logical_items_id: CacheId,
6196    pub(crate) base_direction: BidiDirection,
6197}
6198
6199/// Key for caching the shaping stage.
6200#[derive(Debug, Clone, Eq, PartialEq, Hash)]
6201pub(crate) struct ShapedItemsKey {
6202    pub(crate) visual_items_id: CacheId,
6203    pub(crate) style_hash: u64,
6204}
6205
6206impl ShapedItemsKey {
6207    pub(crate) fn new(visual_items_id: CacheId, visual_items: &[VisualItem]) -> Self {
6208        let style_hash = {
6209            let mut hasher = DefaultHasher::new();
6210            for item in visual_items {
6211                // Hash the style from the logical source, as this is what determines the font.
6212                match &item.logical_source {
6213                    LogicalItem::Text { style, .. } | LogicalItem::CombinedText { style, .. } => {
6214                        style.as_ref().hash(&mut hasher);
6215                    }
6216                    _ => {}
6217                }
6218            }
6219            hasher.finish()
6220        };
6221
6222        Self {
6223            visual_items_id,
6224            style_hash,
6225        }
6226    }
6227}
6228
6229/// Key for the final layout stage.
6230#[derive(Debug, Clone, Eq, PartialEq, Hash)]
6231pub(crate) struct LayoutKey {
6232    pub(crate) shaped_items_id: CacheId,
6233    pub(crate) constraints: UnifiedConstraints,
6234}
6235
6236/// Helper to create a `CacheId` from any `Hash`able type.
6237fn calculate_id<T: Hash>(item: &T) -> CacheId {
6238    let mut hasher = DefaultHasher::new();
6239    item.hash(&mut hasher);
6240    hasher.finish()
6241}
6242
6243// --- Main Layout Pipeline Implementation ---
6244
6245impl TextShapingCache {
6246    /// New top-level entry point for flowing layout across multiple regions.
6247    ///
6248    /// This function orchestrates the entire layout pipeline, but instead of fitting
6249    /// content into a single set of constraints, it flows the content through an
6250    /// ordered sequence of `LayoutFragment`s.
6251    ///
6252    /// # CSS Inline Layout Module Level 3: Pipeline Implementation
6253    ///
6254    /// This implements the inline formatting context with 5 stages:
6255    ///
6256    /// ## Stage 1: Logical Analysis (`InlineContent` -> `LogicalItem`)
6257    /// \u2705 IMPLEMENTED: Parses raw content into logical units
6258    /// - Handles text runs, inline-blocks, replaced elements
6259    /// - Applies style overrides at character level
6260    /// - Implements \u00a7 2.2: Content size contribution calculation
6261    ///
6262    /// ## Stage 2: `BiDi` Reordering (`LogicalItem` -> `VisualItem`)
6263    /// \u2705 IMPLEMENTED: Uses CSS 'direction' property per CSS Writing Modes
6264    /// - Reorders items for right-to-left text (Arabic, Hebrew)
6265    /// - Respects containing block direction (not auto-detection)
6266    /// - Conforms to Unicode `BiDi` Algorithm (UAX #9)
6267    ///
6268    /// ## Stage 3: Shaping (`VisualItem` -> `ShapedItem`)
6269    /// \u2705 IMPLEMENTED: Converts text to glyphs
6270    /// - Uses `HarfBuzz` for OpenType shaping
6271    /// - Handles ligatures, kerning, contextual forms
6272    /// - Caches shaped results for performance
6273    ///
6274    /// ## Stage 4: Text Orientation Transformations
6275    /// \u26a0\ufe0f PARTIAL: Applies text-orientation for vertical text
6276    /// - Uses constraints from *first* fragment only
6277    /// - \u274c TODO: Should re-orient if fragments have different writing modes
6278    ///
6279    /// ## Stage 5: Flow Loop (`ShapedItem` -> `PositionedItem`)
6280    /// \u2705 IMPLEMENTED: Breaks lines and positions content
6281    /// - Calls `perform_fragment_layout` for each fragment
6282    /// - Uses `BreakCursor` to flow content across fragments
6283    /// - Implements \u00a7 5: Line breaking and hyphenation
6284    ///
6285    /// # Missing Features from CSS Inline-3:
6286    /// - \u00a7 3.3: initial-letter (drop caps)
6287    /// - \u00a7 4: vertical-align (only baseline supported)
6288    /// - \u00a7 6: text-box-trim (leading trim)
6289    /// - \u00a7 7: inline-sizing (aspect-ratio for inline-blocks)
6290    ///
6291    /// # Arguments
6292    /// * `content` - The raw `InlineContent` to be laid out.
6293    /// * `style_overrides` - Character-level style changes.
6294    /// * `flow_chain` - An ordered slice of `LayoutFragment` defining the regions (e.g., columns,
6295    ///   pages) that the content should flow through.
6296    /// * `font_chain_cache` - Pre-resolved font chains (from `FontManager.font_chain_cache`)
6297    /// * `fc_cache` - The fontconfig cache for font lookups
6298    /// * `loaded_fonts` - Pre-loaded fonts, keyed by `FontId`
6299    ///
6300    /// # Returns
6301    /// A `FlowLayout` struct containing the positioned items for each fragment that
6302    /// was filled, and any content that did not fit in the final fragment.
6303    #[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
6304    /// # Panics
6305    ///
6306    /// Panics if bidi reordering of the logical items fails (an internal invariant).
6307    /// # Errors
6308    ///
6309    /// Returns a `LayoutError` if text flow layout fails.
6310    pub fn layout_flow<T: ParsedFontTrait>(
6311        &mut self,
6312        content: &[InlineContent],
6313        style_overrides: &[StyleOverride],
6314        flow_chain: &[LayoutFragment],
6315        font_chain_cache: &HashMap<FontChainKey, rust_fontconfig::FontFallbackChain>,
6316        fc_cache: &FcFontCache,
6317        loaded_fonts: &LoadedFonts<T>,
6318        debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
6319    ) -> Result<FlowLayout, LayoutError> {
6320        // [g150 az-web-lift DIAG] content data ptr (0x60BD0) + len (0x60BD4) at layout_flow ENTRY.
6321        #[cfg(feature = "web_lift")]
6322        unsafe {
6323            crate::az_mark((0x60BD0) as u32, (content.as_ptr() as usize as u32) as u32);
6324            crate::az_mark((0x60BD4) as u32, (content.len() as u32 | 0xC0DE0000) as u32);
6325        }
6326        // [g218 2026-06-09] The g158 `content.len()` force-materialize (a volatile read of content+16) is
6327        // DELETED: the within-fn SROA-to-0 of content.len() it worked around is now fixed (NEON-decoder +
6328        // volatile-guest-load transpiler work). VERIFIED: hello-world lays out without it — counter "5"
6329        // (label_wrapper 8,16,784,40) + button shape correctly, same rects as before. (The cross-FN Vec-*return*-
6330        // len mis-lift is a separate, still-present issue handled by the g127/g129/g130 out-param hacks — see
6331        // g134 marker: callee content.len=1 but the caller's return-read sees 0.)
6332        // --- Stages 1-3: Preparation ---
6333        // These stages are independent of the final geometry. We perform them once
6334        // on the entire content block before flowing. Caching is used at each stage.
6335
6336        // Cap per-item shaped cache to prevent unbounded growth.
6337        // When threshold is exceeded, evict entries not accessed this generation.
6338        const PER_ITEM_CACHE_MAX: usize = 4096;
6339        if self.per_item_shaped.len() > PER_ITEM_CACHE_MAX {
6340            self.begin_generation();
6341        }
6342
6343        // Stage 1: Logical Analysis (InlineContent -> LogicalItem)
6344        // [g213 2026-06-09] The web lift uses the real `self.logical_items` HashMap cache (NO bypass).
6345        // This entry() find-probe USED to spin forever on the lift (g178-g210 mis-diagnosed it many ways).
6346        // TRUE root cause: hashbrown's portable WIDTH=8 `Group::static_empty()` — `[0xFF; 8]` in libazul's
6347        // `__TEXT.__const` — was not mirrored into the wasm, so the empty-map ctrl-scan read 0x00, looked
6348        // ALL-FULL (EMPTY=0xFF), and the probe never terminated. FIXED entirely transpiler-side in
6349        // `dll/src/web/symbol_table.rs::compute_hashbrown_empty_group_ranges` (signature-scans `__const`
6350        // for >=8-byte 8-aligned 0xFF runs and mirrors them). Verified: web-nested-text lays out
6351        // ("Hello" at 8,16,800,20), __remill_error=0. No azul-source workaround needed here.
6352        let logical_items_id = calculate_id(&content);
6353        let logical_items = self
6354            .logical_items
6355            .entry(logical_items_id)
6356            .or_insert_with(|| {
6357                Arc::new(create_logical_items(content, style_overrides, debug_messages))
6358            })
6359            .clone();
6360
6361        // Get the first fragment's constraints to extract the CSS direction property.
6362        // This is used for BiDi reordering in Stage 2.
6363        let default_constraints = UnifiedConstraints::default();
6364        let first_constraints = flow_chain
6365            .first()
6366            .map_or(&default_constraints, |f| &f.constraints);
6367
6368        // +spec:containing-block:e7a271 - paragraph embedding level set from containing block's 'direction' property
6369        // +spec:display-property:7665cb - inline boxes split into multiple visual runs due to bidi text processing
6370        // +spec:display-property:929d6b - applies Unicode bidi algorithm to inline-level box sequences
6371        // +spec:display-property:e8584a - Apply Unicode bidi algorithm to inline-level box sequences per CSS Writing Modes §2.4
6372        // Stage 2: Bidi Reordering (LogicalItem -> VisualItem)
6373        // +spec:containing-block:961e3c - bidi paragraph level from containing block direction, not UAX9 heuristic
6374        // +spec:writing-modes:0a5368 - unicode-bidi: plaintext auto-detects direction from text content
6375        // Per CSS Writing Modes §8.3: when unicode-bidi is plaintext, the paragraph's
6376        // base direction is determined from text content (first strong character), ignoring
6377        // the containing block's direction property. Empty paragraphs fall back to
6378        // the containing block's direction.
6379        let unicode_bidi_val = first_constraints.unicode_bidi;
6380        let base_direction = if unicode_bidi_val == UnicodeBidi::Plaintext {
6381            // Auto-detect from text content; fall back to containing block direction
6382            let has_strong = logical_items.iter().any(|item| {
6383                if let LogicalItem::Text { text, .. } = item {
6384                    matches!(unicode_bidi::get_base_direction(text.as_str()),
6385                        unicode_bidi::Direction::Ltr | unicode_bidi::Direction::Rtl)
6386                } else {
6387                    false
6388                }
6389            });
6390            if has_strong {
6391                get_base_direction_from_logical(&logical_items)
6392            } else {
6393                // Empty paragraph: use containing block's direction
6394                first_constraints.direction.unwrap_or(BidiDirection::Ltr)
6395            }
6396        } else {
6397            // Normal case: use CSS direction property
6398            first_constraints.direction.unwrap_or(BidiDirection::Ltr)
6399        };
6400        let visual_key = VisualItemsKey {
6401            logical_items_id,
6402            base_direction,
6403        };
6404        let visual_items_id = calculate_id(&visual_key);
6405        // [g213] web lift uses the real visual_items HashMap cache (g180 bypass deleted; WIDTH=8
6406        // EMPTY_GROUP now mirrored — see Stage-1 note + symbol_table.rs).
6407        let visual_items = self
6408            .visual_items
6409            .entry(visual_items_id)
6410            .or_insert_with(|| {
6411                Arc::new(
6412                    reorder_logical_items(&logical_items, base_direction, unicode_bidi_val, debug_messages).unwrap(),
6413                )
6414            })
6415            .clone();
6416
6417        // Stage 3: Shaping (VisualItem -> ShapedItem)
6418        // Two-level cache: monolithic (fast path) + per-item (incremental path).
6419        let shaped_key = ShapedItemsKey::new(visual_items_id, &visual_items);
6420        let shaped_items_id = calculate_id(&shaped_key);
6421        // [g213] web lift uses the real shaped_items HashMap cache (g180 bypass deleted).
6422        let shaped_items = if let Some(cached) = self.shaped_items.get(&shaped_items_id) {
6423            // Monolithic cache hit — all visual items unchanged
6424            cached.clone()
6425        } else {
6426            // Monolithic miss — use per-item cache for incremental reshaping.
6427            // Items not in per-item cache are shaped; cached items are reused.
6428            let items = Arc::new(shape_visual_items_with_per_item_cache(
6429                &visual_items,
6430                &mut self.per_item_shaped,
6431                &mut self.per_item_accessed,
6432                font_chain_cache,
6433                fc_cache,
6434                loaded_fonts,
6435                debug_messages,
6436            )?);
6437            self.shaped_items.insert(shaped_items_id, items.clone());
6438            items
6439        };
6440
6441        // --- Stage 4: Apply Vertical Text Transformations ---
6442
6443        // Note: first_constraints was already extracted above for BiDi reordering (Stage 2).
6444        // This orients all text based on the constraints of the *first* fragment.
6445        // A more advanced system could defer orientation until inside the loop if
6446        // fragments can have different writing modes.
6447        let oriented_items = apply_text_orientation(shaped_items, first_constraints);
6448
6449        // --- Stage 5: The Flow Loop ---
6450        let mut fragment_layouts = HashMap::new();
6451        // The cursor now manages the stream of items for the entire flow.
6452        // §5.2 word-break: pass word_break from constraints to cursor
6453        let mut cursor = BreakCursor::with_word_break(&oriented_items, first_constraints.word_break);
6454        cursor.hyphens = first_constraints.hyphenation;
6455        cursor.line_break = first_constraints.line_break;
6456
6457        // [g147 az-web-lift] Hard safety bound on the Stage-5 flow loop. On the remill lift this
6458        // `for fragment in flow_chain` (or the `cursor.is_done()` break) mis-lifts for the NESTED IFC
6459        // and iterates without terminating → solveLayoutReal HANGS (fuel trap in layout_flow). The text
6460        // is fully laid out on the first iteration(s); cap the iterations so the loop always converges.
6461        // (native is unaffected — the cap is far above any real fragment count.)
6462        #[allow(clippy::no_effect_underscore_binding)] // web_lift-gated debug iteration counter
6463        let mut _az_flow_iters: usize = 0;
6464        for fragment in flow_chain {
6465            #[cfg(feature = "web_lift")]
6466            {
6467                _az_flow_iters += 1;
6468                unsafe { crate::az_mark((0x60BC0) as u32, (_az_flow_iters as u32 | 0xC0DE0000) as u32); }
6469                if _az_flow_iters > 256 {
6470                    break;
6471                }
6472            }
6473            // Perform layout for this single fragment, consuming items from the cursor.
6474            let fragment_layout = perform_fragment_layout(
6475                &mut cursor,
6476                &logical_items,
6477                &fragment.constraints,
6478                debug_messages,
6479                loaded_fonts,
6480            )?;
6481
6482            fragment_layouts.insert(fragment.id.clone(), Arc::new(fragment_layout));
6483            if cursor.is_done() {
6484                break; // All content has been laid out.
6485            }
6486        }
6487
6488        Ok(FlowLayout {
6489            fragment_layouts,
6490            remaining_items: cursor.drain_remaining(),
6491        })
6492    }
6493
6494    /// Runs stages 1–4 of the layout pipeline (logical analysis, `BiDi`, shaping,
6495    /// text orientation) and derives min/max-content widths by scanning the
6496    /// resulting `ShapedItem`s directly — without running stage 5's line-breaking
6497    /// `BreakCursor` loop.
6498    ///
6499    /// Used by `calculate_ifc_root_intrinsic_sizes` to avoid the 24% CPU spent
6500    /// cloning `ShapedCluster`s inside `BreakCursor::peek_next_unit` on every
6501    /// sizing pass. Since stages 1–3 hit the same `per_item_shaped` cache as
6502    /// `layout_flow`, a subsequent `layout_flow` call for the same content at
6503    /// a real container width is a pure cache hit for the shaping work.
6504    ///
6505    /// The item walk uses the same break-opportunity predicate that the
6506    /// `BreakCursor` would — min-content accumulates advances between break
6507    /// opportunities and tracks the maximum; max-content is the sum of all
6508    /// advances (as if the flow were laid out on a single infinitely-wide line).
6509    #[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
6510    /// # Panics
6511    ///
6512    /// Panics if bidi reordering of the logical items fails (an internal invariant).
6513    /// # Errors
6514    ///
6515    /// Returns a `LayoutError` if measuring intrinsic widths fails.
6516    pub fn measure_intrinsic_widths<T: ParsedFontTrait>(
6517        &mut self,
6518        content: &[InlineContent],
6519        style_overrides: &[StyleOverride],
6520        constraints: &UnifiedConstraints,
6521        font_chain_cache: &HashMap<FontChainKey, rust_fontconfig::FontFallbackChain>,
6522        fc_cache: &FcFontCache,
6523        loaded_fonts: &LoadedFonts<T>,
6524        debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
6525    ) -> Result<IntrinsicTextSizes, LayoutError> {
6526        const PER_ITEM_CACHE_MAX: usize = 4096;
6527        if self.per_item_shaped.len() > PER_ITEM_CACHE_MAX {
6528            self.begin_generation();
6529        }
6530
6531        // Stage 1: Logical Analysis (cached, same as layout_flow — the historic web-lift
6532        // bypass here was rooted in the un-mirrored hashbrown EMPTY_GROUP, fixed transpiler-side
6533        // in symbol_table.rs::compute_hashbrown_empty_group_ranges).
6534        let logical_items_id = calculate_id(&content);
6535        let logical_items = self
6536            .logical_items
6537            .entry(logical_items_id)
6538            .or_insert_with(|| {
6539                Arc::new(create_logical_items(content, style_overrides, debug_messages))
6540            })
6541            .clone();
6542
6543        // Stage 2: BiDi (same derivation as layout_flow)
6544        let unicode_bidi_val = constraints.unicode_bidi;
6545        let base_direction = if unicode_bidi_val == UnicodeBidi::Plaintext {
6546            let has_strong = logical_items.iter().any(|item| {
6547                if let LogicalItem::Text { text, .. } = item {
6548                    matches!(unicode_bidi::get_base_direction(text.as_str()),
6549                        unicode_bidi::Direction::Ltr | unicode_bidi::Direction::Rtl)
6550                } else {
6551                    false
6552                }
6553            });
6554            if has_strong {
6555                get_base_direction_from_logical(&logical_items)
6556            } else {
6557                constraints.direction.unwrap_or(BidiDirection::Ltr)
6558            }
6559        } else {
6560            constraints.direction.unwrap_or(BidiDirection::Ltr)
6561        };
6562        let visual_key = VisualItemsKey {
6563            logical_items_id,
6564            base_direction,
6565        };
6566        let visual_items_id = calculate_id(&visual_key);
6567        let visual_items = self
6568            .visual_items
6569            .entry(visual_items_id)
6570            .or_insert_with(|| {
6571                Arc::new(
6572                    reorder_logical_items(&logical_items, base_direction, unicode_bidi_val, debug_messages).unwrap(),
6573                )
6574            })
6575            .clone();
6576
6577        // Stage 3: Shaping (two-level cache, same as layout_flow)
6578        let shaped_key = ShapedItemsKey::new(visual_items_id, &visual_items);
6579        let shaped_items_id = calculate_id(&shaped_key);
6580        let shaped_items = if let Some(cached) = self.shaped_items.get(&shaped_items_id) { cached.clone() } else {
6581            let items = Arc::new(shape_visual_items_with_per_item_cache(
6582                &visual_items,
6583                &mut self.per_item_shaped,
6584                &mut self.per_item_accessed,
6585                font_chain_cache,
6586                fc_cache,
6587                loaded_fonts,
6588                debug_messages,
6589            )?);
6590            self.shaped_items.insert(shaped_items_id, items.clone());
6591            items
6592        };
6593
6594        // Stage 4: Text orientation
6595        let oriented_items = apply_text_orientation(shaped_items, constraints);
6596
6597        // Stage 5 bypass: scan items for min/max contributions.
6598        let word_break = constraints.word_break;
6599        let hyphens = constraints.hyphenation;
6600
6601        let mut total = 0.0f32;      // running width of the current line
6602        let mut max_line = 0.0f32;   // widest line between forced breaks = max-content
6603        let mut max_word = 0.0f32;
6604        let mut cur_word = 0.0f32;
6605        let mut max_line_height = 0.0f32;
6606
6607        for item in oriented_items.iter() {
6608            // A forced break (preserved LF, <br>) ends the current line. max-content
6609            // is the widest line BETWEEN forced breaks, not the running sum across
6610            // them — otherwise a white-space:pre block with newlines (or any <br>
6611            // content) over-measures its max-content as the concatenation of all
6612            // lines. Reset the line accumulators here.
6613            if let ShapedItem::Break { .. } = item {
6614                if total > max_line { max_line = total; }
6615                if cur_word > max_word { max_word = cur_word; }
6616                total = 0.0;
6617                cur_word = 0.0;
6618                continue;
6619            }
6620            // Must match get_item_measure() exactly: a cluster's inline advance
6621            // INCLUDES per-glyph kerning. Omitting kerning here under-measures
6622            // max-content, so a shrink-to-fit box (e.g. a flex item sized to its
6623            // text's max-content) ends up narrower than the kerned text the line
6624            // breaker lays out — the word then "overflows" its own box and, with
6625            // overflow-wrap:normal, gets force-broken to its first cluster
6626            // (the menubar "View" → "V" clip). Summing (advance + kerning) here,
6627            // in the same order as the breaker, makes the box exactly fit.
6628            let advance = match item {
6629                ShapedItem::Cluster(c) => {
6630                    let total_kerning: f32 = c.glyphs.iter().map(|g| g.kerning).sum();
6631                    let mut a = c.advance + total_kerning;
6632                    // Match position_line_items exactly: letter-spacing is added after
6633                    // every non-cursive cluster and word-spacing on word separators.
6634                    // Omitting them here under-measures a shrink-to-fit box, so the
6635                    // laid-out (spaced) text overflows its own min/max-content width.
6636                    if !is_cursive_script_cluster(c) {
6637                        a += c.style.letter_spacing.resolve_px(c.style.font_size_px);
6638                    }
6639                    if is_word_separator(item) {
6640                        a += c.style.word_spacing.resolve_px(c.style.font_size_px);
6641                    }
6642                    a
6643                }
6644                ShapedItem::CombinedBlock { bounds, .. }
6645                | ShapedItem::Object { bounds, .. }
6646                | ShapedItem::Tab { bounds, .. } => bounds.width,
6647                ShapedItem::Break { .. } => 0.0,
6648            };
6649            let adv = advance.max(0.0);
6650            total += adv;
6651
6652            let (asc, desc) = get_item_vertical_metrics_approx(item);
6653            let h = (asc + desc).max(item.bounds().height);
6654            if h > max_line_height {
6655                max_line_height = h;
6656            }
6657
6658            if is_break_opportunity_with_word_break(item, word_break, hyphens) {
6659                if cur_word > max_word {
6660                    max_word = cur_word;
6661                }
6662                // A break opportunity that is itself a rendered unit (a CJK
6663                // ideograph in normal mode, or any cluster under break-all /
6664                // overflow-wrap:anywhere) still forms a minimal unbreakable unit
6665                // of its own advance; only true separators (spaces) contribute 0.
6666                // Without this, pure-CJK / break-all text measures min-content = 0
6667                // and the box collapses to zero inline width.
6668                if !is_word_separator(item) && adv > max_word {
6669                    max_word = adv;
6670                }
6671                cur_word = 0.0;
6672            } else {
6673                cur_word += adv;
6674            }
6675        }
6676        if cur_word > max_word {
6677            max_word = cur_word;
6678        }
6679        if total > max_line {
6680            max_line = total;
6681        }
6682
6683        // white-space:nowrap forbids soft-wrap opportunities entirely, so the
6684        // min-content width equals the max-content width (one unbreakable line).
6685        // Without this the scan resets cur_word at each space and reports a
6686        // too-small min-content, letting flex/shrink-to-fit clip the text.
6687        let min_content_width = if matches!(constraints.white_space_mode, WhiteSpaceMode::Nowrap) {
6688            max_line
6689        } else {
6690            max_word
6691        };
6692
6693        Ok(IntrinsicTextSizes {
6694            min_content_width,
6695            max_content_width: max_line,
6696            max_content_height: max_line_height,
6697        })
6698    }
6699}
6700
6701// --- Stage 1 Implementation ---
6702#[allow(clippy::cast_possible_truncation)] // bounded pixel/coord/colour/glyph cast
6703#[allow(clippy::too_many_lines, clippy::cognitive_complexity)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
6704/// # Panics
6705///
6706/// Panics if the scan cursor advances past the end of `text` (an internal invariant).
6707pub fn create_logical_items(
6708    content: &[InlineContent],
6709    style_overrides: &[StyleOverride],
6710    debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
6711) -> Vec<LogicalItem> {
6712    if let Some(msgs) = debug_messages {
6713        msgs.push(LayoutDebugMessage::info(
6714            "\n--- Entering create_logical_items (Refactored) ---".to_string(),
6715        ));
6716        msgs.push(LayoutDebugMessage::info(format!(
6717            "Input content length: {}",
6718            content.len()
6719        )));
6720        msgs.push(LayoutDebugMessage::info(format!(
6721            "Input overrides length: {}",
6722            style_overrides.len()
6723        )));
6724    }
6725
6726    let mut items: Vec<LogicalItem> = Vec::new();
6727    let mut style_cache: HashMap<u64, Arc<StyleProperties>> = HashMap::new();
6728
6729    // 1. Organize overrides for fast lookup per run.
6730    let mut run_overrides: HashMap<u32, HashMap<u32, &PartialStyleProperties>> = HashMap::new();
6731    for override_item in style_overrides {
6732        run_overrides
6733            .entry(override_item.target.run_index)
6734            .or_default()
6735            .insert(override_item.target.item_index, &override_item.style);
6736    }
6737
6738    for (run_idx, inline_item) in content.iter().enumerate() {
6739        if let Some(msgs) = debug_messages {
6740            msgs.push(LayoutDebugMessage::info(format!(
6741                "Processing content run #{run_idx}"
6742            )));
6743        }
6744
6745        // Extract marker information if this is a marker
6746        let marker_position_outside = match inline_item {
6747            InlineContent::Marker {
6748                position_outside, ..
6749            } => Some(*position_outside),
6750            _ => None,
6751        };
6752
6753        // [az-web-lift FIX 2026-06-06] Handle the common Text/Marker case via a STANDALONE `if let`
6754        // (a simple discriminant compare) instead of the first arm of the multi-way `match` below.
6755        // The remill lift mis-routes that multi-way InlineContent switch (LLVM's `subs/csel`-clamp
6756        // lowering): a Text(disc 0) variant lands in the `_`/Object arm → `inline_item.clone()` →
6757        // `<InlineContent as Clone>::clone` ALSO mis-routes to its Vec-clone arm → reads a heap ptr
6758        // as a Vec len → ×8 → ~789 MB alloc → BumpAlloc memset OOB. A standalone if-let lowers to a
6759        // single cmp/beq the lift handles correctly, so Text reaches its real body. Native unaffected.
6760        if let InlineContent::Text(run) | InlineContent::Marker { run, .. } = inline_item {
6761                let text = &run.text;
6762                if text.is_empty() {
6763                    if let Some(msgs) = debug_messages {
6764                        msgs.push(LayoutDebugMessage::info(
6765                            "  Run is empty, skipping.".to_string(),
6766                        ));
6767                    }
6768                    continue;
6769                }
6770                if let Some(msgs) = debug_messages {
6771                    msgs.push(LayoutDebugMessage::info(format!("  Run text: '{text}'")));
6772                }
6773
6774                let current_run_overrides = run_overrides.get(&(run_idx as u32));
6775                let mut boundaries = BTreeSet::new();
6776                boundaries.insert(0);
6777                boundaries.insert(text.len());
6778
6779                // --- Stateful Boundary Generation ---
6780                // web-lift FIX + perf: this scan_cursor walk ONLY inserts boundaries for
6781                // per-char style overrides (Rule 2) or text-combine-upright digit runs (Rule 1).
6782                // For plain text (no overrides AND no combine-upright) it inserts NOTHING and just
6783                // walks char-by-char via `scan_cursor += current_char.len_utf8()` — which the web
6784                // lift mis-advances (overshoot → slice_start_index_len_fail OOB; stall → infinite
6785                // loop). Skip the whole walk in that common case so `boundaries` stays {0, len}.
6786                let needs_scan = current_run_overrides.is_some()
6787                    || run.style.text_combine_upright.is_some();
6788                let mut scan_cursor = 0;
6789                while needs_scan && scan_cursor < text.len() {
6790                    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));
6791
6792                    let current_char = text[scan_cursor..].chars().next().unwrap();
6793
6794                    // +spec:containing-block:e4d9de - text-combine-upright digit run rules: digits sharing an ancestor with same value form one sequence across box boundaries
6795                    // +spec:inline-formatting-context:f65029 - text-combine-upright text run rules: combine consecutive digits not interrupted by box boundary
6796                    // Rule 1: Multi-character features take precedence.
6797                    // +spec:containing-block:9a26bd - text-combine-upright digit runs scoped by ancestor style boundaries
6798                    if let Some(TextCombineUpright::Digits(max_digits)) =
6799                        style_at_cursor.text_combine_upright
6800                    {
6801                        if max_digits > 0 && current_char.is_ascii_digit() {
6802                            let digit_chunk: String = text[scan_cursor..]
6803                                .chars()
6804                                .take(max_digits as usize)
6805                                .take_while(char::is_ascii_digit)
6806                                .collect();
6807
6808                            let end_of_chunk = scan_cursor + digit_chunk.len();
6809                            boundaries.insert(scan_cursor);
6810                            boundaries.insert(end_of_chunk);
6811                            scan_cursor = end_of_chunk; // Jump past the entire sequence
6812                            continue;
6813                        }
6814                    }
6815
6816                    // Rule 2: If no multi-char feature, check for a normal single-grapheme
6817                    // override.
6818                    if current_run_overrides
6819                        .and_then(|o| o.get(&(scan_cursor as u32)))
6820                        .is_some()
6821                    {
6822                        let grapheme_len = text[scan_cursor..]
6823                            .graphemes(true)
6824                            .next()
6825                            .unwrap_or("")
6826                            .len();
6827                        boundaries.insert(scan_cursor);
6828                        boundaries.insert(scan_cursor + grapheme_len);
6829                        scan_cursor += grapheme_len;
6830                        continue;
6831                    }
6832
6833                    // Rule 3: No special features or overrides at this point, just advance one
6834                    // char.
6835                    scan_cursor += current_char.len_utf8();
6836                }
6837
6838                if let Some(msgs) = debug_messages {
6839                    msgs.push(LayoutDebugMessage::info(format!(
6840                        "  Boundaries: {boundaries:?}"
6841                    )));
6842                }
6843
6844                // --- Chunk Processing ---
6845                for (start, end) in boundaries.iter().zip(boundaries.iter().skip(1)) {
6846                    let (start, end) = (*start, *end);
6847                    if start >= end {
6848                        continue;
6849                    }
6850
6851                    let text_slice = &text[start..end];
6852                    if let Some(msgs) = debug_messages {
6853                        msgs.push(LayoutDebugMessage::info(format!(
6854                            "  Processing chunk from {start} to {end}: '{text_slice}'"
6855                        )));
6856                    }
6857
6858                    let style_to_use = current_run_overrides.and_then(|o| o.get(&(start as u32))).map_or_else(|| run.style.clone(), |partial_style| {
6859                        if let Some(msgs) = debug_messages {
6860                            msgs.push(LayoutDebugMessage::info(format!(
6861                                "  -> Applying override at byte {start}"
6862                            )));
6863                        }
6864                        let mut hasher = DefaultHasher::new();
6865                        Arc::as_ptr(&run.style).hash(&mut hasher);
6866                        partial_style.hash(&mut hasher);
6867                        style_cache
6868                            .entry(hasher.finish())
6869                            .or_insert_with(|| Arc::new(run.style.apply_override(partial_style)))
6870                            .clone()
6871                    });
6872
6873                    // +spec:block-formatting-context:9e7c79 - text-combine-upright combines multiple characters into 1em in vertical writing
6874                    // +spec:containing-block:2b399b - text-combine-upright digits: combine ASCII digit sequences within max_digits limit; box boundaries implicitly prevent cross-box combination
6875                    // +spec:display-contents:644c78 - text-combine-upright run boundary check:
6876                    // if a combinable run boundary is due only to inline box boundaries,
6877                    // and adjacent chars would form a longer combinable sequence, do not combine
6878                    // +spec:white-space-processing:409d90 - text-combine-upright combined text: white space at start/end processed as in inline-block
6879                    let is_combinable_chunk = match &style_to_use.text_combine_upright {
6880                        Some(TextCombineUpright::All) => !text_slice.is_empty(),
6881                        Some(TextCombineUpright::Digits(max_digits)) => {
6882                            *max_digits > 0
6883                                && !text_slice.is_empty()
6884                                && text_slice.chars().all(|c| c.is_ascii_digit())
6885                                && text_slice.chars().count() <= *max_digits as usize
6886                        }
6887                        _ => false,
6888                    };
6889
6890                    if is_combinable_chunk {
6891                        // Trim leading/trailing white space like an inline-block
6892                        let trimmed = text_slice.trim();
6893                        let combined_text = if trimmed.is_empty() {
6894                            text_slice.to_string()
6895                        } else {
6896                            trimmed.to_string()
6897                        };
6898                        items.push(LogicalItem::CombinedText {
6899                            source: ContentIndex {
6900                                run_index: run_idx as u32,
6901                                item_index: start as u32,
6902                            },
6903                            text: combined_text,
6904                            style: style_to_use,
6905                        });
6906                    } else {
6907                        items.push(LogicalItem::Text {
6908                            source: ContentIndex {
6909                                run_index: run_idx as u32,
6910                                item_index: start as u32,
6911                            },
6912                            text: text_slice.to_string(),
6913                            style: style_to_use,
6914                            marker_position_outside,
6915                            source_node_id: run.source_node_id,
6916                        });
6917                    }
6918                }
6919        } else {
6920            match inline_item {
6921            // line breaking class characters must be treated as forced line breaks
6922            InlineContent::LineBreak(break_info) => {
6923                if let Some(msgs) = debug_messages {
6924                    msgs.push(LayoutDebugMessage::info(format!(
6925                        "  LineBreak: {break_info:?}"
6926                    )));
6927                }
6928                items.push(LogicalItem::Break {
6929                    source: ContentIndex {
6930                        run_index: run_idx as u32,
6931                        item_index: 0,
6932                    },
6933                    break_info: *break_info,
6934                });
6935            }
6936            // Handle tab characters
6937            InlineContent::Tab { style } => {
6938                if let Some(msgs) = debug_messages {
6939                    msgs.push(LayoutDebugMessage::info("  Tab character".to_string()));
6940                }
6941                items.push(LogicalItem::Tab {
6942                    source: ContentIndex {
6943                        run_index: run_idx as u32,
6944                        item_index: 0,
6945                    },
6946                    style: style.clone(),
6947                });
6948            }
6949            // Other cases (Image, Shape, Space, Ruby). Text/Marker are handled by the `if let`
6950            // above (so they never reach here at runtime); `_` keeps this inner match exhaustive.
6951            _ => {
6952                if let Some(msgs) = debug_messages {
6953                    msgs.push(LayoutDebugMessage::info(
6954                        "  Run is not text, creating generic LogicalItem.".to_string(),
6955                    ));
6956                }
6957                items.push(LogicalItem::Object {
6958                    source: ContentIndex {
6959                        run_index: run_idx as u32,
6960                        item_index: 0,
6961                    },
6962                    content: inline_item.clone(),
6963                });
6964            }
6965            }
6966        }
6967    }
6968    if let Some(msgs) = debug_messages {
6969        msgs.push(LayoutDebugMessage::info(format!(
6970            "--- Exiting create_logical_items, created {} items ---",
6971            items.len()
6972        )));
6973    }
6974    items
6975}
6976
6977// --- Stage 2 Implementation ---
6978
6979// +spec:inline-block:d47971 - unicode-bidi:plaintext uses P2/P3 heuristic for base direction (implemented via get_base_direction)
6980// +spec:writing-modes:287491 - BiDi reordering and base direction detection (Appendix A text processing order)
6981// when determining base direction, consistent with their neutral bidi treatment
6982#[must_use] pub fn get_base_direction_from_logical(logical_items: &[LogicalItem]) -> BidiDirection {
6983    let first_strong = logical_items.iter().find_map(|item| {
6984        if let LogicalItem::Text { text, .. } = item {
6985            Some(unicode_bidi::get_base_direction(text.as_str()))
6986        } else {
6987            None
6988        }
6989    });
6990
6991    match first_strong {
6992        Some(unicode_bidi::Direction::Rtl) => BidiDirection::Rtl,
6993        _ => BidiDirection::Ltr,
6994    }
6995}
6996
6997// +spec:containing-block:149255 - bidi reordering produces inline box fragments that may separate in wide containing blocks
6998// +spec:containing-block:c7c08f - bidi reordering produces inline box fragments that may be adjacent in narrow containing blocks
6999// +spec:containing-block:2936ae - bidi reordering splits inline boxes into visual fragments (CSS Writing Modes 4 §2.4.5)
7000// +spec:display-property:0cdbd3 - bidi reordering splits inline boxes into visual runs; each run is shaped/formatted independently
7001// +spec:display-property:0d62a2 - bidi reordering of inline content respects block direction and unicode-bidi embedding
7002// +spec:display-property:10f9cd - bidi reordering splits and reorders inline box fragments
7003// +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
7004// +spec:display-property:ecd935 - inline boxes split and reordered for uniform bidi flow
7005// +spec:writing-modes:330b8f - text ordered according to Unicode bidi algorithm after white-space processing
7006// +spec:writing-modes:7a9e7d - bidi control translation: text passed to unicode_bidi for reordering
7007// +spec:writing-modes:8e7281 - unicode-bidi property: bidi control codes inserted via BidiInfo
7008#[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
7009#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
7010/// # Errors
7011///
7012/// Returns a `LayoutError` if bidi reordering fails.
7013pub fn reorder_logical_items(
7014    logical_items: &[LogicalItem],
7015    base_direction: BidiDirection,
7016    unicode_bidi: UnicodeBidi,
7017    debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
7018) -> Result<Vec<VisualItem>, LayoutError> {
7019    if let Some(msgs) = debug_messages {
7020        msgs.push(LayoutDebugMessage::info(
7021            "\n--- Entering reorder_logical_items ---".to_string(),
7022        ));
7023        msgs.push(LayoutDebugMessage::info(format!(
7024            "Input logical items count: {}",
7025            logical_items.len()
7026        )));
7027        msgs.push(LayoutDebugMessage::info(format!(
7028            "Base direction: {base_direction:?}"
7029        )));
7030    }
7031
7032    // +spec:writing-modes:809513 - bidi string built across inline element boundaries; unicode-bidi:normal adds no extra embedding levels
7033    let mut bidi_str = String::new();
7034    let mut item_map = Vec::new();
7035    // Byte offset in `bidi_str` where each logical item's text begins, indexed
7036    // by logical item index. Used to re-base each visual run's byte offset to be
7037    // relative to its own logical run (see `run_byte_offset`).
7038    let mut logical_item_starts = Vec::with_capacity(logical_items.len());
7039    for (idx, item) in logical_items.iter().enumerate() {
7040        // +spec:containing-block:1fdc31 - inline boxes with unicode-bidi:normal are transparent to bidi algorithm
7041        // +spec:display-property:074abf - inline boxes transparent to bidi when unicode-bidi:normal
7042        // +spec:display-property:354966 - unicode-bidi control code injection for inline boxes
7043        // +spec:display-property:8409d3 - inline-level elements with unicode-bidi:normal have no effect on bidi ordering; embed creates an embedding
7044        // +spec:display-property:89464a - inline boxes with unicode-bidi:normal don't open embedding levels, so direction has no effect on bidi reordering
7045        // +spec:display-property:d47971 - bidi control codes should be injected at inline box boundaries based on unicode-bidi + direction
7046        // +spec:display-property:de657b - bidi control codes injected for display:inline boxes per unicode-bidi value
7047        // +spec:display-property:f01a81 - bidi-override should prepend LRO/RLO and append PDF per unicode-bidi CSS property (not yet implemented)
7048        // are treated as neutral characters in the bidi algorithm. Replaced elements with
7049        // +spec:display-property:fcb011 - unicode-bidi values on inline boxes insert bidi control codes
7050        // +spec:display-property:89095f - isolate/bidi-override/isolate-override/plaintext semantics
7051        // +spec:writing-modes:d490bf - direction only affects reordering when unicode-bidi is embed/override (not yet enforced for inline elements)
7052        // display:inline are also neutral unless unicode-bidi != normal (not yet implemented).
7053        // +spec:display-property:b4756e - replaced inline elements treated as neutral bidi chars;
7054        // embed/bidi-override exception not yet implemented (would make them strong chars).
7055        // U+FFFC (OBJECT REPLACEMENT CHARACTER) is a neutral bidi character.
7056        // +spec:display-property:df11ef - atomic inlines treated as neutral bidi characters (U+FFFC)
7057        // Replaced elements with display:inline are also neutral unless unicode-bidi != normal.
7058        let text = match item {
7059            LogicalItem::Text { text, .. } => text.as_str(),
7060            LogicalItem::CombinedText { text, .. } => text.as_str(),
7061            _ => "\u{FFFC}",
7062        };
7063        let start_byte = bidi_str.len();
7064        logical_item_starts.push(start_byte);
7065        bidi_str.push_str(text);
7066        for _ in start_byte..bidi_str.len() {
7067            item_map.push(idx);
7068        }
7069    }
7070
7071    if bidi_str.is_empty() {
7072        if let Some(msgs) = debug_messages {
7073            msgs.push(LayoutDebugMessage::info(
7074                "Bidi string is empty, returning.".to_string(),
7075            ));
7076        }
7077        return Ok(Vec::new());
7078    }
7079    if let Some(msgs) = debug_messages {
7080        msgs.push(LayoutDebugMessage::info(format!(
7081            "Constructed bidi string: '{bidi_str}'"
7082        )));
7083    }
7084
7085    // +spec:display-property:1a6075 - paragraph embedding level set from direction property per UAX9 HL1
7086    // +spec:containing-block:0d4914 - unicode-bidi: plaintext exception
7087    // When the containing block has unicode-bidi: plaintext, use None so the
7088    // Unicode bidi algorithm applies P2/P3 heuristics instead of the HL1 override
7089    let bidi_level = if unicode_bidi == UnicodeBidi::Plaintext {
7090        None
7091    } else if base_direction == BidiDirection::Rtl {
7092        Some(Level::rtl())
7093    } else {
7094        Some(Level::ltr())
7095    };
7096    // +spec:writing-modes:15bf17 - bidi isolation handled by unicode_bidi UAX #9 implementation
7097    let bidi_info = BidiInfo::new(&bidi_str, bidi_level);
7098    let para = &bidi_info.paragraphs[0];
7099    let (levels, visual_runs) = bidi_info.visual_runs(para, para.range.clone());
7100
7101    if let Some(msgs) = debug_messages {
7102        msgs.push(LayoutDebugMessage::info(
7103            "Bidi visual runs generated:".to_string(),
7104        ));
7105        for (i, run_range) in visual_runs.iter().enumerate() {
7106            let level = levels[run_range.start].number();
7107            let slice = &bidi_str[run_range.start..run_range.end];
7108            msgs.push(LayoutDebugMessage::info(format!(
7109                "  Run {i}: range={run_range:?}, level={level}, text='{slice}'"
7110            )));
7111        }
7112    }
7113
7114    // TODO(text3-review): RTL glyph-level visual reversal is NOT applied.
7115    // `visual_runs` orders the RUNS visually (left-to-right), but the loop below
7116    // emits each run's content in LOGICAL byte order, and shaping/positioning then
7117    // place clusters left-to-right in that logical order. For an RTL run this is
7118    // wrong: the first logical character must land at the LARGEST visual x. The
7119    // shaped clusters of each RTL run therefore need to be reversed (UBA rule L2,
7120    // applied per run at the glyph level AFTER shaping — a single logical Text item
7121    // shapes into multiple clusters, so it cannot be reversed here at the item
7122    // level). This must compose with the run-level ordering already done here
7123    // (naively re-running full L2 on top would double-reverse RTL-base paragraphs),
7124    // and `UnifiedLayout::get_selection_rects` must additionally split a selection
7125    // into one visual rect per directional segment. Deferred as a coherent
7126    // cross-cutting change; see failing tests text3_brutal_shaping::
7127    // {hebrew_run_is_rtl_reversed_and_33px_wide, bidi_mixed_run_is_80px_and_reverses_hebrew}
7128    // and text3_brutal_selection::bidi_selection_over_rtl_run_splits_into_multiple_rects.
7129    let mut visual_items = Vec::new();
7130    for run_range in visual_runs {
7131        let bidi_level = BidiLevel::new(levels[run_range.start].number());
7132        let mut sub_run_start = run_range.start;
7133
7134        for i in (run_range.start + 1)..run_range.end {
7135            if item_map[i] != item_map[sub_run_start] {
7136                let logical_idx = item_map[sub_run_start];
7137                let logical_item = &logical_items[logical_idx];
7138                let text_slice = &bidi_str[sub_run_start..i];
7139                visual_items.push(VisualItem {
7140                    logical_source: logical_item.clone(),
7141                    bidi_level,
7142                    script: crate::text3::script::detect_script(text_slice)
7143                        .unwrap_or(Script::Latin),
7144                    text: text_slice.to_string(),
7145                    run_byte_offset: sub_run_start - logical_item_starts[logical_idx],
7146                });
7147                sub_run_start = i;
7148            }
7149        }
7150
7151        let logical_idx = item_map[sub_run_start];
7152        let logical_item = &logical_items[logical_idx];
7153        let text_slice = &bidi_str[sub_run_start..run_range.end];
7154        visual_items.push(VisualItem {
7155            logical_source: logical_item.clone(),
7156            bidi_level,
7157            script: crate::text3::script::detect_script(text_slice).unwrap_or(Script::Latin),
7158            text: text_slice.to_string(),
7159            run_byte_offset: sub_run_start - logical_item_starts[logical_idx],
7160        });
7161    }
7162
7163    if let Some(msgs) = debug_messages {
7164        msgs.push(LayoutDebugMessage::info(
7165            "Final visual items produced:".to_string(),
7166        ));
7167        for (i, item) in visual_items.iter().enumerate() {
7168            msgs.push(LayoutDebugMessage::info(format!(
7169                "  Item {}: level={}, text='{}'",
7170                i,
7171                item.bidi_level.level(),
7172                item.text
7173            )));
7174        }
7175        msgs.push(LayoutDebugMessage::info(
7176            "--- Exiting reorder_logical_items ---".to_string(),
7177        ));
7178    }
7179    Ok(visual_items)
7180}
7181
7182// --- Stage 3 Implementation ---
7183
7184/// Shape visual items into `ShapedItems` using pre-loaded fonts.
7185///
7186/// This function does NOT load any fonts - all fonts must be pre-loaded and passed in.
7187/// If a required font is not in `loaded_fonts`, the text will be skipped with a warning.
7188///
7189/// **Optimization: Inline Run Coalescing**
7190///
7191/// // +spec:display-property:9c6d59 - text shaping not broken across inline box boundaries when no effective formatting change
7192/// // +spec:display-property:cf8917 - text shaping not broken across inline box boundaries
7193/// When consecutive text `VisualItem`s share the same layout-affecting properties
7194/// (font, size, spacing, etc.) but differ only in rendering properties (color,
7195/// background), they are coalesced into a single shaping call. This dramatically
7196/// reduces the number of `font.shape_text()` invocations for syntax-highlighted
7197/// code where hundreds of `<span>` elements use the same monospace font but
7198/// different colors. After shaping, the original per-span styles are restored
7199/// to each `ShapedCluster` based on byte-range mapping.
7200/// Shape visual items with per-item caching. For each item (or coalesced group),
7201/// compute a cache key from (text, `bidi_level`, script, `style_layout_hash`). On cache
7202/// hit, reuse the previously shaped clusters. On miss, shape and store.
7203///
7204/// This is the incremental shaping path: when one word changes in a paragraph,
7205/// only that word's item misses the per-item cache; all other items hit.
7206#[allow(clippy::implicit_hasher)] // internal helper; only ever called with the default-hasher HashMap/HashSet
7207/// # Errors
7208///
7209/// Returns a `LayoutError` if shaping the visual items fails.
7210pub fn shape_visual_items_with_per_item_cache<T: ParsedFontTrait>(
7211    visual_items: &[VisualItem],
7212    per_item_cache: &mut HashMap<u64, Arc<PerItemShapedEntry>>,
7213    per_item_accessed: &mut HashSet<u64>,
7214    font_chain_cache: &HashMap<FontChainKey, rust_fontconfig::FontFallbackChain>,
7215    fc_cache: &FcFontCache,
7216    loaded_fonts: &LoadedFonts<T>,
7217    debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
7218) -> Result<Vec<ShapedItem>, LayoutError> {
7219    use std::hash::{Hash, Hasher};
7220    // Delegate to the existing shaping logic, but for each coalesce group,
7221    // check the per-item cache first.
7222    //
7223    // Strategy: Identify coalesce groups (adjacent items with same layout_hash,
7224    // bidi_level, script). For each group, compute a key from the concatenated
7225    // text + shared properties. Check cache. On miss, shape the group and cache it.
7226    let mut shaped = Vec::new();
7227    let mut idx = 0;
7228
7229    while idx < visual_items.len() {
7230        let item = &visual_items[idx];
7231
7232        // Determine coalesce group boundaries (same logic as shape_visual_items)
7233        let (layout_hash, bidi_level, script) = match &item.logical_source {
7234            LogicalItem::Text { style, .. } | LogicalItem::CombinedText { style, .. } => {
7235                (style.layout_hash(), item.bidi_level, item.script)
7236            }
7237            _ => {
7238                // Non-text items: shape individually (no coalescing)
7239                let single = shape_visual_items(
7240                    &visual_items[idx..=idx],
7241                    font_chain_cache, fc_cache, loaded_fonts, debug_messages,
7242                )?;
7243                shaped.extend(single);
7244                idx += 1;
7245                continue;
7246            }
7247        };
7248
7249        let mut coalesce_end = idx + 1;
7250        while coalesce_end < visual_items.len() {
7251            let next = &visual_items[coalesce_end];
7252            let next_layout_hash = match &next.logical_source {
7253                LogicalItem::Text { style, .. } | LogicalItem::CombinedText { style, .. } => {
7254                    Some(style.layout_hash())
7255                }
7256                _ => None,
7257            };
7258            if let Some(nlh) = next_layout_hash {
7259                if nlh == layout_hash
7260                    && next.bidi_level == bidi_level
7261                    && next.script == script
7262                {
7263                    coalesce_end += 1;
7264                } else {
7265                    break;
7266                }
7267            } else {
7268                break;
7269            }
7270        }
7271
7272        // Compute per-group cache key
7273        let mut hasher = DefaultHasher::new();
7274        for item in &visual_items[idx..coalesce_end] {
7275            item.text.hash(&mut hasher);
7276        }
7277        layout_hash.hash(&mut hasher);
7278        bidi_level.hash(&mut hasher);
7279        (script as u32).hash(&mut hasher);
7280        let group_key = hasher.finish();
7281
7282        // Check per-item cache
7283        per_item_accessed.insert(group_key);
7284        if let Some(cached) = per_item_cache.get(&group_key) {
7285            shaped.extend(cached.clusters.iter().cloned());
7286        } else {
7287            // Cache miss — shape this group
7288            let group_items = shape_visual_items(
7289                &visual_items[idx..coalesce_end],
7290                font_chain_cache, fc_cache, loaded_fonts, debug_messages,
7291            )?;
7292            let total_advance: f32 = group_items.iter().map(|item| {
7293                match item {
7294                    ShapedItem::Cluster(c) => c.advance,
7295                    _ => 0.0,
7296                }
7297            }).sum();
7298            per_item_cache.insert(group_key, Arc::new(PerItemShapedEntry {
7299                clusters: group_items.clone(),
7300                total_advance,
7301            }));
7302            shaped.extend(group_items);
7303        }
7304
7305        idx = coalesce_end;
7306    }
7307
7308    Ok(shaped)
7309}
7310
7311/// Split text into segments where consecutive characters resolve to the same font
7312/// in the fallback chain. Returns Vec<(`byte_start`, `byte_end`, `FontId`)>.
7313///
7314/// Characters that can't be resolved to any font are skipped (gap in coverage).
7315fn split_text_by_font_coverage<T: ParsedFontTrait>(
7316    text: &str,
7317    font_chain: &rust_fontconfig::FontFallbackChain,
7318    fc_cache: &FcFontCache,
7319    loaded_fonts: &LoadedFonts<T>,
7320) -> Vec<(usize, usize, FontId)> {
7321    let mut segments: Vec<(usize, usize, FontId)> = Vec::new();
7322
7323    // Deterministic "last resort" face for characters no font covers: the lowest
7324    // FontId among the loaded fonts. Used so an uncovered codepoint still emits a
7325    // .notdef (tofu) segment instead of being silently dropped (zero glyphs/advance).
7326    let notdef_font_id = loaded_fonts.iter().map(|(id, _)| *id).min();
7327
7328    for (byte_idx, ch) in text.char_indices() {
7329        let char_end = byte_idx + ch.len_utf8();
7330        // Primary: the resolved fallback chain. Its coverage comes from
7331        // rust-fontconfig's OS/2-derived `unicode_ranges`, which can MISS
7332        // codepoints a font actually has in its cmap — e.g. Noto Sans CJK's
7333        // JP face does not advertise the Hangul OS/2 block, so 한국어 resolves
7334        // to None here even though that face's cmap covers it.
7335        let font_id = font_chain
7336            .resolve_char(fc_cache, ch)
7337            .map(|(id, _)| id)
7338            // Fallback: probe the actually-loaded fonts by REAL glyph coverage
7339            // so OS/2-vs-cmap gaps render instead of being silently dropped.
7340            // The covering CJK face is already loaded (Han/Kana resolved to it),
7341            // so this reuses it for Hangul rather than mixing in another font.
7342            // Iterate in a STABLE order (lowest FontId first) so the chosen face is
7343            // deterministic across processes — a raw HashMap `.find` is seeded per
7344            // process and would pick different faces run-to-run.
7345            .or_else(|| {
7346                loaded_fonts
7347                    .iter()
7348                    .filter(|(_, font)| font.has_glyph(ch as u32))
7349                    .map(|(id, _)| *id)
7350                    .min()
7351            })
7352            // Last resort: no font advertises OR covers this codepoint. Assign it to
7353            // the primary loaded face so the shaper emits a visible .notdef box and
7354            // the byte range is preserved (following text is not shifted).
7355            .or(notdef_font_id);
7356        if let Some(font_id) = font_id {
7357            match segments.last_mut() {
7358                Some(last) if last.2 == font_id && last.1 == byte_idx => {
7359                    // Extend current segment (same font, contiguous)
7360                    last.1 = char_end;
7361                }
7362                _ => {
7363                    // New segment (different font or gap)
7364                    segments.push((byte_idx, char_end, font_id));
7365                }
7366            }
7367        }
7368    }
7369
7370    segments
7371}
7372
7373/// Measures the total inline advance (width in horizontal mode) of `text` shaped at
7374/// `style`, using the same font-resolution path as the main shaper. Returns `None` if the
7375/// font chain is not resolved / shaping fails, so callers can fall back to an estimate.
7376///
7377/// Used by ruby layout to size the base and annotation runs from REAL shaped advances
7378/// (instead of a `chars * font_size * magic_ratio` fudge).
7379fn measure_run_advance<T: ParsedFontTrait>(
7380    text: &str,
7381    style: &Arc<StyleProperties>,
7382    script: Script,
7383    source: ContentIndex,
7384    font_chain_cache: &HashMap<FontChainKey, rust_fontconfig::FontFallbackChain>,
7385    fc_cache: &FcFontCache,
7386    loaded_fonts: &LoadedFonts<T>,
7387) -> Option<f32> {
7388    if text.is_empty() {
7389        return Some(0.0);
7390    }
7391    let language = script_to_language(script, text);
7392    match &style.font_stack {
7393        FontStack::Ref(font_ref) => {
7394            let glyphs = font_ref
7395                .shape_text(text, script, language, BidiDirection::Ltr, style.as_ref())
7396                .ok()?;
7397            Some(glyphs.iter().map(|g| g.advance + g.kerning).sum())
7398        }
7399        FontStack::Stack(selectors) => {
7400            let cache_key = FontChainKey::from_selectors(selectors);
7401            let font_chain = font_chain_cache.get(&cache_key)?;
7402            let clusters = shape_with_font_fallback(
7403                text, script, language, BidiDirection::Ltr, style, source, None, font_chain,
7404                fc_cache, loaded_fonts,
7405            )
7406            .ok()?;
7407            Some(clusters.iter().map(|c| c.advance).sum())
7408        }
7409    }
7410}
7411
7412/// Shape text with per-character font fallback.
7413///
7414/// Splits the text into segments by font coverage, shapes each segment with
7415/// its resolved font, and fixes byte offsets so they're relative to the
7416/// original `text` (not the segment substring).
7417#[allow(clippy::cast_possible_truncation)] // bounded pixel/coord/colour/glyph cast
7418fn shape_with_font_fallback<T: ParsedFontTrait>(
7419    text: &str,
7420    script: Script,
7421    language: Language,
7422    direction: BidiDirection,
7423    style: &Arc<StyleProperties>,
7424    source_index: ContentIndex,
7425    source_node_id: Option<NodeId>,
7426    font_chain: &rust_fontconfig::FontFallbackChain,
7427    fc_cache: &FcFontCache,
7428    loaded_fonts: &LoadedFonts<T>,
7429) -> Result<Vec<ShapedCluster>, LayoutError> {
7430    // Cache the debug flag in a `OnceLock<bool>` — reading it per-shape
7431    // (this function fires once per text segment, ~hundreds of times
7432    // per render of a real DOM) costs ~100 ns per `std::env::var_os`
7433    // call on macOS (env-lock + hashmap lookup), and even before the
7434    // lookup finishes the `eprintln!` machinery takes a stderr lock
7435    // and allocates the formatted string. Both are invisible in
7436    // release unless `AZ_FONT_FALLBACK_DEBUG=1` is set.
7437    static FONT_FB_DEBUG: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
7438    let dbg = *FONT_FB_DEBUG.get_or_init(|| {
7439        std::env::var_os("AZ_FONT_FALLBACK_DEBUG").is_some()
7440    });
7441
7442    let segments = split_text_by_font_coverage(text, font_chain, fc_cache, loaded_fonts);
7443
7444    if dbg && segments.len() > 1 {
7445        eprintln!(
7446            "[FONT FALLBACK] text needs {} font segments for '{}' ({}..{} bytes)",
7447            segments.len(),
7448            text.chars().take(40).collect::<String>(),
7449            0, text.len()
7450        );
7451    }
7452
7453    unsafe { crate::az_mark(0x60850_u32, segments.len() as u32); } // [g123] segments count (split_text_by_font_coverage)
7454    if segments.len() <= 1 {
7455        // Fast path: all characters use the same font (common case)
7456        let (seg_start, seg_end, font_id) = if let Some(s) = segments.first() { unsafe { crate::az_mark(0x60854_u32, 0x0000_0001_u32); } s } else {
7457            unsafe { crate::az_mark(0x60854_u32, 0x0000_00EE_u32); } // [g123] split→0 segments (resolve_char failed all)
7458            if dbg {
7459                eprintln!("[FONT FALLBACK] no font could render any char in '{}'", text.chars().take(20).collect::<String>());
7460            }
7461            return Ok(Vec::new());
7462        };
7463        let font = if let Some(f) = loaded_fonts.get(font_id) { unsafe { crate::az_mark(0x60858_u32, 0x0000_0001_u32); } f } else {
7464            unsafe { crate::az_mark(0x60858_u32, 0x0000_00EE_u32); } // [g123] loaded_fonts.get MISS
7465            if dbg {
7466                eprintln!("[FONT FALLBACK] font {:?} not in loaded_fonts for '{}'", font_id, text.chars().take(20).collect::<String>());
7467            }
7468            return Ok(Vec::new());
7469        };
7470        // If segment covers the full text (overwhelmingly common), skip substr+fixup
7471        if *seg_start == 0 && *seg_end == text.len() {
7472            unsafe { crate::az_mark(0x60860_u32, 0xC0DE_0860_u32); } // [g123] reached shape_text_correctly (full-text)
7473            return shape_text_correctly(
7474                text, script, language, direction,
7475                font, style, source_index, source_node_id,
7476            );
7477        }
7478        let mut clusters = shape_text_correctly(
7479            &text[*seg_start..*seg_end], script, language, direction,
7480            font, style, source_index, source_node_id,
7481        )?;
7482        if *seg_start > 0 {
7483            for cluster in &mut clusters {
7484                cluster.source_cluster_id.start_byte_in_run += *seg_start as u32;
7485            }
7486        }
7487        return Ok(clusters);
7488    }
7489
7490    // Multiple fonts needed — shape each segment separately
7491    let mut all_clusters = Vec::new();
7492    for (seg_start, seg_end, font_id) in &segments {
7493        let Some(font) = loaded_fonts.get(font_id) else {
7494            if dbg {
7495                eprintln!("[FONT FALLBACK] font {font_id:?} NOT loaded, skipping segment bytes {seg_start}..{seg_end}");
7496            }
7497            continue;
7498        };
7499        let segment_text = &text[*seg_start..*seg_end];
7500        if dbg {
7501            eprintln!(
7502                "[FONT FALLBACK] text='{segment_text}' uses font {font_id:?} (bytes {seg_start}..{seg_end})"
7503            );
7504        }
7505        let mut seg_clusters = shape_text_correctly(
7506            segment_text, script, language, direction,
7507            font, style, source_index, source_node_id,
7508        )?;
7509        // Fix byte offsets: shape_text_correctly produces offsets relative to
7510        // segment_text, but callers expect offsets relative to the full text.
7511        if *seg_start > 0 {
7512            for cluster in &mut seg_clusters {
7513                cluster.source_cluster_id.start_byte_in_run += *seg_start as u32;
7514            }
7515        }
7516        all_clusters.extend(seg_clusters);
7517    }
7518    Ok(all_clusters)
7519}
7520
7521#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)] // bounded pixel/coord/colour/glyph cast
7522#[allow(clippy::implicit_hasher)] // internal helper; only ever called with the default-hasher HashMap/HashSet
7523#[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
7524#[allow(clippy::too_many_lines, clippy::cognitive_complexity)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
7525/// # Errors
7526///
7527/// Returns a `LayoutError` if shaping the visual items fails.
7528pub fn shape_visual_items<T: ParsedFontTrait>(
7529    visual_items: &[VisualItem],
7530    font_chain_cache: &HashMap<FontChainKey, rust_fontconfig::FontFallbackChain>,
7531    fc_cache: &FcFontCache,
7532    loaded_fonts: &LoadedFonts<T>,
7533    debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
7534) -> Result<Vec<ShapedItem>, LayoutError> {
7535    let mut shaped = Vec::new();
7536    let mut idx = 0;
7537    let mut _coalesced_runs = 0usize;
7538    let mut _total_runs = 0usize;
7539    let mut _shape_calls = 0usize;
7540
7541    // Log count of visual items for debugging coalescing
7542
7543    while idx < visual_items.len() {
7544        let item = &visual_items[idx];
7545        match &item.logical_source {
7546            LogicalItem::Text {
7547                style,
7548                source,
7549                marker_position_outside,
7550                source_node_id,
7551                ..
7552            } => {
7553                let layout_hash = style.layout_hash();
7554                let bidi_level = item.bidi_level;
7555                let script = item.script;
7556
7557                // +spec:display-property:ca95f6 - text shaping breaks at inline box boundaries when layout-affecting properties differ
7558                // when layout-affecting properties (font weight, family, size, etc.) change
7559                // across element boundaries, preventing ligatures from forming across such changes.
7560                // Look ahead: find consecutive text items with the same layout-affecting
7561                // properties (font, size, spacing) that can be shaped as one merged run.
7562                let mut coalesce_end = idx + 1;
7563                while coalesce_end < visual_items.len() {
7564                    let next = &visual_items[coalesce_end];
7565                    if let LogicalItem::Text { style: next_style, .. } = &next.logical_source {
7566                        if next_style.layout_hash() == layout_hash
7567                            && next.bidi_level == bidi_level
7568                            && next.script == script
7569                        {
7570                            coalesce_end += 1;
7571                        } else {
7572                            break;
7573                        }
7574                    } else {
7575                        break;
7576                    }
7577                }
7578
7579                let coalesce_count = coalesce_end - idx;
7580
7581                if coalesce_count > 1 {
7582                    _coalesced_runs += coalesce_count;
7583                    _shape_calls += 1;
7584                    // ── COALESCED PATH ──
7585                    // Merge N text items into one shaping call, then split results
7586                    // back per original run to preserve per-span rendering styles.
7587
7588                    // Build merged text and record byte ranges → original style
7589                    let total_text_len: usize = visual_items[idx..coalesce_end]
7590                        .iter()
7591                        .map(|v| v.text.len())
7592                        .sum();
7593                    let mut merged_text = String::with_capacity(total_text_len);
7594                    // (byte_start, byte_end, style, source, source_node_id, marker_outside, run_byte_offset)
7595                    let mut byte_ranges: Vec<(
7596                        usize, usize,
7597                        Arc<StyleProperties>,
7598                        ContentIndex,
7599                        Option<NodeId>,
7600                        Option<bool>,
7601                        usize,
7602                    )> = Vec::with_capacity(coalesce_count);
7603
7604                    for item in &visual_items[idx..coalesce_end] {
7605                        let start = merged_text.len();
7606                        merged_text.push_str(&item.text);
7607                        let end = merged_text.len();
7608                        if let LogicalItem::Text {
7609                            style: s, source: src, source_node_id: nid,
7610                            marker_position_outside: mpo, ..
7611                        } = &item.logical_source {
7612                            byte_ranges.push((start, end, s.clone(), *src, *nid, *mpo, item.run_byte_offset));
7613                        }
7614                    }
7615
7616                    if let Some(msgs) = debug_messages {
7617                        msgs.push(LayoutDebugMessage::info(format!(
7618                            "[TextLayout] Coalescing {} text runs ({} bytes) into single shaping call",
7619                            coalesce_count, merged_text.len()
7620                        )));
7621                    }
7622
7623                    let direction = if bidi_level.is_rtl() {
7624                        BidiDirection::Rtl
7625                    } else {
7626                        BidiDirection::Ltr
7627                    };
7628                    let language = script_to_language(script, &merged_text);
7629
7630                    // Shape the merged text using the first item's font (layout is identical
7631                    // for all coalesced items since layout_hash matches).
7632                    let shaped_clusters_result: Result<Vec<ShapedCluster>, LayoutError> = match &style.font_stack {
7633                        FontStack::Ref(font_ref) => {
7634                            shape_text_correctly(
7635                                &merged_text, script, language, direction,
7636                                font_ref, style, *source, *source_node_id,
7637                            )
7638                        }
7639                        FontStack::Stack(selectors) => {
7640                            let cache_key = FontChainKey::from_selectors(selectors);
7641                            let Some(font_chain) = font_chain_cache.get(&cache_key) else { idx = coalesce_end; continue; };
7642                            // Per-character font fallback: split text by font coverage
7643                            shape_with_font_fallback(
7644                                &merged_text, script, language, direction,
7645                                style, *source, *source_node_id,
7646                                font_chain, fc_cache, loaded_fonts,
7647                            )
7648                        }
7649                    };
7650
7651                    let shaped_clusters = shaped_clusters_result?;
7652
7653                    // Restore original per-span styles to each cluster based on byte position.
7654                    // Each ShapedCluster's source_cluster_id.start_byte_in_run is the byte
7655                    // offset within the merged text — we use byte_ranges to find which
7656                    // original run it belongs to and reassign its style, source info, etc.
7657                    for cluster in shaped_clusters {
7658                        let byte_pos = cluster.source_cluster_id.start_byte_in_run as usize;
7659                        // Find the original run this cluster's first byte falls into
7660                        let orig = byte_ranges.iter().find(|(start, end, ..)| {
7661                            byte_pos >= *start && byte_pos < *end
7662                        });
7663                        let mut cluster = cluster;
7664                        if let Some((range_start, _, orig_style, orig_source, orig_nid, orig_mpo, orig_run_offset)) = orig {
7665                            // Reassign rendering-affecting style (color, background, etc.)
7666                            cluster.style = orig_style.clone();
7667                            cluster.source_content_index = *orig_source;
7668                            cluster.source_node_id = *orig_nid;
7669                            // Fix the byte offset to be relative to the original logical run:
7670                            // (position within the merged text - this run's start in the merge)
7671                            // + this visual run's offset within its logical run (bidi split).
7672                            cluster.source_cluster_id.source_run = orig_source.run_index;
7673                            cluster.source_cluster_id.start_byte_in_run = (byte_pos - range_start + *orig_run_offset) as u32;
7674                            // Update glyph styles
7675                            for glyph in &mut cluster.glyphs {
7676                                glyph.style = orig_style.clone();
7677                            }
7678                            if let Some(is_outside) = orig_mpo {
7679                                cluster.marker_position_outside = Some(*is_outside);
7680                            }
7681                        }
7682                        shaped.push(ShapedItem::Cluster(cluster));
7683                    }
7684
7685                    idx = coalesce_end;
7686                    continue;
7687                }
7688
7689                // ── SINGLE ITEM PATH (no coalescing) ──
7690                _total_runs += 1;
7691                _shape_calls += 1;
7692                let direction = if item.bidi_level.is_rtl() {
7693                    BidiDirection::Rtl
7694                } else {
7695                    BidiDirection::Ltr
7696                };
7697
7698                let language = script_to_language(item.script, &item.text);
7699
7700                // Shape text using either FontRef directly or fontconfig-resolved font
7701                let shaped_clusters_result: Result<Vec<ShapedCluster>, LayoutError> = match &style.font_stack {
7702                    FontStack::Ref(font_ref) => {
7703                        unsafe { crate::az_mark(0x60820_u32, 0x0000_0001_u32); } // [g121] Ref arm
7704                        // For FontRef, use the font directly without fontconfig
7705                        if let Some(msgs) = debug_messages {
7706                            msgs.push(LayoutDebugMessage::info(format!(
7707                                "[TextLayout] Using direct FontRef for text: '{}'",
7708                                item.text.chars().take(30).collect::<String>()
7709                            )));
7710                        }
7711                        shape_text_correctly(
7712                            &item.text,
7713                            item.script,
7714                            language,
7715                            direction,
7716                            font_ref,
7717                            style,
7718                            *source,
7719                            *source_node_id,
7720                        )
7721                    }
7722                    FontStack::Stack(selectors) => {
7723                        unsafe { crate::az_mark(0x60820_u32, 0x0000_0002_u32); } // [g121] Stack arm
7724                        // Build FontChainKey and resolve through fontconfig
7725                        let cache_key = FontChainKey::from_selectors(selectors);
7726                        unsafe { crate::az_mark(0x60824_u32, font_chain_cache.len() as u32); } // [g121] chain map len
7727
7728                        // Look up the pre-resolved font chain. (2026-06-10: the g122
7729                        // by_find/by_only fallback chain is GONE — the historic miss was a
7730                        // KEY-CONSTRUCTION divergence (duplicated families on the query side,
7731                        // deduped on the store side), fixed by routing every key build through
7732                        // FontChainKey::from_selectors. Verified lifted: lookup path = get.)
7733                        let Some(font_chain) = font_chain_cache.get(&cache_key) else {
7734                            if let Some(msgs) = debug_messages {
7735                                msgs.push(LayoutDebugMessage::warning(format!(
7736                                    "[TextLayout] Font chain not pre-resolved for {:?} - text will \
7737                                     not be rendered",
7738                                    cache_key.font_families
7739                                )));
7740                            }
7741                            idx += 1;
7742                            continue;
7743                        };
7744
7745                        // Per-character font fallback: split text by font coverage
7746                        shape_with_font_fallback(
7747                            &item.text, item.script, language, direction,
7748                            style, *source, *source_node_id,
7749                            font_chain, fc_cache, loaded_fonts,
7750                        )
7751                    }
7752                };
7753
7754                let mut shaped_clusters = shaped_clusters_result?;
7755
7756                // Re-base cluster byte offsets to the logical run. Shaping produced
7757                // `start_byte_in_run` relative to this visual run's `text`; when bidi
7758                // split the logical run into several visual runs, add the visual run's
7759                // offset so every cluster ID is unique + matches caret byte positions.
7760                let run_byte_offset = item.run_byte_offset as u32;
7761                if run_byte_offset != 0 {
7762                    for cluster in &mut shaped_clusters {
7763                        cluster.source_cluster_id.start_byte_in_run = cluster
7764                            .source_cluster_id
7765                            .start_byte_in_run
7766                            .saturating_add(run_byte_offset);
7767                    }
7768                }
7769
7770                // Set marker flag on all clusters if this is a marker
7771                if let Some(is_outside) = marker_position_outside {
7772                    for cluster in &mut shaped_clusters {
7773                        cluster.marker_position_outside = Some(*is_outside);
7774                    }
7775                }
7776
7777                shaped.extend(shaped_clusters.into_iter().map(ShapedItem::Cluster));
7778            }
7779            // +spec:display-property:df076b - tab-size rendering and inline-level line breaking
7780            // "If the tab size is zero, preserved tabs are not rendered."
7781            // "Otherwise, each preserved tab is rendered as a horizontal shift that lines up
7782            //  the start edge of the next glyph with the next tab stop."
7783            // "Tab stops occur at points that are multiples of the tab size from the starting
7784            //  content edge of the preserved tab's nearest block container ancestor."
7785            LogicalItem::Tab { source, style } => {
7786                if style.tab_size == 0.0 {
7787                    // Tab size zero: tab is not rendered (zero width)
7788                    shaped.push(ShapedItem::Tab {
7789                        source: *source,
7790                        bounds: Rect {
7791                            x: 0.0,
7792                            y: 0.0,
7793                            width: 0.0,
7794                            height: 0.0,
7795                        },
7796                    });
7797                } else {
7798                    // TODO: use actual font's space_width via ParsedFontTrait::get_space_width()
7799                    // once we thread font resolution into the shaping phase for tab stops.
7800                    // For now, approximate space advance as 0.5 * font_size (typical for Latin fonts).
7801                    let space_advance_approx = style.font_size_px * SPACE_WIDTH_RATIO;
7802                    // +spec:text-alignment-spacing:5a5efd - tab-size includes letter-spacing and word-spacing
7803                    let ls = style.letter_spacing.resolve_px(style.font_size_px);
7804                    let ws = style.word_spacing.resolve_px(style.font_size_px);
7805                    // Tab stop interval: tab_size * (space advance + letter-spacing + word-spacing)
7806                    let tab_interval = style.tab_size * (space_advance_approx + ls + ws);
7807                    // Calculate current advance to find next tab stop
7808                    let current_advance: f32 = shaped.iter().map(|item| {
7809                        match item {
7810                            ShapedItem::Cluster(c) => c.advance,
7811                            ShapedItem::Tab { bounds, .. } => bounds.width,
7812                            ShapedItem::Object { bounds, .. } => bounds.width,
7813                            _ => 0.0,
7814                        }
7815                    }).sum();
7816                    // Next tab stop = next multiple of tab_interval from content edge
7817                    let next_tab_stop = ((current_advance / tab_interval).floor() + 1.0) * tab_interval;
7818                    let mut tab_width = next_tab_stop - current_advance;
7819                    // "If this distance is less than 0.5ch, then the subsequent tab stop is used instead."
7820                    let half_ch = space_advance_approx * 0.5;
7821                    if tab_width < half_ch {
7822                        tab_width += tab_interval;
7823                    }
7824                    shaped.push(ShapedItem::Tab {
7825                        source: *source,
7826                        bounds: Rect {
7827                            x: 0.0,
7828                            y: 0.0,
7829                            width: tab_width,
7830                            height: 0.0,
7831                        },
7832                    });
7833                }
7834            }
7835            LogicalItem::Ruby {
7836                source,
7837                base_text,
7838                ruby_text,
7839                style,
7840            } => {
7841                // CSS Ruby Layout (§3): the annotation (ruby-text) is laid out at its used
7842                // `font-size` — the UA default is `RUBY_ANNOTATION_FONT_SCALE` of the base —
7843                // and centered over the base, with the ruby box reserving the WIDER of the
7844                // two inline-sizes and stacking the annotation line above the base line.
7845                //
7846                // Both the base and the annotation are shaped to obtain their REAL inline
7847                // advances (no `chars * font_size * 0.6` fudge). The annotation is shaped at
7848                // the scaled style so its width reflects the smaller glyphs.
7849                let base_font_size = style.font_size_px;
7850                let annotation_font_size = base_font_size * RUBY_ANNOTATION_FONT_SCALE;
7851
7852                let mut annotation_props = (**style).clone();
7853                annotation_props.font_size_px = annotation_font_size;
7854                let annotation_style = Arc::new(annotation_props);
7855
7856                // Fallback estimate (only when shaping fails / no font chain): 1em per char
7857                // is a closer CJK approximation than the old 0.6 ratio.
7858                let base_width = measure_run_advance(
7859                    base_text, style, item.script, *source, font_chain_cache, fc_cache,
7860                    loaded_fonts,
7861                )
7862                .unwrap_or_else(|| base_text.chars().count() as f32 * base_font_size);
7863                let annotation_width = measure_run_advance(
7864                    ruby_text, &annotation_style, item.script, *source, font_chain_cache,
7865                    fc_cache, loaded_fonts,
7866                )
7867                .unwrap_or_else(|| ruby_text.chars().count() as f32 * annotation_font_size);
7868
7869                let base_line_height =
7870                    style.line_height.resolve(base_font_size, 0.0, 0.0, 0.0, 0);
7871                let annotation_line_height = annotation_style.line_height.resolve(
7872                    annotation_font_size, 0.0, 0.0, 0.0, 0,
7873                );
7874                // The ruby box reserves the wider inline-size, and stacks the annotation
7875                // line (at its smaller font-size) above the base line.
7876                let (reserved_width, reserved_height) = ruby_reserved_box(
7877                    base_width,
7878                    annotation_width,
7879                    base_line_height,
7880                    annotation_line_height,
7881                );
7882
7883                // TODO2: the annotation glyphs are now correctly sized + reserve vertical
7884                // space above the base, but are not yet emitted as a separately positioned
7885                // (centered) run — `ShapedItem::Object` carries only the base `StyledRun`.
7886                // Rendering the centered annotation needs a ruby-aware `ShapedItem` variant
7887                // (rendering-structural change); deferred to keep this change layout-safe.
7888                shaped.push(ShapedItem::Object {
7889                    source: *source,
7890                    bounds: Rect {
7891                        x: 0.0,
7892                        y: 0.0,
7893                        width: reserved_width,
7894                        height: reserved_height,
7895                    },
7896                    baseline_offset: 0.0,
7897                    content: InlineContent::Text(StyledRun {
7898                        text: base_text.clone(),
7899                        style: style.clone(),
7900                        logical_start_byte: 0,
7901                        source_node_id: None,
7902                    }),
7903                });
7904            }
7905            LogicalItem::CombinedText {
7906                style,
7907                source,
7908                text,
7909            } => {
7910                let language = script_to_language(item.script, &item.text);
7911
7912                // +spec:width-calculation:657f75 - convert full-width chars to non-full-width before compression
7913                // +spec:width-calculation:d0a295 - full-width digit conversion example (e.g. "23" stays narrow)
7914                // When combined text has more than one typographic character unit,
7915                // full-width characters (U+FF01..U+FF5E) are converted to their
7916                // ASCII equivalents (U+0021..U+007E) before compression.
7917                let text = if text.chars().count() > 1 {
7918                    let converted: String = text.chars().map(|c| {
7919                        let cp = c as u32;
7920                        if (0xFF01..=0xFF5E).contains(&cp) {
7921                            // Reverse of text-transform: full-width
7922                            char::from_u32(cp - 0xFF01 + 0x0021).unwrap_or(c)
7923                        } else {
7924                            c
7925                        }
7926                    }).collect();
7927                    converted
7928                } else {
7929                    text.clone()
7930                };
7931
7932                // +spec:width-calculation:1ed84d - OpenType compression (half-width/third-width substitution)
7933                // is delegated to the font shaping layer via shape_text()
7934
7935                // Shape CombinedText using either FontRef directly or fontconfig-resolved font
7936                let glyphs: Vec<Glyph> = match &style.font_stack {
7937                    FontStack::Ref(font_ref) => {
7938                        // For FontRef, use the font directly without fontconfig
7939                        if let Some(msgs) = debug_messages {
7940                            msgs.push(LayoutDebugMessage::info(format!(
7941                                "[TextLayout] Using direct FontRef for CombinedText: '{}'",
7942                                text.chars().take(30).collect::<String>()
7943                            )));
7944                        }
7945                        font_ref.shape_text(
7946                            &text,
7947                            item.script,
7948                            language,
7949                            BidiDirection::Ltr,
7950                            style.as_ref(),
7951                        )?
7952                    }
7953                    FontStack::Stack(selectors) => {
7954                        // Build FontChainKey and resolve through fontconfig
7955                        let cache_key = FontChainKey::from_selectors(selectors);
7956
7957                        let Some(font_chain) = font_chain_cache.get(&cache_key) else {
7958                            if let Some(msgs) = debug_messages {
7959                                msgs.push(LayoutDebugMessage::warning(format!(
7960                                    "[TextLayout] Font chain not pre-resolved for CombinedText {:?}",
7961                                    cache_key.font_families
7962                                )));
7963                            }
7964                            idx += 1;
7965                            continue;
7966                        };
7967
7968                        // Per-character font fallback for CombinedText
7969                        let segments = split_text_by_font_coverage(&text, font_chain, fc_cache, loaded_fonts);
7970                        let mut all_glyphs = Vec::new();
7971                        for (seg_start, seg_end, font_id) in &segments {
7972                            let Some(font) = loaded_fonts.get(font_id) else { continue; };
7973                            let segment_text = &text[*seg_start..*seg_end];
7974                            let mut seg_glyphs = font.shape_text(
7975                                segment_text,
7976                                item.script,
7977                                language,
7978                                BidiDirection::Ltr,
7979                                style.as_ref(),
7980                            )?;
7981                            // Fix byte offsets for glyphs
7982                            if *seg_start > 0 {
7983                                for g in &mut seg_glyphs {
7984                                    g.logical_byte_index += *seg_start;
7985                                    g.cluster += *seg_start as u32;
7986                                }
7987                            }
7988                            all_glyphs.extend(seg_glyphs);
7989                        }
7990                        if all_glyphs.is_empty() {
7991                            idx += 1;
7992                            continue;
7993                        }
7994                        all_glyphs
7995                    }
7996                };
7997
7998                let shaped_glyphs: ShapedGlyphVec = glyphs
7999                    .into_iter()
8000                    .map(|g| ShapedGlyph {
8001                        kind: GlyphKind::Character,
8002                        glyph_id: g.glyph_id,
8003                        script: g.script,
8004                        font_hash: g.font_hash,
8005                        font_metrics: g.font_metrics,
8006                        style: g.style,
8007                        cluster_offset: 0,
8008                        advance: g.advance,
8009                        kerning: g.kerning,
8010                        offset: g.offset,
8011                        vertical_advance: g.vertical_advance,
8012                        vertical_offset: g.vertical_bearing,
8013                    })
8014                    .collect();
8015
8016                // +spec:block-formatting-context:dc4549 - text-combine-upright compression: UA may scale composition to match 水 advance height
8017                let total_width: f32 = shaped_glyphs.iter().map(|g| g.advance + g.kerning).sum();
8018                // +spec:inline-formatting-context:8c5969 - text-combine-upright baseline centering
8019                // The composition forms a 1em square. Per spec, its baseline must be
8020                // chosen so the square is centered between the text-over and text-under
8021                // baselines of the parent inline box. We approximate by using font_size
8022                // as the square height and centering it (baseline_offset = em_size / 2).
8023                let em_size = shaped_glyphs.first()
8024                    .map_or(style.font_size_px, |g| g.style.font_size_px);
8025                let bounds = Rect {
8026                    x: 0.0,
8027                    y: 0.0,
8028                    width: total_width,
8029                    height: em_size,
8030                };
8031
8032                shaped.push(ShapedItem::CombinedBlock {
8033                    source: *source,
8034                    glyphs: shaped_glyphs,
8035                    bounds,
8036                    baseline_offset: em_size / 2.0,
8037                });
8038            }
8039            LogicalItem::Object {
8040                content, source, ..
8041            } => {
8042                let (bounds, baseline) = measure_inline_object(content)?;
8043                shaped.push(ShapedItem::Object {
8044                    source: *source,
8045                    bounds,
8046                    baseline_offset: baseline,
8047                    content: content.clone(),
8048                });
8049            }
8050            LogicalItem::Break { source, break_info } => {
8051                shaped.push(ShapedItem::Break {
8052                    source: *source,
8053                    break_info: *break_info,
8054                });
8055            }
8056        }
8057        idx += 1;
8058    }
8059
8060    Ok(shaped)
8061}
8062
8063/// Returns true if `c` is a hanging punctuation stop or comma per CSS Text 3 §8.2.1.
8064// +spec:hanging-punctuation - full stop/comma character list per CSS Text 3 §8.2.1
8065const fn is_hanging_punctuation_char(c: char) -> bool {
8066    matches!(c,
8067        ','      | // U+002C COMMA
8068        '.'      | // U+002E FULL STOP
8069        '\u{060C}' | // ARABIC COMMA
8070        '\u{06D4}' | // ARABIC FULL STOP
8071        '\u{3001}' | // IDEOGRAPHIC COMMA
8072        '\u{3002}' | // IDEOGRAPHIC FULL STOP
8073        '\u{FF0C}' | // FULLWIDTH COMMA
8074        '\u{FF0E}' | // FULLWIDTH FULL STOP
8075        '\u{FE50}' | // SMALL COMMA
8076        '\u{FE51}' | // SMALL IDEOGRAPHIC COMMA
8077        '\u{FE52}' | // SMALL FULL STOP
8078        '\u{FF61}' | // HALFWIDTH IDEOGRAPHIC FULL STOP
8079        '\u{FF64}'   // HALFWIDTH IDEOGRAPHIC COMMA
8080    )
8081}
8082
8083/// Helper to check if a cluster contains only hanging punctuation.
8084// +spec:box-model:8bbcd1 - non-zero inline-axis borders/padding between hangable glyph and line edge prevent hanging
8085/// +spec:inline-formatting-context:135be2 - hanging punctuation placed outside the line box
8086/// +spec:intrinsic-sizing:407d8b - hanging glyphs not counted in intrinsic size computation
8087fn is_hanging_punctuation(item: &ShapedItem) -> bool {
8088    if let ShapedItem::Cluster(c) = item {
8089        if c.glyphs.len() == 1 {
8090            c.text.chars().next().is_some_and(is_hanging_punctuation_char)
8091        } else {
8092            false
8093        }
8094    } else {
8095        false
8096    }
8097}
8098
8099#[allow(clippy::cast_possible_truncation)] // bounded pixel/coord/colour/glyph cast
8100#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
8101fn shape_text_correctly<T: ParsedFontTrait>(
8102    text: &str,
8103    script: Script,
8104    language: Language,
8105    direction: BidiDirection,
8106    font: &T, // Changed from &Arc<T>
8107    style: &Arc<StyleProperties>,
8108    source_index: ContentIndex,
8109    source_node_id: Option<NodeId>,
8110) -> Result<Vec<ShapedCluster>, LayoutError> {
8111    unsafe { crate::az_mark(0x60864_u32, 0xC0DE_0864_u32); } // [g123] shape_text_correctly ENTERED
8112    let glyphs = font.shape_text(text, script, language, direction, style.as_ref())?;
8113    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
8114
8115    if glyphs.is_empty() {
8116        return Ok(Vec::new());
8117    }
8118
8119    let mut clusters = Vec::new();
8120
8121    // Group glyphs by cluster ID from the shaper.
8122    let mut current_cluster_glyphs = Vec::new();
8123    let mut cluster_id = glyphs[0].cluster;
8124    let mut cluster_start_byte_in_text = glyphs[0].logical_byte_index;
8125
8126    for glyph in glyphs {
8127        if glyph.cluster != cluster_id {
8128            // Finalize previous cluster
8129            let advance = current_cluster_glyphs
8130                .iter()
8131                .map(|g: &Glyph| g.advance)
8132                .sum();
8133
8134            // Safely extract cluster text - handle cases where byte indices may be out of order
8135            // (can happen with RTL text or complex GSUB reordering)
8136            let (start, end) = if cluster_start_byte_in_text <= glyph.logical_byte_index {
8137                (cluster_start_byte_in_text, glyph.logical_byte_index)
8138            } else {
8139                (glyph.logical_byte_index, cluster_start_byte_in_text)
8140            };
8141            let cluster_text = text.get(start..end).unwrap_or("");
8142
8143            clusters.push(ShapedCluster {
8144                text: cluster_text.to_string(), // Store original text for hyphenation
8145                source_cluster_id: GraphemeClusterId {
8146                    source_run: source_index.run_index,
8147                    start_byte_in_run: cluster_id,
8148                },
8149                source_content_index: source_index,
8150                source_node_id,
8151                glyphs: current_cluster_glyphs
8152                    .iter()
8153                    .map(|g| {
8154                        // Calculate cluster_offset safely
8155                        let cluster_offset = if g.logical_byte_index >= cluster_start_byte_in_text {
8156                            (g.logical_byte_index - cluster_start_byte_in_text) as u32
8157                        } else {
8158                            0
8159                        };
8160                        ShapedGlyph {
8161                            kind: if g.glyph_id == 0 {
8162                                GlyphKind::NotDef
8163                            } else {
8164                                GlyphKind::Character
8165                            },
8166                            glyph_id: g.glyph_id,
8167                            script: g.script,
8168                            font_hash: g.font_hash,
8169                            font_metrics: g.font_metrics,
8170                            style: g.style.clone(),
8171                            cluster_offset,
8172                            advance: g.advance,
8173                            kerning: g.kerning,
8174                            vertical_advance: g.vertical_advance,
8175                            vertical_offset: g.vertical_bearing,
8176                            offset: g.offset,
8177                        }
8178                    })
8179                    .collect(),
8180                advance,
8181                direction,
8182                style: style.clone(),
8183                marker_position_outside: None,
8184                is_first_fragment: true,
8185                is_last_fragment: true,
8186            });
8187            current_cluster_glyphs.clear();
8188            cluster_id = glyph.cluster;
8189            cluster_start_byte_in_text = glyph.logical_byte_index;
8190        }
8191        current_cluster_glyphs.push(glyph);
8192    }
8193
8194    // Finalize the last cluster
8195    if !current_cluster_glyphs.is_empty() {
8196        let advance = current_cluster_glyphs
8197            .iter()
8198            .map(|g: &Glyph| g.advance)
8199            .sum();
8200        let cluster_text = text.get(cluster_start_byte_in_text..).unwrap_or("");
8201        clusters.push(ShapedCluster {
8202            text: cluster_text.to_string(), // Store original text
8203            source_cluster_id: GraphemeClusterId {
8204                source_run: source_index.run_index,
8205                start_byte_in_run: cluster_id,
8206            },
8207            source_content_index: source_index,
8208            source_node_id,
8209            glyphs: current_cluster_glyphs
8210                .iter()
8211                .map(|g| {
8212                    // Calculate cluster_offset safely
8213                    let cluster_offset = if g.logical_byte_index >= cluster_start_byte_in_text {
8214                        (g.logical_byte_index - cluster_start_byte_in_text) as u32
8215                    } else {
8216                        0
8217                    };
8218                    ShapedGlyph {
8219                        kind: if g.glyph_id == 0 {
8220                            GlyphKind::NotDef
8221                        } else {
8222                            GlyphKind::Character
8223                        },
8224                        glyph_id: g.glyph_id,
8225                        font_hash: g.font_hash,
8226                        font_metrics: g.font_metrics,
8227                        style: g.style.clone(),
8228                        script: g.script,
8229                        vertical_advance: g.vertical_advance,
8230                        vertical_offset: g.vertical_bearing,
8231                        cluster_offset,
8232                        advance: g.advance,
8233                        kerning: g.kerning,
8234                        offset: g.offset,
8235                    }
8236                })
8237                .collect(),
8238            advance,
8239            direction,
8240            style: style.clone(),
8241            marker_position_outside: None,
8242            is_first_fragment: true,
8243            is_last_fragment: true,
8244        });
8245    }
8246
8247    Ok(clusters)
8248}
8249
8250/// Measures a non-text object, returning its bounds and baseline offset.
8251fn measure_inline_object(item: &InlineContent) -> Result<(Rect, f32), LayoutError> {
8252    match item {
8253        InlineContent::Image(img) => {
8254            let size = img.display_size.unwrap_or(img.intrinsic_size);
8255            Ok((
8256                Rect {
8257                    x: 0.0,
8258                    y: 0.0,
8259                    width: size.width,
8260                    height: size.height,
8261                },
8262                img.baseline_offset,
8263            ))
8264        }
8265        InlineContent::Shape(shape) => Ok({
8266            let size = shape.shape_def.get_size();
8267            (
8268                Rect {
8269                    x: 0.0,
8270                    y: 0.0,
8271                    width: size.width,
8272                    height: size.height,
8273                },
8274                shape.baseline_offset,
8275            )
8276        }),
8277        InlineContent::Space(space) => Ok((
8278            Rect {
8279                x: 0.0,
8280                y: 0.0,
8281                width: space.width,
8282                height: 0.0,
8283            },
8284            0.0,
8285        )),
8286        InlineContent::Marker { .. } => {
8287            // Markers are treated as text content, not measurable objects
8288            Err(LayoutError::InvalidText(
8289                "Marker is text content, not a measurable object".into(),
8290            ))
8291        }
8292        _ => Err(LayoutError::InvalidText("Not a measurable object".into())),
8293    }
8294}
8295
8296// --- Stage 4 Implementation: Vertical Text ---
8297
8298/// Applies orientation and vertical metrics to glyphs if the writing mode is vertical.
8299// +spec:block-formatting-context:227171 - vertical glyph orientation with fallback vertical metrics
8300// +spec:block-formatting-context:df20a5 - mixed vertical orientation dispatch (TextOrientation::Mixed)
8301fn apply_text_orientation(
8302    items: Arc<Vec<ShapedItem>>,
8303    constraints: &UnifiedConstraints,
8304) -> Arc<Vec<ShapedItem>> {
8305    if !constraints.is_vertical() {
8306        return items;
8307    }
8308
8309    let mut oriented_items = Vec::with_capacity(items.len());
8310    let writing_mode = constraints.writing_mode.unwrap_or_default();
8311
8312    for item in items.iter() {
8313        match item {
8314            ShapedItem::Cluster(cluster) => {
8315                let mut new_cluster = cluster.clone();
8316                let mut total_vertical_advance = 0.0;
8317
8318                for glyph in &mut new_cluster.glyphs {
8319                    // Use the vertical metrics already computed during shaping
8320                    // If they're zero, use fallback values
8321                    if glyph.vertical_advance > 0.0 {
8322                        total_vertical_advance += glyph.vertical_advance;
8323                    } else {
8324                        // Fallback: use line height for vertical advance
8325                        let fallback_advance = cluster.style.line_height.resolve_with_metrics(cluster.style.font_size_px, &glyph.font_metrics);
8326                        glyph.vertical_advance = fallback_advance;
8327                        // Center the glyph horizontally as a fallback
8328                        glyph.vertical_offset = Point {
8329                            x: -glyph.advance / 2.0,
8330                            y: 0.0,
8331                        };
8332                        total_vertical_advance += fallback_advance;
8333                    }
8334                }
8335                // The cluster's `advance` now represents vertical advance.
8336                new_cluster.advance = total_vertical_advance;
8337                oriented_items.push(ShapedItem::Cluster(new_cluster));
8338            }
8339            // Non-text objects also need their advance axis swapped.
8340            ShapedItem::Object {
8341                source,
8342                bounds,
8343                baseline_offset,
8344                content,
8345            } => {
8346                let mut new_bounds = *bounds;
8347                std::mem::swap(&mut new_bounds.width, &mut new_bounds.height);
8348                oriented_items.push(ShapedItem::Object {
8349                    source: *source,
8350                    bounds: new_bounds,
8351                    baseline_offset: *baseline_offset,
8352                    content: content.clone(),
8353                });
8354            }
8355            _ => oriented_items.push(item.clone()),
8356        }
8357    }
8358
8359    Arc::new(oriented_items)
8360}
8361
8362// --- Stage 5 & 6 Implementation: Combined Layout Pass ---
8363// This section replaces the previous simple line breaking and positioning logic.
8364
8365/// Extracts the per-item vertical-align from a `ShapedItem`.
8366///
8367/// For `Object` items (inline-blocks, images), this returns the alignment stored
8368/// in the original `InlineContent`. For text clusters and other items, returns `None`
8369/// to indicate the global `constraints.vertical_align` should be used.
8370fn get_item_vertical_align(item: &ShapedItem) -> Option<VerticalAlign> {
8371    match item {
8372        ShapedItem::Object { content, .. } => match content {
8373            InlineContent::Image(img) => Some(img.alignment),
8374            InlineContent::Shape(shape) => Some(shape.alignment),
8375            _ => None,
8376        },
8377        // A text cluster carries its span's vertical-align on its style. A non-baseline
8378        // value (sub / super / length / percentage on an inline <span>) overrides the
8379        // line's default alignment so the cluster is shifted; baseline yields None so the
8380        // cluster keeps the line/IFC default.
8381        ShapedItem::Cluster(c) => match c.style.vertical_align {
8382            VerticalAlign::Baseline => None,
8383            va => Some(va),
8384        },
8385        _ => None,
8386    }
8387}
8388
8389/// Approximate version of `get_item_vertical_metrics` for use without constraints (e.g. `bounds()`).
8390/// Uses 80/20 ascent/descent ratio as fallback for empty-glyph strut case.
8391#[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
8392#[must_use] pub fn get_item_vertical_metrics_approx(item: &ShapedItem) -> (f32, f32) {
8393    // For non-empty clusters, delegate to the font-metrics-based calculation
8394    if let ShapedItem::Cluster(c) = item {
8395        if !c.glyphs.is_empty() {
8396            // Reuse the glyph-based calculation (same as get_item_vertical_metrics)
8397            let (asc, desc) = c.glyphs
8398                .iter()
8399                .fold((0.0f32, 0.0f32), |(max_asc, max_desc), glyph| {
8400                    let metrics = &glyph.font_metrics;
8401                    if metrics.units_per_em == 0 {
8402                        return (max_asc, max_desc);
8403                    }
8404                    let scale = glyph.style.font_size_px / f32::from(metrics.units_per_em);
8405                    let font_ascent = metrics.ascent * scale;
8406                    let font_descent = (-metrics.descent * scale).max(0.0);
8407                    let ad = font_ascent + font_descent;
8408                    let resolved_lh = c.style.line_height.resolve_with_metrics(glyph.style.font_size_px, &glyph.font_metrics);
8409                    let half_leading = (resolved_lh - ad) / 2.0;
8410                    (max_asc.max(font_ascent + half_leading), max_desc.max(font_descent + half_leading))
8411                });
8412            return (asc, desc);
8413        }
8414    }
8415    // Fallback for empty glyphs or non-cluster items
8416    match item {
8417        ShapedItem::Cluster(c) => {
8418            let lh = c.style.line_height.resolve(c.style.font_size_px, 0.0, 0.0, 0.0, 0);
8419            (lh * FALLBACK_ASCENT_RATIO, lh * FALLBACK_DESCENT_RATIO)
8420        }
8421        ShapedItem::CombinedBlock { bounds, .. } => {
8422            (bounds.height * FALLBACK_ASCENT_RATIO, bounds.height * FALLBACK_DESCENT_RATIO)
8423        }
8424        ShapedItem::Object { bounds, .. } => (bounds.height, 0.0),
8425        ShapedItem::Tab { bounds, .. } => {
8426            (bounds.height * FALLBACK_ASCENT_RATIO, bounds.height * FALLBACK_DESCENT_RATIO)
8427        }
8428        ShapedItem::Break { .. } => (0.0, 0.0),
8429    }
8430}
8431
8432/// Gets the ascent (distance from baseline to top) and descent (distance from baseline to bottom)
8433/// for a single item, incorporating half-leading from line-height.
8434///
8435// +spec:box-model:37aeb2 - inline box margins/borders/padding do not affect line box height (leading model)
8436// +spec:display-property:184f0d - Inline box baseline derives from first available font metrics
8437// +spec:display-property:238bf5 - Inline box layout bounds from own text metrics, not child boxes
8438// +spec:display-property:29b194 - baseline determination for inline boxes (CSS Box Alignment 3 §9.1)
8439// +spec:display-property:2987db - per-glyph font metrics impact inline box layout bounds (line-height: normal caveat not yet distinguished)
8440/// +spec:display-property:fd42a9 - line-height affects line box contribution, not inline box size
8441// +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
8442// +spec:font-metrics:773029 - ascent/descent font metrics used for baseline calculations (visual centering depends on these)
8443// +spec:font-metrics:f42870 - half-leading model: leading = line-height - (ascent + descent), distributed equally above/below
8444// +spec:writing-modes:531c2e - UAs should use vertical baseline tables in vertical typographic modes
8445#[must_use] pub fn get_item_vertical_metrics(item: &ShapedItem, constraints: &UnifiedConstraints) -> (f32, f32) {
8446    // (ascent, descent)
8447    match item {
8448        ShapedItem::Cluster(c) => {
8449            if c.glyphs.is_empty() {
8450                // +spec:display-property:626c86 - strut for inline box with no glyphs uses first available font metrics
8451                // +spec:line-height:0078fa - strut: zero-width inline box with element's font/line-height
8452                // §10.8.1 strut: if inline box contains no glyphs, it is considered to
8453                // contain a strut with A and D of the element's first available font.
8454                // Half-leading: L = line-height - (A + D), A' = A + L/2, D' = D + L/2
8455                let ad = constraints.strut_ascent + constraints.strut_descent;
8456                let resolved_lh = c.style.line_height.resolve(c.style.font_size_px, 0.0, 0.0, 0.0, 0);
8457                let half_leading = (resolved_lh - ad) / 2.0;
8458                return (constraints.strut_ascent + half_leading, constraints.strut_descent + half_leading);
8459            }
8460            // +spec:box-model:0b3e1f - inline non-replaced box height uses only line-height, not vertical padding/border/margin
8461            // +spec:display-property:80b900 - fallback glyphs affect line box size via per-glyph metrics
8462            // +spec:display-property:d52f26 - layout bounds enclose all glyphs from highest A to deepest D
8463            // +spec:font-metrics:387751 - content area uses max ascenders/descenders across all fonts
8464            // +spec:font-metrics:790fd2 - half-leading: L = line-height - (A+D), A' = A + L/2, D' = D + L/2
8465            // +spec:line-height:1ae6f5 - line-height on non-replaced inline: half-leading model
8466            // +spec:line-height:0078fa - half-leading: L = line-height - (A+D), distributed equally above/below
8467            // +spec:line-height:32b3da - half-leading: L = line-height - AD, A' = A + L/2, D' = D + L/2
8468            // §10.8.1: for each glyph determine A, D from font metrics,
8469            // then L = line-height - (A + D), and adjust: A' = A + L/2, D' = D + L/2.
8470            // Note: L may be negative.
8471            // +spec:height-calculation:eb98b5 - multi-font normal line-height uses max across glyph metrics
8472            c.glyphs
8473                .iter()
8474                .fold((0.0f32, 0.0f32), |(max_asc, max_desc), glyph| {
8475                    let metrics = &glyph.font_metrics;
8476                    if metrics.units_per_em == 0 {
8477                        return (max_asc, max_desc);
8478                    }
8479                    let scale = glyph.style.font_size_px / f32::from(metrics.units_per_em);
8480                    let a = metrics.ascent * scale;
8481                    // Descent in OpenType is typically negative, so we negate it to get a positive
8482                    // distance.
8483                    let d = (-metrics.descent * scale).max(0.0);
8484                    let ad = a + d;
8485                    let resolved_lh = glyph.style.line_height.resolve_with_metrics(glyph.style.font_size_px, &glyph.font_metrics);
8486                    let leading = resolved_lh - ad;
8487                    let half_leading = leading / 2.0;
8488                    let item_asc = a + half_leading;
8489                    let item_desc = d + half_leading;
8490                    (max_asc.max(item_asc), max_desc.max(item_desc))
8491                })
8492        }
8493        ShapedItem::Object {
8494            bounds,
8495            baseline_offset,
8496            ..
8497        } => {
8498            // Per analysis, `baseline_offset` is the distance from the bottom.
8499            // bounds.height already includes margins (set from margin_box_height in fc.rs)
8500            let ascent = bounds.height - *baseline_offset;
8501            let descent = *baseline_offset;
8502            (ascent.max(0.0), descent.max(0.0))
8503        }
8504        ShapedItem::CombinedBlock {
8505            bounds,
8506            baseline_offset,
8507            ..
8508        } => {
8509            // CORRECTED: Treat baseline_offset consistently as distance from the bottom (descent).
8510            let ascent = bounds.height - *baseline_offset;
8511            let descent = *baseline_offset;
8512            (ascent.max(0.0), descent.max(0.0))
8513        }
8514        _ => (0.0, 0.0), // Breaks and other non-visible items don't affect line height.
8515    }
8516}
8517
8518// +spec:block-formatting-context:861155 - vertical-align affects vertical positioning inside line box for inline-level elements
8519/// Calculates the maximum ascent and descent for an entire line of items.
8520/// This determines the "line box" used for vertical alignment.
8521/// // +spec:display-contents:66d910 - line box height fitted to contents, controlled by line-height
8522// +spec:inline-formatting-context:c3fc54 - line box tall enough for all boxes, vertical-align determines alignment within line box
8523///
8524/// Per CSS 2.2 §10.8: Inline-level boxes aligned 'top' or 'bottom' must be aligned
8525/// so as to minimize the line box height. The algorithm is:
8526/// 1. First pass: compute line box height from baseline-aligned items only
8527///    (baseline, sub, super, middle, text-top, text-bottom, offset).
8528/// 2. Second pass: check if any top/bottom-aligned items are taller than the
8529///    line box from pass 1, and expand if necessary.
8530// +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)
8531fn calculate_line_metrics(
8532    items: &[ShapedItem],
8533    default_vertical_align: VerticalAlign,
8534    constraints: &UnifiedConstraints,
8535) -> (f32, f32) {
8536    // +spec:font-metrics:95152b - baseline alignment: items with different font sizes aligned by matching alphabetic baselines
8537    // Pass 1: Compute ascent/descent from baseline-aligned items only
8538    // (i.e., items that are NOT vertical-align: top or bottom).
8539    let (mut max_asc, mut max_desc) = items
8540        .iter()
8541        .fold((0.0f32, 0.0f32), |(max_asc, max_desc), item| {
8542            let effective_align = get_item_vertical_align(item)
8543                .unwrap_or(default_vertical_align);
8544            match effective_align {
8545                VerticalAlign::Top | VerticalAlign::Bottom => {
8546                    // Skip top/bottom items in first pass
8547                    (max_asc, max_desc)
8548                }
8549                _ => {
8550                    let (item_asc, item_desc) = get_item_vertical_metrics(item, constraints);
8551                    (max_asc.max(item_asc), max_desc.max(item_desc))
8552                }
8553            }
8554        });
8555
8556    let baseline_line_height = max_asc + max_desc;
8557
8558    // Pass 2: Check top/bottom aligned items. If any of them is taller
8559    // than the current line box, expand the line box to fit.
8560    for item in items {
8561        let effective_align = get_item_vertical_align(item)
8562            .unwrap_or(default_vertical_align);
8563        match effective_align {
8564            VerticalAlign::Top | VerticalAlign::Bottom => {
8565                let (item_asc, item_desc) = get_item_vertical_metrics(item, constraints);
8566                let item_height = item_asc + item_desc;
8567                if item_height > baseline_line_height {
8568                    // To minimize height, expand in the direction the item is aligned to
8569                    if effective_align == VerticalAlign::Top {
8570                        // Top-aligned item extends downward from line top
8571                        max_desc = max_desc.max(item_height - max_asc);
8572                    } else {
8573                        // Bottom-aligned item extends upward from line bottom
8574                        max_asc = max_asc.max(item_height - max_desc);
8575                    }
8576                }
8577            }
8578            _ => {} // Already handled in first pass
8579        }
8580    }
8581
8582    (max_asc, max_desc)
8583}
8584
8585/// Unicode Bidi Algorithm rule L2, applied at the glyph/cluster level for one line.
8586///
8587/// `reorder_logical_items` already placed the level RUNS of the paragraph in
8588/// visual order (rule L2 at the run level, via `unicode_bidi::visual_runs`), but
8589/// left the clusters *within* each run in LOGICAL order. To finish L2 the clusters
8590/// of every RTL (odd-level) run must be reversed so the run reads right-to-left.
8591///
8592/// We reverse each maximal contiguous run of clusters that share the same
8593/// direction, flipping only the RTL ones. Re-running full L2 over the whole line
8594/// instead would double-reverse the run order that is already correct. Grouping
8595/// by direction is exact here: under implicit bidi (no explicit embedding
8596/// controls, which azul does not inject) two runs of the same direction are never
8597/// visually adjacent — a higher even level nests inside its odd parent and a lower
8598/// level separates two same-parity runs — so a "same-direction" group is always a
8599/// single real level run. Non-cluster items (breaks/objects/tabs) act as run
8600/// boundaries. Applied per line, so a wrapped RTL run reorders correctly per line.
8601fn apply_l2_visual_reversal(line_items: &mut [ShapedItem]) {
8602    let dir_of = |it: &ShapedItem| it.as_cluster().map(|c| c.direction);
8603    let mut i = 0;
8604    while i < line_items.len() {
8605        let Some(dir) = dir_of(&line_items[i]) else {
8606            i += 1;
8607            continue;
8608        };
8609        let mut j = i + 1;
8610        while j < line_items.len() && dir_of(&line_items[j]) == Some(dir) {
8611            j += 1;
8612        }
8613        if dir == BidiDirection::Rtl {
8614            line_items[i..j].reverse();
8615        }
8616        i = j;
8617    }
8618}
8619
8620/// Performs layout for a single fragment, consuming items from a `BreakCursor`.
8621///
8622/// This function contains the core line-breaking and positioning logic, but is
8623/// designed to operate on a portion of a larger content stream and within the
8624/// constraints of a single geometric area (a fragment).
8625///
8626/// The loop terminates when either the fragment is filled (e.g., runs out of
8627/// vertical space) or the content stream managed by the `cursor` is exhausted.
8628///
8629/// # CSS Inline Layout Module Level 3 Implementation
8630///
8631/// This function implements the inline formatting context as described in:
8632/// <https://www.w3.org/TR/css-inline-3/#inline-formatting-context>
8633///
8634/// ## § 2.1 Layout of Line Boxes
8635/// "In general, the line-left edge of a line box touches the line-left edge of its
8636/// containing block and the line-right edge touches the line-right edge of its
8637/// containing block, and thus the logical width of a line box is equal to the inner
8638/// logical width of its containing block."
8639///
8640/// [ISSUE] `available_width` should be set to the containing block's inner width,
8641/// but is currently defaulting to 0.0 in `UnifiedConstraints::default()`.
8642/// This causes premature line breaking.
8643///
8644/// ## § 2.2 Layout Within Line Boxes
8645/// The layout process follows these steps:
8646/// 1. Baseline Alignment: All inline-level boxes are aligned by their baselines
8647/// 2. Content Size Contribution: Calculate layout bounds for each box
8648/// 3. Line Box Sizing: Size line box to fit aligned layout bounds
8649/// 4. Content Positioning: Position boxes within the line box
8650///
8651/// ## Missing Features:
8652/// - § 3 Baselines and Alignment Metrics: Only basic baseline alignment implemented
8653/// - § 4 Baseline Alignment: vertical-align property not fully supported
8654/// - § 5 Line Spacing: line-height implemented, but line-fit-edge missing
8655/// - § 6 Trimming Leading: text-box-trim not implemented
8656#[allow(clippy::cast_precision_loss)] // bounded pixel/coord/colour/glyph cast
8657#[allow(clippy::too_many_lines, clippy::cognitive_complexity)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
8658/// # Errors
8659///
8660/// Returns a `LayoutError` if fragment layout fails.
8661pub fn perform_fragment_layout<T: ParsedFontTrait>(
8662    cursor: &mut BreakCursor<'_>,
8663    logical_items: &[LogicalItem],
8664    fragment_constraints: &UnifiedConstraints,
8665    debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
8666    fonts: &LoadedFonts<T>,
8667) -> Result<UnifiedLayout, LayoutError> {
8668    const MAX_EMPTY_SEGMENTS: usize = 1000; // Maximum allowed consecutive empty segments
8669    if let Some(msgs) = debug_messages {
8670        msgs.push(LayoutDebugMessage::info(
8671            "\n--- Entering perform_fragment_layout ---".to_string(),
8672        ));
8673        msgs.push(LayoutDebugMessage::info(format!(
8674            "Constraints: available_width={:?}, available_height={:?}, columns={}, text_wrap={:?}",
8675            fragment_constraints.available_width,
8676            fragment_constraints.available_height,
8677            fragment_constraints.columns,
8678            fragment_constraints.text_wrap
8679        )));
8680    }
8681
8682    // For TextWrap::Balance, use Knuth-Plass algorithm for optimal line breaking
8683    // This produces more visually balanced lines at the cost of more computation
8684    if fragment_constraints.text_wrap == TextWrap::Balance {
8685        if let Some(msgs) = debug_messages {
8686            msgs.push(LayoutDebugMessage::info(
8687                "Using Knuth-Plass algorithm for text-wrap: balance".to_string(),
8688            ));
8689        }
8690
8691        // Get the shaped items from the cursor
8692        let shaped_items: Vec<ShapedItem> = cursor.drain_remaining();
8693
8694        // +spec:line-breaking:90c1bd - only auto-hyphenate when language is known and hyphenation resource available
8695        let hyphenator = if fragment_constraints.hyphenation == Hyphens::Auto {
8696            fragment_constraints
8697                .hyphenation_language
8698                .and_then(|lang| get_hyphenator(lang).ok())
8699        } else {
8700            None
8701        };
8702
8703        // Use the Knuth-Plass algorithm for optimal line breaking
8704        return Ok(crate::text3::knuth_plass::kp_layout(
8705            &shaped_items,
8706            logical_items,
8707            fragment_constraints,
8708            hyphenator.as_ref(),
8709            fonts,
8710        ));
8711    }
8712
8713    // +spec:intrinsic-sizing:57e02d - hyphenation opportunities considered in min-content sizing
8714    let hyphenator = if fragment_constraints.hyphenation == Hyphens::Auto {
8715        fragment_constraints
8716            .hyphenation_language
8717            .and_then(|lang| get_hyphenator(lang).ok())
8718    } else {
8719        None
8720    };
8721
8722    let mut positioned_items = Vec::new();
8723    let mut layout_bounds = Rect::default();
8724
8725    let num_columns = fragment_constraints.columns.max(1);
8726    let total_column_gap = fragment_constraints.column_gap * (num_columns - 1) as f32;
8727
8728    // CSS Inline Layout § 2.1: "the logical width of a line box is equal to the inner
8729    // logical width of its containing block"
8730    //
8731    // Handle the different available space modes:
8732    // - Definite(width): Use the specified width for column calculation
8733    // - MinContent: Force line breaks at word boundaries, return widest word width
8734    // - MaxContent: Use a large value to allow content to expand naturally
8735    //
8736    // IMPORTANT: For MinContent, we do NOT use 0.0 (which would break after every character).
8737    // Instead, we use a large width but track the is_min_content flag to force word-level
8738    // line breaks in the line breaker. The actual min-content width is the width of the
8739    // widest resulting line (typically the widest word).
8740    let is_min_content = matches!(fragment_constraints.available_width, AvailableSpace::MinContent);
8741    let is_max_content = matches!(fragment_constraints.available_width, AvailableSpace::MaxContent);
8742    
8743    let column_width = match fragment_constraints.available_width {
8744        AvailableSpace::Definite(width) => (width - total_column_gap) / num_columns as f32,
8745        AvailableSpace::MinContent | AvailableSpace::MaxContent => {
8746            // For intrinsic sizing, use a large width to measure actual content width.
8747            // The line breaker will handle MinContent specially by breaking after each word.
8748            f32::MAX / 2.0
8749        }
8750    };
8751    let mut current_column = 0;
8752    if let Some(msgs) = debug_messages {
8753        msgs.push(LayoutDebugMessage::info(format!(
8754            "Column width calculated: {column_width}"
8755        )));
8756    }
8757
8758    // Use the CSS direction from constraints instead of auto-detecting from text
8759    // This ensures that mixed-direction text (e.g., "مرحبا - Hello") uses the
8760    // correct paragraph-level direction for alignment purposes.
8761    // With unicode-bidi: plaintext, direction is auto-detected from text content
8762    // per CSS Writing Modes §8.3.
8763    let base_direction = if fragment_constraints.unicode_bidi == UnicodeBidi::Plaintext {
8764        // Auto-detect from remaining shaped items' text content
8765        let remaining = &cursor.items[cursor.next_item_index..];
8766        let text: String = remaining.iter()
8767            .filter_map(|i| i.as_cluster())
8768            .map(|c| c.text.as_str())
8769            .collect();
8770        match unicode_bidi::get_base_direction(text.as_str()) {
8771            unicode_bidi::Direction::Ltr => BidiDirection::Ltr,
8772            unicode_bidi::Direction::Rtl => BidiDirection::Rtl,
8773            // No strong character: fall back to containing block direction
8774            unicode_bidi::Direction::Mixed => fragment_constraints.direction.unwrap_or(BidiDirection::Ltr),
8775        }
8776    } else {
8777        fragment_constraints.direction.unwrap_or(BidiDirection::Ltr)
8778    };
8779
8780    if let Some(msgs) = debug_messages {
8781        msgs.push(LayoutDebugMessage::info(format!(
8782            "[PFLayout] Base direction: {:?} (from CSS), Text align: {:?}",
8783            base_direction, fragment_constraints.text_align
8784        )));
8785    }
8786
8787    // +spec:multi-column - column-fill:balance (the initial/default value): content is
8788    // balanced so the columns are as short and as equal in height as possible. The column loop
8789    // below only advances to the next column once a column reaches `available_height` — but a
8790    // block on a page is handed the whole page height as available space, which the short
8791    // content never reaches, so every line lands in column 0 (a single visual column). Fix:
8792    // measure the total line count up front (a cheap dry run of the line breaker over a CLONED
8793    // cursor at the column width) and give each column an equal share of lines; the balanced
8794    // budget (content_lines / N) is far below the page-height threshold so it takes precedence.
8795    // Gated on num_columns>1 with no shape boundaries and non-intrinsic sizing — exactly the
8796    // otherwise-broken case — so single-column and shaped/intrinsic layouts are untouched
8797    // (zero blast radius). column-fill:auto (fill-then-advance) is rare and not modelled here.
8798    let balanced_lines_per_column: Option<usize> = if num_columns > 1
8799        && fragment_constraints.shape_boundaries.is_empty()
8800        && !is_min_content
8801        && !is_max_content
8802    {
8803        let mut probe = cursor.clone();
8804        let mut probe_col_constraints = fragment_constraints.clone();
8805        probe_col_constraints.available_width = AvailableSpace::Definite(column_width);
8806        let probe_line_height = fragment_constraints.resolved_line_height();
8807        // A line consumes at least one shaped item, so the item count bounds the loop.
8808        let iter_cap = probe.items.len().saturating_mul(4).max(64);
8809        let mut total_lines = 0usize;
8810        let mut probe_y = 0.0_f32;
8811        let mut probe_guard = 0usize;
8812        while !probe.is_done() && probe_guard < iter_cap {
8813            probe_guard += 1;
8814            let lc = get_line_constraints(probe_y, probe_line_height, &probe_col_constraints, &mut None);
8815            if lc.segments.is_empty() {
8816                break;
8817            }
8818            let (probe_line, _) = break_one_line(
8819                &mut probe,
8820                &lc,
8821                false,
8822                hyphenator.as_ref(),
8823                fonts,
8824                fragment_constraints.line_break,
8825                fragment_constraints.white_space_mode,
8826                fragment_constraints.overflow_wrap,
8827            );
8828            if probe_line.is_empty() {
8829                break;
8830            }
8831            total_lines += 1;
8832            probe_y += probe_line_height;
8833        }
8834        (total_lines > 0).then(|| total_lines.div_ceil(num_columns as usize).max(1))
8835    } else {
8836        None
8837    };
8838
8839    'column_loop: while current_column < num_columns {
8840        if let Some(msgs) = debug_messages {
8841            msgs.push(LayoutDebugMessage::info(format!(
8842                "\n-- Starting Column {current_column} --"
8843            )));
8844        }
8845        let column_start_x =
8846            (column_width + fragment_constraints.column_gap) * current_column as f32;
8847        let mut line_top_y = 0.0;
8848        let mut line_index = 0;
8849        let mut empty_segment_count = 0; // Failsafe counter for infinite loops
8850        let mut is_after_forced_break = false;
8851        // +spec:writing-modes:6e22a7 - vertical-rl advances columns (lines) right-to-left.
8852        // The positioner lays every line out at an increasing block-axis (x) offset from 0,
8853        // i.e. left-to-right. For vertical-rl we record each line's block band here so we can
8854        // mirror the block axis once the column's total extent is known (see after the loop).
8855        let column_item_start = positioned_items.len();
8856        let mut line_bands: Vec<(usize, f32, f32)> = Vec::new();
8857
8858        // [g147 az-web-lift] Hard total-iteration cap on the line-build loop. On the remill lift,
8859        // `cursor.is_done()` (or the empty-segment failsafe) mis-lifts for the NESTED IFC (content.len
8860        // reads 0 → the cursor is starved but never reports done) → this `while !cursor.is_done()` spins
8861        // forever → solveLayoutReal HANGS inside perform_fragment_layout. Cap total iterations so the loop
8862        // always converges (the harness can then read the markers). native is unaffected (far above real
8863        // line counts). The 0x60BC4 marker exposes the iteration count.
8864        #[allow(clippy::no_effect_underscore_binding)] // web_lift-gated debug iteration counter
8865        let mut _az_line_iters: usize = 0;
8866        while !cursor.is_done() {
8867            #[cfg(feature = "web_lift")]
8868            {
8869                _az_line_iters += 1;
8870                unsafe { crate::az_mark((0x60BC4) as u32, (_az_line_iters as u32 | 0xC0DE0000) as u32); }
8871                if _az_line_iters > 4096 {
8872                    break;
8873                }
8874            }
8875            if let Some(max_height) = fragment_constraints.available_height {
8876                if line_top_y >= max_height {
8877                    if let Some(msgs) = debug_messages {
8878                        msgs.push(LayoutDebugMessage::info(format!(
8879                            "  Column full (pen {line_top_y} >= height {max_height}), breaking to next column."
8880                        )));
8881                    }
8882                    break;
8883                }
8884            }
8885
8886            if let Some(clamp) = fragment_constraints.line_clamp {
8887                if line_index >= clamp.get() {
8888                    break;
8889                }
8890            }
8891
8892            // +spec:multi-column - column-fill:balance: cap this column at its balanced share of
8893            // lines so content distributes across columns. The LAST column takes whatever remains
8894            // (so integer rounding of the per-column budget never drops content).
8895            if let Some(budget) = balanced_lines_per_column {
8896                if current_column + 1 < num_columns && line_index >= budget {
8897                    break;
8898                }
8899            }
8900
8901            // Create constraints specific to the current column for the line breaker.
8902            let mut column_constraints = fragment_constraints.clone();
8903            // For MinContent/MaxContent, preserve the semantic type so the line breaker
8904            // can handle word-level breaking correctly. Only use Definite for actual widths.
8905            if is_min_content {
8906                column_constraints.available_width = AvailableSpace::MinContent;
8907            } else if is_max_content {
8908                column_constraints.available_width = AvailableSpace::MaxContent;
8909            } else {
8910                column_constraints.available_width = AvailableSpace::Definite(column_width);
8911            }
8912            let line_constraints = get_line_constraints(
8913                line_top_y,
8914                fragment_constraints.resolved_line_height(),
8915                &column_constraints,
8916                debug_messages,
8917            );
8918
8919            if line_constraints.segments.is_empty() {
8920                empty_segment_count += 1;
8921                if let Some(msgs) = debug_messages {
8922                    msgs.push(LayoutDebugMessage::info(format!(
8923                        "  No available segments at y={line_top_y}, skipping to next line. (empty count: \
8924                         {empty_segment_count}/{MAX_EMPTY_SEGMENTS})"
8925                    )));
8926                }
8927
8928                // Failsafe: If we've skipped too many lines without content, break out
8929                if empty_segment_count >= MAX_EMPTY_SEGMENTS {
8930                    if let Some(msgs) = debug_messages {
8931                        msgs.push(LayoutDebugMessage::warning(format!(
8932                            "  [WARN] Reached maximum empty segment count ({MAX_EMPTY_SEGMENTS}). Breaking to \
8933                             prevent infinite loop."
8934                        )));
8935                        msgs.push(LayoutDebugMessage::warning(
8936                            "  This likely means the shape constraints are too restrictive or \
8937                             positioned incorrectly."
8938                                .to_string(),
8939                        ));
8940                        msgs.push(LayoutDebugMessage::warning(format!(
8941                            "  Current y={line_top_y}, shape boundaries might be outside this range."
8942                        )));
8943                    }
8944                    break;
8945                }
8946
8947                // Additional check: If we have shapes and are far beyond the expected height,
8948                // also break to avoid infinite loops
8949                if !fragment_constraints.shape_boundaries.is_empty() && empty_segment_count > 50 {
8950                    // Calculate maximum shape height
8951                    let max_shape_y: f32 = fragment_constraints
8952                        .shape_boundaries
8953                        .iter()
8954                        .map(|shape| {
8955                            match shape {
8956                                ShapeBoundary::Circle { center, radius } => center.y + radius,
8957                                ShapeBoundary::Ellipse { center, radii } => center.y + radii.height,
8958                                ShapeBoundary::Polygon { points } => {
8959                                    points.iter().map(|p| p.y).fold(0.0, f32::max)
8960                                }
8961                                ShapeBoundary::Rectangle(rect) => rect.y + rect.height,
8962                                ShapeBoundary::Path { segments } => segments
8963                                    .iter()
8964                                    .filter_map(|s| match s {
8965                                        PathSegment::MoveTo(p) | PathSegment::LineTo(p) => Some(p.y),
8966                                        PathSegment::CurveTo { end, .. }
8967                                        | PathSegment::QuadTo { end, .. } => Some(end.y),
8968                                        PathSegment::Arc { center, radius, .. } => {
8969                                            Some(center.y + radius)
8970                                        }
8971                                        PathSegment::Close => None,
8972                                    })
8973                                    .fold(0.0, f32::max),
8974                            }
8975                        })
8976                        .fold(0.0, f32::max);
8977
8978                    if line_top_y > max_shape_y + 100.0 {
8979                        if let Some(msgs) = debug_messages {
8980                            msgs.push(LayoutDebugMessage::info(format!(
8981                                "  [INFO] Current y={line_top_y} is far beyond maximum shape extent y={max_shape_y}. \
8982                                 Breaking layout."
8983                            )));
8984                            msgs.push(LayoutDebugMessage::info(
8985                                "  Shape boundaries exist but no segments available - text cannot \
8986                                 fit in shape."
8987                                    .to_string(),
8988                            ));
8989                        }
8990                        break;
8991                    }
8992                }
8993
8994                line_top_y += fragment_constraints.resolved_line_height();
8995                continue;
8996            }
8997
8998            // Reset counter when we find valid segments
8999            empty_segment_count = 0;
9000
9001            // +spec:line-breaking:3bb032 - break-word not considered for min-content intrinsic sizes
9002            // +spec:overflow:b932c4 - overflow-wrap/word-wrap (normal/break-word/anywhere) and hyphens interaction
9003            // `anywhere` introduces soft wrap opportunities (min-content = widest cluster),
9004            // but `break-word` does NOT (min-content = widest unbreakable word).
9005            let effective_overflow_wrap = if is_min_content && fragment_constraints.overflow_wrap == OverflowWrap::Anywhere {
9006                OverflowWrap::Anywhere
9007            } else if is_min_content && fragment_constraints.overflow_wrap == OverflowWrap::BreakWord {
9008                OverflowWrap::Normal
9009            } else {
9010                fragment_constraints.overflow_wrap
9011            };
9012
9013            // CSS Text Module Level 3 § 5 Line Breaking and Word Boundaries
9014            // https://www.w3.org/TR/css-text-3/#line-breaking
9015            // +spec:display-property:2608cc - inline box splitting across line boxes, overflow for unsplittable boxes
9016            // +spec:display-property:ea615c - inline boxes split and distributed across line boxes
9017            // "When an inline box exceeds the logical width of a line box, it is split
9018            // into several fragments, which are partitioned across multiple line boxes."
9019            let (mut line_items, was_hyphenated) =
9020                break_one_line(cursor, &line_constraints, false, hyphenator.as_ref(), fonts, fragment_constraints.line_break, fragment_constraints.white_space_mode, effective_overflow_wrap);
9021            if line_items.is_empty() {
9022                if let Some(msgs) = debug_messages {
9023                    msgs.push(LayoutDebugMessage::info(
9024                        "  Break returned no items. Ending column.".to_string(),
9025                    ));
9026                }
9027                break;
9028            }
9029
9030            let line_text_before_rev: String = line_items
9031                .iter()
9032                .filter_map(|i| i.as_cluster())
9033                .map(|c| c.text.as_str())
9034                .collect();
9035            if let Some(msgs) = debug_messages {
9036                msgs.push(LayoutDebugMessage::info(format!(
9037                    // FIX: The log message was misleading. Items are in visual order.
9038                    "[PFLayout] Line items from breaker (visual order): [{line_text_before_rev}]"
9039                )));
9040            }
9041
9042            // Unicode Bidi rule L2 (glyph-level reversal). `reorder_logical_items`
9043            // already ordered the level RUNS visually; here we reverse the clusters
9044            // within each RTL run so an RTL run reads right-to-left. Applied per line
9045            // (after line breaking) so a wrapped RTL run reorders correctly per line.
9046            apply_l2_visual_reversal(&mut line_items);
9047
9048            if let Some(msgs) = debug_messages {
9049                let after: String = line_items
9050                    .iter()
9051                    .filter_map(|i| i.as_cluster())
9052                    .map(|c| c.text.as_str())
9053                    .collect();
9054                if after != line_text_before_rev {
9055                    msgs.push(LayoutDebugMessage::info(format!(
9056                        "[PFLayout] Line items after L2 reversal: [{after}]"
9057                    )));
9058                }
9059            }
9060
9061            // +spec:line-breaking:c59944 - forced line breaks detected for bidi-aware alignment
9062            let line_ends_with_forced_break = line_items.iter().any(|item| matches!(item, ShapedItem::Break { .. }));
9063
9064            // uses text-align-last (last line of block, or line right before forced break)
9065            let is_last_line = cursor.is_done() && !was_hyphenated;
9066            let effective_align = resolve_effective_alignment(
9067                fragment_constraints.text_align,
9068                fragment_constraints.text_align_last,
9069                is_last_line || line_ends_with_forced_break,
9070            );
9071
9072            let (mut line_pos_items, line_height) = position_one_line(
9073                &line_items,
9074                &line_constraints,
9075                line_top_y,
9076                line_index,
9077                effective_align,
9078                base_direction,
9079                is_last_line,
9080                fragment_constraints,
9081                debug_messages,
9082                fonts,
9083                is_after_forced_break,
9084            );
9085
9086            // Track whether the next line follows a forced break
9087            is_after_forced_break = line_ends_with_forced_break;
9088
9089            for item in &mut line_pos_items {
9090                item.position.x += column_start_x;
9091            }
9092
9093            // +spec:display-property:6c4978 - line-height on block container establishes minimum line box height
9094            let band_height = line_height.max(fragment_constraints.resolved_line_height());
9095            line_bands.push((line_index, line_top_y, band_height));
9096            line_top_y += band_height;
9097            line_index += 1;
9098            positioned_items.extend(line_pos_items);
9099        }
9100
9101        // +spec:writing-modes:6e22a7 - vertical-rl column order: mirror the block axis so the
9102        // FIRST line becomes the RIGHTMOST column and successive lines advance leftward. Each
9103        // line occupies the block band [t, t + h]; after mirroring within the column's total
9104        // block extent `block_extent` the band moves to [block_extent - t - h, block_extent - t],
9105        // which is x += block_extent - 2t - h for every item on that line. The inline (y) axis
9106        // and within-column glyph stacking are untouched. vertical-lr keeps left-to-right order.
9107        if fragment_constraints.writing_mode == Some(WritingMode::VerticalRl) {
9108            let block_extent = line_top_y;
9109            for item in &mut positioned_items[column_item_start..] {
9110                if let Some(&(_, t, h)) =
9111                    line_bands.iter().find(|(li, _, _)| *li == item.line_index)
9112                {
9113                    // delta = block_extent - 2t - h (written without a `2.0 * t`
9114                    // product so the flops lint stays quiet).
9115                    item.position.x += block_extent - t - t - h;
9116                }
9117            }
9118        }
9119        current_column += 1;
9120    }
9121
9122    if let Some(msgs) = debug_messages {
9123        msgs.push(LayoutDebugMessage::info(format!(
9124            "--- Exiting perform_fragment_layout, positioned {} items ---",
9125            positioned_items.len()
9126        )));
9127    }
9128
9129    let mut layout = UnifiedLayout {
9130        items: positioned_items,
9131        overflow: OverflowInfo::default(),
9132    };
9133
9134    // Calculate bounds on demand via the bounds() method
9135    let calculated_bounds = layout.bounds();
9136
9137    // Record the unclipped content bounds. `overflow_items` stays empty by
9138    // design: this positioner places *every* item, so visual overflow is handled
9139    // at paint time via clipping rather than by dropping items here.
9140    // TODO(superplan): only populate `overflow_items` if a future positioning
9141    // path actually discards content that does not fit.
9142    layout.overflow.unclipped_bounds = calculated_bounds;
9143
9144    if let Some(msgs) = debug_messages {
9145        msgs.push(LayoutDebugMessage::info(format!(
9146            "--- Calculated bounds: width={}, height={} ---",
9147            calculated_bounds.width, calculated_bounds.height
9148        )));
9149    }
9150
9151    Ok(layout)
9152}
9153
9154/// Breaks a single line of items to fit within the given geometric constraints,
9155/// handling multi-segment lines and hyphenation.
9156/// Break a single line from the current cursor position.
9157///
9158/// # CSS Text Module Level 3 \u00a7 5 Line Breaking and Word Boundaries
9159/// <https://www.w3.org/TR/css-text-3/#line-breaking>
9160///
9161/// Implements the line breaking algorithm:
9162/// 1. "When an inline box exceeds the logical width of a line box, it is split into several
9163///    fragments, which are partitioned across multiple line boxes."
9164///
9165/// ## \u2705 Implemented Features:
9166/// - **Break Opportunities**: Identifies word boundaries and break points
9167/// - **Soft Wraps**: Wraps at spaces between words
9168/// - **Hard Breaks**: Handles explicit line breaks (\\n)
9169/// - **Overflow**: If a word is too long, places it anyway to avoid infinite loop
9170/// - **Hyphenation**: Tries to break long words at hyphenation points (\u00a7 5.4)
9171///
9172/// ## \u26a0\ufe0f Known Issues:
9173/// - If `line_constraints.total_available` is 0.0 (from `available_width: 0.0` bug), every word
9174///   will overflow, causing single-word lines
9175/// - This is the symptom visible in the PDF: "List items break extremely early"
9176///
9177/// ## \u00a7 5.2 Breaking Rules for Letters
9178/// \u2705 IMPLEMENTED: Uses Unicode line breaking algorithm
9179/// - Relies on UAX #14 for break opportunities
9180/// - Respects non-breaking spaces and zero-width joiners
9181///
9182/// ## \u00a7 5.3 Breaking Rules for Punctuation
9183/// \u26a0\ufe0f PARTIAL: Basic punctuation handling
9184/// - \u274c TODO: hanging-punctuation is declared in `UnifiedConstraints` but not used here
9185/// - \u274c TODO: Should implement punctuation trimming at line edges
9186///   // +spec:intrinsic-sizing:6085cf - hanging glyphs must be excluded from intrinsic size computation
9187///
9188/// ## \u00a7 5.4 Hyphenation
9189/// \u2705 IMPLEMENTED: Automatic hyphenation with hyphenator library
9190/// - Tries to hyphenate words that overflow
9191/// - Inserts hyphen glyph at break point
9192/// - Carries remainder to next line
9193///
9194/// ## \u00a7 5.5 Overflow Wrapping
9195/// \u2705 IMPLEMENTED: Emergency breaking
9196/// - If line is empty and word doesn't fit, forces at least one item
9197/// - Prevents infinite loop
9198/// - This is "overflow-wrap: break-word" behavior
9199///
9200/// # Missing Features:
9201/// - word-break property (normal, break-all, keep-all) - IMPLEMENTED via `BreakCursor.word_break`
9202/// - \u26a0\ufe0f line-break property: anywhere implemented; loose/normal/strict CJK strictness
9203///   filtering added via `is_cjk_break_allowed_by_strictness` (§5.3)
9204/// - \u274c overflow-wrap: anywhere vs break-word distinction
9205/// - \u2705 white-space: break-spaces handling
9206// around every typographic character unit including preserved white spaces; with break-spaces
9207// it allows breaking before the first space of a sequence
9208// +spec:line-breaking:722f3b - wrapping only at soft wrap opportunities, minimizing overflow
9209#[allow(clippy::cognitive_complexity)] // cohesive line-break state machine: one branch per CSS line-break case
9210#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
9211/// # Panics
9212///
9213/// Panics if a break unit is unexpectedly empty (an internal invariant).
9214pub fn break_one_line<T: ParsedFontTrait>(
9215    cursor: &mut BreakCursor<'_>,
9216    line_constraints: &LineConstraints,
9217    is_vertical: bool,
9218    hyphenator: Option<&Standard>,
9219    fonts: &LoadedFonts<T>,
9220    line_break: LineBreakStrictness,
9221    white_space_mode: WhiteSpaceMode,
9222    overflow_wrap: OverflowWrap,
9223) -> (Vec<ShapedItem>, bool) {
9224    let mut line_items = Vec::new();
9225    let mut current_width = 0.0;
9226
9227    if cursor.is_done() {
9228        return (Vec::new(), false);
9229    }
9230
9231    // +spec:white-space-processing:c83dbd - Phase II: collapsible spaces at line start removed, trailing spaces removed, tab stops
9232    // CSS Text Module Level 3 § 4.1.2: At the beginning of a line, white space
9233    // is collapsed away. Skip leading whitespace at line start.
9234    // https://www.w3.org/TR/css-text-3/#white-space-phase-2
9235    // Per CSS Text 3 §4.1.1/§4.1.2, leading white space at line start is collapsed
9236    // ONLY for the collapsing white-space modes. Pre / pre-wrap / break-spaces must
9237    // preserve leading indentation, so only strip for Normal / Nowrap / Pre-line.
9238    let strip_leading = matches!(
9239        white_space_mode,
9240        WhiteSpaceMode::Normal | WhiteSpaceMode::Nowrap | WhiteSpaceMode::PreLine
9241    );
9242    if strip_leading {
9243        while !cursor.is_done() {
9244            let next_unit = cursor.peek_next_unit();
9245            if next_unit.is_empty() {
9246                break;
9247            }
9248            if next_unit.len() == 1 && is_collapsible_whitespace(&next_unit[0]) {
9249                cursor.consume(1);
9250            } else {
9251                break;
9252            }
9253        }
9254    }
9255
9256    // +spec:line-breaking:35817b - white-space: nowrap/pre prevent soft wrap opportunities
9257    // CSS Text Level 3 § 3: For nowrap and pre, wrapping is suppressed. All content
9258    // stays on a single line, overflowing if necessary.
9259    let no_wrap = matches!(white_space_mode, WhiteSpaceMode::Nowrap | WhiteSpaceMode::Pre);
9260
9261    if no_wrap {
9262        // No soft wrapping — consume everything onto one line.
9263        // Only explicit <br>/newline breaks are honored.
9264        loop {
9265            let next_unit = cursor.peek_next_unit();
9266            if next_unit.is_empty() {
9267                break;
9268            }
9269            if let Some(ShapedItem::Break { .. }) = next_unit.first() {
9270                line_items.push(next_unit[0].clone());
9271                cursor.consume(1);
9272                return (line_items, false);
9273            }
9274            line_items.extend_from_slice(&next_unit);
9275            cursor.consume(next_unit.len());
9276        }
9277    } else {
9278
9279    loop {
9280        // typographic character unit as a soft wrap opportunity; hyphenation is not applied
9281        let next_unit = if line_break == LineBreakStrictness::Anywhere {
9282            cursor.peek_next_single_item()
9283        } else {
9284            cursor.peek_next_unit()
9285        };
9286        if next_unit.is_empty() {
9287            break; // End of content
9288        }
9289
9290        if let Some(ShapedItem::Break { .. }) = next_unit.first() {
9291            line_items.push(next_unit[0].clone());
9292            cursor.consume(1);
9293            return (line_items, false);
9294        }
9295
9296        // Min-content: break at EVERY soft-wrap opportunity so each word forms its
9297        // own line (min-content = widest unbreakable unit). `total_available` is a
9298        // sentinel (f32::MAX/2) during intrinsic sizing and never overflows, so
9299        // without this the run would collapse onto one line and min-content would
9300        // wrongly equal max-content. Once the line holds content and the next unit
9301        // is a break opportunity (a space, CJK ideograph, hyphen, …), finish here;
9302        // a leading space is stripped at the next line's start (collapsing modes).
9303        if line_constraints.is_min_content
9304            && !line_items.is_empty()
9305            && next_unit.len() == 1
9306            && is_break_opportunity_with_word_break(&next_unit[0], cursor.word_break, cursor.hyphens)
9307        {
9308            break;
9309        }
9310
9311        let unit_width: f32 = next_unit
9312            .iter()
9313            .map(|item| get_item_measure_with_spacing(item, is_vertical))
9314            .sum();
9315        let available_width = line_constraints.total_available - current_width;
9316
9317        // 2. Can the whole unit fit on the current line?
9318        if unit_width <= available_width {
9319            line_items.extend_from_slice(&next_unit);
9320            current_width += unit_width;
9321            cursor.consume(next_unit.len());
9322        } else {
9323            // 3. The unit overflows. Can we hyphenate it?
9324            if line_break != LineBreakStrictness::Anywhere {
9325                if let Some(hyphenator) = hyphenator {
9326                    if !is_break_opportunity(next_unit.last().unwrap()) {
9327                        if let Some(hyphenation_result) = try_hyphenate_word_cluster(
9328                            &next_unit,
9329                            available_width,
9330                            is_vertical,
9331                            hyphenator,
9332                            fonts,
9333                        ) {
9334                            line_items.extend(hyphenation_result.line_part);
9335                            cursor.consume(next_unit.len());
9336                            cursor.partial_remainder = hyphenation_result.remainder_part;
9337                            return (line_items, true);
9338                        }
9339                    }
9340                }
9341            }
9342
9343            // an otherwise unbreakable sequence at an arbitrary point when no other
9344            // break points exist. Grapheme clusters stay together; no hyphen inserted.
9345            // 4. Cannot hyphenate or fit. The line is finished.
9346            // If the line is empty, we must force at least one item to avoid an infinite loop.
9347            // With overflow-wrap: anywhere or break-word, we break the unbreakable
9348            // unit at an arbitrary cluster boundary. With normal, we only force one
9349            // item to prevent infinite loops (content will overflow).
9350            if line_items.is_empty() {
9351                match overflow_wrap {
9352                    OverflowWrap::Anywhere | OverflowWrap::BreakWord => {
9353                        // Emergency break: fit as many clusters as possible on
9354                        // this line.  Grapheme clusters stay together.
9355                        //
9356                        // Per CSS Text 3 §5.5: "an otherwise unbreakable sequence
9357                        // of characters may be broken at an arbitrary point" when
9358                        // overflow-wrap is anywhere/break-word.
9359                        let avail = line_constraints.total_available;
9360                        for item in &next_unit {
9361                            let item_w = get_item_measure_with_spacing(item, is_vertical);
9362                            // Break BEFORE this item if adding it would overflow,
9363                            // but only if we already have at least one item on the
9364                            // line (must always make progress).
9365                            if !line_items.is_empty() && avail > 0.0 && current_width + item_w > avail {
9366                                break;
9367                            }
9368                            line_items.push(item.clone());
9369                            current_width += item_w;
9370                            // When the container is zero-width (avail <= 0), the
9371                            // break-before check above is skipped (it requires
9372                            // avail > 0), so every item lands on this one line —
9373                            // there's nowhere to break TO, content just overflows.
9374                            // This matches browser behavior for `width: 0`
9375                            // containers.
9376                        }
9377                        let consumed = line_items.len().max(1);
9378                        if line_items.is_empty() {
9379                            line_items.push(next_unit[0].clone());
9380                        }
9381                        cursor.consume(consumed);
9382                    }
9383                    OverflowWrap::Normal => {
9384                        // overflow-wrap:normal keeps an unbreakable word intact and
9385                        // lets it overflow the line box — it must NOT be shredded one
9386                        // grapheme per line. Place the whole unit on this (empty) line.
9387                        line_items.extend_from_slice(&next_unit);
9388                        cursor.consume(next_unit.len());
9389                    }
9390                }
9391            }
9392            break;
9393        }
9394    }
9395
9396    } // end !no_wrap
9397
9398    // +spec:white-space-processing:fef250 - Phase II: trailing collapsible spaces and U+1680 removed at line end
9399    // as well as any trailing U+1680 OGHAM SPACE MARK whose white-space is normal/nowrap/pre-line.
9400    // Note: pre-wrap and break-spaces have different handling (hanging/preserving)
9401    // which is not yet implemented here.
9402    // Trailing collapsible white space is trimmed only for the collapsing modes.
9403    // Pre keeps significant trailing spaces; pre-wrap hangs them (handled in
9404    // position_one_line); break-spaces must never drop them.
9405    let strip_trailing = matches!(
9406        white_space_mode,
9407        WhiteSpaceMode::Normal | WhiteSpaceMode::Nowrap | WhiteSpaceMode::PreLine
9408    );
9409    if strip_trailing {
9410        while let Some(last) = line_items.last() {
9411            if is_collapsible_whitespace(last) {
9412                line_items.pop();
9413            } else {
9414                break;
9415            }
9416        }
9417    }
9418
9419    (line_items, false)
9420}
9421
9422/// Represents a single valid hyphenation point within a word.
9423#[derive(Debug, Clone)]
9424pub struct HyphenationBreak {
9425    /// The number of characters from the original word string included on the line.
9426    pub char_len_on_line: usize,
9427    /// The total advance width of the line part + the hyphen.
9428    pub width_on_line: f32,
9429    /// The cluster(s) that will remain on the current line.
9430    pub line_part: Vec<ShapedItem>,
9431    /// The cluster that represents the hyphen character itself.
9432    pub hyphen_item: ShapedItem,
9433    /// The cluster(s) that will be carried over to the next line.
9434    /// CRITICAL FIX: Changed from `ShapedItem` to Vec<ShapedItem>
9435    pub remainder_part: Vec<ShapedItem>,
9436}
9437
9438/// A "word" is defined as a sequence of one or more adjacent `ShapedClusters`.
9439#[allow(clippy::cast_precision_loss)] // bounded pixel/coord/colour/glyph cast
9440/// # Panics
9441///
9442/// Panics if a word's cluster or glyph list is unexpectedly empty (an internal invariant).
9443#[must_use] pub fn find_all_hyphenation_breaks<T: ParsedFontTrait>(
9444    word_clusters: &[ShapedCluster],
9445    hyphenator: &Standard,
9446    is_vertical: bool, // Pass this in to use correct metrics
9447    fonts: &LoadedFonts<T>,
9448) -> Option<Vec<HyphenationBreak>> {
9449    if word_clusters.is_empty() {
9450        return None;
9451    }
9452
9453    // --- 1. Concatenate the TRUE text and build a robust map ---
9454    let mut word_string = String::new();
9455    let mut char_map = Vec::new();
9456    let mut current_width = 0.0;
9457
9458    for (cluster_idx, cluster) in word_clusters.iter().enumerate() {
9459        for (char_byte_offset, _ch) in cluster.text.char_indices() {
9460            let glyph_idx = cluster
9461                .glyphs
9462                .iter()
9463                .rposition(|g| g.cluster_offset as usize <= char_byte_offset)
9464                .unwrap_or(0);
9465            let glyph = &cluster.glyphs[glyph_idx];
9466
9467            let num_chars_in_glyph = cluster.text[glyph.cluster_offset as usize..]
9468                .chars()
9469                .count();
9470            let advance_per_char = if is_vertical {
9471                glyph.vertical_advance
9472            } else {
9473                glyph.advance
9474            } / (num_chars_in_glyph as f32).max(1.0);
9475
9476            current_width += advance_per_char;
9477            char_map.push((cluster_idx, glyph_idx, current_width));
9478        }
9479        word_string.push_str(&cluster.text);
9480    }
9481
9482    // +spec:line-breaking:d7ed93 - language-specific hyphenation rules apply to both auto and explicit (soft hyphen) opportunities
9483    // --- 2. Get hyphenation opportunities ---
9484    let opportunities = hyphenator.hyphenate(&word_string);
9485    if opportunities.breaks.is_empty() {
9486        return None;
9487    }
9488
9489    let last_cluster = word_clusters.last().unwrap();
9490    let last_glyph = last_cluster.glyphs.last().unwrap();
9491    let style = last_cluster.style.clone();
9492
9493    // Look up font from hash
9494    let font = fonts.get_by_hash(last_glyph.font_hash)?;
9495    let (hyphen_glyph_id, hyphen_advance) =
9496        font.get_hyphen_glyph_and_advance(style.font_size_px)?;
9497
9498    let mut possible_breaks = Vec::new();
9499
9500    // --- 3. Generate a HyphenationBreak for each valid opportunity ---
9501    for &break_char_idx in &opportunities.breaks {
9502        // The break is *before* the character at this index.
9503        // So the last character on the line is at `break_char_idx - 1`.
9504        if break_char_idx == 0 || break_char_idx > char_map.len() {
9505            continue;
9506        }
9507
9508        let (_, _, width_at_break) = char_map[break_char_idx - 1];
9509
9510        // The line part is all clusters *before* the break index.
9511        let line_part: Vec<ShapedItem> = word_clusters[..break_char_idx]
9512            .iter()
9513            .map(|c| ShapedItem::Cluster(c.clone()))
9514            .collect();
9515
9516        // The remainder is all clusters *from* the break index onward.
9517        let remainder_part: Vec<ShapedItem> = word_clusters[break_char_idx..]
9518            .iter()
9519            .map(|c| ShapedItem::Cluster(c.clone()))
9520            .collect();
9521
9522        let hyphen_item = ShapedItem::Cluster(ShapedCluster {
9523            text: "-".to_string(),
9524            source_cluster_id: GraphemeClusterId {
9525                source_run: u32::MAX,
9526                start_byte_in_run: u32::MAX,
9527            },
9528            source_content_index: ContentIndex {
9529                run_index: u32::MAX,
9530                item_index: u32::MAX,
9531            },
9532            source_node_id: None, // Hyphen is generated, not from DOM
9533            glyphs: smallvec![ShapedGlyph {
9534                kind: GlyphKind::Hyphen,
9535                glyph_id: hyphen_glyph_id,
9536                font_hash: last_glyph.font_hash,
9537                font_metrics: last_glyph.font_metrics,
9538                cluster_offset: 0,
9539                script: Script::Latin,
9540                advance: hyphen_advance,
9541                kerning: 0.0,
9542                offset: Point::default(),
9543                style: style.clone(),
9544                vertical_advance: hyphen_advance,
9545                vertical_offset: Point::default(),
9546            }],
9547            advance: hyphen_advance,
9548            direction: BidiDirection::Ltr,
9549            style: style.clone(),
9550            marker_position_outside: None,
9551            is_first_fragment: true,
9552            is_last_fragment: true,
9553        });
9554
9555        possible_breaks.push(HyphenationBreak {
9556            char_len_on_line: break_char_idx,
9557            width_on_line: width_at_break + hyphen_advance,
9558            line_part,
9559            hyphen_item,
9560            remainder_part,
9561        });
9562    }
9563
9564    Some(possible_breaks)
9565}
9566
9567/// Tries to find a hyphenation point within a word, returning the line part and remainder.
9568fn try_hyphenate_word_cluster<T: ParsedFontTrait>(
9569    word_items: &[ShapedItem],
9570    remaining_width: f32,
9571    is_vertical: bool,
9572    hyphenator: &Standard,
9573    fonts: &LoadedFonts<T>,
9574) -> Option<HyphenationResult> {
9575    let word_clusters: Vec<ShapedCluster> = word_items
9576        .iter()
9577        .filter_map(|item| item.as_cluster().cloned())
9578        .collect();
9579
9580    if word_clusters.is_empty() {
9581        return None;
9582    }
9583
9584    let all_breaks = find_all_hyphenation_breaks(&word_clusters, hyphenator, is_vertical, fonts)?;
9585
9586    if let Some(best_break) = all_breaks
9587        .into_iter()
9588        .rfind(|b| b.width_on_line <= remaining_width)
9589    {
9590        let mut line_part = best_break.line_part;
9591        line_part.push(best_break.hyphen_item);
9592
9593        return Some(HyphenationResult {
9594            line_part,
9595            remainder_part: best_break.remainder_part,
9596        });
9597    }
9598
9599    None
9600}
9601
9602/// Positions a single line of items, handling alignment and justification within segments.
9603///
9604/// This function is architecturally critical for cache safety. It does not mutate the
9605/// `advance` or `bounds` of the input `ShapedItem`s. Instead, it applies justification
9606/// spacing by adjusting the drawing pen's position (`main_axis_pen`).
9607///
9608/// # Returns
9609/// A tuple containing the `Vec` of positioned items and the calculated height of the line box.
9610/// Position items on a single line after breaking.
9611///
9612/// # CSS Inline Layout Module Level 3 \u00a7 2.2 Layout Within Line Boxes
9613/// <https://www.w3.org/TR/css-inline-3/#layout-within-line-boxes>
9614///
9615/// Implements the positioning algorithm:
9616/// 1. "All inline-level boxes are aligned by their baselines"
9617/// 2. "Calculate layout bounds for each inline box"
9618/// 3. "Size the line box to fit the aligned layout bounds"
9619/// 4. "Position all inline boxes within the line box"
9620///
9621/// ## \u2705 Implemented Features:
9622///
9623/// ### \u00a7 4 Baseline Alignment (vertical-align)
9624/// \u26a0\ufe0f PARTIAL IMPLEMENTATION:
9625/// - \u2705 `baseline`: Aligns box baseline with parent baseline (default)
9626/// - \u2705 `top`: Aligns top of box with top of line box
9627/// - \u2705 `middle`: Centers box within line box
9628/// - \u2705 `bottom`: Aligns bottom of box with bottom of line box
9629/// - \u274c MISSING: `text-top`, `text-bottom`, `sub`, `super`
9630/// - \u274c MISSING: `<length>`, `<percentage>` values for custom offset
9631///
9632/// ### \u00a7 2.2.1 Text Alignment (text-align)
9633/// +spec:containing-block:8d5146 - text-align aligns within line box, not viewport/containing block
9634/// \u2705 IMPLEMENTED:
9635/// - `left`, `right`, `center`: Physical alignment
9636/// - `start`, `end`: Logical alignment (respects direction: ltr/rtl)
9637/// - `justify`: Distributes space between words/characters
9638/// - `justify-all`: Justifies last line too
9639///
9640/// ### \u00a7 7.3 Text Justification (text-justify)
9641/// \u2705 IMPLEMENTED:
9642/// - `inter-word`: Adds space between words
9643/// - `inter-character`: Adds space between characters
9644/// - `kashida`: Arabic kashida elongation
9645/// - \u274c MISSING: `distribute` (CJK justification)
9646///
9647/// ### CSS Text \u00a7 8.1 Text Indentation (text-indent)
9648/// \u2705 IMPLEMENTED: First line indentation
9649///
9650/// ### CSS Text \u00a7 4.1 Word Spacing (word-spacing)
9651/// \u2705 IMPLEMENTED: Additional space between words
9652///
9653/// ### CSS Text \u00a7 4.2 Letter Spacing (letter-spacing)
9654/// \u2705 IMPLEMENTED: Additional space between characters
9655///
9656/// ## Segment-Aware Layout:
9657/// \u2705 Handles CSS Shapes and multi-column layouts
9658/// - Breaks line into segments (for shape boundaries)
9659/// - Calculates justification per segment
9660/// - Applies alignment within each segment's bounds
9661///
9662/// ## Known Issues:
9663/// - \u26a0\ufe0f If segment.width is infinite (from intrinsic sizing), sets `alignment_offset=0` to
9664///   avoid infinite positioning. This is correct for measurement but documented for clarity.
9665/// - The function assumes `line_index == 0` means first line for text-indent. A more robust system
9666///   would track paragraph boundaries.
9667///
9668/// # Missing Features:
9669/// - \u274c \u00a7 6 Trimming Leading (text-box-trim, text-box-edge)
9670/// - \u274c \u00a7 3.3 Initial Letters (drop caps)
9671///   // +spec:display-property:265c04 - initial letter exclusion area must continue into subsequent blocks when paragraph is shorter than drop cap
9672/// - \u274c Full vertical-align support (sub, super, lengths, percentages)
9673/// - \u274c white-space: break-spaces alignment behavior
9674// +spec:text-alignment-spacing:c8a926 - order of operations: shaping → letter/word-spacing → justification → alignment
9675#[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
9676#[allow(clippy::cast_precision_loss)] // bounded pixel/coord/colour/glyph cast
9677#[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
9678#[allow(clippy::too_many_lines, clippy::cognitive_complexity)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
9679pub fn position_one_line<T: ParsedFontTrait>(
9680    line_items: &[ShapedItem],
9681    line_constraints: &LineConstraints,
9682    line_top_y: f32,
9683    line_index: usize,
9684    text_align: TextAlign,
9685    base_direction: BidiDirection,
9686    is_last_line: bool,
9687    constraints: &UnifiedConstraints,
9688    debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
9689    fonts: &LoadedFonts<T>,
9690    is_after_forced_break: bool,
9691) -> (Vec<PositionedItem>, f32) {
9692    let line_text: String = line_items
9693        .iter()
9694        .filter_map(|i| i.as_cluster())
9695        .map(|c| c.text.as_str())
9696        .collect();
9697    if let Some(msgs) = debug_messages {
9698        msgs.push(LayoutDebugMessage::info(format!(
9699            "\n--- Entering position_one_line for line: [{line_text}] ---"
9700        )));
9701    }
9702    // +spec:text-alignment-spacing:13b72d - line box start/end determined by inline base direction
9703    // +spec:text-alignment-spacing:d497af - line box inline base direction affects text-align resolution
9704    // +spec:text-alignment-spacing:68332e - bidi direction determines start/end to left/right mapping
9705    let physical_align = match (text_align, base_direction) {
9706        (TextAlign::Start, BidiDirection::Ltr) => TextAlign::Left,
9707        (TextAlign::Start, BidiDirection::Rtl) => TextAlign::Right,
9708        (TextAlign::End, BidiDirection::Ltr) => TextAlign::Right,
9709        (TextAlign::End, BidiDirection::Rtl) => TextAlign::Left,
9710        // Physical alignments are returned as-is, regardless of direction.
9711        (other, _) => other,
9712    };
9713    if let Some(msgs) = debug_messages {
9714        msgs.push(LayoutDebugMessage::info(format!(
9715            "[Pos1Line] Physical align: {physical_align:?}"
9716        )));
9717    }
9718
9719    // +spec:box-model:847003 - Phantom line boxes: empty lines treated as zero-height
9720    // +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
9721    // +spec:display-property:90d782 - Phantom line boxes (containing only empty inline boxes, out-of-flow items, or collapsed whitespace) are ignored
9722    if line_items.is_empty() {
9723        return (Vec::new(), 0.0);
9724    }
9725    let mut positioned = Vec::new();
9726    let is_vertical = constraints.is_vertical();
9727
9728    // +spec:line-height:9ca9d9 - line box height = distance from uppermost box top to lowermost box bottom, including strut
9729    // The line box is calculated once for all items on the line, regardless of segment.
9730    // Per CSS 2.2 §10.8, top/bottom aligned items are handled in a second pass to
9731    // minimize line box height; baseline-aligned items determine the initial height.
9732    let (content_ascent, content_descent) = calculate_line_metrics(line_items, constraints.vertical_align, constraints);
9733
9734    // +spec:box-model:e99f7d - strut: each line box starts with zero-width inline box with block container's font/line-height
9735    // +spec:line-height:29c478 - strut: zero-width inline box with block container's font/line-height
9736    // inline box with the block container's font and line-height. The strut has A (ascent) and
9737    // D (descent) from the block container's first available font. Half-leading L/2 is applied:
9738    // L = line-height - (A + D), strut_above = A + L/2, strut_below = D + L/2.
9739    // +spec:height-calculation:8e91b2 - specified line-height used in line box height calculation
9740    let strut_ad = constraints.strut_ascent + constraints.strut_descent;
9741    let strut_leading_half = (constraints.resolved_line_height() - strut_ad) / 2.0;
9742    let strut_above = constraints.strut_ascent + strut_leading_half;
9743    let strut_below = constraints.strut_descent + strut_leading_half;
9744    let line_ascent = content_ascent.max(strut_above);
9745    let line_descent = content_descent.max(strut_below);
9746    let line_box_height = line_ascent + line_descent;
9747
9748    // The baseline for the entire line is determined by its tallest item.
9749    let line_baseline_y = line_top_y + line_ascent;
9750
9751    // --- Segment-Aware Positioning ---
9752    let mut item_cursor = 0;
9753    let is_first_line_of_para = line_index == 0; // Simplified assumption
9754
9755    for (segment_idx, segment) in line_constraints.segments.iter().enumerate() {
9756        if item_cursor >= line_items.len() {
9757            break;
9758        }
9759
9760        // 1. Collect all items that fit into the current segment.
9761        let mut segment_items = Vec::new();
9762        let mut current_segment_width = 0.0;
9763        while item_cursor < line_items.len() {
9764            let item = &line_items[item_cursor];
9765            let item_measure = get_item_measure(item, is_vertical);
9766            // Put at least one item in the segment to avoid getting stuck.
9767            if current_segment_width + item_measure > segment.width && !segment_items.is_empty() {
9768                break;
9769            }
9770            segment_items.push(item.clone());
9771            current_segment_width += item_measure;
9772            item_cursor += 1;
9773        }
9774
9775        if segment_items.is_empty() {
9776            continue;
9777        }
9778
9779        // +spec:text-alignment-spacing:b9d88e - justify stretches inline boxes via text-justify; non-collapsible WS may skip justification
9780        // 2. Calculate justification spacing *for this segment only*.
9781        // +spec:text-alignment-spacing:30d322 - justify lines with justification opportunities when text-align is justify
9782        // CSS Text 3 §6: text-justify controls HOW to justify, but only applies
9783        // when text-align is justify/justify-all. Without this check, ALL text
9784        // gets justified because text-justify defaults to auto (→ InterWord).
9785        let (extra_word_spacing, extra_char_spacing) = if (constraints.text_align == TextAlign::Justify
9786            || constraints.text_align == TextAlign::JustifyAll)
9787            && constraints.text_justify != JustifyContent::None
9788            && (!is_last_line || constraints.text_align == TextAlign::JustifyAll)
9789            && constraints.text_justify != JustifyContent::Kashida
9790        {
9791            let segment_line_constraints = LineConstraints {
9792                segments: vec![*segment],
9793                total_available: segment.width,
9794                is_min_content: false,
9795            };
9796            calculate_justification_spacing(
9797                &segment_items,
9798                &segment_line_constraints,
9799                constraints.text_justify,
9800                is_vertical,
9801            )
9802        } else {
9803            (0.0, 0.0)
9804        };
9805
9806        // Kashida justification needs to be segment-aware if used.
9807        let justified_segment_items = if constraints.text_justify == JustifyContent::Kashida
9808            && (!is_last_line || constraints.text_align == TextAlign::JustifyAll)
9809        {
9810            let segment_line_constraints = LineConstraints {
9811                segments: vec![*segment],
9812                total_available: segment.width,
9813                is_min_content: false,
9814            };
9815            justify_kashida_and_rebuild(
9816                segment_items,
9817                &segment_line_constraints,
9818                is_vertical,
9819                debug_messages,
9820                fonts,
9821            )
9822        } else {
9823            segment_items
9824        };
9825
9826        // Recalculate width in case kashida changed the item list
9827        let final_segment_width: f32 = justified_segment_items
9828            .iter()
9829            .map(|item| get_item_measure(item, is_vertical))
9830            .sum();
9831
9832        // +spec:line-breaking:155a96 - pre-wrap hanging spaces: unconditionally hang without forced break, conditionally hang with forced break
9833        // +spec:white-space-processing:68af09 - Phase II: trailing whitespace hanging/conditional hanging per white-space mode
9834        // +spec:white-space-processing:75d91e - preserved white space hangs at line end, affecting intrinsic sizing
9835        // +spec:overflow:a68394 - Hanging trailing whitespace: unconditionally hang (not considered
9836        // during alignment, may overflow) for lines without forced break; conditionally hang for
9837        // lines ending with forced break (only hang if would overflow).
9838        // For normal/nowrap/pre-line: unconditionally hang trailing WS.
9839        // For pre-wrap: unconditionally hang, unless before forced break (then conditionally hang).
9840        // For break-spaces: trailing spaces cannot hang.
9841        // For pre: no hanging (whitespace preserved as-is).
9842        // +spec:intrinsic-sizing:1db683 - conditionally hanging glyphs excluded from min-content, included in max-content
9843        let trailing_ws_width = match constraints.white_space_mode {
9844            WhiteSpaceMode::BreakSpaces | WhiteSpaceMode::Pre => 0.0,
9845            WhiteSpaceMode::Normal | WhiteSpaceMode::Nowrap | WhiteSpaceMode::PreLine => {
9846                measure_trailing_whitespace(&justified_segment_items, is_vertical)
9847            }
9848            // +spec:line-breaking:8aa426 - space before forced break does not hang if it doesn't overflow
9849            WhiteSpaceMode::PreWrap => {
9850                let has_forced_break = justified_segment_items.last()
9851                    .is_some_and(|item| matches!(item, ShapedItem::Break { .. }));
9852                let ws_width = measure_trailing_whitespace(&justified_segment_items, is_vertical);
9853                if has_forced_break {
9854                    // +spec:display-contents:2704a2 - conditionally hanging chars not considered when measuring line fit
9855                    // Conditionally hang: only hang if it would overflow
9856                    let content_width = final_segment_width - ws_width;
9857                    if content_width + ws_width > segment.width {
9858                        ws_width
9859                    } else {
9860                        0.0
9861                    }
9862                } else {
9863                    ws_width // unconditionally hang
9864                }
9865            }
9866        };
9867        let effective_segment_width = final_segment_width - trailing_ws_width;
9868
9869        // +spec:text-alignment-spacing:287316 - overflow content is start-aligned; alignment offset within line box
9870        // 3. Calculate alignment offset *within this segment*.
9871        let remaining_space = segment.width - effective_segment_width;
9872
9873        // Handle MaxContent/indefinite width: when available_width is MaxContent (for intrinsic
9874        // sizing), segment.width will be f32::MAX / 2.0. Alignment calculations would
9875        // produce huge offsets. In this case, treat as left-aligned (offset = 0) since
9876        // we're measuring natural content width. We check for both infinite AND very large
9877        // values (> 1e30) to catch the MaxContent case.
9878        let is_indefinite_width = segment.width.is_infinite() || segment.width > 1e30;
9879        // +spec:text-alignment-spacing:ab1d4f - unexpandable justify text aligns as center
9880        let alignment_offset = if is_indefinite_width {
9881            0.0 // No alignment offset for indefinite width
9882        } else {
9883            match physical_align {
9884                TextAlign::Center => remaining_space / 2.0,
9885                TextAlign::Right => remaining_space,
9886                TextAlign::Justify | TextAlign::JustifyAll
9887                    if remaining_space > 0.0
9888                        && extra_word_spacing == 0.0
9889                        && extra_char_spacing == 0.0 =>
9890                {
9891                    // CSS Text §6.4.3: If text cannot be stretched to full width
9892                    // and text-align-last is justify, align as center.
9893                    remaining_space / 2.0
9894                }
9895                _ => 0.0, // Left, Justify (when justification succeeded)
9896            }
9897        };
9898
9899        let mut main_axis_pen = segment.start_x + alignment_offset;
9900        if let Some(msgs) = debug_messages {
9901            msgs.push(LayoutDebugMessage::info(format!(
9902                "[Pos1Line] Segment width: {}, Item width: {}, Remaining space: {}, Initial pen: \
9903                 {}",
9904                segment.width, final_segment_width, remaining_space, main_axis_pen
9905            )));
9906        }
9907
9908        // Default: indent first line only. each-line: also indent after forced breaks.
9909        // hanging: invert which lines get the indent.
9910        if segment_idx == 0 {
9911            let is_indent_target = if constraints.text_indent_each_line {
9912                // each-line: first line AND each line after a forced break
9913                is_first_line_of_para || is_after_forced_break
9914            } else {
9915                // Default: only the first line of the block
9916                is_first_line_of_para
9917            };
9918            // hanging: inverts which lines are affected
9919            let should_indent = if constraints.text_indent_hanging {
9920                !is_indent_target
9921            } else {
9922                is_indent_target
9923            };
9924            if should_indent {
9925                main_axis_pen += constraints.text_indent;
9926            }
9927        }
9928
9929        // Calculate total marker width for proper outside marker positioning
9930        // We need to position all marker clusters together in the padding gutter
9931        let total_marker_width: f32 = justified_segment_items
9932            .iter()
9933            .filter_map(|item| {
9934                if let ShapedItem::Cluster(c) = item {
9935                    if c.marker_position_outside == Some(true) {
9936                        return Some(get_item_measure(item, is_vertical));
9937                    }
9938                }
9939                None
9940            })
9941            .sum();
9942
9943        // Track marker pen separately - starts at negative position for outside markers
9944        let marker_spacing = 4.0; // Small gap between marker and content
9945        let mut marker_pen = if total_marker_width > 0.0 {
9946            -(total_marker_width + marker_spacing)
9947        } else {
9948            0.0
9949        };
9950
9951        // 4. Position the items belonging to this segment.
9952        //
9953        // +spec:inline-formatting-context:267438 - Content positioning: position aligned subtree and baseline-shift values within line box
9954        //
9955        // Vertical alignment positioning (CSS vertical-align)
9956        //
9957        // +spec:font-metrics:cae541 - dominant baseline used for inline alignment
9958        // Per CSS Inline Layout Level 3 § 4 (Baseline Alignment), each inline
9959        // element can specify its own `vertical-align`. For Object items
9960        // (inline-blocks, images), we use their per-item alignment stored in
9961        // `InlineContent::Shape.alignment` or `InlineContent::Image.alignment`.
9962        // For text clusters or items without a per-item override, we fall back
9963        // to the global `constraints.vertical_align` from the containing block.
9964        //
9965        // +spec:font-metrics:f29b61 - baseline alignment matches corresponding baseline types (only alphabetic implemented)
9966        // Reference: https://www.w3.org/TR/css-inline-3/#baseline-alignment
9967        // +spec:block-formatting-context:26b535 - In vertical typographic mode, central baseline is dominant when text-orientation is mixed/upright; otherwise alphabetic
9968        // +spec:inline-formatting-context:eb735b - alignment-baseline: inline-level boxes aligned to parent's baseline via vertical-align
9969        // +spec:inline-formatting-context:da3f34 - baseline alignment of in-flow inline-level boxes in block axis per dominant-baseline/vertical-align
9970        // +spec:line-height:e2253a - vertical-align positioning within line boxes
9971
9972        // Pre-compute inline border/padding offsets at span boundaries.
9973        // Only the FIRST cluster of each inline span gets left_inset, and only
9974        // the LAST cluster gets right_inset. We detect span boundaries by comparing
9975        // Arc<StyleProperties> pointers between consecutive clusters.
9976        let inline_offsets: Vec<(f32, f32)> = {
9977            let items_slice: &[ShapedItem] = &justified_segment_items;
9978            items_slice.iter().enumerate().map(|(idx, item)| {
9979                if let ShapedItem::Cluster(c) = item {
9980                    if let Some(border) = c.style.border.as_ref() {
9981                        if border.has_chrome() {
9982                            let style_ptr = Arc::as_ptr(&c.style);
9983                            let prev_same_span = idx > 0 && items_slice[idx - 1]
9984                                .as_cluster()
9985                                .is_some_and(|pc| Arc::as_ptr(&pc.style) == style_ptr);
9986                            let next_same_span = idx + 1 < items_slice.len() && items_slice[idx + 1]
9987                                .as_cluster()
9988                                .is_some_and(|nc| Arc::as_ptr(&nc.style) == style_ptr);
9989                            let left = if prev_same_span { 0.0 } else { border.left_inset() };
9990                            let right = if next_same_span { 0.0 } else { border.right_inset() };
9991                            return (left, right);
9992                        }
9993                    }
9994                }
9995                (0.0, 0.0)
9996            }).collect()
9997        };
9998        for (inline_offset_idx, item) in justified_segment_items.into_iter().enumerate() {
9999            let (item_ascent, item_descent) = get_item_vertical_metrics(&item, constraints);
10000            // Use per-item alignment if available, otherwise fall back to global
10001            let effective_align = get_item_vertical_align(&item)
10002                .unwrap_or(constraints.vertical_align);
10003            // +spec:display-property:328cfc - baseline-shift / aligned subtree vertical alignment (sub, super, top, bottom, center)
10004            // §10.8.1 vertical-align positioning
10005            // +spec:line-height:0fcfab - vertical-align property values (baseline, top, middle, bottom, sub, super, text-top, text-bottom, percentage, length)
10006            let item_baseline_pos = match effective_align {
10007                // +spec:display-property:8e018d - aligned subtree edges used for top/bottom line box alignment
10008                // +spec:inline-formatting-context:495672 - line-relative vertical-align (top/center/bottom) and aligned subtree positioning
10009                // top: align top of aligned subtree with top of line box
10010                VerticalAlign::Top => line_top_y + item_ascent,
10011                // +spec:font-metrics:70000d - align vertical midpoint of box with baseline + half x-height of parent
10012                VerticalAlign::Middle => {
10013                    let half_x_height = constraints.strut_x_height / 2.0;
10014                    line_baseline_y + half_x_height - f32::midpoint(item_ascent, item_descent) + item_ascent
10015                }
10016                // bottom: align bottom of aligned subtree with bottom of line box
10017                VerticalAlign::Bottom => line_top_y + line_box_height - item_descent,
10018                // +spec:font-metrics:aa21f7 - sub: lower baseline to proper subscript position
10019                VerticalAlign::Sub => line_baseline_y + line_ascent * SUBSCRIPT_OFFSET_RATIO,
10020                // +spec:display-property:3b0e76 - baseline-shift super raises by ~1/3 font-size; top/bottom align to line box edges
10021                // super: raise baseline to proper superscript position (~0.4em)
10022                VerticalAlign::Super => line_baseline_y - line_ascent * SUPERSCRIPT_OFFSET_RATIO,
10023                // text-top: align top of box with top of parent's content area (§10.6.1)
10024                // Parent's content area top = baseline - strut_ascent
10025                VerticalAlign::TextTop => (line_baseline_y - constraints.strut_ascent) + item_ascent,
10026                // text-bottom: align bottom of box with bottom of parent's content area (§10.6.1)
10027                // Parent's content area bottom = baseline + strut_descent
10028                VerticalAlign::TextBottom => (line_baseline_y + constraints.strut_descent) - item_descent,
10029                // <length>/<percentage>: raise (positive) or lower (negative); 0 = baseline
10030                VerticalAlign::Offset(offset) => line_baseline_y - offset,
10031                // +spec:display-property:8bf37e - dominant-baseline defaults to alphabetic; baseline alignment matches parent
10032                // baseline: align baseline of box with baseline of parent box
10033                // +spec:font-metrics:96bbd3 - baseline: align alphabetic baseline of box with parent's alphabetic baseline
10034                VerticalAlign::Baseline => line_baseline_y,
10035            };
10036
10037            // Calculate item measure (needed for both positioning and pen advance)
10038            let item_measure = get_item_measure(&item, is_vertical);
10039
10040            // Advance pen by inline left_inset at span entry (before positioning glyphs)
10041            let (left_inset, right_inset) = if inline_offset_idx < inline_offsets.len() {
10042                inline_offsets[inline_offset_idx]
10043            } else {
10044                (0.0, 0.0)
10045            };
10046            main_axis_pen += left_inset;
10047
10048            let position = if is_vertical {
10049                Point {
10050                    x: item_baseline_pos - item_ascent,
10051                    y: main_axis_pen,
10052                }
10053            } else {
10054                if let Some(msgs) = debug_messages {
10055                    msgs.push(LayoutDebugMessage::info(format!(
10056                        "[Pos1Line] is_vertical=false, main_axis_pen={main_axis_pen}, item_baseline_pos={item_baseline_pos}, \
10057                         item_ascent={item_ascent}"
10058                    )));
10059                }
10060
10061                // Check if this is an outside marker - if so, position it in the padding gutter
10062                let x_position = if let ShapedItem::Cluster(cluster) = &item {
10063                    if cluster.marker_position_outside == Some(true) {
10064                        // Use marker_pen for sequential marker positioning
10065                        let marker_width = item_measure;
10066                        if let Some(msgs) = debug_messages {
10067                            msgs.push(LayoutDebugMessage::info(format!(
10068                                "[Pos1Line] Outside marker detected! width={marker_width}, positioning at \
10069                                 marker_pen={marker_pen}"
10070                            )));
10071                        }
10072                        let pos = marker_pen;
10073                        marker_pen += marker_width; // Advance marker pen for next marker cluster
10074                        pos
10075                    } else {
10076                        main_axis_pen
10077                    }
10078                } else {
10079                    main_axis_pen
10080                };
10081
10082                Point {
10083                    y: item_baseline_pos - item_ascent,
10084                    x: x_position,
10085                }
10086            };
10087
10088            // item_measure is calculated above for marker positioning
10089            let item_text = item
10090                .as_cluster()
10091                .map_or("[OBJ]", |c| c.text.as_str());
10092            if let Some(msgs) = debug_messages {
10093                msgs.push(LayoutDebugMessage::info(format!(
10094                    "[Pos1Line] Positioning item '{item_text}' at pen_x={main_axis_pen}"
10095                )));
10096            }
10097            positioned.push(PositionedItem {
10098                item: item.clone(),
10099                position,
10100                line_index,
10101            });
10102
10103            // Outside markers don't advance the pen - they're positioned in the padding gutter
10104            let is_outside_marker = if let ShapedItem::Cluster(c) = &item {
10105                c.marker_position_outside == Some(true)
10106            } else {
10107                false
10108            };
10109
10110            if !is_outside_marker {
10111                main_axis_pen += item_measure;
10112                // Advance pen by inline right_inset at span exit (after glyph advance)
10113                main_axis_pen += right_inset;
10114            }
10115
10116            // +spec:text-alignment-spacing:e09bd1 - justification space added on top of letter-spacing/word-spacing
10117            // +spec:text-alignment-spacing:456643 - cursive scripts don't admit inter-character gaps
10118            let is_cursive = if let ShapedItem::Cluster(c) = &item { is_cursive_script_cluster(c) } else { false };
10119            if !is_outside_marker && extra_char_spacing > 0.0 && can_justify_after(&item) && !is_cursive {
10120                main_axis_pen += extra_char_spacing;
10121            }
10122            // +spec:display-property:3a833c - consecutive atomic inlines treated as single unit for letter-spacing
10123            // +spec:display-property:49f04f - letter-spacing applied per innermost inline element
10124            // +spec:text-alignment-spacing:22bea4 - letter-spacing applied after bidi reordering, additive with kerning and word-spacing; justification may further adjust
10125            if let ShapedItem::Cluster(c) = &item {
10126                if !is_outside_marker {
10127                    // +spec:display-property:756454 - letter-spacing applied between typographic character units
10128                    // +spec:overflow:e63bc0 - letter-spacing ignores zero-width formatting chars (Cf); handled by shaper merging them into clusters
10129                    // +spec:text-alignment-spacing:80f9ec - letter-spacing applied per-cluster using innermost element's style (UA-allowed attachment)
10130                    // +spec:text-alignment-spacing:bdd704 - letter-spacing applied after each cluster, not at line start
10131                    // +spec:text-alignment-spacing:d3ef6e - single-char element: only trailing space, no inter-char effect
10132                    // +spec:text-alignment-spacing:d668fc - letter-spacing only affects characters within the element (per-cluster style)
10133                    // +spec:text-alignment-spacing:8dbb78 - zero letter-spacing behaves as normal (Px(0) adds no spacing)
10134                    // +spec:text-alignment-spacing:456643 - skip letter-spacing for cursive scripts
10135                    if !is_cursive_script_cluster(c) {
10136                    let letter_spacing_px = c.style.letter_spacing.resolve_px(c.style.font_size_px);
10137                    main_axis_pen += letter_spacing_px;
10138                    }
10139                    // +spec:width-calculation:9447d1 - word-spacing only applied to word separators; zero-width chars like U+200B are excluded
10140                    if is_word_separator(&item) {
10141                        let word_spacing_px = c.style.word_spacing.resolve_px(c.style.font_size_px);
10142                        main_axis_pen += word_spacing_px;
10143                        main_axis_pen += extra_word_spacing;
10144                    }
10145                }
10146            }
10147        }
10148    }
10149
10150    (positioned, line_box_height)
10151}
10152
10153/// Calculates the starting pen offset to achieve the desired text alignment.
10154fn calculate_alignment_offset(
10155    items: &[ShapedItem],
10156    line_constraints: &LineConstraints,
10157    align: TextAlign,
10158    is_vertical: bool,
10159    constraints: &UnifiedConstraints,
10160) -> f32 {
10161    // Simplified to use the first segment for alignment.
10162    if let Some(segment) = line_constraints.segments.first() {
10163        // Include letter/word-spacing so center/right alignment matches the width the
10164        // text is actually positioned at (position_one_line adds the spacing).
10165        let total_width: f32 = items
10166            .iter()
10167            .map(|item| get_item_measure_with_spacing(item, is_vertical))
10168            .sum();
10169
10170        let available_width = if constraints.segment_alignment == SegmentAlignment::Total {
10171            line_constraints.total_available
10172        } else {
10173            segment.width
10174        };
10175
10176        if total_width >= available_width {
10177            return 0.0; // No alignment needed if line is full or overflows
10178        }
10179
10180        let remaining_space = available_width - total_width;
10181
10182        match align {
10183            TextAlign::Center => remaining_space / 2.0,
10184            TextAlign::Right => remaining_space,
10185            _ => 0.0, // Left, Justify, Start, End
10186        }
10187    } else {
10188        0.0
10189    }
10190}
10191
10192/// Calculates the extra spacing needed for justification without modifying the items.
10193///
10194/// This function is pure and does not mutate any state, making it safe to use
10195/// with cached `ShapedItem` data.
10196///
10197/// # Arguments
10198/// * `items` - A slice of items on the line.
10199/// * `line_constraints` - The geometric constraints for the line.
10200/// * `text_justify` - The type of justification to calculate.
10201/// * `is_vertical` - Whether the layout is vertical.
10202///
10203/// # Returns
10204/// A tuple `(extra_per_word, extra_per_char)` containing the extra space in pixels
10205/// to add at each word or character justification opportunity.
10206// +spec:display-contents:654278 - distributes remaining space to fill line box when justifying
10207// +spec:text-alignment-spacing:56c7f4 - equal distribution of justification space within priority level
10208// +spec:text-alignment-spacing:f17bbc - justification opportunities controlled by text-justify value (inter-word = word separators, inter-character = character juxtaposition)
10209#[allow(clippy::cast_precision_loss)] // bounded pixel/coord/colour/glyph cast
10210fn calculate_justification_spacing(
10211    items: &[ShapedItem],
10212    line_constraints: &LineConstraints,
10213    text_justify: JustifyContent,
10214    is_vertical: bool,
10215) -> (f32, f32) {
10216    // (extra_per_word, extra_per_char)
10217    let total_width: f32 = items
10218        .iter()
10219        .map(|item| get_item_measure(item, is_vertical))
10220        .sum();
10221    let available_width = line_constraints.total_available;
10222
10223    if total_width >= available_width || available_width <= 0.0 {
10224        return (0.0, 0.0);
10225    }
10226
10227    let extra_space = available_width - total_width;
10228
10229    // +spec:text-alignment-spacing:71314a - script categories for justification: inter-word for clustered, kashida for cursive (Arabic), inter-character for block (CJK)
10230    match text_justify {
10231        JustifyContent::InterWord => {
10232            // Count justification opportunities (spaces).
10233            let space_count = items.iter().filter(|item| is_word_separator(item)).count();
10234            if space_count > 0 {
10235                (extra_space / space_count as f32, 0.0)
10236            } else {
10237                (0.0, 0.0) // No spaces to expand, do nothing.
10238            }
10239        }
10240        JustifyContent::InterCharacter | JustifyContent::Distribute => {
10241            // Count justification opportunities (between non-combining characters).
10242            let gap_count = items
10243                .iter()
10244                .enumerate()
10245                .filter(|(i, item)| *i < items.len() - 1 && can_justify_after(item))
10246                .count();
10247            if gap_count > 0 {
10248                (0.0, extra_space / gap_count as f32)
10249            } else {
10250                (0.0, 0.0) // No gaps to expand, do nothing.
10251            }
10252        }
10253        // Kashida justification modifies the item list and is handled by a separate function.
10254        _ => (0.0, 0.0),
10255    }
10256}
10257
10258/// Rebuilds a line of items, inserting Kashida glyphs for justification.
10259///
10260/// This function is non-mutating with respect to its inputs. It takes ownership of the
10261/// original items and returns a completely new `Vec`. This is necessary because Kashida
10262/// justification changes the number of items on the line, and must not modify cached data.
10263#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] // bounded pixel/coord/colour/glyph cast
10264#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
10265pub fn justify_kashida_and_rebuild<T: ParsedFontTrait>(
10266    items: Vec<ShapedItem>,
10267    line_constraints: &LineConstraints,
10268    is_vertical: bool,
10269    debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
10270    fonts: &LoadedFonts<T>,
10271) -> Vec<ShapedItem> {
10272    if let Some(msgs) = debug_messages {
10273        msgs.push(LayoutDebugMessage::info(
10274            "\n--- Entering justify_kashida_and_rebuild ---".to_string(),
10275        ));
10276    }
10277    let total_width: f32 = items
10278        .iter()
10279        .map(|item| get_item_measure(item, is_vertical))
10280        .sum();
10281    let available_width = line_constraints.total_available;
10282    if let Some(msgs) = debug_messages {
10283        msgs.push(LayoutDebugMessage::info(format!(
10284            "Total item width: {total_width}, Available width: {available_width}"
10285        )));
10286    }
10287
10288    if total_width >= available_width || available_width <= 0.0 {
10289        if let Some(msgs) = debug_messages {
10290            msgs.push(LayoutDebugMessage::info(
10291                "No justification needed (line is full or invalid).".to_string(),
10292            ));
10293        }
10294        return items;
10295    }
10296
10297    let extra_space = available_width - total_width;
10298    if let Some(msgs) = debug_messages {
10299        msgs.push(LayoutDebugMessage::info(format!(
10300            "Extra space to fill: {extra_space}"
10301        )));
10302    }
10303
10304    let font_info = items.iter().find_map(|item| {
10305        if let ShapedItem::Cluster(c) = item {
10306            if let Some(glyph) = c.glyphs.first() {
10307                if glyph.script == Script::Arabic {
10308                    // Look up font from hash
10309                    if let Some(font) = fonts.get_by_hash(glyph.font_hash) {
10310                        return Some((
10311                            font.clone(),
10312                            glyph.font_hash,
10313                            glyph.font_metrics,
10314                            glyph.style.clone(),
10315                        ));
10316                    }
10317                }
10318            }
10319        }
10320        None
10321    });
10322
10323    let (font, font_hash, font_metrics, style) = if let Some(info) = font_info {
10324        if let Some(msgs) = debug_messages {
10325            msgs.push(LayoutDebugMessage::info(
10326                "Found Arabic font for kashida.".to_string(),
10327            ));
10328        }
10329        info
10330    } else {
10331        if let Some(msgs) = debug_messages {
10332            msgs.push(LayoutDebugMessage::info(
10333                "No Arabic font found on line. Cannot insert kashidas.".to_string(),
10334            ));
10335        }
10336        return items;
10337    };
10338
10339    let (kashida_glyph_id, kashida_advance) =
10340        match font.get_kashida_glyph_and_advance(style.font_size_px) {
10341            Some((id, adv)) if adv > 0.0 => {
10342                if let Some(msgs) = debug_messages {
10343                    msgs.push(LayoutDebugMessage::info(format!(
10344                        "Font provides kashida glyph with advance {adv}"
10345                    )));
10346                }
10347                (id, adv)
10348            }
10349            _ => {
10350                if let Some(msgs) = debug_messages {
10351                    msgs.push(LayoutDebugMessage::info(
10352                        "Font does not support kashida justification.".to_string(),
10353                    ));
10354                }
10355                return items;
10356            }
10357        };
10358
10359    let opportunity_indices: Vec<usize> = items
10360        .windows(2)
10361        .enumerate()
10362        .filter_map(|(i, window)| {
10363            if let (ShapedItem::Cluster(cur), ShapedItem::Cluster(next)) = (&window[0], &window[1])
10364            {
10365                if is_arabic_cluster(cur)
10366                    && is_arabic_cluster(next)
10367                    && !is_word_separator(&window[1])
10368                {
10369                    return Some(i + 1);
10370                }
10371            }
10372            None
10373        })
10374        .collect();
10375
10376    if let Some(msgs) = debug_messages {
10377        msgs.push(LayoutDebugMessage::info(format!(
10378            "Found {} kashida insertion opportunities at indices: {:?}",
10379            opportunity_indices.len(),
10380            opportunity_indices
10381        )));
10382    }
10383
10384    if opportunity_indices.is_empty() {
10385        if let Some(msgs) = debug_messages {
10386            msgs.push(LayoutDebugMessage::info(
10387                "No opportunities found. Exiting.".to_string(),
10388            ));
10389        }
10390        return items;
10391    }
10392
10393    let num_kashidas_to_insert = (extra_space / kashida_advance).floor() as usize;
10394    if let Some(msgs) = debug_messages {
10395        msgs.push(LayoutDebugMessage::info(format!(
10396            "Calculated number of kashidas to insert: {num_kashidas_to_insert}"
10397        )));
10398    }
10399
10400    if num_kashidas_to_insert == 0 {
10401        return items;
10402    }
10403
10404    let kashidas_per_point = num_kashidas_to_insert / opportunity_indices.len();
10405    let mut remainder = num_kashidas_to_insert % opportunity_indices.len();
10406    if let Some(msgs) = debug_messages {
10407        msgs.push(LayoutDebugMessage::info(format!(
10408            "Distributing kashidas: {kashidas_per_point} per point, with {remainder} remainder."
10409        )));
10410    }
10411
10412    let kashida_item = {
10413        /* ... as before ... */
10414        let kashida_glyph = ShapedGlyph {
10415            kind: GlyphKind::Kashida {
10416                width: kashida_advance,
10417            },
10418            glyph_id: kashida_glyph_id,
10419            font_hash,
10420            font_metrics,
10421            style: style.clone(),
10422            script: Script::Arabic,
10423            advance: kashida_advance,
10424            kerning: 0.0,
10425            cluster_offset: 0,
10426            offset: Point::default(),
10427            vertical_advance: 0.0,
10428            vertical_offset: Point::default(),
10429        };
10430        ShapedItem::Cluster(ShapedCluster {
10431            text: "\u{0640}".to_string(),
10432            source_cluster_id: GraphemeClusterId {
10433                source_run: u32::MAX,
10434                start_byte_in_run: u32::MAX,
10435            },
10436            source_content_index: ContentIndex {
10437                run_index: u32::MAX,
10438                item_index: u32::MAX,
10439            },
10440            source_node_id: None, // Kashida is generated, not from DOM
10441            glyphs: smallvec![kashida_glyph],
10442            advance: kashida_advance,
10443            direction: BidiDirection::Ltr,
10444            style,
10445            marker_position_outside: None,
10446            is_first_fragment: true,
10447            is_last_fragment: true,
10448        })
10449    };
10450
10451    let mut new_items = Vec::with_capacity(items.len() + num_kashidas_to_insert);
10452    let mut last_copy_idx = 0;
10453    for &point in &opportunity_indices {
10454        new_items.extend_from_slice(&items[last_copy_idx..point]);
10455        let mut num_to_insert = kashidas_per_point;
10456        if remainder > 0 {
10457            num_to_insert += 1;
10458            remainder -= 1;
10459        }
10460        for _ in 0..num_to_insert {
10461            new_items.push(kashida_item.clone());
10462        }
10463        last_copy_idx = point;
10464    }
10465    new_items.extend_from_slice(&items[last_copy_idx..]);
10466
10467    if let Some(msgs) = debug_messages {
10468        msgs.push(LayoutDebugMessage::info(format!(
10469            "--- Exiting justify_kashida_and_rebuild, new item count: {} ---",
10470            new_items.len()
10471        )));
10472    }
10473    new_items
10474}
10475
10476/// Helper to determine if a cluster belongs to the Arabic script.
10477fn is_arabic_cluster(cluster: &ShapedCluster) -> bool {
10478    // A cluster is considered Arabic if its first non-NotDef glyph is from the Arabic script.
10479    // This is a robust heuristic for mixed-script lines.
10480    cluster.glyphs.iter().any(|g| g.script == Script::Arabic)
10481}
10482
10483/// Helper to identify if an item is a word separator (like a space).
10484fn measure_trailing_whitespace(items: &[ShapedItem], is_vertical: bool) -> f32 {
10485    let mut trailing_ws = 0.0;
10486    for item in items.iter().rev() {
10487        if is_collapsible_whitespace(item) {
10488            trailing_ws += get_item_measure(item, is_vertical);
10489        } else {
10490            break;
10491        }
10492    }
10493    trailing_ws
10494}
10495
10496/// Returns true if the item is collapsible whitespace per CSS Text 3 §4.1.2 Phase II.
10497///
10498/// This is used for stripping leading/trailing whitespace at line edges —
10499/// distinct from `is_word_separator` which is for word-spacing per §7.1.
10500#[must_use] pub fn is_collapsible_whitespace(item: &ShapedItem) -> bool {
10501    if let ShapedItem::Cluster(c) = item {
10502        c.text.chars().all(|ch| matches!(ch,
10503            ' ' | '\t' | '\u{1680}' // Ogham space mark (collapsible per spec)
10504        ))
10505    } else {
10506        false
10507    }
10508}
10509
10510// +spec:text-alignment-spacing:456643 - cursive scripts do not admit letter-spacing gaps
10511/// Returns true if the cluster's first character belongs to a cursive script
10512/// (Arabic, Syriac, Mongolian, N'Ko, Mandaic, Phags Pa, Hanifi Rohingya)
10513/// per CSS Text 3 Appendix D.
10514///
10515/// These scripts should not have letter-spacing applied.
10516pub fn is_cursive_script_cluster(c: &ShapedCluster) -> bool {
10517    c.text.chars().next().is_some_and(is_cursive_script_char)
10518}
10519
10520fn is_cursive_script_char(ch: char) -> bool {
10521    let cp = ch as u32;
10522    // Arabic (U+0600–U+06FF, U+0750–U+077F, U+08A0–U+08FF, U+FB50–U+FDFF, U+FE70–U+FEFF)
10523    if (0x0600..=0x06FF).contains(&cp) { return true; }
10524    if (0x0750..=0x077F).contains(&cp) { return true; }
10525    if (0x08A0..=0x08FF).contains(&cp) { return true; }
10526    if (0xFB50..=0xFDFF).contains(&cp) { return true; }
10527    if (0xFE70..=0xFEFF).contains(&cp) { return true; }
10528    // Syriac (U+0700–U+074F)
10529    if (0x0700..=0x074F).contains(&cp) { return true; }
10530    // Mongolian (U+1800–U+18AF)
10531    if (0x1800..=0x18AF).contains(&cp) { return true; }
10532    // N'Ko (U+07C0–U+07FF)
10533    if (0x07C0..=0x07FF).contains(&cp) { return true; }
10534    // Mandaic (U+0840–U+085F)
10535    if (0x0840..=0x085F).contains(&cp) { return true; }
10536    // Phags Pa (U+A840–U+A87F)
10537    if (0xA840..=0xA87F).contains(&cp) { return true; }
10538    // Hanifi Rohingya (U+10D00–U+10D3F)
10539    if (0x10D00..=0x10D3F).contains(&cp) { return true; }
10540    false
10541}
10542
10543/// Word-segmentation predicate shared by word selection (double-click) and word
10544/// cursor motion (Ctrl/Alt+Arrow) so they agree on what a "word" is.
10545///
10546/// A word character is alphanumeric or underscore; everything else — whitespace
10547/// AND punctuation — is a word boundary. This is deliberately distinct from
10548/// [`is_word_separator`] (which classifies *spacing* characters for word-spacing
10549/// justification per CSS Text §7.1, and treats punctuation as non-separator).
10550/// Used by `selection::find_word_boundaries` and `UnifiedLayout::move_cursor_to_*_word`.
10551pub(crate) fn is_word_char(ch: char) -> bool {
10552    ch.is_alphanumeric() || ch == '_'
10553}
10554
10555/// True when a shaped cluster is a word-segmentation boundary (whitespace or
10556/// punctuation), i.e. it contains no word characters. Keeps cursor word-motion
10557/// consistent with `selection::find_word_boundaries`.
10558fn cluster_is_word_boundary(cluster: &ShapedCluster) -> bool {
10559    !cluster.text.chars().any(is_word_char)
10560}
10561
10562// exclude punctuation and fixed-width spaces (U+3000, U+2000..U+200A)
10563pub fn is_word_separator(item: &ShapedItem) -> bool {
10564    if let ShapedItem::Cluster(c) = item {
10565        c.text.chars().any(is_word_separator_char)
10566    } else {
10567        false
10568    }
10569}
10570
10571/// True for separators that add word-spacing but must NOT offer a soft-wrap opportunity.
10572///
10573/// (UAX#14 class GL/WJ): NBSP, NARROW NO-BREAK SPACE, WORD JOINER, ZWNBSP. These are a
10574/// subset of `is_word_separator` — they still contribute Glue, but no break Penalty.
10575#[must_use] pub fn is_no_break_space(item: &ShapedItem) -> bool {
10576    if let ShapedItem::Cluster(c) = item {
10577        c.text
10578            .chars()
10579            .any(|ch| matches!(ch, '\u{00A0}' | '\u{202F}' | '\u{2060}' | '\u{FEFF}'))
10580    } else {
10581        false
10582    }
10583}
10584
10585// +spec:margin-collapsing:6706c1 - fixed-width spaces (U+2000–U+200A, U+3000) excluded from word separators
10586/// Returns true if the character is a word-separator character per CSS Text §7.1.
10587/// Punctuation and fixed-width spaces (U+3000, U+2000 through U+200A) are NOT
10588/// word-separator characters even though they may visually separate words.
10589// +spec:text-alignment-spacing:3e0655 - word-separator characters for word-spacing
10590#[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
10591const fn is_word_separator_char(c: char) -> bool {
10592    match c {
10593        // Standard ASCII space
10594        '\u{0020}' => true,
10595        // NO-BREAK SPACE
10596        '\u{00A0}' => true,
10597        // OGHAM SPACE MARK
10598        '\u{1680}' => true,
10599        // ETHIOPIC WORDSPACE (spec §7.1)
10600        '\u{1361}' => true,
10601        // Fixed-width spaces: NOT word separators per spec
10602        '\u{2000}'..='\u{200A}' => false,
10603        // NARROW NO-BREAK SPACE
10604        '\u{202F}' => true,
10605        // MEDIUM MATHEMATICAL SPACE
10606        '\u{205F}' => true,
10607        // IDEOGRAPHIC SPACE: NOT a word separator per spec
10608        '\u{3000}' => false,
10609        // AEGEAN WORD SEPARATOR LINE (spec §7.1)
10610        '\u{10100}' => true,
10611        // AEGEAN WORD SEPARATOR DOT (spec §7.1)
10612        '\u{10101}' => true,
10613        // UGARITIC WORD DIVIDER (spec §7.1)
10614        '\u{1039F}' => true,
10615        // PHOENICIAN WORD SEPARATOR (spec §7.1)
10616        '\u{1091F}' => true,
10617        // Other Unicode whitespace not listed above
10618        _ => false,
10619    }
10620}
10621
10622/// Helper to identify if an item is a zero-width space (U+200B),
10623/// which provides a soft wrap opportunity with no visible width.
10624///
10625/// Used in scripts like Thai, Lao, and Khmer that don't use spaces between words.
10626// +spec:line-breaking:fd3164 - U+200B as explicit word delimiter for scripts without space-separated words
10627#[must_use] pub fn is_zero_width_space(item: &ShapedItem) -> bool {
10628    if let ShapedItem::Cluster(c) = item {
10629        c.text.contains('\u{200B}')
10630    } else {
10631        false
10632    }
10633}
10634
10635/// Helper to identify if space can be added after an item.
10636fn can_justify_after(item: &ShapedItem) -> bool {
10637    if let ShapedItem::Cluster(c) = item {
10638        c.text.chars().last().is_some_and(|g| {
10639            !g.is_whitespace() && classify_character(g as u32) != CharacterClass::Combining
10640        })
10641    } else {
10642        // Per CSS 2.2 §9.4.2, justification must NOT stretch inline-table and
10643        // inline-block boxes. Object items represent these atomic inline-level
10644        // boxes, so we return false to prevent adding justification space after them.
10645        false
10646    }
10647}
10648
10649// +spec:font-metrics:b8eb97 - Script group classification for justification/letter-spacing behavior
10650/// Classifies a character for layout purposes (e.g., justification behavior).
10651/// Copied from `mod.rs`.
10652#[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
10653const fn classify_character(codepoint: u32) -> CharacterClass {
10654    match codepoint {
10655        0x0020 | 0x00A0 | 0x3000 => CharacterClass::Space,
10656        0x0021..=0x002F | 0x003A..=0x0040 | 0x005B..=0x0060 | 0x007B..=0x007E => {
10657            CharacterClass::Punctuation
10658        }
10659        0x4E00..=0x9FFF | 0x3400..=0x4DBF => CharacterClass::Ideograph,
10660        0x0300..=0x036F | 0x1AB0..=0x1AFF => CharacterClass::Combining,
10661        // Mongolian script range
10662        0x1800..=0x18AF => CharacterClass::Letter,
10663        _ => CharacterClass::Letter,
10664    }
10665}
10666
10667/// Helper to get the primary measure (width or height) of a shaped item.
10668#[must_use] pub fn get_item_measure(item: &ShapedItem, is_vertical: bool) -> f32 {
10669    match item {
10670        ShapedItem::Cluster(c) => {
10671            // Total width = base advance + kerning adjustments
10672            // Kerning is stored separately in glyphs for inspection, but the total
10673            // cluster width must include it for correct layout positioning
10674            let total_kerning: f32 = c.glyphs.iter().map(|g| g.kerning).sum();
10675            c.advance + total_kerning
10676        }
10677        ShapedItem::Object { bounds, .. }
10678        | ShapedItem::CombinedBlock { bounds, .. }
10679        | ShapedItem::Tab { bounds, .. } => {
10680            if is_vertical {
10681                bounds.height
10682            } else {
10683                bounds.width
10684            }
10685        }
10686        ShapedItem::Break { .. } => 0.0,
10687    }
10688}
10689
10690/// Like [`get_item_measure`] but ALSO includes the per-cluster letter-spacing and
10691/// per-separator word-spacing that `position_one_line` adds after each cluster.
10692///
10693/// Line breaking and center/right alignment must measure the SAME width the text is
10694/// finally positioned at; `get_item_measure` alone omits letter/word-spacing, so a run
10695/// that "just fits" without spacing overflows its box (or mis-aligns) once the spacing
10696/// is applied. Selection/caret geometry must NOT include the trailing spacing, so those
10697/// callers keep using the bare `get_item_measure`.
10698#[must_use]
10699pub fn get_item_measure_with_spacing(item: &ShapedItem, is_vertical: bool) -> f32 {
10700    let base = get_item_measure(item, is_vertical);
10701    if let ShapedItem::Cluster(c) = item {
10702        let mut extra = 0.0;
10703        if !is_cursive_script_cluster(c) {
10704            extra += c.style.letter_spacing.resolve_px(c.style.font_size_px);
10705        }
10706        if is_word_separator(item) {
10707            extra += c.style.word_spacing.resolve_px(c.style.font_size_px);
10708        }
10709        base + extra
10710    } else {
10711        base
10712    }
10713}
10714
10715/// Calculates the available horizontal segments for a line at a given vertical position,
10716/// considering both shape boundaries and exclusions.
10717#[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
10718fn get_line_constraints(
10719    line_y: f32,
10720    line_height: f32,
10721    constraints: &UnifiedConstraints,
10722    debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
10723) -> LineConstraints {
10724    if let Some(msgs) = debug_messages {
10725        msgs.push(LayoutDebugMessage::info(format!(
10726            "\n--- Entering get_line_constraints for y={line_y} ---"
10727        )));
10728    }
10729
10730    let mut available_segments = Vec::new();
10731    if constraints.shape_boundaries.is_empty() {
10732        // The segment_width is determined by available_width, NOT by TextWrap.
10733        // TextWrap::NoWrap only affects whether the LineBreaker can insert soft breaks,
10734        // it should NOT override a definite width constraint from CSS.
10735        // +spec:overflow:b06c3e - text overflows when wrapping is prevented (e.g. white-space: nowrap)
10736        // CSS Text Level 3: For 'white-space: pre/nowrap', text overflows horizontally
10737        // if it doesn't fit, rather than expanding the container.
10738        //
10739        // For MinContent/MaxContent intrinsic sizing: use a large value to let text 
10740        // lay out fully. The line breaker handles min-content by breaking at word 
10741        // boundaries. The actual content width is measured from the laid-out lines.
10742        let segment_width = match constraints.available_width {
10743            AvailableSpace::Definite(w) => w, // Respect definite width from CSS
10744            AvailableSpace::MaxContent => f32::MAX / 2.0, // For intrinsic max-content sizing
10745            AvailableSpace::MinContent => f32::MAX / 2.0, // For intrinsic min-content sizing
10746        };
10747        // Note: TextWrap::NoWrap is handled by the LineBreaker in break_one_line()
10748        // to prevent soft wraps. The text will simply overflow if it exceeds segment_width.
10749        available_segments.push(LineSegment {
10750            start_x: 0.0,
10751            width: segment_width,
10752            priority: 0,
10753        });
10754    } else {
10755        // ... complex boundary logic ...
10756    }
10757
10758    if let Some(msgs) = debug_messages {
10759        msgs.push(LayoutDebugMessage::info(format!(
10760            "Initial available segments: {available_segments:?}"
10761        )));
10762    }
10763
10764    for (idx, exclusion) in constraints.shape_exclusions.iter().enumerate() {
10765        if let Some(msgs) = debug_messages {
10766            msgs.push(LayoutDebugMessage::info(format!(
10767                "Applying exclusion #{idx}: {exclusion:?}"
10768            )));
10769        }
10770        let exclusion_spans =
10771            get_shape_horizontal_spans(exclusion, line_y, line_height);
10772        if let Some(msgs) = debug_messages {
10773            msgs.push(LayoutDebugMessage::info(format!(
10774                "  Exclusion spans at y={line_y}: {exclusion_spans:?}"
10775            )));
10776        }
10777
10778        if exclusion_spans.is_empty() {
10779            continue;
10780        }
10781
10782        let mut next_segments = Vec::new();
10783        for (excl_start, excl_end) in exclusion_spans {
10784            for segment in &available_segments {
10785                let seg_start = segment.start_x;
10786                let seg_end = segment.start_x + segment.width;
10787
10788                // Create new segments by subtracting the exclusion
10789                if seg_end > excl_start && seg_start < excl_end {
10790                    if seg_start < excl_start {
10791                        // Left part
10792                        next_segments.push(LineSegment {
10793                            start_x: seg_start,
10794                            width: excl_start - seg_start,
10795                            priority: segment.priority,
10796                        });
10797                    }
10798                    if seg_end > excl_end {
10799                        // Right part
10800                        next_segments.push(LineSegment {
10801                            start_x: excl_end,
10802                            width: seg_end - excl_end,
10803                            priority: segment.priority,
10804                        });
10805                    }
10806                } else {
10807                    next_segments.push(*segment); // No overlap
10808                }
10809            }
10810            available_segments = merge_segments(next_segments);
10811            next_segments = Vec::new();
10812        }
10813        if let Some(msgs) = debug_messages {
10814            msgs.push(LayoutDebugMessage::info(format!(
10815                "  Segments after exclusion #{idx}: {available_segments:?}"
10816            )));
10817        }
10818    }
10819
10820    let total_width = available_segments.iter().map(|s| s.width).sum();
10821    if let Some(msgs) = debug_messages {
10822        msgs.push(LayoutDebugMessage::info(format!(
10823            "Final segments: {available_segments:?}, total available width: {total_width}"
10824        )));
10825        msgs.push(LayoutDebugMessage::info(
10826            "--- Exiting get_line_constraints ---".to_string(),
10827        ));
10828    }
10829
10830    LineConstraints {
10831        segments: available_segments,
10832        total_available: total_width,
10833        is_min_content: matches!(constraints.available_width, AvailableSpace::MinContent),
10834    }
10835}
10836
10837/// Flattens a parsed SVG multipolygon (from a CSS `path()` shape) into a flat list of
10838/// `PathSegment`s in absolute coordinates (offset by the reference box origin). Each ring
10839/// becomes a `MoveTo` + a run of `LineTo`s + `Close`; curve elements are sampled into line
10840/// segments (~one segment per 4px of arc length, capped) so the scanline intersection can
10841/// treat each subpath as a polygon.
10842// bounded curve-sampling geometry casts (step count / arc-length parameter / coords)
10843#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss)]
10844fn flatten_svg_to_path_segments(
10845    multipolygon: &azul_core::svg::SvgMultiPolygon,
10846    reference_box: Rect,
10847) -> Vec<PathSegment> {
10848    use azul_core::svg::SvgPathElement;
10849
10850    let mut out: Vec<PathSegment> = Vec::new();
10851
10852    for ring in multipolygon.rings.as_ref() {
10853        let elements = ring.items.as_ref();
10854        if elements.is_empty() {
10855            continue;
10856        }
10857        let start = elements[0].get_start();
10858        out.push(PathSegment::MoveTo(Point {
10859            x: reference_box.x + start.x,
10860            y: reference_box.y + start.y,
10861        }));
10862        for el in elements {
10863            match el {
10864                SvgPathElement::Line(l) => {
10865                    out.push(PathSegment::LineTo(Point {
10866                        x: reference_box.x + l.end.x,
10867                        y: reference_box.y + l.end.y,
10868                    }));
10869                }
10870                curve => {
10871                    // Sample the curve by arc length into line segments.
10872                    let len = curve.get_length();
10873                    let steps = ((len / 4.0).ceil() as usize).clamp(1, 64);
10874                    for i in 1..=steps {
10875                        let offset = len * (i as f64) / (steps as f64);
10876                        let t = curve.get_t_at_offset(offset);
10877                        out.push(PathSegment::LineTo(Point {
10878                            x: reference_box.x + curve.get_x_at_t(t) as f32,
10879                            y: reference_box.y + curve.get_y_at_t(t) as f32,
10880                        }));
10881                    }
10882                }
10883            }
10884        }
10885        out.push(PathSegment::Close);
10886    }
10887
10888    out
10889}
10890
10891/// Computes horizontal line segments where a flattened `path()` shape (a set of
10892/// `MoveTo`/`LineTo`/`Close` subpaths) intersects a scanline at the given y range. Uses an
10893/// even-odd fill rule over the union of all subpaths so reversed rings (holes) carve out
10894/// space. Curves are assumed already flattened to `LineTo`s by `flatten_svg_to_path_segments`.
10895fn path_segments_line_intersection(
10896    segments: &[PathSegment],
10897    y: f32,
10898    line_height: f32,
10899) -> Vec<(f32, f32)> {
10900    let line_center_y = y + line_height / 2.0;
10901    let mut crossings: Vec<f32> = Vec::new();
10902
10903    // Walk the segments, reconstructing each subpath's vertices and intersecting its
10904    // (closing) edges with the scanline.
10905    let mut subpath: Vec<Point> = Vec::new();
10906    let flush = |subpath: &mut Vec<Point>, crossings: &mut Vec<f32>| {
10907        if subpath.len() >= 2 {
10908            for i in 0..subpath.len() {
10909                let p1 = subpath[i];
10910                let p2 = subpath[(i + 1) % subpath.len()];
10911                if (p2.y - p1.y).abs() < f32::EPSILON {
10912                    continue;
10913                }
10914                let crosses = (p1.y <= line_center_y && p2.y > line_center_y)
10915                    || (p1.y > line_center_y && p2.y <= line_center_y);
10916                if crosses {
10917                    let t = (line_center_y - p1.y) / (p2.y - p1.y);
10918                    crossings.push(t.mul_add(p2.x - p1.x, p1.x));
10919                }
10920            }
10921        }
10922        subpath.clear();
10923    };
10924
10925    for seg in segments {
10926        match seg {
10927            PathSegment::MoveTo(p) => {
10928                flush(&mut subpath, &mut crossings);
10929                subpath.push(*p);
10930            }
10931            PathSegment::LineTo(p) => subpath.push(*p),
10932            PathSegment::Close => flush(&mut subpath, &mut crossings),
10933            // CurveTo/QuadTo/Arc should have been flattened to LineTo already; sample the
10934            // end point as a fallback so an unflattened path still produces a polygon.
10935            PathSegment::CurveTo { end, .. } | PathSegment::QuadTo { end, .. } => {
10936                subpath.push(*end);
10937            }
10938            PathSegment::Arc { center, .. } => subpath.push(*center),
10939        }
10940    }
10941    flush(&mut subpath, &mut crossings);
10942
10943    crossings.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
10944    let mut spans = Vec::new();
10945    for chunk in crossings.chunks_exact(2) {
10946        if chunk[1] > chunk[0] {
10947            spans.push((chunk[0], chunk[1]));
10948        }
10949    }
10950    spans
10951}
10952
10953/// Helper function to get the horizontal spans of any shape at a given y-coordinate.
10954/// Returns a list of (`start_x`, `end_x`) tuples.
10955#[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
10956fn get_shape_horizontal_spans(
10957    shape: &ShapeBoundary,
10958    y: f32,
10959    line_height: f32,
10960) -> Vec<(f32, f32)> {
10961    match shape {
10962        ShapeBoundary::Rectangle(rect) => {
10963            // Check for any overlap between the line box [y, y + line_height]
10964            // and the rectangle's vertical span [rect.y, rect.y + rect.height].
10965            let line_start = y;
10966            let line_end = y + line_height;
10967            let rect_start = rect.y;
10968            let rect_end = rect.y + rect.height;
10969
10970            if line_start < rect_end && line_end > rect_start {
10971                vec![(rect.x, rect.x + rect.width)]
10972            } else {
10973                vec![]
10974            }
10975        }
10976        ShapeBoundary::Circle { center, radius } => {
10977            let line_center_y = y + line_height / 2.0;
10978            let dy = (line_center_y - center.y).abs();
10979            if dy <= *radius {
10980                let dx = (radius.powi(2) - dy.powi(2)).sqrt();
10981                vec![(center.x - dx, center.x + dx)]
10982            } else {
10983                vec![]
10984            }
10985        }
10986        ShapeBoundary::Ellipse { center, radii } => {
10987            let line_center_y = y + line_height / 2.0;
10988            let dy = line_center_y - center.y;
10989            if dy.abs() <= radii.height {
10990                // Formula: (x-h)^2/a^2 + (y-k)^2/b^2 = 1
10991                let y_term = dy / radii.height;
10992                let x_term_squared = 1.0 - y_term.powi(2);
10993                if x_term_squared >= 0.0 {
10994                    let dx = radii.width * x_term_squared.sqrt();
10995                    vec![(center.x - dx, center.x + dx)]
10996                } else {
10997                    vec![]
10998                }
10999            } else {
11000                vec![]
11001            }
11002        }
11003        ShapeBoundary::Polygon { points } => {
11004            let segments = polygon_line_intersection(points, y, line_height);
11005            segments
11006                .iter()
11007                .map(|s| (s.start_x, s.start_x + s.width))
11008                .collect()
11009        }
11010        // Scanline intersection for `path()` shapes. `segments` is the flattened
11011        // (Close-terminated, curves pre-sampled) output of `flatten_svg_to_path_segments`;
11012        // intersect each subpath polygon with this scanline under an even-odd fill rule so
11013        // reversed rings (holes) carve out space.
11014        ShapeBoundary::Path { segments } => {
11015            path_segments_line_intersection(segments, y, line_height)
11016        }
11017    }
11018}
11019
11020/// Merges overlapping or adjacent line segments into larger ones.
11021fn merge_segments(mut segments: Vec<LineSegment>) -> Vec<LineSegment> {
11022    if segments.len() <= 1 {
11023        return segments;
11024    }
11025    segments.sort_by(|a, b| a.start_x.partial_cmp(&b.start_x).unwrap_or(Ordering::Equal));
11026    let mut merged = vec![segments[0]];
11027    for next_seg in segments.iter().skip(1) {
11028        let last = merged.last_mut().unwrap();
11029        if next_seg.start_x <= last.start_x + last.width {
11030            let new_width = (next_seg.start_x + next_seg.width) - last.start_x;
11031            last.width = last.width.max(new_width);
11032        } else {
11033            merged.push(*next_seg);
11034        }
11035    }
11036    merged
11037}
11038
11039/// Computes horizontal line segments where a polygon intersects a scanline at the given y range.
11040#[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
11041fn polygon_line_intersection(
11042    points: &[Point],
11043    y: f32,
11044    line_height: f32,
11045) -> Vec<LineSegment> {
11046    if points.len() < 3 {
11047        return vec![];
11048    }
11049
11050    let line_center_y = y + line_height / 2.0;
11051    let mut intersections = Vec::new();
11052
11053    // Use winding number algorithm for robustness with complex polygons.
11054    for i in 0..points.len() {
11055        let p1 = points[i];
11056        let p2 = points[(i + 1) % points.len()];
11057
11058        // Skip horizontal edges as they don't intersect a horizontal scanline in a meaningful way.
11059        if (p2.y - p1.y).abs() < f32::EPSILON {
11060            continue;
11061        }
11062
11063        // Check if our horizontal scanline at `line_center_y` crosses this polygon edge.
11064        let crosses = (p1.y <= line_center_y && p2.y > line_center_y)
11065            || (p1.y > line_center_y && p2.y <= line_center_y);
11066
11067        if crosses {
11068            // Calculate intersection x-coordinate using linear interpolation.
11069            let t = (line_center_y - p1.y) / (p2.y - p1.y);
11070            let x = p1.x + t * (p2.x - p1.x);
11071            intersections.push(x);
11072        }
11073    }
11074
11075    // Sort intersections by x-coordinate to form spans.
11076    intersections.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
11077
11078    // Build segments from paired intersection points.
11079    let mut segments = Vec::new();
11080    for chunk in intersections.chunks_exact(2) {
11081        let start_x = chunk[0];
11082        let end_x = chunk[1];
11083        if end_x > start_x {
11084            segments.push(LineSegment {
11085                start_x,
11086                width: end_x - start_x,
11087                priority: 0,
11088            });
11089        }
11090    }
11091
11092    segments
11093}
11094
11095// ADDITION: A helper function to get a hyphenator.
11096/// Helper to get a hyphenator for a given language.
11097/// TODO: In a real app, this would be cached.
11098#[cfg(feature = "text_layout_hyphenation")]
11099fn get_hyphenator(language: HyphenationLanguage) -> Result<Standard, LayoutError> {
11100    Standard::from_embedded(language).map_err(|e| LayoutError::HyphenationError(e.to_string()))
11101}
11102
11103/// Stub when hyphenation is disabled - always returns an error
11104#[cfg(not(feature = "text_layout_hyphenation"))]
11105fn get_hyphenator(_language: Language) -> Result<Standard, LayoutError> {
11106    Err(LayoutError::HyphenationError("Hyphenation feature not enabled".to_string()))
11107}
11108
11109// +spec:inline-block:6e7dd9 - Non-tailorable Unicode line breaking controls take precedence over atomic inline rules (CSS-TEXT-3 recent changes, issue 8972)
11110
11111const fn is_break_suppressing_control(ch: char) -> bool {
11112    matches!(ch,
11113        '\u{200D}' | // ZERO WIDTH JOINER
11114        '\u{2060}' | // WORD JOINER
11115        '\u{FEFF}'   // ZERO WIDTH NO-BREAK SPACE
11116    )
11117}
11118
11119const fn is_break_forcing_control(ch: char) -> bool {
11120    matches!(ch,
11121        '\u{200B}' | // ZERO WIDTH SPACE (already handled but included for completeness)
11122        '\u{2028}' | // LINE SEPARATOR
11123        '\u{2029}'   // PARAGRAPH SEPARATOR
11124    )
11125}
11126
11127// +spec:line-breaking:495247 - CJK/syllabic writing systems allow breaks between typographic letter units with varying strictness
11128// §5.2 word-break: determines if a character is CJK ideograph/kana
11129const fn is_cjk_character(ch: char) -> bool {
11130    let cp = ch as u32;
11131    matches!(cp,
11132        // CJK Unified Ideographs
11133        0x4E00..=0x9FFF |
11134        // CJK Unified Ideographs Extension A
11135        0x3400..=0x4DBF |
11136        // CJK Unified Ideographs Extension B
11137        0x20000..=0x2A6DF |
11138        // CJK Compatibility Ideographs
11139        0xF900..=0xFAFF |
11140        // Hiragana
11141        0x3040..=0x309F |
11142        // Katakana
11143        0x30A0..=0x30FF |
11144        // Katakana Phonetic Extensions
11145        0x31F0..=0x31FF |
11146        // CJK Symbols and Punctuation
11147        0x3000..=0x303F |
11148        // Halfwidth and Fullwidth Forms
11149        0xFF00..=0xFFEF |
11150        // Hangul Syllables
11151        0xAC00..=0xD7AF
11152    )
11153}
11154
11155// §5.2 word-break: checks if a cluster contains CJK characters
11156fn is_cjk_cluster(cluster: &ShapedCluster) -> bool {
11157    cluster.text.chars().any(is_cjk_character)
11158}
11159
11160// +spec:line-breaking:e1fc9d - word-break normal/break-all/keep-all break opportunity rules
11161// +spec:line-breaking:73d5fe - word-break break-point determination for CJK and Latin text
11162// +spec:line-breaking:31ef1a - word-break property controls soft wrap opportunities between letters (NU/AL/AI/ID classes as letter units)
11163// +spec:line-breaking:798252 - word-break property affects break opportunities (normal/break-all/keep-all)
11164// +spec:line-breaking:8fed57 - word-break: break-all treats all clusters as break opportunities, keep-all suppresses CJK breaks
11165// +spec:line-breaking:e2b374 - word-break: normal (only at separators) vs break-all (between all letters incl. Ethiopic)
11166// +spec:overflow:53a97f - word-break (normal/break-all/keep-all) and line-break strictness rules
11167// +spec:line-breaking:1c830a - word-break: normal/break-all/keep-all break opportunity rules
11168// §5.2 word-break property: break opportunity logic
11169// +spec:line-breaking:a75147 - word-break property: normal (CJK breaks), break-all (every cluster), keep-all (suppress CJK breaks)
11170// +spec:line-breaking:65ab41 - word-break: normal/break-all/keep-all break opportunity rules
11171// +spec:line-breaking:7eca16 - U+200B ZERO WIDTH SPACE is always a break opportunity, even with keep-all
11172fn is_break_opportunity_with_word_break(item: &ShapedItem, word_break: WordBreak, hyphens: Hyphens) -> bool {
11173    // No-break spaces (UAX#14 class GL/WJ) are word separators for word-spacing
11174    // purposes but must NOT offer a soft-wrap opportunity. This is the segmentation
11175    // path used by BreakCursor::peek_next_unit, so it must suppress them the same way
11176    // the dedicated is_break_opportunity() does; otherwise "10\u{00A0}km" wrongly wraps.
11177    if let ShapedItem::Cluster(c) = item {
11178        if c.text
11179            .chars()
11180            .any(|ch| matches!(ch, '\u{00A0}' | '\u{202F}' | '\u{2060}' | '\u{FEFF}'))
11181        {
11182            return false;
11183        }
11184    }
11185    // Break after spaces or explicit break items (always, regardless of word-break).
11186    if is_word_separator(item) {
11187        return true;
11188    }
11189    if let ShapedItem::Break { .. } = item {
11190        return true;
11191    }
11192    // +spec:line-breaking:432d5b - hyphens property controls soft wrap opportunities via hyphenation
11193    // +spec:line-breaking:5a32a1 - soft hyphen (U+00AD) creates break opportunity; glyph styled per surrounding text properties
11194    // U+200B ZERO WIDTH SPACE is always a soft wrap opportunity regardless of word-break.
11195    // This allows authors to mark explicit wrap points (e.g. with <wbr> or &#x200B;)
11196    // even when using word-break: keep-all to suppress other breaks.
11197    if is_zero_width_space(item) {
11198        return true;
11199    }
11200    // only when hyphens != none. With hyphens:none, soft hyphens do not create break points.
11201    if hyphens != Hyphens::None {
11202        if let ShapedItem::Cluster(c) = item {
11203            if c.text.starts_with('\u{00AD}') {
11204                return true;
11205            }
11206        }
11207    }
11208
11209    // +spec:line-breaking:05e09a - U+002D HYPHEN-MINUS / U+2010 HYPHEN always create a
11210    // soft-wrap opportunity AFTER them (UAX#14 class HY/BA), independent of the hyphens
11211    // property (they are NOT hyphenation opportunities — no extra glyph is inserted).
11212    // U+002F SOLIDUS (UAX#14 class SY) likewise offers a break AFTER it (URLs/paths),
11213    // matching browser practice. Mirrors is_break_opportunity(); this predicate drives
11214    // the greedy BreakCursor path, which previously never broke after a plain hyphen/slash.
11215    if let ShapedItem::Cluster(c) = item {
11216        if c.text.ends_with('\u{002D}') || c.text.ends_with('\u{2010}') || c.text.ends_with('\u{002F}') {
11217            return true;
11218        }
11219    }
11220
11221    // +spec:line-breaking:2bbda0 - word-break does not affect soft wrap opportunities around punctuation
11222    match word_break {
11223        WordBreak::Normal => {
11224            // CJK characters are implicit break opportunities in normal mode.
11225            if let ShapedItem::Cluster(c) = item {
11226                if is_cjk_cluster(c) {
11227                    return true;
11228                }
11229            }
11230            false
11231        }
11232        WordBreak::BreakAll => {
11233            // Every typographic letter unit is a break opportunity.
11234            if let ShapedItem::Cluster(_) = item {
11235                return true;
11236            }
11237            false
11238        }
11239        WordBreak::KeepAll => {
11240            // +spec:line-breaking:aa3044 - keep-all suppresses CJK (incl. Korean) inter-character breaks
11241            // Only break at spaces/hyphens (already handled above).
11242            false
11243        }
11244    }
11245}
11246
11247// +spec:line-breaking:db0289 - line-break strictness: anywhere allows soft wrap around every typographic character unit
11248// +spec:line-breaking:7d242b - line-break strictness levels: loose/normal/strict/anywhere with CJK punctuation rules
11249// +spec:line-breaking:67bfe8 - line-break strictness (auto/loose/normal/strict/anywhere) controls
11250// CSS Text Level 3 §5.3: Determines whether a break opportunity before a character is
11251// allowed based on the line-break strictness level. The spec defines:
11252// - strict: forbids breaks before small kana (class CJ), CJK hyphens, and certain punctuation
11253// - normal: allows breaks before small kana (CJ); allows CJK hyphen breaks for CJK writing systems
11254// - loose: additionally allows breaks before hyphens U+2010/U+2013 after ID-class chars
11255// - anywhere: allows soft wrap around every typographic character unit
11256#[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
11257const fn is_cjk_break_allowed_by_strictness(
11258    ch: char,
11259    _prev_ch: Option<char>,
11260    strictness: LineBreakStrictness,
11261) -> bool {
11262    match strictness {
11263        LineBreakStrictness::Anywhere => true,
11264        LineBreakStrictness::Loose => {
11265            // Loose allows breaks before hyphens U+2010, U+2013 when preceded by ID-class chars
11266            // Also allows breaks before small kana (CJ class) and CJK hyphens
11267            true
11268        }
11269        LineBreakStrictness::Normal | LineBreakStrictness::Auto => {
11270            // Normal forbids breaks before hyphens U+2010/U+2013 for non-CJK text
11271            // but allows breaks before small kana (CJ) and CJK hyphen-like chars
11272            // (〜 U+301C, ゠ U+30A0) for CJK writing systems
11273            match ch {
11274                '\u{2010}' | '\u{2013}' => false, // hyphens forbidden in normal
11275                _ => true,
11276            }
11277        }
11278        LineBreakStrictness::Strict => {
11279            // Strict forbids breaks before:
11280            // - Small kana and prolonged sound mark (Unicode line break class CJ)
11281            // - CJK hyphen-like characters: 〜 U+301C, ゠ U+30A0
11282            // - Hyphens: ‐ U+2010, – U+2013
11283            match ch {
11284                '\u{301C}' | '\u{30A0}' => false, // CJK hyphen-like
11285                '\u{2010}' | '\u{2013}' => false,  // hyphens
11286                c if is_small_kana(c) => false,
11287                _ => true,
11288            }
11289        }
11290    }
11291}
11292
11293/// Returns true if the character is a Japanese small kana or Katakana-Hiragana prolonged sound mark
11294/// (Unicode line break class CJ). These are forbidden break points in strict line breaking.
11295const fn is_small_kana(ch: char) -> bool {
11296    matches!(ch,
11297        '\u{3041}' | // ぁ HIRAGANA LETTER SMALL A
11298        '\u{3043}' | // ぃ HIRAGANA LETTER SMALL I
11299        '\u{3045}' | // ぅ HIRAGANA LETTER SMALL U
11300        '\u{3047}' | // ぇ HIRAGANA LETTER SMALL E
11301        '\u{3049}' | // ぉ HIRAGANA LETTER SMALL O
11302        '\u{3063}' | // っ HIRAGANA LETTER SMALL TU
11303        '\u{3083}' | // ゃ HIRAGANA LETTER SMALL YA
11304        '\u{3085}' | // ゅ HIRAGANA LETTER SMALL YU
11305        '\u{3087}' | // ょ HIRAGANA LETTER SMALL YO
11306        '\u{308E}' | // ゎ HIRAGANA LETTER SMALL WA
11307        '\u{3095}' | // ゕ HIRAGANA LETTER SMALL KA
11308        '\u{3096}' | // ゖ HIRAGANA LETTER SMALL KE
11309        '\u{30A1}' | // ァ KATAKANA LETTER SMALL A
11310        '\u{30A3}' | // ィ KATAKANA LETTER SMALL I
11311        '\u{30A5}' | // ゥ KATAKANA LETTER SMALL U
11312        '\u{30A7}' | // ェ KATAKANA LETTER SMALL E
11313        '\u{30A9}' | // ォ KATAKANA LETTER SMALL O
11314        '\u{30C3}' | // ッ KATAKANA LETTER SMALL TU
11315        '\u{30E3}' | // ャ KATAKANA LETTER SMALL YA
11316        '\u{30E5}' | // ュ KATAKANA LETTER SMALL YU
11317        '\u{30E7}' | // ョ KATAKANA LETTER SMALL YO
11318        '\u{30EE}' | // ヮ KATAKANA LETTER SMALL WA
11319        '\u{30F5}' | // ヵ KATAKANA LETTER SMALL KA
11320        '\u{30F6}' | // ヶ KATAKANA LETTER SMALL KE
11321        '\u{30FC}'   // ー KATAKANA-HIRAGANA PROLONGED SOUND MARK
11322    )
11323}
11324
11325// for every typographic character unit, disregarding GL/WJ/ZWJ line breaking classes
11326// replaced element or other atomic inline for web-compat
11327fn is_break_opportunity(item: &ShapedItem) -> bool {
11328    // Per CSS Text 3 §5.1: "there is a soft wrap opportunity before and
11329    // after each replaced element or other atomic inline"
11330    if matches!(item, ShapedItem::Object { .. } | ShapedItem::CombinedBlock { .. }) {
11331        return true;
11332    }
11333    // over atomic inline rules: break-forcing controls (ZWSP, LS, PS) create break opportunities
11334    // even adjacent to atomic inlines, while break-suppressing controls (WJ, ZWJ, ZWNBSP)
11335    // prevent breaks
11336    if let ShapedItem::Cluster(c) = item {
11337        // ZW (zero-width space U+200B) is always a break opportunity
11338        if c.text.contains('\u{200B}') {
11339            return true;
11340        }
11341        // Break-forcing Unicode controls (LS, PS) create break opportunities
11342        if c.text.chars().any(is_break_forcing_control) {
11343            return true;
11344        }
11345        // WJ (word joiner U+2060), ZWJ (U+200D), and GL (NBSP U+00A0) suppress breaks
11346        if c.text.chars().any(|ch| matches!(ch, '\u{2060}' | '\u{200D}' | '\u{00A0}')) {
11347            return false;
11348        }
11349        // +spec:line-breaking:05e09a - U+002D/U+2010 always create soft wrap opportunities regardless of hyphens property
11350        // are always visible and create a soft wrap opportunity after them, but are NOT
11351        // hyphenation opportunities (no extra glyph is inserted at the break).
11352        if c.text.ends_with('\u{002D}') || c.text.ends_with('\u{2010}') {
11353            return true;
11354        }
11355    }
11356    is_break_opportunity_with_word_break(item, WordBreak::Normal, Hyphens::Manual)
11357}
11358
11359// A cursor to manage the state of the line breaking process.
11360// This allows us to handle items that are partially consumed by hyphenation.
11361// `Clone` is used to take a cheap snapshot for the multi-column balancing dry run
11362// (measuring total line count without consuming the real cursor).
11363#[derive(Debug, Clone)]
11364pub struct BreakCursor<'a> {
11365    /// A reference to the complete list of shaped items.
11366    pub items: &'a [ShapedItem],
11367    /// The index of the next *full* item to be processed from the `items` slice.
11368    pub next_item_index: usize,
11369    /// The remainder of an item that was split by hyphenation on the previous line.
11370    /// This will be the very first piece of content considered for the next line.
11371    pub partial_remainder: Vec<ShapedItem>,
11372    // §5.2 word-break property stored on cursor
11373    pub word_break: WordBreak,
11374    pub hyphens: Hyphens,
11375    pub line_break: LineBreakStrictness,
11376}
11377
11378impl<'a> BreakCursor<'a> {
11379    #[must_use] pub fn new(items: &'a [ShapedItem]) -> Self {
11380        Self {
11381            items,
11382            next_item_index: 0,
11383            partial_remainder: Vec::new(),
11384            word_break: WordBreak::Normal,
11385            hyphens: Hyphens::default(),
11386            line_break: LineBreakStrictness::default(),
11387        }
11388    }
11389
11390    #[must_use] pub fn with_word_break(items: &'a [ShapedItem], word_break: WordBreak) -> Self {
11391        Self {
11392            items,
11393            next_item_index: 0,
11394            partial_remainder: Vec::new(),
11395            word_break,
11396            hyphens: Hyphens::default(),
11397            line_break: LineBreakStrictness::default(),
11398        }
11399    }
11400
11401    /// Checks if the cursor is at the very beginning of the content stream.
11402    #[must_use] pub const fn is_at_start(&self) -> bool {
11403        self.next_item_index == 0 && self.partial_remainder.is_empty()
11404    }
11405
11406    /// Consumes the cursor and returns all remaining items as a `Vec`.
11407    pub fn drain_remaining(&mut self) -> Vec<ShapedItem> {
11408        let mut remaining = std::mem::take(&mut self.partial_remainder);
11409        if self.next_item_index < self.items.len() {
11410            remaining.extend_from_slice(&self.items[self.next_item_index..]);
11411        }
11412        self.next_item_index = self.items.len();
11413        remaining
11414    }
11415
11416    /// Checks if all content, including any partial remainders, has been processed.
11417    #[must_use] pub const fn is_done(&self) -> bool {
11418        self.next_item_index >= self.items.len() && self.partial_remainder.is_empty()
11419    }
11420
11421    /// Consumes a number of items from the cursor's stream.
11422    pub fn consume(&mut self, count: usize) {
11423        if count == 0 {
11424            return;
11425        }
11426
11427        let remainder_len = self.partial_remainder.len();
11428        if count <= remainder_len {
11429            // Consuming only from the remainder.
11430            self.partial_remainder.drain(..count);
11431        } else {
11432            // Consuming all of the remainder and some from the main list.
11433            let from_main_list = count - remainder_len;
11434            self.partial_remainder.clear();
11435            self.next_item_index += from_main_list;
11436        }
11437    }
11438
11439    /// Looks ahead and returns the next "unbreakable" unit of content.
11440    /// This is typically a word (a series of non-space clusters) followed by a
11441    /// space, or just a single space if that's next.
11442    /// The definition of "unbreakable unit" depends on the word-break property.
11443    // a single typographic character unit (every character is a soft wrap opportunity), including
11444    // punctuation and preserved white spaces; currently handled via peek_next_single_item
11445    pub fn peek_next_unit(&self) -> Vec<ShapedItem> {
11446        let mut unit = Vec::new();
11447        let mut source_items = self.partial_remainder.clone();
11448        source_items.extend_from_slice(&self.items[self.next_item_index..]);
11449
11450        if source_items.is_empty() {
11451            return unit;
11452        }
11453
11454        // If the first item is a break opportunity (like a space), it's a unit on its own.
11455        if is_break_opportunity_with_word_break(&source_items[0], self.word_break, self.hyphens) {
11456            unit.push(source_items[0].clone());
11457            return unit;
11458        }
11459
11460        // Otherwise, collect all items until the next break opportunity.
11461        // For break-all: each cluster is its own unit.
11462        // For keep-all: CJK sequences are NOT break opportunities.
11463        // For normal: CJK characters are individual break opportunities.
11464        // glue items together: if the last cluster ends with a break-suppressing control,
11465        // the next item cannot be separated from it.
11466        let mut suppress_next_break = false;
11467        for (i, item) in source_items.iter().enumerate() {
11468            // Also suppress break if this item starts with a break-suppressing control
11469            // (WJ/ZWJ/ZWNBSP suppress breaks on both sides per Unicode line breaking)
11470            let starts_with_suppress = if let ShapedItem::Cluster(c) = item {
11471                c.text.chars().next().is_some_and(is_break_suppressing_control)
11472            } else {
11473                false
11474            };
11475            // If the item is a CJK cluster, check if the break is allowed by strictness
11476            let cjk_strictness_suppressed = if let ShapedItem::Cluster(c) = item {
11477                c.text.chars().next().is_some_and(|ch| {
11478                    !is_cjk_break_allowed_by_strictness(ch, None, self.line_break)
11479                })
11480            } else {
11481                false
11482            };
11483            if i > 0 && !suppress_next_break && !starts_with_suppress && !cjk_strictness_suppressed && is_break_opportunity_with_word_break(item, self.word_break, self.hyphens) {
11484                break;
11485            }
11486            suppress_next_break = false;
11487            unit.push(item.clone());
11488
11489            // Check if this item ends with a break-suppressing control character
11490            if let ShapedItem::Cluster(c) = item {
11491                if let Some(last_ch) = c.text.chars().last() {
11492                    if is_break_suppressing_control(last_ch) {
11493                        suppress_next_break = true;
11494                    }
11495                }
11496            }
11497
11498            // For break-all, each non-space cluster is a unit on its own
11499            if self.word_break == WordBreak::BreakAll {
11500                if let ShapedItem::Cluster(_) = item {
11501                    break;
11502                }
11503            }
11504        }
11505        unit
11506    }
11507
11508    #[must_use] pub fn peek_next_single_item(&self) -> Vec<ShapedItem> {
11509        if !self.partial_remainder.is_empty() {
11510            return vec![self.partial_remainder[0].clone()];
11511        }
11512        if self.next_item_index < self.items.len() {
11513            return vec![self.items[self.next_item_index].clone()];
11514        }
11515        Vec::new()
11516    }
11517}
11518
11519// A structured result from a hyphenation attempt.
11520struct HyphenationResult {
11521    /// The items that fit on the current line, including the new hyphen.
11522    line_part: Vec<ShapedItem>,
11523    /// The remainder of the split item to be carried over to the next line.
11524    remainder_part: Vec<ShapedItem>,
11525}
11526
11527fn perform_bidi_analysis<'a>(
11528    styled_runs: &'a [TextRunInfo<'_>],
11529    full_text: &'a str,
11530    force_lang: Option<Language>,
11531) -> (Vec<VisualRun<'a>>, BidiDirection) {
11532    if full_text.is_empty() {
11533        return (Vec::new(), BidiDirection::Ltr);
11534    }
11535
11536    let bidi_info = BidiInfo::new(full_text, None);
11537    let para = &bidi_info.paragraphs[0];
11538    let base_direction = if para.level.is_rtl() {
11539        BidiDirection::Rtl
11540    } else {
11541        BidiDirection::Ltr
11542    };
11543
11544    // Create a map from each byte index to its original styled run.
11545    let mut byte_to_run_index: Vec<usize> = vec![0; full_text.len()];
11546    for (run_idx, run) in styled_runs.iter().enumerate() {
11547        let start = run.logical_start;
11548        let end = start + run.text.len();
11549        for slot in &mut byte_to_run_index[start..end] {
11550            *slot = run_idx;
11551        }
11552    }
11553
11554    let mut final_visual_runs = Vec::new();
11555    let (levels, visual_run_ranges) = bidi_info.visual_runs(para, para.range.clone());
11556
11557    for range in visual_run_ranges {
11558        let bidi_level = levels[range.start];
11559        let mut sub_run_start = range.start;
11560
11561        // Iterate through the bytes of the visual run to detect style changes.
11562        for i in (range.start + 1)..range.end {
11563            if byte_to_run_index[i] != byte_to_run_index[sub_run_start] {
11564                // Style boundary found. Finalize the previous sub-run.
11565                let original_run_idx = byte_to_run_index[sub_run_start];
11566                let script = crate::text3::script::detect_script(&full_text[sub_run_start..i])
11567                    .unwrap_or(Script::Latin);
11568                final_visual_runs.push(VisualRun {
11569                    text_slice: &full_text[sub_run_start..i],
11570                    style: styled_runs[original_run_idx].style.clone(),
11571                    logical_start_byte: sub_run_start,
11572                    bidi_level: BidiLevel::new(bidi_level.number()),
11573                    language: force_lang.unwrap_or_else(|| {
11574                        script_to_language(
11575                            script,
11576                            &full_text[sub_run_start..i],
11577                        )
11578                    }),
11579                    script,
11580                });
11581                // Start a new sub-run.
11582                sub_run_start = i;
11583            }
11584        }
11585
11586        // Add the last sub-run (or the only one if no style change occurred).
11587        let original_run_idx = byte_to_run_index[sub_run_start];
11588        let script = crate::text3::script::detect_script(&full_text[sub_run_start..range.end])
11589            .unwrap_or(Script::Latin);
11590
11591        final_visual_runs.push(VisualRun {
11592            text_slice: &full_text[sub_run_start..range.end],
11593            style: styled_runs[original_run_idx].style.clone(),
11594            logical_start_byte: sub_run_start,
11595            bidi_level: BidiLevel::new(bidi_level.number()),
11596            script,
11597            language: force_lang.unwrap_or_else(|| {
11598                script_to_language(
11599                    script,
11600                    &full_text[sub_run_start..range.end],
11601                )
11602            }),
11603        });
11604    }
11605
11606    (final_visual_runs, base_direction)
11607}
11608
11609const fn get_justification_priority(class: CharacterClass) -> u8 {
11610    match class {
11611        CharacterClass::Space => 0,
11612        CharacterClass::Punctuation => 64,
11613        CharacterClass::Ideograph => 128,
11614        CharacterClass::Letter => 192,
11615        CharacterClass::Symbol => 224,
11616        CharacterClass::Combining => 255,
11617    }
11618}
11619
11620#[cfg(test)]
11621mod shape_outside_and_ruby_tests {
11622    use super::*;
11623    use azul_css::shape::{CssShape, ShapePath};
11624
11625    fn path_shape(d: &str) -> CssShape {
11626        CssShape::Path(ShapePath {
11627            data: d.into(),
11628        })
11629    }
11630
11631    // --- shape-outside: path() ----------------------------------------------
11632
11633    #[test]
11634    fn css_path_shape_builds_path_boundary_not_rect_fallback() {
11635        // A right triangle (0,0)-(100,0)-(0,100).
11636        let shape = path_shape("M 0 0 L 100 0 L 0 100 Z");
11637        let rbox = Rect { x: 0.0, y: 0.0, width: 100.0, height: 100.0 };
11638        let boundary = ShapeBoundary::from_css_shape(&shape, rbox, &mut None);
11639        match boundary {
11640            ShapeBoundary::Path { segments } => {
11641                assert!(!segments.is_empty(), "path() must flatten to real segments");
11642                assert!(matches!(segments[0], PathSegment::MoveTo(_)));
11643                assert!(segments.iter().any(|s| matches!(s, PathSegment::Close)));
11644            }
11645            other => panic!("expected ShapeBoundary::Path, got {other:?}"),
11646        }
11647    }
11648
11649    #[test]
11650    fn empty_or_garbage_path_falls_back_to_rectangle() {
11651        let rbox = Rect { x: 0.0, y: 0.0, width: 50.0, height: 50.0 };
11652        let boundary = ShapeBoundary::from_css_shape(&path_shape("   "), rbox, &mut None);
11653        assert!(matches!(boundary, ShapeBoundary::Rectangle(_)),
11654            "unparseable path() should fall back to the reference rectangle");
11655    }
11656
11657    #[test]
11658    fn path_triangle_narrows_line_box_per_scanline() {
11659        // Right triangle with the hypotenuse running (100,0) -> (0,100).
11660        // At scanline y, the shape spans x in [0, 100 - y]. So the available band
11661        // must NARROW as y increases — the proof that real path geometry (not a
11662        // full-width rect) drives the per-line exclusion.
11663        let shape = path_shape("M 0 0 L 100 0 L 0 100 Z");
11664        let rbox = Rect { x: 0.0, y: 0.0, width: 100.0, height: 100.0 };
11665        let boundary = ShapeBoundary::from_css_shape(&shape, rbox, &mut None);
11666
11667        let spans_top = get_shape_horizontal_spans(&boundary, 10.0, 1.0);
11668        let spans_bot = get_shape_horizontal_spans(&boundary, 80.0, 1.0);
11669
11670        assert_eq!(spans_top.len(), 1, "single span expected near the top");
11671        assert_eq!(spans_bot.len(), 1, "single span expected near the bottom");
11672
11673        let width_top = spans_top[0].1 - spans_top[0].0;
11674        let width_bot = spans_bot[0].1 - spans_bot[0].0;
11675
11676        // Geometry check: width ~= 100 - y (line center is y + 0.5).
11677        assert!((width_top - 89.5).abs() < 1.5, "top width {width_top} != ~89.5");
11678        assert!((width_bot - 19.5).abs() < 1.5, "bottom width {width_bot} != ~19.5");
11679        assert!(width_top > width_bot,
11680            "path() exclusion band must narrow with y ({width_top} !> {width_bot})");
11681
11682        // And it must differ from a plain full-width rectangle (which would be 0..100
11683        // at every scanline) — i.e. this is not the old rect/empty stub.
11684        assert!(width_bot < 50.0, "rect fallback would give full width here");
11685    }
11686
11687    #[test]
11688    fn path_with_hole_carves_out_interior_via_even_odd() {
11689        // Outer square 0..100 with an inner reversed square 30..70 (a hole). At a
11690        // scanline through the hole, even-odd fill yields two spans straddling the hole.
11691        let shape = path_shape(
11692            "M 0 0 L 100 0 L 100 100 L 0 100 Z \
11693             M 30 30 L 30 70 L 70 70 L 70 30 Z",
11694        );
11695        let rbox = Rect { x: 0.0, y: 0.0, width: 100.0, height: 100.0 };
11696        let boundary = ShapeBoundary::from_css_shape(&shape, rbox, &mut None);
11697        let spans = get_shape_horizontal_spans(&boundary, 50.0, 1.0);
11698        assert_eq!(spans.len(), 2, "hole should split the band into two spans: {spans:?}");
11699    }
11700
11701    // --- ruby ----------------------------------------------------------------
11702
11703    #[test]
11704    #[allow(clippy::float_cmp)] // exact, representable expected values
11705    fn ruby_annotation_font_scale_is_real_not_06_fudge() {
11706        // The annotation is sized at the used font-size of the ruby-text run, which the
11707        // UA stylesheet sets to 50% of the base — NOT a 0.6 per-character fudge.
11708        let base_font_size = 20.0_f32;
11709        let annotation_font_size = base_font_size * RUBY_ANNOTATION_FONT_SCALE;
11710        assert_eq!(annotation_font_size, 10.0);
11711        assert!((RUBY_ANNOTATION_FONT_SCALE - 0.6).abs() > f32::EPSILON,
11712            "annotation scale must not be the old 0.6 magic ratio");
11713    }
11714
11715    #[test]
11716    #[allow(clippy::float_cmp)] // exact, representable expected values
11717    fn ruby_box_reserves_max_width_and_stacks_annotation_above_base() {
11718        // Wider base, narrower annotation: reserved inline-size = base width.
11719        let (w, h) = ruby_reserved_box(80.0, 30.0, 24.0, 12.0);
11720        assert_eq!(w, 80.0, "reserved width is the wider of base/annotation");
11721        // Block-size stacks the annotation line above the base line => base reserves
11722        // vertical space for the annotation.
11723        assert_eq!(h, 36.0, "block-size = base line + annotation line");
11724        assert!(h > 24.0, "ruby box must reserve extra vertical space for the annotation");
11725
11726        // Narrower base, wider annotation: reserved inline-size = annotation width.
11727        let (w2, _) = ruby_reserved_box(20.0, 50.0, 24.0, 12.0);
11728        assert_eq!(w2, 50.0, "a long annotation widens the reserved box");
11729    }
11730}
11731
11732#[cfg(test)]
11733mod font_cache_swap_tests {
11734    use azul_css::props::basic::FontRef;
11735
11736    use super::*;
11737
11738    /// The invariant `memory_families` exists to uphold: it is an INDEX into
11739    /// `fc_cache`, so every `FontId` it hands to chain resolution must still be
11740    /// loadable from the cache that is live *right now*. A dangling id does not
11741    /// fail loudly — it resolves, then fails to load, and the text silently
11742    /// re-measures with the fallback font's metrics.
11743    fn assert_index_is_live(m: &FontManager<FontRef>) {
11744        for (family, faces) in &m.memory_families {
11745            for f in faces {
11746                assert!(
11747                    m.fc_cache.is_memory_font(&f.font_match.id),
11748                    "`{family}` is indexed with {:?}, which the live fc_cache cannot load",
11749                    f.font_match.id
11750                );
11751            }
11752        }
11753    }
11754
11755    #[test]
11756    fn swapping_the_fc_cache_does_not_strand_a_dead_memory_font_id() {
11757        let mut m: FontManager<FontRef> =
11758            FontManager::new(FcFontCache::default()).expect("FontManager::new must not fail");
11759        let norm = rust_fontconfig::utils::normalize_family_name("Azul Mock Mono");
11760
11761        let before = m
11762            .memory_families
11763            .get(&norm)
11764            .cloned()
11765            .expect("the built-in mock fonts are registered by every constructor");
11766        assert_eq!(before.len(), 1);
11767        assert_index_is_live(&m);
11768
11769        // Exactly what the DLL does at the top of EVERY `regenerate_layout`.
11770        for swap in 1..=3 {
11771            m.replace_fc_cache(FcFontCache::default());
11772            assert_index_is_live(&m);
11773            let faces = m
11774                .memory_families
11775                .get(&norm)
11776                .expect("the mock fonts are re-registered into the new cache");
11777            assert_eq!(
11778                faces.len(),
11779                1,
11780                "swap {swap} appended a face instead of replacing it: the index grows by one \
11781                 dead face per cache swap and `pick_memory_face` keeps returning the first \
11782                 (dead) one"
11783            );
11784        }
11785
11786        // Non-vacuity: the fresh caches really were empty, so the face WAS
11787        // re-minted under a new id — the old id is exactly the one that used to
11788        // be stranded at the head of the list.
11789        let after = &m.memory_families[&norm];
11790        assert_ne!(
11791            after[0].font_match.id, before[0].font_match.id,
11792            "a fresh FcFontCache cannot already contain the mock font"
11793        );
11794        assert!(
11795            !m.fc_cache.is_memory_font(&before[0].font_match.id),
11796            "the pre-swap id must be dead — otherwise this test proves nothing"
11797        );
11798    }
11799}
11800
11801/// Adversarial unit tests generated for `layout/src/text3/cache.rs`.
11802///
11803/// These probe the boundaries the production code never sees: NaN / ±inf floats,
11804/// `u16::MAX` units-per-em, empty slices, `usize::MAX` counts, degenerate geometry
11805/// and sentinel-value round trips. Where a function has a surprising-but-real
11806/// behaviour (e.g. `round_eq(NaN, 0.0) == true`), the test PINS that behaviour and
11807/// says so, rather than pretending it is safe.
11808#[cfg(test)]
11809#[allow(
11810    clippy::float_cmp,
11811    clippy::too_many_lines,
11812    clippy::unreadable_literal,
11813    clippy::cast_precision_loss,
11814    clippy::similar_names
11815)]
11816mod autotest_generated {
11817    use super::*;
11818
11819    // ---------------------------------------------------------------------
11820    // Fixtures
11821    // ---------------------------------------------------------------------
11822
11823    fn metrics(upem: u16, ascent: f32, descent: f32, line_gap: f32) -> LayoutFontMetrics {
11824        LayoutFontMetrics {
11825            ascent,
11826            descent,
11827            line_gap,
11828            units_per_em: upem,
11829            x_height: None,
11830            cap_height: None,
11831        }
11832    }
11833
11834    /// 1000 upem, 800 asc, -200 desc, 0 gap → `line-height: normal` == 1.0em.
11835    fn std_metrics() -> LayoutFontMetrics {
11836        metrics(1000, 800.0, -200.0, 0.0)
11837    }
11838
11839    fn style() -> Arc<StyleProperties> {
11840        Arc::new(StyleProperties::default())
11841    }
11842
11843    fn styled(f: impl FnOnce(&mut StyleProperties)) -> Arc<StyleProperties> {
11844        let mut s = StyleProperties::default();
11845        f(&mut s);
11846        Arc::new(s)
11847    }
11848
11849    const fn ci(run: u32, item: u32) -> ContentIndex {
11850        ContentIndex {
11851            run_index: run,
11852            item_index: item,
11853        }
11854    }
11855
11856    const fn gid(run: u32, byte: u32) -> GraphemeClusterId {
11857        GraphemeClusterId {
11858            source_run: run,
11859            start_byte_in_run: byte,
11860        }
11861    }
11862
11863    fn shaped_glyph(st: Arc<StyleProperties>, fm: LayoutFontMetrics, advance: f32) -> ShapedGlyph {
11864        ShapedGlyph {
11865            kind: GlyphKind::Character,
11866            glyph_id: 42,
11867            cluster_offset: 0,
11868            advance,
11869            kerning: 0.0,
11870            offset: Point { x: 0.0, y: 0.0 },
11871            vertical_advance: advance,
11872            vertical_offset: Point { x: 0.0, y: 0.0 },
11873            script: Script::Latin,
11874            style: st,
11875            font_hash: 0xABCD_u64,
11876            font_metrics: fm,
11877        }
11878    }
11879
11880    fn make_cluster(
11881        text: &str,
11882        advance: f32,
11883        st: Arc<StyleProperties>,
11884        glyphs: ShapedGlyphVec,
11885        id: GraphemeClusterId,
11886    ) -> ShapedItem {
11887        ShapedItem::Cluster(ShapedCluster {
11888            text: text.to_string(),
11889            source_cluster_id: id,
11890            source_content_index: ci(id.source_run, id.start_byte_in_run),
11891            source_node_id: None,
11892            glyphs,
11893            advance,
11894            direction: BidiDirection::Ltr,
11895            style: st,
11896            marker_position_outside: None,
11897            is_first_fragment: true,
11898            is_last_fragment: true,
11899        })
11900    }
11901
11902    /// Single-glyph cluster with the standard 1000-upem metrics.
11903    fn cl(text: &str, advance: f32) -> ShapedItem {
11904        let st = style();
11905        let g = shaped_glyph(st.clone(), std_metrics(), advance);
11906        make_cluster(text, advance, st, smallvec![g], gid(0, 0))
11907    }
11908
11909    /// Single-glyph cluster with an explicit grapheme id (for caret tests).
11910    fn cl_at(text: &str, advance: f32, run: u32, byte: u32) -> ShapedItem {
11911        let st = style();
11912        let g = shaped_glyph(st.clone(), std_metrics(), advance);
11913        make_cluster(text, advance, st, smallvec![g], gid(run, byte))
11914    }
11915
11916    /// Cluster carrying an explicit style (letter/word-spacing tests).
11917    fn cl_styled(text: &str, advance: f32, st: Arc<StyleProperties>) -> ShapedItem {
11918        let g = shaped_glyph(st.clone(), std_metrics(), advance);
11919        make_cluster(text, advance, st, smallvec![g], gid(0, 0))
11920    }
11921
11922    /// Cluster with NO glyphs — the CSS "strut" case.
11923    fn cl_no_glyphs(text: &str, advance: f32) -> ShapedItem {
11924        make_cluster(text, advance, style(), ShapedGlyphVec::new(), gid(0, 0))
11925    }
11926
11927    fn obj(width: f32, height: f32, baseline_offset: f32) -> ShapedItem {
11928        ShapedItem::Object {
11929            source: ci(0, 0),
11930            bounds: Rect {
11931                x: 0.0,
11932                y: 0.0,
11933                width,
11934                height,
11935            },
11936            baseline_offset,
11937            content: InlineContent::Space(InlineSpace {
11938                width,
11939                is_breaking: false,
11940                is_stretchy: false,
11941            }),
11942        }
11943    }
11944
11945    fn brk() -> ShapedItem {
11946        ShapedItem::Break {
11947            source: ci(0, 0),
11948            break_info: InlineBreak {
11949                break_type: BreakType::Hard,
11950                clear: ClearType::None,
11951                content_index: 0,
11952            },
11953        }
11954    }
11955
11956    fn tab(width: f32, height: f32) -> ShapedItem {
11957        ShapedItem::Tab {
11958            source: ci(0, 0),
11959            bounds: Rect {
11960                x: 0.0,
11961                y: 0.0,
11962                width,
11963                height,
11964            },
11965        }
11966    }
11967
11968    fn pos(item: ShapedItem, x: f32, y: f32, line_index: usize) -> PositionedItem {
11969        PositionedItem {
11970            item,
11971            position: Point { x, y },
11972            line_index,
11973        }
11974    }
11975
11976    fn text_content(t: &str, st: Arc<StyleProperties>) -> InlineContent {
11977        InlineContent::Text(StyledRun {
11978            text: t.to_string(),
11979            style: st,
11980            logical_start_byte: 0,
11981            source_node_id: None,
11982        })
11983    }
11984
11985    fn sel(family: &str) -> FontSelector {
11986        FontSelector {
11987            family: family.to_string(),
11988            ..FontSelector::default()
11989        }
11990    }
11991
11992    /// A minimal in-memory `ParsedFontTrait` so `LoadedFonts` / `FontManager`
11993    /// can be exercised without touching the filesystem or fontconfig.
11994    #[derive(Debug, Clone)]
11995    struct TestFont {
11996        hash: u64,
11997    }
11998
11999    impl ShallowClone for TestFont {
12000        fn shallow_clone(&self) -> Self {
12001            self.clone()
12002        }
12003    }
12004
12005    impl ParsedFontTrait for TestFont {
12006        fn shape_text(
12007            &self,
12008            _text: &str,
12009            _script: Script,
12010            _language: Language,
12011            _direction: BidiDirection,
12012            _style: &StyleProperties,
12013        ) -> Result<Vec<Glyph>, LayoutError> {
12014            Ok(Vec::new())
12015        }
12016        fn get_hash(&self) -> u64 {
12017            self.hash
12018        }
12019        fn get_glyph_size(&self, _glyph_id: u16, font_size: f32) -> Option<LogicalSize> {
12020            Some(LogicalSize {
12021                width: font_size,
12022                height: font_size,
12023            })
12024        }
12025        fn get_hyphen_glyph_and_advance(&self, font_size: f32) -> Option<(u16, f32)> {
12026            Some((1, font_size * 0.3))
12027        }
12028        fn get_kashida_glyph_and_advance(&self, font_size: f32) -> Option<(u16, f32)> {
12029            Some((2, font_size * 0.2))
12030        }
12031        fn has_glyph(&self, _codepoint: u32) -> bool {
12032            true
12033        }
12034        fn get_vertical_metrics(&self, _glyph_id: u16) -> Option<VerticalMetrics> {
12035            None
12036        }
12037        fn get_font_metrics(&self) -> LayoutFontMetrics {
12038            std_metrics()
12039        }
12040        fn num_glyphs(&self) -> u16 {
12041            10
12042        }
12043        fn get_space_width(&self) -> Option<usize> {
12044            Some(500)
12045        }
12046    }
12047
12048    fn hash_of<T: Hash>(v: &T) -> u64 {
12049        let mut h = DefaultHasher::new();
12050        v.hash(&mut h);
12051        h.finish()
12052    }
12053
12054    /// Compare two derived f32s. Used wherever the expected value comes out of a
12055    /// divide-then-multiply chain, whose last-ulp rounding is not worth pinning.
12056    #[track_caller]
12057    fn approx(actual: f32, expected: f32) {
12058        assert!(
12059            (actual - expected).abs() < 1e-4,
12060            "expected ~{expected}, got {actual}"
12061        );
12062    }
12063
12064    // =====================================================================
12065    // numeric: ruby_reserved_box
12066    // =====================================================================
12067
12068    #[test]
12069    fn ruby_reserved_box_zero_and_negative_are_deterministic() {
12070        assert_eq!(ruby_reserved_box(0.0, 0.0, 0.0, 0.0), (0.0, 0.0));
12071        // max() of two negatives is the one closer to zero; the block-size sums.
12072        let (w, h) = ruby_reserved_box(-10.0, -4.0, -3.0, -2.0);
12073        assert_eq!(w, -4.0);
12074        assert_eq!(h, -5.0);
12075    }
12076
12077    #[test]
12078    fn ruby_reserved_box_nan_width_is_ignored_by_max_but_poisons_height() {
12079        // f32::max propagates the NON-NaN operand, so a NaN advance silently
12080        // yields the other run's width instead of NaN.
12081        let (w, h) = ruby_reserved_box(f32::NAN, 30.0, 24.0, f32::NAN);
12082        assert_eq!(w, 30.0, "f32::max discards the NaN operand");
12083        assert!(h.is_nan(), "but the additive block-size does propagate NaN");
12084    }
12085
12086    #[test]
12087    fn ruby_reserved_box_infinities_do_not_panic() {
12088        let (w, h) = ruby_reserved_box(f32::INFINITY, 10.0, f32::INFINITY, f32::NEG_INFINITY);
12089        assert!(w.is_infinite() && w.is_sign_positive());
12090        assert!(h.is_nan(), "inf + -inf is NaN, not a panic");
12091    }
12092
12093    #[test]
12094    fn ruby_reserved_box_saturates_to_infinity_at_f32_max() {
12095        let (w, h) = ruby_reserved_box(f32::MAX, f32::MAX, f32::MAX, f32::MAX);
12096        assert_eq!(w, f32::MAX);
12097        assert!(h.is_infinite(), "f32 addition saturates, it does not panic");
12098    }
12099
12100    // =====================================================================
12101    // numeric: LineHeight::resolve / resolve_with_metrics
12102    // =====================================================================
12103
12104    #[test]
12105    fn line_height_px_ignores_every_font_metric() {
12106        let lh = LineHeight::Px(7.5);
12107        assert_eq!(lh.resolve(16.0, 800.0, -200.0, 0.0, 1000), 7.5);
12108        // Even garbage metrics cannot perturb an explicit px value.
12109        assert_eq!(lh.resolve(f32::NAN, f32::NAN, f32::NAN, f32::NAN, 0), 7.5);
12110    }
12111
12112    #[test]
12113    fn line_height_normal_zero_upem_falls_back_to_1_2_em() {
12114        let lh = LineHeight::Normal;
12115        assert_eq!(lh.resolve(16.0, 800.0, -200.0, 0.0, 0), 19.2);
12116        assert_eq!(lh.resolve(0.0, 800.0, -200.0, 0.0, 0), 0.0);
12117    }
12118
12119    #[test]
12120    fn line_height_normal_scales_ascent_minus_descent_plus_gap() {
12121        // (800 - (-200) + 0) / 1000 * 16 == 16.0
12122        approx(LineHeight::Normal.resolve(16.0, 800.0, -200.0, 0.0, 1000), 16.0);
12123        // line_gap widens the line box.
12124        approx(
12125            LineHeight::Normal.resolve(16.0, 800.0, -200.0, 250.0, 1000),
12126            20.0,
12127        );
12128        // A descent given with the WRONG (positive) sign shrinks the line box —
12129        // the formula subtracts it unconditionally.
12130        approx(LineHeight::Normal.resolve(16.0, 800.0, 200.0, 0.0, 1000), 9.6);
12131    }
12132
12133    #[test]
12134    fn line_height_normal_at_u16_max_upem_does_not_panic() {
12135        let v = LineHeight::Normal.resolve(16.0, 800.0, -200.0, 0.0, u16::MAX);
12136        assert!(v.is_finite() && v > 0.0, "got {v}");
12137        assert!(v < 1.0, "a 65535-upem font must produce a tiny scale, got {v}");
12138    }
12139
12140    #[test]
12141    fn line_height_normal_nan_and_inf_inputs_are_defined_not_panics() {
12142        assert!(LineHeight::Normal
12143            .resolve(f32::NAN, 800.0, -200.0, 0.0, 1000)
12144            .is_nan());
12145        assert!(LineHeight::Normal
12146            .resolve(f32::INFINITY, 800.0, -200.0, 0.0, 1000)
12147            .is_infinite());
12148        // ascent == descent == inf → inf - inf == NaN
12149        assert!(LineHeight::Normal
12150            .resolve(16.0, f32::INFINITY, f32::INFINITY, 0.0, 1000)
12151            .is_nan());
12152    }
12153
12154    #[test]
12155    fn line_height_resolve_with_metrics_matches_resolve() {
12156        let fm = metrics(2048, 1600.0, -400.0, 100.0);
12157        let lh = LineHeight::Normal;
12158        assert_eq!(
12159            lh.resolve_with_metrics(24.0, &fm),
12160            lh.resolve(24.0, fm.ascent, fm.descent, fm.line_gap, fm.units_per_em)
12161        );
12162        // Px path is metric-independent.
12163        assert_eq!(LineHeight::Px(3.0).resolve_with_metrics(24.0, &fm), 3.0);
12164    }
12165
12166    #[test]
12167    fn line_height_px_nan_is_self_equal_under_the_manual_partialeq() {
12168        // The manual PartialEq compares raw bits, so Px(NaN) == Px(NaN) even
12169        // though NaN != NaN — required for Hash/Eq consistency of the cache key.
12170        assert_eq!(LineHeight::Px(f32::NAN), LineHeight::Px(f32::NAN));
12171        assert_eq!(
12172            hash_of(&LineHeight::Px(f32::NAN)),
12173            hash_of(&LineHeight::Px(f32::NAN))
12174        );
12175        assert_ne!(LineHeight::Normal, LineHeight::Px(0.0));
12176    }
12177
12178    // =====================================================================
12179    // AvailableSpace (predicate / numeric / constructor)
12180    // =====================================================================
12181
12182    #[test]
12183    fn available_space_definite_and_indefinite_are_exact_complements() {
12184        for v in [
12185            AvailableSpace::Definite(0.0),
12186            AvailableSpace::Definite(-1.0),
12187            AvailableSpace::Definite(f32::NAN),
12188            AvailableSpace::MinContent,
12189            AvailableSpace::MaxContent,
12190        ] {
12191            assert_ne!(v.is_definite(), v.is_indefinite(), "{v:?}");
12192        }
12193        assert!(AvailableSpace::Definite(f32::NAN).is_definite());
12194        assert!(AvailableSpace::default().is_indefinite());
12195        assert_eq!(AvailableSpace::default(), AvailableSpace::MaxContent);
12196    }
12197
12198    #[test]
12199    fn available_space_unwrap_or_returns_definite_even_when_nan_or_inf() {
12200        assert_eq!(AvailableSpace::Definite(0.0).unwrap_or(99.0), 0.0);
12201        assert_eq!(AvailableSpace::Definite(-5.0).unwrap_or(99.0), -5.0);
12202        assert!(AvailableSpace::Definite(f32::NAN).unwrap_or(99.0).is_nan());
12203        assert!(AvailableSpace::Definite(f32::INFINITY)
12204            .unwrap_or(99.0)
12205            .is_infinite());
12206        // Indefinite variants hand back the fallback verbatim, NaN included.
12207        assert_eq!(AvailableSpace::MinContent.unwrap_or(99.0), 99.0);
12208        assert_eq!(AvailableSpace::MaxContent.unwrap_or(-0.0), -0.0);
12209        assert!(AvailableSpace::MaxContent.unwrap_or(f32::NAN).is_nan());
12210    }
12211
12212    #[test]
12213    fn available_space_to_f32_for_layout_uses_half_max_for_both_intrinsic_modes() {
12214        assert_eq!(AvailableSpace::MinContent.to_f32_for_layout(), f32::MAX / 2.0);
12215        assert_eq!(AvailableSpace::MaxContent.to_f32_for_layout(), f32::MAX / 2.0);
12216        assert_eq!(AvailableSpace::Definite(12.5).to_f32_for_layout(), 12.5);
12217        assert!(AvailableSpace::Definite(f32::NAN)
12218            .to_f32_for_layout()
12219            .is_nan());
12220    }
12221
12222    #[test]
12223    fn available_space_from_f32_sentinels() {
12224        assert_eq!(AvailableSpace::from_f32(f32::INFINITY), AvailableSpace::MaxContent);
12225        assert_eq!(AvailableSpace::from_f32(f32::MAX), AvailableSpace::MaxContent);
12226        // The documented cut-over point is exactly MAX/2 (inclusive).
12227        assert_eq!(
12228            AvailableSpace::from_f32(f32::MAX / 2.0),
12229            AvailableSpace::MaxContent
12230        );
12231        assert_eq!(AvailableSpace::from_f32(0.0), AvailableSpace::MinContent);
12232        assert_eq!(AvailableSpace::from_f32(-0.0), AvailableSpace::MinContent);
12233        assert_eq!(AvailableSpace::from_f32(-1.0), AvailableSpace::MinContent);
12234        assert_eq!(AvailableSpace::from_f32(100.0), AvailableSpace::Definite(100.0));
12235    }
12236
12237    #[test]
12238    fn available_space_from_f32_negative_infinity_becomes_max_content() {
12239        // QUIRK worth pinning: the `is_infinite()` guard runs FIRST, so -inf —
12240        // a nonsensical width — resolves to MaxContent ("no wrapping"), not the
12241        // MinContent that every other negative value maps to.
12242        assert_eq!(
12243            AvailableSpace::from_f32(f32::NEG_INFINITY),
12244            AvailableSpace::MaxContent
12245        );
12246    }
12247
12248    #[test]
12249    fn available_space_from_f32_nan_falls_through_to_definite_nan() {
12250        // NaN fails is_infinite(), fails `>= MAX/2`, and fails `<= 0.0`, so it
12251        // lands in the Definite arm and a NaN width is smuggled into layout.
12252        let got = AvailableSpace::from_f32(f32::NAN);
12253        match got {
12254            AvailableSpace::Definite(v) => assert!(v.is_nan(), "expected Definite(NaN)"),
12255            other => panic!("NaN should fall through to Definite, got {other:?}"),
12256        }
12257        // ...and Definite(NaN) is not even equal to itself under the derived PartialEq.
12258        assert_ne!(got, AvailableSpace::from_f32(f32::NAN));
12259    }
12260
12261    #[test]
12262    fn available_space_hash_eq_contract_holds_for_signed_zero() {
12263        // +0.0 == -0.0 under PartialEq, so their hashes MUST agree.
12264        assert_eq!(
12265            AvailableSpace::Definite(0.0),
12266            AvailableSpace::Definite(-0.0)
12267        );
12268        assert_eq!(
12269            hash_of(&AvailableSpace::Definite(0.0)),
12270            hash_of(&AvailableSpace::Definite(-0.0))
12271        );
12272        // Sub-pixel widths must NOT collide (they wrap lines differently).
12273        assert_ne!(
12274            hash_of(&AvailableSpace::Definite(100.1)),
12275            hash_of(&AvailableSpace::Definite(100.4))
12276        );
12277        assert_ne!(
12278            hash_of(&AvailableSpace::MinContent),
12279            hash_of(&AvailableSpace::MaxContent)
12280        );
12281    }
12282
12283    // =====================================================================
12284    // constructor: FontChainKey / FontChainKeyOrRef / FontStack / FontHash
12285    // =====================================================================
12286
12287    #[test]
12288    fn font_chain_key_from_empty_selectors_defaults_to_serif() {
12289        let k = FontChainKey::from_selectors(&[]);
12290        assert_eq!(k.font_families, vec!["serif".to_string()]);
12291        assert_eq!(k.weight, FcWeight::Normal);
12292        assert!(!k.italic && !k.oblique);
12293    }
12294
12295    #[test]
12296    fn font_chain_key_dedups_first_wins_and_skips_empty_families() {
12297        let stack = [sel("Arial"), sel("Times"), sel("Arial"), sel("")];
12298        let k = FontChainKey::from_selectors(&stack);
12299        assert_eq!(
12300            k.font_families,
12301            vec!["Arial".to_string(), "Times".to_string()],
12302            "duplicate families must collapse first-wins, empty names dropped"
12303        );
12304    }
12305
12306    #[test]
12307    fn font_chain_key_all_empty_families_still_yields_serif() {
12308        let stack = [sel(""), sel(""), sel("")];
12309        let k = FontChainKey::from_selectors(&stack);
12310        assert_eq!(k.font_families, vec!["serif".to_string()]);
12311    }
12312
12313    #[test]
12314    fn font_chain_key_weight_and_style_come_from_the_first_selector_even_if_it_is_dropped() {
12315        // QUIRK: the first selector's family is skipped (empty), but its weight
12316        // and italic flag still win — the key describes a family it does not list.
12317        let mut first = sel("");
12318        first.style = FontStyle::Italic;
12319        first.weight = FcWeight::Bold;
12320        let stack = [first, sel("Arial")];
12321        let k = FontChainKey::from_selectors(&stack);
12322        assert_eq!(k.font_families, vec!["Arial".to_string()]);
12323        assert_eq!(k.weight, FcWeight::Bold);
12324        assert!(k.italic, "italic taken from the dropped first selector");
12325        assert!(!k.oblique);
12326    }
12327
12328    #[test]
12329    fn font_chain_key_oblique_is_exclusive_of_italic() {
12330        let mut s = sel("Arial");
12331        s.style = FontStyle::Oblique;
12332        let k = FontChainKey::from_selectors(&[s]);
12333        assert!(k.oblique && !k.italic);
12334    }
12335
12336    #[test]
12337    fn font_chain_key_huge_duplicate_stack_does_not_hang() {
12338        let stack: Vec<FontSelector> = (0..5000).map(|_| sel("Arial")).collect();
12339        let k = FontChainKey::from_selectors(&stack);
12340        assert_eq!(k.font_families.len(), 1, "5000 dupes collapse to one entry");
12341    }
12342
12343    #[test]
12344    fn font_chain_key_is_a_stable_hash_map_key() {
12345        let a = FontChainKey::from_selectors(&[sel("Arial"), sel("Times")]);
12346        let b = FontChainKey::from_selectors(&[sel("Arial"), sel("Arial"), sel("Times")]);
12347        assert_eq!(a, b, "dedup makes the two stacks resolve to the same key");
12348        assert_eq!(hash_of(&a), hash_of(&b));
12349    }
12350
12351    #[test]
12352    fn font_chain_key_or_ref_from_stack_is_a_chain() {
12353        let fs = FontStack::Stack(vec![sel("Arial")]);
12354        let k = FontChainKeyOrRef::from_font_stack(&fs);
12355        assert!(!k.is_ref());
12356        assert_eq!(k.as_ref_ptr(), None);
12357        assert_eq!(
12358            k.as_chain().map(|c| c.font_families.clone()),
12359            Some(vec!["Arial".to_string()])
12360        );
12361    }
12362
12363    #[test]
12364    fn font_chain_key_or_ref_ref_variant_accessors_at_boundaries() {
12365        for ptr in [0_usize, 1, usize::MAX] {
12366            let k = FontChainKeyOrRef::Ref(ptr);
12367            assert!(k.is_ref());
12368            assert_eq!(k.as_ref_ptr(), Some(ptr));
12369            assert!(k.as_chain().is_none());
12370        }
12371        // A null-pointer Ref is still distinguishable from a Chain.
12372        assert_ne!(
12373            FontChainKeyOrRef::Ref(0),
12374            FontChainKeyOrRef::Chain(FontChainKey::from_selectors(&[]))
12375        );
12376    }
12377
12378    #[test]
12379    fn font_stack_default_is_a_single_serif_selector() {
12380        let fs = FontStack::default();
12381        assert!(!fs.is_ref());
12382        assert!(fs.as_ref().is_none());
12383        assert_eq!(fs.as_stack().map(<[FontSelector]>::len), Some(1));
12384        assert_eq!(fs.first_selector().map(|s| s.family.as_str()), Some("serif"));
12385        assert_eq!(fs.first_family(), "serif");
12386    }
12387
12388    #[test]
12389    fn font_stack_empty_stack_reports_serif_placeholder_but_no_first_selector() {
12390        let fs = FontStack::Stack(Vec::new());
12391        assert_eq!(fs.as_stack().map(<[FontSelector]>::len), Some(0));
12392        assert!(fs.first_selector().is_none());
12393        assert_eq!(
12394            fs.first_family(),
12395            "serif",
12396            "an EMPTY stack must not panic; it reports the serif fallback"
12397        );
12398    }
12399
12400    #[test]
12401    fn font_hash_invalid_is_zero_and_is_the_default() {
12402        assert_eq!(FontHash::invalid().font_hash, 0);
12403        assert_eq!(FontHash::default(), FontHash::invalid());
12404        assert_eq!(FontHash::from_hash(0), FontHash::invalid());
12405        assert_eq!(FontHash::from_hash(u64::MAX).font_hash, u64::MAX);
12406        assert_ne!(FontHash::from_hash(u64::MAX), FontHash::invalid());
12407    }
12408
12409    // =====================================================================
12410    // numeric/getter: LayoutFontMetrics
12411    // =====================================================================
12412
12413    #[test]
12414    fn layout_font_metrics_baseline_scaled_typical_and_zero_font_size() {
12415        let fm = std_metrics();
12416        approx(fm.baseline_scaled(16.0), 12.8); // 800/1000 * 16
12417        assert_eq!(fm.baseline_scaled(0.0), 0.0);
12418        approx(fm.baseline_scaled(-16.0), -12.8);
12419    }
12420
12421    #[test]
12422    fn layout_font_metrics_zero_upem_divides_by_zero_instead_of_guarding() {
12423        // NOTE: `LineHeight::resolve` explicitly guards `units_per_em == 0`, but the
12424        // *_scaled helpers do not — they divide by zero. Pin the actual behaviour so
12425        // a future guard shows up as a deliberate change rather than a silent one.
12426        let fm = metrics(0, 800.0, -200.0, 0.0);
12427        assert!(fm.baseline_scaled(16.0).is_infinite());
12428        assert!(fm.cap_height_scaled(16.0).is_infinite());
12429
12430        // ascent == 0 turns 0/0 into NaN rather than inf.
12431        let zero = metrics(0, 0.0, 0.0, 0.0);
12432        assert!(zero.baseline_scaled(16.0).is_nan());
12433    }
12434
12435    #[test]
12436    fn layout_font_metrics_x_height_falls_back_to_half_em() {
12437        let fm = std_metrics(); // x_height: None
12438        assert_eq!(fm.x_height_scaled(16.0), 8.0, "fallback is 0.5em");
12439        assert_eq!(fm.x_height_scaled(0.0), 0.0);
12440
12441        let mut with_xh = std_metrics();
12442        with_xh.x_height = Some(500.0);
12443        approx(with_xh.x_height_scaled(16.0), 8.0);
12444        with_xh.x_height = Some(0.0);
12445        assert_eq!(
12446            with_xh.x_height_scaled(16.0),
12447            0.0,
12448            "an explicit sxHeight of 0 must NOT re-trigger the 0.5em fallback"
12449        );
12450    }
12451
12452    #[test]
12453    fn layout_font_metrics_cap_height_falls_back_to_ascent() {
12454        let fm = std_metrics(); // cap_height: None
12455        assert_eq!(fm.cap_height_scaled(16.0), fm.baseline_scaled(16.0));
12456
12457        let mut with_cap = std_metrics();
12458        with_cap.cap_height = Some(700.0);
12459        approx(with_cap.cap_height_scaled(16.0), 11.2);
12460    }
12461
12462    #[test]
12463    fn layout_font_metrics_nan_font_size_propagates_without_panicking() {
12464        let fm = std_metrics();
12465        assert!(fm.baseline_scaled(f32::NAN).is_nan());
12466        assert!(fm.x_height_scaled(f32::NAN).is_nan());
12467        assert!(fm.cap_height_scaled(f32::NAN).is_nan());
12468        assert!(fm.baseline_scaled(f32::INFINITY).is_infinite());
12469    }
12470
12471    #[test]
12472    fn layout_font_metrics_synthesized_baselines_span_exactly_one_em() {
12473        let fm = std_metrics();
12474        assert_eq!(fm.central_baseline(), 300.0); // midpoint(800, -200)
12475        assert_eq!(fm.em_over(), 800.0); // 300 + 1000/2
12476        assert_eq!(fm.em_under(), -200.0); // 300 - 1000/2
12477        assert_eq!(
12478            fm.em_over() - fm.em_under(),
12479            f32::from(fm.units_per_em),
12480            "em-over minus em-under is by definition 1em"
12481        );
12482    }
12483
12484    #[test]
12485    fn layout_font_metrics_baselines_at_u16_max_upem_and_zero_upem() {
12486        let big = metrics(u16::MAX, 0.0, 0.0, 0.0);
12487        assert_eq!(big.central_baseline(), 0.0);
12488        assert_eq!(big.em_over(), f32::from(u16::MAX) / 2.0);
12489        assert_eq!(big.em_under(), -f32::from(u16::MAX) / 2.0);
12490
12491        let zero = metrics(0, 100.0, -50.0, 0.0);
12492        assert_eq!(zero.em_over(), zero.central_baseline());
12493        assert_eq!(zero.em_under(), zero.central_baseline());
12494    }
12495
12496    #[test]
12497    fn layout_font_metrics_central_baseline_with_infinite_extents_is_nan() {
12498        let fm = metrics(1000, f32::INFINITY, f32::NEG_INFINITY, 0.0);
12499        assert!(fm.central_baseline().is_nan(), "midpoint(inf, -inf) is NaN");
12500        assert!(fm.em_over().is_nan());
12501    }
12502
12503    // =====================================================================
12504    // numeric: round_eq (the equality primitive under Rect/Size/Point/Stroke)
12505    // =====================================================================
12506
12507    #[test]
12508    fn round_eq_rounds_half_away_from_zero() {
12509        assert!(round_eq(0.4, -0.4), "both round to 0");
12510        assert!(round_eq(1.5, 2.4), "1.5 rounds away from zero to 2");
12511        assert!(!round_eq(1.4, 1.5));
12512        assert!(round_eq(-1.5, -2.0));
12513    }
12514
12515    #[test]
12516    fn round_eq_treats_nan_as_equal_to_everything_rounding_to_zero() {
12517        // `NaN.round() as isize` is a SATURATING cast that yields 0, so NaN
12518        // compares equal to 0.0 (and to itself). Any Rect/Size/Point carrying a
12519        // NaN coordinate therefore compares "equal" to a zeroed one — a real
12520        // cache-key hazard, pinned here.
12521        assert!(round_eq(f32::NAN, f32::NAN));
12522        assert!(round_eq(f32::NAN, 0.0));
12523        assert!(round_eq(f32::NAN, 0.49));
12524        assert!(!round_eq(f32::NAN, 1.0));
12525
12526        assert_eq!(
12527            Rect {
12528                x: f32::NAN,
12529                y: 0.0,
12530                width: 0.0,
12531                height: 0.0
12532            },
12533            Rect::default(),
12534            "a NaN-x Rect compares equal to the zero Rect"
12535        );
12536    }
12537
12538    #[test]
12539    fn round_eq_saturates_infinity_and_f32_max_to_the_same_isize() {
12540        // Both +inf and f32::MAX saturate to isize::MAX, so they are "equal".
12541        assert!(round_eq(f32::INFINITY, f32::MAX));
12542        assert!(round_eq(f32::NEG_INFINITY, f32::MIN));
12543        assert!(!round_eq(f32::INFINITY, f32::NEG_INFINITY));
12544
12545        assert_eq!(
12546            Size::new(f32::INFINITY, 0.0),
12547            Size::new(f32::MAX, 0.0),
12548            "saturating cast collapses inf and f32::MAX into one bucket"
12549        );
12550    }
12551
12552    // =====================================================================
12553    // numeric/getter: Size, calculate_bounding_box_size, ShapeDefinition
12554    // =====================================================================
12555
12556    #[test]
12557    fn size_zero_is_the_neutral_element_and_new_preserves_bits() {
12558        assert_eq!(Size::zero(), Size::new(0.0, 0.0));
12559        assert_eq!(Size::zero().width, 0.0);
12560        assert_eq!(Size::zero(), Size::default());
12561
12562        let weird = Size::new(f32::NAN, f32::INFINITY);
12563        assert!(weird.width.is_nan(), "the constructor must not sanitize");
12564        assert!(weird.height.is_infinite());
12565    }
12566
12567    #[test]
12568    fn bounding_box_of_empty_and_single_point_is_zero() {
12569        assert_eq!(calculate_bounding_box_size(&[]), Size::zero());
12570        assert_eq!(
12571            calculate_bounding_box_size(&[Point { x: 5.0, y: -5.0 }]),
12572            Size::zero()
12573        );
12574    }
12575
12576    #[test]
12577    fn bounding_box_spans_negative_coordinates() {
12578        let pts = [
12579            Point { x: -10.0, y: -20.0 },
12580            Point { x: 30.0, y: 5.0 },
12581            Point { x: 0.0, y: 0.0 },
12582        ];
12583        assert_eq!(calculate_bounding_box_size(&pts), Size::new(40.0, 25.0));
12584    }
12585
12586    #[test]
12587    fn bounding_box_of_all_nan_points_collapses_to_zero() {
12588        // min()/max() discard NaN, leaving min > max, which the guard catches.
12589        let pts = [Point {
12590            x: f32::NAN,
12591            y: f32::NAN,
12592        }];
12593        assert_eq!(calculate_bounding_box_size(&pts), Size::zero());
12594    }
12595
12596    #[test]
12597    fn bounding_box_of_extreme_points_overflows_to_infinity_without_panicking() {
12598        let pts = [
12599            Point {
12600                x: f32::MIN,
12601                y: f32::MIN,
12602            },
12603            Point {
12604                x: f32::MAX,
12605                y: f32::MAX,
12606            },
12607        ];
12608        let s = calculate_bounding_box_size(&pts);
12609        assert!(s.width.is_infinite() && s.height.is_infinite());
12610    }
12611
12612    #[test]
12613    fn shape_definition_get_size_for_each_variant() {
12614        assert_eq!(
12615            ShapeDefinition::Rectangle {
12616                size: Size::new(3.0, 4.0),
12617                corner_radius: None
12618            }
12619            .get_size(),
12620            Size::new(3.0, 4.0)
12621        );
12622        assert_eq!(
12623            ShapeDefinition::Circle { radius: 10.0 }.get_size(),
12624            Size::new(20.0, 20.0)
12625        );
12626        assert_eq!(
12627            ShapeDefinition::Ellipse {
12628                radii: Size::new(5.0, 2.0)
12629            }
12630            .get_size(),
12631            Size::new(10.0, 4.0)
12632        );
12633        assert_eq!(
12634            ShapeDefinition::Polygon { points: Vec::new() }.get_size(),
12635            Size::zero()
12636        );
12637        assert_eq!(
12638            ShapeDefinition::Path {
12639                segments: Vec::new()
12640            }
12641            .get_size(),
12642            Size::zero()
12643        );
12644    }
12645
12646    #[test]
12647    fn shape_definition_negative_circle_radius_yields_a_negative_size() {
12648        // Pinned, not endorsed: the constructor never validates the radius, so a
12649        // negative CSS radius propagates a negative bounding box into layout.
12650        let s = ShapeDefinition::Circle { radius: -10.0 }.get_size();
12651        assert_eq!(s.width, -20.0);
12652        assert_eq!(s.height, -20.0);
12653    }
12654
12655    #[test]
12656    fn shape_definition_path_of_only_close_segments_is_zero_sized() {
12657        let s = ShapeDefinition::Path {
12658            segments: vec![PathSegment::Close, PathSegment::Close],
12659        }
12660        .get_size();
12661        assert_eq!(s, Size::zero(), "Close contributes no points");
12662    }
12663
12664    #[test]
12665    fn shape_definition_path_bounding_box_includes_control_points() {
12666        let s = ShapeDefinition::Path {
12667            segments: vec![
12668                PathSegment::MoveTo(Point { x: 0.0, y: 0.0 }),
12669                PathSegment::QuadTo {
12670                    control: Point { x: 50.0, y: 100.0 },
12671                    end: Point { x: 100.0, y: 0.0 },
12672                },
12673            ],
12674        }
12675        .get_size();
12676        assert_eq!(
12677            s,
12678            Size::new(100.0, 100.0),
12679            "the control point (not the true curve extremum) sets the height"
12680        );
12681    }
12682
12683    // =====================================================================
12684    // numeric: ShapeBoundary::inflate
12685    // =====================================================================
12686
12687    #[test]
12688    fn shape_boundary_inflate_by_zero_is_identity() {
12689        let r = ShapeBoundary::Rectangle(Rect {
12690            x: 1.0,
12691            y: 2.0,
12692            width: 3.0,
12693            height: 4.0,
12694        });
12695        assert_eq!(r.inflate(0.0), r);
12696        let c = ShapeBoundary::Circle {
12697            center: Point { x: 0.0, y: 0.0 },
12698            radius: 5.0,
12699        };
12700        assert_eq!(c.inflate(0.0), c);
12701    }
12702
12703    #[test]
12704    fn shape_boundary_inflate_rectangle_clamps_negative_dimensions_to_zero() {
12705        let r = ShapeBoundary::Rectangle(Rect {
12706            x: 0.0,
12707            y: 0.0,
12708            width: 10.0,
12709            height: 10.0,
12710        });
12711        match r.inflate(-100.0) {
12712            ShapeBoundary::Rectangle(out) => {
12713                assert_eq!(out.width, 0.0, "over-deflation must clamp, not go negative");
12714                assert_eq!(out.height, 0.0);
12715                assert_eq!(out.x, 100.0, "the origin is NOT clamped");
12716            }
12717            other => panic!("expected Rectangle, got {other:?}"),
12718        }
12719    }
12720
12721    #[test]
12722    fn shape_boundary_inflate_nan_margin_zeroes_the_rectangle_extent() {
12723        // `margin == 0.0` is false for NaN, so we take the inflate path; then
12724        // `NaN.max(0.0)` returns 0.0 (f32::max discards NaN) — the box silently
12725        // collapses to zero width/height with a NaN origin.
12726        let r = ShapeBoundary::Rectangle(Rect {
12727            x: 0.0,
12728            y: 0.0,
12729            width: 10.0,
12730            height: 10.0,
12731        });
12732        match r.inflate(f32::NAN) {
12733            ShapeBoundary::Rectangle(out) => {
12734                assert_eq!(out.width, 0.0);
12735                assert_eq!(out.height, 0.0);
12736                assert!(out.x.is_nan());
12737            }
12738            other => panic!("expected Rectangle, got {other:?}"),
12739        }
12740    }
12741
12742    #[test]
12743    fn shape_boundary_inflate_circle_radius_is_unclamped() {
12744        let c = ShapeBoundary::Circle {
12745            center: Point { x: 1.0, y: 2.0 },
12746            radius: 5.0,
12747        };
12748        match c.inflate(-50.0) {
12749            ShapeBoundary::Circle { center, radius } => {
12750                assert_eq!(center, Point { x: 1.0, y: 2.0 });
12751                assert_eq!(radius, -45.0, "circle radius is NOT clamped at 0 (unlike Rect)");
12752            }
12753            other => panic!("expected Circle, got {other:?}"),
12754        }
12755        match c.inflate(f32::INFINITY) {
12756            ShapeBoundary::Circle { radius, .. } => assert!(radius.is_infinite()),
12757            other => panic!("expected Circle, got {other:?}"),
12758        }
12759    }
12760
12761    #[test]
12762    fn shape_boundary_inflate_is_a_documented_no_op_for_polygon_and_path() {
12763        let p = ShapeBoundary::Polygon {
12764            points: vec![Point { x: 0.0, y: 0.0 }, Point { x: 1.0, y: 1.0 }],
12765        };
12766        assert_eq!(p.inflate(10.0), p, "polygon inflation is not implemented");
12767        let path = ShapeBoundary::Path {
12768            segments: vec![PathSegment::MoveTo(Point { x: 0.0, y: 0.0 })],
12769        };
12770        assert_eq!(path.inflate(10.0), path, "path inflation is not implemented");
12771    }
12772
12773    // =====================================================================
12774    // other: resolve_effective_alignment
12775    // =====================================================================
12776
12777    #[test]
12778    fn resolve_effective_alignment_passes_through_for_non_last_lines() {
12779        for ta in [
12780            TextAlign::Left,
12781            TextAlign::Right,
12782            TextAlign::Center,
12783            TextAlign::Justify,
12784            TextAlign::Start,
12785            TextAlign::End,
12786            TextAlign::JustifyAll,
12787        ] {
12788            assert_eq!(
12789                resolve_effective_alignment(ta, TextAlign::Right, false),
12790                ta,
12791                "text-align-last must not touch a non-last line"
12792            );
12793        }
12794    }
12795
12796    #[test]
12797    fn resolve_effective_alignment_last_line_justify_degrades_to_start() {
12798        assert_eq!(
12799            resolve_effective_alignment(TextAlign::Justify, TextAlign::default(), true),
12800            TextAlign::Start
12801        );
12802        assert_eq!(
12803            resolve_effective_alignment(TextAlign::Center, TextAlign::default(), true),
12804            TextAlign::Center,
12805            "non-justify alignments survive onto the last line"
12806        );
12807    }
12808
12809    #[test]
12810    fn resolve_effective_alignment_explicit_text_align_last_left_is_indistinguishable_from_auto() {
12811        // QUIRK: "auto" is encoded as TextAlign::default() == Left, so an author
12812        // writing `text-align-last: left` on `text-align: center` gets CENTER, not
12813        // left — the explicit value is swallowed by the auto check.
12814        assert_eq!(
12815            resolve_effective_alignment(TextAlign::Center, TextAlign::Left, true),
12816            TextAlign::Center
12817        );
12818        // Any other explicit value does win.
12819        assert_eq!(
12820            resolve_effective_alignment(TextAlign::Center, TextAlign::Right, true),
12821            TextAlign::Right
12822        );
12823        assert_eq!(
12824            resolve_effective_alignment(TextAlign::Justify, TextAlign::Justify, true),
12825            TextAlign::Justify
12826        );
12827    }
12828
12829    // =====================================================================
12830    // numeric: Spacing::resolve_px
12831    // =====================================================================
12832
12833    #[test]
12834    fn spacing_resolve_px_default_is_zero_and_font_size_independent() {
12835        assert_eq!(Spacing::default(), Spacing::Px(0));
12836        assert_eq!(Spacing::default().resolve_px(16.0), 0.0);
12837        assert_eq!(Spacing::Px(0).resolve_px(f32::NAN), 0.0);
12838    }
12839
12840    #[test]
12841    fn spacing_resolve_px_at_i32_extremes_stays_finite() {
12842        let hi = Spacing::Px(i32::MAX).resolve_px(16.0);
12843        let lo = Spacing::Px(i32::MIN).resolve_px(16.0);
12844        assert!(hi.is_finite() && hi > 2.0e9, "got {hi}");
12845        assert!(lo.is_finite() && lo < -2.0e9, "got {lo}");
12846        assert_eq!(lo, -2147483648.0);
12847    }
12848
12849    #[test]
12850    fn spacing_resolve_px_em_scales_with_font_size() {
12851        assert_eq!(Spacing::Em(2.0).resolve_px(16.0), 32.0);
12852        assert_eq!(Spacing::Em(2.0).resolve_px(0.0), 0.0);
12853        assert_eq!(Spacing::Em(-0.5).resolve_px(16.0), -8.0);
12854        assert_eq!(Spacing::PxF(0.4).resolve_px(999.0), 0.4, "PxF ignores font size");
12855    }
12856
12857    #[test]
12858    fn spacing_resolve_px_nan_and_overflow_are_defined() {
12859        assert!(Spacing::Em(f32::NAN).resolve_px(16.0).is_nan());
12860        assert!(Spacing::Em(1.0).resolve_px(f32::NAN).is_nan());
12861        assert!(Spacing::PxF(f32::NAN).resolve_px(16.0).is_nan());
12862        assert!(Spacing::Em(f32::MAX).resolve_px(2.0).is_infinite());
12863        // 0 * inf is NaN, not 0.
12864        assert!(Spacing::Em(0.0).resolve_px(f32::INFINITY).is_nan());
12865    }
12866
12867    #[test]
12868    fn spacing_px_and_pxf_of_the_same_value_are_distinct_cache_keys() {
12869        assert_ne!(Spacing::Px(1), Spacing::PxF(1.0));
12870        assert_ne!(hash_of(&Spacing::Px(1)), hash_of(&Spacing::PxF(1.0)));
12871        assert_eq!(Spacing::Px(1).resolve_px(16.0), Spacing::PxF(1.0).resolve_px(16.0));
12872    }
12873
12874    // =====================================================================
12875    // predicate/getter: BidiDirection, BidiLevel, WritingMode
12876    // =====================================================================
12877
12878    #[test]
12879    fn bidi_direction_is_rtl() {
12880        assert!(!BidiDirection::Ltr.is_rtl());
12881        assert!(BidiDirection::Rtl.is_rtl());
12882    }
12883
12884    #[test]
12885    fn bidi_level_parity_defines_rtl_across_the_whole_u8_range() {
12886        for lvl in [0_u8, 1, 2, 3, 126, 127, 254, u8::MAX] {
12887            let b = BidiLevel::new(lvl);
12888            assert_eq!(b.level(), lvl, "level() must round-trip new()");
12889            assert_eq!(b.is_rtl(), lvl % 2 == 1, "odd embedding levels are RTL");
12890        }
12891    }
12892
12893    #[test]
12894    fn writing_mode_is_advance_horizontal_for_every_variant() {
12895        assert!(WritingMode::HorizontalTb.is_advance_horizontal());
12896        assert!(WritingMode::SidewaysRl.is_advance_horizontal());
12897        assert!(WritingMode::SidewaysLr.is_advance_horizontal());
12898        assert!(!WritingMode::VerticalRl.is_advance_horizontal());
12899        assert!(!WritingMode::VerticalLr.is_advance_horizontal());
12900        assert_eq!(WritingMode::default(), WritingMode::HorizontalTb);
12901    }
12902
12903    #[test]
12904    fn writing_mode_get_direction_only_horizontal_defers_to_content() {
12905        assert_eq!(WritingMode::HorizontalTb.get_direction(), None);
12906        assert_eq!(WritingMode::VerticalRl.get_direction(), Some(BidiDirection::Rtl));
12907        assert_eq!(WritingMode::VerticalLr.get_direction(), Some(BidiDirection::Ltr));
12908        assert_eq!(WritingMode::SidewaysRl.get_direction(), Some(BidiDirection::Rtl));
12909        assert_eq!(WritingMode::SidewaysLr.get_direction(), Some(BidiDirection::Ltr));
12910    }
12911
12912    // =====================================================================
12913    // UnifiedConstraints
12914    // =====================================================================
12915
12916    #[test]
12917    fn unified_constraints_default_is_horizontal_max_content() {
12918        let c = UnifiedConstraints::default();
12919        assert!(!c.is_vertical());
12920        assert_eq!(c.available_width, AvailableSpace::MaxContent);
12921        assert_eq!(c.columns, 1);
12922        assert_eq!(c, UnifiedConstraints::default());
12923        assert_eq!(
12924            hash_of(&c),
12925            hash_of(&UnifiedConstraints::default()),
12926            "Hash/Eq must agree for the default constraints"
12927        );
12928    }
12929
12930    #[test]
12931    fn unified_constraints_is_vertical_only_for_the_two_vertical_modes() {
12932        let mut c = UnifiedConstraints::default();
12933        for (wm, want) in [
12934            (WritingMode::HorizontalTb, false),
12935            (WritingMode::VerticalRl, true),
12936            (WritingMode::VerticalLr, true),
12937            (WritingMode::SidewaysRl, false),
12938            (WritingMode::SidewaysLr, false),
12939        ] {
12940            c.writing_mode = Some(wm);
12941            assert_eq!(c.is_vertical(), want, "{wm:?}");
12942        }
12943        c.writing_mode = None;
12944        assert!(!c.is_vertical());
12945    }
12946
12947    #[test]
12948    fn unified_constraints_direction_uses_fallback_unless_the_writing_mode_forces_one() {
12949        let mut c = UnifiedConstraints::default();
12950        // No writing mode → fallback wins.
12951        assert_eq!(c.direction(BidiDirection::Rtl), BidiDirection::Rtl);
12952        // horizontal-tb → still content-determined → fallback wins.
12953        c.writing_mode = Some(WritingMode::HorizontalTb);
12954        assert_eq!(c.direction(BidiDirection::Rtl), BidiDirection::Rtl);
12955        // vertical-rl OVERRIDES the fallback.
12956        c.writing_mode = Some(WritingMode::VerticalRl);
12957        assert_eq!(c.direction(BidiDirection::Ltr), BidiDirection::Rtl);
12958    }
12959
12960    #[test]
12961    fn unified_constraints_resolved_line_height_uses_the_strut_for_normal() {
12962        let mut c = UnifiedConstraints::default();
12963        assert_eq!(
12964            c.resolved_line_height(),
12965            DEFAULT_STRUT_ASCENT + DEFAULT_STRUT_DESCENT
12966        );
12967        assert_eq!(c.resolved_line_height(), 16.0);
12968
12969        c.line_height = LineHeight::Px(0.0);
12970        assert_eq!(c.resolved_line_height(), 0.0, "an explicit 0 is honoured");
12971
12972        // Pinned: a negative / NaN px line-height is passed straight through.
12973        c.line_height = LineHeight::Px(-5.0);
12974        assert_eq!(c.resolved_line_height(), -5.0);
12975        c.line_height = LineHeight::Px(f32::NAN);
12976        assert!(c.resolved_line_height().is_nan());
12977    }
12978
12979    #[test]
12980    fn unified_constraints_partial_eq_is_rounding_tolerant() {
12981        // PartialEq rounds f32 fields, so sub-pixel strut differences compare EQUAL.
12982        let mut a = UnifiedConstraints::default();
12983        let mut b = UnifiedConstraints::default();
12984        a.strut_ascent = 12.8;
12985        b.strut_ascent = 12.9;
12986        assert_eq!(a, b, "12.8 and 12.9 both round to 13");
12987        b.strut_ascent = 14.0;
12988        assert_ne!(a, b);
12989    }
12990
12991    // =====================================================================
12992    // constructor: TextDecoration::from_css
12993    // =====================================================================
12994
12995    #[test]
12996    fn text_decoration_from_css_maps_each_variant_exclusively() {
12997        use azul_css::props::style::text::StyleTextDecoration;
12998        let none = TextDecoration::from_css(StyleTextDecoration::None);
12999        assert_eq!(none, TextDecoration::default());
13000        assert!(!none.underline && !none.strikethrough && !none.overline);
13001
13002        let u = TextDecoration::from_css(StyleTextDecoration::Underline);
13003        assert!(u.underline && !u.strikethrough && !u.overline);
13004
13005        let o = TextDecoration::from_css(StyleTextDecoration::Overline);
13006        assert!(!o.underline && !o.strikethrough && o.overline);
13007
13008        let lt = TextDecoration::from_css(StyleTextDecoration::LineThrough);
13009        assert!(!lt.underline && lt.strikethrough && !lt.overline);
13010    }
13011
13012    // =====================================================================
13013    // predicate/getter: InlineBorderInfo
13014    // =====================================================================
13015
13016    #[test]
13017    fn inline_border_info_default_has_no_border_and_no_chrome() {
13018        let b = InlineBorderInfo::default();
13019        assert!(!b.has_border());
13020        assert!(!b.has_chrome());
13021        assert_eq!(b.left_inset(), 0.0);
13022        assert_eq!(b.right_inset(), 0.0);
13023        assert_eq!(b.top_inset(), 0.0);
13024        assert_eq!(b.bottom_inset(), 0.0);
13025    }
13026
13027    #[test]
13028    fn inline_border_info_negative_widths_do_not_count_as_a_border() {
13029        let b = InlineBorderInfo {
13030            top: -1.0,
13031            right: -1.0,
13032            bottom: -1.0,
13033            left: -1.0,
13034            ..InlineBorderInfo::default()
13035        };
13036        assert!(!b.has_border(), "the predicate is strictly `> 0.0`");
13037        assert!(!b.has_chrome());
13038        // ...but the inset arithmetic still returns the negative value.
13039        assert_eq!(b.left_inset(), -1.0);
13040    }
13041
13042    #[test]
13043    fn inline_border_info_padding_alone_is_chrome_but_not_a_border() {
13044        let b = InlineBorderInfo {
13045            padding_left: 4.0,
13046            ..InlineBorderInfo::default()
13047        };
13048        assert!(!b.has_border());
13049        assert!(b.has_chrome());
13050        assert_eq!(b.left_inset(), 4.0);
13051    }
13052
13053    #[test]
13054    fn inline_border_info_nan_border_width_is_not_a_border() {
13055        let b = InlineBorderInfo {
13056            top: f32::NAN,
13057            ..InlineBorderInfo::default()
13058        };
13059        assert!(!b.has_border(), "NaN > 0.0 is false");
13060        assert!(b.top_inset().is_nan(), "but the inset still carries the NaN");
13061    }
13062
13063    #[test]
13064    fn inline_border_info_split_insets_swap_edges_in_rtl() {
13065        let base = InlineBorderInfo {
13066            left: 2.0,
13067            right: 3.0,
13068            padding_left: 1.0,
13069            padding_right: 1.0,
13070            ..InlineBorderInfo::default()
13071        };
13072
13073        // LTR: left edge on the FIRST fragment, right edge on the LAST.
13074        let ltr_first = InlineBorderInfo {
13075            is_first_fragment: true,
13076            is_last_fragment: false,
13077            ..base
13078        };
13079        assert_eq!(ltr_first.left_inset(), 3.0);
13080        assert_eq!(ltr_first.right_inset(), 0.0);
13081
13082        let ltr_last = InlineBorderInfo {
13083            is_first_fragment: false,
13084            is_last_fragment: true,
13085            ..base
13086        };
13087        assert_eq!(ltr_last.left_inset(), 0.0);
13088        assert_eq!(ltr_last.right_inset(), 4.0);
13089
13090        // RTL: mirrored.
13091        let rtl_first = InlineBorderInfo {
13092            is_first_fragment: true,
13093            is_last_fragment: false,
13094            is_rtl: true,
13095            ..base
13096        };
13097        assert_eq!(rtl_first.left_inset(), 0.0);
13098        assert_eq!(rtl_first.right_inset(), 4.0);
13099
13100        let rtl_last = InlineBorderInfo {
13101            is_first_fragment: false,
13102            is_last_fragment: true,
13103            is_rtl: true,
13104            ..base
13105        };
13106        assert_eq!(rtl_last.left_inset(), 3.0);
13107        assert_eq!(rtl_last.right_inset(), 0.0);
13108
13109        // A middle fragment (neither first nor last) draws NO horizontal edge.
13110        let middle = InlineBorderInfo {
13111            is_first_fragment: false,
13112            is_last_fragment: false,
13113            ..base
13114        };
13115        assert_eq!(middle.left_inset(), 0.0);
13116        assert_eq!(middle.right_inset(), 0.0);
13117        // Vertical insets are never suppressed.
13118        let tall = InlineBorderInfo {
13119            top: 1.0,
13120            bottom: 2.0,
13121            padding_top: 3.0,
13122            padding_bottom: 4.0,
13123            is_first_fragment: false,
13124            is_last_fragment: false,
13125            ..InlineBorderInfo::default()
13126        };
13127        assert_eq!(tall.top_inset(), 4.0);
13128        assert_eq!(tall.bottom_inset(), 6.0);
13129    }
13130
13131    // =====================================================================
13132    // getter/other: StyleProperties::layout_hash / layout_eq / apply_override
13133    // =====================================================================
13134
13135    #[test]
13136    fn style_layout_eq_ignores_render_only_properties() {
13137        let a = StyleProperties::default();
13138        let b = StyleProperties {
13139            color: ColorU {
13140                r: 255,
13141                g: 0,
13142                b: 0,
13143                a: 255,
13144            },
13145            background_color: Some(ColorU::TRANSPARENT),
13146            text_decoration: TextDecoration {
13147                underline: true,
13148                strikethrough: false,
13149                overline: false,
13150            },
13151            border: Some(InlineBorderInfo::default()),
13152            ..StyleProperties::default()
13153        };
13154        assert_ne!(a, b, "the full PartialEq DOES see the colour change");
13155        assert!(
13156            a.layout_eq(&b),
13157            "but layout_eq must ignore colour/decoration/border"
13158        );
13159        assert_eq!(a.layout_hash(), b.layout_hash());
13160    }
13161
13162    #[test]
13163    fn style_layout_eq_sees_sub_pixel_font_size_changes() {
13164        let a = StyleProperties::default();
13165        let b = StyleProperties {
13166            font_size_px: 16.4,
13167            ..StyleProperties::default()
13168        };
13169        assert!(
13170            !a.layout_eq(&b),
13171            "16.0 vs 16.4 must NOT share a shaping-cache entry"
13172        );
13173    }
13174
13175    #[test]
13176    fn style_layout_eq_sees_spacing_and_font_stack_changes() {
13177        let base = StyleProperties::default();
13178
13179        let spaced = StyleProperties {
13180            letter_spacing: Spacing::PxF(0.5),
13181            ..StyleProperties::default()
13182        };
13183        assert!(!base.layout_eq(&spaced));
13184
13185        let worded = StyleProperties {
13186            word_spacing: Spacing::Em(0.1),
13187            ..StyleProperties::default()
13188        };
13189        assert!(!base.layout_eq(&worded));
13190
13191        let other_font = StyleProperties {
13192            font_stack: FontStack::Stack(vec![sel("Arial")]),
13193            ..StyleProperties::default()
13194        };
13195        assert!(!base.layout_eq(&other_font));
13196
13197        let vertical = StyleProperties {
13198            writing_mode: WritingMode::VerticalRl,
13199            ..StyleProperties::default()
13200        };
13201        assert!(!base.layout_eq(&vertical));
13202    }
13203
13204    #[test]
13205    fn style_layout_hash_is_stable_across_repeated_calls() {
13206        let s = StyleProperties::default();
13207        assert_eq!(s.layout_hash(), s.layout_hash());
13208        assert!(s.layout_eq(&StyleProperties::default()));
13209    }
13210
13211    #[test]
13212    fn style_layout_eq_treats_two_nan_font_sizes_as_equal() {
13213        // layout_hash hashes the raw bits, so NaN == NaN here (unlike `==` on f32).
13214        let a = StyleProperties {
13215            font_size_px: f32::NAN,
13216            ..StyleProperties::default()
13217        };
13218        let b = StyleProperties {
13219            font_size_px: f32::NAN,
13220            ..StyleProperties::default()
13221        };
13222        assert!(a.layout_eq(&b));
13223        assert!(!a.layout_eq(&StyleProperties::default()));
13224    }
13225
13226    #[test]
13227    fn style_apply_override_with_an_empty_partial_changes_nothing() {
13228        let base = StyleProperties::default();
13229        let out = base.apply_override(&PartialStyleProperties::default());
13230        assert_eq!(out, base);
13231    }
13232
13233    #[test]
13234    fn style_apply_override_applies_only_the_some_fields() {
13235        let base = StyleProperties::default();
13236        let partial = PartialStyleProperties {
13237            font_size_px: Some(32.0),
13238            letter_spacing: Some(Spacing::PxF(1.5)),
13239            ..PartialStyleProperties::default()
13240        };
13241        let out = base.apply_override(&partial);
13242        assert_eq!(out.font_size_px, 32.0);
13243        assert_eq!(out.letter_spacing, Spacing::PxF(1.5));
13244        // Untouched fields are inherited verbatim.
13245        assert_eq!(out.word_spacing, base.word_spacing);
13246        assert_eq!(out.tab_size, base.tab_size);
13247        assert_eq!(out.font_stack, base.font_stack);
13248        assert!(!out.layout_eq(&base));
13249    }
13250
13251    #[test]
13252    fn style_apply_override_can_inject_nan_font_size() {
13253        let base = StyleProperties::default();
13254        let partial = PartialStyleProperties {
13255            font_size_px: Some(f32::NAN),
13256            ..PartialStyleProperties::default()
13257        };
13258        let out = base.apply_override(&partial);
13259        assert!(out.font_size_px.is_nan(), "no validation happens here");
13260    }
13261
13262    // =====================================================================
13263    // numeric: classify_character / get_justification_priority
13264    // =====================================================================
13265
13266    #[test]
13267    fn classify_character_covers_each_class() {
13268        assert_eq!(classify_character(0x0020), CharacterClass::Space);
13269        assert_eq!(classify_character(0x00A0), CharacterClass::Space);
13270        assert_eq!(classify_character(0x3000), CharacterClass::Space);
13271        assert_eq!(classify_character('.' as u32), CharacterClass::Punctuation);
13272        assert_eq!(classify_character('~' as u32), CharacterClass::Punctuation);
13273        assert_eq!(classify_character('a' as u32), CharacterClass::Letter);
13274        assert_eq!(classify_character(0x4E00), CharacterClass::Ideograph);
13275        assert_eq!(classify_character(0x9FFF), CharacterClass::Ideograph);
13276        assert_eq!(classify_character(0x0301), CharacterClass::Combining);
13277    }
13278
13279    #[test]
13280    fn classify_character_at_u32_extremes_defaults_to_letter() {
13281        assert_eq!(classify_character(0), CharacterClass::Letter);
13282        assert_eq!(classify_character(u32::MAX), CharacterClass::Letter);
13283        // Boundary walk around the ideograph range.
13284        assert_eq!(classify_character(0x4DFF), CharacterClass::Letter);
13285        assert_eq!(classify_character(0xA000), CharacterClass::Letter);
13286    }
13287
13288    #[test]
13289    fn get_justification_priority_is_strictly_ordered_space_to_combining() {
13290        let p = |c| get_justification_priority(c);
13291        assert_eq!(p(CharacterClass::Space), 0);
13292        assert_eq!(p(CharacterClass::Combining), 255);
13293        assert!(p(CharacterClass::Space) < p(CharacterClass::Punctuation));
13294        assert!(p(CharacterClass::Punctuation) < p(CharacterClass::Ideograph));
13295        assert!(p(CharacterClass::Ideograph) < p(CharacterClass::Letter));
13296        assert!(p(CharacterClass::Letter) < p(CharacterClass::Symbol));
13297        assert!(p(CharacterClass::Symbol) < p(CharacterClass::Combining));
13298    }
13299
13300    // =====================================================================
13301    // predicate: char-level classifiers
13302    // =====================================================================
13303
13304    #[test]
13305    fn is_hanging_punctuation_char_only_stops_and_commas() {
13306        assert!(is_hanging_punctuation_char(','));
13307        assert!(is_hanging_punctuation_char('.'));
13308        assert!(is_hanging_punctuation_char('\u{3001}'));
13309        assert!(is_hanging_punctuation_char('\u{FF0E}'));
13310        assert!(!is_hanging_punctuation_char(';'));
13311        assert!(!is_hanging_punctuation_char(' '));
13312        assert!(!is_hanging_punctuation_char('\0'));
13313        assert!(!is_hanging_punctuation_char(char::MAX));
13314    }
13315
13316    #[test]
13317    fn is_word_char_is_alphanumeric_or_underscore() {
13318        assert!(is_word_char('a'));
13319        assert!(is_word_char('Z'));
13320        assert!(is_word_char('9'));
13321        assert!(is_word_char('_'));
13322        assert!(is_word_char('é'), "non-ASCII letters are word chars");
13323        assert!(is_word_char('中'), "ideographs are alphanumeric");
13324        assert!(!is_word_char(' '));
13325        assert!(!is_word_char('-'));
13326        assert!(!is_word_char('.'));
13327        assert!(!is_word_char('\u{00A0}'));
13328        assert!(!is_word_char('\0'));
13329    }
13330
13331    #[test]
13332    fn is_word_separator_char_excludes_tabs_and_fixed_width_spaces() {
13333        assert!(is_word_separator_char(' '));
13334        assert!(is_word_separator_char('\u{00A0}'), "NBSP IS a word separator");
13335        assert!(is_word_separator_char('\u{1680}'));
13336        assert!(is_word_separator_char('\u{202F}'));
13337        assert!(is_word_separator_char('\u{10100}'));
13338
13339        // Per CSS Text §7.1 these are NOT word separators, despite looking like spaces.
13340        assert!(!is_word_separator_char('\u{2000}'));
13341        assert!(!is_word_separator_char('\u{200A}'));
13342        assert!(!is_word_separator_char('\u{3000}'), "ideographic space excluded");
13343        // Nor are tab/newline (they are handled by white-space processing instead).
13344        assert!(!is_word_separator_char('\t'));
13345        assert!(!is_word_separator_char('\n'));
13346        assert!(!is_word_separator_char('.'));
13347        assert!(!is_word_separator_char(char::MAX));
13348    }
13349
13350    #[test]
13351    fn is_cursive_script_char_boundaries() {
13352        assert!(!is_cursive_script_char('\u{05FF}'), "one below Arabic");
13353        assert!(is_cursive_script_char('\u{0600}'), "Arabic block start");
13354        assert!(is_cursive_script_char('\u{06FF}'), "Arabic block end");
13355        assert!(is_cursive_script_char('\u{0700}'), "Syriac");
13356        assert!(is_cursive_script_char('\u{1800}'), "Mongolian");
13357        assert!(is_cursive_script_char('\u{10D00}'), "Hanifi Rohingya (astral)");
13358        assert!(!is_cursive_script_char('a'));
13359        assert!(!is_cursive_script_char('中'));
13360        assert!(!is_cursive_script_char('\0'));
13361        assert!(!is_cursive_script_char(char::MAX));
13362    }
13363
13364    #[test]
13365    fn is_cjk_character_boundaries() {
13366        assert!(is_cjk_character('中')); // U+4E2D
13367        assert!(is_cjk_character('\u{4E00}'));
13368        assert!(is_cjk_character('\u{9FFF}'));
13369        assert!(is_cjk_character('\u{3040}'), "hiragana block");
13370        assert!(is_cjk_character('\u{30FF}'), "katakana block");
13371        assert!(is_cjk_character('\u{AC00}'), "hangul syllables");
13372        assert!(is_cjk_character('\u{FF01}'), "fullwidth forms");
13373        assert!(!is_cjk_character('\u{4DFF}'), "one below the ideograph block");
13374        assert!(!is_cjk_character('a'));
13375        assert!(!is_cjk_character('\0'));
13376        assert!(!is_cjk_character(char::MAX));
13377    }
13378
13379    #[test]
13380    fn break_control_predicates_are_disjoint() {
13381        assert!(is_break_suppressing_control('\u{200D}'));
13382        assert!(is_break_suppressing_control('\u{2060}'));
13383        assert!(is_break_suppressing_control('\u{FEFF}'));
13384        assert!(!is_break_suppressing_control(' '));
13385        assert!(!is_break_suppressing_control('\u{200B}'));
13386
13387        assert!(is_break_forcing_control('\u{200B}'));
13388        assert!(is_break_forcing_control('\u{2028}'));
13389        assert!(is_break_forcing_control('\u{2029}'));
13390        assert!(!is_break_forcing_control(' '));
13391        assert!(!is_break_forcing_control('\u{200D}'));
13392
13393        for ch in ['\u{200D}', '\u{2060}', '\u{FEFF}', '\u{200B}', '\u{2028}'] {
13394            assert!(
13395                !(is_break_suppressing_control(ch) && is_break_forcing_control(ch)),
13396                "{ch:?} cannot both force and suppress a break"
13397            );
13398        }
13399    }
13400
13401    #[test]
13402    fn is_small_kana_matches_only_the_cj_class() {
13403        assert!(is_small_kana('っ'));
13404        assert!(is_small_kana('ゃ'));
13405        assert!(is_small_kana('ッ'));
13406        assert!(is_small_kana('ー'), "prolonged sound mark is class CJ");
13407        assert!(!is_small_kana('つ'), "the FULL-size kana is not CJ");
13408        assert!(!is_small_kana('中'));
13409        assert!(!is_small_kana('a'));
13410        assert!(!is_small_kana('\0'));
13411    }
13412
13413    #[test]
13414    fn is_cjk_break_allowed_by_strictness_per_level() {
13415        use LineBreakStrictness::{Anywhere, Auto, Loose, Normal, Strict};
13416
13417        // Anywhere / Loose: everything is breakable.
13418        for ch in ['っ', '\u{301C}', '\u{2010}', '中'] {
13419            assert!(is_cjk_break_allowed_by_strictness(ch, None, Anywhere), "{ch:?}");
13420            assert!(is_cjk_break_allowed_by_strictness(ch, None, Loose), "{ch:?}");
13421        }
13422
13423        // Normal/Auto: hyphens forbidden, small kana allowed.
13424        for level in [Normal, Auto] {
13425            assert!(!is_cjk_break_allowed_by_strictness('\u{2010}', None, level));
13426            assert!(!is_cjk_break_allowed_by_strictness('\u{2013}', None, level));
13427            assert!(is_cjk_break_allowed_by_strictness('っ', None, level));
13428            assert!(is_cjk_break_allowed_by_strictness('中', None, level));
13429        }
13430
13431        // Strict: small kana and CJK hyphen-likes are forbidden too.
13432        assert!(!is_cjk_break_allowed_by_strictness('っ', None, Strict));
13433        assert!(!is_cjk_break_allowed_by_strictness('ー', None, Strict));
13434        assert!(!is_cjk_break_allowed_by_strictness('\u{301C}', None, Strict));
13435        assert!(!is_cjk_break_allowed_by_strictness('\u{30A0}', None, Strict));
13436        assert!(is_cjk_break_allowed_by_strictness('中', None, Strict));
13437
13438        // prev_ch is currently ignored — pin that so a future use is a deliberate change.
13439        assert_eq!(
13440            is_cjk_break_allowed_by_strictness('中', Some('x'), Strict),
13441            is_cjk_break_allowed_by_strictness('中', None, Strict)
13442        );
13443    }
13444
13445    // =====================================================================
13446    // getter/predicate: Glyph
13447    // =====================================================================
13448
13449    fn plain_glyph(codepoint: char, advance: f32) -> Glyph {
13450        Glyph {
13451            glyph_id: 1,
13452            codepoint,
13453            font_hash: 7,
13454            font_metrics: std_metrics(),
13455            style: style(),
13456            source: GlyphSource::Char,
13457            logical_byte_index: 0,
13458            logical_byte_len: codepoint.len_utf8(),
13459            content_index: 0,
13460            cluster: 0,
13461            advance,
13462            kerning: 0.0,
13463            offset: Point { x: 0.0, y: 0.0 },
13464            vertical_advance: advance,
13465            vertical_origin_y: 0.0,
13466            vertical_bearing: Point { x: 0.0, y: 0.0 },
13467            orientation: GlyphOrientation::Horizontal,
13468            script: Script::Latin,
13469            bidi_level: BidiLevel::new(0),
13470        }
13471    }
13472
13473    #[test]
13474    fn glyph_bounds_is_advance_by_resolved_line_height() {
13475        let g = plain_glyph('a', 9.5);
13476        let b = g.bounds();
13477        assert_eq!(b.x, 0.0);
13478        assert_eq!(b.y, 0.0);
13479        assert_eq!(b.width, 9.5);
13480        approx(b.height, 16.0); // normal line-height on a 1000/800/-200 font @16px
13481    }
13482
13483    #[test]
13484    fn glyph_bounds_with_zero_advance_and_zero_upem_does_not_panic() {
13485        let mut g = plain_glyph('a', 0.0);
13486        g.font_metrics = metrics(0, 0.0, 0.0, 0.0);
13487        let b = g.bounds();
13488        assert_eq!(b.width, 0.0);
13489        approx(b.height, 19.2); // zero-upem falls back to 1.2em
13490    }
13491
13492    #[test]
13493    fn glyph_whitespace_and_justification_predicates() {
13494        let space = plain_glyph(' ', 4.0);
13495        assert!(space.is_whitespace());
13496        assert_eq!(space.character_class(), CharacterClass::Space);
13497        assert!(!space.can_justify(), "whitespace is never itself justified");
13498        assert_eq!(space.justification_priority(), 0);
13499        assert!(space.break_opportunity_after());
13500
13501        let letter = plain_glyph('a', 8.0);
13502        assert!(!letter.is_whitespace());
13503        assert!(letter.can_justify());
13504        assert_eq!(letter.justification_priority(), 192);
13505        assert!(!letter.break_opportunity_after());
13506
13507        let combining = plain_glyph('\u{0301}', 0.0);
13508        assert!(!combining.is_whitespace());
13509        assert!(!combining.can_justify(), "combining marks are never justified");
13510        assert_eq!(combining.justification_priority(), 255);
13511    }
13512
13513    #[test]
13514    fn glyph_break_opportunity_after_covers_every_hyphen_form() {
13515        assert!(plain_glyph('\u{00AD}', 0.0).break_opportunity_after(), "soft hyphen");
13516        assert!(plain_glyph('\u{002D}', 4.0).break_opportunity_after(), "hyphen-minus");
13517        assert!(plain_glyph('\u{2010}', 4.0).break_opportunity_after(), "U+2010");
13518        assert!(plain_glyph('\t', 8.0).break_opportunity_after(), "tab is whitespace");
13519        assert!(!plain_glyph('\u{2011}', 4.0).break_opportunity_after(), "NON-BREAKING hyphen");
13520        assert!(!plain_glyph('/', 4.0).break_opportunity_after());
13521    }
13522
13523    // =====================================================================
13524    // getter/predicate: ShapedItem + item helpers
13525    // =====================================================================
13526
13527    #[test]
13528    fn shaped_item_as_cluster_only_matches_clusters() {
13529        assert!(cl("a", 8.0).as_cluster().is_some());
13530        assert!(obj(10.0, 10.0, 0.0).as_cluster().is_none());
13531        assert!(brk().as_cluster().is_none());
13532        assert!(tab(8.0, 16.0).as_cluster().is_none());
13533    }
13534
13535    #[test]
13536    fn shaped_item_bounds_of_a_break_is_the_zero_rect() {
13537        assert_eq!(brk().bounds(), Rect::default());
13538        assert_eq!(obj(10.0, 20.0, 0.0).bounds().width, 10.0);
13539        assert_eq!(tab(8.0, 16.0).bounds().height, 16.0);
13540        let c = cl("a", 9.5);
13541        assert_eq!(c.bounds().width, 9.5, "a cluster's width is its advance");
13542        approx(c.bounds().height, 16.0); // ascent + descent of the fixture font
13543    }
13544
13545    #[test]
13546    fn get_item_measure_sums_advance_and_kerning() {
13547        let st = style();
13548        let mut g1 = shaped_glyph(st.clone(), std_metrics(), 8.0);
13549        g1.kerning = -1.5;
13550        let mut g2 = shaped_glyph(st.clone(), std_metrics(), 8.0);
13551        g2.kerning = 0.5;
13552        let item = make_cluster("ab", 16.0, st, smallvec![g1, g2], gid(0, 0));
13553        assert_eq!(get_item_measure(&item, false), 15.0, "16 + (-1.5) + 0.5");
13554        assert_eq!(
13555            get_item_measure(&item, true),
13556            15.0,
13557            "clusters ignore the is_vertical flag (advance is already axis-relative)"
13558        );
13559    }
13560
13561    #[test]
13562    fn get_item_measure_of_a_break_is_zero_and_objects_switch_axis() {
13563        assert_eq!(get_item_measure(&brk(), false), 0.0);
13564        assert_eq!(get_item_measure(&brk(), true), 0.0);
13565        let o = obj(30.0, 20.0, 0.0);
13566        assert_eq!(get_item_measure(&o, false), 30.0);
13567        assert_eq!(get_item_measure(&o, true), 20.0);
13568    }
13569
13570    #[test]
13571    fn get_item_measure_with_spacing_adds_letter_spacing_but_not_for_cursive() {
13572        let st = styled(|s| s.letter_spacing = Spacing::PxF(2.0));
13573        let latin = cl_styled("a", 10.0, st.clone());
13574        assert_eq!(get_item_measure(&latin, false), 10.0);
13575        assert_eq!(get_item_measure_with_spacing(&latin, false), 12.0);
13576
13577        // Cursive (Arabic) clusters must never receive letter-spacing.
13578        let arabic = cl_styled("\u{0627}", 10.0, st);
13579        assert_eq!(
13580            get_item_measure_with_spacing(&arabic, false),
13581            10.0,
13582            "letter-spacing is suppressed for cursive scripts (CSS Text 3 App. D)"
13583        );
13584    }
13585
13586    #[test]
13587    fn get_item_measure_with_spacing_adds_word_spacing_only_on_separators() {
13588        let st = styled(|s| {
13589            s.word_spacing = Spacing::PxF(5.0);
13590            s.letter_spacing = Spacing::PxF(1.0);
13591        });
13592        let space = cl_styled(" ", 4.0, st.clone());
13593        assert_eq!(
13594            get_item_measure_with_spacing(&space, false),
13595            10.0,
13596            "4 + letter(1) + word(5)"
13597        );
13598        let letter = cl_styled("a", 8.0, st);
13599        assert_eq!(
13600            get_item_measure_with_spacing(&letter, false),
13601            9.0,
13602            "no word-spacing on a non-separator"
13603        );
13604        // Non-cluster items get no spacing at all.
13605        assert_eq!(get_item_measure_with_spacing(&brk(), false), 0.0);
13606    }
13607
13608    #[test]
13609    fn is_collapsible_whitespace_is_vacuously_true_for_an_empty_cluster() {
13610        assert!(is_collapsible_whitespace(&cl(" ", 4.0)));
13611        assert!(is_collapsible_whitespace(&cl("\t", 8.0)));
13612        assert!(is_collapsible_whitespace(&cl("\u{1680}", 4.0)));
13613        assert!(is_collapsible_whitespace(&cl("  \t ", 16.0)));
13614        assert!(!is_collapsible_whitespace(&cl("\n", 0.0)), "newline is NOT collapsible here");
13615        assert!(!is_collapsible_whitespace(&cl("a", 8.0)));
13616        assert!(!is_collapsible_whitespace(&cl("a ", 12.0)), "all() — mixed is false");
13617        assert!(!is_collapsible_whitespace(&obj(1.0, 1.0, 0.0)));
13618        // QUIRK: `chars().all(..)` on an empty string is vacuously TRUE, so a
13619        // zero-text cluster is treated as strippable whitespace at line edges.
13620        assert!(
13621            is_collapsible_whitespace(&cl("", 0.0)),
13622            "an empty cluster counts as collapsible whitespace"
13623        );
13624    }
13625
13626    #[test]
13627    fn is_word_separator_and_zero_width_space_on_items() {
13628        assert!(is_word_separator(&cl(" ", 4.0)));
13629        assert!(is_word_separator(&cl("a b", 20.0)), "any() — one space suffices");
13630        assert!(!is_word_separator(&cl("", 0.0)), "any() on empty is false");
13631        assert!(!is_word_separator(&cl("\u{3000}", 16.0)));
13632        assert!(!is_word_separator(&brk()));
13633        assert!(!is_word_separator(&obj(1.0, 1.0, 0.0)));
13634
13635        assert!(is_zero_width_space(&cl("\u{200B}", 0.0)));
13636        assert!(is_zero_width_space(&cl("a\u{200B}", 8.0)), "contains(), not equals()");
13637        assert!(!is_zero_width_space(&cl(" ", 4.0)));
13638        assert!(!is_zero_width_space(&obj(1.0, 1.0, 0.0)));
13639    }
13640
13641    #[test]
13642    fn can_justify_after_rejects_objects_empty_clusters_and_combining_marks() {
13643        assert!(can_justify_after(&cl("a", 8.0)));
13644        assert!(!can_justify_after(&cl(" ", 4.0)));
13645        assert!(!can_justify_after(&cl("a\u{0301}", 8.0)), "trailing combining mark");
13646        assert!(!can_justify_after(&cl("", 0.0)), "no last char → false");
13647        assert!(
13648            !can_justify_after(&obj(10.0, 10.0, 0.0)),
13649            "CSS 2.2 §9.4.2: never stretch after an atomic inline"
13650        );
13651        assert!(!can_justify_after(&brk()));
13652    }
13653
13654    #[test]
13655    fn is_hanging_punctuation_requires_a_single_glyph_cluster() {
13656        assert!(is_hanging_punctuation(&cl(".", 4.0)));
13657        assert!(is_hanging_punctuation(&cl(",", 4.0)));
13658        assert!(!is_hanging_punctuation(&cl("a", 8.0)));
13659        assert!(!is_hanging_punctuation(&cl("", 0.0)), "no first char");
13660        assert!(!is_hanging_punctuation(&obj(1.0, 1.0, 0.0)));
13661
13662        // A two-glyph cluster is rejected even if it starts with a full stop.
13663        let st = style();
13664        let g1 = shaped_glyph(st.clone(), std_metrics(), 4.0);
13665        let g2 = shaped_glyph(st.clone(), std_metrics(), 4.0);
13666        let two = make_cluster(".", 8.0, st, smallvec![g1, g2], gid(0, 0));
13667        assert!(!is_hanging_punctuation(&two));
13668    }
13669
13670    #[test]
13671    fn cluster_script_predicates() {
13672        let arabic = cl("\u{0627}", 10.0);
13673        let latin = cl("a", 8.0);
13674        let cjk = cl("中", 16.0);
13675
13676        assert!(is_cursive_script_cluster(arabic.as_cluster().unwrap()));
13677        assert!(!is_cursive_script_cluster(latin.as_cluster().unwrap()));
13678        assert!(
13679            !is_cursive_script_cluster(cl("", 0.0).as_cluster().unwrap()),
13680            "empty cluster has no first char"
13681        );
13682
13683        assert!(is_cjk_cluster(cjk.as_cluster().unwrap()));
13684        assert!(!is_cjk_cluster(latin.as_cluster().unwrap()));
13685
13686        // is_arabic_cluster keys off the GLYPH script, not the text.
13687        assert!(
13688            !is_arabic_cluster(arabic.as_cluster().unwrap()),
13689            "the fixture's glyph carries Script::Latin, so the text alone is not enough"
13690        );
13691        let st = style();
13692        let mut g = shaped_glyph(st.clone(), std_metrics(), 10.0);
13693        g.script = Script::Arabic;
13694        let real_arabic = make_cluster("\u{0627}", 10.0, st, smallvec![g], gid(0, 0));
13695        assert!(is_arabic_cluster(real_arabic.as_cluster().unwrap()));
13696    }
13697
13698    #[test]
13699    fn cluster_is_word_boundary_for_punctuation_and_whitespace() {
13700        assert!(cluster_is_word_boundary(cl(" ", 4.0).as_cluster().unwrap()));
13701        assert!(cluster_is_word_boundary(cl(".", 4.0).as_cluster().unwrap()));
13702        assert!(cluster_is_word_boundary(cl("", 0.0).as_cluster().unwrap()), "vacuous");
13703        assert!(!cluster_is_word_boundary(cl("a", 8.0).as_cluster().unwrap()));
13704        assert!(!cluster_is_word_boundary(cl("_", 8.0).as_cluster().unwrap()));
13705    }
13706
13707    #[test]
13708    fn get_baseline_for_item_only_defined_for_clusters_and_boxes() {
13709        assert_eq!(get_baseline_for_item(&brk()), None);
13710        assert_eq!(get_baseline_for_item(&tab(8.0, 16.0)), None);
13711        assert_eq!(get_baseline_for_item(&obj(10.0, 20.0, 3.0)), Some(3.0));
13712        // Cluster: baseline of the LAST glyph, scaled to font size (800/1000*16).
13713        approx(
13714            get_baseline_for_item(&cl("a", 8.0)).expect("a glyph-bearing cluster has a baseline"),
13715            12.8,
13716        );
13717        assert_eq!(
13718            get_baseline_for_item(&cl_no_glyphs("", 0.0)),
13719            None,
13720            "a glyph-less cluster has no baseline"
13721        );
13722    }
13723
13724    #[test]
13725    fn get_item_vertical_metrics_approx_for_every_variant() {
13726        // Cluster with real glyphs: ascent 12.8, descent 3.2, no leading (lh == a+d).
13727        let (a, d) = get_item_vertical_metrics_approx(&cl("a", 8.0));
13728        approx(a, 12.8);
13729        approx(d, 3.2);
13730
13731        // Glyph-less cluster → 80/20 split of the fallback 1.2em line box.
13732        let (a, d) = get_item_vertical_metrics_approx(&cl_no_glyphs("", 0.0));
13733        approx(a, 19.2 * FALLBACK_ASCENT_RATIO);
13734        approx(d, 19.2 * FALLBACK_DESCENT_RATIO);
13735
13736        // Object → all ascent, no descent.
13737        assert_eq!(get_item_vertical_metrics_approx(&obj(10.0, 20.0, 5.0)), (20.0, 0.0));
13738        // Break → nothing.
13739        assert_eq!(get_item_vertical_metrics_approx(&brk()), (0.0, 0.0));
13740        // Tab → 80/20 of its box height.
13741        let (a, d) = get_item_vertical_metrics_approx(&tab(8.0, 10.0));
13742        approx(a, 8.0);
13743        approx(d, 2.0);
13744    }
13745
13746    #[test]
13747    fn get_item_vertical_metrics_approx_skips_zero_upem_glyphs() {
13748        let st = style();
13749        let g = shaped_glyph(st.clone(), metrics(0, 800.0, -200.0, 0.0), 8.0);
13750        let item = make_cluster("a", 8.0, st, smallvec![g], gid(0, 0));
13751        assert_eq!(
13752            get_item_vertical_metrics_approx(&item),
13753            (0.0, 0.0),
13754            "a zero-upem glyph is skipped rather than producing inf/NaN metrics"
13755        );
13756    }
13757
13758    #[test]
13759    fn get_item_vertical_metrics_uses_the_strut_for_glyphless_clusters() {
13760        let c = UnifiedConstraints::default();
13761        let (a, d) = get_item_vertical_metrics(&cl_no_glyphs("", 0.0), &c);
13762        // resolved lh = 1.2 * 16 = 19.2; a+d = 16.0; half-leading = 1.6
13763        approx(a, DEFAULT_STRUT_ASCENT + 1.6);
13764        approx(d, DEFAULT_STRUT_DESCENT + 1.6);
13765
13766        assert_eq!(get_item_vertical_metrics(&brk(), &c), (0.0, 0.0));
13767        // Objects clamp negative ascent/descent at 0.
13768        let (a, d) = get_item_vertical_metrics(&obj(10.0, 10.0, 30.0), &c);
13769        assert_eq!(a, 0.0, "baseline_offset > height must clamp the ascent at 0");
13770        assert_eq!(d, 30.0);
13771    }
13772
13773    #[test]
13774    fn get_item_vertical_align_only_for_objects_with_image_or_shape_content() {
13775        assert_eq!(get_item_vertical_align(&cl("a", 8.0)), None);
13776        assert_eq!(get_item_vertical_align(&brk()), None);
13777        // Our fixture Object carries a Space, which has no alignment.
13778        assert_eq!(get_item_vertical_align(&obj(1.0, 1.0, 0.0)), None);
13779
13780        let img = ShapedItem::Object {
13781            source: ci(0, 0),
13782            bounds: Rect::default(),
13783            baseline_offset: 0.0,
13784            content: InlineContent::Image(InlineImage {
13785                source: ImageSource::Placeholder(Size::new(10.0, 10.0)),
13786                intrinsic_size: Size::new(10.0, 10.0),
13787                display_size: None,
13788                baseline_offset: 0.0,
13789                alignment: VerticalAlign::Top,
13790                object_fit: ObjectFit::Fill,
13791            }),
13792        };
13793        assert_eq!(get_item_vertical_align(&img), Some(VerticalAlign::Top));
13794    }
13795
13796    // =====================================================================
13797    // predicate: break-opportunity logic
13798    // =====================================================================
13799
13800    #[test]
13801    fn no_break_space_is_a_word_separator_but_never_a_break_opportunity() {
13802        let nbsp = cl("\u{00A0}", 4.0);
13803        assert!(is_word_separator(&nbsp), "NBSP participates in word-spacing");
13804        assert!(
13805            !is_break_opportunity(&nbsp),
13806            "...but must NOT offer a soft wrap (10\\u{{00A0}}km must not wrap)"
13807        );
13808        assert!(!is_break_opportunity_with_word_break(
13809            &nbsp,
13810            WordBreak::BreakAll,
13811            Hyphens::Auto
13812        ));
13813        // Same for NNBSP / word joiner / ZWNBSP.
13814        for ch in ['\u{202F}', '\u{2060}', '\u{FEFF}'] {
13815            let item = cl(&ch.to_string(), 4.0);
13816            assert!(
13817                !is_break_opportunity_with_word_break(&item, WordBreak::BreakAll, Hyphens::Auto),
13818                "{ch:?} must suppress breaks"
13819            );
13820        }
13821    }
13822
13823    #[test]
13824    fn zero_width_space_breaks_even_under_keep_all() {
13825        let zwsp = cl("\u{200B}", 0.0);
13826        assert!(is_break_opportunity(&zwsp));
13827        for wb in [WordBreak::Normal, WordBreak::BreakAll, WordBreak::KeepAll] {
13828            assert!(
13829                is_break_opportunity_with_word_break(&zwsp, wb, Hyphens::None),
13830                "ZWSP must always break ({wb:?})"
13831            );
13832        }
13833    }
13834
13835    #[test]
13836    fn word_break_modes_change_cjk_break_opportunities() {
13837        let cjk = cl("中", 16.0);
13838        assert!(is_break_opportunity_with_word_break(&cjk, WordBreak::Normal, Hyphens::Manual));
13839        assert!(is_break_opportunity_with_word_break(&cjk, WordBreak::BreakAll, Hyphens::Manual));
13840        assert!(
13841            !is_break_opportunity_with_word_break(&cjk, WordBreak::KeepAll, Hyphens::Manual),
13842            "keep-all suppresses inter-ideograph breaks"
13843        );
13844
13845        let latin = cl("a", 8.0);
13846        assert!(!is_break_opportunity_with_word_break(&latin, WordBreak::Normal, Hyphens::Manual));
13847        assert!(
13848            is_break_opportunity_with_word_break(&latin, WordBreak::BreakAll, Hyphens::Manual),
13849            "break-all makes every cluster breakable"
13850        );
13851        assert!(!is_break_opportunity_with_word_break(&latin, WordBreak::KeepAll, Hyphens::Manual));
13852    }
13853
13854    #[test]
13855    fn soft_hyphen_break_depends_on_the_hyphens_property() {
13856        let shy = cl("\u{00AD}", 0.0);
13857        assert!(!is_break_opportunity_with_word_break(
13858            &shy,
13859            WordBreak::Normal,
13860            Hyphens::None
13861        ));
13862        assert!(is_break_opportunity_with_word_break(
13863            &shy,
13864            WordBreak::Normal,
13865            Hyphens::Manual
13866        ));
13867        assert!(is_break_opportunity_with_word_break(
13868            &shy,
13869            WordBreak::Normal,
13870            Hyphens::Auto
13871        ));
13872    }
13873
13874    #[test]
13875    fn trailing_hyphen_and_slash_always_break_regardless_of_hyphens() {
13876        for text in ["co-", "co\u{2010}", "a/"] {
13877            let item = cl(text, 10.0);
13878            assert!(
13879                is_break_opportunity_with_word_break(&item, WordBreak::KeepAll, Hyphens::None),
13880                "{text:?} must offer a break after it even with hyphens:none"
13881            );
13882        }
13883        // A LEADING hyphen is not a break opportunity after the cluster.
13884        assert!(!is_break_opportunity_with_word_break(
13885            &cl("-a", 10.0),
13886            WordBreak::Normal,
13887            Hyphens::None
13888        ));
13889    }
13890
13891    #[test]
13892    fn atomic_inlines_are_break_opportunities_but_breaks_and_tabs_differ() {
13893        assert!(is_break_opportunity(&obj(10.0, 10.0, 0.0)), "CSS Text 3 §5.1");
13894        assert!(is_break_opportunity(&brk()));
13895        assert!(!is_break_opportunity(&tab(8.0, 16.0)), "a Tab is not itself a wrap point");
13896        assert!(is_break_opportunity(&cl(" ", 4.0)));
13897        assert!(!is_break_opportunity(&cl("a", 8.0)));
13898    }
13899
13900    // =====================================================================
13901    // numeric: geometry scanline helpers
13902    // =====================================================================
13903
13904    #[test]
13905    fn merge_segments_of_zero_or_one_segment_is_identity() {
13906        assert!(merge_segments(Vec::new()).is_empty());
13907        let one = vec![LineSegment {
13908            start_x: 5.0,
13909            width: 3.0,
13910            priority: 0,
13911        }];
13912        let out = merge_segments(one);
13913        assert_eq!(out.len(), 1);
13914        assert_eq!(out[0].start_x, 5.0);
13915    }
13916
13917    #[test]
13918    fn merge_segments_joins_overlapping_and_touching_spans() {
13919        let segs = vec![
13920            LineSegment {
13921                start_x: 0.0,
13922                width: 10.0,
13923                priority: 0,
13924            },
13925            LineSegment {
13926                start_x: 5.0,
13927                width: 10.0,
13928                priority: 0,
13929            }, // overlaps
13930            LineSegment {
13931                start_x: 15.0,
13932                width: 5.0,
13933                priority: 0,
13934            }, // exactly adjacent
13935            LineSegment {
13936                start_x: 100.0,
13937                width: 5.0,
13938                priority: 0,
13939            }, // disjoint
13940        ];
13941        let out = merge_segments(segs);
13942        assert_eq!(out.len(), 2, "three touching spans collapse into one");
13943        assert_eq!(out[0].start_x, 0.0);
13944        assert_eq!(out[0].width, 20.0);
13945        assert_eq!(out[1].start_x, 100.0);
13946    }
13947
13948    #[test]
13949    fn merge_segments_sorts_unordered_input() {
13950        let segs = vec![
13951            LineSegment {
13952                start_x: 50.0,
13953                width: 5.0,
13954                priority: 0,
13955            },
13956            LineSegment {
13957                start_x: 0.0,
13958                width: 5.0,
13959                priority: 0,
13960            },
13961        ];
13962        let out = merge_segments(segs);
13963        assert_eq!(out.len(), 2);
13964        assert_eq!(out[0].start_x, 0.0);
13965        assert_eq!(out[1].start_x, 50.0);
13966    }
13967
13968    #[test]
13969    fn merge_segments_is_nan_tolerant() {
13970        // A NaN start_x used to abort layout via `partial_cmp(...).unwrap()`; the
13971        // sort now falls back to Equal (like every other float compare in this
13972        // module), so the merge completes without panicking.
13973        let segs = vec![
13974            LineSegment {
13975                start_x: 0.0,
13976                width: 10.0,
13977                priority: 0,
13978            },
13979            LineSegment {
13980                start_x: f32::NAN,
13981                width: 10.0,
13982                priority: 0,
13983            },
13984        ];
13985        let out = merge_segments(segs);
13986        assert!(!out.is_empty(), "NaN input must not abort the merge");
13987    }
13988
13989    #[test]
13990    fn polygon_line_intersection_needs_at_least_three_points() {
13991        assert!(polygon_line_intersection(&[], 0.0, 1.0).is_empty());
13992        assert!(polygon_line_intersection(&[Point { x: 0.0, y: 0.0 }], 0.0, 1.0).is_empty());
13993        assert!(polygon_line_intersection(
13994            &[Point { x: 0.0, y: 0.0 }, Point { x: 1.0, y: 1.0 }],
13995            0.0,
13996            1.0
13997        )
13998        .is_empty());
13999    }
14000
14001    #[test]
14002    fn polygon_line_intersection_narrows_across_a_triangle() {
14003        // Right triangle (0,0) - (100,0) - (0,100): span width ≈ 100 - y.
14004        let tri = [
14005            Point { x: 0.0, y: 0.0 },
14006            Point { x: 100.0, y: 0.0 },
14007            Point { x: 0.0, y: 100.0 },
14008        ];
14009        let top = polygon_line_intersection(&tri, 10.0, 1.0);
14010        let bot = polygon_line_intersection(&tri, 80.0, 1.0);
14011        assert_eq!(top.len(), 1);
14012        assert_eq!(bot.len(), 1);
14013        assert!(
14014            top[0].width > bot[0].width,
14015            "the band must narrow with y ({} !> {})",
14016            top[0].width,
14017            bot[0].width
14018        );
14019        assert!((top[0].width - 89.5).abs() < 1.0);
14020    }
14021
14022    #[test]
14023    fn polygon_line_intersection_outside_the_shape_and_on_nan_scanlines_is_empty() {
14024        let tri = [
14025            Point { x: 0.0, y: 0.0 },
14026            Point { x: 100.0, y: 0.0 },
14027            Point { x: 0.0, y: 100.0 },
14028        ];
14029        assert!(
14030            polygon_line_intersection(&tri, 500.0, 1.0).is_empty(),
14031            "a scanline below the shape yields no spans"
14032        );
14033        assert!(
14034            polygon_line_intersection(&tri, f32::NAN, 1.0).is_empty(),
14035            "a NaN scanline must not panic — every crossing test is false"
14036        );
14037        assert!(polygon_line_intersection(&tri, f32::INFINITY, 1.0).is_empty());
14038    }
14039
14040    #[test]
14041    fn polygon_line_intersection_of_a_degenerate_flat_polygon_is_empty() {
14042        // All edges horizontal → every edge is skipped.
14043        let flat = [
14044            Point { x: 0.0, y: 5.0 },
14045            Point { x: 10.0, y: 5.0 },
14046            Point { x: 20.0, y: 5.0 },
14047        ];
14048        assert!(polygon_line_intersection(&flat, 4.5, 1.0).is_empty());
14049    }
14050
14051    #[test]
14052    fn path_segments_line_intersection_on_empty_and_degenerate_input() {
14053        assert!(path_segments_line_intersection(&[], 0.0, 1.0).is_empty());
14054        // A lone MoveTo cannot form a subpath.
14055        assert!(path_segments_line_intersection(
14056            &[PathSegment::MoveTo(Point { x: 0.0, y: 0.0 })],
14057            0.0,
14058            1.0
14059        )
14060        .is_empty());
14061    }
14062
14063    #[test]
14064    fn path_segments_line_intersection_of_a_square() {
14065        let sq = vec![
14066            PathSegment::MoveTo(Point { x: 0.0, y: 0.0 }),
14067            PathSegment::LineTo(Point { x: 100.0, y: 0.0 }),
14068            PathSegment::LineTo(Point {
14069                x: 100.0,
14070                y: 100.0,
14071            }),
14072            PathSegment::LineTo(Point { x: 0.0, y: 100.0 }),
14073            PathSegment::Close,
14074        ];
14075        let spans = path_segments_line_intersection(&sq, 50.0, 1.0);
14076        assert_eq!(spans.len(), 1);
14077        assert!((spans[0].0 - 0.0).abs() < 0.01);
14078        assert!((spans[0].1 - 100.0).abs() < 0.01);
14079        // Outside the square vertically → nothing.
14080        assert!(path_segments_line_intersection(&sq, 500.0, 1.0).is_empty());
14081    }
14082
14083    #[test]
14084    fn get_shape_horizontal_spans_rectangle_only_when_the_line_box_overlaps() {
14085        let r = ShapeBoundary::Rectangle(Rect {
14086            x: 10.0,
14087            y: 20.0,
14088            width: 30.0,
14089            height: 40.0,
14090        });
14091        assert_eq!(get_shape_horizontal_spans(&r, 30.0, 10.0), vec![(10.0, 40.0)]);
14092        assert!(get_shape_horizontal_spans(&r, 0.0, 10.0).is_empty(), "above");
14093        assert!(get_shape_horizontal_spans(&r, 100.0, 10.0).is_empty(), "below");
14094        // Exactly touching the top edge: line [10,20) vs rect [20,60) → no overlap.
14095        assert!(get_shape_horizontal_spans(&r, 10.0, 10.0).is_empty());
14096        // A zero-height rect can never overlap.
14097        let flat = ShapeBoundary::Rectangle(Rect {
14098            x: 0.0,
14099            y: 0.0,
14100            width: 10.0,
14101            height: 0.0,
14102        });
14103        assert!(get_shape_horizontal_spans(&flat, 0.0, 10.0).is_empty());
14104    }
14105
14106    #[test]
14107    fn get_shape_horizontal_spans_circle_edges_and_zero_radius() {
14108        let c = ShapeBoundary::Circle {
14109            center: Point { x: 50.0, y: 50.0 },
14110            radius: 10.0,
14111        };
14112        let mid = get_shape_horizontal_spans(&c, 49.5, 1.0); // line centre == 50.0
14113        assert_eq!(mid.len(), 1);
14114        assert!((mid[0].0 - 40.0).abs() < 0.01);
14115        assert!((mid[0].1 - 60.0).abs() < 0.01);
14116        assert!(get_shape_horizontal_spans(&c, 1000.0, 1.0).is_empty());
14117
14118        // Zero radius: the scanline exactly through the centre yields a zero-width span.
14119        let dot = ShapeBoundary::Circle {
14120            center: Point { x: 5.0, y: 5.0 },
14121            radius: 0.0,
14122        };
14123        let spans = get_shape_horizontal_spans(&dot, 4.5, 1.0);
14124        assert_eq!(spans, vec![(5.0, 5.0)], "degenerate but not a panic");
14125    }
14126
14127    #[test]
14128    fn get_shape_horizontal_spans_ellipse_with_zero_radii_is_empty_not_a_panic() {
14129        // radii.height == 0 → dy/0 → NaN → `NaN.abs() <= 0.0` is false → no spans.
14130        let e = ShapeBoundary::Ellipse {
14131            center: Point { x: 0.0, y: 0.0 },
14132            radii: Size::zero(),
14133        };
14134        assert!(
14135            get_shape_horizontal_spans(&e, 0.0, 1.0).is_empty(),
14136            "a zero-sized ellipse divides by zero but must not panic"
14137        );
14138    }
14139
14140    #[test]
14141    fn get_shape_horizontal_spans_polygon_delegates_to_the_scanline() {
14142        let p = ShapeBoundary::Polygon {
14143            points: vec![
14144                Point { x: 0.0, y: 0.0 },
14145                Point { x: 100.0, y: 0.0 },
14146                Point { x: 0.0, y: 100.0 },
14147            ],
14148        };
14149        let spans = get_shape_horizontal_spans(&p, 10.0, 1.0);
14150        assert_eq!(spans.len(), 1);
14151        assert!(spans[0].1 > spans[0].0);
14152    }
14153
14154    // =====================================================================
14155    // numeric/other: extract_line_breaks + try_incremental_relayout
14156    // =====================================================================
14157
14158    #[test]
14159    fn extract_line_breaks_of_no_items_is_empty_but_keeps_the_width() {
14160        let lb = extract_line_breaks(&[], 640.0);
14161        assert!(lb.line_ranges.is_empty());
14162        assert!(lb.line_widths.is_empty());
14163        assert_eq!(lb.available_width, 640.0);
14164        // Even a NaN constraint round-trips untouched.
14165        let nan = extract_line_breaks(&[], f32::NAN);
14166        assert!(nan.available_width.is_nan());
14167    }
14168
14169    #[test]
14170    fn extract_line_breaks_groups_items_by_line_index() {
14171        let items = vec![
14172            pos(cl("a", 10.0), 0.0, 0.0, 0),
14173            pos(cl("b", 10.0), 10.0, 0.0, 0),
14174            pos(cl("c", 10.0), 0.0, 20.0, 1),
14175        ];
14176        let lb = extract_line_breaks(&items, 100.0);
14177        assert_eq!(lb.line_ranges, vec![(0, 2), (2, 3)]);
14178        assert_eq!(lb.line_widths, vec![20.0, 10.0]);
14179        assert_eq!(lb.line_ranges.len(), lb.line_widths.len());
14180    }
14181
14182    #[test]
14183    fn extract_line_breaks_splits_on_every_line_index_change_even_going_backwards() {
14184        // The scanner is purely edge-triggered: a non-monotonic line_index sequence
14185        // produces THREE ranges, not two. Pinned so a reorder-tolerant rewrite is visible.
14186        let items = vec![
14187            pos(cl("a", 10.0), 0.0, 0.0, 0),
14188            pos(cl("b", 10.0), 0.0, 20.0, 1),
14189            pos(cl("c", 10.0), 0.0, 0.0, 0),
14190        ];
14191        let lb = extract_line_breaks(&items, 100.0);
14192        assert_eq!(lb.line_ranges.len(), 3);
14193        assert_eq!(lb.line_widths, vec![10.0, 10.0, 10.0]);
14194    }
14195
14196    #[test]
14197    fn try_incremental_relayout_no_dirty_items_is_a_glyph_swap() {
14198        let lb = CachedLineBreaks {
14199            line_ranges: vec![(0, 2)],
14200            line_widths: vec![20.0],
14201            available_width: 100.0,
14202        };
14203        assert!(matches!(
14204            try_incremental_relayout(&[], &[10.0, 10.0], &[10.0, 10.0], &lb),
14205            IncrementalRelayoutResult::GlyphSwap
14206        ));
14207    }
14208
14209    #[test]
14210    fn try_incremental_relayout_out_of_range_dirty_index_falls_back_to_full() {
14211        let lb = CachedLineBreaks {
14212            line_ranges: vec![(0, 2)],
14213            line_widths: vec![20.0],
14214            available_width: 100.0,
14215        };
14216        assert!(matches!(
14217            try_incremental_relayout(&[99], &[10.0, 10.0], &[10.0, 10.0], &lb),
14218            IncrementalRelayoutResult::FullRelayout
14219        ));
14220        assert!(
14221            matches!(
14222                try_incremental_relayout(&[usize::MAX], &[10.0], &[10.0], &lb),
14223                IncrementalRelayoutResult::FullRelayout
14224            ),
14225            "usize::MAX must not index-panic"
14226        );
14227        // Mismatched advance vectors are also caught by the bounds check.
14228        assert!(matches!(
14229            try_incremental_relayout(&[1], &[10.0, 10.0], &[10.0], &lb),
14230            IncrementalRelayoutResult::FullRelayout
14231        ));
14232    }
14233
14234    #[test]
14235    fn try_incremental_relayout_same_width_is_a_glyph_swap() {
14236        let lb = CachedLineBreaks {
14237            line_ranges: vec![(0, 2)],
14238            line_widths: vec![20.0],
14239            available_width: 100.0,
14240        };
14241        // Below the 0.001 epsilon → treated as unchanged.
14242        assert!(matches!(
14243            try_incremental_relayout(&[0], &[10.0, 10.0], &[10.0005, 10.0], &lb),
14244            IncrementalRelayoutResult::GlyphSwap
14245        ));
14246    }
14247
14248    #[test]
14249    fn try_incremental_relayout_shifts_when_it_still_fits_and_reflows_when_it_does_not() {
14250        let lb = CachedLineBreaks {
14251            line_ranges: vec![(0, 2), (2, 4)],
14252            line_widths: vec![20.0, 20.0],
14253            available_width: 100.0,
14254        };
14255        let old = [10.0, 10.0, 10.0, 10.0];
14256
14257        let grew = [10.0, 30.0, 10.0, 10.0]; // line 0 → 40 ≤ 100
14258        match try_incremental_relayout(&[1], &old, &grew, &lb) {
14259            IncrementalRelayoutResult::LineShift {
14260                affected_item,
14261                delta,
14262            } => {
14263                assert_eq!(affected_item, 1);
14264                assert_eq!(delta, 20.0);
14265            }
14266            other => panic!("expected LineShift, got {other:?}"),
14267        }
14268
14269        let exploded = [10.0, 10.0, 10.0, 500.0]; // line 1 → 510 > 100
14270        match try_incremental_relayout(&[3], &old, &exploded, &lb) {
14271            IncrementalRelayoutResult::PartialReflow { reflow_from_line } => {
14272                assert_eq!(reflow_from_line, 1);
14273            }
14274            other => panic!("expected PartialReflow, got {other:?}"),
14275        }
14276    }
14277
14278    #[test]
14279    fn try_incremental_relayout_dirty_item_outside_every_line_range_is_a_full_relayout() {
14280        let lb = CachedLineBreaks {
14281            line_ranges: vec![(0, 1)],
14282            line_widths: vec![10.0],
14283            available_width: 100.0,
14284        };
14285        // Item 1 exists in the advance arrays but is on no known line.
14286        assert!(matches!(
14287            try_incremental_relayout(&[1], &[10.0, 10.0], &[10.0, 50.0], &lb),
14288            IncrementalRelayoutResult::FullRelayout
14289        ));
14290    }
14291
14292    #[test]
14293    fn try_incremental_relayout_with_nan_advances_reflows_rather_than_shifting() {
14294        let lb = CachedLineBreaks {
14295            line_ranges: vec![(0, 1)],
14296            line_widths: vec![10.0],
14297            available_width: 100.0,
14298        };
14299        // delta = NaN: `NaN.abs() < 0.001` is false, and `NaN <= width` is false,
14300        // so we land in PartialReflow — a defined outcome, not a panic.
14301        match try_incremental_relayout(&[0], &[10.0], &[f32::NAN], &lb) {
14302            IncrementalRelayoutResult::PartialReflow { reflow_from_line } => {
14303                assert_eq!(reflow_from_line, 0);
14304            }
14305            other => panic!("NaN advance should reflow, got {other:?}"),
14306        }
14307    }
14308
14309    // =====================================================================
14310    // getter/other: TextShapingCache + TextCacheMemoryReport + calculate_id
14311    // =====================================================================
14312
14313    #[test]
14314    fn text_cache_memory_report_total_bytes_sums_only_the_byte_fields() {
14315        let r = TextCacheMemoryReport::default();
14316        assert_eq!(r.total_bytes(), 0);
14317
14318        let full = TextCacheMemoryReport {
14319            logical_items_entries: 1_000_000, // must NOT be counted
14320            logical_items_bytes: 1,
14321            visual_items_entries: 1_000_000, // must NOT be counted
14322            visual_items_bytes: 2,
14323            shaped_items_entries: 1_000_000, // must NOT be counted
14324            shaped_items_bytes: 4,
14325            shaped_glyph_bytes: 8,
14326            shaped_cluster_text_bytes: 16,
14327            per_item_shaped_entries: 1_000_000, // must NOT be counted
14328            per_item_shaped_bytes: 32,
14329        };
14330        assert_eq!(full.total_bytes(), 63, "1+2+4+8+16+32");
14331    }
14332
14333    #[test]
14334    fn text_shaping_cache_new_is_empty_and_reports_zero_bytes() {
14335        let c = TextShapingCache::new();
14336        let r = c.memory_report();
14337        assert_eq!(r.total_bytes(), 0);
14338        assert_eq!(r.logical_items_entries, 0);
14339        assert_eq!(r.per_item_shaped_entries, 0);
14340        assert_eq!(c.generation, 0);
14341        // Default must agree with new().
14342        let d = TextShapingCache::default();
14343        assert_eq!(d.memory_report().total_bytes(), 0);
14344    }
14345
14346    #[test]
14347    fn text_shaping_cache_begin_generation_is_idempotent_on_an_empty_cache() {
14348        let mut c = TextShapingCache::new();
14349        for expect in 1..=5_u64 {
14350            c.begin_generation();
14351            assert_eq!(c.generation, expect);
14352        }
14353        assert!(c.per_item_accessed.is_empty());
14354        assert!(c.per_item_shaped.is_empty());
14355    }
14356
14357    #[test]
14358    fn text_shaping_cache_begin_generation_evicts_unaccessed_per_item_entries() {
14359        let mut c = TextShapingCache::new();
14360        c.per_item_shaped.insert(
14361            1,
14362            Arc::new(PerItemShapedEntry {
14363                clusters: vec![cl("a", 8.0)],
14364                total_advance: 8.0,
14365            }),
14366        );
14367        c.per_item_shaped.insert(
14368            2,
14369            Arc::new(PerItemShapedEntry {
14370                clusters: Vec::new(),
14371                total_advance: 0.0,
14372            }),
14373        );
14374        // Generation 0 → the eviction guard is skipped entirely.
14375        c.begin_generation();
14376        assert_eq!(c.per_item_shaped.len(), 2, "gen 0 never evicts");
14377
14378        // Touch only key 1, then roll the generation: key 2 must be dropped.
14379        c.per_item_accessed.insert(1);
14380        c.begin_generation();
14381        assert_eq!(c.per_item_shaped.len(), 1);
14382        assert!(c.per_item_shaped.contains_key(&1));
14383
14384        // Nothing accessed this generation → the retain is skipped (NOT a full flush).
14385        c.begin_generation();
14386        assert_eq!(
14387            c.per_item_shaped.len(),
14388            1,
14389            "an empty access-set must not wipe the cache"
14390        );
14391    }
14392
14393    #[test]
14394    fn use_old_layout_accepts_a_render_only_change_and_rejects_layout_changes() {
14395        let c = UnifiedConstraints::default();
14396        let red = styled(|s| {
14397            s.color = ColorU {
14398                r: 255,
14399                g: 0,
14400                b: 0,
14401                a: 255,
14402            };
14403        });
14404        let old = [text_content("hi", style())];
14405        let new_colour = [text_content("hi", red)];
14406        assert!(
14407            TextShapingCache::use_old_layout(&c, &c, &old, &new_colour),
14408            "a colour-only change must reuse the cached layout"
14409        );
14410
14411        // Different text → no reuse.
14412        let new_text = [text_content("ho", style())];
14413        assert!(!TextShapingCache::use_old_layout(&c, &c, &old, &new_text));
14414
14415        // Different font size → no reuse.
14416        let bigger = [text_content("hi", styled(|s| s.font_size_px = 32.0))];
14417        assert!(!TextShapingCache::use_old_layout(&c, &c, &old, &bigger));
14418
14419        // Different constraints → no reuse.
14420        let c2 = UnifiedConstraints {
14421            available_width: AvailableSpace::Definite(100.0),
14422            ..Default::default()
14423        };
14424        assert!(!TextShapingCache::use_old_layout(&c, &c2, &old, &old));
14425    }
14426
14427    #[test]
14428    fn use_old_layout_on_empty_content_and_length_or_variant_mismatch() {
14429        let c = UnifiedConstraints::default();
14430        assert!(
14431            TextShapingCache::use_old_layout(&c, &c, &[], &[]),
14432            "empty vs empty is trivially reusable"
14433        );
14434        let one = [text_content("a", style())];
14435        assert!(!TextShapingCache::use_old_layout(&c, &c, &[], &one));
14436        assert!(!TextShapingCache::use_old_layout(&c, &c, &one, &[]));
14437
14438        // Same length, different variant.
14439        let space = [InlineContent::Space(InlineSpace {
14440            width: 4.0,
14441            is_breaking: true,
14442            is_stretchy: true,
14443        })];
14444        assert!(!TextShapingCache::use_old_layout(&c, &c, &one, &space));
14445        assert!(TextShapingCache::use_old_layout(&c, &c, &space, &space));
14446    }
14447
14448    #[test]
14449    fn inline_content_layout_eq_recurses_into_ruby() {
14450        let ruby = |base: &str| InlineContent::Ruby {
14451            base: vec![text_content(base, style())],
14452            text: vec![text_content("ふり", style())],
14453            style: style(),
14454        };
14455        assert!(TextShapingCache::inline_content_layout_eq(
14456            &ruby("漢"),
14457            &ruby("漢")
14458        ));
14459        assert!(!TextShapingCache::inline_content_layout_eq(
14460            &ruby("漢"),
14461            &ruby("字")
14462        ));
14463    }
14464
14465    #[test]
14466    fn calculate_id_is_deterministic_and_discriminating() {
14467        assert_eq!(calculate_id(&"abc"), calculate_id(&"abc"));
14468        assert_ne!(calculate_id(&"abc"), calculate_id(&"abd"));
14469        assert_eq!(calculate_id(&0_u64), calculate_id(&0_u64));
14470        assert_ne!(calculate_id(&0_u64), calculate_id(&u64::MAX));
14471        // Empty input must still produce a stable id (not a panic / not zero-by-accident).
14472        let e: Vec<u8> = Vec::new();
14473        assert_eq!(calculate_id(&e), calculate_id(&Vec::<u8>::new()));
14474    }
14475
14476    #[test]
14477    fn shaped_items_key_new_on_empty_visual_items_is_stable() {
14478        let a = ShapedItemsKey::new(7, &[]);
14479        let b = ShapedItemsKey::new(7, &[]);
14480        assert_eq!(a, b);
14481        assert_eq!(hash_of(&a), hash_of(&b));
14482        // The cache id participates in identity.
14483        assert_ne!(a, ShapedItemsKey::new(8, &[]));
14484    }
14485
14486    #[test]
14487    fn shaped_items_key_new_hashes_the_text_styles() {
14488        let vi = |st: Arc<StyleProperties>| VisualItem {
14489            logical_source: LogicalItem::Text {
14490                source: ci(0, 0),
14491                text: "a".to_string(),
14492                style: st,
14493                marker_position_outside: None,
14494                source_node_id: None,
14495            },
14496            bidi_level: BidiLevel::new(0),
14497            script: Script::Latin,
14498            text: "a".to_string(),
14499            run_byte_offset: 0,
14500        };
14501        let base = ShapedItemsKey::new(1, &[vi(style())]);
14502        let same = ShapedItemsKey::new(1, &[vi(style())]);
14503        let other = ShapedItemsKey::new(1, &[vi(styled(|s| s.font_size_px = 32.0))]);
14504        assert_eq!(base, same);
14505        assert_ne!(base.style_hash, other.style_hash, "font size must change the key");
14506    }
14507
14508    // =====================================================================
14509    // getter/predicate: OverflowInfo + UnifiedLayout
14510    // =====================================================================
14511
14512    #[test]
14513    fn overflow_info_default_has_no_overflow() {
14514        let o = OverflowInfo::default();
14515        assert!(!o.has_overflow());
14516        assert_eq!(o.unclipped_bounds, Rect::default());
14517
14518        let with = OverflowInfo {
14519            overflow_items: vec![cl("a", 8.0)],
14520            unclipped_bounds: Rect::default(),
14521        };
14522        assert!(with.has_overflow());
14523    }
14524
14525    fn layout_of(items: Vec<PositionedItem>) -> UnifiedLayout {
14526        UnifiedLayout {
14527            items,
14528            overflow: OverflowInfo::default(),
14529        }
14530    }
14531
14532    #[test]
14533    fn unified_layout_empty_is_inert_across_every_accessor() {
14534        let l = layout_of(Vec::new());
14535        assert!(l.is_empty());
14536        assert_eq!(l.bounds(), Rect::default());
14537        assert_eq!(l.first_baseline(), None);
14538        assert_eq!(l.last_baseline(), None);
14539        assert_eq!(l.get_first_cluster_cursor(), None);
14540        assert_eq!(l.get_last_cluster_cursor(), None);
14541        assert!(l.grapheme_stops().is_empty());
14542        assert_eq!(
14543            l.hittest_cursor(LogicalPosition { x: 0.0, y: 0.0 }),
14544            None,
14545            "hit-testing an empty layout must return None, not index [0]"
14546        );
14547        assert_eq!(
14548            l.hittest_cursor(LogicalPosition {
14549                x: f32::NAN,
14550                y: f32::NAN
14551            }),
14552            None
14553        );
14554    }
14555
14556    #[test]
14557    fn unified_layout_bounds_spans_all_items() {
14558        let l = layout_of(vec![
14559            pos(cl("a", 10.0), 0.0, 0.0, 0),
14560            pos(cl("b", 10.0), 90.0, 20.0, 1),
14561        ]);
14562        let b = l.bounds();
14563        assert_eq!(b.x, 0.0);
14564        assert_eq!(b.y, 0.0);
14565        assert_eq!(b.width, 100.0, "0 → 90+10");
14566        approx(b.height, 36.0); // 0 → 20 + the 16px line box
14567        assert!(!l.is_empty());
14568    }
14569
14570    #[test]
14571    fn unified_layout_baselines_skip_breaks_and_tabs() {
14572        let l = layout_of(vec![
14573            pos(brk(), 0.0, 0.0, 0),
14574            pos(cl("a", 10.0), 0.0, 0.0, 0),
14575            pos(obj(10.0, 20.0, 5.0), 10.0, 0.0, 0),
14576            pos(tab(8.0, 16.0), 20.0, 0.0, 0),
14577        ]);
14578        approx(
14579            l.first_baseline().expect("the cluster, not the break"),
14580            12.8,
14581        );
14582        assert_eq!(l.last_baseline(), Some(5.0), "the object, not the tab");
14583    }
14584
14585    #[test]
14586    fn unified_layout_cluster_cursors_skip_non_clusters() {
14587        let l = layout_of(vec![
14588            pos(brk(), 0.0, 0.0, 0),
14589            pos(cl_at("a", 10.0, 0, 0), 0.0, 0.0, 0),
14590            pos(cl_at("b", 10.0, 0, 1), 10.0, 0.0, 0),
14591            pos(tab(8.0, 16.0), 20.0, 0.0, 0),
14592        ]);
14593        assert_eq!(
14594            l.get_first_cluster_cursor(),
14595            Some(TextCursor {
14596                cluster_id: gid(0, 0),
14597                affinity: CursorAffinity::Leading
14598            })
14599        );
14600        assert_eq!(
14601            l.get_last_cluster_cursor(),
14602            Some(TextCursor {
14603                cluster_id: gid(0, 1),
14604                affinity: CursorAffinity::Trailing
14605            })
14606        );
14607
14608        // A layout with no clusters at all has no cursors.
14609        let no_clusters = layout_of(vec![pos(brk(), 0.0, 0.0, 0)]);
14610        assert_eq!(no_clusters.get_first_cluster_cursor(), None);
14611        assert_eq!(no_clusters.get_last_cluster_cursor(), None);
14612    }
14613
14614    #[test]
14615    fn unified_layout_grapheme_stops_sorts_dedups_and_folds_combining_marks() {
14616        // Deliberately out of order, with a duplicate id and a combining mark.
14617        let l = layout_of(vec![
14618            pos(cl_at("b", 10.0, 0, 1), 10.0, 0.0, 0),
14619            pos(cl_at("a", 10.0, 0, 0), 0.0, 0.0, 0),
14620            pos(cl_at("a", 10.0, 0, 0), 0.0, 0.0, 0), // duplicate id
14621            pos(cl_at("\u{0301}", 0.0, 0, 2), 20.0, 0.0, 0), // combining acute
14622        ]);
14623        let stops = l.grapheme_stops();
14624        assert_eq!(
14625            stops,
14626            vec![gid(0, 0), gid(0, 1)],
14627            "sorted, de-duplicated, with the combining mark folded away"
14628        );
14629    }
14630
14631    #[test]
14632    fn unified_layout_cluster_is_grapheme_continuation() {
14633        assert!(UnifiedLayout::cluster_is_grapheme_continuation("\u{0301}"));
14634        assert!(UnifiedLayout::cluster_is_grapheme_continuation("\u{FE0F}"), "VS-16");
14635        assert!(!UnifiedLayout::cluster_is_grapheme_continuation("a"));
14636        assert!(!UnifiedLayout::cluster_is_grapheme_continuation("中"));
14637        assert!(
14638            !UnifiedLayout::cluster_is_grapheme_continuation(""),
14639            "an empty cluster must return false, not panic"
14640        );
14641    }
14642
14643    #[test]
14644    fn unified_layout_grapheme_caret_offset_maps_affinity_and_gaps() {
14645        let stops = [gid(0, 0), gid(0, 1), gid(0, 2)];
14646        assert_eq!(
14647            UnifiedLayout::grapheme_caret_offset(
14648                &stops,
14649                &TextCursor {
14650                    cluster_id: gid(0, 0),
14651                    affinity: CursorAffinity::Leading
14652                }
14653            ),
14654            Some(0)
14655        );
14656        assert_eq!(
14657            UnifiedLayout::grapheme_caret_offset(
14658                &stops,
14659                &TextCursor {
14660                    cluster_id: gid(0, 2),
14661                    affinity: CursorAffinity::Trailing
14662                }
14663            ),
14664            Some(3),
14665            "the document end is len, i.e. one past the last stop"
14666        );
14667        // A cursor addressing a folded mark snaps back to the preceding stop.
14668        assert_eq!(
14669            UnifiedLayout::grapheme_caret_offset(
14670                &stops,
14671                &TextCursor {
14672                    cluster_id: gid(0, 99),
14673                    affinity: CursorAffinity::Leading
14674                }
14675            ),
14676            Some(2)
14677        );
14678        // A cursor before every stop has no offset.
14679        assert_eq!(
14680            UnifiedLayout::grapheme_caret_offset(
14681                &[gid(5, 5)],
14682                &TextCursor {
14683                    cluster_id: gid(0, 0),
14684                    affinity: CursorAffinity::Leading
14685                }
14686            ),
14687            None
14688        );
14689        // Empty stop list → None, no panic.
14690        assert_eq!(
14691            UnifiedLayout::grapheme_caret_offset(
14692                &[],
14693                &TextCursor {
14694                    cluster_id: gid(0, 0),
14695                    affinity: CursorAffinity::Leading
14696                }
14697            ),
14698            None
14699        );
14700    }
14701
14702    #[test]
14703    fn unified_layout_cursor_from_grapheme_offset_clamps_past_the_end() {
14704        let stops = [gid(0, 0), gid(0, 1)];
14705        assert_eq!(
14706            UnifiedLayout::cursor_from_grapheme_offset(&stops, 0),
14707            TextCursor {
14708                cluster_id: gid(0, 0),
14709                affinity: CursorAffinity::Leading
14710            }
14711        );
14712        assert_eq!(
14713            UnifiedLayout::cursor_from_grapheme_offset(&stops, 1),
14714            TextCursor {
14715                cluster_id: gid(0, 1),
14716                affinity: CursorAffinity::Leading
14717            }
14718        );
14719        // offset == len and anything beyond clamp to Trailing-on-last.
14720        let end = TextCursor {
14721            cluster_id: gid(0, 1),
14722            affinity: CursorAffinity::Trailing,
14723        };
14724        assert_eq!(UnifiedLayout::cursor_from_grapheme_offset(&stops, 2), end);
14725        assert_eq!(
14726            UnifiedLayout::cursor_from_grapheme_offset(&stops, usize::MAX),
14727            end,
14728            "usize::MAX must clamp, not overflow"
14729        );
14730    }
14731
14732    #[test]
14733    #[should_panic]
14734    fn unified_layout_cursor_from_grapheme_offset_panics_on_an_empty_stop_list() {
14735        // FINDING: with `stops == []`, `offset >= n` is true for offset 0 and the
14736        // function indexes `stops[n - 1]` → `0usize - 1`. Every public caller happens
14737        // to guard `stops.is_empty()` first, so this is latent, not live — but the
14738        // helper itself has no guard.
14739        let _ = UnifiedLayout::cursor_from_grapheme_offset(&[], 0);
14740    }
14741
14742    #[test]
14743    fn unified_layout_cursor_motion_on_an_empty_layout_returns_the_cursor_unchanged() {
14744        let l = layout_of(Vec::new());
14745        let c = TextCursor {
14746            cluster_id: gid(0, 0),
14747            affinity: CursorAffinity::Leading,
14748        };
14749        let mut dbg = None;
14750        assert_eq!(l.move_cursor_left(c, &mut dbg), c);
14751        assert_eq!(l.move_cursor_right(c, &mut dbg), c);
14752        assert_eq!(l.move_cursor_to_line_start(c, &mut dbg), c);
14753        assert_eq!(l.move_cursor_to_line_end(c, &mut dbg), c);
14754        assert_eq!(l.move_cursor_to_prev_word(c, &mut dbg), c);
14755        assert_eq!(l.move_cursor_to_next_word(c, &mut dbg), c);
14756        let mut goal = None;
14757        assert_eq!(l.move_cursor_up(c, &mut goal, &mut dbg), c);
14758        assert_eq!(l.move_cursor_down(c, &mut goal, &mut dbg), c);
14759    }
14760
14761    #[test]
14762    fn unified_layout_move_cursor_left_right_walk_one_grapheme_at_a_time() {
14763        let l = layout_of(vec![
14764            pos(cl_at("a", 10.0, 0, 0), 0.0, 0.0, 0),
14765            pos(cl_at("b", 10.0, 0, 1), 10.0, 0.0, 0),
14766            pos(cl_at("c", 10.0, 0, 2), 20.0, 0.0, 0),
14767        ]);
14768        let mut dbg = None;
14769        let start = TextCursor {
14770            cluster_id: gid(0, 0),
14771            affinity: CursorAffinity::Leading,
14772        };
14773
14774        // Left at the document start is a fixed point (saturating_sub).
14775        assert_eq!(l.move_cursor_left(start, &mut dbg), start);
14776
14777        // Right advances one stop at a time and reaches the document end.
14778        let c1 = l.move_cursor_right(start, &mut dbg);
14779        assert_eq!(c1.cluster_id, gid(0, 1));
14780        let c2 = l.move_cursor_right(c1, &mut dbg);
14781        assert_eq!(c2.cluster_id, gid(0, 2));
14782        let end = l.move_cursor_right(c2, &mut dbg);
14783        assert_eq!(end.cluster_id, gid(0, 2));
14784        assert_eq!(end.affinity, CursorAffinity::Trailing, "document end");
14785        // ...and is a fixed point there.
14786        assert_eq!(l.move_cursor_right(end, &mut dbg), end);
14787
14788        // Left from the end walks back symmetrically.
14789        assert_eq!(l.move_cursor_left(end, &mut dbg).cluster_id, gid(0, 2));
14790    }
14791
14792    #[test]
14793    fn unified_layout_hittest_cursor_picks_the_nearest_cluster_and_its_half() {
14794        let l = layout_of(vec![
14795            pos(cl_at("a", 10.0, 0, 0), 0.0, 0.0, 0),
14796            pos(cl_at("b", 10.0, 0, 1), 10.0, 0.0, 0),
14797        ]);
14798        let hit = |x: f32| l.hittest_cursor(LogicalPosition { x, y: 5.0 }).unwrap();
14799        assert_eq!(hit(1.0).cluster_id, gid(0, 0));
14800        assert_eq!(hit(1.0).affinity, CursorAffinity::Leading);
14801        assert_eq!(hit(9.0).affinity, CursorAffinity::Trailing, "right half of 'a'");
14802        assert_eq!(hit(11.0).cluster_id, gid(0, 1));
14803        // Far outside the layout still resolves to the nearest cluster, not None.
14804        assert_eq!(hit(-1000.0).cluster_id, gid(0, 0));
14805        assert_eq!(hit(1000.0).cluster_id, gid(0, 1));
14806    }
14807
14808    #[test]
14809    fn unified_layout_get_selection_rects_on_an_unknown_range_is_empty() {
14810        let l = layout_of(vec![pos(cl_at("a", 10.0, 0, 0), 0.0, 0.0, 0)]);
14811        let unknown = SelectionRange {
14812            start: TextCursor {
14813                cluster_id: gid(9, 9),
14814                affinity: CursorAffinity::Leading,
14815            },
14816            end: TextCursor {
14817                cluster_id: gid(9, 9),
14818                affinity: CursorAffinity::Trailing,
14819            },
14820        };
14821        assert!(l.get_selection_rects(&unknown).is_empty());
14822
14823        // A degenerate (collapsed) range over a real cluster must not panic.
14824        let collapsed = SelectionRange {
14825            start: TextCursor {
14826                cluster_id: gid(0, 0),
14827                affinity: CursorAffinity::Leading,
14828            },
14829            end: TextCursor {
14830                cluster_id: gid(0, 0),
14831                affinity: CursorAffinity::Leading,
14832            },
14833        };
14834        let _ = l.get_selection_rects(&collapsed);
14835    }
14836
14837    #[test]
14838    fn unified_layout_get_cursor_rect_for_known_and_unknown_cursors() {
14839        let l = layout_of(vec![pos(cl_at("a", 10.0, 0, 0), 5.0, 7.0, 0)]);
14840        let leading = l.get_cursor_rect(&TextCursor {
14841            cluster_id: gid(0, 0),
14842            affinity: CursorAffinity::Leading,
14843        });
14844        let r = leading.expect("the leading edge of a placed cluster must have a rect");
14845        assert_eq!(r.origin.x, 5.0);
14846        assert_eq!(r.origin.y, 7.0);
14847        assert_eq!(r.size.width, 1.0, "the caret is a 1px sliver");
14848
14849        // A cursor in a run that was never laid out has no rect.
14850        assert_eq!(
14851            l.get_cursor_rect(&TextCursor {
14852                cluster_id: gid(9, 0),
14853                affinity: CursorAffinity::Leading
14854            }),
14855            None
14856        );
14857        // An empty layout has no rect for anything.
14858        assert_eq!(
14859            layout_of(Vec::new()).get_cursor_rect(&TextCursor {
14860                cluster_id: gid(0, 0),
14861                affinity: CursorAffinity::Leading
14862            }),
14863            None
14864        );
14865    }
14866
14867    // =====================================================================
14868    // constructor/getter: BreakCursor
14869    // =====================================================================
14870
14871    #[test]
14872    fn break_cursor_new_on_an_empty_slice_is_both_at_start_and_done() {
14873        let items: Vec<ShapedItem> = Vec::new();
14874        let mut c = BreakCursor::new(&items);
14875        assert!(c.is_at_start());
14876        assert!(c.is_done());
14877        assert_eq!(c.word_break, WordBreak::Normal);
14878        assert_eq!(c.hyphens, Hyphens::default());
14879        assert_eq!(c.line_break, LineBreakStrictness::default());
14880        assert!(c.peek_next_unit().is_empty());
14881        assert!(c.peek_next_single_item().is_empty());
14882        assert!(c.drain_remaining().is_empty());
14883    }
14884
14885    #[test]
14886    fn break_cursor_with_word_break_stores_the_mode() {
14887        let items = vec![cl("a", 8.0)];
14888        let c = BreakCursor::with_word_break(&items, WordBreak::BreakAll);
14889        assert_eq!(c.word_break, WordBreak::BreakAll);
14890        assert!(c.is_at_start());
14891        assert!(!c.is_done());
14892    }
14893
14894    #[test]
14895    fn break_cursor_consume_zero_is_a_no_op() {
14896        let items = vec![cl("a", 8.0), cl("b", 8.0)];
14897        let mut c = BreakCursor::new(&items);
14898        c.consume(0);
14899        assert!(c.is_at_start());
14900        assert_eq!(c.next_item_index, 0);
14901    }
14902
14903    #[test]
14904    fn break_cursor_consume_advances_and_ends_the_stream() {
14905        let items = vec![cl("a", 8.0), cl("b", 8.0)];
14906        let mut c = BreakCursor::new(&items);
14907        c.consume(1);
14908        assert!(!c.is_at_start());
14909        assert!(!c.is_done());
14910        assert_eq!(c.peek_next_single_item().len(), 1);
14911        c.consume(1);
14912        assert!(c.is_done());
14913        assert!(c.peek_next_single_item().is_empty());
14914    }
14915
14916    #[test]
14917    fn break_cursor_over_consuming_past_the_end_still_reports_done() {
14918        let items = vec![cl("a", 8.0)];
14919        let mut c = BreakCursor::new(&items);
14920        c.consume(usize::MAX);
14921        assert!(c.is_done(), "an over-consume must not wrap around to not-done");
14922        assert!(
14923            c.drain_remaining().is_empty(),
14924            "drain_remaining is bounds-guarded"
14925        );
14926    }
14927
14928    #[test]
14929    #[should_panic]
14930    fn break_cursor_peek_next_unit_after_over_consuming_slices_out_of_bounds() {
14931        // FINDING: `consume()` does not clamp `next_item_index` to `items.len()`, and
14932        // `peek_next_unit` slices `self.items[self.next_item_index..]` unguarded (unlike
14933        // `peek_next_single_item` / `drain_remaining`, which both test `<  len`). A caller
14934        // that over-consumes and then peeks gets a slice-index panic instead of an empty unit.
14935        let items = vec![cl("a", 8.0)];
14936        let mut c = BreakCursor::new(&items);
14937        c.consume(5);
14938        let _ = c.peek_next_unit();
14939    }
14940
14941    #[test]
14942    fn break_cursor_drains_the_remainder_before_the_main_list() {
14943        let items = vec![cl("a", 8.0), cl("b", 8.0)];
14944        let mut c = BreakCursor::new(&items);
14945        c.partial_remainder = vec![cl("R", 8.0)];
14946        assert!(!c.is_at_start(), "a pending remainder means we are mid-stream");
14947        assert!(!c.is_done());
14948
14949        assert_eq!(
14950            c.peek_next_single_item()[0].as_cluster().unwrap().text,
14951            "R",
14952            "the remainder is served first"
14953        );
14954
14955        let drained = c.drain_remaining();
14956        assert_eq!(drained.len(), 3, "remainder + both queued items");
14957        assert_eq!(drained[0].as_cluster().unwrap().text, "R");
14958        assert!(c.is_done());
14959    }
14960
14961    #[test]
14962    fn break_cursor_is_done_is_false_while_a_remainder_is_pending() {
14963        let items: Vec<ShapedItem> = Vec::new();
14964        let mut c = BreakCursor::new(&items);
14965        c.partial_remainder = vec![cl("x", 8.0)];
14966        assert!(!c.is_done(), "the main list is exhausted but the remainder is not");
14967        c.consume(1);
14968        assert!(c.is_done());
14969    }
14970
14971    #[test]
14972    fn break_cursor_consume_spanning_remainder_and_main_list() {
14973        let items = vec![cl("a", 8.0), cl("b", 8.0), cl("c", 8.0)];
14974        let mut c = BreakCursor::new(&items);
14975        c.partial_remainder = vec![cl("R1", 8.0), cl("R2", 8.0)];
14976        // Eat both remainder items + one from the main list.
14977        c.consume(3);
14978        assert!(c.partial_remainder.is_empty());
14979        assert_eq!(c.next_item_index, 1);
14980        assert_eq!(c.peek_next_single_item()[0].as_cluster().unwrap().text, "b");
14981    }
14982
14983    #[test]
14984    fn break_cursor_peek_next_unit_returns_a_whole_word_then_the_space() {
14985        let items = vec![
14986            cl("h", 8.0),
14987            cl("i", 8.0),
14988            cl(" ", 4.0),
14989            cl("y", 8.0),
14990            cl("o", 8.0),
14991        ];
14992        let mut c = BreakCursor::new(&items);
14993        let word = c.peek_next_unit();
14994        assert_eq!(word.len(), 2, "the word stops at the space");
14995        assert_eq!(word[0].as_cluster().unwrap().text, "h");
14996
14997        c.consume(word.len());
14998        let space = c.peek_next_unit();
14999        assert_eq!(space.len(), 1, "a leading break opportunity is a unit on its own");
15000        assert_eq!(space[0].as_cluster().unwrap().text, " ");
15001
15002        c.consume(1);
15003        assert_eq!(c.peek_next_unit().len(), 2, "the trailing word");
15004    }
15005
15006    #[test]
15007    fn break_cursor_peek_next_unit_honours_break_all_and_keep_all() {
15008        let items = vec![cl("中", 16.0), cl("文", 16.0), cl("字", 16.0)];
15009
15010        let normal = BreakCursor::new(&items);
15011        assert_eq!(
15012            normal.peek_next_unit().len(),
15013            1,
15014            "word-break:normal — each ideograph is its own unit"
15015        );
15016
15017        let all = BreakCursor::with_word_break(&items, WordBreak::BreakAll);
15018        assert_eq!(all.peek_next_unit().len(), 1, "break-all — one cluster per unit");
15019
15020        let keep = BreakCursor::with_word_break(&items, WordBreak::KeepAll);
15021        assert_eq!(
15022            keep.peek_next_unit().len(),
15023            3,
15024            "keep-all — the whole CJK run is unbreakable"
15025        );
15026
15027        let latin = vec![cl("a", 8.0), cl("b", 8.0)];
15028        let latin_all = BreakCursor::with_word_break(&latin, WordBreak::BreakAll);
15029        assert_eq!(latin_all.peek_next_unit().len(), 1, "break-all splits Latin too");
15030    }
15031
15032    #[test]
15033    fn break_cursor_peek_next_unit_glues_across_a_word_joiner() {
15034        // Control: without a joiner the unit ends at the space.
15035        let plain = vec![cl("a", 8.0), cl(" ", 4.0), cl("b", 8.0)];
15036        let control = BreakCursor::new(&plain);
15037        assert_eq!(
15038            control.peek_next_unit().len(),
15039            1,
15040            "the unit normally stops before the space"
15041        );
15042
15043        // With a WORD JOINER (U+2060) in between, the break after it is suppressed,
15044        // so the space is pulled into the same unbreakable unit.
15045        let glued = vec![
15046            cl("a", 8.0),
15047            cl("\u{2060}", 0.0),
15048            cl(" ", 4.0),
15049            cl("b", 8.0),
15050        ];
15051        let c = BreakCursor::new(&glued);
15052        let unit = c.peek_next_unit();
15053        assert!(
15054            unit.len() > 1,
15055            "a word joiner must suppress the following break, got {} item(s)",
15056            unit.len()
15057        );
15058    }
15059
15060    #[test]
15061    fn break_cursor_peek_next_single_item_prefers_the_remainder() {
15062        let items = vec![cl("a", 8.0)];
15063        let mut c = BreakCursor::new(&items);
15064        assert_eq!(c.peek_next_single_item()[0].as_cluster().unwrap().text, "a");
15065        c.partial_remainder = vec![cl("R", 8.0)];
15066        assert_eq!(c.peek_next_single_item()[0].as_cluster().unwrap().text, "R");
15067        assert_eq!(
15068            c.peek_next_single_item().len(),
15069            1,
15070            "peek must never return more than one item"
15071        );
15072    }
15073
15074    // =====================================================================
15075    // constructor/getter: LoadedFonts
15076    // =====================================================================
15077
15078    fn tf(hash: u64) -> TestFont {
15079        TestFont { hash }
15080    }
15081
15082    #[test]
15083    fn loaded_fonts_new_is_empty_and_misses_every_lookup() {
15084        let lf: LoadedFonts<TestFont> = LoadedFonts::new();
15085        assert!(lf.is_empty());
15086        assert_eq!(lf.len(), 0);
15087        assert_eq!(lf.iter().count(), 0);
15088        assert!(lf.get(&FontId(0)).is_none());
15089        assert!(!lf.contains_key(&FontId(u128::MAX)));
15090        // Hash lookups at the numeric boundaries must miss, not panic.
15091        for h in [0_u64, 1, u64::MAX] {
15092            assert!(lf.get_by_hash(h).is_none());
15093            assert!(lf.get_font_id_by_hash(h).is_none());
15094            assert!(!lf.contains_hash(h));
15095        }
15096        // Default agrees with new().
15097        let d: LoadedFonts<TestFont> = LoadedFonts::default();
15098        assert!(d.is_empty());
15099    }
15100
15101    #[test]
15102    fn loaded_fonts_insert_indexes_by_id_and_by_hash() {
15103        let mut lf: LoadedFonts<TestFont> = LoadedFonts::new();
15104        lf.insert(FontId(1), tf(0xDEAD));
15105        assert_eq!(lf.len(), 1);
15106        assert!(!lf.is_empty());
15107        assert!(lf.contains_key(&FontId(1)));
15108        assert!(lf.contains_hash(0xDEAD));
15109        assert_eq!(lf.get(&FontId(1)).map(TestFont::get_hash), Some(0xDEAD));
15110        assert_eq!(lf.get_by_hash(0xDEAD).map(TestFont::get_hash), Some(0xDEAD));
15111        assert_eq!(lf.get_font_id_by_hash(0xDEAD), Some(&FontId(1)));
15112        assert!(lf.get_by_hash(0).is_none());
15113    }
15114
15115    #[test]
15116    fn loaded_fonts_zero_hash_is_a_valid_key_not_a_sentinel() {
15117        let mut lf: LoadedFonts<TestFont> = LoadedFonts::new();
15118        lf.insert(FontId(1), tf(0));
15119        assert!(lf.contains_hash(0), "hash 0 must be storable and findable");
15120        assert_eq!(lf.get_by_hash(0).map(TestFont::get_hash), Some(0));
15121    }
15122
15123    #[test]
15124    fn loaded_fonts_two_ids_sharing_a_hash_keep_only_the_last_reverse_mapping() {
15125        let mut lf: LoadedFonts<TestFont> = LoadedFonts::new();
15126        lf.insert(FontId(1), tf(7));
15127        lf.insert(FontId(2), tf(7));
15128        assert_eq!(lf.len(), 2, "both fonts are stored by id");
15129        assert_eq!(
15130            lf.get_font_id_by_hash(7),
15131            Some(&FontId(2)),
15132            "the reverse index keeps only the LAST id for a colliding hash"
15133        );
15134    }
15135
15136    #[test]
15137    fn loaded_fonts_replacing_a_font_id_leaves_a_stale_hash_mapping() {
15138        // FINDING (staleness, not a crash): `insert` never removes the OLD hash of a
15139        // replaced FontId, so the reverse index keeps pointing at that id forever.
15140        // A by-hash lookup for the evicted font therefore succeeds and returns the
15141        // WRONG font instead of None.
15142        let mut lf: LoadedFonts<TestFont> = LoadedFonts::new();
15143        lf.insert(FontId(1), tf(100));
15144        lf.insert(FontId(1), tf(200)); // same id, new hash
15145        assert_eq!(lf.len(), 1, "the font map correctly holds one entry");
15146
15147        assert!(
15148            lf.contains_hash(100),
15149            "the old hash is still in the reverse index"
15150        );
15151        let stale = lf.get_by_hash(100).expect("stale mapping resolves");
15152        assert_eq!(
15153            stale.get_hash(),
15154            200,
15155            "looking up the OLD hash hands back the NEW font"
15156        );
15157    }
15158
15159    #[test]
15160    fn loaded_fonts_from_iterator_matches_repeated_inserts() {
15161        let lf: LoadedFonts<TestFont> =
15162            vec![(FontId(1), tf(10)), (FontId(2), tf(20))].into_iter().collect();
15163        assert_eq!(lf.len(), 2);
15164        assert!(lf.contains_hash(10) && lf.contains_hash(20));
15165        assert_eq!(lf.iter().count(), 2);
15166    }
15167
15168    // =====================================================================
15169    // constructor/other: FontManager + FontContext
15170    // =====================================================================
15171
15172    fn manager() -> FontManager<TestFont> {
15173        FontManager::new(FcFontCache::default()).expect("FontManager::new must not fail")
15174    }
15175
15176    #[test]
15177    fn font_manager_constructors_start_empty() {
15178        for m in [
15179            manager(),
15180            FontManager::from_shared(FcFontCache::default()).unwrap(),
15181            FontManager::from_arc_shared(
15182                FcFontCache::default(),
15183                Arc::new(Mutex::new(HashMap::new())),
15184            )
15185            .unwrap(),
15186        ] {
15187            assert!(m.get_font_chain_cache().is_empty());
15188            assert!(m.get_loaded_fonts().is_empty());
15189            assert!(m.get_loaded_font_ids().is_empty());
15190            assert!(m.registry.is_none());
15191            assert_eq!(m.last_resolved_font_stacks_sig, None);
15192            assert!(m.get_font_by_hash(0).is_none());
15193            assert!(m.get_embedded_font_by_hash(u64::MAX).is_none());
15194        }
15195    }
15196
15197    #[test]
15198    fn font_manager_from_arc_shared_sees_writes_through_the_shared_pool() {
15199        let pool: Arc<Mutex<HashMap<FontId, TestFont>>> = Arc::new(Mutex::new(HashMap::new()));
15200        let a = FontManager::from_arc_shared(FcFontCache::default(), pool.clone()).unwrap();
15201        let b = FontManager::from_arc_shared(FcFontCache::default(), pool).unwrap();
15202
15203        assert!(a.insert_font(FontId(1), tf(5)).is_none(), "no previous font");
15204        assert_eq!(
15205            b.get_loaded_fonts().len(),
15206            1,
15207            "the second manager must observe the first's insert"
15208        );
15209        assert_eq!(b.get_font_by_hash(5).map(|f| f.get_hash()), Some(5));
15210
15211        // shared_parsed_fonts hands back the same Arc.
15212        assert!(Arc::ptr_eq(&a.shared_parsed_fonts(), &b.shared_parsed_fonts()));
15213    }
15214
15215    #[test]
15216    fn font_manager_insert_font_returns_the_replaced_font() {
15217        let m = manager();
15218        assert!(m.insert_font(FontId(1), tf(1)).is_none());
15219        let old = m.insert_font(FontId(1), tf(2)).expect("must return the old font");
15220        assert_eq!(old.get_hash(), 1);
15221        assert_eq!(m.get_loaded_fonts().len(), 1);
15222    }
15223
15224    #[test]
15225    fn font_manager_insert_fonts_and_remove_font() {
15226        let m = manager();
15227        m.insert_fonts(vec![(FontId(1), tf(1)), (FontId(2), tf(2))]);
15228        assert_eq!(m.get_loaded_font_ids().len(), 2);
15229
15230        assert_eq!(m.remove_font(&FontId(1)).map(|f| f.get_hash()), Some(1));
15231        assert!(m.remove_font(&FontId(1)).is_none(), "double-remove is a no-op");
15232        assert!(
15233            m.remove_font(&FontId(u128::MAX)).is_none(),
15234            "removing an unknown id must not panic"
15235        );
15236        assert_eq!(m.get_loaded_fonts().len(), 1);
15237
15238        // Inserting an empty iterator is a no-op.
15239        m.insert_fonts(Vec::new());
15240        assert_eq!(m.get_loaded_fonts().len(), 1);
15241    }
15242
15243    #[test]
15244    fn font_manager_get_font_by_hash_scans_linearly_and_misses_cleanly() {
15245        let m = manager();
15246        m.insert_fonts(vec![(FontId(1), tf(11)), (FontId(2), tf(22))]);
15247        assert_eq!(m.get_font_by_hash(22).map(|f| f.get_hash()), Some(22));
15248        assert!(m.get_font_by_hash(33).is_none());
15249        assert!(m.get_font_by_hash(u64::MAX).is_none());
15250        assert!(m.get_font_by_hash(0).is_none());
15251    }
15252
15253    #[test]
15254    fn font_manager_chain_cache_set_merge_and_signature() {
15255        let mut m = manager();
15256        assert!(m.get_font_chain_cache().is_empty());
15257
15258        // set_font_chain_cache_with_sig records the signature...
15259        m.set_font_chain_cache_with_sig(HashMap::new(), Some(42));
15260        assert_eq!(m.last_resolved_font_stacks_sig, Some(42));
15261
15262        // ...and the single-arg setter clears it again.
15263        m.set_font_chain_cache(HashMap::new());
15264        assert_eq!(
15265            m.last_resolved_font_stacks_sig, None,
15266            "a signature-less set must invalidate the recorded signature"
15267        );
15268
15269        // merge on an empty cache is a no-op that does not panic.
15270        m.merge_font_chain_cache(HashMap::new());
15271        assert!(m.get_font_chain_cache().is_empty());
15272    }
15273
15274    #[test]
15275    fn font_manager_garbage_collect_evicts_everything_not_in_the_keep_set() {
15276        let mut m = manager();
15277        m.insert_fonts(vec![
15278            (FontId(1), tf(1)),
15279            (FontId(2), tf(2)),
15280            (FontId(3), tf(3)),
15281        ]);
15282
15283        let mut keep = HashSet::new();
15284        keep.insert(FontId(2));
15285        let evicted = m.garbage_collect_fonts(&keep, &HashSet::new());
15286        assert_eq!(evicted, 2);
15287        assert_eq!(m.get_loaded_font_ids(), keep);
15288
15289        // GC-ing again evicts nothing (saturating_sub must not underflow).
15290        assert_eq!(m.garbage_collect_fonts(&keep, &HashSet::new()), 0);
15291
15292        // An empty keep-set flushes the pool entirely.
15293        assert_eq!(m.garbage_collect_fonts(&HashSet::new(), &HashSet::new()), 1);
15294        assert!(m.get_loaded_fonts().is_empty());
15295        // ...and a GC on an already-empty pool is still 0, not a panic.
15296        assert_eq!(m.garbage_collect_fonts(&HashSet::new(), &HashSet::new()), 0);
15297    }
15298
15299    #[test]
15300    fn font_manager_load_missing_for_chains_with_no_chains_loads_nothing() {
15301        use crate::solver3::getters::ResolvedFontChains;
15302        let m = manager();
15303        let empty = ResolvedFontChains {
15304            chains: HashMap::new(),
15305            ..Default::default()
15306        };
15307        let failed = m.load_missing_for_chains(
15308            &empty,
15309            |_bytes, _idx| -> Result<TestFont, LayoutError> {
15310                panic!("the loader must never be invoked when there is nothing to load")
15311            },
15312        );
15313        assert!(failed.is_empty());
15314        assert!(m.get_loaded_fonts().is_empty());
15315    }
15316
15317    #[test]
15318    fn font_context_from_fc_cache_starts_empty_and_converts_to_a_manager() {
15319        let ctx = FontContext::from_fc_cache(FcFontCache::default());
15320        assert!(ctx.font_chain_cache.is_empty());
15321        assert!(ctx.embedded_fonts.is_empty());
15322        assert!(ctx.font_hash_to_families.is_empty());
15323        assert!(ctx.registry.is_none());
15324        assert!(ctx.parsed_fonts.lock().unwrap().is_empty());
15325
15326        // Warming an empty chain set must be a no-op (and must not hit the disk).
15327        ctx.load_fonts_for_chains();
15328        assert!(ctx.parsed_fonts.lock().unwrap().is_empty());
15329
15330        let mgr = ctx.to_font_manager();
15331        assert!(mgr.get_font_chain_cache().is_empty());
15332        assert!(mgr.registry.is_none());
15333        assert_eq!(mgr.last_resolved_font_stacks_sig, None);
15334        assert!(
15335            Arc::ptr_eq(&mgr.parsed_fonts, &ctx.parsed_fonts),
15336            "the manager must share (not copy) the parsed-font pool"
15337        );
15338    }
15339
15340    // =====================================================================
15341    // other: create_logical_items / bidi entry points
15342    // =====================================================================
15343
15344    #[test]
15345    fn create_logical_items_on_empty_and_whitespace_only_content() {
15346        let mut dbg = None;
15347        assert!(create_logical_items(&[], &[], &mut dbg).is_empty());
15348
15349        // An empty text run is skipped entirely.
15350        let empty_run = [text_content("", style())];
15351        assert!(create_logical_items(&empty_run, &[], &mut dbg).is_empty());
15352
15353        // Whitespace-only text still produces items.
15354        let ws = [text_content("   \t\n", style())];
15355        assert!(!create_logical_items(&ws, &[], &mut dbg).is_empty());
15356    }
15357
15358    #[test]
15359    fn create_logical_items_handles_multibyte_and_astral_text_without_panicking() {
15360        let mut dbg = None;
15361        for s in ["\u{1F600}", "é\u{0301}", "中文", "a\u{200B}b", "\u{FEFF}"] {
15362            let content = [text_content(s, style())];
15363            let items = create_logical_items(&content, &[], &mut dbg);
15364            assert!(!items.is_empty(), "{s:?} produced no logical items");
15365        }
15366    }
15367
15368    #[test]
15369    fn create_logical_items_on_a_long_run_does_not_hang() {
15370        let mut dbg = None;
15371        let long = "a".repeat(100_000);
15372        let content = [text_content(&long, style())];
15373        let items = create_logical_items(&content, &[], &mut dbg);
15374        assert!(!items.is_empty());
15375    }
15376
15377    #[test]
15378    fn create_logical_items_debug_messages_are_recorded_when_requested() {
15379        let mut dbg = Some(Vec::new());
15380        let content = [text_content("hi", style())];
15381        let _ = create_logical_items(&content, &[], &mut dbg);
15382        assert!(
15383            !dbg.expect("Some(..) in → Some(..) out").is_empty(),
15384            "the debug sink must be populated when it is Some"
15385        );
15386    }
15387
15388    #[test]
15389    fn get_base_direction_from_logical_defaults_to_ltr_on_empty_input() {
15390        assert_eq!(get_base_direction_from_logical(&[]), BidiDirection::Ltr);
15391
15392        let mut dbg = None;
15393        let ltr = create_logical_items(&[text_content("hello", style())], &[], &mut dbg);
15394        assert_eq!(get_base_direction_from_logical(&ltr), BidiDirection::Ltr);
15395
15396        // A Hebrew run must be detected as RTL.
15397        let rtl = create_logical_items(&[text_content("\u{05D0}\u{05D1}", style())], &[], &mut dbg);
15398        assert_eq!(get_base_direction_from_logical(&rtl), BidiDirection::Rtl);
15399    }
15400
15401    #[test]
15402    fn reorder_logical_items_on_empty_input_is_ok_and_empty() {
15403        let mut dbg = None;
15404        let out = reorder_logical_items(&[], BidiDirection::Ltr, UnicodeBidi::Normal, &mut dbg)
15405            .expect("reordering nothing must succeed");
15406        assert!(out.is_empty());
15407    }
15408
15409    #[test]
15410    fn reorder_logical_items_preserves_content_for_pure_ltr_text() {
15411        let mut dbg = None;
15412        let logical = create_logical_items(&[text_content("abc", style())], &[], &mut dbg);
15413        let visual = reorder_logical_items(
15414            &logical,
15415            BidiDirection::Ltr,
15416            UnicodeBidi::Normal,
15417            &mut dbg,
15418        )
15419        .expect("LTR reordering must succeed");
15420        let joined: String = visual.iter().map(|v| v.text.as_str()).collect();
15421        assert_eq!(joined, "abc", "pure LTR text must survive bidi unchanged");
15422        assert!(visual.iter().all(|v| !v.bidi_level.is_rtl()));
15423    }
15424
15425    // =====================================================================
15426    // other: hyphenation stubs (feature-gated)
15427    // =====================================================================
15428
15429    #[cfg(not(feature = "text_layout_hyphenation"))]
15430    #[test]
15431    fn stub_hyphenate_never_reports_a_break() {
15432        let s = Standard;
15433        assert!(s.hyphenate("").breaks.is_empty());
15434        assert!(s.hyphenate("hyphenation").breaks.is_empty());
15435        assert!(s.hyphenate(&"a".repeat(10_000)).breaks.is_empty());
15436        assert!(s.hyphenate("\u{1F600}\u{0301}").breaks.is_empty());
15437    }
15438
15439    #[cfg(not(feature = "text_layout_hyphenation"))]
15440    #[test]
15441    fn stub_get_hyphenator_always_errors() {
15442        assert!(matches!(
15443            get_hyphenator(Language::EnglishUS),
15444            Err(LayoutError::HyphenationError(_))
15445        ));
15446    }
15447
15448    #[cfg(feature = "text_layout_hyphenation")]
15449    #[test]
15450    fn get_hyphenator_loads_an_embedded_language_and_hyphenates() {
15451        let h =
15452            get_hyphenator(HyphenationLanguage::EnglishUS).expect("en-US dictionaries are embedded");
15453
15454        // Empty / single-char words have no interior break points.
15455        let empty = h.hyphenate("");
15456        assert!(empty.breaks.is_empty(), "an empty word must not panic");
15457        let one = h.hyphenate("a");
15458        assert!(one.breaks.is_empty());
15459
15460        // Every reported break must be a valid INTERIOR char boundary.
15461        let word = "hyphenation";
15462        let opps = h.hyphenate(word);
15463        for &b in &opps.breaks {
15464            assert!(b > 0 && b < word.len(), "break {b} is outside {word:?}");
15465            assert!(word.is_char_boundary(b), "break {b} splits a char");
15466        }
15467
15468        // Astral + combining input must not panic.
15469        let weird = h.hyphenate("\u{1F600}\u{0301}");
15470        assert!(weird.breaks.len() < 8, "no runaway break list");
15471    }
15472}