#![forbid(unsafe_code)]
use crate::layout_policy::{LayoutTier, RuntimeCapability};
use crate::script_segmentation::{RunDirection, Script};
use crate::shaped_render::ShapedLineLayout;
use crate::shaping::{FontFeatures, NoopShaper, ShapedRun, TextShaper};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum LigatureMode {
#[default]
Auto,
Enabled,
Disabled,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum FallbackEvent {
ShapedSuccessfully,
ShapingRejected,
NoopUsed,
SkippedByPolicy,
}
impl FallbackEvent {
#[inline]
pub const fn was_shaped(&self) -> bool {
matches!(self, Self::ShapedSuccessfully)
}
#[inline]
pub const fn is_fallback(&self) -> bool {
!self.was_shaped()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct FallbackStats {
pub total_lines: u64,
pub shaped_lines: u64,
pub fallback_lines: u64,
pub rejected_lines: u64,
pub skipped_lines: u64,
}
impl FallbackStats {
pub fn record(&mut self, event: FallbackEvent) {
self.total_lines += 1;
match event {
FallbackEvent::ShapedSuccessfully => self.shaped_lines += 1,
FallbackEvent::ShapingRejected => {
self.fallback_lines += 1;
self.rejected_lines += 1;
}
FallbackEvent::NoopUsed => self.fallback_lines += 1,
FallbackEvent::SkippedByPolicy => self.skipped_lines += 1,
}
}
pub fn shaping_rate(&self) -> f64 {
if self.total_lines == 0 {
return 0.0;
}
self.shaped_lines as f64 / self.total_lines as f64
}
pub fn fallback_rate(&self) -> f64 {
if self.total_lines == 0 {
return 0.0;
}
self.fallback_lines as f64 / self.total_lines as f64
}
}
pub struct ShapingFallback<S: TextShaper = NoopShaper> {
primary: Option<S>,
features: FontFeatures,
shaping_tier: LayoutTier,
capabilities: RuntimeCapability,
ligature_mode: LigatureMode,
validate_output: bool,
}
impl ShapingFallback<NoopShaper> {
#[must_use]
pub fn terminal() -> Self {
Self {
primary: None,
features: FontFeatures::default(),
shaping_tier: LayoutTier::Quality,
capabilities: RuntimeCapability::TERMINAL,
ligature_mode: LigatureMode::Disabled,
validate_output: false,
}
}
}
impl<S: TextShaper> ShapingFallback<S> {
#[must_use]
pub fn with_shaper(shaper: S, capabilities: RuntimeCapability) -> Self {
Self {
primary: Some(shaper),
features: FontFeatures::default(),
shaping_tier: LayoutTier::Balanced,
capabilities,
ligature_mode: LigatureMode::Auto,
validate_output: true,
}
}
pub fn set_features(&mut self, features: FontFeatures) {
self.features = features;
}
pub fn set_shaping_tier(&mut self, tier: LayoutTier) {
self.shaping_tier = tier;
}
pub fn set_ligature_mode(&mut self, mode: LigatureMode) {
self.ligature_mode = mode;
}
pub fn set_capabilities(&mut self, caps: RuntimeCapability) {
self.capabilities = caps;
}
pub fn set_validate_output(&mut self, validate: bool) {
self.validate_output = validate;
}
pub fn shape_line(
&self,
text: &str,
script: Script,
direction: RunDirection,
) -> (ShapedLineLayout, FallbackEvent) {
if text.is_empty() {
return (ShapedLineLayout::from_text(""), FallbackEvent::NoopUsed);
}
let Some(shaper) = &self.primary else {
return (ShapedLineLayout::from_text(text), FallbackEvent::NoopUsed);
};
let effective_tier = self.capabilities.best_tier();
if effective_tier < self.shaping_tier {
return (
ShapedLineLayout::from_text(text),
FallbackEvent::SkippedByPolicy,
);
}
let ligature_requested = match self.ligature_mode {
LigatureMode::Enabled => true,
LigatureMode::Disabled => false,
LigatureMode::Auto => self.features.standard_ligatures_enabled().unwrap_or(false),
};
if ligature_requested && !self.capabilities.ligature_support {
tracing::debug!(
text_len = text.len(),
mode = ?self.ligature_mode,
"Ligatures requested but unsupported, using canonical grapheme fallback"
);
return (
ShapedLineLayout::from_text(text),
FallbackEvent::SkippedByPolicy,
);
}
let mut effective_features = self.features.clone();
match self.ligature_mode {
LigatureMode::Enabled => effective_features.set_standard_ligatures(true),
LigatureMode::Disabled => effective_features.set_standard_ligatures(false),
LigatureMode::Auto => {
if !self.capabilities.ligature_support {
effective_features.set_standard_ligatures(false);
}
}
}
{
let run = shaper.shape(text, script, direction, &effective_features);
if self.validate_output
&& let Some(rejection) = validate_shaped_run(text, &run)
{
tracing::debug!(
text_len = text.len(),
glyph_count = run.glyphs.len(),
reason = %rejection,
"Shaped output rejected, falling back to NoopShaper"
);
return (
ShapedLineLayout::from_text(text),
FallbackEvent::ShapingRejected,
);
}
(
ShapedLineLayout::from_run(text, &run),
FallbackEvent::ShapedSuccessfully,
)
}
}
pub fn shape_lines(
&self,
lines: &[&str],
script: Script,
direction: RunDirection,
) -> (Vec<ShapedLineLayout>, FallbackStats) {
let mut layouts = Vec::with_capacity(lines.len());
let mut stats = FallbackStats::default();
for text in lines {
let (layout, event) = self.shape_line(text, script, direction);
stats.record(event);
layouts.push(layout);
}
(layouts, stats)
}
}
fn validate_shaped_run(text: &str, run: &ShapedRun) -> Option<&'static str> {
if text.is_empty() {
return None; }
if run.glyphs.is_empty() {
return Some("no glyphs produced for non-empty input");
}
if run.glyphs.len() > text.len() * 4 {
return Some("glyph count exceeds 4x text byte length");
}
if run.glyphs.iter().all(|g| g.x_advance == 0) {
return Some("all glyph advances are zero");
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::shaping::ShapedGlyph;
#[derive(Debug, Clone, Copy)]
struct FeatureAwareLigatureShaper;
impl TextShaper for FeatureAwareLigatureShaper {
fn shape(
&self,
text: &str,
_script: Script,
_direction: RunDirection,
features: &FontFeatures,
) -> ShapedRun {
let ligatures_on = features.feature_value(*b"liga").unwrap_or(1) != 0;
if ligatures_on && text == "file" {
return ShapedRun {
glyphs: vec![
ShapedGlyph {
glyph_id: 1,
cluster: 0, x_advance: 2,
y_advance: 0,
x_offset: 0,
y_offset: 0,
},
ShapedGlyph {
glyph_id: 2,
cluster: 2,
x_advance: 1,
y_advance: 0,
x_offset: 0,
y_offset: 0,
},
ShapedGlyph {
glyph_id: 3,
cluster: 3,
x_advance: 1,
y_advance: 0,
x_offset: 0,
y_offset: 0,
},
],
total_advance: 4,
};
}
let mut glyphs = Vec::new();
for (byte_offset, ch) in text.char_indices() {
glyphs.push(ShapedGlyph {
glyph_id: ch as u32,
cluster: byte_offset as u32,
x_advance: 1,
y_advance: 0,
x_offset: 0,
y_offset: 0,
});
}
let total_advance = i32::try_from(glyphs.len()).unwrap_or(i32::MAX);
ShapedRun {
glyphs,
total_advance,
}
}
}
#[test]
fn terminal_fallback() {
let fb = ShapingFallback::terminal();
let (layout, event) = fb.shape_line("Hello", Script::Latin, RunDirection::Ltr);
assert_eq!(layout.total_cells(), 5);
assert_eq!(event, FallbackEvent::NoopUsed);
}
#[test]
fn terminal_empty_input() {
let fb = ShapingFallback::terminal();
let (layout, event) = fb.shape_line("", Script::Latin, RunDirection::Ltr);
assert!(layout.is_empty());
assert_eq!(event, FallbackEvent::NoopUsed);
}
#[test]
fn terminal_wide_chars() {
let fb = ShapingFallback::terminal();
let (layout, _) = fb.shape_line("\u{4E16}\u{754C}", Script::Han, RunDirection::Ltr);
assert_eq!(layout.total_cells(), 4); }
#[test]
fn noop_shaper_primary() {
let fb = ShapingFallback::with_shaper(NoopShaper, RuntimeCapability::TERMINAL);
let (layout, event) = fb.shape_line("Hello", Script::Latin, RunDirection::Ltr);
assert_eq!(layout.total_cells(), 5);
assert_eq!(event, FallbackEvent::ShapedSuccessfully);
}
#[test]
fn noop_shaper_with_full_caps() {
let fb = ShapingFallback::with_shaper(NoopShaper, RuntimeCapability::FULL);
let (layout, event) = fb.shape_line("Hello", Script::Latin, RunDirection::Ltr);
assert_eq!(layout.total_cells(), 5);
assert_eq!(event, FallbackEvent::ShapedSuccessfully);
}
#[test]
fn validate_empty_run() {
let run = ShapedRun {
glyphs: vec![],
total_advance: 0,
};
assert!(validate_shaped_run("Hello", &run).is_some());
}
#[test]
fn validate_empty_input() {
let run = ShapedRun {
glyphs: vec![],
total_advance: 0,
};
assert!(validate_shaped_run("", &run).is_none());
}
#[test]
fn validate_zero_advances() {
use crate::shaping::ShapedGlyph;
let run = ShapedRun {
glyphs: vec![
ShapedGlyph {
glyph_id: 1,
cluster: 0,
x_advance: 0,
y_advance: 0,
x_offset: 0,
y_offset: 0,
},
ShapedGlyph {
glyph_id: 2,
cluster: 1,
x_advance: 0,
y_advance: 0,
x_offset: 0,
y_offset: 0,
},
],
total_advance: 0,
};
assert!(validate_shaped_run("AB", &run).is_some());
}
#[test]
fn validate_valid_run() {
use crate::shaping::ShapedGlyph;
let run = ShapedRun {
glyphs: vec![
ShapedGlyph {
glyph_id: 1,
cluster: 0,
x_advance: 1,
y_advance: 0,
x_offset: 0,
y_offset: 0,
},
ShapedGlyph {
glyph_id: 2,
cluster: 1,
x_advance: 1,
y_advance: 0,
x_offset: 0,
y_offset: 0,
},
],
total_advance: 2,
};
assert!(validate_shaped_run("AB", &run).is_none());
}
#[test]
fn stats_tracking() {
let mut stats = FallbackStats::default();
stats.record(FallbackEvent::ShapedSuccessfully);
stats.record(FallbackEvent::ShapedSuccessfully);
stats.record(FallbackEvent::NoopUsed);
stats.record(FallbackEvent::ShapingRejected);
assert_eq!(stats.total_lines, 4);
assert_eq!(stats.shaped_lines, 2);
assert_eq!(stats.fallback_lines, 2);
assert_eq!(stats.rejected_lines, 1);
assert_eq!(stats.shaping_rate(), 0.5);
assert_eq!(stats.fallback_rate(), 0.5);
}
#[test]
fn stats_empty() {
let stats = FallbackStats::default();
assert_eq!(stats.shaping_rate(), 0.0);
assert_eq!(stats.fallback_rate(), 0.0);
}
#[test]
fn shape_lines_batch() {
let fb = ShapingFallback::terminal();
let lines = vec!["Hello", "World", "\u{4E16}\u{754C}"];
let (layouts, stats) = fb.shape_lines(&lines, Script::Latin, RunDirection::Ltr);
assert_eq!(layouts.len(), 3);
assert_eq!(stats.total_lines, 3);
assert_eq!(stats.fallback_lines, 3);
}
#[test]
fn event_predicates() {
assert!(FallbackEvent::ShapedSuccessfully.was_shaped());
assert!(!FallbackEvent::ShapedSuccessfully.is_fallback());
assert!(!FallbackEvent::NoopUsed.was_shaped());
assert!(FallbackEvent::NoopUsed.is_fallback());
assert!(!FallbackEvent::ShapingRejected.was_shaped());
assert!(FallbackEvent::ShapingRejected.is_fallback());
assert!(!FallbackEvent::SkippedByPolicy.was_shaped());
assert!(FallbackEvent::SkippedByPolicy.is_fallback());
}
#[test]
fn shaped_and_unshaped_same_total_cells() {
let text = "Hello World!";
let fb_shaped = ShapingFallback::with_shaper(NoopShaper, RuntimeCapability::FULL);
let (layout_shaped, _) = fb_shaped.shape_line(text, Script::Latin, RunDirection::Ltr);
let fb_unshaped = ShapingFallback::terminal();
let (layout_unshaped, _) = fb_unshaped.shape_line(text, Script::Latin, RunDirection::Ltr);
assert_eq!(layout_shaped.total_cells(), layout_unshaped.total_cells());
}
#[test]
fn shaped_and_unshaped_identical_interaction() {
let text = "A\u{4E16}B";
let fb_shaped = ShapingFallback::with_shaper(NoopShaper, RuntimeCapability::FULL);
let (layout_s, _) = fb_shaped.shape_line(text, Script::Latin, RunDirection::Ltr);
let fb_unshaped = ShapingFallback::terminal();
let (layout_u, _) = fb_unshaped.shape_line(text, Script::Latin, RunDirection::Ltr);
let cm_s = layout_s.cluster_map();
let cm_u = layout_u.cluster_map();
for byte in [0, 1, 4] {
assert_eq!(
cm_s.byte_to_cell(byte),
cm_u.byte_to_cell(byte),
"byte_to_cell mismatch at byte {byte}"
);
}
for cell in 0..layout_s.total_cells() {
assert_eq!(
cm_s.cell_to_byte(cell),
cm_u.cell_to_byte(cell),
"cell_to_byte mismatch at cell {cell}"
);
}
}
#[test]
fn set_features() {
let mut fb = ShapingFallback::terminal();
fb.set_features(FontFeatures::default());
let (layout, _) = fb.shape_line("test", Script::Latin, RunDirection::Ltr);
assert_eq!(layout.total_cells(), 4);
}
#[test]
fn set_shaping_tier() {
let mut fb = ShapingFallback::with_shaper(NoopShaper, RuntimeCapability::FULL);
fb.set_shaping_tier(LayoutTier::Quality);
let (_, event) = fb.shape_line("test", Script::Latin, RunDirection::Ltr);
assert_eq!(event, FallbackEvent::ShapedSuccessfully);
}
#[test]
fn ligature_mode_enabled_without_capability_falls_back() {
let mut fb =
ShapingFallback::with_shaper(FeatureAwareLigatureShaper, RuntimeCapability::TERMINAL);
fb.set_ligature_mode(LigatureMode::Enabled);
let (layout, event) = fb.shape_line("file", Script::Latin, RunDirection::Ltr);
assert_eq!(event, FallbackEvent::SkippedByPolicy);
assert_eq!(layout.total_cells(), 4);
assert_eq!(layout.cluster_map().byte_to_cell(1), 1);
}
#[test]
fn ligature_mode_enabled_with_capability_shapes() {
let mut fb =
ShapingFallback::with_shaper(FeatureAwareLigatureShaper, RuntimeCapability::FULL);
fb.set_ligature_mode(LigatureMode::Enabled);
let (layout, event) = fb.shape_line("file", Script::Latin, RunDirection::Ltr);
assert_eq!(event, FallbackEvent::ShapedSuccessfully);
assert_eq!(layout.total_cells(), 4);
assert_eq!(layout.cluster_map().byte_to_cell(1), 0); assert_eq!(layout.extract_text("file", 0, 2), "fi");
}
#[test]
fn ligature_mode_disabled_forces_canonical_boundaries() {
let mut fb =
ShapingFallback::with_shaper(FeatureAwareLigatureShaper, RuntimeCapability::FULL);
fb.set_ligature_mode(LigatureMode::Disabled);
let (layout, event) = fb.shape_line("file", Script::Latin, RunDirection::Ltr);
assert_eq!(event, FallbackEvent::ShapedSuccessfully);
assert_eq!(layout.total_cells(), 4);
assert_eq!(layout.cluster_map().byte_to_cell(1), 1);
}
#[test]
fn auto_mode_honors_explicit_ligature_request_when_unsupported() {
let mut fb =
ShapingFallback::with_shaper(FeatureAwareLigatureShaper, RuntimeCapability::TERMINAL);
let mut features = FontFeatures::default();
features.set_standard_ligatures(true);
fb.set_features(features);
let (layout, event) = fb.shape_line("file", Script::Latin, RunDirection::Ltr);
assert_eq!(event, FallbackEvent::SkippedByPolicy);
assert_eq!(layout.cluster_map().byte_to_cell(1), 1);
}
#[test]
fn auto_mode_disables_implicit_ligatures_when_unsupported() {
let fb =
ShapingFallback::with_shaper(FeatureAwareLigatureShaper, RuntimeCapability::TERMINAL);
let (layout, event) = fb.shape_line("file", Script::Latin, RunDirection::Ltr);
assert_eq!(event, FallbackEvent::ShapedSuccessfully);
assert_eq!(layout.cluster_map().byte_to_cell(1), 1);
}
}