Skip to main content

latexml_core/
ligature.rs

1use std::{fmt, rc::Rc};
2
3use libxml::tree::Node;
4
5use crate::{
6  common::{error::Result, font::Font},
7  document::Document,
8};
9
10pub type LigatureClosure = Rc<dyn Fn(&str) -> String>;
11pub type FontTestClosure = Rc<dyn Fn(&Font) -> bool>;
12pub type LigatureMatcher =
13  Rc<dyn Fn(&mut Document, &mut Node) -> Result<Option<(usize, String, MathLigatureOptions)>>>;
14
15#[derive(Debug, Default, Clone, PartialEq, Eq)]
16pub struct MathLigatureOptions {
17  pub role:    Option<String>,
18  pub name:    Option<String>,
19  pub meaning: Option<String>,
20}
21
22impl MathLigatureOptions {
23  pub fn sorted_each(&self) -> [(&str, Option<&String>); 3] {
24    [
25      ("meaning", self.meaning.as_ref()),
26      ("name", self.name.as_ref()),
27      ("role", self.role.as_ref()),
28    ]
29  }
30}
31
32#[derive(Clone, Default)]
33pub struct Ligature {
34  pub id:        usize,
35  pub regex:     Option<String>,
36  pub code:      Option<LigatureClosure>,
37  pub font_test: Option<FontTestClosure>,
38  pub matcher:   Option<LigatureMatcher>,
39}
40
41impl fmt::Debug for Ligature {
42  fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "{:?}", self.regex) }
43}
44
45impl PartialEq for Ligature {
46  fn eq(&self, other: &Ligature) -> bool { self.id == other.id }
47}
48
49#[cfg(test)]
50mod tests {
51  use super::*;
52
53  #[test]
54  fn math_ligature_options_default_is_none_triple() {
55    let m = MathLigatureOptions::default();
56    assert!(m.role.is_none());
57    assert!(m.name.is_none());
58    assert!(m.meaning.is_none());
59  }
60
61  #[test]
62  fn math_ligature_options_equality() {
63    let a = MathLigatureOptions {
64      role:    Some("ADDOP".into()),
65      name:    None,
66      meaning: Some("plus".into()),
67    };
68    let b = MathLigatureOptions {
69      role:    Some("ADDOP".into()),
70      name:    None,
71      meaning: Some("plus".into()),
72    };
73    let c = MathLigatureOptions {
74      role:    Some("RELOP".into()), // differs
75      name:    None,
76      meaning: Some("plus".into()),
77    };
78    assert_eq!(a, b);
79    assert_ne!(a, c);
80  }
81
82  #[test]
83  fn math_ligature_sorted_each_fixed_order() {
84    // Perl parity: output always ordered (meaning, name, role).
85    let m = MathLigatureOptions {
86      role:    Some("r".into()),
87      name:    Some("n".into()),
88      meaning: Some("m".into()),
89    };
90    let ordered = m.sorted_each();
91    assert_eq!(ordered[0].0, "meaning");
92    assert_eq!(ordered[1].0, "name");
93    assert_eq!(ordered[2].0, "role");
94  }
95
96  #[test]
97  fn math_ligature_sorted_each_none_values_preserved() {
98    // sorted_each reports None when a field is None.
99    let m = MathLigatureOptions::default();
100    let ordered = m.sorted_each();
101    for (_, v) in ordered {
102      assert!(v.is_none());
103    }
104  }
105
106  #[test]
107  fn ligature_default_has_zero_id_and_none_fields() {
108    let l = Ligature::default();
109    assert_eq!(l.id, 0);
110    assert!(l.regex.is_none());
111    assert!(l.code.is_none());
112    assert!(l.font_test.is_none());
113    assert!(l.matcher.is_none());
114  }
115
116  #[test]
117  fn ligature_equality_by_id_only() {
118    let mut a = Ligature::default();
119    let mut b = Ligature::default();
120    a.id = 1;
121    b.id = 1;
122    // equal ids compare equal even when other fields (regex, code)
123    // differ — Perl parity.
124    assert_eq!(a, b);
125    b.id = 2;
126    assert_ne!(a, b);
127  }
128
129  #[test]
130  fn ligature_debug_format_uses_regex() {
131    // Debug writes just the regex (whatever formatting the Option
132    // picks). Verify it doesn't panic and uses the regex field.
133    let l = Ligature {
134      regex: Some("test_regex".to_string()),
135      ..Default::default()
136    };
137    let out = format!("{l:?}");
138    assert!(out.contains("test_regex"), "got {out:?}");
139  }
140}