Skip to content

Equation editor for Avalonia: MathEditBox, MathView, and their packages - #20

Open
simonebortolin wants to merge 74 commits into
claude/xslt-generatorfrom
claude/focused-clarke-f4k4a9
Open

simonebortolin wants to merge 74 commits into
claude/xslt-generatorfrom
claude/focused-clarke-f4k4a9

Conversation

@simonebortolin

Copy link
Copy Markdown
Owner

A real-time equation editor for Avalonia in the manner of the RichEditBox of WinUI in math mode (math only): UnicodeMath or LaTeX typed at the caret builds up into the equation when a space or an operator ends an argument, and the equation is read and written as MathML (GetMath/SetMath) or as an Equation. The design is in docs/EDITOR.md.

Projects

Project Target Content
SharpEquations.Editing netstandard2.0, net10.0, no UI MathNode tree with runs of linear text, caret, selection, undo (a bounded stack of immutable trees), build up and build down
SharpEquations.Rendering netstandard2.0, net10.0, no UI Layout of MathML Core over the OpenType MATH table; glyph boxes from the glyf/CFF outlines, ssty variants from GSUB; caret and selection geometry
SharpEquations.Avalonia net8.0, net10.0 MathEditBox, MathView, the floating bar (MathQuickBar); Cambria Math on Windows, embedded Libertinus Math (OFL) elsewhere
samples/SharpEquations.Avalonia.Demo net10.0 A window with the tools of the equations of Word

Features

  • Build up as in Word; linear view (Ctrl+Shift+L); UnicodeMath or LaTeX input (read as Word reads it)
  • Caret through the slots, Up/Down between slots and lines; Backspace selects a structure, then deletes it; undo/redo
  • Copy in any format (GetSelectedText, CopyTextFormat: MathML pastes into Word, Copying event), paste of MathML/UnicodeMath/LaTeX
  • Equation numbers on the right (#(1), \tag{1}, EquationNumber)
  • Lines: Shift+Enter makes an equation array aligned at &; automatic line breaks of long display equations before the operators
  • Matrices and tables: insert/delete rows and columns, Tab between cells
  • Completion of commands and a floating bar (completions, structures for the selection, table commands, quick symbols)
  • Accessibility: MathEditBoxAutomationPeer, the equation as UnicodeMath

Core

The core's only change is new public API: EquationCommand and EquationCommands (the control words of UnicodeMath and the commands of LaTeX with their symbols), in PublicAPI.Unshipped.txt, checked against the parsers by PublicApi/EquationCommandsTests. It calls for a minor version: VersionPrefix is still 1.0.5 and the commits say [no-new-version], so the bump is to be made before merging.

Release

The CI tests the editor (tests/SharpEquations.Avalonia.Tests, net10.0, and net8.0 on Linux), and packs and publishes the three packages of the editor with the core, with one version. Their public API is tracked in PublicAPI.*.txt. The package IDs SharpEquations.Editing, .Rendering and .Avalonia are new on nuget.org: the Trusted Publishing policy of NUGET_USER has to cover them.

Tests

  • Editor: 84 tests on net10.0, 83 on net8.0 (the demo test is net10.0 only). They cover the UnicodeMathML corpus, the LaTeX of latex2mathml, the OMML of Word documents, the MTEF and mml2tex data, the controls in a headless Avalonia session with Skia, and the demo window.
    • Loading an equation into the editor, and building it down to linear text and up again, gives the same equation (UnicodeMath corpus at 100%).
    • Typing one character at a time gives the equation of the whole text for 93% of the UnicodeMath corpus and 90% of the LaTeX corpus.
  • dotnet test --solution SharpEquations.slnx: 5673 passed. No test was run on Windows or macOS, or with a real window.

Generated by Claude Code

simonebortolin and others added 30 commits November 7, 2026 22:12
tests/data/latex holds the 327 conversions and 10 macros of latex2mathml
(LaTeX with the MathML it writes), tests/data/mathml the XSpec scenarios
and examples of mml2tex (MathML with the LaTeX it writes). The MathML
writes LaTeX that reads back stably, the LaTeX reads as latex2mathml
reads it once the conventions of its MathML are made the same, and the
macros expand to their equivalent; what does not yet is listed, with
why, and a case that starts to pass must leave the list.

Writer: an empty identifier is {}, not \operatorname{}; the marks over a
base are \mathring, \overleftarrow, \overleftrightarrow, \overline; Ⓢ is
\circledS; a capital Greek letter is \Gamma upright and \mathit{\Gamma}
italic, not \text{Γ} and \Gamma.

Reader: two \overset in a row, each grouped whole; \mid a bar that
EscapeOddBars escapes without its pair and that is the bar of a ket
before ⟩; \mathring, \overleftarrow, \overleftrightarrow, \circledS; a
phantom with scripts braced; \_ held before a command.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…version]

Pin the OMML written for math alphanumerics (plain letters with m:scr/m:sty)
and emoji, how OMML with astral characters reads, and the round trips. The
tests named ...CountingUtf16Units depend on mml2omml.xsl counting UTF-16
units under XslCompiledTransform: 🍕 counts as two characters and is written
upright (m:sty p) in a run of its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…2mathml tests

Reader:
- \limits puts the scripts of an operator under and over it (┬ and ┴),
  \nolimits at its side, and in a display equation the scripts of a
  summation (\sum, \prod, \bigcup...) go under and over it, as TeX puts
  them; an integral keeps them at its side. The reader is told whether
  the equation is display (ToUnicodeMath, from FromLaTeX).
- the blanks around a script are left out: x ^ { 2 }.
- \begin{array} { l }: the column specification after a blank; matrix*
  with its optional alignment; \hline, \hdashline, \hfil left out.
- the symbols of amsmath, amssymb and plain TeX that UnicodeMath has no
  control word for: \lt, \gt, \dotsb and the other \dots, \S, \omicron,
  \gggtr, \nsubseteqq...; \emph as text.

Writer: \limits or \nolimits after a large operator whose scripts are
not where TeX puts them (under and over an inline summation or an
integral, at the side of a display summation).

latex2mathml corpus: the conventions of its MathML made the same as ours
(~ and ̃, munder with ― and menclose, empty matrices, mathvariant as math
alphanumerics, mi and mo for symbols, intent=":text", largeop, mstyle);
nine snapshots corrected where its MathML is not what the LaTeX means
(the ~ of text, ( - 25 ) ^ { 2 }, \omicron, \lim in display); and what
the reader still reads otherwise in four groups: commands Word's LaTeX
does not have, LaTeX not read yet, the same equation held otherwise,
what is no LaTeX.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
omml2mml.xsl writes no mathvariant on numbers, so a bold one of OMML
(m:sty b, "1") was read as a plain <mn>1</mn>: 𝟏 of UnicodeMath and
\mathbf{1} of LaTeX lost their bold through OMML. The digits of a bold,
double-struck, sans-serif or monospace run now go through the stylesheet as
their mathematical alphanumerics, each an identifier, as UnicodeMath and
LaTeX read them, so the round trip gives the same equation. Monospace is
read but still not written: mml2omml.xsl writes no m:scr for it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…8c6b

[no-new-version] OMML: tests of astral characters, styled digits keep their style
…not read

- extensible arrows with an optional argument and an unbraced label
  (\xrightarrow[y] x, \xLeftarrow, \xleftrightarrow, \xhookleftarrow),
  the arrows under a base (\underleftarrow), unbraced arguments of
  stacks, \boxed, \dbinom, \tbinom and \cfrac (\underset ab, \dbinom a b)
- \underset{a}{\overset{b}{X}} as one munderover, as the writer writes it
- \substack as a matrix, \sideset as the scripts on the phantom of the
  operator, \genfrac with escaped delimiters (\genfrac\{]...)
- \pod, \mathstrut, \not over a symbol or a group, \lvert and \rvert as
  bars, \color{}, \mathscr, \idotsint, \Box, \operatorname* and
  \operatornamewithlimits
- a script or a prime without a base (^3, 'x) on an empty one; a comma
  before a digit parts two numbers (a_{1,1}, a_{m,1})
- an argument can be a command with its arguments or an escaped
  character (\not\operatorname{R}, \genfrac\{)

latex2mathml corpus: empty placeholders, zero lengths, the thickness of a
bar, the moves of mpadded and the digits of a math alphabet (one
identifier, as OMML reads them since #6) made the same; 103 cases left,
18 of them LaTeX not read yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t| _0)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…l Greek written as \Delta

{\rm ...}, {\bf ...}, {\it ...}, {\cal ...}, {\sf ...} and {\tt ...} style the
rest of their group, as \mathrm{...} and the others. An upright capital Greek
letter is written \Delta rather than \text{Δ}, which read back as text: the
capitals of LaTeX are upright.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… as it, not \overline (¯)

\bar{x} reads as menclose notation="top", \overline{x} as an mover with ¯: the
writer wrote both as \overline, so that \bar{x} read back as another equation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…om it, and takes scripts

The script of ┴ took what came after the stack (a\overset{n\to\infty}{\longrightarrow}b
read b into it): a space ends it, also after \xrightarrow. \overset{a}{b}^2, which
was not valid UnicodeMath, is grouped for its script.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…which would be its numerator

lim_(ℎ→0) ⁡𝑓(𝑥)/ℎ and ⟶┴(𝑛→∞) 1/2 read back as fractions of all that came
before: the function apply sign does not end a numerator, and the space after a
script ends the script, not the numerator. The fraction is grouped there:
lim_(ℎ→0) ⁡〖𝑓(𝑥)/ℎ〗, ⟶┴(𝑛→∞) 〖1/2〗.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
LaTeX: the test cases of latex2mathml and mml2tex, and what they found
…latex-webc-fixes

The conflicts take what main does, which covers what this branch did there:
\lt and \gt among the symbols of main, the blanks around a script removed
before and after it, \overset grouped whole (which keeps it apart from what
follows, so EndScript goes), upright capital Greek written \Gamma. Kept: \degree
for °, and calligraphic-mode, which {\cal ...} now reads, out of the known
failures of the latex2mathml corpus.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Content MathML (apply, ci, cn...) outside the annotations is rewritten as
presentation MathML by David Carlisle's ctop.xsl (MIT), keeping the
presentation MathML around it. White space between elements and around
the text of ci/cn is removed first, and the limits ctop leaves empty
after. ctop.xsl is fixed for nested mi in bvar, x^{} in partialdiff
without a degree, an unprefixed mo, a bare log and ∫ as mi. The 40
equations of ctop's test page are the corpus of the new tests.

From the table of entities.xsl (XSLT MathML Library): about 70 amssymb
symbols written as commands, 15 commands the LaTeX reader did not know
(\square, \surd, \triangleq, \S...), and □ and √ written as \square and
\surd in standard LaTeX instead of \□ and \√.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…□ and √, upright names with digits

- UnicodeMath writer: a bar that is the base of a script is written ├| (sin├|_ℤ), a slash
  with nothing before it as ⓡ(/&U+002F), a bar without its pair inside brackets as
  ⓡ(|&U+007C) ([𝑎|𝑏] read as [𝑎 and the fence |𝑏]), and no ┤ is added before a fenced
  row at the end of the equation
- UnicodeMath grammar: a name marked as a function without an operand (NaN⁡,) is one
  name, so \operatorname{NaN}, reads back as NaN and stays stable
- Word's LaTeX: an identifier that is a UnicodeMath structure operator (□, √) is escaped
- OMML reader: an upright name with digits (sharet1) is one identifier

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
LaTeX and UnicodeMath fixes from the math.webc.site demos
- Standard LaTeX writer: □ as \square, √ as {\surd} (the symbol, not a root); the reader
  reads \square and \Box as □
- UnicodeMath writer: an identifier or an operator that builds a structure in UnicodeMath
  (□, √, ⒜...) is written as a remark, ⓡ(□&U+25A1), which reads back as that operator

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The structure operators of standard LaTeX take main's command lookup (\circledS) besides
\square and {\surd}; \operatorname{lg} now reads back, so it leaves the known failures of
the latex2mathml corpus.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… \underbrace

\mathop of one symbol is a group, to which \limits attaches its scripts
(munderover(x, 0, 1)); \overbrace and \underbrace with both scripts are, as
for TeX, operators with limits: \underset{x}{\overset{a}{\overbrace{3}}}.
The latex2mathml cases issue-125-4-limits, overbrace-c and underbrace-c pass.

[no-new-version]

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…does

\begin{name}{arguments}...\end{name} becomes the begin code with its #1...
(the first argument optional with a default), the body and the end code,
before the other passes; the definitions go to the macros, where later
conversions find them. ## in a macro body is # in math mode, which TeX
rejects: newcommand-escaped-hash is no LaTeX.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ter-fixes

Writer and reader fixes found with the Content MathML corpus
…acdf3

# Conflicts:
#	tests/SharpEquations.Tests/LaTeX/Latex2MathMLCorpusTests.cs
…sts to any branch

Stacked pull requests (based on another PR's branch rather than main) ran no
workflow at all, since every pull_request trigger was filtered to main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s out of NotInWordLaTeX

[no-new-version]

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The groups of NotInWordLaTeX taken from main, without the seven cases the
reader now reads (\tt, \Bbb, \mathop, the arrows of mathtools), and \Box
renamed once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The tests, CodeQL and the cleanup of the pre-release run for a pull request
whatever its base, so that a PR stacked on another one is tested too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
simonebortolin and others added 23 commits November 9, 2026 19:29
…tex-symbols-readback-e290e7

LaTeX reader reads every command the writer writes
…F-8 bytes

Word writes the "MathML" clipboard format as UTF-8 bytes; read as a string of
Windows, as UTF-16, each pair of bytes became a CJK character, and the MathML
that did not parse was inserted as text, before the plain text of the
clipboard. The format is now read and written as bytes (MathClipboard: UTF-8,
or UTF-16 with its byte order mark), and MathML or OMML that does not parse
inserts nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q8QQiaNyPsgRSRubdbT58H
…chem

mhchem: \ce and \pu in the LaTeX reader
…reads

Since main reads a script without a base as one on an empty base ({^3H} is
{}^3 H, as in TeX), the prescripts mhchem wrote at the start of a group
were read as scripts of an empty base. The reader reads \prescript of
mathtools as UnicodeMath prescripts, and mhchem writes them with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyVio98rJkziNnwWW9T1Mn
…ex-limits-dacdf3

LaTeX reader: limits of \mathop{x} and both scripts of \overbrace/\underbrace
…tex-macros-bd8629

LaTeX reader: \newenvironment and \renewenvironment
Content MathML read through ctop.xsl; amssymb symbols in LaTeX
…nd-space-42f4d4

Unknown LaTeX control words end at their letters
…tex-bars-spaces-89b298

LaTeX reader: space commands, \ outside environments, \middle beside empty sides
samples/SharpEquations.Avalonia.Browser.Demo runs the demo in the browser with
Avalonia.Browser: Inter for the text and the embedded Libertinus Math as its
fallback, System.Private.Xml kept whole for the OMML stylesheets, a splash with
the download progress. The desktop window and the page share DemoView, now
redesigned: a header, cards, a ribbon of structures drawn as formulas, symbols
by group, a gallery of examples rendered with MathView, the outputs with a copy
button, the light and the dark theme, and a layout for narrow screens.

The project needs the wasm-tools workload, so it is not in the solution:
.github/workflows/demo.yml builds it and publishes it to GitHub Pages from main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyVio7wKz4nEifoqJzu9wA
The default math font is Libertinus Math everywhere but Windows, so "Default"
and "Libertinus Math" were the same font, and Cambria Math was offered where it
is not installed: the change of font showed nothing. The demo embeds STIX Two
Math (SIL Open Font License), names the default font, offers Cambria Math only
where it is a math font, and applies the font to the gallery too.
DemoTests.FontChangesTheLayout checks it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyVio7wKz4nEifoqJzu9wA
…efault font to another and back

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyVio7wKz4nEifoqJzu9wA
… new structures of LaTeX in the commands

After the rebase on main, the LaTeX reader reads the arguments of a command without
braces (\underset ab, \xrightarrow[y] x, \boxed \Box, \not a): the editor no longer builds
up at a space while a command still awaits its arguments, counted from EquationCommands.
The commands list the structures the reader now reads: the extensible arrows of amsmath
and mathtools, the arrows under a base, \dddot, \ddddot, \mathscr, \not, \substack,
\prescript and \ce, \pu of mhchem.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyVioB6ZwExLPtzuBzNTgX
…with, by name

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyVio7wKz4nEifoqJzu9wA
@simonebortolin
simonebortolin changed the base branch from main to claude/xslt-generator November 9, 2026 19:28
…e web demo

ctop.xsl, which came from main after the branch, is compiled by the generator
too (CtopStylesheet). The stylesheets now count code points as XPath does, not
UTF-16 units as XslCompiledTransform did: an emoji (🍕) is an identifier of one
letter, written italic in OMML in one run with its neighbours, and goes through
OMML and back as the same equation; OmmlAstralTests pins that now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyVio7wKz4nEifoqJzu9wA
…h the MATH table

Cambria and Cambria Math share their glyphs in cambria.ttc, so the number of
glyphs matched Cambria, the first face, which has no MATH table: Cambria Math
was never a math font, the default font of Windows was Libertinus Math, and
choosing Cambria Math changed nothing. Of the faces with the number of glyphs,
FontFile now reads the one with a MATH table.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyVio7wKz4nEifoqJzu9wA
…ound for XslCompiledTransform

With the stylesheets compiled to C#, nothing generates code at run time: the
release build of the web demo is compiled to WebAssembly ahead of time, and is
trimmed with no warning, without keeping System.Private.Xml whole.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyVio7wKz4nEifoqJzu9wA
@simonebortolin
simonebortolin added this pull request to stack #25 November 9, 2026 19:43
…hen run by hand

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyVio7wKz4nEifoqJzu9wA

This branch had an error being deployed

1 failed deployment
github-pages — 16dc2288 Deployed Nov 9, 2026 by simonebortolin via Deploy to GitHub Pages #5
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