Add equaSHion: the equation editor as five crates (math, font, layout, render, egui)
Extracted from WordCraft and shared by WordCraft and DeckCraft. Keeps the equashion-* names; assets, test corpus and goldens sit at the repo root so the crates' relative paths are unchanged. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -13,8 +13,26 @@ repository = "https://github.com/storytold/craft-libs"
|
||||
# internal: add each crate here as `craft-<name> = { path = "crates/<name>" }`
|
||||
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"
|
||||
|
||||
|
||||
@@ -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/<name>`, named
|
||||
`craft-<name>`, with `version`, `edition`, `license` and `repository` inherited from the workspace
|
||||
`craft-<name>` (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-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit
|
||||
|
||||
- [`craft-ui`](crates/ui/README.md): SVG catalogs, icon buttons, tabs, pane controls, transactional docking and theme helpers for egui frontends;
|
||||
assets, themes, localization and command handling stay in each app. Requires Rust 1.95 or later.
|
||||
- `equashion-*` ([docs](docs/equashion/README.md)): equaSHion, a pure-Rust equation editor in five crates: `equashion-math` (math tree, OMML/MathML/LaTeX/linear formats, edit model), `equashion-font`, `equashion-layout` (TeX-style layout), `equashion-render` (pixels and SVG) and `equashion-egui` (the editor widget). Used by WordCraft and DeckCraft; assets in `assets/fonts`, test data in `corpus/` and `tests/golden/`.
|
||||
- [Shared UI component audit](docs/ui-components-audit.md): source inventory, extraction priorities
|
||||
and compatibility boundaries across the Craft suite.
|
||||
- [Theme infrastructure](docs/theme-infrastructure.md): eight-app comparison and the small shared
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION AND CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,92 @@
|
||||
Copyright 2001-2021 The STIX Fonts Project Authors (https://github.com/stipub/stixfonts), with Reserved Font Name "TM Math". STIX Fonts™ is a trademark of The Institute of Electrical and Electronics Engineers, Inc.
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright 2014 - 2023 Adobe (http://www.adobe.com/), with Reserved Font Name ‘Source’. All Rights Reserved. Source is a trademark of Adobe in the United States and/or other countries.
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
|
||||
This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,45 @@
|
||||
# id linear display (1 = display style, 0 = inline)
|
||||
frac_simple \frac{a}{b} 0
|
||||
frac_nested \frac{1}{1+\frac{1}{x}} 1
|
||||
frac_nested_inline \frac{a}{\frac{b}{c}} 0
|
||||
cfrac a_0+\cfrac{1}{a_1+\cfrac{1}{a_2+\cfrac{1}{a_3}}} 1
|
||||
sqrt \sqrt{x^2+y^2} 0
|
||||
sqrt_degree \sqrt[3]{x+1} 0
|
||||
sqrt_nested \sqrt{1+\sqrt{1+\sqrt{x}}} 1
|
||||
script_sup x^2 0
|
||||
script_sub a_n 0
|
||||
script_both x_i^2 0
|
||||
script_nested e^{-x^{2}} 0
|
||||
sum_display \sum_{i=1}^{n} i^2 1
|
||||
sum_inline \sum_{i=1}^{n} i^2 0
|
||||
prod_display \prod_{k=1}^{n} k 1
|
||||
int_display \int_0^1 x^2\,dx 1
|
||||
int_inline \int_0^1 x^2\,dx 0
|
||||
oint \oint_C f\,dz 1
|
||||
lim_display \lim_{x\to0}\frac{\sin x}{x}=1 1
|
||||
lim_inline \lim_{n\to\infty}\left(1+\frac{1}{n}\right)^n=e 0
|
||||
pmatrix \begin{pmatrix}a&b\\c&d\end{pmatrix} 1
|
||||
bmatrix \begin{bmatrix}1&0&0\\0&1&0\\0&0&1\end{bmatrix} 1
|
||||
cases |x|=\begin{cases}x&x\geq0\\-x&x<0\end{cases} 1
|
||||
accent_hat \hat{x}+\hat{y} 0
|
||||
accent_vec \vec{v}\cdot\vec{w} 0
|
||||
accent_bar \bar{z}z=|z|^2 0
|
||||
overline \overline{AB} 0
|
||||
overbrace \overbrace{a+b+c}^{3} 1
|
||||
underbrace \underbrace{x+x+x+x}_{n} 1
|
||||
overrightarrow \overrightarrow{AB} 0
|
||||
quadratic x=\frac{-b\pm\sqrt{b^2-4ac}}{2a} 1
|
||||
euler e^{i\pi}+1=0 1
|
||||
maxwell_gauss \nabla\cdot E=\frac{\rho}{\varepsilon_0} 1
|
||||
maxwell_faraday \nabla\times E=-\frac{\partial B}{\partial t} 1
|
||||
maxwell_ampere \nabla\times B=\mu_0J+\mu_0\varepsilon_0\frac{\partial E}{\partial t} 1
|
||||
binom \binom{n}{k}=\frac{n!}{k!(n-k)!} 1
|
||||
delims_tall \left[\frac{a}{b}\right]+\left\{\frac{c}{d}\right\} 1
|
||||
floor_ceil ⌊x⌋+⌈y⌉ 0
|
||||
abs_tall \left|\frac{a}{b}\right| 0
|
||||
angle ⟨u,v⟩ 0
|
||||
greek \alpha+\beta=\gamma 0
|
||||
func \sin^2\theta+\cos^2\theta=1 0
|
||||
boxed \boxed{E=mc^2} 0
|
||||
wide_matrix \begin{matrix}a_0&a_1&a_2&a_3&a_4&a_5&a_6&a_7&a_8&a_9&a_{10}&a_{11}&a_{12}&a_{13}&a_{14}&a_{15}&a_{16}&a_{17}&a_{18}&a_{19}\end{matrix} 1
|
||||
long_wrap (a+b)^{6}=a^{6}+6a^5b+15a^4b^2+20a^3b^3+15a^2b^4+6ab^5+b^{6}+c^{6}+6c^5d+15c^4d^2+20c^3d^3+15c^2d^4+6cd^5+d^{6} 0
|
||||
|
@@ -0,0 +1,92 @@
|
||||
# input (MathLive-style LaTeX) to_latex(from_linear(input))
|
||||
x^2 x^2
|
||||
x^{2} x^2
|
||||
x^{2+1} x^{2+1}
|
||||
x^{n^{2}} x^{n^2}
|
||||
x^2+3x-2 x^2+3x-2
|
||||
2x 2x
|
||||
3x^2+2x-1 3x^2+2x-1
|
||||
\frac{1}{x} \frac{1}{x}
|
||||
\frac{x+1}{x-1} \frac{x+1}{x-1}
|
||||
\frac{\frac{1}{2}}{3} \frac{\frac{1}{2}}{3}
|
||||
\frac{x^{2}}{3} \frac{x^2}{3}
|
||||
\sqrt{x} \sqrt{x}
|
||||
\sqrt{x+1} \sqrt{x+1}
|
||||
\sqrt[3]{x} \sqrt[3]{x}
|
||||
\sqrt{\sqrt{x}} \sqrt{\sqrt{x}}
|
||||
\sin(x) \sin\left(x\right)
|
||||
\sin x \sin\left(x\right)
|
||||
\sin{x} \sin\left(x\right)
|
||||
\cos\left(x\right) \cos\left(x\right)
|
||||
\sin\left(2x\right)+\cos(x) \sin\left(2x\right)+\cos\left(x\right)
|
||||
\tan(x) \tan\left(x\right)
|
||||
\arcsin(x) \arcsin\left(x\right)
|
||||
\sinh(x) \sinh\left(x\right)
|
||||
\ln(x) \ln\left(x\right)
|
||||
\ln x \ln\left(x\right)
|
||||
\log(x) \log\left(x\right)
|
||||
\exp(x) \exp\left(x\right)
|
||||
\abs{x} \left|x\right|
|
||||
|x| \left|x\right|
|
||||
|x-1|+|x+1| \left|x-1\right|+\left|x+1\right|
|
||||
2\pi x 2\pi x
|
||||
\sin(\pi x) \sin\left(\pi x\right)
|
||||
\theta \theta
|
||||
1+\cos\theta 1+\cos\left(\theta\right)
|
||||
\infty \infty
|
||||
\mathrm{e}^{x} \mathrm{e}^x
|
||||
x_1+x_{12} x_1+x_{12}
|
||||
2\cdot3 2\cdot3
|
||||
2\times3 2\times3
|
||||
4\div2 4\div2
|
||||
x\pm1 x\pm1
|
||||
(x+1)(x-1) \left(x+1\right)\left(x-1\right)
|
||||
(x+1)2 \left(x+1\right)2
|
||||
2(x+1) 2\left(x+1\right)
|
||||
x^2+y^2-1 x^2+y^2-1
|
||||
x--1 x--1
|
||||
x++1 x++1
|
||||
3\sqrt{x} 3\sqrt{x}
|
||||
e^{-x^2} e^{-x^2}
|
||||
\frac{\sin(x)}{x} \frac{\sin\left(x\right)}{x}
|
||||
\left|x\right| \left|x\right|
|
||||
\cot(x)\sec(x)\csc(x) \cot\left(x\right)\sec\left(x\right)\csc\left(x\right)
|
||||
2\sin(3x) 2\sin\left(3x\right)
|
||||
\sin^{2}(x) \sin^2\left(x\right)
|
||||
\sqrt[n]{x+1} \sqrt[n]{x+1}
|
||||
a b a b
|
||||
\frac{1}{2}x \frac{1}{2}x
|
||||
x^{\frac{1}{2}} x^{\frac{1}{2}}
|
||||
\sin\theta \sin\left(\theta\right)
|
||||
10x 10x
|
||||
2.5x 2.5x
|
||||
(2)(3)(4) \left(2\right)\left(3\right)\left(4\right)
|
||||
\operatorname{abs}(x) \operatorname{abs}\left(x\right)
|
||||
\frac{1}{} \frac{1}{}
|
||||
\arctan(x)^{2} {\arctan\left(x\right)}^2
|
||||
\cosh x \cosh\left(x\right)
|
||||
\tanh(x) \tanh\left(x\right)
|
||||
x^{2}_{3} x_3^2
|
||||
x^2+y^2=25 x^2+y^2=25
|
||||
y=\frac{x^2-1}{x+1} y=\frac{x^2-1}{x+1}
|
||||
y=\sqrt{4-x^2} y=\sqrt{4-x^2}
|
||||
r=1+\cos\theta r=1+\cos\left(\theta\right)
|
||||
\left(x-2\right)^2+\left(y+1\right)^2=9 \left(x-2\right)^2+\left(y+1\right)^2=9
|
||||
y=\left|x-3\right|+2 y=\left|x-3\right|+2
|
||||
y=\sin\left(2x\right)\cos\left(x\right) y=\sin\left(2x\right)\cos\left(x\right)
|
||||
y=\frac{1}{2}x+\frac{3}{4} y=\frac{1}{2}x+\frac{3}{4}
|
||||
y=e^{x} y=e^x
|
||||
y=\ln\left(x+1\right) y=\ln\left(x+1\right)
|
||||
y=\sqrt[3]{x^2} y=\sqrt[3]{x^2}
|
||||
f\left(x\right)=ax^2+bx+c f\left(x\right)=ax^2+bx+c
|
||||
\frac{d}{dx}x^2 \frac{d}{dx}x^2
|
||||
y=2^{x} y=2^x
|
||||
y=x^{\frac{2}{3}} y=x^{\frac{2}{3}}
|
||||
x^{2}+y^{2}\le 4 x^2+y^2\le 4
|
||||
y=\sin^{2}\left(x\right) y=\sin^2\left(x\right)
|
||||
\left(1,2\right) \left(1,2\right)
|
||||
\left[1,2,3\right] \left[1,2,3\right]
|
||||
a=\frac{-b\pm\sqrt{b^{2}-4ac}}{2a} a=\frac{-b\pm\sqrt{b^2-4ac}}{2a}
|
||||
y=\left\{x<0:-x,x\right\} y=\left\{x<0:-x,x\right\}
|
||||
\sum_{n=1}^{5}n^2 \sum_{n=1}^5n^2
|
||||
\pi r^{2} \pi r^2
|
||||
|
@@ -0,0 +1 @@
|
||||
<m:oMathPara xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math"><m:oMath><m:f><m:num><m:r><m:t>a</m:t></m:r></m:num><m:den><m:r><m:t>b</m:t></m:r></m:den></m:f></m:oMath></m:oMathPara>
|
||||
@@ -0,0 +1 @@
|
||||
<m:oMathPara xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math"><m:oMath><m:f><m:num><m:r><a:rPr xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" lang="en-US" sz="2800" b="1"/><m:t>x</m:t></m:r></m:num><m:den><m:r><m:t>2</m:t></m:r></m:den></m:f></m:oMath></m:oMathPara>
|
||||
@@ -0,0 +1 @@
|
||||
<mm:oMath xmlns:mm="http://schemas.openxmlformats.org/officeDocument/2006/math"><mm:sSup><mm:e><mm:r><mm:t>x</mm:t></mm:r></mm:e><mm:sup><mm:r><mm:t>2</mm:t></mm:r></mm:sup></mm:sSup></mm:oMath>
|
||||
@@ -0,0 +1 @@
|
||||
<m:oMathPara xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math"><m:oMathParaPr><m:jc m:val="left"/></m:oMathParaPr><m:oMath><m:rad><m:radPr><m:degHide m:val="1"/></m:radPr><m:deg/><m:e><m:r><m:t>x</m:t></m:r></m:e></m:rad></m:oMath></m:oMathPara>
|
||||
@@ -0,0 +1 @@
|
||||
<m:oMath xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math" xmlns:p="urn:p" xmlns:q="urn:q" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"><m:r xsi:type="foo" p:val="1" q:val="2"><p:ext/><m:t>x</m:t></m:r></m:oMath>
|
||||
@@ -0,0 +1 @@
|
||||
<m:oMath xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math"><m:f><m:num><m:r><m:t>a</m:t></m:r></m:num><m:den><m:r><m:t>b</m:t></m:r></m:den></m:f><m:r><m:t>+1</m:t></m:r></m:oMath>
|
||||
@@ -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"
|
||||
@@ -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=<path.png>` saves one frame and exits (for headless checks under xvfb).
|
||||
|
||||
use equashion_egui::EquationEditor;
|
||||
|
||||
struct Demo {
|
||||
editor: EquationEditor,
|
||||
last: String,
|
||||
screenshot: Option<std::path::PathBuf>,
|
||||
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,
|
||||
}))
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -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<Math> = 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<char> = 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<Math> = 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
|
||||
}
|
||||
}
|
||||
@@ -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<FontDb> {
|
||||
static DB: OnceLock<Arc<FontDb>> = 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<FontDb>,
|
||||
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<String>,
|
||||
canvas: Option<Canvas>,
|
||||
palette: HashMap<String, palette::Symbol>,
|
||||
/// 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<FontDb>) -> 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<f32> {
|
||||
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<egui::Vec2> {
|
||||
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<String> {
|
||||
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<Math>, sym: Option<char>) {
|
||||
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;
|
||||
@@ -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<Math> = None;
|
||||
let mut chosen_symbol: Option<char> = 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);
|
||||
}
|
||||
}
|
||||
@@ -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<EquationEditor>,
|
||||
host_text: Option<String>,
|
||||
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("<m:sSup>"), "{}", 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<String> {
|
||||
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<egui::Event>) -> Option<String> {
|
||||
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<String> = 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:?}");
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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<Vec<u8>>),
|
||||
}
|
||||
|
||||
/// 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<harfrust::ShaperInstance>,
|
||||
/// Basic Multilingual Plane coverage bitset, built on first use.
|
||||
bmp: std::sync::OnceLock<Box<[u64]>>,
|
||||
}
|
||||
|
||||
/// 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<'_>> {
|
||||
skrifa::FontRef::from_index(self.data(), self.index).ok()
|
||||
}
|
||||
pub fn hb(&self) -> Option<harfrust::FontRef<'_>> {
|
||||
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<Vec<Arc<FontFace>>>,
|
||||
outlines: Mutex<HashMap<(u32, u32), Arc<BezPath>>>,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
catalog: RwLock<Vec<CatalogEntry>>,
|
||||
/// 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<std::path::PathBuf>,
|
||||
/// 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<SysFallback>,
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[derive(Default)]
|
||||
struct SysFallback {
|
||||
enabled: bool,
|
||||
misses: std::collections::HashSet<char>,
|
||||
}
|
||||
|
||||
/// 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<String> {
|
||||
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<Vec<u8>> {
|
||||
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<u32> = 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<std::path::PathBuf> {
|
||||
let mut dirs: Vec<std::path::PathBuf> = 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<N>.
|
||||
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<Found> {
|
||||
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<FontFace> {
|
||||
static FACE: std::sync::OnceLock<Arc<FontFace>> = 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<FontFace> {
|
||||
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<std::path::PathBuf>) -> 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<Arc<FontFace>> {
|
||||
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<FontDb> = std::sync::OnceLock::new();
|
||||
DB.get_or_init(|| FontDb::with_font_dirs(system_font_dirs()))
|
||||
}
|
||||
|
||||
fn read_faces(&self) -> std::sync::RwLockReadGuard<'_, Vec<Arc<FontFace>>> {
|
||||
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<CatalogEntry>> {
|
||||
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<String> {
|
||||
let mut v: Vec<String> = 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<String> {
|
||||
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<u8>) -> 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<std::path::PathBuf> = {
|
||||
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<FontFace> {
|
||||
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<Arc<FontFace>> {
|
||||
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<Arc<FontFace>> {
|
||||
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<FontFace>> = 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<Arc<FontFace>> {
|
||||
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<Arc<FontFace>> {
|
||||
let faces = self.read_faces();
|
||||
let mut order: Vec<&Arc<FontFace>> = 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<String> = 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<std::path::PathBuf> = 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<BezPath> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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<Feature> {
|
||||
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<usize>, bool)> {
|
||||
let mut runs: Vec<(std::ops::Range<usize>, bool)> = Vec::new();
|
||||
let mut cur: Option<bool> = 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<ShapedGlyph> {
|
||||
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<ShapedGlyph>> = 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<ShapedGlyph> {
|
||||
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<_>>(), 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<usize> = 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");
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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<FontFace>;
|
||||
/// A face that covers `c`, other than the face with id `exclude`.
|
||||
fn fallback_for(&self, c: char, exclude: u32) -> Option<Arc<FontFace>>;
|
||||
/// The outline of glyph `gid` of `f`, in font units, y down.
|
||||
fn outline(&self, f: &FontFace, gid: u32) -> Arc<BezPath>;
|
||||
/// The face a [`Prim::Glyphs`] names.
|
||||
fn face_by_id(&self, id: FaceId) -> Option<Arc<FontFace>>;
|
||||
}
|
||||
|
||||
impl MathFonts for FontDb {
|
||||
fn face(&self, family: &str, style: &str) -> Arc<FontFace> {
|
||||
FontDb::face(self, family, style)
|
||||
}
|
||||
fn fallback_for(&self, c: char, exclude: u32) -> Option<Arc<FontFace>> {
|
||||
FontDb::fallback_for(self, c, exclude)
|
||||
}
|
||||
fn outline(&self, f: &FontFace, gid: u32) -> Arc<BezPath> {
|
||||
FontDb::outline(self, f, gid)
|
||||
}
|
||||
fn face_by_id(&self, id: FaceId) -> Option<Arc<FontFace>> {
|
||||
FontDb::face_by_id(self, id)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -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<Prim>,
|
||||
/// 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),
|
||||
}
|
||||
@@ -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<FontDb> = 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("<m:oMath xmlns:m=\"x\"><m:f/></m:oMath>");
|
||||
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:?}");
|
||||
}
|
||||
@@ -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<FontDb> = 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<Vec<(usize, usize)>>) {
|
||||
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<char> = corpus()
|
||||
.iter()
|
||||
.flat_map(|(_, m, _)| equashion_math::to_linear(m).chars().collect::<Vec<_>>())
|
||||
.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::<char>::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");
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -0,0 +1,8 @@
|
||||
//! Prints `input<TAB>to_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)));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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)");
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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<GalleryTemplate>,
|
||||
}
|
||||
|
||||
/// 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<Section>,
|
||||
}
|
||||
|
||||
/// 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<Gallery>,
|
||||
pub symbols: Vec<SymbolSet>,
|
||||
/// Ready-made equations (Insert › Equation), grouped by subject.
|
||||
pub builtins: Vec<GalleryTemplate>,
|
||||
/// 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<String, String>,
|
||||
}
|
||||
|
||||
/// The galleries, parsed once.
|
||||
#[allow(clippy::expect_used)]
|
||||
pub fn galleries() -> &'static Galleries {
|
||||
static G: OnceLock<Galleries> = 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<Item = &GalleryTemplate> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -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("<m:argPr") => {}
|
||||
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<Seq>], 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<char> {
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Node>;
|
||||
|
||||
/// 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<String>,
|
||||
/// 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<Seq>, body: Seq },
|
||||
/// Sub/superscript on `base`.
|
||||
Script { base: Seq, sub: Option<Seq>, sup: Option<Seq> },
|
||||
/// N-ary operator (sum, product, integral...) with optional limits.
|
||||
Nary { op: char, sub: Option<Seq>, sup: Option<Seq>, body: Seq },
|
||||
/// Delimiters around one or more items separated by `sep`.
|
||||
Delim { open: String, close: String, sep: String, items: Vec<Seq> },
|
||||
/// Matrix: rows of cells.
|
||||
Matrix { rows: Vec<Vec<Seq>> },
|
||||
/// 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<Seq> },
|
||||
/// `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<Node> },
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
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("<m:oMath/>")), "\u{25A1}");
|
||||
assert!(!run_text(&from_linear("a\nb\tc")).chars().any(char::is_control));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,757 @@
|
||||
//! OMML (ECMA-376 Part 1, §22.1) reader and writer.
|
||||
|
||||
use crate::xml::{self, XEl};
|
||||
use crate::{Extra, Math, Node, Seq, Style};
|
||||
|
||||
pub const OMML_NS: &str = "http://schemas.openxmlformats.org/officeDocument/2006/math";
|
||||
|
||||
/// Joins two `m:oMath` children of one `m:oMathPara` (or sibling `m:oMath` elements) in a body;
|
||||
/// written back verbatim, which restores the two elements.
|
||||
pub const OMATH_BREAK: &str = "</m:oMath><m:oMath xmlns:m=\"http://schemas.openxmlformats.org/officeDocument/2006/math\">";
|
||||
|
||||
fn is_math(e: &XEl) -> bool {
|
||||
e.math
|
||||
}
|
||||
|
||||
/// Children of `e`'s property container (`<local>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<Style>) {
|
||||
let Some(style) = style else { return push_text(out, s) };
|
||||
if s.is_empty() {
|
||||
return;
|
||||
}
|
||||
if let Some(Node::Styled { text, style: st }) = out.last_mut()
|
||||
&& *st == style
|
||||
{
|
||||
text.push_str(s);
|
||||
return;
|
||||
}
|
||||
out.push(Node::Styled { text: s.to_string(), style });
|
||||
}
|
||||
|
||||
fn run_style(e: &XEl) -> Option<Style> {
|
||||
let pr = child(e, "rPr")?;
|
||||
if let Some(n) = child(pr, "nor")
|
||||
&& !matches!(n.attr_local("val"), Some("0") | Some("false") | Some("off"))
|
||||
{
|
||||
return Some(Style::Normal);
|
||||
}
|
||||
match child(pr, "sty")?.attr_local("val") {
|
||||
Some("p") => Some(Style::Plain),
|
||||
Some("b") => Some(Style::Bold),
|
||||
Some("bi") => Some(Style::BoldItalic),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The text of a sequence made only of text runs, if it is one.
|
||||
fn plain_text(s: &Seq) -> Option<String> {
|
||||
let mut out = String::new();
|
||||
for n in s {
|
||||
match n {
|
||||
Node::Text(t) | Node::Styled { text: t, .. } => out.push_str(t),
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn seq_of(e: &XEl, local: &str) -> Seq {
|
||||
child(e, local).map(convert_children).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn opt_seq(e: &XEl, local: &str) -> Option<Seq> {
|
||||
child(e, local).map(convert_children)
|
||||
}
|
||||
|
||||
/// Like [`opt_seq`], but a part flagged hidden is dropped only when it is empty: hidden content
|
||||
/// is kept so that an edit does not lose it.
|
||||
fn hidable_seq(e: &XEl, props: &str, flag_name: &str, local: &str) -> Option<Seq> {
|
||||
let s = opt_seq(e, local);
|
||||
if flag(e, props, flag_name) { s.filter(|q| !q.is_empty()) } else { s }
|
||||
}
|
||||
|
||||
fn val_of(e: &XEl, props: &str, local: &str) -> Option<String> {
|
||||
child(e, props).and_then(|p| child(p, local)).map(|c| c.attr_local("val").unwrap_or("").to_string())
|
||||
}
|
||||
|
||||
fn flag(e: &XEl, props: &str, local: &str) -> bool {
|
||||
match child(e, props).and_then(|p| child(p, local)) {
|
||||
Some(c) => !matches!(c.attr_local("val"), Some("0") | Some("false") | Some("off")),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_children(e: &XEl) -> Seq {
|
||||
let mut out = Seq::new();
|
||||
for c in e.els() {
|
||||
convert_el(c, &mut out);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Structural children of each modelled element (the property container is implied).
|
||||
fn kids_of(local: &str) -> Option<&'static [&'static str]> {
|
||||
Some(match local {
|
||||
"f" => &["num", "den"],
|
||||
"rad" => &["deg", "e"],
|
||||
"sSup" => &["e", "sup"],
|
||||
"sSub" => &["e", "sub"],
|
||||
"sSubSup" => &["e", "sub", "sup"],
|
||||
"nary" => &["sub", "sup", "e"],
|
||||
"d" | "eqArr" | "acc" | "bar" | "groupChr" | "box" | "borderBox" => &["e"],
|
||||
"m" => &["mr"],
|
||||
"func" => &["fName", "e"],
|
||||
"limLow" | "limUpp" => &["e", "lim"],
|
||||
"sPre" => &["sub", "sup", "e"],
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// True when the tree cannot hold everything in `e` in place: attributes or foreign children on
|
||||
/// an inner container (`m:num`, `m:e`, `m:mr`, ...), a repeated container, or a foreign child
|
||||
/// ahead of a modelled one (the tail is written last). Such an element is kept verbatim.
|
||||
fn needs_raw(e: &XEl, kids: &[&str]) -> bool {
|
||||
let prc = format!("{}Pr", e.local());
|
||||
let mut foreign_seen = false;
|
||||
let mut seen: Vec<&str> = vec![];
|
||||
let repeats = matches!(e.local(), "d" | "eqArr" | "m");
|
||||
for c in e.els() {
|
||||
let modeled = is_math(c) && (c.local() == prc || kids.contains(&c.local()));
|
||||
if !modeled {
|
||||
foreign_seen = true;
|
||||
continue;
|
||||
}
|
||||
if foreign_seen || (!repeats && seen.contains(&c.local())) || !xml::start_extras(c, OMML_NS).is_empty() {
|
||||
return true;
|
||||
}
|
||||
seen.push(c.local());
|
||||
if c.local() == "mr" && (c.els().any(|g| !(is_math(g) && g.local() == "e")) || c.els().any(|g| !xml::start_extras(g, OMML_NS).is_empty())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn convert_el(e: &XEl, out: &mut Seq) {
|
||||
if !is_math(e) {
|
||||
out.push(raw(e));
|
||||
return;
|
||||
}
|
||||
if let Some(kids) = kids_of(e.local())
|
||||
&& needs_raw(e, kids)
|
||||
{
|
||||
out.push(raw(e));
|
||||
return;
|
||||
}
|
||||
match e.local() {
|
||||
"oMath" | "oMathPara" => {
|
||||
for c in e.els() {
|
||||
convert_el(c, out);
|
||||
}
|
||||
}
|
||||
// Paragraph properties are lifted into `Math::{para, jc}` by `convert_top`.
|
||||
"oMathParaPr" => {}
|
||||
"r" => {
|
||||
let mut s = String::new();
|
||||
let mut other = false;
|
||||
let mut side = vec![];
|
||||
for c in e.els() {
|
||||
if is_math(c) && c.local() == "t" {
|
||||
s.push_str(&c.text());
|
||||
} else if is_math(c) && c.local() == "rPr" {
|
||||
} else if is_math(c) {
|
||||
other = true;
|
||||
} else {
|
||||
side.push(extra_xml(c));
|
||||
}
|
||||
}
|
||||
if other {
|
||||
out.push(raw(e));
|
||||
return;
|
||||
}
|
||||
let style = run_style(e);
|
||||
let mut pr = vec![];
|
||||
if let Some(rp) = child(e, "rPr") {
|
||||
for c in rp.els() {
|
||||
let modeled = is_math(c)
|
||||
&& match c.local() {
|
||||
"nor" => style == Some(Style::Normal),
|
||||
"sty" => matches!(style, Some(Style::Plain | Style::Bold | Style::BoldItalic)),
|
||||
_ => false,
|
||||
};
|
||||
if !modeled {
|
||||
pr.push((if is_math(c) { c.local().to_string() } else { c.name.clone() }, extra_xml(c)));
|
||||
}
|
||||
}
|
||||
}
|
||||
sort_pr(&mut pr, "rPr");
|
||||
if pr.is_empty() && side.is_empty() {
|
||||
push_styled(out, &s, style);
|
||||
} else {
|
||||
let node = match style {
|
||||
Some(style) => Node::Styled { text: s, style },
|
||||
None => Node::Text(s),
|
||||
};
|
||||
out.push(Node::Props { extra: Extra { pr, side, ..Default::default() }, node: Box::new(node) });
|
||||
}
|
||||
}
|
||||
"f" => out.push(keep(Node::Frac { num: seq_of(e, "num"), den: seq_of(e, "den") }, ex(e, &[], &["num", "den"]))),
|
||||
"rad" => {
|
||||
let deg = hidable_seq(e, "radPr", "degHide", "deg");
|
||||
let hide: &[&str] = if flag(e, "radPr", "degHide") && deg.is_none() { &["degHide"] } else { &[] };
|
||||
out.push(keep(Node::Rad { deg, body: seq_of(e, "e") }, ex(e, hide, &["deg", "e"])));
|
||||
}
|
||||
"sSup" => out.push(keep(Node::Script { base: seq_of(e, "e"), sub: None, sup: Some(seq_of(e, "sup")) }, ex(e, &[], &["e", "sup"]))),
|
||||
"sSub" => out.push(keep(Node::Script { base: seq_of(e, "e"), sub: Some(seq_of(e, "sub")), sup: None }, ex(e, &[], &["e", "sub"]))),
|
||||
"sSubSup" => out.push(keep(
|
||||
Node::Script { base: seq_of(e, "e"), sub: Some(seq_of(e, "sub")), sup: Some(seq_of(e, "sup")) },
|
||||
ex(e, &[], &["e", "sub", "sup"]),
|
||||
)),
|
||||
"nary" => {
|
||||
// No `m:chr` means the default integral; an empty `m:chr` means no operator (NUL).
|
||||
let op = match val_of(e, "naryPr", "chr") {
|
||||
Some(v) => v.chars().next().unwrap_or('\0'),
|
||||
None => '\u{222B}',
|
||||
};
|
||||
let sub = hidable_seq(e, "naryPr", "subHide", "sub");
|
||||
let sup = hidable_seq(e, "naryPr", "supHide", "sup");
|
||||
let mut hide = vec![];
|
||||
if val_of(e, "naryPr", "chr").is_none_or(|v| v.chars().count() <= 1) {
|
||||
hide.push("chr");
|
||||
}
|
||||
if sub.is_none() {
|
||||
hide.push("subHide");
|
||||
}
|
||||
if sup.is_none() {
|
||||
hide.push("supHide");
|
||||
}
|
||||
let mut pr = ex(e, &hide, &["sub", "sup", "e"]);
|
||||
// The writer supplies the usual limit placement; only a different one is preserved.
|
||||
let usual = if matches!(op, '\u{222B}'..='\u{2233}') { "subSup" } else { "undOvr" };
|
||||
if val_of(e, "naryPr", "limLoc").as_deref() == Some(usual) {
|
||||
pr.pr.retain(|(k, _)| k != "limLoc");
|
||||
}
|
||||
out.push(keep(Node::Nary { op, sub, sup, body: seq_of(e, "e") }, pr));
|
||||
}
|
||||
"d" => {
|
||||
let open = val_of(e, "dPr", "begChr").unwrap_or_else(|| "(".into());
|
||||
let close = val_of(e, "dPr", "endChr").unwrap_or_else(|| ")".into());
|
||||
let sep = val_of(e, "dPr", "sepChr").unwrap_or_else(|| "|".into());
|
||||
let mut items: Vec<Seq> = e.els().filter(|c| is_math(c) && c.local() == "e").map(convert_children).collect();
|
||||
if items.is_empty() {
|
||||
items.push(Seq::new());
|
||||
}
|
||||
out.push(keep(Node::Delim { open, close, sep, items }, ex(e, &["begChr", "endChr", "sepChr"], &["e"])));
|
||||
}
|
||||
"m" => {
|
||||
let rows = e
|
||||
.els()
|
||||
.filter(|r| is_math(r) && r.local() == "mr")
|
||||
.map(|r| r.els().filter(|c| is_math(c) && c.local() == "e").map(convert_children).collect::<Vec<_>>())
|
||||
.collect();
|
||||
out.push(keep(Node::Matrix { rows }, ex(e, &[], &["mr"])));
|
||||
}
|
||||
"func" => {
|
||||
let fname = child(e, "fName");
|
||||
let runs_only = fname.is_some_and(|f| f.els().all(|c| is_math(c) && c.local() == "r"));
|
||||
let nseq = seq_of(e, "fName");
|
||||
let bare: Seq = nseq.iter().map(|n| n.bare().clone()).collect();
|
||||
let plain = plain_text(&bare).filter(|_| runs_only);
|
||||
// A structured name (Word writes `lim` as a limLow, `log_b` as a sSub) keeps its XML
|
||||
// verbatim next to a linear-text name, so the func wrapper survives.
|
||||
let structured = fname.is_some() && plain.is_none();
|
||||
let n = match plain {
|
||||
Some(n) => n.trim().to_string(),
|
||||
None => crate::to_linear(&Math::new(nseq)).trim().to_string(),
|
||||
};
|
||||
let mut gen_run = String::new();
|
||||
run(&mut gen_run, &n, Some(Style::Plain), &Extra::default(), false);
|
||||
let mut raw_runs = String::new();
|
||||
if let Some(f) = fname {
|
||||
for c in f.els() {
|
||||
raw_runs.push_str(&extra_xml(c));
|
||||
}
|
||||
}
|
||||
let raw_runs = raw_runs.replace(&format!(" xmlns:m=\"{OMML_NS}\""), "");
|
||||
let mut x = ex(e, &[], &["fName", "e"]);
|
||||
x.side = if raw_runs == gen_run && !structured { vec![] } else { vec![raw_runs] };
|
||||
out.push(keep(Node::Func { name: n, body: seq_of(e, "e") }, x));
|
||||
}
|
||||
"acc" => {
|
||||
// An empty or multi-character `m:chr` cannot be a single `char`; its XML is kept verbatim.
|
||||
let v = val_of(e, "accPr", "chr");
|
||||
let single = v.as_deref().is_some_and(|s| s.chars().count() == 1);
|
||||
let ch = v.and_then(|s| s.chars().next()).unwrap_or('\u{0302}');
|
||||
let modeled: &[&str] = if single || val_of(e, "accPr", "chr").is_none() { &["chr"] } else { &[] };
|
||||
out.push(keep(Node::Accent { ch, body: seq_of(e, "e") }, ex(e, modeled, &["e"])));
|
||||
}
|
||||
"limLow" | "limUpp" => {
|
||||
out.push(keep(Node::Limit { lower: e.local() == "limLow", base: seq_of(e, "e"), lim: seq_of(e, "lim") }, ex(e, &[], &["e", "lim"])))
|
||||
}
|
||||
"eqArr" => {
|
||||
let mut rows: Vec<Seq> = e.els().filter(|c| is_math(c) && c.local() == "e").map(convert_children).collect();
|
||||
if rows.is_empty() {
|
||||
rows.push(Seq::new());
|
||||
}
|
||||
out.push(keep(Node::EqArr { rows }, ex(e, &[], &["e"])));
|
||||
}
|
||||
"sPre" => {
|
||||
out.push(keep(Node::PreScript { sub: seq_of(e, "sub"), sup: seq_of(e, "sup"), base: seq_of(e, "e") }, ex(e, &[], &["sub", "sup", "e"])))
|
||||
}
|
||||
"bar" => {
|
||||
out.push(keep(Node::Bar { top: val_of(e, "barPr", "pos").as_deref() == Some("top"), body: seq_of(e, "e") }, ex(e, &["pos"], &["e"])))
|
||||
}
|
||||
"groupChr" => {
|
||||
let ch = val_of(e, "groupChrPr", "chr").and_then(|s| s.chars().next()).unwrap_or('\u{23DF}');
|
||||
let top = val_of(e, "groupChrPr", "pos").as_deref() == Some("top");
|
||||
out.push(keep(Node::GroupChr { ch, top, body: seq_of(e, "e") }, ex(e, &["chr", "pos"], &["e"])));
|
||||
}
|
||||
"box" => out.push(keep(Node::Boxed { border: false, body: seq_of(e, "e") }, ex(e, &[], &["e"]))),
|
||||
"borderBox" => out.push(keep(Node::Boxed { border: true, body: seq_of(e, "e") }, ex(e, &[], &["e"]))),
|
||||
// A stray run-properties element carries no content.
|
||||
"rPr" => {}
|
||||
_ => out.push(raw(e)),
|
||||
}
|
||||
}
|
||||
|
||||
fn raw_xml(e: &XEl) -> String {
|
||||
let mut s = String::new();
|
||||
xml::write_el(e, true, &mut s, 0);
|
||||
s
|
||||
}
|
||||
|
||||
fn raw(e: &XEl) -> Node {
|
||||
Node::Raw(raw_xml(e))
|
||||
}
|
||||
|
||||
/// Separate the second and later `m:oMath` of a document from what came before.
|
||||
fn join_omath(e: &XEl, m: &mut Math, seen: &mut usize) {
|
||||
if is_math(e) && e.local() == "oMath" {
|
||||
// Bare declarations are not an attribute worth carrying; the Raw nodes bring their own.
|
||||
let plain = e.attrs.iter().any(|(k, _)| k != "xmlns" && !k.starts_with("xmlns:"));
|
||||
let attrs = if plain { xml::start_extras(e, OMML_NS) } else { String::new() };
|
||||
if *seen > 0 {
|
||||
let mut brk = OMATH_BREAK.to_string();
|
||||
if !attrs.is_empty() {
|
||||
brk.pop();
|
||||
brk.push_str(&attrs);
|
||||
brk.push('>');
|
||||
}
|
||||
m.body.push(Node::Raw(brk));
|
||||
} else {
|
||||
m.attrs = attrs;
|
||||
}
|
||||
*seen += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Top level: wrapper elements that are not OMML (for example `a14:m`) are looked through.
|
||||
fn convert_top(e: &XEl, m: &mut Math, depth: usize, seen: &mut usize) {
|
||||
if is_math(e) && e.local() == "oMathPara" {
|
||||
m.para = true;
|
||||
if let Some(jc) = val_of(e, "oMathParaPr", "jc") {
|
||||
m.jc = Some(jc);
|
||||
}
|
||||
for c in e.els() {
|
||||
join_omath(c, m, seen);
|
||||
convert_el(c, &mut m.body);
|
||||
}
|
||||
} else if is_math(e) || depth > crate::XML_DEPTH {
|
||||
join_omath(e, m, seen);
|
||||
convert_el(e, &mut m.body);
|
||||
} else {
|
||||
for c in e.els() {
|
||||
convert_top(c, m, depth + 1, seen);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse OMML (an `m:oMath`, `m:oMathPara`, or a bare fragment). Never fails: unknown elements
|
||||
/// become [`Node::Raw`] and malformed input yields what could be read.
|
||||
pub fn from_omml(xml_src: &str) -> Math {
|
||||
let roots = xml::parse(xml_src);
|
||||
let mut m = Math::default();
|
||||
let mut seen = 0usize;
|
||||
for r in &roots {
|
||||
convert_top(r, &mut m, 0, &mut seen);
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
// ---- writer -------------------------------------------------------------------------------
|
||||
|
||||
fn val_s(tag: &str, v: &str) -> (String, String) {
|
||||
let mut out = String::new();
|
||||
out.push_str("<m:");
|
||||
out.push_str(tag);
|
||||
out.push_str(" m:val=\"");
|
||||
xml::escape_attr(v, &mut out);
|
||||
out.push_str("\"/>");
|
||||
(tag.to_string(), out)
|
||||
}
|
||||
|
||||
fn val(out: &mut String, tag: &str, v: &str) {
|
||||
out.push_str(&val_s(tag, v).1);
|
||||
}
|
||||
|
||||
/// Merge generated property children with the preserved ones (preserved win on a name clash,
|
||||
/// they are never ones the tree models) in schema order. `always` writes the container even
|
||||
/// when it is empty.
|
||||
fn pr(out: &mut String, tag: &str, generated: Vec<(String, String)>, x: &Extra, always: bool) {
|
||||
let mut items: Vec<(String, String)> = generated.into_iter().filter(|(k, _)| !x.pr.iter().any(|(xk, _)| xk == k)).collect();
|
||||
items.extend(x.pr.iter().cloned());
|
||||
if items.is_empty() && !always {
|
||||
return;
|
||||
}
|
||||
let order = pr_order(tag);
|
||||
items.sort_by_key(|(k, _)| order.iter().position(|o| o == k).unwrap_or(order.len()));
|
||||
out.push_str("<m:");
|
||||
out.push_str(tag);
|
||||
out.push('>');
|
||||
for (_, xml) in &items {
|
||||
out.push_str(xml);
|
||||
}
|
||||
out.push_str("</m:");
|
||||
out.push_str(tag);
|
||||
out.push('>');
|
||||
}
|
||||
|
||||
/// True while the verbatim `m:fName` content still stands for `name` (as plain text or as the
|
||||
/// linear form of its structure).
|
||||
fn name_matches(raw: &str, name: &str) -> bool {
|
||||
let m = from_omml(&format!("<m:oMath>{raw}</m:oMath>"));
|
||||
let bare: Seq = m.body.iter().map(|n| n.bare().clone()).collect();
|
||||
plain_text(&bare).is_some_and(|t| t.trim() == name) || crate::to_linear(&m).trim() == name
|
||||
}
|
||||
|
||||
fn run(out: &mut String, text: &str, style: Option<Style>, x: &Extra, force: bool) {
|
||||
if text.is_empty() && !force {
|
||||
return;
|
||||
}
|
||||
out.push_str("<m:r>");
|
||||
let mut pre = vec![];
|
||||
match style {
|
||||
Some(Style::Plain) => pre.push(val_s("sty", "p")),
|
||||
Some(Style::Bold) => pre.push(val_s("sty", "b")),
|
||||
Some(Style::BoldItalic) => pre.push(val_s("sty", "bi")),
|
||||
Some(Style::Normal) => pre.push(("nor".to_string(), "<m:nor/>".to_string())),
|
||||
None => {}
|
||||
}
|
||||
pr(out, "rPr", pre, x, false);
|
||||
for s in &x.side {
|
||||
out.push_str(s);
|
||||
}
|
||||
out.push_str("<m:t xml:space=\"preserve\">");
|
||||
xml::escape(text, out);
|
||||
out.push_str("</m:t></m:r>");
|
||||
}
|
||||
|
||||
fn wrap(out: &mut String, tag: &str, s: &[Node], depth: usize) {
|
||||
out.push_str("<m:");
|
||||
out.push_str(tag);
|
||||
out.push('>');
|
||||
write_seq(out, s, depth);
|
||||
out.push_str("</m:");
|
||||
out.push_str(tag);
|
||||
out.push('>');
|
||||
}
|
||||
|
||||
fn write_seq(out: &mut String, s: &[Node], depth: usize) {
|
||||
if depth > crate::XML_DEPTH {
|
||||
return;
|
||||
}
|
||||
for n in s {
|
||||
write_node(out, n, depth + 1, &Extra::default());
|
||||
}
|
||||
}
|
||||
|
||||
fn open(out: &mut String, tag: &str, x: &Extra) {
|
||||
out.push_str("<m:");
|
||||
out.push_str(tag);
|
||||
out.push_str(&x.attrs);
|
||||
out.push('>');
|
||||
}
|
||||
|
||||
fn close(out: &mut String, tag: &str, x: &Extra) {
|
||||
for t in &x.tail {
|
||||
out.push_str(t);
|
||||
}
|
||||
out.push_str("</m:");
|
||||
out.push_str(tag);
|
||||
out.push('>');
|
||||
}
|
||||
|
||||
fn write_node(out: &mut String, n: &Node, depth: usize, x: &Extra) {
|
||||
match n {
|
||||
Node::Props { extra, node } => {
|
||||
// Bounded: `Props` wrappers are only built by the reader, one per element.
|
||||
let mut inner = node.as_ref();
|
||||
let mut ex = extra;
|
||||
let mut guard = 0;
|
||||
while let Node::Props { extra: e2, node: n2 } = inner {
|
||||
if guard > 8 {
|
||||
break;
|
||||
}
|
||||
guard += 1;
|
||||
inner = n2;
|
||||
ex = e2;
|
||||
}
|
||||
write_node(out, inner, depth, ex)
|
||||
}
|
||||
Node::Text(t) => run(out, t, None, x, !x.is_empty()),
|
||||
Node::Styled { text, style } => run(out, text, Some(*style), x, !x.is_empty()),
|
||||
Node::Frac { num, den } => {
|
||||
open(out, "f", x);
|
||||
pr(out, "fPr", vec![], x, false);
|
||||
wrap(out, "num", num, depth);
|
||||
wrap(out, "den", den, depth);
|
||||
close(out, "f", x);
|
||||
}
|
||||
Node::Rad { deg, body } => {
|
||||
open(out, "rad", x);
|
||||
let pre = if deg.is_none() { vec![val_s("degHide", "1")] } else { vec![] };
|
||||
pr(out, "radPr", pre, x, true);
|
||||
wrap(out, "deg", deg.as_deref().unwrap_or(&[]), depth);
|
||||
wrap(out, "e", body, depth);
|
||||
close(out, "rad", x);
|
||||
}
|
||||
Node::Script { base, sub, sup } => match (sub, sup) {
|
||||
(None, None) => write_seq(out, base, depth),
|
||||
(None, Some(p)) => {
|
||||
open(out, "sSup", x);
|
||||
pr(out, "sSupPr", vec![], x, false);
|
||||
wrap(out, "e", base, depth);
|
||||
wrap(out, "sup", p, depth);
|
||||
close(out, "sSup", x);
|
||||
}
|
||||
(Some(b), None) => {
|
||||
open(out, "sSub", x);
|
||||
pr(out, "sSubPr", vec![], x, false);
|
||||
wrap(out, "e", base, depth);
|
||||
wrap(out, "sub", b, depth);
|
||||
close(out, "sSub", x);
|
||||
}
|
||||
(Some(b), Some(p)) => {
|
||||
open(out, "sSubSup", x);
|
||||
pr(out, "sSubSupPr", vec![], x, false);
|
||||
wrap(out, "e", base, depth);
|
||||
wrap(out, "sub", b, depth);
|
||||
wrap(out, "sup", p, depth);
|
||||
close(out, "sSubSup", x);
|
||||
}
|
||||
},
|
||||
Node::Nary { op, sub, sup, body } => {
|
||||
open(out, "nary", x);
|
||||
let opstr = if *op == '\0' { String::new() } else { op.to_string() };
|
||||
let mut pre = vec![val_s("chr", &opstr), val_s("limLoc", if matches!(op, '\u{222B}'..='\u{2233}') { "subSup" } else { "undOvr" })];
|
||||
if sub.is_none() {
|
||||
pre.push(val_s("subHide", "1"));
|
||||
}
|
||||
if sup.is_none() {
|
||||
pre.push(val_s("supHide", "1"));
|
||||
}
|
||||
pr(out, "naryPr", pre, x, true);
|
||||
wrap(out, "sub", sub.as_deref().unwrap_or(&[]), depth);
|
||||
wrap(out, "sup", sup.as_deref().unwrap_or(&[]), depth);
|
||||
wrap(out, "e", body, depth);
|
||||
close(out, "nary", x);
|
||||
}
|
||||
Node::Delim { open: o, close: c, sep, items } => {
|
||||
open(out, "d", x);
|
||||
let mut pre = vec![val_s("begChr", o)];
|
||||
if items.len() > 1 || sep != "|" {
|
||||
pre.push(val_s("sepChr", sep));
|
||||
}
|
||||
pre.push(val_s("endChr", c));
|
||||
pr(out, "dPr", pre, x, true);
|
||||
if items.is_empty() {
|
||||
wrap(out, "e", &[], depth);
|
||||
}
|
||||
for it in items {
|
||||
wrap(out, "e", it, depth);
|
||||
}
|
||||
close(out, "d", x);
|
||||
}
|
||||
Node::Matrix { rows } => {
|
||||
open(out, "m", x);
|
||||
pr(out, "mPr", vec![], x, false);
|
||||
for r in rows {
|
||||
open(out, "mr", &Extra::default());
|
||||
for c in r {
|
||||
wrap(out, "e", c, depth);
|
||||
}
|
||||
close(out, "mr", &Extra::default());
|
||||
}
|
||||
close(out, "m", x);
|
||||
}
|
||||
Node::Func { name, body } => {
|
||||
open(out, "func", x);
|
||||
pr(out, "funcPr", vec![], x, false);
|
||||
open(out, "fName", &Extra::default());
|
||||
match x.side.first() {
|
||||
// Verbatim name run, valid only while it still carries the name.
|
||||
Some(raw) if name_matches(raw, name) => out.push_str(raw),
|
||||
_ => run(out, name, Some(Style::Plain), &Extra::default(), false),
|
||||
}
|
||||
close(out, "fName", &Extra::default());
|
||||
wrap(out, "e", body, depth);
|
||||
close(out, "func", x);
|
||||
}
|
||||
Node::Accent { ch, body } => {
|
||||
open(out, "acc", x);
|
||||
pr(out, "accPr", vec![val_s("chr", &ch.to_string())], x, true);
|
||||
wrap(out, "e", body, depth);
|
||||
close(out, "acc", x);
|
||||
}
|
||||
Node::Limit { lower, base, lim } => {
|
||||
let tag = if *lower { "limLow" } else { "limUpp" };
|
||||
open(out, tag, x);
|
||||
pr(out, if *lower { "limLowPr" } else { "limUppPr" }, vec![], x, false);
|
||||
wrap(out, "e", base, depth);
|
||||
wrap(out, "lim", lim, depth);
|
||||
close(out, tag, x);
|
||||
}
|
||||
Node::EqArr { rows } => {
|
||||
open(out, "eqArr", x);
|
||||
pr(out, "eqArrPr", vec![], x, false);
|
||||
if rows.is_empty() {
|
||||
wrap(out, "e", &[], depth);
|
||||
}
|
||||
for r in rows {
|
||||
wrap(out, "e", r, depth);
|
||||
}
|
||||
close(out, "eqArr", x);
|
||||
}
|
||||
Node::PreScript { sub, sup, base } => {
|
||||
open(out, "sPre", x);
|
||||
pr(out, "sPrePr", vec![], x, false);
|
||||
wrap(out, "sub", sub, depth);
|
||||
wrap(out, "sup", sup, depth);
|
||||
wrap(out, "e", base, depth);
|
||||
close(out, "sPre", x);
|
||||
}
|
||||
Node::Bar { top, body } => {
|
||||
open(out, "bar", x);
|
||||
pr(out, "barPr", vec![val_s("pos", if *top { "top" } else { "bot" })], x, true);
|
||||
wrap(out, "e", body, depth);
|
||||
close(out, "bar", x);
|
||||
}
|
||||
Node::GroupChr { ch, top, body } => {
|
||||
open(out, "groupChr", x);
|
||||
pr(out, "groupChrPr", vec![val_s("chr", &ch.to_string()), val_s("pos", if *top { "top" } else { "bot" })], x, true);
|
||||
wrap(out, "e", body, depth);
|
||||
close(out, "groupChr", x);
|
||||
}
|
||||
Node::Boxed { border, body } => {
|
||||
let tag = if *border { "borderBox" } else { "box" };
|
||||
open(out, tag, x);
|
||||
pr(out, if *border { "borderBoxPr" } else { "boxPr" }, vec![], x, false);
|
||||
wrap(out, "e", body, depth);
|
||||
close(out, tag, x);
|
||||
}
|
||||
Node::Raw(s) => out.push_str(s),
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialise to a standalone element carrying `xmlns:m`: `m:oMath`, or `m:oMathPara` (with its
|
||||
/// `m:oMathParaPr` as a sibling before the `m:oMath`, as the schema requires) for display math.
|
||||
pub fn to_omml(m: &Math) -> String {
|
||||
let mut out = String::new();
|
||||
if m.para {
|
||||
out.push_str("<m:oMathPara xmlns:m=\"");
|
||||
out.push_str(OMML_NS);
|
||||
out.push_str("\">");
|
||||
if let Some(jc) = &m.jc {
|
||||
out.push_str("<m:oMathParaPr>");
|
||||
val(&mut out, "jc", jc);
|
||||
out.push_str("</m:oMathParaPr>");
|
||||
}
|
||||
out.push_str("<m:oMath");
|
||||
out.push_str(&m.attrs);
|
||||
out.push('>');
|
||||
} else {
|
||||
out.push_str("<m:oMath xmlns:m=\"");
|
||||
out.push_str(OMML_NS);
|
||||
out.push('"');
|
||||
out.push_str(&m.attrs);
|
||||
out.push('>');
|
||||
}
|
||||
write_seq(&mut out, &m.body, 0);
|
||||
out.push_str("</m:oMath>");
|
||||
if m.para {
|
||||
out.push_str("</m:oMathPara>");
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
//! The template palette: structures with empty slots (`Text("")`) the dialog fills in.
|
||||
|
||||
use crate::{Math, Node, Seq, Style};
|
||||
|
||||
/// One palette entry.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Template {
|
||||
pub id: &'static str,
|
||||
pub label: &'static str,
|
||||
pub group: &'static str,
|
||||
pub math: Math,
|
||||
}
|
||||
|
||||
fn slot() -> Seq {
|
||||
vec![Node::Text(String::new())]
|
||||
}
|
||||
|
||||
fn delim(open: &str, close: &str) -> Node {
|
||||
Node::Delim { open: open.into(), close: close.into(), sep: "|".into(), items: vec![slot()] }
|
||||
}
|
||||
|
||||
fn nary(op: char, limits: bool) -> Node {
|
||||
Node::Nary { op, sub: limits.then(slot), sup: limits.then(slot), body: slot() }
|
||||
}
|
||||
|
||||
/// A bracketed matrix (a bare grid reads as a mistake to most people).
|
||||
fn matrix(r: usize, c: usize, open: &str, close: &str) -> Node {
|
||||
let grid = Node::Matrix { rows: (0..r).map(|_| (0..c).map(|_| slot()).collect()).collect() };
|
||||
Node::Delim { open: open.into(), close: close.into(), sep: "|".into(), items: vec![vec![grid]] }
|
||||
}
|
||||
|
||||
/// An empty `rows` x `cols` matrix in the given brackets, as a one-node equation.
|
||||
pub fn matrix_template(rows: usize, cols: usize, open: &str, close: &str) -> Math {
|
||||
Math::new(vec![matrix(rows.max(1), cols.max(1), open, close)])
|
||||
}
|
||||
|
||||
/// `(n over k)`: a two-row, one-column matrix in parentheses, as `\binom` reads.
|
||||
fn binom() -> Node {
|
||||
let grid = Node::Matrix { rows: vec![vec![slot()], vec![slot()]] };
|
||||
Node::Delim { open: "(".into(), close: ")".into(), sep: "|".into(), items: vec![vec![grid]] }
|
||||
}
|
||||
|
||||
/// `name(□, □)`: a function applied to two places, as `nCr(n, r)` is written.
|
||||
fn call2(id: &'static str, label: &'static str, name: &str) -> Template {
|
||||
let args = Node::Delim { open: "(".into(), close: ")".into(), sep: ",".into(), items: vec![slot(), slot()] };
|
||||
Template { id, label, group: "Functions", math: Math::new(vec![Node::Func { name: name.into(), body: vec![args] }]) }
|
||||
}
|
||||
|
||||
/// `ₙC_r`: the letter with a place before it (n) and one after it (r), as in the textbook
|
||||
/// notation for combinations and permutations.
|
||||
fn choose(id: &'static str, label: &'static str, letter: &str) -> Template {
|
||||
let n = Node::Script { base: vec![], sub: Some(slot()), sup: None };
|
||||
let c = Node::Script { base: vec![Node::Text(letter.into())], sub: Some(slot()), sup: None };
|
||||
Template { id, label, group: "Functions", math: Math::new(vec![n, c]) }
|
||||
}
|
||||
|
||||
fn mk(id: &'static str, label: &'static str, group: &'static str, n: Node) -> Template {
|
||||
Template { id, label, group, math: Math::new(vec![n]) }
|
||||
}
|
||||
|
||||
/// All templates, in palette order.
|
||||
pub fn templates() -> Vec<Template> {
|
||||
let script = |sub: bool, sup: bool| Node::Script { base: slot(), sub: sub.then(slot), sup: sup.then(slot) };
|
||||
let func = |n: &str| Node::Func { name: n.into(), body: slot() };
|
||||
vec![
|
||||
mk("frac", "Fraction", "Fractions", Node::Frac { num: slot(), den: slot() }),
|
||||
mk("sup", "Superscript", "Scripts", script(false, true)),
|
||||
mk("sub", "Subscript", "Scripts", script(true, false)),
|
||||
mk("subsup", "Sub-superscript", "Scripts", script(true, true)),
|
||||
mk("sqrt", "Square root", "Radicals", Node::Rad { deg: None, body: slot() }),
|
||||
mk("root", "Nth root", "Radicals", Node::Rad { deg: Some(slot()), body: slot() }),
|
||||
mk("sum", "Sum", "Large operators", nary('∑', true)),
|
||||
mk("prod", "Product", "Large operators", nary('∏', true)),
|
||||
mk("int", "Integral", "Large operators", nary('∫', false)),
|
||||
mk("intlim", "Definite integral", "Large operators", nary('∫', true)),
|
||||
mk("paren", "Parentheses", "Brackets", delim("(", ")")),
|
||||
mk("bracket", "Square brackets", "Brackets", delim("[", "]")),
|
||||
mk("brace", "Braces", "Brackets", delim("{", "}")),
|
||||
mk("abs", "Absolute value", "Brackets", delim("|", "|")),
|
||||
mk("matrix2", "2 x 2 matrix", "Matrices", matrix(2, 2, "(", ")")),
|
||||
mk("matrix3", "3 x 3 matrix", "Matrices", matrix(3, 3, "(", ")")),
|
||||
mk("bmatrix2", "2 x 2 matrix, square brackets", "Matrices", matrix(2, 2, "[", "]")),
|
||||
mk("bmatrix3", "3 x 3 matrix, square brackets", "Matrices", matrix(3, 3, "[", "]")),
|
||||
mk("binom", "Binomial coefficient", "Fractions", binom()),
|
||||
mk("sin", "Sine", "Functions", func("sin")),
|
||||
mk("cos", "Cosine", "Functions", func("cos")),
|
||||
mk("tan", "Tangent", "Functions", func("tan")),
|
||||
mk("log", "Logarithm", "Functions", func("log")),
|
||||
mk("ln", "Natural log", "Functions", func("ln")),
|
||||
mk("sinh", "Hyperbolic sine", "Functions", func("sinh")),
|
||||
mk("cosh", "Hyperbolic cosine", "Functions", func("cosh")),
|
||||
mk("tanh", "Hyperbolic tangent", "Functions", func("tanh")),
|
||||
mk("csch", "Hyperbolic cosecant", "Functions", func("csch")),
|
||||
mk("sech", "Hyperbolic secant", "Functions", func("sech")),
|
||||
mk("coth", "Hyperbolic cotangent", "Functions", func("coth")),
|
||||
mk("round", "Round", "Functions", func("round")),
|
||||
mk("sign", "Sign", "Functions", func("sign")),
|
||||
choose("nCr", "Combinations", "C"),
|
||||
choose("nPr", "Permutations", "P"),
|
||||
call2("lcm", "Least common multiple", "lcm"),
|
||||
mk("mean", "Mean", "Functions", func("mean")),
|
||||
mk("median", "Median", "Functions", func("median")),
|
||||
mk("stdev", "Standard deviation", "Functions", func("stdev")),
|
||||
mk("var", "Variance", "Functions", func("var")),
|
||||
Template {
|
||||
id: "lim",
|
||||
label: "Limit",
|
||||
group: "Functions",
|
||||
math: Math::new(vec![
|
||||
Node::Limit { lower: true, base: vec![Node::Styled { text: "lim".into(), style: Style::Plain }], lim: slot() },
|
||||
Node::Text(String::new()),
|
||||
]),
|
||||
},
|
||||
mk(
|
||||
"cases",
|
||||
"Cases",
|
||||
"Brackets",
|
||||
Node::Delim { open: "{".into(), close: String::new(), sep: "|".into(), items: vec![vec![Node::EqArr { rows: vec![slot(), slot()] }]] },
|
||||
),
|
||||
mk("overline", "Overline", "Accents", Node::Bar { top: true, body: slot() }),
|
||||
mk("underline", "Underline", "Accents", Node::Bar { top: false, body: slot() }),
|
||||
mk("overbrace", "Overbrace", "Accents", Node::GroupChr { ch: '\u{23DE}', top: true, body: slot() }),
|
||||
mk("hat", "Hat", "Accents", Node::Accent { ch: '\u{0302}', body: slot() }),
|
||||
mk("bar", "Bar", "Accents", Node::Accent { ch: '\u{0305}', body: slot() }),
|
||||
mk("vec", "Vector arrow", "Accents", Node::Accent { ch: '\u{20D7}', body: slot() }),
|
||||
]
|
||||
}
|
||||
|
||||
/// Look a template up by id.
|
||||
pub fn template(id: &str) -> Option<Template> {
|
||||
templates().into_iter().find(|t| t.id == id)
|
||||
}
|
||||
@@ -0,0 +1,491 @@
|
||||
//! Tiny generic XML tree used by the OMML reader. Infallible: malformed input yields whatever
|
||||
//! was parsed up to the error.
|
||||
|
||||
use crate::{MAX_DEPTH, MAX_NODES};
|
||||
|
||||
/// Deepest XML nesting kept: room for `MAX_DEPTH` levels of math nodes, each costing up to two
|
||||
/// XML levels (`m:f` + `m:num`), plus the `oMathPara`/`oMath` wrappers.
|
||||
pub const XML_DEPTH: usize = 2 * MAX_DEPTH + 8;
|
||||
use quick_xml::Reader;
|
||||
use quick_xml::events::{BytesStart, Event};
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum XNode {
|
||||
El(XEl),
|
||||
Text(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct XEl {
|
||||
/// Qualified name as written (`m:f`).
|
||||
pub name: String,
|
||||
pub attrs: Vec<(String, String)>,
|
||||
pub children: Vec<XNode>,
|
||||
/// `xmlns` declarations from ancestors that this element's subtree actually uses (bounded).
|
||||
pub inherited_ns: Arc<Vec<(String, String)>>,
|
||||
/// True when the element is in the OMML namespace (by URI; an unbound `m` or empty prefix
|
||||
/// counts, so bare fragments without declarations still read).
|
||||
pub math: bool,
|
||||
/// True when a needed inherited declaration was dropped to stay within the namespace byte
|
||||
/// budgets; serialising this element would then not be well-formed, so it is not emitted.
|
||||
pub ns_lost: bool,
|
||||
}
|
||||
|
||||
impl XEl {
|
||||
pub fn prefix(&self) -> &str {
|
||||
self.name.split_once(':').map(|(p, _)| p).unwrap_or("")
|
||||
}
|
||||
pub fn local(&self) -> &str {
|
||||
self.name.split_once(':').map(|(_, l)| l).unwrap_or(&self.name)
|
||||
}
|
||||
pub fn attr_local(&self, local: &str) -> Option<&str> {
|
||||
self.attrs.iter().find(|(k, _)| k.rsplit(':').next() == Some(local)).map(|(_, v)| v.as_str())
|
||||
}
|
||||
pub fn els(&self) -> impl Iterator<Item = &XEl> {
|
||||
self.children.iter().filter_map(|c| match c {
|
||||
XNode::El(e) => Some(e),
|
||||
XNode::Text(_) => None,
|
||||
})
|
||||
}
|
||||
pub fn text(&self) -> String {
|
||||
let mut s = String::new();
|
||||
for c in &self.children {
|
||||
match c {
|
||||
XNode::Text(t) => s.push_str(t),
|
||||
XNode::El(e) => s.push_str(&e.text()),
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
/// True for characters XML 1.0 can carry and that are not noncharacters.
|
||||
pub fn xml_ok(c: char) -> bool {
|
||||
let u = c as u32;
|
||||
matches!(u, 0x9 | 0xA | 0xD | 0x20..=0xFDCF | 0xFDF0..=0x10FFFF) && (u & 0xFFFE) != 0xFFFE
|
||||
}
|
||||
|
||||
/// Drop characters that cannot appear in XML (controls, noncharacters).
|
||||
pub fn clean(s: &str) -> String {
|
||||
if s.chars().all(xml_ok) { s.to_string() } else { s.chars().filter(|&c| xml_ok(c)).collect() }
|
||||
}
|
||||
|
||||
pub fn escape(s: &str, out: &mut String) {
|
||||
esc_into(s, out, false)
|
||||
}
|
||||
|
||||
/// Attribute values: tab, CR and LF are written as character references, since a conforming
|
||||
/// parser normalises literal ones to a space.
|
||||
pub fn escape_attr(s: &str, out: &mut String) {
|
||||
esc_into(s, out, true)
|
||||
}
|
||||
|
||||
fn esc_into(s: &str, out: &mut String, attr: bool) {
|
||||
for c in s.chars() {
|
||||
if !xml_ok(c) {
|
||||
continue;
|
||||
}
|
||||
match c {
|
||||
'\r' => out.push_str(" "),
|
||||
'\n' if attr => out.push_str(" "),
|
||||
'\t' if attr => out.push_str("	"),
|
||||
'&' => out.push_str("&"),
|
||||
'<' => out.push_str("<"),
|
||||
'>' => out.push_str(">"),
|
||||
'"' => out.push_str("""),
|
||||
_ => out.push(c),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A name a conforming parser accepts: at most one colon, both parts non-empty NCNames.
|
||||
fn valid_name(n: &str) -> bool {
|
||||
let mut parts = n.split(':');
|
||||
let ok = |p: &str| {
|
||||
let mut cs = p.chars();
|
||||
cs.next().is_some_and(|c| c.is_alphabetic() || c == '_') && cs.all(|c| c.is_alphanumeric() || matches!(c, '_' | '-' | '.'))
|
||||
};
|
||||
let (a, b, c) = (parts.next(), parts.next(), parts.next());
|
||||
c.is_none() && a.is_some_and(ok) && b.is_none_or(ok)
|
||||
}
|
||||
|
||||
/// Serialise an element (with inherited namespace declarations it does not declare itself).
|
||||
pub fn write_el(e: &XEl, with_inherited: bool, out: &mut String, depth: usize) {
|
||||
if depth > XML_DEPTH + 4 || !valid_name(&e.name) || (with_inherited && e.ns_lost) {
|
||||
return;
|
||||
}
|
||||
out.push('<');
|
||||
out.push_str(&e.name);
|
||||
if with_inherited {
|
||||
for (k, v) in e.inherited_ns.iter() {
|
||||
if !e.attrs.iter().any(|(ak, _)| ak == k) {
|
||||
out.push(' ');
|
||||
out.push_str(k);
|
||||
out.push_str("=\"");
|
||||
escape_attr(v, out);
|
||||
out.push('"');
|
||||
}
|
||||
}
|
||||
}
|
||||
for (k, v) in e.attrs.iter().filter(|(k, _)| valid_name(k)) {
|
||||
out.push(' ');
|
||||
out.push_str(k);
|
||||
out.push_str("=\"");
|
||||
escape_attr(v, out);
|
||||
out.push('"');
|
||||
}
|
||||
if e.children.is_empty() {
|
||||
out.push_str("/>");
|
||||
return;
|
||||
}
|
||||
out.push('>');
|
||||
for c in &e.children {
|
||||
match c {
|
||||
XNode::Text(t) => escape(t, out),
|
||||
XNode::El(ch) => write_el(ch, false, out, depth + 1),
|
||||
}
|
||||
}
|
||||
out.push_str("</");
|
||||
out.push_str(&e.name);
|
||||
out.push('>');
|
||||
}
|
||||
|
||||
/// The attributes of `e` as ` name="value"` text, for re-emission on a rewritten element whose
|
||||
/// name is `m:*` under a root that declares `xmlns:m`. Declarations of the OMML namespace are
|
||||
/// dropped (attributes in it are renamed to `m:`), a declaration an attribute needs is added from
|
||||
/// the inherited set, and an attribute whose prefix is unbound is left out.
|
||||
///
|
||||
/// Known limitation (accepted): OMML that rebinds prefixes, for example an alias prefix bound to
|
||||
/// the OMML URI together with a different URI bound to `m`, is mis-namespaced on rewrite.
|
||||
pub fn start_extras(e: &XEl, omml_ns: &str) -> String {
|
||||
if e.ns_lost {
|
||||
return String::new();
|
||||
}
|
||||
let lookup = |p: &str| -> Option<&str> {
|
||||
let key = format!("xmlns:{p}");
|
||||
e.attrs
|
||||
.iter()
|
||||
.find(|(k, _)| *k == key)
|
||||
.map(|(_, v)| v.as_str())
|
||||
.or_else(|| e.inherited_ns.iter().find(|(k, _)| *k == key).map(|(_, v)| v.as_str()))
|
||||
};
|
||||
let mut decls = String::new();
|
||||
let mut attrs = String::new();
|
||||
for (k, v) in &e.attrs {
|
||||
if !valid_name(k) {
|
||||
continue;
|
||||
}
|
||||
let put = |name: &str, out: &mut String| {
|
||||
out.push(' ');
|
||||
out.push_str(name);
|
||||
out.push_str("=\"");
|
||||
escape_attr(v, out);
|
||||
out.push('"');
|
||||
};
|
||||
if let Some(p) = k.strip_prefix("xmlns:") {
|
||||
if v != omml_ns && p != "m" {
|
||||
put(k, &mut decls);
|
||||
}
|
||||
} else if k == "xmlns" {
|
||||
// A default namespace would change the meaning of the rewritten element.
|
||||
} else if let Some((p, l)) = k.split_once(':') {
|
||||
if p == "xml" {
|
||||
put(k, &mut attrs);
|
||||
} else {
|
||||
match lookup(p) {
|
||||
Some(ns) if ns == omml_ns => put(&format!("m:{l}"), &mut attrs),
|
||||
Some(ns) => {
|
||||
if !e.attrs.iter().any(|(ak, _)| ak.strip_prefix("xmlns:") == Some(p)) && !decls.contains(&format!(" xmlns:{p}=")) {
|
||||
decls.push_str(&format!(" xmlns:{p}=\""));
|
||||
escape_attr(ns, &mut decls);
|
||||
decls.push('"');
|
||||
}
|
||||
put(k, &mut attrs);
|
||||
}
|
||||
None if p == "m" => put(k, &mut attrs),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
put(k, &mut attrs);
|
||||
}
|
||||
}
|
||||
decls + &attrs
|
||||
}
|
||||
|
||||
fn start_el(b: &BytesStart, reader: &Reader<&[u8]>) -> XEl {
|
||||
let name = String::from_utf8_lossy(b.name().as_ref()).into_owned();
|
||||
let mut attrs = vec![];
|
||||
for a in b.attributes().with_checks(false).flatten().take(MAX_NS_DECLS + MAX_ATTRS + 64) {
|
||||
let k = String::from_utf8_lossy(a.key.as_ref()).into_owned();
|
||||
let v =
|
||||
a.decode_and_unescape_value(reader.decoder()).map(|c| c.into_owned()).unwrap_or_else(|_| String::from_utf8_lossy(&a.value).into_owned());
|
||||
attrs.push((k, clean(&v)));
|
||||
}
|
||||
let mut el = XEl { name, attrs, children: vec![], inherited_ns: Arc::default(), math: false, ns_lost: false };
|
||||
cap_decls(&mut el);
|
||||
el
|
||||
}
|
||||
|
||||
fn push_text(p: &mut XEl, s: &str) {
|
||||
let s = &clean(s);
|
||||
if s.is_empty() {
|
||||
return;
|
||||
}
|
||||
if let Some(XNode::Text(t)) = p.children.last_mut() {
|
||||
t.push_str(s);
|
||||
} else {
|
||||
p.children.push(XNode::Text(s.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Most `xmlns` declarations honoured on one element, and most distinct unbound prefixes tracked
|
||||
/// per subtree. Both bound the work and the size of hostile namespace input.
|
||||
const MAX_NS_DECLS: usize = 64;
|
||||
const MAX_UNBOUND: usize = 64;
|
||||
/// Most plain attributes kept on one element. Re-emission scans an element's attributes once per
|
||||
/// attribute, so an unbounded count would be quadratic on hostile input.
|
||||
const MAX_ATTRS: usize = 256;
|
||||
/// Bytes of inherited declarations copied onto one element, and onto all elements of a document.
|
||||
const MAX_INHERIT_ELEM: usize = 8 * 1024;
|
||||
const MAX_INHERIT_DOC: usize = 16 * 1024 * 1024;
|
||||
|
||||
type Scope = HashMap<String, Vec<String>>;
|
||||
|
||||
/// Identical inherited-declaration sets are shared in memory; the document budget is still
|
||||
/// charged for every copy that will be emitted.
|
||||
type Interner = HashMap<Vec<(String, String)>, Arc<Vec<(String, String)>>>;
|
||||
|
||||
struct Budget {
|
||||
left: usize,
|
||||
sets: Interner,
|
||||
}
|
||||
|
||||
fn is_decl(k: &str) -> bool {
|
||||
k == "xmlns" || k.starts_with("xmlns:")
|
||||
}
|
||||
|
||||
/// Keep at most [`MAX_NS_DECLS`] namespace declarations on an element.
|
||||
fn cap_decls(el: &mut XEl) {
|
||||
let mut seen = 0usize;
|
||||
let mut plain = 0usize;
|
||||
el.attrs.retain(|(k, _)| {
|
||||
if is_decl(k) {
|
||||
seen += 1;
|
||||
seen <= MAX_NS_DECLS
|
||||
} else {
|
||||
plain += 1;
|
||||
plain <= MAX_ATTRS
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Record the prefixes this element's own name and attributes use.
|
||||
fn note_uses(el: &XEl, unbound: &mut BTreeSet<String>) {
|
||||
let mut add = |p: &str| {
|
||||
if unbound.len() < MAX_UNBOUND || unbound.contains(p) {
|
||||
unbound.insert(p.to_string());
|
||||
}
|
||||
};
|
||||
match el.name.split_once(':') {
|
||||
Some((p, _)) => add(&format!("xmlns:{p}")),
|
||||
None => add("xmlns"),
|
||||
}
|
||||
for (k, _) in &el.attrs {
|
||||
if is_decl(k) {
|
||||
continue;
|
||||
}
|
||||
if let Some((p, _)) = k.split_once(':')
|
||||
&& p != "xml"
|
||||
{
|
||||
add(&format!("xmlns:{p}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Namespace test by URI, falling back to the conventional spelling when the prefix is unbound.
|
||||
fn resolve_math(el: &XEl, scope: &Scope) -> bool {
|
||||
let key = match el.name.split_once(':') {
|
||||
Some((p, _)) => format!("xmlns:{p}"),
|
||||
None => "xmlns".to_string(),
|
||||
};
|
||||
match scope.get(&key).and_then(|v| v.last()) {
|
||||
Some(uri) => uri == crate::OMML_NS,
|
||||
None => matches!(el.prefix(), "m" | ""),
|
||||
}
|
||||
}
|
||||
|
||||
fn enter(el: &XEl, scope: &mut Scope) {
|
||||
for (k, v) in &el.attrs {
|
||||
if is_decl(k) {
|
||||
scope.entry(k.clone()).or_default().push(v.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn leave(el: &XEl, scope: &mut Scope) {
|
||||
for (k, _) in &el.attrs {
|
||||
if is_decl(k)
|
||||
&& let Some(v) = scope.get_mut(k)
|
||||
{
|
||||
v.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Finish an element: names not declared within its own subtree are resolved against the scope
|
||||
/// of its ancestors (lazily, only for prefixes actually used) and the rest bubbles to the parent.
|
||||
fn finish(el: &mut XEl, mut unbound: BTreeSet<String>, scope: &Scope, parent: Option<&mut BTreeSet<String>>, budget: &mut Budget) {
|
||||
for (k, _) in &el.attrs {
|
||||
if is_decl(k) {
|
||||
unbound.remove(k.as_str());
|
||||
}
|
||||
}
|
||||
// `scope` still holds this element's own declarations, which were just removed from `unbound`.
|
||||
let mut used = 0usize;
|
||||
let mut mine: Vec<(String, String)> = vec![];
|
||||
for k in &unbound {
|
||||
if let Some(v) = scope.get(k).and_then(|v| v.last()) {
|
||||
let cost = k.len() + v.len() + 4;
|
||||
if used + cost > MAX_INHERIT_ELEM {
|
||||
el.ns_lost = true;
|
||||
continue;
|
||||
}
|
||||
used += cost;
|
||||
mine.push((k.clone(), v.clone()));
|
||||
}
|
||||
}
|
||||
if !mine.is_empty() {
|
||||
// Charged per emitted copy: every element re-serialises its own inherited set, so the
|
||||
// document budget bounds the total bytes written, not the distinct sets stored.
|
||||
if used > budget.left {
|
||||
el.ns_lost = true;
|
||||
} else {
|
||||
budget.left -= used;
|
||||
if let Some(shared) = budget.sets.get(&mine) {
|
||||
el.inherited_ns = Arc::clone(shared);
|
||||
} else {
|
||||
let shared = Arc::new(mine.clone());
|
||||
budget.sets.insert(mine, Arc::clone(&shared));
|
||||
el.inherited_ns = shared;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(p) = parent {
|
||||
for k in unbound {
|
||||
if p.len() < MAX_UNBOUND || p.contains(&k) {
|
||||
p.insert(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse into a forest of top-level elements.
|
||||
pub fn parse(xml: &str) -> Vec<XEl> {
|
||||
let mut reader = Reader::from_str(xml);
|
||||
let mut stack: Vec<XEl> = vec![];
|
||||
let mut roots: Vec<XEl> = vec![];
|
||||
let mut overflow = 0usize;
|
||||
let mut nodes = 0usize;
|
||||
let mut unb: Vec<BTreeSet<String>> = vec![];
|
||||
let mut scope: Scope = HashMap::new();
|
||||
let mut budget = Budget { left: MAX_INHERIT_DOC, sets: HashMap::new() };
|
||||
let close = |stack: &mut Vec<XEl>, unb: &mut Vec<BTreeSet<String>>, scope: &mut Scope, roots: &mut Vec<XEl>, budget: &mut Budget| {
|
||||
if let Some(mut el) = stack.pop() {
|
||||
let mine = unb.pop().unwrap_or_default();
|
||||
finish(&mut el, mine, scope, unb.last_mut(), budget);
|
||||
leave(&el, scope);
|
||||
match stack.last_mut() {
|
||||
Some(p) => p.children.push(XNode::El(el)),
|
||||
None => roots.push(el),
|
||||
}
|
||||
}
|
||||
};
|
||||
while let Ok(ev) = reader.read_event() {
|
||||
if matches!(ev, Event::Start(_) | Event::Empty(_)) {
|
||||
nodes += 1;
|
||||
if nodes > MAX_NODES {
|
||||
break;
|
||||
}
|
||||
}
|
||||
match ev {
|
||||
Event::Start(b) => {
|
||||
if overflow > 0 || stack.len() >= XML_DEPTH {
|
||||
overflow += 1;
|
||||
} else {
|
||||
let mut el = start_el(&b, &reader);
|
||||
enter(&el, &mut scope);
|
||||
el.math = resolve_math(&el, &scope);
|
||||
let mut u = BTreeSet::new();
|
||||
note_uses(&el, &mut u);
|
||||
unb.push(u);
|
||||
stack.push(el);
|
||||
}
|
||||
}
|
||||
Event::Empty(b) => {
|
||||
if overflow == 0 && stack.len() < XML_DEPTH {
|
||||
let mut el = start_el(&b, &reader);
|
||||
enter(&el, &mut scope);
|
||||
el.math = resolve_math(&el, &scope);
|
||||
let mut u = BTreeSet::new();
|
||||
note_uses(&el, &mut u);
|
||||
finish(&mut el, u, &scope, unb.last_mut(), &mut budget);
|
||||
leave(&el, &mut scope);
|
||||
match stack.last_mut() {
|
||||
Some(p) => p.children.push(XNode::El(el)),
|
||||
None => roots.push(el),
|
||||
}
|
||||
}
|
||||
}
|
||||
Event::End(_) => {
|
||||
if overflow > 0 {
|
||||
overflow -= 1;
|
||||
} else {
|
||||
close(&mut stack, &mut unb, &mut scope, &mut roots, &mut budget);
|
||||
}
|
||||
}
|
||||
Event::Text(t) => {
|
||||
if overflow == 0
|
||||
&& let Some(p) = stack.last_mut()
|
||||
{
|
||||
let s = t.decode().map(|c| c.into_owned()).unwrap_or_default();
|
||||
push_text(p, &s);
|
||||
}
|
||||
}
|
||||
Event::CData(t) => {
|
||||
if overflow == 0
|
||||
&& let Some(p) = stack.last_mut()
|
||||
{
|
||||
push_text(p, &String::from_utf8_lossy(&t));
|
||||
}
|
||||
}
|
||||
Event::GeneralRef(r) => {
|
||||
if overflow == 0
|
||||
&& let Some(p) = stack.last_mut()
|
||||
{
|
||||
let s = if r.is_char_ref() {
|
||||
r.resolve_char_ref().ok().flatten().map(String::from).unwrap_or_default()
|
||||
} else {
|
||||
match r.decode().map(|c| c.into_owned()).unwrap_or_default().as_str() {
|
||||
"amp" => "&".into(),
|
||||
"lt" => "<".into(),
|
||||
"gt" => ">".into(),
|
||||
"quot" => "\"".into(),
|
||||
"apos" => "'".into(),
|
||||
_ => String::new(),
|
||||
}
|
||||
};
|
||||
push_text(p, &s);
|
||||
}
|
||||
}
|
||||
Event::Eof => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
while !stack.is_empty() {
|
||||
close(&mut stack, &mut unb, &mut scope, &mut roots, &mut budget);
|
||||
}
|
||||
roots
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
//! Property tests over the shared corpus (`corpus/equations.tsv`, `corpus/omml/*.xml`): the
|
||||
//! OMML and linear round trips are stable for every item.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use equashion_math::{Math, from_linear, from_omml, to_linear, to_omml, unknown_commands};
|
||||
|
||||
fn corpus_dir() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../corpus")
|
||||
}
|
||||
|
||||
/// (id, linear, display) rows of `equations.tsv`.
|
||||
fn rows() -> Vec<(String, String, bool)> {
|
||||
let text = std::fs::read_to_string(corpus_dir().join("equations.tsv")).expect("corpus/equations.tsv");
|
||||
let rows: Vec<_> = text
|
||||
.lines()
|
||||
.filter(|l| !l.trim().is_empty() && !l.starts_with('#'))
|
||||
.map(|l| {
|
||||
let f: Vec<&str> = l.split('\t').collect();
|
||||
assert_eq!(f.len(), 3, "malformed row: {l:?}");
|
||||
(f[0].to_string(), f[1].to_string(), f[2].trim() == "1")
|
||||
})
|
||||
.collect();
|
||||
assert!(rows.len() >= 40, "{} rows", rows.len());
|
||||
rows
|
||||
}
|
||||
|
||||
/// (file name, OMML) of every `corpus/omml/*.xml`.
|
||||
fn omml_samples() -> Vec<(String, String)> {
|
||||
let mut v: Vec<_> = std::fs::read_dir(corpus_dir().join("omml"))
|
||||
.expect("corpus/omml")
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.extension().is_some_and(|e| e == "xml"))
|
||||
.map(|p| (p.file_name().unwrap().to_string_lossy().to_string(), std::fs::read_to_string(&p).unwrap()))
|
||||
.collect();
|
||||
v.sort();
|
||||
assert!(v.len() >= 3);
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_corpus_row_parses_without_unknown_commands() {
|
||||
let mut ids = std::collections::HashSet::new();
|
||||
for (id, lin, _) in rows() {
|
||||
assert!(ids.insert(id.clone()), "duplicate id {id}");
|
||||
assert!(id.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'), "id {id}");
|
||||
let m = from_linear(&lin);
|
||||
assert!(!m.body.is_empty(), "{id}");
|
||||
assert!(unknown_commands(&m).is_empty(), "{id}: {:?}", unknown_commands(&m));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_omml_of_from_omml_is_idempotent() {
|
||||
let check = |name: &str, m: &Math| {
|
||||
let x0 = to_omml(m);
|
||||
let x1 = to_omml(&from_omml(&x0));
|
||||
assert_eq!(x0, x1, "{name}");
|
||||
};
|
||||
for (id, lin, display) in rows() {
|
||||
let mut m = from_linear(&lin);
|
||||
m.para = display;
|
||||
check(&id, &m);
|
||||
}
|
||||
for (name, x) in omml_samples() {
|
||||
let m = from_omml(&x);
|
||||
assert!(!m.body.is_empty(), "{name} parses to something");
|
||||
check(&name, &m);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_linear_of_to_linear_is_stable() {
|
||||
let check = |name: &str, m: &Math| {
|
||||
let back = from_linear(&to_linear(m));
|
||||
assert_eq!(&back.body, &m.body, "{name}: {}", to_linear(m));
|
||||
// And the text itself is a fixed point.
|
||||
assert_eq!(to_linear(&back), to_linear(m), "{name}");
|
||||
};
|
||||
for (id, lin, _) in rows() {
|
||||
// The typed (LaTeX-ish) input is not itself the canonical linear text; its tree is.
|
||||
check(&id, &from_linear(&lin));
|
||||
}
|
||||
// OMML run properties (`m:rPr`, `a:rPr`, ...) have no linear form, so for the OMML samples
|
||||
// only the linear text is compared: it must be a fixed point.
|
||||
for (name, x) in omml_samples() {
|
||||
let lin = to_linear(&from_omml(&x));
|
||||
assert_eq!(to_linear(&from_linear(&lin)), lin, "{name}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
//! `corpus/latex.tsv`: MathLive-style LaTeX inputs and the LaTeX `to_latex` prints for them.
|
||||
//! The viSHual repo checks the same file against its LaTeX-to-expression converter.
|
||||
|
||||
use equashion_math::{from_linear, to_latex};
|
||||
|
||||
#[test]
|
||||
fn every_corpus_line_prints_as_recorded_and_is_stable() {
|
||||
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../corpus/latex.tsv");
|
||||
let src = std::fs::read_to_string(path).expect("corpus/latex.tsv");
|
||||
let mut n = 0;
|
||||
for line in src.lines().filter(|l| !l.starts_with('#') && !l.is_empty()) {
|
||||
let (input, want) = line.split_once('\t').expect("input<TAB>latex");
|
||||
let got = to_latex(&from_linear(input));
|
||||
assert_eq!(got, want, "input {input:?}");
|
||||
assert_eq!(to_latex(&from_linear(&got)), got, "not stable for {input:?}");
|
||||
n += 1;
|
||||
}
|
||||
assert!(n > 50, "corpus has {n} lines");
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
//! MathML over the whole corpus (`corpus/equations.tsv`, `corpus/latex.tsv`, `corpus/omml/*.xml`
|
||||
//! and the template palette): the output is well-formed XML with the MathML namespace on the
|
||||
//! root, has no DOCTYPE and no named entities, and `from_mathml(to_mathml(m))` is stable.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use equashion_math::{Math, Node, Seq, from_linear, from_mathml, from_omml, templates, to_mathml, to_mathml_display};
|
||||
use quick_xml::Reader;
|
||||
use quick_xml::events::Event;
|
||||
|
||||
fn corpus_dir() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../corpus")
|
||||
}
|
||||
|
||||
/// Every equation of the corpus: (name, tree, from linear text).
|
||||
fn corpus() -> Vec<(String, Math, bool)> {
|
||||
let mut out = Vec::new();
|
||||
let eq = std::fs::read_to_string(corpus_dir().join("equations.tsv")).unwrap();
|
||||
for l in eq.lines().filter(|l| !l.trim().is_empty() && !l.starts_with('#')) {
|
||||
let f: Vec<&str> = l.split('\t').collect();
|
||||
let mut m = from_linear(f[1]);
|
||||
m.para = f[2].trim() == "1";
|
||||
out.push((f[0].to_string(), m, true));
|
||||
}
|
||||
let lx = std::fs::read_to_string(corpus_dir().join("latex.tsv")).unwrap();
|
||||
for l in lx.lines().filter(|l| !l.trim().is_empty() && !l.starts_with('#')) {
|
||||
let src = l.split('\t').next().unwrap();
|
||||
out.push((format!("latex.tsv {src}"), from_linear(src), true));
|
||||
}
|
||||
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.into_iter().filter(|p| p.extension().is_some_and(|e| e == "xml")) {
|
||||
let x = std::fs::read_to_string(&p).unwrap();
|
||||
out.push((p.file_name().unwrap().to_string_lossy().to_string(), from_omml(&x), false));
|
||||
}
|
||||
for t in templates() {
|
||||
out.push((format!("template {}", t.id), t.math.clone(), false));
|
||||
}
|
||||
// Constructs the corpus files do not happen to use.
|
||||
for src in [r"\text{if } x>0", r"{}_a^b X", r"\underline{x}", r"\mathbf{v}+\boldsymbol{w}"] {
|
||||
out.push((format!("extra {src}"), from_linear(src), true));
|
||||
}
|
||||
assert!(out.len() > 100, "{} items", out.len());
|
||||
out
|
||||
}
|
||||
|
||||
/// Parse with quick-xml (end names checked); panics on any error. Returns the element names.
|
||||
fn well_formed(name: &str, x: &str) -> Vec<String> {
|
||||
let mut r = Reader::from_str(x);
|
||||
let mut names = Vec::new();
|
||||
let mut depth = 0i32;
|
||||
let mut roots = 0;
|
||||
loop {
|
||||
match r.read_event() {
|
||||
Ok(Event::Start(b)) => {
|
||||
if depth == 0 {
|
||||
roots += 1;
|
||||
let ns = b.attributes().flatten().find(|a| a.key.as_ref() == b"xmlns").map(|a| a.value.to_vec());
|
||||
assert_eq!(ns.as_deref(), Some(&b"http://www.w3.org/1998/Math/MathML"[..]), "{name}: root namespace");
|
||||
}
|
||||
depth += 1;
|
||||
names.push(String::from_utf8_lossy(b.name().as_ref()).to_string());
|
||||
}
|
||||
Ok(Event::Empty(b)) => {
|
||||
assert!(depth > 0, "{name}: empty root");
|
||||
names.push(String::from_utf8_lossy(b.name().as_ref()).to_string());
|
||||
}
|
||||
Ok(Event::End(_)) => depth -= 1,
|
||||
Ok(Event::GeneralRef(g)) => assert!(g.is_char_ref(), "{name}: named entity &{};", String::from_utf8_lossy(&g)),
|
||||
Ok(Event::DocType(_)) => panic!("{name}: DOCTYPE"),
|
||||
Ok(Event::Decl(_)) => panic!("{name}: XML declaration"),
|
||||
Ok(Event::Text(t)) => assert!(depth > 0 || t.iter().all(u8::is_ascii_whitespace), "{name}: text outside the root"),
|
||||
Ok(Event::Eof) => break,
|
||||
Ok(_) => {}
|
||||
Err(e) => panic!("{name}: not well-formed at {}: {e}\n{x}", r.buffer_position()),
|
||||
}
|
||||
}
|
||||
assert_eq!(depth, 0, "{name}: unclosed");
|
||||
assert_eq!(roots, 1, "{name}: one root");
|
||||
assert_eq!(names.first().map(String::as_str), Some("math"), "{name}");
|
||||
assert!(names.iter().all(|n| !n.contains(':')), "{name}: prefixed element");
|
||||
names
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_corpus_item_is_well_formed_mathml() {
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
for (name, m, _) in corpus() {
|
||||
let x = to_mathml(&m);
|
||||
assert!(x.starts_with("<math xmlns=\"http://www.w3.org/1998/Math/MathML\" display=\""), "{name}: {x}");
|
||||
let block = to_mathml_display(&m, true);
|
||||
assert!(block.starts_with("<math xmlns=\"http://www.w3.org/1998/Math/MathML\" display=\"block\">"), "{name}");
|
||||
// No named entity at all, not even the five XML ones.
|
||||
assert!(!x.contains("&") && !x.contains("<") && !x.contains(">") && !x.contains(""") && !x.contains("'"), "{name}: {x}");
|
||||
for n in well_formed(&name, &x) {
|
||||
seen.insert(n);
|
||||
}
|
||||
well_formed(&name, &block);
|
||||
}
|
||||
for want in [
|
||||
"mfrac",
|
||||
"msqrt",
|
||||
"mroot",
|
||||
"msup",
|
||||
"msub",
|
||||
"msubsup",
|
||||
"munderover",
|
||||
"munder",
|
||||
"mover",
|
||||
"mtable",
|
||||
"mtr",
|
||||
"mtd",
|
||||
"mi",
|
||||
"mn",
|
||||
"mo",
|
||||
"mtext",
|
||||
"mspace",
|
||||
"mrow",
|
||||
"menclose",
|
||||
] {
|
||||
assert!(seen.contains(want), "no corpus item uses <{want}>; seen {seen:?}");
|
||||
}
|
||||
assert!(!seen.contains("mfenced"));
|
||||
}
|
||||
|
||||
/// The tree with what MathML does not carry removed: OMML-only properties, typed ASCII spaces.
|
||||
fn norm(s: &Seq) -> Seq {
|
||||
let mut out: Seq = Vec::new();
|
||||
for n in s {
|
||||
let n = match n.bare().clone() {
|
||||
Node::Text(t) => Node::Text(t.replace(' ', "")),
|
||||
Node::Frac { num, den } => Node::Frac { num: norm(&num), den: norm(&den) },
|
||||
Node::Rad { deg, body } => Node::Rad { deg: deg.map(|d| norm(&d)), body: norm(&body) },
|
||||
Node::Script { base, sub, sup } => Node::Script { base: norm(&base), sub: sub.map(|d| norm(&d)), sup: sup.map(|d| norm(&d)) },
|
||||
Node::Nary { op, sub, sup, body } => Node::Nary { op, sub: sub.map(|d| norm(&d)), sup: sup.map(|d| norm(&d)), body: norm(&body) },
|
||||
Node::Delim { open, close, sep, items } => Node::Delim { open, close, sep, items: items.iter().map(norm).collect() },
|
||||
Node::Matrix { rows } => Node::Matrix { rows: rows.iter().map(|r| r.iter().map(norm).collect()).collect() },
|
||||
Node::Func { name, body } => Node::Func { name, body: norm(&body) },
|
||||
Node::Accent { ch, body } => Node::Accent { ch, body: norm(&body) },
|
||||
Node::Limit { lower, base, lim } => Node::Limit { lower, base: norm(&base), lim: norm(&lim) },
|
||||
Node::EqArr { rows } => Node::EqArr { rows: rows.iter().map(norm).collect() },
|
||||
Node::PreScript { sub, sup, base } => Node::PreScript { sub: norm(&sub), sup: norm(&sup), base: norm(&base) },
|
||||
Node::Bar { top, body } => Node::Bar { top, body: norm(&body) },
|
||||
Node::GroupChr { ch, top, body } => Node::GroupChr { ch, top, body: norm(&body) },
|
||||
Node::Boxed { border, body } => Node::Boxed { border, body: norm(&body) },
|
||||
other => other,
|
||||
};
|
||||
match (out.last_mut(), n) {
|
||||
(_, Node::Text(t)) if t.is_empty() => {}
|
||||
(Some(Node::Text(a)), Node::Text(b)) => a.push_str(&b),
|
||||
(_, n) => out.push(n),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_mathml_of_to_mathml_is_stable() {
|
||||
for (name, m, typed) in corpus() {
|
||||
let x1 = to_mathml(&m);
|
||||
let back = from_mathml(&x1).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
assert_eq!(back.para, m.para, "{name}");
|
||||
let x2 = to_mathml(&back);
|
||||
assert_eq!(x1, x2, "{name}: not a fixed point");
|
||||
// Typed equations come back as the same tree (up to typed spaces).
|
||||
if typed {
|
||||
assert_eq!(norm(&back.body), norm(&m.body), "{name}\n{x1}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
[package]
|
||||
name = "equashion-render"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "equaSHion rendering: equation scenes to RGBA pixels (vello_cpu) and to SVG"
|
||||
|
||||
[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 = ["raster", "extra-fonts"]
|
||||
extra-fonts = ["equashion-layout/extra-fonts"]
|
||||
# RGBA rendering through vello_cpu. SVG output does not need it.
|
||||
raster = ["dep:vello_cpu"]
|
||||
|
||||
[dependencies]
|
||||
equashion-layout = { workspace = true }
|
||||
kurbo = { workspace = true }
|
||||
vello_cpu = { workspace = true, optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
equashion-math = { workspace = true }
|
||||
@@ -0,0 +1,20 @@
|
||||
//! Per-keystroke cost: parse + layout + SVG for typical rows (native; wasm is a small multiple).
|
||||
use equashion_layout::{DEFAULT_FAMILY, FontDb, Rgb, layout_equation};
|
||||
use std::time::Instant;
|
||||
|
||||
fn main() {
|
||||
let fonts = FontDb::bundled_only();
|
||||
let rows = [r"y=x^2", r"y=\frac{x^2-1}{x+1}", r"a=\frac{-b\pm\sqrt{b^{2}-4ac}}{2a}", r"\sum_{n=1}^{5}\left(n^2+\frac{1}{n}\right)"];
|
||||
for src in rows {
|
||||
let n = 300;
|
||||
let t = Instant::now();
|
||||
let mut bytes = 0;
|
||||
for _ in 0..n {
|
||||
let m = equashion_math::from_linear(src);
|
||||
let eq = layout_equation(&fonts, &m, DEFAULT_FAMILY, 24.0, true, Rgb::BLACK, 4.0);
|
||||
bytes = equashion_render::scene_to_svg(&eq.scene, &fonts, DEFAULT_FAMILY).len();
|
||||
}
|
||||
let per = t.elapsed().as_secs_f64() * 1000.0 / n as f64;
|
||||
println!("{per:6.2} ms {bytes:6} B svg {src}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
//! equaSHion rendering: a laid-out [`Scene`] to premultiplied RGBA pixels ([`render_scene`],
|
||||
//! with vello_cpu, behind the default `raster` feature) or to an SVG document ([`scene_to_svg`]).
|
||||
//!
|
||||
//! Without `raster` only the SVG path is built and `vello_cpu` is not a dependency, which is
|
||||
//! what a browser UI that draws equations as SVG wants.
|
||||
//!
|
||||
//! The raster path is WordCraft's `render_eq` and the `Draw::Glyphs` / `Draw::Fill` /
|
||||
//! `Draw::Path` branches of its display-list renderer, including synthetic bold (the outline
|
||||
//! stroked at 3% of the em as well as filled) and synthetic italic (a -0.21 horizontal skew).
|
||||
//! The SVG path draws the same primitives with the same transforms.
|
||||
//!
|
||||
//! ```
|
||||
//! use equashion_layout::{DEFAULT_FAMILY, FontDb, Rgb, layout_equation};
|
||||
//!
|
||||
//! let fonts = FontDb::bundled_only();
|
||||
//! let m = equashion_math::from_linear(r"x=\frac{-b\pm\sqrt{b^2-4ac}}{2a}");
|
||||
//! let eq = layout_equation(&fonts, &m, DEFAULT_FAMILY, 24.0, true, Rgb::BLACK, 4.0);
|
||||
//! let svg = equashion_render::scene_to_svg(&eq.scene, &fonts, DEFAULT_FAMILY);
|
||||
//! let pixels = equashion_render::render_eq(&eq, &fonts, 2.0);
|
||||
//! assert!(svg.contains("<rect ") && pixels.width > 0);
|
||||
//! ```
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod svg;
|
||||
|
||||
use kurbo::Affine;
|
||||
|
||||
#[cfg(feature = "raster")]
|
||||
use equashion_layout::{EqLayout, MathFonts, Prim, Rgb, Scene};
|
||||
#[cfg(feature = "raster")]
|
||||
use vello_cpu::peniko;
|
||||
#[cfg(feature = "raster")]
|
||||
use vello_cpu::{RenderContext, Resources};
|
||||
|
||||
pub use svg::{fmt2, scene_to_svg};
|
||||
|
||||
/// Largest raster side vello_cpu handles comfortably.
|
||||
#[cfg(feature = "raster")]
|
||||
pub const MAX_SIDE: u32 = 16_000;
|
||||
|
||||
/// Horizontal shear of synthetic italic (WordCraft's value).
|
||||
pub const SYNTH_ITALIC_SKEW: f64 = -0.21;
|
||||
|
||||
/// Stroke width of synthetic bold, as a fraction of the em (WordCraft's value).
|
||||
pub const SYNTH_BOLD_STROKE: f64 = 0.03;
|
||||
|
||||
/// A rendered image (premultiplied RGBA8, row-major).
|
||||
#[cfg(feature = "raster")]
|
||||
#[derive(Clone, Default)]
|
||||
pub struct Rendered {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub pixels: Vec<u8>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "raster")]
|
||||
impl Rendered {
|
||||
/// The pixels with alpha un-premultiplied (straight RGBA8).
|
||||
pub fn to_straight(&self) -> Vec<u8> {
|
||||
let mut out = self.pixels.clone();
|
||||
for px in out.as_chunks_mut::<4>().0 {
|
||||
let a = px[3] as u32;
|
||||
if a != 0 && a != 255 {
|
||||
for c in px.iter_mut().take(3) {
|
||||
*c = ((*c as u32 * 255 + a / 2) / a).min(255) as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
/// Straight RGBA at (x, y) (transparent outside).
|
||||
pub fn pixel(&self, x: u32, y: u32) -> [u8; 4] {
|
||||
if x >= self.width || y >= self.height {
|
||||
return [0; 4];
|
||||
}
|
||||
let i = ((y as usize * self.width as usize) + x as usize) * 4;
|
||||
let Some(p) = self.pixels.get(i..i + 4) else { return [0; 4] };
|
||||
let a = p[3] as u32;
|
||||
if a == 0 {
|
||||
return [0; 4];
|
||||
}
|
||||
let un = |c: u8| ((c as u32 * 255 + a / 2) / a).min(255) as u8;
|
||||
[un(p[0]), un(p[1]), un(p[2]), p[3]]
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "raster")]
|
||||
fn color(c: Rgb) -> peniko::Color {
|
||||
peniko::Color::from_rgba8(c.0, c.1, c.2, 255)
|
||||
}
|
||||
|
||||
/// The transform that places glyph outlines (font units, y down) of a face with `upem` units
|
||||
/// per em at `size` points, with its origin at (x, y), slanted for synthetic italic.
|
||||
pub fn glyph_transform(x: f64, y: f64, size: f64, upem: f64, synth_italic: bool) -> Affine {
|
||||
let k = size / upem.max(1.0);
|
||||
let skew = if synth_italic { Affine::new([1.0, 0.0, SYNTH_ITALIC_SKEW, 1.0, 0.0, 0.0]) } else { Affine::IDENTITY };
|
||||
Affine::translate((x, y)) * skew * Affine::scale(k)
|
||||
}
|
||||
|
||||
/// Render `scene` at `scale` pixels per point, on `background` (transparent when `None`).
|
||||
#[cfg(feature = "raster")]
|
||||
pub fn render_scene(scene: &Scene, fonts: &dyn MathFonts, scale: f64, background: Option<Rgb>) -> Rendered {
|
||||
let scale = if scale.is_finite() { scale.clamp(0.1, 16.0) } else { 1.0 };
|
||||
let side = |v: f64| if v.is_finite() { (v * scale).ceil().clamp(1.0, MAX_SIDE as f64) as u16 } else { 1 };
|
||||
let (w, h) = (side(scene.width), side(scene.height));
|
||||
let mut ctx = RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() });
|
||||
if let Some(bg) = background {
|
||||
ctx.set_transform(Affine::IDENTITY);
|
||||
ctx.set_paint(color(bg));
|
||||
ctx.fill_rect(&kurbo::Rect::new(0.0, 0.0, w as f64, h as f64));
|
||||
}
|
||||
let view = Affine::scale(scale);
|
||||
for p in &scene.items {
|
||||
draw(&mut ctx, fonts, p, view);
|
||||
}
|
||||
ctx.flush();
|
||||
let mut pixels = vec![0u8; w as usize * h as usize * 4];
|
||||
let mut res = Resources::new();
|
||||
if let Some(pm) = vello_cpu::PixmapMut::new(w, h, &mut pixels) {
|
||||
ctx.render(pm, &mut res);
|
||||
}
|
||||
Rendered { width: w as u32, height: h as u32, pixels }
|
||||
}
|
||||
|
||||
/// Render an equation laid out by [`equashion_layout::layout_equation`] on white (the equation
|
||||
/// editor's canvas); `scale` is pixels per point.
|
||||
#[cfg(feature = "raster")]
|
||||
pub fn render_eq(l: &EqLayout, fonts: &dyn MathFonts, scale: f64) -> Rendered {
|
||||
render_scene(&l.scene, fonts, scale, Some(Rgb::WHITE))
|
||||
}
|
||||
|
||||
#[cfg(feature = "raster")]
|
||||
fn finite(r: &kurbo::Rect) -> bool {
|
||||
r.x0.is_finite() && r.y0.is_finite() && r.x1.is_finite() && r.y1.is_finite()
|
||||
}
|
||||
|
||||
#[cfg(feature = "raster")]
|
||||
fn draw(ctx: &mut RenderContext, fonts: &dyn MathFonts, p: &Prim, view: Affine) {
|
||||
match p {
|
||||
Prim::Rule(r, c) => {
|
||||
if !finite(r) {
|
||||
return;
|
||||
}
|
||||
ctx.set_transform(view);
|
||||
ctx.set_paint(color(*c));
|
||||
ctx.fill_rect(r);
|
||||
}
|
||||
Prim::Path(path, c) => {
|
||||
if !finite(&kurbo::Shape::bounding_box(path)) {
|
||||
return;
|
||||
}
|
||||
ctx.set_transform(view);
|
||||
ctx.set_paint(color(*c));
|
||||
ctx.fill_path(path);
|
||||
}
|
||||
Prim::Glyphs { font, size, glyphs, color: c, synth_bold, synth_italic } => {
|
||||
let Some(face) = fonts.face_by_id(*font) else { return };
|
||||
ctx.set_paint(color(*c));
|
||||
if *synth_bold {
|
||||
ctx.set_stroke(kurbo::Stroke::new(face.upem * SYNTH_BOLD_STROKE));
|
||||
}
|
||||
for (gid, x, y) in glyphs {
|
||||
let o = fonts.outline(&face, *gid);
|
||||
if o.elements().is_empty() {
|
||||
continue;
|
||||
}
|
||||
ctx.set_transform(view * glyph_transform(*x as f64, *y as f64, *size as f64, face.upem, *synth_italic));
|
||||
ctx.fill_path(&o);
|
||||
if *synth_bold {
|
||||
ctx.stroke_path(&o);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use equashion_layout::{DEFAULT_FAMILY, FontDb, layout_equation};
|
||||
use equashion_math::from_linear;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
fn fonts() -> &'static FontDb {
|
||||
static DB: OnceLock<FontDb> = OnceLock::new();
|
||||
DB.get_or_init(FontDb::bundled_only)
|
||||
}
|
||||
|
||||
fn ink(r: &Rendered) -> usize {
|
||||
(0..r.height).flat_map(|y| (0..r.width).map(move |x| (x, y))).filter(|&(x, y)| r.pixel(x, y)[0] < 128).count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_equation_renders_dark_pixels_on_white() {
|
||||
let l = layout_equation(fonts(), &from_linear("x^2/sqrt(y)"), DEFAULT_FAMILY, 24.0, true, Rgb::BLACK, 4.0);
|
||||
let r = render_eq(&l, fonts(), 2.0);
|
||||
assert_eq!((r.width, r.height), ((l.width() * 2.0).ceil() as u32, (l.height() * 2.0).ceil() as u32));
|
||||
assert_eq!(r.pixel(0, 0), [255, 255, 255, 255], "white canvas");
|
||||
assert!(ink(&r) > 200, "glyphs, the bar and the radical are drawn");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_transparent_scene_has_only_ink() {
|
||||
let l = layout_equation(fonts(), &from_linear("a+b"), DEFAULT_FAMILY, 24.0, false, Rgb(200, 0, 0), 2.0);
|
||||
let r = render_scene(&l.scene, fonts(), 1.0, None);
|
||||
assert_eq!(r.pixel(0, 0), [0; 4]);
|
||||
assert!(r.to_straight().as_chunks::<4>().0.iter().any(|p| p[3] == 255 && p[0] == 200 && p[1] == 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthetic_bold_and_italic_change_the_ink() {
|
||||
let db = fonts();
|
||||
let face = db.face("Inter", "Regular");
|
||||
let gid = face.glyph_for('l');
|
||||
let glyph = |synth_bold, synth_italic| Scene {
|
||||
items: vec![Prim::Glyphs {
|
||||
font: face.face_id(),
|
||||
size: 40.0,
|
||||
glyphs: vec![(gid, 10.0, 40.0)],
|
||||
color: Rgb::BLACK,
|
||||
synth_bold,
|
||||
synth_italic,
|
||||
}],
|
||||
width: 40.0,
|
||||
height: 50.0,
|
||||
};
|
||||
let plain = render_scene(&glyph(false, false), db, 1.0, Some(Rgb::WHITE));
|
||||
let bold = render_scene(&glyph(true, false), db, 1.0, Some(Rgb::WHITE));
|
||||
let italic = render_scene(&glyph(false, true), db, 1.0, Some(Rgb::WHITE));
|
||||
assert!(ink(&bold) > ink(&plain), "bold is heavier: {} vs {}", ink(&bold), ink(&plain));
|
||||
// The skew moves the top of the stem right of the bottom.
|
||||
let leftmost = |r: &Rendered, y: u32| (0..r.width).find(|&x| r.pixel(x, y)[0] < 128);
|
||||
let (top, bottom) = (leftmost(&italic, 14), leftmost(&italic, 38));
|
||||
assert!(top > bottom, "{top:?} {bottom:?}");
|
||||
assert_eq!(leftmost(&plain, 14), leftmost(&plain, 38), "upright stem");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_scales_and_sizes_are_clamped() {
|
||||
let l = layout_equation(fonts(), &from_linear("x"), DEFAULT_FAMILY, 24.0, false, Rgb::BLACK, 0.0);
|
||||
for s in [f64::NAN, f64::INFINITY, -3.0, 0.0, 1e9] {
|
||||
let r = render_eq(&l, fonts(), s);
|
||||
assert!(r.width >= 1 && r.width <= MAX_SIDE && r.height >= 1);
|
||||
}
|
||||
let huge = Scene { items: vec![], width: f64::INFINITY, height: 1e12 };
|
||||
let r = render_scene(&huge, fonts(), 1.0, None);
|
||||
assert!(r.width == 1 && r.height == MAX_SIDE);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
//! A [`Scene`] as an SVG document: every glyph, rule and shape as a filled path in absolute
|
||||
//! coordinates (points), numbers printed to 2 decimals. Deterministic for a given scene and font
|
||||
//! database, so it serves as the golden-file format.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use equashion_layout::{MathFonts, Prim, Rgb, Scene};
|
||||
use kurbo::{BezPath, PathEl};
|
||||
|
||||
use crate::{SYNTH_BOLD_STROKE, glyph_transform};
|
||||
|
||||
/// `v` with 2 decimals; never `-0.00`, and `0.00` for a non-finite value.
|
||||
pub fn fmt2(v: f64) -> String {
|
||||
if !v.is_finite() {
|
||||
return "0.00".into();
|
||||
}
|
||||
let s = format!("{v:.2}");
|
||||
if s == "-0.00" { "0.00".into() } else { s }
|
||||
}
|
||||
|
||||
fn hex(c: Rgb) -> String {
|
||||
format!("#{:02x}{:02x}{:02x}", c.0, c.1, c.2)
|
||||
}
|
||||
|
||||
fn path_d(p: &BezPath) -> String {
|
||||
let mut d = String::new();
|
||||
let pt = |d: &mut String, x: f64, y: f64| {
|
||||
let _ = write!(d, "{} {}", fmt2(x), fmt2(y));
|
||||
};
|
||||
for el in p.elements() {
|
||||
if !d.is_empty() {
|
||||
d.push(' ');
|
||||
}
|
||||
match *el {
|
||||
PathEl::MoveTo(a) => {
|
||||
d.push('M');
|
||||
pt(&mut d, a.x, a.y);
|
||||
}
|
||||
PathEl::LineTo(a) => {
|
||||
d.push('L');
|
||||
pt(&mut d, a.x, a.y);
|
||||
}
|
||||
PathEl::QuadTo(a, b) => {
|
||||
d.push('Q');
|
||||
pt(&mut d, a.x, a.y);
|
||||
d.push(' ');
|
||||
pt(&mut d, b.x, b.y);
|
||||
}
|
||||
PathEl::CurveTo(a, b, c) => {
|
||||
d.push('C');
|
||||
pt(&mut d, a.x, a.y);
|
||||
d.push(' ');
|
||||
pt(&mut d, b.x, b.y);
|
||||
d.push(' ');
|
||||
pt(&mut d, c.x, c.y);
|
||||
}
|
||||
PathEl::ClosePath => d.push('Z'),
|
||||
}
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
/// Escape text for an XML attribute value.
|
||||
fn attr(s: &str) -> String {
|
||||
s.replace('&', "&").replace('"', """).replace('<', "<").replace('>', ">")
|
||||
}
|
||||
|
||||
/// The scene as a standalone SVG document. `family` is recorded on the root element (as
|
||||
/// `data-family`) so a golden file names the font it was made with; pass `""` to leave it out.
|
||||
pub fn scene_to_svg(scene: &Scene, fonts: &dyn MathFonts, family: &str) -> String {
|
||||
let (w, h) = (fmt2(scene.width), fmt2(scene.height));
|
||||
let mut out = String::new();
|
||||
let fam = if family.is_empty() { String::new() } else { format!(" data-family=\"{}\"", attr(family)) };
|
||||
let _ = writeln!(out, "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{w}\" height=\"{h}\" viewBox=\"0 0 {w} {h}\"{fam}>");
|
||||
for p in &scene.items {
|
||||
match p {
|
||||
Prim::Rule(r, c) => {
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"<rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" fill=\"{}\"/>",
|
||||
fmt2(r.x0),
|
||||
fmt2(r.y0),
|
||||
fmt2(r.width()),
|
||||
fmt2(r.height()),
|
||||
hex(*c)
|
||||
);
|
||||
}
|
||||
Prim::Path(path, c) => {
|
||||
let _ = writeln!(out, "<path d=\"{}\" fill=\"{}\"/>", path_d(path), hex(*c));
|
||||
}
|
||||
Prim::Glyphs { font, size, glyphs, color, synth_bold, synth_italic } => {
|
||||
let Some(face) = fonts.face_by_id(*font) else { continue };
|
||||
let stroke = if *synth_bold {
|
||||
format!(" stroke=\"{}\" stroke-width=\"{}\"", hex(*color), fmt2(*size as f64 * SYNTH_BOLD_STROKE))
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"<g fill=\"{}\"{stroke} data-face=\"{} {}\" data-size=\"{}\">",
|
||||
hex(*color),
|
||||
attr(&face.family),
|
||||
attr(&face.style),
|
||||
fmt2(*size as f64)
|
||||
);
|
||||
for (gid, x, y) in glyphs {
|
||||
let o = fonts.outline(&face, *gid);
|
||||
if o.elements().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut q = (*o).clone();
|
||||
q.apply_affine(glyph_transform(*x as f64, *y as f64, *size as f64, face.upem, *synth_italic));
|
||||
let _ = writeln!(out, "<path data-gid=\"{gid}\" d=\"{}\"/>", path_d(&q));
|
||||
}
|
||||
out.push_str("</g>\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push_str("</svg>\n");
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use equashion_layout::{DEFAULT_FAMILY, FontDb, layout_equation};
|
||||
use equashion_math::from_linear;
|
||||
|
||||
#[test]
|
||||
fn numbers_have_two_decimals_and_no_negative_zero() {
|
||||
assert_eq!(fmt2(1.0), "1.00");
|
||||
assert_eq!(fmt2(-0.001), "0.00");
|
||||
assert_eq!(fmt2(2.345), "2.35");
|
||||
assert_eq!(fmt2(f64::NAN), "0.00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_equation_becomes_paths_and_rects() {
|
||||
let db = FontDb::bundled_only();
|
||||
let l = layout_equation(&db, &from_linear("a/b+sqrt(x)"), DEFAULT_FAMILY, 20.0, false, Rgb::BLACK, 2.0);
|
||||
let svg = scene_to_svg(&l.scene, &db, DEFAULT_FAMILY);
|
||||
assert!(svg.starts_with("<svg xmlns=\"http://www.w3.org/2000/svg\""));
|
||||
assert!(svg.contains("data-family=\"Source Serif 4\""));
|
||||
assert!(svg.contains("<rect "), "fraction bar");
|
||||
assert!(svg.contains("data-gid="), "glyphs");
|
||||
assert!(svg.trim_end().ends_with("</svg>"));
|
||||
// The same scene gives the same text.
|
||||
assert_eq!(svg, scene_to_svg(&l.scene, &db, DEFAULT_FAMILY));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
//! Golden tests: every row of `corpus/equations.tsv` and every `corpus/omml/*.xml` sample is laid
|
||||
//! out with the bundled fonts only and compared with `tests/golden/<id>.svg` (the SVG backend)
|
||||
//! and `tests/golden/<id>.json` (metrics: width, ascent, descent, rows).
|
||||
//!
|
||||
//! Rewrite the files with `EQUASHION_BLESS=1 cargo test -p equashion-render` (or
|
||||
//! `cargo xtask bless`) after an intended change, and review the diff.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::fmt::Write as _;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use equashion_layout::{DEFAULT_FAMILY, FontDb, Rgb, layout_equation_wrapped, layout_math_wrapped};
|
||||
use equashion_math::{Math, from_linear, from_omml};
|
||||
use equashion_render::{fmt2, render_scene, scene_to_svg};
|
||||
|
||||
/// The family every golden is made with. Pinned here (not just inherited from
|
||||
/// `DEFAULT_FAMILY`) so a change of default shows up as a test failure, not as new goldens.
|
||||
const FAMILY: &str = "Source Serif 4";
|
||||
/// Font size, points.
|
||||
const SIZE: f64 = 24.0;
|
||||
/// Padding around the equation, points.
|
||||
const PAD: f64 = 4.0;
|
||||
/// Line width, points: long equations wrap, unbreakable ones shrink.
|
||||
const LINE: f64 = 400.0;
|
||||
|
||||
fn root() -> PathBuf {
|
||||
let p = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
p.canonicalize().unwrap_or(p)
|
||||
}
|
||||
|
||||
fn golden_dir() -> PathBuf {
|
||||
root().join("tests/golden")
|
||||
}
|
||||
|
||||
/// (id, equation, display) for every corpus item.
|
||||
fn corpus() -> Vec<(String, Math, bool)> {
|
||||
let text = std::fs::read_to_string(root().join("corpus/equations.tsv")).expect("corpus/equations.tsv");
|
||||
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();
|
||||
assert_eq!(f.len(), 3, "malformed row {l:?}");
|
||||
(f[0].to_string(), from_linear(f[1]), f[2].trim() == "1")
|
||||
})
|
||||
.collect();
|
||||
let mut files: Vec<PathBuf> = std::fs::read_dir(root().join("corpus/omml")).expect("corpus/omml").flatten().map(|e| e.path()).collect();
|
||||
files.sort();
|
||||
for p in files.into_iter().filter(|p| p.extension().is_some_and(|e| e == "xml")) {
|
||||
let stem = p.file_stem().unwrap().to_string_lossy().to_string();
|
||||
let m = from_omml(&std::fs::read_to_string(&p).unwrap());
|
||||
let display = m.para;
|
||||
v.push((format!("omml_{stem}"), m, display));
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn fonts() -> FontDb {
|
||||
FontDb::bundled_only()
|
||||
}
|
||||
|
||||
fn metrics_json(db: &FontDb, m: &Math, display: bool, rows: usize) -> String {
|
||||
let b = layout_math_wrapped(db, m, FAMILY, SIZE, false, display, LINE);
|
||||
let mut s = String::from("{\n");
|
||||
let _ = writeln!(s, " \"family\": \"{FAMILY}\",");
|
||||
let _ = writeln!(s, " \"size\": {},", fmt2(SIZE));
|
||||
let _ = writeln!(s, " \"line\": {},", fmt2(LINE));
|
||||
let _ = writeln!(s, " \"display\": {display},");
|
||||
let _ = writeln!(s, " \"width\": {},", fmt2(b.width()));
|
||||
let _ = writeln!(s, " \"ascent\": {},", fmt2(b.ascent()));
|
||||
let _ = writeln!(s, " \"descent\": {},", fmt2(b.descent()));
|
||||
let last = b.last_line_width().map_or("null".to_string(), fmt2);
|
||||
let _ = writeln!(s, " \"last_line_width\": {last},");
|
||||
let _ = writeln!(s, " \"rows\": {rows}");
|
||||
s.push_str("}\n");
|
||||
s
|
||||
}
|
||||
|
||||
/// Compare `got` with the file at `path` (or write it when blessing). Returns a message on
|
||||
/// mismatch.
|
||||
fn compare(path: &Path, got: &str, bless: bool) -> Option<String> {
|
||||
if bless {
|
||||
if std::fs::read_to_string(path).ok().as_deref() != Some(got) {
|
||||
std::fs::write(path, got).unwrap();
|
||||
}
|
||||
return None;
|
||||
}
|
||||
let Ok(want) = std::fs::read_to_string(path) else {
|
||||
return Some(format!("{}: missing (run `cargo xtask bless`)", path.display()));
|
||||
};
|
||||
if want == got {
|
||||
return None;
|
||||
}
|
||||
let line = want.lines().zip(got.lines()).position(|(a, b)| a != b).unwrap_or_else(|| want.lines().count().min(got.lines().count()));
|
||||
let w = want.lines().nth(line).unwrap_or("<end>");
|
||||
let g = got.lines().nth(line).unwrap_or("<end>");
|
||||
let cut = |s: &str| s.chars().take(160).collect::<String>();
|
||||
Some(format!("{}: differs at line {}\n want: {}\n got: {}", path.display(), line + 1, cut(w), cut(g)))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn goldens_match() {
|
||||
assert_eq!(FAMILY, DEFAULT_FAMILY, "the goldens pin the default family");
|
||||
let bless = std::env::var_os("EQUASHION_BLESS").is_some_and(|v| v != "0" && !v.is_empty());
|
||||
let dir = golden_dir();
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let db = fonts();
|
||||
let mut failures = vec![];
|
||||
let mut expected: BTreeSet<String> = BTreeSet::new();
|
||||
let items = corpus();
|
||||
assert!(items.len() >= 40, "{} corpus items", items.len());
|
||||
for (id, m, display) in &items {
|
||||
let l = layout_equation_wrapped(&db, m, FAMILY, SIZE, *display, Rgb::BLACK, PAD, LINE);
|
||||
// Every face the scene uses is a bundled one: the pinned family, Inter, or STIX Two Math
|
||||
// (the fallback for symbols the text faces lack), and no glyph is .notdef.
|
||||
for p in &l.scene.items {
|
||||
if let equashion_layout::Prim::Glyphs { font, glyphs, .. } = p {
|
||||
let f = db.face_by_id(*font).unwrap();
|
||||
assert!(f.family == FAMILY || f.family == "Inter" || f.family == "STIX Two Math", "{id}: {f:?}");
|
||||
assert!(glyphs.iter().all(|g| g.0 != 0), "{id}: .notdef glyph in {f:?}");
|
||||
}
|
||||
}
|
||||
// The raster backend draws the same scene without trouble.
|
||||
let r = render_scene(&l.scene, &db, 1.0, Some(Rgb::WHITE));
|
||||
assert!(r.width >= 1 && r.height >= 1, "{id}");
|
||||
let svg = scene_to_svg(&l.scene, &db, FAMILY);
|
||||
let json = metrics_json(&db, m, *display, l.rows());
|
||||
for (ext, text) in [("svg", svg), ("json", json)] {
|
||||
let name = format!("{id}.{ext}");
|
||||
expected.insert(name.clone());
|
||||
failures.extend(compare(&dir.join(&name), &text, bless));
|
||||
}
|
||||
}
|
||||
// Golden files with no corpus item.
|
||||
for e in std::fs::read_dir(&dir).unwrap().flatten() {
|
||||
let name = e.file_name().to_string_lossy().to_string();
|
||||
if (name.ends_with(".svg") || name.ends_with(".json")) && !expected.contains(&name) {
|
||||
if bless {
|
||||
std::fs::remove_file(e.path()).unwrap();
|
||||
} else {
|
||||
failures.push(format!("{}: stale (no corpus item; `cargo xtask bless` removes it)", e.path().display()));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(failures.is_empty(), "{} golden mismatches:\n{}", failures.len(), failures.join("\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn goldens_are_deterministic() {
|
||||
// Two databases, two layouts: the same SVG (face ids differ between databases, the output
|
||||
// must not).
|
||||
let (a, b) = (fonts(), fonts());
|
||||
for (id, m, display) in corpus().iter().take(12) {
|
||||
let la = layout_equation_wrapped(&a, m, FAMILY, SIZE, *display, Rgb::BLACK, PAD, LINE);
|
||||
let lb = layout_equation_wrapped(&b, m, FAMILY, SIZE, *display, Rgb::BLACK, PAD, LINE);
|
||||
assert_eq!(scene_to_svg(&la.scene, &a, FAMILY), scene_to_svg(&lb.scene, &b, FAMILY), "{id}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
# Divergence from WordCraft dbfe1b1
|
||||
|
||||
Behaviour that differs from the imported WordCraft code, and why. Structural changes (the
|
||||
`Scene` seam, the `MathFonts` trait, the trimmed font crate) are in PROVENANCE.md; this file is
|
||||
about what a user would notice.
|
||||
|
||||
## Edit model: exponent rules from DeckCraft 7972cf5
|
||||
|
||||
Three DeckCraft tests were ported into `crates/math/src/edit/tests.rs`. All three failed against
|
||||
WordCraft's `edit.rs`. In each case DeckCraft's behaviour was chosen, because it matches how
|
||||
people type the formulas (and MathLive): the exponent stops where a reader would expect it to.
|
||||
The rules were ported into `edit.rs` from DeckCraft's `edit.rs`. All 134 WordCraft math tests
|
||||
still pass unchanged.
|
||||
|
||||
| Test | WordCraft dbfe1b1 | Now (DeckCraft 7972cf5) |
|
||||
|---|---|---|
|
||||
| `a_single_digit_exponent_ends_at_the_next_term` | `mv^2/2` became `mv^(2/2)`; `x^2sin x` became `x^(2 sin x)` | A digits-only exponent ends at the next character that is not a digit, `.`, `^`, `_` or whitespace: `(mv^2)/2`, `x^2 · sin x`. `2^10` still keeps going. |
|
||||
| `letters_keep_going_in_an_exponent` | `x^n+1` left the exponent at `+` (`x^n + 1`); `e^ipi` stayed `e^(ipi)` | An operator leaves a digits-only exponent (`x^2+1`) but stays in a letter exponent (`x^(n+1)`, `2^(n−1)`). A three-letter run ending in `pi` converts the `pi` (`e^(iπ)`). |
|
||||
| `a_parenthesised_exponent_is_the_group_itself` | `x^(n+1)` dropped the parentheses but left the caret in the exponent | The parentheses are still dropped, and closing the group also takes the caret out of the exponent. |
|
||||
|
||||
What changed in `edit.rs`:
|
||||
|
||||
- `type_char_raw`: before handling the character, a caret at the end of a digits-only script
|
||||
slot steps out of it unless the character is a digit, `.`, `^`, `_`, whitespace or a control
|
||||
character. It is skipped inside a typed LaTeX `{...}` argument (`brace_depth > 0`), so
|
||||
`x^{2a}` still works. DeckCraft has no LaTeX brace handling, so this guard is new.
|
||||
- `leave_func_arg`: an operator leaves a script slot only when the slot holds digits.
|
||||
- `unwrap_frac_paren`: after unwrapping the group of a script slot, the caret steps out of the
|
||||
slot. Fractions, radicals and big-operator limits behave as before.
|
||||
- `autoconvert`: a three-letter run ending in `pi` converts the `pi`.
|
||||
- New helpers `slot_is_digits` and `exit_script_slot`, taken from DeckCraft.
|
||||
|
||||
Side effect: `x^2 y` used to give `x^(2y)`; it now gives `x^2 y`, because the `y` ends the
|
||||
digit exponent. The same applies to subscripts (`a_1b` is `a_1 b`, `x_i+1` is `x_(i+1)`).
|
||||
|
||||
## Default equation font
|
||||
|
||||
WordCraft's equation editor (`eqdialog.rs`, `EQ_FAMILY`) asked for "Cambria Math", which is
|
||||
not bundled. Unless the system had it installed, the font database fell back to Source Sans 3
|
||||
without saying so. equaSHion names its default family,
|
||||
`equashion_font::DEFAULT_FAMILY` = "Source Serif 4", bundles all four styles of it, and pins
|
||||
that family in the golden tests. An unknown family still falls back, now to Source Serif 4.
|
||||
"Cambria Math" is still present in some OMML test data in `crates/math/src/tests.rs`, because
|
||||
Word writes it there, but it is never a default.
|
||||
|
||||
## Editor canvas height
|
||||
|
||||
`layout_equation` sized the canvas from the equation's ink (with a floor of 0.78 em above and
|
||||
0.25 em below the baseline). A row's caret reaches at least 0.22 em below its baseline even
|
||||
where the row has no ink there, so in a display fraction such as `lim_{x→0} sin x / x` the
|
||||
caret in the denominator stuck out of the canvas by about a point. The canvas now also covers
|
||||
every caret row. Found by the corpus property test
|
||||
(`crates/layout/tests/corpus.rs`), which checks that every caret lies inside the canvas.
|
||||
|
||||
## Symbols the bundled fonts lack: STIX Two Math fallback
|
||||
|
||||
WordCraft's `FontDb::global()` loads system fonts for characters its bundled fonts lack.
|
||||
`FontDb::bundled_only()` does not, and Source Serif 4 and Inter have no glyph for ∇ (U+2207),
|
||||
∮ (U+222E), ⌈ ⌉ ⌊ ⌋ (U+2308–230B) or ⟨ ⟩ (U+27E8, U+27E9); these used to draw as .notdef boxes.
|
||||
equaSHion now bundles STIX Two Math Regular (OFL, unmodified, 838 KB) after the text faces, so
|
||||
`fallback_for` finds it for exactly those characters (the fallback order is the default family
|
||||
first, then load order, so nothing Source Serif 4 or Inter covers moves). The `maxwell_*`,
|
||||
`oint`, `floor_ceil` and `angle` goldens changed accordingly; no other golden did. Pinned by
|
||||
`symbols_the_text_faces_lack_resolve_to_stix_two_math` and `no_corpus_equation_draws_a_missing_glyph`
|
||||
(`crates/layout/tests/corpus.rs`), the golden test (no .notdef glyph) and the widget's
|
||||
`every_palette_face_is_drawn_from_the_bundled_fonts`.
|
||||
|
||||
Visible side effects: ∮ comes from STIX at the big-operator size, so it is heavier than the
|
||||
Source Serif ∫ and its limits sit a little lower; ∇ is slightly lighter than the surrounding
|
||||
Source Serif glyphs.
|
||||
|
||||
## Typed bracket commands
|
||||
|
||||
The typed-text parser now knows `\langle`, `\rangle`, `\lfloor`, `\rfloor`, `\lceil` and
|
||||
`\rceil` (WordCraft reported them as unknown commands and kept them as literal text). They
|
||||
become the characters ⟨ ⟩ ⌊ ⌋ ⌈ ⌉ as text, the same as typing those characters; with `\left` /
|
||||
`\right` they are still text (those two are ignored, as before), so the drawn angle and floor
|
||||
delimiters of the layout are only reached from OMML.
|
||||
|
||||
## Closing a parenthesis ends the structure it closes
|
||||
|
||||
`sqrt(x)+1`, `sin(x)+1` and `1/(x-1)+2` used to leave the caret inside the radical, the function
|
||||
argument or the denominator after the `)`, so the `+1` was typed into it (`√(x+1)`, a doubled
|
||||
`sin((x))`). Closing a parenthesis that is the whole slot now ends the structure, the way
|
||||
`x^(n+1)` already did and the way Desmos behaves: the group's parentheses are dropped (the
|
||||
structure already groups) and the caret moves to just after it. `sqrt x+1` without parentheses
|
||||
still keeps taking what follows. Needed so equations typed for graphing mean what they say.
|
||||
@@ -0,0 +1,224 @@
|
||||
# Provenance
|
||||
|
||||
equaSHion is the equation editor extracted from WordCraft, with three edit-model tests taken
|
||||
from DeckCraft. It has its own git history: the files were imported with `git archive` from the
|
||||
commits below, not with `git filter-repo`, so the upstream history is not carried over.
|
||||
|
||||
## Sources
|
||||
|
||||
| Repository | Commit | Branch | Role |
|
||||
|---|---|---|---|
|
||||
| `shuff57/wordcraft` (fork of `storytold/wordcraft`) | `dbfe1b1` | `equation-editor` | Base implementation: math tree, OMML, linear format, templates, edit model, equation layout, font database, rasteriser |
|
||||
| `shuff57/deckcraft` | `7972cf5` | — | Three edit-model tests only (see below) |
|
||||
|
||||
Every file was read from those commits with
|
||||
`git -C <repo> archive <commit> <paths> | tar -x -C <scratch>`, never from a working tree.
|
||||
|
||||
## What was taken
|
||||
|
||||
### equashion-math (`crates/math`) <- WordCraft `crates/math`
|
||||
|
||||
Taken whole: `lib.rs`, `edit.rs`, `edit/tests.rs`, `linear.rs`, `omml.rs`, `templates.rs`,
|
||||
`tests.rs`, `xml.rs`, `Cargo.toml` (dependencies `serde`, `quick-xml`).
|
||||
|
||||
Changed:
|
||||
- Package renamed `wordcraft-math` -> `equashion-math`; the crate doc comment no longer names
|
||||
WordCraft.
|
||||
- `edit.rs`: DeckCraft's exponent rules (see DIVERGENCE.md).
|
||||
- `edit/tests.rs`: three tests appended from DeckCraft `7972cf5`
|
||||
`crates/math/src/edit/tests.rs`: `a_single_digit_exponent_ends_at_the_next_term`,
|
||||
`letters_keep_going_in_an_exponent`, `a_parenthesised_exponent_is_the_group_itself`. Nothing
|
||||
else was taken from DeckCraft.
|
||||
|
||||
### equashion-font (`crates/font`) <- WordCraft `crates/fonts` (trimmed)
|
||||
|
||||
Taken: `src/fontdb.rs` (`FontFace`, `FontDb`, outlines, `fallback_for`, the system font catalog
|
||||
behind `cfg(not(target_arch = "wasm32"))`), and from `src/lib.rs` `shape`, `feature`, `is_rtl`,
|
||||
`first_glyph`, `ShapedGlyph` and `DEFAULT_FAMILY`, with the tests that only need bundled fonts.
|
||||
|
||||
Trimmed:
|
||||
- `word.rs` (Word font-name resolution), `build.rs` and the craft-fonts Japanese/Chinese fonts
|
||||
(`CraftFont`, `japanese_*_fonts`, `chinese_fonts`, `ui_cjk_fonts`), `tests_sysfonts.rs`,
|
||||
`tests/system_fonts.rs`, the `variable_font_named_instances_are_styles` test (it read fonts
|
||||
from the system font folders) and the two craft-fonts tests.
|
||||
- `FaceRef` (a `&'static` handle made by leaking an `Arc` per face). Scenes name faces with the
|
||||
new `FaceId(u32)` (the face's process-unique id) and backends resolve it with the new
|
||||
`FontDb::face_by_id`.
|
||||
- Bundled fonts: Source Serif 4 Regular/Italic/Bold/BoldItalic and Inter Regular, byte-identical
|
||||
copies of WordCraft's `assets/fonts/` files, with their OFL texts. Source Sans 3, Source Serif
|
||||
4 Semibold, Inter Medium/SemiBold and JetBrains Mono are not bundled.
|
||||
- `FALLBACK_FAMILY` (an unknown family's replacement and the first fallback face) was "Source
|
||||
Sans 3"; it is now `DEFAULT_FAMILY`, "Source Serif 4". The last-resort face is Source Serif 4
|
||||
Regular.
|
||||
|
||||
Later added (not from WordCraft): STIX Two Math Regular, bundled after the text faces as the
|
||||
fallback for math symbols they lack (see DIVERGENCE.md and ATTRIBUTION.md).
|
||||
|
||||
Added: `FontDb::bundled_only()` (no font folders, system fallback off), `FaceId`,
|
||||
`FontFace::face_id`, `FontDb::face_by_id`, and tests that pin the bundled family list and the
|
||||
default family.
|
||||
|
||||
### equashion-layout (`crates/layout`) <- WordCraft `crates/layout/src/math.rs`
|
||||
|
||||
Taken: all of `math.rs` (`MathBox`, `SeqGeo`, `Lay`, `EqLayout`, `hit_geo`, `layout_equation`,
|
||||
`layout_math_wrapped`, `MathBox::last_line_width`, the drawn delimiters, radicals, big
|
||||
operators and braces, line wrapping and shrink-to-fit) and its 32 unit tests. Nothing else from
|
||||
WordCraft's layout crate: `para.rs`, `display.rs`, `hit.rs`, `table.rs`, `fields.rs` and the
|
||||
paragraph engine in `lib.rs` stay out.
|
||||
|
||||
Host coupling cut, at the smallest seam:
|
||||
- `wordcraft_doc::props::Rgb` -> a local `Rgb(pub u8, pub u8, pub u8)` (`scene.rs`).
|
||||
- `wordcraft_geom::Rect` (used only to emit fraction bars as `Draw::Fill`) -> `kurbo::Rect`.
|
||||
- `crate::display::Draw` -> the new backend-neutral `Scene { items: Vec<Prim>, width, height }`
|
||||
with `Prim::{Glyphs, Rule, Path}`, mirroring `math.rs`'s own `Item`. `emit()` lost its `alpha`
|
||||
argument and the `Draw::Glyphs` fields `text` and `link`, which equations never set.
|
||||
`EqLayout { draws, width, height }` became `EqLayout { scene }` with `width()` and
|
||||
`height()`.
|
||||
- `FontDb::global()` (5 call sites: `Faces::new`, `ink`, `Lay::pick`, `layout_equation` and
|
||||
the tests' `face()` helper) and `wordcraft_fonts::shape` (1 site, `Lay::text`) -> an explicit
|
||||
`&dyn MathFonts` argument, a trait implemented for `FontDb`. The trait has `face`, `fallback_for`, `outline`
|
||||
as specified plus `face_by_id`, which a backend needs to draw a `Prim::Glyphs`.
|
||||
- `layout_math` and `layout_math_wrapped` took the base `&FontFace` only to read its family;
|
||||
they now take the family name.
|
||||
- The process-wide `CACHE` static behind `layout_cached` -> an optional, host-owned
|
||||
`LayoutCache` (`LayoutCache::layout`); `layout_src` is the uncached form. `Src` and `Cached`
|
||||
are public.
|
||||
- Added `layout_equation_wrapped` (`layout_equation` with a line width; the goldens use it for
|
||||
the wrapping and shrink-to-fit cases). `layout_math`, `hit_geo` and `emit` became public.
|
||||
- `layout_equation` makes the canvas tall enough for every caret (see DIVERGENCE.md).
|
||||
|
||||
The only reach into paragraph layout was `para.rs` calling `layout_cached` and storing the
|
||||
`MathBox`; nothing in `math.rs` called into `para.rs` or `display.rs` apart from `Draw`.
|
||||
|
||||
Tests: the 32 `math.rs` unit tests, with the same assertions, now laid out against
|
||||
`FontDb::bundled_only()` in Inter as upstream. `layout_cache_returns_the_same_box` and
|
||||
`emit_places_glyphs_relative_to_origin` were rewritten for `LayoutCache` and `Prim`. From
|
||||
`layout/src/tests.rs`, the equation tests were rewritten to call `layout_equation` /
|
||||
`layout_math_wrapped` directly (`src/tests.rs`): taller-than-text with glyphs, bar and radical
|
||||
(plus the caret-width check that replaced the paragraph cluster check), hostile and broken
|
||||
OMML input, wrapping at operators, display style. Not ported, because they test the paragraph
|
||||
rather than the equation: `display_equation_is_centred_on_its_own_line`,
|
||||
`line_before_a_display_equation_is_not_justified`, and the line-padding half of
|
||||
`display_equation_line_has_room_above_and_below`.
|
||||
|
||||
### equashion-render (`crates/render`) <- WordCraft `crates/render/src/lib.rs` (parts)
|
||||
|
||||
Taken: `render_eq`, the `Draw::Glyphs` branch of `draw` (synthetic bold: the outline also
|
||||
stroked at `upem * 0.03`; synthetic italic: the `-0.21` skew), the `Draw::Fill` and
|
||||
`Draw::Path` branches as `Prim::Rule` and `Prim::Path`, `Rendered` with `to_straight` and
|
||||
`pixel`, `MAX_SIDE`. Not taken: page, region and thumbnail rendering, images, watermarks,
|
||||
lines, marks, shapes, PNG/JPEG export (and with it the `image` dependency). vello_cpu is used
|
||||
single-threaded (`num_threads: 0`, as `render_eq` did) without its `multithreading` feature.
|
||||
|
||||
Added: `render_scene` (any scene, optional background), `glyph_transform`, and an SVG backend
|
||||
(`scene_to_svg`, `fmt2`) that draws the same primitives with the same transforms.
|
||||
|
||||
### equashion-egui (`crates/egui`) <- WordCraft `crates/ui-egui/src/eqdialog.rs`
|
||||
|
||||
Imported with `git -C ~/wordcraft archive dbfe1b1 crates/ui-egui/src/eqdialog.rs`; DeckCraft's
|
||||
`7972cf5:crates/ui-egui/src/eqdialog.rs` was read for comparison only. WordCraft's dialog
|
||||
already had DeckCraft's Ctrl+Z/Y, Ctrl+C/X (via egui `Copy`/`Cut` events), Tab between places
|
||||
and Esc-asks-before-discarding, so no DeckCraft code was taken.
|
||||
|
||||
Taken (into `src/lib.rs` and `src/palette.rs`): the canvas (layout, shrink-to-fit and caret
|
||||
scrolling, active-place tint, selection, caret blink, placeholder text), the input mapping
|
||||
(`handle_input`), mouse placement, drag and shift-click selection, the palette tabs, groups,
|
||||
symbol sets and button faces (`glyph` -> `palette::label`), the linear field and display
|
||||
checkbox, the Esc discard logic.
|
||||
|
||||
Changed:
|
||||
- No host types: `WordApp`, `Session`, `app.run("insert.equation" / "equation.update")`,
|
||||
`sync_live`, `finish`, `wordcraft_doc::{Pos, Path}`, `serde_json` targets, Track Changes, the
|
||||
Insert / Update / Cancel buttons and `tl!` are gone. The host reads `EditorResponse
|
||||
{ changed, committed, cancelled }` and the equation (`linear()`, `omml()`, `math()`).
|
||||
- `theme::*` (`Tokens`, `theme::regular`, `theme::semibold`, `APP_COLOR`) -> `ui.visuals()`
|
||||
colours and `FontId::proportional` / `FontId::monospace`. `theme::semibold` was a
|
||||
`FontFamily::Name`, which panics on a context that never registered it.
|
||||
- Palette buttons were egui text buttons (the symbols came from the host's egui fonts); they
|
||||
are now custom buttons whose faces are small equations laid out and rasterised by
|
||||
equashion-render (structures show their empty places as boxes), tinted with the visuals'
|
||||
text colour, with the old face text as their accessible label. They are clickable but not
|
||||
focusable.
|
||||
- The canvas was a white texture with dark ink; the ink is now rendered white on transparent
|
||||
and tinted with `visuals.strong_text_color()` on `visuals.extreme_bg_color`.
|
||||
- Ids: `Id::new("eq-canvas")` / `"eq-linear"` -> derived from a per-widget `Id`; the texture
|
||||
name `"equation-canvas"` -> `equashion/editor/<id>/canvas`, palette textures
|
||||
`equashion/palette/<key>`. The canvas texture is updated in place instead of reloaded.
|
||||
- Focus: WordCraft's canvas took the keyboard back whenever the linear field did not hold it;
|
||||
it now only does so while no widget has focus, and gives up typing once another widget
|
||||
takes focus. Esc is handled only when the canvas or linear field had focus (the canvas keeps
|
||||
focus on Esc via its focus-lock filter).
|
||||
- Ctrl+C / Ctrl+X also act on the key event when the platform sent no `Copy` / `Cut` event in
|
||||
that frame (kittest and some backends send only the key).
|
||||
- Enter commits and resets the Esc baseline; Enter with a blank equation is reported as a
|
||||
commit (WordCraft refused it with a message); the host decides.
|
||||
- Family "Cambria Math" -> `DEFAULT_FAMILY`; canvas width follows the available width.
|
||||
|
||||
Tests (`src/tests.rs`, 25): ported from the dialog's 40 kittest tests, the ones that do not
|
||||
need the document: typing `x^2`, `_` `/` `sqrt` and Greek names, arrows and Backspace, undo /
|
||||
redo, Tab, Esc asks before discarding, shift-arrows plus a palette click wrapping the
|
||||
selection, the quadratic formula, typing after a palette click (now also asserting the caret
|
||||
and focus), palette structures at the caret, empty places, click placement, shift-click,
|
||||
linear field, hostile input, typed LaTeX, `lim_x`. New: Ctrl+C / Ctrl+X, Greek and operator
|
||||
tabs, a cross-row drag, typing once under multi-pass frames and with two editors, leaving a
|
||||
host text field alone, texture namespacing, the load/revert API. `tests/shared_context.rs`
|
||||
runs the widget on a context with and without a host-registered `FontFamily::Name("semibold")`
|
||||
and checks that fonts and styles are unchanged, plus a control test showing the named-family
|
||||
panic. Not ported (they test the document or the app): insert / update / re-edit, live
|
||||
editing in the page, Track Changes, Cancel undoing the live edit, the ribbon command,
|
||||
Alt+=, double-click in the page, short-window layout.
|
||||
|
||||
### equashion-web (`apps/equashion-web`, new)
|
||||
|
||||
Written for this repository. Packaging follows WordCraft `dbfe1b1` `apps/wordcraft-web`
|
||||
(`Cargo.toml`, `Trunk.toml`, `index.html`, and the `WebRunner` start-up and loading-message
|
||||
handling of `src/web.rs`, read with `git archive`). Changed from that reference: eframe's
|
||||
`glow` renderer (WebGL2) instead of `wgpu` (WebGPU with a WebGL2 fallback), no `rfd`, file
|
||||
drop or `Services`; `wasm-bindgen` pinned to `=0.2.128`; output to the workspace `dist/`;
|
||||
the `window.equashion` hooks are new. The look (`src/theme.rs`: colour tokens, light and dark
|
||||
themes, Geist fonts) and the About tab are new and were not taken from WordCraft.
|
||||
|
||||
### Corpus, goldens, xtask (new)
|
||||
|
||||
- `corpus/equations.tsv`: 44 typed equations written for this repository.
|
||||
- `corpus/omml/*.xml`: OMML taken verbatim from test fixtures (there are no `.docx`/`.pptx`
|
||||
fixture files with equations in either repository; the OMML lives in the Rust test sources):
|
||||
- `wordcraft_docx_fixture_ssup.xml` <- WordCraft `crates/docx/tests/fixtures.rs:378` (the
|
||||
`mm:oMath` element of the paragraph XML; a non-`m` prefix),
|
||||
- `wordcraft_docx_roundtrip_frac.xml` <- WordCraft `crates/docx/tests/roundtrip.rs:775`,
|
||||
- `wordcraft_docx_roundtrip_display_rad.xml` <- WordCraft `crates/docx/tests/roundtrip.rs:776`,
|
||||
- `wordcraft_docx_roundtrip_foreign_attrs.xml` <- WordCraft
|
||||
`crates/docx/tests/roundtrip.rs:933`,
|
||||
- `deckcraft_pptx_roundtrip_frac_rpr.xml` <- DeckCraft `crates/pptx/tests/roundtrip.rs:345`,
|
||||
- `deckcraft_pptx_roundtrip_frac.xml` <- DeckCraft `crates/pptx/tests/roundtrip.rs:695`.
|
||||
- `tests/golden/`: generated by `cargo xtask bless`.
|
||||
- `xtask`: written for this repository (`check`, `bless`, `web`, `serve` with a std-only static
|
||||
file server, `smoke`); WordCraft's xtask commands (assets,
|
||||
layers, parity, packaging, ...) were not taken. `.cargo/config.toml`, `rustfmt.toml` and
|
||||
`clippy.toml` are WordCraft's, unchanged.
|
||||
- `Cargo.lock` was seeded from WordCraft's lock at `dbfe1b1`, so every shared dependency
|
||||
resolves to the same version (kurbo 0.13.1, skrifa 0.47.0, harfrust 0.13.3, quick-xml 0.38.4,
|
||||
vello_cpu 0.2.0, ...). No `cargo update` was run.
|
||||
|
||||
## Not taken
|
||||
|
||||
Everything else in WordCraft: the document model, docx/odt/rtf/html/markdown/pdf formats,
|
||||
paragraph/table/page layout, the engine, MCP server, the egui app (the equation dialog was ported later as `equashion-egui`, see above), the web app,
|
||||
packaging, brand assets and docs, Source Sans 3, JetBrains Mono, the hyphenation data.
|
||||
|
||||
## Licence
|
||||
|
||||
WordCraft at `dbfe1b1` is `MIT OR Apache-2.0` (`license` field of its workspace `Cargo.toml`,
|
||||
`LICENSE-MIT`, `LICENSE-APACHE`, `NOTICE`). `LICENSE-MIT` and `LICENSE-APACHE` are copied
|
||||
unchanged; they keep the upstream copyright line ("ArtCraft Team and the WordCraft
|
||||
contributors"), which both licences require to be preserved. `NOTICE` is rewritten for this
|
||||
repository: it keeps that copyright line, adds "Modified by shuff57, 2026", and lists only the
|
||||
third-party material bundled here.
|
||||
|
||||
## Galleries (`crates/math/src/gallery.rs`, `gallery.json`)
|
||||
|
||||
The structure galleries (fractions through matrices, 222 templates), the eight symbol sets and the
|
||||
fourteen ready-made equations are WordCraft's `math_gallery.rs` at v0.5.0 (e4b7d15, Apache-2.0),
|
||||
exported once through WordCraft's own OMML writer and equaSHion's OMML reader, so each entry is an
|
||||
equaSHion tree. Only the ids, labels, linear text and symbols are carried; the font properties
|
||||
WordCraft attaches to every node were dropped.
|
||||
@@ -0,0 +1,22 @@
|
||||
# equaSHion in craft-libs
|
||||
|
||||
equaSHion is the equation editor that WordCraft and DeckCraft share. It was extracted from WordCraft
|
||||
(see [PROVENANCE.md](PROVENANCE.md)) and lives in the `equashion-*` crates here.
|
||||
|
||||
| Crate | What |
|
||||
|---|---|
|
||||
| `equashion-math` | math tree, OMML / MathML / LaTeX / linear text, templates, caret and selection edit model |
|
||||
| `equashion-font` | font database (bundled OFL fonts), metrics, glyph outlines, shaping |
|
||||
| `equashion-layout` | TeX-style layout into a backend-neutral scene, caret geometry |
|
||||
| `equashion-render` | scenes to RGBA pixels (vello_cpu) and SVG |
|
||||
| `equashion-egui` | the editor widget (canvas, palette, linear field), with no host types |
|
||||
|
||||
Layout in the repo (kept from the standalone repo so the crates' relative paths still work):
|
||||
|
||||
- `assets/fonts/`: the bundled fonts and their OFL texts, included with `include_bytes!`.
|
||||
- `corpus/`: typed equations, LaTeX and OMML samples the test suites read.
|
||||
- `tests/golden/`: the rendered goldens `equashion-render` compares against.
|
||||
|
||||
[DIVERGENCE.md](DIVERGENCE.md) lists where behaviour differs from the WordCraft code it came from.
|
||||
The crates keep the `equashion-*` names (not `craft-*`) because three apps already import them by
|
||||
that name.
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"family": "Source Serif 4",
|
||||
"size": 24.00,
|
||||
"line": 400.00,
|
||||
"display": false,
|
||||
"width": 22.44,
|
||||
"ascent": 27.02,
|
||||
"descent": 15.02,
|
||||
"last_line_width": null,
|
||||
"rows": 4
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="30.44" height="51.09" viewBox="0 0 30.44 51.09" data-family="Source Serif 4">
|
||||
<rect x="5.44" y="4.00" width="1.91" height="42.03" fill="#000000"/>
|
||||
<g fill="#000000" data-face="Source Serif 4 Italic" data-size="17.28">
|
||||
<path data-gid="28" d="M12.53 16.78 Q12.53 17.58 12.81 17.96 Q13.10 18.34 13.56 18.34 Q14.01 18.34 14.60 17.98 Q15.19 17.61 16.03 16.68 L16.97 12.34 Q16.73 12.21 16.46 12.11 Q16.19 12.02 15.91 12.02 Q15.50 12.02 14.99 12.27 Q14.48 12.53 14.01 13.02 Q13.39 13.66 12.96 14.65 Q12.53 15.64 12.53 16.78 Z M16.67 19.53 Q16.33 19.53 16.05 19.29 Q15.77 19.05 15.77 18.53 Q15.77 18.03 15.84 17.61 Q15.10 18.46 14.31 19.00 Q13.53 19.53 12.82 19.53 Q12.20 19.53 11.69 19.05 Q11.18 18.56 11.18 17.49 Q11.18 16.59 11.52 15.66 Q11.85 14.73 12.41 13.87 Q12.98 13.02 13.68 12.34 Q14.37 11.67 15.11 11.27 Q15.84 10.87 16.52 10.87 Q16.81 10.87 17.05 11.00 Q17.30 11.12 17.50 11.34 L18.30 10.70 L18.61 10.86 L17.23 16.97 Q17.07 17.61 17.07 17.96 Q17.07 18.13 17.19 18.24 Q17.31 18.34 17.49 18.34 Q17.81 18.34 18.27 17.99 Q18.73 17.65 19.18 17.10 L19.59 17.42 Q19.27 17.92 18.80 18.41 Q18.33 18.89 17.80 19.21 Q17.26 19.53 16.67 19.53 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="Source Serif 4 Italic" data-size="17.28">
|
||||
<path data-gid="29" d="M13.46 41.49 Q13.46 42.42 14.32 42.42 Q15.38 42.42 16.36 41.49 Q17.07 40.82 17.49 39.77 Q17.90 38.73 17.90 37.67 Q17.90 36.84 17.62 36.43 Q17.35 36.01 16.86 36.01 Q16.29 36.01 15.58 36.50 Q14.88 36.98 14.05 37.90 L13.80 39.09 Q13.67 39.74 13.59 40.15 Q13.51 40.56 13.49 40.86 Q13.46 41.16 13.46 41.49 Z M13.55 43.51 Q12.98 43.51 12.55 43.13 Q12.13 42.75 12.13 42.08 Q12.13 41.52 12.24 40.88 Q12.35 40.23 12.53 39.36 L13.99 31.99 L12.28 31.86 L12.37 31.36 L15.36 30.50 L15.55 30.64 L14.22 37.07 Q15.01 36.03 15.95 35.44 Q16.88 34.85 17.64 34.85 Q18.28 34.85 18.75 35.33 Q19.21 35.80 19.21 36.98 Q19.21 37.86 18.90 38.78 Q18.59 39.69 18.04 40.54 Q17.49 41.39 16.76 42.06 Q16.03 42.73 15.21 43.12 Q14.39 43.51 13.55 43.51 Z"/>
|
||||
</g>
|
||||
<rect x="10.71" y="24.33" width="9.02" height="1.37" fill="#000000"/>
|
||||
<rect x="23.09" y="4.00" width="1.91" height="42.03" fill="#000000"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"family": "Source Serif 4",
|
||||
"size": 24.00,
|
||||
"line": 400.00,
|
||||
"display": false,
|
||||
"width": 82.13,
|
||||
"ascent": 24.84,
|
||||
"descent": 7.92,
|
||||
"last_line_width": null,
|
||||
"rows": 5
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="90.13" height="41.80" viewBox="0 0 90.13 41.80" data-family="Source Serif 4">
|
||||
<g fill="#000000" data-face="Source Serif 4 Italic" data-size="24.00">
|
||||
<path data-gid="53" d="M5.20 23.06 L5.75 19.48 L7.41 18.16 Q8.90 18.55 10.26 18.99 Q11.63 19.44 13.10 20.06 L13.91 18.48 L15.23 18.19 L15.52 18.43 Q15.11 19.44 13.24 21.38 Q11.73 22.92 10.16 24.27 Q8.58 25.63 7.10 27.04 Q8.49 28.08 9.47 28.42 Q10.46 28.77 11.20 28.77 Q11.70 28.77 12.15 28.63 Q12.59 28.48 12.98 28.24 Q12.59 27.79 12.42 27.48 Q12.26 27.16 12.26 26.83 Q12.26 26.25 12.60 25.92 Q12.95 25.58 13.46 25.53 Q13.84 25.68 14.04 26.10 Q14.25 26.52 14.25 26.95 Q14.25 27.81 13.67 28.62 Q13.10 29.42 12.22 29.92 Q11.34 30.43 10.41 30.43 Q9.38 30.43 8.42 29.90 Q7.46 29.37 6.26 27.88 L5.30 29.88 L3.93 30.02 L3.74 29.88 Q4.10 29.25 4.53 28.65 Q4.96 28.05 5.70 27.31 Q7.26 25.80 8.91 24.33 Q10.55 22.87 12.14 21.26 Q10.89 20.85 9.76 20.55 Q8.63 20.25 7.19 19.94 L6.18 23.20 Z"/>
|
||||
</g>
|
||||
<rect x="4.00" y="15.76" width="11.09" height="1.20" fill="#000000"/>
|
||||
<g fill="#000000" data-face="Source Serif 4 Italic" data-size="24.00">
|
||||
<path data-gid="53" d="M16.29 23.06 L16.84 19.48 L18.50 18.16 Q19.98 18.55 21.35 18.99 Q22.72 19.44 24.18 20.06 L25.00 18.48 L26.32 18.19 L26.61 18.43 Q26.20 19.44 24.33 21.38 Q22.82 22.92 21.24 24.27 Q19.67 25.63 18.18 27.04 Q19.58 28.08 20.56 28.42 Q21.54 28.77 22.29 28.77 Q22.79 28.77 23.24 28.63 Q23.68 28.48 24.06 28.24 Q23.68 27.79 23.51 27.48 Q23.34 27.16 23.34 26.83 Q23.34 26.25 23.69 25.92 Q24.04 25.58 24.54 25.53 Q24.93 25.68 25.13 26.10 Q25.34 26.52 25.34 26.95 Q25.34 27.81 24.76 28.62 Q24.18 29.42 23.31 29.92 Q22.43 30.43 21.50 30.43 Q20.46 30.43 19.50 29.90 Q18.54 29.37 17.34 27.88 L16.38 29.88 L15.02 30.02 L14.82 29.88 Q15.18 29.25 15.62 28.65 Q16.05 28.05 16.79 27.31 Q18.35 25.80 20.00 24.33 Q21.64 22.87 23.22 21.26 Q21.98 20.85 20.85 20.55 Q19.72 20.25 18.28 19.94 L17.27 23.20 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="Source Serif 4 Regular" data-size="24.00">
|
||||
<path data-gid="715" d="M46.22 25.05 L34.87 25.05 L34.87 23.68 L46.22 23.68 Z M46.22 20.25 L34.87 20.25 L34.87 18.88 L46.22 18.88 Z"/>
|
||||
</g>
|
||||
<rect x="56.34" y="9.96" width="1.91" height="27.84" fill="#000000"/>
|
||||
<g fill="#000000" data-face="Source Serif 4 Italic" data-size="24.00">
|
||||
<path data-gid="53" d="M61.37 23.06 L61.92 19.48 L63.58 18.16 Q65.06 18.55 66.43 18.99 Q67.80 19.44 69.26 20.06 L70.08 18.48 L71.40 18.19 L71.69 18.43 Q71.28 19.44 69.41 21.38 Q67.90 22.92 66.32 24.27 Q64.75 25.63 63.26 27.04 Q64.66 28.08 65.64 28.42 Q66.62 28.77 67.37 28.77 Q67.87 28.77 68.32 28.63 Q68.76 28.48 69.14 28.24 Q68.76 27.79 68.59 27.48 Q68.42 27.16 68.42 26.83 Q68.42 26.25 68.77 25.92 Q69.12 25.58 69.62 25.53 Q70.01 25.68 70.21 26.10 Q70.42 26.52 70.42 26.95 Q70.42 27.81 69.84 28.62 Q69.26 29.42 68.39 29.92 Q67.51 30.43 66.58 30.43 Q65.54 30.43 64.58 29.90 Q63.62 29.37 62.42 27.88 L61.46 29.88 L60.10 30.02 L59.90 29.88 Q60.26 29.25 60.70 28.65 Q61.13 28.05 61.87 27.31 Q63.43 25.80 65.08 24.33 Q66.72 22.87 68.30 21.26 Q67.06 20.85 65.93 20.55 Q64.80 20.25 63.36 19.94 L62.35 23.20 Z"/>
|
||||
</g>
|
||||
<rect x="73.18" y="9.96" width="1.91" height="27.84" fill="#000000"/>
|
||||
<g fill="#000000" data-face="Source Serif 4 Regular" data-size="17.28">
|
||||
<path data-gid="430" d="M77.85 16.44 L77.85 15.32 Q78.55 14.64 79.19 13.98 Q79.84 13.31 80.39 12.73 Q81.69 11.36 82.29 10.25 Q82.90 9.13 82.90 8.13 Q82.90 7.06 82.34 6.44 Q81.78 5.81 80.83 5.81 Q80.65 5.81 80.44 5.83 Q80.22 5.85 79.98 5.90 L79.65 7.09 Q79.48 7.73 79.24 8.01 Q79.01 8.29 78.65 8.29 Q78.37 8.29 78.14 8.13 Q77.91 7.97 77.77 7.72 Q77.87 6.80 78.37 6.20 Q78.87 5.61 79.63 5.32 Q80.39 5.04 81.26 5.04 Q82.93 5.04 83.69 5.86 Q84.45 6.68 84.45 7.97 Q84.45 8.73 84.15 9.45 Q83.85 10.17 83.11 11.07 Q82.36 11.97 81.03 13.24 Q80.69 13.59 80.20 14.06 Q79.70 14.52 79.17 15.02 L84.96 15.02 L84.96 16.44 Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"family": "Source Serif 4",
|
||||
"size": 24.00,
|
||||
"line": 400.00,
|
||||
"display": false,
|
||||
"width": 49.93,
|
||||
"ascent": 17.81,
|
||||
"descent": 5.76,
|
||||
"last_line_width": null,
|
||||
"rows": 3
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="57.93" height="32.72" viewBox="0 0 57.93 32.72" data-family="Source Serif 4">
|
||||
<g fill="#000000" data-face="Source Serif 4 Italic" data-size="24.00">
|
||||
<path data-gid="51" d="M12.35 23.03 Q11.82 23.03 11.42 22.68 Q11.01 22.34 10.70 21.30 L9.81 18.35 Q9.06 19.53 8.39 20.50 Q7.72 21.47 7.10 22.22 Q6.71 22.67 6.30 22.85 Q5.90 23.03 5.42 23.03 Q4.70 23.03 4.11 22.61 Q3.52 22.19 3.45 21.33 Q3.54 20.90 3.87 20.68 Q4.19 20.46 4.48 20.46 Q4.86 20.46 5.13 20.62 Q5.39 20.78 5.70 21.33 L5.97 21.81 Q6.18 21.64 6.42 21.38 Q7.29 20.44 8.03 19.46 Q8.78 18.47 9.52 17.37 L8.51 13.91 Q8.30 13.17 8.12 12.89 Q7.94 12.62 7.55 12.62 Q7.31 12.62 6.84 12.90 Q6.38 13.19 5.75 14.25 L5.20 13.84 Q5.87 12.35 6.66 11.68 Q7.46 11.01 8.20 11.01 Q8.90 11.01 9.30 11.46 Q9.71 11.92 10.05 13.07 L10.79 15.76 Q11.58 14.51 12.26 13.58 Q12.93 12.64 13.38 12.09 Q13.91 11.46 14.34 11.24 Q14.78 11.01 15.35 11.01 Q15.83 11.01 16.32 11.32 Q16.82 11.63 16.96 12.28 Q16.96 12.78 16.67 13.11 Q16.38 13.43 15.98 13.43 Q15.62 13.43 15.30 13.28 Q14.99 13.12 14.66 12.62 L14.51 12.42 Q14.42 12.50 14.34 12.59 Q14.27 12.69 14.18 12.78 Q13.34 13.72 12.57 14.72 Q11.80 15.71 11.06 16.77 L11.92 19.79 Q12.18 20.78 12.48 21.08 Q12.78 21.38 13.29 21.38 Q13.74 21.38 14.34 20.92 Q14.94 20.46 15.59 19.65 L16.19 20.10 Q15.33 21.38 14.32 22.20 Q13.31 23.03 12.35 23.03 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="Source Serif 4 Regular" data-size="24.00">
|
||||
<path data-gid="770" d="M13.25 9.81 L10.23 7.07 L7.20 9.81 L6.65 9.28 L9.82 5.06 L10.64 5.06 L13.80 9.28 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="Source Serif 4 Regular" data-size="24.00">
|
||||
<path data-gid="710" d="M28.76 20.87 L28.76 15.50 L23.82 15.50 L23.82 14.13 L28.76 14.13 L28.76 8.87 L30.23 8.87 L30.23 14.13 L35.17 14.13 L35.17 15.50 L30.23 15.50 L30.23 20.87 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="Source Serif 4 Italic" data-size="24.00">
|
||||
<path data-gid="52" d="M41.81 28.48 Q41.02 28.48 40.59 28.08 Q40.16 27.69 40.11 27.09 Q40.21 26.56 40.55 26.28 Q40.90 26.01 41.41 26.01 Q41.93 26.01 42.38 26.24 Q42.82 26.46 43.35 26.85 L43.45 26.92 Q44.57 26.15 45.58 25.14 Q45.92 24.81 46.23 24.45 Q46.54 24.09 46.85 23.70 Q46.76 22.29 46.63 21.17 Q46.49 20.06 46.34 18.86 Q46.18 17.66 45.97 16.05 Q45.77 14.51 45.64 13.80 Q45.51 13.10 45.38 12.88 Q45.25 12.66 45.03 12.66 Q44.72 12.66 44.39 12.96 Q44.07 13.26 43.42 14.25 L42.87 13.86 Q43.59 12.33 44.38 11.67 Q45.17 11.01 45.92 11.01 Q46.37 11.01 46.70 11.25 Q47.02 11.49 47.25 12.17 Q47.48 12.86 47.62 14.20 Q47.84 16.29 47.97 18.15 Q48.10 20.01 48.17 21.69 Q49.01 20.15 49.60 18.96 Q50.19 17.78 50.63 16.64 Q51.08 15.50 51.46 14.13 Q51.87 12.74 52.15 12.04 Q52.42 11.34 52.69 11.10 Q52.95 10.86 53.29 10.86 Q53.69 10.86 53.93 11.09 Q54.17 11.32 54.17 11.68 Q54.17 12.14 54.05 12.52 Q53.93 12.90 53.57 13.62 Q53.19 14.39 52.77 15.23 Q52.35 16.07 51.65 17.30 Q50.98 18.47 50.20 19.85 Q49.42 21.23 48.44 22.72 Q47.98 23.42 47.45 24.17 Q46.93 24.93 46.16 25.74 Q44.98 27.02 43.93 27.75 Q42.87 28.48 41.81 28.48 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="Source Serif 4 Regular" data-size="24.00">
|
||||
<path data-gid="770" d="M51.25 9.66 L48.22 6.93 L45.20 9.66 L44.65 9.14 L47.81 4.91 L48.63 4.91 L51.80 9.14 Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.1 KiB |
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"family": "Source Serif 4",
|
||||
"size": 24.00,
|
||||
"line": 400.00,
|
||||
"display": false,
|
||||
"width": 49.50,
|
||||
"ascent": 22.77,
|
||||
"descent": 0.17,
|
||||
"last_line_width": null,
|
||||
"rows": 3
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="57.50" height="36.77" viewBox="0 0 57.50 36.77" data-family="Source Serif 4">
|
||||
<g fill="#000000" data-face="Source Serif 4 Italic" data-size="24.00">
|
||||
<path data-gid="49" d="M7.84 26.82 Q7.77 23.99 7.62 21.87 Q7.48 19.74 7.22 17.85 Q7.12 17.13 6.96 16.92 Q6.81 16.72 6.54 16.72 Q6.23 16.72 5.88 17.04 Q5.54 17.37 4.91 18.30 L4.36 17.92 Q5.63 15.06 7.38 15.06 Q8.13 15.06 8.48 15.52 Q8.82 15.97 8.99 16.96 Q9.26 18.57 9.35 20.67 Q9.45 22.77 9.45 24.88 Q10.72 23.37 11.69 21.90 Q12.66 20.44 13.55 18.78 Q13.58 18.73 13.59 18.70 Q13.60 18.66 13.62 18.64 L13.46 17.70 Q13.43 17.46 13.40 17.20 Q13.36 16.93 13.36 16.77 Q13.36 15.85 13.71 15.46 Q14.06 15.06 14.63 15.06 Q14.97 15.06 15.22 15.16 Q15.47 15.25 15.57 15.37 Q15.69 15.54 15.69 16.05 Q15.69 16.29 15.63 16.65 Q15.57 17.01 15.32 17.62 Q15.06 18.23 14.49 19.31 Q13.94 20.37 13.04 21.65 Q12.14 22.93 11.03 24.28 Q9.93 25.62 8.70 26.87 L8.01 26.94 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="Source Serif 4 Regular" data-size="16.80">
|
||||
<path data-gid="738" d="M4.53 9.53 L4.53 8.32 L7.37 8.32 L13.38 8.62 L9.37 4.44 L9.80 4.00 L14.94 8.74 L14.94 9.11 L9.80 13.86 L9.37 13.42 L13.38 9.24 L7.37 9.53 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="Source Serif 4 Regular" data-size="24.00">
|
||||
<path data-gid="507" d="M25.74 20.51 Q25.02 20.51 24.53 20.01 Q24.03 19.50 24.03 18.81 Q24.03 18.09 24.53 17.58 Q25.02 17.08 25.74 17.08 Q26.46 17.08 26.95 17.58 Q27.44 18.09 27.44 18.81 Q27.44 19.50 26.95 20.01 Q26.46 20.51 25.74 20.51 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="Source Serif 4 Italic" data-size="24.00">
|
||||
<path data-gid="50" d="M39.77 26.82 Q39.75 23.92 39.62 21.76 Q39.49 19.60 39.22 17.77 Q39.13 17.08 38.97 16.90 Q38.81 16.72 38.55 16.72 Q38.21 16.72 37.85 17.08 Q37.49 17.44 36.92 18.30 L36.37 17.92 Q37.64 15.06 39.32 15.06 Q40.09 15.06 40.43 15.43 Q40.78 15.81 40.93 16.67 Q41.09 17.65 41.19 18.97 Q41.29 20.29 41.32 21.71 Q41.36 23.13 41.36 24.47 Q44.02 20.63 46.28 15.35 L47.24 15.35 Q47.36 17.80 47.44 20.19 Q47.53 22.57 47.60 24.69 Q48.80 23.17 49.71 21.83 Q50.62 20.49 51.51 18.81 Q51.56 18.73 51.59 18.67 Q51.63 18.61 51.65 18.54 L51.46 17.75 Q51.41 17.53 51.38 17.23 Q51.34 16.93 51.34 16.77 Q51.34 15.85 51.79 15.46 Q52.23 15.06 52.78 15.06 Q53.07 15.06 53.27 15.16 Q53.48 15.25 53.57 15.37 Q53.72 15.59 53.72 16.09 Q53.72 16.33 53.63 16.69 Q53.55 17.05 53.30 17.67 Q53.05 18.28 52.49 19.31 Q51.65 20.94 50.19 22.95 Q48.73 24.95 46.88 26.87 L46.18 26.94 L46.01 26.82 Q45.92 24.90 45.82 23.01 Q45.73 21.11 45.63 19.21 Q45.03 20.37 44.23 21.73 Q43.42 23.10 42.52 24.43 Q41.62 25.77 40.66 26.87 L39.94 26.94 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="Source Serif 4 Regular" data-size="16.80">
|
||||
<path data-gid="738" d="M39.55 9.53 L39.55 8.32 L42.38 8.32 L48.40 8.62 L44.38 4.44 L44.82 4.00 L49.96 8.74 L49.96 9.11 L44.82 13.86 L44.38 13.42 L48.40 9.24 L42.38 9.53 Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"family": "Source Serif 4",
|
||||
"size": 24.00,
|
||||
"line": 400.00,
|
||||
"display": false,
|
||||
"width": 52.67,
|
||||
"ascent": 17.66,
|
||||
"descent": 4.70,
|
||||
"last_line_width": null,
|
||||
"rows": 1
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="60.67" height="32.72" viewBox="0 0 60.67 32.72" data-family="Source Serif 4">
|
||||
<g fill="#000000" data-face="STIX Two Math Regular" data-size="24.00">
|
||||
<path data-gid="2492" d="M11.18 5.06 L10.29 5.06 L4.96 16.00 L4.96 16.48 L10.29 27.42 L11.18 27.42 L6.69 16.24 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="Source Serif 4 Italic" data-size="24.00">
|
||||
<path data-gid="48" d="M15.81 23.03 Q15.02 23.03 14.54 22.60 Q14.06 22.17 14.06 21.28 Q14.06 20.61 14.24 19.78 Q14.42 18.95 14.56 18.26 L15.66 13.05 L13.29 12.93 L13.38 12.21 L17.56 11.01 L17.87 11.25 L16.36 18.06 Q16.19 18.81 16.10 19.37 Q16.00 19.94 16.00 20.34 Q16.00 21.38 16.91 21.38 Q17.68 21.38 18.52 20.88 Q19.36 20.39 20.70 19.31 L21.38 16.10 Q21.86 13.79 22.24 11.49 L23.80 11.01 L24.11 11.25 L22.41 19.48 Q22.31 19.94 22.25 20.30 Q22.19 20.66 22.19 20.85 Q22.19 21.09 22.36 21.23 Q22.53 21.38 22.74 21.38 Q23.22 21.38 23.79 20.94 Q24.35 20.51 25.10 19.65 L25.70 20.10 Q25.14 20.82 24.52 21.50 Q23.90 22.17 23.18 22.60 Q22.46 23.03 21.59 23.03 Q21.11 23.03 20.72 22.72 Q20.32 22.41 20.32 21.78 Q20.32 21.57 20.36 21.24 Q20.39 20.92 20.56 20.25 Q19.17 21.54 18.02 22.29 Q16.86 23.03 15.81 23.03 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="Source Serif 4 Regular" data-size="24.00">
|
||||
<path data-gid="480" d="M27.30 26.97 L26.94 26.08 Q28.10 25.53 28.73 24.90 Q29.37 24.28 29.68 23.32 L28.86 22.84 Q27.81 22.19 27.81 21.33 Q27.81 20.66 28.20 20.19 Q28.60 19.72 29.42 19.72 Q29.80 19.72 30.16 19.86 Q30.52 20.01 30.88 20.39 Q31.00 20.78 31.04 21.21 Q31.07 21.64 31.07 22.05 Q31.07 23.25 30.56 24.22 Q30.04 25.19 29.19 25.89 Q28.34 26.58 27.30 26.97 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="Source Serif 4 Italic" data-size="24.00">
|
||||
<path data-gid="49" d="M40.90 22.77 Q40.83 19.94 40.69 17.81 Q40.54 15.69 40.28 13.79 Q40.18 13.07 40.03 12.87 Q39.87 12.66 39.61 12.66 Q39.30 12.66 38.95 12.99 Q38.60 13.31 37.98 14.25 L37.42 13.86 Q38.70 11.01 40.45 11.01 Q41.19 11.01 41.54 11.46 Q41.89 11.92 42.06 12.90 Q42.32 14.51 42.42 16.61 Q42.51 18.71 42.51 20.82 Q43.78 19.31 44.76 17.85 Q45.73 16.38 46.62 14.73 Q46.64 14.68 46.65 14.64 Q46.66 14.61 46.69 14.58 L46.52 13.65 Q46.50 13.41 46.46 13.14 Q46.42 12.88 46.42 12.71 Q46.42 11.80 46.77 11.40 Q47.12 11.01 47.70 11.01 Q48.03 11.01 48.28 11.10 Q48.54 11.20 48.63 11.32 Q48.75 11.49 48.75 11.99 Q48.75 12.23 48.69 12.59 Q48.63 12.95 48.38 13.56 Q48.13 14.18 47.55 15.26 Q47.00 16.31 46.10 17.60 Q45.20 18.88 44.10 20.22 Q42.99 21.57 41.77 22.82 L41.07 22.89 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="STIX Two Math Regular" data-size="24.00">
|
||||
<path data-gid="2497" d="M53.98 16.24 L49.50 27.42 L50.38 27.42 L55.71 16.48 L55.71 16.00 L50.38 5.06 L49.50 5.06 Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"family": "Source Serif 4",
|
||||
"size": 24.00,
|
||||
"line": 400.00,
|
||||
"display": true,
|
||||
"width": 162.73,
|
||||
"ascent": 35.40,
|
||||
"descent": 23.51,
|
||||
"last_line_width": null,
|
||||
"rows": 7
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="170.73" height="66.91" viewBox="0 0 170.73 66.91" data-family="Source Serif 4">
|
||||
<path d="M13.45 4.00 C3.76 11.06 3.76 55.74 13.45 62.80 L14.19 62.80 C7.48 55.74 7.48 11.06 14.19 4.00 Z" fill="#000000"/>
|
||||
<g fill="#000000" data-face="Source Serif 4 Italic" data-size="24.00">
|
||||
<path data-gid="41" d="M17.00 23.51 L16.76 23.32 L18.80 13.67 L16.42 13.53 L16.52 12.83 L20.60 11.61 L20.91 11.85 L20.41 14.32 Q21.61 13.19 22.96 12.40 Q24.32 11.61 25.42 11.61 Q26.96 11.61 26.96 13.26 Q26.96 13.84 26.83 14.43 Q26.70 15.02 26.55 15.62 L25.47 20.08 Q25.35 20.58 25.27 20.93 Q25.18 21.28 25.18 21.47 Q25.18 21.98 25.64 21.98 Q26.10 21.98 26.73 21.52 Q27.37 21.06 28.02 20.25 L28.59 20.70 Q28.14 21.40 27.48 22.08 Q26.82 22.77 26.05 23.20 Q25.28 23.63 24.44 23.63 Q23.89 23.63 23.56 23.34 Q23.24 23.06 23.24 22.60 Q23.24 22.19 23.35 21.80 Q23.46 21.40 23.62 20.66 L24.66 16.41 Q24.80 15.78 24.92 15.23 Q25.04 14.68 25.04 14.32 Q25.04 13.70 24.82 13.46 Q24.61 13.22 24.20 13.22 Q23.55 13.22 22.54 13.74 Q21.54 14.27 20.22 15.35 L19.57 18.28 Q19.30 19.53 19.08 20.79 Q18.85 22.05 18.61 23.32 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="Source Serif 4 Italic" data-size="24.00">
|
||||
<path data-gid="38" d="M17.52 53.08 L19.82 41.61 Q20.25 39.47 20.61 37.36 L18.33 37.22 L18.43 36.52 L22.48 35.32 L22.72 35.51 L20.56 46.48 L26.95 41.49 Q27.33 41.51 27.57 41.80 Q27.81 42.09 27.81 42.47 Q27.81 42.78 27.54 43.07 Q27.26 43.36 26.59 43.72 L20.76 46.96 L21.31 47.08 Q22.36 47.27 22.98 47.86 Q23.59 48.45 24.00 49.36 L24.69 50.87 Q25.10 51.74 25.44 51.98 Q25.77 52.22 26.32 52.26 L27.55 52.36 L27.43 53.08 Q26.90 53.25 26.52 53.32 Q26.13 53.39 25.72 53.39 Q24.88 53.39 24.26 53.06 Q23.64 52.72 23.11 51.47 L22.34 49.60 Q21.91 48.59 21.55 48.17 Q21.19 47.75 20.56 47.58 L20.35 47.54 L20.25 48.04 Q20.01 49.29 19.78 50.55 Q19.56 51.81 19.34 53.08 L17.73 53.27 Z"/>
|
||||
</g>
|
||||
<path d="M30.80 4.00 C40.49 11.06 40.49 55.74 30.80 62.80 L30.06 62.80 C36.77 55.74 36.77 11.06 30.06 4.00 Z" fill="#000000"/>
|
||||
<g fill="#000000" data-face="Source Serif 4 Regular" data-size="24.00">
|
||||
<path data-gid="715" d="M60.30 34.58 L48.94 34.58 L48.94 33.21 L60.30 33.21 Z M60.30 29.78 L48.94 29.78 L48.94 28.41 L60.30 28.41 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="Source Serif 4 Italic" data-size="24.00">
|
||||
<path data-gid="41" d="M109.26 23.34 L109.02 23.15 L111.06 13.50 L108.68 13.36 L108.78 12.66 L112.86 11.44 L113.17 11.68 L112.67 14.15 Q113.87 13.02 115.22 12.23 Q116.58 11.44 117.68 11.44 Q119.22 11.44 119.22 13.10 Q119.22 13.67 119.09 14.26 Q118.95 14.85 118.81 15.45 L117.73 19.91 Q117.61 20.42 117.53 20.76 Q117.44 21.11 117.44 21.30 Q117.44 21.81 117.90 21.81 Q118.35 21.81 118.99 21.35 Q119.63 20.90 120.27 20.08 L120.85 20.54 Q120.39 21.23 119.73 21.92 Q119.07 22.60 118.31 23.03 Q117.54 23.46 116.70 23.46 Q116.15 23.46 115.82 23.18 Q115.50 22.89 115.50 22.43 Q115.50 22.02 115.61 21.63 Q115.71 21.23 115.88 20.49 L116.91 16.24 Q117.06 15.62 117.18 15.06 Q117.30 14.51 117.30 14.15 Q117.30 13.53 117.08 13.29 Q116.87 13.05 116.46 13.05 Q115.81 13.05 114.80 13.58 Q113.79 14.10 112.47 15.18 L111.83 18.11 Q111.56 19.36 111.33 20.62 Q111.11 21.88 110.87 23.15 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="Source Serif 4 Regular" data-size="24.00">
|
||||
<path data-gid="484" d="M124.62 23.46 Q123.97 23.46 123.51 23.00 Q123.06 22.53 123.06 21.90 Q123.06 21.28 123.51 20.81 Q123.97 20.34 124.62 20.34 Q125.27 20.34 125.71 20.81 Q126.15 21.28 126.15 21.90 Q126.15 22.53 125.71 23.00 Q125.27 23.46 124.62 23.46 Z M124.62 6.71 Q125.24 6.71 125.61 7.07 Q125.99 7.43 125.99 8.22 Q125.99 8.90 125.82 10.55 Q125.65 12.21 125.39 14.70 L125.05 17.99 L124.16 17.99 L123.85 14.70 Q123.59 12.21 123.41 10.55 Q123.23 8.90 123.23 8.22 Q123.23 7.43 123.60 7.07 Q123.97 6.71 124.62 6.71 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="Source Serif 4 Italic" data-size="24.00">
|
||||
<path data-gid="38" d="M71.46 56.55 L73.77 45.08 Q74.20 42.94 74.56 40.83 L72.28 40.68 L72.38 39.99 L76.43 38.79 L76.67 38.98 L74.51 49.95 L80.90 44.96 Q81.28 44.98 81.52 45.27 Q81.76 45.56 81.76 45.94 Q81.76 46.25 81.48 46.54 Q81.21 46.83 80.54 47.19 L74.70 50.43 L75.26 50.55 Q76.31 50.74 76.92 51.33 Q77.54 51.92 77.94 52.83 L78.64 54.34 Q79.05 55.20 79.38 55.44 Q79.72 55.68 80.27 55.73 L81.50 55.83 L81.38 56.55 Q80.85 56.72 80.46 56.79 Q80.08 56.86 79.67 56.86 Q78.83 56.86 78.21 56.52 Q77.58 56.19 77.06 54.94 L76.29 53.07 Q75.86 52.06 75.50 51.64 Q75.14 51.22 74.51 51.05 L74.30 51.00 L74.20 51.51 Q73.96 52.76 73.73 54.02 Q73.50 55.28 73.29 56.55 L71.68 56.74 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="Source Serif 4 Regular" data-size="24.00">
|
||||
<path data-gid="484" d="M85.22 56.86 Q84.57 56.86 84.11 56.39 Q83.66 55.92 83.66 55.30 Q83.66 54.68 84.11 54.21 Q84.57 53.74 85.22 53.74 Q85.86 53.74 86.31 54.21 Q86.75 54.68 86.75 55.30 Q86.75 55.92 86.31 56.39 Q85.86 56.86 85.22 56.86 Z M85.22 40.11 Q85.84 40.11 86.21 40.47 Q86.58 40.83 86.58 41.62 Q86.58 42.29 86.42 43.95 Q86.25 45.60 85.98 48.10 L85.65 51.39 L84.76 51.39 L84.45 48.10 Q84.18 45.60 84.00 43.95 Q83.82 42.29 83.82 41.62 Q83.82 40.83 84.20 40.47 Q84.57 40.11 85.22 40.11 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="Source Serif 4 Regular" data-size="26.30">
|
||||
<path data-gid="510" d="M92.88 50.55 Q92.88 52.65 93.25 54.48 Q93.62 56.31 94.50 58.19 Q95.38 60.07 96.93 62.30 L96.25 62.91 Q93.46 60.30 92.12 57.23 Q90.78 54.15 90.78 50.55 Q90.78 46.95 92.12 43.87 Q93.46 40.79 96.25 38.19 L96.93 38.79 Q95.38 41.03 94.50 42.91 Q93.62 44.79 93.25 46.62 Q92.88 48.44 92.88 50.55 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="Source Serif 4 Italic" data-size="24.00">
|
||||
<path data-gid="41" d="M99.70 56.74 L99.46 56.55 L101.50 46.90 L99.12 46.76 L99.22 46.06 L103.30 44.84 L103.61 45.08 L103.11 47.55 Q104.31 46.42 105.66 45.63 Q107.02 44.84 108.12 44.84 Q109.66 44.84 109.66 46.49 Q109.66 47.07 109.53 47.66 Q109.40 48.24 109.25 48.84 L108.17 53.31 Q108.05 53.81 107.97 54.16 Q107.88 54.51 107.88 54.70 Q107.88 55.20 108.34 55.20 Q108.80 55.20 109.43 54.75 Q110.07 54.29 110.72 53.48 L111.29 53.93 Q110.84 54.63 110.18 55.31 Q109.52 56.00 108.75 56.43 Q107.98 56.86 107.14 56.86 Q106.59 56.86 106.26 56.57 Q105.94 56.28 105.94 55.83 Q105.94 55.42 106.05 55.02 Q106.16 54.63 106.32 53.88 L107.36 49.64 Q107.50 49.01 107.62 48.46 Q107.74 47.91 107.74 47.55 Q107.74 46.92 107.52 46.68 Q107.31 46.44 106.90 46.44 Q106.25 46.44 105.24 46.97 Q104.24 47.50 102.92 48.58 L102.27 51.51 Q102.00 52.76 101.78 54.02 Q101.55 55.28 101.31 56.55 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="Source Serif 4 Regular" data-size="24.00">
|
||||
<path data-gid="711" d="M130.29 49.32 L118.94 49.32 L118.94 47.96 L130.29 47.96 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="Source Serif 4 Italic" data-size="24.00">
|
||||
<path data-gid="38" d="M138.69 56.55 L140.99 45.08 Q141.42 42.94 141.78 40.83 L139.50 40.68 L139.60 39.99 L143.66 38.79 L143.90 38.98 L141.74 49.95 L148.12 44.96 Q148.50 44.98 148.74 45.27 Q148.98 45.56 148.98 45.94 Q148.98 46.25 148.71 46.54 Q148.43 46.83 147.76 47.19 L141.93 50.43 L142.48 50.55 Q143.54 50.74 144.15 51.33 Q144.76 51.92 145.17 52.83 L145.86 54.34 Q146.27 55.20 146.61 55.44 Q146.94 55.68 147.50 55.73 L148.72 55.83 L148.60 56.55 Q148.07 56.72 147.69 56.79 Q147.30 56.86 146.90 56.86 Q146.06 56.86 145.43 56.52 Q144.81 56.19 144.28 54.94 L143.51 53.07 Q143.08 52.06 142.72 51.64 Q142.36 51.22 141.74 51.05 L141.52 51.00 L141.42 51.51 Q141.18 52.76 140.96 54.02 Q140.73 55.28 140.51 56.55 L138.90 56.74 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="Source Serif 4 Regular" data-size="26.30">
|
||||
<path data-gid="511" d="M154.15 50.55 Q154.15 48.44 153.78 46.62 Q153.41 44.79 152.53 42.91 Q151.65 41.03 150.10 38.79 L150.78 38.19 Q153.57 40.79 154.91 43.87 Q156.25 46.95 156.25 50.55 Q156.25 54.15 154.91 57.23 Q153.57 60.30 150.78 62.91 L150.10 62.30 Q151.65 60.07 152.53 58.19 Q153.41 56.31 153.78 54.48 Q154.15 52.65 154.15 50.55 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="Source Serif 4 Regular" data-size="24.00">
|
||||
<path data-gid="484" d="M161.84 56.86 Q161.19 56.86 160.73 56.39 Q160.28 55.92 160.28 55.30 Q160.28 54.68 160.73 54.21 Q161.19 53.74 161.84 53.74 Q162.48 53.74 162.93 54.21 Q163.37 54.68 163.37 55.30 Q163.37 55.92 162.93 56.39 Q162.48 56.86 161.84 56.86 Z M161.84 40.11 Q162.46 40.11 162.83 40.47 Q163.20 40.83 163.20 41.62 Q163.20 42.29 163.04 43.95 Q162.87 45.60 162.60 48.10 L162.27 51.39 L161.38 51.39 L161.07 48.10 Q160.80 45.60 160.62 43.95 Q160.44 42.29 160.44 41.62 Q160.44 40.83 160.82 40.47 Q161.19 40.11 161.84 40.11 Z"/>
|
||||
</g>
|
||||
<rect x="70.41" y="32.72" width="94.88" height="1.37" fill="#000000"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 8.3 KiB |
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"family": "Source Serif 4",
|
||||
"size": 24.00,
|
||||
"line": 400.00,
|
||||
"display": true,
|
||||
"width": 104.16,
|
||||
"ascent": 49.32,
|
||||
"descent": 37.32,
|
||||
"last_line_width": null,
|
||||
"rows": 11
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="112.16" height="94.64" viewBox="0 0 112.16 94.64" data-family="Source Serif 4">
|
||||
<rect x="5.44" y="4.00" width="1.91" height="86.64" fill="#000000"/>
|
||||
<rect x="5.44" y="4.00" width="6.72" height="1.91" fill="#000000"/>
|
||||
<rect x="5.44" y="88.73" width="6.72" height="1.91" fill="#000000"/>
|
||||
<g fill="#000000" data-face="Source Serif 4 Regular" data-size="24.00">
|
||||
<path data-gid="429" d="M15.86 23.32 L15.86 22.46 L19.24 21.88 Q19.26 20.97 19.28 20.06 Q19.29 19.14 19.29 18.26 L19.29 9.59 L16.12 9.86 L16.12 8.87 L21.06 7.60 L21.40 7.84 L21.30 11.27 L21.30 18.26 Q21.30 19.14 21.32 20.06 Q21.33 20.97 21.35 21.88 L24.76 22.46 L24.76 23.32 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="Source Serif 4 Regular" data-size="24.00">
|
||||
<path data-gid="428" d="M56.10 23.63 Q54.83 23.63 53.67 22.85 Q52.50 22.07 51.78 20.31 Q51.06 18.54 51.06 15.59 Q51.06 12.62 51.78 10.84 Q52.50 9.06 53.67 8.27 Q54.83 7.48 56.10 7.48 Q57.38 7.48 58.52 8.27 Q59.66 9.06 60.39 10.84 Q61.12 12.62 61.12 15.59 Q61.12 18.54 60.39 20.31 Q59.66 22.07 58.52 22.85 Q57.38 23.63 56.10 23.63 Z M56.10 22.55 Q56.82 22.55 57.46 21.96 Q58.10 21.38 58.49 19.86 Q58.89 18.35 58.89 15.59 Q58.89 12.78 58.49 11.26 Q58.10 9.74 57.46 9.15 Q56.82 8.56 56.10 8.56 Q55.36 8.56 54.71 9.15 Q54.06 9.74 53.67 11.26 Q53.27 12.78 53.27 15.59 Q53.27 18.38 53.67 19.89 Q54.06 21.40 54.71 21.98 Q55.36 22.55 56.10 22.55 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="Source Serif 4 Regular" data-size="24.00">
|
||||
<path data-gid="428" d="M92.10 23.63 Q90.83 23.63 89.67 22.85 Q88.50 22.07 87.78 20.31 Q87.06 18.54 87.06 15.59 Q87.06 12.62 87.78 10.84 Q88.50 9.06 89.67 8.27 Q90.83 7.48 92.10 7.48 Q93.38 7.48 94.52 8.27 Q95.66 9.06 96.39 10.84 Q97.12 12.62 97.12 15.59 Q97.12 18.54 96.39 20.31 Q95.66 22.07 94.52 22.85 Q93.38 23.63 92.10 23.63 Z M92.10 22.55 Q92.82 22.55 93.46 21.96 Q94.10 21.38 94.49 19.86 Q94.89 18.35 94.89 15.59 Q94.89 12.78 94.49 11.26 Q94.10 9.74 93.46 9.15 Q92.82 8.56 92.10 8.56 Q91.36 8.56 90.71 9.15 Q90.06 9.74 89.67 11.26 Q89.27 12.78 89.27 15.59 Q89.27 18.38 89.67 19.89 Q90.06 21.40 90.71 21.98 Q91.36 22.55 92.10 22.55 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="Source Serif 4 Regular" data-size="24.00">
|
||||
<path data-gid="428" d="M20.10 52.43 Q18.83 52.43 17.67 51.65 Q16.50 50.87 15.78 49.11 Q15.06 47.34 15.06 44.39 Q15.06 41.42 15.78 39.64 Q16.50 37.86 17.67 37.07 Q18.83 36.28 20.10 36.28 Q21.38 36.28 22.52 37.07 Q23.66 37.86 24.39 39.64 Q25.12 41.42 25.12 44.39 Q25.12 47.34 24.39 49.11 Q23.66 50.87 22.52 51.65 Q21.38 52.43 20.10 52.43 Z M20.10 51.35 Q20.82 51.35 21.46 50.76 Q22.10 50.18 22.49 48.66 Q22.89 47.15 22.89 44.39 Q22.89 41.58 22.49 40.06 Q22.10 38.54 21.46 37.95 Q20.82 37.36 20.10 37.36 Q19.36 37.36 18.71 37.95 Q18.06 38.54 17.67 40.06 Q17.27 41.58 17.27 44.39 Q17.27 47.18 17.67 48.69 Q18.06 50.20 18.71 50.78 Q19.36 51.35 20.10 51.35 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="Source Serif 4 Regular" data-size="24.00">
|
||||
<path data-gid="429" d="M51.86 52.12 L51.86 51.26 L55.24 50.68 Q55.26 49.77 55.28 48.86 Q55.29 47.94 55.29 47.06 L55.29 38.39 L52.12 38.66 L52.12 37.67 L57.06 36.40 L57.40 36.64 L57.30 40.07 L57.30 47.06 Q57.30 47.94 57.32 48.86 Q57.33 49.77 57.35 50.68 L60.76 51.26 L60.76 52.12 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="Source Serif 4 Regular" data-size="24.00">
|
||||
<path data-gid="428" d="M92.10 52.43 Q90.83 52.43 89.67 51.65 Q88.50 50.87 87.78 49.11 Q87.06 47.34 87.06 44.39 Q87.06 41.42 87.78 39.64 Q88.50 37.86 89.67 37.07 Q90.83 36.28 92.10 36.28 Q93.38 36.28 94.52 37.07 Q95.66 37.86 96.39 39.64 Q97.12 41.42 97.12 44.39 Q97.12 47.34 96.39 49.11 Q95.66 50.87 94.52 51.65 Q93.38 52.43 92.10 52.43 Z M92.10 51.35 Q92.82 51.35 93.46 50.76 Q94.10 50.18 94.49 48.66 Q94.89 47.15 94.89 44.39 Q94.89 41.58 94.49 40.06 Q94.10 38.54 93.46 37.95 Q92.82 37.36 92.10 37.36 Q91.36 37.36 90.71 37.95 Q90.06 38.54 89.67 40.06 Q89.27 41.58 89.27 44.39 Q89.27 47.18 89.67 48.69 Q90.06 50.20 90.71 50.78 Q91.36 51.35 92.10 51.35 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="Source Serif 4 Regular" data-size="24.00">
|
||||
<path data-gid="428" d="M20.10 81.23 Q18.83 81.23 17.67 80.45 Q16.50 79.67 15.78 77.91 Q15.06 76.14 15.06 73.19 Q15.06 70.22 15.78 68.44 Q16.50 66.66 17.67 65.87 Q18.83 65.08 20.10 65.08 Q21.38 65.08 22.52 65.87 Q23.66 66.66 24.39 68.44 Q25.12 70.22 25.12 73.19 Q25.12 76.14 24.39 77.91 Q23.66 79.67 22.52 80.45 Q21.38 81.23 20.10 81.23 Z M20.10 80.15 Q20.82 80.15 21.46 79.56 Q22.10 78.98 22.49 77.46 Q22.89 75.95 22.89 73.19 Q22.89 70.38 22.49 68.86 Q22.10 67.34 21.46 66.75 Q20.82 66.16 20.10 66.16 Q19.36 66.16 18.71 66.75 Q18.06 67.34 17.67 68.86 Q17.27 70.38 17.27 73.19 Q17.27 75.98 17.67 77.49 Q18.06 79.00 18.71 79.58 Q19.36 80.15 20.10 80.15 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="Source Serif 4 Regular" data-size="24.00">
|
||||
<path data-gid="428" d="M56.10 81.23 Q54.83 81.23 53.67 80.45 Q52.50 79.67 51.78 77.91 Q51.06 76.14 51.06 73.19 Q51.06 70.22 51.78 68.44 Q52.50 66.66 53.67 65.87 Q54.83 65.08 56.10 65.08 Q57.38 65.08 58.52 65.87 Q59.66 66.66 60.39 68.44 Q61.12 70.22 61.12 73.19 Q61.12 76.14 60.39 77.91 Q59.66 79.67 58.52 80.45 Q57.38 81.23 56.10 81.23 Z M56.10 80.15 Q56.82 80.15 57.46 79.56 Q58.10 78.98 58.49 77.46 Q58.89 75.95 58.89 73.19 Q58.89 70.38 58.49 68.86 Q58.10 67.34 57.46 66.75 Q56.82 66.16 56.10 66.16 Q55.36 66.16 54.71 66.75 Q54.06 67.34 53.67 68.86 Q53.27 70.38 53.27 73.19 Q53.27 75.98 53.67 77.49 Q54.06 79.00 54.71 79.58 Q55.36 80.15 56.10 80.15 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="Source Serif 4 Regular" data-size="24.00">
|
||||
<path data-gid="429" d="M87.86 80.92 L87.86 80.06 L91.24 79.48 Q91.26 78.57 91.28 77.66 Q91.29 76.74 91.29 75.86 L91.29 67.19 L88.12 67.46 L88.12 66.47 L93.06 65.20 L93.40 65.44 L93.30 68.87 L93.30 75.86 Q93.30 76.74 93.32 77.66 Q93.33 78.57 93.35 79.48 L96.76 80.06 L96.76 80.92 Z"/>
|
||||
</g>
|
||||
<rect x="104.81" y="4.00" width="1.91" height="86.64" fill="#000000"/>
|
||||
<rect x="100.00" y="4.00" width="6.72" height="1.91" fill="#000000"/>
|
||||
<rect x="100.00" y="88.73" width="6.72" height="1.91" fill="#000000"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.9 KiB |
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"family": "Source Serif 4",
|
||||
"size": 24.00,
|
||||
"line": 400.00,
|
||||
"display": false,
|
||||
"width": 89.97,
|
||||
"ascent": 24.20,
|
||||
"descent": 4.39,
|
||||
"last_line_width": null,
|
||||
"rows": 4
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="97.97" height="38.20" viewBox="0 0 97.97 38.20" data-family="Source Serif 4">
|
||||
<g fill="#000000" data-face="Source Serif 4 Italic" data-size="24.00">
|
||||
<path data-gid="6" d="M10.58 13.10 L10.77 12.12 L21.86 12.12 L21.28 16.39 L20.06 16.39 L20.08 13.34 L14.87 13.34 L13.72 19.44 L17.44 19.44 L18.26 17.13 L19.26 17.13 L18.18 22.82 L17.18 22.82 L17.25 20.54 L13.53 20.54 L13.48 20.76 Q13.19 22.29 12.93 23.86 Q12.66 25.44 12.40 27.00 L18.64 27.00 L19.72 23.92 L20.94 23.92 L20.01 28.20 L8.01 28.20 L8.22 27.24 L10.29 26.97 L11.68 19.56 Q12.26 16.46 12.76 13.36 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="Source Serif 4 Regular" data-size="24.00">
|
||||
<path data-gid="715" d="M42.22 23.37 L30.86 23.37 L30.86 22.00 L42.22 22.00 Z M42.22 18.57 L30.86 18.57 L30.86 17.20 L42.22 17.20 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="Source Serif 4 Italic" data-size="24.00">
|
||||
<path data-gid="40" d="M66.10 28.51 Q65.53 28.51 65.20 28.22 Q64.88 27.93 64.88 27.48 Q64.88 27.07 64.99 26.67 Q65.10 26.28 65.26 25.53 L66.30 21.28 Q66.44 20.66 66.56 20.11 Q66.68 19.56 66.68 19.20 Q66.68 18.57 66.46 18.33 Q66.25 18.09 65.84 18.09 Q64.66 18.09 62.10 20.23 L61.47 23.16 Q61.21 24.40 60.97 25.66 Q60.73 26.92 60.49 28.20 L58.93 28.39 L58.69 28.20 L60.34 20.35 Q60.39 20.04 60.45 19.72 Q60.51 19.41 60.51 19.12 Q60.51 18.55 60.31 18.32 Q60.10 18.09 59.70 18.09 Q58.35 18.09 55.74 20.20 L55.09 23.16 Q54.82 24.40 54.60 25.66 Q54.37 26.92 54.13 28.20 L52.52 28.39 L52.28 28.20 L54.32 18.55 L51.94 18.40 L52.04 17.71 L56.12 16.48 L56.43 16.72 L55.93 19.20 Q56.72 18.45 57.60 17.84 Q58.47 17.23 59.34 16.86 Q60.20 16.48 60.94 16.48 Q62.46 16.48 62.46 18.14 Q62.46 18.60 62.34 19.15 Q63.46 18.04 64.75 17.26 Q66.03 16.48 67.09 16.48 Q68.62 16.48 68.62 18.14 Q68.62 18.72 68.49 19.30 Q68.36 19.89 68.22 20.49 L67.14 24.96 Q67.02 25.46 66.92 25.81 Q66.82 26.16 66.82 26.35 Q66.82 26.59 66.97 26.72 Q67.11 26.85 67.30 26.85 Q68.22 26.85 69.66 25.12 L70.23 25.58 Q69.70 26.30 69.07 26.97 Q68.43 27.64 67.70 28.08 Q66.97 28.51 66.10 28.51 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="Source Serif 4 Italic" data-size="24.00">
|
||||
<path data-gid="30" d="M74.96 28.51 Q73.40 28.51 72.39 27.39 Q71.38 26.28 71.38 24.02 Q71.38 22.51 71.91 21.14 Q72.44 19.77 73.33 18.73 Q74.22 17.68 75.32 17.08 Q76.42 16.48 77.55 16.48 Q78.66 16.48 79.30 16.98 Q79.95 17.47 80.14 18.24 Q80.07 18.69 79.77 18.88 Q79.47 19.08 79.14 19.08 Q78.75 19.08 78.40 18.82 Q78.06 18.57 77.72 18.21 L77.00 17.40 Q75.97 17.76 75.10 18.70 Q74.24 19.65 73.71 20.96 Q73.18 22.27 73.18 23.76 Q73.18 25.32 73.93 26.08 Q74.67 26.85 75.85 26.85 Q76.81 26.85 77.54 26.28 Q78.27 25.70 78.78 25.10 L79.33 25.53 Q78.61 26.78 77.41 27.64 Q76.21 28.51 74.96 28.51 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="Source Serif 4 Regular" data-size="17.28">
|
||||
<path data-gid="430" d="M81.61 19.48 L81.61 18.36 Q82.31 17.69 82.95 17.02 Q83.60 16.36 84.15 15.77 Q85.45 14.40 86.06 13.29 Q86.66 12.18 86.66 11.17 Q86.66 10.10 86.10 9.48 Q85.54 8.86 84.59 8.86 Q84.41 8.86 84.20 8.87 Q83.98 8.89 83.74 8.94 L83.41 10.14 Q83.24 10.78 83.01 11.05 Q82.77 11.33 82.41 11.33 Q82.13 11.33 81.90 11.17 Q81.67 11.02 81.53 10.76 Q81.63 9.84 82.13 9.25 Q82.63 8.65 83.39 8.37 Q84.15 8.08 85.02 8.08 Q86.70 8.08 87.46 8.90 Q88.22 9.72 88.22 11.02 Q88.22 11.78 87.91 12.50 Q87.61 13.21 86.87 14.11 Q86.12 15.01 84.79 16.29 Q84.45 16.63 83.96 17.10 Q83.46 17.57 82.93 18.07 L88.72 18.07 L88.72 19.48 Z"/>
|
||||
</g>
|
||||
<rect x="4.00" y="4.00" width="89.97" height="1.20" fill="#000000"/>
|
||||
<rect x="4.00" y="31.39" width="89.97" height="1.20" fill="#000000"/>
|
||||
<rect x="4.00" y="4.00" width="1.20" height="28.59" fill="#000000"/>
|
||||
<rect x="92.77" y="4.00" width="1.20" height="28.59" fill="#000000"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.6 KiB |
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"family": "Source Serif 4",
|
||||
"size": 24.00,
|
||||
"line": 400.00,
|
||||
"display": true,
|
||||
"width": 178.55,
|
||||
"ascent": 34.92,
|
||||
"descent": 22.92,
|
||||
"last_line_width": null,
|
||||
"rows": 5
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="186.55" height="65.84" viewBox="0 0 186.55 65.84" data-family="Source Serif 4">
|
||||
<rect x="5.44" y="19.00" width="1.91" height="27.84" fill="#000000"/>
|
||||
<g fill="#000000" data-face="Source Serif 4 Italic" data-size="24.00">
|
||||
<path data-gid="51" d="M17.62 39.23 Q17.10 39.23 16.69 38.88 Q16.28 38.54 15.97 37.50 L15.08 34.55 Q14.34 35.73 13.66 36.70 Q12.99 37.67 12.37 38.42 Q11.98 38.87 11.58 39.05 Q11.17 39.23 10.69 39.23 Q9.97 39.23 9.38 38.81 Q8.79 38.39 8.72 37.53 Q8.82 37.10 9.14 36.88 Q9.46 36.66 9.75 36.66 Q10.14 36.66 10.40 36.82 Q10.66 36.98 10.98 37.53 L11.24 38.01 Q11.46 37.84 11.70 37.58 Q12.56 36.64 13.30 35.66 Q14.05 34.67 14.79 33.57 L13.78 30.11 Q13.57 29.37 13.39 29.09 Q13.21 28.82 12.82 28.82 Q12.58 28.82 12.12 29.10 Q11.65 29.39 11.02 30.45 L10.47 30.04 Q11.14 28.55 11.94 27.88 Q12.73 27.21 13.47 27.21 Q14.17 27.21 14.58 27.66 Q14.98 28.12 15.32 29.27 L16.06 31.96 Q16.86 30.71 17.53 29.78 Q18.20 28.84 18.66 28.29 Q19.18 27.66 19.62 27.44 Q20.05 27.21 20.62 27.21 Q21.10 27.21 21.60 27.52 Q22.09 27.83 22.23 28.48 Q22.23 28.98 21.94 29.31 Q21.66 29.63 21.25 29.63 Q20.89 29.63 20.58 29.48 Q20.26 29.32 19.93 28.82 L19.78 28.62 Q19.69 28.70 19.62 28.79 Q19.54 28.89 19.45 28.98 Q18.61 29.92 17.84 30.92 Q17.07 31.91 16.33 32.97 L17.19 35.99 Q17.46 36.98 17.76 37.28 Q18.06 37.58 18.56 37.58 Q19.02 37.58 19.62 37.12 Q20.22 36.66 20.86 35.85 L21.46 36.30 Q20.60 37.58 19.59 38.40 Q18.58 39.23 17.62 39.23 Z"/>
|
||||
</g>
|
||||
<rect x="23.65" y="19.00" width="1.91" height="27.84" fill="#000000"/>
|
||||
<g fill="#000000" data-face="Source Serif 4 Regular" data-size="24.00">
|
||||
<path data-gid="715" d="M47.05 34.10 L35.70 34.10 L35.70 32.73 L47.05 32.73 Z M47.05 29.30 L35.70 29.30 L35.70 27.93 L47.05 27.93 Z"/>
|
||||
</g>
|
||||
<path d="M63.44 5.84 C62.66 5.84 62.60 6.50 62.60 11.86 L62.60 25.98 C62.60 31.91 58.77 33.64 58.41 33.78 L58.08 32.00 C62.68 32.00 62.60 34.46 62.60 39.86 L62.60 53.98 C62.60 59.34 62.66 60.00 63.44 60.00 L63.44 61.84 C60.50 61.84 60.76 58.89 60.76 53.98 L60.76 39.86 C60.76 34.28 60.76 33.84 58.08 33.84 C57.64 33.84 57.26 33.52 57.17 33.09 C57.09 32.65 57.33 32.22 57.75 32.06 C57.75 32.06 60.76 30.92 60.76 25.98 L60.76 11.86 C60.76 6.95 60.50 4.00 63.44 4.00 Z" fill="#000000"/>
|
||||
<g fill="#000000" data-face="Source Serif 4 Italic" data-size="24.00">
|
||||
<path data-gid="51" d="M87.71 23.63 Q87.18 23.63 86.78 23.28 Q86.37 22.94 86.06 21.90 L85.17 18.95 Q84.42 20.13 83.75 21.10 Q83.08 22.07 82.46 22.82 Q82.07 23.27 81.66 23.45 Q81.26 23.63 80.78 23.63 Q80.06 23.63 79.47 23.21 Q78.88 22.79 78.81 21.93 Q78.90 21.50 79.23 21.28 Q79.55 21.06 79.84 21.06 Q80.22 21.06 80.49 21.22 Q80.75 21.38 81.06 21.93 L81.33 22.41 Q81.54 22.24 81.78 21.98 Q82.65 21.04 83.39 20.06 Q84.14 19.07 84.88 17.97 L83.87 14.51 Q83.66 13.77 83.48 13.49 Q83.30 13.22 82.91 13.22 Q82.67 13.22 82.20 13.50 Q81.74 13.79 81.11 14.85 L80.56 14.44 Q81.23 12.95 82.02 12.28 Q82.82 11.61 83.56 11.61 Q84.26 11.61 84.66 12.06 Q85.07 12.52 85.41 13.67 L86.15 16.36 Q86.94 15.11 87.62 14.18 Q88.29 13.24 88.74 12.69 Q89.27 12.06 89.70 11.84 Q90.14 11.61 90.71 11.61 Q91.19 11.61 91.68 11.92 Q92.18 12.23 92.32 12.88 Q92.32 13.38 92.03 13.71 Q91.74 14.03 91.34 14.03 Q90.98 14.03 90.66 13.88 Q90.35 13.72 90.02 13.22 L89.87 13.02 Q89.78 13.10 89.70 13.19 Q89.63 13.29 89.54 13.38 Q88.70 14.32 87.93 15.32 Q87.16 16.31 86.42 17.37 L87.28 20.39 Q87.54 21.38 87.84 21.68 Q88.14 21.98 88.65 21.98 Q89.10 21.98 89.70 21.52 Q90.30 21.06 90.95 20.25 L91.55 20.70 Q90.69 21.98 89.68 22.80 Q88.67 23.63 87.71 23.63 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="Source Serif 4 Italic" data-size="24.00">
|
||||
<path data-gid="51" d="M131.37 23.63 Q130.84 23.63 130.43 23.28 Q130.02 22.94 129.71 21.90 L128.82 18.95 Q128.08 20.13 127.41 21.10 Q126.74 22.07 126.11 22.82 Q125.73 23.27 125.32 23.45 Q124.91 23.63 124.43 23.63 Q123.71 23.63 123.12 23.21 Q122.54 22.79 122.46 21.93 Q122.56 21.50 122.88 21.28 Q123.21 21.06 123.50 21.06 Q123.88 21.06 124.14 21.22 Q124.41 21.38 124.72 21.93 L124.98 22.41 Q125.20 22.24 125.44 21.98 Q126.30 21.04 127.05 20.06 Q127.79 19.07 128.54 17.97 L127.53 14.51 Q127.31 13.77 127.13 13.49 Q126.95 13.22 126.57 13.22 Q126.33 13.22 125.86 13.50 Q125.39 13.79 124.77 14.85 L124.22 14.44 Q124.89 12.95 125.68 12.28 Q126.47 11.61 127.22 11.61 Q127.91 11.61 128.32 12.06 Q128.73 12.52 129.06 13.67 L129.81 16.36 Q130.60 15.11 131.27 14.18 Q131.94 13.24 132.40 12.69 Q132.93 12.06 133.36 11.84 Q133.79 11.61 134.37 11.61 Q134.85 11.61 135.34 11.92 Q135.83 12.23 135.98 12.88 Q135.98 13.38 135.69 13.71 Q135.40 14.03 134.99 14.03 Q134.63 14.03 134.32 13.88 Q134.01 13.72 133.67 13.22 L133.53 13.02 Q133.43 13.10 133.36 13.19 Q133.29 13.29 133.19 13.38 Q132.35 14.32 131.58 15.32 Q130.82 16.31 130.07 17.37 L130.94 20.39 Q131.20 21.38 131.50 21.68 Q131.80 21.98 132.30 21.98 Q132.76 21.98 133.36 21.52 Q133.96 21.06 134.61 20.25 L135.21 20.70 Q134.34 21.98 133.34 22.80 Q132.33 23.63 131.37 23.63 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="Source Serif 4 Regular" data-size="24.00">
|
||||
<path data-gid="719" d="M144.17 23.32 L144.17 21.93 L155.52 21.93 L155.52 23.32 Z M155.14 15.95 L145.58 20.87 L144.86 19.58 L153.34 15.38 L144.86 11.18 L145.58 9.88 L155.14 14.82 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="Source Serif 4 Regular" data-size="24.00">
|
||||
<path data-gid="428" d="M170.22 23.63 Q168.94 23.63 167.78 22.85 Q166.62 22.07 165.90 20.31 Q165.18 18.54 165.18 15.59 Q165.18 12.62 165.90 10.84 Q166.62 9.06 167.78 8.27 Q168.94 7.48 170.22 7.48 Q171.49 7.48 172.63 8.27 Q173.77 9.06 174.50 10.84 Q175.23 12.62 175.23 15.59 Q175.23 18.54 174.50 20.31 Q173.77 22.07 172.63 22.85 Q171.49 23.63 170.22 23.63 Z M170.22 22.55 Q170.94 22.55 171.57 21.96 Q172.21 21.38 172.60 19.86 Q173.00 18.35 173.00 15.59 Q173.00 12.78 172.60 11.26 Q172.21 9.74 171.57 9.15 Q170.94 8.56 170.22 8.56 Q169.47 8.56 168.82 9.15 Q168.18 9.74 167.78 11.26 Q167.38 12.78 167.38 15.59 Q167.38 18.38 167.78 19.89 Q168.18 21.40 168.82 21.98 Q169.47 22.55 170.22 22.55 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="Source Serif 4 Regular" data-size="24.00">
|
||||
<path data-gid="711" d="M85.05 44.90 L73.70 44.90 L73.70 43.53 L85.05 43.53 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="Source Serif 4 Italic" data-size="24.00">
|
||||
<path data-gid="51" d="M94.07 52.43 Q93.54 52.43 93.14 52.08 Q92.73 51.74 92.42 50.70 L91.53 47.75 Q90.78 48.93 90.11 49.90 Q89.44 50.87 88.82 51.62 Q88.43 52.07 88.02 52.25 Q87.62 52.43 87.14 52.43 Q86.42 52.43 85.83 52.01 Q85.24 51.59 85.17 50.73 Q85.26 50.30 85.59 50.08 Q85.91 49.86 86.20 49.86 Q86.58 49.86 86.85 50.02 Q87.11 50.18 87.42 50.73 L87.69 51.21 Q87.90 51.04 88.14 50.78 Q89.01 49.84 89.75 48.86 Q90.50 47.87 91.24 46.77 L90.23 43.31 Q90.02 42.57 89.84 42.29 Q89.66 42.02 89.27 42.02 Q89.03 42.02 88.56 42.30 Q88.10 42.59 87.47 43.65 L86.92 43.24 Q87.59 41.75 88.38 41.08 Q89.18 40.41 89.92 40.41 Q90.62 40.41 91.02 40.86 Q91.43 41.32 91.77 42.47 L92.51 45.16 Q93.30 43.91 93.98 42.98 Q94.65 42.04 95.10 41.49 Q95.63 40.86 96.06 40.64 Q96.50 40.41 97.07 40.41 Q97.55 40.41 98.04 40.72 Q98.54 41.03 98.68 41.68 Q98.68 42.18 98.39 42.51 Q98.10 42.83 97.70 42.83 Q97.34 42.83 97.02 42.68 Q96.71 42.52 96.38 42.02 L96.23 41.82 Q96.14 41.90 96.06 41.99 Q95.99 42.09 95.90 42.18 Q95.06 43.12 94.29 44.12 Q93.52 45.11 92.78 46.17 L93.64 49.19 Q93.90 50.18 94.20 50.48 Q94.50 50.78 95.01 50.78 Q95.46 50.78 96.06 50.32 Q96.66 49.86 97.31 49.05 L97.91 49.50 Q97.05 50.78 96.04 51.60 Q95.03 52.43 94.07 52.43 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="Source Serif 4 Italic" data-size="24.00">
|
||||
<path data-gid="51" d="M137.73 52.43 Q137.20 52.43 136.79 52.08 Q136.38 51.74 136.07 50.70 L135.18 47.75 Q134.44 48.93 133.77 49.90 Q133.10 50.87 132.47 51.62 Q132.09 52.07 131.68 52.25 Q131.27 52.43 130.79 52.43 Q130.07 52.43 129.48 52.01 Q128.90 51.59 128.82 50.73 Q128.92 50.30 129.24 50.08 Q129.57 49.86 129.86 49.86 Q130.24 49.86 130.50 50.02 Q130.77 50.18 131.08 50.73 L131.34 51.21 Q131.56 51.04 131.80 50.78 Q132.66 49.84 133.41 48.86 Q134.15 47.87 134.90 46.77 L133.89 43.31 Q133.67 42.57 133.49 42.29 Q133.31 42.02 132.93 42.02 Q132.69 42.02 132.22 42.30 Q131.75 42.59 131.13 43.65 L130.58 43.24 Q131.25 41.75 132.04 41.08 Q132.83 40.41 133.58 40.41 Q134.27 40.41 134.68 40.86 Q135.09 41.32 135.42 42.47 L136.17 45.16 Q136.96 43.91 137.63 42.98 Q138.30 42.04 138.76 41.49 Q139.29 40.86 139.72 40.64 Q140.15 40.41 140.73 40.41 Q141.21 40.41 141.70 40.72 Q142.19 41.03 142.34 41.68 Q142.34 42.18 142.05 42.51 Q141.76 42.83 141.35 42.83 Q140.99 42.83 140.68 42.68 Q140.37 42.52 140.03 42.02 L139.89 41.82 Q139.79 41.90 139.72 41.99 Q139.65 42.09 139.55 42.18 Q138.71 43.12 137.94 44.12 Q137.18 45.11 136.43 46.17 L137.30 49.19 Q137.56 50.18 137.86 50.48 Q138.16 50.78 138.66 50.78 Q139.12 50.78 139.72 50.32 Q140.32 49.86 140.97 49.05 L141.57 49.50 Q140.70 50.78 139.70 51.60 Q138.69 52.43 137.73 52.43 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="Source Serif 4 Regular" data-size="24.00">
|
||||
<path data-gid="716" d="M151.20 44.78 L151.20 43.65 L160.49 38.25 L161.21 39.54 L152.88 44.20 L161.21 48.88 L160.49 50.18 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="Source Serif 4 Regular" data-size="24.00">
|
||||
<path data-gid="428" d="M176.58 52.43 Q175.30 52.43 174.14 51.65 Q172.98 50.87 172.26 49.11 Q171.54 47.34 171.54 44.39 Q171.54 41.42 172.26 39.64 Q172.98 37.86 174.14 37.07 Q175.30 36.28 176.58 36.28 Q177.85 36.28 178.99 37.07 Q180.13 37.86 180.86 39.64 Q181.59 41.42 181.59 44.39 Q181.59 47.34 180.86 49.11 Q180.13 50.87 178.99 51.65 Q177.85 52.43 176.58 52.43 Z M176.58 51.35 Q177.30 51.35 177.93 50.76 Q178.57 50.18 178.96 48.66 Q179.36 47.15 179.36 44.39 Q179.36 41.58 178.96 40.06 Q178.57 38.54 177.93 37.95 Q177.30 37.36 176.58 37.36 Q175.83 37.36 175.18 37.95 Q174.54 38.54 174.14 40.06 Q173.74 41.58 173.74 44.39 Q173.74 47.18 174.14 48.69 Q174.54 50.20 175.18 50.78 Q175.83 51.35 176.58 51.35 Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 9.6 KiB |
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"family": "Source Serif 4",
|
||||
"size": 24.00,
|
||||
"line": 400.00,
|
||||
"display": true,
|
||||
"width": 151.53,
|
||||
"ascent": 31.97,
|
||||
"descent": 49.10,
|
||||
"last_line_width": null,
|
||||
"rows": 15
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="159.53" height="93.51" viewBox="0 0 159.53 93.51" data-family="Source Serif 4">
|
||||
<g fill="#000000" data-face="Source Serif 4 Italic" data-size="24.00">
|
||||
<path data-gid="28" d="M6.52 34.02 Q6.52 35.13 6.92 35.66 Q7.31 36.18 7.96 36.18 Q8.58 36.18 9.40 35.68 Q10.22 35.18 11.39 33.88 L12.69 27.86 Q12.35 27.66 11.98 27.53 Q11.61 27.40 11.22 27.40 Q10.65 27.40 9.94 27.76 Q9.23 28.12 8.58 28.79 Q7.72 29.68 7.12 31.06 Q6.52 32.44 6.52 34.02 Z M12.28 37.84 Q11.80 37.84 11.42 37.50 Q11.03 37.17 11.03 36.45 Q11.03 35.75 11.13 35.18 Q10.10 36.35 9.00 37.10 Q7.91 37.84 6.93 37.84 Q6.06 37.84 5.36 37.17 Q4.65 36.50 4.65 35.01 Q4.65 33.76 5.12 32.46 Q5.58 31.17 6.36 29.98 Q7.14 28.79 8.12 27.86 Q9.09 26.92 10.11 26.37 Q11.13 25.82 12.06 25.82 Q12.47 25.82 12.81 25.98 Q13.14 26.15 13.43 26.46 L14.54 25.58 L14.97 25.79 L13.05 34.29 Q12.83 35.18 12.83 35.66 Q12.83 35.90 13.00 36.04 Q13.17 36.18 13.41 36.18 Q13.86 36.18 14.50 35.70 Q15.14 35.22 15.76 34.46 L16.34 34.91 Q15.88 35.61 15.23 36.28 Q14.58 36.95 13.84 37.40 Q13.10 37.84 12.28 37.84 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="Source Serif 4 Regular" data-size="17.28">
|
||||
<path data-gid="428" d="M21.35 41.35 Q20.43 41.35 19.59 40.79 Q18.75 40.23 18.23 38.96 Q17.72 37.69 17.72 35.56 Q17.72 33.42 18.23 32.14 Q18.75 30.86 19.59 30.29 Q20.43 29.72 21.35 29.72 Q22.26 29.72 23.08 30.29 Q23.90 30.86 24.43 32.14 Q24.96 33.42 24.96 35.56 Q24.96 37.69 24.43 38.96 Q23.90 40.23 23.08 40.79 Q22.26 41.35 21.35 41.35 Z M21.35 40.58 Q21.86 40.58 22.32 40.15 Q22.78 39.73 23.06 38.64 Q23.35 37.55 23.35 35.56 Q23.35 33.54 23.06 32.44 Q22.78 31.35 22.32 30.92 Q21.86 30.50 21.35 30.50 Q20.81 30.50 20.34 30.92 Q19.88 31.35 19.59 32.44 Q19.31 33.54 19.31 35.56 Q19.31 37.57 19.59 38.66 Q19.88 39.75 20.34 40.16 Q20.81 40.58 21.35 40.58 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="Source Serif 4 Regular" data-size="24.00">
|
||||
<path data-gid="710" d="M38.43 35.68 L38.43 30.30 L33.49 30.30 L33.49 28.94 L38.43 28.94 L38.43 23.68 L39.90 23.68 L39.90 28.94 L44.84 28.94 L44.84 30.30 L39.90 30.30 L39.90 35.68 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="Source Serif 4 Regular" data-size="24.00">
|
||||
<path data-gid="429" d="M99.63 21.28 L99.63 20.42 L103.02 19.84 Q103.04 18.93 103.05 18.02 Q103.06 17.10 103.06 16.22 L103.06 7.55 L99.90 7.82 L99.90 6.83 L104.84 5.56 L105.18 5.80 L105.08 9.23 L105.08 16.22 Q105.08 17.10 105.09 18.02 Q105.10 18.93 105.13 19.84 L108.54 20.42 L108.54 21.28 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="Source Serif 4 Italic" data-size="24.00">
|
||||
<path data-gid="28" d="M56.14 55.61 Q56.14 56.72 56.54 57.25 Q56.93 57.77 57.58 57.77 Q58.21 57.77 59.02 57.27 Q59.84 56.77 61.01 55.47 L62.31 49.45 Q61.97 49.25 61.60 49.12 Q61.23 48.99 60.85 48.99 Q60.27 48.99 59.56 49.35 Q58.85 49.71 58.21 50.38 Q57.34 51.27 56.74 52.65 Q56.14 54.03 56.14 55.61 Z M61.90 59.43 Q61.42 59.43 61.04 59.09 Q60.65 58.76 60.65 58.04 Q60.65 57.34 60.75 56.77 Q59.72 57.94 58.63 58.69 Q57.53 59.43 56.55 59.43 Q55.69 59.43 54.98 58.76 Q54.27 58.09 54.27 56.60 Q54.27 55.35 54.74 54.05 Q55.21 52.76 55.99 51.57 Q56.77 50.38 57.74 49.45 Q58.71 48.51 59.73 47.96 Q60.75 47.41 61.69 47.41 Q62.09 47.41 62.43 47.57 Q62.77 47.74 63.05 48.05 L64.16 47.17 L64.59 47.38 L62.67 55.88 Q62.45 56.77 62.45 57.25 Q62.45 57.49 62.62 57.63 Q62.79 57.77 63.03 57.77 Q63.49 57.77 64.12 57.29 Q64.76 56.81 65.38 56.05 L65.96 56.50 Q65.50 57.20 64.85 57.87 Q64.21 58.54 63.46 58.99 Q62.72 59.43 61.90 59.43 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="Source Serif 4 Regular" data-size="17.28">
|
||||
<path data-gid="429" d="M67.91 62.72 L67.91 62.10 L70.34 61.68 Q70.36 61.02 70.37 60.37 Q70.38 59.71 70.38 59.07 L70.38 52.83 L68.10 53.02 L68.10 52.32 L71.66 51.40 L71.90 51.57 L71.83 54.04 L71.83 59.07 Q71.83 59.71 71.84 60.37 Q71.85 61.02 71.87 61.68 L74.32 62.10 L74.32 62.72 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="Source Serif 4 Regular" data-size="24.00">
|
||||
<path data-gid="710" d="M88.06 57.27 L88.06 51.89 L83.11 51.89 L83.11 50.53 L88.06 50.53 L88.06 45.27 L89.52 45.27 L89.52 50.53 L94.46 50.53 L94.46 51.89 L89.52 51.89 L89.52 57.27 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="Source Serif 4 Regular" data-size="17.28">
|
||||
<path data-gid="429" d="M124.90 47.63 L124.90 47.01 L127.34 46.60 Q127.36 45.94 127.37 45.28 Q127.38 44.63 127.38 43.99 L127.38 37.75 L125.09 37.94 L125.09 37.23 L128.65 36.32 L128.90 36.49 L128.83 38.96 L128.83 43.99 Q128.83 44.63 128.84 45.28 Q128.84 45.94 128.86 46.60 L131.32 47.01 L131.32 47.63 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="Source Serif 4 Italic" data-size="17.28">
|
||||
<path data-gid="28" d="M105.06 73.47 Q105.06 74.26 105.34 74.64 Q105.63 75.02 106.09 75.02 Q106.54 75.02 107.13 74.66 Q107.72 74.30 108.56 73.36 L109.50 69.02 Q109.26 68.89 108.99 68.79 Q108.72 68.70 108.44 68.70 Q108.03 68.70 107.52 68.96 Q107.01 69.21 106.54 69.70 Q105.92 70.34 105.49 71.33 Q105.06 72.33 105.06 73.47 Z M109.20 76.21 Q108.86 76.21 108.58 75.97 Q108.31 75.73 108.31 75.21 Q108.31 74.71 108.37 74.30 Q107.63 75.14 106.85 75.68 Q106.06 76.21 105.35 76.21 Q104.73 76.21 104.22 75.73 Q103.71 75.25 103.71 74.17 Q103.71 73.28 104.05 72.34 Q104.38 71.41 104.94 70.55 Q105.51 69.70 106.21 69.02 Q106.91 68.35 107.64 67.95 Q108.37 67.56 109.05 67.56 Q109.34 67.56 109.58 67.68 Q109.83 67.80 110.03 68.02 L110.83 67.38 L111.14 67.54 L109.76 73.66 Q109.60 74.30 109.60 74.64 Q109.60 74.81 109.72 74.92 Q109.84 75.02 110.02 75.02 Q110.34 75.02 110.80 74.68 Q111.26 74.33 111.71 73.78 L112.12 74.10 Q111.80 74.61 111.33 75.09 Q110.86 75.57 110.33 75.89 Q109.79 76.21 109.20 76.21 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="Source Serif 4 Regular" data-size="13.92">
|
||||
<path data-gid="430" d="M113.29 78.61 L113.29 77.70 Q113.85 77.16 114.37 76.63 Q114.89 76.09 115.34 75.62 Q116.38 74.52 116.87 73.62 Q117.36 72.72 117.36 71.91 Q117.36 71.05 116.90 70.55 Q116.45 70.05 115.68 70.05 Q115.55 70.05 115.37 70.06 Q115.20 70.08 115.00 70.12 L114.74 71.08 Q114.60 71.59 114.41 71.82 Q114.22 72.04 113.93 72.04 Q113.71 72.04 113.52 71.91 Q113.33 71.79 113.22 71.58 Q113.30 70.84 113.71 70.36 Q114.11 69.88 114.72 69.65 Q115.34 69.42 116.03 69.42 Q117.38 69.42 118.00 70.08 Q118.61 70.74 118.61 71.79 Q118.61 72.40 118.36 72.98 Q118.12 73.56 117.52 74.28 Q116.92 75.00 115.85 76.03 Q115.57 76.31 115.18 76.69 Q114.78 77.06 114.35 77.47 L119.01 77.47 L119.01 78.61 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="Source Serif 4 Regular" data-size="17.28">
|
||||
<path data-gid="710" d="M127.33 74.66 L127.33 70.79 L123.78 70.79 L123.78 69.80 L127.33 69.80 L127.33 66.02 L128.39 66.02 L128.39 69.80 L131.95 69.80 L131.95 70.79 L128.39 70.79 L128.39 74.66 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="Source Serif 4 Regular" data-size="13.92">
|
||||
<path data-gid="429" d="M141.77 67.72 L141.77 67.22 L143.73 66.88 Q143.75 66.36 143.75 65.83 Q143.76 65.30 143.76 64.78 L143.76 59.76 L141.92 59.91 L141.92 59.34 L144.79 58.60 L144.99 58.74 L144.93 60.73 L144.93 64.78 Q144.93 65.30 144.94 65.83 Q144.94 66.36 144.96 66.88 L146.94 67.22 L146.94 67.72 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="Source Serif 4 Italic" data-size="13.92">
|
||||
<path data-gid="28" d="M138.29 80.52 Q138.29 81.16 138.52 81.46 Q138.75 81.77 139.13 81.77 Q139.49 81.77 139.96 81.48 Q140.43 81.18 141.12 80.43 L141.87 76.94 Q141.67 76.83 141.46 76.75 Q141.24 76.67 141.02 76.67 Q140.69 76.67 140.27 76.88 Q139.86 77.09 139.49 77.48 Q138.99 78.00 138.64 78.80 Q138.29 79.60 138.29 80.52 Z M141.63 82.73 Q141.35 82.73 141.13 82.54 Q140.91 82.34 140.91 81.92 Q140.91 81.52 140.96 81.18 Q140.36 81.87 139.73 82.30 Q139.10 82.73 138.53 82.73 Q138.03 82.73 137.62 82.34 Q137.21 81.95 137.21 81.09 Q137.21 80.36 137.48 79.61 Q137.75 78.86 138.20 78.17 Q138.65 77.48 139.22 76.94 Q139.78 76.40 140.37 76.08 Q140.96 75.76 141.51 75.76 Q141.74 75.76 141.94 75.85 Q142.13 75.95 142.30 76.13 L142.94 75.62 L143.19 75.74 L142.08 80.67 Q141.95 81.18 141.95 81.46 Q141.95 81.60 142.05 81.69 Q142.15 81.77 142.29 81.77 Q142.55 81.77 142.92 81.49 Q143.29 81.21 143.65 80.77 L143.98 81.03 Q143.72 81.44 143.34 81.83 Q142.97 82.22 142.54 82.47 Q142.10 82.73 141.63 82.73 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="Source Serif 4 Regular" data-size="13.92">
|
||||
<path data-gid="431" d="M147.66 86.63 Q146.64 86.63 145.91 86.16 Q145.18 85.70 144.99 84.65 Q145.22 84.19 145.70 84.19 Q146.06 84.19 146.22 84.43 Q146.38 84.66 146.52 85.19 L146.70 85.92 Q146.94 85.97 147.13 85.99 Q147.32 86.00 147.48 86.00 Q148.51 86.00 149.02 85.47 Q149.54 84.93 149.54 84.00 Q149.54 83.09 149.01 82.58 Q148.48 82.06 147.41 82.06 L146.74 82.06 L146.74 81.37 L147.30 81.37 Q148.24 81.37 148.77 80.87 Q149.29 80.38 149.29 79.49 Q149.29 78.72 148.85 78.30 Q148.41 77.89 147.59 77.89 Q147.45 77.89 147.30 77.90 Q147.14 77.91 146.95 77.94 L146.77 78.64 Q146.66 79.07 146.49 79.28 Q146.32 79.50 145.96 79.50 Q145.74 79.50 145.56 79.39 Q145.38 79.28 145.29 79.01 Q145.47 78.14 146.17 77.70 Q146.87 77.26 147.83 77.26 Q149.05 77.26 149.77 77.87 Q150.50 78.47 150.50 79.44 Q150.50 80.20 150.02 80.80 Q149.54 81.39 148.68 81.70 Q149.75 81.91 150.26 82.51 Q150.78 83.11 150.78 83.97 Q150.78 84.72 150.39 85.32 Q150.00 85.92 149.30 86.27 Q148.61 86.63 147.66 86.63 Z"/>
|
||||
</g>
|
||||
<rect x="136.83" y="71.18" width="14.78" height="0.98" fill="#000000"/>
|
||||
<rect x="103.24" y="52.43" width="49.41" height="1.37" fill="#000000"/>
|
||||
<rect x="53.62" y="30.84" width="100.47" height="1.37" fill="#000000"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 9.1 KiB |
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"family": "Source Serif 4",
|
||||
"size": 24.00,
|
||||
"line": 400.00,
|
||||
"display": true,
|
||||
"width": 98.20,
|
||||
"ascent": 31.38,
|
||||
"descent": 19.38,
|
||||
"last_line_width": null,
|
||||
"rows": 7
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="106.20" height="63.36" viewBox="0 0 106.20 63.36" data-family="Source Serif 4">
|
||||
<rect x="5.44" y="6.15" width="1.91" height="50.76" fill="#000000"/>
|
||||
<rect x="5.44" y="6.15" width="6.72" height="1.91" fill="#000000"/>
|
||||
<rect x="5.44" y="55.00" width="6.72" height="1.91" fill="#000000"/>
|
||||
<g fill="#000000" data-face="Source Serif 4 Italic" data-size="24.00">
|
||||
<path data-gid="28" d="M18.04 17.78 Q18.04 18.88 18.44 19.41 Q18.83 19.94 19.48 19.94 Q20.10 19.94 20.92 19.43 Q21.74 18.93 22.91 17.63 L24.21 11.61 Q23.87 11.42 23.50 11.28 Q23.13 11.15 22.74 11.15 Q22.17 11.15 21.46 11.51 Q20.75 11.87 20.10 12.54 Q19.24 13.43 18.64 14.81 Q18.04 16.19 18.04 17.78 Z M23.80 21.59 Q23.32 21.59 22.94 21.26 Q22.55 20.92 22.55 20.20 Q22.55 19.50 22.65 18.93 Q21.62 20.10 20.52 20.85 Q19.43 21.59 18.45 21.59 Q17.58 21.59 16.88 20.92 Q16.17 20.25 16.17 18.76 Q16.17 17.51 16.64 16.22 Q17.10 14.92 17.88 13.73 Q18.66 12.54 19.64 11.61 Q20.61 10.67 21.63 10.12 Q22.65 9.57 23.58 9.57 Q23.99 9.57 24.33 9.74 Q24.66 9.90 24.95 10.22 L26.06 9.33 L26.49 9.54 L24.57 18.04 Q24.35 18.93 24.35 19.41 Q24.35 19.65 24.52 19.79 Q24.69 19.94 24.93 19.94 Q25.38 19.94 26.02 19.46 Q26.66 18.98 27.28 18.21 L27.86 18.66 Q27.40 19.36 26.75 20.03 Q26.10 20.70 25.36 21.15 Q24.62 21.59 23.80 21.59 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="Source Serif 4 Italic" data-size="24.00">
|
||||
<path data-gid="29" d="M19.34 51.58 Q19.34 52.88 20.54 52.88 Q22.00 52.88 23.37 51.58 Q24.35 50.64 24.93 49.19 Q25.50 47.74 25.50 46.28 Q25.50 45.12 25.12 44.55 Q24.74 43.97 24.06 43.97 Q23.27 43.97 22.29 44.64 Q21.30 45.32 20.15 46.59 L19.82 48.24 Q19.62 49.16 19.52 49.72 Q19.41 50.28 19.37 50.70 Q19.34 51.12 19.34 51.58 Z M19.46 54.39 Q18.66 54.39 18.08 53.86 Q17.49 53.33 17.49 52.40 Q17.49 51.63 17.64 50.73 Q17.80 49.83 18.04 48.63 L20.08 38.38 L17.70 38.21 L17.82 37.52 L21.98 36.32 L22.24 36.51 L20.39 45.44 Q21.50 44.00 22.79 43.18 Q24.09 42.36 25.14 42.36 Q26.03 42.36 26.68 43.02 Q27.33 43.68 27.33 45.32 Q27.33 46.54 26.90 47.81 Q26.46 49.08 25.70 50.26 Q24.93 51.44 23.92 52.37 Q22.91 53.31 21.77 53.85 Q20.63 54.39 19.46 54.39 Z"/>
|
||||
</g>
|
||||
<rect x="15.52" y="30.84" width="12.53" height="1.37" fill="#000000"/>
|
||||
<rect x="36.22" y="6.15" width="1.91" height="50.76" fill="#000000"/>
|
||||
<rect x="31.41" y="6.15" width="6.72" height="1.91" fill="#000000"/>
|
||||
<rect x="31.41" y="55.00" width="6.72" height="1.91" fill="#000000"/>
|
||||
<g fill="#000000" data-face="Source Serif 4 Regular" data-size="24.00">
|
||||
<path data-gid="710" d="M51.87 35.68 L51.87 30.30 L46.93 30.30 L46.93 28.94 L51.87 28.94 L51.87 23.68 L53.34 23.68 L53.34 28.94 L58.28 28.94 L58.28 30.30 L53.34 30.30 L53.34 35.68 Z"/>
|
||||
</g>
|
||||
<path d="M73.34 7.99 C72.54 7.99 72.50 8.38 72.50 13.16 L72.50 25.44 C72.50 30.70 68.65 32.27 68.28 32.40 L67.98 30.61 C72.54 30.61 72.50 32.80 72.50 37.62 L72.50 49.90 C72.50 54.67 72.54 55.07 73.34 55.07 L73.34 56.91 C70.41 56.91 70.66 54.18 70.66 49.90 L70.66 37.62 C70.66 32.80 70.70 32.45 67.98 32.45 C67.53 32.45 67.15 32.12 67.07 31.68 C67.00 31.23 67.26 30.80 67.69 30.66 C67.69 30.66 70.66 29.67 70.66 25.44 L70.66 13.16 C70.66 8.87 70.41 6.15 73.34 6.15 Z" fill="#000000"/>
|
||||
<g fill="#000000" data-face="Source Serif 4 Italic" data-size="24.00">
|
||||
<path data-gid="30" d="M83.47 21.59 Q81.91 21.59 80.90 20.48 Q79.89 19.36 79.89 17.10 Q79.89 15.59 80.42 14.22 Q80.95 12.86 81.83 11.81 Q82.72 10.77 83.83 10.17 Q84.93 9.57 86.06 9.57 Q87.16 9.57 87.81 10.06 Q88.46 10.55 88.65 11.32 Q88.58 11.78 88.28 11.97 Q87.98 12.16 87.64 12.16 Q87.26 12.16 86.91 11.91 Q86.56 11.66 86.23 11.30 L85.51 10.48 Q84.47 10.84 83.61 11.79 Q82.75 12.74 82.22 14.04 Q81.69 15.35 81.69 16.84 Q81.69 18.40 82.43 19.17 Q83.18 19.94 84.35 19.94 Q85.31 19.94 86.05 19.36 Q86.78 18.78 87.28 18.18 L87.83 18.62 Q87.11 19.86 85.91 20.73 Q84.71 21.59 83.47 21.59 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="Source Serif 4 Italic" data-size="24.00">
|
||||
<path data-gid="31" d="M80.17 50.45 Q80.17 51.63 80.60 52.18 Q81.03 52.73 81.68 52.73 Q82.33 52.73 83.14 52.19 Q83.96 51.65 85.18 50.28 L86.33 44.52 Q85.97 44.21 85.60 44.08 Q85.23 43.95 84.87 43.95 Q84.10 43.95 83.36 44.34 Q82.61 44.74 81.99 45.44 Q81.17 46.32 80.67 47.66 Q80.17 48.99 80.17 50.45 Z M86.09 54.39 Q85.54 54.39 85.15 54.08 Q84.75 53.76 84.75 53.14 Q84.75 52.88 84.77 52.53 Q84.80 52.18 84.92 51.56 Q83.84 52.80 82.65 53.60 Q81.46 54.39 80.48 54.39 Q79.61 54.39 78.97 53.69 Q78.32 53.00 78.32 51.36 Q78.32 50.16 78.75 48.90 Q79.18 47.64 79.93 46.48 Q80.67 45.32 81.62 44.38 Q82.57 43.44 83.59 42.90 Q84.61 42.36 85.59 42.36 Q86.24 42.36 86.77 42.72 L87.68 38.38 L85.25 38.21 L85.35 37.52 L89.45 36.32 L89.74 36.51 L86.84 50.84 Q86.65 51.80 86.65 52.20 Q86.65 52.44 86.81 52.59 Q86.98 52.73 87.20 52.73 Q87.68 52.73 88.31 52.25 Q88.95 51.77 89.55 51.00 L90.13 51.46 Q89.67 52.16 89.03 52.83 Q88.40 53.50 87.65 53.94 Q86.91 54.39 86.09 54.39 Z"/>
|
||||
</g>
|
||||
<rect x="77.62" y="30.84" width="12.58" height="1.37" fill="#000000"/>
|
||||
<path d="M94.48 6.15 C97.41 6.15 97.16 8.87 97.16 13.16 L97.16 25.44 C97.16 29.67 100.13 30.66 100.13 30.66 C100.56 30.80 100.82 31.23 100.75 31.68 C100.67 32.12 100.29 32.45 99.84 32.45 C97.12 32.45 97.16 32.80 97.16 37.62 L97.16 49.90 C97.16 54.18 97.41 56.91 94.48 56.91 L94.48 55.07 C95.28 55.07 95.32 54.67 95.32 49.90 L95.32 37.62 C95.32 32.80 95.28 30.61 99.84 30.61 L99.54 32.40 C99.17 32.27 95.32 30.70 95.32 25.44 L95.32 13.16 C95.32 8.38 95.28 7.99 94.48 7.99 Z" fill="#000000"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.3 KiB |
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"family": "Source Serif 4",
|
||||
"size": 24.00,
|
||||
"line": 400.00,
|
||||
"display": true,
|
||||
"width": 104.85,
|
||||
"ascent": 22.72,
|
||||
"descent": 0.31,
|
||||
"last_line_width": null,
|
||||
"rows": 3
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="112.85" height="36.72" viewBox="0 0 112.85 36.72" data-family="Source Serif 4">
|
||||
<g fill="#000000" data-face="Source Serif 4 Italic" data-size="24.00">
|
||||
<path data-gid="32" d="M10.60 16.04 Q10.05 16.04 9.39 16.48 Q8.73 16.92 8.10 17.69 Q7.48 18.46 7.06 19.46 Q6.64 20.45 6.54 21.58 Q8.54 21.00 9.71 20.28 Q10.89 19.56 11.40 18.83 Q11.92 18.10 11.92 17.45 Q11.92 16.78 11.56 16.41 Q11.20 16.04 10.60 16.04 Z M8.46 27.03 Q6.90 27.03 5.88 25.95 Q4.86 24.87 4.86 22.56 Q4.86 21.12 5.37 19.77 Q5.87 18.41 6.74 17.33 Q7.60 16.25 8.74 15.63 Q9.88 15.00 11.13 15.00 Q12.28 15.00 12.90 15.58 Q13.53 16.16 13.53 17.21 Q13.53 17.98 12.93 18.92 Q12.33 19.85 10.80 20.79 Q9.28 21.72 6.54 22.52 Q6.62 24.05 7.26 24.71 Q7.91 25.37 9.02 25.37 Q10.14 25.37 10.96 24.80 Q11.78 24.22 12.26 23.62 L12.81 24.05 Q12.06 25.28 10.94 26.15 Q9.81 27.03 8.46 27.03 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="Source Serif 4 Italic" data-size="17.28">
|
||||
<path data-gid="36" d="M15.49 16.15 Q15.49 15.96 15.53 15.69 Q15.56 15.42 15.66 14.89 L16.72 9.86 L15.01 9.75 L15.08 9.25 L18.02 8.37 L18.24 8.54 L17.01 14.47 Q16.94 14.80 16.90 15.06 Q16.86 15.32 16.86 15.46 Q16.86 15.63 16.98 15.73 Q17.10 15.84 17.24 15.84 Q17.86 15.84 18.90 14.59 L19.33 14.92 Q18.95 15.44 18.51 15.92 Q18.07 16.41 17.56 16.72 Q17.05 17.03 16.43 17.03 Q16.06 17.03 15.78 16.80 Q15.49 16.58 15.49 16.15 Z M18.19 5.99 Q17.81 5.99 17.56 5.74 Q17.31 5.49 17.31 5.05 Q17.31 4.62 17.59 4.31 Q17.88 4.00 18.26 4.00 Q18.65 4.00 18.90 4.25 Q19.16 4.50 19.16 4.92 Q19.16 5.35 18.86 5.67 Q18.57 5.99 18.19 5.99 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="Source Serif 4 Italic" data-size="17.28">
|
||||
<path data-gid="867" d="M20.59 10.12 L20.59 9.50 Q21.07 9.17 21.50 8.97 Q21.92 8.77 22.47 8.68 Q23.03 8.60 23.86 8.60 L29.77 8.60 L29.59 9.81 L27.54 9.74 Q27.24 11.40 27.07 12.38 Q26.90 13.37 26.82 13.88 Q26.74 14.40 26.72 14.65 Q26.69 14.90 26.69 15.11 Q26.69 15.85 27.21 15.85 Q27.45 15.85 27.73 15.72 Q28.00 15.58 28.28 15.35 L28.56 15.77 Q27.55 17.03 26.50 17.03 Q25.93 17.03 25.60 16.68 Q25.27 16.34 25.27 15.75 Q25.27 15.54 25.32 15.19 Q25.36 14.83 25.49 14.19 Q25.62 13.54 25.89 12.46 Q26.15 11.38 26.60 9.72 L23.77 9.72 Q23.53 10.86 23.35 11.65 Q23.18 12.43 23.05 13.00 Q22.92 13.57 22.80 14.03 Q22.68 14.49 22.54 14.97 Q21.97 17.03 20.56 17.03 Q20.14 17.03 19.89 16.85 Q19.64 16.67 19.64 16.29 Q19.64 15.96 19.84 15.73 Q20.04 15.51 20.23 15.39 Q20.88 15.25 21.64 15.20 Q22.04 13.83 22.40 12.48 Q22.77 11.12 23.06 9.75 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="Source Serif 4 Regular" data-size="24.00">
|
||||
<path data-gid="710" d="M42.38 24.87 L42.38 19.49 L37.44 19.49 L37.44 18.12 L42.38 18.12 L42.38 12.87 L43.84 12.87 L43.84 18.12 L48.79 18.12 L48.79 19.49 L43.84 19.49 L43.84 24.87 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="Source Serif 4 Regular" data-size="24.00">
|
||||
<path data-gid="429" d="M57.90 26.72 L57.90 25.85 L61.29 25.28 Q61.31 24.36 61.32 23.45 Q61.33 22.54 61.33 21.65 L61.33 12.99 L58.17 13.25 L58.17 12.27 L63.11 11.00 L63.45 11.24 L63.35 14.67 L63.35 21.65 Q63.35 22.54 63.36 23.45 Q63.37 24.36 63.40 25.28 L66.81 25.85 L66.81 26.72 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="Source Serif 4 Regular" data-size="24.00">
|
||||
<path data-gid="715" d="M88.17 21.89 L76.82 21.89 L76.82 20.52 L88.17 20.52 Z M88.17 17.09 L76.82 17.09 L76.82 15.72 L88.17 15.72 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="Source Serif 4 Regular" data-size="24.00">
|
||||
<path data-gid="428" d="M102.87 27.03 Q101.60 27.03 100.43 26.25 Q99.27 25.47 98.55 23.70 Q97.83 21.94 97.83 18.99 Q97.83 16.01 98.55 14.24 Q99.27 12.46 100.43 11.67 Q101.60 10.88 102.87 10.88 Q104.14 10.88 105.28 11.67 Q106.42 12.46 107.15 14.24 Q107.89 16.01 107.89 18.99 Q107.89 21.94 107.15 23.70 Q106.42 25.47 105.28 26.25 Q104.14 27.03 102.87 27.03 Z M102.87 25.95 Q103.59 25.95 104.23 25.36 Q104.86 24.77 105.26 23.26 Q105.65 21.75 105.65 18.99 Q105.65 16.18 105.26 14.66 Q104.86 13.13 104.23 12.54 Q103.59 11.96 102.87 11.96 Q102.13 11.96 101.48 12.54 Q100.83 13.13 100.43 14.66 Q100.04 16.18 100.04 18.99 Q100.04 21.77 100.43 23.28 Q100.83 24.80 101.48 25.37 Q102.13 25.95 102.87 25.95 Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"family": "Source Serif 4",
|
||||
"size": 24.00,
|
||||
"line": 400.00,
|
||||
"display": false,
|
||||
"width": 83.44,
|
||||
"ascent": 17.66,
|
||||
"descent": 5.76,
|
||||
"last_line_width": null,
|
||||
"rows": 1
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="91.44" height="32.72" viewBox="0 0 91.44 32.72" data-family="Source Serif 4">
|
||||
<g fill="#000000" data-face="STIX Two Math Regular" data-size="24.00">
|
||||
<path data-gid="2017" d="M8.06 26.51 L8.06 5.06 L6.16 5.06 L6.16 27.28 L11.66 27.28 L11.66 26.51 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="Source Serif 4 Italic" data-size="24.00">
|
||||
<path data-gid="51" d="M20.73 23.03 Q20.20 23.03 19.79 22.68 Q19.38 22.34 19.07 21.30 L18.18 18.35 Q17.44 19.53 16.77 20.50 Q16.10 21.47 15.47 22.22 Q15.09 22.67 14.68 22.85 Q14.27 23.03 13.79 23.03 Q13.07 23.03 12.48 22.61 Q11.90 22.19 11.82 21.33 Q11.92 20.90 12.24 20.68 Q12.57 20.46 12.86 20.46 Q13.24 20.46 13.50 20.62 Q13.77 20.78 14.08 21.33 L14.34 21.81 Q14.56 21.64 14.80 21.38 Q15.66 20.44 16.41 19.46 Q17.15 18.47 17.90 17.37 L16.89 13.91 Q16.67 13.17 16.49 12.89 Q16.31 12.62 15.93 12.62 Q15.69 12.62 15.22 12.90 Q14.75 13.19 14.13 14.25 L13.58 13.84 Q14.25 12.35 15.04 11.68 Q15.83 11.01 16.58 11.01 Q17.27 11.01 17.68 11.46 Q18.09 11.92 18.42 13.07 L19.17 15.76 Q19.96 14.51 20.63 13.58 Q21.30 12.64 21.76 12.09 Q22.29 11.46 22.72 11.24 Q23.15 11.01 23.73 11.01 Q24.21 11.01 24.70 11.32 Q25.19 11.63 25.34 12.28 Q25.34 12.78 25.05 13.11 Q24.76 13.43 24.35 13.43 Q23.99 13.43 23.68 13.28 Q23.37 13.12 23.03 12.62 L22.89 12.42 Q22.79 12.50 22.72 12.59 Q22.65 12.69 22.55 12.78 Q21.71 13.72 20.94 14.72 Q20.18 15.71 19.43 16.77 L20.30 19.79 Q20.56 20.78 20.86 21.08 Q21.16 21.38 21.66 21.38 Q22.12 21.38 22.72 20.92 Q23.32 20.46 23.97 19.65 L24.57 20.10 Q23.70 21.38 22.70 22.20 Q21.69 23.03 20.73 23.03 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="STIX Two Math Regular" data-size="24.00">
|
||||
<path data-gid="2022" d="M25.55 26.51 L25.55 27.28 L31.05 27.28 L31.05 5.06 L29.15 5.06 L29.15 26.51 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="Source Serif 4 Regular" data-size="24.00">
|
||||
<path data-gid="710" d="M45.51 20.87 L45.51 15.50 L40.57 15.50 L40.57 14.13 L45.51 14.13 L45.51 8.87 L46.98 8.87 L46.98 14.13 L51.92 14.13 L51.92 15.50 L46.98 15.50 L46.98 20.87 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="STIX Two Math Regular" data-size="24.00">
|
||||
<path data-gid="2007" d="M66.92 5.82 L66.92 5.06 L61.42 5.06 L61.42 27.28 L63.32 27.28 L63.32 5.82 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="Source Serif 4 Italic" data-size="24.00">
|
||||
<path data-gid="52" d="M66.94 28.48 Q66.15 28.48 65.72 28.08 Q65.29 27.69 65.24 27.09 Q65.33 26.56 65.68 26.28 Q66.03 26.01 66.53 26.01 Q67.06 26.01 67.51 26.24 Q67.95 26.46 68.48 26.85 L68.57 26.92 Q69.70 26.15 70.71 25.14 Q71.05 24.81 71.36 24.45 Q71.67 24.09 71.98 23.70 Q71.89 22.29 71.75 21.17 Q71.62 20.06 71.47 18.86 Q71.31 17.66 71.09 16.05 Q70.90 14.51 70.77 13.80 Q70.64 13.10 70.51 12.88 Q70.37 12.66 70.16 12.66 Q69.85 12.66 69.52 12.96 Q69.20 13.26 68.55 14.25 L68.00 13.86 Q68.72 12.33 69.51 11.67 Q70.30 11.01 71.05 11.01 Q71.50 11.01 71.83 11.25 Q72.15 11.49 72.38 12.17 Q72.61 12.86 72.75 14.20 Q72.97 16.29 73.10 18.15 Q73.23 20.01 73.30 21.69 Q74.14 20.15 74.73 18.96 Q75.32 17.78 75.76 16.64 Q76.21 15.50 76.59 14.13 Q77.00 12.74 77.27 12.04 Q77.55 11.34 77.81 11.10 Q78.08 10.86 78.41 10.86 Q78.82 10.86 79.06 11.09 Q79.30 11.32 79.30 11.68 Q79.30 12.14 79.18 12.52 Q79.06 12.90 78.70 13.62 Q78.32 14.39 77.90 15.23 Q77.48 16.07 76.78 17.30 Q76.11 18.47 75.33 19.85 Q74.55 21.23 73.57 22.72 Q73.11 23.42 72.58 24.17 Q72.05 24.93 71.29 25.74 Q70.11 27.02 69.05 27.75 Q68.00 28.48 66.94 28.48 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="STIX Two Math Regular" data-size="24.00">
|
||||
<path data-gid="2012" d="M83.38 5.82 L83.38 27.28 L85.28 27.28 L85.28 5.06 L79.78 5.06 L79.78 5.82 Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.5 KiB |
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"family": "Source Serif 4",
|
||||
"size": 24.00,
|
||||
"line": 400.00,
|
||||
"display": true,
|
||||
"width": 52.78,
|
||||
"ascent": 31.97,
|
||||
"descent": 30.10,
|
||||
"last_line_width": null,
|
||||
"rows": 5
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="60.78" height="75.20" viewBox="0 0 60.78 75.20" data-family="Source Serif 4">
|
||||
<g fill="#000000" data-face="Source Serif 4 Regular" data-size="24.00">
|
||||
<path data-gid="429" d="M26.17 21.28 L26.17 20.42 L29.55 19.84 Q29.57 18.93 29.59 18.02 Q29.60 17.10 29.60 16.22 L29.60 7.55 L26.43 7.82 L26.43 6.83 L31.37 5.56 L31.71 5.80 L31.61 9.23 L31.61 16.22 Q31.61 17.10 31.63 18.02 Q31.64 18.93 31.66 19.84 L35.07 20.42 L35.07 21.28 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="Source Serif 4 Regular" data-size="24.00">
|
||||
<path data-gid="429" d="M7.22 59.12 L7.22 58.25 L10.60 57.68 Q10.62 56.77 10.64 55.85 Q10.65 54.94 10.65 54.05 L10.65 45.39 L7.48 45.65 L7.48 44.67 L12.42 43.40 L12.76 43.64 L12.66 47.07 L12.66 54.05 Q12.66 54.94 12.68 55.85 Q12.69 56.77 12.71 57.68 L16.12 58.25 L16.12 59.12 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="Source Serif 4 Regular" data-size="24.00">
|
||||
<path data-gid="710" d="M29.75 57.27 L29.75 51.89 L24.80 51.89 L24.80 50.53 L29.75 50.53 L29.75 45.27 L31.21 45.27 L31.21 50.53 L36.15 50.53 L36.15 51.89 L31.21 51.89 L31.21 57.27 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="Source Serif 4 Regular" data-size="17.28">
|
||||
<path data-gid="429" d="M46.38 47.63 L46.38 47.01 L48.81 46.60 Q48.83 45.94 48.84 45.28 Q48.85 44.63 48.85 43.99 L48.85 37.75 L46.57 37.94 L46.57 37.23 L50.13 36.32 L50.37 36.49 L50.30 38.96 L50.30 43.99 Q50.30 44.63 50.31 45.28 Q50.32 45.94 50.33 46.60 L52.79 47.01 L52.79 47.63 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="Source Serif 4 Italic" data-size="17.28">
|
||||
<path data-gid="51" d="M50.95 67.62 Q50.57 67.62 50.27 67.37 Q49.98 67.12 49.75 66.38 L49.12 64.25 Q48.58 65.10 48.10 65.80 Q47.61 66.50 47.16 67.04 Q46.89 67.36 46.59 67.49 Q46.30 67.62 45.95 67.62 Q45.43 67.62 45.01 67.32 Q44.59 67.02 44.54 66.40 Q44.61 66.09 44.84 65.93 Q45.07 65.77 45.28 65.77 Q45.56 65.77 45.75 65.89 Q45.94 66.00 46.16 66.40 L46.35 66.74 Q46.51 66.62 46.68 66.43 Q47.30 65.76 47.84 65.05 Q48.37 64.34 48.91 63.54 L48.18 61.06 Q48.03 60.52 47.90 60.32 Q47.77 60.12 47.49 60.12 Q47.32 60.12 46.98 60.33 Q46.64 60.54 46.19 61.30 L45.80 61.00 Q46.28 59.93 46.85 59.45 Q47.42 58.97 47.96 58.97 Q48.46 58.97 48.75 59.29 Q49.05 59.62 49.29 60.45 L49.82 62.39 Q50.39 61.49 50.88 60.81 Q51.36 60.14 51.69 59.74 Q52.07 59.29 52.38 59.13 Q52.69 58.97 53.11 58.97 Q53.45 58.97 53.81 59.19 Q54.16 59.42 54.26 59.88 Q54.26 60.24 54.06 60.48 Q53.85 60.71 53.56 60.71 Q53.30 60.71 53.07 60.60 Q52.85 60.49 52.61 60.12 L52.50 59.99 Q52.43 60.04 52.38 60.11 Q52.33 60.18 52.26 60.24 Q51.66 60.92 51.10 61.64 Q50.55 62.35 50.01 63.11 L50.64 65.29 Q50.83 66.00 51.04 66.21 Q51.26 66.43 51.62 66.43 Q51.95 66.43 52.38 66.10 Q52.81 65.77 53.28 65.19 L53.71 65.51 Q53.09 66.43 52.36 67.03 Q51.64 67.62 50.95 67.62 Z"/>
|
||||
</g>
|
||||
<rect x="44.93" y="52.43" width="8.97" height="1.37" fill="#000000"/>
|
||||
<rect x="5.44" y="30.84" width="49.90" height="1.37" fill="#000000"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"family": "Source Serif 4",
|
||||
"size": 24.00,
|
||||
"line": 400.00,
|
||||
"display": false,
|
||||
"width": 11.90,
|
||||
"ascent": 20.31,
|
||||
"descent": 24.84,
|
||||
"last_line_width": null,
|
||||
"rows": 5
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="19.90" height="59.87" viewBox="0 0 19.90 59.87" data-family="Source Serif 4">
|
||||
<g fill="#000000" data-face="Source Serif 4 Italic" data-size="17.28">
|
||||
<path data-gid="28" d="M7.25 13.92 Q7.25 14.71 7.54 15.09 Q7.82 15.47 8.29 15.47 Q8.74 15.47 9.33 15.11 Q9.92 14.75 10.76 13.82 L11.70 9.48 Q11.45 9.34 11.19 9.24 Q10.92 9.15 10.64 9.15 Q10.23 9.15 9.72 9.41 Q9.21 9.67 8.74 10.15 Q8.12 10.79 7.69 11.78 Q7.25 12.78 7.25 13.92 Z M11.40 16.67 Q11.06 16.67 10.78 16.42 Q10.50 16.18 10.50 15.66 Q10.50 15.16 10.57 14.75 Q9.83 15.59 9.04 16.13 Q8.26 16.67 7.55 16.67 Q6.93 16.67 6.42 16.18 Q5.91 15.70 5.91 14.63 Q5.91 13.73 6.24 12.80 Q6.58 11.86 7.14 11.01 Q7.70 10.15 8.40 9.48 Q9.10 8.80 9.84 8.41 Q10.57 8.01 11.25 8.01 Q11.54 8.01 11.78 8.13 Q12.02 8.25 12.23 8.48 L13.03 7.84 L13.34 7.99 L11.95 14.11 Q11.80 14.75 11.80 15.09 Q11.80 15.27 11.92 15.37 Q12.04 15.47 12.21 15.47 Q12.54 15.47 13.00 15.13 Q13.46 14.78 13.91 14.23 L14.32 14.56 Q13.99 15.06 13.53 15.54 Q13.06 16.03 12.52 16.35 Q11.99 16.67 11.40 16.67 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="Source Serif 4 Italic" data-size="13.92">
|
||||
<path data-gid="29" d="M8.53 36.49 Q8.53 37.24 9.23 37.24 Q10.08 37.24 10.87 36.49 Q11.44 35.94 11.77 35.10 Q12.11 34.26 12.11 33.41 Q12.11 32.74 11.88 32.41 Q11.66 32.07 11.27 32.07 Q10.81 32.07 10.24 32.46 Q9.67 32.85 9.00 33.59 L8.81 34.55 Q8.70 35.08 8.63 35.41 Q8.57 35.74 8.55 35.98 Q8.53 36.22 8.53 36.49 Z M8.60 38.12 Q8.14 38.12 7.80 37.81 Q7.46 37.50 7.46 36.96 Q7.46 36.52 7.55 35.99 Q7.64 35.47 7.78 34.78 L8.96 28.83 L7.58 28.73 L7.65 28.33 L10.06 27.63 L10.21 27.75 L9.14 32.92 Q9.78 32.09 10.53 31.62 Q11.29 31.14 11.90 31.14 Q12.41 31.14 12.79 31.52 Q13.17 31.91 13.17 32.85 Q13.17 33.56 12.92 34.30 Q12.66 35.04 12.22 35.72 Q11.77 36.40 11.19 36.95 Q10.60 37.49 9.94 37.80 Q9.28 38.12 8.60 38.12 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="Source Serif 4 Italic" data-size="13.92">
|
||||
<path data-gid="30" d="M9.69 52.99 Q8.79 52.99 8.20 52.34 Q7.62 51.69 7.62 50.38 Q7.62 49.51 7.92 48.71 Q8.23 47.92 8.75 47.31 Q9.26 46.71 9.90 46.36 Q10.54 46.01 11.20 46.01 Q11.84 46.01 12.21 46.30 Q12.59 46.58 12.70 47.03 Q12.66 47.29 12.48 47.40 Q12.31 47.52 12.11 47.52 Q11.89 47.52 11.69 47.37 Q11.49 47.22 11.29 47.02 L10.88 46.54 Q10.28 46.75 9.78 47.30 Q9.27 47.85 8.97 48.61 Q8.66 49.37 8.66 50.23 Q8.66 51.14 9.09 51.58 Q9.53 52.03 10.21 52.03 Q10.76 52.03 11.19 51.69 Q11.61 51.36 11.91 51.01 L12.23 51.26 Q11.81 51.98 11.11 52.49 Q10.42 52.99 9.69 52.99 Z"/>
|
||||
</g>
|
||||
<rect x="6.51" y="41.57" width="6.88" height="0.98" fill="#000000"/>
|
||||
<rect x="5.44" y="21.47" width="9.02" height="1.37" fill="#000000"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"family": "Source Serif 4",
|
||||
"size": 24.00,
|
||||
"line": 400.00,
|
||||
"display": false,
|
||||
"width": 11.90,
|
||||
"ascent": 20.31,
|
||||
"descent": 12.50,
|
||||
"last_line_width": null,
|
||||
"rows": 3
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="19.90" height="48.22" viewBox="0 0 19.90 48.22" data-family="Source Serif 4">
|
||||
<g fill="#000000" data-face="Source Serif 4 Italic" data-size="17.28">
|
||||
<path data-gid="28" d="M7.25 13.92 Q7.25 14.71 7.54 15.09 Q7.82 15.47 8.29 15.47 Q8.74 15.47 9.33 15.11 Q9.92 14.75 10.76 13.82 L11.70 9.48 Q11.45 9.34 11.19 9.24 Q10.92 9.15 10.64 9.15 Q10.23 9.15 9.72 9.41 Q9.21 9.67 8.74 10.15 Q8.12 10.79 7.69 11.78 Q7.25 12.78 7.25 13.92 Z M11.40 16.67 Q11.06 16.67 10.78 16.42 Q10.50 16.18 10.50 15.66 Q10.50 15.16 10.57 14.75 Q9.83 15.59 9.04 16.13 Q8.26 16.67 7.55 16.67 Q6.93 16.67 6.42 16.18 Q5.91 15.70 5.91 14.63 Q5.91 13.73 6.24 12.80 Q6.58 11.86 7.14 11.01 Q7.70 10.15 8.40 9.48 Q9.10 8.80 9.84 8.41 Q10.57 8.01 11.25 8.01 Q11.54 8.01 11.78 8.13 Q12.02 8.25 12.23 8.48 L13.03 7.84 L13.34 7.99 L11.95 14.11 Q11.80 14.75 11.80 15.09 Q11.80 15.27 11.92 15.37 Q12.04 15.47 12.21 15.47 Q12.54 15.47 13.00 15.13 Q13.46 14.78 13.91 14.23 L14.32 14.56 Q13.99 15.06 13.53 15.54 Q13.06 16.03 12.52 16.35 Q11.99 16.67 11.40 16.67 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="Source Serif 4 Italic" data-size="17.28">
|
||||
<path data-gid="29" d="M8.19 38.62 Q8.19 39.56 9.05 39.56 Q10.11 39.56 11.09 38.62 Q11.80 37.95 12.21 36.90 Q12.63 35.86 12.63 34.81 Q12.63 33.98 12.35 33.56 Q12.08 33.15 11.59 33.15 Q11.02 33.15 10.31 33.63 Q9.60 34.11 8.78 35.03 L8.53 36.22 Q8.39 36.88 8.32 37.29 Q8.24 37.69 8.21 37.99 Q8.19 38.30 8.19 38.62 Z M8.27 40.65 Q7.70 40.65 7.28 40.27 Q6.86 39.89 6.86 39.21 Q6.86 38.66 6.97 38.01 Q7.08 37.36 7.25 36.50 L8.72 29.12 L7.01 29.00 L7.10 28.50 L10.09 27.63 L10.28 27.77 L8.95 34.20 Q9.74 33.16 10.68 32.58 Q11.61 31.99 12.37 31.99 Q13.01 31.99 13.48 32.46 Q13.94 32.94 13.94 34.11 Q13.94 35.00 13.63 35.91 Q13.32 36.83 12.77 37.67 Q12.21 38.52 11.49 39.19 Q10.76 39.87 9.94 40.26 Q9.12 40.65 8.27 40.65 Z"/>
|
||||
</g>
|
||||
<rect x="5.44" y="21.47" width="9.02" height="1.37" fill="#000000"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"family": "Source Serif 4",
|
||||
"size": 24.00,
|
||||
"line": 400.00,
|
||||
"display": false,
|
||||
"width": 177.05,
|
||||
"ascent": 20.12,
|
||||
"descent": 0.31,
|
||||
"last_line_width": null,
|
||||
"rows": 5
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="185.05" height="35.15" viewBox="0 0 185.05 35.15" data-family="Source Serif 4">
|
||||
<g fill="#000000" data-face="Source Serif 4 Regular" data-size="24.00">
|
||||
<path data-gid="46" d="M8.80 25.47 Q7.79 25.47 6.95 25.24 Q6.11 25.01 5.22 24.55 L5.13 21.91 L6.45 21.91 L6.90 23.98 Q7.34 24.19 7.83 24.29 Q8.32 24.39 8.85 24.39 Q10.22 24.39 10.88 23.88 Q11.54 23.38 11.54 22.56 Q11.54 21.79 10.98 21.33 Q10.43 20.86 9.30 20.47 L8.20 20.07 Q6.88 19.59 6.09 18.82 Q5.30 18.05 5.30 16.83 Q5.30 15.94 5.78 15.17 Q6.26 14.40 7.19 13.92 Q8.13 13.44 9.47 13.44 Q10.46 13.44 11.26 13.69 Q12.06 13.95 12.83 14.43 L12.74 16.87 L11.49 16.87 L11.10 14.95 Q10.29 14.55 9.45 14.55 Q8.34 14.55 7.78 14.99 Q7.22 15.43 7.22 16.18 Q7.22 16.97 7.71 17.38 Q8.20 17.79 9.42 18.24 L10.17 18.51 Q11.97 19.18 12.72 20.04 Q13.48 20.91 13.48 22.03 Q13.48 22.95 12.95 23.73 Q12.42 24.51 11.39 24.99 Q10.36 25.47 8.80 25.47 Z"/>
|
||||
<path data-gid="36" d="M15.26 25.15 L15.26 24.29 L17.03 23.95 Q17.06 23.04 17.07 21.99 Q17.08 20.93 17.08 20.11 L17.08 18.99 Q17.08 18.00 17.07 17.34 Q17.06 16.68 17.01 15.96 L15.11 15.75 L15.11 14.91 L18.83 13.44 L19.19 13.68 L19.12 16.95 L19.12 20.11 Q19.12 20.91 19.13 21.97 Q19.14 23.04 19.17 23.95 L20.82 24.29 L20.82 25.15 Z M17.97 11.02 Q17.30 11.02 16.83 10.59 Q16.36 10.15 16.36 9.48 Q16.36 8.81 16.83 8.38 Q17.30 7.95 17.97 7.95 Q18.64 7.95 19.11 8.38 Q19.58 8.81 19.58 9.48 Q19.58 10.15 19.11 10.59 Q18.64 11.02 17.97 11.02 Z"/>
|
||||
<path data-gid="41" d="M22.53 25.15 L22.53 24.29 L24.30 23.95 Q24.33 23.04 24.34 21.99 Q24.35 20.93 24.35 20.11 L24.35 18.96 Q24.35 17.98 24.34 17.33 Q24.33 16.68 24.28 15.96 L22.38 15.75 L22.38 14.91 L25.74 13.44 L26.10 13.68 L26.30 15.60 Q27.18 14.64 28.34 14.04 Q29.49 13.44 30.59 13.44 Q32.15 13.44 32.91 14.49 Q33.66 15.53 33.66 17.88 L33.66 20.11 Q33.66 20.93 33.68 21.99 Q33.69 23.04 33.71 23.95 L35.39 24.29 L35.39 25.15 L29.82 25.15 L29.82 24.29 L31.60 23.95 Q31.62 23.07 31.64 22.00 Q31.65 20.93 31.65 20.11 L31.65 18.07 Q31.65 16.42 31.18 15.79 Q30.71 15.17 29.66 15.17 Q28.84 15.17 28.07 15.51 Q27.30 15.84 26.39 16.61 L26.39 20.11 Q26.39 20.91 26.40 21.97 Q26.42 23.04 26.44 23.95 L28.10 24.29 L28.10 25.15 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="Source Serif 4 Regular" data-size="17.28">
|
||||
<path data-gid="430" d="M37.44 16.44 L37.44 15.32 Q38.13 14.64 38.78 13.98 Q39.43 13.31 39.98 12.73 Q41.27 11.36 41.88 10.25 Q42.48 9.13 42.48 8.13 Q42.48 7.06 41.92 6.44 Q41.36 5.81 40.41 5.81 Q40.24 5.81 40.02 5.83 Q39.81 5.85 39.56 5.90 L39.24 7.09 Q39.06 7.73 38.83 8.01 Q38.60 8.29 38.23 8.29 Q37.96 8.29 37.72 8.13 Q37.49 7.97 37.35 7.72 Q37.46 6.80 37.96 6.20 Q38.46 5.61 39.22 5.32 Q39.98 5.04 40.84 5.04 Q42.52 5.04 43.28 5.86 Q44.04 6.68 44.04 7.97 Q44.04 8.73 43.74 9.45 Q43.43 10.17 42.69 11.07 Q41.95 11.97 40.62 13.24 Q40.27 13.59 39.78 14.06 Q39.29 14.52 38.75 15.02 L44.54 15.02 L44.54 16.44 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="Source Serif 4 Italic" data-size="24.00">
|
||||
<path data-gid="859" d="M50.80 25.47 Q48.90 25.47 47.93 24.04 Q46.96 22.61 46.96 20.28 Q46.96 17.55 47.50 15.21 Q48.04 12.87 48.99 11.11 Q49.94 9.36 51.20 8.38 Q52.46 7.39 53.87 7.39 Q55.74 7.39 56.68 8.82 Q57.62 10.25 57.62 12.79 Q57.62 15.55 57.09 17.87 Q56.56 20.19 55.62 21.89 Q54.69 23.59 53.45 24.53 Q52.22 25.47 50.80 25.47 Z M53.66 8.38 Q52.31 8.38 51.11 10.29 Q49.91 12.19 49.31 15.65 L55.50 15.65 Q55.60 14.79 55.65 13.84 Q55.70 12.89 55.70 11.86 Q55.70 10.03 55.24 9.21 Q54.78 8.38 53.66 8.38 Z M51.02 24.51 Q52.38 24.51 53.60 22.56 Q54.81 20.62 55.34 16.97 L49.12 16.97 Q48.90 18.77 48.90 20.93 Q48.90 22.66 49.40 23.58 Q49.89 24.51 51.02 24.51 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="Source Serif 4 Regular" data-size="24.00">
|
||||
<path data-gid="710" d="M69.85 23.31 L69.85 17.93 L64.91 17.93 L64.91 16.56 L69.85 16.56 L69.85 11.31 L71.31 11.31 L71.31 16.56 L76.26 16.56 L76.26 17.93 L71.31 17.93 L71.31 23.31 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="Source Serif 4 Regular" data-size="24.00">
|
||||
<path data-gid="30" d="M90.13 25.47 Q87.56 25.47 86.12 23.91 Q84.68 22.35 84.68 19.59 Q84.68 17.64 85.46 16.27 Q86.24 14.91 87.51 14.17 Q88.78 13.44 90.22 13.44 Q91.30 13.44 92.21 13.85 Q93.13 14.26 93.73 14.97 Q94.33 15.67 94.47 16.56 Q94.30 17.47 93.37 17.47 Q92.77 17.47 92.47 17.15 Q92.17 16.83 92.05 16.32 L91.61 14.74 Q91.28 14.62 90.94 14.58 Q90.61 14.55 90.32 14.55 Q88.78 14.55 87.79 15.77 Q86.79 16.99 86.79 19.35 Q86.79 20.95 87.32 21.96 Q87.85 22.97 88.72 23.44 Q89.60 23.91 90.68 23.91 Q91.59 23.91 92.41 23.58 Q93.22 23.26 94.04 22.32 L94.57 22.75 Q93.87 24.05 92.84 24.76 Q91.81 25.47 90.13 25.47 Z"/>
|
||||
<path data-gid="42" d="M101.89 25.47 Q100.49 25.47 99.23 24.81 Q97.97 24.15 97.18 22.81 Q96.39 21.48 96.39 19.51 Q96.39 17.52 97.18 16.17 Q97.97 14.81 99.23 14.13 Q100.49 13.44 101.89 13.44 Q103.28 13.44 104.54 14.13 Q105.80 14.81 106.60 16.17 Q107.41 17.52 107.41 19.51 Q107.41 21.48 106.60 22.81 Q105.80 24.15 104.54 24.81 Q103.28 25.47 101.89 25.47 Z M101.89 24.39 Q103.45 24.39 104.29 23.15 Q105.13 21.91 105.13 19.51 Q105.13 17.09 104.29 15.82 Q103.45 14.55 101.89 14.55 Q100.33 14.55 99.50 15.82 Q98.67 17.09 98.67 19.51 Q98.67 21.91 99.50 23.15 Q100.33 24.39 101.89 24.39 Z"/>
|
||||
<path data-gid="46" d="M113.29 25.47 Q112.28 25.47 111.44 25.24 Q110.60 25.01 109.71 24.55 L109.61 21.91 L110.93 21.91 L111.39 23.98 Q111.82 24.19 112.31 24.29 Q112.81 24.39 113.33 24.39 Q114.70 24.39 115.36 23.88 Q116.02 23.38 116.02 22.56 Q116.02 21.79 115.47 21.33 Q114.92 20.86 113.79 20.47 L112.69 20.07 Q111.37 19.59 110.57 18.82 Q109.78 18.05 109.78 16.83 Q109.78 15.94 110.26 15.17 Q110.74 14.40 111.68 13.92 Q112.61 13.44 113.96 13.44 Q114.94 13.44 115.75 13.69 Q116.55 13.95 117.32 14.43 L117.22 16.87 L115.97 16.87 L115.59 14.95 Q114.77 14.55 113.93 14.55 Q112.83 14.55 112.27 14.99 Q111.70 15.43 111.70 16.18 Q111.70 16.97 112.19 17.38 Q112.69 17.79 113.91 18.24 L114.65 18.51 Q116.45 19.18 117.21 20.04 Q117.97 20.91 117.97 22.03 Q117.97 22.95 117.44 23.73 Q116.91 24.51 115.88 24.99 Q114.85 25.47 113.29 25.47 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="Source Serif 4 Regular" data-size="17.28">
|
||||
<path data-gid="430" d="M120.23 16.44 L120.23 15.32 Q120.92 14.64 121.57 13.98 Q122.22 13.31 122.77 12.73 Q124.06 11.36 124.67 10.25 Q125.27 9.13 125.27 8.13 Q125.27 7.06 124.71 6.44 Q124.15 5.81 123.20 5.81 Q123.03 5.81 122.81 5.83 Q122.60 5.85 122.35 5.90 L122.03 7.09 Q121.85 7.73 121.62 8.01 Q121.39 8.29 121.02 8.29 Q120.75 8.29 120.51 8.13 Q120.28 7.97 120.14 7.72 Q120.25 6.80 120.75 6.20 Q121.25 5.61 122.01 5.32 Q122.77 5.04 123.63 5.04 Q125.31 5.04 126.07 5.86 Q126.83 6.68 126.83 7.97 Q126.83 8.73 126.53 9.45 Q126.22 10.17 125.48 11.07 Q124.74 11.97 123.41 13.24 Q123.06 13.59 122.57 14.06 Q122.08 14.52 121.54 15.02 L127.33 15.02 L127.33 16.44 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="Source Serif 4 Italic" data-size="24.00">
|
||||
<path data-gid="859" d="M133.59 25.47 Q131.69 25.47 130.72 24.04 Q129.75 22.61 129.75 20.28 Q129.75 17.55 130.29 15.21 Q130.83 12.87 131.78 11.11 Q132.73 9.36 133.99 8.38 Q135.25 7.39 136.66 7.39 Q138.53 7.39 139.47 8.82 Q140.41 10.25 140.41 12.79 Q140.41 15.55 139.88 17.87 Q139.35 20.19 138.41 21.89 Q137.48 23.59 136.24 24.53 Q135.01 25.47 133.59 25.47 Z M136.45 8.38 Q135.10 8.38 133.90 10.29 Q132.70 12.19 132.10 15.65 L138.29 15.65 Q138.39 14.79 138.44 13.84 Q138.49 12.89 138.49 11.86 Q138.49 10.03 138.03 9.21 Q137.57 8.38 136.45 8.38 Z M133.81 24.51 Q135.17 24.51 136.39 22.56 Q137.60 20.62 138.13 16.97 L131.91 16.97 Q131.69 18.77 131.69 20.93 Q131.69 22.66 132.19 23.58 Q132.68 24.51 133.81 24.51 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="Source Serif 4 Regular" data-size="24.00">
|
||||
<path data-gid="715" d="M160.38 20.33 L149.03 20.33 L149.03 18.96 L160.38 18.96 Z M160.38 15.53 L149.03 15.53 L149.03 14.16 L160.38 14.16 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="Source Serif 4 Regular" data-size="24.00">
|
||||
<path data-gid="429" d="M170.83 25.15 L170.83 24.29 L174.21 23.71 Q174.24 22.80 174.25 21.89 Q174.26 20.98 174.26 20.09 L174.26 11.43 L171.09 11.69 L171.09 10.71 L176.04 9.43 L176.37 9.67 L176.28 13.11 L176.28 20.09 Q176.28 20.98 176.29 21.89 Q176.30 22.80 176.33 23.71 L179.73 24.29 L179.73 25.15 Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.9 KiB |
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"family": "Source Serif 4",
|
||||
"size": 24.00,
|
||||
"line": 400.00,
|
||||
"display": false,
|
||||
"width": 92.57,
|
||||
"ascent": 17.76,
|
||||
"descent": 4.51,
|
||||
"last_line_width": null,
|
||||
"rows": 1
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="100.57" height="32.72" viewBox="0 0 100.57 32.72" data-family="Source Serif 4">
|
||||
<g fill="#000000" data-face="Source Serif 4 Italic" data-size="24.00">
|
||||
<path data-gid="852" d="M6.59 19.05 Q6.59 21.38 7.77 21.38 Q8.54 21.38 9.54 20.49 Q10.55 19.60 11.82 17.78 L11.49 14.75 Q11.32 13.26 10.97 12.70 Q10.62 12.14 10.05 12.14 Q9.33 12.14 8.70 12.84 Q8.08 13.55 7.60 14.66 Q7.12 15.76 6.86 16.94 Q6.59 18.11 6.59 19.05 Z M7.02 23.03 Q5.85 23.03 5.22 22.19 Q4.60 21.35 4.60 19.79 Q4.60 18.71 4.89 17.51 Q5.18 16.31 5.70 15.15 Q6.23 13.98 6.95 13.05 Q7.67 12.11 8.55 11.56 Q9.42 11.01 10.41 11.01 Q11.46 11.01 12.15 11.67 Q12.83 12.33 13.05 14.03 L13.24 15.66 L15.57 10.94 L17.32 11.70 L17.32 11.90 L13.46 17.22 L13.77 19.72 Q13.89 20.75 14.10 21.08 Q14.32 21.40 14.80 21.40 Q15.45 21.40 16.29 20.66 L16.67 21.23 Q15.23 23.03 13.84 23.03 Q13.05 23.03 12.68 22.54 Q12.30 22.05 12.18 20.90 L11.97 18.93 Q10.96 20.51 10.10 21.40 Q9.23 22.29 8.48 22.66 Q7.72 23.03 7.02 23.03 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="Source Serif 4 Regular" data-size="24.00">
|
||||
<path data-gid="710" d="M29.60 20.87 L29.60 15.50 L24.66 15.50 L24.66 14.13 L29.60 14.13 L29.60 8.87 L31.07 8.87 L31.07 14.13 L36.01 14.13 L36.01 15.50 L31.07 15.50 L31.07 20.87 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="Source Serif 4 Italic" data-size="24.00">
|
||||
<path data-gid="853" d="M48.10 11.51 L46.66 20.10 Q47.84 21.57 49.95 21.57 Q51.56 21.57 52.55 20.51 Q53.55 19.46 53.55 17.75 Q53.55 15.71 52.39 14.91 Q51.22 14.10 49.47 14.37 L49.25 13.24 Q54.20 12.38 54.20 8.49 Q54.20 7.22 53.72 6.58 Q53.24 5.94 52.16 5.94 Q50.62 5.94 49.63 7.18 Q48.63 8.42 48.10 11.51 Z M43.61 27.23 L43.37 27.04 L44.81 19.62 L46.47 10.91 Q47.05 7.86 48.68 6.41 Q50.31 4.96 52.40 4.96 Q54.17 4.96 55.09 5.96 Q56.00 6.95 56.00 8.37 Q56.00 10.12 54.81 11.44 Q53.62 12.76 51.58 13.43 Q53.21 13.72 54.33 14.84 Q55.45 15.95 55.45 17.66 Q55.45 19.24 54.75 20.45 Q54.05 21.66 52.88 22.35 Q51.70 23.03 50.21 23.03 Q49.11 23.03 48.10 22.50 Q47.09 21.98 46.42 20.99 L45.63 26.87 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="Source Serif 4 Regular" data-size="24.00">
|
||||
<path data-gid="715" d="M76.60 17.90 L65.25 17.90 L65.25 16.53 L76.60 16.53 Z M76.60 13.10 L65.25 13.10 L65.25 11.73 L76.60 11.73 Z"/>
|
||||
</g>
|
||||
<g fill="#000000" data-face="Source Serif 4 Italic" data-size="24.00">
|
||||
<path data-gid="854" d="M87.91 27.23 L87.67 27.04 L89.11 22.62 Q89.04 19.86 88.89 17.78 Q88.75 15.69 88.49 13.79 Q88.39 13.10 88.20 12.88 Q88.01 12.66 87.65 12.66 Q87.33 12.66 87.01 12.76 Q86.69 12.86 86.18 13.05 L85.87 12.42 Q86.81 11.61 87.35 11.31 Q87.89 11.01 88.53 11.01 Q89.23 11.01 89.66 11.46 Q90.09 11.92 90.26 12.90 Q90.53 14.56 90.60 16.67 Q90.67 18.78 90.67 20.94 Q91.94 19.41 92.84 17.92 Q93.74 16.43 94.56 14.90 Q94.61 14.80 94.65 14.73 Q94.70 14.66 94.73 14.58 L94.56 13.65 Q94.53 13.41 94.50 13.14 Q94.46 12.88 94.46 12.71 Q94.46 11.80 94.81 11.40 Q95.16 11.01 95.76 11.01 Q96.09 11.01 96.33 11.12 Q96.57 11.22 96.67 11.32 Q96.79 11.56 96.79 11.99 Q96.79 12.59 96.51 13.32 Q96.24 14.06 95.59 15.26 Q94.70 16.94 93.35 18.80 Q91.99 20.66 90.55 22.24 L89.93 26.87 Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.1 KiB |