1#![forbid(unsafe_code)]
2
3use crate::layout_policy::{LayoutTier, RuntimeCapability};
55use crate::script_segmentation::{RunDirection, Script};
56use crate::shaped_render::ShapedLineLayout;
57use crate::shaping::{FontFeatures, NoopShaper, ShapedRun, TextShaper};
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
65pub enum LigatureMode {
66 #[default]
71 Auto,
72 Enabled,
74 Disabled,
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
87pub enum FallbackEvent {
88 ShapedSuccessfully,
90 ShapingRejected,
93 NoopUsed,
95 SkippedByPolicy,
97}
98
99impl FallbackEvent {
100 #[inline]
102 pub const fn was_shaped(&self) -> bool {
103 matches!(self, Self::ShapedSuccessfully)
104 }
105
106 #[inline]
108 pub const fn is_fallback(&self) -> bool {
109 !self.was_shaped()
110 }
111}
112
113#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
119pub struct FallbackStats {
120 pub total_lines: u64,
122 pub shaped_lines: u64,
124 pub fallback_lines: u64,
126 pub rejected_lines: u64,
128 pub skipped_lines: u64,
130}
131
132impl FallbackStats {
133 pub fn record(&mut self, event: FallbackEvent) {
135 self.total_lines += 1;
136 match event {
137 FallbackEvent::ShapedSuccessfully => self.shaped_lines += 1,
138 FallbackEvent::ShapingRejected => {
139 self.fallback_lines += 1;
140 self.rejected_lines += 1;
141 }
142 FallbackEvent::NoopUsed => self.fallback_lines += 1,
143 FallbackEvent::SkippedByPolicy => self.skipped_lines += 1,
144 }
145 }
146
147 pub fn shaping_rate(&self) -> f64 {
149 if self.total_lines == 0 {
150 return 0.0;
151 }
152 self.shaped_lines as f64 / self.total_lines as f64
153 }
154
155 pub fn fallback_rate(&self) -> f64 {
157 if self.total_lines == 0 {
158 return 0.0;
159 }
160 self.fallback_lines as f64 / self.total_lines as f64
161 }
162}
163
164pub struct ShapingFallback<S: TextShaper = NoopShaper> {
178 primary: Option<S>,
180 features: FontFeatures,
182 shaping_tier: LayoutTier,
185 capabilities: RuntimeCapability,
187 ligature_mode: LigatureMode,
189 validate_output: bool,
191}
192
193impl ShapingFallback<NoopShaper> {
194 #[must_use]
196 pub fn terminal() -> Self {
197 Self {
198 primary: None,
199 features: FontFeatures::default(),
200 shaping_tier: LayoutTier::Quality,
201 capabilities: RuntimeCapability::TERMINAL,
202 ligature_mode: LigatureMode::Disabled,
203 validate_output: false,
204 }
205 }
206}
207
208impl<S: TextShaper> ShapingFallback<S> {
209 #[must_use]
211 pub fn with_shaper(shaper: S, capabilities: RuntimeCapability) -> Self {
212 Self {
213 primary: Some(shaper),
214 features: FontFeatures::default(),
215 shaping_tier: LayoutTier::Balanced,
216 capabilities,
217 ligature_mode: LigatureMode::Auto,
218 validate_output: true,
219 }
220 }
221
222 pub fn set_features(&mut self, features: FontFeatures) {
224 self.features = features;
225 }
226
227 pub fn set_shaping_tier(&mut self, tier: LayoutTier) {
229 self.shaping_tier = tier;
230 }
231
232 pub fn set_ligature_mode(&mut self, mode: LigatureMode) {
234 self.ligature_mode = mode;
235 }
236
237 pub fn set_capabilities(&mut self, caps: RuntimeCapability) {
239 self.capabilities = caps;
240 }
241
242 pub fn set_validate_output(&mut self, validate: bool) {
244 self.validate_output = validate;
245 }
246
247 pub fn shape_line(
253 &self,
254 text: &str,
255 script: Script,
256 direction: RunDirection,
257 ) -> (ShapedLineLayout, FallbackEvent) {
258 if text.is_empty() {
259 return (ShapedLineLayout::from_text(""), FallbackEvent::NoopUsed);
260 }
261
262 let Some(shaper) = &self.primary else {
264 return (ShapedLineLayout::from_text(text), FallbackEvent::NoopUsed);
265 };
266
267 let effective_tier = self.capabilities.best_tier();
269 if effective_tier < self.shaping_tier {
270 return (
271 ShapedLineLayout::from_text(text),
272 FallbackEvent::SkippedByPolicy,
273 );
274 }
275
276 let ligature_requested = match self.ligature_mode {
277 LigatureMode::Enabled => true,
278 LigatureMode::Disabled => false,
279 LigatureMode::Auto => self.features.standard_ligatures_enabled().unwrap_or(false),
280 };
281 if ligature_requested && !self.capabilities.ligature_support {
282 tracing::debug!(
283 text_len = text.len(),
284 mode = ?self.ligature_mode,
285 "Ligatures requested but unsupported, using canonical grapheme fallback"
286 );
287 return (
288 ShapedLineLayout::from_text(text),
289 FallbackEvent::SkippedByPolicy,
290 );
291 }
292
293 let mut effective_features = self.features.clone();
294 match self.ligature_mode {
295 LigatureMode::Enabled => effective_features.set_standard_ligatures(true),
296 LigatureMode::Disabled => effective_features.set_standard_ligatures(false),
297 LigatureMode::Auto => {
298 if !self.capabilities.ligature_support {
302 effective_features.set_standard_ligatures(false);
303 }
304 }
305 }
306
307 {
309 let run = shaper.shape(text, script, direction, &effective_features);
310
311 if self.validate_output
312 && let Some(rejection) = validate_shaped_run(text, &run)
313 {
314 tracing::debug!(
315 text_len = text.len(),
316 glyph_count = run.glyphs.len(),
317 reason = %rejection,
318 "Shaped output rejected, falling back to NoopShaper"
319 );
320 return (
321 ShapedLineLayout::from_text(text),
322 FallbackEvent::ShapingRejected,
323 );
324 }
325
326 (
327 ShapedLineLayout::from_run(text, &run),
328 FallbackEvent::ShapedSuccessfully,
329 )
330 }
331 }
332
333 pub fn shape_lines(
337 &self,
338 lines: &[&str],
339 script: Script,
340 direction: RunDirection,
341 ) -> (Vec<ShapedLineLayout>, FallbackStats) {
342 let mut layouts = Vec::with_capacity(lines.len());
343 let mut stats = FallbackStats::default();
344
345 for text in lines {
346 let (layout, event) = self.shape_line(text, script, direction);
347 stats.record(event);
348 layouts.push(layout);
349 }
350
351 (layouts, stats)
352 }
353}
354
355fn validate_shaped_run(text: &str, run: &ShapedRun) -> Option<&'static str> {
366 if text.is_empty() {
367 return None; }
369
370 if run.glyphs.is_empty() {
372 return Some("no glyphs produced for non-empty input");
373 }
374
375 if run.glyphs.len() > text.len() * 4 {
379 return Some("glyph count exceeds 4x text byte length");
380 }
381
382 if run.glyphs.iter().all(|g| g.x_advance == 0) {
384 return Some("all glyph advances are zero");
385 }
386
387 None
388}
389
390#[cfg(test)]
395mod tests {
396 use super::*;
397 use crate::shaping::ShapedGlyph;
398
399 #[derive(Debug, Clone, Copy)]
400 struct FeatureAwareLigatureShaper;
401
402 impl TextShaper for FeatureAwareLigatureShaper {
403 fn shape(
404 &self,
405 text: &str,
406 _script: Script,
407 _direction: RunDirection,
408 features: &FontFeatures,
409 ) -> ShapedRun {
410 let ligatures_on = features.feature_value(*b"liga").unwrap_or(1) != 0;
413 if ligatures_on && text == "file" {
414 return ShapedRun {
415 glyphs: vec![
416 ShapedGlyph {
417 glyph_id: 1,
418 cluster: 0, x_advance: 2,
420 y_advance: 0,
421 x_offset: 0,
422 y_offset: 0,
423 },
424 ShapedGlyph {
425 glyph_id: 2,
426 cluster: 2,
427 x_advance: 1,
428 y_advance: 0,
429 x_offset: 0,
430 y_offset: 0,
431 },
432 ShapedGlyph {
433 glyph_id: 3,
434 cluster: 3,
435 x_advance: 1,
436 y_advance: 0,
437 x_offset: 0,
438 y_offset: 0,
439 },
440 ],
441 total_advance: 4,
442 };
443 }
444
445 let mut glyphs = Vec::new();
446 for (byte_offset, ch) in text.char_indices() {
447 glyphs.push(ShapedGlyph {
448 glyph_id: ch as u32,
449 cluster: byte_offset as u32,
450 x_advance: 1,
451 y_advance: 0,
452 x_offset: 0,
453 y_offset: 0,
454 });
455 }
456 let total_advance = i32::try_from(glyphs.len()).unwrap_or(i32::MAX);
457 ShapedRun {
458 glyphs,
459 total_advance,
460 }
461 }
462 }
463
464 #[test]
469 fn terminal_fallback() {
470 let fb = ShapingFallback::terminal();
471 let (layout, event) = fb.shape_line("Hello", Script::Latin, RunDirection::Ltr);
472
473 assert_eq!(layout.total_cells(), 5);
474 assert_eq!(event, FallbackEvent::NoopUsed);
475 }
476
477 #[test]
478 fn terminal_empty_input() {
479 let fb = ShapingFallback::terminal();
480 let (layout, event) = fb.shape_line("", Script::Latin, RunDirection::Ltr);
481
482 assert!(layout.is_empty());
483 assert_eq!(event, FallbackEvent::NoopUsed);
484 }
485
486 #[test]
487 fn terminal_wide_chars() {
488 let fb = ShapingFallback::terminal();
489 let (layout, _) = fb.shape_line("\u{4E16}\u{754C}", Script::Han, RunDirection::Ltr);
490
491 assert_eq!(layout.total_cells(), 4); }
493
494 #[test]
499 fn noop_shaper_primary() {
500 let fb = ShapingFallback::with_shaper(NoopShaper, RuntimeCapability::TERMINAL);
501 let (layout, event) = fb.shape_line("Hello", Script::Latin, RunDirection::Ltr);
502
503 assert_eq!(layout.total_cells(), 5);
504 assert_eq!(event, FallbackEvent::ShapedSuccessfully);
507 }
508
509 #[test]
510 fn noop_shaper_with_full_caps() {
511 let fb = ShapingFallback::with_shaper(NoopShaper, RuntimeCapability::FULL);
512 let (layout, event) = fb.shape_line("Hello", Script::Latin, RunDirection::Ltr);
513
514 assert_eq!(layout.total_cells(), 5);
515 assert_eq!(event, FallbackEvent::ShapedSuccessfully);
516 }
517
518 #[test]
523 fn validate_empty_run() {
524 let run = ShapedRun {
525 glyphs: vec![],
526 total_advance: 0,
527 };
528 assert!(validate_shaped_run("Hello", &run).is_some());
529 }
530
531 #[test]
532 fn validate_empty_input() {
533 let run = ShapedRun {
534 glyphs: vec![],
535 total_advance: 0,
536 };
537 assert!(validate_shaped_run("", &run).is_none());
538 }
539
540 #[test]
541 fn validate_zero_advances() {
542 use crate::shaping::ShapedGlyph;
543
544 let run = ShapedRun {
545 glyphs: vec![
546 ShapedGlyph {
547 glyph_id: 1,
548 cluster: 0,
549 x_advance: 0,
550 y_advance: 0,
551 x_offset: 0,
552 y_offset: 0,
553 },
554 ShapedGlyph {
555 glyph_id: 2,
556 cluster: 1,
557 x_advance: 0,
558 y_advance: 0,
559 x_offset: 0,
560 y_offset: 0,
561 },
562 ],
563 total_advance: 0,
564 };
565 assert!(validate_shaped_run("AB", &run).is_some());
566 }
567
568 #[test]
569 fn validate_valid_run() {
570 use crate::shaping::ShapedGlyph;
571
572 let run = ShapedRun {
573 glyphs: vec![
574 ShapedGlyph {
575 glyph_id: 1,
576 cluster: 0,
577 x_advance: 1,
578 y_advance: 0,
579 x_offset: 0,
580 y_offset: 0,
581 },
582 ShapedGlyph {
583 glyph_id: 2,
584 cluster: 1,
585 x_advance: 1,
586 y_advance: 0,
587 x_offset: 0,
588 y_offset: 0,
589 },
590 ],
591 total_advance: 2,
592 };
593 assert!(validate_shaped_run("AB", &run).is_none());
594 }
595
596 #[test]
601 fn stats_tracking() {
602 let mut stats = FallbackStats::default();
603
604 stats.record(FallbackEvent::ShapedSuccessfully);
605 stats.record(FallbackEvent::ShapedSuccessfully);
606 stats.record(FallbackEvent::NoopUsed);
607 stats.record(FallbackEvent::ShapingRejected);
608
609 assert_eq!(stats.total_lines, 4);
610 assert_eq!(stats.shaped_lines, 2);
611 assert_eq!(stats.fallback_lines, 2);
612 assert_eq!(stats.rejected_lines, 1);
613 assert_eq!(stats.shaping_rate(), 0.5);
614 assert_eq!(stats.fallback_rate(), 0.5);
615 }
616
617 #[test]
618 fn stats_empty() {
619 let stats = FallbackStats::default();
620 assert_eq!(stats.shaping_rate(), 0.0);
621 assert_eq!(stats.fallback_rate(), 0.0);
622 }
623
624 #[test]
629 fn shape_lines_batch() {
630 let fb = ShapingFallback::terminal();
631 let lines = vec!["Hello", "World", "\u{4E16}\u{754C}"];
632
633 let (layouts, stats) = fb.shape_lines(&lines, Script::Latin, RunDirection::Ltr);
634
635 assert_eq!(layouts.len(), 3);
636 assert_eq!(stats.total_lines, 3);
637 assert_eq!(stats.fallback_lines, 3);
638 }
639
640 #[test]
645 fn event_predicates() {
646 assert!(FallbackEvent::ShapedSuccessfully.was_shaped());
647 assert!(!FallbackEvent::ShapedSuccessfully.is_fallback());
648
649 assert!(!FallbackEvent::NoopUsed.was_shaped());
650 assert!(FallbackEvent::NoopUsed.is_fallback());
651
652 assert!(!FallbackEvent::ShapingRejected.was_shaped());
653 assert!(FallbackEvent::ShapingRejected.is_fallback());
654
655 assert!(!FallbackEvent::SkippedByPolicy.was_shaped());
656 assert!(FallbackEvent::SkippedByPolicy.is_fallback());
657 }
658
659 #[test]
664 fn shaped_and_unshaped_same_total_cells() {
665 let text = "Hello World!";
666
667 let fb_shaped = ShapingFallback::with_shaper(NoopShaper, RuntimeCapability::FULL);
669 let (layout_shaped, _) = fb_shaped.shape_line(text, Script::Latin, RunDirection::Ltr);
670
671 let fb_unshaped = ShapingFallback::terminal();
673 let (layout_unshaped, _) = fb_unshaped.shape_line(text, Script::Latin, RunDirection::Ltr);
674
675 assert_eq!(layout_shaped.total_cells(), layout_unshaped.total_cells());
677 }
678
679 #[test]
680 fn shaped_and_unshaped_identical_interaction() {
681 let text = "A\u{4E16}B";
682
683 let fb_shaped = ShapingFallback::with_shaper(NoopShaper, RuntimeCapability::FULL);
684 let (layout_s, _) = fb_shaped.shape_line(text, Script::Latin, RunDirection::Ltr);
685
686 let fb_unshaped = ShapingFallback::terminal();
687 let (layout_u, _) = fb_unshaped.shape_line(text, Script::Latin, RunDirection::Ltr);
688
689 let cm_s = layout_s.cluster_map();
691 let cm_u = layout_u.cluster_map();
692
693 for byte in [0, 1, 4] {
694 assert_eq!(
695 cm_s.byte_to_cell(byte),
696 cm_u.byte_to_cell(byte),
697 "byte_to_cell mismatch at byte {byte}"
698 );
699 }
700
701 for cell in 0..layout_s.total_cells() {
702 assert_eq!(
703 cm_s.cell_to_byte(cell),
704 cm_u.cell_to_byte(cell),
705 "cell_to_byte mismatch at cell {cell}"
706 );
707 }
708 }
709
710 #[test]
715 fn set_features() {
716 let mut fb = ShapingFallback::terminal();
717 fb.set_features(FontFeatures::default());
718 let (layout, _) = fb.shape_line("test", Script::Latin, RunDirection::Ltr);
720 assert_eq!(layout.total_cells(), 4);
721 }
722
723 #[test]
724 fn set_shaping_tier() {
725 let mut fb = ShapingFallback::with_shaper(NoopShaper, RuntimeCapability::FULL);
726 fb.set_shaping_tier(LayoutTier::Quality);
727
728 let (_, event) = fb.shape_line("test", Script::Latin, RunDirection::Ltr);
730 assert_eq!(event, FallbackEvent::ShapedSuccessfully);
731 }
732
733 #[test]
734 fn ligature_mode_enabled_without_capability_falls_back() {
735 let mut fb =
736 ShapingFallback::with_shaper(FeatureAwareLigatureShaper, RuntimeCapability::TERMINAL);
737 fb.set_ligature_mode(LigatureMode::Enabled);
738
739 let (layout, event) = fb.shape_line("file", Script::Latin, RunDirection::Ltr);
740 assert_eq!(event, FallbackEvent::SkippedByPolicy);
741 assert_eq!(layout.total_cells(), 4);
742 assert_eq!(layout.cluster_map().byte_to_cell(1), 1);
743 }
744
745 #[test]
746 fn ligature_mode_enabled_with_capability_shapes() {
747 let mut fb =
748 ShapingFallback::with_shaper(FeatureAwareLigatureShaper, RuntimeCapability::FULL);
749 fb.set_ligature_mode(LigatureMode::Enabled);
750
751 let (layout, event) = fb.shape_line("file", Script::Latin, RunDirection::Ltr);
752 assert_eq!(event, FallbackEvent::ShapedSuccessfully);
753 assert_eq!(layout.total_cells(), 4);
754 assert_eq!(layout.cluster_map().byte_to_cell(1), 0); assert_eq!(layout.extract_text("file", 0, 2), "fi");
756 }
757
758 #[test]
759 fn ligature_mode_disabled_forces_canonical_boundaries() {
760 let mut fb =
761 ShapingFallback::with_shaper(FeatureAwareLigatureShaper, RuntimeCapability::FULL);
762 fb.set_ligature_mode(LigatureMode::Disabled);
763
764 let (layout, event) = fb.shape_line("file", Script::Latin, RunDirection::Ltr);
765 assert_eq!(event, FallbackEvent::ShapedSuccessfully);
766 assert_eq!(layout.total_cells(), 4);
767 assert_eq!(layout.cluster_map().byte_to_cell(1), 1);
768 }
769
770 #[test]
771 fn auto_mode_honors_explicit_ligature_request_when_unsupported() {
772 let mut fb =
773 ShapingFallback::with_shaper(FeatureAwareLigatureShaper, RuntimeCapability::TERMINAL);
774 let mut features = FontFeatures::default();
775 features.set_standard_ligatures(true);
776 fb.set_features(features);
777
778 let (layout, event) = fb.shape_line("file", Script::Latin, RunDirection::Ltr);
779 assert_eq!(event, FallbackEvent::SkippedByPolicy);
780 assert_eq!(layout.cluster_map().byte_to_cell(1), 1);
781 }
782
783 #[test]
784 fn auto_mode_disables_implicit_ligatures_when_unsupported() {
785 let fb =
786 ShapingFallback::with_shaper(FeatureAwareLigatureShaper, RuntimeCapability::TERMINAL);
787
788 let (layout, event) = fb.shape_line("file", Script::Latin, RunDirection::Ltr);
791 assert_eq!(event, FallbackEvent::ShapedSuccessfully);
792 assert_eq!(layout.cluster_map().byte_to_cell(1), 1);
793 }
794}