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!(
352                cls,
353                BidiClass::R | BidiClass::AL | BidiClass::AN
354            )
355        })
356    }
357
358    // -----------------------------------------------------------------------
359    // Line breaking
360    // -----------------------------------------------------------------------
361
362    /// Characters at which line breaking is allowed (MapLibre pattern).
363    pub(crate) fn is_breakable(c: char) -> bool {
364        matches!(
365            c,
366            '\n' | ' ' | '&' | '(' | ')' | '+' | '-' | '/'
367            | '\u{00AD}' // soft hyphen
368            | '\u{00B7}' // middle dot
369            | '\u{200B}' // zero-width space
370            | '\u{2010}' // hyphen
371            | '\u{2013}' // en-dash
372            | '\u{2027}' // interpunct
373        )
374    }
375
376    /// Whether a character is CJK and allows ideographic breaking.
377    pub(crate) fn allows_ideographic_break(c: char) -> bool {
378        let cp = c as u32;
379        // CJK Unified Ideographs and common CJK ranges.
380        (0x4E00..=0x9FFF).contains(&cp)
381            || (0x3400..=0x4DBF).contains(&cp)
382            || (0x20000..=0x2A6DF).contains(&cp)
383            || (0x2A700..=0x2B73F).contains(&cp)
384            || (0x2B740..=0x2B81F).contains(&cp)
385            || (0x2B820..=0x2CEAF).contains(&cp)
386            || (0xF900..=0xFAFF).contains(&cp)
387            || (0x2F800..=0x2FA1F).contains(&cp)
388            // CJK Compatibility Ideographs
389            || (0x3000..=0x303F).contains(&cp) // CJK Symbols
390            || (0x3040..=0x309F).contains(&cp) // Hiragana
391            || (0x30A0..=0x30FF).contains(&cp) // Katakana
392            || (0xFF00..=0xFFEF).contains(&cp) // Fullwidth forms
393    }
394
395    /// Determine line break positions using a badness-based algorithm.
396    ///
397    /// Returns indices into `advances` where lines should break.
398    /// Each break index is the first glyph of the **next** line.
399    pub(crate) fn determine_line_breaks(
400        chars: &[char],
401        advances: &[f32],
402        max_width_lu: f32,
403    ) -> Vec<usize> {
404        if chars.is_empty() || max_width_lu <= 0.0 {
405            return Vec::new();
406        }
407
408        let total_width: f32 = advances.iter().sum();
409        if total_width <= max_width_lu {
410            return Vec::new();
411        }
412
413        let line_count = (total_width / max_width_lu).ceil().max(1.0);
414        let target_width = total_width / line_count;
415
416        // Dynamic programming: for each breakable position, store (cost, prev_break).
417        struct BreakCandidate {
418            index: usize,
419            x: f32,
420            cost: f32,
421            prev: Option<usize>,
422        }
423
424        let mut candidates: Vec<BreakCandidate> = Vec::new();
425        // Sentinel: start of text.
426        candidates.push(BreakCandidate {
427            index: 0,
428            x: 0.0,
429            cost: 0.0,
430            prev: None,
431        });
432
433        let mut pen_x = 0.0f32;
434        for (i, &c) in chars.iter().enumerate() {
435            pen_x += advances[i];
436
437            let breakable = is_breakable(c) || allows_ideographic_break(c);
438            let forced = c == '\n';
439
440            if !breakable && !forced {
441                continue;
442            }
443
444            // This break would start the next line at i+1.
445            let break_after = i + 1;
446            if break_after >= chars.len() {
447                continue;
448            }
449
450            // Find the best prior break for this candidate.
451            let mut best_cost = f32::INFINITY;
452            let mut best_prev = None;
453
454            for (ci, cand) in candidates.iter().enumerate() {
455                let line_width = pen_x - cand.x;
456                let diff = line_width - target_width;
457                let badness = diff * diff;
458                let total = cand.cost + badness;
459
460                if forced {
461                    // Forced break always wins.
462                    best_cost = total.min(best_cost);
463                    best_prev = Some(ci);
464                    break;
465                }
466
467                if total < best_cost {
468                    best_cost = total;
469                    best_prev = Some(ci);
470                }
471            }
472
473            candidates.push(BreakCandidate {
474                index: break_after,
475                x: pen_x,
476                cost: best_cost,
477                prev: best_prev,
478            });
479        }
480
481        // Evaluate the "last line" cost from each candidate to end-of-text.
482        let mut best_final_cost = f32::INFINITY;
483        let mut best_final_idx = 0usize;
484        for (ci, cand) in candidates.iter().enumerate() {
485            let remaining = total_width - cand.x;
486            let diff = remaining - target_width;
487            // Favor short last lines (MapLibre: ×0.5 if shorter, ×2 if longer).
488            let penalty = if diff < 0.0 { diff * diff * 0.5 } else { diff * diff * 2.0 };
489            let total = cand.cost + penalty;
490            if total < best_final_cost {
491                best_final_cost = total;
492                best_final_idx = ci;
493            }
494        }
495
496        // Walk backwards to collect breaks.
497        let mut breaks = Vec::new();
498        let mut cur = best_final_idx;
499        while cur > 0 {
500            let cand = &candidates[cur];
501            if cand.index > 0 {
502                breaks.push(cand.index);
503            }
504            cur = cand.prev.unwrap_or(0);
505        }
506        breaks.reverse();
507        breaks
508    }
509
510    // -----------------------------------------------------------------------
511    // The main shaping entry point
512    // -----------------------------------------------------------------------
513
514    /// Shape text into positioned glyphs using rustybuzz.
515    ///
516    /// This is the primary entry point for the text shaping engine.
517    pub fn shape_text(
518        text: &str,
519        registry: &mut FontRegistry,
520        options: &ShapeTextOptions,
521    ) -> Option<ShapedText> {
522        if text.is_empty() {
523            return None;
524        }
525
526        // 1. Apply text transform.
527        let transformed = match options.text_transform {
528            SymbolTextTransform::Uppercase => text.to_uppercase(),
529            SymbolTextTransform::Lowercase => text.to_lowercase(),
530            SymbolTextTransform::None => text.to_owned(),
531        };
532
533        // 2. BiDi reorder.
534        let display_text = if contains_rtl(&transformed) {
535            bidi_reorder(&transformed)
536        } else {
537            transformed.clone()
538        };
539
540        // 3. Resolve font.
541        let family_name = registry.resolve_stack(&options.font_stack)?.to_owned();
542        let font = registry.get_font(&family_name)?.clone();
543
544        // 4. Shape with rustybuzz.
545        let face = rustybuzz::Face::from_slice(font.data(), font.face_index())?;
546        let mut buffer = rustybuzz::UnicodeBuffer::new();
547        buffer.push_str(&display_text);
548        let shaped = rustybuzz::shape(&face, &[], buffer);
549
550        let scale = font.scale_to_layout();
551        let infos = shaped.glyph_infos();
552        let positions = shaped.glyph_positions();
553
554        // Build per-glyph advances and map back to chars.
555        let display_chars: Vec<char> = display_text.chars().collect();
556        let mut glyph_advances: Vec<f32> = Vec::with_capacity(infos.len());
557        let mut glyph_chars: Vec<char> = Vec::with_capacity(infos.len());
558        let mut glyph_ids: Vec<u16> = Vec::with_capacity(infos.len());
559        let mut glyph_x_offsets: Vec<f32> = Vec::with_capacity(infos.len());
560        let mut glyph_y_offsets: Vec<f32> = Vec::with_capacity(infos.len());
561
562        for (i, (info, pos)) in infos.iter().zip(positions.iter()).enumerate() {
563            let cluster = info.cluster as usize;
564            let c = display_chars.get(cluster).copied().unwrap_or('\u{FFFD}');
565            let advance = pos.x_advance as f32 * scale + options.letter_spacing * ONE_EM;
566            glyph_advances.push(advance);
567            glyph_chars.push(c);
568            glyph_ids.push(info.glyph_id as u16);
569            glyph_x_offsets.push(pos.x_offset as f32 * scale);
570            glyph_y_offsets.push(pos.y_offset as f32 * scale);
571
572            let _ = i; // suppress unused warning
573        }
574
575        // 5. Line breaking.
576        let max_width_lu = options
577            .max_width
578            .map(|mw| mw * ONE_EM)
579            .unwrap_or(f32::INFINITY);
580        let breaks = determine_line_breaks(&glyph_chars, &glyph_advances, max_width_lu);
581
582        // 6. Position glyphs per line.
583        let line_height_lu = options.line_height * ONE_EM;
584
585        // Default glyph vertical offset (MapLibre: SHAPING_DEFAULT_OFFSET = -17).
586        const SHAPING_DEFAULT_OFFSET: f32 = -17.0;
587
588        let mut lines: Vec<ShapedLine> = Vec::new();
589        let mut line_start = 0usize;
590        let mut current_y = 0.0f32;
591
592        let mut break_iter = breaks.iter().peekable();
593        let total_glyphs = glyph_chars.len();
594
595        loop {
596            let line_end = break_iter.next().copied().unwrap_or(total_glyphs);
597
598            // Skip leading whitespace in wrapped lines (but not the first).
599            let effective_start = if !lines.is_empty() {
600                let mut s = line_start;
601                while s < line_end && glyph_chars.get(s) == Some(&' ') {
602                    s += 1;
603                }
604                s
605            } else {
606                line_start
607            };
608
609            let mut positioned = Vec::new();
610            let mut pen_x = 0.0f32;
611            let line_index = lines.len();
612
613            for gi in effective_start..line_end {
614                let c = glyph_chars[gi];
615                // Skip newlines — they're layout-only control characters.
616                if c == '\n' {
617                    continue;
618                }
619
620                positioned.push(PositionedGlyph {
621                    codepoint: c,
622                    glyph_id: glyph_ids[gi],
623                    x: pen_x + glyph_x_offsets[gi],
624                    y: current_y + SHAPING_DEFAULT_OFFSET + glyph_y_offsets[gi],
625                    advance: glyph_advances[gi],
626                    metrics_width: 0.0, // filled by atlas later
627                    metrics_height: 0.0,
628                    metrics_left: 0.0,
629                    metrics_top: 0.0,
630                    font_stack: options.font_stack.clone(),
631                    vertical: options.writing_mode == SymbolWritingMode::Vertical,
632                    line_index,
633                });
634                pen_x += glyph_advances[gi];
635            }
636
637            // Justify the line.
638            if !positioned.is_empty() {
639                let line_width = positioned
640                    .last()
641                    .map(|g| g.x + g.advance)
642                    .unwrap_or(0.0);
643                let justify_factor = match options.justify {
644                    TextJustify::Left => 0.0,
645                    TextJustify::Center => 0.5,
646                    TextJustify::Right => 1.0,
647                };
648                let shift = -line_width * justify_factor;
649                for g in &mut positioned {
650                    g.x += shift;
651                }
652            }
653
654            lines.push(ShapedLine {
655                glyphs: positioned,
656                line_offset: 0.0,
657            });
658
659            if line_end >= total_glyphs {
660                break;
661            }
662            line_start = line_end;
663            current_y += line_height_lu;
664        }
665
666        // 7. Compute bounding box.
667        let mut left = f32::INFINITY;
668        let mut right = f32::NEG_INFINITY;
669        let mut top = f32::INFINITY;
670        let mut bottom = f32::NEG_INFINITY;
671
672        for line in &lines {
673            for g in &line.glyphs {
674                left = left.min(g.x);
675                right = right.max(g.x + g.advance);
676                top = top.min(g.y);
677                bottom = bottom.max(g.y + ONE_EM);
678            }
679        }
680
681        if left == f32::INFINITY {
682            // No visible glyphs.
683            left = 0.0;
684            right = 0.0;
685            top = 0.0;
686            bottom = 0.0;
687        }
688
689        // 8. Anchor alignment: shift all glyphs so the anchor point is at (0, 0).
690        let text_width = right - left;
691        let text_height = bottom - top;
692        let h_align = options.anchor.horizontal_align();
693        let v_align = options.anchor.vertical_align();
694        let dx = -left - text_width * h_align;
695        let dy = -top - text_height * v_align;
696
697        for line in &mut lines {
698            for g in &mut line.glyphs {
699                g.x += dx;
700                g.y += dy;
701            }
702        }
703
704        left += dx;
705        right += dx;
706        top += dy;
707        bottom += dy;
708
709        Some(ShapedText {
710            lines,
711            left,
712            top,
713            right,
714            bottom,
715            text: display_text,
716            writing_mode: options.writing_mode,
717        })
718    }
719
720    // -----------------------------------------------------------------------
721    // Shaped glyph provider (bridges to GlyphProvider trait)
722    // -----------------------------------------------------------------------
723
724    /// A `GlyphProvider` backed by the text shaping engine's font registry.
725    ///
726    /// Unlike `ProceduralGlyphProvider`, this produces real SDF-quality glyph
727    /// bitmaps by rasterizing from loaded TTF/OTF fonts via `ttf-parser`.
728    #[derive(Debug, Clone, Default)]
729    pub struct ShapedGlyphProvider {
730        registry: FontRegistry,
731    }
732
733    impl ShapedGlyphProvider {
734        /// Create a provider backed by the given font registry.
735        pub fn new(registry: FontRegistry) -> Self {
736            Self { registry }
737        }
738
739        /// Mutable access to the underlying font registry.
740        pub fn registry_mut(&mut self) -> &mut FontRegistry {
741            &mut self.registry
742        }
743
744        /// Immutable access to the underlying font registry.
745        pub fn registry(&self) -> &FontRegistry {
746            &self.registry
747        }
748    }
749
750    impl GlyphProvider for ShapedGlyphProvider {
751        fn load_glyph(&self, font_stack: &str, codepoint: char) -> Option<GlyphRaster> {
752            // Resolve font stack without mutable access — scan directly.
753            let family_name = font_stack
754                .split(',')
755                .map(str::trim)
756                .find(|name| self.registry.fonts.contains_key(*name))?;
757            let font = self.registry.fonts.get(family_name)?;
758
759            let face = ttf_parser::Face::parse(font.data(), font.face_index()).ok()?;
760            let glyph_id = face.glyph_index(codepoint)?;
761
762            // Glyph metrics.
763            let upem = face.units_per_em() as f32;
764            let target_px = ONE_EM as f32; // Render at 24px (ONE_EM).
765            let scale = target_px / upem;
766
767            let h_advance = face.glyph_hor_advance(glyph_id).unwrap_or(0) as f32;
768            let advance_px = h_advance * scale;
769
770            let bbox = face.glyph_bounding_box(glyph_id);
771            let (glyph_width, glyph_height, bearing_x, bearing_y) = if let Some(bb) = bbox {
772                let w = ((bb.x_max - bb.x_min) as f32 * scale).ceil() as u16;
773                let h = ((bb.y_max - bb.y_min) as f32 * scale).ceil() as u16;
774                let bx = (bb.x_min as f32 * scale).floor() as i16;
775                let by = (bb.y_max as f32 * scale).ceil() as i16;
776                (w.max(1), h.max(1), bx, by)
777            } else {
778                // Whitespace or glyph without outlines.
779                return Some(GlyphRaster::new(0, 0, advance_px, 0, 0, Vec::new()));
780            };
781
782            // Simple binary rasterization of glyph outline via ttf-parser.
783            // For SDF rendering this produces hard-edged glyphs that work with
784            // the existing threshold-based SDF shader (inside=255, outside=0).
785            let alpha = rasterize_glyph_alpha(
786                &face,
787                glyph_id,
788                glyph_width,
789                glyph_height,
790                scale,
791                bbox.unwrap(),
792            );
793
794            Some(GlyphRaster::new(
795                glyph_width,
796                glyph_height,
797                advance_px,
798                bearing_x,
799                bearing_y,
800                alpha,
801            ))
802        }
803    }
804
805    /// Rasterize a glyph outline into a binary alpha bitmap.
806    ///
807    /// Uses a simple scanline fill: for each row, walk the outline segments
808    /// and count crossings to determine inside/outside. This is equivalent
809    /// to the even-odd fill rule used for TrueType outlines.
810    fn rasterize_glyph_alpha(
811        face: &ttf_parser::Face<'_>,
812        glyph_id: ttf_parser::GlyphId,
813        width: u16,
814        height: u16,
815        scale: f32,
816        bbox: ttf_parser::Rect,
817    ) -> Vec<u8> {
818        let w = width as usize;
819        let h = height as usize;
820        let mut alpha = vec![0u8; w * h];
821
822        // Collect outline segments.
823        struct SegmentCollector {
824            segments: Vec<((f32, f32), (f32, f32))>,
825            current: (f32, f32),
826            start: (f32, f32),
827        }
828
829        impl ttf_parser::OutlineBuilder for SegmentCollector {
830            fn move_to(&mut self, x: f32, y: f32) {
831                self.current = (x, y);
832                self.start = (x, y);
833            }
834            fn line_to(&mut self, x: f32, y: f32) {
835                self.segments.push((self.current, (x, y)));
836                self.current = (x, y);
837            }
838            fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) {
839                // Flatten quadratic Bézier into line segments.
840                let steps = 4;
841                let (px, py) = self.current;
842                for i in 1..=steps {
843                    let t = i as f32 / steps as f32;
844                    let it = 1.0 - t;
845                    let nx = it * it * px + 2.0 * it * t * x1 + t * t * x;
846                    let ny = it * it * py + 2.0 * it * t * y1 + t * t * y;
847                    self.segments.push((self.current, (nx, ny)));
848                    self.current = (nx, ny);
849                }
850            }
851            fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) {
852                // Flatten cubic Bézier into line segments.
853                let steps = 8;
854                let (px, py) = self.current;
855                for i in 1..=steps {
856                    let t = i as f32 / steps as f32;
857                    let it = 1.0 - t;
858                    let nx = it * it * it * px
859                        + 3.0 * it * it * t * x1
860                        + 3.0 * it * t * t * x2
861                        + t * t * t * x;
862                    let ny = it * it * it * py
863                        + 3.0 * it * it * t * y1
864                        + 3.0 * it * t * t * y2
865                        + t * t * t * y;
866                    self.segments.push((self.current, (nx, ny)));
867                    self.current = (nx, ny);
868                }
869            }
870            fn close(&mut self) {
871                if self.current != self.start {
872                    self.segments.push((self.current, self.start));
873                }
874                self.current = self.start;
875            }
876        }
877
878        let mut collector = SegmentCollector {
879            segments: Vec::new(),
880            current: (0.0, 0.0),
881            start: (0.0, 0.0),
882        };
883        face.outline_glyph(glyph_id, &mut collector);
884
885        // Transform segments from font-space to bitmap-space.
886        let origin_x = bbox.x_min as f32;
887        let origin_y = bbox.y_min as f32;
888        let segments: Vec<((f32, f32), (f32, f32))> = collector
889            .segments
890            .iter()
891            .map(|&((x0, y0), (x1, y1))| {
892                // Font y-axis is up; bitmap y-axis is down.
893                let bx0 = (x0 - origin_x) * scale;
894                let by0 = (h as f32) - (y0 - origin_y) * scale;
895                let bx1 = (x1 - origin_x) * scale;
896                let by1 = (h as f32) - (y1 - origin_y) * scale;
897                ((bx0, by0), (bx1, by1))
898            })
899            .collect();
900
901        // Scanline rasterization with even-odd fill rule.
902        for row in 0..h {
903            let y = row as f32 + 0.5;
904            let mut crossings: Vec<f32> = Vec::new();
905
906            for &((x0, y0), (x1, y1)) in &segments {
907                if (y0 <= y && y1 > y) || (y1 <= y && y0 > y) {
908                    let t = (y - y0) / (y1 - y0);
909                    let x_intersect = x0 + t * (x1 - x0);
910                    crossings.push(x_intersect);
911                }
912            }
913
914            crossings.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
915
916            for pair in crossings.chunks(2) {
917                if pair.len() == 2 {
918                    let start_col = (pair[0].max(0.0) as usize).min(w);
919                    let end_col = ((pair[1] + 1.0).max(0.0) as usize).min(w);
920                    for col in start_col..end_col {
921                        alpha[row * w + col] = 255;
922                    }
923                }
924            }
925        }
926
927        alpha
928    }
929}
930
931// Re-export the inner module contents when the feature is enabled.
932#[cfg(feature = "text-shaping")]
933pub use inner::*;
934
935// -----------------------------------------------------------------------
936// Tests
937// -----------------------------------------------------------------------
938
939#[cfg(test)]
940#[cfg(feature = "text-shaping")]
941mod tests {
942    use super::inner::*;
943    use super::*;
944
945    /// Build a minimal font registry with a test font.
946    ///
947    /// We use a synthetic approach: since we cannot bundle a real TTF in tests,
948    /// we test the structural correctness with the `ShapedGlyphProvider` and
949    /// `FontRegistry` APIs, and we test shaping/BiDi logic with integration
950    /// tests that don't depend on a real font.
951    fn test_options() -> ShapeTextOptions {
952        ShapeTextOptions {
953            font_stack: "TestFont".to_owned(),
954            ..Default::default()
955        }
956    }
957
958    #[test]
959    fn bidi_reorder_pure_ltr_is_identity() {
960        let text = "Hello World";
961        let reordered = bidi_reorder(text);
962        assert_eq!(reordered, "Hello World");
963    }
964
965    #[test]
966    fn bidi_reorder_rtl_reverses_characters() {
967        // Arabic text should be visually reordered.
968        let text = "\u{0645}\u{0631}\u{062D}\u{0628}\u{0627}"; // مرحبا
969        let reordered = bidi_reorder(text);
970        // BiDi should reverse RTL runs.
971        assert!(!reordered.is_empty());
972        assert_eq!(reordered.chars().count(), 5);
973    }
974
975    #[test]
976    fn contains_rtl_detects_arabic() {
977        assert!(contains_rtl("\u{0645}\u{0631}\u{062D}\u{0628}\u{0627}"));
978        assert!(!contains_rtl("Hello World"));
979    }
980
981    #[test]
982    fn contains_rtl_detects_hebrew() {
983        assert!(contains_rtl("\u{05E9}\u{05DC}\u{05D5}\u{05DD}")); // שלום
984    }
985
986    #[test]
987    fn line_breaking_no_break_within_limit() {
988        let chars: Vec<char> = "Hello".chars().collect();
989        let advances = vec![10.0; 5]; // total = 50
990        let breaks = determine_line_breaks(&chars, &advances, 100.0);
991        assert!(breaks.is_empty());
992    }
993
994    #[test]
995    fn line_breaking_wraps_at_space() {
996        let text = "Hello World Test";
997        let chars: Vec<char> = text.chars().collect();
998        // Each char = 10 layout units, total = 160, limit = 80 → should wrap.
999        let advances = vec![10.0; chars.len()];
1000        let breaks = determine_line_breaks(&chars, &advances, 80.0);
1001        assert!(!breaks.is_empty());
1002        // Break should be at a space character.
1003        for &b in &breaks {
1004            assert!(b > 0 && b < chars.len());
1005        }
1006    }
1007
1008    #[test]
1009    fn line_breaking_forced_newline() {
1010        let text = "Line1\nLine2";
1011        let chars: Vec<char> = text.chars().collect();
1012        let advances = vec![10.0; chars.len()];
1013        let breaks = determine_line_breaks(&chars, &advances, 1000.0);
1014        // Even with a huge max width, the newline forces a break.
1015        // But total width = 110 < 1000, so no automatic breaks needed.
1016        // Forced newlines are only breaks when the badness DP runs.
1017        // With total < max, the early return skips breaking entirely.
1018        // This is correct: forced newlines at input level are pre-split.
1019        assert!(breaks.is_empty());
1020    }
1021
1022    #[test]
1023    fn ideographic_break_allows_cjk() {
1024        assert!(allows_ideographic_break('中'));
1025        assert!(allows_ideographic_break('漢'));
1026        assert!(!allows_ideographic_break('A'));
1027    }
1028
1029    #[test]
1030    fn text_anchor_alignment_factors() {
1031        assert_eq!(TextAnchor::TopLeft.horizontal_align(), 0.0);
1032        assert_eq!(TextAnchor::TopLeft.vertical_align(), 0.0);
1033        assert_eq!(TextAnchor::Center.horizontal_align(), 0.5);
1034        assert_eq!(TextAnchor::Center.vertical_align(), 0.5);
1035        assert_eq!(TextAnchor::BottomRight.horizontal_align(), 1.0);
1036        assert_eq!(TextAnchor::BottomRight.vertical_align(), 1.0);
1037    }
1038
1039    #[test]
1040    fn font_registry_resolve_stack() {
1041        let mut registry = FontRegistry::new();
1042        // Without any fonts registered, stack resolution should fail.
1043        assert!(registry.resolve_stack("SomeFont, FallbackFont").is_none());
1044    }
1045
1046    #[test]
1047    fn font_registry_font_count() {
1048        let registry = FontRegistry::new();
1049        assert_eq!(registry.font_count(), 0);
1050    }
1051
1052    #[test]
1053    fn shaped_text_options_default() {
1054        let opts = ShapeTextOptions::default();
1055        assert_eq!(opts.line_height, 1.2);
1056        assert_eq!(opts.letter_spacing, 0.0);
1057        assert!(matches!(opts.justify, TextJustify::Center));
1058        assert!(matches!(opts.anchor, TextAnchor::Center));
1059    }
1060
1061    #[test]
1062    fn shape_text_returns_none_for_empty() {
1063        let mut registry = FontRegistry::new();
1064        let options = test_options();
1065        let result = shape_text("", &mut registry, &options);
1066        assert!(result.is_none());
1067    }
1068
1069    #[test]
1070    fn shape_text_returns_none_without_font() {
1071        let mut registry = FontRegistry::new();
1072        let options = test_options();
1073        let result = shape_text("Hello", &mut registry, &options);
1074        assert!(result.is_none());
1075    }
1076
1077    #[test]
1078    fn mixed_bidi_preserves_length() {
1079        let text = "Hello \u{0645}\u{0631}\u{062D}\u{0628}\u{0627} World";
1080        let reordered = bidi_reorder(text);
1081        assert_eq!(reordered.chars().count(), text.chars().count());
1082    }
1083
1084    #[test]
1085    fn breakable_characters_recognized() {
1086        assert!(is_breakable(' '));
1087        assert!(is_breakable('-'));
1088        assert!(is_breakable('/'));
1089        assert!(is_breakable('\u{200B}')); // zero-width space
1090        assert!(!is_breakable('A'));
1091        assert!(!is_breakable('中'));
1092    }
1093
1094    #[test]
1095    fn one_em_constant_is_24() {
1096        assert_eq!(ONE_EM, 24.0);
1097    }
1098}