Skip to main content

ftui_text/
shaping.rs

1#![forbid(unsafe_code)]
2
3//! Text shaping backend and deterministic shaped-run cache.
4//!
5//! This module provides the interface and caching layer for text shaping —
6//! the process of converting a sequence of Unicode codepoints into positioned
7//! glyphs. Shaping handles script-specific reordering, ligature substitution,
8//! and glyph positioning (kerning, mark attachment).
9//!
10//! # Architecture
11//!
12//! ```text
13//! TextRun (from script_segmentation)
14//!     │
15//!     ▼
16//! ┌───────────────┐
17//! │ ShapingCache   │──cache hit──▶ ShapedRun (cached)
18//! │ (LRU + gen)    │
19//! └───────┬───────┘
20//!         │ cache miss
21//!         ▼
22//! ┌───────────────┐
23//! │ TextShaper     │  trait (NoopShaper | RustybuzzShaper)
24//! └───────┬───────┘
25//!         │
26//!         ▼
27//!     ShapedRun
28//! ```
29//!
30//! # Key schema
31//!
32//! The [`ShapingKey`] captures all parameters that affect shaping output:
33//! text content (hashed), script, direction, style, font identity, font size,
34//! and OpenType features. Two runs producing the same `ShapingKey` are
35//! guaranteed to produce identical `ShapedRun` output.
36//!
37//! # Invalidation
38//!
39//! The cache uses generation-based invalidation. When fonts change (DPR
40//! change, zoom, font swap), the generation is bumped and stale entries are
41//! lazily evicted on access. This avoids expensive bulk-clear operations.
42//!
43//! # Example
44//!
45//! ```
46//! use ftui_text::shaping::{
47//!     NoopShaper, ShapingCache, FontId, FontFeatures,
48//! };
49//! use ftui_text::script_segmentation::{Script, RunDirection};
50//!
51//! let shaper = NoopShaper;
52//! let mut cache = ShapingCache::new(shaper, 1024);
53//!
54//! let result = cache.shape(
55//!     "Hello",
56//!     Script::Latin,
57//!     RunDirection::Ltr,
58//!     FontId(0),
59//!     256 * 12, // 12pt in 1/256th units
60//!     &FontFeatures::default(),
61//! );
62//! assert!(!result.glyphs.is_empty());
63//! ```
64
65use crate::script_segmentation::{RunDirection, Script};
66use lru::LruCache;
67use rustc_hash::FxHasher;
68use smallvec::SmallVec;
69use std::hash::{Hash, Hasher};
70use std::num::NonZeroUsize;
71
72// ---------------------------------------------------------------------------
73// Font identity types
74// ---------------------------------------------------------------------------
75
76/// Opaque identifier for a font face within the application.
77///
78/// The mapping from `FontId` to actual font data is managed by the caller.
79/// The shaping layer treats this as an opaque discriminant for cache keying.
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
81pub struct FontId(pub u32);
82
83/// A single OpenType feature tag + value.
84///
85/// Tags are 4-byte ASCII identifiers (e.g., `b"liga"`, `b"kern"`, `b"smcp"`).
86/// Value 0 disables the feature, 1 enables it, higher values select alternates.
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
88pub struct FontFeature {
89    /// OpenType tag (4 ASCII bytes, e.g., `*b"liga"`).
90    pub tag: [u8; 4],
91    /// Feature value (0 = off, 1 = on, >1 = alternate selection).
92    pub value: u32,
93}
94
95impl FontFeature {
96    /// Create a new feature from a tag and value.
97    #[inline]
98    pub const fn new(tag: [u8; 4], value: u32) -> Self {
99        Self { tag, value }
100    }
101
102    /// Create an enabled feature from a tag.
103    #[inline]
104    pub const fn enabled(tag: [u8; 4]) -> Self {
105        Self { tag, value: 1 }
106    }
107
108    /// Create a disabled feature from a tag.
109    #[inline]
110    pub const fn disabled(tag: [u8; 4]) -> Self {
111        Self { tag, value: 0 }
112    }
113}
114
115/// A set of OpenType features requested for shaping.
116///
117/// Stack-allocated for the common case of ≤4 features.
118#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
119pub struct FontFeatures {
120    features: SmallVec<[FontFeature; 4]>,
121}
122
123impl FontFeatures {
124    /// Create an empty feature set.
125    #[inline]
126    pub fn new() -> Self {
127        Self {
128            features: SmallVec::new(),
129        }
130    }
131
132    /// Add a feature to the set.
133    #[inline]
134    pub fn push(&mut self, feature: FontFeature) {
135        self.features.push(feature);
136    }
137
138    /// Create from a slice of features.
139    pub fn from_slice(features: &[FontFeature]) -> Self {
140        Self {
141            features: SmallVec::from_slice(features),
142        }
143    }
144
145    /// Number of features.
146    #[inline]
147    pub fn len(&self) -> usize {
148        self.features.len()
149    }
150
151    /// Whether the feature set is empty.
152    #[inline]
153    pub fn is_empty(&self) -> bool {
154        self.features.is_empty()
155    }
156
157    /// Iterate over features.
158    #[inline]
159    pub fn iter(&self) -> impl Iterator<Item = &FontFeature> {
160        self.features.iter()
161    }
162
163    /// Sort features by tag for deterministic hashing.
164    pub fn canonicalize(&mut self) {
165        self.features.sort_by_key(|f| f.tag);
166    }
167}
168
169// ---------------------------------------------------------------------------
170// Shaped output types
171// ---------------------------------------------------------------------------
172
173/// A single positioned glyph from the shaping engine.
174///
175/// All metric values are in font design units. The caller converts to pixels
176/// using the font's units-per-em and the desired point size.
177#[derive(Debug, Clone, Copy, PartialEq, Eq)]
178pub struct ShapedGlyph {
179    /// Glyph ID from the font (0 = `.notdef`).
180    pub glyph_id: u32,
181    /// Byte offset of the start of this glyph's cluster in the source text.
182    ///
183    /// Multiple glyphs can share the same cluster (ligatures produce one glyph
184    /// for multiple characters; complex scripts may produce multiple glyphs
185    /// for one character).
186    pub cluster: u32,
187    /// Horizontal advance in font design units.
188    pub x_advance: i32,
189    /// Vertical advance in font design units.
190    pub y_advance: i32,
191    /// Horizontal offset from the nominal position.
192    pub x_offset: i32,
193    /// Vertical offset from the nominal position.
194    pub y_offset: i32,
195}
196
197/// The result of shaping a text run.
198///
199/// Contains the positioned glyphs and aggregate metrics needed for layout.
200#[derive(Debug, Clone, PartialEq, Eq)]
201pub struct ShapedRun {
202    /// Positioned glyphs in visual order.
203    pub glyphs: Vec<ShapedGlyph>,
204    /// Total horizontal advance of all glyphs (sum of x_advance).
205    pub total_advance: i32,
206}
207
208impl ShapedRun {
209    /// Number of glyphs in the run.
210    #[inline]
211    pub fn len(&self) -> usize {
212        self.glyphs.len()
213    }
214
215    /// Whether the run contains no glyphs.
216    #[inline]
217    pub fn is_empty(&self) -> bool {
218        self.glyphs.is_empty()
219    }
220}
221
222// ---------------------------------------------------------------------------
223// ShapingKey — deterministic cache key
224// ---------------------------------------------------------------------------
225
226/// Deterministic cache key for shaped glyph output.
227///
228/// Captures all parameters that affect shaping results. Two identical keys
229/// are guaranteed to produce identical `ShapedRun` output, enabling safe
230/// caching.
231///
232/// # Key components
233///
234/// | Field          | Purpose                                        |
235/// |----------------|------------------------------------------------|
236/// | `text_hash`    | FxHash of the text content                     |
237/// | `text_len`     | Byte length (collision avoidance)               |
238/// | `script`       | Unicode script (affects glyph selection)        |
239/// | `direction`    | LTR/RTL (affects reordering + positioning)     |
240/// | `style_id`     | Style discriminant (bold/italic affect glyphs) |
241/// | `font_id`      | Font face identity                             |
242/// | `size_256ths`  | Font size in 1/256th point units               |
243/// | `features`     | Active OpenType features                       |
244/// | `generation`   | Cache generation (invalidation epoch)          |
245#[derive(Debug, Clone, PartialEq, Eq, Hash)]
246pub struct ShapingKey {
247    /// FxHash of the text content.
248    pub text_hash: u64,
249    /// Byte length of the text (for collision avoidance with the hash).
250    pub text_len: u32,
251    /// Unicode script.
252    pub script: Script,
253    /// Text direction.
254    pub direction: RunDirection,
255    /// Style discriminant.
256    pub style_id: u64,
257    /// Font face identity.
258    pub font_id: FontId,
259    /// Font size in 1/256th of a point (sub-pixel precision matching ftui-render).
260    pub size_256ths: u32,
261    /// Active OpenType features (canonicalized for determinism).
262    pub features: FontFeatures,
263    /// Cache generation epoch — entries from older generations are stale.
264    pub generation: u64,
265}
266
267impl ShapingKey {
268    /// Build a key from shaping parameters.
269    #[allow(clippy::too_many_arguments)]
270    pub fn new(
271        text: &str,
272        script: Script,
273        direction: RunDirection,
274        style_id: u64,
275        font_id: FontId,
276        size_256ths: u32,
277        features: &FontFeatures,
278        generation: u64,
279    ) -> Self {
280        let mut hasher = FxHasher::default();
281        text.hash(&mut hasher);
282        let text_hash = hasher.finish();
283
284        Self {
285            text_hash,
286            text_len: text.len() as u32,
287            script,
288            direction,
289            style_id,
290            font_id,
291            size_256ths,
292            features: features.clone(),
293            generation,
294        }
295    }
296}
297
298// ---------------------------------------------------------------------------
299// TextShaper trait
300// ---------------------------------------------------------------------------
301
302/// Abstract text shaping backend.
303///
304/// Implementations convert a Unicode text string into positioned glyphs
305/// according to the rules of the specified script, direction, and font
306/// features.
307///
308/// The trait is object-safe to allow dynamic dispatch between backends
309/// (e.g., terminal noop vs. web rustybuzz).
310pub trait TextShaper {
311    /// Shape a text run into positioned glyphs.
312    ///
313    /// # Parameters
314    ///
315    /// * `text` — The text to shape (UTF-8, from a single `TextRun`).
316    /// * `script` — The resolved Unicode script.
317    /// * `direction` — LTR or RTL text direction.
318    /// * `features` — OpenType features to apply.
319    ///
320    /// # Returns
321    ///
322    /// A `ShapedRun` containing positioned glyphs in visual order.
323    fn shape(
324        &self,
325        text: &str,
326        script: Script,
327        direction: RunDirection,
328        features: &FontFeatures,
329    ) -> ShapedRun;
330}
331
332// ---------------------------------------------------------------------------
333// NoopShaper — terminal / monospace backend
334// ---------------------------------------------------------------------------
335
336/// Identity shaper for monospace terminal rendering.
337///
338/// Maps each grapheme cluster to a single glyph with uniform advance.
339/// This is the correct shaping backend for fixed-width terminal output
340/// where each cell is one column wide (or two for CJK/wide characters).
341///
342/// The glyph ID is set to the first codepoint of each grapheme, and
343/// the advance is the grapheme's display width in terminal cells.
344pub struct NoopShaper;
345
346impl TextShaper for NoopShaper {
347    fn shape(
348        &self,
349        text: &str,
350        _script: Script,
351        _direction: RunDirection,
352        _features: &FontFeatures,
353    ) -> ShapedRun {
354        use unicode_segmentation::UnicodeSegmentation;
355
356        let mut glyphs = Vec::new();
357        let mut total_advance = 0i32;
358
359        for (byte_offset, grapheme) in text.grapheme_indices(true) {
360            let first_char = grapheme.chars().next().unwrap_or('\0');
361            let width = crate::grapheme_width(grapheme) as i32;
362
363            glyphs.push(ShapedGlyph {
364                glyph_id: first_char as u32,
365                cluster: byte_offset as u32,
366                x_advance: width,
367                y_advance: 0,
368                x_offset: 0,
369                y_offset: 0,
370            });
371
372            total_advance += width;
373        }
374
375        ShapedRun {
376            glyphs,
377            total_advance,
378        }
379    }
380}
381
382// ---------------------------------------------------------------------------
383// ShapingCache — LRU cache with generation-based invalidation
384// ---------------------------------------------------------------------------
385
386/// Statistics for the shaping cache.
387#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
388pub struct ShapingCacheStats {
389    /// Number of cache hits.
390    pub hits: u64,
391    /// Number of cache misses (triggered shaping).
392    pub misses: u64,
393    /// Number of stale entries evicted due to generation mismatch.
394    pub stale_evictions: u64,
395    /// Current number of entries in the cache.
396    pub size: usize,
397    /// Maximum capacity of the cache.
398    pub capacity: usize,
399    /// Current invalidation generation.
400    pub generation: u64,
401}
402
403impl ShapingCacheStats {
404    /// Hit rate as a fraction (0.0 to 1.0).
405    #[must_use]
406    pub fn hit_rate(&self) -> f64 {
407        let total = self.hits + self.misses;
408        if total == 0 {
409            0.0
410        } else {
411            self.hits as f64 / total as f64
412        }
413    }
414}
415
416/// Cached entry with its generation stamp.
417#[derive(Debug, Clone)]
418struct CachedEntry {
419    run: ShapedRun,
420    generation: u64,
421}
422
423/// LRU cache for shaped text runs with generation-based invalidation.
424///
425/// # Invalidation policy
426///
427/// The cache tracks a monotonically increasing generation counter. Each
428/// cached entry is stamped with the generation at insertion time. When
429/// global state changes (font swap, DPR change, zoom), the caller bumps
430/// the generation via [`invalidate`](Self::invalidate). Entries from older
431/// generations are treated as misses on access and lazily replaced.
432///
433/// This avoids expensive bulk-clear operations while ensuring correctness.
434///
435/// # Thread safety
436///
437/// The cache is not `Sync`. For multi-threaded use, wrap in a `Mutex` or
438/// use per-thread instances (matching the `thread_local_cache` feature
439/// pattern from `WidthCache`).
440pub struct ShapingCache<S: TextShaper> {
441    shaper: S,
442    cache: LruCache<ShapingKey, CachedEntry>,
443    generation: u64,
444    stats: ShapingCacheStats,
445}
446
447impl<S: TextShaper> ShapingCache<S> {
448    /// Create a new shaping cache with the given backend and capacity.
449    pub fn new(shaper: S, capacity: usize) -> Self {
450        let cap = NonZeroUsize::new(capacity.max(1)).expect("capacity must be > 0");
451        Self {
452            shaper,
453            cache: LruCache::new(cap),
454            generation: 0,
455            stats: ShapingCacheStats {
456                capacity,
457                ..Default::default()
458            },
459        }
460    }
461
462    /// Shape a text run, returning a cached result if available.
463    ///
464    /// The full shaping key is constructed from the provided parameters.
465    /// If a cache entry exists with the current generation, it is returned
466    /// directly. Otherwise, the shaper is invoked and the result is cached.
467    pub fn shape(
468        &mut self,
469        text: &str,
470        script: Script,
471        direction: RunDirection,
472        font_id: FontId,
473        size_256ths: u32,
474        features: &FontFeatures,
475    ) -> ShapedRun {
476        self.shape_with_style(text, script, direction, 0, font_id, size_256ths, features)
477    }
478
479    /// Shape with an explicit style discriminant.
480    #[allow(clippy::too_many_arguments)]
481    pub fn shape_with_style(
482        &mut self,
483        text: &str,
484        script: Script,
485        direction: RunDirection,
486        style_id: u64,
487        font_id: FontId,
488        size_256ths: u32,
489        features: &FontFeatures,
490    ) -> ShapedRun {
491        let key = ShapingKey::new(
492            text,
493            script,
494            direction,
495            style_id,
496            font_id,
497            size_256ths,
498            features,
499            self.generation,
500        );
501
502        // Check cache.
503        if let Some(entry) = self.cache.get(&key) {
504            if entry.generation == self.generation {
505                self.stats.hits += 1;
506                return entry.run.clone();
507            }
508            // Stale entry — will be replaced below.
509            self.stats.stale_evictions += 1;
510        }
511
512        // Cache miss — invoke shaper.
513        self.stats.misses += 1;
514        let run = self.shaper.shape(text, script, direction, features);
515
516        self.cache.put(
517            key,
518            CachedEntry {
519                run: run.clone(),
520                generation: self.generation,
521            },
522        );
523
524        self.stats.size = self.cache.len();
525        run
526    }
527
528    /// Bump the generation counter, invalidating all cached entries.
529    ///
530    /// Stale entries are not removed eagerly — they are lazily evicted
531    /// on next access. This makes invalidation O(1).
532    ///
533    /// Call this when:
534    /// - The font set changes (font swap, fallback resolution).
535    /// - Display DPR changes (affects pixel grid rounding).
536    /// - Zoom level changes.
537    pub fn invalidate(&mut self) {
538        self.generation += 1;
539        self.stats.generation = self.generation;
540    }
541
542    /// Clear all cached entries and reset stats.
543    pub fn clear(&mut self) {
544        self.cache.clear();
545        self.generation += 1;
546        self.stats = ShapingCacheStats {
547            capacity: self.stats.capacity,
548            generation: self.generation,
549            ..Default::default()
550        };
551    }
552
553    /// Current cache statistics.
554    #[inline]
555    pub fn stats(&self) -> ShapingCacheStats {
556        ShapingCacheStats {
557            size: self.cache.len(),
558            ..self.stats
559        }
560    }
561
562    /// Current generation counter.
563    #[inline]
564    pub fn generation(&self) -> u64 {
565        self.generation
566    }
567
568    /// Access the underlying shaper.
569    #[inline]
570    pub fn shaper(&self) -> &S {
571        &self.shaper
572    }
573
574    /// Resize the cache capacity.
575    ///
576    /// If the new capacity is smaller than the current size, excess
577    /// entries are evicted in LRU order.
578    pub fn resize(&mut self, new_capacity: usize) {
579        let cap = NonZeroUsize::new(new_capacity.max(1)).expect("capacity must be > 0");
580        self.cache.resize(cap);
581        self.stats.capacity = new_capacity;
582        self.stats.size = self.cache.len();
583    }
584}
585
586// ---------------------------------------------------------------------------
587// RustybuzzShaper — real shaping backend (feature-gated)
588// ---------------------------------------------------------------------------
589
590#[cfg(feature = "shaping")]
591mod rustybuzz_backend {
592    use super::*;
593
594    /// HarfBuzz-compatible shaper using the rustybuzz pure-Rust engine.
595    ///
596    /// Wraps a `rustybuzz::Face` and provides the `TextShaper` interface.
597    /// The face data must outlive the shaper (typically held in an `Arc`).
598    ///
599    /// # Example
600    ///
601    /// ```ignore
602    /// let font_data: &[u8] = include_bytes!("path/to/font.ttf");
603    /// let face = rustybuzz::Face::from_slice(font_data, 0).unwrap();
604    /// let shaper = RustybuzzShaper::new(face);
605    /// ```
606    pub struct RustybuzzShaper {
607        face: rustybuzz::Face<'static>,
608    }
609
610    impl RustybuzzShaper {
611        /// Create a shaper from a rustybuzz face.
612        ///
613        /// The face must have `'static` lifetime — typically achieved by
614        /// loading font data into a leaked `Box<[u8]>` or `Arc` with a
615        /// transmuted lifetime (handled by the font loading layer).
616        pub fn new(face: rustybuzz::Face<'static>) -> Self {
617            Self { face }
618        }
619
620        /// Convert our Script enum to a rustybuzz script constant.
621        fn to_rb_script(script: Script) -> rustybuzz::Script {
622            use rustybuzz::script;
623            match script {
624                Script::Latin => script::LATIN,
625                Script::Greek => script::GREEK,
626                Script::Cyrillic => script::CYRILLIC,
627                Script::Armenian => script::ARMENIAN,
628                Script::Hebrew => script::HEBREW,
629                Script::Arabic => script::ARABIC,
630                Script::Syriac => script::SYRIAC,
631                Script::Thaana => script::THAANA,
632                Script::Devanagari => script::DEVANAGARI,
633                Script::Bengali => script::BENGALI,
634                Script::Gurmukhi => script::GURMUKHI,
635                Script::Gujarati => script::GUJARATI,
636                Script::Oriya => script::ORIYA,
637                Script::Tamil => script::TAMIL,
638                Script::Telugu => script::TELUGU,
639                Script::Kannada => script::KANNADA,
640                Script::Malayalam => script::MALAYALAM,
641                Script::Sinhala => script::SINHALA,
642                Script::Thai => script::THAI,
643                Script::Lao => script::LAO,
644                Script::Tibetan => script::TIBETAN,
645                Script::Myanmar => script::MYANMAR,
646                Script::Georgian => script::GEORGIAN,
647                Script::Hangul => script::HANGUL,
648                Script::Ethiopic => script::ETHIOPIC,
649                Script::Han => script::HAN,
650                Script::Hiragana => script::HIRAGANA,
651                Script::Katakana => script::KATAKANA,
652                Script::Bopomofo => script::BOPOMOFO,
653                Script::Common | Script::Inherited | Script::Unknown => script::COMMON,
654            }
655        }
656
657        /// Convert our RunDirection to rustybuzz::Direction.
658        fn to_rb_direction(direction: RunDirection) -> rustybuzz::Direction {
659            match direction {
660                RunDirection::Ltr => rustybuzz::Direction::LeftToRight,
661                RunDirection::Rtl => rustybuzz::Direction::RightToLeft,
662            }
663        }
664
665        /// Convert our FontFeature to rustybuzz::Feature.
666        fn to_rb_feature(feature: &FontFeature) -> rustybuzz::Feature {
667            let tag = rustybuzz::ttf_parser::Tag::from_bytes(&feature.tag);
668            rustybuzz::Feature::new(tag, feature.value, ..)
669        }
670    }
671
672    impl TextShaper for RustybuzzShaper {
673        fn shape(
674            &self,
675            text: &str,
676            script: Script,
677            direction: RunDirection,
678            features: &FontFeatures,
679        ) -> ShapedRun {
680            let mut buffer = rustybuzz::UnicodeBuffer::new();
681            buffer.push_str(text);
682            buffer.set_script(Self::to_rb_script(script));
683            buffer.set_direction(Self::to_rb_direction(direction));
684
685            let rb_features: Vec<rustybuzz::Feature> =
686                features.iter().map(Self::to_rb_feature).collect();
687
688            let output = rustybuzz::shape(&self.face, &rb_features, buffer);
689
690            let infos = output.glyph_infos();
691            let positions = output.glyph_positions();
692
693            let mut glyphs = Vec::with_capacity(infos.len());
694            let mut total_advance = 0i32;
695
696            for (info, pos) in infos.iter().zip(positions.iter()) {
697                glyphs.push(ShapedGlyph {
698                    glyph_id: info.glyph_id,
699                    cluster: info.cluster,
700                    x_advance: pos.x_advance,
701                    y_advance: pos.y_advance,
702                    x_offset: pos.x_offset,
703                    y_offset: pos.y_offset,
704                });
705                total_advance += pos.x_advance;
706            }
707
708            ShapedRun {
709                glyphs,
710                total_advance,
711            }
712        }
713    }
714}
715
716#[cfg(feature = "shaping")]
717pub use rustybuzz_backend::RustybuzzShaper;
718
719// ===========================================================================
720// Tests
721// ===========================================================================
722
723#[cfg(test)]
724mod tests {
725    use super::*;
726    use crate::script_segmentation::{RunDirection, Script};
727
728    // -----------------------------------------------------------------------
729    // FontFeature / FontFeatures tests
730    // -----------------------------------------------------------------------
731
732    #[test]
733    fn font_feature_new() {
734        let f = FontFeature::new(*b"liga", 1);
735        assert_eq!(f.tag, *b"liga");
736        assert_eq!(f.value, 1);
737    }
738
739    #[test]
740    fn font_feature_enabled_disabled() {
741        let on = FontFeature::enabled(*b"kern");
742        assert_eq!(on.value, 1);
743
744        let off = FontFeature::disabled(*b"kern");
745        assert_eq!(off.value, 0);
746    }
747
748    #[test]
749    fn font_features_push_and_iter() {
750        let mut ff = FontFeatures::new();
751        assert!(ff.is_empty());
752
753        ff.push(FontFeature::enabled(*b"liga"));
754        ff.push(FontFeature::enabled(*b"kern"));
755        assert_eq!(ff.len(), 2);
756
757        let tags: Vec<[u8; 4]> = ff.iter().map(|f| f.tag).collect();
758        assert_eq!(tags, vec![*b"liga", *b"kern"]);
759    }
760
761    #[test]
762    fn font_features_canonicalize() {
763        let mut ff = FontFeatures::from_slice(&[
764            FontFeature::enabled(*b"kern"),
765            FontFeature::enabled(*b"aalt"),
766            FontFeature::enabled(*b"liga"),
767        ]);
768        ff.canonicalize();
769        let tags: Vec<[u8; 4]> = ff.iter().map(|f| f.tag).collect();
770        assert_eq!(tags, vec![*b"aalt", *b"kern", *b"liga"]);
771    }
772
773    #[test]
774    fn font_features_default_is_empty() {
775        let ff = FontFeatures::default();
776        assert!(ff.is_empty());
777    }
778
779    // -----------------------------------------------------------------------
780    // ShapedRun tests
781    // -----------------------------------------------------------------------
782
783    #[test]
784    fn shaped_run_len_and_empty() {
785        let empty = ShapedRun {
786            glyphs: vec![],
787            total_advance: 0,
788        };
789        assert!(empty.is_empty());
790        assert_eq!(empty.len(), 0);
791
792        let non_empty = ShapedRun {
793            glyphs: vec![ShapedGlyph {
794                glyph_id: 65,
795                cluster: 0,
796                x_advance: 600,
797                y_advance: 0,
798                x_offset: 0,
799                y_offset: 0,
800            }],
801            total_advance: 600,
802        };
803        assert!(!non_empty.is_empty());
804        assert_eq!(non_empty.len(), 1);
805    }
806
807    // -----------------------------------------------------------------------
808    // ShapingKey tests
809    // -----------------------------------------------------------------------
810
811    #[test]
812    fn shaping_key_same_input_same_key() {
813        let ff = FontFeatures::default();
814        let k1 = ShapingKey::new(
815            "Hello",
816            Script::Latin,
817            RunDirection::Ltr,
818            0,
819            FontId(0),
820            3072,
821            &ff,
822            0,
823        );
824        let k2 = ShapingKey::new(
825            "Hello",
826            Script::Latin,
827            RunDirection::Ltr,
828            0,
829            FontId(0),
830            3072,
831            &ff,
832            0,
833        );
834        assert_eq!(k1, k2);
835    }
836
837    #[test]
838    fn shaping_key_differs_by_text() {
839        let ff = FontFeatures::default();
840        let k1 = ShapingKey::new(
841            "Hello",
842            Script::Latin,
843            RunDirection::Ltr,
844            0,
845            FontId(0),
846            3072,
847            &ff,
848            0,
849        );
850        let k2 = ShapingKey::new(
851            "World",
852            Script::Latin,
853            RunDirection::Ltr,
854            0,
855            FontId(0),
856            3072,
857            &ff,
858            0,
859        );
860        assert_ne!(k1, k2);
861    }
862
863    #[test]
864    fn shaping_key_differs_by_font() {
865        let ff = FontFeatures::default();
866        let k1 = ShapingKey::new(
867            "Hello",
868            Script::Latin,
869            RunDirection::Ltr,
870            0,
871            FontId(0),
872            3072,
873            &ff,
874            0,
875        );
876        let k2 = ShapingKey::new(
877            "Hello",
878            Script::Latin,
879            RunDirection::Ltr,
880            0,
881            FontId(1),
882            3072,
883            &ff,
884            0,
885        );
886        assert_ne!(k1, k2);
887    }
888
889    #[test]
890    fn shaping_key_differs_by_size() {
891        let ff = FontFeatures::default();
892        let k1 = ShapingKey::new(
893            "Hello",
894            Script::Latin,
895            RunDirection::Ltr,
896            0,
897            FontId(0),
898            3072,
899            &ff,
900            0,
901        );
902        let k2 = ShapingKey::new(
903            "Hello",
904            Script::Latin,
905            RunDirection::Ltr,
906            0,
907            FontId(0),
908            4096,
909            &ff,
910            0,
911        );
912        assert_ne!(k1, k2);
913    }
914
915    #[test]
916    fn shaping_key_differs_by_generation() {
917        let ff = FontFeatures::default();
918        let k1 = ShapingKey::new(
919            "Hello",
920            Script::Latin,
921            RunDirection::Ltr,
922            0,
923            FontId(0),
924            3072,
925            &ff,
926            0,
927        );
928        let k2 = ShapingKey::new(
929            "Hello",
930            Script::Latin,
931            RunDirection::Ltr,
932            0,
933            FontId(0),
934            3072,
935            &ff,
936            1,
937        );
938        assert_ne!(k1, k2);
939    }
940
941    #[test]
942    fn shaping_key_differs_by_features() {
943        let mut ff1 = FontFeatures::default();
944        ff1.push(FontFeature::enabled(*b"liga"));
945
946        let ff2 = FontFeatures::default();
947
948        let k1 = ShapingKey::new(
949            "Hello",
950            Script::Latin,
951            RunDirection::Ltr,
952            0,
953            FontId(0),
954            3072,
955            &ff1,
956            0,
957        );
958        let k2 = ShapingKey::new(
959            "Hello",
960            Script::Latin,
961            RunDirection::Ltr,
962            0,
963            FontId(0),
964            3072,
965            &ff2,
966            0,
967        );
968        assert_ne!(k1, k2);
969    }
970
971    #[test]
972    fn shaping_key_hashable() {
973        use std::collections::HashSet;
974        let ff = FontFeatures::default();
975        let key = ShapingKey::new(
976            "test",
977            Script::Latin,
978            RunDirection::Ltr,
979            0,
980            FontId(0),
981            3072,
982            &ff,
983            0,
984        );
985        let mut set = HashSet::new();
986        set.insert(key.clone());
987        assert!(set.contains(&key));
988    }
989
990    // -----------------------------------------------------------------------
991    // NoopShaper tests
992    // -----------------------------------------------------------------------
993
994    #[test]
995    fn noop_shaper_ascii() {
996        let shaper = NoopShaper;
997        let ff = FontFeatures::default();
998        let run = shaper.shape("Hello", Script::Latin, RunDirection::Ltr, &ff);
999
1000        assert_eq!(run.len(), 5);
1001        assert_eq!(run.total_advance, 5); // 5 ASCII chars × 1 cell each
1002
1003        // Each glyph should have the codepoint as glyph_id.
1004        assert_eq!(run.glyphs[0].glyph_id, b'H' as u32);
1005        assert_eq!(run.glyphs[1].glyph_id, b'e' as u32);
1006        assert_eq!(run.glyphs[4].glyph_id, b'o' as u32);
1007
1008        // Clusters should be byte offsets.
1009        assert_eq!(run.glyphs[0].cluster, 0);
1010        assert_eq!(run.glyphs[1].cluster, 1);
1011        assert_eq!(run.glyphs[4].cluster, 4);
1012    }
1013
1014    #[test]
1015    fn noop_shaper_empty() {
1016        let shaper = NoopShaper;
1017        let ff = FontFeatures::default();
1018        let run = shaper.shape("", Script::Latin, RunDirection::Ltr, &ff);
1019        assert!(run.is_empty());
1020        assert_eq!(run.total_advance, 0);
1021    }
1022
1023    #[test]
1024    fn noop_shaper_wide_chars() {
1025        let shaper = NoopShaper;
1026        let ff = FontFeatures::default();
1027        // CJK characters are 2 cells wide
1028        let run = shaper.shape("\u{4E16}\u{754C}", Script::Han, RunDirection::Ltr, &ff);
1029
1030        assert_eq!(run.len(), 2);
1031        assert_eq!(run.total_advance, 4); // 2 chars × 2 cells each
1032        assert_eq!(run.glyphs[0].x_advance, 2);
1033        assert_eq!(run.glyphs[1].x_advance, 2);
1034    }
1035
1036    #[test]
1037    fn noop_shaper_combining_marks() {
1038        let shaper = NoopShaper;
1039        let ff = FontFeatures::default();
1040        // "é" as e + combining acute: single grapheme cluster
1041        let run = shaper.shape("e\u{0301}", Script::Latin, RunDirection::Ltr, &ff);
1042
1043        // Should produce 1 glyph (one grapheme cluster).
1044        assert_eq!(run.len(), 1);
1045        assert_eq!(run.total_advance, 1);
1046        assert_eq!(run.glyphs[0].glyph_id, b'e' as u32);
1047        assert_eq!(run.glyphs[0].cluster, 0);
1048    }
1049
1050    #[test]
1051    fn noop_shaper_ignores_direction_and_features() {
1052        let shaper = NoopShaper;
1053        let mut ff = FontFeatures::new();
1054        ff.push(FontFeature::enabled(*b"liga"));
1055
1056        let ltr = shaper.shape("ABC", Script::Latin, RunDirection::Ltr, &ff);
1057        let rtl = shaper.shape("ABC", Script::Latin, RunDirection::Rtl, &ff);
1058
1059        // NoopShaper produces identical output regardless of direction.
1060        assert_eq!(ltr, rtl);
1061    }
1062
1063    // -----------------------------------------------------------------------
1064    // ShapingCache tests
1065    // -----------------------------------------------------------------------
1066
1067    #[test]
1068    fn cache_hit_on_second_call() {
1069        let mut cache = ShapingCache::new(NoopShaper, 64);
1070        let ff = FontFeatures::default();
1071
1072        let r1 = cache.shape(
1073            "Hello",
1074            Script::Latin,
1075            RunDirection::Ltr,
1076            FontId(0),
1077            3072,
1078            &ff,
1079        );
1080        let r2 = cache.shape(
1081            "Hello",
1082            Script::Latin,
1083            RunDirection::Ltr,
1084            FontId(0),
1085            3072,
1086            &ff,
1087        );
1088
1089        assert_eq!(r1, r2);
1090        assert_eq!(cache.stats().hits, 1);
1091        assert_eq!(cache.stats().misses, 1);
1092    }
1093
1094    #[test]
1095    fn cache_miss_on_different_text() {
1096        let mut cache = ShapingCache::new(NoopShaper, 64);
1097        let ff = FontFeatures::default();
1098
1099        cache.shape(
1100            "Hello",
1101            Script::Latin,
1102            RunDirection::Ltr,
1103            FontId(0),
1104            3072,
1105            &ff,
1106        );
1107        cache.shape(
1108            "World",
1109            Script::Latin,
1110            RunDirection::Ltr,
1111            FontId(0),
1112            3072,
1113            &ff,
1114        );
1115
1116        assert_eq!(cache.stats().hits, 0);
1117        assert_eq!(cache.stats().misses, 2);
1118    }
1119
1120    #[test]
1121    fn cache_miss_on_different_font() {
1122        let mut cache = ShapingCache::new(NoopShaper, 64);
1123        let ff = FontFeatures::default();
1124
1125        cache.shape(
1126            "Hello",
1127            Script::Latin,
1128            RunDirection::Ltr,
1129            FontId(0),
1130            3072,
1131            &ff,
1132        );
1133        cache.shape(
1134            "Hello",
1135            Script::Latin,
1136            RunDirection::Ltr,
1137            FontId(1),
1138            3072,
1139            &ff,
1140        );
1141
1142        assert_eq!(cache.stats().misses, 2);
1143    }
1144
1145    #[test]
1146    fn cache_miss_on_different_size() {
1147        let mut cache = ShapingCache::new(NoopShaper, 64);
1148        let ff = FontFeatures::default();
1149
1150        cache.shape(
1151            "Hello",
1152            Script::Latin,
1153            RunDirection::Ltr,
1154            FontId(0),
1155            3072,
1156            &ff,
1157        );
1158        cache.shape(
1159            "Hello",
1160            Script::Latin,
1161            RunDirection::Ltr,
1162            FontId(0),
1163            4096,
1164            &ff,
1165        );
1166
1167        assert_eq!(cache.stats().misses, 2);
1168    }
1169
1170    #[test]
1171    fn cache_invalidation_bumps_generation() {
1172        let mut cache = ShapingCache::new(NoopShaper, 64);
1173        assert_eq!(cache.generation(), 0);
1174
1175        cache.invalidate();
1176        assert_eq!(cache.generation(), 1);
1177
1178        cache.invalidate();
1179        assert_eq!(cache.generation(), 2);
1180    }
1181
1182    #[test]
1183    fn cache_stale_entries_are_reshared() {
1184        let mut cache = ShapingCache::new(NoopShaper, 64);
1185        let ff = FontFeatures::default();
1186
1187        // Cache a result at generation 0.
1188        cache.shape(
1189            "Hello",
1190            Script::Latin,
1191            RunDirection::Ltr,
1192            FontId(0),
1193            3072,
1194            &ff,
1195        );
1196        assert_eq!(cache.stats().misses, 1);
1197        assert_eq!(cache.stats().hits, 0);
1198
1199        // Invalidate (bump to generation 1).
1200        cache.invalidate();
1201
1202        // Same text — should be a miss because generation changed.
1203        cache.shape(
1204            "Hello",
1205            Script::Latin,
1206            RunDirection::Ltr,
1207            FontId(0),
1208            3072,
1209            &ff,
1210        );
1211        assert_eq!(cache.stats().misses, 2);
1212        assert_eq!(cache.stats().stale_evictions, 0); // old key had gen=0, new key has gen=1, they don't match by key
1213    }
1214
1215    #[test]
1216    fn cache_clear_resets_everything() {
1217        let mut cache = ShapingCache::new(NoopShaper, 64);
1218        let ff = FontFeatures::default();
1219
1220        cache.shape(
1221            "Hello",
1222            Script::Latin,
1223            RunDirection::Ltr,
1224            FontId(0),
1225            3072,
1226            &ff,
1227        );
1228        cache.shape(
1229            "World",
1230            Script::Latin,
1231            RunDirection::Ltr,
1232            FontId(0),
1233            3072,
1234            &ff,
1235        );
1236
1237        cache.clear();
1238
1239        let stats = cache.stats();
1240        assert_eq!(stats.hits, 0);
1241        assert_eq!(stats.misses, 0);
1242        assert_eq!(stats.size, 0);
1243        assert!(cache.generation() > 0);
1244    }
1245
1246    #[test]
1247    fn cache_resize_evicts_lru() {
1248        let mut cache = ShapingCache::new(NoopShaper, 4);
1249        let ff = FontFeatures::default();
1250
1251        // Fill cache with 4 entries.
1252        for i in 0..4u8 {
1253            let text = format!("text{i}");
1254            cache.shape(
1255                &text,
1256                Script::Latin,
1257                RunDirection::Ltr,
1258                FontId(0),
1259                3072,
1260                &ff,
1261            );
1262        }
1263        assert_eq!(cache.stats().size, 4);
1264
1265        // Shrink to 2 — should evict 2 LRU entries.
1266        cache.resize(2);
1267        assert!(cache.stats().size <= 2);
1268    }
1269
1270    #[test]
1271    fn cache_with_style_id() {
1272        let mut cache = ShapingCache::new(NoopShaper, 64);
1273        let ff = FontFeatures::default();
1274
1275        let r1 = cache.shape_with_style(
1276            "Hello",
1277            Script::Latin,
1278            RunDirection::Ltr,
1279            1,
1280            FontId(0),
1281            3072,
1282            &ff,
1283        );
1284        let r2 = cache.shape_with_style(
1285            "Hello",
1286            Script::Latin,
1287            RunDirection::Ltr,
1288            2,
1289            FontId(0),
1290            3072,
1291            &ff,
1292        );
1293
1294        // Same text but different style — both are misses.
1295        assert_eq!(cache.stats().misses, 2);
1296        // Results are the same (NoopShaper ignores style) but they're cached separately.
1297        assert_eq!(r1, r2);
1298    }
1299
1300    #[test]
1301    fn cache_stats_hit_rate() {
1302        let stats = ShapingCacheStats {
1303            hits: 75,
1304            misses: 25,
1305            ..Default::default()
1306        };
1307        let rate = stats.hit_rate();
1308        assert!((rate - 0.75).abs() < f64::EPSILON);
1309
1310        let empty = ShapingCacheStats::default();
1311        assert_eq!(empty.hit_rate(), 0.0);
1312    }
1313
1314    #[test]
1315    fn cache_shaper_accessible() {
1316        let cache = ShapingCache::new(NoopShaper, 64);
1317        let _shaper: &NoopShaper = cache.shaper();
1318    }
1319
1320    // -----------------------------------------------------------------------
1321    // Integration: script_segmentation → shaping
1322    // -----------------------------------------------------------------------
1323
1324    #[test]
1325    fn shape_partitioned_runs() {
1326        use crate::script_segmentation::partition_text_runs;
1327
1328        let text = "Hello\u{4E16}\u{754C}World";
1329        let runs = partition_text_runs(text, None, None);
1330
1331        let mut cache = ShapingCache::new(NoopShaper, 64);
1332        let ff = FontFeatures::default();
1333
1334        let mut total_advance = 0;
1335        for run in &runs {
1336            let shaped = cache.shape(
1337                run.text(text),
1338                run.script,
1339                run.direction,
1340                FontId(0),
1341                3072,
1342                &ff,
1343            );
1344            total_advance += shaped.total_advance;
1345        }
1346
1347        // Hello (5) + 世界 (4) + World (5) = 14 cells
1348        assert_eq!(total_advance, 14);
1349    }
1350
1351    #[test]
1352    fn shape_empty_run() {
1353        let mut cache = ShapingCache::new(NoopShaper, 64);
1354        let ff = FontFeatures::default();
1355        let run = cache.shape("", Script::Latin, RunDirection::Ltr, FontId(0), 3072, &ff);
1356        assert!(run.is_empty());
1357    }
1358}