Skip to main content

cvkg_runic_text/
lib.rs

1// CVKG Runic Text — Production text shaping, layout, and rasterization engine
2//
3// Features:
4//   - Font discovery via fontdb (system fonts + user fonts)
5//   - Text shaping via rustybuzz (OpenType shaping, ligatures, kerning)
6//   - BiDi support via unicode-bidi
7//   - Font fallback with glyph-level resolution
8//   - LRU shape cache with deterministic keys
9//   - Word wrapping, text alignment, line height modes
10//   - Selection rects, hit testing, cursor positioning
11//   - Text overflow modes (clip, ellipsis, visible, word-wrap)
12//   - OpenType features and variable font axes
13//   - TextStyle with weight, stretch, style, color, spacing, decorations
14#![allow(
15    clippy::too_many_arguments,
16    clippy::needless_range_loop,
17    clippy::ptr_arg
18)]
19
20use std::collections::HashMap;
21use std::sync::Arc;
22
23/// Shared test engine that loads only bundled fonts (no system fonts).
24/// Uses Arc for thread-safe sharing across parallel tests.
25#[allow(dead_code)]
26static TEST_ENGINE: std::sync::OnceLock<Arc<RunicTextEngine>> = std::sync::OnceLock::new();
27
28/// Get or create the shared test engine.
29pub fn test_engine() -> &'static Arc<RunicTextEngine> {
30    TEST_ENGINE.get_or_init(|| {
31        let mut engine = RunicTextEngine::new_light();
32        // Load bundled Jupiteroid font for tests
33        engine.load_font_data(include_bytes!("../Fonts/Jupiteroid.ttf").to_vec());
34        Arc::new(engine)
35    })
36}
37
38use fontdb::{Database, Family, Query, Source, Stretch, Style, Weight};
39use rustybuzz::{Direction, Feature, UnicodeBuffer};
40use swash::FontRef;
41use swash::scale::{Render, ScaleContext, Source as SwashSource};
42use unicode_bidi::BidiInfo;
43use unicode_segmentation::UnicodeSegmentation;
44
45// ── Constants ──────────────────────────────────────────────────────────────
46
47/// Default font size in pixels.
48pub const DEFAULT_FONT_SIZE: f32 = 16.0;
49
50/// Default line height multiplier.
51pub const DEFAULT_LINE_HEIGHT: f32 = 1.2;
52
53/// Maximum number of entries in the shape cache.
54const MAX_CACHE_SIZE: usize = 1024;
55
56// ── Error type ──────────────────────────────────────────────────────────────
57
58/// Errors that can occur during text shaping and layout.
59#[derive(Debug, Clone, PartialEq)]
60pub enum ShapingError {
61    /// No font could be found for the given text/style.
62    NoFontFound(String),
63    /// The font database returned an invalid font ID.
64    InvalidFontId,
65    /// Shaping produced no glyphs for non-empty input.
66    EmptyShape(String),
67    /// An embedded font data reference was invalid.
68    InvalidFontData,
69}
70
71impl std::fmt::Display for ShapingError {
72    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73        match self {
74            ShapingError::NoFontFound(s) => write!(f, "No font found for: {}", s),
75            ShapingError::InvalidFontId => write!(f, "Invalid font ID"),
76            ShapingError::EmptyShape(s) => write!(f, "Empty shaping result for: {}", s),
77            ShapingError::InvalidFontData => write!(f, "Invalid font data"),
78        }
79    }
80}
81
82impl std::error::Error for ShapingError {}
83
84// ── FontAxisInfo ─────────────────────────────────────────────────────────────
85
86/// Describes a single variable font axis.
87#[derive(Debug, Clone, PartialEq)]
88pub struct FontAxisInfo {
89    /// The 4-byte axis tag (e.g. `b"wght"`, `b"wdth"`, `b"ital"`).
90    pub tag: u32,
91    /// The axis tag as a human-readable string.
92    pub tag_string: String,
93    /// Minimum value for this axis.
94    pub min: f32,
95    /// Maximum value for this axis.
96    pub max: f32,
97    /// Default value for this axis.
98    pub default: f32,
99    /// Whether this axis is a standard registered axis.
100    pub is_standard: bool,
101}
102
103impl FontAxisInfo {
104    /// Get the standard name for known axes, or the raw tag string for custom axes.
105    pub fn display_name(&self) -> &str {
106        match &self.tag_string[..] {
107            "wght" => "Weight",
108            "wdth" => "Width",
109            "ital" => "Italic",
110            "slnt" => "Slant",
111            "opsz" => "Optical Size",
112            "GRAD" => "Grade",
113            "XTRA" => "X Tra Bold",
114            "XOPQ" => "X Opacity",
115            "YOPQ" => "Y Opacity",
116            "YTLC" => "Y Tall Cap Height",
117            "YTUC" => "Y Uppercase Height",
118            "YTAS" => "Y Tall Ascender",
119            "YTDE" => "Y Tall Descender",
120            "YTFI" => "Y Tall Figure Height",
121            _ => &self.tag_string,
122        }
123    }
124}
125
126// ── TextStyle ────────────────────────────────────────────────────────────────
127
128/// Text decoration flags.
129#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
130pub struct TextDecorations {
131    /// Underline.
132    pub underline: bool,
133    /// Strikethrough.
134    pub strikethrough: bool,
135    /// Overline.
136    pub overline: bool,
137}
138
139/// How line height is computed.
140#[derive(Debug, Clone, Copy, PartialEq)]
141pub enum LineHeight {
142    /// Multiple of font size (e.g. 1.2 = 120% of font size).
143    Multiple(f32),
144    /// Fixed pixel height.
145    Fixed(f32),
146}
147
148impl Default for LineHeight {
149    fn default() -> Self {
150        LineHeight::Multiple(DEFAULT_LINE_HEIGHT)
151    }
152}
153
154impl LineHeight {
155    /// Compute the line height in pixels for a given font size.
156    pub fn to_pixels(self, font_size: f32) -> f32 {
157        match self {
158            LineHeight::Multiple(m) => font_size * m,
159            LineHeight::Fixed(px) => px,
160        }
161    }
162}
163
164/// Text overflow handling mode.
165#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
166pub enum TextOverflow {
167    /// Clip text at the boundary.
168    Clip,
169    /// Show ellipsis when text overflows.
170    Ellipsis,
171    /// Let text overflow visibly.
172    Visible,
173    /// Wrap words that exceed the width.
174    #[default]
175    WordWrap,
176}
177
178/// Text alignment within a line.
179#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
180pub enum TextAlign {
181    /// Align to the start (left in LTR, right in RTL).
182    #[default]
183    Start,
184    /// Align to the end.
185    End,
186    /// Center within the available width.
187    Center,
188    /// Justify text (stretch to fill width - basic implementation).
189    Justify,
190}
191
192/// Glyph rasterization mode.
193#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
194pub enum RenderMode {
195    /// Standard grayscale anti-aliased rendering.
196    Grayscale,
197    /// LCD subpixel anti-aliased rendering (3-channel horizontal mask).
198    #[default]
199    Subpixel,
200    /// Color emoji / layered vector font rendering (COLR/CPAL, SVG, sbix).
201    Color,
202    /// Multi-channel signed distance field rendering (resolution-independent).
203    Msdf,
204}
205
206/// A variable font axis setting.
207#[derive(Debug, Clone, Copy, PartialEq)]
208pub struct VariableAxis {
209    /// The OpenType axis tag (e.g. `wght`, `wdth`, `ital`).
210    pub tag: u32,
211    /// The axis value.
212    pub value: f32,
213}
214
215impl VariableAxis {
216    /// Create a new variable axis setting from a 4-byte tag.
217    pub fn new(tag_bytes: [u8; 4], value: f32) -> Self {
218        let tag = u32::from_be_bytes(tag_bytes);
219        VariableAxis { tag, value }
220    }
221
222    /// Weight axis (100-900).
223    pub fn weight(value: f32) -> Self {
224        VariableAxis::new(*b"wght", value)
225    }
226
227    /// Width axis.
228    pub fn width(value: f32) -> Self {
229        VariableAxis::new(*b"wdth", value)
230    }
231
232    /// Italic axis (0.0 or 1.0).
233    pub fn italic(value: f32) -> Self {
234        VariableAxis::new(*b"ital", value)
235    }
236
237    /// Slant axis.
238    pub fn slant(value: f32) -> Self {
239        VariableAxis::new(*b"slnt", value)
240    }
241}
242
243/// A Bezier spline path for positioning and rotating glyphs along arbitrary curves.
244///
245/// # Contract
246/// The path is constructed from control points. The `sample` method interpolates
247/// along the path at normalized parameter `t` (0.0 to 1.0) and returns the 2D position
248/// and the tangent rotation angle in radians for orienting characters correctly.
249#[derive(Debug, Clone, PartialEq)]
250pub struct TextPath {
251    /// Control points for the Bezier spline segments.
252    pub control_points: Vec<(f32, f32)>,
253}
254
255impl TextPath {
256    /// Create a new text path from control points.
257    pub fn new(control_points: Vec<(f32, f32)>) -> Self {
258        TextPath { control_points }
259    }
260
261    /// Sample the position and tangent rotation angle (radians) at normalized parameter `t` (0.0..=1.0).
262    pub fn sample(&self, t: f32) -> ((f32, f32), f32) {
263        if self.control_points.is_empty() {
264            return ((0.0, 0.0), 0.0);
265        }
266        let n = self.control_points.len();
267        if n == 1 {
268            return (self.control_points[0], 0.0);
269        }
270        if n == 3 {
271            // Quadratic Bezier interpolation
272            let p0 = self.control_points[0];
273            let p1 = self.control_points[1];
274            let p2 = self.control_points[2];
275            let u = 1.0 - t;
276            let tt = t * t;
277            let uu = u * u;
278            let x = uu * p0.0 + 2.0 * u * t * p1.0 + tt * p2.0;
279            let y = uu * p0.1 + 2.0 * u * t * p1.1 + tt * p2.1;
280            let tx = 2.0 * u * (p1.0 - p0.0) + 2.0 * t * (p2.0 - p1.0);
281            let ty = 2.0 * u * (p1.1 - p0.1) + 2.0 * t * (p2.1 - p1.1);
282            let angle = ty.atan2(tx);
283            ((x, y), angle)
284        } else if n == 4 {
285            // Cubic Bezier interpolation
286            let p0 = self.control_points[0];
287            let p1 = self.control_points[1];
288            let p2 = self.control_points[2];
289            let p3 = self.control_points[3];
290            let u = 1.0 - t;
291            let tt = t * t;
292            let uu = u * u;
293            let uuu = uu * u;
294            let ttt = tt * t;
295            let x = uuu * p0.0 + 3.0 * uu * t * p1.0 + 3.0 * u * tt * p2.0 + ttt * p3.0;
296            let y = uuu * p0.1 + 3.0 * uu * t * p1.1 + 3.0 * u * tt * p2.1 + ttt * p3.1;
297            let tx =
298                3.0 * uu * (p1.0 - p0.0) + 6.0 * u * t * (p2.0 - p1.0) + 3.0 * tt * (p3.0 - p2.0);
299            let ty =
300                3.0 * uu * (p1.1 - p0.1) + 6.0 * u * t * (p2.1 - p1.1) + 3.0 * tt * (p3.1 - p2.1);
301            let angle = ty.atan2(tx);
302            ((x, y), angle)
303        } else {
304            // Fallback: Linear polyline interpolation
305            let segments = n - 1;
306            let scaled_t = t * segments as f32;
307            let idx = (scaled_t.floor() as usize).min(segments - 1);
308            let local_t = scaled_t - idx as f32;
309            let p0 = self.control_points[idx];
310            let p1 = self.control_points[idx + 1];
311            let x = p0.0 + (p1.0 - p0.0) * local_t;
312            let y = p0.1 + (p1.1 - p0.1) * local_t;
313            let tx = p1.0 - p0.0;
314            let ty = p1.1 - p0.1;
315            let angle = ty.atan2(tx);
316            ((x, y), angle)
317        }
318    }
319}
320
321/// Boundary shapes used for non-rectangular text wrapping.
322///
323/// # Contract
324/// Represents geometric limits within which text flows are allowed or clipped.
325/// The layouter checks collision with boundaries during the line reflow calculations.
326#[derive(Debug, Clone, PartialEq)]
327pub enum LayoutBoundary {
328    /// Circular boundary: center x, center y, radius.
329    Circle {
330        /// Center X coordinate.
331        cx: f32,
332        /// Center Y coordinate.
333        cy: f32,
334        /// Radius of boundary circle.
335        r: f32,
336    },
337    /// Convex polygon boundary defined by a set of clockwise vertices.
338    Polygon {
339        /// Vertices (x, y) defining the polygon boundary.
340        vertices: Vec<(f32, f32)>,
341    },
342}
343
344impl LayoutBoundary {
345    /// Compute the allowed horizontal span `[x_min, x_max]` at a vertical coordinate `y`.
346    ///
347    /// # Contract
348    /// Checks intersection of a horizontal line at `y` with the boundary shape.
349    /// Returns `Some((x_min, x_max))` if the line intersects the boundary, otherwise `None`.
350    pub fn allowed_span(&self, y: f32) -> Option<(f32, f32)> {
351        match self {
352            LayoutBoundary::Circle { cx, cy, r } => {
353                let dy = y - cy;
354                if dy.abs() < *r {
355                    let dx = (r * r - dy * dy).sqrt();
356                    Some((cx - dx, cx + dx))
357                } else {
358                    None
359                }
360            }
361            LayoutBoundary::Polygon { vertices } => {
362                if vertices.len() < 3 {
363                    return None;
364                }
365                let mut intersections = Vec::new();
366                for i in 0..vertices.len() {
367                    let p0 = vertices[i];
368                    let p1 = vertices[(i + 1) % vertices.len()];
369                    let y_min = p0.1.min(p1.1);
370                    let y_max = p0.1.max(p1.1);
371                    if y >= y_min && y <= y_max && (p1.1 - p0.1).abs() > 1e-5 {
372                        let t = (y - p0.1) / (p1.1 - p0.1);
373                        let x = p0.0 + t * (p1.0 - p0.0);
374                        intersections.push(x);
375                    }
376                }
377                if intersections.len() >= 2 {
378                    intersections.sort_by(|a, b| a.partial_cmp(b).unwrap());
379                    Some((intersections[0], intersections[intersections.len() - 1]))
380                } else {
381                    None
382                }
383            }
384        }
385    }
386}
387
388/// An OpenType feature to enable during shaping.
389#[derive(Debug, Clone, Copy, PartialEq, Eq)]
390pub struct OpenTypeFeature {
391    /// The feature tag (4-byte identifier).
392    pub tag: u32,
393    /// The feature value (0 = disable, 1 = enable, higher = alternate index).
394    pub value: u32,
395}
396
397impl OpenTypeFeature {
398    /// Create a new OpenType feature from a 4-byte tag.
399    pub fn new(tag_bytes: [u8; 4], value: u32) -> Self {
400        let tag = u32::from_be_bytes(tag_bytes);
401        OpenTypeFeature { tag, value }
402    }
403
404    /// Enable standard ligatures.
405    pub fn liga() -> Self {
406        OpenTypeFeature::new(*b"liga", 1)
407    }
408
409    /// Enable kerning.
410    pub fn kern() -> Self {
411        OpenTypeFeature::new(*b"kern", 1)
412    }
413
414    /// Enable contextual alternates.
415    pub fn calt() -> Self {
416        OpenTypeFeature::new(*b"calt", 1)
417    }
418
419    /// Enable discretionary ligatures.
420    pub fn dlig() -> Self {
421        OpenTypeFeature::new(*b"dlig", 1)
422    }
423}
424
425/// Complete text styling for a span of text.
426#[derive(Debug, Clone, PartialEq)]
427pub struct TextStyle {
428    /// Font family name (primary).
429    pub family: String,
430    /// Fallback font family names.
431    pub fallback_families: Vec<String>,
432    /// Font size in pixels.
433    pub font_size: f32,
434    /// Font weight (100-900).
435    pub weight: Weight,
436    /// Font stretch.
437    pub stretch: Stretch,
438    /// Font style (normal, italic, oblique).
439    pub style: Style,
440    /// Text color as RGBA.
441    pub color: [u8; 4],
442    /// Letter spacing in pixels (added to each glyph advance).
443    pub letter_spacing: f32,
444    /// Word spacing in pixels (added to space glyph advance).
445    pub word_spacing: f32,
446    /// Line height mode.
447    pub line_height: LineHeight,
448    /// Text decorations.
449    pub decorations: TextDecorations,
450    /// OpenType features to enable (after liga/kern/calt which are always on).
451    pub extra_features: Vec<OpenTypeFeature>,
452    /// Variable font axis settings.
453    pub variable_axes: Vec<VariableAxis>,
454    /// Whether to synthesize bold/italic when the variant font is missing.
455    pub synthesize_styles: bool,
456    /// Rendering mode for glyph rasterization.
457    pub render_mode: RenderMode,
458    /// Whether to render glyphs as resolution-independent vector outlines.
459    pub outline_rendering: bool,
460    /// Unique identifier for dynamic material and visual rendering effects.
461    pub material_effect_id: u32,
462}
463
464impl Default for TextStyle {
465    fn default() -> Self {
466        TextStyle {
467            family: "Jupiteroid".to_string(),
468            fallback_families: vec![
469                "Operation Napalm".to_string(),
470                "OSerif".to_string(),
471                "Lanix Ox".to_string(),
472            ],
473            font_size: DEFAULT_FONT_SIZE,
474            weight: Weight::NORMAL,
475            stretch: Stretch::Normal,
476            style: Style::Normal,
477            color: [255, 255, 255, 255],
478            letter_spacing: 0.0,
479            word_spacing: 0.0,
480            line_height: LineHeight::default(),
481            decorations: TextDecorations::default(),
482            extra_features: vec![],
483            variable_axes: vec![],
484            synthesize_styles: false,
485            render_mode: RenderMode::default(),
486            outline_rendering: false,
487            material_effect_id: 0,
488        }
489    }
490}
491
492impl TextStyle {
493    /// Create a new text style with the given family and size.
494    pub fn new(family: &str, font_size: f32) -> Self {
495        TextStyle {
496            family: family.to_string(),
497            font_size,
498            ..Default::default()
499        }
500    }
501
502    /// Set the font weight.
503    pub fn with_weight(mut self, weight: u16) -> Self {
504        self.weight = Weight(weight);
505        self
506    }
507
508    /// Set italic style.
509    pub fn italic(mut self) -> Self {
510        self.style = Style::Italic;
511        self
512    }
513
514    /// Set the text color.
515    pub fn with_color(mut self, r: u8, g: u8, b: u8, a: u8) -> Self {
516        self.color = [r, g, b, a];
517        self
518    }
519
520    /// Set letter spacing.
521    pub fn with_letter_spacing(mut self, spacing: f32) -> Self {
522        self.letter_spacing = spacing;
523        self
524    }
525
526    /// Set word spacing.
527    pub fn with_word_spacing(mut self, spacing: f32) -> Self {
528        self.word_spacing = spacing;
529        self
530    }
531
532    /// Set line height as a multiple of font size.
533    pub fn with_line_height_multiple(mut self, multiple: f32) -> Self {
534        self.line_height = LineHeight::Multiple(multiple);
535        self
536    }
537
538    /// Set a fixed line height in pixels.
539    pub fn with_line_height_fixed(mut self, pixels: f32) -> Self {
540        self.line_height = LineHeight::Fixed(pixels);
541        self
542    }
543
544    /// Add an OpenType feature.
545    pub fn with_feature(mut self, feature: OpenTypeFeature) -> Self {
546        self.extra_features.push(feature);
547        self
548    }
549
550    /// Add a variable font axis.
551    pub fn with_axis(mut self, axis: VariableAxis) -> Self {
552        self.variable_axes.push(axis);
553        self
554    }
555
556    /// Enable underline decoration.
557    pub fn with_underline(mut self) -> Self {
558        self.decorations.underline = true;
559        self
560    }
561
562    /// Enable strikethrough decoration.
563    pub fn with_strikethrough(mut self) -> Self {
564        self.decorations.strikethrough = true;
565        self
566    }
567
568    /// Set whether outline vector path rendering is enabled.
569    pub fn with_outline_rendering(mut self, enabled: bool) -> Self {
570        self.outline_rendering = enabled;
571        self
572    }
573
574    /// Set the material effect ID for dynamic visual rendering.
575    pub fn with_material_effect(mut self, effect_id: u32) -> Self {
576        self.material_effect_id = effect_id;
577        self
578    }
579}
580
581// ── TextSpan ─────────────────────────────────────────────────────────────────
582
583/// Vertical alignment strategies for inline UI portals within a text line.
584#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
585pub enum PortalAlignment {
586    /// Align the bottom of the portal box to the text baseline.
587    #[default]
588    Baseline,
589    /// Align the top of the portal box to the top of the line height.
590    Top,
591    /// Center the portal box vertically within the line height.
592    Center,
593    /// Align the bottom of the portal box to the bottom of the line height.
594    Bottom,
595}
596
597/// Identifies the layout behavior of a TextSpan (standard text vs inline portal).
598#[derive(Debug, Clone, PartialEq, Default)]
599pub enum TextSpanKind {
600    /// Standard text flow.
601    #[default]
602    Text,
603    /// An inline interactive widget box.
604    Portal {
605        /// Width of the portal box in pixels.
606        width: f32,
607        /// Height of the portal box in pixels.
608        height: f32,
609        /// Vertical alignment mode.
610        alignment: PortalAlignment,
611        /// Unique identifier for downstream portal instantiation.
612        id: String,
613    },
614}
615
616/// A span of text or an inline UI portal with associated styling.
617#[derive(Debug, Clone, PartialEq)]
618pub struct TextSpan {
619    /// The text content (stores "\u{FFFC}" object placeholder for portals).
620    pub text: String,
621    /// The style to apply.
622    pub style: TextStyle,
623    /// Byte offset in the full text where this span starts.
624    pub byte_offset: usize,
625    /// Layout category of the span.
626    pub kind: TextSpanKind,
627}
628
629impl TextSpan {
630    /// Create a new text span.
631    pub fn new(text: &str, style: TextStyle) -> Self {
632        TextSpan {
633            text: text.to_string(),
634            style,
635            byte_offset: 0,
636            kind: TextSpanKind::Text,
637        }
638    }
639
640    /// Create a new text span at a specific byte offset.
641    pub fn at(text: &str, style: TextStyle, byte_offset: usize) -> Self {
642        TextSpan {
643            text: text.to_string(),
644            style,
645            byte_offset,
646            kind: TextSpanKind::Text,
647        }
648    }
649
650    /// Create a new inline UI portal span.
651    pub fn portal(
652        width: f32,
653        height: f32,
654        alignment: PortalAlignment,
655        id: &str,
656        style: TextStyle,
657    ) -> Self {
658        TextSpan {
659            text: "\u{FFFC}".to_string(),
660            style,
661            byte_offset: 0,
662            kind: TextSpanKind::Portal {
663                width,
664                height,
665                alignment,
666                id: id.to_string(),
667            },
668        }
669    }
670
671    /// Create a new inline UI portal span at a specific byte offset.
672    pub fn portal_at(
673        width: f32,
674        height: f32,
675        alignment: PortalAlignment,
676        id: &str,
677        style: TextStyle,
678        byte_offset: usize,
679    ) -> Self {
680        TextSpan {
681            text: "\u{FFFC}".to_string(),
682            style,
683            byte_offset,
684            kind: TextSpanKind::Portal {
685                width,
686                height,
687                alignment,
688                id: id.to_string(),
689            },
690        }
691    }
692}
693
694// ── CacheKey ─────────────────────────────────────────────────────────────────
695
696/// Deterministic cache key for shaped text.
697///
698/// Uses font swash::CacheKey (u64) which is derived from font data identity,
699/// not fontdb::ID which uses slotmap and differs across processes.
700#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
701pub struct CacheKey {
702    /// Hash of the text content.
703    pub text_hash: u64,
704    /// Font swash cache key (identifies font data uniquely).
705    pub font_cache_key: u64,
706    /// Font size in pixels (quantized to 0.5px steps for cache friendliness).
707    pub font_size: u32,
708    /// Font weight.
709    pub weight: u16,
710    /// Font stretch raw value.
711    pub stretch: u16,
712    /// Font style discriminant.
713    pub style: u8,
714    /// Direction: 0 = LTR, 1 = RTL.
715    pub direction: u8,
716    /// Letter spacing (quantized to 1/100px).
717    pub letter_spacing: i32,
718    /// Word spacing (quantized to 1/100px).
719    pub word_spacing: i32,
720}
721
722impl CacheKey {
723    /// Create a new cache key.
724    pub fn new(
725        text: &str,
726        font_cache_key: u64,
727        font_size: f32,
728        weight: Weight,
729        stretch: Stretch,
730        style: Style,
731        direction: Direction,
732        letter_spacing: f32,
733        word_spacing: f32,
734    ) -> Self {
735        use std::collections::hash_map::DefaultHasher;
736        use std::hash::{Hash, Hasher};
737
738        let mut hasher = DefaultHasher::new();
739        text.hash(&mut hasher);
740        let text_hash = hasher.finish();
741
742        CacheKey {
743            text_hash,
744            font_cache_key,
745            font_size: (font_size * 2.0).round() as u32,
746            weight: weight.0,
747            stretch: stretch.to_number(),
748            style: match style {
749                Style::Normal => 0,
750                Style::Italic => 1,
751                Style::Oblique => 2,
752            },
753            direction: match direction {
754                Direction::LeftToRight => 0,
755                Direction::RightToLeft => 1,
756                _ => 0,
757            },
758            letter_spacing: (letter_spacing * 100.0).round() as i32,
759            word_spacing: (word_spacing * 100.0).round() as i32,
760        }
761    }
762}
763
764// ── Glyph types ──────────────────────────────────────────────────────────────
765
766/// A positioned glyph ready for rendering.
767#[derive(Debug, Clone, Copy, PartialEq)]
768pub struct GlyphInstance {
769    /// The glyph ID.
770    pub glyph_id: u16,
771    /// X position (pixels from origin).
772    pub x: f32,
773    /// Y position (pixels from origin, baseline-relative).
774    pub y: f32,
775    /// Rotation angle in radians (used when rendering text along curves).
776    pub angle: f32,
777    /// Advance width in pixels.
778    pub advance_width: f32,
779    /// Advance height in pixels.
780    pub advance_height: f32,
781    /// The cluster index this glyph belongs to.
782    pub cluster: u32,
783    /// Whether this glyph is from a RTL run.
784    pub is_rtl: bool,
785    /// Unique composite cache key for rasterization lookup, incorporating font identity, size, styling, and glyph ID.
786    pub cache_key: u64,
787    /// Linear index of this glyph in the paragraph (used for animation cascades).
788    pub glyph_index: usize,
789    /// Time offset applied to this glyph for kinetic typography.
790    pub time_offset: f32,
791}
792
793/// A segment in a glyph vector outline path.
794///
795/// Exposes raw quadratic and cubic Bezier control points to be processed
796/// and evaluated directly by GPU shaders for resolution-independent rendering.
797#[derive(Debug, Clone, Copy, PartialEq)]
798pub enum RunicPathSegment {
799    /// Move the pen to the specified point. Starts a new subpath.
800    MoveTo {
801        /// X coordinate of destination point.
802        x: f32,
803        /// Y coordinate of destination point.
804        y: f32,
805    },
806    /// Draw a straight line segment to the specified point.
807    LineTo {
808        /// X coordinate of destination point.
809        x: f32,
810        /// Y coordinate of destination point.
811        y: f32,
812    },
813    /// Draw a quadratic Bezier curve to the specified point using one control point.
814    QuadTo {
815        /// X coordinate of the Bezier control point.
816        cx: f32,
817        /// Y coordinate of the Bezier control point.
818        cy: f32,
819        /// X coordinate of destination point.
820        x: f32,
821        /// Y coordinate of destination point.
822        y: f32,
823    },
824    /// Draw a cubic Bezier curve to the specified point using two control points.
825    CubicTo {
826        /// X coordinate of the first Bezier control point.
827        cx1: f32,
828        /// Y coordinate of the first Bezier control point.
829        cy1: f32,
830        /// X coordinate of the second Bezier control point.
831        cx2: f32,
832        /// Y coordinate of the second Bezier control point.
833        cy2: f32,
834        /// X coordinate of destination point.
835        x: f32,
836        /// Y coordinate of destination point.
837        y: f32,
838    },
839    /// Close the current subpath by drawing a straight line back to the start.
840    Close,
841}
842
843/// A rasterized glyph image.
844#[derive(Debug, Clone, PartialEq)]
845pub struct GlyphImage {
846    /// The glyph ID.
847    pub glyph_id: u16,
848    /// Width of the image in pixels.
849    pub width: u32,
850    /// Height of the image in pixels.
851    pub height: u32,
852    /// Pixel data (RGBA, premultiplied alpha).
853    pub data: Vec<u8>,
854    /// X offset from the cursor position.
855    pub x_offset: f32,
856    /// Y offset from the cursor position (positive = up).
857    pub y_offset: f32,
858    /// Cache key for the swash cache.
859    pub cache_key: u64,
860}
861
862// ── LineInfo ─────────────────────────────────────────────────────────────────
863
864/// Information about a single line of laid-out text.
865#[derive(Debug, Clone, PartialEq)]
866pub struct LineInfo {
867    /// Index of the first glyph in this line.
868    pub glyph_start: usize,
869    /// Index past the last glyph in this line.
870    pub glyph_end: usize,
871    /// Y position of the line baseline.
872    pub baseline_y: f32,
873    /// Height of this line.
874    pub height: f32,
875    /// Width of the text content in this line.
876    pub width: f32,
877    /// X offset for alignment (0 for left-aligned).
878    pub x_offset: f32,
879    /// Byte offset in the full text where this line starts.
880    pub byte_offset: usize,
881    /// The text content of this line.
882    pub text: String,
883}
884
885// ── ShapedText ───────────────────────────────────────────────────────────────
886
887/// The result of shaping and laying out text.
888#[derive(Debug, Clone, PartialEq)]
889pub struct ShapedText {
890    /// All positioned glyphs.
891    pub glyphs: Vec<GlyphInstance>,
892    /// Line information.
893    pub lines: Vec<LineInfo>,
894    /// Total width of the layout.
895    pub width: f32,
896    /// Total height of the layout.
897    pub height: f32,
898    /// The text that was shaped.
899    pub text: String,
900    /// The spans that were used.
901    pub spans: Vec<TextSpan>,
902    /// Whether the text has RTL content.
903    pub has_rtl: bool,
904    /// Font ascent for the primary font.
905    pub ascent: f32,
906    /// Font descent for the primary font.
907    pub descent: f32,
908    /// Font line gap for the primary font.
909    pub line_gap: f32,
910    /// Precomputed grapheme cluster boundaries (byte offsets into `text`).
911    pub grapheme_boundaries: Vec<usize>,
912}
913
914impl ShapedText {
915    /// Find the glyph index for a given byte position in the text.
916    pub fn hit_test(&self, byte_index: usize) -> (usize, u32) {
917        if self.glyphs.is_empty() {
918            return (0, 0);
919        }
920
921        let mut best_glyph = 0u32;
922        let mut best_dist = u64::MAX;
923
924        // Find the cluster whose byte range contains byte_index
925        for glyph in &self.glyphs {
926            let cluster_byte = self.byte_pos_for_cluster(glyph.cluster);
927            let dist = if cluster_byte > byte_index {
928                (cluster_byte - byte_index) as u64
929            } else {
930                (byte_index - cluster_byte) as u64
931            };
932            if dist < best_dist {
933                best_dist = dist;
934                best_glyph = glyph.cluster;
935            }
936        }
937
938        // Find the glyph index for this cluster
939        for (i, glyph) in self.glyphs.iter().enumerate() {
940            if glyph.cluster == best_glyph {
941                return (i, best_glyph);
942            }
943        }
944
945        (0, 0)
946    }
947
948    /// Get the cursor position (x, line_index) for a byte index.
949    pub fn cursor_position(&self, byte_index: usize) -> (f32, usize) {
950        if self.glyphs.is_empty() {
951            return (0.0, 0);
952        }
953
954        let (glyph_idx, _cluster) = self.hit_test(byte_index);
955
956        // Find which line this glyph is on
957        let mut line_idx = 0;
958        for (li, line) in self.lines.iter().enumerate() {
959            if glyph_idx >= line.glyph_start && glyph_idx < line.glyph_end {
960                line_idx = li;
961                break;
962            }
963        }
964
965        // x is the left edge of the glyph, adjusted for alignment
966        let glyph = &self.glyphs[glyph_idx];
967        let line = &self.lines[line_idx];
968        let x = line.x_offset + glyph.x;
969
970        (x, line_idx)
971    }
972
973    /// Get selection rectangles for a byte range [start, end).
974    pub fn selection_rects(&self, start: usize, end: usize) -> Vec<[f32; 4]> {
975        if self.glyphs.is_empty() || start >= end {
976            return vec![];
977        }
978
979        let mut rects = Vec::new();
980        let mut current_rect: Option<[f32; 4]> = None;
981
982        for glyph in &self.glyphs {
983            let cluster_start = self.byte_pos_for_cluster(glyph.cluster);
984            let cluster_end = if glyph.cluster + 1 < self.total_clusters() {
985                self.byte_pos_for_cluster(glyph.cluster + 1)
986            } else {
987                self.text.len()
988            };
989
990            // Check if this glyph's cluster overlaps with the selection
991            if cluster_start < end && cluster_end > start {
992                // Find the line for y/height
993                let mut line_top = 0.0f32;
994                let mut line_h = self.height;
995                for line in &self.lines {
996                    if glyph.cluster >= self.glyphs[line.glyph_start].cluster
997                        && (line.glyph_end == self.glyphs.len()
998                            || glyph.cluster < self.glyphs[line.glyph_end].cluster)
999                    {
1000                        line_top = line.baseline_y - self.ascent;
1001                        line_h = line.height;
1002                        break;
1003                    }
1004                }
1005
1006                let x = glyph.x;
1007                let w = glyph.advance_width.max(1.0);
1008
1009                if let Some(ref mut rect) = current_rect {
1010                    if (rect[0] + rect[2] - x).abs() < 2.0 && (rect[1] - line_top).abs() < 1.0 {
1011                        // Extend current rect
1012                        rect[2] = (x + w) - rect[0];
1013                    } else {
1014                        // Start new rect
1015                        rects.push(*rect);
1016                        current_rect = Some([x, line_top, w, line_h]);
1017                    }
1018                } else {
1019                    current_rect = Some([x, line_top, w, line_h]);
1020                }
1021            }
1022        }
1023
1024        if let Some(rect) = current_rect {
1025            rects.push(rect);
1026        }
1027
1028        rects
1029    }
1030
1031    /// Get the byte position for a cluster index.
1032    fn byte_pos_for_cluster(&self, cluster: u32) -> usize {
1033        self.grapheme_boundaries
1034            .get(cluster as usize)
1035            .copied()
1036            .unwrap_or(self.text.len())
1037    }
1038
1039    /// Total number of clusters in the text.
1040    fn total_clusters(&self) -> u32 {
1041        self.grapheme_boundaries.len() as u32
1042    }
1043}
1044
1045// ── FontData ─────────────────────────────────────────────────────────────────
1046
1047/// Owning wrapper for font data that can be shared.
1048#[derive(Clone)]
1049struct FontData {
1050    data: std::sync::Arc<Vec<u8>>,
1051    index: u32,
1052}
1053
1054impl FontData {
1055    fn new(data: Vec<u8>, index: u32) -> Self {
1056        FontData {
1057            data: std::sync::Arc::new(data),
1058            index,
1059        }
1060    }
1061
1062    fn as_bytes(&self) -> &[u8] {
1063        &self.data
1064    }
1065
1066    fn font_ref(&self) -> Option<FontRef<'_>> {
1067        FontRef::from_index(&self.data, self.index as usize)
1068    }
1069
1070    fn face(&self) -> Option<rustybuzz::Face<'_>> {
1071        rustybuzz::Face::from_slice(&self.data, self.index)
1072    }
1073}
1074
1075// ── ResolvedFont ─────────────────────────────────────────────────────────────
1076
1077/// A resolved font with its faces and metadata.
1078struct ResolvedFont {
1079    primary: FontData,
1080    fallbacks: Vec<FontData>,
1081    cache_key: u64,
1082    units_per_em: u16,
1083    ascent: f32,
1084    descent: f32,
1085    line_gap: f32,
1086    x_height: f32,
1087    cap_height: f32,
1088    has_colr: bool,
1089}
1090
1091impl ResolvedFont {
1092    fn from_data(data: FontData) -> Option<Self> {
1093        let font_ref = data.font_ref()?;
1094        let _face_ref = font_ref; // FontRef derefs to provide table data
1095
1096        // Get metrics from the font
1097        let _metrics = swash::scale::image::Image::new(); // placeholder
1098        // We'll get metrics via font_ref's internal data
1099        // Use swash's metrics method through the shape module
1100        let cache_key = font_ref.key.value();
1101
1102        // Read metrics directly from the font data using ttf-parser
1103        let ttf_face = rustybuzz::ttf_parser::Face::parse(data.as_bytes(), data.index).ok()?;
1104        let units_per_em = ttf_face.units_per_em();
1105        let ascent = ttf_face.ascender() as f32;
1106        let descent = ttf_face.descender().abs() as f32;
1107        let line_gap = ttf_face.line_gap() as f32;
1108
1109        let (os2_xh, os2_ch) = ttf_face
1110            .x_height()
1111            .and_then(|xh| ttf_face.capital_height().map(|ch| (xh as f32, ch as f32)))
1112            .unwrap_or((0.0, 0.0));
1113        let has_colr = ttf_face
1114            .raw_face()
1115            .table(rustybuzz::ttf_parser::Tag(u32::from_be_bytes(*b"COLR")))
1116            .is_some();
1117
1118        Some(ResolvedFont {
1119            primary: data,
1120            fallbacks: vec![],
1121            cache_key,
1122            units_per_em,
1123            ascent,
1124            descent,
1125            line_gap,
1126            x_height: os2_xh,
1127            cap_height: os2_ch,
1128            has_colr,
1129        })
1130    }
1131
1132    fn metrics_pixels(&self, font_size: f32) -> (f32, f32, f32) {
1133        let scale = font_size / self.units_per_em as f32;
1134        (
1135            self.ascent * scale,
1136            self.descent * scale,
1137            self.line_gap * scale,
1138        )
1139    }
1140}
1141
1142// ── RunicTextEngine ──────────────────────────────────────────────────────────
1143
1144/// The main text shaping and layout engine.
1145pub struct RunicTextEngine {
1146    /// Font database.
1147    db: Database,
1148    /// Font data cache: fontdb::ID -> FontData.
1149    font_data: HashMap<fontdb::ID, FontData>,
1150    /// Shape cache.
1151    cache: HashMap<CacheKey, Vec<GlyphInstance>>,
1152    /// Cache access order for LRU eviction.
1153    cache_order: Vec<CacheKey>,
1154    /// Scale context for rasterization.
1155    scale_context: ScaleContext,
1156}
1157
1158impl RunicTextEngine {
1159    /// Create a new text engine with system fonts and user fonts.
1160    ///
1161    /// # Contract
1162    /// Guaranteed to successfully instantiate a usable text engine. Loads all standard
1163    /// system and user fonts first, and then embeds Jupiteroid.ttf as an absolute last-resort
1164    /// fallback so text rendering cannot fail even on zero-font systems.
1165    pub fn new() -> Self {
1166        let mut db = Database::new();
1167        db.load_system_fonts();
1168
1169        // Load user fonts from standard directories
1170        let home = std::env::var("HOME").unwrap_or_default();
1171        for dir in &[
1172            format!("{}/.local/share/fonts", home),
1173            format!("{}/.fonts", home),
1174            "/usr/share/fonts".to_string(),
1175            "/usr/local/share/fonts".to_string(),
1176        ] {
1177            db.load_fonts_dir(dir);
1178        }
1179
1180        let mut engine = RunicTextEngine {
1181            db,
1182            font_data: HashMap::new(),
1183            cache: HashMap::new(),
1184            cache_order: Vec::new(),
1185            scale_context: ScaleContext::new(),
1186        };
1187
1188        // Load Jupiteroid.ttf as a built-in last-resort fallback font
1189        engine.load_font_data(include_bytes!("../Fonts/Jupiteroid.ttf").to_vec());
1190
1191        engine
1192    }
1193
1194    /// Create a light text engine for testing — no system/user font loading.
1195    /// Only bundled fonts (loaded via `load_font_data()`) are available.
1196    pub fn new_light() -> Self {
1197        RunicTextEngine {
1198            db: Database::new(),
1199            font_data: HashMap::new(),
1200            cache: HashMap::new(),
1201            cache_order: Vec::new(),
1202            scale_context: ScaleContext::new(),
1203        }
1204    }
1205
1206    /// Create a test engine with only the bundled Jupiteroid font loaded.
1207    /// Avoids loading system fonts (which cause OOM in CI with many parallel tests).
1208    pub fn new_test() -> Self {
1209        let mut engine = Self::new_light();
1210        engine.load_font_data(include_bytes!("../Fonts/Jupiteroid.ttf").to_vec());
1211        engine
1212    }
1213
1214    /// Load a font from file data.
1215    pub fn load_font_data(&mut self, data: Vec<u8>) {
1216        self.db.load_font_data(data.clone());
1217        for face in self.db.faces() {
1218            let id = face.id;
1219            self.font_data.entry(id).or_insert_with(|| {
1220                let face_index = face.index;
1221                FontData::new(data.clone(), face_index)
1222            });
1223        }
1224    }
1225    /// Get or load FontData for a fontdb ID.
1226    fn get_font_data(&mut self, id: fontdb::ID) -> Option<FontData> {
1227        if let Some(data) = self.font_data.get(&id) {
1228            return Some(data.clone());
1229        }
1230
1231        // Load from the database
1232        let (source, face_index) = self.db.face_source(id)?;
1233        let data = match source {
1234            Source::Binary(arc_data) => {
1235                // arc_data is Arc<dyn AsRef<[u8]> + Sync + Send>
1236                let bytes: Vec<u8> = arc_data.as_ref().as_ref().to_vec();
1237                bytes
1238            }
1239            Source::File(path) => std::fs::read(&path).ok()?,
1240            _ => return None,
1241        };
1242
1243        let font_data = FontData::new(data, face_index);
1244        self.font_data.insert(id, font_data.clone());
1245        Some(font_data)
1246    }
1247
1248    /// Resolve a font for the given style.
1249    fn resolve_font(&mut self, style: &TextStyle) -> Result<ResolvedFont, ShapingError> {
1250        // Try primary family
1251        for family_name in std::iter::once(&style.family).chain(style.fallback_families.iter()) {
1252            let query = Query {
1253                families: &[Family::Name(family_name)],
1254                weight: style.weight,
1255                stretch: style.stretch,
1256                style: style.style,
1257            };
1258
1259            if let Some(id) = self.db.query(&query)
1260                && let Some(data) = self.get_font_data(id)
1261                && let Some(mut resolved) = ResolvedFont::from_data(data.clone())
1262            {
1263                // Load fallbacks - collect IDs first to avoid borrow issues
1264                let fallback_ids: Vec<fontdb::ID> = self
1265                    .db
1266                    .faces()
1267                    .filter(|f| f.id != id)
1268                    .map(|f| f.id)
1269                    .collect();
1270                for fb_id in fallback_ids {
1271                    if let Some(fb_data) = self.get_font_data(fb_id) {
1272                        resolved.fallbacks.push(fb_data);
1273                    }
1274                }
1275                return Ok(resolved);
1276            }
1277        }
1278
1279        // Last resort: any font
1280        let all_ids: Vec<fontdb::ID> = self.db.faces().map(|f| f.id).collect();
1281        for id in &all_ids {
1282            if let Some(data) = self.get_font_data(*id)
1283                && let Some(mut resolved) = ResolvedFont::from_data(data)
1284            {
1285                for fb_id in &all_ids {
1286                    if *fb_id != *id
1287                        && let Some(fb_data) = self.get_font_data(*fb_id)
1288                    {
1289                        resolved.fallbacks.push(fb_data);
1290                    }
1291                }
1292                return Ok(resolved);
1293            }
1294        }
1295
1296        Err(ShapingError::NoFontFound(style.family.clone()))
1297    }
1298
1299    /// Build rustybuzz Features from a TextStyle.
1300    fn build_features(style: &TextStyle) -> Vec<Feature> {
1301        use rustybuzz::ttf_parser::Tag;
1302        let mut features = vec![
1303            Feature::new(Tag::from_bytes(b"liga"), 1, 0..usize::MAX),
1304            Feature::new(Tag::from_bytes(b"kern"), 1, 0..usize::MAX),
1305            Feature::new(Tag::from_bytes(b"calt"), 1, 0..usize::MAX),
1306        ];
1307
1308        for extra in &style.extra_features {
1309            features.push(Feature::new(
1310                Tag::from_bytes(&extra.tag.to_be_bytes()),
1311                extra.value,
1312                0..usize::MAX,
1313            ));
1314        }
1315
1316        features
1317    }
1318
1319    /// Computes a unique cache key for a glyph instance under a specific text style.
1320    ///
1321    /// # Contract
1322    /// Hashes the font identifier, quantized font size, glyph ID, and stylistic attributes
1323    /// (weight, stretch, style) into a single deterministic 64-bit unsigned integer to
1324    /// prevent texture atlas key collisions while keeping cache size bounded.
1325    fn calculate_glyph_cache_key(
1326        font_cache_key: u64,
1327        font_size: f32,
1328        glyph_id: u16,
1329        style: &TextStyle,
1330    ) -> u64 {
1331        use std::collections::hash_map::DefaultHasher;
1332        use std::hash::{Hash, Hasher};
1333        let mut hasher = DefaultHasher::new();
1334        font_cache_key.hash(&mut hasher);
1335        ((font_size * 2.0).round() as u32).hash(&mut hasher);
1336        glyph_id.hash(&mut hasher);
1337        style.weight.0.hash(&mut hasher);
1338        style.stretch.to_number().hash(&mut hasher);
1339        let style_discriminant = match style.style {
1340            Style::Normal => 0u8,
1341            Style::Italic => 1u8,
1342            Style::Oblique => 2u8,
1343        };
1344        style_discriminant.hash(&mut hasher);
1345        hasher.finish()
1346    }
1347
1348    /// Shape a single run of text.
1349    fn shape_run(
1350        &mut self,
1351        text: &str,
1352        style: &TextStyle,
1353        direction: Direction,
1354    ) -> Result<Vec<GlyphInstance>, ShapingError> {
1355        let resolved = self.resolve_font(style)?;
1356
1357        let features = Self::build_features(style);
1358
1359        // Build cache key
1360        let cache_key = CacheKey::new(
1361            text,
1362            resolved.cache_key,
1363            style.font_size,
1364            style.weight,
1365            style.stretch,
1366            style.style,
1367            direction,
1368            style.letter_spacing,
1369            style.word_spacing,
1370        );
1371
1372        // Check cache
1373        if let Some(glyphs) = self.cache.get(&cache_key) {
1374            return Ok(glyphs.clone());
1375        }
1376
1377        // Create rustybuzz face
1378        let face = resolved
1379            .primary
1380            .face()
1381            .ok_or(ShapingError::InvalidFontData)?;
1382
1383        // Build buffer
1384        let mut buffer = UnicodeBuffer::new();
1385        buffer.push_str(text);
1386        buffer.set_direction(direction);
1387
1388        // Shape
1389        let output = rustybuzz::shape(&face, &features, buffer);
1390
1391        let glyph_infos = output.glyph_infos();
1392        let glyph_positions = output.glyph_positions();
1393
1394        let scale = style.font_size / (resolved.units_per_em as f32);
1395
1396        let mut glyphs = Vec::new();
1397        let mut x_offset = 0.0f32;
1398
1399        for (info, pos) in glyph_infos.iter().zip(glyph_positions.iter()) {
1400            let advance = (pos.x_advance as f32) * scale;
1401            let letter_space = if Self::is_space_cluster(text, info.cluster) {
1402                style.word_spacing
1403            } else {
1404                0.0
1405            };
1406
1407            let glyph_cache_key = Self::calculate_glyph_cache_key(
1408                resolved.cache_key,
1409                style.font_size,
1410                info.glyph_id as u16,
1411                style,
1412            );
1413
1414            glyphs.push(GlyphInstance {
1415                glyph_id: info.glyph_id as u16,
1416                x: x_offset + (pos.x_offset as f32) * scale,
1417                y: (pos.y_offset as f32) * scale,
1418                angle: 0.0,
1419                advance_width: advance + style.letter_spacing + letter_space,
1420                advance_height: (pos.y_advance as f32) * scale,
1421                cluster: info.cluster,
1422                is_rtl: direction == Direction::RightToLeft,
1423                cache_key: glyph_cache_key,
1424                glyph_index: 0,
1425                time_offset: 0.0,
1426            });
1427
1428            x_offset += advance + style.letter_spacing + letter_space;
1429        }
1430
1431        // Apply font fallback for missing glyphs
1432        self.apply_fallbacks(&mut glyphs, text, style, &resolved, &features);
1433
1434        // Update cache
1435        self.insert_cache(cache_key, glyphs.clone());
1436
1437        Ok(glyphs)
1438    }
1439
1440    /// Check if a cluster represents a space character.
1441    fn is_space_cluster(text: &str, cluster: u32) -> bool {
1442        text.chars()
1443            .nth(cluster as usize)
1444            .is_some_and(|c| c.is_ascii_whitespace())
1445    }
1446
1447    /// Resolves missing glyphs in primary font by looking up fallback fonts.
1448    ///
1449    /// # Contract
1450    /// Evaluates glyph instances in place. For any glyph with ID 0, queries loaded
1451    /// fallback fonts sequentially and overrides the ID, position metrics, and calculates
1452    /// a new unique cache key using the fallback font identity if a match is found.
1453    fn apply_fallbacks(
1454        &mut self,
1455        glyphs: &mut [GlyphInstance],
1456        text: &str,
1457        style: &TextStyle,
1458        resolved: &ResolvedFont,
1459        features: &[Feature],
1460    ) {
1461        let len = glyphs.len();
1462        for i in 0..len {
1463            if glyphs[i].glyph_id == 0 {
1464                let glyph_cluster = glyphs[i].cluster;
1465                let glyph_is_rtl = glyphs[i].is_rtl;
1466                let glyph_x = glyphs[i].x;
1467                let c = text
1468                    .chars()
1469                    .nth(glyph_cluster as usize)
1470                    .unwrap_or('\u{FFFD}');
1471
1472                // Try each fallback font
1473                for fallback in &resolved.fallbacks {
1474                    if let Some(face) = fallback.face() {
1475                        let mut buf = UnicodeBuffer::new();
1476                        buf.add(c, glyph_cluster);
1477                        buf.set_direction(if glyph_is_rtl {
1478                            Direction::RightToLeft
1479                        } else {
1480                            Direction::LeftToRight
1481                        });
1482
1483                        let output = rustybuzz::shape(&face, features, buf);
1484                        let infos = output.glyph_infos();
1485                        let positions = output.glyph_positions();
1486
1487                        if let (Some(info), Some(pos)) = (infos.first(), positions.first())
1488                            && info.glyph_id != 0
1489                        {
1490                            let scale = style.font_size / (resolved.units_per_em as f32);
1491                            glyphs[i].glyph_id = info.glyph_id as u16;
1492                            glyphs[i].x = glyph_x + (pos.x_offset as f32) * scale;
1493                            glyphs[i].y = (pos.y_offset as f32) * scale;
1494
1495                            let fallback_key = fallback
1496                                .font_ref()
1497                                .map(|r| r.key.value())
1498                                .unwrap_or(resolved.cache_key);
1499                            glyphs[i].cache_key = Self::calculate_glyph_cache_key(
1500                                fallback_key,
1501                                style.font_size,
1502                                info.glyph_id as u16,
1503                                style,
1504                            );
1505                            break;
1506                        }
1507                    }
1508                }
1509            }
1510        }
1511    }
1512
1513    /// Insert into cache with LRU eviction.
1514    fn insert_cache(&mut self, key: CacheKey, value: Vec<GlyphInstance>) {
1515        if self.cache.len() >= MAX_CACHE_SIZE
1516            && let Some(oldest) = self.cache_order.first().cloned()
1517        {
1518            self.cache.remove(&oldest);
1519            self.cache_order.remove(0);
1520        }
1521
1522        self.cache.insert(key, value);
1523        self.cache_order.push(key);
1524    }
1525
1526    /// Shape and layout text with the given spans.
1527    pub fn shape_layout(
1528        &mut self,
1529        spans: &[TextSpan],
1530        max_width: Option<f32>,
1531        align: TextAlign,
1532        overflow: TextOverflow,
1533    ) -> Result<ShapedText, ShapingError> {
1534        self.shape_layout_ex(spans, max_width, align, overflow, None, None)
1535    }
1536
1537    /// Shape and layout text with advanced capabilities (curved text paths and boundaries).
1538    ///
1539    /// # Contract
1540    /// Performs shaping over the spans and applies line breaking. If a `path` is provided,
1541    /// positions and rotates glyphs along the Bezier curve. If a `boundary` is provided,
1542    /// wrapping reflows dynamically to fit within the geometry.
1543    pub fn shape_layout_ex(
1544        &mut self,
1545        spans: &[TextSpan],
1546        max_width: Option<f32>,
1547        align: TextAlign,
1548        overflow: TextOverflow,
1549        path: Option<TextPath>,
1550        boundary: Option<LayoutBoundary>,
1551    ) -> Result<ShapedText, ShapingError> {
1552        if spans.is_empty() {
1553            return Ok(ShapedText {
1554                glyphs: vec![],
1555                lines: vec![],
1556                width: 0.0,
1557                height: 0.0,
1558                text: String::new(),
1559                spans: vec![],
1560                has_rtl: false,
1561                ascent: 0.0,
1562                descent: 0.0,
1563                line_gap: 0.0,
1564                grapheme_boundaries: vec![],
1565            });
1566        }
1567
1568        // Concatenate all text
1569        let full_text: String = spans.iter().map(|s| s.text.as_str()).collect();
1570
1571        // Detect BiDi
1572        let bidi = unicode_bidi::BidiInfo::new(&full_text, Some(unicode_bidi::Level::ltr()));
1573
1574        let mut all_glyphs: Vec<GlyphInstance> = Vec::new();
1575        let mut has_rtl = false;
1576        let mut primary_metrics = (0.0f32, 0.0f32, 0.0f32);
1577        let mut primary_line_height_px = DEFAULT_LINE_HEIGHT * DEFAULT_FONT_SIZE;
1578        let mut global_glyph_index = 0;
1579
1580        // Shape each span
1581        for span in spans {
1582            // Determine direction from BiDi analysis
1583            let direction = if let Some(para_info) = bidi.paragraphs.first() {
1584                let mut dir = Direction::LeftToRight;
1585                for bi in para_info.range.clone() {
1586                    if bi < bidi.levels.len() {
1587                        if bidi.levels[bi].is_rtl() {
1588                            dir = Direction::RightToLeft;
1589                            has_rtl = true;
1590                        }
1591                        break;
1592                    }
1593                }
1594                dir
1595            } else {
1596                Direction::LeftToRight
1597            };
1598
1599            let mut run_glyphs = match &span.kind {
1600                TextSpanKind::Text => self.shape_run(&span.text, &span.style, direction)?,
1601                TextSpanKind::Portal { width, height, .. } => {
1602                    vec![GlyphInstance {
1603                        glyph_id: 0xFFFF,
1604                        x: 0.0,
1605                        y: 0.0,
1606                        angle: 0.0,
1607                        advance_width: *width,
1608                        advance_height: *height,
1609                        cluster: span.byte_offset as u32,
1610                        is_rtl: false,
1611                        cache_key: 0,
1612                        glyph_index: 0,
1613                        time_offset: 0.0,
1614                    }]
1615                }
1616            };
1617
1618            // Offset glyph x positions by accumulated width
1619            let span_offset_x = all_glyphs
1620                .last()
1621                .map(|g| g.x + g.advance_width)
1622                .unwrap_or(0.0);
1623            for glyph in &mut run_glyphs {
1624                glyph.x += span_offset_x;
1625            }
1626
1627            // Track primary font metrics from the first span
1628            if all_glyphs.is_empty() {
1629                primary_metrics = (
1630                    span.style.font_size * 0.8, // ascent estimate
1631                    span.style.font_size * 0.2, // descent estimate
1632                    span.style.font_size * 0.2, // line gap estimate
1633                );
1634                if let Ok(resolved) = self.resolve_font(&span.style) {
1635                    primary_metrics = resolved.metrics_pixels(span.style.font_size);
1636                }
1637                primary_line_height_px = span.style.line_height.to_pixels(span.style.font_size);
1638            }
1639
1640            for mut glyph in run_glyphs {
1641                glyph.glyph_index = global_glyph_index;
1642                glyph.time_offset = global_glyph_index as f32 * 0.05; // Base 50ms stagger
1643                global_glyph_index += 1;
1644                all_glyphs.push(glyph);
1645            }
1646        }
1647
1648        // Perform line breaking and layout
1649        let lines = self.layout_lines(
1650            &mut all_glyphs,
1651            &full_text,
1652            &bidi,
1653            max_width,
1654            align,
1655            overflow,
1656            primary_metrics.0,
1657            primary_metrics.1,
1658            primary_metrics.2,
1659            primary_line_height_px,
1660            path.as_ref(),
1661            boundary.as_ref(),
1662            spans,
1663        );
1664
1665        // Compute total dimensions
1666        let mut total_width = 0.0f32;
1667        let total_height = lines.last().map(|l| l.baseline_y + l.height).unwrap_or(0.0);
1668
1669        for line in &lines {
1670            if line.width > total_width {
1671                total_width = line.width;
1672            }
1673        }
1674
1675        let grapheme_boundaries: Vec<usize> = full_text
1676            .grapheme_indices(true)
1677            .map(|(offset, _)| offset)
1678            .collect();
1679
1680        Ok(ShapedText {
1681            glyphs: all_glyphs,
1682            lines,
1683            width: total_width,
1684            height: total_height,
1685            text: full_text,
1686            spans: spans.to_vec(),
1687            has_rtl,
1688            ascent: primary_metrics.0,
1689            descent: primary_metrics.1,
1690            line_gap: primary_metrics.2,
1691            grapheme_boundaries,
1692        })
1693    }
1694
1695    /// Layout glyphs into lines with word wrapping and alignment.
1696    fn layout_lines(
1697        &self,
1698        glyphs: &mut Vec<GlyphInstance>,
1699        text: &str,
1700        bidi: &BidiInfo,
1701        max_width: Option<f32>,
1702        align: TextAlign,
1703        overflow: TextOverflow,
1704        ascent: f32,
1705        _descent: f32,
1706        _line_gap: f32,
1707        line_height_px: f32,
1708        path: Option<&TextPath>,
1709        boundary: Option<&LayoutBoundary>,
1710        spans: &[TextSpan],
1711    ) -> Vec<LineInfo> {
1712        let mut lines = Vec::new();
1713        let mut current_y = ascent;
1714
1715        if glyphs.is_empty() {
1716            return lines;
1717        }
1718
1719        if max_width.is_some() || boundary.is_some() {
1720            // Word wrapping mode
1721            let mut line_start_glyph = 0;
1722            let mut line_start_byte = 0;
1723            let mut last_word_break_glyph = 0usize;
1724            let mut last_word_break_byte = 0usize;
1725
1726            for i in 0..glyphs.len() {
1727                let glyph = &glyphs[i];
1728                let char_at_cluster = text.chars().nth(glyph.cluster as usize).unwrap_or(' ');
1729                let is_space = char_at_cluster.is_ascii_whitespace();
1730
1731                if is_space && i > line_start_glyph {
1732                    last_word_break_glyph = i + 1;
1733                    // Compute byte position after this cluster
1734                    let mut byte_pos = 0;
1735                    let mut ci = 0u32;
1736                    let text_bytes = text.as_bytes();
1737                    while byte_pos < text_bytes.len() && ci <= glyph.cluster {
1738                        byte_pos += Self::utf8_len(text_bytes[byte_pos]);
1739                        ci += 1;
1740                    }
1741                    last_word_break_byte = byte_pos;
1742                }
1743
1744                // Query constraints for the current line
1745                let (line_x_start, line_max_w) = if let Some(b) = boundary {
1746                    b.allowed_span(current_y)
1747                        .unwrap_or((0.0, max_width.unwrap_or(f32::MAX)))
1748                } else {
1749                    (0.0, max_width.unwrap_or(f32::MAX))
1750                };
1751
1752                let glyph_right_edge = glyph.x + glyph.advance_width;
1753                let line_left = if line_start_glyph < glyphs.len() {
1754                    glyphs[line_start_glyph].x
1755                } else {
1756                    0.0
1757                };
1758                let line_content_width = glyph_right_edge - line_left;
1759
1760                if line_content_width > line_max_w && i > line_start_glyph {
1761                    // Need to break
1762                    let break_glyph = if last_word_break_glyph > line_start_glyph {
1763                        last_word_break_glyph
1764                    } else {
1765                        i
1766                    };
1767                    let break_byte = if last_word_break_byte > line_start_byte {
1768                        last_word_break_byte
1769                    } else {
1770                        // Compute byte offset for cluster at break point
1771                        let mut bp = 0;
1772                        let mut ci2 = 0u32;
1773                        let tb = text.as_bytes();
1774                        while bp < tb.len()
1775                            && ci2 < glyphs[break_glyph.min(glyphs.len() - 1)].cluster
1776                        {
1777                            bp += Self::utf8_len(tb[bp]);
1778                            ci2 += 1;
1779                        }
1780                        bp
1781                    };
1782
1783                    // Compute line width
1784                    let line_width: f32 = glyphs[line_start_glyph..break_glyph]
1785                        .iter()
1786                        .map(|g| g.advance_width)
1787                        .sum();
1788
1789                    let x_offset = line_x_start
1790                        + Self::compute_x_offset(
1791                            align,
1792                            line_max_w,
1793                            line_width,
1794                            glyphs,
1795                            line_start_glyph,
1796                            break_glyph,
1797                        );
1798
1799                    // Position glyphs
1800                    let mut x = x_offset;
1801                    for g in &mut glyphs[line_start_glyph..break_glyph] {
1802                        g.x = x;
1803                        if g.glyph_id == 0xFFFF {
1804                            let mut portal_h = g.advance_height;
1805                            let mut alignment = PortalAlignment::Baseline;
1806                            for span in spans {
1807                                if let TextSpanKind::Portal {
1808                                    height,
1809                                    alignment: align_mode,
1810                                    ..
1811                                } = &span.kind
1812                                    && span.byte_offset as u32 == g.cluster
1813                                {
1814                                    portal_h = *height;
1815                                    alignment = *align_mode;
1816                                    break;
1817                                }
1818                            }
1819                            let y_offset = match alignment {
1820                                PortalAlignment::Baseline => 0.0,
1821                                PortalAlignment::Top => -ascent,
1822                                PortalAlignment::Center => {
1823                                    -ascent + (line_height_px - portal_h) / 2.0
1824                                }
1825                                PortalAlignment::Bottom => -ascent + line_height_px - portal_h,
1826                            };
1827                            g.y = current_y + y_offset;
1828                        } else {
1829                            g.y = current_y;
1830                        }
1831                        x += g.advance_width;
1832                    }
1833
1834                    let line_text = text[line_start_byte..break_byte.min(text.len())].to_string();
1835                    lines.push(LineInfo {
1836                        glyph_start: line_start_glyph,
1837                        glyph_end: break_glyph,
1838                        baseline_y: current_y,
1839                        height: line_height_px,
1840                        width: line_width,
1841                        x_offset,
1842                        byte_offset: line_start_byte,
1843                        text: line_text,
1844                    });
1845
1846                    current_y += line_height_px;
1847                    line_start_glyph = break_glyph;
1848                    line_start_byte = break_byte;
1849                }
1850            }
1851
1852            // Last line
1853            if line_start_glyph < glyphs.len() {
1854                let (line_x_start, line_max_w) = if let Some(b) = boundary {
1855                    b.allowed_span(current_y)
1856                        .unwrap_or((0.0, max_width.unwrap_or(f32::MAX)))
1857                } else {
1858                    (0.0, max_width.unwrap_or(f32::MAX))
1859                };
1860
1861                let line_width: f32 = glyphs[line_start_glyph..]
1862                    .iter()
1863                    .map(|g| g.advance_width)
1864                    .sum();
1865
1866                let glyph_end = glyphs.len();
1867                let x_offset = line_x_start
1868                    + Self::compute_x_offset(
1869                        align,
1870                        line_max_w,
1871                        line_width,
1872                        glyphs,
1873                        line_start_glyph,
1874                        glyph_end,
1875                    );
1876
1877                let mut x = x_offset;
1878                for g in &mut glyphs[line_start_glyph..] {
1879                    g.x = x;
1880                    if g.glyph_id == 0xFFFF {
1881                        // Locate matching portal span configuration by matching byte offset cluster index
1882                        let mut portal_h = g.advance_height;
1883                        let mut alignment = PortalAlignment::Baseline;
1884                        for span in spans {
1885                            if let TextSpanKind::Portal {
1886                                height,
1887                                alignment: align_mode,
1888                                ..
1889                            } = &span.kind
1890                                && span.byte_offset as u32 == g.cluster
1891                            {
1892                                portal_h = *height;
1893                                alignment = *align_mode;
1894                                break;
1895                            }
1896                        }
1897                        // Adjust Y offset depending on portal alignment relative to baseline/line height
1898                        let y_offset = match alignment {
1899                            PortalAlignment::Baseline => 0.0,
1900                            PortalAlignment::Top => -ascent,
1901                            PortalAlignment::Center => -ascent + (line_height_px - portal_h) / 2.0,
1902                            PortalAlignment::Bottom => -ascent + line_height_px - portal_h,
1903                        };
1904                        g.y = current_y + y_offset;
1905                    } else {
1906                        g.y = current_y;
1907                    }
1908                    x += g.advance_width;
1909                }
1910
1911                let remaining_text = text[line_start_byte.min(text.len())..].to_string();
1912                lines.push(LineInfo {
1913                    glyph_start: line_start_glyph,
1914                    glyph_end: glyphs.len(),
1915                    baseline_y: current_y,
1916                    height: line_height_px,
1917                    width: line_width,
1918                    x_offset,
1919                    byte_offset: line_start_byte,
1920                    text: remaining_text,
1921                });
1922            }
1923        } else {
1924            // No wrapping - single line
1925            let line_width: f32 = glyphs.iter().map(|g| g.advance_width).sum();
1926
1927            let mut x = 0.0;
1928            for g in glyphs.iter_mut() {
1929                g.x = x;
1930                if g.glyph_id == 0xFFFF {
1931                    // Locate matching portal span configuration by matching byte offset cluster index
1932                    let mut portal_h = g.advance_height;
1933                    let mut alignment = PortalAlignment::Baseline;
1934                    for span in spans {
1935                        if let TextSpanKind::Portal {
1936                            height,
1937                            alignment: align_mode,
1938                            ..
1939                        } = &span.kind
1940                            && span.byte_offset as u32 == g.cluster
1941                        {
1942                            portal_h = *height;
1943                            alignment = *align_mode;
1944                            break;
1945                        }
1946                    }
1947                    // Adjust Y offset depending on portal alignment relative to baseline/line height
1948                    let y_offset = match alignment {
1949                        PortalAlignment::Baseline => 0.0,
1950                        PortalAlignment::Top => -ascent,
1951                        PortalAlignment::Center => -ascent + (line_height_px - portal_h) / 2.0,
1952                        PortalAlignment::Bottom => -ascent + line_height_px - portal_h,
1953                    };
1954                    g.y = current_y + y_offset;
1955                } else {
1956                    g.y = current_y;
1957                }
1958                x += g.advance_width;
1959            }
1960
1961            lines.push(LineInfo {
1962                glyph_start: 0,
1963                glyph_end: glyphs.len(),
1964                baseline_y: current_y,
1965                height: line_height_px,
1966                width: line_width,
1967                x_offset: 0.0,
1968                byte_offset: 0,
1969                text: text.to_string(),
1970            });
1971        }
1972
1973        // Reorder glyphs within each line for BiDi
1974        for line_idx in 0..lines.len() {
1975            let line = &lines[line_idx];
1976            if line.glyph_start < line.glyph_end && line.glyph_end <= glyphs.len() {
1977                let level = line_bidi_level(bidi, line.byte_offset);
1978                if level.is_rtl() {
1979                    reorder_line_rtl(glyphs, line.glyph_start, line.glyph_end);
1980                }
1981            }
1982        }
1983
1984        // Handle text overflow ellipsis
1985        if overflow == TextOverflow::Ellipsis
1986            && let Some(max_w) = max_width
1987        {
1988            for line_idx in 0..lines.len() {
1989                let line = &lines[line_idx];
1990                if line.width > max_w {
1991                    // Find how many glyphs fit
1992                    let mut trunc_width = 0.0f32;
1993                    let mut trunc_glyph_end = line.glyph_start;
1994                    // Approximate ellipsis width
1995                    let ellipsis_w = line_height_px * 0.6 * 3.0;
1996
1997                    for gi in line.glyph_start..line.glyph_end {
1998                        if gi < glyphs.len() {
1999                            trunc_width += glyphs[gi].advance_width;
2000                            if trunc_width + ellipsis_w > max_w {
2001                                break;
2002                            }
2003                            trunc_glyph_end = gi + 1;
2004                        }
2005                    }
2006
2007                    lines[line_idx].glyph_end = trunc_glyph_end;
2008                    lines[line_idx].width = trunc_width;
2009                }
2010            }
2011        }
2012
2013        // Apply path layout constraint if present
2014        if let Some(tp) = path
2015            && let Some(last_glyph) = glyphs.last()
2016        {
2017            let total_x_len = last_glyph.x + last_glyph.advance_width;
2018            if total_x_len > 0.0 {
2019                for glyph in glyphs.iter_mut() {
2020                    let t = (glyph.x / total_x_len).clamp(0.0, 1.0);
2021                    let (pos, angle) = tp.sample(t);
2022                    // Offset perpendicularly by the baseline relative coordinate
2023                    let dy = glyph.y - ascent;
2024                    let perp_x = -angle.sin() * dy;
2025                    let perp_y = angle.cos() * dy;
2026
2027                    glyph.x = pos.0 + perp_x;
2028                    glyph.y = pos.1 + perp_y;
2029                    glyph.angle = angle;
2030                }
2031            }
2032        }
2033
2034        lines
2035    }
2036
2037    /// Compute x offset for alignment.
2038    fn compute_x_offset(
2039        align: TextAlign,
2040        max_w: f32,
2041        line_width: f32,
2042        glyphs: &mut [GlyphInstance],
2043        start: usize,
2044        end: usize,
2045    ) -> f32 {
2046        match align {
2047            TextAlign::Start => 0.0,
2048            TextAlign::End => (max_w - line_width).max(0.0),
2049            TextAlign::Center => ((max_w - line_width) / 2.0).max(0.0),
2050            TextAlign::Justify => {
2051                if end <= start + 1 || max_w <= line_width {
2052                    return 0.0;
2053                }
2054                let extra = max_w - line_width;
2055                let space_count = glyphs[start..end]
2056                    .iter()
2057                    .filter(|g| g.glyph_id == 3)
2058                    .count();
2059                if space_count > 0 {
2060                    let add_per_space = extra / space_count as f32;
2061                    let mut x = 0.0f32;
2062                    for i in start..end {
2063                        glyphs[i].x = x;
2064                        if glyphs[i].glyph_id == 3 {
2065                            x += glyphs[i].advance_width + add_per_space;
2066                        } else {
2067                            x += glyphs[i].advance_width;
2068                        }
2069                    }
2070                }
2071                0.0
2072            }
2073        }
2074    }
2075
2076    /// UTF-8 char length helper.
2077    fn utf8_len(first_byte: u8) -> usize {
2078        if first_byte < 0x80 {
2079            1
2080        } else if first_byte < 0xE0 {
2081            2
2082        } else if first_byte < 0xF0 {
2083            3
2084        } else {
2085            4
2086        }
2087    }
2088
2089    /// Rasterize a glyph to a bitmap image.
2090    pub fn rasterize_glyph(
2091        &mut self,
2092        glyph_id: u16,
2093        style: &TextStyle,
2094    ) -> Result<GlyphImage, ShapingError> {
2095        let resolved = self.resolve_font(style)?;
2096
2097        let font_ref = resolved
2098            .primary
2099            .font_ref()
2100            .ok_or(ShapingError::InvalidFontData)?;
2101
2102        let mut scaler = self
2103            .scale_context
2104            .builder(font_ref)
2105            .size(style.font_size)
2106            .build();
2107
2108        let use_color = resolved.has_colr && style.render_mode == RenderMode::Color;
2109        let use_subpixel = style.render_mode == RenderMode::Subpixel;
2110
2111        let sources: Vec<SwashSource> = if use_color {
2112            vec![SwashSource::ColorOutline(glyph_id), SwashSource::Outline]
2113        } else {
2114            vec![SwashSource::Outline]
2115        };
2116
2117        let mut render = Render::new(&sources);
2118
2119        if use_subpixel {
2120            render.format(swash::zeno::Format::Subpixel);
2121        } else {
2122            render.format(swash::zeno::Format::Alpha);
2123        }
2124
2125        if style.synthesize_styles && style.weight >= Weight(700) {
2126            render.embolden(0.04);
2127        }
2128
2129        if let Some(image) = render.render(&mut scaler, glyph_id) {
2130            log::info!("Swash rendered image for glyph {}. content: {:?}, size: {}x{}, data len: {}", glyph_id, image.content, image.placement.width, image.placement.height, image.data.len());
2131            return Ok(GlyphImage {
2132                glyph_id,
2133                width: image.placement.width,
2134                height: image.placement.height,
2135                data: image.data,
2136                x_offset: image.placement.left as f32,
2137                y_offset: image.placement.top as f32,
2138                cache_key: resolved.cache_key,
2139            });
2140        }
2141
2142        // Try fallback fonts
2143        for fallback in &resolved.fallbacks {
2144            if let Some(font_ref) = fallback.font_ref() {
2145                let mut scaler = self
2146                    .scale_context
2147                    .builder(font_ref)
2148                    .size(style.font_size)
2149                    .build();
2150                if let Some(image) = render.render(&mut scaler, glyph_id) {
2151                    return Ok(GlyphImage {
2152                        glyph_id,
2153                        width: image.placement.width,
2154                        height: image.placement.height,
2155                        data: image.data,
2156                        x_offset: image.placement.left as f32,
2157                        y_offset: image.placement.top as f32,
2158                        cache_key: resolved.cache_key,
2159                    });
2160                }
2161            }
2162        }
2163
2164        Err(ShapingError::EmptyShape(format!(
2165            "Could not rasterize glyph {}",
2166            glyph_id
2167        )))
2168    }
2169
2170    /// Extract the vector outline path for a given glyph at the specified size.
2171    ///
2172    /// # Contract
2173    /// Resolves the font using the provided TextStyle and extracts its Bezier outline.
2174    /// Returns a list of `RunicPathSegment` representing the raw MoveTo, LineTo, QuadTo,
2175    /// CubicTo, and Close commands of the glyph contours, scaled to the given size.
2176    /// If the font does not contain outline data or the glyph is empty, returns an empty path.
2177    pub fn extract_glyph_path(
2178        &mut self,
2179        glyph_id: u16,
2180        size: f32,
2181        style: &TextStyle,
2182    ) -> Result<Vec<RunicPathSegment>, ShapingError> {
2183        let resolved = self.resolve_font(style)?;
2184        let font_ref = resolved
2185            .primary
2186            .font_ref()
2187            .ok_or(ShapingError::InvalidFontData)?;
2188
2189        let mut scaler = self.scale_context.builder(font_ref).size(size).build();
2190
2191        // Helper closure to map Outline points and verbs directly into RunicPathSegment vector
2192        let map_outline_to_segments =
2193            |outline: swash::scale::outline::Outline| -> Vec<RunicPathSegment> {
2194                let mut segments = Vec::new();
2195                let mut points_iter = outline.points().iter();
2196                for verb in outline.verbs() {
2197                    match verb {
2198                        swash::zeno::Verb::MoveTo => {
2199                            if let Some(p) = points_iter.next() {
2200                                segments.push(RunicPathSegment::MoveTo { x: p.x, y: p.y });
2201                            }
2202                        }
2203                        swash::zeno::Verb::LineTo => {
2204                            if let Some(p) = points_iter.next() {
2205                                segments.push(RunicPathSegment::LineTo { x: p.x, y: p.y });
2206                            }
2207                        }
2208                        swash::zeno::Verb::QuadTo => {
2209                            if let Some(cp) = points_iter.next()
2210                                && let Some(p) = points_iter.next()
2211                            {
2212                                segments.push(RunicPathSegment::QuadTo {
2213                                    cx: cp.x,
2214                                    cy: cp.y,
2215                                    x: p.x,
2216                                    y: p.y,
2217                                });
2218                            }
2219                        }
2220                        swash::zeno::Verb::CurveTo => {
2221                            if let Some(cp1) = points_iter.next()
2222                                && let Some(cp2) = points_iter.next()
2223                                && let Some(p) = points_iter.next()
2224                            {
2225                                segments.push(RunicPathSegment::CubicTo {
2226                                    cx1: cp1.x,
2227                                    cy1: cp1.y,
2228                                    cx2: cp2.x,
2229                                    cy2: cp2.y,
2230                                    x: p.x,
2231                                    y: p.y,
2232                                });
2233                            }
2234                        }
2235                        swash::zeno::Verb::Close => {
2236                            segments.push(RunicPathSegment::Close);
2237                        }
2238                    }
2239                }
2240                segments
2241            };
2242
2243        // Use swash's outline scaler to retrieve raw curves
2244        if let Some(outline) = scaler.scale_outline(glyph_id) {
2245            return Ok(map_outline_to_segments(outline));
2246        }
2247
2248        // Try fallbacks
2249        for fallback in &resolved.fallbacks {
2250            if let Some(font_ref) = fallback.font_ref() {
2251                let mut scaler = self.scale_context.builder(font_ref).size(size).build();
2252                if let Some(outline) = scaler.scale_outline(glyph_id) {
2253                    return Ok(map_outline_to_segments(outline));
2254                }
2255            }
2256        }
2257
2258        Ok(Vec::new())
2259    }
2260
2261    /// Get font metrics for a style.
2262    pub fn font_metrics(&mut self, style: &TextStyle) -> Result<FontMetrics, ShapingError> {
2263        let resolved = self.resolve_font(style)?;
2264        let (ascent, descent, line_gap) = resolved.metrics_pixels(style.font_size);
2265
2266        Ok(FontMetrics {
2267            ascent,
2268            descent,
2269            line_gap,
2270            units_per_em: resolved.units_per_em,
2271            x_height: resolved.x_height * style.font_size / resolved.units_per_em as f32,
2272            cap_height: resolved.cap_height * style.font_size / resolved.units_per_em as f32,
2273        })
2274    }
2275
2276    /// Clear the shape cache.
2277    pub fn clear_cache(&mut self) {
2278        self.cache.clear();
2279        self.cache_order.clear();
2280    }
2281
2282    /// Get cache statistics.
2283    pub fn cache_stats(&self) -> (usize, usize) {
2284        (self.cache.len(), MAX_CACHE_SIZE)
2285    }
2286
2287    /// Get the number of faces in the database.
2288    pub fn font_count(&self) -> usize {
2289        self.db.faces().count()
2290    }
2291
2292    /// Query the variable font axes available for a given font family.
2293    ///
2294    /// Returns `Ok(None)` if the font is not variable.
2295    /// Returns `Err` if the font cannot be found.
2296    ///
2297    /// # Arguments
2298    /// * `family` — Font family name.
2299    /// * `font_size` — Font size for resolving the face.
2300    pub fn query_font_axes(
2301        &mut self,
2302        family: &str,
2303        _font_size: f32,
2304    ) -> Result<Option<Vec<FontAxisInfo>>, ShapingError> {
2305        let query = Query {
2306            families: &[Family::Name(family)],
2307            weight: Weight::NORMAL,
2308            stretch: Stretch::Normal,
2309            style: Style::Normal,
2310        };
2311
2312        let id = self
2313            .db
2314            .query(&query)
2315            .ok_or_else(|| ShapingError::NoFontFound(family.to_string()))?;
2316        let data = self
2317            .get_font_data(id)
2318            .ok_or(ShapingError::InvalidFontData)?;
2319        let _font_ref = data.font_ref().ok_or(ShapingError::InvalidFontData)?;
2320
2321        // Use ttf-parser to read the fvar table
2322        let ttf_face = rustybuzz::ttf_parser::Face::parse(data.as_bytes(), data.index)
2323            .map_err(|_| ShapingError::InvalidFontData)?;
2324
2325        // Check if this is a variable font
2326        let fvar_data = match ttf_face
2327            .raw_face()
2328            .table(rustybuzz::ttf_parser::Tag(u32::from_be_bytes(*b"fvar")))
2329        {
2330            Some(d) => d,
2331            None => return Ok(None), // Not a variable font
2332        };
2333
2334        // Parse the fvar table manually
2335        // fvar table format: version(4), offsetToData(2), reserved(2), axisCount(2), axisSize(2), instanceCount(2), instanceSize(2)
2336        if fvar_data.len() < 16 {
2337            return Ok(None);
2338        }
2339
2340        let axis_count = u16::from_be_bytes([fvar_data[8], fvar_data[9]]) as usize;
2341        let axis_size = u16::from_be_bytes([fvar_data[10], fvar_data[11]]) as usize;
2342        let data_offset = u16::from_be_bytes([fvar_data[4], fvar_data[5]]) as usize;
2343
2344        let mut axes = Vec::new();
2345        for i in 0..axis_count {
2346            let offset = data_offset + i * axis_size;
2347            if offset + axis_size > fvar_data.len() {
2348                break;
2349            }
2350
2351            let axis_data = &fvar_data[offset..offset + axis_size];
2352
2353            // fvar axis record: tag(4), minValue(4), defaultValue(4), maxValue(4), flags(2), nameID(2)
2354            if axis_data.len() < 20 {
2355                break;
2356            }
2357
2358            let tag = u32::from_be_bytes([axis_data[0], axis_data[1], axis_data[2], axis_data[3]]);
2359            let min_val =
2360                f32::from_be_bytes([axis_data[4], axis_data[5], axis_data[6], axis_data[7]]);
2361            let default_val =
2362                f32::from_be_bytes([axis_data[8], axis_data[9], axis_data[10], axis_data[11]]);
2363            let max_val =
2364                f32::from_be_bytes([axis_data[12], axis_data[13], axis_data[14], axis_data[15]]);
2365            let _name_id = u16::from_be_bytes([axis_data[18], axis_data[19]]);
2366
2367            let tag_bytes = tag.to_be_bytes();
2368            let tag_string = String::from_utf8_lossy(&tag_bytes).trim().to_string();
2369
2370            // Standard axes: wght, wdth, ital, slnt, opsz, plus many more
2371            let standard_tags: &[&[u8]] = &[
2372                b"wght", b"wdth", b"ital", b"slnt", b"opsz", b"GRAD", b"XTRA", b"XOPQ", b"YOPQ",
2373                b"YTLC", b"YTUC", b"YTAS", b"YTDE", b"YTFI", b"wdth",
2374            ];
2375            let is_standard = standard_tags.contains(&tag_bytes.as_slice());
2376
2377            axes.push(FontAxisInfo {
2378                tag,
2379                tag_string,
2380                min: min_val,
2381                max: max_val,
2382                default: default_val,
2383                is_standard,
2384            });
2385        }
2386
2387        if axes.is_empty() {
2388            Ok(None)
2389        } else {
2390            Ok(Some(axes))
2391        }
2392    }
2393
2394    // ── Backward-compatible API for cvkg-render-gpu ──────────────────────────
2395
2396    /// Shape text with a simple family/size interface (backward-compatible).
2397    ///
2398    /// This wraps `shape_layout` with a single span and default settings
2399    /// for use by the cvkg-render-gpu crate.
2400    pub fn shape(&mut self, text: &str, family: &str, size: f32) -> ShapedText {
2401        let style = TextStyle::new(family, size);
2402        let spans = vec![TextSpan::new(text, style)];
2403        self.shape_layout(&spans, None, TextAlign::Start, TextOverflow::WordWrap)
2404            .unwrap_or_else(|_| ShapedText {
2405                glyphs: Vec::new(),
2406                lines: Vec::new(),
2407                width: 0.0,
2408                height: 0.0,
2409                text: text.to_string(),
2410                spans: Vec::new(),
2411                has_rtl: false,
2412                ascent: 0.0,
2413                descent: 0.0,
2414                line_gap: 0.0,
2415                grapheme_boundaries: vec![],
2416            })
2417    }
2418
2419    /// Rasterizes a glyph by lookup using its unique composite cache key.
2420    ///
2421    /// # Contract
2422    /// The `cache_key` must match a key generated during text shaping that hashes the
2423    /// font data identity, size, styling, and glyph ID. This function resolves the matching
2424    /// glyph parameters from the shape cache and rasterizes it at the correct size and weight
2425    /// to prevent cache collisions and visual distortion. Returns `None` if no matching shaped
2426    /// glyph is present in the cache.
2427    pub fn rasterize(&mut self, cache_key: u64) -> Option<GlyphImage> {
2428        let mut found: Option<(CacheKey, GlyphInstance)> = None;
2429        for (ck, glyphs) in &self.cache {
2430            if let Some(g) = glyphs.iter().find(|g| g.cache_key == cache_key) {
2431                found = Some((*ck, *g));
2432                break;
2433            }
2434        }
2435        let (ck, glyph) = found?;
2436
2437        // Reconstruct font family from the database matching the font_cache_key
2438        let mut family = "sans-serif".to_string();
2439        let face_ids: Vec<fontdb::ID> = self.db.faces().map(|f| f.id).collect();
2440        for id in face_ids {
2441            if let Some(font_data) = self.get_font_data(id)
2442                && let Some(font_ref) = font_data.font_ref()
2443                && font_ref.key.value() == ck.font_cache_key
2444            {
2445                if let Some(face) = self.db.face(id)
2446                    && let Some((name, _)) = face.families.first()
2447                {
2448                    family = name.clone();
2449                }
2450                break;
2451            }
2452        }
2453
2454        let mut style = TextStyle::new(&family, ck.font_size as f32 / 2.0);
2455        style.weight = Weight(ck.weight);
2456        style.stretch = match ck.stretch {
2457            1 => Stretch::UltraCondensed,
2458            2 => Stretch::ExtraCondensed,
2459            3 => Stretch::Condensed,
2460            4 => Stretch::SemiCondensed,
2461            5 => Stretch::Normal,
2462            6 => Stretch::SemiExpanded,
2463            7 => Stretch::Expanded,
2464            8 => Stretch::ExtraExpanded,
2465            9 => Stretch::UltraExpanded,
2466            _ => Stretch::Normal,
2467        };
2468        style.style = match ck.style {
2469            0 => Style::Normal,
2470            1 => Style::Italic,
2471            2 => Style::Oblique,
2472            _ => Style::Normal,
2473        };
2474
2475        let mut image = self.rasterize_glyph(glyph.glyph_id, &style).ok()?;
2476        image.cache_key = cache_key;
2477        Some(image)
2478    }
2479}
2480
2481fn byte_offset_level(bidi: &BidiInfo, byte_offset: usize) -> unicode_bidi::Level {
2482    if let Some(para) = bidi.paragraphs.first() {
2483        let relative = byte_offset.saturating_sub(para.range.start);
2484        if relative < bidi.levels.len() {
2485            return bidi.levels[relative];
2486        }
2487    }
2488    unicode_bidi::Level::ltr()
2489}
2490
2491fn line_bidi_level(bidi: &BidiInfo, byte_offset: usize) -> unicode_bidi::Level {
2492    byte_offset_level(bidi, byte_offset)
2493}
2494
2495fn reorder_line_rtl(glyphs: &mut [GlyphInstance], start: usize, end: usize) {
2496    if end <= start {
2497        return;
2498    }
2499    let slice = &mut glyphs[start..end];
2500    slice.reverse();
2501    let mut x = 0.0f32;
2502    for g in slice.iter_mut() {
2503        g.x = x;
2504        x += g.advance_width;
2505    }
2506}
2507
2508impl Default for RunicTextEngine {
2509    fn default() -> Self {
2510        Self::new()
2511    }
2512}
2513
2514// ── FontMetrics ──────────────────────────────────────────────────────────────
2515
2516/// Font metrics for a given style.
2517#[derive(Debug, Clone, Copy, PartialEq)]
2518pub struct FontMetrics {
2519    /// Ascent above baseline.
2520    pub ascent: f32,
2521    /// Descent below baseline (positive value).
2522    pub descent: f32,
2523    /// Recommended line gap.
2524    pub line_gap: f32,
2525    /// Units per em.
2526    pub units_per_em: u16,
2527    /// X-height.
2528    pub x_height: f32,
2529    /// Cap height.
2530    pub cap_height: f32,
2531}
2532
2533// ── Tests ────────────────────────────────────────────────────────────────────
2534
2535// ── MSDF Glyph Rendering ────────────────────────────────────────────────────
2536
2537pub mod msdf;
2538
2539// ── Knuth-Plass Line Breaking ───────────────────────────────────────────────
2540
2541pub mod knuth_plass;
2542
2543// ── Color Emoji Atlas ───────────────────────────────────────────────────────
2544
2545pub mod emoji;
2546
2547// ── Subpixel LCD Positioning ────────────────────────────────────────────────
2548
2549pub mod subpixel;
2550
2551// ── Tests ────────────────────────────────────────────────────────────────────
2552
2553#[cfg(test)]
2554mod tests {
2555    use super::*;
2556
2557    #[test]
2558    fn test_basic_shaping() {
2559        let mut engine = RunicTextEngine::new_test();
2560        let style = TextStyle::new("Jupiteroid", 16.0);
2561        let glyphs = engine
2562            .shape_run("Hello", &style, Direction::LeftToRight)
2563            .unwrap();
2564        assert!(!glyphs.is_empty(), "Should produce glyphs for 'Hello'");
2565    }
2566
2567    #[test]
2568    fn test_hit_test() {
2569        let mut engine = RunicTextEngine::new_test();
2570        let style = TextStyle::new("Jupiteroid", 16.0);
2571        let spans = vec![TextSpan::new("Hello", style.clone())];
2572        let shaped = engine
2573            .shape_layout(&spans, None, TextAlign::Start, TextOverflow::WordWrap)
2574            .unwrap();
2575        let (glyph_idx, cluster) = shaped.hit_test(0);
2576        assert!(glyph_idx < shaped.glyphs.len());
2577        assert_eq!(cluster, 0);
2578    }
2579
2580    #[test]
2581    fn test_word_wrapping() {
2582        let mut engine = RunicTextEngine::new_test();
2583        let style = TextStyle::new("Jupiteroid", 16.0);
2584        let spans = vec![TextSpan::new("Hello World This Is A Test", style.clone())];
2585        let shaped = engine
2586            .shape_layout(&spans, Some(80.0), TextAlign::Start, TextOverflow::WordWrap)
2587            .unwrap();
2588        assert!(
2589            shaped.lines.len() > 1,
2590            "Should wrap into multiple lines, got {}",
2591            shaped.lines.len()
2592        );
2593    }
2594
2595    #[test]
2596    fn test_text_style_defaults() {
2597        let style = TextStyle::default();
2598        assert_eq!(style.family, "Jupiteroid");
2599        assert_eq!(style.font_size, DEFAULT_FONT_SIZE);
2600        assert_eq!(style.weight, Weight::NORMAL);
2601        assert_eq!(style.color, [255, 255, 255, 255]);
2602        assert!(!style.fallback_families.is_empty());
2603    }
2604
2605    #[test]
2606    fn test_text_style_builder() {
2607        let style = TextStyle::new("Jupiteroid", 24.0)
2608            .with_weight(700)
2609            .italic()
2610            .with_color(255, 0, 0, 255)
2611            .with_letter_spacing(1.5)
2612            .with_underline();
2613
2614        assert_eq!(style.font_size, 24.0);
2615        assert_eq!(style.weight, Weight(700));
2616        assert_eq!(style.style, Style::Italic);
2617        assert_eq!(style.color, [255, 0, 0, 255]);
2618        assert_eq!(style.letter_spacing, 1.5);
2619        assert!(style.decorations.underline);
2620    }
2621
2622    #[test]
2623    fn test_line_height() {
2624        let multiple = LineHeight::Multiple(1.5);
2625        assert_eq!(multiple.to_pixels(16.0), 24.0);
2626
2627        let fixed = LineHeight::Fixed(20.0);
2628        assert_eq!(fixed.to_pixels(16.0), 20.0);
2629    }
2630
2631    #[test]
2632    fn test_cache_key_deterministic() {
2633        let key1 = CacheKey::new(
2634            "Hello",
2635            12345,
2636            16.0,
2637            Weight::NORMAL,
2638            Stretch::Normal,
2639            Style::Normal,
2640            Direction::LeftToRight,
2641            0.0,
2642            0.0,
2643        );
2644        let key2 = CacheKey::new(
2645            "Hello",
2646            12345,
2647            16.0,
2648            Weight::NORMAL,
2649            Stretch::Normal,
2650            Style::Normal,
2651            Direction::LeftToRight,
2652            0.0,
2653            0.0,
2654        );
2655        assert_eq!(key1, key2);
2656
2657        let key3 = CacheKey::new(
2658            "World",
2659            12345,
2660            16.0,
2661            Weight::NORMAL,
2662            Stretch::Normal,
2663            Style::Normal,
2664            Direction::LeftToRight,
2665            0.0,
2666            0.0,
2667        );
2668        assert_ne!(key1, key3);
2669    }
2670
2671    #[test]
2672    fn test_cursor_position() {
2673        let mut engine = RunicTextEngine::new_test();
2674        let style = TextStyle::new("Jupiteroid", 16.0);
2675        let spans = vec![TextSpan::new("Hello", style.clone())];
2676        let shaped = engine
2677            .shape_layout(&spans, None, TextAlign::Start, TextOverflow::WordWrap)
2678            .unwrap();
2679        let (x, line) = shaped.cursor_position(0);
2680        assert_eq!(line, 0);
2681        assert!(x >= 0.0);
2682    }
2683
2684    #[test]
2685    fn test_selection_rects() {
2686        let mut engine = RunicTextEngine::new_test();
2687        let style = TextStyle::new("Jupiteroid", 16.0);
2688        let spans = vec![TextSpan::new("Hello World", style.clone())];
2689        let shaped = engine
2690            .shape_layout(&spans, None, TextAlign::Start, TextOverflow::WordWrap)
2691            .unwrap();
2692        let rects = shaped.selection_rects(0, 5);
2693        assert!(
2694            !rects.is_empty(),
2695            "Should produce selection rects for 'Hello'"
2696        );
2697    }
2698
2699    #[test]
2700    fn test_open_type_features() {
2701        let liga = OpenTypeFeature::liga();
2702        assert_eq!(liga.tag, u32::from_be_bytes(*b"liga"));
2703        assert_eq!(liga.value, 1);
2704
2705        let kern = OpenTypeFeature::kern();
2706        assert_eq!(kern.tag, u32::from_be_bytes(*b"kern"));
2707    }
2708
2709    #[test]
2710    fn test_variable_axes() {
2711        let weight = VariableAxis::weight(700.0);
2712        assert_eq!(weight.tag, u32::from_be_bytes(*b"wght"));
2713        assert_eq!(weight.value, 700.0);
2714
2715        let italic = VariableAxis::italic(1.0);
2716        assert_eq!(italic.tag, u32::from_be_bytes(*b"ital"));
2717    }
2718
2719    #[test]
2720    fn test_font_metrics() {
2721        let mut engine = RunicTextEngine::new_test();
2722        let style = TextStyle::new("Jupiteroid", 16.0);
2723        let metrics = engine.font_metrics(&style).unwrap();
2724        assert!(metrics.ascent > 0.0);
2725        assert!(metrics.descent > 0.0);
2726        assert!(metrics.units_per_em > 0);
2727    }
2728
2729    #[test]
2730    fn test_empty_input() {
2731        let mut engine = RunicTextEngine::new_test();
2732        let style = TextStyle::new("Jupiteroid", 16.0);
2733        let spans = vec![TextSpan::new("", style.clone())];
2734        let shaped = engine
2735            .shape_layout(&spans, None, TextAlign::Start, TextOverflow::WordWrap)
2736            .unwrap();
2737        assert!(shaped.glyphs.is_empty());
2738    }
2739
2740    #[test]
2741    fn test_multi_span_layout() {
2742        let mut engine = RunicTextEngine::new_test();
2743        let style1 = TextStyle::new("Jupiteroid", 16.0);
2744        let style2 = TextStyle::new("Jupiteroid", 24.0).with_color(255, 0, 0, 255);
2745        let spans = vec![
2746            TextSpan::at("Hello ", style1, 0),
2747            TextSpan::at("World", style2, 6),
2748        ];
2749        let shaped = engine
2750            .shape_layout(&spans, None, TextAlign::Start, TextOverflow::WordWrap)
2751            .unwrap();
2752        assert!(!shaped.glyphs.is_empty());
2753        assert_eq!(shaped.text, "Hello World");
2754    }
2755
2756    #[test]
2757    fn test_text_align_center() {
2758        let mut engine = RunicTextEngine::new_test();
2759        let style = TextStyle::new("Jupiteroid", 16.0);
2760        let spans = vec![TextSpan::new("Hi", style.clone())];
2761        let shaped = engine
2762            .shape_layout(
2763                &spans,
2764                Some(200.0),
2765                TextAlign::Center,
2766                TextOverflow::WordWrap,
2767            )
2768            .unwrap();
2769        assert!(!shaped.lines.is_empty());
2770        let line = &shaped.lines[0];
2771        assert!(
2772            line.x_offset > 0.0,
2773            "Center-aligned line should have positive x_offset, got {}",
2774            line.x_offset
2775        );
2776    }
2777
2778    #[test]
2779    fn test_text_overflow_ellipsis() {
2780        let mut engine = RunicTextEngine::new_test();
2781        let style = TextStyle::new("Jupiteroid", 16.0);
2782        let spans = vec![TextSpan::new("Hello World This Is Long", style.clone())];
2783        let shaped = engine
2784            .shape_layout(&spans, Some(50.0), TextAlign::Start, TextOverflow::Ellipsis)
2785            .unwrap();
2786        assert!(!shaped.lines.is_empty());
2787    }
2788
2789    #[test]
2790    fn test_decorations() {
2791        let decorations = TextDecorations {
2792            underline: true,
2793            strikethrough: true,
2794            overline: false,
2795        };
2796        assert!(decorations.underline);
2797        assert!(decorations.strikethrough);
2798        assert!(!decorations.overline);
2799    }
2800
2801    #[test]
2802    fn test_cache_eviction() {
2803        let mut engine = RunicTextEngine::new_test();
2804        let style = TextStyle::new("Jupiteroid", 16.0);
2805
2806        let _ = engine.shape_run("Test", &style, Direction::LeftToRight);
2807
2808        let (size, max) = engine.cache_stats();
2809        assert!(size > 0, "Cache should have entries after shaping");
2810        assert_eq!(max, MAX_CACHE_SIZE);
2811
2812        engine.clear_cache();
2813        let (size, _) = engine.cache_stats();
2814        assert_eq!(size, 0);
2815    }
2816
2817    #[test]
2818    fn test_font_count() {
2819        let engine = RunicTextEngine::new_test();
2820        let count = engine.font_count();
2821        assert!(count > 0, "Should find at least one font, got {}", count);
2822    }
2823
2824    #[test]
2825    fn test_jupiteroid_font_available() {
2826        let engine = RunicTextEngine::new_test();
2827        assert!(engine.font_count() > 0, "Should have fonts loaded");
2828    }
2829
2830    #[test]
2831    fn test_extract_glyph_path() {
2832        let mut engine = RunicTextEngine::new_test();
2833        let style = TextStyle::new("Jupiteroid", 16.0);
2834
2835        // Shape a character to get a guaranteed valid glyph ID in the test font
2836        let glyphs = engine
2837            .shape_run("A", &style, Direction::LeftToRight)
2838            .unwrap();
2839        assert!(!glyphs.is_empty(), "Shaping 'A' should yield a glyph");
2840        let glyph_id = glyphs[0].glyph_id;
2841
2842        // Extract the outline vector path for this glyph
2843        let path = engine.extract_glyph_path(glyph_id, 16.0, &style).unwrap();
2844
2845        // Verify that the outline path is not empty and starts with MoveTo, containing at least one Close
2846        assert!(!path.is_empty(), "Glyph path for 'A' should not be empty");
2847        match path[0] {
2848            RunicPathSegment::MoveTo { x, y } => {
2849                assert!(x.is_finite());
2850                assert!(y.is_finite());
2851            }
2852            _ => panic!("Expected first segment to be a MoveTo, got {:?}", path[0]),
2853        }
2854
2855        let has_close = path
2856            .iter()
2857            .any(|seg| matches!(seg, RunicPathSegment::Close));
2858        assert!(
2859            has_close,
2860            "Expected glyph path to contain at least one Close command"
2861        );
2862
2863        // Assert all segment coordinates are finite values
2864        for segment in &path {
2865            match *segment {
2866                RunicPathSegment::MoveTo { x, y } => {
2867                    assert!(x.is_finite());
2868                    assert!(y.is_finite());
2869                }
2870                RunicPathSegment::LineTo { x, y } => {
2871                    assert!(x.is_finite());
2872                    assert!(y.is_finite());
2873                }
2874                RunicPathSegment::QuadTo { cx, cy, x, y } => {
2875                    assert!(cx.is_finite());
2876                    assert!(cy.is_finite());
2877                    assert!(x.is_finite());
2878                    assert!(y.is_finite());
2879                }
2880                RunicPathSegment::CubicTo {
2881                    cx1,
2882                    cy1,
2883                    cx2,
2884                    cy2,
2885                    x,
2886                    y,
2887                } => {
2888                    assert!(cx1.is_finite());
2889                    assert!(cy1.is_finite());
2890                    assert!(cx2.is_finite());
2891                    assert!(cy2.is_finite());
2892                    assert!(x.is_finite());
2893                    assert!(y.is_finite());
2894                }
2895                RunicPathSegment::Close => {}
2896            }
2897        }
2898    }
2899
2900    #[test]
2901    fn test_new_text_style_fields() {
2902        let style = TextStyle::new("Jupiteroid", 16.0)
2903            .with_outline_rendering(true)
2904            .with_material_effect(42);
2905
2906        assert!(style.outline_rendering);
2907        assert_eq!(style.material_effect_id, 42);
2908    }
2909
2910    #[test]
2911    fn test_text_path_sampling() {
2912        // Curve: (0,0) -> (100, 100) -> (200, 0)
2913        let tp = TextPath::new(vec![(0.0, 0.0), (100.0, 100.0), (200.0, 0.0)]);
2914        let ((x_start, y_start), angle_start) = tp.sample(0.0);
2915        let ((x_mid, y_mid), angle_mid) = tp.sample(0.5);
2916
2917        assert_eq!(x_start, 0.0);
2918        assert_eq!(y_start, 0.0);
2919        assert!(angle_start > 0.0);
2920
2921        assert_eq!(x_mid, 100.0);
2922        assert_eq!(y_mid, 50.0);
2923        assert!(angle_mid.abs() < 1e-4); // peak tangent is horizontal (angle=0)
2924    }
2925
2926    #[test]
2927    fn test_layout_boundary_circle() {
2928        let boundary = LayoutBoundary::Circle {
2929            cx: 100.0,
2930            cy: 100.0,
2931            r: 50.0,
2932        };
2933        // At y = 100 (center of circle), allowed span should be [50.0, 150.0]
2934        let span = boundary.allowed_span(100.0).unwrap();
2935        assert_eq!(span.0, 50.0);
2936        assert_eq!(span.1, 150.0);
2937
2938        // At y = 150 (edge), dy = 50 -> dx = 0 -> allowed span [100.0, 100.0]
2939        let span_edge = boundary.allowed_span(150.0);
2940        assert!(span_edge.is_none() || span_edge.unwrap().0 >= 100.0);
2941    }
2942
2943    #[test]
2944    fn test_shape_layout_with_path_and_boundary() {
2945        let mut engine = RunicTextEngine::new_test();
2946        let style = TextStyle::new("Jupiteroid", 16.0);
2947        let spans = vec![TextSpan::new(
2948            "Hello World Curved Layout Test String",
2949            style,
2950        )];
2951
2952        // Test with curve path
2953        let tp = TextPath::new(vec![(0.0, 0.0), (100.0, 50.0), (200.0, 0.0)]);
2954        let shaped_path = engine
2955            .shape_layout_ex(
2956                &spans,
2957                None,
2958                TextAlign::Start,
2959                TextOverflow::WordWrap,
2960                Some(tp),
2961                None,
2962            )
2963            .unwrap();
2964        assert!(!shaped_path.glyphs.is_empty());
2965        // Verify glyph angles are non-zero due to curve tangent mapping
2966        let has_angles = shaped_path.glyphs.iter().any(|g| g.angle != 0.0);
2967        assert!(has_angles);
2968
2969        // Test with boundary circle
2970        let boundary = LayoutBoundary::Circle {
2971            cx: 100.0,
2972            cy: 100.0,
2973            r: 50.0,
2974        };
2975        let shaped_boundary = engine
2976            .shape_layout_ex(
2977                &spans,
2978                None,
2979                TextAlign::Start,
2980                TextOverflow::WordWrap,
2981                None,
2982                Some(boundary),
2983            )
2984            .unwrap();
2985        assert!(!shaped_boundary.glyphs.is_empty());
2986    }
2987
2988    #[test]
2989    fn test_portal_alignment() {
2990        let mut engine = RunicTextEngine::new_test();
2991        let style = TextStyle::new("Jupiteroid", 16.0);
2992
2993        // Construct portal spans with different vertical alignment modes using correct byte offsets
2994        let spans = vec![
2995            TextSpan::at("Txt ", style.clone(), 0),
2996            TextSpan::portal_at(
2997                30.0,
2998                20.0,
2999                PortalAlignment::Baseline,
3000                "p_base",
3001                style.clone(),
3002                4,
3003            ),
3004            TextSpan::portal_at(30.0, 20.0, PortalAlignment::Top, "p_top", style.clone(), 7),
3005            TextSpan::portal_at(
3006                30.0,
3007                20.0,
3008                PortalAlignment::Center,
3009                "p_center",
3010                style.clone(),
3011                10,
3012            ),
3013            TextSpan::portal_at(
3014                30.0,
3015                20.0,
3016                PortalAlignment::Bottom,
3017                "p_bottom",
3018                style.clone(),
3019                13,
3020            ),
3021        ];
3022
3023        // 1. Verify single-line layout (no wrapping)
3024        let shaped_single = engine
3025            .shape_layout(&spans, None, TextAlign::Start, TextOverflow::WordWrap)
3026            .unwrap();
3027
3028        let portals_s: Vec<_> = shaped_single
3029            .glyphs
3030            .iter()
3031            .filter(|g| g.glyph_id == 0xFFFF)
3032            .collect();
3033        assert_eq!(portals_s.len(), 4);
3034
3035        let baseline_y = shaped_single.lines[0].baseline_y;
3036        let ascent = shaped_single.ascent;
3037        let line_height_px = shaped_single.lines[0].height;
3038
3039        // Baseline alignment -> y = baseline_y
3040        assert_eq!(portals_s[0].y, baseline_y);
3041
3042        // Top alignment -> y = baseline_y - ascent
3043        assert_eq!(portals_s[1].y, baseline_y - ascent);
3044
3045        // Center alignment -> y = baseline_y - ascent + (line_height - portal_h) / 2
3046        assert_eq!(
3047            portals_s[2].y,
3048            baseline_y - ascent + (line_height_px - 20.0) / 2.0
3049        );
3050
3051        // Bottom alignment -> y = baseline_y - ascent + line_height - portal_h
3052        assert_eq!(portals_s[3].y, baseline_y - ascent + line_height_px - 20.0);
3053
3054        // 2. Verify wrapped line layouts
3055        let shaped_wrapped = engine
3056            .shape_layout(&spans, Some(50.0), TextAlign::Start, TextOverflow::WordWrap)
3057            .unwrap();
3058
3059        let portals_w: Vec<_> = shaped_wrapped
3060            .glyphs
3061            .iter()
3062            .filter(|g| g.glyph_id == 0xFFFF)
3063            .collect();
3064        assert_eq!(portals_w.len(), 4);
3065    }
3066}