Skip to main content

rustial_engine/symbols/
text_shaper.rs

1//! Text shaping engine for symbol label rendering.
2//!
3//! Produces correctly-positioned glyph quads from input text, handling:
4//!
5//! - **Font loading** via `ttf-parser` (TrueType/OpenType).
6//! - **Text shaping** via `rustybuzz` (HarfBuzz port): ligatures, kerning,
7//!   contextual Arabic forms, CJK, and complex scripts.
8//! - **BiDi reordering** via `unicode-bidi` (UAX #9): correct visual ordering
9//!   for mixed LTR/RTL content.
10//! - **Line wrapping** with a badness-based algorithm (MapLibre pattern).
11//! - **Glyph positioning** with per-glyph x/y offsets, line justification,
12//!   and anchor alignment.
13//!
14//! All internal layout units are **1/24 em** (MapLibre's `ONE_EM = 24`).
15//!
16//! Gated behind the `text-shaping` feature flag.
17
18#[cfg(feature = "text-shaping")]
19mod inner {
20    use std::collections::HashMap;
21    use std::sync::Arc;
22
23    // Re-import parent module types (symbols/mod.rs).
24    use crate::symbols::{GlyphProvider, GlyphRaster, SymbolTextTransform, SymbolWritingMode};
25
26    /// Internal em-space constant: 24 layout units = 1 em.
27    pub const ONE_EM: f32 = 24.0;
28
29    // -----------------------------------------------------------------------
30    // Font registry
31    // -----------------------------------------------------------------------
32
33    /// An owned font face that can be shared across threads.
34    ///
35    /// Wraps the raw font data and a parsed `ttf_parser::Face` behind an `Arc`
36    /// so clones are cheap. The `rustybuzz::Face` is created on-demand during
37    /// shaping since it borrows from the data.
38    #[derive(Clone)]
39    pub struct OwnedFont {
40        /// Raw font file bytes (TTF/OTF).
41        data: Arc<Vec<u8>>,
42        /// Face index inside a TTC collection (usually 0).
43        face_index: u32,
44        /// Units-per-em from the font head table.
45        units_per_em: u16,
46    }
47
48    impl std::fmt::Debug for OwnedFont {
49        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50            f.debug_struct("OwnedFont")
51                .field("face_index", &self.face_index)
52                .field("units_per_em", &self.units_per_em)
53                .finish()
54        }
55    }
56
57    impl OwnedFont {
58        /// Parse a font from raw TTF/OTF bytes.
59        ///
60        /// Returns `None` if the data is not a valid font.
61        pub fn from_bytes(data: Vec<u8>, face_index: u32) -> Option<Self> {
62            let face = ttf_parser::Face::parse(&data, face_index).ok()?;
63            let units_per_em = face.units_per_em();
64            Some(Self {
65                data: Arc::new(data),
66                units_per_em,
67                face_index,
68            })
69        }
70
71        /// Units-per-em for this font.
72        pub fn units_per_em(&self) -> u16 {
73            self.units_per_em
74        }
75
76        /// Scale factor to convert font units to layout units (1/24 em).
77        fn scale_to_layout(&self) -> f32 {
78            ONE_EM / self.units_per_em as f32
79        }
80
81        /// Access the raw font data.
82        pub fn data(&self) -> &[u8] {
83            &self.data
84        }
85
86        /// Face index inside a TTC collection.
87        pub fn face_index(&self) -> u32 {
88            self.face_index
89        }
90    }
91
92    /// Registry of loaded fonts keyed by font stack names.
93    ///
94    /// A "font stack" is a comma-separated list of font family names
95    /// (e.g. `"Noto Sans Regular, Arial Unicode MS Bold"`). The registry
96    /// resolves the first available font for each stack.
97    #[derive(Debug, Clone, Default)]
98    pub struct FontRegistry {
99        /// Individual fonts keyed by their family name.
100        fonts: HashMap<String, OwnedFont>,
101        /// Cached stack → resolved font mapping.
102        stack_cache: HashMap<String, Option<String>>,
103    }
104
105    impl FontRegistry {
106        /// Create an empty font registry.
107        pub fn new() -> Self {
108            Self::default()
109        }
110
111        /// Register a font under a family name.
112        pub fn register(&mut self, family_name: impl Into<String>, font: OwnedFont) {
113            let name = family_name.into();
114            self.fonts.insert(name, font);
115            // Invalidate stack cache since new fonts may resolve previously
116            // unresolvable stacks.
117            self.stack_cache.clear();
118        }
119
120        /// How many individual fonts are registered.
121        pub fn font_count(&self) -> usize {
122            self.fonts.len()
123        }
124
125        /// Resolve a font stack to the first available font.
126        ///
127        /// Returns the family name of the resolved font, or `None` if no font
128        /// in the stack is registered.
129        pub fn resolve_stack(&mut self, font_stack: &str) -> Option<&str> {
130            if !self.stack_cache.contains_key(font_stack) {
131                let resolved = font_stack
132                    .split(',')
133                    .map(str::trim)
134                    .find(|name| self.fonts.contains_key(*name))
135                    .map(String::from);
136                self.stack_cache.insert(font_stack.to_owned(), resolved);
137            }
138            self.stack_cache
139                .get(font_stack)
140                .and_then(|opt| opt.as_deref())
141        }
142
143        /// Get a font by its exact family name.
144        pub fn get_font(&self, family_name: &str) -> Option<&OwnedFont> {
145            self.fonts.get(family_name)
146        }
147    }
148
149    // -----------------------------------------------------------------------
150    // Shaped output types
151    // -----------------------------------------------------------------------
152
153    /// A positioned glyph after shaping and layout.
154    ///
155    /// Coordinates are in layout units (1/24 em). Multiply by
156    /// `(target_size_px / ONE_EM)` to get pixel coordinates.
157    #[derive(Debug, Clone, PartialEq)]
158    pub struct PositionedGlyph {
159        /// Unicode codepoint.
160        pub codepoint: char,
161        /// Glyph ID in the font (for atlas lookup).
162        pub glyph_id: u16,
163        /// Horizontal offset from the label anchor in layout units.
164        pub x: f32,
165        /// Vertical offset from the label anchor in layout units.
166        pub y: f32,
167        /// Glyph advance width in layout units.
168        pub advance: f32,
169        /// Glyph bitmap width in font units (for atlas sizing).
170        pub metrics_width: f32,
171        /// Glyph bitmap height in font units.
172        pub metrics_height: f32,
173        /// Left bearing in font units.
174        pub metrics_left: f32,
175        /// Top bearing in font units.
176        pub metrics_top: f32,
177        /// Font stack that supplied this glyph.
178        pub font_stack: String,
179        /// Whether this glyph is in vertical orientation.
180        pub vertical: bool,
181        /// Line index (0-based).
182        pub line_index: usize,
183    }
184
185    /// A shaped line of positioned glyphs.
186    #[derive(Debug, Clone, PartialEq)]
187    pub struct ShapedLine {
188        /// Positioned glyphs on this line.
189        pub glyphs: Vec<PositionedGlyph>,
190        /// Extra vertical offset for tall inline elements.
191        pub line_offset: f32,
192    }
193
194    /// Result of text shaping: positioned lines with a bounding box.
195    #[derive(Debug, Clone, PartialEq)]
196    pub struct ShapedText {
197        /// Positioned lines of glyphs.
198        pub lines: Vec<ShapedLine>,
199        /// Bounding box: left edge in layout units.
200        pub left: f32,
201        /// Bounding box: top edge in layout units.
202        pub top: f32,
203        /// Bounding box: right edge in layout units.
204        pub right: f32,
205        /// Bounding box: bottom edge in layout units.
206        pub bottom: f32,
207        /// Original text (post-transform).
208        pub text: String,
209        /// Writing mode used for shaping.
210        pub writing_mode: SymbolWritingMode,
211    }
212
213    impl ShapedText {
214        /// Total number of positioned glyphs across all lines.
215        pub fn glyph_count(&self) -> usize {
216            self.lines.iter().map(|l| l.glyphs.len()).sum()
217        }
218
219        /// Width in layout units.
220        pub fn width(&self) -> f32 {
221            self.right - self.left
222        }
223
224        /// Height in layout units.
225        pub fn height(&self) -> f32 {
226            self.bottom - self.top
227        }
228    }
229
230    // -----------------------------------------------------------------------
231    // Text shaping options
232    // -----------------------------------------------------------------------
233
234    /// Horizontal text justification.
235    #[derive(Debug, Clone, Copy, PartialEq)]
236    pub enum TextJustify {
237        /// Left-aligned.
238        Left,
239        /// Centered.
240        Center,
241        /// Right-aligned.
242        Right,
243    }
244
245    /// Text anchor alignment (maps to MapLibre's horizontal/vertical align).
246    #[derive(Debug, Clone, Copy, PartialEq)]
247    pub enum TextAnchor {
248        /// Centered.
249        Center,
250        /// Left-aligned.
251        Left,
252        /// Right-aligned.
253        Right,
254        /// Top-aligned.
255        Top,
256        /// Bottom-aligned.
257        Bottom,
258        /// Top-left corner.
259        TopLeft,
260        /// Top-right corner.
261        TopRight,
262        /// Bottom-left corner.
263        BottomLeft,
264        /// Bottom-right corner.
265        BottomRight,
266    }
267
268    impl TextAnchor {
269        /// Horizontal alignment factor: 0.0 = left, 0.5 = center, 1.0 = right.
270        pub fn horizontal_align(self) -> f32 {
271            match self {
272                TextAnchor::Left | TextAnchor::TopLeft | TextAnchor::BottomLeft => 0.0,
273                TextAnchor::Right | TextAnchor::TopRight | TextAnchor::BottomRight => 1.0,
274                _ => 0.5,
275            }
276        }
277
278        /// Vertical alignment factor: 0.0 = top, 0.5 = center, 1.0 = bottom.
279        pub fn vertical_align(self) -> f32 {
280            match self {
281                TextAnchor::Top | TextAnchor::TopLeft | TextAnchor::TopRight => 0.0,
282                TextAnchor::Bottom | TextAnchor::BottomLeft | TextAnchor::BottomRight => 1.0,
283                _ => 0.5,
284            }
285        }
286    }
287
288    /// Options for the text shaping pipeline.
289    #[derive(Debug, Clone)]
290    pub struct ShapeTextOptions {
291        /// Font stack name (comma-separated).
292        pub font_stack: String,
293        /// Maximum label width before wrapping, in em units.
294        /// `None` disables wrapping.
295        pub max_width: Option<f32>,
296        /// Line height in em units (default 1.2).
297        pub line_height: f32,
298        /// Extra letter spacing in em units (default 0.0).
299        pub letter_spacing: f32,
300        /// Text justification.
301        pub justify: TextJustify,
302        /// Anchor alignment.
303        pub anchor: TextAnchor,
304        /// Writing mode.
305        pub writing_mode: SymbolWritingMode,
306        /// Text transform.
307        pub text_transform: SymbolTextTransform,
308    }
309
310    impl Default for ShapeTextOptions {
311        fn default() -> Self {
312            Self {
313                font_stack: String::new(),
314                max_width: Some(10.0),
315                line_height: 1.2,
316                letter_spacing: 0.0,
317                justify: TextJustify::Center,
318                anchor: TextAnchor::Center,
319                writing_mode: SymbolWritingMode::Horizontal,
320                text_transform: SymbolTextTransform::None,
321            }
322        }
323    }
324
325    // -----------------------------------------------------------------------
326    // BiDi reordering
327    // -----------------------------------------------------------------------
328
329    /// Reorder a string according to the Unicode Bidirectional Algorithm.
330    ///
331    /// Returns the visual-order string for display.
332    pub fn bidi_reorder(text: &str) -> String {
333        use unicode_bidi::{BidiInfo, Level};
334
335        let bidi = BidiInfo::new(text, Some(Level::ltr()));
336        let mut output = String::with_capacity(text.len());
337        for para in &bidi.paragraphs {
338            let line = para.range.clone();
339            let display = bidi.reorder_line(para, line);
340            output.push_str(&display);
341        }
342        output
343    }
344
345    /// Check whether text contains any RTL characters.
346    pub fn contains_rtl(text: &str) -> bool {
347        use unicode_bidi::BidiClass;
348
349        text.chars().any(|c| {
350            let cls = unicode_bidi::bidi_class(c);
351            matches!(cls, BidiClass::R | BidiClass::AL | BidiClass::AN)
352        })
353    }
354
355    // -----------------------------------------------------------------------
356    // Line breaking
357    // -----------------------------------------------------------------------
358
359    /// Characters at which line breaking is allowed (MapLibre pattern).
360    pub(crate) fn is_breakable(c: char) -> bool {
361        matches!(
362            c,
363            '\n' | ' ' | '&' | '(' | ')' | '+' | '-' | '/'
364            | '\u{00AD}' // soft hyphen
365            | '\u{00B7}' // middle dot
366            | '\u{200B}' // zero-width space
367            | '\u{2010}' // hyphen
368            | '\u{2013}' // en-dash
369            | '\u{2027}' // interpunct
370        )
371    }
372
373    /// Whether a character is CJK and allows ideographic breaking.
374    pub(crate) fn allows_ideographic_break(c: char) -> bool {
375        let cp = c as u32;
376        // CJK Unified Ideographs and common CJK ranges.
377        (0x4E00..=0x9FFF).contains(&cp)
378            || (0x3400..=0x4DBF).contains(&cp)
379            || (0x20000..=0x2A6DF).contains(&cp)
380            || (0x2A700..=0x2B73F).contains(&cp)
381            || (0x2B740..=0x2B81F).contains(&cp)
382            || (0x2B820..=0x2CEAF).contains(&cp)
383            || (0xF900..=0xFAFF).contains(&cp)
384            || (0x2F800..=0x2FA1F).contains(&cp)
385            // CJK Compatibility Ideographs
386            || (0x3000..=0x303F).contains(&cp) // CJK Symbols
387            || (0x3040..=0x309F).contains(&cp) // Hiragana
388            || (0x30A0..=0x30FF).contains(&cp) // Katakana
389            || (0xFF00..=0xFFEF).contains(&cp) // Fullwidth forms
390    }
391
392    /// Determine line break positions using a badness-based algorithm.
393    ///
394    /// Returns indices into `advances` where lines should break.
395    /// Each break index is the first glyph of the **next** line.
396    pub(crate) fn determine_line_breaks(
397        chars: &[char],
398        advances: &[f32],
399        max_width_lu: f32,
400    ) -> Vec<usize> {
401        if chars.is_empty() || max_width_lu <= 0.0 {
402            return Vec::new();
403        }
404
405        let total_width: f32 = advances.iter().sum();
406        if total_width <= max_width_lu {
407            return Vec::new();
408        }
409
410        let line_count = (total_width / max_width_lu).ceil().max(1.0);
411        let target_width = total_width / line_count;
412
413        // Dynamic programming: for each breakable position, store (cost, prev_break).
414        struct BreakCandidate {
415            index: usize,
416            x: f32,
417            cost: f32,
418            prev: Option<usize>,
419        }
420
421        let mut candidates: Vec<BreakCandidate> = Vec::new();
422        // Sentinel: start of text.
423        candidates.push(BreakCandidate {
424            index: 0,
425            x: 0.0,
426            cost: 0.0,
427            prev: None,
428        });
429
430        let mut pen_x = 0.0f32;
431        for (i, &c) in chars.iter().enumerate() {
432            pen_x += advances[i];
433
434            let breakable = is_breakable(c) || allows_ideographic_break(c);
435            let forced = c == '\n';
436
437            if !breakable && !forced {
438                continue;
439            }
440
441            // This break would start the next line at i+1.
442            let break_after = i + 1;
443            if break_after >= chars.len() {
444                continue;
445            }
446
447            // Find the best prior break for this candidate.
448            let mut best_cost = f32::INFINITY;
449            let mut best_prev = None;
450
451            for (ci, cand) in candidates.iter().enumerate() {
452                let line_width = pen_x - cand.x;
453                let diff = line_width - target_width;
454                let badness = diff * diff;
455                let total = cand.cost + badness;
456
457                if forced {
458                    // Forced break always wins.
459                    best_cost = total.min(best_cost);
460                    best_prev = Some(ci);
461                    break;
462                }
463
464                if total < best_cost {
465                    best_cost = total;
466                    best_prev = Some(ci);
467                }
468            }
469
470            candidates.push(BreakCandidate {
471                index: break_after,
472                x: pen_x,
473                cost: best_cost,
474                prev: best_prev,
475            });
476        }
477
478        // Evaluate the "last line" cost from each candidate to end-of-text.
479        let mut best_final_cost = f32::INFINITY;
480        let mut best_final_idx = 0usize;
481        for (ci, cand) in candidates.iter().enumerate() {
482            let remaining = total_width - cand.x;
483            let diff = remaining - target_width;
484            // Favor short last lines (MapLibre: ×0.5 if shorter, ×2 if longer).
485            let penalty = if diff < 0.0 {
486                diff * diff * 0.5
487            } else {
488                diff * diff * 2.0
489            };
490            let total = cand.cost + penalty;
491            if total < best_final_cost {
492                best_final_cost = total;
493                best_final_idx = ci;
494            }
495        }
496
497        // Walk backwards to collect breaks.
498        let mut breaks = Vec::new();
499        let mut cur = best_final_idx;
500        while cur > 0 {
501            let cand = &candidates[cur];
502            if cand.index > 0 {
503                breaks.push(cand.index);
504            }
505            cur = cand.prev.unwrap_or(0);
506        }
507        breaks.reverse();
508        breaks
509    }
510
511    // -----------------------------------------------------------------------
512    // The main shaping entry point
513    // -----------------------------------------------------------------------
514
515    /// Shape text into positioned glyphs using rustybuzz.
516    ///
517    /// This is the primary entry point for the text shaping engine.
518    pub fn shape_text(
519        text: &str,
520        registry: &mut FontRegistry,
521        options: &ShapeTextOptions,
522    ) -> Option<ShapedText> {
523        if text.is_empty() {
524            return None;
525        }
526
527        // 1. Apply text transform.
528        let transformed = match options.text_transform {
529            SymbolTextTransform::Uppercase => text.to_uppercase(),
530            SymbolTextTransform::Lowercase => text.to_lowercase(),
531            SymbolTextTransform::None => text.to_owned(),
532        };
533
534        // 2. BiDi reorder.
535        let display_text = if contains_rtl(&transformed) {
536            bidi_reorder(&transformed)
537        } else {
538            transformed.clone()
539        };
540
541        // 3. Resolve font.
542        let family_name = registry.resolve_stack(&options.font_stack)?.to_owned();
543        let font = registry.get_font(&family_name)?.clone();
544
545        // 4. Shape with rustybuzz.
546        let face = rustybuzz::Face::from_slice(font.data(), font.face_index())?;
547        let mut buffer = rustybuzz::UnicodeBuffer::new();
548        buffer.push_str(&display_text);
549        let shaped = rustybuzz::shape(&face, &[], buffer);
550
551        let scale = font.scale_to_layout();
552        let infos = shaped.glyph_infos();
553        let positions = shaped.glyph_positions();
554
555        // Build per-glyph advances and map back to chars.
556        let display_chars: Vec<char> = display_text.chars().collect();
557        let mut glyph_advances: Vec<f32> = Vec::with_capacity(infos.len());
558        let mut glyph_chars: Vec<char> = Vec::with_capacity(infos.len());
559        let mut glyph_ids: Vec<u16> = Vec::with_capacity(infos.len());
560        let mut glyph_x_offsets: Vec<f32> = Vec::with_capacity(infos.len());
561        let mut glyph_y_offsets: Vec<f32> = Vec::with_capacity(infos.len());
562
563        for (i, (info, pos)) in infos.iter().zip(positions.iter()).enumerate() {
564            let cluster = info.cluster as usize;
565            let c = display_chars.get(cluster).copied().unwrap_or('\u{FFFD}');
566            let advance = pos.x_advance as f32 * scale + options.letter_spacing * ONE_EM;
567            glyph_advances.push(advance);
568            glyph_chars.push(c);
569            glyph_ids.push(info.glyph_id as u16);
570            glyph_x_offsets.push(pos.x_offset as f32 * scale);
571            glyph_y_offsets.push(pos.y_offset as f32 * scale);
572
573            let _ = i; // suppress unused warning
574        }
575
576        // 5. Line breaking.
577        let max_width_lu = options
578            .max_width
579            .map(|mw| mw * ONE_EM)
580            .unwrap_or(f32::INFINITY);
581        let breaks = determine_line_breaks(&glyph_chars, &glyph_advances, max_width_lu);
582
583        // 6. Position glyphs per line.
584        let line_height_lu = options.line_height * ONE_EM;
585
586        // Default glyph vertical offset (MapLibre: SHAPING_DEFAULT_OFFSET = -17).
587        const SHAPING_DEFAULT_OFFSET: f32 = -17.0;
588
589        let mut lines: Vec<ShapedLine> = Vec::new();
590        let mut line_start = 0usize;
591        let mut current_y = 0.0f32;
592
593        let mut break_iter = breaks.iter().peekable();
594        let total_glyphs = glyph_chars.len();
595
596        loop {
597            let line_end = break_iter.next().copied().unwrap_or(total_glyphs);
598
599            // Skip leading whitespace in wrapped lines (but not the first).
600            let effective_start = if !lines.is_empty() {
601                let mut s = line_start;
602                while s < line_end && glyph_chars.get(s) == Some(&' ') {
603                    s += 1;
604                }
605                s
606            } else {
607                line_start
608            };
609
610            let mut positioned = Vec::new();
611            let mut pen_x = 0.0f32;
612            let line_index = lines.len();
613
614            for gi in effective_start..line_end {
615                let c = glyph_chars[gi];
616                // Skip newlines — they're layout-only control characters.
617                if c == '\n' {
618                    continue;
619                }
620
621                positioned.push(PositionedGlyph {
622                    codepoint: c,
623                    glyph_id: glyph_ids[gi],
624                    x: pen_x + glyph_x_offsets[gi],
625                    y: current_y + SHAPING_DEFAULT_OFFSET + glyph_y_offsets[gi],
626                    advance: glyph_advances[gi],
627                    metrics_width: 0.0, // filled by atlas later
628                    metrics_height: 0.0,
629                    metrics_left: 0.0,
630                    metrics_top: 0.0,
631                    font_stack: options.font_stack.clone(),
632                    vertical: options.writing_mode == SymbolWritingMode::Vertical,
633                    line_index,
634                });
635                pen_x += glyph_advances[gi];
636            }
637
638            // Justify the line.
639            if !positioned.is_empty() {
640                let line_width = positioned.last().map(|g| g.x + g.advance).unwrap_or(0.0);
641                let justify_factor = match options.justify {
642                    TextJustify::Left => 0.0,
643                    TextJustify::Center => 0.5,
644                    TextJustify::Right => 1.0,
645                };
646                let shift = -line_width * justify_factor;
647                for g in &mut positioned {
648                    g.x += shift;
649                }
650            }
651
652            lines.push(ShapedLine {
653                glyphs: positioned,
654                line_offset: 0.0,
655            });
656
657            if line_end >= total_glyphs {
658                break;
659            }
660            line_start = line_end;
661            current_y += line_height_lu;
662        }
663
664        // 7. Compute bounding box.
665        let mut left = f32::INFINITY;
666        let mut right = f32::NEG_INFINITY;
667        let mut top = f32::INFINITY;
668        let mut bottom = f32::NEG_INFINITY;
669
670        for line in &lines {
671            for g in &line.glyphs {
672                left = left.min(g.x);
673                right = right.max(g.x + g.advance);
674                top = top.min(g.y);
675                bottom = bottom.max(g.y + ONE_EM);
676            }
677        }
678
679        if left == f32::INFINITY {
680            // No visible glyphs.
681            left = 0.0;
682            right = 0.0;
683            top = 0.0;
684            bottom = 0.0;
685        }
686
687        // 8. Anchor alignment: shift all glyphs so the anchor point is at (0, 0).
688        let text_width = right - left;
689        let text_height = bottom - top;
690        let h_align = options.anchor.horizontal_align();
691        let v_align = options.anchor.vertical_align();
692        let dx = -left - text_width * h_align;
693        let dy = -top - text_height * v_align;
694
695        for line in &mut lines {
696            for g in &mut line.glyphs {
697                g.x += dx;
698                g.y += dy;
699            }
700        }
701
702        left += dx;
703        right += dx;
704        top += dy;
705        bottom += dy;
706
707        Some(ShapedText {
708            lines,
709            left,
710            top,
711            right,
712            bottom,
713            text: display_text,
714            writing_mode: options.writing_mode,
715        })
716    }
717
718    // -----------------------------------------------------------------------
719    // Shaped glyph provider (bridges to GlyphProvider trait)
720    // -----------------------------------------------------------------------
721
722    /// A `GlyphProvider` backed by the text shaping engine's font registry.
723    ///
724    /// Unlike `ProceduralGlyphProvider`, this produces real SDF-quality glyph
725    /// bitmaps by rasterizing from loaded TTF/OTF fonts via `ttf-parser`.
726    #[derive(Debug, Clone, Default)]
727    pub struct ShapedGlyphProvider {
728        registry: FontRegistry,
729    }
730
731    impl ShapedGlyphProvider {
732        /// Create a provider backed by the given font registry.
733        pub fn new(registry: FontRegistry) -> Self {
734            Self { registry }
735        }
736
737        /// Mutable access to the underlying font registry.
738        pub fn registry_mut(&mut self) -> &mut FontRegistry {
739            &mut self.registry
740        }
741
742        /// Immutable access to the underlying font registry.
743        pub fn registry(&self) -> &FontRegistry {
744            &self.registry
745        }
746    }
747
748    impl GlyphProvider for ShapedGlyphProvider {
749        #[allow(clippy::unwrap_used)] // bbox is checked via if-let above
750        fn load_glyph(&self, font_stack: &str, codepoint: char) -> Option<GlyphRaster> {
751            // Resolve font stack without mutable access — scan directly.
752            let family_name = font_stack
753                .split(',')
754                .map(str::trim)
755                .find(|name| self.registry.fonts.contains_key(*name))?;
756            let font = self.registry.fonts.get(family_name)?;
757
758            let face = ttf_parser::Face::parse(font.data(), font.face_index()).ok()?;
759            let glyph_id = face.glyph_index(codepoint)?;
760
761            // Glyph metrics.
762            let upem = face.units_per_em() as f32;
763            let target_px = ONE_EM; // Render at 24px (ONE_EM).
764            let scale = target_px / upem;
765
766            let h_advance = face.glyph_hor_advance(glyph_id).unwrap_or(0) as f32;
767            let advance_px = h_advance * scale;
768
769            let bbox = face.glyph_bounding_box(glyph_id);
770            let (glyph_width, glyph_height, bearing_x, bearing_y) = if let Some(bb) = bbox {
771                let w = ((bb.x_max - bb.x_min) as f32 * scale).ceil() as u16;
772                let h = ((bb.y_max - bb.y_min) as f32 * scale).ceil() as u16;
773                let bx = (bb.x_min as f32 * scale).floor() as i16;
774                let by = (bb.y_max as f32 * scale).ceil() as i16;
775                (w.max(1), h.max(1), bx, by)
776            } else {
777                // Whitespace or glyph without outlines.
778                return Some(GlyphRaster::new(0, 0, advance_px, 0, 0, Vec::new()));
779            };
780
781            // Simple binary rasterization of glyph outline via ttf-parser.
782            // For SDF rendering this produces hard-edged glyphs that work with
783            // the existing threshold-based SDF shader (inside=255, outside=0).
784            let alpha = rasterize_glyph_alpha(
785                &face,
786                glyph_id,
787                glyph_width,
788                glyph_height,
789                scale,
790                bbox.unwrap(),
791            );
792
793            Some(GlyphRaster::new(
794                glyph_width,
795                glyph_height,
796                advance_px,
797                bearing_x,
798                bearing_y,
799                alpha,
800            ))
801        }
802    }
803
804    /// Rasterize a glyph outline into a binary alpha bitmap.
805    ///
806    /// Uses a simple scanline fill: for each row, walk the outline segments
807    /// and count crossings to determine inside/outside. This is equivalent
808    /// to the even-odd fill rule used for TrueType outlines.
809    fn rasterize_glyph_alpha(
810        face: &ttf_parser::Face<'_>,
811        glyph_id: ttf_parser::GlyphId,
812        width: u16,
813        height: u16,
814        scale: f32,
815        bbox: ttf_parser::Rect,
816    ) -> Vec<u8> {
817        let w = width as usize;
818        let h = height as usize;
819        let mut alpha = vec![0u8; w * h];
820
821        // Collect outline segments.
822        struct SegmentCollector {
823            segments: Vec<((f32, f32), (f32, f32))>,
824            current: (f32, f32),
825            start: (f32, f32),
826        }
827
828        impl ttf_parser::OutlineBuilder for SegmentCollector {
829            fn move_to(&mut self, x: f32, y: f32) {
830                self.current = (x, y);
831                self.start = (x, y);
832            }
833            fn line_to(&mut self, x: f32, y: f32) {
834                self.segments.push((self.current, (x, y)));
835                self.current = (x, y);
836            }
837            fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) {
838                // Flatten quadratic Bézier into line segments.
839                let steps = 4;
840                let (px, py) = self.current;
841                for i in 1..=steps {
842                    let t = i as f32 / steps as f32;
843                    let it = 1.0 - t;
844                    let nx = it * it * px + 2.0 * it * t * x1 + t * t * x;
845                    let ny = it * it * py + 2.0 * it * t * y1 + t * t * y;
846                    self.segments.push((self.current, (nx, ny)));
847                    self.current = (nx, ny);
848                }
849            }
850            fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) {
851                // Flatten cubic Bézier into line segments.
852                let steps = 8;
853                let (px, py) = self.current;
854                for i in 1..=steps {
855                    let t = i as f32 / steps as f32;
856                    let it = 1.0 - t;
857                    let nx = it * it * it * px
858                        + 3.0 * it * it * t * x1
859                        + 3.0 * it * t * t * x2
860                        + t * t * t * x;
861                    let ny = it * it * it * py
862                        + 3.0 * it * it * t * y1
863                        + 3.0 * it * t * t * y2
864                        + t * t * t * y;
865                    self.segments.push((self.current, (nx, ny)));
866                    self.current = (nx, ny);
867                }
868            }
869            fn close(&mut self) {
870                if self.current != self.start {
871                    self.segments.push((self.current, self.start));
872                }
873                self.current = self.start;
874            }
875        }
876
877        let mut collector = SegmentCollector {
878            segments: Vec::new(),
879            current: (0.0, 0.0),
880            start: (0.0, 0.0),
881        };
882        face.outline_glyph(glyph_id, &mut collector);
883
884        // Transform segments from font-space to bitmap-space.
885        let origin_x = bbox.x_min as f32;
886        let origin_y = bbox.y_min as f32;
887        let segments: Vec<((f32, f32), (f32, f32))> = collector
888            .segments
889            .iter()
890            .map(|&((x0, y0), (x1, y1))| {
891                // Font y-axis is up; bitmap y-axis is down.
892                let bx0 = (x0 - origin_x) * scale;
893                let by0 = (h as f32) - (y0 - origin_y) * scale;
894                let bx1 = (x1 - origin_x) * scale;
895                let by1 = (h as f32) - (y1 - origin_y) * scale;
896                ((bx0, by0), (bx1, by1))
897            })
898            .collect();
899
900        // Scanline rasterization with even-odd fill rule.
901        for row in 0..h {
902            let y = row as f32 + 0.5;
903            let mut crossings: Vec<f32> = Vec::new();
904
905            for &((x0, y0), (x1, y1)) in &segments {
906                if (y0 <= y && y1 > y) || (y1 <= y && y0 > y) {
907                    let t = (y - y0) / (y1 - y0);
908                    let x_intersect = x0 + t * (x1 - x0);
909                    crossings.push(x_intersect);
910                }
911            }
912
913            crossings.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
914
915            for pair in crossings.chunks(2) {
916                if pair.len() == 2 {
917                    let start_col = (pair[0].max(0.0) as usize).min(w);
918                    let end_col = ((pair[1] + 1.0).max(0.0) as usize).min(w);
919                    for col in start_col..end_col {
920                        alpha[row * w + col] = 255;
921                    }
922                }
923            }
924        }
925
926        alpha
927    }
928}
929
930// Re-export the inner module contents when the feature is enabled.
931#[cfg(feature = "text-shaping")]
932pub use inner::*;
933
934// -----------------------------------------------------------------------
935// Tests
936// -----------------------------------------------------------------------
937
938#[cfg(test)]
939#[cfg(feature = "text-shaping")]
940mod tests {
941    use super::inner::*;
942    use super::*;
943
944    /// Build a minimal font registry with a test font.
945    ///
946    /// We use a synthetic approach: since we cannot bundle a real TTF in tests,
947    /// we test the structural correctness with the `ShapedGlyphProvider` and
948    /// `FontRegistry` APIs, and we test shaping/BiDi logic with integration
949    /// tests that don't depend on a real font.
950    fn test_options() -> ShapeTextOptions {
951        ShapeTextOptions {
952            font_stack: "TestFont".to_owned(),
953            ..Default::default()
954        }
955    }
956
957    #[test]
958    fn bidi_reorder_pure_ltr_is_identity() {
959        let text = "Hello World";
960        let reordered = bidi_reorder(text);
961        assert_eq!(reordered, "Hello World");
962    }
963
964    #[test]
965    fn bidi_reorder_rtl_reverses_characters() {
966        // Arabic text should be visually reordered.
967        let text = "\u{0645}\u{0631}\u{062D}\u{0628}\u{0627}"; // مرحبا
968        let reordered = bidi_reorder(text);
969        // BiDi should reverse RTL runs.
970        assert!(!reordered.is_empty());
971        assert_eq!(reordered.chars().count(), 5);
972    }
973
974    #[test]
975    fn contains_rtl_detects_arabic() {
976        assert!(contains_rtl("\u{0645}\u{0631}\u{062D}\u{0628}\u{0627}"));
977        assert!(!contains_rtl("Hello World"));
978    }
979
980    #[test]
981    fn contains_rtl_detects_hebrew() {
982        assert!(contains_rtl("\u{05E9}\u{05DC}\u{05D5}\u{05DD}")); // שלום
983    }
984
985    #[test]
986    fn line_breaking_no_break_within_limit() {
987        let chars: Vec<char> = "Hello".chars().collect();
988        let advances = vec![10.0; 5]; // total = 50
989        let breaks = determine_line_breaks(&chars, &advances, 100.0);
990        assert!(breaks.is_empty());
991    }
992
993    #[test]
994    fn line_breaking_wraps_at_space() {
995        let text = "Hello World Test";
996        let chars: Vec<char> = text.chars().collect();
997        // Each char = 10 layout units, total = 160, limit = 80 → should wrap.
998        let advances = vec![10.0; chars.len()];
999        let breaks = determine_line_breaks(&chars, &advances, 80.0);
1000        assert!(!breaks.is_empty());
1001        // Break should be at a space character.
1002        for &b in &breaks {
1003            assert!(b > 0 && b < chars.len());
1004        }
1005    }
1006
1007    #[test]
1008    fn line_breaking_forced_newline() {
1009        let text = "Line1\nLine2";
1010        let chars: Vec<char> = text.chars().collect();
1011        let advances = vec![10.0; chars.len()];
1012        let breaks = determine_line_breaks(&chars, &advances, 1000.0);
1013        // Even with a huge max width, the newline forces a break.
1014        // But total width = 110 < 1000, so no automatic breaks needed.
1015        // Forced newlines are only breaks when the badness DP runs.
1016        // With total < max, the early return skips breaking entirely.
1017        // This is correct: forced newlines at input level are pre-split.
1018        assert!(breaks.is_empty());
1019    }
1020
1021    #[test]
1022    fn ideographic_break_allows_cjk() {
1023        assert!(allows_ideographic_break('中'));
1024        assert!(allows_ideographic_break('漢'));
1025        assert!(!allows_ideographic_break('A'));
1026    }
1027
1028    #[test]
1029    fn text_anchor_alignment_factors() {
1030        assert_eq!(TextAnchor::TopLeft.horizontal_align(), 0.0);
1031        assert_eq!(TextAnchor::TopLeft.vertical_align(), 0.0);
1032        assert_eq!(TextAnchor::Center.horizontal_align(), 0.5);
1033        assert_eq!(TextAnchor::Center.vertical_align(), 0.5);
1034        assert_eq!(TextAnchor::BottomRight.horizontal_align(), 1.0);
1035        assert_eq!(TextAnchor::BottomRight.vertical_align(), 1.0);
1036    }
1037
1038    #[test]
1039    fn font_registry_resolve_stack() {
1040        let mut registry = FontRegistry::new();
1041        // Without any fonts registered, stack resolution should fail.
1042        assert!(registry.resolve_stack("SomeFont, FallbackFont").is_none());
1043    }
1044
1045    #[test]
1046    fn font_registry_font_count() {
1047        let registry = FontRegistry::new();
1048        assert_eq!(registry.font_count(), 0);
1049    }
1050
1051    #[test]
1052    fn shaped_text_options_default() {
1053        let opts = ShapeTextOptions::default();
1054        assert_eq!(opts.line_height, 1.2);
1055        assert_eq!(opts.letter_spacing, 0.0);
1056        assert!(matches!(opts.justify, TextJustify::Center));
1057        assert!(matches!(opts.anchor, TextAnchor::Center));
1058    }
1059
1060    #[test]
1061    fn shape_text_returns_none_for_empty() {
1062        let mut registry = FontRegistry::new();
1063        let options = test_options();
1064        let result = shape_text("", &mut registry, &options);
1065        assert!(result.is_none());
1066    }
1067
1068    #[test]
1069    fn shape_text_returns_none_without_font() {
1070        let mut registry = FontRegistry::new();
1071        let options = test_options();
1072        let result = shape_text("Hello", &mut registry, &options);
1073        assert!(result.is_none());
1074    }
1075
1076    #[test]
1077    fn mixed_bidi_preserves_length() {
1078        let text = "Hello \u{0645}\u{0631}\u{062D}\u{0628}\u{0627} World";
1079        let reordered = bidi_reorder(text);
1080        assert_eq!(reordered.chars().count(), text.chars().count());
1081    }
1082
1083    #[test]
1084    fn breakable_characters_recognized() {
1085        assert!(is_breakable(' '));
1086        assert!(is_breakable('-'));
1087        assert!(is_breakable('/'));
1088        assert!(is_breakable('\u{200B}')); // zero-width space
1089        assert!(!is_breakable('A'));
1090        assert!(!is_breakable('中'));
1091    }
1092
1093    #[test]
1094    fn one_em_constant_is_24() {
1095        assert_eq!(ONE_EM, 24.0);
1096    }
1097}