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, 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        };
759        // Idempotent: reuses the FontIds already in the shared fc_cache.
760        fm.register_builtin_mock_fonts();
761        fm
762    }
763}
764
765#[derive(Debug)]
766pub struct FontManager<T> {
767    /// The font-path cache. `FcFontCache` in rust-fontconfig 4.1 is
768    /// already a shared handle internally (`Arc<RwLock<_>>`), so no
769    /// further `Arc<...>` wrapping is needed — clones are cheap and
770    /// all clones see builder writes instantly.
771    pub fc_cache: FcFontCache,
772    /// Holds the actual parsed font (usually with the font bytes attached).
773    /// Wrapped in Arc so multiple `FontManager` instances can share the same
774    /// pool of already-parsed fonts (avoids re-reading from disk).
775    pub parsed_fonts: Arc<Mutex<HashMap<FontId, T>>>,
776    // Cache for font chains - populated by resolve_all_font_chains() before layout
777    // This is read-only during layout - no locking needed for reads
778    pub font_chain_cache: HashMap<FontChainKey, rust_fontconfig::FontFallbackChain>,
779    /// Cache for direct `FontRefs` (embedded fonts like Material Icons)
780    /// These are fonts referenced via `FontStack::Ref` that bypass fontconfig
781    pub embedded_fonts: Mutex<HashMap<u64, azul_css::props::basic::FontRef>>,
782    /// Reverse map: `font_family_hash` → actual `StyleFontFamilyVec`.
783    /// Accumulated across DOMs. Used by font collection and text shaping to
784    /// resolve compact cache hashes without `get_property_slow`.
785    pub font_hash_to_families: HashMap<u64, azul_css::props::basic::font::StyleFontFamilyVec>,
786    /// Optional link back to the live `FcFontRegistry`. When present,
787    /// chain resolution uses
788    /// [`rust_fontconfig::registry::FcFontRegistry::request_and_resolve_with_scripts`]
789    /// which lazy-parses system fonts as the DOM requests them
790    /// (scout-on-demand). `None` falls back to querying whatever is
791    /// already in the shared cache.
792    pub registry: Option<Arc<rust_fontconfig::registry::FcFontRegistry>>,
793    /// `FxHash` of the `prev_font_hashes` slice at the moment the last
794    /// successful `collect_and_resolve_font_chains_with_registration`
795    /// call populated `font_chain_cache`. Lets repeated layouts of the
796    /// same DOM skip the ~1.5 ms (cold) / ~0.9 ms (warm) chain resolver
797    /// when the set of font-family hashes has not changed. Cleared
798    /// whenever `font_chain_cache` is explicitly emptied.
799    pub last_resolved_font_stacks_sig: Option<u64>,
800    /// Index of every font registered by FAMILY NAME into `fc_cache`'s
801    /// in-memory font table (bundled fonts, embedder fonts, the built-in
802    /// mock test fonts): normalized family name → the `FontMatch` the
803    /// resolver should emit for it.
804    ///
805    /// WHY THIS EXISTS (architectural, see `resolve_font_chains_fast`):
806    /// the fast chain resolver in rust-fontconfig 4.4
807    /// (`FcFontRegistry::request_fonts_fast`) resolves families purely
808    /// against `known_paths` — i.e. fonts that exist as FILES ON DISK.
809    /// In-memory fonts are invisible to it, so a family registered with
810    /// `FcFontCache::with_memory_fonts` could never be matched by name
811    /// on the production path (which always has a live registry): it
812    /// silently fell back to a system font. This index is consulted
813    /// FIRST, before the disk probe, so a memory-registered family wins
814    /// exactly as CSS says it should.
815    pub memory_families: HashMap<String, rust_fontconfig::FontMatch>,
816}
817
818impl<T: ParsedFontTrait> FontManager<T> {
819    /// # Errors
820    ///
821    /// Returns a `LayoutError` if the font cache cannot be initialized.
822    pub fn new(fc_cache: FcFontCache) -> Result<Self, LayoutError> {
823        let mut fm = Self {
824            fc_cache,
825            parsed_fonts: Arc::new(Mutex::new(HashMap::new())),
826            font_chain_cache: HashMap::new(),
827            embedded_fonts: Mutex::new(HashMap::new()),
828            font_hash_to_families: HashMap::new(),
829            registry: None,
830            last_resolved_font_stacks_sig: None,
831            memory_families: HashMap::new(),
832        };
833        fm.register_builtin_mock_fonts();
834        Ok(fm)
835    }
836
837    /// Register a font by FAMILY NAME from raw bytes, as an in-memory font
838    /// in the shared `FcFontCache`.
839    ///
840    /// This is the ONE hook an embedder (or a test) uses to make a font
841    /// resolvable by `font-family: "<family>"`. It mints one `FontId` for
842    /// the font, inserts it into the fontconfig cache's memory-font table
843    /// (so `get_font_bytes` / `load_fonts_from_disk` find it with no
844    /// special-casing) and indexes it in [`Self::memory_families`] so the
845    /// fast chain resolver can match it by name.
846    ///
847    /// `coverage` are the codepoint ranges the font actually covers.
848    /// Passing the true ranges matters: `FontFallbackChain::resolve_char`
849    /// skips any font that reports no coverage, and a font claiming
850    /// coverage it doesn't have would render .notdef instead of falling
851    /// back.
852    ///
853    /// Returns the `FontId` the family now resolves to.
854    pub fn register_named_font(
855        &mut self,
856        family: &str,
857        bytes: &[u8],
858        coverage: Vec<rust_fontconfig::UnicodeRange>,
859    ) -> FontId {
860        let norm = rust_fontconfig::utils::normalize_family_name(family);
861
862        // IDEMPOTENT: several `FontManager`s (one per window, plus the PDF
863        // writer) share one `FcFontCache`. Registering the same family twice
864        // would mint a second `FontId` for the same bytes, orphan the first
865        // in the cache's metadata table and make the family's id
866        // non-deterministic. Reuse the existing memory font instead.
867        let mut existing: Vec<(FontId, Vec<rust_fontconfig::UnicodeRange>)> = Vec::new();
868        self.fc_cache.for_each_pattern(|pattern, id| {
869            let hit = pattern
870                .family
871                .as_deref()
872                .is_some_and(|f| rust_fontconfig::utils::normalize_family_name(f) == norm);
873            if hit {
874                existing.push((*id, pattern.unicode_ranges.clone()));
875            }
876        });
877        if let Some((id, ranges)) = existing
878            .into_iter()
879            .find(|(id, _)| self.fc_cache.is_memory_font(id))
880        {
881            self.memory_families.insert(
882                norm,
883                rust_fontconfig::FontMatch {
884                    id,
885                    unicode_ranges: ranges,
886                    fallbacks: Vec::new(),
887                },
888            );
889            return id;
890        }
891
892        let pattern = rust_fontconfig::FcPattern {
893            name: Some(family.to_string()),
894            family: Some(family.to_string()),
895            unicode_ranges: coverage.clone(),
896            ..Default::default()
897        };
898        let id = FontId::new();
899        self.fc_cache.with_memory_font_with_id(
900            id,
901            pattern,
902            rust_fontconfig::FcFont {
903                bytes: bytes.to_vec(),
904                font_index: 0,
905                id: family.to_string(),
906            },
907        );
908        self.memory_families.insert(
909            norm,
910            rust_fontconfig::FontMatch {
911                id,
912                unicode_ranges: coverage,
913                fallbacks: Vec::new(),
914            },
915        );
916        id
917    }
918
919    /// Register the built-in mock test fonts (see
920    /// [`crate::text3::mock_fonts`]). Called from every constructor: the
921    /// mock families are only reachable if a stylesheet names them, and
922    /// having them always present means tests exercise the *same* font
923    /// path as production instead of a test-only bypass.
924    pub fn register_builtin_mock_fonts(&mut self) {
925        for (family, bytes) in crate::text3::mock_fonts::BUILTIN_MOCK_FONTS {
926            self.register_named_font(
927                family,
928                bytes,
929                crate::text3::mock_fonts::mock_font_ranges(),
930            );
931        }
932    }
933
934    /// Create a `FontManager` sharing the font-path cache handle.
935    ///
936    /// The `parsed_fonts` pool starts empty. Fonts loaded during the first
937    /// layout pass are cached and will be available on subsequent calls
938    /// if you clone the `parsed_fonts` Arc before creating the next instance.
939    /// For full sharing, prefer `from_arc_shared()`.
940    /// # Errors
941    ///
942    /// Returns a `LayoutError` if the font cache cannot be initialized.
943    pub fn from_shared(fc_cache: FcFontCache) -> Result<Self, LayoutError> {
944        Self::new(fc_cache)
945    }
946
947    /// Create a `FontManager` sharing both the font-path cache and the
948    /// already-parsed font data with another `FontManager`.
949    ///
950    /// This avoids re-reading and re-parsing font files from disk when
951    /// rendering multiple documents that use the same fonts.
952    /// # Errors
953    ///
954    /// Returns a `LayoutError` if the font cache cannot be initialized.
955    pub fn from_arc_shared(
956        fc_cache: FcFontCache,
957        parsed_fonts: Arc<Mutex<HashMap<FontId, T>>>,
958    ) -> Result<Self, LayoutError> {
959        let mut fm = Self {
960            fc_cache,
961            parsed_fonts,
962            font_chain_cache: HashMap::new(),
963            embedded_fonts: Mutex::new(HashMap::new()),
964            font_hash_to_families: HashMap::new(),
965            registry: None,
966            last_resolved_font_stacks_sig: None,
967            memory_families: HashMap::new(),
968        };
969        fm.register_builtin_mock_fonts();
970        Ok(fm)
971    }
972
973    /// Attach a `FcFontRegistry` to this `FontManager` so subsequent
974    /// chain-resolution calls use the on-demand path
975    /// ([`rust_fontconfig::registry::FcFontRegistry::request_and_resolve_with_scripts`]).
976    #[must_use]
977    pub fn with_registry(
978        mut self,
979        registry: Arc<rust_fontconfig::registry::FcFontRegistry>,
980    ) -> Self {
981        self.registry = Some(registry);
982        self
983    }
984
985    /// Get a shareable handle to the parsed-font pool.
986    ///
987    /// Pass this to `from_arc_shared()` to create a new `FontManager` that
988    /// reuses already-parsed fonts.
989    pub fn shared_parsed_fonts(&self) -> Arc<Mutex<HashMap<FontId, T>>> {
990        Arc::clone(&self.parsed_fonts)
991    }
992
993    /// Set the font chain cache from externally resolved chains
994    ///
995    /// This should be called with the result of `resolve_font_chains()` or
996    /// `collect_and_resolve_font_chains()` from `solver3::getters`.
997    pub fn set_font_chain_cache(
998        &mut self,
999        chains: HashMap<FontChainKey, rust_fontconfig::FontFallbackChain>,
1000    ) {
1001        self.font_chain_cache = chains;
1002        self.last_resolved_font_stacks_sig = None;
1003    }
1004
1005    /// Set the font chain cache and record the input signature so
1006    /// subsequent layouts with the same `prev_font_hashes` skip the
1007    /// resolver. Pass `sig = None` if the caller cannot compute a
1008    /// reliable signature — equivalent to the single-arg
1009    /// `set_font_chain_cache`.
1010    pub fn set_font_chain_cache_with_sig(
1011        &mut self,
1012        chains: HashMap<FontChainKey, rust_fontconfig::FontFallbackChain>,
1013        sig: Option<u64>,
1014    ) {
1015        // (2026-06-10: reverted to HashMap — the empty-map RawIter hang behind the 2026-06-05
1016        // BTreeMap migration was the un-mirrored hashbrown EMPTY_GROUP static, fixed
1017        // transpiler-side.)
1018        self.font_chain_cache = chains;
1019        self.last_resolved_font_stacks_sig = sig;
1020    }
1021
1022    /// Merge additional font chains into the existing cache
1023    ///
1024    /// Useful when processing multiple DOMs that may have different font requirements.
1025    pub fn merge_font_chain_cache(
1026        &mut self,
1027        chains: HashMap<FontChainKey, rust_fontconfig::FontFallbackChain>,
1028    ) {
1029        self.font_chain_cache.extend(chains);
1030    }
1031
1032    /// Get a reference to the font chain cache
1033    pub const fn get_font_chain_cache(
1034        &self,
1035    ) -> &HashMap<FontChainKey, rust_fontconfig::FontFallbackChain> {
1036        &self.font_chain_cache
1037    }
1038
1039    /// Get an embedded font by its hash (used for `WebRender` registration)
1040    /// Returns the `FontRef` if it exists in the `embedded_fonts` cache.
1041    /// # Panics
1042    ///
1043    /// Panics if the internal font-cache mutex is poisoned.
1044    pub fn get_embedded_font_by_hash(&self, font_hash: u64) -> Option<azul_css::props::basic::FontRef> {
1045        let embedded = self.embedded_fonts.lock().unwrap();
1046        embedded.get(&font_hash).cloned()
1047    }
1048
1049    /// Get a parsed font by its hash (used for `WebRender` registration)
1050    /// Returns the parsed font if it exists in the `parsed_fonts` cache.
1051    /// # Panics
1052    ///
1053    /// Panics if the internal font-cache mutex is poisoned.
1054    pub fn get_font_by_hash(&self, font_hash: u64) -> Option<T> {
1055        let parsed = self.parsed_fonts.lock().unwrap();
1056        // Linear search through all cached fonts to find one with matching hash
1057        let found = parsed
1058            .iter()
1059            .find(|(_, font)| font.get_hash() == font_hash)
1060            .map(|(_, font)| font.clone());
1061        drop(parsed);
1062        found
1063    }
1064
1065    /// Register an embedded `FontRef` for later lookup by hash
1066    /// This is called when using `FontStack::Ref` during shaping
1067    /// # Panics
1068    ///
1069    /// Panics if the internal font-cache mutex is poisoned.
1070    pub fn register_embedded_font(&self, font_ref: &azul_css::props::basic::FontRef) {
1071        let hash = font_ref.get_hash();
1072        let mut embedded = self.embedded_fonts.lock().unwrap();
1073        embedded.insert(hash, font_ref.clone());
1074    }
1075
1076    /// Get a snapshot of all currently loaded fonts
1077    ///
1078    /// This returns a copy of all parsed fonts, which can be passed to the shaper.
1079    /// No locking is required after this call - the returned `HashMap` is independent.
1080    ///
1081    /// NOTE: This should be called AFTER loading all required fonts for a layout pass.
1082    /// # Panics
1083    ///
1084    /// Panics if the internal font-cache mutex is poisoned.
1085    pub fn get_loaded_fonts(&self) -> LoadedFonts<T> {
1086        let parsed = self.parsed_fonts.lock().unwrap();
1087        parsed
1088            .iter()
1089            .map(|(id, font)| (*id, font.shallow_clone()))
1090            .collect()
1091    }
1092
1093    /// Get the set of `FontIds` that are currently loaded
1094    ///
1095    /// This is useful for computing which fonts need to be loaded
1096    /// (diff with required fonts).
1097    /// # Panics
1098    ///
1099    /// Panics if the internal font-cache mutex is poisoned.
1100    pub fn get_loaded_font_ids(&self) -> HashSet<FontId> {
1101        let parsed = self.parsed_fonts.lock().unwrap();
1102        // M12.7: skip hashbrown's RawIterRange on an empty map — its NEON
1103        // control-byte group-scan mis-lifts to wasm and iterates forever
1104        // (the headless web layout uses an empty font cache → parsed is
1105        // empty here). is_empty() is len-based (no iteration), so it is safe.
1106        if parsed.is_empty() {
1107            return HashSet::new();
1108        }
1109        unsafe { crate::az_mark(0x60788, 0xA1) };
1110        let out = parsed.keys().copied().collect();
1111        drop(parsed);
1112        unsafe { crate::az_mark(0x6078C, 0xA2) };
1113        out
1114    }
1115
1116    /// Insert a loaded font into the cache
1117    ///
1118    /// Returns the old font if one was already present for this `FontId`.
1119    /// # Panics
1120    ///
1121    /// Panics if the internal font-cache mutex is poisoned.
1122    pub fn insert_font(&self, font_id: FontId, font: T) -> Option<T> {
1123        let mut parsed = self.parsed_fonts.lock().unwrap();
1124        parsed.insert(font_id, font)
1125    }
1126
1127    /// Insert multiple loaded fonts into the cache
1128    ///
1129    /// This is more efficient than calling `insert_font` multiple times
1130    /// because it only acquires the lock once.
1131    /// # Panics
1132    ///
1133    /// Panics if the internal font-cache mutex is poisoned.
1134    pub fn insert_fonts(&self, fonts: impl IntoIterator<Item = (FontId, T)>) {
1135        let mut parsed = self.parsed_fonts.lock().unwrap();
1136        for (font_id, font) in fonts {
1137            parsed.insert(font_id, font);
1138        }
1139    }
1140
1141    /// One-shot helper that resolves "what fonts does `chains` need
1142    /// that this manager hasn't loaded yet" and loads them via the
1143    /// supplied `load_fn` closure (typically
1144    /// `PathLoader::load_font_shared` for the production lazy-decode
1145    /// path). Updates `parsed_fonts` in place and returns any failures
1146    /// for the caller to log.
1147    ///
1148    /// Replaces the same four-step `collect → compute_diff →
1149    /// load_from_disk → insert_fonts` dance previously inlined in
1150    /// `LayoutWindow::layout_document`, the CPU rasterizer pre-fill
1151    /// in `cpurender.rs`, and `FontContext::load_fonts_for_chains`.
1152    pub fn load_missing_for_chains<F>(
1153        &self,
1154        chains: &crate::solver3::getters::ResolvedFontChains,
1155        load_fn: F,
1156    ) -> Vec<(FontId, String)>
1157    where
1158        F: Fn(Arc<rust_fontconfig::FontBytes>, usize) -> Result<T, LayoutError>,
1159    {
1160        use crate::solver3::getters::{
1161            collect_font_ids_from_chains, compute_fonts_to_load, load_fonts_from_disk,
1162        };
1163        let required = collect_font_ids_from_chains(chains);
1164        let already = self.get_loaded_font_ids();
1165        let to_load = compute_fonts_to_load(&required, &already);
1166        if to_load.is_empty() {
1167            return Vec::new();
1168        }
1169        let result = load_fonts_from_disk(&to_load, &self.fc_cache, load_fn);
1170        self.insert_fonts(result.loaded);
1171        result.failed
1172    }
1173
1174    /// Remove a font from the cache
1175    ///
1176    /// Returns the removed font if it was present.
1177    /// # Panics
1178    ///
1179    /// Panics if the internal font-cache mutex is poisoned.
1180    pub fn remove_font(&self, font_id: &FontId) -> Option<T> {
1181        let mut parsed = self.parsed_fonts.lock().unwrap();
1182        parsed.remove(font_id)
1183    }
1184
1185    /// FONT GC — evict everything the CURRENT document no longer references.
1186    ///
1187    /// `keep_ids` are the `FontId`s reachable from the font chains just resolved
1188    /// for this document; `keep_hashes` are the font-family hashes present in its
1189    /// CSS property cache. Anything else belonged to a node that is gone.
1190    ///
1191    /// Without this, `parsed_fonts` / `font_hash_to_families` only ever GREW: a
1192    /// font loaded for one node stayed resident for the life of the window even
1193    /// after the node (and every other user of that family) disappeared — an app
1194    /// that cycles fonts (font picker, editor, live CSS) leaked every font it ever
1195    /// touched.
1196    ///
1197    /// Eviction is always safe: `load_missing_for_chains` re-loads any font a
1198    /// later layout turns out to need. The cost of a wrong guess is one re-parse,
1199    /// never a missing glyph.
1200    ///
1201    /// Returns the number of parsed fonts evicted.
1202    /// # Panics
1203    ///
1204    /// Panics if the internal font-cache mutex is poisoned.
1205    pub fn garbage_collect_fonts(
1206        &mut self,
1207        keep_ids: &HashSet<FontId>,
1208        keep_hashes: &HashSet<u64>,
1209    ) -> usize {
1210        let evicted = {
1211            let mut parsed = self.parsed_fonts.lock().unwrap();
1212            let before = parsed.len();
1213            parsed.retain(|id, _| keep_ids.contains(id));
1214            before.saturating_sub(parsed.len())
1215        };
1216        self.font_hash_to_families
1217            .retain(|h, _| keep_hashes.contains(h));
1218        evicted
1219    }
1220}
1221
1222// Error handling
1223// [g119 az-web-lift FIX] `#[repr(C, u8)]` (was repr(Rust)): the String/FontSelector payloads give
1224// `Result<T, LayoutError>` (e.g. measure_intrinsic_widths' return + reorder/shape/orientation `?`)
1225// a POINTER-niche disc the web lift mis-reads → Ok→Err. Explicit u8 tag = simple-compare niche the
1226// lift handles. Also nested in solver3::LayoutError::Text (so both must be repr(C,u8)). Not FFI-exposed.
1227#[derive(Debug, thiserror::Error)]
1228#[repr(C, u8)]
1229pub enum LayoutError {
1230    #[error("Bidi analysis failed: {0}")]
1231    BidiError(String),
1232    #[error("Shaping failed: {0}")]
1233    ShapingError(String),
1234    #[error("Font not found: {0:?}")]
1235    FontNotFound(FontSelector),
1236    #[error("Invalid text input: {0}")]
1237    InvalidText(String),
1238    #[error("Hyphenation failed: {0}")]
1239    HyphenationError(String),
1240}
1241
1242/// Text boundary types for cursor movement
1243#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1244pub enum TextBoundary {
1245    /// Reached top of text (first line)
1246    Top,
1247    /// Reached bottom of text (last line)
1248    Bottom,
1249    /// Reached start of text (first character)
1250    Start,
1251    /// Reached end of text (last character)
1252    End,
1253}
1254
1255/// Error returned when cursor movement hits a boundary
1256#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1257pub(crate) struct CursorBoundsError {
1258    pub(crate) boundary: TextBoundary,
1259    pub(crate) cursor: TextCursor,
1260}
1261
1262/// Unified constraints combining all layout features
1263///
1264/// # CSS Inline Layout Module Level 3: Constraint Mapping
1265///
1266/// This structure maps CSS properties to layout constraints:
1267///
1268/// ## \u00a7 2.1 Layout of Line Boxes
1269/// - `available_width`: \u26a0\ufe0f CRITICAL - Should equal containing block's inner width
1270///   * Currently defaults to 0.0 which causes immediate line breaking
1271///   * Per spec: "logical width of a line box is equal to the inner logical width of its containing
1272///     block"
1273/// - `available_height`: For block-axis constraints (max-height)
1274///
1275/// ## \u00a7 2.2 Layout Within Line Boxes
1276/// - `text_align`: \u2705 Horizontal alignment (start, end, center, justify)
1277/// - `vertical_align`: \u26a0\ufe0f PARTIAL - Only baseline supported, missing:
1278///   * top, bottom, middle, text-top, text-bottom
1279///   * <length>, <percentage> values
1280///   * sub, super positions
1281/// - `line_height`: \u2705 Distance between baselines
1282///
1283/// ## \u00a7 3 Baselines and Alignment Metrics
1284/// - `text_orientation`: \u2705 For vertical writing (sideways, upright)
1285/// - `writing_mode`: \u2705 horizontal-tb, vertical-rl, vertical-lr
1286/// - `direction`: \u2705 ltr, rtl for `BiDi`
1287///
1288/// ## \u00a7 4 Baseline Alignment (vertical-align property)
1289/// \u26a0\ufe0f INCOMPLETE: Only basic baseline alignment implemented
1290///
1291/// ## \u00a7 5 Line Spacing (line-height property)
1292/// - `line_height`: \u2705 Implemented
1293/// - \u274c MISSING: line-fit-edge for controlling which edges contribute to line height
1294///   +spec:box-model:51342f - inline box margins/borders/padding do not affect line box height (default leading mode)
1295///   +spec:font-metrics:618776 - line-fit-edge (cap, ex, ideographic, alphabetic edge selection) not yet implemented
1296///
1297/// ## \u00a7 6 Trimming Leading (text-box-trim)
1298/// - \u274c NOT IMPLEMENTED: text-box-trim property
1299/// - \u274c NOT IMPLEMENTED: text-box-edge property
1300///   +spec:box-model:c09331 - text-box-trim trims block container first/last line to font metrics
1301///   // +spec:overflow:dc2196 - text-box-trim overflow handled as normal overflow (no special handling needed)
1302///
1303/// ## CSS Text Module Level 3
1304/// - `text_indent`: \u2705 First line indentation
1305/// - `text_justify`: \u2705 Justification algorithm (auto, inter-word, inter-character)
1306/// - `hyphenation`: \u2705 Hyphens property (none / manual / auto)
1307/// - `hanging_punctuation`: \u2705 Hanging punctuation at line edges
1308///
1309/// ## CSS Text Level 4
1310/// - `text_wrap`: \u2705 balance, pretty, stable
1311/// - `line_clamp`: \u2705 Max number of lines
1312///
1313/// ## CSS Writing Modes Level 4
1314/// - `text_combine_upright`: \u2705 Tate-chu-yoko for vertical text
1315///
1316/// ## CSS Shapes Module
1317/// - `shape_boundaries`: \u2705 Custom line box shapes
1318/// - `shape_exclusions`: \u2705 Exclusion areas (float-like behavior)
1319/// - `exclusion_margin`: \u2705 Margin around exclusions
1320///
1321/// ## Multi-column Layout
1322/// - `columns`: \u2705 Number of columns
1323/// - `column_gap`: \u2705 Gap between columns
1324///
1325/// # Known Issues:
1326/// 1. [ISSUE] `available_width` defaults to Definite(0.0) instead of containing block width
1327/// 2. [ISSUE] `vertical_align` only supports baseline
1328/// 3. [TODO] initial-letter (drop caps) not implemented
1329// +spec:box-model:415ef3 - initial letters use standard margin/padding/border box model; exclusion area = margin box
1330// +spec:box-model:d53ea3 - when block-start padding+border are zero, content edge coincides with over alignment point
1331///    +spec:positioning:fb233a - initial letter block-axis: if size < sink, use over alignment
1332#[derive(Debug, Clone)]
1333pub struct UnifiedConstraints {
1334    // Shape definition
1335    pub shape_boundaries: Vec<ShapeBoundary>,
1336    pub shape_exclusions: Vec<ShapeBoundary>,
1337
1338    // Basic layout - using AvailableSpace for proper indefinite handling
1339    pub available_width: AvailableSpace,
1340    pub available_height: Option<f32>,
1341
1342    // Text layout
1343    pub writing_mode: Option<WritingMode>,
1344    // +spec:writing-modes:6c5ab9 - blocks inherit base direction from parent via CSS direction property
1345    // Base direction from CSS, overrides auto-detection
1346    pub direction: Option<BidiDirection>,
1347    pub text_orientation: TextOrientation,
1348    pub text_align: TextAlign,
1349    pub text_justify: JustifyContent,
1350    // +spec:display-property:3bcac8 - inline boxes sized in block axis based on font metrics (ascent/descent)
1351    pub line_height: LineHeight,
1352    pub vertical_align: VerticalAlign,
1353    // block container's first available font, used for minimum line box height
1354    pub strut_ascent: f32,
1355    pub strut_descent: f32,
1356    // x-height of the strut font (scaled to font_size), for vertical-align: middle
1357    pub strut_x_height: f32,
1358
1359    // Width of '0' (zero) character in px, used for ch unit and tab-size.
1360    // Approximated as space_width from the first available font, or 0.5 * font_size fallback.
1361    pub ch_width: f32,
1362
1363    // Overflow handling
1364    pub overflow: OverflowBehavior,
1365    pub segment_alignment: SegmentAlignment,
1366
1367    // Advanced features
1368    pub text_combine_upright: Option<TextCombineUpright>,
1369    pub exclusion_margin: f32,
1370    pub hyphenation: Hyphens,
1371    pub hyphenation_language: Option<Language>,
1372    pub text_indent: f32,
1373    pub text_indent_each_line: bool,
1374    pub text_indent_hanging: bool,
1375    pub initial_letter: Option<InitialLetter>,
1376    pub line_clamp: Option<NonZeroUsize>,
1377
1378    // text-wrap: balance
1379    pub text_wrap: TextWrap,
1380    pub columns: u32,
1381    pub column_gap: f32,
1382    pub hanging_punctuation: bool,
1383    pub overflow_wrap: OverflowWrap,
1384    pub text_align_last: TextAlign,
1385    // §5.2 word-break property on constraints
1386    pub word_break: WordBreak,
1387    pub white_space_mode: WhiteSpaceMode,
1388    pub line_break: LineBreakStrictness,
1389    // CSS unicode-bidi property; Plaintext causes per-paragraph auto-detection
1390    pub unicode_bidi: UnicodeBidi,
1391}
1392
1393impl Default for UnifiedConstraints {
1394    fn default() -> Self {
1395        Self {
1396            shape_boundaries: Vec::new(),
1397            shape_exclusions: Vec::new(),
1398
1399            // Use MaxContent as default to avoid premature line breaking.
1400            // MaxContent means "use intrinsic width" which is appropriate when
1401            // the containing block's width is not yet known.
1402            // Previously this was Definite(0.0) which caused each character to
1403            // wrap to its own line. The actual width should be passed from the 
1404            // box layout solver (fc.rs) when creating UnifiedConstraints.
1405            available_width: AvailableSpace::MaxContent,
1406            available_height: None,
1407            writing_mode: None,
1408            direction: None, // Will default to LTR if not specified
1409            text_orientation: TextOrientation::default(),
1410            text_align: TextAlign::default(),
1411            text_justify: JustifyContent::default(),
1412            line_height: LineHeight::Normal,
1413            vertical_align: VerticalAlign::default(),
1414            strut_ascent: DEFAULT_STRUT_ASCENT,
1415            strut_descent: DEFAULT_STRUT_DESCENT,
1416            strut_x_height: DEFAULT_X_HEIGHT,
1417            ch_width: DEFAULT_CH_WIDTH,
1418            overflow: OverflowBehavior::default(),
1419            segment_alignment: SegmentAlignment::default(),
1420            text_combine_upright: None,
1421            exclusion_margin: 0.0,
1422            hyphenation: Hyphens::default(),
1423            hyphenation_language: None,
1424            columns: 1,
1425            column_gap: 0.0,
1426            hanging_punctuation: false,
1427            text_indent: 0.0,
1428            text_indent_each_line: false,
1429            text_indent_hanging: false,
1430            initial_letter: None,
1431            line_clamp: None,
1432            text_wrap: TextWrap::default(),
1433            overflow_wrap: OverflowWrap::default(),
1434            text_align_last: TextAlign::default(),
1435            word_break: WordBreak::default(),
1436            white_space_mode: WhiteSpaceMode::default(),
1437            line_break: LineBreakStrictness::default(),
1438            unicode_bidi: UnicodeBidi::default(),
1439        }
1440    }
1441}
1442
1443// UnifiedConstraints
1444impl Hash for UnifiedConstraints {
1445    #[allow(clippy::cast_possible_truncation)] // bounded pixel/coord/colour/glyph cast
1446    fn hash<H: Hasher>(&self, state: &mut H) {
1447        self.shape_boundaries.hash(state);
1448        self.shape_exclusions.hash(state);
1449        self.available_width.hash(state);
1450        self.available_height
1451            .map(|h| h.round() as isize)
1452            .hash(state);
1453        self.writing_mode.hash(state);
1454        self.direction.hash(state);
1455        self.text_orientation.hash(state);
1456        self.text_align.hash(state);
1457        self.text_justify.hash(state);
1458        self.line_height.hash(state);
1459        self.vertical_align.hash(state);
1460        (self.strut_ascent.round() as isize).hash(state);
1461        (self.strut_descent.round() as isize).hash(state);
1462        (self.strut_x_height.round() as isize).hash(state);
1463        (self.ch_width.round() as isize).hash(state);
1464        self.overflow.hash(state);
1465        self.segment_alignment.hash(state);
1466        self.text_combine_upright.hash(state);
1467        (self.exclusion_margin.round() as isize).hash(state);
1468        self.hyphenation.hash(state);
1469        self.hyphenation_language.hash(state);
1470        (self.text_indent.round() as isize).hash(state);
1471        self.text_indent_each_line.hash(state);
1472        self.text_indent_hanging.hash(state);
1473        self.initial_letter.hash(state);
1474        self.line_clamp.hash(state);
1475        self.columns.hash(state);
1476        (self.column_gap.round() as isize).hash(state);
1477        self.hanging_punctuation.hash(state);
1478        self.overflow_wrap.hash(state);
1479        self.text_align_last.hash(state);
1480        self.word_break.hash(state);
1481        self.white_space_mode.hash(state);
1482        self.line_break.hash(state);
1483        self.unicode_bidi.hash(state);
1484    }
1485}
1486
1487impl PartialEq for UnifiedConstraints {
1488    fn eq(&self, other: &Self) -> bool {
1489        self.shape_boundaries == other.shape_boundaries
1490            && self.shape_exclusions == other.shape_exclusions
1491            && self.available_width == other.available_width
1492            && match (self.available_height, other.available_height) {
1493                (None, None) => true,
1494                (Some(h1), Some(h2)) => round_eq(h1, h2),
1495                _ => false,
1496            }
1497            && self.writing_mode == other.writing_mode
1498            && self.direction == other.direction
1499            && self.text_orientation == other.text_orientation
1500            && self.text_align == other.text_align
1501            && self.text_justify == other.text_justify
1502            && self.line_height == other.line_height
1503            && self.vertical_align == other.vertical_align
1504            && round_eq(self.strut_ascent, other.strut_ascent)
1505            && round_eq(self.strut_descent, other.strut_descent)
1506            && round_eq(self.strut_x_height, other.strut_x_height)
1507            && round_eq(self.ch_width, other.ch_width)
1508            && self.overflow == other.overflow
1509            && self.segment_alignment == other.segment_alignment
1510            && self.text_combine_upright == other.text_combine_upright
1511            && round_eq(self.exclusion_margin, other.exclusion_margin)
1512            && self.hyphenation == other.hyphenation
1513            && self.hyphenation_language == other.hyphenation_language
1514            && round_eq(self.text_indent, other.text_indent)
1515            && self.text_indent_each_line == other.text_indent_each_line
1516            && self.text_indent_hanging == other.text_indent_hanging
1517            && self.initial_letter == other.initial_letter
1518            && self.line_clamp == other.line_clamp
1519            && self.columns == other.columns
1520            && round_eq(self.column_gap, other.column_gap)
1521            && self.hanging_punctuation == other.hanging_punctuation
1522            && self.overflow_wrap == other.overflow_wrap
1523            && self.text_align_last == other.text_align_last
1524            && self.word_break == other.word_break
1525            && self.white_space_mode == other.white_space_mode
1526            && self.line_break == other.line_break
1527            && self.unicode_bidi == other.unicode_bidi
1528    }
1529}
1530
1531impl Eq for UnifiedConstraints {}
1532
1533impl UnifiedConstraints {
1534    /// Resolve `line_height` to a pixel value using the strut metrics as a font-size proxy.
1535    /// `strut_ascent + strut_descent` approximates `font_size` (the block container's font).
1536    #[must_use] pub fn resolved_line_height(&self) -> f32 {
1537        match self.line_height {
1538            // `line-height: normal` — the minimum line-box height is the block's
1539            // first-available-font metrics, approximated here by the strut's
1540            // ascent + descent. Resolving `Normal` with no real metrics fell back
1541            // to `font_size * 1.2`, which inflated every non-last line's advance
1542            // ~20% (block auto-heights came out too tall). The real per-line box
1543            // height (from each run's actual glyph metrics) is folded in via
1544            // `.max()` at the call sites, so this strut value is the correct floor.
1545            LineHeight::Normal => self.strut_ascent + self.strut_descent,
1546            LineHeight::Px(px) => px,
1547        }
1548    }
1549    fn direction(&self, fallback: BidiDirection) -> BidiDirection {
1550        self.writing_mode.map_or(fallback, |s| s.get_direction().unwrap_or(fallback))
1551    }
1552    const fn is_vertical(&self) -> bool {
1553        matches!(
1554            self.writing_mode,
1555            Some(WritingMode::VerticalRl | WritingMode::VerticalLr)
1556        )
1557    }
1558}
1559
1560/// Line constraints with multi-segment support
1561#[derive(Debug, Clone)]
1562pub struct LineConstraints {
1563    pub segments: Vec<LineSegment>,
1564    pub total_available: f32,
1565    /// True when measuring min-content: the breaker must break at EVERY soft-wrap
1566    /// opportunity (each word on its own line) rather than filling `total_available`
1567    /// (which is a sentinel `f32::MAX / 2` for intrinsic sizing and never overflows).
1568    pub is_min_content: bool,
1569}
1570
1571impl WritingMode {
1572    #[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)
1573    #[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
1574    const fn get_direction(&self) -> Option<BidiDirection> {
1575        match self {
1576            // determined by text content
1577            Self::HorizontalTb => None,
1578            Self::VerticalRl => Some(BidiDirection::Rtl),
1579            Self::VerticalLr => Some(BidiDirection::Ltr),
1580            Self::SidewaysRl => Some(BidiDirection::Rtl),
1581            Self::SidewaysLr => Some(BidiDirection::Ltr),
1582        }
1583    }
1584}
1585
1586// Stage 1: Collection - Styled runs from DOM traversal
1587#[derive(Debug, Clone, Hash)]
1588pub struct StyledRun {
1589    pub text: String,
1590    pub style: Arc<StyleProperties>,
1591    /// Byte index in the original logical paragraph text
1592    pub logical_start_byte: usize,
1593    /// The DOM `NodeId` of the Text node this run came from.
1594    /// None for generated content (e.g., list markers, `::before/::after`).
1595    pub source_node_id: Option<NodeId>,
1596}
1597
1598// Stage 2: Bidi Analysis - Visual runs in display order
1599#[derive(Debug, Clone)]
1600pub struct VisualRun<'a> {
1601    pub text_slice: &'a str,
1602    pub style: Arc<StyleProperties>,
1603    pub logical_start_byte: usize,
1604    pub bidi_level: BidiLevel,
1605    pub script: Script,
1606    pub language: Language,
1607}
1608
1609// Font and styling types
1610
1611/// A selector for loading fonts from the font cache.
1612/// Used by `FontManager` to query fontconfig and load font files.
1613#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
1614pub struct FontSelector {
1615    pub family: String,
1616    pub weight: FcWeight,
1617    pub style: FontStyle,
1618    pub unicode_ranges: Vec<UnicodeRange>,
1619}
1620
1621impl Default for FontSelector {
1622    fn default() -> Self {
1623        Self {
1624            family: "serif".to_string(),
1625            weight: FcWeight::Normal,
1626            style: FontStyle::Normal,
1627            unicode_ranges: Vec::new(),
1628        }
1629    }
1630}
1631
1632/// Font stack that can be either a list of font selectors (resolved via fontconfig)
1633/// or a direct `FontRef` (bypasses fontconfig entirely).
1634///
1635/// When a `FontRef` is used, it bypasses fontconfig resolution entirely
1636/// and uses the pre-parsed font data directly. This is used for embedded
1637/// fonts like Material Icons.
1638// [g121 az-web-lift] `#[repr(C, u8)]` — same disc-mis-lift guard as the other text3 enums; matched in
1639// shape_visual_items (`match &style.font_stack { Ref => shape, Stack => resolve }`). repr(Rust) niche
1640// (from the Vec/FontRef payloads) could mis-route. Explicit u8 tag = simple load. Internal to text3.
1641#[derive(Debug, Clone)]
1642#[repr(C, u8)]
1643pub enum FontStack {
1644    /// A stack of font selectors to be resolved via fontconfig
1645    /// First font is primary, rest are fallbacks
1646    Stack(Vec<FontSelector>),
1647    /// A direct reference to a pre-parsed font (e.g., embedded icon fonts)
1648    /// This font covers the entire Unicode range and has no fallbacks.
1649    Ref(azul_css::props::basic::font::FontRef),
1650}
1651
1652impl Default for FontStack {
1653    fn default() -> Self {
1654        Self::Stack(vec![FontSelector::default()])
1655    }
1656}
1657
1658impl FontStack {
1659    /// Returns true if this is a direct `FontRef`
1660    #[must_use] pub const fn is_ref(&self) -> bool {
1661        matches!(self, Self::Ref(_))
1662    }
1663
1664    /// Returns the `FontRef` if this is a Ref variant
1665    #[must_use] pub const fn as_ref(&self) -> Option<&azul_css::props::basic::font::FontRef> {
1666        match self {
1667            Self::Ref(r) => Some(r),
1668            Self::Stack(_) => None,
1669        }
1670    }
1671
1672    /// Returns the font selectors if this is a Stack variant
1673    #[must_use] pub fn as_stack(&self) -> Option<&[FontSelector]> {
1674        match self {
1675            Self::Stack(s) => Some(s),
1676            Self::Ref(_) => None,
1677        }
1678    }
1679
1680    /// Returns the first `FontSelector` if this is a Stack variant, None if Ref
1681    #[must_use] pub fn first_selector(&self) -> Option<&FontSelector> {
1682        match self {
1683            Self::Stack(s) => s.first(),
1684            Self::Ref(_) => None,
1685        }
1686    }
1687
1688    /// Returns the first font family name (for Stack) or a placeholder (for Ref)
1689    #[must_use] pub fn first_family(&self) -> &str {
1690        match self {
1691            Self::Stack(s) => s.first().map_or("serif", |f| f.family.as_str()),
1692            Self::Ref(_) => "<embedded-font>",
1693        }
1694    }
1695}
1696
1697impl PartialEq for FontStack {
1698    fn eq(&self, other: &Self) -> bool {
1699        match (self, other) {
1700            (Self::Stack(a), Self::Stack(b)) => a == b,
1701            (Self::Ref(a), Self::Ref(b)) => a.parsed == b.parsed,
1702            _ => false,
1703        }
1704    }
1705}
1706
1707impl Eq for FontStack {}
1708
1709impl Hash for FontStack {
1710    fn hash<H: Hasher>(&self, state: &mut H) {
1711        discriminant(self).hash(state);
1712        match self {
1713            Self::Stack(s) => s.hash(state),
1714            Self::Ref(r) => (r.parsed as usize).hash(state),
1715        }
1716    }
1717}
1718
1719/// A reference to a font for rendering, identified by its hash.
1720/// This hash corresponds to `ParsedFont::hash` and is used to look up
1721/// the actual font data in the renderer's font cache.
1722#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
1723pub struct FontHash {
1724    /// The hash of the `ParsedFont`. 0 means invalid/unknown font.
1725    pub font_hash: u64,
1726}
1727
1728impl FontHash {
1729    #[must_use] pub const fn invalid() -> Self {
1730        Self { font_hash: 0 }
1731    }
1732
1733    #[must_use] pub const fn from_hash(font_hash: u64) -> Self {
1734        Self { font_hash }
1735    }
1736}
1737
1738#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
1739pub enum FontStyle {
1740    Normal,
1741    Italic,
1742    Oblique,
1743}
1744
1745/// Defines how text should be aligned when a line contains multiple disjoint segments.
1746#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
1747pub enum SegmentAlignment {
1748    /// Align text within the first available segment on the line.
1749    #[default]
1750    First,
1751    /// Align text relative to the total available width of all
1752    /// segments on the line combined.
1753    Total,
1754}
1755
1756#[derive(Copy, Debug, Clone)]
1757pub struct VerticalMetrics {
1758    pub advance: f32,
1759    pub bearing_x: f32,
1760    pub bearing_y: f32,
1761    pub origin_y: f32,
1762}
1763
1764// +spec:font-metrics:df51b1 - font metrics (ascent, descent, line_gap) used as baselines for inline layout alignment and box sizing
1765/// Layout-specific font metrics extracted from `FontMetrics`
1766/// Contains only the metrics needed for text layout and rendering
1767// +spec:box-model:a2f1c1 - inline box content area sized from first available font metrics (ascent/descent)
1768// +spec:font-metrics:9c2ca5 - ascent and descent metrics per font for inline layout
1769// +spec:font-metrics:797593 - font metrics (ascent, descent, line-gap) used for baseline calculations
1770// +spec:font-metrics:842d6a - font metrics (ascent, descent) used for precise spacing control
1771// +spec:font-metrics:eb97e0 - Font baseline metrics (ascent/descent) from font tables used for baseline alignment
1772// +spec:font-metrics:f2cd75 - em-over/em-under baselines intentionally not included (not used by CSS per spec)
1773// +spec:inline-formatting-context:76cd57 - ascent/descent font metrics for inline formatting context layout
1774// +spec:font-metrics:207e6b - ascent/descent metrics used for baseline calculations
1775#[derive(Copy, Debug, Clone)]
1776pub struct LayoutFontMetrics {
1777    pub ascent: f32,
1778    pub descent: f32,
1779    pub line_gap: f32,
1780    pub units_per_em: u16,
1781    /// OS/2 sxHeight: distance from baseline to top of lowercase 'x' (in font units).
1782    /// Used for `vertical-align: middle` per CSS Inline 3 §4.1.
1783    pub x_height: Option<f32>,
1784    /// OS/2 sCapHeight: height of capital letters from baseline (in font units).
1785    /// Used for drop cap / initial-letter alignment per CSS Inline 3 §7.1.1.
1786    pub cap_height: Option<f32>,
1787}
1788
1789impl LayoutFontMetrics {
1790    // +spec:font-metrics:006bd8 - baseline position from font design coordinates, scaled with font size
1791    // +spec:font-metrics:910c0a - dominant-baseline: auto resolves to alphabetic for horizontal text
1792    // +spec:writing-modes:098958 - baseline is along the inline axis, used to align glyphs
1793    #[must_use] pub fn baseline_scaled(&self, font_size: f32) -> f32 {
1794        let scale = font_size / f32::from(self.units_per_em);
1795        self.ascent * scale
1796    }
1797
1798    /// Returns the x-height scaled to the given font size in px.
1799    /// Falls back to 0.5em when the font doesn't provide sxHeight.
1800    #[must_use] pub fn x_height_scaled(&self, font_size: f32) -> f32 {
1801        let scale = font_size / f32::from(self.units_per_em);
1802        self.x_height.map_or(font_size * 0.5, |xh| xh * scale)
1803    }
1804
1805    /// Returns the cap height scaled to the given font size in px.
1806    /// Falls back to ascent when the font doesn't provide sCapHeight.
1807    #[must_use] pub fn cap_height_scaled(&self, font_size: f32) -> f32 {
1808        let scale = font_size / f32::from(self.units_per_em);
1809        self.cap_height.unwrap_or(self.ascent) * scale
1810    }
1811
1812    // +spec:line-height:471816 - line gap metric extracted from font for optional use when line-height is normal
1813    /// Convert from full `FontMetrics` to layout-specific metrics.
1814    ///
1815    // +spec:font-metrics:05193a - prefer OS/2 sTypoAscender/sTypoDescender, fall back to HHEA
1816    // +spec:font-metrics:17a71c - prefer OS/2 sTypoAscender/sTypoDescender, fall back to HHEA
1817    // +spec:font-metrics:62c659 - prefer OS/2 sTypoAscender/sTypoDescender, fall back to HHEA
1818    // +spec:writing-modes:451a3e - ascent/descent/line-gap metrics: prefer OS/2, fallback HHEA, floor line_gap at 0
1819    /// Per CSS 2.2 §10.8.1: prefer OS/2 sTypoAscender/sTypoDescender,
1820    /// fall back to HHEA Ascent/Descent if OS/2 metrics are absent.
1821    // +spec:font-metrics:3dc8c1 - text-over/text-under baselines from font ascent/descent metrics
1822    // +spec:font-metrics:332c16 - text-over/text-under baseline metrics derived from font ascent/descent
1823    // +spec:font-metrics:9895e2 - baseline table is a font-level property; metrics apply uniformly to all glyphs
1824    // +spec:font-metrics:e05c40 - font ascent/descent metric extraction (text edge metrics)
1825    // +spec:font-metrics:21a3de - ascent/descent used as basis for em-over/em-under normalization
1826    // +spec:font-metrics:1257b7 - font ascent/descent ensure text fits within line box
1827    // +spec:table-layout:6bbd10 - use sTypoAscender/sTypoDescender as ascent/descent metrics per spec recommendation
1828    // +spec:font-metrics:5346d2 - prefer OS/2 sTypoAscender/sTypoDescender, fall back to HHEA
1829    // +spec:font-metrics:e16941 - line gap metric floored at zero per spec
1830    // +spec:font-metrics:a55c05 - metrics taken from font, synthesized if missing (prefers OS/2, falls back to HHEA)
1831    #[must_use] pub fn from_font_metrics(metrics: &azul_css::props::basic::FontMetrics) -> Self {
1832        let ascent = metrics.s_typo_ascender
1833            .as_option()
1834            .map_or_else(|| f32::from(metrics.ascender), |v| f32::from(*v));
1835        let descent = metrics.s_typo_descender
1836            .as_option()
1837            .map_or_else(|| f32::from(metrics.descender), |v| f32::from(*v));
1838        // UAs must floor the line gap metric at zero (css-inline-3 §3.2.2)
1839        // Spec: "UAs must floor the line gap metric at zero."
1840        let line_gap = metrics.s_typo_line_gap
1841            .as_option()
1842            .map_or_else(|| f32::from(metrics.line_gap), |v| f32::from(*v))
1843            .max(0.0);
1844        let x_height = metrics.sx_height
1845            .as_option()
1846            .map(|v| f32::from(*v));
1847        let cap_height = metrics.s_cap_height
1848            .as_option()
1849            .map(|v| f32::from(*v));
1850        Self {
1851            ascent,
1852            descent,
1853            line_gap,
1854            units_per_em: metrics.units_per_em,
1855            x_height,
1856            cap_height,
1857        }
1858    }
1859
1860    // +spec:font-metrics:1eda6b - em-over is 0.5em over central baseline, em-under is 0.5em under
1861    /// Synthesize em-over baseline offset (in font units).
1862    /// Per CSS Inline 3 Appendix A.1: em-over = central baseline + 0.5em.
1863    /// Central baseline is synthesized as midpoint of ascent and descent.
1864    #[must_use] pub fn em_over(&self) -> f32 {
1865        let central = self.central_baseline();
1866        central + (f32::from(self.units_per_em) / 2.0)
1867    }
1868
1869    /// Synthesize em-under baseline offset (in font units).
1870    /// Per CSS Inline 3 Appendix A.1: em-under = central baseline - 0.5em.
1871    #[must_use] pub fn em_under(&self) -> f32 {
1872        let central = self.central_baseline();
1873        central - (f32::from(self.units_per_em) / 2.0)
1874    }
1875
1876    /// Synthesize central baseline (in font units).
1877    /// Midpoint between ascent and descent when not provided by the font.
1878    #[must_use] pub const fn central_baseline(&self) -> f32 {
1879        f32::midpoint(self.ascent, self.descent)
1880    }
1881}
1882
1883#[derive(Copy, Debug, Clone)]
1884pub struct LineSegment {
1885    pub start_x: f32,
1886    pub width: f32,
1887    // For choosing best segment when multiple available
1888    pub priority: u8,
1889}
1890
1891#[derive(Debug, Clone, Copy, PartialEq, Hash, Eq, PartialOrd, Ord, Default)]
1892pub enum TextWrap {
1893    #[default]
1894    Wrap,
1895    Balance,
1896    NoWrap,
1897}
1898
1899/// CSS `overflow-wrap` (aka `word-wrap`) property.
1900///
1901/// Controls whether an otherwise unbreakable sequence of characters
1902/// may be broken at an arbitrary point to prevent overflow.
1903#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
1904pub enum OverflowWrap {
1905    /// No special break opportunities are introduced.
1906    #[default]
1907    Normal,
1908    /// Break at arbitrary points if no other break points exist.
1909    /// Soft wrap opportunities from `anywhere` ARE considered
1910    /// when calculating min-content intrinsic sizes.
1911    Anywhere,
1912    /// Same as `anywhere` except soft wrap opportunities introduced
1913    /// by `break-word` are NOT considered when calculating
1914    /// min-content intrinsic sizes.
1915    BreakWord,
1916}
1917
1918// +spec:line-breaking:841a87 - hyphens property: manual (U+00AD/U+2010 only) and auto (language-aware automatic hyphenation)
1919// +spec:line-breaking:68c6ad - hyphens property controls hyphenation opportunities (none/manual/auto)
1920/// Controls whether hyphenation is allowed to create soft wrap opportunities.
1921#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
1922pub enum Hyphens {
1923    /// No hyphenation: U+00AD soft hyphens are not treated as break points.
1924    None,
1925    /// Only break at manually-inserted soft hyphens (U+00AD) or explicit hyphens.
1926    #[default]
1927    Manual,
1928    /// The UA may automatically hyphenate words in addition to manual opportunities.
1929    Auto,
1930}
1931
1932// +spec:line-breaking:ce5258 - white-space property controls collapsing, wrapping, and forced breaks
1933// +spec:line-breaking:35817b - normal/pre/nowrap/pre-wrap/break-spaces/pre-line behaviors
1934// +spec:white-space-processing:dec7aa - White space not removed/collapsed is "preserved white space"
1935#[derive(Debug, Clone, Copy, PartialEq, Hash, Eq, PartialOrd, Ord, Default)]
1936pub enum WhiteSpaceMode {
1937    #[default]
1938    Normal,
1939    Nowrap,
1940    Pre,
1941    PreWrap,
1942    PreLine,
1943    BreakSpaces,
1944}
1945
1946// CSS Text Level 3 §5.3: The line-break property controls strictness of line breaking rules.
1947// - Auto: UA-dependent, typically normal for CJK, loose for non-CJK
1948// - Loose: least restrictive, allows breaks before small kana, CJK hyphens, etc.
1949// - Normal: default CJK rules, allows breaks before CJK hyphen-like chars for CJK text
1950// - Strict: most restrictive, forbids breaks before small kana and CJK punctuation
1951// - Anywhere: allows soft wrap opportunities around every typographic character unit
1952#[derive(Debug, Clone, Copy, PartialEq, Hash, Eq, PartialOrd, Ord, Default)]
1953pub enum LineBreakStrictness {
1954    #[default]
1955    Auto,
1956    Loose,
1957    Normal,
1958    Strict,
1959    /// Soft wrap opportunity around every typographic character unit.
1960    /// Hyphenation is not applied.
1961    Anywhere,
1962}
1963
1964// §5.2 word-break property: normal, break-all, keep-all
1965#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
1966pub enum WordBreak {
1967    /// Normal break rules: CJK characters break between each other,
1968    /// non-CJK text only breaks at spaces/hyphens.
1969    #[default]
1970    Normal,
1971    /// Allow breaks between any two characters, including within Latin words.
1972    BreakAll,
1973    /// Suppress breaks between CJK characters (treat them like Latin words,
1974    /// only breaking at spaces). Sequences of CJK characters do not break.
1975    KeepAll,
1976}
1977
1978// +spec:display-property:162c99 - Initial letter box: in-flow inline-level box with special layout behavior
1979// +spec:display-property:72a797 - Initial letter handled like inline-level content in originating line box
1980// initial-letter
1981// +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
1982// +spec:font-metrics:1e5325 - drop initial cap-height = (N-1)*line_height + surrounding cap-height
1983// +spec:font-metrics:3aa518 - initial-letter-align: cap-height/ideographic/hanging/leading/border-box baseline alignment
1984// +spec:writing-modes:9698b0 - Han-derived scripts: initial letter extends from block-start to block-end of Nth line
1985#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
1986pub struct InitialLetter {
1987    /// How many lines tall the initial letter should be.
1988    pub size: f32,
1989    // +spec:font-metrics:dc0632 - raised initial "sinks" to first text baseline (sink=1)
1990    /// How many lines the letter should sink into.
1991    pub sink: u32,
1992    /// How many characters to apply this styling to.
1993    pub count: NonZeroUsize,
1994    // +spec:display-property:4c69bf - alignment points for sizing/positioning initial letter
1995    /// Alignment mode for the initial letter (over/under alignment points
1996    /// matched to corresponding points of the root inline box).
1997    pub align: InitialLetterAlign,
1998}
1999
2000/// Alignment mode for initial letters, controlling which alignment points
2001/// are used to size and position the letter relative to the root inline box.
2002#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
2003pub enum InitialLetterAlign {
2004    /// UA chooses based on script
2005    Auto,
2006    /// Alphabetic baseline alignment
2007    Alphabetic,
2008    /// Hanging baseline alignment
2009    Hanging,
2010    /// Ideographic baseline alignment
2011    Ideographic,
2012}
2013
2014// A type that implements `Hash` must also implement `Eq`.
2015// Since f32 does not implement `Eq`, we provide a manual implementation.
2016// This is a marker trait, indicating that `a == b` is a true equivalence
2017// relation. The derived `PartialEq` already satisfies this.
2018impl Eq for InitialLetter {}
2019
2020impl Hash for InitialLetter {
2021    #[allow(clippy::cast_possible_truncation)] // bounded pixel/coord/colour/glyph cast
2022    fn hash<H: Hasher>(&self, state: &mut H) {
2023        // Per the request, round the f32 to a usize for hashing.
2024        // This is a lossy conversion; values like 2.3 and 2.4 will produce
2025        // the same hash value for this field. This is acceptable as long as
2026        // the `PartialEq` implementation correctly distinguishes them.
2027        (self.size.round() as isize).hash(state);
2028        self.sink.hash(state);
2029        self.count.hash(state);
2030        self.align.hash(state);
2031    }
2032}
2033
2034// Path and shape definitions
2035#[derive(Copy, Debug, Clone, PartialOrd)]
2036pub enum PathSegment {
2037    MoveTo(Point),
2038    LineTo(Point),
2039    CurveTo {
2040        control1: Point,
2041        control2: Point,
2042        end: Point,
2043    },
2044    QuadTo {
2045        control: Point,
2046        end: Point,
2047    },
2048    Arc {
2049        center: Point,
2050        radius: f32,
2051        start_angle: f32,
2052        end_angle: f32,
2053    },
2054    Close,
2055}
2056
2057// PathSegment
2058impl Hash for PathSegment {
2059    #[allow(clippy::cast_possible_truncation)] // bounded pixel/coord/colour/glyph cast
2060    #[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
2061    fn hash<H: Hasher>(&self, state: &mut H) {
2062        // Hash the enum variant's discriminant first to distinguish them
2063        discriminant(self).hash(state);
2064
2065        match self {
2066            Self::MoveTo(p) => p.hash(state),
2067            Self::LineTo(p) => p.hash(state),
2068            Self::CurveTo {
2069                control1,
2070                control2,
2071                end,
2072            } => {
2073                control1.hash(state);
2074                control2.hash(state);
2075                end.hash(state);
2076            }
2077            Self::QuadTo { control, end } => {
2078                control.hash(state);
2079                end.hash(state);
2080            }
2081            Self::Arc {
2082                center,
2083                radius,
2084                start_angle,
2085                end_angle,
2086            } => {
2087                center.hash(state);
2088                (radius.round() as isize).hash(state);
2089                (start_angle.round() as isize).hash(state);
2090                (end_angle.round() as isize).hash(state);
2091            }
2092            Self::Close => {} // No data to hash
2093        }
2094    }
2095}
2096
2097impl PartialEq for PathSegment {
2098    #[allow(clippy::similar_names)] // domain-standard coordinate/geometry/short-lived names
2099    #[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
2100    fn eq(&self, other: &Self) -> bool {
2101        match (self, other) {
2102            (Self::MoveTo(a), Self::MoveTo(b)) => a == b,
2103            (Self::LineTo(a), Self::LineTo(b)) => a == b,
2104            (
2105                Self::CurveTo {
2106                    control1: c1a,
2107                    control2: c2a,
2108                    end: ea,
2109                },
2110                Self::CurveTo {
2111                    control1: c1b,
2112                    control2: c2b,
2113                    end: eb,
2114                },
2115            ) => c1a == c1b && c2a == c2b && ea == eb,
2116            (
2117                Self::QuadTo {
2118                    control: ca,
2119                    end: ea,
2120                },
2121                Self::QuadTo {
2122                    control: cb,
2123                    end: eb,
2124                },
2125            ) => ca == cb && ea == eb,
2126            (
2127                Self::Arc {
2128                    center: ca,
2129                    radius: ra,
2130                    start_angle: sa_a,
2131                    end_angle: ea_a,
2132                },
2133                Self::Arc {
2134                    center: cb,
2135                    radius: rb,
2136                    start_angle: sa_b,
2137                    end_angle: ea_b,
2138                },
2139            ) => ca == cb && round_eq(*ra, *rb) && round_eq(*sa_a, *sa_b) && round_eq(*ea_a, *ea_b),
2140            (Self::Close, Self::Close) => true,
2141            _ => false, // Variants are different
2142        }
2143    }
2144}
2145
2146impl Eq for PathSegment {}
2147
2148// Enhanced content model supporting mixed inline content
2149// [g117 az-web-lift FIX] `#[repr(C, u8)]` (was repr(Rust)): the web lift MIS-READS a repr(Rust)
2150// niche/compiler-placed discriminant — `<InlineContent as Clone>::clone` and create_logical_items'
2151// match both mis-route a Text(disc 0) to a Vec-bearing variant → clone reads a heap ptr as a Vec len
2152// → ~789MB alloc → OOB (g111/g115/g116 named stack = InlineContent::clone ← create_logical_items;
2153// content is CLEAN: len=1, ptr ok, disc-at-0=0). An explicit u8 tag at offset 0 (no niche) lowers to
2154// a simple load the lift handles correctly — the layout other (repr(C,u8)) enums use. Not FFI-exposed
2155// (internal to text3; only native shell code matches it), so the repr change is layout-safe.
2156#[derive(Debug, Clone, Hash)]
2157#[repr(C, u8)]
2158pub enum InlineContent {
2159    Text(StyledRun),
2160    Image(InlineImage),
2161    Shape(InlineShape),
2162    Space(InlineSpace),
2163    LineBreak(InlineBreak),
2164    /// Tab character - rendered with width based on tab-size CSS property
2165    Tab {
2166        style: Arc<StyleProperties>,
2167    },
2168    /// List marker (`::marker` pseudo-element)
2169    /// Markers with list-style-position: outside are positioned
2170    /// in the padding gutter of the list container
2171    Marker {
2172        run: StyledRun,
2173        /// Whether marker is positioned outside (in padding) or inside (inline)
2174        position_outside: bool,
2175    },
2176    // Ruby annotation
2177    Ruby {
2178        base: Vec<InlineContent>,
2179        text: Vec<InlineContent>,
2180        // Style for the ruby text itself
2181        style: Arc<StyleProperties>,
2182    },
2183}
2184
2185#[derive(Debug, Clone)]
2186pub struct InlineImage {
2187    pub source: ImageSource,
2188    pub intrinsic_size: Size,
2189    pub display_size: Option<Size>,
2190    // How much to shift baseline
2191    pub baseline_offset: f32,
2192    pub alignment: VerticalAlign,
2193    pub object_fit: ObjectFit,
2194}
2195
2196impl PartialEq for InlineImage {
2197    fn eq(&self, other: &Self) -> bool {
2198        self.baseline_offset.to_bits() == other.baseline_offset.to_bits()
2199            && self.source == other.source
2200            && self.intrinsic_size == other.intrinsic_size
2201            && self.display_size == other.display_size
2202            && self.alignment == other.alignment
2203            && self.object_fit == other.object_fit
2204    }
2205}
2206
2207impl Eq for InlineImage {}
2208
2209impl Hash for InlineImage {
2210    fn hash<H: Hasher>(&self, state: &mut H) {
2211        self.source.hash(state);
2212        self.intrinsic_size.hash(state);
2213        self.display_size.hash(state);
2214        self.baseline_offset.to_bits().hash(state);
2215        self.alignment.hash(state);
2216        self.object_fit.hash(state);
2217    }
2218}
2219
2220impl PartialOrd for InlineImage {
2221    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
2222        Some(self.cmp(other))
2223    }
2224}
2225
2226impl Ord for InlineImage {
2227    fn cmp(&self, other: &Self) -> Ordering {
2228        self.source
2229            .cmp(&other.source)
2230            .then_with(|| self.intrinsic_size.cmp(&other.intrinsic_size))
2231            .then_with(|| self.display_size.cmp(&other.display_size))
2232            .then_with(|| self.baseline_offset.total_cmp(&other.baseline_offset))
2233            .then_with(|| self.alignment.cmp(&other.alignment))
2234            .then_with(|| self.object_fit.cmp(&other.object_fit))
2235    }
2236}
2237
2238/// Enhanced glyph with all features
2239#[derive(Debug, Clone)]
2240pub struct Glyph {
2241    // Core glyph data
2242    pub glyph_id: u16,
2243    pub codepoint: char,
2244    /// Hash of the font - use `LoadedFonts` to look up the actual font when needed
2245    pub font_hash: u64,
2246    /// Cached font metrics to avoid font lookup for common operations
2247    pub font_metrics: LayoutFontMetrics,
2248    pub style: Arc<StyleProperties>,
2249    pub source: GlyphSource,
2250
2251    // Text mapping
2252    pub logical_byte_index: usize,
2253    pub logical_byte_len: usize,
2254    pub content_index: usize,
2255    pub cluster: u32,
2256
2257    // Metrics
2258    pub advance: f32,
2259    pub kerning: f32,
2260    pub offset: Point,
2261
2262    // Vertical text support
2263    pub vertical_advance: f32,
2264    pub vertical_origin_y: f32, // from VORG
2265    pub vertical_bearing: Point,
2266    pub orientation: GlyphOrientation,
2267
2268    // Layout properties
2269    pub script: Script,
2270    pub bidi_level: BidiLevel,
2271}
2272
2273impl Glyph {
2274    #[inline]
2275    fn bounds(&self) -> Rect {
2276        Rect {
2277            x: 0.0,
2278            y: 0.0,
2279            width: self.advance,
2280            height: self.style.line_height.resolve_with_metrics(self.style.font_size_px, &self.font_metrics),
2281        }
2282    }
2283
2284    #[inline]
2285    const fn character_class(&self) -> CharacterClass {
2286        classify_character(self.codepoint as u32)
2287    }
2288
2289    #[inline]
2290    fn is_whitespace(&self) -> bool {
2291        self.character_class() == CharacterClass::Space
2292    }
2293
2294    #[inline]
2295    fn can_justify(&self) -> bool {
2296        !self.codepoint.is_whitespace() && self.character_class() != CharacterClass::Combining
2297    }
2298
2299    #[inline]
2300    const fn justification_priority(&self) -> u8 {
2301        get_justification_priority(self.character_class())
2302    }
2303
2304    #[inline]
2305    const fn break_opportunity_after(&self) -> bool {
2306        let is_whitespace = self.codepoint.is_whitespace();
2307        let is_soft_hyphen = self.codepoint == '\u{00AD}';
2308        let is_hyphen_minus = self.codepoint == '\u{002D}';
2309        let is_hyphen = self.codepoint == '\u{2010}';
2310        is_whitespace || is_soft_hyphen || is_hyphen_minus || is_hyphen
2311    }
2312}
2313
2314// Information about text runs after initial analysis
2315#[derive(Debug, Clone)]
2316pub(crate) struct TextRunInfo<'a> {
2317    pub(crate) text: &'a str,
2318    pub(crate) style: Arc<StyleProperties>,
2319    pub(crate) logical_start: usize,
2320    pub(crate) content_index: usize,
2321}
2322
2323#[derive(Debug, Clone)]
2324pub enum ImageSource {
2325    /// Direct reference to decoded image (from DOM `NodeType::Image`)
2326    Ref(ImageRef),
2327    /// CSS url reference (from background-image, needs `ImageCache` lookup)
2328    Url(String),
2329    /// Raw image data
2330    Data(Arc<[u8]>),
2331    /// SVG source
2332    Svg(Arc<str>),
2333    /// Placeholder for layout without actual image
2334    Placeholder(Size),
2335}
2336
2337impl PartialEq for ImageSource {
2338    fn eq(&self, other: &Self) -> bool {
2339        match (self, other) {
2340            (Self::Ref(a), Self::Ref(b)) => a.get_hash() == b.get_hash(),
2341            (Self::Url(a), Self::Url(b)) => a == b,
2342            (Self::Data(a), Self::Data(b)) => Arc::ptr_eq(a, b),
2343            (Self::Svg(a), Self::Svg(b)) => Arc::ptr_eq(a, b),
2344            (Self::Placeholder(a), Self::Placeholder(b)) => {
2345                a.width.to_bits() == b.width.to_bits() && a.height.to_bits() == b.height.to_bits()
2346            }
2347            _ => false,
2348        }
2349    }
2350}
2351
2352impl Eq for ImageSource {}
2353
2354impl Hash for ImageSource {
2355    fn hash<H: Hasher>(&self, state: &mut H) {
2356        discriminant(self).hash(state);
2357        match self {
2358            Self::Ref(r) => r.get_hash().hash(state),
2359            Self::Url(s) => s.hash(state),
2360            Self::Data(d) => (Arc::as_ptr(d).cast::<u8>() as usize).hash(state),
2361            Self::Svg(s) => (Arc::as_ptr(s).cast::<u8>() as usize).hash(state),
2362            Self::Placeholder(sz) => {
2363                sz.width.to_bits().hash(state);
2364                sz.height.to_bits().hash(state);
2365            }
2366        }
2367    }
2368}
2369
2370impl PartialOrd for ImageSource {
2371    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
2372        Some(self.cmp(other))
2373    }
2374}
2375
2376impl Ord for ImageSource {
2377    fn cmp(&self, other: &Self) -> Ordering {
2378        const fn variant_index(s: &ImageSource) -> u8 {
2379            match s {
2380                ImageSource::Ref(_) => 0,
2381                ImageSource::Url(_) => 1,
2382                ImageSource::Data(_) => 2,
2383                ImageSource::Svg(_) => 3,
2384                ImageSource::Placeholder(_) => 4,
2385            }
2386        }
2387        match (self, other) {
2388            (Self::Ref(a), Self::Ref(b)) => a.get_hash().cmp(&b.get_hash()),
2389            (Self::Url(a), Self::Url(b)) => a.cmp(b),
2390            (Self::Data(a), Self::Data(b)) => {
2391                (Arc::as_ptr(a).cast::<u8>() as usize).cmp(&(Arc::as_ptr(b).cast::<u8>() as usize))
2392            }
2393            (Self::Svg(a), Self::Svg(b)) => {
2394                (Arc::as_ptr(a).cast::<u8>() as usize).cmp(&(Arc::as_ptr(b).cast::<u8>() as usize))
2395            }
2396            (Self::Placeholder(a), Self::Placeholder(b)) => {
2397                (a.width.to_bits(), a.height.to_bits())
2398                    .cmp(&(b.width.to_bits(), b.height.to_bits()))
2399            }
2400            // Different variants: compare by variant index
2401            _ => variant_index(self).cmp(&variant_index(other)),
2402        }
2403    }
2404}
2405
2406// +spec:font-metrics:fa104e - vertical-align values; baseline-source defaults to auto (first baseline)
2407// +spec:inline-formatting-context:340729 - alignment-baseline values for IFC baseline alignment (only baseline/top/bottom/middle implemented)
2408// CSS 2.2 §10.8.1 vertical-align property values
2409// +spec:display-property:0b1deb - inline boxes use dominant baseline to align text and inline-level children
2410// +spec:inline-formatting-context:3996a6 - dominant-baseline defaults to alphabetic in horizontal mode; vertical-align handles baseline alignment and super/sub shifting
2411#[derive(Default, Debug, Clone, Copy, PartialEq, PartialOrd)]
2412pub enum VerticalAlign {
2413    // Align baseline of box with baseline of parent box
2414    #[default]
2415    Baseline,
2416    // Align bottom of aligned subtree with bottom of line box
2417    Bottom,
2418    // Align top of aligned subtree with top of line box
2419    Top,
2420    // Align vertical midpoint of box with baseline of parent plus half x-height
2421    Middle,
2422    // Align top of box with top of parent's content area (§10.6.1)
2423    TextTop,
2424    // Align bottom of box with bottom of parent's content area (§10.6.1)
2425    TextBottom,
2426    // Lower baseline to proper subscript position
2427    Sub,
2428    // Raise baseline to proper superscript position
2429    Super,
2430    // +spec:font-metrics:152df3 - Raise (positive) or lower (negative) by this distance; 0 = baseline
2431    Offset(f32),
2432}
2433
2434impl Hash for VerticalAlign {
2435    fn hash<H: Hasher>(&self, state: &mut H) {
2436        discriminant(self).hash(state);
2437        if let Self::Offset(f) = self {
2438            f.to_bits().hash(state);
2439        }
2440    }
2441}
2442
2443impl Eq for VerticalAlign {}
2444
2445// cmp delegates to the derived PartialOrd (unwrap_or(Equal)), so Ord and PartialOrd are
2446// consistent; Ord can't be derived because of the f32 `Offset` variant.
2447#[allow(clippy::derive_ord_xor_partial_ord)]
2448impl Ord for VerticalAlign {
2449    fn cmp(&self, other: &Self) -> Ordering {
2450        self.partial_cmp(other).unwrap_or(Ordering::Equal)
2451    }
2452}
2453
2454#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, PartialOrd, Ord)]
2455pub enum ObjectFit {
2456    // Stretch to fit display size
2457    Fill,
2458    // Scale to fit within display size
2459    Contain,
2460    // Scale to cover display size
2461    Cover,
2462    // Use intrinsic size
2463    None,
2464    // Like contain but never scale up
2465    ScaleDown,
2466}
2467
2468/// Border information for inline elements (display: inline, inline-block)
2469///
2470/// This stores the resolved border properties needed for rendering inline element borders.
2471/// Unlike block elements which render borders via `paint_node_background_and_border()`,
2472/// inline element borders must be rendered per glyph-run to handle line breaks correctly.
2473#[derive(Copy, Debug, Clone, PartialEq)]
2474pub struct InlineBorderInfo {
2475    /// Border widths in pixels for each side
2476    pub top: f32,
2477    pub right: f32,
2478    pub bottom: f32,
2479    pub left: f32,
2480    /// Border colors for each side
2481    pub top_color: ColorU,
2482    pub right_color: ColorU,
2483    pub bottom_color: ColorU,
2484    pub left_color: ColorU,
2485    /// Border radius (if any)
2486    pub radius: Option<f32>,
2487    /// Padding widths in pixels for each side (needed to expand background rect)
2488    pub padding_top: f32,
2489    pub padding_right: f32,
2490    pub padding_bottom: f32,
2491    pub padding_left: f32,
2492    // +spec:box-model:c5723b - inline box split: suppress margin/border/padding at split points
2493    /// CSS 2.2 §9.4.2 / §8.6: when an inline box is split across line boxes,
2494    /// margins, borders, and padding have no visible effect at the split points.
2495    /// True if this is the first fragment of the inline box.
2496    pub is_first_fragment: bool,
2497    /// True if this is the last fragment of the inline box.
2498    pub is_last_fragment: bool,
2499    /// CSS 2.2 §8.6: direction flag for visual-order rendering in bidi context.
2500    /// LTR: first fragment gets left edge, last gets right edge.
2501    /// RTL: first fragment gets right edge, last gets left edge.
2502    pub is_rtl: bool,
2503}
2504
2505impl Default for InlineBorderInfo {
2506    fn default() -> Self {
2507        Self {
2508            top: 0.0,
2509            right: 0.0,
2510            bottom: 0.0,
2511            left: 0.0,
2512            top_color: ColorU::TRANSPARENT,
2513            right_color: ColorU::TRANSPARENT,
2514            bottom_color: ColorU::TRANSPARENT,
2515            left_color: ColorU::TRANSPARENT,
2516            radius: None,
2517            padding_top: 0.0,
2518            padding_right: 0.0,
2519            padding_bottom: 0.0,
2520            padding_left: 0.0,
2521            is_first_fragment: true,
2522            is_last_fragment: true,
2523            is_rtl: false,
2524        }
2525    }
2526}
2527
2528impl InlineBorderInfo {
2529    /// Returns true if any border has a non-zero width
2530    #[must_use] pub fn has_border(&self) -> bool {
2531        self.top > 0.0 || self.right > 0.0 || self.bottom > 0.0 || self.left > 0.0
2532    }
2533
2534    /// Returns true if any border or padding is present
2535    #[must_use] pub fn has_chrome(&self) -> bool {
2536        self.has_border()
2537            || self.padding_top > 0.0
2538            || self.padding_right > 0.0
2539            || self.padding_bottom > 0.0
2540            || self.padding_left > 0.0
2541    }
2542
2543    // +spec:box-model:da0ba2 - RTL bidi inline box split: left/right edges assigned to correct fragments
2544    // +spec:box-model:e9144f - visual-order margin/border/padding for inline boxes in bidi context
2545    // +spec:box-model:fac66f - Assigns margins/borders/padding in visual order for bidi inline fragments
2546    // +spec:box-model:720688 - LTR: left on first, right on last; RTL: right on first, left on last
2547    // +spec:positioning:1fcad6 - bidi-aware margin/border/padding on inline box fragments per visual order
2548    /// Total left inset (border + padding), suppressed at split points per §8.6.
2549    /// In LTR: left edge drawn on first fragment. In RTL: left edge drawn on last fragment.
2550    // +spec:box-model:bae97f - visual-order margin/border/padding assignment for bidi inline fragments
2551    #[must_use] pub fn left_inset(&self) -> f32 {
2552        let show = if self.is_rtl { self.is_last_fragment } else { self.is_first_fragment };
2553        if show { self.left + self.padding_left } else { 0.0 }
2554    }
2555    /// Total right inset (border + padding), suppressed at split points per §8.6.
2556    /// In LTR: right edge drawn on last fragment. In RTL: right edge drawn on first fragment.
2557    #[must_use] pub fn right_inset(&self) -> f32 {
2558        let show = if self.is_rtl { self.is_first_fragment } else { self.is_last_fragment };
2559        if show { self.right + self.padding_right } else { 0.0 }
2560    }
2561    /// Total top inset (border + padding)
2562    #[must_use] pub fn top_inset(&self) -> f32 { self.top + self.padding_top }
2563    /// Total bottom inset (border + padding)
2564    #[must_use] pub fn bottom_inset(&self) -> f32 { self.bottom + self.padding_bottom }
2565}
2566
2567#[derive(Debug, Clone)]
2568pub struct InlineShape {
2569    pub shape_def: ShapeDefinition,
2570    pub fill: Option<ColorU>,
2571    pub stroke: Option<Stroke>,
2572    pub baseline_offset: f32,
2573    /// Per-item vertical alignment (CSS `vertical-align` on the inline-block element).
2574    /// This overrides the global `TextStyleOptions::vertical_align` for this shape.
2575    pub alignment: VerticalAlign,
2576    /// The `NodeId` of the element that created this shape
2577    /// (e.g., inline-block) - this allows us to look up
2578    /// styling information (background, border) when rendering
2579    pub source_node_id: Option<NodeId>,
2580}
2581
2582#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
2583pub enum OverflowBehavior {
2584    // Content extends outside shape
2585    Visible,
2586    // Content is clipped to shape
2587    Hidden,
2588    // Scrollable overflow
2589    Scroll,
2590    // Browser/system decides
2591    #[default]
2592    Auto,
2593    // Break into next shape/page
2594    Break,
2595}
2596
2597#[derive(Debug, Clone)]
2598pub(crate) struct MeasuredImage {
2599    pub(crate) source: ImageSource,
2600    pub(crate) size: Size,
2601    pub(crate) baseline_offset: f32,
2602    pub(crate) alignment: VerticalAlign,
2603    pub(crate) content_index: usize,
2604}
2605
2606#[derive(Debug, Clone)]
2607pub(crate) struct MeasuredShape {
2608    pub(crate) shape_def: ShapeDefinition,
2609    pub(crate) size: Size,
2610    pub(crate) baseline_offset: f32,
2611    pub(crate) alignment: VerticalAlign,
2612    pub(crate) content_index: usize,
2613}
2614
2615#[derive(Copy, Debug, Clone)]
2616pub struct InlineSpace {
2617    pub width: f32,
2618    pub is_breaking: bool, // Can line break here
2619    pub is_stretchy: bool, // Can be expanded for justification
2620}
2621
2622impl PartialEq for InlineSpace {
2623    fn eq(&self, other: &Self) -> bool {
2624        self.width.to_bits() == other.width.to_bits()
2625            && self.is_breaking == other.is_breaking
2626            && self.is_stretchy == other.is_stretchy
2627    }
2628}
2629
2630impl Eq for InlineSpace {}
2631
2632impl Hash for InlineSpace {
2633    fn hash<H: Hasher>(&self, state: &mut H) {
2634        self.width.to_bits().hash(state);
2635        self.is_breaking.hash(state);
2636        self.is_stretchy.hash(state);
2637    }
2638}
2639
2640impl PartialOrd for InlineSpace {
2641    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
2642        Some(self.cmp(other))
2643    }
2644}
2645
2646impl Ord for InlineSpace {
2647    fn cmp(&self, other: &Self) -> Ordering {
2648        self.width
2649            .total_cmp(&other.width)
2650            .then_with(|| self.is_breaking.cmp(&other.is_breaking))
2651            .then_with(|| self.is_stretchy.cmp(&other.is_stretchy))
2652    }
2653}
2654
2655impl PartialEq for InlineShape {
2656    fn eq(&self, other: &Self) -> bool {
2657        self.baseline_offset.to_bits() == other.baseline_offset.to_bits()
2658            && self.shape_def == other.shape_def
2659            && self.fill == other.fill
2660            && self.stroke == other.stroke
2661            && self.alignment == other.alignment
2662            && self.source_node_id == other.source_node_id
2663    }
2664}
2665
2666impl Eq for InlineShape {}
2667
2668impl Hash for InlineShape {
2669    fn hash<H: Hasher>(&self, state: &mut H) {
2670        self.shape_def.hash(state);
2671        self.fill.hash(state);
2672        self.stroke.hash(state);
2673        self.baseline_offset.to_bits().hash(state);
2674        self.alignment.hash(state);
2675        self.source_node_id.hash(state);
2676    }
2677}
2678
2679impl PartialOrd for InlineShape {
2680    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
2681        Some(
2682            self.shape_def
2683                .partial_cmp(&other.shape_def)?
2684                .then_with(|| self.fill.cmp(&other.fill))
2685                .then_with(|| {
2686                    self.stroke
2687                        .partial_cmp(&other.stroke)
2688                        .unwrap_or(Ordering::Equal)
2689                })
2690                .then_with(|| self.baseline_offset.total_cmp(&other.baseline_offset))
2691                .then_with(|| self.alignment.cmp(&other.alignment))
2692                .then_with(|| self.source_node_id.cmp(&other.source_node_id)),
2693        )
2694    }
2695}
2696
2697#[derive(Debug, Default, Clone, Copy)]
2698pub struct Rect {
2699    pub x: f32,
2700    pub y: f32,
2701    pub width: f32,
2702    pub height: f32,
2703}
2704
2705impl PartialEq for Rect {
2706    fn eq(&self, other: &Self) -> bool {
2707        round_eq(self.x, other.x)
2708            && round_eq(self.y, other.y)
2709            && round_eq(self.width, other.width)
2710            && round_eq(self.height, other.height)
2711    }
2712}
2713impl Eq for Rect {}
2714
2715impl Hash for Rect {
2716    #[allow(clippy::cast_possible_truncation)] // bounded pixel/coord/colour/glyph cast
2717    fn hash<H: Hasher>(&self, state: &mut H) {
2718        // The order in which you hash the fields matters.
2719        // A consistent order is crucial.
2720        (self.x.round() as isize).hash(state);
2721        (self.y.round() as isize).hash(state);
2722        (self.width.round() as isize).hash(state);
2723        (self.height.round() as isize).hash(state);
2724    }
2725}
2726
2727#[derive(Debug, Default, Clone, Copy)]
2728pub struct Size {
2729    pub width: f32,
2730    pub height: f32,
2731}
2732
2733impl PartialOrd for Size {
2734    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
2735        Some(self.cmp(other))
2736    }
2737}
2738
2739impl Ord for Size {
2740    #[allow(clippy::cast_possible_truncation)] // bounded pixel/coord/colour/glyph cast
2741    fn cmp(&self, other: &Self) -> Ordering {
2742        (self.width.round() as isize)
2743            .cmp(&(other.width.round() as isize))
2744            .then_with(|| (self.height.round() as isize).cmp(&(other.height.round() as isize)))
2745    }
2746}
2747
2748// Size
2749impl Hash for Size {
2750    #[allow(clippy::cast_possible_truncation)] // bounded pixel/coord/colour/glyph cast
2751    fn hash<H: Hasher>(&self, state: &mut H) {
2752        (self.width.round() as isize).hash(state);
2753        (self.height.round() as isize).hash(state);
2754    }
2755}
2756impl PartialEq for Size {
2757    fn eq(&self, other: &Self) -> bool {
2758        round_eq(self.width, other.width) && round_eq(self.height, other.height)
2759    }
2760}
2761impl Eq for Size {}
2762
2763impl Size {
2764    #[must_use] pub const fn zero() -> Self {
2765        Self::new(0.0, 0.0)
2766    }
2767    #[must_use] pub const fn new(width: f32, height: f32) -> Self {
2768        Self { width, height }
2769    }
2770}
2771
2772#[derive(Debug, Default, Clone, Copy, PartialOrd)]
2773pub struct Point {
2774    pub x: f32,
2775    pub y: f32,
2776}
2777
2778// Point
2779impl Hash for Point {
2780    #[allow(clippy::cast_possible_truncation)] // bounded pixel/coord/colour/glyph cast
2781    fn hash<H: Hasher>(&self, state: &mut H) {
2782        (self.x.round() as isize).hash(state);
2783        (self.y.round() as isize).hash(state);
2784    }
2785}
2786
2787impl PartialEq for Point {
2788    fn eq(&self, other: &Self) -> bool {
2789        round_eq(self.x, other.x) && round_eq(self.y, other.y)
2790    }
2791}
2792
2793impl Eq for Point {}
2794
2795#[derive(Debug, Clone, PartialOrd)]
2796pub enum ShapeDefinition {
2797    Rectangle {
2798        size: Size,
2799        corner_radius: Option<f32>,
2800    },
2801    Circle {
2802        radius: f32,
2803    },
2804    Ellipse {
2805        radii: Size,
2806    },
2807    Polygon {
2808        points: Vec<Point>,
2809    },
2810    Path {
2811        segments: Vec<PathSegment>,
2812    },
2813}
2814
2815// ShapeDefinition
2816impl Hash for ShapeDefinition {
2817    #[allow(clippy::cast_possible_truncation)] // bounded pixel/coord/colour/glyph cast
2818    fn hash<H: Hasher>(&self, state: &mut H) {
2819        discriminant(self).hash(state);
2820        match self {
2821            Self::Rectangle {
2822                size,
2823                corner_radius,
2824            } => {
2825                size.hash(state);
2826                corner_radius.map(|r| r.round() as isize).hash(state);
2827            }
2828            Self::Circle { radius } => {
2829                (radius.round() as isize).hash(state);
2830            }
2831            Self::Ellipse { radii } => {
2832                radii.hash(state);
2833            }
2834            Self::Polygon { points } => {
2835                // Since Point implements Hash, we can hash the Vec directly.
2836                points.hash(state);
2837            }
2838            Self::Path { segments } => {
2839                // Same for Vec<PathSegment>
2840                segments.hash(state);
2841            }
2842        }
2843    }
2844}
2845
2846impl PartialEq for ShapeDefinition {
2847    fn eq(&self, other: &Self) -> bool {
2848        match (self, other) {
2849            (
2850                Self::Rectangle {
2851                    size: s1,
2852                    corner_radius: r1,
2853                },
2854                Self::Rectangle {
2855                    size: s2,
2856                    corner_radius: r2,
2857                },
2858            ) => {
2859                s1 == s2
2860                    && match (r1, r2) {
2861                        (None, None) => true,
2862                        (Some(v1), Some(v2)) => round_eq(*v1, *v2),
2863                        _ => false,
2864                    }
2865            }
2866            (Self::Circle { radius: r1 }, Self::Circle { radius: r2 }) => {
2867                round_eq(*r1, *r2)
2868            }
2869            (Self::Ellipse { radii: r1 }, Self::Ellipse { radii: r2 }) => {
2870                r1 == r2
2871            }
2872            (Self::Polygon { points: p1 }, Self::Polygon { points: p2 }) => {
2873                p1 == p2
2874            }
2875            (Self::Path { segments: s1 }, Self::Path { segments: s2 }) => {
2876                s1 == s2
2877            }
2878            _ => false,
2879        }
2880    }
2881}
2882impl Eq for ShapeDefinition {}
2883
2884impl ShapeDefinition {
2885    /// Calculates the bounding box size for the shape.
2886    #[must_use] pub fn get_size(&self) -> Size {
2887        match self {
2888            // The size is explicitly defined.
2889            Self::Rectangle { size, .. } => *size,
2890
2891            // The bounding box of a circle is a square with sides equal to the diameter.
2892            Self::Circle { radius } => {
2893                let diameter = radius * 2.0;
2894                Size::new(diameter, diameter)
2895            }
2896
2897            // The bounding box of an ellipse has width and height equal to twice its radii.
2898            Self::Ellipse { radii } => Size::new(radii.width * 2.0, radii.height * 2.0),
2899
2900            // For a polygon, we must find the min/max coordinates to get the bounds.
2901            Self::Polygon { points } => calculate_bounding_box_size(points),
2902
2903            // For a path, we find the bounding box of all its anchor and control points.
2904            //
2905            // NOTE: This is a common and fast approximation. The true bounding box of
2906            // bezier curves can be slightly smaller than the box containing their control
2907            // points. For pixel-perfect results, one would need to calculate the
2908            // curve's extrema.
2909            Self::Path { segments } => {
2910                let mut points = Vec::new();
2911                let mut current_pos = Point { x: 0.0, y: 0.0 };
2912
2913                for segment in segments {
2914                    match segment {
2915                        PathSegment::MoveTo(p) | PathSegment::LineTo(p) => {
2916                            points.push(*p);
2917                            current_pos = *p;
2918                        }
2919                        PathSegment::QuadTo { control, end } => {
2920                            points.push(current_pos);
2921                            points.push(*control);
2922                            points.push(*end);
2923                            current_pos = *end;
2924                        }
2925                        PathSegment::CurveTo {
2926                            control1,
2927                            control2,
2928                            end,
2929                        } => {
2930                            points.push(current_pos);
2931                            points.push(*control1);
2932                            points.push(*control2);
2933                            points.push(*end);
2934                            current_pos = *end;
2935                        }
2936                        PathSegment::Arc {
2937                            center,
2938                            radius,
2939                            start_angle,
2940                            end_angle,
2941                        } => {
2942                            // 1. Calculate and add the arc's start and end points to the list.
2943                            let start_point = Point {
2944                                x: center.x + radius * start_angle.cos(),
2945                                y: center.y + radius * start_angle.sin(),
2946                            };
2947                            let end_point = Point {
2948                                x: center.x + radius * end_angle.cos(),
2949                                y: center.y + radius * end_angle.sin(),
2950                            };
2951                            points.push(start_point);
2952                            points.push(end_point);
2953
2954                            // 2. Normalize the angles to handle cases where the arc crosses the
2955                            //    0-radian line.
2956                            // This ensures we can iterate forward from a start to an end angle.
2957                            let mut normalized_end = *end_angle;
2958                            #[allow(clippy::while_float)] // intentional bounded float loop (angle-wrap / pixel-step); an integer counter would be artificial
2959                            while normalized_end < *start_angle {
2960                                normalized_end += 2.0 * std::f32::consts::PI;
2961                            }
2962
2963                            // 3. Find the first cardinal point (multiples of PI/2) at or after the
2964                            //    start angle.
2965                            let mut check_angle = (*start_angle / std::f32::consts::FRAC_PI_2)
2966                                .ceil()
2967                                * std::f32::consts::FRAC_PI_2;
2968
2969                            // 4. Iterate through all cardinal points that fall within the arc's
2970                            //    sweep and add them.
2971                            // These points define the maximum extent of the arc's bounding box.
2972                            #[allow(clippy::while_float)] // intentional bounded float loop (angle-wrap / pixel-step); an integer counter would be artificial
2973                            while check_angle < normalized_end {
2974                                points.push(Point {
2975                                    x: center.x + radius * check_angle.cos(),
2976                                    y: center.y + radius * check_angle.sin(),
2977                                });
2978                                check_angle += std::f32::consts::FRAC_PI_2;
2979                            }
2980
2981                            // 5. The end of the arc is the new current position for subsequent path
2982                            //    segments.
2983                            current_pos = end_point;
2984                        }
2985                        PathSegment::Close => {
2986                            // No new points are added for closing the path
2987                        }
2988                    }
2989                }
2990                calculate_bounding_box_size(&points)
2991            }
2992        }
2993    }
2994}
2995
2996// +spec:text-alignment-spacing:25e82a - text-align shorthand resolves text-align-all / text-align-last
2997/// Resolve effective text alignment for a line, handling text-align-last per CSS Text §6.3.
2998/// For the last line (or lines before forced breaks), text-align-last overrides text-align.
2999/// When text-align-last is auto (default), justify falls back to start; others use text-align.
3000// +spec:text-alignment-spacing:bca77d - text-align-last auto falls back to text-align-all, justify→start
3001// +spec:line-breaking:9b10d2 - text-align-last applies to last line and lines before forced breaks
3002/// +spec:text-alignment-spacing:8d88ce - text-align-last overrides justify on last line/forced break
3003pub(crate) fn resolve_effective_alignment(
3004    text_align: TextAlign,
3005    text_align_last: TextAlign,
3006    is_last_or_forced: bool,
3007) -> TextAlign {
3008    if is_last_or_forced {
3009        if text_align_last == TextAlign::default() {
3010            if text_align == TextAlign::Justify { TextAlign::Start } else { text_align }
3011        } else {
3012            text_align_last
3013        }
3014    } else {
3015        text_align
3016    }
3017}
3018
3019/// Helper function to calculate the size of the bounding box enclosing a set of points.
3020fn calculate_bounding_box_size(points: &[Point]) -> Size {
3021    if points.is_empty() {
3022        return Size::zero();
3023    }
3024
3025    let mut min_x = f32::MAX;
3026    let mut max_x = f32::MIN;
3027    let mut min_y = f32::MAX;
3028    let mut max_y = f32::MIN;
3029
3030    for point in points {
3031        min_x = min_x.min(point.x);
3032        max_x = max_x.max(point.x);
3033        min_y = min_y.min(point.y);
3034        max_y = max_y.max(point.y);
3035    }
3036
3037    // Handle case where points might be collinear or a single point
3038    if min_x > max_x || min_y > max_y {
3039        return Size::zero();
3040    }
3041
3042    Size::new(max_x - min_x, max_y - min_y)
3043}
3044
3045#[derive(Debug, Clone, PartialOrd)]
3046pub struct Stroke {
3047    pub color: ColorU,
3048    pub width: f32,
3049    pub dash_pattern: Option<Vec<f32>>,
3050}
3051
3052// Stroke
3053impl Hash for Stroke {
3054    #[allow(clippy::cast_possible_truncation)] // bounded pixel/coord/colour/glyph cast
3055    fn hash<H: Hasher>(&self, state: &mut H) {
3056        self.color.hash(state);
3057        (self.width.round() as isize).hash(state);
3058
3059        // Manual hashing for Option<Vec<f32>>
3060        match &self.dash_pattern {
3061            None => 0u8.hash(state), // Hash a discriminant for None
3062            Some(pattern) => {
3063                1u8.hash(state); // Hash a discriminant for Some
3064                pattern.len().hash(state); // Hash the length
3065                for &val in pattern {
3066                    (val.round() as isize).hash(state); // Hash each rounded value
3067                }
3068            }
3069        }
3070    }
3071}
3072
3073impl PartialEq for Stroke {
3074    fn eq(&self, other: &Self) -> bool {
3075        if self.color != other.color || !round_eq(self.width, other.width) {
3076            return false;
3077        }
3078        match (&self.dash_pattern, &other.dash_pattern) {
3079            (None, None) => true,
3080            (Some(p1), Some(p2)) => {
3081                p1.len() == p2.len() && p1.iter().zip(p2.iter()).all(|(a, b)| round_eq(*a, *b))
3082            }
3083            _ => false,
3084        }
3085    }
3086}
3087
3088impl Eq for Stroke {}
3089
3090// Helper function to round f32 for comparison
3091#[allow(clippy::cast_possible_truncation)] // bounded pixel/coord/colour/glyph cast
3092fn round_eq(a: f32, b: f32) -> bool {
3093    (a.round() as isize) == (b.round() as isize)
3094}
3095
3096#[derive(Debug, Clone)]
3097pub enum ShapeBoundary {
3098    Rectangle(Rect),
3099    Circle { center: Point, radius: f32 },
3100    Ellipse { center: Point, radii: Size },
3101    Polygon { points: Vec<Point> },
3102    Path { segments: Vec<PathSegment> },
3103}
3104
3105impl ShapeBoundary {
3106    #[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
3107    #[must_use] pub fn inflate(&self, margin: f32) -> Self {
3108        if margin == 0.0 {
3109            return self.clone();
3110        }
3111        match self {
3112            Self::Rectangle(rect) => Self::Rectangle(Rect {
3113                x: rect.x - margin,
3114                y: rect.y - margin,
3115                width: (rect.width + margin * 2.0).max(0.0),
3116                height: (rect.height + margin * 2.0).max(0.0),
3117            }),
3118            Self::Circle { center, radius } => Self::Circle {
3119                center: *center,
3120                radius: radius + margin,
3121            },
3122            // For simplicity, Polygon and Path inflation is not implemented here.
3123            // A full implementation would require a geometry library to offset the path.
3124            _ => self.clone(),
3125        }
3126    }
3127}
3128
3129// ShapeBoundary
3130impl Hash for ShapeBoundary {
3131    #[allow(clippy::cast_possible_truncation)] // bounded pixel/coord/colour/glyph cast
3132    fn hash<H: Hasher>(&self, state: &mut H) {
3133        discriminant(self).hash(state);
3134        match self {
3135            Self::Rectangle(rect) => rect.hash(state),
3136            Self::Circle { center, radius } => {
3137                center.hash(state);
3138                (radius.round() as isize).hash(state);
3139            }
3140            Self::Ellipse { center, radii } => {
3141                center.hash(state);
3142                radii.hash(state);
3143            }
3144            Self::Polygon { points } => points.hash(state),
3145            Self::Path { segments } => segments.hash(state),
3146        }
3147    }
3148}
3149impl PartialEq for ShapeBoundary {
3150    fn eq(&self, other: &Self) -> bool {
3151        match (self, other) {
3152            (Self::Rectangle(r1), Self::Rectangle(r2)) => r1 == r2,
3153            (
3154                Self::Circle {
3155                    center: c1,
3156                    radius: r1,
3157                },
3158                Self::Circle {
3159                    center: c2,
3160                    radius: r2,
3161                },
3162            ) => c1 == c2 && round_eq(*r1, *r2),
3163            (
3164                Self::Ellipse {
3165                    center: c1,
3166                    radii: r1,
3167                },
3168                Self::Ellipse {
3169                    center: c2,
3170                    radii: r2,
3171                },
3172            ) => c1 == c2 && r1 == r2,
3173            (Self::Polygon { points: p1 }, Self::Polygon { points: p2 }) => {
3174                p1 == p2
3175            }
3176            (Self::Path { segments: s1 }, Self::Path { segments: s2 }) => {
3177                s1 == s2
3178            }
3179            _ => false,
3180        }
3181    }
3182}
3183impl Eq for ShapeBoundary {}
3184
3185impl ShapeBoundary {
3186    /// Converts a CSS shape (from azul-css) to a layout engine `ShapeBoundary`
3187    ///
3188    /// # Arguments
3189    /// * `css_shape` - The parsed CSS shape from azul-css
3190    /// * `reference_box` - The containing box for resolving coordinates (from layout solver)
3191    ///
3192    /// # Returns
3193    /// A `ShapeBoundary` ready for use in the text layout engine
3194    #[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
3195    pub fn from_css_shape(
3196        css_shape: &azul_css::shape::CssShape,
3197        reference_box: Rect,
3198        debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
3199    ) -> Self {
3200        use azul_css::shape::CssShape;
3201
3202        if let Some(msgs) = debug_messages {
3203            msgs.push(LayoutDebugMessage::info(format!(
3204                "[ShapeBoundary::from_css_shape] Input CSS shape: {css_shape:?}"
3205            )));
3206            msgs.push(LayoutDebugMessage::info(format!(
3207                "[ShapeBoundary::from_css_shape] Reference box: {reference_box:?}"
3208            )));
3209        }
3210
3211        let result = match css_shape {
3212            CssShape::Circle(circle) => {
3213                let center = Point {
3214                    x: reference_box.x + circle.center.x,
3215                    y: reference_box.y + circle.center.y,
3216                };
3217                if let Some(msgs) = debug_messages {
3218                    msgs.push(LayoutDebugMessage::info(format!(
3219                        "[ShapeBoundary::from_css_shape] Circle - CSS center: ({}, {}), radius: {}",
3220                        circle.center.x, circle.center.y, circle.radius
3221                    )));
3222                    msgs.push(LayoutDebugMessage::info(format!(
3223                        "[ShapeBoundary::from_css_shape] Circle - Absolute center: ({}, {}), \
3224                         radius: {}",
3225                        center.x, center.y, circle.radius
3226                    )));
3227                }
3228                Self::Circle {
3229                    center,
3230                    radius: circle.radius,
3231                }
3232            }
3233
3234            CssShape::Ellipse(ellipse) => {
3235                let center = Point {
3236                    x: reference_box.x + ellipse.center.x,
3237                    y: reference_box.y + ellipse.center.y,
3238                };
3239                let radii = Size {
3240                    width: ellipse.radius_x,
3241                    height: ellipse.radius_y,
3242                };
3243                if let Some(msgs) = debug_messages {
3244                    msgs.push(LayoutDebugMessage::info(format!(
3245                        "[ShapeBoundary::from_css_shape] Ellipse - center: ({}, {}), radii: ({}, \
3246                         {})",
3247                        center.x, center.y, radii.width, radii.height
3248                    )));
3249                }
3250                Self::Ellipse { center, radii }
3251            }
3252
3253            CssShape::Polygon(polygon) => {
3254                let points = polygon
3255                    .points
3256                    .as_ref()
3257                    .iter()
3258                    .map(|pt| Point {
3259                        x: reference_box.x + pt.x,
3260                        y: reference_box.y + pt.y,
3261                    })
3262                    .collect();
3263                if let Some(msgs) = debug_messages {
3264                    msgs.push(LayoutDebugMessage::info(format!(
3265                        "[ShapeBoundary::from_css_shape] Polygon - {} points",
3266                        polygon.points.as_ref().len()
3267                    )));
3268                }
3269                Self::Polygon { points }
3270            }
3271
3272            CssShape::Inset(inset) => {
3273                // Inset defines distances from reference box edges
3274                let x = reference_box.x + inset.inset_left;
3275                let y = reference_box.y + inset.inset_top;
3276                let width = reference_box.width - inset.inset_left - inset.inset_right;
3277                let height = reference_box.height - inset.inset_top - inset.inset_bottom;
3278
3279                if let Some(msgs) = debug_messages {
3280                    msgs.push(LayoutDebugMessage::info(format!(
3281                        "[ShapeBoundary::from_css_shape] Inset - insets: ({}, {}, {}, {})",
3282                        inset.inset_top, inset.inset_right, inset.inset_bottom, inset.inset_left
3283                    )));
3284                    msgs.push(LayoutDebugMessage::info(format!(
3285                        "[ShapeBoundary::from_css_shape] Inset - resulting rect: x={x}, y={y}, \
3286                         w={width}, h={height}"
3287                    )));
3288                }
3289
3290                Self::Rectangle(Rect {
3291                    x,
3292                    y,
3293                    width: width.max(0.0),
3294                    height: height.max(0.0),
3295                })
3296            }
3297
3298            CssShape::Path(path) => {
3299                // CSS `path()` value: `path.data` is a raw SVG path `d=""` string in the
3300                // reference-box coordinate system (origin at the reference box's top-left).
3301                // Parse + flatten it into `Vec<PathSegment>` (curves sampled to line
3302                // segments) so the scanline code in `get_shape_horizontal_spans` can
3303                // intersect it per line, exactly like `polygon`.
3304                let segments = azul_core::path_parser::parse_svg_path_d(path.data.as_str())
3305                    .map_or_else(|_| Vec::new(), |multipolygon| {
3306                        flatten_svg_to_path_segments(&multipolygon, reference_box)
3307                    });
3308                if let Some(msgs) = debug_messages {
3309                    msgs.push(LayoutDebugMessage::info(format!(
3310                        "[ShapeBoundary::from_css_shape] Path - parsed {} flattened segments",
3311                        segments.len()
3312                    )));
3313                }
3314                if segments.is_empty() {
3315                    // Unparseable / empty path: fall back to the reference rectangle so a
3316                    // shape-inside container does not collapse to zero usable space.
3317                    Self::Rectangle(reference_box)
3318                } else {
3319                    Self::Path { segments }
3320                }
3321            }
3322        };
3323
3324        if let Some(msgs) = debug_messages {
3325            msgs.push(LayoutDebugMessage::info(format!(
3326                "[ShapeBoundary::from_css_shape] Result: {result:?}"
3327            )));
3328        }
3329        result
3330    }
3331}
3332
3333#[derive(Copy, Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
3334pub struct InlineBreak {
3335    pub break_type: BreakType,
3336    pub clear: ClearType,
3337    pub content_index: usize,
3338}
3339
3340// +spec:line-breaking:d70ffd - Defines forced line break (Hard) vs soft wrap break (Soft) types
3341#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
3342pub enum BreakType {
3343    Soft,   // Soft wrap break: UA creates unforced line breaks to fit content within the measure
3344    Hard,   // Forced line break: explicit line-breaking controls (preserved newline, <br>)
3345    Page,   // Page break
3346    Column, // Column break
3347}
3348
3349#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
3350pub enum ClearType {
3351    None,
3352    Left,
3353    Right,
3354    Both,
3355}
3356
3357// Complex shape constraints for non-rectangular text flow
3358#[derive(Debug, Clone)]
3359pub(crate) struct ShapeConstraints {
3360    pub(crate) boundaries: Vec<ShapeBoundary>,
3361    pub(crate) exclusions: Vec<ShapeBoundary>,
3362    pub(crate) writing_mode: WritingMode,
3363    pub(crate) text_align: TextAlign,
3364    pub(crate) line_height: LineHeight,
3365}
3366
3367#[derive(Debug, Clone, Copy, PartialEq, Default, Hash, Eq, PartialOrd, Ord)]
3368pub enum WritingMode {
3369    #[default]
3370    HorizontalTb, // horizontal-tb (normal horizontal)
3371    VerticalRl, // +spec:writing-modes:6e22a7 - vertical-rl (vertical right-to-left, commonly used in East Asia)
3372    VerticalLr, // vertical-lr (vertical left-to-right)
3373    SidewaysRl, // sideways-rl (rotated horizontal in vertical context)
3374    SidewaysLr, // sideways-lr (rotated horizontal in vertical context)
3375}
3376
3377impl WritingMode {
3378    /// Necessary to determine if the glyphs are advancing in a horizontal direction
3379    #[must_use] pub const fn is_advance_horizontal(&self) -> bool {
3380        matches!(
3381            self,
3382            Self::HorizontalTb | Self::SidewaysRl | Self::SidewaysLr
3383        )
3384    }
3385}
3386
3387#[derive(Debug, Clone, Copy, PartialEq, Default, Hash, Eq, PartialOrd, Ord)]
3388pub enum JustifyContent {
3389    #[default]
3390    None,
3391    InterWord,      // Expand spaces between words
3392    InterCharacter, // Expand spaces between all characters (for CJK)
3393    Distribute,     // Distribute space evenly including start/end
3394    Kashida,        // Stretch Arabic text using kashidas
3395}
3396
3397// Enhanced text alignment with logical directions
3398#[derive(Debug, Clone, Copy, PartialEq, Default, Hash, Eq, PartialOrd, Ord)]
3399pub enum TextAlign {
3400    #[default]
3401    Left,
3402    Right,
3403    Center,
3404    Justify,
3405    Start,
3406    End,        // Logical start/end
3407    JustifyAll, // Justify including last line
3408}
3409
3410// +spec:block-formatting-context:458d31 - vertical text orientation: upright for horizontal scripts, intrinsic for vertical scripts
3411// Vertical text orientation for individual characters
3412#[derive(Debug, Clone, Copy, PartialEq, Default, Eq, PartialOrd, Ord, Hash)]
3413pub enum TextOrientation {
3414    #[default]
3415    Mixed, // Default: upright for scripts, rotated for others
3416    Upright,  // All characters upright
3417    Sideways, // All characters rotated 90 degrees
3418}
3419
3420#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
3421#[derive(Default)]
3422pub struct TextDecoration {
3423    pub underline: bool,
3424    pub strikethrough: bool,
3425    pub overline: bool,
3426}
3427
3428
3429impl TextDecoration {
3430    /// Convert from CSS `StyleTextDecoration` enum to our internal representation.
3431    /// 
3432    /// Note: CSS text-decoration can have multiple values (underline line-through),
3433    /// but the current azul-css parser only supports single values. This can be
3434    /// extended in the future if CSS parsing is updated.
3435    #[must_use] pub fn from_css(css: azul_css::props::style::text::StyleTextDecoration) -> Self {
3436        use azul_css::props::style::text::StyleTextDecoration;
3437        match css {
3438            StyleTextDecoration::None => Self::default(),
3439            StyleTextDecoration::Underline => Self {
3440                underline: true,
3441                strikethrough: false,
3442                overline: false,
3443            },
3444            StyleTextDecoration::Overline => Self {
3445                underline: false,
3446                strikethrough: false,
3447                overline: true,
3448            },
3449            StyleTextDecoration::LineThrough => Self {
3450                underline: false,
3451                strikethrough: true,
3452                overline: false,
3453            },
3454        }
3455    }
3456}
3457
3458#[derive(Debug, Clone, Copy, PartialEq, Hash, Eq, PartialOrd, Ord, Default)]
3459pub enum TextTransform {
3460    #[default]
3461    None,
3462    Uppercase,
3463    Lowercase,
3464    Capitalize,
3465    // only within preserved white space (non-preserved spaces already collapsed in Phase I)
3466    FullWidth,
3467}
3468
3469// Type alias for OpenType feature tags
3470pub type FourCc = [u8; 4];
3471
3472// Enum for relative or absolute spacing
3473#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
3474pub enum Spacing {
3475    Px(i32), // Whole-pixel spacing (kept for hashing/equality convenience)
3476    /// Sub-pixel resolved pixel spacing. `letter-spacing`/`word-spacing` accumulate
3477    /// once per glyph, so quantizing to whole pixels (the `Px(i32)` variant) multiplies
3478    /// the rounding error across a run. The CSS resolution path emits this variant to
3479    /// preserve the exact sub-pixel value (e.g. `letter-spacing: 0.4px`).
3480    PxF(f32),
3481    Em(f32),
3482}
3483
3484// A type that implements `Hash` must also implement `Eq`.
3485// Since f32 does not implement `Eq`, we provide a manual implementation.
3486// The derived `PartialEq` is sufficient for this marker trait.
3487impl Eq for Spacing {}
3488
3489impl Hash for Spacing {
3490    fn hash<H: Hasher>(&self, state: &mut H) {
3491        // First, hash the enum variant to distinguish between Px and Em.
3492        discriminant(self).hash(state);
3493        match self {
3494            Self::Px(val) => val.hash(state),
3495            // For hashing floats, convert them to their raw bit representation.
3496            // This ensures that identical float values produce identical hashes.
3497            Self::PxF(val) | Self::Em(val) => val.to_bits().hash(state),
3498        }
3499    }
3500}
3501
3502impl Default for Spacing {
3503    fn default() -> Self {
3504        Self::Px(0)
3505    }
3506}
3507
3508impl Spacing {
3509    /// Resolve this spacing to pixels given the element's font size (for `Em`).
3510    #[allow(clippy::cast_precision_loss)] // small integer px values; f32 mantissa is ample
3511    #[must_use]
3512    pub fn resolve_px(self, font_size_px: f32) -> f32 {
3513        match self {
3514            Self::Px(px) => px as f32,
3515            Self::PxF(px) => px,
3516            Self::Em(em) => em * font_size_px,
3517        }
3518    }
3519}
3520
3521impl Default for FontHash {
3522    fn default() -> Self {
3523        Self::invalid()
3524    }
3525}
3526
3527/// Style properties with vertical text support
3528#[derive(Debug, Clone, PartialEq)]
3529pub struct StyleProperties {
3530    /// Font stack for fallback support (priority order)
3531    /// Can be either a list of `FontSelectors` (resolved via fontconfig)
3532    /// or a direct `FontRef` (bypasses fontconfig entirely).
3533    pub font_stack: FontStack,
3534    pub font_size_px: f32,
3535    pub color: ColorU,
3536    /// Background color for inline elements (e.g., `<span style="background-color: yellow">`)
3537    ///
3538    /// This is propagated from CSS through the style system and eventually used by
3539    /// the PDF renderer to draw filled rectangles behind text. The value is `None`
3540    /// for transparent backgrounds (the default).
3541    ///
3542    /// The propagation chain is:
3543    /// CSS -> `get_style_properties()` -> `StyleProperties` -> `ShapedGlyph` -> `PdfGlyphRun`
3544    ///
3545    /// See `PdfGlyphRun::background_color` for how this is used in PDF rendering.
3546    pub background_color: Option<ColorU>,
3547    /// Full background content layers (for gradients, images, etc.)
3548    /// This extends `background_color` to support CSS gradients on inline elements.
3549    pub background_content: Vec<StyleBackgroundContent>,
3550    /// Border information for inline elements
3551    pub border: Option<InlineBorderInfo>,
3552    // +spec:text-alignment-spacing:b39a04 - word-spacing and letter-spacing control text spacing
3553    pub letter_spacing: Spacing,
3554    pub word_spacing: Spacing,
3555
3556    pub line_height: LineHeight,
3557    pub text_decoration: TextDecoration,
3558
3559    // Represents CSS font-feature-settings like `"liga"`, `"smcp=1"`.
3560    pub font_features: Vec<String>,
3561
3562    // Variable fonts
3563    pub font_variations: Vec<(FourCc, f32)>,
3564    // Multiplier of the space width
3565    pub tab_size: f32,
3566    // text-transform
3567    pub text_transform: TextTransform,
3568    // Vertical text properties
3569    pub writing_mode: WritingMode,
3570    pub text_orientation: TextOrientation,
3571    // Tate-chu-yoko
3572    pub text_combine_upright: Option<TextCombineUpright>,
3573
3574    // Variant handling
3575    pub font_variant_caps: FontVariantCaps,
3576    pub font_variant_numeric: FontVariantNumeric,
3577    pub font_variant_ligatures: FontVariantLigatures,
3578    pub font_variant_east_asian: FontVariantEastAsian,
3579}
3580
3581impl Default for StyleProperties {
3582    fn default() -> Self {
3583        const FONT_SIZE: f32 = 16.0;
3584        const TAB_SIZE: f32 = 8.0;
3585        Self {
3586            font_stack: FontStack::default(),
3587            font_size_px: FONT_SIZE,
3588            color: ColorU::default(),
3589            background_color: None,
3590            background_content: Vec::new(),
3591            border: None,
3592            letter_spacing: Spacing::default(), // Px(0)
3593            word_spacing: Spacing::default(),   // Px(0)
3594            line_height: LineHeight::Normal,
3595            text_decoration: TextDecoration::default(),
3596            font_features: Vec::new(),
3597            font_variations: Vec::new(),
3598            tab_size: TAB_SIZE, // CSS default
3599            text_transform: TextTransform::default(),
3600            writing_mode: WritingMode::default(),
3601            text_orientation: TextOrientation::default(),
3602            text_combine_upright: None,
3603            font_variant_caps: FontVariantCaps::default(),
3604            font_variant_numeric: FontVariantNumeric::default(),
3605            font_variant_ligatures: FontVariantLigatures::default(),
3606            font_variant_east_asian: FontVariantEastAsian::default(),
3607        }
3608    }
3609}
3610
3611impl Hash for StyleProperties {
3612    #[allow(clippy::cast_possible_truncation)] // bounded pixel/coord/colour/glyph cast
3613    fn hash<H: Hasher>(&self, state: &mut H) {
3614        self.font_stack.hash(state);
3615        self.color.hash(state);
3616        self.background_color.hash(state);
3617        self.text_decoration.hash(state);
3618        self.font_features.hash(state);
3619        self.writing_mode.hash(state);
3620        self.text_orientation.hash(state);
3621        self.text_combine_upright.hash(state);
3622        self.letter_spacing.hash(state);
3623        self.word_spacing.hash(state);
3624
3625        // For f32 fields, round and cast to usize before hashing.
3626        (self.font_size_px.round() as isize).hash(state);
3627        self.line_height.hash(state);
3628    }
3629}
3630
3631impl StyleProperties {
3632    /// Returns a hash that only includes properties that affect text layout.
3633    /// 
3634    /// Properties that DON'T affect layout (only rendering):
3635    /// - color, `background_color`, `background_content`
3636    /// - `text_decoration` (underline, etc.)
3637    /// - border (for inline elements)
3638    ///
3639    /// Properties that DO affect layout:
3640    /// - `font_stack`, `font_size_px`, `font_features`, `font_variations`
3641    /// - `letter_spacing`, `word_spacing`, `line_height`, `tab_size`
3642    /// - `writing_mode`, `text_orientation`, `text_combine_upright`
3643    /// - `text_transform`
3644    /// - `font_variant`_* (affects glyph selection)
3645    ///
3646    /// This allows the layout cache to reuse layouts when only rendering
3647    /// properties change (e.g., color changes on hover).
3648    // (family, weight, style) so that shaping runs break at element boundaries where font
3649    // properties differ, preventing impossible cross-boundary ligatures (e.g. "and" → "&").
3650    #[allow(clippy::cast_possible_truncation)] // bounded pixel/coord/colour/glyph cast
3651    #[must_use] pub fn layout_hash(&self) -> u64 {
3652        use std::hash::Hasher;
3653        let mut hasher = DefaultHasher::new();
3654
3655        // Font selection (affects shaping and metrics)
3656        self.font_stack.hash(&mut hasher);
3657        // Hash the EXACT font size bits, not a rounded integer: two styles differing
3658        // by <0.5px must not share a shaping-cache entry / coalesce, or one run gets
3659        // shaped at the other's size (wrong advances/metrics).
3660        self.font_size_px.to_bits().hash(&mut hasher);
3661        self.font_features.hash(&mut hasher);
3662        // font_variations affects glyph outlines
3663        for (tag, value) in &self.font_variations {
3664            tag.hash(&mut hasher);
3665            (value.round() as i32).hash(&mut hasher);
3666        }
3667        
3668        // Spacing (affects glyph positions)
3669        self.letter_spacing.hash(&mut hasher);
3670        self.word_spacing.hash(&mut hasher);
3671        self.line_height.hash(&mut hasher);
3672        (self.tab_size.round() as isize).hash(&mut hasher);
3673        
3674        // Writing mode (affects layout direction)
3675        self.writing_mode.hash(&mut hasher);
3676        self.text_orientation.hash(&mut hasher);
3677        self.text_combine_upright.hash(&mut hasher);
3678        
3679        // Text transform (affects which characters are used)
3680        self.text_transform.hash(&mut hasher);
3681        
3682        // Font variants (affect glyph selection)
3683        self.font_variant_caps.hash(&mut hasher);
3684        self.font_variant_numeric.hash(&mut hasher);
3685        self.font_variant_ligatures.hash(&mut hasher);
3686        self.font_variant_east_asian.hash(&mut hasher);
3687        
3688        hasher.finish()
3689    }
3690    
3691    /// Check if two `StyleProperties` have the same layout-affecting properties.
3692    ///
3693    /// Returns true if the layouts would be identical (only rendering differs).
3694    ///
3695    /// **Note:** This is a fast-path comparison using 64-bit hashes.  Hash
3696    /// collisions are theoretically possible, which could cause the cache to
3697    /// serve a stale layout.  In practice the probability is negligible for
3698    /// the number of distinct `StyleProperties` values in a single document.
3699    #[must_use] pub fn layout_eq(&self, other: &Self) -> bool {
3700        self.layout_hash() == other.layout_hash()
3701    }
3702}
3703
3704#[derive(Copy, Debug, Clone, PartialEq, Hash, Eq, PartialOrd, Ord)]
3705pub enum TextCombineUpright {
3706    None,
3707    All,        // Combine all characters in horizontal layout
3708    Digits(u8), // Combine up to N digits
3709}
3710
3711#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
3712pub enum GlyphSource {
3713    /// Glyph generated from a character in the source text.
3714    Char,
3715    /// Glyph inserted dynamically by the layout engine (e.g., a hyphen).
3716    Hyphen,
3717}
3718
3719#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3720pub enum CharacterClass {
3721    Space,       // Regular spaces - highest justification priority
3722    Punctuation, // Can sometimes be adjusted
3723    Letter,      // Normal letters
3724    Ideograph,   // CJK characters - can be justified between
3725    Symbol,      // Symbols, emojis
3726    Combining,   // Combining marks - never justified
3727}
3728
3729#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3730pub enum GlyphOrientation {
3731    Horizontal, // Keep horizontal (normal in horizontal text)
3732    Vertical,   // Rotate to vertical (normal in vertical text)
3733    Upright,    // Keep upright regardless of writing mode
3734    Mixed,      // Use script-specific default orientation
3735}
3736
3737// Bidi and script detection
3738#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
3739pub enum BidiDirection {
3740    Ltr,
3741    Rtl,
3742}
3743
3744impl BidiDirection {
3745    #[must_use] pub const fn is_rtl(&self) -> bool {
3746        matches!(self, Self::Rtl)
3747    }
3748}
3749
3750/// CSS `unicode-bidi` property values relevant to layout.
3751///
3752/// When `Plaintext`, the bidi algorithm uses P2/P3 heuristics to auto-detect
3753/// paragraph direction from text content, instead of the HL1 override from
3754/// the CSS `direction` property.
3755#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
3756#[derive(Default)]
3757pub enum UnicodeBidi {
3758    #[default]
3759    Normal,
3760    Embed,
3761    Isolate,
3762    BidiOverride,
3763    IsolateOverride,
3764    Plaintext,
3765}
3766
3767
3768#[derive(Debug, Clone, Copy, PartialEq, Hash, Eq, PartialOrd, Ord, Default)]
3769pub enum FontVariantCaps {
3770    #[default]
3771    Normal,
3772    SmallCaps,
3773    AllSmallCaps,
3774    PetiteCaps,
3775    AllPetiteCaps,
3776    Unicase,
3777    TitlingCaps,
3778}
3779
3780#[derive(Debug, Clone, Copy, PartialEq, Hash, Eq, PartialOrd, Ord, Default)]
3781pub enum FontVariantNumeric {
3782    #[default]
3783    Normal,
3784    LiningNums,
3785    OldstyleNums,
3786    ProportionalNums,
3787    TabularNums,
3788    DiagonalFractions,
3789    StackedFractions,
3790    Ordinal,
3791    SlashedZero,
3792}
3793
3794#[derive(Debug, Clone, Copy, PartialEq, Hash, Eq, PartialOrd, Ord, Default)]
3795pub enum FontVariantLigatures {
3796    #[default]
3797    Normal,
3798    None,
3799    Common,
3800    NoCommon,
3801    Discretionary,
3802    NoDiscretionary,
3803    Historical,
3804    NoHistorical,
3805    Contextual,
3806    NoContextual,
3807}
3808
3809#[derive(Debug, Clone, Copy, PartialEq, Hash, Eq, PartialOrd, Ord, Default)]
3810pub enum FontVariantEastAsian {
3811    #[default]
3812    Normal,
3813    Jis78,
3814    Jis83,
3815    Jis90,
3816    Jis04,
3817    Simplified,
3818    Traditional,
3819    FullWidth,
3820    ProportionalWidth,
3821    Ruby,
3822}
3823
3824#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
3825pub struct BidiLevel(u8);
3826
3827impl BidiLevel {
3828    #[must_use] pub const fn new(level: u8) -> Self {
3829        Self(level)
3830    }
3831    #[must_use] pub const fn is_rtl(&self) -> bool {
3832        self.0 % 2 == 1
3833    }
3834    #[must_use] pub const fn level(&self) -> u8 {
3835        self.0
3836    }
3837}
3838
3839// Add this new struct for style overrides
3840#[derive(Debug, Clone)]
3841pub struct StyleOverride {
3842    /// The specific character this override applies to.
3843    pub target: ContentIndex,
3844    /// The style properties to apply.
3845    /// Any `None` value means "inherit from the base style".
3846    pub style: PartialStyleProperties,
3847}
3848
3849#[derive(Debug, Clone, Default)]
3850pub struct PartialStyleProperties {
3851    pub font_stack: Option<FontStack>,
3852    pub font_size_px: Option<f32>,
3853    pub color: Option<ColorU>,
3854    pub letter_spacing: Option<Spacing>,
3855    pub word_spacing: Option<Spacing>,
3856    pub line_height: Option<LineHeight>,
3857    pub text_decoration: Option<TextDecoration>,
3858    pub font_features: Option<Vec<String>>,
3859    pub font_variations: Option<Vec<(FourCc, f32)>>,
3860    pub tab_size: Option<f32>,
3861    pub text_transform: Option<TextTransform>,
3862    pub writing_mode: Option<WritingMode>,
3863    pub text_orientation: Option<TextOrientation>,
3864    pub text_combine_upright: Option<Option<TextCombineUpright>>,
3865    pub font_variant_caps: Option<FontVariantCaps>,
3866    pub font_variant_numeric: Option<FontVariantNumeric>,
3867    pub font_variant_ligatures: Option<FontVariantLigatures>,
3868    pub font_variant_east_asian: Option<FontVariantEastAsian>,
3869}
3870
3871impl Hash for PartialStyleProperties {
3872    fn hash<H: Hasher>(&self, state: &mut H) {
3873        self.font_stack.hash(state);
3874        self.font_size_px.map(f32::to_bits).hash(state);
3875        self.color.hash(state);
3876        self.letter_spacing.hash(state);
3877        self.word_spacing.hash(state);
3878        self.line_height.hash(state);
3879        self.text_decoration.hash(state);
3880        self.font_features.hash(state);
3881
3882        // Manual hashing for Vec<(FourCc, f32)>
3883        if let Some(v) = self.font_variations.as_ref() {
3884            for (tag, val) in v {
3885                tag.hash(state);
3886                val.to_bits().hash(state);
3887            }
3888        }
3889
3890        self.tab_size.map(f32::to_bits).hash(state);
3891        self.text_transform.hash(state);
3892        self.writing_mode.hash(state);
3893        self.text_orientation.hash(state);
3894        self.text_combine_upright.hash(state);
3895        self.font_variant_caps.hash(state);
3896        self.font_variant_numeric.hash(state);
3897        self.font_variant_ligatures.hash(state);
3898        self.font_variant_east_asian.hash(state);
3899    }
3900}
3901
3902impl PartialEq for PartialStyleProperties {
3903    fn eq(&self, other: &Self) -> bool {
3904        self.font_stack == other.font_stack &&
3905        self.font_size_px.map(f32::to_bits) == other.font_size_px.map(f32::to_bits) &&
3906        self.color == other.color &&
3907        self.letter_spacing == other.letter_spacing &&
3908        self.word_spacing == other.word_spacing &&
3909        self.line_height == other.line_height &&
3910        self.text_decoration == other.text_decoration &&
3911        self.font_features == other.font_features &&
3912        self.font_variations == other.font_variations && // Vec<(FourCc, f32)> is PartialEq
3913        self.tab_size.map(f32::to_bits) == other.tab_size.map(f32::to_bits) &&
3914        self.text_transform == other.text_transform &&
3915        self.writing_mode == other.writing_mode &&
3916        self.text_orientation == other.text_orientation &&
3917        self.text_combine_upright == other.text_combine_upright &&
3918        self.font_variant_caps == other.font_variant_caps &&
3919        self.font_variant_numeric == other.font_variant_numeric &&
3920        self.font_variant_ligatures == other.font_variant_ligatures &&
3921        self.font_variant_east_asian == other.font_variant_east_asian
3922    }
3923}
3924
3925impl Eq for PartialStyleProperties {}
3926
3927impl StyleProperties {
3928    fn apply_override(&self, partial: &PartialStyleProperties) -> Self {
3929        let mut new_style = self.clone();
3930        if let Some(val) = &partial.font_stack {
3931            new_style.font_stack = val.clone();
3932        }
3933        if let Some(val) = partial.font_size_px {
3934            new_style.font_size_px = val;
3935        }
3936        if let Some(val) = &partial.color {
3937            new_style.color = *val;
3938        }
3939        if let Some(val) = partial.letter_spacing {
3940            new_style.letter_spacing = val;
3941        }
3942        if let Some(val) = partial.word_spacing {
3943            new_style.word_spacing = val;
3944        }
3945        if let Some(val) = partial.line_height {
3946            new_style.line_height = val;
3947        }
3948        if let Some(val) = &partial.text_decoration {
3949            new_style.text_decoration = *val;
3950        }
3951        if let Some(val) = &partial.font_features {
3952            new_style.font_features.clone_from(val);
3953        }
3954        if let Some(val) = &partial.font_variations {
3955            new_style.font_variations.clone_from(val);
3956        }
3957        if let Some(val) = partial.tab_size {
3958            new_style.tab_size = val;
3959        }
3960        if let Some(val) = partial.text_transform {
3961            new_style.text_transform = val;
3962        }
3963        if let Some(val) = partial.writing_mode {
3964            new_style.writing_mode = val;
3965        }
3966        if let Some(val) = partial.text_orientation {
3967            new_style.text_orientation = val;
3968        }
3969        if let Some(val) = &partial.text_combine_upright {
3970            new_style.text_combine_upright.clone_from(val);
3971        }
3972        if let Some(val) = partial.font_variant_caps {
3973            new_style.font_variant_caps = val;
3974        }
3975        if let Some(val) = partial.font_variant_numeric {
3976            new_style.font_variant_numeric = val;
3977        }
3978        if let Some(val) = partial.font_variant_ligatures {
3979            new_style.font_variant_ligatures = val;
3980        }
3981        if let Some(val) = partial.font_variant_east_asian {
3982            new_style.font_variant_east_asian = val;
3983        }
3984        new_style
3985    }
3986}
3987
3988/// The kind of a glyph, used to distinguish characters from layout-inserted items.
3989#[derive(Debug, Clone, Copy, PartialEq)]
3990pub enum GlyphKind {
3991    /// A standard glyph representing one or more characters from the source text.
3992    Character,
3993    /// A hyphen glyph inserted by the line breaking algorithm.
3994    Hyphen,
3995    /// A `.notdef` glyph, indicating a character that could not be found in any font.
3996    NotDef,
3997    /// A Kashida justification glyph, inserted to stretch Arabic text.
3998    Kashida {
3999        /// The target width of the kashida.
4000        width: f32,
4001    },
4002}
4003
4004// --- Stage 1: Logical Representation ---
4005
4006// [g117 az-web-lift FIX] `#[repr(C, u8)]` (was repr(Rust)) — same disc-mis-lift class as InlineContent
4007// above. LogicalItem is matched in measure Stage-2 (`if let LogicalItem::Text`) + reorder_logical_items;
4008// a repr(Rust) niche disc mis-lifts on the web. Explicit u8 tag at offset 0 = a simple load the lift
4009// reads correctly. Internal to text3 (not FFI-exposed). LogicalItem::Object embeds InlineContent inline.
4010#[derive(Debug, Clone)]
4011#[repr(C, u8)]
4012pub enum LogicalItem {
4013    Text {
4014        /// A stable ID pointing back to the original source character.
4015        source: ContentIndex,
4016        /// The text of this specific logical item (often a single grapheme cluster).
4017        text: String,
4018        style: Arc<StyleProperties>,
4019        /// If this text is a list marker: whether it should be positioned outside
4020        /// (in the padding gutter) or inside (inline with content).
4021        /// None for non-marker content.
4022        marker_position_outside: Option<bool>,
4023        /// The DOM `NodeId` of the Text node this item originated from.
4024        /// None for generated content (list markers, `::before/::after`, etc.)
4025        source_node_id: Option<NodeId>,
4026    },
4027    // +spec:display-property:b1533f - text-combine-upright tate-chu-yoko horizontal-in-vertical composition
4028    /// Tate-chu-yoko: Run of text to be laid out horizontally within a vertical context.
4029    CombinedText {
4030        source: ContentIndex,
4031        text: String,
4032        style: Arc<StyleProperties>,
4033    },
4034    Ruby {
4035        source: ContentIndex,
4036        // For the stub, we simplify to strings. A full implementation
4037        // would need to handle Vec<LogicalItem> for both.
4038        base_text: String,
4039        ruby_text: String,
4040        style: Arc<StyleProperties>,
4041    },
4042    Object {
4043        /// A stable ID pointing back to the original source object.
4044        source: ContentIndex,
4045        /// The original non-text object.
4046        content: InlineContent,
4047    },
4048    Tab {
4049        source: ContentIndex,
4050        style: Arc<StyleProperties>,
4051    },
4052    Break {
4053        source: ContentIndex,
4054        break_info: InlineBreak,
4055    },
4056}
4057
4058impl Hash for LogicalItem {
4059    fn hash<H: Hasher>(&self, state: &mut H) {
4060        discriminant(self).hash(state);
4061        match self {
4062            Self::Text {
4063                source,
4064                text,
4065                style,
4066                marker_position_outside,
4067                source_node_id,
4068            } => {
4069                source.hash(state);
4070                text.hash(state);
4071                style.as_ref().hash(state); // Hash the content, not the Arc pointer
4072                marker_position_outside.hash(state);
4073                source_node_id.hash(state);
4074            }
4075            Self::CombinedText {
4076                source,
4077                text,
4078                style,
4079            } => {
4080                source.hash(state);
4081                text.hash(state);
4082                style.as_ref().hash(state);
4083            }
4084            Self::Ruby {
4085                source,
4086                base_text,
4087                ruby_text,
4088                style,
4089            } => {
4090                source.hash(state);
4091                base_text.hash(state);
4092                ruby_text.hash(state);
4093                style.as_ref().hash(state);
4094            }
4095            Self::Object { source, content } => {
4096                source.hash(state);
4097                content.hash(state);
4098            }
4099            Self::Tab { source, style } => {
4100                source.hash(state);
4101                style.as_ref().hash(state);
4102            }
4103            Self::Break { source, break_info } => {
4104                source.hash(state);
4105                break_info.hash(state);
4106            }
4107        }
4108    }
4109}
4110
4111// --- Stage 2: Visual Representation ---
4112
4113#[derive(Debug, Clone)]
4114pub struct VisualItem {
4115    /// A reference to the logical item this visual item originated from.
4116    /// A single `LogicalItem` can be split into multiple `VisualItems`.
4117    pub logical_source: LogicalItem,
4118    /// The Bidi embedding level for this item.
4119    pub bidi_level: BidiLevel,
4120    /// The script detected for this run, crucial for shaping.
4121    pub script: Script,
4122    /// The text content for this specific visual run.
4123    pub text: String,
4124    /// Byte offset of this visual run's `text` within its source logical run's
4125    /// text. When bidi splits one logical run into several visual runs, each
4126    /// shaped cluster's `start_byte_in_run` is produced relative to this visual
4127    /// run's `text`; adding `run_byte_offset` re-bases it to the logical run so
4128    /// cluster IDs stay unique and match caret/selection byte positions.
4129    pub run_byte_offset: usize,
4130}
4131
4132// --- Stage 3: Shaped Representation ---
4133
4134// [g118 az-web-lift FIX] `#[repr(C, u8)]` (was repr(Rust)) — same disc-mis-lift class as InlineContent
4135// + LogicalItem (g117). ShapedItem is matched in measure Stage-5 (`match item { ShapedItem::Cluster ..}`)
4136// + cloned/matched throughout shaping; a repr(Rust) niche disc mis-lifts on the web. Explicit u8 tag at
4137// offset 0 = a simple load the lift reads correctly. Internal to text3 (not FFI-exposed).
4138#[derive(Debug, Clone)]
4139#[repr(C, u8)]
4140pub enum ShapedItem {
4141    Cluster(ShapedCluster),
4142    /// A block of combined text (tate-chu-yoko) that is laid out
4143    // as a single unbreakable object.
4144    CombinedBlock {
4145        source: ContentIndex,
4146        /// The glyphs to be rendered horizontally within the vertical line.
4147        glyphs: ShapedGlyphVec,
4148        bounds: Rect,
4149        baseline_offset: f32,
4150    },
4151    Object {
4152        source: ContentIndex,
4153        bounds: Rect,
4154        baseline_offset: f32,
4155        // Store original object for rendering
4156        content: InlineContent,
4157    },
4158    Tab {
4159        source: ContentIndex,
4160        bounds: Rect,
4161    },
4162    Break {
4163        source: ContentIndex,
4164        break_info: InlineBreak,
4165    },
4166}
4167
4168impl ShapedItem {
4169    #[must_use] pub const fn as_cluster(&self) -> Option<&ShapedCluster> {
4170        match self {
4171            Self::Cluster(c) => Some(c),
4172            _ => None,
4173        }
4174    }
4175    /// Returns the bounding box of the item, relative to its own origin.
4176    ///
4177    /// The origin of the returned `Rect` is `(0,0)`, representing the top-left corner
4178    /// of the item's layout space before final positioning. The size represents the
4179    /// item's total advance (width in horizontal mode) and its line height (ascent + descent).
4180    #[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
4181    #[must_use] pub fn bounds(&self) -> Rect {
4182        match self {
4183            Self::Cluster(cluster) => {
4184                // The width of a text cluster is its total advance.
4185                let width = cluster.advance;
4186
4187                // The height is the sum of its ascent and descent, which defines its line box.
4188                // We use the existing helper function which correctly calculates this from font
4189                // metrics.
4190                let (ascent, descent) = get_item_vertical_metrics_approx(self);
4191                let height = ascent + descent;
4192
4193                Rect {
4194                    x: 0.0,
4195                    y: 0.0,
4196                    width,
4197                    height,
4198                }
4199            }
4200            // For atomic inline items like objects, combined blocks, and tabs,
4201            // their bounds have already been calculated during the shaping or measurement phase.
4202            Self::CombinedBlock { bounds, .. } => *bounds,
4203            Self::Object { bounds, .. } => *bounds,
4204            Self::Tab { bounds, .. } => *bounds,
4205
4206            // Breaks are control characters and have no visual geometry.
4207            Self::Break { .. } => Rect::default(), // A zero-sized rectangle.
4208        }
4209    }
4210}
4211
4212/// A group of glyphs that corresponds to one or more source characters (a cluster).
4213#[derive(Debug, Clone)]
4214pub struct ShapedCluster {
4215    /// The original text that this cluster was shaped from.
4216    /// This is crucial for correct hyphenation.
4217    pub text: String,
4218    /// The ID of the grapheme cluster this glyph cluster represents.
4219    pub source_cluster_id: GraphemeClusterId,
4220    /// The source `ContentIndex` for mapping back to logical items.
4221    pub source_content_index: ContentIndex,
4222    /// The DOM `NodeId` of the Text node this cluster originated from.
4223    /// None for generated content (list markers, `::before/::after`, etc.)
4224    pub source_node_id: Option<NodeId>,
4225    /// The glyphs that make up this cluster. `SmallVec<[T; 1]>` — inline
4226    /// single-glyph clusters (the common case for Latin text), spill to
4227    /// heap only for ligatures / combining marks.
4228    pub glyphs: ShapedGlyphVec,
4229    /// The total advance width (horizontal) or height (vertical) of the cluster.
4230    pub advance: f32,
4231    /// The direction of this cluster, inherited from its `VisualItem`.
4232    pub direction: BidiDirection,
4233    /// Font style of this cluster
4234    pub style: Arc<StyleProperties>,
4235    /// If this cluster is a list marker: whether it should be positioned outside
4236    /// (in the padding gutter) or inside (inline with content).
4237    /// None for non-marker content.
4238    pub marker_position_outside: Option<bool>,
4239    /// True if this is the first visual fragment of its inline box.
4240    /// Used for `box-decoration-break` and split inline border/padding.
4241    /// When an inline element wraps across lines, only the first fragment
4242    /// gets the start-edge border/padding.
4243    pub is_first_fragment: bool,
4244    /// True if this is the last visual fragment of its inline box.
4245    /// Only the last fragment gets the end-edge border/padding.
4246    pub is_last_fragment: bool,
4247}
4248
4249/// A single, shaped glyph with its essential metrics.
4250#[derive(Debug, Clone)]
4251pub struct ShapedGlyph {
4252    /// The kind of glyph this is (character, hyphen, etc.).
4253    pub kind: GlyphKind,
4254    /// Glyph ID inside of the font
4255    pub glyph_id: u16,
4256    /// The byte offset of this glyph's source character(s) within its cluster text.
4257    pub cluster_offset: u32,
4258    /// The horizontal advance for this glyph (for horizontal text) - this is the BASE advance
4259    /// from the font metrics, WITHOUT kerning applied
4260    pub advance: f32,
4261    /// The kerning adjustment for this glyph (positive = more space, negative = less space)
4262    /// This is separate from advance so we can position glyphs absolutely
4263    pub kerning: f32,
4264    /// The horizontal offset/bearing for this glyph
4265    pub offset: Point,
4266    /// The vertical advance for this glyph (for vertical text).
4267    pub vertical_advance: f32,
4268    /// The vertical offset/bearing for this glyph.
4269    pub vertical_offset: Point,
4270    pub script: Script,
4271    pub style: Arc<StyleProperties>,
4272    /// Hash of the font - use `LoadedFonts` to look up the actual font when needed
4273    pub font_hash: u64,
4274    /// Cached font metrics to avoid font lookup for common operations
4275    pub font_metrics: LayoutFontMetrics,
4276}
4277
4278impl ShapedGlyph {
4279    #[must_use] pub fn into_glyph_instance<T: ParsedFontTrait>(
4280        &self,
4281        writing_mode: WritingMode,
4282        loaded_fonts: &LoadedFonts<T>,
4283    ) -> GlyphInstance {
4284        let size = loaded_fonts
4285            .get_by_hash(self.font_hash)
4286            .and_then(|font| font.get_glyph_size(self.glyph_id, self.style.font_size_px))
4287            .unwrap_or_default();
4288
4289        let position = if writing_mode.is_advance_horizontal() {
4290            LogicalPosition {
4291                x: self.offset.x,
4292                y: self.offset.y,
4293            }
4294        } else {
4295            LogicalPosition {
4296                x: self.vertical_offset.x,
4297                y: self.vertical_offset.y,
4298            }
4299        };
4300
4301        GlyphInstance {
4302            index: u32::from(self.glyph_id),
4303            point: position,
4304            size,
4305        }
4306    }
4307
4308    /// Convert this `ShapedGlyph` into a `GlyphInstance` with an absolute position.
4309    /// This is used for display list generation where glyphs need their final page coordinates.
4310    #[must_use] pub fn into_glyph_instance_at<T: ParsedFontTrait>(
4311        &self,
4312        writing_mode: WritingMode,
4313        absolute_position: LogicalPosition,
4314        loaded_fonts: &LoadedFonts<T>,
4315    ) -> GlyphInstance {
4316        let size = loaded_fonts
4317            .get_by_hash(self.font_hash)
4318            .and_then(|font| font.get_glyph_size(self.glyph_id, self.style.font_size_px))
4319            .unwrap_or_default();
4320
4321        GlyphInstance {
4322            index: u32::from(self.glyph_id),
4323            point: absolute_position,
4324            size,
4325        }
4326    }
4327
4328    /// Convert this `ShapedGlyph` into a `GlyphInstance` with an absolute position.
4329    /// This version doesn't require fonts - it uses a default size.
4330    /// Use this when you don't need precise glyph bounds (e.g., display list generation).
4331    #[must_use] pub fn into_glyph_instance_at_simple(
4332        &self,
4333        _writing_mode: WritingMode,
4334        absolute_position: LogicalPosition,
4335    ) -> GlyphInstance {
4336        // Use font metrics to estimate size, or default to zero
4337        // The actual rendering will use the font directly
4338        GlyphInstance {
4339            index: u32::from(self.glyph_id),
4340            point: absolute_position,
4341            size: LogicalSize::default(),
4342        }
4343    }
4344}
4345
4346// --- Stage 4: Positioned Representation (Final Layout) ---
4347
4348#[derive(Debug, Clone)]
4349pub struct PositionedItem {
4350    pub item: ShapedItem,
4351    pub position: Point,
4352    pub line_index: usize,
4353}
4354
4355#[derive(Debug, Clone)]
4356pub struct UnifiedLayout {
4357    pub items: Vec<PositionedItem>,
4358    /// Information about content that did not fit.
4359    pub overflow: OverflowInfo,
4360}
4361
4362impl UnifiedLayout {
4363    /// Calculate the bounding box of all positioned items.
4364    /// This is computed on-demand rather than cached.
4365    #[must_use] pub fn bounds(&self) -> Rect {
4366        if self.items.is_empty() {
4367            return Rect::default();
4368        }
4369
4370        let mut min_x = f32::MAX;
4371        let mut min_y = f32::MAX;
4372        let mut max_x = f32::MIN;
4373        let mut max_y = f32::MIN;
4374
4375        for item in &self.items {
4376            let item_x = item.position.x;
4377            let item_y = item.position.y;
4378
4379            // Get item dimensions
4380            let item_bounds = item.item.bounds();
4381            let item_width = item_bounds.width;
4382            let item_height = item_bounds.height;
4383
4384            min_x = min_x.min(item_x);
4385            min_y = min_y.min(item_y);
4386            max_x = max_x.max(item_x + item_width);
4387            max_y = max_y.max(item_y + item_height);
4388        }
4389
4390        Rect {
4391            x: min_x,
4392            y: min_y,
4393            width: max_x - min_x,
4394            height: max_y - min_y,
4395        }
4396    }
4397
4398    #[must_use] pub const fn is_empty(&self) -> bool {
4399        self.items.is_empty()
4400    }
4401    #[must_use] pub fn first_baseline(&self) -> Option<f32> {
4402        self.items
4403            .iter()
4404            .find_map(|item| get_baseline_for_item(&item.item))
4405    }
4406
4407    #[must_use] pub fn last_baseline(&self) -> Option<f32> {
4408        self.items
4409            .iter()
4410            .rev()
4411            .find_map(|item| get_baseline_for_item(&item.item))
4412    }
4413
4414    /// Takes a point relative to the layout's origin and returns the closest
4415    /// logical cursor position.
4416    ///
4417    /// This is the unified hit-testing implementation. The old `hit_test_to_cursor`
4418    /// method is deprecated in favor of this one.
4419    #[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
4420    #[must_use] pub fn hittest_cursor(&self, point: LogicalPosition) -> Option<TextCursor> {
4421        if self.items.is_empty() {
4422            return None;
4423        }
4424
4425        // Find the closest cluster vertically and horizontally
4426        let mut closest_item_idx = 0;
4427        let mut closest_distance = f32::MAX;
4428
4429        for (idx, item) in self.items.iter().enumerate() {
4430            // Only consider cluster items for cursor placement
4431            if !matches!(item.item, ShapedItem::Cluster(_)) {
4432                continue;
4433            }
4434
4435            let item_bounds = item.item.bounds();
4436            let item_center_y = item.position.y + item_bounds.height / 2.0;
4437
4438            // Distance from click position to item center
4439            let vertical_distance = (point.y - item_center_y).abs();
4440
4441            // For horizontal distance, check if we're within the cluster bounds
4442            let horizontal_distance = if point.x < item.position.x {
4443                item.position.x - point.x
4444            } else if point.x > item.position.x + item_bounds.width {
4445                point.x - (item.position.x + item_bounds.width)
4446            } else {
4447                0.0 // Inside the cluster horizontally
4448            };
4449
4450            // Combined distance (prioritize vertical proximity)
4451            let distance = vertical_distance * 2.0 + horizontal_distance;
4452
4453            if distance < closest_distance {
4454                closest_distance = distance;
4455                closest_item_idx = idx;
4456            }
4457        }
4458
4459        // Get the closest cluster
4460        let closest_item = &self.items[closest_item_idx];
4461        let cluster = match &closest_item.item {
4462            ShapedItem::Cluster(c) => c,
4463            // Objects are treated as a single cluster for selection
4464            ShapedItem::Object { source, .. } | ShapedItem::CombinedBlock { source, .. } => {
4465                return Some(TextCursor {
4466                    cluster_id: GraphemeClusterId {
4467                        source_run: source.run_index,
4468                        start_byte_in_run: source.item_index,
4469                    },
4470                    affinity: if point.x
4471                        < closest_item.position.x + (closest_item.item.bounds().width / 2.0)
4472                    {
4473                        CursorAffinity::Leading
4474                    } else {
4475                        CursorAffinity::Trailing
4476                    },
4477                });
4478            }
4479            _ => return None,
4480        };
4481
4482        // Determine affinity based on which half of the cluster was clicked
4483        let cluster_mid_x = closest_item.position.x + cluster.advance / 2.0;
4484        let affinity = if point.x < cluster_mid_x {
4485            CursorAffinity::Leading
4486        } else {
4487            CursorAffinity::Trailing
4488        };
4489
4490        Some(TextCursor {
4491            cluster_id: cluster.source_cluster_id,
4492            affinity,
4493        })
4494    }
4495
4496    /// Given a logical selection range, returns a vector of visual rectangles
4497    /// that cover the selected text, in the layout's coordinate space.
4498    #[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
4499    #[must_use] pub fn get_selection_rects(&self, range: &SelectionRange) -> Vec<LogicalRect> {
4500        // 1. Build a map from the logical cluster ID to the visual PositionedItem for fast lookups.
4501        let mut cluster_map: HashMap<GraphemeClusterId, &PositionedItem> = HashMap::new();
4502        for item in &self.items {
4503            if let Some(cluster) = item.item.as_cluster() {
4504                cluster_map.insert(cluster.source_cluster_id, item);
4505            }
4506        }
4507
4508        // 2. Normalize the range to ensure start always logically precedes end.
4509        let (start_cursor, end_cursor) = if range.start.cluster_id > range.end.cluster_id
4510            || (range.start.cluster_id == range.end.cluster_id
4511                && range.start.affinity > range.end.affinity)
4512        {
4513            (range.end, range.start)
4514        } else {
4515            (range.start, range.end)
4516        };
4517
4518        // 3. Find the positioned items corresponding to the start and end of the selection.
4519        let Some(start_item) = cluster_map.get(&start_cursor.cluster_id) else {
4520            return Vec::new();
4521        };
4522        let Some(end_item) = cluster_map.get(&end_cursor.cluster_id) else {
4523            return Vec::new();
4524        };
4525
4526        let mut rects = Vec::new();
4527
4528        // Helper to get the absolute visual X coordinate of a cursor. The logical
4529        // start (Leading) edge is the cluster's LEFT for LTR but its RIGHT for RTL;
4530        // Trailing is the mirror.
4531        let get_cursor_x = |item: &PositionedItem, affinity: CursorAffinity| -> f32 {
4532            let left = item.position.x;
4533            let right = item.position.x + get_item_measure(&item.item, false);
4534            let rtl = item.item.as_cluster().is_some_and(|c| c.direction.is_rtl());
4535            match (affinity, rtl) {
4536                (CursorAffinity::Leading, false) | (CursorAffinity::Trailing, true) => left,
4537                (CursorAffinity::Trailing, false) | (CursorAffinity::Leading, true) => right,
4538            }
4539        };
4540
4541        // Helper to get the visual bounding box of all content on a specific line index.
4542        let get_line_bounds = |line_index: usize| -> Option<LogicalRect> {
4543            let items_on_line = self.items.iter().filter(|i| i.line_index == line_index);
4544
4545            let mut min_x: Option<f32> = None;
4546            let mut max_x: Option<f32> = None;
4547            let mut min_y: Option<f32> = None;
4548            let mut max_y: Option<f32> = None;
4549
4550            for item in items_on_line {
4551                // Skip items that don't take up space (like hard breaks)
4552                let item_bounds = item.item.bounds();
4553                if item_bounds.width <= 0.0 && item_bounds.height <= 0.0 {
4554                    continue;
4555                }
4556
4557                let item_x_end = item.position.x + item_bounds.width;
4558                let item_y_end = item.position.y + item_bounds.height;
4559
4560                min_x = Some(min_x.map_or(item.position.x, |mx| mx.min(item.position.x)));
4561                max_x = Some(max_x.map_or(item_x_end, |mx| mx.max(item_x_end)));
4562                min_y = Some(min_y.map_or(item.position.y, |my| my.min(item.position.y)));
4563                max_y = Some(max_y.map_or(item_y_end, |my| my.max(item_y_end)));
4564            }
4565
4566            if let (Some(min_x), Some(max_x), Some(min_y), Some(max_y)) =
4567                (min_x, max_x, min_y, max_y)
4568            {
4569                Some(LogicalRect {
4570                    origin: LogicalPosition { x: min_x, y: min_y },
4571                    size: LogicalSize {
4572                        width: max_x - min_x,
4573                        height: max_y - min_y,
4574                    },
4575                })
4576            } else {
4577                None
4578            }
4579        };
4580
4581        // 4. Handle single-line selection.
4582        if start_item.line_index == end_item.line_index {
4583            if let Some(line_bounds) = get_line_bounds(start_item.line_index) {
4584                // Walk the selected clusters in VISUAL order and group them into
4585                // segments by bidi direction + visual contiguity, emitting one rect
4586                // per segment. A single endpoint-to-endpoint span over-covers (and can
4587                // under-cover) bidi selections, whose logically-contiguous clusters are
4588                // NOT visually contiguous. Pure-LTR/RTL contiguous runs collapse to a
4589                // single rect, matching browser/CoreText behavior.
4590                let mut segments: Vec<(f32, f32, BidiDirection)> = Vec::new();
4591                for item in &self.items {
4592                    if item.line_index != start_item.line_index {
4593                        continue;
4594                    }
4595                    let Some(c) = item.item.as_cluster() else {
4596                        continue;
4597                    };
4598                    let id = c.source_cluster_id;
4599                    // A cluster is selected when it lies within the (affinity-aware)
4600                    // logical range: the start cluster is included only if the start
4601                    // cursor sits on its leading edge; the end cluster only if the end
4602                    // cursor sits on its trailing edge.
4603                    let after_start = id > start_cursor.cluster_id
4604                        || (id == start_cursor.cluster_id
4605                            && start_cursor.affinity == CursorAffinity::Leading);
4606                    let before_end = id < end_cursor.cluster_id
4607                        || (id == end_cursor.cluster_id
4608                            && end_cursor.affinity == CursorAffinity::Trailing);
4609                    if !(after_start && before_end) {
4610                        continue;
4611                    }
4612                    let x0 = item.position.x;
4613                    let x1 = item.position.x + get_item_measure(&item.item, false);
4614                    let (lo, hi) = (x0.min(x1), x0.max(x1));
4615                    if let Some(last) = segments.last_mut() {
4616                        let contiguous = lo <= last.1 + 0.5 && hi >= last.0 - 0.5;
4617                        if last.2 == c.direction && contiguous {
4618                            last.0 = last.0.min(lo);
4619                            last.1 = last.1.max(hi);
4620                            continue;
4621                        }
4622                    }
4623                    segments.push((lo, hi, c.direction));
4624                }
4625
4626                if segments.is_empty() {
4627                    // No glyph-bearing clusters (e.g. zero-advance selection):
4628                    // fall back to the endpoint span so a caret-width rect still shows.
4629                    let start_x = get_cursor_x(start_item, start_cursor.affinity);
4630                    let end_x = get_cursor_x(end_item, end_cursor.affinity);
4631                    rects.push(LogicalRect {
4632                        origin: LogicalPosition {
4633                            x: start_x.min(end_x),
4634                            y: line_bounds.origin.y,
4635                        },
4636                        size: LogicalSize {
4637                            width: (end_x - start_x).abs(),
4638                            height: line_bounds.size.height,
4639                        },
4640                    });
4641                } else {
4642                    for (lo, hi, _dir) in segments {
4643                        rects.push(LogicalRect {
4644                            origin: LogicalPosition {
4645                                x: lo,
4646                                y: line_bounds.origin.y,
4647                            },
4648                            size: LogicalSize {
4649                                width: hi - lo,
4650                                height: line_bounds.size.height,
4651                            },
4652                        });
4653                    }
4654                }
4655            }
4656        }
4657        // 5. Handle multi-line selection.
4658        else {
4659            // Rectangle for the start line (from the start cursor to the line's end
4660            // in READING order). For an LTR line that is rightward (to the line's
4661            // right content edge); for an RTL line it is leftward (to the left edge).
4662            if let Some(start_line_bounds) = get_line_bounds(start_item.line_index) {
4663                let start_x = get_cursor_x(start_item, start_cursor.affinity);
4664                let line_left = start_line_bounds.origin.x;
4665                let line_right = start_line_bounds.origin.x + start_line_bounds.size.width;
4666                let rtl = start_item.item.as_cluster().is_some_and(|c| c.direction.is_rtl());
4667                let (lo, hi) = if rtl { (line_left, start_x) } else { (start_x, line_right) };
4668                rects.push(LogicalRect {
4669                    origin: LogicalPosition {
4670                        x: lo,
4671                        y: start_line_bounds.origin.y,
4672                    },
4673                    size: LogicalSize {
4674                        width: hi - lo,
4675                        height: start_line_bounds.size.height,
4676                    },
4677                });
4678            }
4679
4680            // Rectangles for all full lines in between.
4681            for line_idx in (start_item.line_index + 1)..end_item.line_index {
4682                if let Some(line_bounds) = get_line_bounds(line_idx) {
4683                    rects.push(line_bounds);
4684                }
4685            }
4686
4687            // Rectangle for the end line (from the line's start in READING order to
4688            // the end cursor). For an LTR line that starts at the left content edge;
4689            // for an RTL line it starts at the right edge.
4690            if let Some(end_line_bounds) = get_line_bounds(end_item.line_index) {
4691                let end_x = get_cursor_x(end_item, end_cursor.affinity);
4692                let line_left = end_line_bounds.origin.x;
4693                let line_right = end_line_bounds.origin.x + end_line_bounds.size.width;
4694                let rtl = end_item.item.as_cluster().is_some_and(|c| c.direction.is_rtl());
4695                let (lo, hi) = if rtl { (end_x, line_right) } else { (line_left, end_x) };
4696                rects.push(LogicalRect {
4697                    origin: LogicalPosition {
4698                        x: lo,
4699                        y: end_line_bounds.origin.y,
4700                    },
4701                    size: LogicalSize {
4702                        width: hi - lo,
4703                        height: end_line_bounds.size.height,
4704                    },
4705                });
4706            }
4707        }
4708
4709        rects
4710    }
4711
4712    /// Calculates the visual rectangle for a cursor at a given logical position.
4713    #[must_use] pub fn get_cursor_rect(&self, cursor: &TextCursor) -> Option<LogicalRect> {
4714        // Find the item and glyph corresponding to the cursor's cluster ID.
4715        let mut last_cluster: Option<(&PositionedItem, &ShapedCluster)> = None;
4716        for item in &self.items {
4717            if let ShapedItem::Cluster(cluster) = &item.item {
4718                if cluster.source_cluster_id == cursor.cluster_id {
4719                    // Exact match
4720                    let line_height = item.item.bounds().height;
4721                    // The logical-start (Leading) caret edge is the glyph's LEFT side for
4722                    // an LTR cluster but its RIGHT side for an RTL cluster; Trailing is the
4723                    // mirror. Resolve the edges from the cluster's own bidi direction.
4724                    let (lead_x, trail_x) = if cluster.direction.is_rtl() {
4725                        (item.position.x + cluster.advance, item.position.x)
4726                    } else {
4727                        (item.position.x, item.position.x + cluster.advance)
4728                    };
4729                    let cursor_x = match cursor.affinity {
4730                        CursorAffinity::Leading => lead_x,
4731                        CursorAffinity::Trailing => trail_x,
4732                    };
4733                    return Some(LogicalRect {
4734                        origin: LogicalPosition {
4735                            x: cursor_x,
4736                            y: item.position.y,
4737                        },
4738                        size: LogicalSize {
4739                            width: 1.0,
4740                            height: line_height,
4741                        },
4742                    });
4743                }
4744                last_cluster = Some((item, cluster));
4745            }
4746        }
4747        // Cursor past end of text: position after the last cluster
4748        if let Some((item, cluster)) = last_cluster {
4749            if cursor.cluster_id.source_run == cluster.source_cluster_id.source_run
4750                && cursor.cluster_id.start_byte_in_run >= cluster.source_cluster_id.start_byte_in_run
4751            {
4752                let line_height = item.item.bounds().height;
4753                // Past the logical end of the run: the caret sits after the last cluster,
4754                // which is its RIGHT edge for LTR but its LEFT edge for RTL.
4755                let past_end_x = if cluster.direction.is_rtl() {
4756                    item.position.x
4757                } else {
4758                    item.position.x + cluster.advance
4759                };
4760                return Some(LogicalRect {
4761                    origin: LogicalPosition {
4762                        x: past_end_x,
4763                        y: item.position.y,
4764                    },
4765                    size: LogicalSize {
4766                        width: 1.0,
4767                        height: line_height,
4768                    },
4769                });
4770            }
4771        }
4772        None
4773    }
4774
4775    /// Get a cursor at the first cluster (leading edge) in the layout.
4776    #[must_use] pub fn get_first_cluster_cursor(&self) -> Option<TextCursor> {
4777        for item in &self.items {
4778            if let ShapedItem::Cluster(cluster) = &item.item {
4779                return Some(TextCursor {
4780                    cluster_id: cluster.source_cluster_id,
4781                    affinity: CursorAffinity::Leading,
4782                });
4783            }
4784        }
4785        None
4786    }
4787
4788    /// Get a cursor at the last cluster (trailing edge) in the layout.
4789    #[must_use] pub fn get_last_cluster_cursor(&self) -> Option<TextCursor> {
4790        for item in self.items.iter().rev() {
4791            if let ShapedItem::Cluster(cluster) = &item.item {
4792                return Some(TextCursor {
4793                    cluster_id: cluster.source_cluster_id,
4794                    affinity: CursorAffinity::Trailing,
4795                });
4796            }
4797        }
4798        None
4799    }
4800
4801    /// Logical sequence of caret-stop grapheme clusters, sorted by
4802    /// `(source_run, start_byte_in_run)` and de-duplicated, with combining-mark
4803    /// continuations folded into their base (UAX#29). Left/right caret motion
4804    /// advances over THIS sequence so a base and its combining marks move as one
4805    /// unit, and so the document start/end are always reachable.
4806    fn grapheme_stops(&self) -> Vec<GraphemeClusterId> {
4807        let mut stops: Vec<(GraphemeClusterId, &str)> = self
4808            .items
4809            .iter()
4810            .filter_map(|it| {
4811                it.item
4812                    .as_cluster()
4813                    .map(|c| (c.source_cluster_id, c.text.as_str()))
4814            })
4815            .collect();
4816        stops.sort_by(|a, b| {
4817            (a.0.source_run, a.0.start_byte_in_run).cmp(&(b.0.source_run, b.0.start_byte_in_run))
4818        });
4819        stops.dedup_by_key(|(id, _)| *id);
4820        stops
4821            .into_iter()
4822            .filter(|(_, text)| !Self::cluster_is_grapheme_continuation(text))
4823            .map(|(id, _)| id)
4824            .collect()
4825    }
4826
4827    /// True if `text`'s leading char is a grapheme extender (combining mark,
4828    /// variation selector, …) — a cluster that merges into a preceding base and
4829    /// therefore must not be a standalone caret stop (UAX#29).
4830    fn cluster_is_grapheme_continuation(text: &str) -> bool {
4831        let Some(first) = text.chars().next() else {
4832            return false;
4833        };
4834        // Probe with a dummy base letter: if `x` + first collapses to a single
4835        // grapheme, `first` extends the preceding grapheme.
4836        let mut probe = String::with_capacity(1 + first.len_utf8());
4837        probe.push('x');
4838        probe.push(first);
4839        probe.graphemes(true).count() == 1
4840    }
4841
4842    /// Caret offset of `cursor` within `stops` (0..=len): the index of its
4843    /// grapheme, plus 1 for a Trailing affinity. A cursor addressing a folded
4844    /// combining mark (or otherwise between stops) maps to the nearest preceding
4845    /// stop.
4846    fn grapheme_caret_offset(stops: &[GraphemeClusterId], cursor: &TextCursor) -> Option<usize> {
4847        let trailing = usize::from(cursor.affinity == CursorAffinity::Trailing);
4848        if let Some(idx) = stops.iter().position(|id| *id == cursor.cluster_id) {
4849            return Some(idx + trailing);
4850        }
4851        let key = (cursor.cluster_id.source_run, cursor.cluster_id.start_byte_in_run);
4852        let idx = stops
4853            .iter()
4854            .rposition(|id| (id.source_run, id.start_byte_in_run) <= key)?;
4855        Some(idx + trailing)
4856    }
4857
4858    /// Canonical cursor for a grapheme-stop `offset` (0..=len): interior/first
4859    /// offsets are the Leading edge of the stop that begins there; `len` is the
4860    /// Trailing edge of the last stop (the document end).
4861    fn cursor_from_grapheme_offset(stops: &[GraphemeClusterId], offset: usize) -> TextCursor {
4862        let n = stops.len();
4863        if offset >= n {
4864            TextCursor { cluster_id: stops[n - 1], affinity: CursorAffinity::Trailing }
4865        } else {
4866            TextCursor { cluster_id: stops[offset], affinity: CursorAffinity::Leading }
4867        }
4868    }
4869
4870    /// Moves a cursor one visible position to the left (the previous grapheme
4871    /// boundary). Affinity is consulted so each press moves exactly one stop and
4872    /// the document start (first grapheme, Leading) is reachable; combining marks
4873    /// move together with their base.
4874    pub fn move_cursor_left(
4875        &self,
4876        cursor: TextCursor,
4877        debug: &mut Option<Vec<String>>,
4878    ) -> TextCursor {
4879        let stops = self.grapheme_stops();
4880        if stops.is_empty() {
4881            return cursor;
4882        }
4883        let Some(offset) = Self::grapheme_caret_offset(&stops, &cursor) else {
4884            return cursor;
4885        };
4886        let moved = Self::cursor_from_grapheme_offset(&stops, offset.saturating_sub(1));
4887        if let Some(d) = debug {
4888            d.push(format!(
4889                "[Cursor] move_cursor_left: byte {} -> byte {}",
4890                cursor.cluster_id.start_byte_in_run, moved.cluster_id.start_byte_in_run
4891            ));
4892        }
4893        moved
4894    }
4895
4896    /// Moves a cursor one visible position to the right (the next grapheme
4897    /// boundary). Affinity is consulted so each press moves exactly one stop and
4898    /// the document end (last grapheme, Trailing) is reachable; combining marks
4899    /// move together with their base.
4900    pub fn move_cursor_right(
4901        &self,
4902        cursor: TextCursor,
4903        debug: &mut Option<Vec<String>>,
4904    ) -> TextCursor {
4905        let stops = self.grapheme_stops();
4906        if stops.is_empty() {
4907            return cursor;
4908        }
4909        let Some(offset) = Self::grapheme_caret_offset(&stops, &cursor) else {
4910            return cursor;
4911        };
4912        let moved = Self::cursor_from_grapheme_offset(&stops, (offset + 1).min(stops.len()));
4913        if let Some(d) = debug {
4914            d.push(format!(
4915                "[Cursor] move_cursor_right: byte {} -> byte {}",
4916                cursor.cluster_id.start_byte_in_run, moved.cluster_id.start_byte_in_run
4917            ));
4918        }
4919        moved
4920    }
4921
4922    /// Moves a cursor up one line, attempting to preserve the horizontal column.
4923    pub fn move_cursor_up(
4924        &self,
4925        cursor: TextCursor,
4926        goal_x: &mut Option<f32>,
4927        debug: &mut Option<Vec<String>>,
4928    ) -> TextCursor {
4929        if let Some(d) = debug {
4930            d.push(format!(
4931                "[Cursor] move_cursor_up: from byte {} (affinity {:?})",
4932                cursor.cluster_id.start_byte_in_run, cursor.affinity
4933            ));
4934        }
4935
4936        let Some(current_item) = self.items.iter().find(|i| {
4937            i.item
4938                .as_cluster()
4939                .is_some_and(|c| c.source_cluster_id == cursor.cluster_id)
4940        }) else {
4941            if let Some(d) = debug {
4942                d.push(format!(
4943                    "[Cursor] move_cursor_up: cursor not found in items, staying at byte {}",
4944                    cursor.cluster_id.start_byte_in_run
4945                ));
4946            }
4947            return cursor;
4948        };
4949
4950        if let Some(d) = debug {
4951            d.push(format!(
4952                "[Cursor] move_cursor_up: current line {}, position ({}, {})",
4953                current_item.line_index, current_item.position.x, current_item.position.y
4954            ));
4955        }
4956
4957        let target_line_idx = current_item.line_index.saturating_sub(1);
4958        if current_item.line_index == target_line_idx {
4959            if let Some(d) = debug {
4960                d.push(format!(
4961                    "[Cursor] move_cursor_up: already at top line {}, staying put",
4962                    current_item.line_index
4963                ));
4964            }
4965            return cursor;
4966        }
4967
4968        let current_x = goal_x.unwrap_or_else(|| {
4969            let x = match cursor.affinity {
4970                CursorAffinity::Leading => current_item.position.x,
4971                CursorAffinity::Trailing => {
4972                    current_item.position.x + get_item_measure(&current_item.item, false)
4973                }
4974            };
4975            *goal_x = Some(x);
4976            x
4977        });
4978
4979        // Find the Y coordinate of the middle of the target line
4980        let target_y = self
4981            .items
4982            .iter()
4983            .find(|i| i.line_index == target_line_idx)
4984            .map_or(current_item.position.y, |i| i.position.y + (i.item.bounds().height / 2.0));
4985
4986        if let Some(d) = debug {
4987            d.push(format!(
4988                "[Cursor] move_cursor_up: target line {target_line_idx}, hittesting at ({current_x}, {target_y})"
4989            ));
4990        }
4991
4992        let result = self
4993            .hittest_cursor(LogicalPosition {
4994                x: current_x,
4995                y: target_y,
4996            })
4997            .unwrap_or(cursor);
4998
4999        if let Some(d) = debug {
5000            d.push(format!(
5001                "[Cursor] move_cursor_up: result byte {} (affinity {:?})",
5002                result.cluster_id.start_byte_in_run, result.affinity
5003            ));
5004        }
5005
5006        result
5007    }
5008
5009    /// Moves a cursor down one line, attempting to preserve the horizontal column.
5010    pub fn move_cursor_down(
5011        &self,
5012        cursor: TextCursor,
5013        goal_x: &mut Option<f32>,
5014        debug: &mut Option<Vec<String>>,
5015    ) -> TextCursor {
5016        if let Some(d) = debug {
5017            d.push(format!(
5018                "[Cursor] move_cursor_down: from byte {} (affinity {:?})",
5019                cursor.cluster_id.start_byte_in_run, cursor.affinity
5020            ));
5021        }
5022
5023        let Some(current_item) = self.items.iter().find(|i| {
5024            i.item
5025                .as_cluster()
5026                .is_some_and(|c| c.source_cluster_id == cursor.cluster_id)
5027        }) else {
5028            if let Some(d) = debug {
5029                d.push(format!(
5030                    "[Cursor] move_cursor_down: cursor not found in items, staying at byte {}",
5031                    cursor.cluster_id.start_byte_in_run
5032                ));
5033            }
5034            return cursor;
5035        };
5036
5037        if let Some(d) = debug {
5038            d.push(format!(
5039                "[Cursor] move_cursor_down: current line {}, position ({}, {})",
5040                current_item.line_index, current_item.position.x, current_item.position.y
5041            ));
5042        }
5043
5044        let max_line = self.items.iter().map(|i| i.line_index).max().unwrap_or(0);
5045        let target_line_idx = (current_item.line_index + 1).min(max_line);
5046        if current_item.line_index == target_line_idx {
5047            if let Some(d) = debug {
5048                d.push(format!(
5049                    "[Cursor] move_cursor_down: already at bottom line {}, staying put",
5050                    current_item.line_index
5051                ));
5052            }
5053            return cursor;
5054        }
5055
5056        let current_x = goal_x.unwrap_or_else(|| {
5057            let x = match cursor.affinity {
5058                CursorAffinity::Leading => current_item.position.x,
5059                CursorAffinity::Trailing => {
5060                    current_item.position.x + get_item_measure(&current_item.item, false)
5061                }
5062            };
5063            *goal_x = Some(x);
5064            x
5065        });
5066
5067        let target_y = self
5068            .items
5069            .iter()
5070            .find(|i| i.line_index == target_line_idx)
5071            .map_or(current_item.position.y, |i| i.position.y + (i.item.bounds().height / 2.0));
5072
5073        if let Some(d) = debug {
5074            d.push(format!(
5075                "[Cursor] move_cursor_down: hit testing at ({current_x}, {target_y})"
5076            ));
5077        }
5078
5079        let result = self
5080            .hittest_cursor(LogicalPosition {
5081                x: current_x,
5082                y: target_y,
5083            })
5084            .unwrap_or(cursor);
5085
5086        if let Some(d) = debug {
5087            d.push(format!(
5088                "[Cursor] move_cursor_down: result byte {}, affinity {:?}",
5089                result.cluster_id.start_byte_in_run, result.affinity
5090            ));
5091        }
5092
5093        result
5094    }
5095
5096    /// Moves a cursor to the visual start of its current line.
5097    pub fn move_cursor_to_line_start(
5098        &self,
5099        cursor: TextCursor,
5100        debug: &mut Option<Vec<String>>,
5101    ) -> TextCursor {
5102        if let Some(d) = debug {
5103            d.push(format!(
5104                "[Cursor] move_cursor_to_line_start: starting at byte {}, affinity {:?}",
5105                cursor.cluster_id.start_byte_in_run, cursor.affinity
5106            ));
5107        }
5108
5109        let Some(current_item) = self.items.iter().find(|i| {
5110            i.item
5111                .as_cluster()
5112                .is_some_and(|c| c.source_cluster_id == cursor.cluster_id)
5113        }) else {
5114            if let Some(d) = debug {
5115                d.push(format!(
5116                    "[Cursor] move_cursor_to_line_start: cursor not found, staying at byte {}",
5117                    cursor.cluster_id.start_byte_in_run
5118                ));
5119            }
5120            return cursor;
5121        };
5122
5123        if let Some(d) = debug {
5124            d.push(format!(
5125                "[Cursor] move_cursor_to_line_start: current line {}, position ({}, {})",
5126                current_item.line_index, current_item.position.x, current_item.position.y
5127            ));
5128        }
5129
5130        let first_item_on_line = self
5131            .items
5132            .iter()
5133            .filter(|i| i.line_index == current_item.line_index)
5134            .min_by(|a, b| {
5135                a.position
5136                    .x
5137                    .partial_cmp(&b.position.x)
5138                    .unwrap_or(Ordering::Equal)
5139            });
5140
5141        if let Some(item) = first_item_on_line {
5142            if let ShapedItem::Cluster(c) = &item.item {
5143                let result = TextCursor {
5144                    cluster_id: c.source_cluster_id,
5145                    affinity: CursorAffinity::Leading,
5146                };
5147                if let Some(d) = debug {
5148                    d.push(format!(
5149                        "[Cursor] move_cursor_to_line_start: result byte {}, affinity {:?}",
5150                        result.cluster_id.start_byte_in_run, result.affinity
5151                    ));
5152                }
5153                return result;
5154            }
5155        }
5156
5157        if let Some(d) = debug {
5158            d.push(format!(
5159                "[Cursor] move_cursor_to_line_start: no first item found, staying at byte {}",
5160                cursor.cluster_id.start_byte_in_run
5161            ));
5162        }
5163        cursor
5164    }
5165
5166    /// Moves a cursor to the visual end of its current line.
5167    pub fn move_cursor_to_line_end(
5168        &self,
5169        cursor: TextCursor,
5170        debug: &mut Option<Vec<String>>,
5171    ) -> TextCursor {
5172        if let Some(d) = debug {
5173            d.push(format!(
5174                "[Cursor] move_cursor_to_line_end: starting at byte {}, affinity {:?}",
5175                cursor.cluster_id.start_byte_in_run, cursor.affinity
5176            ));
5177        }
5178
5179        let Some(current_item) = self.items.iter().find(|i| {
5180            i.item
5181                .as_cluster()
5182                .is_some_and(|c| c.source_cluster_id == cursor.cluster_id)
5183        }) else {
5184            if let Some(d) = debug {
5185                d.push(format!(
5186                    "[Cursor] move_cursor_to_line_end: cursor not found, staying at byte {}",
5187                    cursor.cluster_id.start_byte_in_run
5188                ));
5189            }
5190            return cursor;
5191        };
5192
5193        if let Some(d) = debug {
5194            d.push(format!(
5195                "[Cursor] move_cursor_to_line_end: current line {}, position ({}, {})",
5196                current_item.line_index, current_item.position.x, current_item.position.y
5197            ));
5198        }
5199
5200        let last_item_on_line = self
5201            .items
5202            .iter()
5203            .filter(|i| i.line_index == current_item.line_index)
5204            .max_by(|a, b| {
5205                a.position
5206                    .x
5207                    .partial_cmp(&b.position.x)
5208                    .unwrap_or(Ordering::Equal)
5209            });
5210
5211        if let Some(item) = last_item_on_line {
5212            if let ShapedItem::Cluster(c) = &item.item {
5213                let result = TextCursor {
5214                    cluster_id: c.source_cluster_id,
5215                    affinity: CursorAffinity::Trailing,
5216                };
5217                if let Some(d) = debug {
5218                    d.push(format!(
5219                        "[Cursor] move_cursor_to_line_end: result byte {}, affinity {:?}",
5220                        result.cluster_id.start_byte_in_run, result.affinity
5221                    ));
5222                }
5223                return result;
5224            }
5225        }
5226
5227        if let Some(d) = debug {
5228            d.push(format!(
5229                "[Cursor] move_cursor_to_line_end: no last item found, staying at byte {}",
5230                cursor.cluster_id.start_byte_in_run
5231            ));
5232        }
5233        cursor
5234    }
5235
5236    /// Moves a cursor one word to the left (Ctrl+Left / Option+Left).
5237    ///
5238    /// Word boundaries use the shared [`is_word_char`] predicate (alphanumeric or
5239    /// underscore are word characters; whitespace AND punctuation are boundaries),
5240    /// so this agrees with double-click word selection. The cursor moves past any
5241    /// boundary clusters to the left, then past word clusters until the next
5242    /// boundary or start of text.
5243    pub fn move_cursor_to_prev_word(
5244        &self,
5245        cursor: TextCursor,
5246        debug: &mut Option<Vec<String>>,
5247    ) -> TextCursor {
5248        if let Some(d) = debug {
5249            d.push(format!(
5250                "[Cursor] move_cursor_to_prev_word: starting at byte {}, affinity {:?}",
5251                cursor.cluster_id.start_byte_in_run, cursor.affinity
5252            ));
5253        }
5254
5255        let Some(current_pos) = self.items.iter().position(|i| {
5256            i.item
5257                .as_cluster()
5258                .is_some_and(|c| c.source_cluster_id == cursor.cluster_id)
5259        }) else {
5260            return cursor;
5261        };
5262
5263        // Phase 1: Skip whitespace going left
5264        let mut pos = if cursor.affinity == CursorAffinity::Leading {
5265            // Already at leading edge, start from previous item
5266            current_pos.checked_sub(1)
5267        } else {
5268            // At trailing edge, start from current item
5269            Some(current_pos)
5270        };
5271
5272        // Skip boundary clusters (whitespace + punctuation)
5273        while let Some(p) = pos {
5274            if let Some(cluster) = self.items[p].item.as_cluster() {
5275                if !cluster_is_word_boundary(cluster) {
5276                    break;
5277                }
5278            }
5279            pos = p.checked_sub(1);
5280        }
5281
5282        // Phase 2: Skip word clusters going left (the word itself)
5283        while let Some(p) = pos {
5284            if let Some(cluster) = self.items[p].item.as_cluster() {
5285                if cluster_is_word_boundary(cluster) {
5286                    // We've reached a boundary before the word — stop at next cluster
5287                    if p + 1 < self.items.len() {
5288                        if let Some(c) = self.items[p + 1].item.as_cluster() {
5289                            return TextCursor {
5290                                cluster_id: c.source_cluster_id,
5291                                affinity: CursorAffinity::Leading,
5292                            };
5293                        }
5294                    }
5295                    break;
5296                }
5297            }
5298            if p == 0 {
5299                // Reached start of text — return first cluster
5300                if let Some(c) = self.items[0].item.as_cluster() {
5301                    return TextCursor {
5302                        cluster_id: c.source_cluster_id,
5303                        affinity: CursorAffinity::Leading,
5304                    };
5305                }
5306                break;
5307            }
5308            pos = p.checked_sub(1);
5309        }
5310
5311        // If we exhausted the search, go to first cluster
5312        if pos.is_none() {
5313            if let Some(first) = self.get_first_cluster_cursor() {
5314                return first;
5315            }
5316        }
5317
5318        cursor
5319    }
5320
5321    /// Moves a cursor one word to the right (Ctrl+Right / Option+Right).
5322    ///
5323    /// Word boundaries use the shared [`is_word_char`] predicate (alphanumeric or
5324    /// underscore are word characters; whitespace AND punctuation are boundaries),
5325    /// so this agrees with double-click word selection. The cursor moves past any
5326    /// word clusters, then past boundary clusters until the next word or end of text.
5327    pub fn move_cursor_to_next_word(
5328        &self,
5329        cursor: TextCursor,
5330        debug: &mut Option<Vec<String>>,
5331    ) -> TextCursor {
5332        if let Some(d) = debug {
5333            d.push(format!(
5334                "[Cursor] move_cursor_to_next_word: starting at byte {}, affinity {:?}",
5335                cursor.cluster_id.start_byte_in_run, cursor.affinity
5336            ));
5337        }
5338
5339        let Some(current_pos) = self.items.iter().position(|i| {
5340            i.item
5341                .as_cluster()
5342                .is_some_and(|c| c.source_cluster_id == cursor.cluster_id)
5343        }) else {
5344            return cursor;
5345        };
5346
5347        let len = self.items.len();
5348
5349        // Start position: if at leading edge, start from current; if trailing, start from next
5350        let start = if cursor.affinity == CursorAffinity::Trailing {
5351            current_pos + 1
5352        } else {
5353            current_pos
5354        };
5355
5356        if start >= len {
5357            return cursor;
5358        }
5359
5360        let mut pos = start;
5361
5362        // Phase 1: Skip word clusters (current word)
5363        while pos < len {
5364            if let Some(cluster) = self.items[pos].item.as_cluster() {
5365                if cluster_is_word_boundary(cluster) {
5366                    break;
5367                }
5368            }
5369            pos += 1;
5370        }
5371
5372        // Phase 2: Skip boundary clusters (whitespace + punctuation) after word
5373        while pos < len {
5374            if let Some(cluster) = self.items[pos].item.as_cluster() {
5375                if !cluster_is_word_boundary(cluster) {
5376                    // Found start of next word
5377                    return TextCursor {
5378                        cluster_id: cluster.source_cluster_id,
5379                        affinity: CursorAffinity::Leading,
5380                    };
5381                }
5382            }
5383            pos += 1;
5384        }
5385
5386        // Reached end of text
5387        if let Some(last) = self.get_last_cluster_cursor() {
5388            return last;
5389        }
5390
5391        cursor
5392    }
5393}
5394
5395#[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
5396fn get_baseline_for_item(item: &ShapedItem) -> Option<f32> {
5397    match item {
5398        ShapedItem::CombinedBlock {
5399            baseline_offset, ..
5400        } => Some(*baseline_offset),
5401        ShapedItem::Object {
5402            baseline_offset, ..
5403        } => Some(*baseline_offset),
5404        // We have to get the clusters font from the last glyph
5405        ShapedItem::Cluster(ref cluster) => {
5406            cluster.glyphs.last().map(|last_glyph| last_glyph
5407                        .font_metrics
5408                        .baseline_scaled(last_glyph.style.font_size_px))
5409        }
5410        ShapedItem::Break { source, break_info } => {
5411            // Breaks do not contribute to baseline
5412            None
5413        }
5414        ShapedItem::Tab { source, bounds } => {
5415            // Tabs do not contribute to baseline
5416            None
5417        }
5418    }
5419}
5420
5421/// Stores information about content that exceeded the available layout space.
5422#[derive(Debug, Clone, Default)]
5423pub struct OverflowInfo {
5424    /// The items that did not fit within the constraints.
5425    ///
5426    /// Currently always empty: the positioners place every item (visual overflow
5427    /// is clipped at paint time) rather than dropping content, so nothing is ever
5428    /// recorded here. The `window.rs` incremental-patch guard reads
5429    /// `overflow_items.is_empty()` to stay future-proof against a positioning path
5430    /// that *does* drop items. TODO(superplan): populate this if such a path lands.
5431    pub overflow_items: Vec<ShapedItem>,
5432    /// The total bounds of all positioned content, including any that overflows
5433    /// the constraints. Populated by both positioners (greedy + Knuth-Plass) from
5434    /// [`UnifiedLayout::bounds`]; useful for `OverflowBehavior::Visible`/`Scroll`.
5435    pub unclipped_bounds: Rect,
5436}
5437
5438impl OverflowInfo {
5439    #[must_use] pub const fn has_overflow(&self) -> bool {
5440        !self.overflow_items.is_empty()
5441    }
5442}
5443
5444/// Intermediate structure carrying information from the line breaker to the positioner.
5445#[derive(Debug, Clone)]
5446pub struct UnifiedLine {
5447    pub items: Vec<ShapedItem>,
5448    /// The y-position (for horizontal) or x-position (for vertical) of the line's baseline.
5449    pub cross_axis_position: f32,
5450    /// The geometric segments this line must fit into.
5451    pub constraints: LineConstraints,
5452    pub is_last: bool,
5453}
5454
5455// --- Caching Infrastructure ---
5456
5457pub type CacheId = u64;
5458
5459/// Defines a single area for layout, with its own shape and properties.
5460#[derive(Debug, Clone)]
5461pub struct LayoutFragment {
5462    /// A unique identifier for this fragment (e.g., "main-content", "sidebar").
5463    pub id: String,
5464    /// The geometric and style constraints for this specific fragment.
5465    pub constraints: UnifiedConstraints,
5466}
5467
5468/// Represents the final layout distributed across multiple fragments.
5469#[derive(Debug, Clone)]
5470pub(crate) struct FlowLayout {
5471    /// A map from a fragment's unique ID to the layout it contains.
5472    pub(crate) fragment_layouts: HashMap<String, Arc<UnifiedLayout>>,
5473    /// Any items that did not fit into the last fragment in the flow chain.
5474    /// This is useful for pagination or determining if more layout space is needed.
5475    pub(crate) remaining_items: Vec<ShapedItem>,
5476}
5477
5478/// Inline-axis intrinsic contributions derived from shaped text, without running
5479/// the line-breaking stage of the pipeline.
5480///
5481/// Callers that only need min/max-content widths for sizing (see
5482/// `calculate_ifc_root_intrinsic_sizes`) should prefer this over invoking
5483/// `layout_flow` twice with `AvailableSpace::MinContent`/`MaxContent`. The
5484/// latter runs the full flow loop — including `BreakCursor::peek_next_unit`,
5485/// which clones every `ShapedCluster` it inspects — even though no constraint
5486/// actually limits the line width.
5487#[derive(Copy, Debug, Clone, Default)]
5488pub struct IntrinsicTextSizes {
5489    /// CSS min-content = widest unbreakable unit (word) along the inline axis.
5490    pub min_content_width: f32,
5491    /// CSS max-content = sum of all advances along the inline axis (single line).
5492    pub max_content_width: f32,
5493    /// Height of a single line box: max(ascent + descent) across all items.
5494    pub max_content_height: f32,
5495}
5496
5497/// Cached line break boundaries from a previous layout pass.
5498///
5499/// Enables incremental relayout: when a word changes width,
5500/// we can check if it still fits on the same line without
5501/// re-running the full line-breaking algorithm.
5502#[derive(Clone, Debug)]
5503pub struct CachedLineBreaks {
5504    /// Per-line: (`first_item_idx`, `last_item_idx_exclusive`) into positioned items.
5505    pub line_ranges: Vec<(usize, usize)>,
5506    /// Per-line total width (sum of item advances on that line).
5507    pub line_widths: Vec<f32>,
5508    /// The available width constraint used when these breaks were computed.
5509    pub available_width: f32,
5510}
5511
5512/// Result of an incremental relayout attempt.
5513#[derive(Copy, Clone, Debug)]
5514pub enum IncrementalRelayoutResult {
5515    /// Glyphs changed but advance widths identical — swap in place, no repositioning.
5516    GlyphSwap,
5517    /// Width changed but still fits on same line — shift `x_offsets` of subsequent items.
5518    LineShift {
5519        /// Index of the first affected item.
5520        affected_item: usize,
5521        /// Width delta (`new_advance` - `old_advance`).
5522        delta: f32,
5523    },
5524    /// Line breaks changed — need to reflow from this line onward.
5525    PartialReflow {
5526        /// The line index from which to start reflowing.
5527        reflow_from_line: usize,
5528    },
5529    /// Cannot do incremental — fall back to full relayout.
5530    FullRelayout,
5531}
5532
5533/// Extract line break boundaries from a positioned items list.
5534#[must_use] pub fn extract_line_breaks(
5535    items: &[PositionedItem],
5536    available_width: f32,
5537) -> CachedLineBreaks {
5538    let mut line_ranges = Vec::new();
5539    let mut line_widths = Vec::new();
5540
5541    if items.is_empty() {
5542        return CachedLineBreaks { line_ranges, line_widths, available_width };
5543    }
5544
5545    let mut line_start = 0usize;
5546    let mut current_line = items[0].line_index;
5547    let mut line_width = 0.0f32;
5548
5549    for (i, item) in items.iter().enumerate() {
5550        if item.line_index != current_line {
5551            line_ranges.push((line_start, i));
5552            line_widths.push(line_width);
5553            line_start = i;
5554            current_line = item.line_index;
5555            line_width = 0.0;
5556        }
5557        line_width += get_item_measure(&item.item, false);
5558    }
5559
5560    // Final line
5561    line_ranges.push((line_start, items.len()));
5562    line_widths.push(line_width);
5563
5564    CachedLineBreaks { line_ranges, line_widths, available_width }
5565}
5566
5567/// Attempt incremental relayout given old metrics and new per-item advance widths.
5568///
5569/// `dirty_item_indices`: which items in the shaped list changed.
5570/// `old_advances`: per-item advance widths from the previous layout.
5571/// `new_advances`: per-item advance widths after reshaping.
5572/// `line_breaks`: cached line boundaries from previous layout.
5573#[must_use] pub fn try_incremental_relayout(
5574    dirty_item_indices: &[usize],
5575    old_advances: &[f32],
5576    new_advances: &[f32],
5577    line_breaks: &CachedLineBreaks,
5578) -> IncrementalRelayoutResult {
5579    if dirty_item_indices.is_empty() {
5580        return IncrementalRelayoutResult::GlyphSwap;
5581    }
5582
5583    // Check each dirty item
5584    for &dirty_idx in dirty_item_indices {
5585        if dirty_idx >= old_advances.len() || dirty_idx >= new_advances.len() {
5586            return IncrementalRelayoutResult::FullRelayout;
5587        }
5588
5589        let old_adv = old_advances[dirty_idx];
5590        let new_adv = new_advances[dirty_idx];
5591        let delta = new_adv - old_adv;
5592
5593        if delta.abs() < 0.001 {
5594            // Same width — just swap glyphs (GlyphSwap for this item)
5595            continue;
5596        }
5597
5598        // Width changed — find which line this item is on
5599        let line_idx = line_breaks.line_ranges.iter()
5600            .position(|&(start, end)| dirty_idx >= start && dirty_idx < end);
5601
5602        let Some(line_idx) = line_idx else {
5603            return IncrementalRelayoutResult::FullRelayout;
5604        };
5605
5606        let old_line_width = line_breaks.line_widths[line_idx];
5607        let new_line_width = old_line_width + delta;
5608
5609        if new_line_width <= line_breaks.available_width {
5610            // Still fits on same line — shift subsequent items
5611            return IncrementalRelayoutResult::LineShift {
5612                affected_item: dirty_idx,
5613                delta,
5614            };
5615        }
5616        // Overflows line — need to reflow from this line
5617        return IncrementalRelayoutResult::PartialReflow {
5618            reflow_from_line: line_idx,
5619        };
5620    }
5621
5622    // All dirty items had same width
5623    IncrementalRelayoutResult::GlyphSwap
5624}
5625
5626/// Cached shaped result for a single visual item (or coalesced group).
5627/// Enables per-item cache hits when only one word changes in a paragraph.
5628#[derive(Debug)]
5629pub(crate) struct PerItemShapedEntry {
5630    /// The shaped clusters for this single item/group.
5631    pub(crate) clusters: Vec<ShapedItem>,
5632    /// Sum of advance widths — for fast same-width detection during incremental relayout.
5633    pub(crate) total_advance: f32,
5634}
5635
5636#[derive(Debug)]
5637pub struct TextShapingCache {
5638    // Stage 1 Cache: InlineContent -> LogicalItems
5639    logical_items: HashMap<CacheId, Arc<Vec<LogicalItem>>>,
5640    // Stage 2 Cache: LogicalItems -> VisualItems
5641    visual_items: HashMap<CacheId, Arc<Vec<VisualItem>>>,
5642    // Stage 3 Cache: VisualItems -> ShapedItems (monolithic, for backward compat)
5643    shaped_items: HashMap<CacheId, Arc<Vec<ShapedItem>>>,
5644    // Stage 3b Cache: Per-item/coalesce-group shaped results
5645    // Key: hash(text, bidi_level, script, style.layout_hash())
5646    per_item_shaped: HashMap<u64, Arc<PerItemShapedEntry>>,
5647    /// Tracks which `per_item_shaped` keys were accessed in the current generation.
5648    per_item_accessed: HashSet<u64>,
5649    /// Current generation counter, incremented each layout pass.
5650    generation: u64,
5651}
5652
5653/// Approximate heap bytes retained by a [`TextShapingCache`].
5654#[derive(Copy, Debug, Clone, Default)]
5655pub struct TextCacheMemoryReport {
5656    pub logical_items_entries: usize,
5657    pub logical_items_bytes: usize,
5658    pub visual_items_entries: usize,
5659    pub visual_items_bytes: usize,
5660    pub shaped_items_entries: usize,
5661    pub shaped_items_bytes: usize,
5662    pub shaped_glyph_bytes: usize,
5663    pub shaped_cluster_text_bytes: usize,
5664    pub per_item_shaped_entries: usize,
5665    pub per_item_shaped_bytes: usize,
5666}
5667
5668impl TextCacheMemoryReport {
5669    #[must_use] pub const fn total_bytes(&self) -> usize {
5670        self.logical_items_bytes
5671            + self.visual_items_bytes
5672            + self.shaped_items_bytes
5673            + self.shaped_glyph_bytes
5674            + self.shaped_cluster_text_bytes
5675            + self.per_item_shaped_bytes
5676    }
5677}
5678
5679impl TextShapingCache {
5680    #[must_use] pub fn new() -> Self {
5681        Self {
5682            logical_items: HashMap::new(),
5683            visual_items: HashMap::new(),
5684            shaped_items: HashMap::new(),
5685            per_item_shaped: HashMap::new(),
5686            per_item_accessed: HashSet::new(),
5687            generation: 0,
5688        }
5689    }
5690
5691    /// Approximate per-stage heap-byte breakdown.
5692    #[allow(clippy::field_reassign_with_default)] // struct built incrementally / test setup; a struct literal is not clearer here
5693    #[must_use] pub fn memory_report(&self) -> TextCacheMemoryReport {
5694        let mut r = TextCacheMemoryReport::default();
5695        r.logical_items_entries = self.logical_items.len();
5696        for arc in self.logical_items.values() {
5697            r.logical_items_bytes += arc.capacity() * size_of::<LogicalItem>();
5698        }
5699        r.visual_items_entries = self.visual_items.len();
5700        for arc in self.visual_items.values() {
5701            r.visual_items_bytes += arc.capacity() * size_of::<VisualItem>();
5702        }
5703        r.shaped_items_entries = self.shaped_items.len();
5704        for arc in self.shaped_items.values() {
5705            r.shaped_items_bytes += arc.capacity() * size_of::<ShapedItem>();
5706            for item in arc.iter() {
5707                if let ShapedItem::Cluster(c) = item {
5708                    r.shaped_glyph_bytes += c.glyphs.capacity() * size_of::<ShapedGlyph>();
5709                    r.shaped_cluster_text_bytes += c.text.capacity();
5710                }
5711            }
5712        }
5713        r.per_item_shaped_entries = self.per_item_shaped.len();
5714        for arc in self.per_item_shaped.values() {
5715            r.per_item_shaped_bytes += arc.clusters.capacity() * size_of::<ShapedItem>();
5716            for item in &arc.clusters {
5717                if let ShapedItem::Cluster(c) = item {
5718                    r.per_item_shaped_bytes += c.glyphs.capacity() * size_of::<ShapedGlyph>();
5719                    r.per_item_shaped_bytes += c.text.capacity();
5720                }
5721            }
5722        }
5723        r
5724    }
5725
5726    /// Call at the start of each layout pass. Evicts per-item shaped entries
5727    /// not accessed in the previous generation to prevent unbounded growth.
5728    pub fn begin_generation(&mut self) {
5729        if self.generation > 0 && !self.per_item_accessed.is_empty() {
5730            // Evict entries not accessed in this generation
5731            let accessed = &self.per_item_accessed;
5732            self.per_item_shaped.retain(|k, _| accessed.contains(k));
5733        }
5734        self.per_item_accessed.clear();
5735        self.generation += 1;
5736    }
5737
5738    /// Check if we can reuse an old layout based on layout-affecting parameters.
5739    /// 
5740    /// This function compares only the parameters that affect glyph positions,
5741    /// not rendering-only parameters like color or text-decoration.
5742    /// 
5743    /// # Parameters
5744    /// - `old_constraints`: The constraints used for the cached layout
5745    /// - `new_constraints`: The constraints for the new layout request
5746    /// - `old_content`: The content used for the cached layout
5747    /// - `new_content`: The new content to layout
5748    /// 
5749    /// # Returns
5750    /// - `true` if the old layout can be reused (only rendering changed)
5751    /// - `false` if a new layout is needed (layout-affecting params changed)
5752    #[must_use] pub fn use_old_layout(
5753        old_constraints: &UnifiedConstraints,
5754        new_constraints: &UnifiedConstraints,
5755        old_content: &[InlineContent],
5756        new_content: &[InlineContent],
5757    ) -> bool {
5758        // First check: constraints must match exactly for layout purposes
5759        if old_constraints != new_constraints {
5760            return false;
5761        }
5762        
5763        // Second check: content length must match
5764        if old_content.len() != new_content.len() {
5765            return false;
5766        }
5767        
5768        // Third check: each content item must have same layout properties
5769        for (old, new) in old_content.iter().zip(new_content.iter()) {
5770            if !Self::inline_content_layout_eq(old, new) {
5771                return false;
5772            }
5773        }
5774        
5775        true
5776    }
5777    
5778    /// Compare two `InlineContent` items for layout equality.
5779    /// 
5780    /// Returns true if the layouts would be identical (only rendering differs).
5781    fn inline_content_layout_eq(old: &InlineContent, new: &InlineContent) -> bool {
5782        use InlineContent::{Text, Image, Space, LineBreak, Tab, Marker, Shape, Ruby};
5783        match (old, new) {
5784            (Text(old_run), Text(new_run)) => {
5785                // Text must match exactly, but style only needs layout_eq
5786                old_run.text == new_run.text 
5787                    && old_run.style.layout_eq(&new_run.style)
5788            }
5789            (Image(old_img), Image(new_img)) => {
5790                // Images: size affects layout, but not visual properties
5791                old_img.intrinsic_size == new_img.intrinsic_size
5792                    && old_img.display_size == new_img.display_size
5793                    && old_img.baseline_offset == new_img.baseline_offset
5794                    && old_img.alignment == new_img.alignment
5795            }
5796            (Space(old_sp), Space(new_sp)) => old_sp == new_sp,
5797            (LineBreak(old_br), LineBreak(new_br)) => old_br == new_br,
5798            (Tab { style: old_style }, Tab { style: new_style }) => old_style.layout_eq(new_style),
5799            (Marker { run: old_run, position_outside: old_pos },
5800             Marker { run: new_run, position_outside: new_pos }) => {
5801                old_pos == new_pos
5802                    && old_run.text == new_run.text
5803                    && old_run.style.layout_eq(&new_run.style)
5804            }
5805            (Shape(old_shape), Shape(new_shape)) => {
5806                // Shapes: shape_def affects layout, not fill/stroke
5807                old_shape.shape_def == new_shape.shape_def
5808                    && old_shape.baseline_offset == new_shape.baseline_offset
5809            }
5810            (Ruby { base: old_base, text: old_text, style: old_style },
5811             Ruby { base: new_base, text: new_text, style: new_style }) => {
5812                old_style.layout_eq(new_style)
5813                    && old_base.len() == new_base.len()
5814                    && old_text.len() == new_text.len()
5815                    && old_base.iter().zip(new_base.iter())
5816                        .all(|(o, n)| Self::inline_content_layout_eq(o, n))
5817                    && old_text.iter().zip(new_text.iter())
5818                        .all(|(o, n)| Self::inline_content_layout_eq(o, n))
5819            }
5820            // Different variants cannot have same layout
5821            _ => false,
5822        }
5823    }
5824}
5825
5826impl Default for TextShapingCache {
5827    fn default() -> Self {
5828        Self::new()
5829    }
5830}
5831
5832/// Key for caching the conversion from `InlineContent` to `LogicalItem`s.
5833#[derive(Debug, Clone, Eq, PartialEq, Hash)]
5834pub(crate) struct LogicalItemsKey<'a> {
5835    pub(crate) inline_content_hash: u64,
5836    pub(crate) default_font_size: u32,
5837    pub(crate) _marker: std::marker::PhantomData<&'a ()>,
5838}
5839
5840/// Key for caching the Bidi reordering stage.
5841#[derive(Debug, Clone, Eq, PartialEq, Hash)]
5842pub(crate) struct VisualItemsKey {
5843    pub(crate) logical_items_id: CacheId,
5844    pub(crate) base_direction: BidiDirection,
5845}
5846
5847/// Key for caching the shaping stage.
5848#[derive(Debug, Clone, Eq, PartialEq, Hash)]
5849pub(crate) struct ShapedItemsKey {
5850    pub(crate) visual_items_id: CacheId,
5851    pub(crate) style_hash: u64,
5852}
5853
5854impl ShapedItemsKey {
5855    pub(crate) fn new(visual_items_id: CacheId, visual_items: &[VisualItem]) -> Self {
5856        let style_hash = {
5857            let mut hasher = DefaultHasher::new();
5858            for item in visual_items {
5859                // Hash the style from the logical source, as this is what determines the font.
5860                match &item.logical_source {
5861                    LogicalItem::Text { style, .. } | LogicalItem::CombinedText { style, .. } => {
5862                        style.as_ref().hash(&mut hasher);
5863                    }
5864                    _ => {}
5865                }
5866            }
5867            hasher.finish()
5868        };
5869
5870        Self {
5871            visual_items_id,
5872            style_hash,
5873        }
5874    }
5875}
5876
5877/// Key for the final layout stage.
5878#[derive(Debug, Clone, Eq, PartialEq, Hash)]
5879pub(crate) struct LayoutKey {
5880    pub(crate) shaped_items_id: CacheId,
5881    pub(crate) constraints: UnifiedConstraints,
5882}
5883
5884/// Helper to create a `CacheId` from any `Hash`able type.
5885fn calculate_id<T: Hash>(item: &T) -> CacheId {
5886    let mut hasher = DefaultHasher::new();
5887    item.hash(&mut hasher);
5888    hasher.finish()
5889}
5890
5891// --- Main Layout Pipeline Implementation ---
5892
5893impl TextShapingCache {
5894    /// New top-level entry point for flowing layout across multiple regions.
5895    ///
5896    /// This function orchestrates the entire layout pipeline, but instead of fitting
5897    /// content into a single set of constraints, it flows the content through an
5898    /// ordered sequence of `LayoutFragment`s.
5899    ///
5900    /// # CSS Inline Layout Module Level 3: Pipeline Implementation
5901    ///
5902    /// This implements the inline formatting context with 5 stages:
5903    ///
5904    /// ## Stage 1: Logical Analysis (`InlineContent` -> `LogicalItem`)
5905    /// \u2705 IMPLEMENTED: Parses raw content into logical units
5906    /// - Handles text runs, inline-blocks, replaced elements
5907    /// - Applies style overrides at character level
5908    /// - Implements \u00a7 2.2: Content size contribution calculation
5909    ///
5910    /// ## Stage 2: `BiDi` Reordering (`LogicalItem` -> `VisualItem`)
5911    /// \u2705 IMPLEMENTED: Uses CSS 'direction' property per CSS Writing Modes
5912    /// - Reorders items for right-to-left text (Arabic, Hebrew)
5913    /// - Respects containing block direction (not auto-detection)
5914    /// - Conforms to Unicode `BiDi` Algorithm (UAX #9)
5915    ///
5916    /// ## Stage 3: Shaping (`VisualItem` -> `ShapedItem`)
5917    /// \u2705 IMPLEMENTED: Converts text to glyphs
5918    /// - Uses `HarfBuzz` for OpenType shaping
5919    /// - Handles ligatures, kerning, contextual forms
5920    /// - Caches shaped results for performance
5921    ///
5922    /// ## Stage 4: Text Orientation Transformations
5923    /// \u26a0\ufe0f PARTIAL: Applies text-orientation for vertical text
5924    /// - Uses constraints from *first* fragment only
5925    /// - \u274c TODO: Should re-orient if fragments have different writing modes
5926    ///
5927    /// ## Stage 5: Flow Loop (`ShapedItem` -> `PositionedItem`)
5928    /// \u2705 IMPLEMENTED: Breaks lines and positions content
5929    /// - Calls `perform_fragment_layout` for each fragment
5930    /// - Uses `BreakCursor` to flow content across fragments
5931    /// - Implements \u00a7 5: Line breaking and hyphenation
5932    ///
5933    /// # Missing Features from CSS Inline-3:
5934    /// - \u00a7 3.3: initial-letter (drop caps)
5935    /// - \u00a7 4: vertical-align (only baseline supported)
5936    /// - \u00a7 6: text-box-trim (leading trim)
5937    /// - \u00a7 7: inline-sizing (aspect-ratio for inline-blocks)
5938    ///
5939    /// # Arguments
5940    /// * `content` - The raw `InlineContent` to be laid out.
5941    /// * `style_overrides` - Character-level style changes.
5942    /// * `flow_chain` - An ordered slice of `LayoutFragment` defining the regions (e.g., columns,
5943    ///   pages) that the content should flow through.
5944    /// * `font_chain_cache` - Pre-resolved font chains (from `FontManager.font_chain_cache`)
5945    /// * `fc_cache` - The fontconfig cache for font lookups
5946    /// * `loaded_fonts` - Pre-loaded fonts, keyed by `FontId`
5947    ///
5948    /// # Returns
5949    /// A `FlowLayout` struct containing the positioned items for each fragment that
5950    /// was filled, and any content that did not fit in the final fragment.
5951    #[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
5952    /// # Panics
5953    ///
5954    /// Panics if bidi reordering of the logical items fails (an internal invariant).
5955    /// # Errors
5956    ///
5957    /// Returns a `LayoutError` if text flow layout fails.
5958    pub fn layout_flow<T: ParsedFontTrait>(
5959        &mut self,
5960        content: &[InlineContent],
5961        style_overrides: &[StyleOverride],
5962        flow_chain: &[LayoutFragment],
5963        font_chain_cache: &HashMap<FontChainKey, rust_fontconfig::FontFallbackChain>,
5964        fc_cache: &FcFontCache,
5965        loaded_fonts: &LoadedFonts<T>,
5966        debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
5967    ) -> Result<FlowLayout, LayoutError> {
5968        // [g150 az-web-lift DIAG] content data ptr (0x60BD0) + len (0x60BD4) at layout_flow ENTRY.
5969        #[cfg(feature = "web_lift")]
5970        unsafe {
5971            crate::az_mark((0x60BD0) as u32, (content.as_ptr() as usize as u32) as u32);
5972            crate::az_mark((0x60BD4) as u32, (content.len() as u32 | 0xC0DE0000) as u32);
5973        }
5974        // [g218 2026-06-09] The g158 `content.len()` force-materialize (a volatile read of content+16) is
5975        // DELETED: the within-fn SROA-to-0 of content.len() it worked around is now fixed (NEON-decoder +
5976        // volatile-guest-load transpiler work). VERIFIED: hello-world lays out without it — counter "5"
5977        // (label_wrapper 8,16,784,40) + button shape correctly, same rects as before. (The cross-FN Vec-*return*-
5978        // len mis-lift is a separate, still-present issue handled by the g127/g129/g130 out-param hacks — see
5979        // g134 marker: callee content.len=1 but the caller's return-read sees 0.)
5980        // --- Stages 1-3: Preparation ---
5981        // These stages are independent of the final geometry. We perform them once
5982        // on the entire content block before flowing. Caching is used at each stage.
5983
5984        // Cap per-item shaped cache to prevent unbounded growth.
5985        // When threshold is exceeded, evict entries not accessed this generation.
5986        const PER_ITEM_CACHE_MAX: usize = 4096;
5987        if self.per_item_shaped.len() > PER_ITEM_CACHE_MAX {
5988            self.begin_generation();
5989        }
5990
5991        // Stage 1: Logical Analysis (InlineContent -> LogicalItem)
5992        // [g213 2026-06-09] The web lift uses the real `self.logical_items` HashMap cache (NO bypass).
5993        // This entry() find-probe USED to spin forever on the lift (g178-g210 mis-diagnosed it many ways).
5994        // TRUE root cause: hashbrown's portable WIDTH=8 `Group::static_empty()` — `[0xFF; 8]` in libazul's
5995        // `__TEXT.__const` — was not mirrored into the wasm, so the empty-map ctrl-scan read 0x00, looked
5996        // ALL-FULL (EMPTY=0xFF), and the probe never terminated. FIXED entirely transpiler-side in
5997        // `dll/src/web/symbol_table.rs::compute_hashbrown_empty_group_ranges` (signature-scans `__const`
5998        // for >=8-byte 8-aligned 0xFF runs and mirrors them). Verified: web-nested-text lays out
5999        // ("Hello" at 8,16,800,20), __remill_error=0. No azul-source workaround needed here.
6000        let logical_items_id = calculate_id(&content);
6001        let logical_items = self
6002            .logical_items
6003            .entry(logical_items_id)
6004            .or_insert_with(|| {
6005                Arc::new(create_logical_items(content, style_overrides, debug_messages))
6006            })
6007            .clone();
6008
6009        // Get the first fragment's constraints to extract the CSS direction property.
6010        // This is used for BiDi reordering in Stage 2.
6011        let default_constraints = UnifiedConstraints::default();
6012        let first_constraints = flow_chain
6013            .first()
6014            .map_or(&default_constraints, |f| &f.constraints);
6015
6016        // +spec:containing-block:e7a271 - paragraph embedding level set from containing block's 'direction' property
6017        // +spec:display-property:7665cb - inline boxes split into multiple visual runs due to bidi text processing
6018        // +spec:display-property:929d6b - applies Unicode bidi algorithm to inline-level box sequences
6019        // +spec:display-property:e8584a - Apply Unicode bidi algorithm to inline-level box sequences per CSS Writing Modes §2.4
6020        // Stage 2: Bidi Reordering (LogicalItem -> VisualItem)
6021        // +spec:containing-block:961e3c - bidi paragraph level from containing block direction, not UAX9 heuristic
6022        // +spec:writing-modes:0a5368 - unicode-bidi: plaintext auto-detects direction from text content
6023        // Per CSS Writing Modes §8.3: when unicode-bidi is plaintext, the paragraph's
6024        // base direction is determined from text content (first strong character), ignoring
6025        // the containing block's direction property. Empty paragraphs fall back to
6026        // the containing block's direction.
6027        let unicode_bidi_val = first_constraints.unicode_bidi;
6028        let base_direction = if unicode_bidi_val == UnicodeBidi::Plaintext {
6029            // Auto-detect from text content; fall back to containing block direction
6030            let has_strong = logical_items.iter().any(|item| {
6031                if let LogicalItem::Text { text, .. } = item {
6032                    matches!(unicode_bidi::get_base_direction(text.as_str()),
6033                        unicode_bidi::Direction::Ltr | unicode_bidi::Direction::Rtl)
6034                } else {
6035                    false
6036                }
6037            });
6038            if has_strong {
6039                get_base_direction_from_logical(&logical_items)
6040            } else {
6041                // Empty paragraph: use containing block's direction
6042                first_constraints.direction.unwrap_or(BidiDirection::Ltr)
6043            }
6044        } else {
6045            // Normal case: use CSS direction property
6046            first_constraints.direction.unwrap_or(BidiDirection::Ltr)
6047        };
6048        let visual_key = VisualItemsKey {
6049            logical_items_id,
6050            base_direction,
6051        };
6052        let visual_items_id = calculate_id(&visual_key);
6053        // [g213] web lift uses the real visual_items HashMap cache (g180 bypass deleted; WIDTH=8
6054        // EMPTY_GROUP now mirrored — see Stage-1 note + symbol_table.rs).
6055        let visual_items = self
6056            .visual_items
6057            .entry(visual_items_id)
6058            .or_insert_with(|| {
6059                Arc::new(
6060                    reorder_logical_items(&logical_items, base_direction, unicode_bidi_val, debug_messages).unwrap(),
6061                )
6062            })
6063            .clone();
6064
6065        // Stage 3: Shaping (VisualItem -> ShapedItem)
6066        // Two-level cache: monolithic (fast path) + per-item (incremental path).
6067        let shaped_key = ShapedItemsKey::new(visual_items_id, &visual_items);
6068        let shaped_items_id = calculate_id(&shaped_key);
6069        // [g213] web lift uses the real shaped_items HashMap cache (g180 bypass deleted).
6070        let shaped_items = if let Some(cached) = self.shaped_items.get(&shaped_items_id) {
6071            // Monolithic cache hit — all visual items unchanged
6072            cached.clone()
6073        } else {
6074            // Monolithic miss — use per-item cache for incremental reshaping.
6075            // Items not in per-item cache are shaped; cached items are reused.
6076            let items = Arc::new(shape_visual_items_with_per_item_cache(
6077                &visual_items,
6078                &mut self.per_item_shaped,
6079                &mut self.per_item_accessed,
6080                font_chain_cache,
6081                fc_cache,
6082                loaded_fonts,
6083                debug_messages,
6084            )?);
6085            self.shaped_items.insert(shaped_items_id, items.clone());
6086            items
6087        };
6088
6089        // --- Stage 4: Apply Vertical Text Transformations ---
6090
6091        // Note: first_constraints was already extracted above for BiDi reordering (Stage 2).
6092        // This orients all text based on the constraints of the *first* fragment.
6093        // A more advanced system could defer orientation until inside the loop if
6094        // fragments can have different writing modes.
6095        let oriented_items = apply_text_orientation(shaped_items, first_constraints);
6096
6097        // --- Stage 5: The Flow Loop ---
6098        let mut fragment_layouts = HashMap::new();
6099        // The cursor now manages the stream of items for the entire flow.
6100        // §5.2 word-break: pass word_break from constraints to cursor
6101        let mut cursor = BreakCursor::with_word_break(&oriented_items, first_constraints.word_break);
6102        cursor.hyphens = first_constraints.hyphenation;
6103        cursor.line_break = first_constraints.line_break;
6104
6105        // [g147 az-web-lift] Hard safety bound on the Stage-5 flow loop. On the remill lift this
6106        // `for fragment in flow_chain` (or the `cursor.is_done()` break) mis-lifts for the NESTED IFC
6107        // and iterates without terminating → solveLayoutReal HANGS (fuel trap in layout_flow). The text
6108        // is fully laid out on the first iteration(s); cap the iterations so the loop always converges.
6109        // (native is unaffected — the cap is far above any real fragment count.)
6110        #[allow(clippy::no_effect_underscore_binding)] // web_lift-gated debug iteration counter
6111        let mut _az_flow_iters: usize = 0;
6112        for fragment in flow_chain {
6113            #[cfg(feature = "web_lift")]
6114            {
6115                _az_flow_iters += 1;
6116                unsafe { crate::az_mark((0x60BC0) as u32, (_az_flow_iters as u32 | 0xC0DE0000) as u32); }
6117                if _az_flow_iters > 256 {
6118                    break;
6119                }
6120            }
6121            // Perform layout for this single fragment, consuming items from the cursor.
6122            let fragment_layout = perform_fragment_layout(
6123                &mut cursor,
6124                &logical_items,
6125                &fragment.constraints,
6126                debug_messages,
6127                loaded_fonts,
6128            )?;
6129
6130            fragment_layouts.insert(fragment.id.clone(), Arc::new(fragment_layout));
6131            if cursor.is_done() {
6132                break; // All content has been laid out.
6133            }
6134        }
6135
6136        Ok(FlowLayout {
6137            fragment_layouts,
6138            remaining_items: cursor.drain_remaining(),
6139        })
6140    }
6141
6142    /// Runs stages 1–4 of the layout pipeline (logical analysis, `BiDi`, shaping,
6143    /// text orientation) and derives min/max-content widths by scanning the
6144    /// resulting `ShapedItem`s directly — without running stage 5's line-breaking
6145    /// `BreakCursor` loop.
6146    ///
6147    /// Used by `calculate_ifc_root_intrinsic_sizes` to avoid the 24% CPU spent
6148    /// cloning `ShapedCluster`s inside `BreakCursor::peek_next_unit` on every
6149    /// sizing pass. Since stages 1–3 hit the same `per_item_shaped` cache as
6150    /// `layout_flow`, a subsequent `layout_flow` call for the same content at
6151    /// a real container width is a pure cache hit for the shaping work.
6152    ///
6153    /// The item walk uses the same break-opportunity predicate that the
6154    /// `BreakCursor` would — min-content accumulates advances between break
6155    /// opportunities and tracks the maximum; max-content is the sum of all
6156    /// advances (as if the flow were laid out on a single infinitely-wide line).
6157    #[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
6158    /// # Panics
6159    ///
6160    /// Panics if bidi reordering of the logical items fails (an internal invariant).
6161    /// # Errors
6162    ///
6163    /// Returns a `LayoutError` if measuring intrinsic widths fails.
6164    pub fn measure_intrinsic_widths<T: ParsedFontTrait>(
6165        &mut self,
6166        content: &[InlineContent],
6167        style_overrides: &[StyleOverride],
6168        constraints: &UnifiedConstraints,
6169        font_chain_cache: &HashMap<FontChainKey, rust_fontconfig::FontFallbackChain>,
6170        fc_cache: &FcFontCache,
6171        loaded_fonts: &LoadedFonts<T>,
6172        debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
6173    ) -> Result<IntrinsicTextSizes, LayoutError> {
6174        const PER_ITEM_CACHE_MAX: usize = 4096;
6175        if self.per_item_shaped.len() > PER_ITEM_CACHE_MAX {
6176            self.begin_generation();
6177        }
6178
6179        // Stage 1: Logical Analysis (cached, same as layout_flow — the historic web-lift
6180        // bypass here was rooted in the un-mirrored hashbrown EMPTY_GROUP, fixed transpiler-side
6181        // in symbol_table.rs::compute_hashbrown_empty_group_ranges).
6182        let logical_items_id = calculate_id(&content);
6183        let logical_items = self
6184            .logical_items
6185            .entry(logical_items_id)
6186            .or_insert_with(|| {
6187                Arc::new(create_logical_items(content, style_overrides, debug_messages))
6188            })
6189            .clone();
6190
6191        // Stage 2: BiDi (same derivation as layout_flow)
6192        let unicode_bidi_val = constraints.unicode_bidi;
6193        let base_direction = if unicode_bidi_val == UnicodeBidi::Plaintext {
6194            let has_strong = logical_items.iter().any(|item| {
6195                if let LogicalItem::Text { text, .. } = item {
6196                    matches!(unicode_bidi::get_base_direction(text.as_str()),
6197                        unicode_bidi::Direction::Ltr | unicode_bidi::Direction::Rtl)
6198                } else {
6199                    false
6200                }
6201            });
6202            if has_strong {
6203                get_base_direction_from_logical(&logical_items)
6204            } else {
6205                constraints.direction.unwrap_or(BidiDirection::Ltr)
6206            }
6207        } else {
6208            constraints.direction.unwrap_or(BidiDirection::Ltr)
6209        };
6210        let visual_key = VisualItemsKey {
6211            logical_items_id,
6212            base_direction,
6213        };
6214        let visual_items_id = calculate_id(&visual_key);
6215        let visual_items = self
6216            .visual_items
6217            .entry(visual_items_id)
6218            .or_insert_with(|| {
6219                Arc::new(
6220                    reorder_logical_items(&logical_items, base_direction, unicode_bidi_val, debug_messages).unwrap(),
6221                )
6222            })
6223            .clone();
6224
6225        // Stage 3: Shaping (two-level cache, same as layout_flow)
6226        let shaped_key = ShapedItemsKey::new(visual_items_id, &visual_items);
6227        let shaped_items_id = calculate_id(&shaped_key);
6228        let shaped_items = if let Some(cached) = self.shaped_items.get(&shaped_items_id) { cached.clone() } else {
6229            let items = Arc::new(shape_visual_items_with_per_item_cache(
6230                &visual_items,
6231                &mut self.per_item_shaped,
6232                &mut self.per_item_accessed,
6233                font_chain_cache,
6234                fc_cache,
6235                loaded_fonts,
6236                debug_messages,
6237            )?);
6238            self.shaped_items.insert(shaped_items_id, items.clone());
6239            items
6240        };
6241
6242        // Stage 4: Text orientation
6243        let oriented_items = apply_text_orientation(shaped_items, constraints);
6244
6245        // Stage 5 bypass: scan items for min/max contributions.
6246        let word_break = constraints.word_break;
6247        let hyphens = constraints.hyphenation;
6248
6249        let mut total = 0.0f32;      // running width of the current line
6250        let mut max_line = 0.0f32;   // widest line between forced breaks = max-content
6251        let mut max_word = 0.0f32;
6252        let mut cur_word = 0.0f32;
6253        let mut max_line_height = 0.0f32;
6254
6255        for item in oriented_items.iter() {
6256            // A forced break (preserved LF, <br>) ends the current line. max-content
6257            // is the widest line BETWEEN forced breaks, not the running sum across
6258            // them — otherwise a white-space:pre block with newlines (or any <br>
6259            // content) over-measures its max-content as the concatenation of all
6260            // lines. Reset the line accumulators here.
6261            if let ShapedItem::Break { .. } = item {
6262                if total > max_line { max_line = total; }
6263                if cur_word > max_word { max_word = cur_word; }
6264                total = 0.0;
6265                cur_word = 0.0;
6266                continue;
6267            }
6268            // Must match get_item_measure() exactly: a cluster's inline advance
6269            // INCLUDES per-glyph kerning. Omitting kerning here under-measures
6270            // max-content, so a shrink-to-fit box (e.g. a flex item sized to its
6271            // text's max-content) ends up narrower than the kerned text the line
6272            // breaker lays out — the word then "overflows" its own box and, with
6273            // overflow-wrap:normal, gets force-broken to its first cluster
6274            // (the menubar "View" → "V" clip). Summing (advance + kerning) here,
6275            // in the same order as the breaker, makes the box exactly fit.
6276            let advance = match item {
6277                ShapedItem::Cluster(c) => {
6278                    let total_kerning: f32 = c.glyphs.iter().map(|g| g.kerning).sum();
6279                    let mut a = c.advance + total_kerning;
6280                    // Match position_line_items exactly: letter-spacing is added after
6281                    // every non-cursive cluster and word-spacing on word separators.
6282                    // Omitting them here under-measures a shrink-to-fit box, so the
6283                    // laid-out (spaced) text overflows its own min/max-content width.
6284                    if !is_cursive_script_cluster(c) {
6285                        a += c.style.letter_spacing.resolve_px(c.style.font_size_px);
6286                    }
6287                    if is_word_separator(item) {
6288                        a += c.style.word_spacing.resolve_px(c.style.font_size_px);
6289                    }
6290                    a
6291                }
6292                ShapedItem::CombinedBlock { bounds, .. }
6293                | ShapedItem::Object { bounds, .. }
6294                | ShapedItem::Tab { bounds, .. } => bounds.width,
6295                ShapedItem::Break { .. } => 0.0,
6296            };
6297            let adv = advance.max(0.0);
6298            total += adv;
6299
6300            let (asc, desc) = get_item_vertical_metrics_approx(item);
6301            let h = (asc + desc).max(item.bounds().height);
6302            if h > max_line_height {
6303                max_line_height = h;
6304            }
6305
6306            if is_break_opportunity_with_word_break(item, word_break, hyphens) {
6307                if cur_word > max_word {
6308                    max_word = cur_word;
6309                }
6310                // A break opportunity that is itself a rendered unit (a CJK
6311                // ideograph in normal mode, or any cluster under break-all /
6312                // overflow-wrap:anywhere) still forms a minimal unbreakable unit
6313                // of its own advance; only true separators (spaces) contribute 0.
6314                // Without this, pure-CJK / break-all text measures min-content = 0
6315                // and the box collapses to zero inline width.
6316                if !is_word_separator(item) && adv > max_word {
6317                    max_word = adv;
6318                }
6319                cur_word = 0.0;
6320            } else {
6321                cur_word += adv;
6322            }
6323        }
6324        if cur_word > max_word {
6325            max_word = cur_word;
6326        }
6327        if total > max_line {
6328            max_line = total;
6329        }
6330
6331        // white-space:nowrap forbids soft-wrap opportunities entirely, so the
6332        // min-content width equals the max-content width (one unbreakable line).
6333        // Without this the scan resets cur_word at each space and reports a
6334        // too-small min-content, letting flex/shrink-to-fit clip the text.
6335        let min_content_width = if matches!(constraints.white_space_mode, WhiteSpaceMode::Nowrap) {
6336            max_line
6337        } else {
6338            max_word
6339        };
6340
6341        Ok(IntrinsicTextSizes {
6342            min_content_width,
6343            max_content_width: max_line,
6344            max_content_height: max_line_height,
6345        })
6346    }
6347}
6348
6349// --- Stage 1 Implementation ---
6350#[allow(clippy::cast_possible_truncation)] // bounded pixel/coord/colour/glyph cast
6351#[allow(clippy::too_many_lines, clippy::cognitive_complexity)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
6352/// # Panics
6353///
6354/// Panics if the scan cursor advances past the end of `text` (an internal invariant).
6355pub fn create_logical_items(
6356    content: &[InlineContent],
6357    style_overrides: &[StyleOverride],
6358    debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
6359) -> Vec<LogicalItem> {
6360    if let Some(msgs) = debug_messages {
6361        msgs.push(LayoutDebugMessage::info(
6362            "\n--- Entering create_logical_items (Refactored) ---".to_string(),
6363        ));
6364        msgs.push(LayoutDebugMessage::info(format!(
6365            "Input content length: {}",
6366            content.len()
6367        )));
6368        msgs.push(LayoutDebugMessage::info(format!(
6369            "Input overrides length: {}",
6370            style_overrides.len()
6371        )));
6372    }
6373
6374    let mut items: Vec<LogicalItem> = Vec::new();
6375    let mut style_cache: HashMap<u64, Arc<StyleProperties>> = HashMap::new();
6376
6377    // 1. Organize overrides for fast lookup per run.
6378    let mut run_overrides: HashMap<u32, HashMap<u32, &PartialStyleProperties>> = HashMap::new();
6379    for override_item in style_overrides {
6380        run_overrides
6381            .entry(override_item.target.run_index)
6382            .or_default()
6383            .insert(override_item.target.item_index, &override_item.style);
6384    }
6385
6386    for (run_idx, inline_item) in content.iter().enumerate() {
6387        if let Some(msgs) = debug_messages {
6388            msgs.push(LayoutDebugMessage::info(format!(
6389                "Processing content run #{run_idx}"
6390            )));
6391        }
6392
6393        // Extract marker information if this is a marker
6394        let marker_position_outside = match inline_item {
6395            InlineContent::Marker {
6396                position_outside, ..
6397            } => Some(*position_outside),
6398            _ => None,
6399        };
6400
6401        // [az-web-lift FIX 2026-06-06] Handle the common Text/Marker case via a STANDALONE `if let`
6402        // (a simple discriminant compare) instead of the first arm of the multi-way `match` below.
6403        // The remill lift mis-routes that multi-way InlineContent switch (LLVM's `subs/csel`-clamp
6404        // lowering): a Text(disc 0) variant lands in the `_`/Object arm → `inline_item.clone()` →
6405        // `<InlineContent as Clone>::clone` ALSO mis-routes to its Vec-clone arm → reads a heap ptr
6406        // as a Vec len → ×8 → ~789 MB alloc → BumpAlloc memset OOB. A standalone if-let lowers to a
6407        // single cmp/beq the lift handles correctly, so Text reaches its real body. Native unaffected.
6408        if let InlineContent::Text(run) | InlineContent::Marker { run, .. } = inline_item {
6409                let text = &run.text;
6410                if text.is_empty() {
6411                    if let Some(msgs) = debug_messages {
6412                        msgs.push(LayoutDebugMessage::info(
6413                            "  Run is empty, skipping.".to_string(),
6414                        ));
6415                    }
6416                    continue;
6417                }
6418                if let Some(msgs) = debug_messages {
6419                    msgs.push(LayoutDebugMessage::info(format!("  Run text: '{text}'")));
6420                }
6421
6422                let current_run_overrides = run_overrides.get(&(run_idx as u32));
6423                let mut boundaries = BTreeSet::new();
6424                boundaries.insert(0);
6425                boundaries.insert(text.len());
6426
6427                // --- Stateful Boundary Generation ---
6428                // web-lift FIX + perf: this scan_cursor walk ONLY inserts boundaries for
6429                // per-char style overrides (Rule 2) or text-combine-upright digit runs (Rule 1).
6430                // For plain text (no overrides AND no combine-upright) it inserts NOTHING and just
6431                // walks char-by-char via `scan_cursor += current_char.len_utf8()` — which the web
6432                // lift mis-advances (overshoot → slice_start_index_len_fail OOB; stall → infinite
6433                // loop). Skip the whole walk in that common case so `boundaries` stays {0, len}.
6434                let needs_scan = current_run_overrides.is_some()
6435                    || run.style.text_combine_upright.is_some();
6436                let mut scan_cursor = 0;
6437                while needs_scan && scan_cursor < text.len() {
6438                    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));
6439
6440                    let current_char = text[scan_cursor..].chars().next().unwrap();
6441
6442                    // +spec:containing-block:e4d9de - text-combine-upright digit run rules: digits sharing an ancestor with same value form one sequence across box boundaries
6443                    // +spec:inline-formatting-context:f65029 - text-combine-upright text run rules: combine consecutive digits not interrupted by box boundary
6444                    // Rule 1: Multi-character features take precedence.
6445                    // +spec:containing-block:9a26bd - text-combine-upright digit runs scoped by ancestor style boundaries
6446                    if let Some(TextCombineUpright::Digits(max_digits)) =
6447                        style_at_cursor.text_combine_upright
6448                    {
6449                        if max_digits > 0 && current_char.is_ascii_digit() {
6450                            let digit_chunk: String = text[scan_cursor..]
6451                                .chars()
6452                                .take(max_digits as usize)
6453                                .take_while(char::is_ascii_digit)
6454                                .collect();
6455
6456                            let end_of_chunk = scan_cursor + digit_chunk.len();
6457                            boundaries.insert(scan_cursor);
6458                            boundaries.insert(end_of_chunk);
6459                            scan_cursor = end_of_chunk; // Jump past the entire sequence
6460                            continue;
6461                        }
6462                    }
6463
6464                    // Rule 2: If no multi-char feature, check for a normal single-grapheme
6465                    // override.
6466                    if current_run_overrides
6467                        .and_then(|o| o.get(&(scan_cursor as u32)))
6468                        .is_some()
6469                    {
6470                        let grapheme_len = text[scan_cursor..]
6471                            .graphemes(true)
6472                            .next()
6473                            .unwrap_or("")
6474                            .len();
6475                        boundaries.insert(scan_cursor);
6476                        boundaries.insert(scan_cursor + grapheme_len);
6477                        scan_cursor += grapheme_len;
6478                        continue;
6479                    }
6480
6481                    // Rule 3: No special features or overrides at this point, just advance one
6482                    // char.
6483                    scan_cursor += current_char.len_utf8();
6484                }
6485
6486                if let Some(msgs) = debug_messages {
6487                    msgs.push(LayoutDebugMessage::info(format!(
6488                        "  Boundaries: {boundaries:?}"
6489                    )));
6490                }
6491
6492                // --- Chunk Processing ---
6493                for (start, end) in boundaries.iter().zip(boundaries.iter().skip(1)) {
6494                    let (start, end) = (*start, *end);
6495                    if start >= end {
6496                        continue;
6497                    }
6498
6499                    let text_slice = &text[start..end];
6500                    if let Some(msgs) = debug_messages {
6501                        msgs.push(LayoutDebugMessage::info(format!(
6502                            "  Processing chunk from {start} to {end}: '{text_slice}'"
6503                        )));
6504                    }
6505
6506                    let style_to_use = current_run_overrides.and_then(|o| o.get(&(start as u32))).map_or_else(|| run.style.clone(), |partial_style| {
6507                        if let Some(msgs) = debug_messages {
6508                            msgs.push(LayoutDebugMessage::info(format!(
6509                                "  -> Applying override at byte {start}"
6510                            )));
6511                        }
6512                        let mut hasher = DefaultHasher::new();
6513                        Arc::as_ptr(&run.style).hash(&mut hasher);
6514                        partial_style.hash(&mut hasher);
6515                        style_cache
6516                            .entry(hasher.finish())
6517                            .or_insert_with(|| Arc::new(run.style.apply_override(partial_style)))
6518                            .clone()
6519                    });
6520
6521                    // +spec:block-formatting-context:9e7c79 - text-combine-upright combines multiple characters into 1em in vertical writing
6522                    // +spec:containing-block:2b399b - text-combine-upright digits: combine ASCII digit sequences within max_digits limit; box boundaries implicitly prevent cross-box combination
6523                    // +spec:display-contents:644c78 - text-combine-upright run boundary check:
6524                    // if a combinable run boundary is due only to inline box boundaries,
6525                    // and adjacent chars would form a longer combinable sequence, do not combine
6526                    // +spec:white-space-processing:409d90 - text-combine-upright combined text: white space at start/end processed as in inline-block
6527                    let is_combinable_chunk = match &style_to_use.text_combine_upright {
6528                        Some(TextCombineUpright::All) => !text_slice.is_empty(),
6529                        Some(TextCombineUpright::Digits(max_digits)) => {
6530                            *max_digits > 0
6531                                && !text_slice.is_empty()
6532                                && text_slice.chars().all(|c| c.is_ascii_digit())
6533                                && text_slice.chars().count() <= *max_digits as usize
6534                        }
6535                        _ => false,
6536                    };
6537
6538                    if is_combinable_chunk {
6539                        // Trim leading/trailing white space like an inline-block
6540                        let trimmed = text_slice.trim();
6541                        let combined_text = if trimmed.is_empty() {
6542                            text_slice.to_string()
6543                        } else {
6544                            trimmed.to_string()
6545                        };
6546                        items.push(LogicalItem::CombinedText {
6547                            source: ContentIndex {
6548                                run_index: run_idx as u32,
6549                                item_index: start as u32,
6550                            },
6551                            text: combined_text,
6552                            style: style_to_use,
6553                        });
6554                    } else {
6555                        items.push(LogicalItem::Text {
6556                            source: ContentIndex {
6557                                run_index: run_idx as u32,
6558                                item_index: start as u32,
6559                            },
6560                            text: text_slice.to_string(),
6561                            style: style_to_use,
6562                            marker_position_outside,
6563                            source_node_id: run.source_node_id,
6564                        });
6565                    }
6566                }
6567        } else {
6568            match inline_item {
6569            // line breaking class characters must be treated as forced line breaks
6570            InlineContent::LineBreak(break_info) => {
6571                if let Some(msgs) = debug_messages {
6572                    msgs.push(LayoutDebugMessage::info(format!(
6573                        "  LineBreak: {break_info:?}"
6574                    )));
6575                }
6576                items.push(LogicalItem::Break {
6577                    source: ContentIndex {
6578                        run_index: run_idx as u32,
6579                        item_index: 0,
6580                    },
6581                    break_info: *break_info,
6582                });
6583            }
6584            // Handle tab characters
6585            InlineContent::Tab { style } => {
6586                if let Some(msgs) = debug_messages {
6587                    msgs.push(LayoutDebugMessage::info("  Tab character".to_string()));
6588                }
6589                items.push(LogicalItem::Tab {
6590                    source: ContentIndex {
6591                        run_index: run_idx as u32,
6592                        item_index: 0,
6593                    },
6594                    style: style.clone(),
6595                });
6596            }
6597            // Other cases (Image, Shape, Space, Ruby). Text/Marker are handled by the `if let`
6598            // above (so they never reach here at runtime); `_` keeps this inner match exhaustive.
6599            _ => {
6600                if let Some(msgs) = debug_messages {
6601                    msgs.push(LayoutDebugMessage::info(
6602                        "  Run is not text, creating generic LogicalItem.".to_string(),
6603                    ));
6604                }
6605                items.push(LogicalItem::Object {
6606                    source: ContentIndex {
6607                        run_index: run_idx as u32,
6608                        item_index: 0,
6609                    },
6610                    content: inline_item.clone(),
6611                });
6612            }
6613            }
6614        }
6615    }
6616    if let Some(msgs) = debug_messages {
6617        msgs.push(LayoutDebugMessage::info(format!(
6618            "--- Exiting create_logical_items, created {} items ---",
6619            items.len()
6620        )));
6621    }
6622    items
6623}
6624
6625// --- Stage 2 Implementation ---
6626
6627// +spec:inline-block:d47971 - unicode-bidi:plaintext uses P2/P3 heuristic for base direction (implemented via get_base_direction)
6628// +spec:writing-modes:287491 - BiDi reordering and base direction detection (Appendix A text processing order)
6629// when determining base direction, consistent with their neutral bidi treatment
6630#[must_use] pub fn get_base_direction_from_logical(logical_items: &[LogicalItem]) -> BidiDirection {
6631    let first_strong = logical_items.iter().find_map(|item| {
6632        if let LogicalItem::Text { text, .. } = item {
6633            Some(unicode_bidi::get_base_direction(text.as_str()))
6634        } else {
6635            None
6636        }
6637    });
6638
6639    match first_strong {
6640        Some(unicode_bidi::Direction::Rtl) => BidiDirection::Rtl,
6641        _ => BidiDirection::Ltr,
6642    }
6643}
6644
6645// +spec:containing-block:149255 - bidi reordering produces inline box fragments that may separate in wide containing blocks
6646// +spec:containing-block:c7c08f - bidi reordering produces inline box fragments that may be adjacent in narrow containing blocks
6647// +spec:containing-block:2936ae - bidi reordering splits inline boxes into visual fragments (CSS Writing Modes 4 §2.4.5)
6648// +spec:display-property:0cdbd3 - bidi reordering splits inline boxes into visual runs; each run is shaped/formatted independently
6649// +spec:display-property:0d62a2 - bidi reordering of inline content respects block direction and unicode-bidi embedding
6650// +spec:display-property:10f9cd - bidi reordering splits and reorders inline box fragments
6651// +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
6652// +spec:display-property:ecd935 - inline boxes split and reordered for uniform bidi flow
6653// +spec:writing-modes:330b8f - text ordered according to Unicode bidi algorithm after white-space processing
6654// +spec:writing-modes:7a9e7d - bidi control translation: text passed to unicode_bidi for reordering
6655// +spec:writing-modes:8e7281 - unicode-bidi property: bidi control codes inserted via BidiInfo
6656#[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
6657#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
6658/// # Errors
6659///
6660/// Returns a `LayoutError` if bidi reordering fails.
6661pub fn reorder_logical_items(
6662    logical_items: &[LogicalItem],
6663    base_direction: BidiDirection,
6664    unicode_bidi: UnicodeBidi,
6665    debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
6666) -> Result<Vec<VisualItem>, LayoutError> {
6667    if let Some(msgs) = debug_messages {
6668        msgs.push(LayoutDebugMessage::info(
6669            "\n--- Entering reorder_logical_items ---".to_string(),
6670        ));
6671        msgs.push(LayoutDebugMessage::info(format!(
6672            "Input logical items count: {}",
6673            logical_items.len()
6674        )));
6675        msgs.push(LayoutDebugMessage::info(format!(
6676            "Base direction: {base_direction:?}"
6677        )));
6678    }
6679
6680    // +spec:writing-modes:809513 - bidi string built across inline element boundaries; unicode-bidi:normal adds no extra embedding levels
6681    let mut bidi_str = String::new();
6682    let mut item_map = Vec::new();
6683    // Byte offset in `bidi_str` where each logical item's text begins, indexed
6684    // by logical item index. Used to re-base each visual run's byte offset to be
6685    // relative to its own logical run (see `run_byte_offset`).
6686    let mut logical_item_starts = Vec::with_capacity(logical_items.len());
6687    for (idx, item) in logical_items.iter().enumerate() {
6688        // +spec:containing-block:1fdc31 - inline boxes with unicode-bidi:normal are transparent to bidi algorithm
6689        // +spec:display-property:074abf - inline boxes transparent to bidi when unicode-bidi:normal
6690        // +spec:display-property:354966 - unicode-bidi control code injection for inline boxes
6691        // +spec:display-property:8409d3 - inline-level elements with unicode-bidi:normal have no effect on bidi ordering; embed creates an embedding
6692        // +spec:display-property:89464a - inline boxes with unicode-bidi:normal don't open embedding levels, so direction has no effect on bidi reordering
6693        // +spec:display-property:d47971 - bidi control codes should be injected at inline box boundaries based on unicode-bidi + direction
6694        // +spec:display-property:de657b - bidi control codes injected for display:inline boxes per unicode-bidi value
6695        // +spec:display-property:f01a81 - bidi-override should prepend LRO/RLO and append PDF per unicode-bidi CSS property (not yet implemented)
6696        // are treated as neutral characters in the bidi algorithm. Replaced elements with
6697        // +spec:display-property:fcb011 - unicode-bidi values on inline boxes insert bidi control codes
6698        // +spec:display-property:89095f - isolate/bidi-override/isolate-override/plaintext semantics
6699        // +spec:writing-modes:d490bf - direction only affects reordering when unicode-bidi is embed/override (not yet enforced for inline elements)
6700        // display:inline are also neutral unless unicode-bidi != normal (not yet implemented).
6701        // +spec:display-property:b4756e - replaced inline elements treated as neutral bidi chars;
6702        // embed/bidi-override exception not yet implemented (would make them strong chars).
6703        // U+FFFC (OBJECT REPLACEMENT CHARACTER) is a neutral bidi character.
6704        // +spec:display-property:df11ef - atomic inlines treated as neutral bidi characters (U+FFFC)
6705        // Replaced elements with display:inline are also neutral unless unicode-bidi != normal.
6706        let text = match item {
6707            LogicalItem::Text { text, .. } => text.as_str(),
6708            LogicalItem::CombinedText { text, .. } => text.as_str(),
6709            _ => "\u{FFFC}",
6710        };
6711        let start_byte = bidi_str.len();
6712        logical_item_starts.push(start_byte);
6713        bidi_str.push_str(text);
6714        for _ in start_byte..bidi_str.len() {
6715            item_map.push(idx);
6716        }
6717    }
6718
6719    if bidi_str.is_empty() {
6720        if let Some(msgs) = debug_messages {
6721            msgs.push(LayoutDebugMessage::info(
6722                "Bidi string is empty, returning.".to_string(),
6723            ));
6724        }
6725        return Ok(Vec::new());
6726    }
6727    if let Some(msgs) = debug_messages {
6728        msgs.push(LayoutDebugMessage::info(format!(
6729            "Constructed bidi string: '{bidi_str}'"
6730        )));
6731    }
6732
6733    // +spec:display-property:1a6075 - paragraph embedding level set from direction property per UAX9 HL1
6734    // +spec:containing-block:0d4914 - unicode-bidi: plaintext exception
6735    // When the containing block has unicode-bidi: plaintext, use None so the
6736    // Unicode bidi algorithm applies P2/P3 heuristics instead of the HL1 override
6737    let bidi_level = if unicode_bidi == UnicodeBidi::Plaintext {
6738        None
6739    } else if base_direction == BidiDirection::Rtl {
6740        Some(Level::rtl())
6741    } else {
6742        Some(Level::ltr())
6743    };
6744    // +spec:writing-modes:15bf17 - bidi isolation handled by unicode_bidi UAX #9 implementation
6745    let bidi_info = BidiInfo::new(&bidi_str, bidi_level);
6746    let para = &bidi_info.paragraphs[0];
6747    let (levels, visual_runs) = bidi_info.visual_runs(para, para.range.clone());
6748
6749    if let Some(msgs) = debug_messages {
6750        msgs.push(LayoutDebugMessage::info(
6751            "Bidi visual runs generated:".to_string(),
6752        ));
6753        for (i, run_range) in visual_runs.iter().enumerate() {
6754            let level = levels[run_range.start].number();
6755            let slice = &bidi_str[run_range.start..run_range.end];
6756            msgs.push(LayoutDebugMessage::info(format!(
6757                "  Run {i}: range={run_range:?}, level={level}, text='{slice}'"
6758            )));
6759        }
6760    }
6761
6762    // TODO(text3-review): RTL glyph-level visual reversal is NOT applied.
6763    // `visual_runs` orders the RUNS visually (left-to-right), but the loop below
6764    // emits each run's content in LOGICAL byte order, and shaping/positioning then
6765    // place clusters left-to-right in that logical order. For an RTL run this is
6766    // wrong: the first logical character must land at the LARGEST visual x. The
6767    // shaped clusters of each RTL run therefore need to be reversed (UBA rule L2,
6768    // applied per run at the glyph level AFTER shaping — a single logical Text item
6769    // shapes into multiple clusters, so it cannot be reversed here at the item
6770    // level). This must compose with the run-level ordering already done here
6771    // (naively re-running full L2 on top would double-reverse RTL-base paragraphs),
6772    // and `UnifiedLayout::get_selection_rects` must additionally split a selection
6773    // into one visual rect per directional segment. Deferred as a coherent
6774    // cross-cutting change; see failing tests text3_brutal_shaping::
6775    // {hebrew_run_is_rtl_reversed_and_33px_wide, bidi_mixed_run_is_80px_and_reverses_hebrew}
6776    // and text3_brutal_selection::bidi_selection_over_rtl_run_splits_into_multiple_rects.
6777    let mut visual_items = Vec::new();
6778    for run_range in visual_runs {
6779        let bidi_level = BidiLevel::new(levels[run_range.start].number());
6780        let mut sub_run_start = run_range.start;
6781
6782        for i in (run_range.start + 1)..run_range.end {
6783            if item_map[i] != item_map[sub_run_start] {
6784                let logical_idx = item_map[sub_run_start];
6785                let logical_item = &logical_items[logical_idx];
6786                let text_slice = &bidi_str[sub_run_start..i];
6787                visual_items.push(VisualItem {
6788                    logical_source: logical_item.clone(),
6789                    bidi_level,
6790                    script: crate::text3::script::detect_script(text_slice)
6791                        .unwrap_or(Script::Latin),
6792                    text: text_slice.to_string(),
6793                    run_byte_offset: sub_run_start - logical_item_starts[logical_idx],
6794                });
6795                sub_run_start = i;
6796            }
6797        }
6798
6799        let logical_idx = item_map[sub_run_start];
6800        let logical_item = &logical_items[logical_idx];
6801        let text_slice = &bidi_str[sub_run_start..run_range.end];
6802        visual_items.push(VisualItem {
6803            logical_source: logical_item.clone(),
6804            bidi_level,
6805            script: crate::text3::script::detect_script(text_slice).unwrap_or(Script::Latin),
6806            text: text_slice.to_string(),
6807            run_byte_offset: sub_run_start - logical_item_starts[logical_idx],
6808        });
6809    }
6810
6811    if let Some(msgs) = debug_messages {
6812        msgs.push(LayoutDebugMessage::info(
6813            "Final visual items produced:".to_string(),
6814        ));
6815        for (i, item) in visual_items.iter().enumerate() {
6816            msgs.push(LayoutDebugMessage::info(format!(
6817                "  Item {}: level={}, text='{}'",
6818                i,
6819                item.bidi_level.level(),
6820                item.text
6821            )));
6822        }
6823        msgs.push(LayoutDebugMessage::info(
6824            "--- Exiting reorder_logical_items ---".to_string(),
6825        ));
6826    }
6827    Ok(visual_items)
6828}
6829
6830// --- Stage 3 Implementation ---
6831
6832/// Shape visual items into `ShapedItems` using pre-loaded fonts.
6833///
6834/// This function does NOT load any fonts - all fonts must be pre-loaded and passed in.
6835/// If a required font is not in `loaded_fonts`, the text will be skipped with a warning.
6836///
6837/// **Optimization: Inline Run Coalescing**
6838///
6839/// // +spec:display-property:9c6d59 - text shaping not broken across inline box boundaries when no effective formatting change
6840/// // +spec:display-property:cf8917 - text shaping not broken across inline box boundaries
6841/// When consecutive text `VisualItem`s share the same layout-affecting properties
6842/// (font, size, spacing, etc.) but differ only in rendering properties (color,
6843/// background), they are coalesced into a single shaping call. This dramatically
6844/// reduces the number of `font.shape_text()` invocations for syntax-highlighted
6845/// code where hundreds of `<span>` elements use the same monospace font but
6846/// different colors. After shaping, the original per-span styles are restored
6847/// to each `ShapedCluster` based on byte-range mapping.
6848/// Shape visual items with per-item caching. For each item (or coalesced group),
6849/// compute a cache key from (text, `bidi_level`, script, `style_layout_hash`). On cache
6850/// hit, reuse the previously shaped clusters. On miss, shape and store.
6851///
6852/// This is the incremental shaping path: when one word changes in a paragraph,
6853/// only that word's item misses the per-item cache; all other items hit.
6854#[allow(clippy::implicit_hasher)] // internal helper; only ever called with the default-hasher HashMap/HashSet
6855/// # Errors
6856///
6857/// Returns a `LayoutError` if shaping the visual items fails.
6858pub fn shape_visual_items_with_per_item_cache<T: ParsedFontTrait>(
6859    visual_items: &[VisualItem],
6860    per_item_cache: &mut HashMap<u64, Arc<PerItemShapedEntry>>,
6861    per_item_accessed: &mut HashSet<u64>,
6862    font_chain_cache: &HashMap<FontChainKey, rust_fontconfig::FontFallbackChain>,
6863    fc_cache: &FcFontCache,
6864    loaded_fonts: &LoadedFonts<T>,
6865    debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
6866) -> Result<Vec<ShapedItem>, LayoutError> {
6867    use std::hash::{Hash, Hasher};
6868    // Delegate to the existing shaping logic, but for each coalesce group,
6869    // check the per-item cache first.
6870    //
6871    // Strategy: Identify coalesce groups (adjacent items with same layout_hash,
6872    // bidi_level, script). For each group, compute a key from the concatenated
6873    // text + shared properties. Check cache. On miss, shape the group and cache it.
6874    let mut shaped = Vec::new();
6875    let mut idx = 0;
6876
6877    while idx < visual_items.len() {
6878        let item = &visual_items[idx];
6879
6880        // Determine coalesce group boundaries (same logic as shape_visual_items)
6881        let (layout_hash, bidi_level, script) = match &item.logical_source {
6882            LogicalItem::Text { style, .. } | LogicalItem::CombinedText { style, .. } => {
6883                (style.layout_hash(), item.bidi_level, item.script)
6884            }
6885            _ => {
6886                // Non-text items: shape individually (no coalescing)
6887                let single = shape_visual_items(
6888                    &visual_items[idx..=idx],
6889                    font_chain_cache, fc_cache, loaded_fonts, debug_messages,
6890                )?;
6891                shaped.extend(single);
6892                idx += 1;
6893                continue;
6894            }
6895        };
6896
6897        let mut coalesce_end = idx + 1;
6898        while coalesce_end < visual_items.len() {
6899            let next = &visual_items[coalesce_end];
6900            let next_layout_hash = match &next.logical_source {
6901                LogicalItem::Text { style, .. } | LogicalItem::CombinedText { style, .. } => {
6902                    Some(style.layout_hash())
6903                }
6904                _ => None,
6905            };
6906            if let Some(nlh) = next_layout_hash {
6907                if nlh == layout_hash
6908                    && next.bidi_level == bidi_level
6909                    && next.script == script
6910                {
6911                    coalesce_end += 1;
6912                } else {
6913                    break;
6914                }
6915            } else {
6916                break;
6917            }
6918        }
6919
6920        // Compute per-group cache key
6921        let mut hasher = DefaultHasher::new();
6922        for item in &visual_items[idx..coalesce_end] {
6923            item.text.hash(&mut hasher);
6924        }
6925        layout_hash.hash(&mut hasher);
6926        bidi_level.hash(&mut hasher);
6927        (script as u32).hash(&mut hasher);
6928        let group_key = hasher.finish();
6929
6930        // Check per-item cache
6931        per_item_accessed.insert(group_key);
6932        if let Some(cached) = per_item_cache.get(&group_key) {
6933            shaped.extend(cached.clusters.iter().cloned());
6934        } else {
6935            // Cache miss — shape this group
6936            let group_items = shape_visual_items(
6937                &visual_items[idx..coalesce_end],
6938                font_chain_cache, fc_cache, loaded_fonts, debug_messages,
6939            )?;
6940            let total_advance: f32 = group_items.iter().map(|item| {
6941                match item {
6942                    ShapedItem::Cluster(c) => c.advance,
6943                    _ => 0.0,
6944                }
6945            }).sum();
6946            per_item_cache.insert(group_key, Arc::new(PerItemShapedEntry {
6947                clusters: group_items.clone(),
6948                total_advance,
6949            }));
6950            shaped.extend(group_items);
6951        }
6952
6953        idx = coalesce_end;
6954    }
6955
6956    Ok(shaped)
6957}
6958
6959/// Split text into segments where consecutive characters resolve to the same font
6960/// in the fallback chain. Returns Vec<(`byte_start`, `byte_end`, `FontId`)>.
6961///
6962/// Characters that can't be resolved to any font are skipped (gap in coverage).
6963fn split_text_by_font_coverage<T: ParsedFontTrait>(
6964    text: &str,
6965    font_chain: &rust_fontconfig::FontFallbackChain,
6966    fc_cache: &FcFontCache,
6967    loaded_fonts: &LoadedFonts<T>,
6968) -> Vec<(usize, usize, FontId)> {
6969    let mut segments: Vec<(usize, usize, FontId)> = Vec::new();
6970
6971    // Deterministic "last resort" face for characters no font covers: the lowest
6972    // FontId among the loaded fonts. Used so an uncovered codepoint still emits a
6973    // .notdef (tofu) segment instead of being silently dropped (zero glyphs/advance).
6974    let notdef_font_id = loaded_fonts.iter().map(|(id, _)| *id).min();
6975
6976    for (byte_idx, ch) in text.char_indices() {
6977        let char_end = byte_idx + ch.len_utf8();
6978        // Primary: the resolved fallback chain. Its coverage comes from
6979        // rust-fontconfig's OS/2-derived `unicode_ranges`, which can MISS
6980        // codepoints a font actually has in its cmap — e.g. Noto Sans CJK's
6981        // JP face does not advertise the Hangul OS/2 block, so 한국어 resolves
6982        // to None here even though that face's cmap covers it.
6983        let font_id = font_chain
6984            .resolve_char(fc_cache, ch)
6985            .map(|(id, _)| id)
6986            // Fallback: probe the actually-loaded fonts by REAL glyph coverage
6987            // so OS/2-vs-cmap gaps render instead of being silently dropped.
6988            // The covering CJK face is already loaded (Han/Kana resolved to it),
6989            // so this reuses it for Hangul rather than mixing in another font.
6990            // Iterate in a STABLE order (lowest FontId first) so the chosen face is
6991            // deterministic across processes — a raw HashMap `.find` is seeded per
6992            // process and would pick different faces run-to-run.
6993            .or_else(|| {
6994                loaded_fonts
6995                    .iter()
6996                    .filter(|(_, font)| font.has_glyph(ch as u32))
6997                    .map(|(id, _)| *id)
6998                    .min()
6999            })
7000            // Last resort: no font advertises OR covers this codepoint. Assign it to
7001            // the primary loaded face so the shaper emits a visible .notdef box and
7002            // the byte range is preserved (following text is not shifted).
7003            .or(notdef_font_id);
7004        if let Some(font_id) = font_id {
7005            match segments.last_mut() {
7006                Some(last) if last.2 == font_id && last.1 == byte_idx => {
7007                    // Extend current segment (same font, contiguous)
7008                    last.1 = char_end;
7009                }
7010                _ => {
7011                    // New segment (different font or gap)
7012                    segments.push((byte_idx, char_end, font_id));
7013                }
7014            }
7015        }
7016    }
7017
7018    segments
7019}
7020
7021/// Measures the total inline advance (width in horizontal mode) of `text` shaped at
7022/// `style`, using the same font-resolution path as the main shaper. Returns `None` if the
7023/// font chain is not resolved / shaping fails, so callers can fall back to an estimate.
7024///
7025/// Used by ruby layout to size the base and annotation runs from REAL shaped advances
7026/// (instead of a `chars * font_size * magic_ratio` fudge).
7027fn measure_run_advance<T: ParsedFontTrait>(
7028    text: &str,
7029    style: &Arc<StyleProperties>,
7030    script: Script,
7031    source: ContentIndex,
7032    font_chain_cache: &HashMap<FontChainKey, rust_fontconfig::FontFallbackChain>,
7033    fc_cache: &FcFontCache,
7034    loaded_fonts: &LoadedFonts<T>,
7035) -> Option<f32> {
7036    if text.is_empty() {
7037        return Some(0.0);
7038    }
7039    let language = script_to_language(script, text);
7040    match &style.font_stack {
7041        FontStack::Ref(font_ref) => {
7042            let glyphs = font_ref
7043                .shape_text(text, script, language, BidiDirection::Ltr, style.as_ref())
7044                .ok()?;
7045            Some(glyphs.iter().map(|g| g.advance + g.kerning).sum())
7046        }
7047        FontStack::Stack(selectors) => {
7048            let cache_key = FontChainKey::from_selectors(selectors);
7049            let font_chain = font_chain_cache.get(&cache_key)?;
7050            let clusters = shape_with_font_fallback(
7051                text, script, language, BidiDirection::Ltr, style, source, None, font_chain,
7052                fc_cache, loaded_fonts,
7053            )
7054            .ok()?;
7055            Some(clusters.iter().map(|c| c.advance).sum())
7056        }
7057    }
7058}
7059
7060/// Shape text with per-character font fallback.
7061///
7062/// Splits the text into segments by font coverage, shapes each segment with
7063/// its resolved font, and fixes byte offsets so they're relative to the
7064/// original `text` (not the segment substring).
7065#[allow(clippy::cast_possible_truncation)] // bounded pixel/coord/colour/glyph cast
7066fn shape_with_font_fallback<T: ParsedFontTrait>(
7067    text: &str,
7068    script: Script,
7069    language: Language,
7070    direction: BidiDirection,
7071    style: &Arc<StyleProperties>,
7072    source_index: ContentIndex,
7073    source_node_id: Option<NodeId>,
7074    font_chain: &rust_fontconfig::FontFallbackChain,
7075    fc_cache: &FcFontCache,
7076    loaded_fonts: &LoadedFonts<T>,
7077) -> Result<Vec<ShapedCluster>, LayoutError> {
7078    // Cache the debug flag in a `OnceLock<bool>` — reading it per-shape
7079    // (this function fires once per text segment, ~hundreds of times
7080    // per render of a real DOM) costs ~100 ns per `std::env::var_os`
7081    // call on macOS (env-lock + hashmap lookup), and even before the
7082    // lookup finishes the `eprintln!` machinery takes a stderr lock
7083    // and allocates the formatted string. Both are invisible in
7084    // release unless `AZ_FONT_FALLBACK_DEBUG=1` is set.
7085    static FONT_FB_DEBUG: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
7086    let dbg = *FONT_FB_DEBUG.get_or_init(|| {
7087        std::env::var_os("AZ_FONT_FALLBACK_DEBUG").is_some()
7088    });
7089
7090    let segments = split_text_by_font_coverage(text, font_chain, fc_cache, loaded_fonts);
7091
7092    if dbg && segments.len() > 1 {
7093        eprintln!(
7094            "[FONT FALLBACK] text needs {} font segments for '{}' ({}..{} bytes)",
7095            segments.len(),
7096            text.chars().take(40).collect::<String>(),
7097            0, text.len()
7098        );
7099    }
7100
7101    unsafe { crate::az_mark(0x60850_u32, segments.len() as u32); } // [g123] segments count (split_text_by_font_coverage)
7102    if segments.len() <= 1 {
7103        // Fast path: all characters use the same font (common case)
7104        let (seg_start, seg_end, font_id) = if let Some(s) = segments.first() { unsafe { crate::az_mark(0x60854_u32, 0x0000_0001_u32); } s } else {
7105            unsafe { crate::az_mark(0x60854_u32, 0x0000_00EE_u32); } // [g123] split→0 segments (resolve_char failed all)
7106            if dbg {
7107                eprintln!("[FONT FALLBACK] no font could render any char in '{}'", text.chars().take(20).collect::<String>());
7108            }
7109            return Ok(Vec::new());
7110        };
7111        let font = if let Some(f) = loaded_fonts.get(font_id) { unsafe { crate::az_mark(0x60858_u32, 0x0000_0001_u32); } f } else {
7112            unsafe { crate::az_mark(0x60858_u32, 0x0000_00EE_u32); } // [g123] loaded_fonts.get MISS
7113            if dbg {
7114                eprintln!("[FONT FALLBACK] font {:?} not in loaded_fonts for '{}'", font_id, text.chars().take(20).collect::<String>());
7115            }
7116            return Ok(Vec::new());
7117        };
7118        // If segment covers the full text (overwhelmingly common), skip substr+fixup
7119        if *seg_start == 0 && *seg_end == text.len() {
7120            unsafe { crate::az_mark(0x60860_u32, 0xC0DE_0860_u32); } // [g123] reached shape_text_correctly (full-text)
7121            return shape_text_correctly(
7122                text, script, language, direction,
7123                font, style, source_index, source_node_id,
7124            );
7125        }
7126        let mut clusters = shape_text_correctly(
7127            &text[*seg_start..*seg_end], script, language, direction,
7128            font, style, source_index, source_node_id,
7129        )?;
7130        if *seg_start > 0 {
7131            for cluster in &mut clusters {
7132                cluster.source_cluster_id.start_byte_in_run += *seg_start as u32;
7133            }
7134        }
7135        return Ok(clusters);
7136    }
7137
7138    // Multiple fonts needed — shape each segment separately
7139    let mut all_clusters = Vec::new();
7140    for (seg_start, seg_end, font_id) in &segments {
7141        let Some(font) = loaded_fonts.get(font_id) else {
7142            if dbg {
7143                eprintln!("[FONT FALLBACK] font {font_id:?} NOT loaded, skipping segment bytes {seg_start}..{seg_end}");
7144            }
7145            continue;
7146        };
7147        let segment_text = &text[*seg_start..*seg_end];
7148        if dbg {
7149            eprintln!(
7150                "[FONT FALLBACK] text='{segment_text}' uses font {font_id:?} (bytes {seg_start}..{seg_end})"
7151            );
7152        }
7153        let mut seg_clusters = shape_text_correctly(
7154            segment_text, script, language, direction,
7155            font, style, source_index, source_node_id,
7156        )?;
7157        // Fix byte offsets: shape_text_correctly produces offsets relative to
7158        // segment_text, but callers expect offsets relative to the full text.
7159        if *seg_start > 0 {
7160            for cluster in &mut seg_clusters {
7161                cluster.source_cluster_id.start_byte_in_run += *seg_start as u32;
7162            }
7163        }
7164        all_clusters.extend(seg_clusters);
7165    }
7166    Ok(all_clusters)
7167}
7168
7169#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)] // bounded pixel/coord/colour/glyph cast
7170#[allow(clippy::implicit_hasher)] // internal helper; only ever called with the default-hasher HashMap/HashSet
7171#[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
7172#[allow(clippy::too_many_lines, clippy::cognitive_complexity)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
7173/// # Errors
7174///
7175/// Returns a `LayoutError` if shaping the visual items fails.
7176pub fn shape_visual_items<T: ParsedFontTrait>(
7177    visual_items: &[VisualItem],
7178    font_chain_cache: &HashMap<FontChainKey, rust_fontconfig::FontFallbackChain>,
7179    fc_cache: &FcFontCache,
7180    loaded_fonts: &LoadedFonts<T>,
7181    debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
7182) -> Result<Vec<ShapedItem>, LayoutError> {
7183    let mut shaped = Vec::new();
7184    let mut idx = 0;
7185    let mut _coalesced_runs = 0usize;
7186    let mut _total_runs = 0usize;
7187    let mut _shape_calls = 0usize;
7188
7189    // Log count of visual items for debugging coalescing
7190
7191    while idx < visual_items.len() {
7192        let item = &visual_items[idx];
7193        match &item.logical_source {
7194            LogicalItem::Text {
7195                style,
7196                source,
7197                marker_position_outside,
7198                source_node_id,
7199                ..
7200            } => {
7201                let layout_hash = style.layout_hash();
7202                let bidi_level = item.bidi_level;
7203                let script = item.script;
7204
7205                // +spec:display-property:ca95f6 - text shaping breaks at inline box boundaries when layout-affecting properties differ
7206                // when layout-affecting properties (font weight, family, size, etc.) change
7207                // across element boundaries, preventing ligatures from forming across such changes.
7208                // Look ahead: find consecutive text items with the same layout-affecting
7209                // properties (font, size, spacing) that can be shaped as one merged run.
7210                let mut coalesce_end = idx + 1;
7211                while coalesce_end < visual_items.len() {
7212                    let next = &visual_items[coalesce_end];
7213                    if let LogicalItem::Text { style: next_style, .. } = &next.logical_source {
7214                        if next_style.layout_hash() == layout_hash
7215                            && next.bidi_level == bidi_level
7216                            && next.script == script
7217                        {
7218                            coalesce_end += 1;
7219                        } else {
7220                            break;
7221                        }
7222                    } else {
7223                        break;
7224                    }
7225                }
7226
7227                let coalesce_count = coalesce_end - idx;
7228
7229                if coalesce_count > 1 {
7230                    _coalesced_runs += coalesce_count;
7231                    _shape_calls += 1;
7232                    // ── COALESCED PATH ──
7233                    // Merge N text items into one shaping call, then split results
7234                    // back per original run to preserve per-span rendering styles.
7235
7236                    // Build merged text and record byte ranges → original style
7237                    let total_text_len: usize = visual_items[idx..coalesce_end]
7238                        .iter()
7239                        .map(|v| v.text.len())
7240                        .sum();
7241                    let mut merged_text = String::with_capacity(total_text_len);
7242                    // (byte_start, byte_end, style, source, source_node_id, marker_outside, run_byte_offset)
7243                    let mut byte_ranges: Vec<(
7244                        usize, usize,
7245                        Arc<StyleProperties>,
7246                        ContentIndex,
7247                        Option<NodeId>,
7248                        Option<bool>,
7249                        usize,
7250                    )> = Vec::with_capacity(coalesce_count);
7251
7252                    for item in &visual_items[idx..coalesce_end] {
7253                        let start = merged_text.len();
7254                        merged_text.push_str(&item.text);
7255                        let end = merged_text.len();
7256                        if let LogicalItem::Text {
7257                            style: s, source: src, source_node_id: nid,
7258                            marker_position_outside: mpo, ..
7259                        } = &item.logical_source {
7260                            byte_ranges.push((start, end, s.clone(), *src, *nid, *mpo, item.run_byte_offset));
7261                        }
7262                    }
7263
7264                    if let Some(msgs) = debug_messages {
7265                        msgs.push(LayoutDebugMessage::info(format!(
7266                            "[TextLayout] Coalescing {} text runs ({} bytes) into single shaping call",
7267                            coalesce_count, merged_text.len()
7268                        )));
7269                    }
7270
7271                    let direction = if bidi_level.is_rtl() {
7272                        BidiDirection::Rtl
7273                    } else {
7274                        BidiDirection::Ltr
7275                    };
7276                    let language = script_to_language(script, &merged_text);
7277
7278                    // Shape the merged text using the first item's font (layout is identical
7279                    // for all coalesced items since layout_hash matches).
7280                    let shaped_clusters_result: Result<Vec<ShapedCluster>, LayoutError> = match &style.font_stack {
7281                        FontStack::Ref(font_ref) => {
7282                            shape_text_correctly(
7283                                &merged_text, script, language, direction,
7284                                font_ref, style, *source, *source_node_id,
7285                            )
7286                        }
7287                        FontStack::Stack(selectors) => {
7288                            let cache_key = FontChainKey::from_selectors(selectors);
7289                            let Some(font_chain) = font_chain_cache.get(&cache_key) else { idx = coalesce_end; continue; };
7290                            // Per-character font fallback: split text by font coverage
7291                            shape_with_font_fallback(
7292                                &merged_text, script, language, direction,
7293                                style, *source, *source_node_id,
7294                                font_chain, fc_cache, loaded_fonts,
7295                            )
7296                        }
7297                    };
7298
7299                    let shaped_clusters = shaped_clusters_result?;
7300
7301                    // Restore original per-span styles to each cluster based on byte position.
7302                    // Each ShapedCluster's source_cluster_id.start_byte_in_run is the byte
7303                    // offset within the merged text — we use byte_ranges to find which
7304                    // original run it belongs to and reassign its style, source info, etc.
7305                    for cluster in shaped_clusters {
7306                        let byte_pos = cluster.source_cluster_id.start_byte_in_run as usize;
7307                        // Find the original run this cluster's first byte falls into
7308                        let orig = byte_ranges.iter().find(|(start, end, ..)| {
7309                            byte_pos >= *start && byte_pos < *end
7310                        });
7311                        let mut cluster = cluster;
7312                        if let Some((range_start, _, orig_style, orig_source, orig_nid, orig_mpo, orig_run_offset)) = orig {
7313                            // Reassign rendering-affecting style (color, background, etc.)
7314                            cluster.style = orig_style.clone();
7315                            cluster.source_content_index = *orig_source;
7316                            cluster.source_node_id = *orig_nid;
7317                            // Fix the byte offset to be relative to the original logical run:
7318                            // (position within the merged text - this run's start in the merge)
7319                            // + this visual run's offset within its logical run (bidi split).
7320                            cluster.source_cluster_id.source_run = orig_source.run_index;
7321                            cluster.source_cluster_id.start_byte_in_run = (byte_pos - range_start + *orig_run_offset) as u32;
7322                            // Update glyph styles
7323                            for glyph in &mut cluster.glyphs {
7324                                glyph.style = orig_style.clone();
7325                            }
7326                            if let Some(is_outside) = orig_mpo {
7327                                cluster.marker_position_outside = Some(*is_outside);
7328                            }
7329                        }
7330                        shaped.push(ShapedItem::Cluster(cluster));
7331                    }
7332
7333                    idx = coalesce_end;
7334                    continue;
7335                }
7336
7337                // ── SINGLE ITEM PATH (no coalescing) ──
7338                _total_runs += 1;
7339                _shape_calls += 1;
7340                let direction = if item.bidi_level.is_rtl() {
7341                    BidiDirection::Rtl
7342                } else {
7343                    BidiDirection::Ltr
7344                };
7345
7346                let language = script_to_language(item.script, &item.text);
7347
7348                // Shape text using either FontRef directly or fontconfig-resolved font
7349                let shaped_clusters_result: Result<Vec<ShapedCluster>, LayoutError> = match &style.font_stack {
7350                    FontStack::Ref(font_ref) => {
7351                        unsafe { crate::az_mark(0x60820_u32, 0x0000_0001_u32); } // [g121] Ref arm
7352                        // For FontRef, use the font directly without fontconfig
7353                        if let Some(msgs) = debug_messages {
7354                            msgs.push(LayoutDebugMessage::info(format!(
7355                                "[TextLayout] Using direct FontRef for text: '{}'",
7356                                item.text.chars().take(30).collect::<String>()
7357                            )));
7358                        }
7359                        shape_text_correctly(
7360                            &item.text,
7361                            item.script,
7362                            language,
7363                            direction,
7364                            font_ref,
7365                            style,
7366                            *source,
7367                            *source_node_id,
7368                        )
7369                    }
7370                    FontStack::Stack(selectors) => {
7371                        unsafe { crate::az_mark(0x60820_u32, 0x0000_0002_u32); } // [g121] Stack arm
7372                        // Build FontChainKey and resolve through fontconfig
7373                        let cache_key = FontChainKey::from_selectors(selectors);
7374                        unsafe { crate::az_mark(0x60824_u32, font_chain_cache.len() as u32); } // [g121] chain map len
7375
7376                        // Look up the pre-resolved font chain. (2026-06-10: the g122
7377                        // by_find/by_only fallback chain is GONE — the historic miss was a
7378                        // KEY-CONSTRUCTION divergence (duplicated families on the query side,
7379                        // deduped on the store side), fixed by routing every key build through
7380                        // FontChainKey::from_selectors. Verified lifted: lookup path = get.)
7381                        let Some(font_chain) = font_chain_cache.get(&cache_key) else {
7382                            if let Some(msgs) = debug_messages {
7383                                msgs.push(LayoutDebugMessage::warning(format!(
7384                                    "[TextLayout] Font chain not pre-resolved for {:?} - text will \
7385                                     not be rendered",
7386                                    cache_key.font_families
7387                                )));
7388                            }
7389                            idx += 1;
7390                            continue;
7391                        };
7392
7393                        // Per-character font fallback: split text by font coverage
7394                        shape_with_font_fallback(
7395                            &item.text, item.script, language, direction,
7396                            style, *source, *source_node_id,
7397                            font_chain, fc_cache, loaded_fonts,
7398                        )
7399                    }
7400                };
7401
7402                let mut shaped_clusters = shaped_clusters_result?;
7403
7404                // Re-base cluster byte offsets to the logical run. Shaping produced
7405                // `start_byte_in_run` relative to this visual run's `text`; when bidi
7406                // split the logical run into several visual runs, add the visual run's
7407                // offset so every cluster ID is unique + matches caret byte positions.
7408                let run_byte_offset = item.run_byte_offset as u32;
7409                if run_byte_offset != 0 {
7410                    for cluster in &mut shaped_clusters {
7411                        cluster.source_cluster_id.start_byte_in_run = cluster
7412                            .source_cluster_id
7413                            .start_byte_in_run
7414                            .saturating_add(run_byte_offset);
7415                    }
7416                }
7417
7418                // Set marker flag on all clusters if this is a marker
7419                if let Some(is_outside) = marker_position_outside {
7420                    for cluster in &mut shaped_clusters {
7421                        cluster.marker_position_outside = Some(*is_outside);
7422                    }
7423                }
7424
7425                shaped.extend(shaped_clusters.into_iter().map(ShapedItem::Cluster));
7426            }
7427            // +spec:display-property:df076b - tab-size rendering and inline-level line breaking
7428            // "If the tab size is zero, preserved tabs are not rendered."
7429            // "Otherwise, each preserved tab is rendered as a horizontal shift that lines up
7430            //  the start edge of the next glyph with the next tab stop."
7431            // "Tab stops occur at points that are multiples of the tab size from the starting
7432            //  content edge of the preserved tab's nearest block container ancestor."
7433            LogicalItem::Tab { source, style } => {
7434                if style.tab_size == 0.0 {
7435                    // Tab size zero: tab is not rendered (zero width)
7436                    shaped.push(ShapedItem::Tab {
7437                        source: *source,
7438                        bounds: Rect {
7439                            x: 0.0,
7440                            y: 0.0,
7441                            width: 0.0,
7442                            height: 0.0,
7443                        },
7444                    });
7445                } else {
7446                    // TODO: use actual font's space_width via ParsedFontTrait::get_space_width()
7447                    // once we thread font resolution into the shaping phase for tab stops.
7448                    // For now, approximate space advance as 0.5 * font_size (typical for Latin fonts).
7449                    let space_advance_approx = style.font_size_px * SPACE_WIDTH_RATIO;
7450                    // +spec:text-alignment-spacing:5a5efd - tab-size includes letter-spacing and word-spacing
7451                    let ls = style.letter_spacing.resolve_px(style.font_size_px);
7452                    let ws = style.word_spacing.resolve_px(style.font_size_px);
7453                    // Tab stop interval: tab_size * (space advance + letter-spacing + word-spacing)
7454                    let tab_interval = style.tab_size * (space_advance_approx + ls + ws);
7455                    // Calculate current advance to find next tab stop
7456                    let current_advance: f32 = shaped.iter().map(|item| {
7457                        match item {
7458                            ShapedItem::Cluster(c) => c.advance,
7459                            ShapedItem::Tab { bounds, .. } => bounds.width,
7460                            ShapedItem::Object { bounds, .. } => bounds.width,
7461                            _ => 0.0,
7462                        }
7463                    }).sum();
7464                    // Next tab stop = next multiple of tab_interval from content edge
7465                    let next_tab_stop = ((current_advance / tab_interval).floor() + 1.0) * tab_interval;
7466                    let mut tab_width = next_tab_stop - current_advance;
7467                    // "If this distance is less than 0.5ch, then the subsequent tab stop is used instead."
7468                    let half_ch = space_advance_approx * 0.5;
7469                    if tab_width < half_ch {
7470                        tab_width += tab_interval;
7471                    }
7472                    shaped.push(ShapedItem::Tab {
7473                        source: *source,
7474                        bounds: Rect {
7475                            x: 0.0,
7476                            y: 0.0,
7477                            width: tab_width,
7478                            height: 0.0,
7479                        },
7480                    });
7481                }
7482            }
7483            LogicalItem::Ruby {
7484                source,
7485                base_text,
7486                ruby_text,
7487                style,
7488            } => {
7489                // CSS Ruby Layout (§3): the annotation (ruby-text) is laid out at its used
7490                // `font-size` — the UA default is `RUBY_ANNOTATION_FONT_SCALE` of the base —
7491                // and centered over the base, with the ruby box reserving the WIDER of the
7492                // two inline-sizes and stacking the annotation line above the base line.
7493                //
7494                // Both the base and the annotation are shaped to obtain their REAL inline
7495                // advances (no `chars * font_size * 0.6` fudge). The annotation is shaped at
7496                // the scaled style so its width reflects the smaller glyphs.
7497                let base_font_size = style.font_size_px;
7498                let annotation_font_size = base_font_size * RUBY_ANNOTATION_FONT_SCALE;
7499
7500                let mut annotation_props = (**style).clone();
7501                annotation_props.font_size_px = annotation_font_size;
7502                let annotation_style = Arc::new(annotation_props);
7503
7504                // Fallback estimate (only when shaping fails / no font chain): 1em per char
7505                // is a closer CJK approximation than the old 0.6 ratio.
7506                let base_width = measure_run_advance(
7507                    base_text, style, item.script, *source, font_chain_cache, fc_cache,
7508                    loaded_fonts,
7509                )
7510                .unwrap_or_else(|| base_text.chars().count() as f32 * base_font_size);
7511                let annotation_width = measure_run_advance(
7512                    ruby_text, &annotation_style, item.script, *source, font_chain_cache,
7513                    fc_cache, loaded_fonts,
7514                )
7515                .unwrap_or_else(|| ruby_text.chars().count() as f32 * annotation_font_size);
7516
7517                let base_line_height =
7518                    style.line_height.resolve(base_font_size, 0.0, 0.0, 0.0, 0);
7519                let annotation_line_height = annotation_style.line_height.resolve(
7520                    annotation_font_size, 0.0, 0.0, 0.0, 0,
7521                );
7522                // The ruby box reserves the wider inline-size, and stacks the annotation
7523                // line (at its smaller font-size) above the base line.
7524                let (reserved_width, reserved_height) = ruby_reserved_box(
7525                    base_width,
7526                    annotation_width,
7527                    base_line_height,
7528                    annotation_line_height,
7529                );
7530
7531                // TODO2: the annotation glyphs are now correctly sized + reserve vertical
7532                // space above the base, but are not yet emitted as a separately positioned
7533                // (centered) run — `ShapedItem::Object` carries only the base `StyledRun`.
7534                // Rendering the centered annotation needs a ruby-aware `ShapedItem` variant
7535                // (rendering-structural change); deferred to keep this change layout-safe.
7536                shaped.push(ShapedItem::Object {
7537                    source: *source,
7538                    bounds: Rect {
7539                        x: 0.0,
7540                        y: 0.0,
7541                        width: reserved_width,
7542                        height: reserved_height,
7543                    },
7544                    baseline_offset: 0.0,
7545                    content: InlineContent::Text(StyledRun {
7546                        text: base_text.clone(),
7547                        style: style.clone(),
7548                        logical_start_byte: 0,
7549                        source_node_id: None,
7550                    }),
7551                });
7552            }
7553            LogicalItem::CombinedText {
7554                style,
7555                source,
7556                text,
7557            } => {
7558                let language = script_to_language(item.script, &item.text);
7559
7560                // +spec:width-calculation:657f75 - convert full-width chars to non-full-width before compression
7561                // +spec:width-calculation:d0a295 - full-width digit conversion example (e.g. "23" stays narrow)
7562                // When combined text has more than one typographic character unit,
7563                // full-width characters (U+FF01..U+FF5E) are converted to their
7564                // ASCII equivalents (U+0021..U+007E) before compression.
7565                let text = if text.chars().count() > 1 {
7566                    let converted: String = text.chars().map(|c| {
7567                        let cp = c as u32;
7568                        if (0xFF01..=0xFF5E).contains(&cp) {
7569                            // Reverse of text-transform: full-width
7570                            char::from_u32(cp - 0xFF01 + 0x0021).unwrap_or(c)
7571                        } else {
7572                            c
7573                        }
7574                    }).collect();
7575                    converted
7576                } else {
7577                    text.clone()
7578                };
7579
7580                // +spec:width-calculation:1ed84d - OpenType compression (half-width/third-width substitution)
7581                // is delegated to the font shaping layer via shape_text()
7582
7583                // Shape CombinedText using either FontRef directly or fontconfig-resolved font
7584                let glyphs: Vec<Glyph> = match &style.font_stack {
7585                    FontStack::Ref(font_ref) => {
7586                        // For FontRef, use the font directly without fontconfig
7587                        if let Some(msgs) = debug_messages {
7588                            msgs.push(LayoutDebugMessage::info(format!(
7589                                "[TextLayout] Using direct FontRef for CombinedText: '{}'",
7590                                text.chars().take(30).collect::<String>()
7591                            )));
7592                        }
7593                        font_ref.shape_text(
7594                            &text,
7595                            item.script,
7596                            language,
7597                            BidiDirection::Ltr,
7598                            style.as_ref(),
7599                        )?
7600                    }
7601                    FontStack::Stack(selectors) => {
7602                        // Build FontChainKey and resolve through fontconfig
7603                        let cache_key = FontChainKey::from_selectors(selectors);
7604
7605                        let Some(font_chain) = font_chain_cache.get(&cache_key) else {
7606                            if let Some(msgs) = debug_messages {
7607                                msgs.push(LayoutDebugMessage::warning(format!(
7608                                    "[TextLayout] Font chain not pre-resolved for CombinedText {:?}",
7609                                    cache_key.font_families
7610                                )));
7611                            }
7612                            idx += 1;
7613                            continue;
7614                        };
7615
7616                        // Per-character font fallback for CombinedText
7617                        let segments = split_text_by_font_coverage(&text, font_chain, fc_cache, loaded_fonts);
7618                        let mut all_glyphs = Vec::new();
7619                        for (seg_start, seg_end, font_id) in &segments {
7620                            let Some(font) = loaded_fonts.get(font_id) else { continue; };
7621                            let segment_text = &text[*seg_start..*seg_end];
7622                            let mut seg_glyphs = font.shape_text(
7623                                segment_text,
7624                                item.script,
7625                                language,
7626                                BidiDirection::Ltr,
7627                                style.as_ref(),
7628                            )?;
7629                            // Fix byte offsets for glyphs
7630                            if *seg_start > 0 {
7631                                for g in &mut seg_glyphs {
7632                                    g.logical_byte_index += *seg_start;
7633                                    g.cluster += *seg_start as u32;
7634                                }
7635                            }
7636                            all_glyphs.extend(seg_glyphs);
7637                        }
7638                        if all_glyphs.is_empty() {
7639                            idx += 1;
7640                            continue;
7641                        }
7642                        all_glyphs
7643                    }
7644                };
7645
7646                let shaped_glyphs: ShapedGlyphVec = glyphs
7647                    .into_iter()
7648                    .map(|g| ShapedGlyph {
7649                        kind: GlyphKind::Character,
7650                        glyph_id: g.glyph_id,
7651                        script: g.script,
7652                        font_hash: g.font_hash,
7653                        font_metrics: g.font_metrics,
7654                        style: g.style,
7655                        cluster_offset: 0,
7656                        advance: g.advance,
7657                        kerning: g.kerning,
7658                        offset: g.offset,
7659                        vertical_advance: g.vertical_advance,
7660                        vertical_offset: g.vertical_bearing,
7661                    })
7662                    .collect();
7663
7664                // +spec:block-formatting-context:dc4549 - text-combine-upright compression: UA may scale composition to match 水 advance height
7665                let total_width: f32 = shaped_glyphs.iter().map(|g| g.advance + g.kerning).sum();
7666                // +spec:inline-formatting-context:8c5969 - text-combine-upright baseline centering
7667                // The composition forms a 1em square. Per spec, its baseline must be
7668                // chosen so the square is centered between the text-over and text-under
7669                // baselines of the parent inline box. We approximate by using font_size
7670                // as the square height and centering it (baseline_offset = em_size / 2).
7671                let em_size = shaped_glyphs.first()
7672                    .map_or(style.font_size_px, |g| g.style.font_size_px);
7673                let bounds = Rect {
7674                    x: 0.0,
7675                    y: 0.0,
7676                    width: total_width,
7677                    height: em_size,
7678                };
7679
7680                shaped.push(ShapedItem::CombinedBlock {
7681                    source: *source,
7682                    glyphs: shaped_glyphs,
7683                    bounds,
7684                    baseline_offset: em_size / 2.0,
7685                });
7686            }
7687            LogicalItem::Object {
7688                content, source, ..
7689            } => {
7690                let (bounds, baseline) = measure_inline_object(content)?;
7691                shaped.push(ShapedItem::Object {
7692                    source: *source,
7693                    bounds,
7694                    baseline_offset: baseline,
7695                    content: content.clone(),
7696                });
7697            }
7698            LogicalItem::Break { source, break_info } => {
7699                shaped.push(ShapedItem::Break {
7700                    source: *source,
7701                    break_info: *break_info,
7702                });
7703            }
7704        }
7705        idx += 1;
7706    }
7707
7708    Ok(shaped)
7709}
7710
7711/// Returns true if `c` is a hanging punctuation stop or comma per CSS Text 3 §8.2.1.
7712// +spec:hanging-punctuation - full stop/comma character list per CSS Text 3 §8.2.1
7713const fn is_hanging_punctuation_char(c: char) -> bool {
7714    matches!(c,
7715        ','      | // U+002C COMMA
7716        '.'      | // U+002E FULL STOP
7717        '\u{060C}' | // ARABIC COMMA
7718        '\u{06D4}' | // ARABIC FULL STOP
7719        '\u{3001}' | // IDEOGRAPHIC COMMA
7720        '\u{3002}' | // IDEOGRAPHIC FULL STOP
7721        '\u{FF0C}' | // FULLWIDTH COMMA
7722        '\u{FF0E}' | // FULLWIDTH FULL STOP
7723        '\u{FE50}' | // SMALL COMMA
7724        '\u{FE51}' | // SMALL IDEOGRAPHIC COMMA
7725        '\u{FE52}' | // SMALL FULL STOP
7726        '\u{FF61}' | // HALFWIDTH IDEOGRAPHIC FULL STOP
7727        '\u{FF64}'   // HALFWIDTH IDEOGRAPHIC COMMA
7728    )
7729}
7730
7731/// Helper to check if a cluster contains only hanging punctuation.
7732// +spec:box-model:8bbcd1 - non-zero inline-axis borders/padding between hangable glyph and line edge prevent hanging
7733/// +spec:inline-formatting-context:135be2 - hanging punctuation placed outside the line box
7734/// +spec:intrinsic-sizing:407d8b - hanging glyphs not counted in intrinsic size computation
7735fn is_hanging_punctuation(item: &ShapedItem) -> bool {
7736    if let ShapedItem::Cluster(c) = item {
7737        if c.glyphs.len() == 1 {
7738            c.text.chars().next().is_some_and(is_hanging_punctuation_char)
7739        } else {
7740            false
7741        }
7742    } else {
7743        false
7744    }
7745}
7746
7747#[allow(clippy::cast_possible_truncation)] // bounded pixel/coord/colour/glyph cast
7748#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
7749fn shape_text_correctly<T: ParsedFontTrait>(
7750    text: &str,
7751    script: Script,
7752    language: Language,
7753    direction: BidiDirection,
7754    font: &T, // Changed from &Arc<T>
7755    style: &Arc<StyleProperties>,
7756    source_index: ContentIndex,
7757    source_node_id: Option<NodeId>,
7758) -> Result<Vec<ShapedCluster>, LayoutError> {
7759    unsafe { crate::az_mark(0x60864_u32, 0xC0DE_0864_u32); } // [g123] shape_text_correctly ENTERED
7760    let glyphs = font.shape_text(text, script, language, direction, style.as_ref())?;
7761    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
7762
7763    if glyphs.is_empty() {
7764        return Ok(Vec::new());
7765    }
7766
7767    let mut clusters = Vec::new();
7768
7769    // Group glyphs by cluster ID from the shaper.
7770    let mut current_cluster_glyphs = Vec::new();
7771    let mut cluster_id = glyphs[0].cluster;
7772    let mut cluster_start_byte_in_text = glyphs[0].logical_byte_index;
7773
7774    for glyph in glyphs {
7775        if glyph.cluster != cluster_id {
7776            // Finalize previous cluster
7777            let advance = current_cluster_glyphs
7778                .iter()
7779                .map(|g: &Glyph| g.advance)
7780                .sum();
7781
7782            // Safely extract cluster text - handle cases where byte indices may be out of order
7783            // (can happen with RTL text or complex GSUB reordering)
7784            let (start, end) = if cluster_start_byte_in_text <= glyph.logical_byte_index {
7785                (cluster_start_byte_in_text, glyph.logical_byte_index)
7786            } else {
7787                (glyph.logical_byte_index, cluster_start_byte_in_text)
7788            };
7789            let cluster_text = text.get(start..end).unwrap_or("");
7790
7791            clusters.push(ShapedCluster {
7792                text: cluster_text.to_string(), // Store original text for hyphenation
7793                source_cluster_id: GraphemeClusterId {
7794                    source_run: source_index.run_index,
7795                    start_byte_in_run: cluster_id,
7796                },
7797                source_content_index: source_index,
7798                source_node_id,
7799                glyphs: current_cluster_glyphs
7800                    .iter()
7801                    .map(|g| {
7802                        // Calculate cluster_offset safely
7803                        let cluster_offset = if g.logical_byte_index >= cluster_start_byte_in_text {
7804                            (g.logical_byte_index - cluster_start_byte_in_text) as u32
7805                        } else {
7806                            0
7807                        };
7808                        ShapedGlyph {
7809                            kind: if g.glyph_id == 0 {
7810                                GlyphKind::NotDef
7811                            } else {
7812                                GlyphKind::Character
7813                            },
7814                            glyph_id: g.glyph_id,
7815                            script: g.script,
7816                            font_hash: g.font_hash,
7817                            font_metrics: g.font_metrics,
7818                            style: g.style.clone(),
7819                            cluster_offset,
7820                            advance: g.advance,
7821                            kerning: g.kerning,
7822                            vertical_advance: g.vertical_advance,
7823                            vertical_offset: g.vertical_bearing,
7824                            offset: g.offset,
7825                        }
7826                    })
7827                    .collect(),
7828                advance,
7829                direction,
7830                style: style.clone(),
7831                marker_position_outside: None,
7832                is_first_fragment: true,
7833                is_last_fragment: true,
7834            });
7835            current_cluster_glyphs.clear();
7836            cluster_id = glyph.cluster;
7837            cluster_start_byte_in_text = glyph.logical_byte_index;
7838        }
7839        current_cluster_glyphs.push(glyph);
7840    }
7841
7842    // Finalize the last cluster
7843    if !current_cluster_glyphs.is_empty() {
7844        let advance = current_cluster_glyphs
7845            .iter()
7846            .map(|g: &Glyph| g.advance)
7847            .sum();
7848        let cluster_text = text.get(cluster_start_byte_in_text..).unwrap_or("");
7849        clusters.push(ShapedCluster {
7850            text: cluster_text.to_string(), // Store original text
7851            source_cluster_id: GraphemeClusterId {
7852                source_run: source_index.run_index,
7853                start_byte_in_run: cluster_id,
7854            },
7855            source_content_index: source_index,
7856            source_node_id,
7857            glyphs: current_cluster_glyphs
7858                .iter()
7859                .map(|g| {
7860                    // Calculate cluster_offset safely
7861                    let cluster_offset = if g.logical_byte_index >= cluster_start_byte_in_text {
7862                        (g.logical_byte_index - cluster_start_byte_in_text) as u32
7863                    } else {
7864                        0
7865                    };
7866                    ShapedGlyph {
7867                        kind: if g.glyph_id == 0 {
7868                            GlyphKind::NotDef
7869                        } else {
7870                            GlyphKind::Character
7871                        },
7872                        glyph_id: g.glyph_id,
7873                        font_hash: g.font_hash,
7874                        font_metrics: g.font_metrics,
7875                        style: g.style.clone(),
7876                        script: g.script,
7877                        vertical_advance: g.vertical_advance,
7878                        vertical_offset: g.vertical_bearing,
7879                        cluster_offset,
7880                        advance: g.advance,
7881                        kerning: g.kerning,
7882                        offset: g.offset,
7883                    }
7884                })
7885                .collect(),
7886            advance,
7887            direction,
7888            style: style.clone(),
7889            marker_position_outside: None,
7890            is_first_fragment: true,
7891            is_last_fragment: true,
7892        });
7893    }
7894
7895    Ok(clusters)
7896}
7897
7898/// Measures a non-text object, returning its bounds and baseline offset.
7899fn measure_inline_object(item: &InlineContent) -> Result<(Rect, f32), LayoutError> {
7900    match item {
7901        InlineContent::Image(img) => {
7902            let size = img.display_size.unwrap_or(img.intrinsic_size);
7903            Ok((
7904                Rect {
7905                    x: 0.0,
7906                    y: 0.0,
7907                    width: size.width,
7908                    height: size.height,
7909                },
7910                img.baseline_offset,
7911            ))
7912        }
7913        InlineContent::Shape(shape) => Ok({
7914            let size = shape.shape_def.get_size();
7915            (
7916                Rect {
7917                    x: 0.0,
7918                    y: 0.0,
7919                    width: size.width,
7920                    height: size.height,
7921                },
7922                shape.baseline_offset,
7923            )
7924        }),
7925        InlineContent::Space(space) => Ok((
7926            Rect {
7927                x: 0.0,
7928                y: 0.0,
7929                width: space.width,
7930                height: 0.0,
7931            },
7932            0.0,
7933        )),
7934        InlineContent::Marker { .. } => {
7935            // Markers are treated as text content, not measurable objects
7936            Err(LayoutError::InvalidText(
7937                "Marker is text content, not a measurable object".into(),
7938            ))
7939        }
7940        _ => Err(LayoutError::InvalidText("Not a measurable object".into())),
7941    }
7942}
7943
7944// --- Stage 4 Implementation: Vertical Text ---
7945
7946/// Applies orientation and vertical metrics to glyphs if the writing mode is vertical.
7947// +spec:block-formatting-context:227171 - vertical glyph orientation with fallback vertical metrics
7948// +spec:block-formatting-context:df20a5 - mixed vertical orientation dispatch (TextOrientation::Mixed)
7949fn apply_text_orientation(
7950    items: Arc<Vec<ShapedItem>>,
7951    constraints: &UnifiedConstraints,
7952) -> Arc<Vec<ShapedItem>> {
7953    if !constraints.is_vertical() {
7954        return items;
7955    }
7956
7957    let mut oriented_items = Vec::with_capacity(items.len());
7958    let writing_mode = constraints.writing_mode.unwrap_or_default();
7959
7960    for item in items.iter() {
7961        match item {
7962            ShapedItem::Cluster(cluster) => {
7963                let mut new_cluster = cluster.clone();
7964                let mut total_vertical_advance = 0.0;
7965
7966                for glyph in &mut new_cluster.glyphs {
7967                    // Use the vertical metrics already computed during shaping
7968                    // If they're zero, use fallback values
7969                    if glyph.vertical_advance > 0.0 {
7970                        total_vertical_advance += glyph.vertical_advance;
7971                    } else {
7972                        // Fallback: use line height for vertical advance
7973                        let fallback_advance = cluster.style.line_height.resolve_with_metrics(cluster.style.font_size_px, &glyph.font_metrics);
7974                        glyph.vertical_advance = fallback_advance;
7975                        // Center the glyph horizontally as a fallback
7976                        glyph.vertical_offset = Point {
7977                            x: -glyph.advance / 2.0,
7978                            y: 0.0,
7979                        };
7980                        total_vertical_advance += fallback_advance;
7981                    }
7982                }
7983                // The cluster's `advance` now represents vertical advance.
7984                new_cluster.advance = total_vertical_advance;
7985                oriented_items.push(ShapedItem::Cluster(new_cluster));
7986            }
7987            // Non-text objects also need their advance axis swapped.
7988            ShapedItem::Object {
7989                source,
7990                bounds,
7991                baseline_offset,
7992                content,
7993            } => {
7994                let mut new_bounds = *bounds;
7995                std::mem::swap(&mut new_bounds.width, &mut new_bounds.height);
7996                oriented_items.push(ShapedItem::Object {
7997                    source: *source,
7998                    bounds: new_bounds,
7999                    baseline_offset: *baseline_offset,
8000                    content: content.clone(),
8001                });
8002            }
8003            _ => oriented_items.push(item.clone()),
8004        }
8005    }
8006
8007    Arc::new(oriented_items)
8008}
8009
8010// --- Stage 5 & 6 Implementation: Combined Layout Pass ---
8011// This section replaces the previous simple line breaking and positioning logic.
8012
8013/// Extracts the per-item vertical-align from a `ShapedItem`.
8014///
8015/// For `Object` items (inline-blocks, images), this returns the alignment stored
8016/// in the original `InlineContent`. For text clusters and other items, returns `None`
8017/// to indicate the global `constraints.vertical_align` should be used.
8018const fn get_item_vertical_align(item: &ShapedItem) -> Option<VerticalAlign> {
8019    match item {
8020        ShapedItem::Object { content, .. } => match content {
8021            InlineContent::Image(img) => Some(img.alignment),
8022            InlineContent::Shape(shape) => Some(shape.alignment),
8023            _ => None,
8024        },
8025        _ => None,
8026    }
8027}
8028
8029/// Approximate version of `get_item_vertical_metrics` for use without constraints (e.g. `bounds()`).
8030/// Uses 80/20 ascent/descent ratio as fallback for empty-glyph strut case.
8031#[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
8032#[must_use] pub fn get_item_vertical_metrics_approx(item: &ShapedItem) -> (f32, f32) {
8033    // For non-empty clusters, delegate to the font-metrics-based calculation
8034    if let ShapedItem::Cluster(c) = item {
8035        if !c.glyphs.is_empty() {
8036            // Reuse the glyph-based calculation (same as get_item_vertical_metrics)
8037            let (asc, desc) = c.glyphs
8038                .iter()
8039                .fold((0.0f32, 0.0f32), |(max_asc, max_desc), glyph| {
8040                    let metrics = &glyph.font_metrics;
8041                    if metrics.units_per_em == 0 {
8042                        return (max_asc, max_desc);
8043                    }
8044                    let scale = glyph.style.font_size_px / f32::from(metrics.units_per_em);
8045                    let font_ascent = metrics.ascent * scale;
8046                    let font_descent = (-metrics.descent * scale).max(0.0);
8047                    let ad = font_ascent + font_descent;
8048                    let resolved_lh = c.style.line_height.resolve_with_metrics(glyph.style.font_size_px, &glyph.font_metrics);
8049                    let half_leading = (resolved_lh - ad) / 2.0;
8050                    (max_asc.max(font_ascent + half_leading), max_desc.max(font_descent + half_leading))
8051                });
8052            return (asc, desc);
8053        }
8054    }
8055    // Fallback for empty glyphs or non-cluster items
8056    match item {
8057        ShapedItem::Cluster(c) => {
8058            let lh = c.style.line_height.resolve(c.style.font_size_px, 0.0, 0.0, 0.0, 0);
8059            (lh * FALLBACK_ASCENT_RATIO, lh * FALLBACK_DESCENT_RATIO)
8060        }
8061        ShapedItem::CombinedBlock { bounds, .. } => {
8062            (bounds.height * FALLBACK_ASCENT_RATIO, bounds.height * FALLBACK_DESCENT_RATIO)
8063        }
8064        ShapedItem::Object { bounds, .. } => (bounds.height, 0.0),
8065        ShapedItem::Tab { bounds, .. } => {
8066            (bounds.height * FALLBACK_ASCENT_RATIO, bounds.height * FALLBACK_DESCENT_RATIO)
8067        }
8068        ShapedItem::Break { .. } => (0.0, 0.0),
8069    }
8070}
8071
8072/// Gets the ascent (distance from baseline to top) and descent (distance from baseline to bottom)
8073/// for a single item, incorporating half-leading from line-height.
8074///
8075// +spec:box-model:37aeb2 - inline box margins/borders/padding do not affect line box height (leading model)
8076// +spec:display-property:184f0d - Inline box baseline derives from first available font metrics
8077// +spec:display-property:238bf5 - Inline box layout bounds from own text metrics, not child boxes
8078// +spec:display-property:29b194 - baseline determination for inline boxes (CSS Box Alignment 3 §9.1)
8079// +spec:display-property:2987db - per-glyph font metrics impact inline box layout bounds (line-height: normal caveat not yet distinguished)
8080/// +spec:display-property:fd42a9 - line-height affects line box contribution, not inline box size
8081// +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
8082// +spec:font-metrics:773029 - ascent/descent font metrics used for baseline calculations (visual centering depends on these)
8083// +spec:font-metrics:f42870 - half-leading model: leading = line-height - (ascent + descent), distributed equally above/below
8084// +spec:writing-modes:531c2e - UAs should use vertical baseline tables in vertical typographic modes
8085#[must_use] pub fn get_item_vertical_metrics(item: &ShapedItem, constraints: &UnifiedConstraints) -> (f32, f32) {
8086    // (ascent, descent)
8087    match item {
8088        ShapedItem::Cluster(c) => {
8089            if c.glyphs.is_empty() {
8090                // +spec:display-property:626c86 - strut for inline box with no glyphs uses first available font metrics
8091                // +spec:line-height:0078fa - strut: zero-width inline box with element's font/line-height
8092                // §10.8.1 strut: if inline box contains no glyphs, it is considered to
8093                // contain a strut with A and D of the element's first available font.
8094                // Half-leading: L = line-height - (A + D), A' = A + L/2, D' = D + L/2
8095                let ad = constraints.strut_ascent + constraints.strut_descent;
8096                let resolved_lh = c.style.line_height.resolve(c.style.font_size_px, 0.0, 0.0, 0.0, 0);
8097                let half_leading = (resolved_lh - ad) / 2.0;
8098                return (constraints.strut_ascent + half_leading, constraints.strut_descent + half_leading);
8099            }
8100            // +spec:box-model:0b3e1f - inline non-replaced box height uses only line-height, not vertical padding/border/margin
8101            // +spec:display-property:80b900 - fallback glyphs affect line box size via per-glyph metrics
8102            // +spec:display-property:d52f26 - layout bounds enclose all glyphs from highest A to deepest D
8103            // +spec:font-metrics:387751 - content area uses max ascenders/descenders across all fonts
8104            // +spec:font-metrics:790fd2 - half-leading: L = line-height - (A+D), A' = A + L/2, D' = D + L/2
8105            // +spec:line-height:1ae6f5 - line-height on non-replaced inline: half-leading model
8106            // +spec:line-height:0078fa - half-leading: L = line-height - (A+D), distributed equally above/below
8107            // +spec:line-height:32b3da - half-leading: L = line-height - AD, A' = A + L/2, D' = D + L/2
8108            // §10.8.1: for each glyph determine A, D from font metrics,
8109            // then L = line-height - (A + D), and adjust: A' = A + L/2, D' = D + L/2.
8110            // Note: L may be negative.
8111            // +spec:height-calculation:eb98b5 - multi-font normal line-height uses max across glyph metrics
8112            c.glyphs
8113                .iter()
8114                .fold((0.0f32, 0.0f32), |(max_asc, max_desc), glyph| {
8115                    let metrics = &glyph.font_metrics;
8116                    if metrics.units_per_em == 0 {
8117                        return (max_asc, max_desc);
8118                    }
8119                    let scale = glyph.style.font_size_px / f32::from(metrics.units_per_em);
8120                    let a = metrics.ascent * scale;
8121                    // Descent in OpenType is typically negative, so we negate it to get a positive
8122                    // distance.
8123                    let d = (-metrics.descent * scale).max(0.0);
8124                    let ad = a + d;
8125                    let resolved_lh = glyph.style.line_height.resolve_with_metrics(glyph.style.font_size_px, &glyph.font_metrics);
8126                    let leading = resolved_lh - ad;
8127                    let half_leading = leading / 2.0;
8128                    let item_asc = a + half_leading;
8129                    let item_desc = d + half_leading;
8130                    (max_asc.max(item_asc), max_desc.max(item_desc))
8131                })
8132        }
8133        ShapedItem::Object {
8134            bounds,
8135            baseline_offset,
8136            ..
8137        } => {
8138            // Per analysis, `baseline_offset` is the distance from the bottom.
8139            // bounds.height already includes margins (set from margin_box_height in fc.rs)
8140            let ascent = bounds.height - *baseline_offset;
8141            let descent = *baseline_offset;
8142            (ascent.max(0.0), descent.max(0.0))
8143        }
8144        ShapedItem::CombinedBlock {
8145            bounds,
8146            baseline_offset,
8147            ..
8148        } => {
8149            // CORRECTED: Treat baseline_offset consistently as distance from the bottom (descent).
8150            let ascent = bounds.height - *baseline_offset;
8151            let descent = *baseline_offset;
8152            (ascent.max(0.0), descent.max(0.0))
8153        }
8154        _ => (0.0, 0.0), // Breaks and other non-visible items don't affect line height.
8155    }
8156}
8157
8158// +spec:block-formatting-context:861155 - vertical-align affects vertical positioning inside line box for inline-level elements
8159/// Calculates the maximum ascent and descent for an entire line of items.
8160/// This determines the "line box" used for vertical alignment.
8161/// // +spec:display-contents:66d910 - line box height fitted to contents, controlled by line-height
8162// +spec:inline-formatting-context:c3fc54 - line box tall enough for all boxes, vertical-align determines alignment within line box
8163///
8164/// Per CSS 2.2 §10.8: Inline-level boxes aligned 'top' or 'bottom' must be aligned
8165/// so as to minimize the line box height. The algorithm is:
8166/// 1. First pass: compute line box height from baseline-aligned items only
8167///    (baseline, sub, super, middle, text-top, text-bottom, offset).
8168/// 2. Second pass: check if any top/bottom-aligned items are taller than the
8169///    line box from pass 1, and expand if necessary.
8170// +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)
8171fn calculate_line_metrics(
8172    items: &[ShapedItem],
8173    default_vertical_align: VerticalAlign,
8174    constraints: &UnifiedConstraints,
8175) -> (f32, f32) {
8176    // +spec:font-metrics:95152b - baseline alignment: items with different font sizes aligned by matching alphabetic baselines
8177    // Pass 1: Compute ascent/descent from baseline-aligned items only
8178    // (i.e., items that are NOT vertical-align: top or bottom).
8179    let (mut max_asc, mut max_desc) = items
8180        .iter()
8181        .fold((0.0f32, 0.0f32), |(max_asc, max_desc), item| {
8182            let effective_align = get_item_vertical_align(item)
8183                .unwrap_or(default_vertical_align);
8184            match effective_align {
8185                VerticalAlign::Top | VerticalAlign::Bottom => {
8186                    // Skip top/bottom items in first pass
8187                    (max_asc, max_desc)
8188                }
8189                _ => {
8190                    let (item_asc, item_desc) = get_item_vertical_metrics(item, constraints);
8191                    (max_asc.max(item_asc), max_desc.max(item_desc))
8192                }
8193            }
8194        });
8195
8196    let baseline_line_height = max_asc + max_desc;
8197
8198    // Pass 2: Check top/bottom aligned items. If any of them is taller
8199    // than the current line box, expand the line box to fit.
8200    for item in items {
8201        let effective_align = get_item_vertical_align(item)
8202            .unwrap_or(default_vertical_align);
8203        match effective_align {
8204            VerticalAlign::Top | VerticalAlign::Bottom => {
8205                let (item_asc, item_desc) = get_item_vertical_metrics(item, constraints);
8206                let item_height = item_asc + item_desc;
8207                if item_height > baseline_line_height {
8208                    // To minimize height, expand in the direction the item is aligned to
8209                    if effective_align == VerticalAlign::Top {
8210                        // Top-aligned item extends downward from line top
8211                        max_desc = max_desc.max(item_height - max_asc);
8212                    } else {
8213                        // Bottom-aligned item extends upward from line bottom
8214                        max_asc = max_asc.max(item_height - max_desc);
8215                    }
8216                }
8217            }
8218            _ => {} // Already handled in first pass
8219        }
8220    }
8221
8222    (max_asc, max_desc)
8223}
8224
8225/// Unicode Bidi Algorithm rule L2, applied at the glyph/cluster level for one line.
8226///
8227/// `reorder_logical_items` already placed the level RUNS of the paragraph in
8228/// visual order (rule L2 at the run level, via `unicode_bidi::visual_runs`), but
8229/// left the clusters *within* each run in LOGICAL order. To finish L2 the clusters
8230/// of every RTL (odd-level) run must be reversed so the run reads right-to-left.
8231///
8232/// We reverse each maximal contiguous run of clusters that share the same
8233/// direction, flipping only the RTL ones. Re-running full L2 over the whole line
8234/// instead would double-reverse the run order that is already correct. Grouping
8235/// by direction is exact here: under implicit bidi (no explicit embedding
8236/// controls, which azul does not inject) two runs of the same direction are never
8237/// visually adjacent — a higher even level nests inside its odd parent and a lower
8238/// level separates two same-parity runs — so a "same-direction" group is always a
8239/// single real level run. Non-cluster items (breaks/objects/tabs) act as run
8240/// boundaries. Applied per line, so a wrapped RTL run reorders correctly per line.
8241fn apply_l2_visual_reversal(line_items: &mut [ShapedItem]) {
8242    let dir_of = |it: &ShapedItem| it.as_cluster().map(|c| c.direction);
8243    let mut i = 0;
8244    while i < line_items.len() {
8245        let Some(dir) = dir_of(&line_items[i]) else {
8246            i += 1;
8247            continue;
8248        };
8249        let mut j = i + 1;
8250        while j < line_items.len() && dir_of(&line_items[j]) == Some(dir) {
8251            j += 1;
8252        }
8253        if dir == BidiDirection::Rtl {
8254            line_items[i..j].reverse();
8255        }
8256        i = j;
8257    }
8258}
8259
8260/// Performs layout for a single fragment, consuming items from a `BreakCursor`.
8261///
8262/// This function contains the core line-breaking and positioning logic, but is
8263/// designed to operate on a portion of a larger content stream and within the
8264/// constraints of a single geometric area (a fragment).
8265///
8266/// The loop terminates when either the fragment is filled (e.g., runs out of
8267/// vertical space) or the content stream managed by the `cursor` is exhausted.
8268///
8269/// # CSS Inline Layout Module Level 3 Implementation
8270///
8271/// This function implements the inline formatting context as described in:
8272/// <https://www.w3.org/TR/css-inline-3/#inline-formatting-context>
8273///
8274/// ## § 2.1 Layout of Line Boxes
8275/// "In general, the line-left edge of a line box touches the line-left edge of its
8276/// containing block and the line-right edge touches the line-right edge of its
8277/// containing block, and thus the logical width of a line box is equal to the inner
8278/// logical width of its containing block."
8279///
8280/// [ISSUE] `available_width` should be set to the containing block's inner width,
8281/// but is currently defaulting to 0.0 in `UnifiedConstraints::default()`.
8282/// This causes premature line breaking.
8283///
8284/// ## § 2.2 Layout Within Line Boxes
8285/// The layout process follows these steps:
8286/// 1. Baseline Alignment: All inline-level boxes are aligned by their baselines
8287/// 2. Content Size Contribution: Calculate layout bounds for each box
8288/// 3. Line Box Sizing: Size line box to fit aligned layout bounds
8289/// 4. Content Positioning: Position boxes within the line box
8290///
8291/// ## Missing Features:
8292/// - § 3 Baselines and Alignment Metrics: Only basic baseline alignment implemented
8293/// - § 4 Baseline Alignment: vertical-align property not fully supported
8294/// - § 5 Line Spacing: line-height implemented, but line-fit-edge missing
8295/// - § 6 Trimming Leading: text-box-trim not implemented
8296#[allow(clippy::cast_precision_loss)] // bounded pixel/coord/colour/glyph cast
8297#[allow(clippy::too_many_lines, clippy::cognitive_complexity)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
8298/// # Errors
8299///
8300/// Returns a `LayoutError` if fragment layout fails.
8301pub fn perform_fragment_layout<T: ParsedFontTrait>(
8302    cursor: &mut BreakCursor<'_>,
8303    logical_items: &[LogicalItem],
8304    fragment_constraints: &UnifiedConstraints,
8305    debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
8306    fonts: &LoadedFonts<T>,
8307) -> Result<UnifiedLayout, LayoutError> {
8308    const MAX_EMPTY_SEGMENTS: usize = 1000; // Maximum allowed consecutive empty segments
8309    if let Some(msgs) = debug_messages {
8310        msgs.push(LayoutDebugMessage::info(
8311            "\n--- Entering perform_fragment_layout ---".to_string(),
8312        ));
8313        msgs.push(LayoutDebugMessage::info(format!(
8314            "Constraints: available_width={:?}, available_height={:?}, columns={}, text_wrap={:?}",
8315            fragment_constraints.available_width,
8316            fragment_constraints.available_height,
8317            fragment_constraints.columns,
8318            fragment_constraints.text_wrap
8319        )));
8320    }
8321
8322    // For TextWrap::Balance, use Knuth-Plass algorithm for optimal line breaking
8323    // This produces more visually balanced lines at the cost of more computation
8324    if fragment_constraints.text_wrap == TextWrap::Balance {
8325        if let Some(msgs) = debug_messages {
8326            msgs.push(LayoutDebugMessage::info(
8327                "Using Knuth-Plass algorithm for text-wrap: balance".to_string(),
8328            ));
8329        }
8330
8331        // Get the shaped items from the cursor
8332        let shaped_items: Vec<ShapedItem> = cursor.drain_remaining();
8333
8334        // +spec:line-breaking:90c1bd - only auto-hyphenate when language is known and hyphenation resource available
8335        let hyphenator = if fragment_constraints.hyphenation == Hyphens::Auto {
8336            fragment_constraints
8337                .hyphenation_language
8338                .and_then(|lang| get_hyphenator(lang).ok())
8339        } else {
8340            None
8341        };
8342
8343        // Use the Knuth-Plass algorithm for optimal line breaking
8344        return Ok(crate::text3::knuth_plass::kp_layout(
8345            &shaped_items,
8346            logical_items,
8347            fragment_constraints,
8348            hyphenator.as_ref(),
8349            fonts,
8350        ));
8351    }
8352
8353    // +spec:intrinsic-sizing:57e02d - hyphenation opportunities considered in min-content sizing
8354    let hyphenator = if fragment_constraints.hyphenation == Hyphens::Auto {
8355        fragment_constraints
8356            .hyphenation_language
8357            .and_then(|lang| get_hyphenator(lang).ok())
8358    } else {
8359        None
8360    };
8361
8362    let mut positioned_items = Vec::new();
8363    let mut layout_bounds = Rect::default();
8364
8365    let num_columns = fragment_constraints.columns.max(1);
8366    let total_column_gap = fragment_constraints.column_gap * (num_columns - 1) as f32;
8367
8368    // CSS Inline Layout § 2.1: "the logical width of a line box is equal to the inner
8369    // logical width of its containing block"
8370    //
8371    // Handle the different available space modes:
8372    // - Definite(width): Use the specified width for column calculation
8373    // - MinContent: Force line breaks at word boundaries, return widest word width
8374    // - MaxContent: Use a large value to allow content to expand naturally
8375    //
8376    // IMPORTANT: For MinContent, we do NOT use 0.0 (which would break after every character).
8377    // Instead, we use a large width but track the is_min_content flag to force word-level
8378    // line breaks in the line breaker. The actual min-content width is the width of the
8379    // widest resulting line (typically the widest word).
8380    let is_min_content = matches!(fragment_constraints.available_width, AvailableSpace::MinContent);
8381    let is_max_content = matches!(fragment_constraints.available_width, AvailableSpace::MaxContent);
8382    
8383    let column_width = match fragment_constraints.available_width {
8384        AvailableSpace::Definite(width) => (width - total_column_gap) / num_columns as f32,
8385        AvailableSpace::MinContent | AvailableSpace::MaxContent => {
8386            // For intrinsic sizing, use a large width to measure actual content width.
8387            // The line breaker will handle MinContent specially by breaking after each word.
8388            f32::MAX / 2.0
8389        }
8390    };
8391    let mut current_column = 0;
8392    if let Some(msgs) = debug_messages {
8393        msgs.push(LayoutDebugMessage::info(format!(
8394            "Column width calculated: {column_width}"
8395        )));
8396    }
8397
8398    // Use the CSS direction from constraints instead of auto-detecting from text
8399    // This ensures that mixed-direction text (e.g., "مرحبا - Hello") uses the
8400    // correct paragraph-level direction for alignment purposes.
8401    // With unicode-bidi: plaintext, direction is auto-detected from text content
8402    // per CSS Writing Modes §8.3.
8403    let base_direction = if fragment_constraints.unicode_bidi == UnicodeBidi::Plaintext {
8404        // Auto-detect from remaining shaped items' text content
8405        let remaining = &cursor.items[cursor.next_item_index..];
8406        let text: String = remaining.iter()
8407            .filter_map(|i| i.as_cluster())
8408            .map(|c| c.text.as_str())
8409            .collect();
8410        match unicode_bidi::get_base_direction(text.as_str()) {
8411            unicode_bidi::Direction::Ltr => BidiDirection::Ltr,
8412            unicode_bidi::Direction::Rtl => BidiDirection::Rtl,
8413            // No strong character: fall back to containing block direction
8414            unicode_bidi::Direction::Mixed => fragment_constraints.direction.unwrap_or(BidiDirection::Ltr),
8415        }
8416    } else {
8417        fragment_constraints.direction.unwrap_or(BidiDirection::Ltr)
8418    };
8419
8420    if let Some(msgs) = debug_messages {
8421        msgs.push(LayoutDebugMessage::info(format!(
8422            "[PFLayout] Base direction: {:?} (from CSS), Text align: {:?}",
8423            base_direction, fragment_constraints.text_align
8424        )));
8425    }
8426
8427    'column_loop: while current_column < num_columns {
8428        if let Some(msgs) = debug_messages {
8429            msgs.push(LayoutDebugMessage::info(format!(
8430                "\n-- Starting Column {current_column} --"
8431            )));
8432        }
8433        let column_start_x =
8434            (column_width + fragment_constraints.column_gap) * current_column as f32;
8435        let mut line_top_y = 0.0;
8436        let mut line_index = 0;
8437        let mut empty_segment_count = 0; // Failsafe counter for infinite loops
8438        let mut is_after_forced_break = false;
8439
8440        // [g147 az-web-lift] Hard total-iteration cap on the line-build loop. On the remill lift,
8441        // `cursor.is_done()` (or the empty-segment failsafe) mis-lifts for the NESTED IFC (content.len
8442        // reads 0 → the cursor is starved but never reports done) → this `while !cursor.is_done()` spins
8443        // forever → solveLayoutReal HANGS inside perform_fragment_layout. Cap total iterations so the loop
8444        // always converges (the harness can then read the markers). native is unaffected (far above real
8445        // line counts). The 0x60BC4 marker exposes the iteration count.
8446        #[allow(clippy::no_effect_underscore_binding)] // web_lift-gated debug iteration counter
8447        let mut _az_line_iters: usize = 0;
8448        while !cursor.is_done() {
8449            #[cfg(feature = "web_lift")]
8450            {
8451                _az_line_iters += 1;
8452                unsafe { crate::az_mark((0x60BC4) as u32, (_az_line_iters as u32 | 0xC0DE0000) as u32); }
8453                if _az_line_iters > 4096 {
8454                    break;
8455                }
8456            }
8457            if let Some(max_height) = fragment_constraints.available_height {
8458                if line_top_y >= max_height {
8459                    if let Some(msgs) = debug_messages {
8460                        msgs.push(LayoutDebugMessage::info(format!(
8461                            "  Column full (pen {line_top_y} >= height {max_height}), breaking to next column."
8462                        )));
8463                    }
8464                    break;
8465                }
8466            }
8467
8468            if let Some(clamp) = fragment_constraints.line_clamp {
8469                if line_index >= clamp.get() {
8470                    break;
8471                }
8472            }
8473
8474            // Create constraints specific to the current column for the line breaker.
8475            let mut column_constraints = fragment_constraints.clone();
8476            // For MinContent/MaxContent, preserve the semantic type so the line breaker
8477            // can handle word-level breaking correctly. Only use Definite for actual widths.
8478            if is_min_content {
8479                column_constraints.available_width = AvailableSpace::MinContent;
8480            } else if is_max_content {
8481                column_constraints.available_width = AvailableSpace::MaxContent;
8482            } else {
8483                column_constraints.available_width = AvailableSpace::Definite(column_width);
8484            }
8485            let line_constraints = get_line_constraints(
8486                line_top_y,
8487                fragment_constraints.resolved_line_height(),
8488                &column_constraints,
8489                debug_messages,
8490            );
8491
8492            if line_constraints.segments.is_empty() {
8493                empty_segment_count += 1;
8494                if let Some(msgs) = debug_messages {
8495                    msgs.push(LayoutDebugMessage::info(format!(
8496                        "  No available segments at y={line_top_y}, skipping to next line. (empty count: \
8497                         {empty_segment_count}/{MAX_EMPTY_SEGMENTS})"
8498                    )));
8499                }
8500
8501                // Failsafe: If we've skipped too many lines without content, break out
8502                if empty_segment_count >= MAX_EMPTY_SEGMENTS {
8503                    if let Some(msgs) = debug_messages {
8504                        msgs.push(LayoutDebugMessage::warning(format!(
8505                            "  [WARN] Reached maximum empty segment count ({MAX_EMPTY_SEGMENTS}). Breaking to \
8506                             prevent infinite loop."
8507                        )));
8508                        msgs.push(LayoutDebugMessage::warning(
8509                            "  This likely means the shape constraints are too restrictive or \
8510                             positioned incorrectly."
8511                                .to_string(),
8512                        ));
8513                        msgs.push(LayoutDebugMessage::warning(format!(
8514                            "  Current y={line_top_y}, shape boundaries might be outside this range."
8515                        )));
8516                    }
8517                    break;
8518                }
8519
8520                // Additional check: If we have shapes and are far beyond the expected height,
8521                // also break to avoid infinite loops
8522                if !fragment_constraints.shape_boundaries.is_empty() && empty_segment_count > 50 {
8523                    // Calculate maximum shape height
8524                    let max_shape_y: f32 = fragment_constraints
8525                        .shape_boundaries
8526                        .iter()
8527                        .map(|shape| {
8528                            match shape {
8529                                ShapeBoundary::Circle { center, radius } => center.y + radius,
8530                                ShapeBoundary::Ellipse { center, radii } => center.y + radii.height,
8531                                ShapeBoundary::Polygon { points } => {
8532                                    points.iter().map(|p| p.y).fold(0.0, f32::max)
8533                                }
8534                                ShapeBoundary::Rectangle(rect) => rect.y + rect.height,
8535                                ShapeBoundary::Path { segments } => segments
8536                                    .iter()
8537                                    .filter_map(|s| match s {
8538                                        PathSegment::MoveTo(p) | PathSegment::LineTo(p) => Some(p.y),
8539                                        PathSegment::CurveTo { end, .. }
8540                                        | PathSegment::QuadTo { end, .. } => Some(end.y),
8541                                        PathSegment::Arc { center, radius, .. } => {
8542                                            Some(center.y + radius)
8543                                        }
8544                                        PathSegment::Close => None,
8545                                    })
8546                                    .fold(0.0, f32::max),
8547                            }
8548                        })
8549                        .fold(0.0, f32::max);
8550
8551                    if line_top_y > max_shape_y + 100.0 {
8552                        if let Some(msgs) = debug_messages {
8553                            msgs.push(LayoutDebugMessage::info(format!(
8554                                "  [INFO] Current y={line_top_y} is far beyond maximum shape extent y={max_shape_y}. \
8555                                 Breaking layout."
8556                            )));
8557                            msgs.push(LayoutDebugMessage::info(
8558                                "  Shape boundaries exist but no segments available - text cannot \
8559                                 fit in shape."
8560                                    .to_string(),
8561                            ));
8562                        }
8563                        break;
8564                    }
8565                }
8566
8567                line_top_y += fragment_constraints.resolved_line_height();
8568                continue;
8569            }
8570
8571            // Reset counter when we find valid segments
8572            empty_segment_count = 0;
8573
8574            // +spec:line-breaking:3bb032 - break-word not considered for min-content intrinsic sizes
8575            // +spec:overflow:b932c4 - overflow-wrap/word-wrap (normal/break-word/anywhere) and hyphens interaction
8576            // `anywhere` introduces soft wrap opportunities (min-content = widest cluster),
8577            // but `break-word` does NOT (min-content = widest unbreakable word).
8578            let effective_overflow_wrap = if is_min_content && fragment_constraints.overflow_wrap == OverflowWrap::Anywhere {
8579                OverflowWrap::Anywhere
8580            } else if is_min_content && fragment_constraints.overflow_wrap == OverflowWrap::BreakWord {
8581                OverflowWrap::Normal
8582            } else {
8583                fragment_constraints.overflow_wrap
8584            };
8585
8586            // CSS Text Module Level 3 § 5 Line Breaking and Word Boundaries
8587            // https://www.w3.org/TR/css-text-3/#line-breaking
8588            // +spec:display-property:2608cc - inline box splitting across line boxes, overflow for unsplittable boxes
8589            // +spec:display-property:ea615c - inline boxes split and distributed across line boxes
8590            // "When an inline box exceeds the logical width of a line box, it is split
8591            // into several fragments, which are partitioned across multiple line boxes."
8592            let (mut line_items, was_hyphenated) =
8593                break_one_line(cursor, &line_constraints, false, hyphenator.as_ref(), fonts, fragment_constraints.line_break, fragment_constraints.white_space_mode, effective_overflow_wrap);
8594            if line_items.is_empty() {
8595                if let Some(msgs) = debug_messages {
8596                    msgs.push(LayoutDebugMessage::info(
8597                        "  Break returned no items. Ending column.".to_string(),
8598                    ));
8599                }
8600                break;
8601            }
8602
8603            let line_text_before_rev: String = line_items
8604                .iter()
8605                .filter_map(|i| i.as_cluster())
8606                .map(|c| c.text.as_str())
8607                .collect();
8608            if let Some(msgs) = debug_messages {
8609                msgs.push(LayoutDebugMessage::info(format!(
8610                    // FIX: The log message was misleading. Items are in visual order.
8611                    "[PFLayout] Line items from breaker (visual order): [{line_text_before_rev}]"
8612                )));
8613            }
8614
8615            // Unicode Bidi rule L2 (glyph-level reversal). `reorder_logical_items`
8616            // already ordered the level RUNS visually; here we reverse the clusters
8617            // within each RTL run so an RTL run reads right-to-left. Applied per line
8618            // (after line breaking) so a wrapped RTL run reorders correctly per line.
8619            apply_l2_visual_reversal(&mut line_items);
8620
8621            if let Some(msgs) = debug_messages {
8622                let after: String = line_items
8623                    .iter()
8624                    .filter_map(|i| i.as_cluster())
8625                    .map(|c| c.text.as_str())
8626                    .collect();
8627                if after != line_text_before_rev {
8628                    msgs.push(LayoutDebugMessage::info(format!(
8629                        "[PFLayout] Line items after L2 reversal: [{after}]"
8630                    )));
8631                }
8632            }
8633
8634            // +spec:line-breaking:c59944 - forced line breaks detected for bidi-aware alignment
8635            let line_ends_with_forced_break = line_items.iter().any(|item| matches!(item, ShapedItem::Break { .. }));
8636
8637            // uses text-align-last (last line of block, or line right before forced break)
8638            let is_last_line = cursor.is_done() && !was_hyphenated;
8639            let effective_align = resolve_effective_alignment(
8640                fragment_constraints.text_align,
8641                fragment_constraints.text_align_last,
8642                is_last_line || line_ends_with_forced_break,
8643            );
8644
8645            let (mut line_pos_items, line_height) = position_one_line(
8646                &line_items,
8647                &line_constraints,
8648                line_top_y,
8649                line_index,
8650                effective_align,
8651                base_direction,
8652                is_last_line,
8653                fragment_constraints,
8654                debug_messages,
8655                fonts,
8656                is_after_forced_break,
8657            );
8658
8659            // Track whether the next line follows a forced break
8660            is_after_forced_break = line_ends_with_forced_break;
8661
8662            for item in &mut line_pos_items {
8663                item.position.x += column_start_x;
8664            }
8665
8666            // +spec:display-property:6c4978 - line-height on block container establishes minimum line box height
8667            line_top_y += line_height.max(fragment_constraints.resolved_line_height());
8668            line_index += 1;
8669            positioned_items.extend(line_pos_items);
8670        }
8671        current_column += 1;
8672    }
8673
8674    if let Some(msgs) = debug_messages {
8675        msgs.push(LayoutDebugMessage::info(format!(
8676            "--- Exiting perform_fragment_layout, positioned {} items ---",
8677            positioned_items.len()
8678        )));
8679    }
8680
8681    let mut layout = UnifiedLayout {
8682        items: positioned_items,
8683        overflow: OverflowInfo::default(),
8684    };
8685
8686    // Calculate bounds on demand via the bounds() method
8687    let calculated_bounds = layout.bounds();
8688
8689    // Record the unclipped content bounds. `overflow_items` stays empty by
8690    // design: this positioner places *every* item, so visual overflow is handled
8691    // at paint time via clipping rather than by dropping items here.
8692    // TODO(superplan): only populate `overflow_items` if a future positioning
8693    // path actually discards content that does not fit.
8694    layout.overflow.unclipped_bounds = calculated_bounds;
8695
8696    if let Some(msgs) = debug_messages {
8697        msgs.push(LayoutDebugMessage::info(format!(
8698            "--- Calculated bounds: width={}, height={} ---",
8699            calculated_bounds.width, calculated_bounds.height
8700        )));
8701    }
8702
8703    Ok(layout)
8704}
8705
8706/// Breaks a single line of items to fit within the given geometric constraints,
8707/// handling multi-segment lines and hyphenation.
8708/// Break a single line from the current cursor position.
8709///
8710/// # CSS Text Module Level 3 \u00a7 5 Line Breaking and Word Boundaries
8711/// <https://www.w3.org/TR/css-text-3/#line-breaking>
8712///
8713/// Implements the line breaking algorithm:
8714/// 1. "When an inline box exceeds the logical width of a line box, it is split into several
8715///    fragments, which are partitioned across multiple line boxes."
8716///
8717/// ## \u2705 Implemented Features:
8718/// - **Break Opportunities**: Identifies word boundaries and break points
8719/// - **Soft Wraps**: Wraps at spaces between words
8720/// - **Hard Breaks**: Handles explicit line breaks (\\n)
8721/// - **Overflow**: If a word is too long, places it anyway to avoid infinite loop
8722/// - **Hyphenation**: Tries to break long words at hyphenation points (\u00a7 5.4)
8723///
8724/// ## \u26a0\ufe0f Known Issues:
8725/// - If `line_constraints.total_available` is 0.0 (from `available_width: 0.0` bug), every word
8726///   will overflow, causing single-word lines
8727/// - This is the symptom visible in the PDF: "List items break extremely early"
8728///
8729/// ## \u00a7 5.2 Breaking Rules for Letters
8730/// \u2705 IMPLEMENTED: Uses Unicode line breaking algorithm
8731/// - Relies on UAX #14 for break opportunities
8732/// - Respects non-breaking spaces and zero-width joiners
8733///
8734/// ## \u00a7 5.3 Breaking Rules for Punctuation
8735/// \u26a0\ufe0f PARTIAL: Basic punctuation handling
8736/// - \u274c TODO: hanging-punctuation is declared in `UnifiedConstraints` but not used here
8737/// - \u274c TODO: Should implement punctuation trimming at line edges
8738///   // +spec:intrinsic-sizing:6085cf - hanging glyphs must be excluded from intrinsic size computation
8739///
8740/// ## \u00a7 5.4 Hyphenation
8741/// \u2705 IMPLEMENTED: Automatic hyphenation with hyphenator library
8742/// - Tries to hyphenate words that overflow
8743/// - Inserts hyphen glyph at break point
8744/// - Carries remainder to next line
8745///
8746/// ## \u00a7 5.5 Overflow Wrapping
8747/// \u2705 IMPLEMENTED: Emergency breaking
8748/// - If line is empty and word doesn't fit, forces at least one item
8749/// - Prevents infinite loop
8750/// - This is "overflow-wrap: break-word" behavior
8751///
8752/// # Missing Features:
8753/// - word-break property (normal, break-all, keep-all) - IMPLEMENTED via `BreakCursor.word_break`
8754/// - \u26a0\ufe0f line-break property: anywhere implemented; loose/normal/strict CJK strictness
8755///   filtering added via `is_cjk_break_allowed_by_strictness` (§5.3)
8756/// - \u274c overflow-wrap: anywhere vs break-word distinction
8757/// - \u2705 white-space: break-spaces handling
8758// around every typographic character unit including preserved white spaces; with break-spaces
8759// it allows breaking before the first space of a sequence
8760// +spec:line-breaking:722f3b - wrapping only at soft wrap opportunities, minimizing overflow
8761#[allow(clippy::cognitive_complexity)] // cohesive line-break state machine: one branch per CSS line-break case
8762#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
8763/// # Panics
8764///
8765/// Panics if a break unit is unexpectedly empty (an internal invariant).
8766pub fn break_one_line<T: ParsedFontTrait>(
8767    cursor: &mut BreakCursor<'_>,
8768    line_constraints: &LineConstraints,
8769    is_vertical: bool,
8770    hyphenator: Option<&Standard>,
8771    fonts: &LoadedFonts<T>,
8772    line_break: LineBreakStrictness,
8773    white_space_mode: WhiteSpaceMode,
8774    overflow_wrap: OverflowWrap,
8775) -> (Vec<ShapedItem>, bool) {
8776    let mut line_items = Vec::new();
8777    let mut current_width = 0.0;
8778
8779    if cursor.is_done() {
8780        return (Vec::new(), false);
8781    }
8782
8783    // +spec:white-space-processing:c83dbd - Phase II: collapsible spaces at line start removed, trailing spaces removed, tab stops
8784    // CSS Text Module Level 3 § 4.1.2: At the beginning of a line, white space
8785    // is collapsed away. Skip leading whitespace at line start.
8786    // https://www.w3.org/TR/css-text-3/#white-space-phase-2
8787    // Per CSS Text 3 §4.1.1/§4.1.2, leading white space at line start is collapsed
8788    // ONLY for the collapsing white-space modes. Pre / pre-wrap / break-spaces must
8789    // preserve leading indentation, so only strip for Normal / Nowrap / Pre-line.
8790    let strip_leading = matches!(
8791        white_space_mode,
8792        WhiteSpaceMode::Normal | WhiteSpaceMode::Nowrap | WhiteSpaceMode::PreLine
8793    );
8794    if strip_leading {
8795        while !cursor.is_done() {
8796            let next_unit = cursor.peek_next_unit();
8797            if next_unit.is_empty() {
8798                break;
8799            }
8800            if next_unit.len() == 1 && is_collapsible_whitespace(&next_unit[0]) {
8801                cursor.consume(1);
8802            } else {
8803                break;
8804            }
8805        }
8806    }
8807
8808    // +spec:line-breaking:35817b - white-space: nowrap/pre prevent soft wrap opportunities
8809    // CSS Text Level 3 § 3: For nowrap and pre, wrapping is suppressed. All content
8810    // stays on a single line, overflowing if necessary.
8811    let no_wrap = matches!(white_space_mode, WhiteSpaceMode::Nowrap | WhiteSpaceMode::Pre);
8812
8813    if no_wrap {
8814        // No soft wrapping — consume everything onto one line.
8815        // Only explicit <br>/newline breaks are honored.
8816        loop {
8817            let next_unit = cursor.peek_next_unit();
8818            if next_unit.is_empty() {
8819                break;
8820            }
8821            if let Some(ShapedItem::Break { .. }) = next_unit.first() {
8822                line_items.push(next_unit[0].clone());
8823                cursor.consume(1);
8824                return (line_items, false);
8825            }
8826            line_items.extend_from_slice(&next_unit);
8827            cursor.consume(next_unit.len());
8828        }
8829    } else {
8830
8831    loop {
8832        // typographic character unit as a soft wrap opportunity; hyphenation is not applied
8833        let next_unit = if line_break == LineBreakStrictness::Anywhere {
8834            cursor.peek_next_single_item()
8835        } else {
8836            cursor.peek_next_unit()
8837        };
8838        if next_unit.is_empty() {
8839            break; // End of content
8840        }
8841
8842        if let Some(ShapedItem::Break { .. }) = next_unit.first() {
8843            line_items.push(next_unit[0].clone());
8844            cursor.consume(1);
8845            return (line_items, false);
8846        }
8847
8848        // Min-content: break at EVERY soft-wrap opportunity so each word forms its
8849        // own line (min-content = widest unbreakable unit). `total_available` is a
8850        // sentinel (f32::MAX/2) during intrinsic sizing and never overflows, so
8851        // without this the run would collapse onto one line and min-content would
8852        // wrongly equal max-content. Once the line holds content and the next unit
8853        // is a break opportunity (a space, CJK ideograph, hyphen, …), finish here;
8854        // a leading space is stripped at the next line's start (collapsing modes).
8855        if line_constraints.is_min_content
8856            && !line_items.is_empty()
8857            && next_unit.len() == 1
8858            && is_break_opportunity_with_word_break(&next_unit[0], cursor.word_break, cursor.hyphens)
8859        {
8860            break;
8861        }
8862
8863        let unit_width: f32 = next_unit
8864            .iter()
8865            .map(|item| get_item_measure_with_spacing(item, is_vertical))
8866            .sum();
8867        let available_width = line_constraints.total_available - current_width;
8868
8869        // 2. Can the whole unit fit on the current line?
8870        if unit_width <= available_width {
8871            line_items.extend_from_slice(&next_unit);
8872            current_width += unit_width;
8873            cursor.consume(next_unit.len());
8874        } else {
8875            // 3. The unit overflows. Can we hyphenate it?
8876            if line_break != LineBreakStrictness::Anywhere {
8877                if let Some(hyphenator) = hyphenator {
8878                    if !is_break_opportunity(next_unit.last().unwrap()) {
8879                        if let Some(hyphenation_result) = try_hyphenate_word_cluster(
8880                            &next_unit,
8881                            available_width,
8882                            is_vertical,
8883                            hyphenator,
8884                            fonts,
8885                        ) {
8886                            line_items.extend(hyphenation_result.line_part);
8887                            cursor.consume(next_unit.len());
8888                            cursor.partial_remainder = hyphenation_result.remainder_part;
8889                            return (line_items, true);
8890                        }
8891                    }
8892                }
8893            }
8894
8895            // an otherwise unbreakable sequence at an arbitrary point when no other
8896            // break points exist. Grapheme clusters stay together; no hyphen inserted.
8897            // 4. Cannot hyphenate or fit. The line is finished.
8898            // If the line is empty, we must force at least one item to avoid an infinite loop.
8899            // With overflow-wrap: anywhere or break-word, we break the unbreakable
8900            // unit at an arbitrary cluster boundary. With normal, we only force one
8901            // item to prevent infinite loops (content will overflow).
8902            if line_items.is_empty() {
8903                match overflow_wrap {
8904                    OverflowWrap::Anywhere | OverflowWrap::BreakWord => {
8905                        // Emergency break: fit as many clusters as possible on
8906                        // this line.  Grapheme clusters stay together.
8907                        //
8908                        // Per CSS Text 3 §5.5: "an otherwise unbreakable sequence
8909                        // of characters may be broken at an arbitrary point" when
8910                        // overflow-wrap is anywhere/break-word.
8911                        let avail = line_constraints.total_available;
8912                        for item in &next_unit {
8913                            let item_w = get_item_measure_with_spacing(item, is_vertical);
8914                            // Break BEFORE this item if adding it would overflow,
8915                            // but only if we already have at least one item on the
8916                            // line (must always make progress).
8917                            if !line_items.is_empty() && avail > 0.0 && current_width + item_w > avail {
8918                                break;
8919                            }
8920                            line_items.push(item.clone());
8921                            current_width += item_w;
8922                            // When the container is zero-width (avail <= 0), the
8923                            // break-before check above is skipped (it requires
8924                            // avail > 0), so every item lands on this one line —
8925                            // there's nowhere to break TO, content just overflows.
8926                            // This matches browser behavior for `width: 0`
8927                            // containers.
8928                        }
8929                        let consumed = line_items.len().max(1);
8930                        if line_items.is_empty() {
8931                            line_items.push(next_unit[0].clone());
8932                        }
8933                        cursor.consume(consumed);
8934                    }
8935                    OverflowWrap::Normal => {
8936                        // overflow-wrap:normal keeps an unbreakable word intact and
8937                        // lets it overflow the line box — it must NOT be shredded one
8938                        // grapheme per line. Place the whole unit on this (empty) line.
8939                        line_items.extend_from_slice(&next_unit);
8940                        cursor.consume(next_unit.len());
8941                    }
8942                }
8943            }
8944            break;
8945        }
8946    }
8947
8948    } // end !no_wrap
8949
8950    // +spec:white-space-processing:fef250 - Phase II: trailing collapsible spaces and U+1680 removed at line end
8951    // as well as any trailing U+1680 OGHAM SPACE MARK whose white-space is normal/nowrap/pre-line.
8952    // Note: pre-wrap and break-spaces have different handling (hanging/preserving)
8953    // which is not yet implemented here.
8954    // Trailing collapsible white space is trimmed only for the collapsing modes.
8955    // Pre keeps significant trailing spaces; pre-wrap hangs them (handled in
8956    // position_one_line); break-spaces must never drop them.
8957    let strip_trailing = matches!(
8958        white_space_mode,
8959        WhiteSpaceMode::Normal | WhiteSpaceMode::Nowrap | WhiteSpaceMode::PreLine
8960    );
8961    if strip_trailing {
8962        while let Some(last) = line_items.last() {
8963            if is_collapsible_whitespace(last) {
8964                line_items.pop();
8965            } else {
8966                break;
8967            }
8968        }
8969    }
8970
8971    (line_items, false)
8972}
8973
8974/// Represents a single valid hyphenation point within a word.
8975#[derive(Debug, Clone)]
8976pub struct HyphenationBreak {
8977    /// The number of characters from the original word string included on the line.
8978    pub char_len_on_line: usize,
8979    /// The total advance width of the line part + the hyphen.
8980    pub width_on_line: f32,
8981    /// The cluster(s) that will remain on the current line.
8982    pub line_part: Vec<ShapedItem>,
8983    /// The cluster that represents the hyphen character itself.
8984    pub hyphen_item: ShapedItem,
8985    /// The cluster(s) that will be carried over to the next line.
8986    /// CRITICAL FIX: Changed from `ShapedItem` to Vec<ShapedItem>
8987    pub remainder_part: Vec<ShapedItem>,
8988}
8989
8990/// A "word" is defined as a sequence of one or more adjacent `ShapedClusters`.
8991#[allow(clippy::cast_precision_loss)] // bounded pixel/coord/colour/glyph cast
8992/// # Panics
8993///
8994/// Panics if a word's cluster or glyph list is unexpectedly empty (an internal invariant).
8995#[must_use] pub fn find_all_hyphenation_breaks<T: ParsedFontTrait>(
8996    word_clusters: &[ShapedCluster],
8997    hyphenator: &Standard,
8998    is_vertical: bool, // Pass this in to use correct metrics
8999    fonts: &LoadedFonts<T>,
9000) -> Option<Vec<HyphenationBreak>> {
9001    if word_clusters.is_empty() {
9002        return None;
9003    }
9004
9005    // --- 1. Concatenate the TRUE text and build a robust map ---
9006    let mut word_string = String::new();
9007    let mut char_map = Vec::new();
9008    let mut current_width = 0.0;
9009
9010    for (cluster_idx, cluster) in word_clusters.iter().enumerate() {
9011        for (char_byte_offset, _ch) in cluster.text.char_indices() {
9012            let glyph_idx = cluster
9013                .glyphs
9014                .iter()
9015                .rposition(|g| g.cluster_offset as usize <= char_byte_offset)
9016                .unwrap_or(0);
9017            let glyph = &cluster.glyphs[glyph_idx];
9018
9019            let num_chars_in_glyph = cluster.text[glyph.cluster_offset as usize..]
9020                .chars()
9021                .count();
9022            let advance_per_char = if is_vertical {
9023                glyph.vertical_advance
9024            } else {
9025                glyph.advance
9026            } / (num_chars_in_glyph as f32).max(1.0);
9027
9028            current_width += advance_per_char;
9029            char_map.push((cluster_idx, glyph_idx, current_width));
9030        }
9031        word_string.push_str(&cluster.text);
9032    }
9033
9034    // +spec:line-breaking:d7ed93 - language-specific hyphenation rules apply to both auto and explicit (soft hyphen) opportunities
9035    // --- 2. Get hyphenation opportunities ---
9036    let opportunities = hyphenator.hyphenate(&word_string);
9037    if opportunities.breaks.is_empty() {
9038        return None;
9039    }
9040
9041    let last_cluster = word_clusters.last().unwrap();
9042    let last_glyph = last_cluster.glyphs.last().unwrap();
9043    let style = last_cluster.style.clone();
9044
9045    // Look up font from hash
9046    let font = fonts.get_by_hash(last_glyph.font_hash)?;
9047    let (hyphen_glyph_id, hyphen_advance) =
9048        font.get_hyphen_glyph_and_advance(style.font_size_px)?;
9049
9050    let mut possible_breaks = Vec::new();
9051
9052    // --- 3. Generate a HyphenationBreak for each valid opportunity ---
9053    for &break_char_idx in &opportunities.breaks {
9054        // The break is *before* the character at this index.
9055        // So the last character on the line is at `break_char_idx - 1`.
9056        if break_char_idx == 0 || break_char_idx > char_map.len() {
9057            continue;
9058        }
9059
9060        let (_, _, width_at_break) = char_map[break_char_idx - 1];
9061
9062        // The line part is all clusters *before* the break index.
9063        let line_part: Vec<ShapedItem> = word_clusters[..break_char_idx]
9064            .iter()
9065            .map(|c| ShapedItem::Cluster(c.clone()))
9066            .collect();
9067
9068        // The remainder is all clusters *from* the break index onward.
9069        let remainder_part: Vec<ShapedItem> = word_clusters[break_char_idx..]
9070            .iter()
9071            .map(|c| ShapedItem::Cluster(c.clone()))
9072            .collect();
9073
9074        let hyphen_item = ShapedItem::Cluster(ShapedCluster {
9075            text: "-".to_string(),
9076            source_cluster_id: GraphemeClusterId {
9077                source_run: u32::MAX,
9078                start_byte_in_run: u32::MAX,
9079            },
9080            source_content_index: ContentIndex {
9081                run_index: u32::MAX,
9082                item_index: u32::MAX,
9083            },
9084            source_node_id: None, // Hyphen is generated, not from DOM
9085            glyphs: smallvec![ShapedGlyph {
9086                kind: GlyphKind::Hyphen,
9087                glyph_id: hyphen_glyph_id,
9088                font_hash: last_glyph.font_hash,
9089                font_metrics: last_glyph.font_metrics,
9090                cluster_offset: 0,
9091                script: Script::Latin,
9092                advance: hyphen_advance,
9093                kerning: 0.0,
9094                offset: Point::default(),
9095                style: style.clone(),
9096                vertical_advance: hyphen_advance,
9097                vertical_offset: Point::default(),
9098            }],
9099            advance: hyphen_advance,
9100            direction: BidiDirection::Ltr,
9101            style: style.clone(),
9102            marker_position_outside: None,
9103            is_first_fragment: true,
9104            is_last_fragment: true,
9105        });
9106
9107        possible_breaks.push(HyphenationBreak {
9108            char_len_on_line: break_char_idx,
9109            width_on_line: width_at_break + hyphen_advance,
9110            line_part,
9111            hyphen_item,
9112            remainder_part,
9113        });
9114    }
9115
9116    Some(possible_breaks)
9117}
9118
9119/// Tries to find a hyphenation point within a word, returning the line part and remainder.
9120fn try_hyphenate_word_cluster<T: ParsedFontTrait>(
9121    word_items: &[ShapedItem],
9122    remaining_width: f32,
9123    is_vertical: bool,
9124    hyphenator: &Standard,
9125    fonts: &LoadedFonts<T>,
9126) -> Option<HyphenationResult> {
9127    let word_clusters: Vec<ShapedCluster> = word_items
9128        .iter()
9129        .filter_map(|item| item.as_cluster().cloned())
9130        .collect();
9131
9132    if word_clusters.is_empty() {
9133        return None;
9134    }
9135
9136    let all_breaks = find_all_hyphenation_breaks(&word_clusters, hyphenator, is_vertical, fonts)?;
9137
9138    if let Some(best_break) = all_breaks
9139        .into_iter()
9140        .rfind(|b| b.width_on_line <= remaining_width)
9141    {
9142        let mut line_part = best_break.line_part;
9143        line_part.push(best_break.hyphen_item);
9144
9145        return Some(HyphenationResult {
9146            line_part,
9147            remainder_part: best_break.remainder_part,
9148        });
9149    }
9150
9151    None
9152}
9153
9154/// Positions a single line of items, handling alignment and justification within segments.
9155///
9156/// This function is architecturally critical for cache safety. It does not mutate the
9157/// `advance` or `bounds` of the input `ShapedItem`s. Instead, it applies justification
9158/// spacing by adjusting the drawing pen's position (`main_axis_pen`).
9159///
9160/// # Returns
9161/// A tuple containing the `Vec` of positioned items and the calculated height of the line box.
9162/// Position items on a single line after breaking.
9163///
9164/// # CSS Inline Layout Module Level 3 \u00a7 2.2 Layout Within Line Boxes
9165/// <https://www.w3.org/TR/css-inline-3/#layout-within-line-boxes>
9166///
9167/// Implements the positioning algorithm:
9168/// 1. "All inline-level boxes are aligned by their baselines"
9169/// 2. "Calculate layout bounds for each inline box"
9170/// 3. "Size the line box to fit the aligned layout bounds"
9171/// 4. "Position all inline boxes within the line box"
9172///
9173/// ## \u2705 Implemented Features:
9174///
9175/// ### \u00a7 4 Baseline Alignment (vertical-align)
9176/// \u26a0\ufe0f PARTIAL IMPLEMENTATION:
9177/// - \u2705 `baseline`: Aligns box baseline with parent baseline (default)
9178/// - \u2705 `top`: Aligns top of box with top of line box
9179/// - \u2705 `middle`: Centers box within line box
9180/// - \u2705 `bottom`: Aligns bottom of box with bottom of line box
9181/// - \u274c MISSING: `text-top`, `text-bottom`, `sub`, `super`
9182/// - \u274c MISSING: `<length>`, `<percentage>` values for custom offset
9183///
9184/// ### \u00a7 2.2.1 Text Alignment (text-align)
9185/// +spec:containing-block:8d5146 - text-align aligns within line box, not viewport/containing block
9186/// \u2705 IMPLEMENTED:
9187/// - `left`, `right`, `center`: Physical alignment
9188/// - `start`, `end`: Logical alignment (respects direction: ltr/rtl)
9189/// - `justify`: Distributes space between words/characters
9190/// - `justify-all`: Justifies last line too
9191///
9192/// ### \u00a7 7.3 Text Justification (text-justify)
9193/// \u2705 IMPLEMENTED:
9194/// - `inter-word`: Adds space between words
9195/// - `inter-character`: Adds space between characters
9196/// - `kashida`: Arabic kashida elongation
9197/// - \u274c MISSING: `distribute` (CJK justification)
9198///
9199/// ### CSS Text \u00a7 8.1 Text Indentation (text-indent)
9200/// \u2705 IMPLEMENTED: First line indentation
9201///
9202/// ### CSS Text \u00a7 4.1 Word Spacing (word-spacing)
9203/// \u2705 IMPLEMENTED: Additional space between words
9204///
9205/// ### CSS Text \u00a7 4.2 Letter Spacing (letter-spacing)
9206/// \u2705 IMPLEMENTED: Additional space between characters
9207///
9208/// ## Segment-Aware Layout:
9209/// \u2705 Handles CSS Shapes and multi-column layouts
9210/// - Breaks line into segments (for shape boundaries)
9211/// - Calculates justification per segment
9212/// - Applies alignment within each segment's bounds
9213///
9214/// ## Known Issues:
9215/// - \u26a0\ufe0f If segment.width is infinite (from intrinsic sizing), sets `alignment_offset=0` to
9216///   avoid infinite positioning. This is correct for measurement but documented for clarity.
9217/// - The function assumes `line_index == 0` means first line for text-indent. A more robust system
9218///   would track paragraph boundaries.
9219///
9220/// # Missing Features:
9221/// - \u274c \u00a7 6 Trimming Leading (text-box-trim, text-box-edge)
9222/// - \u274c \u00a7 3.3 Initial Letters (drop caps)
9223///   // +spec:display-property:265c04 - initial letter exclusion area must continue into subsequent blocks when paragraph is shorter than drop cap
9224/// - \u274c Full vertical-align support (sub, super, lengths, percentages)
9225/// - \u274c white-space: break-spaces alignment behavior
9226// +spec:text-alignment-spacing:c8a926 - order of operations: shaping → letter/word-spacing → justification → alignment
9227#[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
9228#[allow(clippy::cast_precision_loss)] // bounded pixel/coord/colour/glyph cast
9229#[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
9230#[allow(clippy::too_many_lines, clippy::cognitive_complexity)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
9231pub fn position_one_line<T: ParsedFontTrait>(
9232    line_items: &[ShapedItem],
9233    line_constraints: &LineConstraints,
9234    line_top_y: f32,
9235    line_index: usize,
9236    text_align: TextAlign,
9237    base_direction: BidiDirection,
9238    is_last_line: bool,
9239    constraints: &UnifiedConstraints,
9240    debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
9241    fonts: &LoadedFonts<T>,
9242    is_after_forced_break: bool,
9243) -> (Vec<PositionedItem>, f32) {
9244    let line_text: String = line_items
9245        .iter()
9246        .filter_map(|i| i.as_cluster())
9247        .map(|c| c.text.as_str())
9248        .collect();
9249    if let Some(msgs) = debug_messages {
9250        msgs.push(LayoutDebugMessage::info(format!(
9251            "\n--- Entering position_one_line for line: [{line_text}] ---"
9252        )));
9253    }
9254    // +spec:text-alignment-spacing:13b72d - line box start/end determined by inline base direction
9255    // +spec:text-alignment-spacing:d497af - line box inline base direction affects text-align resolution
9256    // +spec:text-alignment-spacing:68332e - bidi direction determines start/end to left/right mapping
9257    let physical_align = match (text_align, base_direction) {
9258        (TextAlign::Start, BidiDirection::Ltr) => TextAlign::Left,
9259        (TextAlign::Start, BidiDirection::Rtl) => TextAlign::Right,
9260        (TextAlign::End, BidiDirection::Ltr) => TextAlign::Right,
9261        (TextAlign::End, BidiDirection::Rtl) => TextAlign::Left,
9262        // Physical alignments are returned as-is, regardless of direction.
9263        (other, _) => other,
9264    };
9265    if let Some(msgs) = debug_messages {
9266        msgs.push(LayoutDebugMessage::info(format!(
9267            "[Pos1Line] Physical align: {physical_align:?}"
9268        )));
9269    }
9270
9271    // +spec:box-model:847003 - Phantom line boxes: empty lines treated as zero-height
9272    // +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
9273    // +spec:display-property:90d782 - Phantom line boxes (containing only empty inline boxes, out-of-flow items, or collapsed whitespace) are ignored
9274    if line_items.is_empty() {
9275        return (Vec::new(), 0.0);
9276    }
9277    let mut positioned = Vec::new();
9278    let is_vertical = constraints.is_vertical();
9279
9280    // +spec:line-height:9ca9d9 - line box height = distance from uppermost box top to lowermost box bottom, including strut
9281    // The line box is calculated once for all items on the line, regardless of segment.
9282    // Per CSS 2.2 §10.8, top/bottom aligned items are handled in a second pass to
9283    // minimize line box height; baseline-aligned items determine the initial height.
9284    let (content_ascent, content_descent) = calculate_line_metrics(line_items, constraints.vertical_align, constraints);
9285
9286    // +spec:box-model:e99f7d - strut: each line box starts with zero-width inline box with block container's font/line-height
9287    // +spec:line-height:29c478 - strut: zero-width inline box with block container's font/line-height
9288    // inline box with the block container's font and line-height. The strut has A (ascent) and
9289    // D (descent) from the block container's first available font. Half-leading L/2 is applied:
9290    // L = line-height - (A + D), strut_above = A + L/2, strut_below = D + L/2.
9291    // +spec:height-calculation:8e91b2 - specified line-height used in line box height calculation
9292    let strut_ad = constraints.strut_ascent + constraints.strut_descent;
9293    let strut_leading_half = (constraints.resolved_line_height() - strut_ad) / 2.0;
9294    let strut_above = constraints.strut_ascent + strut_leading_half;
9295    let strut_below = constraints.strut_descent + strut_leading_half;
9296    let line_ascent = content_ascent.max(strut_above);
9297    let line_descent = content_descent.max(strut_below);
9298    let line_box_height = line_ascent + line_descent;
9299
9300    // The baseline for the entire line is determined by its tallest item.
9301    let line_baseline_y = line_top_y + line_ascent;
9302
9303    // --- Segment-Aware Positioning ---
9304    let mut item_cursor = 0;
9305    let is_first_line_of_para = line_index == 0; // Simplified assumption
9306
9307    for (segment_idx, segment) in line_constraints.segments.iter().enumerate() {
9308        if item_cursor >= line_items.len() {
9309            break;
9310        }
9311
9312        // 1. Collect all items that fit into the current segment.
9313        let mut segment_items = Vec::new();
9314        let mut current_segment_width = 0.0;
9315        while item_cursor < line_items.len() {
9316            let item = &line_items[item_cursor];
9317            let item_measure = get_item_measure(item, is_vertical);
9318            // Put at least one item in the segment to avoid getting stuck.
9319            if current_segment_width + item_measure > segment.width && !segment_items.is_empty() {
9320                break;
9321            }
9322            segment_items.push(item.clone());
9323            current_segment_width += item_measure;
9324            item_cursor += 1;
9325        }
9326
9327        if segment_items.is_empty() {
9328            continue;
9329        }
9330
9331        // +spec:text-alignment-spacing:b9d88e - justify stretches inline boxes via text-justify; non-collapsible WS may skip justification
9332        // 2. Calculate justification spacing *for this segment only*.
9333        // +spec:text-alignment-spacing:30d322 - justify lines with justification opportunities when text-align is justify
9334        // CSS Text 3 §6: text-justify controls HOW to justify, but only applies
9335        // when text-align is justify/justify-all. Without this check, ALL text
9336        // gets justified because text-justify defaults to auto (→ InterWord).
9337        let (extra_word_spacing, extra_char_spacing) = if (constraints.text_align == TextAlign::Justify
9338            || constraints.text_align == TextAlign::JustifyAll)
9339            && constraints.text_justify != JustifyContent::None
9340            && (!is_last_line || constraints.text_align == TextAlign::JustifyAll)
9341            && constraints.text_justify != JustifyContent::Kashida
9342        {
9343            let segment_line_constraints = LineConstraints {
9344                segments: vec![*segment],
9345                total_available: segment.width,
9346                is_min_content: false,
9347            };
9348            calculate_justification_spacing(
9349                &segment_items,
9350                &segment_line_constraints,
9351                constraints.text_justify,
9352                is_vertical,
9353            )
9354        } else {
9355            (0.0, 0.0)
9356        };
9357
9358        // Kashida justification needs to be segment-aware if used.
9359        let justified_segment_items = if constraints.text_justify == JustifyContent::Kashida
9360            && (!is_last_line || constraints.text_align == TextAlign::JustifyAll)
9361        {
9362            let segment_line_constraints = LineConstraints {
9363                segments: vec![*segment],
9364                total_available: segment.width,
9365                is_min_content: false,
9366            };
9367            justify_kashida_and_rebuild(
9368                segment_items,
9369                &segment_line_constraints,
9370                is_vertical,
9371                debug_messages,
9372                fonts,
9373            )
9374        } else {
9375            segment_items
9376        };
9377
9378        // Recalculate width in case kashida changed the item list
9379        let final_segment_width: f32 = justified_segment_items
9380            .iter()
9381            .map(|item| get_item_measure(item, is_vertical))
9382            .sum();
9383
9384        // +spec:line-breaking:155a96 - pre-wrap hanging spaces: unconditionally hang without forced break, conditionally hang with forced break
9385        // +spec:white-space-processing:68af09 - Phase II: trailing whitespace hanging/conditional hanging per white-space mode
9386        // +spec:white-space-processing:75d91e - preserved white space hangs at line end, affecting intrinsic sizing
9387        // +spec:overflow:a68394 - Hanging trailing whitespace: unconditionally hang (not considered
9388        // during alignment, may overflow) for lines without forced break; conditionally hang for
9389        // lines ending with forced break (only hang if would overflow).
9390        // For normal/nowrap/pre-line: unconditionally hang trailing WS.
9391        // For pre-wrap: unconditionally hang, unless before forced break (then conditionally hang).
9392        // For break-spaces: trailing spaces cannot hang.
9393        // For pre: no hanging (whitespace preserved as-is).
9394        // +spec:intrinsic-sizing:1db683 - conditionally hanging glyphs excluded from min-content, included in max-content
9395        let trailing_ws_width = match constraints.white_space_mode {
9396            WhiteSpaceMode::BreakSpaces | WhiteSpaceMode::Pre => 0.0,
9397            WhiteSpaceMode::Normal | WhiteSpaceMode::Nowrap | WhiteSpaceMode::PreLine => {
9398                measure_trailing_whitespace(&justified_segment_items, is_vertical)
9399            }
9400            // +spec:line-breaking:8aa426 - space before forced break does not hang if it doesn't overflow
9401            WhiteSpaceMode::PreWrap => {
9402                let has_forced_break = justified_segment_items.last()
9403                    .is_some_and(|item| matches!(item, ShapedItem::Break { .. }));
9404                let ws_width = measure_trailing_whitespace(&justified_segment_items, is_vertical);
9405                if has_forced_break {
9406                    // +spec:display-contents:2704a2 - conditionally hanging chars not considered when measuring line fit
9407                    // Conditionally hang: only hang if it would overflow
9408                    let content_width = final_segment_width - ws_width;
9409                    if content_width + ws_width > segment.width {
9410                        ws_width
9411                    } else {
9412                        0.0
9413                    }
9414                } else {
9415                    ws_width // unconditionally hang
9416                }
9417            }
9418        };
9419        let effective_segment_width = final_segment_width - trailing_ws_width;
9420
9421        // +spec:text-alignment-spacing:287316 - overflow content is start-aligned; alignment offset within line box
9422        // 3. Calculate alignment offset *within this segment*.
9423        let remaining_space = segment.width - effective_segment_width;
9424
9425        // Handle MaxContent/indefinite width: when available_width is MaxContent (for intrinsic
9426        // sizing), segment.width will be f32::MAX / 2.0. Alignment calculations would
9427        // produce huge offsets. In this case, treat as left-aligned (offset = 0) since
9428        // we're measuring natural content width. We check for both infinite AND very large
9429        // values (> 1e30) to catch the MaxContent case.
9430        let is_indefinite_width = segment.width.is_infinite() || segment.width > 1e30;
9431        // +spec:text-alignment-spacing:ab1d4f - unexpandable justify text aligns as center
9432        let alignment_offset = if is_indefinite_width {
9433            0.0 // No alignment offset for indefinite width
9434        } else {
9435            match physical_align {
9436                TextAlign::Center => remaining_space / 2.0,
9437                TextAlign::Right => remaining_space,
9438                TextAlign::Justify | TextAlign::JustifyAll
9439                    if remaining_space > 0.0
9440                        && extra_word_spacing == 0.0
9441                        && extra_char_spacing == 0.0 =>
9442                {
9443                    // CSS Text §6.4.3: If text cannot be stretched to full width
9444                    // and text-align-last is justify, align as center.
9445                    remaining_space / 2.0
9446                }
9447                _ => 0.0, // Left, Justify (when justification succeeded)
9448            }
9449        };
9450
9451        let mut main_axis_pen = segment.start_x + alignment_offset;
9452        if let Some(msgs) = debug_messages {
9453            msgs.push(LayoutDebugMessage::info(format!(
9454                "[Pos1Line] Segment width: {}, Item width: {}, Remaining space: {}, Initial pen: \
9455                 {}",
9456                segment.width, final_segment_width, remaining_space, main_axis_pen
9457            )));
9458        }
9459
9460        // Default: indent first line only. each-line: also indent after forced breaks.
9461        // hanging: invert which lines get the indent.
9462        if segment_idx == 0 {
9463            let is_indent_target = if constraints.text_indent_each_line {
9464                // each-line: first line AND each line after a forced break
9465                is_first_line_of_para || is_after_forced_break
9466            } else {
9467                // Default: only the first line of the block
9468                is_first_line_of_para
9469            };
9470            // hanging: inverts which lines are affected
9471            let should_indent = if constraints.text_indent_hanging {
9472                !is_indent_target
9473            } else {
9474                is_indent_target
9475            };
9476            if should_indent {
9477                main_axis_pen += constraints.text_indent;
9478            }
9479        }
9480
9481        // Calculate total marker width for proper outside marker positioning
9482        // We need to position all marker clusters together in the padding gutter
9483        let total_marker_width: f32 = justified_segment_items
9484            .iter()
9485            .filter_map(|item| {
9486                if let ShapedItem::Cluster(c) = item {
9487                    if c.marker_position_outside == Some(true) {
9488                        return Some(get_item_measure(item, is_vertical));
9489                    }
9490                }
9491                None
9492            })
9493            .sum();
9494
9495        // Track marker pen separately - starts at negative position for outside markers
9496        let marker_spacing = 4.0; // Small gap between marker and content
9497        let mut marker_pen = if total_marker_width > 0.0 {
9498            -(total_marker_width + marker_spacing)
9499        } else {
9500            0.0
9501        };
9502
9503        // 4. Position the items belonging to this segment.
9504        //
9505        // +spec:inline-formatting-context:267438 - Content positioning: position aligned subtree and baseline-shift values within line box
9506        //
9507        // Vertical alignment positioning (CSS vertical-align)
9508        //
9509        // +spec:font-metrics:cae541 - dominant baseline used for inline alignment
9510        // Per CSS Inline Layout Level 3 § 4 (Baseline Alignment), each inline
9511        // element can specify its own `vertical-align`. For Object items
9512        // (inline-blocks, images), we use their per-item alignment stored in
9513        // `InlineContent::Shape.alignment` or `InlineContent::Image.alignment`.
9514        // For text clusters or items without a per-item override, we fall back
9515        // to the global `constraints.vertical_align` from the containing block.
9516        //
9517        // +spec:font-metrics:f29b61 - baseline alignment matches corresponding baseline types (only alphabetic implemented)
9518        // Reference: https://www.w3.org/TR/css-inline-3/#baseline-alignment
9519        // +spec:block-formatting-context:26b535 - In vertical typographic mode, central baseline is dominant when text-orientation is mixed/upright; otherwise alphabetic
9520        // +spec:inline-formatting-context:eb735b - alignment-baseline: inline-level boxes aligned to parent's baseline via vertical-align
9521        // +spec:inline-formatting-context:da3f34 - baseline alignment of in-flow inline-level boxes in block axis per dominant-baseline/vertical-align
9522        // +spec:line-height:e2253a - vertical-align positioning within line boxes
9523
9524        // Pre-compute inline border/padding offsets at span boundaries.
9525        // Only the FIRST cluster of each inline span gets left_inset, and only
9526        // the LAST cluster gets right_inset. We detect span boundaries by comparing
9527        // Arc<StyleProperties> pointers between consecutive clusters.
9528        let inline_offsets: Vec<(f32, f32)> = {
9529            let items_slice: &[ShapedItem] = &justified_segment_items;
9530            items_slice.iter().enumerate().map(|(idx, item)| {
9531                if let ShapedItem::Cluster(c) = item {
9532                    if let Some(border) = c.style.border.as_ref() {
9533                        if border.has_chrome() {
9534                            let style_ptr = Arc::as_ptr(&c.style);
9535                            let prev_same_span = idx > 0 && items_slice[idx - 1]
9536                                .as_cluster()
9537                                .is_some_and(|pc| Arc::as_ptr(&pc.style) == style_ptr);
9538                            let next_same_span = idx + 1 < items_slice.len() && items_slice[idx + 1]
9539                                .as_cluster()
9540                                .is_some_and(|nc| Arc::as_ptr(&nc.style) == style_ptr);
9541                            let left = if prev_same_span { 0.0 } else { border.left_inset() };
9542                            let right = if next_same_span { 0.0 } else { border.right_inset() };
9543                            return (left, right);
9544                        }
9545                    }
9546                }
9547                (0.0, 0.0)
9548            }).collect()
9549        };
9550        for (inline_offset_idx, item) in justified_segment_items.into_iter().enumerate() {
9551            let (item_ascent, item_descent) = get_item_vertical_metrics(&item, constraints);
9552            // Use per-item alignment if available, otherwise fall back to global
9553            let effective_align = get_item_vertical_align(&item)
9554                .unwrap_or(constraints.vertical_align);
9555            // +spec:display-property:328cfc - baseline-shift / aligned subtree vertical alignment (sub, super, top, bottom, center)
9556            // §10.8.1 vertical-align positioning
9557            // +spec:line-height:0fcfab - vertical-align property values (baseline, top, middle, bottom, sub, super, text-top, text-bottom, percentage, length)
9558            let item_baseline_pos = match effective_align {
9559                // +spec:display-property:8e018d - aligned subtree edges used for top/bottom line box alignment
9560                // +spec:inline-formatting-context:495672 - line-relative vertical-align (top/center/bottom) and aligned subtree positioning
9561                // top: align top of aligned subtree with top of line box
9562                VerticalAlign::Top => line_top_y + item_ascent,
9563                // +spec:font-metrics:70000d - align vertical midpoint of box with baseline + half x-height of parent
9564                VerticalAlign::Middle => {
9565                    let half_x_height = constraints.strut_x_height / 2.0;
9566                    line_baseline_y + half_x_height - f32::midpoint(item_ascent, item_descent) + item_ascent
9567                }
9568                // bottom: align bottom of aligned subtree with bottom of line box
9569                VerticalAlign::Bottom => line_top_y + line_box_height - item_descent,
9570                // +spec:font-metrics:aa21f7 - sub: lower baseline to proper subscript position
9571                VerticalAlign::Sub => line_baseline_y + line_ascent * SUBSCRIPT_OFFSET_RATIO,
9572                // +spec:display-property:3b0e76 - baseline-shift super raises by ~1/3 font-size; top/bottom align to line box edges
9573                // super: raise baseline to proper superscript position (~0.4em)
9574                VerticalAlign::Super => line_baseline_y - line_ascent * SUPERSCRIPT_OFFSET_RATIO,
9575                // text-top: align top of box with top of parent's content area (§10.6.1)
9576                // Parent's content area top = baseline - strut_ascent
9577                VerticalAlign::TextTop => (line_baseline_y - constraints.strut_ascent) + item_ascent,
9578                // text-bottom: align bottom of box with bottom of parent's content area (§10.6.1)
9579                // Parent's content area bottom = baseline + strut_descent
9580                VerticalAlign::TextBottom => (line_baseline_y + constraints.strut_descent) - item_descent,
9581                // <length>/<percentage>: raise (positive) or lower (negative); 0 = baseline
9582                VerticalAlign::Offset(offset) => line_baseline_y - offset,
9583                // +spec:display-property:8bf37e - dominant-baseline defaults to alphabetic; baseline alignment matches parent
9584                // baseline: align baseline of box with baseline of parent box
9585                // +spec:font-metrics:96bbd3 - baseline: align alphabetic baseline of box with parent's alphabetic baseline
9586                VerticalAlign::Baseline => line_baseline_y,
9587            };
9588
9589            // Calculate item measure (needed for both positioning and pen advance)
9590            let item_measure = get_item_measure(&item, is_vertical);
9591
9592            // Advance pen by inline left_inset at span entry (before positioning glyphs)
9593            let (left_inset, right_inset) = if inline_offset_idx < inline_offsets.len() {
9594                inline_offsets[inline_offset_idx]
9595            } else {
9596                (0.0, 0.0)
9597            };
9598            main_axis_pen += left_inset;
9599
9600            let position = if is_vertical {
9601                Point {
9602                    x: item_baseline_pos - item_ascent,
9603                    y: main_axis_pen,
9604                }
9605            } else {
9606                if let Some(msgs) = debug_messages {
9607                    msgs.push(LayoutDebugMessage::info(format!(
9608                        "[Pos1Line] is_vertical=false, main_axis_pen={main_axis_pen}, item_baseline_pos={item_baseline_pos}, \
9609                         item_ascent={item_ascent}"
9610                    )));
9611                }
9612
9613                // Check if this is an outside marker - if so, position it in the padding gutter
9614                let x_position = if let ShapedItem::Cluster(cluster) = &item {
9615                    if cluster.marker_position_outside == Some(true) {
9616                        // Use marker_pen for sequential marker positioning
9617                        let marker_width = item_measure;
9618                        if let Some(msgs) = debug_messages {
9619                            msgs.push(LayoutDebugMessage::info(format!(
9620                                "[Pos1Line] Outside marker detected! width={marker_width}, positioning at \
9621                                 marker_pen={marker_pen}"
9622                            )));
9623                        }
9624                        let pos = marker_pen;
9625                        marker_pen += marker_width; // Advance marker pen for next marker cluster
9626                        pos
9627                    } else {
9628                        main_axis_pen
9629                    }
9630                } else {
9631                    main_axis_pen
9632                };
9633
9634                Point {
9635                    y: item_baseline_pos - item_ascent,
9636                    x: x_position,
9637                }
9638            };
9639
9640            // item_measure is calculated above for marker positioning
9641            let item_text = item
9642                .as_cluster()
9643                .map_or("[OBJ]", |c| c.text.as_str());
9644            if let Some(msgs) = debug_messages {
9645                msgs.push(LayoutDebugMessage::info(format!(
9646                    "[Pos1Line] Positioning item '{item_text}' at pen_x={main_axis_pen}"
9647                )));
9648            }
9649            positioned.push(PositionedItem {
9650                item: item.clone(),
9651                position,
9652                line_index,
9653            });
9654
9655            // Outside markers don't advance the pen - they're positioned in the padding gutter
9656            let is_outside_marker = if let ShapedItem::Cluster(c) = &item {
9657                c.marker_position_outside == Some(true)
9658            } else {
9659                false
9660            };
9661
9662            if !is_outside_marker {
9663                main_axis_pen += item_measure;
9664                // Advance pen by inline right_inset at span exit (after glyph advance)
9665                main_axis_pen += right_inset;
9666            }
9667
9668            // +spec:text-alignment-spacing:e09bd1 - justification space added on top of letter-spacing/word-spacing
9669            // +spec:text-alignment-spacing:456643 - cursive scripts don't admit inter-character gaps
9670            let is_cursive = if let ShapedItem::Cluster(c) = &item { is_cursive_script_cluster(c) } else { false };
9671            if !is_outside_marker && extra_char_spacing > 0.0 && can_justify_after(&item) && !is_cursive {
9672                main_axis_pen += extra_char_spacing;
9673            }
9674            // +spec:display-property:3a833c - consecutive atomic inlines treated as single unit for letter-spacing
9675            // +spec:display-property:49f04f - letter-spacing applied per innermost inline element
9676            // +spec:text-alignment-spacing:22bea4 - letter-spacing applied after bidi reordering, additive with kerning and word-spacing; justification may further adjust
9677            if let ShapedItem::Cluster(c) = &item {
9678                if !is_outside_marker {
9679                    // +spec:display-property:756454 - letter-spacing applied between typographic character units
9680                    // +spec:overflow:e63bc0 - letter-spacing ignores zero-width formatting chars (Cf); handled by shaper merging them into clusters
9681                    // +spec:text-alignment-spacing:80f9ec - letter-spacing applied per-cluster using innermost element's style (UA-allowed attachment)
9682                    // +spec:text-alignment-spacing:bdd704 - letter-spacing applied after each cluster, not at line start
9683                    // +spec:text-alignment-spacing:d3ef6e - single-char element: only trailing space, no inter-char effect
9684                    // +spec:text-alignment-spacing:d668fc - letter-spacing only affects characters within the element (per-cluster style)
9685                    // +spec:text-alignment-spacing:8dbb78 - zero letter-spacing behaves as normal (Px(0) adds no spacing)
9686                    // +spec:text-alignment-spacing:456643 - skip letter-spacing for cursive scripts
9687                    if !is_cursive_script_cluster(c) {
9688                    let letter_spacing_px = c.style.letter_spacing.resolve_px(c.style.font_size_px);
9689                    main_axis_pen += letter_spacing_px;
9690                    }
9691                    // +spec:width-calculation:9447d1 - word-spacing only applied to word separators; zero-width chars like U+200B are excluded
9692                    if is_word_separator(&item) {
9693                        let word_spacing_px = c.style.word_spacing.resolve_px(c.style.font_size_px);
9694                        main_axis_pen += word_spacing_px;
9695                        main_axis_pen += extra_word_spacing;
9696                    }
9697                }
9698            }
9699        }
9700    }
9701
9702    (positioned, line_box_height)
9703}
9704
9705/// Calculates the starting pen offset to achieve the desired text alignment.
9706fn calculate_alignment_offset(
9707    items: &[ShapedItem],
9708    line_constraints: &LineConstraints,
9709    align: TextAlign,
9710    is_vertical: bool,
9711    constraints: &UnifiedConstraints,
9712) -> f32 {
9713    // Simplified to use the first segment for alignment.
9714    if let Some(segment) = line_constraints.segments.first() {
9715        // Include letter/word-spacing so center/right alignment matches the width the
9716        // text is actually positioned at (position_one_line adds the spacing).
9717        let total_width: f32 = items
9718            .iter()
9719            .map(|item| get_item_measure_with_spacing(item, is_vertical))
9720            .sum();
9721
9722        let available_width = if constraints.segment_alignment == SegmentAlignment::Total {
9723            line_constraints.total_available
9724        } else {
9725            segment.width
9726        };
9727
9728        if total_width >= available_width {
9729            return 0.0; // No alignment needed if line is full or overflows
9730        }
9731
9732        let remaining_space = available_width - total_width;
9733
9734        match align {
9735            TextAlign::Center => remaining_space / 2.0,
9736            TextAlign::Right => remaining_space,
9737            _ => 0.0, // Left, Justify, Start, End
9738        }
9739    } else {
9740        0.0
9741    }
9742}
9743
9744/// Calculates the extra spacing needed for justification without modifying the items.
9745///
9746/// This function is pure and does not mutate any state, making it safe to use
9747/// with cached `ShapedItem` data.
9748///
9749/// # Arguments
9750/// * `items` - A slice of items on the line.
9751/// * `line_constraints` - The geometric constraints for the line.
9752/// * `text_justify` - The type of justification to calculate.
9753/// * `is_vertical` - Whether the layout is vertical.
9754///
9755/// # Returns
9756/// A tuple `(extra_per_word, extra_per_char)` containing the extra space in pixels
9757/// to add at each word or character justification opportunity.
9758// +spec:display-contents:654278 - distributes remaining space to fill line box when justifying
9759// +spec:text-alignment-spacing:56c7f4 - equal distribution of justification space within priority level
9760// +spec:text-alignment-spacing:f17bbc - justification opportunities controlled by text-justify value (inter-word = word separators, inter-character = character juxtaposition)
9761#[allow(clippy::cast_precision_loss)] // bounded pixel/coord/colour/glyph cast
9762fn calculate_justification_spacing(
9763    items: &[ShapedItem],
9764    line_constraints: &LineConstraints,
9765    text_justify: JustifyContent,
9766    is_vertical: bool,
9767) -> (f32, f32) {
9768    // (extra_per_word, extra_per_char)
9769    let total_width: f32 = items
9770        .iter()
9771        .map(|item| get_item_measure(item, is_vertical))
9772        .sum();
9773    let available_width = line_constraints.total_available;
9774
9775    if total_width >= available_width || available_width <= 0.0 {
9776        return (0.0, 0.0);
9777    }
9778
9779    let extra_space = available_width - total_width;
9780
9781    // +spec:text-alignment-spacing:71314a - script categories for justification: inter-word for clustered, kashida for cursive (Arabic), inter-character for block (CJK)
9782    match text_justify {
9783        JustifyContent::InterWord => {
9784            // Count justification opportunities (spaces).
9785            let space_count = items.iter().filter(|item| is_word_separator(item)).count();
9786            if space_count > 0 {
9787                (extra_space / space_count as f32, 0.0)
9788            } else {
9789                (0.0, 0.0) // No spaces to expand, do nothing.
9790            }
9791        }
9792        JustifyContent::InterCharacter | JustifyContent::Distribute => {
9793            // Count justification opportunities (between non-combining characters).
9794            let gap_count = items
9795                .iter()
9796                .enumerate()
9797                .filter(|(i, item)| *i < items.len() - 1 && can_justify_after(item))
9798                .count();
9799            if gap_count > 0 {
9800                (0.0, extra_space / gap_count as f32)
9801            } else {
9802                (0.0, 0.0) // No gaps to expand, do nothing.
9803            }
9804        }
9805        // Kashida justification modifies the item list and is handled by a separate function.
9806        _ => (0.0, 0.0),
9807    }
9808}
9809
9810/// Rebuilds a line of items, inserting Kashida glyphs for justification.
9811///
9812/// This function is non-mutating with respect to its inputs. It takes ownership of the
9813/// original items and returns a completely new `Vec`. This is necessary because Kashida
9814/// justification changes the number of items on the line, and must not modify cached data.
9815#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] // bounded pixel/coord/colour/glyph cast
9816#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
9817pub fn justify_kashida_and_rebuild<T: ParsedFontTrait>(
9818    items: Vec<ShapedItem>,
9819    line_constraints: &LineConstraints,
9820    is_vertical: bool,
9821    debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
9822    fonts: &LoadedFonts<T>,
9823) -> Vec<ShapedItem> {
9824    if let Some(msgs) = debug_messages {
9825        msgs.push(LayoutDebugMessage::info(
9826            "\n--- Entering justify_kashida_and_rebuild ---".to_string(),
9827        ));
9828    }
9829    let total_width: f32 = items
9830        .iter()
9831        .map(|item| get_item_measure(item, is_vertical))
9832        .sum();
9833    let available_width = line_constraints.total_available;
9834    if let Some(msgs) = debug_messages {
9835        msgs.push(LayoutDebugMessage::info(format!(
9836            "Total item width: {total_width}, Available width: {available_width}"
9837        )));
9838    }
9839
9840    if total_width >= available_width || available_width <= 0.0 {
9841        if let Some(msgs) = debug_messages {
9842            msgs.push(LayoutDebugMessage::info(
9843                "No justification needed (line is full or invalid).".to_string(),
9844            ));
9845        }
9846        return items;
9847    }
9848
9849    let extra_space = available_width - total_width;
9850    if let Some(msgs) = debug_messages {
9851        msgs.push(LayoutDebugMessage::info(format!(
9852            "Extra space to fill: {extra_space}"
9853        )));
9854    }
9855
9856    let font_info = items.iter().find_map(|item| {
9857        if let ShapedItem::Cluster(c) = item {
9858            if let Some(glyph) = c.glyphs.first() {
9859                if glyph.script == Script::Arabic {
9860                    // Look up font from hash
9861                    if let Some(font) = fonts.get_by_hash(glyph.font_hash) {
9862                        return Some((
9863                            font.clone(),
9864                            glyph.font_hash,
9865                            glyph.font_metrics,
9866                            glyph.style.clone(),
9867                        ));
9868                    }
9869                }
9870            }
9871        }
9872        None
9873    });
9874
9875    let (font, font_hash, font_metrics, style) = if let Some(info) = font_info {
9876        if let Some(msgs) = debug_messages {
9877            msgs.push(LayoutDebugMessage::info(
9878                "Found Arabic font for kashida.".to_string(),
9879            ));
9880        }
9881        info
9882    } else {
9883        if let Some(msgs) = debug_messages {
9884            msgs.push(LayoutDebugMessage::info(
9885                "No Arabic font found on line. Cannot insert kashidas.".to_string(),
9886            ));
9887        }
9888        return items;
9889    };
9890
9891    let (kashida_glyph_id, kashida_advance) =
9892        match font.get_kashida_glyph_and_advance(style.font_size_px) {
9893            Some((id, adv)) if adv > 0.0 => {
9894                if let Some(msgs) = debug_messages {
9895                    msgs.push(LayoutDebugMessage::info(format!(
9896                        "Font provides kashida glyph with advance {adv}"
9897                    )));
9898                }
9899                (id, adv)
9900            }
9901            _ => {
9902                if let Some(msgs) = debug_messages {
9903                    msgs.push(LayoutDebugMessage::info(
9904                        "Font does not support kashida justification.".to_string(),
9905                    ));
9906                }
9907                return items;
9908            }
9909        };
9910
9911    let opportunity_indices: Vec<usize> = items
9912        .windows(2)
9913        .enumerate()
9914        .filter_map(|(i, window)| {
9915            if let (ShapedItem::Cluster(cur), ShapedItem::Cluster(next)) = (&window[0], &window[1])
9916            {
9917                if is_arabic_cluster(cur)
9918                    && is_arabic_cluster(next)
9919                    && !is_word_separator(&window[1])
9920                {
9921                    return Some(i + 1);
9922                }
9923            }
9924            None
9925        })
9926        .collect();
9927
9928    if let Some(msgs) = debug_messages {
9929        msgs.push(LayoutDebugMessage::info(format!(
9930            "Found {} kashida insertion opportunities at indices: {:?}",
9931            opportunity_indices.len(),
9932            opportunity_indices
9933        )));
9934    }
9935
9936    if opportunity_indices.is_empty() {
9937        if let Some(msgs) = debug_messages {
9938            msgs.push(LayoutDebugMessage::info(
9939                "No opportunities found. Exiting.".to_string(),
9940            ));
9941        }
9942        return items;
9943    }
9944
9945    let num_kashidas_to_insert = (extra_space / kashida_advance).floor() as usize;
9946    if let Some(msgs) = debug_messages {
9947        msgs.push(LayoutDebugMessage::info(format!(
9948            "Calculated number of kashidas to insert: {num_kashidas_to_insert}"
9949        )));
9950    }
9951
9952    if num_kashidas_to_insert == 0 {
9953        return items;
9954    }
9955
9956    let kashidas_per_point = num_kashidas_to_insert / opportunity_indices.len();
9957    let mut remainder = num_kashidas_to_insert % opportunity_indices.len();
9958    if let Some(msgs) = debug_messages {
9959        msgs.push(LayoutDebugMessage::info(format!(
9960            "Distributing kashidas: {kashidas_per_point} per point, with {remainder} remainder."
9961        )));
9962    }
9963
9964    let kashida_item = {
9965        /* ... as before ... */
9966        let kashida_glyph = ShapedGlyph {
9967            kind: GlyphKind::Kashida {
9968                width: kashida_advance,
9969            },
9970            glyph_id: kashida_glyph_id,
9971            font_hash,
9972            font_metrics,
9973            style: style.clone(),
9974            script: Script::Arabic,
9975            advance: kashida_advance,
9976            kerning: 0.0,
9977            cluster_offset: 0,
9978            offset: Point::default(),
9979            vertical_advance: 0.0,
9980            vertical_offset: Point::default(),
9981        };
9982        ShapedItem::Cluster(ShapedCluster {
9983            text: "\u{0640}".to_string(),
9984            source_cluster_id: GraphemeClusterId {
9985                source_run: u32::MAX,
9986                start_byte_in_run: u32::MAX,
9987            },
9988            source_content_index: ContentIndex {
9989                run_index: u32::MAX,
9990                item_index: u32::MAX,
9991            },
9992            source_node_id: None, // Kashida is generated, not from DOM
9993            glyphs: smallvec![kashida_glyph],
9994            advance: kashida_advance,
9995            direction: BidiDirection::Ltr,
9996            style,
9997            marker_position_outside: None,
9998            is_first_fragment: true,
9999            is_last_fragment: true,
10000        })
10001    };
10002
10003    let mut new_items = Vec::with_capacity(items.len() + num_kashidas_to_insert);
10004    let mut last_copy_idx = 0;
10005    for &point in &opportunity_indices {
10006        new_items.extend_from_slice(&items[last_copy_idx..point]);
10007        let mut num_to_insert = kashidas_per_point;
10008        if remainder > 0 {
10009            num_to_insert += 1;
10010            remainder -= 1;
10011        }
10012        for _ in 0..num_to_insert {
10013            new_items.push(kashida_item.clone());
10014        }
10015        last_copy_idx = point;
10016    }
10017    new_items.extend_from_slice(&items[last_copy_idx..]);
10018
10019    if let Some(msgs) = debug_messages {
10020        msgs.push(LayoutDebugMessage::info(format!(
10021            "--- Exiting justify_kashida_and_rebuild, new item count: {} ---",
10022            new_items.len()
10023        )));
10024    }
10025    new_items
10026}
10027
10028/// Helper to determine if a cluster belongs to the Arabic script.
10029fn is_arabic_cluster(cluster: &ShapedCluster) -> bool {
10030    // A cluster is considered Arabic if its first non-NotDef glyph is from the Arabic script.
10031    // This is a robust heuristic for mixed-script lines.
10032    cluster.glyphs.iter().any(|g| g.script == Script::Arabic)
10033}
10034
10035/// Helper to identify if an item is a word separator (like a space).
10036fn measure_trailing_whitespace(items: &[ShapedItem], is_vertical: bool) -> f32 {
10037    let mut trailing_ws = 0.0;
10038    for item in items.iter().rev() {
10039        if is_collapsible_whitespace(item) {
10040            trailing_ws += get_item_measure(item, is_vertical);
10041        } else {
10042            break;
10043        }
10044    }
10045    trailing_ws
10046}
10047
10048/// Returns true if the item is collapsible whitespace per CSS Text 3 §4.1.2 Phase II.
10049///
10050/// This is used for stripping leading/trailing whitespace at line edges —
10051/// distinct from `is_word_separator` which is for word-spacing per §7.1.
10052#[must_use] pub fn is_collapsible_whitespace(item: &ShapedItem) -> bool {
10053    if let ShapedItem::Cluster(c) = item {
10054        c.text.chars().all(|ch| matches!(ch,
10055            ' ' | '\t' | '\u{1680}' // Ogham space mark (collapsible per spec)
10056        ))
10057    } else {
10058        false
10059    }
10060}
10061
10062// +spec:text-alignment-spacing:456643 - cursive scripts do not admit letter-spacing gaps
10063/// Returns true if the cluster's first character belongs to a cursive script
10064/// (Arabic, Syriac, Mongolian, N'Ko, Mandaic, Phags Pa, Hanifi Rohingya)
10065/// per CSS Text 3 Appendix D.
10066///
10067/// These scripts should not have letter-spacing applied.
10068pub fn is_cursive_script_cluster(c: &ShapedCluster) -> bool {
10069    c.text.chars().next().is_some_and(is_cursive_script_char)
10070}
10071
10072fn is_cursive_script_char(ch: char) -> bool {
10073    let cp = ch as u32;
10074    // Arabic (U+0600–U+06FF, U+0750–U+077F, U+08A0–U+08FF, U+FB50–U+FDFF, U+FE70–U+FEFF)
10075    if (0x0600..=0x06FF).contains(&cp) { return true; }
10076    if (0x0750..=0x077F).contains(&cp) { return true; }
10077    if (0x08A0..=0x08FF).contains(&cp) { return true; }
10078    if (0xFB50..=0xFDFF).contains(&cp) { return true; }
10079    if (0xFE70..=0xFEFF).contains(&cp) { return true; }
10080    // Syriac (U+0700–U+074F)
10081    if (0x0700..=0x074F).contains(&cp) { return true; }
10082    // Mongolian (U+1800–U+18AF)
10083    if (0x1800..=0x18AF).contains(&cp) { return true; }
10084    // N'Ko (U+07C0–U+07FF)
10085    if (0x07C0..=0x07FF).contains(&cp) { return true; }
10086    // Mandaic (U+0840–U+085F)
10087    if (0x0840..=0x085F).contains(&cp) { return true; }
10088    // Phags Pa (U+A840–U+A87F)
10089    if (0xA840..=0xA87F).contains(&cp) { return true; }
10090    // Hanifi Rohingya (U+10D00–U+10D3F)
10091    if (0x10D00..=0x10D3F).contains(&cp) { return true; }
10092    false
10093}
10094
10095/// Word-segmentation predicate shared by word selection (double-click) and word
10096/// cursor motion (Ctrl/Alt+Arrow) so they agree on what a "word" is.
10097///
10098/// A word character is alphanumeric or underscore; everything else — whitespace
10099/// AND punctuation — is a word boundary. This is deliberately distinct from
10100/// [`is_word_separator`] (which classifies *spacing* characters for word-spacing
10101/// justification per CSS Text §7.1, and treats punctuation as non-separator).
10102/// Used by `selection::find_word_boundaries` and `UnifiedLayout::move_cursor_to_*_word`.
10103pub(crate) fn is_word_char(ch: char) -> bool {
10104    ch.is_alphanumeric() || ch == '_'
10105}
10106
10107/// True when a shaped cluster is a word-segmentation boundary (whitespace or
10108/// punctuation), i.e. it contains no word characters. Keeps cursor word-motion
10109/// consistent with `selection::find_word_boundaries`.
10110fn cluster_is_word_boundary(cluster: &ShapedCluster) -> bool {
10111    !cluster.text.chars().any(is_word_char)
10112}
10113
10114// exclude punctuation and fixed-width spaces (U+3000, U+2000..U+200A)
10115pub fn is_word_separator(item: &ShapedItem) -> bool {
10116    if let ShapedItem::Cluster(c) = item {
10117        c.text.chars().any(is_word_separator_char)
10118    } else {
10119        false
10120    }
10121}
10122
10123// +spec:margin-collapsing:6706c1 - fixed-width spaces (U+2000–U+200A, U+3000) excluded from word separators
10124/// Returns true if the character is a word-separator character per CSS Text §7.1.
10125/// Punctuation and fixed-width spaces (U+3000, U+2000 through U+200A) are NOT
10126/// word-separator characters even though they may visually separate words.
10127// +spec:text-alignment-spacing:3e0655 - word-separator characters for word-spacing
10128#[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
10129const fn is_word_separator_char(c: char) -> bool {
10130    match c {
10131        // Standard ASCII space
10132        '\u{0020}' => true,
10133        // NO-BREAK SPACE
10134        '\u{00A0}' => true,
10135        // OGHAM SPACE MARK
10136        '\u{1680}' => true,
10137        // ETHIOPIC WORDSPACE (spec §7.1)
10138        '\u{1361}' => true,
10139        // Fixed-width spaces: NOT word separators per spec
10140        '\u{2000}'..='\u{200A}' => false,
10141        // NARROW NO-BREAK SPACE
10142        '\u{202F}' => true,
10143        // MEDIUM MATHEMATICAL SPACE
10144        '\u{205F}' => true,
10145        // IDEOGRAPHIC SPACE: NOT a word separator per spec
10146        '\u{3000}' => false,
10147        // AEGEAN WORD SEPARATOR LINE (spec §7.1)
10148        '\u{10100}' => true,
10149        // AEGEAN WORD SEPARATOR DOT (spec §7.1)
10150        '\u{10101}' => true,
10151        // UGARITIC WORD DIVIDER (spec §7.1)
10152        '\u{1039F}' => true,
10153        // PHOENICIAN WORD SEPARATOR (spec §7.1)
10154        '\u{1091F}' => true,
10155        // Other Unicode whitespace not listed above
10156        _ => false,
10157    }
10158}
10159
10160/// Helper to identify if an item is a zero-width space (U+200B),
10161/// which provides a soft wrap opportunity with no visible width.
10162///
10163/// Used in scripts like Thai, Lao, and Khmer that don't use spaces between words.
10164// +spec:line-breaking:fd3164 - U+200B as explicit word delimiter for scripts without space-separated words
10165#[must_use] pub fn is_zero_width_space(item: &ShapedItem) -> bool {
10166    if let ShapedItem::Cluster(c) = item {
10167        c.text.contains('\u{200B}')
10168    } else {
10169        false
10170    }
10171}
10172
10173/// Helper to identify if space can be added after an item.
10174fn can_justify_after(item: &ShapedItem) -> bool {
10175    if let ShapedItem::Cluster(c) = item {
10176        c.text.chars().last().is_some_and(|g| {
10177            !g.is_whitespace() && classify_character(g as u32) != CharacterClass::Combining
10178        })
10179    } else {
10180        // Per CSS 2.2 §9.4.2, justification must NOT stretch inline-table and
10181        // inline-block boxes. Object items represent these atomic inline-level
10182        // boxes, so we return false to prevent adding justification space after them.
10183        false
10184    }
10185}
10186
10187// +spec:font-metrics:b8eb97 - Script group classification for justification/letter-spacing behavior
10188/// Classifies a character for layout purposes (e.g., justification behavior).
10189/// Copied from `mod.rs`.
10190#[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
10191const fn classify_character(codepoint: u32) -> CharacterClass {
10192    match codepoint {
10193        0x0020 | 0x00A0 | 0x3000 => CharacterClass::Space,
10194        0x0021..=0x002F | 0x003A..=0x0040 | 0x005B..=0x0060 | 0x007B..=0x007E => {
10195            CharacterClass::Punctuation
10196        }
10197        0x4E00..=0x9FFF | 0x3400..=0x4DBF => CharacterClass::Ideograph,
10198        0x0300..=0x036F | 0x1AB0..=0x1AFF => CharacterClass::Combining,
10199        // Mongolian script range
10200        0x1800..=0x18AF => CharacterClass::Letter,
10201        _ => CharacterClass::Letter,
10202    }
10203}
10204
10205/// Helper to get the primary measure (width or height) of a shaped item.
10206#[must_use] pub fn get_item_measure(item: &ShapedItem, is_vertical: bool) -> f32 {
10207    match item {
10208        ShapedItem::Cluster(c) => {
10209            // Total width = base advance + kerning adjustments
10210            // Kerning is stored separately in glyphs for inspection, but the total
10211            // cluster width must include it for correct layout positioning
10212            let total_kerning: f32 = c.glyphs.iter().map(|g| g.kerning).sum();
10213            c.advance + total_kerning
10214        }
10215        ShapedItem::Object { bounds, .. }
10216        | ShapedItem::CombinedBlock { bounds, .. }
10217        | ShapedItem::Tab { bounds, .. } => {
10218            if is_vertical {
10219                bounds.height
10220            } else {
10221                bounds.width
10222            }
10223        }
10224        ShapedItem::Break { .. } => 0.0,
10225    }
10226}
10227
10228/// Like [`get_item_measure`] but ALSO includes the per-cluster letter-spacing and
10229/// per-separator word-spacing that `position_one_line` adds after each cluster.
10230///
10231/// Line breaking and center/right alignment must measure the SAME width the text is
10232/// finally positioned at; `get_item_measure` alone omits letter/word-spacing, so a run
10233/// that "just fits" without spacing overflows its box (or mis-aligns) once the spacing
10234/// is applied. Selection/caret geometry must NOT include the trailing spacing, so those
10235/// callers keep using the bare `get_item_measure`.
10236#[must_use]
10237pub fn get_item_measure_with_spacing(item: &ShapedItem, is_vertical: bool) -> f32 {
10238    let base = get_item_measure(item, is_vertical);
10239    if let ShapedItem::Cluster(c) = item {
10240        let mut extra = 0.0;
10241        if !is_cursive_script_cluster(c) {
10242            extra += c.style.letter_spacing.resolve_px(c.style.font_size_px);
10243        }
10244        if is_word_separator(item) {
10245            extra += c.style.word_spacing.resolve_px(c.style.font_size_px);
10246        }
10247        base + extra
10248    } else {
10249        base
10250    }
10251}
10252
10253/// Calculates the available horizontal segments for a line at a given vertical position,
10254/// considering both shape boundaries and exclusions.
10255#[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
10256fn get_line_constraints(
10257    line_y: f32,
10258    line_height: f32,
10259    constraints: &UnifiedConstraints,
10260    debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
10261) -> LineConstraints {
10262    if let Some(msgs) = debug_messages {
10263        msgs.push(LayoutDebugMessage::info(format!(
10264            "\n--- Entering get_line_constraints for y={line_y} ---"
10265        )));
10266    }
10267
10268    let mut available_segments = Vec::new();
10269    if constraints.shape_boundaries.is_empty() {
10270        // The segment_width is determined by available_width, NOT by TextWrap.
10271        // TextWrap::NoWrap only affects whether the LineBreaker can insert soft breaks,
10272        // it should NOT override a definite width constraint from CSS.
10273        // +spec:overflow:b06c3e - text overflows when wrapping is prevented (e.g. white-space: nowrap)
10274        // CSS Text Level 3: For 'white-space: pre/nowrap', text overflows horizontally
10275        // if it doesn't fit, rather than expanding the container.
10276        //
10277        // For MinContent/MaxContent intrinsic sizing: use a large value to let text 
10278        // lay out fully. The line breaker handles min-content by breaking at word 
10279        // boundaries. The actual content width is measured from the laid-out lines.
10280        let segment_width = match constraints.available_width {
10281            AvailableSpace::Definite(w) => w, // Respect definite width from CSS
10282            AvailableSpace::MaxContent => f32::MAX / 2.0, // For intrinsic max-content sizing
10283            AvailableSpace::MinContent => f32::MAX / 2.0, // For intrinsic min-content sizing
10284        };
10285        // Note: TextWrap::NoWrap is handled by the LineBreaker in break_one_line()
10286        // to prevent soft wraps. The text will simply overflow if it exceeds segment_width.
10287        available_segments.push(LineSegment {
10288            start_x: 0.0,
10289            width: segment_width,
10290            priority: 0,
10291        });
10292    } else {
10293        // ... complex boundary logic ...
10294    }
10295
10296    if let Some(msgs) = debug_messages {
10297        msgs.push(LayoutDebugMessage::info(format!(
10298            "Initial available segments: {available_segments:?}"
10299        )));
10300    }
10301
10302    for (idx, exclusion) in constraints.shape_exclusions.iter().enumerate() {
10303        if let Some(msgs) = debug_messages {
10304            msgs.push(LayoutDebugMessage::info(format!(
10305                "Applying exclusion #{idx}: {exclusion:?}"
10306            )));
10307        }
10308        let exclusion_spans =
10309            get_shape_horizontal_spans(exclusion, line_y, line_height);
10310        if let Some(msgs) = debug_messages {
10311            msgs.push(LayoutDebugMessage::info(format!(
10312                "  Exclusion spans at y={line_y}: {exclusion_spans:?}"
10313            )));
10314        }
10315
10316        if exclusion_spans.is_empty() {
10317            continue;
10318        }
10319
10320        let mut next_segments = Vec::new();
10321        for (excl_start, excl_end) in exclusion_spans {
10322            for segment in &available_segments {
10323                let seg_start = segment.start_x;
10324                let seg_end = segment.start_x + segment.width;
10325
10326                // Create new segments by subtracting the exclusion
10327                if seg_end > excl_start && seg_start < excl_end {
10328                    if seg_start < excl_start {
10329                        // Left part
10330                        next_segments.push(LineSegment {
10331                            start_x: seg_start,
10332                            width: excl_start - seg_start,
10333                            priority: segment.priority,
10334                        });
10335                    }
10336                    if seg_end > excl_end {
10337                        // Right part
10338                        next_segments.push(LineSegment {
10339                            start_x: excl_end,
10340                            width: seg_end - excl_end,
10341                            priority: segment.priority,
10342                        });
10343                    }
10344                } else {
10345                    next_segments.push(*segment); // No overlap
10346                }
10347            }
10348            available_segments = merge_segments(next_segments);
10349            next_segments = Vec::new();
10350        }
10351        if let Some(msgs) = debug_messages {
10352            msgs.push(LayoutDebugMessage::info(format!(
10353                "  Segments after exclusion #{idx}: {available_segments:?}"
10354            )));
10355        }
10356    }
10357
10358    let total_width = available_segments.iter().map(|s| s.width).sum();
10359    if let Some(msgs) = debug_messages {
10360        msgs.push(LayoutDebugMessage::info(format!(
10361            "Final segments: {available_segments:?}, total available width: {total_width}"
10362        )));
10363        msgs.push(LayoutDebugMessage::info(
10364            "--- Exiting get_line_constraints ---".to_string(),
10365        ));
10366    }
10367
10368    LineConstraints {
10369        segments: available_segments,
10370        total_available: total_width,
10371        is_min_content: matches!(constraints.available_width, AvailableSpace::MinContent),
10372    }
10373}
10374
10375/// Flattens a parsed SVG multipolygon (from a CSS `path()` shape) into a flat list of
10376/// `PathSegment`s in absolute coordinates (offset by the reference box origin). Each ring
10377/// becomes a `MoveTo` + a run of `LineTo`s + `Close`; curve elements are sampled into line
10378/// segments (~one segment per 4px of arc length, capped) so the scanline intersection can
10379/// treat each subpath as a polygon.
10380// bounded curve-sampling geometry casts (step count / arc-length parameter / coords)
10381#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss)]
10382fn flatten_svg_to_path_segments(
10383    multipolygon: &azul_core::svg::SvgMultiPolygon,
10384    reference_box: Rect,
10385) -> Vec<PathSegment> {
10386    use azul_core::svg::SvgPathElement;
10387
10388    let mut out: Vec<PathSegment> = Vec::new();
10389
10390    for ring in multipolygon.rings.as_ref() {
10391        let elements = ring.items.as_ref();
10392        if elements.is_empty() {
10393            continue;
10394        }
10395        let start = elements[0].get_start();
10396        out.push(PathSegment::MoveTo(Point {
10397            x: reference_box.x + start.x,
10398            y: reference_box.y + start.y,
10399        }));
10400        for el in elements {
10401            match el {
10402                SvgPathElement::Line(l) => {
10403                    out.push(PathSegment::LineTo(Point {
10404                        x: reference_box.x + l.end.x,
10405                        y: reference_box.y + l.end.y,
10406                    }));
10407                }
10408                curve => {
10409                    // Sample the curve by arc length into line segments.
10410                    let len = curve.get_length();
10411                    let steps = ((len / 4.0).ceil() as usize).clamp(1, 64);
10412                    for i in 1..=steps {
10413                        let offset = len * (i as f64) / (steps as f64);
10414                        let t = curve.get_t_at_offset(offset);
10415                        out.push(PathSegment::LineTo(Point {
10416                            x: reference_box.x + curve.get_x_at_t(t) as f32,
10417                            y: reference_box.y + curve.get_y_at_t(t) as f32,
10418                        }));
10419                    }
10420                }
10421            }
10422        }
10423        out.push(PathSegment::Close);
10424    }
10425
10426    out
10427}
10428
10429/// Computes horizontal line segments where a flattened `path()` shape (a set of
10430/// `MoveTo`/`LineTo`/`Close` subpaths) intersects a scanline at the given y range. Uses an
10431/// even-odd fill rule over the union of all subpaths so reversed rings (holes) carve out
10432/// space. Curves are assumed already flattened to `LineTo`s by `flatten_svg_to_path_segments`.
10433fn path_segments_line_intersection(
10434    segments: &[PathSegment],
10435    y: f32,
10436    line_height: f32,
10437) -> Vec<(f32, f32)> {
10438    let line_center_y = y + line_height / 2.0;
10439    let mut crossings: Vec<f32> = Vec::new();
10440
10441    // Walk the segments, reconstructing each subpath's vertices and intersecting its
10442    // (closing) edges with the scanline.
10443    let mut subpath: Vec<Point> = Vec::new();
10444    let flush = |subpath: &mut Vec<Point>, crossings: &mut Vec<f32>| {
10445        if subpath.len() >= 2 {
10446            for i in 0..subpath.len() {
10447                let p1 = subpath[i];
10448                let p2 = subpath[(i + 1) % subpath.len()];
10449                if (p2.y - p1.y).abs() < f32::EPSILON {
10450                    continue;
10451                }
10452                let crosses = (p1.y <= line_center_y && p2.y > line_center_y)
10453                    || (p1.y > line_center_y && p2.y <= line_center_y);
10454                if crosses {
10455                    let t = (line_center_y - p1.y) / (p2.y - p1.y);
10456                    crossings.push(t.mul_add(p2.x - p1.x, p1.x));
10457                }
10458            }
10459        }
10460        subpath.clear();
10461    };
10462
10463    for seg in segments {
10464        match seg {
10465            PathSegment::MoveTo(p) => {
10466                flush(&mut subpath, &mut crossings);
10467                subpath.push(*p);
10468            }
10469            PathSegment::LineTo(p) => subpath.push(*p),
10470            PathSegment::Close => flush(&mut subpath, &mut crossings),
10471            // CurveTo/QuadTo/Arc should have been flattened to LineTo already; sample the
10472            // end point as a fallback so an unflattened path still produces a polygon.
10473            PathSegment::CurveTo { end, .. } | PathSegment::QuadTo { end, .. } => {
10474                subpath.push(*end);
10475            }
10476            PathSegment::Arc { center, .. } => subpath.push(*center),
10477        }
10478    }
10479    flush(&mut subpath, &mut crossings);
10480
10481    crossings.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
10482    let mut spans = Vec::new();
10483    for chunk in crossings.chunks_exact(2) {
10484        if chunk[1] > chunk[0] {
10485            spans.push((chunk[0], chunk[1]));
10486        }
10487    }
10488    spans
10489}
10490
10491/// Helper function to get the horizontal spans of any shape at a given y-coordinate.
10492/// Returns a list of (`start_x`, `end_x`) tuples.
10493#[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
10494fn get_shape_horizontal_spans(
10495    shape: &ShapeBoundary,
10496    y: f32,
10497    line_height: f32,
10498) -> Vec<(f32, f32)> {
10499    match shape {
10500        ShapeBoundary::Rectangle(rect) => {
10501            // Check for any overlap between the line box [y, y + line_height]
10502            // and the rectangle's vertical span [rect.y, rect.y + rect.height].
10503            let line_start = y;
10504            let line_end = y + line_height;
10505            let rect_start = rect.y;
10506            let rect_end = rect.y + rect.height;
10507
10508            if line_start < rect_end && line_end > rect_start {
10509                vec![(rect.x, rect.x + rect.width)]
10510            } else {
10511                vec![]
10512            }
10513        }
10514        ShapeBoundary::Circle { center, radius } => {
10515            let line_center_y = y + line_height / 2.0;
10516            let dy = (line_center_y - center.y).abs();
10517            if dy <= *radius {
10518                let dx = (radius.powi(2) - dy.powi(2)).sqrt();
10519                vec![(center.x - dx, center.x + dx)]
10520            } else {
10521                vec![]
10522            }
10523        }
10524        ShapeBoundary::Ellipse { center, radii } => {
10525            let line_center_y = y + line_height / 2.0;
10526            let dy = line_center_y - center.y;
10527            if dy.abs() <= radii.height {
10528                // Formula: (x-h)^2/a^2 + (y-k)^2/b^2 = 1
10529                let y_term = dy / radii.height;
10530                let x_term_squared = 1.0 - y_term.powi(2);
10531                if x_term_squared >= 0.0 {
10532                    let dx = radii.width * x_term_squared.sqrt();
10533                    vec![(center.x - dx, center.x + dx)]
10534                } else {
10535                    vec![]
10536                }
10537            } else {
10538                vec![]
10539            }
10540        }
10541        ShapeBoundary::Polygon { points } => {
10542            let segments = polygon_line_intersection(points, y, line_height);
10543            segments
10544                .iter()
10545                .map(|s| (s.start_x, s.start_x + s.width))
10546                .collect()
10547        }
10548        // Scanline intersection for `path()` shapes. `segments` is the flattened
10549        // (Close-terminated, curves pre-sampled) output of `flatten_svg_to_path_segments`;
10550        // intersect each subpath polygon with this scanline under an even-odd fill rule so
10551        // reversed rings (holes) carve out space.
10552        ShapeBoundary::Path { segments } => {
10553            path_segments_line_intersection(segments, y, line_height)
10554        }
10555    }
10556}
10557
10558/// Merges overlapping or adjacent line segments into larger ones.
10559fn merge_segments(mut segments: Vec<LineSegment>) -> Vec<LineSegment> {
10560    if segments.len() <= 1 {
10561        return segments;
10562    }
10563    segments.sort_by(|a, b| a.start_x.partial_cmp(&b.start_x).unwrap());
10564    let mut merged = vec![segments[0]];
10565    for next_seg in segments.iter().skip(1) {
10566        let last = merged.last_mut().unwrap();
10567        if next_seg.start_x <= last.start_x + last.width {
10568            let new_width = (next_seg.start_x + next_seg.width) - last.start_x;
10569            last.width = last.width.max(new_width);
10570        } else {
10571            merged.push(*next_seg);
10572        }
10573    }
10574    merged
10575}
10576
10577/// Computes horizontal line segments where a polygon intersects a scanline at the given y range.
10578#[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
10579fn polygon_line_intersection(
10580    points: &[Point],
10581    y: f32,
10582    line_height: f32,
10583) -> Vec<LineSegment> {
10584    if points.len() < 3 {
10585        return vec![];
10586    }
10587
10588    let line_center_y = y + line_height / 2.0;
10589    let mut intersections = Vec::new();
10590
10591    // Use winding number algorithm for robustness with complex polygons.
10592    for i in 0..points.len() {
10593        let p1 = points[i];
10594        let p2 = points[(i + 1) % points.len()];
10595
10596        // Skip horizontal edges as they don't intersect a horizontal scanline in a meaningful way.
10597        if (p2.y - p1.y).abs() < f32::EPSILON {
10598            continue;
10599        }
10600
10601        // Check if our horizontal scanline at `line_center_y` crosses this polygon edge.
10602        let crosses = (p1.y <= line_center_y && p2.y > line_center_y)
10603            || (p1.y > line_center_y && p2.y <= line_center_y);
10604
10605        if crosses {
10606            // Calculate intersection x-coordinate using linear interpolation.
10607            let t = (line_center_y - p1.y) / (p2.y - p1.y);
10608            let x = p1.x + t * (p2.x - p1.x);
10609            intersections.push(x);
10610        }
10611    }
10612
10613    // Sort intersections by x-coordinate to form spans.
10614    intersections.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
10615
10616    // Build segments from paired intersection points.
10617    let mut segments = Vec::new();
10618    for chunk in intersections.chunks_exact(2) {
10619        let start_x = chunk[0];
10620        let end_x = chunk[1];
10621        if end_x > start_x {
10622            segments.push(LineSegment {
10623                start_x,
10624                width: end_x - start_x,
10625                priority: 0,
10626            });
10627        }
10628    }
10629
10630    segments
10631}
10632
10633// ADDITION: A helper function to get a hyphenator.
10634/// Helper to get a hyphenator for a given language.
10635/// TODO: In a real app, this would be cached.
10636#[cfg(feature = "text_layout_hyphenation")]
10637fn get_hyphenator(language: HyphenationLanguage) -> Result<Standard, LayoutError> {
10638    Standard::from_embedded(language).map_err(|e| LayoutError::HyphenationError(e.to_string()))
10639}
10640
10641/// Stub when hyphenation is disabled - always returns an error
10642#[cfg(not(feature = "text_layout_hyphenation"))]
10643fn get_hyphenator(_language: Language) -> Result<Standard, LayoutError> {
10644    Err(LayoutError::HyphenationError("Hyphenation feature not enabled".to_string()))
10645}
10646
10647// +spec:inline-block:6e7dd9 - Non-tailorable Unicode line breaking controls take precedence over atomic inline rules (CSS-TEXT-3 recent changes, issue 8972)
10648
10649const fn is_break_suppressing_control(ch: char) -> bool {
10650    matches!(ch,
10651        '\u{200D}' | // ZERO WIDTH JOINER
10652        '\u{2060}' | // WORD JOINER
10653        '\u{FEFF}'   // ZERO WIDTH NO-BREAK SPACE
10654    )
10655}
10656
10657const fn is_break_forcing_control(ch: char) -> bool {
10658    matches!(ch,
10659        '\u{200B}' | // ZERO WIDTH SPACE (already handled but included for completeness)
10660        '\u{2028}' | // LINE SEPARATOR
10661        '\u{2029}'   // PARAGRAPH SEPARATOR
10662    )
10663}
10664
10665// +spec:line-breaking:495247 - CJK/syllabic writing systems allow breaks between typographic letter units with varying strictness
10666// §5.2 word-break: determines if a character is CJK ideograph/kana
10667const fn is_cjk_character(ch: char) -> bool {
10668    let cp = ch as u32;
10669    matches!(cp,
10670        // CJK Unified Ideographs
10671        0x4E00..=0x9FFF |
10672        // CJK Unified Ideographs Extension A
10673        0x3400..=0x4DBF |
10674        // CJK Unified Ideographs Extension B
10675        0x20000..=0x2A6DF |
10676        // CJK Compatibility Ideographs
10677        0xF900..=0xFAFF |
10678        // Hiragana
10679        0x3040..=0x309F |
10680        // Katakana
10681        0x30A0..=0x30FF |
10682        // Katakana Phonetic Extensions
10683        0x31F0..=0x31FF |
10684        // CJK Symbols and Punctuation
10685        0x3000..=0x303F |
10686        // Halfwidth and Fullwidth Forms
10687        0xFF00..=0xFFEF |
10688        // Hangul Syllables
10689        0xAC00..=0xD7AF
10690    )
10691}
10692
10693// §5.2 word-break: checks if a cluster contains CJK characters
10694fn is_cjk_cluster(cluster: &ShapedCluster) -> bool {
10695    cluster.text.chars().any(is_cjk_character)
10696}
10697
10698// +spec:line-breaking:e1fc9d - word-break normal/break-all/keep-all break opportunity rules
10699// +spec:line-breaking:73d5fe - word-break break-point determination for CJK and Latin text
10700// +spec:line-breaking:31ef1a - word-break property controls soft wrap opportunities between letters (NU/AL/AI/ID classes as letter units)
10701// +spec:line-breaking:798252 - word-break property affects break opportunities (normal/break-all/keep-all)
10702// +spec:line-breaking:8fed57 - word-break: break-all treats all clusters as break opportunities, keep-all suppresses CJK breaks
10703// +spec:line-breaking:e2b374 - word-break: normal (only at separators) vs break-all (between all letters incl. Ethiopic)
10704// +spec:overflow:53a97f - word-break (normal/break-all/keep-all) and line-break strictness rules
10705// +spec:line-breaking:1c830a - word-break: normal/break-all/keep-all break opportunity rules
10706// §5.2 word-break property: break opportunity logic
10707// +spec:line-breaking:a75147 - word-break property: normal (CJK breaks), break-all (every cluster), keep-all (suppress CJK breaks)
10708// +spec:line-breaking:65ab41 - word-break: normal/break-all/keep-all break opportunity rules
10709// +spec:line-breaking:7eca16 - U+200B ZERO WIDTH SPACE is always a break opportunity, even with keep-all
10710fn is_break_opportunity_with_word_break(item: &ShapedItem, word_break: WordBreak, hyphens: Hyphens) -> bool {
10711    // No-break spaces (UAX#14 class GL/WJ) are word separators for word-spacing
10712    // purposes but must NOT offer a soft-wrap opportunity. This is the segmentation
10713    // path used by BreakCursor::peek_next_unit, so it must suppress them the same way
10714    // the dedicated is_break_opportunity() does; otherwise "10\u{00A0}km" wrongly wraps.
10715    if let ShapedItem::Cluster(c) = item {
10716        if c.text
10717            .chars()
10718            .any(|ch| matches!(ch, '\u{00A0}' | '\u{202F}' | '\u{2060}' | '\u{FEFF}'))
10719        {
10720            return false;
10721        }
10722    }
10723    // Break after spaces or explicit break items (always, regardless of word-break).
10724    if is_word_separator(item) {
10725        return true;
10726    }
10727    if let ShapedItem::Break { .. } = item {
10728        return true;
10729    }
10730    // +spec:line-breaking:432d5b - hyphens property controls soft wrap opportunities via hyphenation
10731    // +spec:line-breaking:5a32a1 - soft hyphen (U+00AD) creates break opportunity; glyph styled per surrounding text properties
10732    // U+200B ZERO WIDTH SPACE is always a soft wrap opportunity regardless of word-break.
10733    // This allows authors to mark explicit wrap points (e.g. with <wbr> or &#x200B;)
10734    // even when using word-break: keep-all to suppress other breaks.
10735    if is_zero_width_space(item) {
10736        return true;
10737    }
10738    // only when hyphens != none. With hyphens:none, soft hyphens do not create break points.
10739    if hyphens != Hyphens::None {
10740        if let ShapedItem::Cluster(c) = item {
10741            if c.text.starts_with('\u{00AD}') {
10742                return true;
10743            }
10744        }
10745    }
10746
10747    // +spec:line-breaking:05e09a - U+002D HYPHEN-MINUS / U+2010 HYPHEN always create a
10748    // soft-wrap opportunity AFTER them (UAX#14 class HY/BA), independent of the hyphens
10749    // property (they are NOT hyphenation opportunities — no extra glyph is inserted).
10750    // U+002F SOLIDUS (UAX#14 class SY) likewise offers a break AFTER it (URLs/paths),
10751    // matching browser practice. Mirrors is_break_opportunity(); this predicate drives
10752    // the greedy BreakCursor path, which previously never broke after a plain hyphen/slash.
10753    if let ShapedItem::Cluster(c) = item {
10754        if c.text.ends_with('\u{002D}') || c.text.ends_with('\u{2010}') || c.text.ends_with('\u{002F}') {
10755            return true;
10756        }
10757    }
10758
10759    // +spec:line-breaking:2bbda0 - word-break does not affect soft wrap opportunities around punctuation
10760    match word_break {
10761        WordBreak::Normal => {
10762            // CJK characters are implicit break opportunities in normal mode.
10763            if let ShapedItem::Cluster(c) = item {
10764                if is_cjk_cluster(c) {
10765                    return true;
10766                }
10767            }
10768            false
10769        }
10770        WordBreak::BreakAll => {
10771            // Every typographic letter unit is a break opportunity.
10772            if let ShapedItem::Cluster(_) = item {
10773                return true;
10774            }
10775            false
10776        }
10777        WordBreak::KeepAll => {
10778            // +spec:line-breaking:aa3044 - keep-all suppresses CJK (incl. Korean) inter-character breaks
10779            // Only break at spaces/hyphens (already handled above).
10780            false
10781        }
10782    }
10783}
10784
10785// +spec:line-breaking:db0289 - line-break strictness: anywhere allows soft wrap around every typographic character unit
10786// +spec:line-breaking:7d242b - line-break strictness levels: loose/normal/strict/anywhere with CJK punctuation rules
10787// +spec:line-breaking:67bfe8 - line-break strictness (auto/loose/normal/strict/anywhere) controls
10788// CSS Text Level 3 §5.3: Determines whether a break opportunity before a character is
10789// allowed based on the line-break strictness level. The spec defines:
10790// - strict: forbids breaks before small kana (class CJ), CJK hyphens, and certain punctuation
10791// - normal: allows breaks before small kana (CJ); allows CJK hyphen breaks for CJK writing systems
10792// - loose: additionally allows breaks before hyphens U+2010/U+2013 after ID-class chars
10793// - anywhere: allows soft wrap around every typographic character unit
10794#[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
10795const fn is_cjk_break_allowed_by_strictness(
10796    ch: char,
10797    _prev_ch: Option<char>,
10798    strictness: LineBreakStrictness,
10799) -> bool {
10800    match strictness {
10801        LineBreakStrictness::Anywhere => true,
10802        LineBreakStrictness::Loose => {
10803            // Loose allows breaks before hyphens U+2010, U+2013 when preceded by ID-class chars
10804            // Also allows breaks before small kana (CJ class) and CJK hyphens
10805            true
10806        }
10807        LineBreakStrictness::Normal | LineBreakStrictness::Auto => {
10808            // Normal forbids breaks before hyphens U+2010/U+2013 for non-CJK text
10809            // but allows breaks before small kana (CJ) and CJK hyphen-like chars
10810            // (〜 U+301C, ゠ U+30A0) for CJK writing systems
10811            match ch {
10812                '\u{2010}' | '\u{2013}' => false, // hyphens forbidden in normal
10813                _ => true,
10814            }
10815        }
10816        LineBreakStrictness::Strict => {
10817            // Strict forbids breaks before:
10818            // - Small kana and prolonged sound mark (Unicode line break class CJ)
10819            // - CJK hyphen-like characters: 〜 U+301C, ゠ U+30A0
10820            // - Hyphens: ‐ U+2010, – U+2013
10821            match ch {
10822                '\u{301C}' | '\u{30A0}' => false, // CJK hyphen-like
10823                '\u{2010}' | '\u{2013}' => false,  // hyphens
10824                c if is_small_kana(c) => false,
10825                _ => true,
10826            }
10827        }
10828    }
10829}
10830
10831/// Returns true if the character is a Japanese small kana or Katakana-Hiragana prolonged sound mark
10832/// (Unicode line break class CJ). These are forbidden break points in strict line breaking.
10833const fn is_small_kana(ch: char) -> bool {
10834    matches!(ch,
10835        '\u{3041}' | // ぁ HIRAGANA LETTER SMALL A
10836        '\u{3043}' | // ぃ HIRAGANA LETTER SMALL I
10837        '\u{3045}' | // ぅ HIRAGANA LETTER SMALL U
10838        '\u{3047}' | // ぇ HIRAGANA LETTER SMALL E
10839        '\u{3049}' | // ぉ HIRAGANA LETTER SMALL O
10840        '\u{3063}' | // っ HIRAGANA LETTER SMALL TU
10841        '\u{3083}' | // ゃ HIRAGANA LETTER SMALL YA
10842        '\u{3085}' | // ゅ HIRAGANA LETTER SMALL YU
10843        '\u{3087}' | // ょ HIRAGANA LETTER SMALL YO
10844        '\u{308E}' | // ゎ HIRAGANA LETTER SMALL WA
10845        '\u{3095}' | // ゕ HIRAGANA LETTER SMALL KA
10846        '\u{3096}' | // ゖ HIRAGANA LETTER SMALL KE
10847        '\u{30A1}' | // ァ KATAKANA LETTER SMALL A
10848        '\u{30A3}' | // ィ KATAKANA LETTER SMALL I
10849        '\u{30A5}' | // ゥ KATAKANA LETTER SMALL U
10850        '\u{30A7}' | // ェ KATAKANA LETTER SMALL E
10851        '\u{30A9}' | // ォ KATAKANA LETTER SMALL O
10852        '\u{30C3}' | // ッ KATAKANA LETTER SMALL TU
10853        '\u{30E3}' | // ャ KATAKANA LETTER SMALL YA
10854        '\u{30E5}' | // ュ KATAKANA LETTER SMALL YU
10855        '\u{30E7}' | // ョ KATAKANA LETTER SMALL YO
10856        '\u{30EE}' | // ヮ KATAKANA LETTER SMALL WA
10857        '\u{30F5}' | // ヵ KATAKANA LETTER SMALL KA
10858        '\u{30F6}' | // ヶ KATAKANA LETTER SMALL KE
10859        '\u{30FC}'   // ー KATAKANA-HIRAGANA PROLONGED SOUND MARK
10860    )
10861}
10862
10863// for every typographic character unit, disregarding GL/WJ/ZWJ line breaking classes
10864// replaced element or other atomic inline for web-compat
10865fn is_break_opportunity(item: &ShapedItem) -> bool {
10866    // Per CSS Text 3 §5.1: "there is a soft wrap opportunity before and
10867    // after each replaced element or other atomic inline"
10868    if matches!(item, ShapedItem::Object { .. } | ShapedItem::CombinedBlock { .. }) {
10869        return true;
10870    }
10871    // over atomic inline rules: break-forcing controls (ZWSP, LS, PS) create break opportunities
10872    // even adjacent to atomic inlines, while break-suppressing controls (WJ, ZWJ, ZWNBSP)
10873    // prevent breaks
10874    if let ShapedItem::Cluster(c) = item {
10875        // ZW (zero-width space U+200B) is always a break opportunity
10876        if c.text.contains('\u{200B}') {
10877            return true;
10878        }
10879        // Break-forcing Unicode controls (LS, PS) create break opportunities
10880        if c.text.chars().any(is_break_forcing_control) {
10881            return true;
10882        }
10883        // WJ (word joiner U+2060), ZWJ (U+200D), and GL (NBSP U+00A0) suppress breaks
10884        if c.text.chars().any(|ch| matches!(ch, '\u{2060}' | '\u{200D}' | '\u{00A0}')) {
10885            return false;
10886        }
10887        // +spec:line-breaking:05e09a - U+002D/U+2010 always create soft wrap opportunities regardless of hyphens property
10888        // are always visible and create a soft wrap opportunity after them, but are NOT
10889        // hyphenation opportunities (no extra glyph is inserted at the break).
10890        if c.text.ends_with('\u{002D}') || c.text.ends_with('\u{2010}') {
10891            return true;
10892        }
10893    }
10894    is_break_opportunity_with_word_break(item, WordBreak::Normal, Hyphens::Manual)
10895}
10896
10897// A cursor to manage the state of the line breaking process.
10898// This allows us to handle items that are partially consumed by hyphenation.
10899#[derive(Debug)]
10900pub struct BreakCursor<'a> {
10901    /// A reference to the complete list of shaped items.
10902    pub items: &'a [ShapedItem],
10903    /// The index of the next *full* item to be processed from the `items` slice.
10904    pub next_item_index: usize,
10905    /// The remainder of an item that was split by hyphenation on the previous line.
10906    /// This will be the very first piece of content considered for the next line.
10907    pub partial_remainder: Vec<ShapedItem>,
10908    // §5.2 word-break property stored on cursor
10909    pub word_break: WordBreak,
10910    pub hyphens: Hyphens,
10911    pub line_break: LineBreakStrictness,
10912}
10913
10914impl<'a> BreakCursor<'a> {
10915    #[must_use] pub fn new(items: &'a [ShapedItem]) -> Self {
10916        Self {
10917            items,
10918            next_item_index: 0,
10919            partial_remainder: Vec::new(),
10920            word_break: WordBreak::Normal,
10921            hyphens: Hyphens::default(),
10922            line_break: LineBreakStrictness::default(),
10923        }
10924    }
10925
10926    #[must_use] pub fn with_word_break(items: &'a [ShapedItem], word_break: WordBreak) -> Self {
10927        Self {
10928            items,
10929            next_item_index: 0,
10930            partial_remainder: Vec::new(),
10931            word_break,
10932            hyphens: Hyphens::default(),
10933            line_break: LineBreakStrictness::default(),
10934        }
10935    }
10936
10937    /// Checks if the cursor is at the very beginning of the content stream.
10938    #[must_use] pub const fn is_at_start(&self) -> bool {
10939        self.next_item_index == 0 && self.partial_remainder.is_empty()
10940    }
10941
10942    /// Consumes the cursor and returns all remaining items as a `Vec`.
10943    pub fn drain_remaining(&mut self) -> Vec<ShapedItem> {
10944        let mut remaining = std::mem::take(&mut self.partial_remainder);
10945        if self.next_item_index < self.items.len() {
10946            remaining.extend_from_slice(&self.items[self.next_item_index..]);
10947        }
10948        self.next_item_index = self.items.len();
10949        remaining
10950    }
10951
10952    /// Checks if all content, including any partial remainders, has been processed.
10953    #[must_use] pub const fn is_done(&self) -> bool {
10954        self.next_item_index >= self.items.len() && self.partial_remainder.is_empty()
10955    }
10956
10957    /// Consumes a number of items from the cursor's stream.
10958    pub fn consume(&mut self, count: usize) {
10959        if count == 0 {
10960            return;
10961        }
10962
10963        let remainder_len = self.partial_remainder.len();
10964        if count <= remainder_len {
10965            // Consuming only from the remainder.
10966            self.partial_remainder.drain(..count);
10967        } else {
10968            // Consuming all of the remainder and some from the main list.
10969            let from_main_list = count - remainder_len;
10970            self.partial_remainder.clear();
10971            self.next_item_index += from_main_list;
10972        }
10973    }
10974
10975    /// Looks ahead and returns the next "unbreakable" unit of content.
10976    /// This is typically a word (a series of non-space clusters) followed by a
10977    /// space, or just a single space if that's next.
10978    /// The definition of "unbreakable unit" depends on the word-break property.
10979    // a single typographic character unit (every character is a soft wrap opportunity), including
10980    // punctuation and preserved white spaces; currently handled via peek_next_single_item
10981    pub fn peek_next_unit(&self) -> Vec<ShapedItem> {
10982        let mut unit = Vec::new();
10983        let mut source_items = self.partial_remainder.clone();
10984        source_items.extend_from_slice(&self.items[self.next_item_index..]);
10985
10986        if source_items.is_empty() {
10987            return unit;
10988        }
10989
10990        // If the first item is a break opportunity (like a space), it's a unit on its own.
10991        if is_break_opportunity_with_word_break(&source_items[0], self.word_break, self.hyphens) {
10992            unit.push(source_items[0].clone());
10993            return unit;
10994        }
10995
10996        // Otherwise, collect all items until the next break opportunity.
10997        // For break-all: each cluster is its own unit.
10998        // For keep-all: CJK sequences are NOT break opportunities.
10999        // For normal: CJK characters are individual break opportunities.
11000        // glue items together: if the last cluster ends with a break-suppressing control,
11001        // the next item cannot be separated from it.
11002        let mut suppress_next_break = false;
11003        for (i, item) in source_items.iter().enumerate() {
11004            // Also suppress break if this item starts with a break-suppressing control
11005            // (WJ/ZWJ/ZWNBSP suppress breaks on both sides per Unicode line breaking)
11006            let starts_with_suppress = if let ShapedItem::Cluster(c) = item {
11007                c.text.chars().next().is_some_and(is_break_suppressing_control)
11008            } else {
11009                false
11010            };
11011            // If the item is a CJK cluster, check if the break is allowed by strictness
11012            let cjk_strictness_suppressed = if let ShapedItem::Cluster(c) = item {
11013                c.text.chars().next().is_some_and(|ch| {
11014                    !is_cjk_break_allowed_by_strictness(ch, None, self.line_break)
11015                })
11016            } else {
11017                false
11018            };
11019            if i > 0 && !suppress_next_break && !starts_with_suppress && !cjk_strictness_suppressed && is_break_opportunity_with_word_break(item, self.word_break, self.hyphens) {
11020                break;
11021            }
11022            suppress_next_break = false;
11023            unit.push(item.clone());
11024
11025            // Check if this item ends with a break-suppressing control character
11026            if let ShapedItem::Cluster(c) = item {
11027                if let Some(last_ch) = c.text.chars().last() {
11028                    if is_break_suppressing_control(last_ch) {
11029                        suppress_next_break = true;
11030                    }
11031                }
11032            }
11033
11034            // For break-all, each non-space cluster is a unit on its own
11035            if self.word_break == WordBreak::BreakAll {
11036                if let ShapedItem::Cluster(_) = item {
11037                    break;
11038                }
11039            }
11040        }
11041        unit
11042    }
11043
11044    #[must_use] pub fn peek_next_single_item(&self) -> Vec<ShapedItem> {
11045        if !self.partial_remainder.is_empty() {
11046            return vec![self.partial_remainder[0].clone()];
11047        }
11048        if self.next_item_index < self.items.len() {
11049            return vec![self.items[self.next_item_index].clone()];
11050        }
11051        Vec::new()
11052    }
11053}
11054
11055// A structured result from a hyphenation attempt.
11056struct HyphenationResult {
11057    /// The items that fit on the current line, including the new hyphen.
11058    line_part: Vec<ShapedItem>,
11059    /// The remainder of the split item to be carried over to the next line.
11060    remainder_part: Vec<ShapedItem>,
11061}
11062
11063fn perform_bidi_analysis<'a>(
11064    styled_runs: &'a [TextRunInfo<'_>],
11065    full_text: &'a str,
11066    force_lang: Option<Language>,
11067) -> (Vec<VisualRun<'a>>, BidiDirection) {
11068    if full_text.is_empty() {
11069        return (Vec::new(), BidiDirection::Ltr);
11070    }
11071
11072    let bidi_info = BidiInfo::new(full_text, None);
11073    let para = &bidi_info.paragraphs[0];
11074    let base_direction = if para.level.is_rtl() {
11075        BidiDirection::Rtl
11076    } else {
11077        BidiDirection::Ltr
11078    };
11079
11080    // Create a map from each byte index to its original styled run.
11081    let mut byte_to_run_index: Vec<usize> = vec![0; full_text.len()];
11082    for (run_idx, run) in styled_runs.iter().enumerate() {
11083        let start = run.logical_start;
11084        let end = start + run.text.len();
11085        for slot in &mut byte_to_run_index[start..end] {
11086            *slot = run_idx;
11087        }
11088    }
11089
11090    let mut final_visual_runs = Vec::new();
11091    let (levels, visual_run_ranges) = bidi_info.visual_runs(para, para.range.clone());
11092
11093    for range in visual_run_ranges {
11094        let bidi_level = levels[range.start];
11095        let mut sub_run_start = range.start;
11096
11097        // Iterate through the bytes of the visual run to detect style changes.
11098        for i in (range.start + 1)..range.end {
11099            if byte_to_run_index[i] != byte_to_run_index[sub_run_start] {
11100                // Style boundary found. Finalize the previous sub-run.
11101                let original_run_idx = byte_to_run_index[sub_run_start];
11102                let script = crate::text3::script::detect_script(&full_text[sub_run_start..i])
11103                    .unwrap_or(Script::Latin);
11104                final_visual_runs.push(VisualRun {
11105                    text_slice: &full_text[sub_run_start..i],
11106                    style: styled_runs[original_run_idx].style.clone(),
11107                    logical_start_byte: sub_run_start,
11108                    bidi_level: BidiLevel::new(bidi_level.number()),
11109                    language: force_lang.unwrap_or_else(|| {
11110                        script_to_language(
11111                            script,
11112                            &full_text[sub_run_start..i],
11113                        )
11114                    }),
11115                    script,
11116                });
11117                // Start a new sub-run.
11118                sub_run_start = i;
11119            }
11120        }
11121
11122        // Add the last sub-run (or the only one if no style change occurred).
11123        let original_run_idx = byte_to_run_index[sub_run_start];
11124        let script = crate::text3::script::detect_script(&full_text[sub_run_start..range.end])
11125            .unwrap_or(Script::Latin);
11126
11127        final_visual_runs.push(VisualRun {
11128            text_slice: &full_text[sub_run_start..range.end],
11129            style: styled_runs[original_run_idx].style.clone(),
11130            logical_start_byte: sub_run_start,
11131            bidi_level: BidiLevel::new(bidi_level.number()),
11132            script,
11133            language: force_lang.unwrap_or_else(|| {
11134                script_to_language(
11135                    script,
11136                    &full_text[sub_run_start..range.end],
11137                )
11138            }),
11139        });
11140    }
11141
11142    (final_visual_runs, base_direction)
11143}
11144
11145const fn get_justification_priority(class: CharacterClass) -> u8 {
11146    match class {
11147        CharacterClass::Space => 0,
11148        CharacterClass::Punctuation => 64,
11149        CharacterClass::Ideograph => 128,
11150        CharacterClass::Letter => 192,
11151        CharacterClass::Symbol => 224,
11152        CharacterClass::Combining => 255,
11153    }
11154}
11155
11156#[cfg(test)]
11157mod shape_outside_and_ruby_tests {
11158    use super::*;
11159    use azul_css::shape::{CssShape, ShapePath};
11160
11161    fn path_shape(d: &str) -> CssShape {
11162        CssShape::Path(ShapePath {
11163            data: d.into(),
11164        })
11165    }
11166
11167    // --- shape-outside: path() ----------------------------------------------
11168
11169    #[test]
11170    fn css_path_shape_builds_path_boundary_not_rect_fallback() {
11171        // A right triangle (0,0)-(100,0)-(0,100).
11172        let shape = path_shape("M 0 0 L 100 0 L 0 100 Z");
11173        let rbox = Rect { x: 0.0, y: 0.0, width: 100.0, height: 100.0 };
11174        let boundary = ShapeBoundary::from_css_shape(&shape, rbox, &mut None);
11175        match boundary {
11176            ShapeBoundary::Path { segments } => {
11177                assert!(!segments.is_empty(), "path() must flatten to real segments");
11178                assert!(matches!(segments[0], PathSegment::MoveTo(_)));
11179                assert!(segments.iter().any(|s| matches!(s, PathSegment::Close)));
11180            }
11181            other => panic!("expected ShapeBoundary::Path, got {other:?}"),
11182        }
11183    }
11184
11185    #[test]
11186    fn empty_or_garbage_path_falls_back_to_rectangle() {
11187        let rbox = Rect { x: 0.0, y: 0.0, width: 50.0, height: 50.0 };
11188        let boundary = ShapeBoundary::from_css_shape(&path_shape("   "), rbox, &mut None);
11189        assert!(matches!(boundary, ShapeBoundary::Rectangle(_)),
11190            "unparseable path() should fall back to the reference rectangle");
11191    }
11192
11193    #[test]
11194    fn path_triangle_narrows_line_box_per_scanline() {
11195        // Right triangle with the hypotenuse running (100,0) -> (0,100).
11196        // At scanline y, the shape spans x in [0, 100 - y]. So the available band
11197        // must NARROW as y increases — the proof that real path geometry (not a
11198        // full-width rect) drives the per-line exclusion.
11199        let shape = path_shape("M 0 0 L 100 0 L 0 100 Z");
11200        let rbox = Rect { x: 0.0, y: 0.0, width: 100.0, height: 100.0 };
11201        let boundary = ShapeBoundary::from_css_shape(&shape, rbox, &mut None);
11202
11203        let spans_top = get_shape_horizontal_spans(&boundary, 10.0, 1.0);
11204        let spans_bot = get_shape_horizontal_spans(&boundary, 80.0, 1.0);
11205
11206        assert_eq!(spans_top.len(), 1, "single span expected near the top");
11207        assert_eq!(spans_bot.len(), 1, "single span expected near the bottom");
11208
11209        let width_top = spans_top[0].1 - spans_top[0].0;
11210        let width_bot = spans_bot[0].1 - spans_bot[0].0;
11211
11212        // Geometry check: width ~= 100 - y (line center is y + 0.5).
11213        assert!((width_top - 89.5).abs() < 1.5, "top width {width_top} != ~89.5");
11214        assert!((width_bot - 19.5).abs() < 1.5, "bottom width {width_bot} != ~19.5");
11215        assert!(width_top > width_bot,
11216            "path() exclusion band must narrow with y ({width_top} !> {width_bot})");
11217
11218        // And it must differ from a plain full-width rectangle (which would be 0..100
11219        // at every scanline) — i.e. this is not the old rect/empty stub.
11220        assert!(width_bot < 50.0, "rect fallback would give full width here");
11221    }
11222
11223    #[test]
11224    fn path_with_hole_carves_out_interior_via_even_odd() {
11225        // Outer square 0..100 with an inner reversed square 30..70 (a hole). At a
11226        // scanline through the hole, even-odd fill yields two spans straddling the hole.
11227        let shape = path_shape(
11228            "M 0 0 L 100 0 L 100 100 L 0 100 Z \
11229             M 30 30 L 30 70 L 70 70 L 70 30 Z",
11230        );
11231        let rbox = Rect { x: 0.0, y: 0.0, width: 100.0, height: 100.0 };
11232        let boundary = ShapeBoundary::from_css_shape(&shape, rbox, &mut None);
11233        let spans = get_shape_horizontal_spans(&boundary, 50.0, 1.0);
11234        assert_eq!(spans.len(), 2, "hole should split the band into two spans: {spans:?}");
11235    }
11236
11237    // --- ruby ----------------------------------------------------------------
11238
11239    #[test]
11240    #[allow(clippy::float_cmp)] // exact, representable expected values
11241    fn ruby_annotation_font_scale_is_real_not_06_fudge() {
11242        // The annotation is sized at the used font-size of the ruby-text run, which the
11243        // UA stylesheet sets to 50% of the base — NOT a 0.6 per-character fudge.
11244        let base_font_size = 20.0_f32;
11245        let annotation_font_size = base_font_size * RUBY_ANNOTATION_FONT_SCALE;
11246        assert_eq!(annotation_font_size, 10.0);
11247        assert!((RUBY_ANNOTATION_FONT_SCALE - 0.6).abs() > f32::EPSILON,
11248            "annotation scale must not be the old 0.6 magic ratio");
11249    }
11250
11251    #[test]
11252    #[allow(clippy::float_cmp)] // exact, representable expected values
11253    fn ruby_box_reserves_max_width_and_stacks_annotation_above_base() {
11254        // Wider base, narrower annotation: reserved inline-size = base width.
11255        let (w, h) = ruby_reserved_box(80.0, 30.0, 24.0, 12.0);
11256        assert_eq!(w, 80.0, "reserved width is the wider of base/annotation");
11257        // Block-size stacks the annotation line above the base line => base reserves
11258        // vertical space for the annotation.
11259        assert_eq!(h, 36.0, "block-size = base line + annotation line");
11260        assert!(h > 24.0, "ruby box must reserve extra vertical space for the annotation");
11261
11262        // Narrower base, wider annotation: reserved inline-size = annotation width.
11263        let (w2, _) = ruby_reserved_box(20.0, 50.0, 24.0, 12.0);
11264        assert_eq!(w2, 50.0, "a long annotation widens the reserved box");
11265    }
11266}