diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5a14b17..9d7cf80 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,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 @@ -103,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 @@ -110,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: @@ -150,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 @@ -199,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/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/AGENTS.md b/AGENTS.md index 5825a69..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 @@ -82,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 @@ -94,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 47c6701..82cf4c4 100644 --- a/NOTICE +++ b/NOTICE @@ -300,3 +300,12 @@ 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 86e54f5..dc29be1 100644 --- a/README.md +++ b/README.md @@ -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..4c701fe 100644 --- a/SharpEquations.slnx +++ b/SharpEquations.slnx @@ -1,4 +1,11 @@ + + + + + + + @@ -9,14 +16,18 @@ - + + + + + diff --git a/benchmarks/SharpEquations.Benchmarks/EditorBenchmarks.cs b/benchmarks/SharpEquations.Benchmarks/EditorBenchmarks.cs new file mode 100644 index 0000000..b4cc685 --- /dev/null +++ b/benchmarks/SharpEquations.Benchmarks/EditorBenchmarks.cs @@ -0,0 +1,117 @@ +namespace SharpEquations.Benchmarks +{ + using System; + using System.Collections.Generic; + using System.IO; + using System.Linq; + using BenchmarkDotNet.Attributes; + using SharpEquations.Editing; + using SharpEquations.Rendering; + using SharpEquations.Rendering.OpenType; + + /// + /// A keystroke in the editor on large equations: the edit (with its build up) and the layout of the equation after + /// it, in Libertinus Math read without a toolkit. + /// + [MemoryDiagnoser] + public class EditorBenchmarks + { + private static readonly Dictionary Equations = new() + { + ["matrix10x10"] = "(■(" + string.Join("@", Enumerable.Range(1, 10).Select(r => string.Join("&", Enumerable.Range(1, 10).Select(c => $"a_({r}{c})")))) + "))", + ["lines30"] = "█(" + string.Join("@", Enumerable.Range(1, 30).Select(i => $"x_{i}&=a_{i}+b_{i}^2/(c_{i}+1)")) + ")", + ["polynomial60"] = "f(x)=" + string.Join("+", Enumerable.Range(0, 61).Select(i => $"a_{i} x^{i}")), + }; + + private MathEditor editor = new(); + private BenchmarkFont font = null!; + private MathLayoutOptions options = new(); + private MathLayoutCache cache = new(); + + [Params("matrix10x10", "lines30", "polynomial60")] + public string Equation { get; set; } = ""; + + [GlobalSetup] + public void Setup() + { + font = BenchmarkFont.Load(); + editor = new MathEditor(SharpEquations.Equation.Parse(Equations[Equation], EquationFormat.UnicodeMath, new EquationOptions { Display = true })); + editor.MoveEnd(); + options = new MathLayoutOptions { FontSize = 20, ShowPlaceholders = true }; + cache = new MathLayoutCache(); + MathLayoutEngine.Layout(editor.Root, font, options, cache); + } + + /// A character typed and undone, with the build up of the operator. + [Benchmark] + public void Edit() + { + editor.Insert("+"); + editor.Undo(); + } + + /// The layout of the whole equation, from nothing. + [Benchmark(Baseline = true)] + public MathLayout Layout() => MathLayoutEngine.Layout(editor.Root, font, options); + + /// The layout of the equation again, from the cache: what a move of the caret or a new measure costs. + [Benchmark] + public MathLayout LayoutCached() => MathLayoutEngine.Layout(editor.Root, font, options, cache); + + /// + /// A keystroke as the control does it: the edit and the layout after it with the cache of the layouts before, + /// twice (typed and undone). + /// + [Benchmark] + public MathLayout Keystroke() + { + editor.Insert("+"); + MathLayoutEngine.Layout(editor.Root, font, options, cache); + editor.Undo(); + return MathLayoutEngine.Layout(editor.Root, font, options, cache); + } + + /// A keystroke with the whole equation laid out again after each edit, without a cache. + [Benchmark] + public MathLayout KeystrokeUncached() + { + editor.Insert("+"); + MathLayoutEngine.Layout(editor.Root, font, options); + editor.Undo(); + return MathLayoutEngine.Layout(editor.Root, font, options); + } + } + + /// A math font read from its file, as the layout needs it, without a toolkit. + internal sealed class BenchmarkFont : IMathFont + { + private readonly FontFile file; + private readonly GlyphOutlines? outlines; + private readonly ScriptStyles? scriptStyles; + + private BenchmarkFont(FontFile file) + { + this.file = file; + Math = file.Table("MATH") is { } table ? MathTable.Parse(table) : null; + outlines = GlyphOutlines.Create(file.Table); + scriptStyles = ScriptStyles.Parse(file.Table("GSUB")); + } + + public static BenchmarkFont Load() => + new(FontFile.Parse(File.ReadAllBytes(Path.Combine(AppContext.BaseDirectory, "data", "fonts", "LibertinusMath-Regular.otf")))); + + public double UnitsPerEm => file.UnitsPerEm; + + public MathTable? Math { get; } + + public bool TryGetGlyph(int codePoint, out ushort glyph) => file.TryGetGlyph(codePoint, out glyph); + + public double GetAdvance(ushort glyph) => file.Advance(glyph); + + public GlyphBounds GetBounds(ushort glyph) => outlines?.Bounds(glyph) ?? default; + + public ushort GetScriptVariant(ushort glyph, int scriptLevel) => scriptStyles?.Variant(glyph, scriptLevel) ?? glyph; + + public IMathFont? GetFallback(int codePoint) => null; + } +} diff --git a/benchmarks/SharpEquations.Benchmarks/SharpEquations.Benchmarks.csproj b/benchmarks/SharpEquations.Benchmarks/SharpEquations.Benchmarks.csproj index 04931b3..1cf827f 100644 --- a/benchmarks/SharpEquations.Benchmarks/SharpEquations.Benchmarks.csproj +++ b/benchmarks/SharpEquations.Benchmarks/SharpEquations.Benchmarks.csproj @@ -15,10 +15,13 @@ + + + diff --git a/docs/EDITOR.md b/docs/EDITOR.md new file mode 100644 index 0000000..766a0c6 --- /dev/null +++ b/docs/EDITOR.md @@ -0,0 +1,267 @@ +# 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`). +- **Context menu**: right click, a long press on touch, the Menu key or Shift+F10 open it (under the caret from the + keyboard): undo, redo, cut, copy, paste, *copy as* MathML, UnicodeMath, LaTeX or OMML (the selection, or the whole + equation: `CopyAsAsync`), select all, linear or professional, build up and down, new line, the number of the + equation, and the rows and columns of the table of the caret. A right click outside the selection moves the caret + there first; a read-only editor has only the copy commands and select all. An application changes it in + `ContextMenuOpening` (its items have a tag that names their command), overrides `CreateContextMenu`, or turns it off + with `IsContextMenuEnabled`. +- **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{|}{}`). +- **Math AutoCorrect** (`MathAutoCorrect`; `AutoCorrect` and `IsAutoCorrectEnabled` of `MathEditBox`): as in Word, + `\alpha␣` becomes α (the commands of `EquationCommands` with a symbol) and `->`, `<=`, `!=`, `+-`, `...` become + → ≤ ≠ ± … as they are typed; `Entries` is the list the application changes (`\R` → ℝ). An operator that starts a + sequence waits for the next character to build up; in LaTeX only the backslash shortcuts apply; Ctrl+Z right after + a replacement gives back what was typed. +- **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). +- **Cache** (`MathLayoutCache`): the boxes of the nodes laid out, by node (by reference: the trees are immutable and an + edit shares what it does not change) and by style and stretch, with the paths of their rows relative to the node, so + that a box serves wherever its node moves; and the advances, ink boxes and glyphs of the font, asked of it once. After + a keystroke only the nodes the edit replaced are laid out again. `MathEditBox` and `MathView` keep one each; + `MathEditBox` lays out again only when the tree changed, and draws its caret in a layer of its own, so that a move of + the caret or its blinking draws nothing else again. + +## 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