Skip to content

Add support for dir=rtl in MathML and TeX, with automatic detection of rtl languages - #1572

Open
dpvc wants to merge 8 commits into
developfrom
feature/bidi
Open

dpvc wants to merge 8 commits into
developfrom
feature/bidi

Conversation

@dpvc

@dpvc dpvc commented Sep 20, 2026

Copy link
Copy Markdown
Member

This PR implements right-to-left support in MathJax by adding support for dir="rtl" attributes, and automatic detection of RTL languages in token nodes. Its handling of RTL text is not the complete Unicode bidirectional algorithm (which would require much more character-by-character data), but should suffice for most situations. Ones where it does not can be handled via mtextInheritFont: true, where the system text renderer will be used.

The main idea is to use transform: scalex(-1) to flip expressions with dir="rtl", but to apply scalex(-1) again for token nodes so that their glyphs appear in the correct orientation. This requires some extra g container nodes in SVG output in order to have an appropriate reference for the transform-origin CSS property.

Within a token node, the text is broken into separate runs of RTL and LTR text, and the RTL sections are reversed before rendering. Some characters that have reflected versions (like parentheses, brackets, and braces) have their reflected versions substituted, and those that require mirroring in the output are handled by using scalex(-1) again. This doesn't implement the complete unicode bidi algorithm, and it doesn't always handle numbers intermixed with RTL languages as that algorithm requires, for example. But is gets a wide range of situations correct.

In TeX input, \text{} and related text-mode macros can include internal mathematics, which interrupts the processing or RTL content. For example, the MathML

<math xmlns="http://www.w3.org/1998/Math/MathML" display="block">
<mtext>&#x5D0; (12, 34) &#x5D1;&#x5D2;</mtext>
</math>

produces

RTL expression

in Firefox's native MathML support, while

<math xmlns="http://www.w3.org/1998/Math/MathML" display="block">
  <mrow>
    <mtext>&#x5D0;&#xA0;</mtext>
    <mrow data-mjx-texclass="ORD">
      <mo stretchy="false">(</mo>
      <mn>12</mn>
      <mo>,</mo>
      <mn>34</mn>
      <mo stretchy="false">)</mo>
    </mrow>
    <mtext>&#xA0;&#x5D1;&#x5D2;</mtext>
  </mrow>
</math>

(the result of \text{\U{5D0} $(12, 34)$ \U{5D1}\U{5D2}}, where I use \U{...} in order to prevent the browser from reordering the Hebrew letters when it displays this post) is rendered as

Inappropriate rendering

In order to handle this better, TeX's internal math processing will mark the containing mrow with dir="rtl" when the first letter character in the text is from an RTL language. In addition, new \rtl and \ltr macros are defined to TeX to allow additional control over the rendering direction.


Input Details

In order to handle the RTL attributes, the output jax needs to walk the MathML tree to identify RTL sections for special handling. Most of the time, however, this will not be necessary, as most math is LTR, so in order to avoid the overhead, the MathML input jax keeps track of whether a dir attribute appears int he expression and marks the root element with a bidi property that the output jax uses to decide wether it needs to do the special bidi pre-processing. This is done in the MathMLCompile.ts file.

The TeX input has a similar mechanism. Any macro that sets the dir attribute also sets the inputData.bidi value on the MathItem being processed, and the TeX input jax transfers that to the root node's bidi property when it finishes the compilation.

The internalMath function in ParseUtil.ts is modified to check if the first letter is an RTL one, and keeps track of that letter in the dir variable (it does so when \U{...} is processed, or if the character is not one of the ones handled by the big if-then structure). In the end, if a letter has been found (dir is not empty), then if there are multiple MML nodes in the text (e.g., there was internal math notation in the text), and if the direction letter is an RTL one, we set dir to rtl, otherwise set it to ''). Finally, after the MathML has been turned into an mrow or mstyle, we add the dir="rtl", if needed.

In order to make the \rtl and \ltr easier to produce, I moved the GetArgumentMml() function from the html package to the TexParser.ts file, then add the new macros to the base package. The method used adds the needed dir attribute and marks the inputData.bidi value if the direction is rtl (no need to pre-process if everything is ltr).

We add the new macros to the textmacros list, so that they can be used in text mode. Because the textmacros package replaces the internalMath function, we need to handle the addition of the dir attribute to the mrow for the text that it produces, just like for the original internalMath method.

First, because the TextParser is a separate parser from the current TexParser, we copy the mathItem from the TexParser so that the macros that need to set the bidi property have access to it. Then in the saveText() method, we check for the first letter character, and set the dir property appropriately. Finally, in mml(), we add the dir attribute, if needed.


Output Details

First, look at the common.ts file. We add a new pre-filter that checks if the bidi property is set on the root element, and if so, it checks if the default for dir has been changed (by the user configuration) to rtl, and if so, makes the attribute explicit. Then we recursively look for dir attributes and mark the nodes that change the direction. For token elements, if the direction is currently reversed, we reverse the token contents to get it back to normal. We also mark RTL tokens as needing their text mixed runs reversed (e.g., Latin text before Hebrew will be reversed).

The common/Wrapper.ts is where the processing of RTL text is handled. The remapChars() function now handles the various mirroring actions that are needed. First, the code points are made into a string, and the string is split by runs of RTL text. If we are not in an RTL mode, then we may have groups of RTL text separated by whitespace or numbers, and these need to be presented in reversed order, so we look for those and reverse the groups by hand.

If we are in RTL mode, the ranges are reversed, then, for each range (LTR then RTL then LTR, etc), if reversing, we strip of non-letter characters at the beginning and ending, otherwise we use the full range if not reversing. We split the main text into unicode code points, and if we are in an RTL section (every other one), we reverse the characters. If we had opening or closing character (which is only when reversing), we move the opening ones to the end of the range, mirroring any that can be. If there were closing characters, we move them to the beginning, mirroring any that can be. Note that special characters interior to a range are not mirrored. Characters that have mirrored equivalents are set to those values, and the ones that must be mirrored by the output jax are marked by being set to the negative of their code point values.

This algorithm works well for most situations, but there are some situations that it doesn't get right. For example, \text{abc \U{5D0}\U{5D1}\U{5D1} 123} in LTR mode should swap the order of the numbers and the Hebrew groups, but this algorithm doesn't do that. For now, if you need that behavior, you should set mtextInheritFont: true so that the browser handles the bidi processing.

The common/Wrapper.ts file also include a large table of the reversible and mirrored characters from the Unicode specification. There is more there than is probably needed for MathJax, so some could be commented out to save space, if that seems appropriate.

The common wrapper for TextNode is modified to handle the negative code points for mirrored characters. The mn and mo wrappers, which override remapChars(), now call super.remapChars() to get the proper handling of the RTL characters. In addition, the mo handling of stretchy characters checks to see if the character needs to be reversed before stretching it, and stores the character in a new stretchC property (so we don't have to compute that again later).

What remains is the CHTML and SVG implementations for the reversing regions and mirrored characters.

In chtml.ts, we add CSS styles that reverse the marked sub-expressions and mirrored characters. We use a separate mjx-mirror DOM element for mirrored characters, since they may appear within a group of characters in a token element, and we don't want to mirror all the others.

In chtml/Wrapper.ts, we had a new handleBidi() method that uses the pre-processed reverse properties to determine when the data-mjx-reverse attributes need to be added.

In chtml/Wrappers/TextNode.ts, when an explicit font is used (and we create an unknown text entry), we add the dir="rtl" property, if needed. Otherwise, when processing individual characters, we check if they are to be mirrored (and convert back to a positive code point value), and create the mjx-c earlier, then wrap it in mjx-mirror if needed when it is added to the parent.

Similarlty, for SVG output, we add CSS to handle the reversing, and add a handleBidi() method. In this case, it adds a new g element with data-mjx-reverse attribute. This is so that the transform-box: content-box CSS is the correct, and transform-origin: center will be the center of that box. Because of that extra element, the place() method needs to take that into account.

The placeChar() function deals with mirroring an individual character.

The SVG TextNode.ts wrapper handles the unknown text. When the direction is set to rtl, that changes which end of the text the starting point is at, so we add a translation to push it back into the proper location (a bit awkward but works; there might be a better way).

Finally, the pattern for RTL character ranges is added to util/string.ts so that it can be used by multiple modules.

@dpvc
dpvc requested a review from zorkow September 20, 2026 13:53
@dpvc dpvc added this to the v4.2 milestone Sep 20, 2026
@codecov

codecov Bot commented Sep 20, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 67.15789% with 468 lines in your changes missing coverage. Please review.
✅ Project coverage is 86.82%. Comparing base (e521742) to head (0c5a49b).

Files with missing lines Patch % Lines
ts/output/common/Wrapper.ts 68.70% 363 Missing ⚠️
ts/output/common.ts 38.09% 26 Missing ⚠️
ts/output/svg/Wrapper.ts 36.11% 23 Missing ⚠️
ts/output/chtml/Wrappers/TextNode.ts 7.14% 13 Missing ⚠️
ts/output/svg/Wrappers/TextNode.ts 0.00% 13 Missing ⚠️
ts/output/chtml/Wrapper.ts 41.66% 7 Missing ⚠️
ts/output/common/Wrappers/mo.ts 46.15% 7 Missing ⚠️
ts/input/tex/base/BaseMethods.ts 62.50% 6 Missing ⚠️
ts/input/tex/ParseUtil.ts 81.25% 3 Missing ⚠️
ts/input/tex/textmacros/TextParser.ts 88.00% 3 Missing ⚠️
... and 3 more
Additional details and impacted files
@@             Coverage Diff             @@
##           develop    #1572      +/-   ##
===========================================
- Coverage    87.13%   86.82%   -0.31%     
===========================================
  Files          392      392              
  Lines        89279    90635    +1356     
  Branches      5079     3399    -1680     
===========================================
+ Hits         77793    78697     +904     
- Misses       11486    11938     +452     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@dpvc

dpvc commented Sep 22, 2026

Copy link
Copy Markdown
Member Author

I have pushed a commit that improves the bidi handling within token nodes to make it work more in line with the unicode bidi algorithm. This seems to work well in my testing, but it still doesn't do everything that Unicode requires. For example, the Unicode algorithm does something about matching delimiters that I don't fully understand, and so haven't implemented. But it only seems to fail when there are improperly matched delimiters (where it seems to me that the Unicode algorithm screws things up rather than helps it out).

In any case, I think this is pretty good, and certainly much better than what we had (which was no bidi support at all in v3/v4).

@zorkow zorkow left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A couple of issues. One could be serious in TextParser.ts.

Comment thread ts/input/tex/textmacros/TextParser.ts Outdated
this.configuration.mathItem =
this.configuration.packageData.get(
'textmacros'
).texParser.configuration.mathItem;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we be sure that at this point get('textmacros') is always defined?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. The package data for textmacros is set up in the config method that is part of the textmacros configuration object at

parseOptions.packageData.set('textmacros', {
textConf,
parseOptions,
jax,
texParser: null,
});

This is called when the TeX input jax is instantiated and its packages are configured. That happens before any TeX parsing occurs, which is when a TextParser is instantiated. So this data will be available at that time.

On the other hand, when I checked this to make sure, I noticed that there is a get texParser method that I should have called, so I can simplify this by using that call, and will do so.

Comment thread ts/input/tex/base/BaseMethods.ts Outdated
Comment thread ts/input/tex.ts Outdated
/**
* The stretchy character to use (could be reversed).
*/
public stretchC: number;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be initialized?
Or in other words: Can getStretchedVariant ever be called before canStretch?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All calls to getStretchedVariant() only occur after calls to canStretch(). Note that the first lines of getStretchedVariant() are to return if this.stretch.dir === DIRECTION.NONE. The initial value for this.stretch has this.strech.dir set to DIRECTION.NONE, and this.stretch is only changed from the default in canStretch(). So even if getStretchedVariant() were to be called without canStretch() being called, it would exit immediately, and never use the uninitialized stretchC value.

dpvc and others added 2 commits September 25, 2026 08:20
Co-authored-by: Volker Sorge <v.sorge@mathjax.org>
Co-authored-by: Volker Sorge <v.sorge@mathjax.org>

@zorkow zorkow left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the changes. Looks good.
As discussed I am now waiting for the final changes/commits.

dpvc and others added 2 commits October 10, 2026 16:53
@dpvc

dpvc commented Oct 10, 2026

Copy link
Copy Markdown
Member Author

OK, I have finally gotten the bidi algorithm working for text that includes delimiters. It was a bit of an ordeal figuring out what the rules are, and I went though at least 8 different re-writes before getting this one that I'm happy with. I have used a test file that generates random strings involving RTL and LTR letters, various numbers, and various symbols, all of which affect how the outcome looks, and have run it on 400 examples without errors. I have to do the testing visually, as the test compares MathJax's output with the browser output, and I don't have a good way of automating that comparison, since the input strings are the same (it is the visual output that needs to be compared). In any case, I'm satisfied that this does the trick, and if it doesn't the cases where it fails are rare.

The code includes a lot of comments, so I hope it is clear how it works. I won't repeat that all here.

The last commit includes the new algorithm, which involves several new functions. Because the processing can be extensive, I also implemented a cache in the common TextNode wrapper so that the algorithm won't run twice (once when the size is being computed and once when the output is being generated). That should help improve performance when RTL characters are used (of there aren't any, the complicated algorithm is skipped, so most existing cases will work as efficiently as they currently do.

People currently using RTL languages will likely have mtextInheritFont set, which will use the browser's implementation of the bidi support, bypassing this new code. So I don't think this should constitute a breaking change for anyone. And if it is, setting mtextInheritFont would be a work-around. If you think it is necessary, I can add a configuration option for it, and have it off by default, but I think that might be overkill.

In any case, I think this is ready for review again.

@dpvc
dpvc requested a review from zorkow October 10, 2026 21:07
@dpvc

dpvc commented Oct 10, 2026

Copy link
Copy Markdown
Member Author

PS, I got the mirror data from the Unicode data files (listed in the comments). One thing to note, however, is that they don't include data for mirroring arrows, so these currently aren't reflected. Firefox does mirror those, so I'm wondering if we should extend this mapping further. I suspect that mirroring arrows would be important, so that x -> 10 doesn't become 10 -> x in RTL mode.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants