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