azul_layout/text3/cache.rs
1//! Core types and layout pipeline for the text/inline formatting context.
2//!
3//! This module defines the central data structures (`UnifiedConstraints`,
4//! `LayoutCache`, `FontManager`, `UnifiedLayout`, etc.) and implements the
5//! 5-stage inline layout pipeline:
6//!
7//! 1. **Logical Analysis** — `InlineContent` → `LogicalItem`
8//! 2. **`BiDi` Reordering** — `LogicalItem` → `VisualItem`
9//! 3. **Shaping** — `VisualItem` → `ShapedItem`
10//! 4. **Text Orientation** — vertical writing-mode transforms
11//! 5. **Flow / Positioning** — line breaking + final `PositionedItem` placement
12//!
13//! The module also contains cursor movement helpers, caching infrastructure
14//! (per-item and monolithic), and font management (`FontContext`, `FontManager`,
15//! `LoadedFonts`). Integration with the box layout solver lives in
16//! `solver3/fc.rs`.
17
18use std::{
19 cmp::Ordering,
20 collections::{
21 hash_map::{DefaultHasher, HashMap},
22 BTreeSet, HashSet,
23 },
24 hash::{Hash, Hasher},
25 mem::discriminant,
26 num::NonZeroUsize,
27 sync::{Arc, Mutex},
28};
29
30pub use azul_core::selection::{ContentIndex, GraphemeClusterId};
31use azul_core::{
32 dom::NodeId,
33 geom::{LogicalPosition, LogicalRect, LogicalSize},
34 resources::ImageRef,
35 selection::{CursorAffinity, SelectionRange, TextCursor},
36 ui_solver::GlyphInstance,
37};
38use azul_css::{
39 corety::LayoutDebugMessage, props::basic::ColorU, props::style::StyleBackgroundContent,
40};
41#[cfg(feature = "text_layout_hyphenation")]
42use hyphenation::{Hyphenator, Language as HyphenationLanguage, Load, Standard};
43use rust_fontconfig::{FcFontCache, FcPattern, FcWeight, FontId, PatternMatch, UnicodeRange};
44use smallvec::{smallvec, SmallVec};
45use unicode_bidi::{BidiInfo, Level, TextSource};
46use unicode_segmentation::UnicodeSegmentation;
47
48// --- Named constants for layout heuristics ---
49
50/// Fraction of line-height used as ascent when no font metrics are available.
51/// Matches the typical 80/20 ascent/descent ratio found in Latin fonts.
52const FALLBACK_ASCENT_RATIO: f32 = 0.8;
53const FALLBACK_DESCENT_RATIO: f32 = 1.0 - FALLBACK_ASCENT_RATIO;
54
55// Strut/metric fallbacks below assume the CSS-initial 16px font size when no
56// explicit size is set.
57
58/// Default strut ascent: `FALLBACK_ASCENT_RATIO` * (16px * `DEFAULT_LINE_HEIGHT_FACTOR`)
59const DEFAULT_STRUT_ASCENT: f32 = 12.8;
60/// Default strut descent: `FALLBACK_DESCENT_RATIO` * (16px * `DEFAULT_LINE_HEIGHT_FACTOR`)
61const DEFAULT_STRUT_DESCENT: f32 = 3.2;
62
63/// Default x-height approximation: 0.5 * 16px (CSS spec fallback).
64const DEFAULT_X_HEIGHT: f32 = 8.0;
65/// Default ch-width (advance of '0'): 0.5 * 16px.
66const DEFAULT_CH_WIDTH: f32 = 8.0;
67
68/// Approximate space character width as a fraction of `font_size`.
69const SPACE_WIDTH_RATIO: f32 = 0.5;
70
71/// CSS subscript baseline offset as fraction of line ascent (CSS Inline §3).
72const SUBSCRIPT_OFFSET_RATIO: f32 = 0.3;
73/// CSS superscript baseline offset as fraction of line ascent (CSS Inline §3).
74const SUPERSCRIPT_OFFSET_RATIO: f32 = 0.4;
75
76/// Ruby annotation font size relative to the base, per the CSS UA stylesheet
77/// (`rt { font-size: 50% }`). Used to reserve placeholder width for the
78/// annotation so a long annotation is not clipped by a short base.
79const RUBY_ANNOTATION_FONT_SCALE: f32 = 0.5;
80
81/// Computes the reserved box size for a ruby pair (CSS Ruby Layout §3): the inline-size is
82/// the wider of the base and annotation runs (the narrower is centered over the wider), and
83/// the block-size stacks the annotation line above the base line so the base reserves
84/// vertical space for the annotation. Both inputs are REAL shaped advances / resolved line
85/// heights — no magic per-character ratio.
86fn ruby_reserved_box(
87 base_width: f32,
88 annotation_width: f32,
89 base_line_height: f32,
90 annotation_line_height: f32,
91) -> (f32, f32) {
92 (
93 base_width.max(annotation_width),
94 base_line_height + annotation_line_height,
95 )
96}
97
98/// Glyph storage for a single shaped cluster.
99///
100/// Inline one glyph (the
101/// common case for Latin text), spill to heap for ligatures / combining
102/// marks / multi-glyph clusters. The `union` feature of smallvec packs
103/// the inline buffer and the heap pointer into the same bytes, so sizeof
104/// stays `sizeof(ShapedGlyph) + 2*usize` regardless of inline/heap state.
105pub type ShapedGlyphVec = SmallVec<[ShapedGlyph; 1]>;
106
107/// CSS `line-height` value.
108///
109/// `Normal` defers resolution to the point where font metrics are available,
110/// computing `(ascent + |descent| + lineGap) / upem * fontSize`.
111/// `Px` is an already-resolved pixel value from an explicit CSS declaration
112/// (e.g. `line-height: 1.5` → `Px(fontSize * 1.5)`).
113#[derive(Debug, Clone, Copy)]
114#[derive(Default)]
115pub enum LineHeight {
116 /// `line-height: normal` — resolve from font metrics at layout time
117 #[default]
118 Normal,
119 /// Pre-resolved pixel value (from CSS `line-height: <number|length|percentage>`)
120 Px(f32),
121}
122
123
124impl LineHeight {
125 /// Resolve to a pixel value, using font metrics when `Normal`.
126 ///
127 /// `ascent`, `descent` (negative in OpenType convention), `line_gap` are in font units.
128 /// `font_size_px` and `units_per_em` are used to scale.
129 #[must_use] pub fn resolve(&self, font_size_px: f32, ascent: f32, descent: f32, line_gap: f32, units_per_em: u16) -> f32 {
130 match self {
131 Self::Px(px) => *px,
132 Self::Normal => {
133 if units_per_em == 0 {
134 return font_size_px * 1.2; // fallback
135 }
136 let scale = font_size_px / f32::from(units_per_em);
137 (ascent - descent + line_gap) * scale
138 }
139 }
140 }
141
142 /// Resolve using a `LayoutFontMetrics` struct for convenience.
143 #[must_use] pub fn resolve_with_metrics(&self, font_size_px: f32, metrics: &LayoutFontMetrics) -> f32 {
144 self.resolve(font_size_px, metrics.ascent, metrics.descent, metrics.line_gap, metrics.units_per_em)
145 }
146}
147
148impl PartialEq for LineHeight {
149 fn eq(&self, other: &Self) -> bool {
150 match (self, other) {
151 (Self::Normal, Self::Normal) => true,
152 (Self::Px(a), Self::Px(b)) => a.to_bits() == b.to_bits(),
153 _ => false,
154 }
155 }
156}
157
158impl Eq for LineHeight {}
159
160impl Hash for LineHeight {
161 fn hash<H: Hasher>(&self, state: &mut H) {
162 discriminant(self).hash(state);
163 if let Self::Px(v) = self {
164 v.to_bits().hash(state);
165 }
166 }
167}
168
169// Stub type when hyphenation is disabled
170#[cfg(not(feature = "text_layout_hyphenation"))]
171pub struct Standard;
172
173#[cfg(not(feature = "text_layout_hyphenation"))]
174impl Standard {
175 /// Stub hyphenate method that returns no breaks
176 pub fn hyphenate<'a>(&'a self, _word: &'a str) -> StubHyphenationBreaks {
177 StubHyphenationBreaks { breaks: Vec::new() }
178 }
179}
180
181/// Result of hyphenation (stub when feature is disabled)
182#[cfg(not(feature = "text_layout_hyphenation"))]
183pub struct StubHyphenationBreaks {
184 pub breaks: Vec<usize>,
185}
186
187// Always import Language from script module
188use crate::text3::script::{script_to_language, Language, Script};
189
190/// Available space for layout, similar to Taffy's `AvailableSpace`.
191///
192/// This type explicitly represents the three possible states for available space:
193///
194/// - `Definite(f32)`: A specific pixel width is available
195/// - `MinContent`: Layout should use minimum content width (shrink-wrap)
196/// - `MaxContent`: Layout should use maximum content width (no line breaks unless necessary)
197///
198/// This is critical for proper handling of intrinsic sizing in Flexbox/Grid
199/// where the available space may be indefinite during the measure phase.
200#[derive(Debug, Clone, Copy, PartialEq)]
201pub enum AvailableSpace {
202 /// A specific amount of space is available (in pixels).
203 /// Must be >= 0. A value of 0.0 means "genuinely zero-width container"
204 /// (e.g. `width: 0px`), NOT "unresolved".
205 Definite(f32),
206 /// The node should be laid out under a min-content constraint
207 MinContent,
208 /// The node should be laid out under a max-content constraint.
209 /// This is the correct default: "lay out to natural width, no constraint".
210 MaxContent,
211}
212
213impl Default for AvailableSpace {
214 /// Default is `MaxContent` — the absence of a width constraint.
215 /// Never `Definite(0.0)`, which would make every word overflow.
216 fn default() -> Self {
217 Self::MaxContent
218 }
219}
220
221impl AvailableSpace {
222 /// Returns true if this is a definite (finite, known) amount of space
223 #[must_use] pub const fn is_definite(&self) -> bool {
224 matches!(self, Self::Definite(_))
225 }
226
227 /// Returns true if this is an indefinite (min-content or max-content) constraint
228 #[must_use] pub const fn is_indefinite(&self) -> bool {
229 !self.is_definite()
230 }
231
232 /// Returns the definite value if available, or a fallback for indefinite constraints
233 #[must_use] pub const fn unwrap_or(self, fallback: f32) -> f32 {
234 match self {
235 Self::Definite(v) => v,
236 _ => fallback,
237 }
238 }
239
240 /// Returns the definite value, or a large value for both min-content and max-content.
241 ///
242 /// For intrinsic sizing, we use a large value to let text lay out fully,
243 /// then measure the result. The distinction between min/max-content is handled
244 /// by the line breaking algorithm, not by constraining the available width.
245 #[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
246 #[must_use] pub fn to_f32_for_layout(self) -> f32 {
247 match self {
248 Self::Definite(v) => v,
249 Self::MinContent => f32::MAX / 2.0,
250 Self::MaxContent => f32::MAX / 2.0,
251 }
252 }
253
254 /// Create from an f32 value, recognizing special sentinel values.
255 ///
256 /// This function provides backwards compatibility with code that uses f32 for constraints:
257 /// - `f32::INFINITY` or `f32::MAX` → `MaxContent` (no line wrapping)
258 /// - `0.0` → `MinContent` (maximum line wrapping, return longest word width)
259 /// - Other values → `Definite(value)`
260 ///
261 /// Note: Using sentinel values like 0.0 for `MinContent` is fragile. Prefer using
262 /// `AvailableSpace::MinContent` directly when possible.
263 #[must_use] pub fn from_f32(value: f32) -> Self {
264 if value.is_infinite() || value >= f32::MAX / 2.0 {
265 // Treat very large values (including f32::MAX) as MaxContent
266 Self::MaxContent
267 } else if value <= 0.0 {
268 // Treat zero or negative as MinContent (shrink-wrap)
269 Self::MinContent
270 } else {
271 Self::Definite(value)
272 }
273 }
274}
275
276impl Hash for AvailableSpace {
277 fn hash<H: Hasher>(&self, state: &mut H) {
278 discriminant(self).hash(state);
279 if let Self::Definite(v) = self {
280 // Hash the full f32 bit pattern, NOT the integer-rounded value. The
281 // derived `PartialEq` compares `Definite` widths exactly, so rounding
282 // here both (a) broke sub-pixel precision — a 100.1px vs 100.4px
283 // constraint can wrap lines differently yet collided in the same hash
284 // bucket — and (b) was inconsistent with the exact equality used as the
285 // cache key. `-0.0` is normalized to `+0.0` so the `+0.0 == -0.0`
286 // PartialEq pair still hashes identically (Hash/Eq contract).
287 let normalized = if *v == 0.0 { 0.0f32 } else { *v };
288 normalized.to_bits().hash(state);
289 }
290 }
291}
292
293// Re-export traits for backwards compatibility
294pub use crate::font_traits::{ParsedFontTrait, ShallowClone};
295
296// --- Core Data Structures for the New Architecture ---
297
298/// Key for caching font chains - based only on CSS properties, not text content
299#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
300pub struct FontChainKey {
301 pub font_families: Vec<String>,
302 pub weight: FcWeight,
303 pub italic: bool,
304 pub oblique: bool,
305}
306
307/// Either a `FontChainKey` (resolved via fontconfig) or a direct `FontRef` hash.
308///
309/// This enum cleanly separates:
310/// - `Chain`: Fonts resolved through fontconfig with fallback support
311/// - `Ref`: Direct `FontRef` that bypasses fontconfig entirely (e.g., embedded icon fonts)
312#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
313pub enum FontChainKeyOrRef {
314 /// Regular font chain resolved via fontconfig
315 Chain(FontChainKey),
316 /// Direct `FontRef` identified by pointer address (covers entire Unicode range, no fallbacks)
317 Ref(usize),
318}
319
320impl FontChainKeyOrRef {
321 /// Create from a `FontStack` enum
322 #[must_use] pub fn from_font_stack(font_stack: &FontStack) -> Self {
323 match font_stack {
324 FontStack::Stack(selectors) => Self::Chain(FontChainKey::from_selectors(selectors)),
325 FontStack::Ref(font_ref) => Self::Ref(font_ref.parsed as usize),
326 }
327 }
328
329 /// Returns true if this is a direct `FontRef`
330 #[must_use] pub const fn is_ref(&self) -> bool {
331 matches!(self, Self::Ref(_))
332 }
333
334 /// Returns the `FontRef` pointer if this is a Ref variant
335 #[must_use] pub const fn as_ref_ptr(&self) -> Option<usize> {
336 match self {
337 Self::Ref(ptr) => Some(*ptr),
338 Self::Chain(_) => None,
339 }
340 }
341
342 /// Returns the `FontChainKey` if this is a Chain variant
343 #[must_use] pub const fn as_chain(&self) -> Option<&FontChainKey> {
344 match self {
345 Self::Chain(key) => Some(key),
346 Self::Ref(_) => None,
347 }
348 }
349}
350
351impl FontChainKey {
352 /// Create a `FontChainKey` from a slice of font selectors
353 #[must_use] pub fn from_selectors(font_stack: &[FontSelector]) -> Self {
354 // (2026-06-10) FIRST-WINS DEDUP: cascaded font stacks can carry duplicate
355 // families (e.g. [serif, sans-serif, serif, monospace] when the UA fallback
356 // list is appended to a stack already naming serif). The pre-resolve
357 // collector dedupes its stacks, so without deduping HERE the shaping-time
358 // key never matched the stored key (the g121/g122 chain-lookup misses).
359 // This is THE canonical FontChainKey constructor — every key-build site
360 // must go through it so lookups match by construction.
361 let mut font_families: Vec<String> = Vec::new();
362 for sel in font_stack {
363 if sel.family.is_empty() || font_families.contains(&sel.family) {
364 continue;
365 }
366 font_families.push(sel.family.clone());
367 }
368
369 let font_families = if font_families.is_empty() {
370 vec!["serif".to_string()]
371 } else {
372 font_families
373 };
374
375 let weight = font_stack
376 .first()
377 .map_or(FcWeight::Normal, |s| s.weight);
378 let is_italic = font_stack
379 .first()
380 .is_some_and(|s| s.style == FontStyle::Italic);
381 let is_oblique = font_stack
382 .first()
383 .is_some_and(|s| s.style == FontStyle::Oblique);
384
385 Self {
386 font_families,
387 weight,
388 italic: is_italic,
389 oblique: is_oblique,
390 }
391 }
392}
393
394/// A map of pre-loaded fonts, keyed by `FontId` (from rust-fontconfig)
395///
396/// This is passed to the shaper - no font loading happens during shaping
397/// The fonts are loaded BEFORE layout based on the font chains and text content.
398///
399/// Provides both `FontId` and hash-based lookup for efficient glyph operations.
400#[derive(Debug, Clone)]
401pub struct LoadedFonts<T> {
402 /// Primary storage: `FontId` -> Font
403 pub fonts: HashMap<FontId, T>,
404 /// Reverse index: `font_hash` -> `FontId` for fast hash-based lookups
405 hash_to_id: HashMap<u64, FontId>,
406}
407
408impl<T: ParsedFontTrait> LoadedFonts<T> {
409 #[must_use] pub fn new() -> Self {
410 Self {
411 fonts: HashMap::new(),
412 hash_to_id: HashMap::new(),
413 }
414 }
415
416 /// Insert a font with its `FontId`
417 pub fn insert(&mut self, font_id: FontId, font: T) {
418 let hash = font.get_hash();
419 self.hash_to_id.insert(hash, font_id);
420 self.fonts.insert(font_id, font);
421 }
422
423 /// Get a font by `FontId`
424 #[must_use] pub fn get(&self, font_id: &FontId) -> Option<&T> {
425 self.fonts.get(font_id)
426 }
427
428 /// Get a font by its hash
429 #[must_use] pub fn get_by_hash(&self, hash: u64) -> Option<&T> {
430 self.hash_to_id.get(&hash).and_then(|id| self.fonts.get(id))
431 }
432
433 /// Get the `FontId` for a hash
434 #[must_use] pub fn get_font_id_by_hash(&self, hash: u64) -> Option<&FontId> {
435 self.hash_to_id.get(&hash)
436 }
437
438 /// Check if a `FontId` is present
439 #[must_use] pub fn contains_key(&self, font_id: &FontId) -> bool {
440 self.fonts.contains_key(font_id)
441 }
442
443 /// Check if a hash is present
444 #[must_use] pub fn contains_hash(&self, hash: u64) -> bool {
445 self.hash_to_id.contains_key(&hash)
446 }
447
448 /// Iterate over all fonts
449 pub fn iter(&self) -> impl Iterator<Item = (&FontId, &T)> {
450 self.fonts.iter()
451 }
452
453 /// Get the number of loaded fonts
454 #[must_use] pub fn len(&self) -> usize {
455 self.fonts.len()
456 }
457
458 /// Check if empty
459 #[must_use] pub fn is_empty(&self) -> bool {
460 self.fonts.is_empty()
461 }
462}
463
464impl<T: ParsedFontTrait> Default for LoadedFonts<T> {
465 fn default() -> Self {
466 Self::new()
467 }
468}
469
470impl<T: ParsedFontTrait> FromIterator<(FontId, T)> for LoadedFonts<T> {
471 fn from_iter<I: IntoIterator<Item = (FontId, T)>>(iter: I) -> Self {
472 let mut loaded = Self::new();
473 for (id, font) in iter {
474 loaded.insert(id, font);
475 }
476 loaded
477 }
478}
479
480/// Enum that wraps either a fontconfig-resolved font (T) or a direct `FontRef`.
481///
482/// This allows the shaping code to handle both fontconfig-resolved fonts
483/// and embedded fonts (`FontRef`) uniformly through the `ParsedFontTrait` interface.
484#[derive(Debug, Clone)]
485pub enum FontOrRef<T> {
486 /// A font loaded via fontconfig
487 Font(T),
488 /// A direct `FontRef` (embedded font, bypasses fontconfig)
489 Ref(azul_css::props::basic::FontRef),
490}
491
492impl<T: ParsedFontTrait> ShallowClone for FontOrRef<T> {
493 fn shallow_clone(&self) -> Self {
494 match self {
495 Self::Font(f) => Self::Font(f.shallow_clone()),
496 Self::Ref(r) => Self::Ref(r.clone()),
497 }
498 }
499}
500
501impl<T: ParsedFontTrait> ParsedFontTrait for FontOrRef<T> {
502 fn shape_text(
503 &self,
504 text: &str,
505 script: Script,
506 language: Language,
507 direction: BidiDirection,
508 style: &StyleProperties,
509 ) -> Result<Vec<Glyph>, LayoutError> {
510 match self {
511 Self::Font(f) => f.shape_text(text, script, language, direction, style),
512 Self::Ref(r) => r.shape_text(text, script, language, direction, style),
513 }
514 }
515
516 fn get_hash(&self) -> u64 {
517 match self {
518 Self::Font(f) => f.get_hash(),
519 Self::Ref(r) => r.get_hash(),
520 }
521 }
522
523 fn get_glyph_size(&self, glyph_id: u16, font_size: f32) -> Option<LogicalSize> {
524 match self {
525 Self::Font(f) => f.get_glyph_size(glyph_id, font_size),
526 Self::Ref(r) => r.get_glyph_size(glyph_id, font_size),
527 }
528 }
529
530 fn get_hyphen_glyph_and_advance(&self, font_size: f32) -> Option<(u16, f32)> {
531 match self {
532 Self::Font(f) => f.get_hyphen_glyph_and_advance(font_size),
533 Self::Ref(r) => r.get_hyphen_glyph_and_advance(font_size),
534 }
535 }
536
537 fn get_kashida_glyph_and_advance(&self, font_size: f32) -> Option<(u16, f32)> {
538 match self {
539 Self::Font(f) => f.get_kashida_glyph_and_advance(font_size),
540 Self::Ref(r) => r.get_kashida_glyph_and_advance(font_size),
541 }
542 }
543
544 fn has_glyph(&self, codepoint: u32) -> bool {
545 match self {
546 Self::Font(f) => f.has_glyph(codepoint),
547 Self::Ref(r) => r.has_glyph(codepoint),
548 }
549 }
550
551 fn get_vertical_metrics(&self, glyph_id: u16) -> Option<VerticalMetrics> {
552 match self {
553 Self::Font(f) => f.get_vertical_metrics(glyph_id),
554 Self::Ref(r) => r.get_vertical_metrics(glyph_id),
555 }
556 }
557
558 fn get_font_metrics(&self) -> LayoutFontMetrics {
559 match self {
560 Self::Font(f) => f.get_font_metrics(),
561 Self::Ref(r) => r.get_font_metrics(),
562 }
563 }
564
565 fn num_glyphs(&self) -> u16 {
566 match self {
567 Self::Font(f) => f.num_glyphs(),
568 Self::Ref(r) => r.num_glyphs(),
569 }
570 }
571
572 fn get_space_width(&self) -> Option<usize> {
573 match self {
574 Self::Font(f) => f.get_space_width(),
575 Self::Ref(r) => r.get_space_width(),
576 }
577 }
578}
579
580/// Bundles all font-related state that can be shared across layout passes.
581///
582/// Separates font concerns from layout/rendering state (`LayoutWindow`).
583/// Each test/render creates a fresh `LayoutWindow` from a shared `FontContext`,
584/// avoiding stale layout cache reuse while keeping parsed fonts warm.
585///
586/// Usage:
587/// ```ignore
588/// let ctx = FontContext::from_fc_cache(fc_cache);
589/// ctx.pre_resolve_chains(&styled_dom, &platform);
590/// ctx.load_fonts_for_chains();
591///
592/// // Per-test: create fresh LayoutWindow from context
593/// let mut window = LayoutWindow::from_font_context(&ctx)?;
594/// window.layout_and_generate_display_list(styled_dom, ...)?;
595/// ```
596#[derive(Debug, Clone)]
597pub struct FontContext {
598 /// The shared font cache. As of rust-fontconfig 4.1 this type is
599 /// itself backed by `Arc<RwLock<_>>`, so cloning is cheap and all
600 /// clones see builder-thread writes immediately — no more `Arc<T>`
601 /// wrapping is needed and no more stale-snapshot refresh dance.
602 pub fc_cache: FcFontCache,
603 pub parsed_fonts: Arc<Mutex<HashMap<FontId, azul_css::props::basic::FontRef>>>,
604 pub font_chain_cache: HashMap<FontChainKey, rust_fontconfig::FontFallbackChain>,
605 pub embedded_fonts: HashMap<u64, azul_css::props::basic::FontRef>,
606 /// Reverse map: `font_family_hash` → actual `StyleFontFamilyVec`.
607 /// Accumulated across DOMs for persistence. Copied to `FontManager` on `LayoutWindow` creation.
608 pub font_hash_to_families: HashMap<u64, azul_css::props::basic::font::StyleFontFamilyVec>,
609 /// Optional link back to the live `FcFontRegistry`. Present iff the
610 /// caller wants the scout-on-demand path
611 /// ([`rust_fontconfig::registry::FcFontRegistry::request_and_resolve_with_scripts`]),
612 /// which priority-bumps the builder for not-yet-parsed families
613 /// rather than falling back to the empty-snapshot response.
614 pub registry: Option<Arc<rust_fontconfig::registry::FcFontRegistry>>,
615}
616
617impl FontContext {
618 /// Create from an `FcFontCache`. Parsed fonts, font chains, and
619 /// embedded fonts start empty.
620 ///
621 /// The resulting `FontContext` has `registry = None`, so font
622 /// chain resolution only sees what's already in the cache. For
623 /// the scout-on-demand path, use [`FontContext::from_registry`]
624 /// instead, which keeps a handle to the registry so that chain
625 /// resolution can lazy-parse families the DOM needs.
626 #[must_use] pub fn from_fc_cache(fc_cache: FcFontCache) -> Self {
627 Self {
628 fc_cache,
629 parsed_fonts: Arc::new(Mutex::new(HashMap::new())),
630 font_chain_cache: HashMap::new(),
631 embedded_fonts: HashMap::new(),
632 font_hash_to_families: HashMap::new(),
633 registry: None,
634 }
635 }
636
637 /// Create from a live `FcFontRegistry`. The `fc_cache` field gets
638 /// a *shared* handle to the registry's cache (cheap `Arc::clone`
639 /// on the v4.1 shared-state cache) — writes by builder threads
640 /// show up immediately in every reader. Chain resolution goes
641 /// through
642 /// [`rust_fontconfig::registry::FcFontRegistry::request_and_resolve_with_scripts`]
643 /// which priority-bumps the builder for unparsed families and
644 /// waits for them. This is the "scout-on-demand" path: a
645 /// headless renderer can skip the eager common-stack parse and
646 /// pay only the per-family cost on first use, dropping peak RSS
647 /// by the common-stack metadata size (~15 MiB on macOS).
648 pub fn from_registry(
649 registry: Arc<rust_fontconfig::registry::FcFontRegistry>,
650 ) -> Self {
651 let fc_cache = registry.shared_cache();
652 Self {
653 fc_cache,
654 parsed_fonts: Arc::new(Mutex::new(HashMap::new())),
655 font_chain_cache: HashMap::new(),
656 embedded_fonts: HashMap::new(),
657 font_hash_to_families: HashMap::new(),
658 registry: Some(registry),
659 }
660 }
661
662 /// Pre-resolve font chains for a `StyledDom`'s CSS font stacks.
663 /// Call this before layout so text rendering doesn't skip glyphs.
664 ///
665 /// Unicode-fallback fonts are limited to the scripts actually
666 /// present in the document's text content — for an ASCII-only
667 /// page, this skips the ~300 MiB Arial-Unicode / CJK / Arabic
668 /// pull-in entirely. See
669 /// [`crate::solver3::getters::scripts_present_in_styled_dom`].
670 pub fn pre_resolve_chains_for_dom(
671 &mut self,
672 styled_dom: &azul_core::styled_dom::StyledDom,
673 platform: &azul_css::system::Platform,
674 ) {
675 use crate::solver3::getters::{
676 collect_font_stacks_from_styled_dom, collect_used_codepoints,
677 prune_chain_to_used_chars, resolve_font_chains, scripts_present_in_styled_dom,
678 };
679 let collected = collect_font_stacks_from_styled_dom(styled_dom, platform);
680 let scripts = scripts_present_in_styled_dom(styled_dom);
681 let mut chains = resolve_font_chains(&collected, &self.fc_cache, Some(&scripts));
682 // Coverage-based prune (matches `collect_and_resolve_font_chains_with_registration`).
683 let used_chars = collect_used_codepoints(styled_dom);
684 for chain in chains.chains.values_mut() {
685 prune_chain_to_used_chars(chain, &used_chars);
686 }
687 // WEB-LIFT last resort (after prune, so it survives — prune drops the registered
688 // fallback because its cmap isn't parsed yet): if a chain ended up with no fonts,
689 // append the first registered font so load_missing_for_chains finds it and text
690 // shapes instead of measuring 0. (Done in azul-layout, NOT rust-fontconfig, so the
691 // lift-fragile with_memory_fonts isn't re-codegen'd into a trapping shape.)
692 for chain in chains.chains.values_mut() {
693 let total = chain.css_fallbacks.iter().map(|g| g.fonts.len()).sum::<usize>()
694 + chain.unicode_fallbacks.len();
695 if total == 0 {
696 if let Some((pattern, id)) = self.fc_cache.list().first() {
697 chain.unicode_fallbacks.push(rust_fontconfig::FontMatch {
698 id: *id,
699 unicode_ranges: pattern.unicode_ranges.clone(),
700 fallbacks: Vec::new(),
701 });
702 }
703 }
704 }
705 self.font_chain_cache = chains.into_fontconfig_chains();
706 }
707
708 /// Load parsed font bytes from disk for all fonts referenced in `font_chain_cache`.
709 ///
710 /// Thin wrapper that materialises a `ResolvedFontChains` from the
711 /// cached chain map and delegates the actual disk-load to the
712 /// shared `FontManager::load_missing_for_chains` helper, so the
713 /// "collect → diff → load → insert" sequence lives in exactly
714 /// one place. Failures are silently dropped here (the caller is
715 /// the warmup path which has no good place to log them); use
716 /// `FontManager::load_missing_for_chains` directly for diagnostics.
717 pub fn load_fonts_for_chains(&self) {
718 use crate::solver3::getters::ResolvedFontChains;
719 use crate::text3::default::PathLoader;
720
721 let chains_map: HashMap<FontChainKeyOrRef, _> = self
722 .font_chain_cache
723 .iter()
724 .map(|(k, v)| (FontChainKeyOrRef::Chain(k.clone()), v.clone()))
725 .collect();
726 let resolved = ResolvedFontChains {
727 chains: chains_map,
728 ..Default::default()
729 };
730
731 // Borrow our shared `parsed_fonts` Arc as a transient
732 // FontManager so we can use the helper. `from_arc_shared`
733 // returns a manager that mutates the same underlying pool.
734 let Ok(manager) = FontManager::<azul_css::props::basic::FontRef>::from_arc_shared(
735 self.fc_cache.clone(),
736 self.parsed_fonts.clone(),
737 ) else {
738 return;
739 };
740 let loader = PathLoader::new();
741 let _failed = manager
742 .load_missing_for_chains(&resolved, |bytes, idx| loader.load_font_shared(bytes, idx));
743 }
744
745 /// Convert into a `FontManager` with all data populated.
746 /// Carries the `registry` forward so the resulting manager also
747 /// has the scout-on-demand path available.
748 #[must_use] pub fn to_font_manager(&self) -> FontManager<azul_css::props::basic::FontRef> {
749 let mut fm = FontManager {
750 fc_cache: self.fc_cache.clone(),
751 parsed_fonts: self.parsed_fonts.clone(),
752 font_chain_cache: self.font_chain_cache.clone(),
753 embedded_fonts: Mutex::new(self.embedded_fonts.clone()),
754 font_hash_to_families: self.font_hash_to_families.clone(),
755 registry: self.registry.clone(),
756 last_resolved_font_stacks_sig: None,
757 memory_families: HashMap::new(),
758 };
759 // Idempotent: reuses the FontIds already in the shared fc_cache.
760 fm.register_builtin_mock_fonts();
761 fm
762 }
763}
764
765#[derive(Debug)]
766pub struct FontManager<T> {
767 /// The font-path cache. `FcFontCache` in rust-fontconfig 4.1 is
768 /// already a shared handle internally (`Arc<RwLock<_>>`), so no
769 /// further `Arc<...>` wrapping is needed — clones are cheap and
770 /// all clones see builder writes instantly.
771 pub fc_cache: FcFontCache,
772 /// Holds the actual parsed font (usually with the font bytes attached).
773 /// Wrapped in Arc so multiple `FontManager` instances can share the same
774 /// pool of already-parsed fonts (avoids re-reading from disk).
775 pub parsed_fonts: Arc<Mutex<HashMap<FontId, T>>>,
776 // Cache for font chains - populated by resolve_all_font_chains() before layout
777 // This is read-only during layout - no locking needed for reads
778 pub font_chain_cache: HashMap<FontChainKey, rust_fontconfig::FontFallbackChain>,
779 /// Cache for direct `FontRefs` (embedded fonts like Material Icons)
780 /// These are fonts referenced via `FontStack::Ref` that bypass fontconfig
781 pub embedded_fonts: Mutex<HashMap<u64, azul_css::props::basic::FontRef>>,
782 /// Reverse map: `font_family_hash` → actual `StyleFontFamilyVec`.
783 /// Accumulated across DOMs. Used by font collection and text shaping to
784 /// resolve compact cache hashes without `get_property_slow`.
785 pub font_hash_to_families: HashMap<u64, azul_css::props::basic::font::StyleFontFamilyVec>,
786 /// Optional link back to the live `FcFontRegistry`. When present,
787 /// chain resolution uses
788 /// [`rust_fontconfig::registry::FcFontRegistry::request_and_resolve_with_scripts`]
789 /// which lazy-parses system fonts as the DOM requests them
790 /// (scout-on-demand). `None` falls back to querying whatever is
791 /// already in the shared cache.
792 pub registry: Option<Arc<rust_fontconfig::registry::FcFontRegistry>>,
793 /// `FxHash` of the `prev_font_hashes` slice at the moment the last
794 /// successful `collect_and_resolve_font_chains_with_registration`
795 /// call populated `font_chain_cache`. Lets repeated layouts of the
796 /// same DOM skip the ~1.5 ms (cold) / ~0.9 ms (warm) chain resolver
797 /// when the set of font-family hashes has not changed. Cleared
798 /// whenever `font_chain_cache` is explicitly emptied.
799 pub last_resolved_font_stacks_sig: Option<u64>,
800 /// Index of every font registered by FAMILY NAME into `fc_cache`'s
801 /// in-memory font table (bundled fonts, embedder fonts, the built-in
802 /// mock test fonts): normalized family name → the `FontMatch` the
803 /// resolver should emit for it.
804 ///
805 /// WHY THIS EXISTS (architectural, see `resolve_font_chains_fast`):
806 /// the fast chain resolver in rust-fontconfig 4.4
807 /// (`FcFontRegistry::request_fonts_fast`) resolves families purely
808 /// against `known_paths` — i.e. fonts that exist as FILES ON DISK.
809 /// In-memory fonts are invisible to it, so a family registered with
810 /// `FcFontCache::with_memory_fonts` could never be matched by name
811 /// on the production path (which always has a live registry): it
812 /// silently fell back to a system font. This index is consulted
813 /// FIRST, before the disk probe, so a memory-registered family wins
814 /// exactly as CSS says it should.
815 pub memory_families: HashMap<String, rust_fontconfig::FontMatch>,
816}
817
818impl<T: ParsedFontTrait> FontManager<T> {
819 /// # Errors
820 ///
821 /// Returns a `LayoutError` if the font cache cannot be initialized.
822 pub fn new(fc_cache: FcFontCache) -> Result<Self, LayoutError> {
823 let mut fm = Self {
824 fc_cache,
825 parsed_fonts: Arc::new(Mutex::new(HashMap::new())),
826 font_chain_cache: HashMap::new(),
827 embedded_fonts: Mutex::new(HashMap::new()),
828 font_hash_to_families: HashMap::new(),
829 registry: None,
830 last_resolved_font_stacks_sig: None,
831 memory_families: HashMap::new(),
832 };
833 fm.register_builtin_mock_fonts();
834 Ok(fm)
835 }
836
837 /// Register a font by FAMILY NAME from raw bytes, as an in-memory font
838 /// in the shared `FcFontCache`.
839 ///
840 /// This is the ONE hook an embedder (or a test) uses to make a font
841 /// resolvable by `font-family: "<family>"`. It mints one `FontId` for
842 /// the font, inserts it into the fontconfig cache's memory-font table
843 /// (so `get_font_bytes` / `load_fonts_from_disk` find it with no
844 /// special-casing) and indexes it in [`Self::memory_families`] so the
845 /// fast chain resolver can match it by name.
846 ///
847 /// `coverage` are the codepoint ranges the font actually covers.
848 /// Passing the true ranges matters: `FontFallbackChain::resolve_char`
849 /// skips any font that reports no coverage, and a font claiming
850 /// coverage it doesn't have would render .notdef instead of falling
851 /// back.
852 ///
853 /// Returns the `FontId` the family now resolves to.
854 pub fn register_named_font(
855 &mut self,
856 family: &str,
857 bytes: &[u8],
858 coverage: Vec<rust_fontconfig::UnicodeRange>,
859 ) -> FontId {
860 let norm = rust_fontconfig::utils::normalize_family_name(family);
861
862 // IDEMPOTENT: several `FontManager`s (one per window, plus the PDF
863 // writer) share one `FcFontCache`. Registering the same family twice
864 // would mint a second `FontId` for the same bytes, orphan the first
865 // in the cache's metadata table and make the family's id
866 // non-deterministic. Reuse the existing memory font instead.
867 let mut existing: Vec<(FontId, Vec<rust_fontconfig::UnicodeRange>)> = Vec::new();
868 self.fc_cache.for_each_pattern(|pattern, id| {
869 let hit = pattern
870 .family
871 .as_deref()
872 .is_some_and(|f| rust_fontconfig::utils::normalize_family_name(f) == norm);
873 if hit {
874 existing.push((*id, pattern.unicode_ranges.clone()));
875 }
876 });
877 if let Some((id, ranges)) = existing
878 .into_iter()
879 .find(|(id, _)| self.fc_cache.is_memory_font(id))
880 {
881 self.memory_families.insert(
882 norm,
883 rust_fontconfig::FontMatch {
884 id,
885 unicode_ranges: ranges,
886 fallbacks: Vec::new(),
887 },
888 );
889 return id;
890 }
891
892 let pattern = rust_fontconfig::FcPattern {
893 name: Some(family.to_string()),
894 family: Some(family.to_string()),
895 unicode_ranges: coverage.clone(),
896 ..Default::default()
897 };
898 let id = FontId::new();
899 self.fc_cache.with_memory_font_with_id(
900 id,
901 pattern,
902 rust_fontconfig::FcFont {
903 bytes: bytes.to_vec(),
904 font_index: 0,
905 id: family.to_string(),
906 },
907 );
908 self.memory_families.insert(
909 norm,
910 rust_fontconfig::FontMatch {
911 id,
912 unicode_ranges: coverage,
913 fallbacks: Vec::new(),
914 },
915 );
916 id
917 }
918
919 /// Register the built-in mock test fonts (see
920 /// [`crate::text3::mock_fonts`]). Called from every constructor: the
921 /// mock families are only reachable if a stylesheet names them, and
922 /// having them always present means tests exercise the *same* font
923 /// path as production instead of a test-only bypass.
924 pub fn register_builtin_mock_fonts(&mut self) {
925 for (family, bytes) in crate::text3::mock_fonts::BUILTIN_MOCK_FONTS {
926 self.register_named_font(
927 family,
928 bytes,
929 crate::text3::mock_fonts::mock_font_ranges(),
930 );
931 }
932 }
933
934 /// Create a `FontManager` sharing the font-path cache handle.
935 ///
936 /// The `parsed_fonts` pool starts empty. Fonts loaded during the first
937 /// layout pass are cached and will be available on subsequent calls
938 /// if you clone the `parsed_fonts` Arc before creating the next instance.
939 /// For full sharing, prefer `from_arc_shared()`.
940 /// # Errors
941 ///
942 /// Returns a `LayoutError` if the font cache cannot be initialized.
943 pub fn from_shared(fc_cache: FcFontCache) -> Result<Self, LayoutError> {
944 Self::new(fc_cache)
945 }
946
947 /// Create a `FontManager` sharing both the font-path cache and the
948 /// already-parsed font data with another `FontManager`.
949 ///
950 /// This avoids re-reading and re-parsing font files from disk when
951 /// rendering multiple documents that use the same fonts.
952 /// # Errors
953 ///
954 /// Returns a `LayoutError` if the font cache cannot be initialized.
955 pub fn from_arc_shared(
956 fc_cache: FcFontCache,
957 parsed_fonts: Arc<Mutex<HashMap<FontId, T>>>,
958 ) -> Result<Self, LayoutError> {
959 let mut fm = Self {
960 fc_cache,
961 parsed_fonts,
962 font_chain_cache: HashMap::new(),
963 embedded_fonts: Mutex::new(HashMap::new()),
964 font_hash_to_families: HashMap::new(),
965 registry: None,
966 last_resolved_font_stacks_sig: None,
967 memory_families: HashMap::new(),
968 };
969 fm.register_builtin_mock_fonts();
970 Ok(fm)
971 }
972
973 /// Attach a `FcFontRegistry` to this `FontManager` so subsequent
974 /// chain-resolution calls use the on-demand path
975 /// ([`rust_fontconfig::registry::FcFontRegistry::request_and_resolve_with_scripts`]).
976 #[must_use]
977 pub fn with_registry(
978 mut self,
979 registry: Arc<rust_fontconfig::registry::FcFontRegistry>,
980 ) -> Self {
981 self.registry = Some(registry);
982 self
983 }
984
985 /// Get a shareable handle to the parsed-font pool.
986 ///
987 /// Pass this to `from_arc_shared()` to create a new `FontManager` that
988 /// reuses already-parsed fonts.
989 pub fn shared_parsed_fonts(&self) -> Arc<Mutex<HashMap<FontId, T>>> {
990 Arc::clone(&self.parsed_fonts)
991 }
992
993 /// Set the font chain cache from externally resolved chains
994 ///
995 /// This should be called with the result of `resolve_font_chains()` or
996 /// `collect_and_resolve_font_chains()` from `solver3::getters`.
997 pub fn set_font_chain_cache(
998 &mut self,
999 chains: HashMap<FontChainKey, rust_fontconfig::FontFallbackChain>,
1000 ) {
1001 self.font_chain_cache = chains;
1002 self.last_resolved_font_stacks_sig = None;
1003 }
1004
1005 /// Set the font chain cache and record the input signature so
1006 /// subsequent layouts with the same `prev_font_hashes` skip the
1007 /// resolver. Pass `sig = None` if the caller cannot compute a
1008 /// reliable signature — equivalent to the single-arg
1009 /// `set_font_chain_cache`.
1010 pub fn set_font_chain_cache_with_sig(
1011 &mut self,
1012 chains: HashMap<FontChainKey, rust_fontconfig::FontFallbackChain>,
1013 sig: Option<u64>,
1014 ) {
1015 // (2026-06-10: reverted to HashMap — the empty-map RawIter hang behind the 2026-06-05
1016 // BTreeMap migration was the un-mirrored hashbrown EMPTY_GROUP static, fixed
1017 // transpiler-side.)
1018 self.font_chain_cache = chains;
1019 self.last_resolved_font_stacks_sig = sig;
1020 }
1021
1022 /// Merge additional font chains into the existing cache
1023 ///
1024 /// Useful when processing multiple DOMs that may have different font requirements.
1025 pub fn merge_font_chain_cache(
1026 &mut self,
1027 chains: HashMap<FontChainKey, rust_fontconfig::FontFallbackChain>,
1028 ) {
1029 self.font_chain_cache.extend(chains);
1030 }
1031
1032 /// Get a reference to the font chain cache
1033 pub const fn get_font_chain_cache(
1034 &self,
1035 ) -> &HashMap<FontChainKey, rust_fontconfig::FontFallbackChain> {
1036 &self.font_chain_cache
1037 }
1038
1039 /// Get an embedded font by its hash (used for `WebRender` registration)
1040 /// Returns the `FontRef` if it exists in the `embedded_fonts` cache.
1041 /// # Panics
1042 ///
1043 /// Panics if the internal font-cache mutex is poisoned.
1044 pub fn get_embedded_font_by_hash(&self, font_hash: u64) -> Option<azul_css::props::basic::FontRef> {
1045 let embedded = self.embedded_fonts.lock().unwrap();
1046 embedded.get(&font_hash).cloned()
1047 }
1048
1049 /// Get a parsed font by its hash (used for `WebRender` registration)
1050 /// Returns the parsed font if it exists in the `parsed_fonts` cache.
1051 /// # Panics
1052 ///
1053 /// Panics if the internal font-cache mutex is poisoned.
1054 pub fn get_font_by_hash(&self, font_hash: u64) -> Option<T> {
1055 let parsed = self.parsed_fonts.lock().unwrap();
1056 // Linear search through all cached fonts to find one with matching hash
1057 let found = parsed
1058 .iter()
1059 .find(|(_, font)| font.get_hash() == font_hash)
1060 .map(|(_, font)| font.clone());
1061 drop(parsed);
1062 found
1063 }
1064
1065 /// Register an embedded `FontRef` for later lookup by hash
1066 /// This is called when using `FontStack::Ref` during shaping
1067 /// # Panics
1068 ///
1069 /// Panics if the internal font-cache mutex is poisoned.
1070 pub fn register_embedded_font(&self, font_ref: &azul_css::props::basic::FontRef) {
1071 let hash = font_ref.get_hash();
1072 let mut embedded = self.embedded_fonts.lock().unwrap();
1073 embedded.insert(hash, font_ref.clone());
1074 }
1075
1076 /// Get a snapshot of all currently loaded fonts
1077 ///
1078 /// This returns a copy of all parsed fonts, which can be passed to the shaper.
1079 /// No locking is required after this call - the returned `HashMap` is independent.
1080 ///
1081 /// NOTE: This should be called AFTER loading all required fonts for a layout pass.
1082 /// # Panics
1083 ///
1084 /// Panics if the internal font-cache mutex is poisoned.
1085 pub fn get_loaded_fonts(&self) -> LoadedFonts<T> {
1086 let parsed = self.parsed_fonts.lock().unwrap();
1087 parsed
1088 .iter()
1089 .map(|(id, font)| (*id, font.shallow_clone()))
1090 .collect()
1091 }
1092
1093 /// Get the set of `FontIds` that are currently loaded
1094 ///
1095 /// This is useful for computing which fonts need to be loaded
1096 /// (diff with required fonts).
1097 /// # Panics
1098 ///
1099 /// Panics if the internal font-cache mutex is poisoned.
1100 pub fn get_loaded_font_ids(&self) -> HashSet<FontId> {
1101 let parsed = self.parsed_fonts.lock().unwrap();
1102 // M12.7: skip hashbrown's RawIterRange on an empty map — its NEON
1103 // control-byte group-scan mis-lifts to wasm and iterates forever
1104 // (the headless web layout uses an empty font cache → parsed is
1105 // empty here). is_empty() is len-based (no iteration), so it is safe.
1106 if parsed.is_empty() {
1107 return HashSet::new();
1108 }
1109 unsafe { crate::az_mark(0x60788, 0xA1) };
1110 let out = parsed.keys().copied().collect();
1111 drop(parsed);
1112 unsafe { crate::az_mark(0x6078C, 0xA2) };
1113 out
1114 }
1115
1116 /// Insert a loaded font into the cache
1117 ///
1118 /// Returns the old font if one was already present for this `FontId`.
1119 /// # Panics
1120 ///
1121 /// Panics if the internal font-cache mutex is poisoned.
1122 pub fn insert_font(&self, font_id: FontId, font: T) -> Option<T> {
1123 let mut parsed = self.parsed_fonts.lock().unwrap();
1124 parsed.insert(font_id, font)
1125 }
1126
1127 /// Insert multiple loaded fonts into the cache
1128 ///
1129 /// This is more efficient than calling `insert_font` multiple times
1130 /// because it only acquires the lock once.
1131 /// # Panics
1132 ///
1133 /// Panics if the internal font-cache mutex is poisoned.
1134 pub fn insert_fonts(&self, fonts: impl IntoIterator<Item = (FontId, T)>) {
1135 let mut parsed = self.parsed_fonts.lock().unwrap();
1136 for (font_id, font) in fonts {
1137 parsed.insert(font_id, font);
1138 }
1139 }
1140
1141 /// One-shot helper that resolves "what fonts does `chains` need
1142 /// that this manager hasn't loaded yet" and loads them via the
1143 /// supplied `load_fn` closure (typically
1144 /// `PathLoader::load_font_shared` for the production lazy-decode
1145 /// path). Updates `parsed_fonts` in place and returns any failures
1146 /// for the caller to log.
1147 ///
1148 /// Replaces the same four-step `collect → compute_diff →
1149 /// load_from_disk → insert_fonts` dance previously inlined in
1150 /// `LayoutWindow::layout_document`, the CPU rasterizer pre-fill
1151 /// in `cpurender.rs`, and `FontContext::load_fonts_for_chains`.
1152 pub fn load_missing_for_chains<F>(
1153 &self,
1154 chains: &crate::solver3::getters::ResolvedFontChains,
1155 load_fn: F,
1156 ) -> Vec<(FontId, String)>
1157 where
1158 F: Fn(Arc<rust_fontconfig::FontBytes>, usize) -> Result<T, LayoutError>,
1159 {
1160 use crate::solver3::getters::{
1161 collect_font_ids_from_chains, compute_fonts_to_load, load_fonts_from_disk,
1162 };
1163 let required = collect_font_ids_from_chains(chains);
1164 let already = self.get_loaded_font_ids();
1165 let to_load = compute_fonts_to_load(&required, &already);
1166 if to_load.is_empty() {
1167 return Vec::new();
1168 }
1169 let result = load_fonts_from_disk(&to_load, &self.fc_cache, load_fn);
1170 self.insert_fonts(result.loaded);
1171 result.failed
1172 }
1173
1174 /// Remove a font from the cache
1175 ///
1176 /// Returns the removed font if it was present.
1177 /// # Panics
1178 ///
1179 /// Panics if the internal font-cache mutex is poisoned.
1180 pub fn remove_font(&self, font_id: &FontId) -> Option<T> {
1181 let mut parsed = self.parsed_fonts.lock().unwrap();
1182 parsed.remove(font_id)
1183 }
1184
1185 /// FONT GC — evict everything the CURRENT document no longer references.
1186 ///
1187 /// `keep_ids` are the `FontId`s reachable from the font chains just resolved
1188 /// for this document; `keep_hashes` are the font-family hashes present in its
1189 /// CSS property cache. Anything else belonged to a node that is gone.
1190 ///
1191 /// Without this, `parsed_fonts` / `font_hash_to_families` only ever GREW: a
1192 /// font loaded for one node stayed resident for the life of the window even
1193 /// after the node (and every other user of that family) disappeared — an app
1194 /// that cycles fonts (font picker, editor, live CSS) leaked every font it ever
1195 /// touched.
1196 ///
1197 /// Eviction is always safe: `load_missing_for_chains` re-loads any font a
1198 /// later layout turns out to need. The cost of a wrong guess is one re-parse,
1199 /// never a missing glyph.
1200 ///
1201 /// Returns the number of parsed fonts evicted.
1202 /// # Panics
1203 ///
1204 /// Panics if the internal font-cache mutex is poisoned.
1205 pub fn garbage_collect_fonts(
1206 &mut self,
1207 keep_ids: &HashSet<FontId>,
1208 keep_hashes: &HashSet<u64>,
1209 ) -> usize {
1210 let evicted = {
1211 let mut parsed = self.parsed_fonts.lock().unwrap();
1212 let before = parsed.len();
1213 parsed.retain(|id, _| keep_ids.contains(id));
1214 before.saturating_sub(parsed.len())
1215 };
1216 self.font_hash_to_families
1217 .retain(|h, _| keep_hashes.contains(h));
1218 evicted
1219 }
1220}
1221
1222// Error handling
1223// [g119 az-web-lift FIX] `#[repr(C, u8)]` (was repr(Rust)): the String/FontSelector payloads give
1224// `Result<T, LayoutError>` (e.g. measure_intrinsic_widths' return + reorder/shape/orientation `?`)
1225// a POINTER-niche disc the web lift mis-reads → Ok→Err. Explicit u8 tag = simple-compare niche the
1226// lift handles. Also nested in solver3::LayoutError::Text (so both must be repr(C,u8)). Not FFI-exposed.
1227#[derive(Debug, thiserror::Error)]
1228#[repr(C, u8)]
1229pub enum LayoutError {
1230 #[error("Bidi analysis failed: {0}")]
1231 BidiError(String),
1232 #[error("Shaping failed: {0}")]
1233 ShapingError(String),
1234 #[error("Font not found: {0:?}")]
1235 FontNotFound(FontSelector),
1236 #[error("Invalid text input: {0}")]
1237 InvalidText(String),
1238 #[error("Hyphenation failed: {0}")]
1239 HyphenationError(String),
1240}
1241
1242/// Text boundary types for cursor movement
1243#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1244pub enum TextBoundary {
1245 /// Reached top of text (first line)
1246 Top,
1247 /// Reached bottom of text (last line)
1248 Bottom,
1249 /// Reached start of text (first character)
1250 Start,
1251 /// Reached end of text (last character)
1252 End,
1253}
1254
1255/// Error returned when cursor movement hits a boundary
1256#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1257pub(crate) struct CursorBoundsError {
1258 pub(crate) boundary: TextBoundary,
1259 pub(crate) cursor: TextCursor,
1260}
1261
1262/// Unified constraints combining all layout features
1263///
1264/// # CSS Inline Layout Module Level 3: Constraint Mapping
1265///
1266/// This structure maps CSS properties to layout constraints:
1267///
1268/// ## \u00a7 2.1 Layout of Line Boxes
1269/// - `available_width`: \u26a0\ufe0f CRITICAL - Should equal containing block's inner width
1270/// * Currently defaults to 0.0 which causes immediate line breaking
1271/// * Per spec: "logical width of a line box is equal to the inner logical width of its containing
1272/// block"
1273/// - `available_height`: For block-axis constraints (max-height)
1274///
1275/// ## \u00a7 2.2 Layout Within Line Boxes
1276/// - `text_align`: \u2705 Horizontal alignment (start, end, center, justify)
1277/// - `vertical_align`: \u26a0\ufe0f PARTIAL - Only baseline supported, missing:
1278/// * top, bottom, middle, text-top, text-bottom
1279/// * <length>, <percentage> values
1280/// * sub, super positions
1281/// - `line_height`: \u2705 Distance between baselines
1282///
1283/// ## \u00a7 3 Baselines and Alignment Metrics
1284/// - `text_orientation`: \u2705 For vertical writing (sideways, upright)
1285/// - `writing_mode`: \u2705 horizontal-tb, vertical-rl, vertical-lr
1286/// - `direction`: \u2705 ltr, rtl for `BiDi`
1287///
1288/// ## \u00a7 4 Baseline Alignment (vertical-align property)
1289/// \u26a0\ufe0f INCOMPLETE: Only basic baseline alignment implemented
1290///
1291/// ## \u00a7 5 Line Spacing (line-height property)
1292/// - `line_height`: \u2705 Implemented
1293/// - \u274c MISSING: line-fit-edge for controlling which edges contribute to line height
1294/// +spec:box-model:51342f - inline box margins/borders/padding do not affect line box height (default leading mode)
1295/// +spec:font-metrics:618776 - line-fit-edge (cap, ex, ideographic, alphabetic edge selection) not yet implemented
1296///
1297/// ## \u00a7 6 Trimming Leading (text-box-trim)
1298/// - \u274c NOT IMPLEMENTED: text-box-trim property
1299/// - \u274c NOT IMPLEMENTED: text-box-edge property
1300/// +spec:box-model:c09331 - text-box-trim trims block container first/last line to font metrics
1301/// // +spec:overflow:dc2196 - text-box-trim overflow handled as normal overflow (no special handling needed)
1302///
1303/// ## CSS Text Module Level 3
1304/// - `text_indent`: \u2705 First line indentation
1305/// - `text_justify`: \u2705 Justification algorithm (auto, inter-word, inter-character)
1306/// - `hyphenation`: \u2705 Hyphens property (none / manual / auto)
1307/// - `hanging_punctuation`: \u2705 Hanging punctuation at line edges
1308///
1309/// ## CSS Text Level 4
1310/// - `text_wrap`: \u2705 balance, pretty, stable
1311/// - `line_clamp`: \u2705 Max number of lines
1312///
1313/// ## CSS Writing Modes Level 4
1314/// - `text_combine_upright`: \u2705 Tate-chu-yoko for vertical text
1315///
1316/// ## CSS Shapes Module
1317/// - `shape_boundaries`: \u2705 Custom line box shapes
1318/// - `shape_exclusions`: \u2705 Exclusion areas (float-like behavior)
1319/// - `exclusion_margin`: \u2705 Margin around exclusions
1320///
1321/// ## Multi-column Layout
1322/// - `columns`: \u2705 Number of columns
1323/// - `column_gap`: \u2705 Gap between columns
1324///
1325/// # Known Issues:
1326/// 1. [ISSUE] `available_width` defaults to Definite(0.0) instead of containing block width
1327/// 2. [ISSUE] `vertical_align` only supports baseline
1328/// 3. [TODO] initial-letter (drop caps) not implemented
1329// +spec:box-model:415ef3 - initial letters use standard margin/padding/border box model; exclusion area = margin box
1330// +spec:box-model:d53ea3 - when block-start padding+border are zero, content edge coincides with over alignment point
1331/// +spec:positioning:fb233a - initial letter block-axis: if size < sink, use over alignment
1332#[derive(Debug, Clone)]
1333pub struct UnifiedConstraints {
1334 // Shape definition
1335 pub shape_boundaries: Vec<ShapeBoundary>,
1336 pub shape_exclusions: Vec<ShapeBoundary>,
1337
1338 // Basic layout - using AvailableSpace for proper indefinite handling
1339 pub available_width: AvailableSpace,
1340 pub available_height: Option<f32>,
1341
1342 // Text layout
1343 pub writing_mode: Option<WritingMode>,
1344 // +spec:writing-modes:6c5ab9 - blocks inherit base direction from parent via CSS direction property
1345 // Base direction from CSS, overrides auto-detection
1346 pub direction: Option<BidiDirection>,
1347 pub text_orientation: TextOrientation,
1348 pub text_align: TextAlign,
1349 pub text_justify: JustifyContent,
1350 // +spec:display-property:3bcac8 - inline boxes sized in block axis based on font metrics (ascent/descent)
1351 pub line_height: LineHeight,
1352 pub vertical_align: VerticalAlign,
1353 // block container's first available font, used for minimum line box height
1354 pub strut_ascent: f32,
1355 pub strut_descent: f32,
1356 // x-height of the strut font (scaled to font_size), for vertical-align: middle
1357 pub strut_x_height: f32,
1358
1359 // Width of '0' (zero) character in px, used for ch unit and tab-size.
1360 // Approximated as space_width from the first available font, or 0.5 * font_size fallback.
1361 pub ch_width: f32,
1362
1363 // Overflow handling
1364 pub overflow: OverflowBehavior,
1365 pub segment_alignment: SegmentAlignment,
1366
1367 // Advanced features
1368 pub text_combine_upright: Option<TextCombineUpright>,
1369 pub exclusion_margin: f32,
1370 pub hyphenation: Hyphens,
1371 pub hyphenation_language: Option<Language>,
1372 pub text_indent: f32,
1373 pub text_indent_each_line: bool,
1374 pub text_indent_hanging: bool,
1375 pub initial_letter: Option<InitialLetter>,
1376 pub line_clamp: Option<NonZeroUsize>,
1377
1378 // text-wrap: balance
1379 pub text_wrap: TextWrap,
1380 pub columns: u32,
1381 pub column_gap: f32,
1382 pub hanging_punctuation: bool,
1383 pub overflow_wrap: OverflowWrap,
1384 pub text_align_last: TextAlign,
1385 // §5.2 word-break property on constraints
1386 pub word_break: WordBreak,
1387 pub white_space_mode: WhiteSpaceMode,
1388 pub line_break: LineBreakStrictness,
1389 // CSS unicode-bidi property; Plaintext causes per-paragraph auto-detection
1390 pub unicode_bidi: UnicodeBidi,
1391}
1392
1393impl Default for UnifiedConstraints {
1394 fn default() -> Self {
1395 Self {
1396 shape_boundaries: Vec::new(),
1397 shape_exclusions: Vec::new(),
1398
1399 // Use MaxContent as default to avoid premature line breaking.
1400 // MaxContent means "use intrinsic width" which is appropriate when
1401 // the containing block's width is not yet known.
1402 // Previously this was Definite(0.0) which caused each character to
1403 // wrap to its own line. The actual width should be passed from the
1404 // box layout solver (fc.rs) when creating UnifiedConstraints.
1405 available_width: AvailableSpace::MaxContent,
1406 available_height: None,
1407 writing_mode: None,
1408 direction: None, // Will default to LTR if not specified
1409 text_orientation: TextOrientation::default(),
1410 text_align: TextAlign::default(),
1411 text_justify: JustifyContent::default(),
1412 line_height: LineHeight::Normal,
1413 vertical_align: VerticalAlign::default(),
1414 strut_ascent: DEFAULT_STRUT_ASCENT,
1415 strut_descent: DEFAULT_STRUT_DESCENT,
1416 strut_x_height: DEFAULT_X_HEIGHT,
1417 ch_width: DEFAULT_CH_WIDTH,
1418 overflow: OverflowBehavior::default(),
1419 segment_alignment: SegmentAlignment::default(),
1420 text_combine_upright: None,
1421 exclusion_margin: 0.0,
1422 hyphenation: Hyphens::default(),
1423 hyphenation_language: None,
1424 columns: 1,
1425 column_gap: 0.0,
1426 hanging_punctuation: false,
1427 text_indent: 0.0,
1428 text_indent_each_line: false,
1429 text_indent_hanging: false,
1430 initial_letter: None,
1431 line_clamp: None,
1432 text_wrap: TextWrap::default(),
1433 overflow_wrap: OverflowWrap::default(),
1434 text_align_last: TextAlign::default(),
1435 word_break: WordBreak::default(),
1436 white_space_mode: WhiteSpaceMode::default(),
1437 line_break: LineBreakStrictness::default(),
1438 unicode_bidi: UnicodeBidi::default(),
1439 }
1440 }
1441}
1442
1443// UnifiedConstraints
1444impl Hash for UnifiedConstraints {
1445 #[allow(clippy::cast_possible_truncation)] // bounded pixel/coord/colour/glyph cast
1446 fn hash<H: Hasher>(&self, state: &mut H) {
1447 self.shape_boundaries.hash(state);
1448 self.shape_exclusions.hash(state);
1449 self.available_width.hash(state);
1450 self.available_height
1451 .map(|h| h.round() as isize)
1452 .hash(state);
1453 self.writing_mode.hash(state);
1454 self.direction.hash(state);
1455 self.text_orientation.hash(state);
1456 self.text_align.hash(state);
1457 self.text_justify.hash(state);
1458 self.line_height.hash(state);
1459 self.vertical_align.hash(state);
1460 (self.strut_ascent.round() as isize).hash(state);
1461 (self.strut_descent.round() as isize).hash(state);
1462 (self.strut_x_height.round() as isize).hash(state);
1463 (self.ch_width.round() as isize).hash(state);
1464 self.overflow.hash(state);
1465 self.segment_alignment.hash(state);
1466 self.text_combine_upright.hash(state);
1467 (self.exclusion_margin.round() as isize).hash(state);
1468 self.hyphenation.hash(state);
1469 self.hyphenation_language.hash(state);
1470 (self.text_indent.round() as isize).hash(state);
1471 self.text_indent_each_line.hash(state);
1472 self.text_indent_hanging.hash(state);
1473 self.initial_letter.hash(state);
1474 self.line_clamp.hash(state);
1475 self.columns.hash(state);
1476 (self.column_gap.round() as isize).hash(state);
1477 self.hanging_punctuation.hash(state);
1478 self.overflow_wrap.hash(state);
1479 self.text_align_last.hash(state);
1480 self.word_break.hash(state);
1481 self.white_space_mode.hash(state);
1482 self.line_break.hash(state);
1483 self.unicode_bidi.hash(state);
1484 }
1485}
1486
1487impl PartialEq for UnifiedConstraints {
1488 fn eq(&self, other: &Self) -> bool {
1489 self.shape_boundaries == other.shape_boundaries
1490 && self.shape_exclusions == other.shape_exclusions
1491 && self.available_width == other.available_width
1492 && match (self.available_height, other.available_height) {
1493 (None, None) => true,
1494 (Some(h1), Some(h2)) => round_eq(h1, h2),
1495 _ => false,
1496 }
1497 && self.writing_mode == other.writing_mode
1498 && self.direction == other.direction
1499 && self.text_orientation == other.text_orientation
1500 && self.text_align == other.text_align
1501 && self.text_justify == other.text_justify
1502 && self.line_height == other.line_height
1503 && self.vertical_align == other.vertical_align
1504 && round_eq(self.strut_ascent, other.strut_ascent)
1505 && round_eq(self.strut_descent, other.strut_descent)
1506 && round_eq(self.strut_x_height, other.strut_x_height)
1507 && round_eq(self.ch_width, other.ch_width)
1508 && self.overflow == other.overflow
1509 && self.segment_alignment == other.segment_alignment
1510 && self.text_combine_upright == other.text_combine_upright
1511 && round_eq(self.exclusion_margin, other.exclusion_margin)
1512 && self.hyphenation == other.hyphenation
1513 && self.hyphenation_language == other.hyphenation_language
1514 && round_eq(self.text_indent, other.text_indent)
1515 && self.text_indent_each_line == other.text_indent_each_line
1516 && self.text_indent_hanging == other.text_indent_hanging
1517 && self.initial_letter == other.initial_letter
1518 && self.line_clamp == other.line_clamp
1519 && self.columns == other.columns
1520 && round_eq(self.column_gap, other.column_gap)
1521 && self.hanging_punctuation == other.hanging_punctuation
1522 && self.overflow_wrap == other.overflow_wrap
1523 && self.text_align_last == other.text_align_last
1524 && self.word_break == other.word_break
1525 && self.white_space_mode == other.white_space_mode
1526 && self.line_break == other.line_break
1527 && self.unicode_bidi == other.unicode_bidi
1528 }
1529}
1530
1531impl Eq for UnifiedConstraints {}
1532
1533impl UnifiedConstraints {
1534 /// Resolve `line_height` to a pixel value using the strut metrics as a font-size proxy.
1535 /// `strut_ascent + strut_descent` approximates `font_size` (the block container's font).
1536 #[must_use] pub fn resolved_line_height(&self) -> f32 {
1537 match self.line_height {
1538 // `line-height: normal` — the minimum line-box height is the block's
1539 // first-available-font metrics, approximated here by the strut's
1540 // ascent + descent. Resolving `Normal` with no real metrics fell back
1541 // to `font_size * 1.2`, which inflated every non-last line's advance
1542 // ~20% (block auto-heights came out too tall). The real per-line box
1543 // height (from each run's actual glyph metrics) is folded in via
1544 // `.max()` at the call sites, so this strut value is the correct floor.
1545 LineHeight::Normal => self.strut_ascent + self.strut_descent,
1546 LineHeight::Px(px) => px,
1547 }
1548 }
1549 fn direction(&self, fallback: BidiDirection) -> BidiDirection {
1550 self.writing_mode.map_or(fallback, |s| s.get_direction().unwrap_or(fallback))
1551 }
1552 const fn is_vertical(&self) -> bool {
1553 matches!(
1554 self.writing_mode,
1555 Some(WritingMode::VerticalRl | WritingMode::VerticalLr)
1556 )
1557 }
1558}
1559
1560/// Line constraints with multi-segment support
1561#[derive(Debug, Clone)]
1562pub struct LineConstraints {
1563 pub segments: Vec<LineSegment>,
1564 pub total_available: f32,
1565 /// True when measuring min-content: the breaker must break at EVERY soft-wrap
1566 /// opportunity (each word on its own line) rather than filling `total_available`
1567 /// (which is a sentinel `f32::MAX / 2` for intrinsic sizing and never overflows).
1568 pub is_min_content: bool,
1569}
1570
1571impl WritingMode {
1572 #[allow(clippy::trivially_copy_pass_by_ref)] // <=8B Copy param kept by-ref intentionally (hot pixel/coord path or to avoid churning call sites for a perf-neutral change)
1573 #[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
1574 const fn get_direction(&self) -> Option<BidiDirection> {
1575 match self {
1576 // determined by text content
1577 Self::HorizontalTb => None,
1578 Self::VerticalRl => Some(BidiDirection::Rtl),
1579 Self::VerticalLr => Some(BidiDirection::Ltr),
1580 Self::SidewaysRl => Some(BidiDirection::Rtl),
1581 Self::SidewaysLr => Some(BidiDirection::Ltr),
1582 }
1583 }
1584}
1585
1586// Stage 1: Collection - Styled runs from DOM traversal
1587#[derive(Debug, Clone, Hash)]
1588pub struct StyledRun {
1589 pub text: String,
1590 pub style: Arc<StyleProperties>,
1591 /// Byte index in the original logical paragraph text
1592 pub logical_start_byte: usize,
1593 /// The DOM `NodeId` of the Text node this run came from.
1594 /// None for generated content (e.g., list markers, `::before/::after`).
1595 pub source_node_id: Option<NodeId>,
1596}
1597
1598// Stage 2: Bidi Analysis - Visual runs in display order
1599#[derive(Debug, Clone)]
1600pub struct VisualRun<'a> {
1601 pub text_slice: &'a str,
1602 pub style: Arc<StyleProperties>,
1603 pub logical_start_byte: usize,
1604 pub bidi_level: BidiLevel,
1605 pub script: Script,
1606 pub language: Language,
1607}
1608
1609// Font and styling types
1610
1611/// A selector for loading fonts from the font cache.
1612/// Used by `FontManager` to query fontconfig and load font files.
1613#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
1614pub struct FontSelector {
1615 pub family: String,
1616 pub weight: FcWeight,
1617 pub style: FontStyle,
1618 pub unicode_ranges: Vec<UnicodeRange>,
1619}
1620
1621impl Default for FontSelector {
1622 fn default() -> Self {
1623 Self {
1624 family: "serif".to_string(),
1625 weight: FcWeight::Normal,
1626 style: FontStyle::Normal,
1627 unicode_ranges: Vec::new(),
1628 }
1629 }
1630}
1631
1632/// Font stack that can be either a list of font selectors (resolved via fontconfig)
1633/// or a direct `FontRef` (bypasses fontconfig entirely).
1634///
1635/// When a `FontRef` is used, it bypasses fontconfig resolution entirely
1636/// and uses the pre-parsed font data directly. This is used for embedded
1637/// fonts like Material Icons.
1638// [g121 az-web-lift] `#[repr(C, u8)]` — same disc-mis-lift guard as the other text3 enums; matched in
1639// shape_visual_items (`match &style.font_stack { Ref => shape, Stack => resolve }`). repr(Rust) niche
1640// (from the Vec/FontRef payloads) could mis-route. Explicit u8 tag = simple load. Internal to text3.
1641#[derive(Debug, Clone)]
1642#[repr(C, u8)]
1643pub enum FontStack {
1644 /// A stack of font selectors to be resolved via fontconfig
1645 /// First font is primary, rest are fallbacks
1646 Stack(Vec<FontSelector>),
1647 /// A direct reference to a pre-parsed font (e.g., embedded icon fonts)
1648 /// This font covers the entire Unicode range and has no fallbacks.
1649 Ref(azul_css::props::basic::font::FontRef),
1650}
1651
1652impl Default for FontStack {
1653 fn default() -> Self {
1654 Self::Stack(vec![FontSelector::default()])
1655 }
1656}
1657
1658impl FontStack {
1659 /// Returns true if this is a direct `FontRef`
1660 #[must_use] pub const fn is_ref(&self) -> bool {
1661 matches!(self, Self::Ref(_))
1662 }
1663
1664 /// Returns the `FontRef` if this is a Ref variant
1665 #[must_use] pub const fn as_ref(&self) -> Option<&azul_css::props::basic::font::FontRef> {
1666 match self {
1667 Self::Ref(r) => Some(r),
1668 Self::Stack(_) => None,
1669 }
1670 }
1671
1672 /// Returns the font selectors if this is a Stack variant
1673 #[must_use] pub fn as_stack(&self) -> Option<&[FontSelector]> {
1674 match self {
1675 Self::Stack(s) => Some(s),
1676 Self::Ref(_) => None,
1677 }
1678 }
1679
1680 /// Returns the first `FontSelector` if this is a Stack variant, None if Ref
1681 #[must_use] pub fn first_selector(&self) -> Option<&FontSelector> {
1682 match self {
1683 Self::Stack(s) => s.first(),
1684 Self::Ref(_) => None,
1685 }
1686 }
1687
1688 /// Returns the first font family name (for Stack) or a placeholder (for Ref)
1689 #[must_use] pub fn first_family(&self) -> &str {
1690 match self {
1691 Self::Stack(s) => s.first().map_or("serif", |f| f.family.as_str()),
1692 Self::Ref(_) => "<embedded-font>",
1693 }
1694 }
1695}
1696
1697impl PartialEq for FontStack {
1698 fn eq(&self, other: &Self) -> bool {
1699 match (self, other) {
1700 (Self::Stack(a), Self::Stack(b)) => a == b,
1701 (Self::Ref(a), Self::Ref(b)) => a.parsed == b.parsed,
1702 _ => false,
1703 }
1704 }
1705}
1706
1707impl Eq for FontStack {}
1708
1709impl Hash for FontStack {
1710 fn hash<H: Hasher>(&self, state: &mut H) {
1711 discriminant(self).hash(state);
1712 match self {
1713 Self::Stack(s) => s.hash(state),
1714 Self::Ref(r) => (r.parsed as usize).hash(state),
1715 }
1716 }
1717}
1718
1719/// A reference to a font for rendering, identified by its hash.
1720/// This hash corresponds to `ParsedFont::hash` and is used to look up
1721/// the actual font data in the renderer's font cache.
1722#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
1723pub struct FontHash {
1724 /// The hash of the `ParsedFont`. 0 means invalid/unknown font.
1725 pub font_hash: u64,
1726}
1727
1728impl FontHash {
1729 #[must_use] pub const fn invalid() -> Self {
1730 Self { font_hash: 0 }
1731 }
1732
1733 #[must_use] pub const fn from_hash(font_hash: u64) -> Self {
1734 Self { font_hash }
1735 }
1736}
1737
1738#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
1739pub enum FontStyle {
1740 Normal,
1741 Italic,
1742 Oblique,
1743}
1744
1745/// Defines how text should be aligned when a line contains multiple disjoint segments.
1746#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
1747pub enum SegmentAlignment {
1748 /// Align text within the first available segment on the line.
1749 #[default]
1750 First,
1751 /// Align text relative to the total available width of all
1752 /// segments on the line combined.
1753 Total,
1754}
1755
1756#[derive(Copy, Debug, Clone)]
1757pub struct VerticalMetrics {
1758 pub advance: f32,
1759 pub bearing_x: f32,
1760 pub bearing_y: f32,
1761 pub origin_y: f32,
1762}
1763
1764// +spec:font-metrics:df51b1 - font metrics (ascent, descent, line_gap) used as baselines for inline layout alignment and box sizing
1765/// Layout-specific font metrics extracted from `FontMetrics`
1766/// Contains only the metrics needed for text layout and rendering
1767// +spec:box-model:a2f1c1 - inline box content area sized from first available font metrics (ascent/descent)
1768// +spec:font-metrics:9c2ca5 - ascent and descent metrics per font for inline layout
1769// +spec:font-metrics:797593 - font metrics (ascent, descent, line-gap) used for baseline calculations
1770// +spec:font-metrics:842d6a - font metrics (ascent, descent) used for precise spacing control
1771// +spec:font-metrics:eb97e0 - Font baseline metrics (ascent/descent) from font tables used for baseline alignment
1772// +spec:font-metrics:f2cd75 - em-over/em-under baselines intentionally not included (not used by CSS per spec)
1773// +spec:inline-formatting-context:76cd57 - ascent/descent font metrics for inline formatting context layout
1774// +spec:font-metrics:207e6b - ascent/descent metrics used for baseline calculations
1775#[derive(Copy, Debug, Clone)]
1776pub struct LayoutFontMetrics {
1777 pub ascent: f32,
1778 pub descent: f32,
1779 pub line_gap: f32,
1780 pub units_per_em: u16,
1781 /// OS/2 sxHeight: distance from baseline to top of lowercase 'x' (in font units).
1782 /// Used for `vertical-align: middle` per CSS Inline 3 §4.1.
1783 pub x_height: Option<f32>,
1784 /// OS/2 sCapHeight: height of capital letters from baseline (in font units).
1785 /// Used for drop cap / initial-letter alignment per CSS Inline 3 §7.1.1.
1786 pub cap_height: Option<f32>,
1787}
1788
1789impl LayoutFontMetrics {
1790 // +spec:font-metrics:006bd8 - baseline position from font design coordinates, scaled with font size
1791 // +spec:font-metrics:910c0a - dominant-baseline: auto resolves to alphabetic for horizontal text
1792 // +spec:writing-modes:098958 - baseline is along the inline axis, used to align glyphs
1793 #[must_use] pub fn baseline_scaled(&self, font_size: f32) -> f32 {
1794 let scale = font_size / f32::from(self.units_per_em);
1795 self.ascent * scale
1796 }
1797
1798 /// Returns the x-height scaled to the given font size in px.
1799 /// Falls back to 0.5em when the font doesn't provide sxHeight.
1800 #[must_use] pub fn x_height_scaled(&self, font_size: f32) -> f32 {
1801 let scale = font_size / f32::from(self.units_per_em);
1802 self.x_height.map_or(font_size * 0.5, |xh| xh * scale)
1803 }
1804
1805 /// Returns the cap height scaled to the given font size in px.
1806 /// Falls back to ascent when the font doesn't provide sCapHeight.
1807 #[must_use] pub fn cap_height_scaled(&self, font_size: f32) -> f32 {
1808 let scale = font_size / f32::from(self.units_per_em);
1809 self.cap_height.unwrap_or(self.ascent) * scale
1810 }
1811
1812 // +spec:line-height:471816 - line gap metric extracted from font for optional use when line-height is normal
1813 /// Convert from full `FontMetrics` to layout-specific metrics.
1814 ///
1815 // +spec:font-metrics:05193a - prefer OS/2 sTypoAscender/sTypoDescender, fall back to HHEA
1816 // +spec:font-metrics:17a71c - prefer OS/2 sTypoAscender/sTypoDescender, fall back to HHEA
1817 // +spec:font-metrics:62c659 - prefer OS/2 sTypoAscender/sTypoDescender, fall back to HHEA
1818 // +spec:writing-modes:451a3e - ascent/descent/line-gap metrics: prefer OS/2, fallback HHEA, floor line_gap at 0
1819 /// Per CSS 2.2 §10.8.1: prefer OS/2 sTypoAscender/sTypoDescender,
1820 /// fall back to HHEA Ascent/Descent if OS/2 metrics are absent.
1821 // +spec:font-metrics:3dc8c1 - text-over/text-under baselines from font ascent/descent metrics
1822 // +spec:font-metrics:332c16 - text-over/text-under baseline metrics derived from font ascent/descent
1823 // +spec:font-metrics:9895e2 - baseline table is a font-level property; metrics apply uniformly to all glyphs
1824 // +spec:font-metrics:e05c40 - font ascent/descent metric extraction (text edge metrics)
1825 // +spec:font-metrics:21a3de - ascent/descent used as basis for em-over/em-under normalization
1826 // +spec:font-metrics:1257b7 - font ascent/descent ensure text fits within line box
1827 // +spec:table-layout:6bbd10 - use sTypoAscender/sTypoDescender as ascent/descent metrics per spec recommendation
1828 // +spec:font-metrics:5346d2 - prefer OS/2 sTypoAscender/sTypoDescender, fall back to HHEA
1829 // +spec:font-metrics:e16941 - line gap metric floored at zero per spec
1830 // +spec:font-metrics:a55c05 - metrics taken from font, synthesized if missing (prefers OS/2, falls back to HHEA)
1831 #[must_use] pub fn from_font_metrics(metrics: &azul_css::props::basic::FontMetrics) -> Self {
1832 let ascent = metrics.s_typo_ascender
1833 .as_option()
1834 .map_or_else(|| f32::from(metrics.ascender), |v| f32::from(*v));
1835 let descent = metrics.s_typo_descender
1836 .as_option()
1837 .map_or_else(|| f32::from(metrics.descender), |v| f32::from(*v));
1838 // UAs must floor the line gap metric at zero (css-inline-3 §3.2.2)
1839 // Spec: "UAs must floor the line gap metric at zero."
1840 let line_gap = metrics.s_typo_line_gap
1841 .as_option()
1842 .map_or_else(|| f32::from(metrics.line_gap), |v| f32::from(*v))
1843 .max(0.0);
1844 let x_height = metrics.sx_height
1845 .as_option()
1846 .map(|v| f32::from(*v));
1847 let cap_height = metrics.s_cap_height
1848 .as_option()
1849 .map(|v| f32::from(*v));
1850 Self {
1851 ascent,
1852 descent,
1853 line_gap,
1854 units_per_em: metrics.units_per_em,
1855 x_height,
1856 cap_height,
1857 }
1858 }
1859
1860 // +spec:font-metrics:1eda6b - em-over is 0.5em over central baseline, em-under is 0.5em under
1861 /// Synthesize em-over baseline offset (in font units).
1862 /// Per CSS Inline 3 Appendix A.1: em-over = central baseline + 0.5em.
1863 /// Central baseline is synthesized as midpoint of ascent and descent.
1864 #[must_use] pub fn em_over(&self) -> f32 {
1865 let central = self.central_baseline();
1866 central + (f32::from(self.units_per_em) / 2.0)
1867 }
1868
1869 /// Synthesize em-under baseline offset (in font units).
1870 /// Per CSS Inline 3 Appendix A.1: em-under = central baseline - 0.5em.
1871 #[must_use] pub fn em_under(&self) -> f32 {
1872 let central = self.central_baseline();
1873 central - (f32::from(self.units_per_em) / 2.0)
1874 }
1875
1876 /// Synthesize central baseline (in font units).
1877 /// Midpoint between ascent and descent when not provided by the font.
1878 #[must_use] pub const fn central_baseline(&self) -> f32 {
1879 f32::midpoint(self.ascent, self.descent)
1880 }
1881}
1882
1883#[derive(Copy, Debug, Clone)]
1884pub struct LineSegment {
1885 pub start_x: f32,
1886 pub width: f32,
1887 // For choosing best segment when multiple available
1888 pub priority: u8,
1889}
1890
1891#[derive(Debug, Clone, Copy, PartialEq, Hash, Eq, PartialOrd, Ord, Default)]
1892pub enum TextWrap {
1893 #[default]
1894 Wrap,
1895 Balance,
1896 NoWrap,
1897}
1898
1899/// CSS `overflow-wrap` (aka `word-wrap`) property.
1900///
1901/// Controls whether an otherwise unbreakable sequence of characters
1902/// may be broken at an arbitrary point to prevent overflow.
1903#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
1904pub enum OverflowWrap {
1905 /// No special break opportunities are introduced.
1906 #[default]
1907 Normal,
1908 /// Break at arbitrary points if no other break points exist.
1909 /// Soft wrap opportunities from `anywhere` ARE considered
1910 /// when calculating min-content intrinsic sizes.
1911 Anywhere,
1912 /// Same as `anywhere` except soft wrap opportunities introduced
1913 /// by `break-word` are NOT considered when calculating
1914 /// min-content intrinsic sizes.
1915 BreakWord,
1916}
1917
1918// +spec:line-breaking:841a87 - hyphens property: manual (U+00AD/U+2010 only) and auto (language-aware automatic hyphenation)
1919// +spec:line-breaking:68c6ad - hyphens property controls hyphenation opportunities (none/manual/auto)
1920/// Controls whether hyphenation is allowed to create soft wrap opportunities.
1921#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
1922pub enum Hyphens {
1923 /// No hyphenation: U+00AD soft hyphens are not treated as break points.
1924 None,
1925 /// Only break at manually-inserted soft hyphens (U+00AD) or explicit hyphens.
1926 #[default]
1927 Manual,
1928 /// The UA may automatically hyphenate words in addition to manual opportunities.
1929 Auto,
1930}
1931
1932// +spec:line-breaking:ce5258 - white-space property controls collapsing, wrapping, and forced breaks
1933// +spec:line-breaking:35817b - normal/pre/nowrap/pre-wrap/break-spaces/pre-line behaviors
1934// +spec:white-space-processing:dec7aa - White space not removed/collapsed is "preserved white space"
1935#[derive(Debug, Clone, Copy, PartialEq, Hash, Eq, PartialOrd, Ord, Default)]
1936pub enum WhiteSpaceMode {
1937 #[default]
1938 Normal,
1939 Nowrap,
1940 Pre,
1941 PreWrap,
1942 PreLine,
1943 BreakSpaces,
1944}
1945
1946// CSS Text Level 3 §5.3: The line-break property controls strictness of line breaking rules.
1947// - Auto: UA-dependent, typically normal for CJK, loose for non-CJK
1948// - Loose: least restrictive, allows breaks before small kana, CJK hyphens, etc.
1949// - Normal: default CJK rules, allows breaks before CJK hyphen-like chars for CJK text
1950// - Strict: most restrictive, forbids breaks before small kana and CJK punctuation
1951// - Anywhere: allows soft wrap opportunities around every typographic character unit
1952#[derive(Debug, Clone, Copy, PartialEq, Hash, Eq, PartialOrd, Ord, Default)]
1953pub enum LineBreakStrictness {
1954 #[default]
1955 Auto,
1956 Loose,
1957 Normal,
1958 Strict,
1959 /// Soft wrap opportunity around every typographic character unit.
1960 /// Hyphenation is not applied.
1961 Anywhere,
1962}
1963
1964// §5.2 word-break property: normal, break-all, keep-all
1965#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
1966pub enum WordBreak {
1967 /// Normal break rules: CJK characters break between each other,
1968 /// non-CJK text only breaks at spaces/hyphens.
1969 #[default]
1970 Normal,
1971 /// Allow breaks between any two characters, including within Latin words.
1972 BreakAll,
1973 /// Suppress breaks between CJK characters (treat them like Latin words,
1974 /// only breaking at spaces). Sequences of CJK characters do not break.
1975 KeepAll,
1976}
1977
1978// +spec:display-property:162c99 - Initial letter box: in-flow inline-level box with special layout behavior
1979// +spec:display-property:72a797 - Initial letter handled like inline-level content in originating line box
1980// initial-letter
1981// +spec:containing-block:46a499 - subsequent block must clear previous block's initial letter if it starts with its own initial letter, establishes independent FC, or specifies clear in initial letter's CB start direction
1982// +spec:font-metrics:1e5325 - drop initial cap-height = (N-1)*line_height + surrounding cap-height
1983// +spec:font-metrics:3aa518 - initial-letter-align: cap-height/ideographic/hanging/leading/border-box baseline alignment
1984// +spec:writing-modes:9698b0 - Han-derived scripts: initial letter extends from block-start to block-end of Nth line
1985#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
1986pub struct InitialLetter {
1987 /// How many lines tall the initial letter should be.
1988 pub size: f32,
1989 // +spec:font-metrics:dc0632 - raised initial "sinks" to first text baseline (sink=1)
1990 /// How many lines the letter should sink into.
1991 pub sink: u32,
1992 /// How many characters to apply this styling to.
1993 pub count: NonZeroUsize,
1994 // +spec:display-property:4c69bf - alignment points for sizing/positioning initial letter
1995 /// Alignment mode for the initial letter (over/under alignment points
1996 /// matched to corresponding points of the root inline box).
1997 pub align: InitialLetterAlign,
1998}
1999
2000/// Alignment mode for initial letters, controlling which alignment points
2001/// are used to size and position the letter relative to the root inline box.
2002#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
2003pub enum InitialLetterAlign {
2004 /// UA chooses based on script
2005 Auto,
2006 /// Alphabetic baseline alignment
2007 Alphabetic,
2008 /// Hanging baseline alignment
2009 Hanging,
2010 /// Ideographic baseline alignment
2011 Ideographic,
2012}
2013
2014// A type that implements `Hash` must also implement `Eq`.
2015// Since f32 does not implement `Eq`, we provide a manual implementation.
2016// This is a marker trait, indicating that `a == b` is a true equivalence
2017// relation. The derived `PartialEq` already satisfies this.
2018impl Eq for InitialLetter {}
2019
2020impl Hash for InitialLetter {
2021 #[allow(clippy::cast_possible_truncation)] // bounded pixel/coord/colour/glyph cast
2022 fn hash<H: Hasher>(&self, state: &mut H) {
2023 // Per the request, round the f32 to a usize for hashing.
2024 // This is a lossy conversion; values like 2.3 and 2.4 will produce
2025 // the same hash value for this field. This is acceptable as long as
2026 // the `PartialEq` implementation correctly distinguishes them.
2027 (self.size.round() as isize).hash(state);
2028 self.sink.hash(state);
2029 self.count.hash(state);
2030 self.align.hash(state);
2031 }
2032}
2033
2034// Path and shape definitions
2035#[derive(Copy, Debug, Clone, PartialOrd)]
2036pub enum PathSegment {
2037 MoveTo(Point),
2038 LineTo(Point),
2039 CurveTo {
2040 control1: Point,
2041 control2: Point,
2042 end: Point,
2043 },
2044 QuadTo {
2045 control: Point,
2046 end: Point,
2047 },
2048 Arc {
2049 center: Point,
2050 radius: f32,
2051 start_angle: f32,
2052 end_angle: f32,
2053 },
2054 Close,
2055}
2056
2057// PathSegment
2058impl Hash for PathSegment {
2059 #[allow(clippy::cast_possible_truncation)] // bounded pixel/coord/colour/glyph cast
2060 #[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
2061 fn hash<H: Hasher>(&self, state: &mut H) {
2062 // Hash the enum variant's discriminant first to distinguish them
2063 discriminant(self).hash(state);
2064
2065 match self {
2066 Self::MoveTo(p) => p.hash(state),
2067 Self::LineTo(p) => p.hash(state),
2068 Self::CurveTo {
2069 control1,
2070 control2,
2071 end,
2072 } => {
2073 control1.hash(state);
2074 control2.hash(state);
2075 end.hash(state);
2076 }
2077 Self::QuadTo { control, end } => {
2078 control.hash(state);
2079 end.hash(state);
2080 }
2081 Self::Arc {
2082 center,
2083 radius,
2084 start_angle,
2085 end_angle,
2086 } => {
2087 center.hash(state);
2088 (radius.round() as isize).hash(state);
2089 (start_angle.round() as isize).hash(state);
2090 (end_angle.round() as isize).hash(state);
2091 }
2092 Self::Close => {} // No data to hash
2093 }
2094 }
2095}
2096
2097impl PartialEq for PathSegment {
2098 #[allow(clippy::similar_names)] // domain-standard coordinate/geometry/short-lived names
2099 #[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
2100 fn eq(&self, other: &Self) -> bool {
2101 match (self, other) {
2102 (Self::MoveTo(a), Self::MoveTo(b)) => a == b,
2103 (Self::LineTo(a), Self::LineTo(b)) => a == b,
2104 (
2105 Self::CurveTo {
2106 control1: c1a,
2107 control2: c2a,
2108 end: ea,
2109 },
2110 Self::CurveTo {
2111 control1: c1b,
2112 control2: c2b,
2113 end: eb,
2114 },
2115 ) => c1a == c1b && c2a == c2b && ea == eb,
2116 (
2117 Self::QuadTo {
2118 control: ca,
2119 end: ea,
2120 },
2121 Self::QuadTo {
2122 control: cb,
2123 end: eb,
2124 },
2125 ) => ca == cb && ea == eb,
2126 (
2127 Self::Arc {
2128 center: ca,
2129 radius: ra,
2130 start_angle: sa_a,
2131 end_angle: ea_a,
2132 },
2133 Self::Arc {
2134 center: cb,
2135 radius: rb,
2136 start_angle: sa_b,
2137 end_angle: ea_b,
2138 },
2139 ) => ca == cb && round_eq(*ra, *rb) && round_eq(*sa_a, *sa_b) && round_eq(*ea_a, *ea_b),
2140 (Self::Close, Self::Close) => true,
2141 _ => false, // Variants are different
2142 }
2143 }
2144}
2145
2146impl Eq for PathSegment {}
2147
2148// Enhanced content model supporting mixed inline content
2149// [g117 az-web-lift FIX] `#[repr(C, u8)]` (was repr(Rust)): the web lift MIS-READS a repr(Rust)
2150// niche/compiler-placed discriminant — `<InlineContent as Clone>::clone` and create_logical_items'
2151// match both mis-route a Text(disc 0) to a Vec-bearing variant → clone reads a heap ptr as a Vec len
2152// → ~789MB alloc → OOB (g111/g115/g116 named stack = InlineContent::clone ← create_logical_items;
2153// content is CLEAN: len=1, ptr ok, disc-at-0=0). An explicit u8 tag at offset 0 (no niche) lowers to
2154// a simple load the lift handles correctly — the layout other (repr(C,u8)) enums use. Not FFI-exposed
2155// (internal to text3; only native shell code matches it), so the repr change is layout-safe.
2156#[derive(Debug, Clone, Hash)]
2157#[repr(C, u8)]
2158pub enum InlineContent {
2159 Text(StyledRun),
2160 Image(InlineImage),
2161 Shape(InlineShape),
2162 Space(InlineSpace),
2163 LineBreak(InlineBreak),
2164 /// Tab character - rendered with width based on tab-size CSS property
2165 Tab {
2166 style: Arc<StyleProperties>,
2167 },
2168 /// List marker (`::marker` pseudo-element)
2169 /// Markers with list-style-position: outside are positioned
2170 /// in the padding gutter of the list container
2171 Marker {
2172 run: StyledRun,
2173 /// Whether marker is positioned outside (in padding) or inside (inline)
2174 position_outside: bool,
2175 },
2176 // Ruby annotation
2177 Ruby {
2178 base: Vec<InlineContent>,
2179 text: Vec<InlineContent>,
2180 // Style for the ruby text itself
2181 style: Arc<StyleProperties>,
2182 },
2183}
2184
2185#[derive(Debug, Clone)]
2186pub struct InlineImage {
2187 pub source: ImageSource,
2188 pub intrinsic_size: Size,
2189 pub display_size: Option<Size>,
2190 // How much to shift baseline
2191 pub baseline_offset: f32,
2192 pub alignment: VerticalAlign,
2193 pub object_fit: ObjectFit,
2194}
2195
2196impl PartialEq for InlineImage {
2197 fn eq(&self, other: &Self) -> bool {
2198 self.baseline_offset.to_bits() == other.baseline_offset.to_bits()
2199 && self.source == other.source
2200 && self.intrinsic_size == other.intrinsic_size
2201 && self.display_size == other.display_size
2202 && self.alignment == other.alignment
2203 && self.object_fit == other.object_fit
2204 }
2205}
2206
2207impl Eq for InlineImage {}
2208
2209impl Hash for InlineImage {
2210 fn hash<H: Hasher>(&self, state: &mut H) {
2211 self.source.hash(state);
2212 self.intrinsic_size.hash(state);
2213 self.display_size.hash(state);
2214 self.baseline_offset.to_bits().hash(state);
2215 self.alignment.hash(state);
2216 self.object_fit.hash(state);
2217 }
2218}
2219
2220impl PartialOrd for InlineImage {
2221 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
2222 Some(self.cmp(other))
2223 }
2224}
2225
2226impl Ord for InlineImage {
2227 fn cmp(&self, other: &Self) -> Ordering {
2228 self.source
2229 .cmp(&other.source)
2230 .then_with(|| self.intrinsic_size.cmp(&other.intrinsic_size))
2231 .then_with(|| self.display_size.cmp(&other.display_size))
2232 .then_with(|| self.baseline_offset.total_cmp(&other.baseline_offset))
2233 .then_with(|| self.alignment.cmp(&other.alignment))
2234 .then_with(|| self.object_fit.cmp(&other.object_fit))
2235 }
2236}
2237
2238/// Enhanced glyph with all features
2239#[derive(Debug, Clone)]
2240pub struct Glyph {
2241 // Core glyph data
2242 pub glyph_id: u16,
2243 pub codepoint: char,
2244 /// Hash of the font - use `LoadedFonts` to look up the actual font when needed
2245 pub font_hash: u64,
2246 /// Cached font metrics to avoid font lookup for common operations
2247 pub font_metrics: LayoutFontMetrics,
2248 pub style: Arc<StyleProperties>,
2249 pub source: GlyphSource,
2250
2251 // Text mapping
2252 pub logical_byte_index: usize,
2253 pub logical_byte_len: usize,
2254 pub content_index: usize,
2255 pub cluster: u32,
2256
2257 // Metrics
2258 pub advance: f32,
2259 pub kerning: f32,
2260 pub offset: Point,
2261
2262 // Vertical text support
2263 pub vertical_advance: f32,
2264 pub vertical_origin_y: f32, // from VORG
2265 pub vertical_bearing: Point,
2266 pub orientation: GlyphOrientation,
2267
2268 // Layout properties
2269 pub script: Script,
2270 pub bidi_level: BidiLevel,
2271}
2272
2273impl Glyph {
2274 #[inline]
2275 fn bounds(&self) -> Rect {
2276 Rect {
2277 x: 0.0,
2278 y: 0.0,
2279 width: self.advance,
2280 height: self.style.line_height.resolve_with_metrics(self.style.font_size_px, &self.font_metrics),
2281 }
2282 }
2283
2284 #[inline]
2285 const fn character_class(&self) -> CharacterClass {
2286 classify_character(self.codepoint as u32)
2287 }
2288
2289 #[inline]
2290 fn is_whitespace(&self) -> bool {
2291 self.character_class() == CharacterClass::Space
2292 }
2293
2294 #[inline]
2295 fn can_justify(&self) -> bool {
2296 !self.codepoint.is_whitespace() && self.character_class() != CharacterClass::Combining
2297 }
2298
2299 #[inline]
2300 const fn justification_priority(&self) -> u8 {
2301 get_justification_priority(self.character_class())
2302 }
2303
2304 #[inline]
2305 const fn break_opportunity_after(&self) -> bool {
2306 let is_whitespace = self.codepoint.is_whitespace();
2307 let is_soft_hyphen = self.codepoint == '\u{00AD}';
2308 let is_hyphen_minus = self.codepoint == '\u{002D}';
2309 let is_hyphen = self.codepoint == '\u{2010}';
2310 is_whitespace || is_soft_hyphen || is_hyphen_minus || is_hyphen
2311 }
2312}
2313
2314// Information about text runs after initial analysis
2315#[derive(Debug, Clone)]
2316pub(crate) struct TextRunInfo<'a> {
2317 pub(crate) text: &'a str,
2318 pub(crate) style: Arc<StyleProperties>,
2319 pub(crate) logical_start: usize,
2320 pub(crate) content_index: usize,
2321}
2322
2323#[derive(Debug, Clone)]
2324pub enum ImageSource {
2325 /// Direct reference to decoded image (from DOM `NodeType::Image`)
2326 Ref(ImageRef),
2327 /// CSS url reference (from background-image, needs `ImageCache` lookup)
2328 Url(String),
2329 /// Raw image data
2330 Data(Arc<[u8]>),
2331 /// SVG source
2332 Svg(Arc<str>),
2333 /// Placeholder for layout without actual image
2334 Placeholder(Size),
2335}
2336
2337impl PartialEq for ImageSource {
2338 fn eq(&self, other: &Self) -> bool {
2339 match (self, other) {
2340 (Self::Ref(a), Self::Ref(b)) => a.get_hash() == b.get_hash(),
2341 (Self::Url(a), Self::Url(b)) => a == b,
2342 (Self::Data(a), Self::Data(b)) => Arc::ptr_eq(a, b),
2343 (Self::Svg(a), Self::Svg(b)) => Arc::ptr_eq(a, b),
2344 (Self::Placeholder(a), Self::Placeholder(b)) => {
2345 a.width.to_bits() == b.width.to_bits() && a.height.to_bits() == b.height.to_bits()
2346 }
2347 _ => false,
2348 }
2349 }
2350}
2351
2352impl Eq for ImageSource {}
2353
2354impl Hash for ImageSource {
2355 fn hash<H: Hasher>(&self, state: &mut H) {
2356 discriminant(self).hash(state);
2357 match self {
2358 Self::Ref(r) => r.get_hash().hash(state),
2359 Self::Url(s) => s.hash(state),
2360 Self::Data(d) => (Arc::as_ptr(d).cast::<u8>() as usize).hash(state),
2361 Self::Svg(s) => (Arc::as_ptr(s).cast::<u8>() as usize).hash(state),
2362 Self::Placeholder(sz) => {
2363 sz.width.to_bits().hash(state);
2364 sz.height.to_bits().hash(state);
2365 }
2366 }
2367 }
2368}
2369
2370impl PartialOrd for ImageSource {
2371 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
2372 Some(self.cmp(other))
2373 }
2374}
2375
2376impl Ord for ImageSource {
2377 fn cmp(&self, other: &Self) -> Ordering {
2378 const fn variant_index(s: &ImageSource) -> u8 {
2379 match s {
2380 ImageSource::Ref(_) => 0,
2381 ImageSource::Url(_) => 1,
2382 ImageSource::Data(_) => 2,
2383 ImageSource::Svg(_) => 3,
2384 ImageSource::Placeholder(_) => 4,
2385 }
2386 }
2387 match (self, other) {
2388 (Self::Ref(a), Self::Ref(b)) => a.get_hash().cmp(&b.get_hash()),
2389 (Self::Url(a), Self::Url(b)) => a.cmp(b),
2390 (Self::Data(a), Self::Data(b)) => {
2391 (Arc::as_ptr(a).cast::<u8>() as usize).cmp(&(Arc::as_ptr(b).cast::<u8>() as usize))
2392 }
2393 (Self::Svg(a), Self::Svg(b)) => {
2394 (Arc::as_ptr(a).cast::<u8>() as usize).cmp(&(Arc::as_ptr(b).cast::<u8>() as usize))
2395 }
2396 (Self::Placeholder(a), Self::Placeholder(b)) => {
2397 (a.width.to_bits(), a.height.to_bits())
2398 .cmp(&(b.width.to_bits(), b.height.to_bits()))
2399 }
2400 // Different variants: compare by variant index
2401 _ => variant_index(self).cmp(&variant_index(other)),
2402 }
2403 }
2404}
2405
2406// +spec:font-metrics:fa104e - vertical-align values; baseline-source defaults to auto (first baseline)
2407// +spec:inline-formatting-context:340729 - alignment-baseline values for IFC baseline alignment (only baseline/top/bottom/middle implemented)
2408// CSS 2.2 §10.8.1 vertical-align property values
2409// +spec:display-property:0b1deb - inline boxes use dominant baseline to align text and inline-level children
2410// +spec:inline-formatting-context:3996a6 - dominant-baseline defaults to alphabetic in horizontal mode; vertical-align handles baseline alignment and super/sub shifting
2411#[derive(Default, Debug, Clone, Copy, PartialEq, PartialOrd)]
2412pub enum VerticalAlign {
2413 // Align baseline of box with baseline of parent box
2414 #[default]
2415 Baseline,
2416 // Align bottom of aligned subtree with bottom of line box
2417 Bottom,
2418 // Align top of aligned subtree with top of line box
2419 Top,
2420 // Align vertical midpoint of box with baseline of parent plus half x-height
2421 Middle,
2422 // Align top of box with top of parent's content area (§10.6.1)
2423 TextTop,
2424 // Align bottom of box with bottom of parent's content area (§10.6.1)
2425 TextBottom,
2426 // Lower baseline to proper subscript position
2427 Sub,
2428 // Raise baseline to proper superscript position
2429 Super,
2430 // +spec:font-metrics:152df3 - Raise (positive) or lower (negative) by this distance; 0 = baseline
2431 Offset(f32),
2432}
2433
2434impl Hash for VerticalAlign {
2435 fn hash<H: Hasher>(&self, state: &mut H) {
2436 discriminant(self).hash(state);
2437 if let Self::Offset(f) = self {
2438 f.to_bits().hash(state);
2439 }
2440 }
2441}
2442
2443impl Eq for VerticalAlign {}
2444
2445// cmp delegates to the derived PartialOrd (unwrap_or(Equal)), so Ord and PartialOrd are
2446// consistent; Ord can't be derived because of the f32 `Offset` variant.
2447#[allow(clippy::derive_ord_xor_partial_ord)]
2448impl Ord for VerticalAlign {
2449 fn cmp(&self, other: &Self) -> Ordering {
2450 self.partial_cmp(other).unwrap_or(Ordering::Equal)
2451 }
2452}
2453
2454#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, PartialOrd, Ord)]
2455pub enum ObjectFit {
2456 // Stretch to fit display size
2457 Fill,
2458 // Scale to fit within display size
2459 Contain,
2460 // Scale to cover display size
2461 Cover,
2462 // Use intrinsic size
2463 None,
2464 // Like contain but never scale up
2465 ScaleDown,
2466}
2467
2468/// Border information for inline elements (display: inline, inline-block)
2469///
2470/// This stores the resolved border properties needed for rendering inline element borders.
2471/// Unlike block elements which render borders via `paint_node_background_and_border()`,
2472/// inline element borders must be rendered per glyph-run to handle line breaks correctly.
2473#[derive(Copy, Debug, Clone, PartialEq)]
2474pub struct InlineBorderInfo {
2475 /// Border widths in pixels for each side
2476 pub top: f32,
2477 pub right: f32,
2478 pub bottom: f32,
2479 pub left: f32,
2480 /// Border colors for each side
2481 pub top_color: ColorU,
2482 pub right_color: ColorU,
2483 pub bottom_color: ColorU,
2484 pub left_color: ColorU,
2485 /// Border radius (if any)
2486 pub radius: Option<f32>,
2487 /// Padding widths in pixels for each side (needed to expand background rect)
2488 pub padding_top: f32,
2489 pub padding_right: f32,
2490 pub padding_bottom: f32,
2491 pub padding_left: f32,
2492 // +spec:box-model:c5723b - inline box split: suppress margin/border/padding at split points
2493 /// CSS 2.2 §9.4.2 / §8.6: when an inline box is split across line boxes,
2494 /// margins, borders, and padding have no visible effect at the split points.
2495 /// True if this is the first fragment of the inline box.
2496 pub is_first_fragment: bool,
2497 /// True if this is the last fragment of the inline box.
2498 pub is_last_fragment: bool,
2499 /// CSS 2.2 §8.6: direction flag for visual-order rendering in bidi context.
2500 /// LTR: first fragment gets left edge, last gets right edge.
2501 /// RTL: first fragment gets right edge, last gets left edge.
2502 pub is_rtl: bool,
2503}
2504
2505impl Default for InlineBorderInfo {
2506 fn default() -> Self {
2507 Self {
2508 top: 0.0,
2509 right: 0.0,
2510 bottom: 0.0,
2511 left: 0.0,
2512 top_color: ColorU::TRANSPARENT,
2513 right_color: ColorU::TRANSPARENT,
2514 bottom_color: ColorU::TRANSPARENT,
2515 left_color: ColorU::TRANSPARENT,
2516 radius: None,
2517 padding_top: 0.0,
2518 padding_right: 0.0,
2519 padding_bottom: 0.0,
2520 padding_left: 0.0,
2521 is_first_fragment: true,
2522 is_last_fragment: true,
2523 is_rtl: false,
2524 }
2525 }
2526}
2527
2528impl InlineBorderInfo {
2529 /// Returns true if any border has a non-zero width
2530 #[must_use] pub fn has_border(&self) -> bool {
2531 self.top > 0.0 || self.right > 0.0 || self.bottom > 0.0 || self.left > 0.0
2532 }
2533
2534 /// Returns true if any border or padding is present
2535 #[must_use] pub fn has_chrome(&self) -> bool {
2536 self.has_border()
2537 || self.padding_top > 0.0
2538 || self.padding_right > 0.0
2539 || self.padding_bottom > 0.0
2540 || self.padding_left > 0.0
2541 }
2542
2543 // +spec:box-model:da0ba2 - RTL bidi inline box split: left/right edges assigned to correct fragments
2544 // +spec:box-model:e9144f - visual-order margin/border/padding for inline boxes in bidi context
2545 // +spec:box-model:fac66f - Assigns margins/borders/padding in visual order for bidi inline fragments
2546 // +spec:box-model:720688 - LTR: left on first, right on last; RTL: right on first, left on last
2547 // +spec:positioning:1fcad6 - bidi-aware margin/border/padding on inline box fragments per visual order
2548 /// Total left inset (border + padding), suppressed at split points per §8.6.
2549 /// In LTR: left edge drawn on first fragment. In RTL: left edge drawn on last fragment.
2550 // +spec:box-model:bae97f - visual-order margin/border/padding assignment for bidi inline fragments
2551 #[must_use] pub fn left_inset(&self) -> f32 {
2552 let show = if self.is_rtl { self.is_last_fragment } else { self.is_first_fragment };
2553 if show { self.left + self.padding_left } else { 0.0 }
2554 }
2555 /// Total right inset (border + padding), suppressed at split points per §8.6.
2556 /// In LTR: right edge drawn on last fragment. In RTL: right edge drawn on first fragment.
2557 #[must_use] pub fn right_inset(&self) -> f32 {
2558 let show = if self.is_rtl { self.is_first_fragment } else { self.is_last_fragment };
2559 if show { self.right + self.padding_right } else { 0.0 }
2560 }
2561 /// Total top inset (border + padding)
2562 #[must_use] pub fn top_inset(&self) -> f32 { self.top + self.padding_top }
2563 /// Total bottom inset (border + padding)
2564 #[must_use] pub fn bottom_inset(&self) -> f32 { self.bottom + self.padding_bottom }
2565}
2566
2567#[derive(Debug, Clone)]
2568pub struct InlineShape {
2569 pub shape_def: ShapeDefinition,
2570 pub fill: Option<ColorU>,
2571 pub stroke: Option<Stroke>,
2572 pub baseline_offset: f32,
2573 /// Per-item vertical alignment (CSS `vertical-align` on the inline-block element).
2574 /// This overrides the global `TextStyleOptions::vertical_align` for this shape.
2575 pub alignment: VerticalAlign,
2576 /// The `NodeId` of the element that created this shape
2577 /// (e.g., inline-block) - this allows us to look up
2578 /// styling information (background, border) when rendering
2579 pub source_node_id: Option<NodeId>,
2580}
2581
2582#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
2583pub enum OverflowBehavior {
2584 // Content extends outside shape
2585 Visible,
2586 // Content is clipped to shape
2587 Hidden,
2588 // Scrollable overflow
2589 Scroll,
2590 // Browser/system decides
2591 #[default]
2592 Auto,
2593 // Break into next shape/page
2594 Break,
2595}
2596
2597#[derive(Debug, Clone)]
2598pub(crate) struct MeasuredImage {
2599 pub(crate) source: ImageSource,
2600 pub(crate) size: Size,
2601 pub(crate) baseline_offset: f32,
2602 pub(crate) alignment: VerticalAlign,
2603 pub(crate) content_index: usize,
2604}
2605
2606#[derive(Debug, Clone)]
2607pub(crate) struct MeasuredShape {
2608 pub(crate) shape_def: ShapeDefinition,
2609 pub(crate) size: Size,
2610 pub(crate) baseline_offset: f32,
2611 pub(crate) alignment: VerticalAlign,
2612 pub(crate) content_index: usize,
2613}
2614
2615#[derive(Copy, Debug, Clone)]
2616pub struct InlineSpace {
2617 pub width: f32,
2618 pub is_breaking: bool, // Can line break here
2619 pub is_stretchy: bool, // Can be expanded for justification
2620}
2621
2622impl PartialEq for InlineSpace {
2623 fn eq(&self, other: &Self) -> bool {
2624 self.width.to_bits() == other.width.to_bits()
2625 && self.is_breaking == other.is_breaking
2626 && self.is_stretchy == other.is_stretchy
2627 }
2628}
2629
2630impl Eq for InlineSpace {}
2631
2632impl Hash for InlineSpace {
2633 fn hash<H: Hasher>(&self, state: &mut H) {
2634 self.width.to_bits().hash(state);
2635 self.is_breaking.hash(state);
2636 self.is_stretchy.hash(state);
2637 }
2638}
2639
2640impl PartialOrd for InlineSpace {
2641 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
2642 Some(self.cmp(other))
2643 }
2644}
2645
2646impl Ord for InlineSpace {
2647 fn cmp(&self, other: &Self) -> Ordering {
2648 self.width
2649 .total_cmp(&other.width)
2650 .then_with(|| self.is_breaking.cmp(&other.is_breaking))
2651 .then_with(|| self.is_stretchy.cmp(&other.is_stretchy))
2652 }
2653}
2654
2655impl PartialEq for InlineShape {
2656 fn eq(&self, other: &Self) -> bool {
2657 self.baseline_offset.to_bits() == other.baseline_offset.to_bits()
2658 && self.shape_def == other.shape_def
2659 && self.fill == other.fill
2660 && self.stroke == other.stroke
2661 && self.alignment == other.alignment
2662 && self.source_node_id == other.source_node_id
2663 }
2664}
2665
2666impl Eq for InlineShape {}
2667
2668impl Hash for InlineShape {
2669 fn hash<H: Hasher>(&self, state: &mut H) {
2670 self.shape_def.hash(state);
2671 self.fill.hash(state);
2672 self.stroke.hash(state);
2673 self.baseline_offset.to_bits().hash(state);
2674 self.alignment.hash(state);
2675 self.source_node_id.hash(state);
2676 }
2677}
2678
2679impl PartialOrd for InlineShape {
2680 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
2681 Some(
2682 self.shape_def
2683 .partial_cmp(&other.shape_def)?
2684 .then_with(|| self.fill.cmp(&other.fill))
2685 .then_with(|| {
2686 self.stroke
2687 .partial_cmp(&other.stroke)
2688 .unwrap_or(Ordering::Equal)
2689 })
2690 .then_with(|| self.baseline_offset.total_cmp(&other.baseline_offset))
2691 .then_with(|| self.alignment.cmp(&other.alignment))
2692 .then_with(|| self.source_node_id.cmp(&other.source_node_id)),
2693 )
2694 }
2695}
2696
2697#[derive(Debug, Default, Clone, Copy)]
2698pub struct Rect {
2699 pub x: f32,
2700 pub y: f32,
2701 pub width: f32,
2702 pub height: f32,
2703}
2704
2705impl PartialEq for Rect {
2706 fn eq(&self, other: &Self) -> bool {
2707 round_eq(self.x, other.x)
2708 && round_eq(self.y, other.y)
2709 && round_eq(self.width, other.width)
2710 && round_eq(self.height, other.height)
2711 }
2712}
2713impl Eq for Rect {}
2714
2715impl Hash for Rect {
2716 #[allow(clippy::cast_possible_truncation)] // bounded pixel/coord/colour/glyph cast
2717 fn hash<H: Hasher>(&self, state: &mut H) {
2718 // The order in which you hash the fields matters.
2719 // A consistent order is crucial.
2720 (self.x.round() as isize).hash(state);
2721 (self.y.round() as isize).hash(state);
2722 (self.width.round() as isize).hash(state);
2723 (self.height.round() as isize).hash(state);
2724 }
2725}
2726
2727#[derive(Debug, Default, Clone, Copy)]
2728pub struct Size {
2729 pub width: f32,
2730 pub height: f32,
2731}
2732
2733impl PartialOrd for Size {
2734 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
2735 Some(self.cmp(other))
2736 }
2737}
2738
2739impl Ord for Size {
2740 #[allow(clippy::cast_possible_truncation)] // bounded pixel/coord/colour/glyph cast
2741 fn cmp(&self, other: &Self) -> Ordering {
2742 (self.width.round() as isize)
2743 .cmp(&(other.width.round() as isize))
2744 .then_with(|| (self.height.round() as isize).cmp(&(other.height.round() as isize)))
2745 }
2746}
2747
2748// Size
2749impl Hash for Size {
2750 #[allow(clippy::cast_possible_truncation)] // bounded pixel/coord/colour/glyph cast
2751 fn hash<H: Hasher>(&self, state: &mut H) {
2752 (self.width.round() as isize).hash(state);
2753 (self.height.round() as isize).hash(state);
2754 }
2755}
2756impl PartialEq for Size {
2757 fn eq(&self, other: &Self) -> bool {
2758 round_eq(self.width, other.width) && round_eq(self.height, other.height)
2759 }
2760}
2761impl Eq for Size {}
2762
2763impl Size {
2764 #[must_use] pub const fn zero() -> Self {
2765 Self::new(0.0, 0.0)
2766 }
2767 #[must_use] pub const fn new(width: f32, height: f32) -> Self {
2768 Self { width, height }
2769 }
2770}
2771
2772#[derive(Debug, Default, Clone, Copy, PartialOrd)]
2773pub struct Point {
2774 pub x: f32,
2775 pub y: f32,
2776}
2777
2778// Point
2779impl Hash for Point {
2780 #[allow(clippy::cast_possible_truncation)] // bounded pixel/coord/colour/glyph cast
2781 fn hash<H: Hasher>(&self, state: &mut H) {
2782 (self.x.round() as isize).hash(state);
2783 (self.y.round() as isize).hash(state);
2784 }
2785}
2786
2787impl PartialEq for Point {
2788 fn eq(&self, other: &Self) -> bool {
2789 round_eq(self.x, other.x) && round_eq(self.y, other.y)
2790 }
2791}
2792
2793impl Eq for Point {}
2794
2795#[derive(Debug, Clone, PartialOrd)]
2796pub enum ShapeDefinition {
2797 Rectangle {
2798 size: Size,
2799 corner_radius: Option<f32>,
2800 },
2801 Circle {
2802 radius: f32,
2803 },
2804 Ellipse {
2805 radii: Size,
2806 },
2807 Polygon {
2808 points: Vec<Point>,
2809 },
2810 Path {
2811 segments: Vec<PathSegment>,
2812 },
2813}
2814
2815// ShapeDefinition
2816impl Hash for ShapeDefinition {
2817 #[allow(clippy::cast_possible_truncation)] // bounded pixel/coord/colour/glyph cast
2818 fn hash<H: Hasher>(&self, state: &mut H) {
2819 discriminant(self).hash(state);
2820 match self {
2821 Self::Rectangle {
2822 size,
2823 corner_radius,
2824 } => {
2825 size.hash(state);
2826 corner_radius.map(|r| r.round() as isize).hash(state);
2827 }
2828 Self::Circle { radius } => {
2829 (radius.round() as isize).hash(state);
2830 }
2831 Self::Ellipse { radii } => {
2832 radii.hash(state);
2833 }
2834 Self::Polygon { points } => {
2835 // Since Point implements Hash, we can hash the Vec directly.
2836 points.hash(state);
2837 }
2838 Self::Path { segments } => {
2839 // Same for Vec<PathSegment>
2840 segments.hash(state);
2841 }
2842 }
2843 }
2844}
2845
2846impl PartialEq for ShapeDefinition {
2847 fn eq(&self, other: &Self) -> bool {
2848 match (self, other) {
2849 (
2850 Self::Rectangle {
2851 size: s1,
2852 corner_radius: r1,
2853 },
2854 Self::Rectangle {
2855 size: s2,
2856 corner_radius: r2,
2857 },
2858 ) => {
2859 s1 == s2
2860 && match (r1, r2) {
2861 (None, None) => true,
2862 (Some(v1), Some(v2)) => round_eq(*v1, *v2),
2863 _ => false,
2864 }
2865 }
2866 (Self::Circle { radius: r1 }, Self::Circle { radius: r2 }) => {
2867 round_eq(*r1, *r2)
2868 }
2869 (Self::Ellipse { radii: r1 }, Self::Ellipse { radii: r2 }) => {
2870 r1 == r2
2871 }
2872 (Self::Polygon { points: p1 }, Self::Polygon { points: p2 }) => {
2873 p1 == p2
2874 }
2875 (Self::Path { segments: s1 }, Self::Path { segments: s2 }) => {
2876 s1 == s2
2877 }
2878 _ => false,
2879 }
2880 }
2881}
2882impl Eq for ShapeDefinition {}
2883
2884impl ShapeDefinition {
2885 /// Calculates the bounding box size for the shape.
2886 #[must_use] pub fn get_size(&self) -> Size {
2887 match self {
2888 // The size is explicitly defined.
2889 Self::Rectangle { size, .. } => *size,
2890
2891 // The bounding box of a circle is a square with sides equal to the diameter.
2892 Self::Circle { radius } => {
2893 let diameter = radius * 2.0;
2894 Size::new(diameter, diameter)
2895 }
2896
2897 // The bounding box of an ellipse has width and height equal to twice its radii.
2898 Self::Ellipse { radii } => Size::new(radii.width * 2.0, radii.height * 2.0),
2899
2900 // For a polygon, we must find the min/max coordinates to get the bounds.
2901 Self::Polygon { points } => calculate_bounding_box_size(points),
2902
2903 // For a path, we find the bounding box of all its anchor and control points.
2904 //
2905 // NOTE: This is a common and fast approximation. The true bounding box of
2906 // bezier curves can be slightly smaller than the box containing their control
2907 // points. For pixel-perfect results, one would need to calculate the
2908 // curve's extrema.
2909 Self::Path { segments } => {
2910 let mut points = Vec::new();
2911 let mut current_pos = Point { x: 0.0, y: 0.0 };
2912
2913 for segment in segments {
2914 match segment {
2915 PathSegment::MoveTo(p) | PathSegment::LineTo(p) => {
2916 points.push(*p);
2917 current_pos = *p;
2918 }
2919 PathSegment::QuadTo { control, end } => {
2920 points.push(current_pos);
2921 points.push(*control);
2922 points.push(*end);
2923 current_pos = *end;
2924 }
2925 PathSegment::CurveTo {
2926 control1,
2927 control2,
2928 end,
2929 } => {
2930 points.push(current_pos);
2931 points.push(*control1);
2932 points.push(*control2);
2933 points.push(*end);
2934 current_pos = *end;
2935 }
2936 PathSegment::Arc {
2937 center,
2938 radius,
2939 start_angle,
2940 end_angle,
2941 } => {
2942 // 1. Calculate and add the arc's start and end points to the list.
2943 let start_point = Point {
2944 x: center.x + radius * start_angle.cos(),
2945 y: center.y + radius * start_angle.sin(),
2946 };
2947 let end_point = Point {
2948 x: center.x + radius * end_angle.cos(),
2949 y: center.y + radius * end_angle.sin(),
2950 };
2951 points.push(start_point);
2952 points.push(end_point);
2953
2954 // 2. Normalize the angles to handle cases where the arc crosses the
2955 // 0-radian line.
2956 // This ensures we can iterate forward from a start to an end angle.
2957 let mut normalized_end = *end_angle;
2958 #[allow(clippy::while_float)] // intentional bounded float loop (angle-wrap / pixel-step); an integer counter would be artificial
2959 while normalized_end < *start_angle {
2960 normalized_end += 2.0 * std::f32::consts::PI;
2961 }
2962
2963 // 3. Find the first cardinal point (multiples of PI/2) at or after the
2964 // start angle.
2965 let mut check_angle = (*start_angle / std::f32::consts::FRAC_PI_2)
2966 .ceil()
2967 * std::f32::consts::FRAC_PI_2;
2968
2969 // 4. Iterate through all cardinal points that fall within the arc's
2970 // sweep and add them.
2971 // These points define the maximum extent of the arc's bounding box.
2972 #[allow(clippy::while_float)] // intentional bounded float loop (angle-wrap / pixel-step); an integer counter would be artificial
2973 while check_angle < normalized_end {
2974 points.push(Point {
2975 x: center.x + radius * check_angle.cos(),
2976 y: center.y + radius * check_angle.sin(),
2977 });
2978 check_angle += std::f32::consts::FRAC_PI_2;
2979 }
2980
2981 // 5. The end of the arc is the new current position for subsequent path
2982 // segments.
2983 current_pos = end_point;
2984 }
2985 PathSegment::Close => {
2986 // No new points are added for closing the path
2987 }
2988 }
2989 }
2990 calculate_bounding_box_size(&points)
2991 }
2992 }
2993 }
2994}
2995
2996// +spec:text-alignment-spacing:25e82a - text-align shorthand resolves text-align-all / text-align-last
2997/// Resolve effective text alignment for a line, handling text-align-last per CSS Text §6.3.
2998/// For the last line (or lines before forced breaks), text-align-last overrides text-align.
2999/// When text-align-last is auto (default), justify falls back to start; others use text-align.
3000// +spec:text-alignment-spacing:bca77d - text-align-last auto falls back to text-align-all, justify→start
3001// +spec:line-breaking:9b10d2 - text-align-last applies to last line and lines before forced breaks
3002/// +spec:text-alignment-spacing:8d88ce - text-align-last overrides justify on last line/forced break
3003pub(crate) fn resolve_effective_alignment(
3004 text_align: TextAlign,
3005 text_align_last: TextAlign,
3006 is_last_or_forced: bool,
3007) -> TextAlign {
3008 if is_last_or_forced {
3009 if text_align_last == TextAlign::default() {
3010 if text_align == TextAlign::Justify { TextAlign::Start } else { text_align }
3011 } else {
3012 text_align_last
3013 }
3014 } else {
3015 text_align
3016 }
3017}
3018
3019/// Helper function to calculate the size of the bounding box enclosing a set of points.
3020fn calculate_bounding_box_size(points: &[Point]) -> Size {
3021 if points.is_empty() {
3022 return Size::zero();
3023 }
3024
3025 let mut min_x = f32::MAX;
3026 let mut max_x = f32::MIN;
3027 let mut min_y = f32::MAX;
3028 let mut max_y = f32::MIN;
3029
3030 for point in points {
3031 min_x = min_x.min(point.x);
3032 max_x = max_x.max(point.x);
3033 min_y = min_y.min(point.y);
3034 max_y = max_y.max(point.y);
3035 }
3036
3037 // Handle case where points might be collinear or a single point
3038 if min_x > max_x || min_y > max_y {
3039 return Size::zero();
3040 }
3041
3042 Size::new(max_x - min_x, max_y - min_y)
3043}
3044
3045#[derive(Debug, Clone, PartialOrd)]
3046pub struct Stroke {
3047 pub color: ColorU,
3048 pub width: f32,
3049 pub dash_pattern: Option<Vec<f32>>,
3050}
3051
3052// Stroke
3053impl Hash for Stroke {
3054 #[allow(clippy::cast_possible_truncation)] // bounded pixel/coord/colour/glyph cast
3055 fn hash<H: Hasher>(&self, state: &mut H) {
3056 self.color.hash(state);
3057 (self.width.round() as isize).hash(state);
3058
3059 // Manual hashing for Option<Vec<f32>>
3060 match &self.dash_pattern {
3061 None => 0u8.hash(state), // Hash a discriminant for None
3062 Some(pattern) => {
3063 1u8.hash(state); // Hash a discriminant for Some
3064 pattern.len().hash(state); // Hash the length
3065 for &val in pattern {
3066 (val.round() as isize).hash(state); // Hash each rounded value
3067 }
3068 }
3069 }
3070 }
3071}
3072
3073impl PartialEq for Stroke {
3074 fn eq(&self, other: &Self) -> bool {
3075 if self.color != other.color || !round_eq(self.width, other.width) {
3076 return false;
3077 }
3078 match (&self.dash_pattern, &other.dash_pattern) {
3079 (None, None) => true,
3080 (Some(p1), Some(p2)) => {
3081 p1.len() == p2.len() && p1.iter().zip(p2.iter()).all(|(a, b)| round_eq(*a, *b))
3082 }
3083 _ => false,
3084 }
3085 }
3086}
3087
3088impl Eq for Stroke {}
3089
3090// Helper function to round f32 for comparison
3091#[allow(clippy::cast_possible_truncation)] // bounded pixel/coord/colour/glyph cast
3092fn round_eq(a: f32, b: f32) -> bool {
3093 (a.round() as isize) == (b.round() as isize)
3094}
3095
3096#[derive(Debug, Clone)]
3097pub enum ShapeBoundary {
3098 Rectangle(Rect),
3099 Circle { center: Point, radius: f32 },
3100 Ellipse { center: Point, radii: Size },
3101 Polygon { points: Vec<Point> },
3102 Path { segments: Vec<PathSegment> },
3103}
3104
3105impl ShapeBoundary {
3106 #[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
3107 #[must_use] pub fn inflate(&self, margin: f32) -> Self {
3108 if margin == 0.0 {
3109 return self.clone();
3110 }
3111 match self {
3112 Self::Rectangle(rect) => Self::Rectangle(Rect {
3113 x: rect.x - margin,
3114 y: rect.y - margin,
3115 width: (rect.width + margin * 2.0).max(0.0),
3116 height: (rect.height + margin * 2.0).max(0.0),
3117 }),
3118 Self::Circle { center, radius } => Self::Circle {
3119 center: *center,
3120 radius: radius + margin,
3121 },
3122 // For simplicity, Polygon and Path inflation is not implemented here.
3123 // A full implementation would require a geometry library to offset the path.
3124 _ => self.clone(),
3125 }
3126 }
3127}
3128
3129// ShapeBoundary
3130impl Hash for ShapeBoundary {
3131 #[allow(clippy::cast_possible_truncation)] // bounded pixel/coord/colour/glyph cast
3132 fn hash<H: Hasher>(&self, state: &mut H) {
3133 discriminant(self).hash(state);
3134 match self {
3135 Self::Rectangle(rect) => rect.hash(state),
3136 Self::Circle { center, radius } => {
3137 center.hash(state);
3138 (radius.round() as isize).hash(state);
3139 }
3140 Self::Ellipse { center, radii } => {
3141 center.hash(state);
3142 radii.hash(state);
3143 }
3144 Self::Polygon { points } => points.hash(state),
3145 Self::Path { segments } => segments.hash(state),
3146 }
3147 }
3148}
3149impl PartialEq for ShapeBoundary {
3150 fn eq(&self, other: &Self) -> bool {
3151 match (self, other) {
3152 (Self::Rectangle(r1), Self::Rectangle(r2)) => r1 == r2,
3153 (
3154 Self::Circle {
3155 center: c1,
3156 radius: r1,
3157 },
3158 Self::Circle {
3159 center: c2,
3160 radius: r2,
3161 },
3162 ) => c1 == c2 && round_eq(*r1, *r2),
3163 (
3164 Self::Ellipse {
3165 center: c1,
3166 radii: r1,
3167 },
3168 Self::Ellipse {
3169 center: c2,
3170 radii: r2,
3171 },
3172 ) => c1 == c2 && r1 == r2,
3173 (Self::Polygon { points: p1 }, Self::Polygon { points: p2 }) => {
3174 p1 == p2
3175 }
3176 (Self::Path { segments: s1 }, Self::Path { segments: s2 }) => {
3177 s1 == s2
3178 }
3179 _ => false,
3180 }
3181 }
3182}
3183impl Eq for ShapeBoundary {}
3184
3185impl ShapeBoundary {
3186 /// Converts a CSS shape (from azul-css) to a layout engine `ShapeBoundary`
3187 ///
3188 /// # Arguments
3189 /// * `css_shape` - The parsed CSS shape from azul-css
3190 /// * `reference_box` - The containing box for resolving coordinates (from layout solver)
3191 ///
3192 /// # Returns
3193 /// A `ShapeBoundary` ready for use in the text layout engine
3194 #[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
3195 pub fn from_css_shape(
3196 css_shape: &azul_css::shape::CssShape,
3197 reference_box: Rect,
3198 debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
3199 ) -> Self {
3200 use azul_css::shape::CssShape;
3201
3202 if let Some(msgs) = debug_messages {
3203 msgs.push(LayoutDebugMessage::info(format!(
3204 "[ShapeBoundary::from_css_shape] Input CSS shape: {css_shape:?}"
3205 )));
3206 msgs.push(LayoutDebugMessage::info(format!(
3207 "[ShapeBoundary::from_css_shape] Reference box: {reference_box:?}"
3208 )));
3209 }
3210
3211 let result = match css_shape {
3212 CssShape::Circle(circle) => {
3213 let center = Point {
3214 x: reference_box.x + circle.center.x,
3215 y: reference_box.y + circle.center.y,
3216 };
3217 if let Some(msgs) = debug_messages {
3218 msgs.push(LayoutDebugMessage::info(format!(
3219 "[ShapeBoundary::from_css_shape] Circle - CSS center: ({}, {}), radius: {}",
3220 circle.center.x, circle.center.y, circle.radius
3221 )));
3222 msgs.push(LayoutDebugMessage::info(format!(
3223 "[ShapeBoundary::from_css_shape] Circle - Absolute center: ({}, {}), \
3224 radius: {}",
3225 center.x, center.y, circle.radius
3226 )));
3227 }
3228 Self::Circle {
3229 center,
3230 radius: circle.radius,
3231 }
3232 }
3233
3234 CssShape::Ellipse(ellipse) => {
3235 let center = Point {
3236 x: reference_box.x + ellipse.center.x,
3237 y: reference_box.y + ellipse.center.y,
3238 };
3239 let radii = Size {
3240 width: ellipse.radius_x,
3241 height: ellipse.radius_y,
3242 };
3243 if let Some(msgs) = debug_messages {
3244 msgs.push(LayoutDebugMessage::info(format!(
3245 "[ShapeBoundary::from_css_shape] Ellipse - center: ({}, {}), radii: ({}, \
3246 {})",
3247 center.x, center.y, radii.width, radii.height
3248 )));
3249 }
3250 Self::Ellipse { center, radii }
3251 }
3252
3253 CssShape::Polygon(polygon) => {
3254 let points = polygon
3255 .points
3256 .as_ref()
3257 .iter()
3258 .map(|pt| Point {
3259 x: reference_box.x + pt.x,
3260 y: reference_box.y + pt.y,
3261 })
3262 .collect();
3263 if let Some(msgs) = debug_messages {
3264 msgs.push(LayoutDebugMessage::info(format!(
3265 "[ShapeBoundary::from_css_shape] Polygon - {} points",
3266 polygon.points.as_ref().len()
3267 )));
3268 }
3269 Self::Polygon { points }
3270 }
3271
3272 CssShape::Inset(inset) => {
3273 // Inset defines distances from reference box edges
3274 let x = reference_box.x + inset.inset_left;
3275 let y = reference_box.y + inset.inset_top;
3276 let width = reference_box.width - inset.inset_left - inset.inset_right;
3277 let height = reference_box.height - inset.inset_top - inset.inset_bottom;
3278
3279 if let Some(msgs) = debug_messages {
3280 msgs.push(LayoutDebugMessage::info(format!(
3281 "[ShapeBoundary::from_css_shape] Inset - insets: ({}, {}, {}, {})",
3282 inset.inset_top, inset.inset_right, inset.inset_bottom, inset.inset_left
3283 )));
3284 msgs.push(LayoutDebugMessage::info(format!(
3285 "[ShapeBoundary::from_css_shape] Inset - resulting rect: x={x}, y={y}, \
3286 w={width}, h={height}"
3287 )));
3288 }
3289
3290 Self::Rectangle(Rect {
3291 x,
3292 y,
3293 width: width.max(0.0),
3294 height: height.max(0.0),
3295 })
3296 }
3297
3298 CssShape::Path(path) => {
3299 // CSS `path()` value: `path.data` is a raw SVG path `d=""` string in the
3300 // reference-box coordinate system (origin at the reference box's top-left).
3301 // Parse + flatten it into `Vec<PathSegment>` (curves sampled to line
3302 // segments) so the scanline code in `get_shape_horizontal_spans` can
3303 // intersect it per line, exactly like `polygon`.
3304 let segments = azul_core::path_parser::parse_svg_path_d(path.data.as_str())
3305 .map_or_else(|_| Vec::new(), |multipolygon| {
3306 flatten_svg_to_path_segments(&multipolygon, reference_box)
3307 });
3308 if let Some(msgs) = debug_messages {
3309 msgs.push(LayoutDebugMessage::info(format!(
3310 "[ShapeBoundary::from_css_shape] Path - parsed {} flattened segments",
3311 segments.len()
3312 )));
3313 }
3314 if segments.is_empty() {
3315 // Unparseable / empty path: fall back to the reference rectangle so a
3316 // shape-inside container does not collapse to zero usable space.
3317 Self::Rectangle(reference_box)
3318 } else {
3319 Self::Path { segments }
3320 }
3321 }
3322 };
3323
3324 if let Some(msgs) = debug_messages {
3325 msgs.push(LayoutDebugMessage::info(format!(
3326 "[ShapeBoundary::from_css_shape] Result: {result:?}"
3327 )));
3328 }
3329 result
3330 }
3331}
3332
3333#[derive(Copy, Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
3334pub struct InlineBreak {
3335 pub break_type: BreakType,
3336 pub clear: ClearType,
3337 pub content_index: usize,
3338}
3339
3340// +spec:line-breaking:d70ffd - Defines forced line break (Hard) vs soft wrap break (Soft) types
3341#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
3342pub enum BreakType {
3343 Soft, // Soft wrap break: UA creates unforced line breaks to fit content within the measure
3344 Hard, // Forced line break: explicit line-breaking controls (preserved newline, <br>)
3345 Page, // Page break
3346 Column, // Column break
3347}
3348
3349#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
3350pub enum ClearType {
3351 None,
3352 Left,
3353 Right,
3354 Both,
3355}
3356
3357// Complex shape constraints for non-rectangular text flow
3358#[derive(Debug, Clone)]
3359pub(crate) struct ShapeConstraints {
3360 pub(crate) boundaries: Vec<ShapeBoundary>,
3361 pub(crate) exclusions: Vec<ShapeBoundary>,
3362 pub(crate) writing_mode: WritingMode,
3363 pub(crate) text_align: TextAlign,
3364 pub(crate) line_height: LineHeight,
3365}
3366
3367#[derive(Debug, Clone, Copy, PartialEq, Default, Hash, Eq, PartialOrd, Ord)]
3368pub enum WritingMode {
3369 #[default]
3370 HorizontalTb, // horizontal-tb (normal horizontal)
3371 VerticalRl, // +spec:writing-modes:6e22a7 - vertical-rl (vertical right-to-left, commonly used in East Asia)
3372 VerticalLr, // vertical-lr (vertical left-to-right)
3373 SidewaysRl, // sideways-rl (rotated horizontal in vertical context)
3374 SidewaysLr, // sideways-lr (rotated horizontal in vertical context)
3375}
3376
3377impl WritingMode {
3378 /// Necessary to determine if the glyphs are advancing in a horizontal direction
3379 #[must_use] pub const fn is_advance_horizontal(&self) -> bool {
3380 matches!(
3381 self,
3382 Self::HorizontalTb | Self::SidewaysRl | Self::SidewaysLr
3383 )
3384 }
3385}
3386
3387#[derive(Debug, Clone, Copy, PartialEq, Default, Hash, Eq, PartialOrd, Ord)]
3388pub enum JustifyContent {
3389 #[default]
3390 None,
3391 InterWord, // Expand spaces between words
3392 InterCharacter, // Expand spaces between all characters (for CJK)
3393 Distribute, // Distribute space evenly including start/end
3394 Kashida, // Stretch Arabic text using kashidas
3395}
3396
3397// Enhanced text alignment with logical directions
3398#[derive(Debug, Clone, Copy, PartialEq, Default, Hash, Eq, PartialOrd, Ord)]
3399pub enum TextAlign {
3400 #[default]
3401 Left,
3402 Right,
3403 Center,
3404 Justify,
3405 Start,
3406 End, // Logical start/end
3407 JustifyAll, // Justify including last line
3408}
3409
3410// +spec:block-formatting-context:458d31 - vertical text orientation: upright for horizontal scripts, intrinsic for vertical scripts
3411// Vertical text orientation for individual characters
3412#[derive(Debug, Clone, Copy, PartialEq, Default, Eq, PartialOrd, Ord, Hash)]
3413pub enum TextOrientation {
3414 #[default]
3415 Mixed, // Default: upright for scripts, rotated for others
3416 Upright, // All characters upright
3417 Sideways, // All characters rotated 90 degrees
3418}
3419
3420#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
3421#[derive(Default)]
3422pub struct TextDecoration {
3423 pub underline: bool,
3424 pub strikethrough: bool,
3425 pub overline: bool,
3426}
3427
3428
3429impl TextDecoration {
3430 /// Convert from CSS `StyleTextDecoration` enum to our internal representation.
3431 ///
3432 /// Note: CSS text-decoration can have multiple values (underline line-through),
3433 /// but the current azul-css parser only supports single values. This can be
3434 /// extended in the future if CSS parsing is updated.
3435 #[must_use] pub fn from_css(css: azul_css::props::style::text::StyleTextDecoration) -> Self {
3436 use azul_css::props::style::text::StyleTextDecoration;
3437 match css {
3438 StyleTextDecoration::None => Self::default(),
3439 StyleTextDecoration::Underline => Self {
3440 underline: true,
3441 strikethrough: false,
3442 overline: false,
3443 },
3444 StyleTextDecoration::Overline => Self {
3445 underline: false,
3446 strikethrough: false,
3447 overline: true,
3448 },
3449 StyleTextDecoration::LineThrough => Self {
3450 underline: false,
3451 strikethrough: true,
3452 overline: false,
3453 },
3454 }
3455 }
3456}
3457
3458#[derive(Debug, Clone, Copy, PartialEq, Hash, Eq, PartialOrd, Ord, Default)]
3459pub enum TextTransform {
3460 #[default]
3461 None,
3462 Uppercase,
3463 Lowercase,
3464 Capitalize,
3465 // only within preserved white space (non-preserved spaces already collapsed in Phase I)
3466 FullWidth,
3467}
3468
3469// Type alias for OpenType feature tags
3470pub type FourCc = [u8; 4];
3471
3472// Enum for relative or absolute spacing
3473#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
3474pub enum Spacing {
3475 Px(i32), // Whole-pixel spacing (kept for hashing/equality convenience)
3476 /// Sub-pixel resolved pixel spacing. `letter-spacing`/`word-spacing` accumulate
3477 /// once per glyph, so quantizing to whole pixels (the `Px(i32)` variant) multiplies
3478 /// the rounding error across a run. The CSS resolution path emits this variant to
3479 /// preserve the exact sub-pixel value (e.g. `letter-spacing: 0.4px`).
3480 PxF(f32),
3481 Em(f32),
3482}
3483
3484// A type that implements `Hash` must also implement `Eq`.
3485// Since f32 does not implement `Eq`, we provide a manual implementation.
3486// The derived `PartialEq` is sufficient for this marker trait.
3487impl Eq for Spacing {}
3488
3489impl Hash for Spacing {
3490 fn hash<H: Hasher>(&self, state: &mut H) {
3491 // First, hash the enum variant to distinguish between Px and Em.
3492 discriminant(self).hash(state);
3493 match self {
3494 Self::Px(val) => val.hash(state),
3495 // For hashing floats, convert them to their raw bit representation.
3496 // This ensures that identical float values produce identical hashes.
3497 Self::PxF(val) | Self::Em(val) => val.to_bits().hash(state),
3498 }
3499 }
3500}
3501
3502impl Default for Spacing {
3503 fn default() -> Self {
3504 Self::Px(0)
3505 }
3506}
3507
3508impl Spacing {
3509 /// Resolve this spacing to pixels given the element's font size (for `Em`).
3510 #[allow(clippy::cast_precision_loss)] // small integer px values; f32 mantissa is ample
3511 #[must_use]
3512 pub fn resolve_px(self, font_size_px: f32) -> f32 {
3513 match self {
3514 Self::Px(px) => px as f32,
3515 Self::PxF(px) => px,
3516 Self::Em(em) => em * font_size_px,
3517 }
3518 }
3519}
3520
3521impl Default for FontHash {
3522 fn default() -> Self {
3523 Self::invalid()
3524 }
3525}
3526
3527/// Style properties with vertical text support
3528#[derive(Debug, Clone, PartialEq)]
3529pub struct StyleProperties {
3530 /// Font stack for fallback support (priority order)
3531 /// Can be either a list of `FontSelectors` (resolved via fontconfig)
3532 /// or a direct `FontRef` (bypasses fontconfig entirely).
3533 pub font_stack: FontStack,
3534 pub font_size_px: f32,
3535 pub color: ColorU,
3536 /// Background color for inline elements (e.g., `<span style="background-color: yellow">`)
3537 ///
3538 /// This is propagated from CSS through the style system and eventually used by
3539 /// the PDF renderer to draw filled rectangles behind text. The value is `None`
3540 /// for transparent backgrounds (the default).
3541 ///
3542 /// The propagation chain is:
3543 /// CSS -> `get_style_properties()` -> `StyleProperties` -> `ShapedGlyph` -> `PdfGlyphRun`
3544 ///
3545 /// See `PdfGlyphRun::background_color` for how this is used in PDF rendering.
3546 pub background_color: Option<ColorU>,
3547 /// Full background content layers (for gradients, images, etc.)
3548 /// This extends `background_color` to support CSS gradients on inline elements.
3549 pub background_content: Vec<StyleBackgroundContent>,
3550 /// Border information for inline elements
3551 pub border: Option<InlineBorderInfo>,
3552 // +spec:text-alignment-spacing:b39a04 - word-spacing and letter-spacing control text spacing
3553 pub letter_spacing: Spacing,
3554 pub word_spacing: Spacing,
3555
3556 pub line_height: LineHeight,
3557 pub text_decoration: TextDecoration,
3558
3559 // Represents CSS font-feature-settings like `"liga"`, `"smcp=1"`.
3560 pub font_features: Vec<String>,
3561
3562 // Variable fonts
3563 pub font_variations: Vec<(FourCc, f32)>,
3564 // Multiplier of the space width
3565 pub tab_size: f32,
3566 // text-transform
3567 pub text_transform: TextTransform,
3568 // Vertical text properties
3569 pub writing_mode: WritingMode,
3570 pub text_orientation: TextOrientation,
3571 // Tate-chu-yoko
3572 pub text_combine_upright: Option<TextCombineUpright>,
3573
3574 // Variant handling
3575 pub font_variant_caps: FontVariantCaps,
3576 pub font_variant_numeric: FontVariantNumeric,
3577 pub font_variant_ligatures: FontVariantLigatures,
3578 pub font_variant_east_asian: FontVariantEastAsian,
3579}
3580
3581impl Default for StyleProperties {
3582 fn default() -> Self {
3583 const FONT_SIZE: f32 = 16.0;
3584 const TAB_SIZE: f32 = 8.0;
3585 Self {
3586 font_stack: FontStack::default(),
3587 font_size_px: FONT_SIZE,
3588 color: ColorU::default(),
3589 background_color: None,
3590 background_content: Vec::new(),
3591 border: None,
3592 letter_spacing: Spacing::default(), // Px(0)
3593 word_spacing: Spacing::default(), // Px(0)
3594 line_height: LineHeight::Normal,
3595 text_decoration: TextDecoration::default(),
3596 font_features: Vec::new(),
3597 font_variations: Vec::new(),
3598 tab_size: TAB_SIZE, // CSS default
3599 text_transform: TextTransform::default(),
3600 writing_mode: WritingMode::default(),
3601 text_orientation: TextOrientation::default(),
3602 text_combine_upright: None,
3603 font_variant_caps: FontVariantCaps::default(),
3604 font_variant_numeric: FontVariantNumeric::default(),
3605 font_variant_ligatures: FontVariantLigatures::default(),
3606 font_variant_east_asian: FontVariantEastAsian::default(),
3607 }
3608 }
3609}
3610
3611impl Hash for StyleProperties {
3612 #[allow(clippy::cast_possible_truncation)] // bounded pixel/coord/colour/glyph cast
3613 fn hash<H: Hasher>(&self, state: &mut H) {
3614 self.font_stack.hash(state);
3615 self.color.hash(state);
3616 self.background_color.hash(state);
3617 self.text_decoration.hash(state);
3618 self.font_features.hash(state);
3619 self.writing_mode.hash(state);
3620 self.text_orientation.hash(state);
3621 self.text_combine_upright.hash(state);
3622 self.letter_spacing.hash(state);
3623 self.word_spacing.hash(state);
3624
3625 // For f32 fields, round and cast to usize before hashing.
3626 (self.font_size_px.round() as isize).hash(state);
3627 self.line_height.hash(state);
3628 }
3629}
3630
3631impl StyleProperties {
3632 /// Returns a hash that only includes properties that affect text layout.
3633 ///
3634 /// Properties that DON'T affect layout (only rendering):
3635 /// - color, `background_color`, `background_content`
3636 /// - `text_decoration` (underline, etc.)
3637 /// - border (for inline elements)
3638 ///
3639 /// Properties that DO affect layout:
3640 /// - `font_stack`, `font_size_px`, `font_features`, `font_variations`
3641 /// - `letter_spacing`, `word_spacing`, `line_height`, `tab_size`
3642 /// - `writing_mode`, `text_orientation`, `text_combine_upright`
3643 /// - `text_transform`
3644 /// - `font_variant`_* (affects glyph selection)
3645 ///
3646 /// This allows the layout cache to reuse layouts when only rendering
3647 /// properties change (e.g., color changes on hover).
3648 // (family, weight, style) so that shaping runs break at element boundaries where font
3649 // properties differ, preventing impossible cross-boundary ligatures (e.g. "and" → "&").
3650 #[allow(clippy::cast_possible_truncation)] // bounded pixel/coord/colour/glyph cast
3651 #[must_use] pub fn layout_hash(&self) -> u64 {
3652 use std::hash::Hasher;
3653 let mut hasher = DefaultHasher::new();
3654
3655 // Font selection (affects shaping and metrics)
3656 self.font_stack.hash(&mut hasher);
3657 // Hash the EXACT font size bits, not a rounded integer: two styles differing
3658 // by <0.5px must not share a shaping-cache entry / coalesce, or one run gets
3659 // shaped at the other's size (wrong advances/metrics).
3660 self.font_size_px.to_bits().hash(&mut hasher);
3661 self.font_features.hash(&mut hasher);
3662 // font_variations affects glyph outlines
3663 for (tag, value) in &self.font_variations {
3664 tag.hash(&mut hasher);
3665 (value.round() as i32).hash(&mut hasher);
3666 }
3667
3668 // Spacing (affects glyph positions)
3669 self.letter_spacing.hash(&mut hasher);
3670 self.word_spacing.hash(&mut hasher);
3671 self.line_height.hash(&mut hasher);
3672 (self.tab_size.round() as isize).hash(&mut hasher);
3673
3674 // Writing mode (affects layout direction)
3675 self.writing_mode.hash(&mut hasher);
3676 self.text_orientation.hash(&mut hasher);
3677 self.text_combine_upright.hash(&mut hasher);
3678
3679 // Text transform (affects which characters are used)
3680 self.text_transform.hash(&mut hasher);
3681
3682 // Font variants (affect glyph selection)
3683 self.font_variant_caps.hash(&mut hasher);
3684 self.font_variant_numeric.hash(&mut hasher);
3685 self.font_variant_ligatures.hash(&mut hasher);
3686 self.font_variant_east_asian.hash(&mut hasher);
3687
3688 hasher.finish()
3689 }
3690
3691 /// Check if two `StyleProperties` have the same layout-affecting properties.
3692 ///
3693 /// Returns true if the layouts would be identical (only rendering differs).
3694 ///
3695 /// **Note:** This is a fast-path comparison using 64-bit hashes. Hash
3696 /// collisions are theoretically possible, which could cause the cache to
3697 /// serve a stale layout. In practice the probability is negligible for
3698 /// the number of distinct `StyleProperties` values in a single document.
3699 #[must_use] pub fn layout_eq(&self, other: &Self) -> bool {
3700 self.layout_hash() == other.layout_hash()
3701 }
3702}
3703
3704#[derive(Copy, Debug, Clone, PartialEq, Hash, Eq, PartialOrd, Ord)]
3705pub enum TextCombineUpright {
3706 None,
3707 All, // Combine all characters in horizontal layout
3708 Digits(u8), // Combine up to N digits
3709}
3710
3711#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
3712pub enum GlyphSource {
3713 /// Glyph generated from a character in the source text.
3714 Char,
3715 /// Glyph inserted dynamically by the layout engine (e.g., a hyphen).
3716 Hyphen,
3717}
3718
3719#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3720pub enum CharacterClass {
3721 Space, // Regular spaces - highest justification priority
3722 Punctuation, // Can sometimes be adjusted
3723 Letter, // Normal letters
3724 Ideograph, // CJK characters - can be justified between
3725 Symbol, // Symbols, emojis
3726 Combining, // Combining marks - never justified
3727}
3728
3729#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3730pub enum GlyphOrientation {
3731 Horizontal, // Keep horizontal (normal in horizontal text)
3732 Vertical, // Rotate to vertical (normal in vertical text)
3733 Upright, // Keep upright regardless of writing mode
3734 Mixed, // Use script-specific default orientation
3735}
3736
3737// Bidi and script detection
3738#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
3739pub enum BidiDirection {
3740 Ltr,
3741 Rtl,
3742}
3743
3744impl BidiDirection {
3745 #[must_use] pub const fn is_rtl(&self) -> bool {
3746 matches!(self, Self::Rtl)
3747 }
3748}
3749
3750/// CSS `unicode-bidi` property values relevant to layout.
3751///
3752/// When `Plaintext`, the bidi algorithm uses P2/P3 heuristics to auto-detect
3753/// paragraph direction from text content, instead of the HL1 override from
3754/// the CSS `direction` property.
3755#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
3756#[derive(Default)]
3757pub enum UnicodeBidi {
3758 #[default]
3759 Normal,
3760 Embed,
3761 Isolate,
3762 BidiOverride,
3763 IsolateOverride,
3764 Plaintext,
3765}
3766
3767
3768#[derive(Debug, Clone, Copy, PartialEq, Hash, Eq, PartialOrd, Ord, Default)]
3769pub enum FontVariantCaps {
3770 #[default]
3771 Normal,
3772 SmallCaps,
3773 AllSmallCaps,
3774 PetiteCaps,
3775 AllPetiteCaps,
3776 Unicase,
3777 TitlingCaps,
3778}
3779
3780#[derive(Debug, Clone, Copy, PartialEq, Hash, Eq, PartialOrd, Ord, Default)]
3781pub enum FontVariantNumeric {
3782 #[default]
3783 Normal,
3784 LiningNums,
3785 OldstyleNums,
3786 ProportionalNums,
3787 TabularNums,
3788 DiagonalFractions,
3789 StackedFractions,
3790 Ordinal,
3791 SlashedZero,
3792}
3793
3794#[derive(Debug, Clone, Copy, PartialEq, Hash, Eq, PartialOrd, Ord, Default)]
3795pub enum FontVariantLigatures {
3796 #[default]
3797 Normal,
3798 None,
3799 Common,
3800 NoCommon,
3801 Discretionary,
3802 NoDiscretionary,
3803 Historical,
3804 NoHistorical,
3805 Contextual,
3806 NoContextual,
3807}
3808
3809#[derive(Debug, Clone, Copy, PartialEq, Hash, Eq, PartialOrd, Ord, Default)]
3810pub enum FontVariantEastAsian {
3811 #[default]
3812 Normal,
3813 Jis78,
3814 Jis83,
3815 Jis90,
3816 Jis04,
3817 Simplified,
3818 Traditional,
3819 FullWidth,
3820 ProportionalWidth,
3821 Ruby,
3822}
3823
3824#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
3825pub struct BidiLevel(u8);
3826
3827impl BidiLevel {
3828 #[must_use] pub const fn new(level: u8) -> Self {
3829 Self(level)
3830 }
3831 #[must_use] pub const fn is_rtl(&self) -> bool {
3832 self.0 % 2 == 1
3833 }
3834 #[must_use] pub const fn level(&self) -> u8 {
3835 self.0
3836 }
3837}
3838
3839// Add this new struct for style overrides
3840#[derive(Debug, Clone)]
3841pub struct StyleOverride {
3842 /// The specific character this override applies to.
3843 pub target: ContentIndex,
3844 /// The style properties to apply.
3845 /// Any `None` value means "inherit from the base style".
3846 pub style: PartialStyleProperties,
3847}
3848
3849#[derive(Debug, Clone, Default)]
3850pub struct PartialStyleProperties {
3851 pub font_stack: Option<FontStack>,
3852 pub font_size_px: Option<f32>,
3853 pub color: Option<ColorU>,
3854 pub letter_spacing: Option<Spacing>,
3855 pub word_spacing: Option<Spacing>,
3856 pub line_height: Option<LineHeight>,
3857 pub text_decoration: Option<TextDecoration>,
3858 pub font_features: Option<Vec<String>>,
3859 pub font_variations: Option<Vec<(FourCc, f32)>>,
3860 pub tab_size: Option<f32>,
3861 pub text_transform: Option<TextTransform>,
3862 pub writing_mode: Option<WritingMode>,
3863 pub text_orientation: Option<TextOrientation>,
3864 pub text_combine_upright: Option<Option<TextCombineUpright>>,
3865 pub font_variant_caps: Option<FontVariantCaps>,
3866 pub font_variant_numeric: Option<FontVariantNumeric>,
3867 pub font_variant_ligatures: Option<FontVariantLigatures>,
3868 pub font_variant_east_asian: Option<FontVariantEastAsian>,
3869}
3870
3871impl Hash for PartialStyleProperties {
3872 fn hash<H: Hasher>(&self, state: &mut H) {
3873 self.font_stack.hash(state);
3874 self.font_size_px.map(f32::to_bits).hash(state);
3875 self.color.hash(state);
3876 self.letter_spacing.hash(state);
3877 self.word_spacing.hash(state);
3878 self.line_height.hash(state);
3879 self.text_decoration.hash(state);
3880 self.font_features.hash(state);
3881
3882 // Manual hashing for Vec<(FourCc, f32)>
3883 if let Some(v) = self.font_variations.as_ref() {
3884 for (tag, val) in v {
3885 tag.hash(state);
3886 val.to_bits().hash(state);
3887 }
3888 }
3889
3890 self.tab_size.map(f32::to_bits).hash(state);
3891 self.text_transform.hash(state);
3892 self.writing_mode.hash(state);
3893 self.text_orientation.hash(state);
3894 self.text_combine_upright.hash(state);
3895 self.font_variant_caps.hash(state);
3896 self.font_variant_numeric.hash(state);
3897 self.font_variant_ligatures.hash(state);
3898 self.font_variant_east_asian.hash(state);
3899 }
3900}
3901
3902impl PartialEq for PartialStyleProperties {
3903 fn eq(&self, other: &Self) -> bool {
3904 self.font_stack == other.font_stack &&
3905 self.font_size_px.map(f32::to_bits) == other.font_size_px.map(f32::to_bits) &&
3906 self.color == other.color &&
3907 self.letter_spacing == other.letter_spacing &&
3908 self.word_spacing == other.word_spacing &&
3909 self.line_height == other.line_height &&
3910 self.text_decoration == other.text_decoration &&
3911 self.font_features == other.font_features &&
3912 self.font_variations == other.font_variations && // Vec<(FourCc, f32)> is PartialEq
3913 self.tab_size.map(f32::to_bits) == other.tab_size.map(f32::to_bits) &&
3914 self.text_transform == other.text_transform &&
3915 self.writing_mode == other.writing_mode &&
3916 self.text_orientation == other.text_orientation &&
3917 self.text_combine_upright == other.text_combine_upright &&
3918 self.font_variant_caps == other.font_variant_caps &&
3919 self.font_variant_numeric == other.font_variant_numeric &&
3920 self.font_variant_ligatures == other.font_variant_ligatures &&
3921 self.font_variant_east_asian == other.font_variant_east_asian
3922 }
3923}
3924
3925impl Eq for PartialStyleProperties {}
3926
3927impl StyleProperties {
3928 fn apply_override(&self, partial: &PartialStyleProperties) -> Self {
3929 let mut new_style = self.clone();
3930 if let Some(val) = &partial.font_stack {
3931 new_style.font_stack = val.clone();
3932 }
3933 if let Some(val) = partial.font_size_px {
3934 new_style.font_size_px = val;
3935 }
3936 if let Some(val) = &partial.color {
3937 new_style.color = *val;
3938 }
3939 if let Some(val) = partial.letter_spacing {
3940 new_style.letter_spacing = val;
3941 }
3942 if let Some(val) = partial.word_spacing {
3943 new_style.word_spacing = val;
3944 }
3945 if let Some(val) = partial.line_height {
3946 new_style.line_height = val;
3947 }
3948 if let Some(val) = &partial.text_decoration {
3949 new_style.text_decoration = *val;
3950 }
3951 if let Some(val) = &partial.font_features {
3952 new_style.font_features.clone_from(val);
3953 }
3954 if let Some(val) = &partial.font_variations {
3955 new_style.font_variations.clone_from(val);
3956 }
3957 if let Some(val) = partial.tab_size {
3958 new_style.tab_size = val;
3959 }
3960 if let Some(val) = partial.text_transform {
3961 new_style.text_transform = val;
3962 }
3963 if let Some(val) = partial.writing_mode {
3964 new_style.writing_mode = val;
3965 }
3966 if let Some(val) = partial.text_orientation {
3967 new_style.text_orientation = val;
3968 }
3969 if let Some(val) = &partial.text_combine_upright {
3970 new_style.text_combine_upright.clone_from(val);
3971 }
3972 if let Some(val) = partial.font_variant_caps {
3973 new_style.font_variant_caps = val;
3974 }
3975 if let Some(val) = partial.font_variant_numeric {
3976 new_style.font_variant_numeric = val;
3977 }
3978 if let Some(val) = partial.font_variant_ligatures {
3979 new_style.font_variant_ligatures = val;
3980 }
3981 if let Some(val) = partial.font_variant_east_asian {
3982 new_style.font_variant_east_asian = val;
3983 }
3984 new_style
3985 }
3986}
3987
3988/// The kind of a glyph, used to distinguish characters from layout-inserted items.
3989#[derive(Debug, Clone, Copy, PartialEq)]
3990pub enum GlyphKind {
3991 /// A standard glyph representing one or more characters from the source text.
3992 Character,
3993 /// A hyphen glyph inserted by the line breaking algorithm.
3994 Hyphen,
3995 /// A `.notdef` glyph, indicating a character that could not be found in any font.
3996 NotDef,
3997 /// A Kashida justification glyph, inserted to stretch Arabic text.
3998 Kashida {
3999 /// The target width of the kashida.
4000 width: f32,
4001 },
4002}
4003
4004// --- Stage 1: Logical Representation ---
4005
4006// [g117 az-web-lift FIX] `#[repr(C, u8)]` (was repr(Rust)) — same disc-mis-lift class as InlineContent
4007// above. LogicalItem is matched in measure Stage-2 (`if let LogicalItem::Text`) + reorder_logical_items;
4008// a repr(Rust) niche disc mis-lifts on the web. Explicit u8 tag at offset 0 = a simple load the lift
4009// reads correctly. Internal to text3 (not FFI-exposed). LogicalItem::Object embeds InlineContent inline.
4010#[derive(Debug, Clone)]
4011#[repr(C, u8)]
4012pub enum LogicalItem {
4013 Text {
4014 /// A stable ID pointing back to the original source character.
4015 source: ContentIndex,
4016 /// The text of this specific logical item (often a single grapheme cluster).
4017 text: String,
4018 style: Arc<StyleProperties>,
4019 /// If this text is a list marker: whether it should be positioned outside
4020 /// (in the padding gutter) or inside (inline with content).
4021 /// None for non-marker content.
4022 marker_position_outside: Option<bool>,
4023 /// The DOM `NodeId` of the Text node this item originated from.
4024 /// None for generated content (list markers, `::before/::after`, etc.)
4025 source_node_id: Option<NodeId>,
4026 },
4027 // +spec:display-property:b1533f - text-combine-upright tate-chu-yoko horizontal-in-vertical composition
4028 /// Tate-chu-yoko: Run of text to be laid out horizontally within a vertical context.
4029 CombinedText {
4030 source: ContentIndex,
4031 text: String,
4032 style: Arc<StyleProperties>,
4033 },
4034 Ruby {
4035 source: ContentIndex,
4036 // For the stub, we simplify to strings. A full implementation
4037 // would need to handle Vec<LogicalItem> for both.
4038 base_text: String,
4039 ruby_text: String,
4040 style: Arc<StyleProperties>,
4041 },
4042 Object {
4043 /// A stable ID pointing back to the original source object.
4044 source: ContentIndex,
4045 /// The original non-text object.
4046 content: InlineContent,
4047 },
4048 Tab {
4049 source: ContentIndex,
4050 style: Arc<StyleProperties>,
4051 },
4052 Break {
4053 source: ContentIndex,
4054 break_info: InlineBreak,
4055 },
4056}
4057
4058impl Hash for LogicalItem {
4059 fn hash<H: Hasher>(&self, state: &mut H) {
4060 discriminant(self).hash(state);
4061 match self {
4062 Self::Text {
4063 source,
4064 text,
4065 style,
4066 marker_position_outside,
4067 source_node_id,
4068 } => {
4069 source.hash(state);
4070 text.hash(state);
4071 style.as_ref().hash(state); // Hash the content, not the Arc pointer
4072 marker_position_outside.hash(state);
4073 source_node_id.hash(state);
4074 }
4075 Self::CombinedText {
4076 source,
4077 text,
4078 style,
4079 } => {
4080 source.hash(state);
4081 text.hash(state);
4082 style.as_ref().hash(state);
4083 }
4084 Self::Ruby {
4085 source,
4086 base_text,
4087 ruby_text,
4088 style,
4089 } => {
4090 source.hash(state);
4091 base_text.hash(state);
4092 ruby_text.hash(state);
4093 style.as_ref().hash(state);
4094 }
4095 Self::Object { source, content } => {
4096 source.hash(state);
4097 content.hash(state);
4098 }
4099 Self::Tab { source, style } => {
4100 source.hash(state);
4101 style.as_ref().hash(state);
4102 }
4103 Self::Break { source, break_info } => {
4104 source.hash(state);
4105 break_info.hash(state);
4106 }
4107 }
4108 }
4109}
4110
4111// --- Stage 2: Visual Representation ---
4112
4113#[derive(Debug, Clone)]
4114pub struct VisualItem {
4115 /// A reference to the logical item this visual item originated from.
4116 /// A single `LogicalItem` can be split into multiple `VisualItems`.
4117 pub logical_source: LogicalItem,
4118 /// The Bidi embedding level for this item.
4119 pub bidi_level: BidiLevel,
4120 /// The script detected for this run, crucial for shaping.
4121 pub script: Script,
4122 /// The text content for this specific visual run.
4123 pub text: String,
4124 /// Byte offset of this visual run's `text` within its source logical run's
4125 /// text. When bidi splits one logical run into several visual runs, each
4126 /// shaped cluster's `start_byte_in_run` is produced relative to this visual
4127 /// run's `text`; adding `run_byte_offset` re-bases it to the logical run so
4128 /// cluster IDs stay unique and match caret/selection byte positions.
4129 pub run_byte_offset: usize,
4130}
4131
4132// --- Stage 3: Shaped Representation ---
4133
4134// [g118 az-web-lift FIX] `#[repr(C, u8)]` (was repr(Rust)) — same disc-mis-lift class as InlineContent
4135// + LogicalItem (g117). ShapedItem is matched in measure Stage-5 (`match item { ShapedItem::Cluster ..}`)
4136// + cloned/matched throughout shaping; a repr(Rust) niche disc mis-lifts on the web. Explicit u8 tag at
4137// offset 0 = a simple load the lift reads correctly. Internal to text3 (not FFI-exposed).
4138#[derive(Debug, Clone)]
4139#[repr(C, u8)]
4140pub enum ShapedItem {
4141 Cluster(ShapedCluster),
4142 /// A block of combined text (tate-chu-yoko) that is laid out
4143 // as a single unbreakable object.
4144 CombinedBlock {
4145 source: ContentIndex,
4146 /// The glyphs to be rendered horizontally within the vertical line.
4147 glyphs: ShapedGlyphVec,
4148 bounds: Rect,
4149 baseline_offset: f32,
4150 },
4151 Object {
4152 source: ContentIndex,
4153 bounds: Rect,
4154 baseline_offset: f32,
4155 // Store original object for rendering
4156 content: InlineContent,
4157 },
4158 Tab {
4159 source: ContentIndex,
4160 bounds: Rect,
4161 },
4162 Break {
4163 source: ContentIndex,
4164 break_info: InlineBreak,
4165 },
4166}
4167
4168impl ShapedItem {
4169 #[must_use] pub const fn as_cluster(&self) -> Option<&ShapedCluster> {
4170 match self {
4171 Self::Cluster(c) => Some(c),
4172 _ => None,
4173 }
4174 }
4175 /// Returns the bounding box of the item, relative to its own origin.
4176 ///
4177 /// The origin of the returned `Rect` is `(0,0)`, representing the top-left corner
4178 /// of the item's layout space before final positioning. The size represents the
4179 /// item's total advance (width in horizontal mode) and its line height (ascent + descent).
4180 #[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
4181 #[must_use] pub fn bounds(&self) -> Rect {
4182 match self {
4183 Self::Cluster(cluster) => {
4184 // The width of a text cluster is its total advance.
4185 let width = cluster.advance;
4186
4187 // The height is the sum of its ascent and descent, which defines its line box.
4188 // We use the existing helper function which correctly calculates this from font
4189 // metrics.
4190 let (ascent, descent) = get_item_vertical_metrics_approx(self);
4191 let height = ascent + descent;
4192
4193 Rect {
4194 x: 0.0,
4195 y: 0.0,
4196 width,
4197 height,
4198 }
4199 }
4200 // For atomic inline items like objects, combined blocks, and tabs,
4201 // their bounds have already been calculated during the shaping or measurement phase.
4202 Self::CombinedBlock { bounds, .. } => *bounds,
4203 Self::Object { bounds, .. } => *bounds,
4204 Self::Tab { bounds, .. } => *bounds,
4205
4206 // Breaks are control characters and have no visual geometry.
4207 Self::Break { .. } => Rect::default(), // A zero-sized rectangle.
4208 }
4209 }
4210}
4211
4212/// A group of glyphs that corresponds to one or more source characters (a cluster).
4213#[derive(Debug, Clone)]
4214pub struct ShapedCluster {
4215 /// The original text that this cluster was shaped from.
4216 /// This is crucial for correct hyphenation.
4217 pub text: String,
4218 /// The ID of the grapheme cluster this glyph cluster represents.
4219 pub source_cluster_id: GraphemeClusterId,
4220 /// The source `ContentIndex` for mapping back to logical items.
4221 pub source_content_index: ContentIndex,
4222 /// The DOM `NodeId` of the Text node this cluster originated from.
4223 /// None for generated content (list markers, `::before/::after`, etc.)
4224 pub source_node_id: Option<NodeId>,
4225 /// The glyphs that make up this cluster. `SmallVec<[T; 1]>` — inline
4226 /// single-glyph clusters (the common case for Latin text), spill to
4227 /// heap only for ligatures / combining marks.
4228 pub glyphs: ShapedGlyphVec,
4229 /// The total advance width (horizontal) or height (vertical) of the cluster.
4230 pub advance: f32,
4231 /// The direction of this cluster, inherited from its `VisualItem`.
4232 pub direction: BidiDirection,
4233 /// Font style of this cluster
4234 pub style: Arc<StyleProperties>,
4235 /// If this cluster is a list marker: whether it should be positioned outside
4236 /// (in the padding gutter) or inside (inline with content).
4237 /// None for non-marker content.
4238 pub marker_position_outside: Option<bool>,
4239 /// True if this is the first visual fragment of its inline box.
4240 /// Used for `box-decoration-break` and split inline border/padding.
4241 /// When an inline element wraps across lines, only the first fragment
4242 /// gets the start-edge border/padding.
4243 pub is_first_fragment: bool,
4244 /// True if this is the last visual fragment of its inline box.
4245 /// Only the last fragment gets the end-edge border/padding.
4246 pub is_last_fragment: bool,
4247}
4248
4249/// A single, shaped glyph with its essential metrics.
4250#[derive(Debug, Clone)]
4251pub struct ShapedGlyph {
4252 /// The kind of glyph this is (character, hyphen, etc.).
4253 pub kind: GlyphKind,
4254 /// Glyph ID inside of the font
4255 pub glyph_id: u16,
4256 /// The byte offset of this glyph's source character(s) within its cluster text.
4257 pub cluster_offset: u32,
4258 /// The horizontal advance for this glyph (for horizontal text) - this is the BASE advance
4259 /// from the font metrics, WITHOUT kerning applied
4260 pub advance: f32,
4261 /// The kerning adjustment for this glyph (positive = more space, negative = less space)
4262 /// This is separate from advance so we can position glyphs absolutely
4263 pub kerning: f32,
4264 /// The horizontal offset/bearing for this glyph
4265 pub offset: Point,
4266 /// The vertical advance for this glyph (for vertical text).
4267 pub vertical_advance: f32,
4268 /// The vertical offset/bearing for this glyph.
4269 pub vertical_offset: Point,
4270 pub script: Script,
4271 pub style: Arc<StyleProperties>,
4272 /// Hash of the font - use `LoadedFonts` to look up the actual font when needed
4273 pub font_hash: u64,
4274 /// Cached font metrics to avoid font lookup for common operations
4275 pub font_metrics: LayoutFontMetrics,
4276}
4277
4278impl ShapedGlyph {
4279 #[must_use] pub fn into_glyph_instance<T: ParsedFontTrait>(
4280 &self,
4281 writing_mode: WritingMode,
4282 loaded_fonts: &LoadedFonts<T>,
4283 ) -> GlyphInstance {
4284 let size = loaded_fonts
4285 .get_by_hash(self.font_hash)
4286 .and_then(|font| font.get_glyph_size(self.glyph_id, self.style.font_size_px))
4287 .unwrap_or_default();
4288
4289 let position = if writing_mode.is_advance_horizontal() {
4290 LogicalPosition {
4291 x: self.offset.x,
4292 y: self.offset.y,
4293 }
4294 } else {
4295 LogicalPosition {
4296 x: self.vertical_offset.x,
4297 y: self.vertical_offset.y,
4298 }
4299 };
4300
4301 GlyphInstance {
4302 index: u32::from(self.glyph_id),
4303 point: position,
4304 size,
4305 }
4306 }
4307
4308 /// Convert this `ShapedGlyph` into a `GlyphInstance` with an absolute position.
4309 /// This is used for display list generation where glyphs need their final page coordinates.
4310 #[must_use] pub fn into_glyph_instance_at<T: ParsedFontTrait>(
4311 &self,
4312 writing_mode: WritingMode,
4313 absolute_position: LogicalPosition,
4314 loaded_fonts: &LoadedFonts<T>,
4315 ) -> GlyphInstance {
4316 let size = loaded_fonts
4317 .get_by_hash(self.font_hash)
4318 .and_then(|font| font.get_glyph_size(self.glyph_id, self.style.font_size_px))
4319 .unwrap_or_default();
4320
4321 GlyphInstance {
4322 index: u32::from(self.glyph_id),
4323 point: absolute_position,
4324 size,
4325 }
4326 }
4327
4328 /// Convert this `ShapedGlyph` into a `GlyphInstance` with an absolute position.
4329 /// This version doesn't require fonts - it uses a default size.
4330 /// Use this when you don't need precise glyph bounds (e.g., display list generation).
4331 #[must_use] pub fn into_glyph_instance_at_simple(
4332 &self,
4333 _writing_mode: WritingMode,
4334 absolute_position: LogicalPosition,
4335 ) -> GlyphInstance {
4336 // Use font metrics to estimate size, or default to zero
4337 // The actual rendering will use the font directly
4338 GlyphInstance {
4339 index: u32::from(self.glyph_id),
4340 point: absolute_position,
4341 size: LogicalSize::default(),
4342 }
4343 }
4344}
4345
4346// --- Stage 4: Positioned Representation (Final Layout) ---
4347
4348#[derive(Debug, Clone)]
4349pub struct PositionedItem {
4350 pub item: ShapedItem,
4351 pub position: Point,
4352 pub line_index: usize,
4353}
4354
4355#[derive(Debug, Clone)]
4356pub struct UnifiedLayout {
4357 pub items: Vec<PositionedItem>,
4358 /// Information about content that did not fit.
4359 pub overflow: OverflowInfo,
4360}
4361
4362impl UnifiedLayout {
4363 /// Calculate the bounding box of all positioned items.
4364 /// This is computed on-demand rather than cached.
4365 #[must_use] pub fn bounds(&self) -> Rect {
4366 if self.items.is_empty() {
4367 return Rect::default();
4368 }
4369
4370 let mut min_x = f32::MAX;
4371 let mut min_y = f32::MAX;
4372 let mut max_x = f32::MIN;
4373 let mut max_y = f32::MIN;
4374
4375 for item in &self.items {
4376 let item_x = item.position.x;
4377 let item_y = item.position.y;
4378
4379 // Get item dimensions
4380 let item_bounds = item.item.bounds();
4381 let item_width = item_bounds.width;
4382 let item_height = item_bounds.height;
4383
4384 min_x = min_x.min(item_x);
4385 min_y = min_y.min(item_y);
4386 max_x = max_x.max(item_x + item_width);
4387 max_y = max_y.max(item_y + item_height);
4388 }
4389
4390 Rect {
4391 x: min_x,
4392 y: min_y,
4393 width: max_x - min_x,
4394 height: max_y - min_y,
4395 }
4396 }
4397
4398 #[must_use] pub const fn is_empty(&self) -> bool {
4399 self.items.is_empty()
4400 }
4401 #[must_use] pub fn first_baseline(&self) -> Option<f32> {
4402 self.items
4403 .iter()
4404 .find_map(|item| get_baseline_for_item(&item.item))
4405 }
4406
4407 #[must_use] pub fn last_baseline(&self) -> Option<f32> {
4408 self.items
4409 .iter()
4410 .rev()
4411 .find_map(|item| get_baseline_for_item(&item.item))
4412 }
4413
4414 /// Takes a point relative to the layout's origin and returns the closest
4415 /// logical cursor position.
4416 ///
4417 /// This is the unified hit-testing implementation. The old `hit_test_to_cursor`
4418 /// method is deprecated in favor of this one.
4419 #[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
4420 #[must_use] pub fn hittest_cursor(&self, point: LogicalPosition) -> Option<TextCursor> {
4421 if self.items.is_empty() {
4422 return None;
4423 }
4424
4425 // Find the closest cluster vertically and horizontally
4426 let mut closest_item_idx = 0;
4427 let mut closest_distance = f32::MAX;
4428
4429 for (idx, item) in self.items.iter().enumerate() {
4430 // Only consider cluster items for cursor placement
4431 if !matches!(item.item, ShapedItem::Cluster(_)) {
4432 continue;
4433 }
4434
4435 let item_bounds = item.item.bounds();
4436 let item_center_y = item.position.y + item_bounds.height / 2.0;
4437
4438 // Distance from click position to item center
4439 let vertical_distance = (point.y - item_center_y).abs();
4440
4441 // For horizontal distance, check if we're within the cluster bounds
4442 let horizontal_distance = if point.x < item.position.x {
4443 item.position.x - point.x
4444 } else if point.x > item.position.x + item_bounds.width {
4445 point.x - (item.position.x + item_bounds.width)
4446 } else {
4447 0.0 // Inside the cluster horizontally
4448 };
4449
4450 // Combined distance (prioritize vertical proximity)
4451 let distance = vertical_distance * 2.0 + horizontal_distance;
4452
4453 if distance < closest_distance {
4454 closest_distance = distance;
4455 closest_item_idx = idx;
4456 }
4457 }
4458
4459 // Get the closest cluster
4460 let closest_item = &self.items[closest_item_idx];
4461 let cluster = match &closest_item.item {
4462 ShapedItem::Cluster(c) => c,
4463 // Objects are treated as a single cluster for selection
4464 ShapedItem::Object { source, .. } | ShapedItem::CombinedBlock { source, .. } => {
4465 return Some(TextCursor {
4466 cluster_id: GraphemeClusterId {
4467 source_run: source.run_index,
4468 start_byte_in_run: source.item_index,
4469 },
4470 affinity: if point.x
4471 < closest_item.position.x + (closest_item.item.bounds().width / 2.0)
4472 {
4473 CursorAffinity::Leading
4474 } else {
4475 CursorAffinity::Trailing
4476 },
4477 });
4478 }
4479 _ => return None,
4480 };
4481
4482 // Determine affinity based on which half of the cluster was clicked
4483 let cluster_mid_x = closest_item.position.x + cluster.advance / 2.0;
4484 let affinity = if point.x < cluster_mid_x {
4485 CursorAffinity::Leading
4486 } else {
4487 CursorAffinity::Trailing
4488 };
4489
4490 Some(TextCursor {
4491 cluster_id: cluster.source_cluster_id,
4492 affinity,
4493 })
4494 }
4495
4496 /// Given a logical selection range, returns a vector of visual rectangles
4497 /// that cover the selected text, in the layout's coordinate space.
4498 #[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
4499 #[must_use] pub fn get_selection_rects(&self, range: &SelectionRange) -> Vec<LogicalRect> {
4500 // 1. Build a map from the logical cluster ID to the visual PositionedItem for fast lookups.
4501 let mut cluster_map: HashMap<GraphemeClusterId, &PositionedItem> = HashMap::new();
4502 for item in &self.items {
4503 if let Some(cluster) = item.item.as_cluster() {
4504 cluster_map.insert(cluster.source_cluster_id, item);
4505 }
4506 }
4507
4508 // 2. Normalize the range to ensure start always logically precedes end.
4509 let (start_cursor, end_cursor) = if range.start.cluster_id > range.end.cluster_id
4510 || (range.start.cluster_id == range.end.cluster_id
4511 && range.start.affinity > range.end.affinity)
4512 {
4513 (range.end, range.start)
4514 } else {
4515 (range.start, range.end)
4516 };
4517
4518 // 3. Find the positioned items corresponding to the start and end of the selection.
4519 let Some(start_item) = cluster_map.get(&start_cursor.cluster_id) else {
4520 return Vec::new();
4521 };
4522 let Some(end_item) = cluster_map.get(&end_cursor.cluster_id) else {
4523 return Vec::new();
4524 };
4525
4526 let mut rects = Vec::new();
4527
4528 // Helper to get the absolute visual X coordinate of a cursor. The logical
4529 // start (Leading) edge is the cluster's LEFT for LTR but its RIGHT for RTL;
4530 // Trailing is the mirror.
4531 let get_cursor_x = |item: &PositionedItem, affinity: CursorAffinity| -> f32 {
4532 let left = item.position.x;
4533 let right = item.position.x + get_item_measure(&item.item, false);
4534 let rtl = item.item.as_cluster().is_some_and(|c| c.direction.is_rtl());
4535 match (affinity, rtl) {
4536 (CursorAffinity::Leading, false) | (CursorAffinity::Trailing, true) => left,
4537 (CursorAffinity::Trailing, false) | (CursorAffinity::Leading, true) => right,
4538 }
4539 };
4540
4541 // Helper to get the visual bounding box of all content on a specific line index.
4542 let get_line_bounds = |line_index: usize| -> Option<LogicalRect> {
4543 let items_on_line = self.items.iter().filter(|i| i.line_index == line_index);
4544
4545 let mut min_x: Option<f32> = None;
4546 let mut max_x: Option<f32> = None;
4547 let mut min_y: Option<f32> = None;
4548 let mut max_y: Option<f32> = None;
4549
4550 for item in items_on_line {
4551 // Skip items that don't take up space (like hard breaks)
4552 let item_bounds = item.item.bounds();
4553 if item_bounds.width <= 0.0 && item_bounds.height <= 0.0 {
4554 continue;
4555 }
4556
4557 let item_x_end = item.position.x + item_bounds.width;
4558 let item_y_end = item.position.y + item_bounds.height;
4559
4560 min_x = Some(min_x.map_or(item.position.x, |mx| mx.min(item.position.x)));
4561 max_x = Some(max_x.map_or(item_x_end, |mx| mx.max(item_x_end)));
4562 min_y = Some(min_y.map_or(item.position.y, |my| my.min(item.position.y)));
4563 max_y = Some(max_y.map_or(item_y_end, |my| my.max(item_y_end)));
4564 }
4565
4566 if let (Some(min_x), Some(max_x), Some(min_y), Some(max_y)) =
4567 (min_x, max_x, min_y, max_y)
4568 {
4569 Some(LogicalRect {
4570 origin: LogicalPosition { x: min_x, y: min_y },
4571 size: LogicalSize {
4572 width: max_x - min_x,
4573 height: max_y - min_y,
4574 },
4575 })
4576 } else {
4577 None
4578 }
4579 };
4580
4581 // 4. Handle single-line selection.
4582 if start_item.line_index == end_item.line_index {
4583 if let Some(line_bounds) = get_line_bounds(start_item.line_index) {
4584 // Walk the selected clusters in VISUAL order and group them into
4585 // segments by bidi direction + visual contiguity, emitting one rect
4586 // per segment. A single endpoint-to-endpoint span over-covers (and can
4587 // under-cover) bidi selections, whose logically-contiguous clusters are
4588 // NOT visually contiguous. Pure-LTR/RTL contiguous runs collapse to a
4589 // single rect, matching browser/CoreText behavior.
4590 let mut segments: Vec<(f32, f32, BidiDirection)> = Vec::new();
4591 for item in &self.items {
4592 if item.line_index != start_item.line_index {
4593 continue;
4594 }
4595 let Some(c) = item.item.as_cluster() else {
4596 continue;
4597 };
4598 let id = c.source_cluster_id;
4599 // A cluster is selected when it lies within the (affinity-aware)
4600 // logical range: the start cluster is included only if the start
4601 // cursor sits on its leading edge; the end cluster only if the end
4602 // cursor sits on its trailing edge.
4603 let after_start = id > start_cursor.cluster_id
4604 || (id == start_cursor.cluster_id
4605 && start_cursor.affinity == CursorAffinity::Leading);
4606 let before_end = id < end_cursor.cluster_id
4607 || (id == end_cursor.cluster_id
4608 && end_cursor.affinity == CursorAffinity::Trailing);
4609 if !(after_start && before_end) {
4610 continue;
4611 }
4612 let x0 = item.position.x;
4613 let x1 = item.position.x + get_item_measure(&item.item, false);
4614 let (lo, hi) = (x0.min(x1), x0.max(x1));
4615 if let Some(last) = segments.last_mut() {
4616 let contiguous = lo <= last.1 + 0.5 && hi >= last.0 - 0.5;
4617 if last.2 == c.direction && contiguous {
4618 last.0 = last.0.min(lo);
4619 last.1 = last.1.max(hi);
4620 continue;
4621 }
4622 }
4623 segments.push((lo, hi, c.direction));
4624 }
4625
4626 if segments.is_empty() {
4627 // No glyph-bearing clusters (e.g. zero-advance selection):
4628 // fall back to the endpoint span so a caret-width rect still shows.
4629 let start_x = get_cursor_x(start_item, start_cursor.affinity);
4630 let end_x = get_cursor_x(end_item, end_cursor.affinity);
4631 rects.push(LogicalRect {
4632 origin: LogicalPosition {
4633 x: start_x.min(end_x),
4634 y: line_bounds.origin.y,
4635 },
4636 size: LogicalSize {
4637 width: (end_x - start_x).abs(),
4638 height: line_bounds.size.height,
4639 },
4640 });
4641 } else {
4642 for (lo, hi, _dir) in segments {
4643 rects.push(LogicalRect {
4644 origin: LogicalPosition {
4645 x: lo,
4646 y: line_bounds.origin.y,
4647 },
4648 size: LogicalSize {
4649 width: hi - lo,
4650 height: line_bounds.size.height,
4651 },
4652 });
4653 }
4654 }
4655 }
4656 }
4657 // 5. Handle multi-line selection.
4658 else {
4659 // Rectangle for the start line (from the start cursor to the line's end
4660 // in READING order). For an LTR line that is rightward (to the line's
4661 // right content edge); for an RTL line it is leftward (to the left edge).
4662 if let Some(start_line_bounds) = get_line_bounds(start_item.line_index) {
4663 let start_x = get_cursor_x(start_item, start_cursor.affinity);
4664 let line_left = start_line_bounds.origin.x;
4665 let line_right = start_line_bounds.origin.x + start_line_bounds.size.width;
4666 let rtl = start_item.item.as_cluster().is_some_and(|c| c.direction.is_rtl());
4667 let (lo, hi) = if rtl { (line_left, start_x) } else { (start_x, line_right) };
4668 rects.push(LogicalRect {
4669 origin: LogicalPosition {
4670 x: lo,
4671 y: start_line_bounds.origin.y,
4672 },
4673 size: LogicalSize {
4674 width: hi - lo,
4675 height: start_line_bounds.size.height,
4676 },
4677 });
4678 }
4679
4680 // Rectangles for all full lines in between.
4681 for line_idx in (start_item.line_index + 1)..end_item.line_index {
4682 if let Some(line_bounds) = get_line_bounds(line_idx) {
4683 rects.push(line_bounds);
4684 }
4685 }
4686
4687 // Rectangle for the end line (from the line's start in READING order to
4688 // the end cursor). For an LTR line that starts at the left content edge;
4689 // for an RTL line it starts at the right edge.
4690 if let Some(end_line_bounds) = get_line_bounds(end_item.line_index) {
4691 let end_x = get_cursor_x(end_item, end_cursor.affinity);
4692 let line_left = end_line_bounds.origin.x;
4693 let line_right = end_line_bounds.origin.x + end_line_bounds.size.width;
4694 let rtl = end_item.item.as_cluster().is_some_and(|c| c.direction.is_rtl());
4695 let (lo, hi) = if rtl { (end_x, line_right) } else { (line_left, end_x) };
4696 rects.push(LogicalRect {
4697 origin: LogicalPosition {
4698 x: lo,
4699 y: end_line_bounds.origin.y,
4700 },
4701 size: LogicalSize {
4702 width: hi - lo,
4703 height: end_line_bounds.size.height,
4704 },
4705 });
4706 }
4707 }
4708
4709 rects
4710 }
4711
4712 /// Calculates the visual rectangle for a cursor at a given logical position.
4713 #[must_use] pub fn get_cursor_rect(&self, cursor: &TextCursor) -> Option<LogicalRect> {
4714 // Find the item and glyph corresponding to the cursor's cluster ID.
4715 let mut last_cluster: Option<(&PositionedItem, &ShapedCluster)> = None;
4716 for item in &self.items {
4717 if let ShapedItem::Cluster(cluster) = &item.item {
4718 if cluster.source_cluster_id == cursor.cluster_id {
4719 // Exact match
4720 let line_height = item.item.bounds().height;
4721 // The logical-start (Leading) caret edge is the glyph's LEFT side for
4722 // an LTR cluster but its RIGHT side for an RTL cluster; Trailing is the
4723 // mirror. Resolve the edges from the cluster's own bidi direction.
4724 let (lead_x, trail_x) = if cluster.direction.is_rtl() {
4725 (item.position.x + cluster.advance, item.position.x)
4726 } else {
4727 (item.position.x, item.position.x + cluster.advance)
4728 };
4729 let cursor_x = match cursor.affinity {
4730 CursorAffinity::Leading => lead_x,
4731 CursorAffinity::Trailing => trail_x,
4732 };
4733 return Some(LogicalRect {
4734 origin: LogicalPosition {
4735 x: cursor_x,
4736 y: item.position.y,
4737 },
4738 size: LogicalSize {
4739 width: 1.0,
4740 height: line_height,
4741 },
4742 });
4743 }
4744 last_cluster = Some((item, cluster));
4745 }
4746 }
4747 // Cursor past end of text: position after the last cluster
4748 if let Some((item, cluster)) = last_cluster {
4749 if cursor.cluster_id.source_run == cluster.source_cluster_id.source_run
4750 && cursor.cluster_id.start_byte_in_run >= cluster.source_cluster_id.start_byte_in_run
4751 {
4752 let line_height = item.item.bounds().height;
4753 // Past the logical end of the run: the caret sits after the last cluster,
4754 // which is its RIGHT edge for LTR but its LEFT edge for RTL.
4755 let past_end_x = if cluster.direction.is_rtl() {
4756 item.position.x
4757 } else {
4758 item.position.x + cluster.advance
4759 };
4760 return Some(LogicalRect {
4761 origin: LogicalPosition {
4762 x: past_end_x,
4763 y: item.position.y,
4764 },
4765 size: LogicalSize {
4766 width: 1.0,
4767 height: line_height,
4768 },
4769 });
4770 }
4771 }
4772 None
4773 }
4774
4775 /// Get a cursor at the first cluster (leading edge) in the layout.
4776 #[must_use] pub fn get_first_cluster_cursor(&self) -> Option<TextCursor> {
4777 for item in &self.items {
4778 if let ShapedItem::Cluster(cluster) = &item.item {
4779 return Some(TextCursor {
4780 cluster_id: cluster.source_cluster_id,
4781 affinity: CursorAffinity::Leading,
4782 });
4783 }
4784 }
4785 None
4786 }
4787
4788 /// Get a cursor at the last cluster (trailing edge) in the layout.
4789 #[must_use] pub fn get_last_cluster_cursor(&self) -> Option<TextCursor> {
4790 for item in self.items.iter().rev() {
4791 if let ShapedItem::Cluster(cluster) = &item.item {
4792 return Some(TextCursor {
4793 cluster_id: cluster.source_cluster_id,
4794 affinity: CursorAffinity::Trailing,
4795 });
4796 }
4797 }
4798 None
4799 }
4800
4801 /// Logical sequence of caret-stop grapheme clusters, sorted by
4802 /// `(source_run, start_byte_in_run)` and de-duplicated, with combining-mark
4803 /// continuations folded into their base (UAX#29). Left/right caret motion
4804 /// advances over THIS sequence so a base and its combining marks move as one
4805 /// unit, and so the document start/end are always reachable.
4806 fn grapheme_stops(&self) -> Vec<GraphemeClusterId> {
4807 let mut stops: Vec<(GraphemeClusterId, &str)> = self
4808 .items
4809 .iter()
4810 .filter_map(|it| {
4811 it.item
4812 .as_cluster()
4813 .map(|c| (c.source_cluster_id, c.text.as_str()))
4814 })
4815 .collect();
4816 stops.sort_by(|a, b| {
4817 (a.0.source_run, a.0.start_byte_in_run).cmp(&(b.0.source_run, b.0.start_byte_in_run))
4818 });
4819 stops.dedup_by_key(|(id, _)| *id);
4820 stops
4821 .into_iter()
4822 .filter(|(_, text)| !Self::cluster_is_grapheme_continuation(text))
4823 .map(|(id, _)| id)
4824 .collect()
4825 }
4826
4827 /// True if `text`'s leading char is a grapheme extender (combining mark,
4828 /// variation selector, …) — a cluster that merges into a preceding base and
4829 /// therefore must not be a standalone caret stop (UAX#29).
4830 fn cluster_is_grapheme_continuation(text: &str) -> bool {
4831 let Some(first) = text.chars().next() else {
4832 return false;
4833 };
4834 // Probe with a dummy base letter: if `x` + first collapses to a single
4835 // grapheme, `first` extends the preceding grapheme.
4836 let mut probe = String::with_capacity(1 + first.len_utf8());
4837 probe.push('x');
4838 probe.push(first);
4839 probe.graphemes(true).count() == 1
4840 }
4841
4842 /// Caret offset of `cursor` within `stops` (0..=len): the index of its
4843 /// grapheme, plus 1 for a Trailing affinity. A cursor addressing a folded
4844 /// combining mark (or otherwise between stops) maps to the nearest preceding
4845 /// stop.
4846 fn grapheme_caret_offset(stops: &[GraphemeClusterId], cursor: &TextCursor) -> Option<usize> {
4847 let trailing = usize::from(cursor.affinity == CursorAffinity::Trailing);
4848 if let Some(idx) = stops.iter().position(|id| *id == cursor.cluster_id) {
4849 return Some(idx + trailing);
4850 }
4851 let key = (cursor.cluster_id.source_run, cursor.cluster_id.start_byte_in_run);
4852 let idx = stops
4853 .iter()
4854 .rposition(|id| (id.source_run, id.start_byte_in_run) <= key)?;
4855 Some(idx + trailing)
4856 }
4857
4858 /// Canonical cursor for a grapheme-stop `offset` (0..=len): interior/first
4859 /// offsets are the Leading edge of the stop that begins there; `len` is the
4860 /// Trailing edge of the last stop (the document end).
4861 fn cursor_from_grapheme_offset(stops: &[GraphemeClusterId], offset: usize) -> TextCursor {
4862 let n = stops.len();
4863 if offset >= n {
4864 TextCursor { cluster_id: stops[n - 1], affinity: CursorAffinity::Trailing }
4865 } else {
4866 TextCursor { cluster_id: stops[offset], affinity: CursorAffinity::Leading }
4867 }
4868 }
4869
4870 /// Moves a cursor one visible position to the left (the previous grapheme
4871 /// boundary). Affinity is consulted so each press moves exactly one stop and
4872 /// the document start (first grapheme, Leading) is reachable; combining marks
4873 /// move together with their base.
4874 pub fn move_cursor_left(
4875 &self,
4876 cursor: TextCursor,
4877 debug: &mut Option<Vec<String>>,
4878 ) -> TextCursor {
4879 let stops = self.grapheme_stops();
4880 if stops.is_empty() {
4881 return cursor;
4882 }
4883 let Some(offset) = Self::grapheme_caret_offset(&stops, &cursor) else {
4884 return cursor;
4885 };
4886 let moved = Self::cursor_from_grapheme_offset(&stops, offset.saturating_sub(1));
4887 if let Some(d) = debug {
4888 d.push(format!(
4889 "[Cursor] move_cursor_left: byte {} -> byte {}",
4890 cursor.cluster_id.start_byte_in_run, moved.cluster_id.start_byte_in_run
4891 ));
4892 }
4893 moved
4894 }
4895
4896 /// Moves a cursor one visible position to the right (the next grapheme
4897 /// boundary). Affinity is consulted so each press moves exactly one stop and
4898 /// the document end (last grapheme, Trailing) is reachable; combining marks
4899 /// move together with their base.
4900 pub fn move_cursor_right(
4901 &self,
4902 cursor: TextCursor,
4903 debug: &mut Option<Vec<String>>,
4904 ) -> TextCursor {
4905 let stops = self.grapheme_stops();
4906 if stops.is_empty() {
4907 return cursor;
4908 }
4909 let Some(offset) = Self::grapheme_caret_offset(&stops, &cursor) else {
4910 return cursor;
4911 };
4912 let moved = Self::cursor_from_grapheme_offset(&stops, (offset + 1).min(stops.len()));
4913 if let Some(d) = debug {
4914 d.push(format!(
4915 "[Cursor] move_cursor_right: byte {} -> byte {}",
4916 cursor.cluster_id.start_byte_in_run, moved.cluster_id.start_byte_in_run
4917 ));
4918 }
4919 moved
4920 }
4921
4922 /// Moves a cursor up one line, attempting to preserve the horizontal column.
4923 pub fn move_cursor_up(
4924 &self,
4925 cursor: TextCursor,
4926 goal_x: &mut Option<f32>,
4927 debug: &mut Option<Vec<String>>,
4928 ) -> TextCursor {
4929 if let Some(d) = debug {
4930 d.push(format!(
4931 "[Cursor] move_cursor_up: from byte {} (affinity {:?})",
4932 cursor.cluster_id.start_byte_in_run, cursor.affinity
4933 ));
4934 }
4935
4936 let Some(current_item) = self.items.iter().find(|i| {
4937 i.item
4938 .as_cluster()
4939 .is_some_and(|c| c.source_cluster_id == cursor.cluster_id)
4940 }) else {
4941 if let Some(d) = debug {
4942 d.push(format!(
4943 "[Cursor] move_cursor_up: cursor not found in items, staying at byte {}",
4944 cursor.cluster_id.start_byte_in_run
4945 ));
4946 }
4947 return cursor;
4948 };
4949
4950 if let Some(d) = debug {
4951 d.push(format!(
4952 "[Cursor] move_cursor_up: current line {}, position ({}, {})",
4953 current_item.line_index, current_item.position.x, current_item.position.y
4954 ));
4955 }
4956
4957 let target_line_idx = current_item.line_index.saturating_sub(1);
4958 if current_item.line_index == target_line_idx {
4959 if let Some(d) = debug {
4960 d.push(format!(
4961 "[Cursor] move_cursor_up: already at top line {}, staying put",
4962 current_item.line_index
4963 ));
4964 }
4965 return cursor;
4966 }
4967
4968 let current_x = goal_x.unwrap_or_else(|| {
4969 let x = match cursor.affinity {
4970 CursorAffinity::Leading => current_item.position.x,
4971 CursorAffinity::Trailing => {
4972 current_item.position.x + get_item_measure(¤t_item.item, false)
4973 }
4974 };
4975 *goal_x = Some(x);
4976 x
4977 });
4978
4979 // Find the Y coordinate of the middle of the target line
4980 let target_y = self
4981 .items
4982 .iter()
4983 .find(|i| i.line_index == target_line_idx)
4984 .map_or(current_item.position.y, |i| i.position.y + (i.item.bounds().height / 2.0));
4985
4986 if let Some(d) = debug {
4987 d.push(format!(
4988 "[Cursor] move_cursor_up: target line {target_line_idx}, hittesting at ({current_x}, {target_y})"
4989 ));
4990 }
4991
4992 let result = self
4993 .hittest_cursor(LogicalPosition {
4994 x: current_x,
4995 y: target_y,
4996 })
4997 .unwrap_or(cursor);
4998
4999 if let Some(d) = debug {
5000 d.push(format!(
5001 "[Cursor] move_cursor_up: result byte {} (affinity {:?})",
5002 result.cluster_id.start_byte_in_run, result.affinity
5003 ));
5004 }
5005
5006 result
5007 }
5008
5009 /// Moves a cursor down one line, attempting to preserve the horizontal column.
5010 pub fn move_cursor_down(
5011 &self,
5012 cursor: TextCursor,
5013 goal_x: &mut Option<f32>,
5014 debug: &mut Option<Vec<String>>,
5015 ) -> TextCursor {
5016 if let Some(d) = debug {
5017 d.push(format!(
5018 "[Cursor] move_cursor_down: from byte {} (affinity {:?})",
5019 cursor.cluster_id.start_byte_in_run, cursor.affinity
5020 ));
5021 }
5022
5023 let Some(current_item) = self.items.iter().find(|i| {
5024 i.item
5025 .as_cluster()
5026 .is_some_and(|c| c.source_cluster_id == cursor.cluster_id)
5027 }) else {
5028 if let Some(d) = debug {
5029 d.push(format!(
5030 "[Cursor] move_cursor_down: cursor not found in items, staying at byte {}",
5031 cursor.cluster_id.start_byte_in_run
5032 ));
5033 }
5034 return cursor;
5035 };
5036
5037 if let Some(d) = debug {
5038 d.push(format!(
5039 "[Cursor] move_cursor_down: current line {}, position ({}, {})",
5040 current_item.line_index, current_item.position.x, current_item.position.y
5041 ));
5042 }
5043
5044 let max_line = self.items.iter().map(|i| i.line_index).max().unwrap_or(0);
5045 let target_line_idx = (current_item.line_index + 1).min(max_line);
5046 if current_item.line_index == target_line_idx {
5047 if let Some(d) = debug {
5048 d.push(format!(
5049 "[Cursor] move_cursor_down: already at bottom line {}, staying put",
5050 current_item.line_index
5051 ));
5052 }
5053 return cursor;
5054 }
5055
5056 let current_x = goal_x.unwrap_or_else(|| {
5057 let x = match cursor.affinity {
5058 CursorAffinity::Leading => current_item.position.x,
5059 CursorAffinity::Trailing => {
5060 current_item.position.x + get_item_measure(¤t_item.item, false)
5061 }
5062 };
5063 *goal_x = Some(x);
5064 x
5065 });
5066
5067 let target_y = self
5068 .items
5069 .iter()
5070 .find(|i| i.line_index == target_line_idx)
5071 .map_or(current_item.position.y, |i| i.position.y + (i.item.bounds().height / 2.0));
5072
5073 if let Some(d) = debug {
5074 d.push(format!(
5075 "[Cursor] move_cursor_down: hit testing at ({current_x}, {target_y})"
5076 ));
5077 }
5078
5079 let result = self
5080 .hittest_cursor(LogicalPosition {
5081 x: current_x,
5082 y: target_y,
5083 })
5084 .unwrap_or(cursor);
5085
5086 if let Some(d) = debug {
5087 d.push(format!(
5088 "[Cursor] move_cursor_down: result byte {}, affinity {:?}",
5089 result.cluster_id.start_byte_in_run, result.affinity
5090 ));
5091 }
5092
5093 result
5094 }
5095
5096 /// Moves a cursor to the visual start of its current line.
5097 pub fn move_cursor_to_line_start(
5098 &self,
5099 cursor: TextCursor,
5100 debug: &mut Option<Vec<String>>,
5101 ) -> TextCursor {
5102 if let Some(d) = debug {
5103 d.push(format!(
5104 "[Cursor] move_cursor_to_line_start: starting at byte {}, affinity {:?}",
5105 cursor.cluster_id.start_byte_in_run, cursor.affinity
5106 ));
5107 }
5108
5109 let Some(current_item) = self.items.iter().find(|i| {
5110 i.item
5111 .as_cluster()
5112 .is_some_and(|c| c.source_cluster_id == cursor.cluster_id)
5113 }) else {
5114 if let Some(d) = debug {
5115 d.push(format!(
5116 "[Cursor] move_cursor_to_line_start: cursor not found, staying at byte {}",
5117 cursor.cluster_id.start_byte_in_run
5118 ));
5119 }
5120 return cursor;
5121 };
5122
5123 if let Some(d) = debug {
5124 d.push(format!(
5125 "[Cursor] move_cursor_to_line_start: current line {}, position ({}, {})",
5126 current_item.line_index, current_item.position.x, current_item.position.y
5127 ));
5128 }
5129
5130 let first_item_on_line = self
5131 .items
5132 .iter()
5133 .filter(|i| i.line_index == current_item.line_index)
5134 .min_by(|a, b| {
5135 a.position
5136 .x
5137 .partial_cmp(&b.position.x)
5138 .unwrap_or(Ordering::Equal)
5139 });
5140
5141 if let Some(item) = first_item_on_line {
5142 if let ShapedItem::Cluster(c) = &item.item {
5143 let result = TextCursor {
5144 cluster_id: c.source_cluster_id,
5145 affinity: CursorAffinity::Leading,
5146 };
5147 if let Some(d) = debug {
5148 d.push(format!(
5149 "[Cursor] move_cursor_to_line_start: result byte {}, affinity {:?}",
5150 result.cluster_id.start_byte_in_run, result.affinity
5151 ));
5152 }
5153 return result;
5154 }
5155 }
5156
5157 if let Some(d) = debug {
5158 d.push(format!(
5159 "[Cursor] move_cursor_to_line_start: no first item found, staying at byte {}",
5160 cursor.cluster_id.start_byte_in_run
5161 ));
5162 }
5163 cursor
5164 }
5165
5166 /// Moves a cursor to the visual end of its current line.
5167 pub fn move_cursor_to_line_end(
5168 &self,
5169 cursor: TextCursor,
5170 debug: &mut Option<Vec<String>>,
5171 ) -> TextCursor {
5172 if let Some(d) = debug {
5173 d.push(format!(
5174 "[Cursor] move_cursor_to_line_end: starting at byte {}, affinity {:?}",
5175 cursor.cluster_id.start_byte_in_run, cursor.affinity
5176 ));
5177 }
5178
5179 let Some(current_item) = self.items.iter().find(|i| {
5180 i.item
5181 .as_cluster()
5182 .is_some_and(|c| c.source_cluster_id == cursor.cluster_id)
5183 }) else {
5184 if let Some(d) = debug {
5185 d.push(format!(
5186 "[Cursor] move_cursor_to_line_end: cursor not found, staying at byte {}",
5187 cursor.cluster_id.start_byte_in_run
5188 ));
5189 }
5190 return cursor;
5191 };
5192
5193 if let Some(d) = debug {
5194 d.push(format!(
5195 "[Cursor] move_cursor_to_line_end: current line {}, position ({}, {})",
5196 current_item.line_index, current_item.position.x, current_item.position.y
5197 ));
5198 }
5199
5200 let last_item_on_line = self
5201 .items
5202 .iter()
5203 .filter(|i| i.line_index == current_item.line_index)
5204 .max_by(|a, b| {
5205 a.position
5206 .x
5207 .partial_cmp(&b.position.x)
5208 .unwrap_or(Ordering::Equal)
5209 });
5210
5211 if let Some(item) = last_item_on_line {
5212 if let ShapedItem::Cluster(c) = &item.item {
5213 let result = TextCursor {
5214 cluster_id: c.source_cluster_id,
5215 affinity: CursorAffinity::Trailing,
5216 };
5217 if let Some(d) = debug {
5218 d.push(format!(
5219 "[Cursor] move_cursor_to_line_end: result byte {}, affinity {:?}",
5220 result.cluster_id.start_byte_in_run, result.affinity
5221 ));
5222 }
5223 return result;
5224 }
5225 }
5226
5227 if let Some(d) = debug {
5228 d.push(format!(
5229 "[Cursor] move_cursor_to_line_end: no last item found, staying at byte {}",
5230 cursor.cluster_id.start_byte_in_run
5231 ));
5232 }
5233 cursor
5234 }
5235
5236 /// Moves a cursor one word to the left (Ctrl+Left / Option+Left).
5237 ///
5238 /// Word boundaries use the shared [`is_word_char`] predicate (alphanumeric or
5239 /// underscore are word characters; whitespace AND punctuation are boundaries),
5240 /// so this agrees with double-click word selection. The cursor moves past any
5241 /// boundary clusters to the left, then past word clusters until the next
5242 /// boundary or start of text.
5243 pub fn move_cursor_to_prev_word(
5244 &self,
5245 cursor: TextCursor,
5246 debug: &mut Option<Vec<String>>,
5247 ) -> TextCursor {
5248 if let Some(d) = debug {
5249 d.push(format!(
5250 "[Cursor] move_cursor_to_prev_word: starting at byte {}, affinity {:?}",
5251 cursor.cluster_id.start_byte_in_run, cursor.affinity
5252 ));
5253 }
5254
5255 let Some(current_pos) = self.items.iter().position(|i| {
5256 i.item
5257 .as_cluster()
5258 .is_some_and(|c| c.source_cluster_id == cursor.cluster_id)
5259 }) else {
5260 return cursor;
5261 };
5262
5263 // Phase 1: Skip whitespace going left
5264 let mut pos = if cursor.affinity == CursorAffinity::Leading {
5265 // Already at leading edge, start from previous item
5266 current_pos.checked_sub(1)
5267 } else {
5268 // At trailing edge, start from current item
5269 Some(current_pos)
5270 };
5271
5272 // Skip boundary clusters (whitespace + punctuation)
5273 while let Some(p) = pos {
5274 if let Some(cluster) = self.items[p].item.as_cluster() {
5275 if !cluster_is_word_boundary(cluster) {
5276 break;
5277 }
5278 }
5279 pos = p.checked_sub(1);
5280 }
5281
5282 // Phase 2: Skip word clusters going left (the word itself)
5283 while let Some(p) = pos {
5284 if let Some(cluster) = self.items[p].item.as_cluster() {
5285 if cluster_is_word_boundary(cluster) {
5286 // We've reached a boundary before the word — stop at next cluster
5287 if p + 1 < self.items.len() {
5288 if let Some(c) = self.items[p + 1].item.as_cluster() {
5289 return TextCursor {
5290 cluster_id: c.source_cluster_id,
5291 affinity: CursorAffinity::Leading,
5292 };
5293 }
5294 }
5295 break;
5296 }
5297 }
5298 if p == 0 {
5299 // Reached start of text — return first cluster
5300 if let Some(c) = self.items[0].item.as_cluster() {
5301 return TextCursor {
5302 cluster_id: c.source_cluster_id,
5303 affinity: CursorAffinity::Leading,
5304 };
5305 }
5306 break;
5307 }
5308 pos = p.checked_sub(1);
5309 }
5310
5311 // If we exhausted the search, go to first cluster
5312 if pos.is_none() {
5313 if let Some(first) = self.get_first_cluster_cursor() {
5314 return first;
5315 }
5316 }
5317
5318 cursor
5319 }
5320
5321 /// Moves a cursor one word to the right (Ctrl+Right / Option+Right).
5322 ///
5323 /// Word boundaries use the shared [`is_word_char`] predicate (alphanumeric or
5324 /// underscore are word characters; whitespace AND punctuation are boundaries),
5325 /// so this agrees with double-click word selection. The cursor moves past any
5326 /// word clusters, then past boundary clusters until the next word or end of text.
5327 pub fn move_cursor_to_next_word(
5328 &self,
5329 cursor: TextCursor,
5330 debug: &mut Option<Vec<String>>,
5331 ) -> TextCursor {
5332 if let Some(d) = debug {
5333 d.push(format!(
5334 "[Cursor] move_cursor_to_next_word: starting at byte {}, affinity {:?}",
5335 cursor.cluster_id.start_byte_in_run, cursor.affinity
5336 ));
5337 }
5338
5339 let Some(current_pos) = self.items.iter().position(|i| {
5340 i.item
5341 .as_cluster()
5342 .is_some_and(|c| c.source_cluster_id == cursor.cluster_id)
5343 }) else {
5344 return cursor;
5345 };
5346
5347 let len = self.items.len();
5348
5349 // Start position: if at leading edge, start from current; if trailing, start from next
5350 let start = if cursor.affinity == CursorAffinity::Trailing {
5351 current_pos + 1
5352 } else {
5353 current_pos
5354 };
5355
5356 if start >= len {
5357 return cursor;
5358 }
5359
5360 let mut pos = start;
5361
5362 // Phase 1: Skip word clusters (current word)
5363 while pos < len {
5364 if let Some(cluster) = self.items[pos].item.as_cluster() {
5365 if cluster_is_word_boundary(cluster) {
5366 break;
5367 }
5368 }
5369 pos += 1;
5370 }
5371
5372 // Phase 2: Skip boundary clusters (whitespace + punctuation) after word
5373 while pos < len {
5374 if let Some(cluster) = self.items[pos].item.as_cluster() {
5375 if !cluster_is_word_boundary(cluster) {
5376 // Found start of next word
5377 return TextCursor {
5378 cluster_id: cluster.source_cluster_id,
5379 affinity: CursorAffinity::Leading,
5380 };
5381 }
5382 }
5383 pos += 1;
5384 }
5385
5386 // Reached end of text
5387 if let Some(last) = self.get_last_cluster_cursor() {
5388 return last;
5389 }
5390
5391 cursor
5392 }
5393}
5394
5395#[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
5396fn get_baseline_for_item(item: &ShapedItem) -> Option<f32> {
5397 match item {
5398 ShapedItem::CombinedBlock {
5399 baseline_offset, ..
5400 } => Some(*baseline_offset),
5401 ShapedItem::Object {
5402 baseline_offset, ..
5403 } => Some(*baseline_offset),
5404 // We have to get the clusters font from the last glyph
5405 ShapedItem::Cluster(ref cluster) => {
5406 cluster.glyphs.last().map(|last_glyph| last_glyph
5407 .font_metrics
5408 .baseline_scaled(last_glyph.style.font_size_px))
5409 }
5410 ShapedItem::Break { source, break_info } => {
5411 // Breaks do not contribute to baseline
5412 None
5413 }
5414 ShapedItem::Tab { source, bounds } => {
5415 // Tabs do not contribute to baseline
5416 None
5417 }
5418 }
5419}
5420
5421/// Stores information about content that exceeded the available layout space.
5422#[derive(Debug, Clone, Default)]
5423pub struct OverflowInfo {
5424 /// The items that did not fit within the constraints.
5425 ///
5426 /// Currently always empty: the positioners place every item (visual overflow
5427 /// is clipped at paint time) rather than dropping content, so nothing is ever
5428 /// recorded here. The `window.rs` incremental-patch guard reads
5429 /// `overflow_items.is_empty()` to stay future-proof against a positioning path
5430 /// that *does* drop items. TODO(superplan): populate this if such a path lands.
5431 pub overflow_items: Vec<ShapedItem>,
5432 /// The total bounds of all positioned content, including any that overflows
5433 /// the constraints. Populated by both positioners (greedy + Knuth-Plass) from
5434 /// [`UnifiedLayout::bounds`]; useful for `OverflowBehavior::Visible`/`Scroll`.
5435 pub unclipped_bounds: Rect,
5436}
5437
5438impl OverflowInfo {
5439 #[must_use] pub const fn has_overflow(&self) -> bool {
5440 !self.overflow_items.is_empty()
5441 }
5442}
5443
5444/// Intermediate structure carrying information from the line breaker to the positioner.
5445#[derive(Debug, Clone)]
5446pub struct UnifiedLine {
5447 pub items: Vec<ShapedItem>,
5448 /// The y-position (for horizontal) or x-position (for vertical) of the line's baseline.
5449 pub cross_axis_position: f32,
5450 /// The geometric segments this line must fit into.
5451 pub constraints: LineConstraints,
5452 pub is_last: bool,
5453}
5454
5455// --- Caching Infrastructure ---
5456
5457pub type CacheId = u64;
5458
5459/// Defines a single area for layout, with its own shape and properties.
5460#[derive(Debug, Clone)]
5461pub struct LayoutFragment {
5462 /// A unique identifier for this fragment (e.g., "main-content", "sidebar").
5463 pub id: String,
5464 /// The geometric and style constraints for this specific fragment.
5465 pub constraints: UnifiedConstraints,
5466}
5467
5468/// Represents the final layout distributed across multiple fragments.
5469#[derive(Debug, Clone)]
5470pub(crate) struct FlowLayout {
5471 /// A map from a fragment's unique ID to the layout it contains.
5472 pub(crate) fragment_layouts: HashMap<String, Arc<UnifiedLayout>>,
5473 /// Any items that did not fit into the last fragment in the flow chain.
5474 /// This is useful for pagination or determining if more layout space is needed.
5475 pub(crate) remaining_items: Vec<ShapedItem>,
5476}
5477
5478/// Inline-axis intrinsic contributions derived from shaped text, without running
5479/// the line-breaking stage of the pipeline.
5480///
5481/// Callers that only need min/max-content widths for sizing (see
5482/// `calculate_ifc_root_intrinsic_sizes`) should prefer this over invoking
5483/// `layout_flow` twice with `AvailableSpace::MinContent`/`MaxContent`. The
5484/// latter runs the full flow loop — including `BreakCursor::peek_next_unit`,
5485/// which clones every `ShapedCluster` it inspects — even though no constraint
5486/// actually limits the line width.
5487#[derive(Copy, Debug, Clone, Default)]
5488pub struct IntrinsicTextSizes {
5489 /// CSS min-content = widest unbreakable unit (word) along the inline axis.
5490 pub min_content_width: f32,
5491 /// CSS max-content = sum of all advances along the inline axis (single line).
5492 pub max_content_width: f32,
5493 /// Height of a single line box: max(ascent + descent) across all items.
5494 pub max_content_height: f32,
5495}
5496
5497/// Cached line break boundaries from a previous layout pass.
5498///
5499/// Enables incremental relayout: when a word changes width,
5500/// we can check if it still fits on the same line without
5501/// re-running the full line-breaking algorithm.
5502#[derive(Clone, Debug)]
5503pub struct CachedLineBreaks {
5504 /// Per-line: (`first_item_idx`, `last_item_idx_exclusive`) into positioned items.
5505 pub line_ranges: Vec<(usize, usize)>,
5506 /// Per-line total width (sum of item advances on that line).
5507 pub line_widths: Vec<f32>,
5508 /// The available width constraint used when these breaks were computed.
5509 pub available_width: f32,
5510}
5511
5512/// Result of an incremental relayout attempt.
5513#[derive(Copy, Clone, Debug)]
5514pub enum IncrementalRelayoutResult {
5515 /// Glyphs changed but advance widths identical — swap in place, no repositioning.
5516 GlyphSwap,
5517 /// Width changed but still fits on same line — shift `x_offsets` of subsequent items.
5518 LineShift {
5519 /// Index of the first affected item.
5520 affected_item: usize,
5521 /// Width delta (`new_advance` - `old_advance`).
5522 delta: f32,
5523 },
5524 /// Line breaks changed — need to reflow from this line onward.
5525 PartialReflow {
5526 /// The line index from which to start reflowing.
5527 reflow_from_line: usize,
5528 },
5529 /// Cannot do incremental — fall back to full relayout.
5530 FullRelayout,
5531}
5532
5533/// Extract line break boundaries from a positioned items list.
5534#[must_use] pub fn extract_line_breaks(
5535 items: &[PositionedItem],
5536 available_width: f32,
5537) -> CachedLineBreaks {
5538 let mut line_ranges = Vec::new();
5539 let mut line_widths = Vec::new();
5540
5541 if items.is_empty() {
5542 return CachedLineBreaks { line_ranges, line_widths, available_width };
5543 }
5544
5545 let mut line_start = 0usize;
5546 let mut current_line = items[0].line_index;
5547 let mut line_width = 0.0f32;
5548
5549 for (i, item) in items.iter().enumerate() {
5550 if item.line_index != current_line {
5551 line_ranges.push((line_start, i));
5552 line_widths.push(line_width);
5553 line_start = i;
5554 current_line = item.line_index;
5555 line_width = 0.0;
5556 }
5557 line_width += get_item_measure(&item.item, false);
5558 }
5559
5560 // Final line
5561 line_ranges.push((line_start, items.len()));
5562 line_widths.push(line_width);
5563
5564 CachedLineBreaks { line_ranges, line_widths, available_width }
5565}
5566
5567/// Attempt incremental relayout given old metrics and new per-item advance widths.
5568///
5569/// `dirty_item_indices`: which items in the shaped list changed.
5570/// `old_advances`: per-item advance widths from the previous layout.
5571/// `new_advances`: per-item advance widths after reshaping.
5572/// `line_breaks`: cached line boundaries from previous layout.
5573#[must_use] pub fn try_incremental_relayout(
5574 dirty_item_indices: &[usize],
5575 old_advances: &[f32],
5576 new_advances: &[f32],
5577 line_breaks: &CachedLineBreaks,
5578) -> IncrementalRelayoutResult {
5579 if dirty_item_indices.is_empty() {
5580 return IncrementalRelayoutResult::GlyphSwap;
5581 }
5582
5583 // Check each dirty item
5584 for &dirty_idx in dirty_item_indices {
5585 if dirty_idx >= old_advances.len() || dirty_idx >= new_advances.len() {
5586 return IncrementalRelayoutResult::FullRelayout;
5587 }
5588
5589 let old_adv = old_advances[dirty_idx];
5590 let new_adv = new_advances[dirty_idx];
5591 let delta = new_adv - old_adv;
5592
5593 if delta.abs() < 0.001 {
5594 // Same width — just swap glyphs (GlyphSwap for this item)
5595 continue;
5596 }
5597
5598 // Width changed — find which line this item is on
5599 let line_idx = line_breaks.line_ranges.iter()
5600 .position(|&(start, end)| dirty_idx >= start && dirty_idx < end);
5601
5602 let Some(line_idx) = line_idx else {
5603 return IncrementalRelayoutResult::FullRelayout;
5604 };
5605
5606 let old_line_width = line_breaks.line_widths[line_idx];
5607 let new_line_width = old_line_width + delta;
5608
5609 if new_line_width <= line_breaks.available_width {
5610 // Still fits on same line — shift subsequent items
5611 return IncrementalRelayoutResult::LineShift {
5612 affected_item: dirty_idx,
5613 delta,
5614 };
5615 }
5616 // Overflows line — need to reflow from this line
5617 return IncrementalRelayoutResult::PartialReflow {
5618 reflow_from_line: line_idx,
5619 };
5620 }
5621
5622 // All dirty items had same width
5623 IncrementalRelayoutResult::GlyphSwap
5624}
5625
5626/// Cached shaped result for a single visual item (or coalesced group).
5627/// Enables per-item cache hits when only one word changes in a paragraph.
5628#[derive(Debug)]
5629pub(crate) struct PerItemShapedEntry {
5630 /// The shaped clusters for this single item/group.
5631 pub(crate) clusters: Vec<ShapedItem>,
5632 /// Sum of advance widths — for fast same-width detection during incremental relayout.
5633 pub(crate) total_advance: f32,
5634}
5635
5636#[derive(Debug)]
5637pub struct TextShapingCache {
5638 // Stage 1 Cache: InlineContent -> LogicalItems
5639 logical_items: HashMap<CacheId, Arc<Vec<LogicalItem>>>,
5640 // Stage 2 Cache: LogicalItems -> VisualItems
5641 visual_items: HashMap<CacheId, Arc<Vec<VisualItem>>>,
5642 // Stage 3 Cache: VisualItems -> ShapedItems (monolithic, for backward compat)
5643 shaped_items: HashMap<CacheId, Arc<Vec<ShapedItem>>>,
5644 // Stage 3b Cache: Per-item/coalesce-group shaped results
5645 // Key: hash(text, bidi_level, script, style.layout_hash())
5646 per_item_shaped: HashMap<u64, Arc<PerItemShapedEntry>>,
5647 /// Tracks which `per_item_shaped` keys were accessed in the current generation.
5648 per_item_accessed: HashSet<u64>,
5649 /// Current generation counter, incremented each layout pass.
5650 generation: u64,
5651}
5652
5653/// Approximate heap bytes retained by a [`TextShapingCache`].
5654#[derive(Copy, Debug, Clone, Default)]
5655pub struct TextCacheMemoryReport {
5656 pub logical_items_entries: usize,
5657 pub logical_items_bytes: usize,
5658 pub visual_items_entries: usize,
5659 pub visual_items_bytes: usize,
5660 pub shaped_items_entries: usize,
5661 pub shaped_items_bytes: usize,
5662 pub shaped_glyph_bytes: usize,
5663 pub shaped_cluster_text_bytes: usize,
5664 pub per_item_shaped_entries: usize,
5665 pub per_item_shaped_bytes: usize,
5666}
5667
5668impl TextCacheMemoryReport {
5669 #[must_use] pub const fn total_bytes(&self) -> usize {
5670 self.logical_items_bytes
5671 + self.visual_items_bytes
5672 + self.shaped_items_bytes
5673 + self.shaped_glyph_bytes
5674 + self.shaped_cluster_text_bytes
5675 + self.per_item_shaped_bytes
5676 }
5677}
5678
5679impl TextShapingCache {
5680 #[must_use] pub fn new() -> Self {
5681 Self {
5682 logical_items: HashMap::new(),
5683 visual_items: HashMap::new(),
5684 shaped_items: HashMap::new(),
5685 per_item_shaped: HashMap::new(),
5686 per_item_accessed: HashSet::new(),
5687 generation: 0,
5688 }
5689 }
5690
5691 /// Approximate per-stage heap-byte breakdown.
5692 #[allow(clippy::field_reassign_with_default)] // struct built incrementally / test setup; a struct literal is not clearer here
5693 #[must_use] pub fn memory_report(&self) -> TextCacheMemoryReport {
5694 let mut r = TextCacheMemoryReport::default();
5695 r.logical_items_entries = self.logical_items.len();
5696 for arc in self.logical_items.values() {
5697 r.logical_items_bytes += arc.capacity() * size_of::<LogicalItem>();
5698 }
5699 r.visual_items_entries = self.visual_items.len();
5700 for arc in self.visual_items.values() {
5701 r.visual_items_bytes += arc.capacity() * size_of::<VisualItem>();
5702 }
5703 r.shaped_items_entries = self.shaped_items.len();
5704 for arc in self.shaped_items.values() {
5705 r.shaped_items_bytes += arc.capacity() * size_of::<ShapedItem>();
5706 for item in arc.iter() {
5707 if let ShapedItem::Cluster(c) = item {
5708 r.shaped_glyph_bytes += c.glyphs.capacity() * size_of::<ShapedGlyph>();
5709 r.shaped_cluster_text_bytes += c.text.capacity();
5710 }
5711 }
5712 }
5713 r.per_item_shaped_entries = self.per_item_shaped.len();
5714 for arc in self.per_item_shaped.values() {
5715 r.per_item_shaped_bytes += arc.clusters.capacity() * size_of::<ShapedItem>();
5716 for item in &arc.clusters {
5717 if let ShapedItem::Cluster(c) = item {
5718 r.per_item_shaped_bytes += c.glyphs.capacity() * size_of::<ShapedGlyph>();
5719 r.per_item_shaped_bytes += c.text.capacity();
5720 }
5721 }
5722 }
5723 r
5724 }
5725
5726 /// Call at the start of each layout pass. Evicts per-item shaped entries
5727 /// not accessed in the previous generation to prevent unbounded growth.
5728 pub fn begin_generation(&mut self) {
5729 if self.generation > 0 && !self.per_item_accessed.is_empty() {
5730 // Evict entries not accessed in this generation
5731 let accessed = &self.per_item_accessed;
5732 self.per_item_shaped.retain(|k, _| accessed.contains(k));
5733 }
5734 self.per_item_accessed.clear();
5735 self.generation += 1;
5736 }
5737
5738 /// Check if we can reuse an old layout based on layout-affecting parameters.
5739 ///
5740 /// This function compares only the parameters that affect glyph positions,
5741 /// not rendering-only parameters like color or text-decoration.
5742 ///
5743 /// # Parameters
5744 /// - `old_constraints`: The constraints used for the cached layout
5745 /// - `new_constraints`: The constraints for the new layout request
5746 /// - `old_content`: The content used for the cached layout
5747 /// - `new_content`: The new content to layout
5748 ///
5749 /// # Returns
5750 /// - `true` if the old layout can be reused (only rendering changed)
5751 /// - `false` if a new layout is needed (layout-affecting params changed)
5752 #[must_use] pub fn use_old_layout(
5753 old_constraints: &UnifiedConstraints,
5754 new_constraints: &UnifiedConstraints,
5755 old_content: &[InlineContent],
5756 new_content: &[InlineContent],
5757 ) -> bool {
5758 // First check: constraints must match exactly for layout purposes
5759 if old_constraints != new_constraints {
5760 return false;
5761 }
5762
5763 // Second check: content length must match
5764 if old_content.len() != new_content.len() {
5765 return false;
5766 }
5767
5768 // Third check: each content item must have same layout properties
5769 for (old, new) in old_content.iter().zip(new_content.iter()) {
5770 if !Self::inline_content_layout_eq(old, new) {
5771 return false;
5772 }
5773 }
5774
5775 true
5776 }
5777
5778 /// Compare two `InlineContent` items for layout equality.
5779 ///
5780 /// Returns true if the layouts would be identical (only rendering differs).
5781 fn inline_content_layout_eq(old: &InlineContent, new: &InlineContent) -> bool {
5782 use InlineContent::{Text, Image, Space, LineBreak, Tab, Marker, Shape, Ruby};
5783 match (old, new) {
5784 (Text(old_run), Text(new_run)) => {
5785 // Text must match exactly, but style only needs layout_eq
5786 old_run.text == new_run.text
5787 && old_run.style.layout_eq(&new_run.style)
5788 }
5789 (Image(old_img), Image(new_img)) => {
5790 // Images: size affects layout, but not visual properties
5791 old_img.intrinsic_size == new_img.intrinsic_size
5792 && old_img.display_size == new_img.display_size
5793 && old_img.baseline_offset == new_img.baseline_offset
5794 && old_img.alignment == new_img.alignment
5795 }
5796 (Space(old_sp), Space(new_sp)) => old_sp == new_sp,
5797 (LineBreak(old_br), LineBreak(new_br)) => old_br == new_br,
5798 (Tab { style: old_style }, Tab { style: new_style }) => old_style.layout_eq(new_style),
5799 (Marker { run: old_run, position_outside: old_pos },
5800 Marker { run: new_run, position_outside: new_pos }) => {
5801 old_pos == new_pos
5802 && old_run.text == new_run.text
5803 && old_run.style.layout_eq(&new_run.style)
5804 }
5805 (Shape(old_shape), Shape(new_shape)) => {
5806 // Shapes: shape_def affects layout, not fill/stroke
5807 old_shape.shape_def == new_shape.shape_def
5808 && old_shape.baseline_offset == new_shape.baseline_offset
5809 }
5810 (Ruby { base: old_base, text: old_text, style: old_style },
5811 Ruby { base: new_base, text: new_text, style: new_style }) => {
5812 old_style.layout_eq(new_style)
5813 && old_base.len() == new_base.len()
5814 && old_text.len() == new_text.len()
5815 && old_base.iter().zip(new_base.iter())
5816 .all(|(o, n)| Self::inline_content_layout_eq(o, n))
5817 && old_text.iter().zip(new_text.iter())
5818 .all(|(o, n)| Self::inline_content_layout_eq(o, n))
5819 }
5820 // Different variants cannot have same layout
5821 _ => false,
5822 }
5823 }
5824}
5825
5826impl Default for TextShapingCache {
5827 fn default() -> Self {
5828 Self::new()
5829 }
5830}
5831
5832/// Key for caching the conversion from `InlineContent` to `LogicalItem`s.
5833#[derive(Debug, Clone, Eq, PartialEq, Hash)]
5834pub(crate) struct LogicalItemsKey<'a> {
5835 pub(crate) inline_content_hash: u64,
5836 pub(crate) default_font_size: u32,
5837 pub(crate) _marker: std::marker::PhantomData<&'a ()>,
5838}
5839
5840/// Key for caching the Bidi reordering stage.
5841#[derive(Debug, Clone, Eq, PartialEq, Hash)]
5842pub(crate) struct VisualItemsKey {
5843 pub(crate) logical_items_id: CacheId,
5844 pub(crate) base_direction: BidiDirection,
5845}
5846
5847/// Key for caching the shaping stage.
5848#[derive(Debug, Clone, Eq, PartialEq, Hash)]
5849pub(crate) struct ShapedItemsKey {
5850 pub(crate) visual_items_id: CacheId,
5851 pub(crate) style_hash: u64,
5852}
5853
5854impl ShapedItemsKey {
5855 pub(crate) fn new(visual_items_id: CacheId, visual_items: &[VisualItem]) -> Self {
5856 let style_hash = {
5857 let mut hasher = DefaultHasher::new();
5858 for item in visual_items {
5859 // Hash the style from the logical source, as this is what determines the font.
5860 match &item.logical_source {
5861 LogicalItem::Text { style, .. } | LogicalItem::CombinedText { style, .. } => {
5862 style.as_ref().hash(&mut hasher);
5863 }
5864 _ => {}
5865 }
5866 }
5867 hasher.finish()
5868 };
5869
5870 Self {
5871 visual_items_id,
5872 style_hash,
5873 }
5874 }
5875}
5876
5877/// Key for the final layout stage.
5878#[derive(Debug, Clone, Eq, PartialEq, Hash)]
5879pub(crate) struct LayoutKey {
5880 pub(crate) shaped_items_id: CacheId,
5881 pub(crate) constraints: UnifiedConstraints,
5882}
5883
5884/// Helper to create a `CacheId` from any `Hash`able type.
5885fn calculate_id<T: Hash>(item: &T) -> CacheId {
5886 let mut hasher = DefaultHasher::new();
5887 item.hash(&mut hasher);
5888 hasher.finish()
5889}
5890
5891// --- Main Layout Pipeline Implementation ---
5892
5893impl TextShapingCache {
5894 /// New top-level entry point for flowing layout across multiple regions.
5895 ///
5896 /// This function orchestrates the entire layout pipeline, but instead of fitting
5897 /// content into a single set of constraints, it flows the content through an
5898 /// ordered sequence of `LayoutFragment`s.
5899 ///
5900 /// # CSS Inline Layout Module Level 3: Pipeline Implementation
5901 ///
5902 /// This implements the inline formatting context with 5 stages:
5903 ///
5904 /// ## Stage 1: Logical Analysis (`InlineContent` -> `LogicalItem`)
5905 /// \u2705 IMPLEMENTED: Parses raw content into logical units
5906 /// - Handles text runs, inline-blocks, replaced elements
5907 /// - Applies style overrides at character level
5908 /// - Implements \u00a7 2.2: Content size contribution calculation
5909 ///
5910 /// ## Stage 2: `BiDi` Reordering (`LogicalItem` -> `VisualItem`)
5911 /// \u2705 IMPLEMENTED: Uses CSS 'direction' property per CSS Writing Modes
5912 /// - Reorders items for right-to-left text (Arabic, Hebrew)
5913 /// - Respects containing block direction (not auto-detection)
5914 /// - Conforms to Unicode `BiDi` Algorithm (UAX #9)
5915 ///
5916 /// ## Stage 3: Shaping (`VisualItem` -> `ShapedItem`)
5917 /// \u2705 IMPLEMENTED: Converts text to glyphs
5918 /// - Uses `HarfBuzz` for OpenType shaping
5919 /// - Handles ligatures, kerning, contextual forms
5920 /// - Caches shaped results for performance
5921 ///
5922 /// ## Stage 4: Text Orientation Transformations
5923 /// \u26a0\ufe0f PARTIAL: Applies text-orientation for vertical text
5924 /// - Uses constraints from *first* fragment only
5925 /// - \u274c TODO: Should re-orient if fragments have different writing modes
5926 ///
5927 /// ## Stage 5: Flow Loop (`ShapedItem` -> `PositionedItem`)
5928 /// \u2705 IMPLEMENTED: Breaks lines and positions content
5929 /// - Calls `perform_fragment_layout` for each fragment
5930 /// - Uses `BreakCursor` to flow content across fragments
5931 /// - Implements \u00a7 5: Line breaking and hyphenation
5932 ///
5933 /// # Missing Features from CSS Inline-3:
5934 /// - \u00a7 3.3: initial-letter (drop caps)
5935 /// - \u00a7 4: vertical-align (only baseline supported)
5936 /// - \u00a7 6: text-box-trim (leading trim)
5937 /// - \u00a7 7: inline-sizing (aspect-ratio for inline-blocks)
5938 ///
5939 /// # Arguments
5940 /// * `content` - The raw `InlineContent` to be laid out.
5941 /// * `style_overrides` - Character-level style changes.
5942 /// * `flow_chain` - An ordered slice of `LayoutFragment` defining the regions (e.g., columns,
5943 /// pages) that the content should flow through.
5944 /// * `font_chain_cache` - Pre-resolved font chains (from `FontManager.font_chain_cache`)
5945 /// * `fc_cache` - The fontconfig cache for font lookups
5946 /// * `loaded_fonts` - Pre-loaded fonts, keyed by `FontId`
5947 ///
5948 /// # Returns
5949 /// A `FlowLayout` struct containing the positioned items for each fragment that
5950 /// was filled, and any content that did not fit in the final fragment.
5951 #[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
5952 /// # Panics
5953 ///
5954 /// Panics if bidi reordering of the logical items fails (an internal invariant).
5955 /// # Errors
5956 ///
5957 /// Returns a `LayoutError` if text flow layout fails.
5958 pub fn layout_flow<T: ParsedFontTrait>(
5959 &mut self,
5960 content: &[InlineContent],
5961 style_overrides: &[StyleOverride],
5962 flow_chain: &[LayoutFragment],
5963 font_chain_cache: &HashMap<FontChainKey, rust_fontconfig::FontFallbackChain>,
5964 fc_cache: &FcFontCache,
5965 loaded_fonts: &LoadedFonts<T>,
5966 debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
5967 ) -> Result<FlowLayout, LayoutError> {
5968 // [g150 az-web-lift DIAG] content data ptr (0x60BD0) + len (0x60BD4) at layout_flow ENTRY.
5969 #[cfg(feature = "web_lift")]
5970 unsafe {
5971 crate::az_mark((0x60BD0) as u32, (content.as_ptr() as usize as u32) as u32);
5972 crate::az_mark((0x60BD4) as u32, (content.len() as u32 | 0xC0DE0000) as u32);
5973 }
5974 // [g218 2026-06-09] The g158 `content.len()` force-materialize (a volatile read of content+16) is
5975 // DELETED: the within-fn SROA-to-0 of content.len() it worked around is now fixed (NEON-decoder +
5976 // volatile-guest-load transpiler work). VERIFIED: hello-world lays out without it — counter "5"
5977 // (label_wrapper 8,16,784,40) + button shape correctly, same rects as before. (The cross-FN Vec-*return*-
5978 // len mis-lift is a separate, still-present issue handled by the g127/g129/g130 out-param hacks — see
5979 // g134 marker: callee content.len=1 but the caller's return-read sees 0.)
5980 // --- Stages 1-3: Preparation ---
5981 // These stages are independent of the final geometry. We perform them once
5982 // on the entire content block before flowing. Caching is used at each stage.
5983
5984 // Cap per-item shaped cache to prevent unbounded growth.
5985 // When threshold is exceeded, evict entries not accessed this generation.
5986 const PER_ITEM_CACHE_MAX: usize = 4096;
5987 if self.per_item_shaped.len() > PER_ITEM_CACHE_MAX {
5988 self.begin_generation();
5989 }
5990
5991 // Stage 1: Logical Analysis (InlineContent -> LogicalItem)
5992 // [g213 2026-06-09] The web lift uses the real `self.logical_items` HashMap cache (NO bypass).
5993 // This entry() find-probe USED to spin forever on the lift (g178-g210 mis-diagnosed it many ways).
5994 // TRUE root cause: hashbrown's portable WIDTH=8 `Group::static_empty()` — `[0xFF; 8]` in libazul's
5995 // `__TEXT.__const` — was not mirrored into the wasm, so the empty-map ctrl-scan read 0x00, looked
5996 // ALL-FULL (EMPTY=0xFF), and the probe never terminated. FIXED entirely transpiler-side in
5997 // `dll/src/web/symbol_table.rs::compute_hashbrown_empty_group_ranges` (signature-scans `__const`
5998 // for >=8-byte 8-aligned 0xFF runs and mirrors them). Verified: web-nested-text lays out
5999 // ("Hello" at 8,16,800,20), __remill_error=0. No azul-source workaround needed here.
6000 let logical_items_id = calculate_id(&content);
6001 let logical_items = self
6002 .logical_items
6003 .entry(logical_items_id)
6004 .or_insert_with(|| {
6005 Arc::new(create_logical_items(content, style_overrides, debug_messages))
6006 })
6007 .clone();
6008
6009 // Get the first fragment's constraints to extract the CSS direction property.
6010 // This is used for BiDi reordering in Stage 2.
6011 let default_constraints = UnifiedConstraints::default();
6012 let first_constraints = flow_chain
6013 .first()
6014 .map_or(&default_constraints, |f| &f.constraints);
6015
6016 // +spec:containing-block:e7a271 - paragraph embedding level set from containing block's 'direction' property
6017 // +spec:display-property:7665cb - inline boxes split into multiple visual runs due to bidi text processing
6018 // +spec:display-property:929d6b - applies Unicode bidi algorithm to inline-level box sequences
6019 // +spec:display-property:e8584a - Apply Unicode bidi algorithm to inline-level box sequences per CSS Writing Modes §2.4
6020 // Stage 2: Bidi Reordering (LogicalItem -> VisualItem)
6021 // +spec:containing-block:961e3c - bidi paragraph level from containing block direction, not UAX9 heuristic
6022 // +spec:writing-modes:0a5368 - unicode-bidi: plaintext auto-detects direction from text content
6023 // Per CSS Writing Modes §8.3: when unicode-bidi is plaintext, the paragraph's
6024 // base direction is determined from text content (first strong character), ignoring
6025 // the containing block's direction property. Empty paragraphs fall back to
6026 // the containing block's direction.
6027 let unicode_bidi_val = first_constraints.unicode_bidi;
6028 let base_direction = if unicode_bidi_val == UnicodeBidi::Plaintext {
6029 // Auto-detect from text content; fall back to containing block direction
6030 let has_strong = logical_items.iter().any(|item| {
6031 if let LogicalItem::Text { text, .. } = item {
6032 matches!(unicode_bidi::get_base_direction(text.as_str()),
6033 unicode_bidi::Direction::Ltr | unicode_bidi::Direction::Rtl)
6034 } else {
6035 false
6036 }
6037 });
6038 if has_strong {
6039 get_base_direction_from_logical(&logical_items)
6040 } else {
6041 // Empty paragraph: use containing block's direction
6042 first_constraints.direction.unwrap_or(BidiDirection::Ltr)
6043 }
6044 } else {
6045 // Normal case: use CSS direction property
6046 first_constraints.direction.unwrap_or(BidiDirection::Ltr)
6047 };
6048 let visual_key = VisualItemsKey {
6049 logical_items_id,
6050 base_direction,
6051 };
6052 let visual_items_id = calculate_id(&visual_key);
6053 // [g213] web lift uses the real visual_items HashMap cache (g180 bypass deleted; WIDTH=8
6054 // EMPTY_GROUP now mirrored — see Stage-1 note + symbol_table.rs).
6055 let visual_items = self
6056 .visual_items
6057 .entry(visual_items_id)
6058 .or_insert_with(|| {
6059 Arc::new(
6060 reorder_logical_items(&logical_items, base_direction, unicode_bidi_val, debug_messages).unwrap(),
6061 )
6062 })
6063 .clone();
6064
6065 // Stage 3: Shaping (VisualItem -> ShapedItem)
6066 // Two-level cache: monolithic (fast path) + per-item (incremental path).
6067 let shaped_key = ShapedItemsKey::new(visual_items_id, &visual_items);
6068 let shaped_items_id = calculate_id(&shaped_key);
6069 // [g213] web lift uses the real shaped_items HashMap cache (g180 bypass deleted).
6070 let shaped_items = if let Some(cached) = self.shaped_items.get(&shaped_items_id) {
6071 // Monolithic cache hit — all visual items unchanged
6072 cached.clone()
6073 } else {
6074 // Monolithic miss — use per-item cache for incremental reshaping.
6075 // Items not in per-item cache are shaped; cached items are reused.
6076 let items = Arc::new(shape_visual_items_with_per_item_cache(
6077 &visual_items,
6078 &mut self.per_item_shaped,
6079 &mut self.per_item_accessed,
6080 font_chain_cache,
6081 fc_cache,
6082 loaded_fonts,
6083 debug_messages,
6084 )?);
6085 self.shaped_items.insert(shaped_items_id, items.clone());
6086 items
6087 };
6088
6089 // --- Stage 4: Apply Vertical Text Transformations ---
6090
6091 // Note: first_constraints was already extracted above for BiDi reordering (Stage 2).
6092 // This orients all text based on the constraints of the *first* fragment.
6093 // A more advanced system could defer orientation until inside the loop if
6094 // fragments can have different writing modes.
6095 let oriented_items = apply_text_orientation(shaped_items, first_constraints);
6096
6097 // --- Stage 5: The Flow Loop ---
6098 let mut fragment_layouts = HashMap::new();
6099 // The cursor now manages the stream of items for the entire flow.
6100 // §5.2 word-break: pass word_break from constraints to cursor
6101 let mut cursor = BreakCursor::with_word_break(&oriented_items, first_constraints.word_break);
6102 cursor.hyphens = first_constraints.hyphenation;
6103 cursor.line_break = first_constraints.line_break;
6104
6105 // [g147 az-web-lift] Hard safety bound on the Stage-5 flow loop. On the remill lift this
6106 // `for fragment in flow_chain` (or the `cursor.is_done()` break) mis-lifts for the NESTED IFC
6107 // and iterates without terminating → solveLayoutReal HANGS (fuel trap in layout_flow). The text
6108 // is fully laid out on the first iteration(s); cap the iterations so the loop always converges.
6109 // (native is unaffected — the cap is far above any real fragment count.)
6110 #[allow(clippy::no_effect_underscore_binding)] // web_lift-gated debug iteration counter
6111 let mut _az_flow_iters: usize = 0;
6112 for fragment in flow_chain {
6113 #[cfg(feature = "web_lift")]
6114 {
6115 _az_flow_iters += 1;
6116 unsafe { crate::az_mark((0x60BC0) as u32, (_az_flow_iters as u32 | 0xC0DE0000) as u32); }
6117 if _az_flow_iters > 256 {
6118 break;
6119 }
6120 }
6121 // Perform layout for this single fragment, consuming items from the cursor.
6122 let fragment_layout = perform_fragment_layout(
6123 &mut cursor,
6124 &logical_items,
6125 &fragment.constraints,
6126 debug_messages,
6127 loaded_fonts,
6128 )?;
6129
6130 fragment_layouts.insert(fragment.id.clone(), Arc::new(fragment_layout));
6131 if cursor.is_done() {
6132 break; // All content has been laid out.
6133 }
6134 }
6135
6136 Ok(FlowLayout {
6137 fragment_layouts,
6138 remaining_items: cursor.drain_remaining(),
6139 })
6140 }
6141
6142 /// Runs stages 1–4 of the layout pipeline (logical analysis, `BiDi`, shaping,
6143 /// text orientation) and derives min/max-content widths by scanning the
6144 /// resulting `ShapedItem`s directly — without running stage 5's line-breaking
6145 /// `BreakCursor` loop.
6146 ///
6147 /// Used by `calculate_ifc_root_intrinsic_sizes` to avoid the 24% CPU spent
6148 /// cloning `ShapedCluster`s inside `BreakCursor::peek_next_unit` on every
6149 /// sizing pass. Since stages 1–3 hit the same `per_item_shaped` cache as
6150 /// `layout_flow`, a subsequent `layout_flow` call for the same content at
6151 /// a real container width is a pure cache hit for the shaping work.
6152 ///
6153 /// The item walk uses the same break-opportunity predicate that the
6154 /// `BreakCursor` would — min-content accumulates advances between break
6155 /// opportunities and tracks the maximum; max-content is the sum of all
6156 /// advances (as if the flow were laid out on a single infinitely-wide line).
6157 #[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
6158 /// # Panics
6159 ///
6160 /// Panics if bidi reordering of the logical items fails (an internal invariant).
6161 /// # Errors
6162 ///
6163 /// Returns a `LayoutError` if measuring intrinsic widths fails.
6164 pub fn measure_intrinsic_widths<T: ParsedFontTrait>(
6165 &mut self,
6166 content: &[InlineContent],
6167 style_overrides: &[StyleOverride],
6168 constraints: &UnifiedConstraints,
6169 font_chain_cache: &HashMap<FontChainKey, rust_fontconfig::FontFallbackChain>,
6170 fc_cache: &FcFontCache,
6171 loaded_fonts: &LoadedFonts<T>,
6172 debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
6173 ) -> Result<IntrinsicTextSizes, LayoutError> {
6174 const PER_ITEM_CACHE_MAX: usize = 4096;
6175 if self.per_item_shaped.len() > PER_ITEM_CACHE_MAX {
6176 self.begin_generation();
6177 }
6178
6179 // Stage 1: Logical Analysis (cached, same as layout_flow — the historic web-lift
6180 // bypass here was rooted in the un-mirrored hashbrown EMPTY_GROUP, fixed transpiler-side
6181 // in symbol_table.rs::compute_hashbrown_empty_group_ranges).
6182 let logical_items_id = calculate_id(&content);
6183 let logical_items = self
6184 .logical_items
6185 .entry(logical_items_id)
6186 .or_insert_with(|| {
6187 Arc::new(create_logical_items(content, style_overrides, debug_messages))
6188 })
6189 .clone();
6190
6191 // Stage 2: BiDi (same derivation as layout_flow)
6192 let unicode_bidi_val = constraints.unicode_bidi;
6193 let base_direction = if unicode_bidi_val == UnicodeBidi::Plaintext {
6194 let has_strong = logical_items.iter().any(|item| {
6195 if let LogicalItem::Text { text, .. } = item {
6196 matches!(unicode_bidi::get_base_direction(text.as_str()),
6197 unicode_bidi::Direction::Ltr | unicode_bidi::Direction::Rtl)
6198 } else {
6199 false
6200 }
6201 });
6202 if has_strong {
6203 get_base_direction_from_logical(&logical_items)
6204 } else {
6205 constraints.direction.unwrap_or(BidiDirection::Ltr)
6206 }
6207 } else {
6208 constraints.direction.unwrap_or(BidiDirection::Ltr)
6209 };
6210 let visual_key = VisualItemsKey {
6211 logical_items_id,
6212 base_direction,
6213 };
6214 let visual_items_id = calculate_id(&visual_key);
6215 let visual_items = self
6216 .visual_items
6217 .entry(visual_items_id)
6218 .or_insert_with(|| {
6219 Arc::new(
6220 reorder_logical_items(&logical_items, base_direction, unicode_bidi_val, debug_messages).unwrap(),
6221 )
6222 })
6223 .clone();
6224
6225 // Stage 3: Shaping (two-level cache, same as layout_flow)
6226 let shaped_key = ShapedItemsKey::new(visual_items_id, &visual_items);
6227 let shaped_items_id = calculate_id(&shaped_key);
6228 let shaped_items = if let Some(cached) = self.shaped_items.get(&shaped_items_id) { cached.clone() } else {
6229 let items = Arc::new(shape_visual_items_with_per_item_cache(
6230 &visual_items,
6231 &mut self.per_item_shaped,
6232 &mut self.per_item_accessed,
6233 font_chain_cache,
6234 fc_cache,
6235 loaded_fonts,
6236 debug_messages,
6237 )?);
6238 self.shaped_items.insert(shaped_items_id, items.clone());
6239 items
6240 };
6241
6242 // Stage 4: Text orientation
6243 let oriented_items = apply_text_orientation(shaped_items, constraints);
6244
6245 // Stage 5 bypass: scan items for min/max contributions.
6246 let word_break = constraints.word_break;
6247 let hyphens = constraints.hyphenation;
6248
6249 let mut total = 0.0f32; // running width of the current line
6250 let mut max_line = 0.0f32; // widest line between forced breaks = max-content
6251 let mut max_word = 0.0f32;
6252 let mut cur_word = 0.0f32;
6253 let mut max_line_height = 0.0f32;
6254
6255 for item in oriented_items.iter() {
6256 // A forced break (preserved LF, <br>) ends the current line. max-content
6257 // is the widest line BETWEEN forced breaks, not the running sum across
6258 // them — otherwise a white-space:pre block with newlines (or any <br>
6259 // content) over-measures its max-content as the concatenation of all
6260 // lines. Reset the line accumulators here.
6261 if let ShapedItem::Break { .. } = item {
6262 if total > max_line { max_line = total; }
6263 if cur_word > max_word { max_word = cur_word; }
6264 total = 0.0;
6265 cur_word = 0.0;
6266 continue;
6267 }
6268 // Must match get_item_measure() exactly: a cluster's inline advance
6269 // INCLUDES per-glyph kerning. Omitting kerning here under-measures
6270 // max-content, so a shrink-to-fit box (e.g. a flex item sized to its
6271 // text's max-content) ends up narrower than the kerned text the line
6272 // breaker lays out — the word then "overflows" its own box and, with
6273 // overflow-wrap:normal, gets force-broken to its first cluster
6274 // (the menubar "View" → "V" clip). Summing (advance + kerning) here,
6275 // in the same order as the breaker, makes the box exactly fit.
6276 let advance = match item {
6277 ShapedItem::Cluster(c) => {
6278 let total_kerning: f32 = c.glyphs.iter().map(|g| g.kerning).sum();
6279 let mut a = c.advance + total_kerning;
6280 // Match position_line_items exactly: letter-spacing is added after
6281 // every non-cursive cluster and word-spacing on word separators.
6282 // Omitting them here under-measures a shrink-to-fit box, so the
6283 // laid-out (spaced) text overflows its own min/max-content width.
6284 if !is_cursive_script_cluster(c) {
6285 a += c.style.letter_spacing.resolve_px(c.style.font_size_px);
6286 }
6287 if is_word_separator(item) {
6288 a += c.style.word_spacing.resolve_px(c.style.font_size_px);
6289 }
6290 a
6291 }
6292 ShapedItem::CombinedBlock { bounds, .. }
6293 | ShapedItem::Object { bounds, .. }
6294 | ShapedItem::Tab { bounds, .. } => bounds.width,
6295 ShapedItem::Break { .. } => 0.0,
6296 };
6297 let adv = advance.max(0.0);
6298 total += adv;
6299
6300 let (asc, desc) = get_item_vertical_metrics_approx(item);
6301 let h = (asc + desc).max(item.bounds().height);
6302 if h > max_line_height {
6303 max_line_height = h;
6304 }
6305
6306 if is_break_opportunity_with_word_break(item, word_break, hyphens) {
6307 if cur_word > max_word {
6308 max_word = cur_word;
6309 }
6310 // A break opportunity that is itself a rendered unit (a CJK
6311 // ideograph in normal mode, or any cluster under break-all /
6312 // overflow-wrap:anywhere) still forms a minimal unbreakable unit
6313 // of its own advance; only true separators (spaces) contribute 0.
6314 // Without this, pure-CJK / break-all text measures min-content = 0
6315 // and the box collapses to zero inline width.
6316 if !is_word_separator(item) && adv > max_word {
6317 max_word = adv;
6318 }
6319 cur_word = 0.0;
6320 } else {
6321 cur_word += adv;
6322 }
6323 }
6324 if cur_word > max_word {
6325 max_word = cur_word;
6326 }
6327 if total > max_line {
6328 max_line = total;
6329 }
6330
6331 // white-space:nowrap forbids soft-wrap opportunities entirely, so the
6332 // min-content width equals the max-content width (one unbreakable line).
6333 // Without this the scan resets cur_word at each space and reports a
6334 // too-small min-content, letting flex/shrink-to-fit clip the text.
6335 let min_content_width = if matches!(constraints.white_space_mode, WhiteSpaceMode::Nowrap) {
6336 max_line
6337 } else {
6338 max_word
6339 };
6340
6341 Ok(IntrinsicTextSizes {
6342 min_content_width,
6343 max_content_width: max_line,
6344 max_content_height: max_line_height,
6345 })
6346 }
6347}
6348
6349// --- Stage 1 Implementation ---
6350#[allow(clippy::cast_possible_truncation)] // bounded pixel/coord/colour/glyph cast
6351#[allow(clippy::too_many_lines, clippy::cognitive_complexity)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
6352/// # Panics
6353///
6354/// Panics if the scan cursor advances past the end of `text` (an internal invariant).
6355pub fn create_logical_items(
6356 content: &[InlineContent],
6357 style_overrides: &[StyleOverride],
6358 debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
6359) -> Vec<LogicalItem> {
6360 if let Some(msgs) = debug_messages {
6361 msgs.push(LayoutDebugMessage::info(
6362 "\n--- Entering create_logical_items (Refactored) ---".to_string(),
6363 ));
6364 msgs.push(LayoutDebugMessage::info(format!(
6365 "Input content length: {}",
6366 content.len()
6367 )));
6368 msgs.push(LayoutDebugMessage::info(format!(
6369 "Input overrides length: {}",
6370 style_overrides.len()
6371 )));
6372 }
6373
6374 let mut items: Vec<LogicalItem> = Vec::new();
6375 let mut style_cache: HashMap<u64, Arc<StyleProperties>> = HashMap::new();
6376
6377 // 1. Organize overrides for fast lookup per run.
6378 let mut run_overrides: HashMap<u32, HashMap<u32, &PartialStyleProperties>> = HashMap::new();
6379 for override_item in style_overrides {
6380 run_overrides
6381 .entry(override_item.target.run_index)
6382 .or_default()
6383 .insert(override_item.target.item_index, &override_item.style);
6384 }
6385
6386 for (run_idx, inline_item) in content.iter().enumerate() {
6387 if let Some(msgs) = debug_messages {
6388 msgs.push(LayoutDebugMessage::info(format!(
6389 "Processing content run #{run_idx}"
6390 )));
6391 }
6392
6393 // Extract marker information if this is a marker
6394 let marker_position_outside = match inline_item {
6395 InlineContent::Marker {
6396 position_outside, ..
6397 } => Some(*position_outside),
6398 _ => None,
6399 };
6400
6401 // [az-web-lift FIX 2026-06-06] Handle the common Text/Marker case via a STANDALONE `if let`
6402 // (a simple discriminant compare) instead of the first arm of the multi-way `match` below.
6403 // The remill lift mis-routes that multi-way InlineContent switch (LLVM's `subs/csel`-clamp
6404 // lowering): a Text(disc 0) variant lands in the `_`/Object arm → `inline_item.clone()` →
6405 // `<InlineContent as Clone>::clone` ALSO mis-routes to its Vec-clone arm → reads a heap ptr
6406 // as a Vec len → ×8 → ~789 MB alloc → BumpAlloc memset OOB. A standalone if-let lowers to a
6407 // single cmp/beq the lift handles correctly, so Text reaches its real body. Native unaffected.
6408 if let InlineContent::Text(run) | InlineContent::Marker { run, .. } = inline_item {
6409 let text = &run.text;
6410 if text.is_empty() {
6411 if let Some(msgs) = debug_messages {
6412 msgs.push(LayoutDebugMessage::info(
6413 " Run is empty, skipping.".to_string(),
6414 ));
6415 }
6416 continue;
6417 }
6418 if let Some(msgs) = debug_messages {
6419 msgs.push(LayoutDebugMessage::info(format!(" Run text: '{text}'")));
6420 }
6421
6422 let current_run_overrides = run_overrides.get(&(run_idx as u32));
6423 let mut boundaries = BTreeSet::new();
6424 boundaries.insert(0);
6425 boundaries.insert(text.len());
6426
6427 // --- Stateful Boundary Generation ---
6428 // web-lift FIX + perf: this scan_cursor walk ONLY inserts boundaries for
6429 // per-char style overrides (Rule 2) or text-combine-upright digit runs (Rule 1).
6430 // For plain text (no overrides AND no combine-upright) it inserts NOTHING and just
6431 // walks char-by-char via `scan_cursor += current_char.len_utf8()` — which the web
6432 // lift mis-advances (overshoot → slice_start_index_len_fail OOB; stall → infinite
6433 // loop). Skip the whole walk in that common case so `boundaries` stays {0, len}.
6434 let needs_scan = current_run_overrides.is_some()
6435 || run.style.text_combine_upright.is_some();
6436 let mut scan_cursor = 0;
6437 while needs_scan && scan_cursor < text.len() {
6438 let style_at_cursor = current_run_overrides.and_then(|o| o.get(&(scan_cursor as u32))).map_or_else(|| (*run.style).clone(), |partial| run.style.apply_override(partial));
6439
6440 let current_char = text[scan_cursor..].chars().next().unwrap();
6441
6442 // +spec:containing-block:e4d9de - text-combine-upright digit run rules: digits sharing an ancestor with same value form one sequence across box boundaries
6443 // +spec:inline-formatting-context:f65029 - text-combine-upright text run rules: combine consecutive digits not interrupted by box boundary
6444 // Rule 1: Multi-character features take precedence.
6445 // +spec:containing-block:9a26bd - text-combine-upright digit runs scoped by ancestor style boundaries
6446 if let Some(TextCombineUpright::Digits(max_digits)) =
6447 style_at_cursor.text_combine_upright
6448 {
6449 if max_digits > 0 && current_char.is_ascii_digit() {
6450 let digit_chunk: String = text[scan_cursor..]
6451 .chars()
6452 .take(max_digits as usize)
6453 .take_while(char::is_ascii_digit)
6454 .collect();
6455
6456 let end_of_chunk = scan_cursor + digit_chunk.len();
6457 boundaries.insert(scan_cursor);
6458 boundaries.insert(end_of_chunk);
6459 scan_cursor = end_of_chunk; // Jump past the entire sequence
6460 continue;
6461 }
6462 }
6463
6464 // Rule 2: If no multi-char feature, check for a normal single-grapheme
6465 // override.
6466 if current_run_overrides
6467 .and_then(|o| o.get(&(scan_cursor as u32)))
6468 .is_some()
6469 {
6470 let grapheme_len = text[scan_cursor..]
6471 .graphemes(true)
6472 .next()
6473 .unwrap_or("")
6474 .len();
6475 boundaries.insert(scan_cursor);
6476 boundaries.insert(scan_cursor + grapheme_len);
6477 scan_cursor += grapheme_len;
6478 continue;
6479 }
6480
6481 // Rule 3: No special features or overrides at this point, just advance one
6482 // char.
6483 scan_cursor += current_char.len_utf8();
6484 }
6485
6486 if let Some(msgs) = debug_messages {
6487 msgs.push(LayoutDebugMessage::info(format!(
6488 " Boundaries: {boundaries:?}"
6489 )));
6490 }
6491
6492 // --- Chunk Processing ---
6493 for (start, end) in boundaries.iter().zip(boundaries.iter().skip(1)) {
6494 let (start, end) = (*start, *end);
6495 if start >= end {
6496 continue;
6497 }
6498
6499 let text_slice = &text[start..end];
6500 if let Some(msgs) = debug_messages {
6501 msgs.push(LayoutDebugMessage::info(format!(
6502 " Processing chunk from {start} to {end}: '{text_slice}'"
6503 )));
6504 }
6505
6506 let style_to_use = current_run_overrides.and_then(|o| o.get(&(start as u32))).map_or_else(|| run.style.clone(), |partial_style| {
6507 if let Some(msgs) = debug_messages {
6508 msgs.push(LayoutDebugMessage::info(format!(
6509 " -> Applying override at byte {start}"
6510 )));
6511 }
6512 let mut hasher = DefaultHasher::new();
6513 Arc::as_ptr(&run.style).hash(&mut hasher);
6514 partial_style.hash(&mut hasher);
6515 style_cache
6516 .entry(hasher.finish())
6517 .or_insert_with(|| Arc::new(run.style.apply_override(partial_style)))
6518 .clone()
6519 });
6520
6521 // +spec:block-formatting-context:9e7c79 - text-combine-upright combines multiple characters into 1em in vertical writing
6522 // +spec:containing-block:2b399b - text-combine-upright digits: combine ASCII digit sequences within max_digits limit; box boundaries implicitly prevent cross-box combination
6523 // +spec:display-contents:644c78 - text-combine-upright run boundary check:
6524 // if a combinable run boundary is due only to inline box boundaries,
6525 // and adjacent chars would form a longer combinable sequence, do not combine
6526 // +spec:white-space-processing:409d90 - text-combine-upright combined text: white space at start/end processed as in inline-block
6527 let is_combinable_chunk = match &style_to_use.text_combine_upright {
6528 Some(TextCombineUpright::All) => !text_slice.is_empty(),
6529 Some(TextCombineUpright::Digits(max_digits)) => {
6530 *max_digits > 0
6531 && !text_slice.is_empty()
6532 && text_slice.chars().all(|c| c.is_ascii_digit())
6533 && text_slice.chars().count() <= *max_digits as usize
6534 }
6535 _ => false,
6536 };
6537
6538 if is_combinable_chunk {
6539 // Trim leading/trailing white space like an inline-block
6540 let trimmed = text_slice.trim();
6541 let combined_text = if trimmed.is_empty() {
6542 text_slice.to_string()
6543 } else {
6544 trimmed.to_string()
6545 };
6546 items.push(LogicalItem::CombinedText {
6547 source: ContentIndex {
6548 run_index: run_idx as u32,
6549 item_index: start as u32,
6550 },
6551 text: combined_text,
6552 style: style_to_use,
6553 });
6554 } else {
6555 items.push(LogicalItem::Text {
6556 source: ContentIndex {
6557 run_index: run_idx as u32,
6558 item_index: start as u32,
6559 },
6560 text: text_slice.to_string(),
6561 style: style_to_use,
6562 marker_position_outside,
6563 source_node_id: run.source_node_id,
6564 });
6565 }
6566 }
6567 } else {
6568 match inline_item {
6569 // line breaking class characters must be treated as forced line breaks
6570 InlineContent::LineBreak(break_info) => {
6571 if let Some(msgs) = debug_messages {
6572 msgs.push(LayoutDebugMessage::info(format!(
6573 " LineBreak: {break_info:?}"
6574 )));
6575 }
6576 items.push(LogicalItem::Break {
6577 source: ContentIndex {
6578 run_index: run_idx as u32,
6579 item_index: 0,
6580 },
6581 break_info: *break_info,
6582 });
6583 }
6584 // Handle tab characters
6585 InlineContent::Tab { style } => {
6586 if let Some(msgs) = debug_messages {
6587 msgs.push(LayoutDebugMessage::info(" Tab character".to_string()));
6588 }
6589 items.push(LogicalItem::Tab {
6590 source: ContentIndex {
6591 run_index: run_idx as u32,
6592 item_index: 0,
6593 },
6594 style: style.clone(),
6595 });
6596 }
6597 // Other cases (Image, Shape, Space, Ruby). Text/Marker are handled by the `if let`
6598 // above (so they never reach here at runtime); `_` keeps this inner match exhaustive.
6599 _ => {
6600 if let Some(msgs) = debug_messages {
6601 msgs.push(LayoutDebugMessage::info(
6602 " Run is not text, creating generic LogicalItem.".to_string(),
6603 ));
6604 }
6605 items.push(LogicalItem::Object {
6606 source: ContentIndex {
6607 run_index: run_idx as u32,
6608 item_index: 0,
6609 },
6610 content: inline_item.clone(),
6611 });
6612 }
6613 }
6614 }
6615 }
6616 if let Some(msgs) = debug_messages {
6617 msgs.push(LayoutDebugMessage::info(format!(
6618 "--- Exiting create_logical_items, created {} items ---",
6619 items.len()
6620 )));
6621 }
6622 items
6623}
6624
6625// --- Stage 2 Implementation ---
6626
6627// +spec:inline-block:d47971 - unicode-bidi:plaintext uses P2/P3 heuristic for base direction (implemented via get_base_direction)
6628// +spec:writing-modes:287491 - BiDi reordering and base direction detection (Appendix A text processing order)
6629// when determining base direction, consistent with their neutral bidi treatment
6630#[must_use] pub fn get_base_direction_from_logical(logical_items: &[LogicalItem]) -> BidiDirection {
6631 let first_strong = logical_items.iter().find_map(|item| {
6632 if let LogicalItem::Text { text, .. } = item {
6633 Some(unicode_bidi::get_base_direction(text.as_str()))
6634 } else {
6635 None
6636 }
6637 });
6638
6639 match first_strong {
6640 Some(unicode_bidi::Direction::Rtl) => BidiDirection::Rtl,
6641 _ => BidiDirection::Ltr,
6642 }
6643}
6644
6645// +spec:containing-block:149255 - bidi reordering produces inline box fragments that may separate in wide containing blocks
6646// +spec:containing-block:c7c08f - bidi reordering produces inline box fragments that may be adjacent in narrow containing blocks
6647// +spec:containing-block:2936ae - bidi reordering splits inline boxes into visual fragments (CSS Writing Modes 4 §2.4.5)
6648// +spec:display-property:0cdbd3 - bidi reordering splits inline boxes into visual runs; each run is shaped/formatted independently
6649// +spec:display-property:0d62a2 - bidi reordering of inline content respects block direction and unicode-bidi embedding
6650// +spec:display-property:10f9cd - bidi reordering splits and reorders inline box fragments
6651// +spec:display-property:58b30a - bidi paragraph breaks within inline boxes: each IFC does independent bidi analysis, so splitting an inline box at a paragraph boundary naturally closes/reopens bidi embeddings
6652// +spec:display-property:ecd935 - inline boxes split and reordered for uniform bidi flow
6653// +spec:writing-modes:330b8f - text ordered according to Unicode bidi algorithm after white-space processing
6654// +spec:writing-modes:7a9e7d - bidi control translation: text passed to unicode_bidi for reordering
6655// +spec:writing-modes:8e7281 - unicode-bidi property: bidi control codes inserted via BidiInfo
6656#[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
6657#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
6658/// # Errors
6659///
6660/// Returns a `LayoutError` if bidi reordering fails.
6661pub fn reorder_logical_items(
6662 logical_items: &[LogicalItem],
6663 base_direction: BidiDirection,
6664 unicode_bidi: UnicodeBidi,
6665 debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
6666) -> Result<Vec<VisualItem>, LayoutError> {
6667 if let Some(msgs) = debug_messages {
6668 msgs.push(LayoutDebugMessage::info(
6669 "\n--- Entering reorder_logical_items ---".to_string(),
6670 ));
6671 msgs.push(LayoutDebugMessage::info(format!(
6672 "Input logical items count: {}",
6673 logical_items.len()
6674 )));
6675 msgs.push(LayoutDebugMessage::info(format!(
6676 "Base direction: {base_direction:?}"
6677 )));
6678 }
6679
6680 // +spec:writing-modes:809513 - bidi string built across inline element boundaries; unicode-bidi:normal adds no extra embedding levels
6681 let mut bidi_str = String::new();
6682 let mut item_map = Vec::new();
6683 // Byte offset in `bidi_str` where each logical item's text begins, indexed
6684 // by logical item index. Used to re-base each visual run's byte offset to be
6685 // relative to its own logical run (see `run_byte_offset`).
6686 let mut logical_item_starts = Vec::with_capacity(logical_items.len());
6687 for (idx, item) in logical_items.iter().enumerate() {
6688 // +spec:containing-block:1fdc31 - inline boxes with unicode-bidi:normal are transparent to bidi algorithm
6689 // +spec:display-property:074abf - inline boxes transparent to bidi when unicode-bidi:normal
6690 // +spec:display-property:354966 - unicode-bidi control code injection for inline boxes
6691 // +spec:display-property:8409d3 - inline-level elements with unicode-bidi:normal have no effect on bidi ordering; embed creates an embedding
6692 // +spec:display-property:89464a - inline boxes with unicode-bidi:normal don't open embedding levels, so direction has no effect on bidi reordering
6693 // +spec:display-property:d47971 - bidi control codes should be injected at inline box boundaries based on unicode-bidi + direction
6694 // +spec:display-property:de657b - bidi control codes injected for display:inline boxes per unicode-bidi value
6695 // +spec:display-property:f01a81 - bidi-override should prepend LRO/RLO and append PDF per unicode-bidi CSS property (not yet implemented)
6696 // are treated as neutral characters in the bidi algorithm. Replaced elements with
6697 // +spec:display-property:fcb011 - unicode-bidi values on inline boxes insert bidi control codes
6698 // +spec:display-property:89095f - isolate/bidi-override/isolate-override/plaintext semantics
6699 // +spec:writing-modes:d490bf - direction only affects reordering when unicode-bidi is embed/override (not yet enforced for inline elements)
6700 // display:inline are also neutral unless unicode-bidi != normal (not yet implemented).
6701 // +spec:display-property:b4756e - replaced inline elements treated as neutral bidi chars;
6702 // embed/bidi-override exception not yet implemented (would make them strong chars).
6703 // U+FFFC (OBJECT REPLACEMENT CHARACTER) is a neutral bidi character.
6704 // +spec:display-property:df11ef - atomic inlines treated as neutral bidi characters (U+FFFC)
6705 // Replaced elements with display:inline are also neutral unless unicode-bidi != normal.
6706 let text = match item {
6707 LogicalItem::Text { text, .. } => text.as_str(),
6708 LogicalItem::CombinedText { text, .. } => text.as_str(),
6709 _ => "\u{FFFC}",
6710 };
6711 let start_byte = bidi_str.len();
6712 logical_item_starts.push(start_byte);
6713 bidi_str.push_str(text);
6714 for _ in start_byte..bidi_str.len() {
6715 item_map.push(idx);
6716 }
6717 }
6718
6719 if bidi_str.is_empty() {
6720 if let Some(msgs) = debug_messages {
6721 msgs.push(LayoutDebugMessage::info(
6722 "Bidi string is empty, returning.".to_string(),
6723 ));
6724 }
6725 return Ok(Vec::new());
6726 }
6727 if let Some(msgs) = debug_messages {
6728 msgs.push(LayoutDebugMessage::info(format!(
6729 "Constructed bidi string: '{bidi_str}'"
6730 )));
6731 }
6732
6733 // +spec:display-property:1a6075 - paragraph embedding level set from direction property per UAX9 HL1
6734 // +spec:containing-block:0d4914 - unicode-bidi: plaintext exception
6735 // When the containing block has unicode-bidi: plaintext, use None so the
6736 // Unicode bidi algorithm applies P2/P3 heuristics instead of the HL1 override
6737 let bidi_level = if unicode_bidi == UnicodeBidi::Plaintext {
6738 None
6739 } else if base_direction == BidiDirection::Rtl {
6740 Some(Level::rtl())
6741 } else {
6742 Some(Level::ltr())
6743 };
6744 // +spec:writing-modes:15bf17 - bidi isolation handled by unicode_bidi UAX #9 implementation
6745 let bidi_info = BidiInfo::new(&bidi_str, bidi_level);
6746 let para = &bidi_info.paragraphs[0];
6747 let (levels, visual_runs) = bidi_info.visual_runs(para, para.range.clone());
6748
6749 if let Some(msgs) = debug_messages {
6750 msgs.push(LayoutDebugMessage::info(
6751 "Bidi visual runs generated:".to_string(),
6752 ));
6753 for (i, run_range) in visual_runs.iter().enumerate() {
6754 let level = levels[run_range.start].number();
6755 let slice = &bidi_str[run_range.start..run_range.end];
6756 msgs.push(LayoutDebugMessage::info(format!(
6757 " Run {i}: range={run_range:?}, level={level}, text='{slice}'"
6758 )));
6759 }
6760 }
6761
6762 // TODO(text3-review): RTL glyph-level visual reversal is NOT applied.
6763 // `visual_runs` orders the RUNS visually (left-to-right), but the loop below
6764 // emits each run's content in LOGICAL byte order, and shaping/positioning then
6765 // place clusters left-to-right in that logical order. For an RTL run this is
6766 // wrong: the first logical character must land at the LARGEST visual x. The
6767 // shaped clusters of each RTL run therefore need to be reversed (UBA rule L2,
6768 // applied per run at the glyph level AFTER shaping — a single logical Text item
6769 // shapes into multiple clusters, so it cannot be reversed here at the item
6770 // level). This must compose with the run-level ordering already done here
6771 // (naively re-running full L2 on top would double-reverse RTL-base paragraphs),
6772 // and `UnifiedLayout::get_selection_rects` must additionally split a selection
6773 // into one visual rect per directional segment. Deferred as a coherent
6774 // cross-cutting change; see failing tests text3_brutal_shaping::
6775 // {hebrew_run_is_rtl_reversed_and_33px_wide, bidi_mixed_run_is_80px_and_reverses_hebrew}
6776 // and text3_brutal_selection::bidi_selection_over_rtl_run_splits_into_multiple_rects.
6777 let mut visual_items = Vec::new();
6778 for run_range in visual_runs {
6779 let bidi_level = BidiLevel::new(levels[run_range.start].number());
6780 let mut sub_run_start = run_range.start;
6781
6782 for i in (run_range.start + 1)..run_range.end {
6783 if item_map[i] != item_map[sub_run_start] {
6784 let logical_idx = item_map[sub_run_start];
6785 let logical_item = &logical_items[logical_idx];
6786 let text_slice = &bidi_str[sub_run_start..i];
6787 visual_items.push(VisualItem {
6788 logical_source: logical_item.clone(),
6789 bidi_level,
6790 script: crate::text3::script::detect_script(text_slice)
6791 .unwrap_or(Script::Latin),
6792 text: text_slice.to_string(),
6793 run_byte_offset: sub_run_start - logical_item_starts[logical_idx],
6794 });
6795 sub_run_start = i;
6796 }
6797 }
6798
6799 let logical_idx = item_map[sub_run_start];
6800 let logical_item = &logical_items[logical_idx];
6801 let text_slice = &bidi_str[sub_run_start..run_range.end];
6802 visual_items.push(VisualItem {
6803 logical_source: logical_item.clone(),
6804 bidi_level,
6805 script: crate::text3::script::detect_script(text_slice).unwrap_or(Script::Latin),
6806 text: text_slice.to_string(),
6807 run_byte_offset: sub_run_start - logical_item_starts[logical_idx],
6808 });
6809 }
6810
6811 if let Some(msgs) = debug_messages {
6812 msgs.push(LayoutDebugMessage::info(
6813 "Final visual items produced:".to_string(),
6814 ));
6815 for (i, item) in visual_items.iter().enumerate() {
6816 msgs.push(LayoutDebugMessage::info(format!(
6817 " Item {}: level={}, text='{}'",
6818 i,
6819 item.bidi_level.level(),
6820 item.text
6821 )));
6822 }
6823 msgs.push(LayoutDebugMessage::info(
6824 "--- Exiting reorder_logical_items ---".to_string(),
6825 ));
6826 }
6827 Ok(visual_items)
6828}
6829
6830// --- Stage 3 Implementation ---
6831
6832/// Shape visual items into `ShapedItems` using pre-loaded fonts.
6833///
6834/// This function does NOT load any fonts - all fonts must be pre-loaded and passed in.
6835/// If a required font is not in `loaded_fonts`, the text will be skipped with a warning.
6836///
6837/// **Optimization: Inline Run Coalescing**
6838///
6839/// // +spec:display-property:9c6d59 - text shaping not broken across inline box boundaries when no effective formatting change
6840/// // +spec:display-property:cf8917 - text shaping not broken across inline box boundaries
6841/// When consecutive text `VisualItem`s share the same layout-affecting properties
6842/// (font, size, spacing, etc.) but differ only in rendering properties (color,
6843/// background), they are coalesced into a single shaping call. This dramatically
6844/// reduces the number of `font.shape_text()` invocations for syntax-highlighted
6845/// code where hundreds of `<span>` elements use the same monospace font but
6846/// different colors. After shaping, the original per-span styles are restored
6847/// to each `ShapedCluster` based on byte-range mapping.
6848/// Shape visual items with per-item caching. For each item (or coalesced group),
6849/// compute a cache key from (text, `bidi_level`, script, `style_layout_hash`). On cache
6850/// hit, reuse the previously shaped clusters. On miss, shape and store.
6851///
6852/// This is the incremental shaping path: when one word changes in a paragraph,
6853/// only that word's item misses the per-item cache; all other items hit.
6854#[allow(clippy::implicit_hasher)] // internal helper; only ever called with the default-hasher HashMap/HashSet
6855/// # Errors
6856///
6857/// Returns a `LayoutError` if shaping the visual items fails.
6858pub fn shape_visual_items_with_per_item_cache<T: ParsedFontTrait>(
6859 visual_items: &[VisualItem],
6860 per_item_cache: &mut HashMap<u64, Arc<PerItemShapedEntry>>,
6861 per_item_accessed: &mut HashSet<u64>,
6862 font_chain_cache: &HashMap<FontChainKey, rust_fontconfig::FontFallbackChain>,
6863 fc_cache: &FcFontCache,
6864 loaded_fonts: &LoadedFonts<T>,
6865 debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
6866) -> Result<Vec<ShapedItem>, LayoutError> {
6867 use std::hash::{Hash, Hasher};
6868 // Delegate to the existing shaping logic, but for each coalesce group,
6869 // check the per-item cache first.
6870 //
6871 // Strategy: Identify coalesce groups (adjacent items with same layout_hash,
6872 // bidi_level, script). For each group, compute a key from the concatenated
6873 // text + shared properties. Check cache. On miss, shape the group and cache it.
6874 let mut shaped = Vec::new();
6875 let mut idx = 0;
6876
6877 while idx < visual_items.len() {
6878 let item = &visual_items[idx];
6879
6880 // Determine coalesce group boundaries (same logic as shape_visual_items)
6881 let (layout_hash, bidi_level, script) = match &item.logical_source {
6882 LogicalItem::Text { style, .. } | LogicalItem::CombinedText { style, .. } => {
6883 (style.layout_hash(), item.bidi_level, item.script)
6884 }
6885 _ => {
6886 // Non-text items: shape individually (no coalescing)
6887 let single = shape_visual_items(
6888 &visual_items[idx..=idx],
6889 font_chain_cache, fc_cache, loaded_fonts, debug_messages,
6890 )?;
6891 shaped.extend(single);
6892 idx += 1;
6893 continue;
6894 }
6895 };
6896
6897 let mut coalesce_end = idx + 1;
6898 while coalesce_end < visual_items.len() {
6899 let next = &visual_items[coalesce_end];
6900 let next_layout_hash = match &next.logical_source {
6901 LogicalItem::Text { style, .. } | LogicalItem::CombinedText { style, .. } => {
6902 Some(style.layout_hash())
6903 }
6904 _ => None,
6905 };
6906 if let Some(nlh) = next_layout_hash {
6907 if nlh == layout_hash
6908 && next.bidi_level == bidi_level
6909 && next.script == script
6910 {
6911 coalesce_end += 1;
6912 } else {
6913 break;
6914 }
6915 } else {
6916 break;
6917 }
6918 }
6919
6920 // Compute per-group cache key
6921 let mut hasher = DefaultHasher::new();
6922 for item in &visual_items[idx..coalesce_end] {
6923 item.text.hash(&mut hasher);
6924 }
6925 layout_hash.hash(&mut hasher);
6926 bidi_level.hash(&mut hasher);
6927 (script as u32).hash(&mut hasher);
6928 let group_key = hasher.finish();
6929
6930 // Check per-item cache
6931 per_item_accessed.insert(group_key);
6932 if let Some(cached) = per_item_cache.get(&group_key) {
6933 shaped.extend(cached.clusters.iter().cloned());
6934 } else {
6935 // Cache miss — shape this group
6936 let group_items = shape_visual_items(
6937 &visual_items[idx..coalesce_end],
6938 font_chain_cache, fc_cache, loaded_fonts, debug_messages,
6939 )?;
6940 let total_advance: f32 = group_items.iter().map(|item| {
6941 match item {
6942 ShapedItem::Cluster(c) => c.advance,
6943 _ => 0.0,
6944 }
6945 }).sum();
6946 per_item_cache.insert(group_key, Arc::new(PerItemShapedEntry {
6947 clusters: group_items.clone(),
6948 total_advance,
6949 }));
6950 shaped.extend(group_items);
6951 }
6952
6953 idx = coalesce_end;
6954 }
6955
6956 Ok(shaped)
6957}
6958
6959/// Split text into segments where consecutive characters resolve to the same font
6960/// in the fallback chain. Returns Vec<(`byte_start`, `byte_end`, `FontId`)>.
6961///
6962/// Characters that can't be resolved to any font are skipped (gap in coverage).
6963fn split_text_by_font_coverage<T: ParsedFontTrait>(
6964 text: &str,
6965 font_chain: &rust_fontconfig::FontFallbackChain,
6966 fc_cache: &FcFontCache,
6967 loaded_fonts: &LoadedFonts<T>,
6968) -> Vec<(usize, usize, FontId)> {
6969 let mut segments: Vec<(usize, usize, FontId)> = Vec::new();
6970
6971 // Deterministic "last resort" face for characters no font covers: the lowest
6972 // FontId among the loaded fonts. Used so an uncovered codepoint still emits a
6973 // .notdef (tofu) segment instead of being silently dropped (zero glyphs/advance).
6974 let notdef_font_id = loaded_fonts.iter().map(|(id, _)| *id).min();
6975
6976 for (byte_idx, ch) in text.char_indices() {
6977 let char_end = byte_idx + ch.len_utf8();
6978 // Primary: the resolved fallback chain. Its coverage comes from
6979 // rust-fontconfig's OS/2-derived `unicode_ranges`, which can MISS
6980 // codepoints a font actually has in its cmap — e.g. Noto Sans CJK's
6981 // JP face does not advertise the Hangul OS/2 block, so 한국어 resolves
6982 // to None here even though that face's cmap covers it.
6983 let font_id = font_chain
6984 .resolve_char(fc_cache, ch)
6985 .map(|(id, _)| id)
6986 // Fallback: probe the actually-loaded fonts by REAL glyph coverage
6987 // so OS/2-vs-cmap gaps render instead of being silently dropped.
6988 // The covering CJK face is already loaded (Han/Kana resolved to it),
6989 // so this reuses it for Hangul rather than mixing in another font.
6990 // Iterate in a STABLE order (lowest FontId first) so the chosen face is
6991 // deterministic across processes — a raw HashMap `.find` is seeded per
6992 // process and would pick different faces run-to-run.
6993 .or_else(|| {
6994 loaded_fonts
6995 .iter()
6996 .filter(|(_, font)| font.has_glyph(ch as u32))
6997 .map(|(id, _)| *id)
6998 .min()
6999 })
7000 // Last resort: no font advertises OR covers this codepoint. Assign it to
7001 // the primary loaded face so the shaper emits a visible .notdef box and
7002 // the byte range is preserved (following text is not shifted).
7003 .or(notdef_font_id);
7004 if let Some(font_id) = font_id {
7005 match segments.last_mut() {
7006 Some(last) if last.2 == font_id && last.1 == byte_idx => {
7007 // Extend current segment (same font, contiguous)
7008 last.1 = char_end;
7009 }
7010 _ => {
7011 // New segment (different font or gap)
7012 segments.push((byte_idx, char_end, font_id));
7013 }
7014 }
7015 }
7016 }
7017
7018 segments
7019}
7020
7021/// Measures the total inline advance (width in horizontal mode) of `text` shaped at
7022/// `style`, using the same font-resolution path as the main shaper. Returns `None` if the
7023/// font chain is not resolved / shaping fails, so callers can fall back to an estimate.
7024///
7025/// Used by ruby layout to size the base and annotation runs from REAL shaped advances
7026/// (instead of a `chars * font_size * magic_ratio` fudge).
7027fn measure_run_advance<T: ParsedFontTrait>(
7028 text: &str,
7029 style: &Arc<StyleProperties>,
7030 script: Script,
7031 source: ContentIndex,
7032 font_chain_cache: &HashMap<FontChainKey, rust_fontconfig::FontFallbackChain>,
7033 fc_cache: &FcFontCache,
7034 loaded_fonts: &LoadedFonts<T>,
7035) -> Option<f32> {
7036 if text.is_empty() {
7037 return Some(0.0);
7038 }
7039 let language = script_to_language(script, text);
7040 match &style.font_stack {
7041 FontStack::Ref(font_ref) => {
7042 let glyphs = font_ref
7043 .shape_text(text, script, language, BidiDirection::Ltr, style.as_ref())
7044 .ok()?;
7045 Some(glyphs.iter().map(|g| g.advance + g.kerning).sum())
7046 }
7047 FontStack::Stack(selectors) => {
7048 let cache_key = FontChainKey::from_selectors(selectors);
7049 let font_chain = font_chain_cache.get(&cache_key)?;
7050 let clusters = shape_with_font_fallback(
7051 text, script, language, BidiDirection::Ltr, style, source, None, font_chain,
7052 fc_cache, loaded_fonts,
7053 )
7054 .ok()?;
7055 Some(clusters.iter().map(|c| c.advance).sum())
7056 }
7057 }
7058}
7059
7060/// Shape text with per-character font fallback.
7061///
7062/// Splits the text into segments by font coverage, shapes each segment with
7063/// its resolved font, and fixes byte offsets so they're relative to the
7064/// original `text` (not the segment substring).
7065#[allow(clippy::cast_possible_truncation)] // bounded pixel/coord/colour/glyph cast
7066fn shape_with_font_fallback<T: ParsedFontTrait>(
7067 text: &str,
7068 script: Script,
7069 language: Language,
7070 direction: BidiDirection,
7071 style: &Arc<StyleProperties>,
7072 source_index: ContentIndex,
7073 source_node_id: Option<NodeId>,
7074 font_chain: &rust_fontconfig::FontFallbackChain,
7075 fc_cache: &FcFontCache,
7076 loaded_fonts: &LoadedFonts<T>,
7077) -> Result<Vec<ShapedCluster>, LayoutError> {
7078 // Cache the debug flag in a `OnceLock<bool>` — reading it per-shape
7079 // (this function fires once per text segment, ~hundreds of times
7080 // per render of a real DOM) costs ~100 ns per `std::env::var_os`
7081 // call on macOS (env-lock + hashmap lookup), and even before the
7082 // lookup finishes the `eprintln!` machinery takes a stderr lock
7083 // and allocates the formatted string. Both are invisible in
7084 // release unless `AZ_FONT_FALLBACK_DEBUG=1` is set.
7085 static FONT_FB_DEBUG: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
7086 let dbg = *FONT_FB_DEBUG.get_or_init(|| {
7087 std::env::var_os("AZ_FONT_FALLBACK_DEBUG").is_some()
7088 });
7089
7090 let segments = split_text_by_font_coverage(text, font_chain, fc_cache, loaded_fonts);
7091
7092 if dbg && segments.len() > 1 {
7093 eprintln!(
7094 "[FONT FALLBACK] text needs {} font segments for '{}' ({}..{} bytes)",
7095 segments.len(),
7096 text.chars().take(40).collect::<String>(),
7097 0, text.len()
7098 );
7099 }
7100
7101 unsafe { crate::az_mark(0x60850_u32, segments.len() as u32); } // [g123] segments count (split_text_by_font_coverage)
7102 if segments.len() <= 1 {
7103 // Fast path: all characters use the same font (common case)
7104 let (seg_start, seg_end, font_id) = if let Some(s) = segments.first() { unsafe { crate::az_mark(0x60854_u32, 0x0000_0001_u32); } s } else {
7105 unsafe { crate::az_mark(0x60854_u32, 0x0000_00EE_u32); } // [g123] split→0 segments (resolve_char failed all)
7106 if dbg {
7107 eprintln!("[FONT FALLBACK] no font could render any char in '{}'", text.chars().take(20).collect::<String>());
7108 }
7109 return Ok(Vec::new());
7110 };
7111 let font = if let Some(f) = loaded_fonts.get(font_id) { unsafe { crate::az_mark(0x60858_u32, 0x0000_0001_u32); } f } else {
7112 unsafe { crate::az_mark(0x60858_u32, 0x0000_00EE_u32); } // [g123] loaded_fonts.get MISS
7113 if dbg {
7114 eprintln!("[FONT FALLBACK] font {:?} not in loaded_fonts for '{}'", font_id, text.chars().take(20).collect::<String>());
7115 }
7116 return Ok(Vec::new());
7117 };
7118 // If segment covers the full text (overwhelmingly common), skip substr+fixup
7119 if *seg_start == 0 && *seg_end == text.len() {
7120 unsafe { crate::az_mark(0x60860_u32, 0xC0DE_0860_u32); } // [g123] reached shape_text_correctly (full-text)
7121 return shape_text_correctly(
7122 text, script, language, direction,
7123 font, style, source_index, source_node_id,
7124 );
7125 }
7126 let mut clusters = shape_text_correctly(
7127 &text[*seg_start..*seg_end], script, language, direction,
7128 font, style, source_index, source_node_id,
7129 )?;
7130 if *seg_start > 0 {
7131 for cluster in &mut clusters {
7132 cluster.source_cluster_id.start_byte_in_run += *seg_start as u32;
7133 }
7134 }
7135 return Ok(clusters);
7136 }
7137
7138 // Multiple fonts needed — shape each segment separately
7139 let mut all_clusters = Vec::new();
7140 for (seg_start, seg_end, font_id) in &segments {
7141 let Some(font) = loaded_fonts.get(font_id) else {
7142 if dbg {
7143 eprintln!("[FONT FALLBACK] font {font_id:?} NOT loaded, skipping segment bytes {seg_start}..{seg_end}");
7144 }
7145 continue;
7146 };
7147 let segment_text = &text[*seg_start..*seg_end];
7148 if dbg {
7149 eprintln!(
7150 "[FONT FALLBACK] text='{segment_text}' uses font {font_id:?} (bytes {seg_start}..{seg_end})"
7151 );
7152 }
7153 let mut seg_clusters = shape_text_correctly(
7154 segment_text, script, language, direction,
7155 font, style, source_index, source_node_id,
7156 )?;
7157 // Fix byte offsets: shape_text_correctly produces offsets relative to
7158 // segment_text, but callers expect offsets relative to the full text.
7159 if *seg_start > 0 {
7160 for cluster in &mut seg_clusters {
7161 cluster.source_cluster_id.start_byte_in_run += *seg_start as u32;
7162 }
7163 }
7164 all_clusters.extend(seg_clusters);
7165 }
7166 Ok(all_clusters)
7167}
7168
7169#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)] // bounded pixel/coord/colour/glyph cast
7170#[allow(clippy::implicit_hasher)] // internal helper; only ever called with the default-hasher HashMap/HashSet
7171#[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
7172#[allow(clippy::too_many_lines, clippy::cognitive_complexity)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
7173/// # Errors
7174///
7175/// Returns a `LayoutError` if shaping the visual items fails.
7176pub fn shape_visual_items<T: ParsedFontTrait>(
7177 visual_items: &[VisualItem],
7178 font_chain_cache: &HashMap<FontChainKey, rust_fontconfig::FontFallbackChain>,
7179 fc_cache: &FcFontCache,
7180 loaded_fonts: &LoadedFonts<T>,
7181 debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
7182) -> Result<Vec<ShapedItem>, LayoutError> {
7183 let mut shaped = Vec::new();
7184 let mut idx = 0;
7185 let mut _coalesced_runs = 0usize;
7186 let mut _total_runs = 0usize;
7187 let mut _shape_calls = 0usize;
7188
7189 // Log count of visual items for debugging coalescing
7190
7191 while idx < visual_items.len() {
7192 let item = &visual_items[idx];
7193 match &item.logical_source {
7194 LogicalItem::Text {
7195 style,
7196 source,
7197 marker_position_outside,
7198 source_node_id,
7199 ..
7200 } => {
7201 let layout_hash = style.layout_hash();
7202 let bidi_level = item.bidi_level;
7203 let script = item.script;
7204
7205 // +spec:display-property:ca95f6 - text shaping breaks at inline box boundaries when layout-affecting properties differ
7206 // when layout-affecting properties (font weight, family, size, etc.) change
7207 // across element boundaries, preventing ligatures from forming across such changes.
7208 // Look ahead: find consecutive text items with the same layout-affecting
7209 // properties (font, size, spacing) that can be shaped as one merged run.
7210 let mut coalesce_end = idx + 1;
7211 while coalesce_end < visual_items.len() {
7212 let next = &visual_items[coalesce_end];
7213 if let LogicalItem::Text { style: next_style, .. } = &next.logical_source {
7214 if next_style.layout_hash() == layout_hash
7215 && next.bidi_level == bidi_level
7216 && next.script == script
7217 {
7218 coalesce_end += 1;
7219 } else {
7220 break;
7221 }
7222 } else {
7223 break;
7224 }
7225 }
7226
7227 let coalesce_count = coalesce_end - idx;
7228
7229 if coalesce_count > 1 {
7230 _coalesced_runs += coalesce_count;
7231 _shape_calls += 1;
7232 // ── COALESCED PATH ──
7233 // Merge N text items into one shaping call, then split results
7234 // back per original run to preserve per-span rendering styles.
7235
7236 // Build merged text and record byte ranges → original style
7237 let total_text_len: usize = visual_items[idx..coalesce_end]
7238 .iter()
7239 .map(|v| v.text.len())
7240 .sum();
7241 let mut merged_text = String::with_capacity(total_text_len);
7242 // (byte_start, byte_end, style, source, source_node_id, marker_outside, run_byte_offset)
7243 let mut byte_ranges: Vec<(
7244 usize, usize,
7245 Arc<StyleProperties>,
7246 ContentIndex,
7247 Option<NodeId>,
7248 Option<bool>,
7249 usize,
7250 )> = Vec::with_capacity(coalesce_count);
7251
7252 for item in &visual_items[idx..coalesce_end] {
7253 let start = merged_text.len();
7254 merged_text.push_str(&item.text);
7255 let end = merged_text.len();
7256 if let LogicalItem::Text {
7257 style: s, source: src, source_node_id: nid,
7258 marker_position_outside: mpo, ..
7259 } = &item.logical_source {
7260 byte_ranges.push((start, end, s.clone(), *src, *nid, *mpo, item.run_byte_offset));
7261 }
7262 }
7263
7264 if let Some(msgs) = debug_messages {
7265 msgs.push(LayoutDebugMessage::info(format!(
7266 "[TextLayout] Coalescing {} text runs ({} bytes) into single shaping call",
7267 coalesce_count, merged_text.len()
7268 )));
7269 }
7270
7271 let direction = if bidi_level.is_rtl() {
7272 BidiDirection::Rtl
7273 } else {
7274 BidiDirection::Ltr
7275 };
7276 let language = script_to_language(script, &merged_text);
7277
7278 // Shape the merged text using the first item's font (layout is identical
7279 // for all coalesced items since layout_hash matches).
7280 let shaped_clusters_result: Result<Vec<ShapedCluster>, LayoutError> = match &style.font_stack {
7281 FontStack::Ref(font_ref) => {
7282 shape_text_correctly(
7283 &merged_text, script, language, direction,
7284 font_ref, style, *source, *source_node_id,
7285 )
7286 }
7287 FontStack::Stack(selectors) => {
7288 let cache_key = FontChainKey::from_selectors(selectors);
7289 let Some(font_chain) = font_chain_cache.get(&cache_key) else { idx = coalesce_end; continue; };
7290 // Per-character font fallback: split text by font coverage
7291 shape_with_font_fallback(
7292 &merged_text, script, language, direction,
7293 style, *source, *source_node_id,
7294 font_chain, fc_cache, loaded_fonts,
7295 )
7296 }
7297 };
7298
7299 let shaped_clusters = shaped_clusters_result?;
7300
7301 // Restore original per-span styles to each cluster based on byte position.
7302 // Each ShapedCluster's source_cluster_id.start_byte_in_run is the byte
7303 // offset within the merged text — we use byte_ranges to find which
7304 // original run it belongs to and reassign its style, source info, etc.
7305 for cluster in shaped_clusters {
7306 let byte_pos = cluster.source_cluster_id.start_byte_in_run as usize;
7307 // Find the original run this cluster's first byte falls into
7308 let orig = byte_ranges.iter().find(|(start, end, ..)| {
7309 byte_pos >= *start && byte_pos < *end
7310 });
7311 let mut cluster = cluster;
7312 if let Some((range_start, _, orig_style, orig_source, orig_nid, orig_mpo, orig_run_offset)) = orig {
7313 // Reassign rendering-affecting style (color, background, etc.)
7314 cluster.style = orig_style.clone();
7315 cluster.source_content_index = *orig_source;
7316 cluster.source_node_id = *orig_nid;
7317 // Fix the byte offset to be relative to the original logical run:
7318 // (position within the merged text - this run's start in the merge)
7319 // + this visual run's offset within its logical run (bidi split).
7320 cluster.source_cluster_id.source_run = orig_source.run_index;
7321 cluster.source_cluster_id.start_byte_in_run = (byte_pos - range_start + *orig_run_offset) as u32;
7322 // Update glyph styles
7323 for glyph in &mut cluster.glyphs {
7324 glyph.style = orig_style.clone();
7325 }
7326 if let Some(is_outside) = orig_mpo {
7327 cluster.marker_position_outside = Some(*is_outside);
7328 }
7329 }
7330 shaped.push(ShapedItem::Cluster(cluster));
7331 }
7332
7333 idx = coalesce_end;
7334 continue;
7335 }
7336
7337 // ── SINGLE ITEM PATH (no coalescing) ──
7338 _total_runs += 1;
7339 _shape_calls += 1;
7340 let direction = if item.bidi_level.is_rtl() {
7341 BidiDirection::Rtl
7342 } else {
7343 BidiDirection::Ltr
7344 };
7345
7346 let language = script_to_language(item.script, &item.text);
7347
7348 // Shape text using either FontRef directly or fontconfig-resolved font
7349 let shaped_clusters_result: Result<Vec<ShapedCluster>, LayoutError> = match &style.font_stack {
7350 FontStack::Ref(font_ref) => {
7351 unsafe { crate::az_mark(0x60820_u32, 0x0000_0001_u32); } // [g121] Ref arm
7352 // For FontRef, use the font directly without fontconfig
7353 if let Some(msgs) = debug_messages {
7354 msgs.push(LayoutDebugMessage::info(format!(
7355 "[TextLayout] Using direct FontRef for text: '{}'",
7356 item.text.chars().take(30).collect::<String>()
7357 )));
7358 }
7359 shape_text_correctly(
7360 &item.text,
7361 item.script,
7362 language,
7363 direction,
7364 font_ref,
7365 style,
7366 *source,
7367 *source_node_id,
7368 )
7369 }
7370 FontStack::Stack(selectors) => {
7371 unsafe { crate::az_mark(0x60820_u32, 0x0000_0002_u32); } // [g121] Stack arm
7372 // Build FontChainKey and resolve through fontconfig
7373 let cache_key = FontChainKey::from_selectors(selectors);
7374 unsafe { crate::az_mark(0x60824_u32, font_chain_cache.len() as u32); } // [g121] chain map len
7375
7376 // Look up the pre-resolved font chain. (2026-06-10: the g122
7377 // by_find/by_only fallback chain is GONE — the historic miss was a
7378 // KEY-CONSTRUCTION divergence (duplicated families on the query side,
7379 // deduped on the store side), fixed by routing every key build through
7380 // FontChainKey::from_selectors. Verified lifted: lookup path = get.)
7381 let Some(font_chain) = font_chain_cache.get(&cache_key) else {
7382 if let Some(msgs) = debug_messages {
7383 msgs.push(LayoutDebugMessage::warning(format!(
7384 "[TextLayout] Font chain not pre-resolved for {:?} - text will \
7385 not be rendered",
7386 cache_key.font_families
7387 )));
7388 }
7389 idx += 1;
7390 continue;
7391 };
7392
7393 // Per-character font fallback: split text by font coverage
7394 shape_with_font_fallback(
7395 &item.text, item.script, language, direction,
7396 style, *source, *source_node_id,
7397 font_chain, fc_cache, loaded_fonts,
7398 )
7399 }
7400 };
7401
7402 let mut shaped_clusters = shaped_clusters_result?;
7403
7404 // Re-base cluster byte offsets to the logical run. Shaping produced
7405 // `start_byte_in_run` relative to this visual run's `text`; when bidi
7406 // split the logical run into several visual runs, add the visual run's
7407 // offset so every cluster ID is unique + matches caret byte positions.
7408 let run_byte_offset = item.run_byte_offset as u32;
7409 if run_byte_offset != 0 {
7410 for cluster in &mut shaped_clusters {
7411 cluster.source_cluster_id.start_byte_in_run = cluster
7412 .source_cluster_id
7413 .start_byte_in_run
7414 .saturating_add(run_byte_offset);
7415 }
7416 }
7417
7418 // Set marker flag on all clusters if this is a marker
7419 if let Some(is_outside) = marker_position_outside {
7420 for cluster in &mut shaped_clusters {
7421 cluster.marker_position_outside = Some(*is_outside);
7422 }
7423 }
7424
7425 shaped.extend(shaped_clusters.into_iter().map(ShapedItem::Cluster));
7426 }
7427 // +spec:display-property:df076b - tab-size rendering and inline-level line breaking
7428 // "If the tab size is zero, preserved tabs are not rendered."
7429 // "Otherwise, each preserved tab is rendered as a horizontal shift that lines up
7430 // the start edge of the next glyph with the next tab stop."
7431 // "Tab stops occur at points that are multiples of the tab size from the starting
7432 // content edge of the preserved tab's nearest block container ancestor."
7433 LogicalItem::Tab { source, style } => {
7434 if style.tab_size == 0.0 {
7435 // Tab size zero: tab is not rendered (zero width)
7436 shaped.push(ShapedItem::Tab {
7437 source: *source,
7438 bounds: Rect {
7439 x: 0.0,
7440 y: 0.0,
7441 width: 0.0,
7442 height: 0.0,
7443 },
7444 });
7445 } else {
7446 // TODO: use actual font's space_width via ParsedFontTrait::get_space_width()
7447 // once we thread font resolution into the shaping phase for tab stops.
7448 // For now, approximate space advance as 0.5 * font_size (typical for Latin fonts).
7449 let space_advance_approx = style.font_size_px * SPACE_WIDTH_RATIO;
7450 // +spec:text-alignment-spacing:5a5efd - tab-size includes letter-spacing and word-spacing
7451 let ls = style.letter_spacing.resolve_px(style.font_size_px);
7452 let ws = style.word_spacing.resolve_px(style.font_size_px);
7453 // Tab stop interval: tab_size * (space advance + letter-spacing + word-spacing)
7454 let tab_interval = style.tab_size * (space_advance_approx + ls + ws);
7455 // Calculate current advance to find next tab stop
7456 let current_advance: f32 = shaped.iter().map(|item| {
7457 match item {
7458 ShapedItem::Cluster(c) => c.advance,
7459 ShapedItem::Tab { bounds, .. } => bounds.width,
7460 ShapedItem::Object { bounds, .. } => bounds.width,
7461 _ => 0.0,
7462 }
7463 }).sum();
7464 // Next tab stop = next multiple of tab_interval from content edge
7465 let next_tab_stop = ((current_advance / tab_interval).floor() + 1.0) * tab_interval;
7466 let mut tab_width = next_tab_stop - current_advance;
7467 // "If this distance is less than 0.5ch, then the subsequent tab stop is used instead."
7468 let half_ch = space_advance_approx * 0.5;
7469 if tab_width < half_ch {
7470 tab_width += tab_interval;
7471 }
7472 shaped.push(ShapedItem::Tab {
7473 source: *source,
7474 bounds: Rect {
7475 x: 0.0,
7476 y: 0.0,
7477 width: tab_width,
7478 height: 0.0,
7479 },
7480 });
7481 }
7482 }
7483 LogicalItem::Ruby {
7484 source,
7485 base_text,
7486 ruby_text,
7487 style,
7488 } => {
7489 // CSS Ruby Layout (§3): the annotation (ruby-text) is laid out at its used
7490 // `font-size` — the UA default is `RUBY_ANNOTATION_FONT_SCALE` of the base —
7491 // and centered over the base, with the ruby box reserving the WIDER of the
7492 // two inline-sizes and stacking the annotation line above the base line.
7493 //
7494 // Both the base and the annotation are shaped to obtain their REAL inline
7495 // advances (no `chars * font_size * 0.6` fudge). The annotation is shaped at
7496 // the scaled style so its width reflects the smaller glyphs.
7497 let base_font_size = style.font_size_px;
7498 let annotation_font_size = base_font_size * RUBY_ANNOTATION_FONT_SCALE;
7499
7500 let mut annotation_props = (**style).clone();
7501 annotation_props.font_size_px = annotation_font_size;
7502 let annotation_style = Arc::new(annotation_props);
7503
7504 // Fallback estimate (only when shaping fails / no font chain): 1em per char
7505 // is a closer CJK approximation than the old 0.6 ratio.
7506 let base_width = measure_run_advance(
7507 base_text, style, item.script, *source, font_chain_cache, fc_cache,
7508 loaded_fonts,
7509 )
7510 .unwrap_or_else(|| base_text.chars().count() as f32 * base_font_size);
7511 let annotation_width = measure_run_advance(
7512 ruby_text, &annotation_style, item.script, *source, font_chain_cache,
7513 fc_cache, loaded_fonts,
7514 )
7515 .unwrap_or_else(|| ruby_text.chars().count() as f32 * annotation_font_size);
7516
7517 let base_line_height =
7518 style.line_height.resolve(base_font_size, 0.0, 0.0, 0.0, 0);
7519 let annotation_line_height = annotation_style.line_height.resolve(
7520 annotation_font_size, 0.0, 0.0, 0.0, 0,
7521 );
7522 // The ruby box reserves the wider inline-size, and stacks the annotation
7523 // line (at its smaller font-size) above the base line.
7524 let (reserved_width, reserved_height) = ruby_reserved_box(
7525 base_width,
7526 annotation_width,
7527 base_line_height,
7528 annotation_line_height,
7529 );
7530
7531 // TODO2: the annotation glyphs are now correctly sized + reserve vertical
7532 // space above the base, but are not yet emitted as a separately positioned
7533 // (centered) run — `ShapedItem::Object` carries only the base `StyledRun`.
7534 // Rendering the centered annotation needs a ruby-aware `ShapedItem` variant
7535 // (rendering-structural change); deferred to keep this change layout-safe.
7536 shaped.push(ShapedItem::Object {
7537 source: *source,
7538 bounds: Rect {
7539 x: 0.0,
7540 y: 0.0,
7541 width: reserved_width,
7542 height: reserved_height,
7543 },
7544 baseline_offset: 0.0,
7545 content: InlineContent::Text(StyledRun {
7546 text: base_text.clone(),
7547 style: style.clone(),
7548 logical_start_byte: 0,
7549 source_node_id: None,
7550 }),
7551 });
7552 }
7553 LogicalItem::CombinedText {
7554 style,
7555 source,
7556 text,
7557 } => {
7558 let language = script_to_language(item.script, &item.text);
7559
7560 // +spec:width-calculation:657f75 - convert full-width chars to non-full-width before compression
7561 // +spec:width-calculation:d0a295 - full-width digit conversion example (e.g. "23" stays narrow)
7562 // When combined text has more than one typographic character unit,
7563 // full-width characters (U+FF01..U+FF5E) are converted to their
7564 // ASCII equivalents (U+0021..U+007E) before compression.
7565 let text = if text.chars().count() > 1 {
7566 let converted: String = text.chars().map(|c| {
7567 let cp = c as u32;
7568 if (0xFF01..=0xFF5E).contains(&cp) {
7569 // Reverse of text-transform: full-width
7570 char::from_u32(cp - 0xFF01 + 0x0021).unwrap_or(c)
7571 } else {
7572 c
7573 }
7574 }).collect();
7575 converted
7576 } else {
7577 text.clone()
7578 };
7579
7580 // +spec:width-calculation:1ed84d - OpenType compression (half-width/third-width substitution)
7581 // is delegated to the font shaping layer via shape_text()
7582
7583 // Shape CombinedText using either FontRef directly or fontconfig-resolved font
7584 let glyphs: Vec<Glyph> = match &style.font_stack {
7585 FontStack::Ref(font_ref) => {
7586 // For FontRef, use the font directly without fontconfig
7587 if let Some(msgs) = debug_messages {
7588 msgs.push(LayoutDebugMessage::info(format!(
7589 "[TextLayout] Using direct FontRef for CombinedText: '{}'",
7590 text.chars().take(30).collect::<String>()
7591 )));
7592 }
7593 font_ref.shape_text(
7594 &text,
7595 item.script,
7596 language,
7597 BidiDirection::Ltr,
7598 style.as_ref(),
7599 )?
7600 }
7601 FontStack::Stack(selectors) => {
7602 // Build FontChainKey and resolve through fontconfig
7603 let cache_key = FontChainKey::from_selectors(selectors);
7604
7605 let Some(font_chain) = font_chain_cache.get(&cache_key) else {
7606 if let Some(msgs) = debug_messages {
7607 msgs.push(LayoutDebugMessage::warning(format!(
7608 "[TextLayout] Font chain not pre-resolved for CombinedText {:?}",
7609 cache_key.font_families
7610 )));
7611 }
7612 idx += 1;
7613 continue;
7614 };
7615
7616 // Per-character font fallback for CombinedText
7617 let segments = split_text_by_font_coverage(&text, font_chain, fc_cache, loaded_fonts);
7618 let mut all_glyphs = Vec::new();
7619 for (seg_start, seg_end, font_id) in &segments {
7620 let Some(font) = loaded_fonts.get(font_id) else { continue; };
7621 let segment_text = &text[*seg_start..*seg_end];
7622 let mut seg_glyphs = font.shape_text(
7623 segment_text,
7624 item.script,
7625 language,
7626 BidiDirection::Ltr,
7627 style.as_ref(),
7628 )?;
7629 // Fix byte offsets for glyphs
7630 if *seg_start > 0 {
7631 for g in &mut seg_glyphs {
7632 g.logical_byte_index += *seg_start;
7633 g.cluster += *seg_start as u32;
7634 }
7635 }
7636 all_glyphs.extend(seg_glyphs);
7637 }
7638 if all_glyphs.is_empty() {
7639 idx += 1;
7640 continue;
7641 }
7642 all_glyphs
7643 }
7644 };
7645
7646 let shaped_glyphs: ShapedGlyphVec = glyphs
7647 .into_iter()
7648 .map(|g| ShapedGlyph {
7649 kind: GlyphKind::Character,
7650 glyph_id: g.glyph_id,
7651 script: g.script,
7652 font_hash: g.font_hash,
7653 font_metrics: g.font_metrics,
7654 style: g.style,
7655 cluster_offset: 0,
7656 advance: g.advance,
7657 kerning: g.kerning,
7658 offset: g.offset,
7659 vertical_advance: g.vertical_advance,
7660 vertical_offset: g.vertical_bearing,
7661 })
7662 .collect();
7663
7664 // +spec:block-formatting-context:dc4549 - text-combine-upright compression: UA may scale composition to match 水 advance height
7665 let total_width: f32 = shaped_glyphs.iter().map(|g| g.advance + g.kerning).sum();
7666 // +spec:inline-formatting-context:8c5969 - text-combine-upright baseline centering
7667 // The composition forms a 1em square. Per spec, its baseline must be
7668 // chosen so the square is centered between the text-over and text-under
7669 // baselines of the parent inline box. We approximate by using font_size
7670 // as the square height and centering it (baseline_offset = em_size / 2).
7671 let em_size = shaped_glyphs.first()
7672 .map_or(style.font_size_px, |g| g.style.font_size_px);
7673 let bounds = Rect {
7674 x: 0.0,
7675 y: 0.0,
7676 width: total_width,
7677 height: em_size,
7678 };
7679
7680 shaped.push(ShapedItem::CombinedBlock {
7681 source: *source,
7682 glyphs: shaped_glyphs,
7683 bounds,
7684 baseline_offset: em_size / 2.0,
7685 });
7686 }
7687 LogicalItem::Object {
7688 content, source, ..
7689 } => {
7690 let (bounds, baseline) = measure_inline_object(content)?;
7691 shaped.push(ShapedItem::Object {
7692 source: *source,
7693 bounds,
7694 baseline_offset: baseline,
7695 content: content.clone(),
7696 });
7697 }
7698 LogicalItem::Break { source, break_info } => {
7699 shaped.push(ShapedItem::Break {
7700 source: *source,
7701 break_info: *break_info,
7702 });
7703 }
7704 }
7705 idx += 1;
7706 }
7707
7708 Ok(shaped)
7709}
7710
7711/// Returns true if `c` is a hanging punctuation stop or comma per CSS Text 3 §8.2.1.
7712// +spec:hanging-punctuation - full stop/comma character list per CSS Text 3 §8.2.1
7713const fn is_hanging_punctuation_char(c: char) -> bool {
7714 matches!(c,
7715 ',' | // U+002C COMMA
7716 '.' | // U+002E FULL STOP
7717 '\u{060C}' | // ARABIC COMMA
7718 '\u{06D4}' | // ARABIC FULL STOP
7719 '\u{3001}' | // IDEOGRAPHIC COMMA
7720 '\u{3002}' | // IDEOGRAPHIC FULL STOP
7721 '\u{FF0C}' | // FULLWIDTH COMMA
7722 '\u{FF0E}' | // FULLWIDTH FULL STOP
7723 '\u{FE50}' | // SMALL COMMA
7724 '\u{FE51}' | // SMALL IDEOGRAPHIC COMMA
7725 '\u{FE52}' | // SMALL FULL STOP
7726 '\u{FF61}' | // HALFWIDTH IDEOGRAPHIC FULL STOP
7727 '\u{FF64}' // HALFWIDTH IDEOGRAPHIC COMMA
7728 )
7729}
7730
7731/// Helper to check if a cluster contains only hanging punctuation.
7732// +spec:box-model:8bbcd1 - non-zero inline-axis borders/padding between hangable glyph and line edge prevent hanging
7733/// +spec:inline-formatting-context:135be2 - hanging punctuation placed outside the line box
7734/// +spec:intrinsic-sizing:407d8b - hanging glyphs not counted in intrinsic size computation
7735fn is_hanging_punctuation(item: &ShapedItem) -> bool {
7736 if let ShapedItem::Cluster(c) = item {
7737 if c.glyphs.len() == 1 {
7738 c.text.chars().next().is_some_and(is_hanging_punctuation_char)
7739 } else {
7740 false
7741 }
7742 } else {
7743 false
7744 }
7745}
7746
7747#[allow(clippy::cast_possible_truncation)] // bounded pixel/coord/colour/glyph cast
7748#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
7749fn shape_text_correctly<T: ParsedFontTrait>(
7750 text: &str,
7751 script: Script,
7752 language: Language,
7753 direction: BidiDirection,
7754 font: &T, // Changed from &Arc<T>
7755 style: &Arc<StyleProperties>,
7756 source_index: ContentIndex,
7757 source_node_id: Option<NodeId>,
7758) -> Result<Vec<ShapedCluster>, LayoutError> {
7759 unsafe { crate::az_mark(0x60864_u32, 0xC0DE_0864_u32); } // [g123] shape_text_correctly ENTERED
7760 let glyphs = font.shape_text(text, script, language, direction, style.as_ref())?;
7761 unsafe { crate::az_mark(0x60868_u32, (glyphs.len() as u32) | 0x8000_0000_u32); } // [g123] font.shape_text returned (high bit set); low bits = glyph count
7762
7763 if glyphs.is_empty() {
7764 return Ok(Vec::new());
7765 }
7766
7767 let mut clusters = Vec::new();
7768
7769 // Group glyphs by cluster ID from the shaper.
7770 let mut current_cluster_glyphs = Vec::new();
7771 let mut cluster_id = glyphs[0].cluster;
7772 let mut cluster_start_byte_in_text = glyphs[0].logical_byte_index;
7773
7774 for glyph in glyphs {
7775 if glyph.cluster != cluster_id {
7776 // Finalize previous cluster
7777 let advance = current_cluster_glyphs
7778 .iter()
7779 .map(|g: &Glyph| g.advance)
7780 .sum();
7781
7782 // Safely extract cluster text - handle cases where byte indices may be out of order
7783 // (can happen with RTL text or complex GSUB reordering)
7784 let (start, end) = if cluster_start_byte_in_text <= glyph.logical_byte_index {
7785 (cluster_start_byte_in_text, glyph.logical_byte_index)
7786 } else {
7787 (glyph.logical_byte_index, cluster_start_byte_in_text)
7788 };
7789 let cluster_text = text.get(start..end).unwrap_or("");
7790
7791 clusters.push(ShapedCluster {
7792 text: cluster_text.to_string(), // Store original text for hyphenation
7793 source_cluster_id: GraphemeClusterId {
7794 source_run: source_index.run_index,
7795 start_byte_in_run: cluster_id,
7796 },
7797 source_content_index: source_index,
7798 source_node_id,
7799 glyphs: current_cluster_glyphs
7800 .iter()
7801 .map(|g| {
7802 // Calculate cluster_offset safely
7803 let cluster_offset = if g.logical_byte_index >= cluster_start_byte_in_text {
7804 (g.logical_byte_index - cluster_start_byte_in_text) as u32
7805 } else {
7806 0
7807 };
7808 ShapedGlyph {
7809 kind: if g.glyph_id == 0 {
7810 GlyphKind::NotDef
7811 } else {
7812 GlyphKind::Character
7813 },
7814 glyph_id: g.glyph_id,
7815 script: g.script,
7816 font_hash: g.font_hash,
7817 font_metrics: g.font_metrics,
7818 style: g.style.clone(),
7819 cluster_offset,
7820 advance: g.advance,
7821 kerning: g.kerning,
7822 vertical_advance: g.vertical_advance,
7823 vertical_offset: g.vertical_bearing,
7824 offset: g.offset,
7825 }
7826 })
7827 .collect(),
7828 advance,
7829 direction,
7830 style: style.clone(),
7831 marker_position_outside: None,
7832 is_first_fragment: true,
7833 is_last_fragment: true,
7834 });
7835 current_cluster_glyphs.clear();
7836 cluster_id = glyph.cluster;
7837 cluster_start_byte_in_text = glyph.logical_byte_index;
7838 }
7839 current_cluster_glyphs.push(glyph);
7840 }
7841
7842 // Finalize the last cluster
7843 if !current_cluster_glyphs.is_empty() {
7844 let advance = current_cluster_glyphs
7845 .iter()
7846 .map(|g: &Glyph| g.advance)
7847 .sum();
7848 let cluster_text = text.get(cluster_start_byte_in_text..).unwrap_or("");
7849 clusters.push(ShapedCluster {
7850 text: cluster_text.to_string(), // Store original text
7851 source_cluster_id: GraphemeClusterId {
7852 source_run: source_index.run_index,
7853 start_byte_in_run: cluster_id,
7854 },
7855 source_content_index: source_index,
7856 source_node_id,
7857 glyphs: current_cluster_glyphs
7858 .iter()
7859 .map(|g| {
7860 // Calculate cluster_offset safely
7861 let cluster_offset = if g.logical_byte_index >= cluster_start_byte_in_text {
7862 (g.logical_byte_index - cluster_start_byte_in_text) as u32
7863 } else {
7864 0
7865 };
7866 ShapedGlyph {
7867 kind: if g.glyph_id == 0 {
7868 GlyphKind::NotDef
7869 } else {
7870 GlyphKind::Character
7871 },
7872 glyph_id: g.glyph_id,
7873 font_hash: g.font_hash,
7874 font_metrics: g.font_metrics,
7875 style: g.style.clone(),
7876 script: g.script,
7877 vertical_advance: g.vertical_advance,
7878 vertical_offset: g.vertical_bearing,
7879 cluster_offset,
7880 advance: g.advance,
7881 kerning: g.kerning,
7882 offset: g.offset,
7883 }
7884 })
7885 .collect(),
7886 advance,
7887 direction,
7888 style: style.clone(),
7889 marker_position_outside: None,
7890 is_first_fragment: true,
7891 is_last_fragment: true,
7892 });
7893 }
7894
7895 Ok(clusters)
7896}
7897
7898/// Measures a non-text object, returning its bounds and baseline offset.
7899fn measure_inline_object(item: &InlineContent) -> Result<(Rect, f32), LayoutError> {
7900 match item {
7901 InlineContent::Image(img) => {
7902 let size = img.display_size.unwrap_or(img.intrinsic_size);
7903 Ok((
7904 Rect {
7905 x: 0.0,
7906 y: 0.0,
7907 width: size.width,
7908 height: size.height,
7909 },
7910 img.baseline_offset,
7911 ))
7912 }
7913 InlineContent::Shape(shape) => Ok({
7914 let size = shape.shape_def.get_size();
7915 (
7916 Rect {
7917 x: 0.0,
7918 y: 0.0,
7919 width: size.width,
7920 height: size.height,
7921 },
7922 shape.baseline_offset,
7923 )
7924 }),
7925 InlineContent::Space(space) => Ok((
7926 Rect {
7927 x: 0.0,
7928 y: 0.0,
7929 width: space.width,
7930 height: 0.0,
7931 },
7932 0.0,
7933 )),
7934 InlineContent::Marker { .. } => {
7935 // Markers are treated as text content, not measurable objects
7936 Err(LayoutError::InvalidText(
7937 "Marker is text content, not a measurable object".into(),
7938 ))
7939 }
7940 _ => Err(LayoutError::InvalidText("Not a measurable object".into())),
7941 }
7942}
7943
7944// --- Stage 4 Implementation: Vertical Text ---
7945
7946/// Applies orientation and vertical metrics to glyphs if the writing mode is vertical.
7947// +spec:block-formatting-context:227171 - vertical glyph orientation with fallback vertical metrics
7948// +spec:block-formatting-context:df20a5 - mixed vertical orientation dispatch (TextOrientation::Mixed)
7949fn apply_text_orientation(
7950 items: Arc<Vec<ShapedItem>>,
7951 constraints: &UnifiedConstraints,
7952) -> Arc<Vec<ShapedItem>> {
7953 if !constraints.is_vertical() {
7954 return items;
7955 }
7956
7957 let mut oriented_items = Vec::with_capacity(items.len());
7958 let writing_mode = constraints.writing_mode.unwrap_or_default();
7959
7960 for item in items.iter() {
7961 match item {
7962 ShapedItem::Cluster(cluster) => {
7963 let mut new_cluster = cluster.clone();
7964 let mut total_vertical_advance = 0.0;
7965
7966 for glyph in &mut new_cluster.glyphs {
7967 // Use the vertical metrics already computed during shaping
7968 // If they're zero, use fallback values
7969 if glyph.vertical_advance > 0.0 {
7970 total_vertical_advance += glyph.vertical_advance;
7971 } else {
7972 // Fallback: use line height for vertical advance
7973 let fallback_advance = cluster.style.line_height.resolve_with_metrics(cluster.style.font_size_px, &glyph.font_metrics);
7974 glyph.vertical_advance = fallback_advance;
7975 // Center the glyph horizontally as a fallback
7976 glyph.vertical_offset = Point {
7977 x: -glyph.advance / 2.0,
7978 y: 0.0,
7979 };
7980 total_vertical_advance += fallback_advance;
7981 }
7982 }
7983 // The cluster's `advance` now represents vertical advance.
7984 new_cluster.advance = total_vertical_advance;
7985 oriented_items.push(ShapedItem::Cluster(new_cluster));
7986 }
7987 // Non-text objects also need their advance axis swapped.
7988 ShapedItem::Object {
7989 source,
7990 bounds,
7991 baseline_offset,
7992 content,
7993 } => {
7994 let mut new_bounds = *bounds;
7995 std::mem::swap(&mut new_bounds.width, &mut new_bounds.height);
7996 oriented_items.push(ShapedItem::Object {
7997 source: *source,
7998 bounds: new_bounds,
7999 baseline_offset: *baseline_offset,
8000 content: content.clone(),
8001 });
8002 }
8003 _ => oriented_items.push(item.clone()),
8004 }
8005 }
8006
8007 Arc::new(oriented_items)
8008}
8009
8010// --- Stage 5 & 6 Implementation: Combined Layout Pass ---
8011// This section replaces the previous simple line breaking and positioning logic.
8012
8013/// Extracts the per-item vertical-align from a `ShapedItem`.
8014///
8015/// For `Object` items (inline-blocks, images), this returns the alignment stored
8016/// in the original `InlineContent`. For text clusters and other items, returns `None`
8017/// to indicate the global `constraints.vertical_align` should be used.
8018const fn get_item_vertical_align(item: &ShapedItem) -> Option<VerticalAlign> {
8019 match item {
8020 ShapedItem::Object { content, .. } => match content {
8021 InlineContent::Image(img) => Some(img.alignment),
8022 InlineContent::Shape(shape) => Some(shape.alignment),
8023 _ => None,
8024 },
8025 _ => None,
8026 }
8027}
8028
8029/// Approximate version of `get_item_vertical_metrics` for use without constraints (e.g. `bounds()`).
8030/// Uses 80/20 ascent/descent ratio as fallback for empty-glyph strut case.
8031#[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
8032#[must_use] pub fn get_item_vertical_metrics_approx(item: &ShapedItem) -> (f32, f32) {
8033 // For non-empty clusters, delegate to the font-metrics-based calculation
8034 if let ShapedItem::Cluster(c) = item {
8035 if !c.glyphs.is_empty() {
8036 // Reuse the glyph-based calculation (same as get_item_vertical_metrics)
8037 let (asc, desc) = c.glyphs
8038 .iter()
8039 .fold((0.0f32, 0.0f32), |(max_asc, max_desc), glyph| {
8040 let metrics = &glyph.font_metrics;
8041 if metrics.units_per_em == 0 {
8042 return (max_asc, max_desc);
8043 }
8044 let scale = glyph.style.font_size_px / f32::from(metrics.units_per_em);
8045 let font_ascent = metrics.ascent * scale;
8046 let font_descent = (-metrics.descent * scale).max(0.0);
8047 let ad = font_ascent + font_descent;
8048 let resolved_lh = c.style.line_height.resolve_with_metrics(glyph.style.font_size_px, &glyph.font_metrics);
8049 let half_leading = (resolved_lh - ad) / 2.0;
8050 (max_asc.max(font_ascent + half_leading), max_desc.max(font_descent + half_leading))
8051 });
8052 return (asc, desc);
8053 }
8054 }
8055 // Fallback for empty glyphs or non-cluster items
8056 match item {
8057 ShapedItem::Cluster(c) => {
8058 let lh = c.style.line_height.resolve(c.style.font_size_px, 0.0, 0.0, 0.0, 0);
8059 (lh * FALLBACK_ASCENT_RATIO, lh * FALLBACK_DESCENT_RATIO)
8060 }
8061 ShapedItem::CombinedBlock { bounds, .. } => {
8062 (bounds.height * FALLBACK_ASCENT_RATIO, bounds.height * FALLBACK_DESCENT_RATIO)
8063 }
8064 ShapedItem::Object { bounds, .. } => (bounds.height, 0.0),
8065 ShapedItem::Tab { bounds, .. } => {
8066 (bounds.height * FALLBACK_ASCENT_RATIO, bounds.height * FALLBACK_DESCENT_RATIO)
8067 }
8068 ShapedItem::Break { .. } => (0.0, 0.0),
8069 }
8070}
8071
8072/// Gets the ascent (distance from baseline to top) and descent (distance from baseline to bottom)
8073/// for a single item, incorporating half-leading from line-height.
8074///
8075// +spec:box-model:37aeb2 - inline box margins/borders/padding do not affect line box height (leading model)
8076// +spec:display-property:184f0d - Inline box baseline derives from first available font metrics
8077// +spec:display-property:238bf5 - Inline box layout bounds from own text metrics, not child boxes
8078// +spec:display-property:29b194 - baseline determination for inline boxes (CSS Box Alignment 3 §9.1)
8079// +spec:display-property:2987db - per-glyph font metrics impact inline box layout bounds (line-height: normal caveat not yet distinguished)
8080/// +spec:display-property:fd42a9 - line-height affects line box contribution, not inline box size
8081// +spec:font-metrics:506abb - A/D from font metrics with half-leading: L = line-height - (A+D), A' = A + L/2, D' = D + L/2
8082// +spec:font-metrics:773029 - ascent/descent font metrics used for baseline calculations (visual centering depends on these)
8083// +spec:font-metrics:f42870 - half-leading model: leading = line-height - (ascent + descent), distributed equally above/below
8084// +spec:writing-modes:531c2e - UAs should use vertical baseline tables in vertical typographic modes
8085#[must_use] pub fn get_item_vertical_metrics(item: &ShapedItem, constraints: &UnifiedConstraints) -> (f32, f32) {
8086 // (ascent, descent)
8087 match item {
8088 ShapedItem::Cluster(c) => {
8089 if c.glyphs.is_empty() {
8090 // +spec:display-property:626c86 - strut for inline box with no glyphs uses first available font metrics
8091 // +spec:line-height:0078fa - strut: zero-width inline box with element's font/line-height
8092 // §10.8.1 strut: if inline box contains no glyphs, it is considered to
8093 // contain a strut with A and D of the element's first available font.
8094 // Half-leading: L = line-height - (A + D), A' = A + L/2, D' = D + L/2
8095 let ad = constraints.strut_ascent + constraints.strut_descent;
8096 let resolved_lh = c.style.line_height.resolve(c.style.font_size_px, 0.0, 0.0, 0.0, 0);
8097 let half_leading = (resolved_lh - ad) / 2.0;
8098 return (constraints.strut_ascent + half_leading, constraints.strut_descent + half_leading);
8099 }
8100 // +spec:box-model:0b3e1f - inline non-replaced box height uses only line-height, not vertical padding/border/margin
8101 // +spec:display-property:80b900 - fallback glyphs affect line box size via per-glyph metrics
8102 // +spec:display-property:d52f26 - layout bounds enclose all glyphs from highest A to deepest D
8103 // +spec:font-metrics:387751 - content area uses max ascenders/descenders across all fonts
8104 // +spec:font-metrics:790fd2 - half-leading: L = line-height - (A+D), A' = A + L/2, D' = D + L/2
8105 // +spec:line-height:1ae6f5 - line-height on non-replaced inline: half-leading model
8106 // +spec:line-height:0078fa - half-leading: L = line-height - (A+D), distributed equally above/below
8107 // +spec:line-height:32b3da - half-leading: L = line-height - AD, A' = A + L/2, D' = D + L/2
8108 // §10.8.1: for each glyph determine A, D from font metrics,
8109 // then L = line-height - (A + D), and adjust: A' = A + L/2, D' = D + L/2.
8110 // Note: L may be negative.
8111 // +spec:height-calculation:eb98b5 - multi-font normal line-height uses max across glyph metrics
8112 c.glyphs
8113 .iter()
8114 .fold((0.0f32, 0.0f32), |(max_asc, max_desc), glyph| {
8115 let metrics = &glyph.font_metrics;
8116 if metrics.units_per_em == 0 {
8117 return (max_asc, max_desc);
8118 }
8119 let scale = glyph.style.font_size_px / f32::from(metrics.units_per_em);
8120 let a = metrics.ascent * scale;
8121 // Descent in OpenType is typically negative, so we negate it to get a positive
8122 // distance.
8123 let d = (-metrics.descent * scale).max(0.0);
8124 let ad = a + d;
8125 let resolved_lh = glyph.style.line_height.resolve_with_metrics(glyph.style.font_size_px, &glyph.font_metrics);
8126 let leading = resolved_lh - ad;
8127 let half_leading = leading / 2.0;
8128 let item_asc = a + half_leading;
8129 let item_desc = d + half_leading;
8130 (max_asc.max(item_asc), max_desc.max(item_desc))
8131 })
8132 }
8133 ShapedItem::Object {
8134 bounds,
8135 baseline_offset,
8136 ..
8137 } => {
8138 // Per analysis, `baseline_offset` is the distance from the bottom.
8139 // bounds.height already includes margins (set from margin_box_height in fc.rs)
8140 let ascent = bounds.height - *baseline_offset;
8141 let descent = *baseline_offset;
8142 (ascent.max(0.0), descent.max(0.0))
8143 }
8144 ShapedItem::CombinedBlock {
8145 bounds,
8146 baseline_offset,
8147 ..
8148 } => {
8149 // CORRECTED: Treat baseline_offset consistently as distance from the bottom (descent).
8150 let ascent = bounds.height - *baseline_offset;
8151 let descent = *baseline_offset;
8152 (ascent.max(0.0), descent.max(0.0))
8153 }
8154 _ => (0.0, 0.0), // Breaks and other non-visible items don't affect line height.
8155 }
8156}
8157
8158// +spec:block-formatting-context:861155 - vertical-align affects vertical positioning inside line box for inline-level elements
8159/// Calculates the maximum ascent and descent for an entire line of items.
8160/// This determines the "line box" used for vertical alignment.
8161/// // +spec:display-contents:66d910 - line box height fitted to contents, controlled by line-height
8162// +spec:inline-formatting-context:c3fc54 - line box tall enough for all boxes, vertical-align determines alignment within line box
8163///
8164/// Per CSS 2.2 §10.8: Inline-level boxes aligned 'top' or 'bottom' must be aligned
8165/// so as to minimize the line box height. The algorithm is:
8166/// 1. First pass: compute line box height from baseline-aligned items only
8167/// (baseline, sub, super, middle, text-top, text-bottom, offset).
8168/// 2. Second pass: check if any top/bottom-aligned items are taller than the
8169/// line box from pass 1, and expand if necessary.
8170// +spec:box-model:c9bcd7 - when line-fit-edge is not leading, layout bounds inflated by margin+border+padding (not yet implemented; default leading behavior is correct)
8171fn calculate_line_metrics(
8172 items: &[ShapedItem],
8173 default_vertical_align: VerticalAlign,
8174 constraints: &UnifiedConstraints,
8175) -> (f32, f32) {
8176 // +spec:font-metrics:95152b - baseline alignment: items with different font sizes aligned by matching alphabetic baselines
8177 // Pass 1: Compute ascent/descent from baseline-aligned items only
8178 // (i.e., items that are NOT vertical-align: top or bottom).
8179 let (mut max_asc, mut max_desc) = items
8180 .iter()
8181 .fold((0.0f32, 0.0f32), |(max_asc, max_desc), item| {
8182 let effective_align = get_item_vertical_align(item)
8183 .unwrap_or(default_vertical_align);
8184 match effective_align {
8185 VerticalAlign::Top | VerticalAlign::Bottom => {
8186 // Skip top/bottom items in first pass
8187 (max_asc, max_desc)
8188 }
8189 _ => {
8190 let (item_asc, item_desc) = get_item_vertical_metrics(item, constraints);
8191 (max_asc.max(item_asc), max_desc.max(item_desc))
8192 }
8193 }
8194 });
8195
8196 let baseline_line_height = max_asc + max_desc;
8197
8198 // Pass 2: Check top/bottom aligned items. If any of them is taller
8199 // than the current line box, expand the line box to fit.
8200 for item in items {
8201 let effective_align = get_item_vertical_align(item)
8202 .unwrap_or(default_vertical_align);
8203 match effective_align {
8204 VerticalAlign::Top | VerticalAlign::Bottom => {
8205 let (item_asc, item_desc) = get_item_vertical_metrics(item, constraints);
8206 let item_height = item_asc + item_desc;
8207 if item_height > baseline_line_height {
8208 // To minimize height, expand in the direction the item is aligned to
8209 if effective_align == VerticalAlign::Top {
8210 // Top-aligned item extends downward from line top
8211 max_desc = max_desc.max(item_height - max_asc);
8212 } else {
8213 // Bottom-aligned item extends upward from line bottom
8214 max_asc = max_asc.max(item_height - max_desc);
8215 }
8216 }
8217 }
8218 _ => {} // Already handled in first pass
8219 }
8220 }
8221
8222 (max_asc, max_desc)
8223}
8224
8225/// Unicode Bidi Algorithm rule L2, applied at the glyph/cluster level for one line.
8226///
8227/// `reorder_logical_items` already placed the level RUNS of the paragraph in
8228/// visual order (rule L2 at the run level, via `unicode_bidi::visual_runs`), but
8229/// left the clusters *within* each run in LOGICAL order. To finish L2 the clusters
8230/// of every RTL (odd-level) run must be reversed so the run reads right-to-left.
8231///
8232/// We reverse each maximal contiguous run of clusters that share the same
8233/// direction, flipping only the RTL ones. Re-running full L2 over the whole line
8234/// instead would double-reverse the run order that is already correct. Grouping
8235/// by direction is exact here: under implicit bidi (no explicit embedding
8236/// controls, which azul does not inject) two runs of the same direction are never
8237/// visually adjacent — a higher even level nests inside its odd parent and a lower
8238/// level separates two same-parity runs — so a "same-direction" group is always a
8239/// single real level run. Non-cluster items (breaks/objects/tabs) act as run
8240/// boundaries. Applied per line, so a wrapped RTL run reorders correctly per line.
8241fn apply_l2_visual_reversal(line_items: &mut [ShapedItem]) {
8242 let dir_of = |it: &ShapedItem| it.as_cluster().map(|c| c.direction);
8243 let mut i = 0;
8244 while i < line_items.len() {
8245 let Some(dir) = dir_of(&line_items[i]) else {
8246 i += 1;
8247 continue;
8248 };
8249 let mut j = i + 1;
8250 while j < line_items.len() && dir_of(&line_items[j]) == Some(dir) {
8251 j += 1;
8252 }
8253 if dir == BidiDirection::Rtl {
8254 line_items[i..j].reverse();
8255 }
8256 i = j;
8257 }
8258}
8259
8260/// Performs layout for a single fragment, consuming items from a `BreakCursor`.
8261///
8262/// This function contains the core line-breaking and positioning logic, but is
8263/// designed to operate on a portion of a larger content stream and within the
8264/// constraints of a single geometric area (a fragment).
8265///
8266/// The loop terminates when either the fragment is filled (e.g., runs out of
8267/// vertical space) or the content stream managed by the `cursor` is exhausted.
8268///
8269/// # CSS Inline Layout Module Level 3 Implementation
8270///
8271/// This function implements the inline formatting context as described in:
8272/// <https://www.w3.org/TR/css-inline-3/#inline-formatting-context>
8273///
8274/// ## § 2.1 Layout of Line Boxes
8275/// "In general, the line-left edge of a line box touches the line-left edge of its
8276/// containing block and the line-right edge touches the line-right edge of its
8277/// containing block, and thus the logical width of a line box is equal to the inner
8278/// logical width of its containing block."
8279///
8280/// [ISSUE] `available_width` should be set to the containing block's inner width,
8281/// but is currently defaulting to 0.0 in `UnifiedConstraints::default()`.
8282/// This causes premature line breaking.
8283///
8284/// ## § 2.2 Layout Within Line Boxes
8285/// The layout process follows these steps:
8286/// 1. Baseline Alignment: All inline-level boxes are aligned by their baselines
8287/// 2. Content Size Contribution: Calculate layout bounds for each box
8288/// 3. Line Box Sizing: Size line box to fit aligned layout bounds
8289/// 4. Content Positioning: Position boxes within the line box
8290///
8291/// ## Missing Features:
8292/// - § 3 Baselines and Alignment Metrics: Only basic baseline alignment implemented
8293/// - § 4 Baseline Alignment: vertical-align property not fully supported
8294/// - § 5 Line Spacing: line-height implemented, but line-fit-edge missing
8295/// - § 6 Trimming Leading: text-box-trim not implemented
8296#[allow(clippy::cast_precision_loss)] // bounded pixel/coord/colour/glyph cast
8297#[allow(clippy::too_many_lines, clippy::cognitive_complexity)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
8298/// # Errors
8299///
8300/// Returns a `LayoutError` if fragment layout fails.
8301pub fn perform_fragment_layout<T: ParsedFontTrait>(
8302 cursor: &mut BreakCursor<'_>,
8303 logical_items: &[LogicalItem],
8304 fragment_constraints: &UnifiedConstraints,
8305 debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
8306 fonts: &LoadedFonts<T>,
8307) -> Result<UnifiedLayout, LayoutError> {
8308 const MAX_EMPTY_SEGMENTS: usize = 1000; // Maximum allowed consecutive empty segments
8309 if let Some(msgs) = debug_messages {
8310 msgs.push(LayoutDebugMessage::info(
8311 "\n--- Entering perform_fragment_layout ---".to_string(),
8312 ));
8313 msgs.push(LayoutDebugMessage::info(format!(
8314 "Constraints: available_width={:?}, available_height={:?}, columns={}, text_wrap={:?}",
8315 fragment_constraints.available_width,
8316 fragment_constraints.available_height,
8317 fragment_constraints.columns,
8318 fragment_constraints.text_wrap
8319 )));
8320 }
8321
8322 // For TextWrap::Balance, use Knuth-Plass algorithm for optimal line breaking
8323 // This produces more visually balanced lines at the cost of more computation
8324 if fragment_constraints.text_wrap == TextWrap::Balance {
8325 if let Some(msgs) = debug_messages {
8326 msgs.push(LayoutDebugMessage::info(
8327 "Using Knuth-Plass algorithm for text-wrap: balance".to_string(),
8328 ));
8329 }
8330
8331 // Get the shaped items from the cursor
8332 let shaped_items: Vec<ShapedItem> = cursor.drain_remaining();
8333
8334 // +spec:line-breaking:90c1bd - only auto-hyphenate when language is known and hyphenation resource available
8335 let hyphenator = if fragment_constraints.hyphenation == Hyphens::Auto {
8336 fragment_constraints
8337 .hyphenation_language
8338 .and_then(|lang| get_hyphenator(lang).ok())
8339 } else {
8340 None
8341 };
8342
8343 // Use the Knuth-Plass algorithm for optimal line breaking
8344 return Ok(crate::text3::knuth_plass::kp_layout(
8345 &shaped_items,
8346 logical_items,
8347 fragment_constraints,
8348 hyphenator.as_ref(),
8349 fonts,
8350 ));
8351 }
8352
8353 // +spec:intrinsic-sizing:57e02d - hyphenation opportunities considered in min-content sizing
8354 let hyphenator = if fragment_constraints.hyphenation == Hyphens::Auto {
8355 fragment_constraints
8356 .hyphenation_language
8357 .and_then(|lang| get_hyphenator(lang).ok())
8358 } else {
8359 None
8360 };
8361
8362 let mut positioned_items = Vec::new();
8363 let mut layout_bounds = Rect::default();
8364
8365 let num_columns = fragment_constraints.columns.max(1);
8366 let total_column_gap = fragment_constraints.column_gap * (num_columns - 1) as f32;
8367
8368 // CSS Inline Layout § 2.1: "the logical width of a line box is equal to the inner
8369 // logical width of its containing block"
8370 //
8371 // Handle the different available space modes:
8372 // - Definite(width): Use the specified width for column calculation
8373 // - MinContent: Force line breaks at word boundaries, return widest word width
8374 // - MaxContent: Use a large value to allow content to expand naturally
8375 //
8376 // IMPORTANT: For MinContent, we do NOT use 0.0 (which would break after every character).
8377 // Instead, we use a large width but track the is_min_content flag to force word-level
8378 // line breaks in the line breaker. The actual min-content width is the width of the
8379 // widest resulting line (typically the widest word).
8380 let is_min_content = matches!(fragment_constraints.available_width, AvailableSpace::MinContent);
8381 let is_max_content = matches!(fragment_constraints.available_width, AvailableSpace::MaxContent);
8382
8383 let column_width = match fragment_constraints.available_width {
8384 AvailableSpace::Definite(width) => (width - total_column_gap) / num_columns as f32,
8385 AvailableSpace::MinContent | AvailableSpace::MaxContent => {
8386 // For intrinsic sizing, use a large width to measure actual content width.
8387 // The line breaker will handle MinContent specially by breaking after each word.
8388 f32::MAX / 2.0
8389 }
8390 };
8391 let mut current_column = 0;
8392 if let Some(msgs) = debug_messages {
8393 msgs.push(LayoutDebugMessage::info(format!(
8394 "Column width calculated: {column_width}"
8395 )));
8396 }
8397
8398 // Use the CSS direction from constraints instead of auto-detecting from text
8399 // This ensures that mixed-direction text (e.g., "مرحبا - Hello") uses the
8400 // correct paragraph-level direction for alignment purposes.
8401 // With unicode-bidi: plaintext, direction is auto-detected from text content
8402 // per CSS Writing Modes §8.3.
8403 let base_direction = if fragment_constraints.unicode_bidi == UnicodeBidi::Plaintext {
8404 // Auto-detect from remaining shaped items' text content
8405 let remaining = &cursor.items[cursor.next_item_index..];
8406 let text: String = remaining.iter()
8407 .filter_map(|i| i.as_cluster())
8408 .map(|c| c.text.as_str())
8409 .collect();
8410 match unicode_bidi::get_base_direction(text.as_str()) {
8411 unicode_bidi::Direction::Ltr => BidiDirection::Ltr,
8412 unicode_bidi::Direction::Rtl => BidiDirection::Rtl,
8413 // No strong character: fall back to containing block direction
8414 unicode_bidi::Direction::Mixed => fragment_constraints.direction.unwrap_or(BidiDirection::Ltr),
8415 }
8416 } else {
8417 fragment_constraints.direction.unwrap_or(BidiDirection::Ltr)
8418 };
8419
8420 if let Some(msgs) = debug_messages {
8421 msgs.push(LayoutDebugMessage::info(format!(
8422 "[PFLayout] Base direction: {:?} (from CSS), Text align: {:?}",
8423 base_direction, fragment_constraints.text_align
8424 )));
8425 }
8426
8427 'column_loop: while current_column < num_columns {
8428 if let Some(msgs) = debug_messages {
8429 msgs.push(LayoutDebugMessage::info(format!(
8430 "\n-- Starting Column {current_column} --"
8431 )));
8432 }
8433 let column_start_x =
8434 (column_width + fragment_constraints.column_gap) * current_column as f32;
8435 let mut line_top_y = 0.0;
8436 let mut line_index = 0;
8437 let mut empty_segment_count = 0; // Failsafe counter for infinite loops
8438 let mut is_after_forced_break = false;
8439
8440 // [g147 az-web-lift] Hard total-iteration cap on the line-build loop. On the remill lift,
8441 // `cursor.is_done()` (or the empty-segment failsafe) mis-lifts for the NESTED IFC (content.len
8442 // reads 0 → the cursor is starved but never reports done) → this `while !cursor.is_done()` spins
8443 // forever → solveLayoutReal HANGS inside perform_fragment_layout. Cap total iterations so the loop
8444 // always converges (the harness can then read the markers). native is unaffected (far above real
8445 // line counts). The 0x60BC4 marker exposes the iteration count.
8446 #[allow(clippy::no_effect_underscore_binding)] // web_lift-gated debug iteration counter
8447 let mut _az_line_iters: usize = 0;
8448 while !cursor.is_done() {
8449 #[cfg(feature = "web_lift")]
8450 {
8451 _az_line_iters += 1;
8452 unsafe { crate::az_mark((0x60BC4) as u32, (_az_line_iters as u32 | 0xC0DE0000) as u32); }
8453 if _az_line_iters > 4096 {
8454 break;
8455 }
8456 }
8457 if let Some(max_height) = fragment_constraints.available_height {
8458 if line_top_y >= max_height {
8459 if let Some(msgs) = debug_messages {
8460 msgs.push(LayoutDebugMessage::info(format!(
8461 " Column full (pen {line_top_y} >= height {max_height}), breaking to next column."
8462 )));
8463 }
8464 break;
8465 }
8466 }
8467
8468 if let Some(clamp) = fragment_constraints.line_clamp {
8469 if line_index >= clamp.get() {
8470 break;
8471 }
8472 }
8473
8474 // Create constraints specific to the current column for the line breaker.
8475 let mut column_constraints = fragment_constraints.clone();
8476 // For MinContent/MaxContent, preserve the semantic type so the line breaker
8477 // can handle word-level breaking correctly. Only use Definite for actual widths.
8478 if is_min_content {
8479 column_constraints.available_width = AvailableSpace::MinContent;
8480 } else if is_max_content {
8481 column_constraints.available_width = AvailableSpace::MaxContent;
8482 } else {
8483 column_constraints.available_width = AvailableSpace::Definite(column_width);
8484 }
8485 let line_constraints = get_line_constraints(
8486 line_top_y,
8487 fragment_constraints.resolved_line_height(),
8488 &column_constraints,
8489 debug_messages,
8490 );
8491
8492 if line_constraints.segments.is_empty() {
8493 empty_segment_count += 1;
8494 if let Some(msgs) = debug_messages {
8495 msgs.push(LayoutDebugMessage::info(format!(
8496 " No available segments at y={line_top_y}, skipping to next line. (empty count: \
8497 {empty_segment_count}/{MAX_EMPTY_SEGMENTS})"
8498 )));
8499 }
8500
8501 // Failsafe: If we've skipped too many lines without content, break out
8502 if empty_segment_count >= MAX_EMPTY_SEGMENTS {
8503 if let Some(msgs) = debug_messages {
8504 msgs.push(LayoutDebugMessage::warning(format!(
8505 " [WARN] Reached maximum empty segment count ({MAX_EMPTY_SEGMENTS}). Breaking to \
8506 prevent infinite loop."
8507 )));
8508 msgs.push(LayoutDebugMessage::warning(
8509 " This likely means the shape constraints are too restrictive or \
8510 positioned incorrectly."
8511 .to_string(),
8512 ));
8513 msgs.push(LayoutDebugMessage::warning(format!(
8514 " Current y={line_top_y}, shape boundaries might be outside this range."
8515 )));
8516 }
8517 break;
8518 }
8519
8520 // Additional check: If we have shapes and are far beyond the expected height,
8521 // also break to avoid infinite loops
8522 if !fragment_constraints.shape_boundaries.is_empty() && empty_segment_count > 50 {
8523 // Calculate maximum shape height
8524 let max_shape_y: f32 = fragment_constraints
8525 .shape_boundaries
8526 .iter()
8527 .map(|shape| {
8528 match shape {
8529 ShapeBoundary::Circle { center, radius } => center.y + radius,
8530 ShapeBoundary::Ellipse { center, radii } => center.y + radii.height,
8531 ShapeBoundary::Polygon { points } => {
8532 points.iter().map(|p| p.y).fold(0.0, f32::max)
8533 }
8534 ShapeBoundary::Rectangle(rect) => rect.y + rect.height,
8535 ShapeBoundary::Path { segments } => segments
8536 .iter()
8537 .filter_map(|s| match s {
8538 PathSegment::MoveTo(p) | PathSegment::LineTo(p) => Some(p.y),
8539 PathSegment::CurveTo { end, .. }
8540 | PathSegment::QuadTo { end, .. } => Some(end.y),
8541 PathSegment::Arc { center, radius, .. } => {
8542 Some(center.y + radius)
8543 }
8544 PathSegment::Close => None,
8545 })
8546 .fold(0.0, f32::max),
8547 }
8548 })
8549 .fold(0.0, f32::max);
8550
8551 if line_top_y > max_shape_y + 100.0 {
8552 if let Some(msgs) = debug_messages {
8553 msgs.push(LayoutDebugMessage::info(format!(
8554 " [INFO] Current y={line_top_y} is far beyond maximum shape extent y={max_shape_y}. \
8555 Breaking layout."
8556 )));
8557 msgs.push(LayoutDebugMessage::info(
8558 " Shape boundaries exist but no segments available - text cannot \
8559 fit in shape."
8560 .to_string(),
8561 ));
8562 }
8563 break;
8564 }
8565 }
8566
8567 line_top_y += fragment_constraints.resolved_line_height();
8568 continue;
8569 }
8570
8571 // Reset counter when we find valid segments
8572 empty_segment_count = 0;
8573
8574 // +spec:line-breaking:3bb032 - break-word not considered for min-content intrinsic sizes
8575 // +spec:overflow:b932c4 - overflow-wrap/word-wrap (normal/break-word/anywhere) and hyphens interaction
8576 // `anywhere` introduces soft wrap opportunities (min-content = widest cluster),
8577 // but `break-word` does NOT (min-content = widest unbreakable word).
8578 let effective_overflow_wrap = if is_min_content && fragment_constraints.overflow_wrap == OverflowWrap::Anywhere {
8579 OverflowWrap::Anywhere
8580 } else if is_min_content && fragment_constraints.overflow_wrap == OverflowWrap::BreakWord {
8581 OverflowWrap::Normal
8582 } else {
8583 fragment_constraints.overflow_wrap
8584 };
8585
8586 // CSS Text Module Level 3 § 5 Line Breaking and Word Boundaries
8587 // https://www.w3.org/TR/css-text-3/#line-breaking
8588 // +spec:display-property:2608cc - inline box splitting across line boxes, overflow for unsplittable boxes
8589 // +spec:display-property:ea615c - inline boxes split and distributed across line boxes
8590 // "When an inline box exceeds the logical width of a line box, it is split
8591 // into several fragments, which are partitioned across multiple line boxes."
8592 let (mut line_items, was_hyphenated) =
8593 break_one_line(cursor, &line_constraints, false, hyphenator.as_ref(), fonts, fragment_constraints.line_break, fragment_constraints.white_space_mode, effective_overflow_wrap);
8594 if line_items.is_empty() {
8595 if let Some(msgs) = debug_messages {
8596 msgs.push(LayoutDebugMessage::info(
8597 " Break returned no items. Ending column.".to_string(),
8598 ));
8599 }
8600 break;
8601 }
8602
8603 let line_text_before_rev: String = line_items
8604 .iter()
8605 .filter_map(|i| i.as_cluster())
8606 .map(|c| c.text.as_str())
8607 .collect();
8608 if let Some(msgs) = debug_messages {
8609 msgs.push(LayoutDebugMessage::info(format!(
8610 // FIX: The log message was misleading. Items are in visual order.
8611 "[PFLayout] Line items from breaker (visual order): [{line_text_before_rev}]"
8612 )));
8613 }
8614
8615 // Unicode Bidi rule L2 (glyph-level reversal). `reorder_logical_items`
8616 // already ordered the level RUNS visually; here we reverse the clusters
8617 // within each RTL run so an RTL run reads right-to-left. Applied per line
8618 // (after line breaking) so a wrapped RTL run reorders correctly per line.
8619 apply_l2_visual_reversal(&mut line_items);
8620
8621 if let Some(msgs) = debug_messages {
8622 let after: String = line_items
8623 .iter()
8624 .filter_map(|i| i.as_cluster())
8625 .map(|c| c.text.as_str())
8626 .collect();
8627 if after != line_text_before_rev {
8628 msgs.push(LayoutDebugMessage::info(format!(
8629 "[PFLayout] Line items after L2 reversal: [{after}]"
8630 )));
8631 }
8632 }
8633
8634 // +spec:line-breaking:c59944 - forced line breaks detected for bidi-aware alignment
8635 let line_ends_with_forced_break = line_items.iter().any(|item| matches!(item, ShapedItem::Break { .. }));
8636
8637 // uses text-align-last (last line of block, or line right before forced break)
8638 let is_last_line = cursor.is_done() && !was_hyphenated;
8639 let effective_align = resolve_effective_alignment(
8640 fragment_constraints.text_align,
8641 fragment_constraints.text_align_last,
8642 is_last_line || line_ends_with_forced_break,
8643 );
8644
8645 let (mut line_pos_items, line_height) = position_one_line(
8646 &line_items,
8647 &line_constraints,
8648 line_top_y,
8649 line_index,
8650 effective_align,
8651 base_direction,
8652 is_last_line,
8653 fragment_constraints,
8654 debug_messages,
8655 fonts,
8656 is_after_forced_break,
8657 );
8658
8659 // Track whether the next line follows a forced break
8660 is_after_forced_break = line_ends_with_forced_break;
8661
8662 for item in &mut line_pos_items {
8663 item.position.x += column_start_x;
8664 }
8665
8666 // +spec:display-property:6c4978 - line-height on block container establishes minimum line box height
8667 line_top_y += line_height.max(fragment_constraints.resolved_line_height());
8668 line_index += 1;
8669 positioned_items.extend(line_pos_items);
8670 }
8671 current_column += 1;
8672 }
8673
8674 if let Some(msgs) = debug_messages {
8675 msgs.push(LayoutDebugMessage::info(format!(
8676 "--- Exiting perform_fragment_layout, positioned {} items ---",
8677 positioned_items.len()
8678 )));
8679 }
8680
8681 let mut layout = UnifiedLayout {
8682 items: positioned_items,
8683 overflow: OverflowInfo::default(),
8684 };
8685
8686 // Calculate bounds on demand via the bounds() method
8687 let calculated_bounds = layout.bounds();
8688
8689 // Record the unclipped content bounds. `overflow_items` stays empty by
8690 // design: this positioner places *every* item, so visual overflow is handled
8691 // at paint time via clipping rather than by dropping items here.
8692 // TODO(superplan): only populate `overflow_items` if a future positioning
8693 // path actually discards content that does not fit.
8694 layout.overflow.unclipped_bounds = calculated_bounds;
8695
8696 if let Some(msgs) = debug_messages {
8697 msgs.push(LayoutDebugMessage::info(format!(
8698 "--- Calculated bounds: width={}, height={} ---",
8699 calculated_bounds.width, calculated_bounds.height
8700 )));
8701 }
8702
8703 Ok(layout)
8704}
8705
8706/// Breaks a single line of items to fit within the given geometric constraints,
8707/// handling multi-segment lines and hyphenation.
8708/// Break a single line from the current cursor position.
8709///
8710/// # CSS Text Module Level 3 \u00a7 5 Line Breaking and Word Boundaries
8711/// <https://www.w3.org/TR/css-text-3/#line-breaking>
8712///
8713/// Implements the line breaking algorithm:
8714/// 1. "When an inline box exceeds the logical width of a line box, it is split into several
8715/// fragments, which are partitioned across multiple line boxes."
8716///
8717/// ## \u2705 Implemented Features:
8718/// - **Break Opportunities**: Identifies word boundaries and break points
8719/// - **Soft Wraps**: Wraps at spaces between words
8720/// - **Hard Breaks**: Handles explicit line breaks (\\n)
8721/// - **Overflow**: If a word is too long, places it anyway to avoid infinite loop
8722/// - **Hyphenation**: Tries to break long words at hyphenation points (\u00a7 5.4)
8723///
8724/// ## \u26a0\ufe0f Known Issues:
8725/// - If `line_constraints.total_available` is 0.0 (from `available_width: 0.0` bug), every word
8726/// will overflow, causing single-word lines
8727/// - This is the symptom visible in the PDF: "List items break extremely early"
8728///
8729/// ## \u00a7 5.2 Breaking Rules for Letters
8730/// \u2705 IMPLEMENTED: Uses Unicode line breaking algorithm
8731/// - Relies on UAX #14 for break opportunities
8732/// - Respects non-breaking spaces and zero-width joiners
8733///
8734/// ## \u00a7 5.3 Breaking Rules for Punctuation
8735/// \u26a0\ufe0f PARTIAL: Basic punctuation handling
8736/// - \u274c TODO: hanging-punctuation is declared in `UnifiedConstraints` but not used here
8737/// - \u274c TODO: Should implement punctuation trimming at line edges
8738/// // +spec:intrinsic-sizing:6085cf - hanging glyphs must be excluded from intrinsic size computation
8739///
8740/// ## \u00a7 5.4 Hyphenation
8741/// \u2705 IMPLEMENTED: Automatic hyphenation with hyphenator library
8742/// - Tries to hyphenate words that overflow
8743/// - Inserts hyphen glyph at break point
8744/// - Carries remainder to next line
8745///
8746/// ## \u00a7 5.5 Overflow Wrapping
8747/// \u2705 IMPLEMENTED: Emergency breaking
8748/// - If line is empty and word doesn't fit, forces at least one item
8749/// - Prevents infinite loop
8750/// - This is "overflow-wrap: break-word" behavior
8751///
8752/// # Missing Features:
8753/// - word-break property (normal, break-all, keep-all) - IMPLEMENTED via `BreakCursor.word_break`
8754/// - \u26a0\ufe0f line-break property: anywhere implemented; loose/normal/strict CJK strictness
8755/// filtering added via `is_cjk_break_allowed_by_strictness` (§5.3)
8756/// - \u274c overflow-wrap: anywhere vs break-word distinction
8757/// - \u2705 white-space: break-spaces handling
8758// around every typographic character unit including preserved white spaces; with break-spaces
8759// it allows breaking before the first space of a sequence
8760// +spec:line-breaking:722f3b - wrapping only at soft wrap opportunities, minimizing overflow
8761#[allow(clippy::cognitive_complexity)] // cohesive line-break state machine: one branch per CSS line-break case
8762#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
8763/// # Panics
8764///
8765/// Panics if a break unit is unexpectedly empty (an internal invariant).
8766pub fn break_one_line<T: ParsedFontTrait>(
8767 cursor: &mut BreakCursor<'_>,
8768 line_constraints: &LineConstraints,
8769 is_vertical: bool,
8770 hyphenator: Option<&Standard>,
8771 fonts: &LoadedFonts<T>,
8772 line_break: LineBreakStrictness,
8773 white_space_mode: WhiteSpaceMode,
8774 overflow_wrap: OverflowWrap,
8775) -> (Vec<ShapedItem>, bool) {
8776 let mut line_items = Vec::new();
8777 let mut current_width = 0.0;
8778
8779 if cursor.is_done() {
8780 return (Vec::new(), false);
8781 }
8782
8783 // +spec:white-space-processing:c83dbd - Phase II: collapsible spaces at line start removed, trailing spaces removed, tab stops
8784 // CSS Text Module Level 3 § 4.1.2: At the beginning of a line, white space
8785 // is collapsed away. Skip leading whitespace at line start.
8786 // https://www.w3.org/TR/css-text-3/#white-space-phase-2
8787 // Per CSS Text 3 §4.1.1/§4.1.2, leading white space at line start is collapsed
8788 // ONLY for the collapsing white-space modes. Pre / pre-wrap / break-spaces must
8789 // preserve leading indentation, so only strip for Normal / Nowrap / Pre-line.
8790 let strip_leading = matches!(
8791 white_space_mode,
8792 WhiteSpaceMode::Normal | WhiteSpaceMode::Nowrap | WhiteSpaceMode::PreLine
8793 );
8794 if strip_leading {
8795 while !cursor.is_done() {
8796 let next_unit = cursor.peek_next_unit();
8797 if next_unit.is_empty() {
8798 break;
8799 }
8800 if next_unit.len() == 1 && is_collapsible_whitespace(&next_unit[0]) {
8801 cursor.consume(1);
8802 } else {
8803 break;
8804 }
8805 }
8806 }
8807
8808 // +spec:line-breaking:35817b - white-space: nowrap/pre prevent soft wrap opportunities
8809 // CSS Text Level 3 § 3: For nowrap and pre, wrapping is suppressed. All content
8810 // stays on a single line, overflowing if necessary.
8811 let no_wrap = matches!(white_space_mode, WhiteSpaceMode::Nowrap | WhiteSpaceMode::Pre);
8812
8813 if no_wrap {
8814 // No soft wrapping — consume everything onto one line.
8815 // Only explicit <br>/newline breaks are honored.
8816 loop {
8817 let next_unit = cursor.peek_next_unit();
8818 if next_unit.is_empty() {
8819 break;
8820 }
8821 if let Some(ShapedItem::Break { .. }) = next_unit.first() {
8822 line_items.push(next_unit[0].clone());
8823 cursor.consume(1);
8824 return (line_items, false);
8825 }
8826 line_items.extend_from_slice(&next_unit);
8827 cursor.consume(next_unit.len());
8828 }
8829 } else {
8830
8831 loop {
8832 // typographic character unit as a soft wrap opportunity; hyphenation is not applied
8833 let next_unit = if line_break == LineBreakStrictness::Anywhere {
8834 cursor.peek_next_single_item()
8835 } else {
8836 cursor.peek_next_unit()
8837 };
8838 if next_unit.is_empty() {
8839 break; // End of content
8840 }
8841
8842 if let Some(ShapedItem::Break { .. }) = next_unit.first() {
8843 line_items.push(next_unit[0].clone());
8844 cursor.consume(1);
8845 return (line_items, false);
8846 }
8847
8848 // Min-content: break at EVERY soft-wrap opportunity so each word forms its
8849 // own line (min-content = widest unbreakable unit). `total_available` is a
8850 // sentinel (f32::MAX/2) during intrinsic sizing and never overflows, so
8851 // without this the run would collapse onto one line and min-content would
8852 // wrongly equal max-content. Once the line holds content and the next unit
8853 // is a break opportunity (a space, CJK ideograph, hyphen, …), finish here;
8854 // a leading space is stripped at the next line's start (collapsing modes).
8855 if line_constraints.is_min_content
8856 && !line_items.is_empty()
8857 && next_unit.len() == 1
8858 && is_break_opportunity_with_word_break(&next_unit[0], cursor.word_break, cursor.hyphens)
8859 {
8860 break;
8861 }
8862
8863 let unit_width: f32 = next_unit
8864 .iter()
8865 .map(|item| get_item_measure_with_spacing(item, is_vertical))
8866 .sum();
8867 let available_width = line_constraints.total_available - current_width;
8868
8869 // 2. Can the whole unit fit on the current line?
8870 if unit_width <= available_width {
8871 line_items.extend_from_slice(&next_unit);
8872 current_width += unit_width;
8873 cursor.consume(next_unit.len());
8874 } else {
8875 // 3. The unit overflows. Can we hyphenate it?
8876 if line_break != LineBreakStrictness::Anywhere {
8877 if let Some(hyphenator) = hyphenator {
8878 if !is_break_opportunity(next_unit.last().unwrap()) {
8879 if let Some(hyphenation_result) = try_hyphenate_word_cluster(
8880 &next_unit,
8881 available_width,
8882 is_vertical,
8883 hyphenator,
8884 fonts,
8885 ) {
8886 line_items.extend(hyphenation_result.line_part);
8887 cursor.consume(next_unit.len());
8888 cursor.partial_remainder = hyphenation_result.remainder_part;
8889 return (line_items, true);
8890 }
8891 }
8892 }
8893 }
8894
8895 // an otherwise unbreakable sequence at an arbitrary point when no other
8896 // break points exist. Grapheme clusters stay together; no hyphen inserted.
8897 // 4. Cannot hyphenate or fit. The line is finished.
8898 // If the line is empty, we must force at least one item to avoid an infinite loop.
8899 // With overflow-wrap: anywhere or break-word, we break the unbreakable
8900 // unit at an arbitrary cluster boundary. With normal, we only force one
8901 // item to prevent infinite loops (content will overflow).
8902 if line_items.is_empty() {
8903 match overflow_wrap {
8904 OverflowWrap::Anywhere | OverflowWrap::BreakWord => {
8905 // Emergency break: fit as many clusters as possible on
8906 // this line. Grapheme clusters stay together.
8907 //
8908 // Per CSS Text 3 §5.5: "an otherwise unbreakable sequence
8909 // of characters may be broken at an arbitrary point" when
8910 // overflow-wrap is anywhere/break-word.
8911 let avail = line_constraints.total_available;
8912 for item in &next_unit {
8913 let item_w = get_item_measure_with_spacing(item, is_vertical);
8914 // Break BEFORE this item if adding it would overflow,
8915 // but only if we already have at least one item on the
8916 // line (must always make progress).
8917 if !line_items.is_empty() && avail > 0.0 && current_width + item_w > avail {
8918 break;
8919 }
8920 line_items.push(item.clone());
8921 current_width += item_w;
8922 // When the container is zero-width (avail <= 0), the
8923 // break-before check above is skipped (it requires
8924 // avail > 0), so every item lands on this one line —
8925 // there's nowhere to break TO, content just overflows.
8926 // This matches browser behavior for `width: 0`
8927 // containers.
8928 }
8929 let consumed = line_items.len().max(1);
8930 if line_items.is_empty() {
8931 line_items.push(next_unit[0].clone());
8932 }
8933 cursor.consume(consumed);
8934 }
8935 OverflowWrap::Normal => {
8936 // overflow-wrap:normal keeps an unbreakable word intact and
8937 // lets it overflow the line box — it must NOT be shredded one
8938 // grapheme per line. Place the whole unit on this (empty) line.
8939 line_items.extend_from_slice(&next_unit);
8940 cursor.consume(next_unit.len());
8941 }
8942 }
8943 }
8944 break;
8945 }
8946 }
8947
8948 } // end !no_wrap
8949
8950 // +spec:white-space-processing:fef250 - Phase II: trailing collapsible spaces and U+1680 removed at line end
8951 // as well as any trailing U+1680 OGHAM SPACE MARK whose white-space is normal/nowrap/pre-line.
8952 // Note: pre-wrap and break-spaces have different handling (hanging/preserving)
8953 // which is not yet implemented here.
8954 // Trailing collapsible white space is trimmed only for the collapsing modes.
8955 // Pre keeps significant trailing spaces; pre-wrap hangs them (handled in
8956 // position_one_line); break-spaces must never drop them.
8957 let strip_trailing = matches!(
8958 white_space_mode,
8959 WhiteSpaceMode::Normal | WhiteSpaceMode::Nowrap | WhiteSpaceMode::PreLine
8960 );
8961 if strip_trailing {
8962 while let Some(last) = line_items.last() {
8963 if is_collapsible_whitespace(last) {
8964 line_items.pop();
8965 } else {
8966 break;
8967 }
8968 }
8969 }
8970
8971 (line_items, false)
8972}
8973
8974/// Represents a single valid hyphenation point within a word.
8975#[derive(Debug, Clone)]
8976pub struct HyphenationBreak {
8977 /// The number of characters from the original word string included on the line.
8978 pub char_len_on_line: usize,
8979 /// The total advance width of the line part + the hyphen.
8980 pub width_on_line: f32,
8981 /// The cluster(s) that will remain on the current line.
8982 pub line_part: Vec<ShapedItem>,
8983 /// The cluster that represents the hyphen character itself.
8984 pub hyphen_item: ShapedItem,
8985 /// The cluster(s) that will be carried over to the next line.
8986 /// CRITICAL FIX: Changed from `ShapedItem` to Vec<ShapedItem>
8987 pub remainder_part: Vec<ShapedItem>,
8988}
8989
8990/// A "word" is defined as a sequence of one or more adjacent `ShapedClusters`.
8991#[allow(clippy::cast_precision_loss)] // bounded pixel/coord/colour/glyph cast
8992/// # Panics
8993///
8994/// Panics if a word's cluster or glyph list is unexpectedly empty (an internal invariant).
8995#[must_use] pub fn find_all_hyphenation_breaks<T: ParsedFontTrait>(
8996 word_clusters: &[ShapedCluster],
8997 hyphenator: &Standard,
8998 is_vertical: bool, // Pass this in to use correct metrics
8999 fonts: &LoadedFonts<T>,
9000) -> Option<Vec<HyphenationBreak>> {
9001 if word_clusters.is_empty() {
9002 return None;
9003 }
9004
9005 // --- 1. Concatenate the TRUE text and build a robust map ---
9006 let mut word_string = String::new();
9007 let mut char_map = Vec::new();
9008 let mut current_width = 0.0;
9009
9010 for (cluster_idx, cluster) in word_clusters.iter().enumerate() {
9011 for (char_byte_offset, _ch) in cluster.text.char_indices() {
9012 let glyph_idx = cluster
9013 .glyphs
9014 .iter()
9015 .rposition(|g| g.cluster_offset as usize <= char_byte_offset)
9016 .unwrap_or(0);
9017 let glyph = &cluster.glyphs[glyph_idx];
9018
9019 let num_chars_in_glyph = cluster.text[glyph.cluster_offset as usize..]
9020 .chars()
9021 .count();
9022 let advance_per_char = if is_vertical {
9023 glyph.vertical_advance
9024 } else {
9025 glyph.advance
9026 } / (num_chars_in_glyph as f32).max(1.0);
9027
9028 current_width += advance_per_char;
9029 char_map.push((cluster_idx, glyph_idx, current_width));
9030 }
9031 word_string.push_str(&cluster.text);
9032 }
9033
9034 // +spec:line-breaking:d7ed93 - language-specific hyphenation rules apply to both auto and explicit (soft hyphen) opportunities
9035 // --- 2. Get hyphenation opportunities ---
9036 let opportunities = hyphenator.hyphenate(&word_string);
9037 if opportunities.breaks.is_empty() {
9038 return None;
9039 }
9040
9041 let last_cluster = word_clusters.last().unwrap();
9042 let last_glyph = last_cluster.glyphs.last().unwrap();
9043 let style = last_cluster.style.clone();
9044
9045 // Look up font from hash
9046 let font = fonts.get_by_hash(last_glyph.font_hash)?;
9047 let (hyphen_glyph_id, hyphen_advance) =
9048 font.get_hyphen_glyph_and_advance(style.font_size_px)?;
9049
9050 let mut possible_breaks = Vec::new();
9051
9052 // --- 3. Generate a HyphenationBreak for each valid opportunity ---
9053 for &break_char_idx in &opportunities.breaks {
9054 // The break is *before* the character at this index.
9055 // So the last character on the line is at `break_char_idx - 1`.
9056 if break_char_idx == 0 || break_char_idx > char_map.len() {
9057 continue;
9058 }
9059
9060 let (_, _, width_at_break) = char_map[break_char_idx - 1];
9061
9062 // The line part is all clusters *before* the break index.
9063 let line_part: Vec<ShapedItem> = word_clusters[..break_char_idx]
9064 .iter()
9065 .map(|c| ShapedItem::Cluster(c.clone()))
9066 .collect();
9067
9068 // The remainder is all clusters *from* the break index onward.
9069 let remainder_part: Vec<ShapedItem> = word_clusters[break_char_idx..]
9070 .iter()
9071 .map(|c| ShapedItem::Cluster(c.clone()))
9072 .collect();
9073
9074 let hyphen_item = ShapedItem::Cluster(ShapedCluster {
9075 text: "-".to_string(),
9076 source_cluster_id: GraphemeClusterId {
9077 source_run: u32::MAX,
9078 start_byte_in_run: u32::MAX,
9079 },
9080 source_content_index: ContentIndex {
9081 run_index: u32::MAX,
9082 item_index: u32::MAX,
9083 },
9084 source_node_id: None, // Hyphen is generated, not from DOM
9085 glyphs: smallvec![ShapedGlyph {
9086 kind: GlyphKind::Hyphen,
9087 glyph_id: hyphen_glyph_id,
9088 font_hash: last_glyph.font_hash,
9089 font_metrics: last_glyph.font_metrics,
9090 cluster_offset: 0,
9091 script: Script::Latin,
9092 advance: hyphen_advance,
9093 kerning: 0.0,
9094 offset: Point::default(),
9095 style: style.clone(),
9096 vertical_advance: hyphen_advance,
9097 vertical_offset: Point::default(),
9098 }],
9099 advance: hyphen_advance,
9100 direction: BidiDirection::Ltr,
9101 style: style.clone(),
9102 marker_position_outside: None,
9103 is_first_fragment: true,
9104 is_last_fragment: true,
9105 });
9106
9107 possible_breaks.push(HyphenationBreak {
9108 char_len_on_line: break_char_idx,
9109 width_on_line: width_at_break + hyphen_advance,
9110 line_part,
9111 hyphen_item,
9112 remainder_part,
9113 });
9114 }
9115
9116 Some(possible_breaks)
9117}
9118
9119/// Tries to find a hyphenation point within a word, returning the line part and remainder.
9120fn try_hyphenate_word_cluster<T: ParsedFontTrait>(
9121 word_items: &[ShapedItem],
9122 remaining_width: f32,
9123 is_vertical: bool,
9124 hyphenator: &Standard,
9125 fonts: &LoadedFonts<T>,
9126) -> Option<HyphenationResult> {
9127 let word_clusters: Vec<ShapedCluster> = word_items
9128 .iter()
9129 .filter_map(|item| item.as_cluster().cloned())
9130 .collect();
9131
9132 if word_clusters.is_empty() {
9133 return None;
9134 }
9135
9136 let all_breaks = find_all_hyphenation_breaks(&word_clusters, hyphenator, is_vertical, fonts)?;
9137
9138 if let Some(best_break) = all_breaks
9139 .into_iter()
9140 .rfind(|b| b.width_on_line <= remaining_width)
9141 {
9142 let mut line_part = best_break.line_part;
9143 line_part.push(best_break.hyphen_item);
9144
9145 return Some(HyphenationResult {
9146 line_part,
9147 remainder_part: best_break.remainder_part,
9148 });
9149 }
9150
9151 None
9152}
9153
9154/// Positions a single line of items, handling alignment and justification within segments.
9155///
9156/// This function is architecturally critical for cache safety. It does not mutate the
9157/// `advance` or `bounds` of the input `ShapedItem`s. Instead, it applies justification
9158/// spacing by adjusting the drawing pen's position (`main_axis_pen`).
9159///
9160/// # Returns
9161/// A tuple containing the `Vec` of positioned items and the calculated height of the line box.
9162/// Position items on a single line after breaking.
9163///
9164/// # CSS Inline Layout Module Level 3 \u00a7 2.2 Layout Within Line Boxes
9165/// <https://www.w3.org/TR/css-inline-3/#layout-within-line-boxes>
9166///
9167/// Implements the positioning algorithm:
9168/// 1. "All inline-level boxes are aligned by their baselines"
9169/// 2. "Calculate layout bounds for each inline box"
9170/// 3. "Size the line box to fit the aligned layout bounds"
9171/// 4. "Position all inline boxes within the line box"
9172///
9173/// ## \u2705 Implemented Features:
9174///
9175/// ### \u00a7 4 Baseline Alignment (vertical-align)
9176/// \u26a0\ufe0f PARTIAL IMPLEMENTATION:
9177/// - \u2705 `baseline`: Aligns box baseline with parent baseline (default)
9178/// - \u2705 `top`: Aligns top of box with top of line box
9179/// - \u2705 `middle`: Centers box within line box
9180/// - \u2705 `bottom`: Aligns bottom of box with bottom of line box
9181/// - \u274c MISSING: `text-top`, `text-bottom`, `sub`, `super`
9182/// - \u274c MISSING: `<length>`, `<percentage>` values for custom offset
9183///
9184/// ### \u00a7 2.2.1 Text Alignment (text-align)
9185/// +spec:containing-block:8d5146 - text-align aligns within line box, not viewport/containing block
9186/// \u2705 IMPLEMENTED:
9187/// - `left`, `right`, `center`: Physical alignment
9188/// - `start`, `end`: Logical alignment (respects direction: ltr/rtl)
9189/// - `justify`: Distributes space between words/characters
9190/// - `justify-all`: Justifies last line too
9191///
9192/// ### \u00a7 7.3 Text Justification (text-justify)
9193/// \u2705 IMPLEMENTED:
9194/// - `inter-word`: Adds space between words
9195/// - `inter-character`: Adds space between characters
9196/// - `kashida`: Arabic kashida elongation
9197/// - \u274c MISSING: `distribute` (CJK justification)
9198///
9199/// ### CSS Text \u00a7 8.1 Text Indentation (text-indent)
9200/// \u2705 IMPLEMENTED: First line indentation
9201///
9202/// ### CSS Text \u00a7 4.1 Word Spacing (word-spacing)
9203/// \u2705 IMPLEMENTED: Additional space between words
9204///
9205/// ### CSS Text \u00a7 4.2 Letter Spacing (letter-spacing)
9206/// \u2705 IMPLEMENTED: Additional space between characters
9207///
9208/// ## Segment-Aware Layout:
9209/// \u2705 Handles CSS Shapes and multi-column layouts
9210/// - Breaks line into segments (for shape boundaries)
9211/// - Calculates justification per segment
9212/// - Applies alignment within each segment's bounds
9213///
9214/// ## Known Issues:
9215/// - \u26a0\ufe0f If segment.width is infinite (from intrinsic sizing), sets `alignment_offset=0` to
9216/// avoid infinite positioning. This is correct for measurement but documented for clarity.
9217/// - The function assumes `line_index == 0` means first line for text-indent. A more robust system
9218/// would track paragraph boundaries.
9219///
9220/// # Missing Features:
9221/// - \u274c \u00a7 6 Trimming Leading (text-box-trim, text-box-edge)
9222/// - \u274c \u00a7 3.3 Initial Letters (drop caps)
9223/// // +spec:display-property:265c04 - initial letter exclusion area must continue into subsequent blocks when paragraph is shorter than drop cap
9224/// - \u274c Full vertical-align support (sub, super, lengths, percentages)
9225/// - \u274c white-space: break-spaces alignment behavior
9226// +spec:text-alignment-spacing:c8a926 - order of operations: shaping → letter/word-spacing → justification → alignment
9227#[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
9228#[allow(clippy::cast_precision_loss)] // bounded pixel/coord/colour/glyph cast
9229#[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
9230#[allow(clippy::too_many_lines, clippy::cognitive_complexity)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
9231pub fn position_one_line<T: ParsedFontTrait>(
9232 line_items: &[ShapedItem],
9233 line_constraints: &LineConstraints,
9234 line_top_y: f32,
9235 line_index: usize,
9236 text_align: TextAlign,
9237 base_direction: BidiDirection,
9238 is_last_line: bool,
9239 constraints: &UnifiedConstraints,
9240 debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
9241 fonts: &LoadedFonts<T>,
9242 is_after_forced_break: bool,
9243) -> (Vec<PositionedItem>, f32) {
9244 let line_text: String = line_items
9245 .iter()
9246 .filter_map(|i| i.as_cluster())
9247 .map(|c| c.text.as_str())
9248 .collect();
9249 if let Some(msgs) = debug_messages {
9250 msgs.push(LayoutDebugMessage::info(format!(
9251 "\n--- Entering position_one_line for line: [{line_text}] ---"
9252 )));
9253 }
9254 // +spec:text-alignment-spacing:13b72d - line box start/end determined by inline base direction
9255 // +spec:text-alignment-spacing:d497af - line box inline base direction affects text-align resolution
9256 // +spec:text-alignment-spacing:68332e - bidi direction determines start/end to left/right mapping
9257 let physical_align = match (text_align, base_direction) {
9258 (TextAlign::Start, BidiDirection::Ltr) => TextAlign::Left,
9259 (TextAlign::Start, BidiDirection::Rtl) => TextAlign::Right,
9260 (TextAlign::End, BidiDirection::Ltr) => TextAlign::Right,
9261 (TextAlign::End, BidiDirection::Rtl) => TextAlign::Left,
9262 // Physical alignments are returned as-is, regardless of direction.
9263 (other, _) => other,
9264 };
9265 if let Some(msgs) = debug_messages {
9266 msgs.push(LayoutDebugMessage::info(format!(
9267 "[Pos1Line] Physical align: {physical_align:?}"
9268 )));
9269 }
9270
9271 // +spec:box-model:847003 - Phantom line boxes: empty lines treated as zero-height
9272 // +spec:box-model:d781f3 - empty line boxes (no text, no preserved whitespace, no inline elements with non-zero margins/padding/borders, no in-flow content) are treated as zero-height
9273 // +spec:display-property:90d782 - Phantom line boxes (containing only empty inline boxes, out-of-flow items, or collapsed whitespace) are ignored
9274 if line_items.is_empty() {
9275 return (Vec::new(), 0.0);
9276 }
9277 let mut positioned = Vec::new();
9278 let is_vertical = constraints.is_vertical();
9279
9280 // +spec:line-height:9ca9d9 - line box height = distance from uppermost box top to lowermost box bottom, including strut
9281 // The line box is calculated once for all items on the line, regardless of segment.
9282 // Per CSS 2.2 §10.8, top/bottom aligned items are handled in a second pass to
9283 // minimize line box height; baseline-aligned items determine the initial height.
9284 let (content_ascent, content_descent) = calculate_line_metrics(line_items, constraints.vertical_align, constraints);
9285
9286 // +spec:box-model:e99f7d - strut: each line box starts with zero-width inline box with block container's font/line-height
9287 // +spec:line-height:29c478 - strut: zero-width inline box with block container's font/line-height
9288 // inline box with the block container's font and line-height. The strut has A (ascent) and
9289 // D (descent) from the block container's first available font. Half-leading L/2 is applied:
9290 // L = line-height - (A + D), strut_above = A + L/2, strut_below = D + L/2.
9291 // +spec:height-calculation:8e91b2 - specified line-height used in line box height calculation
9292 let strut_ad = constraints.strut_ascent + constraints.strut_descent;
9293 let strut_leading_half = (constraints.resolved_line_height() - strut_ad) / 2.0;
9294 let strut_above = constraints.strut_ascent + strut_leading_half;
9295 let strut_below = constraints.strut_descent + strut_leading_half;
9296 let line_ascent = content_ascent.max(strut_above);
9297 let line_descent = content_descent.max(strut_below);
9298 let line_box_height = line_ascent + line_descent;
9299
9300 // The baseline for the entire line is determined by its tallest item.
9301 let line_baseline_y = line_top_y + line_ascent;
9302
9303 // --- Segment-Aware Positioning ---
9304 let mut item_cursor = 0;
9305 let is_first_line_of_para = line_index == 0; // Simplified assumption
9306
9307 for (segment_idx, segment) in line_constraints.segments.iter().enumerate() {
9308 if item_cursor >= line_items.len() {
9309 break;
9310 }
9311
9312 // 1. Collect all items that fit into the current segment.
9313 let mut segment_items = Vec::new();
9314 let mut current_segment_width = 0.0;
9315 while item_cursor < line_items.len() {
9316 let item = &line_items[item_cursor];
9317 let item_measure = get_item_measure(item, is_vertical);
9318 // Put at least one item in the segment to avoid getting stuck.
9319 if current_segment_width + item_measure > segment.width && !segment_items.is_empty() {
9320 break;
9321 }
9322 segment_items.push(item.clone());
9323 current_segment_width += item_measure;
9324 item_cursor += 1;
9325 }
9326
9327 if segment_items.is_empty() {
9328 continue;
9329 }
9330
9331 // +spec:text-alignment-spacing:b9d88e - justify stretches inline boxes via text-justify; non-collapsible WS may skip justification
9332 // 2. Calculate justification spacing *for this segment only*.
9333 // +spec:text-alignment-spacing:30d322 - justify lines with justification opportunities when text-align is justify
9334 // CSS Text 3 §6: text-justify controls HOW to justify, but only applies
9335 // when text-align is justify/justify-all. Without this check, ALL text
9336 // gets justified because text-justify defaults to auto (→ InterWord).
9337 let (extra_word_spacing, extra_char_spacing) = if (constraints.text_align == TextAlign::Justify
9338 || constraints.text_align == TextAlign::JustifyAll)
9339 && constraints.text_justify != JustifyContent::None
9340 && (!is_last_line || constraints.text_align == TextAlign::JustifyAll)
9341 && constraints.text_justify != JustifyContent::Kashida
9342 {
9343 let segment_line_constraints = LineConstraints {
9344 segments: vec![*segment],
9345 total_available: segment.width,
9346 is_min_content: false,
9347 };
9348 calculate_justification_spacing(
9349 &segment_items,
9350 &segment_line_constraints,
9351 constraints.text_justify,
9352 is_vertical,
9353 )
9354 } else {
9355 (0.0, 0.0)
9356 };
9357
9358 // Kashida justification needs to be segment-aware if used.
9359 let justified_segment_items = if constraints.text_justify == JustifyContent::Kashida
9360 && (!is_last_line || constraints.text_align == TextAlign::JustifyAll)
9361 {
9362 let segment_line_constraints = LineConstraints {
9363 segments: vec![*segment],
9364 total_available: segment.width,
9365 is_min_content: false,
9366 };
9367 justify_kashida_and_rebuild(
9368 segment_items,
9369 &segment_line_constraints,
9370 is_vertical,
9371 debug_messages,
9372 fonts,
9373 )
9374 } else {
9375 segment_items
9376 };
9377
9378 // Recalculate width in case kashida changed the item list
9379 let final_segment_width: f32 = justified_segment_items
9380 .iter()
9381 .map(|item| get_item_measure(item, is_vertical))
9382 .sum();
9383
9384 // +spec:line-breaking:155a96 - pre-wrap hanging spaces: unconditionally hang without forced break, conditionally hang with forced break
9385 // +spec:white-space-processing:68af09 - Phase II: trailing whitespace hanging/conditional hanging per white-space mode
9386 // +spec:white-space-processing:75d91e - preserved white space hangs at line end, affecting intrinsic sizing
9387 // +spec:overflow:a68394 - Hanging trailing whitespace: unconditionally hang (not considered
9388 // during alignment, may overflow) for lines without forced break; conditionally hang for
9389 // lines ending with forced break (only hang if would overflow).
9390 // For normal/nowrap/pre-line: unconditionally hang trailing WS.
9391 // For pre-wrap: unconditionally hang, unless before forced break (then conditionally hang).
9392 // For break-spaces: trailing spaces cannot hang.
9393 // For pre: no hanging (whitespace preserved as-is).
9394 // +spec:intrinsic-sizing:1db683 - conditionally hanging glyphs excluded from min-content, included in max-content
9395 let trailing_ws_width = match constraints.white_space_mode {
9396 WhiteSpaceMode::BreakSpaces | WhiteSpaceMode::Pre => 0.0,
9397 WhiteSpaceMode::Normal | WhiteSpaceMode::Nowrap | WhiteSpaceMode::PreLine => {
9398 measure_trailing_whitespace(&justified_segment_items, is_vertical)
9399 }
9400 // +spec:line-breaking:8aa426 - space before forced break does not hang if it doesn't overflow
9401 WhiteSpaceMode::PreWrap => {
9402 let has_forced_break = justified_segment_items.last()
9403 .is_some_and(|item| matches!(item, ShapedItem::Break { .. }));
9404 let ws_width = measure_trailing_whitespace(&justified_segment_items, is_vertical);
9405 if has_forced_break {
9406 // +spec:display-contents:2704a2 - conditionally hanging chars not considered when measuring line fit
9407 // Conditionally hang: only hang if it would overflow
9408 let content_width = final_segment_width - ws_width;
9409 if content_width + ws_width > segment.width {
9410 ws_width
9411 } else {
9412 0.0
9413 }
9414 } else {
9415 ws_width // unconditionally hang
9416 }
9417 }
9418 };
9419 let effective_segment_width = final_segment_width - trailing_ws_width;
9420
9421 // +spec:text-alignment-spacing:287316 - overflow content is start-aligned; alignment offset within line box
9422 // 3. Calculate alignment offset *within this segment*.
9423 let remaining_space = segment.width - effective_segment_width;
9424
9425 // Handle MaxContent/indefinite width: when available_width is MaxContent (for intrinsic
9426 // sizing), segment.width will be f32::MAX / 2.0. Alignment calculations would
9427 // produce huge offsets. In this case, treat as left-aligned (offset = 0) since
9428 // we're measuring natural content width. We check for both infinite AND very large
9429 // values (> 1e30) to catch the MaxContent case.
9430 let is_indefinite_width = segment.width.is_infinite() || segment.width > 1e30;
9431 // +spec:text-alignment-spacing:ab1d4f - unexpandable justify text aligns as center
9432 let alignment_offset = if is_indefinite_width {
9433 0.0 // No alignment offset for indefinite width
9434 } else {
9435 match physical_align {
9436 TextAlign::Center => remaining_space / 2.0,
9437 TextAlign::Right => remaining_space,
9438 TextAlign::Justify | TextAlign::JustifyAll
9439 if remaining_space > 0.0
9440 && extra_word_spacing == 0.0
9441 && extra_char_spacing == 0.0 =>
9442 {
9443 // CSS Text §6.4.3: If text cannot be stretched to full width
9444 // and text-align-last is justify, align as center.
9445 remaining_space / 2.0
9446 }
9447 _ => 0.0, // Left, Justify (when justification succeeded)
9448 }
9449 };
9450
9451 let mut main_axis_pen = segment.start_x + alignment_offset;
9452 if let Some(msgs) = debug_messages {
9453 msgs.push(LayoutDebugMessage::info(format!(
9454 "[Pos1Line] Segment width: {}, Item width: {}, Remaining space: {}, Initial pen: \
9455 {}",
9456 segment.width, final_segment_width, remaining_space, main_axis_pen
9457 )));
9458 }
9459
9460 // Default: indent first line only. each-line: also indent after forced breaks.
9461 // hanging: invert which lines get the indent.
9462 if segment_idx == 0 {
9463 let is_indent_target = if constraints.text_indent_each_line {
9464 // each-line: first line AND each line after a forced break
9465 is_first_line_of_para || is_after_forced_break
9466 } else {
9467 // Default: only the first line of the block
9468 is_first_line_of_para
9469 };
9470 // hanging: inverts which lines are affected
9471 let should_indent = if constraints.text_indent_hanging {
9472 !is_indent_target
9473 } else {
9474 is_indent_target
9475 };
9476 if should_indent {
9477 main_axis_pen += constraints.text_indent;
9478 }
9479 }
9480
9481 // Calculate total marker width for proper outside marker positioning
9482 // We need to position all marker clusters together in the padding gutter
9483 let total_marker_width: f32 = justified_segment_items
9484 .iter()
9485 .filter_map(|item| {
9486 if let ShapedItem::Cluster(c) = item {
9487 if c.marker_position_outside == Some(true) {
9488 return Some(get_item_measure(item, is_vertical));
9489 }
9490 }
9491 None
9492 })
9493 .sum();
9494
9495 // Track marker pen separately - starts at negative position for outside markers
9496 let marker_spacing = 4.0; // Small gap between marker and content
9497 let mut marker_pen = if total_marker_width > 0.0 {
9498 -(total_marker_width + marker_spacing)
9499 } else {
9500 0.0
9501 };
9502
9503 // 4. Position the items belonging to this segment.
9504 //
9505 // +spec:inline-formatting-context:267438 - Content positioning: position aligned subtree and baseline-shift values within line box
9506 //
9507 // Vertical alignment positioning (CSS vertical-align)
9508 //
9509 // +spec:font-metrics:cae541 - dominant baseline used for inline alignment
9510 // Per CSS Inline Layout Level 3 § 4 (Baseline Alignment), each inline
9511 // element can specify its own `vertical-align`. For Object items
9512 // (inline-blocks, images), we use their per-item alignment stored in
9513 // `InlineContent::Shape.alignment` or `InlineContent::Image.alignment`.
9514 // For text clusters or items without a per-item override, we fall back
9515 // to the global `constraints.vertical_align` from the containing block.
9516 //
9517 // +spec:font-metrics:f29b61 - baseline alignment matches corresponding baseline types (only alphabetic implemented)
9518 // Reference: https://www.w3.org/TR/css-inline-3/#baseline-alignment
9519 // +spec:block-formatting-context:26b535 - In vertical typographic mode, central baseline is dominant when text-orientation is mixed/upright; otherwise alphabetic
9520 // +spec:inline-formatting-context:eb735b - alignment-baseline: inline-level boxes aligned to parent's baseline via vertical-align
9521 // +spec:inline-formatting-context:da3f34 - baseline alignment of in-flow inline-level boxes in block axis per dominant-baseline/vertical-align
9522 // +spec:line-height:e2253a - vertical-align positioning within line boxes
9523
9524 // Pre-compute inline border/padding offsets at span boundaries.
9525 // Only the FIRST cluster of each inline span gets left_inset, and only
9526 // the LAST cluster gets right_inset. We detect span boundaries by comparing
9527 // Arc<StyleProperties> pointers between consecutive clusters.
9528 let inline_offsets: Vec<(f32, f32)> = {
9529 let items_slice: &[ShapedItem] = &justified_segment_items;
9530 items_slice.iter().enumerate().map(|(idx, item)| {
9531 if let ShapedItem::Cluster(c) = item {
9532 if let Some(border) = c.style.border.as_ref() {
9533 if border.has_chrome() {
9534 let style_ptr = Arc::as_ptr(&c.style);
9535 let prev_same_span = idx > 0 && items_slice[idx - 1]
9536 .as_cluster()
9537 .is_some_and(|pc| Arc::as_ptr(&pc.style) == style_ptr);
9538 let next_same_span = idx + 1 < items_slice.len() && items_slice[idx + 1]
9539 .as_cluster()
9540 .is_some_and(|nc| Arc::as_ptr(&nc.style) == style_ptr);
9541 let left = if prev_same_span { 0.0 } else { border.left_inset() };
9542 let right = if next_same_span { 0.0 } else { border.right_inset() };
9543 return (left, right);
9544 }
9545 }
9546 }
9547 (0.0, 0.0)
9548 }).collect()
9549 };
9550 for (inline_offset_idx, item) in justified_segment_items.into_iter().enumerate() {
9551 let (item_ascent, item_descent) = get_item_vertical_metrics(&item, constraints);
9552 // Use per-item alignment if available, otherwise fall back to global
9553 let effective_align = get_item_vertical_align(&item)
9554 .unwrap_or(constraints.vertical_align);
9555 // +spec:display-property:328cfc - baseline-shift / aligned subtree vertical alignment (sub, super, top, bottom, center)
9556 // §10.8.1 vertical-align positioning
9557 // +spec:line-height:0fcfab - vertical-align property values (baseline, top, middle, bottom, sub, super, text-top, text-bottom, percentage, length)
9558 let item_baseline_pos = match effective_align {
9559 // +spec:display-property:8e018d - aligned subtree edges used for top/bottom line box alignment
9560 // +spec:inline-formatting-context:495672 - line-relative vertical-align (top/center/bottom) and aligned subtree positioning
9561 // top: align top of aligned subtree with top of line box
9562 VerticalAlign::Top => line_top_y + item_ascent,
9563 // +spec:font-metrics:70000d - align vertical midpoint of box with baseline + half x-height of parent
9564 VerticalAlign::Middle => {
9565 let half_x_height = constraints.strut_x_height / 2.0;
9566 line_baseline_y + half_x_height - f32::midpoint(item_ascent, item_descent) + item_ascent
9567 }
9568 // bottom: align bottom of aligned subtree with bottom of line box
9569 VerticalAlign::Bottom => line_top_y + line_box_height - item_descent,
9570 // +spec:font-metrics:aa21f7 - sub: lower baseline to proper subscript position
9571 VerticalAlign::Sub => line_baseline_y + line_ascent * SUBSCRIPT_OFFSET_RATIO,
9572 // +spec:display-property:3b0e76 - baseline-shift super raises by ~1/3 font-size; top/bottom align to line box edges
9573 // super: raise baseline to proper superscript position (~0.4em)
9574 VerticalAlign::Super => line_baseline_y - line_ascent * SUPERSCRIPT_OFFSET_RATIO,
9575 // text-top: align top of box with top of parent's content area (§10.6.1)
9576 // Parent's content area top = baseline - strut_ascent
9577 VerticalAlign::TextTop => (line_baseline_y - constraints.strut_ascent) + item_ascent,
9578 // text-bottom: align bottom of box with bottom of parent's content area (§10.6.1)
9579 // Parent's content area bottom = baseline + strut_descent
9580 VerticalAlign::TextBottom => (line_baseline_y + constraints.strut_descent) - item_descent,
9581 // <length>/<percentage>: raise (positive) or lower (negative); 0 = baseline
9582 VerticalAlign::Offset(offset) => line_baseline_y - offset,
9583 // +spec:display-property:8bf37e - dominant-baseline defaults to alphabetic; baseline alignment matches parent
9584 // baseline: align baseline of box with baseline of parent box
9585 // +spec:font-metrics:96bbd3 - baseline: align alphabetic baseline of box with parent's alphabetic baseline
9586 VerticalAlign::Baseline => line_baseline_y,
9587 };
9588
9589 // Calculate item measure (needed for both positioning and pen advance)
9590 let item_measure = get_item_measure(&item, is_vertical);
9591
9592 // Advance pen by inline left_inset at span entry (before positioning glyphs)
9593 let (left_inset, right_inset) = if inline_offset_idx < inline_offsets.len() {
9594 inline_offsets[inline_offset_idx]
9595 } else {
9596 (0.0, 0.0)
9597 };
9598 main_axis_pen += left_inset;
9599
9600 let position = if is_vertical {
9601 Point {
9602 x: item_baseline_pos - item_ascent,
9603 y: main_axis_pen,
9604 }
9605 } else {
9606 if let Some(msgs) = debug_messages {
9607 msgs.push(LayoutDebugMessage::info(format!(
9608 "[Pos1Line] is_vertical=false, main_axis_pen={main_axis_pen}, item_baseline_pos={item_baseline_pos}, \
9609 item_ascent={item_ascent}"
9610 )));
9611 }
9612
9613 // Check if this is an outside marker - if so, position it in the padding gutter
9614 let x_position = if let ShapedItem::Cluster(cluster) = &item {
9615 if cluster.marker_position_outside == Some(true) {
9616 // Use marker_pen for sequential marker positioning
9617 let marker_width = item_measure;
9618 if let Some(msgs) = debug_messages {
9619 msgs.push(LayoutDebugMessage::info(format!(
9620 "[Pos1Line] Outside marker detected! width={marker_width}, positioning at \
9621 marker_pen={marker_pen}"
9622 )));
9623 }
9624 let pos = marker_pen;
9625 marker_pen += marker_width; // Advance marker pen for next marker cluster
9626 pos
9627 } else {
9628 main_axis_pen
9629 }
9630 } else {
9631 main_axis_pen
9632 };
9633
9634 Point {
9635 y: item_baseline_pos - item_ascent,
9636 x: x_position,
9637 }
9638 };
9639
9640 // item_measure is calculated above for marker positioning
9641 let item_text = item
9642 .as_cluster()
9643 .map_or("[OBJ]", |c| c.text.as_str());
9644 if let Some(msgs) = debug_messages {
9645 msgs.push(LayoutDebugMessage::info(format!(
9646 "[Pos1Line] Positioning item '{item_text}' at pen_x={main_axis_pen}"
9647 )));
9648 }
9649 positioned.push(PositionedItem {
9650 item: item.clone(),
9651 position,
9652 line_index,
9653 });
9654
9655 // Outside markers don't advance the pen - they're positioned in the padding gutter
9656 let is_outside_marker = if let ShapedItem::Cluster(c) = &item {
9657 c.marker_position_outside == Some(true)
9658 } else {
9659 false
9660 };
9661
9662 if !is_outside_marker {
9663 main_axis_pen += item_measure;
9664 // Advance pen by inline right_inset at span exit (after glyph advance)
9665 main_axis_pen += right_inset;
9666 }
9667
9668 // +spec:text-alignment-spacing:e09bd1 - justification space added on top of letter-spacing/word-spacing
9669 // +spec:text-alignment-spacing:456643 - cursive scripts don't admit inter-character gaps
9670 let is_cursive = if let ShapedItem::Cluster(c) = &item { is_cursive_script_cluster(c) } else { false };
9671 if !is_outside_marker && extra_char_spacing > 0.0 && can_justify_after(&item) && !is_cursive {
9672 main_axis_pen += extra_char_spacing;
9673 }
9674 // +spec:display-property:3a833c - consecutive atomic inlines treated as single unit for letter-spacing
9675 // +spec:display-property:49f04f - letter-spacing applied per innermost inline element
9676 // +spec:text-alignment-spacing:22bea4 - letter-spacing applied after bidi reordering, additive with kerning and word-spacing; justification may further adjust
9677 if let ShapedItem::Cluster(c) = &item {
9678 if !is_outside_marker {
9679 // +spec:display-property:756454 - letter-spacing applied between typographic character units
9680 // +spec:overflow:e63bc0 - letter-spacing ignores zero-width formatting chars (Cf); handled by shaper merging them into clusters
9681 // +spec:text-alignment-spacing:80f9ec - letter-spacing applied per-cluster using innermost element's style (UA-allowed attachment)
9682 // +spec:text-alignment-spacing:bdd704 - letter-spacing applied after each cluster, not at line start
9683 // +spec:text-alignment-spacing:d3ef6e - single-char element: only trailing space, no inter-char effect
9684 // +spec:text-alignment-spacing:d668fc - letter-spacing only affects characters within the element (per-cluster style)
9685 // +spec:text-alignment-spacing:8dbb78 - zero letter-spacing behaves as normal (Px(0) adds no spacing)
9686 // +spec:text-alignment-spacing:456643 - skip letter-spacing for cursive scripts
9687 if !is_cursive_script_cluster(c) {
9688 let letter_spacing_px = c.style.letter_spacing.resolve_px(c.style.font_size_px);
9689 main_axis_pen += letter_spacing_px;
9690 }
9691 // +spec:width-calculation:9447d1 - word-spacing only applied to word separators; zero-width chars like U+200B are excluded
9692 if is_word_separator(&item) {
9693 let word_spacing_px = c.style.word_spacing.resolve_px(c.style.font_size_px);
9694 main_axis_pen += word_spacing_px;
9695 main_axis_pen += extra_word_spacing;
9696 }
9697 }
9698 }
9699 }
9700 }
9701
9702 (positioned, line_box_height)
9703}
9704
9705/// Calculates the starting pen offset to achieve the desired text alignment.
9706fn calculate_alignment_offset(
9707 items: &[ShapedItem],
9708 line_constraints: &LineConstraints,
9709 align: TextAlign,
9710 is_vertical: bool,
9711 constraints: &UnifiedConstraints,
9712) -> f32 {
9713 // Simplified to use the first segment for alignment.
9714 if let Some(segment) = line_constraints.segments.first() {
9715 // Include letter/word-spacing so center/right alignment matches the width the
9716 // text is actually positioned at (position_one_line adds the spacing).
9717 let total_width: f32 = items
9718 .iter()
9719 .map(|item| get_item_measure_with_spacing(item, is_vertical))
9720 .sum();
9721
9722 let available_width = if constraints.segment_alignment == SegmentAlignment::Total {
9723 line_constraints.total_available
9724 } else {
9725 segment.width
9726 };
9727
9728 if total_width >= available_width {
9729 return 0.0; // No alignment needed if line is full or overflows
9730 }
9731
9732 let remaining_space = available_width - total_width;
9733
9734 match align {
9735 TextAlign::Center => remaining_space / 2.0,
9736 TextAlign::Right => remaining_space,
9737 _ => 0.0, // Left, Justify, Start, End
9738 }
9739 } else {
9740 0.0
9741 }
9742}
9743
9744/// Calculates the extra spacing needed for justification without modifying the items.
9745///
9746/// This function is pure and does not mutate any state, making it safe to use
9747/// with cached `ShapedItem` data.
9748///
9749/// # Arguments
9750/// * `items` - A slice of items on the line.
9751/// * `line_constraints` - The geometric constraints for the line.
9752/// * `text_justify` - The type of justification to calculate.
9753/// * `is_vertical` - Whether the layout is vertical.
9754///
9755/// # Returns
9756/// A tuple `(extra_per_word, extra_per_char)` containing the extra space in pixels
9757/// to add at each word or character justification opportunity.
9758// +spec:display-contents:654278 - distributes remaining space to fill line box when justifying
9759// +spec:text-alignment-spacing:56c7f4 - equal distribution of justification space within priority level
9760// +spec:text-alignment-spacing:f17bbc - justification opportunities controlled by text-justify value (inter-word = word separators, inter-character = character juxtaposition)
9761#[allow(clippy::cast_precision_loss)] // bounded pixel/coord/colour/glyph cast
9762fn calculate_justification_spacing(
9763 items: &[ShapedItem],
9764 line_constraints: &LineConstraints,
9765 text_justify: JustifyContent,
9766 is_vertical: bool,
9767) -> (f32, f32) {
9768 // (extra_per_word, extra_per_char)
9769 let total_width: f32 = items
9770 .iter()
9771 .map(|item| get_item_measure(item, is_vertical))
9772 .sum();
9773 let available_width = line_constraints.total_available;
9774
9775 if total_width >= available_width || available_width <= 0.0 {
9776 return (0.0, 0.0);
9777 }
9778
9779 let extra_space = available_width - total_width;
9780
9781 // +spec:text-alignment-spacing:71314a - script categories for justification: inter-word for clustered, kashida for cursive (Arabic), inter-character for block (CJK)
9782 match text_justify {
9783 JustifyContent::InterWord => {
9784 // Count justification opportunities (spaces).
9785 let space_count = items.iter().filter(|item| is_word_separator(item)).count();
9786 if space_count > 0 {
9787 (extra_space / space_count as f32, 0.0)
9788 } else {
9789 (0.0, 0.0) // No spaces to expand, do nothing.
9790 }
9791 }
9792 JustifyContent::InterCharacter | JustifyContent::Distribute => {
9793 // Count justification opportunities (between non-combining characters).
9794 let gap_count = items
9795 .iter()
9796 .enumerate()
9797 .filter(|(i, item)| *i < items.len() - 1 && can_justify_after(item))
9798 .count();
9799 if gap_count > 0 {
9800 (0.0, extra_space / gap_count as f32)
9801 } else {
9802 (0.0, 0.0) // No gaps to expand, do nothing.
9803 }
9804 }
9805 // Kashida justification modifies the item list and is handled by a separate function.
9806 _ => (0.0, 0.0),
9807 }
9808}
9809
9810/// Rebuilds a line of items, inserting Kashida glyphs for justification.
9811///
9812/// This function is non-mutating with respect to its inputs. It takes ownership of the
9813/// original items and returns a completely new `Vec`. This is necessary because Kashida
9814/// justification changes the number of items on the line, and must not modify cached data.
9815#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] // bounded pixel/coord/colour/glyph cast
9816#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
9817pub fn justify_kashida_and_rebuild<T: ParsedFontTrait>(
9818 items: Vec<ShapedItem>,
9819 line_constraints: &LineConstraints,
9820 is_vertical: bool,
9821 debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
9822 fonts: &LoadedFonts<T>,
9823) -> Vec<ShapedItem> {
9824 if let Some(msgs) = debug_messages {
9825 msgs.push(LayoutDebugMessage::info(
9826 "\n--- Entering justify_kashida_and_rebuild ---".to_string(),
9827 ));
9828 }
9829 let total_width: f32 = items
9830 .iter()
9831 .map(|item| get_item_measure(item, is_vertical))
9832 .sum();
9833 let available_width = line_constraints.total_available;
9834 if let Some(msgs) = debug_messages {
9835 msgs.push(LayoutDebugMessage::info(format!(
9836 "Total item width: {total_width}, Available width: {available_width}"
9837 )));
9838 }
9839
9840 if total_width >= available_width || available_width <= 0.0 {
9841 if let Some(msgs) = debug_messages {
9842 msgs.push(LayoutDebugMessage::info(
9843 "No justification needed (line is full or invalid).".to_string(),
9844 ));
9845 }
9846 return items;
9847 }
9848
9849 let extra_space = available_width - total_width;
9850 if let Some(msgs) = debug_messages {
9851 msgs.push(LayoutDebugMessage::info(format!(
9852 "Extra space to fill: {extra_space}"
9853 )));
9854 }
9855
9856 let font_info = items.iter().find_map(|item| {
9857 if let ShapedItem::Cluster(c) = item {
9858 if let Some(glyph) = c.glyphs.first() {
9859 if glyph.script == Script::Arabic {
9860 // Look up font from hash
9861 if let Some(font) = fonts.get_by_hash(glyph.font_hash) {
9862 return Some((
9863 font.clone(),
9864 glyph.font_hash,
9865 glyph.font_metrics,
9866 glyph.style.clone(),
9867 ));
9868 }
9869 }
9870 }
9871 }
9872 None
9873 });
9874
9875 let (font, font_hash, font_metrics, style) = if let Some(info) = font_info {
9876 if let Some(msgs) = debug_messages {
9877 msgs.push(LayoutDebugMessage::info(
9878 "Found Arabic font for kashida.".to_string(),
9879 ));
9880 }
9881 info
9882 } else {
9883 if let Some(msgs) = debug_messages {
9884 msgs.push(LayoutDebugMessage::info(
9885 "No Arabic font found on line. Cannot insert kashidas.".to_string(),
9886 ));
9887 }
9888 return items;
9889 };
9890
9891 let (kashida_glyph_id, kashida_advance) =
9892 match font.get_kashida_glyph_and_advance(style.font_size_px) {
9893 Some((id, adv)) if adv > 0.0 => {
9894 if let Some(msgs) = debug_messages {
9895 msgs.push(LayoutDebugMessage::info(format!(
9896 "Font provides kashida glyph with advance {adv}"
9897 )));
9898 }
9899 (id, adv)
9900 }
9901 _ => {
9902 if let Some(msgs) = debug_messages {
9903 msgs.push(LayoutDebugMessage::info(
9904 "Font does not support kashida justification.".to_string(),
9905 ));
9906 }
9907 return items;
9908 }
9909 };
9910
9911 let opportunity_indices: Vec<usize> = items
9912 .windows(2)
9913 .enumerate()
9914 .filter_map(|(i, window)| {
9915 if let (ShapedItem::Cluster(cur), ShapedItem::Cluster(next)) = (&window[0], &window[1])
9916 {
9917 if is_arabic_cluster(cur)
9918 && is_arabic_cluster(next)
9919 && !is_word_separator(&window[1])
9920 {
9921 return Some(i + 1);
9922 }
9923 }
9924 None
9925 })
9926 .collect();
9927
9928 if let Some(msgs) = debug_messages {
9929 msgs.push(LayoutDebugMessage::info(format!(
9930 "Found {} kashida insertion opportunities at indices: {:?}",
9931 opportunity_indices.len(),
9932 opportunity_indices
9933 )));
9934 }
9935
9936 if opportunity_indices.is_empty() {
9937 if let Some(msgs) = debug_messages {
9938 msgs.push(LayoutDebugMessage::info(
9939 "No opportunities found. Exiting.".to_string(),
9940 ));
9941 }
9942 return items;
9943 }
9944
9945 let num_kashidas_to_insert = (extra_space / kashida_advance).floor() as usize;
9946 if let Some(msgs) = debug_messages {
9947 msgs.push(LayoutDebugMessage::info(format!(
9948 "Calculated number of kashidas to insert: {num_kashidas_to_insert}"
9949 )));
9950 }
9951
9952 if num_kashidas_to_insert == 0 {
9953 return items;
9954 }
9955
9956 let kashidas_per_point = num_kashidas_to_insert / opportunity_indices.len();
9957 let mut remainder = num_kashidas_to_insert % opportunity_indices.len();
9958 if let Some(msgs) = debug_messages {
9959 msgs.push(LayoutDebugMessage::info(format!(
9960 "Distributing kashidas: {kashidas_per_point} per point, with {remainder} remainder."
9961 )));
9962 }
9963
9964 let kashida_item = {
9965 /* ... as before ... */
9966 let kashida_glyph = ShapedGlyph {
9967 kind: GlyphKind::Kashida {
9968 width: kashida_advance,
9969 },
9970 glyph_id: kashida_glyph_id,
9971 font_hash,
9972 font_metrics,
9973 style: style.clone(),
9974 script: Script::Arabic,
9975 advance: kashida_advance,
9976 kerning: 0.0,
9977 cluster_offset: 0,
9978 offset: Point::default(),
9979 vertical_advance: 0.0,
9980 vertical_offset: Point::default(),
9981 };
9982 ShapedItem::Cluster(ShapedCluster {
9983 text: "\u{0640}".to_string(),
9984 source_cluster_id: GraphemeClusterId {
9985 source_run: u32::MAX,
9986 start_byte_in_run: u32::MAX,
9987 },
9988 source_content_index: ContentIndex {
9989 run_index: u32::MAX,
9990 item_index: u32::MAX,
9991 },
9992 source_node_id: None, // Kashida is generated, not from DOM
9993 glyphs: smallvec![kashida_glyph],
9994 advance: kashida_advance,
9995 direction: BidiDirection::Ltr,
9996 style,
9997 marker_position_outside: None,
9998 is_first_fragment: true,
9999 is_last_fragment: true,
10000 })
10001 };
10002
10003 let mut new_items = Vec::with_capacity(items.len() + num_kashidas_to_insert);
10004 let mut last_copy_idx = 0;
10005 for &point in &opportunity_indices {
10006 new_items.extend_from_slice(&items[last_copy_idx..point]);
10007 let mut num_to_insert = kashidas_per_point;
10008 if remainder > 0 {
10009 num_to_insert += 1;
10010 remainder -= 1;
10011 }
10012 for _ in 0..num_to_insert {
10013 new_items.push(kashida_item.clone());
10014 }
10015 last_copy_idx = point;
10016 }
10017 new_items.extend_from_slice(&items[last_copy_idx..]);
10018
10019 if let Some(msgs) = debug_messages {
10020 msgs.push(LayoutDebugMessage::info(format!(
10021 "--- Exiting justify_kashida_and_rebuild, new item count: {} ---",
10022 new_items.len()
10023 )));
10024 }
10025 new_items
10026}
10027
10028/// Helper to determine if a cluster belongs to the Arabic script.
10029fn is_arabic_cluster(cluster: &ShapedCluster) -> bool {
10030 // A cluster is considered Arabic if its first non-NotDef glyph is from the Arabic script.
10031 // This is a robust heuristic for mixed-script lines.
10032 cluster.glyphs.iter().any(|g| g.script == Script::Arabic)
10033}
10034
10035/// Helper to identify if an item is a word separator (like a space).
10036fn measure_trailing_whitespace(items: &[ShapedItem], is_vertical: bool) -> f32 {
10037 let mut trailing_ws = 0.0;
10038 for item in items.iter().rev() {
10039 if is_collapsible_whitespace(item) {
10040 trailing_ws += get_item_measure(item, is_vertical);
10041 } else {
10042 break;
10043 }
10044 }
10045 trailing_ws
10046}
10047
10048/// Returns true if the item is collapsible whitespace per CSS Text 3 §4.1.2 Phase II.
10049///
10050/// This is used for stripping leading/trailing whitespace at line edges —
10051/// distinct from `is_word_separator` which is for word-spacing per §7.1.
10052#[must_use] pub fn is_collapsible_whitespace(item: &ShapedItem) -> bool {
10053 if let ShapedItem::Cluster(c) = item {
10054 c.text.chars().all(|ch| matches!(ch,
10055 ' ' | '\t' | '\u{1680}' // Ogham space mark (collapsible per spec)
10056 ))
10057 } else {
10058 false
10059 }
10060}
10061
10062// +spec:text-alignment-spacing:456643 - cursive scripts do not admit letter-spacing gaps
10063/// Returns true if the cluster's first character belongs to a cursive script
10064/// (Arabic, Syriac, Mongolian, N'Ko, Mandaic, Phags Pa, Hanifi Rohingya)
10065/// per CSS Text 3 Appendix D.
10066///
10067/// These scripts should not have letter-spacing applied.
10068pub fn is_cursive_script_cluster(c: &ShapedCluster) -> bool {
10069 c.text.chars().next().is_some_and(is_cursive_script_char)
10070}
10071
10072fn is_cursive_script_char(ch: char) -> bool {
10073 let cp = ch as u32;
10074 // Arabic (U+0600–U+06FF, U+0750–U+077F, U+08A0–U+08FF, U+FB50–U+FDFF, U+FE70–U+FEFF)
10075 if (0x0600..=0x06FF).contains(&cp) { return true; }
10076 if (0x0750..=0x077F).contains(&cp) { return true; }
10077 if (0x08A0..=0x08FF).contains(&cp) { return true; }
10078 if (0xFB50..=0xFDFF).contains(&cp) { return true; }
10079 if (0xFE70..=0xFEFF).contains(&cp) { return true; }
10080 // Syriac (U+0700–U+074F)
10081 if (0x0700..=0x074F).contains(&cp) { return true; }
10082 // Mongolian (U+1800–U+18AF)
10083 if (0x1800..=0x18AF).contains(&cp) { return true; }
10084 // N'Ko (U+07C0–U+07FF)
10085 if (0x07C0..=0x07FF).contains(&cp) { return true; }
10086 // Mandaic (U+0840–U+085F)
10087 if (0x0840..=0x085F).contains(&cp) { return true; }
10088 // Phags Pa (U+A840–U+A87F)
10089 if (0xA840..=0xA87F).contains(&cp) { return true; }
10090 // Hanifi Rohingya (U+10D00–U+10D3F)
10091 if (0x10D00..=0x10D3F).contains(&cp) { return true; }
10092 false
10093}
10094
10095/// Word-segmentation predicate shared by word selection (double-click) and word
10096/// cursor motion (Ctrl/Alt+Arrow) so they agree on what a "word" is.
10097///
10098/// A word character is alphanumeric or underscore; everything else — whitespace
10099/// AND punctuation — is a word boundary. This is deliberately distinct from
10100/// [`is_word_separator`] (which classifies *spacing* characters for word-spacing
10101/// justification per CSS Text §7.1, and treats punctuation as non-separator).
10102/// Used by `selection::find_word_boundaries` and `UnifiedLayout::move_cursor_to_*_word`.
10103pub(crate) fn is_word_char(ch: char) -> bool {
10104 ch.is_alphanumeric() || ch == '_'
10105}
10106
10107/// True when a shaped cluster is a word-segmentation boundary (whitespace or
10108/// punctuation), i.e. it contains no word characters. Keeps cursor word-motion
10109/// consistent with `selection::find_word_boundaries`.
10110fn cluster_is_word_boundary(cluster: &ShapedCluster) -> bool {
10111 !cluster.text.chars().any(is_word_char)
10112}
10113
10114// exclude punctuation and fixed-width spaces (U+3000, U+2000..U+200A)
10115pub fn is_word_separator(item: &ShapedItem) -> bool {
10116 if let ShapedItem::Cluster(c) = item {
10117 c.text.chars().any(is_word_separator_char)
10118 } else {
10119 false
10120 }
10121}
10122
10123// +spec:margin-collapsing:6706c1 - fixed-width spaces (U+2000–U+200A, U+3000) excluded from word separators
10124/// Returns true if the character is a word-separator character per CSS Text §7.1.
10125/// Punctuation and fixed-width spaces (U+3000, U+2000 through U+200A) are NOT
10126/// word-separator characters even though they may visually separate words.
10127// +spec:text-alignment-spacing:3e0655 - word-separator characters for word-spacing
10128#[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
10129const fn is_word_separator_char(c: char) -> bool {
10130 match c {
10131 // Standard ASCII space
10132 '\u{0020}' => true,
10133 // NO-BREAK SPACE
10134 '\u{00A0}' => true,
10135 // OGHAM SPACE MARK
10136 '\u{1680}' => true,
10137 // ETHIOPIC WORDSPACE (spec §7.1)
10138 '\u{1361}' => true,
10139 // Fixed-width spaces: NOT word separators per spec
10140 '\u{2000}'..='\u{200A}' => false,
10141 // NARROW NO-BREAK SPACE
10142 '\u{202F}' => true,
10143 // MEDIUM MATHEMATICAL SPACE
10144 '\u{205F}' => true,
10145 // IDEOGRAPHIC SPACE: NOT a word separator per spec
10146 '\u{3000}' => false,
10147 // AEGEAN WORD SEPARATOR LINE (spec §7.1)
10148 '\u{10100}' => true,
10149 // AEGEAN WORD SEPARATOR DOT (spec §7.1)
10150 '\u{10101}' => true,
10151 // UGARITIC WORD DIVIDER (spec §7.1)
10152 '\u{1039F}' => true,
10153 // PHOENICIAN WORD SEPARATOR (spec §7.1)
10154 '\u{1091F}' => true,
10155 // Other Unicode whitespace not listed above
10156 _ => false,
10157 }
10158}
10159
10160/// Helper to identify if an item is a zero-width space (U+200B),
10161/// which provides a soft wrap opportunity with no visible width.
10162///
10163/// Used in scripts like Thai, Lao, and Khmer that don't use spaces between words.
10164// +spec:line-breaking:fd3164 - U+200B as explicit word delimiter for scripts without space-separated words
10165#[must_use] pub fn is_zero_width_space(item: &ShapedItem) -> bool {
10166 if let ShapedItem::Cluster(c) = item {
10167 c.text.contains('\u{200B}')
10168 } else {
10169 false
10170 }
10171}
10172
10173/// Helper to identify if space can be added after an item.
10174fn can_justify_after(item: &ShapedItem) -> bool {
10175 if let ShapedItem::Cluster(c) = item {
10176 c.text.chars().last().is_some_and(|g| {
10177 !g.is_whitespace() && classify_character(g as u32) != CharacterClass::Combining
10178 })
10179 } else {
10180 // Per CSS 2.2 §9.4.2, justification must NOT stretch inline-table and
10181 // inline-block boxes. Object items represent these atomic inline-level
10182 // boxes, so we return false to prevent adding justification space after them.
10183 false
10184 }
10185}
10186
10187// +spec:font-metrics:b8eb97 - Script group classification for justification/letter-spacing behavior
10188/// Classifies a character for layout purposes (e.g., justification behavior).
10189/// Copied from `mod.rs`.
10190#[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
10191const fn classify_character(codepoint: u32) -> CharacterClass {
10192 match codepoint {
10193 0x0020 | 0x00A0 | 0x3000 => CharacterClass::Space,
10194 0x0021..=0x002F | 0x003A..=0x0040 | 0x005B..=0x0060 | 0x007B..=0x007E => {
10195 CharacterClass::Punctuation
10196 }
10197 0x4E00..=0x9FFF | 0x3400..=0x4DBF => CharacterClass::Ideograph,
10198 0x0300..=0x036F | 0x1AB0..=0x1AFF => CharacterClass::Combining,
10199 // Mongolian script range
10200 0x1800..=0x18AF => CharacterClass::Letter,
10201 _ => CharacterClass::Letter,
10202 }
10203}
10204
10205/// Helper to get the primary measure (width or height) of a shaped item.
10206#[must_use] pub fn get_item_measure(item: &ShapedItem, is_vertical: bool) -> f32 {
10207 match item {
10208 ShapedItem::Cluster(c) => {
10209 // Total width = base advance + kerning adjustments
10210 // Kerning is stored separately in glyphs for inspection, but the total
10211 // cluster width must include it for correct layout positioning
10212 let total_kerning: f32 = c.glyphs.iter().map(|g| g.kerning).sum();
10213 c.advance + total_kerning
10214 }
10215 ShapedItem::Object { bounds, .. }
10216 | ShapedItem::CombinedBlock { bounds, .. }
10217 | ShapedItem::Tab { bounds, .. } => {
10218 if is_vertical {
10219 bounds.height
10220 } else {
10221 bounds.width
10222 }
10223 }
10224 ShapedItem::Break { .. } => 0.0,
10225 }
10226}
10227
10228/// Like [`get_item_measure`] but ALSO includes the per-cluster letter-spacing and
10229/// per-separator word-spacing that `position_one_line` adds after each cluster.
10230///
10231/// Line breaking and center/right alignment must measure the SAME width the text is
10232/// finally positioned at; `get_item_measure` alone omits letter/word-spacing, so a run
10233/// that "just fits" without spacing overflows its box (or mis-aligns) once the spacing
10234/// is applied. Selection/caret geometry must NOT include the trailing spacing, so those
10235/// callers keep using the bare `get_item_measure`.
10236#[must_use]
10237pub fn get_item_measure_with_spacing(item: &ShapedItem, is_vertical: bool) -> f32 {
10238 let base = get_item_measure(item, is_vertical);
10239 if let ShapedItem::Cluster(c) = item {
10240 let mut extra = 0.0;
10241 if !is_cursive_script_cluster(c) {
10242 extra += c.style.letter_spacing.resolve_px(c.style.font_size_px);
10243 }
10244 if is_word_separator(item) {
10245 extra += c.style.word_spacing.resolve_px(c.style.font_size_px);
10246 }
10247 base + extra
10248 } else {
10249 base
10250 }
10251}
10252
10253/// Calculates the available horizontal segments for a line at a given vertical position,
10254/// considering both shape boundaries and exclusions.
10255#[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
10256fn get_line_constraints(
10257 line_y: f32,
10258 line_height: f32,
10259 constraints: &UnifiedConstraints,
10260 debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
10261) -> LineConstraints {
10262 if let Some(msgs) = debug_messages {
10263 msgs.push(LayoutDebugMessage::info(format!(
10264 "\n--- Entering get_line_constraints for y={line_y} ---"
10265 )));
10266 }
10267
10268 let mut available_segments = Vec::new();
10269 if constraints.shape_boundaries.is_empty() {
10270 // The segment_width is determined by available_width, NOT by TextWrap.
10271 // TextWrap::NoWrap only affects whether the LineBreaker can insert soft breaks,
10272 // it should NOT override a definite width constraint from CSS.
10273 // +spec:overflow:b06c3e - text overflows when wrapping is prevented (e.g. white-space: nowrap)
10274 // CSS Text Level 3: For 'white-space: pre/nowrap', text overflows horizontally
10275 // if it doesn't fit, rather than expanding the container.
10276 //
10277 // For MinContent/MaxContent intrinsic sizing: use a large value to let text
10278 // lay out fully. The line breaker handles min-content by breaking at word
10279 // boundaries. The actual content width is measured from the laid-out lines.
10280 let segment_width = match constraints.available_width {
10281 AvailableSpace::Definite(w) => w, // Respect definite width from CSS
10282 AvailableSpace::MaxContent => f32::MAX / 2.0, // For intrinsic max-content sizing
10283 AvailableSpace::MinContent => f32::MAX / 2.0, // For intrinsic min-content sizing
10284 };
10285 // Note: TextWrap::NoWrap is handled by the LineBreaker in break_one_line()
10286 // to prevent soft wraps. The text will simply overflow if it exceeds segment_width.
10287 available_segments.push(LineSegment {
10288 start_x: 0.0,
10289 width: segment_width,
10290 priority: 0,
10291 });
10292 } else {
10293 // ... complex boundary logic ...
10294 }
10295
10296 if let Some(msgs) = debug_messages {
10297 msgs.push(LayoutDebugMessage::info(format!(
10298 "Initial available segments: {available_segments:?}"
10299 )));
10300 }
10301
10302 for (idx, exclusion) in constraints.shape_exclusions.iter().enumerate() {
10303 if let Some(msgs) = debug_messages {
10304 msgs.push(LayoutDebugMessage::info(format!(
10305 "Applying exclusion #{idx}: {exclusion:?}"
10306 )));
10307 }
10308 let exclusion_spans =
10309 get_shape_horizontal_spans(exclusion, line_y, line_height);
10310 if let Some(msgs) = debug_messages {
10311 msgs.push(LayoutDebugMessage::info(format!(
10312 " Exclusion spans at y={line_y}: {exclusion_spans:?}"
10313 )));
10314 }
10315
10316 if exclusion_spans.is_empty() {
10317 continue;
10318 }
10319
10320 let mut next_segments = Vec::new();
10321 for (excl_start, excl_end) in exclusion_spans {
10322 for segment in &available_segments {
10323 let seg_start = segment.start_x;
10324 let seg_end = segment.start_x + segment.width;
10325
10326 // Create new segments by subtracting the exclusion
10327 if seg_end > excl_start && seg_start < excl_end {
10328 if seg_start < excl_start {
10329 // Left part
10330 next_segments.push(LineSegment {
10331 start_x: seg_start,
10332 width: excl_start - seg_start,
10333 priority: segment.priority,
10334 });
10335 }
10336 if seg_end > excl_end {
10337 // Right part
10338 next_segments.push(LineSegment {
10339 start_x: excl_end,
10340 width: seg_end - excl_end,
10341 priority: segment.priority,
10342 });
10343 }
10344 } else {
10345 next_segments.push(*segment); // No overlap
10346 }
10347 }
10348 available_segments = merge_segments(next_segments);
10349 next_segments = Vec::new();
10350 }
10351 if let Some(msgs) = debug_messages {
10352 msgs.push(LayoutDebugMessage::info(format!(
10353 " Segments after exclusion #{idx}: {available_segments:?}"
10354 )));
10355 }
10356 }
10357
10358 let total_width = available_segments.iter().map(|s| s.width).sum();
10359 if let Some(msgs) = debug_messages {
10360 msgs.push(LayoutDebugMessage::info(format!(
10361 "Final segments: {available_segments:?}, total available width: {total_width}"
10362 )));
10363 msgs.push(LayoutDebugMessage::info(
10364 "--- Exiting get_line_constraints ---".to_string(),
10365 ));
10366 }
10367
10368 LineConstraints {
10369 segments: available_segments,
10370 total_available: total_width,
10371 is_min_content: matches!(constraints.available_width, AvailableSpace::MinContent),
10372 }
10373}
10374
10375/// Flattens a parsed SVG multipolygon (from a CSS `path()` shape) into a flat list of
10376/// `PathSegment`s in absolute coordinates (offset by the reference box origin). Each ring
10377/// becomes a `MoveTo` + a run of `LineTo`s + `Close`; curve elements are sampled into line
10378/// segments (~one segment per 4px of arc length, capped) so the scanline intersection can
10379/// treat each subpath as a polygon.
10380// bounded curve-sampling geometry casts (step count / arc-length parameter / coords)
10381#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss)]
10382fn flatten_svg_to_path_segments(
10383 multipolygon: &azul_core::svg::SvgMultiPolygon,
10384 reference_box: Rect,
10385) -> Vec<PathSegment> {
10386 use azul_core::svg::SvgPathElement;
10387
10388 let mut out: Vec<PathSegment> = Vec::new();
10389
10390 for ring in multipolygon.rings.as_ref() {
10391 let elements = ring.items.as_ref();
10392 if elements.is_empty() {
10393 continue;
10394 }
10395 let start = elements[0].get_start();
10396 out.push(PathSegment::MoveTo(Point {
10397 x: reference_box.x + start.x,
10398 y: reference_box.y + start.y,
10399 }));
10400 for el in elements {
10401 match el {
10402 SvgPathElement::Line(l) => {
10403 out.push(PathSegment::LineTo(Point {
10404 x: reference_box.x + l.end.x,
10405 y: reference_box.y + l.end.y,
10406 }));
10407 }
10408 curve => {
10409 // Sample the curve by arc length into line segments.
10410 let len = curve.get_length();
10411 let steps = ((len / 4.0).ceil() as usize).clamp(1, 64);
10412 for i in 1..=steps {
10413 let offset = len * (i as f64) / (steps as f64);
10414 let t = curve.get_t_at_offset(offset);
10415 out.push(PathSegment::LineTo(Point {
10416 x: reference_box.x + curve.get_x_at_t(t) as f32,
10417 y: reference_box.y + curve.get_y_at_t(t) as f32,
10418 }));
10419 }
10420 }
10421 }
10422 }
10423 out.push(PathSegment::Close);
10424 }
10425
10426 out
10427}
10428
10429/// Computes horizontal line segments where a flattened `path()` shape (a set of
10430/// `MoveTo`/`LineTo`/`Close` subpaths) intersects a scanline at the given y range. Uses an
10431/// even-odd fill rule over the union of all subpaths so reversed rings (holes) carve out
10432/// space. Curves are assumed already flattened to `LineTo`s by `flatten_svg_to_path_segments`.
10433fn path_segments_line_intersection(
10434 segments: &[PathSegment],
10435 y: f32,
10436 line_height: f32,
10437) -> Vec<(f32, f32)> {
10438 let line_center_y = y + line_height / 2.0;
10439 let mut crossings: Vec<f32> = Vec::new();
10440
10441 // Walk the segments, reconstructing each subpath's vertices and intersecting its
10442 // (closing) edges with the scanline.
10443 let mut subpath: Vec<Point> = Vec::new();
10444 let flush = |subpath: &mut Vec<Point>, crossings: &mut Vec<f32>| {
10445 if subpath.len() >= 2 {
10446 for i in 0..subpath.len() {
10447 let p1 = subpath[i];
10448 let p2 = subpath[(i + 1) % subpath.len()];
10449 if (p2.y - p1.y).abs() < f32::EPSILON {
10450 continue;
10451 }
10452 let crosses = (p1.y <= line_center_y && p2.y > line_center_y)
10453 || (p1.y > line_center_y && p2.y <= line_center_y);
10454 if crosses {
10455 let t = (line_center_y - p1.y) / (p2.y - p1.y);
10456 crossings.push(t.mul_add(p2.x - p1.x, p1.x));
10457 }
10458 }
10459 }
10460 subpath.clear();
10461 };
10462
10463 for seg in segments {
10464 match seg {
10465 PathSegment::MoveTo(p) => {
10466 flush(&mut subpath, &mut crossings);
10467 subpath.push(*p);
10468 }
10469 PathSegment::LineTo(p) => subpath.push(*p),
10470 PathSegment::Close => flush(&mut subpath, &mut crossings),
10471 // CurveTo/QuadTo/Arc should have been flattened to LineTo already; sample the
10472 // end point as a fallback so an unflattened path still produces a polygon.
10473 PathSegment::CurveTo { end, .. } | PathSegment::QuadTo { end, .. } => {
10474 subpath.push(*end);
10475 }
10476 PathSegment::Arc { center, .. } => subpath.push(*center),
10477 }
10478 }
10479 flush(&mut subpath, &mut crossings);
10480
10481 crossings.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
10482 let mut spans = Vec::new();
10483 for chunk in crossings.chunks_exact(2) {
10484 if chunk[1] > chunk[0] {
10485 spans.push((chunk[0], chunk[1]));
10486 }
10487 }
10488 spans
10489}
10490
10491/// Helper function to get the horizontal spans of any shape at a given y-coordinate.
10492/// Returns a list of (`start_x`, `end_x`) tuples.
10493#[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
10494fn get_shape_horizontal_spans(
10495 shape: &ShapeBoundary,
10496 y: f32,
10497 line_height: f32,
10498) -> Vec<(f32, f32)> {
10499 match shape {
10500 ShapeBoundary::Rectangle(rect) => {
10501 // Check for any overlap between the line box [y, y + line_height]
10502 // and the rectangle's vertical span [rect.y, rect.y + rect.height].
10503 let line_start = y;
10504 let line_end = y + line_height;
10505 let rect_start = rect.y;
10506 let rect_end = rect.y + rect.height;
10507
10508 if line_start < rect_end && line_end > rect_start {
10509 vec![(rect.x, rect.x + rect.width)]
10510 } else {
10511 vec![]
10512 }
10513 }
10514 ShapeBoundary::Circle { center, radius } => {
10515 let line_center_y = y + line_height / 2.0;
10516 let dy = (line_center_y - center.y).abs();
10517 if dy <= *radius {
10518 let dx = (radius.powi(2) - dy.powi(2)).sqrt();
10519 vec![(center.x - dx, center.x + dx)]
10520 } else {
10521 vec![]
10522 }
10523 }
10524 ShapeBoundary::Ellipse { center, radii } => {
10525 let line_center_y = y + line_height / 2.0;
10526 let dy = line_center_y - center.y;
10527 if dy.abs() <= radii.height {
10528 // Formula: (x-h)^2/a^2 + (y-k)^2/b^2 = 1
10529 let y_term = dy / radii.height;
10530 let x_term_squared = 1.0 - y_term.powi(2);
10531 if x_term_squared >= 0.0 {
10532 let dx = radii.width * x_term_squared.sqrt();
10533 vec![(center.x - dx, center.x + dx)]
10534 } else {
10535 vec![]
10536 }
10537 } else {
10538 vec![]
10539 }
10540 }
10541 ShapeBoundary::Polygon { points } => {
10542 let segments = polygon_line_intersection(points, y, line_height);
10543 segments
10544 .iter()
10545 .map(|s| (s.start_x, s.start_x + s.width))
10546 .collect()
10547 }
10548 // Scanline intersection for `path()` shapes. `segments` is the flattened
10549 // (Close-terminated, curves pre-sampled) output of `flatten_svg_to_path_segments`;
10550 // intersect each subpath polygon with this scanline under an even-odd fill rule so
10551 // reversed rings (holes) carve out space.
10552 ShapeBoundary::Path { segments } => {
10553 path_segments_line_intersection(segments, y, line_height)
10554 }
10555 }
10556}
10557
10558/// Merges overlapping or adjacent line segments into larger ones.
10559fn merge_segments(mut segments: Vec<LineSegment>) -> Vec<LineSegment> {
10560 if segments.len() <= 1 {
10561 return segments;
10562 }
10563 segments.sort_by(|a, b| a.start_x.partial_cmp(&b.start_x).unwrap());
10564 let mut merged = vec![segments[0]];
10565 for next_seg in segments.iter().skip(1) {
10566 let last = merged.last_mut().unwrap();
10567 if next_seg.start_x <= last.start_x + last.width {
10568 let new_width = (next_seg.start_x + next_seg.width) - last.start_x;
10569 last.width = last.width.max(new_width);
10570 } else {
10571 merged.push(*next_seg);
10572 }
10573 }
10574 merged
10575}
10576
10577/// Computes horizontal line segments where a polygon intersects a scanline at the given y range.
10578#[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
10579fn polygon_line_intersection(
10580 points: &[Point],
10581 y: f32,
10582 line_height: f32,
10583) -> Vec<LineSegment> {
10584 if points.len() < 3 {
10585 return vec![];
10586 }
10587
10588 let line_center_y = y + line_height / 2.0;
10589 let mut intersections = Vec::new();
10590
10591 // Use winding number algorithm for robustness with complex polygons.
10592 for i in 0..points.len() {
10593 let p1 = points[i];
10594 let p2 = points[(i + 1) % points.len()];
10595
10596 // Skip horizontal edges as they don't intersect a horizontal scanline in a meaningful way.
10597 if (p2.y - p1.y).abs() < f32::EPSILON {
10598 continue;
10599 }
10600
10601 // Check if our horizontal scanline at `line_center_y` crosses this polygon edge.
10602 let crosses = (p1.y <= line_center_y && p2.y > line_center_y)
10603 || (p1.y > line_center_y && p2.y <= line_center_y);
10604
10605 if crosses {
10606 // Calculate intersection x-coordinate using linear interpolation.
10607 let t = (line_center_y - p1.y) / (p2.y - p1.y);
10608 let x = p1.x + t * (p2.x - p1.x);
10609 intersections.push(x);
10610 }
10611 }
10612
10613 // Sort intersections by x-coordinate to form spans.
10614 intersections.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
10615
10616 // Build segments from paired intersection points.
10617 let mut segments = Vec::new();
10618 for chunk in intersections.chunks_exact(2) {
10619 let start_x = chunk[0];
10620 let end_x = chunk[1];
10621 if end_x > start_x {
10622 segments.push(LineSegment {
10623 start_x,
10624 width: end_x - start_x,
10625 priority: 0,
10626 });
10627 }
10628 }
10629
10630 segments
10631}
10632
10633// ADDITION: A helper function to get a hyphenator.
10634/// Helper to get a hyphenator for a given language.
10635/// TODO: In a real app, this would be cached.
10636#[cfg(feature = "text_layout_hyphenation")]
10637fn get_hyphenator(language: HyphenationLanguage) -> Result<Standard, LayoutError> {
10638 Standard::from_embedded(language).map_err(|e| LayoutError::HyphenationError(e.to_string()))
10639}
10640
10641/// Stub when hyphenation is disabled - always returns an error
10642#[cfg(not(feature = "text_layout_hyphenation"))]
10643fn get_hyphenator(_language: Language) -> Result<Standard, LayoutError> {
10644 Err(LayoutError::HyphenationError("Hyphenation feature not enabled".to_string()))
10645}
10646
10647// +spec:inline-block:6e7dd9 - Non-tailorable Unicode line breaking controls take precedence over atomic inline rules (CSS-TEXT-3 recent changes, issue 8972)
10648
10649const fn is_break_suppressing_control(ch: char) -> bool {
10650 matches!(ch,
10651 '\u{200D}' | // ZERO WIDTH JOINER
10652 '\u{2060}' | // WORD JOINER
10653 '\u{FEFF}' // ZERO WIDTH NO-BREAK SPACE
10654 )
10655}
10656
10657const fn is_break_forcing_control(ch: char) -> bool {
10658 matches!(ch,
10659 '\u{200B}' | // ZERO WIDTH SPACE (already handled but included for completeness)
10660 '\u{2028}' | // LINE SEPARATOR
10661 '\u{2029}' // PARAGRAPH SEPARATOR
10662 )
10663}
10664
10665// +spec:line-breaking:495247 - CJK/syllabic writing systems allow breaks between typographic letter units with varying strictness
10666// §5.2 word-break: determines if a character is CJK ideograph/kana
10667const fn is_cjk_character(ch: char) -> bool {
10668 let cp = ch as u32;
10669 matches!(cp,
10670 // CJK Unified Ideographs
10671 0x4E00..=0x9FFF |
10672 // CJK Unified Ideographs Extension A
10673 0x3400..=0x4DBF |
10674 // CJK Unified Ideographs Extension B
10675 0x20000..=0x2A6DF |
10676 // CJK Compatibility Ideographs
10677 0xF900..=0xFAFF |
10678 // Hiragana
10679 0x3040..=0x309F |
10680 // Katakana
10681 0x30A0..=0x30FF |
10682 // Katakana Phonetic Extensions
10683 0x31F0..=0x31FF |
10684 // CJK Symbols and Punctuation
10685 0x3000..=0x303F |
10686 // Halfwidth and Fullwidth Forms
10687 0xFF00..=0xFFEF |
10688 // Hangul Syllables
10689 0xAC00..=0xD7AF
10690 )
10691}
10692
10693// §5.2 word-break: checks if a cluster contains CJK characters
10694fn is_cjk_cluster(cluster: &ShapedCluster) -> bool {
10695 cluster.text.chars().any(is_cjk_character)
10696}
10697
10698// +spec:line-breaking:e1fc9d - word-break normal/break-all/keep-all break opportunity rules
10699// +spec:line-breaking:73d5fe - word-break break-point determination for CJK and Latin text
10700// +spec:line-breaking:31ef1a - word-break property controls soft wrap opportunities between letters (NU/AL/AI/ID classes as letter units)
10701// +spec:line-breaking:798252 - word-break property affects break opportunities (normal/break-all/keep-all)
10702// +spec:line-breaking:8fed57 - word-break: break-all treats all clusters as break opportunities, keep-all suppresses CJK breaks
10703// +spec:line-breaking:e2b374 - word-break: normal (only at separators) vs break-all (between all letters incl. Ethiopic)
10704// +spec:overflow:53a97f - word-break (normal/break-all/keep-all) and line-break strictness rules
10705// +spec:line-breaking:1c830a - word-break: normal/break-all/keep-all break opportunity rules
10706// §5.2 word-break property: break opportunity logic
10707// +spec:line-breaking:a75147 - word-break property: normal (CJK breaks), break-all (every cluster), keep-all (suppress CJK breaks)
10708// +spec:line-breaking:65ab41 - word-break: normal/break-all/keep-all break opportunity rules
10709// +spec:line-breaking:7eca16 - U+200B ZERO WIDTH SPACE is always a break opportunity, even with keep-all
10710fn is_break_opportunity_with_word_break(item: &ShapedItem, word_break: WordBreak, hyphens: Hyphens) -> bool {
10711 // No-break spaces (UAX#14 class GL/WJ) are word separators for word-spacing
10712 // purposes but must NOT offer a soft-wrap opportunity. This is the segmentation
10713 // path used by BreakCursor::peek_next_unit, so it must suppress them the same way
10714 // the dedicated is_break_opportunity() does; otherwise "10\u{00A0}km" wrongly wraps.
10715 if let ShapedItem::Cluster(c) = item {
10716 if c.text
10717 .chars()
10718 .any(|ch| matches!(ch, '\u{00A0}' | '\u{202F}' | '\u{2060}' | '\u{FEFF}'))
10719 {
10720 return false;
10721 }
10722 }
10723 // Break after spaces or explicit break items (always, regardless of word-break).
10724 if is_word_separator(item) {
10725 return true;
10726 }
10727 if let ShapedItem::Break { .. } = item {
10728 return true;
10729 }
10730 // +spec:line-breaking:432d5b - hyphens property controls soft wrap opportunities via hyphenation
10731 // +spec:line-breaking:5a32a1 - soft hyphen (U+00AD) creates break opportunity; glyph styled per surrounding text properties
10732 // U+200B ZERO WIDTH SPACE is always a soft wrap opportunity regardless of word-break.
10733 // This allows authors to mark explicit wrap points (e.g. with <wbr> or ​)
10734 // even when using word-break: keep-all to suppress other breaks.
10735 if is_zero_width_space(item) {
10736 return true;
10737 }
10738 // only when hyphens != none. With hyphens:none, soft hyphens do not create break points.
10739 if hyphens != Hyphens::None {
10740 if let ShapedItem::Cluster(c) = item {
10741 if c.text.starts_with('\u{00AD}') {
10742 return true;
10743 }
10744 }
10745 }
10746
10747 // +spec:line-breaking:05e09a - U+002D HYPHEN-MINUS / U+2010 HYPHEN always create a
10748 // soft-wrap opportunity AFTER them (UAX#14 class HY/BA), independent of the hyphens
10749 // property (they are NOT hyphenation opportunities — no extra glyph is inserted).
10750 // U+002F SOLIDUS (UAX#14 class SY) likewise offers a break AFTER it (URLs/paths),
10751 // matching browser practice. Mirrors is_break_opportunity(); this predicate drives
10752 // the greedy BreakCursor path, which previously never broke after a plain hyphen/slash.
10753 if let ShapedItem::Cluster(c) = item {
10754 if c.text.ends_with('\u{002D}') || c.text.ends_with('\u{2010}') || c.text.ends_with('\u{002F}') {
10755 return true;
10756 }
10757 }
10758
10759 // +spec:line-breaking:2bbda0 - word-break does not affect soft wrap opportunities around punctuation
10760 match word_break {
10761 WordBreak::Normal => {
10762 // CJK characters are implicit break opportunities in normal mode.
10763 if let ShapedItem::Cluster(c) = item {
10764 if is_cjk_cluster(c) {
10765 return true;
10766 }
10767 }
10768 false
10769 }
10770 WordBreak::BreakAll => {
10771 // Every typographic letter unit is a break opportunity.
10772 if let ShapedItem::Cluster(_) = item {
10773 return true;
10774 }
10775 false
10776 }
10777 WordBreak::KeepAll => {
10778 // +spec:line-breaking:aa3044 - keep-all suppresses CJK (incl. Korean) inter-character breaks
10779 // Only break at spaces/hyphens (already handled above).
10780 false
10781 }
10782 }
10783}
10784
10785// +spec:line-breaking:db0289 - line-break strictness: anywhere allows soft wrap around every typographic character unit
10786// +spec:line-breaking:7d242b - line-break strictness levels: loose/normal/strict/anywhere with CJK punctuation rules
10787// +spec:line-breaking:67bfe8 - line-break strictness (auto/loose/normal/strict/anywhere) controls
10788// CSS Text Level 3 §5.3: Determines whether a break opportunity before a character is
10789// allowed based on the line-break strictness level. The spec defines:
10790// - strict: forbids breaks before small kana (class CJ), CJK hyphens, and certain punctuation
10791// - normal: allows breaks before small kana (CJ); allows CJK hyphen breaks for CJK writing systems
10792// - loose: additionally allows breaks before hyphens U+2010/U+2013 after ID-class chars
10793// - anywhere: allows soft wrap around every typographic character unit
10794#[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
10795const fn is_cjk_break_allowed_by_strictness(
10796 ch: char,
10797 _prev_ch: Option<char>,
10798 strictness: LineBreakStrictness,
10799) -> bool {
10800 match strictness {
10801 LineBreakStrictness::Anywhere => true,
10802 LineBreakStrictness::Loose => {
10803 // Loose allows breaks before hyphens U+2010, U+2013 when preceded by ID-class chars
10804 // Also allows breaks before small kana (CJ class) and CJK hyphens
10805 true
10806 }
10807 LineBreakStrictness::Normal | LineBreakStrictness::Auto => {
10808 // Normal forbids breaks before hyphens U+2010/U+2013 for non-CJK text
10809 // but allows breaks before small kana (CJ) and CJK hyphen-like chars
10810 // (〜 U+301C, ゠ U+30A0) for CJK writing systems
10811 match ch {
10812 '\u{2010}' | '\u{2013}' => false, // hyphens forbidden in normal
10813 _ => true,
10814 }
10815 }
10816 LineBreakStrictness::Strict => {
10817 // Strict forbids breaks before:
10818 // - Small kana and prolonged sound mark (Unicode line break class CJ)
10819 // - CJK hyphen-like characters: 〜 U+301C, ゠ U+30A0
10820 // - Hyphens: ‐ U+2010, – U+2013
10821 match ch {
10822 '\u{301C}' | '\u{30A0}' => false, // CJK hyphen-like
10823 '\u{2010}' | '\u{2013}' => false, // hyphens
10824 c if is_small_kana(c) => false,
10825 _ => true,
10826 }
10827 }
10828 }
10829}
10830
10831/// Returns true if the character is a Japanese small kana or Katakana-Hiragana prolonged sound mark
10832/// (Unicode line break class CJ). These are forbidden break points in strict line breaking.
10833const fn is_small_kana(ch: char) -> bool {
10834 matches!(ch,
10835 '\u{3041}' | // ぁ HIRAGANA LETTER SMALL A
10836 '\u{3043}' | // ぃ HIRAGANA LETTER SMALL I
10837 '\u{3045}' | // ぅ HIRAGANA LETTER SMALL U
10838 '\u{3047}' | // ぇ HIRAGANA LETTER SMALL E
10839 '\u{3049}' | // ぉ HIRAGANA LETTER SMALL O
10840 '\u{3063}' | // っ HIRAGANA LETTER SMALL TU
10841 '\u{3083}' | // ゃ HIRAGANA LETTER SMALL YA
10842 '\u{3085}' | // ゅ HIRAGANA LETTER SMALL YU
10843 '\u{3087}' | // ょ HIRAGANA LETTER SMALL YO
10844 '\u{308E}' | // ゎ HIRAGANA LETTER SMALL WA
10845 '\u{3095}' | // ゕ HIRAGANA LETTER SMALL KA
10846 '\u{3096}' | // ゖ HIRAGANA LETTER SMALL KE
10847 '\u{30A1}' | // ァ KATAKANA LETTER SMALL A
10848 '\u{30A3}' | // ィ KATAKANA LETTER SMALL I
10849 '\u{30A5}' | // ゥ KATAKANA LETTER SMALL U
10850 '\u{30A7}' | // ェ KATAKANA LETTER SMALL E
10851 '\u{30A9}' | // ォ KATAKANA LETTER SMALL O
10852 '\u{30C3}' | // ッ KATAKANA LETTER SMALL TU
10853 '\u{30E3}' | // ャ KATAKANA LETTER SMALL YA
10854 '\u{30E5}' | // ュ KATAKANA LETTER SMALL YU
10855 '\u{30E7}' | // ョ KATAKANA LETTER SMALL YO
10856 '\u{30EE}' | // ヮ KATAKANA LETTER SMALL WA
10857 '\u{30F5}' | // ヵ KATAKANA LETTER SMALL KA
10858 '\u{30F6}' | // ヶ KATAKANA LETTER SMALL KE
10859 '\u{30FC}' // ー KATAKANA-HIRAGANA PROLONGED SOUND MARK
10860 )
10861}
10862
10863// for every typographic character unit, disregarding GL/WJ/ZWJ line breaking classes
10864// replaced element or other atomic inline for web-compat
10865fn is_break_opportunity(item: &ShapedItem) -> bool {
10866 // Per CSS Text 3 §5.1: "there is a soft wrap opportunity before and
10867 // after each replaced element or other atomic inline"
10868 if matches!(item, ShapedItem::Object { .. } | ShapedItem::CombinedBlock { .. }) {
10869 return true;
10870 }
10871 // over atomic inline rules: break-forcing controls (ZWSP, LS, PS) create break opportunities
10872 // even adjacent to atomic inlines, while break-suppressing controls (WJ, ZWJ, ZWNBSP)
10873 // prevent breaks
10874 if let ShapedItem::Cluster(c) = item {
10875 // ZW (zero-width space U+200B) is always a break opportunity
10876 if c.text.contains('\u{200B}') {
10877 return true;
10878 }
10879 // Break-forcing Unicode controls (LS, PS) create break opportunities
10880 if c.text.chars().any(is_break_forcing_control) {
10881 return true;
10882 }
10883 // WJ (word joiner U+2060), ZWJ (U+200D), and GL (NBSP U+00A0) suppress breaks
10884 if c.text.chars().any(|ch| matches!(ch, '\u{2060}' | '\u{200D}' | '\u{00A0}')) {
10885 return false;
10886 }
10887 // +spec:line-breaking:05e09a - U+002D/U+2010 always create soft wrap opportunities regardless of hyphens property
10888 // are always visible and create a soft wrap opportunity after them, but are NOT
10889 // hyphenation opportunities (no extra glyph is inserted at the break).
10890 if c.text.ends_with('\u{002D}') || c.text.ends_with('\u{2010}') {
10891 return true;
10892 }
10893 }
10894 is_break_opportunity_with_word_break(item, WordBreak::Normal, Hyphens::Manual)
10895}
10896
10897// A cursor to manage the state of the line breaking process.
10898// This allows us to handle items that are partially consumed by hyphenation.
10899#[derive(Debug)]
10900pub struct BreakCursor<'a> {
10901 /// A reference to the complete list of shaped items.
10902 pub items: &'a [ShapedItem],
10903 /// The index of the next *full* item to be processed from the `items` slice.
10904 pub next_item_index: usize,
10905 /// The remainder of an item that was split by hyphenation on the previous line.
10906 /// This will be the very first piece of content considered for the next line.
10907 pub partial_remainder: Vec<ShapedItem>,
10908 // §5.2 word-break property stored on cursor
10909 pub word_break: WordBreak,
10910 pub hyphens: Hyphens,
10911 pub line_break: LineBreakStrictness,
10912}
10913
10914impl<'a> BreakCursor<'a> {
10915 #[must_use] pub fn new(items: &'a [ShapedItem]) -> Self {
10916 Self {
10917 items,
10918 next_item_index: 0,
10919 partial_remainder: Vec::new(),
10920 word_break: WordBreak::Normal,
10921 hyphens: Hyphens::default(),
10922 line_break: LineBreakStrictness::default(),
10923 }
10924 }
10925
10926 #[must_use] pub fn with_word_break(items: &'a [ShapedItem], word_break: WordBreak) -> Self {
10927 Self {
10928 items,
10929 next_item_index: 0,
10930 partial_remainder: Vec::new(),
10931 word_break,
10932 hyphens: Hyphens::default(),
10933 line_break: LineBreakStrictness::default(),
10934 }
10935 }
10936
10937 /// Checks if the cursor is at the very beginning of the content stream.
10938 #[must_use] pub const fn is_at_start(&self) -> bool {
10939 self.next_item_index == 0 && self.partial_remainder.is_empty()
10940 }
10941
10942 /// Consumes the cursor and returns all remaining items as a `Vec`.
10943 pub fn drain_remaining(&mut self) -> Vec<ShapedItem> {
10944 let mut remaining = std::mem::take(&mut self.partial_remainder);
10945 if self.next_item_index < self.items.len() {
10946 remaining.extend_from_slice(&self.items[self.next_item_index..]);
10947 }
10948 self.next_item_index = self.items.len();
10949 remaining
10950 }
10951
10952 /// Checks if all content, including any partial remainders, has been processed.
10953 #[must_use] pub const fn is_done(&self) -> bool {
10954 self.next_item_index >= self.items.len() && self.partial_remainder.is_empty()
10955 }
10956
10957 /// Consumes a number of items from the cursor's stream.
10958 pub fn consume(&mut self, count: usize) {
10959 if count == 0 {
10960 return;
10961 }
10962
10963 let remainder_len = self.partial_remainder.len();
10964 if count <= remainder_len {
10965 // Consuming only from the remainder.
10966 self.partial_remainder.drain(..count);
10967 } else {
10968 // Consuming all of the remainder and some from the main list.
10969 let from_main_list = count - remainder_len;
10970 self.partial_remainder.clear();
10971 self.next_item_index += from_main_list;
10972 }
10973 }
10974
10975 /// Looks ahead and returns the next "unbreakable" unit of content.
10976 /// This is typically a word (a series of non-space clusters) followed by a
10977 /// space, or just a single space if that's next.
10978 /// The definition of "unbreakable unit" depends on the word-break property.
10979 // a single typographic character unit (every character is a soft wrap opportunity), including
10980 // punctuation and preserved white spaces; currently handled via peek_next_single_item
10981 pub fn peek_next_unit(&self) -> Vec<ShapedItem> {
10982 let mut unit = Vec::new();
10983 let mut source_items = self.partial_remainder.clone();
10984 source_items.extend_from_slice(&self.items[self.next_item_index..]);
10985
10986 if source_items.is_empty() {
10987 return unit;
10988 }
10989
10990 // If the first item is a break opportunity (like a space), it's a unit on its own.
10991 if is_break_opportunity_with_word_break(&source_items[0], self.word_break, self.hyphens) {
10992 unit.push(source_items[0].clone());
10993 return unit;
10994 }
10995
10996 // Otherwise, collect all items until the next break opportunity.
10997 // For break-all: each cluster is its own unit.
10998 // For keep-all: CJK sequences are NOT break opportunities.
10999 // For normal: CJK characters are individual break opportunities.
11000 // glue items together: if the last cluster ends with a break-suppressing control,
11001 // the next item cannot be separated from it.
11002 let mut suppress_next_break = false;
11003 for (i, item) in source_items.iter().enumerate() {
11004 // Also suppress break if this item starts with a break-suppressing control
11005 // (WJ/ZWJ/ZWNBSP suppress breaks on both sides per Unicode line breaking)
11006 let starts_with_suppress = if let ShapedItem::Cluster(c) = item {
11007 c.text.chars().next().is_some_and(is_break_suppressing_control)
11008 } else {
11009 false
11010 };
11011 // If the item is a CJK cluster, check if the break is allowed by strictness
11012 let cjk_strictness_suppressed = if let ShapedItem::Cluster(c) = item {
11013 c.text.chars().next().is_some_and(|ch| {
11014 !is_cjk_break_allowed_by_strictness(ch, None, self.line_break)
11015 })
11016 } else {
11017 false
11018 };
11019 if i > 0 && !suppress_next_break && !starts_with_suppress && !cjk_strictness_suppressed && is_break_opportunity_with_word_break(item, self.word_break, self.hyphens) {
11020 break;
11021 }
11022 suppress_next_break = false;
11023 unit.push(item.clone());
11024
11025 // Check if this item ends with a break-suppressing control character
11026 if let ShapedItem::Cluster(c) = item {
11027 if let Some(last_ch) = c.text.chars().last() {
11028 if is_break_suppressing_control(last_ch) {
11029 suppress_next_break = true;
11030 }
11031 }
11032 }
11033
11034 // For break-all, each non-space cluster is a unit on its own
11035 if self.word_break == WordBreak::BreakAll {
11036 if let ShapedItem::Cluster(_) = item {
11037 break;
11038 }
11039 }
11040 }
11041 unit
11042 }
11043
11044 #[must_use] pub fn peek_next_single_item(&self) -> Vec<ShapedItem> {
11045 if !self.partial_remainder.is_empty() {
11046 return vec![self.partial_remainder[0].clone()];
11047 }
11048 if self.next_item_index < self.items.len() {
11049 return vec![self.items[self.next_item_index].clone()];
11050 }
11051 Vec::new()
11052 }
11053}
11054
11055// A structured result from a hyphenation attempt.
11056struct HyphenationResult {
11057 /// The items that fit on the current line, including the new hyphen.
11058 line_part: Vec<ShapedItem>,
11059 /// The remainder of the split item to be carried over to the next line.
11060 remainder_part: Vec<ShapedItem>,
11061}
11062
11063fn perform_bidi_analysis<'a>(
11064 styled_runs: &'a [TextRunInfo<'_>],
11065 full_text: &'a str,
11066 force_lang: Option<Language>,
11067) -> (Vec<VisualRun<'a>>, BidiDirection) {
11068 if full_text.is_empty() {
11069 return (Vec::new(), BidiDirection::Ltr);
11070 }
11071
11072 let bidi_info = BidiInfo::new(full_text, None);
11073 let para = &bidi_info.paragraphs[0];
11074 let base_direction = if para.level.is_rtl() {
11075 BidiDirection::Rtl
11076 } else {
11077 BidiDirection::Ltr
11078 };
11079
11080 // Create a map from each byte index to its original styled run.
11081 let mut byte_to_run_index: Vec<usize> = vec![0; full_text.len()];
11082 for (run_idx, run) in styled_runs.iter().enumerate() {
11083 let start = run.logical_start;
11084 let end = start + run.text.len();
11085 for slot in &mut byte_to_run_index[start..end] {
11086 *slot = run_idx;
11087 }
11088 }
11089
11090 let mut final_visual_runs = Vec::new();
11091 let (levels, visual_run_ranges) = bidi_info.visual_runs(para, para.range.clone());
11092
11093 for range in visual_run_ranges {
11094 let bidi_level = levels[range.start];
11095 let mut sub_run_start = range.start;
11096
11097 // Iterate through the bytes of the visual run to detect style changes.
11098 for i in (range.start + 1)..range.end {
11099 if byte_to_run_index[i] != byte_to_run_index[sub_run_start] {
11100 // Style boundary found. Finalize the previous sub-run.
11101 let original_run_idx = byte_to_run_index[sub_run_start];
11102 let script = crate::text3::script::detect_script(&full_text[sub_run_start..i])
11103 .unwrap_or(Script::Latin);
11104 final_visual_runs.push(VisualRun {
11105 text_slice: &full_text[sub_run_start..i],
11106 style: styled_runs[original_run_idx].style.clone(),
11107 logical_start_byte: sub_run_start,
11108 bidi_level: BidiLevel::new(bidi_level.number()),
11109 language: force_lang.unwrap_or_else(|| {
11110 script_to_language(
11111 script,
11112 &full_text[sub_run_start..i],
11113 )
11114 }),
11115 script,
11116 });
11117 // Start a new sub-run.
11118 sub_run_start = i;
11119 }
11120 }
11121
11122 // Add the last sub-run (or the only one if no style change occurred).
11123 let original_run_idx = byte_to_run_index[sub_run_start];
11124 let script = crate::text3::script::detect_script(&full_text[sub_run_start..range.end])
11125 .unwrap_or(Script::Latin);
11126
11127 final_visual_runs.push(VisualRun {
11128 text_slice: &full_text[sub_run_start..range.end],
11129 style: styled_runs[original_run_idx].style.clone(),
11130 logical_start_byte: sub_run_start,
11131 bidi_level: BidiLevel::new(bidi_level.number()),
11132 script,
11133 language: force_lang.unwrap_or_else(|| {
11134 script_to_language(
11135 script,
11136 &full_text[sub_run_start..range.end],
11137 )
11138 }),
11139 });
11140 }
11141
11142 (final_visual_runs, base_direction)
11143}
11144
11145const fn get_justification_priority(class: CharacterClass) -> u8 {
11146 match class {
11147 CharacterClass::Space => 0,
11148 CharacterClass::Punctuation => 64,
11149 CharacterClass::Ideograph => 128,
11150 CharacterClass::Letter => 192,
11151 CharacterClass::Symbol => 224,
11152 CharacterClass::Combining => 255,
11153 }
11154}
11155
11156#[cfg(test)]
11157mod shape_outside_and_ruby_tests {
11158 use super::*;
11159 use azul_css::shape::{CssShape, ShapePath};
11160
11161 fn path_shape(d: &str) -> CssShape {
11162 CssShape::Path(ShapePath {
11163 data: d.into(),
11164 })
11165 }
11166
11167 // --- shape-outside: path() ----------------------------------------------
11168
11169 #[test]
11170 fn css_path_shape_builds_path_boundary_not_rect_fallback() {
11171 // A right triangle (0,0)-(100,0)-(0,100).
11172 let shape = path_shape("M 0 0 L 100 0 L 0 100 Z");
11173 let rbox = Rect { x: 0.0, y: 0.0, width: 100.0, height: 100.0 };
11174 let boundary = ShapeBoundary::from_css_shape(&shape, rbox, &mut None);
11175 match boundary {
11176 ShapeBoundary::Path { segments } => {
11177 assert!(!segments.is_empty(), "path() must flatten to real segments");
11178 assert!(matches!(segments[0], PathSegment::MoveTo(_)));
11179 assert!(segments.iter().any(|s| matches!(s, PathSegment::Close)));
11180 }
11181 other => panic!("expected ShapeBoundary::Path, got {other:?}"),
11182 }
11183 }
11184
11185 #[test]
11186 fn empty_or_garbage_path_falls_back_to_rectangle() {
11187 let rbox = Rect { x: 0.0, y: 0.0, width: 50.0, height: 50.0 };
11188 let boundary = ShapeBoundary::from_css_shape(&path_shape(" "), rbox, &mut None);
11189 assert!(matches!(boundary, ShapeBoundary::Rectangle(_)),
11190 "unparseable path() should fall back to the reference rectangle");
11191 }
11192
11193 #[test]
11194 fn path_triangle_narrows_line_box_per_scanline() {
11195 // Right triangle with the hypotenuse running (100,0) -> (0,100).
11196 // At scanline y, the shape spans x in [0, 100 - y]. So the available band
11197 // must NARROW as y increases — the proof that real path geometry (not a
11198 // full-width rect) drives the per-line exclusion.
11199 let shape = path_shape("M 0 0 L 100 0 L 0 100 Z");
11200 let rbox = Rect { x: 0.0, y: 0.0, width: 100.0, height: 100.0 };
11201 let boundary = ShapeBoundary::from_css_shape(&shape, rbox, &mut None);
11202
11203 let spans_top = get_shape_horizontal_spans(&boundary, 10.0, 1.0);
11204 let spans_bot = get_shape_horizontal_spans(&boundary, 80.0, 1.0);
11205
11206 assert_eq!(spans_top.len(), 1, "single span expected near the top");
11207 assert_eq!(spans_bot.len(), 1, "single span expected near the bottom");
11208
11209 let width_top = spans_top[0].1 - spans_top[0].0;
11210 let width_bot = spans_bot[0].1 - spans_bot[0].0;
11211
11212 // Geometry check: width ~= 100 - y (line center is y + 0.5).
11213 assert!((width_top - 89.5).abs() < 1.5, "top width {width_top} != ~89.5");
11214 assert!((width_bot - 19.5).abs() < 1.5, "bottom width {width_bot} != ~19.5");
11215 assert!(width_top > width_bot,
11216 "path() exclusion band must narrow with y ({width_top} !> {width_bot})");
11217
11218 // And it must differ from a plain full-width rectangle (which would be 0..100
11219 // at every scanline) — i.e. this is not the old rect/empty stub.
11220 assert!(width_bot < 50.0, "rect fallback would give full width here");
11221 }
11222
11223 #[test]
11224 fn path_with_hole_carves_out_interior_via_even_odd() {
11225 // Outer square 0..100 with an inner reversed square 30..70 (a hole). At a
11226 // scanline through the hole, even-odd fill yields two spans straddling the hole.
11227 let shape = path_shape(
11228 "M 0 0 L 100 0 L 100 100 L 0 100 Z \
11229 M 30 30 L 30 70 L 70 70 L 70 30 Z",
11230 );
11231 let rbox = Rect { x: 0.0, y: 0.0, width: 100.0, height: 100.0 };
11232 let boundary = ShapeBoundary::from_css_shape(&shape, rbox, &mut None);
11233 let spans = get_shape_horizontal_spans(&boundary, 50.0, 1.0);
11234 assert_eq!(spans.len(), 2, "hole should split the band into two spans: {spans:?}");
11235 }
11236
11237 // --- ruby ----------------------------------------------------------------
11238
11239 #[test]
11240 #[allow(clippy::float_cmp)] // exact, representable expected values
11241 fn ruby_annotation_font_scale_is_real_not_06_fudge() {
11242 // The annotation is sized at the used font-size of the ruby-text run, which the
11243 // UA stylesheet sets to 50% of the base — NOT a 0.6 per-character fudge.
11244 let base_font_size = 20.0_f32;
11245 let annotation_font_size = base_font_size * RUBY_ANNOTATION_FONT_SCALE;
11246 assert_eq!(annotation_font_size, 10.0);
11247 assert!((RUBY_ANNOTATION_FONT_SCALE - 0.6).abs() > f32::EPSILON,
11248 "annotation scale must not be the old 0.6 magic ratio");
11249 }
11250
11251 #[test]
11252 #[allow(clippy::float_cmp)] // exact, representable expected values
11253 fn ruby_box_reserves_max_width_and_stacks_annotation_above_base() {
11254 // Wider base, narrower annotation: reserved inline-size = base width.
11255 let (w, h) = ruby_reserved_box(80.0, 30.0, 24.0, 12.0);
11256 assert_eq!(w, 80.0, "reserved width is the wider of base/annotation");
11257 // Block-size stacks the annotation line above the base line => base reserves
11258 // vertical space for the annotation.
11259 assert_eq!(h, 36.0, "block-size = base line + annotation line");
11260 assert!(h > 24.0, "ruby box must reserve extra vertical space for the annotation");
11261
11262 // Narrower base, wider annotation: reserved inline-size = annotation width.
11263 let (w2, _) = ruby_reserved_box(20.0, 50.0, 24.0, 12.0);
11264 assert_eq!(w2, 50.0, "a long annotation widens the reserved box");
11265 }
11266}