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