Skip to main content

latexml_post/
unicode_math.rs

1//! Unicode math conversion processor.
2//!
3//! Port of `LaTeXML::Post::UnicodeMath` (435 lines of Perl).
4//! Converts XMath nodes to Unicode mathematical notation strings.
5//! Attempts compliance with UTN#28 (Unicode Technical Note on Plain Text Math).
6//!
7//! Used as:
8//! 1. A standalone text math format
9//! 2. A secondary format within MathML `m:annotation`
10//! 3. A utility for converting math to plain text (e.g. for title attributes)
11
12use libxml::tree::Node;
13
14use crate::{
15  document::{PostDocument, element_children},
16  math_processor::{MathConversion, MathProcessor},
17  processor::{ProcessResult, Processor},
18};
19
20const UNICODE_MATH_MIMETYPE: &str = "application/x-unicodemath";
21
22// Precedence levels (matching Perl)
23const PREC_RELOP: i32 = 1;
24const PREC_ADDOP: i32 = 2;
25const PREC_MULOP: i32 = 3;
26const PREC_SCRIPTOP: i32 = 4;
27const PREC_SYMBOL: i32 = 10;
28
29/// UnicodeMath post-processor.
30///
31/// Port of `LaTeXML::Post::UnicodeMath`.
32pub struct UnicodeMath {
33  name:         String,
34  is_secondary: bool,
35}
36
37impl Default for UnicodeMath {
38  fn default() -> Self { Self::new() }
39}
40
41impl UnicodeMath {
42  pub fn new() -> Self {
43    UnicodeMath {
44      name:         "UnicodeMath".to_string(),
45      is_secondary: false,
46    }
47  }
48}
49
50impl Processor for UnicodeMath {
51  fn get_name(&self) -> &str { &self.name }
52
53  fn to_process(&self, doc: &PostDocument) -> Vec<Node> {
54    doc.findnodes("//ltx:Math[not(ancestor::ltx:Math)]")
55  }
56
57  fn process(&mut self, doc: PostDocument, _nodes: Vec<Node>) -> ProcessResult { Ok(vec![doc]) }
58}
59
60impl MathProcessor for UnicodeMath {
61  fn convert_node(&self, doc: &PostDocument, xmath: &Node) -> Option<MathConversion> {
62    let math = xmath.get_parent()?;
63    let (string, _prec) = unimath_internal(doc, &math);
64    Some(MathConversion {
65      processor_name: self.name.clone(),
66      mimetype:       Some(UNICODE_MATH_MIMETYPE.to_string()),
67      xml:            None,
68      string:         Some(string),
69      src:            None,
70      width:          None,
71      height:         None,
72      depth:          None,
73    })
74  }
75
76  fn raw_id_suffix(&self) -> &str { ".muni" }
77
78  fn is_secondary(&self) -> bool { self.is_secondary }
79}
80
81/// Public entry point: convert a Math/XMath node to Unicode string.
82///
83/// Port of `unicodemath($doc, $node)`.
84pub fn unicodemath(doc: &PostDocument, node: &Node) -> String {
85  let (uni, _prec) = unimath_internal(doc, node);
86  uni
87}
88
89// ======================================================================
90// Precedence-based infix roles
91
92fn infix_prec(role: &str) -> Option<i32> {
93  match role {
94    "ADDOP" | "BINOP" => Some(PREC_ADDOP),
95    "MULOP" | "MIDDLE" | "COMPOSEOP" | "MODIFIEROP" => Some(PREC_MULOP),
96    "RELOP" | "METARELOP" | "ARROW" => Some(PREC_RELOP),
97    _ => None,
98  }
99}
100
101/// Get the operator role, following embellished operators.
102///
103/// Port of `getOperatorRole`.
104fn get_operator_role(doc: &PostDocument, node: &Node) -> Option<String> {
105  if let Some(role) = node.get_attribute("role") {
106    return Some(role);
107  }
108  if doc.is_qname(node, "ltx:XMApp") {
109    let children = element_children(node);
110    if children.len() >= 2 {
111      let op_role = children[0].get_attribute("role").unwrap_or_default();
112      if matches!(
113        op_role.as_str(),
114        "SUPERSCRIPTOP" | "SUBSCRIPTOP" | "OVERACCENT" | "UNDERACCENT" | "MODIFIER" | "MODIFIEROP"
115      ) {
116        return get_operator_role(doc, &children[1]);
117      }
118    }
119  }
120  None
121}
122
123/// Realize an XMRef node to its target.
124fn realize(doc: &PostDocument, node: &Node) -> Option<Node> { doc.realize_xm_node(node) }
125
126// ======================================================================
127// Core conversion
128
129/// Convert a node, returning (string, precedence).
130///
131/// Port of `unimath_internal`.
132fn unimath_internal(doc: &PostDocument, node: &Node) -> (String, i32) {
133  let tag = doc.get_qname(node).unwrap_or_default();
134  let role = node.get_attribute("role").unwrap_or_default();
135
136  match tag.as_str() {
137    "ltx:Math" | "ltx:XMath" => unimath_map(doc, &element_children(node)),
138    "ltx:XMDual" => {
139      let children = element_children(node);
140      if children.len() >= 2 {
141        unimath_internal(doc, &children[1]) // Presentation branch
142      } else {
143        unimath_error("Empty XMDual")
144      }
145    },
146    "ltx:XMWrap" | "ltx:XMArg" => unimath_map(doc, &element_children(node)),
147    "ltx:XMApp" => {
148      let children = element_children(node);
149      if children.is_empty() {
150        return unimath_error("Missing Operator");
151      }
152      let op = &children[0];
153      let args = &children[1..];
154
155      // Handle floating/post scripts
156      if role.contains("SUBSCRIPT") {
157        return unimath_sub(doc, None, op);
158      }
159      if role.contains("SUPERSCRIPT") {
160        return unimath_sup(doc, None, op);
161      }
162
163      // Realize operator and dispatch
164      let rop = realize(doc, op).unwrap_or_else(|| op.clone());
165      let op_role = get_operator_role(doc, &rop).unwrap_or_default();
166      let meaning = rop.get_attribute("meaning").unwrap_or_default();
167
168      // Dispatch by role/meaning
169      match (op_role.as_str(), meaning.as_str()) {
170        (_, "formulae") | (_, "multirelation") => unimath_map(doc, args),
171        (_, "limit-from") | (_, "annotated") => unimath_prefix(doc, op, args),
172        (_, "square-root") => {
173          if !args.is_empty() {
174            let inner = unimath_nested(doc, &args[0], PREC_MULOP);
175            (format!("\u{221A}{}", inner), PREC_MULOP)
176          } else {
177            ("\u{221A}".to_string(), PREC_MULOP)
178          }
179        },
180        (_, "nth-root") => {
181          if args.len() >= 2 {
182            // nth-root args are (degree, radicand) — same order as Perl.
183            let (n, _) = unimath_internal(doc, &args[0]);
184            let base = unimath_nested(doc, &args[1], PREC_MULOP);
185            let op_str = match n.as_str() {
186              "2" => "\u{221A}".to_string(),
187              "3" => "\u{221B}".to_string(),
188              "4" => "\u{221C}".to_string(),
189              _ => format!("\\root {}\\of", n),
190            };
191            (format!("{}{}", op_str, base), PREC_MULOP)
192          } else {
193            unimath_prefix(doc, op, args)
194          }
195        },
196        (_, "continued-fraction") => unimath_error("continued fraction"),
197        ("FRACOP", _) => {
198          if args.len() >= 2 {
199            let thickness = children[0].get_attribute("thickness");
200            if thickness.is_some() {
201              // Binomial-like
202              let num = unimath_nested(doc, &args[0], 0);
203              let den = unimath_nested(doc, &args[1], 0);
204              (format!("({}\u{00A6}{})", num, den), PREC_SYMBOL)
205            } else {
206              let num = unimath_nested(doc, &args[0], PREC_MULOP);
207              let den = unimath_nested(doc, &args[1], PREC_MULOP);
208              (format!("{}/{}", num, den), 1)
209            }
210          } else {
211            unimath_prefix(doc, op, args)
212          }
213        },
214        ("SUPERSCRIPTOP", _) => {
215          if args.len() >= 2 {
216            unimath_sup(doc, Some(&args[0]), &args[1])
217          } else {
218            unimath_prefix(doc, op, args)
219          }
220        },
221        ("SUBSCRIPTOP", _) => {
222          if args.len() >= 2 {
223            unimath_sub(doc, Some(&args[0]), &args[1])
224          } else {
225            unimath_prefix(doc, op, args)
226          }
227        },
228        ("OVERACCENT", _) => {
229          if !args.is_empty() {
230            unimath_overaccent(doc, op, &args[0])
231          } else {
232            unimath_prefix(doc, op, args)
233          }
234        },
235        ("UNDERACCENT", _) => {
236          if !args.is_empty() {
237            unimath_underaccent(doc, op, &args[0])
238          } else {
239            unimath_prefix(doc, op, args)
240          }
241        },
242        ("POSTFIX", _) => {
243          if !args.is_empty() {
244            let (op_str, _) = unimath_internal(doc, op);
245            let base = unimath_nested(doc, &args[0], PREC_MULOP);
246            (format!("{}{}", base, op_str), PREC_MULOP)
247          } else {
248            unimath_prefix(doc, op, args)
249          }
250        },
251        ("ENCLOSE", _) => {
252          if !args.is_empty() {
253            (unimath_nested(doc, &args[0], PREC_SYMBOL), PREC_SYMBOL)
254          } else {
255            (String::new(), PREC_SYMBOL)
256          }
257        },
258        _ => {
259          // Check if it's an infix role
260          if let Some(prec) = infix_prec(&op_role) {
261            unimath_infix_with_prec(doc, op, args, prec)
262          } else {
263            // Default: prefix
264            unimath_prefix(doc, op, args)
265          }
266        },
267      }
268    },
269    "ltx:XMTok" => {
270      let meaning = node.get_attribute("meaning").unwrap_or_default();
271      if meaning == "absent" {
272        return (String::new(), PREC_SYMBOL);
273      }
274      let text = stylize_content(node);
275      (text, PREC_SYMBOL)
276    },
277    "ltx:XMHint" => (String::new(), 0),
278    "ltx:XMArray" => {
279      let rows: Vec<String> = element_children(node)
280        .iter()
281        .map(|row| {
282          element_children(row)
283            .iter()
284            .map(|cell| unimath_nested(doc, cell, 0))
285            .collect::<Vec<_>>()
286            .join("&")
287        })
288        .collect();
289      (format!("\u{25A0}({})", rows.join("@")), 0)
290    },
291    "ltx:XMText" => unimath_text(node),
292    "ltx:XMRef" => match realize(doc, node) {
293      Some(target) => unimath_internal(doc, &target),
294      _ => unimath_error("Unresolved XMRef"),
295    },
296    "ltx:ERROR" => unimath_error(&node.get_content()),
297    _ => unimath_text(node),
298  }
299}
300
301/// Convert and wrap in braces if precedence is too low.
302///
303/// Port of `unimath_nested`.
304fn unimath_nested(doc: &PostDocument, node: &Node, prec: i32) -> String {
305  let (string, iprec) = unimath_internal(doc, node);
306  if iprec >= prec {
307    string
308  } else {
309    format!("{{{}}}", string)
310  }
311}
312
313/// Combine conversion of multiple nodes.
314///
315/// Port of `unimath_map`.
316fn unimath_map(doc: &PostDocument, args: &[Node]) -> (String, i32) {
317  let mut oprec = 0;
318  // If wrapped in OPEN...CLOSE, it's a symbol-level expression
319  if args.len() > 1 {
320    let first_role = args
321      .first()
322      .and_then(|n| n.get_attribute("role"))
323      .unwrap_or_default();
324    let last_role = args
325      .last()
326      .and_then(|n| n.get_attribute("role"))
327      .unwrap_or_default();
328    if first_role == "OPEN" && last_role == "CLOSE" {
329      oprec = PREC_SYMBOL;
330    }
331  }
332  let result: String = args.iter().map(|a| unimath_nested(doc, a, 0)).collect();
333  (result, oprec)
334}
335
336/// Prefix application: op arg1 arg2 ...
337///
338/// Port of `unimath_prefix`.
339fn unimath_prefix(doc: &PostDocument, op: &Node, args: &[Node]) -> (String, i32) {
340  if args.is_empty() {
341    return (String::new(), PREC_SYMBOL);
342  }
343  let op_str = unimath_nested(doc, op, 0);
344  let args_str: String = args
345    .iter()
346    .map(|a| unimath_nested(doc, a, PREC_SYMBOL))
347    .collect();
348  (format!("{}{}", op_str, args_str), PREC_SYMBOL)
349}
350
351/// Infix application with explicit precedence.
352///
353/// Port of `unimath_infix`.
354fn unimath_infix_with_prec(
355  doc: &PostDocument,
356  op: &Node,
357  args: &[Node],
358  prec: i32,
359) -> (String, i32) {
360  if args.is_empty() {
361    return (String::new(), PREC_SYMBOL);
362  }
363  let opuni = unimath_nested(doc, op, prec);
364  if args.len() == 1 {
365    // Single arg = prefix
366    let arg = unimath_nested(doc, &args[0], prec);
367    (format!("{}{}", opuni, arg), prec)
368  } else {
369    let mut items = vec![unimath_nested(doc, &args[0], prec)];
370    for arg in &args[1..] {
371      items.push(opuni.clone());
372      items.push(unimath_nested(doc, arg, prec));
373    }
374    (items.join(""), prec)
375  }
376}
377
378/// Subscript: base_script or _{script}base (pre).
379///
380/// Port of `unimath_sub`.
381fn unimath_sub(doc: &PostDocument, base: Option<&Node>, script: &Node) -> (String, i32) {
382  let ubase = base
383    .map(|b| unimath_nested(doc, b, PREC_SCRIPTOP))
384    .unwrap_or_default();
385  let (uscript, prec) = unimath_internal(doc, script);
386  let uscript = if prec < PREC_SCRIPTOP {
387    format!("{{{}}}", uscript)
388  } else {
389    uscript
390  };
391  (format!("{}_{}", ubase, uscript), PREC_SCRIPTOP)
392}
393
394/// Superscript: base^script or ^{script}base (pre).
395///
396/// Port of `unimath_sup`.
397fn unimath_sup(doc: &PostDocument, base: Option<&Node>, script: &Node) -> (String, i32) {
398  let ubase = base
399    .map(|b| unimath_nested(doc, b, PREC_SCRIPTOP))
400    .unwrap_or_default();
401  let (uscript, prec) = unimath_internal(doc, script);
402  let uscript = if prec < PREC_SCRIPTOP {
403    format!("{{{}}}", uscript)
404  } else {
405    uscript
406  };
407  (format!("{}^{}", ubase, uscript), PREC_SCRIPTOP)
408}
409
410/// Over-accent: combining character above.
411///
412/// Port of `unimath_overaccent`.
413fn unimath_overaccent(doc: &PostDocument, op: &Node, base: &Node) -> (String, i32) {
414  let acc = op.get_content();
415  let combining = overaccent_combining(&acc);
416  let (mut ubase, _) = unimath_internal(doc, base);
417  if ubase.chars().count() > 1 {
418    ubase = format!("({})", ubase);
419  }
420  let accent_str = combining.unwrap_or_else(|| format!("\u{252C}{}", acc));
421  (format!("{}{}", ubase, accent_str), PREC_SCRIPTOP)
422}
423
424/// Under-accent: combining character below.
425///
426/// Port of `unimath_underaccent`.
427fn unimath_underaccent(doc: &PostDocument, op: &Node, base: &Node) -> (String, i32) {
428  let acc = op.get_content();
429  let combining = underaccent_combining(&acc);
430  let (mut ubase, _) = unimath_internal(doc, base);
431  if ubase.chars().count() > 1 {
432    ubase = format!("({})", ubase);
433  }
434  let accent_str = combining.unwrap_or_else(|| format!("\u{252C}{}", acc));
435  (format!("{}{}", ubase, accent_str), PREC_SCRIPTOP)
436}
437
438/// Convert an XMTok's content to styled Unicode text.
439///
440/// Port of `stylizeContent` (simplified — full version needs unicode_convert).
441fn stylize_content(node: &Node) -> String {
442  let role = node
443    .get_attribute("role")
444    .unwrap_or_else(|| "ID".to_string());
445  let text = node.get_content();
446  if text.is_empty() {
447    // Fallback for empty tokens
448    static DEFAULT_CONTENT: &[(&str, &str)] = &[
449      ("MULOP", "\u{2062}"), // INVISIBLE TIMES
450      ("ADDOP", "\u{2064}"), // INVISIBLE PLUS
451      ("PUNCT", "\u{2063}"), // INVISIBLE SEPARATOR
452    ];
453    for (r, default) in DEFAULT_CONTENT {
454      if role == *r {
455        return default.to_string();
456      }
457    }
458    node
459      .get_attribute("name")
460      .or_else(|| node.get_attribute("meaning"))
461      .unwrap_or(role)
462  } else {
463    text
464  }
465}
466
467/// Text content in quotes.
468///
469/// Port of `unimath_text`.
470fn unimath_text(node: &Node) -> (String, i32) {
471  let text = node.get_content();
472  (format!("\"{}\"", text), PREC_SYMBOL)
473}
474
475/// Error representation.
476fn unimath_error(msg: &str) -> (String, i32) { (format!("\"ERROR {}\"", msg), PREC_SYMBOL) }
477
478/// Map over-accent character to combining equivalent.
479fn overaccent_combining(acc: &str) -> Option<String> {
480  let c = acc.chars().next()?;
481  let combining = match c {
482    '^' => '\u{0302}',        // hat
483    '\u{02C7}' => '\u{030C}', // check
484    '~' => '\u{0303}',        // tilde
485    '\u{0084}' => '\u{0301}', // acute
486    '\u{0060}' => '\u{0300}', // grave
487    '\u{02D9}' => '\u{0307}', // dot
488    '\u{00AB}' => '\u{0308}', // ddot
489    '\u{00AF}' => '\u{0304}', // bar/overline
490    '\u{2192}' => '\u{20D7}', // vec
491    '\u{02D8}' => '\u{0306}', // breve
492    'o' => '\u{030A}',        // ring
493    '\u{02DD}' => '\u{030B}', // double acute
494    _ => return None,
495  };
496  Some(combining.to_string())
497}
498
499/// Map under-accent character to combining equivalent.
500fn underaccent_combining(acc: &str) -> Option<String> {
501  let c = acc.chars().next()?;
502  let combining = match c {
503    '\u{00B8}' => '\u{0327}', // cedilla
504    '.' => '\u{0323}',        // dot below
505    '\u{00AF}' => '\u{0331}', // macron below
506    '=' | ',' => '\u{0361}',  // tie / lfhook
507    _ => return None,
508  };
509  Some(combining.to_string())
510}
511
512#[cfg(test)]
513mod tests {
514  use super::*;
515
516  #[test]
517  fn test_stylize_empty_token() {
518    // MULOP with no text should produce invisible times
519    let result = stylize_content_for_role("MULOP", "");
520    assert_eq!(result, "\u{2062}");
521  }
522
523  #[test]
524  fn test_overaccent_combining() {
525    assert_eq!(overaccent_combining("^"), Some("\u{0302}".to_string()));
526    assert_eq!(overaccent_combining("~"), Some("\u{0303}".to_string()));
527    assert_eq!(overaccent_combining("x"), None);
528  }
529
530  /// Helper for testing stylize_content without a Node.
531  fn stylize_content_for_role(role: &str, text: &str) -> String {
532    static DEFAULT_CONTENT: &[(&str, &str)] = &[
533      ("MULOP", "\u{2062}"),
534      ("ADDOP", "\u{2064}"),
535      ("PUNCT", "\u{2063}"),
536    ];
537    if text.is_empty() {
538      for (r, default) in DEFAULT_CONTENT {
539        if role == *r {
540          return default.to_string();
541        }
542      }
543    }
544    text.to_string()
545  }
546}