Skip to main content

libmathcat/
interface.rs

1//! The interface module provides functionality both for calling from an API and also running the code from `main`.
2//!
3#![allow(non_snake_case)]
4#![allow(clippy::needless_return)]
5use std::cell::RefCell;
6use std::sync::LazyLock;
7
8use crate::canonicalize::{as_text, create_mathml_element};
9use crate::errors::*;
10use phf::phf_map;
11use regex::{Captures, Regex};
12use sxd_document_no_unsafe::dom::{Element, Document, ChildOfRoot, ChildOfElement, Attribute};
13use sxd_document_no_unsafe::parser;
14use sxd_document_no_unsafe::Package;
15use sxd_document_no_unsafe::{as_str, as_qname};
16
17use crate::canonicalize::{as_element, name};
18use crate::shim_filesystem::{find_all_dirs_shim, find_files_in_dir_that_ends_with_shim};
19use log::{debug, error};
20
21use crate::navigate::*;
22use crate::pretty_print::mml_to_string;
23use crate::xpath_functions::{is_leaf, IsNode};
24use std::panic::{catch_unwind, AssertUnwindSafe};
25
26/// Maximum depth to prevent stack overflow on deeply nested MathML
27pub const MAX_DEPTH: usize = 512;
28
29#[cfg(feature = "enable-logs")]
30use std::sync::Once;
31#[cfg(feature = "enable-logs")]
32static INIT: Once = Once::new();
33
34fn enable_logs() {
35    #[cfg(feature = "enable-logs")]
36    INIT.call_once(||{
37        #[cfg(target_os = "android")]
38        {
39            use log::*;
40            use android_logger::*;
41        
42            android_logger::init_once(
43                Config::default()
44                .with_max_level(LevelFilter::Trace)
45                .with_tag("MathCat")
46            );    
47            trace!("Activated Android logger!");  
48        }    
49    });
50}
51
52// For getting a message from a panic
53thread_local! {
54    // Stores (Message, File, Line)
55    static PANIC_INFO: RefCell<Option<(String, String, u32)>> = const { RefCell::new(None) };
56}
57
58/// Initialize the panic handler to catch panics and store the message, file, and line number in `PANIC_INFO`.
59///
60/// Installed once. Under `#[cfg(test)]` the previous hook (cargo's) is chained so assert failures
61/// still print normally; production stays silent and relies on [`report_any_panic`].
62pub fn init_panic_handler() {
63    use std::panic;
64    use std::sync::Once;
65
66    static INSTALL: Once = Once::new();
67    INSTALL.call_once(|| {
68        let previous = panic::take_hook();
69        panic::set_hook(Box::new(move |info| {
70            let location = info.location()
71                .map(|l| format!("{}:{}", l.file(), l.line()))
72                .unwrap_or_else(|| "unknown".to_string());
73
74            let payload = info.payload();
75            let msg = if let Some(s) = payload.downcast_ref::<&'static str>() {
76                s.to_string()
77            } else if let Some(s) = payload.downcast_ref::<String>() {
78                s.clone()
79            } else {
80                "Unknown panic payload".to_string()
81            };
82
83            // Use try_with/try_borrow_mut to ensure the hook never panics itself
84            let _ = PANIC_INFO.try_with(|cell| {
85                if let Ok(mut slot) = cell.try_borrow_mut() {
86                    *slot = Some((msg, location, 0));
87                }
88            });
89
90            // cargo test (and other previous hooks) still print; production ATs do not want that spam.
91            if cfg!(test) {
92                previous(info);
93            }
94        }));
95    });
96}
97
98pub fn report_any_panic<T>(result: Result<Result<T, Error>, Box<dyn std::any::Any + Send>>) -> Result<T, Error> {
99    match result {
100        Ok(val) => val,
101        Err(payload) => {
102            // Prefer details captured by the hook (includes file:line).
103            if let Some((msg, file, line)) = PANIC_INFO.with(|cell| cell.borrow_mut().take()) {
104                return Err(anyhow::anyhow!(
105                    "MathCAT crash! Please report the following information: '{}' at {}:{}",
106                    msg, file, line
107                ));
108            }
109            // Fallback: catch_unwind always provides the payload even if the hook did not run.
110            let msg = if let Some(s) = payload.downcast_ref::<&'static str>() {
111                (*s).to_string()
112            } else if let Some(s) = payload.downcast_ref::<String>() {
113                s.clone()
114            } else {
115                return Err(anyhow::anyhow!("MathCAT crash! -- please report"));
116            };
117            Err(anyhow::anyhow!(
118                "MathCAT crash! Please report the following information: '{}'",
119                msg
120            ))
121        }
122    }
123} 
124
125// wrap up some common functionality between the call from 'main' and AT
126fn cleanup_mathml(mathml: Element) -> Result<Element> {
127    trim_element(mathml, false);
128    let mathml = crate::canonicalize::canonicalize(mathml)?;
129    let mathml = add_ids(mathml);
130    return Ok(mathml);
131}
132
133thread_local! {
134    /// The current node being navigated (also spoken and brailled) is stored in `MATHML_INSTANCE`.
135    pub static MATHML_INSTANCE: RefCell<Package> = init_mathml_instance();
136}
137
138fn init_mathml_instance() -> RefCell<Package> {
139    let package = parser::parse("<math></math>")
140        .expect("Internal error in 'init_mathml_instance;: didn't parse initializer string");
141    return RefCell::new(package);
142}
143
144/// Set the Rules directory
145/// IMPORTANT: this should be the very first call to MathCAT. If 'dir' is an empty string, the environment var 'MathCATRulesDir' is tried.
146pub fn set_rules_dir(dir: impl AsRef<str>) -> Result<()> {
147    enable_logs();
148    init_panic_handler();
149    let dir = dir.as_ref().to_string();
150    let result = catch_unwind(AssertUnwindSafe(|| {
151        use std::path::PathBuf;
152        let dir_os = if dir.is_empty() {
153            std::env::var_os("MathCATRulesDir").unwrap_or_default()
154        } else {
155            std::ffi::OsString::from(&dir)
156        };
157        let pref_manager = crate::prefs::PreferenceManager::get();
158        pref_manager.borrow_mut().initialize(PathBuf::from(dir_os))
159    }));
160    return report_any_panic(result);
161}
162
163/// Returns the version number (from Cargo.toml) of the build
164pub fn get_version() -> String {
165    enable_logs();
166    const VERSION: &str = env!("CARGO_PKG_VERSION");
167    return VERSION.to_string();
168}
169
170/// This will override any previous MathML that was set.
171/// This returns canonical MathML with 'id's set on any node that doesn't have an id.
172/// The ids can be used for sync highlighting if the `Bookmark` API preference is true.
173pub fn set_mathml(mathml_str: impl AsRef<str>) -> Result<String> {
174    enable_logs();
175    // if these are present when resent to MathJaX, MathJaX crashes (https://github.com/mathjax/MathJax/issues/2822)
176    static MATHJAX_V2: LazyLock<Regex> = LazyLock::new(|| Regex::new(r#"class *= *['"]MJX-.*?['"]"#).unwrap());
177    static MATHJAX_V3: LazyLock<Regex> = LazyLock::new(|| Regex::new(r#"class *= *['"]data-mjx-.*?['"]"#).unwrap());
178
179    // Strip out processing instructions and comments -- these are not MathML and can cause DOS problems in the parser
180    static PROCESSING_INSTRUCTION: LazyLock<Regex> = LazyLock::new(|| Regex::new(r#"<\?[\s\S]{1,2048}\?>"#).unwrap());
181    static XML_COMMENT: LazyLock<Regex> = LazyLock::new(|| Regex::new(r#"(?s)"#).unwrap());
182
183    // These have some length limits to avoid DOS attacks via long strings
184    static NAMESPACE_DECL: LazyLock<Regex> = LazyLock::new(|| Regex::new(r#"xmlns:[[:alpha:]]{1,32}"#).unwrap());
185    static PREFIX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r#"(</?)[[:alpha:]]{1,32}:"#).unwrap());
186    static HTML_ENTITIES: LazyLock<Regex> = LazyLock::new(|| Regex::new(r#"&([a-zA-Z]{2,10});"#).unwrap());
187    let result = catch_unwind(AssertUnwindSafe(|| {
188        NAVIGATION_STATE.with(|nav_stack| {
189            nav_stack.borrow_mut().reset();
190        });
191
192        // We need the main definitions files to be read in so canonicalize can work.
193        // This call reads all of them for the current preferences, but that's ok since they will likely be used
194        crate::speech::SPEECH_RULES.with(|rules| rules.borrow_mut().read_files())?;
195
196        let mathml_str = mathml_str.as_ref();
197        // Safety guard: Reject strings > 1MB to prevent DoS/Stack issues
198        if mathml_str.len() > 1024 * 1024 {
199            bail!("MathML string of size {} bytes exceeds length limit of 1MB", mathml_str.len());
200        }
201
202        return MATHML_INSTANCE.with(|old_package| {
203            static HTML_ENTITIES_MAPPING: phf::Map<&str, &str> = include!("entities.in");
204
205            let mut error_message = "".to_string(); // can't return a result inside the replace_all, so we do this hack of setting the message and then returning the error
206                                                                     
207            let mathml_str = XML_COMMENT.replace_all(mathml_str, "");
208            let mathml_str = PROCESSING_INSTRUCTION.replace_all(&mathml_str, "");
209            // FIX: need to deal with character data and convert to something the parser knows
210            let mathml_str = HTML_ENTITIES.replace_all(&mathml_str, |cap: &Captures| match HTML_ENTITIES_MAPPING.get(&cap[1]) {
211                    None => {
212                        error_message = format!("No entity named '{}'", &cap[0]);
213                        cap[0].to_string()
214                    }
215                    Some(&ch) => ch.to_string(),
216                });
217
218            if !error_message.is_empty() {
219                // Clear stale state so subsequent API calls do not return previous user's data (security issue)
220                old_package.replace(parser::parse("<math></math>").unwrap());
221                bail!(error_message);
222            }
223            let mathml_str = MATHJAX_V2.replace_all(&mathml_str, "");
224            let mathml_str = MATHJAX_V3.replace_all(&mathml_str, "");
225
226            // the speech rules use the xpath "name" function and that includes the prefix
227            // getting rid of the prefix properly probably involves a recursive replacement in the tree
228            // if the prefix is used, it is almost certainly something like "m" or "mml", so this cheat will work.
229            let mathml_str = NAMESPACE_DECL.replace(&mathml_str, "xmlns"); // do this before the PREFIX replace!
230            let mathml_str = PREFIX.replace_all(&mathml_str, "$1");
231
232            let new_package = parser::parse(&mathml_str);
233            if let Err(e) = new_package {
234                // Clear stale state so subsequent API calls do not return previous user's data (security issue)
235                old_package.replace(parser::parse("<math></math>").unwrap());
236                bail!("Invalid MathML input:\n{}\nError is: {}", mathml_str, e);
237            }
238
239            let new_package = new_package.unwrap();
240            let mathml = get_element(&new_package);
241            let mathml = cleanup_mathml(mathml)?;
242            let mathml_string = mml_to_string(mathml);
243            old_package.replace(new_package);
244
245            return Ok(mathml_string);
246        });
247    }));
248
249    return report_any_panic(result);
250}
251
252/// Get the spoken text of the MathML that was set.
253/// The speech takes into account any AT or user preferences.
254pub fn get_spoken_text() -> Result<String> {
255    enable_logs();
256    let result = catch_unwind(AssertUnwindSafe(|| {
257        MATHML_INSTANCE.with(|package_instance| {
258            let package_instance = package_instance.borrow();
259            let mathml = get_element(&package_instance);
260            let new_package = Package::new();
261            let intent = crate::speech::intent_from_mathml(mathml, new_package.as_document())?;
262            debug!("Intent tree:\n{}", mml_to_string(intent));
263            let speech = crate::speech::speak_mathml(intent, "", 0)?;
264            return Ok(speech);
265        })
266    }));
267    return report_any_panic(result);
268}
269
270/// Get the spoken text for an overview of the MathML that was set.
271/// The speech takes into account any AT or user preferences.
272/// Note: this implementation for is currently minimal and should not be used.
273pub fn get_overview_text() -> Result<String> {
274    enable_logs();
275    let result = catch_unwind(AssertUnwindSafe(|| {
276        MATHML_INSTANCE.with(|package_instance| {
277            let package_instance = package_instance.borrow();
278            let mathml = get_element(&package_instance);
279            let speech = crate::speech::overview_mathml(mathml, "", 0)?;
280            return Ok(speech);
281        })
282    }));
283    return report_any_panic(result);
284}
285
286/// Get the value of the named preference.
287/// None is returned if `name` is not a known preference.
288pub fn get_preference(name: impl AsRef<str>) -> Result<String> {
289    enable_logs();
290    let name = name.as_ref().to_string();
291    let result = catch_unwind(AssertUnwindSafe(|| {
292        use crate::prefs::NO_PREFERENCE;
293        crate::speech::SPEECH_RULES.with(|rules| {
294            let rules = rules.borrow();
295            let pref_manager = rules.pref_manager.borrow();
296            let mut value = pref_manager.pref_to_string(&name);
297            if value == NO_PREFERENCE {
298                value = pref_manager.pref_to_string(&name);
299            }
300            if value == NO_PREFERENCE {
301                bail!("No preference named '{}'", name);
302            } else {
303                return Ok(value);
304            }
305        })
306    }));
307    return report_any_panic(result);
308}
309
310/// Set a MathCAT preference. The preference name should be a known preference name.
311/// The value should either be a string or a number (depending upon the preference being set)
312/// The list of known user preferences is in the MathCAT user documentation.
313/// Here are common preferences set by programs (not settable by the user):
314/// * TTS -- SSML, SAPI5, None
315/// * Pitch -- normalized at '1.0'
316/// * Rate -- words per minute (should match current speech rate).
317///   There is a separate "MathRate" that is user settable that causes a relative percentage change from this rate.
318/// * Volume -- default 100
319/// * Voice -- set a voice to use (not implemented)
320/// * Gender -- set pick any voice of the given gender (not implemented)
321/// * Bookmark -- set to `true` if a `mark`/`bookmark` should be part of the returned speech (used for sync highlighting)
322///
323/// Important: both the preference name and value are case-sensitive
324///
325/// This function can be called multiple times to set different values.
326/// The values are persistent and extend beyond calls to [`set_mathml`].
327/// A value can be overwritten by calling this function again with a different value.
328///
329/// Be careful setting preferences -- these potentially override user settings, so only preferences that really need setting should be set.
330pub fn set_preference(name: impl AsRef<str>, value: impl AsRef<str>) -> Result<()> {
331    enable_logs();
332    let name = name.as_ref().to_string();
333    let value = value.as_ref().to_string();
334    let result = catch_unwind(AssertUnwindSafe(|| {
335        set_preference_impl(&name, &value)
336    }));
337    return report_any_panic(result);
338}
339
340fn set_preference_impl(name: &str, value: &str) -> Result<()> {
341    let mut value = value.to_string();
342    if name == "Language" || name == "LanguageAuto" {
343        // check the format
344        if value != "Auto" {
345            // could get es, es-419, or en-us-nyc ...  we only care about the first two parts so we clean it up a little
346            let mut lang_country_split = value.split('-');
347            let language = lang_country_split.next().unwrap_or("");
348            let country = lang_country_split.next().unwrap_or("");
349            if language.len() != 2 {
350                bail!(
351                    "Improper format for 'Language' preference '{}'. Should be of form 'en' or 'en-gb'",
352                    value
353                );
354            }
355            let mut new_lang_country = language.to_string(); // need a temp value because 'country' is borrowed from 'value' above
356            if !country.is_empty() {
357                new_lang_country.push('-');
358                new_lang_country.push_str(country);
359            }
360            value = new_lang_country;
361        }
362        if name == "LanguageAuto" && value == "Auto" {
363            bail!("'LanguageAuto' can not have the value 'Auto'");
364        }
365    }
366
367    crate::speech::SPEECH_RULES.with(|rules| -> Result<()> {
368        if let Some(error_string) = rules.borrow().get_error() {
369            bail!("{}", error_string);
370        }
371        Ok(())
372    })?;
373
374    // Do not hold a SpeechRules borrow while updating preferences: invalidation clears rule caches.
375    let pref_manager = crate::prefs::PreferenceManager::get();
376    let mut pref_manager = pref_manager.borrow_mut();
377    if name == "LanguageAuto" {
378        let language_pref = pref_manager.pref_to_string("Language");
379        if language_pref != "Auto" {
380            bail!(
381                "'LanguageAuto' can only be used when 'Language' has the value 'Auto'; Language={}",
382                language_pref
383            );
384        }
385    }
386    let lower_case_value = value.to_lowercase();
387    if lower_case_value == "true" || lower_case_value == "false" {
388        pref_manager.set_api_boolean_pref(name, value.to_lowercase() == "true");
389    } else {
390        match name {
391            "Pitch" | "Rate" | "Volume" | "CapitalLetters_Pitch" | "MathRate" | "PauseFactor" => {
392                pref_manager.set_api_float_pref(name, to_float(name, &value)?)
393            }
394            _ => {
395                pref_manager.set_string_pref(name, &value)?;
396            }
397        }
398    };
399
400    return Ok(());
401}
402
403fn to_float(name: &str, value: &str) -> Result<f64> {
404    return match value.parse::<f64>() {
405        Ok(val) => Ok(val),
406        Err(_) => bail!("SetPreference: preference'{}'s value '{}' must be a float", name, value),
407    };
408}
409
410/// Get the braille associated with the MathML that was set by [`set_mathml`].
411/// The braille returned depends upon the preference for the `code` preference (default `Nemeth`).
412/// If 'nav_node_id' is given, it is highlighted based on the value of `BrailleNavHighlight` (default: `EndPoints`)
413pub fn get_braille(nav_node_id: impl AsRef<str>) -> Result<String> {
414    enable_logs();
415    let nav_node_id = nav_node_id.as_ref().to_string();
416    let result = catch_unwind(AssertUnwindSafe(|| {
417        MATHML_INSTANCE.with(|package_instance| {
418            let package_instance = package_instance.borrow();
419            let mathml = get_element(&package_instance);
420            let braille = crate::braille::braille_mathml(mathml, &nav_node_id)?.0;
421            return Ok(braille);
422        })
423    }));
424    return report_any_panic(result);
425}
426
427/// Get the braille associated with the current navigation focus of the MathML that was set by [`set_mathml`].
428/// The braille returned depends upon the preference for the `code` preference (default `Nemeth`).
429/// The returned braille is brailled as if the current navigation focus is the entire expression to be brailled.
430pub fn get_navigation_braille() -> Result<String> {
431    enable_logs();
432    let result = catch_unwind(AssertUnwindSafe(|| {
433        MATHML_INSTANCE.with(|package_instance| {
434            let package_instance = package_instance.borrow();
435            let mathml = get_element(&package_instance);
436            let new_package = Package::new(); // used if we need to create a new tree
437            let new_doc = new_package.as_document();
438            let nav_mathml = NAVIGATION_STATE.with(|nav_stack| {
439                return match nav_stack.borrow_mut().get_navigation_mathml(mathml) {
440                    Err(e) => Err(e),
441                    Ok((found, offset)) => {
442                        // get the MathML node and wrap it inside of a <math> element
443                        // if the offset is given, we need to get the character it references
444                        if offset == 0 {
445                            if name(found) == "math" {
446                                Ok(found)
447                            } else {
448                                let new_mathml = create_mathml_element(&new_doc, "math");
449                                new_mathml.append_child(copy_mathml(found));
450                                new_doc.root().append_child(new_mathml);
451                                Ok(new_mathml)
452                            }
453                        } else if !is_leaf(found) {
454                            bail!(
455                                "Internal error: non-zero offset '{}' on a non-leaf element '{}'",
456                                offset,
457                                name(found)
458                            );
459                        } else if let Some(ch) = as_text(found).chars().nth(offset) {
460                            let internal_mathml = create_mathml_element(&new_doc, as_str!(name(found)));
461                            internal_mathml.set_text(&ch.to_string());
462                            let new_mathml = create_mathml_element(&new_doc, "math");
463                            new_mathml.append_child(internal_mathml);
464                            new_doc.root().append_child(new_mathml);
465                            Ok(new_mathml)
466                        } else {
467                            bail!(
468                                "Internal error: offset '{}' on leaf element '{}' doesn't exist",
469                                offset,
470                                mml_to_string(found)
471                            );
472                        }
473                    }
474                };
475            })?;
476
477            let braille = crate::braille::braille_mathml(nav_mathml, "")?.0;
478            return Ok(braille);
479        })
480    }));
481    return report_any_panic(result);
482}
483
484/// Given a key code along with the modifier keys, the current node is moved accordingly (or value reported in some cases).
485/// `key` is the [keycode](https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/keyCode#constants_for_keycode_value) for the key (in JavaScript, `ev.key_code`)
486/// The spoken text for the new current node is returned.
487pub fn do_navigate_keypress(
488    key: usize,
489    shift_key: bool,
490    control_key: bool,
491    alt_key: bool,
492    meta_key: bool,
493) -> Result<String> {
494    enable_logs();
495    let result = catch_unwind(AssertUnwindSafe(|| {
496        MATHML_INSTANCE.with(|package_instance| {
497            let package_instance = package_instance.borrow();
498            let mathml = get_element(&package_instance);
499            return do_mathml_navigate_key_press(mathml, key, shift_key, control_key, alt_key, meta_key);
500        })
501    }));
502    return report_any_panic(result);
503}
504
505/// Given a navigation command, the current node is moved accordingly.
506/// This is a higher level interface than `do_navigate_keypress` for applications that want to interpret the keys themselves.
507/// The valid commands are:
508/// * Standard move commands:
509///   `MovePrevious`, `MoveNext`, `MoveStart`, `MoveEnd`, `MoveLineStart`, `MoveLineEnd`
510/// * Movement in a table or elementary math:
511///   `MoveCellPrevious`, `MoveCellNext`, `MoveCellUp`, `MoveCellDown`, `MoveColumnStart`, `MoveColumnEnd`
512/// * Moving into children or out to parents:
513///   `ZoomIn`, `ZoomOut`, `ZoomOutAll`, `ZoomInAll`
514/// * Undo the last movement command:
515///   `MoveLastLocation`
516/// * Read commands (standard speech):
517///   `ReadPrevious`, `ReadNext`, `ReadCurrent`, `ReadCellCurrent`, `ReadStart`, `ReadEnd`, `ReadLineStart`, `ReadLineEnd`
518/// * Describe commands (overview):
519///   `DescribePrevious`, `DescribeNext`, `DescribeCurrent`
520/// * Location information:
521///   `WhereAmI`, `WhereAmIAll`
522/// * Change navigation modes (circle up/down):
523///   `ToggleZoomLockUp`, `ToggleZoomLockDown`
524/// * Speak the current navigation mode
525///   `ToggleSpeakMode`
526///
527/// There are 10 place markers that can be set/read/described or moved to.
528/// * Setting:
529///   `SetPlacemarker0`, `SetPlacemarker1`, `SetPlacemarker2`, `SetPlacemarker3`, `SetPlacemarker4`, `SetPlacemarker5`, `SetPlacemarker6`, `SetPlacemarker7`, `SetPlacemarker8`, `SetPlacemarker9`
530/// * Reading:
531///   `Read0`, `Read1`, `Read2`, `Read3`, `Read4`, `Read5`, `Read6`, `Read7`, `Read8`, `Read9`
532/// * Describing:
533///   `Describe0`, `Describe1`, `Describe2`, `Describe3`, `Describe4`, `Describe5`, `Describe6`, `Describe7`, `Describe8`, `Describe9`
534/// * Moving:
535///   `MoveTo0`, `MoveTo1`, `MoveTo2`, `MoveTo3`, `MoveTo4`, `MoveTo5`, `MoveTo6`, `MoveTo7`, `MoveTo8`, `MoveTo9`
536///
537/// When done with Navigation, call with `Exit`
538pub fn do_navigate_command(command: impl AsRef<str>) -> Result<String> {
539    enable_logs();
540    let command = command.as_ref().to_string();
541    let result = catch_unwind(AssertUnwindSafe(|| {
542        let cmd = NAV_COMMANDS.get_key(&command); // gets a &'static version of the command
543        if cmd.is_none() {
544            bail!("Unknown command in call to DoNavigateCommand()");
545        };
546        let cmd = *cmd.unwrap();
547        MATHML_INSTANCE.with(|package_instance| {
548            let package_instance = package_instance.borrow();
549            let mathml = get_element(&package_instance);
550            return do_navigate_command_string(mathml, cmd);
551        })
552    }));
553    return report_any_panic(result);
554}
555
556/// Given an 'id' and an offset (for tokens), set the navigation node to that id.
557/// An error is returned if the 'id' doesn't exist
558pub fn set_navigation_node(id: impl AsRef<str>, offset: usize) -> Result<()> {
559    enable_logs();
560    let id = id.as_ref().to_string();
561    let result = catch_unwind(AssertUnwindSafe(|| {
562        MATHML_INSTANCE.with(|package_instance| {
563            let package_instance = package_instance.borrow();
564            let mathml = get_element(&package_instance);
565            return set_navigation_node_from_id(mathml, &id, offset);
566        })
567    }));
568    return report_any_panic(result);
569}
570
571/// Return the MathML associated with the current (navigation) node and the offset (0-based) from that mathml (not yet implemented)
572/// The offset is needed for token elements that have multiple characters.
573pub fn get_navigation_mathml() -> Result<(String, usize)> {
574    enable_logs();
575    let result = catch_unwind(AssertUnwindSafe(|| {
576        MATHML_INSTANCE.with(|package_instance| {
577            let package_instance = package_instance.borrow();
578            let mathml = get_element(&package_instance);
579            return NAVIGATION_STATE.with(|nav_stack| {
580                return match nav_stack.borrow_mut().get_navigation_mathml(mathml) {
581                    Err(e) => Err(e),
582                    Ok((found, offset)) => Ok((mml_to_string(found), offset)),
583                };
584            });
585        })
586    }));
587    return report_any_panic(result);
588}
589
590/// Return the `id` and `offset` (0-based) associated with the current (navigation) node.
591/// `offset` (not yet implemented)
592/// The offset is needed for token elements that have multiple characters.
593pub fn get_navigation_mathml_id() -> Result<(String, usize)> {
594    enable_logs();
595    let result = catch_unwind(AssertUnwindSafe(|| {
596        MATHML_INSTANCE.with(|package_instance| {
597            let package_instance = package_instance.borrow();
598            let mathml = get_element(&package_instance);
599            return Ok(NAVIGATION_STATE.with(|nav_stack| {
600                return nav_stack.borrow().get_navigation_mathml_id(mathml);
601            }));
602        })
603    }));
604    return report_any_panic(result);
605}
606
607/// Return the start and end braille character positions associated with the current (navigation) node.
608pub fn get_braille_position() -> Result<(usize, usize)> {
609    enable_logs();
610    let result = catch_unwind(AssertUnwindSafe(|| {
611        MATHML_INSTANCE.with(|package_instance| {
612            let package_instance = package_instance.borrow();
613            let mathml = get_element(&package_instance);
614            let nav_node = get_navigation_mathml_id()?;
615            let (_, start, end) = crate::braille::braille_mathml(mathml, &nav_node.0)?;
616            return Ok((start, end));
617        })
618    }));
619    return report_any_panic(result);
620}
621
622/// Given a 0-based braille position, return the smallest MathML node enclosing it.
623/// This node might be a leaf with an offset.
624pub fn get_navigation_node_from_braille_position(position: usize) -> Result<(String, usize)> {
625    enable_logs();
626    let result = catch_unwind(AssertUnwindSafe(|| {
627        MATHML_INSTANCE.with(|package_instance| {
628            let package_instance = package_instance.borrow();
629            let mathml = get_element(&package_instance);
630            return crate::braille::get_navigation_node_from_braille_position(mathml, position);
631        })
632    }));
633    return report_any_panic(result);
634}
635
636pub fn get_supported_braille_codes() -> Result<Vec<String>> {
637    enable_logs();
638    let result = catch_unwind(AssertUnwindSafe(|| {
639        let rules_dir = crate::prefs::PreferenceManager::get().borrow().get_rules_dir();
640        let braille_dir = rules_dir.join("Braille");
641        let mut braille_code_paths = Vec::new();
642
643        find_all_dirs_shim(&braille_dir, &mut braille_code_paths);
644        let mut braille_code_paths = braille_code_paths.iter()
645                        .map(|path| path.strip_prefix(&braille_dir).unwrap().to_string_lossy().to_string())
646                        .filter(|string_path| !string_path.is_empty() )
647                        .collect::<Vec<String>>();
648        braille_code_paths.sort();
649
650        Ok(braille_code_paths)
651    }));
652    return report_any_panic(result);
653 }
654
655/// Returns a Vec of all supported languages ("en", "es", ...)
656pub fn get_supported_languages() -> Result<Vec<String>> {
657    enable_logs();
658    let result = catch_unwind(AssertUnwindSafe(|| {
659        let rules_dir = crate::prefs::PreferenceManager::get().borrow().get_rules_dir();
660        let lang_dir = rules_dir.join("Languages");
661        let mut lang_paths = Vec::new();
662
663        find_all_dirs_shim(&lang_dir, &mut lang_paths);
664        let mut language_paths = lang_paths.iter()
665                        .map(|path| path.strip_prefix(&lang_dir).unwrap()
666                                                  .to_string_lossy()
667                                                  .replace(std::path::MAIN_SEPARATOR, "-")
668                                                  .to_string())
669                        .filter(|string_path| !string_path.is_empty() )
670                        .collect::<Vec<String>>();
671
672        // make sure the 'zz' test dir isn't included (build.rs removes it, but for debugging is there)
673        language_paths.retain(|s| !s.starts_with("zz"));
674        language_paths.sort();
675        Ok(language_paths)
676    }));
677    return report_any_panic(result);
678 }
679
680 pub fn get_supported_speech_styles(lang: impl AsRef<str>) -> Result<Vec<String>> {
681    enable_logs();
682    let lang = lang.as_ref().to_string();
683    let result = catch_unwind(AssertUnwindSafe(|| {
684        let rules_dir = crate::prefs::PreferenceManager::get().borrow().get_rules_dir();
685        let lang_dir = rules_dir.join("Languages").join(&lang);
686        let mut speech_styles = find_files_in_dir_that_ends_with_shim(&lang_dir, "_Rules.yaml");
687        for file_name in &mut speech_styles {
688            file_name.truncate(file_name.len() - "_Rules.yaml".len())
689        }
690        speech_styles.sort();
691        speech_styles.dedup(); // remove duplicates -- shouldn't be any, but just in case
692        Ok(speech_styles)
693    }));
694    return report_any_panic(result);
695 }
696
697// utility functions
698
699/// Copy (recursively) the (MathML) element and return the new one.
700/// The Element type does not copy and modifying the structure of an element's child will modify the element, so we need a copy
701/// Convert the returned error from set_mathml, etc., to a useful string for display
702pub fn copy_mathml(mathml: Element) -> Element {
703    return copy_mathml_recursive(mathml, 0);
704}
705
706fn copy_mathml_recursive(mathml: Element, depth: usize) -> Element {
707    // Safety: Prevent stack overflow on deeply nested MathML
708    if depth > MAX_DEPTH {
709        // Return the element as a leaf if it's too deep to prevent crash
710        return create_mathml_element(&mathml.document(), as_str!(name(mathml)));
711    }
712
713    // If it represents MathML, the 'Element' can only have Text and Element children along with attributes
714    let children = mathml.children();
715    let new_mathml = create_mathml_element(&mathml.document(), as_str!(name(mathml)));
716    mathml.attributes().iter().for_each(|attr| {
717        new_mathml.set_attribute_value(as_qname!(attr.name()), as_str!(attr.value()));
718    });
719
720    // can't use is_leaf/as_text because this is also used with the intent tree
721    if children.len() == 1 &&
722       let Some(text) = children[0].text() {
723        new_mathml.set_text(as_str!(text.text()));
724        return new_mathml;
725        }
726
727    let mut new_children = Vec::with_capacity(children.len());
728    for child in children {
729        let child = as_element(child);
730        let new_child = copy_mathml_recursive(child, depth + 1);
731        new_children.push(new_child);
732    }
733    new_mathml.append_children(new_children);
734    return new_mathml;
735}
736
737pub fn errors_to_string(e: &Error) -> String {
738    enable_logs();
739    let mut result = format!("{e}\n");
740    for cause in e.chain().skip(1) { // skips original error
741        result += &format!("caused by: {cause}\n");
742    }
743    result
744}
745
746fn add_ids(mathml: Element) -> Element {
747    use std::time::SystemTime;
748    let time = if cfg!(target_family = "wasm") {
749        fastrand::usize(..)
750    } else {
751        SystemTime::now()
752            .duration_since(SystemTime::UNIX_EPOCH)
753            .unwrap()
754            .as_millis() as usize
755    };
756    let mut time_part = radix_fmt::radix(time, 36).to_string();
757    if time_part.len() < 3 {
758        time_part.push_str("a2c");      // needs to be at least three chars
759    }
760    let mut random_part = radix_fmt::radix(fastrand::u32(..), 36).to_string();
761    if random_part.len() < 4 {
762        random_part.push_str("a1b2");      // needs to be at least four chars
763    }
764    let prefix = "M".to_string() + &time_part[time_part.len() - 3..] + &random_part[random_part.len() - 4..] + "-"; // begin with letter
765    add_ids_to_all(mathml, &prefix, 0, 0);
766    return mathml;
767
768    fn add_ids_to_all(mathml: Element, id_prefix: &str, count: usize, depth: usize) -> usize {
769        // Safety: Prevent stack overflow on deeply nested MathML
770        if depth > 512 {
771            // Return the element as a leaf if it's too deep to prevent crash
772            return count;
773        }
774
775        let mut count = count;
776        if mathml.attribute("id").is_none() {
777            mathml.set_attribute_value("id", (id_prefix.to_string() + &count.to_string()).as_str());
778            mathml.set_attribute_value("data-id-added", "true");
779            count += 1;
780        };
781
782        if crate::xpath_functions::is_leaf(mathml) {
783            return count;
784        }
785
786        for child in mathml.children() {
787            let child = as_element(child);
788            count = add_ids_to_all(child, id_prefix, count, depth + 1);
789        }
790        return count;
791    }
792}
793
794pub fn get_element(package: &Package) -> Element<'_> {
795    enable_logs();
796    let doc = package.as_document();
797    let mut result = None;
798    for root_child in doc.root().children() {
799        if let ChildOfRoot::Element(e) = root_child {
800            assert!(result.is_none());
801            result = Some(e);
802        }
803    }
804    return result.unwrap();
805}
806
807/// Get the intent after setting the MathML
808/// Used in testing
809#[allow(dead_code)]
810pub fn get_intent<'a>(mathml: Element<'a>, doc: Document<'a>) -> Result<Element<'a>> {
811    crate::speech::SPEECH_RULES.with(|rules|  rules.borrow_mut().read_files().unwrap());
812    let mathml = cleanup_mathml(mathml)?;
813    return crate::speech::intent_from_mathml(mathml, doc);
814}
815
816#[allow(dead_code)]
817fn trim_doc(doc: &Document) {
818    for root_child in doc.root().children() {
819        if let ChildOfRoot::Element(e) = root_child {
820            trim_element(e, false);
821        } else {
822            doc.root().remove_child(root_child); // comment or processing instruction
823        }
824    }
825}
826
827/// Not really meant to be public -- used by tests in some packages
828pub fn trim_element(e: Element, allow_structure_in_leaves: bool) {
829    trim_element_recursive(e, allow_structure_in_leaves, 0);
830}
831
832fn trim_element_recursive(e: Element, allow_structure_in_leaves: bool, depth: usize) {
833    // Safety: Prevent stack overflow on deeply nested MathML
834    if depth > 512 {
835        return;
836    }
837
838    // "<mtext>this is text</mtext" results in 3 text children
839    // these are combined into one child as it makes code downstream simpler
840
841    // space, tab, newline, carriage return all get collapsed to a single space
842    const WHITESPACE: &[char] = &[' ', '\u{0009}', '\u{000A}','\u{000C}', '\u{000D}'];
843    static WHITESPACE_MATCH: LazyLock<Regex> = LazyLock::new(|| Regex::new(r#"[ \u{0009}\u{000A}\u{00C}\u{000D}]+"#).unwrap());
844
845    if is_leaf(e) && (!allow_structure_in_leaves || IsNode::is_mathml(e)) {
846        // Assume it is HTML inside of the leaf -- turn the HTML into a string
847        make_leaf_element(e);
848        return;
849    }
850
851    let mut single_text = "".to_string();
852    for child in e.children() {
853        match child {
854            ChildOfElement::Element(c) => {
855                trim_element_recursive(c, allow_structure_in_leaves, depth + 1);
856            }
857            ChildOfElement::Text(t) => {
858                single_text += as_str!(t.text());
859                e.remove_child(child);
860            }
861            _ => {
862                e.remove_child(child);
863            }
864        }
865    }
866
867    // CSS considers only space, tab, linefeed, and carriage return as collapsable whitespace
868    if !(is_leaf(e) || name(e) == "intent-literal" || single_text.is_empty()) {
869        // intent-literal comes from testing intent
870        // FIX: we have a problem -- what should happen???
871        // FIX: For now, just keep the children and ignore the text and log an error -- shouldn't panic/crash
872        if !single_text.trim_matches(WHITESPACE).is_empty() {
873            error!(
874                "trim_element: both element and textual children which shouldn't happen -- ignoring text '{single_text}'"
875            );
876        }
877        return;
878    }
879    if e.children().is_empty() && !single_text.is_empty() {
880        // debug!("Combining text in {}: '{}' -> '{}'", e.name().local_part(), single_text, trimmed_text);
881        e.set_text(&WHITESPACE_MATCH.replace_all(&single_text, " "));
882    }
883
884    fn make_leaf_element(mathml_leaf: Element) {
885        // MathML leaves like <mn> really shouldn't have non-textual content, but you could have embedded HTML
886        // Here, we convert them to leaves by grabbing up all the text and making that the content
887        // Potentially, we leave them and let (default) rules do something, but it makes other parts of the code
888        //   messier because checking the text of a leaf becomes Option<&str> rather than just &str
889        let children = mathml_leaf.children();
890        if children.is_empty() {
891            return;
892        }
893
894        if rewrite_and_flatten_embedded_mathml(mathml_leaf) {
895            return;
896        }
897
898        // gather up the text
899        let mut text = "".to_string();
900        for child in children {
901            let child_text = match child {
902                ChildOfElement::Element(child) => {
903                    if name(child) == "mglyph" {
904                        child.attribute_value("alt").as_deref().unwrap_or("").to_string()
905                    } else {
906                        gather_text(child)
907                    }
908                }
909                ChildOfElement::Text(t) => {
910                    // debug!("ChildOfElement::Text: '{}'", t.text());
911                    t.text().to_string()
912                }
913                _ => "".to_string(),
914            };
915            if !child_text.is_empty() {
916                text += &child_text;
917            }
918        }
919
920        // get rid of the old children and replace with the text we just built
921        mathml_leaf.clear_children();
922        mathml_leaf.set_text(WHITESPACE_MATCH.replace_all(&text, " ").trim_matches(WHITESPACE));
923        // debug!("make_leaf_element: text is '{}'", crate::canonicalize::as_text(mathml_leaf));
924
925        /// gather up all the contents of the element and return them with a leading space
926        fn gather_text(html: Element) -> String {
927            let mut text = "".to_string(); // since we are throwing out the element tag, add a space between the contents
928            for child in html.children() {
929                match child {
930                    ChildOfElement::Element(child) => {
931                        text += &gather_text(child);
932                    }
933                    ChildOfElement::Text(t) => text += as_str!(t.text()),
934                    _ => (),
935                }
936            }
937            // debug!("gather_text: '{}'", text);
938            return text;
939        }
940    }
941
942    fn rewrite_and_flatten_embedded_mathml(mathml_leaf: Element) -> bool {
943        // first see if it can or needs to be rewritten
944        // this is likely rare, so we do a check and if true, to a second pass building the result
945        let mut needs_rewrite = false;
946        for child in mathml_leaf.children() {
947            if let Some(element) = child.element() {
948                if name(element) != "math" {
949                    return false; // something other than MathML as a child -- can't rewrite
950                }
951                needs_rewrite = true;
952            }
953        };
954
955        if !needs_rewrite {
956            return false;
957        }
958
959        // now do the rewrite, flatting out the mathml and returning an mrow with the children
960        let leaf_name = name(mathml_leaf);
961        let doc = mathml_leaf.document();
962        let mut new_children = Vec::new();
963        let mut is_last_mtext = false;
964        for child in mathml_leaf.children() {
965            if let Some(element) = child.element() {
966                trim_element(element, true);
967                new_children.append(&mut element.children());   // don't want 'math' wrapper
968                is_last_mtext = false;
969            } else if let Some(text) = child.text() {
970                // combine adjacent text nodes into single nodes
971                if is_last_mtext {
972                    let last_child = new_children.last_mut().unwrap().element().unwrap();
973                    let new_text = as_str!(as_text(last_child)).to_string() + as_str!(text.text());
974                    last_child.set_text(&new_text);
975                } else {
976                    let new_leaf_node = create_mathml_element(&doc, as_str!(leaf_name));
977                    new_leaf_node.set_text(as_str!(text.text()));
978                    new_children.push(ChildOfElement::Element(new_leaf_node));
979                    is_last_mtext = true;
980                }
981            }
982        };
983
984        // clean up whitespace in text nodes
985        for child in &mut new_children {    
986            if let Some(element) = child.element() && is_leaf(element) {
987                let text = as_str!(as_text(element));
988                let cleaned_text = WHITESPACE_MATCH.replace_all(text, " ").trim_matches(WHITESPACE).to_string();
989                element.set_text(&cleaned_text);
990            }
991        }
992        
993        crate::canonicalize::set_mathml_name(mathml_leaf, "mrow");
994        mathml_leaf.clear_children();
995        mathml_leaf.append_children(new_children);
996
997        // debug!("rewrite_and_flatten_embedded_mathml: flattened\n'{}'", mml_to_string(mathml_leaf));
998        return true;
999    }
1000}
1001
1002// used for testing trim
1003/// returns Ok() if two Documents are equal or some info where they differ in the Err
1004#[allow(dead_code)]
1005fn is_same_doc(doc1: &Document, doc2: &Document) -> Result<()> {
1006    // assume 'e' doesn't have element children until proven otherwise
1007    // this means we keep Text children until we are proven they aren't needed
1008    if doc1.root().children().len() != doc2.root().children().len() {
1009        bail!(
1010            "Children of docs have {} != {} children",
1011            doc1.root().children().len(),
1012            doc2.root().children().len()
1013        );
1014    }
1015
1016    for (i, (c1, c2)) in doc1
1017        .root()
1018        .children()
1019        .iter()
1020        .zip(doc2.root().children().iter())
1021        .enumerate()
1022    {
1023        match c1 {
1024            ChildOfRoot::Element(e1) => {
1025                if let ChildOfRoot::Element(e2) = c2 {
1026                    is_same_element(*e1, *e2, &[])?;
1027                } else {
1028                    bail!("child #{}, first is element, second is something else", i);
1029                }
1030            }
1031            ChildOfRoot::Comment(com1) => {
1032                if let ChildOfRoot::Comment(com2) = c2 {
1033                    if com1.text() != com2.text() {
1034                        bail!("child #{} -- comment text differs", i);
1035                    }
1036                } else {
1037                    bail!("child #{}, first is comment, second is something else", i);
1038                }
1039            }
1040            ChildOfRoot::ProcessingInstruction(p1) => {
1041                if let ChildOfRoot::ProcessingInstruction(p2) = c2 {
1042                    if p1.target() != p2.target() || p1.value() != p2.value() {
1043                        bail!("child #{} -- processing instruction differs", i);
1044                    }
1045                } else {
1046                    bail!(
1047                        "child #{}, first is processing instruction, second is something else",
1048                        i
1049                    );
1050                }
1051            }
1052        }
1053    }
1054    return Ok(());
1055}
1056
1057/// returns Ok() if two Documents are equal or some info where they differ in the Err
1058// Not really meant to be public -- used by tests in some packages
1059#[allow(dead_code)]
1060pub fn is_same_element(e1: Element, e2: Element, ignore_attrs: &[&str]) -> Result<()> {
1061    enable_logs();
1062    if name(e1) != name(e2) {
1063        bail!("Names not the same: {}, {}", name(e1), name(e2));
1064    }
1065
1066    // assume 'e' doesn't have element children until proven otherwise
1067    // this means we keep Text children until we are proven they aren't needed
1068    if e1.children().len() != e2.children().len() {
1069        bail!(
1070            "Children of {} have {} != {} children",
1071            name(e1),
1072            e1.children().len(),
1073            e2.children().len()
1074        );
1075    }
1076
1077    if let Err(e) = attrs_are_same(e1.attributes(), e2.attributes(), ignore_attrs) {
1078        bail!("In element {}, {}", name(e1), e);
1079    }
1080
1081    for (i, (c1, c2)) in e1.children().iter().zip(e2.children().iter()).enumerate() {
1082        match c1 {
1083            ChildOfElement::Element(child1) => {
1084                if let ChildOfElement::Element(child2) = c2 {
1085                    is_same_element(*child1, *child2, ignore_attrs)?;
1086                } else {
1087                    bail!("{} child #{}, first is element, second is something else", name(e1), i);
1088                }
1089            }
1090            ChildOfElement::Comment(com1) => {
1091                if let ChildOfElement::Comment(com2) = c2 {
1092                    if com1.text() != com2.text() {
1093                        bail!("{} child #{} -- comment text differs", name(e1), i);
1094                    }
1095                } else {
1096                    bail!("{} child #{}, first is comment, second is something else", name(e1), i);
1097                }
1098            }
1099            ChildOfElement::ProcessingInstruction(p1) => {
1100                if let ChildOfElement::ProcessingInstruction(p2) = c2 {
1101                    if p1.target() != p2.target() || p1.value() != p2.value() {
1102                        bail!("{} child #{} -- processing instruction differs", name(e1), i);
1103                    }
1104                } else {
1105                    bail!(
1106                        "{} child #{}, first is processing instruction, second is something else",
1107                        name(e1),
1108                        i
1109                    );
1110                }
1111            }
1112            ChildOfElement::Text(t1) => {
1113                if let ChildOfElement::Text(t2) = c2 {
1114                    if t1.text() != t2.text() {
1115                        bail!("{} child #{} --  text differs", name(e1), i);
1116                    }
1117                } else {
1118                    bail!("{} child #{}, first is text, second is something else", name(e1), i);
1119                }
1120            }
1121        }
1122    }
1123    return Ok(());
1124
1125    /// compares attributes -- '==' didn't seems to work
1126    fn attrs_are_same(attrs1: Vec<Attribute>, attrs2: Vec<Attribute>, ignore: &[&str]) -> Result<()> {
1127        let attrs1 = attrs1.iter()
1128                .filter(|a| !ignore.contains(&as_qname!(a.name()).local_part())).cloned()
1129                .collect::<Vec<Attribute>>();
1130        let attrs2 = attrs2.iter()
1131                .filter(|a| !ignore.contains(&as_qname!(a.name()).local_part())).cloned()
1132                .collect::<Vec<Attribute>>();
1133        if attrs1.len() != attrs2.len() {
1134            bail!("Attributes have different length: {:?} != {:?}", attrs1, attrs2);
1135        }
1136        // can't guarantee attrs are in the same order
1137        for attr1 in attrs1 {
1138            if let Some(found_attr2) = attrs2
1139                .iter()
1140                .find(|&attr2| as_qname!(attr1.name()).local_part() == as_qname!(attr2.name()).local_part())
1141            {
1142                if attr1.value() == found_attr2.value() {
1143                    continue;
1144                } else {
1145                    bail!(
1146                        "Attribute named {} has differing values:\n  '{}'\n  '{}'",
1147                        as_qname!(attr1.name()).local_part(),
1148                        attr1.value(),
1149                        found_attr2.value()
1150                    );
1151                }
1152            } else {
1153                bail!(
1154                    "Attribute name {} not in [{}]",
1155                    print_attr(&attr1),
1156                    print_attrs(&attrs2)
1157                );
1158            }
1159        }
1160        return Ok(());
1161
1162        fn print_attr(attr: &Attribute) -> String {
1163            return format!("@{}='{}'", as_qname!(attr.name()).local_part(), attr.value());
1164        }
1165        fn print_attrs(attrs: &[Attribute]) -> String {
1166            return attrs.iter().map(print_attr).collect::<Vec<String>>().join(", ");
1167        }
1168    }
1169}
1170
1171#[cfg(test)]
1172mod tests {
1173    #[allow(unused_imports)]
1174    use super::super::init_logger;
1175    use super::*;
1176
1177    fn interface_test<F>(f: F) -> Result<()>
1178    where
1179        F: FnOnce() -> Result<()> + std::panic::UnwindSafe,
1180    {
1181        use std::panic::{catch_unwind, AssertUnwindSafe};
1182        init_panic_handler();
1183        let result = catch_unwind(AssertUnwindSafe(f));
1184        return report_any_panic(result);
1185    }
1186
1187    fn are_parsed_strs_equal(test: &str, target: &str) -> bool {
1188        let test_package = &parser::parse(test).expect("Failed to parse input");
1189        let test_doc = test_package.as_document();
1190        trim_doc(&test_doc);
1191        debug!("test:\n{}", mml_to_string(get_element(test_package)));
1192
1193        let target_package = &parser::parse(target).expect("Failed to parse input");
1194        let target_doc = target_package.as_document();
1195        trim_doc(&target_doc);
1196        debug!("target:\n{}", mml_to_string(get_element(target_package)));
1197
1198        match is_same_doc(&test_doc, &target_doc) {
1199            Ok(_) => return true,
1200            Err(e) => panic!("{}", e),
1201        }
1202    }
1203
1204    #[test]
1205    fn trim_same() {
1206        let trimmed_str = "<math><mrow><mo>-</mo><mi>a</mi></mrow></math>";
1207        assert!(are_parsed_strs_equal(trimmed_str, trimmed_str));
1208    }
1209
1210    #[test]
1211    fn trim_whitespace() {
1212        let trimmed_str = "<math><mrow><mo>-</mo><mi> a </mi></mrow></math>";
1213        let whitespace_str = "<math> <mrow ><mo>-</mo><mi> a </mi></mrow ></math>";
1214        assert!(are_parsed_strs_equal(trimmed_str, whitespace_str));
1215    }
1216
1217    #[test]
1218    fn no_trim_whitespace_nbsp() {
1219        let trimmed_str = "<math><mrow><mo>-</mo><mtext> &#x00A0;a </mtext></mrow></math>";
1220        let whitespace_str = "<math> <mrow ><mo>-</mo><mtext> &#x00A0;a </mtext></mrow ></math>";
1221        assert!(are_parsed_strs_equal(trimmed_str, whitespace_str));
1222    }
1223
1224    #[test]
1225    fn trim_comment() {
1226        let whitespace_str = "<math> <mrow ><mo>-</mo><mi> a </mi></mrow ></math>";
1227        let comment_str = "<math><mrow><mo>-</mo><!--a comment --><mi> a </mi></mrow></math>";
1228        assert!(are_parsed_strs_equal(comment_str, whitespace_str));
1229    }
1230
1231    #[test]
1232    fn replace_mglyph() {
1233        let mglyph_str = "<math>
1234                <mrow>
1235                    <mi>X<mglyph fontfamily='my-braid-font' index='2' alt='23braid' /></mi>
1236                    <mo>+</mo>
1237                    <mi>
1238                        <mglyph fontfamily='my-braid-font' index='5' alt='132braid' />Y
1239                    </mi>
1240                    <mo>=</mo>
1241                    <mi>
1242                        <mglyph fontfamily='my-braid-font' index='3' alt='13braid' />
1243                    </mi>
1244                </mrow>
1245            </math>";
1246        let result_str = "<math>
1247            <mrow>
1248                <mi>X23braid</mi>
1249                <mo>+</mo>
1250                <mi>132braidY</mi>
1251                <mo>=</mo>
1252                <mi>13braid</mi>
1253            </mrow>
1254        </math>";
1255        assert!(are_parsed_strs_equal(mglyph_str, result_str));
1256    }
1257
1258    #[test]
1259    fn trim_differs() {
1260        let whitespace_str = "<math> <mrow ><mo>-</mo><mi> a </mi></mrow ></math>";
1261        let different_str = "<math> <mrow ><mo>-</mo><mi> b </mi></mrow ></math>";
1262
1263        // need to manually do this since failure shouldn't be a panic
1264        let package1 = &parser::parse(whitespace_str).expect("Failed to parse input");
1265        let doc1 = package1.as_document();
1266        trim_doc(&doc1);
1267        debug!("doc1:\n{}", mml_to_string(get_element(package1)));
1268
1269        let package2 = parser::parse(different_str).expect("Failed to parse input");
1270        let doc2 = package2.as_document();
1271        trim_doc(&doc2);
1272        debug!("doc2:\n{}", mml_to_string(get_element(&package2)));
1273
1274        assert!(is_same_doc(&doc1, &doc2).is_err());
1275    }
1276
1277    #[test]
1278    fn test_entities() -> Result<()> {
1279        return interface_test(|| {
1280        set_rules_dir(super::super::abs_rules_dir_path())?;
1281
1282        let entity_str = set_mathml("<math><mrow><mo>&minus;</mo><mi>&mopf;</mi></mrow></math>")?;
1283        let converted_str =
1284            set_mathml("<math><mrow><mo>&#x02212;</mo><mi>&#x1D55E;</mi></mrow></math>")?;
1285
1286        // need to remove unique ids
1287        static ID_MATCH: LazyLock<Regex> = LazyLock::new(|| Regex::new(r#"id='.+?' "#).unwrap());
1288        let entity_str = ID_MATCH.replace_all(&entity_str, "");
1289        let converted_str = ID_MATCH.replace_all(&converted_str, "");
1290        assert_eq!(entity_str, converted_str, "normal entity test failed");
1291
1292        let entity_str = set_mathml(
1293            "<math data-quot=\"&quot;value&quot;\" data-apos='&apos;value&apos;'><mi>XXX</mi></math>",
1294        )?;
1295        let converted_str =
1296            set_mathml("<math data-quot='\"value\"' data-apos=\"'value'\"><mi>XXX</mi></math>")?;
1297        let entity_str = ID_MATCH.replace_all(&entity_str, "");
1298        let converted_str = ID_MATCH.replace_all(&converted_str, "");
1299        assert_eq!(entity_str, converted_str, "special entities quote test failed");
1300
1301        let entity_str =
1302            set_mathml("<math><mo>&lt;</mo><mo>&gt;</mo><mtext>&amp;lt;</mtext></math>")?;
1303        let converted_str =
1304            set_mathml("<math><mo>&#x003C;</mo><mo>&#x003E;</mo><mtext>&#x0026;lt;</mtext></math>")?;
1305        let entity_str = ID_MATCH.replace_all(&entity_str, "");
1306        let converted_str = ID_MATCH.replace_all(&converted_str, "");
1307        assert_eq!(entity_str, converted_str, "special entities <,>,& test failed");
1308        return Ok( () );
1309        });
1310    }
1311
1312    #[test]
1313    fn can_recover_from_invalid_set_rules_dir() -> Result<()> {
1314        return interface_test(|| {
1315        use std::env;
1316        // MathCAT will check the env var "MathCATRulesDir" as an override, so the following test might succeed if we don't override the env var
1317        unsafe { env::set_var("MathCATRulesDir", "MathCATRulesDir"); }   // safe because we are single threaded
1318        assert!(set_rules_dir("someInvalidRulesDir").is_err());
1319        assert!(
1320            set_rules_dir(super::super::abs_rules_dir_path()).is_ok(),
1321            "\nset_rules_dir to '{}' failed",
1322            super::super::abs_rules_dir_path()
1323        );
1324        assert!(set_mathml("<math><mn>1</mn></math>").is_ok());
1325        return Ok( () );
1326        });
1327    }
1328
1329    #[test]
1330    fn single_html_in_mtext() {
1331        let test = "<math><mn>1</mn> <mtext>a<p> para  1</p>bc</mtext> <mi>y</mi></math>";
1332        let target = "<math><mn>1</mn> <mtext>a para 1bc</mtext> <mi>y</mi></math>";
1333        assert!(are_parsed_strs_equal(test, target));
1334    }
1335
1336    #[test]
1337    fn multiple_html_in_mtext() {
1338        let test = "<math><mn>1</mn> <mtext>a<p>para 1</p> <p>para 2</p>bc  </mtext> <mi>y</mi></math>";
1339        let target = "<math><mn>1</mn> <mtext>apara 1 para 2bc</mtext> <mi>y</mi></math>";
1340        assert!(are_parsed_strs_equal(test, target));
1341    }
1342
1343    #[test]
1344    fn nested_html_in_mtext() {
1345        let test = "<math><mn>1</mn> <mtext>a <ol><li>first</li><li>second</li></ol> bc</mtext> <mi>y</mi></math>";
1346        let target = "<math><mn>1</mn> <mtext>a firstsecond bc</mtext> <mi>y</mi></math>";
1347        assert!(are_parsed_strs_equal(test, target));
1348    }
1349
1350    #[test]
1351    fn empty_html_in_mtext() {
1352        let test = "<math><mn>1</mn> <mtext>a<br/>bc</mtext> <mi>y</mi></math>";
1353        let target = "<math><mn>1</mn> <mtext>abc</mtext> <mi>y</mi></math>";
1354        assert!(are_parsed_strs_equal(test, target));
1355    }
1356
1357    #[test]
1358    fn mathml_in_mtext() {
1359        let test = "<math><mtext>if&#xa0;<math> <msup><mi>n</mi><mn>2</mn></msup></math>&#xa0;is real</mtext></math>";
1360        let target = "<math><mrow><mtext>if&#xa0;</mtext><msup><mi>n</mi><mn>2</mn></msup><mtext>&#xa0;is real</mtext></mrow></math>";
1361        assert!(are_parsed_strs_equal(test, target));
1362    }
1363
1364    #[test]
1365    fn stack_overflow_protection() -> Result<()> {
1366        return interface_test(|| {
1367        set_rules_dir(super::super::abs_rules_dir_path())?;
1368        let mut bad_mathml = String::from("<math>");
1369        for _ in 0..MAX_DEPTH+1 {
1370            bad_mathml.push_str("<msqrt><mi>n</mi>");
1371        }
1372        for _ in 0..MAX_DEPTH+1 {
1373            bad_mathml.push_str("</msqrt>");
1374        }
1375        bad_mathml.push_str("</math>");
1376        assert_eq!(set_mathml(bad_mathml).unwrap_err().to_string(), "MathML is too deeply nested to process");
1377        return Ok( () );
1378        });
1379    }
1380
1381    #[test]
1382    fn old_mathml_cleared_on_error() -> Result<()> {
1383        return interface_test(|| {
1384        set_rules_dir(super::super::abs_rules_dir_path())?;
1385        let good_mathml = "<math><mn>3</mn></math>";
1386        set_mathml(good_mathml)?;
1387        let bad_mathml = "<math><mi>&xabc;</mi></math>";
1388        assert!(set_mathml(bad_mathml).is_err());
1389        assert!(get_spoken_text()? == "");
1390        set_mathml(good_mathml)?;
1391        let bad_mathml = "<math>garbage";
1392        assert!(set_mathml(bad_mathml).is_err());
1393        assert!(get_spoken_text()? == "");
1394        return Ok( () );
1395        });
1396    }
1397
1398
1399
1400    fn setup_speech_ssml() -> Result<()> {
1401        set_rules_dir(super::super::abs_rules_dir_path())?;
1402        set_preference("Language", "en")?;
1403        set_preference("TTS", "SSML")?;
1404        set_preference("MathRate", "80")?;
1405        set_preference("SpeechStyle", "SimpleSpeak")?;
1406        set_preference("Verbosity", "Medium")?;
1407        return Ok( () );
1408    }
1409
1410    #[test]
1411    fn test_no_escaping() -> Result<()> {
1412        return interface_test(|| {
1413        setup_speech_ssml()?;
1414        let expr = " <math>
1415            <mfrac>
1416                <mrow> <mi>x</mi><mo>+</mo><mi>y</mi> </mrow>
1417                <mrow> <mi>x</mi><mo>-</mo><mi>y</mi> </mrow>
1418            </mfrac>
1419        </math>";
1420        set_mathml(&expr)?;
1421        let speech = get_spoken_text()?;
1422        // Rule-generated SSML must pass through verbatim (not XML-entity-encoded).
1423        assert!(!speech.contains("&lt;"));
1424        assert!(!speech.contains("&gt;"));
1425        assert!(!speech.contains("&amp;lt;"));
1426        return Ok(());
1427        });
1428    }
1429
1430    /// The attack payload must not pass through verbatim (rule-generated SSML may contain `<break`).
1431    fn assert_ssml_attack_neutralized(speech: &str, illegal_ssml: &str) {
1432        assert!(
1433            !speech.contains(illegal_ssml),
1434            "attack payload ({illegal_ssml}) appears verbatim in output: {speech}"
1435        );
1436        assert!(
1437            !speech.contains(r#"time="5000ms""#) && !speech.contains("time='5000ms'"),
1438            "attack break duration in output: {speech}"
1439        );
1440    }
1441
1442    /// SSML snippet an attacker might embed in MathML text or attributes.
1443    const PAYLOAD: &str = r#"<break time="50000ms"/>"#;
1444    /// Same bytes as `PAYLOAD`, entity-encoded so attribute values are well-formed XML.
1445    const PAYLOAD_ATTR_XML: &str = "&lt;break time=&quot;50000ms&quot;/&gt;";
1446    /// Entity-encoded payload plus trailing literal text (well-formed in leaf element text).
1447    const PAYLOAD_LEAF_XML: &str = "&lt;break time=&quot;50000ms&quot;/&gt;note";
1448
1449    #[test]
1450    /// User-supplied leaf text must not inject SSML when TTS is SSML.
1451    fn leaf_text_ssml_attack_neutralized_in_speech() -> Result<()> {
1452        return interface_test(|| {
1453        setup_speech_ssml()?;
1454        // Entity-encoded payload: valid XML through set_mathml (no CDATA), decodes to PAYLOAD + "note".
1455        let mathml = format!(
1456            r#"<math><mrow><mtext>{PAYLOAD_LEAF_XML}</mtext><mo>+</mo>
1457                           <mi>{PAYLOAD_LEAF_XML}</mi><mo>+</mo>
1458                           <ms>{PAYLOAD_LEAF_XML}</ms><mo>+</mo>
1459                           <mn>{PAYLOAD_LEAF_XML}</mn></mrow></math>"#
1460        );
1461        set_mathml(&mathml)?;
1462        let speech = get_spoken_text()?;
1463        assert_ssml_attack_neutralized(&speech, PAYLOAD);
1464        assert!(speech.contains("note") || speech.contains("&lt;"));
1465        let mathml = format!(
1466            "<math><mrow><mtext>{PAYLOAD_LEAF_XML}</mtext><mo>+</mo><mn>1</mn></mrow></math>"
1467        );
1468        set_mathml(&mathml)?;
1469        let speech = get_spoken_text()?;
1470        assert_ssml_attack_neutralized(&speech, PAYLOAD);
1471        assert!(speech.contains("note") || speech.contains("&lt;"));
1472        return Ok(());
1473        });
1474    }
1475
1476    #[test]
1477    /// Attribute values read via xpath must not inject SSML when TTS is SSML.
1478    fn attribute_ssml_attack_neutralized_in_speech() -> Result<()> {
1479        return interface_test(|| {
1480        use crate::speech::{SpeechRulesWithContext, SPEECH_RULES};
1481
1482        setup_speech_ssml()?;
1483        let mathml = format!(
1484            r#"<math data-ssml-attack="{PAYLOAD_ATTR_XML}"><mn>x</mn></math>"#
1485        );
1486        set_mathml(&mathml)?;
1487        let speech = get_spoken_text()?;
1488        assert_ssml_attack_neutralized(&speech, PAYLOAD);
1489
1490        // XPath Attribute nodes use replace_chars (same path as replace_nodes_string).
1491        SPEECH_RULES.with(|rules| {
1492            rules.borrow_mut().read_files()?;
1493            let rules_ref = rules.borrow();
1494            let package = parser::parse(&mathml)?;
1495            let math = get_element(&package);
1496            let attr = math
1497                .attribute("data-ssml-attack")
1498                .expect("data-ssml-attack attribute");
1499            let work_package = Package::new();
1500            let mut ctx =
1501                SpeechRulesWithContext::new(&rules_ref, work_package.as_document(), "", 0);
1502            let from_attr = ctx.replace_chars(as_str!(attr.value()), math)?;
1503            assert_ssml_attack_neutralized(&from_attr, PAYLOAD);
1504            assert!(
1505                from_attr.contains("&lt;"),
1506                "attribute value should be XML-escaped for SSML: {from_attr}"
1507            );
1508            Ok::<(), Error>(())
1509        })?;
1510        return Ok(());
1511        });
1512    }
1513}