From c4d1241ca989a2c348dc146e48ca722fb84bc9ff Mon Sep 17 00:00:00 2001 From: Mason Smith Date: Thu, 29 Jan 2026 23:57:43 -0800 Subject: [PATCH 01/10] add a rule for array/table intents --- Rules/Intent/general.yaml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/Rules/Intent/general.yaml b/Rules/Intent/general.yaml index 068c4f59..e6fb38e2 100644 --- a/Rules/Intent/general.yaml +++ b/Rules/Intent/general.yaml @@ -801,6 +801,20 @@ name: "system-of-equations" children: - x: "*" +- + name: mtable-array-property + tag: mtable + match: "count(*) > 0 and ((@frame='solid' or @frame='dashed') or descendant::*[@rowspan])" + replace: + - with: + variables: + - TableProperty: "'array'" + replace: + - intent: + name: "array" + children: + - x: "*" + - name: mtable-lines-property From 520e4481d0a43bda6c2c229e381312a0f317850c Mon Sep 17 00:00:00 2001 From: Mason Smith Date: Wed, 11 Feb 2026 21:29:01 -0800 Subject: [PATCH 02/10] implement CountTableRows, CountTableDims xpath functions --- src/xpath_functions.rs | 102 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 101 insertions(+), 1 deletion(-) diff --git a/src/xpath_functions.rs b/src/xpath_functions.rs index e08329af..a2badf80 100644 --- a/src/xpath_functions.rs +++ b/src/xpath_functions.rs @@ -1413,6 +1413,83 @@ impl Function for ReplaceAll { } } +pub struct CountTableDims; +impl CountTableDims { + /// For an `mtable` element, count the number of rows and columns in the table. + /// + /// This function is relatively permissive. Non-`mtr` rows are + /// ignored. The number of columns is determined only from the first + /// row, if it exists. Within that row, non-`mtd` elements are ignored. + fn count_table_dims<'d>(e: Element<'_>) -> Result<(Value<'d>, Value<'d>), Error> { + if e.name().local_part() != "mtable" { + return Err(Error::Other(format!("invalid tag {} for CountTableRows", e.name().local_part()))); + } + let mut num_cols = 0; + let mut num_rows = 0; + for child in e.children() { + let ChildOfElement::Element(row) = child else { + continue + }; + + // each child of mtable should be an mtr. Ignore non-mtr rows. + if name(row) != "mtr" { + continue; + } + num_rows += 1; + + // count columns based on the number of rows. + if num_rows == 1 { + // count the number of columns, including column spans, in the first row. + for row_child in row.children() { + let ChildOfElement::Element(mtd) = row_child else { + continue; + }; + if name(mtd) != "mtd" { + continue; + } + // add the contributing columns, taking colspan into account + let colspan = mtd.attribute_value("colspan").map_or(1, |e| e.parse::().unwrap_or(0)); + num_cols += colspan; + } + } + } + + Ok((Value::Number(num_rows as f64), Value::Number(num_cols as f64))) + } + + fn evaluate<'c, 'd>(fn_name: &str, + args: Vec>) -> Result<(Value<'d>, Value<'d>), Error> { + let mut args = Args(args); + args.exactly(1)?; + let element = args.pop_nodeset()?; + let node = validate_one_node(element, fn_name)?; + if let Node::Element(e) = node { + return Ok(Self::count_table_dims(e)?); + } + + Err( Error::Other(format!("couldn't count table rows")) ) + } +} + +pub struct CountTableRows; +impl Function for CountTableRows { + fn evaluate<'c, 'd>(&self, + _context: &context::Evaluation<'c, 'd>, + args: Vec>) -> Result, Error> { + CountTableDims::evaluate("CountTableRows", args).map(|a| a.0) + } +} + +pub struct CountTableCols; +impl Function for CountTableCols { + fn evaluate<'c, 'd>(&self, + _context: &context::Evaluation<'c, 'd>, + args: Vec>) -> Result, Error> { + CountTableDims::evaluate("CountTableCols", args).map(|a| a.1) + } +} + + /// Add all the functions defined in this module to `context`. pub fn add_builtin_functions(context: &mut Context) { context.set_function("NestingChars", crate::braille::NemethNestingChars); @@ -1432,6 +1509,8 @@ pub fn add_builtin_functions(context: &mut Context) { context.set_function("SpeakIntentName", SpeakIntentName); context.set_function("GetBracketingIntentName", GetBracketingIntentName); context.set_function("GetNavigationPartName", GetNavigationPartName); + context.set_function("CountTableRows", CountTableRows); + context.set_function("CountTableCols", CountTableCols); context.set_function("DEBUG", Debug); // Not used: remove?? @@ -1606,6 +1685,27 @@ mod tests { } + #[test] + fn table_row_count() { + let mathml = "a"; + let package = parser::parse(mathml).expect("failed to parse XML"); + let math_elem = get_element(&package); + let child = as_element(math_elem.children()[0]); + assert!(CountTableDims::count_table_dims(child).is_err()); + + let mathml = "a"; + let package = parser::parse(mathml).expect("failed to parse XML"); + let math_elem = get_element(&package); + let child = as_element(math_elem.children()[0]); + assert!(CountTableDims::count_table_dims(child) == Ok((Value::Number(1.0), Value::Number(1.0)))); + + let mathml = "ab"; + let package = parser::parse(mathml).expect("failed to parse XML"); + let math_elem = get_element(&package); + let child = as_element(math_elem.children()[0]); + assert!(CountTableDims::count_table_dims(child) == Ok((Value::Number(2.0), Value::Number(4.0)))); + } + #[test] fn at_left_edge() { let mathml = "30x4"; @@ -1636,4 +1736,4 @@ mod tests { let mn = as_element(as_element(fraction.children()[1]).children()[0]); assert_eq!(EdgeNode::edge_node(mn, true, "2D"), None); } -} \ No newline at end of file +} From 3761b9f51d9a1d5de0acf766738134b89df43c41 Mon Sep 17 00:00:00 2001 From: Mason Smith Date: Thu, 19 Feb 2026 00:13:52 -0800 Subject: [PATCH 03/10] add debug constraint to TreeOrString --- src/speech.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/speech.rs b/src/speech.rs index 71fdae55..0edfeb6e 100644 --- a/src/speech.rs +++ b/src/speech.rs @@ -7,6 +7,7 @@ use std::path::PathBuf; use std::collections::HashMap; use std::cell::{RefCell, RefMut}; use std::sync::LazyLock; +use std::fmt::Debug; use sxd_document::dom::{ChildOfElement, Document, Element}; use sxd_document::{Package, QName}; use sxd_xpath::context::Evaluation; @@ -311,7 +312,7 @@ pub fn process_include(current_file: &Path, new_file_name: &str, mut read_new /// As the name says, TreeOrString is either a Tree (Element) or a String /// It is used to share code during pattern matching -pub trait TreeOrString<'c, 'm:'c, T> { +pub trait TreeOrString<'c, 'm:'c, T: Debug> : Debug { fn from_element(e: Element<'m>) -> Result; fn from_string(s: String, doc: Document<'m>) -> Result; fn replace_tts<'s:'c, 'r>(tts: &TTS, command: &TTSCommandRule, prefs: &PreferenceManager, rules_with_context: &'r mut SpeechRulesWithContext<'c, 's,'m>, mathml: Element<'c>) -> Result; From 8c55efc19ef85679a37aa7f2bd54e4452dab302e Mon Sep 17 00:00:00 2001 From: Mason Smith Date: Thu, 19 Feb 2026 00:14:38 -0800 Subject: [PATCH 04/10] incorporate CountTableRows/Columns into mtable / array speech rules --- Rules/Languages/en/SharedRules/default.yaml | 11 ++++++----- src/xpath_functions.rs | 17 ++++------------- 2 files changed, 10 insertions(+), 18 deletions(-) diff --git a/Rules/Languages/en/SharedRules/default.yaml b/Rules/Languages/en/SharedRules/default.yaml index 2a715c5a..162aba35 100644 --- a/Rules/Languages/en/SharedRules/default.yaml +++ b/Rules/Languages/en/SharedRules/default.yaml @@ -419,22 +419,23 @@ - t: "end scripts" # phrase(At this point 'end scripts' occurs) - name: default - tag: mtable + tag: [mtable, array] variables: - IsColumnSilent: "false()" - - NumColumns: "count(*[1]/*) - IfThenElse(*/self::m:mlabeledtr, 1, 0)" + - NumColumns: "CountTableColumns(.)" + - NumRows: "CountTableRows(.)" match: "." replace: - t: "table with" # phrase(the 'table with' 3 rows) - - x: count(*) + - x: "$NumRows" - test: - if: count(*)=1 + if: "$NumRows=1" then: [t: "row"] # phrase(the table with 1 'row') else: [t: "rows"] # phrase(the table with 3 'rows') - t: "and" # phrase(the table with 3 rows 'and' 4 columns) - x: "$NumColumns" - test: - if: "NumColumns=1" + if: "$NumColumns=1" then: [t: "column"] # phrase(the table with 3 rows and 1 'column') else: [t: "columns"] # phrase(the table with 3 rows and 4 'columns') - pause: long diff --git a/src/xpath_functions.rs b/src/xpath_functions.rs index a2badf80..566c8953 100644 --- a/src/xpath_functions.rs +++ b/src/xpath_functions.rs @@ -1421,9 +1421,6 @@ impl CountTableDims { /// ignored. The number of columns is determined only from the first /// row, if it exists. Within that row, non-`mtd` elements are ignored. fn count_table_dims<'d>(e: Element<'_>) -> Result<(Value<'d>, Value<'d>), Error> { - if e.name().local_part() != "mtable" { - return Err(Error::Other(format!("invalid tag {} for CountTableRows", e.name().local_part()))); - } let mut num_cols = 0; let mut num_rows = 0; for child in e.children() { @@ -1480,12 +1477,12 @@ impl Function for CountTableRows { } } -pub struct CountTableCols; -impl Function for CountTableCols { +pub struct CountTableColumns; +impl Function for CountTableColumns { fn evaluate<'c, 'd>(&self, _context: &context::Evaluation<'c, 'd>, args: Vec>) -> Result, Error> { - CountTableDims::evaluate("CountTableCols", args).map(|a| a.1) + CountTableDims::evaluate("CountTableColumns", args).map(|a| a.1) } } @@ -1510,7 +1507,7 @@ pub fn add_builtin_functions(context: &mut Context) { context.set_function("GetBracketingIntentName", GetBracketingIntentName); context.set_function("GetNavigationPartName", GetNavigationPartName); context.set_function("CountTableRows", CountTableRows); - context.set_function("CountTableCols", CountTableCols); + context.set_function("CountTableColumns", CountTableColumns); context.set_function("DEBUG", Debug); // Not used: remove?? @@ -1687,12 +1684,6 @@ mod tests { #[test] fn table_row_count() { - let mathml = "a"; - let package = parser::parse(mathml).expect("failed to parse XML"); - let math_elem = get_element(&package); - let child = as_element(math_elem.children()[0]); - assert!(CountTableDims::count_table_dims(child).is_err()); - let mathml = "a"; let package = parser::parse(mathml).expect("failed to parse XML"); let math_elem = get_element(&package); From 7eea7338692d9e7577f18aac3756fcd4b80b412e Mon Sep 17 00:00:00 2001 From: Mason Smith Date: Thu, 19 Feb 2026 00:20:41 -0800 Subject: [PATCH 05/10] CountTableRows/Columns correctly supported --- src/xpath_functions.rs | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/src/xpath_functions.rs b/src/xpath_functions.rs index 566c8953..903f19b5 100644 --- a/src/xpath_functions.rs +++ b/src/xpath_functions.rs @@ -1429,14 +1429,21 @@ impl CountTableDims { }; // each child of mtable should be an mtr. Ignore non-mtr rows. - if name(row) != "mtr" { + let row_name = name(row); + + let labeled_row = if row_name == "mlabeledtr" { + true + } else if row_name == "mtr" { + false + } else { continue; - } + }; num_rows += 1; // count columns based on the number of rows. if num_rows == 1 { // count the number of columns, including column spans, in the first row. + let mut first_elem = true; for row_child in row.children() { let ChildOfElement::Element(mtd) = row_child else { continue; @@ -1444,9 +1451,13 @@ impl CountTableDims { if name(mtd) != "mtd" { continue; } - // add the contributing columns, taking colspan into account + // Add the contributing columns, taking colspan into account. Don't contribute if + // this is the first element of a labeled row. let colspan = mtd.attribute_value("colspan").map_or(1, |e| e.parse::().unwrap_or(0)); - num_cols += colspan; + if !(labeled_row && first_elem) { + num_cols += colspan; + } + first_elem = false; } } } @@ -1695,6 +1706,13 @@ mod tests { let math_elem = get_element(&package); let child = as_element(math_elem.children()[0]); assert!(CountTableDims::count_table_dims(child) == Ok((Value::Number(2.0), Value::Number(4.0)))); + + let mathml = "labelabcd"; + let package = parser::parse(mathml).expect("failed to parse XML"); + let math_elem = get_element(&package); + let child = as_element(math_elem.children()[0]); + let ctd = CountTableDims::count_table_dims(child); + assert!(ctd == Ok((Value::Number(2.0), Value::Number(2.0)))); } #[test] From 15cd6524fd9b8314cec3004f75807a8fa4a3aa4f Mon Sep 17 00:00:00 2001 From: Mason Smith Date: Fri, 27 Feb 2026 10:44:57 -0800 Subject: [PATCH 06/10] improve array intent rule find rowspan or colspan attributes only on children and grandchildren of mtable element --- Rules/Intent/general.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Rules/Intent/general.yaml b/Rules/Intent/general.yaml index e6fb38e2..952b261b 100644 --- a/Rules/Intent/general.yaml +++ b/Rules/Intent/general.yaml @@ -804,7 +804,7 @@ - name: mtable-array-property tag: mtable - match: "count(*) > 0 and ((@frame='solid' or @frame='dashed') or descendant::*[@rowspan])" + match: "count(*) > 0 and ((@frame='solid' or @frame='dashed') or child::*[@rowspan]) or child::*/child::*[@rowspan or @colspan]" replace: - with: variables: From 522ff301d31b25ad98c2f2674b324a7918d1a660 Mon Sep 17 00:00:00 2001 From: Mason Smith Date: Fri, 27 Feb 2026 10:45:35 -0800 Subject: [PATCH 07/10] add a couple of tests for new array intent logic --- tests/Languages/en/mtable.rs | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/tests/Languages/en/mtable.rs b/tests/Languages/en/mtable.rs index 0207d6c0..bfca16a7 100644 --- a/tests/Languages/en/mtable.rs +++ b/tests/Languages/en/mtable.rs @@ -1028,9 +1028,8 @@ let expr = "( test_ClearSpeak("en", "ClearSpeak_Matrix", "EndVector", expr, "the 2 by 2 matrix; row 1; column 1; b sub 1 1; column 2; b sub 1 2; \ row 2; column 1; b sub 2 1; column 2; b sub 2 2; end matrix")?; - return Ok(()); - } - + return Ok(()); +} #[test] @@ -1041,11 +1040,28 @@ fn matrix_binomial() -> Result<()> { ) "; test_ClearSpeak("en", "ClearSpeak_Matrix", "Combinatorics", expr, "3 choose 2")?; - return Ok(()); - } + return Ok(()); +} + +#[test] +fn matrix_simple_table() { + let expr = " + 32 + "; + test("en", "ClearSpeak", expr, "table with 2 rows and 1 column; row 1; column 1; 3; row 2; column 1; 2"); +} + +#[test] +fn matrix_span_table() { + let expr = " + 32 + "; + test("en", "ClearSpeak", expr, "table with 2 rows and 1 column; row 1; column 1; 3; row 2; column 1; 2"); +} + #[test] -fn matrix_times() -> Result<()> { +fn matrix_times() { let expr = " 1234 abcd From 8ad6f7b2717510e259f4853f9592cba07c9793bf Mon Sep 17 00:00:00 2001 From: Mason Smith Date: Fri, 27 Feb 2026 10:59:02 -0800 Subject: [PATCH 08/10] capture and ignore `Result`s in tests --- tests/Languages/en/mtable.rs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/tests/Languages/en/mtable.rs b/tests/Languages/en/mtable.rs index bfca16a7..9a47b986 100644 --- a/tests/Languages/en/mtable.rs +++ b/tests/Languages/en/mtable.rs @@ -1048,7 +1048,7 @@ fn matrix_simple_table() { let expr = " 32 "; - test("en", "ClearSpeak", expr, "table with 2 rows and 1 column; row 1; column 1; 3; row 2; column 1; 2"); + let _ = test("en", "ClearSpeak", expr, "table with 2 rows and 1 column; row 1; column 1; 3; row 2; column 1; 2"); } #[test] @@ -1056,7 +1056,7 @@ fn matrix_span_table() { let expr = " 32 "; - test("en", "ClearSpeak", expr, "table with 2 rows and 1 column; row 1; column 1; 3; row 2; column 1; 2"); + let _ = test("en", "ClearSpeak", expr, "table with 2 rows and 1 column; row 1; column 1; 3; row 2; column 1; 2"); } @@ -1066,10 +1066,9 @@ fn matrix_times() { 1234 abcd "; - test("en", "SimpleSpeak", expr, - "the 2 by 2 matrix; row 1; 1, 2; row 2; 3, 4; times, the 2 by 2 matrix; row 1; eigh, b; row 2; c, d")?; - return Ok(()); - } + let _ = test("en", "SimpleSpeak", expr, + "the 2 by 2 matrix; row 1; 1, 2; row 2; 3, 4; times, the 2 by 2 matrix; row 1; eigh, b; row 2; c, d"); +} #[test] fn unknown_mtable_property() -> Result<()> { From 4fb49492cffaf927d2f5008ee675b02d6f4cc982 Mon Sep 17 00:00:00 2001 From: Mason Smith Date: Fri, 27 Feb 2026 11:40:14 -0800 Subject: [PATCH 09/10] clippy fixes --- src/xpath_functions.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/xpath_functions.rs b/src/xpath_functions.rs index 903f19b5..be3f0dd8 100644 --- a/src/xpath_functions.rs +++ b/src/xpath_functions.rs @@ -1465,17 +1465,17 @@ impl CountTableDims { Ok((Value::Number(num_rows as f64), Value::Number(num_cols as f64))) } - fn evaluate<'c, 'd>(fn_name: &str, + fn evaluate<'d>(fn_name: &str, args: Vec>) -> Result<(Value<'d>, Value<'d>), Error> { let mut args = Args(args); args.exactly(1)?; let element = args.pop_nodeset()?; let node = validate_one_node(element, fn_name)?; if let Node::Element(e) = node { - return Ok(Self::count_table_dims(e)?); + return Self::count_table_dims(e); } - Err( Error::Other(format!("couldn't count table rows")) ) + Err( Error::Other("couldn't count table rows".to_string()) ) } } From 7d09dfadaf67461b078d61a12dafec58bc63f40e Mon Sep 17 00:00:00 2001 From: Mason Smith Date: Sun, 1 Mar 2026 17:16:38 -0800 Subject: [PATCH 10/10] review feedback: make CountTableDims et al not public, fix formatting --- src/xpath_functions.rs | 112 ++++++++++++++++++++--------------------- 1 file changed, 56 insertions(+), 56 deletions(-) diff --git a/src/xpath_functions.rs b/src/xpath_functions.rs index be3f0dd8..0d2a91db 100644 --- a/src/xpath_functions.rs +++ b/src/xpath_functions.rs @@ -1413,7 +1413,7 @@ impl Function for ReplaceAll { } } -pub struct CountTableDims; +struct CountTableDims; impl CountTableDims { /// For an `mtable` element, count the number of rows and columns in the table. /// @@ -1421,79 +1421,79 @@ impl CountTableDims { /// ignored. The number of columns is determined only from the first /// row, if it exists. Within that row, non-`mtd` elements are ignored. fn count_table_dims<'d>(e: Element<'_>) -> Result<(Value<'d>, Value<'d>), Error> { - let mut num_cols = 0; - let mut num_rows = 0; - for child in e.children() { - let ChildOfElement::Element(row) = child else { - continue - }; - - // each child of mtable should be an mtr. Ignore non-mtr rows. - let row_name = name(row); - - let labeled_row = if row_name == "mlabeledtr" { - true - } else if row_name == "mtr" { - false - } else { - continue; - }; - num_rows += 1; - - // count columns based on the number of rows. - if num_rows == 1 { - // count the number of columns, including column spans, in the first row. - let mut first_elem = true; - for row_child in row.children() { - let ChildOfElement::Element(mtd) = row_child else { - continue; - }; - if name(mtd) != "mtd" { - continue; - } - // Add the contributing columns, taking colspan into account. Don't contribute if - // this is the first element of a labeled row. - let colspan = mtd.attribute_value("colspan").map_or(1, |e| e.parse::().unwrap_or(0)); - if !(labeled_row && first_elem) { - num_cols += colspan; - } - first_elem = false; - } - } - } - - Ok((Value::Number(num_rows as f64), Value::Number(num_cols as f64))) + let mut num_cols = 0; + let mut num_rows = 0; + for child in e.children() { + let ChildOfElement::Element(row) = child else { + continue + }; + + // each child of mtable should be an mtr. Ignore non-mtr rows. + let row_name = name(row); + + let labeled_row = if row_name == "mlabeledtr" { + true + } else if row_name == "mtr" { + false + } else { + continue; + }; + num_rows += 1; + + // count columns based on the number of rows. + if num_rows == 1 { + // count the number of columns, including column spans, in the first row. + let mut first_elem = true; + for row_child in row.children() { + let ChildOfElement::Element(mtd) = row_child else { + continue; + }; + if name(mtd) != "mtd" { + continue; + } + // Add the contributing columns, taking colspan into account. Don't contribute if + // this is the first element of a labeled row. + let colspan = mtd.attribute_value("colspan").map_or(1, |e| e.parse::().unwrap_or(0)); + if !(labeled_row && first_elem) { + num_cols += colspan; + } + first_elem = false; + } + } + } + + Ok((Value::Number(num_rows as f64), Value::Number(num_cols as f64))) } fn evaluate<'d>(fn_name: &str, args: Vec>) -> Result<(Value<'d>, Value<'d>), Error> { - let mut args = Args(args); - args.exactly(1)?; - let element = args.pop_nodeset()?; - let node = validate_one_node(element, fn_name)?; - if let Node::Element(e) = node { - return Self::count_table_dims(e); - } - - Err( Error::Other("couldn't count table rows".to_string()) ) + let mut args = Args(args); + args.exactly(1)?; + let element = args.pop_nodeset()?; + let node = validate_one_node(element, fn_name)?; + if let Node::Element(e) = node { + return Self::count_table_dims(e); + } + + Err( Error::Other("couldn't count table rows".to_string()) ) } } -pub struct CountTableRows; +struct CountTableRows; impl Function for CountTableRows { fn evaluate<'c, 'd>(&self, _context: &context::Evaluation<'c, 'd>, args: Vec>) -> Result, Error> { - CountTableDims::evaluate("CountTableRows", args).map(|a| a.0) + CountTableDims::evaluate("CountTableRows", args).map(|a| a.0) } } -pub struct CountTableColumns; +struct CountTableColumns; impl Function for CountTableColumns { fn evaluate<'c, 'd>(&self, _context: &context::Evaluation<'c, 'd>, args: Vec>) -> Result, Error> { - CountTableDims::evaluate("CountTableColumns", args).map(|a| a.1) + CountTableDims::evaluate("CountTableColumns", args).map(|a| a.1) } }