Repository: KaTeX/KaTeX (js)

Issue:
Multidigit numbers aren't read out by the voice over screen reader
> Before reporting a bug
- [ ] Check [common issues](https://katex.org/docs/issues.html).
- [x] Check the bug is reproducible in [the demo](https://katex.org). If not, check KaTeX is up-to-date and installed correctly.
- [x] Search for [existing issues](https://github.com/KaTeX/KaTeX/issues).

This problem happens on MacOSX 12.6 with voice over, on both chromium and safari.
I originally noticed the issue when using pandoc with its `--katex` option
but I can reproduce the issue in the demo as well.

When rendering the following expression:

```
49 + 51 = 100
```

The numbers aren't read out.
When I get to the part where the numbers are,it reads it as if there are two (or three) different items,
one for each digit.

If one right clicked on the output
and selected "inspect"
in the generated MathML
the `<mn>` tags for the numbers
would contain multiple elements under them,
also one for each digit.
I guess something about the way KaTeX generates those elements
causes multiple nodes to be created in the DOM,
which then confuses voice over while manipulating it.

Pull request description:
fix: MathML combines multidigit numbers with sup/subscript, comma separators, and multicharacter text when outputting to DOM
<!-- PR title should follow Angular Commit Message Conventions (https://github.com/angular/angular.js/blob/master/DEVELOPERS.md#-git-commit-guidelines) -->

**What is the previous behavior before this PR?**

When rendering MathML directly to the DOM (as opposed to a string), such as on the katex.org front-page demo, consecutive digits and text characters get rendered as multiple text children of an `<mn>` or `<mtext>` elements:

![image](https://github.com/user-attachments/assets/6bed3144-29a4-4531-a58e-13814c33c330)

**What is the new behavior after this PR?**

When rendering MathML directly to the DOM, we combine consecutive text children into one:

![image](https://github.com/user-attachments/assets/c20ae6cc-f5bc-43e9-b015-b444b1bd4dd4)

Fixes #3995 which reports that this causes screen-reading issues.

---

Merging of consecutive `<mtext>` and `<mn>` elements is already done here:

https://github.com/KaTeX/KaTeX/blob/2d1fec9f989723c5a24f5ae5ab7d89bd7f7081e0/src/buildMathML.js#L157-L167

And when rendering to a string, this was enough. Indeed, we don't have a great way of testing this, without a virtual DOM setup. But the inspect screenshots above confirm that this PR fixes the issue.

Patch:
diff --git a/src/buildMathML.js b/src/buildMathML.js
index c49aff2d..376814e8 100644
--- a/src/buildMathML.js
+++ b/src/buildMathML.js
@@ -128,6 +128,30 @@ export const getVariant = function(
     return null;
 };
 
+/**
+ * Check for <mi>.</mi> which is how a dot renders in MathML,
+ * or <mo separator="true" lspace="0em" rspace="0em">,</mo>
+ * which is how a braced comma {,} renders in MathML
+ */
+function isNumberPunctuation(group: ?MathNode): boolean {
+    if (!group) {
+        return false;
+    }
+    if (group.type === 'mi' && group.children.length === 1) {
+        const child = group.children[0];
+        return child instanceof TextNode && child.text === '.';
+    } else if (group.type === 'mo' && group.children.length === 1 &&
+        group.getAttribute('separator') === 'true' &&
+        group.getAttribute('lspace') === '0em' &&
+        group.getAttribute('rspace') === '0em'
+    ) {
+        const child = group.children[0];
+        return child instanceof TextNode && child.text === ',';
+    } else {
+        return false;
+    }
+}
+
 /**
  * Takes a list of nodes, builds them, and returns a list of the generated
  * MathML nodes.  Also combine consecutive <mtext> outputs into a single
@@ -165,13 +189,25 @@ export const buildExpression = function(
                 lastGroup.children.push(...group.children);
                 continue;
             // Concatenate <mn>...</mn> followed by <mi>.</mi>
-            } else if (group.type === 'mi' && group.children.length === 1 &&
-                       lastGroup.type === 'mn') {
-                const child = group.children[0];
-                if (child instanceof TextNode && child.text === '.') {
-                    lastGroup.children.push(...group.children);
-                    continue;
+            } else if (isNumberPunctuation(group) && lastGroup.type === 'mn') {
+                lastGroup.children.push(...group.children);
+                continue;
+            // Concatenate <mi>.</mi> followed by <mn>...</mn>
+            } else if (group.type === 'mn' && isNumberPunctuation(lastGroup)) {
+                group.children = [...lastGroup.children, ...group.children];
+                groups.pop();
+            // Put preceding <mn>...</mn> or <mi>.</mi> inside base of
+            // <msup><mn>...base...</mn>...exponent...</msup> (or <msub>)
+            } else if ((group.type === 'msup' || group.type === 'msub') &&
+                group.children.length >= 1 &&
+                (lastGroup.type === 'mn' || isNumberPunctuation(lastGroup))
+            ) {
+                const base = group.children[0];
+                if (base instanceof MathNode && base.type === 'mn') {
+                    base.children = [...lastGroup.children, ...base.children];
+                    groups.pop();
                 }
+            // \not
             } else if (lastGroup.type === 'mi' && lastGroup.children.length === 1) {
                 const lastChild = lastGroup.children[0];
                 if (lastChild instanceof TextNode && lastChild.text === '\u0338' &&
diff --git a/src/mathMLTree.js b/src/mathMLTree.js
index 9e0cc9f5..3b591836 100644
--- a/src/mathMLTree.js
+++ b/src/mathMLTree.js
@@ -95,7 +95,18 @@ export class MathNode implements MathDomNode {
         }
 
         for (let i = 0; i < this.children.length; i++) {
-            node.appendChild(this.children[i].toNode());
+            // Combine multiple TextNodes into one TextNode, to prevent
+            // screen readers from reading each as a separate word [#3995]
+            if (this.children[i] instanceof TextNode &&
+                this.children[i + 1] instanceof TextNode) {
+                let text = this.children[i].toText() + this.children[++i].toText();
+                while (this.children[i + 1] instanceof TextNode) {
+                    text += this.children[++i].toText();
+                }
+                node.appendChild(new TextNode(text).toNode());
+            } else {
+                node.appendChild(this.children[i].toNode());
+            }
         }
 
         return node;


Test patch:
diff --git a/test/__snapshots__/mathml-spec.js.snap b/test/__snapshots__/mathml-spec.js.snap
index 8505c271..2a784ff1 100644
--- a/test/__snapshots__/mathml-spec.js.snap
+++ b/test/__snapshots__/mathml-spec.js.snap
@@ -428,9 +428,35 @@ exports[`A MathML builder should concatenate digits into single <mn> 1`] = `
       <mn>
         0.34
       </mn>
+      <mo>
+        =
+      </mo>
+      <msup>
+        <mn>
+          .34
+        </mn>
+        <mn>
+          1
+        </mn>
+      </msup>
+    </mrow>
+    <annotation encoding="application/x-tex">
+      \\sin{\\alpha}=0.34=.34^1
+    </annotation>
+  </semantics>
+</math>
+`;
+
+exports[`A MathML builder should concatenate digits into single <mn> 2`] = `
+<math xmlns="http://www.w3.org/1998/Math/MathML">
+  <semantics>
+    <mrow>
+      <mn>
+        1,000,000
+      </mn>
     </mrow>
     <annotation encoding="application/x-tex">
-      \\sin{\\alpha}=0.34
+      1{,}000{,}000
     </annotation>
   </semantics>
 </math>
diff --git a/test/mathml-spec.js b/test/mathml-spec.js
index 8965c597..94a4b424 100644
--- a/test/mathml-spec.js
+++ b/test/mathml-spec.js
@@ -29,7 +29,8 @@ describe("A MathML builder", function() {
     });
 
     it('should concatenate digits into single <mn>', () => {
-        expect(getMathML("\\sin{\\alpha}=0.34")).toMatchSnapshot();
+        expect(getMathML("\\sin{\\alpha}=0.34=.34^1")).toMatchSnapshot();
+        expect(getMathML("1{,}000{,}000")).toMatchSnapshot();
     });
 
     it('should make prime operators into <mo> nodes', () => {