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/// Glyph storage for a single shaped cluster. Inline one glyph (the
49/// common case for Latin text), spill to heap for ligatures / combining
50/// marks / multi-glyph clusters. The `union` feature of smallvec packs
51/// the inline buffer and the heap pointer into the same bytes, so sizeof
52/// stays `sizeof(ShapedGlyph) + 2*usize` regardless of inline/heap state.
53pub type ShapedGlyphVec = SmallVec<[ShapedGlyph; 1]>;
54
55/// CSS `line-height` value.
56///
57/// `Normal` defers resolution to the point where font metrics are available,
58/// computing `(ascent + |descent| + lineGap) / upem * fontSize`.
59/// `Px` is an already-resolved pixel value from an explicit CSS declaration
60/// (e.g. `line-height: 1.5` → `Px(fontSize * 1.5)`).
61#[derive(Debug, Clone, Copy)]
62pub enum LineHeight {
63    /// `line-height: normal` — resolve from font metrics at layout time
64    Normal,
65    /// Pre-resolved pixel value (from CSS `line-height: <number|length|percentage>`)
66    Px(f32),
67}
68
69impl Default for LineHeight {
70    fn default() -> Self {
71        LineHeight::Normal
72    }
73}
74
75impl LineHeight {
76    /// Resolve to a pixel value, using font metrics when `Normal`.
77    ///
78    /// `ascent`, `descent` (negative in OpenType convention), `line_gap` are in font units.
79    /// `font_size_px` and `units_per_em` are used to scale.
80    pub fn resolve(&self, font_size_px: f32, ascent: f32, descent: f32, line_gap: f32, units_per_em: u16) -> f32 {
81        match self {
82            LineHeight::Px(px) => *px,
83            LineHeight::Normal => {
84                if units_per_em == 0 {
85                    return font_size_px * 1.2; // fallback
86                }
87                let scale = font_size_px / units_per_em as f32;
88                (ascent - descent + line_gap) * scale
89            }
90        }
91    }
92
93    /// Resolve using a `LayoutFontMetrics` struct for convenience.
94    pub fn resolve_with_metrics(&self, font_size_px: f32, metrics: &LayoutFontMetrics) -> f32 {
95        self.resolve(font_size_px, metrics.ascent, metrics.descent, metrics.line_gap, metrics.units_per_em)
96    }
97}
98
99impl PartialEq for LineHeight {
100    fn eq(&self, other: &Self) -> bool {
101        match (self, other) {
102            (LineHeight::Normal, LineHeight::Normal) => true,
103            (LineHeight::Px(a), LineHeight::Px(b)) => a.to_bits() == b.to_bits(),
104            _ => false,
105        }
106    }
107}
108
109impl Eq for LineHeight {}
110
111impl Hash for LineHeight {
112    fn hash<H: Hasher>(&self, state: &mut H) {
113        std::mem::discriminant(self).hash(state);
114        if let LineHeight::Px(v) = self {
115            v.to_bits().hash(state);
116        }
117    }
118}
119
120// Stub type when hyphenation is disabled
121#[cfg(not(feature = "text_layout_hyphenation"))]
122pub struct Standard;
123
124#[cfg(not(feature = "text_layout_hyphenation"))]
125impl Standard {
126    /// Stub hyphenate method that returns no breaks
127    pub fn hyphenate<'a>(&'a self, _word: &'a str) -> StubHyphenationBreaks {
128        StubHyphenationBreaks { breaks: Vec::new() }
129    }
130}
131
132/// Result of hyphenation (stub when feature is disabled)
133#[cfg(not(feature = "text_layout_hyphenation"))]
134pub struct StubHyphenationBreaks {
135    pub breaks: Vec<usize>,
136}
137
138// Always import Language from script module
139use crate::text3::script::{script_to_language, Language, Script};
140
141/// Available space for layout, similar to Taffy's AvailableSpace.
142///
143/// This type explicitly represents the three possible states for available space:
144///
145/// - `Definite(f32)`: A specific pixel width is available
146/// - `MinContent`: Layout should use minimum content width (shrink-wrap)
147/// - `MaxContent`: Layout should use maximum content width (no line breaks unless necessary)
148///
149/// This is critical for proper handling of intrinsic sizing in Flexbox/Grid
150/// where the available space may be indefinite during the measure phase.
151#[derive(Debug, Clone, Copy, PartialEq)]
152pub enum AvailableSpace {
153    /// A specific amount of space is available (in pixels).
154    /// Must be >= 0.  A value of 0.0 means "genuinely zero-width container"
155    /// (e.g. `width: 0px`), NOT "unresolved".
156    Definite(f32),
157    /// The node should be laid out under a min-content constraint
158    MinContent,
159    /// The node should be laid out under a max-content constraint.
160    /// This is the correct default: "lay out to natural width, no constraint".
161    MaxContent,
162}
163
164impl Default for AvailableSpace {
165    /// Default is `MaxContent` — the absence of a width constraint.
166    /// Never `Definite(0.0)`, which would make every word overflow.
167    fn default() -> Self {
168        AvailableSpace::MaxContent
169    }
170}
171
172impl AvailableSpace {
173    /// Returns true if this is a definite (finite, known) amount of space
174    pub fn is_definite(&self) -> bool {
175        matches!(self, AvailableSpace::Definite(_))
176    }
177
178    /// Returns true if this is an indefinite (min-content or max-content) constraint
179    pub fn is_indefinite(&self) -> bool {
180        !self.is_definite()
181    }
182
183    /// Returns the definite value if available, or a fallback for indefinite constraints
184    pub fn unwrap_or(self, fallback: f32) -> f32 {
185        match self {
186            AvailableSpace::Definite(v) => v,
187            _ => fallback,
188        }
189    }
190
191    /// Returns the definite value, or a large value for both min-content and max-content.
192    /// 
193    /// For intrinsic sizing, we use a large value to let text lay out fully,
194    /// then measure the result. The distinction between min/max-content is handled
195    /// by the line breaking algorithm, not by constraining the available width.
196    pub fn to_f32_for_layout(self) -> f32 {
197        match self {
198            AvailableSpace::Definite(v) => v,
199            AvailableSpace::MinContent => f32::MAX / 2.0,
200            AvailableSpace::MaxContent => f32::MAX / 2.0,
201        }
202    }
203
204    /// Create from an f32 value, recognizing special sentinel values.
205    ///
206    /// This function provides backwards compatibility with code that uses f32 for constraints:
207    /// - `f32::INFINITY` or `f32::MAX` → `MaxContent` (no line wrapping)
208    /// - `0.0` → `MinContent` (maximum line wrapping, return longest word width)
209    /// - Other values → `Definite(value)`
210    ///
211    /// Note: Using sentinel values like 0.0 for MinContent is fragile. Prefer using
212    /// `AvailableSpace::MinContent` directly when possible.
213    pub fn from_f32(value: f32) -> Self {
214        if value.is_infinite() || value >= f32::MAX / 2.0 {
215            // Treat very large values (including f32::MAX) as MaxContent
216            AvailableSpace::MaxContent
217        } else if value <= 0.0 {
218            // Treat zero or negative as MinContent (shrink-wrap)
219            AvailableSpace::MinContent
220        } else {
221            AvailableSpace::Definite(value)
222        }
223    }
224}
225
226impl Hash for AvailableSpace {
227    fn hash<H: Hasher>(&self, state: &mut H) {
228        std::mem::discriminant(self).hash(state);
229        if let AvailableSpace::Definite(v) = self {
230            (v.round() as usize).hash(state);
231        }
232    }
233}
234
235// Re-export traits for backwards compatibility
236pub use crate::font_traits::{ParsedFontTrait, ShallowClone};
237
238// --- Core Data Structures for the New Architecture ---
239
240/// Key for caching font chains - based only on CSS properties, not text content
241#[derive(Debug, Clone, PartialEq, Eq, Hash)]
242pub struct FontChainKey {
243    pub font_families: Vec<String>,
244    pub weight: FcWeight,
245    pub italic: bool,
246    pub oblique: bool,
247}
248
249/// Either a FontChainKey (resolved via fontconfig) or a direct FontRef hash.
250/// 
251/// This enum cleanly separates:
252/// - `Chain`: Fonts resolved through fontconfig with fallback support
253/// - `Ref`: Direct FontRef that bypasses fontconfig entirely (e.g., embedded icon fonts)
254#[derive(Debug, Clone, PartialEq, Eq, Hash)]
255pub enum FontChainKeyOrRef {
256    /// Regular font chain resolved via fontconfig
257    Chain(FontChainKey),
258    /// Direct FontRef identified by pointer address (covers entire Unicode range, no fallbacks)
259    Ref(usize),
260}
261
262impl FontChainKeyOrRef {
263    /// Create from a FontStack enum
264    pub fn from_font_stack(font_stack: &FontStack) -> Self {
265        match font_stack {
266            FontStack::Stack(selectors) => FontChainKeyOrRef::Chain(FontChainKey::from_selectors(selectors)),
267            FontStack::Ref(font_ref) => FontChainKeyOrRef::Ref(font_ref.parsed as usize),
268        }
269    }
270    
271    /// Returns true if this is a direct FontRef
272    pub fn is_ref(&self) -> bool {
273        matches!(self, FontChainKeyOrRef::Ref(_))
274    }
275    
276    /// Returns the FontRef pointer if this is a Ref variant
277    pub fn as_ref_ptr(&self) -> Option<usize> {
278        match self {
279            FontChainKeyOrRef::Ref(ptr) => Some(*ptr),
280            _ => None,
281        }
282    }
283    
284    /// Returns the FontChainKey if this is a Chain variant
285    pub fn as_chain(&self) -> Option<&FontChainKey> {
286        match self {
287            FontChainKeyOrRef::Chain(key) => Some(key),
288            _ => None,
289        }
290    }
291}
292
293impl FontChainKey {
294    /// Create a FontChainKey from a slice of font selectors
295    pub fn from_selectors(font_stack: &[FontSelector]) -> Self {
296        let font_families: Vec<String> = font_stack
297            .iter()
298            .map(|s| s.family.clone())
299            .filter(|f| !f.is_empty())
300            .collect();
301
302        let font_families = if font_families.is_empty() {
303            vec!["serif".to_string()]
304        } else {
305            font_families
306        };
307
308        let weight = font_stack
309            .first()
310            .map(|s| s.weight)
311            .unwrap_or(FcWeight::Normal);
312        let is_italic = font_stack
313            .first()
314            .map(|s| s.style == FontStyle::Italic)
315            .unwrap_or(false);
316        let is_oblique = font_stack
317            .first()
318            .map(|s| s.style == FontStyle::Oblique)
319            .unwrap_or(false);
320
321        FontChainKey {
322            font_families,
323            weight,
324            italic: is_italic,
325            oblique: is_oblique,
326        }
327    }
328}
329
330/// A map of pre-loaded fonts, keyed by FontId (from rust-fontconfig)
331///
332/// This is passed to the shaper - no font loading happens during shaping
333/// The fonts are loaded BEFORE layout based on the font chains and text content.
334///
335/// Provides both FontId and hash-based lookup for efficient glyph operations.
336#[derive(Debug, Clone)]
337pub struct LoadedFonts<T> {
338    /// Primary storage: FontId -> Font
339    pub fonts: HashMap<FontId, T>,
340    /// Reverse index: font_hash -> FontId for fast hash-based lookups
341    hash_to_id: HashMap<u64, FontId>,
342}
343
344impl<T: ParsedFontTrait> LoadedFonts<T> {
345    pub fn new() -> Self {
346        Self {
347            fonts: HashMap::new(),
348            hash_to_id: HashMap::new(),
349        }
350    }
351
352    /// Insert a font with its FontId
353    pub fn insert(&mut self, font_id: FontId, font: T) {
354        let hash = font.get_hash();
355        self.hash_to_id.insert(hash, font_id.clone());
356        self.fonts.insert(font_id, font);
357    }
358
359    /// Get a font by FontId
360    pub fn get(&self, font_id: &FontId) -> Option<&T> {
361        self.fonts.get(font_id)
362    }
363
364    /// Get a font by its hash
365    pub fn get_by_hash(&self, hash: u64) -> Option<&T> {
366        self.hash_to_id.get(&hash).and_then(|id| self.fonts.get(id))
367    }
368
369    /// Get the FontId for a hash
370    pub fn get_font_id_by_hash(&self, hash: u64) -> Option<&FontId> {
371        self.hash_to_id.get(&hash)
372    }
373
374    /// Check if a FontId is present
375    pub fn contains_key(&self, font_id: &FontId) -> bool {
376        self.fonts.contains_key(font_id)
377    }
378
379    /// Check if a hash is present
380    pub fn contains_hash(&self, hash: u64) -> bool {
381        self.hash_to_id.contains_key(&hash)
382    }
383
384    /// Iterate over all fonts
385    pub fn iter(&self) -> impl Iterator<Item = (&FontId, &T)> {
386        self.fonts.iter()
387    }
388
389    /// Get the number of loaded fonts
390    pub fn len(&self) -> usize {
391        self.fonts.len()
392    }
393
394    /// Check if empty
395    pub fn is_empty(&self) -> bool {
396        self.fonts.is_empty()
397    }
398}
399
400impl<T: ParsedFontTrait> Default for LoadedFonts<T> {
401    fn default() -> Self {
402        Self::new()
403    }
404}
405
406impl<T: ParsedFontTrait> FromIterator<(FontId, T)> for LoadedFonts<T> {
407    fn from_iter<I: IntoIterator<Item = (FontId, T)>>(iter: I) -> Self {
408        let mut loaded = LoadedFonts::new();
409        for (id, font) in iter {
410            loaded.insert(id, font);
411        }
412        loaded
413    }
414}
415
416/// Enum that wraps either a fontconfig-resolved font (T) or a direct FontRef.
417///
418/// This allows the shaping code to handle both fontconfig-resolved fonts
419/// and embedded fonts (FontRef) uniformly through the ParsedFontTrait interface.
420#[derive(Debug, Clone)]
421pub enum FontOrRef<T> {
422    /// A font loaded via fontconfig
423    Font(T),
424    /// A direct FontRef (embedded font, bypasses fontconfig)
425    Ref(azul_css::props::basic::FontRef),
426}
427
428impl<T: ParsedFontTrait> ShallowClone for FontOrRef<T> {
429    fn shallow_clone(&self) -> Self {
430        match self {
431            FontOrRef::Font(f) => FontOrRef::Font(f.shallow_clone()),
432            FontOrRef::Ref(r) => FontOrRef::Ref(r.clone()),
433        }
434    }
435}
436
437impl<T: ParsedFontTrait> ParsedFontTrait for FontOrRef<T> {
438    fn shape_text(
439        &self,
440        text: &str,
441        script: Script,
442        language: Language,
443        direction: BidiDirection,
444        style: &StyleProperties,
445    ) -> Result<Vec<Glyph>, LayoutError> {
446        match self {
447            FontOrRef::Font(f) => f.shape_text(text, script, language, direction, style),
448            FontOrRef::Ref(r) => r.shape_text(text, script, language, direction, style),
449        }
450    }
451
452    fn get_hash(&self) -> u64 {
453        match self {
454            FontOrRef::Font(f) => f.get_hash(),
455            FontOrRef::Ref(r) => r.get_hash(),
456        }
457    }
458
459    fn get_glyph_size(&self, glyph_id: u16, font_size: f32) -> Option<LogicalSize> {
460        match self {
461            FontOrRef::Font(f) => f.get_glyph_size(glyph_id, font_size),
462            FontOrRef::Ref(r) => r.get_glyph_size(glyph_id, font_size),
463        }
464    }
465
466    fn get_hyphen_glyph_and_advance(&self, font_size: f32) -> Option<(u16, f32)> {
467        match self {
468            FontOrRef::Font(f) => f.get_hyphen_glyph_and_advance(font_size),
469            FontOrRef::Ref(r) => r.get_hyphen_glyph_and_advance(font_size),
470        }
471    }
472
473    fn get_kashida_glyph_and_advance(&self, font_size: f32) -> Option<(u16, f32)> {
474        match self {
475            FontOrRef::Font(f) => f.get_kashida_glyph_and_advance(font_size),
476            FontOrRef::Ref(r) => r.get_kashida_glyph_and_advance(font_size),
477        }
478    }
479
480    fn has_glyph(&self, codepoint: u32) -> bool {
481        match self {
482            FontOrRef::Font(f) => f.has_glyph(codepoint),
483            FontOrRef::Ref(r) => r.has_glyph(codepoint),
484        }
485    }
486
487    fn get_vertical_metrics(&self, glyph_id: u16) -> Option<VerticalMetrics> {
488        match self {
489            FontOrRef::Font(f) => f.get_vertical_metrics(glyph_id),
490            FontOrRef::Ref(r) => r.get_vertical_metrics(glyph_id),
491        }
492    }
493
494    fn get_font_metrics(&self) -> LayoutFontMetrics {
495        match self {
496            FontOrRef::Font(f) => f.get_font_metrics(),
497            FontOrRef::Ref(r) => r.get_font_metrics(),
498        }
499    }
500
501    fn num_glyphs(&self) -> u16 {
502        match self {
503            FontOrRef::Font(f) => f.num_glyphs(),
504            FontOrRef::Ref(r) => r.num_glyphs(),
505        }
506    }
507
508    fn get_space_width(&self) -> Option<usize> {
509        match self {
510            FontOrRef::Font(f) => f.get_space_width(),
511            FontOrRef::Ref(r) => r.get_space_width(),
512        }
513    }
514}
515
516/// Bundles all font-related state that can be shared across layout passes.
517///
518/// Separates font concerns from layout/rendering state (`LayoutWindow`).
519/// Each test/render creates a fresh `LayoutWindow` from a shared `FontContext`,
520/// avoiding stale layout cache reuse while keeping parsed fonts warm.
521///
522/// Usage:
523/// ```ignore
524/// let ctx = FontContext::from_fc_cache(fc_cache);
525/// ctx.pre_resolve_chains(&styled_dom, &platform);
526/// ctx.load_fonts_for_chains();
527///
528/// // Per-test: create fresh LayoutWindow from context
529/// let mut window = LayoutWindow::from_font_context(&ctx)?;
530/// window.layout_and_generate_display_list(styled_dom, ...)?;
531/// ```
532#[derive(Debug, Clone)]
533pub struct FontContext {
534    /// The shared font cache. As of rust-fontconfig 4.1 this type is
535    /// itself backed by `Arc<RwLock<_>>`, so cloning is cheap and all
536    /// clones see builder-thread writes immediately — no more `Arc<T>`
537    /// wrapping is needed and no more stale-snapshot refresh dance.
538    pub fc_cache: FcFontCache,
539    pub parsed_fonts: Arc<Mutex<HashMap<FontId, azul_css::props::basic::FontRef>>>,
540    pub font_chain_cache: HashMap<FontChainKey, rust_fontconfig::FontFallbackChain>,
541    pub embedded_fonts: HashMap<u64, azul_css::props::basic::FontRef>,
542    /// Reverse map: font_family_hash → actual StyleFontFamilyVec.
543    /// Accumulated across DOMs for persistence. Copied to FontManager on LayoutWindow creation.
544    pub font_hash_to_families: HashMap<u64, azul_css::props::basic::font::StyleFontFamilyVec>,
545    /// Optional link back to the live `FcFontRegistry`. Present iff the
546    /// caller wants the scout-on-demand path
547    /// ([`rust_fontconfig::registry::FcFontRegistry::request_and_resolve_with_scripts`]),
548    /// which priority-bumps the builder for not-yet-parsed families
549    /// rather than falling back to the empty-snapshot response.
550    pub registry: Option<Arc<rust_fontconfig::registry::FcFontRegistry>>,
551}
552
553impl FontContext {
554    /// Create from an `FcFontCache`. Parsed fonts, font chains, and
555    /// embedded fonts start empty.
556    ///
557    /// The resulting `FontContext` has `registry = None`, so font
558    /// chain resolution only sees what's already in the cache. For
559    /// the scout-on-demand path, use [`FontContext::from_registry`]
560    /// instead, which keeps a handle to the registry so that chain
561    /// resolution can lazy-parse families the DOM needs.
562    pub fn from_fc_cache(fc_cache: FcFontCache) -> Self {
563        Self {
564            fc_cache,
565            parsed_fonts: Arc::new(Mutex::new(HashMap::new())),
566            font_chain_cache: HashMap::new(),
567            embedded_fonts: HashMap::new(),
568            font_hash_to_families: HashMap::new(),
569            registry: None,
570        }
571    }
572
573    /// Create from a live `FcFontRegistry`. The `fc_cache` field gets
574    /// a *shared* handle to the registry's cache (cheap `Arc::clone`
575    /// on the v4.1 shared-state cache) — writes by builder threads
576    /// show up immediately in every reader. Chain resolution goes
577    /// through
578    /// [`rust_fontconfig::registry::FcFontRegistry::request_and_resolve_with_scripts`]
579    /// which priority-bumps the builder for unparsed families and
580    /// waits for them. This is the "scout-on-demand" path: a
581    /// headless renderer can skip the eager common-stack parse and
582    /// pay only the per-family cost on first use, dropping peak RSS
583    /// by the common-stack metadata size (~15 MiB on macOS).
584    pub fn from_registry(
585        registry: Arc<rust_fontconfig::registry::FcFontRegistry>,
586    ) -> Self {
587        let fc_cache = registry.shared_cache();
588        Self {
589            fc_cache,
590            parsed_fonts: Arc::new(Mutex::new(HashMap::new())),
591            font_chain_cache: HashMap::new(),
592            embedded_fonts: HashMap::new(),
593            font_hash_to_families: HashMap::new(),
594            registry: Some(registry),
595        }
596    }
597
598    /// Pre-resolve font chains for a StyledDom's CSS font stacks.
599    /// Call this before layout so text rendering doesn't skip glyphs.
600    ///
601    /// Unicode-fallback fonts are limited to the scripts actually
602    /// present in the document's text content — for an ASCII-only
603    /// page, this skips the ~300 MiB Arial-Unicode / CJK / Arabic
604    /// pull-in entirely. See
605    /// [`crate::solver3::getters::scripts_present_in_styled_dom`].
606    pub fn pre_resolve_chains_for_dom(
607        &mut self,
608        styled_dom: &azul_core::styled_dom::StyledDom,
609        platform: &azul_css::system::Platform,
610    ) {
611        use crate::solver3::getters::{
612            collect_font_stacks_from_styled_dom, collect_used_codepoints,
613            prune_chain_to_used_chars, resolve_font_chains, scripts_present_in_styled_dom,
614        };
615        let collected = collect_font_stacks_from_styled_dom(styled_dom, platform);
616        let scripts = scripts_present_in_styled_dom(styled_dom);
617        let mut chains = resolve_font_chains(&collected, &self.fc_cache, Some(&scripts));
618        // Coverage-based prune (matches `collect_and_resolve_font_chains_with_registration`).
619        let used_chars = collect_used_codepoints(styled_dom);
620        for chain in chains.chains.values_mut() {
621            prune_chain_to_used_chars(chain, &used_chars);
622        }
623        self.font_chain_cache = chains.into_fontconfig_chains();
624    }
625
626    /// Load parsed font bytes from disk for all fonts referenced in `font_chain_cache`.
627    ///
628    /// Thin wrapper that materialises a `ResolvedFontChains` from the
629    /// cached chain map and delegates the actual disk-load to the
630    /// shared `FontManager::load_missing_for_chains` helper, so the
631    /// "collect → diff → load → insert" sequence lives in exactly
632    /// one place. Failures are silently dropped here (the caller is
633    /// the warmup path which has no good place to log them); use
634    /// `FontManager::load_missing_for_chains` directly for diagnostics.
635    pub fn load_fonts_for_chains(&self) {
636        use crate::solver3::getters::ResolvedFontChains;
637        use crate::text3::default::PathLoader;
638
639        let chains_map: HashMap<FontChainKeyOrRef, _> = self
640            .font_chain_cache
641            .iter()
642            .map(|(k, v)| (FontChainKeyOrRef::Chain(k.clone()), v.clone()))
643            .collect();
644        let resolved = ResolvedFontChains { chains: chains_map };
645
646        // Borrow our shared `parsed_fonts` Arc as a transient
647        // FontManager so we can use the helper. `from_arc_shared`
648        // returns a manager that mutates the same underlying pool.
649        let manager = match FontManager::<azul_css::props::basic::FontRef>::from_arc_shared(
650            self.fc_cache.clone(),
651            self.parsed_fonts.clone(),
652        ) {
653            Ok(m) => m,
654            Err(_) => return,
655        };
656        let loader = PathLoader::new();
657        let _failed = manager
658            .load_missing_for_chains(&resolved, |bytes, idx| loader.load_font_shared(bytes, idx));
659    }
660
661    /// Convert into a `FontManager` with all data populated.
662    /// Carries the `registry` forward so the resulting manager also
663    /// has the scout-on-demand path available.
664    pub fn to_font_manager(&self) -> FontManager<azul_css::props::basic::FontRef> {
665        FontManager {
666            fc_cache: self.fc_cache.clone(),
667            parsed_fonts: self.parsed_fonts.clone(),
668            font_chain_cache: self.font_chain_cache.clone(),
669            embedded_fonts: Mutex::new(self.embedded_fonts.clone()),
670            font_hash_to_families: self.font_hash_to_families.clone(),
671            registry: self.registry.clone(),
672            last_resolved_font_stacks_sig: None,
673        }
674    }
675}
676
677#[derive(Debug)]
678pub struct FontManager<T> {
679    /// The font-path cache. `FcFontCache` in rust-fontconfig 4.1 is
680    /// already a shared handle internally (`Arc<RwLock<_>>`), so no
681    /// further `Arc<...>` wrapping is needed — clones are cheap and
682    /// all clones see builder writes instantly.
683    pub fc_cache: FcFontCache,
684    /// Holds the actual parsed font (usually with the font bytes attached).
685    /// Wrapped in Arc so multiple FontManager instances can share the same
686    /// pool of already-parsed fonts (avoids re-reading from disk).
687    pub parsed_fonts: Arc<Mutex<HashMap<FontId, T>>>,
688    // Cache for font chains - populated by resolve_all_font_chains() before layout
689    // This is read-only during layout - no locking needed for reads
690    pub font_chain_cache: HashMap<FontChainKey, rust_fontconfig::FontFallbackChain>,
691    /// Cache for direct FontRefs (embedded fonts like Material Icons)
692    /// These are fonts referenced via FontStack::Ref that bypass fontconfig
693    pub embedded_fonts: Mutex<HashMap<u64, azul_css::props::basic::FontRef>>,
694    /// Reverse map: font_family_hash → actual StyleFontFamilyVec.
695    /// Accumulated across DOMs. Used by font collection and text shaping to
696    /// resolve compact cache hashes without get_property_slow.
697    pub font_hash_to_families: HashMap<u64, azul_css::props::basic::font::StyleFontFamilyVec>,
698    /// Optional link back to the live `FcFontRegistry`. When present,
699    /// chain resolution uses
700    /// [`rust_fontconfig::registry::FcFontRegistry::request_and_resolve_with_scripts`]
701    /// which lazy-parses system fonts as the DOM requests them
702    /// (scout-on-demand). `None` falls back to querying whatever is
703    /// already in the shared cache.
704    pub registry: Option<Arc<rust_fontconfig::registry::FcFontRegistry>>,
705    /// FxHash of the `prev_font_hashes` slice at the moment the last
706    /// successful `collect_and_resolve_font_chains_with_registration`
707    /// call populated `font_chain_cache`. Lets repeated layouts of the
708    /// same DOM skip the ~1.5 ms (cold) / ~0.9 ms (warm) chain resolver
709    /// when the set of font-family hashes has not changed. Cleared
710    /// whenever `font_chain_cache` is explicitly emptied.
711    pub last_resolved_font_stacks_sig: Option<u64>,
712}
713
714impl<T: ParsedFontTrait> FontManager<T> {
715    pub fn new(fc_cache: FcFontCache) -> Result<Self, LayoutError> {
716        Ok(Self {
717            fc_cache,
718            parsed_fonts: Arc::new(Mutex::new(HashMap::new())),
719            font_chain_cache: HashMap::new(),
720            embedded_fonts: Mutex::new(HashMap::new()),
721            font_hash_to_families: HashMap::new(),
722            registry: None,
723            last_resolved_font_stacks_sig: None,
724        })
725    }
726
727    /// Create a FontManager sharing the font-path cache handle.
728    ///
729    /// The parsed_fonts pool starts empty. Fonts loaded during the first
730    /// layout pass are cached and will be available on subsequent calls
731    /// if you clone the `parsed_fonts` Arc before creating the next instance.
732    /// For full sharing, prefer `from_arc_shared()`.
733    pub fn from_shared(fc_cache: FcFontCache) -> Result<Self, LayoutError> {
734        Ok(Self {
735            fc_cache,
736            parsed_fonts: Arc::new(Mutex::new(HashMap::new())),
737            font_chain_cache: HashMap::new(),
738            embedded_fonts: Mutex::new(HashMap::new()),
739            font_hash_to_families: HashMap::new(),
740            registry: None,
741            last_resolved_font_stacks_sig: None,
742        })
743    }
744
745    /// Create a FontManager sharing both the font-path cache and the
746    /// already-parsed font data with another FontManager.
747    ///
748    /// This avoids re-reading and re-parsing font files from disk when
749    /// rendering multiple documents that use the same fonts.
750    pub fn from_arc_shared(
751        fc_cache: FcFontCache,
752        parsed_fonts: Arc<Mutex<HashMap<FontId, T>>>,
753    ) -> Result<Self, LayoutError> {
754        Ok(Self {
755            fc_cache,
756            parsed_fonts,
757            font_chain_cache: HashMap::new(),
758            embedded_fonts: Mutex::new(HashMap::new()),
759            font_hash_to_families: HashMap::new(),
760            registry: None,
761            last_resolved_font_stacks_sig: None,
762        })
763    }
764
765    /// Attach a `FcFontRegistry` to this FontManager so subsequent
766    /// chain-resolution calls use the on-demand path
767    /// ([`rust_fontconfig::registry::FcFontRegistry::request_and_resolve_with_scripts`]).
768    pub fn with_registry(
769        mut self,
770        registry: Arc<rust_fontconfig::registry::FcFontRegistry>,
771    ) -> Self {
772        self.registry = Some(registry);
773        self
774    }
775
776    /// Get a shareable handle to the parsed-font pool.
777    ///
778    /// Pass this to `from_arc_shared()` to create a new FontManager that
779    /// reuses already-parsed fonts.
780    pub fn shared_parsed_fonts(&self) -> Arc<Mutex<HashMap<FontId, T>>> {
781        Arc::clone(&self.parsed_fonts)
782    }
783
784    /// Set the font chain cache from externally resolved chains
785    ///
786    /// This should be called with the result of `resolve_font_chains()` or
787    /// `collect_and_resolve_font_chains()` from `solver3::getters`.
788    pub fn set_font_chain_cache(
789        &mut self,
790        chains: HashMap<FontChainKey, rust_fontconfig::FontFallbackChain>,
791    ) {
792        self.font_chain_cache = chains;
793        self.last_resolved_font_stacks_sig = None;
794    }
795
796    /// Set the font chain cache and record the input signature so
797    /// subsequent layouts with the same `prev_font_hashes` skip the
798    /// resolver. Pass `sig = None` if the caller cannot compute a
799    /// reliable signature — equivalent to the single-arg
800    /// `set_font_chain_cache`.
801    pub fn set_font_chain_cache_with_sig(
802        &mut self,
803        chains: HashMap<FontChainKey, rust_fontconfig::FontFallbackChain>,
804        sig: Option<u64>,
805    ) {
806        self.font_chain_cache = chains;
807        self.last_resolved_font_stacks_sig = sig;
808    }
809
810    /// Merge additional font chains into the existing cache
811    ///
812    /// Useful when processing multiple DOMs that may have different font requirements.
813    pub fn merge_font_chain_cache(
814        &mut self,
815        chains: HashMap<FontChainKey, rust_fontconfig::FontFallbackChain>,
816    ) {
817        self.font_chain_cache.extend(chains);
818    }
819
820    /// Get a reference to the font chain cache
821    pub fn get_font_chain_cache(
822        &self,
823    ) -> &HashMap<FontChainKey, rust_fontconfig::FontFallbackChain> {
824        &self.font_chain_cache
825    }
826
827    /// Get an embedded font by its hash (used for WebRender registration)
828    /// Returns the FontRef if it exists in the embedded_fonts cache.
829    pub fn get_embedded_font_by_hash(&self, font_hash: u64) -> Option<azul_css::props::basic::FontRef> {
830        let embedded = self.embedded_fonts.lock().unwrap();
831        embedded.get(&font_hash).cloned()
832    }
833
834    /// Get a parsed font by its hash (used for WebRender registration)
835    /// Returns the parsed font if it exists in the parsed_fonts cache.
836    pub fn get_font_by_hash(&self, font_hash: u64) -> Option<T> {
837        let parsed = self.parsed_fonts.lock().unwrap();
838        // Linear search through all cached fonts to find one with matching hash
839        for (_, font) in parsed.iter() {
840            if font.get_hash() == font_hash {
841                return Some(font.clone());
842            }
843        }
844        None
845    }
846
847    /// Register an embedded FontRef for later lookup by hash
848    /// This is called when using FontStack::Ref during shaping
849    pub fn register_embedded_font(&self, font_ref: &azul_css::props::basic::FontRef) {
850        let hash = font_ref.get_hash();
851        let mut embedded = self.embedded_fonts.lock().unwrap();
852        embedded.insert(hash, font_ref.clone());
853    }
854
855    /// Get a snapshot of all currently loaded fonts
856    ///
857    /// This returns a copy of all parsed fonts, which can be passed to the shaper.
858    /// No locking is required after this call - the returned HashMap is independent.
859    ///
860    /// NOTE: This should be called AFTER loading all required fonts for a layout pass.
861    pub fn get_loaded_fonts(&self) -> LoadedFonts<T> {
862        let parsed = self.parsed_fonts.lock().unwrap();
863        parsed
864            .iter()
865            .map(|(id, font)| (id.clone(), font.shallow_clone()))
866            .collect()
867    }
868
869    /// Get the set of FontIds that are currently loaded
870    ///
871    /// This is useful for computing which fonts need to be loaded
872    /// (diff with required fonts).
873    pub fn get_loaded_font_ids(&self) -> std::collections::HashSet<FontId> {
874        let parsed = self.parsed_fonts.lock().unwrap();
875        // M12.7: skip hashbrown's RawIterRange on an empty map — its NEON
876        // control-byte group-scan mis-lifts to wasm and iterates forever
877        // (the headless web layout uses an empty font cache → parsed is
878        // empty here). is_empty() is len-based (no iteration), so it is safe.
879        if parsed.is_empty() {
880            return std::collections::HashSet::new();
881        }
882        parsed.keys().cloned().collect()
883    }
884
885    /// Insert a loaded font into the cache
886    ///
887    /// Returns the old font if one was already present for this FontId.
888    pub fn insert_font(&self, font_id: FontId, font: T) -> Option<T> {
889        let mut parsed = self.parsed_fonts.lock().unwrap();
890        parsed.insert(font_id, font)
891    }
892
893    /// Insert multiple loaded fonts into the cache
894    ///
895    /// This is more efficient than calling `insert_font` multiple times
896    /// because it only acquires the lock once.
897    pub fn insert_fonts(&self, fonts: impl IntoIterator<Item = (FontId, T)>) {
898        let mut parsed = self.parsed_fonts.lock().unwrap();
899        for (font_id, font) in fonts {
900            parsed.insert(font_id, font);
901        }
902    }
903
904    /// One-shot helper that resolves "what fonts does `chains` need
905    /// that this manager hasn't loaded yet" and loads them via the
906    /// supplied `load_fn` closure (typically
907    /// `PathLoader::load_font_shared` for the production lazy-decode
908    /// path). Updates `parsed_fonts` in place and returns any failures
909    /// for the caller to log.
910    ///
911    /// Replaces the same four-step `collect → compute_diff →
912    /// load_from_disk → insert_fonts` dance previously inlined in
913    /// `LayoutWindow::layout_document`, the CPU rasterizer pre-fill
914    /// in `cpurender.rs`, and `FontContext::load_fonts_for_chains`.
915    pub fn load_missing_for_chains<F>(
916        &self,
917        chains: &crate::solver3::getters::ResolvedFontChains,
918        load_fn: F,
919    ) -> Vec<(FontId, String)>
920    where
921        F: Fn(std::sync::Arc<rust_fontconfig::FontBytes>, usize) -> Result<T, LayoutError>,
922    {
923        use crate::solver3::getters::{
924            collect_font_ids_from_chains, compute_fonts_to_load, load_fonts_from_disk,
925        };
926        let required = collect_font_ids_from_chains(chains);
927        let already = self.get_loaded_font_ids();
928        let to_load = compute_fonts_to_load(&required, &already);
929        if to_load.is_empty() {
930            return Vec::new();
931        }
932        let result = load_fonts_from_disk(&to_load, &self.fc_cache, load_fn);
933        self.insert_fonts(result.loaded);
934        result.failed
935    }
936
937    /// Remove a font from the cache
938    ///
939    /// Returns the removed font if it was present.
940    pub fn remove_font(&self, font_id: &FontId) -> Option<T> {
941        let mut parsed = self.parsed_fonts.lock().unwrap();
942        parsed.remove(font_id)
943    }
944}
945
946// Error handling
947#[derive(Debug, thiserror::Error)]
948pub enum LayoutError {
949    #[error("Bidi analysis failed: {0}")]
950    BidiError(String),
951    #[error("Shaping failed: {0}")]
952    ShapingError(String),
953    #[error("Font not found: {0:?}")]
954    FontNotFound(FontSelector),
955    #[error("Invalid text input: {0}")]
956    InvalidText(String),
957    #[error("Hyphenation failed: {0}")]
958    HyphenationError(String),
959}
960
961/// Text boundary types for cursor movement
962#[derive(Debug, Clone, Copy, PartialEq, Eq)]
963pub enum TextBoundary {
964    /// Reached top of text (first line)
965    Top,
966    /// Reached bottom of text (last line)
967    Bottom,
968    /// Reached start of text (first character)
969    Start,
970    /// Reached end of text (last character)
971    End,
972}
973
974/// Error returned when cursor movement hits a boundary
975#[derive(Debug, Clone, Copy, PartialEq, Eq)]
976pub struct CursorBoundsError {
977    /// The boundary that was hit
978    pub boundary: TextBoundary,
979    /// The cursor position (unchanged from input)
980    pub cursor: TextCursor,
981}
982
983/// Unified constraints combining all layout features
984///
985/// # CSS Inline Layout Module Level 3: Constraint Mapping
986///
987/// This structure maps CSS properties to layout constraints:
988///
989/// ## \u00a7 2.1 Layout of Line Boxes
990/// - `available_width`: \u26a0\ufe0f CRITICAL - Should equal containing block's inner width
991///   * Currently defaults to 0.0 which causes immediate line breaking
992///   * Per spec: "logical width of a line box is equal to the inner logical width of its containing
993///     block"
994/// - `available_height`: For block-axis constraints (max-height)
995///
996/// ## \u00a7 2.2 Layout Within Line Boxes
997/// - `text_align`: \u2705 Horizontal alignment (start, end, center, justify)
998/// - `vertical_align`: \u26a0\ufe0f PARTIAL - Only baseline supported, missing:
999///   * top, bottom, middle, text-top, text-bottom
1000///   * <length>, <percentage> values
1001///   * sub, super positions
1002/// - `line_height`: \u2705 Distance between baselines
1003///
1004/// ## \u00a7 3 Baselines and Alignment Metrics
1005/// - `text_orientation`: \u2705 For vertical writing (sideways, upright)
1006/// - `writing_mode`: \u2705 horizontal-tb, vertical-rl, vertical-lr
1007/// - `direction`: \u2705 ltr, rtl for BiDi
1008///
1009/// ## \u00a7 4 Baseline Alignment (vertical-align property)
1010/// \u26a0\ufe0f INCOMPLETE: Only basic baseline alignment implemented
1011///
1012/// ## \u00a7 5 Line Spacing (line-height property)
1013/// - `line_height`: \u2705 Implemented
1014/// - \u274c MISSING: line-fit-edge for controlling which edges contribute to line height
1015/// +spec:box-model:51342f - inline box margins/borders/padding do not affect line box height (default leading mode)
1016/// +spec:font-metrics:618776 - line-fit-edge (cap, ex, ideographic, alphabetic edge selection) not yet implemented
1017///
1018/// ## \u00a7 6 Trimming Leading (text-box-trim)
1019/// - \u274c NOT IMPLEMENTED: text-box-trim property
1020/// - \u274c NOT IMPLEMENTED: text-box-edge property
1021/// +spec:box-model:c09331 - text-box-trim trims block container first/last line to font metrics
1022/// // +spec:overflow:dc2196 - text-box-trim overflow handled as normal overflow (no special handling needed)
1023///
1024/// ## CSS Text Module Level 3
1025/// - `text_indent`: \u2705 First line indentation
1026/// - `text_justify`: \u2705 Justification algorithm (auto, inter-word, inter-character)
1027/// - `hyphenation`: \u2705 Hyphens property (none / manual / auto)
1028/// - `hanging_punctuation`: \u2705 Hanging punctuation at line edges
1029///
1030/// ## CSS Text Level 4
1031/// - `text_wrap`: \u2705 balance, pretty, stable
1032/// - `line_clamp`: \u2705 Max number of lines
1033///
1034/// ## CSS Writing Modes Level 4
1035/// - `text_combine_upright`: \u2705 Tate-chu-yoko for vertical text
1036///
1037/// ## CSS Shapes Module
1038/// - `shape_boundaries`: \u2705 Custom line box shapes
1039/// - `shape_exclusions`: \u2705 Exclusion areas (float-like behavior)
1040/// - `exclusion_margin`: \u2705 Margin around exclusions
1041///
1042/// ## Multi-column Layout
1043/// - `columns`: \u2705 Number of columns
1044/// - `column_gap`: \u2705 Gap between columns
1045///
1046/// # Known Issues:
1047/// 1. [ISSUE] available_width defaults to Definite(0.0) instead of containing block width
1048/// 2. [ISSUE] vertical_align only supports baseline
1049/// 3. [TODO] initial-letter (drop caps) not implemented
1050// +spec:box-model:415ef3 - initial letters use standard margin/padding/border box model; exclusion area = margin box
1051// +spec:box-model:d53ea3 - when block-start padding+border are zero, content edge coincides with over alignment point
1052/// +spec:positioning:fb233a - initial letter block-axis: if size < sink, use over alignment
1053#[derive(Debug, Clone)]
1054pub struct UnifiedConstraints {
1055    // Shape definition
1056    pub shape_boundaries: Vec<ShapeBoundary>,
1057    pub shape_exclusions: Vec<ShapeBoundary>,
1058
1059    // Basic layout - using AvailableSpace for proper indefinite handling
1060    pub available_width: AvailableSpace,
1061    pub available_height: Option<f32>,
1062
1063    // Text layout
1064    pub writing_mode: Option<WritingMode>,
1065    // +spec:writing-modes:6c5ab9 - blocks inherit base direction from parent via CSS direction property
1066    // Base direction from CSS, overrides auto-detection
1067    pub direction: Option<BidiDirection>,
1068    pub text_orientation: TextOrientation,
1069    pub text_align: TextAlign,
1070    pub text_justify: JustifyContent,
1071    // +spec:display-property:3bcac8 - inline boxes sized in block axis based on font metrics (ascent/descent)
1072    pub line_height: LineHeight,
1073    pub vertical_align: VerticalAlign,
1074    // block container's first available font, used for minimum line box height
1075    pub strut_ascent: f32,
1076    pub strut_descent: f32,
1077    // x-height of the strut font (scaled to font_size), for vertical-align: middle
1078    pub strut_x_height: f32,
1079
1080    // Width of '0' (zero) character in px, used for ch unit and tab-size.
1081    // Approximated as space_width from the first available font, or 0.5 * font_size fallback.
1082    pub ch_width: f32,
1083
1084    // Overflow handling
1085    pub overflow: OverflowBehavior,
1086    pub segment_alignment: SegmentAlignment,
1087
1088    // Advanced features
1089    pub text_combine_upright: Option<TextCombineUpright>,
1090    pub exclusion_margin: f32,
1091    pub hyphenation: Hyphens,
1092    pub hyphenation_language: Option<Language>,
1093    pub text_indent: f32,
1094    pub text_indent_each_line: bool,
1095    pub text_indent_hanging: bool,
1096    pub initial_letter: Option<InitialLetter>,
1097    pub line_clamp: Option<NonZeroUsize>,
1098
1099    // text-wrap: balance
1100    pub text_wrap: TextWrap,
1101    pub columns: u32,
1102    pub column_gap: f32,
1103    pub hanging_punctuation: bool,
1104    pub overflow_wrap: OverflowWrap,
1105    pub text_align_last: TextAlign,
1106    // §5.2 word-break property on constraints
1107    pub word_break: WordBreak,
1108    pub white_space_mode: WhiteSpaceMode,
1109    pub line_break: LineBreakStrictness,
1110    // CSS unicode-bidi property; Plaintext causes per-paragraph auto-detection
1111    pub unicode_bidi: UnicodeBidi,
1112}
1113
1114impl Default for UnifiedConstraints {
1115    fn default() -> Self {
1116        Self {
1117            shape_boundaries: Vec::new(),
1118            shape_exclusions: Vec::new(),
1119
1120            // Use MaxContent as default to avoid premature line breaking.
1121            // MaxContent means "use intrinsic width" which is appropriate when
1122            // the containing block's width is not yet known.
1123            // Previously this was Definite(0.0) which caused each character to
1124            // wrap to its own line. The actual width should be passed from the 
1125            // box layout solver (fc.rs) when creating UnifiedConstraints.
1126            available_width: AvailableSpace::MaxContent,
1127            available_height: None,
1128            writing_mode: None,
1129            direction: None, // Will default to LTR if not specified
1130            text_orientation: TextOrientation::default(),
1131            text_align: TextAlign::default(),
1132            text_justify: JustifyContent::default(),
1133            line_height: LineHeight::Normal,
1134            vertical_align: VerticalAlign::default(),
1135            strut_ascent: 12.8, // 80% of default line-height (typical ratio)
1136            strut_descent: 3.2, // 20% of default line-height
1137            strut_x_height: 8.0, // 0.5 * default font_size (16.0), spec fallback
1138            ch_width: 8.0, // 0.5 * default font_size (16.0)
1139            overflow: OverflowBehavior::default(),
1140            segment_alignment: SegmentAlignment::default(),
1141            text_combine_upright: None,
1142            exclusion_margin: 0.0,
1143            hyphenation: Hyphens::default(),
1144            hyphenation_language: None,
1145            columns: 1,
1146            column_gap: 0.0,
1147            hanging_punctuation: false,
1148            text_indent: 0.0,
1149            text_indent_each_line: false,
1150            text_indent_hanging: false,
1151            initial_letter: None,
1152            line_clamp: None,
1153            text_wrap: TextWrap::default(),
1154            overflow_wrap: OverflowWrap::default(),
1155            text_align_last: TextAlign::default(),
1156            word_break: WordBreak::default(),
1157            white_space_mode: WhiteSpaceMode::default(),
1158            line_break: LineBreakStrictness::default(),
1159            unicode_bidi: UnicodeBidi::default(),
1160        }
1161    }
1162}
1163
1164// UnifiedConstraints
1165impl Hash for UnifiedConstraints {
1166    fn hash<H: Hasher>(&self, state: &mut H) {
1167        self.shape_boundaries.hash(state);
1168        self.shape_exclusions.hash(state);
1169        self.available_width.hash(state);
1170        self.available_height
1171            .map(|h| h.round() as usize)
1172            .hash(state);
1173        self.writing_mode.hash(state);
1174        self.direction.hash(state);
1175        self.text_orientation.hash(state);
1176        self.text_align.hash(state);
1177        self.text_justify.hash(state);
1178        self.line_height.hash(state);
1179        self.vertical_align.hash(state);
1180        (self.strut_ascent.round() as usize).hash(state);
1181        (self.strut_descent.round() as usize).hash(state);
1182        (self.strut_x_height.round() as usize).hash(state);
1183        (self.ch_width.round() as usize).hash(state);
1184        self.overflow.hash(state);
1185        self.segment_alignment.hash(state);
1186        self.text_combine_upright.hash(state);
1187        (self.exclusion_margin.round() as usize).hash(state);
1188        self.hyphenation.hash(state);
1189        self.hyphenation_language.hash(state);
1190        (self.text_indent.round() as usize).hash(state);
1191        self.text_indent_each_line.hash(state);
1192        self.text_indent_hanging.hash(state);
1193        self.initial_letter.hash(state);
1194        self.line_clamp.hash(state);
1195        self.columns.hash(state);
1196        (self.column_gap.round() as usize).hash(state);
1197        self.hanging_punctuation.hash(state);
1198        self.overflow_wrap.hash(state);
1199        self.text_align_last.hash(state);
1200        self.word_break.hash(state);
1201        self.white_space_mode.hash(state);
1202        self.line_break.hash(state);
1203        self.unicode_bidi.hash(state);
1204    }
1205}
1206
1207impl PartialEq for UnifiedConstraints {
1208    fn eq(&self, other: &Self) -> bool {
1209        self.shape_boundaries == other.shape_boundaries
1210            && self.shape_exclusions == other.shape_exclusions
1211            && self.available_width == other.available_width
1212            && match (self.available_height, other.available_height) {
1213                (None, None) => true,
1214                (Some(h1), Some(h2)) => round_eq(h1, h2),
1215                _ => false,
1216            }
1217            && self.writing_mode == other.writing_mode
1218            && self.direction == other.direction
1219            && self.text_orientation == other.text_orientation
1220            && self.text_align == other.text_align
1221            && self.text_justify == other.text_justify
1222            && self.line_height == other.line_height
1223            && self.vertical_align == other.vertical_align
1224            && round_eq(self.strut_ascent, other.strut_ascent)
1225            && round_eq(self.strut_descent, other.strut_descent)
1226            && round_eq(self.strut_x_height, other.strut_x_height)
1227            && round_eq(self.ch_width, other.ch_width)
1228            && self.overflow == other.overflow
1229            && self.segment_alignment == other.segment_alignment
1230            && self.text_combine_upright == other.text_combine_upright
1231            && round_eq(self.exclusion_margin, other.exclusion_margin)
1232            && self.hyphenation == other.hyphenation
1233            && self.hyphenation_language == other.hyphenation_language
1234            && round_eq(self.text_indent, other.text_indent)
1235            && self.text_indent_each_line == other.text_indent_each_line
1236            && self.text_indent_hanging == other.text_indent_hanging
1237            && self.initial_letter == other.initial_letter
1238            && self.line_clamp == other.line_clamp
1239            && self.columns == other.columns
1240            && round_eq(self.column_gap, other.column_gap)
1241            && self.hanging_punctuation == other.hanging_punctuation
1242            && self.overflow_wrap == other.overflow_wrap
1243            && self.text_align_last == other.text_align_last
1244            && self.word_break == other.word_break
1245            && self.white_space_mode == other.white_space_mode
1246            && self.line_break == other.line_break
1247            && self.unicode_bidi == other.unicode_bidi
1248    }
1249}
1250
1251impl Eq for UnifiedConstraints {}
1252
1253impl UnifiedConstraints {
1254    /// Resolve `line_height` to a pixel value using the strut metrics as a font-size proxy.
1255    /// `strut_ascent + strut_descent` approximates `font_size` (the block container's font).
1256    pub fn resolved_line_height(&self) -> f32 {
1257        let font_size_approx = self.strut_ascent + self.strut_descent;
1258        self.line_height.resolve(font_size_approx, 0.0, 0.0, 0.0, 0)
1259    }
1260    fn direction(&self, fallback: BidiDirection) -> BidiDirection {
1261        match self.writing_mode {
1262            Some(s) => s.get_direction().unwrap_or(fallback),
1263            None => fallback,
1264        }
1265    }
1266    fn is_vertical(&self) -> bool {
1267        matches!(
1268            self.writing_mode,
1269            Some(WritingMode::VerticalRl) | Some(WritingMode::VerticalLr)
1270        )
1271    }
1272}
1273
1274/// Line constraints with multi-segment support
1275#[derive(Debug, Clone)]
1276pub struct LineConstraints {
1277    pub segments: Vec<LineSegment>,
1278    pub total_available: f32,
1279}
1280
1281impl WritingMode {
1282    fn get_direction(&self) -> Option<BidiDirection> {
1283        match self {
1284            // determined by text content
1285            WritingMode::HorizontalTb => None,
1286            WritingMode::VerticalRl => Some(BidiDirection::Rtl),
1287            WritingMode::VerticalLr => Some(BidiDirection::Ltr),
1288            WritingMode::SidewaysRl => Some(BidiDirection::Rtl),
1289            WritingMode::SidewaysLr => Some(BidiDirection::Ltr),
1290        }
1291    }
1292}
1293
1294// Stage 1: Collection - Styled runs from DOM traversal
1295#[derive(Debug, Clone, Hash)]
1296pub struct StyledRun {
1297    pub text: String,
1298    pub style: Arc<StyleProperties>,
1299    /// Byte index in the original logical paragraph text
1300    pub logical_start_byte: usize,
1301    /// The DOM NodeId of the Text node this run came from.
1302    /// None for generated content (e.g., list markers, ::before/::after).
1303    pub source_node_id: Option<NodeId>,
1304}
1305
1306// Stage 2: Bidi Analysis - Visual runs in display order
1307#[derive(Debug, Clone)]
1308pub struct VisualRun<'a> {
1309    pub text_slice: &'a str,
1310    pub style: Arc<StyleProperties>,
1311    pub logical_start_byte: usize,
1312    pub bidi_level: BidiLevel,
1313    pub script: Script,
1314    pub language: Language,
1315}
1316
1317// Font and styling types
1318
1319/// A selector for loading fonts from the font cache.
1320/// Used by FontManager to query fontconfig and load font files.
1321#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
1322pub struct FontSelector {
1323    pub family: String,
1324    pub weight: FcWeight,
1325    pub style: FontStyle,
1326    pub unicode_ranges: Vec<UnicodeRange>,
1327}
1328
1329impl Default for FontSelector {
1330    fn default() -> Self {
1331        Self {
1332            family: "serif".to_string(),
1333            weight: FcWeight::Normal,
1334            style: FontStyle::Normal,
1335            unicode_ranges: Vec::new(),
1336        }
1337    }
1338}
1339
1340/// Font stack that can be either a list of font selectors (resolved via fontconfig)
1341/// or a direct FontRef (bypasses fontconfig entirely).
1342///
1343/// When a `FontRef` is used, it bypasses fontconfig resolution entirely
1344/// and uses the pre-parsed font data directly. This is used for embedded
1345/// fonts like Material Icons.
1346#[derive(Debug, Clone)]
1347pub enum FontStack {
1348    /// A stack of font selectors to be resolved via fontconfig
1349    /// First font is primary, rest are fallbacks
1350    Stack(Vec<FontSelector>),
1351    /// A direct reference to a pre-parsed font (e.g., embedded icon fonts)
1352    /// This font covers the entire Unicode range and has no fallbacks.
1353    Ref(azul_css::props::basic::font::FontRef),
1354}
1355
1356impl Default for FontStack {
1357    fn default() -> Self {
1358        FontStack::Stack(vec![FontSelector::default()])
1359    }
1360}
1361
1362impl FontStack {
1363    /// Returns true if this is a direct FontRef
1364    pub fn is_ref(&self) -> bool {
1365        matches!(self, FontStack::Ref(_))
1366    }
1367
1368    /// Returns the FontRef if this is a Ref variant
1369    pub fn as_ref(&self) -> Option<&azul_css::props::basic::font::FontRef> {
1370        match self {
1371            FontStack::Ref(r) => Some(r),
1372            _ => None,
1373        }
1374    }
1375
1376    /// Returns the font selectors if this is a Stack variant
1377    pub fn as_stack(&self) -> Option<&[FontSelector]> {
1378        match self {
1379            FontStack::Stack(s) => Some(s),
1380            _ => None,
1381        }
1382    }
1383
1384    /// Returns the first FontSelector if this is a Stack variant, None if Ref
1385    pub fn first_selector(&self) -> Option<&FontSelector> {
1386        match self {
1387            FontStack::Stack(s) => s.first(),
1388            FontStack::Ref(_) => None,
1389        }
1390    }
1391
1392    /// Returns the first font family name (for Stack) or a placeholder (for Ref)
1393    pub fn first_family(&self) -> &str {
1394        match self {
1395            FontStack::Stack(s) => s.first().map(|f| f.family.as_str()).unwrap_or("serif"),
1396            FontStack::Ref(_) => "<embedded-font>",
1397        }
1398    }
1399}
1400
1401impl PartialEq for FontStack {
1402    fn eq(&self, other: &Self) -> bool {
1403        match (self, other) {
1404            (FontStack::Stack(a), FontStack::Stack(b)) => a == b,
1405            (FontStack::Ref(a), FontStack::Ref(b)) => a.parsed == b.parsed,
1406            _ => false,
1407        }
1408    }
1409}
1410
1411impl Eq for FontStack {}
1412
1413impl Hash for FontStack {
1414    fn hash<H: Hasher>(&self, state: &mut H) {
1415        core::mem::discriminant(self).hash(state);
1416        match self {
1417            FontStack::Stack(s) => s.hash(state),
1418            FontStack::Ref(r) => (r.parsed as usize).hash(state),
1419        }
1420    }
1421}
1422
1423/// A reference to a font for rendering, identified by its hash.
1424/// This hash corresponds to ParsedFont::hash and is used to look up
1425/// the actual font data in the renderer's font cache.
1426#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
1427pub struct FontHash {
1428    /// The hash of the ParsedFont. 0 means invalid/unknown font.
1429    pub font_hash: u64,
1430}
1431
1432impl FontHash {
1433    pub fn invalid() -> Self {
1434        Self { font_hash: 0 }
1435    }
1436
1437    pub fn from_hash(font_hash: u64) -> Self {
1438        Self { font_hash }
1439    }
1440}
1441
1442#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
1443pub enum FontStyle {
1444    Normal,
1445    Italic,
1446    Oblique,
1447}
1448
1449/// Defines how text should be aligned when a line contains multiple disjoint segments.
1450#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
1451pub enum SegmentAlignment {
1452    /// Align text within the first available segment on the line.
1453    #[default]
1454    First,
1455    /// Align text relative to the total available width of all
1456    /// segments on the line combined.
1457    Total,
1458}
1459
1460#[derive(Debug, Clone)]
1461pub struct VerticalMetrics {
1462    pub advance: f32,
1463    pub bearing_x: f32,
1464    pub bearing_y: f32,
1465    pub origin_y: f32,
1466}
1467
1468// +spec:font-metrics:df51b1 - font metrics (ascent, descent, line_gap) used as baselines for inline layout alignment and box sizing
1469/// Layout-specific font metrics extracted from FontMetrics
1470/// Contains only the metrics needed for text layout and rendering
1471// +spec:box-model:a2f1c1 - inline box content area sized from first available font metrics (ascent/descent)
1472// +spec:font-metrics:9c2ca5 - ascent and descent metrics per font for inline layout
1473// +spec:font-metrics:797593 - font metrics (ascent, descent, line-gap) used for baseline calculations
1474// +spec:font-metrics:842d6a - font metrics (ascent, descent) used for precise spacing control
1475// +spec:font-metrics:eb97e0 - Font baseline metrics (ascent/descent) from font tables used for baseline alignment
1476// +spec:font-metrics:f2cd75 - em-over/em-under baselines intentionally not included (not used by CSS per spec)
1477// +spec:inline-formatting-context:76cd57 - ascent/descent font metrics for inline formatting context layout
1478// +spec:font-metrics:207e6b - ascent/descent metrics used for baseline calculations
1479#[derive(Debug, Clone)]
1480pub struct LayoutFontMetrics {
1481    pub ascent: f32,
1482    pub descent: f32,
1483    pub line_gap: f32,
1484    pub units_per_em: u16,
1485    /// OS/2 sxHeight: distance from baseline to top of lowercase 'x' (in font units).
1486    /// Used for `vertical-align: middle` per CSS Inline 3 §4.1.
1487    pub x_height: Option<f32>,
1488    /// OS/2 sCapHeight: height of capital letters from baseline (in font units).
1489    /// Used for drop cap / initial-letter alignment per CSS Inline 3 §7.1.1.
1490    pub cap_height: Option<f32>,
1491}
1492
1493impl LayoutFontMetrics {
1494    // +spec:font-metrics:006bd8 - baseline position from font design coordinates, scaled with font size
1495    // +spec:font-metrics:910c0a - dominant-baseline: auto resolves to alphabetic for horizontal text
1496    // +spec:writing-modes:098958 - baseline is along the inline axis, used to align glyphs
1497    pub fn baseline_scaled(&self, font_size: f32) -> f32 {
1498        let scale = font_size / self.units_per_em as f32;
1499        self.ascent * scale
1500    }
1501
1502    /// Returns the x-height scaled to the given font size in px.
1503    /// Falls back to 0.5em when the font doesn't provide sxHeight.
1504    pub fn x_height_scaled(&self, font_size: f32) -> f32 {
1505        let scale = font_size / self.units_per_em as f32;
1506        match self.x_height {
1507            Some(xh) => xh * scale,
1508            None => font_size * 0.5,
1509        }
1510    }
1511
1512    /// Returns the cap height scaled to the given font size in px.
1513    /// Falls back to ascent when the font doesn't provide sCapHeight.
1514    pub fn cap_height_scaled(&self, font_size: f32) -> f32 {
1515        let scale = font_size / self.units_per_em as f32;
1516        self.cap_height.unwrap_or(self.ascent) * scale
1517    }
1518
1519    // +spec:line-height:471816 - line gap metric extracted from font for optional use when line-height is normal
1520    /// Convert from full FontMetrics to layout-specific metrics.
1521    ///
1522    // +spec:font-metrics:05193a - prefer OS/2 sTypoAscender/sTypoDescender, fall back to HHEA
1523    // +spec:font-metrics:17a71c - prefer OS/2 sTypoAscender/sTypoDescender, fall back to HHEA
1524    // +spec:font-metrics:62c659 - prefer OS/2 sTypoAscender/sTypoDescender, fall back to HHEA
1525    // +spec:writing-modes:451a3e - ascent/descent/line-gap metrics: prefer OS/2, fallback HHEA, floor line_gap at 0
1526    /// Per CSS 2.2 §10.8.1: prefer OS/2 sTypoAscender/sTypoDescender,
1527    /// fall back to HHEA Ascent/Descent if OS/2 metrics are absent.
1528    // +spec:font-metrics:3dc8c1 - text-over/text-under baselines from font ascent/descent metrics
1529    // +spec:font-metrics:332c16 - text-over/text-under baseline metrics derived from font ascent/descent
1530    // +spec:font-metrics:9895e2 - baseline table is a font-level property; metrics apply uniformly to all glyphs
1531    // +spec:font-metrics:e05c40 - font ascent/descent metric extraction (text edge metrics)
1532    // +spec:font-metrics:21a3de - ascent/descent used as basis for em-over/em-under normalization
1533    // +spec:font-metrics:1257b7 - font ascent/descent ensure text fits within line box
1534    // +spec:table-layout:6bbd10 - use sTypoAscender/sTypoDescender as ascent/descent metrics per spec recommendation
1535    // +spec:font-metrics:5346d2 - prefer OS/2 sTypoAscender/sTypoDescender, fall back to HHEA
1536    // +spec:font-metrics:e16941 - line gap metric floored at zero per spec
1537    // +spec:font-metrics:a55c05 - metrics taken from font, synthesized if missing (prefers OS/2, falls back to HHEA)
1538    pub fn from_font_metrics(metrics: &azul_css::props::basic::FontMetrics) -> Self {
1539        let ascent = metrics.s_typo_ascender
1540            .as_option()
1541            .map(|v| *v as f32)
1542            .unwrap_or(metrics.ascender as f32);
1543        let descent = metrics.s_typo_descender
1544            .as_option()
1545            .map(|v| *v as f32)
1546            .unwrap_or(metrics.descender as f32);
1547        // UAs must floor the line gap metric at zero (css-inline-3 §3.2.2)
1548        // Spec: "UAs must floor the line gap metric at zero."
1549        let line_gap = metrics.s_typo_line_gap
1550            .as_option()
1551            .map(|v| *v as f32)
1552            .unwrap_or(metrics.line_gap as f32)
1553            .max(0.0);
1554        let x_height = metrics.sx_height
1555            .as_option()
1556            .map(|v| *v as f32);
1557        let cap_height = metrics.s_cap_height
1558            .as_option()
1559            .map(|v| *v as f32);
1560        Self {
1561            ascent,
1562            descent,
1563            line_gap,
1564            units_per_em: metrics.units_per_em,
1565            x_height,
1566            cap_height,
1567        }
1568    }
1569
1570    // +spec:font-metrics:1eda6b - em-over is 0.5em over central baseline, em-under is 0.5em under
1571    /// Synthesize em-over baseline offset (in font units).
1572    /// Per CSS Inline 3 Appendix A.1: em-over = central baseline + 0.5em.
1573    /// Central baseline is synthesized as midpoint of ascent and descent.
1574    pub fn em_over(&self) -> f32 {
1575        let central = self.central_baseline();
1576        central + (self.units_per_em as f32 / 2.0)
1577    }
1578
1579    /// Synthesize em-under baseline offset (in font units).
1580    /// Per CSS Inline 3 Appendix A.1: em-under = central baseline - 0.5em.
1581    pub fn em_under(&self) -> f32 {
1582        let central = self.central_baseline();
1583        central - (self.units_per_em as f32 / 2.0)
1584    }
1585
1586    /// Synthesize central baseline (in font units).
1587    /// Midpoint between ascent and descent when not provided by the font.
1588    pub fn central_baseline(&self) -> f32 {
1589        (self.ascent + self.descent) / 2.0
1590    }
1591}
1592
1593#[derive(Debug, Clone)]
1594pub struct LineSegment {
1595    pub start_x: f32,
1596    pub width: f32,
1597    // For choosing best segment when multiple available
1598    pub priority: u8,
1599}
1600
1601#[derive(Debug, Clone, Copy, PartialEq, Hash, Eq, PartialOrd, Ord, Default)]
1602pub enum TextWrap {
1603    #[default]
1604    Wrap,
1605    Balance,
1606    NoWrap,
1607}
1608
1609/// CSS `overflow-wrap` (aka `word-wrap`) property.
1610///
1611/// Controls whether an otherwise unbreakable sequence of characters
1612/// may be broken at an arbitrary point to prevent overflow.
1613#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
1614pub enum OverflowWrap {
1615    /// No special break opportunities are introduced.
1616    #[default]
1617    Normal,
1618    /// Break at arbitrary points if no other break points exist.
1619    /// Soft wrap opportunities from `anywhere` ARE considered
1620    /// when calculating min-content intrinsic sizes.
1621    Anywhere,
1622    /// Same as `anywhere` except soft wrap opportunities introduced
1623    /// by `break-word` are NOT considered when calculating
1624    /// min-content intrinsic sizes.
1625    BreakWord,
1626}
1627
1628// +spec:line-breaking:841a87 - hyphens property: manual (U+00AD/U+2010 only) and auto (language-aware automatic hyphenation)
1629// +spec:line-breaking:68c6ad - hyphens property controls hyphenation opportunities (none/manual/auto)
1630/// Controls whether hyphenation is allowed to create soft wrap opportunities.
1631#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
1632pub enum Hyphens {
1633    /// No hyphenation: U+00AD soft hyphens are not treated as break points.
1634    None,
1635    /// Only break at manually-inserted soft hyphens (U+00AD) or explicit hyphens.
1636    #[default]
1637    Manual,
1638    /// The UA may automatically hyphenate words in addition to manual opportunities.
1639    Auto,
1640}
1641
1642// +spec:line-breaking:ce5258 - white-space property controls collapsing, wrapping, and forced breaks
1643// +spec:line-breaking:35817b - normal/pre/nowrap/pre-wrap/break-spaces/pre-line behaviors
1644// +spec:white-space-processing:dec7aa - White space not removed/collapsed is "preserved white space"
1645#[derive(Debug, Clone, Copy, PartialEq, Hash, Eq, PartialOrd, Ord, Default)]
1646pub enum WhiteSpaceMode {
1647    #[default]
1648    Normal,
1649    Nowrap,
1650    Pre,
1651    PreWrap,
1652    PreLine,
1653    BreakSpaces,
1654}
1655
1656// CSS Text Level 3 §5.3: The line-break property controls strictness of line breaking rules.
1657// - Auto: UA-dependent, typically normal for CJK, loose for non-CJK
1658// - Loose: least restrictive, allows breaks before small kana, CJK hyphens, etc.
1659// - Normal: default CJK rules, allows breaks before CJK hyphen-like chars for CJK text
1660// - Strict: most restrictive, forbids breaks before small kana and CJK punctuation
1661// - Anywhere: allows soft wrap opportunities around every typographic character unit
1662#[derive(Debug, Clone, Copy, PartialEq, Hash, Eq, PartialOrd, Ord, Default)]
1663pub enum LineBreakStrictness {
1664    #[default]
1665    Auto,
1666    Loose,
1667    Normal,
1668    Strict,
1669    /// Soft wrap opportunity around every typographic character unit.
1670    /// Hyphenation is not applied.
1671    Anywhere,
1672}
1673
1674// §5.2 word-break property: normal, break-all, keep-all
1675#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
1676pub enum WordBreak {
1677    /// Normal break rules: CJK characters break between each other,
1678    /// non-CJK text only breaks at spaces/hyphens.
1679    #[default]
1680    Normal,
1681    /// Allow breaks between any two characters, including within Latin words.
1682    BreakAll,
1683    /// Suppress breaks between CJK characters (treat them like Latin words,
1684    /// only breaking at spaces). Sequences of CJK characters do not break.
1685    KeepAll,
1686}
1687
1688// +spec:display-property:162c99 - Initial letter box: in-flow inline-level box with special layout behavior
1689// +spec:display-property:72a797 - Initial letter handled like inline-level content in originating line box
1690// initial-letter
1691// +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
1692// +spec:font-metrics:1e5325 - drop initial cap-height = (N-1)*line_height + surrounding cap-height
1693// +spec:font-metrics:3aa518 - initial-letter-align: cap-height/ideographic/hanging/leading/border-box baseline alignment
1694// +spec:writing-modes:9698b0 - Han-derived scripts: initial letter extends from block-start to block-end of Nth line
1695#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
1696pub struct InitialLetter {
1697    /// How many lines tall the initial letter should be.
1698    pub size: f32,
1699    // +spec:font-metrics:dc0632 - raised initial "sinks" to first text baseline (sink=1)
1700    /// How many lines the letter should sink into.
1701    pub sink: u32,
1702    /// How many characters to apply this styling to.
1703    pub count: NonZeroUsize,
1704    // +spec:display-property:4c69bf - alignment points for sizing/positioning initial letter
1705    /// Alignment mode for the initial letter (over/under alignment points
1706    /// matched to corresponding points of the root inline box).
1707    pub align: InitialLetterAlign,
1708}
1709
1710/// Alignment mode for initial letters, controlling which alignment points
1711/// are used to size and position the letter relative to the root inline box.
1712#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
1713pub enum InitialLetterAlign {
1714    /// UA chooses based on script
1715    Auto,
1716    /// Alphabetic baseline alignment
1717    Alphabetic,
1718    /// Hanging baseline alignment
1719    Hanging,
1720    /// Ideographic baseline alignment
1721    Ideographic,
1722}
1723
1724// A type that implements `Hash` must also implement `Eq`.
1725// Since f32 does not implement `Eq`, we provide a manual implementation.
1726// This is a marker trait, indicating that `a == b` is a true equivalence
1727// relation. The derived `PartialEq` already satisfies this.
1728impl Eq for InitialLetter {}
1729
1730impl Hash for InitialLetter {
1731    fn hash<H: Hasher>(&self, state: &mut H) {
1732        // Per the request, round the f32 to a usize for hashing.
1733        // This is a lossy conversion; values like 2.3 and 2.4 will produce
1734        // the same hash value for this field. This is acceptable as long as
1735        // the `PartialEq` implementation correctly distinguishes them.
1736        (self.size.round() as usize).hash(state);
1737        self.sink.hash(state);
1738        self.count.hash(state);
1739        self.align.hash(state);
1740    }
1741}
1742
1743// Path and shape definitions
1744#[derive(Debug, Clone, PartialOrd)]
1745pub enum PathSegment {
1746    MoveTo(Point),
1747    LineTo(Point),
1748    CurveTo {
1749        control1: Point,
1750        control2: Point,
1751        end: Point,
1752    },
1753    QuadTo {
1754        control: Point,
1755        end: Point,
1756    },
1757    Arc {
1758        center: Point,
1759        radius: f32,
1760        start_angle: f32,
1761        end_angle: f32,
1762    },
1763    Close,
1764}
1765
1766// PathSegment
1767impl Hash for PathSegment {
1768    fn hash<H: Hasher>(&self, state: &mut H) {
1769        // Hash the enum variant's discriminant first to distinguish them
1770        discriminant(self).hash(state);
1771
1772        match self {
1773            PathSegment::MoveTo(p) => p.hash(state),
1774            PathSegment::LineTo(p) => p.hash(state),
1775            PathSegment::CurveTo {
1776                control1,
1777                control2,
1778                end,
1779            } => {
1780                control1.hash(state);
1781                control2.hash(state);
1782                end.hash(state);
1783            }
1784            PathSegment::QuadTo { control, end } => {
1785                control.hash(state);
1786                end.hash(state);
1787            }
1788            PathSegment::Arc {
1789                center,
1790                radius,
1791                start_angle,
1792                end_angle,
1793            } => {
1794                center.hash(state);
1795                (radius.round() as usize).hash(state);
1796                (start_angle.round() as usize).hash(state);
1797                (end_angle.round() as usize).hash(state);
1798            }
1799            PathSegment::Close => {} // No data to hash
1800        }
1801    }
1802}
1803
1804impl PartialEq for PathSegment {
1805    fn eq(&self, other: &Self) -> bool {
1806        match (self, other) {
1807            (PathSegment::MoveTo(a), PathSegment::MoveTo(b)) => a == b,
1808            (PathSegment::LineTo(a), PathSegment::LineTo(b)) => a == b,
1809            (
1810                PathSegment::CurveTo {
1811                    control1: c1a,
1812                    control2: c2a,
1813                    end: ea,
1814                },
1815                PathSegment::CurveTo {
1816                    control1: c1b,
1817                    control2: c2b,
1818                    end: eb,
1819                },
1820            ) => c1a == c1b && c2a == c2b && ea == eb,
1821            (
1822                PathSegment::QuadTo {
1823                    control: ca,
1824                    end: ea,
1825                },
1826                PathSegment::QuadTo {
1827                    control: cb,
1828                    end: eb,
1829                },
1830            ) => ca == cb && ea == eb,
1831            (
1832                PathSegment::Arc {
1833                    center: ca,
1834                    radius: ra,
1835                    start_angle: sa_a,
1836                    end_angle: ea_a,
1837                },
1838                PathSegment::Arc {
1839                    center: cb,
1840                    radius: rb,
1841                    start_angle: sa_b,
1842                    end_angle: ea_b,
1843                },
1844            ) => ca == cb && round_eq(*ra, *rb) && round_eq(*sa_a, *sa_b) && round_eq(*ea_a, *ea_b),
1845            (PathSegment::Close, PathSegment::Close) => true,
1846            _ => false, // Variants are different
1847        }
1848    }
1849}
1850
1851impl Eq for PathSegment {}
1852
1853// Enhanced content model supporting mixed inline content
1854#[derive(Debug, Clone, Hash)]
1855pub enum InlineContent {
1856    Text(StyledRun),
1857    Image(InlineImage),
1858    Shape(InlineShape),
1859    Space(InlineSpace),
1860    LineBreak(InlineBreak),
1861    /// Tab character - rendered with width based on tab-size CSS property
1862    Tab {
1863        style: Arc<StyleProperties>,
1864    },
1865    /// List marker (::marker pseudo-element)
1866    /// Markers with list-style-position: outside are positioned
1867    /// in the padding gutter of the list container
1868    Marker {
1869        run: StyledRun,
1870        /// Whether marker is positioned outside (in padding) or inside (inline)
1871        position_outside: bool,
1872    },
1873    // Ruby annotation
1874    Ruby {
1875        base: Vec<InlineContent>,
1876        text: Vec<InlineContent>,
1877        // Style for the ruby text itself
1878        style: Arc<StyleProperties>,
1879    },
1880}
1881
1882#[derive(Debug, Clone)]
1883pub struct InlineImage {
1884    pub source: ImageSource,
1885    pub intrinsic_size: Size,
1886    pub display_size: Option<Size>,
1887    // How much to shift baseline
1888    pub baseline_offset: f32,
1889    pub alignment: VerticalAlign,
1890    pub object_fit: ObjectFit,
1891}
1892
1893impl PartialEq for InlineImage {
1894    fn eq(&self, other: &Self) -> bool {
1895        self.baseline_offset.to_bits() == other.baseline_offset.to_bits()
1896            && self.source == other.source
1897            && self.intrinsic_size == other.intrinsic_size
1898            && self.display_size == other.display_size
1899            && self.alignment == other.alignment
1900            && self.object_fit == other.object_fit
1901    }
1902}
1903
1904impl Eq for InlineImage {}
1905
1906impl Hash for InlineImage {
1907    fn hash<H: Hasher>(&self, state: &mut H) {
1908        self.source.hash(state);
1909        self.intrinsic_size.hash(state);
1910        self.display_size.hash(state);
1911        self.baseline_offset.to_bits().hash(state);
1912        self.alignment.hash(state);
1913        self.object_fit.hash(state);
1914    }
1915}
1916
1917impl PartialOrd for InlineImage {
1918    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
1919        Some(self.cmp(other))
1920    }
1921}
1922
1923impl Ord for InlineImage {
1924    fn cmp(&self, other: &Self) -> Ordering {
1925        self.source
1926            .cmp(&other.source)
1927            .then_with(|| self.intrinsic_size.cmp(&other.intrinsic_size))
1928            .then_with(|| self.display_size.cmp(&other.display_size))
1929            .then_with(|| self.baseline_offset.total_cmp(&other.baseline_offset))
1930            .then_with(|| self.alignment.cmp(&other.alignment))
1931            .then_with(|| self.object_fit.cmp(&other.object_fit))
1932    }
1933}
1934
1935/// Enhanced glyph with all features
1936#[derive(Debug, Clone)]
1937pub struct Glyph {
1938    // Core glyph data
1939    pub glyph_id: u16,
1940    pub codepoint: char,
1941    /// Hash of the font - use LoadedFonts to look up the actual font when needed
1942    pub font_hash: u64,
1943    /// Cached font metrics to avoid font lookup for common operations
1944    pub font_metrics: LayoutFontMetrics,
1945    pub style: Arc<StyleProperties>,
1946    pub source: GlyphSource,
1947
1948    // Text mapping
1949    pub logical_byte_index: usize,
1950    pub logical_byte_len: usize,
1951    pub content_index: usize,
1952    pub cluster: u32,
1953
1954    // Metrics
1955    pub advance: f32,
1956    pub kerning: f32,
1957    pub offset: Point,
1958
1959    // Vertical text support
1960    pub vertical_advance: f32,
1961    pub vertical_origin_y: f32, // from VORG
1962    pub vertical_bearing: Point,
1963    pub orientation: GlyphOrientation,
1964
1965    // Layout properties
1966    pub script: Script,
1967    pub bidi_level: BidiLevel,
1968}
1969
1970impl Glyph {
1971    #[inline]
1972    fn bounds(&self) -> Rect {
1973        Rect {
1974            x: 0.0,
1975            y: 0.0,
1976            width: self.advance,
1977            height: self.style.line_height.resolve_with_metrics(self.style.font_size_px, &self.font_metrics),
1978        }
1979    }
1980
1981    #[inline]
1982    fn character_class(&self) -> CharacterClass {
1983        classify_character(self.codepoint as u32)
1984    }
1985
1986    #[inline]
1987    fn is_whitespace(&self) -> bool {
1988        self.character_class() == CharacterClass::Space
1989    }
1990
1991    #[inline]
1992    fn can_justify(&self) -> bool {
1993        !self.codepoint.is_whitespace() && self.character_class() != CharacterClass::Combining
1994    }
1995
1996    #[inline]
1997    fn justification_priority(&self) -> u8 {
1998        get_justification_priority(self.character_class())
1999    }
2000
2001    #[inline]
2002    fn break_opportunity_after(&self) -> bool {
2003        let is_whitespace = self.codepoint.is_whitespace();
2004        let is_soft_hyphen = self.codepoint == '\u{00AD}';
2005        let is_hyphen_minus = self.codepoint == '\u{002D}';
2006        let is_hyphen = self.codepoint == '\u{2010}';
2007        is_whitespace || is_soft_hyphen || is_hyphen_minus || is_hyphen
2008    }
2009}
2010
2011// Information about text runs after initial analysis
2012#[derive(Debug, Clone)]
2013pub struct TextRunInfo<'a> {
2014    pub text: &'a str,
2015    pub style: Arc<StyleProperties>,
2016    pub logical_start: usize,
2017    pub content_index: usize,
2018}
2019
2020#[derive(Debug, Clone)]
2021pub enum ImageSource {
2022    /// Direct reference to decoded image (from DOM NodeType::Image)
2023    Ref(ImageRef),
2024    /// CSS url reference (from background-image, needs ImageCache lookup)
2025    Url(String),
2026    /// Raw image data
2027    Data(Arc<[u8]>),
2028    /// SVG source
2029    Svg(Arc<str>),
2030    /// Placeholder for layout without actual image
2031    Placeholder(Size),
2032}
2033
2034impl PartialEq for ImageSource {
2035    fn eq(&self, other: &Self) -> bool {
2036        match (self, other) {
2037            (ImageSource::Ref(a), ImageSource::Ref(b)) => a.get_hash() == b.get_hash(),
2038            (ImageSource::Url(a), ImageSource::Url(b)) => a == b,
2039            (ImageSource::Data(a), ImageSource::Data(b)) => Arc::ptr_eq(a, b),
2040            (ImageSource::Svg(a), ImageSource::Svg(b)) => Arc::ptr_eq(a, b),
2041            (ImageSource::Placeholder(a), ImageSource::Placeholder(b)) => {
2042                a.width.to_bits() == b.width.to_bits() && a.height.to_bits() == b.height.to_bits()
2043            }
2044            _ => false,
2045        }
2046    }
2047}
2048
2049impl Eq for ImageSource {}
2050
2051impl std::hash::Hash for ImageSource {
2052    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
2053        core::mem::discriminant(self).hash(state);
2054        match self {
2055            ImageSource::Ref(r) => r.get_hash().hash(state),
2056            ImageSource::Url(s) => s.hash(state),
2057            ImageSource::Data(d) => (Arc::as_ptr(d) as *const u8 as usize).hash(state),
2058            ImageSource::Svg(s) => (Arc::as_ptr(s) as *const u8 as usize).hash(state),
2059            ImageSource::Placeholder(sz) => {
2060                sz.width.to_bits().hash(state);
2061                sz.height.to_bits().hash(state);
2062            }
2063        }
2064    }
2065}
2066
2067impl PartialOrd for ImageSource {
2068    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
2069        Some(self.cmp(other))
2070    }
2071}
2072
2073impl Ord for ImageSource {
2074    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
2075        fn variant_index(s: &ImageSource) -> u8 {
2076            match s {
2077                ImageSource::Ref(_) => 0,
2078                ImageSource::Url(_) => 1,
2079                ImageSource::Data(_) => 2,
2080                ImageSource::Svg(_) => 3,
2081                ImageSource::Placeholder(_) => 4,
2082            }
2083        }
2084        match (self, other) {
2085            (ImageSource::Ref(a), ImageSource::Ref(b)) => a.get_hash().cmp(&b.get_hash()),
2086            (ImageSource::Url(a), ImageSource::Url(b)) => a.cmp(b),
2087            (ImageSource::Data(a), ImageSource::Data(b)) => {
2088                (Arc::as_ptr(a) as *const u8 as usize).cmp(&(Arc::as_ptr(b) as *const u8 as usize))
2089            }
2090            (ImageSource::Svg(a), ImageSource::Svg(b)) => {
2091                (Arc::as_ptr(a) as *const u8 as usize).cmp(&(Arc::as_ptr(b) as *const u8 as usize))
2092            }
2093            (ImageSource::Placeholder(a), ImageSource::Placeholder(b)) => {
2094                (a.width.to_bits(), a.height.to_bits())
2095                    .cmp(&(b.width.to_bits(), b.height.to_bits()))
2096            }
2097            // Different variants: compare by variant index
2098            _ => variant_index(self).cmp(&variant_index(other)),
2099        }
2100    }
2101}
2102
2103// +spec:font-metrics:fa104e - vertical-align values; baseline-source defaults to auto (first baseline)
2104// +spec:inline-formatting-context:340729 - alignment-baseline values for IFC baseline alignment (only baseline/top/bottom/middle implemented)
2105// CSS 2.2 §10.8.1 vertical-align property values
2106// +spec:display-property:0b1deb - inline boxes use dominant baseline to align text and inline-level children
2107// +spec:inline-formatting-context:3996a6 - dominant-baseline defaults to alphabetic in horizontal mode; vertical-align handles baseline alignment and super/sub shifting
2108#[derive(Default, Debug, Clone, Copy, PartialEq, PartialOrd)]
2109pub enum VerticalAlign {
2110    // Align baseline of box with baseline of parent box
2111    #[default]
2112    Baseline,
2113    // Align bottom of aligned subtree with bottom of line box
2114    Bottom,
2115    // Align top of aligned subtree with top of line box
2116    Top,
2117    // Align vertical midpoint of box with baseline of parent plus half x-height
2118    Middle,
2119    // Align top of box with top of parent's content area (§10.6.1)
2120    TextTop,
2121    // Align bottom of box with bottom of parent's content area (§10.6.1)
2122    TextBottom,
2123    // Lower baseline to proper subscript position
2124    Sub,
2125    // Raise baseline to proper superscript position
2126    Super,
2127    // +spec:font-metrics:152df3 - Raise (positive) or lower (negative) by this distance; 0 = baseline
2128    Offset(f32),
2129}
2130
2131impl std::hash::Hash for VerticalAlign {
2132    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
2133        core::mem::discriminant(self).hash(state);
2134        if let VerticalAlign::Offset(f) = self {
2135            f.to_bits().hash(state);
2136        }
2137    }
2138}
2139
2140impl Eq for VerticalAlign {}
2141
2142impl Ord for VerticalAlign {
2143    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
2144        self.partial_cmp(other).unwrap_or(std::cmp::Ordering::Equal)
2145    }
2146}
2147
2148#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, PartialOrd, Ord)]
2149pub enum ObjectFit {
2150    // Stretch to fit display size
2151    Fill,
2152    // Scale to fit within display size
2153    Contain,
2154    // Scale to cover display size
2155    Cover,
2156    // Use intrinsic size
2157    None,
2158    // Like contain but never scale up
2159    ScaleDown,
2160}
2161
2162/// Border information for inline elements (display: inline, inline-block)
2163///
2164/// This stores the resolved border properties needed for rendering inline element borders.
2165/// Unlike block elements which render borders via paint_node_background_and_border(),
2166/// inline element borders must be rendered per glyph-run to handle line breaks correctly.
2167#[derive(Debug, Clone, PartialEq)]
2168pub struct InlineBorderInfo {
2169    /// Border widths in pixels for each side
2170    pub top: f32,
2171    pub right: f32,
2172    pub bottom: f32,
2173    pub left: f32,
2174    /// Border colors for each side
2175    pub top_color: ColorU,
2176    pub right_color: ColorU,
2177    pub bottom_color: ColorU,
2178    pub left_color: ColorU,
2179    /// Border radius (if any)
2180    pub radius: Option<f32>,
2181    /// Padding widths in pixels for each side (needed to expand background rect)
2182    pub padding_top: f32,
2183    pub padding_right: f32,
2184    pub padding_bottom: f32,
2185    pub padding_left: f32,
2186    // +spec:box-model:c5723b - inline box split: suppress margin/border/padding at split points
2187    /// CSS 2.2 §9.4.2 / §8.6: when an inline box is split across line boxes,
2188    /// margins, borders, and padding have no visible effect at the split points.
2189    /// True if this is the first fragment of the inline box.
2190    pub is_first_fragment: bool,
2191    /// True if this is the last fragment of the inline box.
2192    pub is_last_fragment: bool,
2193    /// CSS 2.2 §8.6: direction flag for visual-order rendering in bidi context.
2194    /// LTR: first fragment gets left edge, last gets right edge.
2195    /// RTL: first fragment gets right edge, last gets left edge.
2196    pub is_rtl: bool,
2197}
2198
2199impl Default for InlineBorderInfo {
2200    fn default() -> Self {
2201        Self {
2202            top: 0.0,
2203            right: 0.0,
2204            bottom: 0.0,
2205            left: 0.0,
2206            top_color: ColorU::TRANSPARENT,
2207            right_color: ColorU::TRANSPARENT,
2208            bottom_color: ColorU::TRANSPARENT,
2209            left_color: ColorU::TRANSPARENT,
2210            radius: None,
2211            padding_top: 0.0,
2212            padding_right: 0.0,
2213            padding_bottom: 0.0,
2214            padding_left: 0.0,
2215            is_first_fragment: true,
2216            is_last_fragment: true,
2217            is_rtl: false,
2218        }
2219    }
2220}
2221
2222impl InlineBorderInfo {
2223    /// Returns true if any border has a non-zero width
2224    pub fn has_border(&self) -> bool {
2225        self.top > 0.0 || self.right > 0.0 || self.bottom > 0.0 || self.left > 0.0
2226    }
2227
2228    /// Returns true if any border or padding is present
2229    pub fn has_chrome(&self) -> bool {
2230        self.has_border()
2231            || self.padding_top > 0.0
2232            || self.padding_right > 0.0
2233            || self.padding_bottom > 0.0
2234            || self.padding_left > 0.0
2235    }
2236
2237    // +spec:box-model:da0ba2 - RTL bidi inline box split: left/right edges assigned to correct fragments
2238    // +spec:box-model:e9144f - visual-order margin/border/padding for inline boxes in bidi context
2239    // +spec:box-model:fac66f - Assigns margins/borders/padding in visual order for bidi inline fragments
2240    // +spec:box-model:720688 - LTR: left on first, right on last; RTL: right on first, left on last
2241    // +spec:positioning:1fcad6 - bidi-aware margin/border/padding on inline box fragments per visual order
2242    /// Total left inset (border + padding), suppressed at split points per §8.6.
2243    /// In LTR: left edge drawn on first fragment. In RTL: left edge drawn on last fragment.
2244    // +spec:box-model:bae97f - visual-order margin/border/padding assignment for bidi inline fragments
2245    pub fn left_inset(&self) -> f32 {
2246        let show = if self.is_rtl { self.is_last_fragment } else { self.is_first_fragment };
2247        if show { self.left + self.padding_left } else { 0.0 }
2248    }
2249    /// Total right inset (border + padding), suppressed at split points per §8.6.
2250    /// In LTR: right edge drawn on last fragment. In RTL: right edge drawn on first fragment.
2251    pub fn right_inset(&self) -> f32 {
2252        let show = if self.is_rtl { self.is_first_fragment } else { self.is_last_fragment };
2253        if show { self.right + self.padding_right } else { 0.0 }
2254    }
2255    /// Total top inset (border + padding)
2256    pub fn top_inset(&self) -> f32 { self.top + self.padding_top }
2257    /// Total bottom inset (border + padding)
2258    pub fn bottom_inset(&self) -> f32 { self.bottom + self.padding_bottom }
2259}
2260
2261#[derive(Debug, Clone)]
2262pub struct InlineShape {
2263    pub shape_def: ShapeDefinition,
2264    pub fill: Option<ColorU>,
2265    pub stroke: Option<Stroke>,
2266    pub baseline_offset: f32,
2267    /// Per-item vertical alignment (CSS `vertical-align` on the inline-block element).
2268    /// This overrides the global `TextStyleOptions::vertical_align` for this shape.
2269    pub alignment: VerticalAlign,
2270    /// The NodeId of the element that created this shape
2271    /// (e.g., inline-block) - this allows us to look up
2272    /// styling information (background, border) when rendering
2273    pub source_node_id: Option<azul_core::dom::NodeId>,
2274}
2275
2276#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
2277pub enum OverflowBehavior {
2278    // Content extends outside shape
2279    Visible,
2280    // Content is clipped to shape
2281    Hidden,
2282    // Scrollable overflow
2283    Scroll,
2284    // Browser/system decides
2285    #[default]
2286    Auto,
2287    // Break into next shape/page
2288    Break,
2289}
2290
2291#[derive(Debug, Clone)]
2292pub struct MeasuredImage {
2293    pub source: ImageSource,
2294    pub size: Size,
2295    pub baseline_offset: f32,
2296    pub alignment: VerticalAlign,
2297    pub content_index: usize,
2298}
2299
2300#[derive(Debug, Clone)]
2301pub struct MeasuredShape {
2302    pub shape_def: ShapeDefinition,
2303    pub size: Size,
2304    pub baseline_offset: f32,
2305    pub alignment: VerticalAlign,
2306    pub content_index: usize,
2307}
2308
2309#[derive(Debug, Clone)]
2310pub struct InlineSpace {
2311    pub width: f32,
2312    pub is_breaking: bool, // Can line break here
2313    pub is_stretchy: bool, // Can be expanded for justification
2314}
2315
2316impl PartialEq for InlineSpace {
2317    fn eq(&self, other: &Self) -> bool {
2318        self.width.to_bits() == other.width.to_bits()
2319            && self.is_breaking == other.is_breaking
2320            && self.is_stretchy == other.is_stretchy
2321    }
2322}
2323
2324impl Eq for InlineSpace {}
2325
2326impl Hash for InlineSpace {
2327    fn hash<H: Hasher>(&self, state: &mut H) {
2328        self.width.to_bits().hash(state);
2329        self.is_breaking.hash(state);
2330        self.is_stretchy.hash(state);
2331    }
2332}
2333
2334impl PartialOrd for InlineSpace {
2335    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
2336        Some(self.cmp(other))
2337    }
2338}
2339
2340impl Ord for InlineSpace {
2341    fn cmp(&self, other: &Self) -> Ordering {
2342        self.width
2343            .total_cmp(&other.width)
2344            .then_with(|| self.is_breaking.cmp(&other.is_breaking))
2345            .then_with(|| self.is_stretchy.cmp(&other.is_stretchy))
2346    }
2347}
2348
2349impl PartialEq for InlineShape {
2350    fn eq(&self, other: &Self) -> bool {
2351        self.baseline_offset.to_bits() == other.baseline_offset.to_bits()
2352            && self.shape_def == other.shape_def
2353            && self.fill == other.fill
2354            && self.stroke == other.stroke
2355            && self.alignment == other.alignment
2356            && self.source_node_id == other.source_node_id
2357    }
2358}
2359
2360impl Eq for InlineShape {}
2361
2362impl Hash for InlineShape {
2363    fn hash<H: Hasher>(&self, state: &mut H) {
2364        self.shape_def.hash(state);
2365        self.fill.hash(state);
2366        self.stroke.hash(state);
2367        self.baseline_offset.to_bits().hash(state);
2368        self.alignment.hash(state);
2369        self.source_node_id.hash(state);
2370    }
2371}
2372
2373impl PartialOrd for InlineShape {
2374    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
2375        Some(
2376            self.shape_def
2377                .partial_cmp(&other.shape_def)?
2378                .then_with(|| self.fill.cmp(&other.fill))
2379                .then_with(|| {
2380                    self.stroke
2381                        .partial_cmp(&other.stroke)
2382                        .unwrap_or(Ordering::Equal)
2383                })
2384                .then_with(|| self.baseline_offset.total_cmp(&other.baseline_offset))
2385                .then_with(|| self.alignment.cmp(&other.alignment))
2386                .then_with(|| self.source_node_id.cmp(&other.source_node_id)),
2387        )
2388    }
2389}
2390
2391#[derive(Debug, Default, Clone, Copy)]
2392pub struct Rect {
2393    pub x: f32,
2394    pub y: f32,
2395    pub width: f32,
2396    pub height: f32,
2397}
2398
2399impl PartialEq for Rect {
2400    fn eq(&self, other: &Self) -> bool {
2401        round_eq(self.x, other.x)
2402            && round_eq(self.y, other.y)
2403            && round_eq(self.width, other.width)
2404            && round_eq(self.height, other.height)
2405    }
2406}
2407impl Eq for Rect {}
2408
2409impl Hash for Rect {
2410    fn hash<H: Hasher>(&self, state: &mut H) {
2411        // The order in which you hash the fields matters.
2412        // A consistent order is crucial.
2413        (self.x.round() as usize).hash(state);
2414        (self.y.round() as usize).hash(state);
2415        (self.width.round() as usize).hash(state);
2416        (self.height.round() as usize).hash(state);
2417    }
2418}
2419
2420#[derive(Debug, Default, Clone, Copy, PartialOrd)]
2421pub struct Size {
2422    pub width: f32,
2423    pub height: f32,
2424}
2425
2426impl Ord for Size {
2427    fn cmp(&self, other: &Self) -> Ordering {
2428        (self.width.round() as usize)
2429            .cmp(&(other.width.round() as usize))
2430            .then_with(|| (self.height.round() as usize).cmp(&(other.height.round() as usize)))
2431    }
2432}
2433
2434// Size
2435impl Hash for Size {
2436    fn hash<H: Hasher>(&self, state: &mut H) {
2437        (self.width.round() as usize).hash(state);
2438        (self.height.round() as usize).hash(state);
2439    }
2440}
2441impl PartialEq for Size {
2442    fn eq(&self, other: &Self) -> bool {
2443        round_eq(self.width, other.width) && round_eq(self.height, other.height)
2444    }
2445}
2446impl Eq for Size {}
2447
2448impl Size {
2449    pub const fn zero() -> Self {
2450        Self::new(0.0, 0.0)
2451    }
2452    pub const fn new(width: f32, height: f32) -> Self {
2453        Self { width, height }
2454    }
2455}
2456
2457#[derive(Debug, Default, Clone, Copy, PartialOrd)]
2458pub struct Point {
2459    pub x: f32,
2460    pub y: f32,
2461}
2462
2463// Point
2464impl Hash for Point {
2465    fn hash<H: Hasher>(&self, state: &mut H) {
2466        (self.x.round() as usize).hash(state);
2467        (self.y.round() as usize).hash(state);
2468    }
2469}
2470
2471impl PartialEq for Point {
2472    fn eq(&self, other: &Self) -> bool {
2473        round_eq(self.x, other.x) && round_eq(self.y, other.y)
2474    }
2475}
2476
2477impl Eq for Point {}
2478
2479#[derive(Debug, Clone, PartialOrd)]
2480pub enum ShapeDefinition {
2481    Rectangle {
2482        size: Size,
2483        corner_radius: Option<f32>,
2484    },
2485    Circle {
2486        radius: f32,
2487    },
2488    Ellipse {
2489        radii: Size,
2490    },
2491    Polygon {
2492        points: Vec<Point>,
2493    },
2494    Path {
2495        segments: Vec<PathSegment>,
2496    },
2497}
2498
2499// ShapeDefinition
2500impl Hash for ShapeDefinition {
2501    fn hash<H: Hasher>(&self, state: &mut H) {
2502        discriminant(self).hash(state);
2503        match self {
2504            ShapeDefinition::Rectangle {
2505                size,
2506                corner_radius,
2507            } => {
2508                size.hash(state);
2509                corner_radius.map(|r| r.round() as usize).hash(state);
2510            }
2511            ShapeDefinition::Circle { radius } => {
2512                (radius.round() as usize).hash(state);
2513            }
2514            ShapeDefinition::Ellipse { radii } => {
2515                radii.hash(state);
2516            }
2517            ShapeDefinition::Polygon { points } => {
2518                // Since Point implements Hash, we can hash the Vec directly.
2519                points.hash(state);
2520            }
2521            ShapeDefinition::Path { segments } => {
2522                // Same for Vec<PathSegment>
2523                segments.hash(state);
2524            }
2525        }
2526    }
2527}
2528
2529impl PartialEq for ShapeDefinition {
2530    fn eq(&self, other: &Self) -> bool {
2531        match (self, other) {
2532            (
2533                ShapeDefinition::Rectangle {
2534                    size: s1,
2535                    corner_radius: r1,
2536                },
2537                ShapeDefinition::Rectangle {
2538                    size: s2,
2539                    corner_radius: r2,
2540                },
2541            ) => {
2542                s1 == s2
2543                    && match (r1, r2) {
2544                        (None, None) => true,
2545                        (Some(v1), Some(v2)) => round_eq(*v1, *v2),
2546                        _ => false,
2547                    }
2548            }
2549            (ShapeDefinition::Circle { radius: r1 }, ShapeDefinition::Circle { radius: r2 }) => {
2550                round_eq(*r1, *r2)
2551            }
2552            (ShapeDefinition::Ellipse { radii: r1 }, ShapeDefinition::Ellipse { radii: r2 }) => {
2553                r1 == r2
2554            }
2555            (ShapeDefinition::Polygon { points: p1 }, ShapeDefinition::Polygon { points: p2 }) => {
2556                p1 == p2
2557            }
2558            (ShapeDefinition::Path { segments: s1 }, ShapeDefinition::Path { segments: s2 }) => {
2559                s1 == s2
2560            }
2561            _ => false,
2562        }
2563    }
2564}
2565impl Eq for ShapeDefinition {}
2566
2567impl ShapeDefinition {
2568    /// Calculates the bounding box size for the shape.
2569    pub fn get_size(&self) -> Size {
2570        match self {
2571            // The size is explicitly defined.
2572            ShapeDefinition::Rectangle { size, .. } => *size,
2573
2574            // The bounding box of a circle is a square with sides equal to the diameter.
2575            ShapeDefinition::Circle { radius } => {
2576                let diameter = radius * 2.0;
2577                Size::new(diameter, diameter)
2578            }
2579
2580            // The bounding box of an ellipse has width and height equal to twice its radii.
2581            ShapeDefinition::Ellipse { radii } => Size::new(radii.width * 2.0, radii.height * 2.0),
2582
2583            // For a polygon, we must find the min/max coordinates to get the bounds.
2584            ShapeDefinition::Polygon { points } => calculate_bounding_box_size(points),
2585
2586            // For a path, we find the bounding box of all its anchor and control points.
2587            //
2588            // NOTE: This is a common and fast approximation. The true bounding box of
2589            // bezier curves can be slightly smaller than the box containing their control
2590            // points. For pixel-perfect results, one would need to calculate the
2591            // curve's extrema.
2592            ShapeDefinition::Path { segments } => {
2593                let mut points = Vec::new();
2594                let mut current_pos = Point { x: 0.0, y: 0.0 };
2595
2596                for segment in segments {
2597                    match segment {
2598                        PathSegment::MoveTo(p) | PathSegment::LineTo(p) => {
2599                            points.push(*p);
2600                            current_pos = *p;
2601                        }
2602                        PathSegment::QuadTo { control, end } => {
2603                            points.push(current_pos);
2604                            points.push(*control);
2605                            points.push(*end);
2606                            current_pos = *end;
2607                        }
2608                        PathSegment::CurveTo {
2609                            control1,
2610                            control2,
2611                            end,
2612                        } => {
2613                            points.push(current_pos);
2614                            points.push(*control1);
2615                            points.push(*control2);
2616                            points.push(*end);
2617                            current_pos = *end;
2618                        }
2619                        PathSegment::Arc {
2620                            center,
2621                            radius,
2622                            start_angle,
2623                            end_angle,
2624                        } => {
2625                            // 1. Calculate and add the arc's start and end points to the list.
2626                            let start_point = Point {
2627                                x: center.x + radius * start_angle.cos(),
2628                                y: center.y + radius * start_angle.sin(),
2629                            };
2630                            let end_point = Point {
2631                                x: center.x + radius * end_angle.cos(),
2632                                y: center.y + radius * end_angle.sin(),
2633                            };
2634                            points.push(start_point);
2635                            points.push(end_point);
2636
2637                            // 2. Normalize the angles to handle cases where the arc crosses the
2638                            //    0-radian line.
2639                            // This ensures we can iterate forward from a start to an end angle.
2640                            let mut normalized_end = *end_angle;
2641                            while normalized_end < *start_angle {
2642                                normalized_end += 2.0 * std::f32::consts::PI;
2643                            }
2644
2645                            // 3. Find the first cardinal point (multiples of PI/2) at or after the
2646                            //    start angle.
2647                            let mut check_angle = (*start_angle / std::f32::consts::FRAC_PI_2)
2648                                .ceil()
2649                                * std::f32::consts::FRAC_PI_2;
2650
2651                            // 4. Iterate through all cardinal points that fall within the arc's
2652                            //    sweep and add them.
2653                            // These points define the maximum extent of the arc's bounding box.
2654                            while check_angle < normalized_end {
2655                                points.push(Point {
2656                                    x: center.x + radius * check_angle.cos(),
2657                                    y: center.y + radius * check_angle.sin(),
2658                                });
2659                                check_angle += std::f32::consts::FRAC_PI_2;
2660                            }
2661
2662                            // 5. The end of the arc is the new current position for subsequent path
2663                            //    segments.
2664                            current_pos = end_point;
2665                        }
2666                        PathSegment::Close => {
2667                            // No new points are added for closing the path
2668                        }
2669                    }
2670                }
2671                calculate_bounding_box_size(&points)
2672            }
2673        }
2674    }
2675}
2676
2677// +spec:text-alignment-spacing:25e82a - text-align shorthand resolves text-align-all / text-align-last
2678/// Resolve effective text alignment for a line, handling text-align-last per CSS Text §6.3.
2679/// For the last line (or lines before forced breaks), text-align-last overrides text-align.
2680/// When text-align-last is auto (default), justify falls back to start; others use text-align.
2681// +spec:text-alignment-spacing:bca77d - text-align-last auto falls back to text-align-all, justify→start
2682// +spec:line-breaking:9b10d2 - text-align-last applies to last line and lines before forced breaks
2683/// +spec:text-alignment-spacing:8d88ce - text-align-last overrides justify on last line/forced break
2684pub(crate) fn resolve_effective_alignment(
2685    text_align: TextAlign,
2686    text_align_last: TextAlign,
2687    is_last_or_forced: bool,
2688) -> TextAlign {
2689    if is_last_or_forced {
2690        if text_align_last == TextAlign::default() {
2691            if text_align == TextAlign::Justify { TextAlign::Start } else { text_align }
2692        } else {
2693            text_align_last
2694        }
2695    } else {
2696        text_align
2697    }
2698}
2699
2700/// Helper function to calculate the size of the bounding box enclosing a set of points.
2701fn calculate_bounding_box_size(points: &[Point]) -> Size {
2702    if points.is_empty() {
2703        return Size::zero();
2704    }
2705
2706    let mut min_x = f32::MAX;
2707    let mut max_x = f32::MIN;
2708    let mut min_y = f32::MAX;
2709    let mut max_y = f32::MIN;
2710
2711    for point in points {
2712        min_x = min_x.min(point.x);
2713        max_x = max_x.max(point.x);
2714        min_y = min_y.min(point.y);
2715        max_y = max_y.max(point.y);
2716    }
2717
2718    // Handle case where points might be collinear or a single point
2719    if min_x > max_x || min_y > max_y {
2720        return Size::zero();
2721    }
2722
2723    Size::new(max_x - min_x, max_y - min_y)
2724}
2725
2726#[derive(Debug, Clone, PartialOrd)]
2727pub struct Stroke {
2728    pub color: ColorU,
2729    pub width: f32,
2730    pub dash_pattern: Option<Vec<f32>>,
2731}
2732
2733// Stroke
2734impl Hash for Stroke {
2735    fn hash<H: Hasher>(&self, state: &mut H) {
2736        self.color.hash(state);
2737        (self.width.round() as usize).hash(state);
2738
2739        // Manual hashing for Option<Vec<f32>>
2740        match &self.dash_pattern {
2741            None => 0u8.hash(state), // Hash a discriminant for None
2742            Some(pattern) => {
2743                1u8.hash(state); // Hash a discriminant for Some
2744                pattern.len().hash(state); // Hash the length
2745                for &val in pattern {
2746                    (val.round() as usize).hash(state); // Hash each rounded value
2747                }
2748            }
2749        }
2750    }
2751}
2752
2753impl PartialEq for Stroke {
2754    fn eq(&self, other: &Self) -> bool {
2755        if self.color != other.color || !round_eq(self.width, other.width) {
2756            return false;
2757        }
2758        match (&self.dash_pattern, &other.dash_pattern) {
2759            (None, None) => true,
2760            (Some(p1), Some(p2)) => {
2761                p1.len() == p2.len() && p1.iter().zip(p2.iter()).all(|(a, b)| round_eq(*a, *b))
2762            }
2763            _ => false,
2764        }
2765    }
2766}
2767
2768impl Eq for Stroke {}
2769
2770// Helper function to round f32 for comparison
2771fn round_eq(a: f32, b: f32) -> bool {
2772    (a.round() as isize) == (b.round() as isize)
2773}
2774
2775#[derive(Debug, Clone)]
2776pub enum ShapeBoundary {
2777    Rectangle(Rect),
2778    Circle { center: Point, radius: f32 },
2779    Ellipse { center: Point, radii: Size },
2780    Polygon { points: Vec<Point> },
2781    Path { segments: Vec<PathSegment> },
2782}
2783
2784impl ShapeBoundary {
2785    pub fn inflate(&self, margin: f32) -> Self {
2786        if margin == 0.0 {
2787            return self.clone();
2788        }
2789        match self {
2790            Self::Rectangle(rect) => Self::Rectangle(Rect {
2791                x: rect.x - margin,
2792                y: rect.y - margin,
2793                width: (rect.width + margin * 2.0).max(0.0),
2794                height: (rect.height + margin * 2.0).max(0.0),
2795            }),
2796            Self::Circle { center, radius } => Self::Circle {
2797                center: *center,
2798                radius: radius + margin,
2799            },
2800            // For simplicity, Polygon and Path inflation is not implemented here.
2801            // A full implementation would require a geometry library to offset the path.
2802            _ => self.clone(),
2803        }
2804    }
2805}
2806
2807// ShapeBoundary
2808impl Hash for ShapeBoundary {
2809    fn hash<H: Hasher>(&self, state: &mut H) {
2810        discriminant(self).hash(state);
2811        match self {
2812            ShapeBoundary::Rectangle(rect) => rect.hash(state),
2813            ShapeBoundary::Circle { center, radius } => {
2814                center.hash(state);
2815                (radius.round() as usize).hash(state);
2816            }
2817            ShapeBoundary::Ellipse { center, radii } => {
2818                center.hash(state);
2819                radii.hash(state);
2820            }
2821            ShapeBoundary::Polygon { points } => points.hash(state),
2822            ShapeBoundary::Path { segments } => segments.hash(state),
2823        }
2824    }
2825}
2826impl PartialEq for ShapeBoundary {
2827    fn eq(&self, other: &Self) -> bool {
2828        match (self, other) {
2829            (ShapeBoundary::Rectangle(r1), ShapeBoundary::Rectangle(r2)) => r1 == r2,
2830            (
2831                ShapeBoundary::Circle {
2832                    center: c1,
2833                    radius: r1,
2834                },
2835                ShapeBoundary::Circle {
2836                    center: c2,
2837                    radius: r2,
2838                },
2839            ) => c1 == c2 && round_eq(*r1, *r2),
2840            (
2841                ShapeBoundary::Ellipse {
2842                    center: c1,
2843                    radii: r1,
2844                },
2845                ShapeBoundary::Ellipse {
2846                    center: c2,
2847                    radii: r2,
2848                },
2849            ) => c1 == c2 && r1 == r2,
2850            (ShapeBoundary::Polygon { points: p1 }, ShapeBoundary::Polygon { points: p2 }) => {
2851                p1 == p2
2852            }
2853            (ShapeBoundary::Path { segments: s1 }, ShapeBoundary::Path { segments: s2 }) => {
2854                s1 == s2
2855            }
2856            _ => false,
2857        }
2858    }
2859}
2860impl Eq for ShapeBoundary {}
2861
2862impl ShapeBoundary {
2863    /// Converts a CSS shape (from azul-css) to a layout engine ShapeBoundary
2864    ///
2865    /// # Arguments
2866    /// * `css_shape` - The parsed CSS shape from azul-css
2867    /// * `reference_box` - The containing box for resolving coordinates (from layout solver)
2868    ///
2869    /// # Returns
2870    /// A ShapeBoundary ready for use in the text layout engine
2871    pub fn from_css_shape(
2872        css_shape: &azul_css::shape::CssShape,
2873        reference_box: Rect,
2874        debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
2875    ) -> Self {
2876        use azul_css::shape::CssShape;
2877
2878        if let Some(msgs) = debug_messages {
2879            msgs.push(LayoutDebugMessage::info(format!(
2880                "[ShapeBoundary::from_css_shape] Input CSS shape: {:?}",
2881                css_shape
2882            )));
2883            msgs.push(LayoutDebugMessage::info(format!(
2884                "[ShapeBoundary::from_css_shape] Reference box: {:?}",
2885                reference_box
2886            )));
2887        }
2888
2889        let result = match css_shape {
2890            CssShape::Circle(circle) => {
2891                let center = Point {
2892                    x: reference_box.x + circle.center.x,
2893                    y: reference_box.y + circle.center.y,
2894                };
2895                if let Some(msgs) = debug_messages {
2896                    msgs.push(LayoutDebugMessage::info(format!(
2897                        "[ShapeBoundary::from_css_shape] Circle - CSS center: ({}, {}), radius: {}",
2898                        circle.center.x, circle.center.y, circle.radius
2899                    )));
2900                    msgs.push(LayoutDebugMessage::info(format!(
2901                        "[ShapeBoundary::from_css_shape] Circle - Absolute center: ({}, {}), \
2902                         radius: {}",
2903                        center.x, center.y, circle.radius
2904                    )));
2905                }
2906                ShapeBoundary::Circle {
2907                    center,
2908                    radius: circle.radius,
2909                }
2910            }
2911
2912            CssShape::Ellipse(ellipse) => {
2913                let center = Point {
2914                    x: reference_box.x + ellipse.center.x,
2915                    y: reference_box.y + ellipse.center.y,
2916                };
2917                let radii = Size {
2918                    width: ellipse.radius_x,
2919                    height: ellipse.radius_y,
2920                };
2921                if let Some(msgs) = debug_messages {
2922                    msgs.push(LayoutDebugMessage::info(format!(
2923                        "[ShapeBoundary::from_css_shape] Ellipse - center: ({}, {}), radii: ({}, \
2924                         {})",
2925                        center.x, center.y, radii.width, radii.height
2926                    )));
2927                }
2928                ShapeBoundary::Ellipse { center, radii }
2929            }
2930
2931            CssShape::Polygon(polygon) => {
2932                let points = polygon
2933                    .points
2934                    .as_ref()
2935                    .iter()
2936                    .map(|pt| Point {
2937                        x: reference_box.x + pt.x,
2938                        y: reference_box.y + pt.y,
2939                    })
2940                    .collect();
2941                if let Some(msgs) = debug_messages {
2942                    msgs.push(LayoutDebugMessage::info(format!(
2943                        "[ShapeBoundary::from_css_shape] Polygon - {} points",
2944                        polygon.points.as_ref().len()
2945                    )));
2946                }
2947                ShapeBoundary::Polygon { points }
2948            }
2949
2950            CssShape::Inset(inset) => {
2951                // Inset defines distances from reference box edges
2952                let x = reference_box.x + inset.inset_left;
2953                let y = reference_box.y + inset.inset_top;
2954                let width = reference_box.width - inset.inset_left - inset.inset_right;
2955                let height = reference_box.height - inset.inset_top - inset.inset_bottom;
2956
2957                if let Some(msgs) = debug_messages {
2958                    msgs.push(LayoutDebugMessage::info(format!(
2959                        "[ShapeBoundary::from_css_shape] Inset - insets: ({}, {}, {}, {})",
2960                        inset.inset_top, inset.inset_right, inset.inset_bottom, inset.inset_left
2961                    )));
2962                    msgs.push(LayoutDebugMessage::info(format!(
2963                        "[ShapeBoundary::from_css_shape] Inset - resulting rect: x={}, y={}, \
2964                         w={}, h={}",
2965                        x, y, width, height
2966                    )));
2967                }
2968
2969                ShapeBoundary::Rectangle(Rect {
2970                    x,
2971                    y,
2972                    width: width.max(0.0),
2973                    height: height.max(0.0),
2974                })
2975            }
2976
2977            CssShape::Path(path) => {
2978                if let Some(msgs) = debug_messages {
2979                    msgs.push(LayoutDebugMessage::info(
2980                        "[ShapeBoundary::from_css_shape] Path - fallback to rectangle".to_string(),
2981                    ));
2982                }
2983                // TODO: Parse SVG path data into PathSegments
2984                // For now, fall back to rectangle
2985                ShapeBoundary::Rectangle(reference_box)
2986            }
2987        };
2988
2989        if let Some(msgs) = debug_messages {
2990            msgs.push(LayoutDebugMessage::info(format!(
2991                "[ShapeBoundary::from_css_shape] Result: {:?}",
2992                result
2993            )));
2994        }
2995        result
2996    }
2997}
2998
2999#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
3000pub struct InlineBreak {
3001    pub break_type: BreakType,
3002    pub clear: ClearType,
3003    pub content_index: usize,
3004}
3005
3006// +spec:line-breaking:d70ffd - Defines forced line break (Hard) vs soft wrap break (Soft) types
3007#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
3008pub enum BreakType {
3009    Soft,   // Soft wrap break: UA creates unforced line breaks to fit content within the measure
3010    Hard,   // Forced line break: explicit line-breaking controls (preserved newline, <br>)
3011    Page,   // Page break
3012    Column, // Column break
3013}
3014
3015#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
3016pub enum ClearType {
3017    None,
3018    Left,
3019    Right,
3020    Both,
3021}
3022
3023// Complex shape constraints for non-rectangular text flow
3024#[derive(Debug, Clone)]
3025pub struct ShapeConstraints {
3026    pub boundaries: Vec<ShapeBoundary>,
3027    pub exclusions: Vec<ShapeBoundary>,
3028    pub writing_mode: WritingMode,
3029    pub text_align: TextAlign,
3030    pub line_height: LineHeight,
3031}
3032
3033#[derive(Debug, Clone, Copy, PartialEq, Default, Hash, Eq, PartialOrd, Ord)]
3034pub enum WritingMode {
3035    #[default]
3036    HorizontalTb, // horizontal-tb (normal horizontal)
3037    VerticalRl, // +spec:writing-modes:6e22a7 - vertical-rl (vertical right-to-left, commonly used in East Asia)
3038    VerticalLr, // vertical-lr (vertical left-to-right)
3039    SidewaysRl, // sideways-rl (rotated horizontal in vertical context)
3040    SidewaysLr, // sideways-lr (rotated horizontal in vertical context)
3041}
3042
3043impl WritingMode {
3044    /// Necessary to determine if the glyphs are advancing in a horizontal direction
3045    pub fn is_advance_horizontal(&self) -> bool {
3046        matches!(
3047            self,
3048            WritingMode::HorizontalTb | WritingMode::SidewaysRl | WritingMode::SidewaysLr
3049        )
3050    }
3051}
3052
3053#[derive(Debug, Clone, Copy, PartialEq, Default, Hash, Eq, PartialOrd, Ord)]
3054pub enum JustifyContent {
3055    #[default]
3056    None,
3057    InterWord,      // Expand spaces between words
3058    InterCharacter, // Expand spaces between all characters (for CJK)
3059    Distribute,     // Distribute space evenly including start/end
3060    Kashida,        // Stretch Arabic text using kashidas
3061}
3062
3063// Enhanced text alignment with logical directions
3064#[derive(Debug, Clone, Copy, PartialEq, Default, Hash, Eq, PartialOrd, Ord)]
3065pub enum TextAlign {
3066    #[default]
3067    Left,
3068    Right,
3069    Center,
3070    Justify,
3071    Start,
3072    End,        // Logical start/end
3073    JustifyAll, // Justify including last line
3074}
3075
3076// +spec:block-formatting-context:458d31 - vertical text orientation: upright for horizontal scripts, intrinsic for vertical scripts
3077// Vertical text orientation for individual characters
3078#[derive(Debug, Clone, Copy, PartialEq, Default, Eq, PartialOrd, Ord, Hash)]
3079pub enum TextOrientation {
3080    #[default]
3081    Mixed, // Default: upright for scripts, rotated for others
3082    Upright,  // All characters upright
3083    Sideways, // All characters rotated 90 degrees
3084}
3085
3086#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
3087pub struct TextDecoration {
3088    pub underline: bool,
3089    pub strikethrough: bool,
3090    pub overline: bool,
3091}
3092
3093impl Default for TextDecoration {
3094    fn default() -> Self {
3095        TextDecoration {
3096            underline: false,
3097            overline: false,
3098            strikethrough: false,
3099        }
3100    }
3101}
3102
3103impl TextDecoration {
3104    /// Convert from CSS StyleTextDecoration enum to our internal representation.
3105    /// 
3106    /// Note: CSS text-decoration can have multiple values (underline line-through),
3107    /// but the current azul-css parser only supports single values. This can be
3108    /// extended in the future if CSS parsing is updated.
3109    pub fn from_css(css: azul_css::props::style::text::StyleTextDecoration) -> Self {
3110        use azul_css::props::style::text::StyleTextDecoration;
3111        match css {
3112            StyleTextDecoration::None => Self::default(),
3113            StyleTextDecoration::Underline => Self {
3114                underline: true,
3115                strikethrough: false,
3116                overline: false,
3117            },
3118            StyleTextDecoration::Overline => Self {
3119                underline: false,
3120                strikethrough: false,
3121                overline: true,
3122            },
3123            StyleTextDecoration::LineThrough => Self {
3124                underline: false,
3125                strikethrough: true,
3126                overline: false,
3127            },
3128        }
3129    }
3130}
3131
3132#[derive(Debug, Clone, Copy, PartialEq, Hash, Eq, PartialOrd, Ord, Default)]
3133pub enum TextTransform {
3134    #[default]
3135    None,
3136    Uppercase,
3137    Lowercase,
3138    Capitalize,
3139    // only within preserved white space (non-preserved spaces already collapsed in Phase I)
3140    FullWidth,
3141}
3142
3143// Type alias for OpenType feature tags
3144pub type FourCc = [u8; 4];
3145
3146// Enum for relative or absolute spacing
3147#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
3148pub enum Spacing {
3149    Px(i32), // Use integer pixels to simplify hashing and equality
3150    Em(f32),
3151}
3152
3153// A type that implements `Hash` must also implement `Eq`.
3154// Since f32 does not implement `Eq`, we provide a manual implementation.
3155// The derived `PartialEq` is sufficient for this marker trait.
3156impl Eq for Spacing {}
3157
3158impl Hash for Spacing {
3159    fn hash<H: Hasher>(&self, state: &mut H) {
3160        // First, hash the enum variant to distinguish between Px and Em.
3161        discriminant(self).hash(state);
3162        match self {
3163            Spacing::Px(val) => val.hash(state),
3164            // For hashing floats, convert them to their raw bit representation.
3165            // This ensures that identical float values produce identical hashes.
3166            Spacing::Em(val) => val.to_bits().hash(state),
3167        }
3168    }
3169}
3170
3171impl Default for Spacing {
3172    fn default() -> Self {
3173        Spacing::Px(0)
3174    }
3175}
3176
3177impl Default for FontHash {
3178    fn default() -> Self {
3179        Self::invalid()
3180    }
3181}
3182
3183/// Style properties with vertical text support
3184#[derive(Debug, Clone, PartialEq)]
3185pub struct StyleProperties {
3186    /// Font stack for fallback support (priority order)
3187    /// Can be either a list of FontSelectors (resolved via fontconfig)
3188    /// or a direct FontRef (bypasses fontconfig entirely).
3189    pub font_stack: FontStack,
3190    pub font_size_px: f32,
3191    pub color: ColorU,
3192    /// Background color for inline elements (e.g., `<span style="background-color: yellow">`)
3193    ///
3194    /// This is propagated from CSS through the style system and eventually used by
3195    /// the PDF renderer to draw filled rectangles behind text. The value is `None`
3196    /// for transparent backgrounds (the default).
3197    ///
3198    /// The propagation chain is:
3199    /// CSS -> `get_style_properties()` -> `StyleProperties` -> `ShapedGlyph` -> `PdfGlyphRun`
3200    ///
3201    /// See `PdfGlyphRun::background_color` for how this is used in PDF rendering.
3202    pub background_color: Option<ColorU>,
3203    /// Full background content layers (for gradients, images, etc.)
3204    /// This extends background_color to support CSS gradients on inline elements.
3205    pub background_content: Vec<StyleBackgroundContent>,
3206    /// Border information for inline elements
3207    pub border: Option<InlineBorderInfo>,
3208    // +spec:text-alignment-spacing:b39a04 - word-spacing and letter-spacing control text spacing
3209    pub letter_spacing: Spacing,
3210    pub word_spacing: Spacing,
3211
3212    pub line_height: LineHeight,
3213    pub text_decoration: TextDecoration,
3214
3215    // Represents CSS font-feature-settings like `"liga"`, `"smcp=1"`.
3216    pub font_features: Vec<String>,
3217
3218    // Variable fonts
3219    pub font_variations: Vec<(FourCc, f32)>,
3220    // Multiplier of the space width
3221    pub tab_size: f32,
3222    // text-transform
3223    pub text_transform: TextTransform,
3224    // Vertical text properties
3225    pub writing_mode: WritingMode,
3226    pub text_orientation: TextOrientation,
3227    // Tate-chu-yoko
3228    pub text_combine_upright: Option<TextCombineUpright>,
3229
3230    // Variant handling
3231    pub font_variant_caps: FontVariantCaps,
3232    pub font_variant_numeric: FontVariantNumeric,
3233    pub font_variant_ligatures: FontVariantLigatures,
3234    pub font_variant_east_asian: FontVariantEastAsian,
3235}
3236
3237impl Default for StyleProperties {
3238    fn default() -> Self {
3239        const FONT_SIZE: f32 = 16.0;
3240        const TAB_SIZE: f32 = 8.0;
3241        Self {
3242            font_stack: FontStack::default(),
3243            font_size_px: FONT_SIZE,
3244            color: ColorU::default(),
3245            background_color: None,
3246            background_content: Vec::new(),
3247            border: None,
3248            letter_spacing: Spacing::default(), // Px(0)
3249            word_spacing: Spacing::default(),   // Px(0)
3250            line_height: LineHeight::Normal,
3251            text_decoration: TextDecoration::default(),
3252            font_features: Vec::new(),
3253            font_variations: Vec::new(),
3254            tab_size: TAB_SIZE, // CSS default
3255            text_transform: TextTransform::default(),
3256            writing_mode: WritingMode::default(),
3257            text_orientation: TextOrientation::default(),
3258            text_combine_upright: None,
3259            font_variant_caps: FontVariantCaps::default(),
3260            font_variant_numeric: FontVariantNumeric::default(),
3261            font_variant_ligatures: FontVariantLigatures::default(),
3262            font_variant_east_asian: FontVariantEastAsian::default(),
3263        }
3264    }
3265}
3266
3267impl Hash for StyleProperties {
3268    fn hash<H: Hasher>(&self, state: &mut H) {
3269        self.font_stack.hash(state);
3270        self.color.hash(state);
3271        self.background_color.hash(state);
3272        self.text_decoration.hash(state);
3273        self.font_features.hash(state);
3274        self.writing_mode.hash(state);
3275        self.text_orientation.hash(state);
3276        self.text_combine_upright.hash(state);
3277        self.letter_spacing.hash(state);
3278        self.word_spacing.hash(state);
3279
3280        // For f32 fields, round and cast to usize before hashing.
3281        (self.font_size_px.round() as usize).hash(state);
3282        self.line_height.hash(state);
3283    }
3284}
3285
3286impl StyleProperties {
3287    /// Returns a hash that only includes properties that affect text layout.
3288    /// 
3289    /// Properties that DON'T affect layout (only rendering):
3290    /// - color, background_color, background_content
3291    /// - text_decoration (underline, etc.)
3292    /// - border (for inline elements)
3293    ///
3294    /// Properties that DO affect layout:
3295    /// - font_stack, font_size_px, font_features, font_variations
3296    /// - letter_spacing, word_spacing, line_height, tab_size
3297    /// - writing_mode, text_orientation, text_combine_upright
3298    /// - text_transform
3299    /// - font_variant_* (affects glyph selection)
3300    ///
3301    /// This allows the layout cache to reuse layouts when only rendering
3302    /// properties change (e.g., color changes on hover).
3303    // (family, weight, style) so that shaping runs break at element boundaries where font
3304    // properties differ, preventing impossible cross-boundary ligatures (e.g. "and" → "&").
3305    pub fn layout_hash(&self) -> u64 {
3306        use std::hash::Hasher;
3307        let mut hasher = std::collections::hash_map::DefaultHasher::new();
3308
3309        // Font selection (affects shaping and metrics)
3310        self.font_stack.hash(&mut hasher);
3311        (self.font_size_px.round() as usize).hash(&mut hasher);
3312        self.font_features.hash(&mut hasher);
3313        // font_variations affects glyph outlines
3314        for (tag, value) in &self.font_variations {
3315            tag.hash(&mut hasher);
3316            (value.round() as i32).hash(&mut hasher);
3317        }
3318        
3319        // Spacing (affects glyph positions)
3320        self.letter_spacing.hash(&mut hasher);
3321        self.word_spacing.hash(&mut hasher);
3322        self.line_height.hash(&mut hasher);
3323        (self.tab_size.round() as usize).hash(&mut hasher);
3324        
3325        // Writing mode (affects layout direction)
3326        self.writing_mode.hash(&mut hasher);
3327        self.text_orientation.hash(&mut hasher);
3328        self.text_combine_upright.hash(&mut hasher);
3329        
3330        // Text transform (affects which characters are used)
3331        self.text_transform.hash(&mut hasher);
3332        
3333        // Font variants (affect glyph selection)
3334        self.font_variant_caps.hash(&mut hasher);
3335        self.font_variant_numeric.hash(&mut hasher);
3336        self.font_variant_ligatures.hash(&mut hasher);
3337        self.font_variant_east_asian.hash(&mut hasher);
3338        
3339        hasher.finish()
3340    }
3341    
3342    /// Check if two StyleProperties have the same layout-affecting properties.
3343    ///
3344    /// Returns true if the layouts would be identical (only rendering differs).
3345    ///
3346    /// **Note:** This is a fast-path comparison using 64-bit hashes.  Hash
3347    /// collisions are theoretically possible, which could cause the cache to
3348    /// serve a stale layout.  In practice the probability is negligible for
3349    /// the number of distinct `StyleProperties` values in a single document.
3350    pub fn layout_eq(&self, other: &Self) -> bool {
3351        self.layout_hash() == other.layout_hash()
3352    }
3353}
3354
3355#[derive(Debug, Clone, PartialEq, Hash, Eq, PartialOrd, Ord)]
3356pub enum TextCombineUpright {
3357    None,
3358    All,        // Combine all characters in horizontal layout
3359    Digits(u8), // Combine up to N digits
3360}
3361
3362#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
3363pub enum GlyphSource {
3364    /// Glyph generated from a character in the source text.
3365    Char,
3366    /// Glyph inserted dynamically by the layout engine (e.g., a hyphen).
3367    Hyphen,
3368}
3369
3370#[derive(Debug, Clone, Copy, PartialEq)]
3371pub enum CharacterClass {
3372    Space,       // Regular spaces - highest justification priority
3373    Punctuation, // Can sometimes be adjusted
3374    Letter,      // Normal letters
3375    Ideograph,   // CJK characters - can be justified between
3376    Symbol,      // Symbols, emojis
3377    Combining,   // Combining marks - never justified
3378}
3379
3380#[derive(Debug, Clone, Copy, PartialEq)]
3381pub enum GlyphOrientation {
3382    Horizontal, // Keep horizontal (normal in horizontal text)
3383    Vertical,   // Rotate to vertical (normal in vertical text)
3384    Upright,    // Keep upright regardless of writing mode
3385    Mixed,      // Use script-specific default orientation
3386}
3387
3388// Bidi and script detection
3389#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
3390pub enum BidiDirection {
3391    Ltr,
3392    Rtl,
3393}
3394
3395impl BidiDirection {
3396    pub fn is_rtl(&self) -> bool {
3397        matches!(self, BidiDirection::Rtl)
3398    }
3399}
3400
3401/// CSS `unicode-bidi` property values relevant to layout.
3402/// When `Plaintext`, the bidi algorithm uses P2/P3 heuristics to auto-detect
3403/// paragraph direction from text content, instead of the HL1 override from
3404/// the CSS `direction` property.
3405#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
3406pub enum UnicodeBidi {
3407    Normal,
3408    Embed,
3409    Isolate,
3410    BidiOverride,
3411    IsolateOverride,
3412    Plaintext,
3413}
3414
3415impl Default for UnicodeBidi {
3416    fn default() -> Self {
3417        UnicodeBidi::Normal
3418    }
3419}
3420
3421#[derive(Debug, Clone, Copy, PartialEq, Hash, Eq, PartialOrd, Ord, Default)]
3422pub enum FontVariantCaps {
3423    #[default]
3424    Normal,
3425    SmallCaps,
3426    AllSmallCaps,
3427    PetiteCaps,
3428    AllPetiteCaps,
3429    Unicase,
3430    TitlingCaps,
3431}
3432
3433#[derive(Debug, Clone, Copy, PartialEq, Hash, Eq, PartialOrd, Ord, Default)]
3434pub enum FontVariantNumeric {
3435    #[default]
3436    Normal,
3437    LiningNums,
3438    OldstyleNums,
3439    ProportionalNums,
3440    TabularNums,
3441    DiagonalFractions,
3442    StackedFractions,
3443    Ordinal,
3444    SlashedZero,
3445}
3446
3447#[derive(Debug, Clone, Copy, PartialEq, Hash, Eq, PartialOrd, Ord, Default)]
3448pub enum FontVariantLigatures {
3449    #[default]
3450    Normal,
3451    None,
3452    Common,
3453    NoCommon,
3454    Discretionary,
3455    NoDiscretionary,
3456    Historical,
3457    NoHistorical,
3458    Contextual,
3459    NoContextual,
3460}
3461
3462#[derive(Debug, Clone, Copy, PartialEq, Hash, Eq, PartialOrd, Ord, Default)]
3463pub enum FontVariantEastAsian {
3464    #[default]
3465    Normal,
3466    Jis78,
3467    Jis83,
3468    Jis90,
3469    Jis04,
3470    Simplified,
3471    Traditional,
3472    FullWidth,
3473    ProportionalWidth,
3474    Ruby,
3475}
3476
3477#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
3478pub struct BidiLevel(u8);
3479
3480impl BidiLevel {
3481    pub fn new(level: u8) -> Self {
3482        Self(level)
3483    }
3484    pub fn is_rtl(&self) -> bool {
3485        self.0 % 2 == 1
3486    }
3487    pub fn level(&self) -> u8 {
3488        self.0
3489    }
3490}
3491
3492// Add this new struct for style overrides
3493#[derive(Debug, Clone)]
3494pub struct StyleOverride {
3495    /// The specific character this override applies to.
3496    pub target: ContentIndex,
3497    /// The style properties to apply.
3498    /// Any `None` value means "inherit from the base style".
3499    pub style: PartialStyleProperties,
3500}
3501
3502#[derive(Debug, Clone, Default)]
3503pub struct PartialStyleProperties {
3504    pub font_stack: Option<FontStack>,
3505    pub font_size_px: Option<f32>,
3506    pub color: Option<ColorU>,
3507    pub letter_spacing: Option<Spacing>,
3508    pub word_spacing: Option<Spacing>,
3509    pub line_height: Option<LineHeight>,
3510    pub text_decoration: Option<TextDecoration>,
3511    pub font_features: Option<Vec<String>>,
3512    pub font_variations: Option<Vec<(FourCc, f32)>>,
3513    pub tab_size: Option<f32>,
3514    pub text_transform: Option<TextTransform>,
3515    pub writing_mode: Option<WritingMode>,
3516    pub text_orientation: Option<TextOrientation>,
3517    pub text_combine_upright: Option<Option<TextCombineUpright>>,
3518    pub font_variant_caps: Option<FontVariantCaps>,
3519    pub font_variant_numeric: Option<FontVariantNumeric>,
3520    pub font_variant_ligatures: Option<FontVariantLigatures>,
3521    pub font_variant_east_asian: Option<FontVariantEastAsian>,
3522}
3523
3524impl Hash for PartialStyleProperties {
3525    fn hash<H: Hasher>(&self, state: &mut H) {
3526        self.font_stack.hash(state);
3527        self.font_size_px.map(|f| f.to_bits()).hash(state);
3528        self.color.hash(state);
3529        self.letter_spacing.hash(state);
3530        self.word_spacing.hash(state);
3531        self.line_height.hash(state);
3532        self.text_decoration.hash(state);
3533        self.font_features.hash(state);
3534
3535        // Manual hashing for Vec<(FourCc, f32)>
3536        self.font_variations.as_ref().map(|v| {
3537            for (tag, val) in v {
3538                tag.hash(state);
3539                val.to_bits().hash(state);
3540            }
3541        });
3542
3543        self.tab_size.map(|f| f.to_bits()).hash(state);
3544        self.text_transform.hash(state);
3545        self.writing_mode.hash(state);
3546        self.text_orientation.hash(state);
3547        self.text_combine_upright.hash(state);
3548        self.font_variant_caps.hash(state);
3549        self.font_variant_numeric.hash(state);
3550        self.font_variant_ligatures.hash(state);
3551        self.font_variant_east_asian.hash(state);
3552    }
3553}
3554
3555impl PartialEq for PartialStyleProperties {
3556    fn eq(&self, other: &Self) -> bool {
3557        self.font_stack == other.font_stack &&
3558        self.font_size_px.map(|f| f.to_bits()) == other.font_size_px.map(|f| f.to_bits()) &&
3559        self.color == other.color &&
3560        self.letter_spacing == other.letter_spacing &&
3561        self.word_spacing == other.word_spacing &&
3562        self.line_height == other.line_height &&
3563        self.text_decoration == other.text_decoration &&
3564        self.font_features == other.font_features &&
3565        self.font_variations == other.font_variations && // Vec<(FourCc, f32)> is PartialEq
3566        self.tab_size.map(|f| f.to_bits()) == other.tab_size.map(|f| f.to_bits()) &&
3567        self.text_transform == other.text_transform &&
3568        self.writing_mode == other.writing_mode &&
3569        self.text_orientation == other.text_orientation &&
3570        self.text_combine_upright == other.text_combine_upright &&
3571        self.font_variant_caps == other.font_variant_caps &&
3572        self.font_variant_numeric == other.font_variant_numeric &&
3573        self.font_variant_ligatures == other.font_variant_ligatures &&
3574        self.font_variant_east_asian == other.font_variant_east_asian
3575    }
3576}
3577
3578impl Eq for PartialStyleProperties {}
3579
3580impl StyleProperties {
3581    fn apply_override(&self, partial: &PartialStyleProperties) -> Self {
3582        let mut new_style = self.clone();
3583        if let Some(val) = &partial.font_stack {
3584            new_style.font_stack = val.clone();
3585        }
3586        if let Some(val) = partial.font_size_px {
3587            new_style.font_size_px = val;
3588        }
3589        if let Some(val) = &partial.color {
3590            new_style.color = val.clone();
3591        }
3592        if let Some(val) = partial.letter_spacing {
3593            new_style.letter_spacing = val;
3594        }
3595        if let Some(val) = partial.word_spacing {
3596            new_style.word_spacing = val;
3597        }
3598        if let Some(val) = partial.line_height {
3599            new_style.line_height = val;
3600        }
3601        if let Some(val) = &partial.text_decoration {
3602            new_style.text_decoration = val.clone();
3603        }
3604        if let Some(val) = &partial.font_features {
3605            new_style.font_features = val.clone();
3606        }
3607        if let Some(val) = &partial.font_variations {
3608            new_style.font_variations = val.clone();
3609        }
3610        if let Some(val) = partial.tab_size {
3611            new_style.tab_size = val;
3612        }
3613        if let Some(val) = partial.text_transform {
3614            new_style.text_transform = val;
3615        }
3616        if let Some(val) = partial.writing_mode {
3617            new_style.writing_mode = val;
3618        }
3619        if let Some(val) = partial.text_orientation {
3620            new_style.text_orientation = val;
3621        }
3622        if let Some(val) = &partial.text_combine_upright {
3623            new_style.text_combine_upright = val.clone();
3624        }
3625        if let Some(val) = partial.font_variant_caps {
3626            new_style.font_variant_caps = val;
3627        }
3628        if let Some(val) = partial.font_variant_numeric {
3629            new_style.font_variant_numeric = val;
3630        }
3631        if let Some(val) = partial.font_variant_ligatures {
3632            new_style.font_variant_ligatures = val;
3633        }
3634        if let Some(val) = partial.font_variant_east_asian {
3635            new_style.font_variant_east_asian = val;
3636        }
3637        new_style
3638    }
3639}
3640
3641/// The kind of a glyph, used to distinguish characters from layout-inserted items.
3642#[derive(Debug, Clone, Copy, PartialEq)]
3643pub enum GlyphKind {
3644    /// A standard glyph representing one or more characters from the source text.
3645    Character,
3646    /// A hyphen glyph inserted by the line breaking algorithm.
3647    Hyphen,
3648    /// A `.notdef` glyph, indicating a character that could not be found in any font.
3649    NotDef,
3650    /// A Kashida justification glyph, inserted to stretch Arabic text.
3651    Kashida {
3652        /// The target width of the kashida.
3653        width: f32,
3654    },
3655}
3656
3657// --- Stage 1: Logical Representation ---
3658
3659#[derive(Debug, Clone)]
3660pub enum LogicalItem {
3661    Text {
3662        /// A stable ID pointing back to the original source character.
3663        source: ContentIndex,
3664        /// The text of this specific logical item (often a single grapheme cluster).
3665        text: String,
3666        style: Arc<StyleProperties>,
3667        /// If this text is a list marker: whether it should be positioned outside
3668        /// (in the padding gutter) or inside (inline with content).
3669        /// None for non-marker content.
3670        marker_position_outside: Option<bool>,
3671        /// The DOM NodeId of the Text node this item originated from.
3672        /// None for generated content (list markers, ::before/::after, etc.)
3673        source_node_id: Option<NodeId>,
3674    },
3675    // +spec:display-property:b1533f - text-combine-upright tate-chu-yoko horizontal-in-vertical composition
3676    /// Tate-chu-yoko: Run of text to be laid out horizontally within a vertical context.
3677    CombinedText {
3678        source: ContentIndex,
3679        text: String,
3680        style: Arc<StyleProperties>,
3681    },
3682    Ruby {
3683        source: ContentIndex,
3684        // For the stub, we simplify to strings. A full implementation
3685        // would need to handle Vec<LogicalItem> for both.
3686        base_text: String,
3687        ruby_text: String,
3688        style: Arc<StyleProperties>,
3689    },
3690    Object {
3691        /// A stable ID pointing back to the original source object.
3692        source: ContentIndex,
3693        /// The original non-text object.
3694        content: InlineContent,
3695    },
3696    Tab {
3697        source: ContentIndex,
3698        style: Arc<StyleProperties>,
3699    },
3700    Break {
3701        source: ContentIndex,
3702        break_info: InlineBreak,
3703    },
3704}
3705
3706impl Hash for LogicalItem {
3707    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
3708        discriminant(self).hash(state);
3709        match self {
3710            LogicalItem::Text {
3711                source,
3712                text,
3713                style,
3714                marker_position_outside,
3715                source_node_id,
3716            } => {
3717                source.hash(state);
3718                text.hash(state);
3719                style.as_ref().hash(state); // Hash the content, not the Arc pointer
3720                marker_position_outside.hash(state);
3721                source_node_id.hash(state);
3722            }
3723            LogicalItem::CombinedText {
3724                source,
3725                text,
3726                style,
3727            } => {
3728                source.hash(state);
3729                text.hash(state);
3730                style.as_ref().hash(state);
3731            }
3732            LogicalItem::Ruby {
3733                source,
3734                base_text,
3735                ruby_text,
3736                style,
3737            } => {
3738                source.hash(state);
3739                base_text.hash(state);
3740                ruby_text.hash(state);
3741                style.as_ref().hash(state);
3742            }
3743            LogicalItem::Object { source, content } => {
3744                source.hash(state);
3745                content.hash(state);
3746            }
3747            LogicalItem::Tab { source, style } => {
3748                source.hash(state);
3749                style.as_ref().hash(state);
3750            }
3751            LogicalItem::Break { source, break_info } => {
3752                source.hash(state);
3753                break_info.hash(state);
3754            }
3755        }
3756    }
3757}
3758
3759// --- Stage 2: Visual Representation ---
3760
3761#[derive(Debug, Clone)]
3762pub struct VisualItem {
3763    /// A reference to the logical item this visual item originated from.
3764    /// A single LogicalItem can be split into multiple VisualItems.
3765    pub logical_source: LogicalItem,
3766    /// The Bidi embedding level for this item.
3767    pub bidi_level: BidiLevel,
3768    /// The script detected for this run, crucial for shaping.
3769    pub script: Script,
3770    /// The text content for this specific visual run.
3771    pub text: String,
3772}
3773
3774// --- Stage 3: Shaped Representation ---
3775
3776#[derive(Debug, Clone)]
3777pub enum ShapedItem {
3778    Cluster(ShapedCluster),
3779    /// A block of combined text (tate-chu-yoko) that is laid out
3780    // as a single unbreakable object.
3781    CombinedBlock {
3782        source: ContentIndex,
3783        /// The glyphs to be rendered horizontally within the vertical line.
3784        glyphs: ShapedGlyphVec,
3785        bounds: Rect,
3786        baseline_offset: f32,
3787    },
3788    Object {
3789        source: ContentIndex,
3790        bounds: Rect,
3791        baseline_offset: f32,
3792        // Store original object for rendering
3793        content: InlineContent,
3794    },
3795    Tab {
3796        source: ContentIndex,
3797        bounds: Rect,
3798    },
3799    Break {
3800        source: ContentIndex,
3801        break_info: InlineBreak,
3802    },
3803}
3804
3805impl ShapedItem {
3806    pub fn as_cluster(&self) -> Option<&ShapedCluster> {
3807        match self {
3808            ShapedItem::Cluster(c) => Some(c),
3809            _ => None,
3810        }
3811    }
3812    /// Returns the bounding box of the item, relative to its own origin.
3813    ///
3814    /// The origin of the returned `Rect` is `(0,0)`, representing the top-left corner
3815    /// of the item's layout space before final positioning. The size represents the
3816    /// item's total advance (width in horizontal mode) and its line height (ascent + descent).
3817    pub fn bounds(&self) -> Rect {
3818        match self {
3819            ShapedItem::Cluster(cluster) => {
3820                // The width of a text cluster is its total advance.
3821                let width = cluster.advance;
3822
3823                // The height is the sum of its ascent and descent, which defines its line box.
3824                // We use the existing helper function which correctly calculates this from font
3825                // metrics.
3826                let (ascent, descent) = get_item_vertical_metrics_approx(self);
3827                let height = ascent + descent;
3828
3829                Rect {
3830                    x: 0.0,
3831                    y: 0.0,
3832                    width,
3833                    height,
3834                }
3835            }
3836            // For atomic inline items like objects, combined blocks, and tabs,
3837            // their bounds have already been calculated during the shaping or measurement phase.
3838            ShapedItem::CombinedBlock { bounds, .. } => *bounds,
3839            ShapedItem::Object { bounds, .. } => *bounds,
3840            ShapedItem::Tab { bounds, .. } => *bounds,
3841
3842            // Breaks are control characters and have no visual geometry.
3843            ShapedItem::Break { .. } => Rect::default(), // A zero-sized rectangle.
3844        }
3845    }
3846}
3847
3848/// A group of glyphs that corresponds to one or more source characters (a cluster).
3849#[derive(Debug, Clone)]
3850pub struct ShapedCluster {
3851    /// The original text that this cluster was shaped from.
3852    /// This is crucial for correct hyphenation.
3853    pub text: String,
3854    /// The ID of the grapheme cluster this glyph cluster represents.
3855    pub source_cluster_id: GraphemeClusterId,
3856    /// The source `ContentIndex` for mapping back to logical items.
3857    pub source_content_index: ContentIndex,
3858    /// The DOM NodeId of the Text node this cluster originated from.
3859    /// None for generated content (list markers, ::before/::after, etc.)
3860    pub source_node_id: Option<NodeId>,
3861    /// The glyphs that make up this cluster. `SmallVec<[T; 1]>` — inline
3862    /// single-glyph clusters (the common case for Latin text), spill to
3863    /// heap only for ligatures / combining marks.
3864    pub glyphs: ShapedGlyphVec,
3865    /// The total advance width (horizontal) or height (vertical) of the cluster.
3866    pub advance: f32,
3867    /// The direction of this cluster, inherited from its `VisualItem`.
3868    pub direction: BidiDirection,
3869    /// Font style of this cluster
3870    pub style: Arc<StyleProperties>,
3871    /// If this cluster is a list marker: whether it should be positioned outside
3872    /// (in the padding gutter) or inside (inline with content).
3873    /// None for non-marker content.
3874    pub marker_position_outside: Option<bool>,
3875    /// True if this is the first visual fragment of its inline box.
3876    /// Used for `box-decoration-break` and split inline border/padding.
3877    /// When an inline element wraps across lines, only the first fragment
3878    /// gets the start-edge border/padding.
3879    pub is_first_fragment: bool,
3880    /// True if this is the last visual fragment of its inline box.
3881    /// Only the last fragment gets the end-edge border/padding.
3882    pub is_last_fragment: bool,
3883}
3884
3885/// A single, shaped glyph with its essential metrics.
3886#[derive(Debug, Clone)]
3887pub struct ShapedGlyph {
3888    /// The kind of glyph this is (character, hyphen, etc.).
3889    pub kind: GlyphKind,
3890    /// Glyph ID inside of the font
3891    pub glyph_id: u16,
3892    /// The byte offset of this glyph's source character(s) within its cluster text.
3893    pub cluster_offset: u32,
3894    /// The horizontal advance for this glyph (for horizontal text) - this is the BASE advance
3895    /// from the font metrics, WITHOUT kerning applied
3896    pub advance: f32,
3897    /// The kerning adjustment for this glyph (positive = more space, negative = less space)
3898    /// This is separate from advance so we can position glyphs absolutely
3899    pub kerning: f32,
3900    /// The horizontal offset/bearing for this glyph
3901    pub offset: Point,
3902    /// The vertical advance for this glyph (for vertical text).
3903    pub vertical_advance: f32,
3904    /// The vertical offset/bearing for this glyph.
3905    pub vertical_offset: Point,
3906    pub script: Script,
3907    pub style: Arc<StyleProperties>,
3908    /// Hash of the font - use LoadedFonts to look up the actual font when needed
3909    pub font_hash: u64,
3910    /// Cached font metrics to avoid font lookup for common operations
3911    pub font_metrics: LayoutFontMetrics,
3912}
3913
3914impl ShapedGlyph {
3915    pub fn into_glyph_instance<T: ParsedFontTrait>(
3916        &self,
3917        writing_mode: WritingMode,
3918        loaded_fonts: &LoadedFonts<T>,
3919    ) -> GlyphInstance {
3920        let size = loaded_fonts
3921            .get_by_hash(self.font_hash)
3922            .and_then(|font| font.get_glyph_size(self.glyph_id, self.style.font_size_px))
3923            .unwrap_or_default();
3924
3925        let position = if writing_mode.is_advance_horizontal() {
3926            LogicalPosition {
3927                x: self.offset.x,
3928                y: self.offset.y,
3929            }
3930        } else {
3931            LogicalPosition {
3932                x: self.vertical_offset.x,
3933                y: self.vertical_offset.y,
3934            }
3935        };
3936
3937        GlyphInstance {
3938            index: self.glyph_id as u32,
3939            point: position,
3940            size,
3941        }
3942    }
3943
3944    /// Convert this ShapedGlyph into a GlyphInstance with an absolute position.
3945    /// This is used for display list generation where glyphs need their final page coordinates.
3946    pub fn into_glyph_instance_at<T: ParsedFontTrait>(
3947        &self,
3948        writing_mode: WritingMode,
3949        absolute_position: LogicalPosition,
3950        loaded_fonts: &LoadedFonts<T>,
3951    ) -> GlyphInstance {
3952        let size = loaded_fonts
3953            .get_by_hash(self.font_hash)
3954            .and_then(|font| font.get_glyph_size(self.glyph_id, self.style.font_size_px))
3955            .unwrap_or_default();
3956
3957        GlyphInstance {
3958            index: self.glyph_id as u32,
3959            point: absolute_position,
3960            size,
3961        }
3962    }
3963
3964    /// Convert this ShapedGlyph into a GlyphInstance with an absolute position.
3965    /// This version doesn't require fonts - it uses a default size.
3966    /// Use this when you don't need precise glyph bounds (e.g., display list generation).
3967    pub fn into_glyph_instance_at_simple(
3968        &self,
3969        _writing_mode: WritingMode,
3970        absolute_position: LogicalPosition,
3971    ) -> GlyphInstance {
3972        // Use font metrics to estimate size, or default to zero
3973        // The actual rendering will use the font directly
3974        GlyphInstance {
3975            index: self.glyph_id as u32,
3976            point: absolute_position,
3977            size: LogicalSize::default(),
3978        }
3979    }
3980}
3981
3982// --- Stage 4: Positioned Representation (Final Layout) ---
3983
3984#[derive(Debug, Clone)]
3985pub struct PositionedItem {
3986    pub item: ShapedItem,
3987    pub position: Point,
3988    pub line_index: usize,
3989}
3990
3991#[derive(Debug, Clone)]
3992pub struct UnifiedLayout {
3993    pub items: Vec<PositionedItem>,
3994    /// Information about content that did not fit.
3995    pub overflow: OverflowInfo,
3996}
3997
3998impl UnifiedLayout {
3999    /// Calculate the bounding box of all positioned items.
4000    /// This is computed on-demand rather than cached.
4001    pub fn bounds(&self) -> Rect {
4002        if self.items.is_empty() {
4003            return Rect::default();
4004        }
4005
4006        let mut min_x = f32::MAX;
4007        let mut min_y = f32::MAX;
4008        let mut max_x = f32::MIN;
4009        let mut max_y = f32::MIN;
4010
4011        for item in &self.items {
4012            let item_x = item.position.x;
4013            let item_y = item.position.y;
4014
4015            // Get item dimensions
4016            let item_bounds = item.item.bounds();
4017            let item_width = item_bounds.width;
4018            let item_height = item_bounds.height;
4019
4020            min_x = min_x.min(item_x);
4021            min_y = min_y.min(item_y);
4022            max_x = max_x.max(item_x + item_width);
4023            max_y = max_y.max(item_y + item_height);
4024        }
4025
4026        Rect {
4027            x: min_x,
4028            y: min_y,
4029            width: max_x - min_x,
4030            height: max_y - min_y,
4031        }
4032    }
4033
4034    pub fn is_empty(&self) -> bool {
4035        self.items.is_empty()
4036    }
4037    pub fn first_baseline(&self) -> Option<f32> {
4038        self.items
4039            .iter()
4040            .find_map(|item| get_baseline_for_item(&item.item))
4041    }
4042
4043    pub fn last_baseline(&self) -> Option<f32> {
4044        self.items
4045            .iter()
4046            .rev()
4047            .find_map(|item| get_baseline_for_item(&item.item))
4048    }
4049
4050    /// Takes a point relative to the layout's origin and returns the closest
4051    /// logical cursor position.
4052    ///
4053    /// This is the unified hit-testing implementation. The old `hit_test_to_cursor`
4054    /// method is deprecated in favor of this one.
4055    pub fn hittest_cursor(&self, point: LogicalPosition) -> Option<TextCursor> {
4056        if self.items.is_empty() {
4057            return None;
4058        }
4059
4060        // Find the closest cluster vertically and horizontally
4061        let mut closest_item_idx = 0;
4062        let mut closest_distance = f32::MAX;
4063
4064        for (idx, item) in self.items.iter().enumerate() {
4065            // Only consider cluster items for cursor placement
4066            if !matches!(item.item, ShapedItem::Cluster(_)) {
4067                continue;
4068            }
4069
4070            let item_bounds = item.item.bounds();
4071            let item_center_y = item.position.y + item_bounds.height / 2.0;
4072
4073            // Distance from click position to item center
4074            let vertical_distance = (point.y - item_center_y).abs();
4075
4076            // For horizontal distance, check if we're within the cluster bounds
4077            let horizontal_distance = if point.x < item.position.x {
4078                item.position.x - point.x
4079            } else if point.x > item.position.x + item_bounds.width {
4080                point.x - (item.position.x + item_bounds.width)
4081            } else {
4082                0.0 // Inside the cluster horizontally
4083            };
4084
4085            // Combined distance (prioritize vertical proximity)
4086            let distance = vertical_distance * 2.0 + horizontal_distance;
4087
4088            if distance < closest_distance {
4089                closest_distance = distance;
4090                closest_item_idx = idx;
4091            }
4092        }
4093
4094        // Get the closest cluster
4095        let closest_item = &self.items[closest_item_idx];
4096        let cluster = match &closest_item.item {
4097            ShapedItem::Cluster(c) => c,
4098            // Objects are treated as a single cluster for selection
4099            ShapedItem::Object { source, .. } | ShapedItem::CombinedBlock { source, .. } => {
4100                return Some(TextCursor {
4101                    cluster_id: GraphemeClusterId {
4102                        source_run: source.run_index,
4103                        start_byte_in_run: source.item_index,
4104                    },
4105                    affinity: if point.x
4106                        < closest_item.position.x + (closest_item.item.bounds().width / 2.0)
4107                    {
4108                        CursorAffinity::Leading
4109                    } else {
4110                        CursorAffinity::Trailing
4111                    },
4112                });
4113            }
4114            _ => return None,
4115        };
4116
4117        // Determine affinity based on which half of the cluster was clicked
4118        let cluster_mid_x = closest_item.position.x + cluster.advance / 2.0;
4119        let affinity = if point.x < cluster_mid_x {
4120            CursorAffinity::Leading
4121        } else {
4122            CursorAffinity::Trailing
4123        };
4124
4125        Some(TextCursor {
4126            cluster_id: cluster.source_cluster_id,
4127            affinity,
4128        })
4129    }
4130
4131    /// Given a logical selection range, returns a vector of visual rectangles
4132    /// that cover the selected text, in the layout's coordinate space.
4133    pub fn get_selection_rects(&self, range: &SelectionRange) -> Vec<LogicalRect> {
4134        // 1. Build a map from the logical cluster ID to the visual PositionedItem for fast lookups.
4135        let mut cluster_map: HashMap<GraphemeClusterId, &PositionedItem> = HashMap::new();
4136        for item in &self.items {
4137            if let Some(cluster) = item.item.as_cluster() {
4138                cluster_map.insert(cluster.source_cluster_id, item);
4139            }
4140        }
4141
4142        // 2. Normalize the range to ensure start always logically precedes end.
4143        let (start_cursor, end_cursor) = if range.start.cluster_id > range.end.cluster_id
4144            || (range.start.cluster_id == range.end.cluster_id
4145                && range.start.affinity > range.end.affinity)
4146        {
4147            (range.end, range.start)
4148        } else {
4149            (range.start, range.end)
4150        };
4151
4152        // 3. Find the positioned items corresponding to the start and end of the selection.
4153        let Some(start_item) = cluster_map.get(&start_cursor.cluster_id) else {
4154            return Vec::new();
4155        };
4156        let Some(end_item) = cluster_map.get(&end_cursor.cluster_id) else {
4157            return Vec::new();
4158        };
4159
4160        let mut rects = Vec::new();
4161
4162        // Helper to get the absolute visual X coordinate of a cursor.
4163        let get_cursor_x = |item: &PositionedItem, affinity: CursorAffinity| -> f32 {
4164            match affinity {
4165                CursorAffinity::Leading => item.position.x,
4166                CursorAffinity::Trailing => item.position.x + get_item_measure(&item.item, false),
4167            }
4168        };
4169
4170        // Helper to get the visual bounding box of all content on a specific line index.
4171        let get_line_bounds = |line_index: usize| -> Option<LogicalRect> {
4172            let items_on_line = self.items.iter().filter(|i| i.line_index == line_index);
4173
4174            let mut min_x: Option<f32> = None;
4175            let mut max_x: Option<f32> = None;
4176            let mut min_y: Option<f32> = None;
4177            let mut max_y: Option<f32> = None;
4178
4179            for item in items_on_line {
4180                // Skip items that don't take up space (like hard breaks)
4181                let item_bounds = item.item.bounds();
4182                if item_bounds.width <= 0.0 && item_bounds.height <= 0.0 {
4183                    continue;
4184                }
4185
4186                let item_x_end = item.position.x + item_bounds.width;
4187                let item_y_end = item.position.y + item_bounds.height;
4188
4189                min_x = Some(min_x.map_or(item.position.x, |mx| mx.min(item.position.x)));
4190                max_x = Some(max_x.map_or(item_x_end, |mx| mx.max(item_x_end)));
4191                min_y = Some(min_y.map_or(item.position.y, |my| my.min(item.position.y)));
4192                max_y = Some(max_y.map_or(item_y_end, |my| my.max(item_y_end)));
4193            }
4194
4195            if let (Some(min_x), Some(max_x), Some(min_y), Some(max_y)) =
4196                (min_x, max_x, min_y, max_y)
4197            {
4198                Some(LogicalRect {
4199                    origin: LogicalPosition { x: min_x, y: min_y },
4200                    size: LogicalSize {
4201                        width: max_x - min_x,
4202                        height: max_y - min_y,
4203                    },
4204                })
4205            } else {
4206                None
4207            }
4208        };
4209
4210        // 4. Handle single-line selection.
4211        if start_item.line_index == end_item.line_index {
4212            if let Some(line_bounds) = get_line_bounds(start_item.line_index) {
4213                let start_x = get_cursor_x(start_item, start_cursor.affinity);
4214                let end_x = get_cursor_x(end_item, end_cursor.affinity);
4215
4216                // Use min/max and abs to correctly handle selections made from right-to-left.
4217                rects.push(LogicalRect {
4218                    origin: LogicalPosition {
4219                        x: start_x.min(end_x),
4220                        y: line_bounds.origin.y,
4221                    },
4222                    size: LogicalSize {
4223                        width: (end_x - start_x).abs(),
4224                        height: line_bounds.size.height,
4225                    },
4226                });
4227            }
4228        }
4229        // 5. Handle multi-line selection.
4230        else {
4231            // Rectangle for the start line (from cursor to end of line).
4232            if let Some(start_line_bounds) = get_line_bounds(start_item.line_index) {
4233                let start_x = get_cursor_x(start_item, start_cursor.affinity);
4234                let line_end_x = start_line_bounds.origin.x + start_line_bounds.size.width;
4235                rects.push(LogicalRect {
4236                    origin: LogicalPosition {
4237                        x: start_x,
4238                        y: start_line_bounds.origin.y,
4239                    },
4240                    size: LogicalSize {
4241                        width: line_end_x - start_x,
4242                        height: start_line_bounds.size.height,
4243                    },
4244                });
4245            }
4246
4247            // Rectangles for all full lines in between.
4248            for line_idx in (start_item.line_index + 1)..end_item.line_index {
4249                if let Some(line_bounds) = get_line_bounds(line_idx) {
4250                    rects.push(line_bounds);
4251                }
4252            }
4253
4254            // Rectangle for the end line (from start of line to cursor).
4255            if let Some(end_line_bounds) = get_line_bounds(end_item.line_index) {
4256                let line_start_x = end_line_bounds.origin.x;
4257                let end_x = get_cursor_x(end_item, end_cursor.affinity);
4258                rects.push(LogicalRect {
4259                    origin: LogicalPosition {
4260                        x: line_start_x,
4261                        y: end_line_bounds.origin.y,
4262                    },
4263                    size: LogicalSize {
4264                        width: end_x - line_start_x,
4265                        height: end_line_bounds.size.height,
4266                    },
4267                });
4268            }
4269        }
4270
4271        rects
4272    }
4273
4274    /// Calculates the visual rectangle for a cursor at a given logical position.
4275    pub fn get_cursor_rect(&self, cursor: &TextCursor) -> Option<LogicalRect> {
4276        // Find the item and glyph corresponding to the cursor's cluster ID.
4277        let mut last_cluster: Option<(&PositionedItem, &ShapedCluster)> = None;
4278        for item in &self.items {
4279            if let ShapedItem::Cluster(cluster) = &item.item {
4280                if cluster.source_cluster_id == cursor.cluster_id {
4281                    // Exact match
4282                    let line_height = item.item.bounds().height;
4283                    let cursor_x = match cursor.affinity {
4284                        CursorAffinity::Leading => item.position.x,
4285                        CursorAffinity::Trailing => item.position.x + cluster.advance,
4286                    };
4287                    return Some(LogicalRect {
4288                        origin: LogicalPosition {
4289                            x: cursor_x,
4290                            y: item.position.y,
4291                        },
4292                        size: LogicalSize {
4293                            width: 1.0,
4294                            height: line_height,
4295                        },
4296                    });
4297                }
4298                last_cluster = Some((item, cluster));
4299            }
4300        }
4301        // Cursor past end of text: position after the last cluster
4302        if let Some((item, cluster)) = last_cluster {
4303            if cursor.cluster_id.source_run == cluster.source_cluster_id.source_run
4304                && cursor.cluster_id.start_byte_in_run >= cluster.source_cluster_id.start_byte_in_run
4305            {
4306                let line_height = item.item.bounds().height;
4307                return Some(LogicalRect {
4308                    origin: LogicalPosition {
4309                        x: item.position.x + cluster.advance,
4310                        y: item.position.y,
4311                    },
4312                    size: LogicalSize {
4313                        width: 1.0,
4314                        height: line_height,
4315                    },
4316                });
4317            }
4318        }
4319        None
4320    }
4321
4322    /// Get a cursor at the first cluster (leading edge) in the layout.
4323    pub fn get_first_cluster_cursor(&self) -> Option<TextCursor> {
4324        for item in &self.items {
4325            if let ShapedItem::Cluster(cluster) = &item.item {
4326                return Some(TextCursor {
4327                    cluster_id: cluster.source_cluster_id,
4328                    affinity: CursorAffinity::Leading,
4329                });
4330            }
4331        }
4332        None
4333    }
4334
4335    /// Get a cursor at the last cluster (trailing edge) in the layout.
4336    pub fn get_last_cluster_cursor(&self) -> Option<TextCursor> {
4337        for item in self.items.iter().rev() {
4338            if let ShapedItem::Cluster(cluster) = &item.item {
4339                return Some(TextCursor {
4340                    cluster_id: cluster.source_cluster_id,
4341                    affinity: CursorAffinity::Trailing,
4342                });
4343            }
4344        }
4345        None
4346    }
4347
4348    /// Moves a cursor one visual unit to the left, handling line wrapping and Bidi text.
4349    pub fn move_cursor_left(
4350        &self,
4351        cursor: TextCursor,
4352        debug: &mut Option<Vec<String>>,
4353    ) -> TextCursor {
4354        if let Some(d) = debug {
4355            d.push(format!(
4356                "[Cursor] move_cursor_left: starting at byte {}, affinity {:?}",
4357                cursor.cluster_id.start_byte_in_run, cursor.affinity
4358            ));
4359        }
4360
4361        // Find current item
4362        let current_item_pos = self.items.iter().position(|i| {
4363            i.item
4364                .as_cluster()
4365                .map_or(false, |c| c.source_cluster_id == cursor.cluster_id)
4366        });
4367
4368        let Some(current_pos) = current_item_pos else {
4369            if let Some(d) = debug {
4370                d.push(format!(
4371                    "[Cursor] move_cursor_left: cursor not found, staying at byte {}",
4372                    cursor.cluster_id.start_byte_in_run
4373                ));
4374            }
4375            return cursor;
4376        };
4377
4378        // Skip the Trailing→Leading affinity flip for simple cursor movement.
4379        // Each left arrow press should move to the previous visible character position.
4380
4381        // Move to previous cluster's trailing edge
4382        // Search backwards for a cluster on the same line, or any cluster if at line start
4383        let current_line = self.items[current_pos].line_index;
4384
4385        if let Some(d) = debug {
4386            d.push(format!(
4387                "[Cursor] move_cursor_left: at leading edge, current line {}",
4388                current_line
4389            ));
4390        }
4391
4392        // First, try to find previous item on same line
4393        for i in (0..current_pos).rev() {
4394            if let Some(cluster) = self.items[i].item.as_cluster() {
4395                if self.items[i].line_index == current_line {
4396                    if let Some(d) = debug {
4397                        d.push(format!(
4398                            "[Cursor] move_cursor_left: found previous cluster on same line, byte \
4399                             {}",
4400                            cluster.source_cluster_id.start_byte_in_run
4401                        ));
4402                    }
4403                    return TextCursor {
4404                        cluster_id: cluster.source_cluster_id,
4405                        affinity: CursorAffinity::Trailing,
4406                    };
4407                }
4408            }
4409        }
4410
4411        // If no previous item on same line, try to move to end of previous line
4412        if current_line > 0 {
4413            let prev_line = current_line - 1;
4414            if let Some(d) = debug {
4415                d.push(format!(
4416                    "[Cursor] move_cursor_left: trying previous line {}",
4417                    prev_line
4418                ));
4419            }
4420            for i in (0..current_pos).rev() {
4421                if let Some(cluster) = self.items[i].item.as_cluster() {
4422                    if self.items[i].line_index == prev_line {
4423                        if let Some(d) = debug {
4424                            d.push(format!(
4425                                "[Cursor] move_cursor_left: found cluster on previous line, byte \
4426                                 {}",
4427                                cluster.source_cluster_id.start_byte_in_run
4428                            ));
4429                        }
4430                        return TextCursor {
4431                            cluster_id: cluster.source_cluster_id,
4432                            affinity: CursorAffinity::Trailing,
4433                        };
4434                    }
4435                }
4436            }
4437        }
4438
4439        // At start of text, can't move further
4440        if let Some(d) = debug {
4441            d.push(format!(
4442                "[Cursor] move_cursor_left: at start of text, staying at byte {}",
4443                cursor.cluster_id.start_byte_in_run
4444            ));
4445        }
4446        cursor
4447    }
4448
4449    /// Moves a cursor one visual unit to the right.
4450    pub fn move_cursor_right(
4451        &self,
4452        cursor: TextCursor,
4453        debug: &mut Option<Vec<String>>,
4454    ) -> TextCursor {
4455        if let Some(d) = debug {
4456            d.push(format!(
4457                "[Cursor] move_cursor_right: starting at byte {}, affinity {:?}",
4458                cursor.cluster_id.start_byte_in_run, cursor.affinity
4459            ));
4460        }
4461
4462        // Find current item
4463        let current_item_pos = self.items.iter().position(|i| {
4464            i.item
4465                .as_cluster()
4466                .map_or(false, |c| c.source_cluster_id == cursor.cluster_id)
4467        });
4468
4469        let Some(current_pos) = current_item_pos else {
4470            if let Some(d) = debug {
4471                d.push(format!(
4472                    "[Cursor] move_cursor_right: cursor not found, staying at byte {}",
4473                    cursor.cluster_id.start_byte_in_run
4474                ));
4475            }
4476            return cursor;
4477        };
4478
4479        // Skip the Leading→Trailing affinity flip for simple cursor movement.
4480        // The affinity distinction matters for selection extension and bidi text,
4481        // but for basic left/right navigation, the user expects each press to move
4482        // the cursor to the next/previous visible character position.
4483        // If at Leading, go directly to the next cluster's Leading.
4484
4485        // We're at leading or trailing edge, move to next cluster's leading edge
4486        let current_line = self.items[current_pos].line_index;
4487
4488        if let Some(d) = debug {
4489            d.push(format!(
4490                "[Cursor] move_cursor_right: at trailing edge, current line {}",
4491                current_line
4492            ));
4493        }
4494
4495        // First, try to find next item on same line
4496        for i in (current_pos + 1)..self.items.len() {
4497            if let Some(cluster) = self.items[i].item.as_cluster() {
4498                if self.items[i].line_index == current_line {
4499                    if let Some(d) = debug {
4500                        d.push(format!(
4501                            "[Cursor] move_cursor_right: found next cluster on same line, byte {}",
4502                            cluster.source_cluster_id.start_byte_in_run
4503                        ));
4504                    }
4505                    return TextCursor {
4506                        cluster_id: cluster.source_cluster_id,
4507                        affinity: CursorAffinity::Leading,
4508                    };
4509                }
4510            }
4511        }
4512
4513        // If no next item on same line, try to move to start of next line
4514        let next_line = current_line + 1;
4515        if let Some(d) = debug {
4516            d.push(format!(
4517                "[Cursor] move_cursor_right: trying next line {}",
4518                next_line
4519            ));
4520        }
4521        for i in (current_pos + 1)..self.items.len() {
4522            if let Some(cluster) = self.items[i].item.as_cluster() {
4523                if self.items[i].line_index == next_line {
4524                    if let Some(d) = debug {
4525                        d.push(format!(
4526                            "[Cursor] move_cursor_right: found cluster on next line, byte {}",
4527                            cluster.source_cluster_id.start_byte_in_run
4528                        ));
4529                    }
4530                    return TextCursor {
4531                        cluster_id: cluster.source_cluster_id,
4532                        affinity: CursorAffinity::Leading,
4533                    };
4534                }
4535            }
4536        }
4537
4538        // At end of text, can't move further
4539        if let Some(d) = debug {
4540            d.push(format!(
4541                "[Cursor] move_cursor_right: at end of text, staying at byte {}",
4542                cursor.cluster_id.start_byte_in_run
4543            ));
4544        }
4545        cursor
4546    }
4547
4548    /// Moves a cursor up one line, attempting to preserve the horizontal column.
4549    pub fn move_cursor_up(
4550        &self,
4551        cursor: TextCursor,
4552        goal_x: &mut Option<f32>,
4553        debug: &mut Option<Vec<String>>,
4554    ) -> TextCursor {
4555        if let Some(d) = debug {
4556            d.push(format!(
4557                "[Cursor] move_cursor_up: from byte {} (affinity {:?})",
4558                cursor.cluster_id.start_byte_in_run, cursor.affinity
4559            ));
4560        }
4561
4562        let Some(current_item) = self.items.iter().find(|i| {
4563            i.item
4564                .as_cluster()
4565                .map_or(false, |c| c.source_cluster_id == cursor.cluster_id)
4566        }) else {
4567            if let Some(d) = debug {
4568                d.push(format!(
4569                    "[Cursor] move_cursor_up: cursor not found in items, staying at byte {}",
4570                    cursor.cluster_id.start_byte_in_run
4571                ));
4572            }
4573            return cursor;
4574        };
4575
4576        if let Some(d) = debug {
4577            d.push(format!(
4578                "[Cursor] move_cursor_up: current line {}, position ({}, {})",
4579                current_item.line_index, current_item.position.x, current_item.position.y
4580            ));
4581        }
4582
4583        let target_line_idx = current_item.line_index.saturating_sub(1);
4584        if current_item.line_index == target_line_idx {
4585            if let Some(d) = debug {
4586                d.push(format!(
4587                    "[Cursor] move_cursor_up: already at top line {}, staying put",
4588                    current_item.line_index
4589                ));
4590            }
4591            return cursor;
4592        }
4593
4594        let current_x = goal_x.unwrap_or_else(|| {
4595            let x = match cursor.affinity {
4596                CursorAffinity::Leading => current_item.position.x,
4597                CursorAffinity::Trailing => {
4598                    current_item.position.x + get_item_measure(&current_item.item, false)
4599                }
4600            };
4601            *goal_x = Some(x);
4602            x
4603        });
4604
4605        // Find the Y coordinate of the middle of the target line
4606        let target_y = self
4607            .items
4608            .iter()
4609            .find(|i| i.line_index == target_line_idx)
4610            .map(|i| i.position.y + (i.item.bounds().height / 2.0))
4611            .unwrap_or(current_item.position.y);
4612
4613        if let Some(d) = debug {
4614            d.push(format!(
4615                "[Cursor] move_cursor_up: target line {}, hittesting at ({}, {})",
4616                target_line_idx, current_x, target_y
4617            ));
4618        }
4619
4620        let result = self
4621            .hittest_cursor(LogicalPosition {
4622                x: current_x,
4623                y: target_y,
4624            })
4625            .unwrap_or(cursor);
4626
4627        if let Some(d) = debug {
4628            d.push(format!(
4629                "[Cursor] move_cursor_up: result byte {} (affinity {:?})",
4630                result.cluster_id.start_byte_in_run, result.affinity
4631            ));
4632        }
4633
4634        result
4635    }
4636
4637    /// Moves a cursor down one line, attempting to preserve the horizontal column.
4638    pub fn move_cursor_down(
4639        &self,
4640        cursor: TextCursor,
4641        goal_x: &mut Option<f32>,
4642        debug: &mut Option<Vec<String>>,
4643    ) -> TextCursor {
4644        if let Some(d) = debug {
4645            d.push(format!(
4646                "[Cursor] move_cursor_down: from byte {} (affinity {:?})",
4647                cursor.cluster_id.start_byte_in_run, cursor.affinity
4648            ));
4649        }
4650
4651        let Some(current_item) = self.items.iter().find(|i| {
4652            i.item
4653                .as_cluster()
4654                .map_or(false, |c| c.source_cluster_id == cursor.cluster_id)
4655        }) else {
4656            if let Some(d) = debug {
4657                d.push(format!(
4658                    "[Cursor] move_cursor_down: cursor not found in items, staying at byte {}",
4659                    cursor.cluster_id.start_byte_in_run
4660                ));
4661            }
4662            return cursor;
4663        };
4664
4665        if let Some(d) = debug {
4666            d.push(format!(
4667                "[Cursor] move_cursor_down: current line {}, position ({}, {})",
4668                current_item.line_index, current_item.position.x, current_item.position.y
4669            ));
4670        }
4671
4672        let max_line = self.items.iter().map(|i| i.line_index).max().unwrap_or(0);
4673        let target_line_idx = (current_item.line_index + 1).min(max_line);
4674        if current_item.line_index == target_line_idx {
4675            if let Some(d) = debug {
4676                d.push(format!(
4677                    "[Cursor] move_cursor_down: already at bottom line {}, staying put",
4678                    current_item.line_index
4679                ));
4680            }
4681            return cursor;
4682        }
4683
4684        let current_x = goal_x.unwrap_or_else(|| {
4685            let x = match cursor.affinity {
4686                CursorAffinity::Leading => current_item.position.x,
4687                CursorAffinity::Trailing => {
4688                    current_item.position.x + get_item_measure(&current_item.item, false)
4689                }
4690            };
4691            *goal_x = Some(x);
4692            x
4693        });
4694
4695        let target_y = self
4696            .items
4697            .iter()
4698            .find(|i| i.line_index == target_line_idx)
4699            .map(|i| i.position.y + (i.item.bounds().height / 2.0))
4700            .unwrap_or(current_item.position.y);
4701
4702        if let Some(d) = debug {
4703            d.push(format!(
4704                "[Cursor] move_cursor_down: hit testing at ({}, {})",
4705                current_x, target_y
4706            ));
4707        }
4708
4709        let result = self
4710            .hittest_cursor(LogicalPosition {
4711                x: current_x,
4712                y: target_y,
4713            })
4714            .unwrap_or(cursor);
4715
4716        if let Some(d) = debug {
4717            d.push(format!(
4718                "[Cursor] move_cursor_down: result byte {}, affinity {:?}",
4719                result.cluster_id.start_byte_in_run, result.affinity
4720            ));
4721        }
4722
4723        result
4724    }
4725
4726    /// Moves a cursor to the visual start of its current line.
4727    pub fn move_cursor_to_line_start(
4728        &self,
4729        cursor: TextCursor,
4730        debug: &mut Option<Vec<String>>,
4731    ) -> TextCursor {
4732        if let Some(d) = debug {
4733            d.push(format!(
4734                "[Cursor] move_cursor_to_line_start: starting at byte {}, affinity {:?}",
4735                cursor.cluster_id.start_byte_in_run, cursor.affinity
4736            ));
4737        }
4738
4739        let Some(current_item) = self.items.iter().find(|i| {
4740            i.item
4741                .as_cluster()
4742                .map_or(false, |c| c.source_cluster_id == cursor.cluster_id)
4743        }) else {
4744            if let Some(d) = debug {
4745                d.push(format!(
4746                    "[Cursor] move_cursor_to_line_start: cursor not found, staying at byte {}",
4747                    cursor.cluster_id.start_byte_in_run
4748                ));
4749            }
4750            return cursor;
4751        };
4752
4753        if let Some(d) = debug {
4754            d.push(format!(
4755                "[Cursor] move_cursor_to_line_start: current line {}, position ({}, {})",
4756                current_item.line_index, current_item.position.x, current_item.position.y
4757            ));
4758        }
4759
4760        let first_item_on_line = self
4761            .items
4762            .iter()
4763            .filter(|i| i.line_index == current_item.line_index)
4764            .min_by(|a, b| {
4765                a.position
4766                    .x
4767                    .partial_cmp(&b.position.x)
4768                    .unwrap_or(Ordering::Equal)
4769            });
4770
4771        if let Some(item) = first_item_on_line {
4772            if let ShapedItem::Cluster(c) = &item.item {
4773                let result = TextCursor {
4774                    cluster_id: c.source_cluster_id,
4775                    affinity: CursorAffinity::Leading,
4776                };
4777                if let Some(d) = debug {
4778                    d.push(format!(
4779                        "[Cursor] move_cursor_to_line_start: result byte {}, affinity {:?}",
4780                        result.cluster_id.start_byte_in_run, result.affinity
4781                    ));
4782                }
4783                return result;
4784            }
4785        }
4786
4787        if let Some(d) = debug {
4788            d.push(format!(
4789                "[Cursor] move_cursor_to_line_start: no first item found, staying at byte {}",
4790                cursor.cluster_id.start_byte_in_run
4791            ));
4792        }
4793        cursor
4794    }
4795
4796    /// Moves a cursor to the visual end of its current line.
4797    pub fn move_cursor_to_line_end(
4798        &self,
4799        cursor: TextCursor,
4800        debug: &mut Option<Vec<String>>,
4801    ) -> TextCursor {
4802        if let Some(d) = debug {
4803            d.push(format!(
4804                "[Cursor] move_cursor_to_line_end: starting at byte {}, affinity {:?}",
4805                cursor.cluster_id.start_byte_in_run, cursor.affinity
4806            ));
4807        }
4808
4809        let Some(current_item) = self.items.iter().find(|i| {
4810            i.item
4811                .as_cluster()
4812                .map_or(false, |c| c.source_cluster_id == cursor.cluster_id)
4813        }) else {
4814            if let Some(d) = debug {
4815                d.push(format!(
4816                    "[Cursor] move_cursor_to_line_end: cursor not found, staying at byte {}",
4817                    cursor.cluster_id.start_byte_in_run
4818                ));
4819            }
4820            return cursor;
4821        };
4822
4823        if let Some(d) = debug {
4824            d.push(format!(
4825                "[Cursor] move_cursor_to_line_end: current line {}, position ({}, {})",
4826                current_item.line_index, current_item.position.x, current_item.position.y
4827            ));
4828        }
4829
4830        let last_item_on_line = self
4831            .items
4832            .iter()
4833            .filter(|i| i.line_index == current_item.line_index)
4834            .max_by(|a, b| {
4835                a.position
4836                    .x
4837                    .partial_cmp(&b.position.x)
4838                    .unwrap_or(Ordering::Equal)
4839            });
4840
4841        if let Some(item) = last_item_on_line {
4842            if let ShapedItem::Cluster(c) = &item.item {
4843                let result = TextCursor {
4844                    cluster_id: c.source_cluster_id,
4845                    affinity: CursorAffinity::Trailing,
4846                };
4847                if let Some(d) = debug {
4848                    d.push(format!(
4849                        "[Cursor] move_cursor_to_line_end: result byte {}, affinity {:?}",
4850                        result.cluster_id.start_byte_in_run, result.affinity
4851                    ));
4852                }
4853                return result;
4854            }
4855        }
4856
4857        if let Some(d) = debug {
4858            d.push(format!(
4859                "[Cursor] move_cursor_to_line_end: no last item found, staying at byte {}",
4860                cursor.cluster_id.start_byte_in_run
4861            ));
4862        }
4863        cursor
4864    }
4865
4866    /// Moves a cursor one word to the left (Ctrl+Left / Option+Left).
4867    ///
4868    /// Word boundaries are defined by whitespace: the cursor moves past any
4869    /// whitespace to the left, then past non-whitespace characters until
4870    /// the next whitespace or start of text.
4871    pub fn move_cursor_to_prev_word(
4872        &self,
4873        cursor: TextCursor,
4874        debug: &mut Option<Vec<String>>,
4875    ) -> TextCursor {
4876        if let Some(d) = debug {
4877            d.push(format!(
4878                "[Cursor] move_cursor_to_prev_word: starting at byte {}, affinity {:?}",
4879                cursor.cluster_id.start_byte_in_run, cursor.affinity
4880            ));
4881        }
4882
4883        let current_pos = match self.items.iter().position(|i| {
4884            i.item
4885                .as_cluster()
4886                .map_or(false, |c| c.source_cluster_id == cursor.cluster_id)
4887        }) {
4888            Some(pos) => pos,
4889            None => return cursor,
4890        };
4891
4892        // Phase 1: Skip whitespace going left
4893        let mut pos = if cursor.affinity == CursorAffinity::Leading {
4894            // Already at leading edge, start from previous item
4895            current_pos.checked_sub(1)
4896        } else {
4897            // At trailing edge, start from current item
4898            Some(current_pos)
4899        };
4900
4901        // Skip whitespace
4902        while let Some(p) = pos {
4903            if let Some(cluster) = self.items[p].item.as_cluster() {
4904                if !cluster.text.chars().all(|c| c.is_whitespace()) {
4905                    break;
4906                }
4907            }
4908            pos = p.checked_sub(1);
4909        }
4910
4911        // Phase 2: Skip non-whitespace going left (the word itself)
4912        while let Some(p) = pos {
4913            if let Some(cluster) = self.items[p].item.as_cluster() {
4914                if cluster.text.chars().all(|c| c.is_whitespace()) {
4915                    // We've reached whitespace before the word — stop at next cluster
4916                    if p + 1 < self.items.len() {
4917                        if let Some(c) = self.items[p + 1].item.as_cluster() {
4918                            return TextCursor {
4919                                cluster_id: c.source_cluster_id,
4920                                affinity: CursorAffinity::Leading,
4921                            };
4922                        }
4923                    }
4924                    break;
4925                }
4926            }
4927            if p == 0 {
4928                // Reached start of text — return first cluster
4929                if let Some(c) = self.items[0].item.as_cluster() {
4930                    return TextCursor {
4931                        cluster_id: c.source_cluster_id,
4932                        affinity: CursorAffinity::Leading,
4933                    };
4934                }
4935                break;
4936            }
4937            pos = p.checked_sub(1);
4938        }
4939
4940        // If we exhausted the search, go to first cluster
4941        if pos.is_none() {
4942            if let Some(first) = self.get_first_cluster_cursor() {
4943                return first;
4944            }
4945        }
4946
4947        cursor
4948    }
4949
4950    /// Moves a cursor one word to the right (Ctrl+Right / Option+Right).
4951    ///
4952    /// Word boundaries are defined by whitespace: the cursor moves past any
4953    /// non-whitespace characters, then past whitespace until the next word
4954    /// or end of text.
4955    pub fn move_cursor_to_next_word(
4956        &self,
4957        cursor: TextCursor,
4958        debug: &mut Option<Vec<String>>,
4959    ) -> TextCursor {
4960        if let Some(d) = debug {
4961            d.push(format!(
4962                "[Cursor] move_cursor_to_next_word: starting at byte {}, affinity {:?}",
4963                cursor.cluster_id.start_byte_in_run, cursor.affinity
4964            ));
4965        }
4966
4967        let current_pos = match self.items.iter().position(|i| {
4968            i.item
4969                .as_cluster()
4970                .map_or(false, |c| c.source_cluster_id == cursor.cluster_id)
4971        }) {
4972            Some(pos) => pos,
4973            None => return cursor,
4974        };
4975
4976        let len = self.items.len();
4977
4978        // Start position: if at leading edge, start from current; if trailing, start from next
4979        let start = if cursor.affinity == CursorAffinity::Trailing {
4980            current_pos + 1
4981        } else {
4982            current_pos
4983        };
4984
4985        if start >= len {
4986            return cursor;
4987        }
4988
4989        let mut pos = start;
4990
4991        // Phase 1: Skip non-whitespace (current word)
4992        while pos < len {
4993            if let Some(cluster) = self.items[pos].item.as_cluster() {
4994                if cluster.text.chars().all(|c| c.is_whitespace()) {
4995                    break;
4996                }
4997            }
4998            pos += 1;
4999        }
5000
5001        // Phase 2: Skip whitespace after word
5002        while pos < len {
5003            if let Some(cluster) = self.items[pos].item.as_cluster() {
5004                if !cluster.text.chars().all(|c| c.is_whitespace()) {
5005                    // Found start of next word
5006                    return TextCursor {
5007                        cluster_id: cluster.source_cluster_id,
5008                        affinity: CursorAffinity::Leading,
5009                    };
5010                }
5011            }
5012            pos += 1;
5013        }
5014
5015        // Reached end of text
5016        if let Some(last) = self.get_last_cluster_cursor() {
5017            return last;
5018        }
5019
5020        cursor
5021    }
5022}
5023
5024fn get_baseline_for_item(item: &ShapedItem) -> Option<f32> {
5025    match item {
5026        ShapedItem::CombinedBlock {
5027            baseline_offset, ..
5028        } => Some(*baseline_offset),
5029        ShapedItem::Object {
5030            baseline_offset, ..
5031        } => Some(*baseline_offset),
5032        // We have to get the clusters font from the last glyph
5033        ShapedItem::Cluster(ref cluster) => {
5034            if let Some(last_glyph) = cluster.glyphs.last() {
5035                Some(
5036                    last_glyph
5037                        .font_metrics
5038                        .baseline_scaled(last_glyph.style.font_size_px),
5039                )
5040            } else {
5041                None
5042            }
5043        }
5044        ShapedItem::Break { source, break_info } => {
5045            // Breaks do not contribute to baseline
5046            None
5047        }
5048        ShapedItem::Tab { source, bounds } => {
5049            // Tabs do not contribute to baseline
5050            None
5051        }
5052    }
5053}
5054
5055/// Stores information about content that exceeded the available layout space.
5056#[derive(Debug, Clone, Default)]
5057pub struct OverflowInfo {
5058    /// The items that did not fit within the constraints.
5059    pub overflow_items: Vec<ShapedItem>,
5060    /// The total bounds of all content, including overflowing items.
5061    /// This is useful for `OverflowBehavior::Visible` or `Scroll`.
5062    pub unclipped_bounds: Rect,
5063}
5064
5065impl OverflowInfo {
5066    pub fn has_overflow(&self) -> bool {
5067        !self.overflow_items.is_empty()
5068    }
5069}
5070
5071/// Intermediate structure carrying information from the line breaker to the positioner.
5072#[derive(Debug, Clone)]
5073pub struct UnifiedLine {
5074    pub items: Vec<ShapedItem>,
5075    /// The y-position (for horizontal) or x-position (for vertical) of the line's baseline.
5076    pub cross_axis_position: f32,
5077    /// The geometric segments this line must fit into.
5078    pub constraints: LineConstraints,
5079    pub is_last: bool,
5080}
5081
5082// --- Caching Infrastructure ---
5083
5084pub type CacheId = u64;
5085
5086/// Defines a single area for layout, with its own shape and properties.
5087#[derive(Debug, Clone)]
5088pub struct LayoutFragment {
5089    /// A unique identifier for this fragment (e.g., "main-content", "sidebar").
5090    pub id: String,
5091    /// The geometric and style constraints for this specific fragment.
5092    pub constraints: UnifiedConstraints,
5093}
5094
5095/// Represents the final layout distributed across multiple fragments.
5096#[derive(Debug, Clone)]
5097pub struct FlowLayout {
5098    /// A map from a fragment's unique ID to the layout it contains.
5099    pub fragment_layouts: HashMap<String, Arc<UnifiedLayout>>,
5100    /// Any items that did not fit into the last fragment in the flow chain.
5101    /// This is useful for pagination or determining if more layout space is needed.
5102    pub remaining_items: Vec<ShapedItem>,
5103}
5104
5105/// Inline-axis intrinsic contributions derived from shaped text, without running
5106/// the line-breaking stage of the pipeline.
5107///
5108/// Callers that only need min/max-content widths for sizing (see
5109/// `calculate_ifc_root_intrinsic_sizes`) should prefer this over invoking
5110/// `layout_flow` twice with `AvailableSpace::MinContent`/`MaxContent`. The
5111/// latter runs the full flow loop — including `BreakCursor::peek_next_unit`,
5112/// which clones every `ShapedCluster` it inspects — even though no constraint
5113/// actually limits the line width.
5114#[derive(Debug, Clone, Default)]
5115pub struct IntrinsicTextSizes {
5116    /// CSS min-content = widest unbreakable unit (word) along the inline axis.
5117    pub min_content_width: f32,
5118    /// CSS max-content = sum of all advances along the inline axis (single line).
5119    pub max_content_width: f32,
5120    /// Height of a single line box: max(ascent + descent) across all items.
5121    pub max_content_height: f32,
5122}
5123
5124/// Cached line break boundaries from a previous layout pass.
5125/// Enables incremental relayout: when a word changes width,
5126/// we can check if it still fits on the same line without
5127/// re-running the full line-breaking algorithm.
5128#[derive(Clone, Debug)]
5129pub struct CachedLineBreaks {
5130    /// Per-line: (first_item_idx, last_item_idx_exclusive) into positioned items.
5131    pub line_ranges: Vec<(usize, usize)>,
5132    /// Per-line total width (sum of item advances on that line).
5133    pub line_widths: Vec<f32>,
5134    /// The available width constraint used when these breaks were computed.
5135    pub available_width: f32,
5136}
5137
5138/// Result of an incremental relayout attempt.
5139#[derive(Debug)]
5140pub enum IncrementalRelayoutResult {
5141    /// Glyphs changed but advance widths identical — swap in place, no repositioning.
5142    GlyphSwap,
5143    /// Width changed but still fits on same line — shift x_offsets of subsequent items.
5144    LineShift {
5145        /// Index of the first affected item.
5146        affected_item: usize,
5147        /// Width delta (new_advance - old_advance).
5148        delta: f32,
5149    },
5150    /// Line breaks changed — need to reflow from this line onward.
5151    PartialReflow {
5152        /// The line index from which to start reflowing.
5153        reflow_from_line: usize,
5154    },
5155    /// Cannot do incremental — fall back to full relayout.
5156    FullRelayout,
5157}
5158
5159/// Extract line break boundaries from a positioned items list.
5160pub fn extract_line_breaks(
5161    items: &[PositionedItem],
5162    available_width: f32,
5163) -> CachedLineBreaks {
5164    let mut line_ranges = Vec::new();
5165    let mut line_widths = Vec::new();
5166
5167    if items.is_empty() {
5168        return CachedLineBreaks { line_ranges, line_widths, available_width };
5169    }
5170
5171    let mut line_start = 0usize;
5172    let mut current_line = items[0].line_index;
5173    let mut line_width = 0.0f32;
5174
5175    for (i, item) in items.iter().enumerate() {
5176        if item.line_index != current_line {
5177            line_ranges.push((line_start, i));
5178            line_widths.push(line_width);
5179            line_start = i;
5180            current_line = item.line_index;
5181            line_width = 0.0;
5182        }
5183        line_width += get_item_measure(&item.item, false);
5184    }
5185
5186    // Final line
5187    line_ranges.push((line_start, items.len()));
5188    line_widths.push(line_width);
5189
5190    CachedLineBreaks { line_ranges, line_widths, available_width }
5191}
5192
5193/// Attempt incremental relayout given old metrics and new per-item advance widths.
5194///
5195/// `dirty_item_indices`: which items in the shaped list changed.
5196/// `old_advances`: per-item advance widths from the previous layout.
5197/// `new_advances`: per-item advance widths after reshaping.
5198/// `line_breaks`: cached line boundaries from previous layout.
5199pub fn try_incremental_relayout(
5200    dirty_item_indices: &[usize],
5201    old_advances: &[f32],
5202    new_advances: &[f32],
5203    line_breaks: &CachedLineBreaks,
5204) -> IncrementalRelayoutResult {
5205    if dirty_item_indices.is_empty() {
5206        return IncrementalRelayoutResult::GlyphSwap;
5207    }
5208
5209    // Check each dirty item
5210    for &dirty_idx in dirty_item_indices {
5211        if dirty_idx >= old_advances.len() || dirty_idx >= new_advances.len() {
5212            return IncrementalRelayoutResult::FullRelayout;
5213        }
5214
5215        let old_adv = old_advances[dirty_idx];
5216        let new_adv = new_advances[dirty_idx];
5217        let delta = new_adv - old_adv;
5218
5219        if delta.abs() < 0.001 {
5220            // Same width — just swap glyphs (GlyphSwap for this item)
5221            continue;
5222        }
5223
5224        // Width changed — find which line this item is on
5225        let line_idx = line_breaks.line_ranges.iter()
5226            .position(|&(start, end)| dirty_idx >= start && dirty_idx < end);
5227
5228        let Some(line_idx) = line_idx else {
5229            return IncrementalRelayoutResult::FullRelayout;
5230        };
5231
5232        let old_line_width = line_breaks.line_widths[line_idx];
5233        let new_line_width = old_line_width + delta;
5234
5235        if new_line_width <= line_breaks.available_width {
5236            // Still fits on same line — shift subsequent items
5237            return IncrementalRelayoutResult::LineShift {
5238                affected_item: dirty_idx,
5239                delta,
5240            };
5241        } else {
5242            // Overflows line — need to reflow from this line
5243            return IncrementalRelayoutResult::PartialReflow {
5244                reflow_from_line: line_idx as usize,
5245            };
5246        }
5247    }
5248
5249    // All dirty items had same width
5250    IncrementalRelayoutResult::GlyphSwap
5251}
5252
5253/// Cached shaped result for a single visual item (or coalesced group).
5254/// Enables per-item cache hits when only one word changes in a paragraph.
5255pub struct PerItemShapedEntry {
5256    /// The shaped clusters for this single item/group.
5257    pub clusters: Vec<ShapedItem>,
5258    /// Sum of advance widths — for fast same-width detection during incremental relayout.
5259    pub total_advance: f32,
5260}
5261
5262pub struct TextShapingCache {
5263    // Stage 1 Cache: InlineContent -> LogicalItems
5264    logical_items: HashMap<CacheId, Arc<Vec<LogicalItem>>>,
5265    // Stage 2 Cache: LogicalItems -> VisualItems
5266    visual_items: HashMap<CacheId, Arc<Vec<VisualItem>>>,
5267    // Stage 3 Cache: VisualItems -> ShapedItems (monolithic, for backward compat)
5268    shaped_items: HashMap<CacheId, Arc<Vec<ShapedItem>>>,
5269    // Stage 3b Cache: Per-item/coalesce-group shaped results
5270    // Key: hash(text, bidi_level, script, style.layout_hash())
5271    per_item_shaped: HashMap<u64, Arc<PerItemShapedEntry>>,
5272    /// Tracks which per_item_shaped keys were accessed in the current generation.
5273    per_item_accessed: HashSet<u64>,
5274    /// Current generation counter, incremented each layout pass.
5275    generation: u64,
5276}
5277
5278/// Approximate heap bytes retained by a [`TextShapingCache`].
5279#[derive(Debug, Clone, Default)]
5280pub struct TextCacheMemoryReport {
5281    pub logical_items_entries: usize,
5282    pub logical_items_bytes: usize,
5283    pub visual_items_entries: usize,
5284    pub visual_items_bytes: usize,
5285    pub shaped_items_entries: usize,
5286    pub shaped_items_bytes: usize,
5287    pub shaped_glyph_bytes: usize,
5288    pub shaped_cluster_text_bytes: usize,
5289    pub per_item_shaped_entries: usize,
5290    pub per_item_shaped_bytes: usize,
5291}
5292
5293impl TextCacheMemoryReport {
5294    pub fn total_bytes(&self) -> usize {
5295        self.logical_items_bytes
5296            + self.visual_items_bytes
5297            + self.shaped_items_bytes
5298            + self.shaped_glyph_bytes
5299            + self.shaped_cluster_text_bytes
5300            + self.per_item_shaped_bytes
5301    }
5302}
5303
5304impl TextShapingCache {
5305    pub fn new() -> Self {
5306        Self {
5307            logical_items: HashMap::new(),
5308            visual_items: HashMap::new(),
5309            shaped_items: HashMap::new(),
5310            per_item_shaped: HashMap::new(),
5311            per_item_accessed: HashSet::new(),
5312            generation: 0,
5313        }
5314    }
5315
5316    /// Approximate per-stage heap-byte breakdown.
5317    pub fn memory_report(&self) -> TextCacheMemoryReport {
5318        let mut r = TextCacheMemoryReport::default();
5319        r.logical_items_entries = self.logical_items.len();
5320        for (_, arc) in &self.logical_items {
5321            r.logical_items_bytes += arc.capacity() * core::mem::size_of::<LogicalItem>();
5322        }
5323        r.visual_items_entries = self.visual_items.len();
5324        for (_, arc) in &self.visual_items {
5325            r.visual_items_bytes += arc.capacity() * core::mem::size_of::<VisualItem>();
5326        }
5327        r.shaped_items_entries = self.shaped_items.len();
5328        for (_, arc) in &self.shaped_items {
5329            r.shaped_items_bytes += arc.capacity() * core::mem::size_of::<ShapedItem>();
5330            for item in arc.iter() {
5331                if let ShapedItem::Cluster(c) = item {
5332                    r.shaped_glyph_bytes += c.glyphs.capacity() * core::mem::size_of::<ShapedGlyph>();
5333                    r.shaped_cluster_text_bytes += c.text.capacity();
5334                }
5335            }
5336        }
5337        r.per_item_shaped_entries = self.per_item_shaped.len();
5338        for (_, arc) in &self.per_item_shaped {
5339            r.per_item_shaped_bytes += arc.clusters.capacity() * core::mem::size_of::<ShapedItem>();
5340            for item in arc.clusters.iter() {
5341                if let ShapedItem::Cluster(c) = item {
5342                    r.per_item_shaped_bytes += c.glyphs.capacity() * core::mem::size_of::<ShapedGlyph>();
5343                    r.per_item_shaped_bytes += c.text.capacity();
5344                }
5345            }
5346        }
5347        r
5348    }
5349
5350    /// Call at the start of each layout pass. Evicts per-item shaped entries
5351    /// not accessed in the previous generation to prevent unbounded growth.
5352    pub fn begin_generation(&mut self) {
5353        if self.generation > 0 && !self.per_item_accessed.is_empty() {
5354            // Evict entries not accessed in this generation
5355            let accessed = &self.per_item_accessed;
5356            self.per_item_shaped.retain(|k, _| accessed.contains(k));
5357        }
5358        self.per_item_accessed.clear();
5359        self.generation += 1;
5360    }
5361
5362    /// Check if we can reuse an old layout based on layout-affecting parameters.
5363    /// 
5364    /// This function compares only the parameters that affect glyph positions,
5365    /// not rendering-only parameters like color or text-decoration.
5366    /// 
5367    /// # Parameters
5368    /// - `old_constraints`: The constraints used for the cached layout
5369    /// - `new_constraints`: The constraints for the new layout request
5370    /// - `old_content`: The content used for the cached layout
5371    /// - `new_content`: The new content to layout
5372    /// 
5373    /// # Returns
5374    /// - `true` if the old layout can be reused (only rendering changed)
5375    /// - `false` if a new layout is needed (layout-affecting params changed)
5376    pub fn use_old_layout(
5377        old_constraints: &UnifiedConstraints,
5378        new_constraints: &UnifiedConstraints,
5379        old_content: &[InlineContent],
5380        new_content: &[InlineContent],
5381    ) -> bool {
5382        // First check: constraints must match exactly for layout purposes
5383        if old_constraints != new_constraints {
5384            return false;
5385        }
5386        
5387        // Second check: content length must match
5388        if old_content.len() != new_content.len() {
5389            return false;
5390        }
5391        
5392        // Third check: each content item must have same layout properties
5393        for (old, new) in old_content.iter().zip(new_content.iter()) {
5394            if !Self::inline_content_layout_eq(old, new) {
5395                return false;
5396            }
5397        }
5398        
5399        true
5400    }
5401    
5402    /// Compare two InlineContent items for layout equality.
5403    /// 
5404    /// Returns true if the layouts would be identical (only rendering differs).
5405    fn inline_content_layout_eq(old: &InlineContent, new: &InlineContent) -> bool {
5406        use InlineContent::*;
5407        match (old, new) {
5408            (Text(old_run), Text(new_run)) => {
5409                // Text must match exactly, but style only needs layout_eq
5410                old_run.text == new_run.text 
5411                    && old_run.style.layout_eq(&new_run.style)
5412            }
5413            (Image(old_img), Image(new_img)) => {
5414                // Images: size affects layout, but not visual properties
5415                old_img.intrinsic_size == new_img.intrinsic_size
5416                    && old_img.display_size == new_img.display_size
5417                    && old_img.baseline_offset == new_img.baseline_offset
5418                    && old_img.alignment == new_img.alignment
5419            }
5420            (Space(old_sp), Space(new_sp)) => old_sp == new_sp,
5421            (LineBreak(old_br), LineBreak(new_br)) => old_br == new_br,
5422            (Tab { style: old_style }, Tab { style: new_style }) => old_style.layout_eq(new_style),
5423            (Marker { run: old_run, position_outside: old_pos },
5424             Marker { run: new_run, position_outside: new_pos }) => {
5425                old_pos == new_pos
5426                    && old_run.text == new_run.text
5427                    && old_run.style.layout_eq(&new_run.style)
5428            }
5429            (Shape(old_shape), Shape(new_shape)) => {
5430                // Shapes: shape_def affects layout, not fill/stroke
5431                old_shape.shape_def == new_shape.shape_def
5432                    && old_shape.baseline_offset == new_shape.baseline_offset
5433            }
5434            (Ruby { base: old_base, text: old_text, style: old_style },
5435             Ruby { base: new_base, text: new_text, style: new_style }) => {
5436                old_style.layout_eq(new_style)
5437                    && old_base.len() == new_base.len()
5438                    && old_text.len() == new_text.len()
5439                    && old_base.iter().zip(new_base.iter())
5440                        .all(|(o, n)| Self::inline_content_layout_eq(o, n))
5441                    && old_text.iter().zip(new_text.iter())
5442                        .all(|(o, n)| Self::inline_content_layout_eq(o, n))
5443            }
5444            // Different variants cannot have same layout
5445            _ => false,
5446        }
5447    }
5448}
5449
5450impl Default for TextShapingCache {
5451    fn default() -> Self {
5452        Self::new()
5453    }
5454}
5455
5456/// Key for caching the conversion from `InlineContent` to `LogicalItem`s.
5457#[derive(Debug, Clone, Eq, PartialEq, Hash)]
5458pub struct LogicalItemsKey<'a> {
5459    pub inline_content_hash: u64, // Pre-hash the content for efficiency
5460    pub default_font_size: u32,   // Affects space widths
5461    // Add other relevant properties from constraints if they affect this stage
5462    pub _marker: std::marker::PhantomData<&'a ()>,
5463}
5464
5465/// Key for caching the Bidi reordering stage.
5466#[derive(Debug, Clone, Eq, PartialEq, Hash)]
5467pub struct VisualItemsKey {
5468    pub logical_items_id: CacheId,
5469    pub base_direction: BidiDirection,
5470}
5471
5472/// Key for caching the shaping stage.
5473#[derive(Debug, Clone, Eq, PartialEq, Hash)]
5474pub struct ShapedItemsKey {
5475    pub visual_items_id: CacheId,
5476    pub style_hash: u64, // Represents a hash of all font/style properties
5477}
5478
5479impl ShapedItemsKey {
5480    pub fn new(visual_items_id: CacheId, visual_items: &[VisualItem]) -> Self {
5481        let style_hash = {
5482            let mut hasher = DefaultHasher::new();
5483            for item in visual_items.iter() {
5484                // Hash the style from the logical source, as this is what determines the font.
5485                match &item.logical_source {
5486                    LogicalItem::Text { style, .. } | LogicalItem::CombinedText { style, .. } => {
5487                        style.as_ref().hash(&mut hasher);
5488                    }
5489                    _ => {}
5490                }
5491            }
5492            hasher.finish()
5493        };
5494
5495        Self {
5496            visual_items_id,
5497            style_hash,
5498        }
5499    }
5500}
5501
5502/// Key for the final layout stage.
5503#[derive(Debug, Clone, Eq, PartialEq, Hash)]
5504pub struct LayoutKey {
5505    pub shaped_items_id: CacheId,
5506    pub constraints: UnifiedConstraints,
5507}
5508
5509/// Helper to create a `CacheId` from any `Hash`able type.
5510fn calculate_id<T: Hash>(item: &T) -> CacheId {
5511    let mut hasher = DefaultHasher::new();
5512    item.hash(&mut hasher);
5513    hasher.finish()
5514}
5515
5516// --- Main Layout Pipeline Implementation ---
5517
5518impl TextShapingCache {
5519    /// New top-level entry point for flowing layout across multiple regions.
5520    ///
5521    /// This function orchestrates the entire layout pipeline, but instead of fitting
5522    /// content into a single set of constraints, it flows the content through an
5523    /// ordered sequence of `LayoutFragment`s.
5524    ///
5525    /// # CSS Inline Layout Module Level 3: Pipeline Implementation
5526    ///
5527    /// This implements the inline formatting context with 5 stages:
5528    ///
5529    /// ## Stage 1: Logical Analysis (InlineContent -> LogicalItem)
5530    /// \u2705 IMPLEMENTED: Parses raw content into logical units
5531    /// - Handles text runs, inline-blocks, replaced elements
5532    /// - Applies style overrides at character level
5533    /// - Implements \u00a7 2.2: Content size contribution calculation
5534    ///
5535    /// ## Stage 2: BiDi Reordering (LogicalItem -> VisualItem)
5536    /// \u2705 IMPLEMENTED: Uses CSS 'direction' property per CSS Writing Modes
5537    /// - Reorders items for right-to-left text (Arabic, Hebrew)
5538    /// - Respects containing block direction (not auto-detection)
5539    /// - Conforms to Unicode BiDi Algorithm (UAX #9)
5540    ///
5541    /// ## Stage 3: Shaping (VisualItem -> ShapedItem)
5542    /// \u2705 IMPLEMENTED: Converts text to glyphs
5543    /// - Uses HarfBuzz for OpenType shaping
5544    /// - Handles ligatures, kerning, contextual forms
5545    /// - Caches shaped results for performance
5546    ///
5547    /// ## Stage 4: Text Orientation Transformations
5548    /// \u26a0\ufe0f PARTIAL: Applies text-orientation for vertical text
5549    /// - Uses constraints from *first* fragment only
5550    /// - \u274c TODO: Should re-orient if fragments have different writing modes
5551    ///
5552    /// ## Stage 5: Flow Loop (ShapedItem -> PositionedItem)
5553    /// \u2705 IMPLEMENTED: Breaks lines and positions content
5554    /// - Calls perform_fragment_layout for each fragment
5555    /// - Uses BreakCursor to flow content across fragments
5556    /// - Implements \u00a7 5: Line breaking and hyphenation
5557    ///
5558    /// # Missing Features from CSS Inline-3:
5559    /// - \u00a7 3.3: initial-letter (drop caps)
5560    /// - \u00a7 4: vertical-align (only baseline supported)
5561    /// - \u00a7 6: text-box-trim (leading trim)
5562    /// - \u00a7 7: inline-sizing (aspect-ratio for inline-blocks)
5563    ///
5564    /// # Arguments
5565    /// * `content` - The raw `InlineContent` to be laid out.
5566    /// * `style_overrides` - Character-level style changes.
5567    /// * `flow_chain` - An ordered slice of `LayoutFragment` defining the regions (e.g., columns,
5568    ///   pages) that the content should flow through.
5569    /// * `font_chain_cache` - Pre-resolved font chains (from FontManager.font_chain_cache)
5570    /// * `fc_cache` - The fontconfig cache for font lookups
5571    /// * `loaded_fonts` - Pre-loaded fonts, keyed by FontId
5572    ///
5573    /// # Returns
5574    /// A `FlowLayout` struct containing the positioned items for each fragment that
5575    /// was filled, and any content that did not fit in the final fragment.
5576    pub fn layout_flow<T: ParsedFontTrait>(
5577        &mut self,
5578        content: &[InlineContent],
5579        style_overrides: &[StyleOverride],
5580        flow_chain: &[LayoutFragment],
5581        font_chain_cache: &HashMap<FontChainKey, rust_fontconfig::FontFallbackChain>,
5582        fc_cache: &FcFontCache,
5583        loaded_fonts: &LoadedFonts<T>,
5584        debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
5585    ) -> Result<FlowLayout, LayoutError> {
5586        // --- Stages 1-3: Preparation ---
5587        // These stages are independent of the final geometry. We perform them once
5588        // on the entire content block before flowing. Caching is used at each stage.
5589
5590        // Cap per-item shaped cache to prevent unbounded growth.
5591        // When threshold is exceeded, evict entries not accessed this generation.
5592        const PER_ITEM_CACHE_MAX: usize = 4096;
5593        if self.per_item_shaped.len() > PER_ITEM_CACHE_MAX {
5594            self.begin_generation();
5595        }
5596
5597        // Stage 1: Logical Analysis (InlineContent -> LogicalItem)
5598        let logical_items_id = calculate_id(&content);
5599        let logical_items = self
5600            .logical_items
5601            .entry(logical_items_id)
5602            .or_insert_with(|| {
5603                Arc::new(create_logical_items(
5604                    content,
5605                    style_overrides,
5606                    debug_messages,
5607                ))
5608            })
5609            .clone();
5610
5611        // Get the first fragment's constraints to extract the CSS direction property.
5612        // This is used for BiDi reordering in Stage 2.
5613        let default_constraints = UnifiedConstraints::default();
5614        let first_constraints = flow_chain
5615            .first()
5616            .map(|f| &f.constraints)
5617            .unwrap_or(&default_constraints);
5618
5619        // +spec:containing-block:e7a271 - paragraph embedding level set from containing block's 'direction' property
5620        // +spec:display-property:7665cb - inline boxes split into multiple visual runs due to bidi text processing
5621        // +spec:display-property:929d6b - applies Unicode bidi algorithm to inline-level box sequences
5622        // +spec:display-property:e8584a - Apply Unicode bidi algorithm to inline-level box sequences per CSS Writing Modes §2.4
5623        // Stage 2: Bidi Reordering (LogicalItem -> VisualItem)
5624        // +spec:containing-block:961e3c - bidi paragraph level from containing block direction, not UAX9 heuristic
5625        // +spec:writing-modes:0a5368 - unicode-bidi: plaintext auto-detects direction from text content
5626        // Per CSS Writing Modes §8.3: when unicode-bidi is plaintext, the paragraph's
5627        // base direction is determined from text content (first strong character), ignoring
5628        // the containing block's direction property. Empty paragraphs fall back to
5629        // the containing block's direction.
5630        let unicode_bidi_val = first_constraints.unicode_bidi;
5631        let base_direction = if unicode_bidi_val == UnicodeBidi::Plaintext {
5632            // Auto-detect from text content; fall back to containing block direction
5633            let has_strong = logical_items.iter().any(|item| {
5634                if let LogicalItem::Text { text, .. } = item {
5635                    matches!(unicode_bidi::get_base_direction(text.as_str()),
5636                        unicode_bidi::Direction::Ltr | unicode_bidi::Direction::Rtl)
5637                } else {
5638                    false
5639                }
5640            });
5641            if has_strong {
5642                get_base_direction_from_logical(&logical_items)
5643            } else {
5644                // Empty paragraph: use containing block's direction
5645                first_constraints.direction.unwrap_or(BidiDirection::Ltr)
5646            }
5647        } else {
5648            // Normal case: use CSS direction property
5649            first_constraints.direction.unwrap_or(BidiDirection::Ltr)
5650        };
5651        let visual_key = VisualItemsKey {
5652            logical_items_id,
5653            base_direction,
5654        };
5655        let visual_items_id = calculate_id(&visual_key);
5656        let visual_items = self
5657            .visual_items
5658            .entry(visual_items_id)
5659            .or_insert_with(|| {
5660                Arc::new(
5661                    reorder_logical_items(&logical_items, base_direction, unicode_bidi_val, debug_messages).unwrap(),
5662                )
5663            })
5664            .clone();
5665
5666        // Stage 3: Shaping (VisualItem -> ShapedItem)
5667        // Two-level cache: monolithic (fast path) + per-item (incremental path).
5668        let shaped_key = ShapedItemsKey::new(visual_items_id, &visual_items);
5669        let shaped_items_id = calculate_id(&shaped_key);
5670        let shaped_items = match self.shaped_items.get(&shaped_items_id) {
5671            Some(cached) => {
5672                // Monolithic cache hit — all visual items unchanged
5673                cached.clone()
5674            }
5675            None => {
5676                // Monolithic miss — use per-item cache for incremental reshaping.
5677                // Items not in per-item cache are shaped; cached items are reused.
5678                let items = Arc::new(shape_visual_items_with_per_item_cache(
5679                    &visual_items,
5680                    &mut self.per_item_shaped,
5681                    &mut self.per_item_accessed,
5682                    font_chain_cache,
5683                    fc_cache,
5684                    loaded_fonts,
5685                    debug_messages,
5686                )?);
5687                self.shaped_items.insert(shaped_items_id, items.clone());
5688                items
5689            }
5690        };
5691
5692        // --- Stage 4: Apply Vertical Text Transformations ---
5693
5694        // Note: first_constraints was already extracted above for BiDi reordering (Stage 2).
5695        // This orients all text based on the constraints of the *first* fragment.
5696        // A more advanced system could defer orientation until inside the loop if
5697        // fragments can have different writing modes.
5698        let oriented_items = apply_text_orientation(shaped_items, first_constraints)?;
5699
5700        // --- Stage 5: The Flow Loop ---
5701        let mut fragment_layouts = HashMap::new();
5702        // The cursor now manages the stream of items for the entire flow.
5703        // §5.2 word-break: pass word_break from constraints to cursor
5704        let mut cursor = BreakCursor::with_word_break(&oriented_items, first_constraints.word_break);
5705        cursor.hyphens = first_constraints.hyphenation;
5706        cursor.line_break = first_constraints.line_break;
5707
5708        for fragment in flow_chain {
5709            // Perform layout for this single fragment, consuming items from the cursor.
5710            let fragment_layout = perform_fragment_layout(
5711                &mut cursor,
5712                &logical_items,
5713                &fragment.constraints,
5714                debug_messages,
5715                loaded_fonts,
5716            )?;
5717
5718            fragment_layouts.insert(fragment.id.clone(), Arc::new(fragment_layout));
5719            if cursor.is_done() {
5720                break; // All content has been laid out.
5721            }
5722        }
5723
5724        Ok(FlowLayout {
5725            fragment_layouts,
5726            remaining_items: cursor.drain_remaining(),
5727        })
5728    }
5729
5730    /// Runs stages 1–4 of the layout pipeline (logical analysis, BiDi, shaping,
5731    /// text orientation) and derives min/max-content widths by scanning the
5732    /// resulting `ShapedItem`s directly — without running stage 5's line-breaking
5733    /// `BreakCursor` loop.
5734    ///
5735    /// Used by `calculate_ifc_root_intrinsic_sizes` to avoid the 24% CPU spent
5736    /// cloning `ShapedCluster`s inside `BreakCursor::peek_next_unit` on every
5737    /// sizing pass. Since stages 1–3 hit the same `per_item_shaped` cache as
5738    /// `layout_flow`, a subsequent `layout_flow` call for the same content at
5739    /// a real container width is a pure cache hit for the shaping work.
5740    ///
5741    /// The item walk uses the same break-opportunity predicate that the
5742    /// `BreakCursor` would — min-content accumulates advances between break
5743    /// opportunities and tracks the maximum; max-content is the sum of all
5744    /// advances (as if the flow were laid out on a single infinitely-wide line).
5745    pub fn measure_intrinsic_widths<T: ParsedFontTrait>(
5746        &mut self,
5747        content: &[InlineContent],
5748        style_overrides: &[StyleOverride],
5749        constraints: &UnifiedConstraints,
5750        font_chain_cache: &HashMap<FontChainKey, rust_fontconfig::FontFallbackChain>,
5751        fc_cache: &FcFontCache,
5752        loaded_fonts: &LoadedFonts<T>,
5753        debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
5754    ) -> Result<IntrinsicTextSizes, LayoutError> {
5755        const PER_ITEM_CACHE_MAX: usize = 4096;
5756        if self.per_item_shaped.len() > PER_ITEM_CACHE_MAX {
5757            self.begin_generation();
5758        }
5759
5760        // Stage 1: Logical Analysis
5761        let logical_items_id = calculate_id(&content);
5762        let logical_items = self
5763            .logical_items
5764            .entry(logical_items_id)
5765            .or_insert_with(|| {
5766                Arc::new(create_logical_items(
5767                    content,
5768                    style_overrides,
5769                    debug_messages,
5770                ))
5771            })
5772            .clone();
5773
5774        // Stage 2: BiDi (same derivation as layout_flow)
5775        let unicode_bidi_val = constraints.unicode_bidi;
5776        let base_direction = if unicode_bidi_val == UnicodeBidi::Plaintext {
5777            let has_strong = logical_items.iter().any(|item| {
5778                if let LogicalItem::Text { text, .. } = item {
5779                    matches!(unicode_bidi::get_base_direction(text.as_str()),
5780                        unicode_bidi::Direction::Ltr | unicode_bidi::Direction::Rtl)
5781                } else {
5782                    false
5783                }
5784            });
5785            if has_strong {
5786                get_base_direction_from_logical(&logical_items)
5787            } else {
5788                constraints.direction.unwrap_or(BidiDirection::Ltr)
5789            }
5790        } else {
5791            constraints.direction.unwrap_or(BidiDirection::Ltr)
5792        };
5793        let visual_key = VisualItemsKey {
5794            logical_items_id,
5795            base_direction,
5796        };
5797        let visual_items_id = calculate_id(&visual_key);
5798        let visual_items = self
5799            .visual_items
5800            .entry(visual_items_id)
5801            .or_insert_with(|| {
5802                Arc::new(
5803                    reorder_logical_items(&logical_items, base_direction, unicode_bidi_val, debug_messages).unwrap(),
5804                )
5805            })
5806            .clone();
5807
5808        // Stage 3: Shaping
5809        let shaped_key = ShapedItemsKey::new(visual_items_id, &visual_items);
5810        let shaped_items_id = calculate_id(&shaped_key);
5811        let shaped_items = match self.shaped_items.get(&shaped_items_id) {
5812            Some(cached) => cached.clone(),
5813            None => {
5814                let items = Arc::new(shape_visual_items_with_per_item_cache(
5815                    &visual_items,
5816                    &mut self.per_item_shaped,
5817                    &mut self.per_item_accessed,
5818                    font_chain_cache,
5819                    fc_cache,
5820                    loaded_fonts,
5821                    debug_messages,
5822                )?);
5823                self.shaped_items.insert(shaped_items_id, items.clone());
5824                items
5825            }
5826        };
5827
5828        // Stage 4: Text orientation
5829        let oriented_items = apply_text_orientation(shaped_items, constraints)?;
5830
5831        // Stage 5 bypass: scan items for min/max contributions.
5832        let word_break = constraints.word_break;
5833        let hyphens = constraints.hyphenation;
5834
5835        let mut total = 0.0f32;
5836        let mut max_word = 0.0f32;
5837        let mut cur_word = 0.0f32;
5838        let mut max_line_height = 0.0f32;
5839
5840        for item in oriented_items.iter() {
5841            let advance = match item {
5842                ShapedItem::Cluster(c) => c.advance,
5843                ShapedItem::CombinedBlock { bounds, .. }
5844                | ShapedItem::Object { bounds, .. }
5845                | ShapedItem::Tab { bounds, .. } => bounds.width,
5846                ShapedItem::Break { .. } => 0.0,
5847            };
5848            let adv = advance.max(0.0);
5849            total += adv;
5850
5851            let (asc, desc) = get_item_vertical_metrics_approx(item);
5852            let h = (asc + desc).max(item.bounds().height);
5853            if h > max_line_height {
5854                max_line_height = h;
5855            }
5856
5857            if is_break_opportunity_with_word_break(item, word_break, hyphens) {
5858                if cur_word > max_word {
5859                    max_word = cur_word;
5860                }
5861                cur_word = 0.0;
5862            } else {
5863                cur_word += adv;
5864            }
5865        }
5866        if cur_word > max_word {
5867            max_word = cur_word;
5868        }
5869
5870        Ok(IntrinsicTextSizes {
5871            min_content_width: max_word,
5872            max_content_width: total,
5873            max_content_height: max_line_height,
5874        })
5875    }
5876}
5877
5878// --- Stage 1 Implementation ---
5879pub fn create_logical_items(
5880    content: &[InlineContent],
5881    style_overrides: &[StyleOverride],
5882    debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
5883) -> Vec<LogicalItem> {
5884    if let Some(msgs) = debug_messages {
5885        msgs.push(LayoutDebugMessage::info(
5886            "\n--- Entering create_logical_items (Refactored) ---".to_string(),
5887        ));
5888        msgs.push(LayoutDebugMessage::info(format!(
5889            "Input content length: {}",
5890            content.len()
5891        )));
5892        msgs.push(LayoutDebugMessage::info(format!(
5893            "Input overrides length: {}",
5894            style_overrides.len()
5895        )));
5896    }
5897
5898    let mut items = Vec::new();
5899    let mut style_cache: HashMap<u64, Arc<StyleProperties>> = HashMap::new();
5900
5901    // 1. Organize overrides for fast lookup per run.
5902    let mut run_overrides: HashMap<u32, HashMap<u32, &PartialStyleProperties>> = HashMap::new();
5903    for override_item in style_overrides {
5904        run_overrides
5905            .entry(override_item.target.run_index)
5906            .or_default()
5907            .insert(override_item.target.item_index, &override_item.style);
5908    }
5909
5910    for (run_idx, inline_item) in content.iter().enumerate() {
5911        if let Some(msgs) = debug_messages {
5912            msgs.push(LayoutDebugMessage::info(format!(
5913                "Processing content run #{}",
5914                run_idx
5915            )));
5916        }
5917
5918        // Extract marker information if this is a marker
5919        let marker_position_outside = match inline_item {
5920            InlineContent::Marker {
5921                position_outside, ..
5922            } => Some(*position_outside),
5923            _ => None,
5924        };
5925
5926        match inline_item {
5927            InlineContent::Text(run) | InlineContent::Marker { run, .. } => {
5928                let text = &run.text;
5929                if text.is_empty() {
5930                    if let Some(msgs) = debug_messages {
5931                        msgs.push(LayoutDebugMessage::info(
5932                            "  Run is empty, skipping.".to_string(),
5933                        ));
5934                    }
5935                    continue;
5936                }
5937                if let Some(msgs) = debug_messages {
5938                    msgs.push(LayoutDebugMessage::info(format!("  Run text: '{}'", text)));
5939                }
5940
5941                let current_run_overrides = run_overrides.get(&(run_idx as u32));
5942                let mut boundaries = BTreeSet::new();
5943                boundaries.insert(0);
5944                boundaries.insert(text.len());
5945
5946                // --- Stateful Boundary Generation ---
5947                let mut scan_cursor = 0;
5948                while scan_cursor < text.len() {
5949                    let style_at_cursor = if let Some(partial) =
5950                        current_run_overrides.and_then(|o| o.get(&(scan_cursor as u32)))
5951                    {
5952                        // Create a temporary, full style to check its properties
5953                        run.style.apply_override(partial)
5954                    } else {
5955                        (*run.style).clone()
5956                    };
5957
5958                    let current_char = text[scan_cursor..].chars().next().unwrap();
5959
5960                    // +spec:containing-block:e4d9de - text-combine-upright digit run rules: digits sharing an ancestor with same value form one sequence across box boundaries
5961                    // +spec:inline-formatting-context:f65029 - text-combine-upright text run rules: combine consecutive digits not interrupted by box boundary
5962                    // Rule 1: Multi-character features take precedence.
5963                    // +spec:containing-block:9a26bd - text-combine-upright digit runs scoped by ancestor style boundaries
5964                    if let Some(TextCombineUpright::Digits(max_digits)) =
5965                        style_at_cursor.text_combine_upright
5966                    {
5967                        if max_digits > 0 && current_char.is_ascii_digit() {
5968                            let digit_chunk: String = text[scan_cursor..]
5969                                .chars()
5970                                .take(max_digits as usize)
5971                                .take_while(|c| c.is_ascii_digit())
5972                                .collect();
5973
5974                            let end_of_chunk = scan_cursor + digit_chunk.len();
5975                            boundaries.insert(scan_cursor);
5976                            boundaries.insert(end_of_chunk);
5977                            scan_cursor = end_of_chunk; // Jump past the entire sequence
5978                            continue;
5979                        }
5980                    }
5981
5982                    // Rule 2: If no multi-char feature, check for a normal single-grapheme
5983                    // override.
5984                    if current_run_overrides
5985                        .and_then(|o| o.get(&(scan_cursor as u32)))
5986                        .is_some()
5987                    {
5988                        let grapheme_len = text[scan_cursor..]
5989                            .graphemes(true)
5990                            .next()
5991                            .unwrap_or("")
5992                            .len();
5993                        boundaries.insert(scan_cursor);
5994                        boundaries.insert(scan_cursor + grapheme_len);
5995                        scan_cursor += grapheme_len;
5996                        continue;
5997                    }
5998
5999                    // Rule 3: No special features or overrides at this point, just advance one
6000                    // char.
6001                    scan_cursor += current_char.len_utf8();
6002                }
6003
6004                if let Some(msgs) = debug_messages {
6005                    msgs.push(LayoutDebugMessage::info(format!(
6006                        "  Boundaries: {:?}",
6007                        boundaries
6008                    )));
6009                }
6010
6011                // --- Chunk Processing ---
6012                for (start, end) in boundaries.iter().zip(boundaries.iter().skip(1)) {
6013                    let (start, end) = (*start, *end);
6014                    if start >= end {
6015                        continue;
6016                    }
6017
6018                    let text_slice = &text[start..end];
6019                    if let Some(msgs) = debug_messages {
6020                        msgs.push(LayoutDebugMessage::info(format!(
6021                            "  Processing chunk from {} to {}: '{}'",
6022                            start, end, text_slice
6023                        )));
6024                    }
6025
6026                    let style_to_use = if let Some(partial_style) =
6027                        current_run_overrides.and_then(|o| o.get(&(start as u32)))
6028                    {
6029                        if let Some(msgs) = debug_messages {
6030                            msgs.push(LayoutDebugMessage::info(format!(
6031                                "  -> Applying override at byte {}",
6032                                start
6033                            )));
6034                        }
6035                        let mut hasher = DefaultHasher::new();
6036                        Arc::as_ptr(&run.style).hash(&mut hasher);
6037                        partial_style.hash(&mut hasher);
6038                        style_cache
6039                            .entry(hasher.finish())
6040                            .or_insert_with(|| Arc::new(run.style.apply_override(partial_style)))
6041                            .clone()
6042                    } else {
6043                        run.style.clone()
6044                    };
6045
6046                    // +spec:block-formatting-context:9e7c79 - text-combine-upright combines multiple characters into 1em in vertical writing
6047                    // +spec:containing-block:2b399b - text-combine-upright digits: combine ASCII digit sequences within max_digits limit; box boundaries implicitly prevent cross-box combination
6048                    // +spec:display-contents:644c78 - text-combine-upright run boundary check:
6049                    // if a combinable run boundary is due only to inline box boundaries,
6050                    // and adjacent chars would form a longer combinable sequence, do not combine
6051                    // +spec:white-space-processing:409d90 - text-combine-upright combined text: white space at start/end processed as in inline-block
6052                    let is_combinable_chunk = match &style_to_use.text_combine_upright {
6053                        Some(TextCombineUpright::All) => !text_slice.is_empty(),
6054                        Some(TextCombineUpright::Digits(max_digits)) => {
6055                            *max_digits > 0
6056                                && !text_slice.is_empty()
6057                                && text_slice.chars().all(|c| c.is_ascii_digit())
6058                                && text_slice.chars().count() <= *max_digits as usize
6059                        }
6060                        _ => false,
6061                    };
6062
6063                    if is_combinable_chunk {
6064                        // Trim leading/trailing white space like an inline-block
6065                        let trimmed = text_slice.trim();
6066                        let combined_text = if trimmed.is_empty() {
6067                            text_slice.to_string()
6068                        } else {
6069                            trimmed.to_string()
6070                        };
6071                        items.push(LogicalItem::CombinedText {
6072                            source: ContentIndex {
6073                                run_index: run_idx as u32,
6074                                item_index: start as u32,
6075                            },
6076                            text: combined_text,
6077                            style: style_to_use,
6078                        });
6079                    } else {
6080                        items.push(LogicalItem::Text {
6081                            source: ContentIndex {
6082                                run_index: run_idx as u32,
6083                                item_index: start as u32,
6084                            },
6085                            text: text_slice.to_string(),
6086                            style: style_to_use,
6087                            marker_position_outside,
6088                            source_node_id: run.source_node_id,
6089                        });
6090                    }
6091                }
6092            }
6093            // line breaking class characters must be treated as forced line breaks
6094            InlineContent::LineBreak(break_info) => {
6095                if let Some(msgs) = debug_messages {
6096                    msgs.push(LayoutDebugMessage::info(format!(
6097                        "  LineBreak: {:?}",
6098                        break_info
6099                    )));
6100                }
6101                items.push(LogicalItem::Break {
6102                    source: ContentIndex {
6103                        run_index: run_idx as u32,
6104                        item_index: 0,
6105                    },
6106                    break_info: break_info.clone(),
6107                });
6108            }
6109            // Handle tab characters
6110            InlineContent::Tab { style } => {
6111                if let Some(msgs) = debug_messages {
6112                    msgs.push(LayoutDebugMessage::info("  Tab character".to_string()));
6113                }
6114                items.push(LogicalItem::Tab {
6115                    source: ContentIndex {
6116                        run_index: run_idx as u32,
6117                        item_index: 0,
6118                    },
6119                    style: style.clone(),
6120                });
6121            }
6122            // Other cases (Image, Shape, Space, Ruby)
6123            _ => {
6124                if let Some(msgs) = debug_messages {
6125                    msgs.push(LayoutDebugMessage::info(
6126                        "  Run is not text, creating generic LogicalItem.".to_string(),
6127                    ));
6128                }
6129                items.push(LogicalItem::Object {
6130                    source: ContentIndex {
6131                        run_index: run_idx as u32,
6132                        item_index: 0,
6133                    },
6134                    content: inline_item.clone(),
6135                });
6136            }
6137        }
6138    }
6139    if let Some(msgs) = debug_messages {
6140        msgs.push(LayoutDebugMessage::info(format!(
6141            "--- Exiting create_logical_items, created {} items ---",
6142            items.len()
6143        )));
6144    }
6145    items
6146}
6147
6148// --- Stage 2 Implementation ---
6149
6150// +spec:inline-block:d47971 - unicode-bidi:plaintext uses P2/P3 heuristic for base direction (implemented via get_base_direction)
6151// +spec:writing-modes:287491 - BiDi reordering and base direction detection (Appendix A text processing order)
6152// when determining base direction, consistent with their neutral bidi treatment
6153pub fn get_base_direction_from_logical(logical_items: &[LogicalItem]) -> BidiDirection {
6154    let first_strong = logical_items.iter().find_map(|item| {
6155        if let LogicalItem::Text { text, .. } = item {
6156            Some(unicode_bidi::get_base_direction(text.as_str()))
6157        } else {
6158            None
6159        }
6160    });
6161
6162    match first_strong {
6163        Some(unicode_bidi::Direction::Rtl) => BidiDirection::Rtl,
6164        _ => BidiDirection::Ltr,
6165    }
6166}
6167
6168// +spec:containing-block:149255 - bidi reordering produces inline box fragments that may separate in wide containing blocks
6169// +spec:containing-block:c7c08f - bidi reordering produces inline box fragments that may be adjacent in narrow containing blocks
6170// +spec:containing-block:2936ae - bidi reordering splits inline boxes into visual fragments (CSS Writing Modes 4 §2.4.5)
6171// +spec:display-property:0cdbd3 - bidi reordering splits inline boxes into visual runs; each run is shaped/formatted independently
6172// +spec:display-property:0d62a2 - bidi reordering of inline content respects block direction and unicode-bidi embedding
6173// +spec:display-property:10f9cd - bidi reordering splits and reorders inline box fragments
6174// +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
6175// +spec:display-property:ecd935 - inline boxes split and reordered for uniform bidi flow
6176// +spec:writing-modes:330b8f - text ordered according to Unicode bidi algorithm after white-space processing
6177// +spec:writing-modes:7a9e7d - bidi control translation: text passed to unicode_bidi for reordering
6178// +spec:writing-modes:8e7281 - unicode-bidi property: bidi control codes inserted via BidiInfo
6179pub fn reorder_logical_items(
6180    logical_items: &[LogicalItem],
6181    base_direction: BidiDirection,
6182    unicode_bidi: UnicodeBidi,
6183    debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
6184) -> Result<Vec<VisualItem>, LayoutError> {
6185    if let Some(msgs) = debug_messages {
6186        msgs.push(LayoutDebugMessage::info(
6187            "\n--- Entering reorder_logical_items ---".to_string(),
6188        ));
6189        msgs.push(LayoutDebugMessage::info(format!(
6190            "Input logical items count: {}",
6191            logical_items.len()
6192        )));
6193        msgs.push(LayoutDebugMessage::info(format!(
6194            "Base direction: {:?}",
6195            base_direction
6196        )));
6197    }
6198
6199    // +spec:writing-modes:809513 - bidi string built across inline element boundaries; unicode-bidi:normal adds no extra embedding levels
6200    let mut bidi_str = String::new();
6201    let mut item_map = Vec::new();
6202    for (idx, item) in logical_items.iter().enumerate() {
6203        // +spec:containing-block:1fdc31 - inline boxes with unicode-bidi:normal are transparent to bidi algorithm
6204        // +spec:display-property:074abf - inline boxes transparent to bidi when unicode-bidi:normal
6205        // +spec:display-property:354966 - unicode-bidi control code injection for inline boxes
6206        // +spec:display-property:8409d3 - inline-level elements with unicode-bidi:normal have no effect on bidi ordering; embed creates an embedding
6207        // +spec:display-property:89464a - inline boxes with unicode-bidi:normal don't open embedding levels, so direction has no effect on bidi reordering
6208        // +spec:display-property:d47971 - bidi control codes should be injected at inline box boundaries based on unicode-bidi + direction
6209        // +spec:display-property:de657b - bidi control codes injected for display:inline boxes per unicode-bidi value
6210        // +spec:display-property:f01a81 - bidi-override should prepend LRO/RLO and append PDF per unicode-bidi CSS property (not yet implemented)
6211        // are treated as neutral characters in the bidi algorithm. Replaced elements with
6212        // +spec:display-property:fcb011 - unicode-bidi values on inline boxes insert bidi control codes
6213        // +spec:display-property:89095f - isolate/bidi-override/isolate-override/plaintext semantics
6214        // +spec:writing-modes:d490bf - direction only affects reordering when unicode-bidi is embed/override (not yet enforced for inline elements)
6215        // display:inline are also neutral unless unicode-bidi != normal (not yet implemented).
6216        // +spec:display-property:b4756e - replaced inline elements treated as neutral bidi chars;
6217        // embed/bidi-override exception not yet implemented (would make them strong chars).
6218        // U+FFFC (OBJECT REPLACEMENT CHARACTER) is a neutral bidi character.
6219        // +spec:display-property:df11ef - atomic inlines treated as neutral bidi characters (U+FFFC)
6220        // Replaced elements with display:inline are also neutral unless unicode-bidi != normal.
6221        let text = match item {
6222            LogicalItem::Text { text, .. } => text.as_str(),
6223            LogicalItem::CombinedText { text, .. } => text.as_str(),
6224            _ => "\u{FFFC}",
6225        };
6226        let start_byte = bidi_str.len();
6227        bidi_str.push_str(text);
6228        for _ in start_byte..bidi_str.len() {
6229            item_map.push(idx);
6230        }
6231    }
6232
6233    if bidi_str.is_empty() {
6234        if let Some(msgs) = debug_messages {
6235            msgs.push(LayoutDebugMessage::info(
6236                "Bidi string is empty, returning.".to_string(),
6237            ));
6238        }
6239        return Ok(Vec::new());
6240    }
6241    if let Some(msgs) = debug_messages {
6242        msgs.push(LayoutDebugMessage::info(format!(
6243            "Constructed bidi string: '{}'",
6244            bidi_str
6245        )));
6246    }
6247
6248    // +spec:display-property:1a6075 - paragraph embedding level set from direction property per UAX9 HL1
6249    // +spec:containing-block:0d4914 - unicode-bidi: plaintext exception
6250    // When the containing block has unicode-bidi: plaintext, use None so the
6251    // Unicode bidi algorithm applies P2/P3 heuristics instead of the HL1 override
6252    let bidi_level = if unicode_bidi == UnicodeBidi::Plaintext {
6253        None
6254    } else if base_direction == BidiDirection::Rtl {
6255        Some(Level::rtl())
6256    } else {
6257        Some(Level::ltr())
6258    };
6259    // +spec:writing-modes:15bf17 - bidi isolation handled by unicode_bidi UAX #9 implementation
6260    let bidi_info = BidiInfo::new(&bidi_str, bidi_level);
6261    let para = &bidi_info.paragraphs[0];
6262    let (levels, visual_runs) = bidi_info.visual_runs(para, para.range.clone());
6263
6264    if let Some(msgs) = debug_messages {
6265        msgs.push(LayoutDebugMessage::info(
6266            "Bidi visual runs generated:".to_string(),
6267        ));
6268        for (i, run_range) in visual_runs.iter().enumerate() {
6269            let level = levels[run_range.start].number();
6270            let slice = &bidi_str[run_range.start..run_range.end];
6271            msgs.push(LayoutDebugMessage::info(format!(
6272                "  Run {}: range={:?}, level={}, text='{}'",
6273                i, run_range, level, slice
6274            )));
6275        }
6276    }
6277
6278    let mut visual_items = Vec::new();
6279    for run_range in visual_runs {
6280        let bidi_level = BidiLevel::new(levels[run_range.start].number());
6281        let mut sub_run_start = run_range.start;
6282
6283        for i in (run_range.start + 1)..run_range.end {
6284            if item_map[i] != item_map[sub_run_start] {
6285                let logical_idx = item_map[sub_run_start];
6286                let logical_item = &logical_items[logical_idx];
6287                let text_slice = &bidi_str[sub_run_start..i];
6288                visual_items.push(VisualItem {
6289                    logical_source: logical_item.clone(),
6290                    bidi_level,
6291                    script: crate::text3::script::detect_script(text_slice)
6292                        .unwrap_or(Script::Latin),
6293                    text: text_slice.to_string(),
6294                });
6295                sub_run_start = i;
6296            }
6297        }
6298
6299        let logical_idx = item_map[sub_run_start];
6300        let logical_item = &logical_items[logical_idx];
6301        let text_slice = &bidi_str[sub_run_start..run_range.end];
6302        visual_items.push(VisualItem {
6303            logical_source: logical_item.clone(),
6304            bidi_level,
6305            script: crate::text3::script::detect_script(text_slice).unwrap_or(Script::Latin),
6306            text: text_slice.to_string(),
6307        });
6308    }
6309
6310    if let Some(msgs) = debug_messages {
6311        msgs.push(LayoutDebugMessage::info(
6312            "Final visual items produced:".to_string(),
6313        ));
6314        for (i, item) in visual_items.iter().enumerate() {
6315            msgs.push(LayoutDebugMessage::info(format!(
6316                "  Item {}: level={}, text='{}'",
6317                i,
6318                item.bidi_level.level(),
6319                item.text
6320            )));
6321        }
6322        msgs.push(LayoutDebugMessage::info(
6323            "--- Exiting reorder_logical_items ---".to_string(),
6324        ));
6325    }
6326    Ok(visual_items)
6327}
6328
6329// --- Stage 3 Implementation ---
6330
6331/// Shape visual items into ShapedItems using pre-loaded fonts.
6332///
6333/// This function does NOT load any fonts - all fonts must be pre-loaded and passed in.
6334/// If a required font is not in `loaded_fonts`, the text will be skipped with a warning.
6335///
6336/// **Optimization: Inline Run Coalescing**
6337///
6338/// // +spec:display-property:9c6d59 - text shaping not broken across inline box boundaries when no effective formatting change
6339/// // +spec:display-property:cf8917 - text shaping not broken across inline box boundaries
6340/// When consecutive text `VisualItem`s share the same layout-affecting properties
6341/// (font, size, spacing, etc.) but differ only in rendering properties (color,
6342/// background), they are coalesced into a single shaping call. This dramatically
6343/// reduces the number of `font.shape_text()` invocations for syntax-highlighted
6344/// code where hundreds of `<span>` elements use the same monospace font but
6345/// different colors. After shaping, the original per-span styles are restored
6346/// to each `ShapedCluster` based on byte-range mapping.
6347/// Shape visual items with per-item caching. For each item (or coalesced group),
6348/// compute a cache key from (text, bidi_level, script, style_layout_hash). On cache
6349/// hit, reuse the previously shaped clusters. On miss, shape and store.
6350///
6351/// This is the incremental shaping path: when one word changes in a paragraph,
6352/// only that word's item misses the per-item cache; all other items hit.
6353pub fn shape_visual_items_with_per_item_cache<T: ParsedFontTrait>(
6354    visual_items: &[VisualItem],
6355    per_item_cache: &mut HashMap<u64, Arc<PerItemShapedEntry>>,
6356    per_item_accessed: &mut HashSet<u64>,
6357    font_chain_cache: &HashMap<FontChainKey, rust_fontconfig::FontFallbackChain>,
6358    fc_cache: &FcFontCache,
6359    loaded_fonts: &LoadedFonts<T>,
6360    debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
6361) -> Result<Vec<ShapedItem>, LayoutError> {
6362    // Delegate to the existing shaping logic, but for each coalesce group,
6363    // check the per-item cache first.
6364    //
6365    // Strategy: Identify coalesce groups (adjacent items with same layout_hash,
6366    // bidi_level, script). For each group, compute a key from the concatenated
6367    // text + shared properties. Check cache. On miss, shape the group and cache it.
6368    let mut shaped = Vec::new();
6369    let mut idx = 0;
6370
6371    while idx < visual_items.len() {
6372        let item = &visual_items[idx];
6373
6374        // Determine coalesce group boundaries (same logic as shape_visual_items)
6375        let (layout_hash, bidi_level, script) = match &item.logical_source {
6376            LogicalItem::Text { style, .. } | LogicalItem::CombinedText { style, .. } => {
6377                (style.layout_hash(), item.bidi_level, item.script)
6378            }
6379            _ => {
6380                // Non-text items: shape individually (no coalescing)
6381                let single = shape_visual_items(
6382                    &visual_items[idx..idx+1],
6383                    font_chain_cache, fc_cache, loaded_fonts, debug_messages,
6384                )?;
6385                shaped.extend(single);
6386                idx += 1;
6387                continue;
6388            }
6389        };
6390
6391        let mut coalesce_end = idx + 1;
6392        while coalesce_end < visual_items.len() {
6393            let next = &visual_items[coalesce_end];
6394            let next_layout_hash = match &next.logical_source {
6395                LogicalItem::Text { style, .. } | LogicalItem::CombinedText { style, .. } => {
6396                    Some(style.layout_hash())
6397                }
6398                _ => None,
6399            };
6400            if let Some(nlh) = next_layout_hash {
6401                if nlh == layout_hash
6402                    && next.bidi_level == bidi_level
6403                    && next.script == script
6404                {
6405                    coalesce_end += 1;
6406                } else {
6407                    break;
6408                }
6409            } else {
6410                break;
6411            }
6412        }
6413
6414        // Compute per-group cache key
6415        let mut hasher = std::collections::hash_map::DefaultHasher::new();
6416        use std::hash::{Hash, Hasher};
6417        for j in idx..coalesce_end {
6418            visual_items[j].text.hash(&mut hasher);
6419        }
6420        layout_hash.hash(&mut hasher);
6421        bidi_level.hash(&mut hasher);
6422        (script as u32).hash(&mut hasher);
6423        let group_key = hasher.finish();
6424
6425        // Check per-item cache
6426        per_item_accessed.insert(group_key);
6427        if let Some(cached) = per_item_cache.get(&group_key) {
6428            shaped.extend(cached.clusters.iter().cloned());
6429        } else {
6430            // Cache miss — shape this group
6431            let group_items = shape_visual_items(
6432                &visual_items[idx..coalesce_end],
6433                font_chain_cache, fc_cache, loaded_fonts, debug_messages,
6434            )?;
6435            let total_advance: f32 = group_items.iter().map(|item| {
6436                match item {
6437                    ShapedItem::Cluster(c) => c.advance,
6438                    _ => 0.0,
6439                }
6440            }).sum();
6441            per_item_cache.insert(group_key, Arc::new(PerItemShapedEntry {
6442                clusters: group_items.clone(),
6443                total_advance,
6444            }));
6445            shaped.extend(group_items);
6446        }
6447
6448        idx = coalesce_end;
6449    }
6450
6451    Ok(shaped)
6452}
6453
6454/// Split text into segments where consecutive characters resolve to the same font
6455/// in the fallback chain. Returns Vec<(byte_start, byte_end, FontId)>.
6456///
6457/// Characters that can't be resolved to any font are skipped (gap in coverage).
6458fn split_text_by_font_coverage(
6459    text: &str,
6460    font_chain: &rust_fontconfig::FontFallbackChain,
6461    fc_cache: &FcFontCache,
6462) -> Vec<(usize, usize, FontId)> {
6463    let mut segments: Vec<(usize, usize, FontId)> = Vec::new();
6464
6465    for (byte_idx, ch) in text.char_indices() {
6466        let char_end = byte_idx + ch.len_utf8();
6467        if let Some((font_id, _)) = font_chain.resolve_char(fc_cache, ch) {
6468            match segments.last_mut() {
6469                Some(last) if last.2 == font_id && last.1 == byte_idx => {
6470                    // Extend current segment (same font, contiguous)
6471                    last.1 = char_end;
6472                }
6473                _ => {
6474                    // New segment (different font or gap)
6475                    segments.push((byte_idx, char_end, font_id));
6476                }
6477            }
6478        }
6479    }
6480
6481    segments
6482}
6483
6484/// Shape text with per-character font fallback.
6485///
6486/// Splits the text into segments by font coverage, shapes each segment with
6487/// its resolved font, and fixes byte offsets so they're relative to the
6488/// original `text` (not the segment substring).
6489fn shape_with_font_fallback<T: ParsedFontTrait>(
6490    text: &str,
6491    script: Script,
6492    language: crate::text3::script::Language,
6493    direction: BidiDirection,
6494    style: &Arc<StyleProperties>,
6495    source_index: ContentIndex,
6496    source_node_id: Option<NodeId>,
6497    font_chain: &rust_fontconfig::FontFallbackChain,
6498    fc_cache: &FcFontCache,
6499    loaded_fonts: &LoadedFonts<T>,
6500) -> Result<Vec<ShapedCluster>, LayoutError> {
6501    // Cache the debug flag in a `OnceLock<bool>` — reading it per-shape
6502    // (this function fires once per text segment, ~hundreds of times
6503    // per render of a real DOM) costs ~100 ns per `std::env::var_os`
6504    // call on macOS (env-lock + hashmap lookup), and even before the
6505    // lookup finishes the `eprintln!` machinery takes a stderr lock
6506    // and allocates the formatted string. Both are invisible in
6507    // release unless `AZ_FONT_FALLBACK_DEBUG=1` is set.
6508    static FONT_FB_DEBUG: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
6509    let dbg = *FONT_FB_DEBUG.get_or_init(|| {
6510        std::env::var_os("AZ_FONT_FALLBACK_DEBUG").is_some()
6511    });
6512
6513    let segments = split_text_by_font_coverage(text, font_chain, fc_cache);
6514
6515    if dbg && segments.len() > 1 {
6516        eprintln!(
6517            "[FONT FALLBACK] text needs {} font segments for '{}' ({}..{} bytes)",
6518            segments.len(),
6519            text.chars().take(40).collect::<String>(),
6520            0, text.len()
6521        );
6522    }
6523
6524    if segments.len() <= 1 {
6525        // Fast path: all characters use the same font (common case)
6526        let (seg_start, seg_end, font_id) = match segments.first() {
6527            Some(s) => s,
6528            None => {
6529                if dbg {
6530                    eprintln!("[FONT FALLBACK] no font could render any char in '{}'", text.chars().take(20).collect::<String>());
6531                }
6532                return Ok(Vec::new());
6533            }
6534        };
6535        let font = match loaded_fonts.get(font_id) {
6536            Some(f) => f,
6537            None => {
6538                if dbg {
6539                    eprintln!("[FONT FALLBACK] font {:?} not in loaded_fonts for '{}'", font_id, text.chars().take(20).collect::<String>());
6540                }
6541                return Ok(Vec::new());
6542            }
6543        };
6544        // If segment covers the full text (overwhelmingly common), skip substr+fixup
6545        if *seg_start == 0 && *seg_end == text.len() {
6546            return shape_text_correctly(
6547                text, script, language, direction,
6548                font, style, source_index, source_node_id,
6549            );
6550        }
6551        let mut clusters = shape_text_correctly(
6552            &text[*seg_start..*seg_end], script, language, direction,
6553            font, style, source_index, source_node_id,
6554        )?;
6555        if *seg_start > 0 {
6556            for cluster in &mut clusters {
6557                cluster.source_cluster_id.start_byte_in_run += *seg_start as u32;
6558            }
6559        }
6560        return Ok(clusters);
6561    }
6562
6563    // Multiple fonts needed — shape each segment separately
6564    let mut all_clusters = Vec::new();
6565    for (seg_start, seg_end, font_id) in &segments {
6566        let font = match loaded_fonts.get(font_id) {
6567            Some(f) => f,
6568            None => {
6569                if dbg {
6570                    eprintln!("[FONT FALLBACK] font {:?} NOT loaded, skipping segment bytes {}..{}", font_id, seg_start, seg_end);
6571                }
6572                continue;
6573            }
6574        };
6575        let segment_text = &text[*seg_start..*seg_end];
6576        if dbg {
6577            eprintln!(
6578                "[FONT FALLBACK] text='{}' uses font {:?} (bytes {}..{})",
6579                segment_text, font_id, seg_start, seg_end
6580            );
6581        }
6582        let mut seg_clusters = shape_text_correctly(
6583            segment_text, script, language, direction,
6584            font, style, source_index, source_node_id,
6585        )?;
6586        // Fix byte offsets: shape_text_correctly produces offsets relative to
6587        // segment_text, but callers expect offsets relative to the full text.
6588        if *seg_start > 0 {
6589            for cluster in &mut seg_clusters {
6590                cluster.source_cluster_id.start_byte_in_run += *seg_start as u32;
6591            }
6592        }
6593        all_clusters.extend(seg_clusters);
6594    }
6595    Ok(all_clusters)
6596}
6597
6598pub fn shape_visual_items<T: ParsedFontTrait>(
6599    visual_items: &[VisualItem],
6600    font_chain_cache: &HashMap<FontChainKey, rust_fontconfig::FontFallbackChain>,
6601    fc_cache: &FcFontCache,
6602    loaded_fonts: &LoadedFonts<T>,
6603    debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
6604) -> Result<Vec<ShapedItem>, LayoutError> {
6605    let mut shaped = Vec::new();
6606    let mut idx = 0;
6607    let mut _coalesced_runs = 0usize;
6608    let mut _total_runs = 0usize;
6609    let mut _shape_calls = 0usize;
6610
6611    // Log count of visual items for debugging coalescing
6612
6613    while idx < visual_items.len() {
6614        let item = &visual_items[idx];
6615        match &item.logical_source {
6616            LogicalItem::Text {
6617                style,
6618                source,
6619                marker_position_outside,
6620                source_node_id,
6621                ..
6622            } => {
6623                let layout_hash = style.layout_hash();
6624                let bidi_level = item.bidi_level;
6625                let script = item.script;
6626
6627                // +spec:display-property:ca95f6 - text shaping breaks at inline box boundaries when layout-affecting properties differ
6628                // when layout-affecting properties (font weight, family, size, etc.) change
6629                // across element boundaries, preventing ligatures from forming across such changes.
6630                // Look ahead: find consecutive text items with the same layout-affecting
6631                // properties (font, size, spacing) that can be shaped as one merged run.
6632                let mut coalesce_end = idx + 1;
6633                while coalesce_end < visual_items.len() {
6634                    let next = &visual_items[coalesce_end];
6635                    if let LogicalItem::Text { style: next_style, .. } = &next.logical_source {
6636                        if next_style.layout_hash() == layout_hash
6637                            && next.bidi_level == bidi_level
6638                            && next.script == script
6639                        {
6640                            coalesce_end += 1;
6641                        } else {
6642                            break;
6643                        }
6644                    } else {
6645                        break;
6646                    }
6647                }
6648
6649                let coalesce_count = coalesce_end - idx;
6650
6651                if coalesce_count > 1 {
6652                    _coalesced_runs += coalesce_count;
6653                    _shape_calls += 1;
6654                    // ── COALESCED PATH ──
6655                    // Merge N text items into one shaping call, then split results
6656                    // back per original run to preserve per-span rendering styles.
6657
6658                    // Build merged text and record byte ranges → original style
6659                    let total_text_len: usize = visual_items[idx..coalesce_end]
6660                        .iter()
6661                        .map(|v| v.text.len())
6662                        .sum();
6663                    let mut merged_text = String::with_capacity(total_text_len);
6664                    // (byte_start, byte_end, style, source, source_node_id, marker_outside)
6665                    let mut byte_ranges: Vec<(
6666                        usize, usize,
6667                        Arc<StyleProperties>,
6668                        ContentIndex,
6669                        Option<NodeId>,
6670                        Option<bool>,
6671                    )> = Vec::with_capacity(coalesce_count);
6672
6673                    for j in idx..coalesce_end {
6674                        let start = merged_text.len();
6675                        merged_text.push_str(&visual_items[j].text);
6676                        let end = merged_text.len();
6677                        if let LogicalItem::Text {
6678                            style: s, source: src, source_node_id: nid,
6679                            marker_position_outside: mpo, ..
6680                        } = &visual_items[j].logical_source {
6681                            byte_ranges.push((start, end, s.clone(), *src, *nid, *mpo));
6682                        }
6683                    }
6684
6685                    if let Some(msgs) = debug_messages {
6686                        msgs.push(LayoutDebugMessage::info(format!(
6687                            "[TextLayout] Coalescing {} text runs ({} bytes) into single shaping call",
6688                            coalesce_count, merged_text.len()
6689                        )));
6690                    }
6691
6692                    let direction = if bidi_level.is_rtl() {
6693                        BidiDirection::Rtl
6694                    } else {
6695                        BidiDirection::Ltr
6696                    };
6697                    let language = script_to_language(script, &merged_text);
6698
6699                    // Shape the merged text using the first item's font (layout is identical
6700                    // for all coalesced items since layout_hash matches).
6701                    let shaped_clusters_result: Result<Vec<ShapedCluster>, LayoutError> = match &style.font_stack {
6702                        FontStack::Ref(font_ref) => {
6703                            shape_text_correctly(
6704                                &merged_text, script, language, direction,
6705                                font_ref, style, *source, *source_node_id,
6706                            )
6707                        }
6708                        FontStack::Stack(selectors) => {
6709                            let cache_key = FontChainKey::from_selectors(selectors);
6710                            let font_chain = match font_chain_cache.get(&cache_key) {
6711                                Some(chain) => chain,
6712                                None => { idx = coalesce_end; continue; }
6713                            };
6714                            // Per-character font fallback: split text by font coverage
6715                            shape_with_font_fallback(
6716                                &merged_text, script, language, direction,
6717                                style, *source, *source_node_id,
6718                                font_chain, fc_cache, loaded_fonts,
6719                            )
6720                        }
6721                    };
6722
6723                    let shaped_clusters = shaped_clusters_result?;
6724
6725                    // Restore original per-span styles to each cluster based on byte position.
6726                    // Each ShapedCluster's source_cluster_id.start_byte_in_run is the byte
6727                    // offset within the merged text — we use byte_ranges to find which
6728                    // original run it belongs to and reassign its style, source info, etc.
6729                    for cluster in shaped_clusters {
6730                        let byte_pos = cluster.source_cluster_id.start_byte_in_run as usize;
6731                        // Find the original run this cluster's first byte falls into
6732                        let orig = byte_ranges.iter().find(|(start, end, ..)| {
6733                            byte_pos >= *start && byte_pos < *end
6734                        });
6735                        let mut cluster = cluster;
6736                        if let Some((range_start, _, orig_style, orig_source, orig_nid, orig_mpo)) = orig {
6737                            // Reassign rendering-affecting style (color, background, etc.)
6738                            cluster.style = orig_style.clone();
6739                            cluster.source_content_index = *orig_source;
6740                            cluster.source_node_id = *orig_nid;
6741                            // Fix the byte offset to be relative to the original run
6742                            cluster.source_cluster_id.source_run = orig_source.run_index;
6743                            cluster.source_cluster_id.start_byte_in_run = (byte_pos - range_start) as u32;
6744                            // Update glyph styles
6745                            for glyph in &mut cluster.glyphs {
6746                                glyph.style = orig_style.clone();
6747                            }
6748                            if let Some(is_outside) = orig_mpo {
6749                                cluster.marker_position_outside = Some(*is_outside);
6750                            }
6751                        }
6752                        shaped.push(ShapedItem::Cluster(cluster));
6753                    }
6754
6755                    idx = coalesce_end;
6756                    continue;
6757                }
6758
6759                // ── SINGLE ITEM PATH (no coalescing) ──
6760                _total_runs += 1;
6761                _shape_calls += 1;
6762                let direction = if item.bidi_level.is_rtl() {
6763                    BidiDirection::Rtl
6764                } else {
6765                    BidiDirection::Ltr
6766                };
6767
6768                let language = script_to_language(item.script, &item.text);
6769
6770                // Shape text using either FontRef directly or fontconfig-resolved font
6771                let shaped_clusters_result: Result<Vec<ShapedCluster>, LayoutError> = match &style.font_stack {
6772                    FontStack::Ref(font_ref) => {
6773                        // For FontRef, use the font directly without fontconfig
6774                        if let Some(msgs) = debug_messages {
6775                            msgs.push(LayoutDebugMessage::info(format!(
6776                                "[TextLayout] Using direct FontRef for text: '{}'",
6777                                item.text.chars().take(30).collect::<String>()
6778                            )));
6779                        }
6780                        shape_text_correctly(
6781                            &item.text,
6782                            item.script,
6783                            language,
6784                            direction,
6785                            font_ref,
6786                            style,
6787                            *source,
6788                            *source_node_id,
6789                        )
6790                    }
6791                    FontStack::Stack(selectors) => {
6792                        // Build FontChainKey and resolve through fontconfig
6793                        let cache_key = FontChainKey::from_selectors(selectors);
6794
6795                        // Look up pre-resolved font chain
6796                        let font_chain = match font_chain_cache.get(&cache_key) {
6797                            Some(chain) => chain,
6798                            None => {
6799                                if let Some(msgs) = debug_messages {
6800                                    msgs.push(LayoutDebugMessage::warning(format!(
6801                                        "[TextLayout] Font chain not pre-resolved for {:?} - text will \
6802                                         not be rendered",
6803                                        cache_key.font_families
6804                                    )));
6805                                }
6806                                idx += 1;
6807                                continue;
6808                            }
6809                        };
6810
6811                        // Per-character font fallback: split text by font coverage
6812                        shape_with_font_fallback(
6813                            &item.text, item.script, language, direction,
6814                            style, *source, *source_node_id,
6815                            font_chain, fc_cache, loaded_fonts,
6816                        )
6817                    }
6818                };
6819
6820                let mut shaped_clusters = shaped_clusters_result?;
6821
6822                // Set marker flag on all clusters if this is a marker
6823                if let Some(is_outside) = marker_position_outside {
6824                    for cluster in &mut shaped_clusters {
6825                        cluster.marker_position_outside = Some(*is_outside);
6826                    }
6827                }
6828
6829                shaped.extend(shaped_clusters.into_iter().map(ShapedItem::Cluster));
6830            }
6831            // +spec:display-property:df076b - tab-size rendering and inline-level line breaking
6832            // "If the tab size is zero, preserved tabs are not rendered."
6833            // "Otherwise, each preserved tab is rendered as a horizontal shift that lines up
6834            //  the start edge of the next glyph with the next tab stop."
6835            // "Tab stops occur at points that are multiples of the tab size from the starting
6836            //  content edge of the preserved tab's nearest block container ancestor."
6837            LogicalItem::Tab { source, style } => {
6838                if style.tab_size == 0.0 {
6839                    // Tab size zero: tab is not rendered (zero width)
6840                    shaped.push(ShapedItem::Tab {
6841                        source: *source,
6842                        bounds: Rect {
6843                            x: 0.0,
6844                            y: 0.0,
6845                            width: 0.0,
6846                            height: 0.0,
6847                        },
6848                    });
6849                } else {
6850                    // TODO: use actual font's space_width via ParsedFontTrait::get_space_width()
6851                    // once we thread font resolution into the shaping phase for tab stops.
6852                    // For now, approximate space advance as 0.5 * font_size (typical for Latin fonts).
6853                    let space_advance_approx = style.font_size_px * 0.5;
6854                    // +spec:text-alignment-spacing:5a5efd - tab-size includes letter-spacing and word-spacing
6855                    let ls = match style.letter_spacing {
6856                        Spacing::Px(px) => px as f32,
6857                        Spacing::Em(em) => em * style.font_size_px,
6858                    };
6859                    let ws = match style.word_spacing {
6860                        Spacing::Px(px) => px as f32,
6861                        Spacing::Em(em) => em * style.font_size_px,
6862                    };
6863                    // Tab stop interval: tab_size * (space advance + letter-spacing + word-spacing)
6864                    let tab_interval = style.tab_size * (space_advance_approx + ls + ws);
6865                    // Calculate current advance to find next tab stop
6866                    let current_advance: f32 = shaped.iter().map(|item| {
6867                        match item {
6868                            ShapedItem::Cluster(c) => c.advance,
6869                            ShapedItem::Tab { bounds, .. } => bounds.width,
6870                            ShapedItem::Object { bounds, .. } => bounds.width,
6871                            _ => 0.0,
6872                        }
6873                    }).sum();
6874                    // Next tab stop = next multiple of tab_interval from content edge
6875                    let next_tab_stop = ((current_advance / tab_interval).floor() + 1.0) * tab_interval;
6876                    let mut tab_width = next_tab_stop - current_advance;
6877                    // "If this distance is less than 0.5ch, then the subsequent tab stop is used instead."
6878                    let half_ch = space_advance_approx * 0.5;
6879                    if tab_width < half_ch {
6880                        tab_width += tab_interval;
6881                    }
6882                    shaped.push(ShapedItem::Tab {
6883                        source: *source,
6884                        bounds: Rect {
6885                            x: 0.0,
6886                            y: 0.0,
6887                            width: tab_width,
6888                            height: 0.0,
6889                        },
6890                    });
6891                }
6892            }
6893            LogicalItem::Ruby {
6894                source,
6895                base_text,
6896                ruby_text,
6897                style,
6898            } => {
6899                let placeholder_width = base_text.chars().count() as f32 * style.font_size_px * 0.6;
6900                shaped.push(ShapedItem::Object {
6901                    source: *source,
6902                    bounds: Rect {
6903                        x: 0.0,
6904                        y: 0.0,
6905                        width: placeholder_width,
6906                        height: style.line_height.resolve(style.font_size_px, 0.0, 0.0, 0.0, 0) * 1.5,
6907                    },
6908                    baseline_offset: 0.0,
6909                    content: InlineContent::Text(StyledRun {
6910                        text: base_text.clone(),
6911                        style: style.clone(),
6912                        logical_start_byte: 0,
6913                        source_node_id: None,
6914                    }),
6915                });
6916            }
6917            LogicalItem::CombinedText {
6918                style,
6919                source,
6920                text,
6921            } => {
6922                let language = script_to_language(item.script, &item.text);
6923
6924                // +spec:width-calculation:657f75 - convert full-width chars to non-full-width before compression
6925                // +spec:width-calculation:d0a295 - full-width digit conversion example (e.g. "23" stays narrow)
6926                // When combined text has more than one typographic character unit,
6927                // full-width characters (U+FF01..U+FF5E) are converted to their
6928                // ASCII equivalents (U+0021..U+007E) before compression.
6929                let text = if text.chars().count() > 1 {
6930                    let converted: String = text.chars().map(|c| {
6931                        let cp = c as u32;
6932                        if cp >= 0xFF01 && cp <= 0xFF5E {
6933                            // Reverse of text-transform: full-width
6934                            char::from_u32(cp - 0xFF01 + 0x0021).unwrap_or(c)
6935                        } else {
6936                            c
6937                        }
6938                    }).collect();
6939                    converted
6940                } else {
6941                    text.clone()
6942                };
6943
6944                // +spec:width-calculation:1ed84d - OpenType compression (half-width/third-width substitution)
6945                // is delegated to the font shaping layer via shape_text()
6946
6947                // Shape CombinedText using either FontRef directly or fontconfig-resolved font
6948                let glyphs: Vec<Glyph> = match &style.font_stack {
6949                    FontStack::Ref(font_ref) => {
6950                        // For FontRef, use the font directly without fontconfig
6951                        if let Some(msgs) = debug_messages {
6952                            msgs.push(LayoutDebugMessage::info(format!(
6953                                "[TextLayout] Using direct FontRef for CombinedText: '{}'",
6954                                text.chars().take(30).collect::<String>()
6955                            )));
6956                        }
6957                        font_ref.shape_text(
6958                            &text,
6959                            item.script,
6960                            language,
6961                            BidiDirection::Ltr,
6962                            style.as_ref(),
6963                        )?
6964                    }
6965                    FontStack::Stack(selectors) => {
6966                        // Build FontChainKey and resolve through fontconfig
6967                        let cache_key = FontChainKey::from_selectors(selectors);
6968
6969                        let font_chain = match font_chain_cache.get(&cache_key) {
6970                            Some(chain) => chain,
6971                            None => {
6972                                if let Some(msgs) = debug_messages {
6973                                    msgs.push(LayoutDebugMessage::warning(format!(
6974                                        "[TextLayout] Font chain not pre-resolved for CombinedText {:?}",
6975                                        cache_key.font_families
6976                                    )));
6977                                }
6978                                idx += 1;
6979                                continue;
6980                            }
6981                        };
6982
6983                        // Per-character font fallback for CombinedText
6984                        let segments = split_text_by_font_coverage(&text, font_chain, fc_cache);
6985                        let mut all_glyphs = Vec::new();
6986                        for (seg_start, seg_end, font_id) in &segments {
6987                            let font = match loaded_fonts.get(font_id) {
6988                                Some(f) => f,
6989                                None => continue,
6990                            };
6991                            let segment_text = &text[*seg_start..*seg_end];
6992                            let mut seg_glyphs = font.shape_text(
6993                                segment_text,
6994                                item.script,
6995                                language,
6996                                BidiDirection::Ltr,
6997                                style.as_ref(),
6998                            )?;
6999                            // Fix byte offsets for glyphs
7000                            if *seg_start > 0 {
7001                                for g in &mut seg_glyphs {
7002                                    g.logical_byte_index += *seg_start;
7003                                    g.cluster += *seg_start as u32;
7004                                }
7005                            }
7006                            all_glyphs.extend(seg_glyphs);
7007                        }
7008                        if all_glyphs.is_empty() {
7009                            idx += 1;
7010                            continue;
7011                        }
7012                        all_glyphs
7013                    }
7014                };
7015
7016                let shaped_glyphs: ShapedGlyphVec = glyphs
7017                    .into_iter()
7018                    .map(|g| ShapedGlyph {
7019                        kind: GlyphKind::Character,
7020                        glyph_id: g.glyph_id,
7021                        script: g.script,
7022                        font_hash: g.font_hash,
7023                        font_metrics: g.font_metrics,
7024                        style: g.style,
7025                        cluster_offset: 0,
7026                        advance: g.advance,
7027                        kerning: g.kerning,
7028                        offset: g.offset,
7029                        vertical_advance: g.vertical_advance,
7030                        vertical_offset: g.vertical_bearing,
7031                    })
7032                    .collect();
7033
7034                // +spec:block-formatting-context:dc4549 - text-combine-upright compression: UA may scale composition to match 水 advance height
7035                let total_width: f32 = shaped_glyphs.iter().map(|g| g.advance + g.kerning).sum();
7036                // +spec:inline-formatting-context:8c5969 - text-combine-upright baseline centering
7037                // The composition forms a 1em square. Per spec, its baseline must be
7038                // chosen so the square is centered between the text-over and text-under
7039                // baselines of the parent inline box. We approximate by using font_size
7040                // as the square height and centering it (baseline_offset = em_size / 2).
7041                let em_size = shaped_glyphs.first()
7042                    .map(|g| g.style.font_size_px)
7043                    .unwrap_or(style.font_size_px);
7044                let bounds = Rect {
7045                    x: 0.0,
7046                    y: 0.0,
7047                    width: total_width,
7048                    height: em_size,
7049                };
7050
7051                shaped.push(ShapedItem::CombinedBlock {
7052                    source: *source,
7053                    glyphs: shaped_glyphs,
7054                    bounds,
7055                    baseline_offset: em_size / 2.0,
7056                });
7057            }
7058            LogicalItem::Object {
7059                content, source, ..
7060            } => {
7061                let (bounds, baseline) = measure_inline_object(content)?;
7062                shaped.push(ShapedItem::Object {
7063                    source: *source,
7064                    bounds,
7065                    baseline_offset: baseline,
7066                    content: content.clone(),
7067                });
7068            }
7069            LogicalItem::Break { source, break_info } => {
7070                shaped.push(ShapedItem::Break {
7071                    source: *source,
7072                    break_info: break_info.clone(),
7073                });
7074            }
7075        }
7076        idx += 1;
7077    }
7078
7079    Ok(shaped)
7080}
7081
7082/// Returns true if `c` is a hanging punctuation stop or comma per CSS Text 3 §8.2.1.
7083// +spec:hanging-punctuation - full stop/comma character list per CSS Text 3 §8.2.1
7084fn is_hanging_punctuation_char(c: char) -> bool {
7085    matches!(c,
7086        ','      | // U+002C COMMA
7087        '.'      | // U+002E FULL STOP
7088        '\u{060C}' | // ARABIC COMMA
7089        '\u{06D4}' | // ARABIC FULL STOP
7090        '\u{3001}' | // IDEOGRAPHIC COMMA
7091        '\u{3002}' | // IDEOGRAPHIC FULL STOP
7092        '\u{FF0C}' | // FULLWIDTH COMMA
7093        '\u{FF0E}' | // FULLWIDTH FULL STOP
7094        '\u{FE50}' | // SMALL COMMA
7095        '\u{FE51}' | // SMALL IDEOGRAPHIC COMMA
7096        '\u{FE52}' | // SMALL FULL STOP
7097        '\u{FF61}' | // HALFWIDTH IDEOGRAPHIC FULL STOP
7098        '\u{FF64}'   // HALFWIDTH IDEOGRAPHIC COMMA
7099    )
7100}
7101
7102/// Helper to check if a cluster contains only hanging punctuation.
7103// +spec:box-model:8bbcd1 - non-zero inline-axis borders/padding between hangable glyph and line edge prevent hanging
7104/// +spec:inline-formatting-context:135be2 - hanging punctuation placed outside the line box
7105/// +spec:intrinsic-sizing:407d8b - hanging glyphs not counted in intrinsic size computation
7106fn is_hanging_punctuation(item: &ShapedItem) -> bool {
7107    if let ShapedItem::Cluster(c) = item {
7108        if c.glyphs.len() == 1 {
7109            c.text.chars().next().map_or(false, is_hanging_punctuation_char)
7110        } else {
7111            false
7112        }
7113    } else {
7114        false
7115    }
7116}
7117
7118fn shape_text_correctly<T: ParsedFontTrait>(
7119    text: &str,
7120    script: Script,
7121    language: crate::text3::script::Language,
7122    direction: BidiDirection,
7123    font: &T, // Changed from &Arc<T>
7124    style: &Arc<StyleProperties>,
7125    source_index: ContentIndex,
7126    source_node_id: Option<NodeId>,
7127) -> Result<Vec<ShapedCluster>, LayoutError> {
7128    let glyphs = font.shape_text(text, script, language, direction, style.as_ref())?;
7129
7130    if glyphs.is_empty() {
7131        return Ok(Vec::new());
7132    }
7133
7134    let mut clusters = Vec::new();
7135
7136    // Group glyphs by cluster ID from the shaper.
7137    let mut current_cluster_glyphs = Vec::new();
7138    let mut cluster_id = glyphs[0].cluster;
7139    let mut cluster_start_byte_in_text = glyphs[0].logical_byte_index;
7140
7141    for glyph in glyphs {
7142        if glyph.cluster != cluster_id {
7143            // Finalize previous cluster
7144            let advance = current_cluster_glyphs
7145                .iter()
7146                .map(|g: &Glyph| g.advance)
7147                .sum();
7148
7149            // Safely extract cluster text - handle cases where byte indices may be out of order
7150            // (can happen with RTL text or complex GSUB reordering)
7151            let (start, end) = if cluster_start_byte_in_text <= glyph.logical_byte_index {
7152                (cluster_start_byte_in_text, glyph.logical_byte_index)
7153            } else {
7154                (glyph.logical_byte_index, cluster_start_byte_in_text)
7155            };
7156            let cluster_text = text.get(start..end).unwrap_or("");
7157
7158            clusters.push(ShapedCluster {
7159                text: cluster_text.to_string(), // Store original text for hyphenation
7160                source_cluster_id: GraphemeClusterId {
7161                    source_run: source_index.run_index,
7162                    start_byte_in_run: cluster_id,
7163                },
7164                source_content_index: source_index,
7165                source_node_id,
7166                glyphs: current_cluster_glyphs
7167                    .iter()
7168                    .map(|g| {
7169                        // Calculate cluster_offset safely
7170                        let cluster_offset = if g.logical_byte_index >= cluster_start_byte_in_text {
7171                            (g.logical_byte_index - cluster_start_byte_in_text) as u32
7172                        } else {
7173                            0
7174                        };
7175                        ShapedGlyph {
7176                            kind: if g.glyph_id == 0 {
7177                                GlyphKind::NotDef
7178                            } else {
7179                                GlyphKind::Character
7180                            },
7181                            glyph_id: g.glyph_id,
7182                            script: g.script,
7183                            font_hash: g.font_hash,
7184                            font_metrics: g.font_metrics.clone(),
7185                            style: g.style.clone(),
7186                            cluster_offset,
7187                            advance: g.advance,
7188                            kerning: g.kerning,
7189                            vertical_advance: g.vertical_advance,
7190                            vertical_offset: g.vertical_bearing,
7191                            offset: g.offset,
7192                        }
7193                    })
7194                    .collect(),
7195                advance,
7196                direction,
7197                style: style.clone(),
7198                marker_position_outside: None,
7199                is_first_fragment: true,
7200                is_last_fragment: true,
7201            });
7202            current_cluster_glyphs.clear();
7203            cluster_id = glyph.cluster;
7204            cluster_start_byte_in_text = glyph.logical_byte_index;
7205        }
7206        current_cluster_glyphs.push(glyph);
7207    }
7208
7209    // Finalize the last cluster
7210    if !current_cluster_glyphs.is_empty() {
7211        let advance = current_cluster_glyphs
7212            .iter()
7213            .map(|g: &Glyph| g.advance)
7214            .sum();
7215        let cluster_text = text.get(cluster_start_byte_in_text..).unwrap_or("");
7216        clusters.push(ShapedCluster {
7217            text: cluster_text.to_string(), // Store original text
7218            source_cluster_id: GraphemeClusterId {
7219                source_run: source_index.run_index,
7220                start_byte_in_run: cluster_id,
7221            },
7222            source_content_index: source_index,
7223            source_node_id,
7224            glyphs: current_cluster_glyphs
7225                .iter()
7226                .map(|g| {
7227                    // Calculate cluster_offset safely
7228                    let cluster_offset = if g.logical_byte_index >= cluster_start_byte_in_text {
7229                        (g.logical_byte_index - cluster_start_byte_in_text) as u32
7230                    } else {
7231                        0
7232                    };
7233                    ShapedGlyph {
7234                        kind: if g.glyph_id == 0 {
7235                            GlyphKind::NotDef
7236                        } else {
7237                            GlyphKind::Character
7238                        },
7239                        glyph_id: g.glyph_id,
7240                        font_hash: g.font_hash,
7241                        font_metrics: g.font_metrics.clone(),
7242                        style: g.style.clone(),
7243                        script: g.script,
7244                        vertical_advance: g.vertical_advance,
7245                        vertical_offset: g.vertical_bearing,
7246                        cluster_offset,
7247                        advance: g.advance,
7248                        kerning: g.kerning,
7249                        offset: g.offset,
7250                    }
7251                })
7252                .collect(),
7253            advance,
7254            direction,
7255            style: style.clone(),
7256            marker_position_outside: None,
7257            is_first_fragment: true,
7258            is_last_fragment: true,
7259        });
7260    }
7261
7262    Ok(clusters)
7263}
7264
7265/// Measures a non-text object, returning its bounds and baseline offset.
7266fn measure_inline_object(item: &InlineContent) -> Result<(Rect, f32), LayoutError> {
7267    match item {
7268        InlineContent::Image(img) => {
7269            let size = img.display_size.unwrap_or(img.intrinsic_size);
7270            Ok((
7271                Rect {
7272                    x: 0.0,
7273                    y: 0.0,
7274                    width: size.width,
7275                    height: size.height,
7276                },
7277                img.baseline_offset,
7278            ))
7279        }
7280        InlineContent::Shape(shape) => Ok({
7281            let size = shape.shape_def.get_size();
7282            (
7283                Rect {
7284                    x: 0.0,
7285                    y: 0.0,
7286                    width: size.width,
7287                    height: size.height,
7288                },
7289                shape.baseline_offset,
7290            )
7291        }),
7292        InlineContent::Space(space) => Ok((
7293            Rect {
7294                x: 0.0,
7295                y: 0.0,
7296                width: space.width,
7297                height: 0.0,
7298            },
7299            0.0,
7300        )),
7301        InlineContent::Marker { .. } => {
7302            // Markers are treated as text content, not measurable objects
7303            Err(LayoutError::InvalidText(
7304                "Marker is text content, not a measurable object".into(),
7305            ))
7306        }
7307        _ => Err(LayoutError::InvalidText("Not a measurable object".into())),
7308    }
7309}
7310
7311// --- Stage 4 Implementation: Vertical Text ---
7312
7313/// Applies orientation and vertical metrics to glyphs if the writing mode is vertical.
7314// +spec:block-formatting-context:227171 - vertical glyph orientation with fallback vertical metrics
7315// +spec:block-formatting-context:df20a5 - mixed vertical orientation dispatch (TextOrientation::Mixed)
7316fn apply_text_orientation(
7317    items: Arc<Vec<ShapedItem>>,
7318    constraints: &UnifiedConstraints,
7319) -> Result<Arc<Vec<ShapedItem>>, LayoutError> {
7320    if !constraints.is_vertical() {
7321        return Ok(items);
7322    }
7323
7324    let mut oriented_items = Vec::with_capacity(items.len());
7325    let writing_mode = constraints.writing_mode.unwrap_or_default();
7326
7327    for item in items.iter() {
7328        match item {
7329            ShapedItem::Cluster(cluster) => {
7330                let mut new_cluster = cluster.clone();
7331                let mut total_vertical_advance = 0.0;
7332
7333                for glyph in &mut new_cluster.glyphs {
7334                    // Use the vertical metrics already computed during shaping
7335                    // If they're zero, use fallback values
7336                    if glyph.vertical_advance > 0.0 {
7337                        total_vertical_advance += glyph.vertical_advance;
7338                    } else {
7339                        // Fallback: use line height for vertical advance
7340                        let fallback_advance = cluster.style.line_height.resolve_with_metrics(cluster.style.font_size_px, &glyph.font_metrics);
7341                        glyph.vertical_advance = fallback_advance;
7342                        // Center the glyph horizontally as a fallback
7343                        glyph.vertical_offset = Point {
7344                            x: -glyph.advance / 2.0,
7345                            y: 0.0,
7346                        };
7347                        total_vertical_advance += fallback_advance;
7348                    }
7349                }
7350                // The cluster's `advance` now represents vertical advance.
7351                new_cluster.advance = total_vertical_advance;
7352                oriented_items.push(ShapedItem::Cluster(new_cluster));
7353            }
7354            // Non-text objects also need their advance axis swapped.
7355            ShapedItem::Object {
7356                source,
7357                bounds,
7358                baseline_offset,
7359                content,
7360            } => {
7361                let mut new_bounds = *bounds;
7362                std::mem::swap(&mut new_bounds.width, &mut new_bounds.height);
7363                oriented_items.push(ShapedItem::Object {
7364                    source: *source,
7365                    bounds: new_bounds,
7366                    baseline_offset: *baseline_offset,
7367                    content: content.clone(),
7368                });
7369            }
7370            _ => oriented_items.push(item.clone()),
7371        }
7372    }
7373
7374    Ok(Arc::new(oriented_items))
7375}
7376
7377// --- Stage 5 & 6 Implementation: Combined Layout Pass ---
7378// This section replaces the previous simple line breaking and positioning logic.
7379
7380/// Extracts the per-item vertical-align from a ShapedItem.
7381///
7382/// For `Object` items (inline-blocks, images), this returns the alignment stored
7383/// in the original `InlineContent`. For text clusters and other items, returns `None`
7384/// to indicate the global `constraints.vertical_align` should be used.
7385fn get_item_vertical_align(item: &ShapedItem) -> Option<VerticalAlign> {
7386    match item {
7387        ShapedItem::Object { content, .. } => match content {
7388            InlineContent::Image(img) => Some(img.alignment),
7389            InlineContent::Shape(shape) => Some(shape.alignment),
7390            _ => None,
7391        },
7392        _ => None,
7393    }
7394}
7395
7396/// Approximate version of get_item_vertical_metrics for use without constraints (e.g. bounds()).
7397/// Uses 80/20 ascent/descent ratio as fallback for empty-glyph strut case.
7398pub fn get_item_vertical_metrics_approx(item: &ShapedItem) -> (f32, f32) {
7399    // For non-empty clusters, delegate to the font-metrics-based calculation
7400    if let ShapedItem::Cluster(c) = item {
7401        if !c.glyphs.is_empty() {
7402            // Reuse the glyph-based calculation (same as get_item_vertical_metrics)
7403            let (asc, desc) = c.glyphs
7404                .iter()
7405                .fold((0.0f32, 0.0f32), |(max_asc, max_desc), glyph| {
7406                    let metrics = &glyph.font_metrics;
7407                    if metrics.units_per_em == 0 {
7408                        return (max_asc, max_desc);
7409                    }
7410                    let scale = glyph.style.font_size_px / metrics.units_per_em as f32;
7411                    let font_ascent = metrics.ascent * scale;
7412                    let font_descent = (-metrics.descent * scale).max(0.0);
7413                    let ad = font_ascent + font_descent;
7414                    let resolved_lh = c.style.line_height.resolve_with_metrics(glyph.style.font_size_px, &glyph.font_metrics);
7415                    let half_leading = (resolved_lh - ad) / 2.0;
7416                    (max_asc.max(font_ascent + half_leading), max_desc.max(font_descent + half_leading))
7417                });
7418            return (asc, desc);
7419        }
7420    }
7421    // Fallback for empty glyphs or non-cluster items
7422    match item {
7423        ShapedItem::Cluster(c) => {
7424            let lh = c.style.line_height.resolve(c.style.font_size_px, 0.0, 0.0, 0.0, 0);
7425            (lh * 0.8, lh * 0.2)
7426        }
7427        ShapedItem::CombinedBlock { bounds, .. } => (bounds.height * 0.8, bounds.height * 0.2),
7428        ShapedItem::Object { bounds, .. } => (bounds.height, 0.0),
7429        ShapedItem::Tab { bounds, .. } => (bounds.height * 0.8, bounds.height * 0.2),
7430        ShapedItem::Break { .. } => (0.0, 0.0),
7431    }
7432}
7433
7434/// Gets the ascent (distance from baseline to top) and descent (distance from baseline to bottom)
7435/// for a single item, incorporating half-leading from line-height.
7436// +spec:box-model:37aeb2 - inline box margins/borders/padding do not affect line box height (leading model)
7437// +spec:display-property:184f0d - Inline box baseline derives from first available font metrics
7438// +spec:display-property:238bf5 - Inline box layout bounds from own text metrics, not child boxes
7439// +spec:display-property:29b194 - baseline determination for inline boxes (CSS Box Alignment 3 §9.1)
7440// +spec:display-property:2987db - per-glyph font metrics impact inline box layout bounds (line-height: normal caveat not yet distinguished)
7441/// +spec:display-property:fd42a9 - line-height affects line box contribution, not inline box size
7442// +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
7443// +spec:font-metrics:773029 - ascent/descent font metrics used for baseline calculations (visual centering depends on these)
7444// +spec:font-metrics:f42870 - half-leading model: leading = line-height - (ascent + descent), distributed equally above/below
7445// +spec:writing-modes:531c2e - UAs should use vertical baseline tables in vertical typographic modes
7446pub fn get_item_vertical_metrics(item: &ShapedItem, constraints: &UnifiedConstraints) -> (f32, f32) {
7447    // (ascent, descent)
7448    match item {
7449        ShapedItem::Cluster(c) => {
7450            if c.glyphs.is_empty() {
7451                // +spec:display-property:626c86 - strut for inline box with no glyphs uses first available font metrics
7452                // +spec:line-height:0078fa - strut: zero-width inline box with element's font/line-height
7453                // §10.8.1 strut: if inline box contains no glyphs, it is considered to
7454                // contain a strut with A and D of the element's first available font.
7455                // Half-leading: L = line-height - (A + D), A' = A + L/2, D' = D + L/2
7456                let ad = constraints.strut_ascent + constraints.strut_descent;
7457                let resolved_lh = c.style.line_height.resolve(c.style.font_size_px, 0.0, 0.0, 0.0, 0);
7458                let half_leading = (resolved_lh - ad) / 2.0;
7459                return (constraints.strut_ascent + half_leading, constraints.strut_descent + half_leading);
7460            }
7461            // +spec:box-model:0b3e1f - inline non-replaced box height uses only line-height, not vertical padding/border/margin
7462            // +spec:display-property:80b900 - fallback glyphs affect line box size via per-glyph metrics
7463            // +spec:display-property:d52f26 - layout bounds enclose all glyphs from highest A to deepest D
7464            // +spec:font-metrics:387751 - content area uses max ascenders/descenders across all fonts
7465            // +spec:font-metrics:790fd2 - half-leading: L = line-height - (A+D), A' = A + L/2, D' = D + L/2
7466            // +spec:line-height:1ae6f5 - line-height on non-replaced inline: half-leading model
7467            // +spec:line-height:0078fa - half-leading: L = line-height - (A+D), distributed equally above/below
7468            // +spec:line-height:32b3da - half-leading: L = line-height - AD, A' = A + L/2, D' = D + L/2
7469            // §10.8.1: for each glyph determine A, D from font metrics,
7470            // then L = line-height - (A + D), and adjust: A' = A + L/2, D' = D + L/2.
7471            // Note: L may be negative.
7472            // +spec:height-calculation:eb98b5 - multi-font normal line-height uses max across glyph metrics
7473            c.glyphs
7474                .iter()
7475                .fold((0.0f32, 0.0f32), |(max_asc, max_desc), glyph| {
7476                    let metrics = &glyph.font_metrics;
7477                    if metrics.units_per_em == 0 {
7478                        return (max_asc, max_desc);
7479                    }
7480                    let scale = glyph.style.font_size_px / metrics.units_per_em as f32;
7481                    let a = metrics.ascent * scale;
7482                    // Descent in OpenType is typically negative, so we negate it to get a positive
7483                    // distance.
7484                    let d = (-metrics.descent * scale).max(0.0);
7485                    let ad = a + d;
7486                    let resolved_lh = glyph.style.line_height.resolve_with_metrics(glyph.style.font_size_px, &glyph.font_metrics);
7487                    let leading = resolved_lh - ad;
7488                    let half_leading = leading / 2.0;
7489                    let item_asc = a + half_leading;
7490                    let item_desc = d + half_leading;
7491                    (max_asc.max(item_asc), max_desc.max(item_desc))
7492                })
7493        }
7494        ShapedItem::Object {
7495            bounds,
7496            baseline_offset,
7497            ..
7498        } => {
7499            // Per analysis, `baseline_offset` is the distance from the bottom.
7500            // bounds.height already includes margins (set from margin_box_height in fc.rs)
7501            let ascent = bounds.height - *baseline_offset;
7502            let descent = *baseline_offset;
7503            (ascent.max(0.0), descent.max(0.0))
7504        }
7505        ShapedItem::CombinedBlock {
7506            bounds,
7507            baseline_offset,
7508            ..
7509        } => {
7510            // CORRECTED: Treat baseline_offset consistently as distance from the bottom (descent).
7511            let ascent = bounds.height - *baseline_offset;
7512            let descent = *baseline_offset;
7513            (ascent.max(0.0), descent.max(0.0))
7514        }
7515        _ => (0.0, 0.0), // Breaks and other non-visible items don't affect line height.
7516    }
7517}
7518
7519// +spec:block-formatting-context:861155 - vertical-align affects vertical positioning inside line box for inline-level elements
7520/// Calculates the maximum ascent and descent for an entire line of items.
7521/// This determines the "line box" used for vertical alignment.
7522/// // +spec:display-contents:66d910 - line box height fitted to contents, controlled by line-height
7523// +spec:inline-formatting-context:c3fc54 - line box tall enough for all boxes, vertical-align determines alignment within line box
7524///
7525/// Per CSS 2.2 §10.8: Inline-level boxes aligned 'top' or 'bottom' must be aligned
7526/// so as to minimize the line box height. The algorithm is:
7527/// 1. First pass: compute line box height from baseline-aligned items only
7528///    (baseline, sub, super, middle, text-top, text-bottom, offset).
7529/// 2. Second pass: check if any top/bottom-aligned items are taller than the
7530///    line box from pass 1, and expand if necessary.
7531// +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)
7532fn calculate_line_metrics(
7533    items: &[ShapedItem],
7534    default_vertical_align: VerticalAlign,
7535    constraints: &UnifiedConstraints,
7536) -> (f32, f32) {
7537    // +spec:font-metrics:95152b - baseline alignment: items with different font sizes aligned by matching alphabetic baselines
7538    // Pass 1: Compute ascent/descent from baseline-aligned items only
7539    // (i.e., items that are NOT vertical-align: top or bottom).
7540    let (mut max_asc, mut max_desc) = items
7541        .iter()
7542        .fold((0.0f32, 0.0f32), |(max_asc, max_desc), item| {
7543            let effective_align = get_item_vertical_align(item)
7544                .unwrap_or(default_vertical_align);
7545            match effective_align {
7546                VerticalAlign::Top | VerticalAlign::Bottom => {
7547                    // Skip top/bottom items in first pass
7548                    (max_asc, max_desc)
7549                }
7550                _ => {
7551                    let (item_asc, item_desc) = get_item_vertical_metrics(item, constraints);
7552                    (max_asc.max(item_asc), max_desc.max(item_desc))
7553                }
7554            }
7555        });
7556
7557    let baseline_line_height = max_asc + max_desc;
7558
7559    // Pass 2: Check top/bottom aligned items. If any of them is taller
7560    // than the current line box, expand the line box to fit.
7561    for item in items {
7562        let effective_align = get_item_vertical_align(item)
7563            .unwrap_or(default_vertical_align);
7564        match effective_align {
7565            VerticalAlign::Top | VerticalAlign::Bottom => {
7566                let (item_asc, item_desc) = get_item_vertical_metrics(item, constraints);
7567                let item_height = item_asc + item_desc;
7568                if item_height > baseline_line_height {
7569                    // To minimize height, expand in the direction the item is aligned to
7570                    if effective_align == VerticalAlign::Top {
7571                        // Top-aligned item extends downward from line top
7572                        max_desc = max_desc.max(item_height - max_asc);
7573                    } else {
7574                        // Bottom-aligned item extends upward from line bottom
7575                        max_asc = max_asc.max(item_height - max_desc);
7576                    }
7577                }
7578            }
7579            _ => {} // Already handled in first pass
7580        }
7581    }
7582
7583    (max_asc, max_desc)
7584}
7585
7586/// Performs layout for a single fragment, consuming items from a `BreakCursor`.
7587///
7588/// This function contains the core line-breaking and positioning logic, but is
7589/// designed to operate on a portion of a larger content stream and within the
7590/// constraints of a single geometric area (a fragment).
7591///
7592/// The loop terminates when either the fragment is filled (e.g., runs out of
7593/// vertical space) or the content stream managed by the `cursor` is exhausted.
7594///
7595/// # CSS Inline Layout Module Level 3 Implementation
7596///
7597/// This function implements the inline formatting context as described in:
7598/// https://www.w3.org/TR/css-inline-3/#inline-formatting-context
7599///
7600/// ## § 2.1 Layout of Line Boxes
7601/// "In general, the line-left edge of a line box touches the line-left edge of its
7602/// containing block and the line-right edge touches the line-right edge of its
7603/// containing block, and thus the logical width of a line box is equal to the inner
7604/// logical width of its containing block."
7605///
7606/// [ISSUE] available_width should be set to the containing block's inner width,
7607/// but is currently defaulting to 0.0 in UnifiedConstraints::default().
7608/// This causes premature line breaking.
7609///
7610/// ## § 2.2 Layout Within Line Boxes
7611/// The layout process follows these steps:
7612/// 1. Baseline Alignment: All inline-level boxes are aligned by their baselines
7613/// 2. Content Size Contribution: Calculate layout bounds for each box
7614/// 3. Line Box Sizing: Size line box to fit aligned layout bounds
7615/// 4. Content Positioning: Position boxes within the line box
7616///
7617/// ## Missing Features:
7618/// - § 3 Baselines and Alignment Metrics: Only basic baseline alignment implemented
7619/// - § 4 Baseline Alignment: vertical-align property not fully supported
7620/// - § 5 Line Spacing: line-height implemented, but line-fit-edge missing
7621/// - § 6 Trimming Leading: text-box-trim not implemented
7622pub fn perform_fragment_layout<T: ParsedFontTrait>(
7623    cursor: &mut BreakCursor,
7624    logical_items: &[LogicalItem],
7625    fragment_constraints: &UnifiedConstraints,
7626    debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
7627    fonts: &LoadedFonts<T>,
7628) -> Result<UnifiedLayout, LayoutError> {
7629    if let Some(msgs) = debug_messages {
7630        msgs.push(LayoutDebugMessage::info(
7631            "\n--- Entering perform_fragment_layout ---".to_string(),
7632        ));
7633        msgs.push(LayoutDebugMessage::info(format!(
7634            "Constraints: available_width={:?}, available_height={:?}, columns={}, text_wrap={:?}",
7635            fragment_constraints.available_width,
7636            fragment_constraints.available_height,
7637            fragment_constraints.columns,
7638            fragment_constraints.text_wrap
7639        )));
7640    }
7641
7642    // For TextWrap::Balance, use Knuth-Plass algorithm for optimal line breaking
7643    // This produces more visually balanced lines at the cost of more computation
7644    if fragment_constraints.text_wrap == TextWrap::Balance {
7645        if let Some(msgs) = debug_messages {
7646            msgs.push(LayoutDebugMessage::info(
7647                "Using Knuth-Plass algorithm for text-wrap: balance".to_string(),
7648            ));
7649        }
7650
7651        // Get the shaped items from the cursor
7652        let shaped_items: Vec<ShapedItem> = cursor.drain_remaining();
7653
7654        // +spec:line-breaking:90c1bd - only auto-hyphenate when language is known and hyphenation resource available
7655        let hyphenator = if fragment_constraints.hyphenation == Hyphens::Auto {
7656            fragment_constraints
7657                .hyphenation_language
7658                .and_then(|lang| get_hyphenator(lang).ok())
7659        } else {
7660            None
7661        };
7662
7663        // Use the Knuth-Plass algorithm for optimal line breaking
7664        return crate::text3::knuth_plass::kp_layout(
7665            &shaped_items,
7666            logical_items,
7667            fragment_constraints,
7668            hyphenator.as_ref(),
7669            fonts,
7670        );
7671    }
7672
7673    // +spec:intrinsic-sizing:57e02d - hyphenation opportunities considered in min-content sizing
7674    let hyphenator = if fragment_constraints.hyphenation == Hyphens::Auto {
7675        fragment_constraints
7676            .hyphenation_language
7677            .and_then(|lang| get_hyphenator(lang).ok())
7678    } else {
7679        None
7680    };
7681
7682    let mut positioned_items = Vec::new();
7683    let mut layout_bounds = Rect::default();
7684
7685    let num_columns = fragment_constraints.columns.max(1);
7686    let total_column_gap = fragment_constraints.column_gap * (num_columns - 1) as f32;
7687
7688    // CSS Inline Layout § 2.1: "the logical width of a line box is equal to the inner
7689    // logical width of its containing block"
7690    //
7691    // Handle the different available space modes:
7692    // - Definite(width): Use the specified width for column calculation
7693    // - MinContent: Force line breaks at word boundaries, return widest word width
7694    // - MaxContent: Use a large value to allow content to expand naturally
7695    //
7696    // IMPORTANT: For MinContent, we do NOT use 0.0 (which would break after every character).
7697    // Instead, we use a large width but track the is_min_content flag to force word-level
7698    // line breaks in the line breaker. The actual min-content width is the width of the
7699    // widest resulting line (typically the widest word).
7700    let is_min_content = matches!(fragment_constraints.available_width, AvailableSpace::MinContent);
7701    let is_max_content = matches!(fragment_constraints.available_width, AvailableSpace::MaxContent);
7702    
7703    let column_width = match fragment_constraints.available_width {
7704        AvailableSpace::Definite(width) => (width - total_column_gap) / num_columns as f32,
7705        AvailableSpace::MinContent | AvailableSpace::MaxContent => {
7706            // For intrinsic sizing, use a large width to measure actual content width.
7707            // The line breaker will handle MinContent specially by breaking after each word.
7708            f32::MAX / 2.0
7709        }
7710    };
7711    let mut current_column = 0;
7712    if let Some(msgs) = debug_messages {
7713        msgs.push(LayoutDebugMessage::info(format!(
7714            "Column width calculated: {}",
7715            column_width
7716        )));
7717    }
7718
7719    // Use the CSS direction from constraints instead of auto-detecting from text
7720    // This ensures that mixed-direction text (e.g., "مرحبا - Hello") uses the
7721    // correct paragraph-level direction for alignment purposes.
7722    // With unicode-bidi: plaintext, direction is auto-detected from text content
7723    // per CSS Writing Modes §8.3.
7724    let base_direction = if fragment_constraints.unicode_bidi == UnicodeBidi::Plaintext {
7725        // Auto-detect from remaining shaped items' text content
7726        let remaining = &cursor.items[cursor.next_item_index..];
7727        let text: String = remaining.iter()
7728            .filter_map(|i| i.as_cluster())
7729            .map(|c| c.text.as_str())
7730            .collect();
7731        match unicode_bidi::get_base_direction(text.as_str()) {
7732            unicode_bidi::Direction::Ltr => BidiDirection::Ltr,
7733            unicode_bidi::Direction::Rtl => BidiDirection::Rtl,
7734            // No strong character: fall back to containing block direction
7735            unicode_bidi::Direction::Mixed => fragment_constraints.direction.unwrap_or(BidiDirection::Ltr),
7736        }
7737    } else {
7738        fragment_constraints.direction.unwrap_or(BidiDirection::Ltr)
7739    };
7740
7741    if let Some(msgs) = debug_messages {
7742        msgs.push(LayoutDebugMessage::info(format!(
7743            "[PFLayout] Base direction: {:?} (from CSS), Text align: {:?}",
7744            base_direction, fragment_constraints.text_align
7745        )));
7746    }
7747
7748    'column_loop: while current_column < num_columns {
7749        if let Some(msgs) = debug_messages {
7750            msgs.push(LayoutDebugMessage::info(format!(
7751                "\n-- Starting Column {} --",
7752                current_column
7753            )));
7754        }
7755        let column_start_x =
7756            (column_width + fragment_constraints.column_gap) * current_column as f32;
7757        let mut line_top_y = 0.0;
7758        let mut line_index = 0;
7759        let mut empty_segment_count = 0; // Failsafe counter for infinite loops
7760        let mut is_after_forced_break = false;
7761        const MAX_EMPTY_SEGMENTS: usize = 1000; // Maximum allowed consecutive empty segments
7762
7763        while !cursor.is_done() {
7764            if let Some(max_height) = fragment_constraints.available_height {
7765                if line_top_y >= max_height {
7766                    if let Some(msgs) = debug_messages {
7767                        msgs.push(LayoutDebugMessage::info(format!(
7768                            "  Column full (pen {} >= height {}), breaking to next column.",
7769                            line_top_y, max_height
7770                        )));
7771                    }
7772                    break;
7773                }
7774            }
7775
7776            if let Some(clamp) = fragment_constraints.line_clamp {
7777                if line_index >= clamp.get() {
7778                    break;
7779                }
7780            }
7781
7782            // Create constraints specific to the current column for the line breaker.
7783            let mut column_constraints = fragment_constraints.clone();
7784            // For MinContent/MaxContent, preserve the semantic type so the line breaker
7785            // can handle word-level breaking correctly. Only use Definite for actual widths.
7786            if is_min_content {
7787                column_constraints.available_width = AvailableSpace::MinContent;
7788            } else if is_max_content {
7789                column_constraints.available_width = AvailableSpace::MaxContent;
7790            } else {
7791                column_constraints.available_width = AvailableSpace::Definite(column_width);
7792            }
7793            let line_constraints = get_line_constraints(
7794                line_top_y,
7795                fragment_constraints.resolved_line_height(),
7796                &column_constraints,
7797                debug_messages,
7798            );
7799
7800            if line_constraints.segments.is_empty() {
7801                empty_segment_count += 1;
7802                if let Some(msgs) = debug_messages {
7803                    msgs.push(LayoutDebugMessage::info(format!(
7804                        "  No available segments at y={}, skipping to next line. (empty count: \
7805                         {}/{})",
7806                        line_top_y, empty_segment_count, MAX_EMPTY_SEGMENTS
7807                    )));
7808                }
7809
7810                // Failsafe: If we've skipped too many lines without content, break out
7811                if empty_segment_count >= MAX_EMPTY_SEGMENTS {
7812                    if let Some(msgs) = debug_messages {
7813                        msgs.push(LayoutDebugMessage::warning(format!(
7814                            "  [WARN] Reached maximum empty segment count ({}). Breaking to \
7815                             prevent infinite loop.",
7816                            MAX_EMPTY_SEGMENTS
7817                        )));
7818                        msgs.push(LayoutDebugMessage::warning(
7819                            "  This likely means the shape constraints are too restrictive or \
7820                             positioned incorrectly."
7821                                .to_string(),
7822                        ));
7823                        msgs.push(LayoutDebugMessage::warning(format!(
7824                            "  Current y={}, shape boundaries might be outside this range.",
7825                            line_top_y
7826                        )));
7827                    }
7828                    break;
7829                }
7830
7831                // Additional check: If we have shapes and are far beyond the expected height,
7832                // also break to avoid infinite loops
7833                if !fragment_constraints.shape_boundaries.is_empty() && empty_segment_count > 50 {
7834                    // Calculate maximum shape height
7835                    let max_shape_y: f32 = fragment_constraints
7836                        .shape_boundaries
7837                        .iter()
7838                        .map(|shape| {
7839                            match shape {
7840                                ShapeBoundary::Circle { center, radius } => center.y + radius,
7841                                ShapeBoundary::Ellipse { center, radii } => center.y + radii.height,
7842                                ShapeBoundary::Polygon { points } => {
7843                                    points.iter().map(|p| p.y).fold(0.0, f32::max)
7844                                }
7845                                ShapeBoundary::Rectangle(rect) => rect.y + rect.height,
7846                                ShapeBoundary::Path { .. } => f32::MAX, // Can't determine for path
7847                            }
7848                        })
7849                        .fold(0.0, f32::max);
7850
7851                    if line_top_y > max_shape_y + 100.0 {
7852                        if let Some(msgs) = debug_messages {
7853                            msgs.push(LayoutDebugMessage::info(format!(
7854                                "  [INFO] Current y={} is far beyond maximum shape extent y={}. \
7855                                 Breaking layout.",
7856                                line_top_y, max_shape_y
7857                            )));
7858                            msgs.push(LayoutDebugMessage::info(
7859                                "  Shape boundaries exist but no segments available - text cannot \
7860                                 fit in shape."
7861                                    .to_string(),
7862                            ));
7863                        }
7864                        break;
7865                    }
7866                }
7867
7868                line_top_y += fragment_constraints.resolved_line_height();
7869                continue;
7870            }
7871
7872            // Reset counter when we find valid segments
7873            empty_segment_count = 0;
7874
7875            // +spec:line-breaking:3bb032 - break-word not considered for min-content intrinsic sizes
7876            // +spec:overflow:b932c4 - overflow-wrap/word-wrap (normal/break-word/anywhere) and hyphens interaction
7877            // `anywhere` introduces soft wrap opportunities (min-content = widest cluster),
7878            // but `break-word` does NOT (min-content = widest unbreakable word).
7879            let effective_overflow_wrap = if is_min_content && fragment_constraints.overflow_wrap == OverflowWrap::Anywhere {
7880                OverflowWrap::Anywhere
7881            } else if is_min_content && fragment_constraints.overflow_wrap == OverflowWrap::BreakWord {
7882                OverflowWrap::Normal
7883            } else {
7884                fragment_constraints.overflow_wrap
7885            };
7886
7887            // CSS Text Module Level 3 § 5 Line Breaking and Word Boundaries
7888            // https://www.w3.org/TR/css-text-3/#line-breaking
7889            // +spec:display-property:2608cc - inline box splitting across line boxes, overflow for unsplittable boxes
7890            // +spec:display-property:ea615c - inline boxes split and distributed across line boxes
7891            // "When an inline box exceeds the logical width of a line box, it is split
7892            // into several fragments, which are partitioned across multiple line boxes."
7893            let (mut line_items, was_hyphenated) =
7894                break_one_line(cursor, &line_constraints, false, hyphenator.as_ref(), fonts, fragment_constraints.line_break, fragment_constraints.white_space_mode, effective_overflow_wrap);
7895            if line_items.is_empty() {
7896                if let Some(msgs) = debug_messages {
7897                    msgs.push(LayoutDebugMessage::info(
7898                        "  Break returned no items. Ending column.".to_string(),
7899                    ));
7900                }
7901                break;
7902            }
7903
7904            let line_text_before_rev: String = line_items
7905                .iter()
7906                .filter_map(|i| i.as_cluster())
7907                .map(|c| c.text.as_str())
7908                .collect();
7909            if let Some(msgs) = debug_messages {
7910                msgs.push(LayoutDebugMessage::info(format!(
7911                    // FIX: The log message was misleading. Items are in visual order.
7912                    "[PFLayout] Line items from breaker (visual order): [{}]",
7913                    line_text_before_rev
7914                )));
7915            }
7916
7917            // +spec:line-breaking:c59944 - forced line breaks detected for bidi-aware alignment
7918            let line_ends_with_forced_break = line_items.iter().any(|item| matches!(item, ShapedItem::Break { .. }));
7919
7920            // uses text-align-last (last line of block, or line right before forced break)
7921            let is_last_line = cursor.is_done() && !was_hyphenated;
7922            let effective_align = resolve_effective_alignment(
7923                fragment_constraints.text_align,
7924                fragment_constraints.text_align_last,
7925                is_last_line || line_ends_with_forced_break,
7926            );
7927
7928            let (mut line_pos_items, line_height) = position_one_line(
7929                line_items,
7930                &line_constraints,
7931                line_top_y,
7932                line_index,
7933                effective_align,
7934                base_direction,
7935                is_last_line,
7936                fragment_constraints,
7937                debug_messages,
7938                fonts,
7939                is_after_forced_break,
7940            );
7941
7942            // Track whether the next line follows a forced break
7943            is_after_forced_break = line_ends_with_forced_break;
7944
7945            for item in &mut line_pos_items {
7946                item.position.x += column_start_x;
7947            }
7948
7949            // +spec:display-property:6c4978 - line-height on block container establishes minimum line box height
7950            line_top_y += line_height.max(fragment_constraints.resolved_line_height());
7951            line_index += 1;
7952            positioned_items.extend(line_pos_items);
7953        }
7954        current_column += 1;
7955    }
7956
7957    if let Some(msgs) = debug_messages {
7958        msgs.push(LayoutDebugMessage::info(format!(
7959            "--- Exiting perform_fragment_layout, positioned {} items ---",
7960            positioned_items.len()
7961        )));
7962    }
7963
7964    let layout = UnifiedLayout {
7965        items: positioned_items,
7966        overflow: OverflowInfo::default(),
7967    };
7968
7969    // Calculate bounds on demand via the bounds() method
7970    let calculated_bounds = layout.bounds();
7971    
7972    if let Some(msgs) = debug_messages {
7973        msgs.push(LayoutDebugMessage::info(format!(
7974            "--- Calculated bounds: width={}, height={} ---",
7975            calculated_bounds.width, calculated_bounds.height
7976        )));
7977    }
7978
7979    Ok(layout)
7980}
7981
7982/// Breaks a single line of items to fit within the given geometric constraints,
7983/// handling multi-segment lines and hyphenation.
7984/// Break a single line from the current cursor position.
7985///
7986/// # CSS Text Module Level 3 \u00a7 5 Line Breaking and Word Boundaries
7987/// https://www.w3.org/TR/css-text-3/#line-breaking
7988///
7989/// Implements the line breaking algorithm:
7990/// 1. "When an inline box exceeds the logical width of a line box, it is split into several
7991///    fragments, which are partitioned across multiple line boxes."
7992///
7993/// ## \u2705 Implemented Features:
7994/// - **Break Opportunities**: Identifies word boundaries and break points
7995/// - **Soft Wraps**: Wraps at spaces between words
7996/// - **Hard Breaks**: Handles explicit line breaks (\\n)
7997/// - **Overflow**: If a word is too long, places it anyway to avoid infinite loop
7998/// - **Hyphenation**: Tries to break long words at hyphenation points (\u00a7 5.4)
7999///
8000/// ## \u26a0\ufe0f Known Issues:
8001/// - If `line_constraints.total_available` is 0.0 (from `available_width: 0.0` bug), every word
8002///   will overflow, causing single-word lines
8003/// - This is the symptom visible in the PDF: "List items break extremely early"
8004///
8005/// ## \u00a7 5.2 Breaking Rules for Letters
8006/// \u2705 IMPLEMENTED: Uses Unicode line breaking algorithm
8007/// - Relies on UAX #14 for break opportunities
8008/// - Respects non-breaking spaces and zero-width joiners
8009///
8010/// ## \u00a7 5.3 Breaking Rules for Punctuation
8011/// \u26a0\ufe0f PARTIAL: Basic punctuation handling
8012/// - \u274c TODO: hanging-punctuation is declared in UnifiedConstraints but not used here
8013/// - \u274c TODO: Should implement punctuation trimming at line edges
8014/// // +spec:intrinsic-sizing:6085cf - hanging glyphs must be excluded from intrinsic size computation
8015///
8016/// ## \u00a7 5.4 Hyphenation
8017/// \u2705 IMPLEMENTED: Automatic hyphenation with hyphenator library
8018/// - Tries to hyphenate words that overflow
8019/// - Inserts hyphen glyph at break point
8020/// - Carries remainder to next line
8021///
8022/// ## \u00a7 5.5 Overflow Wrapping
8023/// \u2705 IMPLEMENTED: Emergency breaking
8024/// - If line is empty and word doesn't fit, forces at least one item
8025/// - Prevents infinite loop
8026/// - This is "overflow-wrap: break-word" behavior
8027///
8028/// # Missing Features:
8029/// - word-break property (normal, break-all, keep-all) - IMPLEMENTED via BreakCursor.word_break
8030/// - \u26a0\ufe0f line-break property: anywhere implemented; loose/normal/strict CJK strictness
8031///   filtering added via `is_cjk_break_allowed_by_strictness` (§5.3)
8032/// - \u274c overflow-wrap: anywhere vs break-word distinction
8033/// - \u2705 white-space: break-spaces handling
8034// around every typographic character unit including preserved white spaces; with break-spaces
8035// it allows breaking before the first space of a sequence
8036// +spec:line-breaking:722f3b - wrapping only at soft wrap opportunities, minimizing overflow
8037pub fn break_one_line<T: ParsedFontTrait>(
8038    cursor: &mut BreakCursor,
8039    line_constraints: &LineConstraints,
8040    is_vertical: bool,
8041    hyphenator: Option<&Standard>,
8042    fonts: &LoadedFonts<T>,
8043    line_break: LineBreakStrictness,
8044    white_space_mode: WhiteSpaceMode,
8045    overflow_wrap: OverflowWrap,
8046) -> (Vec<ShapedItem>, bool) {
8047    let mut line_items = Vec::new();
8048    let mut current_width = 0.0;
8049
8050    if cursor.is_done() {
8051        return (Vec::new(), false);
8052    }
8053
8054    // +spec:white-space-processing:c83dbd - Phase II: collapsible spaces at line start removed, trailing spaces removed, tab stops
8055    // CSS Text Module Level 3 § 4.1.2: At the beginning of a line, white space
8056    // is collapsed away. Skip leading whitespace at line start.
8057    // https://www.w3.org/TR/css-text-3/#white-space-phase-2
8058    let break_spaces = white_space_mode == WhiteSpaceMode::BreakSpaces;
8059    if !break_spaces {
8060        while !cursor.is_done() {
8061            let next_unit = cursor.peek_next_unit();
8062            if next_unit.is_empty() {
8063                break;
8064            }
8065            if next_unit.len() == 1 && is_collapsible_whitespace(&next_unit[0]) {
8066                cursor.consume(1);
8067            } else {
8068                break;
8069            }
8070        }
8071    }
8072
8073    // +spec:line-breaking:35817b - white-space: nowrap/pre prevent soft wrap opportunities
8074    // CSS Text Level 3 § 3: For nowrap and pre, wrapping is suppressed. All content
8075    // stays on a single line, overflowing if necessary.
8076    let no_wrap = matches!(white_space_mode, WhiteSpaceMode::Nowrap | WhiteSpaceMode::Pre);
8077
8078    if no_wrap {
8079        // No soft wrapping — consume everything onto one line.
8080        // Only explicit <br>/newline breaks are honored.
8081        loop {
8082            let next_unit = cursor.peek_next_unit();
8083            if next_unit.is_empty() {
8084                break;
8085            }
8086            if let Some(ShapedItem::Break { .. }) = next_unit.first() {
8087                line_items.push(next_unit[0].clone());
8088                cursor.consume(1);
8089                return (line_items, false);
8090            }
8091            line_items.extend_from_slice(&next_unit);
8092            cursor.consume(next_unit.len());
8093        }
8094    } else {
8095
8096    loop {
8097        // typographic character unit as a soft wrap opportunity; hyphenation is not applied
8098        let next_unit = if line_break == LineBreakStrictness::Anywhere {
8099            cursor.peek_next_single_item()
8100        } else {
8101            cursor.peek_next_unit()
8102        };
8103        if next_unit.is_empty() {
8104            break; // End of content
8105        }
8106
8107        if let Some(ShapedItem::Break { .. }) = next_unit.first() {
8108            line_items.push(next_unit[0].clone());
8109            cursor.consume(1);
8110            return (line_items, false);
8111        }
8112
8113        let unit_width: f32 = next_unit
8114            .iter()
8115            .map(|item| get_item_measure(item, is_vertical))
8116            .sum();
8117        let available_width = line_constraints.total_available - current_width;
8118
8119        // 2. Can the whole unit fit on the current line?
8120        if unit_width <= available_width {
8121            line_items.extend_from_slice(&next_unit);
8122            current_width += unit_width;
8123            cursor.consume(next_unit.len());
8124        } else {
8125            // 3. The unit overflows. Can we hyphenate it?
8126            if line_break != LineBreakStrictness::Anywhere {
8127                if let Some(hyphenator) = hyphenator {
8128                    if !is_break_opportunity(next_unit.last().unwrap()) {
8129                        if let Some(hyphenation_result) = try_hyphenate_word_cluster(
8130                            &next_unit,
8131                            available_width,
8132                            is_vertical,
8133                            hyphenator,
8134                            fonts,
8135                        ) {
8136                            line_items.extend(hyphenation_result.line_part);
8137                            cursor.consume(next_unit.len());
8138                            cursor.partial_remainder = hyphenation_result.remainder_part;
8139                            return (line_items, true);
8140                        }
8141                    }
8142                }
8143            }
8144
8145            // an otherwise unbreakable sequence at an arbitrary point when no other
8146            // break points exist. Grapheme clusters stay together; no hyphen inserted.
8147            // 4. Cannot hyphenate or fit. The line is finished.
8148            // If the line is empty, we must force at least one item to avoid an infinite loop.
8149            // With overflow-wrap: anywhere or break-word, we break the unbreakable
8150            // unit at an arbitrary cluster boundary. With normal, we only force one
8151            // item to prevent infinite loops (content will overflow).
8152            if line_items.is_empty() {
8153                match overflow_wrap {
8154                    OverflowWrap::Anywhere | OverflowWrap::BreakWord => {
8155                        // Emergency break: fit as many clusters as possible on
8156                        // this line.  Grapheme clusters stay together.
8157                        //
8158                        // Per CSS Text 3 §5.5: "an otherwise unbreakable sequence
8159                        // of characters may be broken at an arbitrary point" when
8160                        // overflow-wrap is anywhere/break-word.
8161                        let avail = line_constraints.total_available;
8162                        for item in next_unit.iter() {
8163                            let item_w = get_item_measure(item, is_vertical);
8164                            // Break BEFORE this item if adding it would overflow,
8165                            // but only if we already have at least one item on the
8166                            // line (must always make progress).
8167                            if !line_items.is_empty() && avail > 0.0 && current_width + item_w > avail {
8168                                break;
8169                            }
8170                            line_items.push(item.clone());
8171                            current_width += item_w;
8172                            // If container is zero-width (avail <= 0), place all
8173                            // items on one line — there's nowhere to break TO,
8174                            // content just overflows.  This matches browser
8175                            // behavior for `width: 0` containers.
8176                            if avail <= 0.0 {
8177                                continue; // Keep adding — can't break into nothing
8178                            }
8179                        }
8180                        let consumed = line_items.len().max(1);
8181                        if line_items.is_empty() {
8182                            line_items.push(next_unit[0].clone());
8183                        }
8184                        cursor.consume(consumed);
8185                    }
8186                    OverflowWrap::Normal => {
8187                        // No emergency breaking — just force one item to prevent infinite loop
8188                        line_items.push(next_unit[0].clone());
8189                        cursor.consume(1);
8190                    }
8191                }
8192            }
8193            break;
8194        }
8195    }
8196
8197    } // end !no_wrap
8198
8199    // +spec:white-space-processing:fef250 - Phase II: trailing collapsible spaces and U+1680 removed at line end
8200    // as well as any trailing U+1680 OGHAM SPACE MARK whose white-space is normal/nowrap/pre-line.
8201    // Note: pre-wrap and break-spaces have different handling (hanging/preserving)
8202    // which is not yet implemented here.
8203    while let Some(last) = line_items.last() {
8204        if is_collapsible_whitespace(last) {
8205            line_items.pop();
8206        } else {
8207            break;
8208        }
8209    }
8210
8211    (line_items, false)
8212}
8213
8214/// Represents a single valid hyphenation point within a word.
8215#[derive(Clone)]
8216pub struct HyphenationBreak {
8217    /// The number of characters from the original word string included on the line.
8218    pub char_len_on_line: usize,
8219    /// The total advance width of the line part + the hyphen.
8220    pub width_on_line: f32,
8221    /// The cluster(s) that will remain on the current line.
8222    pub line_part: Vec<ShapedItem>,
8223    /// The cluster that represents the hyphen character itself.
8224    pub hyphen_item: ShapedItem,
8225    /// The cluster(s) that will be carried over to the next line.
8226    /// CRITICAL FIX: Changed from ShapedItem to Vec<ShapedItem>
8227    pub remainder_part: Vec<ShapedItem>,
8228}
8229
8230/// A "word" is defined as a sequence of one or more adjacent ShapedClusters.
8231pub fn find_all_hyphenation_breaks<T: ParsedFontTrait>(
8232    word_clusters: &[ShapedCluster],
8233    hyphenator: &Standard,
8234    is_vertical: bool, // Pass this in to use correct metrics
8235    fonts: &LoadedFonts<T>,
8236) -> Option<Vec<HyphenationBreak>> {
8237    if word_clusters.is_empty() {
8238        return None;
8239    }
8240
8241    // --- 1. Concatenate the TRUE text and build a robust map ---
8242    let mut word_string = String::new();
8243    let mut char_map = Vec::new();
8244    let mut current_width = 0.0;
8245
8246    for (cluster_idx, cluster) in word_clusters.iter().enumerate() {
8247        for (char_byte_offset, _ch) in cluster.text.char_indices() {
8248            let glyph_idx = cluster
8249                .glyphs
8250                .iter()
8251                .rposition(|g| g.cluster_offset as usize <= char_byte_offset)
8252                .unwrap_or(0);
8253            let glyph = &cluster.glyphs[glyph_idx];
8254
8255            let num_chars_in_glyph = cluster.text[glyph.cluster_offset as usize..]
8256                .chars()
8257                .count();
8258            let advance_per_char = if is_vertical {
8259                glyph.vertical_advance
8260            } else {
8261                glyph.advance
8262            } / (num_chars_in_glyph as f32).max(1.0);
8263
8264            current_width += advance_per_char;
8265            char_map.push((cluster_idx, glyph_idx, current_width));
8266        }
8267        word_string.push_str(&cluster.text);
8268    }
8269
8270    // +spec:line-breaking:d7ed93 - language-specific hyphenation rules apply to both auto and explicit (soft hyphen) opportunities
8271    // --- 2. Get hyphenation opportunities ---
8272    let opportunities = hyphenator.hyphenate(&word_string);
8273    if opportunities.breaks.is_empty() {
8274        return None;
8275    }
8276
8277    let last_cluster = word_clusters.last().unwrap();
8278    let last_glyph = last_cluster.glyphs.last().unwrap();
8279    let style = last_cluster.style.clone();
8280
8281    // Look up font from hash
8282    let font = fonts.get_by_hash(last_glyph.font_hash)?;
8283    let (hyphen_glyph_id, hyphen_advance) =
8284        font.get_hyphen_glyph_and_advance(style.font_size_px)?;
8285
8286    let mut possible_breaks = Vec::new();
8287
8288    // --- 3. Generate a HyphenationBreak for each valid opportunity ---
8289    for &break_char_idx in &opportunities.breaks {
8290        // The break is *before* the character at this index.
8291        // So the last character on the line is at `break_char_idx - 1`.
8292        if break_char_idx == 0 || break_char_idx > char_map.len() {
8293            continue;
8294        }
8295
8296        let (_, _, width_at_break) = char_map[break_char_idx - 1];
8297
8298        // The line part is all clusters *before* the break index.
8299        let line_part: Vec<ShapedItem> = word_clusters[..break_char_idx]
8300            .iter()
8301            .map(|c| ShapedItem::Cluster(c.clone()))
8302            .collect();
8303
8304        // The remainder is all clusters *from* the break index onward.
8305        let remainder_part: Vec<ShapedItem> = word_clusters[break_char_idx..]
8306            .iter()
8307            .map(|c| ShapedItem::Cluster(c.clone()))
8308            .collect();
8309
8310        let hyphen_item = ShapedItem::Cluster(ShapedCluster {
8311            text: "-".to_string(),
8312            source_cluster_id: GraphemeClusterId {
8313                source_run: u32::MAX,
8314                start_byte_in_run: u32::MAX,
8315            },
8316            source_content_index: ContentIndex {
8317                run_index: u32::MAX,
8318                item_index: u32::MAX,
8319            },
8320            source_node_id: None, // Hyphen is generated, not from DOM
8321            glyphs: smallvec![ShapedGlyph {
8322                kind: GlyphKind::Hyphen,
8323                glyph_id: hyphen_glyph_id,
8324                font_hash: last_glyph.font_hash,
8325                font_metrics: last_glyph.font_metrics.clone(),
8326                cluster_offset: 0,
8327                script: Script::Latin,
8328                advance: hyphen_advance,
8329                kerning: 0.0,
8330                offset: Point::default(),
8331                style: style.clone(),
8332                vertical_advance: hyphen_advance,
8333                vertical_offset: Point::default(),
8334            }],
8335            advance: hyphen_advance,
8336            direction: BidiDirection::Ltr,
8337            style: style.clone(),
8338            marker_position_outside: None,
8339            is_first_fragment: true,
8340            is_last_fragment: true,
8341        });
8342
8343        possible_breaks.push(HyphenationBreak {
8344            char_len_on_line: break_char_idx,
8345            width_on_line: width_at_break + hyphen_advance,
8346            line_part,
8347            hyphen_item,
8348            remainder_part,
8349        });
8350    }
8351
8352    Some(possible_breaks)
8353}
8354
8355/// Tries to find a hyphenation point within a word, returning the line part and remainder.
8356fn try_hyphenate_word_cluster<T: ParsedFontTrait>(
8357    word_items: &[ShapedItem],
8358    remaining_width: f32,
8359    is_vertical: bool,
8360    hyphenator: &Standard,
8361    fonts: &LoadedFonts<T>,
8362) -> Option<HyphenationResult> {
8363    let word_clusters: Vec<ShapedCluster> = word_items
8364        .iter()
8365        .filter_map(|item| item.as_cluster().cloned())
8366        .collect();
8367
8368    if word_clusters.is_empty() {
8369        return None;
8370    }
8371
8372    let all_breaks = find_all_hyphenation_breaks(&word_clusters, hyphenator, is_vertical, fonts)?;
8373
8374    if let Some(best_break) = all_breaks
8375        .into_iter()
8376        .rfind(|b| b.width_on_line <= remaining_width)
8377    {
8378        let mut line_part = best_break.line_part;
8379        line_part.push(best_break.hyphen_item);
8380
8381        return Some(HyphenationResult {
8382            line_part,
8383            remainder_part: best_break.remainder_part,
8384        });
8385    }
8386
8387    None
8388}
8389
8390/// Positions a single line of items, handling alignment and justification within segments.
8391///
8392/// This function is architecturally critical for cache safety. It does not mutate the
8393/// `advance` or `bounds` of the input `ShapedItem`s. Instead, it applies justification
8394/// spacing by adjusting the drawing pen's position (`main_axis_pen`).
8395///
8396/// # Returns
8397/// A tuple containing the `Vec` of positioned items and the calculated height of the line box.
8398/// Position items on a single line after breaking.
8399///
8400/// # CSS Inline Layout Module Level 3 \u00a7 2.2 Layout Within Line Boxes
8401/// https://www.w3.org/TR/css-inline-3/#layout-within-line-boxes
8402///
8403/// Implements the positioning algorithm:
8404/// 1. "All inline-level boxes are aligned by their baselines"
8405/// 2. "Calculate layout bounds for each inline box"
8406/// 3. "Size the line box to fit the aligned layout bounds"
8407/// 4. "Position all inline boxes within the line box"
8408///
8409/// ## \u2705 Implemented Features:
8410///
8411/// ### \u00a7 4 Baseline Alignment (vertical-align)
8412/// \u26a0\ufe0f PARTIAL IMPLEMENTATION:
8413/// - \u2705 `baseline`: Aligns box baseline with parent baseline (default)
8414/// - \u2705 `top`: Aligns top of box with top of line box
8415/// - \u2705 `middle`: Centers box within line box
8416/// - \u2705 `bottom`: Aligns bottom of box with bottom of line box
8417/// - \u274c MISSING: `text-top`, `text-bottom`, `sub`, `super`
8418/// - \u274c MISSING: `<length>`, `<percentage>` values for custom offset
8419///
8420/// ### \u00a7 2.2.1 Text Alignment (text-align)
8421/// +spec:containing-block:8d5146 - text-align aligns within line box, not viewport/containing block
8422/// \u2705 IMPLEMENTED:
8423/// - `left`, `right`, `center`: Physical alignment
8424/// - `start`, `end`: Logical alignment (respects direction: ltr/rtl)
8425/// - `justify`: Distributes space between words/characters
8426/// - `justify-all`: Justifies last line too
8427///
8428/// ### \u00a7 7.3 Text Justification (text-justify)
8429/// \u2705 IMPLEMENTED:
8430/// - `inter-word`: Adds space between words
8431/// - `inter-character`: Adds space between characters
8432/// - `kashida`: Arabic kashida elongation
8433/// - \u274c MISSING: `distribute` (CJK justification)
8434///
8435/// ### CSS Text \u00a7 8.1 Text Indentation (text-indent)
8436/// \u2705 IMPLEMENTED: First line indentation
8437///
8438/// ### CSS Text \u00a7 4.1 Word Spacing (word-spacing)
8439/// \u2705 IMPLEMENTED: Additional space between words
8440///
8441/// ### CSS Text \u00a7 4.2 Letter Spacing (letter-spacing)
8442/// \u2705 IMPLEMENTED: Additional space between characters
8443///
8444/// ## Segment-Aware Layout:
8445/// \u2705 Handles CSS Shapes and multi-column layouts
8446/// - Breaks line into segments (for shape boundaries)
8447/// - Calculates justification per segment
8448/// - Applies alignment within each segment's bounds
8449///
8450/// ## Known Issues:
8451/// - \u26a0\ufe0f If segment.width is infinite (from intrinsic sizing), sets alignment_offset=0 to
8452///   avoid infinite positioning. This is correct for measurement but documented for clarity.
8453/// - The function assumes `line_index == 0` means first line for text-indent. A more robust system
8454///   would track paragraph boundaries.
8455///
8456/// # Missing Features:
8457/// - \u274c \u00a7 6 Trimming Leading (text-box-trim, text-box-edge)
8458/// - \u274c \u00a7 3.3 Initial Letters (drop caps)
8459/// // +spec:display-property:265c04 - initial letter exclusion area must continue into subsequent blocks when paragraph is shorter than drop cap
8460/// - \u274c Full vertical-align support (sub, super, lengths, percentages)
8461/// - \u274c white-space: break-spaces alignment behavior
8462// +spec:text-alignment-spacing:c8a926 - order of operations: shaping → letter/word-spacing → justification → alignment
8463pub fn position_one_line<T: ParsedFontTrait>(
8464    line_items: Vec<ShapedItem>,
8465    line_constraints: &LineConstraints,
8466    line_top_y: f32,
8467    line_index: usize,
8468    text_align: TextAlign,
8469    base_direction: BidiDirection,
8470    is_last_line: bool,
8471    constraints: &UnifiedConstraints,
8472    debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
8473    fonts: &LoadedFonts<T>,
8474    is_after_forced_break: bool,
8475) -> (Vec<PositionedItem>, f32) {
8476    let line_text: String = line_items
8477        .iter()
8478        .filter_map(|i| i.as_cluster())
8479        .map(|c| c.text.as_str())
8480        .collect();
8481    if let Some(msgs) = debug_messages {
8482        msgs.push(LayoutDebugMessage::info(format!(
8483            "\n--- Entering position_one_line for line: [{}] ---",
8484            line_text
8485        )));
8486    }
8487    // +spec:text-alignment-spacing:13b72d - line box start/end determined by inline base direction
8488    // +spec:text-alignment-spacing:d497af - line box inline base direction affects text-align resolution
8489    // +spec:text-alignment-spacing:68332e - bidi direction determines start/end to left/right mapping
8490    let physical_align = match (text_align, base_direction) {
8491        (TextAlign::Start, BidiDirection::Ltr) => TextAlign::Left,
8492        (TextAlign::Start, BidiDirection::Rtl) => TextAlign::Right,
8493        (TextAlign::End, BidiDirection::Ltr) => TextAlign::Right,
8494        (TextAlign::End, BidiDirection::Rtl) => TextAlign::Left,
8495        // Physical alignments are returned as-is, regardless of direction.
8496        (other, _) => other,
8497    };
8498    if let Some(msgs) = debug_messages {
8499        msgs.push(LayoutDebugMessage::info(format!(
8500            "[Pos1Line] Physical align: {:?}",
8501            physical_align
8502        )));
8503    }
8504
8505    // +spec:box-model:847003 - Phantom line boxes: empty lines treated as zero-height
8506    // +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
8507    // +spec:display-property:90d782 - Phantom line boxes (containing only empty inline boxes, out-of-flow items, or collapsed whitespace) are ignored
8508    if line_items.is_empty() {
8509        return (Vec::new(), 0.0);
8510    }
8511    let mut positioned = Vec::new();
8512    let is_vertical = constraints.is_vertical();
8513
8514    // +spec:line-height:9ca9d9 - line box height = distance from uppermost box top to lowermost box bottom, including strut
8515    // The line box is calculated once for all items on the line, regardless of segment.
8516    // Per CSS 2.2 §10.8, top/bottom aligned items are handled in a second pass to
8517    // minimize line box height; baseline-aligned items determine the initial height.
8518    let (content_ascent, content_descent) = calculate_line_metrics(&line_items, constraints.vertical_align, constraints);
8519
8520    // +spec:box-model:e99f7d - strut: each line box starts with zero-width inline box with block container's font/line-height
8521    // +spec:line-height:29c478 - strut: zero-width inline box with block container's font/line-height
8522    // inline box with the block container's font and line-height. The strut has A (ascent) and
8523    // D (descent) from the block container's first available font. Half-leading L/2 is applied:
8524    // L = line-height - (A + D), strut_above = A + L/2, strut_below = D + L/2.
8525    // +spec:height-calculation:8e91b2 - specified line-height used in line box height calculation
8526    let strut_ad = constraints.strut_ascent + constraints.strut_descent;
8527    let strut_leading_half = (constraints.resolved_line_height() - strut_ad) / 2.0;
8528    let strut_above = constraints.strut_ascent + strut_leading_half;
8529    let strut_below = constraints.strut_descent + strut_leading_half;
8530    let line_ascent = content_ascent.max(strut_above);
8531    let line_descent = content_descent.max(strut_below);
8532    let line_box_height = line_ascent + line_descent;
8533
8534    // The baseline for the entire line is determined by its tallest item.
8535    let line_baseline_y = line_top_y + line_ascent;
8536
8537    // --- Segment-Aware Positioning ---
8538    let mut item_cursor = 0;
8539    let is_first_line_of_para = line_index == 0; // Simplified assumption
8540
8541    for (segment_idx, segment) in line_constraints.segments.iter().enumerate() {
8542        if item_cursor >= line_items.len() {
8543            break;
8544        }
8545
8546        // 1. Collect all items that fit into the current segment.
8547        let mut segment_items = Vec::new();
8548        let mut current_segment_width = 0.0;
8549        while item_cursor < line_items.len() {
8550            let item = &line_items[item_cursor];
8551            let item_measure = get_item_measure(item, is_vertical);
8552            // Put at least one item in the segment to avoid getting stuck.
8553            if current_segment_width + item_measure > segment.width && !segment_items.is_empty() {
8554                break;
8555            }
8556            segment_items.push(item.clone());
8557            current_segment_width += item_measure;
8558            item_cursor += 1;
8559        }
8560
8561        if segment_items.is_empty() {
8562            continue;
8563        }
8564
8565        // +spec:text-alignment-spacing:b9d88e - justify stretches inline boxes via text-justify; non-collapsible WS may skip justification
8566        // 2. Calculate justification spacing *for this segment only*.
8567        // +spec:text-alignment-spacing:30d322 - justify lines with justification opportunities when text-align is justify
8568        // CSS Text 3 §6: text-justify controls HOW to justify, but only applies
8569        // when text-align is justify/justify-all. Without this check, ALL text
8570        // gets justified because text-justify defaults to auto (→ InterWord).
8571        let (extra_word_spacing, extra_char_spacing) = if (constraints.text_align == TextAlign::Justify
8572            || constraints.text_align == TextAlign::JustifyAll)
8573            && constraints.text_justify != JustifyContent::None
8574            && (!is_last_line || constraints.text_align == TextAlign::JustifyAll)
8575            && constraints.text_justify != JustifyContent::Kashida
8576        {
8577            let segment_line_constraints = LineConstraints {
8578                segments: vec![segment.clone()],
8579                total_available: segment.width,
8580            };
8581            calculate_justification_spacing(
8582                &segment_items,
8583                &segment_line_constraints,
8584                constraints.text_justify,
8585                is_vertical,
8586            )
8587        } else {
8588            (0.0, 0.0)
8589        };
8590
8591        // Kashida justification needs to be segment-aware if used.
8592        let justified_segment_items = if constraints.text_justify == JustifyContent::Kashida
8593            && (!is_last_line || constraints.text_align == TextAlign::JustifyAll)
8594        {
8595            let segment_line_constraints = LineConstraints {
8596                segments: vec![segment.clone()],
8597                total_available: segment.width,
8598            };
8599            justify_kashida_and_rebuild(
8600                segment_items,
8601                &segment_line_constraints,
8602                is_vertical,
8603                debug_messages,
8604                fonts,
8605            )
8606        } else {
8607            segment_items
8608        };
8609
8610        // Recalculate width in case kashida changed the item list
8611        let final_segment_width: f32 = justified_segment_items
8612            .iter()
8613            .map(|item| get_item_measure(item, is_vertical))
8614            .sum();
8615
8616        // +spec:line-breaking:155a96 - pre-wrap hanging spaces: unconditionally hang without forced break, conditionally hang with forced break
8617        // +spec:white-space-processing:68af09 - Phase II: trailing whitespace hanging/conditional hanging per white-space mode
8618        // +spec:white-space-processing:75d91e - preserved white space hangs at line end, affecting intrinsic sizing
8619        // +spec:overflow:a68394 - Hanging trailing whitespace: unconditionally hang (not considered
8620        // during alignment, may overflow) for lines without forced break; conditionally hang for
8621        // lines ending with forced break (only hang if would overflow).
8622        // For normal/nowrap/pre-line: unconditionally hang trailing WS.
8623        // For pre-wrap: unconditionally hang, unless before forced break (then conditionally hang).
8624        // For break-spaces: trailing spaces cannot hang.
8625        // For pre: no hanging (whitespace preserved as-is).
8626        // +spec:intrinsic-sizing:1db683 - conditionally hanging glyphs excluded from min-content, included in max-content
8627        let trailing_ws_width = match constraints.white_space_mode {
8628            WhiteSpaceMode::BreakSpaces | WhiteSpaceMode::Pre => 0.0,
8629            WhiteSpaceMode::Normal | WhiteSpaceMode::Nowrap | WhiteSpaceMode::PreLine => {
8630                measure_trailing_whitespace(&justified_segment_items, is_vertical)
8631            }
8632            // +spec:line-breaking:8aa426 - space before forced break does not hang if it doesn't overflow
8633            WhiteSpaceMode::PreWrap => {
8634                let has_forced_break = justified_segment_items.last()
8635                    .map(|item| matches!(item, ShapedItem::Break { .. }))
8636                    .unwrap_or(false);
8637                let ws_width = measure_trailing_whitespace(&justified_segment_items, is_vertical);
8638                if has_forced_break {
8639                    // +spec:display-contents:2704a2 - conditionally hanging chars not considered when measuring line fit
8640                    // Conditionally hang: only hang if it would overflow
8641                    let content_width = final_segment_width - ws_width;
8642                    if content_width + ws_width > segment.width {
8643                        ws_width
8644                    } else {
8645                        0.0
8646                    }
8647                } else {
8648                    ws_width // unconditionally hang
8649                }
8650            }
8651        };
8652        let effective_segment_width = final_segment_width - trailing_ws_width;
8653
8654        // +spec:text-alignment-spacing:287316 - overflow content is start-aligned; alignment offset within line box
8655        // 3. Calculate alignment offset *within this segment*.
8656        let remaining_space = segment.width - effective_segment_width;
8657
8658        // Handle MaxContent/indefinite width: when available_width is MaxContent (for intrinsic
8659        // sizing), segment.width will be f32::MAX / 2.0. Alignment calculations would
8660        // produce huge offsets. In this case, treat as left-aligned (offset = 0) since
8661        // we're measuring natural content width. We check for both infinite AND very large
8662        // values (> 1e30) to catch the MaxContent case.
8663        let is_indefinite_width = segment.width.is_infinite() || segment.width > 1e30;
8664        // +spec:text-alignment-spacing:ab1d4f - unexpandable justify text aligns as center
8665        let alignment_offset = if is_indefinite_width {
8666            0.0 // No alignment offset for indefinite width
8667        } else {
8668            match physical_align {
8669                TextAlign::Center => remaining_space / 2.0,
8670                TextAlign::Right => remaining_space,
8671                TextAlign::Justify | TextAlign::JustifyAll
8672                    if remaining_space > 0.0
8673                        && extra_word_spacing == 0.0
8674                        && extra_char_spacing == 0.0 =>
8675                {
8676                    // CSS Text §6.4.3: If text cannot be stretched to full width
8677                    // and text-align-last is justify, align as center.
8678                    remaining_space / 2.0
8679                }
8680                _ => 0.0, // Left, Justify (when justification succeeded)
8681            }
8682        };
8683
8684        let mut main_axis_pen = segment.start_x + alignment_offset;
8685        if let Some(msgs) = debug_messages {
8686            msgs.push(LayoutDebugMessage::info(format!(
8687                "[Pos1Line] Segment width: {}, Item width: {}, Remaining space: {}, Initial pen: \
8688                 {}",
8689                segment.width, final_segment_width, remaining_space, main_axis_pen
8690            )));
8691        }
8692
8693        // Default: indent first line only. each-line: also indent after forced breaks.
8694        // hanging: invert which lines get the indent.
8695        if segment_idx == 0 {
8696            let is_indent_target = if constraints.text_indent_each_line {
8697                // each-line: first line AND each line after a forced break
8698                is_first_line_of_para || is_after_forced_break
8699            } else {
8700                // Default: only the first line of the block
8701                is_first_line_of_para
8702            };
8703            // hanging: inverts which lines are affected
8704            let should_indent = if constraints.text_indent_hanging {
8705                !is_indent_target
8706            } else {
8707                is_indent_target
8708            };
8709            if should_indent {
8710                main_axis_pen += constraints.text_indent;
8711            }
8712        }
8713
8714        // Calculate total marker width for proper outside marker positioning
8715        // We need to position all marker clusters together in the padding gutter
8716        let total_marker_width: f32 = justified_segment_items
8717            .iter()
8718            .filter_map(|item| {
8719                if let ShapedItem::Cluster(c) = item {
8720                    if c.marker_position_outside == Some(true) {
8721                        return Some(get_item_measure(item, is_vertical));
8722                    }
8723                }
8724                None
8725            })
8726            .sum();
8727
8728        // Track marker pen separately - starts at negative position for outside markers
8729        let marker_spacing = 4.0; // Small gap between marker and content
8730        let mut marker_pen = if total_marker_width > 0.0 {
8731            -(total_marker_width + marker_spacing)
8732        } else {
8733            0.0
8734        };
8735
8736        // 4. Position the items belonging to this segment.
8737        //
8738        // +spec:inline-formatting-context:267438 - Content positioning: position aligned subtree and baseline-shift values within line box
8739        //
8740        // Vertical alignment positioning (CSS vertical-align)
8741        //
8742        // +spec:font-metrics:cae541 - dominant baseline used for inline alignment
8743        // Per CSS Inline Layout Level 3 § 4 (Baseline Alignment), each inline
8744        // element can specify its own `vertical-align`. For Object items
8745        // (inline-blocks, images), we use their per-item alignment stored in
8746        // `InlineContent::Shape.alignment` or `InlineContent::Image.alignment`.
8747        // For text clusters or items without a per-item override, we fall back
8748        // to the global `constraints.vertical_align` from the containing block.
8749        //
8750        // +spec:font-metrics:f29b61 - baseline alignment matches corresponding baseline types (only alphabetic implemented)
8751        // Reference: https://www.w3.org/TR/css-inline-3/#baseline-alignment
8752        // +spec:block-formatting-context:26b535 - In vertical typographic mode, central baseline is dominant when text-orientation is mixed/upright; otherwise alphabetic
8753        // +spec:inline-formatting-context:eb735b - alignment-baseline: inline-level boxes aligned to parent's baseline via vertical-align
8754        // +spec:inline-formatting-context:da3f34 - baseline alignment of in-flow inline-level boxes in block axis per dominant-baseline/vertical-align
8755        // +spec:line-height:e2253a - vertical-align positioning within line boxes
8756
8757        // Pre-compute inline border/padding offsets at span boundaries.
8758        // Only the FIRST cluster of each inline span gets left_inset, and only
8759        // the LAST cluster gets right_inset. We detect span boundaries by comparing
8760        // Arc<StyleProperties> pointers between consecutive clusters.
8761        let inline_offsets: Vec<(f32, f32)> = {
8762            let items_slice: &[ShapedItem] = &justified_segment_items;
8763            items_slice.iter().enumerate().map(|(idx, item)| {
8764                if let ShapedItem::Cluster(c) = item {
8765                    if let Some(border) = c.style.border.as_ref() {
8766                        if border.has_chrome() {
8767                            let style_ptr = Arc::as_ptr(&c.style);
8768                            let prev_same_span = idx > 0 && items_slice[idx - 1]
8769                                .as_cluster()
8770                                .map(|pc| Arc::as_ptr(&pc.style) == style_ptr)
8771                                .unwrap_or(false);
8772                            let next_same_span = idx + 1 < items_slice.len() && items_slice[idx + 1]
8773                                .as_cluster()
8774                                .map(|nc| Arc::as_ptr(&nc.style) == style_ptr)
8775                                .unwrap_or(false);
8776                            let left = if !prev_same_span { border.left_inset() } else { 0.0 };
8777                            let right = if !next_same_span { border.right_inset() } else { 0.0 };
8778                            return (left, right);
8779                        }
8780                    }
8781                }
8782                (0.0, 0.0)
8783            }).collect()
8784        };
8785        let mut inline_offset_idx = 0;
8786
8787        for item in justified_segment_items {
8788            let (item_ascent, item_descent) = get_item_vertical_metrics(&item, constraints);
8789            // Use per-item alignment if available, otherwise fall back to global
8790            let effective_align = get_item_vertical_align(&item)
8791                .unwrap_or(constraints.vertical_align);
8792            // +spec:display-property:328cfc - baseline-shift / aligned subtree vertical alignment (sub, super, top, bottom, center)
8793            // §10.8.1 vertical-align positioning
8794            // +spec:line-height:0fcfab - vertical-align property values (baseline, top, middle, bottom, sub, super, text-top, text-bottom, percentage, length)
8795            let item_baseline_pos = match effective_align {
8796                // +spec:display-property:8e018d - aligned subtree edges used for top/bottom line box alignment
8797                // +spec:inline-formatting-context:495672 - line-relative vertical-align (top/center/bottom) and aligned subtree positioning
8798                // top: align top of aligned subtree with top of line box
8799                VerticalAlign::Top => line_top_y + item_ascent,
8800                // +spec:font-metrics:70000d - align vertical midpoint of box with baseline + half x-height of parent
8801                VerticalAlign::Middle => {
8802                    let half_x_height = constraints.strut_x_height / 2.0;
8803                    line_baseline_y + half_x_height - (item_ascent + item_descent) / 2.0 + item_ascent
8804                }
8805                // bottom: align bottom of aligned subtree with bottom of line box
8806                VerticalAlign::Bottom => line_top_y + line_box_height - item_descent,
8807                // +spec:font-metrics:aa21f7 - sub: lower baseline to proper subscript position
8808                VerticalAlign::Sub => line_baseline_y + line_ascent * 0.3,
8809                // +spec:display-property:3b0e76 - baseline-shift super raises by ~1/3 font-size; top/bottom align to line box edges
8810                // super: raise baseline to proper superscript position (~0.4em)
8811                VerticalAlign::Super => line_baseline_y - line_ascent * 0.4,
8812                // text-top: align top of box with top of parent's content area (§10.6.1)
8813                // Parent's content area top = baseline - strut_ascent
8814                VerticalAlign::TextTop => (line_baseline_y - constraints.strut_ascent) + item_ascent,
8815                // text-bottom: align bottom of box with bottom of parent's content area (§10.6.1)
8816                // Parent's content area bottom = baseline + strut_descent
8817                VerticalAlign::TextBottom => (line_baseline_y + constraints.strut_descent) - item_descent,
8818                // <length>/<percentage>: raise (positive) or lower (negative); 0 = baseline
8819                VerticalAlign::Offset(offset) => line_baseline_y - offset,
8820                // +spec:display-property:8bf37e - dominant-baseline defaults to alphabetic; baseline alignment matches parent
8821                // baseline: align baseline of box with baseline of parent box
8822                // +spec:font-metrics:96bbd3 - baseline: align alphabetic baseline of box with parent's alphabetic baseline
8823                VerticalAlign::Baseline => line_baseline_y,
8824            };
8825
8826            // Calculate item measure (needed for both positioning and pen advance)
8827            let item_measure = get_item_measure(&item, is_vertical);
8828
8829            // Advance pen by inline left_inset at span entry (before positioning glyphs)
8830            let (left_inset, right_inset) = if inline_offset_idx < inline_offsets.len() {
8831                inline_offsets[inline_offset_idx]
8832            } else {
8833                (0.0, 0.0)
8834            };
8835            inline_offset_idx += 1;
8836            main_axis_pen += left_inset;
8837
8838            let position = if is_vertical {
8839                Point {
8840                    x: item_baseline_pos - item_ascent,
8841                    y: main_axis_pen,
8842                }
8843            } else {
8844                if let Some(msgs) = debug_messages {
8845                    msgs.push(LayoutDebugMessage::info(format!(
8846                        "[Pos1Line] is_vertical=false, main_axis_pen={}, item_baseline_pos={}, \
8847                         item_ascent={}",
8848                        main_axis_pen, item_baseline_pos, item_ascent
8849                    )));
8850                }
8851
8852                // Check if this is an outside marker - if so, position it in the padding gutter
8853                let x_position = if let ShapedItem::Cluster(cluster) = &item {
8854                    if cluster.marker_position_outside == Some(true) {
8855                        // Use marker_pen for sequential marker positioning
8856                        let marker_width = item_measure;
8857                        if let Some(msgs) = debug_messages {
8858                            msgs.push(LayoutDebugMessage::info(format!(
8859                                "[Pos1Line] Outside marker detected! width={}, positioning at \
8860                                 marker_pen={}",
8861                                marker_width, marker_pen
8862                            )));
8863                        }
8864                        let pos = marker_pen;
8865                        marker_pen += marker_width; // Advance marker pen for next marker cluster
8866                        pos
8867                    } else {
8868                        main_axis_pen
8869                    }
8870                } else {
8871                    main_axis_pen
8872                };
8873
8874                Point {
8875                    y: item_baseline_pos - item_ascent,
8876                    x: x_position,
8877                }
8878            };
8879
8880            // item_measure is calculated above for marker positioning
8881            let item_text = item
8882                .as_cluster()
8883                .map(|c| c.text.as_str())
8884                .unwrap_or("[OBJ]");
8885            if let Some(msgs) = debug_messages {
8886                msgs.push(LayoutDebugMessage::info(format!(
8887                    "[Pos1Line] Positioning item '{}' at pen_x={}",
8888                    item_text, main_axis_pen
8889                )));
8890            }
8891            positioned.push(PositionedItem {
8892                item: item.clone(),
8893                position,
8894                line_index,
8895            });
8896
8897            // Outside markers don't advance the pen - they're positioned in the padding gutter
8898            let is_outside_marker = if let ShapedItem::Cluster(c) = &item {
8899                c.marker_position_outside == Some(true)
8900            } else {
8901                false
8902            };
8903
8904            if !is_outside_marker {
8905                main_axis_pen += item_measure;
8906                // Advance pen by inline right_inset at span exit (after glyph advance)
8907                main_axis_pen += right_inset;
8908            }
8909
8910            // +spec:text-alignment-spacing:e09bd1 - justification space added on top of letter-spacing/word-spacing
8911            // +spec:text-alignment-spacing:456643 - cursive scripts don't admit inter-character gaps
8912            let is_cursive = if let ShapedItem::Cluster(c) = &item { is_cursive_script_cluster(c) } else { false };
8913            if !is_outside_marker && extra_char_spacing > 0.0 && can_justify_after(&item) && !is_cursive {
8914                main_axis_pen += extra_char_spacing;
8915            }
8916            // +spec:display-property:3a833c - consecutive atomic inlines treated as single unit for letter-spacing
8917            // +spec:display-property:49f04f - letter-spacing applied per innermost inline element
8918            // +spec:text-alignment-spacing:22bea4 - letter-spacing applied after bidi reordering, additive with kerning and word-spacing; justification may further adjust
8919            if let ShapedItem::Cluster(c) = &item {
8920                if !is_outside_marker {
8921                    // +spec:display-property:756454 - letter-spacing applied between typographic character units
8922                    // +spec:overflow:e63bc0 - letter-spacing ignores zero-width formatting chars (Cf); handled by shaper merging them into clusters
8923                    // +spec:text-alignment-spacing:80f9ec - letter-spacing applied per-cluster using innermost element's style (UA-allowed attachment)
8924                    // +spec:text-alignment-spacing:bdd704 - letter-spacing applied after each cluster, not at line start
8925                    // +spec:text-alignment-spacing:d3ef6e - single-char element: only trailing space, no inter-char effect
8926                    // +spec:text-alignment-spacing:d668fc - letter-spacing only affects characters within the element (per-cluster style)
8927                    // +spec:text-alignment-spacing:8dbb78 - zero letter-spacing behaves as normal (Px(0) adds no spacing)
8928                    // +spec:text-alignment-spacing:456643 - skip letter-spacing for cursive scripts
8929                    if !is_cursive_script_cluster(c) {
8930                    let letter_spacing_px = match c.style.letter_spacing {
8931                        Spacing::Px(px) => px as f32,
8932                        Spacing::Em(em) => em * c.style.font_size_px,
8933                    };
8934                    main_axis_pen += letter_spacing_px;
8935                    }
8936                    // +spec:width-calculation:9447d1 - word-spacing only applied to word separators; zero-width chars like U+200B are excluded
8937                    if is_word_separator(&item) {
8938                        let word_spacing_px = match c.style.word_spacing {
8939                            Spacing::Px(px) => px as f32,
8940                            Spacing::Em(em) => em * c.style.font_size_px,
8941                        };
8942                        main_axis_pen += word_spacing_px;
8943                        main_axis_pen += extra_word_spacing;
8944                    }
8945                }
8946            }
8947        }
8948    }
8949
8950    (positioned, line_box_height)
8951}
8952
8953/// Calculates the starting pen offset to achieve the desired text alignment.
8954fn calculate_alignment_offset(
8955    items: &[ShapedItem],
8956    line_constraints: &LineConstraints,
8957    align: TextAlign,
8958    is_vertical: bool,
8959    constraints: &UnifiedConstraints,
8960) -> f32 {
8961    // Simplified to use the first segment for alignment.
8962    if let Some(segment) = line_constraints.segments.first() {
8963        let total_width: f32 = items
8964            .iter()
8965            .map(|item| get_item_measure(item, is_vertical))
8966            .sum();
8967
8968        let available_width = if constraints.segment_alignment == SegmentAlignment::Total {
8969            line_constraints.total_available
8970        } else {
8971            segment.width
8972        };
8973
8974        if total_width >= available_width {
8975            return 0.0; // No alignment needed if line is full or overflows
8976        }
8977
8978        let remaining_space = available_width - total_width;
8979
8980        match align {
8981            TextAlign::Center => remaining_space / 2.0,
8982            TextAlign::Right => remaining_space,
8983            _ => 0.0, // Left, Justify, Start, End
8984        }
8985    } else {
8986        0.0
8987    }
8988}
8989
8990/// Calculates the extra spacing needed for justification without modifying the items.
8991///
8992/// This function is pure and does not mutate any state, making it safe to use
8993/// with cached `ShapedItem` data.
8994///
8995/// # Arguments
8996/// * `items` - A slice of items on the line.
8997/// * `line_constraints` - The geometric constraints for the line.
8998/// * `text_justify` - The type of justification to calculate.
8999/// * `is_vertical` - Whether the layout is vertical.
9000///
9001/// # Returns
9002/// A tuple `(extra_per_word, extra_per_char)` containing the extra space in pixels
9003/// to add at each word or character justification opportunity.
9004// +spec:display-contents:654278 - distributes remaining space to fill line box when justifying
9005// +spec:text-alignment-spacing:56c7f4 - equal distribution of justification space within priority level
9006// +spec:text-alignment-spacing:f17bbc - justification opportunities controlled by text-justify value (inter-word = word separators, inter-character = character juxtaposition)
9007fn calculate_justification_spacing(
9008    items: &[ShapedItem],
9009    line_constraints: &LineConstraints,
9010    text_justify: JustifyContent,
9011    is_vertical: bool,
9012) -> (f32, f32) {
9013    // (extra_per_word, extra_per_char)
9014    let total_width: f32 = items
9015        .iter()
9016        .map(|item| get_item_measure(item, is_vertical))
9017        .sum();
9018    let available_width = line_constraints.total_available;
9019
9020    if total_width >= available_width || available_width <= 0.0 {
9021        return (0.0, 0.0);
9022    }
9023
9024    let extra_space = available_width - total_width;
9025
9026    // +spec:text-alignment-spacing:71314a - script categories for justification: inter-word for clustered, kashida for cursive (Arabic), inter-character for block (CJK)
9027    match text_justify {
9028        JustifyContent::InterWord => {
9029            // Count justification opportunities (spaces).
9030            let space_count = items.iter().filter(|item| is_word_separator(item)).count();
9031            if space_count > 0 {
9032                (extra_space / space_count as f32, 0.0)
9033            } else {
9034                (0.0, 0.0) // No spaces to expand, do nothing.
9035            }
9036        }
9037        JustifyContent::InterCharacter | JustifyContent::Distribute => {
9038            // Count justification opportunities (between non-combining characters).
9039            let gap_count = items
9040                .iter()
9041                .enumerate()
9042                .filter(|(i, item)| *i < items.len() - 1 && can_justify_after(item))
9043                .count();
9044            if gap_count > 0 {
9045                (0.0, extra_space / gap_count as f32)
9046            } else {
9047                (0.0, 0.0) // No gaps to expand, do nothing.
9048            }
9049        }
9050        // Kashida justification modifies the item list and is handled by a separate function.
9051        _ => (0.0, 0.0),
9052    }
9053}
9054
9055/// Rebuilds a line of items, inserting Kashida glyphs for justification.
9056///
9057/// This function is non-mutating with respect to its inputs. It takes ownership of the
9058/// original items and returns a completely new `Vec`. This is necessary because Kashida
9059/// justification changes the number of items on the line, and must not modify cached data.
9060pub fn justify_kashida_and_rebuild<T: ParsedFontTrait>(
9061    items: Vec<ShapedItem>,
9062    line_constraints: &LineConstraints,
9063    is_vertical: bool,
9064    debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
9065    fonts: &LoadedFonts<T>,
9066) -> Vec<ShapedItem> {
9067    if let Some(msgs) = debug_messages {
9068        msgs.push(LayoutDebugMessage::info(
9069            "\n--- Entering justify_kashida_and_rebuild ---".to_string(),
9070        ));
9071    }
9072    let total_width: f32 = items
9073        .iter()
9074        .map(|item| get_item_measure(item, is_vertical))
9075        .sum();
9076    let available_width = line_constraints.total_available;
9077    if let Some(msgs) = debug_messages {
9078        msgs.push(LayoutDebugMessage::info(format!(
9079            "Total item width: {}, Available width: {}",
9080            total_width, available_width
9081        )));
9082    }
9083
9084    if total_width >= available_width || available_width <= 0.0 {
9085        if let Some(msgs) = debug_messages {
9086            msgs.push(LayoutDebugMessage::info(
9087                "No justification needed (line is full or invalid).".to_string(),
9088            ));
9089        }
9090        return items;
9091    }
9092
9093    let extra_space = available_width - total_width;
9094    if let Some(msgs) = debug_messages {
9095        msgs.push(LayoutDebugMessage::info(format!(
9096            "Extra space to fill: {}",
9097            extra_space
9098        )));
9099    }
9100
9101    let font_info = items.iter().find_map(|item| {
9102        if let ShapedItem::Cluster(c) = item {
9103            if let Some(glyph) = c.glyphs.first() {
9104                if glyph.script == Script::Arabic {
9105                    // Look up font from hash
9106                    if let Some(font) = fonts.get_by_hash(glyph.font_hash) {
9107                        return Some((
9108                            font.clone(),
9109                            glyph.font_hash,
9110                            glyph.font_metrics.clone(),
9111                            glyph.style.clone(),
9112                        ));
9113                    }
9114                }
9115            }
9116        }
9117        None
9118    });
9119
9120    let (font, font_hash, font_metrics, style) = match font_info {
9121        Some(info) => {
9122            if let Some(msgs) = debug_messages {
9123                msgs.push(LayoutDebugMessage::info(
9124                    "Found Arabic font for kashida.".to_string(),
9125                ));
9126            }
9127            info
9128        }
9129        None => {
9130            if let Some(msgs) = debug_messages {
9131                msgs.push(LayoutDebugMessage::info(
9132                    "No Arabic font found on line. Cannot insert kashidas.".to_string(),
9133                ));
9134            }
9135            return items;
9136        }
9137    };
9138
9139    let (kashida_glyph_id, kashida_advance) =
9140        match font.get_kashida_glyph_and_advance(style.font_size_px) {
9141            Some((id, adv)) if adv > 0.0 => {
9142                if let Some(msgs) = debug_messages {
9143                    msgs.push(LayoutDebugMessage::info(format!(
9144                        "Font provides kashida glyph with advance {}",
9145                        adv
9146                    )));
9147                }
9148                (id, adv)
9149            }
9150            _ => {
9151                if let Some(msgs) = debug_messages {
9152                    msgs.push(LayoutDebugMessage::info(
9153                        "Font does not support kashida justification.".to_string(),
9154                    ));
9155                }
9156                return items;
9157            }
9158        };
9159
9160    let opportunity_indices: Vec<usize> = items
9161        .windows(2)
9162        .enumerate()
9163        .filter_map(|(i, window)| {
9164            if let (ShapedItem::Cluster(cur), ShapedItem::Cluster(next)) = (&window[0], &window[1])
9165            {
9166                if is_arabic_cluster(cur)
9167                    && is_arabic_cluster(next)
9168                    && !is_word_separator(&window[1])
9169                {
9170                    return Some(i + 1);
9171                }
9172            }
9173            None
9174        })
9175        .collect();
9176
9177    if let Some(msgs) = debug_messages {
9178        msgs.push(LayoutDebugMessage::info(format!(
9179            "Found {} kashida insertion opportunities at indices: {:?}",
9180            opportunity_indices.len(),
9181            opportunity_indices
9182        )));
9183    }
9184
9185    if opportunity_indices.is_empty() {
9186        if let Some(msgs) = debug_messages {
9187            msgs.push(LayoutDebugMessage::info(
9188                "No opportunities found. Exiting.".to_string(),
9189            ));
9190        }
9191        return items;
9192    }
9193
9194    let num_kashidas_to_insert = (extra_space / kashida_advance).floor() as usize;
9195    if let Some(msgs) = debug_messages {
9196        msgs.push(LayoutDebugMessage::info(format!(
9197            "Calculated number of kashidas to insert: {}",
9198            num_kashidas_to_insert
9199        )));
9200    }
9201
9202    if num_kashidas_to_insert == 0 {
9203        return items;
9204    }
9205
9206    let kashidas_per_point = num_kashidas_to_insert / opportunity_indices.len();
9207    let mut remainder = num_kashidas_to_insert % opportunity_indices.len();
9208    if let Some(msgs) = debug_messages {
9209        msgs.push(LayoutDebugMessage::info(format!(
9210            "Distributing kashidas: {} per point, with {} remainder.",
9211            kashidas_per_point, remainder
9212        )));
9213    }
9214
9215    let kashida_item = {
9216        /* ... as before ... */
9217        let kashida_glyph = ShapedGlyph {
9218            kind: GlyphKind::Kashida {
9219                width: kashida_advance,
9220            },
9221            glyph_id: kashida_glyph_id,
9222            font_hash,
9223            font_metrics: font_metrics.clone(),
9224            style: style.clone(),
9225            script: Script::Arabic,
9226            advance: kashida_advance,
9227            kerning: 0.0,
9228            cluster_offset: 0,
9229            offset: Point::default(),
9230            vertical_advance: 0.0,
9231            vertical_offset: Point::default(),
9232        };
9233        ShapedItem::Cluster(ShapedCluster {
9234            text: "\u{0640}".to_string(),
9235            source_cluster_id: GraphemeClusterId {
9236                source_run: u32::MAX,
9237                start_byte_in_run: u32::MAX,
9238            },
9239            source_content_index: ContentIndex {
9240                run_index: u32::MAX,
9241                item_index: u32::MAX,
9242            },
9243            source_node_id: None, // Kashida is generated, not from DOM
9244            glyphs: smallvec![kashida_glyph],
9245            advance: kashida_advance,
9246            direction: BidiDirection::Ltr,
9247            style,
9248            marker_position_outside: None,
9249            is_first_fragment: true,
9250            is_last_fragment: true,
9251        })
9252    };
9253
9254    let mut new_items = Vec::with_capacity(items.len() + num_kashidas_to_insert);
9255    let mut last_copy_idx = 0;
9256    for &point in &opportunity_indices {
9257        new_items.extend_from_slice(&items[last_copy_idx..point]);
9258        let mut num_to_insert = kashidas_per_point;
9259        if remainder > 0 {
9260            num_to_insert += 1;
9261            remainder -= 1;
9262        }
9263        for _ in 0..num_to_insert {
9264            new_items.push(kashida_item.clone());
9265        }
9266        last_copy_idx = point;
9267    }
9268    new_items.extend_from_slice(&items[last_copy_idx..]);
9269
9270    if let Some(msgs) = debug_messages {
9271        msgs.push(LayoutDebugMessage::info(format!(
9272            "--- Exiting justify_kashida_and_rebuild, new item count: {} ---",
9273            new_items.len()
9274        )));
9275    }
9276    new_items
9277}
9278
9279/// Helper to determine if a cluster belongs to the Arabic script.
9280fn is_arabic_cluster(cluster: &ShapedCluster) -> bool {
9281    // A cluster is considered Arabic if its first non-NotDef glyph is from the Arabic script.
9282    // This is a robust heuristic for mixed-script lines.
9283    cluster.glyphs.iter().any(|g| g.script == Script::Arabic)
9284}
9285
9286/// Helper to identify if an item is a word separator (like a space).
9287fn measure_trailing_whitespace(items: &[ShapedItem], is_vertical: bool) -> f32 {
9288    let mut trailing_ws = 0.0;
9289    for item in items.iter().rev() {
9290        if is_collapsible_whitespace(item) {
9291            trailing_ws += get_item_measure(item, is_vertical);
9292        } else {
9293            break;
9294        }
9295    }
9296    trailing_ws
9297}
9298
9299/// Returns true if the item is collapsible whitespace per CSS Text 3 §4.1.2 Phase II.
9300/// This is used for stripping leading/trailing whitespace at line edges —
9301/// distinct from `is_word_separator` which is for word-spacing per §7.1.
9302pub fn is_collapsible_whitespace(item: &ShapedItem) -> bool {
9303    if let ShapedItem::Cluster(c) = item {
9304        c.text.chars().all(|ch| matches!(ch,
9305            ' ' | '\t' | '\u{1680}' // Ogham space mark (collapsible per spec)
9306        ))
9307    } else {
9308        false
9309    }
9310}
9311
9312// +spec:text-alignment-spacing:456643 - cursive scripts do not admit letter-spacing gaps
9313/// Returns true if the cluster's first character belongs to a cursive script
9314/// (Arabic, Syriac, Mongolian, N'Ko, Mandaic, Phags Pa, Hanifi Rohingya)
9315/// per CSS Text 3 Appendix D. These scripts should not have letter-spacing applied.
9316pub fn is_cursive_script_cluster(c: &ShapedCluster) -> bool {
9317    c.text.chars().next().map_or(false, |ch| is_cursive_script_char(ch))
9318}
9319
9320fn is_cursive_script_char(ch: char) -> bool {
9321    let cp = ch as u32;
9322    // Arabic (U+0600–U+06FF, U+0750–U+077F, U+08A0–U+08FF, U+FB50–U+FDFF, U+FE70–U+FEFF)
9323    if (0x0600..=0x06FF).contains(&cp) { return true; }
9324    if (0x0750..=0x077F).contains(&cp) { return true; }
9325    if (0x08A0..=0x08FF).contains(&cp) { return true; }
9326    if (0xFB50..=0xFDFF).contains(&cp) { return true; }
9327    if (0xFE70..=0xFEFF).contains(&cp) { return true; }
9328    // Syriac (U+0700–U+074F)
9329    if (0x0700..=0x074F).contains(&cp) { return true; }
9330    // Mongolian (U+1800–U+18AF)
9331    if (0x1800..=0x18AF).contains(&cp) { return true; }
9332    // N'Ko (U+07C0–U+07FF)
9333    if (0x07C0..=0x07FF).contains(&cp) { return true; }
9334    // Mandaic (U+0840–U+085F)
9335    if (0x0840..=0x085F).contains(&cp) { return true; }
9336    // Phags Pa (U+A840–U+A87F)
9337    if (0xA840..=0xA87F).contains(&cp) { return true; }
9338    // Hanifi Rohingya (U+10D00–U+10D3F)
9339    if (0x10D00..=0x10D3F).contains(&cp) { return true; }
9340    false
9341}
9342
9343// exclude punctuation and fixed-width spaces (U+3000, U+2000..U+200A)
9344pub fn is_word_separator(item: &ShapedItem) -> bool {
9345    if let ShapedItem::Cluster(c) = item {
9346        c.text.chars().any(|g| is_word_separator_char(g))
9347    } else {
9348        false
9349    }
9350}
9351
9352// +spec:margin-collapsing:6706c1 - fixed-width spaces (U+2000–U+200A, U+3000) excluded from word separators
9353/// Returns true if the character is a word-separator character per CSS Text §7.1.
9354/// Punctuation and fixed-width spaces (U+3000, U+2000 through U+200A) are NOT
9355/// word-separator characters even though they may visually separate words.
9356// +spec:text-alignment-spacing:3e0655 - word-separator characters for word-spacing
9357fn is_word_separator_char(c: char) -> bool {
9358    match c {
9359        // Standard ASCII space
9360        '\u{0020}' => true,
9361        // NO-BREAK SPACE
9362        '\u{00A0}' => true,
9363        // OGHAM SPACE MARK
9364        '\u{1680}' => true,
9365        // ETHIOPIC WORDSPACE (spec §7.1)
9366        '\u{1361}' => true,
9367        // Fixed-width spaces: NOT word separators per spec
9368        '\u{2000}'..='\u{200A}' => false,
9369        // NARROW NO-BREAK SPACE
9370        '\u{202F}' => true,
9371        // MEDIUM MATHEMATICAL SPACE
9372        '\u{205F}' => true,
9373        // IDEOGRAPHIC SPACE: NOT a word separator per spec
9374        '\u{3000}' => false,
9375        // AEGEAN WORD SEPARATOR LINE (spec §7.1)
9376        '\u{10100}' => true,
9377        // AEGEAN WORD SEPARATOR DOT (spec §7.1)
9378        '\u{10101}' => true,
9379        // UGARITIC WORD DIVIDER (spec §7.1)
9380        '\u{1039F}' => true,
9381        // PHOENICIAN WORD SEPARATOR (spec §7.1)
9382        '\u{1091F}' => true,
9383        // Other Unicode whitespace not listed above
9384        _ => false,
9385    }
9386}
9387
9388/// Helper to identify if an item is a zero-width space (U+200B),
9389/// which provides a soft wrap opportunity with no visible width.
9390/// Used in scripts like Thai, Lao, and Khmer that don't use spaces between words.
9391// +spec:line-breaking:fd3164 - U+200B as explicit word delimiter for scripts without space-separated words
9392pub fn is_zero_width_space(item: &ShapedItem) -> bool {
9393    if let ShapedItem::Cluster(c) = item {
9394        c.text.contains('\u{200B}')
9395    } else {
9396        false
9397    }
9398}
9399
9400/// Helper to identify if space can be added after an item.
9401fn can_justify_after(item: &ShapedItem) -> bool {
9402    if let ShapedItem::Cluster(c) = item {
9403        c.text.chars().last().map_or(false, |g| {
9404            !g.is_whitespace() && classify_character(g as u32) != CharacterClass::Combining
9405        })
9406    } else {
9407        // Per CSS 2.2 §9.4.2, justification must NOT stretch inline-table and
9408        // inline-block boxes. Object items represent these atomic inline-level
9409        // boxes, so we return false to prevent adding justification space after them.
9410        false
9411    }
9412}
9413
9414// +spec:font-metrics:b8eb97 - Script group classification for justification/letter-spacing behavior
9415/// Classifies a character for layout purposes (e.g., justification behavior).
9416/// Copied from `mod.rs`.
9417fn classify_character(codepoint: u32) -> CharacterClass {
9418    match codepoint {
9419        0x0020 | 0x00A0 | 0x3000 => CharacterClass::Space,
9420        0x0021..=0x002F | 0x003A..=0x0040 | 0x005B..=0x0060 | 0x007B..=0x007E => {
9421            CharacterClass::Punctuation
9422        }
9423        0x4E00..=0x9FFF | 0x3400..=0x4DBF => CharacterClass::Ideograph,
9424        0x0300..=0x036F | 0x1AB0..=0x1AFF => CharacterClass::Combining,
9425        // Mongolian script range
9426        0x1800..=0x18AF => CharacterClass::Letter,
9427        _ => CharacterClass::Letter,
9428    }
9429}
9430
9431/// Helper to get the primary measure (width or height) of a shaped item.
9432pub fn get_item_measure(item: &ShapedItem, is_vertical: bool) -> f32 {
9433    match item {
9434        ShapedItem::Cluster(c) => {
9435            // Total width = base advance + kerning adjustments
9436            // Kerning is stored separately in glyphs for inspection, but the total
9437            // cluster width must include it for correct layout positioning
9438            let total_kerning: f32 = c.glyphs.iter().map(|g| g.kerning).sum();
9439            c.advance + total_kerning
9440        }
9441        ShapedItem::Object { bounds, .. }
9442        | ShapedItem::CombinedBlock { bounds, .. }
9443        | ShapedItem::Tab { bounds, .. } => {
9444            if is_vertical {
9445                bounds.height
9446            } else {
9447                bounds.width
9448            }
9449        }
9450        ShapedItem::Break { .. } => 0.0,
9451    }
9452}
9453
9454/// Calculates the available horizontal segments for a line at a given vertical position,
9455/// considering both shape boundaries and exclusions.
9456fn get_line_constraints(
9457    line_y: f32,
9458    line_height: f32,
9459    constraints: &UnifiedConstraints,
9460    debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
9461) -> LineConstraints {
9462    if let Some(msgs) = debug_messages {
9463        msgs.push(LayoutDebugMessage::info(format!(
9464            "\n--- Entering get_line_constraints for y={} ---",
9465            line_y
9466        )));
9467    }
9468
9469    let mut available_segments = Vec::new();
9470    if constraints.shape_boundaries.is_empty() {
9471        // The segment_width is determined by available_width, NOT by TextWrap.
9472        // TextWrap::NoWrap only affects whether the LineBreaker can insert soft breaks,
9473        // it should NOT override a definite width constraint from CSS.
9474        // +spec:overflow:b06c3e - text overflows when wrapping is prevented (e.g. white-space: nowrap)
9475        // CSS Text Level 3: For 'white-space: pre/nowrap', text overflows horizontally
9476        // if it doesn't fit, rather than expanding the container.
9477        //
9478        // For MinContent/MaxContent intrinsic sizing: use a large value to let text 
9479        // lay out fully. The line breaker handles min-content by breaking at word 
9480        // boundaries. The actual content width is measured from the laid-out lines.
9481        let segment_width = match constraints.available_width {
9482            AvailableSpace::Definite(w) => w, // Respect definite width from CSS
9483            AvailableSpace::MaxContent => f32::MAX / 2.0, // For intrinsic max-content sizing
9484            AvailableSpace::MinContent => f32::MAX / 2.0, // For intrinsic min-content sizing
9485        };
9486        // Note: TextWrap::NoWrap is handled by the LineBreaker in break_one_line()
9487        // to prevent soft wraps. The text will simply overflow if it exceeds segment_width.
9488        available_segments.push(LineSegment {
9489            start_x: 0.0,
9490            width: segment_width,
9491            priority: 0,
9492        });
9493    } else {
9494        // ... complex boundary logic ...
9495    }
9496
9497    if let Some(msgs) = debug_messages {
9498        msgs.push(LayoutDebugMessage::info(format!(
9499            "Initial available segments: {:?}",
9500            available_segments
9501        )));
9502    }
9503
9504    for (idx, exclusion) in constraints.shape_exclusions.iter().enumerate() {
9505        if let Some(msgs) = debug_messages {
9506            msgs.push(LayoutDebugMessage::info(format!(
9507                "Applying exclusion #{}: {:?}",
9508                idx, exclusion
9509            )));
9510        }
9511        let exclusion_spans =
9512            get_shape_horizontal_spans(exclusion, line_y, line_height).unwrap_or_default();
9513        if let Some(msgs) = debug_messages {
9514            msgs.push(LayoutDebugMessage::info(format!(
9515                "  Exclusion spans at y={}: {:?}",
9516                line_y, exclusion_spans
9517            )));
9518        }
9519
9520        if exclusion_spans.is_empty() {
9521            continue;
9522        }
9523
9524        let mut next_segments = Vec::new();
9525        for (excl_start, excl_end) in exclusion_spans {
9526            for segment in &available_segments {
9527                let seg_start = segment.start_x;
9528                let seg_end = segment.start_x + segment.width;
9529
9530                // Create new segments by subtracting the exclusion
9531                if seg_end > excl_start && seg_start < excl_end {
9532                    if seg_start < excl_start {
9533                        // Left part
9534                        next_segments.push(LineSegment {
9535                            start_x: seg_start,
9536                            width: excl_start - seg_start,
9537                            priority: segment.priority,
9538                        });
9539                    }
9540                    if seg_end > excl_end {
9541                        // Right part
9542                        next_segments.push(LineSegment {
9543                            start_x: excl_end,
9544                            width: seg_end - excl_end,
9545                            priority: segment.priority,
9546                        });
9547                    }
9548                } else {
9549                    next_segments.push(segment.clone()); // No overlap
9550                }
9551            }
9552            available_segments = merge_segments(next_segments);
9553            next_segments = Vec::new();
9554        }
9555        if let Some(msgs) = debug_messages {
9556            msgs.push(LayoutDebugMessage::info(format!(
9557                "  Segments after exclusion #{}: {:?}",
9558                idx, available_segments
9559            )));
9560        }
9561    }
9562
9563    let total_width = available_segments.iter().map(|s| s.width).sum();
9564    if let Some(msgs) = debug_messages {
9565        msgs.push(LayoutDebugMessage::info(format!(
9566            "Final segments: {:?}, total available width: {}",
9567            available_segments, total_width
9568        )));
9569        msgs.push(LayoutDebugMessage::info(
9570            "--- Exiting get_line_constraints ---".to_string(),
9571        ));
9572    }
9573
9574    LineConstraints {
9575        segments: available_segments,
9576        total_available: total_width,
9577    }
9578}
9579
9580/// Helper function to get the horizontal spans of any shape at a given y-coordinate.
9581/// Returns a list of (start_x, end_x) tuples.
9582fn get_shape_horizontal_spans(
9583    shape: &ShapeBoundary,
9584    y: f32,
9585    line_height: f32,
9586) -> Result<Vec<(f32, f32)>, LayoutError> {
9587    match shape {
9588        ShapeBoundary::Rectangle(rect) => {
9589            // Check for any overlap between the line box [y, y + line_height]
9590            // and the rectangle's vertical span [rect.y, rect.y + rect.height].
9591            let line_start = y;
9592            let line_end = y + line_height;
9593            let rect_start = rect.y;
9594            let rect_end = rect.y + rect.height;
9595
9596            if line_start < rect_end && line_end > rect_start {
9597                Ok(vec![(rect.x, rect.x + rect.width)])
9598            } else {
9599                Ok(vec![])
9600            }
9601        }
9602        ShapeBoundary::Circle { center, radius } => {
9603            let line_center_y = y + line_height / 2.0;
9604            let dy = (line_center_y - center.y).abs();
9605            if dy <= *radius {
9606                let dx = (radius.powi(2) - dy.powi(2)).sqrt();
9607                Ok(vec![(center.x - dx, center.x + dx)])
9608            } else {
9609                Ok(vec![])
9610            }
9611        }
9612        ShapeBoundary::Ellipse { center, radii } => {
9613            let line_center_y = y + line_height / 2.0;
9614            let dy = line_center_y - center.y;
9615            if dy.abs() <= radii.height {
9616                // Formula: (x-h)^2/a^2 + (y-k)^2/b^2 = 1
9617                let y_term = dy / radii.height;
9618                let x_term_squared = 1.0 - y_term.powi(2);
9619                if x_term_squared >= 0.0 {
9620                    let dx = radii.width * x_term_squared.sqrt();
9621                    Ok(vec![(center.x - dx, center.x + dx)])
9622                } else {
9623                    Ok(vec![])
9624                }
9625            } else {
9626                Ok(vec![])
9627            }
9628        }
9629        ShapeBoundary::Polygon { points } => {
9630            let segments = polygon_line_intersection(points, y, line_height)?;
9631            Ok(segments
9632                .iter()
9633                .map(|s| (s.start_x, s.start_x + s.width))
9634                .collect())
9635        }
9636        ShapeBoundary::Path { .. } => Ok(vec![]), // TODO!
9637    }
9638}
9639
9640/// Merges overlapping or adjacent line segments into larger ones.
9641fn merge_segments(mut segments: Vec<LineSegment>) -> Vec<LineSegment> {
9642    if segments.len() <= 1 {
9643        return segments;
9644    }
9645    segments.sort_by(|a, b| a.start_x.partial_cmp(&b.start_x).unwrap());
9646    let mut merged = vec![segments[0].clone()];
9647    for next_seg in segments.iter().skip(1) {
9648        let last = merged.last_mut().unwrap();
9649        if next_seg.start_x <= last.start_x + last.width {
9650            let new_width = (next_seg.start_x + next_seg.width) - last.start_x;
9651            last.width = last.width.max(new_width);
9652        } else {
9653            merged.push(next_seg.clone());
9654        }
9655    }
9656    merged
9657}
9658
9659/// Computes horizontal line segments where a polygon intersects a scanline at the given y range.
9660fn polygon_line_intersection(
9661    points: &[Point],
9662    y: f32,
9663    line_height: f32,
9664) -> Result<Vec<LineSegment>, LayoutError> {
9665    if points.len() < 3 {
9666        return Ok(vec![]);
9667    }
9668
9669    let line_center_y = y + line_height / 2.0;
9670    let mut intersections = Vec::new();
9671
9672    // Use winding number algorithm for robustness with complex polygons.
9673    for i in 0..points.len() {
9674        let p1 = points[i];
9675        let p2 = points[(i + 1) % points.len()];
9676
9677        // Skip horizontal edges as they don't intersect a horizontal scanline in a meaningful way.
9678        if (p2.y - p1.y).abs() < f32::EPSILON {
9679            continue;
9680        }
9681
9682        // Check if our horizontal scanline at `line_center_y` crosses this polygon edge.
9683        let crosses = (p1.y <= line_center_y && p2.y > line_center_y)
9684            || (p1.y > line_center_y && p2.y <= line_center_y);
9685
9686        if crosses {
9687            // Calculate intersection x-coordinate using linear interpolation.
9688            let t = (line_center_y - p1.y) / (p2.y - p1.y);
9689            let x = p1.x + t * (p2.x - p1.x);
9690            intersections.push(x);
9691        }
9692    }
9693
9694    // Sort intersections by x-coordinate to form spans.
9695    intersections.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
9696
9697    // Build segments from paired intersection points.
9698    let mut segments = Vec::new();
9699    for chunk in intersections.chunks_exact(2) {
9700        let start_x = chunk[0];
9701        let end_x = chunk[1];
9702        if end_x > start_x {
9703            segments.push(LineSegment {
9704                start_x,
9705                width: end_x - start_x,
9706                priority: 0,
9707            });
9708        }
9709    }
9710
9711    Ok(segments)
9712}
9713
9714// ADDITION: A helper function to get a hyphenator.
9715/// Helper to get a hyphenator for a given language.
9716/// TODO: In a real app, this would be cached.
9717#[cfg(feature = "text_layout_hyphenation")]
9718fn get_hyphenator(language: HyphenationLanguage) -> Result<Standard, LayoutError> {
9719    Standard::from_embedded(language).map_err(|e| LayoutError::HyphenationError(e.to_string()))
9720}
9721
9722/// Stub when hyphenation is disabled - always returns an error
9723#[cfg(not(feature = "text_layout_hyphenation"))]
9724fn get_hyphenator(_language: Language) -> Result<Standard, LayoutError> {
9725    Err(LayoutError::HyphenationError("Hyphenation feature not enabled".to_string()))
9726}
9727
9728// +spec:inline-block:6e7dd9 - Non-tailorable Unicode line breaking controls take precedence over atomic inline rules (CSS-TEXT-3 recent changes, issue 8972)
9729
9730fn is_break_suppressing_control(ch: char) -> bool {
9731    matches!(ch,
9732        '\u{200D}' | // ZERO WIDTH JOINER
9733        '\u{2060}' | // WORD JOINER
9734        '\u{FEFF}'   // ZERO WIDTH NO-BREAK SPACE
9735    )
9736}
9737
9738fn is_break_forcing_control(ch: char) -> bool {
9739    matches!(ch,
9740        '\u{200B}' | // ZERO WIDTH SPACE (already handled but included for completeness)
9741        '\u{2028}' | // LINE SEPARATOR
9742        '\u{2029}'   // PARAGRAPH SEPARATOR
9743    )
9744}
9745
9746// +spec:line-breaking:495247 - CJK/syllabic writing systems allow breaks between typographic letter units with varying strictness
9747// §5.2 word-break: determines if a character is CJK ideograph/kana
9748fn is_cjk_character(ch: char) -> bool {
9749    let cp = ch as u32;
9750    matches!(cp,
9751        // CJK Unified Ideographs
9752        0x4E00..=0x9FFF |
9753        // CJK Unified Ideographs Extension A
9754        0x3400..=0x4DBF |
9755        // CJK Unified Ideographs Extension B
9756        0x20000..=0x2A6DF |
9757        // CJK Compatibility Ideographs
9758        0xF900..=0xFAFF |
9759        // Hiragana
9760        0x3040..=0x309F |
9761        // Katakana
9762        0x30A0..=0x30FF |
9763        // Katakana Phonetic Extensions
9764        0x31F0..=0x31FF |
9765        // CJK Symbols and Punctuation
9766        0x3000..=0x303F |
9767        // Halfwidth and Fullwidth Forms
9768        0xFF00..=0xFFEF |
9769        // Hangul Syllables
9770        0xAC00..=0xD7AF
9771    )
9772}
9773
9774// §5.2 word-break: checks if a cluster contains CJK characters
9775fn is_cjk_cluster(cluster: &ShapedCluster) -> bool {
9776    cluster.text.chars().any(is_cjk_character)
9777}
9778
9779// +spec:line-breaking:e1fc9d - word-break normal/break-all/keep-all break opportunity rules
9780// +spec:line-breaking:73d5fe - word-break break-point determination for CJK and Latin text
9781// +spec:line-breaking:31ef1a - word-break property controls soft wrap opportunities between letters (NU/AL/AI/ID classes as letter units)
9782// +spec:line-breaking:798252 - word-break property affects break opportunities (normal/break-all/keep-all)
9783// +spec:line-breaking:8fed57 - word-break: break-all treats all clusters as break opportunities, keep-all suppresses CJK breaks
9784// +spec:line-breaking:e2b374 - word-break: normal (only at separators) vs break-all (between all letters incl. Ethiopic)
9785// +spec:overflow:53a97f - word-break (normal/break-all/keep-all) and line-break strictness rules
9786// +spec:line-breaking:1c830a - word-break: normal/break-all/keep-all break opportunity rules
9787// §5.2 word-break property: break opportunity logic
9788// +spec:line-breaking:a75147 - word-break property: normal (CJK breaks), break-all (every cluster), keep-all (suppress CJK breaks)
9789// +spec:line-breaking:65ab41 - word-break: normal/break-all/keep-all break opportunity rules
9790// +spec:line-breaking:7eca16 - U+200B ZERO WIDTH SPACE is always a break opportunity, even with keep-all
9791fn is_break_opportunity_with_word_break(item: &ShapedItem, word_break: WordBreak, hyphens: Hyphens) -> bool {
9792    // Break after spaces or explicit break items (always, regardless of word-break).
9793    if is_word_separator(item) {
9794        return true;
9795    }
9796    if let ShapedItem::Break { .. } = item {
9797        return true;
9798    }
9799    // +spec:line-breaking:432d5b - hyphens property controls soft wrap opportunities via hyphenation
9800    // +spec:line-breaking:5a32a1 - soft hyphen (U+00AD) creates break opportunity; glyph styled per surrounding text properties
9801    // U+200B ZERO WIDTH SPACE is always a soft wrap opportunity regardless of word-break.
9802    // This allows authors to mark explicit wrap points (e.g. with <wbr> or &#x200B;)
9803    // even when using word-break: keep-all to suppress other breaks.
9804    if is_zero_width_space(item) {
9805        return true;
9806    }
9807    // only when hyphens != none. With hyphens:none, soft hyphens do not create break points.
9808    if hyphens != Hyphens::None {
9809        if let ShapedItem::Cluster(c) = item {
9810            if c.text.starts_with('\u{00AD}') {
9811                return true;
9812            }
9813        }
9814    }
9815
9816    // +spec:line-breaking:2bbda0 - word-break does not affect soft wrap opportunities around punctuation
9817    match word_break {
9818        WordBreak::Normal => {
9819            // CJK characters are implicit break opportunities in normal mode.
9820            if let ShapedItem::Cluster(c) = item {
9821                if is_cjk_cluster(c) {
9822                    return true;
9823                }
9824            }
9825            false
9826        }
9827        WordBreak::BreakAll => {
9828            // Every typographic letter unit is a break opportunity.
9829            if let ShapedItem::Cluster(_) = item {
9830                return true;
9831            }
9832            false
9833        }
9834        WordBreak::KeepAll => {
9835            // +spec:line-breaking:aa3044 - keep-all suppresses CJK (incl. Korean) inter-character breaks
9836            // Only break at spaces/hyphens (already handled above).
9837            false
9838        }
9839    }
9840}
9841
9842// +spec:line-breaking:db0289 - line-break strictness: anywhere allows soft wrap around every typographic character unit
9843// +spec:line-breaking:7d242b - line-break strictness levels: loose/normal/strict/anywhere with CJK punctuation rules
9844// +spec:line-breaking:67bfe8 - line-break strictness (auto/loose/normal/strict/anywhere) controls
9845// CSS Text Level 3 §5.3: Determines whether a break opportunity before a character is
9846// allowed based on the line-break strictness level. The spec defines:
9847// - strict: forbids breaks before small kana (class CJ), CJK hyphens, and certain punctuation
9848// - normal: allows breaks before small kana (CJ); allows CJK hyphen breaks for CJK writing systems
9849// - loose: additionally allows breaks before hyphens U+2010/U+2013 after ID-class chars
9850// - anywhere: allows soft wrap around every typographic character unit
9851fn is_cjk_break_allowed_by_strictness(
9852    ch: char,
9853    _prev_ch: Option<char>,
9854    strictness: LineBreakStrictness,
9855) -> bool {
9856    match strictness {
9857        LineBreakStrictness::Anywhere => true,
9858        LineBreakStrictness::Loose => {
9859            // Loose allows breaks before hyphens U+2010, U+2013 when preceded by ID-class chars
9860            // Also allows breaks before small kana (CJ class) and CJK hyphens
9861            true
9862        }
9863        LineBreakStrictness::Normal | LineBreakStrictness::Auto => {
9864            // Normal forbids breaks before hyphens U+2010/U+2013 for non-CJK text
9865            // but allows breaks before small kana (CJ) and CJK hyphen-like chars
9866            // (〜 U+301C, ゠ U+30A0) for CJK writing systems
9867            match ch {
9868                '\u{2010}' | '\u{2013}' => false, // hyphens forbidden in normal
9869                _ => true,
9870            }
9871        }
9872        LineBreakStrictness::Strict => {
9873            // Strict forbids breaks before:
9874            // - Small kana and prolonged sound mark (Unicode line break class CJ)
9875            // - CJK hyphen-like characters: 〜 U+301C, ゠ U+30A0
9876            // - Hyphens: ‐ U+2010, – U+2013
9877            match ch {
9878                '\u{301C}' | '\u{30A0}' => false, // CJK hyphen-like
9879                '\u{2010}' | '\u{2013}' => false,  // hyphens
9880                c if is_small_kana(c) => false,
9881                _ => true,
9882            }
9883        }
9884    }
9885}
9886
9887/// Returns true if the character is a Japanese small kana or Katakana-Hiragana prolonged sound mark
9888/// (Unicode line break class CJ). These are forbidden break points in strict line breaking.
9889fn is_small_kana(ch: char) -> bool {
9890    matches!(ch,
9891        '\u{3041}' | // ぁ HIRAGANA LETTER SMALL A
9892        '\u{3043}' | // ぃ HIRAGANA LETTER SMALL I
9893        '\u{3045}' | // ぅ HIRAGANA LETTER SMALL U
9894        '\u{3047}' | // ぇ HIRAGANA LETTER SMALL E
9895        '\u{3049}' | // ぉ HIRAGANA LETTER SMALL O
9896        '\u{3063}' | // っ HIRAGANA LETTER SMALL TU
9897        '\u{3083}' | // ゃ HIRAGANA LETTER SMALL YA
9898        '\u{3085}' | // ゅ HIRAGANA LETTER SMALL YU
9899        '\u{3087}' | // ょ HIRAGANA LETTER SMALL YO
9900        '\u{308E}' | // ゎ HIRAGANA LETTER SMALL WA
9901        '\u{3095}' | // ゕ HIRAGANA LETTER SMALL KA
9902        '\u{3096}' | // ゖ HIRAGANA LETTER SMALL KE
9903        '\u{30A1}' | // ァ KATAKANA LETTER SMALL A
9904        '\u{30A3}' | // ィ KATAKANA LETTER SMALL I
9905        '\u{30A5}' | // ゥ KATAKANA LETTER SMALL U
9906        '\u{30A7}' | // ェ KATAKANA LETTER SMALL E
9907        '\u{30A9}' | // ォ KATAKANA LETTER SMALL O
9908        '\u{30C3}' | // ッ KATAKANA LETTER SMALL TU
9909        '\u{30E3}' | // ャ KATAKANA LETTER SMALL YA
9910        '\u{30E5}' | // ュ KATAKANA LETTER SMALL YU
9911        '\u{30E7}' | // ョ KATAKANA LETTER SMALL YO
9912        '\u{30EE}' | // ヮ KATAKANA LETTER SMALL WA
9913        '\u{30F5}' | // ヵ KATAKANA LETTER SMALL KA
9914        '\u{30F6}' | // ヶ KATAKANA LETTER SMALL KE
9915        '\u{30FC}'   // ー KATAKANA-HIRAGANA PROLONGED SOUND MARK
9916    )
9917}
9918
9919// for every typographic character unit, disregarding GL/WJ/ZWJ line breaking classes
9920// replaced element or other atomic inline for web-compat
9921fn is_break_opportunity(item: &ShapedItem) -> bool {
9922    // Per CSS Text 3 §5.1: "there is a soft wrap opportunity before and
9923    // after each replaced element or other atomic inline"
9924    if matches!(item, ShapedItem::Object { .. } | ShapedItem::CombinedBlock { .. }) {
9925        return true;
9926    }
9927    // over atomic inline rules: break-forcing controls (ZWSP, LS, PS) create break opportunities
9928    // even adjacent to atomic inlines, while break-suppressing controls (WJ, ZWJ, ZWNBSP)
9929    // prevent breaks
9930    if let ShapedItem::Cluster(c) = item {
9931        // ZW (zero-width space U+200B) is always a break opportunity
9932        if c.text.contains('\u{200B}') {
9933            return true;
9934        }
9935        // Break-forcing Unicode controls (LS, PS) create break opportunities
9936        if c.text.chars().any(|ch| is_break_forcing_control(ch)) {
9937            return true;
9938        }
9939        // WJ (word joiner U+2060), ZWJ (U+200D), and GL (NBSP U+00A0) suppress breaks
9940        if c.text.chars().any(|ch| matches!(ch, '\u{2060}' | '\u{200D}' | '\u{00A0}')) {
9941            return false;
9942        }
9943        // +spec:line-breaking:05e09a - U+002D/U+2010 always create soft wrap opportunities regardless of hyphens property
9944        // are always visible and create a soft wrap opportunity after them, but are NOT
9945        // hyphenation opportunities (no extra glyph is inserted at the break).
9946        if c.text.ends_with('\u{002D}') || c.text.ends_with('\u{2010}') {
9947            return true;
9948        }
9949    }
9950    is_break_opportunity_with_word_break(item, WordBreak::Normal, Hyphens::Manual)
9951}
9952
9953// A cursor to manage the state of the line breaking process.
9954// This allows us to handle items that are partially consumed by hyphenation.
9955pub struct BreakCursor<'a> {
9956    /// A reference to the complete list of shaped items.
9957    pub items: &'a [ShapedItem],
9958    /// The index of the next *full* item to be processed from the `items` slice.
9959    pub next_item_index: usize,
9960    /// The remainder of an item that was split by hyphenation on the previous line.
9961    /// This will be the very first piece of content considered for the next line.
9962    pub partial_remainder: Vec<ShapedItem>,
9963    // §5.2 word-break property stored on cursor
9964    pub word_break: WordBreak,
9965    pub hyphens: Hyphens,
9966    pub line_break: LineBreakStrictness,
9967}
9968
9969impl<'a> BreakCursor<'a> {
9970    pub fn new(items: &'a [ShapedItem]) -> Self {
9971        Self {
9972            items,
9973            next_item_index: 0,
9974            partial_remainder: Vec::new(),
9975            word_break: WordBreak::Normal,
9976            hyphens: Hyphens::default(),
9977            line_break: LineBreakStrictness::default(),
9978        }
9979    }
9980
9981    pub fn with_word_break(items: &'a [ShapedItem], word_break: WordBreak) -> Self {
9982        Self {
9983            items,
9984            next_item_index: 0,
9985            partial_remainder: Vec::new(),
9986            word_break,
9987            hyphens: Hyphens::default(),
9988            line_break: LineBreakStrictness::default(),
9989        }
9990    }
9991
9992    /// Checks if the cursor is at the very beginning of the content stream.
9993    pub fn is_at_start(&self) -> bool {
9994        self.next_item_index == 0 && self.partial_remainder.is_empty()
9995    }
9996
9997    /// Consumes the cursor and returns all remaining items as a `Vec`.
9998    pub fn drain_remaining(&mut self) -> Vec<ShapedItem> {
9999        let mut remaining = std::mem::take(&mut self.partial_remainder);
10000        if self.next_item_index < self.items.len() {
10001            remaining.extend_from_slice(&self.items[self.next_item_index..]);
10002        }
10003        self.next_item_index = self.items.len();
10004        remaining
10005    }
10006
10007    /// Checks if all content, including any partial remainders, has been processed.
10008    pub fn is_done(&self) -> bool {
10009        self.next_item_index >= self.items.len() && self.partial_remainder.is_empty()
10010    }
10011
10012    /// Consumes a number of items from the cursor's stream.
10013    pub fn consume(&mut self, count: usize) {
10014        if count == 0 {
10015            return;
10016        }
10017
10018        let remainder_len = self.partial_remainder.len();
10019        if count <= remainder_len {
10020            // Consuming only from the remainder.
10021            self.partial_remainder.drain(..count);
10022        } else {
10023            // Consuming all of the remainder and some from the main list.
10024            let from_main_list = count - remainder_len;
10025            self.partial_remainder.clear();
10026            self.next_item_index += from_main_list;
10027        }
10028    }
10029
10030    /// Looks ahead and returns the next "unbreakable" unit of content.
10031    /// This is typically a word (a series of non-space clusters) followed by a
10032    /// space, or just a single space if that's next.
10033    /// The definition of "unbreakable unit" depends on the word-break property.
10034    // a single typographic character unit (every character is a soft wrap opportunity), including
10035    // punctuation and preserved white spaces; currently handled via peek_next_single_item
10036    pub fn peek_next_unit(&self) -> Vec<ShapedItem> {
10037        let mut unit = Vec::new();
10038        let mut source_items = self.partial_remainder.clone();
10039        source_items.extend_from_slice(&self.items[self.next_item_index..]);
10040
10041        if source_items.is_empty() {
10042            return unit;
10043        }
10044
10045        // If the first item is a break opportunity (like a space), it's a unit on its own.
10046        if is_break_opportunity_with_word_break(&source_items[0], self.word_break, self.hyphens) {
10047            unit.push(source_items[0].clone());
10048            return unit;
10049        }
10050
10051        // Otherwise, collect all items until the next break opportunity.
10052        // For break-all: each cluster is its own unit.
10053        // For keep-all: CJK sequences are NOT break opportunities.
10054        // For normal: CJK characters are individual break opportunities.
10055        // glue items together: if the last cluster ends with a break-suppressing control,
10056        // the next item cannot be separated from it.
10057        let mut suppress_next_break = false;
10058        for (i, item) in source_items.iter().enumerate() {
10059            // Also suppress break if this item starts with a break-suppressing control
10060            // (WJ/ZWJ/ZWNBSP suppress breaks on both sides per Unicode line breaking)
10061            let starts_with_suppress = if let ShapedItem::Cluster(c) = item {
10062                c.text.chars().next().map_or(false, |ch| is_break_suppressing_control(ch))
10063            } else {
10064                false
10065            };
10066            // If the item is a CJK cluster, check if the break is allowed by strictness
10067            let cjk_strictness_suppressed = if let ShapedItem::Cluster(c) = item {
10068                c.text.chars().next().map_or(false, |ch| {
10069                    !is_cjk_break_allowed_by_strictness(ch, None, self.line_break)
10070                })
10071            } else {
10072                false
10073            };
10074            if i > 0 && !suppress_next_break && !starts_with_suppress && !cjk_strictness_suppressed && is_break_opportunity_with_word_break(item, self.word_break, self.hyphens) {
10075                break;
10076            }
10077            suppress_next_break = false;
10078            unit.push(item.clone());
10079
10080            // Check if this item ends with a break-suppressing control character
10081            if let ShapedItem::Cluster(c) = item {
10082                if let Some(last_ch) = c.text.chars().last() {
10083                    if is_break_suppressing_control(last_ch) {
10084                        suppress_next_break = true;
10085                    }
10086                }
10087            }
10088
10089            // For break-all, each non-space cluster is a unit on its own
10090            if self.word_break == WordBreak::BreakAll {
10091                if let ShapedItem::Cluster(_) = item {
10092                    break;
10093                }
10094            }
10095        }
10096        unit
10097    }
10098
10099    pub fn peek_next_single_item(&self) -> Vec<ShapedItem> {
10100        if !self.partial_remainder.is_empty() {
10101            return vec![self.partial_remainder[0].clone()];
10102        }
10103        if self.next_item_index < self.items.len() {
10104            return vec![self.items[self.next_item_index].clone()];
10105        }
10106        Vec::new()
10107    }
10108}
10109
10110// A structured result from a hyphenation attempt.
10111struct HyphenationResult {
10112    /// The items that fit on the current line, including the new hyphen.
10113    line_part: Vec<ShapedItem>,
10114    /// The remainder of the split item to be carried over to the next line.
10115    remainder_part: Vec<ShapedItem>,
10116}
10117
10118fn perform_bidi_analysis<'a, 'b: 'a>(
10119    styled_runs: &'a [TextRunInfo],
10120    full_text: &'b str,
10121    force_lang: Option<Language>,
10122) -> Result<(Vec<VisualRun<'a>>, BidiDirection), LayoutError> {
10123    if full_text.is_empty() {
10124        return Ok((Vec::new(), BidiDirection::Ltr));
10125    }
10126
10127    let bidi_info = BidiInfo::new(full_text, None);
10128    let para = &bidi_info.paragraphs[0];
10129    let base_direction = if para.level.is_rtl() {
10130        BidiDirection::Rtl
10131    } else {
10132        BidiDirection::Ltr
10133    };
10134
10135    // Create a map from each byte index to its original styled run.
10136    let mut byte_to_run_index: Vec<usize> = vec![0; full_text.len()];
10137    for (run_idx, run) in styled_runs.iter().enumerate() {
10138        let start = run.logical_start;
10139        let end = start + run.text.len();
10140        for i in start..end {
10141            byte_to_run_index[i] = run_idx;
10142        }
10143    }
10144
10145    let mut final_visual_runs = Vec::new();
10146    let (levels, visual_run_ranges) = bidi_info.visual_runs(para, para.range.clone());
10147
10148    for range in visual_run_ranges {
10149        let bidi_level = levels[range.start];
10150        let mut sub_run_start = range.start;
10151
10152        // Iterate through the bytes of the visual run to detect style changes.
10153        for i in (range.start + 1)..range.end {
10154            if byte_to_run_index[i] != byte_to_run_index[sub_run_start] {
10155                // Style boundary found. Finalize the previous sub-run.
10156                let original_run_idx = byte_to_run_index[sub_run_start];
10157                let script = crate::text3::script::detect_script(&full_text[sub_run_start..i])
10158                    .unwrap_or(Script::Latin);
10159                final_visual_runs.push(VisualRun {
10160                    text_slice: &full_text[sub_run_start..i],
10161                    style: styled_runs[original_run_idx].style.clone(),
10162                    logical_start_byte: sub_run_start,
10163                    bidi_level: BidiLevel::new(bidi_level.number()),
10164                    language: force_lang.unwrap_or_else(|| {
10165                        crate::text3::script::script_to_language(
10166                            script,
10167                            &full_text[sub_run_start..i],
10168                        )
10169                    }),
10170                    script,
10171                });
10172                // Start a new sub-run.
10173                sub_run_start = i;
10174            }
10175        }
10176
10177        // Add the last sub-run (or the only one if no style change occurred).
10178        let original_run_idx = byte_to_run_index[sub_run_start];
10179        let script = crate::text3::script::detect_script(&full_text[sub_run_start..range.end])
10180            .unwrap_or(Script::Latin);
10181
10182        final_visual_runs.push(VisualRun {
10183            text_slice: &full_text[sub_run_start..range.end],
10184            style: styled_runs[original_run_idx].style.clone(),
10185            logical_start_byte: sub_run_start,
10186            bidi_level: BidiLevel::new(bidi_level.number()),
10187            script,
10188            language: force_lang.unwrap_or_else(|| {
10189                crate::text3::script::script_to_language(
10190                    script,
10191                    &full_text[sub_run_start..range.end],
10192                )
10193            }),
10194        });
10195    }
10196
10197    Ok((final_visual_runs, base_direction))
10198}
10199
10200fn get_justification_priority(class: CharacterClass) -> u8 {
10201    match class {
10202        CharacterClass::Space => 0,
10203        CharacterClass::Punctuation => 64,
10204        CharacterClass::Ideograph => 128,
10205        CharacterClass::Letter => 192,
10206        CharacterClass::Symbol => 224,
10207        CharacterClass::Combining => 255,
10208    }
10209}