diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ce6d012..9d7cf80 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,8 +5,6 @@ on: branches: - main pull_request: - branches: - - main workflow_dispatch: permissions: @@ -23,8 +21,14 @@ env: DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 'true' NUGET_PACKAGES: ${{ github.workspace }}/.nuget/packages SOLUTION: SharpEquations.slnx - LIBRARY_PROJECT: src/SharpEquations/SharpEquations.csproj + # the packages, all with the version of BuildVersion.props: the converter, and the equation editor on top of it + LIBRARY_PROJECTS: >- + src/SharpEquations/SharpEquations.csproj + src/SharpEquations.Editing/SharpEquations.Editing.csproj + src/SharpEquations.Rendering/SharpEquations.Rendering.csproj + src/SharpEquations.Avalonia/SharpEquations.Avalonia.csproj TEST_PROJECT: tests/SharpEquations.Tests/SharpEquations.Tests.csproj + EDITOR_TEST_PROJECT: tests/SharpEquations.Avalonia.Tests/SharpEquations.Avalonia.Tests.csproj FEED_URL: https://nuget.pkg.github.com/${{ github.repository_owner }}/index.json # Stages: Resolve Version -> Test (Linux, Windows, macOS) -> Pack NuGet @@ -105,6 +109,7 @@ jobs: label: Linux x64 os: ubuntu-latest tfms: net10.0 net8.0 + editor_tfms: net10.0 net8.0 dotnet: | 8.0.x 10.0.x @@ -112,11 +117,13 @@ jobs: label: Windows x64 os: windows-latest tfms: net10.0 net481 + editor_tfms: net10.0 dotnet: 10.0.x - rid: osx-arm64 label: macOS arm64 os: macos-latest tfms: net10.0 + editor_tfms: net10.0 dotnet: 10.0.x steps: @@ -152,6 +159,19 @@ jobs: echo "::endgroup::" done + # the equation editor (Avalonia 12 has no .NET Framework build: net10.0, and net8.0 where it is installed) + - name: Run editor tests + shell: bash + run: | + set -e + for TFM in ${{ matrix.editor_tfms }}; do + echo "::group::editor $TFM" + dotnet run --project "$EDITOR_TEST_PROJECT" -c Release -f "$TFM" --no-build -- \ + --report-trx --report-trx-filename "${{ matrix.rid }}.editor.$TFM.trx" \ + --results-directory "$GITHUB_WORKSPACE/TestResults" + echo "::endgroup::" + done + - name: Upload test results if: always() uses: actions/upload-artifact@v7 @@ -201,11 +221,13 @@ jobs: # The .snupkg symbol package is written next to the .nupkg. - name: Pack run: | - dotnet pack "$LIBRARY_PROJECT" \ - -c Release \ - -o artifacts \ - -p:VersionSuffix="$VERSION_SUFFIX" \ - -p:ContinuousIntegrationBuild=true + for PROJECT in $LIBRARY_PROJECTS; do + dotnet pack "$PROJECT" \ + -c Release \ + -o artifacts \ + -p:VersionSuffix="$VERSION_SUFFIX" \ + -p:ContinuousIntegrationBuild=true + done ls -lh artifacts # nuget.org shows the README of the package: fail if it is missing or not declared. diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index e672b65..1016e0d 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -8,8 +8,6 @@ on: branches: - main pull_request: - branches: - - main schedule: - cron: '27 4 * * 3' workflow_dispatch: diff --git a/.github/workflows/demo.yml b/.github/workflows/demo.yml new file mode 100644 index 0000000..327cde7 --- /dev/null +++ b/.github/workflows/demo.yml @@ -0,0 +1,69 @@ +name: Web demo + +# The equation editor on WebAssembly (samples/SharpEquations.Avalonia.Browser.Demo): built on every pull request, +# published to GitHub Pages from main, or from any branch when the workflow is run by hand (Settings → Pages → Source: +# GitHub Actions; a branch other than main must be allowed in Settings → Environments → github-pages). +on: + push: + branches: + - main + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: demo-${{ github.ref }} + cancel-in-progress: true + +env: + DOTNET_NOLOGO: 'true' + DOTNET_CLI_TELEMETRY_OPTOUT: 'true' + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 'true' + PROJECT: samples/SharpEquations.Avalonia.Browser.Demo/SharpEquations.Avalonia.Browser.Demo.csproj + +jobs: + build: + name: Build the web demo + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Setup .NET + uses: actions/setup-dotnet@v6 + with: + dotnet-version: '10.0.x' + + - name: Install the WebAssembly workload + run: dotnet workload install wasm-tools + + - name: Publish + run: dotnet publish "$PROJECT" -c Release -o publish + + # GitHub Pages serves the site from a folder: no Jekyll, and the index for any path that is not a file + - name: Prepare the site + run: | + touch publish/wwwroot/.nojekyll + cp publish/wwwroot/index.html publish/wwwroot/404.html + + - uses: actions/upload-pages-artifact@v4 + with: + path: publish/wwwroot + + deploy: + name: Deploy to GitHub Pages + if: github.event_name == 'workflow_dispatch' || github.event_name == 'push' && github.ref == 'refs/heads/main' + needs: build + runs-on: ubuntu-latest + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - id: deployment + uses: actions/deploy-pages@v5 diff --git a/.github/workflows/prerelease-cleanup.yml b/.github/workflows/prerelease-cleanup.yml index c020278..fbd42bf 100644 --- a/.github/workflows/prerelease-cleanup.yml +++ b/.github/workflows/prerelease-cleanup.yml @@ -5,8 +5,6 @@ name: PR Pre-release Cleanup on: pull_request: types: [closed] - branches: - - main permissions: contents: read diff --git a/AGENTS.md b/AGENTS.md index 8a580d0..ff42ffb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,8 +14,14 @@ generated parser, on netstandard2.0): keep it so, and keep containers and other | `src/SharpEquations/` | The library: `Public/` (the public API), `Internal/` (one folder per format), `Resources/` (XSLT stylesheets) | | `src/SharpEquations.RegexPolyfill/` | Build time only: the `[GeneratedRegex]` source generator of the netstandard2.0 build | | `src/SharpEquations.XsltGenerator/` | Build time only: the source generator that compiles the XSLT stylesheets of `Resources/` to C# | +| `src/SharpEquations.Editing/` | The equation editor's model, no user interface: `MathNode` tree, caret, selection, undo, build up and build down | +| `src/SharpEquations.Rendering/` | The layout of MathML Core over the OpenType MATH table, no user interface: `MathLayoutEngine`, `MathLayout` | +| `src/SharpEquations.Avalonia/` | The controls for Avalonia: `MathEditBox` (the editor), `MathView`; embeds Libertinus Math | +| `samples/SharpEquations.Avalonia.Demo/` | A window with the editor (`DemoView`, shared with the web demo) | +| `samples/SharpEquations.Avalonia.Browser.Demo/` | The same demo on WebAssembly (needs the `wasm-tools` workload, so not in the solution; `.github/workflows/demo.yml` publishes it to GitHub Pages) | | `benchmarks/SharpEquations.Benchmarks/` | BenchmarkDotNet benchmarks of reading, writing and converting the corpora | | `tests/SharpEquations.Tests/` | TUnit tests, in folders by subject (PublicApi, MathML, Omml, Mtef, UnicodeMath, LaTeX, Property, Regression) | +| `tests/SharpEquations.Avalonia.Tests/` | TUnit tests of the editor (Editing, Rendering, and the controls in a headless Avalonia session with Skia) | | `tests/data/` | Test data and its provenance (`tests/data/README.md`) | ## Code Style @@ -57,7 +63,8 @@ init-only record: `Display`, `Strict`, `PreserveFormatting`, `LaTeXFlavor` stand `menclose notation="bottom"`. The default intents (`:fenced`, `:function`, `absolute-value`, `binomial-coefficient`, the matrices...) are not kept, since they would make an equation differ by how it was written (`\binom`, `⒞`, `(¦)`); `DefaultIntents.Restore` infers them again from the structure for the LaTeX and - UnicodeMath writers. + UnicodeMath writers. Content MathML of the source (`apply`, `ci`, `cn`..., outside the annotations) is first rewritten + as presentation MathML by `ctop.xsl` (David Carlisle's, see `NOTICE`), keeping the presentation MathML around it. - `Internal/Omml/`: OMML ↔ MathML with the XSLT 1.0 stylesheets of `Resources/` (from the TEI Stylesheets, see `NOTICE`). OMML is written as Word writes it (plain letters with `m:sty`/`m:scr`, underbars as `m:bar`), and an upright word of OMML (`max`) is read as one identifier. @@ -81,6 +88,27 @@ init-only record: `Display`, `Strict`, `PreserveFormatting`, `LaTeXFlavor` stand UnicodeMath: a slash is a slash, not a fraction, and a bar without its pair is a bar, not an absolute value), and written as standard LaTeX or as the LaTeX Word writes (`LaTeXSerializer`). +## The equation editor + +`docs/EDITOR.md` is its design. The core does not depend on it: the editor is built on the public API of the core only, +and Avalonia is a dependency of `SharpEquations.Avalonia` alone (Editing and Rendering target netstandard2.0 and +net10.0 with no package, as the core). + +- `Editing`: an equation being edited is a tree of `MathNode`s (the normalized MathML with a row, `mrow`, in every + slot) whose only text not built up is in runs. A run builds up with the nodes before it in its row: tokens are written + back as they were typed, structures as placeholders (Yi syllables, which the parsers read as identifiers) put back in + the result; this works because UnicodeMath round-trips exactly. Positions are paths of child indexes; offsets in runs + are code points. +- `Rendering`: `Layout/Engine.cs` lays out after MathML Core with the constants of the MATH table; the ink boxes of the + glyphs come from the outlines of the font (`OpenType/GlyphOutlines.cs`: `glyf`, or the CFF charstrings), never from + the rasterizer, and the tables from a copy of the font file (`OpenType/FontFile.cs`). +- `Avalonia`: `AvaloniaMathFont` reads the font file once with `TryGetStream` and asks Avalonia for its glyph typeface + only to draw, each time (`MathFonts.Register` registers the embedded font again with a new font manager: a headless + test session gives each test one, and a glyph typeface of the old one is released); the tests that use the headless + session are `[NotInParallel("headless")]`, since showing a window or typing runs the jobs of the dispatcher. +- The core's `EquationCommands` (public) lists the control words of UnicodeMath and the commands of LaTeX with their + symbols, for the completion of the editor; `PublicApi/EquationCommandsTests` checks that the parsers read them. + ## Building and Testing ```bash @@ -93,12 +121,17 @@ net481 (Windows, where the test project targets net10.0 and net481). The 738 exa `tests/data/unicodemath/` test the UnicodeMath port, and `Property/RoundTripPropertyTests` checks, for all of them and for the equations of Word documents (`tests/data/omml/`), that an equation written in its own format (UnicodeMath, OMML) reads back to the same equation, and that one written in another format (standard LaTeX, Word's LaTeX, OMML) -loses only the first time what that format cannot hold. Keep these at 100%. +loses only the first time what that format cannot hold. Keep these at 100%. For the editor, an equation of the corpus +loaded in the editor, or built down to linear text and up again, is the same equation (100%); typed one character at a +time it is the equation of the whole text for 93% of the corpus (`Editing/CorpusTests`) and for 90% of the LaTeX of +latex2mathml (`Editing/RepositoryCorporaTests`), which a change must not lower. The public API of the editor's +packages is tracked in their `PublicAPI.*.txt`, as that of the core. ## Releases `.github/workflows/ci.yml`, the same pipeline as SharpMediaInfo and Letra200bSharp: it tests on Linux (net10.0, net8.0), -Windows (net10.0, net481) and macOS (net10.0), packs, publishes a `pr--…` pre-release for each pull request (deleted on close by +Windows (net10.0, net481) and macOS (net10.0), packs the four packages (`SharpEquations` and the editor's `SharpEquations.Editing`, `.Rendering`, `.Avalonia`, with +one version), publishes a `pr--…` pre-release for each pull request (deleted on close by `prerelease-cleanup.yml`) and, on `main`, tags `v` of `BuildVersion.props`, creates the GitHub release and pushes the package to GitHub Packages and nuget.org (Trusted Publishing, repository variable `NUGET_USER`). A change that should release bumps `VersionPrefix`; one that should not says `[no-new-version]` in its commit message. diff --git a/Directory.Packages.props b/Directory.Packages.props index 949f387..9832a40 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,6 +4,13 @@ true + + + + + + + diff --git a/NOTICE b/NOTICE index 6863e21..82cf4c4 100644 --- a/NOTICE +++ b/NOTICE @@ -182,3 +182,130 @@ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +---------------------------------------------------------------------------- +src/SharpEquations/Resources/ctop.xsl +tests/data/contentmathml/ctop-*.mml + +From https://github.com/davidcarlisle/web-xslt (ctop/ctop.xsl), the XSLT 1.0 +stylesheet of Content MathML to presentation MathML, and its test equations +(ctop/tests/ctopxslt.html). The web-xslt collection is distributed under the MIT +licence; ctop.xsl also under the W3C Software Notice and License, Apache 2, +MPL 1.1 or MPL 2.0. SharpEquations uses them under the MIT licence, the stylesheet +modified as described in its header: + +Copyright David Carlisle 2001, 2002, 2008, 2009, 2013. + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +---------------------------------------------------------------------------- +tests/data/latex/latex2mathml-*.json + +Test cases from https://github.com/roniemartinez/latex2mathml: + +MIT License + +Copyright (c) 2016 Ronie Martinez + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +---------------------------------------------------------------------------- +tests/data/mathml/mml2tex.json + +Test cases from https://github.com/transpect/mml2tex: + +Copyright (c) 2015, transpect.io +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +---------------------------------------------------------------------------- +src/SharpEquations/Internal/LaTeX/Mhchem.cs +tests/data/latex/treeblood-mhchem.json + +The rules of mhchem's \ce in Mhchem.cs follow mhchem.go of https://github.com/Wyatt915/treeblood, +rewritten in C# to write LaTeX rather than MathML; the test cases are those of its test/TESTCASES.yaml: + +MIT License + +Copyright (c) 2024 Wyatt Sheffield + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +---------------------------------------------------------------------------- +src/SharpEquations.Avalonia/Assets/Fonts/LibertinusMath-Regular.otf + +Libertinus Math 7.040, from the Libertinus fonts, https://github.com/alerque/libertinus, +the math font that SharpEquations.Avalonia embeds for the systems without Cambria Math. +Copyright © 2012-2021 The Libertinus Project Authors, with Reserved Font Name +"Linux Libertine", "Biolinum", "STIX Fonts". Licensed under the SIL Open Font License, +Version 1.1: src/SharpEquations.Avalonia/Assets/Fonts/OFL.txt (LibertinusMath-OFL.txt in +the package). diff --git a/README.md b/README.md index 01bdc54..dc29be1 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ format) and LaTeX, in .NET, with MathML as the common representation. | Format | Read | Write | |---|---|---| -| MathML | ✓ | ✓ | +| MathML (presentation; Content MathML is read as the presentation it is written as) | ✓ | ✓ | | OMML (Office Math Markup Language, the equations of `.docx`) | ✓ | ✓ | | MTEF (MathType, Equation Editor 3.0) | ✓ | | | UnicodeMath | ✓ | ✓ | @@ -49,6 +49,46 @@ stream of its OLE object (`Equation.Parse(bytes, EquationFormat.Mtef)`); reading Equations compare by their normalized MathML: an equation written in its own format (UnicodeMath, OMML) reads back to the same equation, and one written in another format loses only what that format cannot hold. +## Equation editor for Avalonia + +`SharpEquations.Avalonia` is a real-time equation editor in the manner of the `RichEditBox` of WinUI in math mode and of +the equations of Word: UnicodeMath or LaTeX typed at the caret builds up into the equation when a space or an operator +ends an argument (`a/b␣` becomes a fraction, `\alpha␣` α), the caret moves through the slots of the structures, and the +equation is read and written as MathML or as an `Equation`. + +```xml + +``` + +```csharp +var editor = new MathEditBox { InputFormat = EquationFormat.LaTeX }; +editor.EquationChanged += (_, e) => Console.WriteLine(e.Equation?.ToString(EquationFormat.MathML)); +string mathML = editor.GetMath(); // as RichEditTextDocument.GetMath +editor.SetMath(mathML); // as RichEditTextDocument.SetMath +editor.ViewMode = MathViewMode.Linear; // the equation as one line of UnicodeMath (Ctrl+Shift+L) +editor.EquationNumber = "(1)"; // at the right of the line, as #(1) or \tag{1} +string latex = editor.GetSelectedText(EquationFormat.LaTeX)!; +editor.CopyTextFormat = EquationFormat.MathML; // Ctrl+C pastes as an equation into Word +editor.Editor.InsertLineBreak(); // Shift+Enter: lines aligned at & +editor.Editor.InsertTableRow(); // and columns, in the matrix of the caret +``` + +Typing `\` and the start of a command opens a floating bar at the caret with its completions +(`EquationCommands.Complete("al", EquationFormat.UnicodeMath)` gives them from the core); the bar also offers structures +for the selection, the commands of a table and quick symbols. + +The packages are `SharpEquations.Avalonia` (the controls), `SharpEquations.Rendering` and `SharpEquations.Editing`, with +the version of `SharpEquations`. + +`MathView` shows an equation without editing it. Both lay out MathML after MathML Core with the OpenType MATH table of +a math font: Cambria Math on Windows, and Libertinus Math (SIL Open Font License), embedded, elsewhere. The editor is +three projects, so that the core keeps no dependency: `SharpEquations.Editing` (the editing model, no user interface), +`SharpEquations.Rendering` (the layout, no user interface) and `SharpEquations.Avalonia` (the controls). The design is in +[docs/EDITOR.md](docs/EDITOR.md); `samples/SharpEquations.Avalonia.Demo` is a window with the editor and its MathML, +UnicodeMath and LaTeX, and `samples/SharpEquations.Avalonia.Browser.Demo` the same demo in the browser, on WebAssembly +(published to GitHub Pages from `main`; to run it locally: `dotnet workload install wasm-tools`, then +`dotnet run --project samples/SharpEquations.Avalonia.Browser.Demo`). + ## Architecture How the conversions work, format by format, and how round trips are kept exact: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md). @@ -67,7 +107,7 @@ its README). ## Releases CI (`.github/workflows/ci.yml`) tests every push and pull request on Linux (.NET 10 and .NET 8), Windows (.NET 10 and -.NET Framework 4.8.1) and macOS (.NET 10), and packs the NuGet package. The release version is `VersionPrefix` in `BuildVersion.props`: bump it, and merging to +.NET Framework 4.8.1) and macOS (.NET 10), and packs the NuGet packages (the converter and the three of the editor, with one version). The release version is `VersionPrefix` in `BuildVersion.props`: bump it, and merging to `main` tags `vX.Y.Z`, creates the GitHub release with the package attached, and publishes it to GitHub Packages and to nuget.org (with [Trusted Publishing](https://learn.microsoft.com/nuget/nuget-org/trusted-publishing): the repository variable `NUGET_USER` names the nuget.org account; without it nuget.org is skipped). A push that keeps the version on diff --git a/SharpEquations.slnx b/SharpEquations.slnx index e10d9a6..2c836e1 100644 --- a/SharpEquations.slnx +++ b/SharpEquations.slnx @@ -1,4 +1,8 @@ + + + + @@ -9,14 +13,18 @@ - + + + + + diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 45ab4f0..0dab68c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -161,6 +161,12 @@ also regroups an n-ary operation or a function application that normalization fl ### MathML (`Internal/MathML/`) `ParseXml` reads the XML with DTDs prohibited and no resolver (no external entities), the undeclared prefixes `mml:`, `m:` and `w:` known; in strict mode `Validate` accepts presentation MathML only. +Content MathML (`x1`), alone or inside presentation MathML, is rewritten +first as the presentation MathML it is written as, by `ctop.xsl` (David Carlisle's stylesheet; the changes made +to it are in its header): the white space between the elements and around the text +of `ci` and `cn` goes before, and the limits it leaves empty (an integral without bounds) after. The Content MathML of +an `annotation-xml` is left alone, and a `semantics` whose first child is Content MathML is read from its +`MathML-Presentation` annotation when it has one. `tests/data/contentmathml` holds the test equations of ctop. `MathMLSerializer` writes the normalized MathML as it is. ### OMML (`Internal/Omml/`) diff --git a/docs/EDITOR.md b/docs/EDITOR.md new file mode 100644 index 0000000..70ca930 --- /dev/null +++ b/docs/EDITOR.md @@ -0,0 +1,249 @@ +# Equation editor for Avalonia + +`MathEditBox` is a real-time equation editor for Avalonia in the manner of the `RichEditBox` of WinUI in +`RichEditMathMode.MathOnly`: the user types UnicodeMath or LaTeX, the equation builds up into its two-dimensional form +as it is typed, and the content is read and written as MathML (`GetMath`/`SetMath`, as in WinUI) or as an `Equation`. +SharpEquations is the core under it: it reads what is typed, gives the MathML, and writes the linear form back when a +part of the equation is built down. The editor edits one equation (math only), not math zones in a text document. + +Phases 1 to 6 of the plan below are done; the three projects are published as NuGet packages with +the version of the core. + +## What the user sees + +The behaviour is the one of the equations of Word and of `RichEditBox` in math mode. + +- **Professional** (built up): the equation is shown in two dimensions. Typing goes into the *run* at the caret, a + piece of linear text; when a character that ends an argument is typed (a space, or an operator: `+ - = < > , ; :`, + `± × · ÷`, the relations and arrows) the run is *built up*: `a/b␣` becomes a fraction, `x^2+` a superscript and a + plus, `\alpha␣` becomes `α`, `\sqrt(x)␣` a radical. The space that builds up is not kept. Text that does not parse + yet, or that is not finished (an open bracket or quote, an operator right after `^ _ / \ (`, as in `e^-x`), stays + linear, drawn as typed, until it does. +- **Linear**: the whole equation is shown as one run of UnicodeMath or LaTeX and edited as text + (`ViewMode = MathViewMode.Linear`, or Ctrl+Shift+L, `ToggleViewModeGesture`). Switching back builds it up. +- **Input format**: `InputFormat` is UnicodeMath or LaTeX; LaTeX is read as Word reads it (`LaTeXReader`) and written, + when built down, as the LaTeX of Word (`LaTeXFlavor.Word`). +- **Navigation**: Left and Right go through the slots of the structures (numerator, denominator, base, scripts, the + index of a root before its base, the cells of a table); Up and Down go to the slot above or below (numerator and + denominator, superscript and subscript, limits, the rows of a table), to the position nearest to the caret; Home and + End go to the ends of the row, then of the equation; Shift extends the selection, which covers a whole structure when + it leaves a slot. Backspace and Delete remove a code point of a run or of a token (a token becomes a run then), and + select a structure first and delete it the second time, as in Word. Ctrl+Z and Ctrl+Y undo and redo: the history is a + stack of the immutable trees (1000 at most, `UndoLimit`), which share what an edit did not change; the characters + typed in a row are one step, and a build up another, so that an undo after a build up gives back the text as it was + typed; Ctrl+A selects all; Enter builds up all the runs. The shortcuts of copy, + paste, undo... are those of the platform (`PlatformHotkeyConfiguration`). +- **Mouse**: a click places the caret at the nearest position of the deepest row under the pointer; a drag selects. +- **Clipboard**: copy puts the selection as text in `CopyTextFormat` (the input format by default; MathML pastes as an + equation into Word) and as MathML (`MathML` on Windows, `application/mathml+xml` elsewhere), as UTF-8 bytes, as Word writes and reads + that format (`MathClipboard` decodes UTF-8, or UTF-16 with its byte order mark); a handler of `Copying` + can add formats or cancel; `GetSelectedText(format)` gives the selection in any format (UnicodeMath, LaTeX, MathML, + OMML) without the clipboard. Paste reads MathML, then the text in the input format or as MathML, and inserts plain + text as a run when it does not parse; MathML that does not parse inserts nothing. +- **Input**: one format at a time, UnicodeMath or LaTeX, switched at any moment (what is built up stays). UnicodeMath + already reads the TeX control words of Word (`\alpha`, `\sqrt`, `\sum`); a LaTeX control word followed by a space + waits for its argument when it has one (`\mathring a`), and is built up when it is complete (`\alpha␣`). +- **Equation numbers**: `#(1)` at the end in UnicodeMath, `\tag{1}` in LaTeX, or `EquationNumber = "(1)"`: the number + goes to the right edge of the line (or the left, with `side="left"`), with the equation in the middle of what is + left, as in Word; the layout knows the line from `MathLayoutOptions.LineWidth`, which the controls set to their width. +- **Lines**: Shift+Enter breaks the line at the caret, as in Word: the equation becomes an equation array (the + `█(...@...)` of UnicodeMath, `aligned` of LaTeX: a table of one column whose lines start with `maligngroup`), or gets a + line more; the lines line up at their first `&` (`malignmark`), and keep the display style. Backspace at the start of + a line and Delete at its end join it to its neighbour; Up and Down move between lines. A line is built up as the line + of an array, where `&` is an alignment mark. +- **Automatic line breaks**: a display equation wider than its line (`MathLayoutOptions.LineWidth`, the width of the + control) breaks before its binary operators and relations, the last one that fits each line, as Word does; the lines + after the first are indented by 2 em, and stay one row for the editor: the caret, the selection (a rectangle a line), + clicks and Up and Down (`IMathGeometry.GetPositionOnAdjacentLine`) follow the lines. `BreakLines = false` keeps one + line. +- **Tables and matrices**: `InsertTableRow`, `InsertTableColumn` (above or below, left or right), `DeleteTableRow`, + `DeleteTableColumn` on the table of the caret (the last row or column deleted takes the table); Tab and Shift+Tab go + through the cells. +- **Completion**: after `\` and the start of a command, `CommandPrefix`, `GetCompletions` and `Complete` complete it from + the commands of the core (`EquationCommands`: the control words of UnicodeMath, the commands of LaTeX, each with its + symbol); a command with a symbol becomes the symbol, as Word's math autocorrect does, a LaTeX structure its text with + the caret in its first argument (`\frac{|}{}`). +- **Floating bar** (`MathQuickBar`, `IsQuickBarEnabled`): under the equation, at the caret, it offers the completions of + the command being typed (Up and Down choose, Tab or Enter take, Esc closes), the structures that take the selection + (fraction, root, scripts, brackets, bar, box, build down), the commands of the table of the caret, and quick symbols + (`QuickSymbols`) in an empty slot. Its buttons do not take the focus. +- **Structures from code**: `InsertStructure("mfrac")`, `InsertFences`, `InsertMatrix(2, 2, "(", ")")`, + `InsertTemplate(node)`, with the selection in the first slot and the caret in the first empty one, as the buttons of + the equation tools of Word; Tab and Shift+Tab move through the cells of a table. +- **Empty slots** are drawn as a dashed box, as the placeholder `⬚` of Word. + +## Layers + +```mermaid +flowchart TB + subgraph Core["SharpEquations (unchanged: .NET base library only)"] + P["Equation.Parse(UnicodeMath | LaTeX | MathML)"] + W["Equation.ToString(UnicodeMath | LaTeX)"] + X["Equation.ToMathML()"] + end + subgraph Editing["SharpEquations.Editing (netstandard2.0, net10.0)"] + D["MathNode tree with runs"] + C["MathEditor: caret, selection, undo, commands"] + B["BuildUp: build up, build down"] + end + subgraph Rendering["SharpEquations.Rendering (netstandard2.0, net10.0)"] + M["OpenType: MathTable, GlyphOutlines, FontFile"] + L["Layout/Engine: MathML Core layout"] + H["MathLayout: glyphs, shapes, caret geometry, hit testing"] + end + subgraph Avalonia["SharpEquations.Avalonia (net8.0, net10.0)"] + E["MathEditBox"] + V["MathView"] + F["AvaloniaMathFont, MathFonts, MathDrawing"] + end + B --> P + B --> W + C --> X + E --> C + E --> H + V --> H + L --> M + F --> M +``` + +The core is unchanged and the editor is built on its public API only. The demo (`samples/SharpEquations.Avalonia.Demo`) +has the tools of the equations of Word: the structures and symbols to insert, the input format, the views, the font, +the number of the equation, undo, copy in every format, examples, and the equation as MathML, UnicodeMath, LaTeX and +OMML as it is typed; a test opens it. Editing and Rendering have no dependency on a +user interface (they target netstandard2.0 and net10.0, with no package, as the core), so that they are tested without +a toolkit and could serve another one; only `SharpEquations.Avalonia` depends on Avalonia (12.1). Each is a project of +its own in this repository; `samples/SharpEquations.Avalonia.Demo` is a window with the editor and the equation in every +format, and `tests/SharpEquations.Avalonia.Tests` tests the three. + +## The model (`SharpEquations.Editing`) + +### Why a tree and not a string + +Keeping the linear text as the source of truth and parsing all of it at each keystroke does not work for an editor in +two dimensions: the caret is in the rendered equation, and mapping it to an offset in the text needs the source span of +every node, which the parsers of SharpEquations do not give (the UnicodeMath translator, ported line by line, has no +notion of spans). The model is therefore the one of Word: a tree of built-up structures whose only text is in runs, and +parsing is limited to what is being built up. For the same reason a streaming API in the core (input received a piece +at a time) is not needed: the pieces are short, and each is parsed whole. + +`MathNode` is an element of MathML (`Name`, `Attributes`, `Text` for tokens, `Children`) or a run (`IsRun`), immutable: +an edit gives a new tree that shares the nodes it did not change, which makes undo a stack of trees. Every slot of a +structure is a row (`mrow`), even where MathML has a single element or an inferred row (`msqrt`, `mtd`...), so that the +caret always has a row to stand in. `MathNode.FromMathML` adds these rows; `ToMathML` writes them back, and without a +row the slots that hold a single node, which is the normalized form (the normalization recognizes the operand of an +n-ary operator only that way). Elements the editor does not enter (`semantics`, `mstack`, `mlongdiv`) are kept as they +were read. + +A position (`MathPosition`) is the path of a row (offset between two children) or of a run (offset between two code +points, never UTF-16 units), as child indexes from the `math` node. A position between a run and its neighbour is the +end or the start of the run. A selection (`MathSelection`) is a range of the children of a row, or of the code points of +a run; when its two ends are in different rows, it is the range of the deepest row that holds both, where an end inside +a child covers the child. + +### Build up + +`BuildUp.Parse` builds up a run together with the nodes before it in its row, since the operand of an operator typed in +the run can be one of them: `(a+b)/c` makes a fraction of a bracket built up earlier, `x^2` a superscript of the `x` +before. Those nodes are written in the text as they were typed when they are tokens without attributes (`a`, `+`, `12`, +`sin`) or brackets of tokens, so that the parser reads them as it would have read the text; any other node (a structure, +a token with attributes) is written as a placeholder, a character of the Yi syllables (U+A000...) that the parsers read +as an identifier, and the node is put back in place of the identifier in the result. The result is discarded if a +placeholder is lost, merged or repeated. + +This is exact because UnicodeMath round-trips exactly (`RoundTripPropertyTests`): an equation of the corpus loaded in +the editor, or built down to linear text and up again, is the same equation, for all of them +(`Editing/CorpusTests`). Typed one character at a time, building up as it goes, an equation of the corpus is the +equation of its whole text for 93% of the corpus: the others are text that UnicodeMath reads otherwise when it is read a +piece at a time (prescripts typed before their base, `^+-` that becomes `^±`, spaces that end an operand), where what +builds up at an operator stays built up, as in Word. The other corpora of the repository test the editor too +(`Editing/RepositoryCorporaTests`): the LaTeX of latex2mathml and the OMML of Word documents load unchanged, and round +trip through linear UnicodeMath and the LaTeX of Word; typed one character at a time, 90% of the LaTeX gives the +equation of its text; all of them, with the MathML of mml2tex and the MTEF of the fixtures, lay out. + +`MathEditor.ToEquation()` builds up a copy of the tree, with the runs that do not parse as `merror`, and reads it as +MathML, so that the equation an editor gives is normalized like any other and compares with `==`. + +## Layout and rendering (`SharpEquations.Rendering`) + +The renderer lays out the nodes after MathML Core with the OpenType MATH table of a math font: there is no MathML +renderer for Avalonia (CSharpMath renders LaTeX with its own model, which would be a second representation to keep in +step with the first). + +- **Fonts**: `IMathFont` gives the glyphs, the advances, the ink boxes and the MATH table; `MathTable` reads the + constants, the italic corrections, the top accent attachments and the vertical and horizontal variants and + assemblies. The ink boxes come from the outlines of the font, not from a rasterizer: `GlyphOutlines` reads the boxes of + the `glyf` table of a TrueType font (Cambria Math) or runs the charstrings of the `CFF` table (Libertinus Math), within + 0.12 design units of Skia. (Avalonia's own glyph metrics are empty for CFF outlines, and the bounds of a glyph run in + Skia can come back empty.) +- **Layout** (`Layout/Engine.cs`): tokens (a single letter of `mi` in mathematical italic), rows with the spaces of the + operator dictionary by form (none in scripts, as TeX), vertical stretchy operators to the height of their row + (symmetric about the axis for fences), large operators in display style, fractions (`linethickness`, stacks without + rule), radicals and roots, scripts (with italic correction and the baseline drops), under and over scripts (accents on + the top accent attachment, limits, movable limits as scripts in inline style, horizontal stretchy operators and bars as + wide as the base), `mmultiscripts`, tables (`columnalign`, `columnspacing`, `rowspacing`, labels), `menclose`, + `mstyle`, `mpadded`, `mphantom`, `mspace`, `merror`, `ms`, `mathcolor` and `mathbackground`. +- **Result** (`MathLayout`): glyphs and shapes with absolute coordinates for a toolkit to draw, and the geometry of every + row and run for an editor: the caret rectangle of a position, the rectangles of a selection, the position at a point + (`HitTest`: the deepest row under the point, or the nearest), the nearest position in a row (`IMathGeometry`, which + the editor uses to move up and down). + +## The controls (`SharpEquations.Avalonia`) + +```csharp +public class MathEditBox : Control +{ + public Equation? Equation { get; set; } // two-way; null when empty + public EquationFormat InputFormat { get; set; } // UnicodeMath (default) or LaTeX + public MathViewMode ViewMode { get; set; } // Professional or Linear + public bool IsDisplay { get; set; } // display (default) or inline + public bool IsReadOnly { get; set; } + public double FontSize { get; set; } + public IBrush? Foreground, Background, BorderBrush, SelectionBrush, CaretBrush, PlaceholderBrush { get; set; } + public FontFamily? MathFontFamily { get; set; } // null: MathFonts.Default + public KeyGesture? ToggleViewModeGesture { get; set; } // Ctrl+Shift+L + public MathEditor Editor { get; } // the model, for commands of an application + + public string GetMath(); // as RichEditTextDocument.GetMath + public void SetMath(string mathML); // as RichEditTextDocument.SetMath + public string GetText(EquationFormat format); + public void BuildUp(); public void BuildDown(); + public Task CopyAsync(); public Task CutAsync(); public Task PasteAsync(); + public event EventHandler? EquationChanged; +} + +public class MathView : Control // read-only: Equation (or MathML) in, drawing out +``` + +- **Fonts**: `MathFonts.Default` is Cambria Math on Windows, where it is a font of the system, and Libertinus Math + (SIL Open Font License, embedded as an Avalonia resource) elsewhere or when Cambria Math is missing; `MathFontFamily` + takes any family with a MATH table. Characters the font lacks come from a fallback font of the system. + `AvaloniaMathFont` reads the font file once (`TryGetStream`) and takes from it the glyphs, advances, ink boxes, MATH + and GSUB tables; it asks Avalonia for its glyph typeface only to draw, each time, after registering the embedded font + with the current font manager: a glyph typeface held for long can be released with its font manager (a headless + test session gives each test a new one), and its memory given to another font. +- **Script variants**: in scripts the glyphs are the `ssty` variants of the GSUB table of the font (`ScriptStyles`), drawn + for the sizes of scripts and of scripts of scripts. +- **Text input** comes from `TextInput`, so that IMEs and dead keys work, and a `TextInputMethodClient` gives the IME the + rectangle of the caret. +- The `Equation` property is set on every change of the content, with the runs built up where they parse. + +## Plan + +| Phase | Content | State | +|---|---|---| +| 1 | `SharpEquations.Rendering` and `MathView`: MATH table, outlines, layout of MathML Core | done: the corpus lays out (`Rendering/LayoutTests`) | +| 2 | `SharpEquations.Editing`: nodes ↔ MathML, caret, navigation, selection, build up and down, undo | done: 100% of the corpus loads and round-trips through linear text, 93% typed a character at a time | +| 3 | `MathEditBox`: input, caret, hit testing, selection, linear view, clipboard, demo | done: headless tests of typing, keys, mouse, MathML in and out | +| 4 | Accessibility (`MathEditBoxAutomationPeer`: an edit control whose value is the equation in UnicodeMath), Tab in tables, equation numbers, copy in any format, structures from code, NuGet packages | done | +| 5 | The commands of the core in its public API (`EquationCommands`) and their completion, the floating bar, lines (equation arrays aligned at `&`), rows and columns of tables, the `ssty` variants of scripts | done | +| 6 | Automatic line breaks of long display equations, before the operators and relations, as Word | done | +| 7 | Templates of the floating bar chosen by the application, breaks inside brackets and fractions too wide for a line | to do | + +## Open points + +- **Public API of the core**: `EquationCommand` and `EquationCommands` are new public API of `SharpEquations` (in + `PublicAPI.Unshipped.txt`), which a minor version brings. +- **Packages**: the CI packs the three projects with the core, with its version, and publishes them with it; their + public API is tracked in `PublicAPI.Shipped.txt`/`PublicAPI.Unshipped.txt`, as that of the core. +- **Reference images**: the rendering is tested by the geometry of the layout, not by pictures; the tests write + pictures of a set of equations when `SHARPEQUATIONS_SCREENSHOTS` names a directory, for a look. diff --git a/samples/SharpEquations.Avalonia.Browser.Demo/Program.cs b/samples/SharpEquations.Avalonia.Browser.Demo/Program.cs new file mode 100644 index 0000000..fa16be3 --- /dev/null +++ b/samples/SharpEquations.Avalonia.Browser.Demo/Program.cs @@ -0,0 +1,36 @@ +namespace SharpEquations.Avalonia.Demo +{ + using System.Runtime.Versioning; + using System.Threading.Tasks; + using global::Avalonia; + using global::Avalonia.Browser; + using global::Avalonia.Controls.ApplicationLifetimes; + using global::Avalonia.Media; + + [SupportedOSPlatform("browser")] + public static class Program + { + public static Task Main() => BuildAvaloniaApp().StartBrowserAppAsync("out"); + + // A page has no fonts of its own: Inter for the text, and the embedded Libertinus Math for the symbols it lacks. + public static AppBuilder BuildAvaloniaApp() => AppBuilder.Configure() + .WithInterFont() + .With(new FontManagerOptions + { + DefaultFamilyName = "fonts:Inter#Inter", + FontFallbacks = new[] { new FontFallback { FontFamily = new FontFamily("fonts:SharpEquations#Libertinus Math") } }, + }); + } + + public sealed class App : Application + { + public override void Initialize() => DemoTheme.Apply(this); + + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is ISingleViewApplicationLifetime view) + view.MainView = new DemoView(); + base.OnFrameworkInitializationCompleted(); + } + } +} diff --git a/samples/SharpEquations.Avalonia.Browser.Demo/Properties.json b/samples/SharpEquations.Avalonia.Browser.Demo/Properties.json new file mode 100644 index 0000000..e69de29 diff --git a/samples/SharpEquations.Avalonia.Browser.Demo/SharpEquations.Avalonia.Browser.Demo.csproj b/samples/SharpEquations.Avalonia.Browser.Demo/SharpEquations.Avalonia.Browser.Demo.csproj new file mode 100644 index 0000000..d41d486 --- /dev/null +++ b/samples/SharpEquations.Avalonia.Browser.Demo/SharpEquations.Avalonia.Browser.Demo.csproj @@ -0,0 +1,38 @@ + + + + + net10.0-browser + Exe + false + enable + SharpEquations.Avalonia.Demo + true + + true + true + false + true + + + + + + + + + + + + + + + + + + + + diff --git a/samples/SharpEquations.Avalonia.Browser.Demo/wwwroot/app.css b/samples/SharpEquations.Avalonia.Browser.Demo/wwwroot/app.css new file mode 100644 index 0000000..7173715 --- /dev/null +++ b/samples/SharpEquations.Avalonia.Browser.Demo/wwwroot/app.css @@ -0,0 +1,123 @@ +:root { + --page: #f3f4fa; + --ink: #1f2333; + --muted: #6b7280; + --track: rgba(79, 70, 229, 0.15); +} + +@media (prefers-color-scheme: dark) { + :root { + --page: #0d0f16; + --ink: #e7e9f2; + --muted: #9aa3b5; + --track: rgba(165, 180, 252, 0.18); + } +} + +html, body { + margin: 0; + padding: 0; + height: 100%; + overflow: hidden; + background: var(--page); + color: var(--ink); + font-family: Inter, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; +} + +#out { + position: fixed; + inset: 0; +} + +#splash { + position: fixed; + inset: 0; + display: grid; + place-items: center; + background: + radial-gradient(60rem 40rem at 10% -10%, rgba(99, 102, 241, 0.35), transparent 60%), + radial-gradient(50rem 40rem at 110% 110%, rgba(192, 38, 211, 0.28), transparent 60%), + var(--page); + transition: opacity 0.45s ease, visibility 0.45s; + z-index: 10; +} + +#splash.done { + opacity: 0; + visibility: hidden; +} + +#splash .card { + text-align: center; + padding: 2.5rem 3rem; + max-width: 26rem; +} + +#splash .formula { + display: flex; + justify-content: center; + gap: 0.9rem; + font-size: 2.6rem; + font-family: "Cambria Math", "STIX Two Math", "Latin Modern Math", serif; + background: linear-gradient(120deg, #4338ca, #7c3aed 55%, #c026d3); + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} + +#splash .formula span { + display: inline-block; + animation: float 1.8s ease-in-out infinite; +} + +#splash .formula span:nth-child(2) { animation-delay: 0.15s; } +#splash .formula span:nth-child(3) { animation-delay: 0.3s; } +#splash .formula span:nth-child(4) { animation-delay: 0.45s; } + +@keyframes float { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-0.4rem); } +} + +#splash h1 { + margin: 0.8rem 0 0.3rem; + font-size: 1.7rem; + font-weight: 700; + letter-spacing: -0.01em; +} + +#splash p { + margin: 0 0 1.4rem; + color: var(--muted); +} + +#splash .bar { + height: 6px; + border-radius: 999px; + background: var(--track); + overflow: hidden; +} + +#splash #progress { + width: 4%; + height: 100%; + border-radius: inherit; + background: linear-gradient(90deg, #4338ca, #7c3aed, #c026d3); + transition: width 0.25s ease; +} + +#splash small { + display: block; + margin-top: 1rem; + color: var(--muted); + font-size: 0.75rem; + letter-spacing: 0.02em; +} + +#splash.failed .bar { + display: none; +} + +@media (prefers-reduced-motion: reduce) { + #splash .formula span { animation: none; } +} diff --git a/samples/SharpEquations.Avalonia.Browser.Demo/wwwroot/favicon.svg b/samples/SharpEquations.Avalonia.Browser.Demo/wwwroot/favicon.svg new file mode 100644 index 0000000..afdf5ff --- /dev/null +++ b/samples/SharpEquations.Avalonia.Browser.Demo/wwwroot/favicon.svg @@ -0,0 +1 @@ +∑ diff --git a/samples/SharpEquations.Avalonia.Browser.Demo/wwwroot/index.html b/samples/SharpEquations.Avalonia.Browser.Demo/wwwroot/index.html new file mode 100644 index 0000000..9c1d9e8 --- /dev/null +++ b/samples/SharpEquations.Avalonia.Browser.Demo/wwwroot/index.html @@ -0,0 +1,29 @@ + + + + + + SharpEquations — equation editor + + + + + + + + +
+
+
+
+ ∑∫√π +
+

SharpEquations

+

Loading the equation editor…

+
+ .NET on WebAssembly · Avalonia · OpenType MATH +
+
+ + + diff --git a/samples/SharpEquations.Avalonia.Browser.Demo/wwwroot/main.js b/samples/SharpEquations.Avalonia.Browser.Demo/wwwroot/main.js new file mode 100644 index 0000000..45b77b5 --- /dev/null +++ b/samples/SharpEquations.Avalonia.Browser.Demo/wwwroot/main.js @@ -0,0 +1,28 @@ +import { dotnet } from './_framework/dotnet.js'; + +const splash = document.getElementById('splash'); +const progress = document.getElementById('progress'); + +try { + const runtime = await dotnet + .withDiagnosticTracing(false) + .withApplicationArgumentsFromQuery() + .withResourceLoader((type, name, url) => url) + .withModuleConfig({ + onDownloadResourceProgress: (loaded, total) => { + if (total > 0) progress.style.width = `${Math.round(100 * loaded / total)}%`; + }, + }) + .create(); + const config = runtime.getConfig(); + const started = runtime.runMain(config.mainAssemblyName, [globalThis.location.href]); + const hide = () => splash.classList.add('done'); + new MutationObserver((_, observer) => { + if (document.querySelector('#out canvas')) { hide(); observer.disconnect(); } + }).observe(document.getElementById('out'), { childList: true, subtree: true }); + await started; +} catch (error) { + splash.classList.add('failed'); + document.getElementById('message').textContent = `The demo could not start: ${error}`; + throw error; +} diff --git a/samples/SharpEquations.Avalonia.Demo/Assets/Fonts/OFL.txt b/samples/SharpEquations.Avalonia.Demo/Assets/Fonts/OFL.txt new file mode 100644 index 0000000..11b7b8e --- /dev/null +++ b/samples/SharpEquations.Avalonia.Demo/Assets/Fonts/OFL.txt @@ -0,0 +1,92 @@ +Copyright 2001-2021 The STIX Fonts Project Authors (https://github.com/stipub/stixfonts), with Reserved Font Name "TM Math". STIX Fonts™ is a trademark of The Institute of Electrical and Electronics Engineers, Inc. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/samples/SharpEquations.Avalonia.Demo/Assets/Fonts/STIXTwoMath-Regular.ttf b/samples/SharpEquations.Avalonia.Demo/Assets/Fonts/STIXTwoMath-Regular.ttf new file mode 100644 index 0000000..d68a5af Binary files /dev/null and b/samples/SharpEquations.Avalonia.Demo/Assets/Fonts/STIXTwoMath-Regular.ttf differ diff --git a/samples/SharpEquations.Avalonia.Demo/DemoFonts.cs b/samples/SharpEquations.Avalonia.Demo/DemoFonts.cs new file mode 100644 index 0000000..a781e62 --- /dev/null +++ b/samples/SharpEquations.Avalonia.Demo/DemoFonts.cs @@ -0,0 +1,30 @@ +namespace SharpEquations.Avalonia.Demo +{ + using System; + using global::Avalonia.Media; + using global::Avalonia.Media.Fonts; + + /// The fonts embedded in the demo: STIX Two Math (SIL Open Font License, Assets/Fonts/OFL.txt). + public static class DemoFonts + { + private const string CollectionKey = "fonts:SharpEquationsDemo"; + private static FontManager? registeredOn; + + public static FontFamily StixTwoMath + { + get + { + var manager = FontManager.Current; + if (!ReferenceEquals(registeredOn, manager)) + { + var assembly = typeof(DemoFonts).Assembly.GetName().Name; + manager.AddFontCollection(new EmbeddedFontCollection( + new Uri(CollectionKey, UriKind.Absolute), + new Uri($"avares://{assembly}/Assets/Fonts", UriKind.Absolute))); + registeredOn = manager; + } + return new FontFamily(CollectionKey + "#STIX Two Math"); + } + } + } +} diff --git a/samples/SharpEquations.Avalonia.Demo/DemoTheme.cs b/samples/SharpEquations.Avalonia.Demo/DemoTheme.cs new file mode 100644 index 0000000..e31072b --- /dev/null +++ b/samples/SharpEquations.Avalonia.Demo/DemoTheme.cs @@ -0,0 +1,23 @@ +namespace SharpEquations.Avalonia.Demo +{ + using global::Avalonia; + using global::Avalonia.Media; + using global::Avalonia.Styling; + using global::Avalonia.Themes.Fluent; + + /// The Fluent theme of the demos, with an indigo accent in the light and the dark variant. + public static class DemoTheme + { + public static void Apply(Application application) + { + application.Styles.Add(new FluentTheme + { + Palettes = + { + [ThemeVariant.Light] = new ColorPaletteResources { Accent = Color.FromRgb(0x4F, 0x46, 0xE5) }, + [ThemeVariant.Dark] = new ColorPaletteResources { Accent = Color.FromRgb(0x81, 0x8C, 0xF8) }, + }, + }); + } + } +} diff --git a/samples/SharpEquations.Avalonia.Demo/DemoView.cs b/samples/SharpEquations.Avalonia.Demo/DemoView.cs new file mode 100644 index 0000000..f592c1d --- /dev/null +++ b/samples/SharpEquations.Avalonia.Demo/DemoView.cs @@ -0,0 +1,645 @@ +namespace SharpEquations.Avalonia.Demo +{ + using System; + using System.Collections.Generic; + using System.Linq; + using global::Avalonia; + using global::Avalonia.Controls; + using global::Avalonia.Controls.Primitives; + using global::Avalonia.Input; + using global::Avalonia.Layout; + using global::Avalonia.Media; + using global::Avalonia.Media.Immutable; + using global::Avalonia.Styling; + using SharpEquations; + using SharpEquations.Editing; + + /// + /// The equation editor with the tools of the equations of Word: structures and symbols to insert, the input format + /// (UnicodeMath or LaTeX), the linear and built up views, the number of the equation, copy in every format, a gallery + /// of examples, and the equation as MathML, UnicodeMath, LaTeX and OMML as it is typed. The desktop window and the + /// WebAssembly page show the same view. + /// + public sealed class DemoView : UserControl + { + private const string RepositoryUrl = "https://github.com/simonebortolin/SharpEquations"; + + private static readonly (string Name, string UnicodeMath)[] Examples = + { + ("Quadratic formula", "x=(-b±√(b^2-4ac))/2a"), + ("Euler's identity", "e^(iπ)+1=0"), + ("Gaussian integral", "∫_-∞^∞ e^(-x^2) ⅆx=√π"), + ("Sum of squares", "∑_(k=1)^n k^2=(n(n+1)(2n+1))/6"), + ("Binomial theorem", "(a+b)^n=∑_(k=0)^n (n¦k) a^k b^(n-k)"), + ("Matrix inverse", "(■(a&b@c&d))^(-1)=1/(ad-bc) (■(d&-b@-c&a))"), + ("Absolute value", "|x|={■(x&x≥0@-x&x<0)┤"), + ("A limit", "lim┬(x→0) sin⁡x/x=1"), + ("Ampère–Maxwell", "∇×𝐁=μ_0 (𝐉+ε_0 ∂𝐄/∂t)"), + ("Fourier transform", "f̂(ξ)=∫_-∞^∞ f(x) e^(-2πixξ) ⅆx"), + ("Numbered", "E=mc^2#(1)"), + ("Aligned lines", "█(f(x)&=(x+1)^2@&=x^2+2x+1)"), + }; + + private readonly MathEditBox editor; + private readonly MathView preview; + private readonly List gallery = new List(); + private readonly (string Name, Func Write, TextBox Box)[] outputs; + private readonly TabControl outputTabs = new TabControl(); + private readonly TextBlock status = new TextBlock { FontSize = 12, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis }; + private readonly ToggleSwitch linear = new ToggleSwitch { OnContent = "Linear", OffContent = "Built up" }; + private readonly TextBox number = new TextBox { PlaceholderText = "(1)", Width = 64 }; + private readonly Grid columns = new Grid(); + private readonly Control outputCard; + private readonly Control examplesCard; + + public DemoView() + { + Resources.ThemeDictionaries[ThemeVariant.Light] = new ResourceDictionary + { + ["PageBrush"] = Solid(0xF3F4FA), + ["CardBrush"] = Solid(0xFFFFFF), + ["CardBorderBrush"] = Solid(0xE3E5F0), + ["MutedBrush"] = Solid(0x6B7280), + ["EditorBrush"] = Solid(0xFCFCFF), + ["CodeBrush"] = Solid(0xF6F7FB), + ["ChipBrush"] = Solid(0xEEF0FB), + ["EditorBorderBrush"] = Solid(0x818CF8), + }; + Resources.ThemeDictionaries[ThemeVariant.Dark] = new ResourceDictionary + { + ["PageBrush"] = Solid(0x0D0F16), + ["CardBrush"] = Solid(0x161923), + ["CardBorderBrush"] = Solid(0x262A38), + ["MutedBrush"] = Solid(0x9AA3B5), + ["EditorBrush"] = Solid(0x10131B), + ["CodeBrush"] = Solid(0x0F121A), + ["ChipBrush"] = Solid(0x1E2230), + ["EditorBorderBrush"] = Solid(0x6366F1), + }; + Styles.Add(new Style(x => x.OfType