1#![forbid(unsafe_code)]
2
3use crate::layout_policy::{LayoutTier, RuntimeCapability};
45use crate::script_segmentation::{RunDirection, Script};
46use crate::shaped_render::ShapedLineLayout;
47use crate::shaping::{FontFeatures, NoopShaper, ShapedRun, TextShaper};
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
58pub enum FallbackEvent {
59 ShapedSuccessfully,
61 ShapingRejected,
64 NoopUsed,
66 SkippedByPolicy,
68}
69
70impl FallbackEvent {
71 #[inline]
73 pub const fn was_shaped(&self) -> bool {
74 matches!(self, Self::ShapedSuccessfully)
75 }
76
77 #[inline]
79 pub const fn is_fallback(&self) -> bool {
80 !self.was_shaped()
81 }
82}
83
84#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
90pub struct FallbackStats {
91 pub total_lines: u64,
93 pub shaped_lines: u64,
95 pub fallback_lines: u64,
97 pub rejected_lines: u64,
99 pub skipped_lines: u64,
101}
102
103impl FallbackStats {
104 pub fn record(&mut self, event: FallbackEvent) {
106 self.total_lines += 1;
107 match event {
108 FallbackEvent::ShapedSuccessfully => self.shaped_lines += 1,
109 FallbackEvent::ShapingRejected => {
110 self.fallback_lines += 1;
111 self.rejected_lines += 1;
112 }
113 FallbackEvent::NoopUsed => self.fallback_lines += 1,
114 FallbackEvent::SkippedByPolicy => self.skipped_lines += 1,
115 }
116 }
117
118 pub fn shaping_rate(&self) -> f64 {
120 if self.total_lines == 0 {
121 return 0.0;
122 }
123 self.shaped_lines as f64 / self.total_lines as f64
124 }
125
126 pub fn fallback_rate(&self) -> f64 {
128 if self.total_lines == 0 {
129 return 0.0;
130 }
131 self.fallback_lines as f64 / self.total_lines as f64
132 }
133}
134
135pub struct ShapingFallback<S: TextShaper = NoopShaper> {
149 primary: Option<S>,
151 features: FontFeatures,
153 shaping_tier: LayoutTier,
156 capabilities: RuntimeCapability,
158 validate_output: bool,
160}
161
162impl ShapingFallback<NoopShaper> {
163 #[must_use]
165 pub fn terminal() -> Self {
166 Self {
167 primary: None,
168 features: FontFeatures::default(),
169 shaping_tier: LayoutTier::Quality,
170 capabilities: RuntimeCapability::TERMINAL,
171 validate_output: false,
172 }
173 }
174}
175
176impl<S: TextShaper> ShapingFallback<S> {
177 #[must_use]
179 pub fn with_shaper(shaper: S, capabilities: RuntimeCapability) -> Self {
180 Self {
181 primary: Some(shaper),
182 features: FontFeatures::default(),
183 shaping_tier: LayoutTier::Balanced,
184 capabilities,
185 validate_output: true,
186 }
187 }
188
189 pub fn set_features(&mut self, features: FontFeatures) {
191 self.features = features;
192 }
193
194 pub fn set_shaping_tier(&mut self, tier: LayoutTier) {
196 self.shaping_tier = tier;
197 }
198
199 pub fn set_capabilities(&mut self, caps: RuntimeCapability) {
201 self.capabilities = caps;
202 }
203
204 pub fn set_validate_output(&mut self, validate: bool) {
206 self.validate_output = validate;
207 }
208
209 pub fn shape_line(
215 &self,
216 text: &str,
217 script: Script,
218 direction: RunDirection,
219 ) -> (ShapedLineLayout, FallbackEvent) {
220 if text.is_empty() {
221 return (ShapedLineLayout::from_text(""), FallbackEvent::NoopUsed);
222 }
223
224 let Some(shaper) = &self.primary else {
226 return (ShapedLineLayout::from_text(text), FallbackEvent::NoopUsed);
227 };
228
229 let effective_tier = self.capabilities.best_tier();
231 if effective_tier < self.shaping_tier {
232 return (
233 ShapedLineLayout::from_text(text),
234 FallbackEvent::SkippedByPolicy,
235 );
236 }
237
238 {
240 let run = shaper.shape(text, script, direction, &self.features);
241
242 if self.validate_output
243 && let Some(rejection) = validate_shaped_run(text, &run)
244 {
245 tracing::debug!(
246 text_len = text.len(),
247 glyph_count = run.glyphs.len(),
248 reason = %rejection,
249 "Shaped output rejected, falling back to NoopShaper"
250 );
251 return (
252 ShapedLineLayout::from_text(text),
253 FallbackEvent::ShapingRejected,
254 );
255 }
256
257 (
258 ShapedLineLayout::from_run(text, &run),
259 FallbackEvent::ShapedSuccessfully,
260 )
261 }
262 }
263
264 pub fn shape_lines(
268 &self,
269 lines: &[&str],
270 script: Script,
271 direction: RunDirection,
272 ) -> (Vec<ShapedLineLayout>, FallbackStats) {
273 let mut layouts = Vec::with_capacity(lines.len());
274 let mut stats = FallbackStats::default();
275
276 for text in lines {
277 let (layout, event) = self.shape_line(text, script, direction);
278 stats.record(event);
279 layouts.push(layout);
280 }
281
282 (layouts, stats)
283 }
284}
285
286fn validate_shaped_run(text: &str, run: &ShapedRun) -> Option<&'static str> {
297 if text.is_empty() {
298 return None; }
300
301 if run.glyphs.is_empty() {
303 return Some("no glyphs produced for non-empty input");
304 }
305
306 if run.glyphs.len() > text.len() * 4 {
310 return Some("glyph count exceeds 4x text byte length");
311 }
312
313 if run.glyphs.iter().all(|g| g.x_advance == 0) {
315 return Some("all glyph advances are zero");
316 }
317
318 None
319}
320
321#[cfg(test)]
326mod tests {
327 use super::*;
328
329 #[test]
334 fn terminal_fallback() {
335 let fb = ShapingFallback::terminal();
336 let (layout, event) = fb.shape_line("Hello", Script::Latin, RunDirection::Ltr);
337
338 assert_eq!(layout.total_cells(), 5);
339 assert_eq!(event, FallbackEvent::NoopUsed);
340 }
341
342 #[test]
343 fn terminal_empty_input() {
344 let fb = ShapingFallback::terminal();
345 let (layout, event) = fb.shape_line("", Script::Latin, RunDirection::Ltr);
346
347 assert!(layout.is_empty());
348 assert_eq!(event, FallbackEvent::NoopUsed);
349 }
350
351 #[test]
352 fn terminal_wide_chars() {
353 let fb = ShapingFallback::terminal();
354 let (layout, _) = fb.shape_line("\u{4E16}\u{754C}", Script::Han, RunDirection::Ltr);
355
356 assert_eq!(layout.total_cells(), 4); }
358
359 #[test]
364 fn noop_shaper_primary() {
365 let fb = ShapingFallback::with_shaper(NoopShaper, RuntimeCapability::TERMINAL);
366 let (layout, event) = fb.shape_line("Hello", Script::Latin, RunDirection::Ltr);
367
368 assert_eq!(layout.total_cells(), 5);
369 assert_eq!(event, FallbackEvent::ShapedSuccessfully);
372 }
373
374 #[test]
375 fn noop_shaper_with_full_caps() {
376 let fb = ShapingFallback::with_shaper(NoopShaper, RuntimeCapability::FULL);
377 let (layout, event) = fb.shape_line("Hello", Script::Latin, RunDirection::Ltr);
378
379 assert_eq!(layout.total_cells(), 5);
380 assert_eq!(event, FallbackEvent::ShapedSuccessfully);
381 }
382
383 #[test]
388 fn validate_empty_run() {
389 let run = ShapedRun {
390 glyphs: vec![],
391 total_advance: 0,
392 };
393 assert!(validate_shaped_run("Hello", &run).is_some());
394 }
395
396 #[test]
397 fn validate_empty_input() {
398 let run = ShapedRun {
399 glyphs: vec![],
400 total_advance: 0,
401 };
402 assert!(validate_shaped_run("", &run).is_none());
403 }
404
405 #[test]
406 fn validate_zero_advances() {
407 use crate::shaping::ShapedGlyph;
408
409 let run = ShapedRun {
410 glyphs: vec![
411 ShapedGlyph {
412 glyph_id: 1,
413 cluster: 0,
414 x_advance: 0,
415 y_advance: 0,
416 x_offset: 0,
417 y_offset: 0,
418 },
419 ShapedGlyph {
420 glyph_id: 2,
421 cluster: 1,
422 x_advance: 0,
423 y_advance: 0,
424 x_offset: 0,
425 y_offset: 0,
426 },
427 ],
428 total_advance: 0,
429 };
430 assert!(validate_shaped_run("AB", &run).is_some());
431 }
432
433 #[test]
434 fn validate_valid_run() {
435 use crate::shaping::ShapedGlyph;
436
437 let run = ShapedRun {
438 glyphs: vec![
439 ShapedGlyph {
440 glyph_id: 1,
441 cluster: 0,
442 x_advance: 1,
443 y_advance: 0,
444 x_offset: 0,
445 y_offset: 0,
446 },
447 ShapedGlyph {
448 glyph_id: 2,
449 cluster: 1,
450 x_advance: 1,
451 y_advance: 0,
452 x_offset: 0,
453 y_offset: 0,
454 },
455 ],
456 total_advance: 2,
457 };
458 assert!(validate_shaped_run("AB", &run).is_none());
459 }
460
461 #[test]
466 fn stats_tracking() {
467 let mut stats = FallbackStats::default();
468
469 stats.record(FallbackEvent::ShapedSuccessfully);
470 stats.record(FallbackEvent::ShapedSuccessfully);
471 stats.record(FallbackEvent::NoopUsed);
472 stats.record(FallbackEvent::ShapingRejected);
473
474 assert_eq!(stats.total_lines, 4);
475 assert_eq!(stats.shaped_lines, 2);
476 assert_eq!(stats.fallback_lines, 2);
477 assert_eq!(stats.rejected_lines, 1);
478 assert_eq!(stats.shaping_rate(), 0.5);
479 assert_eq!(stats.fallback_rate(), 0.5);
480 }
481
482 #[test]
483 fn stats_empty() {
484 let stats = FallbackStats::default();
485 assert_eq!(stats.shaping_rate(), 0.0);
486 assert_eq!(stats.fallback_rate(), 0.0);
487 }
488
489 #[test]
494 fn shape_lines_batch() {
495 let fb = ShapingFallback::terminal();
496 let lines = vec!["Hello", "World", "\u{4E16}\u{754C}"];
497
498 let (layouts, stats) = fb.shape_lines(&lines, Script::Latin, RunDirection::Ltr);
499
500 assert_eq!(layouts.len(), 3);
501 assert_eq!(stats.total_lines, 3);
502 assert_eq!(stats.fallback_lines, 3);
503 }
504
505 #[test]
510 fn event_predicates() {
511 assert!(FallbackEvent::ShapedSuccessfully.was_shaped());
512 assert!(!FallbackEvent::ShapedSuccessfully.is_fallback());
513
514 assert!(!FallbackEvent::NoopUsed.was_shaped());
515 assert!(FallbackEvent::NoopUsed.is_fallback());
516
517 assert!(!FallbackEvent::ShapingRejected.was_shaped());
518 assert!(FallbackEvent::ShapingRejected.is_fallback());
519
520 assert!(!FallbackEvent::SkippedByPolicy.was_shaped());
521 assert!(FallbackEvent::SkippedByPolicy.is_fallback());
522 }
523
524 #[test]
529 fn shaped_and_unshaped_same_total_cells() {
530 let text = "Hello World!";
531
532 let fb_shaped = ShapingFallback::with_shaper(NoopShaper, RuntimeCapability::FULL);
534 let (layout_shaped, _) = fb_shaped.shape_line(text, Script::Latin, RunDirection::Ltr);
535
536 let fb_unshaped = ShapingFallback::terminal();
538 let (layout_unshaped, _) = fb_unshaped.shape_line(text, Script::Latin, RunDirection::Ltr);
539
540 assert_eq!(layout_shaped.total_cells(), layout_unshaped.total_cells());
542 }
543
544 #[test]
545 fn shaped_and_unshaped_identical_interaction() {
546 let text = "A\u{4E16}B";
547
548 let fb_shaped = ShapingFallback::with_shaper(NoopShaper, RuntimeCapability::FULL);
549 let (layout_s, _) = fb_shaped.shape_line(text, Script::Latin, RunDirection::Ltr);
550
551 let fb_unshaped = ShapingFallback::terminal();
552 let (layout_u, _) = fb_unshaped.shape_line(text, Script::Latin, RunDirection::Ltr);
553
554 let cm_s = layout_s.cluster_map();
556 let cm_u = layout_u.cluster_map();
557
558 for byte in [0, 1, 4] {
559 assert_eq!(
560 cm_s.byte_to_cell(byte),
561 cm_u.byte_to_cell(byte),
562 "byte_to_cell mismatch at byte {byte}"
563 );
564 }
565
566 for cell in 0..layout_s.total_cells() {
567 assert_eq!(
568 cm_s.cell_to_byte(cell),
569 cm_u.cell_to_byte(cell),
570 "cell_to_byte mismatch at cell {cell}"
571 );
572 }
573 }
574
575 #[test]
580 fn set_features() {
581 let mut fb = ShapingFallback::terminal();
582 fb.set_features(FontFeatures::default());
583 let (layout, _) = fb.shape_line("test", Script::Latin, RunDirection::Ltr);
585 assert_eq!(layout.total_cells(), 4);
586 }
587
588 #[test]
589 fn set_shaping_tier() {
590 let mut fb = ShapingFallback::with_shaper(NoopShaper, RuntimeCapability::FULL);
591 fb.set_shaping_tier(LayoutTier::Quality);
592
593 let (_, event) = fb.shape_line("test", Script::Latin, RunDirection::Ltr);
595 assert_eq!(event, FallbackEvent::ShapedSuccessfully);
596 }
597}