During COVID I wrote up some math notes in HTML taking advantage of the wide screen format of desktop systems to display large formulae. However now that I am back to commuting by train, it would be nice to display those notes on my mobile phone. The desktop mode of the original HTML does a poor job at displaying on such a device. Therefore I have designed and implemented a script preprocessor which converts TeX-like math code into responsive HTML code.
There are some obvious things that can be done to improve the display of math on small touch screens. From most desirable to least
- Text flow inline math, as has been done by KaTeX and MathJax4.
- Reduce margin and gutter sizes to allow more space for the text.
- Provide user with precise, fine grain control of the font size for the web site, and save it as a permanent setting on the device.
- Add horizontal scrolling to elements which are too wide to fit the viewport. Fine if it only affects one or two equations per page but otherwise tedious for the reader.
- Rewrite the math into a more compact format. For example replace $x_1+x_2+x_3$ with $\sum x_i$. Again ok if it affects only one or two equations but overall not so good.
- Author multiple versions of each formula. For example a narrow and wide form, then switch between them based on device or viewport width. Tedious to author and often not very effective.
While those things are fine for inline math they are not good enough for display equations. It would be nice to have something similar to text-flow but more sophisticated, where it rearranges the equation to fit into the available space something like a human would do on a blackboard. This is where the preprocessor comes in.
Preprocessor
The preprocessor converts TeX code to responsive HTML code. These are the steps in the process:
-
Author manually edits the TeX code to remove flow-unfriendly features that mess things up, in particular
-
remove outer level environment blocks like
\begin{equation}
-
replace paired
\left(
and\right)
brackets, which prevent splitting, with their explicitly sized alternatives, for example\big(
and\big)
-
remove most artificial spacing like
\quad
because it tends to mess up the spacing in the new layout - finally add a line indicating which flow algorithm to use
-
remove outer level environment blocks like
-
Preprocessor breaks the input TeX code into "lines" and "words" based on the flow algorithm.
This step is done with a heuristic algorithm which works well about 90% of the time.
To handle difficult cases, it can be assisted by the author inserting explicit
$
signs to mark word breaks and$$
signs to mark line breaks. - Preprocessor then converts each "word" into a standalone piece of TeX code. This step has some complications because when TeX code is split into pieces and run through a TeX to HTML layout engine the spacing gets messed up. Preprocessor needs to correct that to restore the correct visual appearance.
-
Preprocessor then inserts the pieces of TeX code into standard HTML templates for the specific flow algorithm.
These templates are made up of
flex
box andgrid
HTML elements, suitably styled with CSS rules. This step uses several new baseline responsive CSS features added to WWW Standards between 2019 and 2024. - The final HTML is then rendered using one of the standard TeX to HTML layout engines.
Algorithms
flow
The flow algorithm is very similar to normal text flow and is most suitable for homogenous text like polynomials. The left parameter tells the algorithm to break just before plus and minus signs (normally it breaks just after). The indent parameter tells the algorithm to indent lines after the first to the level of the first equals sign.
#flow,left,indent
\Delta = 256a^3e^3 - 192a^2bde^2 - 128a^2c^2e^2 + 144a^2cd^2e - 27a^2d^4 + 144ab^2ce^2 - 6ab^2d^2e - 80abc^2de
+ 18abcd^3 + 16ac^4e - 4ac^3d^2 - 27b^4e^2 + 18b^3cde - 4b^3d^3 - 4b^2c^3e + b^2c^2d^2
This is the above code rendered with an equation label.
To see the math respond to screen size change on a mobile device, rotate the device to switch between portrait and landscape mode.
fold
The fold algorithm is a text flow algorithm in which the line breaks occur at predetermined positions and in a predetermined order. It is better for inhomogenous text like matrices and integrals. For a large equation which won't fit on one line it usually looks best to do the first break after the top level equals sign. Then if needed, another break around the middle of what's remaining.
#fold
\begin{vmatrix}
1 & x_1 & x_1^3 & x_1^4 \\
1 & x_2 & x_2^3 & x_2^4 \\
1 & x_3 & x_3^3 & x_3^4 \\
1 & x_4 & x_4^3 & x_4^4 \\
\end{vmatrix} = $
\big(x_1x_2 + x_1x_3 + x_1x_4 + x_2x_3 + $
x_2x_4 + x_3x_4\big) \cdot \prod_{i\lt j} (x_j-x_i)
This is the above code rendered with an equation label.
stack
A stack is a set of equations which can be written on one line separated by comma's if there is room. Otherwise they can be stacked and aligned vertically at the equals signs. The width parameter is the container width which trigger's the transition from horizontal to vertical format.
#stack,width=500
x_3 = a \cdot \frac {x_1y_1 + x_2y_2} {x_1x_2 + y_1y_2}
y_3 = a \cdot \frac {x_1y_1 - x_2y_2} {x_1y_2 - x_2y_1}
This is the above code rendered with an equation label.
train
A train is a list of expressions coupled together with equals signs. On a wide screen they look good written on one line, but on a narrow screen they look better written as a vertical stack with the equals signs vertically aligned. The width parameter is the container width which trigger's the transition from horizontal to vertical format.
#train,width=700
g_2
= -4(\epsilon_1\epsilon_2 + \epsilon_2\epsilon_3 + \epsilon_3\epsilon_1)
= \tfrac {1} {24} (A^2 + B^2 + C^2)
= \tfrac {1} {12} (12ae - 3bd + c^2)
This is the above code rendered with an equation label.
Where To Next?
The above set of algorithms are a good starting point for implementing responsive layout features in a TeX to HTML javascript library. Implementing natively would have the following advantages
-
Most issues caused by breaking the TeX code into small pieces should vanish.
-
For example splitting
\left(
and\right)
pairs should be easy. - Inconsistent spacing around breaks should be easily eliminated.
- The DOM object tree will be smaller and cleaner. For example using MathJax3 (1) has 650 elements whereas the ideal MathML implementation (5) has just 166 elements (according to AI).
-
For example splitting
- The algorithm for automatically choosing break points should work better because it would have more context to work with.
- More complex responsive structures could be created by nesting combinations of the basic algorithms.
- Performance should be better. In fact if there is sufficient browser support for MathML then the generation of the MathML code can be performed statically at web page publication time, completely eliminating the conversion overhead from both web client and server programs.
I think it unlikely the developers of MathJax and KaTeX would want to implement these more complex responsive layout features. This is because those packages are primarily focused on reproducing the exact rendering of LaTeX and it does not include such features.
Given the recent 2023 adoption of MathML Core support across all major browsers, I think the best way forward is to create a new software package which does TeX to MathML conversion directly, incorporating responsive layout features. It should be possible to mitigate all the undesirable effects of chopping math formula into small pieces, as is required for responsive layout. While the rendering quality of MathML is not as good as those two libraries, it is strategically superior and, given enough time, moderately simple to implement the conversion process.
Prototype MathML
The following equations are manually crafted MathML examples. They represent the ideal output of a TeX math to MathML converter which generates "responsive" HTML.
#flow
#fold
#stack
#train
These examples demonstrate how to apply responsive styling directly on the MathML <mrow>
elements thereby maintaining the semantic integrity of the <math>
object tree.
MathML Issues
To evaluate the current state of MathML I have added KaTeX MathML and TeXZilla to the list of supported converters. They can be selected from the control panel which is activated by clicking on the web page title. Although there are a few issues, they provide a good yardstick to gauge the current state of MathML support in the mainstream browsers. I have also added Lexer a gamma version of the new converter. Current known issues are:
-
Safari and Firefox are yet to implement display styling on
<mrow>
elements. This feature has already been implemented in Chrome, Edge, Opera and Samsung Internet. Without this feature responsive MathML cannot be sensibly implemented. - On Android a third party math font must be used to achieve satisfactory rendering quality.
-
Bracket stretch size algorithm not well-suited to splitting across
<mrow>
elements.
Prototype TeX-Like Math To MathML Converter
Objectives
Create a javascript program to convert TeX-like math formula's into responsive MathML. The objectives in order of importance
- User-friendly TeX-like math input.
- Clean, responsive, browser-supported MathML output.
- Compatibility* with MathJax and KaTeX, but with a smaller set of supported commands.
- Faster execution.
*Excluding bugs, for a good online LaTeX renderer see TeXeR.
Tokens
All input is Unicode, it's not limited to Ascii. Lexer interprets it's as a stream of tokens.
- Whitespace: tab, newline, carriage return and space characters. Whitespace is ignored.
-
Command characters:
{ } _ ^ &
represent begin group, end group, subscript, superscript and alignment respectively -
Hyphen:
'-'
represents mathematical minus sign -
Colon:
':'
represents mathematical logical colon or ratio -
Dashes:
'
,''
,'''
etc. represent single, double, triple primes etc. -
Commands:
\xxx
where xxx are letters a-z or A-Z or a single special character. For example\frac
-
Arguments:
#n
where n is a single digit 1-9, these are the names of macro arguments and mark insertion points during macro expansion -
Comments: characters between a
%
and the end of line, these characters are discarded - Other Characters: any single Unicode code point excluding those above represent a symbol or operator
Mathematical Symbols
Almost all mathematical symbols have been assigned unicode's in the extended plane
and are supported in the common MathML fonts.
So there is no need for special fonts at all.
Commands like \mathfrak
and \mathcal
are implemented simply by mapping A-Z etc. into this plane.
They can also be directly embedded like this ℝ
.
Macros
Are user-defined commands. Perform text substitution - using strings of tokens. Arguments for macro's are marshalled by looking ahead in the input stream for the next token, or string of tokens contained between balanced pairs of { }. As a result of this rule macro argument values always contain balanced sets of braces. The outer pair of braces are not considered to be part of the argument value.
Implementation (Javascript)
Tokeniser
During input processing input text is converted to tokens and macro substitution is performed resulting in a stream of tokens with all whitespace removed, and macro commands and arguments resolved to other tokens. The input processor is conceptually a generator function which yields tokens.
Each token is an object of the form { type, value, r }
where
type
is either CMD
, CHR
, BEG
, END
, MAC
or ARG
.
Respectively commands, characters, begin and end delimiter, macro, macro argument and end of input.
The value
is the token in textual form.
The r
is a resolver function which is dispatched to process the token.
Commands are looked up in a table and extra fields are added to the token or they may be converted into a character tokens.
These extra fields include resolver functions.
So \Delta
becomes {CHR, Δ, rChar}
and
^
becomes {CMD, ^, rSup}
and
\Large
becomes {CMD, \Large, rSize, 2}
.
When resolver functions are dispatched their this
pointer is bound to the Lexer and the token is passed as a parameter.
This gives them direct access to the state of the Lexer and to any additional fields that have been attached to the token.
The EOF
tokens are generated at the end of input text and end of macro's to simplify processing logic.
Their resolver function will never be called unless there is an unexpected end of input condition, in which case it throws an error.
Similarly the resolver function of an END
token is never called unless there are unbalanced delimiters in which case it also
throws an error.
Tokeniser's output the stream of tokens to a token "printer" for debugging.
MathML Renderer
The current state is held in the member variables of the Lexer. The resolver functions are effectively member's of the Lexer and when they execute they utilise the current state. When state change happen the current state is saved in local variables on the javascript call stack and then the state member variable(s) are updated.
The state tracks font size and style.
It is used to implement commands like \large
and \mathfrak
.
The graphics state is saved and restored by the associated token's resolver function.
For example a {CHR, A, rLetter}
token's resolver will look at the state and transform from
a unicode A to a unicode fraktur A when the fraktur font style is in effect.
The resolver functions also build the MathML output tree.
For example rsub
will pull the next operand from the input token stream.
Then it will replace the previous node in the MathML tree with a new <msub>
node representing
the previous node with a subscript generated from the next operand.
Example : \mathfrak x^{10}
The TeX string is converted to the tokens
{CMD, \mathfrac, rMathfrac} {CHR, x, rLetter} {CMD, ^, rSup} {BEG, {, rBegin}, {CHR, 1, rNumber}, {CHR, 0, rNumber}, {END, }, rEnd}
which are converted to MathML. The current tokeniser is called in a loop this.tok.next()
until it receives an EOF
.
The resolver for each token is called and it may itself call the current tokeniser.
-
rMathfrac
is called, it saves and sets the font stylesaveFont = this.font; this.font = MATHFRAQ
and callsthis.tok.next()
which sees a character. Things are done this way so thatrMathfrac
remains on the call stack and can restore state when the next token or token group completes.-
rLetter
is called sees the font style and convertsx
to𝔵
and then inserts a<mi>𝔵</mi>
node in the MathML tree
rMathfrac
resumes and restores the font stylethis.font = saveFont
-
-
in the next loop
rSup
is called and it callsthis.tok.next()
which sees a group-
rBegin
is then called and it creates anmrow
node in the MathML tree it loops callingthis.tok.next()
until anEND
token is encountered-
rNumber
is called and creates an<mn>1</mn>
node -
The next
rNumber
is called and creates an<mn>0</mn>
node
rBegin
resumes and scans themrow
seeing two consecutivemn
nodes which it coalesces into a single<mn>10</mn>
node -
rSup
resumes and removes the last two nodes from the MathML tree and replaces them with a superscript node containing those two nodes<msup><mi>𝔵</mi><mrow><mn>10</mn><mrow></msup>
-
Then the main loop resumes.
Example: \sqrt[3] 2
This string converts to {CMD, \sqrt, rSqrt} {BEG, [, rBegin} {CHR, 3, rNumber} {END, ], rEnd} {CHR, 2, rNumber}
the square brackets being marked as delimiters by the tokeniser because it knows to expect an optional argument after a \sqrt
command
(because when it looked up the command in the command table it said that).
It is resolved as follows: the main loop calls this.tok.next()
which retrieves a token and calls its resolver
-
rSqrt
callsthis.tok.next()
and sees it has an optional argument-
rBegin
is called ...
rSqrt
resumes, and since the first argument was optional callsthis.tok.next()
again-
rNumber
is called ...
rSqrt
resumes and removes the last two nodes from the mathML tree and replaces them with anmroot
node containing them. -
Example: {x \over 2}
This string converts to {BEG, {, rBegin} {CHR, x, rLetter} {CMD, \over, rOver} {CHR, 2, rNumber} {END, }, rEnd}
note although \over
is a command it is interpreted as fence and resolved as follows:
-
rBegin
starts, inserts amrow
node, then callsthis.tok.next()
until it sees an\over
orEND
token-
rLetter
is called ...
rBegin
resumes, knows that it was terminated by an\over
command and so ends the currentmrow
and starts a new one then callsthis.tok.next()
until it see's anEND
token-
rNumber
is called ...
rBegin
resumes knows it has reached the end of the denominator of an\over
command and so it removes the last two nodes from the mathML tree and replaces them with a newmfrac
node containing them. -
Example: \begin{pmatrix} x ... \end{pmatrix}
The tokeniser invokes special processing rules when it sees a \beg
or \end
command and produces the following token string
{BEG, \begin{pmatrix}, rMatrix} {CHR, x, rLetter} ... {END, \end{pmatrix}, rEndEnv}
which is resolved as follows:
-
rMatrix
is called and inserts anmrow
node in the mathML tree then adds amo
node containing a(
followed by anmtable
node. It then callsthis.tok.next()
in a loop until it gets anEND
token-
rLetter
is called ...
rMatrix
resumes checks it got the correct environment end token and terminates themtable
mathML node. It then adds anmo
node containing a)
and terminates themrow
node -