1use libxml::tree::Node;
16
17use crate::document::Document;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum DeclarePatternType {
23 Simple,
25 Subscript,
27 LiteralSubscript,
29 Prime,
31 Accent,
33 FuncApply,
35 LeadWild,
37 CmdDual,
39 Unknown,
41}
42
43#[derive(Debug, Clone)]
46pub struct DeclarePattern {
47 pub xpath: String,
48 pub pattern_type: DeclarePatternType,
49 pub base_text: Option<String>,
51 pub sub_text: Option<String>,
53 pub accent_name: Option<String>,
55 #[allow(dead_code)]
56 pub has_wildcard: bool,
57 pub wildcard_paths: Option<Vec<Vec<usize>>>,
58 pub font_class: Option<&'static str>,
61}
62
63impl DeclarePattern {
64 pub fn select_count(&self) -> Option<usize> {
70 match self.pattern_type {
71 DeclarePatternType::LiteralSubscript
72 | DeclarePatternType::Prime
73 | DeclarePatternType::Subscript => Some(2),
74 DeclarePatternType::Accent => Some(1),
75 DeclarePatternType::FuncApply => self
76 .sub_text
77 .as_deref()
78 .and_then(|s| s.parse::<usize>().ok())
79 .map(|n| 2 * n + 2),
80 DeclarePatternType::LeadWild => match (&self.base_text, &self.sub_text) {
82 (Some(content), Some(suffix)) => Some(content.chars().count() + suffix.chars().count()),
83 _ => None,
84 },
85 _ => None,
86 }
87 }
88}
89
90fn base_text_predicate(base: &str) -> (String, Option<&'static str>) {
100 if base.starts_with('\\') {
101 let cmd = base.trim_start_matches('\\');
102 if let Some(inner) = cmd
103 .strip_prefix("mathcal{")
104 .and_then(|s| s.strip_suffix('}'))
105 {
106 (format!("text()='{inner}'"), Some("caligraphic"))
107 } else {
108 (format!("(@meaning='{cmd}' or @name='{cmd}')"), None)
109 }
110 } else {
111 (format!("text()='{}'", base.replace('\'', "'")), None)
112 }
113}
114
115pub fn compile_declare_pattern(body_text: &str) -> DeclarePattern {
129 if let Some(base) = body_text.strip_suffix("_\\WildCard") {
138 let base = base.trim().to_string();
139 let (base_pred, font_class) = base_text_predicate(&base);
140 return DeclarePattern {
141 xpath: format!("descendant-or-self::*[local-name()='XMTok' and {base_pred}]"),
143 pattern_type: DeclarePatternType::Subscript,
144 base_text: Some(base),
145 sub_text: None,
146 accent_name: None,
147 has_wildcard: true,
148 wildcard_paths: Some(vec![vec![2, 1]]),
150 font_class,
151 };
152 }
153 if body_text.contains("_{\\WildCard")
155 && let Some(idx) = body_text.find("_{")
156 {
157 let base = body_text[..idx].trim().to_string();
158 let (base_pred, font_class) = base_text_predicate(&base);
159 let brace_content = &body_text[idx + 2..body_text.len().saturating_sub(1)];
160 let nwilds = brace_content.matches("\\WildCard").count();
161 let (wpaths, sub_text) = if nwilds <= 1 {
170 (vec![vec![2, 1]], None)
171 } else {
172 (
173 (1..=nwilds).map(|i| vec![2, 1, 2 * i - 1]).collect(),
174 Some(nwilds.to_string()),
175 )
176 };
177 return DeclarePattern {
178 xpath: format!("descendant-or-self::*[local-name()='XMTok' and {base_pred}]"),
179 pattern_type: DeclarePatternType::Subscript,
180 base_text: Some(base),
181 sub_text,
182 accent_name: None,
183 has_wildcard: true,
184 wildcard_paths: Some(wpaths),
185 font_class,
186 };
187 }
188 if let Some((base, sub)) = parse_subscript_literal(body_text) {
191 let base_pred = format!("text()='{}'", base.replace('\'', "'"));
192 return DeclarePattern {
193 xpath: format!("descendant-or-self::*[local-name()='XMTok' and {base_pred}]"),
194 pattern_type: DeclarePatternType::LiteralSubscript,
195 base_text: Some(base),
196 sub_text: Some(sub),
197 accent_name: None,
198 has_wildcard: false,
199 wildcard_paths: None,
200 font_class: None,
201 };
202 }
203
204 for accent in &[
207 "hat", "widehat", "tilde", "bar", "vec", "dot", "ddot", "check", "breve",
208 ] {
209 let pattern = format!("\\{accent}{{\\WildCard}}");
210 if body_text == pattern {
211 return DeclarePattern {
212 xpath: "descendant-or-self::*[local-name()='XMApp']".to_string(),
214 pattern_type: DeclarePatternType::Accent,
215 base_text: None,
216 sub_text: None,
217 accent_name: Some(accent.to_string()),
218 has_wildcard: true,
219 wildcard_paths: Some(vec![vec![1, 2]]),
221 font_class: None,
222 };
223 }
224 }
225 for accent in &[
227 "hat", "widehat", "tilde", "bar", "vec", "dot", "ddot", "check", "breve",
228 ] {
229 if let Some(rest) = body_text.strip_prefix(&format!("\\{accent}{{"))
230 && let Some(inner) = rest.strip_suffix('}')
231 && !inner.contains("WildCard")
232 {
233 return DeclarePattern {
234 xpath: "descendant-or-self::*[local-name()='XMApp']".to_string(),
235 pattern_type: DeclarePatternType::Accent,
236 base_text: Some(inner.to_string()),
237 sub_text: None,
238 accent_name: Some(accent.to_string()),
239 has_wildcard: false,
240 wildcard_paths: None,
241 font_class: None,
242 };
243 }
244 }
245
246 if let Some(base) = body_text.strip_suffix("^{\\prime}") {
250 let base = base.trim().to_string();
251 if !base.is_empty() && !base.contains('\\') {
252 let base_pred = format!("text()='{}'", base.replace('\'', "'"));
253 return DeclarePattern {
254 xpath: format!("descendant-or-self::*[local-name()='XMTok' and {base_pred}]"),
256 pattern_type: DeclarePatternType::Prime,
257 base_text: Some(base),
258 sub_text: None,
259 accent_name: None,
260 has_wildcard: false,
261 wildcard_paths: None,
262 font_class: None,
263 };
264 }
265 }
266 if body_text.ends_with('\'') && body_text.len() > 1 {
268 let base = body_text[..body_text.len() - 1].trim().to_string();
269 if !base.is_empty() && !base.contains('\\') {
270 let base_pred = format!("text()='{}'", base.replace('\'', "'"));
271 return DeclarePattern {
272 xpath: format!("descendant-or-self::*[local-name()='XMTok' and {base_pred}]"),
274 pattern_type: DeclarePatternType::Prime,
275 base_text: Some(base),
276 sub_text: None,
277 accent_name: None,
278 has_wildcard: false,
279 wildcard_paths: None,
280 font_class: None,
281 };
282 }
283 }
284
285 if let Some(idx) = body_text.find("\\WildCard[(") {
296 let base = body_text[..idx].trim().to_string();
297 let content = &body_text[idx + "\\WildCard[(".len()..];
298 if let Some(args) = content.strip_suffix(")]")
299 && !base.is_empty()
300 {
301 let parts: Vec<&str> = args.split(',').collect();
302 if parts.iter().all(|p| p.trim() == "\\WildCard") {
303 let nargs = parts.len();
304 let (base_pred, font_class) = base_text_predicate(&base);
305 let span = 2 * nargs + 2;
308 let wpaths = (2..=span).map(|i| vec![i]).collect();
309 return DeclarePattern {
310 xpath: format!("descendant-or-self::*[local-name()='XMTok' and {base_pred}]"),
311 pattern_type: DeclarePatternType::FuncApply,
312 base_text: Some(base),
313 sub_text: Some(nargs.to_string()),
314 accent_name: None,
315 has_wildcard: true,
316 wildcard_paths: Some(wpaths),
317 font_class,
318 };
319 }
320 }
321 }
322
323 if let Some(rest) = body_text.strip_prefix("\\WildCard[")
330 && let Some(close) = rest.find(']')
331 {
332 let content = &rest[..close];
333 let suffix = &rest[close + 1..];
334 if !content.is_empty()
335 && !suffix.is_empty()
336 && !content.contains('\\')
337 && !suffix.contains('\\')
338 {
339 let k = content.chars().count();
340 let first = content.chars().next().unwrap();
341 let wpaths = (1..=k).map(|i| vec![i]).collect();
342 return DeclarePattern {
343 xpath: format!(
344 "descendant-or-self::*[local-name()='XMTok' and text()='{}']",
345 first.to_string().replace('\'', "'")
346 ),
347 pattern_type: DeclarePatternType::LeadWild,
348 base_text: Some(content.to_string()),
349 sub_text: Some(suffix.to_string()),
350 accent_name: None,
351 has_wildcard: true,
352 wildcard_paths: Some(wpaths),
353 font_class: None,
354 };
355 }
356 }
357
358 if body_text.starts_with('\\')
366 && let Some(cmd_end) = body_text.find("{\\WildCard}")
367 {
368 let cmd = &body_text[1..cmd_end];
369 let rest = &body_text[cmd_end..];
370 if !cmd.is_empty() && cmd.chars().all(|c| c.is_ascii_alphabetic()) {
371 let nargs = rest.matches("{\\WildCard}").count();
372 if nargs >= 1 && rest == "{\\WildCard}".repeat(nargs) {
373 return DeclarePattern {
374 xpath: "descendant-or-self::*[local-name()='XMDual']".to_string(),
375 pattern_type: DeclarePatternType::CmdDual,
376 base_text: Some(cmd.to_string()),
377 sub_text: Some(nargs.to_string()),
378 accent_name: None,
379 has_wildcard: true,
380 wildcard_paths: None,
381 font_class: None,
382 };
383 }
384 }
385 }
386
387 if let Some(cmd) = body_text.strip_prefix('\\')
393 && !cmd.is_empty()
394 && cmd.chars().all(|c| c.is_ascii_alphabetic())
395 {
396 return DeclarePattern {
397 xpath: format!(
398 "descendant-or-self::*[local-name()='XMTok' and @name='{}']",
399 cmd
400 ),
401 pattern_type: DeclarePatternType::Simple,
402 base_text: None,
403 sub_text: None,
404 accent_name: None,
405 has_wildcard: false,
406 wildcard_paths: None,
407 font_class: None,
408 };
409 }
410
411 if !body_text.is_empty() && !body_text.contains('\\') {
415 return DeclarePattern {
416 xpath: format!(
417 "descendant-or-self::*[local-name()='XMTok' and text()='{}']",
418 body_text.replace('\'', "'")
419 ),
420 pattern_type: DeclarePatternType::Simple,
421 base_text: None,
422 sub_text: None,
423 accent_name: None,
424 has_wildcard: false,
425 wildcard_paths: None,
426 font_class: None,
427 };
428 }
429
430 DeclarePattern {
432 xpath: String::new(),
433 pattern_type: DeclarePatternType::Unknown,
434 base_text: None,
435 sub_text: None,
436 accent_name: None,
437 has_wildcard: false,
438 wildcard_paths: None,
439 font_class: None,
440 }
441}
442
443fn parse_subscript_literal(body_text: &str) -> Option<(String, String)> {
446 if body_text.contains("WildCard") {
447 return None;
448 }
449 let idx = body_text.find('_')?;
451 let base = body_text[..idx].trim().to_string();
452 if base.is_empty() {
453 return None;
454 }
455 let sub = body_text[idx + 1..].trim();
456 let sub = sub
458 .strip_prefix('{')
459 .and_then(|s| s.strip_suffix('}'))
460 .unwrap_or(sub);
461 Some((base, sub.to_string()))
462}
463
464pub fn declare_node_matches(document: &Document, node: &Node, pat: &DeclarePattern) -> bool {
474 let base_text = pat.base_text.as_deref();
475 let sub_text = pat.sub_text.as_deref();
476 let accent_name = pat.accent_name.as_deref();
477 let font_class = pat.font_class;
478 if let Some(class) = font_class {
484 let font = document.get_node_font(node);
485 if !font.font_attribute_string().contains(class) {
486 return false;
487 }
488 }
489 let children = node.get_child_nodes();
490 match pat.pattern_type {
491 DeclarePatternType::LiteralSubscript => {
492 let next_sib = node.get_next_sibling();
495 let next_role = next_sib.as_ref().and_then(|s| s.get_property("role"));
496 if next_role.as_deref() != Some("POSTSUBSCRIPT") {
497 return false;
498 }
499 if let Some(sub) = sub_text {
501 let sub_content = next_sib
502 .as_ref()
503 .map(|s| s.get_content())
504 .unwrap_or_default();
505 if sub_content.trim() != sub {
506 return false;
507 }
508 }
509 true
510 },
511 DeclarePatternType::Subscript => {
512 let next_sib = node.get_next_sibling();
515 let next_role = next_sib.as_ref().and_then(|s| s.get_property("role"));
516 if next_role.as_deref() != Some("POSTSUBSCRIPT") {
517 return false;
518 }
519 if let Some(n) = sub_text.and_then(|s| s.parse::<usize>().ok())
527 && n >= 2
528 {
529 let content: Vec<Node> = next_sib
530 .as_ref()
531 .and_then(|s| {
532 s.get_child_nodes()
533 .into_iter()
534 .find(|c| c.get_type() == Some(libxml::tree::NodeType::ElementNode))
535 })
536 .map(|holder| {
537 holder
538 .get_child_nodes()
539 .into_iter()
540 .filter(|c| c.get_type() == Some(libxml::tree::NodeType::ElementNode))
541 .collect()
542 })
543 .unwrap_or_default();
544 if content.len() != 2 * n - 1 {
545 return false;
546 }
547 for (i, c) in content.iter().enumerate() {
548 if i % 2 == 1 && (c.get_name() != "XMTok" || c.get_content().trim() != ",") {
550 return false;
551 }
552 }
553 }
554 true
555 },
556 DeclarePatternType::FuncApply => {
557 let Some(nargs) = sub_text.and_then(|s| s.parse::<usize>().ok()) else {
563 return false;
564 };
565 let mut expected: Vec<Option<&str>> = vec![Some("(")];
566 for i in 0..nargs {
567 if i > 0 {
568 expected.push(Some(","));
569 }
570 expected.push(None); }
572 expected.push(Some(")"));
573 let mut cur = node.clone();
574 for want in expected {
575 let mut next = cur.get_next_sibling();
576 while let Some(ref s) = next {
577 if s.get_type() == Some(libxml::tree::NodeType::ElementNode) {
578 break;
579 }
580 next = s.get_next_sibling();
581 }
582 let Some(sib) = next else {
583 return false;
584 };
585 if let Some(text) = want
586 && (sib.get_name() != "XMTok" || sib.get_content().trim() != text)
587 {
588 return false;
589 }
590 cur = sib;
591 }
592 true
593 },
594 DeclarePatternType::CmdDual => {
595 let (Some(cmd), Some(nargs)) = (base_text, sub_text.and_then(|s| s.parse::<usize>().ok()))
598 else {
599 return false;
600 };
601 let elem_children: Vec<Node> = children
602 .iter()
603 .filter(|c| c.get_type() == Some(libxml::tree::NodeType::ElementNode))
604 .cloned()
605 .collect();
606 let Some(content) = elem_children.first() else {
607 return false;
608 };
609 if content.get_name() != "XMApp" {
610 return false;
611 }
612 let app_children: Vec<Node> = content
613 .get_child_nodes()
614 .into_iter()
615 .filter(|c| c.get_type() == Some(libxml::tree::NodeType::ElementNode))
616 .collect();
617 if app_children.len() != nargs + 1 {
618 return false;
619 }
620 let op = &app_children[0];
621 op.get_name() == "XMTok"
622 && (op.get_property("name").as_deref() == Some(cmd)
623 || op.get_property("meaning").as_deref() == Some(cmd))
624 },
625 DeclarePatternType::LeadWild => {
626 let (Some(content), Some(suffix)) = (base_text, sub_text) else {
631 return false;
632 };
633 let expected: Vec<char> = content.chars().skip(1).chain(suffix.chars()).collect();
634 let mut cur = node.clone();
635 for want in expected {
636 let mut next = cur.get_next_sibling();
637 while let Some(ref s) = next {
638 if s.get_type() == Some(libxml::tree::NodeType::ElementNode) {
639 break;
640 }
641 next = s.get_next_sibling();
642 }
643 let Some(sib) = next else {
644 return false;
645 };
646 if sib.get_name() != "XMTok" || sib.get_content().trim() != want.to_string() {
647 return false;
648 }
649 cur = sib;
650 }
651 true
652 },
653 DeclarePatternType::Prime => {
654 let next_sib = node.get_next_sibling();
657 let next_role = next_sib.as_ref().and_then(|s| s.get_property("role"));
658 if next_role.as_deref() != Some("POSTSUPERSCRIPT") {
659 return false;
660 }
661 let sup_content = next_sib
663 .as_ref()
664 .map(|s| s.get_content())
665 .unwrap_or_default();
666 sup_content.contains('′')
667 },
668 DeclarePatternType::Accent => {
669 if children.len() < 2 {
671 return false;
672 }
673 if let Some(accent) = accent_name {
675 let first_name = children[0]
676 .get_property("name")
677 .or_else(|| children[0].get_property("meaning"));
678 if first_name.as_deref() != Some(accent) {
679 return false;
680 }
681 let role = children[0].get_property("role");
683 let is_accent = role
684 .as_deref()
685 .map(|r| r.contains("ACCENT"))
686 .unwrap_or(false);
687 if !is_accent {
688 return false;
689 }
690 }
691 if let Some(base) = base_text
693 && !declare_base_matches(&children[1], base)
694 {
695 return false;
696 }
697 true
698 },
699 DeclarePatternType::Simple => {
700 let font = document.get_node_font(node);
704 if let Some(series) = font.get_series()
705 && series.as_ref() == "bold"
706 {
707 return false;
708 }
709 if let Some(family) = font.get_family() {
710 let fam = family.as_ref();
711 if fam == "caligraphic" || fam == "typewriter" {
712 return false;
713 }
714 }
715 true
716 },
717 DeclarePatternType::Unknown => true,
719 }
720}
721
722fn declare_base_matches(node: &Node, base_spec: &str) -> bool {
725 if base_spec.starts_with('\\') {
726 let cmd = base_spec.trim_start_matches('\\');
728 if let Some(inner) = cmd
730 .strip_prefix("mathcal{")
731 .and_then(|s| s.strip_suffix('}'))
732 {
733 let font = node.get_property("font").unwrap_or_default();
734 let text = node.get_content();
735 return font == "caligraphic" && text.trim() == inner;
736 }
737 let meaning = node.get_property("meaning").unwrap_or_default();
739 meaning == cmd
740 } else {
741 let text = node.get_content();
743 text.trim() == base_spec
744 }
745}