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.clone());
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 if let Some(cs) = colspan {
1613 td_attrs.insert("columnspan".to_string(), cs);
1614 }
1615 if let Some(rs) = rowspan {
1616 td_attrs.insert("rowspan".to_string(), rs);
1617 }
1618
1619 let cell_children = element_children(&cell_node);
1620 let cell_content = if cell_children.is_empty() {
1621 vec![]
1622 } else {
1623 filter_row(cell_children.iter().map(|c| pmml(doc, c)).collect())
1625 };
1626
1627 cols.push(NodeData::Element {
1628 tag: "m:mtd".to_string(),
1629 attributes: if td_attrs.is_empty() {
1630 None
1631 } else {
1632 Some(td_attrs)
1633 },
1634 children: cell_content,
1635 });
1636 }
1637 if nc > ncols {
1638 ncols = nc;
1639 }
1640 nrows += 1;
1641 rows.push(NodeData::Element {
1642 tag: "m:mtr".to_string(),
1643 attributes: None,
1644 children: cols,
1645 });
1646 }
1647
1648 let emit_rowsep = nrows >= 2;
1650 let emit_colsep = ncols >= 2;
1651
1652 let mut table_attrs = HashMap::default();
1653 if align != "axis" {
1654 table_attrs.insert("align".to_string(), align.to_string());
1655 }
1656 if emit_rowsep {
1657 table_attrs.insert("rowspacing".to_string(), rowsep);
1658 }
1659 if emit_colsep {
1660 table_attrs.insert("columnspacing".to_string(), colsep);
1661 }
1662 if let Some(w) = width {
1663 table_attrs.insert("width".to_string(), w);
1664 }
1665 if CURRENT_STYLE.with(|s| s.get()) == MathStyle::Display {
1667 table_attrs.insert("displaystyle".to_string(), "true".to_string());
1668 }
1669
1670 NodeData::Element {
1671 tag: "m:mtable".to_string(),
1672 attributes: if table_attrs.is_empty() {
1673 None
1674 } else {
1675 Some(table_attrs)
1676 },
1677 children: rows,
1678 }
1679}
1680
1681fn pmml_script_simple(doc: &PostDocument, tag: &str, base: &Node, script: &Node) -> NodeData {
1686 NodeData::Element {
1687 tag: tag.to_string(),
1688 attributes: None,
1689 children: vec![pmml(doc, base), pmml_scriptsize(doc, script)],
1690 }
1691}
1692
1693fn pmml_scriptsize(doc: &PostDocument, node: &Node) -> NodeData {
1697 let old = CURRENT_STYLE.with(|s| {
1698 let o = s.get();
1699 s.set(o.script_step());
1700 o
1701 });
1702 let r = pmml(doc, node);
1703 CURRENT_STYLE.with(|s| s.set(old));
1704 r
1705}
1706
1707fn pmml_smaller(doc: &PostDocument, node: &Node) -> NodeData {
1710 let old = CURRENT_STYLE.with(|s| {
1711 let o = s.get();
1712 s.set(o.step_down());
1713 o
1714 });
1715 let r = pmml(doc, node);
1716 CURRENT_STYLE.with(|s| s.set(old));
1717 r
1718}
1719
1720fn pmml_infix(doc: &PostDocument, op: &Node, args: &[Node]) -> NodeData {
1724 match args {
1748 [] => pmml(doc, op),
1749 [arg] => {
1761 let op_prefix = if op.get_name() == "XMTok" {
1762 pmml_token_inner(doc, op, Some("OPERATOR"))
1763 } else {
1764 pmml(doc, op)
1765 };
1766 pmml_row(vec![op_prefix, pmml(doc, arg)])
1767 },
1768 [first, rest @ ..] => {
1770 let op_mml = pmml(doc, op);
1771 let mut items = vec![pmml(doc, first)];
1772 for arg in rest {
1773 items.push(op_mml.clone());
1774 items.push(pmml(doc, arg));
1775 }
1776 pmml_row(items)
1777 },
1778 }
1779}
1780
1781fn is_absent_operand(node: &Node) -> bool {
1788 if node.get_name() != "XMTok" {
1789 return false;
1790 }
1791 node.get_attribute("meaning").as_deref() == Some("absent")
1792}
1793
1794fn pmml_summation(doc: &PostDocument, op: &Node, args: &[Node]) -> NodeData {
1798 let op_mml = pmml(doc, op);
1799 let needs_apply = !op_base_is_mo(&op_mml);
1804 let mut items = vec![op_mml];
1805 if needs_apply {
1806 items.push(pmml_mo_str("\u{2061}")); }
1808 for arg in args {
1809 items.push(pmml(doc, arg));
1810 }
1811 pmml_row(items)
1812}
1813
1814fn pmml_parenthesize(doc: &PostDocument, op: &Node, args: &[Node]) -> NodeData {
1818 let mut items = vec![pmml(doc, op)];
1819 for arg in args {
1820 items.push(pmml(doc, arg));
1821 }
1822 pmml_row(items)
1823}
1824
1825type ScriptPair = (Option<Node>, Option<Node>);
1833
1834fn pmml_script_full(doc: &PostDocument, op: &Node, base: &Node, script: &Node) -> NodeData {
1838 let (inner_base, pre_scripts, mid_scripts, post_scripts, emb_right) =
1839 pmml_script_decipher(doc, op, base, script);
1840
1841 let ostyle = CURRENT_STYLE.with(|s| s.get());
1847 let bstyle = inner_base
1848 .get_attribute("mathstyle")
1849 .as_deref()
1850 .and_then(MathStyle::from_attr);
1851 if let Some(b) = bstyle {
1852 CURRENT_STYLE.with(|s| s.set(b));
1853 }
1854 let base_mml = pmml(doc, &inner_base);
1855 CURRENT_STYLE.with(|s| s.set(ostyle));
1856
1857 let base_mml = apply_mid_scripts(doc, base_mml, &mid_scripts, emb_right.as_ref());
1859
1860 let layout = apply_multi_scripts(doc, base_mml, &pre_scripts, &post_scripts);
1862 match bstyle {
1863 Some(b) if b != ostyle => NodeData::Element {
1864 tag: "m:mstyle".to_string(),
1865 attributes: Some(HashMap::from_iter([(
1866 "displaystyle".to_string(),
1867 (if b == MathStyle::Display {
1868 "true"
1869 } else {
1870 "false"
1871 })
1872 .to_string(),
1873 )])),
1874 children: vec![layout],
1875 },
1876 _ => layout,
1877 }
1878}
1879
1880fn pmml_script_decipher(
1884 doc: &PostDocument,
1885 op: &Node,
1886 base: &Node,
1887 script: &Node,
1888) -> (
1889 Node,
1890 Vec<ScriptPair>,
1891 Vec<ScriptPair>,
1892 Vec<ScriptPair>,
1893 Option<Node>,
1894) {
1895 let mut pre_scripts: Vec<ScriptPair> = Vec::new();
1896 let mut mid_scripts: Vec<ScriptPair> = Vec::new();
1897 let mut post_scripts: Vec<ScriptPair> = Vec::new();
1898 let mut emb_right: Option<Node> = None;
1903 let mut saw_mid = false;
1904
1905 let (mut pre_level, mut mid_level, mut post_level) =
1910 ("0".to_string(), "0".to_string(), "0".to_string());
1911
1912 let (pos, level) = parse_scriptpos(op);
1913 let is_sub = op.get_attribute("role").unwrap_or_default().contains("SUB");
1914
1915 let pair = if is_sub {
1917 (Some(script.clone()), None)
1918 } else {
1919 (None, Some(script.clone()))
1920 };
1921 match pos {
1922 ScriptPos::Pre => {
1923 pre_scripts.push(pair);
1924 pre_level = level;
1925 },
1926 ScriptPos::Mid => {
1927 saw_mid = true;
1928 mid_scripts.push(pair);
1929 mid_level = level;
1930 },
1931 ScriptPos::Post => {
1932 post_scripts.push(pair);
1933 post_level = level;
1934 },
1935 }
1936
1937 let mut current_base = base.clone();
1939 loop {
1940 let Some(realized) = doc.realize_xm_node_branch(¤t_base, XMBranch::Presentation) else {
1944 break;
1945 };
1946 current_base = realized;
1947
1948 if !doc.is_qname(¤t_base, "ltx:XMApp") {
1949 break;
1950 }
1951
1952 let children = element_children(¤t_base);
1953 if children.len() < 3 {
1954 break;
1955 }
1956
1957 let xop = &children[0];
1958 if !doc.is_qname(xop, "ltx:XMTok") {
1959 break;
1960 }
1961
1962 let xrole = xop.get_attribute("role").unwrap_or_default();
1963 let is_script_op = xrole.contains("SUPERSCRIPTOP") || xrole.contains("SUBSCRIPTOP");
1964 if !is_script_op {
1965 break;
1966 }
1967
1968 let xbase = children[1].clone();
1969 let xscript = &children[2];
1970 let (xpos, xlevel) = parse_scriptpos(xop);
1971 let x_is_sub = xrole.contains("SUB");
1972
1973 match xpos {
1974 ScriptPos::Pre => place_script(
1978 &mut pre_scripts,
1979 &mut pre_level,
1980 xlevel,
1981 x_is_sub,
1982 xscript.clone(),
1983 false,
1984 ),
1985 ScriptPos::Mid => {
1986 saw_mid = true;
1987 place_script(
1988 &mut mid_scripts,
1989 &mut mid_level,
1990 xlevel,
1991 x_is_sub,
1992 xscript.clone(),
1993 true,
1994 );
1995 },
1996 ScriptPos::Post => {
1997 if saw_mid {
2006 emb_right = Some(xscript.clone());
2007 break;
2008 }
2009 place_script(
2010 &mut post_scripts,
2011 &mut post_level,
2012 xlevel,
2013 x_is_sub,
2014 xscript.clone(),
2015 true,
2016 );
2017 },
2018 }
2019
2020 current_base = xbase;
2021 }
2022
2023 (
2024 current_base,
2025 pre_scripts,
2026 mid_scripts,
2027 post_scripts,
2028 emb_right,
2029 )
2030}
2031
2032#[derive(Clone, Copy, PartialEq, Eq, Debug)]
2035enum ScriptPos {
2036 Pre,
2038 Mid,
2040 Post,
2042}
2043
2044fn parse_scriptpos(op: &Node) -> (ScriptPos, String) {
2054 let raw = op
2055 .get_attribute("scriptpos")
2056 .unwrap_or_else(|| "post0".to_string());
2057 let (pos, rest) = if let Some(rest) = raw.strip_prefix("pre") {
2058 (ScriptPos::Pre, rest)
2059 } else if let Some(rest) = raw.strip_prefix("mid") {
2060 (ScriptPos::Mid, rest)
2061 } else if let Some(rest) = raw.strip_prefix("post") {
2062 (ScriptPos::Post, rest)
2063 } else {
2064 (ScriptPos::Post, raw.as_str())
2065 };
2066 if rest.is_empty() || rest.bytes().all(|b| b.is_ascii_digit()) {
2069 (pos, rest.to_string())
2070 } else {
2071 (ScriptPos::Post, String::new())
2072 }
2073}
2074
2075fn place_script(
2096 list: &mut Vec<ScriptPair>,
2097 level: &mut String,
2098 new_level: String,
2099 is_sub: bool,
2100 script: Node,
2101 at_front: bool,
2102) {
2103 let slot_taken = |p: &ScriptPair| if is_sub { p.0.is_some() } else { p.1.is_some() };
2104 let current = if at_front { list.first() } else { list.last() };
2105 if current.is_none_or(slot_taken) || *level != new_level {
2106 if at_front {
2107 list.insert(0, (None, None));
2108 } else {
2109 list.push((None, None));
2110 }
2111 }
2112 let pair = if at_front {
2113 list.first_mut()
2114 } else {
2115 list.last_mut()
2116 }
2117 .expect("a pair was just ensured to exist");
2118 if is_sub {
2119 pair.0 = Some(script);
2120 } else {
2121 pair.1 = Some(script);
2122 }
2123 *level = new_level;
2124}
2125
2126fn apply_mid_scripts(
2128 doc: &PostDocument,
2129 mut base: NodeData,
2130 mid_scripts: &[ScriptPair],
2131 emb_right: Option<&Node>,
2132) -> NodeData {
2133 for (sub_opt, sup_opt) in mid_scripts {
2134 let under = sub_opt
2135 .as_ref()
2136 .map(|s| pmml_scriptsize_padded(doc, s, emb_right));
2137 let over = sup_opt
2138 .as_ref()
2139 .map(|s| pmml_scriptsize_padded(doc, s, emb_right));
2140
2141 base = match (under, over) {
2142 (Some(u), None) => NodeData::Element {
2143 tag: "m:munder".to_string(),
2144 attributes: None,
2145 children: vec![base, u],
2146 },
2147 (None, Some(o)) => NodeData::Element {
2148 tag: "m:mover".to_string(),
2149 attributes: None,
2150 children: vec![base, o],
2151 },
2152 (Some(u), Some(o)) => NodeData::Element {
2153 tag: "m:munderover".to_string(),
2154 attributes: None,
2155 children: vec![base, u, o],
2156 },
2157 (None, None) => base,
2158 };
2159 }
2160 base
2161}
2162
2163fn pmml_scriptsize_padded(doc: &PostDocument, script: &Node, emb_right: Option<&Node>) -> NodeData {
2176 let script_mml = pmml_scriptsize(doc, script);
2177 match emb_right {
2178 None => script_mml,
2179 Some(emb) => NodeData::Element {
2180 tag: "m:mrow".to_string(),
2181 attributes: None,
2182 children: vec![script_mml, NodeData::Element {
2183 tag: "m:mphantom".to_string(),
2184 attributes: None,
2185 children: vec![pmml_scriptsize(doc, emb)],
2186 }],
2187 },
2188 }
2189}
2190
2191fn apply_multi_scripts(
2195 doc: &PostDocument,
2196 base: NodeData,
2197 pre_scripts: &[ScriptPair],
2198 post_scripts: &[ScriptPair],
2199) -> NodeData {
2200 let none_mml = || NodeData::Element {
2205 tag: "m:mrow".to_string(),
2206 attributes: None,
2207 children: vec![],
2208 };
2209
2210 if !pre_scripts.is_empty() {
2211 let mut children = vec![base];
2213 for (sub_opt, sup_opt) in post_scripts {
2214 children.push(
2215 sub_opt
2216 .as_ref()
2217 .map(|s| pmml_scriptsize(doc, s))
2218 .unwrap_or_else(none_mml),
2219 );
2220 children.push(
2221 sup_opt
2222 .as_ref()
2223 .map(|s| pmml_scriptsize(doc, s))
2224 .unwrap_or_else(none_mml),
2225 );
2226 }
2227 children.push(NodeData::Element {
2228 tag: "m:mprescripts".to_string(),
2229 attributes: None,
2230 children: vec![],
2231 });
2232 for (sub_opt, sup_opt) in pre_scripts {
2233 children.push(
2234 sub_opt
2235 .as_ref()
2236 .map(|s| pmml_scriptsize(doc, s))
2237 .unwrap_or_else(none_mml),
2238 );
2239 children.push(
2240 sup_opt
2241 .as_ref()
2242 .map(|s| pmml_scriptsize(doc, s))
2243 .unwrap_or_else(none_mml),
2244 );
2245 }
2246 NodeData::Element {
2247 tag: "m:mmultiscripts".to_string(),
2248 attributes: None,
2249 children,
2250 }
2251 } else if post_scripts.len() > 1 {
2252 let mut children = vec![base];
2254 for (sub_opt, sup_opt) in post_scripts {
2255 children.push(
2256 sub_opt
2257 .as_ref()
2258 .map(|s| pmml_scriptsize(doc, s))
2259 .unwrap_or_else(none_mml),
2260 );
2261 children.push(
2262 sup_opt
2263 .as_ref()
2264 .map(|s| pmml_scriptsize(doc, s))
2265 .unwrap_or_else(none_mml),
2266 );
2267 }
2268 NodeData::Element {
2269 tag: "m:mmultiscripts".to_string(),
2270 attributes: None,
2271 children,
2272 }
2273 } else if post_scripts.is_empty() {
2274 base
2275 } else {
2276 let (sub_opt, sup_opt) = &post_scripts[0];
2278 match (sub_opt, sup_opt) {
2279 (Some(sub_node), None) => NodeData::Element {
2280 tag: "m:msub".to_string(),
2281 attributes: None,
2282 children: vec![base, pmml_scriptsize(doc, sub_node)],
2283 },
2284 (None, Some(sup_node)) => NodeData::Element {
2285 tag: "m:msup".to_string(),
2286 attributes: None,
2287 children: vec![base, pmml_scriptsize(doc, sup_node)],
2288 },
2289 (Some(sub_node), Some(sup_node)) => NodeData::Element {
2290 tag: "m:msubsup".to_string(),
2291 attributes: None,
2292 children: vec![
2293 base,
2294 pmml_scriptsize(doc, sub_node),
2295 pmml_scriptsize(doc, sup_node),
2296 ],
2297 },
2298 (None, None) => base,
2299 }
2300 }
2301}
2302
2303fn pmml_cfrac(doc: &PostDocument, op: &Node, numer: &Node, denom: &Node) -> NodeData {
2312 if op.get_attribute("name").as_deref() == Some("cfrac-inline") {
2315 return pmml_row(do_cfrac(doc, numer, denom));
2316 }
2317 NodeData::Element {
2318 tag: "m:mfrac".to_string(),
2319 attributes: None,
2320 children: vec![pmml_smaller(doc, numer), pmml_smaller(doc, denom)],
2321 }
2322}
2323
2324fn do_cfrac(doc: &PostDocument, numer: &Node, denom: &Node) -> Vec<NodeData> {
2330 if doc.is_qname(denom, "ltx:XMApp") {
2331 let dchildren = element_children(denom);
2332 if dchildren.len() >= 2 {
2333 let denomop = &dchildren[0];
2334 let denomargs = &dchildren[1..];
2335 if denomop.get_attribute("role").as_deref() == Some("ADDOP")
2336 || denomop.get_content() == "\u{22EF}"
2337 {
2338 let (rest, last) = denomargs.split_at(denomargs.len() - 1);
2339 let last = &last[0];
2340 if !rest.is_empty() {
2341 let curr = NodeData::Element {
2342 tag: "m:mfrac".to_string(),
2343 attributes: None,
2344 children: vec![pmml_smaller(doc, numer), NodeData::Element {
2345 tag: "m:mrow".to_string(),
2346 attributes: None,
2347 children: vec![
2348 if rest.len() > 1 {
2349 pmml_infix(doc, denomop, rest)
2350 } else {
2351 pmml_smaller(doc, &rest[0])
2352 },
2353 pmml_smaller(doc, denomop),
2354 ],
2355 }],
2356 };
2357 if last.get_content() == "\u{22EF}" {
2358 return vec![curr, pmml_smaller(doc, last)];
2360 } else if doc.is_qname(last, "ltx:XMApp") {
2361 let lchildren = element_children(last);
2362 if lchildren.len() >= 2 {
2363 let lastop = &lchildren[0];
2364 let lastargs = &lchildren[1..];
2365 if lastop.get_attribute("meaning").as_deref() == Some("continued-fraction")
2366 && lastargs.len() >= 2
2367 {
2368 let mut out = vec![curr];
2370 out.extend(do_cfrac(doc, &lastargs[0], &lastargs[1]));
2371 return out;
2372 } else if lastop.get_content() == "\u{2062}"
2373 && lastargs.len() == 2
2374 && lastargs[0].get_content() == "\u{22EF}"
2375 {
2376 return vec![
2378 curr,
2379 pmml_smaller(doc, &lastargs[0]),
2380 pmml_smaller(doc, &lastargs[1]),
2381 ];
2382 }
2383 }
2384 }
2385 }
2386 }
2387 }
2388 }
2389 vec![NodeData::Element {
2390 tag: "m:mfrac".to_string(),
2391 attributes: None,
2392 children: vec![pmml_smaller(doc, numer), pmml_smaller(doc, denom)],
2393 }]
2394}
2395
2396fn op_base_is_mo(node: &NodeData) -> bool {
2406 let mut cur = node;
2407 loop {
2408 let NodeData::Element { tag, children, .. } = cur else {
2409 return false;
2410 };
2411 if tag == "m:mo" {
2412 return true;
2413 }
2414 if matches!(
2420 tag.as_str(),
2421 "m:msub"
2422 | "m:msup"
2423 | "m:msubsup"
2424 | "m:munder"
2425 | "m:mover"
2426 | "m:munderover"
2427 | "m:mprescripts"
2428 | "m:mstyle"
2429 ) {
2430 match children.first() {
2431 Some(child) => cur = child,
2432 None => return false,
2433 }
2434 } else {
2435 return false;
2436 }
2437 }
2438}
2439
2440fn filter_row(items: Vec<NodeData>) -> Vec<NodeData> {
2442 items
2443 .into_iter()
2444 .filter(|i| {
2445 !matches!(i, NodeData::Element { attributes: Some(a), .. } if a.contains_key("_ignorable"))
2446 })
2447 .collect()
2448}
2449
2450fn pmml_row(children: Vec<NodeData>) -> NodeData {
2451 let children = filter_row(children);
2453 if children.len() == 1 {
2454 children.into_iter().next().unwrap()
2455 } else {
2456 NodeData::Element {
2457 tag: "m:mrow".to_string(),
2458 attributes: None,
2459 children,
2460 }
2461 }
2462}
2463
2464fn pmml_mo_str(text: &str) -> NodeData {
2466 NodeData::Element {
2467 tag: "m:mo".to_string(),
2468 attributes: None,
2469 children: vec![NodeData::Text(text.to_string())],
2470 }
2471}
2472
2473fn pmml_error(msg: &str) -> NodeData {
2475 NodeData::Element {
2476 tag: "m:merror".to_string(),
2477 attributes: None,
2478 children: vec![NodeData::Element {
2479 tag: "m:mtext".to_string(),
2480 attributes: None,
2481 children: vec![NodeData::Text(msg.to_string())],
2482 }],
2483 }
2484}
2485
2486pub fn font_to_mathvariant(font: &str) -> Option<&'static str> {
2491 if font.is_empty() {
2492 return None;
2493 }
2494 Some(crate::unicode::unicode_mathvariant(font))
2495}
2496
2497const TEX_SPACING: [f64; 4] = [0.0, 0.167, 0.222, 0.2778];
2505
2506const SPACING_EPSILON: f64 = 0.01;
2508
2509const SPACING_FUDGE: f64 = 0.3;
2511
2512fn role_to_atom_type(role: &str) -> &'static str {
2514 match role {
2515 "ATOM" | "UNKNOWN" | "ID" | "NUMBER" | "POSTFIX" | "FUNCTION" | "DIFFOP" | "SUPOP"
2516 | "ELIDEOP" => "Ord",
2517 "OPFUNCTION" | "TRIGFUNCTION" | "BIGOP" | "SUMOP" | "INTOP" | "LIMITOP" | "OPERATOR" => "Op",
2518 "ADDOP" | "MULOP" | "BINOP" | "APPLYOP" | "COMPOSEOP" => "Bin",
2519 "RELOP" | "METARELOP" | "MODIFIEROP" | "MODIFIER" | "ARROW" => "Rel",
2520 "OPEN" => "Open",
2521 "CLOSE" => "Close",
2522 "PUNCT" | "VERTBAR" | "PERIOD" => "Punct",
2523 "ARRAY" | "POSTSUBSCRIPT" | "POSTSUPERSCRIPT" | "FLOATSUPERSCRIPT" | "FLOATSUBSCRIPT" => {
2524 "Inner"
2525 },
2526 "MIDDLE" => "Ord",
2527 _ => "Ord",
2528 }
2529}
2530
2531fn atompair_spacing(left: &str, right: &str) -> i32 {
2533 match (left, right) {
2534 ("Ord", "Op") | ("Op", "Ord") | ("Op", "Op") | ("Close", "Op") => 1,
2535 ("Ord", "Bin")
2536 | ("Bin", "Ord")
2537 | ("Bin", "Open")
2538 | ("Bin", "Inner")
2539 | ("Close", "Bin")
2540 | ("Inner", "Bin")
2541 | ("Bin", "Op") => -2,
2542 ("Ord", "Rel")
2543 | ("Rel", "Ord")
2544 | ("Op", "Rel")
2545 | ("Rel", "Open")
2546 | ("Rel", "Inner")
2547 | ("Close", "Rel")
2548 | ("Inner", "Rel")
2549 | ("Rel", "Op") => -3,
2550 ("Ord", "Inner")
2551 | ("Op", "Inner")
2552 | ("Close", "Inner")
2553 | ("Inner", "Inner")
2554 | ("Inner", "Open")
2555 | ("Punct", "Ord")
2556 | ("Punct", "Op")
2557 | ("Punct", "Rel")
2558 | ("Punct", "Open")
2559 | ("Punct", "Close")
2560 | ("Punct", "Punct")
2561 | ("Punct", "Inner")
2562 | ("Inner", "Ord")
2563 | ("Inner", "Punct") => -1,
2564 ("Inner", "Op") => 1,
2565 _ => 0,
2566 }
2567}
2568
2569fn m_atom_type(tag: &str) -> Option<&'static str> {
2571 match tag {
2572 "m:mfrac" => Some("Ord"),
2573 "m:marray" => Some("Inner"),
2574 "m:mspace" => Some("Ord"),
2575 _ => None,
2576 }
2577}
2578
2579fn is_embellisher_tag(tag: &str) -> bool {
2581 matches!(
2582 tag,
2583 "m:msub" | "m:msup" | "m:msubsup" | "m:munder" | "m:mover" | "m:munderover"
2584 )
2585}
2586
2587fn is_mrow_like(tag: &str) -> bool {
2589 matches!(
2590 tag,
2591 "m:mrow" | "m:mpadded" | "m:msqrt" | "m:mstyle" | "m:merror" | "m:mphantom" | "m:mtd"
2592 )
2593}
2594
2595fn is_invisible_op(text: &str) -> bool {
2597 !text.is_empty()
2598 && text
2599 .chars()
2600 .all(|c| matches!(c, '\u{200B}' | '\u{2061}' | '\u{2062}' | '\u{2063}'))
2601}
2602
2603fn fmt_em(val: f64) -> String {
2607 if val == 0.0 {
2608 "0em".to_string()
2609 } else {
2610 format!("{val:.3}em")
2611 }
2612}
2613
2614fn get_node_role(node: &NodeData) -> String {
2616 match node {
2617 NodeData::Element { tag, attributes, children } => {
2618 if is_embellisher_tag(tag) {
2619 if let Some(base) = children.first() {
2620 return get_node_role(base);
2621 }
2622 }
2623 attributes
2624 .as_ref()
2625 .and_then(|a| a.get("_role"))
2626 .cloned()
2627 .unwrap_or_default()
2628 },
2629 _ => String::new(),
2630 }
2631}
2632
2633fn get_node_tag(node: &NodeData) -> &str {
2634 match node {
2635 NodeData::Element { tag, .. } => tag,
2636 _ => "",
2637 }
2638}
2639
2640fn get_node_text(node: &NodeData) -> String {
2641 match node {
2642 NodeData::Text(t) => t.clone(),
2643 NodeData::Element { children, .. } => children.iter().map(get_node_text).collect(),
2644 _ => String::new(),
2645 }
2646}
2647
2648fn is_node_text_invisible_op(node: &NodeData) -> bool {
2652 fn check(node: &NodeData, seen_any: &mut bool) -> bool {
2653 match node {
2654 NodeData::Text(t) => {
2655 if !t.is_empty() {
2656 *seen_any = true;
2657 }
2658 t.chars()
2659 .all(|c| matches!(c, '\u{200B}' | '\u{2061}' | '\u{2062}' | '\u{2063}'))
2660 },
2661 NodeData::Element { children, .. } => children.iter().all(|c| check(c, seen_any)),
2662 _ => true,
2663 }
2664 }
2665 let mut seen_any = false;
2666 let ok = check(node, &mut seen_any);
2667 ok && seen_any
2668}
2669
2670fn set_node_attr(node: &mut NodeData, key: &str, value: &str) {
2671 if let NodeData::Element { attributes, .. } = node {
2672 let attrs = attributes.get_or_insert_with(HashMap::default);
2673 attrs.insert(key.to_string(), value.to_string());
2674 }
2675}
2676
2677fn get_node_attr(node: &NodeData, key: &str) -> Option<String> {
2678 match node {
2679 NodeData::Element { attributes, .. } => attributes.as_ref().and_then(|a| a.get(key)).cloned(),
2680 _ => None,
2681 }
2682}
2683
2684fn get_node_attr_f64(node: &NodeData, key: &str) -> f64 {
2685 match node {
2686 NodeData::Element { attributes, .. } => attributes
2687 .as_ref()
2688 .and_then(|a| a.get(key))
2689 .and_then(|v| v.strip_suffix("em"))
2690 .and_then(|v| v.parse::<f64>().ok())
2691 .unwrap_or(0.0),
2692 _ => 0.0,
2693 }
2694}
2695
2696#[derive(PartialEq, Clone, Copy)]
2699enum WalkType {
2700 Atom,
2701 Mrow,
2702 Other,
2703}
2704fn walk_type(tag: &str) -> WalkType {
2705 match tag {
2706 "m:mi" | "m:mo" | "m:mn" | "m:ms" | "m:mtext" => WalkType::Atom,
2707 "m:mrow" | "m:mpadded" | "m:msqrt" | "m:mstyle" | "m:merror" | "m:mphantom" | "m:mtd" => {
2708 WalkType::Mrow
2709 },
2710 _ => WalkType::Other,
2711 }
2712}
2713
2714fn node_at<'a>(root: &'a NodeData, path: &[usize]) -> &'a NodeData {
2718 let mut cur = root;
2719 for &i in path {
2720 match cur {
2721 NodeData::Element { children, .. } => cur = &children[i],
2722 _ => unreachable!("spacewalk path into non-element"),
2723 }
2724 }
2725 cur
2726}
2727
2728fn node_at_mut<'a>(root: &'a mut NodeData, path: &[usize]) -> &'a mut NodeData {
2729 let mut cur = root;
2730 for &i in path {
2731 match cur {
2732 NodeData::Element { children, .. } => cur = &mut children[i],
2733 _ => unreachable!("spacewalk path into non-element"),
2734 }
2735 }
2736 cur
2737}
2738
2739fn child_path(path: &[usize], i: usize) -> Vec<usize> {
2740 let mut p = path.to_vec();
2741 p.push(i);
2742 p
2743}
2744
2745fn descend_embellishers(root: &NodeData, mut path: Vec<usize>) -> Vec<usize> {
2748 loop {
2749 match node_at(root, &path) {
2750 NodeData::Element { tag, children, .. }
2751 if is_embellisher_tag(tag) && !children.is_empty() =>
2752 {
2753 path.push(0);
2754 },
2755 _ => return path,
2756 }
2757 }
2758}
2759
2760pub fn adjust_spacing(node: &mut NodeData) { space_walk(node, Vec::new()); }
2764
2765fn space_walk(root: &mut NodeData, path: Vec<usize>) {
2771 use std::collections::VecDeque;
2772 let (wt, nch) = match node_at(root, &path) {
2773 NodeData::Element { tag, children, .. } => (walk_type(tag), children.len()),
2774 _ => return,
2775 };
2776 match wt {
2777 WalkType::Atom => {},
2778 WalkType::Other => {
2779 for i in 0..nch {
2780 space_walk(root, child_path(&path, i));
2781 }
2782 },
2783 WalkType::Mrow => {
2784 let mut queue: VecDeque<Vec<usize>> = (0..nch).map(|i| child_path(&path, i)).collect();
2785 let mut first = None;
2787 while let Some(p) = queue.pop_front() {
2788 let unwrap = match node_at(root, &p) {
2789 NodeData::Element { tag, children, .. } if tag == "m:mrow" => Some(children.len()),
2790 _ => None,
2791 };
2792 match unwrap {
2793 Some(n) => {
2794 for i in (0..n).rev() {
2795 queue.push_front(child_path(&p, i));
2796 }
2797 },
2798 None => {
2799 first = Some(p);
2800 break;
2801 },
2802 }
2803 }
2804 let Some(mut prev) = first else { return };
2805 space_walk(root, prev.clone());
2806 while let Some(popped) = queue.pop_front() {
2807 let mut next = popped;
2808 let mut invisop: Option<Vec<usize>> = None;
2811 {
2812 let n = node_at(root, &next);
2813 if get_node_tag(n) == "m:mo" && is_node_text_invisible_op(n) {
2814 invisop = Some(next);
2815 match queue.pop_front() {
2816 Some(p) => next = p,
2817 None => break,
2818 }
2819 }
2820 }
2821 enum Kind {
2822 Mrow(usize),
2823 Script(usize),
2824 Plain,
2825 }
2826 let kind = match node_at(root, &next) {
2827 NodeData::Element { tag, children, .. } => {
2828 if tag == "m:mrow" {
2829 Kind::Mrow(children.len())
2830 } else if !children.is_empty()
2831 && (tag.starts_with("m:msup")
2832 || tag.starts_with("m:msub")
2833 || tag.starts_with("m:munder")
2834 || tag.starts_with("m:mover")
2835 || tag.starts_with("m:mmultiscripts"))
2836 {
2837 Kind::Script(children.len())
2842 } else {
2843 Kind::Plain
2844 }
2845 },
2846 _ => Kind::Plain,
2847 };
2848 match kind {
2849 Kind::Mrow(n) => {
2850 for i in (0..n).rev() {
2852 queue.push_front(child_path(&next, i));
2853 }
2854 if let Some(iv) = invisop {
2855 queue.push_front(iv);
2856 }
2857 continue;
2858 },
2859 Kind::Script(n) => {
2860 for i in 1..n {
2862 space_walk(root, child_path(&next, i));
2863 }
2864 queue.push_front(child_path(&next, 0));
2865 if let Some(iv) = invisop {
2866 queue.push_front(iv);
2867 }
2868 continue;
2869 },
2870 Kind::Plain => {},
2871 }
2872 space_walk(root, next.clone());
2873 adjust_pair(root, &prev, &next, invisop.as_deref());
2874 prev = next;
2875 }
2876 },
2877 }
2878}
2879
2880fn adjust_pair(root: &mut NodeData, prev: &[usize], next: &[usize], invisop: Option<&[usize]>) {
2883 let iprev = descend_embellishers(root, prev.to_vec());
2884 let inext = descend_embellishers(root, next.to_vec());
2885
2886 let prev_req_right = get_node_attr_f64(node_at(root, prev), "_rpadding");
2889 let next_req_left = get_node_attr_f64(node_at(root, next), "_lpadding");
2890 let (iprev_tag, prev_role, prev_dict_right) = {
2891 let n = node_at(root, &iprev);
2892 (
2893 get_node_tag(n).to_string(),
2894 get_node_attr(n, "_role").unwrap_or_else(|| "ATOM".to_string()),
2895 get_node_attr_f64(n, "_rspace"),
2896 )
2897 };
2898 let (inext_tag, next_role, next_dict_left) = {
2899 let n = node_at(root, &inext);
2900 (
2901 get_node_tag(n).to_string(),
2902 get_node_attr(n, "_role").unwrap_or_else(|| "ATOM".to_string()),
2903 get_node_attr_f64(n, "_lspace"),
2904 )
2905 };
2906 let prev_type = m_atom_type(&iprev_tag).unwrap_or_else(|| role_to_atom_type(&prev_role));
2907 let next_type = m_atom_type(&inext_tag).unwrap_or_else(|| role_to_atom_type(&next_role));
2908 let tex_code = atompair_spacing(prev_type, next_type);
2909 let tex_space = TEX_SPACING[tex_code.unsigned_abs() as usize];
2910 let target = prev_req_right + next_req_left + tex_space;
2911 let default = prev_dict_right + next_dict_left;
2912 if (target - default).abs() <= SPACING_EPSILON {
2913 return;
2914 }
2915
2916 let prev_tag = get_node_tag(node_at(root, prev)).to_string();
2917 let next_tag = get_node_tag(node_at(root, next)).to_string();
2918 if target < 0.0 {
2924 let sizeable = match node_at(root, prev) {
2925 NodeData::Element { tag, attributes, .. } if walk_type(tag) == WalkType::Atom => Some(
2926 attributes
2927 .as_ref()
2928 .and_then(|a| a.get("class"))
2929 .cloned()
2930 .unwrap_or_default(),
2931 ),
2932 _ => None,
2933 };
2934 if let Some(class) = sizeable {
2935 let text = get_node_text(node_at(root, prev));
2936 let font = latexml_core::common::font::Font::math_default();
2937 let (w, _h, _d) = font.compute_string_size(&text, Default::default());
2938 let mut w_sp = w.0;
2939 if class.contains("mathscript") {
2943 w_sp = w_sp.max(10 * 65535);
2944 }
2945 let mut reqw = (w_sp as f64 / 65536.0) / 10.0 + target;
2946 if reqw < 0.0 {
2947 reqw = 0.0;
2948 }
2949 let slot = node_at_mut(root, prev);
2950 let old = std::mem::replace(slot, NodeData::Text(String::new()));
2951 *slot = NodeData::Element {
2952 tag: "m:mpadded".to_string(),
2953 attributes: Some(HashMap::from_iter([("width".to_string(), fmt_em(reqw))])),
2954 children: vec![old],
2955 };
2956 }
2957 } else if prev_tag == "m:mspace" || next_tag == "m:mspace" {
2958 let target_path = if prev_tag == "m:mspace" { prev } else { next };
2960 let n = node_at_mut(root, target_path);
2961 let old_w = match n {
2962 NodeData::Element { attributes, .. } => attributes
2963 .as_ref()
2964 .and_then(|a| a.get("width"))
2965 .map(|w| super::get_xm_hint_spacing(w))
2966 .unwrap_or(0.0),
2967 _ => 0.0,
2968 };
2969 set_node_attr(n, "width", &fmt_em(target + old_w));
2970 } else if let Some(iv) = invisop {
2971 set_node_attr(node_at_mut(root, iv), "lspace", &fmt_em(target));
2972 } else if prev_tag == "m:mo" && next_tag == "m:mo" {
2973 let p = prev_dict_right;
2975 let n = next_dict_left;
2976 let rem = target - n;
2977 if rem >= 0.0 {
2978 let v = if rem > SPACING_EPSILON { rem } else { 0.0 };
2979 set_node_attr(node_at_mut(root, prev), "rspace", &fmt_em(v));
2980 } else {
2981 let rem = target - p;
2982 if rem >= 0.0 {
2983 let v = if rem > SPACING_EPSILON { rem } else { 0.0 };
2984 set_node_attr(node_at_mut(root, next), "lspace", &fmt_em(v));
2985 } else {
2986 let rem = target / 2.0;
2989 if rem != p {
2990 set_node_attr(
2991 node_at_mut(root, prev),
2992 "rspace",
2993 &format!("{}em", perl_num(rem)),
2994 );
2995 }
2996 if rem != n {
2997 set_node_attr(
2998 node_at_mut(root, next),
2999 "lspace",
3000 &format!("{}em", perl_num(rem)),
3001 );
3002 }
3003 }
3004 }
3005 } else if prev_tag == "m:mo" {
3006 set_node_attr(node_at_mut(root, prev), "rspace", &fmt_em(target));
3007 } else if next_tag == "m:mo" {
3008 set_node_attr(node_at_mut(root, next), "lspace", &fmt_em(target));
3009 } else if (target - default).abs() > SPACING_FUDGE {
3010 Info!(
3011 "ignored",
3012 "spacing",
3013 "No place to set spacing to {target} (default {default})"
3014 );
3015 }
3016}
3017
3018pub fn clean_internal_attrs(node: &mut NodeData) {
3020 if let NodeData::Element { attributes, children, .. } = node {
3021 if let Some(attrs) = attributes {
3022 attrs.remove("_role");
3023 attrs.remove("_lspace");
3024 attrs.remove("_rspace");
3025 attrs.remove("_largeop");
3026 attrs.remove("_lpadding");
3027 attrs.remove("_rpadding");
3028 attrs.remove("_ignorable");
3029 if attrs.is_empty() {
3030 *attributes = None;
3031 }
3032 }
3033 for child in children {
3034 clean_internal_attrs(child);
3035 }
3036 }
3037}
3038
3039#[cfg(test)]
3040mod tests {
3041 use rustc_hash::FxHashMap as HashMap;
3042
3043 use super::*;
3044
3045 #[test]
3046 fn math_style_step_down_monotone_saturates_at_scriptscript() {
3047 assert_eq!(MathStyle::Display.step_down(), MathStyle::Text);
3048 assert_eq!(MathStyle::Text.step_down(), MathStyle::Script);
3049 assert_eq!(MathStyle::Script.step_down(), MathStyle::ScriptScript);
3050 assert_eq!(MathStyle::ScriptScript.step_down(), MathStyle::ScriptScript);
3051 }
3052
3053 #[test]
3054 fn math_style_script_step_collapses_display_and_text() {
3055 assert_eq!(MathStyle::Display.script_step(), MathStyle::Script);
3056 assert_eq!(MathStyle::Text.script_step(), MathStyle::Script);
3057 assert_eq!(MathStyle::Script.script_step(), MathStyle::ScriptScript);
3058 assert_eq!(
3059 MathStyle::ScriptScript.script_step(),
3060 MathStyle::ScriptScript
3061 );
3062 }
3063
3064 #[test]
3065 fn math_style_size_percent_matches_tex_tradition() {
3066 assert_eq!(MathStyle::Display.size_percent(), "100%");
3067 assert_eq!(MathStyle::Text.size_percent(), "100%");
3068 assert_eq!(MathStyle::Script.size_percent(), "70%");
3069 assert_eq!(MathStyle::ScriptScript.size_percent(), "50%");
3070 }
3071
3072 #[test]
3073 fn invisible_times_roundtrip() {
3074 set_invisible_times(false);
3075 assert!(!get_invisible_times());
3076 set_invisible_times(true);
3077 assert!(get_invisible_times());
3078 }
3079
3080 #[test]
3081 fn embellishing_role_matches_canonical_set() {
3082 for r in [
3083 "SUPERSCRIPTOP",
3084 "SUBSCRIPTOP",
3085 "OVERACCENT",
3086 "UNDERACCENT",
3087 "MODIFIER",
3088 "MODIFIEROP",
3089 ] {
3090 assert!(is_embellishing_role(r), "{} should embellish", r);
3091 }
3092 }
3093
3094 #[test]
3095 fn embellishing_role_rejects_others() {
3096 for r in ["ADDOP", "MULOP", "ATOM", "UNKNOWN", ""] {
3097 assert!(!is_embellishing_role(r), "{} should not embellish", r);
3098 }
3099 }
3100
3101 #[test]
3102 fn default_token_content_maps_invisible_chars() {
3103 assert_eq!(default_token_content("MULOP"), Some("\u{2062}"));
3104 assert_eq!(default_token_content("ADDOP"), Some("\u{2064}"));
3105 assert_eq!(default_token_content("PUNCT"), Some("\u{2063}"));
3106 }
3107
3108 #[test]
3109 fn default_token_content_none_for_other_roles() {
3110 assert_eq!(default_token_content("ATOM"), None);
3111 assert_eq!(default_token_content(""), None);
3112 assert_eq!(default_token_content("RELOP"), None);
3113 }
3114
3115 #[test]
3116 fn clean_internal_attrs_removes_underscore_attrs() {
3117 let mut node = NodeData::Element {
3118 tag: "mrow".to_string(),
3119 attributes: Some(HashMap::from_iter([
3120 ("_role".to_string(), "MULOP".to_string()),
3121 ("_lspace".to_string(), "4".to_string()),
3122 ("keep".to_string(), "yes".to_string()),
3123 ])),
3124 children: vec![],
3125 };
3126 clean_internal_attrs(&mut node);
3127 if let NodeData::Element { attributes, .. } = &node {
3128 let attrs = attributes
3129 .as_ref()
3130 .expect("still has the non-internal attr");
3131 assert_eq!(attrs.len(), 1);
3132 assert_eq!(attrs.get("keep").map(String::as_str), Some("yes"));
3133 } else {
3134 panic!("expected element");
3135 }
3136 }
3137
3138 #[test]
3139 fn clean_internal_attrs_unsets_attributes_when_empty() {
3140 let mut node = NodeData::Element {
3141 tag: "mrow".to_string(),
3142 attributes: Some(HashMap::from_iter([
3143 ("_role".to_string(), "MULOP".to_string()),
3144 ("_largeop".to_string(), "true".to_string()),
3145 ])),
3146 children: vec![],
3147 };
3148 clean_internal_attrs(&mut node);
3149 if let NodeData::Element { attributes, .. } = &node {
3150 assert!(attributes.is_none());
3152 } else {
3153 panic!("expected element");
3154 }
3155 }
3156
3157 #[test]
3158 fn clean_internal_attrs_recurses_into_children() {
3159 let mut node = NodeData::Element {
3160 tag: "mrow".to_string(),
3161 attributes: None,
3162 children: vec![NodeData::Element {
3163 tag: "mi".to_string(),
3164 attributes: Some(HashMap::from_iter([(
3165 "_rspace".to_string(),
3166 "1".to_string(),
3167 )])),
3168 children: vec![],
3169 }],
3170 };
3171 clean_internal_attrs(&mut node);
3172 if let NodeData::Element { children, .. } = &node {
3173 if let NodeData::Element { attributes, .. } = &children[0] {
3174 assert!(attributes.is_none(), "recursion cleared child's only attr");
3175 } else {
3176 panic!("expected element child");
3177 }
3178 } else {
3179 panic!("expected element root");
3180 }
3181 }
3182
3183 #[test]
3184 fn clean_internal_attrs_ignores_text_nodes() {
3185 let mut node = NodeData::Text("x".to_string());
3186 clean_internal_attrs(&mut node);
3187 match &node {
3188 NodeData::Text(s) => assert_eq!(s, "x"),
3189 _ => panic!("expected text untouched"),
3190 }
3191 }
3192 #[test]
3193 fn test_role_to_atom_type() {
3194 assert_eq!(role_to_atom_type("ID"), "Ord");
3196 assert_eq!(role_to_atom_type("NUMBER"), "Ord");
3197 assert_eq!(role_to_atom_type("ADDOP"), "Bin");
3198 assert_eq!(role_to_atom_type("RELOP"), "Rel");
3199 assert_eq!(role_to_atom_type("OPEN"), "Open");
3200 assert_eq!(role_to_atom_type("CLOSE"), "Close");
3201 assert_eq!(role_to_atom_type("SUMOP"), "Op");
3202 assert_eq!(role_to_atom_type("PUNCT"), "Punct");
3203 assert_eq!(role_to_atom_type("ARRAY"), "Inner");
3204 assert_eq!(role_to_atom_type("no-such-role"), "Ord");
3205 }
3206
3207 #[test]
3208 fn test_atompair_spacing() {
3209 assert_eq!(atompair_spacing("Ord", "Op"), 1);
3211 assert_eq!(atompair_spacing("Ord", "Bin"), -2);
3212 assert_eq!(atompair_spacing("Rel", "Ord"), -3);
3213 assert_eq!(atompair_spacing("Open", "Ord"), 0);
3214 assert_eq!(atompair_spacing("Open", "Open"), 0);
3215 assert_eq!(atompair_spacing("Punct", "Bin"), 0);
3216 assert_eq!(atompair_spacing("Inner", "Ord"), -1);
3220 assert_eq!(atompair_spacing("Inner", "Op"), 1);
3221 assert_eq!(atompair_spacing("Inner", "Bin"), -2);
3222 assert_eq!(atompair_spacing("Inner", "Rel"), -3);
3223 assert_eq!(atompair_spacing("Inner", "Open"), -1);
3224 assert_eq!(atompair_spacing("Inner", "Close"), 0);
3225 assert_eq!(atompair_spacing("Inner", "Punct"), -1);
3226 assert_eq!(atompair_spacing("Inner", "Inner"), -1);
3227 }
3228
3229 #[test]
3230 fn test_fmt_em() {
3231 assert_eq!(fmt_em(0.0), "0em");
3233 assert_eq!(fmt_em(1.0), "1.000em");
3234 assert_eq!(fmt_em(0.167), "0.167em");
3235 assert_eq!(fmt_em(0.33), "0.330em");
3236 assert_eq!(fmt_em(1.2), "1.200em");
3237 }
3238}