From 48693c89e86fb39dbf647465818d45c8abff05a6 Mon Sep 17 00:00:00 2001 From: Jeff Witt <1848307+wittjeff@users.noreply.github.com> Date: Thu, 10 Sep 2026 19:34:36 -0700 Subject: [PATCH 1/3] fix(speech): flatten nested TEMP_NAME wrappers so a matrix raised to a power is not read as "TEMP NAME of" The mrow 'matrix' intent rule replaces with a bare `x: "*[2]"`, so its result is wrapped in the internal TEMP_NAME transport element; the parent `power` rule's `x: "*[1]"` wraps it again and lift_children only stripped one level. The leftover wrapper reached the generic function-intent speech rule, which spoke its name: "TEMP NAME of the 2 by 2 matrix; ...; squared" (#762). lift_children now flattens nested wrappers recursively. A leaf wrapper holding text is still only unwrapped as a direct child, matching the previous behavior. Two regression tests added to tests/Languages/en/mtable.rs; no goldens changed. Fixes #762 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01U86v5m1m8oaLSy5UsNbBpc --- src/speech.rs | 34 ++++++++++++++++++++-------- tests/Languages/en/mtable.rs | 44 ++++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 10 deletions(-) diff --git a/src/speech.rs b/src/speech.rs index 100d5ff39..1417000ed 100644 --- a/src/speech.rs +++ b/src/speech.rs @@ -709,24 +709,38 @@ impl Intent { /// "lift" up the children any "TEMP_NAME" child -- could short circuit when only one child + /// + /// TEMP_NAME is only ever a transport wrapper created by `replace_nodes_tree`, so wrappers can nest: + /// a rule whose replacement is a bare `x:` (e.g., the mrow 'matrix' rule returns `x: "*[2]"`) yields + /// TEMP_NAME(matrix), and the parent intent's `x: "*[1]"` wraps that again. Lifting only one level left + /// `power(TEMP_NAME(matrix), 2)`, spoken as "TEMP NAME of the 2 by 2 matrix ... squared" (issue #762). + /// Nested wrappers whose children are all elements are flattened; a leaf wrapper (text from a Text or + /// Attribute node) is only unwrapped when it is a direct child, matching the previous behavior. fn lift_children(result: Element) -> Element { // debug!("lift_children:\n{}", mml_to_string(result)); // most likely there will be the same number of new children as result has, but there could be more let mut new_children = Vec::with_capacity(2*result.children().len()); for child_of_element in result.children() { - match child_of_element { - ChildOfElement::Element(child) => { - if name(child) == "TEMP_NAME" { - new_children.append(&mut child.children()); // almost always just one - } else { - new_children.push(child_of_element); - } - }, - _ => new_children.push(child_of_element), // text() - } + push_lifted(child_of_element, &mut new_children, true); } result.replace_children(new_children); return result; + + fn push_lifted<'a>(child_of_element: ChildOfElement<'a>, new_children: &mut Vec>, is_direct_child: bool) { + if let ChildOfElement::Element(child) = child_of_element { + if name(child) == "TEMP_NAME" { + let grandchildren = child.children(); + let is_leaf_wrapper = grandchildren.iter().any(|gc| matches!(gc, ChildOfElement::Text(_))); + if is_direct_child || !is_leaf_wrapper { + for grandchild in grandchildren { + push_lifted(grandchild, new_children, false); + } + return; + } + } + } + new_children.push(child_of_element); + } } } } diff --git a/tests/Languages/en/mtable.rs b/tests/Languages/en/mtable.rs index 8a670988e..e2d063689 100644 --- a/tests/Languages/en/mtable.rs +++ b/tests/Languages/en/mtable.rs @@ -1390,3 +1390,47 @@ fn single_line_with_label() -> Result<()> { expr, "1 equation, with label 2; b equals 2")?; return Ok(()); } + +#[test] +fn matrix_raised_to_power() -> Result<()> { + // A parenthesized matrix as the base of msup must speak as "the matrix ... squared". + // Regression for #762: the mrow 'matrix' intent rule returns a bare `x:`, so the intent tree + // carried a nested TEMP_NAME wrapper into `power` and NVDA read "TEMP NAME of the 2 by 2 matrix ...". + let expr = " + ( + + 12 + 34 + + ) + 2 + "; + test("en", "ClearSpeak", expr, "the 2 by 2 matrix; row 1; 1, 2; row 2; 3, 4; squared") +} + +#[test] +fn matrix_raised_to_power_lualatex_spacing() -> Result<()> { + // The exact MathML from #762 (LuaLaTeX pmatrix output): negative-width mspace around the + // table and fence attributes on the parens. Canonicalization must fold the spacing away so + // the matrix is still recognized as the base of the power. + let expr = " + + + ( + + + + + 12 + 34 + + + + + ) + + 2 + + "; + test("en", "ClearSpeak", expr, "the 2 by 2 matrix; row 1; 1, 2; row 2; 3, 4; squared") +} From 3ce503d81319e2334e1d97624e75576ca121ac9b Mon Sep 17 00:00:00 2001 From: NSoiffer Date: Sun, 13 Sep 2026 04:19:08 -0700 Subject: [PATCH 2/3] Simplify condition in push_lifted function Fixes clippy warning --- src/speech.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/speech.rs b/src/speech.rs index 1417000ed..3e0db7bbf 100644 --- a/src/speech.rs +++ b/src/speech.rs @@ -727,8 +727,7 @@ impl Intent { return result; fn push_lifted<'a>(child_of_element: ChildOfElement<'a>, new_children: &mut Vec>, is_direct_child: bool) { - if let ChildOfElement::Element(child) = child_of_element { - if name(child) == "TEMP_NAME" { + if let ChildOfElement::Element(child) = child_of_element && name(child) == "TEMP_NAME" { let grandchildren = child.children(); let is_leaf_wrapper = grandchildren.iter().any(|gc| matches!(gc, ChildOfElement::Text(_))); if is_direct_child || !is_leaf_wrapper { From 20e5c33bde60b1e8cfcdf3dcdd9cf99a36d61882 Mon Sep 17 00:00:00 2001 From: NSoiffer Date: Sun, 13 Sep 2026 04:25:49 -0700 Subject: [PATCH 3/3] Fix indent and delete extra "}" I should have deleted on the last change --- src/speech.rs | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/speech.rs b/src/speech.rs index 3e0db7bbf..544ce35c7 100644 --- a/src/speech.rs +++ b/src/speech.rs @@ -728,14 +728,13 @@ impl Intent { fn push_lifted<'a>(child_of_element: ChildOfElement<'a>, new_children: &mut Vec>, is_direct_child: bool) { if let ChildOfElement::Element(child) = child_of_element && name(child) == "TEMP_NAME" { - let grandchildren = child.children(); - let is_leaf_wrapper = grandchildren.iter().any(|gc| matches!(gc, ChildOfElement::Text(_))); - if is_direct_child || !is_leaf_wrapper { - for grandchild in grandchildren { - push_lifted(grandchild, new_children, false); - } - return; - } + let grandchildren = child.children(); + let is_leaf_wrapper = grandchildren.iter().any(|gc| matches!(gc, ChildOfElement::Text(_))); + if is_direct_child || !is_leaf_wrapper { + for grandchild in grandchildren { + push_lifted(grandchild, new_children, false); + } + return; } } new_children.push(child_of_element);