1#![forbid(unsafe_code)]
2
3use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
81pub struct FontId(pub u32);
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
88pub struct FontFeature {
89 pub tag: [u8; 4],
91 pub value: u32,
93}
94
95impl FontFeature {
96 #[inline]
98 pub const fn new(tag: [u8; 4], value: u32) -> Self {
99 Self { tag, value }
100 }
101
102 #[inline]
104 pub const fn enabled(tag: [u8; 4]) -> Self {
105 Self { tag, value: 1 }
106 }
107
108 #[inline]
110 pub const fn disabled(tag: [u8; 4]) -> Self {
111 Self { tag, value: 0 }
112 }
113}
114
115#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
119pub struct FontFeatures {
120 features: SmallVec<[FontFeature; 4]>,
121}
122
123impl FontFeatures {
124 #[inline]
126 pub fn new() -> Self {
127 Self {
128 features: SmallVec::new(),
129 }
130 }
131
132 #[inline]
134 pub fn push(&mut self, feature: FontFeature) {
135 self.features.push(feature);
136 }
137
138 pub fn from_slice(features: &[FontFeature]) -> Self {
140 Self {
141 features: SmallVec::from_slice(features),
142 }
143 }
144
145 #[inline]
147 pub fn len(&self) -> usize {
148 self.features.len()
149 }
150
151 #[inline]
153 pub fn is_empty(&self) -> bool {
154 self.features.is_empty()
155 }
156
157 #[inline]
159 pub fn iter(&self) -> impl Iterator<Item = &FontFeature> {
160 self.features.iter()
161 }
162
163 #[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 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 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 #[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 pub fn canonicalize(&mut self) {
209 self.features.sort_by_key(|f| f.tag);
210 }
211}
212
213#[derive(Debug, Clone, Copy, PartialEq, Eq)]
222pub struct ShapedGlyph {
223 pub glyph_id: u32,
225 pub cluster: u32,
231 pub x_advance: i32,
233 pub y_advance: i32,
235 pub x_offset: i32,
237 pub y_offset: i32,
239}
240
241#[derive(Debug, Clone, PartialEq, Eq)]
245pub struct ShapedRun {
246 pub glyphs: Vec<ShapedGlyph>,
248 pub total_advance: i32,
250}
251
252impl ShapedRun {
253 #[inline]
255 pub fn len(&self) -> usize {
256 self.glyphs.len()
257 }
258
259 #[inline]
261 pub fn is_empty(&self) -> bool {
262 self.glyphs.is_empty()
263 }
264}
265
266#[derive(Debug, Clone, PartialEq, Eq, Hash)]
289pub struct ShapingKey {
290 pub text_hash: u64,
292 pub text_len: u32,
294 pub script: Script,
296 pub direction: RunDirection,
298 pub style_id: u64,
300 pub font_id: FontId,
302 pub size_256ths: u32,
304 pub features: FontFeatures,
306}
307
308impl ShapingKey {
309 #[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
337pub trait TextShaper {
350 fn shape(
363 &self,
364 text: &str,
365 script: Script,
366 direction: RunDirection,
367 features: &FontFeatures,
368 ) -> ShapedRun;
369}
370
371pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
427pub struct ShapingCacheStats {
428 pub hits: u64,
430 pub misses: u64,
432 pub stale_evictions: u64,
434 pub size: usize,
436 pub capacity: usize,
438 pub generation: u64,
440}
441
442impl ShapingCacheStats {
443 #[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#[derive(Debug, Clone)]
457struct CachedEntry {
458 run: ShapedRun,
459 generation: u64,
460}
461
462pub 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 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 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 #[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 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 self.stats.stale_evictions += 1;
548 }
549
550 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 pub fn invalidate(&mut self) {
576 self.generation += 1;
577 self.stats.generation = self.generation;
578 }
579
580 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 #[inline]
593 pub fn stats(&self) -> ShapingCacheStats {
594 ShapingCacheStats {
595 size: self.cache.len(),
596 ..self.stats
597 }
598 }
599
600 #[inline]
602 pub fn generation(&self) -> u64 {
603 self.generation
604 }
605
606 #[inline]
608 pub fn shaper(&self) -> &S {
609 &self.shaper
610 }
611
612 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#[cfg(feature = "shaping")]
629mod rustybuzz_backend {
630 use super::*;
631
632 pub struct RustybuzzShaper {
645 face: rustybuzz::Face<'static>,
646 }
647
648 impl RustybuzzShaper {
649 pub fn new(face: rustybuzz::Face<'static>) -> Self {
655 Self { face }
656 }
657
658 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 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 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#[cfg(test)]
762mod tests {
763 use super::*;
764 use crate::script_segmentation::{RunDirection, Script};
765
766 #[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 #[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 #[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 #[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); 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 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 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); 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 let run = shaper.shape("e\u{0301}", Script::Latin, RunDirection::Ltr, &ff);
1095
1096 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 assert_eq!(ltr, rtl);
1114 }
1115
1116 #[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.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 cache.invalidate();
1326
1327 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.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 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 cache.invalidate();
1371
1372 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 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 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 assert_eq!(cache.stats().misses, 2);
1468 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 #[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 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}