diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 841832ed..fff200de 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -4,4 +4,16 @@ Third-party assets bundled in this repository (fonts, icons, images, test data), author, source and license. Library code and original assets are under the repository license (MIT OR Apache-2.0, see [NOTICE](NOTICE)). -None yet. +| Asset | Author | Source | License | Notes | +|---|---|---|---|---| +| `assets/fonts/SourceSerif4-Regular.ttf` | Frank Grießhammer, Adobe | https://github.com/adobe-fonts/source-serif | OFL-1.1 (`assets/fonts/OFL-SourceSerif4.txt`) | Default equation family (`equashion-font`) | +| `assets/fonts/SourceSerif4-It.ttf` | Frank Grießhammer, Adobe | https://github.com/adobe-fonts/source-serif | OFL-1.1 (`assets/fonts/OFL-SourceSerif4.txt`) | Math italic (`equashion-font`) | +| `assets/fonts/SourceSerif4-Bold.ttf` | Frank Grießhammer, Adobe | https://github.com/adobe-fonts/source-serif | OFL-1.1 (`assets/fonts/OFL-SourceSerif4.txt`) | Bold math (`equashion-font`) | +| `assets/fonts/SourceSerif4-BoldIt.ttf` | Frank Grießhammer, Adobe | https://github.com/adobe-fonts/source-serif | OFL-1.1 (`assets/fonts/OFL-SourceSerif4.txt`) | Bold italic math (`equashion-font`) | +| `assets/fonts/Inter-Regular.ttf` | Rasmus Andersson / The Inter Project Authors | https://github.com/rsms/inter | OFL-1.1 (`assets/fonts/OFL-Inter.txt`) | Second bundled family; the layout unit tests use it (`equashion-font`) | +| `assets/fonts/STIXTwoMath-Regular.otf` | The STIX Fonts Project Authors | https://github.com/stipub/stixfonts (copied unmodified from Fedora's `stix-fonts-2.13b171` package) | OFL-1.1 (`assets/fonts/OFL-STIXTwoMath.txt`) | Math symbol fallback; not subset (838 KB) (`equashion-font`) | +| `assets/fonts/OFL-*.txt` | (license texts) | upstream projects | | | + +The OMML samples in `corpus/omml/` are copied from the WordCraft and DeckCraft test suites +(MIT OR Apache-2.0, like the code). The typed equations in `corpus/equations.tsv` and the generated +files in `tests/golden/` are original to equaSHion. diff --git a/Cargo.lock b/Cargo.lock index 97fa16be..3fe66acf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -34,6 +34,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", + "getrandom 0.3.4", "once_cell", "version_check", "zerocopy", @@ -111,6 +112,12 @@ version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + [[package]] name = "ash" version = "0.38.0+1.3.281" @@ -247,6 +254,18 @@ dependencies = [ "tracing", ] +[[package]] +name = "calloop-wayland-source" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" +dependencies = [ + "calloop 0.13.0", + "rustix 0.38.44", + "wayland-backend", + "wayland-client", +] + [[package]] name = "calloop-wayland-source" version = "0.4.1" @@ -283,6 +302,15 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" +[[package]] +name = "cgl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ced0551234e87afee12411d535648dd89d2e7f34c78b753395567aff3d447ff" +dependencies = [ + "libc", +] + [[package]] name = "clipboard-win" version = "5.4.1" @@ -492,6 +520,10 @@ dependencies = [ "egui", "egui-wgpu", "egui-winit", + "egui_glow", + "glow", + "glutin", + "glutin-winit", "image", "js-sys", "log", @@ -587,6 +619,20 @@ dependencies = [ "resvg", ] +[[package]] +name = "egui_glow" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "606ed4167daf341e5753d703285c4c1d562324b2c07aa8f405cd7d066fe58f3b" +dependencies = [ + "bytemuck", + "egui", + "glow", + "log", + "profiling", + "winit", +] + [[package]] name = "egui_kittest" version = "0.36.2" @@ -652,17 +698,17 @@ dependencies = [ "emath", "epaint_default_fonts", "font-types", - "harfrust", + "harfrust 0.12.0", "log", "nohash-hasher", "parking_lot", "profiling", "self_cell", - "skrifa", + "skrifa 0.44.0", "smallvec", "unicode-general-category", "unicode-segmentation", - "vello_cpu", + "vello_cpu 0.1.0", ] [[package]] @@ -671,6 +717,57 @@ version = "0.36.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773fa9c96dd0dbef887e39d0ed6177f141cce4d68e6041df77570aa3702dfa13" +[[package]] +name = "equashion-egui" +version = "0.1.0" +dependencies = [ + "eframe", + "egui", + "egui_kittest", + "equashion-layout", + "equashion-math", + "equashion-render", +] + +[[package]] +name = "equashion-font" +version = "0.1.0" +dependencies = [ + "harfrust 0.13.3", + "kurbo 0.13.1", + "log", + "skrifa 0.47.0", + "unicode-bidi", +] + +[[package]] +name = "equashion-layout" +version = "0.1.0" +dependencies = [ + "equashion-font", + "equashion-math", + "kurbo 0.13.1", +] + +[[package]] +name = "equashion-math" +version = "0.1.0" +dependencies = [ + "quick-xml 0.38.4", + "serde", + "serde_json", +] + +[[package]] +name = "equashion-render" +version = "0.1.0" +dependencies = [ + "equashion-layout", + "equashion-math", + "kurbo 0.13.1", + "vello_cpu 0.2.0", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -828,6 +925,18 @@ dependencies = [ "windows-link", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + [[package]] name = "getrandom" version = "0.4.3" @@ -836,7 +945,7 @@ checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", ] [[package]] @@ -861,9 +970,26 @@ dependencies = [ "hashbrown 0.17.1", "log", "peniko", - "skrifa", + "skrifa 0.44.0", "smallvec", - "vello_common", + "vello_common 0.1.0", +] + +[[package]] +name = "glifo" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282a26c1e23de04bdab3e34a21b6f877a479b96737792516b9b8b8f69b6661be" +dependencies = [ + "bytemuck", + "foldhash", + "hashbrown 0.17.1", + "log", + "peniko", + "png 0.18.1", + "skrifa 0.44.0", + "smallvec", + "vello_common 0.2.0", ] [[package]] @@ -878,6 +1004,63 @@ dependencies = [ "web-sys", ] +[[package]] +name = "glutin" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12124de845cacfebedff80e877bb37b5b75c34c5a4c89e47e1cdd67fb6041325" +dependencies = [ + "bitflags 2.13.2", + "cfg_aliases", + "cgl", + "dispatch2", + "glutin_egl_sys", + "glutin_glx_sys", + "glutin_wgl_sys", + "libloading", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "once_cell", + "raw-window-handle", + "wayland-sys", + "windows-sys 0.52.0", + "x11-dl", +] + +[[package]] +name = "glutin-winit" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85edca7075f8fc728f28cb8fbb111a96c3b89e930574369e3e9c27eb75d3788f" +dependencies = [ + "cfg_aliases", + "glutin", + "raw-window-handle", + "winit", +] + +[[package]] +name = "glutin_egl_sys" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c4680ba6195f424febdc3ba46e7a42a0e58743f2edb115297b86d7f8ecc02d2" +dependencies = [ + "gl_generator", + "windows-sys 0.52.0", +] + +[[package]] +name = "glutin_glx_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7bb2938045a88b612499fbcba375a77198e01306f52272e692f8c1f3751185" +dependencies = [ + "gl_generator", + "x11-dl", +] + [[package]] name = "glutin_wgl_sys" version = "0.6.1" @@ -930,7 +1113,20 @@ checksum = "c03d949a14aa089bbb282f7dd76a498a7f684428e4257202efc119ec010376f9" dependencies = [ "bitflags 2.13.2", "bytemuck", - "read-fonts", + "read-fonts 0.41.0", + "smallvec", +] + +[[package]] +name = "harfrust" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "948d0741125ba89cd3e1c23e5642415b6ade7e1d29d67ba25fb925b533e989d6" +dependencies = [ + "bitflags 2.13.2", + "bytemuck", + "once_cell", + "read-fonts 0.43.3", "smallvec", ] @@ -1069,7 +1265,7 @@ version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" dependencies = [ - "getrandom", + "getrandom 0.4.3", "libc", ] @@ -1131,6 +1327,7 @@ dependencies = [ "arrayvec", "euclid", "polycool", + "serde", "smallvec", ] @@ -1407,6 +1604,7 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ "bitflags 2.13.2", "objc2 0.6.5", + "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.2", ] @@ -1890,6 +2088,15 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" +[[package]] +name = "quick-xml" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +dependencies = [ + "memchr", +] + [[package]] name = "quick-xml" version = "0.41.0" @@ -1908,6 +2115,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" @@ -1949,6 +2162,28 @@ dependencies = [ "once_cell", ] +[[package]] +name = "read-fonts" +version = "0.43.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "005c8acf251756c478b0bf402885bfd88a1476020c4c7e6060edc1aa68da38ea" +dependencies = [ + "bytemuck", + "font-types", + "once_cell", +] + +[[package]] +name = "read-fonts" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5a0cf4bb6cbce4c29756270d189a5e400ac05e91192161d83a0bcb6e68492e8" +dependencies = [ + "bytemuck", + "font-types", + "once_cell", +] + [[package]] name = "redox_syscall" version = "0.4.1" @@ -2199,7 +2434,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68" dependencies = [ "bytemuck", - "read-fonts", + "read-fonts 0.41.0", +] + +[[package]] +name = "skrifa" +version = "0.47.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "526e654b303ff4ad73a5a40b25de2b3ce3d039c5e3b6da5f89ae6e57004cb40f" +dependencies = [ + "bytemuck", + "read-fonts 0.44.0", ] [[package]] @@ -2222,6 +2467,34 @@ name = "smallvec" version = "1.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" +dependencies = [ + "serde", +] + +[[package]] +name = "smithay-client-toolkit" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" +dependencies = [ + "bitflags 2.13.2", + "calloop 0.13.0", + "calloop-wayland-source 0.3.0", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 0.38.44", + "thiserror 1.0.69", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] [[package]] name = "smithay-client-toolkit" @@ -2231,7 +2504,7 @@ checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" dependencies = [ "bitflags 2.13.2", "calloop 0.14.5", - "calloop-wayland-source", + "calloop-wayland-source 0.4.1", "cursor-icon", "libc", "log", @@ -2257,7 +2530,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226" dependencies = [ "libc", - "smithay-client-toolkit", + "smithay-client-toolkit 0.20.0", "wayland-backend", ] @@ -2333,7 +2606,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom", + "getrandom 0.4.3", "once_cell", "rustix 1.1.5", "windows-sys 0.61.2", @@ -2506,6 +2779,12 @@ dependencies = [ "rustc-hash 2.1.3", ] +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + [[package]] name = "unicode-general-category" version = "1.1.0" @@ -2573,6 +2852,22 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "vello_common" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb2141a2bca6e6d598e471fd4d1d7eed8e020aad6a28187edda07f091a325dd" +dependencies = [ + "bytemuck", + "fearless_simd", + "guillotiere", + "log", + "peniko", + "png 0.18.1", + "smallvec", + "thiserror 2.0.21", +] + [[package]] name = "vello_cpu" version = "0.1.0" @@ -2580,9 +2875,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac7349e1f55f6b801c7c277958df4ea53e7f20f21e8014910ad888b2ecda93ea" dependencies = [ "bytemuck", - "glifo", + "glifo 0.2.0", "hashbrown 0.17.1", - "vello_common", + "vello_common 0.1.0", +] + +[[package]] +name = "vello_cpu" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ace506ca414548966fcf3c283be94eb1cad8254de488791ee698bf438b0890a9" +dependencies = [ + "bytemuck", + "glifo 0.3.0", + "hashbrown 0.17.1", + "png 0.18.1", + "vello_common 0.2.0", ] [[package]] @@ -2601,6 +2909,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.129" @@ -2743,6 +3060,19 @@ dependencies = [ "wayland-scanner", ] +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + [[package]] name = "wayland-protocols-wlr" version = "0.3.12" @@ -2763,7 +3093,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" dependencies = [ "proc-macro2", - "quick-xml", + "quick-xml 0.41.0", "quote", ] @@ -3278,10 +3608,12 @@ version = "0.30.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" dependencies = [ + "ahash", "android-activity", "atomic-waker", "bitflags 2.13.2", "block2 0.5.1", + "bytemuck", "calloop 0.13.0", "cfg_aliases", "concurrent-queue", @@ -3291,24 +3623,33 @@ dependencies = [ "dpi", "js-sys", "libc", + "memmap2", "ndk", "objc2 0.5.3", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", "objc2-ui-kit 0.2.2", "orbclient", + "percent-encoding", "pin-project", "raw-window-handle", "redox_syscall 0.4.1", "rustix 0.38.44", + "smithay-client-toolkit 0.19.2", "smol_str", "tracing", "unicode-segmentation", "wasm-bindgen", "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-plasma", "web-sys", "web-time", "windows-sys 0.52.0", + "x11-dl", + "x11rb", "xkbcommon-dl", ] @@ -3321,13 +3662,34 @@ dependencies = [ "memchr", ] +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + [[package]] name = "x11rb" version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" dependencies = [ + "as-raw-xcb-connection", "gethostname", + "libc", + "libloading", + "once_cell", "rustix 1.1.5", "x11rb-protocol", ] diff --git a/Cargo.toml b/Cargo.toml index 6880b4f8..7c883caf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,8 +13,26 @@ repository = "https://github.com/storytold/craft-libs" # internal: add each crate here as `craft- = { path = "crates/" }` craft-print = { path = "crates/print" } craft-ui = { path = "crates/ui" } +equashion-math = { path = "crates/equashion-math" } +equashion-font = { path = "crates/equashion-font", default-features = false } +equashion-layout = { path = "crates/equashion-layout", default-features = false } +equashion-render = { path = "crates/equashion-render", default-features = false } +equashion-egui = { path = "crates/equashion-egui" } egui = { version = "0.36", default-features = false } +# equaSHion (the equation editor) +kurbo = { version = "0.13", features = ["serde"] } +serde = { version = "1", features = ["derive", "rc"] } +serde_json = { version = "1", features = ["float_roundtrip"] } +log = "0.4" +unicode-bidi = "0.3" +skrifa = "0.47" +harfrust = "0.13" +vello_cpu = "0.2" +quick-xml = "0.38" +eframe = { version = "0.36", default-features = false } +egui_kittest = "0.36" + [workspace.lints.rust] unsafe_code = "forbid" diff --git a/README.md b/README.md index d5b69650..1f64ae97 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ If any libraries grow beyond Craft App usage, eg. RAW format support, they may b ## Layout A Cargo workspace (edition 2024, resolver 3). Each library is a crate under `crates/`, named -`craft-`, with `version`, `edition`, `license` and `repository` inherited from the workspace +`craft-` (the equaSHion crates keep their own `equashion-*` names), with `version`, `edition`, `license` and `repository` inherited from the workspace (`license.workspace = true`, and so on) and `[lints] workspace = true`. Register each crate in `[workspace.dependencies]` in the root `Cargo.toml`. @@ -28,6 +28,7 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = "ab diff --git a/corpus/omml/deckcraft_pptx_roundtrip_frac_rpr.xml b/corpus/omml/deckcraft_pptx_roundtrip_frac_rpr.xml new file mode 100644 index 00000000..ca4636ab --- /dev/null +++ b/corpus/omml/deckcraft_pptx_roundtrip_frac_rpr.xml @@ -0,0 +1 @@ +x2 diff --git a/corpus/omml/wordcraft_docx_fixture_ssup.xml b/corpus/omml/wordcraft_docx_fixture_ssup.xml new file mode 100644 index 00000000..de1ea3b3 --- /dev/null +++ b/corpus/omml/wordcraft_docx_fixture_ssup.xml @@ -0,0 +1 @@ +x2 diff --git a/corpus/omml/wordcraft_docx_roundtrip_display_rad.xml b/corpus/omml/wordcraft_docx_roundtrip_display_rad.xml new file mode 100644 index 00000000..38a71f37 --- /dev/null +++ b/corpus/omml/wordcraft_docx_roundtrip_display_rad.xml @@ -0,0 +1 @@ +x diff --git a/corpus/omml/wordcraft_docx_roundtrip_foreign_attrs.xml b/corpus/omml/wordcraft_docx_roundtrip_foreign_attrs.xml new file mode 100644 index 00000000..270088e0 --- /dev/null +++ b/corpus/omml/wordcraft_docx_roundtrip_foreign_attrs.xml @@ -0,0 +1 @@ +x diff --git a/corpus/omml/wordcraft_docx_roundtrip_frac.xml b/corpus/omml/wordcraft_docx_roundtrip_frac.xml new file mode 100644 index 00000000..e13045b5 --- /dev/null +++ b/corpus/omml/wordcraft_docx_roundtrip_frac.xml @@ -0,0 +1 @@ +ab+1 diff --git a/crates/equashion-egui/Cargo.toml b/crates/equashion-egui/Cargo.toml new file mode 100644 index 00000000..5d578c57 --- /dev/null +++ b/crates/equashion-egui/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "equashion-egui" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "equaSHion egui widget: the equation editor (canvas, palette, linear field) with no host types" + +[lints.rust] +unsafe_code = "forbid" + +[lints.clippy] +dbg_macro = "warn" +todo = "warn" +too_many_arguments = "allow" +type_complexity = "allow" +unwrap_used = "deny" +expect_used = "deny" +panic = "deny" +unimplemented = "deny" +unreachable = "deny" + +[dependencies] +equashion-math = { workspace = true } +equashion-layout = { workspace = true, features = ["extra-fonts"] } +equashion-render = { workspace = true, features = ["raster", "extra-fonts"] } +egui = { workspace = true } + +[dev-dependencies] +egui_kittest = { workspace = true } +# The native example only (glow keeps the build small; no wgpu). +eframe = { workspace = true, features = ["default_fonts", "glow", "x11", "wayland"] } + +[[example]] +name = "widget" diff --git a/crates/equashion-egui/examples/widget.rs b/crates/equashion-egui/examples/widget.rs new file mode 100644 index 00000000..05271dce --- /dev/null +++ b/crates/equashion-egui/examples/widget.rs @@ -0,0 +1,85 @@ +//! The equation editor widget in a native eframe window, with live linear and OMML readouts. +//! +//! `cargo run -p equashion-egui --example widget` (add `-- --light` for the light theme). +//! `EQUASHION_SCREENSHOT=` saves one frame and exits (for headless checks under xvfb). + +use equashion_egui::EquationEditor; + +struct Demo { + editor: EquationEditor, + last: String, + screenshot: Option, + frames: u32, +} + +impl eframe::App for Demo { + fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { + egui::CentralPanel::default().show(ui, |ui| { + ui.heading("equaSHion"); + let r = self.editor.show(ui); + if r.committed { + self.last = format!("committed: {}", self.editor.linear()); + } + if r.cancelled { + self.editor.revert(); + self.last = "cancelled (reverted)".into(); + } + ui.separator(); + ui.label(egui::RichText::new("Linear").strong()); + let mut lin = self.editor.linear(); + ui.add(egui::TextEdit::singleline(&mut lin).interactive(false).desired_width(f32::INFINITY).font(egui::FontId::monospace(13.0))); + ui.label(egui::RichText::new("OMML").strong()); + let mut omml = self.editor.omml(); + egui::ScrollArea::vertical().max_height(160.0).show(ui, |ui| { + ui.add(egui::TextEdit::multiline(&mut omml).interactive(false).desired_width(f32::INFINITY).font(egui::FontId::monospace(12.0))); + }); + if !self.last.is_empty() { + ui.label(&self.last); + } + }); + if let Some(path) = &self.screenshot { + self.frames += 1; + if self.frames == 5 { + ui.ctx().send_viewport_cmd(egui::ViewportCommand::Screenshot(Default::default())); + } + let shot = ui + .ctx() + .input(|i| i.events.iter().find_map(|e| if let egui::Event::Screenshot { image, .. } = e { Some(image.clone()) } else { None })); + if let Some(img) = shot { + let ok = write_ppm(path, &img); + eprintln!("widget: screenshot {} -> {}", if ok { "saved" } else { "FAILED" }, path.display()); + ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close); + } + ui.ctx().request_repaint(); + } + } +} + +/// A binary PPM (no image crate here); `.png` paths get `.ppm` appended. +fn write_ppm(path: &std::path::Path, img: &egui::ColorImage) -> bool { + let mut out = format!("P6\n{} {}\n255\n", img.size[0], img.size[1]).into_bytes(); + for p in &img.pixels { + out.extend_from_slice(&[p.r(), p.g(), p.b()]); + } + std::fs::write(path, out).is_ok() +} + +fn main() -> eframe::Result { + let light = std::env::args().any(|a| a == "--light"); + let screenshot = std::env::var_os("EQUASHION_SCREENSHOT").map(std::path::PathBuf::from); + let options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default().with_inner_size([760.0, 720.0]), ..Default::default() }; + eframe::run_native( + "equaSHion widget", + options, + Box::new(move |cc| { + // The host, not the widget, decides the theme. + cc.egui_ctx.set_theme(if light { egui::Theme::Light } else { egui::Theme::Dark }); + Ok(Box::new(Demo { + editor: EquationEditor::with_linear(r"x=\frac{-b\pm\sqrt{b^2-4ac}}{2a}"), + last: String::new(), + screenshot, + frames: 0, + })) + }), + ) +} diff --git a/crates/equashion-egui/src/gallery.rs b/crates/equashion-egui/src/gallery.rs new file mode 100644 index 00000000..caf8edab --- /dev/null +++ b/crates/equashion-egui/src/gallery.rs @@ -0,0 +1,169 @@ +//! The Equation tab's galleries for a host's ribbon: structure galleries, symbol sets and +//! ready-made equations, drawn with the same bundled-font faces as the palette and inserted +//! into this editor. The host owns the buttons and menus; it calls these inside them: +//! +//! ```ignore +//! ui.menu_button("Fraction", |ui| { +//! if editor.gallery_ui(ui, "fraction") { ui.close(); } +//! }); +//! ``` +//! +//! Data: [`equashion_math::galleries`]. + +use egui::{Ui, vec2}; +use equashion_math::{Editor, Math, Node, galleries}; + +use crate::{EquationEditor, palette}; + +/// Faces per row in a gallery menu. +const COLUMNS_W: f32 = 340.0; + +impl EquationEditor { + /// A rendered face of `math` at `pt` points: its texture and size in points (cached per + /// `key` and pixel density). The face is white on transparent; paint it with the colour you + /// want as the tint. For hosts that draw their own tiles and buttons. + pub fn face(&mut self, ui: &Ui, key: &str, math: &Math, pt: f64) -> Option<(egui::TextureId, egui::Vec2)> { + let sym = self.symbol(ui, key, math, pt)?; + Some((sym.texture_id(), sym.size())) + } + + /// Insert a gallery template at the caret (a script or fraction takes the term before it) + /// and keep typing in the canvas. + pub fn insert_gallery_template(&mut self, ui: &Ui, template: &Math) { + self.palette_insert(ui, Some(template.clone()), None); + } + + /// Insert a symbol at the caret and keep typing in the canvas. + pub fn insert_gallery_symbol(&mut self, ui: &Ui, c: char) { + self.palette_insert(ui, None, Some(c)); + } + + /// Replace the whole equation (a ready-made one) and keep typing in the canvas. Unlike + /// [`EquationEditor::set_math`] this is an edit: Esc would discard it. + pub fn replace_equation(&mut self, ui: &Ui, m: Math) { + let (para, jc) = (self.ed.math().para, self.ed.math().jc.clone()); + let mut m = m; + m.para = para; + m.jc = jc; + self.ed.set_math(m); + self.wants_canvas = true; + let id = self.canvas_id(); + ui.memory_mut(|mem| mem.request_focus(id)); + self.tree_edited(); + self.touched = ui.input(|i| i.time); + } + + /// The equation is a display equation (on a line of its own). + pub fn is_display(&self) -> bool { + self.ed.math().para + } + + /// Show the equation on a line of its own (`true`) or in the line of text. + pub fn set_display(&mut self, display: bool) { + if self.ed.math().para != display { + self.ed.math_mut().para = display; + self.tree_edited(); + } + } + + /// The display equation's justification (`left`, `right`, `center`, `centerGroup`). + pub fn justification(&self) -> Option<&str> { + self.ed.math().jc.as_deref() + } + + /// Justify a display equation; `None` is the default (centred as group). + pub fn set_justification(&mut self, jc: Option<&str>) { + let jc = jc.map(str::to_string); + if self.ed.math().jc != jc { + self.ed.math_mut().jc = jc; + self.tree_edited(); + } + } + + /// A gallery's representative face (its first template, empty places as boxes) at `pt` + /// points, as a texture and its size in points, for a host that draws its own button. + /// Paint it with the button's text colour as the tint. + pub fn gallery_face(&mut self, ui: &Ui, gallery_id: &str, pt: f64) -> Option<(egui::TextureId, egui::Vec2)> { + let t = galleries().structure(gallery_id)?.sections.first()?.templates.first()?; + let face = Editor::new(t.math.clone()).display_math(); + let sym = self.symbol(ui, &format!("face/{gallery_id}"), &face, pt)?; + Some((sym.texture_id(), sym.size())) + } + + /// One structure gallery (`"fraction"`, `"script"`, `"radical"`, `"integral"`, + /// `"largeOperator"`, `"bracket"`, `"function"`, `"accent"`, `"limitLog"`, `"operator"`, + /// `"matrix"`): its sections, each a heading over a wrapped run of template faces. Inserts + /// the chosen template at the caret; true when one was. + pub fn gallery_ui(&mut self, ui: &mut Ui, gallery_id: &str) -> bool { + let Some(g) = galleries().structure(gallery_id) else { return false }; + let mut chosen: Option = None; + ui.set_max_width(COLUMNS_W); + for s in &g.sections { + ui.label(egui::RichText::new(&s.name).size(11.0).strong().color(ui.visuals().weak_text_color())); + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = vec2(3.0, 3.0); + for t in &s.templates { + // The template with its empty places shown as boxes. + let face = Editor::new(t.math.clone()).display_math(); + let Some(sym) = self.symbol(ui, &format!("gallery/{}", t.id), &face, 14.0) else { continue }; + let hover = format!("{} ({})", t.label, t.linear); + if palette::button(ui, sym, vec2(34.0, 30.0), 24.0, &t.label, &hover).clicked() { + chosen = Some(t.math.clone()); + } + } + }); + ui.add_space(4.0); + } + let hit = chosen.is_some(); + self.palette_insert(ui, chosen, None); + hit + } + + /// The names of the symbol sets, in order (index them with [`EquationEditor::symbols_ui`]). + pub fn symbol_set_names() -> Vec<&'static str> { + galleries().symbols.iter().map(|s| s.name.as_str()).collect() + } + + /// One symbol set as a wrapped run of faces. Inserts the chosen symbol at the caret; true + /// when one was. + pub fn symbols_ui(&mut self, ui: &mut Ui, set: usize) -> bool { + let Some(s) = galleries().symbols.get(set) else { return false }; + let mut chosen: Option = None; + ui.set_max_width(COLUMNS_W); + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = vec2(3.0, 3.0); + for c in s.chars.chars() { + let text = c.to_string(); + let face = Math::new(vec![Node::Text(text.clone())]); + let Some(sym) = self.symbol(ui, &format!("sym-{}", c as u32), &face, 15.0) else { continue }; + let hover = format!("U+{:04X}", c as u32); + if palette::button(ui, sym, vec2(28.0, 28.0), 22.0, &text, &hover).clicked() { + chosen = Some(c); + } + } + }); + let hit = chosen.is_some(); + self.palette_insert(ui, None, chosen); + hit + } + + /// The ready-made equations as a list (face and description). Replaces the equation with + /// the chosen one; true when one was. + pub fn builtins_ui(&mut self, ui: &mut Ui) -> bool { + let mut chosen: Option = None; + for t in &galleries().builtins { + let Some(sym) = self.symbol(ui, &format!("builtin/{}", t.id), &t.math, 15.0) else { continue }; + if palette::button(ui, sym, vec2(220.0, 34.0), 30.0, &t.label, &t.label).clicked() { + chosen = Some(t.math.clone()); + } + } + let hit = chosen.is_some(); + if let Some(m) = chosen { + self.set_math(m); + self.wants_canvas = true; + let id = self.canvas_id(); + ui.memory_mut(|mem| mem.request_focus(id)); + } + hit + } +} diff --git a/crates/equashion-egui/src/lib.rs b/crates/equashion-egui/src/lib.rs new file mode 100644 index 00000000..343ad882 --- /dev/null +++ b/crates/equashion-egui/src/lib.rs @@ -0,0 +1,800 @@ +//! equaSHion egui widget: an equation editor you type the formatted equation straight into. +//! +//! [`EquationEditor`] draws a template palette, a canvas with the laid-out equation and a caret +//! inside it, and a linear-text field. Typing `x`, `^`, `2` raises the 2 at once and leaves the +//! caret in the exponent; `/` makes a fraction, `sqrt` a root, Greek names become symbols, the +//! arrow keys and Tab walk the tree, a click places the caret and a drag or shift-click selects. +//! Ctrl+Z / Ctrl+Shift+Z / Ctrl+Y undo and redo, Ctrl+C / Ctrl+X copy and cut the selection as +//! linear text, Ctrl+A selects all, Enter commits and Esc cancels (asking first when there are +//! unsaved changes). The editing itself is `equashion_math::Editor`; this crate only draws it +//! and turns egui input into editor calls. +//! +//! The widget is safe on an `egui::Context` that other code also uses: +//! - it never calls `Context::set_fonts`, `set_visuals` or `set_style`, and only uses +//! `FontId::proportional` / `FontId::monospace` (always bound) for its own text; +//! - equation glyphs, including every palette symbol, are rasterised by `equashion-render` from +//! the bundled fonts, so nothing depends on the host's egui fonts having math operators; +//! - its colours come from `ui.visuals()`, so it follows a light or dark host; +//! - its egui ids derive from the widget's own [`egui::Id`] and its texture names start with +//! `equashion/`; +//! - it only reads keyboard input while its canvas has focus, and only takes focus back when +//! no other widget holds it. +//! +//! ```no_run +//! # fn ui(ui: &mut egui::Ui, editor: &mut equashion_egui::EquationEditor) { +//! let r = editor.show(ui); +//! if r.committed { +//! println!("{}", editor.omml()); +//! } +//! # } +//! ``` +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#![forbid(unsafe_code)] + +mod gallery; +mod palette; + +use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, OnceLock}; + +use egui::{FontId, Key, Pos2, Sense, TextureHandle, TextureOptions, Ui, pos2, vec2}; +use equashion_layout::{DEFAULT_FAMILY, EqLayout, FontDb, Rgb, layout_equation}; +use equashion_math::{Caret, Editor, Key as MKey, Math, from_linear, from_omml, to_linear, to_omml}; + +pub use equashion_math; + +/// Font size of the equation on the canvas, points. +pub const DEFAULT_FONT_SIZE: f64 = 28.0; +/// Default height of the canvas, points. +pub const DEFAULT_CANVAS_HEIGHT: f32 = 120.0; +/// Space around the equation inside the canvas layout, points. +const EQ_PAD: f64 = 8.0; +/// Largest render scale of the canvas texture (pixels per point). +const MAX_SCALE: f64 = 4.0; +/// Largest side of the canvas texture, pixels (egui's guaranteed minimum is 2048). +const MAX_TEXTURE: f64 = 2000.0; +/// Gap between the canvas edge and the equation, points. +const VIEW_MARGIN: f32 = 10.0; +/// Smallest scale a long equation is shrunk to before the canvas scrolls instead. +const MIN_SCALE: f32 = 0.5; +/// Seconds per half blink of the caret. +const BLINK: f64 = 0.5; +/// The message shown after a first Esc with unsaved changes. +pub const DISCARD_PROMPT: &str = "Press Esc again to discard this equation, or Enter to keep it."; + +/// The bundled fonts (Source Serif 4, Inter and the math fallback), loaded once per process. +pub fn bundled_fonts() -> Arc { + static DB: OnceLock> = OnceLock::new(); + DB.get_or_init(|| Arc::new(FontDb::bundled_only())).clone() +} + +/// What happened in one [`EquationEditor::show`] call. +#[derive(Debug)] +pub struct EditorResponse { + /// The equation (tree or display flag) changed this frame. + pub changed: bool, + /// Enter was pressed in the canvas or the linear field: the host should keep the equation. + pub committed: bool, + /// Esc was pressed and confirmed (at once when nothing changed since the last load or + /// commit, otherwise on the second press): the host should drop the edit. The widget + /// keeps its content; call [`EquationEditor::revert`] to go back to the loaded equation. + pub cancelled: bool, + /// The canvas: focus, hover and the rectangle typing goes to. + pub response: egui::Response, + /// The whole widget (palette, canvas, linear field, message line). + pub rect: egui::Rect, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Tab { + Structures, + Greek, + Operators, +} + +/// The equation as drawn: the tree it was drawn from, its layout and the texture. +struct Canvas { + shown: Math, + layout: EqLayout, + tex: TextureHandle, + /// The render scale asked for (pixels per point; the texture may be smaller for size). + want: f64, +} + +/// The equation editor widget. Keep one per edited equation across frames and call +/// [`EquationEditor::show`] every frame. +pub struct EquationEditor { + id: egui::Id, + fonts: Arc, + family: String, + font_size: f64, + canvas_height: f32, + show_palette: bool, + show_linear: bool, + /// Just the equation: no palette, linear field or message line, no background, sized to the + /// equation, for editing in place on the host's page. + inline: bool, + /// Lay the equation out in display style (large operators, limits above and below) or text + /// style, as the host's own drawing of it is. Default display, as a dialog shows it. + display_style: bool, + /// With `inline`: the host draws the equation itself (it is the document's own); the widget + /// draws only the caret, selection and active place over it. + overlay: bool, + /// The idle caret blinks (off for hosts that honour reduced motion). + caret_blink: bool, + ed: Editor, + /// Text of the linear field. + linear: String, + /// The equation as last loaded or committed, to tell whether Esc would lose work. + initial: Math, + tab: Tab, + /// Status line (the discard prompt). + message: Option, + canvas: Option, + palette: HashMap, + /// Frames left in which the canvas asks for the keyboard. + auto_focus: u8, + /// Typing goes to the canvas: it takes the keyboard back while no widget holds it, so a + /// click on a palette button does not leave typing dead. + wants_canvas: bool, + /// The canvas or the linear field had the keyboard at the end of the last frame. + focus_within: bool, + /// Time of the last edit, so the caret stays solid while typing. + touched: f64, + /// True while the mouse button that started in the canvas is held. + dragging: bool, + /// Where the mouse press (or the shift-click's fixed end) is: selections run from here. + origin: Caret, + /// Esc was pressed once with unsaved work: the next one cancels. + discard_armed: bool, + /// Where the canvas was drawn last frame: top-left of the equation and points per layout point. + view: (Pos2, f32), +} + +impl std::fmt::Debug for EquationEditor { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("EquationEditor").field("id", &self.id).field("linear", &self.linear).field("caret", self.ed.caret()).finish_non_exhaustive() + } +} + +impl Default for EquationEditor { + fn default() -> Self { + Self::new() + } +} + +impl EquationEditor { + /// A blank editor using the bundled fonts. Its [`egui::Id`] is unique to this instance; + /// use [`EquationEditor::id_salt`] for a stable one. + pub fn new() -> Self { + static NEXT: AtomicU64 = AtomicU64::new(0); + let n = NEXT.fetch_add(1, Ordering::Relaxed); + EquationEditor { + id: egui::Id::new(("equashion-editor", n)), + fonts: bundled_fonts(), + family: DEFAULT_FAMILY.to_string(), + font_size: DEFAULT_FONT_SIZE, + canvas_height: DEFAULT_CANVAS_HEIGHT, + show_palette: true, + show_linear: true, + inline: false, + overlay: false, + display_style: true, + caret_blink: true, + ed: Editor::new(Math::default()), + linear: String::new(), + initial: Math::default(), + tab: Tab::Structures, + message: None, + canvas: None, + palette: HashMap::new(), + auto_focus: 4, + wants_canvas: true, + focus_within: false, + touched: 0.0, + dragging: false, + origin: Caret::default(), + discard_armed: false, + view: (pos2(0.0, 0.0), 1.0), + } + } + + /// An editor on the equation typed as linear text (`x^2`, `\frac{a}{b}`, `sqrt(x)`). + pub fn with_linear(s: &str) -> Self { + let mut e = Self::new(); + e.set_linear(s); + e + } + + /// An editor on an OMML equation (`m:oMath` or `m:oMathPara`). OMML import never fails: + /// unknown elements are kept verbatim and broken input gives what could be read. + pub fn with_omml(xml: &str) -> Self { + let mut e = Self::new(); + e.set_math(from_omml(xml)); + e + } + + /// Derive the widget's egui ids from `salt` instead of a per-instance counter. + pub fn id_salt(mut self, salt: impl std::hash::Hash + std::fmt::Debug) -> Self { + self.id = egui::Id::new(("equashion-editor", salt)); + self.canvas = None; + self + } + + /// Lay out with these fonts instead of [`bundled_fonts`]. + pub fn fonts(mut self, fonts: Arc) -> Self { + self.fonts = fonts; + self.canvas = None; + self.palette.clear(); + self + } + + /// The font family of the equation (default [`DEFAULT_FAMILY`], "Source Serif 4"). + pub fn family(mut self, family: &str) -> Self { + self.family = family.to_string(); + self.canvas = None; + self + } + + /// The equation's font size on the canvas, points (default 28). + pub fn font_size(mut self, pt: f64) -> Self { + self.font_size = if pt.is_finite() { pt.clamp(6.0, 200.0) } else { DEFAULT_FONT_SIZE }; + self.canvas = None; + self + } + + /// The canvas height, points (default 120). + pub fn canvas_height(mut self, h: f32) -> Self { + self.canvas_height = if h.is_finite() { h.clamp(40.0, 2000.0) } else { DEFAULT_CANVAS_HEIGHT }; + self + } + + /// Edit in place: only the canvas, no background, as large as the equation. The host puts it + /// where the equation sits (see [`EquationEditor::inline_pad`]) and draws what is behind it. + pub fn inline(mut self, on: bool) -> Self { + self.inline = on; + self + } + + /// Display style (the default) or text style, to match how the host draws the equation. + pub fn display_style(mut self, display: bool) -> Self { + self.display_style = display; + self + } + + /// [`EquationEditor::display_style`] on an existing editor: the next frame lays out again. + pub fn set_display_style(&mut self, display: bool) { + if self.display_style != display { + self.display_style = display; + self.canvas = None; + } + } + + /// With [`EquationEditor::inline`]: do not draw the equation, only the caret, selection and + /// active place. The host draws the same equation underneath (same fonts, size and place), so + /// typing reads as typing in the text itself. + pub fn overlay(mut self, on: bool) -> Self { + self.overlay = on; + self + } + + /// Where the baseline sits, points from the top of an inline canvas. + pub fn inline_baseline(&self) -> Option { + self.canvas.as_ref().map(|c| c.layout.baseline() as f32) + } + + /// Space between an inline canvas's left / top edge and the equation's own left / top edge, + /// in points at the editor's scale: place the canvas at the equation's position minus this. + pub fn inline_pad() -> egui::Vec2 { + vec2(VIEW_MARGIN + EQ_PAD as f32, EQ_PAD as f32) + } + + /// The size of the laid-out equation in points (the canvas it last drew), if it has drawn. + pub fn content_size(&self) -> Option { + self.canvas.as_ref().map(|c| vec2(c.layout.width() as f32, c.layout.height() as f32)) + } + + /// Change the font size (points); the next frame lays the equation out again. + pub fn set_font_size(&mut self, pt: f64) { + if (self.font_size - pt).abs() > f64::EPSILON { + self.font_size = pt; + self.canvas = None; + } + } + + /// Show the template palette (default on). + pub fn show_palette(mut self, on: bool) -> Self { + self.show_palette = on; + self + } + + /// Show the linear-text field and the display toggle (default on). + pub fn show_linear_field(mut self, on: bool) -> Self { + self.show_linear = on; + self + } + + /// Blink the caret while idle (default on). Off keeps it solid, for hosts that honour + /// a reduced-motion preference. + pub fn caret_blink(mut self, on: bool) -> Self { + self.caret_blink = on; + self + } + + /// [`EquationEditor::caret_blink`] on an existing editor. + pub fn set_caret_blink(&mut self, on: bool) { + self.caret_blink = on; + } + + /// Take the keyboard in the first frames after creation (default on). + pub fn auto_focus(mut self, on: bool) -> Self { + self.auto_focus = if on { 4 } else { 0 }; + self.wants_canvas = on; + self + } + + /// The widget's id; the canvas and the linear field use ids derived from it. + pub fn id(&self) -> egui::Id { + self.id + } + + /// The id of the canvas (the focus target typing goes to). + pub fn canvas_id(&self) -> egui::Id { + self.id.with("canvas") + } + + /// The id of the linear-text field. + pub fn linear_id(&self) -> egui::Id { + self.id.with("linear") + } + + /// The equation in linear form (canonical: `to_linear` of the tree, not the field text). + pub fn linear(&self) -> String { + to_linear(self.ed.math()) + } + + /// The equation as OMML. + pub fn omml(&self) -> String { + to_omml(self.ed.math()) + } + + /// The equation tree. + pub fn math(&self) -> &Math { + self.ed.math() + } + + /// The edit model (caret, selection, undo). + pub fn editor(&self) -> &Editor { + &self.ed + } + + /// The edit model, mutable. Call [`EquationEditor::sync_linear`] after changing the tree. + pub fn editor_mut(&mut self) -> &mut Editor { + &mut self.ed + } + + /// Backslash commands of the linear field that the parser did not know. + pub fn unknown_commands(&self) -> Vec { + equashion_math::unknown_commands(self.ed.math()) + } + + /// Load the equation typed as linear text. This is a load: Esc compares against it. + pub fn set_linear(&mut self, s: &str) { + self.set_math(from_linear(s)); + self.linear = s.to_string(); + } + + /// Load the equation from OMML. This is a load: Esc compares against it. + pub fn set_omml(&mut self, xml: &str) { + self.set_math(from_omml(xml)); + } + + /// Load an equation tree. This is a load: Esc compares against it. + pub fn set_math(&mut self, m: Math) { + self.ed = Editor::new(m); + self.linear = to_linear(self.ed.math()); + self.initial = self.ed.math().clone(); + self.message = None; + self.discard_armed = false; + } + + /// Go back to the equation as last loaded or committed. + pub fn revert(&mut self) { + let m = self.initial.clone(); + self.set_math(m); + } + + /// True when the equation differs from the one last loaded or committed. + pub fn is_dirty(&self) -> bool { + self.ed.math() != &self.initial + } + + /// Accept the current equation as the new baseline (what Enter does). + pub fn mark_clean(&mut self) { + self.ed.flush(); + self.initial = self.ed.math().clone(); + self.discard_armed = false; + self.message = None; + } + + /// Refresh the linear field from the tree (after [`EquationEditor::editor_mut`]). + pub fn sync_linear(&mut self) { + self.linear = to_linear(self.ed.math()); + } + + /// The status message (the discard prompt), if any. + pub fn message(&self) -> Option<&str> { + self.message.as_deref() + } + + /// The text of the linear field changed: rebuild the tree from it. + fn linear_edited(&mut self) { + let para = self.ed.math().para; + let mut m = from_linear(&self.linear); + m.para = para; + self.ed.set_math(m); + } + + /// The tree changed through the canvas or the palette: show it in the linear field. + fn tree_edited(&mut self) { + self.linear = to_linear(self.ed.math()); + self.message = None; + self.discard_armed = false; + } + + /// Esc: the first press with unsaved work only warns, the second cancels. True when the + /// edit is cancelled. + fn escape(&mut self) -> bool { + if !self.is_dirty() || self.discard_armed { + self.discard_armed = false; + self.message = None; + return true; + } + self.discard_armed = true; + self.message = Some(DISCARD_PROMPT.to_string()); + false + } + + fn commit(&mut self) { + self.mark_clean(); + } + + /// Draw the widget: palette, canvas, linear field and message line. + pub fn show(&mut self, ui: &mut Ui) -> EditorResponse { + let before = self.ed.math().clone(); + let esc = self.focus_within && ui.input(|i| i.key_pressed(Key::Escape)); + let mut committed = false; + let id = self.id; + let inner = ui.push_id(id, |ui| { + if self.show_palette && !self.inline { + self.palette_ui(ui); + ui.add_space(4.0); + } + let canvas = self.canvas_ui(ui, &mut committed); + if !self.inline { + ui.add_space(4.0); + if self.show_linear { + self.linear_ui(ui, &mut committed); + } + self.message_ui(ui); + } + canvas + }); + let mut cancelled = false; + if esc { + cancelled = self.escape(); + } + if committed { + self.commit(); + } + let (c, l) = (self.canvas_id(), self.linear_id()); + self.focus_within = ui.memory(|m| m.has_focus(c) || m.has_focus(l)); + EditorResponse { changed: before != *self.ed.math(), committed, cancelled, response: inner.inner, rect: inner.response.rect } + } + + /// Egui input to editor calls. True when the tree or the caret changed. + fn handle_input(&mut self, ui: &Ui, committed: &mut bool) -> bool { + let events = ui.input(|i| i.events.clone()); + if events.is_empty() { + return false; + } + let before = (self.ed.math().clone(), self.ed.caret().clone(), self.ed.selection()); + // Platforms that send Copy / Cut events also send the key; act on one of them only. + let clipboard_events = events.iter().any(|e| matches!(e, egui::Event::Copy | egui::Event::Cut)); + for ev in &events { + match ev { + egui::Event::Text(t) => self.ed.type_str(t), + egui::Event::Paste(t) => self.ed.type_str(t), + egui::Event::Copy => self.copy(ui), + egui::Event::Cut => self.cut(ui), + egui::Event::Key { key, pressed: true, modifiers, .. } => { + let nav = match key { + Key::ArrowLeft => Some(MKey::Left), + Key::ArrowRight => Some(MKey::Right), + Key::ArrowUp => Some(MKey::Up), + Key::ArrowDown => Some(MKey::Down), + Key::Home => Some(MKey::Home), + Key::End => Some(MKey::End), + Key::Backspace => Some(MKey::Backspace), + Key::Delete => Some(MKey::Delete), + Key::Tab => Some(MKey::Tab), + Key::Enter => { + *committed = true; + None + } + Key::Z if modifiers.command => Some(if modifiers.shift { MKey::Redo } else { MKey::Undo }), + Key::Y if modifiers.command => Some(MKey::Redo), + _ => None, + }; + if let Some(k) = nav { + self.ed.key(k, modifiers.shift); + } else if *key == Key::A && modifiers.command { + self.ed.select_all(); + } else if *key == Key::C && modifiers.command && !clipboard_events { + self.copy(ui); + } else if *key == Key::X && modifiers.command && !clipboard_events { + self.cut(ui); + } + } + _ => {} + } + } + let tree = before.0 != *self.ed.math(); + if tree { + self.tree_edited(); + } + tree || before.1 != *self.ed.caret() || before.2 != self.ed.selection() + } + + fn copy(&mut self, ui: &Ui) { + if let Some(t) = self.ed.selected_linear() { + ui.ctx().copy_text(t); + } + } + + fn cut(&mut self, ui: &Ui) { + if let Some(t) = self.ed.cut() { + ui.ctx().copy_text(t); + } + } + + /// Lay the current tree out and render it, unless the last canvas already shows it. + fn refresh_canvas(&mut self, ui: &Ui) { + let shown = self.ed.display_math(); + let ppp = f64::from(ui.ctx().pixels_per_point()).max(1.0); + let want = (ppp * 2.0).min(MAX_SCALE); + if self.canvas.as_ref().is_some_and(|c| c.shown == shown && c.want == want) { + return; + } + // Ink in white on transparent; the painter tints it with the theme's text colour. + let layout = layout_equation(&*self.fonts, &shown, &self.family, self.font_size, self.display_style, Rgb::WHITE, EQ_PAD); + // Never beyond the largest texture the GPU takes, however wide the equation grows. + let scale = (MAX_TEXTURE / layout.width().max(layout.height()).max(1.0)).min(want); + let img = equashion_render::render_scene(&layout.scene, &*self.fonts, scale, None); + let ci = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels); + let tex = match self.canvas.take() { + Some(mut c) => { + c.tex.set(ci, TextureOptions::LINEAR); + c.tex + } + None => ui.ctx().load_texture(format!("equashion/editor/{:?}/canvas", self.id), ci, TextureOptions::LINEAR), + }; + self.canvas = Some(Canvas { shown, layout, tex, want }); + } + + /// Keyboard focus: the canvas takes it in the first frames and whenever nothing holds it + /// while typing belongs to it; it never takes it from another widget. + fn manage_focus(&mut self, ui: &Ui) { + let canvas = self.canvas_id(); + if self.auto_focus > 0 { + self.auto_focus -= 1; + ui.memory_mut(|m| m.request_focus(canvas)); + return; + } + match ui.memory(|m| m.focused()) { + Some(f) if f == canvas => {} + Some(_) => self.wants_canvas = false, + None if self.wants_canvas => ui.memory_mut(|m| m.request_focus(canvas)), + None => {} + } + } + + /// The editing canvas: the rendered equation, selection, caret, placeholders and mouse. + fn canvas_ui(&mut self, ui: &mut Ui, committed: &mut bool) -> egui::Response { + let id = self.canvas_id(); + self.manage_focus(ui); + // Keys the editor uses must not move the focus away; Esc is the editor's too. + let filter = egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: true }; + ui.memory_mut(|m| m.set_focus_lock_filter(id, filter)); + + let (width, height) = if self.inline { + // As large as the equation (what it drew last frame; a default the first time). + let (w, h) = self + .canvas + .as_ref() + .map_or((120.0, self.font_size as f32 * 2.0), |c| (c.layout.width() as f32 + 2.0 * VIEW_MARGIN, c.layout.height() as f32)); + if self.canvas.is_some() { (w.max(2.0 * VIEW_MARGIN + 8.0), h) } else { (w.max(48.0), h.max(self.font_size as f32 * 1.4)) } + } else { + (ui.available_width().max(160.0), self.canvas_height) + }; + let (rect, _) = ui.allocate_exact_size(vec2(width, height), Sense::hover()); + let resp = ui.interact(rect, id, Sense::click_and_drag()).on_hover_cursor(egui::CursorIcon::Text); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Other, ui.is_enabled(), "Equation")); + let focused = resp.has_focus(); + let now = ui.input(|i| i.time); + + if focused && self.handle_input(ui, committed) { + self.touched = now; + } + self.refresh_canvas(ui); + let Some(cv) = self.canvas.as_ref() else { return resp }; + + // Where the equation sits: left-aligned so it grows to the right as you type, vertically + // centred, shrunk (to at most half size) to fit, then scrolled to keep the caret in view. + let (w, h) = (cv.layout.width() as f32, cv.layout.height() as f32); + let room = width - 2.0 * VIEW_MARGIN; + let k = (room / w.max(1.0)).max(MIN_SCALE).min(height / h.max(1.0)).min(1.0); + let size = vec2(w * k, h * k); + let caret_x = cv.layout.caret_rect(self.ed.caret()).map_or(0.0, |r| r.x1 as f32 * k); + let scroll = if size.x > room { (caret_x - room * 0.85).clamp(0.0, size.x - room) } else { 0.0 }; + let origin = pos2(rect.min.x + VIEW_MARGIN - scroll, rect.center().y - size.y / 2.0); + self.view = (origin, k); + let to_layout = |p: Pos2| ((p.x - origin.x) / k.max(1e-3), (p.y - origin.y) / k.max(1e-3)); + + // Mouse: press places the caret, dragging or shift-click selects (across rows too). + let (pressed, down, pointer, shift) = + ui.input(|i| (i.pointer.primary_pressed(), i.pointer.primary_down(), i.pointer.interact_pos(), i.modifiers.shift)); + if let Some(p) = pointer { + let (x, y) = to_layout(p); + if pressed && rect.contains(p) && resp.hovered() { + ui.memory_mut(|m| m.request_focus(id)); + self.wants_canvas = true; + let hit = cv.layout.hit(f64::from(x), f64::from(y)); + if shift { + // Shift-click: extend from the fixed end of the current selection. + self.origin = self.ed.anchor_caret(); + self.ed.select_between(&self.origin, &hit); + } else { + self.ed.set_caret(hit.clone()); + self.origin = hit; + } + self.dragging = true; + self.touched = now; + } else if self.dragging && down { + let hit = cv.layout.hit(f64::from(x), f64::from(y)); + self.ed.select_between(&self.origin, &hit); + } + } + if !down { + self.dragging = false; + } + + let v = ui.visuals(); + let accent = v.selection.stroke.color; + let ink = v.strong_text_color(); + let painter = ui.painter_at(rect); + // The host's control corner radius, so the canvas matches its other inputs. + let corner = v.widgets.inactive.corner_radius; + if !self.inline { + painter.rect_filled(rect, corner, v.extreme_bg_color); + } + let to_screen = |r: equashion_layout::Rect| { + egui::Rect::from_min_max( + pos2(origin.x + r.x0 as f32 * k, origin.y + r.y0 as f32 * k), + pos2(origin.x + r.x1 as f32 * k, origin.y + r.y1 as f32 * k), + ) + }; + // The active place, tinted like MathLive's active placeholder: empty slots become a + // visible box, filled ones a soft band behind their content. The top level stays plain. + if !self.ed.caret().path.is_empty() + && let Some(r) = cv.layout.row_rect(&self.ed.caret().path) + { + let r = to_screen(r).expand2(vec2(2.5, 1.0)); + painter.rect_filled(r, 2.5, accent.gamma_multiply(0.13)); + painter.rect_stroke(r, 2.5, egui::Stroke::new(1.0, accent.gamma_multiply(0.45)), egui::StrokeKind::Inside); + } + // Selection under the glyphs. + if let Some((a, b)) = self.ed.selection() + && let Some(r) = cv.layout.span_rect(&self.ed.caret().path, a, b) + { + painter.rect_filled(to_screen(r), 1.0, v.selection.bg_fill.gamma_multiply(0.7)); + } + if !(self.inline && self.overlay) { + painter.image(cv.tex.id(), egui::Rect::from_min_size(origin, size), egui::Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), ink); + } + let dim = v.weak_text_color(); + if self.ed.is_blank() && !self.inline { + painter.text( + pos2(rect.min.x + 14.0, rect.center().y), + egui::Align2::LEFT_CENTER, + "Type here: x^2, a/b, \\frac, \\sqrt, \\alpha", + FontId::proportional(13.0), + dim, + ); + } + // The caret: solid while editing, blinking when idle. + if focused { + let on = !self.caret_blink || now - self.touched < BLINK || (((now - self.touched) / BLINK) as u64).is_multiple_of(2); + if on && let Some(r) = cv.layout.caret_rect(self.ed.caret()) { + let mut r = to_screen(r); + // Keep the caret off a radical's bar or a bracket at the start of a place. + if self.ed.caret().pos == 0 && !self.ed.caret().path.is_empty() { + r = r.translate(vec2(2.0 * k.max(0.5), 0.0)); + } + painter.line_segment([pos2(r.min.x, r.min.y), pos2(r.min.x, r.max.y)], egui::Stroke::new(1.6, accent)); + } + if self.caret_blink { + ui.ctx().request_repaint_after(std::time::Duration::from_millis(500)); + } + } else if !self.inline { + painter.text(pos2(rect.max.x - 8.0, rect.min.y + 6.0), egui::Align2::RIGHT_TOP, "Click here to type", FontId::proportional(11.0), dim); + } + let stroke = if focused { egui::Stroke::new(2.0, accent) } else { egui::Stroke::new(1.0, v.widgets.noninteractive.bg_stroke.color) }; + if !(self.inline && self.overlay) { + painter.rect_stroke(rect, if self.inline { 3.into() } else { corner }, stroke, egui::StrokeKind::Inside); + } + resp + } + + /// Linear text, the secondary way in, and the display toggle. + fn linear_ui(&mut self, ui: &mut Ui, committed: &mut bool) { + let id = self.linear_id(); + ui.horizontal(|ui| { + ui.label("Linear"); + let r = ui.add( + egui::TextEdit::singleline(&mut self.linear) + .id(id) + .desired_width(f32::INFINITY) + .font(FontId::monospace(14.0)) + .hint_text("a^2+b^2=c^2, x=(-b+-sqrt(b^2-4ac))/2a"), + ); + if r.lost_focus() && ui.input(|i| i.key_pressed(Key::Enter)) { + *committed = true; + } + if r.has_focus() || r.gained_focus() { + self.wants_canvas = false; + } + if r.changed() { + self.linear_edited(); + self.message = None; + self.discard_armed = false; + } + }); + let mut display = self.ed.math().para; + if ui.checkbox(&mut display, "Display on its own line").changed() { + self.ed.math_mut().para = display; + } + } + + /// One line is always reserved for the message, so showing it does not move what follows. + fn message_ui(&mut self, ui: &mut Ui) { + let w = ui.available_width(); + ui.allocate_ui_with_layout(vec2(w, 18.0), egui::Layout::left_to_right(egui::Align::Center), |ui| { + if let Some(m) = &self.message { + ui.colored_label(ui.visuals().warn_fg_color, m); + } + }); + } + + /// Insert a palette template or symbol at the caret, keeping typing in the canvas. + fn palette_insert(&mut self, ui: &Ui, tpl: Option, sym: Option) { + if tpl.is_none() && sym.is_none() { + return; + } + if let Some(t) = tpl { + self.ed.insert_template(&t); + } + if let Some(c) = sym { + self.ed.insert_symbol(c); + } + self.wants_canvas = true; + let id = self.canvas_id(); + ui.memory_mut(|m| m.request_focus(id)); + self.tree_edited(); + self.touched = ui.input(|i| i.time); + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/equashion-egui/src/palette.rs b/crates/equashion-egui/src/palette.rs new file mode 100644 index 00000000..4e8f19f1 --- /dev/null +++ b/crates/equashion-egui/src/palette.rs @@ -0,0 +1,150 @@ +//! The template palette. Every button face (structures, Greek letters, operators) is a small +//! equation laid out and rasterised by equashion-render from the bundled fonts, so the palette +//! never depends on the host's egui fonts having the symbols. + +use egui::{Sense, TextureHandle, TextureOptions, Ui, Vec2, pos2, vec2}; +use equashion_layout::{Rgb, layout_equation}; +use equashion_math::{Editor, Math, Node, templates, to_linear}; + +use crate::{EquationEditor, Tab}; + +/// Palette symbols: Greek letters and operators (WordCraft's sets). +pub const GREEK_LOWER: &str = "αβγδεζηθικλμνξοπρστυφχψω"; +pub const GREEK_UPPER: &str = "ΓΔΘΛΞΠΣΦΨΩ"; +pub const OPERATORS: &str = "±∓×÷·∘∗≠≈≡≅∼∝≤≥≪≫≺≻∞∂∇∅∈∉⊂⊃⊆⊇∪∩∧∨¬∀∃→←↔⇒⇔↦∴∵…⋯°′″"; + +/// The template groups, in palette order. +const GROUPS: [&str; 8] = ["Fractions", "Scripts", "Radicals", "Large operators", "Brackets", "Matrices", "Functions", "Accents"]; + +/// A rendered button face. +pub(crate) struct Symbol { + tex: TextureHandle, + /// Size of the face in points. + size: Vec2, +} + +impl Symbol { + pub(crate) fn texture_id(&self) -> egui::TextureId { + self.tex.id() + } + + pub(crate) fn size(&self) -> Vec2 { + self.size + } +} + +/// The short accessible label of a structure's palette button (WordCraft's button faces). +pub fn label(id: &str) -> Option<&'static str> { + Some(match id { + "frac" => "a/b", + "sup" => "x^n", + "sub" => "x_n", + "subsup" => "x_n^m", + "sqrt" => "\u{221A}", + "root" => "n\u{221A}", + "sum" => "\u{2211}", + "prod" => "\u{220F}", + "int" => "\u{222B}", + "intlim" => "\u{222B}ab", + "paren" => "( )", + "bracket" => "[ ]", + "brace" => "{ }", + "abs" => "| |", + "matrix2" => "2\u{00D7}2", + "matrix3" => "3\u{00D7}3", + "bmatrix2" => "[2\u{00D7}2]", + "bmatrix3" => "[3\u{00D7}3]", + "cases" => "{ cases", + "overline" => "over", + "underline" => "under", + "overbrace" => "brace", + _ => return None, + }) +} + +/// A palette button: the rendered face tinted with the widget text colour, the egui button +/// frame, and an accessible label. +pub(crate) fn button(ui: &mut Ui, sym: &Symbol, min: Vec2, max_face_h: f32, label: &str, hover: &str) -> egui::Response { + let k = (max_face_h / sym.size.y.max(1.0)).min(1.0); + let face = sym.size * k; + let pad = vec2(6.0, 3.0); + let size = vec2((face.x + 2.0 * pad.x).max(min.x), min.y.max(face.y + 2.0 * pad.y)); + // Clickable but not focusable: a click must not take the keyboard from the canvas. + let (rect, resp) = ui.allocate_exact_size(size, Sense::CLICK); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), label)); + if ui.is_rect_visible(rect) { + let vis = ui.style().interact(&resp); + ui.painter().rect(rect.expand(vis.expansion), vis.corner_radius, vis.weak_bg_fill, vis.bg_stroke, egui::StrokeKind::Inside); + let r = egui::Rect::from_center_size(rect.center(), face); + ui.painter().image(sym.tex.id(), r, egui::Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), vis.text_color()); + } + resp.on_hover_text(hover) +} + +impl EquationEditor { + /// The face of `math` at `pt` points, rendered once per pixel density and cached. + pub(crate) fn symbol(&mut self, ui: &Ui, key: &str, math: &Math, pt: f64) -> Option<&Symbol> { + let ppp = ui.ctx().pixels_per_point(); + let ck = format!("{key}@{ppp}"); + if !self.palette.contains_key(&ck) { + let l = layout_equation(&*self.fonts, math, &self.family, pt, false, Rgb::WHITE, 1.0); + let scale = f64::from(ppp.max(1.0)) * 2.0; + let img = equashion_render::render_scene(&l.scene, &*self.fonts, scale, None); + let ci = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels); + let tex = ui.ctx().load_texture(format!("equashion/palette/{key}"), ci, TextureOptions::LINEAR); + self.palette.insert(ck.clone(), Symbol { tex, size: vec2(l.width() as f32, l.height() as f32) }); + } + self.palette.get(&ck) + } + + pub(crate) fn palette_ui(&mut self, ui: &mut Ui) { + ui.horizontal(|ui| { + for (tab, name) in [(Tab::Structures, "Structures"), (Tab::Greek, "Greek"), (Tab::Operators, "Operators")] { + if ui.selectable_label(self.tab == tab, name).clicked() { + self.tab = tab; + } + } + }); + let mut chosen_template: Option = None; + let mut chosen_symbol: Option = None; + match self.tab { + Tab::Structures => { + // Every group at once, compact buttons in one flow; the full name is the tooltip. + let all = templates(); + for g in GROUPS { + ui.horizontal_wrapped(|ui| { + let dim = ui.visuals().weak_text_color(); + ui.add_sized(vec2(92.0, 26.0), egui::Label::new(egui::RichText::new(g).size(11.0).strong().color(dim))); + for tpl in all.iter().filter(|t| t.group == g) { + // The template with its empty places shown as boxes. + let face = Editor::new(tpl.math.clone()).display_math(); + let Some(sym) = self.symbol(ui, tpl.id, &face, 13.0) else { continue }; + let name = label(tpl.id).unwrap_or(tpl.label); + let hover = format!("{} ({})", tpl.label, to_linear(&tpl.math)); + if button(ui, sym, vec2(30.0, 28.0), 22.0, name, &hover).clicked() { + chosen_template = Some(tpl.math.clone()); + } + } + }); + } + } + Tab::Greek | Tab::Operators => { + let sets: &[&str] = if self.tab == Tab::Greek { &[GREEK_LOWER, GREEK_UPPER] } else { &[OPERATORS] }; + for chars in sets { + ui.horizontal_wrapped(|ui| { + for c in chars.chars() { + let s = c.to_string(); + let face = Math::new(vec![Node::Text(s.clone())]); + let Some(sym) = self.symbol(ui, &format!("sym-{}", c as u32), &face, 15.0) else { continue }; + let hover = format!("U+{:04X}", c as u32); + if button(ui, sym, vec2(28.0, 28.0), 22.0, &s, &hover).clicked() { + chosen_symbol = Some(c); + } + } + }); + } + } + } + self.palette_insert(ui, chosen_template, chosen_symbol); + } +} diff --git a/crates/equashion-egui/src/tests.rs b/crates/equashion-egui/src/tests.rs new file mode 100644 index 00000000..ca1984ea --- /dev/null +++ b/crates/equashion-egui/src/tests.rs @@ -0,0 +1,644 @@ +//! Widget-level tests, ported from WordCraft's `eqdialog.rs` kittest tests (those that do not +//! need the document) plus DeckCraft's clipboard keys and checks for a shared context. + +use egui::{Key, Modifiers, pos2, vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use equashion_math::{Caret, Node, to_linear, units}; + +use super::*; + +/// The host: one editor (optionally a second one and a host text field), counting what the +/// responses reported. +struct App { + ed: EquationEditor, + other: Option, + host_text: Option, + committed: u32, + cancelled: u32, + changed: u32, + /// Ask for a second (discarded) pass every frame. + discard: bool, +} + +impl App { + fn new(ed: EquationEditor) -> Self { + App { ed, other: None, host_text: None, committed: 0, cancelled: 0, changed: 0, discard: false } + } +} + +fn harness(app: App) -> Harness<'static, App> { + Harness::builder().with_size(vec2(820.0, 760.0)).build_ui_state( + |ui, a: &mut App| { + if a.discard && ui.ctx().current_pass_index() == 0 { + ui.ctx().request_discard("test: a second pass"); + } + if let Some(t) = a.host_text.as_mut() { + ui.add(egui::TextEdit::singleline(t).id(egui::Id::new("host-field"))); + } + let r = a.ed.show(ui); + a.committed += u32::from(r.committed); + a.cancelled += u32::from(r.cancelled); + a.changed += u32::from(r.changed); + if let Some(o) = a.other.as_mut() { + o.show(ui); + } + }, + app, + ) +} + +fn opened() -> Harness<'static, App> { + let mut h = harness(App::new(EquationEditor::new())); + h.run(); + h.run(); + h +} + +fn ed<'a>(h: &'a Harness<'static, App>) -> &'a EquationEditor { + &h.state().ed +} + +fn typ(h: &mut Harness<'static, App>, text: &str) { + h.event(egui::Event::Text(text.into())); + h.run(); +} + +fn press(h: &mut Harness<'static, App>, key: Key) { + h.key_press(key); + h.run(); +} + +fn press_mod(h: &mut Harness<'static, App>, m: Modifiers, key: Key) { + h.key_press_modifiers(m, key); + h.run(); +} + +fn click(h: &mut Harness<'static, App>, label: &str) { + h.get_by_label(label).click_accesskit(); + h.run(); + h.run(); +} + +fn lin(h: &Harness<'static, App>) -> String { + to_linear(ed(h).math()).replace(' ', "").replace(['(', ')'], "") +} + +fn canvas_focused(h: &Harness<'static, App>) -> bool { + let id = ed(h).canvas_id(); + h.ctx.memory(|m| m.has_focus(id)) +} + +/// Screen position of the caret `c` (its vertical middle; `left` at its left edge, else centre). +fn caret_pos(h: &Harness<'static, App>, c: &Caret) -> egui::Pos2 { + let e = ed(h); + let cv = e.canvas.as_ref().expect("canvas"); + let r = cv.layout.caret_rect(c).expect("caret rect"); + let (o, k) = e.view; + pos2(o.x + ((r.x0 + r.x1) / 2.0) as f32 * k, o.y + ((r.y0 + r.y1) / 2.0) as f32 * k) +} + +fn mouse(h: &mut Harness<'static, App>, p: egui::Pos2, pressed: bool, m: Modifiers) { + h.event_modifiers(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: m }, m); + h.run(); +} + +#[test] +fn typing_x_caret_2_raises_the_exponent_at_once() { + let mut h = opened(); + assert!(canvas_focused(&h), "the canvas takes the keyboard when it opens"); + typ(&mut h, "x"); + typ(&mut h, "^"); + assert_eq!(ed(&h).editor().caret().path.len(), 1); + typ(&mut h, "2"); + assert_eq!(lin(&h), "x^2"); + let e = ed(&h); + assert!(matches!(e.math().body.first(), Some(Node::Script { sup: Some(_), .. })), "{:?}", e.math().body); + let cv = e.canvas.as_ref().expect("canvas"); + let sup = cv.layout.caret_rect(e.editor().caret()).expect("sup caret"); + let base = cv.layout.caret_rect(&Caret { path: vec![], pos: 0 }).expect("base caret"); + assert!(sup.y0 < base.y0, "{sup:?} {base:?}"); + assert!(cv.tex.size()[0] > 1); + press(&mut h, Key::ArrowRight); + assert!(ed(&h).editor().caret().path.is_empty()); + typ(&mut h, "+1"); + assert_eq!(lin(&h), "x^2+1"); + assert_eq!(ed(&h).linear.replace(' ', ""), "x^2+1", "the linear field follows"); + assert_eq!(ed(&h).linear().replace(' ', ""), "x^2+1"); + assert!(ed(&h).omml().contains(""), "{}", ed(&h).omml()); + assert!(h.state().changed >= 3); +} + +#[test] +fn underscore_slash_sqrt_and_greek_names() { + let mut h = opened(); + typ(&mut h, "a_1"); + assert_eq!(lin(&h), "a_1"); + press(&mut h, Key::ArrowRight); + typ(&mut h, "/"); + assert!(matches!(ed(&h).math().body.first(), Some(Node::Frac { .. }))); + assert_eq!(ed(&h).editor().caret().path, vec![(0, 1)]); + typ(&mut h, "sqrt"); + typ(&mut h, "alpha"); + let l = lin(&h); + assert!(l.contains('\u{221A}') && l.contains('\u{3B1}'), "{l}"); +} + +#[test] +fn arrows_and_backspace_edit_the_tree() { + let mut h = opened(); + typ(&mut h, "a/b"); + press(&mut h, Key::ArrowUp); + assert_eq!(ed(&h).editor().caret().path, vec![(0, 0)], "Up goes to the numerator"); + press(&mut h, Key::ArrowDown); + assert_eq!(ed(&h).editor().caret().path, vec![(0, 1)]); + press(&mut h, Key::ArrowLeft); + press(&mut h, Key::ArrowLeft); + assert_eq!(ed(&h).editor().caret().path, vec![(0, 0)], "Left crosses into the numerator"); + press(&mut h, Key::Home); + press(&mut h, Key::Backspace); + assert_eq!(lin(&h), "ab", "Backspace at the start unwraps the fraction"); + press(&mut h, Key::Delete); + assert_eq!(lin(&h), "b"); + press(&mut h, Key::End); + press(&mut h, Key::Backspace); + assert!(ed(&h).editor().is_blank()); + assert!(canvas_focused(&h), "the arrow keys did not move the focus away"); +} + +#[test] +fn ctrl_z_and_ctrl_shift_z_undo_and_redo() { + let mut h = opened(); + typ(&mut h, "x+a/b"); + press(&mut h, Key::ArrowRight); + press(&mut h, Key::Backspace); + assert_eq!(lin(&h), "x+a/b", "the first Backspace only steps inside"); + press(&mut h, Key::Backspace); + assert_eq!(lin(&h), "x+a/"); + press_mod(&mut h, Modifiers::COMMAND, Key::Z); + assert_eq!(lin(&h), "x+a/b", "Ctrl+Z brings the atom back"); + press_mod(&mut h, Modifiers::COMMAND | Modifiers::SHIFT, Key::Z); + assert_eq!(lin(&h), "x+a/"); + press_mod(&mut h, Modifiers::COMMAND, Key::Z); + press_mod(&mut h, Modifiers::COMMAND, Key::Y); + assert_eq!(lin(&h), "x+a/", "Ctrl+Y redoes too"); +} + +fn copied(h: &Harness<'static, App>) -> Option { + h.output().platform_output.commands.iter().rev().find_map(|c| match c { + egui::OutputCommand::CopyText(t) => Some(t.clone()), + _ => None, + }) +} + +/// Events delivered together in one frame; returns the text that frame copied. +fn frame_with(h: &mut Harness<'static, App>, events: Vec) -> Option { + h.input_mut().events.extend(events); + h.step(); + let c = copied(h); + h.run(); + c +} + +fn cmd(key: Key) -> egui::Event { + egui::Event::Key { key, pressed: true, modifiers: Modifiers::COMMAND, repeat: false, physical_key: None } +} + +#[test] +fn ctrl_c_copies_and_ctrl_x_cuts_the_selection_as_linear_text() { + let mut h = opened(); + typ(&mut h, "a+b"); + press_mod(&mut h, Modifiers::SHIFT, Key::ArrowLeft); + assert_eq!(frame_with(&mut h, vec![cmd(Key::C)]).as_deref(), Some("b")); + assert_eq!(lin(&h), "a+b", "copy leaves the equation alone"); + press_mod(&mut h, Modifiers::COMMAND, Key::A); + assert_eq!(frame_with(&mut h, vec![cmd(Key::X)]).map(|s| s.replace(' ', "")).as_deref(), Some("a+b")); + assert!(ed(&h).editor().is_blank(), "cut removed it"); + // A platform Cut event and the key in the same frame cut once. + typ(&mut h, "xyz"); + press_mod(&mut h, Modifiers::SHIFT, Key::ArrowLeft); + assert_eq!(frame_with(&mut h, vec![egui::Event::Cut, cmd(Key::X)]).as_deref(), Some("z")); + assert_eq!(lin(&h), "xy", "one cut, not two"); + press_mod(&mut h, Modifiers::COMMAND, Key::Z); + assert_eq!(lin(&h), "xyz", "a cut undoes"); +} + +#[test] +fn tab_stays_in_the_canvas_and_moves_between_places() { + let mut h = opened(); + typ(&mut h, "a/b"); + press(&mut h, Key::ArrowUp); + press(&mut h, Key::Tab); + assert_eq!(ed(&h).editor().caret().path, vec![(0, 1)]); + assert!(canvas_focused(&h), "Tab did not move the focus to another widget"); + typ(&mut h, "c"); + assert_eq!(lin(&h), "a/cb", "typing after Tab goes to the tree, not the linear field"); + press_mod(&mut h, Modifiers::SHIFT, Key::Tab); + assert_eq!(ed(&h).editor().caret().path, vec![(0, 0)], "Shift+Tab goes back"); +} + +#[test] +fn escape_with_unsaved_work_asks_before_discarding() { + let mut h = opened(); + typ(&mut h, "x^2"); + press(&mut h, Key::Escape); + assert_eq!(h.state().cancelled, 0, "the first Escape only warns"); + assert_eq!(ed(&h).message(), Some(DISCARD_PROMPT)); + assert!(canvas_focused(&h), "Escape does not drop the canvas focus"); + h.get_by_label(DISCARD_PROMPT); + press(&mut h, Key::Escape); + assert_eq!(h.state().cancelled, 1, "the second Escape cancels"); + assert!(ed(&h).message().is_none()); + h.state_mut().ed.revert(); + assert!(ed(&h).editor().is_blank()); + + // Typing after the warning disarms it. + typ(&mut h, "y"); + press(&mut h, Key::Escape); + typ(&mut h, "z"); + press(&mut h, Key::Escape); + assert_eq!(h.state().cancelled, 1, "the warning starts over after an edit"); + + let mut h = opened(); + press(&mut h, Key::Escape); + assert_eq!(h.state().cancelled, 1, "nothing typed: cancels at once"); +} + +#[test] +fn enter_commits_and_resets_the_baseline() { + let mut h = opened(); + typ(&mut h, "x^2"); + press(&mut h, Key::Enter); + assert_eq!(h.state().committed, 1); + assert!(!ed(&h).is_dirty()); + press(&mut h, Key::Escape); + assert_eq!(h.state().cancelled, 1, "after a commit nothing is lost: Esc cancels at once"); +} + +#[test] +fn shift_arrows_select_and_a_palette_click_wraps_the_selection() { + let mut h = opened(); + typ(&mut h, "xy"); + press_mod(&mut h, Modifiers::SHIFT, Key::ArrowLeft); + press_mod(&mut h, Modifiers::SHIFT, Key::ArrowLeft); + assert_eq!(ed(&h).editor().selection(), Some((0, 2))); + click(&mut h, "\u{221A}"); + assert!(matches!(ed(&h).math().body.first(), Some(Node::Rad { body, .. }) if units(body) == 2), "{:?}", ed(&h).math().body); + typ(&mut h, "z"); + assert_eq!(lin(&h), "\u{221A}xyz"); +} + +#[test] +fn the_quadratic_formula_types_straight_through() { + let mut h = opened(); + typ(&mut h, "x=(-b+-sqrt(b^2"); + press(&mut h, Key::ArrowRight); + typ(&mut h, "-4ac))/2a"); + let l = lin(&h); + assert!(l.contains('\u{B1}') && l.contains('\u{221A}') && l.ends_with("/2a"), "{l}"); + assert!(matches!(ed(&h).math().body.last(), Some(Node::Frac { .. }))); +} + +#[test] +fn the_caret_and_typing_survive_a_palette_click() { + let mut h = opened(); + click(&mut h, "a/b"); + assert!(canvas_focused(&h), "the canvas keeps the keyboard after a palette click"); + let e = ed(&h); + let cv = e.canvas.as_ref().expect("canvas"); + assert_eq!(e.editor().caret().path, vec![(0, 0)], "the caret is in the new numerator"); + assert!(cv.layout.caret_rect(e.editor().caret()).is_some(), "and it has a rectangle to draw"); + click(&mut h, "x^n"); + typ(&mut h, "2"); + assert!(lin(&h).contains('2'), "{}", lin(&h)); +} + +#[test] +fn palette_structures_insert_at_the_caret() { + let mut h = opened(); + typ(&mut h, "b"); + click(&mut h, "x^n"); + typ(&mut h, "2"); + assert_eq!(lin(&h), "b^2"); + press(&mut h, Key::ArrowRight); + click(&mut h, "a/b"); + assert!(matches!(ed(&h).math().body.last(), Some(Node::Frac { .. })), "{:?}", ed(&h).math().body); +} + +#[test] +fn greek_and_operator_tabs_insert_symbols() { + let mut h = opened(); + h.get_by_label("Greek").click(); + h.run(); + click(&mut h, "\u{3B8}"); + h.get_by_label("Operators").click(); + h.run(); + click(&mut h, "\u{2207}"); + click(&mut h, "\u{2264}"); + assert_eq!(lin(&h), "\u{3B8}\u{2207}\u{2264}"); + assert!(canvas_focused(&h)); +} + +#[test] +fn empty_places_show_boxes_and_the_caret_has_a_rect() { + let mut h = opened(); + typ(&mut h, "a/"); + let e = ed(&h); + assert!(to_linear(&e.editor().display_math()).contains('\u{25A1}')); + assert!(!to_linear(e.math()).contains('\u{25A1}')); + let cv = e.canvas.as_ref().expect("canvas"); + assert!(cv.layout.caret_rect(e.editor().caret()).is_some(), "caret has a rectangle inside the empty denominator"); +} + +#[test] +fn clicking_the_canvas_places_the_caret() { + let mut h = opened(); + typ(&mut h, "ab+cd"); + let e = ed(&h); + let r = e.canvas.as_ref().and_then(|c| c.layout.caret_rect(&Caret { path: vec![], pos: 1 })).expect("rect"); + let (o, k) = e.view; + let hit = pos2(o.x + r.x0 as f32 * k, o.y + (r.y0 + r.y1) as f32 * 0.5 * k); + h.event(egui::Event::PointerMoved(hit)); + mouse(&mut h, hit, true, Modifiers::NONE); + mouse(&mut h, hit, false, Modifiers::NONE); + assert_eq!(ed(&h).editor().caret().pos, 1, "caret between a and b"); + typ(&mut h, "x"); + assert_eq!(lin(&h), "axb+cd"); +} + +#[test] +fn shift_click_extends_the_selection() { + let mut h = opened(); + typ(&mut h, "abcd"); + let p1 = caret_pos(&h, &Caret { path: vec![], pos: 1 }); + let p3 = caret_pos(&h, &Caret { path: vec![], pos: 3 }); + h.event(egui::Event::PointerMoved(p1)); + h.run(); + mouse(&mut h, p1, true, Modifiers::NONE); + mouse(&mut h, p1, false, Modifiers::NONE); + h.event(egui::Event::PointerMoved(p3)); + h.run(); + mouse(&mut h, p3, true, Modifiers::SHIFT); + mouse(&mut h, p3, false, Modifiers::SHIFT); + assert_eq!(ed(&h).editor().selection(), Some((1, 3)), "{:?}", ed(&h)); +} + +#[test] +fn a_drag_from_a_numerator_to_the_top_row_selects_whole_structures() { + let mut h = opened(); + typ(&mut h, "a/b"); + press(&mut h, Key::ArrowRight); + typ(&mut h, "+c"); + assert_eq!(lin(&h), "a/b+c"); + let from = Caret { path: vec![(0, 0)], pos: 0 }; + let to = Caret { path: vec![], pos: units(&ed(&h).math().body) }; + let (p0, p1) = (caret_pos(&h, &from), caret_pos(&h, &to)); + // What the edit model says such a drag selects. + let mut want = ed(&h).editor().clone(); + want.select_between(&from, &to); + h.event(egui::Event::PointerMoved(p0)); + h.run(); + mouse(&mut h, p0, true, Modifiers::NONE); + assert_eq!(ed(&h).editor().caret().path, vec![(0, 0)], "the press lands in the numerator"); + for t in [0.3, 0.6, 1.0] { + h.event(egui::Event::PointerMoved(p0 + (p1 - p0) * t)); + h.run(); + } + mouse(&mut h, p1, false, Modifiers::NONE); + let e = ed(&h).editor(); + assert!(e.caret().path.is_empty(), "the selection moved up to the row holding both ends: {:?}", e.caret()); + assert_eq!(e.selection(), want.selection()); + let (a, b) = e.selection().expect("a selection"); + assert!(a == 0 && b >= 3, "the whole fraction and what follows: {a}..{b}"); +} + +#[test] +fn linear_field_is_still_a_way_in() { + let mut h = opened(); + h.get_by_role(egui::accesskit::Role::TextInput).click(); + h.run(); + typ(&mut h, "x^2+1"); + assert_eq!(lin(&h), "x^2+1"); + assert!(ed(&h).canvas.as_ref().is_some_and(|c| c.shown.body.len() >= 2), "the canvas follows the linear field"); + assert!(!canvas_focused(&h), "the field keeps the keyboard"); + press(&mut h, Key::Enter); + assert_eq!(h.state().committed, 1, "Enter in the field commits"); +} + +#[test] +fn typing_once_types_once() { + // A host whose frames take two passes (request_discard) must not see each key twice. + let mut app = App::new(EquationEditor::new()); + app.discard = true; + let mut h = harness(app); + h.run(); + h.run(); + typ(&mut h, "ab"); + typ(&mut h, "c"); + assert_eq!(lin(&h), "abc"); + // Two editors on one context: only the focused one types. + let mut app = App::new(EquationEditor::new()); + app.other = Some(EquationEditor::new().auto_focus(false)); + let mut h = harness(app); + h.run(); + h.run(); + typ(&mut h, "q"); + assert_eq!(lin(&h), "q"); + assert!(h.state().other.as_ref().is_some_and(|o| o.editor().is_blank()), "the other editor got nothing"); +} + +#[test] +fn hostile_input_does_not_panic() { + let mut h = opened(); + let keys = [Key::ArrowLeft, Key::ArrowRight, Key::ArrowUp, Key::ArrowDown, Key::Home, Key::End, Key::Backspace, Key::Delete, Key::Tab]; + let texts = ["x^", "_", "/", "sqrt", "pi", "(", "2", "^^^", "\\frac{", "}", "\\langle"]; + for i in 0..60usize { + typ(&mut h, texts[i % texts.len()]); + press(&mut h, keys[(i * 5 + 3) % keys.len()]); + } + assert!(ed(&h).editor().is_valid()); +} + +#[test] +fn typed_latex_works_in_the_canvas() { + let mut h = opened(); + typ(&mut h, "\\alpha"); + assert_eq!(lin(&h), "\u{3b1}"); + typ(&mut h, "+\\frac{1}{2}"); + assert_eq!(lin(&h), "\u{3b1}+1/2"); + typ(&mut h, "\\sqrt{x}"); + assert!(lin(&h).ends_with('x'), "{}", lin(&h)); + assert!(ed(&h).math().body.iter().any(|n| matches!(n.bare(), Node::Rad { .. }))); +} + +#[test] +fn typing_lim_with_a_subscript_leaves_no_empty_box() { + let mut h = opened(); + typ(&mut h, "lim_x"); + assert!(!ed(&h).linear.contains("()_"), "{}", ed(&h).linear); + let mut h = opened(); + typ(&mut h, "\\lim_{x\\to0}"); + assert!(!ed(&h).linear.contains("()_"), "{}", ed(&h).linear); +} + +#[test] +fn the_widget_leaves_a_host_field_alone() { + let mut app = App::new(EquationEditor::new().auto_focus(false)); + app.host_text = Some(String::new()); + let mut h = harness(app); + h.run(); + let host = egui::Id::new("host-field"); + h.ctx.memory_mut(|m| m.request_focus(host)); + h.run(); + h.run(); + typ(&mut h, "hi"); + assert!(h.ctx.memory(|m| m.has_focus(host)), "the editor did not take the keyboard"); + assert_eq!(h.state().host_text.as_deref(), Some("hi")); + assert!(ed(&h).editor().is_blank()); + press(&mut h, Key::Escape); + assert_eq!(h.state().cancelled, 0, "Esc in a host widget is not the editor's"); +} + +#[test] +fn textures_are_namespaced() { + let mut h = opened(); + typ(&mut h, "x"); + let names: Vec = h.ctx.tex_manager().read().allocated().map(|(_, m)| m.name.clone()).collect(); + let ours: Vec<&String> = names.iter().filter(|n| n.starts_with("equashion/")).collect(); + assert!(ours.len() > 10, "canvas and palette textures: {names:?}"); + assert!(names.iter().all(|n| n.starts_with("equashion/") || n.starts_with("egui")), "{names:?}"); +} + +#[test] +fn set_linear_with_omml_and_revert() { + let mut e = EquationEditor::with_linear(r"\frac{a}{b}"); + assert!(matches!(e.math().body.first(), Some(Node::Frac { .. }))); + let l = e.linear(); + e.set_linear(&l); + assert_eq!(e.linear(), l, "linear round trip"); + let o = e.omml(); + let e2 = EquationEditor::with_omml(&o); + assert_eq!(e2.omml(), o, "OMML round trip"); + e.editor_mut().type_str("+c"); + e.sync_linear(); + assert!(e.is_dirty()); + e.revert(); + assert_eq!(e.linear(), l); + assert!(EquationEditor::with_linear(r"\foo x").unknown_commands().iter().any(|c| c.contains("foo"))); +} + +#[test] +fn every_palette_face_is_drawn_from_the_bundled_fonts() { + use equashion_layout::{DEFAULT_FAMILY, Prim, Rgb, layout_equation}; + let fonts = bundled_fonts(); + let mut faces: Vec<(String, equashion_math::Math)> = + equashion_math::templates().into_iter().map(|t| (t.id.to_string(), equashion_math::Editor::new(t.math).display_math())).collect(); + for c in [palette::GREEK_LOWER, palette::GREEK_UPPER, palette::OPERATORS].concat().chars() { + faces.push((c.to_string(), equashion_math::Math::new(vec![Node::Text(c.to_string())]))); + } + for (name, m) in faces { + let l = layout_equation(&*fonts, &m, DEFAULT_FAMILY, 15.0, false, Rgb::WHITE, 1.0); + let notdef = l.scene.items.iter().any(|p| matches!(p, Prim::Glyphs { glyphs, .. } if glyphs.iter().any(|g| g.0 == 0))); + assert!(!notdef, "palette face {name} has a missing glyph"); + } +} + +/// The galleries a host puts in its ribbon insert into the editor they are called on. +#[test] +fn a_gallery_template_inserts_at_the_caret() { + let mut h = Harness::builder().with_size(vec2(500.0, 600.0)).build_ui_state( + |ui, ed: &mut EquationEditor| { + ed.show(ui); + ed.gallery_ui(ui, "fraction"); + }, + EquationEditor::new().show_palette(false).id_salt("gallery-test"), + ); + h.run(); + h.get_by_label("Stacked Fraction").click(); + h.run(); + assert!(matches!(h.state().math().body.first().map(Node::bare), Some(Node::Frac { .. })), "{:?}", h.state().math()); +} + +#[test] +fn a_gallery_symbol_inserts_at_the_caret() { + let mut h = Harness::builder().with_size(vec2(500.0, 600.0)).build_ui_state( + |ui, ed: &mut EquationEditor| { + ed.show(ui); + ed.symbols_ui(ui, 1); + }, + EquationEditor::new().show_palette(false).id_salt("symbol-test"), + ); + h.run(); + h.get_by_label("π").click(); + h.run(); + assert_eq!(h.state().linear(), "π"); +} + +#[test] +fn a_ready_made_equation_replaces_the_equation() { + let mut h = Harness::builder().with_size(vec2(500.0, 700.0)).build_ui_state( + |ui, ed: &mut EquationEditor| { + ed.show(ui); + ed.builtins_ui(ui); + }, + EquationEditor::new().show_palette(false).id_salt("builtin-test"), + ); + h.run(); + h.get_by_label("Euler's identity").click(); + h.run(); + assert_eq!(h.state().linear(), "e^(iπ)+1=0"); +} + +/// Inline mode is only the equation: it sizes itself to what was typed and has no palette. +#[test] +fn inline_mode_is_sized_to_the_equation() { + let mut h = Harness::builder().with_size(vec2(600.0, 300.0)).build_ui_state( + |ui, ed: &mut EquationEditor| { + ed.show(ui); + }, + EquationEditor::with_linear("x").inline(true).font_size(20.0).id_salt("inline-test"), + ); + h.run_steps(3); + let small = h.state().content_size().expect("drew"); + assert!(h.query_by_label("Fractions").is_none() && h.query_by_label("Greek").is_none(), "no palette"); + h.state_mut().set_linear("a^2+b^2=c^2+d^2+e^2"); + h.run_steps(3); + let big = h.state().content_size().expect("drew"); + assert!(big.x > small.x * 3.0, "{small:?} -> {big:?}"); + h.state_mut().set_font_size(40.0); + h.run_steps(3); + assert!(h.state().content_size().expect("drew").y > big.y * 1.5); +} + +/// An overlay canvas is exactly the equation's size and says where its baseline is, so a host +/// can lay it over its own drawing of the same equation. +#[test] +fn overlay_canvas_reports_its_baseline() { + let mut h = Harness::builder().with_size(vec2(600.0, 300.0)).build_ui_state( + |ui, ed: &mut EquationEditor| { + ed.show(ui); + }, + EquationEditor::with_linear("a/b").inline(true).overlay(true).font_size(24.0).id_salt("overlay-test"), + ); + h.run_steps(4); + let (size, base) = (h.state().content_size().unwrap(), h.state().inline_baseline().unwrap()); + assert!(base > 0.0 && base < size.y, "baseline {base} inside height {}", size.y); +} + +/// Display and text style lay out differently (a sum's limits go above and below in display +/// style), so a host can match how it draws the equation. +#[test] +fn display_style_follows_the_host() { + let mk = |display: bool| { + let mut h = Harness::builder().with_size(vec2(600.0, 300.0)).build_ui_state( + |ui, ed: &mut EquationEditor| { + ed.show(ui); + }, + EquationEditor::with_linear("sum_(i=1)^n i").inline(true).font_size(24.0).display_style(display).id_salt("style-test"), + ); + h.run_steps(3); + h.state().content_size().expect("drew") + }; + let (text, display) = (mk(false), mk(true)); + assert!(display.y > text.y + 1.0, "limits stack in display style: {text:?} vs {display:?}"); +} diff --git a/crates/equashion-egui/tests/shared_context.rs b/crates/equashion-egui/tests/shared_context.rs new file mode 100644 index 00000000..f8c9c224 --- /dev/null +++ b/crates/equashion-egui/tests/shared_context.rs @@ -0,0 +1,116 @@ +//! The widget on a context it shares with a host: it must not depend on, or change, the +//! host's fonts and style. WordCraft's dialog used `FontFamily::Name("semibold")`, which panics +//! on any context that never registered that family; the widget must work with and without it. + +use egui::{FontFamily, FontId, Key, vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use equashion_egui::EquationEditor; + +const SEMIBOLD: &str = "semibold"; + +struct Host { + editor: EquationEditor, + /// Install fonts with a `semibold` family in the first frame (as WordCraft's theme does). + register: bool, + frames: u32, + dark: bool, +} + +/// Both themes' styles and the active theme (what `set_style` / `set_visuals` would change). +fn styles(ctx: &egui::Context) -> (egui::Style, egui::Style, egui::Theme) { + ((*ctx.style_of(egui::Theme::Dark)).clone(), (*ctx.style_of(egui::Theme::Light)).clone(), ctx.theme()) +} + +fn has_semibold(ctx: &egui::Context) -> bool { + ctx.fonts(|f| f.definitions().families.contains_key(&FontFamily::Name(SEMIBOLD.into()))) +} + +fn harness(register: bool, dark: bool) -> Harness<'static, Host> { + Harness::builder().with_size(vec2(820.0, 760.0)).build_ui_state( + |ui, h: &mut Host| { + h.frames += 1; + if h.frames == 1 { + ui.ctx().set_theme(if h.dark { egui::Theme::Dark } else { egui::Theme::Light }); + if h.register { + let mut defs = egui::FontDefinitions::default(); + let first = defs.families.get(&FontFamily::Proportional).and_then(|v| v.first().cloned()).unwrap_or_default(); + defs.families.insert(FontFamily::Name(SEMIBOLD.into()), vec![first]); + ui.ctx().set_fonts(defs); + } + return; + } + if h.register { + // The host's own text in its named family keeps working next to the widget. + ui.label(egui::RichText::new("Host heading").font(FontId::new(14.0, FontFamily::Name(SEMIBOLD.into())))); + } + h.editor.show(ui); + }, + Host { editor: EquationEditor::new(), register, frames: 0, dark }, + ) +} + +/// Every part of the widget: all palette tabs, typing, a palette insert, the linear field, +/// the discard prompt. +fn exercise(h: &mut Harness<'static, Host>) { + h.run(); + h.run(); + h.run(); + for tab in ["Greek", "Operators", "Structures"] { + h.get_by_label(tab).click(); + h.run(); + } + h.event(egui::Event::Text("x^2".into())); + h.run(); + h.get_by_label("a/b").click_accesskit(); + h.run(); + h.event(egui::Event::Text("\\nabla\\oint".into())); + h.run(); + h.key_press(Key::Escape); + h.run(); + assert!(h.state().editor.message().is_some(), "the discard prompt is drawn"); + h.get_by_role(egui::accesskit::Role::TextInput).click(); + h.run(); + h.event(egui::Event::Text("+1".into())); + h.run(); + assert!(h.state().editor.linear().contains('1'), "{}", h.state().editor.linear()); +} + +#[test] +fn works_on_a_context_that_never_registered_a_named_family() { + let mut h = harness(false, false); + h.run(); + assert!(!has_semibold(&h.ctx)); + let style = styles(&h.ctx); + let fonts = h.ctx.fonts(|f| f.definitions().clone()); + exercise(&mut h); + assert!(!has_semibold(&h.ctx), "the widget registered no font family"); + assert_eq!(styles(&h.ctx), style, "the widget did not change the style or visuals"); + assert!(h.ctx.fonts(|f| f.definitions() == &fonts), "the widget did not change the fonts"); +} + +#[test] +fn works_on_a_context_where_the_host_registered_a_named_family() { + let mut h = harness(true, true); + h.run(); + h.run(); + assert!(has_semibold(&h.ctx)); + let style = styles(&h.ctx); + let fonts = h.ctx.fonts(|f| f.definitions().clone()); + exercise(&mut h); + assert!(has_semibold(&h.ctx), "the host's family is still there"); + assert_eq!(styles(&h.ctx), style, "the widget did not change the style or visuals"); + assert!(h.ctx.fonts(|f| f.definitions() == &fonts), "the widget did not change the fonts"); + h.get_by_label("Host heading"); +} + +/// The control: what the test above guards against does panic. A widget that asks for a named +/// family the host never registered (as WordCraft's `theme::semibold` did) takes the app down. +#[test] +#[should_panic(expected = "not bound")] +fn a_named_family_the_host_never_registered_panics() { + let mut h = Harness::builder().with_size(vec2(400.0, 200.0)).build_ui(|ui| { + ui.label(egui::RichText::new("Fractions").font(FontId::new(11.0, FontFamily::Name(SEMIBOLD.into())))); + }); + h.run(); +} diff --git a/crates/equashion-font/Cargo.toml b/crates/equashion-font/Cargo.toml new file mode 100644 index 00000000..820c4bbd --- /dev/null +++ b/crates/equashion-font/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "equashion-font" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "equaSHion fonts: font database (bundled OFL + optional system fonts), metrics, glyph outlines, shaping" + +[lints.rust] +unsafe_code = "forbid" + +[lints.clippy] +dbg_macro = "warn" +todo = "warn" +too_many_arguments = "allow" +type_complexity = "allow" +unwrap_used = "deny" +expect_used = "deny" +panic = "deny" +unimplemented = "deny" +unreachable = "deny" + +[features] +default = ["extra-fonts"] +# Source Serif 4 Bold and Bold Italic, Inter and STIX Two Math. Without it only Source Serif 4 +# Regular and Italic are compiled in (a UI that loads the rest at run time with `add_font`). +extra-fonts = [] + +[dependencies] +kurbo = { workspace = true } +skrifa = { workspace = true } +harfrust = { workspace = true } +unicode-bidi = { workspace = true } +log = { workspace = true } diff --git a/crates/equashion-font/src/fontdb.rs b/crates/equashion-font/src/fontdb.rs new file mode 100644 index 00000000..2bb63e2a --- /dev/null +++ b/crates/equashion-font/src/fontdb.rs @@ -0,0 +1,862 @@ +//! Font database: bundled OFL fonts, user fonts, the installed system fonts (cataloged the first +//! time a lookup needs them, native only; never for [`FontDb::bundled_only`]), outline cache. + +use std::collections::HashMap; +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::{Arc, Mutex, RwLock}; + +use kurbo::BezPath; +use skrifa::instance::{Location, LocationRef, Size}; +use skrifa::outline::{DrawSettings, OutlinePen}; +use skrifa::raw::FileRef; +use skrifa::string::StringId; +use skrifa::{GlyphId, MetadataProvider}; + +/// The family used when a requested family is unknown: the bundled default equation family. +pub const FALLBACK_FAMILY: &str = crate::DEFAULT_FAMILY; + +/// The bundled fonts (OFL), as font file bytes: Source Serif 4 in its four styles, Inter +/// Regular, then STIX Two Math Regular. STIX comes last so that it is only the fallback for +/// characters the text faces lack (`∇`, `∮`, `⌈⌉⌊⌋`, `⟨⟩`, ...): the fallback order is the +/// default family first, then load order. +pub fn bundled() -> &'static [&'static [u8]] { + BUNDLED +} + +#[cfg(not(feature = "extra-fonts"))] +static BUNDLED: &[&[u8]] = + &[include_bytes!("../../../assets/fonts/SourceSerif4-Regular.ttf"), include_bytes!("../../../assets/fonts/SourceSerif4-It.ttf")]; + +#[cfg(feature = "extra-fonts")] +static BUNDLED: &[&[u8]] = &[ + include_bytes!("../../../assets/fonts/SourceSerif4-Regular.ttf"), + include_bytes!("../../../assets/fonts/SourceSerif4-It.ttf"), + include_bytes!("../../../assets/fonts/SourceSerif4-Bold.ttf"), + include_bytes!("../../../assets/fonts/SourceSerif4-BoldIt.ttf"), + include_bytes!("../../../assets/fonts/Inter-Regular.ttf"), + include_bytes!("../../../assets/fonts/STIXTwoMath-Regular.otf"), +]; + +#[derive(Clone)] +enum FontBytes { + Static(&'static [u8]), + Owned(Arc>), +} + +/// One loaded font face. +pub struct FontFace { + id: u32, + /// Typographic family name (e.g. "Source Sans 3"). + pub family: String, + /// Typographic style name (e.g. "Semibold", "Italic"). + pub style: String, + /// usWeightClass-style weight (400 = regular). + pub weight: f32, + pub italic: bool, + bytes: FontBytes, + index: u32, + pub upem: f64, + /// Ascender in font units (positive = up). + pub ascent: f64, + /// Descender in font units (positive = down). + pub descent: f64, + /// Cap height and x height in font units (estimated from the ascent when the font has no OS/2 + /// values). + pub cap_height: f64, + pub x_height: f64, + pub shaper: harfrust::ShaperData, + /// Variable fonts: the named instance's axis settings (user units; empty = default instance), + /// the normalised location and the shaper's view of it. + pub coords: Vec<([u8; 4], f32)>, + location: Location, + pub instance: Option, + /// Basic Multilingual Plane coverage bitset, built on first use. + bmp: std::sync::OnceLock>, +} + +/// A plain, serialisable handle to a face loaded in a [`FontDb`] (its process-unique id). It +/// replaces WordCraft's leaked `FaceRef`: a scene names faces by id and the backend resolves them +/// with [`FontDb::face_by_id`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct FaceId(pub u32); + +impl std::fmt::Debug for FontFace { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "FontFace({} {})", self.family, self.style) + } +} + +impl FontFace { + pub fn data(&self) -> &[u8] { + match &self.bytes { + FontBytes::Static(b) => b, + FontBytes::Owned(v) => v.as_slice(), + } + } + pub fn skrifa(&self) -> Option> { + skrifa::FontRef::from_index(self.data(), self.index).ok() + } + pub fn hb(&self) -> Option> { + harfrust::FontRef::from_index(self.data(), self.index).ok() + } + /// Where in the design space glyphs, metrics and outlines come from. + pub fn location(&self) -> LocationRef<'_> { + (&self.location).into() + } + /// Is this a named instance of a variable font? + pub fn is_variable(&self) -> bool { + !self.coords.is_empty() + } + /// Face index within the font file (collections); 0 for plain fonts. + pub fn index(&self) -> u32 { + self.index + } + /// Unique id of this face within the process. + pub fn id(&self) -> u32 { + self.id + } + /// [`FontFace::id`] as a [`FaceId`] handle. + pub fn face_id(&self) -> FaceId { + FaceId(self.id) + } + /// Does the face map `c` to a glyph? + pub fn covers(&self, c: char) -> bool { + let cp = c as u32; + if cp < 0x1_0000 { + let bits = self.bmp.get_or_init(|| { + let mut b = vec![0u64; 1024].into_boxed_slice(); + if let Some(f) = self.skrifa() { + for (cp, _) in f.charmap().mappings() { + if cp < 0x1_0000 { + b[(cp / 64) as usize] |= 1 << (cp % 64); + } + } + } + b + }); + return bits[(cp / 64) as usize] & (1 << (cp % 64)) != 0; + } + self.skrifa().is_some_and(|f| f.charmap().map(c).is_some()) + } + /// Units per em. + pub fn units_per_em(&self) -> f64 { + self.upem + } + /// (ascent, descent) in font units, both positive. + pub fn vertical_metrics(&self) -> (f64, f64) { + (self.ascent, self.descent) + } + /// Every mapped character and its glyph id, sorted by code point (the Glyphs panel). + pub fn chars(&self) -> Vec<(char, u32)> { + let Some(f) = self.skrifa() else { return vec![] }; + let mut v: Vec<(char, u32)> = f.charmap().mappings().filter_map(|(cp, g)| char::from_u32(cp).map(|c| (c, g.to_u32()))).collect(); + v.sort_unstable_by_key(|x| x.0); + v.dedup_by_key(|x| x.0); + v + } + /// Advance width of glyph `gid` in font units. + pub fn advance(&self, gid: u32) -> f64 { + self.skrifa() + .and_then(|f| f.glyph_metrics(Size::unscaled(), self.location()).advance_width(GlyphId::new(gid))) + .map(|a| a as f64) + .unwrap_or(self.upem * 0.5) + } + /// Glyph id for `c` (0 = .notdef). + pub fn glyph_for(&self, c: char) -> u32 { + self.skrifa().and_then(|f| f.charmap().map(c)).map(|g| g.to_u32()).unwrap_or(0) + } +} + +#[cfg(not(target_arch = "wasm32"))] +#[derive(Clone, Debug)] +struct CatalogEntry { + family: String, + style: String, + path: std::path::PathBuf, +} + +/// Process-wide font database. +pub struct FontDb { + faces: RwLock>>, + outlines: Mutex>>, + #[cfg(not(target_arch = "wasm32"))] + catalog: RwLock>, + /// The folders the system font scan reads (the platform's font folders for [`FontDb::global`]). + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] + font_dirs: Vec, + /// Set once the font folders have been scanned. Lookups by family name wait for the first + /// scan, so what they find doesn't depend on what ran before them. + #[cfg(not(target_arch = "wasm32"))] + cataloged: std::sync::OnceLock<()>, + /// System fallback state: enabled, and characters no system font covers. + #[cfg(not(target_arch = "wasm32"))] + sys: Mutex, +} + +#[cfg(not(target_arch = "wasm32"))] +#[derive(Default)] +struct SysFallback { + enabled: bool, + misses: std::collections::HashSet, +} + +/// Families tried (when installed) for characters the loaded fonts lack: CJK, symbols, emoji. +#[cfg(not(target_arch = "wasm32"))] +const SYSTEM_FALLBACKS: &[&str] = &[ + "Helvetica Neue", + "Arial", + "Segoe UI", + "Noto Sans", + "DejaVu Sans", + "PingFang SC", + "Hiragino Sans", + "Hiragino Kaku Gothic ProN", + "Apple SD Gothic Neo", + "Heiti SC", + "STHeiti", + "Microsoft YaHei", + "Yu Gothic", + "Malgun Gothic", + "Noto Sans CJK SC", + "Noto Sans CJK JP", + "Arial Unicode MS", + "Apple Symbols", + "Segoe UI Symbol", + "Noto Sans Symbols", + "Noto Sans Symbols2", + "Noto Emoji", + "Segoe UI Emoji", + "Apple Color Emoji", + "Noto Color Emoji", +]; + +static NEXT_ID: AtomicU32 = AtomicU32::new(1); +const OUTLINE_CACHE_MAX: usize = 50_000; + +fn name(font: &skrifa::FontRef<'_>, ids: &[StringId]) -> Option { + ids.iter().find_map(|id| font.localized_strings(*id).english_or_first().map(|s| s.to_string()).filter(|s| !s.is_empty())) +} + +/// A face's family and style names (the default instance's style for a variable font). +fn face_names(f: &skrifa::FontRef<'_>) -> Option<(String, String)> { + let family = name(f, &[StringId::TYPOGRAPHIC_FAMILY_NAME, StringId::FAMILY_NAME])?; + let style = name(f, &[StringId::TYPOGRAPHIC_SUBFAMILY_NAME, StringId::SUBFAMILY_NAME]).unwrap_or_else(|| "Regular".into()); + Some((family, style)) +} + +/// (family, style) of every face in the font file at `path`, reading only its table directories +/// and `name` tables: a scan opens hundreds of font files, many of them megabytes long. A +/// variable font is cataloged under its default style; its named instances appear once it loads. +#[cfg(not(target_arch = "wasm32"))] +fn file_face_names(path: &std::path::Path) -> Vec<(String, String)> { + use std::io::{Read, Seek, SeekFrom}; + /// Caps on what a (possibly damaged) file can make the scan read. + const MAX_FACES: u32 = 256; + const MAX_NAME_TABLE: u32 = 1 << 20; + let Ok(mut file) = std::fs::File::open(path) else { return vec![] }; + let mut read_at = |offset: u64, len: usize| -> Option> { + let mut buf = vec![0; len]; + file.seek(SeekFrom::Start(offset)).ok()?; + file.read_exact(&mut buf).ok()?; + Some(buf) + }; + let be32 = |b: &[u8], at: usize| b.get(at..at + 4).and_then(|s| s.try_into().ok()).map(u32::from_be_bytes); + let Some(head) = read_at(0, 12) else { return vec![] }; + // A collection lists where each face's table directory starts. + let starts: Vec = if head.starts_with(b"ttcf") { + let n = be32(&head, 8).unwrap_or(0).min(MAX_FACES) as usize; + read_at(12, n * 4).map(|b| b.as_chunks::<4>().0.iter().map(|c| u32::from_be_bytes(*c)).collect()).unwrap_or_default() + } else { + vec![0] + }; + starts + .into_iter() + .filter_map(|start| { + let dir = read_at(start.into(), 12)?; + let tables = u16::from_be_bytes(dir.get(4..6)?.try_into().ok()?) as usize; + let records = read_at(u64::from(start) + 12, tables * 16)?; + let rec = records.as_chunks::<16>().0.iter().find(|r| r.starts_with(b"name"))?; + let (offset, len) = (be32(rec, 8)?, be32(rec, 12)?); + if len > MAX_NAME_TABLE { + return None; + } + let table = read_at(offset.into(), len as usize)?; + // A one-table font holding just the `name` table, to read it as the font itself would. + let mut font = Vec::with_capacity(28 + table.len()); + font.extend_from_slice(&0x0001_0000_u32.to_be_bytes()); + font.extend_from_slice(&[0, 1, 0, 16, 0, 0, 0, 0]); + font.extend_from_slice(b"name"); + for v in [0, 28, len] { + font.extend_from_slice(&u32::to_be_bytes(v)); + } + font.extend_from_slice(&table); + face_names(&skrifa::FontRef::new(&font).ok()?) + }) + .collect() +} + +/// The platform's font folders (the system's and the user's), scanned by [`FontDb::global`]. +/// Empty on wasm. +pub fn system_font_dirs() -> Vec { + let mut dirs: Vec = Vec::new(); + if cfg!(target_arch = "wasm32") { + return dirs; + } + let home = std::env::var_os("HOME").map(std::path::PathBuf::from); + if cfg!(target_os = "macos") { + dirs.extend(["/System/Library/Fonts", "/Library/Fonts"].map(Into::into)); + if let Some(h) = &home { + dirs.push(h.join("Library/Fonts")); + } + // Fonts macOS ships as assets (PingFang, Yu Mincho, …): com_apple_MobileAsset_Font. + for parent in ["/System/Library/AssetsV2", "/System/Library/AssetsV2/PreinstalledAssetsV2/InstallWithOs"] { + let Ok(entries) = std::fs::read_dir(parent) else { continue }; + for e in entries.flatten() { + if e.file_name().to_string_lossy().starts_with("com_apple_MobileAsset_Font") { + dirs.push(e.path()); + } + } + } + } else if cfg!(windows) { + let root = std::env::var_os("WINDIR").map(std::path::PathBuf::from).unwrap_or_else(|| "C:\\Windows".into()); + dirs.push(root.join("Fonts")); + if let Some(l) = std::env::var_os("LOCALAPPDATA") { + dirs.push(std::path::PathBuf::from(l).join("Microsoft\\Windows\\Fonts")); + } + } else { + dirs.extend(["/usr/share/fonts", "/usr/local/share/fonts"].map(Into::into)); + if let Some(h) = &home { + dirs.push(h.join(".fonts")); + dirs.push(h.join(".local/share/fonts")); + } + } + dirs +} + +/// One face found in a font file: index, family, style and (variable fonts) the named instance's +/// axis settings. +type Found = (u32, String, String, Vec<([u8; 4], f32)>); + +/// Parse every face in `data` (a font file or collection); a variable font yields one face per +/// named instance (InDesign lists them as styles). +fn enumerate_faces(data: &[u8]) -> Vec { + let count = match FileRef::new(data) { + Ok(FileRef::Font(_)) => 1, + Ok(FileRef::Collection(c)) => c.len(), + Err(_) => 0, + }; + let mut out = Vec::new(); + for i in 0..count { + let Ok(f) = skrifa::FontRef::from_index(data, i) else { continue }; + let Some((family, style)) = face_names(&f) else { continue }; + let axes = f.axes(); + let mut named = Vec::new(); + for ni in f.named_instances().iter() { + let Some(style) = name(&f, &[ni.subfamily_name_id()]) else { continue }; + if named.iter().any(|(_, s, _): &(u32, String, _)| s.eq_ignore_ascii_case(&style)) { + continue; + } + let coords: Vec<([u8; 4], f32)> = axes.iter().zip(ni.user_coords()).map(|(a, v)| (a.tag().to_be_bytes(), v)).collect(); + named.push((i, style, coords)); + } + if named.is_empty() { + out.push((i, family, style, Vec::new())); + } else { + out.extend(named.into_iter().map(|(i, s, c)| (i, family.clone(), s, c))); + } + } + out +} + +/// The compiled-in Source Serif 4 Regular, for a database that somehow has no faces at all. +pub(crate) fn last_resort_face() -> Arc { + static FACE: std::sync::OnceLock> = std::sync::OnceLock::new(); + FACE.get_or_init(|| { + // The font is compiled in (`include_bytes!`), so parsing it can't depend on input; the + // `last_resort_face_parses` test proves it on every run. + #[allow(clippy::expect_used)] + let face = make_face(FontBytes::Static(BUNDLED[0]), 0, FALLBACK_FAMILY.into(), "Regular".into(), Vec::new()) + .expect("the compiled-in Source Serif 4 Regular parses"); + Arc::new(face) + }) + .clone() +} + +fn make_face(bytes: FontBytes, index: u32, family: String, style: String, coords: Vec<([u8; 4], f32)>) -> Option { + let data: &[u8] = match &bytes { + FontBytes::Static(b) => b, + FontBytes::Owned(v) => v.as_slice(), + }; + let f = skrifa::FontRef::from_index(data, index).ok()?; + let settings: Vec<(skrifa::Tag, f32)> = coords.iter().map(|(t, v)| (skrifa::Tag::new(t), *v)).collect(); + let location = if coords.is_empty() { Location::default() } else { f.axes().location(settings.iter().copied()) }; + let m = f.metrics(Size::unscaled(), &location); + let a = f.attributes(); + let hb = harfrust::FontRef::from_index(data, index).ok()?; + let shaper = harfrust::ShaperData::new(&hb); + let instance = (!coords.is_empty()) + .then(|| harfrust::ShaperInstance::from_variations(&hb, settings.iter().map(|(t, v)| harfrust::Variation { tag: *t, value: *v }))); + let axis = |tag: &[u8; 4]| coords.iter().find(|(t, _)| t == tag).map(|(_, v)| *v); + let weight = axis(b"wght").unwrap_or(if coords.is_empty() { a.weight.value() } else { style_weight(&style) }); + let italic = axis(b"ital") + .map(|v| v >= 0.5) + .or(axis(b"slnt").map(|v| v.abs() > 0.1)) + .unwrap_or(!matches!(a.style, skrifa::attribute::Style::Normal) || style_italic(&style)); + Some(FontFace { + id: NEXT_ID.fetch_add(1, Ordering::Relaxed), + family, + style, + weight, + italic, + upem: m.units_per_em.max(1) as f64, + ascent: m.ascent as f64, + descent: -(m.descent as f64), + cap_height: m.cap_height.map(|v| v as f64).filter(|v| *v > 0.0).unwrap_or(m.ascent as f64 * 0.72), + x_height: m.x_height.map(|v| v as f64).filter(|v| *v > 0.0).unwrap_or(m.ascent as f64 * 0.5), + shaper, + coords, + location, + instance, + bytes, + index, + bmp: std::sync::OnceLock::new(), + }) +} + +/// The named style part of a style with axis settings (`Bold {wght:650}` → `Bold`). +pub fn base_style(style: &str) -> &str { + style.split('{').next().unwrap_or(style).trim() +} + +fn norm(s: &str) -> String { + s.chars().filter(|c| c.is_alphanumeric()).flat_map(|c| c.to_lowercase()).collect() +} + +/// Weight implied by a style name. +fn style_weight(style: &str) -> f32 { + let s = norm(style); + const TABLE: &[(&str, f32)] = &[ + ("extralight", 200.0), + ("ultralight", 200.0), + ("semibold", 600.0), + ("demibold", 600.0), + ("extrabold", 800.0), + ("ultrabold", 800.0), + ("hairline", 100.0), + ("thin", 100.0), + ("light", 300.0), + ("medium", 500.0), + ("bold", 700.0), + ("black", 900.0), + ("heavy", 900.0), + ]; + TABLE.iter().find(|(k, _)| s.contains(k)).map(|(_, w)| *w).unwrap_or(400.0) +} + +fn style_italic(style: &str) -> bool { + let s = norm(style); + s.contains("italic") || s.contains("oblique") || s == "it" +} + +impl FontDb { + /// A database holding the bundled fonts, whose system font scan reads `font_dirs`. + pub fn with_font_dirs(font_dirs: Vec) -> Self { + let mut faces = Vec::new(); + for data in BUNDLED.iter().copied() { + for (i, family, style, coords) in enumerate_faces(data) { + if let Some(f) = make_face(FontBytes::Static(data), i, family, style, coords) { + faces.push(Arc::new(f)); + } + } + } + Self { + faces: RwLock::new(faces), + outlines: Mutex::new(HashMap::new()), + #[cfg(not(target_arch = "wasm32"))] + catalog: RwLock::new(Vec::new()), + font_dirs, + #[cfg(not(target_arch = "wasm32"))] + cataloged: std::sync::OnceLock::new(), + #[cfg(not(target_arch = "wasm32"))] + sys: Mutex::new(SysFallback { enabled: true, ..Default::default() }), + } + } + + /// A database of the bundled fonts only: no font folders, no system fallback. Deterministic + /// and offline, so tests and goldens give the same result on every machine. + pub fn bundled_only() -> Self { + let db = FontDb::with_font_dirs(Vec::new()); + db.set_system_fallback(false); + db + } + + /// The loaded face with id `id` (see [`FontFace::face_id`]). + pub fn face_by_id(&self, id: FaceId) -> Option> { + self.read_faces().iter().find(|f| f.id == id.0).cloned() + } + + /// Enable or disable the lazy system-font fallback for characters the loaded fonts lack + /// (native only; on by default). + pub fn set_system_fallback(&self, on: bool) { + #[cfg(not(target_arch = "wasm32"))] + { + self.sys.lock().unwrap_or_else(|e| e.into_inner()).enabled = on; + } + #[cfg(target_arch = "wasm32")] + let _ = on; + } + + /// Process-wide database: the bundled fonts, then the installed system fonts, cataloged the + /// first time a lookup by family name needs them (or ahead of time by + /// [`FontDb::scan_in_background`]). + pub fn global() -> &'static FontDb { + static DB: std::sync::OnceLock = std::sync::OnceLock::new(); + DB.get_or_init(|| FontDb::with_font_dirs(system_font_dirs())) + } + + fn read_faces(&self) -> std::sync::RwLockReadGuard<'_, Vec>> { + self.faces.read().unwrap_or_else(|e| e.into_inner()) + } + + /// The system font catalog, scanned first if it hasn't been yet. + #[cfg(not(target_arch = "wasm32"))] + fn read_catalog(&self) -> std::sync::RwLockReadGuard<'_, Vec> { + self.ensure_catalog(); + self.catalog.read().unwrap_or_else(|e| e.into_inner()) + } + + /// Family names available (loaded plus cataloged system fonts), sorted and deduplicated. + pub fn families(&self) -> Vec { + let mut v: Vec = self.read_faces().iter().map(|f| f.family.clone()).collect(); + #[cfg(not(target_arch = "wasm32"))] + v.extend(self.read_catalog().iter().map(|c| c.family.clone())); + v.sort_by_key(|a| a.to_lowercase()); + v.dedup_by(|a, b| a.eq_ignore_ascii_case(b)); + v + } + + /// Style names available for `family` (Regular first, then by weight). + pub fn styles(&self, family: &str) -> Vec { + let mut v: Vec<(bool, f32, String)> = self + .read_faces() + .iter() + .filter(|f| f.family.eq_ignore_ascii_case(family) && !f.style.contains('{')) + .map(|f| (f.italic, f.weight, f.style.clone())) + .collect(); + #[cfg(not(target_arch = "wasm32"))] + for c in self.read_catalog().iter() { + if c.family.eq_ignore_ascii_case(family) && !v.iter().any(|(_, _, s)| s.eq_ignore_ascii_case(&c.style)) { + v.push((style_italic(&c.style), style_weight(&c.style), c.style.clone())); + } + } + v.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.total_cmp(&b.1)).then(a.2.cmp(&b.2))); + v.dedup_by(|a, b| a.2 == b.2); + v.into_iter().map(|t| t.2).collect() + } + + /// Add a user font (TTF/OTF/TTC bytes). Returns the number of faces added (0 if unparseable or + /// every face was already present). + pub fn add_font(&self, bytes: Vec) -> usize { + let data = Arc::new(bytes); + let mut added = 0; + for (i, family, style, coords) in enumerate_faces(&data) { + if self.read_faces().iter().any(|f| f.family.eq_ignore_ascii_case(&family) && f.style.eq_ignore_ascii_case(&style)) { + continue; + } + if let Some(f) = make_face(FontBytes::Owned(data.clone()), i, family, style, coords) { + self.faces.write().unwrap_or_else(|e| e.into_inner()).push(Arc::new(f)); + added += 1; + } + } + added + } + + /// Catalog the installed fonts unless that has been done: the first caller scans the font + /// folders, any other waits for that scan to finish. + #[cfg(not(target_arch = "wasm32"))] + fn ensure_catalog(&self) { + self.cataloged.get_or_init(|| { + self.scan_font_dirs(); + }); + } + + /// Catalog the installed fonts on a background thread, so the first lookup by family name + /// (opening a file, the font menus) doesn't wait for the scan. A no-op once they are + /// cataloged, and on wasm. + pub fn scan_in_background(&'static self) { + #[cfg(not(target_arch = "wasm32"))] + if self.cataloged.get().is_none() { + // A failed spawn leaves the scan to the first lookup that needs it. + let _ = std::thread::Builder::new().name("font-scan".into()).spawn(move || self.ensure_catalog()); + } + } + + /// Scan the font folders again (fonts installed or removed since), cataloging the faces + /// found (native only). Font data is loaded when a cataloged family is first resolved. + /// Returns the number of faces cataloged. + #[cfg(not(target_arch = "wasm32"))] + pub fn load_system_fonts(&self) -> usize { + // The first scan, or a rescan once it is done (never both at once). + let mut first = None; + self.cataloged.get_or_init(|| first = Some(self.scan_font_dirs())); + first.unwrap_or_else(|| self.scan_font_dirs()) + } + + #[cfg(not(target_arch = "wasm32"))] + fn scan_font_dirs(&self) -> usize { + let mut found = Vec::new(); + let mut stack = self.font_dirs.clone(); + // Each folder once, however links lead back to it. + let mut visited = std::collections::HashSet::new(); + while let Some(d) = stack.pop() { + if !visited.insert(std::fs::canonicalize(&d).unwrap_or_else(|_| d.clone())) { + continue; + } + let Ok(rd) = std::fs::read_dir(&d) else { continue }; + for e in rd.flatten() { + let p = e.path(); + if p.is_dir() { + stack.push(p); + continue; + } + let ext = p.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()); + if !matches!(ext.as_deref(), Some("ttf" | "otf" | "ttc" | "otc")) { + continue; + } + for (family, style) in file_face_names(&p) { + found.push(CatalogEntry { family, style, path: p.clone() }); + } + } + } + let n = found.len(); + log::debug!("cataloged {n} system font faces"); + *self.catalog.write().unwrap_or_else(|e| e.into_inner()) = found; + n + } + + /// Load the files of the installed `family`. Returns whether any face was added. + #[cfg(not(target_arch = "wasm32"))] + fn load_cataloged(&self, family: &str) -> bool { + let paths: Vec = { + let cat = self.read_catalog(); + let mut p: Vec<_> = cat.iter().filter(|c| c.family.eq_ignore_ascii_case(family)).map(|c| c.path.clone()).collect(); + p.sort(); + p.dedup(); + p + }; + let mut any = false; + for p in paths { + if let Ok(data) = std::fs::read(&p) { + any |= self.add_font(data) > 0; + } + } + any + } + + /// Resolve a family + style to a face, falling back to the closest style of the family, then to + /// [`FALLBACK_FAMILY`]. Installed system fonts are found by name whatever ran before. + pub fn face(&self, family: &str, style: &str) -> Arc { + if let Some(f) = self.find(family, style) { + return f; + } + #[cfg(not(target_arch = "wasm32"))] + if self.load_cataloged(family) + && let Some(f) = self.find(family, style) + { + return f; + } + self.find(FALLBACK_FAMILY, style) + .or_else(|| self.find(FALLBACK_FAMILY, "Regular")) + .or_else(|| self.read_faces().first().cloned()) + .unwrap_or_else(last_resort_face) + } + + /// Is `family` available (loaded, or installed on the system)? + pub fn has_family(&self, family: &str) -> bool { + if self.is_loaded(family) { + return true; + } + #[cfg(not(target_arch = "wasm32"))] + if self.read_catalog().iter().any(|c| c.family.eq_ignore_ascii_case(family)) { + return true; + } + false + } + + fn is_loaded(&self, family: &str) -> bool { + self.read_faces().iter().any(|f| f.family.eq_ignore_ascii_case(family)) + } + + /// A variable font's axes: (tag, name, min, default, max), empty for static fonts. + pub fn axes(&self, family: &str, style: &str) -> Vec<(String, String, f32, f32, f32)> { + let face = self.face(family, base_style(style)); + let Some(f) = face.skrifa() else { return vec![] }; + f.axes() + .iter() + .map(|a| { + let tag = String::from_utf8_lossy(&a.tag().to_be_bytes()).to_string(); + let name = f.localized_strings(a.name_id()).english_or_first().map(|s| s.to_string()).unwrap_or_else(|| tag.clone()); + (tag, name, a.min_value(), a.default_value(), a.max_value()) + }) + .collect() + } + + /// `Style {wght:650,wdth:90}`: the named style's font at those axis values, made on first use. + fn instance(&self, family: &str, style: &str) -> Option> { + let open = style.find('{')?; + let base = self.find(family, style[..open].trim())?; + if base.skrifa().is_none_or(|f| f.axes().is_empty()) { + return Some(base); + } + let mut coords = base.coords.clone(); + for kv in style[open + 1..].trim_end_matches('}').split(',') { + let (k, v) = kv.split_once(':')?; + let k = k.trim().as_bytes(); + let v: f32 = v.trim().parse().ok()?; + if k.len() != 4 { + return None; + } + let tag = [k[0], k[1], k[2], k[3]]; + match coords.iter_mut().find(|(t, _)| *t == tag) { + Some(c) => c.1 = v, + None => coords.push((tag, v)), + } + } + let f = make_face(base.bytes.clone(), base.index, base.family.clone(), style.to_string(), coords)?; + let f = Arc::new(f); + self.faces.write().unwrap_or_else(|e| e.into_inner()).push(f.clone()); + Some(f) + } + + fn find(&self, family: &str, style: &str) -> Option> { + if style.contains('{') { + { + let faces = self.read_faces(); + if let Some(f) = faces.iter().find(|f| f.family.eq_ignore_ascii_case(family) && f.style == style) { + return Some(f.clone()); + } + } + return self.instance(family, style); + } + let faces = self.read_faces(); + let cands: Vec<&Arc> = faces.iter().filter(|f| f.family.eq_ignore_ascii_case(family)).collect(); + if cands.is_empty() { + return None; + } + let ns = norm(style); + if let Some(f) = cands.iter().find(|f| norm(&f.style) == ns) { + return Some((*f).clone()); + } + let (tw, ti) = (style_weight(style), style_italic(style)); + cands + .iter() + .min_by(|a, b| { + let sa = (a.weight - tw).abs() + if a.italic != ti { 1000.0 } else { 0.0 }; + let sb = (b.weight - tw).abs() + if b.italic != ti { 1000.0 } else { 0.0 }; + sa.total_cmp(&sb) + }) + .map(|f| (*f).clone()) + } + + /// First face (fallback family first, then load order) that covers `c`; on native, system + /// fonts are cataloged and loaded lazily the first time no loaded face covers a character. + pub fn fallback_for(&self, c: char, exclude: u32) -> Option> { + if let Some(f) = self.loaded_fallback(c, exclude) { + return Some(f); + } + #[cfg(not(target_arch = "wasm32"))] + if self.system_fallback(c) { + return self.loaded_fallback(c, exclude); + } + None + } + + fn loaded_fallback(&self, c: char, exclude: u32) -> Option> { + let faces = self.read_faces(); + let mut order: Vec<&Arc> = faces.iter().filter(|f| f.id != exclude).collect(); + order.sort_by_key(|f| (!f.family.eq_ignore_ascii_case(FALLBACK_FAMILY), f.italic, (f.weight - 400.0).abs() as i32)); + order.into_iter().find(|f| f.covers(c)).cloned() + } + + /// Load a system font covering `c` (preferred fallback families first, then any cataloged + /// file under 40 MB). Returns true if one was loaded. Misses are remembered. + #[cfg(not(target_arch = "wasm32"))] + fn system_fallback(&self, c: char) -> bool { + if c.is_control() || c.is_whitespace() { + return false; + } + { + let sys = self.sys.lock().unwrap_or_else(|e| e.into_inner()); + if !sys.enabled || sys.misses.contains(&c) { + return false; + } + } + let covered = |db: &FontDb| db.read_faces().iter().any(|f| f.covers(c)); + let cataloged: Vec = self.read_catalog().iter().map(|e| e.family.clone()).collect(); + for fam in SYSTEM_FALLBACKS { + if !self.is_loaded(fam) && cataloged.iter().any(|f| f.eq_ignore_ascii_case(fam)) { + self.load_cataloged(fam); + if covered(self) { + return true; + } + } + } + let mut paths: Vec = self.catalog.read().unwrap_or_else(|e| e.into_inner()).iter().map(|e| e.path.clone()).collect(); + paths.sort(); + paths.dedup(); + for p in paths { + if std::fs::metadata(&p).map(|m| m.len() > 40 << 20).unwrap_or(true) { + continue; + } + let Ok(data) = std::fs::read(&p) else { continue }; + let hit = + enumerate_faces(&data).iter().any(|(i, _, _, _)| skrifa::FontRef::from_index(&data, *i).is_ok_and(|f| f.charmap().map(c).is_some())); + if hit && self.add_font(data) > 0 && covered(self) { + return true; + } + } + self.sys.lock().unwrap_or_else(|e| e.into_inner()).misses.insert(c); + false + } + + /// Glyph outline in font units, y-down (flipped), cached per (face, glyph). + pub fn outline(&self, face: &FontFace, gid: u32) -> Arc { + let key = (face.id, gid); + if let Some(p) = self.outlines.lock().unwrap_or_else(|e| e.into_inner()).get(&key) { + return p.clone(); + } + let mut pen = FlipPen(BezPath::new()); + if let Some(f) = face.skrifa() + && let Some(g) = f.outline_glyphs().get(GlyphId::new(gid)) + { + let _ = g.draw(DrawSettings::unhinted(Size::unscaled(), face.location()), &mut pen); + } + let p = Arc::new(pen.0); + let mut cache = self.outlines.lock().unwrap_or_else(|e| e.into_inner()); + if cache.len() >= OUTLINE_CACHE_MAX { + cache.clear(); + } + cache.insert(key, p.clone()); + p + } +} + +struct FlipPen(BezPath); + +impl OutlinePen for FlipPen { + fn move_to(&mut self, x: f32, y: f32) { + self.0.move_to((x as f64, -y as f64)); + } + fn line_to(&mut self, x: f32, y: f32) { + self.0.line_to((x as f64, -y as f64)); + } + fn quad_to(&mut self, cx0: f32, cy0: f32, x: f32, y: f32) { + self.0.quad_to((cx0 as f64, -cy0 as f64), (x as f64, -y as f64)); + } + fn curve_to(&mut self, cx0: f32, cy0: f32, cx1: f32, cy1: f32, x: f32, y: f32) { + self.0.curve_to((cx0 as f64, -cy0 as f64), (cx1 as f64, -cy1 as f64), (x as f64, -y as f64)); + } + fn close(&mut self) { + self.0.close_path(); + } +} diff --git a/crates/equashion-font/src/lib.rs b/crates/equashion-font/src/lib.rs new file mode 100644 index 00000000..e7a1a54d --- /dev/null +++ b/crates/equashion-font/src/lib.rs @@ -0,0 +1,262 @@ +//! equaSHion fonts: the font database (bundled OFL families + optional user/system fonts), +//! vertical metrics, glyph outlines and OpenType shaping. +//! +//! Shaping here is style-agnostic: [`shape`] turns a string in one face into glyph ids, clusters +//! and advances in font units; `equashion-layout` applies sizes on top. [`FontDb::bundled_only`] +//! is the offline database (no system fonts) used by tests and goldens. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#![forbid(unsafe_code)] + +mod fontdb; + +pub use fontdb::{FALLBACK_FAMILY, FaceId, FontDb, FontFace, base_style, bundled, system_font_dirs}; +pub use harfrust::Feature; +use harfrust::{Direction, ShapeOptions, Tag, UnicodeBuffer}; +pub use kurbo::BezPath; +use skrifa::MetadataProvider; +use skrifa::instance::Size; + +/// The default equation family: bundled in Regular, Italic, Bold and Bold Italic, so the four +/// math variants never fall back to a different family. +pub const DEFAULT_FAMILY: &str = "Source Serif 4"; + +/// One shaped glyph, in font units. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ShapedGlyph { + pub gid: u32, + /// Byte offset (in the shaped string) of the cluster this glyph belongs to. + pub cluster: usize, + pub x_advance: i32, + pub x_offset: i32, + pub y_offset: i32, +} + +/// An OpenType feature setting: `"liga"`, `"-kern"`, `"ss01"`. +pub fn feature(tag: &str) -> Option { + let (on, t) = match tag.strip_prefix('-') { + Some(r) => (false, r), + None => (true, tag.strip_prefix('+').unwrap_or(tag)), + }; + let b = t.as_bytes(); + if b.len() != 4 { + return None; + } + Some(Feature::new(Tag::new(&[b[0], b[1], b[2], b[3]]), on as u32, ..)) +} + +/// A strong right-to-left character (Hebrew, Arabic, …)? +pub fn is_rtl(c: char) -> bool { + use unicode_bidi::BidiClass::{AL, R}; + matches!(unicode_bidi::bidi_class(c), R | AL) +} + +/// Maximal runs of one direction (neutrals join the run they're in): (byte range, right-to-left). +fn direction_runs(text: &str) -> Vec<(std::ops::Range, bool)> { + let mut runs: Vec<(std::ops::Range, bool)> = Vec::new(); + let mut cur: Option = None; + let mut start = 0; + for (i, c) in text.char_indices() { + let strong = if is_rtl(c) { + Some(true) + } else if unicode_bidi::bidi_class(c) == unicode_bidi::BidiClass::L { + Some(false) + } else { + None + }; + match (cur, strong) { + (None, Some(d)) => cur = Some(d), + (Some(a), Some(d)) if a != d => { + runs.push((start..i, a)); + start = i; + cur = Some(d); + } + _ => {} + } + } + runs.push((start..text.len(), cur.unwrap_or(false))); + runs +} + +/// Shape `text` with `face`. `chars` lets callers substitute characters (e.g. uppercase for All +/// Caps) while keeping clusters pointing into the original string. Glyphs come in logical +/// order: right-to-left runs are shaped right to left, then their clusters put back in text +/// order (each cluster's glyphs keep the shaper's order); line layout reorders them visually. +pub fn shape(face: &FontFace, text: &str, features: &[Feature], map: impl Fn(char) -> char) -> Vec { + if !text.chars().any(is_rtl) { + return shape_dir(face, text, features, &map, false); + } + let mut out = Vec::with_capacity(text.len()); + for (r, rtl) in direction_runs(text) { + let mut g = shape_dir(face, &text[r.clone()], features, &map, rtl); + for x in &mut g { + x.cluster += r.start; + } + if rtl { + // Visual (clusters descending) → logical, cluster by cluster. + let mut groups: Vec> = Vec::new(); + for x in g { + match groups.last_mut() { + Some(last) if last[0].cluster == x.cluster => last.push(x), + _ => groups.push(vec![x]), + } + } + groups.sort_by_key(|grp| grp[0].cluster); + g = groups.into_iter().flatten().collect(); + } + out.extend(g); + } + out +} + +fn shape_dir(face: &FontFace, text: &str, features: &[Feature], map: &impl Fn(char) -> char, rtl: bool) -> Vec { + let mut out = Vec::with_capacity(text.len()); + let shaped = face.hb().map(|hb| { + let shaper = face.shaper.shaper(&hb).instance(face.instance.as_ref()).build(); + let mut buf = UnicodeBuffer::new(); + for (i, c) in text.char_indices() { + buf.add(map(c), i as u32); + } + buf.guess_segment_properties(); + buf.set_direction(if rtl { Direction::RightToLeft } else { Direction::LeftToRight }); + let gb = shaper.shape(buf, ShapeOptions::new().features(features)); + for (info, pos) in gb.glyph_infos().iter().zip(gb.glyph_positions()) { + out.push(ShapedGlyph { + gid: info.glyph_id, + cluster: info.cluster as usize, + x_advance: pos.x_advance, + x_offset: pos.x_offset, + y_offset: pos.y_offset, + }); + } + }); + if shaped.is_none() + && let Some(f) = face.skrifa() + { + let cmap = f.charmap(); + let gm = f.glyph_metrics(Size::unscaled(), face.location()); + for (i, c) in text.char_indices() { + let g = cmap.map(map(c)).unwrap_or_default(); + let adv = gm.advance_width(g).unwrap_or(face.upem as f32 * 0.5); + out.push(ShapedGlyph { gid: g.to_u32(), cluster: i, x_advance: adv.round() as i32, x_offset: 0, y_offset: 0 }); + } + } + out +} + +/// Glyph id of the first of `chars` the face has (0 = .notdef). +pub fn first_glyph(face: &FontFace, chars: &[char]) -> u32 { + chars.iter().map(|c| face.glyph_for(*c)).find(|g| *g != 0).unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn db() -> FontDb { + FontDb::bundled_only() + } + + #[test] + fn last_resort_face_parses() { + let f = fontdb::last_resort_face(); + assert_eq!(f.family, fontdb::FALLBACK_FAMILY); + assert!(f.upem > 0.0); + } + + #[test] + fn bundled_families_load_and_nothing_else() { + let db = db(); + assert_eq!(db.families(), vec!["Inter".to_string(), DEFAULT_FAMILY.to_string(), "STIX Two Math".to_string()]); + let styles = db.styles(DEFAULT_FAMILY); + for s in ["Regular", "Italic", "Bold", "Bold Italic"] { + assert!(styles.iter().any(|x| x == s), "{s} in {styles:?}"); + } + } + + #[test] + fn the_default_family_resolves_to_itself_in_every_math_style() { + let db = db(); + for (style, italic, bold) in [("Regular", false, false), ("Italic", true, false), ("Bold", false, true), ("Bold Italic", true, true)] { + let f = db.face(DEFAULT_FAMILY, style); + assert_eq!(f.family, DEFAULT_FAMILY, "{style}"); + assert_eq!(f.italic, italic, "{style}: {f:?}"); + assert_eq!(f.weight >= 600.0, bold, "{style}: {f:?}"); + } + } + + #[test] + fn an_unknown_family_falls_back_to_the_default_family() { + let db = db(); + assert!(!db.has_family("Cambria Math")); + assert_eq!(db.face("Cambria Math", "Regular").family, DEFAULT_FAMILY); + assert_eq!(FALLBACK_FAMILY, DEFAULT_FAMILY); + } + + #[test] + fn faces_are_found_by_id() { + let db = db(); + let f = db.face("Inter", "Regular"); + let back = db.face_by_id(f.face_id()).unwrap(); + assert!(std::sync::Arc::ptr_eq(&f, &back)); + assert!(db.face_by_id(FaceId(u32::MAX)).is_none()); + } + + #[test] + fn shaping_produces_clusters_and_advances() { + let face = db().face(DEFAULT_FAMILY, "Regular"); + let g = shape(&face, "Hello", &[], |c| c); + assert_eq!(g.len(), 5); + assert_eq!(g.iter().map(|g| g.cluster).collect::>(), vec![0, 1, 2, 3, 4]); + assert!(g.iter().all(|g| g.x_advance > 0 && g.gid != 0)); + } + + #[test] + fn ligatures_can_be_disabled() { + let face = db().face(DEFAULT_FAMILY, "Regular"); + let on = shape(&face, "office", &[], |c| c); + let off = shape(&face, "office", &[feature("-liga").unwrap()], |c| c); + assert!(on.len() <= off.len()); + assert_eq!(off.len(), 6); + } + + #[test] + fn mapping_keeps_clusters() { + let face = db().face(DEFAULT_FAMILY, "Regular"); + let g = shape(&face, "ab", &[], |c| c.to_ascii_uppercase()); + assert_eq!(g[0].gid, face.glyph_for('A')); + assert_eq!(g[1].cluster, 1); + } + + #[test] + fn right_to_left_runs_come_back_in_logical_order() { + // Neither bundled face covers Hebrew, so this only checks cluster order. + let face = db().face(DEFAULT_FAMILY, "Regular"); + let g = shape(&face, "a\u{5D0}\u{5D1}b", &[], |c| c); + let clusters: Vec = g.iter().map(|g| g.cluster).collect(); + let mut sorted = clusters.clone(); + sorted.sort_unstable(); + assert_eq!(clusters, sorted); + } + + #[test] + fn outlines_and_metrics() { + let db = db(); + let face = db.face(DEFAULT_FAMILY, "Bold"); + assert_eq!(face.style, "Bold"); + let o = db.outline(&face, face.glyph_for('O')); + assert!(!o.elements().is_empty()); + assert!(face.ascent > 0.0 && face.descent > 0.0 && face.cap_height > face.x_height); + assert!(feature("abc").is_none()); + } + + #[test] + fn fallback_finds_a_bundled_face_for_a_symbol() { + let db = db(); + let base = db.face(DEFAULT_FAMILY, "Regular"); + // Whatever covers it must be bundled; nothing is loaded from the system. + if let Some(f) = db.fallback_for('\u{2211}', base.id()) { + assert!(f.family == "Inter" || f.family == DEFAULT_FAMILY, "{f:?}"); + } + assert!(db.fallback_for('\u{8A9E}', base.id()).is_none(), "no bundled CJK font, no system fallback"); + } +} diff --git a/crates/equashion-layout/Cargo.toml b/crates/equashion-layout/Cargo.toml new file mode 100644 index 00000000..27402bc2 --- /dev/null +++ b/crates/equashion-layout/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "equashion-layout" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "equaSHion layout: TeX-style equation layout into a backend-neutral scene, caret geometry" + +[lints.rust] +unsafe_code = "forbid" + +[lints.clippy] +dbg_macro = "warn" +todo = "warn" +too_many_arguments = "allow" +type_complexity = "allow" +unwrap_used = "deny" +expect_used = "deny" +panic = "deny" +unimplemented = "deny" +unreachable = "deny" + +[features] +default = ["extra-fonts"] +extra-fonts = ["equashion-font/extra-fonts"] + +[dependencies] +equashion-math = { workspace = true } +equashion-font = { workspace = true } +kurbo = { workspace = true } diff --git a/crates/equashion-layout/src/lib.rs b/crates/equashion-layout/src/lib.rs new file mode 100644 index 00000000..669d0b3c --- /dev/null +++ b/crates/equashion-layout/src/lib.rs @@ -0,0 +1,54 @@ +//! equaSHion layout: TeX-style layout of equations into a backend-neutral [`Scene`], plus the +//! caret geometry an editor needs ([`EqLayout`]). +//! +//! Fonts come in through the [`MathFonts`] trait (implemented for +//! [`equashion_font::FontDb`]), never from a process-wide database, so a host chooses which +//! fonts it offers and tests stay deterministic with [`FontDb::bundled_only`]. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#![forbid(unsafe_code)] + +pub mod math; +mod scene; + +use std::sync::Arc; + +pub use equashion_font::{DEFAULT_FAMILY, FaceId, FontDb, FontFace}; +pub use kurbo::{BezPath, Rect}; +pub use math::{ + Cached, EqLayout, LayoutCache, MathBox, SeqGeo, Src, emit, hit_geo, layout_equation, layout_equation_wrapped, layout_math, layout_math_wrapped, + layout_src, +}; +pub use scene::{Prim, Rgb, Scene}; + +/// The fonts equation layout reads: faces by family and style, a fallback face for characters +/// a face lacks, and glyph outlines (in font units, y down). Backends also resolve the faces a +/// [`Scene`] names with [`MathFonts::face_by_id`]. +pub trait MathFonts { + /// The face for `family` and `style` ("Regular", "Italic", "Bold", "Bold Italic"), or the + /// closest one the implementation has. + fn face(&self, family: &str, style: &str) -> Arc; + /// A face that covers `c`, other than the face with id `exclude`. + fn fallback_for(&self, c: char, exclude: u32) -> Option>; + /// The outline of glyph `gid` of `f`, in font units, y down. + fn outline(&self, f: &FontFace, gid: u32) -> Arc; + /// The face a [`Prim::Glyphs`] names. + fn face_by_id(&self, id: FaceId) -> Option>; +} + +impl MathFonts for FontDb { + fn face(&self, family: &str, style: &str) -> Arc { + FontDb::face(self, family, style) + } + fn fallback_for(&self, c: char, exclude: u32) -> Option> { + FontDb::fallback_for(self, c, exclude) + } + fn outline(&self, f: &FontFace, gid: u32) -> Arc { + FontDb::outline(self, f, gid) + } + fn face_by_id(&self, id: FaceId) -> Option> { + FontDb::face_by_id(self, id) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/equashion-layout/src/math.rs b/crates/equashion-layout/src/math.rs new file mode 100644 index 00000000..275d7700 --- /dev/null +++ b/crates/equashion-layout/src/math.rs @@ -0,0 +1,2165 @@ +//! TeX-style layout of equations (`equashion_math::Math`) into positioned glyphs and rules. +//! +//! The result is a [`MathBox`]: a width, an ascent and a descent around a baseline, plus glyph +//! items and rules in box-local coordinates (x right, y down, baseline at y = 0). A host's +//! paragraph shaper can treat the box as one inline atom; [`emit`] turns it into backend-neutral +//! scene [`Prim`]itives. +//! +//! Metrics come from glyph outlines and the font's own vertical metrics, so layout works with +//! whatever face resolves (including the last-resort font when craft-fonts is absent). Radicals +//! and delimiters grow by scaling the glyph; fractions, radical bars, bars and borders are rules. +//! Recursion is bounded by `MAX_DEPTH` and the number of laid-out nodes by a budget. + +use std::collections::HashMap; +use std::sync::Arc; + +use equashion_font::FontFace; +use equashion_math::{MAX_DEPTH, Math, Node, Style as MStyle, from_omml}; +use kurbo::{Affine, BezPath, Rect, Shape as _}; + +use crate::MathFonts; +use crate::scene::{Prim, Rgb, Scene}; + +/// Most nodes laid out for one equation; the rest is dropped. +const NODE_BUDGET: usize = 20_000; +/// Most layouts a [`LayoutCache`] keeps before it starts over. +const CACHE_MAX: usize = 256; +/// Largest growth factor of a stretched delimiter or radical. +const MAX_STRETCH: f64 = 8.0; +/// Thickness of fraction bars and radical bars, em. +const RULE: f64 = 0.055; + +#[derive(Clone)] +pub(crate) struct GlyphItem { + pub face: Arc, + pub size: f64, + pub glyphs: Vec<(u32, f64, f64)>, + pub fake_bold: bool, + pub fake_italic: bool, +} + +#[derive(Clone)] +pub(crate) enum Item { + Glyphs(GlyphItem), + Rule(Rect), + /// A filled vector shape (radical sign, delimiter, big operator). + Path(BezPath), +} + +/// A laid-out equation (or part of one). +#[derive(Clone, Default)] +pub struct MathBox { + pub(crate) w: f64, + pub(crate) asc: f64, + pub(crate) desc: f64, + pub(crate) items: Vec, + /// Caret geometry of every row laid out inside this box (box-local). + pub(crate) geo: Vec, + /// Width of the last line when the equation was broken into several (inline equations then + /// sit with that line on the text baseline, so following text continues after it). + pub(crate) last_w: Option, +} + +/// Where the caret positions of one row (a child sequence of the math tree) sit. +#[derive(Clone, Debug, Default)] +pub struct SeqGeo { + /// Route to the row, as in `equashion_math::Path`. + pub path: Vec<(usize, usize)>, + /// x of each unit boundary: `units + 1` entries. + pub xs: Vec, + /// Vertical extent of the caret in this row (y down, baseline 0). + pub top: f64, + pub bottom: f64, +} + +impl MathBox { + /// Width, points. + pub fn width(&self) -> f64 { + self.w + } + /// Width of the last line of a broken-up inline equation; `None` for an equation on one line. + pub fn last_line_width(&self) -> Option { + self.last_w + } + /// Height above the baseline, points. + pub fn ascent(&self) -> f64 { + self.asc + } + /// Depth below the baseline, points. + pub fn descent(&self) -> f64 { + self.desc + } + fn space(w: f64) -> MathBox { + MathBox { w: w.max(0.0), ..Default::default() } + } + /// Add `o` with its origin at (dx, dy) (y down); grows the box to hold it. + fn put(&mut self, o: MathBox, dx: f64, dy: f64) { + self.w = self.w.max(dx + o.w); + self.asc = self.asc.max(o.asc - dy); + self.desc = self.desc.max(o.desc + dy); + for mut g in o.geo { + for x in &mut g.xs { + *x += dx; + } + g.top += dy; + g.bottom += dy; + self.geo.push(g); + } + for it in o.items { + self.items.push(match it { + Item::Glyphs(mut g) => { + for p in &mut g.glyphs { + p.1 += dx; + p.2 += dy; + } + Item::Glyphs(g) + } + Item::Rule(r) => Item::Rule(Rect::new(r.x0 + dx, r.y0 + dy, r.x1 + dx, r.y1 + dy)), + Item::Path(mut p) => { + p.apply_affine(Affine::translate((dx, dy))); + Item::Path(p) + } + }); + } + } + fn rule(&mut self, x0: f64, y0: f64, x1: f64, y1: f64) { + if x1 > x0 && y1 > y0 { + self.items.push(Item::Rule(Rect::new(x0, y0, x1, y1))); + self.asc = self.asc.max(-y0); + self.desc = self.desc.max(y1); + self.w = self.w.max(x1); + } + } + /// Add a filled shape; grows the box to hold it. + fn path(&mut self, p: BezPath) { + let b = p.bounding_box(); + if b.x1 > b.x0 && b.y1 > b.y0 && b.x0.is_finite() && b.y0.is_finite() && b.x1.is_finite() && b.y1.is_finite() { + self.asc = self.asc.max(-b.y0); + self.desc = self.desc.max(b.y1); + self.w = self.w.max(b.x1); + self.items.push(Item::Path(p)); + } + } + /// Boxes side by side on a common baseline. + fn row(parts: Vec) -> MathBox { + let mut out = MathBox::default(); + let mut x = 0.0; + for p in parts { + let w = p.w; + out.put(p, x, 0.0); + x += w; + } + out.w = x; + out + } + /// Number of glyph items (tests). + #[cfg(test)] + pub(crate) fn glyph_items(&self) -> usize { + self.items.iter().filter(|i| matches!(i, Item::Glyphs(_))).count() + } + /// Number of rules (tests). + #[cfg(test)] + pub(crate) fn rules(&self) -> usize { + self.items.iter().filter(|i| matches!(i, Item::Rule(_))).count() + } + /// Number of filled shapes (tests). + #[cfg(test)] + pub(crate) fn paths(&self) -> usize { + self.items.iter().filter(|i| matches!(i, Item::Path(_))).count() + } + /// Largest glyph size used (tests). + #[cfg(test)] + pub(crate) fn max_size(&self) -> f64 { + self.items.iter().filter_map(|i| if let Item::Glyphs(g) = i { Some(g.size) } else { None }).fold(0.0, f64::max) + } +} + +/// Style of the surrounding formula: display, text, script, script-script. +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +enum St { + Display, + Text, + Script, + ScriptScript, +} + +impl St { + fn factor(self) -> f64 { + match self { + St::Display | St::Text => 1.0, + St::Script => 0.72, + St::ScriptScript => 0.58, + } + } + fn script(self) -> St { + match self { + St::Display | St::Text => St::Script, + _ => St::ScriptScript, + } + } + /// Style of fraction numerators and denominators. + fn inner(self) -> St { + match self { + St::Display => St::Text, + St::Text => St::Script, + _ => St::ScriptScript, + } + } + fn is_script(self) -> bool { + self >= St::Script + } +} + +/// Caret unit mark: x lies at `f` between part boundaries `a` and `b`. +type Mark = (usize, usize, f64); + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Var { + Italic, + Upright, + Bold, + BoldItalic, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Class { + None, + Ord, + Op, + Bin, + Rel, + Punct, + /// A built-up fraction: it takes a thin space from its neighbours (outside scripts). + Inner, +} + +/// Where a trailing differential (`dx`, `dt`...) starts in an operator's body: the last text +/// node ends in `d` and one letter, with something before it. +fn differential(s: &[Node]) -> Option<(usize, usize)> { + let i = s.len().checked_sub(1)?; + let Node::Text(t) = s.get(i)?.bare() else { return None }; + let chars: Vec = t.chars().collect(); + let n = chars.len(); + let d = n.checked_sub(2)?; + let (a, b) = (*chars.get(d)?, *chars.get(n - 1)?); + let before = (d > 0 || i > 0) && d.checked_sub(1).and_then(|k| chars.get(k)).is_none_or(|c| space_em(*c).is_none()); + (a == 'd' + && b.is_alphabetic() + && before + && (d.checked_sub(1).and_then(|k| chars.get(k)).is_none_or(|c| !c.is_alphabetic()) + || d.checked_sub(2).and_then(|k| chars.get(k)).is_none_or(|c| !c.is_alphabetic()))) + .then_some((i, d)) +} + +/// Width in em of the explicit space characters the typed-LaTeX parser produces: thin +/// (`\,`), medium (`\:`), thick (`\;`) and one em (`\quad`). +fn space_em(c: char) -> Option { + match c { + '\u{2009}' => Some(3.0 / 18.0), + '\u{2005}' => Some(4.0 / 18.0), + '\u{2004}' => Some(5.0 / 18.0), + '\u{2001}' => Some(1.0), + _ => None, + } +} + +fn classify(c: char) -> Class { + match c { + '=' | '<' | '>' | '\u{2264}' | '\u{2265}' | '\u{2260}' | '\u{2248}' | '\u{2261}' | '\u{223C}' | '\u{2245}' | '\u{2208}' | '\u{2209}' + | '\u{220B}' | '\u{2282}' | '\u{2283}' | '\u{2286}' | '\u{2287}' | '\u{2192}' | '\u{2190}' | '\u{2194}' | '\u{21D2}' | '\u{21D0}' + | '\u{21D4}' | '\u{221D}' | '\u{2225}' | '\u{22A5}' | '\u{226A}' | '\u{226B}' | '\u{27F6}' | '\u{21A6}' | '\u{2223}' | '\u{2243}' + | '\u{2250}' => Class::Rel, + '+' | '\u{2212}' | '-' | '\u{D7}' | '\u{F7}' | '\u{B7}' | '\u{B1}' | '\u{2213}' | '\u{2217}' | '\u{2218}' | '\u{222A}' | '\u{2229}' + | '\u{2227}' | '\u{2228}' | '\u{2295}' | '\u{2297}' | '\u{2216}' | '*' | '\u{22C5}' => Class::Bin, + ',' | ';' => Class::Punct, + _ => Class::Ord, + } +} + +/// Space (in em) between two adjacent atoms. +fn gap(prev: Class, next: Class, script: bool) -> f64 { + // A little tighter than TeX's 4/18 and 5/18 (the serif face has wide side bearings), and a + // small space kept in script size so "1+x" in a script-size denominator does not run together. + let med = if script { 3.5 / 18.0 } else { 5.0 / 18.0 }; + let thick = if script { 0.0 } else { 6.0 / 18.0 }; + let thin = 3.0 / 18.0; + match (prev, next) { + (Class::None, _) => 0.0, + (Class::Bin, _) | (_, Class::Bin) => med, + (Class::Rel, _) | (_, Class::Rel) => thick, + (Class::Punct, _) => { + if script { + 0.0 + } else { + thin + } + } + (Class::Op, _) | (_, Class::Op) => thin, + (Class::Inner, _) | (_, Class::Inner) => { + if script { + 0.0 + } else { + thin + } + } + _ => 0.0, + } +} + +/// Add a closed polygon to `p`, wound the same way whatever the order of `pts` (so overlapping +/// pieces of one shape never cancel under the non-zero rule). +fn add_poly(p: &mut BezPath, pts: &[(f64, f64)]) { + let n = pts.len(); + if n < 3 { + return; + } + let mut area = 0.0; + for i in 0..n { + let (a, b) = (pts.get(i).copied().unwrap_or_default(), pts.get((i + 1) % n).copied().unwrap_or_default()); + area += a.0 * b.1 - b.0 * a.1; + } + let ordered: Vec<(f64, f64)> = if area >= 0.0 { pts.to_vec() } else { pts.iter().rev().copied().collect() }; + for (i, pt) in ordered.iter().enumerate() { + if i == 0 { + p.move_to(*pt); + } else { + p.line_to(*pt); + } + } + p.close_path(); +} + +/// A straight stroke of perpendicular thickness `w` from `a` to `b`; `ext` lengthens it by half a +/// width at both ends so consecutive strokes overlap at their joints. +fn add_segment(p: &mut BezPath, a: (f64, f64), b: (f64, f64), w: f64, ext: bool) { + let (dx, dy) = (b.0 - a.0, b.1 - a.1); + let len = (dx * dx + dy * dy).sqrt(); + if !(len.is_finite() && len > 1e-9) { + return; + } + let (ux, uy) = (dx / len, dy / len); + let e = if ext { w / 2.0 } else { 0.0 }; + let (a, b) = ((a.0 - ux * e, a.1 - uy * e), (b.0 + ux * e, b.1 + uy * e)); + let (nx, ny) = (-uy * w / 2.0, ux * w / 2.0); + add_poly(p, &[(a.0 + nx, a.1 + ny), (b.0 + nx, b.1 + ny), (b.0 - nx, b.1 - ny), (a.0 - nx, a.1 - ny)]); +} + +/// Narrow a box made of one glyph run to its ink's right edge (plus a little), so scripts hang +/// by the glyph rather than by its side bearing. +fn trim_right(fonts: &dyn MathFonts, mut b: MathBox, sz: f64) -> MathBox { + let mut right: f64 = 0.0; + for it in &b.items { + if let Item::Glyphs(g) = it { + let k = g.size / g.face.upem.max(1.0); + for (gid, x, _) in &g.glyphs { + if let Some((_, _, x1, _)) = ink(fonts, &g.face, *gid) { + right = right.max(x + x1 * k); + } + } + } + } + if right > 0.0 && right + 0.03 * sz < b.w { + b.w = right + 0.03 * sz; + } + b +} + +/// A parenthesis from `top` to `bot` (y down): a crescent that is thickest in the middle and +/// never thinner than a hairline at the tips. `x` is the tip column; the belly bulges left for an +/// opening parenthesis and right for a closing one. +fn paren_path(open: bool, x: f64, top: f64, bot: f64, bulge: f64, thick: f64, tip: f64) -> BezPath { + let dir = if open { -1.0 } else { 1.0 }; + let h = bot - top; + let k = 4.0 / 3.0; // cubic control offset for a bulge of `b` at the middle + let curve = |p: &mut BezPath, x0: f64, b: f64, from_top: bool| { + let y1 = if from_top { bot } else { top }; + let cx = x0 + dir * b * k; + let (c1, c2) = if from_top { (top + h * 0.12, bot - h * 0.12) } else { (bot - h * 0.12, top + h * 0.12) }; + p.curve_to((cx, c1), (cx, c2), (x0, y1)); + }; + let mut p = BezPath::new(); + p.move_to((x, top)); + curve(&mut p, x, bulge, true); + let inner_x = x - dir * tip; + p.line_to((inner_x, bot)); + let inner_bulge = (bulge - thick + tip).max(0.0); + curve(&mut p, inner_x, inner_bulge, false); + p.close_path(); + p +} + +/// The four faces of the equation font family. +struct Faces { + regular: Arc, + italic: Arc, + bold: Arc, + bold_italic: Arc, +} + +impl Faces { + fn new(db: &dyn MathFonts, family: &str) -> Faces { + Faces { + regular: db.face(family, "Regular"), + italic: db.face(family, "Italic"), + bold: db.face(family, "Bold"), + bold_italic: db.face(family, "Bold Italic"), + } + } + fn get(&self, v: Var) -> &Arc { + match v { + Var::Upright => &self.regular, + Var::Italic => &self.italic, + Var::Bold => &self.bold, + Var::BoldItalic => &self.bold_italic, + } + } +} + +/// The layout engine for one equation: fonts, size and the state of the row being laid out. +struct Lay<'f> { + fonts: &'f dyn MathFonts, + faces: Faces, + /// Font size of the text style, points. + size: f64, + bold: bool, + budget: usize, + /// Route to the row being laid out (see `SeqGeo::path`). + path: Vec<(usize, usize)>, + /// Index, in its row, of the node being laid out. + cur: usize, + /// Widest the top-level row may be before it breaks into several lines, points. + wrap: f64, + /// Parts of the top-level row after which it may break (after a binary operator or relation). + brks: Vec, + /// The next row laid out is the body of a big operator (so a trailing `dx` takes a thin space). + nary_body: bool, + /// (node, character) in the row being laid out where a differential `dx` starts. + thin_at: Option<(usize, usize)>, +} + +/// Ink box of a glyph in font units, y down: (x0, y0, x1, y1). +fn ink(fonts: &dyn MathFonts, face: &FontFace, gid: u32) -> Option<(f64, f64, f64, f64)> { + let p = fonts.outline(face, gid); + if p.elements().is_empty() { + return None; + } + let b = p.bounding_box(); + if b.x0.is_finite() && b.y0.is_finite() && b.x1.is_finite() && b.y1.is_finite() && b.y1 > b.y0 { Some((b.x0, b.y0, b.x1, b.y1)) } else { None } +} + +fn is_upper_greek(c: char) -> bool { + ('\u{391}'..='\u{3A9}').contains(&c) +} + +impl Lay<'_> { + fn pick(&self, v: Var, c: char) -> Arc { + let base = self.faces.get(v); + if c.is_whitespace() || c.is_control() || base.covers(c) { + return base.clone(); + } + self.fonts.fallback_for(c, base.id()).unwrap_or_else(|| base.clone()) + } + + /// Ink box of a glyph in font units, y down (see [`ink`]). + fn ink(&self, face: &FontFace, gid: u32) -> Option<(f64, f64, f64, f64)> { + ink(self.fonts, face, gid) + } + + fn var_for(&self, c: char, v: Var) -> Var { + // Math italic applies to letters only; digits and symbols stay upright. + let italic_letter = !is_upper_greek(c) && (c.is_ascii_alphabetic() || ('\u{3B1}'..='\u{3C9}').contains(&c) || c == '\u{3D1}'); + match (v, self.bold) { + (Var::Italic, b) => match (italic_letter, b) { + (true, false) => Var::Italic, + (true, true) => Var::BoldItalic, + (false, false) => Var::Upright, + (false, true) => Var::Bold, + }, + (Var::Upright, true) => Var::Bold, + (other, _) => other, + } + } + + /// A string at `sz` points in variant `v` (per-character italic choice for `Var::Italic`). + fn text(&self, s: &str, v: Var, sz: f64) -> MathBox { + let mut out = MathBox::default(); + let mut x = 0.0; + let chars: Vec = s.chars().collect(); + let mut i = 0; + while i < chars.len() { + let c = chars.get(i).copied().unwrap_or(' '); + // Wide spaces are fixed gaps, not glyphs (the math fonts may lack them): column breaks. + let gap = match c { + '\u{2003}' => Some(1.0), + '\u{2002}' => Some(0.5), + c => space_em(c), + }; + if let Some(g) = gap { + x += g * sz; + i += 1; + continue; + } + let cv = self.var_for(c, v); + let face = self.pick(cv, c); + let mut j = i + 1; + while j < chars.len() { + let d = chars.get(j).copied().unwrap_or(' '); + let dv = self.var_for(d, v); + if matches!(d, '\u{2003}' | '\u{2002}') || space_em(d).is_some() || dv != cv || !Arc::ptr_eq(&self.pick(dv, d), &face) { + break; + } + j += 1; + } + let seg: String = chars.get(i..j).map(|s| s.iter().collect()).unwrap_or_default(); + let k = sz / face.upem.max(1.0); + let shaped = equashion_font::shape(&face, &seg, &[], |c| c); + let mut item = GlyphItem { + face: face.clone(), + size: sz, + glyphs: vec![], + fake_bold: matches!(cv, Var::Bold | Var::BoldItalic) && face.weight < 550.0, + fake_italic: matches!(cv, Var::Italic | Var::BoldItalic) && !face.italic, + }; + for g in shaped { + let gx = x + g.x_offset as f64 * k; + let gy = -(g.y_offset as f64) * k; + if let Some((_, y0, _, y1)) = self.ink(&face, g.gid) { + out.asc = out.asc.max(-y0 * k - gy); + out.desc = out.desc.max(y1 * k + gy); + } + item.glyphs.push((g.gid, gx, gy)); + x += g.x_advance as f64 * k; + } + if !item.glyphs.is_empty() { + out.items.push(Item::Glyphs(item)); + } + i = j; + } + out.w = x; + out.asc = out.asc.max(0.0); + out.desc = out.desc.max(0.0); + out + } + + fn x_height(&self, sz: f64) -> f64 { + let f = self.faces.get(Var::Upright); + let xh = f.x_height / f.upem.max(1.0) * sz; + if xh.is_finite() && xh > 0.0 { xh } else { 0.45 * sz } + } + + /// Ink extent (width, height) of a character in the upright face, in em; `None` if the face + /// has no outline for it (no fonts installed). + fn ink_em(&self, c: char) -> Option<(f64, f64)> { + let face = self.faces.get(Var::Upright); + let gid = face.glyph_for(c); + let (x0, y0, x1, y1) = (gid != 0).then(|| self.ink(face, gid)).flatten()?; + let u = face.upem.max(1.0); + let (w, h) = ((x1 - x0) / u, (y1 - y0) / u); + (w.is_finite() && h.is_finite() && w > 0.0 && h > 0.0).then_some((w, h)) + } + + /// Weight of horizontal rules (fraction bars, radical strokes): the thickness of the font's + /// own minus sign, so rules match the text. + fn hbar(&self, sz: f64) -> f64 { + let em = self.ink_em('\u{2212}').map(|(_, h)| h).unwrap_or(RULE); + em.clamp(0.048, 0.075) * sz + } + + /// Weight of vertical strokes (brackets, bars): the stem of the font's own `|`. + fn stem(&self, sz: f64) -> f64 { + let em = self.ink_em('|').map(|(w, _)| w).unwrap_or(RULE); + em.clamp(0.03, 0.08) * sz + } + + fn axis(&self, sz: f64) -> f64 { + 0.25 * sz + } + + /// One character grown so its ink is `target` tall (never smaller than `sz`), its ink centre + /// on the math axis. Falls back to the plain glyph when the font has no outline for it. + fn stretch(&self, c: char, target: f64, sz: f64) -> MathBox { + let face = self.pick(Var::Upright, c); + let gid = face.glyph_for(c); + let Some((_, y0, _, y1)) = (gid != 0).then(|| self.ink(&face, gid)).flatten() else { + return self.text(&c.to_string(), Var::Upright, sz); + }; + let upem = face.upem.max(1.0); + let natural = (y1 - y0) / upem; // ink height per point of size + let want = if target.is_finite() { target } else { 0.0 }; + let s = (want / natural).clamp(sz, sz * MAX_STRETCH); + let b = self.text(&c.to_string(), Var::Upright, s); + let centre = (y0 + y1) / 2.0 * s / upem; + let dy = -self.axis(sz) - centre; + let w = b.w; + let mut shifted = MathBox::default(); + shifted.put(b, 0.0, dy); + shifted.w = w; + shifted + } + + fn seq(&mut self, s: &[Node], st: St, depth: usize) -> MathBox { + let mut parts: Vec = vec![]; + let mut marks: Vec = vec![]; + let mut prev = Class::None; + let path = self.path.clone(); + let body_of_nary = std::mem::take(&mut self.nary_body); + let thin = if body_of_nary { differential(s) } else { None }; + for (i, n) in s.iter().enumerate() { + if self.budget == 0 { + break; + } + self.cur = i; + self.thin_at = thin; + self.node(n, st, depth, &mut prev, &mut parts, &mut marks); + } + // x of every part boundary, then of every caret unit. + let mut pre = vec![0.0]; + let mut x = 0.0; + for p in &parts { + x += p.w; + pre.push(x); + } + let at = |k: usize| pre.get(k).copied().unwrap_or(x); + let mut xs: Vec = marks.iter().map(|m| at(m.0) + m.2 * (at(m.1) - at(m.0))).collect(); + xs.push(x); + let sz = self.size * st.factor(); + if self.path.is_empty() && depth == 0 && x > self.wrap { + let brks = std::mem::take(&mut self.brks); + return self.wrapped(parts, &brks, sz, st != St::Display); + } + let mut row = MathBox::row(parts); + row.geo.push(SeqGeo { path, xs, top: (-row.asc).min(-0.72 * sz), bottom: row.desc.max(0.22 * sz) }); + row + } + + /// The top-level `parts` broken into lines of at most `self.wrap` points, stacked left-aligned. + fn wrapped(&self, parts: Vec, brks: &[usize], sz: f64, last_on_baseline: bool) -> MathBox { + let max = self.wrap.max(4.0 * sz); + // Choose where each line starts, by widths alone. + let mut starts = vec![0usize]; + let (mut x, mut start, mut last): (f64, usize, Option) = (0.0, 0, None); + for (i, p) in parts.iter().enumerate() { + if brks.binary_search(&i).is_ok() && i > start { + last = Some(i); + } + if x + p.w > max && i > start { + let at = last.filter(|b| *b > start && *b <= i).unwrap_or(i); + starts.push(at); + x = parts.get(at..i).map_or(0.0, |s| s.iter().map(|q| q.w).sum()); + start = at; + last = None; + } + x += p.w; + } + let mut rows: Vec = Vec::new(); + let mut it = parts.into_iter().enumerate().peekable(); + for r in 0..starts.len() { + let end = starts.get(r + 1).copied().unwrap_or(usize::MAX); + let mut line = Vec::new(); + while let Some((i, _)) = it.peek() { + if *i >= end { + break; + } + if let Some((_, p)) = it.next() { + // A line does not start with the space that followed the operator. + if r > 0 && line.is_empty() && p.items.is_empty() && p.geo.is_empty() { + continue; + } + line.push(p); + } + } + rows.push(MathBox::row(line)); + } + // Offset of each line's baseline from the first line's. + let mut offs = Vec::with_capacity(rows.len()); + let (mut dy, mut prev_desc) = (0.0, 0.0); + for (r, row) in rows.iter().enumerate() { + if r > 0 { + dy += prev_desc + row.asc + 0.25 * sz; + } + prev_desc = row.desc; + offs.push(dy); + } + // An inline equation puts its last line on the text baseline. + let lift = if last_on_baseline { dy } else { 0.0 }; + let last_w = (last_on_baseline && rows.len() > 1).then(|| rows.last().map_or(0.0, |r| r.w)); + let mut out = MathBox::default(); + for (row, off) in rows.into_iter().zip(offs) { + out.put(row, 0.0, off - lift); + } + out.last_w = last_w; + out + } + + fn push_gap(&self, parts: &mut Vec, prev: Class, next: Class, st: St) { + let g = gap(prev, next, st.is_script()) * self.size * st.factor(); + if g > 0.0 { + parts.push(MathBox::space(g)); + } + } + + fn node(&mut self, n: &Node, st: St, depth: usize, prev: &mut Class, parts: &mut Vec, marks: &mut Vec) { + self.budget = self.budget.saturating_sub(1); + let me = self.cur; + let sz = self.size * st.factor(); + match n.bare() { + Node::Text(t) => { + for (ci, c) in t.chars().enumerate() { + let before = parts.len(); + if self.thin_at == Some((me, ci)) { + // The `d` of a trailing differential: a thin space after the integrand. + parts.push(MathBox::space(3.0 / 18.0 * sz)); + *prev = Class::None; + } + if c == '\u{2003}' || c == '\u{2002}' { + // A column break (cases): a fixed wide gap, and what follows starts afresh. + marks.push((before, before, 0.0)); + parts.push(MathBox::space(if c == '\u{2003}' { sz * 1.3 } else { sz * 0.5 })); + *prev = Class::None; + continue; + } + if let Some(em) = space_em(c) { + // `\,` `\:` `\;` `\quad`: a fixed space; it separates, so what follows + // starts afresh. + marks.push((before, before, 0.0)); + parts.push(MathBox::space(em * sz)); + *prev = Class::None; + continue; + } + if c.is_whitespace() { + marks.push((before, before, 0.0)); + continue; + } + let mut k = classify(c); + if k == Class::Bin && matches!(*prev, Class::None | Class::Bin | Class::Rel | Class::Punct | Class::Op) { + k = Class::Ord; + } + self.push_gap(parts, *prev, k, st); + marks.push((before, parts.len(), 0.5)); + match self.arrow(c, sz) { + Some(a) => parts.push(a), + None => parts.push(self.text(&c.to_string(), Var::Italic, sz)), + } + if matches!(k, Class::Bin | Class::Rel) && self.path.is_empty() { + self.brks.push(parts.len()); + } + *prev = k; + } + } + Node::Styled { text, style } => { + let (v, keep_space) = match style { + MStyle::Plain => (Var::Upright, false), + MStyle::Bold => (Var::Bold, false), + MStyle::BoldItalic => (Var::BoldItalic, false), + MStyle::Normal => (Var::Upright, true), + }; + let s: String = if keep_space { text.clone() } else { text.chars().filter(|c| !c.is_whitespace()).collect() }; + self.push_gap(parts, *prev, Class::Ord, st); + // The characters share one box: spread their caret positions across it. + let n = text.chars().count().max(1); + for k in 0..n { + marks.push((parts.len(), parts.len() + 1, k as f64 / n as f64)); + } + parts.push(self.text(&s, v, sz)); + *prev = Class::Ord; + } + Node::Func { name, body } => { + if depth >= MAX_DEPTH { + return; + } + self.push_gap(parts, *prev, Class::Op, st); + marks.push((parts.len(), parts.len(), 0.0)); + let mut b = self.text(name, Var::Upright, sz); + b.w += 3.0 / 18.0 * sz; + parts.push(b); + *prev = Class::Op; + let inner = self.child(me, 0, body, st, depth + 1); + if inner.w > 0.0 { + parts.push(inner); + *prev = Class::Ord; + } + } + Node::Raw(_) => marks.push((parts.len(), parts.len(), 0.0)), + other => { + if depth >= MAX_DEPTH { + marks.push((parts.len(), parts.len(), 0.0)); + return; + } + let k = match other { + Node::Nary { .. } | Node::Limit { .. } => Class::Op, + Node::Frac { .. } => Class::Inner, + _ => Class::Ord, + }; + let before = parts.len(); + self.push_gap(parts, *prev, k, st); + marks.push((before, parts.len(), 0.5)); + let b = self.structure(other, st, depth + 1, me); + parts.push(b); + // `lim`, like `sin`, takes a thin space before what follows. + *prev = match other { + Node::Limit { .. } => Class::Op, + Node::Frac { .. } => Class::Inner, + _ => Class::Ord, + }; + } + } + } + + /// `seq` for a child at nesting `depth` (stops at the depth limit). + fn seq_in(&mut self, s: &[Node], st: St, depth: usize) -> MathBox { + if depth >= MAX_DEPTH { + return MathBox::default(); + } + self.seq(s, st, depth) + } + + /// Lay out child row `c` of the node at index `me` of the current row. + fn child(&mut self, me: usize, c: usize, s: &[Node], st: St, depth: usize) -> MathBox { + self.path.push((me, c)); + let b = self.seq_in(s, st, depth); + self.path.pop(); + b + } + + fn structure(&mut self, n: &Node, st: St, depth: usize, me: usize) -> MathBox { + let sz = self.size * st.factor(); + match n { + Node::Frac { num, den } => { + let ch = st.inner(); + let nb = self.child(me, 0, num, ch, depth); + let db = self.child(me, 1, den, ch, depth); + self.frac(nb, db, st, sz) + } + Node::Rad { deg, body } => { + let b = self.child(me, usize::from(deg.is_some()), body, st, depth); + let d = deg.as_ref().map(|d| self.child(me, 0, d, St::ScriptScript, depth)); + self.radical(b, d, sz) + } + Node::Script { base, sub, sup } => { + let is_char = base.iter().all(|n| matches!(n.bare(), Node::Text(_) | Node::Styled { .. })); + let b = self.child(me, 0, base, st, depth); + let sb = sub.as_ref().map(|s| self.child(me, 1, s, st.script(), depth)); + let sp = sup.as_ref().map(|s| self.child(me, 1 + usize::from(sub.is_some()), s, st.script(), depth)); + self.scripts(b, sb, sp, is_char, st) + } + Node::PreScript { sub, sup, base } => { + let b = self.child(me, 2, base, st, depth); + let sb = self.child(me, 0, sub, st.script(), depth); + let sp = self.child(me, 1, sup, st.script(), depth); + let pre = self.scripts(MathBox::default(), Some(sb), Some(sp), true, st); + MathBox::row(vec![pre, b]) + } + Node::Nary { op, sub, sup, body } => { + let display = st == St::Display; + let integral = matches!(*op, '\u{222B}'..='\u{2233}'); + let opsz = sz + * if !display { + 1.0 + } else if integral { + 2.5 + } else { + 1.75 + }; + let ob = self.big_op(*op, display, sz).unwrap_or_else(|| self.stretch(*op, opsz * 0.85, opsz)); + let ob = if integral { trim_right(self.fonts, ob, sz) } else { ob }; + let sb = sub.as_ref().map(|s| self.child(me, 0, s, st.script(), depth)); + let sp = sup.as_ref().map(|s| self.child(me, usize::from(sub.is_some()), s, st.script(), depth)); + self.nary_body = true; + let body_box = self.child(me, usize::from(sub.is_some()) + usize::from(sup.is_some()), body, st, depth); + self.nary_body = false; + let head = if integral || !display { + self.scripts_tucked(ob, sb, sp, false, st, if integral && display { 0.14 } else { 0.0 }) + } else { + self.limits(ob, sb, sp, sz) + }; + let mut parts = vec![head]; + if body_box.w > 0.0 { + parts.push(MathBox::space(3.0 / 18.0 * sz)); + parts.push(body_box); + } + MathBox::row(parts) + } + Node::Delim { open, close, sep, items } => { + let boxes: Vec = items.iter().enumerate().map(|(k, i)| self.child(me, k, i, st, depth)).collect(); + self.delimiters(open, close, sep, boxes, sz) + } + Node::Matrix { rows } => { + let cs = if st == St::Display { St::Text } else { st }; + let mut k = 0usize; + let mut cells: Vec> = vec![]; + for r in rows { + let mut row = vec![]; + for c in r { + row.push(self.child(me, k, c, cs, depth)); + k += 1; + } + cells.push(row); + } + self.matrix(cells, sz) + } + Node::EqArr { rows } => { + let cells: Vec> = rows.iter().enumerate().map(|(k, r)| vec![self.child(me, k, r, st, depth)]).collect(); + self.matrix(cells, sz) + } + Node::Accent { ch, body } => { + let b = self.child(me, 0, body, st, depth); + self.accent(*ch, b, sz) + } + Node::Limit { lower, base, lim } => { + let b = self.child(me, 0, base, st, depth); + let l = self.child(me, 1, lim, st.script(), depth); + if *lower { self.limits(b, Some(l), None, sz) } else { self.limits(b, None, Some(l), sz) } + } + Node::Bar { top, body } => { + let b = self.child(me, 0, body, st, depth); + let t = (0.05 * sz).max(0.4); + let (w, asc, desc) = (b.w, b.asc, b.desc); + let mut out = MathBox::default(); + out.put(b, 0.0, 0.0); + if *top { + let y = -(asc + 0.12 * sz); + out.rule(0.0, y - t, w, y); + } else { + let y = desc + 0.12 * sz; + out.rule(0.0, y, w, y + t); + } + out.w = w; + out + } + Node::GroupChr { ch, top, body } => { + let b = self.child(me, 0, body, st, depth); + let (w, asc, desc) = (b.w, b.asc, b.desc); + let gap = 0.1 * sz; + let mut out = MathBox::default(); + out.put(b, 0.0, 0.0); + if let Some(g) = self.group_shape(*ch, w, sz, *top) { + // The shape has its near edge at its origin and grows away from the text. + if *top { + out.put(g, 0.0, -(asc + gap)); + } else { + out.put(g, 0.0, desc + gap); + } + out.w = out.w.max(w); + return out; + } + let t = (0.05 * sz).max(0.4); + let g = self.text(&ch.to_string(), Var::Upright, sz * 0.8); + let gw = g.w; + if *top { + let y = -(asc + 0.12 * sz); + out.rule(0.0, y - t, w, y); + out.put(g, ((w - gw) / 2.0).max(0.0), y - t - 0.1 * sz); + } else { + let y = desc + 0.12 * sz; + out.rule(0.0, y, w, y + t); + let ga = g.asc; + out.put(g, ((w - gw) / 2.0).max(0.0), y + t + 0.1 * sz + ga); + } + out.w = out.w.max(w); + out + } + Node::Boxed { border, body } => { + let b = self.child(me, 0, body, st, depth); + if !border { + return b; + } + let pad = 0.12 * sz; + let t = (0.05 * sz).max(0.4); + let (w, asc, desc) = (b.w, b.asc, b.desc); + let mut out = MathBox::default(); + out.put(b, pad + t, 0.0); + let (x1, y0, y1) = (w + 2.0 * (pad + t), -(asc + pad + t), desc + pad + t); + out.rule(0.0, y0, x1, y0 + t); + out.rule(0.0, y1 - t, x1, y1); + out.rule(0.0, y0, t, y1); + out.rule(x1 - t, y0, x1, y1); + out.w = x1; + out + } + // Text-like nodes are handled by `node`. + _ => MathBox::default(), + } + } + + fn frac(&self, num: MathBox, den: MathBox, st: St, sz: f64) -> MathBox { + let t = self.hbar(sz); + let axis = self.axis(sz); + let display = st == St::Display; + // TeX keeps 3 rule widths between the bar and the nearest ink in every style, so a radical + // or a nested fraction in a numerator / denominator never touches the bar. + let gap_min = if display { (3.0 * t).max(0.16 * sz) } else { (3.5 * t).max(0.2 * sz) }; + let num_shift = if display { 0.677 } else { 0.394 } * sz; + let den_shift = if display { 0.686 } else { 0.345 } * sz; + let up = num_shift.max(axis + t / 2.0 + gap_min + num.desc); + let down = den_shift.max(den.asc + gap_min + t / 2.0 - axis); + let pad = 0.06 * sz; + let inner = num.w.max(den.w); + let (nw, dw) = (num.w, den.w); + let mut out = MathBox::default(); + out.put(num, pad + (inner - nw) / 2.0, -up); + out.put(den, pad + (inner - dw) / 2.0, down); + out.rule(pad, -axis - t / 2.0, pad + inner, -axis + t / 2.0); + out.w = inner + 2.0 * pad; + out + } + + fn radical(&self, body: MathBox, deg: Option, sz: f64) -> MathBox { + let t = self.hbar(sz); + // TeX clearance under the bar, so nested bars stay visibly apart. + let bar_top = body.asc + 0.32 * sz + t; + let bottom = body.desc + 0.05 * sz; + let total = bar_top + bottom; + let kern_in = 0.1 * sz; + let pre = deg.as_ref().map(|d| (d.w - 0.45 * sz).max(0.0) + kern_in).unwrap_or(0.0); + // The sign and its bar are one continuous stroke of the bar's own weight: a short tick, + // the down stroke, the up stroke and the vinculum, joined without seams. + let top = -bar_top; + let left = 0.04 * sz + t; + // The check mark grows with the root: its down stroke is about half the total height. + let tall = (0.6 * total).max(0.6 * sz).min(total * 0.8); + let hook_y = bottom - tall; + let c0 = (pre + left, hook_y + 0.05 * sz); + let c1 = (c0.0 + 0.09 * sz, hook_y); + let c2 = (c1.0 + 0.1 * sz + 0.16 * tall, bottom - t / 2.0); + let dxu = 0.3 * sz + 0.14 * (total - tall).clamp(0.0, 3.0 * sz); + let c3 = (c2.0 + dxu, top + t / 2.0); + let end_body = c3.0 + 0.1 * sz; + let bw = body.w; + let end = end_body + bw + 0.03 * sz; + // Thin tick, heavy down stroke, thin up stroke and bar: the contrast of a typeset radical. + let heavy = t * 1.7; + let c2 = (c2.0, c2.1 - heavy * 0.4); + let mut sign = BezPath::new(); + add_segment(&mut sign, c0, c1, t * 0.8, true); + add_segment(&mut sign, c1, c2, heavy, false); + add_segment(&mut sign, c2, c3, t, true); + add_segment(&mut sign, (c3.0 - t * 0.3, c3.1), (end, c3.1), t, false); + let mut out = MathBox::default(); + out.path(sign); + if let Some(d) = deg { + // The degree sits in the notch above the hook: its baseline just clear of the tick's + // top, its right edge a little past the tick's end. + let dx = (c1.0 - d.w + 0.05 * sz).max(0.0); + let dy = hook_y - 0.12 * sz - d.desc; + out.put(d, dx, dy); + } + out.put(body, end_body, 0.0); + out.w = end; + out + } + + fn scripts(&self, base: MathBox, sub: Option, sup: Option, is_char: bool, st: St) -> MathBox { + self.scripts_tucked(base, sub, sup, is_char, st, 0.0) + } + + /// [`scripts`](Self::scripts) with the scripts pulled `tuck` (a fraction of the base's height) + /// towards the base's middle: a tall slanted integral sign has its ink at the centre. + fn scripts_tucked(&self, base: MathBox, sub: Option, sup: Option, is_char: bool, st: St, tuck: f64) -> MathBox { + let sz = self.size * st.factor(); + let ssz = sz * 0.7; + let xh = self.x_height(sz); + let t = 0.06 * sz; + let bw = base.w; + let (basc, bdesc) = (base.asc, base.desc); + let mut out = MathBox::default(); + out.put(base, 0.0, 0.0); + out.w = bw; + let sup_min = if st == St::Display { 0.413 } else { 0.363 } * sz; + let mut u = if is_char { 0.0 } else { basc - 0.386 * ssz - tuck * basc }; + let mut v = if is_char { 0.0 } else { bdesc + 0.05 * ssz - tuck * bdesc }; + let x = bw + 0.02 * sz; + let mut width = bw; + match (sub, sup) { + (None, None) => {} + (None, Some(sp)) => { + u = u.max(sup_min).max(sp.desc + 0.25 * xh); + width = x + sp.w; + out.put(sp, x, -u); + } + (Some(sb), None) => { + v = v.max(0.15 * sz).max(sb.asc - 0.8 * xh); + width = x + sb.w; + out.put(sb, x, v); + } + (Some(sb), Some(sp)) => { + u = u.max(sup_min).max(sp.desc + 0.25 * xh); + v = v.max(0.247 * sz).max(sb.asc - 0.8 * xh); + let clear = (u - sp.desc) - (sb.asc - v); + if clear < 4.0 * t { + v += 4.0 * t - clear; + let psi = 0.8 * xh - (u - sp.desc); + if psi > 0.0 { + u += psi; + v -= psi; + } + } + width = x + sp.w.max(sb.w); + out.put(sp, x, -u); + out.put(sb, x, v); + } + } + out.w = width + 0.02 * sz; + out + } + + /// Limits centred over and under a base (display-style big operators, `limLow`/`limUpp`). + fn limits(&self, base: MathBox, below: Option, above: Option, sz: f64) -> MathBox { + // Big-operator spacing: the limits sit close to the operator's ink. + let gap = 0.12 * sz; + let w = base.w.max(below.as_ref().map(|b| b.w).unwrap_or(0.0)).max(above.as_ref().map(|b| b.w).unwrap_or(0.0)); + let (bw, basc, bdesc) = (base.w, base.asc, base.desc); + let mut out = MathBox::default(); + out.put(base, (w - bw) / 2.0, 0.0); + if let Some(a) = above { + let aw = a.w; + let dy = -(basc + gap + a.desc); + out.put(a, (w - aw) / 2.0, dy); + } + if let Some(b) = below { + let bwid = b.w; + let dy = bdesc + gap + b.asc; + out.put(b, (w - bwid) / 2.0, dy); + } + out.w = w + 0.1 * sz; + out + } + + /// A stretchy grouping mark drawn across `w`: a brace or an arrow. Its origin is the edge + /// nearest the text; it grows downward for an under-mark and upward for an over-mark. + fn group_shape(&self, ch: char, w: f64, sz: f64, top: bool) -> Option { + let t = (self.hbar(sz) * 1.1).max(0.045 * sz); + let w = w.max(0.5 * sz); + let mut b = MathBox::default(); + match ch { + '\u{23DE}' | '\u{23DF}' | '\u{23B4}' | '\u{23B5}' => { + let h = 0.4 * sz; + let r = (h / 2.0).min(w / 4.0 - t).max(0.02 * sz); + let (y0, ym) = (t / 2.0, t / 2.0 + r); + let hw = w / 2.0; + let mut cl = BezPath::new(); + let k = 0.5523 * r; + cl.move_to((t / 2.0, y0)); + cl.curve_to((t / 2.0, y0 + k), (t / 2.0 + r - k, ym), (t / 2.0 + r, ym)); + cl.line_to((hw - r, ym)); + cl.curve_to((hw - r + k, ym), (hw, ym + r - k), (hw, ym + r)); + cl.curve_to((hw, ym + r - k), (hw + r - k, ym), (hw + r, ym)); + cl.line_to((w - t / 2.0 - r, ym)); + cl.curve_to((w - t / 2.0 - r + k, ym), (w - t / 2.0, y0 + k), (w - t / 2.0, y0)); + let stroke = kurbo::Stroke::new(t).with_caps(kurbo::Cap::Butt).with_join(kurbo::Join::Round); + b.path(kurbo::stroke(cl.elements().iter().copied(), &stroke, &kurbo::StrokeOpts::default(), 0.05)); + // Keep the full height even if the stroke bounds are a hair smaller. + b.asc = 0.0; + b.desc = 2.0 * r + t; + b.w = w; + } + '\u{2192}' | '\u{2190}' | '\u{2194}' => { + let head = 0.26 * sz; + let y = 0.17 * sz + t; + let (x0, x1) = (0.02 * sz, w - 0.02 * sz); + let mut p = BezPath::new(); + add_segment(&mut p, (x0, y), (x1, y), t, false); + if ch != '\u{2190}' { + add_segment(&mut p, (x1, y), (x1 - head, y - 0.17 * sz), t, true); + add_segment(&mut p, (x1, y), (x1 - head, y + 0.17 * sz), t, true); + } + if ch != '\u{2192}' { + add_segment(&mut p, (x0, y), (x0 + head, y - 0.17 * sz), t, true); + add_segment(&mut p, (x0, y), (x0 + head, y + 0.17 * sz), t, true); + } + b.path(p); + b.asc = 0.0; + b.desc = 2.0 * y; + b.w = w; + } + _ => return None, + } + if top { + // Mirror about the near edge so the ends point down at the text. + for it in &mut b.items { + if let Item::Path(p) = it { + p.apply_affine(Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 0.0])); + } + } + std::mem::swap(&mut b.asc, &mut b.desc); + } + Some(b) + } + + /// Summation and product signs drawn as shapes, heavy and square like a typeset display + /// operator, centred on the math axis. `None` for other operators. + /// A right or left arrow drawn with a full-length shaft and a clear head (the text fonts' + /// own arrows are short and faint at script size). + fn arrow(&self, c: char, sz: f64) -> Option { + let dir = match c { + '\u{2192}' => 1.0, + '\u{2190}' => -1.0, + _ => return None, + }; + let t = (self.hbar(sz) * 0.9).max(0.035 * sz); + let w = 0.95 * sz; + let y = -self.axis(sz); + let (tail, tip) = if dir > 0.0 { (0.04 * sz, w - 0.02 * sz) } else { (w - 0.04 * sz, 0.02 * sz) }; + let head = 0.26 * sz; + let mut p = BezPath::new(); + add_segment(&mut p, (tail, y), (tip - dir * t, y), t, false); + add_segment(&mut p, (tip, y), (tip - dir * head, y - 0.17 * sz), t, true); + add_segment(&mut p, (tip, y), (tip - dir * head, y + 0.17 * sz), t, true); + let mut b = MathBox::default(); + b.path(p); + b.w = w; + Some(b) + } + + fn big_op(&self, c: char, display: bool, sz: f64) -> Option { + let h = if display { 1.7 * sz } else { 0.95 * sz }; + let top = -self.axis(sz) - h / 2.0; + let mut p = BezPath::new(); + let pad = 0.05 * sz; + let w; + match c { + '\u{2211}' => { + // Thin bars with small serifs, a thin upper diagonal and a heavy lower one. + w = 0.86 * h; + let tb = (self.hbar(sz) * 1.2).min(0.055 * h); + let at = |x: f64, y: f64| (pad + x, top + y); + let (sw, sh) = (0.045 * h, 0.11 * h); + add_poly(&mut p, &[at(0.0, 0.0), at(w, 0.0), at(w, sh), at(w - sw, sh), at(w - sw, tb), at(0.0, tb)]); + add_poly(&mut p, &[at(0.0, h), at(w, h), at(w, h - sh), at(w - sw, h - sh), at(w - sw, h - tb), at(0.0, h - tb)]); + let m = (0.56 * w, h / 2.0); + add_segment(&mut p, at(0.05 * w, tb * 0.5), at(m.0, m.1), 0.05 * h, true); + add_segment(&mut p, at(m.0, m.1), at(0.1 * w, h - tb * 0.5), 0.07 * h, true); + } + '\u{220F}' => { + w = 0.9 * h; + let (tb, lg) = (0.075 * h, 0.1 * h); + let at = |x: f64, y: f64| (pad + x, top + y); + add_poly(&mut p, &[at(0.0, 0.0), at(w, 0.0), at(w, tb), at(0.0, tb)]); + for x0 in [0.1 * w, 0.9 * w - lg] { + add_poly(&mut p, &[at(x0, 0.0), at(x0 + lg, 0.0), at(x0 + lg, h), at(x0, h)]); + } + add_poly( + &mut p, + &[at(0.04 * w, h - 0.07 * h), at(0.1 * w + lg + 0.05 * w, h - 0.07 * h), at(0.1 * w + lg + 0.05 * w, h), at(0.04 * w, h)], + ); + add_poly( + &mut p, + &[at(0.9 * w - lg - 0.05 * w, h - 0.07 * h), at(0.96 * w, h - 0.07 * h), at(0.96 * w, h), at(0.9 * w - lg - 0.05 * w, h)], + ); + } + _ => return None, + } + let mut b = MathBox::default(); + b.path(p); + b.w = w + 2.0 * pad; + Some(b) + } + + /// Parentheses, braces and angle brackets drawn as shapes of the requested height: matched + /// strokes at every size instead of a scaled text glyph. + fn path_delim(&self, c: char, target: f64, sz: f64) -> Option { + let (top, bot) = (-self.axis(sz) - target / 2.0, -self.axis(sz) + target / 2.0); + let pad = 0.06 * sz; + let mut b = MathBox::default(); + match c { + '(' | ')' => { + let open = c == '('; + // Proportions follow the font's own parenthesis: its width sets the belly, the + // stem of `|` the weight; taller shapes only deepen the belly slowly. + let (gw, gh) = self.ink_em('(').unwrap_or((0.2, 0.9)); + let grow = (target / (gh * sz).max(1e-6) - 1.0).clamp(0.0, 4.0); + let thick = self.stem(sz) * 2.1; + let tip = (thick * 0.25).max(0.4); + let bulge = ((gw * sz - tip).max(0.09 * sz) * (1.25 + 0.15 * grow)).min(0.34 * sz); + let wid = bulge + tip; + let x = if open { pad + wid } else { pad }; + b.path(paren_path(open, x, top, bot, bulge, thick, tip)); + b.w = 2.0 * pad + wid; + } + '{' | '}' => { + let open = c == '{'; + let sw = self.stem(sz) * 1.3; + let wid = 0.3 * sz; + let (mid, q) = ((top + bot) / 2.0, (target * 0.12).min(0.3 * sz).max(0.12 * sz)); + // Centre line in a box of width `wid`, drawn for `{` and mirrored for `}`. + let fx = |x: f64| if open { pad + sw / 2.0 + x } else { pad + wid - sw / 2.0 - x }; + let xs = 0.5 * wid; // stem column + let xt = wid - sw; // tip column (outer end of the arms) + let (t0, b0) = (top + sw / 2.0, bot - sw / 2.0); + let mut cl = BezPath::new(); + cl.move_to((fx(xt), t0)); + cl.curve_to((fx(xs), t0), (fx(xs), t0 + q * 0.2), (fx(xs), t0 + q)); + cl.line_to((fx(xs), mid - q)); + cl.curve_to((fx(xs), mid - q * 0.2), (fx(0.0), mid), (fx(0.0), mid)); + cl.curve_to((fx(xs), mid), (fx(xs), mid + q * 0.2), (fx(xs), mid + q)); + cl.line_to((fx(xs), b0 - q)); + cl.curve_to((fx(xs), b0 - q * 0.2), (fx(xs), b0), (fx(xt), b0)); + let st = kurbo::Stroke::new(sw).with_caps(kurbo::Cap::Butt).with_join(kurbo::Join::Round); + b.path(kurbo::stroke(cl.elements().iter().copied(), &st, &kurbo::StrokeOpts::default(), 0.05)); + b.w = 2.0 * pad + wid; + } + '\u{27E8}' | '\u{27E9}' | '\u{2329}' | '\u{232A}' => { + let open = matches!(c, '\u{27E8}' | '\u{2329}'); + let sw = self.stem(sz).max(0.4) * 1.4; + let wid = (0.12 * sz + 0.1 * target).min(0.4 * sz); + let (a, z) = if open { (pad + wid, pad) } else { (pad, pad + wid) }; + let mid = (top + bot) / 2.0; + let mut p = BezPath::new(); + add_segment(&mut p, (a, top), (z, mid), sw, true); + add_segment(&mut p, (z, mid), (a, bot), sw, true); + b.path(p); + b.w = 2.0 * pad + wid; + } + _ => return None, + } + Some(b) + } + + fn delimiters(&self, open: &str, close: &str, sep: &str, items: Vec, sz: f64) -> MathBox { + let axis = self.axis(sz); + let (mut asc, mut desc) = (0.0f64, 0.0f64); + for b in &items { + asc = asc.max(b.asc); + desc = desc.max(b.desc); + } + let half = (asc - axis).max(desc + axis).max(0.0); + // Brackets enclose their content: as tall as it, plus a hair of overshoot. + let target = (2.0 * half + 0.05 * sz).max(sz); + // Drawn (stretched) delimiters overshoot the content a little, like extensible ones. + let big = target + 0.16 * sz; + let make = |s: &str| -> Option { + let c = s.chars().find(|c| !c.is_whitespace())?; + // Around ordinary text a bracket is the font's own glyph, so it matches the text. + if matches!(c, '(' | ')' | '{' | '}') + && let Some((_, gh)) = self.ink_em(c) + && target <= gh * sz * 1.3 + { + return Some(self.stretch(c, target, sz)); + } + Some(self.rule_delim(c, big, sz).or_else(|| self.path_delim(c, big, sz)).unwrap_or_else(|| self.stretch(c, big, sz))) + }; + let mut parts = vec![]; + if let Some(o) = make(open) { + // A brace with nothing closing it (cases) keeps the rows a clear distance away. + let gap = if open.trim() == "{" && close.trim().is_empty() { 0.3 } else { 0.02 }; + parts.push(o); + parts.push(MathBox::space(gap * sz)); + } + let n = items.len(); + for (i, b) in items.into_iter().enumerate() { + parts.push(b); + if i + 1 < n { + parts.push(MathBox::space(0.1 * sz)); + // A bar separator stretches with the brackets; a comma or semicolon is plain text. + if sep.chars().all(|c| c.is_whitespace() || matches!(c, ',' | ';' | '\u{FF0C}')) { + parts.push(self.text(sep.trim(), Var::Upright, sz)); + } else if let Some(s) = make(sep) { + parts.push(s); + } + parts.push(MathBox::space(0.1 * sz)); + } + } + if let Some(c) = make(close) { + parts.push(MathBox::space(0.02 * sz)); + parts.push(c); + } + MathBox::row(parts) + } + + /// Delimiters drawn as rules (brackets, floors, ceilings, bars): crisp and as tall as asked. + fn rule_delim(&self, c: char, target: f64, sz: f64) -> Option { + let t = self.stem(sz) * 1.35; + let arm = 0.28 * sz; + let pad = 0.06 * sz; + let (top, bot) = (-self.axis(sz) - target / 2.0, -self.axis(sz) + target / 2.0); + let mut b = MathBox::default(); + match c { + '[' | '\u{230A}' | '\u{2308}' => { + b.rule(pad, top, pad + t, bot); + if c != '\u{230A}' { + b.rule(pad, top, pad + arm, top + t); + } + if c != '\u{2308}' { + b.rule(pad, bot - t, pad + arm, bot); + } + b.w = pad + arm + pad; + } + ']' | '\u{230B}' | '\u{2309}' => { + b.rule(pad + arm - t, top, pad + arm, bot); + if c != '\u{230B}' { + b.rule(pad, top, pad + arm, top + t); + } + if c != '\u{2309}' { + b.rule(pad, bot - t, pad + arm, bot); + } + b.w = pad + arm + pad; + } + '|' | '\u{2223}' => { + b.rule(pad, top, pad + t, bot); + b.w = 2.0 * pad + t; + } + '\u{2016}' | '\u{2225}' => { + b.rule(pad, top, pad + t, bot); + b.rule(pad + 2.0 * t, top, pad + 3.0 * t, bot); + b.w = 2.0 * pad + 3.0 * t; + } + _ => return None, + } + Some(b) + } + + fn matrix(&self, cells: Vec>, sz: f64) -> MathBox { + let axis = self.axis(sz); + let ncols = cells.iter().map(|r| r.len()).max().unwrap_or(0); + let mut col_w = vec![0.0f64; ncols]; + let mut row_h: Vec<(f64, f64)> = vec![]; + for r in &cells { + let (mut a, mut d) = (0.7 * sz, 0.3 * sz); + for (j, c) in r.iter().enumerate() { + if let Some(w) = col_w.get_mut(j) { + *w = w.max(c.w); + } + a = a.max(c.asc); + d = d.max(c.desc); + } + row_h.push((a, d)); + } + let col_gap = 1.0 * sz; + let row_gap = 0.2 * sz; + let total: f64 = row_h.iter().map(|(a, d)| a + d).sum::() + row_gap * row_h.len().saturating_sub(1) as f64; + let mut out = MathBox::default(); + let mut y_top = -(total / 2.0 + axis); + for (r, (a, d)) in cells.into_iter().zip(row_h) { + let base = y_top + a; + let mut x = 0.0; + for (j, c) in r.into_iter().enumerate() { + let cw = col_w.get(j).copied().unwrap_or(0.0); + let w = c.w; + out.put(c, x + (cw - w) / 2.0, base); + x += cw + col_gap; + } + y_top += a + d + row_gap; + } + out.asc = out.asc.max(total / 2.0 + axis); + out.desc = out.desc.max(total / 2.0 - axis); + out.w = col_w.iter().sum::() + col_gap * ncols.saturating_sub(1) as f64; + out + } + + fn accent(&self, ch: char, body: MathBox, sz: f64) -> MathBox { + let (w, asc, desc) = (body.w, body.asc, body.desc); + let t = (0.05 * sz).max(0.4); + let mut out = MathBox::default(); + let top = asc.max(self.x_height(sz)) + 0.05 * sz; + out.put(body, 0.0, 0.0); + let spacing = match ch { + '\u{302}' => Some('\u{2C6}'), + '\u{303}' => Some('\u{2DC}'), + '\u{307}' => Some('\u{2D9}'), + '\u{308}' => Some('\u{A8}'), + '\u{30C}' => Some('\u{2C7}'), + '\u{301}' => Some('\u{B4}'), + '\u{300}' => Some('`'), + '\u{306}' => Some('\u{2D8}'), + '\u{20D7}' => Some('\u{2192}'), + '\u{304}' | '\u{305}' => None, + c => Some(c), + }; + match spacing { + None => out.rule(0.0, -(top + t), w, -top), + Some(c) => { + let msz = sz * if c == '\u{2192}' { 0.7 } else { 1.0 }; + let g = self.text(&c.to_string(), Var::Upright, msz); + let face = self.pick(Var::Upright, c); + let gid = face.glyph_for(c); + let k = msz / face.upem.max(1.0); + // Ink bottom just above the body; the mark centred over it. + let bottom = self.ink(&face, gid).map(|(_, _, _, y1)| y1 * k).unwrap_or(-0.5 * sz); + let gw = g.w; + out.put(g, (w - gw) / 2.0, -top - bottom); + } + } + out.desc = out.desc.max(desc); + out.w = out.w.max(w); + out + } +} + +/// Lay out an equation at `size` points in `family` (bold when `bold`). +pub fn layout_math(fonts: &dyn MathFonts, m: &Math, family: &str, size: f64, bold: bool, display: bool) -> MathBox { + layout_math_wrapped(fonts, m, family, size, bold, display, f64::INFINITY) +} + +/// [`layout_math`], breaking the top-level row into lines no wider than `max_w` where it can +/// (after binary operators and relations first, between any two items when there is none). +pub fn layout_math_wrapped(fonts: &dyn MathFonts, m: &Math, family: &str, size: f64, bold: bool, display: bool, max_w: f64) -> MathBox { + let mut cur = size.clamp(0.5, 4000.0); + let floor = cur * 0.3; + let mut out = lay_once(fonts, m, family, cur, bold, display, max_w); + // Something unbreakable (a wide matrix) that still overflows the line: shrink it to fit, + // rather than letting the page clip it. + for _ in 0..3 { + if !(max_w.is_finite() && max_w > 0.0 && out.w > max_w * 1.001 && cur > floor) { + break; + } + cur = (cur * max_w / out.w * 0.98).max(floor); + out = lay_once(fonts, m, family, cur, bold, display, max_w); + } + out +} + +fn lay_once(fonts: &dyn MathFonts, m: &Math, family: &str, size: f64, bold: bool, display: bool, max_w: f64) -> MathBox { + let mut lay = Lay { + fonts, + faces: Faces::new(fonts, family), + size, + bold, + budget: NODE_BUDGET, + path: vec![], + cur: 0, + wrap: max_w, + brks: vec![], + nary_body: false, + thin_at: None, + }; + let st = if display { St::Display } else { St::Text }; + lay.seq(&m.body, st, 0) +} + +/// An equation laid out on its own (the equation editor's canvas), with the geometry that +/// carets, selections and mouse clicks need. Coordinates are points from the top-left corner. +pub struct EqLayout { + /// Glyphs, rules and shapes, ready for the renderers (its size is the canvas size). + pub scene: Scene, + geo: Vec, + baseline: f64, +} + +impl EqLayout { + /// Canvas width, points (padding included). + pub fn width(&self) -> f64 { + self.scene.width + } + + /// Canvas height, points (padding included). + pub fn height(&self) -> f64 { + self.scene.height + } + + /// The equation's baseline, points from the top of the canvas. + pub fn baseline(&self) -> f64 { + self.baseline + } + + fn row(&self, path: &[(usize, usize)]) -> Option<&SeqGeo> { + self.geo.iter().find(|g| g.path == path) + } + + /// The thin rectangle of the caret at `c`; `None` when the row was not laid out (too deep). + pub fn caret_rect(&self, c: &equashion_math::Caret) -> Option { + let g = self.row(&c.path)?; + let x = g.xs.get(c.pos).or(g.xs.last()).copied()?; + Some(Rect::new(x, g.top, x, g.bottom)) + } + + /// The box of the whole row at `path` (the active slot's highlight); `None` when the row was + /// not laid out. + pub fn row_rect(&self, path: &[(usize, usize)]) -> Option { + let g = self.row(path)?; + let (x0, x1) = (g.xs.first().copied()?, g.xs.last().copied()?); + Some(Rect::new(x0.min(x1), g.top, x0.max(x1), g.bottom)) + } + + /// The rectangle covering units `a..b` of the row at `path`. + pub fn span_rect(&self, path: &[(usize, usize)], a: usize, b: usize) -> Option { + let g = self.row(path)?; + let x0 = g.xs.get(a).or(g.xs.last()).copied()?; + let x1 = g.xs.get(b).or(g.xs.last()).copied()?; + Some(Rect::new(x0.min(x1), g.top, x0.max(x1), g.bottom)) + } + + /// Number of laid-out rows. + pub fn rows(&self) -> usize { + self.geo.len() + } + + /// The caret position nearest the point (x, y). + pub fn hit(&self, x: f64, y: f64) -> equashion_math::Caret { + hit_geo(&self.geo, x, y) + } +} + +/// The caret position nearest (x, y) among laid-out rows. +pub fn hit_geo(geo: &[SeqGeo], x: f64, y: f64) -> equashion_math::Caret { + const NEAR: f64 = 1.5; + let dist = |g: &SeqGeo| { + let (x0, x1) = (g.xs.first().copied().unwrap_or(0.0), g.xs.last().copied().unwrap_or(0.0)); + let dx = (x0 - x).max(x - x1).max(0.0); + let dy = (g.top - y).max(y - g.bottom).max(0.0); + dx.hypot(dy) + }; + let mut best: Option<(&SeqGeo, f64)> = None; + for g in geo { + let d = dist(g); + let better = match best { + None => true, + Some((b, bd)) => { + let (near, bnear) = (d <= NEAR, bd <= NEAR); + if near && bnear { + g.path.len() > b.path.len() + } else if near != bnear { + near + } else { + d < bd + } + } + }; + if better { + best = Some((g, d)); + } + } + let Some((g, _)) = best else { return equashion_math::Caret::default() }; + let pos = g.xs.iter().enumerate().min_by(|a, b| (a.1 - x).abs().total_cmp(&(b.1 - x).abs())).map_or(0, |(i, _)| i); + equashion_math::Caret { path: g.path.clone(), pos } +} + +/// Lay an equation out at `size` points in `family` with `pad` points around it, tall enough for +/// a caret even when it is empty. +pub fn layout_equation(fonts: &dyn MathFonts, m: &Math, family: &str, size: f64, display: bool, color: Rgb, pad: f64) -> EqLayout { + layout_equation_wrapped(fonts, m, family, size, display, color, pad, f64::INFINITY) +} + +/// [`layout_equation`], with the top-level row broken into lines no wider than `max_w` points +/// (and an unbreakable equation shrunk to fit), as [`layout_math_wrapped`] does. +pub fn layout_equation_wrapped( + fonts: &dyn MathFonts, + m: &Math, + family: &str, + size: f64, + display: bool, + color: Rgb, + pad: f64, + max_w: f64, +) -> EqLayout { + let b = layout_math_wrapped(fonts, m, family, size, false, display, max_w); + let sz = size.clamp(0.5, 4000.0); + // The canvas holds every caret too: a row's caret reaches a minimum depth below its baseline + // even when the row has no ink there (the `x` of a denominator), which WordCraft's canvas + // cut off by up to a point or two. + let geo_top = b.geo.iter().map(|g| -g.top).filter(|v| v.is_finite()).fold(0.0, f64::max); + let geo_bottom = b.geo.iter().map(|g| g.bottom).filter(|v| v.is_finite()).fold(0.0, f64::max); + let (asc, desc) = (b.asc.max(0.78 * sz).max(geo_top), b.desc.max(0.25 * sz).max(geo_bottom)); + let (ox, oy) = (pad, pad + asc); + let mut items = Vec::new(); + emit(&b, ox, oy, color, &mut items); + let geo = b + .geo + .iter() + .map(|g| SeqGeo { path: g.path.clone(), xs: g.xs.iter().map(|x| x + ox).collect(), top: g.top + oy, bottom: g.bottom + oy }) + .collect(); + EqLayout { scene: Scene { items, width: b.w + 2.0 * pad, height: asc + desc + 2.0 * pad }, geo, baseline: oy } +} + +type Key = (String, String, u64, bool, bool, u64); +/// A cached layout: the box, whether the equation is display math, and the justification of +/// display math (`left`, `right`, `center`, ...; from an `m:oMathPara` source). +pub type Cached = (Arc, bool, Option); + +/// What to lay out: stored OMML, or the typed linear form. +#[derive(Clone, Copy, Debug)] +pub enum Src<'a> { + Omml(&'a str), + Linear(&'a str), +} + +/// Laid-out equations by (source, family, size, weight, display, wrap width). WordCraft kept +/// this as a process-wide static; here the host owns one (or none) and passes it in. +#[derive(Default)] +pub struct LayoutCache { + map: HashMap, +} + +impl LayoutCache { + pub fn new() -> LayoutCache { + LayoutCache::default() + } + + /// Number of cached layouts. + pub fn len(&self) -> usize { + self.map.len() + } + + pub fn is_empty(&self) -> bool { + self.map.is_empty() + } + + /// Forget every cached layout (after the fonts change, say). + pub fn clear(&mut self) { + self.map.clear(); + } + + /// The laid-out box for an equation, cached by (source, family, size, weight). Returns the + /// box, whether the equation is display math (`solo` or an `m:oMathPara` source) and the + /// paragraph justification of display math (`left`, `right`, `center`, ...). + pub fn layout(&mut self, fonts: &dyn MathFonts, src: Src, family: &str, size: f64, bold: bool, solo: bool, max_w: f64) -> Cached { + let text = match src { + Src::Omml(x) => format!("O{x}"), + Src::Linear(x) => format!("L{x}"), + }; + let key: Key = (text, family.to_string(), size.to_bits(), bold, solo, max_w.to_bits()); + if let Some(hit) = self.map.get(&key) { + return hit.clone(); + } + let val = layout_src(fonts, src, family, size, bold, solo, max_w); + if self.map.len() >= CACHE_MAX { + self.map.clear(); + } + self.map.insert(key, val.clone()); + val + } +} + +/// [`LayoutCache::layout`] without a cache. +pub fn layout_src(fonts: &dyn MathFonts, src: Src, family: &str, size: f64, bold: bool, solo: bool, max_w: f64) -> Cached { + let m = match src { + Src::Omml(x) => from_omml(x), + Src::Linear(x) => equashion_math::from_linear(x), + }; + let display = m.para || solo; + (Arc::new(layout_math_wrapped(fonts, &m, family, size, bold, display, max_w)), display, m.jc.clone()) +} + +/// Emit a box with its origin at (x, baseline) as scene primitives. +pub fn emit(b: &MathBox, x: f64, baseline: f64, color: Rgb, out: &mut Vec) { + for it in &b.items { + match it { + Item::Glyphs(g) => out.push(Prim::Glyphs { + font: g.face.face_id(), + size: g.size as f32, + glyphs: g.glyphs.iter().map(|(id, gx, gy)| (*id, (x + gx) as f32, (baseline + gy) as f32)).collect(), + color, + synth_bold: g.fake_bold, + synth_italic: g.fake_italic, + }), + Item::Rule(r) => out.push(Prim::Rule(Rect::new(x + r.x0, baseline + r.y0, x + r.x1, baseline + r.y1), color)), + Item::Path(p) => { + let mut q = p.clone(); + q.apply_affine(Affine::translate((x, baseline))); + out.push(Prim::Path(q, color)); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use equashion_font::FontDb; + use equashion_math::from_linear; + + /// The bundled fonts only: no system fonts, so results do not depend on the machine. + fn fonts() -> &'static FontDb { + static DB: std::sync::OnceLock = std::sync::OnceLock::new(); + DB.get_or_init(FontDb::bundled_only) + } + + /// WordCraft's layout tests use Inter; so do these (Inter has only Regular bundled, so its + /// italic and bold are synthesised). + const FAMILY: &str = "Inter"; + + fn layout_math(m: &Math, _face: &str, size: f64, bold: bool, display: bool) -> MathBox { + super::layout_math(fonts(), m, FAMILY, size, bold, display) + } + + fn layout_math_wrapped(m: &Math, _face: &str, size: f64, bold: bool, display: bool, max_w: f64) -> MathBox { + super::layout_math_wrapped(fonts(), m, FAMILY, size, bold, display, max_w) + } + + fn face() -> &'static str { + FAMILY + } + + fn lay(lin: &str, display: bool) -> MathBox { + let m = from_linear(lin); + layout_math(&m, face(), 20.0, false, display) + } + + #[test] + fn plain_text_has_width_and_height() { + let b = lay("xy", false); + assert!(b.w > 0.0 && b.asc > 0.0); + assert!(b.glyph_items() >= 1); + } + + #[test] + fn a_trailing_differential_takes_a_thin_space() { + let with = lay("\\int_0^1 xdx", true); + let without = lay("\\int_0^1 xex", true); + assert!(with.w > without.w + 0.1 * 20.0, "dx is set off from the integrand"); + // Not an operator body: no space is added. + assert!((lay("xdx", false).w - lay("xex", false).w).abs() < 1.0); + } + + #[test] + fn cfrac_is_a_fraction_and_cases_keep_a_gap_after_the_brace() { + let f = lay("1+\\cfrac{1}{x}", false); + assert!(f.rules() >= 1, "cfrac draws a bar"); + let plain = lay("(x^2 & y)", false); + let cases = lay("\\begin{cases} x^2 & y \\\\ z & w \\end{cases}", false); + assert!(cases.w > plain.w + 0.5 * 20.0, "the brace and the columns are spaced"); + } + + #[test] + fn display_operators_are_large_and_the_root_degree_stays_by_the_hook() { + let d = lay("\\sum_{i=1}^n i", true); + let t = lay("\\sum_{i=1}^n i", false); + assert!(d.asc + d.desc > 1.5 * 20.0 * 1.6 / 1.4 * 0.9, "display sum is tall"); + assert!(d.asc + d.desc > t.asc + t.desc); + let r = lay("\\sqrt[3]{x}", false); + let r0 = lay("\\sqrt{x}", false); + assert!(r.asc < r0.asc + 0.9 * 20.0, "the degree does not float far above the bar"); + assert!(r.w > r0.w); + } + + #[test] + fn fraction_stacks_and_has_a_rule() { + let f = lay("a/b", false); + let flat = lay("ab", false); + assert!(f.rules() >= 1, "fraction bar"); + assert!(f.asc + f.desc > flat.asc + flat.desc); + assert!(f.w > 0.0); + } + + #[test] + fn scripts_raise_and_lower() { + let base = lay("x", false); + let sup = lay("x^2", false); + let sub = lay("x_2", false); + assert!(sup.w > base.w && sup.asc > base.asc); + assert!(sub.w > base.w && sub.desc > base.desc); + assert!(sup.max_size() >= 20.0 - 1e-9); + } + + #[test] + fn root_draws_a_bar_and_grows_with_content() { + let r = lay("sqrt(x)", false); + let tall = lay("sqrt(a/b)", false); + assert!(r.paths() >= 1, "radical sign and bar"); + assert!(tall.asc + tall.desc > r.asc + r.desc); + } + + #[test] + fn display_sum_puts_limits_above_and_below() { + let m = from_linear("sum_(i=1)^n i"); + let disp = layout_math(&m, face(), 20.0, false, true); + let text = layout_math(&m, face(), 20.0, false, false); + assert!(disp.asc + disp.desc > text.asc + text.desc, "display limits stack"); + assert!(disp.w > 0.0 && text.w > 0.0); + } + + #[test] + fn delimiters_grow_around_tall_content() { + let small = lay("(x)", false); + let big = lay("(a/b)", false); + assert_eq!(big.paths(), 2, "tall content gets drawn parentheses"); + assert!(small.paths() == 2 || small.paths() == 0, "text-height parentheses are glyphs or shapes"); + assert!(big.asc + big.desc > small.asc + small.desc); + } + + #[test] + fn matrix_is_taller_than_one_row() { + let m = lay("[a,b;c,d]", false); + let one = lay("[a,b]", false); + assert!(m.asc + m.desc > one.asc + one.desc); + assert!(m.w > 0.0); + } + + #[test] + fn tall_brackets_are_rules_that_enclose_the_content() { + let small = lay("[x]", false); + let big = lay("[a/b]", false); + assert!(small.rules() >= 6, "stem and two arms per bracket"); + assert!(big.rules() > small.rules(), "plus the fraction bar"); + let inner = lay("a/b", false); + assert!(big.asc + big.desc >= inner.asc + inner.desc); + } + + #[test] + fn delimiters_radicals_and_sums_are_drawn_shapes() { + let paren = lay("(x)", false); + assert_eq!(lay("(a/b)", false).paths(), 2, "one crescent per parenthesis"); + assert!(paren.paths() == 2 || paren.paths() == 0); + assert!(lay("sqrt(x)", false).paths() >= 1); + let sum = lay("sum_(i=1)^n i", true); + assert!(sum.paths() >= 1); + let o = "xy"; + let brace = layout_math(&from_omml(o), face(), 20.0, false, false); + assert!(brace.paths() >= 2); + // All shapes stay inside finite extents. + for b in [&paren, &sum, &brace] { + assert!(b.w.is_finite() && b.asc.is_finite() && b.desc.is_finite()); + } + } + + #[test] + fn parenthesis_matches_the_text_it_encloses() { + let m = layout_math(&from_linear("(12)"), face(), 24.0, false, false); + let digits = layout_math(&from_linear("12"), face(), 24.0, false, false); + assert!(m.asc + m.desc < (digits.asc + digits.desc) * 1.8, "no taller than needed"); + assert!(m.asc + m.desc > digits.asc + digits.desc, "still encloses the digits"); + } + + #[test] + fn script_size_operators_keep_a_space() { + let m = from_linear("a/(1+b)"); + let b = layout_math(&m, face(), 20.0, false, true); + let flat = layout_math(&from_linear("1b"), face(), 20.0, false, true); + assert!(b.w > flat.w); + } + + #[test] + fn deep_nesting_is_bounded() { + let s = format!("{}x{}", "(".repeat(5000), ")".repeat(5000)); + let b = lay(&s, false); + assert!(b.w.is_finite() && b.asc.is_finite()); + let s = "x^".repeat(3000); + let b = lay(&s, false); + assert!(b.w.is_finite()); + } + + #[test] + fn all_extents_are_finite_for_every_template() { + for t in equashion_math::templates() { + let b = layout_math(&t.math, face(), 18.0, false, true); + assert!(b.w.is_finite() && b.asc.is_finite() && b.desc.is_finite(), "{}", t.id); + assert!(b.w >= 0.0 && b.asc >= 0.0 && b.desc >= 0.0, "{}", t.id); + } + } + + #[test] + fn layout_cache_returns_the_same_box() { + let o = equashion_math::to_omml(&from_linear("a^2+b^2")); + let mut cache = LayoutCache::new(); + let a = cache.layout(fonts(), Src::Omml(&o), FAMILY, 24.0, false, false, f64::INFINITY).0; + let b = cache.layout(fonts(), Src::Omml(&o), FAMILY, 24.0, false, false, f64::INFINITY).0; + assert!(Arc::ptr_eq(&a, &b)); + assert_eq!(cache.len(), 1); + let c = cache.layout(fonts(), Src::Linear("a^2+b^2"), FAMILY, 24.0, false, false, f64::INFINITY).0; + assert_eq!(cache.len(), 2, "linear and OMML sources are cached apart"); + assert!((c.width() - a.width()).abs() < 1e-9, "the same equation either way"); + cache.clear(); + assert!(cache.is_empty()); + } + + #[test] + fn emit_places_glyphs_relative_to_origin() { + let b = lay("a/b", false); + let mut out = vec![]; + emit(&b, 100.0, 50.0, Rgb(0, 0, 0), &mut out); + assert!(out.iter().any(|d| matches!(d, Prim::Rule(..)))); + for d in &out { + match d { + Prim::Glyphs { glyphs, .. } => assert!(glyphs.iter().all(|g| g.1 >= 100.0 - 1e-3)), + Prim::Rule(rect, _) => assert!(rect.x0 >= 100.0 - 1e-3), + Prim::Path(..) => {} + } + } + } + + /// Every row path of a tree (the root included). + fn all_rows(seq: &[Node], path: &mut Vec<(usize, usize)>, out: &mut Vec>) { + out.push(path.clone()); + for (i, n) in seq.iter().enumerate() { + for (c, k) in equashion_math::children(n).into_iter().enumerate() { + path.push((i, c)); + all_rows(k, path, out); + path.pop(); + } + } + } + + fn eq(m: &Math) -> EqLayout { + layout_equation(fonts(), m, FAMILY, 28.0, false, Rgb(0, 0, 0), 6.0) + } + + #[test] + fn every_caret_position_of_every_template_has_a_rectangle() { + for t in equashion_math::templates() { + let mut ed = equashion_math::Editor::new(Math::default()); + ed.type_str("x+"); + ed.insert_template(&t.math); + ed.type_str("y"); + let shown = ed.display_math(); + let l = eq(&shown); + let mut rows = vec![]; + all_rows(&shown.body, &mut vec![], &mut rows); + for path in rows { + let Some(seq) = equashion_math::seq_at(&shown.body, &path) else { continue }; + let mut last_x = f64::MIN; + for pos in 0..=equashion_math::units(seq) { + let c = equashion_math::Caret { path: path.clone(), pos }; + let r = l.caret_rect(&c).unwrap_or_else(|| panic!("{}: no rect for {c:?}", t.id)); + assert!(r.x0.is_finite() && r.y1 > r.y0, "{}: {r:?}", t.id); + assert!( + r.x0 >= -0.5 && r.x0 <= l.width() + 0.5 && r.y0 >= -0.5 && r.y1 <= l.height() + 0.5, + "{}: {c:?} {r:?} in {}x{}", + t.id, + l.width(), + l.height() + ); + assert!(r.x0 >= last_x - 1e-6, "{}: carets run left to right in {path:?}", t.id); + last_x = r.x0; + } + } + } + } + + #[test] + fn script_carets_sit_higher_and_fraction_rows_stack() { + let mut ed = equashion_math::Editor::new(Math::default()); + ed.type_str("x^2"); + let l = eq(&ed.display_math()); + let sup = l.caret_rect(ed.caret()).unwrap_or_default(); + let base = l.caret_rect(&equashion_math::Caret { path: vec![], pos: 0 }).unwrap_or_default(); + assert!(sup.y0 <= base.y0 + 1e-6 && sup.y1 < base.y1 && sup.y1 - sup.y0 < base.y1 - base.y0, "{sup:?} {base:?}"); + let mut ed = equashion_math::Editor::new(Math::default()); + ed.type_str("a/b"); + let l = eq(&ed.display_math()); + let den = l.caret_rect(ed.caret()).unwrap_or_default(); + let num = l.caret_rect(&equashion_math::Caret { path: vec![(0, 0)], pos: 0 }).unwrap_or_default(); + assert!(num.y1 <= den.y0 + 1.0, "numerator above denominator: {num:?} {den:?}"); + } + + #[test] + fn clicking_a_caret_rectangle_returns_that_caret() { + let mut ed = equashion_math::Editor::new(Math::default()); + ed.type_str("1+x^2/sqrt"); + ed.type_str("y"); + let shown = ed.display_math(); + let l = eq(&shown); + let mut rows = vec![]; + all_rows(&shown.body, &mut vec![], &mut rows); + let mut checked = 0; + for path in rows { + let Some(seq) = equashion_math::seq_at(&shown.body, &path) else { continue }; + // Not the boundary shared by two neighbours; the middle of a row is unambiguous. + let n = equashion_math::units(seq); + for pos in 0..=n { + let c = equashion_math::Caret { path: path.clone(), pos }; + let Some(r) = l.caret_rect(&c) else { continue }; + let (cx, cy) = (r.x0, (r.y0 + r.y1) / 2.0); + let got = l.hit(cx, cy); + // The same place can be reached from two rows (end of one, start of the next); + // it must at least be at the same x. + let gr = l.caret_rect(&got).unwrap_or_default(); + assert!((gr.x0 - cx).abs() < 2.0, "{c:?} -> {got:?}"); + checked += 1; + } + } + assert!(checked > 8); + // Far outside lands on the nearest end of the top row. + assert_eq!(l.hit(-500.0, 0.0), equashion_math::Caret { path: vec![], pos: 0 }); + assert_eq!(l.hit(5000.0, 0.0).path, Vec::<(usize, usize)>::new()); + } + + #[test] + fn an_empty_equation_still_has_a_caret_row() { + let l = eq(&Math::default()); + assert_eq!(l.rows(), 1); + assert!(l.caret_rect(&equashion_math::Caret::default()).is_some()); + assert!(l.height() > 10.0); + } + + #[test] + fn layout_geometry_survives_deep_trees() { + let mut node = Node::Text("x".into()); + for _ in 0..(MAX_DEPTH * 3) { + node = Node::Bar { top: true, body: vec![node] }; + } + let l = eq(&Math::new(vec![node])); + assert!(l.rows() <= MAX_DEPTH + 2); + let _ = l.hit(10.0, 10.0); + } + + #[test] + fn explicit_spaces_have_their_widths() { + let plain = lay("ab", false).w; + let thin = lay(r"a\,b", false).w; + let quad = lay(r"a\quad b", false).w; + assert!(thin > plain + 1.0, "thin {thin} vs {plain}"); + assert!(quad > thin + 8.0, "quad {quad} vs {thin}"); + } + + #[test] + fn a_fraction_takes_a_thin_space_from_what_follows() { + let f = lay("a/b", false).w; + let x = lay("x", false).w; + let both = lay("a/b x", false).w; + assert!(both > f + x + 1.0, "{both} vs {f}+{x}"); + } + + #[test] + fn a_differential_after_a_thin_space_is_not_spaced_twice() { + let one = lay(r"\int f\,dx", false).w; + let none = lay(r"\int f dx", false).w; + assert!((one - none).abs() < 1.0, "{one} vs {none}"); + } + + #[test] + fn arrows_are_drawn_full_length() { + let a = lay("x->0", false); + assert!(a.paths() >= 1, "arrow is a shape"); + assert!(a.w > lay("x=0", false).w * 0.8); + } + + #[test] + fn a_broken_inline_equation_reports_its_last_line() { + let m = from_linear(&"a+b+c+d+e+f+".repeat(8)); + let b = layout_math_wrapped(&m, face(), 20.0, false, false, 120.0); + let lw = b.last_line_width().expect("wrapped"); + assert!(lw > 0.0 && lw <= b.w + 1e-9); + // The last line sits on the baseline: the lines above raise the box. + assert!(b.asc > 30.0); + let one = layout_math_wrapped(&m, face(), 20.0, false, false, f64::INFINITY); + assert!(one.last_line_width().is_none()); + } + + #[test] + fn braces_span_the_operand_and_the_label_is_centred() { + let body = lay("a+b+c+d", false); + let b = lay(r"\underbrace{a+b+c+d}_{n}", false); + assert_eq!(b.paths(), 1, "one drawn brace, no separate rule"); + assert_eq!(b.rules(), 0); + let brace = b.items.iter().find_map(|i| if let Item::Path(p) = i { Some(p.bounding_box()) } else { None }).expect("brace"); + assert!((brace.width() - body.w).abs() < 0.1 * body.w, "brace {} vs operand {}", brace.width(), body.w); + // The label sits below the brace, centred. + let label = b.items.iter().rev().find_map(|i| if let Item::Glyphs(g) = i { g.glyphs.first().copied() } else { None }).expect("label"); + assert!(label.2 > brace.y1 - 0.5, "label below the brace"); + assert!((label.1 - body.w / 2.0).abs() < 0.15 * body.w); + let o = lay(r"\overbrace{a+b}^{m}", false); + assert!(o.asc > lay("a+b", false).asc + 5.0, "label above the brace"); + } + + #[test] + fn arrows_over_a_base_span_the_whole_base() { + let b = lay(r"\overrightarrow{AB}", false); + let base = lay("AB", false); + let p = b.items.iter().find_map(|i| if let Item::Path(p) = i { Some(p.bounding_box()) } else { None }).expect("arrow"); + assert!(p.width() > 0.9 * base.w, "arrow {} vs base {}", p.width(), base.w); + } + + #[test] + fn a_wide_unbreakable_matrix_is_shrunk_to_the_line() { + let row = (0..30).map(|j| format!("a_{j}")).collect::>().join("&"); + let m = from_linear(&format!("matrix({row})")); + let free = layout_math_wrapped(&m, face(), 20.0, false, true, f64::INFINITY); + assert!(free.w > 468.0); + let fit = layout_math_wrapped(&m, face(), 20.0, false, true, 468.0); + assert!(fit.w <= 468.0 * 1.01, "{}", fit.w); + } + + #[test] + fn a_display_integral_is_about_two_em_and_hugs_its_limit() { + let b = lay(r"\oint_C f", true); + assert!(b.asc + b.desc < 2.9 * 20.0, "{}", b.asc + b.desc); + } +} diff --git a/crates/equashion-layout/src/scene.rs b/crates/equashion-layout/src/scene.rs new file mode 100644 index 00000000..512b28be --- /dev/null +++ b/crates/equashion-layout/src/scene.rs @@ -0,0 +1,37 @@ +//! The backend-neutral output of equation layout: a list of filled primitives in points, y down, +//! origin at the top-left corner. `equashion-render` turns it into pixels or SVG; a web or egui +//! host can draw it directly. + +use equashion_font::FaceId; + +/// An sRGB colour, 8 bits per channel (the layout's colour; WordCraft took it from its +/// document model). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub struct Rgb(pub u8, pub u8, pub u8); + +impl Rgb { + pub const BLACK: Rgb = Rgb(0, 0, 0); + pub const WHITE: Rgb = Rgb(255, 255, 255); +} + +/// A laid-out equation (or anything else drawn from these primitives). +#[derive(Clone, Debug, Default)] +pub struct Scene { + pub items: Vec, + /// Canvas size, points. + pub width: f64, + pub height: f64, +} + +/// One filled primitive. +#[derive(Clone, Debug)] +pub enum Prim { + /// Glyphs of one face at one size. Each glyph is `(glyph id, x, baseline y)` in points. + /// `synth_bold` asks the backend to embolden (stroke the outline as well as filling it), + /// `synth_italic` to slant it (the face has no real bold or italic). + Glyphs { font: FaceId, size: f32, glyphs: Vec<(u32, f32, f32)>, color: Rgb, synth_bold: bool, synth_italic: bool }, + /// A filled rectangle: fraction bars, overbars, box borders, bracket strokes. + Rule(kurbo::Rect, Rgb), + /// A filled shape (non-zero winding): radical signs, stretched delimiters, big operators. + Path(kurbo::BezPath, Rgb), +} diff --git a/crates/equashion-layout/src/tests.rs b/crates/equashion-layout/src/tests.rs new file mode 100644 index 00000000..4258eb5c --- /dev/null +++ b/crates/equashion-layout/src/tests.rs @@ -0,0 +1,101 @@ +//! Equation tests ported from WordCraft `crates/layout/src/tests.rs`. Upstream they put an +//! equation into a paragraph and inspected the paragraph layout; here they call +//! [`layout_equation`] and [`layout_math_wrapped`] directly. The paragraph-only checks +//! (centring a display equation on its line, justification of the line before it, line padding) +//! have no counterpart without a paragraph layout and were not ported (see PROVENANCE.md). + +use std::sync::OnceLock; + +use equashion_math::{Caret, Math, from_linear, from_omml}; + +use super::*; + +fn fonts() -> &'static FontDb { + static DB: OnceLock = OnceLock::new(); + DB.get_or_init(FontDb::bundled_only) +} + +/// WordCraft's default body text size, points. +const BODY: f64 = 11.0; + +fn eq(m: &Math, display: bool) -> EqLayout { + layout_equation(fonts(), m, DEFAULT_FAMILY, BODY, display, Rgb::BLACK, 0.0) +} + +#[test] +fn an_equation_is_taller_than_text_and_draws_glyphs_a_bar_and_a_radical() { + // Upstream: inline_equation_is_one_char_cluster_taller_than_text. + let plain = eq(&from_linear("ab"), false); + let l = eq(&from_linear("x^2/sqrt(y)"), false); + assert!(l.width() > 10.0, "{}", l.width()); + assert!(l.height() > plain.height(), "a fraction-with-root is taller than plain text"); + let items = &l.scene.items; + assert!(items.iter().any(|i| matches!(i, Prim::Glyphs { .. })), "glyphs"); + assert!(items.iter().any(|i| matches!(i, Prim::Rule(..))), "fraction bar"); + assert!(items.iter().any(|i| matches!(i, Prim::Path(..))), "radical"); + // Carets before and after the equation are its width apart. + let end = equashion_math::units(&from_linear("x^2/sqrt(y)").body); + let (a, b) = (l.caret_rect(&Caret { path: vec![], pos: 0 }).unwrap(), l.caret_rect(&Caret { path: vec![], pos: end }).unwrap()); + assert!((b.x0 - a.x0 - l.width()).abs() < 0.01, "{a:?} {b:?} {}", l.width()); +} + +#[test] +fn equations_from_broken_omml_or_hostile_text_lay_out() { + // Upstream: equations_from_omml_or_hostile_text_lay_out. + let broken = from_omml(""); + for m in [broken, from_linear(""), from_linear("((((((((("), from_linear(&"x^".repeat(5000)), from_linear("\u{0}\u{1}\u{FFFE}")] { + for display in [false, true] { + let l = eq(&m, display); + assert!(l.width().is_finite() && l.height().is_finite() && l.height() > 0.0); + assert!(l.caret_rect(&Caret::default()).is_some()); + } + } +} + +#[test] +fn a_long_inline_equation_breaks_at_operators_within_the_line() { + // Upstream: long_inline_equation_breaks_at_operators_within_the_line (a 6.5in line). + let line = 468.0; + let m = from_linear(&["abc"; 200].join("+")); + let b = layout_math_wrapped(fonts(), &m, DEFAULT_FAMILY, BODY, false, false, line); + assert!(b.width() <= line + 0.5, "{} wider than the line", b.width()); + assert!(b.ascent() + b.descent() > 4.0 * BODY, "several rows tall"); + assert!(b.last_line_width().is_some_and(|w| w > 0.0 && w <= b.width())); + // Every glyph of the scene stays inside the line. + let mut items = vec![]; + emit(&b, 0.0, b.ascent(), Rgb::BLACK, &mut items); + for it in &items { + if let Prim::Glyphs { glyphs, .. } = it { + for g in glyphs { + assert!(f64::from(g.1) < line, "glyph at {} beyond the line", g.1); + } + } + } + // An equation with no operator to break at still wraps between its items. + let m2 = from_linear(&"x^2 ".repeat(300)); + let b2 = layout_math_wrapped(fonts(), &m2, DEFAULT_FAMILY, BODY, false, false, line); + assert!(b2.width() < 500.0, "{}", b2.width()); +} + +#[test] +fn a_display_equation_is_laid_out_in_display_style() { + // What remains of display_equation_line_has_room_above_and_below without a paragraph: + // display style makes a fraction taller than inline style does. + let m = from_linear("x/y"); + let (inline, display) = (eq(&m, false), eq(&m, true)); + assert!(display.height() > inline.height(), "{} vs {}", display.height(), inline.height()); +} + +#[test] +fn the_default_family_is_used_and_named() { + let l = eq(&from_linear("x+1"), false); + let faces: Vec<_> = l + .scene + .items + .iter() + .filter_map(|i| if let Prim::Glyphs { font, .. } = i { fonts().face_by_id(*font) } else { None }) + .map(|f| f.family.clone()) + .collect(); + assert!(!faces.is_empty()); + assert!(faces.iter().all(|f| f == DEFAULT_FAMILY), "{faces:?}"); +} diff --git a/crates/equashion-layout/tests/corpus.rs b/crates/equashion-layout/tests/corpus.rs new file mode 100644 index 00000000..83742baf --- /dev/null +++ b/crates/equashion-layout/tests/corpus.rs @@ -0,0 +1,130 @@ +//! Property test over the shared corpus: every caret position of every corpus equation (linear +//! rows and OMML samples) has a finite caret rectangle inside the canvas, and clicking it comes +//! back to a caret at the same place. The same property over every template is the unit test +//! `every_caret_position_of_every_template_has_a_rectangle` in `src/math.rs`. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::path::PathBuf; +use std::sync::OnceLock; + +use equashion_layout::{DEFAULT_FAMILY, EqLayout, FontDb, Rgb, layout_equation}; +use equashion_math::{Caret, Math, Node, children, from_linear, from_omml, seq_at, units}; + +fn fonts() -> &'static FontDb { + static DB: OnceLock = OnceLock::new(); + DB.get_or_init(FontDb::bundled_only) +} + +fn corpus_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../corpus") +} + +fn corpus() -> Vec<(String, Math, bool)> { + let text = std::fs::read_to_string(corpus_dir().join("equations.tsv")).unwrap(); + let mut v: Vec<(String, Math, bool)> = text + .lines() + .filter(|l| !l.trim().is_empty() && !l.starts_with('#')) + .map(|l| { + let f: Vec<&str> = l.split('\t').collect(); + (f[0].to_string(), from_linear(f[1]), f[2].trim() == "1") + }) + .collect(); + let mut files: Vec<_> = std::fs::read_dir(corpus_dir().join("omml")).unwrap().flatten().map(|e| e.path()).collect(); + files.sort(); + for p in files { + let m = from_omml(&std::fs::read_to_string(&p).unwrap()); + let display = m.para; + v.push((p.file_name().unwrap().to_string_lossy().to_string(), m, display)); + } + v +} + +/// Every row path of a tree (the root included). +fn all_rows(seq: &[Node], path: &mut Vec<(usize, usize)>, out: &mut Vec>) { + out.push(path.clone()); + for (i, n) in seq.iter().enumerate() { + for (c, k) in children(n).into_iter().enumerate() { + path.push((i, c)); + all_rows(k, path, out); + path.pop(); + } + } +} + +fn check(id: &str, m: &Math, l: &EqLayout) -> usize { + let mut rows = vec![]; + all_rows(&m.body, &mut vec![], &mut rows); + let mut n = 0; + for path in rows { + let Some(seq) = seq_at(&m.body, &path) else { continue }; + for pos in 0..=units(seq) { + let c = Caret { path: path.clone(), pos }; + let r = l.caret_rect(&c).unwrap_or_else(|| panic!("{id}: no rect for {c:?}")); + assert!(r.x0.is_finite() && r.y0.is_finite() && r.y1.is_finite() && r.y1 > r.y0, "{id}: {c:?} {r:?}"); + assert!( + r.x0 >= -0.5 && r.x0 <= l.width() + 0.5 && r.y0 >= -0.5 && r.y1 <= l.height() + 0.5, + "{id}: {c:?} {r:?} outside {}x{}", + l.width(), + l.height() + ); + let got = l.hit(r.x0, (r.y0 + r.y1) / 2.0); + let gr = l.caret_rect(&got).unwrap_or_else(|| panic!("{id}: hit gave {got:?} with no rect")); + assert!((gr.x0 - r.x0).abs() < 2.0, "{id}: {c:?} -> {got:?}"); + n += 1; + } + } + n +} + +#[test] +fn every_caret_position_of_every_corpus_equation_has_a_finite_rect() { + let mut total = 0; + for (id, m, display) in corpus() { + for size in [11.0, 24.0] { + let l = layout_equation(fonts(), &m, DEFAULT_FAMILY, size, display, Rgb::BLACK, 4.0); + total += check(&id, &m, &l); + } + } + assert!(total > 500, "{total} caret positions checked"); +} + +/// Source Serif 4 and Inter lack these math symbols; the bundled STIX Two Math covers them, +/// so `bundled_only` (no system fallback) draws them from it. This used to be a pin of the gap +/// (.notdef boxes in the goldens) before STIX Two Math was bundled. +#[test] +fn symbols_the_text_faces_lack_resolve_to_stix_two_math() { + let db = fonts(); + let regular = db.face(DEFAULT_FAMILY, "Regular"); + for c in ['\u{2207}', '\u{222E}', '\u{2308}', '\u{2309}', '\u{230A}', '\u{230B}', '\u{27E8}', '\u{27E9}'] { + assert!(!regular.covers(c), "{c} is now in Source Serif 4: simplify this test"); + let f = db.fallback_for(c, regular.id()).unwrap_or_else(|| panic!("no face for {c}")); + assert_eq!(f.family, "STIX Two Math", "{c}"); + assert_ne!(f.glyph_for(c), 0, "{c}"); + } +} + +/// Every character of every corpus equation has a face, and no laid-out corpus equation draws a +/// .notdef glyph (glyph id 0). +#[test] +fn no_corpus_equation_draws_a_missing_glyph() { + let db = fonts(); + let regular = db.face(DEFAULT_FAMILY, "Regular"); + let mut missing: Vec = corpus() + .iter() + .flat_map(|(_, m, _)| equashion_math::to_linear(m).chars().collect::>()) + .filter(|c| !c.is_ascii() && !c.is_whitespace() && !regular.covers(*c) && db.fallback_for(*c, regular.id()).is_none()) + .collect(); + missing.sort_unstable(); + missing.dedup(); + assert_eq!(missing, Vec::::new(), "characters no bundled font covers"); + for (id, m, display) in corpus() { + let l = layout_equation(db, &m, DEFAULT_FAMILY, 24.0, display, Rgb::BLACK, 4.0); + let notdef: usize = l + .scene + .items + .iter() + .map(|p| if let equashion_layout::Prim::Glyphs { glyphs, .. } = p { glyphs.iter().filter(|g| g.0 == 0).count() } else { 0 }) + .sum(); + assert_eq!(notdef, 0, "{id}: .notdef glyphs drawn"); + } +} diff --git a/crates/equashion-math/Cargo.toml b/crates/equashion-math/Cargo.toml new file mode 100644 index 00000000..09aedf31 --- /dev/null +++ b/crates/equashion-math/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "equashion-math" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "equaSHion equations: math tree, OMML import/export, linear (typed) format, templates, edit model" + +[lints.rust] +unsafe_code = "forbid" + +[lints.clippy] +dbg_macro = "warn" +todo = "warn" +too_many_arguments = "allow" +type_complexity = "allow" +unwrap_used = "deny" +expect_used = "deny" +panic = "deny" +unimplemented = "deny" +unreachable = "deny" + +[dependencies] +serde = { workspace = true } +quick-xml = { workspace = true } +serde_json = { workspace = true } diff --git a/crates/equashion-math/examples/latex_corpus.rs b/crates/equashion-math/examples/latex_corpus.rs new file mode 100644 index 00000000..226176e3 --- /dev/null +++ b/crates/equashion-math/examples/latex_corpus.rs @@ -0,0 +1,8 @@ +//! Prints `inputto_latex(from_linear(input))` for each line of the file given as argument. +fn main() { + let path = std::env::args().nth(1).unwrap_or_default(); + let src = std::fs::read_to_string(&path).unwrap_or_default(); + for l in src.lines().filter(|l| !l.is_empty()) { + println!("{l}\t{}", equashion_math::to_latex(&equashion_math::from_linear(l))); + } +} diff --git a/crates/equashion-math/examples/typed.rs b/crates/equashion-math/examples/typed.rs new file mode 100644 index 00000000..e7dbaa20 --- /dev/null +++ b/crates/equashion-math/examples/typed.rs @@ -0,0 +1,11 @@ +//! `cargo run -p equashion-math --example typed -- 'sqrt(x)+1/2'`: type each argument into an +//! empty editor and print the resulting tree as linear text and LaTeX. +use equashion_math::{Editor, Math}; + +fn main() { + for s in std::env::args().skip(1) { + let mut e = Editor::new(Math::default()); + e.type_str(&s); + println!("{s:24} lin={} tex={}", equashion_math::to_linear(e.math()), e.latex()); + } +} diff --git a/crates/equashion-math/src/edit.rs b/crates/equashion-math/src/edit.rs new file mode 100644 index 00000000..78592680 --- /dev/null +++ b/crates/equashion-math/src/edit.rs @@ -0,0 +1,1993 @@ +//! Direct editing of a math tree: a caret that lives inside the tree, selection, typing with +//! structure keys (`^` `_` `/` `sqrt`), Greek names, arrow navigation in visual order, Backspace +//! that unwraps empty structures, and template insertion at the caret. +//! +//! The caret is a [`Path`] to a child sequence plus a position counted in *units*: one unit per +//! character of a text node and one per structure (a fraction is a single unit of its parent +//! sequence). Nothing here knows about layout or egui, and no input can make it panic: every +//! access is checked, nesting is capped at [`MAX_DEPTH`], and a stale caret is repaired on use. + +use crate::{MAX_DEPTH, Math, Node, Seq}; + +/// Route from the root sequence to a child sequence: at each step the index of a node in the +/// current sequence and the index of one of that node's child sequences (see [`children`]). +pub type Path = Vec<(usize, usize)>; + +/// A position in the tree: inside the sequence at `path`, after `pos` units. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Caret { + pub path: Path, + pub pos: usize, +} + +/// Navigation and deletion keys. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Key { + Left, + Right, + Up, + Down, + Home, + End, + Backspace, + Delete, + /// Next place (cell, numerator, exponent, ...); with Shift the previous one. + Tab, + Undo, + Redo, +} + +impl Key { + /// A key from its name (`left`, `Backspace`, ... any case). + pub fn parse(name: &str) -> Option { + Some(match name.trim().to_ascii_lowercase().as_str() { + "left" => Key::Left, + "right" => Key::Right, + "up" => Key::Up, + "down" => Key::Down, + "home" => Key::Home, + "end" => Key::End, + "backspace" => Key::Backspace, + "delete" | "del" => Key::Delete, + "tab" => Key::Tab, + "undo" => Key::Undo, + "redo" => Key::Redo, + _ => return None, + }) + } +} + +/// The child sequences of a node in reading order. Layout and editing share this order: it is +/// what the second number of a [`Path`] step indexes. +pub fn children(n: &Node) -> Vec<&Seq> { + use std::iter::once; + match n { + Node::Frac { num, den } => vec![num, den], + Node::Rad { deg, body } => deg.iter().chain(once(body)).collect(), + Node::Script { base, sub, sup } => once(base).chain(sub.iter()).chain(sup.iter()).collect(), + Node::Nary { sub, sup, body, .. } => sub.iter().chain(sup.iter()).chain(once(body)).collect(), + Node::Delim { items, .. } => items.iter().collect(), + Node::Matrix { rows } => rows.iter().flatten().collect(), + Node::Func { body, .. } | Node::Accent { body, .. } | Node::Bar { body, .. } | Node::GroupChr { body, .. } | Node::Boxed { body, .. } => { + vec![body] + } + Node::Limit { base, lim, .. } => vec![base, lim], + Node::EqArr { rows } => rows.iter().collect(), + Node::PreScript { sub, sup, base } => vec![sub, sup, base], + Node::Props { node, .. } => children(node), + Node::Text(_) | Node::Styled { .. } | Node::Raw(_) => vec![], + } +} + +/// Mutable [`children`], same order. +pub fn children_mut(n: &mut Node) -> Vec<&mut Seq> { + use std::iter::once; + match n { + Node::Frac { num, den } => vec![num, den], + Node::Rad { deg, body } => deg.iter_mut().chain(once(body)).collect(), + Node::Script { base, sub, sup } => once(base).chain(sub.iter_mut()).chain(sup.iter_mut()).collect(), + Node::Nary { sub, sup, body, .. } => sub.iter_mut().chain(sup.iter_mut()).chain(once(body)).collect(), + Node::Delim { items, .. } => items.iter_mut().collect(), + Node::Matrix { rows } => rows.iter_mut().flatten().collect(), + Node::Func { body, .. } | Node::Accent { body, .. } | Node::Bar { body, .. } | Node::GroupChr { body, .. } | Node::Boxed { body, .. } => { + vec![body] + } + Node::Limit { base, lim, .. } => vec![base, lim], + Node::EqArr { rows } => rows.iter_mut().collect(), + Node::PreScript { sub, sup, base } => vec![sub, sup, base], + Node::Props { node, .. } => children_mut(node), + Node::Text(_) | Node::Styled { .. } | Node::Raw(_) => vec![], + } +} + +fn bare_mut(n: &mut Node) -> &mut Node { + match n { + Node::Props { node, .. } => bare_mut(node), + other => other, + } +} + +/// The text a node carries, if it is a text-like leaf. +fn text_of(n: &Node) -> Option<&str> { + match n.bare() { + Node::Text(t) => Some(t), + Node::Styled { text, .. } => Some(text), + _ => None, + } +} + +fn node_units(n: &Node) -> usize { + text_of(n).map_or(1, |t| t.chars().count()) +} + +/// A term that is one parenthesised group, as a fraction's numerator: the group's contents +/// (the fraction bar makes the parentheses redundant). +fn unwrap_paren(mut term: Vec) -> Vec { + if let [only] = term.as_mut_slice() + && let Node::Delim { open, close, items, .. } = bare_mut(only) + && open == "(" + && close == ")" + && items.len() == 1 + { + return items.pop().unwrap_or_default(); + } + term +} + +/// Number of units in a sequence. +pub fn units(seq: &[Node]) -> usize { + seq.iter().map(node_units).fold(0usize, usize::saturating_add) +} + +/// Units before node `idx`. +fn start_of(seq: &[Node], idx: usize) -> usize { + seq.iter().take(idx).map(node_units).fold(0usize, usize::saturating_add) +} + +/// The node holding the unit that starts at `pos`, and the unit's offset inside it. +fn locate(seq: &[Node], pos: usize) -> Option<(usize, usize)> { + let mut acc = 0usize; + for (i, n) in seq.iter().enumerate() { + let u = node_units(n); + if pos < acc.saturating_add(u) { + return Some((i, pos - acc)); + } + acc = acc.saturating_add(u); + } + None +} + +/// One entry per unit: the character of a text unit, `None` for a structure. +fn unit_chars(seq: &[Node]) -> Vec> { + let mut out = vec![]; + for n in seq { + match text_of(n) { + Some(t) => out.extend(t.chars().map(Some)), + None => out.push(None), + } + } + out +} + +/// The sequence at `path`. +pub fn seq_at<'a>(body: &'a Seq, path: &[(usize, usize)]) -> Option<&'a Seq> { + if path.len() > MAX_DEPTH { + return None; + } + let mut cur = body; + for &(i, c) in path { + cur = children(cur.get(i)?).get(c).copied()?; + } + Some(cur) +} + +/// The sequence at `path`, mutable. +pub fn seq_at_mut<'a>(body: &'a mut Seq, path: &[(usize, usize)]) -> Option<&'a mut Seq> { + if path.len() > MAX_DEPTH { + return None; + } + let mut cur = body; + for &(i, c) in path { + let n = cur.get_mut(i)?; + cur = children_mut(n).into_iter().nth(c)?; + } + Some(cur) +} + +fn split_chars(t: &str, off: usize) -> (String, String) { + (t.chars().take(off).collect(), t.chars().skip(off).collect()) +} + +/// Split a text-like node after `off` characters. +fn split_node(n: &Node, off: usize) -> Option<(Node, Node)> { + match n { + Node::Text(t) => { + let (a, b) = split_chars(t, off); + Some((Node::Text(a), Node::Text(b))) + } + Node::Styled { text, style } => { + let (a, b) = split_chars(text, off); + Some((Node::Styled { text: a, style: *style }, Node::Styled { text: b, style: *style })) + } + Node::Props { extra, node } => { + let (a, b) = split_node(node, off)?; + Some((Node::Props { extra: extra.clone(), node: Box::new(a) }, Node::Props { extra: extra.clone(), node: Box::new(b) })) + } + _ => None, + } +} + +/// Make unit position `pos` a boundary between nodes (splitting a text node if needed) and +/// return the index of the node that starts there. +fn boundary(seq: &mut Seq, pos: usize) -> usize { + let mut acc = 0usize; + let mut i = 0usize; + while i < seq.len() { + if acc >= pos { + return i; + } + let u = seq.get(i).map_or(0, node_units); + if acc.saturating_add(u) <= pos { + acc = acc.saturating_add(u); + i += 1; + continue; + } + let off = pos - acc; + if let Some((a, b)) = seq.get(i).and_then(|n| split_node(n, off)) { + if let Some(slot) = seq.get_mut(i) { + *slot = a; + } + seq.insert(i + 1, b); + return i + 1; + } + return i + 1; + } + seq.len() +} + +/// Join neighbouring plain text nodes and drop empty ones. +fn merge(seq: &mut Seq) { + let old = std::mem::take(seq); + for n in old { + match n { + Node::Text(t) if t.is_empty() => {} + Node::Text(t) => { + if let Some(Node::Text(prev)) = seq.last_mut() { + prev.push_str(&t); + } else { + seq.push(Node::Text(t)); + } + } + other => seq.push(other), + } + } +} + +/// Remove the blank text slots a template carries, so empty places are empty sequences. +fn strip_blanks(seq: &mut Seq, depth: usize) { + if depth > MAX_DEPTH { + return; + } + seq.retain(|n| !matches!(n, Node::Text(t) if t.is_empty())); + for n in seq.iter_mut() { + for child in children_mut(n) { + strip_blanks(child, depth + 1); + } + } +} + +/// Path (relative to `seq`) to the first empty child sequence, depth first. +fn first_empty(seq: &[Node], depth: usize) -> Option { + if depth > MAX_DEPTH { + return None; + } + for (i, n) in seq.iter().enumerate() { + for (c, child) in children(n).into_iter().enumerate() { + if units(child) == 0 { + return Some(vec![(i, c)]); + } + if let Some(mut rest) = first_empty(child, depth + 1) { + rest.insert(0, (i, c)); + return Some(rest); + } + } + } + None +} + +/// [`first_empty`], except that a leading script with an empty base (`ₙC_r`: the n before the +/// letter) is a place of its own: the caret goes to its script, not to its base. +fn first_empty_skipping_prescript(nodes: &[Node]) -> Option { + let lead = match nodes.first() { + Some(n @ Node::Script { base, sub, sup }) if nodes.len() > 1 && base.is_empty() && (sub.is_some() || sup.is_some()) => n, + _ => return first_empty(nodes, 0), + }; + if let Some(c) = children(lead).iter().enumerate().skip(1).find_map(|(c, ch)| (units(ch) == 0).then_some(c)) { + return Some(vec![(0, c)]); + } + let mut rest = first_empty(nodes.get(1..)?, 0)?; + if let Some(first) = rest.first_mut() { + first.0 += 1; + } + Some(rest) +} + +/// Where Up / Down goes from child `c` of `node`, if the structure stacks its children. +fn vertical_neighbor(node: &Node, c: usize, up: bool) -> Option { + match node.bare() { + Node::Frac { .. } => match (c, up) { + (0, false) => Some(1), + (1, true) => Some(0), + _ => None, + }, + Node::Script { sub, sup, .. } => { + let sub_i = sub.is_some().then_some(1usize); + let sup_i = sup.is_some().then_some(1 + usize::from(sub.is_some())); + if up { + if Some(c) == sup_i { + None + } else if Some(c) == sub_i { + sup_i.or(Some(0)) + } else { + sup_i + } + } else if Some(c) == sub_i { + None + } else if Some(c) == sup_i { + sub_i.or(Some(0)) + } else { + sub_i + } + } + Node::Nary { sub, sup, .. } => { + let sub_i = sub.is_some().then_some(0usize); + let sup_i = sup.is_some().then_some(usize::from(sub.is_some())); + if up { + if Some(c) == sup_i { None } else { sup_i } + } else if Some(c) == sub_i { + None + } else { + sub_i + } + } + Node::Limit { lower, .. } => match (c, up, *lower) { + (0, false, true) | (0, true, false) => Some(1), + (1, true, true) | (1, false, false) => Some(0), + _ => None, + }, + Node::Matrix { rows } => { + let mut idx = 0usize; + let mut at = None; + for (r, row) in rows.iter().enumerate() { + if c < idx + row.len() { + at = Some((r, c - idx)); + break; + } + idx += row.len(); + } + let (r, col) = at?; + let tr = if up { r.checked_sub(1)? } else { r + 1 }; + let target = rows.get(tr)?; + let tcol = col.min(target.len().checked_sub(1)?); + let before: usize = rows.iter().take(tr).map(Vec::len).sum(); + Some(before + tcol) + } + Node::EqArr { rows } => { + let t = if up { c.checked_sub(1)? } else { c + 1 }; + (t < rows.len()).then_some(t) + } + Node::Rad { deg: Some(_), .. } => match (c, up) { + (1, true) => Some(0), + (0, false) => Some(1), + _ => None, + }, + Node::PreScript { .. } => match (c, up) { + (0, true) | (2, true) => Some(1), + (1, false) | (2, false) => Some(0), + _ => None, + }, + _ => None, + } +} + +const GREEK: &[(&str, char)] = &[ + ("alpha", '\u{3B1}'), + ("beta", '\u{3B2}'), + ("gamma", '\u{3B3}'), + ("delta", '\u{3B4}'), + ("epsilon", '\u{3B5}'), + ("zeta", '\u{3B6}'), + ("eta", '\u{3B7}'), + ("theta", '\u{3B8}'), + ("iota", '\u{3B9}'), + ("kappa", '\u{3BA}'), + ("lambda", '\u{3BB}'), + ("mu", '\u{3BC}'), + ("nu", '\u{3BD}'), + ("xi", '\u{3BE}'), + ("omicron", '\u{3BF}'), + ("pi", '\u{3C0}'), + ("rho", '\u{3C1}'), + ("sigma", '\u{3C3}'), + ("tau", '\u{3C4}'), + ("upsilon", '\u{3C5}'), + ("phi", '\u{3C6}'), + ("chi", '\u{3C7}'), + ("psi", '\u{3C8}'), + ("omega", '\u{3C9}'), + ("Gamma", '\u{393}'), + ("Delta", '\u{394}'), + ("Theta", '\u{398}'), + ("Lambda", '\u{39B}'), + ("Xi", '\u{39E}'), + ("Pi", '\u{3A0}'), + ("Sigma", '\u{3A3}'), + ("Phi", '\u{3A6}'), + ("Psi", '\u{3A8}'), + ("Omega", '\u{3A9}'), + ("infty", '\u{221E}'), +]; + +/// Words that become a symbol when typed whole (the letters before the caret are exactly the +/// word). `pm` must not fire inside `bpm`, so there is no suffix matching for these. +const WORDS: &[(&str, char)] = &[ + ("pm", '\u{B1}'), + ("times", '\u{D7}'), + ("cdot", '\u{B7}'), + ("leq", '\u{2264}'), + ("geq", '\u{2265}'), + ("neq", '\u{2260}'), + ("approx", '\u{2248}'), + ("partial", '\u{2202}'), + ("nabla", '\u{2207}'), + ("forall", '\u{2200}'), + ("exists", '\u{2203}'), +]; + +/// Characters after which an unparenthesised function argument (`sin x`) is over. +const ARG_ENDERS: &str = "+\u{2212}=<>,\u{B1}\u{D7}\u{B7}"; + +/// Most characters one [`Editor::type_str`] call processes. +const MAX_TYPED: usize = 100_000; + +/// Most single steps one Tab takes looking for the next place. +const MAX_TAB_STEPS: usize = 10_000; + +/// What a typed `\name` becomes. +enum Cmd { + /// A symbol or letter. + Sym(char), + /// A node with no places to fill (an operator word). + Leaf(Node), + /// A structure with empty places, and the place the caret goes to. + Node(Node, usize), + /// Accepted and dropped (`\left`, `\right`). + Nothing, +} + +/// The meaning of a typed LaTeX command name. +fn latex_cmd(name: &str) -> Option { + use crate::linear; + let plain = |t: &str| Node::Styled { text: t.into(), style: crate::Style::Plain }; + Some(match name { + "frac" | "dfrac" | "tfrac" | "cfrac" => Cmd::Node(Node::Frac { num: vec![], den: vec![] }, 0), + "sqrt" => Cmd::Node(Node::Rad { deg: None, body: vec![] }, 0), + "left" | "right" => Cmd::Nothing, + "infty" => Cmd::Sym('\u{221E}'), + "lim" => Cmd::Node(Node::Limit { lower: true, base: vec![plain("lim")], lim: vec![] }, 1), + _ => { + if let Some((op, limits)) = linear::nary_op(name) { + Cmd::Node(Node::Nary { op, sub: limits.then(Vec::new), sup: limits.then(Vec::new), body: vec![] }, 0) + } else if linear::is_bare_func(name) { + Cmd::Node(Node::Func { name: name.into(), body: vec![] }, 0) + } else if linear::is_function_word(name) { + Cmd::Leaf(plain(name)) + } else if let Some(ch) = linear::accent(name) { + Cmd::Node(Node::Accent { ch, body: vec![] }, 0) + } else { + Cmd::Sym(linear::symbol(name)?) + } + } + }) +} + +/// True when some longer command name starts with `name` (`in` is also the start of `int`). +fn has_longer_cmd(name: &str) -> bool { + const EXTRA: [&str; 7] = ["frac", "dfrac", "tfrac", "cfrac", "sqrt", "left", "right"]; + crate::linear::command_names().chain(EXTRA).any(|n| n.len() > name.len() && n.starts_with(name)) +} + +/// A math tree being edited with a caret and an optional selection. +#[derive(Debug, Clone)] +pub struct Editor { + math: Math, + caret: Caret, + /// Other end of the selection, a position in the caret's own sequence. + anchor: Option, + undo: Vec, + redo: Vec, + /// A command or script just made a place: a `{` typed next opens its argument (LaTeX style). + expect_brace: bool, + /// Unclosed argument braces typed so far; a `}` closes the innermost one. + brace_depth: u32, + /// The radical's degree place was opened by a typed `[` after `\sqrt`, so a `]` closes it. + deg_typed: bool, +} + +/// Most edits kept for undo. +const MAX_HISTORY: usize = 200; + +/// A state to go back to. +#[derive(Debug, Clone)] +struct Snap { + math: Math, + caret: Caret, + anchor: Option, +} + +impl Editor { + /// Edit `math` with the caret at the end of the top row. + pub fn new(math: Math) -> Editor { + let mut e = + Editor { math, caret: Caret::default(), anchor: None, undo: vec![], redo: vec![], expect_brace: false, brace_depth: 0, deg_typed: false }; + e.caret.pos = units(&e.math.body); + e + } + + pub fn math(&self) -> &Math { + &self.math + } + + pub fn math_mut(&mut self) -> &mut Math { + &mut self.math + } + + pub fn into_math(self) -> Math { + self.math + } + + pub fn caret(&self) -> &Caret { + &self.caret + } + + /// Replace the whole equation (the caret goes to the end). + pub fn set_math(&mut self, m: Math) { + *self = Editor::new(m); + } + + /// The equation as LaTeX (see [`crate::to_latex`]). + pub fn latex(&self) -> String { + crate::to_latex(&self.math) + } + + /// Replace the whole equation from typed text or LaTeX. Like [`Editor::set_math`] the caret + /// goes to the end and the history starts over, so a host can load a value without leaving + /// an undo step behind. + pub fn set_latex(&mut self, src: &str) { + self.set_math(crate::from_linear(src)); + } + + /// True when the caret is at the very start of the equation (no enclosing slot, position 0). + /// A host uses this to hand an Up or Left key to the next field. + pub fn at_start(&self) -> bool { + self.caret.path.is_empty() && self.caret.pos == 0 + } + + /// True when the caret is at the very end of the top-level equation. + pub fn at_end(&self) -> bool { + self.caret.path.is_empty() && self.caret.pos >= units(&self.math.body) + } + + /// True when nothing has been typed. + pub fn is_blank(&self) -> bool { + units(&self.math.body) == 0 + } + + /// The selected range in the caret's sequence, if any. + pub fn selection(&self) -> Option<(usize, usize)> { + let a = self.anchor?; + (a != self.caret.pos).then(|| (a.min(self.caret.pos), a.max(self.caret.pos))) + } + + /// The sequence the caret is in. + pub fn seq(&self) -> &Seq { + seq_at(&self.math.body, &self.caret.path).unwrap_or(&self.math.body) + } + + fn seq_mut(&mut self) -> Option<&mut Seq> { + seq_at_mut(&mut self.math.body, &self.caret.path) + } + + fn len_here(&self) -> usize { + units(self.seq()) + } + + /// Repair a caret that no longer points into the tree. + fn fix(&mut self) { + if seq_at(&self.math.body, &self.caret.path).is_none() { + self.caret = Caret { path: vec![], pos: units(&self.math.body) }; + self.anchor = None; + } + let n = self.len_here(); + self.caret.pos = self.caret.pos.min(n); + self.anchor = self.anchor.map(|a| a.min(n)); + } + + /// Put the caret at `c` (clamped into the tree), dropping any selection. + pub fn set_caret(&mut self, c: Caret) { + self.expect_brace = false; + self.brace_depth = 0; + self.caret = c; + self.anchor = None; + self.fix(); + } + + /// Move the caret to `c` keeping the selection anchor, when `c` is in the same sequence + /// (a mouse drag). Otherwise the caret just moves there. + pub fn drag_caret(&mut self, c: Caret) { + self.fix(); + if c.path == self.caret.path { + let anchor = self.anchor.unwrap_or(self.caret.pos); + self.caret.pos = c.pos; + self.anchor = Some(anchor); + self.fix(); + } else { + self.set_caret(c); + } + } + + /// Finish a `\name` still being typed (`\in` waits for a longer name): call before reading + /// the result. + pub fn flush(&mut self) { + self.tracked(|e| e.flush_cmd()); + } + + /// The fixed end of the selection as a caret (the caret itself when nothing is selected). + pub fn anchor_caret(&self) -> Caret { + Caret { path: self.caret.path.clone(), pos: self.anchor.unwrap_or(self.caret.pos) } + } + + /// Select from caret `a` to caret `b`, wherever they are: when they are in different rows + /// the selection covers whole structures of the nearest row that holds both (a mouse drag + /// or a shift-click). + pub fn select_between(&mut self, a: &Caret, b: &Caret) { + self.expect_brace = false; + self.brace_depth = 0; + let k = a.path.iter().zip(&b.path).take_while(|(x, y)| x == y).count(); + let Some(seq) = a.path.get(..k).and_then(|p| seq_at(&self.math.body, p)) else { + self.set_caret(b.clone()); + return; + }; + let end = units(seq); + let spot = |c: &Caret| match c.path.get(k) { + Some(&(i, _)) => (start_of(seq, i).min(end), true), + None => (c.pos.min(end), false), + }; + let ((pa, na), (pb, nb)) = (spot(a), spot(b)); + let (anchor, caret) = if pa < pb || (pa == pb && nb) { (pa, pb + usize::from(nb)) } else { (pa + usize::from(na), pb) }; + self.caret = Caret { path: a.path.get(..k).map(<[_]>::to_vec).unwrap_or_default(), pos: caret }; + self.anchor = (anchor != caret).then_some(anchor); + self.fix(); + } + + /// Select the whole top row. + pub fn select_all(&mut self) { + self.caret = Caret { path: vec![], pos: units(&self.math.body) }; + self.anchor = Some(0); + } + + fn can_nest(&self) -> bool { + self.caret.path.len() + 8 < MAX_DEPTH + } + + // ---------- typing ---------- + + /// Type text: every character as if typed on the keyboard. + pub fn type_str(&mut self, s: &str) { + self.tracked(|e| { + for c in s.chars().take(MAX_TYPED) { + e.type_char_raw(c); + } + }); + } + + /// Type one character. + pub fn type_char(&mut self, c: char) { + self.tracked(|e| e.type_char_raw(c)); + } + + /// Run an edit and, when it changed the tree, make it one undo step. + fn tracked(&mut self, f: impl FnOnce(&mut Self)) { + let snap = Snap { math: self.math.clone(), caret: self.caret.clone(), anchor: self.anchor }; + f(self); + if snap.math != self.math { + self.undo.push(snap); + if self.undo.len() > MAX_HISTORY { + self.undo.remove(0); + } + self.redo.clear(); + } + } + + fn snap(&self) -> Snap { + Snap { math: self.math.clone(), caret: self.caret.clone(), anchor: self.anchor } + } + + fn restore(&mut self, s: Snap) { + self.math = s.math; + self.caret = s.caret; + self.anchor = s.anchor; + self.fix(); + } + + /// Step back one edit. False when there is nothing to undo. + pub fn undo(&mut self) -> bool { + let Some(prev) = self.undo.pop() else { return false }; + let cur = self.snap(); + self.redo.push(cur); + self.restore(prev); + true + } + + /// Redo an undone edit. False when there is nothing to redo. + pub fn redo(&mut self) -> bool { + let Some(next) = self.redo.pop() else { return false }; + let cur = self.snap(); + self.undo.push(cur); + self.restore(next); + true + } + + /// The selection as linear text (for Copy), or `None` with no selection. + pub fn selected_linear(&self) -> Option { + let (a, b) = self.selection()?; + let mut tmp = self.clone(); + let body = tmp.take_range(a, b); + let m = Math { body, ..Math::default() }; + Some(crate::to_linear(&m)) + } + + /// Cut: the selection as linear text, removed from the tree. + pub fn cut(&mut self) -> Option { + let t = self.selected_linear()?; + self.key(Key::Delete, false); + Some(t) + } + + fn type_char_raw(&mut self, c: char) { + self.fix(); + if !c.is_ascii_alphabetic() { + self.flush_cmd(); + } + let expect = std::mem::take(&mut self.expect_brace); + if c == '[' && expect && self.open_degree() { + return; + } + if c == ']' && self.deg_typed && self.in_degree() { + self.deg_typed = false; + self.leave_right(); + self.expect_brace = true; + return; + } + if c == '{' && expect { + self.brace_depth = self.brace_depth.saturating_add(1); + return; + } + if c == '}' && self.brace_depth > 0 && !self.in_brace_delim() { + self.brace_depth -= 1; + self.close_arg(); + return; + } + // `x^2` ends after its digits when anything but another digit, `.`, `^` or `_` follows + // (`mv^2/2` is a fraction of `mv^2`, `x^2sin x` is `x^2` times `sin x`). Ported from + // DeckCraft 7972cf5; not inside a typed LaTeX `{...}` argument. + if !(c.is_ascii_digit() || matches!(c, '.' | '^' | '_') || c.is_whitespace() || c.is_control()) + && self.brace_depth == 0 + && self.selection().is_none() + && self.script_slot_here() + && self.caret.pos == self.len_here() + && self.slot_is_digits() + { + self.exit_script_slot(); + } + match c { + '\\' => { + self.leave_func_arg(); + self.insert_char('\\'); + } + '^' => self.script(true), + '_' => self.script(false), + '/' => self.fraction(), + '(' => { + if !self.convert_call() { + self.open_delim("(", ")"); + } + } + '[' => self.open_delim("[", "]"), + '{' => self.open_delim("{", "}"), + ')' | ']' | '}' => { + if !self.close_delim(c) { + self.insert_char(c); + } + } + '-' => { + if !self.replace_prev(&[('+', '\u{B1}')]) { + self.leave_func_arg(); + self.insert_char('\u{2212}'); + } + } + '*' => { + self.leave_func_arg(); + self.insert_char('\u{B7}'); + } + '=' => { + if !self.replace_prev(&[('<', '\u{2264}'), ('>', '\u{2265}'), ('!', '\u{2260}'), ('~', '\u{2248}')]) { + self.leave_func_arg(); + self.insert_char('='); + } + } + '>' => { + if !self.replace_prev(&[('\u{2212}', '\u{2192}')]) { + self.leave_func_arg(); + self.insert_char('>'); + } + } + c if c.is_whitespace() => { + self.leave_nary_limit(false); + } + c if c.is_control() => {} + c if ARG_ENDERS.contains(c) => { + self.leave_func_arg(); + self.insert_char(c); + } + 'h' if self.extend_func_name() => {} + c => { + self.insert_char(c); + if c.is_ascii_alphabetic() && !self.latex_step() { + self.autoconvert(); + } + } + } + } + + // ---------- typed LaTeX commands ---------- + + /// The `\name` being typed right before the caret: where its backslash is and the name. + fn pending_cmd(&self) -> Option<(usize, String)> { + if self.selection().is_some() { + return None; + } + let cls = unit_chars(self.seq()); + let pos = self.caret.pos.min(cls.len()); + let mut s = pos; + while s > 0 && matches!(cls.get(s - 1), Some(Some(c)) if c.is_ascii_alphabetic()) { + s -= 1; + } + if s == pos || s == 0 || cls.get(s - 1) != Some(&Some('\\')) { + return None; + } + let name: String = cls.get(s..pos)?.iter().filter_map(|c| *c).collect(); + Some((s - 1, name)) + } + + /// After a letter of a `\name`: make it right away when nothing longer could be meant + /// (`\alpha`, `\frac`), else wait for the next character. True while a command is pending. + fn latex_step(&mut self) -> bool { + let Some((bs, name)) = self.pending_cmd() else { return false }; + if latex_cmd(&name).is_some() && !has_longer_cmd(&name) { + self.commit_cmd(bs, &name); + } + true + } + + /// A character that cannot continue a command name ends a pending one (`\in` then a space). + fn flush_cmd(&mut self) { + self.flush_word(true); + } + + /// [`Self::flush_cmd`], with or without turning a finished `inf` into the infinity sign. + fn flush_word(&mut self, inf: bool) { + if let Some((bs, name)) = self.pending_cmd() { + if latex_cmd(&name).is_some() { + self.commit_cmd(bs, &name); + } + } else if inf { + self.flush_inf(); + } + } + + /// The word `inf`, finished (by any other character, or by `flush`), becomes the infinity + /// sign. It waits that long because `infty` starts with it. + fn flush_inf(&mut self) { + if self.selection().is_some() { + return; + } + let cls = unit_chars(self.seq()); + let pos = self.caret.pos.min(cls.len()); + let mut s = pos; + while s > 0 && matches!(cls.get(s - 1), Some(Some(c)) if c.is_ascii_alphabetic()) { + s -= 1; + } + let run: String = cls.get(s..pos).unwrap_or(&[]).iter().filter_map(|c| *c).collect(); + if run == "inf" && (s == 0 || cls.get(s - 1) != Some(&Some('\\'))) { + self.delete_range(s, pos); + self.insert_char('\u{221E}'); + } + } + + /// A `[` typed right after `\sqrt`: the radical gets a degree place, which a `]` closes. + fn open_degree(&mut self) -> bool { + if self.selection().is_some() || self.len_here() != 0 { + return false; + } + let Some(&(i, c)) = self.caret.path.last() else { return false }; + let mut parent = self.caret.path.clone(); + parent.pop(); + let Some(Node::Rad { deg, body }) = seq_at_mut(&mut self.math.body, &parent).and_then(|s| s.get_mut(i)).map(bare_mut) else { + return false; + }; + if deg.is_some() || c != 0 || !body.is_empty() { + return false; + } + *deg = Some(vec![]); + self.deg_typed = true; + true + } + + /// The caret is in a radical's degree place. + fn in_degree(&self) -> bool { + let Some(&(i, c)) = self.caret.path.last() else { return false }; + let parent = self.caret.path.get(..self.caret.path.len() - 1).and_then(|p| seq_at(&self.math.body, p)); + c == 0 && matches!(parent.and_then(|s| s.get(i)).map(Node::bare), Some(Node::Rad { deg: Some(_), .. })) + } + + /// Replace the typed `\name` (from `bs` to the caret) by what it stands for. + fn commit_cmd(&mut self, bs: usize, name: &str) { + let Some(cmd) = latex_cmd(name) else { return }; + let pos = self.caret.pos; + if matches!(cmd, Cmd::Node(..)) && !self.can_nest() { + return; + } + self.delete_range(bs, pos); + match cmd { + Cmd::Sym(c) => self.insert_char(c), + Cmd::Nothing => {} + Cmd::Leaf(node) => { + if self.insert_nodes(vec![node]).is_some() { + self.caret.pos += 1; + } + } + Cmd::Node(node, child) => { + if let Some(i) = self.insert_nodes(vec![node]) { + self.caret.path.push((i, child)); + self.caret.pos = 0; + self.expect_brace = true; + } + } + } + } + + /// The innermost place is a `{ }` delimiter pair's content (so a `}` closes that). + fn in_brace_delim(&self) -> bool { + let Some(&(i, _)) = self.caret.path.last() else { return false }; + let parent = self.caret.path.get(..self.caret.path.len() - 1).and_then(|p| seq_at(&self.math.body, p)); + matches!(parent.and_then(|s| s.get(i)).map(Node::bare), Some(Node::Delim { close, .. }) if close == "}") + } + + /// A `}` that ends a LaTeX-style argument: on to the next place of a fraction, otherwise out + /// of the structure. A big operator's limit stays put so `\sum_{i=1}^{n}` can add the other. + fn close_arg(&mut self) { + self.anchor = None; + let Some(&(i, c)) = self.caret.path.last() else { return }; + let parent = self.caret.path.get(..self.caret.path.len() - 1).and_then(|p| seq_at(&self.math.body, p)); + match parent.and_then(|s| s.get(i)).map(Node::bare) { + // The other limit may still follow (`\sum_{i=1}^{n}`), but the last one is done. + Some(Node::Nary { sub, sup, .. }) if c + 1 < usize::from(sub.is_some()) + usize::from(sup.is_some()) => {} + Some(Node::Frac { .. }) if c == 0 => { + self.leave_right(); + self.expect_brace = true; + } + _ => self.leave_right(), + } + } + + /// The character just before the caret, when there is no selection. + fn prev_char(&self) -> Option { + if self.selection().is_some() { + return None; + } + let cls = unit_chars(self.seq()); + cls.get(self.caret.pos.checked_sub(1)?).copied().flatten() + } + + /// If the character before the caret is one of `pairs`' first members, replace it by the + /// second (`+` then `-` makes a plus-minus sign, `<` then `=` makes `<=` as one symbol). + fn replace_prev(&mut self, pairs: &[(char, char)]) -> bool { + let Some(p) = self.prev_char() else { return false }; + let Some(&(_, to)) = pairs.iter().find(|(from, _)| *from == p) else { return false }; + let pos = self.caret.pos; + self.delete_range(pos - 1, pos); + self.insert_char(to); + true + } + + /// The function node whose argument the caret is in. + fn func_here(&self) -> Option<(usize, usize)> { + let &(i, c) = self.caret.path.last()?; + let parent = seq_at(&self.math.body, self.caret.path.get(..self.caret.path.len() - 1)?)?; + matches!(parent.get(i)?.bare(), Node::Func { .. }).then_some((i, c)) + } + + /// The script node whose superscript or subscript the caret is in. + fn script_slot_here(&self) -> bool { + let Some(&(i, c)) = self.caret.path.last() else { return false }; + let Some(parent) = self.caret.path.get(..self.caret.path.len() - 1).and_then(|p| seq_at(&self.math.body, p)) else { + return false; + }; + c >= 1 && matches!(parent.get(i).map(Node::bare), Some(Node::Script { .. })) + } + + /// The script slot the caret is in holds only digits (`x^2`): the next term ends it. + fn slot_is_digits(&self) -> bool { + let cls = unit_chars(self.seq()); + !cls.is_empty() && cls.iter().all(|c| matches!(c, Some(d) if d.is_ascii_digit() || *d == '.')) + } + + /// Step out of the script slot the caret is in, to just after the script. + fn exit_script_slot(&mut self) { + if let Some((i, _)) = self.caret.path.pop() { + self.caret.pos = start_of(self.seq(), i) + 1; + } + } + + /// A filled, unbraced limit of a big operator is over at a space (any limit) or at an + /// operator (the upper limit only: `sum_i=1^n` keeps its `=`): the caret goes on to the + /// operand, so `sum_(i=1)^n i^2` does not pour the summand into the upper limit. True when + /// the caret moved. + fn leave_nary_limit(&mut self, operator: bool) -> bool { + if self.brace_depth > 0 || self.selection().is_some() { + return false; + } + let Some(&(i, c)) = self.caret.path.last() else { return false }; + if self.len_here() == 0 || self.caret.pos != self.len_here() { + return false; + } + let parent = self.caret.path.get(..self.caret.path.len() - 1).and_then(|p| seq_at(&self.math.body, p)); + let Some(Node::Nary { sub, sup, .. }) = parent.and_then(|s| s.get(i)).map(Node::bare) else { return false }; + let limits = usize::from(sub.is_some()) + usize::from(sup.is_some()); + let upper = sup.is_some() && c + 1 == limits; + if c >= limits || (operator && !upper) { + return false; + } + self.caret.path.pop(); + self.caret.path.push((i, limits)); + self.caret.pos = 0; + true + } + + /// Typing an operator ends an unparenthesised function argument (`sin x+1` is `sin x`, plus + /// 1) and leaves a filled exponent or subscript (`x^2+1` is `x` squared, plus 1). + fn leave_func_arg(&mut self) { + for _ in 0..MAX_DEPTH { + if self.selection().is_some() { + return; + } + let n = self.len_here(); + if n == 0 || self.caret.pos != n { + return; + } + if self.func_here().is_some() { + self.leave_right(); + } else if self.script_slot_here() { + // A digit exponent ends at the operator (`x^2+1`); a letter exponent keeps it + // (`x^n+1` is `x^(n+1)`), as in DeckCraft 7972cf5. + if !self.slot_is_digits() { + return; + } + self.exit_script_slot(); + } else { + self.leave_nary_limit(true); + return; + } + } + } + + /// `h` right after a trigonometric name (empty argument) makes its hyperbolic form: + /// `sinh`, `cosh`, `tanh`, `csch`, `sech`, `coth`, `arcsinh`, `arccosh`, `arctanh`. + fn extend_func_name(&mut self) -> bool { + if self.selection().is_some() || self.len_here() != 0 { + return false; + } + let Some((i, _)) = self.func_here() else { return false }; + let mut parent = self.caret.path.clone(); + parent.pop(); + let Some(Node::Func { name, .. }) = seq_at_mut(&mut self.math.body, &parent).and_then(|s| s.get_mut(i)).map(bare_mut) else { + return false; + }; + if matches!(name.as_str(), "sin" | "cos" | "tan" | "csc" | "sec" | "cot" | "arcsin" | "arccos" | "arctan") { + // `csc`, `sec` and `cot` take an `h` to become `csch`, `sech` and `coth`. + name.push('h'); + true + } else { + false + } + } + + /// `(` `[` `{`: a delimiter pair with the caret inside (around the selection, if any). + fn open_delim(&mut self, open: &str, close: &str) { + if !self.can_nest() { + self.insert_char(open.chars().next().unwrap_or('(')); + return; + } + let wrapped = self.selection(); + let items = match wrapped { + Some((a, b)) => self.take_range(a, b), + None => vec![], + }; + let node = Node::Delim { open: open.into(), close: close.into(), sep: "|".into(), items: vec![items] }; + let Some(i) = self.insert_nodes(vec![node]) else { return }; + self.anchor = None; + if wrapped.is_some() { + self.caret.pos += 1; + } else { + self.caret.path.push((i, 0)); + self.caret.pos = 0; + } + } + + /// `)` `]` `}`: step out past the delimiter pair that this character closes, when the caret + /// is at the end of its contents (possibly inside a root or script that ends there). + fn close_delim(&mut self, ch: char) -> bool { + if self.selection().is_some() { + return false; + } + let mut path = self.caret.path.clone(); + let mut pos = self.caret.pos; + for _ in 0..=MAX_DEPTH { + let Some(seq) = seq_at(&self.math.body, &path) else { return false }; + if pos != units(seq) { + return false; + } + let Some((i, c)) = path.pop() else { return false }; + let Some(parent) = seq_at(&self.math.body, &path) else { return false }; + let Some(node) = parent.get(i) else { return false }; + let after = start_of(parent, i) + 1; + if let Node::Delim { close, .. } = node.bare() + && close.chars().eq(std::iter::once(ch)) + { + self.caret = Caret { path, pos: after }; + self.anchor = None; + self.unwrap_frac_paren(); + return true; + } + if c + 1 != children(node).len() { + return false; + } + pos = after; + } + false + } + + /// A fraction's numerator or denominator, a radicand, a script or a limit that is just one + /// `( )` group shows no parentheses (the structure already groups): typing `(x-1)` in a + /// denominator reads like the numerator does, and `sqrt(b^2-4ac)` and `_(i=1)` match. + fn unwrap_frac_paren(&mut self) { + let Some(&(pi, _)) = self.caret.path.last() else { return }; + let Some(&(_, c)) = self.caret.path.last() else { return }; + let parent = self.caret.path.get(..self.caret.path.len() - 1).and_then(|p| seq_at(&self.math.body, p)); + let node = parent.and_then(|s| s.get(pi)).map(Node::bare); + let in_script = matches!(node, Some(Node::Script { .. })) && c >= 1; + let groups = match node { + Some(Node::Frac { .. } | Node::Rad { .. } | Node::Func { .. }) => true, + Some(Node::Script { .. }) => in_script, + Some(Node::Nary { sub, sup, .. }) => c < usize::from(sub.is_some()) + usize::from(sup.is_some()), + Some(Node::Limit { .. }) => c == 1, + _ => false, + }; + if !groups { + return; + } + let last = node.is_some_and(|n| c + 1 == children(n).len()); + let ends = matches!(node, Some(Node::Rad { .. } | Node::Func { .. } | Node::Frac { .. })) && last; + if let Some(seq) = self.seq_mut() { + let term = std::mem::take(seq); + *seq = unwrap_paren(term); + } + self.caret.pos = self.len_here(); + // `x^(n+1)`: the exponent is the group, so closing it also ends the exponent (DeckCraft + // 7972cf5). The same for the argument of `sin(..)`, a radicand (`sqrt(x)+1`) and a + // denominator (`1/(x-1)+2`): the closing parenthesis ends the structure, as in Desmos. + if in_script || ends { + self.exit_script_slot(); + } + } + + /// Insert a symbol from the palette, replacing the selection. No name conversion. + pub fn insert_symbol(&mut self, c: char) { + self.tracked(|e| { + e.fix(); + e.insert_char(c); + }); + } + + fn take_range(&mut self, a: usize, b: usize) -> Vec { + self.anchor = None; + let out = match self.seq_mut() { + Some(seq) => { + let i = boundary(seq, a); + let j = boundary(seq, b).min(seq.len()); + let i = i.min(j); + seq.drain(i..j).collect() + } + None => vec![], + }; + self.caret.pos = a; + out + } + + fn delete_range(&mut self, a: usize, b: usize) { + let _ = self.take_range(a, b); + if let Some(seq) = self.seq_mut() { + merge(seq); + } + } + + fn drop_selection(&mut self) { + if let Some((a, b)) = self.selection() { + self.delete_range(a, b); + } + self.anchor = None; + } + + /// Insert nodes at the caret; the index of the first one. + fn insert_nodes(&mut self, nodes: Vec) -> Option { + let pos = self.caret.pos; + let seq = self.seq_mut()?; + let i = boundary(seq, pos).min(seq.len()); + seq.splice(i..i, nodes); + Some(i) + } + + fn insert_char(&mut self, c: char) { + self.drop_selection(); + let pos = self.caret.pos; + let Some(seq) = self.seq_mut() else { return }; + let i = boundary(seq, pos).min(seq.len()); + let prev = i.checked_sub(1).and_then(|p| seq.get_mut(p)); + if let Some(Node::Text(t)) = prev { + t.push(c); + } else if let Some(Node::Text(t)) = seq.get_mut(i) { + t.insert(0, c); + } else { + seq.insert(i, Node::Text(c.to_string())); + } + self.caret.pos = pos + 1; + } + + /// Where the term before the caret starts: just the last atom (`atom`, for a script base) or + /// the whole run of letters, digits and structures (for a numerator). + fn term_start(&self, atom: bool) -> usize { + let cls = unit_chars(self.seq()); + let pos = self.caret.pos.min(cls.len()); + if pos == 0 { + return 0; + } + if atom { + return match cls.get(pos - 1) { + Some(Some(c)) if c.is_ascii_digit() => { + let mut s = pos - 1; + while s > 0 && matches!(cls.get(s - 1), Some(Some(d)) if d.is_ascii_digit() || *d == '.') { + s -= 1; + } + s + } + _ => pos - 1, + }; + } + let mut s = pos; + while s > 0 { + match cls.get(s - 1) { + Some(None) => s -= 1, + Some(Some(c)) if c.is_alphanumeric() || *c == '.' || *c == '\u{2032}' => s -= 1, + _ => break, + } + } + s + } + + /// `_` or `^` inside a big operator: in its empty body they add the limit (`int_0`), and + /// in the end of one limit they jump to the other (`sum_i^n`, `int_0^1`). + fn nary_limit(&mut self, sup: bool) -> bool { + if self.selection().is_some() { + return false; + } + let Some(&(i, c)) = self.caret.path.last() else { return false }; + let at_end = self.caret.pos == self.len_here(); + let empty = self.len_here() == 0; + let mut parent = self.caret.path.clone(); + parent.pop(); + let Some(Node::Nary { sub, sup: up, .. }) = seq_at_mut(&mut self.math.body, &parent).and_then(|s| s.get_mut(i)).map(bare_mut) else { + return false; + }; + let body_idx = usize::from(sub.is_some()) + usize::from(up.is_some()); + let in_sub = sub.is_some() && c == 0; + let in_sup = up.is_some() && c == usize::from(sub.is_some()); + let go = if c == body_idx && empty { + true + } else if sup { + in_sub && at_end + } else { + in_sup && at_end + }; + if !go { + return false; + } + let idx = if sup { + if up.is_none() { + *up = Some(vec![]); + } + usize::from(sub.is_some()) + } else { + if sub.is_none() { + *sub = Some(vec![]); + } + 0 + }; + self.caret.path.pop(); + self.caret.path.push((i, idx)); + self.caret.pos = self.len_here(); + true + } + + fn script(&mut self, sup: bool) { + self.script_raw(sup); + self.expect_brace = true; + } + + /// `_` right after `lim` (or `^` in an empty upper limit...): the place is already there, so + /// typing it must not nest a script with an empty base inside it. + fn redundant_script(&self, sup: bool) -> bool { + if self.selection().is_some() || self.len_here() != 0 { + return false; + } + let Some(&(i, c)) = self.caret.path.last() else { return false }; + let parent = self.caret.path.get(..self.caret.path.len() - 1).and_then(|p| seq_at(&self.math.body, p)); + match parent.and_then(|s| s.get(i)).map(Node::bare) { + Some(Node::Limit { lower, .. }) => c == 1 && *lower != sup, + Some(Node::Nary { sub, sup: up, .. }) => { + if sup { + up.is_some() && c == usize::from(sub.is_some()) + } else { + sub.is_some() && c == 0 + } + } + _ => false, + } + } + + fn script_raw(&mut self, sup: bool) { + if !self.can_nest() { + return; + } + if self.redundant_script(sup) || self.nary_limit(sup) { + return; + } + let base = if let Some((a, b)) = self.selection() { + self.take_range(a, b) + } else { + if self.extend_script_before(sup) { + return; + } + let (s, p) = (self.term_start(true), self.caret.pos); + self.take_range(s, p) + }; + let node = Node::Script { base, sub: (!sup).then(Vec::new), sup: sup.then(Vec::new) }; + let Some(i) = self.insert_nodes(vec![node]) else { return }; + self.caret.path.push((i, 1)); + self.caret.pos = 0; + self.anchor = None; + } + + /// The caret sits right after a script: add (or re-enter) the wanted part of it. + fn extend_script_before(&mut self, sup: bool) -> bool { + let pos = self.caret.pos; + let Some(seq) = self.seq_mut() else { return false }; + let i = boundary(seq, pos).min(seq.len()); + let Some(p) = i.checked_sub(1) else { return false }; + let Some(Node::Script { sub, sup: up, .. }) = seq.get_mut(p).map(bare_mut) else { return false }; + if sup && up.is_none() { + *up = Some(vec![]); + } else if !sup && sub.is_none() { + *sub = Some(vec![]); + } + let idx = if sup { 1 + usize::from(sub.is_some()) } else { 1 }; + self.caret.path.push((p, idx)); + self.caret.pos = self.len_here(); + self.anchor = None; + true + } + + fn fraction(&mut self) { + if !self.can_nest() { + return; + } + let num = if let Some((a, b)) = self.selection() { + self.take_range(a, b) + } else { + let (s, p) = (self.term_start(false), self.caret.pos); + unwrap_paren(self.take_range(s, p)) + }; + let filled = units(&num) > 0; + let Some(i) = self.insert_nodes(vec![Node::Frac { num, den: vec![] }]) else { return }; + self.caret.path.push((i, usize::from(filled))); + self.caret.pos = 0; + self.anchor = None; + } + + /// After a letter: turn a just-completed name (`alpha`, `sqrt`...) into its symbol. + fn autoconvert(&mut self) { + let cls = unit_chars(self.seq()); + let pos = self.caret.pos.min(cls.len()); + let mut s = pos; + while s > 0 && matches!(cls.get(s - 1), Some(Some(c)) if c.is_ascii_alphabetic()) { + s -= 1; + } + let run: String = cls.get(s..pos).unwrap_or(&[]).iter().filter_map(|c| *c).collect(); + if run.ends_with("sqrt") && self.can_nest() { + self.delete_range(pos - 4, pos); + if let Some(i) = self.insert_nodes(vec![Node::Rad { deg: None, body: vec![] }]) { + self.caret.path.push((i, 0)); + self.caret.pos = 0; + } + return; + } + if self.convert_word(&run, pos) { + return; + } + // `ipi` (as in `e^ipi`) is `i` then `pi` (DeckCraft 7972cf5); other short names only + // convert as whole words. + let short_ok = run.len() == 3 && run.ends_with("pi"); + let suffix = || GREEK.iter().filter(|(n, _)| (n.len() >= 4 || (short_ok && *n == "pi")) && run.ends_with(n)).max_by_key(|(n, _)| n.len()); + let exact = GREEK.iter().find(|(n, _)| *n == run); + if let Some((name, sym)) = exact.or_else(suffix) { + let n = name.chars().count(); + self.delete_range(pos - n, pos); + self.insert_char(*sym); + } + } + + /// `floor(`, `nCr(`: a function name typed just before `(` becomes a function node with the + /// caret in its argument, and the `(` is that argument's own. + fn convert_call(&mut self) -> bool { + if self.selection().is_some() || !self.can_nest() { + return false; + } + let cls = unit_chars(self.seq()); + let pos = self.caret.pos.min(cls.len()); + let mut s = pos; + while s > 0 && matches!(cls.get(s - 1), Some(Some(c)) if c.is_ascii_alphabetic()) { + s -= 1; + } + let run: String = cls.get(s..pos).unwrap_or(&[]).iter().filter_map(|c| *c).collect(); + if !crate::funcs::is_call_only(&run) { + return false; + } + self.delete_range(s, pos); + let Some(i) = self.insert_nodes(vec![Node::Func { name: run, body: vec![] }]) else { return true }; + self.caret.path.push((i, 0)); + self.caret.pos = 0; + true + } + + /// A whole typed word (`pm`, `sin`, `sum`, `lim`...) becomes its symbol or structure. + fn convert_word(&mut self, run: &str, pos: usize) -> bool { + let n = run.chars().count(); + if let Some((_, sym)) = WORDS.iter().find(|(w, _)| *w == run) { + self.delete_range(pos - n, pos); + self.insert_char(*sym); + return true; + } + let node = if crate::funcs::BARE.contains(&run) { + Some((Node::Func { name: run.to_string(), body: vec![] }, 0)) + } else if let Some(&(_, op, limits)) = + [("sum", '\u{2211}', true), ("prod", '\u{220F}', true), ("int", '\u{222B}', false)].iter().find(|(w, ..)| *w == run) + { + Some((Node::Nary { op, sub: limits.then(Vec::new), sup: limits.then(Vec::new), body: vec![] }, 0)) + } else if run == "lim" { + Some((Node::Limit { lower: true, base: vec![Node::Styled { text: "lim".into(), style: crate::Style::Plain }], lim: vec![] }, 1)) + } else { + None + }; + let Some((node, child)) = node else { return false }; + if !self.can_nest() { + return false; + } + self.delete_range(pos - n, pos); + let Some(i) = self.insert_nodes(vec![node]) else { return true }; + self.caret.path.push((i, child)); + self.caret.pos = 0; + true + } + + // ---------- templates ---------- + + /// Insert a palette structure at the caret. A script, fraction or accent takes the term + /// before the caret (or the selection) as its base; other structures wrap the selection + /// into their first blank. The caret lands in the first blank place. + pub fn insert_template(&mut self, t: &Math) { + self.tracked(|e| e.insert_template_raw(t)); + } + + fn insert_template_raw(&mut self, t: &Math) { + self.fix(); + if !self.can_nest() { + return; + } + let mut nodes = t.body.clone(); + strip_blanks(&mut nodes, 0); + if nodes.is_empty() { + return; + } + let single = nodes.len() == 1; + // The part that takes what is before the caret, if the structure has one. + let taker = match nodes.first().map(Node::bare) { + Some(Node::Script { base, .. }) if single && base.is_empty() => Some((true, false)), + Some(Node::Accent { body, .. } | Node::Bar { body, .. } | Node::GroupChr { body, .. }) if single && body.is_empty() => Some((true, true)), + Some(Node::Frac { num, den }) if single && num.is_empty() && den.is_empty() => Some((false, false)), + _ => None, + }; + if let Some((atom, close)) = taker { + let taken = if let Some((a, b)) = self.selection() { + self.take_range(a, b) + } else { + let (s, p) = (self.term_start(atom), self.caret.pos); + let t = self.take_range(s, p); + if atom { t } else { unwrap_paren(t) } + }; + let filled = units(&taken) > 0; + if let Some(n) = nodes.first_mut() + && let Some(first) = children_mut(bare_mut(n)).into_iter().next() + { + *first = taken; + } + let Some(i) = self.insert_nodes(nodes) else { return }; + if close && filled { + self.caret.pos += 1; + } else { + // First place still blank: scripts go to their script, a fraction to its + // denominator, an empty accent into its body. + let c = usize::from(!(close && !filled) && (filled || atom)); + self.caret.path.push((i, c)); + self.caret.pos = 0; + } + self.anchor = None; + return; + } + let selected = self.selection().map(|(a, b)| self.take_range(a, b)).unwrap_or_default(); + let target = first_empty_skipping_prescript(&nodes); + let mut land = units(&selected); + if let Some(rel) = &target + && !selected.is_empty() + && let Some(slot) = seq_at_mut(&mut nodes, rel) + { + *slot = selected; + } else { + land = 0; + } + let count = units(&nodes); + let Some(i) = self.insert_nodes(nodes) else { return }; + match target { + Some(mut rel) => { + if let Some(first) = rel.first_mut() { + first.0 += i; + } + self.caret.path.extend(rel); + self.caret.pos = land; + } + None => self.caret.pos += count, + } + self.anchor = None; + } + + // ---------- keys ---------- + + /// Press a navigation or deletion key; `shift` extends the selection (Left, Right, Home, End). + pub fn key(&mut self, key: Key, shift: bool) { + if !matches!(key, Key::Undo | Key::Redo) { + let inf = !matches!(key, Key::Backspace | Key::Delete); + self.tracked(|e| e.flush_word(inf)); + } + if !matches!(key, Key::Backspace | Key::Delete) { + self.expect_brace = false; + self.brace_depth = 0; + } + match key { + Key::Undo => { + let _ = self.undo(); + } + Key::Redo => { + let _ = self.redo(); + } + Key::Backspace | Key::Delete => self.tracked(|e| e.key_raw(key, shift)), + _ => self.key_raw(key, shift), + } + } + + fn key_raw(&mut self, key: Key, shift: bool) { + self.fix(); + match key { + Key::Undo | Key::Redo => {} + Key::Tab => { + self.anchor = None; + self.tab(shift); + } + Key::Backspace => self.backspace(), + Key::Delete => self.delete_forward(), + Key::Left | Key::Right | Key::Home | Key::End if shift => self.extend(key), + Key::Left | Key::Right => { + if let Some((a, b)) = self.selection() { + self.caret.pos = if key == Key::Left { a } else { b }; + self.anchor = None; + } else { + self.anchor = None; + if key == Key::Left { self.move_left() } else { self.move_right() } + } + } + Key::Up | Key::Down => { + self.anchor = None; + self.move_vertical(key == Key::Up); + } + Key::Home | Key::End => { + self.anchor = None; + let to_start = key == Key::Home; + let edge = if to_start { 0 } else { self.len_here() }; + if self.caret.pos == edge { + self.caret.path.clear(); + self.caret.pos = if to_start { 0 } else { units(&self.math.body) }; + } else { + self.caret.pos = edge; + } + } + } + self.fix(); + } + + /// Move to the next (or previous) place of the tree: the next cell, the denominator after the + /// numerator, out of the last place. Stays put at the ends. + fn tab(&mut self, back: bool) { + let start = self.caret.path.clone(); + let from_prescript = !back && self.in_prescript(); + for _ in 0..MAX_TAB_STEPS { + let before = self.caret.clone(); + if back { + self.move_left(); + } else { + self.move_right(); + } + if self.caret.path != start || self.caret == before { + break; + } + } + // Out of the n of `ₙC_r`: straight into the r. + if from_prescript + && self.caret.path.len() + 1 == start.len() + && let Some((i, false, kids, true)) = self.unit_info(self.caret.pos) + && kids >= 2 + && self.caret.path.len() < MAX_DEPTH + { + self.caret.path.push((i, 1)); + self.caret.pos = 0; + } + } + + /// The caret is in a script of a script node whose base is empty (the n of `ₙC_r`). + fn in_prescript(&self) -> bool { + let Some((&(i, c), parent)) = self.caret.path.split_last() else { return false }; + c > 0 + && seq_at(&self.math.body, parent) + .and_then(|s| s.get(i)) + .is_some_and(|n| matches!(n.bare(), Node::Script { base, .. } if base.is_empty())) + } + + fn extend(&mut self, key: Key) { + let len = self.len_here(); + let pos = self.caret.pos; + let to = match key { + Key::Left => pos.saturating_sub(1), + Key::Right => (pos + 1).min(len), + Key::Home => 0, + _ => len, + }; + if self.anchor.is_none() { + self.anchor = Some(pos); + } + self.caret.pos = to; + if self.anchor == Some(to) { + self.anchor = None; + } + } + + /// Facts about the unit at `pos`: (node index, is text, child count, is a script). + fn unit_info(&self, pos: usize) -> Option<(usize, bool, usize, bool)> { + let seq = self.seq(); + let (i, _) = locate(seq, pos)?; + let n = seq.get(i)?; + Some((i, text_of(n).is_some(), children(n).len(), matches!(n.bare(), Node::Script { .. }))) + } + + fn move_right(&mut self) { + let pos = self.caret.pos; + if pos < self.len_here() { + match self.unit_info(pos) { + Some((i, false, kids, script)) if kids > 0 && self.caret.path.len() < MAX_DEPTH => { + self.caret.path.push((i, 0)); + self.caret.pos = 0; + if script { + // Over the base letter, not in front of it (that is where we came from). + self.caret.pos = self.len_here().min(1); + } + self.enter_lone_grid(); + } + _ => self.caret.pos = pos + 1, + } + } else { + self.leave_right(); + } + } + + /// Caret at the start of a delimiter pair that holds only a grid: step into its first cell. + fn enter_lone_grid(&mut self) { + let grid = matches!(self.seq().as_slice(), [only] if matches!(only.bare(), Node::Matrix { .. } | Node::EqArr { .. })); + if grid && self.caret.pos == 0 && self.caret.path.len() < MAX_DEPTH { + self.caret.path.push((0, 0)); + } + } + + fn move_left(&mut self) { + let pos = self.caret.pos; + if pos > 0 { + match self.unit_info(pos - 1) { + Some((i, false, kids, _)) if kids > 0 && self.caret.path.len() < MAX_DEPTH => { + self.caret.path.push((i, kids - 1)); + self.caret.pos = self.len_here(); + if self.only_grid_in_delim() + && let Some(k) = self.seq().first().map(|n| children(n).len()).filter(|k| *k > 0) + { + self.caret.path.push((0, k - 1)); + self.caret.pos = self.len_here(); + } + } + _ => self.caret.pos = pos - 1, + } + } else { + self.leave_left(); + } + } + + /// True when the caret's sequence holds just one grid (matrix or equation array) inside a + /// delimiter pair, so the caret position before or after the grid looks the same as the + /// grid's own edge and is skipped. + fn only_grid_in_delim(&self) -> bool { + let seq = self.seq(); + let grid = matches!(seq.as_slice(), [only] if matches!(only.bare(), Node::Matrix { .. } | Node::EqArr { .. })); + let Some(&(i, _)) = self.caret.path.last() else { return false }; + let parent = self.caret.path.get(..self.caret.path.len() - 1).and_then(|p| seq_at(&self.math.body, p)); + grid && parent.and_then(|s| s.get(i)).is_some_and(|n| matches!(n.bare(), Node::Delim { .. })) + } + + fn leave_right(&mut self) { + let Some((i, c)) = self.caret.path.pop() else { return }; + let kids = self.seq().get(i).map_or(0, |n| children(n).len()); + if c + 1 < kids { + self.caret.path.push((i, c + 1)); + self.caret.pos = 0; + } else { + self.caret.pos = start_of(self.seq(), i) + 1; + if self.caret.pos == self.len_here() && self.only_grid_in_delim() { + self.leave_right(); + } + } + } + + fn leave_left(&mut self) { + let Some((i, c)) = self.caret.path.pop() else { return }; + if c > 0 { + self.caret.path.push((i, c - 1)); + self.caret.pos = self.len_here(); + } else { + self.caret.pos = start_of(self.seq(), i); + if self.caret.pos == 0 && self.only_grid_in_delim() { + self.leave_left(); + } + } + } + + fn move_vertical(&mut self, up: bool) { + let mut path = self.caret.path.clone(); + let pos = self.caret.pos; + while let Some((i, c)) = path.pop() { + let next = seq_at(&self.math.body, &path).and_then(|s| s.get(i)).and_then(|n| vertical_neighbor(n, c, up)); + if let Some(nc) = next { + path.push((i, nc)); + let n = seq_at(&self.math.body, &path).map_or(0, |s| units(s)); + self.caret = Caret { path, pos: pos.min(n) }; + return; + } + } + self.caret = Caret { path: vec![], pos: if up { 0 } else { units(&self.math.body) } }; + } + + fn backspace(&mut self) { + if let Some((a, b)) = self.selection() { + self.delete_range(a, b); + return; + } + self.anchor = None; + let pos = self.caret.pos; + if pos > 0 { + // Behind a structure with something in it: step inside first (the next Backspace + // deletes from its end), so one key never wipes a whole fraction. + if self.solid_structure(pos - 1) { + let script = self.unit_info(pos - 1).is_some_and(|u| u.3); + self.move_left(); + // A script has one visible tail, so the same key removes its last character + // (stepping in with no visible change reads as a dead key). + let n = self.len_here(); + if script && n > 0 && !self.solid_structure(n - 1) { + self.delete_range(n - 1, n); + } + } else { + self.delete_range(pos - 1, pos); + } + } else { + self.dissolve(); + } + } + + /// True when the unit at `pos` is a structure that holds something (so deleting it would + /// lose content). + fn solid_structure(&self, pos: usize) -> bool { + let Some((i, false, kids, _)) = self.unit_info(pos) else { return false }; + kids > 0 && self.caret.path.len() < MAX_DEPTH && self.seq().get(i).is_some_and(|n| children(n).iter().any(|k| units(k) > 0)) + } + + fn delete_forward(&mut self) { + if let Some((a, b)) = self.selection() { + self.delete_range(a, b); + return; + } + self.anchor = None; + let pos = self.caret.pos; + if pos < self.len_here() { + if self.solid_structure(pos) { + self.move_right(); + } else { + self.delete_range(pos, pos + 1); + } + } else { + let _ = self.remove_if_empty(); + } + } + + /// The structure the caret is in, when every one of its places is empty: remove it. + fn remove_if_empty(&mut self) -> bool { + let Some(&(i, _)) = self.caret.path.last() else { return false }; + let mut parent_path = self.caret.path.clone(); + parent_path.pop(); + let empty = seq_at(&self.math.body, &parent_path).and_then(|s| s.get(i)).is_some_and(|n| children(n).iter().all(|k| units(k) == 0)); + if !empty { + return false; + } + self.caret.path = parent_path; + let start = start_of(self.seq(), i); + if let Some(seq) = self.seq_mut() { + if i < seq.len() { + seq.remove(i); + } + merge(seq); + } + self.caret.pos = start; + true + } + + /// Backspace at the start of a place: remove an empty structure, drop an empty script part, + /// unwrap a structure into its contents, or step out to the left. + fn dissolve(&mut self) { + let Some(&(i, c)) = self.caret.path.last() else { return }; + if self.remove_if_empty() { + return; + } + let mut parent_path = self.caret.path.clone(); + parent_path.pop(); + let facts = seq_at(&self.math.body, &parent_path).and_then(|s| s.get(i)).map(|n| { + let kids = children(n); + (matches!(n.bare(), Node::Script { .. }), kids.get(c).is_none_or(|k| units(k) == 0), kids.iter().take(c).map(|k| units(k)).sum::()) + }); + let Some((script, this_empty, before)) = facts else { return }; + if script { + if c > 0 && this_empty { + self.drop_script_part(parent_path, i, c); + } else { + self.leave_left(); + } + } else if c == 0 || this_empty { + self.unwrap_node(parent_path, i, before); + } else { + self.leave_left(); + } + } + + fn drop_script_part(&mut self, parent_path: Path, i: usize, c: usize) { + let Some(seq) = seq_at_mut(&mut self.math.body, &parent_path) else { return }; + let Some(Node::Script { base, sub, sup }) = seq.get_mut(i).map(bare_mut) else { return }; + if sub.is_some() && c == 1 { + *sub = None; + } else { + *sup = None; + } + let base_units = units(base); + if sub.is_none() && sup.is_none() { + let flat = std::mem::take(base); + let start = start_of(seq, i); + seq.splice(i..=i, flat); + merge(seq); + self.caret = Caret { path: parent_path, pos: start + base_units }; + } else { + self.caret.path = parent_path; + self.caret.path.push((i, 0)); + self.caret.pos = base_units; + } + } + + fn unwrap_node(&mut self, parent_path: Path, i: usize, before: usize) { + let Some(seq) = seq_at_mut(&mut self.math.body, &parent_path) else { return }; + if i >= seq.len() { + return; + } + let mut node = seq.remove(i); + let mut flat: Vec = vec![]; + for k in children_mut(&mut node) { + flat.extend(std::mem::take(k)); + } + let start = start_of(seq, i); + seq.splice(i..i, flat); + merge(seq); + self.caret = Caret { path: parent_path, pos: start + before }; + } + + /// The tree for display: every empty place holds a box character so it can be seen and + /// clicked. The edited tree itself is untouched. + pub fn display_math(&self) -> Math { + fn fill(seq: &mut Seq, depth: usize) { + if depth > MAX_DEPTH { + return; + } + let len = seq.len(); + for (i, n) in seq.iter_mut().enumerate() { + // A script with nothing before it and something after (the n of `ₙC_r`) has no + // base to show: only its scripts are places. + let prescript = i + 1 < len && matches!(n, Node::Script { base, sub, sup } if base.is_empty() && (sub.is_some() || sup.is_some())); + for (c, child) in children_mut(n).into_iter().enumerate() { + if units(child) == 0 { + if !(prescript && c == 0) { + *child = vec![Node::Text("\u{25A1}".into())]; + } + } else { + fill(child, depth + 1); + } + } + } + } + let mut m = self.math.clone(); + fill(&mut m.body, 0); + m + } + + /// True when the caret points at a real place of the tree (tests, debug). + pub fn is_valid(&self) -> bool { + seq_at(&self.math.body, &self.caret.path).is_some_and(|s| self.caret.pos <= units(s)) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/equashion-math/src/edit/tests.rs b/crates/equashion-math/src/edit/tests.rs new file mode 100644 index 00000000..c1524d83 --- /dev/null +++ b/crates/equashion-math/src/edit/tests.rs @@ -0,0 +1,955 @@ +use super::*; +use crate::{from_linear, template, to_linear}; + +fn ed() -> Editor { + Editor::new(Math::default()) +} + +fn typed(s: &str) -> Editor { + let mut e = ed(); + e.type_str(s); + e +} + +fn lin(e: &Editor) -> String { + to_linear(e.math()).replace(' ', "") +} + +fn tpl(id: &str) -> Math { + template(id).map(|t| t.math).unwrap_or_default() +} + +#[test] +fn x_caret_2_builds_a_superscript_with_the_caret_inside() { + let e = typed("x^2"); + assert_eq!(lin(&e), "x^2"); + assert!(matches!(e.math().body.first(), Some(Node::Script { sup: Some(_), .. }))); + assert_eq!(e.caret().path.len(), 1, "caret is inside the superscript"); + assert_eq!(e.caret().pos, 1); +} + +#[test] +fn right_arrow_leaves_the_superscript() { + let mut e = typed("x^2"); + e.key(Key::Right, false); + assert!(e.caret().path.is_empty()); + e.type_str("+1"); + assert_eq!(lin(&e), "x^2+1"); +} + +#[test] +fn underscore_makes_a_subscript_and_both_parts_combine() { + let e = typed("a_1"); + assert_eq!(lin(&e), "a_1"); + let mut e = typed("x^2"); + e.key(Key::Right, false); + e.type_str("_i"); + assert!(matches!(e.math().body.first(), Some(Node::Script { sub: Some(_), sup: Some(_), .. })), "{:?}", e.math().body); + assert_eq!(e.math().body.len(), 1, "one script, not two"); +} + +#[test] +fn a_number_stays_together_as_the_base() { + let e = typed("12^3"); + assert!(matches!(e.math().body.first(), Some(Node::Script { base, .. }) if base.len() == 1)); + let e = typed("ab^2"); + assert_eq!(e.math().body.len(), 2, "only b takes the exponent"); +} + +#[test] +fn slash_turns_the_preceding_term_into_a_numerator() { + let mut e = typed("1+ab/"); + match e.math().body.get(1) { + Some(Node::Frac { num, den }) => { + assert_eq!(units(num), 2); + assert!(den.is_empty()); + } + other => panic!("{other:?}"), + } + assert_eq!(e.caret().path, vec![(1, 1)], "caret is in the denominator"); + e.type_str("c"); + e.key(Key::Right, false); + e.type_str("=2"); + assert_eq!(lin(&e).replace(['(', ')'], ""), "1+ab/c=2"); +} + +#[test] +fn slash_with_nothing_before_it_puts_the_caret_in_the_numerator() { + let e = typed("/"); + assert_eq!(e.caret().path, vec![(0, 0)]); +} + +#[test] +fn sqrt_typed_makes_a_root_with_the_caret_inside() { + let mut e = typed("sqrt"); + assert!(matches!(e.math().body.first(), Some(Node::Rad { deg: None, .. }))); + assert_eq!(e.caret().path, vec![(0, 0)]); + e.type_str("x+1"); + e.key(Key::Right, false); + e.type_str("y"); + assert!(lin(&e).contains('√') && lin(&e).ends_with('y'), "{}", lin(&e)); +} + +#[test] +fn greek_names_become_symbols() { + assert_eq!(lin(&typed("alpha")), "α"); + assert_eq!(lin(&typed("2pi r")), "2πr"); + assert_eq!(lin(&typed("Delta x")), "Δx"); + assert_eq!(lin(&typed("beta+theta")), "β+θ"); + // A name inside a longer word stays text; typing the middle of epsilon does not fire psi. + assert_eq!(lin(&typed("epsilon")), "ε"); + assert_eq!(lin(&typed("menu")), "menu"); + assert_eq!(lin(&typed("a b")), "ab", "spaces are ignored"); +} + +#[test] +fn hyphen_is_a_real_minus() { + assert_eq!(lin(&typed("a-b")), "a\u{2212}b"); +} + +#[test] +fn arrows_walk_the_tree_in_visual_order() { + let mut e = typed("a/b"); + // Caret is in the denominator after b. Left twice: before b, then the end of the numerator. + e.key(Key::Left, false); + assert_eq!((e.caret().path.clone(), e.caret().pos), (vec![(0, 1)], 0)); + e.key(Key::Left, false); + assert_eq!((e.caret().path.clone(), e.caret().pos), (vec![(0, 0)], 1)); + e.key(Key::Left, false); + e.key(Key::Left, false); + assert_eq!((e.caret().path.clone(), e.caret().pos), (vec![], 0)); + // And back: Right enters the numerator, crosses to the denominator, leaves. + e.key(Key::Right, false); + assert_eq!(e.caret().path, vec![(0, 0)]); + e.key(Key::Right, false); + e.key(Key::Right, false); + assert_eq!(e.caret().path, vec![(0, 1)]); + e.key(Key::Right, false); + e.key(Key::Right, false); + assert_eq!((e.caret().path.clone(), e.caret().pos), (vec![], 1)); +} + +#[test] +fn up_and_down_move_between_numerator_denominator_and_scripts() { + let mut e = typed("a/b"); + e.key(Key::Up, false); + assert_eq!(e.caret().path, vec![(0, 0)]); + e.key(Key::Down, false); + assert_eq!(e.caret().path, vec![(0, 1)]); + let mut e = typed("x^2"); + e.key(Key::Down, false); + assert_eq!(e.caret().path, vec![(0, 0)], "no subscript: down goes to the base"); + e.key(Key::Up, false); + assert_eq!(e.caret().path, vec![(0, 1)]); + // Up from the top row goes to the start, Down to the end. + let mut e = typed("ab"); + e.key(Key::Up, false); + assert_eq!(e.caret().pos, 0); + e.key(Key::Down, false); + assert_eq!(e.caret().pos, 2); +} + +#[test] +fn matrix_cells_are_reached_with_up_and_down() { + let mut e = ed(); + e.insert_template(&tpl("matrix2")); + e.type_str("1"); + e.key(Key::Down, false); + e.type_str("3"); + let m = lin(&e); + assert!(m.contains('1') && m.contains('3'), "{m}"); + let at = e.caret().path.last().map(|p| p.1); + assert_eq!(at, Some(2), "second row, first column"); +} + +#[test] +fn backspace_deletes_the_previous_atom_and_unwraps_empty_structures() { + let mut e = typed("x^2"); + e.key(Key::Backspace, false); + assert_eq!(e.caret().path.len(), 1); + e.key(Key::Backspace, false); + // The empty exponent is dropped and the base remains. + assert!(e.caret().path.is_empty()); + assert_eq!(lin(&e), "x"); + assert_eq!(e.caret().pos, 1); + e.key(Key::Backspace, false); + assert!(e.is_blank()); + // Backspace behind a whole fraction steps into it; a second one deletes its last atom. + let mut e = typed("a/b"); + e.key(Key::Right, false); + e.key(Key::Backspace, false); + assert!(!e.is_blank(), "one key never wipes a fraction"); + assert_eq!(e.caret().path, vec![(0, 1)]); + e.key(Key::Backspace, false); + assert_eq!(e.caret().path, vec![(0, 1)]); + assert!(!e.is_blank()); + // Backspace in the denominator at its start unwraps the fraction. + let mut e = typed("a/b"); + e.key(Key::Up, false); + e.key(Key::Home, false); + e.key(Key::Backspace, false); + assert_eq!(lin(&e), "ab"); + assert!(e.caret().path.is_empty()); + assert_eq!(e.caret().pos, 0, "in front of a"); + // At the start of the denominator, Backspace only steps back into the numerator. + let mut e = typed("a/b"); + e.key(Key::Left, false); + e.key(Key::Backspace, false); + assert_eq!(lin(&e).replace(['(', ')'], ""), "a/b"); + assert_eq!(e.caret().path, vec![(0, 0)]); +} + +#[test] +fn backspace_in_a_fresh_structure_removes_it() { + let mut e = ed(); + e.insert_template(&tpl("frac")); + e.key(Key::Backspace, false); + assert!(e.is_blank() && e.math().body.is_empty()); + let mut e = ed(); + e.insert_template(&tpl("sqrt")); + e.key(Key::Backspace, false); + assert!(e.math().body.is_empty()); +} + +#[test] +fn delete_removes_the_next_atom() { + let mut e = typed("abc"); + e.key(Key::Home, false); + e.key(Key::Delete, false); + assert_eq!(lin(&e), "bc"); + // Delete in front of a structure steps into it instead of wiping it. + let mut e = typed("a/b"); + e.key(Key::Home, false); + e.key(Key::Home, false); + e.key(Key::Delete, false); + assert!(!e.is_blank()); + assert_eq!(e.caret().path, vec![(0, 0)]); + // Delete at the end of an empty structure removes it. + let mut e = ed(); + e.insert_template(&tpl("sqrt")); + e.key(Key::Delete, false); + assert!(e.math().body.is_empty()); +} + +#[test] +fn home_and_end_go_to_the_row_ends_then_the_whole_equation() { + let mut e = typed("a+b/cd"); + e.key(Key::Home, false); + assert_eq!(e.caret().pos, 0); + assert_eq!(e.caret().path, vec![(1, 1)], "start of the denominator row"); + e.key(Key::Home, false); + assert!(e.caret().path.is_empty()); + assert_eq!(e.caret().pos, 0); + e.key(Key::End, false); + assert_eq!(e.caret().pos, units(&e.math().body)); +} + +#[test] +fn shift_arrows_select_and_typing_replaces() { + let mut e = typed("abc"); + e.key(Key::Left, true); + e.key(Key::Left, true); + assert_eq!(e.selection(), Some((1, 3))); + e.type_str("x"); + assert_eq!(lin(&e), "ax"); + assert_eq!(e.selection(), None); + // Shift+Home selects to the row start; plain Left collapses to its start. + let mut e = typed("abc"); + e.key(Key::Home, true); + assert_eq!(e.selection(), Some((0, 3))); + e.key(Key::Left, false); + assert_eq!((e.selection(), e.caret().pos), (None, 0)); + let mut e = typed("abc"); + e.select_all(); + e.key(Key::Backspace, false); + assert!(e.is_blank()); +} + +#[test] +fn selection_is_wrapped_by_slash_caret_and_templates() { + let mut e = typed("a+bc"); + e.key(Key::Left, true); + e.key(Key::Left, true); + e.type_str("/"); + assert!(matches!(e.math().body.get(1), Some(Node::Frac { num, .. }) if units(num) == 2)); + let mut e = typed("xy"); + e.key(Key::Left, true); + e.type_str("^"); + assert!(matches!(e.math().body.get(1), Some(Node::Script { base, .. }) if units(base) == 1)); + let mut e = typed("ab"); + e.select_all(); + e.insert_template(&tpl("sqrt")); + assert!(matches!(e.math().body.first(), Some(Node::Rad { body, .. }) if units(body) == 2)); + assert_eq!(e.caret().path, vec![(0, 0)]); + assert_eq!(e.caret().pos, 2, "caret after the wrapped text"); +} + +#[test] +fn palette_structures_land_the_caret_in_the_first_blank() { + let mut e = ed(); + e.insert_template(&tpl("frac")); + assert_eq!(e.caret().path, vec![(0, 0)]); + e.type_str("a"); + e.key(Key::Down, false); + e.type_str("b"); + assert_eq!(lin(&e).replace(['(', ')'], ""), "a/b"); + // A superscript from the palette takes the letter before the caret. + let mut e = typed("b"); + e.insert_template(&tpl("sup")); + e.type_str("2"); + assert_eq!(lin(&e), "b^2"); + // An integral puts the caret in its lower limit. + let mut e = ed(); + e.insert_template(&tpl("intlim")); + e.type_str("0"); + e.key(Key::Right, false); + e.type_str("1"); + assert!(lin(&e).contains('0') && lin(&e).contains('1')); + // A fraction takes the term before it. + let mut e = typed("x+12"); + e.insert_template(&tpl("frac")); + assert!(matches!(e.math().body.get(1), Some(Node::Frac { num, .. }) if units(num) == 2)); + assert_eq!(e.caret().path, vec![(1, 1)]); +} + +#[test] +fn every_template_inserts_and_accepts_typing() { + for t in crate::templates() { + let mut e = typed("x"); + e.insert_template(&t.math); + assert!(e.is_valid(), "{}", t.id); + e.type_str("y"); + assert!(e.is_valid(), "{}", t.id); + assert!(lin(&e).contains('y'), "{}: {}", t.id, lin(&e)); + // Left to the start and Right to the end never get stuck. + for _ in 0..50 { + e.key(Key::Left, false); + } + assert_eq!((e.caret().path.len(), e.caret().pos), (0, 0), "{}", t.id); + for _ in 0..50 { + e.key(Key::Right, false); + } + assert_eq!(e.caret().path.len(), 0, "{}", t.id); + assert_eq!(e.caret().pos, units(&e.math().body), "{}", t.id); + } +} + +#[test] +fn display_math_shows_a_box_in_every_empty_place_and_leaves_the_tree_alone() { + let mut e = ed(); + e.insert_template(&tpl("frac")); + let d = to_linear(&e.display_math()); + assert_eq!(d.matches('\u{25A1}').count(), 2, "{d}"); + assert!(e.math().body.iter().all(|n| children(n).iter().all(|k| k.is_empty()))); +} + +#[test] +fn set_caret_clamps_stale_positions() { + let mut e = typed("ab"); + e.set_caret(Caret { path: vec![(7, 3), (1, 1)], pos: 99 }); + assert!(e.is_valid()); + e.set_caret(Caret { path: vec![], pos: 99 }); + assert_eq!(e.caret().pos, 2); + let mut e = typed("a/b"); + e.set_caret(Caret { path: vec![(0, 1)], pos: 0 }); + e.drag_caret(Caret { path: vec![(0, 1)], pos: 1 }); + assert_eq!(e.selection(), Some((0, 1))); + e.drag_caret(Caret { path: vec![], pos: 0 }); + assert_eq!(e.selection(), None, "dragging out of the row just moves the caret"); +} + +#[test] +fn loaded_equations_are_editable() { + let mut e = Editor::new(from_linear("x^2+1")); + e.key(Key::Left, false); + e.type_str("0"); + assert_eq!(lin(&e), "x^2+01"); + e.key(Key::Home, false); + e.key(Key::Home, false); + e.key(Key::Right, false); + assert!(e.is_valid()); +} + +#[test] +fn nesting_depth_is_capped() { + let mut e = ed(); + for _ in 0..(MAX_DEPTH * 3) { + e.type_str("x^"); + } + for _ in 0..(MAX_DEPTH * 3) { + e.type_str("/"); + e.insert_template(&tpl("frac")); + } + assert!(e.caret().path.len() <= MAX_DEPTH); + assert!(e.is_valid()); + // The tree is walkable to its deepest place and back. + for _ in 0..1000 { + e.key(Key::Left, false); + } + assert!(e.is_valid()); +} + +#[test] +fn hostile_key_sequences_never_panic_and_keep_the_caret_valid() { + let keys = [Key::Left, Key::Right, Key::Up, Key::Down, Key::Home, Key::End, Key::Backspace, Key::Delete]; + let texts = [ + "x", "^", "_", "/", "sqrt", "alpha", "2", "-", "(", " ", "pi", "\u{0}", "é", "\n", ")", "[", "}", "+-", "<=", "->", "sum", "int_", "sin", + "sinh", "lim", "pm", "*", "arcsin", + ]; + let ids: Vec<&'static str> = crate::templates().iter().map(|t| t.id).collect(); + let mut seed = 0x2545_F491_4F6C_DD1Du64; + let mut next = move || { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + seed + }; + for round in 0..40 { + let mut e = if round % 5 == 0 { Editor::new(from_linear("(a+b)^2/sqrt(x)")) } else { ed() }; + for step in 0..1500 { + let r = next(); + match r % 10 { + 0..=3 => e.key(keys[(r >> 8) as usize % keys.len()], (r >> 20) & 1 == 1), + 4..=7 => e.type_str(texts[(r >> 8) as usize % texts.len()]), + 8 => { + let id = ids[(r >> 8) as usize % ids.len()]; + e.insert_template(&tpl(id)); + } + _ => match (r >> 8) % 4 { + 0 => e.select_all(), + 1 => e.set_caret(Caret { path: vec![((r >> 12) as usize % 5, (r >> 16) as usize % 4)], pos: (r >> 24) as usize % 9 }), + 2 => e.drag_caret(Caret { path: e.caret().path.clone(), pos: (r >> 24) as usize % 9 }), + _ => { + let _ = e.display_math(); + } + }, + } + assert!(e.is_valid(), "round {round} step {step}: {:?}", e.caret()); + assert!(e.caret().path.len() <= MAX_DEPTH); + } + // Whatever was built still prints and parses. + let _ = crate::to_omml(e.math()); + let _ = to_linear(e.math()); + } +} + +#[test] +fn key_names_parse() { + assert_eq!(Key::parse("Left"), Some(Key::Left)); + assert_eq!(Key::parse(" backspace "), Some(Key::Backspace)); + assert_eq!(Key::parse("del"), Some(Key::Delete)); + assert_eq!(Key::parse("x"), None); +} + +fn ty(s: &str) -> String { + lin(&typed(s)) +} + +#[test] +fn brackets_pair_up_and_the_closing_one_steps_out() { + let mut e = typed("(a+b"); + assert_eq!(e.caret().path.len(), 1, "caret is inside the new pair"); + assert!(matches!(e.math().body.first(), Some(Node::Delim { .. }))); + e.type_str(")+c"); + assert!(e.caret().path.is_empty(), "the closing bracket stepped out"); + assert_eq!(e.math().body.len(), 2, "{:?}", e.math().body); + assert_eq!(ty("(a+b)+c"), "(a+b)+c"); + // A stray closing bracket is just a character. + assert_eq!(ty("a)"), "a)"); + assert_eq!(ty("[x]"), "[x]"); +} + +#[test] +fn open_bracket_wraps_the_selection() { + let mut e = typed("ab"); + e.key(Key::Left, true); + e.key(Key::Left, true); + e.type_str("("); + assert!(matches!(e.math().body.first(), Some(Node::Delim { items, .. }) if units(&items[0]) == 2)); + assert!(e.caret().path.is_empty()); +} + +#[test] +fn slash_takes_a_parenthesised_group_without_its_parentheses() { + let e = typed("(a+b)/"); + match e.math().body.first() { + Some(Node::Frac { num, den }) => { + assert_eq!(units(num), 3, "a+b, parentheses dropped"); + assert!(den.is_empty()); + } + other => panic!("{other:?}"), + } + assert_eq!(e.caret().path, vec![(0, 1)]); +} + +#[test] +fn the_quadratic_formula_types_in_reading_order() { + let mut e = ed(); + e.type_str("x=(-b+-sqrt(b^2"); + e.key(Key::Right, false); + e.type_str("-4ac))/2a"); + // x = frac( -b ± sqrt(b^2 - 4ac), 2a ) + assert_eq!(e.math().body.len(), 2, "{:?}", e.math().body); + let Some(Node::Frac { num, den }) = e.math().body.get(1) else { panic!("{:?}", e.math().body) }; + assert_eq!(units(den), 2, "2a is the denominator"); + let l = to_linear(&Math::new(num.clone())).replace(' ', ""); + assert!(l.contains('\u{B1}') && l.contains('\u{221A}') && l.contains("4ac"), "{l}"); + assert!(!l.starts_with('('), "no redundant parentheses: {l}"); + assert!(matches!(num.first(), Some(Node::Text(t)) if t.starts_with('\u{2212}'))); +} + +#[test] +fn plus_minus_and_symbol_words_convert() { + assert_eq!(ty("a+-b"), "a\u{B1}b"); + assert_eq!(ty("pm"), "\u{B1}"); + assert_eq!(ty("bpm"), "bpm", "pm inside a word stays"); + assert_eq!(ty("a<=b"), "a\u{2264}b"); + assert_eq!(ty("a>=b"), "a\u{2265}b"); + assert_eq!(ty("a!=b"), "a\u{2260}b"); + assert_eq!(ty("x->y"), "x\u{2192}y"); + assert_eq!(ty("a*b"), "a\u{B7}b"); + assert_eq!(ty("2times3"), "2\u{D7}3"); +} + +#[test] +fn big_operators_and_functions_convert_when_typed() { + let e = typed("sum"); + assert!(matches!(e.math().body.first(), Some(Node::Nary { op: '\u{2211}', sub: Some(_), sup: Some(_), .. }))); + assert_eq!(e.caret().path, vec![(0, 0)], "caret in the lower limit"); + let mut e = typed("int_0"); + e.key(Key::Right, false); + assert!(matches!(e.math().body.first(), Some(Node::Nary { op: '\u{222B}', sub: Some(_), sup: None, .. })), "{:?}", e.math().body); + let e = typed("int_0^1"); + assert!( + matches!(e.math().body.first(), Some(Node::Nary { sub: Some(s), sup: Some(u), .. }) if units(s) == 1 && units(u) == 1), + "{:?}", + e.math().body + ); + let e = typed("sin"); + assert!(matches!(e.math().body.first(), Some(Node::Func { name, .. }) if name == "sin")); + assert!(matches!(typed("sinh").math().body.first(), Some(Node::Func { name, .. }) if name == "sinh")); + assert!(matches!(typed("arcsin").math().body.first(), Some(Node::Func { name, .. }) if name == "arcsin")); + assert!(matches!(typed("lim").math().body.first(), Some(Node::Limit { .. }))); +} + +#[test] +fn an_operator_ends_a_function_argument() { + let e = typed("sinx+1"); + assert_eq!(e.math().body.len(), 2, "{:?}", e.math().body); + assert!(matches!(e.math().body.first(), Some(Node::Func { body, .. }) if units(body) == 1)); + let e = typed("sin(x)+1"); + assert_eq!(e.math().body.len(), 2, "{:?}", e.math().body); +} + +#[test] +fn right_after_the_last_matrix_cell_leaves_the_parentheses() { + let mut e = ed(); + e.insert_template(&tpl("matrix2")); + e.type_str("1"); + for k in ["2", "3", "4"] { + e.key(Key::Right, false); + e.type_str(k); + } + e.key(Key::Right, false); + assert!(e.caret().path.is_empty(), "one Right is enough: {:?}", e.caret()); + e.type_str("+x"); + assert_eq!(e.math().body.len(), 2); + // And back in: Left lands in the last cell. + e.key(Key::Left, false); + e.key(Key::Left, false); + e.key(Key::Left, false); + assert_eq!(e.caret().path.len(), 2, "{:?}", e.caret()); +} + +#[test] +fn plain_fence_characters_in_hostile_order_stay_valid() { + let mut e = ed(); + for _ in 0..200 { + e.type_str("([{"); + } + for _ in 0..200 { + e.type_str(")]}"); + } + assert!(e.is_valid()); +} + +#[test] +fn backspace_after_a_filled_structure_steps_in_then_deletes_inside() { + for src in ["x+a/b", "x+sqrtab", "x+(ab)"] { + let mut e = typed(src); + e.key(Key::Right, false); + let before = lin(&e); + e.key(Key::Backspace, false); + assert_eq!(lin(&e), before, "{src}: first Backspace only enters"); + assert!(!e.caret().path.is_empty(), "{src}"); + e.key(Key::Backspace, false); + assert_ne!(lin(&e), before, "{src}: second Backspace deletes an atom"); + assert!(lin(&e).starts_with("x+"), "{src}"); + } +} + +#[test] +fn undo_and_redo_restore_tree_and_caret() { + let mut e = typed("x^2"); + e.key(Key::Right, false); + e.type_str("+1"); + assert!(e.undo()); + assert_eq!(lin(&e), "x^2"); + assert!(e.undo()); + assert!(e.is_blank()); + assert!(!e.undo()); + assert!(e.redo()); + assert_eq!(lin(&e), "x^2"); + assert!(e.redo()); + assert_eq!(lin(&e), "x^2+1"); + assert!(!e.redo()); + // A wiped fraction comes back. + let mut e = typed("a/b"); + e.select_all(); + e.key(Key::Backspace, false); + assert!(e.is_blank()); + e.key(Key::Undo, false); + assert_eq!(lin(&e).replace(['(', ')'], ""), "a/b"); + // A new edit drops the redo branch. + e.type_str("c"); + assert!(!e.redo()); +} + +#[test] +fn cut_and_copy_use_linear_text() { + let mut e = typed("ab+c"); + assert_eq!(e.selected_linear(), None); + e.key(Key::Left, true); + e.key(Key::Left, true); + assert_eq!(e.selected_linear().as_deref(), Some("+c")); + assert_eq!(e.cut().as_deref(), Some("+c")); + assert_eq!(lin(&e), "ab"); + e.key(Key::Undo, false); + assert_eq!(lin(&e), "ab+c"); +} + +#[test] +fn tab_walks_the_places_and_never_leaves_the_tree() { + let mut e = typed("a/b"); + e.key(Key::Up, false); + e.key(Key::Tab, false); + assert_eq!(e.caret().path, vec![(0, 1)], "numerator to denominator"); + e.key(Key::Tab, false); + assert!(e.caret().path.is_empty(), "out of the last place"); + e.key(Key::Tab, true); + assert_eq!(e.caret().path, vec![(0, 1)], "Shift+Tab goes back"); + let mut e = typed("x^2"); + e.key(Key::Tab, false); + e.type_str("+1"); + assert_eq!(lin(&e), "x^2+1", "Tab out of the exponent keeps typing in the tree"); + let mut e = typed("[1,2;3,4]"); + for _ in 0..50 { + e.key(Key::Tab, false); + e.key(Key::Tab, true); + assert!(e.is_valid()); + } +} + +#[test] +fn parentheses_around_a_denominator_are_dropped_like_the_numerator() { + let e = typed("(x+1)/(x-1)"); + let Some(Node::Frac { num, den }) = e.math().body.first() else { panic!("{:?}", e.math().body) }; + assert!(!matches!(num.first(), Some(Node::Delim { .. }))); + assert!(!matches!(den.first(), Some(Node::Delim { .. })), "{den:?}"); + // Parentheses inside a longer denominator stay. + let e = typed("a/(b)c"); + assert!(matches!(e.math().body.first(), Some(Node::Frac { .. }))); +} + +#[test] +fn hostile_keys_with_undo_never_panic() { + let keys = [Key::Left, Key::Right, Key::Up, Key::Down, Key::Home, Key::End, Key::Backspace, Key::Delete, Key::Tab, Key::Undo, Key::Redo]; + let mut seed = 12345u64; + let mut e = ed(); + for _ in 0..3000 { + seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + let r = (seed >> 33) as usize; + match r % 4 { + 0 => e.type_str(["x", "^", "_", "/", "(", ")", "sqrt", "alpha", "+", "-"][r / 4 % 10]), + _ => e.key(keys[r / 4 % keys.len()], r.is_multiple_of(7)), + } + assert!(e.is_valid()); + } +} + +#[test] +fn operators_leave_a_filled_exponent_or_subscript() { + assert_eq!(lin(&typed("x^2+y^2=z^2")), "x^2+y^2=z^2"); + assert_eq!(lin(&typed("b^2-4ac")), "b^2−4ac"); + assert_eq!(lin(&typed("a_1+a_2")), "a_1+a_2"); + // An empty exponent keeps a leading minus: x^-1. + assert_eq!(lin(&typed("x^-1")), "x^−1"); + // Limits of a big operator are not scripts: `=` stays in the limit. + assert_eq!(typed("sumi=1").caret().path, vec![(0, 0)], "the lower limit keeps the ="); +} + +#[test] +fn typed_latex_commands_become_symbols_and_structures() { + assert_eq!(ty("\\alpha"), "α"); + assert_eq!(ty("\\alpha\\beta"), "αβ"); + assert_eq!(ty("2\\pi r"), "2πr"); + assert_eq!(ty("a\\leq b"), "a≤b"); + assert_eq!(ty("a\\in b"), "a∈b"); + assert_eq!(ty("\\int_0^1"), ty("int_0^1")); + assert!(matches!(typed("\\int").math().body.first(), Some(Node::Nary { op: '\u{222B}', .. })), "int is not eaten by in"); + assert!(matches!(typed("\\sqrt").math().body.first(), Some(Node::Rad { .. }))); + assert!(matches!(typed("\\sin").math().body.first(), Some(Node::Func { name, .. }) if name == "sin") || typed("\\sin ").math().body.len() == 1); + assert!(matches!(typed("\\sin ").math().body.first(), Some(Node::Func { name, .. }) if name == "sin")); + assert!(matches!(typed("\\sinh").math().body.first(), Some(Node::Func { name, .. }) if name == "sinh")); + // An unknown command stays as typed. + assert!(matches!(typed("\\foo").math().body.first(), Some(Node::Text(t)) if t == "\\foo")); + assert_eq!(ty("\\left(x\\right)"), "(x)"); +} + +#[test] +fn braces_after_a_latex_command_are_its_arguments() { + assert_eq!(ty("\\frac{a+b}{c}"), ty("(a+b)/c")); + assert_eq!(ty("\\frac{1}{2}+x"), "1/2+x"); + assert_eq!(ty("\\sqrt{x+1}"), "√(x+1)"); + assert_eq!(ty("x^{2}+1"), "x^2+1"); + assert_eq!(ty("x_{ab}+1"), "x_(ab)+1"); + assert_eq!(ty("\\sum_{i=1}^{n}i"), ty("sum_i=1^n i")); + assert_eq!(ty("e^{-x^{2}}"), ty("e^-x^2")); + // A real brace pair is still a brace pair. + assert!(matches!(typed("{a}").math().body.first(), Some(Node::Delim { open, .. }) if open == "{")); + // Unmatched braces never panic or leave the caret invalid. + for s in ["}", "\\frac}}", "\\frac{{{", "x^{", "\\sqrt}{"] { + assert!(typed(s).is_valid(), "{s}"); + } +} + +#[test] +fn a_limit_subscript_does_not_nest_an_empty_script() { + for s in ["lim_x", "\\lim_x", "\\lim_{x\\to0}"] { + let e = typed(s); + let Some(Node::Limit { lim, .. }) = e.math().body.first() else { panic!("{s}: {:?}", e.math().body) }; + assert!(!lim.iter().any(|n| matches!(n.bare(), Node::Script { .. })), "{s}: stray script in {lim:?}"); + assert!(!to_linear(e.math()).contains("()_"), "{s}: {}", to_linear(e.math())); + } + assert_eq!(lin(&typed("\\lim_{x\\to0}")), lin(&typed("lim_x→0"))); + assert!(matches!(typed("\\lim_{x\\to0}").math().body.first(), Some(Node::Limit { lim, .. }) if units(lim) == 3)); + // The same for an operator whose limit places already exist. + let e = typed("sum_i"); + assert!(matches!(e.math().body.first(), Some(Node::Nary { sub: Some(s), .. }) if units(s) == 1)); +} + +#[test] +fn select_between_covers_whole_structures_across_rows() { + let mut e = typed("a+x/y"); + // From the start of the top row to inside the denominator: everything up to the fraction. + let start = Caret { path: vec![], pos: 0 }; + let in_den = e.caret().clone(); + assert_eq!(in_den.path.len(), 1); + e.select_between(&start, &in_den); + assert!(e.caret().path.is_empty()); + assert_eq!(e.selection(), Some((0, units(&e.math().body)))); + // Backwards, from the denominator to the start. + e.select_between(&in_den, &start); + assert_eq!(e.selection(), Some((0, units(&e.math().body)))); + // Within one row it is a plain range. + let mut e = typed("abcd"); + e.select_between(&Caret { path: vec![], pos: 1 }, &Caret { path: vec![], pos: 3 }); + assert_eq!(e.selection(), Some((1, 3))); + // Stale carets never panic. + e.select_between(&Caret { path: vec![(9, 9)], pos: 9 }, &Caret { path: vec![(8, 1), (1, 1)], pos: 99 }); + assert!(e.is_valid()); +} + +#[test] +fn typed_latex_root_degree() { + let e = typed("\\sqrt[3]{x}"); + assert!(matches!(e.math().body.first(), Some(Node::Rad { deg: Some(d), body }) if units(d) == 1 && units(body) == 1), "{:?}", e.math().body); + assert_eq!(ty("\\sqrt[3]{x}+1"), ty("\\sqrt[3]{x}") + "+1"); + // Without a command in front a `[` is a bracket as before, also inside a radical. + let e = typed("sqrt["); + assert!(matches!(e.math().body.first(), Some(Node::Rad { deg: None, .. }))); + // A stray `]` never panics. + let e = typed("\\sqrt[]]"); + assert!(e.is_valid()); +} + +#[test] +fn parentheses_closing_a_radicand_or_script_are_not_kept() { + let strip = |s: &str| ty(s).replace(' ', ""); + assert_eq!(strip("sqrt(b^2-4ac)"), "√(b^2−4ac)"); + let e = typed("sqrt(b^2-4ac)"); + assert!(matches!(e.math().body.first(), Some(Node::Rad { body, .. }) if !body.iter().any(|n| matches!(n.bare(), Node::Delim { .. })))); + let e = typed("sum_(i=1)^n"); + assert!( + matches!(e.math().body.first(), Some(Node::Nary { sub: Some(s), .. }) if !s.iter().any(|n| matches!(n.bare(), Node::Delim { .. }))), + "{:?}", + e.math().body + ); + let e = typed("x^(n+1)"); + assert!(matches!(e.math().body.first(), Some(Node::Script { sup: Some(s), .. }) if !s.iter().any(|n| matches!(n.bare(), Node::Delim { .. })))); + // Nested groups keep the inner ones, and the quadratic formula still closes properly. + let e = typed("x=(-b+-sqrt(b^2-4ac))/2a"); + assert!(matches!(e.math().body.last(), Some(Node::Frac { .. }))); +} + +#[test] +fn backspace_after_an_exponent_deletes_its_last_character() { + let mut e = typed("z^3"); + e.key(Key::Right, false); + assert!(e.caret().path.is_empty()); + e.key(Key::Backspace, false); + assert_eq!(lin(&e), "z^()"); + e.type_str("2"); + assert_eq!(lin(&e), "z^2"); +} + +#[test] +fn a_space_ends_an_upper_limit_and_inf_becomes_infinity() { + let e = typed("sum_(i=1)^n i^2=1/2"); + let Some(Node::Nary { sup: Some(sup), body, .. }) = e.math().body.first().map(Node::bare) else { panic!("{:?}", e.math().body) }; + assert_eq!(units(sup), 1, "the upper limit is just n"); + assert!(units(body) >= 2, "the summand follows: {body:?}"); + // The braced form ends the last limit at its closing brace. + let e = typed("\\sum_{i=1}^{n}i"); + assert!(matches!(e.math().body.first().map(Node::bare), Some(Node::Nary { sup: Some(s), .. }) if units(s) == 1)); + // `inf` converts once the word is over, and `infty` still works. + let mut e = typed("x->inf"); + assert_eq!(lin(&e).chars().last(), Some('f'), "still being typed"); + e.flush(); + assert_eq!(lin(&e).chars().last(), Some('\u{221E}')); + assert_eq!(lin(&typed("inf+1")), "\u{221E}+1"); + assert_eq!(lin(&typed("\\infty")), "\u{221E}"); + assert_eq!(lin(&typed("infty")), "\u{221E}"); + assert_eq!(lin(&typed("info")), "info"); + let mut e = typed("inf"); + e.key(Key::Backspace, false); + assert_eq!(lin(&e), "in", "Backspace edits the word instead of converting it"); +} + +// --- Ported from DeckCraft 7972cf5 (crates/math/src/edit/tests.rs). --- +// +// All three failed against WordCraft dbfe1b1's edit model (`mv^2/2` gave `mv^(2/2)`, `e^ipi` +// stayed `e^(ipi)`, `x^n+1` gave `x^n` then `+1`, `x^(n+1)` left the caret in the exponent). +// Decision for each: DeckCraft's behaviour wins (MathLive-style exponent rules), ported into +// edit.rs with every WordCraft test still passing. Details in DIVERGENCE.md. + +/// Decision: DeckCraft wins. A digits-only exponent ends at the next non-digit (`mv^2/2` is +/// `(mv^2)/2`, `x^2sin x` is `x^2·sin x`); multi-digit exponents keep going. +#[test] +fn a_single_digit_exponent_ends_at_the_next_term() { + let e = typed("mv^2/2"); + assert!(matches!(e.math().body.first(), Some(Node::Frac { .. })), "{}", lin(&e)); + assert_eq!(e.caret().path.len(), 1, "caret in the denominator"); + let e = typed("x^2sin x"); + assert_eq!(e.math().body.len(), 2, "{}", lin(&e)); + assert!(matches!(e.math().body.get(1), Some(Node::Func { .. }))); + let e = typed("2^10"); + assert_eq!(lin(&e), "2^10"); + assert_eq!(e.caret().path.len(), 1, "multi-digit exponent keeps going"); +} + +/// Decision: DeckCraft wins. A letter exponent keeps operators (`x^n+1` is `x^(n+1)`), and the +/// three-letter run `ipi` converts its `pi` (`e^ipi` is `e^(iπ)`). +#[test] +fn letters_keep_going_in_an_exponent() { + let e = typed("e^ipi"); + assert_eq!(lin(&e), "e^(iπ)"); + assert_eq!(e.caret().path.len(), 1); + let e = typed("x^n+1"); + assert_eq!(lin(&e), "x^(n+1)"); + assert_eq!(e.caret().path.len(), 1, "operators stay in a letter exponent"); + let e = typed("2^n-1"); + assert_eq!(lin(&e), "2^(n−1)"); +} + +/// Decision: DeckCraft wins. WordCraft already dropped the parentheses; closing the group now +/// also takes the caret out of the exponent. +#[test] +fn a_parenthesised_exponent_is_the_group_itself() { + let e = typed("x^(n+1)"); + let Some(Node::Script { sup: Some(s), .. }) = e.math().body.first() else { panic!("no script") }; + assert!(s.iter().all(|n| !matches!(n, Node::Delim { .. })), "no doubled fence"); + assert!(e.caret().path.is_empty(), "closing the group ends the exponent"); + assert_eq!(lin(&e), "x^(n+1)"); +} + +#[test] +fn typed_angle_floor_and_ceiling_commands_become_brackets() { + let mut e = Editor::new(Math::default()); + e.type_str(r"\langle u,v\rangle+\lfloor x\rfloor+\lceil y\rceil"); + assert_eq!(crate::to_linear(e.math()).replace(' ', ""), "⟨u,v⟩+⌊x⌋+⌈y⌉"); +} + +#[test] +fn latex_round_trips_through_the_editor() { + let mut e = Editor::new(Math::default()); + e.set_latex(r"\frac{a}{b}+\sqrt{x}"); + assert_eq!(e.latex(), r"\frac{a}{b}+\sqrt{x}"); + assert!(!e.undo(), "loading a value leaves no undo step"); +} + +#[test] +fn caret_edge_queries() { + let mut e = Editor::new(crate::from_linear("x+1")); + assert!(e.at_end() && !e.at_start()); + e.key(Key::Home, false); + assert!(e.at_start() && !e.at_end()); + let mut f = Editor::new(crate::from_linear(r"\frac{a}{b}")); + f.key(Key::Home, false); + f.key(Key::Right, false); + assert!(!f.at_start() && !f.at_end(), "inside the numerator is neither edge"); +} + +fn typed_linear(src: &str) -> String { + to_linear(typed(src).math()) +} + +#[test] +fn closing_a_parenthesis_ends_the_radical_function_or_denominator() { + assert_eq!(typed_linear("sqrt(x)+1"), "√(x)+1"); + assert_eq!(typed_linear("sqrt(x+1)*2"), "√(x+1)·2"); + assert_eq!(typed_linear("sin(x)+1"), "sin(x)+1"); + assert_eq!(typed_linear("1/(x-1)+2"), "1/(x−1)+2"); + // Without parentheses the structure keeps taking what is typed, as before. + assert_eq!(typed_linear("sqrt x+1"), "√(x+1)"); +} + +#[test] +fn typed_call_names_become_function_nodes_at_the_parenthesis() { + for name in ["floor", "nCr", "mean", "abs", "stdev", "rref"] { + let e = typed(&format!("{name}(x")); + assert!(matches!(e.math().body.first(), Some(Node::Func { name: n, .. }) if n == name), "{name}: {:?}", e.math().body); + assert_eq!(e.caret().path.len(), 1, "{name}: the caret is in the argument"); + } + // Bare-argument names convert as whole words, like sin. + assert!(matches!(typed("sech").math().body.first(), Some(Node::Func { name, .. }) if name == "sech")); + assert!(matches!(typed("arctanh").math().body.first(), Some(Node::Func { name, .. }) if name == "arctanh")); + assert!(matches!(typed("arccot").math().body.first(), Some(Node::Func { name, .. }) if name == "arccot")); + // A call-only name typed without a parenthesis is just letters; a plain "(" still nests. + assert!(!matches!(typed("floor").math().body.first(), Some(Node::Func { .. }))); + assert!(matches!(typed("x(").math().body.get(1), Some(Node::Delim { .. }))); +} + +#[test] +fn choose_templates_have_a_place_where_n_and_r_go() { + for (id, letter) in [("nCr", "C"), ("nPr", "P")] { + let Some(t) = template(id) else { panic!("{id}") }; + let mut e = Editor::new(Math::default()); + e.insert_template(&t.math); + e.type_str("5"); + e.key(Key::Tab, false); + e.type_str("2"); + assert_eq!(e.latex(), format!("{{}}_5{letter}_2"), "{id}"); + } +} + +#[test] +fn lcm_template_has_a_place_for_each_argument() { + let Some(t) = template("lcm") else { panic!("lcm") }; + let mut e = Editor::new(Math::default()); + e.insert_template(&t.math); + e.type_str("4"); + e.key(Key::Tab, false); + e.type_str("6"); + assert_eq!(e.latex(), "\\operatorname{lcm}\\left(4,6\\right)"); +} diff --git a/crates/equashion-math/src/funcs.rs b/crates/equashion-math/src/funcs.rs new file mode 100644 index 00000000..bba3cddf --- /dev/null +++ b/crates/equashion-math/src/funcs.rs @@ -0,0 +1,35 @@ +//! The function names the parsers and the editor know. One place, so a name added here is +//! typed, parsed and printed the same way everywhere. + +/// Names LaTeX spells as commands (`\sin`); the LaTeX writer prints them as `\name`. +pub(crate) const LATEX: &[&str] = &[ + "sin", "cos", "tan", "cot", "sec", "csc", "arcsin", "arccos", "arctan", "sinh", "cosh", "tanh", "log", "ln", "exp", "lim", "max", "min", "det", + "gcd", +]; + +/// Names with no LaTeX command; the LaTeX writer prints them as `\operatorname{name}`. They +/// are typed with a parenthesis (`floor(x)`) unless they are also in [`BARE`]. +pub(crate) const OPNAMES: &[&str] = &[ + // hyperbolic and the inverse trigonometric / hyperbolic functions LaTeX has no command for + "csch", "sech", "coth", "arccsc", "arcsec", "arccot", "arcsinh", "arccosh", "arctanh", // rounding + "abs", "floor", "ceil", "round", "sign", // number theory + "lcm", "nPr", "nCr", // statistics + "mean", "median", "stdev", "stdevp", "var", "cov", "corr", "total", "count", "quartile", "quantile", "mad", // matrices + "trace", "rank", "rref", +]; + +/// Functions that take an unparenthesised argument (`sin x`, `sech x`). +pub(crate) const BARE: &[&str] = &[ + "sin", "cos", "tan", "cot", "sec", "csc", "arcsin", "arccos", "arctan", "sinh", "cosh", "tanh", "log", "ln", "exp", "csch", "sech", "coth", + "arccsc", "arcsec", "arccot", "arcsinh", "arccosh", "arctanh", +]; + +/// True for any name that becomes a function node when typed with its argument. +pub(crate) fn is_func(name: &str) -> bool { + LATEX.contains(&name) || OPNAMES.contains(&name) +} + +/// True for a name that is only a function once a `(` follows it (not a bare-argument one). +pub(crate) fn is_call_only(name: &str) -> bool { + OPNAMES.contains(&name) && !BARE.contains(&name) +} diff --git a/crates/equashion-math/src/gallery.json b/crates/equashion-math/src/gallery.json new file mode 100644 index 00000000..56c6eaa6 --- /dev/null +++ b/crates/equashion-math/src/gallery.json @@ -0,0 +1 @@ +{"builtins":[{"id":"pythagoras","label":"Sides of a right triangle","linear":"a^2+b^2=c^2","math":{"attrs":"","body":[{"Script":{"base":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"a"}}}],"sub":null,"sup":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"2"}}}]}},{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"+"}}},{"Script":{"base":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"b"}}}],"sub":null,"sup":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"2"}}}]}},{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"="}}},{"Script":{"base":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"c"}}}],"sub":null,"sup":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"2"}}}]}}],"jc":null,"para":false}},{"id":"area","label":"Circle area from its radius","linear":"A=πr^2","math":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"A=π"}}},{"Script":{"base":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"r"}}}],"sub":null,"sup":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"2"}}}]}}],"jc":null,"para":false}},{"id":"quadratic","label":"Roots of ax²+bx+c","linear":"x=(−b±√(b^2−4ac))/2a","math":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"x="}}},{"Frac":{"den":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"2a"}}}],"num":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"−b±"}}},{"Rad":{"body":[{"Script":{"base":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"b"}}}],"sub":null,"sup":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"2"}}}]}},{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"−4ac"}}}],"deg":null}}]}}],"jc":null,"para":false}},{"id":"binomial","label":"Binomial expansion of (x+a)ⁿ","linear":"(x+a)^n=∑_(k=0)^n▒〖(n¦k) x^k a^(n−k)〗","math":{"attrs":"","body":[{"Script":{"base":[{"Delim":{"close":")","items":[[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"x+a"}}}]],"open":"(","sep":""}}],"sub":null,"sup":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"n"}}}]}},{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"="}}},{"Nary":{"body":[{"Delim":{"close":")","items":[[{"Props":{"extra":{"attrs":"","pr":[["type",""]],"side":[],"tail":[]},"node":{"Frac":{"den":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"k"}}}],"num":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"n"}}}]}}}}]],"open":"(","sep":""}},{"Script":{"base":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"x"}}}],"sub":null,"sup":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"k"}}}]}},{"Script":{"base":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"a"}}}],"sub":null,"sup":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"n−k"}}}]}}],"op":"∑","sub":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"k=0"}}}],"sup":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"n"}}}]}}],"jc":null,"para":false}},{"id":"expansion","label":"Power series of (1+x)ⁿ","linear":"(1+x)^n=1+nx/1!+(n(n−1)x^2)/2!+⋯","math":{"attrs":"","body":[{"Script":{"base":[{"Delim":{"close":")","items":[[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"1+x"}}}]],"open":"(","sep":""}}],"sub":null,"sup":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"n"}}}]}},{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"=1+"}}},{"Frac":{"den":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"1!"}}}],"num":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"nx"}}}]}},{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"+"}}},{"Frac":{"den":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"2!"}}}],"num":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"n"}}},{"Delim":{"close":")","items":[[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"n−1"}}}]],"open":"(","sep":""}},{"Script":{"base":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"x"}}}],"sub":null,"sup":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"2"}}}]}}]}},{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"+⋯"}}}],"jc":null,"para":false}},{"id":"euler","label":"Euler's identity","linear":"e^(iπ)+1=0","math":{"attrs":"","body":[{"Script":{"base":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"e"}}}],"sub":null,"sup":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"iπ"}}}]}},{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"+1=0"}}}],"jc":null,"para":false}},{"id":"derivative","label":"Derivative as a limit","linear":"f'(x)=lim┬(h→0)⁡〖(f(x+h)−f(x))/h〗","math":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"f'"}}},{"Delim":{"close":")","items":[[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"x"}}}]],"open":"(","sep":""}},{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"="}}},{"Props":{"extra":{"attrs":"","pr":[],"side":["limh→0"],"tail":[]},"node":{"Func":{"body":[{"Frac":{"den":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"h"}}}],"num":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"f"}}},{"Delim":{"close":")","items":[[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"x+h"}}}]],"open":"(","sep":""}},{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"−f"}}},{"Delim":{"close":")","items":[[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"x"}}}]],"open":"(","sep":""}}]}}],"name":"lim_(h→0)"}}}}],"jc":null,"para":false}},{"id":"taylor","label":"Exponential series","linear":"e^x=1+x/1!+x^2/2!+x^3/3!+⋯, −∞"],"tail":[]},"node":{"Text":"e"}}}],"sub":null,"sup":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"x"}}}]}},{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"=1+"}}},{"Frac":{"den":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"1!"}}}],"num":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"x"}}}]}},{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"+"}}},{"Frac":{"den":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"2!"}}}],"num":[{"Script":{"base":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"x"}}}],"sub":null,"sup":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"2"}}}]}}]}},{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"+"}}},{"Frac":{"den":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"3!"}}}],"num":[{"Script":{"base":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"x"}}}],"sub":null,"sup":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"3"}}}]}}]}},{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"+⋯,−∞"],"tail":[]},"node":{"Text":"e"}}}],"sub":null,"sup":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"−"}}},{"Script":{"base":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"x"}}}],"sub":null,"sup":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"2"}}}]}}]}}],"op":"∫","sub":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"−∞"}}}],"sup":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"∞"}}}]}},{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"dx="}}},{"Rad":{"body":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"π"}}}],"deg":null}}],"jc":null,"para":false}},{"id":"fourier","label":"Sine and cosine series of f(x)","linear":"f(x)=a_0+∑_(n=1)^∞▒〖(a_n cos⁡〖nπx/L〗+b_n sin⁡〖nπx/L〗)〗","math":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"f"}}},{"Delim":{"close":")","items":[[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"x"}}}]],"open":"(","sep":""}},{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"="}}},{"Script":{"base":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"a"}}}],"sub":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"0"}}}],"sup":null}},{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"+"}}},{"Nary":{"body":[{"Delim":{"close":")","items":[[{"Script":{"base":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"a"}}}],"sub":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"n"}}}],"sup":null}},{"Props":{"extra":{"attrs":"","pr":[],"side":["cos"],"tail":[]},"node":{"Func":{"body":[{"Frac":{"den":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"L"}}}],"num":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"nπx"}}}]}}],"name":"cos"}}}},{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"+"}}},{"Script":{"base":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"b"}}}],"sub":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"n"}}}],"sup":null}},{"Props":{"extra":{"attrs":"","pr":[],"side":["sin"],"tail":[]},"node":{"Func":{"body":[{"Frac":{"den":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"L"}}}],"num":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"nπx"}}}]}}],"name":"sin"}}}}]],"open":"(","sep":""}}],"op":"∑","sub":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"n=1"}}}],"sup":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"∞"}}}]}}],"jc":null,"para":false}},{"id":"trig1","label":"Sum or difference of sines","linear":"sin⁡α±sin⁡β=2 sin⁡〖1/2(α±β)〗 cos⁡〖1/2(α∓β)〗","math":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[],"side":["sin"],"tail":[]},"node":{"Func":{"body":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"α"}}}],"name":"sin"}}}},{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"±"}}},{"Props":{"extra":{"attrs":"","pr":[],"side":["sin"],"tail":[]},"node":{"Func":{"body":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"β"}}}],"name":"sin"}}}},{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"=2"}}},{"Props":{"extra":{"attrs":"","pr":[],"side":["sin"],"tail":[]},"node":{"Func":{"body":[{"Frac":{"den":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"2"}}},{"Delim":{"close":")","items":[[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"α±β"}}}]],"open":"(","sep":""}}],"num":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"1"}}}]}}],"name":"sin"}}}},{"Props":{"extra":{"attrs":"","pr":[],"side":["cos"],"tail":[]},"node":{"Func":{"body":[{"Frac":{"den":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"2"}}},{"Delim":{"close":")","items":[[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"α∓β"}}}]],"open":"(","sep":""}}],"num":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"1"}}}]}}],"name":"cos"}}}}],"jc":null,"para":false}},{"id":"trig2","label":"Sum of cosines","linear":"cos⁡α+cos⁡β=2 cos⁡〖1/2(α+β)〗 cos⁡〖1/2(α−β)〗","math":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[],"side":["cos"],"tail":[]},"node":{"Func":{"body":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"α"}}}],"name":"cos"}}}},{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"+"}}},{"Props":{"extra":{"attrs":"","pr":[],"side":["cos"],"tail":[]},"node":{"Func":{"body":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"β"}}}],"name":"cos"}}}},{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"=2"}}},{"Props":{"extra":{"attrs":"","pr":[],"side":["cos"],"tail":[]},"node":{"Func":{"body":[{"Frac":{"den":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"2"}}},{"Delim":{"close":")","items":[[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"α+β"}}}]],"open":"(","sep":""}}],"num":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"1"}}}]}}],"name":"cos"}}}},{"Props":{"extra":{"attrs":"","pr":[],"side":["cos"],"tail":[]},"node":{"Func":{"body":[{"Frac":{"den":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"2"}}},{"Delim":{"close":")","items":[[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"α−β"}}}]],"open":"(","sep":""}}],"num":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"1"}}}]}}],"name":"cos"}}}}],"jc":null,"para":false}},{"id":"normal","label":"Normal probability density","linear":"f(x)=1/(σ√(2π)) e^(−(x−μ)^2/(2σ^2))","math":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"f"}}},{"Delim":{"close":")","items":[[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"x"}}}]],"open":"(","sep":""}},{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"="}}},{"Frac":{"den":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"σ"}}},{"Rad":{"body":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"2π"}}}],"deg":null}}],"num":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"1"}}}]}},{"Script":{"base":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"e"}}}],"sub":null,"sup":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"−"}}},{"Frac":{"den":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"2"}}},{"Script":{"base":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"σ"}}}],"sub":null,"sup":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"2"}}}]}}],"num":[{"Script":{"base":[{"Delim":{"close":")","items":[[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"x−μ"}}}]],"open":"(","sep":""}}],"sub":null,"sup":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"2"}}}]}}]}}]}}],"jc":null,"para":false}},{"id":"bayes","label":"Bayes' rule","linear":"P(A|B)=(P(B|A)P(A))/P(B)","math":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"P"}}},{"Delim":{"close":")","items":[[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"A"}}},{"Delim":{"close":"","items":[[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"B"}}}]],"open":"|","sep":""}}]],"open":"(","sep":""}},{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"="}}},{"Frac":{"den":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"P"}}},{"Delim":{"close":")","items":[[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"B"}}}]],"open":"(","sep":""}}],"num":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"P"}}},{"Delim":{"close":")","items":[[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"B|A"}}}]],"open":"(","sep":""}},{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"P"}}},{"Delim":{"close":")","items":[[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"A"}}}]],"open":"(","sep":""}}]}}],"jc":null,"para":false}}],"equationPicture":{"attrs":"","body":[{"Nary":{"body":[],"op":"∑","sub":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"i=1"}}}],"sup":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"n"}}}]}}],"jc":null,"para":false},"structures":[{"id":"fraction","label":"Fraction","picture":{"attrs":"","body":[{"Frac":{"den":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"y"}}}],"num":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"x"}}}]}}],"jc":null,"para":false},"sections":[{"name":"Fraction","templates":[{"id":"frac.stacked","label":"Stacked Fraction","linear":"⬚/⬚","math":{"attrs":"","body":[{"Frac":{"den":[],"num":[]}}],"jc":null,"para":false}},{"id":"frac.skewed","label":"Skewed Fraction","linear":"⬚⁄⬚","math":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[["type",""]],"side":[],"tail":[]},"node":{"Frac":{"den":[],"num":[]}}}}],"jc":null,"para":false}},{"id":"frac.linear","label":"Linear Fraction","linear":"⬚∕⬚","math":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[["type",""]],"side":[],"tail":[]},"node":{"Frac":{"den":[],"num":[]}}}}],"jc":null,"para":false}},{"id":"frac.nobar","label":"Stacked, no bar","linear":"⬚¦⬚","math":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[["type",""]],"side":[],"tail":[]},"node":{"Frac":{"den":[],"num":[]}}}}],"jc":null,"para":false}}]},{"name":"Common Fractions","templates":[{"id":"frac.dydx","label":"Differential","linear":"dy/dx","math":{"attrs":"","body":[{"Frac":{"den":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"dx"}}}],"num":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"dy"}}}]}}],"jc":null,"para":false}},{"id":"frac.DyDx","label":"Change in y over change in x","linear":"Δy/Δx","math":{"attrs":"","body":[{"Frac":{"den":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"Δx"}}}],"num":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"Δy"}}}]}}],"jc":null,"para":false}},{"id":"frac.partial","label":"Partial Differential","linear":"∂y/∂x","math":{"attrs":"","body":[{"Frac":{"den":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"∂x"}}}],"num":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"∂y"}}}]}}],"jc":null,"para":false}},{"id":"frac.delta","label":"Small change","linear":"δy/δx","math":{"attrs":"","body":[{"Frac":{"den":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"δx"}}}],"num":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"δy"}}}]}}],"jc":null,"para":false}},{"id":"frac.pi2","label":"Pi Over 2","linear":"π/2","math":{"attrs":"","body":[{"Frac":{"den":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"2"}}}],"num":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"π"}}}]}}],"jc":null,"para":false}},{"id":"frac.half","label":"One half","linear":"1/2","math":{"attrs":"","body":[{"Frac":{"den":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"2"}}}],"num":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"1"}}}]}}],"jc":null,"para":false}}]}]},{"id":"script","label":"Script","picture":{"attrs":"","body":[{"Script":{"base":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"e"}}}],"sub":null,"sup":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"x"}}}]}}],"jc":null,"para":false},"sections":[{"name":"Subscripts and Superscripts","templates":[{"id":"script.sup","label":"Superscript","linear":"⬚^⬚","math":{"attrs":"","body":[{"Script":{"base":[],"sub":null,"sup":[]}}],"jc":null,"para":false}},{"id":"script.sub","label":"Subscript","linear":"⬚_⬚","math":{"attrs":"","body":[{"Script":{"base":[],"sub":[],"sup":null}}],"jc":null,"para":false}},{"id":"script.subsup","label":"Subscript-Superscript","linear":"⬚_⬚^⬚","math":{"attrs":"","body":[{"Script":{"base":[],"sub":[],"sup":[]}}],"jc":null,"para":false}},{"id":"script.pre","label":"Left Subscript-Superscript","linear":"_⬚^⬚▒⬚","math":{"attrs":"","body":[{"PreScript":{"base":[],"sub":[],"sup":[]}}],"jc":null,"para":false}}]},{"name":"Common Subscripts and Superscripts","templates":[{"id":"script.xy2","label":"Subscript-Superscript","linear":"x_y^2","math":{"attrs":"","body":[{"Script":{"base":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"x"}}}],"sub":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"y"}}}],"sup":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"2"}}}]}}],"jc":null,"para":false}},{"id":"script.eiwt","label":"Exponential","linear":"e^(−iωt)","math":{"attrs":"","body":[{"Script":{"base":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"e"}}}],"sub":null,"sup":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"−iωt"}}}]}}],"jc":null,"para":false}},{"id":"script.x2","label":"Square","linear":"x^2","math":{"attrs":"","body":[{"Script":{"base":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"x"}}}],"sub":null,"sup":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"2"}}}]}}],"jc":null,"para":false}},{"id":"script.pre1n","label":"Left Subscript-Superscript","linear":"_1^n▒Y","math":{"attrs":"","body":[{"PreScript":{"base":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"Y"}}}],"sub":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"1"}}}],"sup":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"n"}}}]}}],"jc":null,"para":false}}]}]},{"id":"radical","label":"Radical","picture":{"attrs":"","body":[{"Rad":{"body":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"x"}}}],"deg":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"n"}}}]}}],"jc":null,"para":false},"sections":[{"name":"Radicals","templates":[{"id":"rad.sqrt","label":"Square Root","linear":"√⬚","math":{"attrs":"","body":[{"Rad":{"body":[],"deg":null}}],"jc":null,"para":false}},{"id":"rad.nth","label":"Radical with Degree","linear":"√(⬚&⬚)","math":{"attrs":"","body":[{"Rad":{"body":[],"deg":[]}}],"jc":null,"para":false}},{"id":"rad.sqrt2","label":"Square Root with Degree","linear":"√(2&⬚)","math":{"attrs":"","body":[{"Rad":{"body":[],"deg":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"2"}}}]}}],"jc":null,"para":false}},{"id":"rad.cbrt","label":"Cube Root","linear":"√(3&⬚)","math":{"attrs":"","body":[{"Rad":{"body":[],"deg":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"3"}}}]}}],"jc":null,"para":false}}]},{"name":"Common Radicals","templates":[{"id":"rad.quadratic","label":"Quadratic Root","linear":"(−b±√(b^2−4ac))/2a","math":{"attrs":"","body":[{"Frac":{"den":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"2a"}}}],"num":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"−b±"}}},{"Rad":{"body":[{"Script":{"base":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"b"}}}],"sub":null,"sup":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"2"}}}]}},{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"−4ac"}}}],"deg":null}}]}}],"jc":null,"para":false}},{"id":"rad.hyp","label":"Hypotenuse","linear":"√(a^2+b^2)","math":{"attrs":"","body":[{"Rad":{"body":[{"Script":{"base":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"a"}}}],"sub":null,"sup":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"2"}}}]}},{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"+"}}},{"Script":{"base":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"b"}}}],"sub":null,"sup":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"2"}}}]}}],"deg":null}}],"jc":null,"para":false}}]}]},{"id":"integral","label":"Integral","picture":{"attrs":"","body":[{"Nary":{"body":[],"op":"∫","sub":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"−x"}}}],"sup":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"x"}}}]}}],"jc":null,"para":false},"sections":[{"name":"Integrals","templates":[{"id":"int.plain","label":"Integral","linear":"∫▒⬚","math":{"attrs":"","body":[{"Nary":{"body":[],"op":"∫","sub":null,"sup":null}}],"jc":null,"para":false}},{"id":"int.limits","label":"Integral with Limits","linear":"∫_⬚^⬚▒⬚","math":{"attrs":"","body":[{"Nary":{"body":[],"op":"∫","sub":[],"sup":[]}}],"jc":null,"para":false}},{"id":"int.stacked","label":"Integral with Stacked Limits","linear":"∫_⬚^⬚▒⬚","math":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[["limLoc",""]],"side":[],"tail":[]},"node":{"Nary":{"body":[],"op":"∫","sub":[],"sup":[]}}}}],"jc":null,"para":false}},{"id":"int.double","label":"Double Integral","linear":"∬▒⬚","math":{"attrs":"","body":[{"Nary":{"body":[],"op":"∬","sub":null,"sup":null}}],"jc":null,"para":false}},{"id":"int.doubleLimits","label":"Double Integral with Limits","linear":"∬_⬚^⬚▒⬚","math":{"attrs":"","body":[{"Nary":{"body":[],"op":"∬","sub":[],"sup":[]}}],"jc":null,"para":false}},{"id":"int.doubleStacked","label":"Double Integral with Stacked Limits","linear":"∬_⬚^⬚▒⬚","math":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[["limLoc",""]],"side":[],"tail":[]},"node":{"Nary":{"body":[],"op":"∬","sub":[],"sup":[]}}}}],"jc":null,"para":false}},{"id":"int.triple","label":"Triple Integral","linear":"∭▒⬚","math":{"attrs":"","body":[{"Nary":{"body":[],"op":"∭","sub":null,"sup":null}}],"jc":null,"para":false}},{"id":"int.tripleLimits","label":"Triple Integral with Limits","linear":"∭_⬚^⬚▒⬚","math":{"attrs":"","body":[{"Nary":{"body":[],"op":"∭","sub":[],"sup":[]}}],"jc":null,"para":false}},{"id":"int.tripleStacked","label":"Triple Integral with Stacked Limits","linear":"∭_⬚^⬚▒⬚","math":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[["limLoc",""]],"side":[],"tail":[]},"node":{"Nary":{"body":[],"op":"∭","sub":[],"sup":[]}}}}],"jc":null,"para":false}}]},{"name":"Contour Integrals","templates":[{"id":"int.contour","label":"Contour Integral","linear":"∮▒⬚","math":{"attrs":"","body":[{"Nary":{"body":[],"op":"∮","sub":null,"sup":null}}],"jc":null,"para":false}},{"id":"int.contourLimits","label":"Contour Integral with Limits","linear":"∮_⬚^⬚▒⬚","math":{"attrs":"","body":[{"Nary":{"body":[],"op":"∮","sub":[],"sup":[]}}],"jc":null,"para":false}},{"id":"int.contourStacked","label":"Contour Integral with Stacked Limits","linear":"∮_⬚^⬚▒⬚","math":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[["limLoc",""]],"side":[],"tail":[]},"node":{"Nary":{"body":[],"op":"∮","sub":[],"sup":[]}}}}],"jc":null,"para":false}},{"id":"int.surface","label":"Surface Integral","linear":"∯▒⬚","math":{"attrs":"","body":[{"Nary":{"body":[],"op":"∯","sub":null,"sup":null}}],"jc":null,"para":false}},{"id":"int.surfaceLimits","label":"Surface Integral with Limits","linear":"∯_⬚^⬚▒⬚","math":{"attrs":"","body":[{"Nary":{"body":[],"op":"∯","sub":[],"sup":[]}}],"jc":null,"para":false}},{"id":"int.surfaceStacked","label":"Surface Integral with Stacked Limits","linear":"∯_⬚^⬚▒⬚","math":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[["limLoc",""]],"side":[],"tail":[]},"node":{"Nary":{"body":[],"op":"∯","sub":[],"sup":[]}}}}],"jc":null,"para":false}},{"id":"int.volume","label":"Volume Integral","linear":"∰▒⬚","math":{"attrs":"","body":[{"Nary":{"body":[],"op":"∰","sub":null,"sup":null}}],"jc":null,"para":false}},{"id":"int.volumeLimits","label":"Volume Integral with Limits","linear":"∰_⬚^⬚▒⬚","math":{"attrs":"","body":[{"Nary":{"body":[],"op":"∰","sub":[],"sup":[]}}],"jc":null,"para":false}},{"id":"int.volumeStacked","label":"Volume Integral with Stacked Limits","linear":"∰_⬚^⬚▒⬚","math":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[["limLoc",""]],"side":[],"tail":[]},"node":{"Nary":{"body":[],"op":"∰","sub":[],"sup":[]}}}}],"jc":null,"para":false}}]},{"name":"Differentials","templates":[{"id":"int.dx","label":"Differential x","linear":"dx","math":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"dx"}}}],"jc":null,"para":false}},{"id":"int.dy","label":"Differential y","linear":"dy","math":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"dy"}}}],"jc":null,"para":false}},{"id":"int.dtheta","label":"Differential theta","linear":"dθ","math":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"dθ"}}}],"jc":null,"para":false}}]}]},{"id":"largeOperator","label":"Large\nOperator","picture":{"attrs":"","body":[{"Nary":{"body":[],"op":"∑","sub":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"i=0"}}}],"sup":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"n"}}}]}}],"jc":null,"para":false},"sections":[{"name":"Summations","templates":[{"id":"nary.sum","label":"Summation","linear":"∑▒⬚","math":{"attrs":"","body":[{"Nary":{"body":[],"op":"∑","sub":null,"sup":null}}],"jc":null,"para":false}},{"id":"nary.sumLimits","label":"Summation with Limits","linear":"∑_⬚^⬚▒⬚","math":{"attrs":"","body":[{"Nary":{"body":[],"op":"∑","sub":[],"sup":[]}}],"jc":null,"para":false}},{"id":"nary.sumScripts","label":"Summation with Subscript-Superscript Limits","linear":"∑_⬚^⬚▒⬚","math":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[["limLoc",""]],"side":[],"tail":[]},"node":{"Nary":{"body":[],"op":"∑","sub":[],"sup":[]}}}}],"jc":null,"para":false}},{"id":"nary.sumLower","label":"Summation with Lower Limit","linear":"∑_⬚▒⬚","math":{"attrs":"","body":[{"Nary":{"body":[],"op":"∑","sub":[],"sup":null}}],"jc":null,"para":false}},{"id":"nary.sumSub","label":"Summation with Subscript Limit","linear":"∑_⬚▒⬚","math":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[["limLoc",""]],"side":[],"tail":[]},"node":{"Nary":{"body":[],"op":"∑","sub":[],"sup":null}}}}],"jc":null,"para":false}}]},{"name":"Products and Coproducts","templates":[{"id":"nary.prod","label":"Product","linear":"∏▒⬚","math":{"attrs":"","body":[{"Nary":{"body":[],"op":"∏","sub":null,"sup":null}}],"jc":null,"para":false}},{"id":"nary.prodLimits","label":"Product with Limits","linear":"∏_⬚^⬚▒⬚","math":{"attrs":"","body":[{"Nary":{"body":[],"op":"∏","sub":[],"sup":[]}}],"jc":null,"para":false}},{"id":"nary.prodScripts","label":"Product with Subscript-Superscript Limits","linear":"∏_⬚^⬚▒⬚","math":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[["limLoc",""]],"side":[],"tail":[]},"node":{"Nary":{"body":[],"op":"∏","sub":[],"sup":[]}}}}],"jc":null,"para":false}},{"id":"nary.prodLower","label":"Product with Lower Limit","linear":"∏_⬚▒⬚","math":{"attrs":"","body":[{"Nary":{"body":[],"op":"∏","sub":[],"sup":null}}],"jc":null,"para":false}},{"id":"nary.coprod","label":"Coproduct","linear":"∐▒⬚","math":{"attrs":"","body":[{"Nary":{"body":[],"op":"∐","sub":null,"sup":null}}],"jc":null,"para":false}},{"id":"nary.coprodLimits","label":"Coproduct with Limits","linear":"∐_⬚^⬚▒⬚","math":{"attrs":"","body":[{"Nary":{"body":[],"op":"∐","sub":[],"sup":[]}}],"jc":null,"para":false}},{"id":"nary.coprodScripts","label":"Coproduct with Subscript-Superscript Limits","linear":"∐_⬚^⬚▒⬚","math":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[["limLoc",""]],"side":[],"tail":[]},"node":{"Nary":{"body":[],"op":"∐","sub":[],"sup":[]}}}}],"jc":null,"para":false}},{"id":"nary.coprodLower","label":"Coproduct with Lower Limit","linear":"∐_⬚▒⬚","math":{"attrs":"","body":[{"Nary":{"body":[],"op":"∐","sub":[],"sup":null}}],"jc":null,"para":false}}]},{"name":"Unions and Intersections","templates":[{"id":"nary.union","label":"Union","linear":"⋃▒⬚","math":{"attrs":"","body":[{"Nary":{"body":[],"op":"⋃","sub":null,"sup":null}}],"jc":null,"para":false}},{"id":"nary.unionLimits","label":"Union with Limits","linear":"⋃_⬚^⬚▒⬚","math":{"attrs":"","body":[{"Nary":{"body":[],"op":"⋃","sub":[],"sup":[]}}],"jc":null,"para":false}},{"id":"nary.unionScripts","label":"Union with Subscript-Superscript Limits","linear":"⋃_⬚^⬚▒⬚","math":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[["limLoc",""]],"side":[],"tail":[]},"node":{"Nary":{"body":[],"op":"⋃","sub":[],"sup":[]}}}}],"jc":null,"para":false}},{"id":"nary.unionLower","label":"Union with Lower Limit","linear":"⋃_⬚▒⬚","math":{"attrs":"","body":[{"Nary":{"body":[],"op":"⋃","sub":[],"sup":null}}],"jc":null,"para":false}},{"id":"nary.intersection","label":"Intersection","linear":"⋂▒⬚","math":{"attrs":"","body":[{"Nary":{"body":[],"op":"⋂","sub":null,"sup":null}}],"jc":null,"para":false}},{"id":"nary.intersectionLimits","label":"Intersection with Limits","linear":"⋂_⬚^⬚▒⬚","math":{"attrs":"","body":[{"Nary":{"body":[],"op":"⋂","sub":[],"sup":[]}}],"jc":null,"para":false}},{"id":"nary.intersectionScripts","label":"Intersection with Subscript-Superscript Limits","linear":"⋂_⬚^⬚▒⬚","math":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[["limLoc",""]],"side":[],"tail":[]},"node":{"Nary":{"body":[],"op":"⋂","sub":[],"sup":[]}}}}],"jc":null,"para":false}},{"id":"nary.intersectionLower","label":"Intersection with Lower Limit","linear":"⋂_⬚▒⬚","math":{"attrs":"","body":[{"Nary":{"body":[],"op":"⋂","sub":[],"sup":null}}],"jc":null,"para":false}}]},{"name":"Other Large Operators","templates":[{"id":"nary.or","label":"Logical OR","linear":"⋁▒⬚","math":{"attrs":"","body":[{"Nary":{"body":[],"op":"⋁","sub":null,"sup":null}}],"jc":null,"para":false}},{"id":"nary.orLimits","label":"Logical OR with Limits","linear":"⋁_⬚^⬚▒⬚","math":{"attrs":"","body":[{"Nary":{"body":[],"op":"⋁","sub":[],"sup":[]}}],"jc":null,"para":false}},{"id":"nary.and","label":"Logical AND","linear":"⋀▒⬚","math":{"attrs":"","body":[{"Nary":{"body":[],"op":"⋀","sub":null,"sup":null}}],"jc":null,"para":false}},{"id":"nary.andLimits","label":"Logical AND with Limits","linear":"⋀_⬚^⬚▒⬚","math":{"attrs":"","body":[{"Nary":{"body":[],"op":"⋀","sub":[],"sup":[]}}],"jc":null,"para":false}},{"id":"nary.oplus","label":"Circled Plus","linear":"⨁▒⬚","math":{"attrs":"","body":[{"Nary":{"body":[],"op":"⨁","sub":null,"sup":null}}],"jc":null,"para":false}},{"id":"nary.otimes","label":"Circled Times","linear":"⨂▒⬚","math":{"attrs":"","body":[{"Nary":{"body":[],"op":"⨂","sub":null,"sup":null}}],"jc":null,"para":false}},{"id":"nary.odot","label":"Circled Dot","linear":"⨀▒⬚","math":{"attrs":"","body":[{"Nary":{"body":[],"op":"⨀","sub":null,"sup":null}}],"jc":null,"para":false}},{"id":"nary.uplus","label":"Union with Plus","linear":"⨄▒⬚","math":{"attrs":"","body":[{"Nary":{"body":[],"op":"⨄","sub":null,"sup":null}}],"jc":null,"para":false}},{"id":"nary.sqcup","label":"Square Union","linear":"⨆▒⬚","math":{"attrs":"","body":[{"Nary":{"body":[],"op":"⨆","sub":null,"sup":null}}],"jc":null,"para":false}}]},{"name":"Common Large Operators","templates":[{"id":"nary.binomial","label":"Summation over binomials","linear":"∑_k▒〖(n¦k)〗","math":{"attrs":"","body":[{"Nary":{"body":[{"Delim":{"close":")","items":[[{"Props":{"extra":{"attrs":"","pr":[["type",""]],"side":[],"tail":[]},"node":{"Frac":{"den":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"k"}}}],"num":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"n"}}}]}}}}]],"open":"(","sep":""}}],"op":"∑","sub":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"k"}}}],"sup":null}}],"jc":null,"para":false}},{"id":"nary.sum0n","label":"Summation from 0 to n","linear":"∑_(i=0)^n▒⬚","math":{"attrs":"","body":[{"Nary":{"body":[],"op":"∑","sub":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"i=0"}}}],"sup":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"n"}}}]}}],"jc":null,"para":false}},{"id":"nary.double","label":"Double summation","linear":"∑_(0≤i≤m,0"],"tail":[]},"node":{"Text":"P"}}},{"Delim":{"close":")","items":[[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"i,j"}}}]],"open":"(","sep":""}}],"op":"∑","sub":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"0≤i≤m,0"],"tail":[]},"node":{"Text":"A"}}}],"sub":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"k"}}}],"sup":null}}],"op":"∏","sub":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"k=1"}}}],"sup":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"n"}}}]}}],"jc":null,"para":false}},{"id":"nary.unionXY","label":"Union of intersections","linear":"⋃_(n=1)^m▒〖(X_n∩Y_n)〗","math":{"attrs":"","body":[{"Nary":{"body":[{"Delim":{"close":")","items":[[{"Script":{"base":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"X"}}}],"sub":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"n"}}}],"sup":null}},{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"∩"}}},{"Script":{"base":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"Y"}}}],"sub":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"n"}}}],"sup":null}}]],"open":"(","sep":""}}],"op":"⋃","sub":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"n=1"}}}],"sup":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"m"}}}]}}],"jc":null,"para":false}}]}]},{"id":"bracket","label":"Bracket","picture":{"attrs":"","body":[{"Delim":{"close":"}","items":[[{"Delim":{"close":")","items":[[]],"open":"(","sep":""}}]],"open":"{","sep":""}}],"jc":null,"para":false},"sections":[{"name":"Brackets","templates":[{"id":"brk.paren","label":"Parentheses","linear":"(⬚)","math":{"attrs":"","body":[{"Delim":{"close":")","items":[[]],"open":"(","sep":""}}],"jc":null,"para":false}},{"id":"brk.square","label":"Brackets","linear":"[⬚]","math":{"attrs":"","body":[{"Delim":{"close":"]","items":[[]],"open":"[","sep":""}}],"jc":null,"para":false}},{"id":"brk.brace","label":"Braces","linear":"{⬚}","math":{"attrs":"","body":[{"Delim":{"close":"}","items":[[]],"open":"{","sep":""}}],"jc":null,"para":false}},{"id":"brk.angle","label":"Angle Brackets","linear":"⟨⬚⟩","math":{"attrs":"","body":[{"Delim":{"close":"⟩","items":[[]],"open":"⟨","sep":""}}],"jc":null,"para":false}},{"id":"brk.floor","label":"Floor","linear":"⌊⬚⌋","math":{"attrs":"","body":[{"Delim":{"close":"⌋","items":[[]],"open":"⌊","sep":""}}],"jc":null,"para":false}},{"id":"brk.ceil","label":"Ceiling","linear":"⌈⬚⌉","math":{"attrs":"","body":[{"Delim":{"close":"⌉","items":[[]],"open":"⌈","sep":""}}],"jc":null,"para":false}},{"id":"brk.abs","label":"Vertical Bars","linear":"|⬚|","math":{"attrs":"","body":[{"Delim":{"close":"|","items":[[]],"open":"|","sep":""}}],"jc":null,"para":false}},{"id":"brk.norm","label":"Double Vertical Bars","linear":"‖⬚‖","math":{"attrs":"","body":[{"Delim":{"close":"‖","items":[[]],"open":"‖","sep":""}}],"jc":null,"para":false}},{"id":"brk.dbl","label":"Double Square Brackets","linear":"⟦⬚⟧","math":{"attrs":"","body":[{"Delim":{"close":"⟧","items":[[]],"open":"⟦","sep":""}}],"jc":null,"para":false}},{"id":"brk.leftSquare","label":"Left Bracket Twice","linear":"├[⬚[┤","math":{"attrs":"","body":[{"Delim":{"close":"[","items":[[]],"open":"[","sep":""}}],"jc":null,"para":false}},{"id":"brk.rightSquare","label":"Right Bracket Twice","linear":"├]⬚]┤","math":{"attrs":"","body":[{"Delim":{"close":"]","items":[[]],"open":"]","sep":""}}],"jc":null,"para":false}},{"id":"brk.outward","label":"Outward Brackets","linear":"├]⬚[┤","math":{"attrs":"","body":[{"Delim":{"close":"[","items":[[]],"open":"]","sep":""}}],"jc":null,"para":false}}]},{"name":"Brackets with Separators","templates":[{"id":"brk.parenSep","label":"Parentheses with Separator","linear":"(⬚│⬚)","math":{"attrs":"","body":[{"Delim":{"close":")","items":[[],[]],"open":"(","sep":"|"}}],"jc":null,"para":false}},{"id":"brk.braceSep","label":"Braces with Separator","linear":"{⬚│⬚}","math":{"attrs":"","body":[{"Delim":{"close":"}","items":[[],[]],"open":"{","sep":"|"}}],"jc":null,"para":false}},{"id":"brk.angleSep","label":"Angle Brackets with Separator","linear":"⟨⬚│⬚⟩","math":{"attrs":"","body":[{"Delim":{"close":"⟩","items":[[],[]],"open":"⟨","sep":"|"}}],"jc":null,"para":false}},{"id":"brk.angleSep2","label":"Angle Brackets with Two Separators","linear":"⟨⬚│⬚│⬚⟩","math":{"attrs":"","body":[{"Delim":{"close":"⟩","items":[[],[],[]],"open":"⟨","sep":"|"}}],"jc":null,"para":false}}]},{"name":"Single Brackets","templates":[{"id":"brk.leftParen","label":"Single Left Parenthesis","linear":"(⬚┤","math":{"attrs":"","body":[{"Delim":{"close":"","items":[[]],"open":"(","sep":""}}],"jc":null,"para":false}},{"id":"brk.rightParen","label":"Single Right Parenthesis","linear":"├⬚)","math":{"attrs":"","body":[{"Delim":{"close":")","items":[[]],"open":"","sep":""}}],"jc":null,"para":false}},{"id":"brk.leftSquareOnly","label":"Single Left Bracket","linear":"[⬚┤","math":{"attrs":"","body":[{"Delim":{"close":"","items":[[]],"open":"[","sep":""}}],"jc":null,"para":false}},{"id":"brk.rightSquareOnly","label":"Single Right Bracket","linear":"├⬚]","math":{"attrs":"","body":[{"Delim":{"close":"]","items":[[]],"open":"","sep":""}}],"jc":null,"para":false}},{"id":"brk.leftBrace","label":"Single Left Brace","linear":"{⬚┤","math":{"attrs":"","body":[{"Delim":{"close":"","items":[[]],"open":"{","sep":""}}],"jc":null,"para":false}},{"id":"brk.rightBrace","label":"Single Right Brace","linear":"├⬚}","math":{"attrs":"","body":[{"Delim":{"close":"}","items":[[]],"open":"","sep":""}}],"jc":null,"para":false}},{"id":"brk.leftBar","label":"Single Left Bar","linear":"├|⬚┤","math":{"attrs":"","body":[{"Delim":{"close":"","items":[[]],"open":"|","sep":""}}],"jc":null,"para":false}},{"id":"brk.leftAngle","label":"Single Left Angle","linear":"⟨⬚┤","math":{"attrs":"","body":[{"Delim":{"close":"","items":[[]],"open":"⟨","sep":""}}],"jc":null,"para":false}}]},{"name":"Cases and Stacks","templates":[{"id":"brk.cases2","label":"Cases (Two Conditions)","linear":"{█(⬚@⬚)┤","math":{"attrs":"","body":[{"Delim":{"close":"","items":[[{"EqArr":{"rows":[[],[]]}}]],"open":"{","sep":""}}],"jc":null,"para":false}},{"id":"brk.cases3","label":"Cases (Three Conditions)","linear":"{█(⬚@⬚@⬚)┤","math":{"attrs":"","body":[{"Delim":{"close":"","items":[[{"EqArr":{"rows":[[],[],[]]}}]],"open":"{","sep":""}}],"jc":null,"para":false}},{"id":"brk.stack2","label":"Stack Object","linear":"█(⬚@⬚)","math":{"attrs":"","body":[{"EqArr":{"rows":[[],[]]}}],"jc":null,"para":false}},{"id":"brk.stack3","label":"Stack Object (Three)","linear":"█(⬚@⬚@⬚)","math":{"attrs":"","body":[{"EqArr":{"rows":[[],[],[]]}}],"jc":null,"para":false}},{"id":"brk.binom","label":"Binomial Coefficient","linear":"(⬚¦⬚)","math":{"attrs":"","body":[{"Delim":{"close":")","items":[[{"Props":{"extra":{"attrs":"","pr":[["type",""]],"side":[],"tail":[]},"node":{"Frac":{"den":[],"num":[]}}}}]],"open":"(","sep":""}}],"jc":null,"para":false}},{"id":"brk.binomAngle","label":"Binomial Coefficient with Angle Brackets","linear":"⟨⬚¦⬚⟩","math":{"attrs":"","body":[{"Delim":{"close":"⟩","items":[[{"Props":{"extra":{"attrs":"","pr":[["type",""]],"side":[],"tail":[]},"node":{"Frac":{"den":[],"num":[]}}}}]],"open":"⟨","sep":""}}],"jc":null,"para":false}}]},{"name":"Common Brackets","templates":[{"id":"brk.casesAbs","label":"Absolute value cases","linear":"f(x)={█(−x,&x<0@x,&x≥0)┤","math":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"f"}}},{"Delim":{"close":")","items":[[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"x"}}}]],"open":"(","sep":""}},{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"="}}},{"Delim":{"close":"","items":[[{"EqArr":{"rows":[[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"−x,&x<0"}}}],[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"x,&x≥0"}}}]]}}]],"open":"{","sep":""}}],"jc":null,"para":false}},{"id":"brk.nk","label":"Binomial Coefficient","linear":"(n¦k)","math":{"attrs":"","body":[{"Delim":{"close":")","items":[[{"Props":{"extra":{"attrs":"","pr":[["type",""]],"side":[],"tail":[]},"node":{"Frac":{"den":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"k"}}}],"num":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"n"}}}]}}}}]],"open":"(","sep":""}}],"jc":null,"para":false}},{"id":"brk.nk2","label":"Binomial Coefficient (angle)","linear":"⟨n¦k⟩","math":{"attrs":"","body":[{"Delim":{"close":"⟩","items":[[{"Props":{"extra":{"attrs":"","pr":[["type",""]],"side":[],"tail":[]},"node":{"Frac":{"den":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"k"}}}],"num":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"n"}}}]}}}}]],"open":"⟨","sep":""}}],"jc":null,"para":false}}]}]},{"id":"function","label":"Function","picture":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[],"side":["sin"],"tail":[]},"node":{"Func":{"body":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"θ"}}}],"name":"sin"}}}}],"jc":null,"para":false},"sections":[{"name":"Trigonometric Functions","templates":[{"id":"fn.sin","label":"Sine Function","linear":"sin⁡⬚","math":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[],"side":["sin"],"tail":[]},"node":{"Func":{"body":[],"name":"sin"}}}}],"jc":null,"para":false}},{"id":"fn.cos","label":"Cosine Function","linear":"cos⁡⬚","math":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[],"side":["cos"],"tail":[]},"node":{"Func":{"body":[],"name":"cos"}}}}],"jc":null,"para":false}},{"id":"fn.tan","label":"Tangent Function","linear":"tan⁡⬚","math":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[],"side":["tan"],"tail":[]},"node":{"Func":{"body":[],"name":"tan"}}}}],"jc":null,"para":false}},{"id":"fn.csc","label":"Cosecant Function","linear":"csc⁡⬚","math":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[],"side":["csc"],"tail":[]},"node":{"Func":{"body":[],"name":"csc"}}}}],"jc":null,"para":false}},{"id":"fn.sec","label":"Secant Function","linear":"sec⁡⬚","math":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[],"side":["sec"],"tail":[]},"node":{"Func":{"body":[],"name":"sec"}}}}],"jc":null,"para":false}},{"id":"fn.cot","label":"Cotangent Function","linear":"cot⁡⬚","math":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[],"side":["cot"],"tail":[]},"node":{"Func":{"body":[],"name":"cot"}}}}],"jc":null,"para":false}}]},{"name":"Inverse Functions","templates":[{"id":"fn.asin","label":"Inverse Sine Function","linear":"sin^(−1)⁡⬚","math":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[],"side":["sin−1"],"tail":[]},"node":{"Func":{"body":[],"name":"(sin)^−1"}}}}],"jc":null,"para":false}},{"id":"fn.acos","label":"Inverse Cosine Function","linear":"cos^(−1)⁡⬚","math":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[],"side":["cos−1"],"tail":[]},"node":{"Func":{"body":[],"name":"(cos)^−1"}}}}],"jc":null,"para":false}},{"id":"fn.atan","label":"Inverse Tangent Function","linear":"tan^(−1)⁡⬚","math":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[],"side":["tan−1"],"tail":[]},"node":{"Func":{"body":[],"name":"(tan)^−1"}}}}],"jc":null,"para":false}},{"id":"fn.acsc","label":"Inverse Cosecant Function","linear":"csc^(−1)⁡⬚","math":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[],"side":["csc−1"],"tail":[]},"node":{"Func":{"body":[],"name":"(csc)^−1"}}}}],"jc":null,"para":false}},{"id":"fn.asec","label":"Inverse Secant Function","linear":"sec^(−1)⁡⬚","math":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[],"side":["sec−1"],"tail":[]},"node":{"Func":{"body":[],"name":"(sec)^−1"}}}}],"jc":null,"para":false}},{"id":"fn.acot","label":"Inverse Cotangent Function","linear":"cot^(−1)⁡⬚","math":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[],"side":["cot−1"],"tail":[]},"node":{"Func":{"body":[],"name":"(cot)^−1"}}}}],"jc":null,"para":false}}]},{"name":"Hyperbolic Functions","templates":[{"id":"fn.sinh","label":"Hyperbolic Sine Function","linear":"sinh⁡⬚","math":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[],"side":["sinh"],"tail":[]},"node":{"Func":{"body":[],"name":"sinh"}}}}],"jc":null,"para":false}},{"id":"fn.cosh","label":"Hyperbolic Cosine Function","linear":"cosh⁡⬚","math":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[],"side":["cosh"],"tail":[]},"node":{"Func":{"body":[],"name":"cosh"}}}}],"jc":null,"para":false}},{"id":"fn.tanh","label":"Hyperbolic Tangent Function","linear":"tanh⁡⬚","math":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[],"side":["tanh"],"tail":[]},"node":{"Func":{"body":[],"name":"tanh"}}}}],"jc":null,"para":false}},{"id":"fn.csch","label":"Hyperbolic Cosecant Function","linear":"csch⁡⬚","math":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[],"side":["csch"],"tail":[]},"node":{"Func":{"body":[],"name":"csch"}}}}],"jc":null,"para":false}},{"id":"fn.sech","label":"Hyperbolic Secant Function","linear":"sech⁡⬚","math":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[],"side":["sech"],"tail":[]},"node":{"Func":{"body":[],"name":"sech"}}}}],"jc":null,"para":false}},{"id":"fn.coth","label":"Hyperbolic Cotangent Function","linear":"coth⁡⬚","math":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[],"side":["coth"],"tail":[]},"node":{"Func":{"body":[],"name":"coth"}}}}],"jc":null,"para":false}}]},{"name":"Inverse Hyperbolic Functions","templates":[{"id":"fn.asinh","label":"Inverse Hyperbolic Sine Function","linear":"sinh^(−1)⁡⬚","math":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[],"side":["sinh−1"],"tail":[]},"node":{"Func":{"body":[],"name":"(sinh)^−1"}}}}],"jc":null,"para":false}},{"id":"fn.acosh","label":"Inverse Hyperbolic Cosine Function","linear":"cosh^(−1)⁡⬚","math":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[],"side":["cosh−1"],"tail":[]},"node":{"Func":{"body":[],"name":"(cosh)^−1"}}}}],"jc":null,"para":false}},{"id":"fn.atanh","label":"Inverse Hyperbolic Tangent Function","linear":"tanh^(−1)⁡⬚","math":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[],"side":["tanh−1"],"tail":[]},"node":{"Func":{"body":[],"name":"(tanh)^−1"}}}}],"jc":null,"para":false}},{"id":"fn.acsch","label":"Inverse Hyperbolic Cosecant Function","linear":"csch^(−1)⁡⬚","math":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[],"side":["csch−1"],"tail":[]},"node":{"Func":{"body":[],"name":"(csch)^−1"}}}}],"jc":null,"para":false}},{"id":"fn.asech","label":"Inverse Hyperbolic Secant Function","linear":"sech^(−1)⁡⬚","math":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[],"side":["sech−1"],"tail":[]},"node":{"Func":{"body":[],"name":"(sech)^−1"}}}}],"jc":null,"para":false}},{"id":"fn.acoth","label":"Inverse Hyperbolic Cotangent Function","linear":"coth^(−1)⁡⬚","math":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[],"side":["coth−1"],"tail":[]},"node":{"Func":{"body":[],"name":"(coth)^−1"}}}}],"jc":null,"para":false}}]},{"name":"Common Functions","templates":[{"id":"fn.sintheta","label":"Sine theta","linear":"sin⁡θ","math":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[],"side":["sin"],"tail":[]},"node":{"Func":{"body":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"θ"}}}],"name":"sin"}}}}],"jc":null,"para":false}},{"id":"fn.cos2x","label":"Cosine 2x","linear":"cos⁡2x","math":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[],"side":["cos"],"tail":[]},"node":{"Func":{"body":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"2x"}}}],"name":"cos"}}}}],"jc":null,"para":false}},{"id":"fn.tanIdentity","label":"Tangent formula","linear":"tan⁡θ=sin⁡θ/cos⁡θ","math":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[],"side":["tan"],"tail":[]},"node":{"Func":{"body":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"θ"}}}],"name":"tan"}}}},{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"="}}},{"Frac":{"den":[{"Props":{"extra":{"attrs":"","pr":[],"side":["cos"],"tail":[]},"node":{"Func":{"body":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"θ"}}}],"name":"cos"}}}}],"num":[{"Props":{"extra":{"attrs":"","pr":[],"side":["sin"],"tail":[]},"node":{"Func":{"body":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"θ"}}}],"name":"sin"}}}}]}}],"jc":null,"para":false}}]}]},{"id":"accent","label":"Accent","picture":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"ä"}}}],"jc":null,"para":false},"sections":[{"name":"Accents","templates":[{"id":"acc.dot","label":"Dot","linear":"⬚̇","math":{"attrs":"","body":[{"Accent":{"body":[],"ch":"̇"}}],"jc":null,"para":false}},{"id":"acc.ddot","label":"Double Dot","linear":"⬚̈","math":{"attrs":"","body":[{"Accent":{"body":[],"ch":"̈"}}],"jc":null,"para":false}},{"id":"acc.dddot","label":"Triple Dot","linear":"⬚⃛","math":{"attrs":"","body":[{"Accent":{"body":[],"ch":"⃛"}}],"jc":null,"para":false}},{"id":"acc.hat","label":"Hat","linear":"⬚̂","math":{"attrs":"","body":[{"Accent":{"body":[],"ch":"̂"}}],"jc":null,"para":false}},{"id":"acc.check","label":"Check","linear":"⬚̌","math":{"attrs":"","body":[{"Accent":{"body":[],"ch":"̌"}}],"jc":null,"para":false}},{"id":"acc.acute","label":"Acute","linear":"⬚́","math":{"attrs":"","body":[{"Accent":{"body":[],"ch":"́"}}],"jc":null,"para":false}},{"id":"acc.grave","label":"Grave","linear":"⬚̀","math":{"attrs":"","body":[{"Accent":{"body":[],"ch":"̀"}}],"jc":null,"para":false}},{"id":"acc.breve","label":"Breve","linear":"⬚̆","math":{"attrs":"","body":[{"Accent":{"body":[],"ch":"̆"}}],"jc":null,"para":false}},{"id":"acc.tilde","label":"Tilde","linear":"⬚̃","math":{"attrs":"","body":[{"Accent":{"body":[],"ch":"̃"}}],"jc":null,"para":false}},{"id":"acc.bar","label":"Bar","linear":"⬚̅","math":{"attrs":"","body":[{"Accent":{"body":[],"ch":"̅"}}],"jc":null,"para":false}},{"id":"acc.dbar","label":"Double Bar","linear":"⬚̿","math":{"attrs":"","body":[{"Accent":{"body":[],"ch":"̿"}}],"jc":null,"para":false}},{"id":"acc.overbrace","label":"Overbrace","linear":"⏞⬚","math":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[["vertJc",""]],"side":[],"tail":[]},"node":{"GroupChr":{"body":[],"ch":"⏞","top":true}}}}],"jc":null,"para":false}},{"id":"acc.underbrace","label":"Underbrace","linear":"⏟⬚","math":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[["vertJc",""]],"side":[],"tail":[]},"node":{"GroupChr":{"body":[],"ch":"⏟","top":false}}}}],"jc":null,"para":false}},{"id":"acc.braceAbove","label":"Grouping Character Above","linear":"⏞(⬚)┴⬚","math":{"attrs":"","body":[{"Limit":{"base":[{"Props":{"extra":{"attrs":"","pr":[["vertJc",""]],"side":[],"tail":[]},"node":{"GroupChr":{"body":[],"ch":"⏞","top":true}}}}],"lim":[],"lower":false}}],"jc":null,"para":false}},{"id":"acc.braceBelow","label":"Grouping Character Below","linear":"⏟(⬚)┬⬚","math":{"attrs":"","body":[{"Limit":{"base":[{"Props":{"extra":{"attrs":"","pr":[["vertJc",""]],"side":[],"tail":[]},"node":{"GroupChr":{"body":[],"ch":"⏟","top":false}}}}],"lim":[],"lower":true}}],"jc":null,"para":false}},{"id":"acc.leftArrow","label":"Leftwards Arrow Above","linear":"⬚⃖","math":{"attrs":"","body":[{"Accent":{"body":[],"ch":"⃖"}}],"jc":null,"para":false}},{"id":"acc.rightArrow","label":"Rightwards Arrow Above","linear":"⬚⃗","math":{"attrs":"","body":[{"Accent":{"body":[],"ch":"⃗"}}],"jc":null,"para":false}},{"id":"acc.bothArrow","label":"Left Right Arrow Above","linear":"⬚⃡","math":{"attrs":"","body":[{"Accent":{"body":[],"ch":"⃡"}}],"jc":null,"para":false}},{"id":"acc.leftHarpoon","label":"Leftwards Harpoon Above","linear":"⬚⃐","math":{"attrs":"","body":[{"Accent":{"body":[],"ch":"⃐"}}],"jc":null,"para":false}},{"id":"acc.rightHarpoon","label":"Rightwards Harpoon Above","linear":"⬚⃑","math":{"attrs":"","body":[{"Accent":{"body":[],"ch":"⃑"}}],"jc":null,"para":false}}]},{"name":"Boxed Formulas","templates":[{"id":"acc.boxed","label":"Boxed Formula","linear":"▭(⬚)","math":{"attrs":"","body":[{"Boxed":{"body":[],"border":true}}],"jc":null,"para":false}},{"id":"acc.boxedPythagoras","label":"Boxed Formula (example)","linear":"▭(a^2=b^2+c^2)","math":{"attrs":"","body":[{"Boxed":{"body":[{"Script":{"base":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"a"}}}],"sub":null,"sup":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"2"}}}]}},{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"="}}},{"Script":{"base":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"b"}}}],"sub":null,"sup":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"2"}}}]}},{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"+"}}},{"Script":{"base":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"c"}}}],"sub":null,"sup":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"2"}}}]}}],"border":true}}],"jc":null,"para":false}}]},{"name":"Overbars and Underbars","templates":[{"id":"acc.overbar","label":"Overbar","linear":"¯(⬚)","math":{"attrs":"","body":[{"Bar":{"body":[],"top":true}}],"jc":null,"para":false}},{"id":"acc.underbar","label":"Underbar","linear":"▁(⬚)","math":{"attrs":"","body":[{"Bar":{"body":[],"top":false}}],"jc":null,"para":false}}]},{"name":"Common Accent Objects","templates":[{"id":"acc.vecA","label":"Vector A","linear":"A⃗","math":{"attrs":"","body":[{"Accent":{"body":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"A"}}}],"ch":"⃗"}}],"jc":null,"para":false}},{"id":"acc.vecAB","label":"Vector AB","linear":"(AB)⃡","math":{"attrs":"","body":[{"Accent":{"body":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"AB"}}}],"ch":"⃡"}}],"jc":null,"para":false}},{"id":"acc.xbar","label":"x bar","linear":"x̅","math":{"attrs":"","body":[{"Accent":{"body":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"x"}}}],"ch":"̅"}}],"jc":null,"para":false}},{"id":"acc.notAxorB","label":"A xor B","linear":"¯(A⊕B)","math":{"attrs":"","body":[{"Bar":{"body":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"A⊕B"}}}],"top":true}}],"jc":null,"para":false}}]}]},{"id":"limitLog","label":"Limit and\nLog","picture":{"attrs":"","body":[{"Limit":{"base":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Styled":{"style":"Plain","text":"lim"}}}}],"lim":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"n→∞"}}}],"lower":true}}],"jc":null,"para":false},"sections":[{"name":"Functions","templates":[{"id":"lim.logBase","label":"Logarithm with Base","linear":"log_⬚⁡⬚","math":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[],"side":["log"],"tail":[]},"node":{"Func":{"body":[],"name":"(log)_()"}}}}],"jc":null,"para":false}},{"id":"lim.log","label":"Logarithm","linear":"log⁡⬚","math":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[],"side":["log"],"tail":[]},"node":{"Func":{"body":[],"name":"log"}}}}],"jc":null,"para":false}},{"id":"lim.lim","label":"Limit","linear":"lim┬⬚⁡⬚","math":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[],"side":["lim"],"tail":[]},"node":{"Func":{"body":[],"name":"lim_()"}}}}],"jc":null,"para":false}},{"id":"lim.min","label":"Minimum","linear":"min┬⬚⁡⬚","math":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[],"side":["min"],"tail":[]},"node":{"Func":{"body":[],"name":"min_()"}}}}],"jc":null,"para":false}},{"id":"lim.max","label":"Maximum","linear":"max┬⬚⁡⬚","math":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[],"side":["max"],"tail":[]},"node":{"Func":{"body":[],"name":"max_()"}}}}],"jc":null,"para":false}},{"id":"lim.ln","label":"Natural Logarithm","linear":"ln⁡⬚","math":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[],"side":["ln"],"tail":[]},"node":{"Func":{"body":[],"name":"ln"}}}}],"jc":null,"para":false}}]},{"name":"Common Functions","templates":[{"id":"lim.e","label":"Limit Example","linear":"lim┬(n→∞)⁡〖(1+1/n)^n〗","math":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[],"side":["limn→∞"],"tail":[]},"node":{"Func":{"body":[{"Script":{"base":[{"Delim":{"close":")","items":[[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"1+"}}},{"Frac":{"den":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"n"}}}],"num":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"1"}}}]}}]],"open":"(","sep":""}}],"sub":null,"sup":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"n"}}}]}}],"name":"lim_(n→∞)"}}}}],"jc":null,"para":false}},{"id":"lim.maxExample","label":"Maximum Example","linear":"max┬(0≤x≤1)⁡〖xe^(−x^2)〗","math":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[],"side":["max0≤x≤1"],"tail":[]},"node":{"Func":{"body":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"x"}}},{"Script":{"base":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"e"}}}],"sub":null,"sup":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"−"}}},{"Script":{"base":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"x"}}}],"sub":null,"sup":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"2"}}}]}}]}}],"name":"max_(0≤x≤1)"}}}}],"jc":null,"para":false}}]}]},{"id":"operator","label":"Operator","picture":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"≜"}}}],"jc":null,"para":false},"sections":[{"name":"Common Operators","templates":[{"id":"op.colonEq","label":"Colon Equal","linear":"≔","math":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"≔"}}}],"jc":null,"para":false}},{"id":"op.eqeq","label":"Equal Equal","linear":"==","math":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"=="}}}],"jc":null,"para":false}},{"id":"op.plusEq","label":"Plus Equal","linear":"+=","math":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"+="}}}],"jc":null,"para":false}},{"id":"op.minusEq","label":"Minus Equal","linear":"−=","math":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"−="}}}],"jc":null,"para":false}},{"id":"op.defEq","label":"Equal by Definition","linear":"≝","math":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"≝"}}}],"jc":null,"para":false}},{"id":"op.measured","label":"Measured By","linear":"≞","math":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"≞"}}}],"jc":null,"para":false}},{"id":"op.delta","label":"Delta Equal To","linear":"≜","math":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"≜"}}}],"jc":null,"para":false}}]},{"name":"Operator Structures","templates":[{"id":"op.leftArrowBelow","label":"Left Arrow with Text Below","linear":"⟵┬⬚","math":{"attrs":"","body":[{"Limit":{"base":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"⟵"}}}],"lim":[],"lower":true}}],"jc":null,"para":false}},{"id":"op.rightArrowBelow","label":"Right Arrow with Text Below","linear":"⟶┬⬚","math":{"attrs":"","body":[{"Limit":{"base":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"⟶"}}}],"lim":[],"lower":true}}],"jc":null,"para":false}},{"id":"op.leftArrowAbove","label":"Left Arrow with Text Above","linear":"⟵┴⬚","math":{"attrs":"","body":[{"Limit":{"base":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"⟵"}}}],"lim":[],"lower":false}}],"jc":null,"para":false}},{"id":"op.rightArrowAbove","label":"Right Arrow with Text Above","linear":"⟶┴⬚","math":{"attrs":"","body":[{"Limit":{"base":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"⟶"}}}],"lim":[],"lower":false}}],"jc":null,"para":false}},{"id":"op.LeftArrowBelow","label":"Double Left Arrow with Text Below","linear":"⟸┬⬚","math":{"attrs":"","body":[{"Limit":{"base":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"⟸"}}}],"lim":[],"lower":true}}],"jc":null,"para":false}},{"id":"op.RightArrowBelow","label":"Double Right Arrow with Text Below","linear":"⟹┬⬚","math":{"attrs":"","body":[{"Limit":{"base":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"⟹"}}}],"lim":[],"lower":true}}],"jc":null,"para":false}},{"id":"op.LeftArrowAbove","label":"Double Left Arrow with Text Above","linear":"⟸┴⬚","math":{"attrs":"","body":[{"Limit":{"base":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"⟸"}}}],"lim":[],"lower":false}}],"jc":null,"para":false}},{"id":"op.RightArrowAbove","label":"Double Right Arrow with Text Above","linear":"⟹┴⬚","math":{"attrs":"","body":[{"Limit":{"base":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"⟹"}}}],"lim":[],"lower":false}}],"jc":null,"para":false}},{"id":"op.bothArrowBelow","label":"Left Right Arrow with Text Below","linear":"⟷┬⬚","math":{"attrs":"","body":[{"Limit":{"base":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"⟷"}}}],"lim":[],"lower":true}}],"jc":null,"para":false}},{"id":"op.bothArrowAbove","label":"Left Right Arrow with Text Above","linear":"⟷┴⬚","math":{"attrs":"","body":[{"Limit":{"base":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"⟷"}}}],"lim":[],"lower":false}}],"jc":null,"para":false}},{"id":"op.BothArrowBelow","label":"Double Left Right Arrow with Text Below","linear":"⟺┬⬚","math":{"attrs":"","body":[{"Limit":{"base":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"⟺"}}}],"lim":[],"lower":true}}],"jc":null,"para":false}},{"id":"op.BothArrowAbove","label":"Double Left Right Arrow with Text Above","linear":"⟺┴⬚","math":{"attrs":"","body":[{"Limit":{"base":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"⟺"}}}],"lim":[],"lower":false}}],"jc":null,"para":false}},{"id":"op.colonEqAbove","label":"Text over Colon Equal","linear":"≔┴⬚","math":{"attrs":"","body":[{"Limit":{"base":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"≔"}}}],"lim":[],"lower":false}}],"jc":null,"para":false}},{"id":"op.eqAbove","label":"Text over Equal","linear":"=┴⬚","math":{"attrs":"","body":[{"Limit":{"base":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"="}}}],"lim":[],"lower":false}}],"jc":null,"para":false}}]},{"name":"Common Operator Structures","templates":[{"id":"op.yields","label":"Yields","linear":"⟶┴(yields)","math":{"attrs":"","body":[{"Limit":{"base":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"⟶"}}}],"lim":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"yields"}}}],"lower":false}}],"jc":null,"para":false}},{"id":"op.deltaArrow","label":"Delta over arrow","linear":"⟶┴∆","math":{"attrs":"","body":[{"Limit":{"base":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"⟶"}}}],"lim":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"∆"}}}],"lower":false}}],"jc":null,"para":false}}]}]},{"id":"matrix","label":"Matrix","picture":{"attrs":"","body":[{"Delim":{"close":"]","items":[[{"Props":{"extra":{"attrs":"","pr":[["mcs",""]],"side":[],"tail":[]},"node":{"Matrix":{"rows":[[[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"1"}}}],[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"0"}}}]],[[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"0"}}}],[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"1"}}}]]]}}}}]],"open":"[","sep":""}}],"jc":null,"para":false},"sections":[{"name":"Empty Matrices","templates":[{"id":"mat.1x2","label":"1×2 Empty Matrix","linear":"■(⬚&⬚)","math":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[["mcs",""]],"side":[],"tail":[]},"node":{"Matrix":{"rows":[[[],[]]]}}}}],"jc":null,"para":false}},{"id":"mat.2x1","label":"2×1 Empty Matrix","linear":"■(⬚@⬚)","math":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[["mcs",""]],"side":[],"tail":[]},"node":{"Matrix":{"rows":[[[]],[[]]]}}}}],"jc":null,"para":false}},{"id":"mat.1x3","label":"1×3 Empty Matrix","linear":"■(⬚&⬚&⬚)","math":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[["mcs",""]],"side":[],"tail":[]},"node":{"Matrix":{"rows":[[[],[],[]]]}}}}],"jc":null,"para":false}},{"id":"mat.3x1","label":"3×1 Empty Matrix","linear":"■(⬚@⬚@⬚)","math":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[["mcs",""]],"side":[],"tail":[]},"node":{"Matrix":{"rows":[[[]],[[]],[[]]]}}}}],"jc":null,"para":false}},{"id":"mat.2x2","label":"2×2 Empty Matrix","linear":"■(⬚&⬚@⬚&⬚)","math":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[["mcs",""]],"side":[],"tail":[]},"node":{"Matrix":{"rows":[[[],[]],[[],[]]]}}}}],"jc":null,"para":false}},{"id":"mat.2x3","label":"2×3 Empty Matrix","linear":"■(⬚&⬚&⬚@⬚&⬚&⬚)","math":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[["mcs",""]],"side":[],"tail":[]},"node":{"Matrix":{"rows":[[[],[],[]],[[],[],[]]]}}}}],"jc":null,"para":false}},{"id":"mat.3x2","label":"3×2 Empty Matrix","linear":"■(⬚&⬚@⬚&⬚@⬚&⬚)","math":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[["mcs",""]],"side":[],"tail":[]},"node":{"Matrix":{"rows":[[[],[]],[[],[]],[[],[]]]}}}}],"jc":null,"para":false}},{"id":"mat.3x3","label":"3×3 Empty Matrix","linear":"■(⬚&⬚&⬚@⬚&⬚&⬚@⬚&⬚&⬚)","math":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[["mcs",""]],"side":[],"tail":[]},"node":{"Matrix":{"rows":[[[],[],[]],[[],[],[]],[[],[],[]]]}}}}],"jc":null,"para":false}}]},{"name":"Dots","templates":[{"id":"mat.cdots","label":"Midline Dots","linear":"⋯","math":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"⋯"}}}],"jc":null,"para":false}},{"id":"mat.ldots","label":"Baseline Dots","linear":"…","math":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"…"}}}],"jc":null,"para":false}},{"id":"mat.vdots","label":"Vertical Dots","linear":"⋮","math":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"⋮"}}}],"jc":null,"para":false}},{"id":"mat.ddots","label":"Diagonal Dots","linear":"⋱","math":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"⋱"}}}],"jc":null,"para":false}},{"id":"mat.adots","label":"Up Diagonal Dots","linear":"⋰","math":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"⋰"}}}],"jc":null,"para":false}}]},{"name":"Identity Matrices","templates":[{"id":"mat.id2","label":"2×2 Identity Matrix","linear":"■(1&0@0&1)","math":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[["mcs",""]],"side":[],"tail":[]},"node":{"Matrix":{"rows":[[[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"1"}}}],[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"0"}}}]],[[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"0"}}}],[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"1"}}}]]]}}}}],"jc":null,"para":false}},{"id":"mat.id2zeros","label":"2×2 Identity Matrix (sparse)","linear":"■(1&⬚@⬚&1)","math":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[["mcs",""]],"side":[],"tail":[]},"node":{"Matrix":{"rows":[[[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"1"}}}],[]],[[],[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"1"}}}]]]}}}}],"jc":null,"para":false}},{"id":"mat.id3","label":"3×3 Identity Matrix","linear":"■(1&0&0@0&1&0@0&0&1)","math":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[["mcs",""]],"side":[],"tail":[]},"node":{"Matrix":{"rows":[[[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"1"}}}],[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"0"}}}],[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"0"}}}]],[[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"0"}}}],[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"1"}}}],[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"0"}}}]],[[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"0"}}}],[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"0"}}}],[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"1"}}}]]]}}}}],"jc":null,"para":false}},{"id":"mat.id3zeros","label":"3×3 Identity Matrix (sparse)","linear":"■(1&⬚&⬚@⬚&1&⬚@⬚&⬚&1)","math":{"attrs":"","body":[{"Props":{"extra":{"attrs":"","pr":[["mcs",""]],"side":[],"tail":[]},"node":{"Matrix":{"rows":[[[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"1"}}}],[],[]],[[],[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"1"}}}],[]],[[],[],[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"1"}}}]]]}}}}],"jc":null,"para":false}}]},{"name":"Matrices with Brackets","templates":[{"id":"mat.paren","label":"2×2 Matrix in Parentheses","linear":"(■(⬚&⬚@⬚&⬚))","math":{"attrs":"","body":[{"Delim":{"close":")","items":[[{"Props":{"extra":{"attrs":"","pr":[["mcs",""]],"side":[],"tail":[]},"node":{"Matrix":{"rows":[[[],[]],[[],[]]]}}}}]],"open":"(","sep":""}}],"jc":null,"para":false}},{"id":"mat.square","label":"2×2 Matrix in Brackets","linear":"[■(⬚&⬚@⬚&⬚)]","math":{"attrs":"","body":[{"Delim":{"close":"]","items":[[{"Props":{"extra":{"attrs":"","pr":[["mcs",""]],"side":[],"tail":[]},"node":{"Matrix":{"rows":[[[],[]],[[],[]]]}}}}]],"open":"[","sep":""}}],"jc":null,"para":false}},{"id":"mat.det","label":"2×2 Determinant","linear":"|■(⬚&⬚@⬚&⬚)|","math":{"attrs":"","body":[{"Delim":{"close":"|","items":[[{"Props":{"extra":{"attrs":"","pr":[["mcs",""]],"side":[],"tail":[]},"node":{"Matrix":{"rows":[[[],[]],[[],[]]]}}}}]],"open":"|","sep":""}}],"jc":null,"para":false}},{"id":"mat.norm","label":"2×2 Matrix in Double Bars","linear":"‖■(⬚&⬚@⬚&⬚)‖","math":{"attrs":"","body":[{"Delim":{"close":"‖","items":[[{"Props":{"extra":{"attrs":"","pr":[["mcs",""]],"side":[],"tail":[]},"node":{"Matrix":{"rows":[[[],[]],[[],[]]]}}}}]],"open":"‖","sep":""}}],"jc":null,"para":false}}]},{"name":"Sparse Matrices","templates":[{"id":"mat.sparse","label":"n×n Matrix","linear":"(■(a_11&⋯&a_(1n)@⋮&⋱&⋮@a_(m1)&⋯&a_(mn)))","math":{"attrs":"","body":[{"Delim":{"close":")","items":[[{"Props":{"extra":{"attrs":"","pr":[["mcs",""]],"side":[],"tail":[]},"node":{"Matrix":{"rows":[[[{"Script":{"base":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"a"}}}],"sub":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"11"}}}],"sup":null}}],[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"⋯"}}}],[{"Script":{"base":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"a"}}}],"sub":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"1n"}}}],"sup":null}}]],[[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"⋮"}}}],[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"⋱"}}}],[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"⋮"}}}]],[[{"Script":{"base":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"a"}}}],"sub":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"m1"}}}],"sup":null}}],[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"⋯"}}}],[{"Script":{"base":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"a"}}}],"sub":[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"mn"}}}],"sup":null}}]]]}}}}]],"open":"(","sep":""}}],"jc":null,"para":false}},{"id":"mat.identityN","label":"n×n Identity Matrix","linear":"[■(1&0&⋯&0@0&1&⋯&0@⋮&⋮&⋱&⋮@0&0&⋯&1)]","math":{"attrs":"","body":[{"Delim":{"close":"]","items":[[{"Props":{"extra":{"attrs":"","pr":[["mcs",""]],"side":[],"tail":[]},"node":{"Matrix":{"rows":[[[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"1"}}}],[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"0"}}}],[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"⋯"}}}],[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"0"}}}]],[[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"0"}}}],[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"1"}}}],[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"⋯"}}}],[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"0"}}}]],[[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"⋮"}}}],[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"⋮"}}}],[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"⋱"}}}],[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"⋮"}}}]],[[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"0"}}}],[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"0"}}}],[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"⋯"}}}],[{"Props":{"extra":{"attrs":"","pr":[],"side":[""],"tail":[]},"node":{"Text":"1"}}}]]]}}}}]],"open":"[","sep":""}}],"jc":null,"para":false}}]}]}],"symbolNames":{"¬":"neg","°":"degree","±":"pm","Å":"Angstrom","×":"times","ð":"eth","÷":"div","ı":"imath","ȷ":"jmath","Α":"Alpha","Β":"Beta","Γ":"Gamma","Δ":"Delta","Ε":"Epsilon","Ζ":"Zeta","Η":"Eta","Θ":"Theta","Ι":"Iota","Κ":"Kappa","Λ":"Lambda","Μ":"Mu","Ν":"Nu","Ξ":"Xi","Ο":"Omicron","Π":"Pi","Ρ":"Rho","Σ":"Sigma","Τ":"Tau","Υ":"Upsilon","Φ":"Phi","Χ":"Chi","Ψ":"Psi","Ω":"Omega","α":"alpha","β":"beta","γ":"gamma","δ":"delta","ε":"varepsilon","ζ":"zeta","η":"eta","θ":"theta","ι":"iota","κ":"kappa","λ":"lambda","μ":"mu","ν":"nu","ξ":"xi","ο":"omicron","π":"pi","ρ":"rho","ς":"varsigma","σ":"sigma","τ":"tau","υ":"upsilon","φ":"varphi","χ":"chi","ψ":"psi","ω":"omega","ϑ":"vartheta","ϕ":"phi","ϖ":"varpi","ϝ":"digamma","ϱ":"varrho","ϵ":"epsilon","ℏ":"hbar","ℑ":"Im","ℓ":"ell","℘":"wp","ℜ":"Re","℧":"mho","ℵ":"aleph","ℶ":"beth","ℷ":"gimel","ℸ":"daleth","ⅅ":"Dd","ⅆ":"dd","ⅇ":"ee","ⅈ":"ii","ⅉ":"jj","←":"leftarrow","↑":"uparrow","→":"rightarrow","↓":"downarrow","↔":"leftrightarrow","↕":"updownarrow","↖":"nwarrow","↗":"nearrow","↘":"searrow","↙":"swarrow","↦":"mapsto","↩":"hookleftarrow","↪":"hookrightarrow","↺":"circlearrowleft","↻":"circlearrowright","↼":"leftharpoonup","↽":"leftharpoondown","⇀":"rightharpoonup","⇁":"rightharpoondown","⇋":"leftrightharpoons","⇌":"rightleftharpoons","⇐":"Leftarrow","⇑":"Uparrow","⇒":"Rightarrow","⇓":"Downarrow","⇔":"Leftrightarrow","⇕":"Updownarrow","∀":"forall","∁":"complement","∂":"partial","∃":"exists","∄":"nexists","∅":"emptyset","∇":"nabla","∈":"in","∉":"notin","∋":"ni","∎":"qed","∏":"prod","∐":"coprod","∑":"sum","∓":"mp","∔":"dotplus","∕":"ldiv","∖":"setminus","∗":"ast","∘":"circ","∙":"bullet","√":"sqrt","∛":"cbrt","∜":"qdrt","∝":"propto","∞":"infty","∟":"rightangle","∠":"angle","∡":"measuredangle","∢":"sphericalangle","∤":"nmid","∥":"parallel","∧":"wedge","∨":"vee","∩":"cap","∪":"cup","∫":"int","∬":"iint","∭":"iiint","∮":"oint","∯":"oiint","∰":"oiiint","∴":"therefore","∵":"because","∼":"sim","≀":"wr","≁":"nsim","≃":"simeq","≅":"cong","≇":"ncong","≈":"approx","≍":"asymp","≐":"doteq","≔":"coloneq","≜":"triangleq","≝":"defeq","≠":"ne","≡":"equiv","≢":"nequiv","≤":"le","≥":"ge","≪":"ll","≫":"gg","≮":"nless","≯":"ngtr","≰":"nleq","≱":"ngeq","≺":"prec","≻":"succ","≼":"preceq","≽":"succeq","⊂":"subset","⊃":"supset","⊄":"nsubset","⊅":"nsupset","⊆":"subseteq","⊇":"supseteq","⊈":"nsubseteq","⊉":"nsupseteq","⊊":"subsetneq","⊋":"supsetneq","⊎":"uplus","⊑":"sqsubseteq","⊒":"sqsupseteq","⊓":"sqcap","⊔":"sqcup","⊕":"oplus","⊖":"ominus","⊗":"otimes","⊘":"oslash","⊙":"odot","⊢":"vdash","⊣":"dashv","⊥":"perp","⋀":"bigwedge","⋁":"bigvee","⋂":"bigcap","⋃":"bigcup","⋄":"diamond","⋅":"cdot","⋆":"star","⋈":"bowtie","⋉":"ltimes","⋊":"rtimes","⋮":"vdots","⋯":"cdots","⋰":"adots","⋱":"ddots","□":"square","▭":"rect","△":"triangle","⟵":"longleftarrow","⟶":"longrightarrow","⟷":"longleftrightarrow","⟸":"Longleftarrow","⟹":"Longrightarrow","⟺":"Longleftrightarrow","⟼":"longmapsto","⨀":"bigodot","⨁":"bigoplus","⨂":"bigotimes","⨄":"biguplus","⨆":"bigsqcup"},"symbols":[{"chars":"±∞=≠~×÷!∝<≪>≫≤≥∓≅≈≡∀∁∂√∛∜∪∩∅%°℉℃∆∇∃∄∈∋←↑→↓↔∴+−¬αβγδεϵθϑμπρστφωω∗∙⋮⋯⋰⋱ℵℶ∎","name":"Basic Math"},{"chars":"αβγδεϵζηθϑικλμνξοπϖρϱσςτυφϕχψωΑΒΓΔΕΖΗΘΙΚΛΜΝΞΟΠΡΣΤΥΦΧΨΩ","name":"Greek Letters"},{"chars":"∀∁ℂ∂ðℇϝℱℊℋℌℍℎℏıℑȷℒℓℕ℘ℙℚℛℜℝℤ℧Åℬℭℰℯℳℴℵℶℷℸⅅⅆⅇⅈⅉ","name":"Letter-Like Symbols"},{"chars":"+−÷×±∓∝∕∗∘∙√∛∜∩∪⊎⊓⊔∧∨⊕⊖⊗⊘⊙⊚⊛⊞⊟⊠⊡⋄⋅⋆⋉⋊∔∸∖≀=≠<>≤≥≮≰≯≱≡∼≃≈≅≢≄≉≇∝≪≫∈∋∉⊂⊃⊆⊇≺≻≼≽⊏⊐⊑⊒∥⊥⊢⊣⋈≍∴∵∷≐≑≒≓≔≕≖≗≜≝≞≟∑∫∬∭∮∯∰∏∐⋂⋃⋀⋁⨀⨁⨂⨄⨆","name":"Operators"},{"chars":"←→↑↓↔↕⇐⇒⇑⇓⇔⇕⟵⟶⟷⟸⟹⟺↦⟼↩↪↼↽⇀⇁↿↾⇃⇂⇋⇌⇄⇆⇇⇉⇈⇊↖↗↘↙↶↷↺↻⊸↜↝↞↠↢↣↫↬↭⇚⇛↰↱⇝","name":"Arrows"},{"chars":"≠≮≯≰≱≢≁≄≉≇≭≨≩⊀⊁⋠⋡∉∌⊄⊅⊈⊉⊊⊋⋢⋣⋦⋧⋨⋩⋪⋫⋬⋭∤∦⊬⊭⊮⊯∄","name":"Negated Relations"},{"chars":"𝒜ℬ𝒞𝒟ℰℱ𝒢ℋℐ𝒥𝒦ℒℳ𝒩𝒪𝒫𝒬ℛ𝒮𝒯𝒰𝒱𝒲𝒳𝒴𝒵𝒶𝒷𝒸𝒹ℯ𝒻ℊ𝒽𝒾𝒿𝓀𝓁𝓂𝓃ℴ𝓅𝓆𝓇𝓈𝓉𝓊𝓋𝓌𝓍𝓎𝓏𝔄𝔅ℭ𝔇𝔈𝔉𝔊ℌℑ𝔍𝔎𝔏𝔐𝔑𝔒𝔓𝔔ℜ𝔖𝔗𝔘𝔙𝔚𝔛𝔜ℨ𝔸𝔹ℂ𝔻𝔼𝔽𝔾ℍ𝕀𝕁𝕂𝕃𝕄ℕ𝕆ℙℚℝ𝕊𝕋𝕌𝕍𝕎𝕏𝕐ℤ","name":"Scripts"},{"chars":"∟∠∡∢⊾⊿⋕⊥∤∥∦∶∷∴∵∎△▭▱○◊□◇⊡⊙","name":"Geometry"}]} \ No newline at end of file diff --git a/crates/equashion-math/src/gallery.rs b/crates/equashion-math/src/gallery.rs new file mode 100644 index 00000000..32efc3c1 --- /dev/null +++ b/crates/equashion-math/src/gallery.rs @@ -0,0 +1,140 @@ +//! The Equation tab's galleries: structure templates (fractions, scripts, radicals, integrals, +//! large operators, brackets, functions, accents, limits and logs, operators, matrices), symbol +//! sets and ready-made equations. +//! +//! The data is WordCraft's (`math_gallery.rs`, v0.5.0), exported once as equaSHion trees so a host +//! needs neither WordCraft's linear-format parser nor its model: each template is a [`Math`] +//! whose empty places are empty sequences, the form [`crate::Editor::insert_template`] takes. +//! `gallery.json` is that export (compact JSON of [`Galleries`]). + +use std::sync::OnceLock; + +use serde::Deserialize; + +use crate::Math; + +/// One template or ready-made equation. +#[derive(Debug, Clone, Deserialize)] +pub struct GalleryTemplate { + pub id: String, + pub label: String, + /// The equation in Word's linear format (`⬚` marks an empty place), for tooltips. + pub linear: String, + pub math: Math, +} + +/// A titled run of templates inside a gallery. +#[derive(Debug, Clone, Deserialize)] +pub struct Section { + pub name: String, + pub templates: Vec, +} + +/// One structure gallery (a button of the Equation tab) with its sections. +#[derive(Debug, Clone, Deserialize)] +pub struct Gallery { + pub id: String, + pub label: String, + /// The picture on the gallery's ribbon button. + pub picture: Math, + pub sections: Vec
, +} + +/// A named set of symbols (the Symbols gallery's categories). +#[derive(Debug, Clone, Deserialize)] +pub struct SymbolSet { + pub name: String, + pub chars: String, +} + +/// Everything the Equation tab offers. +#[derive(Debug, Clone, Deserialize)] +pub struct Galleries { + pub structures: Vec, + pub symbols: Vec, + /// Ready-made equations (Insert › Equation), grouped by subject. + pub builtins: Vec, + /// The picture on the ready-made equations button. + #[serde(rename = "equationPicture")] + pub equation_picture: Math, + /// TeX-style names of the symbols that have one (`α` is `alpha`), for tooltips. + #[serde(rename = "symbolNames")] + pub symbol_names: std::collections::HashMap, +} + +/// The galleries, parsed once. +#[allow(clippy::expect_used)] +pub fn galleries() -> &'static Galleries { + static G: OnceLock = OnceLock::new(); + G.get_or_init(|| serde_json::from_str(include_str!("gallery.json")).expect("gallery.json is valid")) +} + +impl Galleries { + /// A structure gallery by id. + pub fn structure(&self, id: &str) -> Option<&Gallery> { + self.structures.iter().find(|g| g.id == id) + } + + /// A symbol's name (`alpha` for `α`), when it has one. + pub fn symbol_name(&self, c: char) -> Option<&str> { + self.symbol_names.get(c.to_string().as_str()).map(String::as_str) + } + + /// Every structure template. + pub fn templates(&self) -> impl Iterator { + self.structures.iter().flat_map(|g| g.sections.iter().flat_map(|s| s.templates.iter())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Editor, run_text, to_linear}; + + #[test] + fn the_galleries_load_with_every_group() { + let g = galleries(); + let ids: Vec<&str> = g.structures.iter().map(|g| g.id.as_str()).collect(); + assert_eq!( + ids, + ["fraction", "script", "radical", "integral", "largeOperator", "bracket", "function", "accent", "limitLog", "operator", "matrix"] + ); + assert_eq!(g.templates().count(), 222); + assert_eq!(g.symbols.len(), 8); + assert_eq!(g.builtins.len(), 14); + } + + #[test] + fn template_ids_are_unique_and_none_is_empty() { + let g = galleries(); + let mut seen = std::collections::HashSet::new(); + for t in g.templates().chain(g.builtins.iter()) { + assert!(seen.insert(t.id.as_str()), "duplicate id {}", t.id); + assert!(!t.math.body.is_empty(), "{} has no body", t.id); + assert!(!to_linear(&t.math).is_empty(), "{} has no text", t.id); + } + } + + #[test] + fn every_template_inserts_into_an_empty_equation() { + for t in galleries().templates() { + let mut e = Editor::new(Math::new(vec![])); + e.insert_template(&t.math); + assert!(!e.math().body.is_empty(), "{} inserted nothing", t.id); + } + } + + #[test] + fn buttons_have_pictures_and_symbols_have_names() { + let g = galleries(); + assert!(g.structures.iter().all(|s| !s.picture.body.is_empty()), "every gallery has a picture"); + assert!(!g.equation_picture.body.is_empty()); + assert_eq!(g.symbol_name('α'), Some("alpha")); + } + + #[test] + fn a_ready_made_equation_reads_back() { + let pythagoras = galleries().builtins.iter().find(|t| t.id == "pythagoras").expect("pythagoras"); + assert_eq!(run_text(&pythagoras.math), "a^2+b^2=c^2"); + } +} diff --git a/crates/equashion-math/src/latex.rs b/crates/equashion-math/src/latex.rs new file mode 100644 index 00000000..82a56cec --- /dev/null +++ b/crates/equashion-math/src/latex.rs @@ -0,0 +1,620 @@ +//! LaTeX output: print an equation as the LaTeX a MathLive-style editor would produce. +//! +//! [`to_linear`](crate::to_linear) prints the typed linear form (`√(x)`, `·`); this prints LaTeX +//! (`\sqrt{x}`, `\cdot`) for hosts that store and exchange it. The parser side is +//! [`from_linear`](crate::from_linear), which reads the same LaTeX subset, so +//! `from_linear(&to_latex(m))` gives back an equal tree for everything this printer emits. +//! +//! Choices follow MathLive's output where they are visible to a consumer: one-character scripts +//! are written without braces (`x^2`), delimiters are `\left..\right`, matrices use the +//! `pmatrix`-style environments, upright words are `\operatorname{..}`. + +use crate::funcs::LATEX; +use crate::linear::{ACCENTS, GREEK, NARY, SYMS, raw_text}; +use crate::{MAX_DEPTH, Math, Node, Seq, Style}; + +/// Print an equation as LaTeX. +pub fn to_latex(m: &Math) -> String { + let mut t = Tex::default(); + t.seq(&m.body, 0); + t.out.trim().to_string() +} + +/// Output buffer that knows when a command name was just written, so a following letter gets a +/// separating space (`\pi x`, never `\pix`). +#[derive(Default)] +struct Tex { + out: String, + after_command: bool, +} + +impl Tex { + fn raw(&mut self, s: &str) { + if let Some(c) = s.chars().next() { + self.space_for(c); + } + self.out.push_str(s); + self.after_command = false; + } + + fn space_for(&mut self, c: char) { + if self.after_command && c.is_ascii_alphabetic() { + self.out.push(' '); + } + } + + /// A command such as `\pi`; the next letter will be separated from it. + fn cmd(&mut self, name: &str) { + self.raw("\\"); + self.out.push_str(name); + self.after_command = name.chars().last().is_some_and(|c| c.is_ascii_alphabetic()); + } + + fn ch(&mut self, c: char) { + self.space_for(c); + self.out.push(c); + self.after_command = false; + } + + fn text(&mut self, s: &str) { + for c in s.chars() { + self.text_char(c); + } + } + + fn text_char(&mut self, c: char) { + if let Some((name, _)) = GREEK.iter().find(|(_, g)| *g == c) { + return self.cmd(name); + } + if let Some((name, _)) = SYMS.iter().find(|(_, v)| v.chars().count() == 1 && v.starts_with(c)) { + return match *name { + // MathLive prints these three as bare characters / scripts. + "prime" => self.raw("'"), + "leq" => self.cmd("le"), + "geq" => self.cmd("ge"), + "neq" => self.cmd("ne"), + "dprime" => self.raw("''"), + n => self.cmd(n), + }; + } + if let Some(name) = extra_command(c) { + return self.cmd(name); + } + if let Some(l) = double_struck_letter(c) { + self.cmd("mathbb"); + self.raw("{"); + self.ch(l); + self.raw("}"); + return; + } + match c { + '\u{2212}' => self.ch('-'), + '\u{2009}' => self.raw("\\,"), + '\u{2005}' => self.raw("\\:"), + '\u{2004}' => self.raw("\\;"), + '\u{2001}' => self.cmd("quad"), + '\u{00A0}' => self.raw("~"), + '{' | '}' | '#' | '%' | '&' | '$' | '_' => { + self.raw("\\"); + self.out.push(c); + } + '\\' => self.cmd("backslash"), + '^' => self.raw("\\^{}"), + '~' => self.raw("\\~{}"), + c => self.ch(c), + } + } + + /// `{...}` around a printed argument; an empty argument is an empty group. + fn group(&mut self, s: &Seq, d: usize) { + self.raw("{"); + self.seq(s, d); + self.raw("}"); + } + + /// A script argument: bare for one ASCII letter or digit (`x^2`), braced otherwise. + fn script(&mut self, mark: char, s: &Seq, d: usize) { + self.raw(&mark.to_string()); + match s.as_slice() { + [Node::Text(t)] if t.chars().count() == 1 && t.chars().all(|c| c.is_ascii_alphanumeric()) => self.text(t), + _ => self.group(s, d), + } + } + + /// The base of a script: bare when it is one character, a delimiter pair, a word or a + /// function name, grouped otherwise so the script attaches to all of it. + fn script_base(&mut self, base: &Seq, d: usize) { + let bare = match base.as_slice() { + [] => false, + [n] => match n.bare() { + Node::Text(t) => t.chars().count() == 1, + Node::Delim { .. } | Node::Styled { .. } => true, + Node::Func { body, .. } => body.is_empty(), + _ => false, + }, + _ => false, + }; + if bare { + self.seq(base, d); + } else { + self.group(base, d); + } + } + + fn delimiter(&mut self, s: &str, left: bool) { + let name = match s { + "" => ".", + "(" | ")" | "[" | "]" | "/" => s, + "{" => "\\{", + "}" => "\\}", + "|" => "|", + "\u{2016}" => "\\Vert", + "\u{27E8}" => "\\langle", + "\u{27E9}" => "\\rangle", + "\u{2329}" => "\\langle", + "\u{232A}" => "\\rangle", + "\u{230A}" => "\\lfloor", + "\u{230B}" => "\\rfloor", + "\u{2308}" => "\\lceil", + "\u{2309}" => "\\rceil", + other => other, + }; + self.cmd(if left { "left" } else { "right" }); + self.after_command = false; + self.out.push_str(name); + self.after_command = name.starts_with('\\') && name.chars().last().is_some_and(|c| c.is_ascii_alphabetic()); + } + + fn seq(&mut self, s: &Seq, depth: usize) { + if depth > MAX_DEPTH { + return; + } + let d = depth + 1; + for n in s { + self.node(n.bare(), d); + } + } + + fn node(&mut self, n: &Node, d: usize) { + match n { + Node::Props { .. } => {} + Node::Text(t) => self.text(t), + Node::Styled { text, style } => self.styled(text, *style), + Node::Frac { num, den } => { + self.cmd("frac"); + self.group(num, d); + self.group(den, d); + } + Node::Rad { deg, body } => { + self.cmd("sqrt"); + if let Some(dg) = deg { + self.raw("["); + self.seq(dg, d); + self.raw("]"); + } + self.group(body, d); + } + Node::Script { base, sub, sup } => { + self.script_base(base, d); + if let Some(s) = sub { + self.script('_', s, d); + } + if let Some(s) = sup { + self.script('^', s, d); + } + } + Node::Nary { op, sub, sup, body } => { + if let Some((name, _)) = NARY.iter().find(|(_, c)| c == op) { + self.cmd(name); + } else if *op != '\0' { + self.ch(*op); + } + if let Some(s) = sub { + self.script('_', s, d); + } + if let Some(s) = sup { + self.script('^', s, d); + } + // One node (a letter, `i^2`, a parenthesised sum) follows bare; a longer run is + // braced so the operator still covers all of it when the LaTeX is read back. + let one = match body.as_slice() { + [] => true, + [Node::Text(t)] => t.chars().count() == 1, + [_] => true, + _ => false, + }; + if one { + self.seq(body, d); + } else { + self.group(body, d); + } + } + Node::Delim { open, close, sep, items } => self.delim(open, close, sep, items, d), + Node::Matrix { rows } => self.matrix("matrix", rows, d), + Node::EqArr { rows } => { + self.raw("\\begin{aligned}"); + self.rows(rows, d); + self.raw("\\end{aligned}"); + } + Node::Func { name, body } => { + self.function(name); + if let [only @ Node::Delim { open, close, .. }] = body.as_slice() + && open == "(" + && close == ")" + { + // The argument list already has its own parentheses. + self.node(only, d); + } else if !body.is_empty() { + self.delimiter("(", true); + self.seq(body, d); + self.delimiter(")", false); + } + } + Node::Accent { ch, body } => match ACCENTS.iter().find(|(_, c)| c == ch) { + Some((name, _)) => { + self.cmd(name); + self.group(body, d); + } + None => { + self.seq(body, d); + self.ch(*ch); + } + }, + Node::Bar { top, body } => { + self.cmd(if *top { "overline" } else { "underline" }); + self.group(body, d); + } + Node::GroupChr { ch, top, body } => { + let side = if *top { "over" } else { "under" }; + let name = match *ch { + '\u{2192}' => Some(format!("{side}rightarrow")), + '\u{2190}' => Some(format!("{side}leftarrow")), + '\u{2194}' => Some(format!("{side}leftrightarrow")), + '\u{23DE}' if *top => Some("overbrace".to_string()), + '\u{23DF}' if !*top => Some("underbrace".to_string()), + _ => None, + }; + match name { + Some(n) => { + self.cmd(&n); + self.group(body, d); + } + None => self.seq(body, d), + } + } + Node::Boxed { border, body } => { + if *border { + self.cmd("boxed"); + self.group(body, d); + } else { + self.seq(body, d); + } + } + Node::Limit { lower, base, lim } => { + let word = match base.as_slice() { + [Node::Styled { text, .. }] if *lower && matches!(text.as_str(), "lim" | "max" | "min") => Some(text.as_str()), + _ => None, + }; + match word { + Some(w) => { + self.cmd(w); + self.script('_', lim, d); + } + None => { + self.cmd(if *lower { "underset" } else { "overset" }); + self.group(lim, d); + self.group(base, d); + } + } + } + Node::PreScript { sub, sup, base } => { + self.raw("{}"); + self.script('_', sub, d); + self.script('^', sup, d); + self.seq(base, d); + } + Node::Raw(src) if src == crate::OMATH_BREAK => self.raw(" "), + Node::Raw(src) if src.starts_with(" {} + Node::Raw(src) => { + let t = raw_text(src); + if t.is_empty() { + self.raw("{}"); + } else { + self.text(&t); + } + } + } + } + + /// A function name: `\sin` for the standard commands, `\operatorname{..}` otherwise. + fn function(&mut self, name: &str) { + if LATEX.contains(&name) { + self.cmd(name); + } else { + self.cmd("operatorname"); + self.raw("{"); + self.text(name); + self.raw("}"); + } + } + + fn styled(&mut self, text: &str, style: Style) { + match style { + Style::Normal => { + self.cmd("text"); + self.raw("{"); + self.text(text); + self.raw("}"); + } + Style::Bold => { + self.cmd("mathbf"); + self.raw("{"); + self.text(text); + self.raw("}"); + } + Style::BoldItalic => { + self.cmd("boldsymbol"); + self.raw("{"); + self.text(text); + self.raw("}"); + } + Style::Plain => { + let word = text.chars().count() > 1 && text.chars().all(|c| c.is_ascii_alphabetic()); + if word { + self.function(text); + } else { + self.cmd("mathrm"); + self.raw("{"); + self.text(text); + self.raw("}"); + } + } + } + } + + fn rows(&mut self, rows: &[Seq], d: usize) { + for (i, r) in rows.iter().enumerate() { + if i > 0 { + self.raw(" \\\\ "); + } + self.seq(r, d); + } + } + + fn matrix(&mut self, env: &str, rows: &[Vec], d: usize) { + self.raw(&format!("\\begin{{{env}}}")); + for (i, row) in rows.iter().enumerate() { + if i > 0 { + self.raw(" \\\\ "); + } + for (k, cell) in row.iter().enumerate() { + if k > 0 { + self.raw(" & "); + } + self.seq(cell, d); + } + } + self.raw(&format!("\\end{{{env}}}")); + } + + fn delim(&mut self, open: &str, close: &str, sep: &str, items: &[Seq], d: usize) { + // A lone matrix or `cases` array is an environment, not a delimited group. + if let [only] = items { + match only.as_slice() { + [Node::Matrix { rows }] => { + if open == "(" && close == ")" && rows.len() == 2 && rows.iter().all(|r| r.len() == 1) { + self.cmd("binom"); + for r in rows { + if let Some(c) = r.first() { + self.group(c, d); + } + } + return; + } + let env = match (open, close) { + ("(", ")") => Some("pmatrix"), + ("[", "]") => Some("bmatrix"), + ("{", "}") => Some("Bmatrix"), + ("|", "|") => Some("vmatrix"), + ("\u{2016}", "\u{2016}") => Some("Vmatrix"), + _ => None, + }; + if let Some(env) = env { + return self.matrix(env, rows, d); + } + } + [Node::EqArr { rows }] if open == "{" && close.is_empty() => { + self.raw("\\begin{cases}"); + for (i, r) in rows.iter().enumerate() { + if i > 0 { + self.raw(" \\\\ "); + } + let mut sub = Tex::default(); + sub.seq(r, d); + self.raw(&sub.out.replace('\u{2003}', " & ")); + } + self.raw("\\end{cases}"); + return; + } + _ => {} + } + } + self.delimiter(open, true); + for (i, it) in items.iter().enumerate() { + if i > 0 { + // `|` stretches with the brackets; any other separator (a comma) is a plain character. + if sep == "|" || sep.is_empty() { + self.cmd("middle"); + self.raw("|"); + } else { + self.raw(sep); + } + } + self.seq(it, d); + } + self.delimiter(close, false); + } +} + +/// Symbols that have a LaTeX command but are not in the typed-command table. +fn extra_command(c: char) -> Option<&'static str> { + Some(match c { + '\u{00D7}' => "times", + '\u{221E}' => "infty", + '\u{2218}' => "circ", + '\u{2216}' => "setminus", + '\u{00AC}' => "neg", + '\u{2227}' => "wedge", + '\u{2228}' => "vee", + '\u{2295}' => "oplus", + '\u{2297}' => "otimes", + '\u{21A6}' => "mapsto", + '\u{2194}' => "leftrightarrow", + '\u{21D0}' => "Leftarrow", + '\u{2234}' => "therefore", + '\u{2235}' => "because", + '\u{210F}' => "hbar", + '\u{2113}' => "ell", + '\u{2135}' => "aleph", + '\u{221A}' => "surd", + '\u{2260}' => "neq", + '\u{22C5}' => "cdot", + '\u{2217}' => "ast", + '\u{2286}' => "subseteq", + '\u{2287}' => "supseteq", + '\u{2283}' => "supset", + '\u{2192}' => "to", + _ => return None, + }) +} + +fn double_struck_letter(c: char) -> Option { + Some(match c { + '\u{2102}' => 'C', + '\u{210D}' => 'H', + '\u{2115}' => 'N', + '\u{2119}' => 'P', + '\u{211A}' => 'Q', + '\u{211D}' => 'R', + '\u{2124}' => 'Z', + _ => return None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::from_linear; + + fn t(s: &str) -> String { + to_latex(&from_linear(s)) + } + + #[test] + fn plain_text_and_scripts() { + assert_eq!(t("x^2+3x-2"), "x^2+3x-2"); + assert_eq!(t("x^{2}"), "x^2"); + assert_eq!(t("x^{n+1}"), "x^{n+1}"); + assert_eq!(t("x_{12}"), "x_{12}"); + assert_eq!(t("e^{-x^2}"), "e^{-x^2}"); + } + + #[test] + fn fractions_and_roots() { + assert_eq!(t(r"\frac{1}{x}"), r"\frac{1}{x}"); + assert_eq!(t(r"\frac{\frac{1}{2}}{3}"), r"\frac{\frac{1}{2}}{3}"); + assert_eq!(t(r"\sqrt{x+1}"), r"\sqrt{x+1}"); + assert_eq!(t(r"\sqrt[3]{x}"), r"\sqrt[3]{x}"); + } + + #[test] + fn functions_keep_their_parentheses() { + assert_eq!(t(r"\sin\left(2x\right)"), r"\sin\left(2x\right)"); + assert_eq!(t(r"\sin(x)"), r"\sin\left(x\right)"); + assert_eq!(t(r"\sin x"), r"\sin\left(x\right)"); + assert_eq!(t(r"\ln(x)+\cos(x)"), r"\ln\left(x\right)+\cos\left(x\right)"); + } + + #[test] + fn desmos_function_names_print_as_operator_names() { + for name in crate::funcs::OPNAMES { + let want = format!("\\operatorname{{{name}}}\\left(x\\right)"); + assert_eq!(t(&format!("{name}(x)")), want, "{name}"); + } + assert_eq!(t("sech x"), r"\operatorname{sech}\left(x\right)"); + assert_eq!(t("nCr(6,2)"), r"\operatorname{nCr}\left(6,2\right)"); + assert_eq!(t("csch^2 x"), r"\operatorname{csch}^2 x"); + // The LaTeX commands are unchanged. + assert_eq!(t("sinh(x)"), r"\sinh\left(x\right)"); + } + + #[test] + fn desmos_spellings() { + assert_eq!(t("infinity"), r"\infty"); + assert_eq!(t("u cdot v"), r"u \cdot v"); + assert_eq!(t("u cross v"), r"u \times v"); + } + + #[test] + fn commands_are_separated_from_following_letters() { + assert_eq!(t(r"2\pi x"), r"2\pi x"); + assert_eq!(t(r"\theta+\alpha"), r"\theta+\alpha"); + assert_eq!(t(r"a\cdot b"), r"a\cdot b"); + assert_eq!(t(r"a\leq b\geq c\neq d"), r"a\le b\ge c\ne d"); + } + + #[test] + fn delimiters_and_absolute_value() { + assert_eq!(t(r"\left(x+1\right)^{2}"), r"\left(x+1\right)^2"); + assert_eq!(t("|x-1|"), r"\left|x-1\right|"); + assert_eq!(t(r"\left|x\right|"), r"\left|x\right|"); + assert_eq!(t(r"\{x\}"), r"\left\{x\right\}"); + assert_eq!(t(r"\abs{x}"), r"\left|x\right|"); + assert_eq!(t(r"\sin{x}"), r"\sin\left(x\right)"); + } + + #[test] + fn upright_constants_and_words() { + assert_eq!(t(r"\mathrm{e}^{x}"), r"\mathrm{e}^x"); + assert_eq!(t(r"\exponentialE^{x}"), r"\mathrm{e}^x"); + assert_eq!(t(r"\differentialD x"), "d x"); + assert_eq!(t(r"\operatorname{abs}(x)"), r"\operatorname{abs}\left(x\right)"); + assert_eq!(t(r"\text{if } x"), r"\text{if } x"); + } + + #[test] + fn big_operators_and_environments() { + assert_eq!(t(r"\sum_{i=1}^{n}i"), r"\sum_{i=1}^ni"); + assert_eq!(t(r"\sum_{i=1}^{n}(i+1)"), r"\sum_{i=1}^n\left(i+1\right)"); + assert_eq!(t(r"\int_0^1 x\,dx"), r"\int_0^1{x\,dx}"); + assert_eq!(t(r"\begin{pmatrix}a&b\\c&d\end{pmatrix}"), r"\begin{pmatrix}a & b \\ c & d\end{pmatrix}"); + assert_eq!(t(r"\binom{n}{k}"), r"\binom{n}{k}"); + } + + #[test] + fn printed_latex_parses_back_to_the_same_tree() { + for src in [ + r"\frac{a+b}{c}", + r"x^{2}+\sqrt[3]{y}", + r"\sin\left(2x\right)+\cos\left(x\right)", + r"\left|x-1\right|", + r"\sum_{i=1}^{n}i^2", + r"\begin{bmatrix}1&2\\3&4\end{bmatrix}", + r"a\le b\ne c", + r"\overline{x}+\hat{y}", + r"\mathbb{R}\to\infty", + ] { + let m = from_linear(src); + let l = to_latex(&m); + assert_eq!(from_linear(&l).body, m.body, "{src} -> {l}"); + } + } + + #[test] + fn printing_is_stable() { + for src in [r"\frac{x^2}{\sqrt{y}}", r"\sin\left(\pi x\right)", r"2\left(x+1\right)", r"\begin{cases}1&x>0\\0&x<0\end{cases}"] { + let once = t(src); + assert_eq!(t(&once), once, "{src}"); + } + } +} diff --git a/crates/equashion-math/src/lib.rs b/crates/equashion-math/src/lib.rs new file mode 100644 index 00000000..8af7f21f --- /dev/null +++ b/crates/equashion-math/src/lib.rs @@ -0,0 +1,167 @@ +//! equaSHion equations: the math tree, OMML (Office Math Markup) and presentation MathML import +//! and export, a typed "linear" text format, and the template palette. +//! +//! Layer 1, no dependency on fonts: layout lives in `equashion-layout`. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#![forbid(unsafe_code)] + +mod edit; +mod funcs; +mod gallery; +mod latex; +mod linear; +mod mathml; +mod omml; +mod templates; +mod xml; +pub(crate) use xml::XML_DEPTH; + +pub use edit::{Caret, Editor, Key, Path, children, children_mut, seq_at, seq_at_mut, units}; +pub use gallery::{Galleries, Gallery, GalleryTemplate, Section, SymbolSet, galleries}; +pub use latex::to_latex; +pub use linear::{from_linear, to_linear, unknown_commands}; +pub use mathml::{MATHML_NS, MAX_MATHML, MathmlError, from_mathml, to_mathml, to_mathml_display}; +pub use omml::{OMATH_BREAK, OMML_NS, from_omml, to_omml}; +pub use templates::{Template, matrix_template, template, templates}; + +use serde::{Deserialize, Serialize}; + +/// The text a `Math` run carries for an equation: its linear form on one line (control +/// characters, tabs and newlines become spaces) and never empty, so the run always has a width. +pub fn run_text(m: &Math) -> String { + let t: String = to_linear(m).chars().map(|c| if c.is_control() { ' ' } else { c }).collect(); + if t.trim().is_empty() { "\u{25A1}".to_string() } else { t } +} + +/// Maximum nesting depth honoured anywhere in this crate (parsing, printing, layout). +pub const MAX_DEPTH: usize = 48; + +/// Maximum number of XML elements read from one OMML document; the rest is dropped. +pub const MAX_NODES: usize = 250_000; + +/// A sequence of math nodes. +pub type Seq = Vec; + +/// One equation (an `m:oMath`). +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct Math { + pub body: Seq, + /// True when the source was an `m:oMathPara` (display equation); written back the same way. + pub para: bool, + /// Paragraph justification (`m:oMathParaPr/m:jc`), only meaningful with `para`. + pub jc: Option, + /// Attributes of the (first) `m:oMath` element, as ` name="value"` text ready to write. + #[serde(default)] + pub attrs: String, +} + +impl Math { + pub fn new(body: Seq) -> Self { + Math { body, para: false, jc: None, attrs: String::new() } + } + pub fn is_empty(&self) -> bool { + self.body.iter().all(|n| matches!(n, Node::Text(t) if t.is_empty())) + } +} + +/// Run style of a [`Node::Styled`] text. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Style { + /// `m:sty p`: upright. + Plain, + Bold, + /// `m:sty bi`. + BoldItalic, + /// `m:nor`: normal text, not math. + Normal, +} + +/// A node of the math tree. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum Node { + /// Plain run of characters (letters, digits, operators). + Text(String), + /// Fraction `num / den`. + Frac { num: Seq, den: Seq }, + /// Radical; `deg` is the optional root degree. + Rad { deg: Option, body: Seq }, + /// Sub/superscript on `base`. + Script { base: Seq, sub: Option, sup: Option }, + /// N-ary operator (sum, product, integral...) with optional limits. + Nary { op: char, sub: Option, sup: Option, body: Seq }, + /// Delimiters around one or more items separated by `sep`. + Delim { open: String, close: String, sep: String, items: Vec }, + /// Matrix: rows of cells. + Matrix { rows: Vec> }, + /// Named function (sin, log...) applied to `body`. + Func { name: String, body: Seq }, + /// Accent mark `ch` (a combining character) over `body`. + Accent { ch: char, body: Seq }, + /// Text with an explicit run style (`m:sty` / `m:nor`). + Styled { text: String, style: Style }, + /// `m:limLow` (`lower`) or `m:limUpp`: a limit expression under or over `base`. + Limit { lower: bool, base: Seq, lim: Seq }, + /// `m:eqArr`: a vertical array of equation lines. + EqArr { rows: Vec }, + /// `m:sPre`: scripts placed before the base. + PreScript { sub: Seq, sup: Seq, base: Seq }, + /// `m:bar`: a line over (`top`) or under the body. + Bar { top: bool, body: Seq }, + /// `m:groupChr`: a stretchy grouping character (brace, arrow) over or under the body. + GroupChr { ch: char, top: bool, body: Seq }, + /// `m:box` (`border` false) or `m:borderBox`. + Boxed { border: bool, body: Seq }, + /// Unknown OMML element, kept verbatim so it survives a round trip. + Raw(String), + /// A node together with the OMML properties the tree does not model (`fPr/type`, `m:scr`, + /// `m:lit`, `m:ctrlPr`, `w:rPr` fonts, `limLoc`, `grow`, `shp`, ...), re-emitted on write so a + /// first edit does not rewrite the equation into a lossy form. Layout and linear text see + /// straight through it. + Props { extra: Extra, node: Box }, +} + +/// Unmodelled OMML properties of one element (see [`Node::Props`]). +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct Extra { + /// Children of the element's property container (`m:fPr`, `m:rPr`, ...): (name, verbatim XML). + pub pr: Vec<(String, String)>, + /// Run only: sibling elements other than `m:rPr` and `m:t` (for example `w:rPr`), verbatim. + pub side: Vec, + /// Attributes of the element itself, as ` name="value"` text ready to write. + #[serde(default)] + pub attrs: String, + /// Children of a structural element that the tree does not model, verbatim. + #[serde(default)] + pub tail: Vec, +} + +impl Extra { + pub fn is_empty(&self) -> bool { + self.pr.is_empty() && self.side.is_empty() && self.attrs.is_empty() && self.tail.is_empty() + } +} + +impl Node { + /// The node with any [`Node::Props`] wrappers removed. + pub fn bare(&self) -> &Node { + let mut n = self; + while let Node::Props { node, .. } = n { + n = node; + } + n + } +} + +#[cfg(test)] +mod tests; + +#[cfg(test)] +mod run_text_tests { + use super::*; + + #[test] + fn run_text_is_never_empty_or_multiline() { + assert_eq!(run_text(&from_omml("")), "\u{25A1}"); + assert!(!run_text(&from_linear("a\nb\tc")).chars().any(char::is_control)); + } +} diff --git a/crates/equashion-math/src/linear.rs b/crates/equashion-math/src/linear.rs new file mode 100644 index 00000000..88dff977 --- /dev/null +++ b/crates/equashion-math/src/linear.rs @@ -0,0 +1,1736 @@ +//! The "linear" format: equations as typed text, e.g. `x^2 + sqrt(a)/b`, `pi`, `a<=b`. +//! +//! [`from_linear`] is a port of (and superset of) the old `pretty_equation` dialog helper: `^`, +//! `*`, `sqrt`, `<=`, `>=`, `!=`, `pi`, `+-` all keep working, and `a/b`, `x_1` and Greek names +//! now build real structure. [`to_linear`] prints a tree back in the same syntax (Unicode symbols +//! are written as themselves), so `from_linear(to_linear(m))` reproduces canonical trees. +//! +//! The parser is forgiving about ordinary typing: spaces around `/` and before the body of +//! `sqrt`, `sum`, `int` and a bare function (`sin x`); `lim_(x->0)`; `[a,b]`, `|x|`; `matrix(..)` +//! (parenthesised), `■(..)` (bare grid), `cases(..)`; and a LaTeX subset (`\frac{a}{b}`, `\leq`, +//! `\sqrt[3]{x}`, `\sum_{i=1}^{n}`, `\{ .. \}`, `\text{..}`, braces as grouping). + +use crate::xml::{self, XEl}; +use crate::{MAX_DEPTH, Math, Node, Seq, Style}; + +pub(crate) const GREEK: &[(&str, char)] = &[ + ("alpha", 'α'), + ("beta", 'β'), + ("gamma", 'γ'), + ("delta", 'δ'), + ("epsilon", 'ε'), + ("varepsilon", 'ϵ'), + ("zeta", 'ζ'), + ("eta", 'η'), + ("theta", 'θ'), + ("vartheta", 'ϑ'), + ("iota", 'ι'), + ("kappa", 'κ'), + ("lambda", 'λ'), + ("mu", 'μ'), + ("nu", 'ν'), + ("xi", 'ξ'), + ("omicron", 'ο'), + ("pi", 'π'), + ("varpi", 'ϖ'), + ("rho", 'ρ'), + ("sigma", 'σ'), + ("varsigma", 'ς'), + ("tau", 'τ'), + ("upsilon", 'υ'), + ("phi", 'φ'), + ("varphi", 'ϕ'), + ("chi", 'χ'), + ("psi", 'ψ'), + ("omega", 'ω'), + ("Gamma", 'Γ'), + ("Delta", 'Δ'), + ("Theta", 'Θ'), + ("Lambda", 'Λ'), + ("Xi", 'Ξ'), + ("Pi", 'Π'), + ("Sigma", 'Σ'), + ("Phi", 'Φ'), + ("Psi", 'Ψ'), + ("Omega", 'Ω'), +]; + +use crate::funcs::{self, BARE, LATEX}; + +/// LaTeX-style symbol names. +pub(crate) const SYMS: &[(&str, &str)] = &[ + ("leq", "≤"), + ("le", "≤"), + ("geq", "≥"), + ("ge", "≥"), + ("neq", "≠"), + ("ne", "≠"), + ("pm", "±"), + ("mp", "∓"), + ("cdot", "·"), + ("div", "÷"), + ("to", "→"), + ("rightarrow", "→"), + ("leftarrow", "←"), + ("Rightarrow", "⇒"), + ("Leftrightarrow", "⇔"), + ("approx", "≈"), + ("equiv", "≡"), + ("sim", "∼"), + ("ldots", "…"), + ("cdots", "⋯"), + ("dots", "…"), + ("in", "∈"), + ("notin", "∉"), + ("subset", "⊂"), + ("subseteq", "⊆"), + ("cup", "∪"), + ("cap", "∩"), + ("forall", "∀"), + ("exists", "∃"), + ("partial", "∂"), + ("nabla", "∇"), + ("emptyset", "∅"), + ("degree", "°"), + ("angle", "∠"), + ("perp", "⊥"), + ("parallel", "∥"), + ("nparallel", "∦"), + ("prime", "′"), + ("dprime", "″"), + ("backprime", "‵"), + ("mid", "∣"), + ("propto", "∝"), + ("ll", "≪"), + ("gg", "≫"), + // Bracket commands: kept as text, like a typed `⟨` (the drawn delimiters come from OMML). + ("langle", "⟨"), + ("rangle", "⟩"), + ("lfloor", "⌊"), + ("rfloor", "⌋"), + ("lceil", "⌈"), + ("rceil", "⌉"), +]; + +pub(crate) const ACCENTS: &[(&str, char)] = &[ + ("hat", '\u{0302}'), + ("tilde", '\u{0303}'), + ("bar", '\u{0305}'), + ("dot", '\u{0307}'), + ("ddot", '\u{0308}'), + ("vec", '\u{20D7}'), + ("breve", '\u{0306}'), + ("check", '\u{030C}'), + ("acute", '\u{0301}'), + ("grave", '\u{0300}'), +]; + +/// Operator names that LaTeX spells as commands and that print as plain words. +pub(crate) const PASS: &[&str] = &["mod", "bmod", "pmod", "arg", "dim", "ker", "deg", "hom", "Pr", "lg", "sgn", "limsup", "liminf", "sup"]; + +/// Double-struck capital for the common number sets; everything else is left alone. +fn double_struck(c: char) -> char { + match c { + 'C' => 'ℂ', + 'H' => 'ℍ', + 'N' => 'ℕ', + 'P' => 'ℙ', + 'Q' => 'ℚ', + 'R' => 'ℝ', + 'Z' => 'ℤ', + c => c, + } +} + +pub(crate) const NARY: &[(&str, char)] = + &[("sum", '∑'), ("prod", '∏'), ("int", '∫'), ("iint", '∬'), ("iiint", '∭'), ("oint", '∮'), ("bigcup", '⋃'), ("bigcap", '⋂')]; + +/// The single character a LaTeX-style symbol or Greek name stands for (`alpha`, `leq`), if any. +pub(crate) fn symbol(name: &str) -> Option { + if let Some((_, c)) = GREEK.iter().find(|(n, _)| *n == name) { + return Some(*c); + } + let (_, v) = SYMS.iter().find(|(n, _)| *n == name)?; + let mut it = v.chars(); + let c = it.next()?; + it.next().is_none().then_some(c) +} + +/// The big operator a name stands for (`sum`, `int`) and whether it takes limits above and below. +pub(crate) fn nary_op(name: &str) -> Option<(char, bool)> { + let (_, c) = NARY.iter().find(|(n, _)| *n == name)?; + Some((*c, matches!(name, "sum" | "prod" | "bigcup" | "bigcap"))) +} + +/// The accent a name stands for (`hat`, `vec`). +pub(crate) fn accent(name: &str) -> Option { + ACCENTS.iter().find(|(n, _)| *n == name).map(|(_, c)| *c) +} + +/// True for an upright function name (`sin`, `lim`, `max`) and for the operator words that print +/// as plain text (`arg`, `mod`). +pub(crate) fn is_function_word(name: &str) -> bool { + funcs::is_func(name) || PASS.contains(&name) +} + +/// True when `name` is a function that takes a bare argument (`sin x`). +pub(crate) fn is_bare_func(name: &str) -> bool { + BARE.contains(&name) +} + +/// Every command name the typed-LaTeX table knows, for prefix checks. +pub(crate) fn command_names() -> impl Iterator { + GREEK + .iter() + .map(|(n, _)| *n) + .chain(SYMS.iter().map(|(n, _)| *n)) + .chain(NARY.iter().map(|(n, _)| *n)) + .chain(ACCENTS.iter().map(|(n, _)| *n)) + .chain(LATEX.iter().copied()) + .chain(PASS.iter().copied()) +} + +const NARY_CHARS: &[char] = &['∑', '∏', '∫', '∬', '∭', '∮', '⋃', '⋂']; + +// ---- parsing ------------------------------------------------------------------------------ + +struct Parser { + c: Vec, + pos: usize, + depth: usize, + /// Lazily built bracket-match tables for `(`, `[` and `{` (one linear pass each). + tables: [Option>>; 3], +} + +impl Parser { + fn new(c: Vec, depth: usize) -> Parser { + Parser { c, pos: 0, depth, tables: [None, None, None] } + } +} + +const WS: fn(char) -> bool = |c| matches!(c, ' ' | '\t' | '\n' | '\r'); + +/// Typographic minus for an ASCII hyphen. +fn minus(c: char) -> char { + if c == '-' { '\u{2212}' } else { c } +} + +/// Nesting depth of a node, counting at most `cap` levels (the walk itself is bounded by `cap`). +fn node_depth(n: &Node, cap: usize) -> usize { + if cap == 0 { + return 0; + } + let none: &[Node] = &[]; + let mut kids: Vec<&[Node]> = Vec::new(); + match n.bare() { + Node::Props { .. } => {} + Node::Text(_) | Node::Styled { .. } | Node::Raw(_) => {} + Node::Frac { num, den } => kids.extend([num.as_slice(), den.as_slice()]), + Node::Rad { deg, body } => kids.extend([deg.as_deref().unwrap_or(none), body.as_slice()]), + Node::Script { base, sub, sup } => kids.extend([base.as_slice(), sub.as_deref().unwrap_or(none), sup.as_deref().unwrap_or(none)]), + Node::Nary { sub, sup, body, .. } => kids.extend([sub.as_deref().unwrap_or(none), sup.as_deref().unwrap_or(none), body.as_slice()]), + Node::Delim { items, .. } => kids.extend(items.iter().map(|i| i.as_slice())), + Node::Matrix { rows } => kids.extend(rows.iter().flatten().map(|c| c.as_slice())), + Node::EqArr { rows } => kids.extend(rows.iter().map(|r| r.as_slice())), + Node::Func { body, .. } | Node::Accent { body, .. } | Node::Bar { body, .. } | Node::GroupChr { body, .. } | Node::Boxed { body, .. } => { + kids.push(body.as_slice()) + } + Node::Limit { base, lim, .. } => kids.extend([base.as_slice(), lim.as_slice()]), + Node::PreScript { sub, sup, base } => kids.extend([sub.as_slice(), sup.as_slice(), base.as_slice()]), + } + 1 + kids.iter().map(|k| seq_depth(k, cap - 1)).max().unwrap_or(0) +} + +fn seq_depth(s: &[Node], cap: usize) -> usize { + s.iter().map(|n| node_depth(n, cap)).max().unwrap_or(0) +} + +/// Trim blanks off both ends of a cell. +fn trim_seq(mut s: Seq) -> Seq { + if let Some(Node::Text(t)) = s.first_mut() { + *t = t.trim_start_matches(WS).to_string(); + } + if let Some(Node::Text(t)) = s.last_mut() { + *t = t.trim_end_matches(WS).to_string(); + } + s.retain(|n| !matches!(n, Node::Text(t) if t.is_empty())); + s +} + +fn push_text(out: &mut Seq, s: &str) { + if s.is_empty() { + return; + } + if let Some(Node::Text(t)) = out.last_mut() { + t.push_str(s); + } else { + out.push(Node::Text(s.to_string())); + } +} + +fn push_node(out: &mut Seq, n: Node) { + match n { + Node::Text(t) => push_text(out, &t), + n => out.push(n), + } +} + +/// Parse typed text into an equation. Never fails; bad input stays literal text. +pub fn from_linear(s: &str) -> Math { + let mut p = Parser::new(s.chars().collect(), 0); + Math::new(p.seq()) +} + +/// Backslash commands the typed-text parser did not know: they stay in the equation as literal +/// text (so nothing typed is lost) and are listed here so a caller can report them. Each name +/// appears once, in order of first use; the walk is iterative and bounded. +pub fn unknown_commands(m: &Math) -> Vec { + const MAX_NAMES: usize = 32; + let mut out: Vec = Vec::new(); + let mut stack: Vec<&Seq> = vec![&m.body]; + let mut budget = 1_000_000usize; + while let Some(seq) = stack.pop() { + for n in seq.iter().rev() { + budget = budget.saturating_sub(1); + if budget == 0 { + return out; + } + stack.extend(crate::children(n.bare())); + } + for n in seq { + if let Node::Text(t) = n.bare() { + let mut rest = t.as_str(); + while let Some(i) = rest.find('\\') { + rest = rest.get(i + 1..).unwrap_or(""); + let name: String = rest.chars().take_while(|c| c.is_ascii_alphabetic()).collect(); + if !name.is_empty() && out.len() < MAX_NAMES && !out.contains(&name) { + out.push(name); + } + } + } + } + } + out +} + +impl Parser { + fn peek(&self) -> Option { + self.c.get(self.pos).copied() + } + fn at(&self, off: usize) -> Option { + self.c.get(self.pos + off).copied() + } + fn skip_ws(&mut self) { + while self.peek().is_some_and(|c| c == ' ' || c == '\t') { + self.pos += 1; + } + } + + /// Index of the `cl` matching the `o` at `open`, if any. + fn matching(&mut self, open: usize, o: char, cl: char) -> Option { + let ti = match (o, cl) { + ('(', ')') => 0, + ('[', ']') => 1, + ('{', '}') => 2, + _ => return self.matching_scan(open, o, cl), + }; + if self.tables.get(ti).is_some_and(|t| t.is_none()) { + let mut t: Vec> = vec![None; self.c.len()]; + let mut stack: Vec = Vec::new(); + for (i, &ch) in self.c.iter().enumerate() { + if ch == o { + stack.push(i); + } else if ch == cl + && let Some(j) = stack.pop() + && let Some(slot) = t.get_mut(j) + { + *slot = Some(i); + } + } + if let Some(slot) = self.tables.get_mut(ti) { + *slot = Some(t); + } + } + self.tables.get(ti)?.as_ref()?.get(open).copied().flatten() + } + + fn matching_scan(&self, open: usize, o: char, cl: char) -> Option { + let mut d = 0usize; + for i in open..self.c.len() { + let ch = *self.c.get(i)?; + if ch == o { + d += 1; + } else if ch == cl { + d = d.saturating_sub(1); + if d == 0 { + return Some(i); + } + } + } + None + } + + fn sub(&self, from: usize, to: usize) -> Parser { + Parser::new(self.c.get(from..to).map(|s| s.to_vec()).unwrap_or_default(), self.depth + 1) + } + + /// Parse a balanced `o ... cl` group at the cursor; returns the inner sequence. + fn group_with(&mut self, o: char, cl: char) -> Option { + if self.depth >= MAX_DEPTH || self.peek() != Some(o) { + return None; + } + let m = self.matching(self.pos, o, cl)?; + let inner = self.sub(self.pos + 1, m).seq(); + self.pos = m + 1; + Some(inner) + } + + /// `[a, b; c, d]`: a bracket group with a top-level `;` is a matrix. + fn bracket_grid(&mut self) -> bool { + if self.depth >= MAX_DEPTH || self.peek() != Some('[') { + return false; + } + match self.matching(self.pos, '[', ']') { + Some(m) => self.has_top_level(self.pos + 1, m, &[';']), + None => false, + } + } + + fn group(&mut self) -> Option { + self.group_with('(', ')') + } + + fn seq(&mut self) -> Seq { + let mut out = Seq::new(); + while let Some(ch) = self.peek() { + match ch { + '(' => { + let start = self.pos; + match self.group() { + Some(inner) => out.push(Node::Delim { open: "(".into(), close: ")".into(), sep: "|".into(), items: vec![inner] }), + None => { + self.pos = start + 1; + push_text(&mut out, "("); + } + } + } + '[' if self.bracket_grid() => { + let m = self.matching(self.pos, '[', ']').unwrap_or(self.pos); + let rows = self.grid(self.pos + 1, m, ',', ';'); + self.pos = m + 1; + out.push(Node::Delim { open: "[".into(), close: "]".into(), sep: "|".into(), items: vec![vec![Node::Matrix { rows }]] }); + } + '[' => match self.group_with('[', ']') { + Some(inner) => out.push(Node::Delim { open: "[".into(), close: "]".into(), sep: "|".into(), items: vec![inner] }), + None => { + self.pos += 1; + push_text(&mut out, "["); + } + }, + '{' => match self.group_with('{', '}') { + // Braces only group; their content is spliced in. + Some(inner) => { + for n in inner { + push_node(&mut out, n); + } + } + None => { + self.pos += 1; + push_text(&mut out, "{"); + } + }, + '|' => self.bar_group(&mut out), + '\\' => self.command(&mut out), + '^' | '_' => { + self.pos += 1; + let operand = self.atom(false); + attach_script(&mut out, ch == '^', operand); + } + '/' => { + self.pos += 1; + if out.last().is_some_and(|n| node_depth(n, MAX_DEPTH) + 1 >= MAX_DEPTH) { + // Too deeply nested to wrap again: keep the slash literal. + push_text(&mut out, "/"); + continue; + } + let num = take_numerator(&mut out); + let den = self.denominator(); + out.push(Node::Frac { num, den }); + } + '*' => { + self.pos += 1; + push_text(&mut out, "·"); + } + '<' if self.at(1) == Some('=') => { + self.pos += 2; + push_text(&mut out, "≤"); + } + '>' if self.at(1) == Some('=') => { + self.pos += 2; + push_text(&mut out, "≥"); + } + '!' if self.at(1) == Some('=') => { + self.pos += 2; + push_text(&mut out, "≠"); + } + '+' if self.at(1) == Some('-') => { + self.pos += 2; + push_text(&mut out, "±"); + } + '-' if self.at(1) == Some('>') => { + self.pos += 2; + push_text(&mut out, "→"); + } + '√' => { + self.pos += 1; + let n = self.radical(); + out.push(n); + } + '■' if self.at(1) == Some('(') => { + self.pos += 1; + match self.matrix() { + Some(n) => out.push(n), + None => push_text(&mut out, "■"), + } + } + '█' if self.at(1) == Some('(') => { + self.pos += 1; + match self.eq_array() { + Some(n) => out.push(n), + None => push_text(&mut out, "█"), + } + } + c if NARY_CHARS.contains(&c) => { + self.pos += 1; + let n = self.nary(c); + out.push(n); + } + c if c.is_ascii_alphabetic() => { + let w = self.word(); + match self.special(&w) { + Some(n) => push_node(&mut out, n), + None => push_text(&mut out, &w), + } + } + c => { + self.pos += 1; + push_text(&mut out, &minus(c).to_string()); + } + } + } + out + } + + /// `|x|` absolute value when the first bar opens (not directly after a letter or `)`). + fn bar_group(&mut self, out: &mut Seq) { + let opens = match out.last() { + None => true, + Some(Node::Text(t)) => t.chars().last().is_some_and(|c| !c.is_alphanumeric() && c != ')' && c != '|'), + Some(_) => false, + }; + let close = if opens && self.depth < MAX_DEPTH { (self.pos + 1..self.c.len()).find(|&i| self.c.get(i) == Some(&'|')) } else { None }; + match close { + Some(j) if j > self.pos + 1 => { + let inner = self.sub(self.pos + 1, j).seq(); + self.pos = j + 1; + out.push(Node::Delim { open: "|".into(), close: "|".into(), sep: "|".into(), items: vec![inner] }); + } + _ => { + self.pos += 1; + push_text(out, "|"); + } + } + } + + /// A LaTeX-style command or escape at the cursor (`\frac`, `\leq`, `\{`). + fn command(&mut self, out: &mut Seq) { + self.pos += 1; + let Some(c) = self.peek() else { return push_text(out, "\\") }; + if !c.is_ascii_alphabetic() { + self.pos += 1; + match c { + '{' => self.escaped_brace(out), + ',' => push_text(out, "\u{2009}"), + ':' => push_text(out, "\u{2005}"), + ';' => push_text(out, "\u{2004}"), + ' ' => push_text(out, " "), + '!' => {} + c => push_text(out, &c.to_string()), + } + return; + } + let w = self.word(); + match w.as_str() { + "frac" | "dfrac" | "tfrac" | "cfrac" => { + let num = self.frac_arg(); + let den = self.frac_arg(); + out.push(Node::Frac { num, den }); + } + "begin" => self.environment(out), + "binom" | "dbinom" | "tbinom" => { + let n = self.brace_arg(); + let k = self.brace_arg(); + let m = Node::Matrix { rows: vec![vec![n], vec![k]] }; + out.push(Node::Delim { open: "(".into(), close: ")".into(), sep: "|".into(), items: vec![vec![m]] }); + } + "mathbb" => { + let arg = self.brace_arg(); + for n in arg { + match n { + Node::Text(t) => push_text(out, &t.chars().map(double_struck).collect::()), + n => push_node(out, n), + } + } + } + "mathcal" | "mathscr" | "mathfrak" | "mathit" | "mathsf" | "mathtt" | "boldsymbol" | "bm" | "textit" | "textsf" | "emph" | "mbox" + | "hbox" | "mathnormal" => { + for n in self.brace_arg() { + push_node(out, n); + } + } + "overrightarrow" | "overleftarrow" | "overleftrightarrow" | "underrightarrow" | "underleftarrow" | "underleftrightarrow" + if { + self.skip_ws(); + self.peek().is_some() + } => + { + let body = self.brace_arg(); + let ch = if w.ends_with("leftrightarrow") { + '\u{2194}' + } else if w.ends_with("rightarrow") { + '\u{2192}' + } else { + '\u{2190}' + }; + out.push(Node::GroupChr { ch, top: w.starts_with("over"), body }); + } + "widehat" | "widetilde" | "vec" | "hat" | "tilde" | "bar" | "dot" | "ddot" | "breve" | "check" | "acute" | "grave" + if { + self.skip_ws(); + self.peek().is_some() + } => + { + let name = match w.as_str() { + "widehat" => "hat", + "widetilde" => "tilde", + o => o, + }; + let body = self.brace_arg(); + if let Some((_, ch)) = ACCENTS.iter().find(|(n, _)| *n == name) { + out.push(Node::Accent { ch: *ch, body }); + } + } + "overline" | "underline" | "overbrace" | "underbrace" | "boxed" | "box" | "fbox" + if { + self.skip_ws(); + self.peek() == Some('{') + } => + { + let body = self.brace_arg(); + out.push(match w.as_str() { + "overline" => Node::Bar { top: true, body }, + "underline" => Node::Bar { top: false, body }, + "overbrace" => Node::GroupChr { ch: '\u{23DE}', top: true, body }, + "underbrace" => Node::GroupChr { ch: '\u{23DF}', top: false, body }, + "boxed" | "fbox" => Node::Boxed { border: true, body }, + _ => Node::Boxed { border: false, body }, + }); + } + "quad" | "qquad" => { + push_text(out, if w == "quad" { "\u{2001}" } else { "\u{2001}\u{2001}" }); + self.skip_ws(); + } + "lVert" | "rVert" | "Vert" => push_text(out, "\u{2016}"), + "lvert" | "rvert" | "vert" => push_text(out, "|"), + "overset" | "stackrel" | "underset" => { + let (a, b) = (self.brace_arg(), self.brace_arg()); + out.push(Node::Limit { lower: w == "underset", base: b, lim: a }); + } + "limits" | "nolimits" => {} + "left" | "right" | "big" | "Big" | "bigl" | "bigr" | "Bigl" | "Bigr" | "displaystyle" => {} + "abs" + if { + self.skip_ws(); + self.peek() == Some('{') + } => + { + let body = self.brace_arg(); + out.push(Node::Delim { open: "|".into(), close: "|".into(), sep: "|".into(), items: vec![body] }); + } + // An empty slot (`\placeholder{}`) is nothing; a filled one is its content. + "placeholder" => { + self.skip_ws(); + if self.peek() == Some('{') { + for n in self.brace_arg() { + push_node(out, n); + } + } + } + _ if NARY.iter().any(|(n, _)| *n == w) && self.depth < MAX_DEPTH => { + let op = NARY.iter().find(|(n, _)| *n == w).map(|(_, o)| *o).unwrap_or('∑'); + self.depth += 1; + let n = self.nary_latex(op); + self.depth -= 1; + out.push(n); + } + "text" | "textrm" | "mathrm" | "operatorname" | "mathbf" | "textbf" => { + let style = match w.as_str() { + "text" | "textrm" => Style::Normal, + "mathbf" | "textbf" => Style::Bold, + _ => Style::Plain, + }; + self.skip_ws(); + if self.peek() == Some('{') + && let Some(m) = self.matching(self.pos, '{', '}') + { + let text: String = self.c.get(self.pos + 1..m).map(|s| s.iter().collect()).unwrap_or_default(); + self.pos = m + 1; + if !text.is_empty() { + out.push(Node::Styled { text, style }); + } + } + } + _ => { + if let Some(n) = self.special(&w) { + push_node(out, n); + } else if let Some((_, sym)) = SYMS.iter().find(|(n, _)| *n == w) { + push_text(out, sym); + } else if LATEX.contains(&w.as_str()) || PASS.contains(&w.as_str()) { + push_text(out, &w); + } else { + // Unknown command: keep it visible, backslash included. + push_text(out, &format!("\\{w}")); + } + } + } + } + + /// After `\{`: a delimiter pair up to the matching `\}`, else a literal brace. + fn escaped_brace(&mut self, out: &mut Seq) { + let mut d = 1usize; + let mut i = self.pos; + let mut close = None; + while i + 1 < self.c.len() && self.depth < MAX_DEPTH { + match (self.c.get(i), self.c.get(i + 1)) { + (Some('\\'), Some('{')) => { + d += 1; + i += 2; + } + (Some('\\'), Some('}')) => { + d -= 1; + if d == 0 { + close = Some(i); + break; + } + i += 2; + } + _ => i += 1, + } + } + match close { + Some(j) => { + let inner = self.sub(self.pos, j).seq(); + self.pos = j + 2; + out.push(Node::Delim { open: "{".into(), close: "}".into(), sep: "|".into(), items: vec![inner] }); + } + None => push_text(out, "{"), + } + } + + fn at_str(&self, i: usize, s: &str) -> bool { + s.chars().enumerate().all(|(k, ch)| self.c.get(i + k) == Some(&ch)) + } + + /// Index of the `\end{name}` closing an already-consumed `\begin{name}`. + fn find_end(&self, from: usize, name: &str) -> Option { + let begin = format!("\\begin{{{name}}}"); + let end = format!("\\end{{{name}}}"); + let (bl, el) = (begin.chars().count(), end.chars().count()); + let (mut d, mut i) = (1usize, from); + while i < self.c.len() { + if self.at_str(i, &end) { + d = d.saturating_sub(1); + if d == 0 { + return Some(i); + } + i += el; + } else if self.at_str(i, &begin) { + d += 1; + i += bl; + } else { + i += 1; + } + } + None + } + + fn cell(&mut self, from: usize, to: usize) -> Seq { + trim_seq(self.sub(from, to).seq()) + } + + /// Rows and cells of a LaTeX environment body (`&` between cells, `\\` between rows). + fn env_cells(&mut self, from: usize, to: usize) -> Vec> { + let mut rows: Vec> = Vec::new(); + let mut row: Vec = Vec::new(); + let (mut start, mut i, mut envd, mut brace) = (from, from, 0usize, 0usize); + while i < to { + let ch = self.c.get(i).copied().unwrap_or(' '); + if ch == '\\' { + match self.c.get(i + 1).copied() { + Some('\\') => { + if envd == 0 && brace == 0 { + row.push(self.cell(start, i)); + rows.push(std::mem::take(&mut row)); + start = i + 2; + } + i += 2; + } + Some(c) if c.is_ascii_alphabetic() => { + if self.at_str(i, "\\begin") { + envd += 1; + i += 6; + } else if self.at_str(i, "\\end") { + envd = envd.saturating_sub(1); + i += 4; + } else { + i += 1; + } + } + Some(_) => i += 2, + None => i += 1, + } + continue; + } + match ch { + '{' => brace += 1, + '}' => brace = brace.saturating_sub(1), + '&' if envd == 0 && brace == 0 => { + row.push(self.cell(start, i)); + start = i + 1; + } + _ => {} + } + i += 1; + } + let last = self.cell(start, to); + if !row.is_empty() || !last.is_empty() { + row.push(last); + rows.push(row); + } + rows + } + + /// After `\begin`: a matrix, cases or aligned environment up to its `\end`. + fn environment(&mut self, out: &mut Seq) { + self.skip_ws(); + let Some(name) = self.raw_braces() else { return push_text(out, "\\begin") }; + if name == "array" { + self.skip_ws(); + let _ = self.raw_braces(); + } + let start = self.pos; + let end = if self.depth < MAX_DEPTH { self.find_end(start, &name) } else { None }; + let Some(end) = end else { + // Unterminated or too deep: keep the opener visible and carry on. + push_text(out, &format!("\\begin{{{name}}}")); + return; + }; + let rows = self.env_cells(start, end); + self.pos = end + format!("\\end{{{name}}}").chars().count(); + let base = name.trim_end_matches('*'); + let flat = |rows: Vec>, sep: &str| -> Vec { + rows.into_iter() + .map(|r| { + let mut o = Seq::new(); + for (k, c) in r.into_iter().enumerate() { + if k > 0 && !sep.is_empty() { + push_text(&mut o, sep); + } + for n in c { + push_node(&mut o, n); + } + } + o + }) + .collect() + }; + let delim = + |open: &str, close: &str, inner: Node| Node::Delim { open: open.into(), close: close.into(), sep: "|".into(), items: vec![vec![inner]] }; + let node = match base { + "pmatrix" => delim("(", ")", Node::Matrix { rows }), + "bmatrix" => delim("[", "]", Node::Matrix { rows }), + "Bmatrix" => delim("{", "}", Node::Matrix { rows }), + "vmatrix" => delim("|", "|", Node::Matrix { rows }), + "Vmatrix" => delim("\u{2016}", "\u{2016}", Node::Matrix { rows }), + "matrix" | "smallmatrix" | "array" => Node::Matrix { rows }, + "cases" => delim("{", "", Node::EqArr { rows: flat(rows, "\u{2003}") }), + _ => Node::EqArr { rows: flat(rows, "") }, + }; + out.push(node); + } + + /// Raw text of a `{...}` group at the cursor (no parsing). + fn raw_braces(&mut self) -> Option { + if self.peek() != Some('{') { + return None; + } + let m = self.matching(self.pos, '{', '}')?; + let t: String = self.c.get(self.pos + 1..m)?.iter().collect(); + self.pos = m + 1; + Some(t) + } + + /// An argument of `\frac`: a group, a command, or (as in TeX) a single character, so + /// `\frac13` is 1 over 3. + fn frac_arg(&mut self) -> Seq { + self.skip_ws(); + match self.peek() { + Some(c) if c.is_alphanumeric() => { + self.pos += 1; + let mut o = Seq::new(); + push_text(&mut o, &c.to_string()); + o + } + _ => self.brace_arg(), + } + } + + fn brace_arg(&mut self) -> Seq { + if self.depth >= MAX_DEPTH { + return vec![]; + } + self.skip_ws(); + self.depth += 1; + let r = self.atom(false); + self.depth -= 1; + r + } + + fn word(&mut self) -> String { + let mut w = String::new(); + while let Some(c) = self.peek() { + if c.is_ascii_alphabetic() { + w.push(c); + self.pos += 1; + } else { + break; + } + } + w + } + + /// A word with meaning (Greek letter, `sqrt`, function, accent, n-ary). Consumes input only + /// when it returns `Some`. + fn special(&mut self, w: &str) -> Option { + if self.depth >= MAX_DEPTH { + return None; + } + self.depth += 1; + let r = self.special_inner(w); + self.depth -= 1; + r + } + + fn special_inner(&mut self, w: &str) -> Option { + if let Some((_, g)) = GREEK.iter().find(|(n, _)| *n == w) { + return Some(Node::Text(g.to_string())); + } + match w { + "sqrt" => return Some(self.radical()), + "inf" | "infty" => return Some(Node::Text("∞".into())), + // MathLive's upright constants and differential: a typed `d`, an upright `e` and `i`. + "differentialD" => return Some(Node::Text("d".into())), + "exponentialE" => return Some(Node::Styled { text: "e".into(), style: Style::Plain }), + "imaginaryI" => return Some(Node::Styled { text: "i".into(), style: Style::Plain }), + "times" | "cross" => return Some(Node::Text("×".into())), + "cdot" => return Some(Node::Text("·".into())), + "infinity" => return Some(Node::Text("∞".into())), + _ => {} + } + if let Some((_, op)) = NARY.iter().find(|(n, _)| *n == w) { + return Some(self.nary(*op)); + } + if matches!(w, "lim" | "max" | "min") && self.peek() == Some('_') && self.depth < MAX_DEPTH { + self.pos += 1; + let lim = self.atom(false); + return Some(Node::Limit { lower: true, base: vec![Node::Styled { text: w.to_string(), style: Style::Plain }], lim }); + } + if funcs::is_func(w) { + // `\sin\left(2x\right)`: the sizing command is dropped, so the group is the argument. + let save = self.pos; + self.skip_ws(); + if !self.skip_sizer() { + self.pos = save; + } + } + if funcs::is_func(w) && self.peek() == Some('{') && self.depth < MAX_DEPTH { + // `\sin{x}`: a braced argument is the function's argument. + let body = self.brace_arg(); + return Some(Node::Func { name: w.to_string(), body }); + } + if self.peek() == Some('(') && self.depth < MAX_DEPTH { + if funcs::is_func(w) { + let body = self.group()?; + return Some(Node::Func { name: w.to_string(), body }); + } + if w == "root" { + let deg = self.group()?; + self.skip_ws(); + let body = self.operand(true); + return Some(Node::Rad { deg: Some(deg), body }); + } + if let Some((_, ch)) = ACCENTS.iter().find(|(n, _)| *n == w) { + let body = self.group()?; + return Some(Node::Accent { ch: *ch, body }); + } + let delims = match w { + "matrix" | "pmatrix" => Some(("(", ")")), + "bmatrix" => Some(("[", "]")), + "vmatrix" => Some(("|", "|")), + "Bmatrix" => Some(("{", "}")), + _ => None, + }; + if let Some((open, close)) = delims { + let m = self.matrix()?; + return Some(Node::Delim { open: open.into(), close: close.into(), sep: "|".into(), items: vec![vec![m]] }); + } + match w { + "eqarr" => return self.eq_array(), + "cases" => { + // `&` between cells is a column break: a wide gap, shown as `&` in linear text. + let a = match self.matrix()? { + Node::Matrix { rows } => Node::EqArr { + rows: rows + .into_iter() + .map(|r| { + let mut o = Seq::new(); + for (k, c) in r.into_iter().enumerate() { + if k > 0 { + push_text(&mut o, "\u{2003}"); + } + for n in c { + push_node(&mut o, n); + } + } + o + }) + .collect(), + }, + _ => return None, + }; + return Some(Node::Delim { open: "{".into(), close: String::new(), sep: "|".into(), items: vec![vec![a]] }); + } + "overrightarrow" | "overleftarrow" | "overleftrightarrow" | "underrightarrow" | "underleftarrow" | "underleftrightarrow" => { + let body = self.group()?; + let ch = if w.ends_with("leftrightarrow") { + '\u{2194}' + } else if w.ends_with("rightarrow") { + '\u{2192}' + } else { + '\u{2190}' + }; + return Some(Node::GroupChr { ch, top: w.starts_with("over"), body }); + } + "overline" | "underline" => { + let body = self.group()?; + return Some(Node::Bar { top: w == "overline", body }); + } + "overbrace" | "underbrace" => { + let body = self.group()?; + let top = w == "overbrace"; + return Some(Node::GroupChr { ch: if top { '\u{23DE}' } else { '\u{23DF}' }, top, body }); + } + "box" | "boxed" => { + let body = self.group()?; + return Some(Node::Boxed { border: w == "boxed", body }); + } + _ => {} + } + } + if funcs::is_func(w) && matches!(self.peek(), Some('^' | '_')) { + // `sin^2 x`, `log_2 x`: keep the name whole so the script attaches to it. + return Some(Node::Styled { text: w.to_string(), style: Style::Plain }); + } + if BARE.contains(&w) && self.depth < MAX_DEPTH { + // `sin x`, `ln 2`, `cos\theta`: an unparenthesised argument. + let save = self.pos; + self.skip_ws(); + if self.peek().is_some_and(|c| c.is_alphanumeric() || c == '\\' || c == '√') { + let body = self.operand(true); + return Some(Node::Func { name: w.to_string(), body }); + } + self.pos = save; + } + None + } + + /// At `\left`, `\bigl`, ... directly before `(`: step over the command. True when it did. + fn skip_sizer(&mut self) -> bool { + if self.peek() != Some('\\') { + return false; + } + let mut j = self.pos + 1; + let mut w = String::new(); + while let Some(c) = self.c.get(j).filter(|c| c.is_ascii_alphabetic()) { + w.push(*c); + j += 1; + } + if matches!(w.as_str(), "left" | "big" | "Big" | "bigl" | "Bigl" | "biggl" | "Biggl") && self.c.get(j) == Some(&'(') { + self.pos = j; + return true; + } + false + } + + fn radical(&mut self) -> Node { + self.skip_ws(); + let mut deg = None; + if self.peek() == Some('[') + && let Some(m) = self.matching(self.pos, '[', ']') + && self.depth < MAX_DEPTH + { + deg = Some(self.sub(self.pos + 1, m).seq()); + self.pos = m + 1; + } + self.skip_ws(); + let body = self.operand(true); + Node::Rad { deg, body } + } + + fn nary(&mut self, op: char) -> Node { + let (mut sub, mut sup) = (None, None); + for _ in 0..2 { + match self.peek() { + Some('_') if sub.is_none() => { + self.pos += 1; + sub = Some(self.atom(false)); + } + Some('^') if sup.is_none() => { + self.pos += 1; + sup = Some(self.atom(false)); + } + _ => break, + } + } + self.skip_ws(); + // `prod_(k=1)^n (1+1/k)`: a parenthesised sum or difference is the operand with its + // parentheses (dropping them would change the meaning); other groups follow the + // linear format and lose theirs (`sum_(i=1)^n (x_i)`). + let close = if self.peek() == Some('(') && self.depth < MAX_DEPTH { self.matching(self.pos, '(', ')') } else { None }; + let keep = close.is_some_and(|m| self.has_top_level(self.pos + 1, m, &['+', '-', '\u{2212}', '='])); + let mut body = if keep { + match self.group() { + Some(inner) => vec![Node::Delim { open: "(".into(), close: ")".into(), sep: "|".into(), items: vec![inner] }], + None => self.operand(true), + } + } else { + self.operand(true) + }; + loop { + // `sum_(i=1)^n 1/i`: a fraction in the body belongs to the sum. + let save = self.pos; + self.skip_ws(); + if self.peek() == Some('/') && seq_depth(&body, MAX_DEPTH) + 1 < MAX_DEPTH { + self.pos += 1; + let den = self.denominator(); + body = vec![Node::Frac { num: body, den }]; + } else { + self.pos = save; + break; + } + } + if matches!(op, '∫' | '∬' | '∭' | '∮') { + // `int_0^1 x^2 dx`: the differential belongs to the integrand. + let save = self.pos; + self.skip_ws(); + if self.peek() == Some('d') && self.at(1).is_some_and(|c| c.is_ascii_alphabetic()) && !self.at(2).is_some_and(|c| c.is_ascii_alphabetic()) + { + let dx: String = [self.at(0), self.at(1)].into_iter().flatten().collect(); + self.pos += 2; + push_node(&mut body, Node::Text(format!(" {dx}"))); + } else { + self.pos = save; + } + } + Node::Nary { op, sub, sup, body } + } + + /// A LaTeX big operator (`\sum_{i=1}^n (i+1)^2`, `\int_a^b f(x)\,dx`): the scripts, then a + /// body that runs to the next relation, sign, comma or closing bracket at this level, so the + /// whole product belongs to the operator (LaTeX has no operand, so this is the reading a + /// person has in mind). + fn nary_latex(&mut self, op: char) -> Node { + let (mut sub, mut sup) = (None, None); + for _ in 0..2 { + self.skip_ws(); + match self.peek() { + Some('_') if sub.is_none() => { + self.pos += 1; + sub = Some(self.atom(false)); + } + Some('^') if sup.is_none() => { + self.pos += 1; + sup = Some(self.atom(false)); + } + _ => break, + } + } + self.skip_ws(); + let from = self.pos; + let end = self.latex_body_end(from); + let mut body = if end > from && self.depth < MAX_DEPTH { trim_seq(self.sub(from, end).seq()) } else { vec![] }; + self.pos = end.max(from); + while body.first().is_some_and(|n| matches!(n, Node::Text(t) if t.trim().is_empty())) { + body.remove(0); + } + Node::Nary { op, sub, sup, body } + } + + /// End (exclusive) of a LaTeX big operator's body that starts at `from`. + fn latex_body_end(&self, from: usize) -> usize { + const STOP: &[&str] = &[ + "le", + "leq", + "ge", + "geq", + "ne", + "neq", + "approx", + "equiv", + "to", + "rightarrow", + "Rightarrow", + "leftrightarrow", + "Leftrightarrow", + "in", + "subset", + "subseteq", + "pm", + "mp", + "times", + "div", + "cdot", + "quad", + "qquad", + "end", + "right", + "sim", + "cong", + "propto", + "mid", + ]; + let mut i = from; + let mut depth = 0usize; + while let Some(&ch) = self.c.get(i) { + match ch { + '(' | '[' | '{' => depth += 1, + ')' | ']' | '}' => { + if depth == 0 { + break; + } + depth -= 1; + } + '+' | '-' | '\u{2212}' | '=' | '<' | '>' | ',' | ';' | '&' if depth == 0 && i > from => break, + '\\' => match self.c.get(i + 1).copied() { + Some('\\') if depth == 0 => break, + Some(c) if c.is_ascii_alphabetic() => { + let name: String = self.c.get(i + 1..).unwrap_or(&[]).iter().take_while(|c| c.is_ascii_alphabetic()).collect(); + if depth == 0 && STOP.contains(&name.as_str()) { + break; + } + i += name.chars().count(); + } + Some(_) => i += 1, + None => {} + }, + _ => {} + } + i += 1; + } + i.min(self.c.len()) + } + + fn matrix(&mut self) -> Option { + if self.depth >= MAX_DEPTH { + return None; + } + let m = self.matching(self.pos, '(', ')')?; + let (from, to) = (self.pos + 1, m); + // Typed form `matrix(a,b;c,d)`: when no `&`/`@` appears at top level, `,` splits + // cells and `;` splits rows. + let formal = self.has_top_level(from, to, &['&', '@']); + let (cs, rs) = if formal { ('&', '@') } else { (',', ';') }; + let rows = self.grid(from, to, cs, rs); + self.pos = m + 1; + Some(Node::Matrix { rows }) + } + + /// True when one of `chars` occurs in `from..to` outside any bracket nesting. + fn has_top_level(&self, from: usize, to: usize, chars: &[char]) -> bool { + let mut depth = 0usize; + for i in from..to { + match self.c.get(i).copied() { + Some('(' | '[' | '{') => depth += 1, + Some(')' | ']' | '}') => depth = depth.saturating_sub(1), + Some(c) if depth == 0 && chars.contains(&c) => return true, + _ => {} + } + } + false + } + + /// Split `from..to` into rows (`rs`) of cells (`cs`) at bracket depth zero. + fn grid(&mut self, from: usize, to: usize, cs: char, rs: char) -> Vec> { + let mut rows: Vec> = vec![]; + let mut row: Vec = vec![]; + let (mut depth, mut start) = (0usize, from); + for i in from..=to { + let ch = if i == to { '\u{0}' } else { self.c.get(i).copied().unwrap_or('\u{0}') }; + match ch { + '(' | '[' | '{' => depth += 1, + ')' | ']' | '}' => depth = depth.saturating_sub(1), + c if depth == 0 && (c == cs || c == rs || i == to) => { + row.push(trim_seq(self.sub(start, i).seq())); + start = i + 1; + if c != cs { + rows.push(std::mem::take(&mut row)); + } + } + _ => {} + } + } + rows + } + + /// `█(a@b)`: lines separated by `@` (cells within a line are concatenated). + fn eq_array(&mut self) -> Option { + match self.matrix()? { + Node::Matrix { rows } => Some(Node::EqArr { rows: rows.into_iter().map(|r| r.into_iter().flatten().collect()).collect() }), + _ => None, + } + } + + /// An atom followed by any scripts, used for radicands and n-ary bodies. + fn operand(&mut self, full_word: bool) -> Seq { + self.operand_ex(full_word, false) + } + + /// Like [`operand`](Self::operand); `coef` lets a number absorb letters stuck to it (`2a`). + fn operand_ex(&mut self, full_word: bool, coef: bool) -> Seq { + if self.depth >= MAX_DEPTH { + return vec![]; + } + self.depth += 1; + let numeric = self.peek().is_some_and(|c| c.is_ascii_digit()); + let mut out = self.atom(full_word); + if coef + && numeric + && matches!(out.as_slice(), [Node::Text(t)] if !t.is_empty() && t.chars().all(|c| c.is_ascii_digit() || c == '.')) + && self.peek().is_some_and(|c| c.is_ascii_alphabetic()) + { + let w = self.word(); + match self.special(&w) { + Some(n) => push_node(&mut out, n), + None => push_text(&mut out, &w), + } + } + while let Some(ch @ ('^' | '_')) = self.peek() { + self.pos += 1; + let operand = self.atom(false); + attach_script(&mut out, ch == '^', operand); + } + self.depth -= 1; + out + } + + /// The right-hand side of `/`: leading spaces are skipped, `2a` is one term. + fn denominator(&mut self) -> Seq { + self.skip_ws(); + let mut d = self.operand_ex(true, true); + if self.peek() == Some('!') && self.at(1) != Some('=') { + self.pos += 1; + push_text(&mut d, "!"); + } + d + } + + /// One atom: a group (delimiters stripped), a number, a word, a command or a single symbol. + fn atom(&mut self, full_word: bool) -> Seq { + let Some(ch) = self.peek() else { return vec![] }; + if ch == '(' || ch == '{' { + let start = self.pos; + let closer = if ch == '(' { ')' } else { '}' }; + return match self.group_with(ch, closer) { + Some(inner) => inner, + None => { + self.pos = start + 1; + vec![Node::Text(ch.to_string())] + } + }; + } + if ch == '\\' { + let mut tmp = Seq::new(); + self.command(&mut tmp); + return tmp; + } + if ch == '√' { + self.pos += 1; + return vec![self.radical()]; + } + let signed = (ch == '+' || ch == '-') && self.at(1).is_some_and(|c| c.is_ascii_digit()); + if ch.is_ascii_digit() || signed { + let mut s = String::new(); + if signed { + s.push(minus(ch)); + self.pos += 1; + } + while let Some(d) = self.peek() { + let dot = d == '.' && self.at(1).is_some_and(|c| c.is_ascii_digit()); + if d.is_ascii_digit() || dot { + s.push(d); + self.pos += 1; + } else { + break; + } + } + return vec![Node::Text(s)]; + } + if (ch == '+' || ch == '-') && self.at(1).is_some_and(|c| c.is_alphabetic()) { + self.pos += 1; + let mut rest = self.atom(false); + let mut out = vec![Node::Text(minus(ch).to_string())]; + out.append(&mut rest); + let mut merged = Seq::new(); + for n in out { + push_node(&mut merged, n); + } + return merged; + } + if ch.is_ascii_alphabetic() { + let start = self.pos; + let w = self.word(); + if let Some(n) = self.special(&w) { + return vec![n]; + } + if full_word { + return vec![Node::Text(w)]; + } + self.pos = start + 1; + return vec![Node::Text(ch.to_string())]; + } + self.pos += 1; + vec![Node::Text(minus(ch).to_string())] + } +} + +/// Remove and return the base a script attaches to. +fn pop_base(out: &mut Seq) -> Seq { + match out.pop() { + None => vec![], + Some(Node::Text(t)) => { + let chars: Vec = t.chars().collect(); + let mut i = chars.len(); + if chars.last().is_some_and(|c| c.is_ascii_digit()) { + while i > 0 && chars.get(i - 1).is_some_and(|c| c.is_ascii_digit() || *c == '.') { + i -= 1; + } + } else { + i = i.saturating_sub(1); + } + let head: String = chars.iter().take(i).collect(); + let tail: String = chars.iter().skip(i).collect(); + if !head.is_empty() { + out.push(Node::Text(head)); + } + vec![Node::Text(tail)] + } + Some(n) => vec![n], + } +} + +fn attach_script(out: &mut Seq, sup: bool, operand: Seq) { + if out.last().is_some_and(|n| node_depth(n, MAX_DEPTH) + 1 >= MAX_DEPTH) { + // Scripts on scripts on scripts...: past the depth cap the mark stays literal. + push_text(out, if sup { "^" } else { "_" }); + for n in operand { + push_node(out, n); + } + return; + } + if let Some(Node::Script { sub: s, sup: p, .. }) = out.last_mut() { + let slot = if sup { p } else { s }; + if slot.is_none() { + *slot = Some(operand); + return; + } + } + // A script on an over/under brace (or arrow) is its label, centred beyond the mark. + if let Some(Node::GroupChr { top, .. }) = out.last() + && *top == sup + { + let base = pop_base(out); + out.push(Node::Limit { lower: !sup, base, lim: operand }); + return; + } + let base = pop_base(out); + let (sub, sup_v) = if sup { (None, Some(operand)) } else { (Some(operand), None) }; + out.push(Node::Script { base, sub, sup: sup_v }); +} + +/// Drop trailing blanks from the last text node (`a / b`: the space before `/` is not part of +/// the numerator and is not kept). +fn trim_trailing_ws(out: &mut Seq) { + if let Some(Node::Text(t)) = out.last_mut() { + let trimmed = t.trim_end().len(); + t.truncate(trimmed); + if t.is_empty() { + out.pop(); + } + } +} + +fn take_numerator(out: &mut Seq) -> Seq { + trim_trailing_ws(out); + match out.pop() { + None => vec![], + Some(Node::Text(t)) => { + let chars: Vec = t.chars().collect(); + let mut i = chars.len(); + if chars.last() == Some(&'!') { + i -= 1; + } + while i > 0 && chars.get(i - 1).is_some_and(|c| c.is_alphanumeric() || *c == '.') { + i -= 1; + } + let head: String = chars.iter().take(i).collect(); + let tail: String = chars.iter().skip(i).collect(); + if !head.is_empty() { + out.push(Node::Text(head)); + } + if tail.is_empty() { vec![] } else { vec![Node::Text(tail)] } + } + Some(Node::Delim { open, close, items, .. }) if open == "(" && close == ")" && items.len() == 1 => { + items.into_iter().next().unwrap_or_default() + } + Some(n) => vec![n], + } +} + +// ---- printing ----------------------------------------------------------------------------- + +/// Print an equation in the linear format. +pub fn to_linear(m: &Math) -> String { + let mut out = String::new(); + seq(&mut out, &m.body, 0); + out +} + +fn lin(s: &Seq, depth: usize) -> String { + let mut o = String::new(); + seq(&mut o, s, depth); + o +} + +fn run_atomic(s: &str) -> bool { + !s.is_empty() && s.chars().all(|c| c.is_alphanumeric() || c == '.') +} + +fn number(s: &str) -> bool { + let t = s.strip_prefix(['+', '-', '\u{2212}']).unwrap_or(s); + !t.is_empty() && t.chars().all(|c| c.is_ascii_digit() || c == '.') && t.chars().next().is_some_and(|c| c.is_ascii_digit()) +} + +fn script_atomic(s: &str) -> bool { + number(s) || (s.chars().count() == 1 && s.chars().all(|c| c.is_alphabetic())) +} + +fn base_atomic(s: &str) -> bool { + (s.chars().count() == 1 && s.chars().all(|c| c.is_alphabetic())) || (!s.is_empty() && s.chars().all(|c| c.is_ascii_digit() || c == '.')) +} + +fn paren(s: String, atomic: bool) -> String { + if atomic { s } else { format!("({s})") } +} + +/// Escape characters the parser treats as syntax in literal text. +fn esc(s: &str) -> String { + let mut o = String::with_capacity(s.len()); + for c in s.chars() { + if matches!(c, '\\' | '{' | '}') { + o.push('\\'); + } + o.push(c); + } + o +} + +/// Would the node, printed right after an unparenthesised denominator, fuse with it? +fn starts_alnum(n: Option<&Node>, depth: usize) -> bool { + match n.map(Node::bare) { + Some(Node::Text(t)) | Some(Node::Styled { text: t, .. }) => t.chars().next().is_some_and(|c| c.is_alphanumeric() || c == '.'), + Some(Node::Func { .. }) | Some(Node::Limit { .. }) => true, + Some(Node::Script { base, .. }) if depth < MAX_DEPTH => starts_alnum(base.first(), depth + 1), + _ => false, + } +} + +/// Visible text inside an unknown element, so a `Raw` node still reads as something. +pub(crate) fn raw_text(src: &str) -> String { + fn walk(e: &XEl, out: &mut String, d: usize) { + if d > MAX_DEPTH + 16 { + return; + } + if e.local() == "t" { + out.push_str(&e.text()); + } else { + for c in e.els() { + walk(c, out, d + 1); + } + } + } + let mut s = String::new(); + for r in &xml::parse(src) { + walk(r, &mut s, 0); + } + s +} + +fn limits(out: &mut String, sub: &Option, sup: &Option, d: usize) { + for (mark, part) in [('_', sub), ('^', sup)] { + if let Some(s) = part { + let t = lin(s, d); + let a = script_atomic(&t); + out.push(mark); + out.push_str(&paren(t, a)); + } + } +} + +fn seq(out: &mut String, s: &Seq, depth: usize) { + if depth > MAX_DEPTH { + return; + } + let d = depth + 1; + for (i, n) in s.iter().enumerate() { + match n.bare() { + Node::Props { .. } => {} + Node::Text(t) => out.push_str(&esc(t)), + Node::Styled { text, .. } => out.push_str(&esc(text)), + Node::Frac { num, den } => { + let (a, b) = (lin(num, d), lin(den, d)); + let glued = out.chars().last().is_some_and(|c| c.is_alphanumeric() || c == '.'); + let na = run_atomic(&a) && !glued; + out.push_str(&paren(a, na)); + out.push('/'); + let da = run_atomic(&b) && !starts_alnum(s.get(i + 1), 0); + out.push_str(&paren(b, da)); + } + Node::Rad { deg, body } => { + out.push('√'); + if let Some(dg) = deg { + out.push('['); + out.push_str(&lin(dg, d)); + out.push(']'); + } + out.push_str(&format!("({})", lin(body, d))); + } + Node::Script { base, sub, sup } => { + let b = lin(base, d); + let ba = base_atomic(&b) + || matches!(base.as_slice(), [Node::Delim { .. }]) + || matches!(base.as_slice(), [Node::Styled { text, .. }] if funcs::is_func(text)); + out.push_str(&paren(b, ba)); + limits(out, sub, sup, d); + } + Node::Nary { op, sub, sup, body } => { + if *op != '\0' { + out.push(*op); + } + limits(out, sub, sup, d); + out.push_str(&format!("({})", lin(body, d))); + } + Node::Delim { open, close, items, .. } + if open == "{" && close.is_empty() && matches!(items.as_slice(), [it] if matches!(it.as_slice(), [Node::EqArr { .. }])) => + { + let rows = match items.first().and_then(|it| it.first()) { + Some(Node::EqArr { rows }) => rows.iter().map(|r| lin(r, d).replace('\u{2003}', "&")).collect::>(), + _ => vec![], + }; + out.push_str(&format!("cases({})", rows.join("@"))); + } + Node::Delim { open, close, sep, items } => { + out.push_str(&esc(open)); + let parts: Vec = items.iter().map(|i| lin(i, d)).collect(); + out.push_str(&parts.join(&esc(sep))); + out.push_str(&esc(close)); + } + Node::Matrix { rows } => { + let rs: Vec = rows.iter().map(|r| r.iter().map(|c| lin(c, d)).collect::>().join("&")).collect(); + out.push_str(&format!("■({})", rs.join("@"))); + } + Node::EqArr { rows } => { + let rs: Vec = rows.iter().map(|r| lin(r, d)).collect(); + out.push_str(&format!("█({})", rs.join("@"))); + } + Node::Func { name, body } => out.push_str(&format!("{name}({})", lin(body, d))), + Node::Limit { lower, base, lim } => { + let b = lin(base, d); + let t = lin(lim, d); + let a = script_atomic(&t); + if *lower && matches!(b.as_str(), "lim" | "max" | "min") { + out.push_str(&b); + out.push('_'); + } else { + // No linear spelling for other bases: print it as a script. An over/under + // brace or arrow is already a call, so it needs no parentheses. + let marked = matches!(base.as_slice(), [Node::GroupChr { top, .. }] if *top != *lower); + out.push_str(&if marked { b.clone() } else { paren(b.clone(), base_atomic(&b)) }); + out.push(if *lower { '_' } else { '^' }); + } + out.push_str(&paren(t, a)); + } + Node::PreScript { sub, sup, base } => { + // No linear spelling: the scripts are written first, in script syntax. + out.push('_'); + out.push_str(&format!("({})", lin(sub, d))); + out.push('^'); + out.push_str(&format!("({})", lin(sup, d))); + out.push_str(&lin(base, d)); + } + Node::Bar { top, body } => out.push_str(&format!("{}({})", if *top { "overline" } else { "underline" }, lin(body, d))), + Node::GroupChr { ch, top, body } => match (*ch, *top) { + ('\u{2192}', t) => out.push_str(&format!("{}rightarrow({})", if t { "over" } else { "under" }, lin(body, d))), + ('\u{2190}', t) => out.push_str(&format!("{}leftarrow({})", if t { "over" } else { "under" }, lin(body, d))), + ('\u{2194}', t) => out.push_str(&format!("{}leftrightarrow({})", if t { "over" } else { "under" }, lin(body, d))), + ('\u{23DE}', true) => out.push_str(&format!("overbrace({})", lin(body, d))), + ('\u{23DF}', false) => out.push_str(&format!("underbrace({})", lin(body, d))), + _ => out.push_str(&lin(body, d)), + }, + Node::Boxed { border, body } => out.push_str(&format!("{}({})", if *border { "boxed" } else { "box" }, lin(body, d))), + Node::Accent { ch, body } => match ACCENTS.iter().find(|(_, c)| c == ch) { + Some((name, _)) => out.push_str(&format!("{name}({})", lin(body, d))), + None => { + out.push_str(&lin(body, d)); + out.push(*ch); + } + }, + Node::Raw(src) if src == crate::OMATH_BREAK => out.push(' '), + // Argument properties (`m:argPr`) carry no visible content. + Node::Raw(src) if src.starts_with(" {} + Node::Raw(src) => { + let t = raw_text(src); + if t.is_empty() { + out.push('□'); + } else { + out.push_str(&esc(&t)); + } + } + } + } +} + +/// Tree depth for tests (bounded walk). +#[cfg(test)] +pub(crate) fn seq_depth_for_tests(s: &[Node]) -> usize { + seq_depth(s, 100_000) +} diff --git a/crates/equashion-math/src/mathml.rs b/crates/equashion-math/src/mathml.rs new file mode 100644 index 00000000..bab17eaa --- /dev/null +++ b/crates/equashion-math/src/mathml.rs @@ -0,0 +1,1103 @@ +//! Presentation MathML (MathML 3 / MathML Core) writer and reader. +//! +//! The writer produces a standalone `` element: +//! well-formed XML, no XML declaration, no DOCTYPE, no prefix, and no named entities (`<`, `&` +//! and `>` are written as numeric character references, everything else as raw UTF-8). That is +//! the shape Word, LibreOffice and ONLYOFFICE convert into an equation when it is pasted as text. +//! +//! | Tree | MathML | +//! |---|---| +//! | text | one `mi` per letter, `mn` per number, `mo` per operator, `mspace` per typed space | +//! | `Styled` | `mi` (`mathvariant` normal, bold, bold-italic) or `mtext` | +//! | `Frac` | `mfrac` | +//! | `Rad` | `msqrt` or `mroot` | +//! | `Script` | `msub`, `msup`, `msubsup` | +//! | `Nary` | `munderover` (`msubsup` for integrals) around the operator `mo`, then the body | +//! | `Delim` | `mrow` with `mo fence="true" stretchy="true"` at both ends and `mo separator="true"` between items (never `mfenced`) | +//! | `Matrix`, `EqArr` | `mtable` / `mtr` / `mtd` (`columnalign="left"` marks an equation array; cells split at an em space) | +//! | `Func` | `mi` name, `mo` U+2061 (function application), argument | +//! | `Accent` | `mover accent="true"` with the combining mark (U+0302 and so on), as OMML stores it | +//! | `Limit` | `munder` / `mover`, then U+2061 (see `FN_APPLY`) | +//! | `Bar`, `GroupChr` | `mover` / `munder` with a stretchy `mo` (a bar is U+00AF over or under) | +//! | `PreScript` | `mmultiscripts` with `mprescripts` | +//! | `Boxed` | `menclose notation="box"`, or `mpadded` for an invisible box | +//! +//! The reader takes the same constructs (and `mfenced`, `semantics`, `mstyle`, prefixed names +//! such as `mml:math`), and is bounded: input size, XML depth and element count are capped, and +//! hostile input never panics. + +use crate::xml::{self, XEl, XNode}; +use crate::{MAX_DEPTH, Math, Node, OMATH_BREAK, Seq, Style}; + +/// The MathML namespace. +pub const MATHML_NS: &str = "http://www.w3.org/1998/Math/MathML"; + +/// Largest MathML text [`from_mathml`] reads, bytes. +pub const MAX_MATHML: usize = 1 << 20; + +/// Why [`from_mathml`] gave no equation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MathmlError { + /// Nothing but whitespace. + Empty, + /// Longer than [`MAX_MATHML`] bytes. + TooLarge, + /// No `` element in the text. + NoMath, +} + +impl std::fmt::Display for MathmlError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + MathmlError::Empty => "empty MathML", + MathmlError::TooLarge => "MathML too large", + MathmlError::NoMath => "no element", + }) + } +} + +impl std::error::Error for MathmlError {} + +/// Combining accent marks and the spacing characters MathML puts in the `mo` over the base. +const ACCENT_MARKS: &[(char, char)] = &[ + ('\u{0302}', '^'), + ('\u{0303}', '~'), + ('\u{0305}', '\u{00AF}'), + ('\u{0307}', '\u{02D9}'), + ('\u{0308}', '\u{00A8}'), + ('\u{20D7}', '\u{2192}'), + ('\u{0306}', '\u{02D8}'), + ('\u{030C}', '\u{02C7}'), + ('\u{0301}', '\u{00B4}'), + ('\u{0300}', '`'), +]; + +/// Typed spaces and their `mspace` widths. The first entry for a width is the one read back. +const SPACES: &[(char, &str, f32)] = &[ + ('\u{2009}', "0.1667em", 0.1667), + ('\u{2005}', "0.2222em", 0.2222), + ('\u{2004}', "0.2778em", 0.2778), + ('\u{2001}', "1em", 1.0), + ('\u{2002}', "0.5em", 0.5), + ('\u{200A}', "0.0833em", 0.0833), + ('\u{00A0}', "0.25em", 0.25), + ('\u{2003}', "1em", 1.0), + ('\u{2006}', "0.1667em", 0.1667), + ('\u{2008}', "0.2222em", 0.2222), +]; + +/// An invisible function application after an `munder` / `mover` that ONLYOFFICE reads as a +/// limit: its importer gives such a limit the next sibling as an argument and drops it, and this +/// is the sibling it takes (Word writes `lim` the same way, as a function name). +const FN_APPLY: &str = "⁡"; + +/// Grouping marks that ONLYOFFICE reads as `m:groupChr` (no `FN_APPLY` needed after them). +const BRACES: &[char] = &['\u{23DE}', '\u{23DF}', '\u{23DC}', '\u{23DD}', '\u{23B4}', '\u{23B5}', '\u{00AF}']; + +/// Characters drawn as stretchy grouping marks over or under a base (brace, bracket, arrows). +const GROUP_CHARS: &[char] = + &['\u{23DE}', '\u{23DF}', '\u{23B4}', '\u{23B5}', '\u{23DC}', '\u{23DD}', '\u{2190}', '\u{2192}', '\u{2194}', '\u{21D0}', '\u{21D2}', '\u{21D4}']; + +const OPEN_FENCES: &[&str] = &["(", "[", "{", "|", "\u{2016}", "\u{27E8}", "\u{2329}", "\u{230A}", "\u{2308}", "\u{27E6}"]; +const CLOSE_FENCES: &[&str] = &[")", "]", "}", "|", "\u{2016}", "\u{27E9}", "\u{232A}", "\u{230B}", "\u{2309}", "\u{27E7}"]; + +/// Big operators read as n-ary operators. +fn is_nary_char(c: char) -> bool { + matches!(c, '\u{2211}' | '\u{220F}' | '\u{2210}' | '\u{222B}'..='\u{2233}' | '\u{22C0}'..='\u{22C3}' | '\u{2A00}'..='\u{2A0C}') +} + +fn is_fence_char(c: char) -> bool { + let mut b = [0u8; 4]; + let s: &str = c.encode_utf8(&mut b); + OPEN_FENCES.contains(&s) || CLOSE_FENCES.contains(&s) +} + +fn is_integral(c: char) -> bool { + matches!(c, '\u{222B}'..='\u{2233}') +} + +// ---- writer ------------------------------------------------------------------------------- + +/// The equation as a MathML `` element; `display="block"` when it is a display equation +/// (`m.para`), `display="inline"` otherwise. +pub fn to_mathml(m: &Math) -> String { + to_mathml_display(m, m.para) +} + +/// The equation as a MathML `` element with `display="block"` (`block`) or +/// `display="inline"`. The text starts exactly with ` String { + let mut out = String::with_capacity(256); + out.push_str(""); + let mut body = String::new(); + let n = seq(&mut body, &m.body, 0); + // One top-level row, so programs that expect a single child of `math` get one. + if n == 1 { + out.push_str(&body); + } else if n > 1 { + out.push_str(""); + out.push_str(&body); + out.push_str(""); + } + out.push_str(""); + out +} + +/// XML text with `<`, `&` and `>` as numeric references (never named entities). +fn esc(s: &str, out: &mut String) { + for c in s.chars() { + if !xml::xml_ok(c) { + continue; + } + match c { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + '\r' => out.push_str(" "), + _ => out.push(c), + } + } +} + +fn token(out: &mut String, tag: &str, attrs: &str, text: &str) { + out.push('<'); + out.push_str(tag); + out.push_str(attrs); + out.push('>'); + esc(text, out); + out.push_str("'); +} + +/// Write a sequence; returns how many top-level elements were written. +fn seq(out: &mut String, s: &[Node], d: usize) -> usize { + if d > MAX_DEPTH { + return 0; + } + s.iter().map(|n| node(out, n.bare(), d + 1)).sum() +} + +/// One argument of a layout element (exactly one element: bare, or an `mrow`). +fn arg(out: &mut String, s: &[Node], d: usize) { + let mut tmp = String::new(); + if seq(&mut tmp, s, d) == 1 { + out.push_str(&tmp); + } else { + out.push_str(""); + out.push_str(&tmp); + out.push_str(""); + } +} + +fn open(out: &mut String, tag: &str, attrs: &str) { + out.push('<'); + out.push_str(tag); + out.push_str(attrs); + out.push('>'); +} + +fn close(out: &mut String, tag: &str) { + out.push_str("'); +} + +/// Plain text: one token per letter, number, operator or typed space. ASCII spaces separate +/// tokens and are not written (MathML spaces by operator, not by typed blanks). +fn text(out: &mut String, t: &str) -> usize { + let cs: Vec = t.chars().filter(|&c| xml::xml_ok(c)).collect(); + let mut n = 0; + let mut i = 0; + while i < cs.len() { + let c = cs[i]; + if let Some((_, w, _)) = SPACES.iter().find(|(s, _, _)| *s == c) { + out.push_str(""); + n += 1; + i += 1; + continue; + } + if c.is_whitespace() { + i += 1; + continue; + } + if c.is_ascii_digit() { + let start = i; + i += 1; + while i < cs.len() && (cs[i].is_ascii_digit() || (cs[i] == '.' && cs.get(i + 1).is_some_and(char::is_ascii_digit))) { + i += 1; + } + let num: String = cs[start..i].iter().collect(); + token(out, "mn", "", &num); + n += 1; + continue; + } + let s = c.to_string(); + if c.is_alphabetic() || matches!(c, '\u{221E}' | '\u{2202}' | '\u{2207}' | '\u{210F}' | '\u{2113}' | '\u{2135}' | '\u{2205}') { + token(out, "mi", "", &s); + } else if is_nary_char(c) { + // A big-operator character inside text is not an n-ary structure. + token(out, "mo", " largeop=\"false\"", &s); + } else if is_fence_char(c) { + // Typed brackets do not grow; `\left..\right` pairs are `Delim`. + token(out, "mo", " stretchy=\"false\"", &s); + } else { + token(out, "mo", "", &s); + } + n += 1; + i += 1; + } + n +} + +fn styled(out: &mut String, t: &str, style: Style) -> usize { + if t.is_empty() { + return 0; + } + match style { + Style::Normal => { + // Token content is trimmed by MathML renderers: keep edge spaces as no-break spaces. + let lead = t.len() - t.trim_start_matches(' ').len(); + let core = t.trim_matches(' '); + let trail = if core.is_empty() { 0 } else { t.len() - t.trim_end_matches(' ').len() }; + let s = format!("{}{}{}", "\u{00A0}".repeat(lead), core, "\u{00A0}".repeat(trail)); + token(out, "mtext", "", &s); + } + Style::Plain => { + let word = t.chars().count() > 1 && t.chars().all(char::is_alphabetic); + token(out, "mi", if word { "" } else { " mathvariant=\"normal\"" }, t); + } + Style::Bold => token(out, "mi", " mathvariant=\"bold\"", t), + Style::BoldItalic => token(out, "mi", " mathvariant=\"bold-italic\"", t), + } + 1 +} + +/// Split an equation-array row into cells at em spaces in its text. +fn cells(row: &[Node]) -> Vec { + let mut out = vec![Seq::new()]; + for n in row { + match n.bare() { + Node::Text(t) if t.contains('\u{2003}') => { + for (i, part) in t.split('\u{2003}').enumerate() { + if i > 0 { + out.push(Seq::new()); + } + if !part.is_empty() + && let Some(c) = out.last_mut() + { + c.push(Node::Text(part.to_string())); + } + } + } + other => { + if let Some(c) = out.last_mut() { + c.push(other.clone()); + } + } + } + } + out +} + +fn node(out: &mut String, n: &Node, d: usize) -> usize { + match n { + Node::Props { node: inner, .. } => node(out, inner.bare(), d), + Node::Text(t) => text(out, t), + Node::Styled { text: t, style } => styled(out, t, *style), + Node::Frac { num, den } => { + open(out, "mfrac", ""); + arg(out, num, d); + arg(out, den, d); + close(out, "mfrac"); + 1 + } + Node::Rad { deg: None, body } => { + open(out, "msqrt", ""); + seq(out, body, d); + close(out, "msqrt"); + 1 + } + Node::Rad { deg: Some(deg), body } => { + open(out, "mroot", ""); + arg(out, body, d); + arg(out, deg, d); + close(out, "mroot"); + 1 + } + Node::Script { base, sub, sup } => { + let tag = match (sub, sup) { + (None, None) => return seq(out, base, d), + (Some(_), None) => "msub", + (None, Some(_)) => "msup", + (Some(_), Some(_)) => "msubsup", + }; + open(out, tag, ""); + arg(out, base, d); + for s in [sub, sup].into_iter().flatten() { + arg(out, s, d); + } + close(out, tag); + 1 + } + Node::Nary { op, sub, sup, body } => { + open(out, "mrow", ""); + let mut opx = String::new(); + let known = is_nary_char(*op); + let s = if *op == '\0' { String::new() } else { op.to_string() }; + token(&mut opx, "mo", if known { "" } else { " largeop=\"true\"" }, &s); + let side = is_integral(*op); + let tag = match (sub, sup) { + (None, None) => None, + (Some(_), None) => Some(if side { "msub" } else { "munder" }), + (None, Some(_)) => Some(if side { "msup" } else { "mover" }), + (Some(_), Some(_)) => Some(if side { "msubsup" } else { "munderover" }), + }; + match tag { + None => out.push_str(&opx), + Some(tag) => { + open(out, tag, ""); + out.push_str(&opx); + for s in [sub, sup].into_iter().flatten() { + arg(out, s, d); + } + close(out, tag); + } + } + arg(out, body, d); + close(out, "mrow"); + 1 + } + Node::Delim { open: o, close: c, sep, items } => { + open(out, "mrow", ""); + token(out, "mo", " fence=\"true\" stretchy=\"true\"", o); + if items.is_empty() { + out.push_str(""); + } + for (i, it) in items.iter().enumerate() { + if i > 0 { + token(out, "mo", " separator=\"true\"", sep); + } + arg(out, it, d); + } + token(out, "mo", " fence=\"true\" stretchy=\"true\"", c); + close(out, "mrow"); + 1 + } + Node::Matrix { rows } => { + open(out, "mtable", ""); + for r in rows { + open(out, "mtr", ""); + for cell in r { + open(out, "mtd", ""); + seq(out, cell, d); + close(out, "mtd"); + } + close(out, "mtr"); + } + close(out, "mtable"); + 1 + } + Node::EqArr { rows } => { + open(out, "mtable", " columnalign=\"left\""); + for r in rows { + open(out, "mtr", ""); + for cell in cells(r) { + open(out, "mtd", ""); + seq(out, &cell, d); + close(out, "mtd"); + } + close(out, "mtr"); + } + close(out, "mtable"); + 1 + } + Node::Func { name, body } => { + open(out, "mrow", ""); + let one = name.chars().count() == 1; + token(out, "mi", if one { " mathvariant=\"normal\"" } else { "" }, name); + out.push_str("⁡"); + arg(out, body, d); + close(out, "mrow"); + 1 + } + Node::Accent { ch, body } => { + // The combining mark itself (as OMML `m:acc` stores it): ONLYOFFICE turns that into an + // accent, while a spacing `^` becomes an upper limit there. + open(out, "mover", " accent=\"true\""); + arg(out, body, d); + token(out, "mo", " stretchy=\"false\"", &ch.to_string()); + close(out, "mover"); + 1 + } + Node::Limit { lower, base, lim } => { + let tag = if *lower { "munder" } else { "mover" }; + open(out, "mrow", ""); + open(out, tag, ""); + arg(out, base, d); + arg(out, lim, d); + close(out, tag); + out.push_str(FN_APPLY); + close(out, "mrow"); + 1 + } + Node::PreScript { sub, sup, base } => { + open(out, "mmultiscripts", ""); + arg(out, base, d); + out.push_str(""); + arg(out, sub, d); + arg(out, sup, d); + close(out, "mmultiscripts"); + 1 + } + Node::Bar { top, body } => { + let (tag, attrs) = if *top { ("mover", " accent=\"true\"") } else { ("munder", " accentunder=\"true\"") }; + let mark = "\u{00AF}"; + open(out, tag, attrs); + arg(out, body, d); + token(out, "mo", " stretchy=\"true\"", mark); + close(out, tag); + 1 + } + Node::GroupChr { ch, top, body } => { + let tag = if *top { "mover" } else { "munder" }; + let brace = BRACES.contains(ch); + if !brace { + open(out, "mrow", ""); + } + open(out, tag, ""); + arg(out, body, d); + token(out, "mo", " stretchy=\"true\"", &ch.to_string()); + close(out, tag); + if !brace { + out.push_str(FN_APPLY); + close(out, "mrow"); + } + 1 + } + Node::Boxed { border, body } => { + let (tag, attrs) = if *border { ("menclose", " notation=\"box\"") } else { ("mpadded", "") }; + open(out, tag, attrs); + seq(out, body, d); + close(out, tag); + 1 + } + Node::Raw(src) if src == OMATH_BREAK || src.starts_with(&OMATH_BREAK[..OMATH_BREAK.len() - 1]) => { + out.push_str(""); + 1 + } + Node::Raw(src) => { + let t = crate::linear::raw_text(src); + if t.is_empty() { + 0 + } else { + token(out, "mtext", "", &t); + 1 + } + } + } +} + +// ---- reader ------------------------------------------------------------------------------- + +/// The `` element of `src` (any prefix), from its start tag to its end tag (or the end of +/// the text), so MathML pasted inside an HTML page or with an XML declaration still reads. +fn math_slice(src: &str) -> Option<&str> { + let b = src.as_bytes(); + let name_char = |c: u8| c.is_ascii_alphanumeric() || matches!(c, b'_' | b'-' | b'.' | b':'); + let mut start = None; + let mut i = 0; + while let Some(off) = src[i..].find('<') { + let at = i + off; + let mut j = at + 1; + while j < b.len() && name_char(b[j]) { + j += 1; + } + let name = &src[at + 1..j]; + let local = name.rsplit(':').next().unwrap_or(name); + if local == "math" && (j == b.len() || matches!(b[j], b' ' | b'\t' | b'\n' | b'\r' | b'>' | b'/')) { + start = Some((at, name.to_string())); + break; + } + i = at + 1; + } + let (at, name) = start?; + let end_tag = format!("').map_or(src.len(), |g| e + g + 1) + }); + Some(&src[at..end.unwrap_or(src.len())]) +} + +fn find_math(els: &[XEl], d: usize) -> Option<&XEl> { + if d > crate::XML_DEPTH { + return None; + } + for e in els { + if e.local() == "math" { + return Some(e); + } + if let Some(m) = e.children.iter().find_map(|c| match c { + XNode::El(x) => find_math(std::slice::from_ref(x), d + 1), + XNode::Text(_) => None, + }) { + return Some(m); + } + } + None +} + +/// Read MathML: a `` element (any namespace prefix, `display="block"` gives a display +/// equation), possibly inside other markup. Unknown elements give their text. Never panics. +pub fn from_mathml(src: &str) -> Result { + if src.trim().is_empty() { + return Err(MathmlError::Empty); + } + if src.len() > MAX_MATHML { + return Err(MathmlError::TooLarge); + } + let slice = math_slice(src).ok_or(MathmlError::NoMath)?; + let roots = xml::parse(slice); + let root = find_math(&roots, 0).ok_or(MathmlError::NoMath)?; + let para = root.attr_local("display") == Some("block") || root.attr_local("mode") == Some("display"); + Ok(Math { body: read_seq(root.els().collect(), 0), para, ..Default::default() }) +} + +/// Append a node, merging text into the text before it. +fn push(out: &mut Seq, n: Node) { + match (out.last_mut(), n) { + (_, Node::Text(t)) if t.is_empty() => {} + (Some(Node::Text(a)), Node::Text(b)) => a.push_str(&b), + (Some(Node::Styled { text: a, style: sa }), Node::Styled { text: b, style: sb }) if *sa == sb => a.push_str(&b), + (_, n) => out.push(n), + } +} + +fn token_text(e: &XEl) -> String { + e.text().trim_matches(|c: char| matches!(c, ' ' | '\t' | '\n' | '\r')).to_string() +} + +fn attr<'a>(e: &'a XEl, name: &str) -> Option<&'a str> { + e.attrs.iter().find(|(k, _)| k == name).map(|(_, v)| v.trim()) +} + +fn is_true(e: &XEl, name: &str) -> bool { + attr(e, name) == Some("true") +} + +/// A grouping element whose children belong to the surrounding row. +fn is_group(e: &XEl) -> bool { + match e.local() { + "mrow" => !is_delim_row(e), + "mstyle" | "maction" | "merror" => true, + "mpadded" => e.attrs.iter().any(|(k, _)| k != "xmlns" && !k.starts_with("xmlns:")), + _ => false, + } +} + +/// Children with `semantics`, `maction` and annotations looked through. Grouping rows are +/// read by `read_el`, so an n-ary operator only takes its body from its own row. +fn expand<'a>(els: Vec<&'a XEl>, d: usize, out: &mut Vec<&'a XEl>) { + if d > crate::XML_DEPTH { + return; + } + for e in els { + match e.local() { + "semantics" => expand(e.els().take(1).collect(), d + 1, out), + "annotation" | "annotation-xml" | "mphantom" | "none" | "mprescripts" => {} + "maction" => expand(e.els().take(1).collect(), d + 1, out), + _ => out.push(e), + } + } +} + +fn single_char(s: &str) -> Option { + let mut it = s.chars(); + let c = it.next()?; + it.next().is_none().then_some(c) +} + +/// The operator of an n-ary structure: an `mo` holding a big operator (or marked `largeop`). +fn nary_op(e: &XEl) -> Option { + if e.local() != "mo" || attr(e, "largeop") == Some("false") { + return None; + } + let t = token_text(e); + if is_true(e, "largeop") { + return match t.chars().count() { + 0 => Some('\0'), + 1 => single_char(&t), + _ => None, + }; + } + single_char(&t).filter(|&c| is_nary_char(c)) +} + +/// An n-ary operator with optional limits: (op, sub, sup). +type NaryHead = (char, Option, Option); + +fn nary_head(e: &XEl, d: usize) -> Option { + if let Some(op) = nary_op(e) { + return Some((op, None, None)); + } + let kids: Vec<&XEl> = e.els().collect(); + let op = nary_op(kids.first()?)?; + let a = |i: usize| kids.get(i).map(|k| read_arg(k, d)).unwrap_or_default(); + Some(match e.local() { + "msub" | "munder" => (op, Some(a(1)), None), + "msup" | "mover" => (op, None, Some(a(1))), + "msubsup" | "munderover" => (op, Some(a(1)), Some(a(2))), + _ => return None, + }) +} + +fn read_arg(e: &XEl, d: usize) -> Seq { + read_seq(vec![e], d + 1) +} + +fn read_seq(els: Vec<&XEl>, d: usize) -> Seq { + let mut out = Seq::new(); + if d > 2 * MAX_DEPTH + 16 { + return out; + } + let mut v = Vec::new(); + expand(els, d, &mut v); + let mut i = 0; + while i < v.len() { + let e = v[i]; + // Function application: name, U+2061, argument. + if e.local() == "mi" && v.get(i + 1).is_some_and(|n| n.local() == "mo" && token_text(n) == "\u{2061}") { + let body = v.get(i + 2).map(|b| read_arg(b, d)).unwrap_or_default(); + out.push(Node::Func { name: token_text(e), body }); + i += 3; + continue; + } + if let Some((op, sub, sup)) = nary_head(e, d) { + let body = v.get(i + 1).map(|b| read_arg(b, d)).unwrap_or_default(); + out.push(Node::Nary { op, sub, sup, body }); + i += 2; + continue; + } + read_el(e, d, &mut out); + i += 1; + } + out +} + +fn variant_style(e: &XEl) -> Option> { + match attr(e, "mathvariant") { + Some("normal") => Some(Some(Style::Plain)), + Some("bold") => Some(Some(Style::Bold)), + Some("bold-italic") => Some(Some(Style::BoldItalic)), + Some(_) => Some(None), + None => None, + } +} + +fn double_struck(c: char) -> char { + match c { + 'C' => '\u{2102}', + 'H' => '\u{210D}', + 'N' => '\u{2115}', + 'P' => '\u{2119}', + 'Q' => '\u{211A}', + 'R' => '\u{211D}', + 'Z' => '\u{2124}', + c => c, + } +} + +fn space_char(e: &XEl) -> Option { + let w = attr(e, "width")?; + let em = match w { + "veryverythinmathspace" => 1.0 / 18.0, + "verythinmathspace" => 2.0 / 18.0, + "thinmathspace" => 3.0 / 18.0, + "mediummathspace" => 4.0 / 18.0, + "thickmathspace" => 5.0 / 18.0, + "verythickmathspace" => 6.0 / 18.0, + "veryverythickmathspace" => 7.0 / 18.0, + _ => { + let (num, unit) = w.split_at(w.find(|c: char| c.is_ascii_alphabetic() || c == '%').unwrap_or(w.len())); + let v: f32 = num.trim().parse().ok()?; + match unit.trim() { + "em" => v, + "ex" => v * 0.5, + "mu" => v / 18.0, + "px" => v / 16.0, + "pt" => v / 12.0, + _ => return None, + } + } + }; + if !(em.is_finite() && em > 0.02) { + return None; + } + let mut best: Option<(char, f32)> = None; + for (c, _, w) in SPACES { + let diff = (w - em).abs(); + if best.is_none_or(|(_, b)| diff < b - 1e-4) { + best = Some((*c, diff)); + } + } + best.map(|(c, _)| c) +} + +fn is_delim_row(e: &XEl) -> bool { + if e.local() != "mrow" { + return false; + } + let kids: Vec<&XEl> = e.els().collect(); + let (Some(first), Some(last)) = (kids.first(), kids.last()) else { return false }; + if kids.len() < 2 || first.local() != "mo" || last.local() != "mo" { + return false; + } + let fence = |m: &XEl, set: &[&str]| is_true(m, "fence") || (attr(m, "stretchy") != Some("false") && set.contains(&token_text(m).as_str())); + fence(first, OPEN_FENCES) && fence(last, CLOSE_FENCES) +} + +/// `(open, close, sep, items)` from the children between the fences. +fn delim(open: String, close: String, inner: &[&XEl], seps: Option<&str>, d: usize) -> Node { + let mut items: Vec = Vec::new(); + let mut cur: Vec<&XEl> = Vec::new(); + let mut sep: Option = None; + for k in inner { + let is_sep = + k.local() == "mo" && (is_true(k, "separator") || seps.is_some_and(|s| s.contains(token_text(k).as_str()) && !token_text(k).is_empty())); + if is_sep { + sep.get_or_insert_with(|| token_text(k)); + items.push(read_seq(std::mem::take(&mut cur), d + 1)); + } else { + cur.push(k); + } + } + items.push(read_seq(cur, d + 1)); + Node::Delim { open, close, sep: sep.unwrap_or_else(|| "|".into()), items } +} + +fn read_el(e: &XEl, d: usize, out: &mut Seq) { + let kids: Vec<&XEl> = e.els().collect(); + let a = |i: usize| kids.get(i).map(|k| read_arg(k, d)).unwrap_or_default(); + // A single `mo` script mark: (char, stretchy attribute). + let mark = |i: usize| -> Option<(char, Option<&str>)> { + let k = kids.get(i)?; + if k.local() != "mo" { + return None; + } + single_char(&token_text(k)).map(|c| (c, attr(k, "stretchy"))) + }; + match e.local() { + "mi" | "mn" | "mo" => { + let t = token_text(e); + if t.is_empty() || (e.local() == "mo" && matches!(t.as_str(), "\u{2061}" | "\u{2062}" | "\u{2063}" | "\u{2064}")) { + return; + } + let style = match variant_style(e) { + Some(s) => s, + None if e.local() == "mi" && t.chars().count() > 1 => Some(Style::Plain), + None => None, + }; + if attr(e, "mathvariant") == Some("double-struck") { + return push(out, Node::Text(t.chars().map(double_struck).collect())); + } + match style { + Some(style) => push(out, Node::Styled { text: t, style }), + None => push(out, Node::Text(t)), + } + } + "mtext" | "ms" => { + let raw = e.text(); + let t = raw.trim_matches(|c: char| matches!(c, '\t' | '\n' | '\r')); + let lead = t.chars().take_while(|&c| c == '\u{00A0}').count(); + let trail = if t.chars().all(|c| c == '\u{00A0}') { 0 } else { t.chars().rev().take_while(|&c| c == '\u{00A0}').count() }; + let core: String = t.chars().skip(lead).take(t.chars().count().saturating_sub(lead + trail)).collect(); + let s = format!("{}{}{}", " ".repeat(lead), core, " ".repeat(trail)); + if !s.is_empty() { + push(out, Node::Styled { text: s, style: Style::Normal }); + } + } + "mspace" => { + if matches!(attr(e, "linebreak"), Some("newline" | "indentingnewline")) { + out.push(Node::Raw(OMATH_BREAK.to_string())); + } else if let Some(c) = space_char(e) { + push(out, Node::Text(c.to_string())); + } + } + "mrow" if is_delim_row(e) => { + let inner = &kids[1..kids.len().saturating_sub(1).max(1)]; + let (Some(f), Some(l)) = (kids.first(), kids.last()) else { return }; + out.push(delim(token_text(f), token_text(l), inner, None, d)); + } + "mfenced" => { + let open = attr(e, "open").unwrap_or("(").to_string(); + let close = attr(e, "close").unwrap_or(")").to_string(); + let seps: String = attr(e, "separators").unwrap_or(",").chars().filter(|c| !c.is_whitespace()).collect(); + let mut items: Vec = kids.iter().map(|k| read_arg(k, d)).collect(); + if items.is_empty() { + items.push(Seq::new()); + } + let sep = seps.chars().next().map_or_else(|| "|".to_string(), |c| c.to_string()); + out.push(Node::Delim { open, close, sep, items }); + } + "mfrac" => { + let lt = attr(e, "linethickness").unwrap_or(""); + if matches!(lt, "0" | "0px" | "0em" | "0pt" | "0.0") { + out.push(Node::Matrix { rows: vec![vec![a(0)], vec![a(1)]] }); + } else { + out.push(Node::Frac { num: a(0), den: a(1) }); + } + } + "msqrt" => out.push(Node::Rad { deg: None, body: read_seq(kids, d + 1) }), + "mroot" => out.push(Node::Rad { deg: Some(a(1)), body: a(0) }), + "msub" => out.push(Node::Script { base: a(0), sub: Some(a(1)), sup: None }), + "msup" => out.push(Node::Script { base: a(0), sub: None, sup: Some(a(1)) }), + "msubsup" => out.push(Node::Script { base: a(0), sub: Some(a(1)), sup: Some(a(2)) }), + "mover" => { + let base = a(0); + let n = match mark(1) { + Some(('\u{00AF}' | '\u{203E}' | '\u{0305}', Some("false"))) => Node::Accent { ch: '\u{0305}', body: base }, + Some(('\u{00AF}' | '\u{203E}' | '\u{0305}', _)) => Node::Bar { top: true, body: base }, + Some((c, Some("true"))) => Node::GroupChr { ch: c, top: true, body: base }, + Some((c, _)) if is_true(e, "accent") || ACCENT_MARKS.iter().any(|(m, s)| *m == c || *s == c) => { + let ch = ACCENT_MARKS.iter().find(|(_, s)| *s == c).map_or(c, |(m, _)| *m); + Node::Accent { ch, body: base } + } + Some((c, _)) if GROUP_CHARS.contains(&c) => Node::GroupChr { ch: c, top: true, body: base }, + _ => Node::Limit { lower: false, base, lim: a(1) }, + }; + out.push(n); + } + "munder" => { + let base = a(0); + let n = match mark(1) { + Some(('_' | '\u{0332}' | '\u{00AF}' | '\u{203E}', _)) => Node::Bar { top: false, body: base }, + Some((c, Some("true"))) => Node::GroupChr { ch: c, top: false, body: base }, + Some((c, _)) if GROUP_CHARS.contains(&c) => Node::GroupChr { ch: c, top: false, body: base }, + _ => Node::Limit { lower: true, base, lim: a(1) }, + }; + out.push(n); + } + "munderover" => { + let inner = Node::Limit { lower: true, base: a(0), lim: a(1) }; + out.push(Node::Limit { lower: false, base: vec![inner], lim: a(2) }); + } + "mmultiscripts" => { + let base = a(0); + let mut post: Vec<(Seq, Seq)> = Vec::new(); + let mut pre: Vec<(Seq, Seq)> = Vec::new(); + let mut in_pre = false; + let mut pending: Option = None; + for k in kids.iter().skip(1) { + if k.local() == "mprescripts" { + in_pre = true; + pending = None; + continue; + } + let s = if k.local() == "none" { Seq::new() } else { read_arg(k, d) }; + match pending.take() { + None => pending = Some(s), + Some(sub) => { + if in_pre { + pre.push((sub, s)); + } else { + post.push((sub, s)); + } + } + } + } + let mut n = match pre.into_iter().next() { + Some((sub, sup)) => vec![Node::PreScript { sub, sup, base }], + None => base, + }; + if let Some((sub, sup)) = post.into_iter().next() { + let sub = (!sub.is_empty()).then_some(sub); + let sup = (!sup.is_empty()).then_some(sup); + if sub.is_some() || sup.is_some() { + n = vec![Node::Script { base: n, sub, sup }]; + } + } + for x in n { + push(out, x); + } + } + "mtable" => { + let eqarr = attr(e, "columnalign").is_some_and(|c| c.starts_with("left")); + let rows: Vec> = kids + .iter() + .filter(|r| matches!(r.local(), "mtr" | "mlabeledtr")) + .map(|r| { + let skip = usize::from(r.local() == "mlabeledtr"); + r.els().filter(|c| c.local() == "mtd").skip(skip).map(|c| read_seq(c.els().collect(), d + 1)).collect() + }) + .collect(); + if eqarr { + let rows = rows + .into_iter() + .map(|cells| { + let mut row = Seq::new(); + for (i, c) in cells.into_iter().enumerate() { + if i > 0 { + push(&mut row, Node::Text("\u{2003}".into())); + } + for n in c { + push(&mut row, n); + } + } + row + }) + .collect(); + out.push(Node::EqArr { rows }); + } else { + out.push(Node::Matrix { rows }); + } + } + "menclose" => { + let notation = attr(e, "notation").unwrap_or("longdiv"); + let body = read_seq(kids, d + 1); + let n = if notation.split_whitespace().any(|w| matches!(w, "box" | "roundedbox" | "circle")) { + Node::Boxed { border: true, body } + } else if notation.trim() == "top" { + Node::Bar { top: true, body } + } else if notation.trim() == "bottom" { + Node::Bar { top: false, body } + } else { + for n in body { + push(out, n); + } + return; + }; + out.push(n); + } + _ if is_group(e) => { + for n in read_seq(kids, d + 1) { + push(out, n); + } + } + "mpadded" => out.push(Node::Boxed { border: false, body: read_seq(kids, d + 1) }), + _ => { + if kids.is_empty() { + let t = token_text(e); + if !t.is_empty() { + push(out, Node::Text(t)); + } + } else { + for n in read_seq(kids, d + 1) { + push(out, n); + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::from_linear; + + fn ml(latex: &str) -> String { + to_mathml(&from_linear(latex)) + } + + #[test] + fn root_and_header() { + let mut m = from_linear(r"\frac{a}{b}"); + m.para = true; + let x = to_mathml(&m); + assert!(x.starts_with(""), "{x}"); + assert!(to_mathml_display(&m, false).starts_with("")); + assert_eq!(x, "ab"); + } + + #[test] + fn tokens_are_classified() { + let x = ml(r"x^2+3.14\alpha-\sin x"); + assert!(x.contains("x2+3.14α−"), "{x}"); + assert!(x.contains("sin⁡x"), "{x}"); + assert!(ml(r"\text{if } x").contains("if\u{a0}")); + assert!(ml(r"a\,b").contains("")); + assert!(ml("ad").contains("<")); + } + + #[test] + fn structures_use_the_expected_elements() { + for (src, want) in [ + (r"\sqrt{x}", "x"), + (r"\sqrt[3]{x}", "x3"), + (r"x_i^2", "xi2"), + (r"\sum_{i=1}^{n}i", "∑i=1ni"), + (r"\int_0^1 x", "∫01x"), + (r"\lim_{x\to0}x", "limx→0⁡x"), + (r"\hat{x}", "x\u{302}"), + (r"\overline{x}", "x¯"), + (r"\overrightarrow{x}", "x→⁡"), + (r"\overbrace{a}^{3}", "a⏞3⁡"), + (r"\left(x\right)", "(x)"), + (r"\begin{pmatrix}a&b\end{pmatrix}", "ab"), + ( + r"\begin{cases}1&x>0\end{cases}", + "1x>0", + ), + (r"\boxed{x}", "x"), + (r"\mathbf{v}", "v"), + (r"\mathrm{e}", "e"), + ] { + let x = ml(src); + assert!(x.contains(want), "{src}: {x}"); + assert!(!x.contains("mfenced"), "{src}: {x}"); + } + } + + #[test] + fn reads_foreign_mathml() { + let r = |s: &str| from_mathml(s).map(|m| crate::to_latex(&m)).unwrap_or_else(|e| format!("ERR {e}")); + assert_eq!(r(r#"12"#), r"\frac{1}{2}"); + assert_eq!( + r( + r#"x2"# + ), + "x^2" + ); + assert_eq!(r(r#"ab"#), r"\left(a,b\right)"); + assert_eq!(r(r#"(x)"#), r"\left(x\right)"); + assert_eq!(r(r#"xx"#), "x"); + assert_eq!(r(r#"
y"#), "y"); + assert_eq!(r(r#"(nk)"#), r"\binom{n}{k}"); + assert_eq!(from_mathml(" "), Err(MathmlError::Empty)); + assert_eq!(from_mathml("

no

"), Err(MathmlError::NoMath)); + assert!(from_mathml("x").is_ok_and(|m| m.para)); + } + + #[test] + fn hostile_input_is_bounded() { + let h = std::thread::Builder::new().stack_size(1 << 20).spawn(|| { + let cases = vec![ + format!("{}", "".repeat(20_000)), + format!("{}", "".repeat(50_000)), + format!("{}", "x".repeat(100_000)), + "&bogus;��".to_string(), + "]>&b;".to_string(), + "".to_string(), + "".to_string(), + "".to_string(), + "<<>>&&&"; + +fn is_math(e: &XEl) -> bool { + e.math +} + +/// Children of `e`'s property container (`Pr`) that the tree does not model. +fn extra_pr(e: &XEl, modeled: &[&str]) -> Vec<(String, String)> { + let Some(pr) = child(e, &format!("{}Pr", e.local())) else { return vec![] }; + let mut v: Vec<(String, String)> = pr + .els() + .filter(|c| !(is_math(c) && modeled.contains(&c.local()))) + .map(|c| (if is_math(c) { c.local().to_string() } else { c.name.clone() }, extra_xml(c))) + .collect(); + sort_pr(&mut v, &format!("{}Pr", e.local())); + v +} + +/// Schema order, as the writer emits it, so a re-read tree equals the one that was written. +fn sort_pr(v: &mut [(String, String)], tag: &str) { + let order = pr_order(tag); + v.sort_by_key(|(k, _)| order.iter().position(|o| o == k).unwrap_or(order.len())); +} + +/// Verbatim XML of a preserved property; the redundant `xmlns:m` is dropped because `to_omml` +/// declares it on the root. +fn extra_xml(c: &XEl) -> String { + let x = raw_xml(c); + let decl = format!(" xmlns:m=\"{OMML_NS}\""); + if c.name.starts_with("m:") { x.replacen(&decl, "", 1) } else { x } +} + +fn keep(node: Node, extra: Extra) -> Node { + if extra.is_empty() { node } else { Node::Props { extra, node: Box::new(node) } } +} + +/// What a structural element carries beyond the tree: unmodelled properties, its own attributes, +/// and children that are neither its property container nor one of `kids`. +fn ex(e: &XEl, modeled: &[&str], kids: &[&str]) -> Extra { + let prc = format!("{}Pr", e.local()); + let tail = e.els().filter(|c| !(is_math(c) && (c.local() == prc || kids.contains(&c.local())))).map(extra_xml).collect(); + Extra { pr: extra_pr(e, modeled), side: vec![], attrs: xml::start_extras(e, OMML_NS), tail } +} + +/// Schema order of property children, so merged output stays valid. +fn pr_order(tag: &str) -> &'static [&'static str] { + match tag { + "fPr" => &["type", "ctrlPr"], + "radPr" => &["degHide", "ctrlPr"], + "sSubSupPr" => &["alnScr", "ctrlPr"], + "naryPr" => &["chr", "limLoc", "grow", "subHide", "supHide", "ctrlPr"], + "dPr" => &["begChr", "sepChr", "endChr", "grow", "shp", "ctrlPr"], + "mPr" => &["baseJc", "plcHide", "rSp", "cSp", "rSpRule", "cGpRule", "cGp", "mcs", "ctrlPr"], + "accPr" => &["chr", "ctrlPr"], + "barPr" => &["pos", "ctrlPr"], + "groupChrPr" => &["chr", "pos", "vertJc", "ctrlPr"], + "eqArrPr" => &["baseJc", "maxDist", "objDist", "rSp", "rSpRule", "ctrlPr"], + "boxPr" => &["opEmu", "noBreak", "diff", "brk", "aln", "ctrlPr"], + "borderBoxPr" => &["hideTop", "hideBot", "hideLeft", "hideRight", "strikeH", "strikeV", "strikeBLTR", "strikeTLBR", "ctrlPr"], + "rPr" => &["lit", "nor", "scr", "sty", "br", "aln"], + _ => &["ctrlPr"], + } +} + +fn child<'a>(e: &'a XEl, local: &str) -> Option<&'a XEl> { + e.els().find(|c| is_math(c) && c.local() == local) +} + +fn push_text(out: &mut Seq, s: &str) { + if s.is_empty() { + return; + } + if let Some(Node::Text(t)) = out.last_mut() { + t.push_str(s); + } else { + out.push(Node::Text(s.to_string())); + } +} + +fn push_styled(out: &mut Seq, s: &str, style: Option