1use std::cell::Cell;
14
15use libxml::tree::Node;
16use rustc_hash::FxHashMap as HashMap;
17
18use super::operator_dictionary;
19use crate::document::{NodeData, PostDocument, XMBranch, element_children, element_children_iter};
20
21thread_local! {
24 static INVISIBLE_TIMES: Cell<bool> = const { Cell::new(true) };
25 static PLANE1: Cell<bool> = const { Cell::new(true) };
32 static HACK_PLANE1: Cell<bool> = const { Cell::new(false) };
39 static CTX_FONT: std::cell::RefCell<Option<String>> = const { std::cell::RefCell::new(None) };
48 static CTX_COLOR: std::cell::RefCell<Option<String>> = const { std::cell::RefCell::new(None) };
49 static CTX_BGCOLOR: std::cell::RefCell<Option<String>> = const { std::cell::RefCell::new(None) };
50 static CTX_OPACITY: std::cell::RefCell<Option<String>> = const { std::cell::RefCell::new(None) };
51 static CURRENT_STYLE: Cell<MathStyle> = const { Cell::new(MathStyle::Display) };
59}
60
61pub fn set_invisible_times(emit: bool) { INVISIBLE_TIMES.with(|f| f.set(emit)); }
63
64fn get_invisible_times() -> bool { INVISIBLE_TIMES.with(|f| f.get()) }
65
66pub fn set_plane1(plane1: bool, hack_plane1: bool) {
69 PLANE1.with(|f| f.set(plane1 || hack_plane1));
70 HACK_PLANE1.with(|f| f.set(hack_plane1));
71}
72
73fn plane1_hackable(variant: &str) -> Option<&'static str> {
78 match variant {
79 "script" | "bold-script" => Some("script"),
80 "fraktur" | "bold-fraktur" => Some("fraktur"),
81 "double-struck" => Some("double-struck"),
82 _ => None,
83 }
84}
85
86fn plane1_target_variant(variant: &str) -> Option<&str> {
95 if HACK_PLANE1.with(|f| f.get()) {
96 plane1_hackable(variant)
97 } else if PLANE1.with(|f| f.get()) {
98 Some(variant)
99 } else {
100 None
101 }
102}
103
104fn current_context_size() -> &'static str { CURRENT_STYLE.with(|s| s.get().size_percent()) }
107
108#[derive(Debug, Clone, Copy, PartialEq)]
110pub enum MathStyle {
111 Display,
112 Text,
113 Script,
114 ScriptScript,
115}
116
117impl MathStyle {
118 pub fn step_down(self) -> Self {
120 match self {
121 MathStyle::Display => MathStyle::Text,
122 MathStyle::Text => MathStyle::Script,
123 MathStyle::Script => MathStyle::ScriptScript,
124 MathStyle::ScriptScript => MathStyle::ScriptScript,
125 }
126 }
127
128 pub fn script_step(self) -> Self {
130 match self {
131 MathStyle::Display | MathStyle::Text => MathStyle::Script,
132 MathStyle::Script => MathStyle::ScriptScript,
133 MathStyle::ScriptScript => MathStyle::ScriptScript,
134 }
135 }
136
137 pub fn size_percent(self) -> &'static str {
139 match self {
140 MathStyle::Display | MathStyle::Text => "100%",
141 MathStyle::Script => "70%",
142 MathStyle::ScriptScript => "50%",
143 }
144 }
145
146 pub fn from_attr(s: &str) -> Option<Self> {
149 match s {
150 "display" => Some(MathStyle::Display),
151 "text" => Some(MathStyle::Text),
152 "script" => Some(MathStyle::Script),
153 "scriptscript" => Some(MathStyle::ScriptScript),
154 _ => None,
155 }
156 }
157}
158
159fn stylemap_attrs(
165 ostyle: MathStyle,
166 nstyle: MathStyle,
167 needs: bool,
168) -> &'static [(&'static str, &'static str)] {
169 use MathStyle::*;
170 match (ostyle, nstyle, needs) {
171 (Display, Text, true) => &[("displaystyle", "false")],
172 (Display, Script, true) => &[("displaystyle", "false"), ("scriptlevel", "+1")],
173 (Display, ScriptScript, true) => &[("displaystyle", "false"), ("scriptlevel", "+2")],
174 (Text, Display, true) => &[("displaystyle", "true")],
175 (Display, Script, false) | (Text, Script, _) => &[("scriptlevel", "+1")],
176 (Display, ScriptScript, false) | (Text, ScriptScript, _) => &[("scriptlevel", "+2")],
177 (Script, Display, _) => &[("displaystyle", "true"), ("scriptlevel", "-1")],
178 (Script, Text, _) => &[("scriptlevel", "-1")],
179 (Script, ScriptScript, _) => &[("scriptlevel", "+1")],
180 (ScriptScript, Display, _) => &[("displaystyle", "true"), ("scriptlevel", "-2")],
181 (ScriptScript, Text, _) => &[("scriptlevel", "-2")],
182 (ScriptScript, Script, _) => &[("scriptlevel", "-1")],
183 _ => &[],
184 }
185}
186
187fn needs_mathstyle(node: &NodeData) -> bool {
192 if let NodeData::Element { tag, attributes, children } = node {
193 if tag == "m:mfrac" {
194 return true;
195 }
196 if let Some(attrs) = attributes {
197 if attrs.contains_key("_largeop") {
198 return true;
199 }
200 if tag == "m:mstyle" && attrs.contains_key("displaystyle") {
201 return false;
202 }
203 }
204 return children.iter().any(needs_mathstyle);
205 }
206 false
207}
208
209fn maybe_style_wrap(result: NodeData, ostyle: MathStyle, nstyle: Option<MathStyle>) -> NodeData {
213 let Some(nstyle) = nstyle else { return result };
214 let style_attrs = stylemap_attrs(ostyle, nstyle, needs_mathstyle(&result));
215 if style_attrs.is_empty() {
216 return result;
217 }
218 NodeData::Element {
219 tag: "m:mstyle".to_string(),
220 attributes: Some(HashMap::from_iter(
221 style_attrs
222 .iter()
223 .map(|(k, v)| (k.to_string(), v.to_string())),
224 )),
225 children: vec![result],
226 }
227}
228
229fn pmml_maybe_resize(doc: &PostDocument, node: &Node, result: NodeData) -> NodeData {
237 let parent = node.get_parent().filter(|p| doc.is_qname(p, "ltx:XMDual"));
239 let getattr = |name: &str| {
240 node
241 .get_attribute(name)
242 .or_else(|| parent.as_ref().and_then(|p| p.get_attribute(name)))
243 };
244 let width = getattr("width");
245 let height = getattr("height");
246 let depth = getattr("depth");
247 let xoff = getattr("xoffset");
248 let yoff = getattr("yoffset");
249 let role = getattr("role");
250 let class = getattr("class");
251
252 let mut result = result;
253 if let Some(ref w) = width
254 && role.as_deref() == Some("ARROW")
255 && class.as_deref().is_some_and(|c| {
256 c.split_ascii_whitespace()
257 .any(|w| w == "ltx_horizontally_stretchy")
258 })
259 {
260 result = NodeData::Element {
263 tag: "m:mover".to_string(),
264 attributes: None,
265 children: vec![result, NodeData::Element {
266 tag: "m:mspace".to_string(),
267 attributes: Some(HashMap::from_iter([("width".to_string(), w.clone())])),
268 children: vec![],
269 }],
270 };
271 } else if width.is_some()
272 || height.is_some()
273 || depth.is_some()
274 || xoff.is_some()
275 || yoff.is_some()
276 {
277 let needs_wrap = !matches!(&result,
279 NodeData::Element { tag, .. } if tag == "m:mpadded" || tag == "m:mrow");
280 if needs_wrap {
281 result = NodeData::Element {
282 tag: "m:mpadded".to_string(),
283 attributes: None,
284 children: vec![result],
285 };
286 }
287 if let NodeData::Element { tag, attributes, .. } = &mut result {
288 if tag == "m:mrow" {
289 *tag = "m:mpadded".to_string();
290 }
291 let attrs = attributes.get_or_insert_with(Default::default);
292 for (key, val) in [
293 ("width", width),
294 ("height", height),
295 ("depth", depth),
296 ("lspace", xoff),
297 ("voffset", yoff),
298 ] {
299 if let Some(v) = val {
300 attrs.insert(key.to_string(), v);
301 }
302 }
303 }
304 }
305
306 if let Some(frame) = node.get_attribute("framed")
308 && let NodeData::Element { attributes, .. } = &mut result
309 {
310 let attrs = attributes.get_or_insert_with(Default::default);
311 let frame_class = format!("ltx_framed_{frame}");
312 let merged = match attrs.get("class") {
313 Some(c) if !c.is_empty() => format!("{c} {frame_class}"),
314 _ => frame_class,
315 };
316 attrs.insert("class".to_string(), merged);
317 if let Some(color) = node.get_attribute("framecolor") {
318 let style = format!("border-color: {color}");
319 let merged = match attrs.get("style") {
320 Some(s) if !s.is_empty() => format!("{s}; {style}"),
321 _ => style,
322 };
323 attrs.insert("style".to_string(), merged);
324 }
325 }
326 result
327}
328
329fn ctx_get(
330 cell: &'static std::thread::LocalKey<std::cell::RefCell<Option<String>>>,
331) -> Option<String> {
332 cell.with(|c| c.borrow().clone())
333}
334
335fn ctx_rebind(
338 cell: &'static std::thread::LocalKey<std::cell::RefCell<Option<String>>>,
339 attr: Option<String>,
340) -> Option<String> {
341 let attr = attr.filter(|v| !v.is_empty());
343 cell.with(|c| {
344 let old = c.borrow().clone();
345 if attr.is_some() {
346 *c.borrow_mut() = attr;
347 }
348 old
349 })
350}
351
352fn ctx_set(
353 cell: &'static std::thread::LocalKey<std::cell::RefCell<Option<String>>>,
354 val: Option<String>,
355) {
356 cell.with(|c| *c.borrow_mut() = val);
357}
358
359fn is_embellishing_role(role: &str) -> bool {
361 matches!(
362 role,
363 "SUPERSCRIPTOP" | "SUBSCRIPTOP" | "OVERACCENT" | "UNDERACCENT" | "MODIFIER" | "MODIFIEROP"
364 )
365}
366
367pub(super) fn default_token_content(role: &str) -> Option<&'static str> {
372 match role {
373 "MULOP" => Some("\u{2062}"), "ADDOP" => Some("\u{2064}"), "PUNCT" => Some("\u{2063}"), _ => None,
377 }
378}
379
380fn get_operator_role(doc: &PostDocument, node: &Node) -> Option<String> {
382 if let Some(role) = node.get_attribute("role") {
383 return Some(role);
384 }
385 if doc.is_qname(node, "ltx:XMApp") {
386 let children = element_children(node);
387 if children.len() >= 2 {
388 let op_role = children[0].get_attribute("role").unwrap_or_default();
389 if is_embellishing_role(&op_role) {
390 return get_operator_role(doc, &children[1]);
391 }
392 }
393 }
394 None
395}
396
397pub fn convert_to_pmml(doc: &PostDocument, xmath: &Node) -> NodeData {
402 let saved_style = CURRENT_STYLE.with(|s| s.get());
409 let saved_font = CTX_FONT.with(|c| c.borrow().clone());
410 let saved_color = CTX_COLOR.with(|c| c.borrow().clone());
411 let saved_bgcolor = CTX_BGCOLOR.with(|c| c.borrow().clone());
412 let saved_opacity = CTX_OPACITY.with(|c| c.borrow().clone());
413
414 let mode_is_display = xmath
419 .get_parent()
420 .and_then(|p| p.get_attribute("mode"))
421 .is_some_and(|m| m == "display");
422 CURRENT_STYLE.with(|s| {
423 s.set(if mode_is_display {
424 MathStyle::Display
425 } else {
426 MathStyle::Text
427 })
428 });
429 ctx_set(
433 &CTX_FONT,
434 super::find_inherited_attribute(doc, xmath, "font"),
435 );
436 ctx_set(
437 &CTX_COLOR,
438 super::find_inherited_attribute(doc, xmath, "color"),
439 );
440 ctx_set(
441 &CTX_BGCOLOR,
442 super::find_inherited_attribute(doc, xmath, "backgroundcolor"),
443 );
444 ctx_set(
445 &CTX_OPACITY,
446 super::find_inherited_attribute(doc, xmath, "opacity"),
447 );
448 let children = element_children(xmath);
449 let results: Vec<NodeData> = children.iter().map(|c| pmml(doc, c)).collect();
450 let mut result = if results.len() == 1 {
451 results.into_iter().next().unwrap()
452 } else {
453 pmml_row(results)
454 };
455 adjust_spacing(&mut result);
457 clean_internal_attrs(&mut result);
459
460 CURRENT_STYLE.with(|s| s.set(saved_style));
462 ctx_set(&CTX_FONT, saved_font);
463 ctx_set(&CTX_COLOR, saved_color);
464 ctx_set(&CTX_BGCOLOR, saved_bgcolor);
465 ctx_set(&CTX_OPACITY, saved_opacity);
466 result
467}
468
469pub(super) fn pmml_for_ci(doc: &PostDocument, node: &Node) -> NodeData { pmml(doc, node) }
472
473pub(super) fn bind_cmml_top_context(doc: &PostDocument, xmath: &Node) {
482 CURRENT_STYLE.with(|s| s.set(MathStyle::Text));
483 ctx_set(
484 &CTX_FONT,
485 super::find_inherited_attribute(doc, xmath, "font"),
486 );
487 ctx_set(
488 &CTX_COLOR,
489 super::find_inherited_attribute(doc, xmath, "color"),
490 );
491 ctx_set(
492 &CTX_BGCOLOR,
493 super::find_inherited_attribute(doc, xmath, "backgroundcolor"),
494 );
495 ctx_set(
496 &CTX_OPACITY,
497 super::find_inherited_attribute(doc, xmath, "opacity"),
498 );
499}
500
501pub(super) fn ctx_font() -> Option<String> { ctx_get(&CTX_FONT) }
504
505pub(super) fn ctx_color() -> Option<String> { ctx_get(&CTX_COLOR) }
507
508pub(super) fn ctx_bgcolor() -> Option<String> { ctx_get(&CTX_BGCOLOR) }
510
511pub(super) fn ctx_opacity() -> Option<String> { ctx_get(&CTX_OPACITY) }
513
514pub(super) fn context_size() -> &'static str { current_context_size() }
517
518pub(super) fn resolve_size(s: String) -> String { resolve_token_size(s) }
520
521pub(super) fn maybe_resize(doc: &PostDocument, node: &Node, result: NodeData) -> NodeData {
523 pmml_maybe_resize(doc, node, result)
524}
525
526fn pmml(doc: &PostDocument, node: &Node) -> NodeData {
530 let saved_color = ctx_rebind(&CTX_COLOR, node.get_attribute("color"));
533 let saved_bg = ctx_rebind(&CTX_BGCOLOR, node.get_attribute("backgroundcolor"));
534 let saved_op = ctx_rebind(&CTX_OPACITY, node.get_attribute("opacity"));
535 let mut result = pmml_inner(doc, node);
536 ctx_set(&CTX_COLOR, saved_color);
537 ctx_set(&CTX_BGCOLOR, saved_bg);
538 ctx_set(&CTX_OPACITY, saved_op);
539 if let Some(enclose) = node.get_attribute("enclose").filter(|e| !e.is_empty()) {
542 let mut attrs = HashMap::default();
543 attrs.insert("notation".to_string(), enclose);
544 result = NodeData::Element {
545 tag: "m:menclose".to_string(),
546 attributes: Some(attrs),
547 children: vec![result],
548 };
549 }
550 if doc.is_qname(node, "ltx:XMRef") {
564 add_source_padding(node, &mut result);
567 } else {
568 attach_source_padding(node, &mut result);
571 }
572 if let NodeData::Element { ref mut attributes, .. } = result {
573 if let Some(cl) = node.get_attribute("class")
575 && !cl.is_empty()
576 {
577 let attrs = attributes.get_or_insert_with(Default::default);
578 match attrs.get("class") {
579 Some(ocl) if !ocl.is_empty() && *ocl != cl => {
580 let merged = format!("{ocl} {cl}");
581 attrs.insert("class".to_string(), merged);
582 },
583 _ => {
584 attrs.insert("class".to_string(), cl);
585 },
586 }
587 }
588 if let Some(role) = node.get_attribute("role") {
591 let attrs = attributes.get_or_insert_with(Default::default);
592 attrs.insert("_role".to_string(), role);
593 }
594 }
595 result
596}
597
598fn attach_source_padding(node: &Node, result: &mut NodeData) {
601 for (src, dst) in [("lpadding", "_lpadding"), ("rpadding", "_rpadding")] {
607 if let Some(v) = node.get_attribute(src) {
608 let em = super::get_xm_hint_spacing(&v);
609 if em != 0.0
610 && let NodeData::Element { ref mut attributes, .. } = *result
611 {
612 let attrs = attributes.get_or_insert_with(Default::default);
613 attrs.insert(dst.to_string(), fmt_em(em));
614 }
615 }
616 }
617}
618
619fn add_source_padding(node: &Node, result: &mut NodeData) {
622 for (src, dst) in [("lpadding", "_lpadding"), ("rpadding", "_rpadding")] {
623 if let Some(v) = node.get_attribute(src) {
624 let em = super::get_xm_hint_spacing(&v);
625 if em != 0.0
626 && let NodeData::Element { ref mut attributes, .. } = *result
627 {
628 let attrs = attributes.get_or_insert_with(Default::default);
629 let prior = attrs
630 .get(dst)
631 .and_then(|s| s.trim_end_matches("em").parse::<f64>().ok())
632 .unwrap_or(0.0);
633 attrs.insert(dst.to_string(), fmt_em(prior + em));
634 }
635 }
636 }
637}
638
639fn pmml_inner(doc: &PostDocument, node: &Node) -> NodeData {
640 let is_ltx = doc.qname_prefix(node).as_deref() == Some("ltx");
646 let localname = if is_ltx {
647 node.get_name()
648 } else {
649 String::new()
650 };
651
652 if is_ltx && localname == "XMRef" {
654 if let Some(idref) = node.get_attribute("idref") {
655 if let Some(target) = doc.find_node_by_id(&idref) {
656 return pmml(doc, target);
657 }
658 }
659 return pmml_error("Unresolved XMRef");
660 }
661
662 if is_ltx {
663 match localname.as_str() {
664 "XMath" => {
665 let results: Vec<NodeData> = element_children_iter(node).map(|c| pmml(doc, &c)).collect();
666 return pmml_row(results);
667 },
668 "XMDual" => {
669 let children = element_children(node);
670 return if children.len() >= 2 {
671 pmml(doc, &children[1]) } else {
673 pmml_error("Empty XMDual")
674 };
675 },
676 "XMWrap" | "XMArg" => {
677 let results: Vec<NodeData> = element_children_iter(node).map(|c| pmml(doc, &c)).collect();
679 return pmml_maybe_resize(doc, node, pmml_row(results));
680 },
681 "XMApp" => return pmml_apply(doc, node),
682 "XMTok" => return pmml_token(doc, node),
683 "XMHint" => return pmml_hint(doc, node),
684 "XMArray" => return pmml_array(doc, node),
685 "XMText" => {
686 let mut children = Vec::new();
689 if let Some(child) = node.get_first_child() {
690 let mut current = Some(child);
691 while let Some(ref c) = current {
692 children.extend(super::pmml_text_aux(doc, c, &super::TextAttrs::default()));
693 current = c.get_next_sibling();
694 }
695 }
696 return pmml_maybe_resize(doc, node, pmml_row(children));
697 },
698 _ => {},
699 }
700 }
701
702 NodeData::Element {
704 tag: "m:mtext".to_string(),
705 attributes: None,
706 children: vec![NodeData::Text(node.get_content())],
707 }
708}
709
710fn pmml_apply(doc: &PostDocument, node: &Node) -> NodeData {
714 let children = element_children(node);
715 if children.is_empty() {
716 return pmml_error("Missing Operator");
717 }
718
719 let role = node.get_attribute("role").unwrap_or_default();
720
721 if role.contains("SUBSCRIPT") || role.contains("SUPERSCRIPT") {
730 let is_sub = role.contains("SUB");
731 let tag = if is_sub { "m:msub" } else { "m:msup" };
732 return NodeData::Element {
733 tag: tag.to_string(),
734 attributes: None,
735 children: vec![
736 NodeData::Element {
737 tag: "m:mrow".to_string(),
738 attributes: None,
739 children: vec![],
740 },
741 pmml_scriptsize(doc, &children[0]),
742 ],
743 };
744 }
745
746 let op = &children[0];
747 let args = &children[1..];
748
749 let rop = if doc.is_qname(op, "ltx:XMRef") {
751 op.get_attribute("idref")
752 .and_then(|id| doc.find_node_by_id(&id).cloned())
753 .unwrap_or_else(|| op.clone())
754 } else {
755 op.clone()
756 };
757
758 let op_role = get_operator_role(doc, &rop).unwrap_or_default();
759 let meaning = rop.get_attribute("meaning").unwrap_or_default();
760
761 let style_attr = rop
766 .get_attribute("mathstyle")
767 .or_else(|| op.get_attribute("mathstyle"));
768 let ostyle = CURRENT_STYLE.with(|s| s.get());
769 let nstyle = style_attr.as_deref().and_then(MathStyle::from_attr);
770 if let Some(n) = nstyle {
771 CURRENT_STYLE.with(|s| s.set(n));
772 }
773 let result = pmml_apply_dispatch(doc, op, &rop, args, &op_role, &meaning);
774 CURRENT_STYLE.with(|s| s.set(ostyle));
775 let result = pmml_maybe_resize(doc, node, result);
777 maybe_style_wrap(result, ostyle, nstyle)
778}
779
780fn pmml_apply_dispatch(
783 doc: &PostDocument,
784 op: &Node,
785 rop: &Node,
786 args: &[Node],
787 op_role: &str,
788 meaning: &str,
789) -> NodeData {
790 match op_role {
792 "SUPERSCRIPTOP" | "SUBSCRIPTOP" if args.len() >= 2 => {
793 pmml_script_full(doc, op, &args[0], &args[1])
794 },
795 "FRACOP" if args.len() >= 2 => {
796 let mut attrs = HashMap::default();
802 if let Some(t) = rop.get_attribute("thickness") {
803 attrs.insert("linethickness".to_string(), t);
804 }
805 if let Some(c) = rop
806 .get_attribute("color")
807 .filter(|c| !c.is_empty())
808 .or_else(|| ctx_get(&CTX_COLOR))
809 {
810 attrs.insert("mathcolor".to_string(), c);
811 }
812 if let Some(cl) = rop.get_attribute("class")
813 && cl.split_ascii_whitespace().any(|c| c == "ltx_bevelled")
814 {
815 attrs.insert("bevelled".to_string(), "true".to_string());
816 }
817 NodeData::Element {
818 tag: "m:mfrac".to_string(),
819 attributes: if attrs.is_empty() { None } else { Some(attrs) },
820 children: vec![pmml_smaller(doc, &args[0]), pmml_smaller(doc, &args[1])],
821 }
822 },
823 "OVERACCENT" if !args.is_empty() => {
824 let base = &args[0];
826 let base_children = element_children(base);
827 if doc.is_qname(base, "ltx:XMApp") && base_children.len() == 2 {
828 let inner_role = base_children[0].get_attribute("role").unwrap_or_default();
829 if inner_role == "UNDERACCENT" {
830 return NodeData::Element {
832 tag: "m:munderover".to_string(),
833 attributes: Some(HashMap::from_iter([
834 ("accent".to_string(), "true".to_string()),
835 ("accentunder".to_string(), "true".to_string()),
836 ])),
837 children: vec![
838 pmml(doc, &base_children[1]), pmml(doc, &base_children[0]), pmml(doc, op), ],
842 };
843 }
844 }
845 NodeData::Element {
846 tag: "m:mover".to_string(),
847 attributes: Some(HashMap::from_iter([(
848 "accent".to_string(),
849 "true".to_string(),
850 )])),
851 children: vec![pmml(doc, base), pmml(doc, op)],
852 }
853 },
854 "UNDERACCENT" if !args.is_empty() => {
855 let base = &args[0];
857 let base_children = element_children(base);
858 if doc.is_qname(base, "ltx:XMApp") && base_children.len() == 2 {
859 let inner_role = base_children[0].get_attribute("role").unwrap_or_default();
860 if inner_role == "OVERACCENT" {
861 return NodeData::Element {
862 tag: "m:munderover".to_string(),
863 attributes: Some(HashMap::from_iter([
864 ("accent".to_string(), "true".to_string()),
865 ("accentunder".to_string(), "true".to_string()),
866 ])),
867 children: vec![
868 pmml(doc, &base_children[1]), pmml(doc, op), pmml(doc, &base_children[0]), ],
872 };
873 }
874 }
875 NodeData::Element {
876 tag: "m:munder".to_string(),
877 attributes: Some(HashMap::from_iter([(
878 "accentunder".to_string(),
879 "true".to_string(),
880 )])),
881 children: vec![pmml(doc, base), pmml(doc, op)],
882 }
883 },
884 "POSTFIX" if !args.is_empty() => {
885 let mut items: Vec<NodeData> = args.iter().map(|a| pmml(doc, a)).collect();
886 items.push(pmml(doc, op));
887 pmml_row(items)
888 },
889 "ADDOP" | "RELOP" | "MULOP" | "BINOP" | "ARROW" | "METARELOP" | "COMPOSEOP" | "MODIFIEROP"
890 | "MIDDLE" => {
891 pmml_infix(doc, op, args)
893 },
894 "SUMOP" | "INTOP" | "BIGOP" | "LIMITOP" => {
895 pmml_summation(doc, op, args)
901 },
902 "OPEN" | "CLOSE" if !args.is_empty() => {
903 pmml_parenthesize(doc, op, args)
905 },
906 "ENCLOSE" if !args.is_empty() => {
907 let mut attrs = HashMap::default();
913 if let Some(notation) = rop.get_attribute("enclose").filter(|n| !n.is_empty()) {
914 attrs.insert("notation".to_string(), notation);
915 }
916 let color = rop
917 .get_attribute("color")
918 .filter(|c| !c.is_empty())
919 .or_else(|| ctx_get(&CTX_COLOR));
920 let base = pmml(doc, &args[0]);
921 let inner = if let Some(ref c) = color {
922 attrs.insert("mathcolor".to_string(), c.clone());
923 NodeData::Element {
926 tag: "m:mstyle".to_string(),
927 attributes: Some(HashMap::from_iter([(
928 "mathcolor".to_string(),
929 ctx_get(&CTX_COLOR).unwrap_or_else(|| "black".to_string()),
930 )])),
931 children: vec![base],
932 }
933 } else {
934 base
935 };
936 NodeData::Element {
937 tag: "m:menclose".to_string(),
938 attributes: if attrs.is_empty() { None } else { Some(attrs) },
939 children: vec![inner],
940 }
941 },
942 _ if meaning == "multirelation" => {
943 let mut items = Vec::new();
946 for (i, arg) in args.iter().enumerate() {
947 if i > 0 && i % 2 == 1 {
948 items.push(pmml(doc, arg));
950 } else {
951 items.push(pmml(doc, arg));
952 }
953 }
954 pmml_row(items)
955 },
956 _ => {
957 if meaning == "limit-from" && !args.is_empty() {
959 let items: Vec<NodeData> = args.iter().map(|a| pmml(doc, a)).collect();
961 pmml_row(items)
962 } else if meaning == "annotated" && args.len() >= 2 {
963 pmml_row(vec![
965 pmml(doc, &args[0]),
966 NodeData::Element {
967 tag: "m:mspace".to_string(),
968 attributes: Some(HashMap::from_iter([(
969 "width".to_string(),
970 "0.389em".to_string(),
971 )])),
972 children: vec![],
973 },
974 pmml(doc, &args[1]),
975 ])
976 } else if meaning == "square-root" && !args.is_empty() {
977 NodeData::Element {
979 tag: "m:msqrt".to_string(),
980 attributes: rop
981 .get_attribute("color")
982 .or_else(|| ctx_get(&CTX_COLOR))
983 .map(|c| HashMap::from_iter([("mathcolor".to_string(), c)])),
984 children: vec![pmml(doc, &args[0])],
985 }
986 } else if meaning == "continued-fraction" && args.len() >= 2 {
987 pmml_cfrac(doc, op, &args[0], &args[1])
988 } else if meaning == "nth-root" && args.len() >= 2 {
989 NodeData::Element {
994 tag: "m:mroot".to_string(),
995 attributes: rop
996 .get_attribute("color")
997 .or_else(|| ctx_get(&CTX_COLOR))
998 .map(|c| HashMap::from_iter([("mathcolor".to_string(), c)])),
999 children: vec![pmml(doc, &args[1]), pmml_scriptsize(doc, &args[0])],
1000 }
1001 } else {
1002 let pop = pmml(doc, op);
1008 let needs_apply = !op_base_is_mo(&pop);
1009 let mut items = vec![pop];
1010 if needs_apply {
1011 items.push(pmml_mo_str("\u{2061}")); }
1013 for arg in args {
1014 items.push(pmml(doc, arg));
1015 }
1016 pmml_row(items)
1017 }
1018 },
1019 }
1020}
1021
1022fn pmml_token(doc: &PostDocument, node: &Node) -> NodeData {
1026 let nstyle = match node.get_attribute("role").as_deref() {
1032 Some("SUMOP" | "INTOP" | "BIGOP") => node
1033 .get_attribute("mathstyle")
1034 .as_deref()
1035 .and_then(MathStyle::from_attr),
1036 _ => None,
1037 };
1038 let ostyle = CURRENT_STYLE.with(|s| s.get());
1039 if let Some(n) = nstyle {
1040 CURRENT_STYLE.with(|s| s.set(n));
1041 }
1042 let result = pmml_token_inner(doc, node, None);
1043 CURRENT_STYLE.with(|s| s.set(ostyle));
1044 match nstyle {
1045 Some(n) if n != ostyle => {
1046 let style_attrs = stylemap_attrs(ostyle, n, true);
1047 if style_attrs.is_empty() {
1048 result
1049 } else {
1050 NodeData::Element {
1051 tag: "m:mstyle".to_string(),
1052 attributes: Some(HashMap::from_iter(
1053 style_attrs
1054 .iter()
1055 .map(|(k, v)| (k.to_string(), v.to_string())),
1056 )),
1057 children: vec![result],
1058 }
1059 }
1060 },
1061 _ => result,
1062 }
1063}
1064
1065fn resolve_token_size(mut s: String) -> String {
1070 if let Some(req) = s.strip_suffix('%') {
1071 let ctx = current_context_size().trim_end_matches('%');
1072 if matches!(
1073 CURRENT_STYLE.with(|c| c.get()),
1074 MathStyle::Script | MathStyle::ScriptScript
1075 ) && let (Ok(req), Ok(ex)) = (req.parse::<f64>(), ctx.parse::<f64>())
1076 && ex != 0.0
1077 {
1078 s = format!("{}%", (100.0 * req / ex) as i32);
1079 }
1080 if let Some(pct) = s.strip_suffix('%')
1081 && let Ok(pct) = pct.parse::<f64>()
1082 {
1083 s = fmt_em(pct / 100.0);
1084 }
1085 }
1086 s
1087}
1088
1089fn pmml_token_inner(doc: &PostDocument, node: &Node, role_override: Option<&str>) -> NodeData {
1097 let role = role_override
1098 .map(String::from)
1099 .or_else(|| node.get_attribute("role"))
1100 .unwrap_or_else(|| "UNKNOWN".to_string());
1101 let font = node.get_attribute("font").or_else(|| ctx_get(&CTX_FONT));
1103 let mut text = node.get_content();
1104 let meaning = node.get_attribute("meaning");
1105
1106 if meaning.as_deref() == Some("absent") {
1108 return NodeData::Element {
1137 tag: "m:mphantom".to_string(),
1138 attributes: None,
1139 children: vec![],
1140 };
1141 }
1142
1143 let tag = match role.as_str() {
1145 "NUMBER" => "m:mn",
1146 "ID" | "UNKNOWN" => "m:mi",
1147 "FUNCTION" | "OPFUNCTION" | "TRIGFUNCTION" => "m:mi",
1148 _ => "m:mo",
1149 };
1150
1151 if text.is_empty() {
1153 if node
1161 .get_attribute("class")
1162 .is_some_and(|c| c.split_whitespace().any(|w| w == "ltx_unit"))
1163 {
1164 return NodeData::Element {
1165 tag: "m:mphantom".to_string(),
1166 attributes: None,
1167 children: vec![],
1168 };
1169 }
1170 if let Some(default) = default_token_content(&role) {
1171 text = default.to_string();
1172 } else {
1173 text = meaning
1174 .or_else(|| node.get_attribute("name"))
1175 .unwrap_or_else(|| role.clone());
1176 }
1177 }
1178
1179 if text == "-" && matches!(role.as_str(), "ADDOP" | "OPERATOR") {
1181 text = "\u{2212}".to_string(); }
1183
1184 let is_replaced_invisible_times = text == "\u{2062}" && !get_invisible_times();
1186 if is_replaced_invisible_times {
1187 text = "\u{200B}".to_string(); }
1189
1190 let mut attrs = HashMap::default();
1191
1192 let is_format_only = text.chars().all(|c| {
1197 matches!(c,
1198 '\u{00AD}' | '\u{200B}'..='\u{200F}' | '\u{2060}'..='\u{2064}' | '\u{FEFF}')
1199 });
1200
1201 if is_replaced_invisible_times && tag == "m:mo" {
1204 attrs.insert("lspace".to_string(), "0em".to_string());
1205 attrs.insert("rspace".to_string(), "0em".to_string());
1206 }
1207
1208 if !is_format_only {
1211 use crate::unicode;
1212 let mut variant: Option<&str> = font.as_deref().map(unicode::unicode_mathvariant);
1213
1214 if tag == "m:mi" && text.chars().count() == 1 {
1216 if variant == Some("italic") {
1217 variant = None;
1218 } else if variant.is_none() && font.is_none() {
1219 if node.get_attribute("name").is_some() {
1221 variant = Some("normal");
1222 }
1223 } else if variant.is_none() {
1224 variant = Some("normal");
1225 }
1226 } else if font.is_some() && variant == Some("normal") {
1227 variant = None; } else if tag == "m:mi" && text.chars().count() > 1 && font.is_none() {
1229 variant = Some("normal"); }
1231
1232 if let Some(v) = variant
1239 && tag != "m:mtext"
1240 && let Some(u_variant) = plane1_target_variant(v)
1241 && let Some(u_text) = unicode::unicode_convert(&text, u_variant)
1242 && (!u_text.is_empty() || text.is_empty())
1243 {
1244 text = u_text;
1245 variant = if u_variant != v && v.starts_with("bold") {
1250 Some("bold")
1251 } else {
1252 None
1253 };
1254 }
1255
1256 if let Some(v) = variant {
1258 if tag == "m:mi" && text.chars().count() == 1 {
1259 if v != "italic" {
1260 attrs.insert("mathvariant".to_string(), v.to_string());
1261 }
1262 } else if v != "normal" {
1263 attrs.insert("mathvariant".to_string(), v.to_string());
1264 }
1265 }
1266
1267 let is_format_only = text.chars().all(|c| {
1270 matches!(c,
1271 '\u{200B}'..='\u{200F}' | '\u{2028}'..='\u{202F}'
1272 | '\u{2060}'..='\u{2064}' | '\u{FEFF}' | '\u{00AD}')
1273 }) && !text.is_empty();
1274 if let Some(ref f) = font {
1275 if !is_format_only {
1276 if f.contains("caligraphic") {
1277 let prev = attrs.get("class").cloned().unwrap_or_default();
1278 let new = if prev.is_empty() {
1279 "ltx_font_mathcaligraphic".to_string()
1280 } else {
1281 format!("{} ltx_font_mathcaligraphic", prev)
1282 };
1283 attrs.insert("class".to_string(), new);
1284 } else if f.contains("script") {
1285 let prev = attrs.get("class").cloned().unwrap_or_default();
1286 let new = if prev.is_empty() {
1287 "ltx_font_mathscript".to_string()
1288 } else {
1289 format!("{} ltx_font_mathscript", prev)
1290 };
1291 attrs.insert("class".to_string(), new);
1292 } else if f.contains("fraktur") && text.chars().all(|c| "+-0123456789.".contains(c)) {
1293 let prev = attrs.get("class").cloned().unwrap_or_default();
1294 let new = if prev.is_empty() {
1295 "ltx_font_oldstyle".to_string()
1296 } else {
1297 format!("{} ltx_font_oldstyle", prev)
1298 };
1299 attrs.insert("class".to_string(), new);
1300 } else if f.contains("smallcaps") {
1301 let prev = attrs.get("class").cloned().unwrap_or_default();
1302 let new = if prev.is_empty() {
1303 "ltx_font_smallcaps".to_string()
1304 } else {
1305 format!("{} ltx_font_smallcaps", prev)
1306 };
1307 attrs.insert("class".to_string(), new);
1308 } else if let Some(v) = variant {
1309 if v != "normal" {
1310 let prev = attrs.get("class").cloned().unwrap_or_default();
1311 let new = if prev.is_empty() {
1312 format!("ltx_mathvariant_{}", v)
1313 } else {
1314 format!("{} ltx_mathvariant_{}", prev, v)
1315 };
1316 attrs.insert("class".to_string(), new);
1317 }
1318 }
1319 }
1320 }
1321 }
1322
1323 if tag != "m:mo"
1328 && let Some(size) = node.get_attribute("fontsize")
1329 && size != current_context_size()
1330 {
1331 attrs.insert("mathsize".to_string(), resolve_token_size(size));
1332 }
1333
1334 if tag == "m:mo" {
1338 let props = operator_dictionary::opdict_lookup(&text, &role);
1339 let mut stretchy = node.get_attribute("stretchy").as_deref() == Some("true");
1341 let is_fence = matches!(role.as_str(), "OPEN" | "CLOSE" | "MIDDLE");
1342 let is_sep = role == "PUNCT";
1343 let is_largeop = matches!(role.as_str(), "SUMOP" | "INTOP");
1344 let is_moveop = matches!(role.as_str(), "SUMOP" | "INTOP" | "BIGOP" | "LIMITOP"); let is_symm = is_largeop || text == "/"; let pos = node
1347 .get_attribute("scriptpos")
1348 .unwrap_or_else(|| "post".to_string());
1349
1350 let mut size = node.get_attribute("fontsize");
1353 if stretchy {
1354 size = None;
1355 }
1356 let is_invisible = !text.is_empty()
1360 && text
1361 .chars()
1362 .all(|c| matches!(c, '\u{2061}'..='\u{2063}' | '\u{200B}'));
1363 if is_invisible {
1364 stretchy = false;
1365 size = None;
1366 }
1367
1368 let mut props_stretchy = props.stretchy;
1377 let mut stretchyhack = false;
1378 let resolved_size = size
1379 .filter(|s| s != current_context_size())
1380 .map(resolve_token_size);
1381 if let Some(size) = resolved_size {
1382 if is_symm || props.symmetric {
1383 stretchyhack = true;
1384 if !matches!(text.as_str(), "(" | ")" | "[" | "]" | "{" | "}") {
1386 props_stretchy = false;
1387 }
1388 stretchy = true; attrs.insert("minsize".to_string(), size.clone());
1390 attrs.insert("maxsize".to_string(), size);
1391 } else {
1392 stretchy = false; attrs.insert("mathsize".to_string(), size);
1394 }
1395 }
1396 let _ = stretchyhack;
1397
1398 if stretchy != props_stretchy {
1401 attrs.insert(
1402 "stretchy".to_string(),
1403 (if stretchy { "true" } else { "false" }).to_string(),
1404 );
1405 }
1406 if is_fence != props.fence {
1407 attrs.insert(
1408 "fence".to_string(),
1409 (if is_fence { "true" } else { "false" }).to_string(),
1410 );
1411 }
1412 if is_sep != props.separator {
1413 attrs.insert(
1414 "separator".to_string(),
1415 (if is_sep { "true" } else { "false" }).to_string(),
1416 );
1417 }
1418 if is_largeop != props.largeop {
1419 attrs.insert(
1420 "largeop".to_string(),
1421 (if is_largeop { "true" } else { "false" }).to_string(),
1422 );
1423 }
1424 if is_largeop {
1425 attrs.insert("_largeop".to_string(), "1".to_string()); }
1427 if is_symm && !props.symmetric && (stretchy || props_stretchy) {
1428 attrs.insert("symmetric".to_string(), "true".to_string());
1429 }
1430 if is_moveop && pos.contains("mid") {
1434 attrs.insert("movablelimits".to_string(), "false".to_string());
1435 }
1436
1437 if role_override.is_none() {
1444 attrs.insert("_role".to_string(), role);
1445 }
1446 if props.lspace > 0.0 {
1447 attrs.insert("_lspace".to_string(), fmt_em(props.lspace));
1448 }
1449 if props.rspace > 0.0 {
1450 attrs.insert("_rspace".to_string(), fmt_em(props.rspace));
1451 }
1452 }
1453
1454 if !is_format_only {
1457 if let Some(color) = node.get_attribute("color").or_else(|| ctx_get(&CTX_COLOR)) {
1458 attrs.insert("mathcolor".to_string(), color);
1459 }
1460 if let Some(bg) = ctx_get(&CTX_BGCOLOR) {
1464 attrs.insert("mathbackground".to_string(), bg);
1465 }
1466 let cssstyle = node.get_attribute("cssstyle").unwrap_or_default();
1467 let opacity = node
1468 .get_attribute("opacity")
1469 .or_else(|| ctx_get(&CTX_OPACITY));
1470 let style = match (cssstyle.is_empty(), opacity) {
1471 (true, None) => String::new(),
1472 (true, Some(op)) => format!("opacity:{op}"),
1473 (false, None) => cssstyle,
1474 (false, Some(op)) => format!("{cssstyle};opacity:{op}"),
1475 };
1476 if !style.is_empty() {
1477 attrs.insert("style".to_string(), style);
1478 }
1479 }
1480
1481 if let Some(href) = node.get_attribute("href") {
1483 attrs.insert("href".to_string(), href);
1484 }
1485
1486 if let Some(class) = node.get_attribute("class") {
1488 attrs.insert("class".to_string(), class);
1489 }
1490
1491 if let Some(sp) = node.get_attribute_ns("sourcepos", "http://dlmf.nist.gov/LaTeXML/data") {
1498 attrs.insert("data-sourcepos".to_string(), sp);
1499 }
1500
1501 pmml_maybe_resize(doc, node, NodeData::Element {
1504 tag: tag.to_string(),
1505 attributes: if attrs.is_empty() { None } else { Some(attrs) },
1506 children: vec![NodeData::Text(text)],
1507 })
1508}
1509
1510fn pmml_hint(_doc: &PostDocument, node: &Node) -> NodeData {
1514 let w = node
1519 .get_attribute("width")
1520 .map(|w| super::get_xm_hint_spacing(&w))
1521 .unwrap_or(0.0);
1522 let attrs = if w != 0.0 {
1523 HashMap::from_iter([("width".to_string(), format!("{}em", perl_num(w)))])
1526 } else {
1527 HashMap::from_iter([("_ignorable".to_string(), "1".to_string())])
1528 };
1529 NodeData::Element {
1530 tag: "m:mspace".to_string(),
1531 attributes: Some(attrs),
1532 children: vec![],
1533 }
1534}
1535
1536fn perl_num(v: f64) -> String {
1540 if v == 0.0 {
1541 return "0".to_string();
1542 }
1543 let magnitude = v.abs().log10().floor() as i32;
1544 let decimals = (14 - magnitude).clamp(0, 17) as usize;
1545 let s = format!("{v:.decimals$}");
1546 if s.contains('.') {
1547 s.trim_end_matches('0').trim_end_matches('.').to_string()
1548 } else {
1549 s
1550 }
1551}
1552
1553fn pmml_array(doc: &PostDocument, node: &Node) -> NodeData {
1557 let ostyle = CURRENT_STYLE.with(|s| s.get());
1563 let nstyle = node
1564 .get_attribute("mathstyle")
1565 .as_deref()
1566 .and_then(MathStyle::from_attr);
1567 if let Some(n) = nstyle {
1568 CURRENT_STYLE.with(|s| s.set(n));
1569 }
1570 let result = pmml_array_inner(doc, node);
1571 CURRENT_STYLE.with(|s| s.set(ostyle));
1572 let result = maybe_style_wrap(result, ostyle, nstyle);
1574 pmml_maybe_resize(doc, node, result)
1575}
1576
1577fn pmml_array_inner(doc: &PostDocument, node: &Node) -> NodeData {
1578 let mut rows = Vec::new();
1579 let width = node.get_attribute("width");
1580 let vattach = node
1581 .get_attribute("vattach")
1582 .unwrap_or_else(|| "middle".to_string());
1583 let align = match vattach.as_str() {
1584 "top" => "bottom1",
1585 "middle" | "" => "axis",
1586 _ => vattach.as_str(),
1587 };
1588 let rowsep = node
1589 .get_attribute("rowsep")
1590 .unwrap_or_else(|| "0pt".to_string());
1591 let colsep = node
1592 .get_attribute("colsep")
1593 .unwrap_or_else(|| "5pt".to_string());
1594
1595 let mut nrows = 0;
1596 let mut ncols = 0;
1597 for row_node in element_children(node) {
1598 let mut cols = Vec::new();
1599 let mut nc = 0;
1600 for cell_node in element_children(&row_node) {
1601 nc += 1;
1602 let cell_align = cell_node.get_attribute("align");
1603 let colspan = cell_node.get_attribute("colspan");
1604 let rowspan = cell_node.get_attribute("rowspan");
1605 let mut td_attrs = HashMap::default();
1606 if let Some(a) = &cell_align {
1607 if a != "center" {
1608 td_attrs.insert("columnalign".to_string(), a.clone());
1609 td_attrs.insert("class".to_string(), format!("ltx_align_{}", a));
1610 }
1611 }
1612 let bc = cell_node.get_attribute("border").and_then(|b| {
1622 let s = b
1623 .split_whitespace()
1624 .map(|p| format!("ltx_border_{p}"))
1625 .collect::<Vec<_>>()
1626 .join(" ");
1627 (!s.is_empty()).then_some(s)
1628 });
1629 let hc = cell_node.get_attribute("thead").and_then(|t| {
1630 let s = t
1631 .split_whitespace()
1632 .map(|p| format!("ltx_th_{p}"))
1633 .collect::<Vec<_>>()
1634 .join(" ");
1635 (!s.is_empty()).then_some(s)
1636 });
1637 let border_thead = match (bc, hc) {
1639 (Some(bc), Some(hc)) => Some(format!("{bc} {hc}")),
1640 (Some(bc), None) => Some(bc),
1641 (None, Some(hc)) => Some(hc),
1642 (None, None) => None,
1643 };
1644 let cl = cell_node.get_attribute("class").filter(|s| !s.is_empty());
1645 if let Some(class) = match (border_thead, cl) {
1647 (Some(c), Some(cl)) => Some(format!("{c} {cl}")),
1648 (Some(c), None) => Some(c),
1649 (None, Some(cl)) => Some(cl),
1650 (None, None) => None,
1651 } {
1652 td_attrs.insert("class".to_string(), class);
1653 }
1654 if let Some(cs) = colspan {
1655 td_attrs.insert("columnspan".to_string(), cs);
1656 }
1657 if let Some(rs) = rowspan {
1658 td_attrs.insert("rowspan".to_string(), rs);
1659 }
1660 if let Some(bg) = cell_node.get_attribute("backgroundcolor") {
1664 td_attrs.insert("mathbackground".to_string(), bg);
1665 }
1666
1667 let cell_children = element_children(&cell_node);
1668 let cell_content = if cell_children.is_empty() {
1669 vec![]
1670 } else {
1671 filter_row(cell_children.iter().map(|c| pmml(doc, c)).collect())
1673 };
1674
1675 cols.push(NodeData::Element {
1676 tag: "m:mtd".to_string(),
1677 attributes: if td_attrs.is_empty() {
1678 None
1679 } else {
1680 Some(td_attrs)
1681 },
1682 children: cell_content,
1683 });
1684 }
1685 if nc > ncols {
1686 ncols = nc;
1687 }
1688 nrows += 1;
1689 rows.push(NodeData::Element {
1690 tag: "m:mtr".to_string(),
1691 attributes: None,
1692 children: cols,
1693 });
1694 }
1695
1696 let emit_rowsep = nrows >= 2;
1698 let emit_colsep = ncols >= 2;
1699
1700 let mut table_attrs = HashMap::default();
1701 if align != "axis" {
1702 table_attrs.insert("align".to_string(), align.to_string());
1703 }
1704 if emit_rowsep {
1705 table_attrs.insert("rowspacing".to_string(), rowsep);
1706 }
1707 if emit_colsep {
1708 table_attrs.insert("columnspacing".to_string(), colsep);
1709 }
1710 if let Some(w) = width {
1711 table_attrs.insert("width".to_string(), w);
1712 }
1713 if CURRENT_STYLE.with(|s| s.get()) == MathStyle::Display {
1715 table_attrs.insert("displaystyle".to_string(), "true".to_string());
1716 }
1717
1718 NodeData::Element {
1719 tag: "m:mtable".to_string(),
1720 attributes: if table_attrs.is_empty() {
1721 None
1722 } else {
1723 Some(table_attrs)
1724 },
1725 children: rows,
1726 }
1727}
1728
1729fn pmml_script_simple(doc: &PostDocument, tag: &str, base: &Node, script: &Node) -> NodeData {
1734 NodeData::Element {
1735 tag: tag.to_string(),
1736 attributes: None,
1737 children: vec![pmml(doc, base), pmml_scriptsize(doc, script)],
1738 }
1739}
1740
1741fn pmml_scriptsize(doc: &PostDocument, node: &Node) -> NodeData {
1745 let old = CURRENT_STYLE.with(|s| {
1746 let o = s.get();
1747 s.set(o.script_step());
1748 o
1749 });
1750 let r = pmml(doc, node);
1751 CURRENT_STYLE.with(|s| s.set(old));
1752 r
1753}
1754
1755fn pmml_smaller(doc: &PostDocument, node: &Node) -> NodeData {
1758 let old = CURRENT_STYLE.with(|s| {
1759 let o = s.get();
1760 s.set(o.step_down());
1761 o
1762 });
1763 let r = pmml(doc, node);
1764 CURRENT_STYLE.with(|s| s.set(old));
1765 r
1766}
1767
1768fn pmml_infix(doc: &PostDocument, op: &Node, args: &[Node]) -> NodeData {
1772 match args {
1796 [] => pmml(doc, op),
1797 [arg] => {
1809 let op_prefix = if op.get_name() == "XMTok" {
1810 pmml_token_inner(doc, op, Some("OPERATOR"))
1811 } else {
1812 pmml(doc, op)
1813 };
1814 pmml_row(vec![op_prefix, pmml(doc, arg)])
1815 },
1816 [first, rest @ ..] => {
1818 let op_mml = pmml(doc, op);
1819 let mut items = vec![pmml(doc, first)];
1820 for arg in rest {
1821 items.push(op_mml.clone());
1822 items.push(pmml(doc, arg));
1823 }
1824 pmml_row(items)
1825 },
1826 }
1827}
1828
1829fn is_absent_operand(node: &Node) -> bool {
1836 if node.get_name() != "XMTok" {
1837 return false;
1838 }
1839 node.get_attribute("meaning").as_deref() == Some("absent")
1840}
1841
1842fn pmml_summation(doc: &PostDocument, op: &Node, args: &[Node]) -> NodeData {
1846 let op_mml = pmml(doc, op);
1847 let needs_apply = !op_base_is_mo(&op_mml);
1852 let mut items = vec![op_mml];
1853 if needs_apply {
1854 items.push(pmml_mo_str("\u{2061}")); }
1856 for arg in args {
1857 items.push(pmml(doc, arg));
1858 }
1859 pmml_row(items)
1860}
1861
1862fn pmml_parenthesize(doc: &PostDocument, op: &Node, args: &[Node]) -> NodeData {
1866 let mut items = vec![pmml(doc, op)];
1867 for arg in args {
1868 items.push(pmml(doc, arg));
1869 }
1870 pmml_row(items)
1871}
1872
1873type ScriptPair = (Option<Node>, Option<Node>);
1881
1882fn pmml_script_full(doc: &PostDocument, op: &Node, base: &Node, script: &Node) -> NodeData {
1886 let (inner_base, pre_scripts, mid_scripts, post_scripts, emb_right) =
1887 pmml_script_decipher(doc, op, base, script);
1888
1889 let ostyle = CURRENT_STYLE.with(|s| s.get());
1895 let bstyle = inner_base
1896 .get_attribute("mathstyle")
1897 .as_deref()
1898 .and_then(MathStyle::from_attr);
1899 if let Some(b) = bstyle {
1900 CURRENT_STYLE.with(|s| s.set(b));
1901 }
1902 let base_mml = pmml(doc, &inner_base);
1903 CURRENT_STYLE.with(|s| s.set(ostyle));
1904
1905 let base_mml = apply_mid_scripts(doc, base_mml, &mid_scripts, emb_right.as_ref());
1907
1908 let layout = apply_multi_scripts(doc, base_mml, &pre_scripts, &post_scripts);
1910 match bstyle {
1911 Some(b) if b != ostyle => NodeData::Element {
1912 tag: "m:mstyle".to_string(),
1913 attributes: Some(HashMap::from_iter([(
1914 "displaystyle".to_string(),
1915 (if b == MathStyle::Display {
1916 "true"
1917 } else {
1918 "false"
1919 })
1920 .to_string(),
1921 )])),
1922 children: vec![layout],
1923 },
1924 _ => layout,
1925 }
1926}
1927
1928fn pmml_script_decipher(
1932 doc: &PostDocument,
1933 op: &Node,
1934 base: &Node,
1935 script: &Node,
1936) -> (
1937 Node,
1938 Vec<ScriptPair>,
1939 Vec<ScriptPair>,
1940 Vec<ScriptPair>,
1941 Option<Node>,
1942) {
1943 let mut pre_scripts: Vec<ScriptPair> = Vec::new();
1944 let mut mid_scripts: Vec<ScriptPair> = Vec::new();
1945 let mut post_scripts: Vec<ScriptPair> = Vec::new();
1946 let mut emb_right: Option<Node> = None;
1951 let mut saw_mid = false;
1952
1953 let (mut pre_level, mut mid_level, mut post_level) =
1958 ("0".to_string(), "0".to_string(), "0".to_string());
1959
1960 let (pos, level) = parse_scriptpos(op);
1961 let is_sub = op.get_attribute("role").unwrap_or_default().contains("SUB");
1962
1963 let pair = if is_sub {
1965 (Some(script.clone()), None)
1966 } else {
1967 (None, Some(script.clone()))
1968 };
1969 match pos {
1970 ScriptPos::Pre => {
1971 pre_scripts.push(pair);
1972 pre_level = level;
1973 },
1974 ScriptPos::Mid => {
1975 saw_mid = true;
1976 mid_scripts.push(pair);
1977 mid_level = level;
1978 },
1979 ScriptPos::Post => {
1980 post_scripts.push(pair);
1981 post_level = level;
1982 },
1983 }
1984
1985 let mut current_base = base.clone();
1987 loop {
1988 let Some(realized) = doc.realize_xm_node_branch(¤t_base, XMBranch::Presentation) else {
1992 break;
1993 };
1994 current_base = realized;
1995
1996 if !doc.is_qname(¤t_base, "ltx:XMApp") {
1997 break;
1998 }
1999
2000 let children = element_children(¤t_base);
2001 if children.len() < 3 {
2002 break;
2003 }
2004
2005 let xop = &children[0];
2006 if !doc.is_qname(xop, "ltx:XMTok") {
2007 break;
2008 }
2009
2010 let xrole = xop.get_attribute("role").unwrap_or_default();
2011 let is_script_op = xrole.contains("SUPERSCRIPTOP") || xrole.contains("SUBSCRIPTOP");
2012 if !is_script_op {
2013 break;
2014 }
2015
2016 let xbase = children[1].clone();
2017 let xscript = &children[2];
2018 let (xpos, xlevel) = parse_scriptpos(xop);
2019 let x_is_sub = xrole.contains("SUB");
2020
2021 match xpos {
2022 ScriptPos::Pre => place_script(
2026 &mut pre_scripts,
2027 &mut pre_level,
2028 xlevel,
2029 x_is_sub,
2030 xscript.clone(),
2031 false,
2032 ),
2033 ScriptPos::Mid => {
2034 saw_mid = true;
2035 place_script(
2036 &mut mid_scripts,
2037 &mut mid_level,
2038 xlevel,
2039 x_is_sub,
2040 xscript.clone(),
2041 true,
2042 );
2043 },
2044 ScriptPos::Post => {
2045 if saw_mid {
2054 emb_right = Some(xscript.clone());
2055 break;
2056 }
2057 place_script(
2058 &mut post_scripts,
2059 &mut post_level,
2060 xlevel,
2061 x_is_sub,
2062 xscript.clone(),
2063 true,
2064 );
2065 },
2066 }
2067
2068 current_base = xbase;
2069 }
2070
2071 (
2072 current_base,
2073 pre_scripts,
2074 mid_scripts,
2075 post_scripts,
2076 emb_right,
2077 )
2078}
2079
2080#[derive(Clone, Copy, PartialEq, Eq, Debug)]
2083enum ScriptPos {
2084 Pre,
2086 Mid,
2088 Post,
2090}
2091
2092fn parse_scriptpos(op: &Node) -> (ScriptPos, String) {
2102 let raw = op
2103 .get_attribute("scriptpos")
2104 .unwrap_or_else(|| "post0".to_string());
2105 let (pos, rest) = if let Some(rest) = raw.strip_prefix("pre") {
2106 (ScriptPos::Pre, rest)
2107 } else if let Some(rest) = raw.strip_prefix("mid") {
2108 (ScriptPos::Mid, rest)
2109 } else if let Some(rest) = raw.strip_prefix("post") {
2110 (ScriptPos::Post, rest)
2111 } else {
2112 (ScriptPos::Post, raw.as_str())
2113 };
2114 if rest.is_empty() || rest.bytes().all(|b| b.is_ascii_digit()) {
2117 (pos, rest.to_string())
2118 } else {
2119 (ScriptPos::Post, String::new())
2120 }
2121}
2122
2123fn place_script(
2144 list: &mut Vec<ScriptPair>,
2145 level: &mut String,
2146 new_level: String,
2147 is_sub: bool,
2148 script: Node,
2149 at_front: bool,
2150) {
2151 let slot_taken = |p: &ScriptPair| if is_sub { p.0.is_some() } else { p.1.is_some() };
2152 let current = if at_front { list.first() } else { list.last() };
2153 if current.is_none_or(slot_taken) || *level != new_level {
2154 if at_front {
2155 list.insert(0, (None, None));
2156 } else {
2157 list.push((None, None));
2158 }
2159 }
2160 let pair = if at_front {
2161 list.first_mut()
2162 } else {
2163 list.last_mut()
2164 }
2165 .expect("a pair was just ensured to exist");
2166 if is_sub {
2167 pair.0 = Some(script);
2168 } else {
2169 pair.1 = Some(script);
2170 }
2171 *level = new_level;
2172}
2173
2174fn apply_mid_scripts(
2176 doc: &PostDocument,
2177 mut base: NodeData,
2178 mid_scripts: &[ScriptPair],
2179 emb_right: Option<&Node>,
2180) -> NodeData {
2181 for (sub_opt, sup_opt) in mid_scripts {
2182 let under = sub_opt
2183 .as_ref()
2184 .map(|s| pmml_scriptsize_padded(doc, s, emb_right));
2185 let over = sup_opt
2186 .as_ref()
2187 .map(|s| pmml_scriptsize_padded(doc, s, emb_right));
2188
2189 base = match (under, over) {
2190 (Some(u), None) => NodeData::Element {
2191 tag: "m:munder".to_string(),
2192 attributes: None,
2193 children: vec![base, u],
2194 },
2195 (None, Some(o)) => NodeData::Element {
2196 tag: "m:mover".to_string(),
2197 attributes: None,
2198 children: vec![base, o],
2199 },
2200 (Some(u), Some(o)) => NodeData::Element {
2201 tag: "m:munderover".to_string(),
2202 attributes: None,
2203 children: vec![base, u, o],
2204 },
2205 (None, None) => base,
2206 };
2207 }
2208 base
2209}
2210
2211fn pmml_scriptsize_padded(doc: &PostDocument, script: &Node, emb_right: Option<&Node>) -> NodeData {
2224 let script_mml = pmml_scriptsize(doc, script);
2225 match emb_right {
2226 None => script_mml,
2227 Some(emb) => NodeData::Element {
2228 tag: "m:mrow".to_string(),
2229 attributes: None,
2230 children: vec![script_mml, NodeData::Element {
2231 tag: "m:mphantom".to_string(),
2232 attributes: None,
2233 children: vec![pmml_scriptsize(doc, emb)],
2234 }],
2235 },
2236 }
2237}
2238
2239fn apply_multi_scripts(
2243 doc: &PostDocument,
2244 base: NodeData,
2245 pre_scripts: &[ScriptPair],
2246 post_scripts: &[ScriptPair],
2247) -> NodeData {
2248 let none_mml = || NodeData::Element {
2253 tag: "m:mrow".to_string(),
2254 attributes: None,
2255 children: vec![],
2256 };
2257
2258 if !pre_scripts.is_empty() {
2259 let mut children = vec![base];
2261 for (sub_opt, sup_opt) in post_scripts {
2262 children.push(
2263 sub_opt
2264 .as_ref()
2265 .map(|s| pmml_scriptsize(doc, s))
2266 .unwrap_or_else(none_mml),
2267 );
2268 children.push(
2269 sup_opt
2270 .as_ref()
2271 .map(|s| pmml_scriptsize(doc, s))
2272 .unwrap_or_else(none_mml),
2273 );
2274 }
2275 children.push(NodeData::Element {
2276 tag: "m:mprescripts".to_string(),
2277 attributes: None,
2278 children: vec![],
2279 });
2280 for (sub_opt, sup_opt) in pre_scripts {
2281 children.push(
2282 sub_opt
2283 .as_ref()
2284 .map(|s| pmml_scriptsize(doc, s))
2285 .unwrap_or_else(none_mml),
2286 );
2287 children.push(
2288 sup_opt
2289 .as_ref()
2290 .map(|s| pmml_scriptsize(doc, s))
2291 .unwrap_or_else(none_mml),
2292 );
2293 }
2294 NodeData::Element {
2295 tag: "m:mmultiscripts".to_string(),
2296 attributes: None,
2297 children,
2298 }
2299 } else if post_scripts.len() > 1 {
2300 let mut children = vec![base];
2302 for (sub_opt, sup_opt) in post_scripts {
2303 children.push(
2304 sub_opt
2305 .as_ref()
2306 .map(|s| pmml_scriptsize(doc, s))
2307 .unwrap_or_else(none_mml),
2308 );
2309 children.push(
2310 sup_opt
2311 .as_ref()
2312 .map(|s| pmml_scriptsize(doc, s))
2313 .unwrap_or_else(none_mml),
2314 );
2315 }
2316 NodeData::Element {
2317 tag: "m:mmultiscripts".to_string(),
2318 attributes: None,
2319 children,
2320 }
2321 } else if post_scripts.is_empty() {
2322 base
2323 } else {
2324 let (sub_opt, sup_opt) = &post_scripts[0];
2326 match (sub_opt, sup_opt) {
2327 (Some(sub_node), None) => NodeData::Element {
2328 tag: "m:msub".to_string(),
2329 attributes: None,
2330 children: vec![base, pmml_scriptsize(doc, sub_node)],
2331 },
2332 (None, Some(sup_node)) => NodeData::Element {
2333 tag: "m:msup".to_string(),
2334 attributes: None,
2335 children: vec![base, pmml_scriptsize(doc, sup_node)],
2336 },
2337 (Some(sub_node), Some(sup_node)) => NodeData::Element {
2338 tag: "m:msubsup".to_string(),
2339 attributes: None,
2340 children: vec![
2341 base,
2342 pmml_scriptsize(doc, sub_node),
2343 pmml_scriptsize(doc, sup_node),
2344 ],
2345 },
2346 (None, None) => base,
2347 }
2348 }
2349}
2350
2351fn pmml_cfrac(doc: &PostDocument, op: &Node, numer: &Node, denom: &Node) -> NodeData {
2360 if op.get_attribute("name").as_deref() == Some("cfrac-inline") {
2363 return pmml_row(do_cfrac(doc, numer, denom));
2364 }
2365 NodeData::Element {
2366 tag: "m:mfrac".to_string(),
2367 attributes: None,
2368 children: vec![pmml_smaller(doc, numer), pmml_smaller(doc, denom)],
2369 }
2370}
2371
2372fn do_cfrac(doc: &PostDocument, numer: &Node, denom: &Node) -> Vec<NodeData> {
2378 if doc.is_qname(denom, "ltx:XMApp") {
2379 let dchildren = element_children(denom);
2380 if dchildren.len() >= 2 {
2381 let denomop = &dchildren[0];
2382 let denomargs = &dchildren[1..];
2383 if denomop.get_attribute("role").as_deref() == Some("ADDOP")
2384 || denomop.get_content() == "\u{22EF}"
2385 {
2386 let (rest, last) = denomargs.split_at(denomargs.len() - 1);
2387 let last = &last[0];
2388 if !rest.is_empty() {
2389 let curr = NodeData::Element {
2390 tag: "m:mfrac".to_string(),
2391 attributes: None,
2392 children: vec![pmml_smaller(doc, numer), NodeData::Element {
2393 tag: "m:mrow".to_string(),
2394 attributes: None,
2395 children: vec![
2396 if rest.len() > 1 {
2397 pmml_infix(doc, denomop, rest)
2398 } else {
2399 pmml_smaller(doc, &rest[0])
2400 },
2401 pmml_smaller(doc, denomop),
2402 ],
2403 }],
2404 };
2405 if last.get_content() == "\u{22EF}" {
2406 return vec![curr, pmml_smaller(doc, last)];
2408 } else if doc.is_qname(last, "ltx:XMApp") {
2409 let lchildren = element_children(last);
2410 if lchildren.len() >= 2 {
2411 let lastop = &lchildren[0];
2412 let lastargs = &lchildren[1..];
2413 if lastop.get_attribute("meaning").as_deref() == Some("continued-fraction")
2414 && lastargs.len() >= 2
2415 {
2416 let mut out = vec![curr];
2418 out.extend(do_cfrac(doc, &lastargs[0], &lastargs[1]));
2419 return out;
2420 } else if lastop.get_content() == "\u{2062}"
2421 && lastargs.len() == 2
2422 && lastargs[0].get_content() == "\u{22EF}"
2423 {
2424 return vec![
2426 curr,
2427 pmml_smaller(doc, &lastargs[0]),
2428 pmml_smaller(doc, &lastargs[1]),
2429 ];
2430 }
2431 }
2432 }
2433 }
2434 }
2435 }
2436 }
2437 vec![NodeData::Element {
2438 tag: "m:mfrac".to_string(),
2439 attributes: None,
2440 children: vec![pmml_smaller(doc, numer), pmml_smaller(doc, denom)],
2441 }]
2442}
2443
2444fn op_base_is_mo(node: &NodeData) -> bool {
2454 let mut cur = node;
2455 loop {
2456 let NodeData::Element { tag, children, .. } = cur else {
2457 return false;
2458 };
2459 if tag == "m:mo" {
2460 return true;
2461 }
2462 if matches!(
2468 tag.as_str(),
2469 "m:msub"
2470 | "m:msup"
2471 | "m:msubsup"
2472 | "m:munder"
2473 | "m:mover"
2474 | "m:munderover"
2475 | "m:mprescripts"
2476 | "m:mstyle"
2477 ) {
2478 match children.first() {
2479 Some(child) => cur = child,
2480 None => return false,
2481 }
2482 } else {
2483 return false;
2484 }
2485 }
2486}
2487
2488fn filter_row(items: Vec<NodeData>) -> Vec<NodeData> {
2490 items
2491 .into_iter()
2492 .filter(|i| {
2493 !matches!(i, NodeData::Element { attributes: Some(a), .. } if a.contains_key("_ignorable"))
2494 })
2495 .collect()
2496}
2497
2498fn pmml_row(children: Vec<NodeData>) -> NodeData {
2499 let children = filter_row(children);
2501 if children.len() == 1 {
2502 children.into_iter().next().unwrap()
2503 } else {
2504 NodeData::Element {
2505 tag: "m:mrow".to_string(),
2506 attributes: None,
2507 children,
2508 }
2509 }
2510}
2511
2512fn pmml_mo_str(text: &str) -> NodeData {
2514 NodeData::Element {
2515 tag: "m:mo".to_string(),
2516 attributes: None,
2517 children: vec![NodeData::Text(text.to_string())],
2518 }
2519}
2520
2521fn pmml_error(msg: &str) -> NodeData {
2523 NodeData::Element {
2524 tag: "m:merror".to_string(),
2525 attributes: None,
2526 children: vec![NodeData::Element {
2527 tag: "m:mtext".to_string(),
2528 attributes: None,
2529 children: vec![NodeData::Text(msg.to_string())],
2530 }],
2531 }
2532}
2533
2534pub fn font_to_mathvariant(font: &str) -> Option<&'static str> {
2539 if font.is_empty() {
2540 return None;
2541 }
2542 Some(crate::unicode::unicode_mathvariant(font))
2543}
2544
2545const TEX_SPACING: [f64; 4] = [0.0, 0.167, 0.222, 0.2778];
2553
2554const SPACING_EPSILON: f64 = 0.01;
2556
2557const SPACING_FUDGE: f64 = 0.3;
2559
2560fn role_to_atom_type(role: &str) -> &'static str {
2562 match role {
2563 "ATOM" | "UNKNOWN" | "ID" | "NUMBER" | "POSTFIX" | "FUNCTION" | "DIFFOP" | "SUPOP"
2564 | "ELIDEOP" => "Ord",
2565 "OPFUNCTION" | "TRIGFUNCTION" | "BIGOP" | "SUMOP" | "INTOP" | "LIMITOP" | "OPERATOR" => "Op",
2566 "ADDOP" | "MULOP" | "BINOP" | "APPLYOP" | "COMPOSEOP" => "Bin",
2567 "RELOP" | "METARELOP" | "MODIFIEROP" | "MODIFIER" | "ARROW" => "Rel",
2568 "OPEN" => "Open",
2569 "CLOSE" => "Close",
2570 "PUNCT" | "VERTBAR" | "PERIOD" => "Punct",
2571 "ARRAY" | "POSTSUBSCRIPT" | "POSTSUPERSCRIPT" | "FLOATSUPERSCRIPT" | "FLOATSUBSCRIPT" => {
2572 "Inner"
2573 },
2574 "MIDDLE" => "Ord",
2575 _ => "Ord",
2576 }
2577}
2578
2579fn atompair_spacing(left: &str, right: &str) -> i32 {
2581 match (left, right) {
2582 ("Ord", "Op") | ("Op", "Ord") | ("Op", "Op") | ("Close", "Op") => 1,
2583 ("Ord", "Bin")
2584 | ("Bin", "Ord")
2585 | ("Bin", "Open")
2586 | ("Bin", "Inner")
2587 | ("Close", "Bin")
2588 | ("Inner", "Bin")
2589 | ("Bin", "Op") => -2,
2590 ("Ord", "Rel")
2591 | ("Rel", "Ord")
2592 | ("Op", "Rel")
2593 | ("Rel", "Open")
2594 | ("Rel", "Inner")
2595 | ("Close", "Rel")
2596 | ("Inner", "Rel")
2597 | ("Rel", "Op") => -3,
2598 ("Ord", "Inner")
2599 | ("Op", "Inner")
2600 | ("Close", "Inner")
2601 | ("Inner", "Inner")
2602 | ("Inner", "Open")
2603 | ("Punct", "Ord")
2604 | ("Punct", "Op")
2605 | ("Punct", "Rel")
2606 | ("Punct", "Open")
2607 | ("Punct", "Close")
2608 | ("Punct", "Punct")
2609 | ("Punct", "Inner")
2610 | ("Inner", "Ord")
2611 | ("Inner", "Punct") => -1,
2612 ("Inner", "Op") => 1,
2613 _ => 0,
2614 }
2615}
2616
2617fn m_atom_type(tag: &str) -> Option<&'static str> {
2619 match tag {
2620 "m:mfrac" => Some("Ord"),
2621 "m:marray" => Some("Inner"),
2622 "m:mspace" => Some("Ord"),
2623 _ => None,
2624 }
2625}
2626
2627fn is_embellisher_tag(tag: &str) -> bool {
2629 matches!(
2630 tag,
2631 "m:msub" | "m:msup" | "m:msubsup" | "m:munder" | "m:mover" | "m:munderover"
2632 )
2633}
2634
2635fn is_mrow_like(tag: &str) -> bool {
2637 matches!(
2638 tag,
2639 "m:mrow" | "m:mpadded" | "m:msqrt" | "m:mstyle" | "m:merror" | "m:mphantom" | "m:mtd"
2640 )
2641}
2642
2643fn is_invisible_op(text: &str) -> bool {
2645 !text.is_empty()
2646 && text
2647 .chars()
2648 .all(|c| matches!(c, '\u{200B}' | '\u{2061}' | '\u{2062}' | '\u{2063}'))
2649}
2650
2651fn fmt_em(val: f64) -> String {
2655 if val == 0.0 {
2656 "0em".to_string()
2657 } else {
2658 format!("{val:.3}em")
2659 }
2660}
2661
2662fn get_node_role(node: &NodeData) -> String {
2664 match node {
2665 NodeData::Element { tag, attributes, children } => {
2666 if is_embellisher_tag(tag) {
2667 if let Some(base) = children.first() {
2668 return get_node_role(base);
2669 }
2670 }
2671 attributes
2672 .as_ref()
2673 .and_then(|a| a.get("_role"))
2674 .cloned()
2675 .unwrap_or_default()
2676 },
2677 _ => String::new(),
2678 }
2679}
2680
2681fn get_node_tag(node: &NodeData) -> &str {
2682 match node {
2683 NodeData::Element { tag, .. } => tag,
2684 _ => "",
2685 }
2686}
2687
2688fn get_node_text(node: &NodeData) -> String {
2689 match node {
2690 NodeData::Text(t) => t.clone(),
2691 NodeData::Element { children, .. } => children.iter().map(get_node_text).collect(),
2692 _ => String::new(),
2693 }
2694}
2695
2696fn is_node_text_invisible_op(node: &NodeData) -> bool {
2700 fn check(node: &NodeData, seen_any: &mut bool) -> bool {
2701 match node {
2702 NodeData::Text(t) => {
2703 if !t.is_empty() {
2704 *seen_any = true;
2705 }
2706 t.chars()
2707 .all(|c| matches!(c, '\u{200B}' | '\u{2061}' | '\u{2062}' | '\u{2063}'))
2708 },
2709 NodeData::Element { children, .. } => children.iter().all(|c| check(c, seen_any)),
2710 _ => true,
2711 }
2712 }
2713 let mut seen_any = false;
2714 let ok = check(node, &mut seen_any);
2715 ok && seen_any
2716}
2717
2718fn set_node_attr(node: &mut NodeData, key: &str, value: &str) {
2719 if let NodeData::Element { attributes, .. } = node {
2720 let attrs = attributes.get_or_insert_with(HashMap::default);
2721 attrs.insert(key.to_string(), value.to_string());
2722 }
2723}
2724
2725fn get_node_attr(node: &NodeData, key: &str) -> Option<String> {
2726 match node {
2727 NodeData::Element { attributes, .. } => attributes.as_ref().and_then(|a| a.get(key)).cloned(),
2728 _ => None,
2729 }
2730}
2731
2732fn get_node_attr_f64(node: &NodeData, key: &str) -> f64 {
2733 match node {
2734 NodeData::Element { attributes, .. } => attributes
2735 .as_ref()
2736 .and_then(|a| a.get(key))
2737 .and_then(|v| v.strip_suffix("em"))
2738 .and_then(|v| v.parse::<f64>().ok())
2739 .unwrap_or(0.0),
2740 _ => 0.0,
2741 }
2742}
2743
2744#[derive(PartialEq, Clone, Copy)]
2747enum WalkType {
2748 Atom,
2749 Mrow,
2750 Other,
2751}
2752fn walk_type(tag: &str) -> WalkType {
2753 match tag {
2754 "m:mi" | "m:mo" | "m:mn" | "m:ms" | "m:mtext" => WalkType::Atom,
2755 "m:mrow" | "m:mpadded" | "m:msqrt" | "m:mstyle" | "m:merror" | "m:mphantom" | "m:mtd" => {
2756 WalkType::Mrow
2757 },
2758 _ => WalkType::Other,
2759 }
2760}
2761
2762fn node_at<'a>(root: &'a NodeData, path: &[usize]) -> &'a NodeData {
2766 let mut cur = root;
2767 for &i in path {
2768 match cur {
2769 NodeData::Element { children, .. } => cur = &children[i],
2770 _ => unreachable!("spacewalk path into non-element"),
2771 }
2772 }
2773 cur
2774}
2775
2776fn node_at_mut<'a>(root: &'a mut NodeData, path: &[usize]) -> &'a mut NodeData {
2777 let mut cur = root;
2778 for &i in path {
2779 match cur {
2780 NodeData::Element { children, .. } => cur = &mut children[i],
2781 _ => unreachable!("spacewalk path into non-element"),
2782 }
2783 }
2784 cur
2785}
2786
2787fn child_path(path: &[usize], i: usize) -> Vec<usize> {
2788 let mut p = path.to_vec();
2789 p.push(i);
2790 p
2791}
2792
2793fn descend_embellishers(root: &NodeData, mut path: Vec<usize>) -> Vec<usize> {
2796 loop {
2797 match node_at(root, &path) {
2798 NodeData::Element { tag, children, .. }
2799 if is_embellisher_tag(tag) && !children.is_empty() =>
2800 {
2801 path.push(0);
2802 },
2803 _ => return path,
2804 }
2805 }
2806}
2807
2808pub fn adjust_spacing(node: &mut NodeData) { space_walk(node, Vec::new()); }
2812
2813fn space_walk(root: &mut NodeData, path: Vec<usize>) {
2819 use std::collections::VecDeque;
2820 let (wt, nch) = match node_at(root, &path) {
2821 NodeData::Element { tag, children, .. } => (walk_type(tag), children.len()),
2822 _ => return,
2823 };
2824 match wt {
2825 WalkType::Atom => {},
2826 WalkType::Other => {
2827 for i in 0..nch {
2828 space_walk(root, child_path(&path, i));
2829 }
2830 },
2831 WalkType::Mrow => {
2832 let mut queue: VecDeque<Vec<usize>> = (0..nch).map(|i| child_path(&path, i)).collect();
2833 let mut first = None;
2835 while let Some(p) = queue.pop_front() {
2836 let unwrap = match node_at(root, &p) {
2837 NodeData::Element { tag, children, .. } if tag == "m:mrow" => Some(children.len()),
2838 _ => None,
2839 };
2840 match unwrap {
2841 Some(n) => {
2842 for i in (0..n).rev() {
2843 queue.push_front(child_path(&p, i));
2844 }
2845 },
2846 None => {
2847 first = Some(p);
2848 break;
2849 },
2850 }
2851 }
2852 let Some(mut prev) = first else { return };
2853 space_walk(root, prev.clone());
2854 while let Some(popped) = queue.pop_front() {
2855 let mut next = popped;
2856 let mut invisop: Option<Vec<usize>> = None;
2859 {
2860 let n = node_at(root, &next);
2861 if get_node_tag(n) == "m:mo" && is_node_text_invisible_op(n) {
2862 invisop = Some(next);
2863 match queue.pop_front() {
2864 Some(p) => next = p,
2865 None => break,
2866 }
2867 }
2868 }
2869 enum Kind {
2870 Mrow(usize),
2871 Script(usize),
2872 Plain,
2873 }
2874 let kind = match node_at(root, &next) {
2875 NodeData::Element { tag, children, .. } => {
2876 if tag == "m:mrow" {
2877 Kind::Mrow(children.len())
2878 } else if !children.is_empty()
2879 && (tag.starts_with("m:msup")
2880 || tag.starts_with("m:msub")
2881 || tag.starts_with("m:munder")
2882 || tag.starts_with("m:mover")
2883 || tag.starts_with("m:mmultiscripts"))
2884 {
2885 Kind::Script(children.len())
2890 } else {
2891 Kind::Plain
2892 }
2893 },
2894 _ => Kind::Plain,
2895 };
2896 match kind {
2897 Kind::Mrow(n) => {
2898 for i in (0..n).rev() {
2900 queue.push_front(child_path(&next, i));
2901 }
2902 if let Some(iv) = invisop {
2903 queue.push_front(iv);
2904 }
2905 continue;
2906 },
2907 Kind::Script(n) => {
2908 for i in 1..n {
2910 space_walk(root, child_path(&next, i));
2911 }
2912 queue.push_front(child_path(&next, 0));
2913 if let Some(iv) = invisop {
2914 queue.push_front(iv);
2915 }
2916 continue;
2917 },
2918 Kind::Plain => {},
2919 }
2920 space_walk(root, next.clone());
2921 adjust_pair(root, &prev, &next, invisop.as_deref());
2922 prev = next;
2923 }
2924 },
2925 }
2926}
2927
2928fn adjust_pair(root: &mut NodeData, prev: &[usize], next: &[usize], invisop: Option<&[usize]>) {
2931 let iprev = descend_embellishers(root, prev.to_vec());
2932 let inext = descend_embellishers(root, next.to_vec());
2933
2934 let prev_req_right = get_node_attr_f64(node_at(root, prev), "_rpadding");
2937 let next_req_left = get_node_attr_f64(node_at(root, next), "_lpadding");
2938 let (iprev_tag, prev_role, prev_dict_right) = {
2939 let n = node_at(root, &iprev);
2940 (
2941 get_node_tag(n).to_string(),
2942 get_node_attr(n, "_role").unwrap_or_else(|| "ATOM".to_string()),
2943 get_node_attr_f64(n, "_rspace"),
2944 )
2945 };
2946 let (inext_tag, next_role, next_dict_left) = {
2947 let n = node_at(root, &inext);
2948 (
2949 get_node_tag(n).to_string(),
2950 get_node_attr(n, "_role").unwrap_or_else(|| "ATOM".to_string()),
2951 get_node_attr_f64(n, "_lspace"),
2952 )
2953 };
2954 let prev_type = m_atom_type(&iprev_tag).unwrap_or_else(|| role_to_atom_type(&prev_role));
2955 let next_type = m_atom_type(&inext_tag).unwrap_or_else(|| role_to_atom_type(&next_role));
2956 let tex_code = atompair_spacing(prev_type, next_type);
2957 let tex_space = TEX_SPACING[tex_code.unsigned_abs() as usize];
2958 let target = prev_req_right + next_req_left + tex_space;
2959 let default = prev_dict_right + next_dict_left;
2960 if (target - default).abs() <= SPACING_EPSILON {
2961 return;
2962 }
2963
2964 let prev_tag = get_node_tag(node_at(root, prev)).to_string();
2965 let next_tag = get_node_tag(node_at(root, next)).to_string();
2966 if target < 0.0 {
2972 let sizeable = match node_at(root, prev) {
2973 NodeData::Element { tag, attributes, .. } if walk_type(tag) == WalkType::Atom => Some(
2974 attributes
2975 .as_ref()
2976 .and_then(|a| a.get("class"))
2977 .cloned()
2978 .unwrap_or_default(),
2979 ),
2980 _ => None,
2981 };
2982 if let Some(class) = sizeable {
2983 let text = get_node_text(node_at(root, prev));
2984 let font = latexml_core::common::font::Font::math_default();
2985 let (w, _h, _d) = font.compute_string_size(&text, Default::default());
2986 let mut w_sp = w.0;
2987 if class.contains("mathscript") {
2991 w_sp = w_sp.max(10 * 65535);
2992 }
2993 let mut reqw = (w_sp as f64 / 65536.0) / 10.0 + target;
2994 if reqw < 0.0 {
2995 reqw = 0.0;
2996 }
2997 let slot = node_at_mut(root, prev);
2998 let old = std::mem::replace(slot, NodeData::Text(String::new()));
2999 *slot = NodeData::Element {
3000 tag: "m:mpadded".to_string(),
3001 attributes: Some(HashMap::from_iter([("width".to_string(), fmt_em(reqw))])),
3002 children: vec![old],
3003 };
3004 }
3005 } else if prev_tag == "m:mspace" || next_tag == "m:mspace" {
3006 let target_path = if prev_tag == "m:mspace" { prev } else { next };
3008 let n = node_at_mut(root, target_path);
3009 let old_w = match n {
3010 NodeData::Element { attributes, .. } => attributes
3011 .as_ref()
3012 .and_then(|a| a.get("width"))
3013 .map(|w| super::get_xm_hint_spacing(w))
3014 .unwrap_or(0.0),
3015 _ => 0.0,
3016 };
3017 set_node_attr(n, "width", &fmt_em(target + old_w));
3018 } else if let Some(iv) = invisop {
3019 set_node_attr(node_at_mut(root, iv), "lspace", &fmt_em(target));
3020 } else if prev_tag == "m:mo" && next_tag == "m:mo" {
3021 let p = prev_dict_right;
3023 let n = next_dict_left;
3024 let rem = target - n;
3025 if rem >= 0.0 {
3026 let v = if rem > SPACING_EPSILON { rem } else { 0.0 };
3027 set_node_attr(node_at_mut(root, prev), "rspace", &fmt_em(v));
3028 } else {
3029 let rem = target - p;
3030 if rem >= 0.0 {
3031 let v = if rem > SPACING_EPSILON { rem } else { 0.0 };
3032 set_node_attr(node_at_mut(root, next), "lspace", &fmt_em(v));
3033 } else {
3034 let rem = target / 2.0;
3037 if rem != p {
3038 set_node_attr(
3039 node_at_mut(root, prev),
3040 "rspace",
3041 &format!("{}em", perl_num(rem)),
3042 );
3043 }
3044 if rem != n {
3045 set_node_attr(
3046 node_at_mut(root, next),
3047 "lspace",
3048 &format!("{}em", perl_num(rem)),
3049 );
3050 }
3051 }
3052 }
3053 } else if prev_tag == "m:mo" {
3054 set_node_attr(node_at_mut(root, prev), "rspace", &fmt_em(target));
3055 } else if next_tag == "m:mo" {
3056 set_node_attr(node_at_mut(root, next), "lspace", &fmt_em(target));
3057 } else if (target - default).abs() > SPACING_FUDGE {
3058 Info!(
3059 "ignored",
3060 "spacing",
3061 "No place to set spacing to {target} (default {default})"
3062 );
3063 }
3064}
3065
3066pub fn clean_internal_attrs(node: &mut NodeData) {
3068 if let NodeData::Element { attributes, children, .. } = node {
3069 if let Some(attrs) = attributes {
3070 attrs.remove("_role");
3071 attrs.remove("_lspace");
3072 attrs.remove("_rspace");
3073 attrs.remove("_largeop");
3074 attrs.remove("_lpadding");
3075 attrs.remove("_rpadding");
3076 attrs.remove("_ignorable");
3077 if attrs.is_empty() {
3078 *attributes = None;
3079 }
3080 }
3081 for child in children {
3082 clean_internal_attrs(child);
3083 }
3084 }
3085}
3086
3087#[cfg(test)]
3088mod tests {
3089 use rustc_hash::FxHashMap as HashMap;
3090
3091 use super::*;
3092
3093 #[test]
3094 fn math_style_step_down_monotone_saturates_at_scriptscript() {
3095 assert_eq!(MathStyle::Display.step_down(), MathStyle::Text);
3096 assert_eq!(MathStyle::Text.step_down(), MathStyle::Script);
3097 assert_eq!(MathStyle::Script.step_down(), MathStyle::ScriptScript);
3098 assert_eq!(MathStyle::ScriptScript.step_down(), MathStyle::ScriptScript);
3099 }
3100
3101 #[test]
3102 fn math_style_script_step_collapses_display_and_text() {
3103 assert_eq!(MathStyle::Display.script_step(), MathStyle::Script);
3104 assert_eq!(MathStyle::Text.script_step(), MathStyle::Script);
3105 assert_eq!(MathStyle::Script.script_step(), MathStyle::ScriptScript);
3106 assert_eq!(
3107 MathStyle::ScriptScript.script_step(),
3108 MathStyle::ScriptScript
3109 );
3110 }
3111
3112 #[test]
3113 fn math_style_size_percent_matches_tex_tradition() {
3114 assert_eq!(MathStyle::Display.size_percent(), "100%");
3115 assert_eq!(MathStyle::Text.size_percent(), "100%");
3116 assert_eq!(MathStyle::Script.size_percent(), "70%");
3117 assert_eq!(MathStyle::ScriptScript.size_percent(), "50%");
3118 }
3119
3120 #[test]
3121 fn invisible_times_roundtrip() {
3122 set_invisible_times(false);
3123 assert!(!get_invisible_times());
3124 set_invisible_times(true);
3125 assert!(get_invisible_times());
3126 }
3127
3128 #[test]
3129 fn embellishing_role_matches_canonical_set() {
3130 for r in [
3131 "SUPERSCRIPTOP",
3132 "SUBSCRIPTOP",
3133 "OVERACCENT",
3134 "UNDERACCENT",
3135 "MODIFIER",
3136 "MODIFIEROP",
3137 ] {
3138 assert!(is_embellishing_role(r), "{} should embellish", r);
3139 }
3140 }
3141
3142 #[test]
3143 fn embellishing_role_rejects_others() {
3144 for r in ["ADDOP", "MULOP", "ATOM", "UNKNOWN", ""] {
3145 assert!(!is_embellishing_role(r), "{} should not embellish", r);
3146 }
3147 }
3148
3149 #[test]
3150 fn default_token_content_maps_invisible_chars() {
3151 assert_eq!(default_token_content("MULOP"), Some("\u{2062}"));
3152 assert_eq!(default_token_content("ADDOP"), Some("\u{2064}"));
3153 assert_eq!(default_token_content("PUNCT"), Some("\u{2063}"));
3154 }
3155
3156 #[test]
3157 fn default_token_content_none_for_other_roles() {
3158 assert_eq!(default_token_content("ATOM"), None);
3159 assert_eq!(default_token_content(""), None);
3160 assert_eq!(default_token_content("RELOP"), None);
3161 }
3162
3163 #[test]
3164 fn clean_internal_attrs_removes_underscore_attrs() {
3165 let mut node = NodeData::Element {
3166 tag: "mrow".to_string(),
3167 attributes: Some(HashMap::from_iter([
3168 ("_role".to_string(), "MULOP".to_string()),
3169 ("_lspace".to_string(), "4".to_string()),
3170 ("keep".to_string(), "yes".to_string()),
3171 ])),
3172 children: vec![],
3173 };
3174 clean_internal_attrs(&mut node);
3175 if let NodeData::Element { attributes, .. } = &node {
3176 let attrs = attributes
3177 .as_ref()
3178 .expect("still has the non-internal attr");
3179 assert_eq!(attrs.len(), 1);
3180 assert_eq!(attrs.get("keep").map(String::as_str), Some("yes"));
3181 } else {
3182 panic!("expected element");
3183 }
3184 }
3185
3186 #[test]
3187 fn clean_internal_attrs_unsets_attributes_when_empty() {
3188 let mut node = NodeData::Element {
3189 tag: "mrow".to_string(),
3190 attributes: Some(HashMap::from_iter([
3191 ("_role".to_string(), "MULOP".to_string()),
3192 ("_largeop".to_string(), "true".to_string()),
3193 ])),
3194 children: vec![],
3195 };
3196 clean_internal_attrs(&mut node);
3197 if let NodeData::Element { attributes, .. } = &node {
3198 assert!(attributes.is_none());
3200 } else {
3201 panic!("expected element");
3202 }
3203 }
3204
3205 #[test]
3206 fn clean_internal_attrs_recurses_into_children() {
3207 let mut node = NodeData::Element {
3208 tag: "mrow".to_string(),
3209 attributes: None,
3210 children: vec![NodeData::Element {
3211 tag: "mi".to_string(),
3212 attributes: Some(HashMap::from_iter([(
3213 "_rspace".to_string(),
3214 "1".to_string(),
3215 )])),
3216 children: vec![],
3217 }],
3218 };
3219 clean_internal_attrs(&mut node);
3220 if let NodeData::Element { children, .. } = &node {
3221 if let NodeData::Element { attributes, .. } = &children[0] {
3222 assert!(attributes.is_none(), "recursion cleared child's only attr");
3223 } else {
3224 panic!("expected element child");
3225 }
3226 } else {
3227 panic!("expected element root");
3228 }
3229 }
3230
3231 #[test]
3232 fn clean_internal_attrs_ignores_text_nodes() {
3233 let mut node = NodeData::Text("x".to_string());
3234 clean_internal_attrs(&mut node);
3235 match &node {
3236 NodeData::Text(s) => assert_eq!(s, "x"),
3237 _ => panic!("expected text untouched"),
3238 }
3239 }
3240 #[test]
3241 fn test_role_to_atom_type() {
3242 assert_eq!(role_to_atom_type("ID"), "Ord");
3244 assert_eq!(role_to_atom_type("NUMBER"), "Ord");
3245 assert_eq!(role_to_atom_type("ADDOP"), "Bin");
3246 assert_eq!(role_to_atom_type("RELOP"), "Rel");
3247 assert_eq!(role_to_atom_type("OPEN"), "Open");
3248 assert_eq!(role_to_atom_type("CLOSE"), "Close");
3249 assert_eq!(role_to_atom_type("SUMOP"), "Op");
3250 assert_eq!(role_to_atom_type("PUNCT"), "Punct");
3251 assert_eq!(role_to_atom_type("ARRAY"), "Inner");
3252 assert_eq!(role_to_atom_type("no-such-role"), "Ord");
3253 }
3254
3255 #[test]
3256 fn test_atompair_spacing() {
3257 assert_eq!(atompair_spacing("Ord", "Op"), 1);
3259 assert_eq!(atompair_spacing("Ord", "Bin"), -2);
3260 assert_eq!(atompair_spacing("Rel", "Ord"), -3);
3261 assert_eq!(atompair_spacing("Open", "Ord"), 0);
3262 assert_eq!(atompair_spacing("Open", "Open"), 0);
3263 assert_eq!(atompair_spacing("Punct", "Bin"), 0);
3264 assert_eq!(atompair_spacing("Inner", "Ord"), -1);
3268 assert_eq!(atompair_spacing("Inner", "Op"), 1);
3269 assert_eq!(atompair_spacing("Inner", "Bin"), -2);
3270 assert_eq!(atompair_spacing("Inner", "Rel"), -3);
3271 assert_eq!(atompair_spacing("Inner", "Open"), -1);
3272 assert_eq!(atompair_spacing("Inner", "Close"), 0);
3273 assert_eq!(atompair_spacing("Inner", "Punct"), -1);
3274 assert_eq!(atompair_spacing("Inner", "Inner"), -1);
3275 }
3276
3277 #[test]
3278 fn test_fmt_em() {
3279 assert_eq!(fmt_em(0.0), "0em");
3281 assert_eq!(fmt_em(1.0), "1.000em");
3282 assert_eq!(fmt_em(0.167), "0.167em");
3283 assert_eq!(fmt_em(0.33), "0.330em");
3284 assert_eq!(fmt_em(1.2), "1.200em");
3285 }
3286}