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>
This commit is contained in:
shuff57
2026-10-11 21:14:20 -07:00
co-authored by Claude Sonnet 5.5
parent 1aa81a773a
commit 7a6ef6b935
164 changed files with 19083 additions and 17 deletions
+13 -1
View File
@@ -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.
Generated
+377 -15
View File
@@ -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",
]
+18
View File
@@ -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"
+2 -1
View File
@@ -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
Binary file not shown.
+92
View File
@@ -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.
+92
View File
@@ -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.
+93
View File
@@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+45
View File
@@ -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
1 # id linear display (1 = display style, 0 = inline)
2 frac_simple \frac{a}{b} 0
3 frac_nested \frac{1}{1+\frac{1}{x}} 1
4 frac_nested_inline \frac{a}{\frac{b}{c}} 0
5 cfrac a_0+\cfrac{1}{a_1+\cfrac{1}{a_2+\cfrac{1}{a_3}}} 1
6 sqrt \sqrt{x^2+y^2} 0
7 sqrt_degree \sqrt[3]{x+1} 0
8 sqrt_nested \sqrt{1+\sqrt{1+\sqrt{x}}} 1
9 script_sup x^2 0
10 script_sub a_n 0
11 script_both x_i^2 0
12 script_nested e^{-x^{2}} 0
13 sum_display \sum_{i=1}^{n} i^2 1
14 sum_inline \sum_{i=1}^{n} i^2 0
15 prod_display \prod_{k=1}^{n} k 1
16 int_display \int_0^1 x^2\,dx 1
17 int_inline \int_0^1 x^2\,dx 0
18 oint \oint_C f\,dz 1
19 lim_display \lim_{x\to0}\frac{\sin x}{x}=1 1
20 lim_inline \lim_{n\to\infty}\left(1+\frac{1}{n}\right)^n=e 0
21 pmatrix \begin{pmatrix}a&b\\c&d\end{pmatrix} 1
22 bmatrix \begin{bmatrix}1&0&0\\0&1&0\\0&0&1\end{bmatrix} 1
23 cases |x|=\begin{cases}x&x\geq0\\-x&x<0\end{cases} 1
24 accent_hat \hat{x}+\hat{y} 0
25 accent_vec \vec{v}\cdot\vec{w} 0
26 accent_bar \bar{z}z=|z|^2 0
27 overline \overline{AB} 0
28 overbrace \overbrace{a+b+c}^{3} 1
29 underbrace \underbrace{x+x+x+x}_{n} 1
30 overrightarrow \overrightarrow{AB} 0
31 quadratic x=\frac{-b\pm\sqrt{b^2-4ac}}{2a} 1
32 euler e^{i\pi}+1=0 1
33 maxwell_gauss \nabla\cdot E=\frac{\rho}{\varepsilon_0} 1
34 maxwell_faraday \nabla\times E=-\frac{\partial B}{\partial t} 1
35 maxwell_ampere \nabla\times B=\mu_0J+\mu_0\varepsilon_0\frac{\partial E}{\partial t} 1
36 binom \binom{n}{k}=\frac{n!}{k!(n-k)!} 1
37 delims_tall \left[\frac{a}{b}\right]+\left\{\frac{c}{d}\right\} 1
38 floor_ceil ⌊x⌋+⌈y⌉ 0
39 abs_tall \left|\frac{a}{b}\right| 0
40 angle ⟨u,v⟩ 0
41 greek \alpha+\beta=\gamma 0
42 func \sin^2\theta+\cos^2\theta=1 0
43 boxed \boxed{E=mc^2} 0
44 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
45 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
+92
View File
@@ -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
1 # input (MathLive-style LaTeX) to_latex(from_linear(input))
2 x^2 x^2
3 x^{2} x^2
4 x^{2+1} x^{2+1}
5 x^{n^{2}} x^{n^2}
6 x^2+3x-2 x^2+3x-2
7 2x 2x
8 3x^2+2x-1 3x^2+2x-1
9 \frac{1}{x} \frac{1}{x}
10 \frac{x+1}{x-1} \frac{x+1}{x-1}
11 \frac{\frac{1}{2}}{3} \frac{\frac{1}{2}}{3}
12 \frac{x^{2}}{3} \frac{x^2}{3}
13 \sqrt{x} \sqrt{x}
14 \sqrt{x+1} \sqrt{x+1}
15 \sqrt[3]{x} \sqrt[3]{x}
16 \sqrt{\sqrt{x}} \sqrt{\sqrt{x}}
17 \sin(x) \sin\left(x\right)
18 \sin x \sin\left(x\right)
19 \sin{x} \sin\left(x\right)
20 \cos\left(x\right) \cos\left(x\right)
21 \sin\left(2x\right)+\cos(x) \sin\left(2x\right)+\cos\left(x\right)
22 \tan(x) \tan\left(x\right)
23 \arcsin(x) \arcsin\left(x\right)
24 \sinh(x) \sinh\left(x\right)
25 \ln(x) \ln\left(x\right)
26 \ln x \ln\left(x\right)
27 \log(x) \log\left(x\right)
28 \exp(x) \exp\left(x\right)
29 \abs{x} \left|x\right|
30 |x| \left|x\right|
31 |x-1|+|x+1| \left|x-1\right|+\left|x+1\right|
32 2\pi x 2\pi x
33 \sin(\pi x) \sin\left(\pi x\right)
34 \theta \theta
35 1+\cos\theta 1+\cos\left(\theta\right)
36 \infty \infty
37 \mathrm{e}^{x} \mathrm{e}^x
38 x_1+x_{12} x_1+x_{12}
39 2\cdot3 2\cdot3
40 2\times3 2\times3
41 4\div2 4\div2
42 x\pm1 x\pm1
43 (x+1)(x-1) \left(x+1\right)\left(x-1\right)
44 (x+1)2 \left(x+1\right)2
45 2(x+1) 2\left(x+1\right)
46 x^2+y^2-1 x^2+y^2-1
47 x--1 x--1
48 x++1 x++1
49 3\sqrt{x} 3\sqrt{x}
50 e^{-x^2} e^{-x^2}
51 \frac{\sin(x)}{x} \frac{\sin\left(x\right)}{x}
52 \left|x\right| \left|x\right|
53 \cot(x)\sec(x)\csc(x) \cot\left(x\right)\sec\left(x\right)\csc\left(x\right)
54 2\sin(3x) 2\sin\left(3x\right)
55 \sin^{2}(x) \sin^2\left(x\right)
56 \sqrt[n]{x+1} \sqrt[n]{x+1}
57 a b a b
58 \frac{1}{2}x \frac{1}{2}x
59 x^{\frac{1}{2}} x^{\frac{1}{2}}
60 \sin\theta \sin\left(\theta\right)
61 10x 10x
62 2.5x 2.5x
63 (2)(3)(4) \left(2\right)\left(3\right)\left(4\right)
64 \operatorname{abs}(x) \operatorname{abs}\left(x\right)
65 \frac{1}{} \frac{1}{}
66 \arctan(x)^{2} {\arctan\left(x\right)}^2
67 \cosh x \cosh\left(x\right)
68 \tanh(x) \tanh\left(x\right)
69 x^{2}_{3} x_3^2
70 x^2+y^2=25 x^2+y^2=25
71 y=\frac{x^2-1}{x+1} y=\frac{x^2-1}{x+1}
72 y=\sqrt{4-x^2} y=\sqrt{4-x^2}
73 r=1+\cos\theta r=1+\cos\left(\theta\right)
74 \left(x-2\right)^2+\left(y+1\right)^2=9 \left(x-2\right)^2+\left(y+1\right)^2=9
75 y=\left|x-3\right|+2 y=\left|x-3\right|+2
76 y=\sin\left(2x\right)\cos\left(x\right) y=\sin\left(2x\right)\cos\left(x\right)
77 y=\frac{1}{2}x+\frac{3}{4} y=\frac{1}{2}x+\frac{3}{4}
78 y=e^{x} y=e^x
79 y=\ln\left(x+1\right) y=\ln\left(x+1\right)
80 y=\sqrt[3]{x^2} y=\sqrt[3]{x^2}
81 f\left(x\right)=ax^2+bx+c f\left(x\right)=ax^2+bx+c
82 \frac{d}{dx}x^2 \frac{d}{dx}x^2
83 y=2^{x} y=2^x
84 y=x^{\frac{2}{3}} y=x^{\frac{2}{3}}
85 x^{2}+y^{2}\le 4 x^2+y^2\le 4
86 y=\sin^{2}\left(x\right) y=\sin^2\left(x\right)
87 \left(1,2\right) \left(1,2\right)
88 \left[1,2,3\right] \left[1,2,3\right]
89 a=\frac{-b\pm\sqrt{b^{2}-4ac}}{2a} a=\frac{-b\pm\sqrt{b^2-4ac}}{2a}
90 y=\left\{x<0:-x,x\right\} y=\left\{x<0:-x,x\right\}
91 \sum_{n=1}^{5}n^2 \sum_{n=1}^5n^2
92 \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>
+34
View File
@@ -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"
+85
View File
@@ -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,
}))
}),
)
}
+169
View File
@@ -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
}
}
+800
View File
@@ -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;
+150
View File
@@ -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);
}
}
+644
View File
@@ -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();
}
+33
View File
@@ -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 }
+862
View File
@@ -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();
}
}
+262
View File
@@ -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");
}
}
+29
View File
@@ -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 }
+54
View File
@@ -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;
File diff suppressed because it is too large Load Diff
+37
View File
@@ -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),
}
+101
View File
@@ -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:?}");
}
+130
View File
@@ -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");
}
}
+25
View File
@@ -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)));
}
}
+11
View File
@@ -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());
}
}
File diff suppressed because it is too large Load Diff
+955
View File
@@ -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)");
}
+35
View File
@@ -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)
}
File diff suppressed because one or more lines are too long
+140
View File
@@ -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");
}
}
+620
View File
@@ -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}");
}
}
}
+167
View File
@@ -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));
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+757
View File
@@ -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
}
+132
View File
@@ -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)
}
File diff suppressed because it is too large Load Diff
+491
View File
@@ -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("&#13;"),
'\n' if attr => out.push_str("&#10;"),
'\t' if attr => out.push_str("&#9;"),
'&' => out.push_str("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
_ => 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
}
+92
View File
@@ -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("&amp;") && !x.contains("&lt;") && !x.contains("&gt;") && !x.contains("&quot;") && !x.contains("&apos;"), "{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}");
}
}
}
+34
View File
@@ -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 }
+20
View File
@@ -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}");
}
}
+250
View File
@@ -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);
}
}
+150
View File
@@ -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('&', "&amp;").replace('"', "&quot;").replace('<', "&lt;").replace('>', "&gt;")
}
/// 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));
}
}
+159
View File
@@ -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}");
}
}
+87
View File
@@ -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.
+224
View File
@@ -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.
+22
View File
@@ -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.
+11
View File
@@ -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
}
+11
View File
@@ -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

+11
View File
@@ -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
}
+20
View File
@@ -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

+11
View File
@@ -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
}
+17
View File
@@ -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

+11
View File
@@ -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
}
+17
View File
@@ -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

+11
View File
@@ -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
}
+17
View File
@@ -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

+11
View File
@@ -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
}
+44
View File
@@ -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

+11
View File
@@ -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
}
+35
View File
@@ -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

+11
View File
@@ -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
}
+21
View File
@@ -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

+11
View File
@@ -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
}
+38
View File
@@ -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

+11
View File
@@ -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
}
+47
View File
@@ -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

+11
View File
@@ -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
}
+27
View File
@@ -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

+11
View File
@@ -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
}
+23
View File
@@ -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

+11
View File
@@ -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
}
+23
View File
@@ -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

+11
View File
@@ -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
}
+19
View File
@@ -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

+11
View File
@@ -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
}
+13
View File
@@ -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

+11
View File
@@ -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
}
+9
View File
@@ -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

+11
View File
@@ -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
}
+33
View File
@@ -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

+11
View File
@@ -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
}
+17
View File
@@ -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

Some files were not shown because too many files have changed in this diff Show More