diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index c93a94891..2a4b5c96e 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -46,3 +46,5 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra | `docs/images/pivot.png` | GridCraft contributors | screenshot of GridCraft rendered offscreen (`crates/ui-egui/examples/snapshot.rs`) showing an original sample workbook | MIT OR Apache-2.0 | README screenshot; no third-party content | | `docs/images/format-chart.png` | GridCraft contributors | screenshot of GridCraft rendered offscreen (`crates/ui-egui/examples/snapshot.rs`) showing an original sample workbook | MIT OR Apache-2.0 | README screenshot; no third-party content | | `docs/images/page-break.png` | GridCraft contributors | screenshot of GridCraft rendered offscreen (`crates/ui-egui/examples/snapshot.rs`) showing an original sample workbook | MIT OR Apache-2.0 | README screenshot; no third-party content | +| Core 14 AFM metric numbers in `crates/pdf/src/symbol.rs` (Symbol: character codes and advance widths) and `crates/pdf/src/metrics.rs` (Helvetica, Times and Courier advance widths); no font file | Adobe Systems Incorporated | Adobe's Core 14 AFM font metrics files (`Symbol.afm` 001.008 and the Helvetica, Times and Courier AFMs) | Adobe's Core 14 metrics notice: the files may be used, copied and distributed for any purpose and without charge, provided the notice is kept (quoted in `crates/pdf/src/metrics.rs`) | Numbers only, typed in from the public metrics. PDF export embeds no font: Symbol and the Times faces are base-14 fonts every PDF reader supplies, and text outside WinAnsiEncoding is set in a predefined, non-embedded Adobe-GB1 CID font | +| Unicode values of the Symbol encoding in `crates/pdf/src/symbol.rs` (the code-to-character map; numbers only, no file copied) | Unicode, Inc. | `symbol.txt`, "Adobe Symbol Encoding to Unicode", table version 1.0 (2011-07-12), at unicode.org/Public/MAPPINGS/VENDORS/ADOBE/ | Unicode, Inc.'s notice in the file: "Unicode, Inc. hereby grants the right to freely use the information supplied in this file in the creation of products supporting the Unicode Standard, and to make copies of this file in any form for internal or external distribution as long as this notice remains attached." The file is provided as-is, without warranties | Separate from Adobe's Core 14 notice (previous row), which covers the character codes and widths; the Unicode values come from this table | diff --git a/Cargo.lock b/Cargo.lock index 80cf2de70..e8db55880 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -20,7 +20,7 @@ dependencies = [ "accesskit", "accesskit_consumer", "atspi-common", - "phf", + "phf 0.14.0", "serde", "zvariant", ] @@ -468,15 +468,30 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec 0.8.0", +] + [[package]] name = "bit-set" version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d" dependencies = [ - "bit-vec", + "bit-vec 0.9.1", ] +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bit-vec" version = "0.9.1" @@ -1495,6 +1510,17 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fancy-regex" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e24cb5a94bcae1e5408b0effca5cd7172ea3c5755049c5f3af4cd283a165298" +dependencies = [ + "bit-set 0.8.0", + "regex-automata", + "regex-syntax", +] + [[package]] name = "fastrand" version = "2.5.0" @@ -2017,6 +2043,7 @@ dependencies = [ "gridcraft-core", "gridcraft-formula", "gridcraft-functions", + "gridcraft-math", "gridcraft-model", "gridcraft-numfmt", "gridcraft-pdf", @@ -2047,6 +2074,16 @@ dependencies = [ "regex", ] +[[package]] +name = "gridcraft-math" +version = "0.15.0" +dependencies = [ + "ratex-font", + "ratex-layout", + "ratex-parser", + "ratex-types", +] + [[package]] name = "gridcraft-mcp" version = "0.15.0" @@ -2062,6 +2099,7 @@ version = "0.15.0" dependencies = [ "gridcraft-core", "gridcraft-formula", + "gridcraft-math", "serde", "serde_json", "sha2 0.11.1", @@ -2129,6 +2167,7 @@ dependencies = [ "gridcraft-core", "gridcraft-formula", "gridcraft-functions", + "gridcraft-math", "gridcraft-model", "quick-xml 0.41.0", "sha1 0.11.0", @@ -2967,7 +3006,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130" dependencies = [ "arrayvec", - "bit-set", + "bit-set 0.10.0", "bitflags 2.13.2", "cfg-if", "cfg_aliases", @@ -3543,17 +3582,37 @@ dependencies = [ "indexmap", ] +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros 0.11.3", + "phf_shared 0.11.3", +] + [[package]] name = "phf" version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" dependencies = [ - "phf_macros", - "phf_shared", + "phf_macros 0.14.0", + "phf_shared 0.14.0", "serde", ] +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand 0.8.8", +] + [[package]] name = "phf_generator" version = "0.14.0" @@ -3561,7 +3620,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100" dependencies = [ "fastrand", - "phf_shared", + "phf_shared 0.14.0", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -3570,13 +3642,22 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.14.0", + "phf_shared 0.14.0", "proc-macro2", "quote", "syn 2.0.119", ] +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + [[package]] name = "phf_shared" version = "0.14.0" @@ -3852,7 +3933,7 @@ dependencies = [ "bytes", "getrandom 0.4.3", "lru-slab", - "rand", + "rand 0.10.3", "rand_pcg", "ring", "rustc-hash 2.1.3", @@ -3900,6 +3981,15 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "rand_core 0.6.4", +] + [[package]] name = "rand" version = "0.10.3" @@ -3938,6 +4028,66 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" +[[package]] +name = "ratex-font" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a7b73531dabe934a3e725ed5c90d18dacf43778b7a208381390943a9a8c24db" +dependencies = [ + "phf 0.11.3", + "ratex-types", +] + +[[package]] +name = "ratex-layout" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1792738e2e36683cd20de742296a5233759565efc8cd03831064a0ccfb1fe0f7" +dependencies = [ + "ratex-font", + "ratex-parser", + "ratex-types", + "serde_json", +] + +[[package]] +name = "ratex-lexer" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f704add86d7bf4c4ecc7767f45ab38a44f1bbfa4b49725b2ae53c32e83b8a61e" +dependencies = [ + "ratex-types", + "serde", + "thiserror 1.0.69", +] + +[[package]] +name = "ratex-parser" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "079ec2e7ac6bc76137a6de093a173d1f901139a51318589ab9ce17139c21a6f0" +dependencies = [ + "fancy-regex", + "ratex-font", + "ratex-lexer", + "ratex-types", + "regex", + "regex-lite", + "serde", + "serde_json", + "thiserror 1.0.69", + "unicode-normalization", +] + +[[package]] +name = "ratex-types" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cf0be3d050525f0f7d37af55a443d7648c222ccc617639950e3de1f8e6f9900" +dependencies = [ + "serde", +] + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -4037,6 +4187,12 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + [[package]] name = "regex-syntax" version = "0.8.11" @@ -4617,7 +4773,7 @@ dependencies = [ "const-oid", "der", "hex", - "rand", + "rand 0.10.3", "reqwest", "rustls-pki-types", "rustls-webpki", @@ -5346,6 +5502,15 @@ version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" +[[package]] +name = "unicode-normalization" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75cd3fb03d5288b3ba9a1ee103ac7e9cbb59f0b03d64f8ddfcc04dd42758a85d" +dependencies = [ + "tinyvec", +] + [[package]] name = "unicode-properties" version = "0.1.4" @@ -5786,8 +5951,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "14c018fce9b6270aa203c2fdd56f3cce996713534bd757e4ea58c8560b121f14" dependencies = [ "arrayvec", - "bit-set", - "bit-vec", + "bit-set 0.10.0", + "bit-vec 0.9.1", "bitflags 2.13.2", "bytemuck", "cfg_aliases", @@ -5859,7 +6024,7 @@ dependencies = [ "android_system_properties", "arrayvec", "ash", - "bit-set", + "bit-set 0.10.0", "bitflags 2.13.2", "block2 0.6.2", "bytemuck", diff --git a/Cargo.toml b/Cargo.toml index 0ce2e8f0b..8aa7ec8e7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ gridcraft-engine = { path = "crates/engine" } gridcraft-ui-egui = { path = "crates/ui-egui" } gridcraft-mcp = { path = "crates/mcp" } gridcraft-pdf = { path = "crates/pdf" } +gridcraft-math = { path = "crates/math" } # Every craft-libs crate (craft-update and craft-window here, craft-ui in ui-egui, craft-print in # the app) comes from one storytold/craft-libs revision. # craft-update: app-specific pretend versions apply to startup and manual checks. @@ -47,6 +48,13 @@ aes = { version = "0.9", default-features = false } sha1 = { version = "0.11", default-features = false } image = { version = "0.25", default-features = false, features = ["png", "jpeg"] } +# equations: RaTeX (MIT) lays LaTeX out as positioned glyphs, rules and paths. Exact pins: it is +# young (0.1.x), and the gate recorded in docs/decisions.md vetted this release. +ratex-parser = "=0.1.14" +ratex-layout = "=0.1.14" +ratex-types = "=0.1.14" +ratex-font = "=0.1.14" + # ui egui = "0.37" egui_extras = { version = "0.37", default-features = false } diff --git a/ROADMAP.md b/ROADMAP.md index 2471ac5e2..fb9985c72 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away -> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-02 · **Change:** minor (Python cells stored and kept in files; earlier: What-if and analysis: Solver Sensitivity report for Simplex LP and GRG Nonlinear; earlier: floating objects: one selection, eight resize handles, keyboard delete; earlier: Chart data tables: live source values beneath column, bar, line, area and compatible combo charts; legend keys and borders; XLSX round-trip and print/PDF; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and Insert Function categories; XLSX workbook-view selection, bounded tab ratios and sheet-edit regressions; readiness estimates unchanged; earlier: CSV and text open detects each column's date order, so day-first dates are not silently swapped) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-02 · **Change:** minor (Python cells stored and kept in files; earlier: equations: Insert › Equation, editor, gridcraft-math; earlier: What-if and analysis: Solver Sensitivity report for Simplex LP and GRG Nonlinear; earlier: floating objects: one selection, eight resize handles, keyboard delete; earlier: Chart data tables: live source values beneath column, bar, line, area and compatible combo charts; legend keys and borders; XLSX round-trip and print/PDF; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and Insert Function categories; XLSX workbook-view selection, bounded tab ratios and sheet-edit regressions; readiness estimates unchanged; earlier: CSV and text open detects each column's date order, so day-first dates are not silently swapped) · **Target:** Microsoft Excel (Microsoft 365) GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web. @@ -22,7 +22,7 @@ This page is the summary. The detail is in: | Number | Value | Kind | |---|---|---| | 2026-10-14 | Editing a cell with text that would read back as a number, boolean, date or formula (entered with a leading apostrophe) shows the apostrophe in the editor and formula bar, so Enter unchanged keeps it text (#1362) | -| **Feature breadth** (weighted) | **~75%** | estimated, anchored on two measurements: ribbon catalog 276 / 292 (94.5%), worksheet functions 513 / 523 (98.1%; 504 working, 96.4%) | +| **Feature breadth** (weighted) | **~75%** | estimated, anchored on two measurements: ribbon catalog 277 / 292 (94.9%), worksheet functions 513 / 523 (98.1%; 504 working, 96.4%) | | **Ready for real work** | **~50%** | estimated from written weights (51.6); [target-app-parity.md](docs/target-app-parity.md) | | **Mainstream practitioner** | **~50%** | estimated: typical analyst/finance user, weekly features, × 0.90 interaction × 0.92 stability × 0.88 file exchange; [method](docs/target-app-parity.md#mainstream-practitioner) | | **Essentials user** | **~63%** | estimated: core features only, × 0.92 launch/stability × 0.95 clarity × 0.90 opening sent files; [method](docs/target-app-parity.md#essentials-user) | @@ -136,6 +136,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| | 2026-11-02 | Python cells as data: `python.set` stores code in a cell, Python cells copy, move, fill and undo like formulas, and `.xlsx` reads and writes Python cells as [MS-XLSX] specifies (`_xlfn._xlws.PY(index,0 or 1)`, the code in the workbook's Python part) with cached values and spills, so such workbooks open with their results; nothing runs Python (new code shows `#BLOCKED!`), and opening a file limits code size, result size and spill ranges | +| 2026-11-02 | Equations: Insert › Equation (a split button with a gallery of examples) and an editor with quick-insert buttons, the error underlined at its position and a live preview; `insert.equation` and `equation.edit`; LaTeX laid out by RaTeX inside the new `gridcraft-math` crate, capped and panic-guarded, and cached on the shape so that painting never runs the layout engine; xlsx writes Office Math with a plain-text fallback and the source in `descr`, and reads Office Math back into LaTeX; PDF prints equations with the standard Symbol font and `[?]` for anything else | | 2026-11-02 | Solver writes a Sensitivity report: Simplex LP shadow prices, reduced costs and allowable ranges from the final tableau; GRG Nonlinear reduced gradients and Lagrange multipliers from difference gradients, at a Karush-Kuhn-Tucker point only; Solver Results offers it where it applies (no score change) | | 2026-11-02 | Floating objects: charts, pictures, shapes and slicers share one selection with a frame of eight resize handles (each moves only its own edges, also on right-to-left sheets); Delete or Backspace deletes the selected object and Escape drops the selection; slicers follow row and column edits and sorts like the other objects; chart series follow cut-moves and sheet renames and deletes; a copied sheet gives its objects fresh ids; `ui.inspect` reports `selectedObject` and `ui.object.select` selects any object (#190, #213) | | 2026-11-01 | Chart data tables are available from Chart Design › Add Chart Element and Format Chart Area. Tables follow live source values and number formats, keep blank cells blank, offer legend keys and border settings, and survive XLSX save/reopen; the shared renderer includes them in print and PDF. Readiness estimates unchanged. | @@ -255,6 +256,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| | 2026-11-02 | minor | Progress log: Python cells as data | +| 2026-11-02 | minor | Progress log: equations (Insert › Equation, the editor, gridcraft-math) | | 2026-11-02 | minor | What-if and analysis: Solver Sensitivity report for Simplex LP and GRG Nonlinear (no score change) | | 2026-11-02 | minor | Progress log: one selection and eight resize handles for every floating object | | 2026-11-01 | minor | Chart data tables are available from Chart Design › Add Chart Element and Format Chart Area. Tables follow live source values and number formats, keep blank cells blank, offer legend keys and border settings, and survive XLSX save/reopen; the shared renderer includes them in print and PDF. Readiness estimates unchanged. | diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 1de94c60c..341d1b35c 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -23,6 +23,7 @@ log = { workspace = true } gridcraft-xlsx = { workspace = true } gridcraft-chart = { workspace = true } gridcraft-pdf = { workspace = true } +gridcraft-math = { workspace = true } image = { workspace = true } [dev-dependencies] diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 8092968d6..77fbb1d7a 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -5,6 +5,7 @@ pub mod analysis; pub mod data; pub mod draw; pub mod edit; +pub mod equation; pub mod extra; pub mod file; pub mod forecast; @@ -41,7 +42,11 @@ pub struct CommandSpec { pub label: &'static str, /// Ribbon/menu placement, e.g. `["Home", "Font"]`. Empty = not in menus. pub menu: &'static [&'static str], + /// Portable notation: `Cmd` is ⌘ on macOS and Ctrl elsewhere. pub shortcut: Option<&'static str>, + /// macOS override when its key differs. Equal to `shortcut` marks a Mac-only shortcut + /// (Align Left/Center): other platforms show none. See [`CommandInfo::shortcut_for_platform`]. + pub shortcut_mac: Option<&'static str>, pub params: &'static str, pub enabled: Enabled, pub run: Run, @@ -55,12 +60,32 @@ pub struct CommandInfo { pub label: &'static str, pub menu: Vec<&'static str>, pub shortcut: Option<&'static str>, + pub shortcut_mac: Option<&'static str>, pub params: &'static str, pub enabled: bool, #[serde(skip_serializing_if = "Option::is_none")] pub disabled_reason: Option, } +impl CommandInfo { + /// Format the portable shortcut notation for the runtime operating system. + pub fn shortcut_for_platform(&self, mac: bool) -> Option { + let keys = if mac { + self.shortcut_mac.or(self.shortcut) + } else if self.shortcut_mac.is_some() && self.shortcut_mac == self.shortcut { + // An identical Mac override marks a Mac-only display shortcut. + // The portable field remains unchanged for legacy CLI consumers. + None + } else { + self.shortcut + }?; + if mac { + return Some(keys.to_owned()); + } + Some(keys.split('+').map(|part| if part == "Cmd" { "Ctrl" } else { part }).collect::>().join("+")) + } +} + impl CommandSpec { pub fn info(&self, s: &Session) -> CommandInfo { let e = (self.enabled)(s); @@ -69,6 +94,7 @@ impl CommandSpec { label: self.label, menu: self.menu.to_vec(), shortcut: self.shortcut, + shortcut_mac: self.shortcut_mac, params: self.params, enabled: e.is_ok(), disabled_reason: e.err(), @@ -119,13 +145,13 @@ pub fn has_clipboard(s: &Session) -> std::result::Result<(), String> { macro_rules! cmd { ($id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => { - $crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: true, undoable: true } + $crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, shortcut_mac: None, params: $params, enabled: $en, run: $run, journal: true, undoable: true } }; (query $id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => { - $crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: false, undoable: false } + $crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, shortcut_mac: None, params: $params, enabled: $en, run: $run, journal: false, undoable: false } }; (noundo $id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => { - $crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: true, undoable: false } + $crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, shortcut_mac: None, params: $params, enabled: $en, run: $run, journal: true, undoable: false } }; } pub(crate) use cmd; @@ -139,6 +165,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(format::specs()); v.extend(sheet::specs()); v.extend(insert::specs()); + v.extend(equation::specs()); v.extend(data::specs()); v.extend(formulas::specs()); v.extend(review::specs()); @@ -182,6 +209,7 @@ pub fn register_test_command(id: &'static str, run: Run) { label: id, menu: &[], shortcut: None, + shortcut_mac: None, params: "", enabled: |_| Ok(()), run, @@ -199,6 +227,7 @@ pub(crate) fn register_undoable_test_command(id: &'static str, run: Run) { label: id, menu: &[], shortcut: None, + shortcut_mac: None, params: "", enabled: |_| Ok(()), run, @@ -376,6 +405,9 @@ pub(crate) fn commit(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result) }; let r = f(&mut ctx)?; let Ctx { mut wb, changed, structural, mut fit_rows, protection_checked, edits, .. } = ctx; + // Equations whose source changed (or that arrived without a layout, as from a paste) are + // laid out here, so painting only ever reads the cache. + wb.refresh_equations(); if !protection_checked { check_protection(&d.wb, &wb, &d.unlocked_ranges)?; } diff --git a/crates/engine/src/cmd/data.rs b/crates/engine/src/cmd/data.rs index 143763a5e..0906f4893 100644 --- a/crates/engine/src/cmd/data.rs +++ b/crates/engine/src/cmd/data.rs @@ -99,18 +99,30 @@ pub fn specs() -> Vec { ), cmd!(query "data.validate", "Check Value Against Validation", [], None, "{cell?, input} → {ok, message?}", has_doc, validate_cmd), cmd!(noundo "data.circleInvalid", "Circle Invalid Data", ["Data", "Data Tools", "Data Validation"], None, "{} → invalid cells", has_doc, circle_invalid), - cmd!( - "data.group", - "Group", - ["Data", "Outline"], - Some("Alt+Shift+Right"), - "{rows?: \"2:5\" | cols?: \"B:C\" | by?: rows|cols (the selection's rows or columns; a block of cells groups rows by default), sheet?}", - has_doc, - |s, p| group(s, p, true) - ), - cmd!("data.ungroup", "Ungroup", ["Data", "Outline"], Some("Alt+Shift+Left"), "{rows? | cols? | by?: rows|cols, sheet?}", has_doc, |s, p| { - group(s, p, false) - }), + CommandSpec { + shortcut_mac: Some("Cmd+Shift+K"), + ..cmd!( + "data.group", + "Group", + ["Data", "Outline"], + Some("Alt+Shift+Right"), + "{rows?: \"2:5\" | cols?: \"B:C\" | by?: rows|cols (the selection's rows or columns; a block of cells groups rows by default), sheet?}", + has_doc, + |s, p| group(s, p, true) + ) + }, + CommandSpec { + shortcut_mac: Some("Cmd+Shift+J"), + ..cmd!( + "data.ungroup", + "Ungroup", + ["Data", "Outline"], + Some("Alt+Shift+Left"), + "{rows? | cols? | by?: rows|cols, sheet?}", + has_doc, + |s, p| { group(s, p, false) } + ) + }, cmd!( "data.hideDetail", "Hide Detail", diff --git a/crates/engine/src/cmd/draw.rs b/crates/engine/src/cmd/draw.rs index f6184509c..c8921718c 100644 --- a/crates/engine/src/cmd/draw.rs +++ b/crates/engine/src/cmd/draw.rs @@ -80,6 +80,7 @@ fn stroke(s: &mut Session, p: &Json) -> Result { let text = pts.iter().map(|q| format!("{:.1},{:.1}", q[0] - minx, q[1] - miny)).collect::>().join(";"); let id = d.wb.next_object_id(); let shape = Shape { + math: Default::default(), id, kind: ShapeKind::Ink, anchor: Anchor { diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index bf83f37ea..2aa9d8fe3 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -81,15 +81,18 @@ pub fn specs() -> Vec { has_doc, paste ), - cmd!( - "edit.pasteSpecial", - "Paste Special…", - ["Home", "Clipboard"], - Some("Ctrl+Cmd+V"), - "{what: all|formulas|values|formats|comments|validation|allExceptBorders|columnWidths|formulasAndNumberFormats|valuesAndNumberFormats, operation?: none|add|subtract|multiply|divide, skipBlanks?, transpose?, link?}", - has_clipboard, - paste_special - ), + CommandSpec { + shortcut_mac: Some("Ctrl+Cmd+V"), + ..cmd!( + "edit.pasteSpecial", + "Paste Special…", + ["Home", "Clipboard"], + Some("Ctrl+Alt+V"), + "{what: all|formulas|values|formats|comments|validation|allExceptBorders|columnWidths|formulasAndNumberFormats|valuesAndNumberFormats, operation?: none|add|subtract|multiply|divide, skipBlanks?, transpose?, link?}", + has_clipboard, + paste_special + ) + }, cmd!(noundo "edit.clearClipboard", "Cancel Copy", [], Some("Escape"), "{}", has_doc, clear_clipboard), cmd!("edit.clearAll", "Clear All", ["Home", "Editing", "Clear"], None, "{range?}", has_doc, |s, p| clear(s, p, "all")), cmd!("edit.clearFormats", "Clear Formats", ["Home", "Editing", "Clear"], None, "{range?}", has_doc, |s, p| clear(s, p, "formats")), diff --git a/crates/engine/src/cmd/equation.rs b/crates/engine/src/cmd/equation.rs new file mode 100644 index 000000000..d485a3535 --- /dev/null +++ b/crates/engine/src/cmd/equation.rs @@ -0,0 +1,305 @@ +//! Equations (Insert › Symbols › Equation): floating shapes holding LaTeX source, laid out by +//! `gridcraft-math` and drawn from the cached layout. +//! +//! An equation is drawn to fit its frame, so resizing the frame scales it. Inserting sizes the +//! frame for the requested font size; editing keeps the current scale and resizes the frame to +//! the new content. + +use gridcraft_math::{MathError, MathList}; +use gridcraft_model::{Anchor, AnchorMode, Color, EquationCache, Shape, ShapeKind}; +use serde_json::{Value as Json, json}; + +use super::{CommandSpec, EngineError, PROTECTED, Result, Session, bad, cell_param, cmd, edit, f64_param, has_doc, ok, str_param, u32_param}; + +pub fn specs() -> Vec { + vec![ + cmd!( + "insert.equation", + "Equation", + ["Insert", "Symbols"], + None, + "{latex: \"x^2\", cell?|at?: \"B2\", size?: 11 (points), color?: hex|\"auto\"}", + has_doc, + insert_equation + ), + cmd!("equation.edit", "Edit Equation", [], None, "{id, latex}", has_doc, edit_equation), + ] +} + +/// Default equation font size, in points. +pub const DEFAULT_SIZE_PT: f64 = 11.0; +/// Space between an equation's frame and its content, in sheet pixels. +pub const PADDING: f32 = 4.0; +/// Sheet pixels per point. +const PX_PER_PT: f64 = 96.0 / 72.0; + +/// The scale (sheet pixels per em) at which `list` fits a frame of `w` by `h` pixels. +pub fn fit_scale(list: &MathList, w: f32, h: f32) -> f32 { + let (cw, ch) = (list.width.max(0.01), list.total_height().max(0.01)); + let s = ((w - 2.0 * PADDING) / cw).min((h - 2.0 * PADDING) / ch); + if s.is_finite() { s.max(0.0) } else { 0.0 } +} + +/// The frame size for `list` at `scale` pixels per em. +fn frame_for(list: &MathList, scale: f32) -> (f32, f32) { + let w = list.width.max(0.5) * scale + 2.0 * PADDING; + let h = list.total_height().max(1.0) * scale + 2.0 * PADDING; + (w.clamp(8.0, 20_000.0), h.clamp(8.0, 20_000.0)) +} + +/// `latex` laid out, or a readable error with the position of the problem. +fn lay_out(cmd: &str, latex: &str) -> Result { + gridcraft_math::layout(latex).map_err(|e| bad(cmd, describe(latex, &e))) +} + +/// "Error at character 5: Undefined control sequence: \foo". +pub fn describe(latex: &str, e: &MathError) -> String { + match e.char_pos(latex) { + Some(p) => format!("equation error at character {p}: {}", e.message), + None => format!("equation error: {}", e.message), + } +} + +fn insert_equation(s: &mut Session, p: &Json) -> Result { + const CMD: &str = "insert.equation"; + let latex = str_param(p, "latex").ok_or_else(|| bad(CMD, "`latex` must be the equation's LaTeX source, such as \"x^2 + y^2 = r^2\""))?; + if latex.trim().is_empty() { + return Err(bad(CMD, "`latex` is empty")); + } + let size = f64_param(p, "size").unwrap_or(DEFAULT_SIZE_PT); + if !(1.0..=409.0).contains(&size) { + return Err(bad(CMD, "`size` must be between 1 and 409 points")); + } + let color = match p.get("color") { + None | Some(Json::Null) => Color::Auto, + Some(c) => super::format::color_param(Some(c)).ok_or_else(|| bad(CMD, "`color` must be a hex colour such as \"#1F4E79\", or \"auto\""))?, + }; + let list = lay_out(CMD, latex)?; + let at = match cell_param(s, p, "cell")? { + Some(c) => Some(c), + None => cell_param(s, p, "at")?, + }; + let d = s.doc()?; + if d.wb.active().is_some_and(|sh| sh.is_protected()) { + return Err(EngineError::Other(PROTECTED.into())); + } + let at = at.unwrap_or(d.selection.active); + let id = d.wb.next_object_id(); + let (width, height) = frame_for(&list, (size * PX_PER_PT) as f32); + let mut shape = Shape { + id, + kind: ShapeKind::Equation, + anchor: Anchor { cell: at, dx: 0.0, dy: 0.0, width, height, mode: AnchorMode::MoveOnly }, + fill: Color::Auto, + line: color, + text: latex.to_string(), + format: Default::default(), + math: Default::default(), + }; + shape.math.refresh(latex); + let sheet = d.wb.active_sheet(); + edit(s, |cx| { + cx.sheet_mut(sheet)?.shapes.push(shape); + Ok(json!({"equation": id})) + }) +} + +fn edit_equation(s: &mut Session, p: &Json) -> Result { + const CMD: &str = "equation.edit"; + let id = u32_param(p, "id").ok_or_else(|| bad(CMD, "`id` must be an equation id"))?; + let latex = str_param(p, "latex").ok_or_else(|| bad(CMD, "`latex` must be the equation's LaTeX source"))?; + if latex.trim().is_empty() { + return Err(bad(CMD, "`latex` is empty; delete the equation instead")); + } + let d = s.doc()?; + let sheet = d.wb.active_sheet(); + let sh = d.wb.active().ok_or_else(|| bad(CMD, "no such equation"))?; + let shape = sh.shapes.iter().find(|x| x.id == id && x.kind == ShapeKind::Equation).ok_or_else(|| bad(CMD, "no such equation"))?; + if sh.is_protected() { + return Err(EngineError::Other(PROTECTED.into())); + } + // The same source is a no-op, unless it has no layout yet (a file with more equations than + // are laid out at once): then OK lays it out. + if shape.text == latex && matches!(shape.math.get(latex), Some(Ok(_))) { + return ok(); + } + let list = lay_out(CMD, latex)?; + // Keep the size the equation is drawn at now; without a layout, use the default size. + let scale = match shape.math.get(&shape.text) { + Some(Ok(old)) => fit_scale(old, shape.anchor.width, shape.anchor.height), + _ => 0.0, + }; + let scale = if scale > 0.1 { scale } else { (DEFAULT_SIZE_PT * PX_PER_PT) as f32 }; + let (width, height) = frame_for(&list, scale); + edit(s, |cx| { + let shape = cx.sheet_mut(sheet)?.shapes.iter_mut().find(|x| x.id == id).ok_or_else(|| bad(CMD, "no such equation"))?; + shape.text = latex.to_string(); + shape.math = EquationCache::default(); + shape.math.refresh(latex); + shape.anchor.width = width; + shape.anchor.height = height; + Ok(json!({"equation": id})) + }) +} + +// ---------------------------------------------------------------- PDF + +/// The first [`gridcraft_math::MAX_SOURCE_BYTES`] bytes of an equation's source, cut at a char +/// boundary. A longer source never lays out (the editor refuses it, and a file can carry one of +/// megabytes), so what shows it as text, the sheet painter and the PDF fallback, shows this much +/// and does no work on the rest. +pub fn source_excerpt(text: &str) -> &str { + let cap = gridcraft_math::MAX_SOURCE_BYTES; + if text.len() <= cap { + return text; + } + let mut end = cap; + while end > 0 && !text.is_char_boundary(end) { + end -= 1; + } + text.get(..end).unwrap_or("") +} + +/// The PDF face for a glyph: Symbol for Greek letters and operators, else the Times (or +/// Helvetica, Courier) face for its style. `None` when no standard font can show `c`. +fn pdf_font(c: char, face: gridcraft_math::Face) -> Option { + use gridcraft_math::Face; + use gridcraft_pdf::Font; + let text = match face { + Face::Italic => Font::TimesItalic, + Face::Bold => Font::TimesBold, + Face::BoldItalic => Font::TimesBoldItalic, + Face::Sans => Font::Helvetica, + Face::SansBold => Font::HelveticaBold, + Face::SansItalic => Font::HelveticaOblique, + Face::Mono => Font::Courier, + Face::Roman | Face::Large(_) => Font::TimesRoman, + }; + // Letters and digits stay in the text face; everything Symbol has goes to Symbol, whose + // operators (minus, equals, plus) match the maths better than the text faces'. + if c.is_ascii_alphanumeric() { + return Some(text); + } + if Font::Symbol.can_encode(c) { + return Some(Font::Symbol); + } + text.can_encode(c).then_some(text) +} + +/// Draws an equation's layout into the box `(x, y, w, h)` (page points) of a PDF page, with +/// `pad` points of padding. Glyphs no standard font has are drawn as `[?]` boxes. +pub(crate) fn draw_pdf(pg: &mut gridcraft_pdf::Page, list: &MathList, rect: (f32, f32, f32, f32), pad: f32, ink: gridcraft_pdf::Rgb) { + use gridcraft_math::{Face, Item, PathCmd}; + let (x, y, w, h) = rect; + let (cw, ch) = (list.width.max(0.01), list.total_height().max(0.01)); + let s = ((w - 2.0 * pad) / cw).min((h - 2.0 * pad) / ch); + if !(s.is_finite() && s > 0.01) { + return; + } + let s = s.min(4000.0); + let (ox, oy) = (x + (w - cw * s) / 2.0, y + (h - ch * s) / 2.0); + let col = |c: Option<[u8; 3]>| c.unwrap_or(ink); + for item in list.items.iter().take(gridcraft_math::MAX_ITEMS) { + match item { + Item::Glyph(g) => { + let color = col(g.color); + let (gx, base) = (ox + g.x * s, oy + g.y * s); + // The radical sign: Symbol's glyph (AFM bounding box -147 to 917 high, right edge + // at 558, in 1/1000 em) fitted to the TeX box, so its top meets the vinculum. + if g.ch == '\u{221A}' { + let size = (g.height + g.depth) * s / 1.064; + if size.is_finite() && size >= 0.5 { + let top = base - g.height * s; + pg.text(gx + g.width * s - 0.558 * size, top + 0.917 * size, size, gridcraft_pdf::Font::Symbol, color, "\u{221A}"); + } + continue; + } + let large = matches!(g.face, Face::Large(_)); + let size = if large { (g.height + g.depth) * s } else { g.size * s }; + if !(size.is_finite() && size >= 0.5) { + continue; + } + let pick = pdf_font(g.ch, g.face).map(|f| (g.ch, f)).or_else(|| g.alt.and_then(|a| pdf_font(a, g.face).map(|f| (a, f)))); + match pick { + Some((c, font)) => { + let text = c.to_string(); + if large { + // Centred on the glyph's box, like the screen. + let mid = oy + (g.y - (g.height - g.depth) / 2.0) * s; + let tw = gridcraft_pdf::text_width(font, size, &text); + pg.text(gx + (g.width * s - tw) / 2.0, mid + 0.25 * size, size, font, color, &text); + } else { + pg.text(gx, base, size, font, color, &text); + } + } + None => { + // [?]: the glyph's box with a question mark. + let (bw, bh) = ((g.width.max(0.3)) * s, (g.height + g.depth) * s); + pg.rect_stroke(gx, base - g.height * s, bw, bh, (0.05 * s).clamp(0.2, 1.0), color); + let qs = (bh * 0.6).min(bw * 1.2).max(1.0); + let qw = gridcraft_pdf::text_width(gridcraft_pdf::Font::Helvetica, qs, "?"); + pg.text(gx + (bw - qw) / 2.0, base - g.height * s + (bh + qs * 0.7) / 2.0, qs, gridcraft_pdf::Font::Helvetica, color, "?"); + } + } + } + Item::Rule(r) => { + if r.width > 0.0 && r.height > 0.0 { + pg.fill_rect(ox + r.x * s, oy + r.y * s, r.width * s, (r.height * s).max(0.25), col(r.color)); + } + } + Item::Path(p) => { + let (px, py) = (ox + p.x * s, oy + p.y * s); + let mut subs: Vec> = vec![]; + let mut cur: Vec<[f32; 2]> = vec![]; + let at = |a: f32, b: f32| [px + a * s, py + b * s]; + for c in &p.commands { + match *c { + PathCmd::MoveTo(a, b) => { + if cur.len() > 1 { + subs.push(std::mem::take(&mut cur)); + } + cur = vec![at(a, b)]; + } + PathCmd::LineTo(a, b) => cur.push(at(a, b)), + PathCmd::QuadTo(x1, y1, a, b) => { + let p0 = cur.last().copied().unwrap_or(at(x1, y1)); + let (c1, p1) = (at(x1, y1), at(a, b)); + for k in 1..=8 { + let t = k as f32 / 8.0; + let u = 1.0 - t; + cur.push([u * u * p0[0] + 2.0 * u * t * c1[0] + t * t * p1[0], u * u * p0[1] + 2.0 * u * t * c1[1] + t * t * p1[1]]); + } + } + PathCmd::CubicTo(x1, y1, x2, y2, a, b) => { + let p0 = cur.last().copied().unwrap_or(at(x1, y1)); + let (c1, c2, p1) = (at(x1, y1), at(x2, y2), at(a, b)); + for k in 1..=10 { + let t = k as f32 / 10.0; + let u = 1.0 - t; + let (a3, b3, c3, d3) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t); + cur.push([a3 * p0[0] + b3 * c1[0] + c3 * c2[0] + d3 * p1[0], a3 * p0[1] + b3 * c1[1] + c3 * c2[1] + d3 * p1[1]]); + } + } + PathCmd::Close => { + if cur.len() > 1 { + subs.push(std::mem::take(&mut cur)); + } + } + } + } + if cur.len() > 1 { + subs.push(cur); + } + let color = col(p.color); + for sub in subs.iter().filter(|v| v.len() <= 4000) { + if p.fill && sub.len() >= 3 { + // The PDF fill rule handles concave outlines itself. + pg.polygon(sub, Some(color), None); + } else { + pg.polyline(sub, (0.05 * s).clamp(0.2, 1.0), color, &[]); + } + } + } + } + } +} diff --git a/crates/engine/src/cmd/extra.rs b/crates/engine/src/cmd/extra.rs index 7242f8e1d..a6f66b41b 100644 --- a/crates/engine/src/cmd/extra.rs +++ b/crates/engine/src/cmd/extra.rs @@ -3,8 +3,8 @@ use gridcraft_core::{CellRef, RangeRef, Value}; use gridcraft_model::{ - Anchor, Chart, Color, DefinedName, HAlign, LegendPos, NumFmt, Shape, Sheet, SheetViewMode, TableColumn, TotalsFn, Trendline, TrendlineKind, - Workbook, + Anchor, Chart, Color, DefinedName, HAlign, LegendPos, NumFmt, Shape, ShapeKind, Sheet, SheetViewMode, TableColumn, TotalsFn, Trendline, + TrendlineKind, Workbook, }; use serde_json::{Value as Json, json}; @@ -97,7 +97,7 @@ pub fn specs() -> Vec { "Bring Forward", ["Page Layout", "Arrange"], None, - "{kind: chart|image|shape|slicer, id, toFront?: bool}", + "{kind: chart|image|shape|slicer|equation, id, toFront?: bool}", has_doc, |s, p| arrange(s, p, true) ), @@ -106,7 +106,7 @@ pub fn specs() -> Vec { "Send Backward", ["Page Layout", "Arrange"], None, - "{kind: chart|image|shape|slicer, id, toBack?: bool}", + "{kind: chart|image|shape|slicer|equation, id, toBack?: bool}", has_doc, |s, p| arrange(s, p, false) ), @@ -116,7 +116,7 @@ pub fn specs() -> Vec { "Align", ["Page Layout", "Arrange"], None, - "{kind: chart|image|shape|slicer, ids: [..], align: left|center|right|top|middle|bottom}", + "{kind: chart|image|shape|slicer|equation, ids: [..], align: left|center|right|top|middle|bottom}", has_doc, align ), diff --git a/crates/engine/src/cmd/extra/objects.rs b/crates/engine/src/cmd/extra/objects.rs index 03e8d07b2..fa5a5f438 100644 --- a/crates/engine/src/cmd/extra/objects.rs +++ b/crates/engine/src/cmd/extra/objects.rs @@ -1,8 +1,29 @@ +use gridcraft_model::ObjKind; + use super::chart::format_series; use super::{ - Anchor, CellRef, Color, EngineError, Json, Result, Session, Shape, Sheet, anchor_mode_name, bad, bool_param, edit, json, str_param, u32_param, + Anchor, CellRef, Color, EngineError, Json, Result, Session, Shape, ShapeKind, Sheet, anchor_mode_name, bad, bool_param, edit, json, str_param, + u32_param, }; +/// The kind of object a `kind` parameter names: `shape` when the parameter is absent; one that +/// is present but not one of the names (`null`, a number, an unknown word) is an error, as for +/// the `object.*` commands, where the kind is required. +fn kind_param(p: &Json, cmd: &str) -> Result { + let err = || bad(cmd, "kind must be chart, image, shape, slicer or equation"); + match p.get("kind") { + None => Ok(ObjKind::Shape), + Some(Json::String(k)) => ObjKind::parse(k).ok_or_else(err), + Some(_) => Err(err()), + } +} + +/// Whether `kind` names the shape `sh`: an equation is a shape too, and a plain shape is not an +/// equation, as `Sheet::object_anchor` has it for the `object.*` commands. +fn shape_is(kind: ObjKind, sh: &Shape) -> bool { + kind != ObjKind::Equation || sh.kind == ShapeKind::Equation +} + pub(super) fn shape_fill(s: &mut Session, p: &Json) -> Result { if str_param(p, "kind") == Some("shape") { let color = str_param(p, "color").and_then(Color::from_hex).unwrap_or_default(); @@ -33,7 +54,7 @@ fn with_shape(s: &mut Session, p: &Json, f: impl FnOnce(&mut Shape)) -> Result Result { let id = u32_param(p, "id").ok_or_else(|| bad("arrange", "missing `id`"))?; - let kind = str_param(p, "kind").unwrap_or("shape").to_string(); + let kind = kind_param(p, "arrange")?; let extreme = bool_param(p, "toFront").or(bool_param(p, "toBack")).unwrap_or(false); let si = s.doc()?.wb.active_sheet(); fn reorder(v: &mut Vec, i: usize, forward: bool, extreme: bool) { @@ -51,28 +72,17 @@ pub(super) fn arrange(s: &mut Session, p: &Json, forward: bool) -> Result } edit(s, |cx| { let sh = cx.sheet_mut(si)?; - match kind.as_str() { - "chart" => { - if let Some(i) = sh.charts.iter().position(|c| c.id == id) { - reorder(&mut sh.charts, i, forward, extreme); - } + // An id that is not an object of this kind is an error, so a stale id, or a shape's id + // given as an equation, never reorders something else or succeeds silently. + let found = match kind { + ObjKind::Chart => sh.charts.iter().position(|c| c.id == id).map(|i| reorder(&mut sh.charts, i, forward, extreme)), + ObjKind::Image => sh.images.iter().position(|c| c.id == id).map(|i| reorder(&mut sh.images, i, forward, extreme)), + ObjKind::Slicer => sh.slicers.iter().position(|c| c.id == id).map(|i| reorder(&mut sh.slicers, i, forward, extreme)), + ObjKind::Shape | ObjKind::Equation => { + sh.shapes.iter().position(|c| c.id == id && shape_is(kind, c)).map(|i| reorder(&mut sh.shapes, i, forward, extreme)) } - "image" => { - if let Some(i) = sh.images.iter().position(|c| c.id == id) { - reorder(&mut sh.images, i, forward, extreme); - } - } - "slicer" => { - if let Some(i) = sh.slicers.iter().position(|c| c.id == id) { - reorder(&mut sh.slicers, i, forward, extreme); - } - } - _ => { - if let Some(i) = sh.shapes.iter().position(|c| c.id == id) { - reorder(&mut sh.shapes, i, forward, extreme); - } - } - } + }; + found.ok_or_else(|| bad("arrange", "no such object"))?; Ok(Json::Null) }) } @@ -82,7 +92,8 @@ pub(super) fn selection_pane(s: &mut Session, _: &Json) -> Result { let sh = d.wb.active().ok_or(EngineError::NoDocument)?; let mut v = Vec::new(); for c in &sh.shapes { - v.push(json!({"kind": "shape", "id": c.id, "name": format!("{:?} {}", c.kind, c.id), "at": c.anchor.cell.a1(), "anchorMode": anchor_mode_name(c.anchor.mode)})); + let kind = if c.kind == ShapeKind::Equation { "equation" } else { "shape" }; + v.push(json!({"kind": kind, "id": c.id, "name": format!("{:?} {}", c.kind, c.id), "at": c.anchor.cell.a1(), "anchorMode": anchor_mode_name(c.anchor.mode)})); } for c in &sh.images { v.push(json!({"kind": "image", "id": c.id, "name": format!("Picture {}", c.id), "at": c.anchor.cell.a1(), "anchorMode": anchor_mode_name(c.anchor.mode)})); @@ -98,31 +109,43 @@ pub(super) fn selection_pane(s: &mut Session, _: &Json) -> Result { Ok(Json::Array(v)) } -fn anchors_of<'a>(sh: &'a mut Sheet, kind: &str) -> Vec<(u32, &'a mut Anchor)> { +/// The anchors of every object of `kind` on the sheet; under `equation`, only the equations. +fn anchors_of(sh: &mut Sheet, kind: ObjKind) -> Vec<(u32, &mut Anchor)> { match kind { - "chart" => sh.charts.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(), - "image" => sh.images.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(), - "slicer" => sh.slicers.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(), - _ => sh.shapes.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(), + ObjKind::Chart => sh.charts.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(), + ObjKind::Image => sh.images.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(), + ObjKind::Slicer => sh.slicers.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(), + ObjKind::Shape | ObjKind::Equation => sh.shapes.iter_mut().filter(|c| shape_is(kind, c)).map(|c| (c.id, &mut c.anchor)).collect(), } } pub(super) fn align(s: &mut Session, p: &Json) -> Result { - let kind = str_param(p, "kind").unwrap_or("shape").to_string(); - let ids: Vec = - p.get("ids").and_then(Json::as_array).map(|a| a.iter().filter_map(|v| v.as_u64().map(|x| x as u32)).collect()).unwrap_or_default(); + let kind = kind_param(p, "arrange.align")?; + // `ids` may be absent (nothing to align) but, present, must be an array whose every entry is + // an object id naming an object of the kind: a stale or mismatched id, or a value of another + // shape, is an error, as for `arrange` and the `object.*` commands, not something silently + // left out. + let bad_ids = || bad("arrange.align", "ids must be an array of object ids"); + let ids: Vec = match p.get("ids") { + None => Vec::new(), + Some(Json::Array(a)) => a.iter().map(|v| v.as_u64().and_then(|x| u32::try_from(x).ok()).ok_or_else(bad_ids)).collect::>()?, + Some(_) => return Err(bad_ids()), + }; let how = str_param(p, "align").unwrap_or("left").to_string(); let si = s.doc()?.wb.active_sheet(); edit(s, |cx| { let sheet = cx.sheet_mut(si)?; let geo: Vec<(u32, f64, f64, f32, f32)> = { let shc = sheet.clone(); - anchors_of(sheet, &kind) + anchors_of(sheet, kind) .into_iter() .filter(|(id, _)| ids.contains(id)) .map(|(id, a)| (id, shc.col_left(a.cell.col) + a.dx as f64, shc.row_top(a.cell.row) + a.dy as f64, a.width, a.height)) .collect() }; + if let Some(missing) = ids.iter().find(|id| !geo.iter().any(|g| g.0 == **id)) { + return Err(bad("arrange.align", format!("no such object: {missing}"))); + } if geo.len() < 2 { return Ok(Json::Null); } @@ -131,7 +154,7 @@ pub(super) fn align(s: &mut Session, p: &Json) -> Result { let top = geo.iter().map(|g| g.2).fold(f64::INFINITY, f64::min); let bottom = geo.iter().map(|g| g.2 + g.4 as f64).fold(f64::NEG_INFINITY, f64::max); let shc = sheet.clone(); - for (id, a) in anchors_of(sheet, &kind) { + for (id, a) in anchors_of(sheet, kind) { let Some(g) = geo.iter().find(|g| g.0 == id) else { continue }; let (mut x, mut y) = (g.1, g.2); match how.as_str() { diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index e47a05e28..1066f48e2 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -90,8 +90,14 @@ pub fn specs() -> Vec { format_cells ), // Alignment - cmd!("home.alignLeft", "Align Left", ["Home", "Alignment"], Some("Cmd+L"), "{range?}", has_doc, |s, p| halign(s, p, HAlign::Left)), - cmd!("home.alignCenter", "Center", ["Home", "Alignment"], Some("Cmd+E"), "{range?}", has_doc, |s, p| halign(s, p, HAlign::Center)), + CommandSpec { + shortcut_mac: Some("Cmd+L"), + ..cmd!("home.alignLeft", "Align Left", ["Home", "Alignment"], Some("Cmd+L"), "{range?}", has_doc, |s, p| halign(s, p, HAlign::Left)) + }, + CommandSpec { + shortcut_mac: Some("Cmd+E"), + ..cmd!("home.alignCenter", "Center", ["Home", "Alignment"], Some("Cmd+E"), "{range?}", has_doc, |s, p| halign(s, p, HAlign::Center)) + }, cmd!("home.alignRight", "Align Right", ["Home", "Alignment"], None, "{range?}", has_doc, |s, p| halign(s, p, HAlign::Right)), cmd!("home.justify", "Justify", ["Home", "Alignment"], None, "{range?}", has_doc, |s, p| halign(s, p, HAlign::Justify)), cmd!("home.alignTop", "Top Align", ["Home", "Alignment"], None, "{range?}", has_doc, |s, p| valign(s, p, VAlign::Top)), diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 77ebdf901..dbd4d9d78 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -146,13 +146,13 @@ pub fn specs() -> Vec { &with(p, "kind", json!("textBox")) )), cmd!("shape.setText", "Edit Text Box", [], None, "{id: textBox id, text: string}", has_doc, shape_set_text), - cmd!("object.delete", "Delete Object", [], None, "{kind: chart|image|shape|slicer, id}", has_doc, delete_object), + cmd!("object.delete", "Delete Object", [], None, "{kind: chart|image|shape|slicer|equation, id}", has_doc, delete_object), cmd!( "object.move", "Move Object", [], None, - "{kind: chart|image|shape|slicer, id, at?: \"C3\", dx?, dy?, width?, height? (each at most 10,000 points, or the object's current size when a file made it larger)}", + "{kind: chart|image|shape|slicer|equation, id, at?: \"C3\", dx?, dy?, width?, height? (each at most 10,000 points, or the object's current size when a file made it larger)}", has_doc, move_object ), @@ -161,7 +161,7 @@ pub fn specs() -> Vec { "Object Properties", [], None, - "{kind: chart|image|shape|slicer, id, mode: moveAndSize|moveOnly|absolute}", + "{kind: chart|image|shape|slicer|equation, id, mode: moveAndSize|moveOnly|absolute}", has_doc, set_anchor_mode ), @@ -1173,6 +1173,7 @@ fn insert_shape(s: &mut Session, p: &Json) -> Result { Color::Theme(4, -250) }); let shape = Shape { + math: Default::default(), id, kind, anchor: Anchor { @@ -1230,7 +1231,7 @@ fn shape_set_text(s: &mut Session, p: &Json) -> Result { /// objects are errors, so a stale id never succeeds silently. fn object_param(s: &Session, p: &Json, cmd: &str) -> Result<(usize, ObjRef)> { let id = u32_param(p, "id").ok_or_else(|| bad(cmd, "missing `id`"))?; - let kind = str_param(p, "kind").and_then(ObjKind::parse).ok_or_else(|| bad(cmd, "kind must be chart, image, shape or slicer"))?; + let kind = str_param(p, "kind").and_then(ObjKind::parse).ok_or_else(|| bad(cmd, "kind must be chart, image, shape, slicer or equation"))?; let d = s.doc()?; let sheet = d.wb.active_sheet(); let o = ObjRef::new(kind, id); @@ -1787,6 +1788,7 @@ fn insert_icon(s: &mut Session, p: &Json) -> Result { let at = cell_param(s, p, "at")?.unwrap_or(d.selection.active); let color = super::format::color_param(p.get("color")).unwrap_or(Color::Theme(1, 0)); let shape = Shape { + math: Default::default(), id, kind: ShapeKind::Icon, anchor: Anchor { cell: at, dx: 4.0, dy: 4.0, width: size, height: size, mode: anchor_mode_param(p).unwrap_or_default() }, diff --git a/crates/engine/src/cmd/insert/cond_format.rs b/crates/engine/src/cmd/insert/cond_format.rs index faafcfcf2..89bac49c9 100644 --- a/crates/engine/src/cmd/insert/cond_format.rs +++ b/crates/engine/src/cmd/insert/cond_format.rs @@ -333,6 +333,7 @@ pub(super) fn insert_icon(s: &mut Session, p: &Json) -> Result { line: Color::Auto, text: name, format: Default::default(), + math: Default::default(), }; let sheet = d.wb.active_sheet(); edit(s, |cx| { diff --git a/crates/engine/src/cmd/insert/objects.rs b/crates/engine/src/cmd/insert/objects.rs index 9f51daf0f..f86915a3a 100644 --- a/crates/engine/src/cmd/insert/objects.rs +++ b/crates/engine/src/cmd/insert/objects.rs @@ -153,6 +153,7 @@ pub(super) fn insert_shape(s: &mut Session, p: &Json) -> Result { line, text: str_param(p, "text").unwrap_or("").into(), format: Default::default(), + math: Default::default(), }; let sheet = d.wb.active_sheet(); edit(s, |cx| { diff --git a/crates/engine/src/cmd/print/objects.rs b/crates/engine/src/cmd/print/objects.rs index d2128f961..faa855cb9 100644 --- a/crates/engine/src/cmd/print/objects.rs +++ b/crates/engine/src/cmd/print/objects.rs @@ -29,6 +29,26 @@ pub(super) fn draw_cell_picture(pg: &mut Page, data: &[u8], rect: (f32, f32, f32 } /// Sheet pixel rectangle of an anchor. +/// Most source lines printed for an equation that has no layout. +pub(super) const MAX_FALLBACK_LINES: usize = 500; + +/// The lines of `text` printed in place of an equation that has no layout: those of its first +/// `MAX_SOURCE_BYTES` (a longer source never lays out, and a file can carry megabytes) that fit a +/// frame `h` points tall at `line_h` points per line (at least the first), and never more than +/// [`MAX_FALLBACK_LINES`]. A single `pg.text` call would print every newline as `?`. +pub(super) fn fallback_lines(text: &str, h: f32, line_h: f32) -> impl Iterator { + let text = crate::cmd::equation::source_excerpt(text); + let fit = h / line_h; + let n = if fit >= MAX_FALLBACK_LINES as f32 { + MAX_FALLBACK_LINES + } else if fit >= 1.0 { + fit as usize + } else { + 1 + }; + text.lines().take(n) +} + fn anchor_rect(sh: &Sheet, a: &Anchor) -> (f64, f64, f64, f64) { (sh.col_left(a.cell.col) + a.dx as f64, sh.row_top(a.cell.row) + a.dy as f64, a.width.max(0.0) as f64, a.height.max(0.0) as f64) } @@ -79,6 +99,25 @@ pub(super) fn draw_objects(pg: &mut Page, wb: &Workbook, si: usize, sh: &Sheet, continue; } let (x, y, w, h) = place(r); + if shp.kind == ShapeKind::Equation { + let ink = rgb_of(shp.line, wb).unwrap_or([0, 0, 0]); + match shp.math.get(&shp.text) { + Some(Ok(list)) => crate::cmd::equation::draw_pdf(pg, list, (x, y, w, h), crate::cmd::equation::PADDING * b.k, ink), + _ => { + // No layout (the source does not parse, or the file was past the layout + // budget): the source, one text run per line, as many as the frame holds. + let line_h = 12.0 * b.scale; + // A long line stays inside the frame; this clip nests in the block's. + pg.save(); + pg.clip_rect(x, y, w, h); + for (i, line) in fallback_lines(&shp.text, h, line_h).enumerate() { + pg.text(x + 2.0 * b.k, y + line_h * (i + 1) as f32, 10.0 * b.scale, Font::Courier, ink, line); + } + pg.restore(); + } + } + continue; + } draw_shape(pg, wb, shp, b, (x, y, w, h)); } for ch in &sh.charts { diff --git a/crates/engine/src/cmd/print/tests.rs b/crates/engine/src/cmd/print/tests.rs index f3f1c1f22..cd25606b9 100644 --- a/crates/engine/src/cmd/print/tests.rs +++ b/crates/engine/src/cmd/print/tests.rs @@ -329,6 +329,14 @@ fn first_and_even_pages_print_their_own_headers() { assert!(contains(&pdf, "(Odd 1) Tj") && contains(&pdf, "(Odd 2) Tj") && !contains(&pdf, "(Cover) Tj")); } +/// The y position of the first `(text) Tj` in a PDF (PDF space: larger is higher on the page). +fn text_y(pdf: &[u8], text: &str) -> Option { + let s = String::from_utf8_lossy(pdf); + let before = &s[..s.find(&format!("({text}) Tj"))?]; + let tm = before.rfind(" Tm")?; + before[..tm].rsplit([' ', '\n']).next()?.parse().ok() +} + /// The x position and font size of the first `(text) Tj` in a PDF. fn text_at(pdf: &[u8], text: &str) -> Option<(f32, f32)> { let s = String::from_utf8_lossy(pdf); @@ -741,3 +749,113 @@ fn checkbox_cells_print_as_boxes_not_true_false() { assert!(contains(&pdf, "0.063 0.486 0.255 rg"), "the checked box is not filled"); assert!(contains(&pdf, "0.431 0.431 0.431 RG"), "the unchecked box is not outlined"); } + +#[test] +fn an_equation_without_a_layout_prints_its_source_one_line_per_line() { + use super::objects::{MAX_FALLBACK_LINES, fallback_lines}; + let mut s = s(); + s.execute("insert.equation", json!({"latex": "x", "cell": "B2"})).unwrap(); + // A file can carry a source the layout engine refuses: two lines here, in a frame 100 px + // (75 pt) tall, which holds six 12 pt lines. + let with_source = |text: &str| { + let mut wb = (*s.doc().unwrap().wb).clone(); + if let Some(sh) = wb.sheet_mut(0) { + for x in sh.shapes.iter_mut() { + x.text = text.into(); + x.anchor.height = 100.0; + } + } + let mut t = Session::new(); + t.add_document(crate::DocState::new(wb, None, "Book".into(), crate::Trust::FromFile)); + pdf_of(&mut t, json!({})).0 + }; + let pdf = with_source("frac{\nsecond line"); + assert!(contains(&pdf, "(frac{) Tj"), "the first line is its own text run"); + assert!(contains(&pdf, "(second line) Tj"), "and so is the second"); + assert!(!contains(&pdf, "(frac{?second line) Tj"), "the newline is not printed as ?"); + let (y1, y2) = (text_y(&pdf, "frac{").unwrap(), text_y(&pdf, "second line").unwrap()); + assert!(y1 > y2 && y1 - y2 < 13.0, "the second line sits one line below the first: {y1} {y2}"); + // Lines the frame cannot hold are not printed. + let pdf = with_source(&"l\n".repeat(10_000)); + assert_eq!(String::from_utf8_lossy(&pdf).matches("(l) Tj").count(), 6); + // Whatever the frame, the count is capped; a frame too low for one line still shows the first. + let tall = "l\n".repeat(10_000); + assert_eq!(fallback_lines(&tall, f32::INFINITY, 12.0).count(), MAX_FALLBACK_LINES); + assert_eq!(fallback_lines(&tall, 1.0e9, 12.0).count(), MAX_FALLBACK_LINES); + assert_eq!(fallback_lines("a\nb\nc", 30.0, 12.0).count(), 2); + assert_eq!(fallback_lines("a\nb", 0.0, 12.0).count(), 1); + assert_eq!(fallback_lines("a\nb", f32::NAN, 12.0).count(), 1); +} + +#[test] +fn the_fallback_text_is_clipped_to_the_equation_frame() { + // A long line of a source that does not lay out, in a narrow frame (40 by 100 px, 30 by + // 75 pt): the lines are drawn inside a clip of the frame, nested in the block's, so they + // cannot run over neighbours, and the clip is lifted again before the next object (a text + // box below the equation in the same block) is drawn. + let mut s = s(); + s.execute("insert.equation", json!({"latex": "x", "cell": "B2"})).unwrap(); + s.execute("insert.textBox", json!({"at": "B12", "text": "later box"})).unwrap(); + let mut wb = (*s.doc().unwrap().wb).clone(); + if let Some(sh) = wb.sheet_mut(0) { + for x in sh.shapes.iter_mut().filter(|x| x.kind == gridcraft_model::ShapeKind::Equation) { + x.text = format!("frac{{{}", "l".repeat(200)); + x.anchor.width = 40.0; + x.anchor.height = 100.0; + } + } + let mut t = Session::new(); + t.add_document(crate::DocState::new(wb, None, "Book".into(), crate::Trust::FromFile)); + let (pdf, _) = pdf_of(&mut t, json!({})); + let text = String::from_utf8_lossy(&pdf); + let clip = text.find("30 75 re W n").expect("a clip the size of the frame"); + let run = text.find("(frac{llll").expect("the source line"); + let later = text.find("(later box) Tj").expect("the text box after the equation"); + assert!(clip < run && run < later, "clip, then the text, then the later object"); + // The net number of saved graphics states (`q` less `Q`) before an operator. + let depth = |at: usize| text[..at].lines().filter(|l| *l == "q").count() as i64 - text[..at].lines().filter(|l| *l == "Q").count() as i64; + assert_eq!(depth(run), depth(clip), "the text is drawn under the frame's clip"); + assert_eq!(depth(later), depth(clip) - 1, "the later object is drawn with the frame's state restored"); +} + +#[test] +#[allow(clippy::disallowed_methods)] // tests run only off wasm, where the clock exists +fn an_equation_source_of_a_megabyte_prints_a_bounded_excerpt() { + // A file can carry a source far past `MAX_SOURCE_BYTES` (the drawing part may be 512 MiB). It + // never lays out, so the fallback prints the first 4,096 bytes and does no work on the rest: + // the PDF holds one run of that length, not the megabyte, and export takes bounded time. + let mut s = s(); + s.execute("insert.equation", json!({"latex": "x", "cell": "B2"})).unwrap(); + let mut wb = (*s.doc().unwrap().wb).clone(); + if let Some(sh) = wb.sheet_mut(0) { + for x in sh.shapes.iter_mut() { + x.text = "l".repeat(1 << 20); + } + } + let mut t = Session::new(); + t.add_document(crate::DocState::new(wb, None, "Book".into(), crate::Trust::FromFile)); + let start = std::time::Instant::now(); + let (pdf, _) = pdf_of(&mut t, json!({})); + let took = start.elapsed(); + let text = String::from_utf8_lossy(&pdf); + let longest = text.split(|c| c != 'l').map(str::len).max().unwrap_or(0); + assert_eq!(longest, gridcraft_math::MAX_SOURCE_BYTES, "the run is the excerpt and no more"); + assert!(pdf.len() < 64 * 1024, "the PDF holds the excerpt, not the megabyte: {} bytes", pdf.len()); + assert!(took < std::time::Duration::from_secs(5), "{took:?}"); +} + +#[test] +fn equations_print_with_the_symbol_font() { + let mut s = s(); + s.execute("cell.set", json!({"cell": "A1", "input": "x"})).unwrap(); + s.execute("insert.equation", json!({"latex": "\\sum_{i=1}^{n} \\alpha_i \\le \\infty \\pm \\sqrt{x} \\times \\partial y", "cell": "B2"})) + .unwrap(); + s.execute("insert.equation", json!({"latex": "\\mathbb{R} \\oplus \\text{\u{4E2D}}", "cell": "B8"})).unwrap(); + let (pdf, _) = pdf_of(&mut s, json!({})); + assert!(contains(&pdf, "/BaseFont /Symbol >>"), "maths symbols use the Symbol font"); + assert!(contains(&pdf, "/BaseFont /Times-Italic"), "variables are italic"); + // The summation (0xE5) and alpha (0x61) codes in Symbol. + assert!(contains(&pdf, "(\\345) Tj") && contains(&pdf, "(a) Tj")); + // A glyph no standard font has is a [?] box. + assert!(contains(&pdf, "(?) Tj")); +} diff --git a/crates/engine/src/cmd/review.rs b/crates/engine/src/cmd/review.rs index d1aea3cc7..de9c98ecd 100644 --- a/crates/engine/src/cmd/review.rs +++ b/crates/engine/src/cmd/review.rs @@ -35,6 +35,7 @@ pub fn specs() -> Vec { label: "Allow Edit Ranges…", menu: &["Review", "Protect"], shortcut: None, + shortcut_mac: None, params: "{action?: list (default) | add | modify | delete | unlock, title?, range?, password?, newTitle?, sheet?} → ranges", enabled: has_doc, run: allow_edit_ranges, diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 4ea043a68..18f941c0d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -39,6 +39,7 @@ pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command, register_te pub use gridcraft_calc as calc; pub use gridcraft_core as core; pub use gridcraft_formula as formula; +pub use gridcraft_math as math; pub use gridcraft_model as model; pub use gridcraft_numfmt as numfmt; pub use selection::Selection; @@ -304,7 +305,9 @@ impl DocState { calc.rebuild(&wb); Self::with_calc(wb, calc, path, title, trust) } - fn with_calc(wb: Workbook, calc: Calc, path: Option, title: String, trust: Trust) -> DocState { + fn with_calc(mut wb: Workbook, calc: Calc, path: Option, title: String, trust: Trust) -> DocState { + // Equations arrive without a layout (files never store one); painting reads only the cache. + wb.refresh_equations(); let scripts_trusted = !cmd::extra::has_scripts(&wb); let wb = Arc::new(wb); let sel = Selection::at(wb.active().map(|s| s.view_active).unwrap_or_default()); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 38aa12e53..22e71330a 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -5243,6 +5243,7 @@ fn a_script_cannot_switch_windows() { assert_eq!(v(&s, "A1"), Value::number(2.0)); } +mod equation; mod macros; mod objects; mod python_cells; diff --git a/crates/engine/src/tests/equation.rs b/crates/engine/src/tests/equation.rs new file mode 100644 index 000000000..6b544b2ac --- /dev/null +++ b/crates/engine/src/tests/equation.rs @@ -0,0 +1,227 @@ +//! Equations: insert, edit, the layout cache and its rebuild on load. + +use gridcraft_model::{Color, Shape, ShapeKind}; +use serde_json::json; + +use super::s; +use crate::Session; + +fn equation(s: &Session, id: u32) -> Shape { + s.doc().unwrap().wb.active().unwrap().shapes.iter().find(|x| x.id == id).cloned().unwrap() +} + +fn insert(s: &mut Session, p: serde_json::Value) -> u32 { + s.execute("insert.equation", p).unwrap()["equation"].as_u64().unwrap() as u32 +} + +#[test] +fn insert_lays_the_equation_out_and_sizes_its_frame() { + let mut s = s(); + let id = insert(&mut s, json!({"latex": "x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}", "cell": "C3"})); + let e = equation(&s, id); + assert_eq!(e.kind, ShapeKind::Equation); + assert_eq!(e.anchor.cell.a1(), "C3"); + assert_eq!(e.line, Color::Auto, "Automatic ink"); + let list = e.math.get(&e.text).unwrap().unwrap(); + let scale = crate::cmd::equation::fit_scale(list, e.anchor.width, e.anchor.height); + assert!((scale - 11.0 * 96.0 / 72.0).abs() < 0.01, "11 pt: {scale}"); + // A bigger size makes a bigger frame; `at` works like `cell`. + let big = insert(&mut s, json!({"latex": "x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}", "at": "H3", "size": 22, "color": "#1F4E79"})); + let b = equation(&s, big); + assert!((b.anchor.width - 8.0) > 1.9 * (e.anchor.width - 8.0)); + assert_eq!(b.line, Color::Rgb(0x1F4E79)); + assert_eq!(b.anchor.cell.a1(), "H3"); + // One undo step each. + s.execute("edit.undo", json!({})).unwrap(); + assert!(s.doc().unwrap().wb.active().unwrap().shapes.iter().all(|x| x.id != big)); +} + +#[test] +fn insert_refuses_missing_bad_or_hostile_source() { + let mut s = s(); + for p in [ + json!({}), + json!({"latex": ""}), + json!({"latex": " "}), + json!({"latex": 5}), + json!({"latex": "x", "size": 0}), + json!({"latex": "x", "size": f64::MAX}), + json!({"latex": "x", "color": "nope"}), + ] { + assert!(s.execute("insert.equation", p.clone()).is_err(), "{p}"); + } + let e = s.execute("insert.equation", json!({"latex": "a + \\foo"})).unwrap_err().to_string(); + assert!(e.contains("character 5") && e.contains("\\foo"), "{e}"); + let e = s.execute("insert.equation", json!({"latex": "x".repeat(5000)})).unwrap_err().to_string(); + assert!(e.contains("4096"), "{e}"); + let e = s.execute("insert.equation", json!({"latex": format!("{}x{}", "{".repeat(40), "}".repeat(40))})).unwrap_err().to_string(); + assert!(e.contains("nest"), "{e}"); + assert!(s.doc().unwrap().wb.active().unwrap().shapes.is_empty()); + // `equation.edit` is the other way a source reaches the layout engine; it has the same caps, + // and a refused edit leaves the equation as it was. + let id = insert(&mut s, json!({"latex": "x"})); + let e = s.execute("equation.edit", json!({"id": id, "latex": "x".repeat(5000)})).unwrap_err().to_string(); + assert!(e.contains("4096"), "{e}"); + let e = s.execute("equation.edit", json!({"id": id, "latex": format!("{}x{}", "{".repeat(40), "}".repeat(40))})).unwrap_err().to_string(); + assert!(e.contains("nest"), "{e}"); + assert_eq!(equation(&s, id).text, "x"); +} + +#[test] +fn edit_keeps_the_scale_and_fits_the_frame() { + let mut s = s(); + let id = insert(&mut s, json!({"latex": "x", "size": 20})); + let before = equation(&s, id); + s.execute("equation.edit", json!({"id": id, "latex": "x + y + z"})).unwrap(); + let after = equation(&s, id); + assert_eq!(after.text, "x + y + z"); + let list = after.math.get(&after.text).unwrap().unwrap(); + let scale = crate::cmd::equation::fit_scale(list, after.anchor.width, after.anchor.height); + assert!((scale - 20.0 * 96.0 / 72.0).abs() < 0.05, "{scale}"); + assert!(after.anchor.width > before.anchor.width); + // A frame the user resized keeps its new scale through an edit. + s.execute("object.move", json!({"kind": "equation", "id": id, "width": after.anchor.width * 2.0, "height": after.anchor.height * 2.0})).unwrap(); + s.execute("equation.edit", json!({"id": id, "latex": "x + y"})).unwrap(); + let e = equation(&s, id); + let scale = crate::cmd::equation::fit_scale(e.math.get(&e.text).unwrap().unwrap(), e.anchor.width, e.anchor.height); + assert!(scale > 2.0 * 20.0 * 96.0 / 72.0 * 0.95, "{scale}"); + // Undo restores the source and its layout. + s.execute("edit.undo", json!({})).unwrap(); + let e = equation(&s, id); + assert_eq!(e.text, "x + y + z"); + assert!(e.math.get(&e.text).unwrap().is_ok()); + // Same source: nothing to do. Bad source: refused, nothing changes. + let depth = s.doc().unwrap().undo.len(); + s.execute("equation.edit", json!({"id": id, "latex": "x + y + z"})).unwrap(); + assert_eq!(s.doc().unwrap().undo.len(), depth); + assert!(s.execute("equation.edit", json!({"id": id, "latex": "\\frac{"})).is_err()); + assert!(s.execute("equation.edit", json!({"id": id, "latex": ""})).is_err()); + assert_eq!(equation(&s, id).text, "x + y + z"); +} + +#[test] +fn the_source_excerpt_is_at_most_the_cap_and_whole_chars() { + use crate::cmd::equation::source_excerpt; + use gridcraft_math::MAX_SOURCE_BYTES as CAP; + assert_eq!(source_excerpt(""), ""); + assert_eq!(source_excerpt("x+y"), "x+y"); + let at = "x".repeat(CAP); + assert_eq!(source_excerpt(&at), at, "a source at the cap is whole"); + assert_eq!(source_excerpt(&format!("{at}tail")), at); + // A three-byte character straddling the cap is left out rather than cut in two. + let s = format!("{}\u{20AC}", "x".repeat(CAP - 1)); + assert_eq!(source_excerpt(&s), "x".repeat(CAP - 1)); + let s = format!("{}\u{20AC}\u{20AC}", "x".repeat(CAP - 2)); + assert_eq!(source_excerpt(&s).len(), CAP - 2); + assert_eq!(source_excerpt(&"\u{1F600}".repeat(2000)).len(), CAP); +} + +#[test] +fn edit_refuses_what_is_not_an_equation() { + let mut s = s(); + let tb = s.execute("insert.textBox", json!({"text": "hi"})).unwrap()["shape"].as_u64().unwrap(); + assert!(s.execute("equation.edit", json!({"id": tb, "latex": "x"})).is_err()); + assert!(s.execute("equation.edit", json!({"id": 999, "latex": "x"})).is_err()); + assert!(s.execute("equation.edit", json!({"latex": "x"})).is_err()); + assert!(s.execute("equation.edit", json!({"id": -1, "latex": "x"})).is_err()); +} + +#[test] +fn layouts_are_rebuilt_on_load_and_not_saved() { + let mut s = s(); + let id = insert(&mut s, json!({"latex": "e^{i\\pi} + 1 = 0"})); + let wb = s.doc().unwrap().wb.clone(); + let bytes = serde_json::to_vec(&*wb).unwrap(); + assert!(!String::from_utf8_lossy(&bytes).contains("\"math\""), "the cache is not saved"); + let (opened, _) = crate::io::open_bytes("book.json", &bytes).unwrap(); + let raw = opened.active().unwrap().shapes.iter().find(|x| x.id == id).cloned().unwrap(); + assert!(raw.math.get(&raw.text).is_none(), "a freshly read shape has no layout"); + let mut t = Session::new(); + t.add_document(crate::DocState::new(opened, None, "Book".into(), crate::Trust::FromFile)); + let e = equation(&t, id); + assert!(e.math.get(&e.text).unwrap().is_ok(), "opening lays it out"); +} + +#[test] +fn a_bad_source_from_a_file_keeps_its_error() { + let mut s = s(); + let id = insert(&mut s, json!({"latex": "x"})); + let mut wb = (*s.doc().unwrap().wb).clone(); + if let Some(sh) = wb.sheet_mut(0) { + sh.shapes.iter_mut().for_each(|x| x.text = "\\frac{".into()); + } + let mut t = Session::new(); + t.add_document(crate::DocState::new(wb, None, "Book".into(), crate::Trust::FromFile)); + let e = equation(&t, id); + assert!(e.math.get(&e.text).unwrap().is_err()); +} + +#[test] +fn equations_on_a_protected_sheet_are_locked() { + let mut s = s(); + let id = insert(&mut s, json!({"latex": "x"})); + s.execute("review.protectSheet", json!({})).unwrap(); + assert!(s.execute("equation.edit", json!({"id": id, "latex": "y"})).is_err()); + assert!(s.execute("insert.equation", json!({"latex": "y"})).is_err()); +} + +#[test] +fn a_file_with_too_many_equations_lays_out_a_bounded_amount() { + // About 1 KB each: past the budget, equations are refused without running the layout engine, + // so opening the file takes bounded time. + let source = format!("x{}", "+x".repeat(500)); + let count = gridcraft_model::MAX_EQUATION_LAYOUT_BYTES / source.len() + 40; + let mut s = s(); + let first = insert(&mut s, json!({"latex": "x", "cell": "A1"})); + let mut wb = (*s.doc().unwrap().wb).clone(); + if let Some(sh) = wb.sheet_mut(0) { + let template = sh.shapes.iter().find(|x| x.id == first).cloned().unwrap(); + sh.shapes.clear(); + for i in 0..count { + let mut e = template.clone(); + e.id = 100 + i as u32; + e.text = source.clone(); + e.math = Default::default(); + sh.shapes.push(e); + } + } + let mut t = Session::new(); + t.add_document(crate::DocState::new(wb, None, "Book".into(), crate::Trust::FromFile)); + let shapes = t.doc().unwrap().wb.active().unwrap().shapes.clone(); + let laid = shapes.iter().filter(|x| matches!(x.math.get(&x.text), Some(Ok(_)))).count(); + let refused: Vec<&Shape> = + shapes.iter().filter(|x| matches!(x.math.get(&x.text), Some(Err(e)) if e.message == gridcraft_model::EQUATION_BUDGET_MESSAGE)).collect(); + assert_eq!(laid + refused.len(), count, "every equation has a layout or the budget's error"); + assert!(laid * source.len() <= gridcraft_model::MAX_EQUATION_LAYOUT_BYTES, "{laid} laid out"); + assert!(refused.len() >= 40, "{} refused", refused.len()); + // A later edit does not retry them all, and OK on one, unchanged, lays it out. + let id = refused[0].id; + t.execute("cell.set", json!({"cell": "A1", "input": "1"})).unwrap(); + assert!(matches!(equation(&t, id).math.get(&source), Some(Err(_)))); + t.execute("equation.edit", json!({"id": id, "latex": source})).unwrap(); + assert!(matches!(equation(&t, id).math.get(&source), Some(Ok(_))), "editing lays it out"); +} + +#[test] +fn ids_stay_unique_when_a_file_used_the_largest_id() { + // A workbook (from a JSON file, say) whose shape has id u32::MAX: new equations and the + // objects of a copied sheet still get ids no other object has. + let mut s = s(); + let first = insert(&mut s, json!({"latex": "x", "cell": "A1"})); + let mut wb = (*s.doc().unwrap().wb).clone(); + if let Some(sh) = wb.sheet_mut(0) { + sh.shapes.iter_mut().filter(|x| x.id == first).for_each(|x| x.id = u32::MAX); + } + let mut t = Session::new(); + t.add_document(crate::DocState::new(wb, None, "Book".into(), crate::Trust::FromFile)); + let a = insert(&mut t, json!({"latex": "y", "cell": "C1"})); + let b = insert(&mut t, json!({"latex": "z", "cell": "E1"})); + t.execute("sheet.move", json!({"sheet": 0, "to": 1, "copy": true})).unwrap(); + let wb = &t.doc().unwrap().wb; + let mut ids: Vec = wb.sheets.iter().flat_map(|sh| sh.shapes.iter().map(|x| x.id)).collect(); + assert_eq!(ids.len(), 6, "three equations on each sheet"); + assert!(a != u32::MAX && b != u32::MAX && a != b, "{a} {b}"); + ids.sort_unstable(); + ids.dedup(); + assert_eq!(ids.len(), 6, "every id is distinct: {ids:?}"); +} diff --git a/crates/engine/src/tests/objects.rs b/crates/engine/src/tests/objects.rs index c10844fb8..2274f0413 100644 --- a/crates/engine/src/tests/objects.rs +++ b/crates/engine/src/tests/objects.rs @@ -19,9 +19,9 @@ fn id_of(v: &serde_json::Value, key: &str) -> u32 { v[key].as_u64().unwrap() as u32 } -/// A table with one object of each kind on the first sheet: a chart, a picture, a shape and a -/// slicer, in `ObjKind::ALL` order. -fn every_kind(s: &mut Session) -> [ObjRef; 4] { +/// A table with one object of each kind on the first sheet: a chart, a picture, a shape, a +/// slicer and an equation, in `ObjKind::ALL` order. +fn every_kind(s: &mut Session) -> [ObjRef; 5] { s.execute("range.setValues", json!({"range": "A1", "values": [["Region", "Qty"], ["East", 3], ["West", 5], ["East", 2], ["North", 8]]})).unwrap(); s.execute("insert.table", json!({"range": "A1:B5", "name": "Sales"})).unwrap(); let chart = id_of(&s.execute("insert.chart", json!({"range": "B1:B5", "type": "column", "at": "D2"})).unwrap(), "chart"); @@ -29,7 +29,14 @@ fn every_kind(s: &mut Session) -> [ObjRef; 4] { let shape = id_of(&s.execute("insert.shape", json!({"kind": "ellipse", "at": "H18"})).unwrap(), "shape"); s.execute("selection.set", json!({"cell": "A2"})).unwrap(); let slicer = id_of(&s.execute("insert.slicer", json!({"column": "Region", "at": "L2"})).unwrap(), "slicer"); - [ObjRef::new(ObjKind::Chart, chart), ObjRef::new(ObjKind::Image, image), ObjRef::new(ObjKind::Shape, shape), ObjRef::new(ObjKind::Slicer, slicer)] + let equation = id_of(&s.execute("insert.equation", json!({"latex": "x^2", "cell": "L18"})).unwrap(), "equation"); + [ + ObjRef::new(ObjKind::Chart, chart), + ObjRef::new(ObjKind::Image, image), + ObjRef::new(ObjKind::Shape, shape), + ObjRef::new(ObjKind::Slicer, slicer), + ObjRef::new(ObjKind::Equation, equation), + ] } #[test] @@ -45,6 +52,9 @@ fn every_kind_is_found_moved_and_deleted_by_kind_and_id() { s.execute("object.setAnchorMode", json!({"kind": o.kind.name(), "id": o.id, "mode": "absolute"})).unwrap(); assert_eq!(sheet(&s, 0).object_anchor(o).unwrap().mode, AnchorMode::Absolute); } + // An equation is a shape too; a plain shape is not an equation. + assert!(sheet(&s, 0).has_object(ObjRef::new(ObjKind::Shape, objs[4].id))); + assert!(s.execute("object.delete", json!({"kind": "equation", "id": objs[2].id})).is_err()); // A kind that does not match the id, an unknown id or kind, and a missing kind are refused. assert!(s.execute("object.delete", json!({"kind": "slicer", "id": objs[0].id})).is_err()); assert!(s.execute("object.delete", json!({"kind": "chart", "id": 9999})).is_err()); @@ -58,9 +68,9 @@ fn every_kind_is_found_moved_and_deleted_by_kind_and_id() { assert!(sheet(&s, 0).has_object(*later), "deleting {o:?} leaves {later:?}"); } } - // One undo step per delete brings the slicer back. + // One undo step per delete brings the equation back. s.execute("edit.undo", json!({})).unwrap(); - assert!(sheet(&s, 0).has_object(objs[3])); + assert!(sheet(&s, 0).has_object(objs[4])); } #[test] @@ -112,17 +122,17 @@ fn cell_of(s: &Session, o: ObjRef) -> String { fn every_kind_follows_row_and_column_edits_and_sorts() { let mut s = s(); let objs = every_kind(&mut s); - // Anchors: chart D2, picture D18, shape H18, slicer L2. + // Anchors: chart D2, picture D18, shape H18, slicer L2, equation L18. s.execute("object.setAnchorMode", json!({"kind": "shape", "id": objs[2].id, "mode": "absolute"})).unwrap(); let cells = |s: &Session| objs.map(|o| cell_of(s, o)); - assert_eq!(cells(&s), ["D2", "D18", "H18", "L2"]); + assert_eq!(cells(&s), ["D2", "D18", "H18", "L2", "L18"]); s.execute("home.insertRows", json!({"rows": "10:11"})).unwrap(); - assert_eq!(cells(&s), ["D2", "D20", "H18", "L2"], "rows inserted above move the objects below them; an absolute shape stays"); + assert_eq!(cells(&s), ["D2", "D20", "H18", "L2", "L20"], "rows inserted above move the objects below them; an absolute shape stays"); s.execute("home.insertColumns", json!({"cols": "C:C"})).unwrap(); - assert_eq!(cells(&s), ["E2", "E20", "H18", "M2"]); + assert_eq!(cells(&s), ["E2", "E20", "H18", "M2", "M20"]); s.execute("home.deleteColumns", json!({"cols": "C:C"})).unwrap(); s.execute("home.deleteRows", json!({"rows": "10:11"})).unwrap(); - assert_eq!(cells(&s), ["D2", "D18", "H18", "L2"]); + assert_eq!(cells(&s), ["D2", "D18", "H18", "L2", "L18"]); // Sorting moves a picture anchored inside the sorted block with its row. s.execute("range.setValues", json!({"range": "T1", "values": [["k"], [3], [1], [2]]})).unwrap(); let pic = ObjRef::new(ObjKind::Image, super::add_image(&mut s, "T2", AnchorMode::MoveAndSize)); @@ -259,9 +269,11 @@ fn a_copied_sheet_gives_its_objects_fresh_ids() { ids.dedup(); assert_eq!(ids.len(), 2 * objs.len(), "every object id stays unique across the workbook"); let copy = sheet(&s, 1); - for (o, id) in objs.iter().zip(ids_of(copy)) { - assert_eq!(copy.object_ref(id).map(|c| c.kind), Some(o.kind)); - } + let mut kinds: Vec<&str> = ids_of(copy).into_iter().filter_map(|id| copy.object_ref(id)).map(|o| o.kind.name()).collect(); + let mut want: Vec<&str> = objs.iter().map(|o| o.kind.name()).collect(); + kinds.sort_unstable(); + want.sort_unstable(); + assert_eq!(kinds, want, "the copies keep their kinds"); assert!(s.doc().unwrap().wb.next_object_id() > *ids.last().unwrap_or(&0)); } @@ -359,3 +371,106 @@ fn chart_set_keeps_a_chart_size_a_file_made_larger_than_the_limit() { s.execute("chart.set", json!({"chart": chart, "width": 50_000, "height": 50_000})).unwrap(); assert_eq!(size(&s), (11_990.0, 10_000.0), "hostile sizes are capped at the current width and at 10,000"); } + +#[test] +fn arrange_and_align_match_a_shape_id_to_its_kind() { + // `arrange.*` and `arrange.align` under `kind: "equation"` act on equations only: an + // ordinary shape's id given as an equation is refused by `arrange` and left in place by + // `align`, as `object.delete` refuses it. An equation is a shape too, so its id works under + // `shape`, as for the other object commands; an unknown kind, and an id of no object of the + // kind, are refused instead of acting on whatever shape has the id. + let mut s = s(); + let objs = every_kind(&mut s); + let (shape, equation) = (objs[2], objs[4]); + let order = |s: &Session| -> Vec { sheet(s, 0).shapes.iter().map(|c| c.id).collect() }; + assert_eq!(order(&s), vec![shape.id, equation.id]); + assert!(s.execute("arrange.bringForward", json!({"kind": "equation", "id": shape.id, "toFront": true})).is_err()); + assert_eq!(order(&s), vec![shape.id, equation.id], "the shape is not reordered as an equation"); + s.execute("arrange.sendBackward", json!({"kind": "equation", "id": equation.id, "toBack": true})).unwrap(); + assert_eq!(order(&s), vec![equation.id, shape.id]); + s.execute("arrange.bringForward", json!({"kind": "shape", "id": equation.id, "toFront": true})).unwrap(); + assert_eq!(order(&s), vec![shape.id, equation.id], "an equation is a shape too"); + assert!(s.execute("arrange.bringForward", json!({"kind": "widget", "id": shape.id})).is_err()); + assert!(s.execute("arrange.bringForward", json!({"kind": "chart", "id": 9999})).is_err()); + assert!(s.execute("arrange.bringForward", json!({"kind": "shape", "id": objs[0].id})).is_err(), "a chart's id is not a shape"); + // Align: the shape's id given as an equation is refused and nothing moves; the two equations + // line up on their own. + let second = ObjRef::new(ObjKind::Equation, id_of(&s.execute("insert.equation", json!({"latex": "y", "cell": "B30"})).unwrap(), "equation")); + let before = (anchor_of(&s, shape), anchor_of(&s, equation)); + assert!(s.execute("arrange.align", json!({"kind": "equation", "ids": [shape.id, equation.id, second.id], "align": "left"})).is_err()); + assert_eq!((anchor_of(&s, shape), anchor_of(&s, equation)), before, "nothing is aligned with a refused id"); + s.execute("arrange.align", json!({"kind": "equation", "ids": [equation.id, second.id], "align": "left"})).unwrap(); + let (a, b) = (sheet(&s, 0).object_anchor(equation).unwrap(), sheet(&s, 0).object_anchor(second).unwrap()); + assert_eq!((a.cell.col, a.dx), (b.cell.col, b.dx), "the equations line up"); + assert!(s.execute("arrange.align", json!({"kind": "widget", "ids": [shape.id, equation.id], "align": "left"})).is_err()); +} + +#[test] +fn arrange_refuses_a_kind_that_is_present_but_not_a_name() { + // `kind` is `shape` only when the parameter is absent: `null`, a number or another non-name + // is refused, so a shape's id is not reordered or aligned under it. The `object.*` commands + // refuse the same values, their kind being required. + let mut s = s(); + let objs = every_kind(&mut s); + let (shape, equation) = (objs[2], objs[4]); + let order = |s: &Session| -> Vec { sheet(s, 0).shapes.iter().map(|c| c.id).collect() }; + let before = (order(&s), anchor_of(&s, shape)); + for kind in [json!(null), json!(7), json!(["shape"]), json!({"kind": "shape"}), json!(true)] { + assert!(s.execute("arrange.bringForward", json!({"kind": kind, "id": shape.id, "toFront": true})).is_err(), "{kind}"); + assert!(s.execute("arrange.align", json!({"kind": kind, "ids": [shape.id, equation.id], "align": "left"})).is_err(), "{kind}"); + assert!(s.execute("object.move", json!({"kind": kind, "id": shape.id, "at": "A1"})).is_err(), "{kind}"); + } + assert_eq!((order(&s), anchor_of(&s, shape)), before, "nothing moved"); + // Absent, the kind is `shape`, as before. + s.execute("arrange.bringForward", json!({"id": shape.id, "toFront": true})).unwrap(); + assert_eq!(order(&s).last(), Some(&shape.id)); +} + +#[test] +fn align_refuses_an_id_that_is_not_an_object_of_the_kind() { + // `arrange.align` and `arrange.group` refuse an id in `ids` that is not an object of the + // kind, or not a number, as `arrange` and the `object.*` commands refuse it, instead of + // aligning the rest and reporting success; nothing moves. With fewer than two ids they + // change nothing and succeed. + let mut s = s(); + let objs = every_kind(&mut s); + let (shape, equation) = (objs[2], objs[4]); + let second = ObjRef::new(ObjKind::Equation, id_of(&s.execute("insert.equation", json!({"latex": "y", "cell": "B30"})).unwrap(), "equation")); + let before = (anchor_of(&s, shape), anchor_of(&s, equation), anchor_of(&s, second)); + for ids in [ + json!([shape.id, equation.id, second.id]), + json!([equation.id, second.id, 9999]), + json!([equation.id, second.id, -1]), + json!([equation.id, second.id, "x"]), + json!([equation.id, second.id, 1e12]), + json!([equation.id, second.id, null]), + ] { + assert!(s.execute("arrange.align", json!({"kind": "equation", "ids": ids, "align": "left"})).is_err(), "{ids}"); + assert!(s.execute("arrange.group", json!({"kind": "equation", "ids": ids})).is_err(), "{ids}"); + } + assert_eq!((anchor_of(&s, shape), anchor_of(&s, equation), anchor_of(&s, second)), before, "nothing moved"); + s.execute("arrange.align", json!({"kind": "equation", "ids": [equation.id], "align": "left"})).unwrap(); + s.execute("arrange.align", json!({"kind": "equation", "align": "left"})).unwrap(); + assert_eq!(anchor_of(&s, equation), before.1, "fewer than two ids change nothing"); + s.execute("arrange.align", json!({"kind": "equation", "ids": [equation.id, second.id], "align": "left"})).unwrap(); + let (a, b) = (sheet(&s, 0).object_anchor(equation).unwrap(), sheet(&s, 0).object_anchor(second).unwrap()); + assert_eq!((a.cell.col, a.dx), (b.cell.col, b.dx), "the equations line up"); +} + +#[test] +fn align_refuses_ids_that_are_not_an_array() { + // `ids` may be absent (nothing to align) but, present, must be an array of object ids: `null`, + // a number, a string or an object is refused by `arrange.align` and `arrange.group`, and + // nothing moves. + let mut s = s(); + let objs = every_kind(&mut s); + let (shape, equation) = (objs[2], objs[4]); + let before = (anchor_of(&s, shape), anchor_of(&s, equation)); + for ids in [json!(null), json!(7), json!("x"), json!({"ids": [1]})] { + assert!(s.execute("arrange.align", json!({"kind": "shape", "ids": ids, "align": "left"})).is_err(), "{ids}"); + assert!(s.execute("arrange.group", json!({"kind": "shape", "ids": ids})).is_err(), "{ids}"); + } + s.execute("arrange.align", json!({"kind": "shape", "align": "left"})).unwrap(); + s.execute("arrange.group", json!({"kind": "shape"})).unwrap(); + assert_eq!((anchor_of(&s, shape), anchor_of(&s, equation)), before, "nothing moved"); +} diff --git a/crates/math/Cargo.toml b/crates/math/Cargo.toml new file mode 100644 index 000000000..a0bd3a10e --- /dev/null +++ b/crates/math/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "gridcraft-math" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "GridCraft equations: LaTeX laid out as plain glyph, rule and path data, and OMML for xlsx" + +[lints] +workspace = true + +[dependencies] +ratex-parser = { workspace = true } +ratex-layout = { workspace = true } +ratex-types = { workspace = true } +ratex-font = { workspace = true } diff --git a/crates/math/src/lib.rs b/crates/math/src/lib.rs new file mode 100644 index 000000000..5e1e99a41 --- /dev/null +++ b/crates/math/src/lib.rs @@ -0,0 +1,533 @@ +//! GridCraft equations. LaTeX source is laid out by RaTeX (`ratex-parser`, `ratex-layout`; MIT) +//! into a [`MathList`]: plain glyph, rule and path data that the UI and the PDF writer paint +//! without calling RaTeX again. +//! +//! RaTeX is young (0.1.x), so it only ever runs inside this crate's entry points ([`layout`], +//! [`to_omml`], [`check`]). Each one caps its input (4 KB, nesting 32) and runs under +//! `catch_unwind`, so a stray panic becomes a [`MathError`]. See the RaTeX gate in +//! `docs/decisions.md` and `tests/gate.rs`. +//! +//! Units are em: one em is the equation's font size. Coordinates run right and down from the +//! top-left of the list's box; the baseline is at `y = height`. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#![forbid(unsafe_code)] + +mod omml; + +use std::ops::Range; +use std::panic::{AssertUnwindSafe, catch_unwind}; + +use ratex_font::FontId; +use ratex_types::color::Color; +use ratex_types::display_item::{DisplayItem, DisplayList}; +use ratex_types::path_command::PathCommand; + +pub use omml::to_omml; + +/// Longest source accepted, in bytes. +pub const MAX_SOURCE_BYTES: usize = 4096; +/// Deepest nesting of `{…}`, `\left…\right` and `\begin…\end` accepted. +pub const MAX_DEPTH: usize = 32; +/// Largest coordinate or size accepted in a layout, in em. `\rule{1e308em}{1em}` lays out; this +/// stops it from reaching a painter. +pub const MAX_EM: f32 = 10_000.0; +/// Most items one equation may produce. +pub const MAX_ITEMS: usize = 50_000; + +/// The error for a source over [`MAX_SOURCE_BYTES`]. It is one shared value so that a cache can +/// answer for such a source by its length alone, without reading it. +pub static SOURCE_TOO_LONG: std::sync::LazyLock = + std::sync::LazyLock::new(|| MathError::new(format!("An equation can be at most {MAX_SOURCE_BYTES} bytes long."), None)); + +/// Why an equation could not be laid out. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct MathError { + /// The offending source, as a byte range on char boundaries, when known. + pub span: Option>, + pub message: String, +} + +impl MathError { + fn new(message: impl Into, span: Option>) -> MathError { + MathError { span, message: message.into() } + } + + /// The 1-based character position of the error in `source`, when known. + pub fn char_pos(&self, source: &str) -> Option { + let start = self.span.as_ref()?.start; + Some(source.char_indices().take_while(|(i, _)| *i < start).count() + 1) + } +} + +impl std::fmt::Display for MathError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.message) + } +} + +impl std::error::Error for MathError {} + +/// A laid-out equation. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct MathList { + pub items: Vec, + /// Advance width, in em. + pub width: f32, + /// Height above the baseline, in em. + pub height: f32, + /// Depth below the baseline, in em. + pub depth: f32, +} + +impl MathList { + /// Total height (height plus depth), in em. + pub fn total_height(&self) -> f32 { + self.height + self.depth + } +} + +#[derive(Clone, Debug, PartialEq)] +pub enum Item { + Glyph(Glyph), + Rule(Rule), + Path(Path), +} + +/// The typeface a glyph asks for. Painters map it onto the fonts they have. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub enum Face { + #[default] + Roman, + Italic, + Bold, + BoldItalic, + Sans, + SansBold, + SansItalic, + Mono, + /// A large operator or delimiter (TeX's Size1 to Size4 fonts): drawn stretched to the glyph's + /// box rather than at the run's font size. + Large(u8), +} + +impl Face { + pub fn is_italic(self) -> bool { + matches!(self, Face::Italic | Face::BoldItalic | Face::SansItalic) + } + pub fn is_bold(self) -> bool { + matches!(self, Face::Bold | Face::BoldItalic | Face::SansBold) + } +} + +/// One character. +#[derive(Clone, Debug, PartialEq)] +pub struct Glyph { + /// Left edge of the glyph's box. + pub x: f32, + /// The glyph's baseline. + pub y: f32, + /// Font size, in em of the equation (1 for the main line, smaller in scripts). + pub size: f32, + /// The character to draw. + pub ch: char, + /// A plainer character to draw when no font has `ch` (`R` for `ℝ`), before giving up. + pub alt: Option, + pub face: Face, + /// The glyph's box (TeX metrics), already scaled by `size`: advance, height above and depth + /// below the baseline. A painter without the glyph draws a `[?]` box this size. + pub width: f32, + pub height: f32, + pub depth: f32, + /// An explicit colour (`\color`); `None` is the equation's own ("Automatic") colour. + pub color: Option<[u8; 3]>, +} + +/// A filled rectangle (fraction bars, radical vinculums, table rules, box borders). +#[derive(Clone, Debug, PartialEq)] +pub struct Rule { + pub x: f32, + /// Top edge. + pub y: f32, + pub width: f32, + pub height: f32, + pub color: Option<[u8; 3]>, +} + +/// A vector outline (stretchy braces, arrows, radicals), relative to `(x, y)`. +#[derive(Clone, Debug, PartialEq)] +pub struct Path { + pub x: f32, + pub y: f32, + pub commands: Vec, + pub fill: bool, + pub color: Option<[u8; 3]>, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum PathCmd { + MoveTo(f32, f32), + LineTo(f32, f32), + QuadTo(f32, f32, f32, f32), + CubicTo(f32, f32, f32, f32, f32, f32), + Close, +} + +/// Lays `latex` out. Errors carry the offending span when RaTeX reports one. +pub fn layout(latex: &str) -> Result { + check_caps(latex)?; + guarded(|| { + let nodes = parse(latex)?; + let lb = ratex_layout::layout(&nodes, &ratex_layout::LayoutOptions::default()); + convert(&ratex_layout::to_display_list(&lb)) + }) +} + +/// Whether `latex` parses, without laying it out (cheap enough to run on every keystroke). +pub fn check(latex: &str) -> Result<(), MathError> { + check_caps(latex)?; + guarded(|| parse(latex).map(|_| ())) +} + +/// The equation gallery (Insert › Equation): GridCraft's own examples, as (name, LaTeX). +pub const GALLERY: &[(&str, &str)] = &[ + ("Quadratic formula", "x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}"), + ("Pythagoras", "a^2 + b^2 = c^2"), + ("Binomial theorem", "(x + a)^n = \\sum_{k=0}^{n} \\binom{n}{k} x^k a^{n-k}"), + ("Area of a circle", "A = \\pi r^2"), + ("Euler's identity", "e^{i\\pi} + 1 = 0"), + ("2\u{00D7}2 matrix", "\\begin{pmatrix} a & b \\\\ c & d \\end{pmatrix}"), +]; + +/// Runs `f` so that a panic inside it becomes an error. +fn guarded(f: impl FnOnce() -> Result) -> Result { + catch_unwind(AssertUnwindSafe(f)).unwrap_or_else(|_| Err(MathError::new("This equation could not be laid out.", None))) +} + +#[cfg(test)] +thread_local! { + /// Test hook: makes the next guarded parse panic, to prove the guard holds. + static INJECT_PANIC: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +fn parse(latex: &str) -> Result, MathError> { + #[cfg(test)] + if INJECT_PANIC.with(|p| p.replace(false)) { + panic!("injected"); + } + ratex_parser::parse(latex).map_err(|e| { + let span = e.loc.and_then(|l| { + let (s, t) = (l.start.min(latex.len()), l.end.min(latex.len())); + (latex.is_char_boundary(s) && latex.is_char_boundary(t)).then_some(s..t.max(s)) + }); + MathError::new(e.message, span) + }) +} + +/// The size and nesting caps, checked before RaTeX sees the source. +fn check_caps(latex: &str) -> Result<(), MathError> { + if latex.len() > MAX_SOURCE_BYTES { + return Err(SOURCE_TOO_LONG.clone()); + } + let mut depth = 0usize; + let mut chars = latex.char_indices().peekable(); + while let Some((i, c)) = chars.next() { + let open = match c { + '\\' => { + // A control word, or an escaped character (`\{` is not a group). + let mut word = String::new(); + while let Some(&(_, n)) = chars.peek() { + if n.is_ascii_alphabetic() { + word.push(n); + chars.next(); + } else { + break; + } + } + if word.is_empty() { + chars.next(); + continue; + } + match word.as_str() { + "left" | "begin" => Some(true), + "right" | "end" => Some(false), + _ => None, + } + } + '%' => { + // A comment runs to the end of the line. + for (_, n) in chars.by_ref() { + if n == '\n' { + break; + } + } + None + } + '{' => Some(true), + '}' => Some(false), + _ => None, + }; + match open { + Some(true) => { + depth += 1; + if depth > MAX_DEPTH { + return Err(MathError::new(format!("Equations can nest at most {MAX_DEPTH} levels deep."), Some(i..i + c.len_utf8()))); + } + } + Some(false) => depth = depth.saturating_sub(1), + None => {} + } + } + Ok(()) +} + +fn color(c: &Color) -> Option<[u8; 3]> { + let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; + let rgb = [q(c.r), q(c.g), q(c.b)]; + (rgb != [0, 0, 0]).then_some(rgb) +} + +/// A finite value within the caps, as `f32`. +fn em(v: f64) -> Result { + if v.is_finite() && v.abs() <= f64::from(MAX_EM) { Ok(v as f32) } else { Err(MathError::new("This equation is too large to draw.", None)) } +} + +fn convert(dl: &DisplayList) -> Result { + if dl.items.len() > MAX_ITEMS { + return Err(MathError::new("This equation is too large to draw.", None)); + } + let mut items = Vec::with_capacity(dl.items.len()); + for it in &dl.items { + items.push(match it { + DisplayItem::GlyphPath { x, y, scale, font, char_code, color: c } => { + Item::Glyph(glyph(em(*x)?, em(*y)?, em(*scale)?, font, *char_code, color(c))?) + } + DisplayItem::Line { x, y, width, thickness, color: c, .. } => { + let t = em(*thickness)?; + Item::Rule(Rule { x: em(*x)?, y: em(*y)? - t / 2.0, width: em(*width)?, height: t, color: color(c) }) + } + DisplayItem::Rect { x, y, width, height, color: c } => { + Item::Rule(Rule { x: em(*x)?, y: em(*y)?, width: em(*width)?, height: em(*height)?, color: color(c) }) + } + DisplayItem::Path { x, y, commands, fill, color: c } => { + let mut cmds = Vec::with_capacity(commands.len()); + for pc in commands { + cmds.push(match *pc { + PathCommand::MoveTo { x, y } => PathCmd::MoveTo(em(x)?, em(y)?), + PathCommand::LineTo { x, y } => PathCmd::LineTo(em(x)?, em(y)?), + PathCommand::QuadTo { x1, y1, x, y } => PathCmd::QuadTo(em(x1)?, em(y1)?, em(x)?, em(y)?), + PathCommand::CubicTo { x1, y1, x2, y2, x, y } => PathCmd::CubicTo(em(x1)?, em(y1)?, em(x2)?, em(y2)?, em(x)?, em(y)?), + PathCommand::Close => PathCmd::Close, + }); + } + Item::Path(Path { x: em(*x)?, y: em(*y)?, commands: cmds, fill: *fill, color: color(c) }) + } + }); + } + Ok(MathList { items, width: em(dl.width)?.max(0.0), height: em(dl.height)?, depth: em(dl.depth)? }) +} + +fn glyph(x: f32, y: f32, size: f32, font: &str, code: u32, color: Option<[u8; 3]>) -> Result { + let fid = FontId::parse(font).unwrap_or(FontId::MainRegular); + let face = match fid { + FontId::MainBold | FontId::FrakturBold => Face::Bold, + FontId::MainItalic | FontId::MathItalic => Face::Italic, + FontId::MainBoldItalic | FontId::MathBoldItalic => Face::BoldItalic, + FontId::SansSerifRegular => Face::Sans, + FontId::SansSerifBold => Face::SansBold, + FontId::SansSerifItalic => Face::SansItalic, + FontId::TypewriterRegular => Face::Mono, + FontId::Size1Regular => Face::Large(1), + FontId::Size2Regular => Face::Large(2), + FontId::Size3Regular => Face::Large(3), + FontId::Size4Regular => Face::Large(4), + _ => Face::Roman, + }; + let raw = char::from_u32(code).unwrap_or('\u{FFFD}'); + // Blackboard, script and Fraktur letters sit at ASCII slots in TeX's fonts; draw the Unicode + // letter, with the ASCII one as the fallback. + let (ch, alt) = match (fid, raw) { + (FontId::AmsRegular, c @ ('A'..='Z' | 'k')) => (styled(c, Style::DoubleStruck), Some(c)), + (FontId::CaligraphicRegular | FontId::ScriptRegular, c @ ('A'..='Z' | 'a'..='z')) => (styled(c, Style::Script), Some(c)), + (FontId::FrakturRegular, c @ ('A'..='Z' | 'a'..='z')) => (styled(c, Style::Fraktur), Some(c)), + (FontId::FrakturBold, c @ ('A'..='Z' | 'a'..='z')) => (styled(c, Style::BoldFraktur), Some(c)), + (_, c) if (0x1D400..=0x1D7FF).contains(&code) => (c, Some(ratex_font::katex_ttf_glyph_char(fid, code))), + (_, c) => (c, None), + }; + let m = ratex_font::font_and_metric_for_mathematical_alphanumeric(code) + .and_then(|(f, mc)| ratex_font::get_char_metrics(f, mc)) + .or_else(|| ratex_font::get_char_metrics(fid, code)) + .or_else(|| ratex_font::get_char_metrics_with_fallback(fid, code)); + let (w, h, d) = m.map_or((0.5, 0.7, 0.0), |m| (m.width + m.italic, m.height, m.depth)); + Ok(Glyph { x, y, size, ch, alt, face, width: em(w)? * size, height: em(h)? * size, depth: em(d)? * size, color }) +} + +#[derive(Clone, Copy)] +enum Style { + DoubleStruck, + Script, + Fraktur, + BoldFraktur, +} + +/// The Mathematical Alphanumeric Symbols letter for `c` (Unicode 15, chapter 22.2), using the +/// Letterlike Symbols block where Unicode put a letter before the alphanumeric block existed. +fn styled(c: char, style: Style) -> char { + let letterlike = match (style, c) { + (Style::DoubleStruck, 'C') => Some('\u{2102}'), + (Style::DoubleStruck, 'H') => Some('\u{210D}'), + (Style::DoubleStruck, 'N') => Some('\u{2115}'), + (Style::DoubleStruck, 'P') => Some('\u{2119}'), + (Style::DoubleStruck, 'Q') => Some('\u{211A}'), + (Style::DoubleStruck, 'R') => Some('\u{211D}'), + (Style::DoubleStruck, 'Z') => Some('\u{2124}'), + (Style::Script, 'B') => Some('\u{212C}'), + (Style::Script, 'E') => Some('\u{2130}'), + (Style::Script, 'F') => Some('\u{2131}'), + (Style::Script, 'H') => Some('\u{210B}'), + (Style::Script, 'I') => Some('\u{2110}'), + (Style::Script, 'L') => Some('\u{2112}'), + (Style::Script, 'M') => Some('\u{2133}'), + (Style::Script, 'R') => Some('\u{211B}'), + (Style::Script, 'e') => Some('\u{212F}'), + (Style::Script, 'g') => Some('\u{210A}'), + (Style::Script, 'o') => Some('\u{2134}'), + (Style::Fraktur, 'C') => Some('\u{212D}'), + (Style::Fraktur, 'H') => Some('\u{210C}'), + (Style::Fraktur, 'I') => Some('\u{2111}'), + (Style::Fraktur, 'R') => Some('\u{211C}'), + (Style::Fraktur, 'Z') => Some('\u{2128}'), + _ => None, + }; + if let Some(l) = letterlike { + return l; + } + let (upper, lower) = match style { + Style::DoubleStruck => (0x1D538, 0x1D552), + Style::Script => (0x1D49C, 0x1D4B6), + Style::Fraktur => (0x1D504, 0x1D51E), + Style::BoldFraktur => (0x1D56C, 0x1D586), + }; + let code = match c { + 'A'..='Z' => upper + (c as u32 - 'A' as u32), + 'a'..='z' => lower + (c as u32 - 'a' as u32), + _ => return c, + }; + char::from_u32(code).unwrap_or(c) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn glyphs(l: &MathList) -> String { + l.items.iter().filter_map(|i| if let Item::Glyph(g) = i { Some(g.ch) } else { None }).collect() + } + + #[test] + fn boxes_of_a_simple_line() { + let l = layout("x+1").unwrap(); + assert_eq!(glyphs(&l), "x+1"); + assert!(l.width > 1.5 && l.width < 3.0, "{}", l.width); + assert!(l.height > 0.4 && l.height < 1.0); + let Some(Item::Glyph(x)) = l.items.first() else { panic!("a glyph first") }; + assert_eq!(x.face, Face::Italic, "a letter is a math italic"); + assert!((x.y - l.height).abs() < 1e-4, "on the baseline"); + assert!(x.width > 0.3 && x.height > 0.3); + let one = l.items.iter().find_map(|i| if let Item::Glyph(g) = i { (g.ch == '1').then_some(g) } else { None }).unwrap(); + assert_eq!(one.face, Face::Roman); + } + + #[test] + fn fractions_roots_and_scripts() { + let f = layout("\\frac{a}{b}").unwrap(); + let rule = f.items.iter().find_map(|i| if let Item::Rule(r) = i { Some(r) } else { None }).expect("a fraction bar"); + assert!(rule.width > 0.3 && rule.height > 0.0 && rule.height < 0.1); + let (a, b) = match &f.items.iter().filter_map(|i| if let Item::Glyph(g) = i { Some(g.clone()) } else { None }).collect::>()[..] { + [a, b] => (a.clone(), b.clone()), + other => panic!("{other:?}"), + }; + assert!(a.y < rule.y && b.y > rule.y + rule.height, "numerator above, denominator below"); + assert!(f.depth > 0.3); + let s = layout("x^2").unwrap(); + let g: Vec<&Glyph> = s.items.iter().filter_map(|i| if let Item::Glyph(g) = i { Some(g) } else { None }).collect(); + assert!(g[1].size < 1.0 && g[1].y < g[0].y, "the exponent is smaller and raised"); + let r = layout("\\sqrt{x}").unwrap(); + assert!(glyphs(&r).contains('x')); + assert!(r.items.len() >= 2); + let big = layout("\\displaystyle\\sum_{i=1}^n i").unwrap(); + assert!(big.items.iter().any(|i| matches!(i, Item::Glyph(Glyph { ch: '∑', face: Face::Large(_), .. })))); + } + + #[test] + fn styled_letters_map_to_unicode_with_a_plain_fallback() { + let l = layout("\\mathbb{R}\\mathbb{Q}\\mathcal{L}\\mathfrak{g}").unwrap(); + let g: Vec<(char, Option)> = l.items.iter().filter_map(|i| if let Item::Glyph(g) = i { Some((g.ch, g.alt)) } else { None }).collect(); + assert_eq!(g, vec![('ℝ', Some('R')), ('ℚ', Some('Q')), ('ℒ', Some('L')), ('𝔤', Some('g'))]); + assert_eq!(styled('A', Style::DoubleStruck), '𝔸'); + assert_eq!(styled('b', Style::Script), '𝒷'); + assert_eq!(styled('7', Style::Fraktur), '7'); + } + + #[test] + fn colours_other_than_black_are_kept() { + let l = layout("{\\color{red}x}y").unwrap(); + let c: Vec> = l.items.iter().filter_map(|i| if let Item::Glyph(g) = i { Some(g.color) } else { None }).collect(); + assert_eq!(c, vec![Some([255, 0, 0]), None]); + } + + #[test] + fn errors_point_at_the_source() { + let e = layout("a + \\frac{1}{").unwrap_err(); + assert!(!e.message.is_empty()); + let e = layout("x + \\foo").unwrap_err(); + assert_eq!(e.span, Some(4..8), "{e:?}"); + assert_eq!(e.char_pos("x + \\foo"), Some(5)); + let e = layout("é + \\foo").unwrap_err(); + assert_eq!(e.char_pos("é + \\foo"), Some(5), "positions count characters, not bytes"); + assert!(check("\\frac{a}{b}").is_ok()); + assert!(check("\\frac{a}{").is_err()); + } + + #[test] + fn hostile_input_is_refused_without_panicking() { + let e = layout(&"x".repeat(MAX_SOURCE_BYTES + 1)).unwrap_err(); + assert!(e.message.contains("4096")); + assert!(layout(&"x".repeat(MAX_SOURCE_BYTES)).is_ok()); + let deep = format!("{}x{}", "{".repeat(33), "}".repeat(33)); + assert_eq!(layout(&deep).unwrap_err().span, Some(32..33)); + assert!(layout(&format!("{}x{}", "\\left(".repeat(40), "\\right)".repeat(40))).is_err()); + // Escaped braces and comments are not groups. + assert!(check_caps(&"\\{".repeat(100)).is_ok()); + assert!(check_caps(&format!("x % {}\n", "{".repeat(100))).is_ok()); + assert!(layout("\\rule{99999999em}{1em}").unwrap_err().message.contains("too large")); + assert!(layout("\\kern99999999em x").is_err()); + assert!(layout("\\def\\a{\\a\\a}\\a").is_err()); + for s in ["", " ", "\\", "{", "}", "^", "_", "&", "\\\\", "\u{0}", "\u{FFFF}", "\u{1F600}", "$", "#", "\\begin{matrix}", "\\end{matrix}"] { + let _ = layout(s); + let _ = check(s); + let _ = to_omml(s, 11.0, None); + } + } + + #[test] + fn an_injected_panic_is_an_error_not_a_crash() { + INJECT_PANIC.with(|p| p.set(true)); + let e = layout("x").unwrap_err(); + assert_eq!(e.message, "This equation could not be laid out."); + assert!(layout("x").is_ok(), "the next call is unaffected"); + } + + #[test] + fn the_gallery_lays_out_and_converts() { + for (name, src) in GALLERY { + assert!(layout(src).is_ok_and(|l| !l.items.is_empty()), "{name}"); + assert!(to_omml(src, 11.0, None).is_ok(), "{name}"); + } + } + + #[test] + fn empty_source_is_an_empty_list() { + let l = layout("").unwrap(); + assert!(l.items.is_empty()); + } +} diff --git a/crates/math/src/omml.rs b/crates/math/src/omml.rs new file mode 100644 index 000000000..dc0141aed --- /dev/null +++ b/crates/math/src/omml.rs @@ -0,0 +1,669 @@ +//! LaTeX to OMML, the open Office Math Markup of ECMA-376 Part 1 §22.1, for the common subset: +//! runs, fractions and binomials, roots, scripts, big operators with limits, functions and +//! limits, delimiters, matrices, accents, bars, braces and font styles. Anything else is an +//! error, and the caller saves the equation as text only. + +use ratex_parser::ParseNode; +use ratex_parser::parse_node::Mode; + +use crate::MathError; + +/// Deepest tree walked (RaTeX already refuses input nested deeper than 32). +const MAX_WALK: usize = 96; + +/// `latex` as an `` element (prefix `m` for the OMML namespace and `a` for DrawingML, +/// declared by the caller), with runs at `size_pt` points in the `ink` colour (none: the +/// paragraph's own). +pub fn to_omml(latex: &str, size_pt: f32, ink: Option<[u8; 3]>) -> Result { + crate::check_caps(latex)?; + crate::guarded(|| { + let nodes = crate::parse(latex)?; + let sz = if size_pt.is_finite() { (size_pt * 100.0).round().clamp(100.0, 400_000.0) as u32 } else { 1100 }; + let fill = ink.map(|[r, g, b]| format!("")).unwrap_or_default(); + let mut w = Writer { out: String::from(""), sz, fill }; + w.seq(&nodes, &Style::default(), 0)?; + w.out.push_str(""); + Ok(w.out) + }) +} + +#[derive(Clone, Default)] +struct Style { + /// `m:scr` (script) value: double-struck, script, fraktur, sans-serif, monospace. + scr: Option<&'static str>, + /// `m:sty` value: p (plain), b (bold), i, bi. + sty: Option<&'static str>, + /// Ordinary text (`\text`): `m:nor`. + normal: bool, +} + +struct Writer { + out: String, + sz: u32, + /// The DrawingML fill of every run (`…`), or empty. + fill: String, +} + +fn unsupported(what: &str) -> MathError { + MathError::new(format!("{what} can't be saved as an Office equation."), None) +} + +/// Whether XML 1.0 can carry `c`: the same set the xlsx crate's escapes keep, so the OMML is +/// well formed wherever the drawing part embeds it. +fn is_xml_char(c: char) -> bool { + matches!(c, '\t' | '\n' | '\r' | '\u{20}'..='\u{D7FF}' | '\u{E000}'..='\u{FFFD}' | '\u{10000}'..='\u{10FFFF}') +} + +/// `s` escaped for element text; characters XML cannot carry (C0 controls other than tab, newline +/// and carriage return, and U+FFFE and U+FFFF) are dropped, as the xlsx crate's `esc` drops them. +fn esc(s: &str) -> String { + let mut o = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '&' => o.push_str("&"), + '<' => o.push_str("<"), + '>' => o.push_str(">"), + '"' => o.push_str("""), + '\r' => o.push_str(" "), + c if is_xml_char(c) => o.push(c), + _ => {} + } + } + o +} + +/// The character a symbol name (`\alpha`, `(`, `\langle`) stands for. +fn symbol(name: &str, mode: Mode) -> Option { + let m = if mode == Mode::Text { ratex_font::symbols::Mode::Text } else { ratex_font::symbols::Mode::Math }; + if let Some(c) = ratex_font::symbols::get_symbol(name, m).and_then(|s| s.codepoint) { + return Some(c.to_string()); + } + if !name.starts_with('\\') { + return Some(name.to_string()); + } + match name { + "\\{" | "\\lbrace" => Some("{".into()), + "\\}" | "\\rbrace" => Some("}".into()), + "\\|" | "\\Vert" => Some("‖".into()), + "\\vert" => Some("|".into()), + "\\langle" => Some("⟨".into()), + "\\rangle" => Some("⟩".into()), + "\\lfloor" => Some("⌊".into()), + "\\rfloor" => Some("⌋".into()), + "\\lceil" => Some("⌈".into()), + "\\rceil" => Some("⌉".into()), + _ => None, + } +} + +/// A delimiter's character; `.` is no delimiter. +fn delim(d: &str) -> Result { + if d == "." { + return Ok(String::new()); + } + if d.len() == 1 && d.is_ascii() { + return Ok(d.to_string()); + } + symbol(d, Mode::Math).ok_or_else(|| unsupported(&format!("The delimiter {d}"))) +} + +fn is_big_op(n: &ParseNode) -> Option { + match n { + ParseNode::Op { symbol: true, name: Some(name), .. } => symbol(name, Mode::Math), + _ => None, + } +} + +fn is_rel(n: &ParseNode) -> bool { + matches!(n, ParseNode::Atom { family: ratex_parser::parse_node::AtomFamily::Rel, .. }) +} + +impl Writer { + fn run(&mut self, text: &str, st: &Style) { + if text.is_empty() { + return; + } + self.out.push_str(""); + if st.scr.is_some() || st.sty.is_some() || st.normal { + self.out.push_str(""); + if st.normal { + self.out.push_str(""); + } + if let Some(s) = st.scr { + self.out.push_str(&format!("")); + } + if let Some(s) = st.sty { + self.out.push_str(&format!("")); + } + self.out.push_str(""); + } + // Word trims a run's text unless it is marked, so a run that is or ends in whitespace + // (the space after `\text{if }`) says so. + let space = if text.starts_with(char::is_whitespace) || text.ends_with(char::is_whitespace) { " xml:space=\"preserve\"" } else { "" }; + self.out.push_str(&format!( + "{}{}", + self.sz, + self.fill, + space, + esc(text) + )); + } + + /// `…` around the nodes. + fn wrap(&mut self, tag: &str, nodes: &[ParseNode], st: &Style, depth: usize) -> Result<(), MathError> { + self.out.push_str(&format!("<{tag}>")); + self.seq(nodes, st, depth)?; + self.out.push_str(&format!("")); + Ok(()) + } + + fn wrap1(&mut self, tag: &str, node: Option<&ParseNode>, st: &Style, depth: usize) -> Result<(), MathError> { + match node { + Some(n) => self.wrap(tag, std::slice::from_ref(n), st, depth), + None => { + self.out.push_str(&format!("<{tag}/>")); + Ok(()) + } + } + } + + fn seq(&mut self, nodes: &[ParseNode], st: &Style, depth: usize) -> Result<(), MathError> { + if depth > MAX_WALK { + return Err(unsupported("An equation this deep")); + } + let mut i = 0; + while let Some(n) = nodes.get(i) { + i += 1; + // A big operator takes what follows, up to the next relation, as its operand. + let (op, sub, sup) = match n { + ParseNode::SupSub { base: Some(b), sub, sup, .. } => (is_big_op(b), sub.as_deref(), sup.as_deref()), + other => (is_big_op(other), None, None), + }; + if let Some(chr) = op { + let end = nodes.get(i..).and_then(|rest| rest.iter().position(is_rel)).map_or(nodes.len(), |p| i + p); + let limits = match n { + ParseNode::SupSub { base: Some(b), .. } => matches!(**b, ParseNode::Op { limits: true, .. }), + _ => false, + }; + self.out.push_str(&format!( + "{}{}", + esc(&chr), + if limits { "undOvr" } else { "subSup" }, + if sub.is_none() { "" } else { "" }, + if sup.is_none() { "" } else { "" }, + )); + self.wrap1("m:sub", sub, st, depth + 1)?; + self.wrap1("m:sup", sup, st, depth + 1)?; + self.wrap("m:e", nodes.get(i..end).unwrap_or_default(), st, depth + 1)?; + self.out.push_str(""); + i = end; + continue; + } + self.node(n, st, depth + 1)?; + } + Ok(()) + } + + fn node(&mut self, n: &ParseNode, st: &Style, depth: usize) -> Result<(), MathError> { + if depth > MAX_WALK { + return Err(unsupported("An equation this deep")); + } + match n { + ParseNode::Atom { text, mode, .. } + | ParseNode::MathOrd { text, mode, .. } + | ParseNode::TextOrd { text, mode, .. } + | ParseNode::OpToken { text, mode, .. } => { + let t = symbol(text, *mode).ok_or_else(|| unsupported(text))?; + self.run(&t, st); + } + ParseNode::SpacingNode { text, .. } => { + let t = match text.as_str() { + // A literal space in text mode (`\text{if } x`) is a spacing node of " ". + " " | "\\ " | "~" | "\\space" | "\\nobreakspace" => " ", + "\\quad" => "\u{2003}", + "\\qquad" => "\u{2003}\u{2003}", + "\\," | "\\thinspace" => "\u{2009}", + "\\:" | "\\>" | "\\medspace" => "\u{205F}", + "\\;" | "\\thickspace" => "\u{2004}", + _ => "", + }; + self.run(t, st); + } + ParseNode::Kern { .. } => {} + ParseNode::OrdGroup { body, .. } + | ParseNode::Styling { body, .. } + | ParseNode::Sizing { body, .. } + | ParseNode::MClass { body, .. } + | ParseNode::Href { body, .. } + | ParseNode::HBox { body, .. } + | ParseNode::Pmb { body, .. } => self.seq(body, st, depth)?, + ParseNode::Color { body, .. } => self.seq(body, st, depth)?, + ParseNode::Text { body, font, .. } => { + let mut s = Style { normal: true, ..st.clone() }; + if let Some(f) = font { + s.sty = match f.as_str() { + "\\textbf" => Some("b"), + "\\textit" => Some("i"), + _ => s.sty, + }; + } + self.seq(body, &s, depth)?; + } + ParseNode::Font { font, body, .. } => { + let mut s = st.clone(); + match font.trim_start_matches('\\') { + "mathbb" | "Bbb" => s.scr = Some("double-struck"), + "mathcal" | "mathscr" => s.scr = Some("script"), + "mathfrak" | "frak" => s.scr = Some("fraktur"), + "mathsf" | "textsf" => s.scr = Some("sans-serif"), + "mathtt" | "texttt" => s.scr = Some("monospace"), + "mathbf" | "bf" | "textbf" => s.sty = Some("b"), + "boldsymbol" | "bm" => s.sty = Some("bi"), + "mathrm" | "rm" | "textrm" | "mathnormal" => s.sty = Some("p"), + "mathit" | "it" | "textit" => s.sty = Some("i"), + other => return Err(unsupported(&format!("\\{other}"))), + } + self.node(body, &s, depth + 1)?; + } + ParseNode::SupSub { base, sup, sub, .. } => { + // A function with limits under it (\lim_{x\to 0}, \max_i). + if let Some(b) = base.as_deref() + && let ParseNode::Op { limits: true, symbol: false, .. } | ParseNode::OperatorName { limits: true, .. } = b + && sup.is_none() + { + self.out.push_str(""); + self.node(b, st, depth + 1)?; + self.out.push_str(""); + self.wrap1("m:lim", sub.as_deref(), st, depth + 1)?; + self.out.push_str(""); + return Ok(()); + } + // A brace with a label over or under it (`\overbrace{x}^{n}`, `\underbrace{x}_{n}`): + // the label is the limit on the brace's side. A script on the other side + // (`\underbrace{x}_{n}^{2}`) is a script around the whole, `m:sSup` or `m:sSub`, + // so that it reaches Office Math too; without a label it goes around the bare + // brace, with no empty limit. + if let Some(b @ ParseNode::HorizBrace { is_over, .. }) = base.as_deref() { + let (lim_tag, label, (script_tag, script_arg), script) = if *is_over { + ("m:limUpp", sup.as_deref(), ("m:sSub", "m:sub"), sub.as_deref()) + } else { + ("m:limLow", sub.as_deref(), ("m:sSup", "m:sup"), sup.as_deref()) + }; + // Each wrapper around the brace is a level of its own. + let inner = if script.is_some() { depth + 1 } else { depth }; + if script.is_some() { + self.out.push_str(&format!("<{script_tag}>")); + } + if label.is_some() { + self.out.push_str(&format!("<{lim_tag}>")); + self.node(b, st, inner + 1)?; + self.out.push_str(""); + self.wrap1("m:lim", label, st, inner + 1)?; + self.out.push_str(&format!("")); + } else { + self.node(b, st, inner)?; + } + if script.is_some() { + self.out.push_str(""); + self.wrap1(script_arg, script, st, depth + 1)?; + self.out.push_str(&format!("")); + } + return Ok(()); + } + let tag = match (sub.is_some(), sup.is_some()) { + (true, true) => "m:sSubSup", + (true, false) => "m:sSub", + _ => "m:sSup", + }; + self.out.push_str(&format!("<{tag}>")); + self.wrap1("m:e", base.as_deref(), st, depth + 1)?; + if sub.is_some() { + self.wrap1("m:sub", sub.as_deref(), st, depth + 1)?; + } + if sup.is_some() || sub.is_none() { + self.wrap1("m:sup", sup.as_deref(), st, depth + 1)?; + } + self.out.push_str(&format!("")); + } + ParseNode::GenFrac { numer, denom, has_bar_line, left_delim, right_delim, .. } => { + let delims = left_delim.is_some() || right_delim.is_some(); + if delims { + self.out.push_str(&format!( + "", + esc(&delim(left_delim.as_deref().unwrap_or("."))?), + esc(&delim(right_delim.as_deref().unwrap_or("."))?) + )); + } + self.out.push_str(""); + if !has_bar_line { + self.out.push_str(""); + } + self.wrap1("m:num", Some(numer), st, depth + 1)?; + self.wrap1("m:den", Some(denom), st, depth + 1)?; + self.out.push_str(""); + if delims { + self.out.push_str(""); + } + } + ParseNode::Sqrt { body, index, .. } => { + self.out.push_str(""); + match index { + Some(ix) => { + self.wrap1("m:deg", Some(ix), st, depth + 1)?; + } + None => self.out.push_str(""), + } + self.wrap1("m:e", Some(body), st, depth + 1)?; + self.out.push_str(""); + } + ParseNode::Op { name: Some(name), symbol: false, .. } => { + let t = name.trim_start_matches('\\'); + self.run(t, &Style { sty: Some("p"), ..st.clone() }); + } + ParseNode::Op { name: Some(name), .. } => { + let t = symbol(name, Mode::Math).ok_or_else(|| unsupported(name))?; + self.run(&t, st); + } + ParseNode::Op { body: Some(body), .. } => self.seq(body, st, depth)?, + ParseNode::OperatorName { body, .. } => self.seq(body, &Style { sty: Some("p"), ..st.clone() }, depth)?, + ParseNode::LeftRight { body, left, right, .. } => { + self.out.push_str(&format!( + "", + esc(&delim(left)?), + esc(&delim(right)?) + )); + // \middle splits the contents into separated elements. + let mut parts: Vec<&[ParseNode]> = Vec::new(); + let mut start = 0; + for (k, b) in body.iter().enumerate() { + if matches!(b, ParseNode::Middle { .. }) { + parts.push(body.get(start..k).unwrap_or_default()); + start = k + 1; + } + } + parts.push(body.get(start..).unwrap_or_default()); + if parts.len() > 1 { + let sep = + body.iter().find_map(|b| if let ParseNode::Middle { delim: d, .. } = b { delim(d).ok() } else { None }).unwrap_or_default(); + // Rewrite the dPr with the separator: drop what was pushed and start again. + let cut = self.out.rfind("").unwrap_or(self.out.len()); + self.out.truncate(cut); + self.out.push_str(&format!( + "", + esc(&delim(left)?), + esc(&sep), + esc(&delim(right)?) + )); + } + for p in parts { + self.wrap("m:e", p, st, depth + 1)?; + } + self.out.push_str(""); + } + ParseNode::DelimSizing { delim: d, .. } => { + let t = delim(d)?; + self.run(&t, st); + } + ParseNode::Array { body, is_cd: None | Some(false), .. } => { + let cols = body.iter().map(Vec::len).max().unwrap_or(0).max(1); + self.out.push_str(&format!( + "" + )); + // Every row the parser gives is written: RaTeX itself drops the empty last row a + // trailing `\\` leaves, and an empty row anywhere else is the author's blank line. + for row in body { + self.out.push_str(""); + for k in 0..cols { + self.wrap1("m:e", row.get(k), st, depth + 1)?; + } + self.out.push_str(""); + } + self.out.push_str(""); + } + ParseNode::Accent { label, base, .. } => { + let chr = match label.as_str() { + "\\hat" | "\\widehat" => "\u{0302}", + "\\check" | "\\widecheck" => "\u{030C}", + "\\tilde" | "\\widetilde" => "\u{0303}", + "\\acute" => "\u{0301}", + "\\grave" => "\u{0300}", + "\\dot" => "\u{0307}", + "\\ddot" => "\u{0308}", + "\\breve" => "\u{0306}", + "\\bar" => "\u{0305}", + "\\vec" | "\\overrightarrow" => "\u{20D7}", + "\\overleftarrow" => "\u{20D6}", + "\\mathring" => "\u{030A}", + other => return Err(unsupported(other)), + }; + self.out.push_str(&format!("")); + self.wrap1("m:e", Some(base), st, depth + 1)?; + self.out.push_str(""); + } + ParseNode::Overline { body, .. } | ParseNode::Underline { body, .. } => { + let pos = if matches!(n, ParseNode::Overline { .. }) { "top" } else { "bot" }; + self.out.push_str(&format!("")); + self.wrap1("m:e", Some(body), st, depth + 1)?; + self.out.push_str(""); + } + ParseNode::HorizBrace { is_over, base, .. } => { + let (chr, pos, vert) = if *is_over { ("\u{23DE}", "top", "bot") } else { ("\u{23DF}", "bot", "top") }; + self.out.push_str(&format!( + "" + )); + self.wrap1("m:e", Some(base), st, depth + 1)?; + self.out.push_str(""); + } + ParseNode::Enclose { label, body, .. } if label == "\\boxed" || label == "\\fbox" => { + self.out.push_str(""); + self.wrap1("m:e", Some(body), st, depth + 1)?; + self.out.push_str(""); + } + ParseNode::Phantom { body, .. } => { + self.out.push_str(""); + self.wrap("m:e", body, st, depth + 1)?; + self.out.push_str(""); + } + ParseNode::Internal { .. } | ParseNode::NoNumber { .. } | ParseNode::ColorToken { .. } => {} + other => return Err(unsupported(&format!("\"{}\"", node_name(other)))), + } + Ok(()) + } +} + +/// A short name for an unsupported construct, for the error. +fn node_name(n: &ParseNode) -> String { + let dbg = format!("{n:?}"); + dbg.split([' ', '{', '(']).next().unwrap_or("This construct").to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn om(s: &str) -> String { + to_omml(s, 11.0, None).unwrap() + } + + /// The markup without the per-run DrawingML properties, to keep assertions readable. + fn bare(s: &str) -> String { + let mut o = om(s); + while let Some(a) = o.find("") else { break }; + o.replace_range(a..a + b + "".len(), ""); + } + o + } + + #[test] + fn runs_fractions_and_roots() { + assert_eq!(bare("x+1"), "x+1"); + assert!(om("x").contains("sz=\"1100\"")); + assert_eq!( + bare("\\frac{a}{b}"), + "ab" + ); + assert!( + bare("\\binom{n}{k}") + .contains("") + ); + assert!(bare("\\sqrt{x}").starts_with("")); + assert!(bare("\\sqrt[3]{x}").contains("3")); + assert!(bare("\\alpha \\le \\infty").contains("α≤∞")); + } + + #[test] + fn scripts_operators_and_limits() { + assert_eq!(bare("x^2"), "x2"); + assert!(bare("a_{i}^{2}").starts_with("")); + let s = bare("\\sum_{k=0}^{n} k = m"); + assert!(s.starts_with(""), "{s}"); + assert!(s.contains("k="), "the operand stops at the relation: {s}"); + assert!(bare("\\int x").contains("")); + let l = bare("\\lim_{n\\to\\infty} a"); + assert!(l.starts_with("lim"), "{l}"); + assert!(bare("\\sin x").contains("sin")); + } + + #[test] + fn delimiters_matrices_accents_and_fonts() { + let p = bare("\\begin{pmatrix} a & b \\\\ c & d \\end{pmatrix}"); + assert!(p.contains(""), "{p}"); + assert_eq!(p.matches("").count(), 2); + assert_eq!(p.matches("").count(), 5); + assert!(bare("\\left\\{ x \\right.").contains("")); + assert!(bare("\\left\\langle a \\middle| b \\right\\rangle").contains("")); + assert!(bare("\\hat{x}").contains("")); + assert!(bare("\\overline{z}").contains("")); + assert!(bare("\\mathbb{R}").contains("")); + assert!(bare("\\text{if } x").contains("")); + assert!(bare("\\overbrace{a+b}^{n}").contains("")); + assert!(bare("a < b \\& c").contains("<") && bare("a \\& b").contains("&")); + } + + #[test] + fn an_underbrace_with_both_scripts_keeps_its_exponent() { + // The label under the brace is its limit; the exponent on the other side is a + // superscript around the whole, so that an application reading the Office Math sees it + // (the source in `descr` hid its loss in a GridCraft-to-GridCraft round trip). + let brace = |chr: &str, pos: &str, vert: &str| { + format!( + "x" + ) + }; + let under = brace("\u{23DF}", "bot", "top"); + let over = brace("\u{23DE}", "top", "bot"); + assert_eq!( + bare("\\underbrace{x}_{n}^{2}"), + format!( + "{under}n2" + ) + ); + // The mirror: the label over the brace, the subscript around the whole. + assert_eq!( + bare("\\overbrace{x}^{n}_{m}"), + format!( + "{over}nm" + ) + ); + // Nested braces keep every script, one wrapper per level. + let nested = bare("\\underbrace{\\underbrace{x}_{a}^{b}}_{c}^{d}"); + assert_eq!(nested.matches("").count(), 2, "{nested}"); + assert_eq!(nested.matches("").count(), 2, "{nested}"); + for t in ["a", "b", "c", "d"] { + assert!(nested.contains(&format!("{t}")), "{nested}"); + } + } + + #[test] + fn an_underbrace_without_a_label_keeps_its_superscript() { + // No label: the script goes around the bare brace, and there is no empty limit. + let under = bare("\\underbrace{x}^{2}"); + assert!(under.starts_with(""), "{under}"); + assert!(under.ends_with("2"), "{under}"); + assert!(!under.contains(""), "{over}"); + assert!(over.ends_with("m"), "{over}"); + assert!(!over.contains("") && !plain.contains("") && !plain.contains("") && !labelled.contains(""), "{labelled}"); + } + + #[test] + fn the_unsupported_subset_is_an_error() { + for s in ["\\begin{CD} A @>>> B \\end{CD}", "\\rule{1em}{1em}", "\\xrightarrow{f}", "\\verb|x|"] { + assert!(to_omml(s, 11.0, None).is_err(), "{s}"); + } + assert!(to_omml("\\frac{1}{", 11.0, None).is_err()); + assert!(to_omml(&"x".repeat(5000), 11.0, None).is_err()); + assert!(om("x").starts_with("")); + assert!(to_omml("x", f32::NAN, None).unwrap().contains("sz=\"1100\"")); + } + + #[test] + fn a_literal_space_in_text_mode_is_kept() { + // RaTeX gives a text-mode space as a spacing node whose text is " "; it is a run of its + // own, marked `xml:space="preserve"` so that Word keeps it, and `\text{if } x` does not + // come out as `ifx`. + let o = bare("\\text{a b}"); + assert!(o.contains(" "), "{o}"); + let o = bare("\\text{if } x"); + assert!(o.contains(" x"), "{o}"); + assert!(!bare("x+1").contains("xml:space"), "runs without surrounding whitespace are not marked"); + } + + #[test] + fn runs_carry_the_ink_colour_when_one_is_given() { + let o = to_omml("x+1", 11.0, Some([0x1F, 0x4E, 0x79])).unwrap(); + let run = + ""; + assert_eq!(o.matches(run).count(), 3, "{o}"); + assert!(!om("x+1").contains(""), "no ink: the paragraph's colour applies"); + } + + #[test] + fn characters_xml_cannot_carry_are_dropped_from_runs() { + // U+FFFE and U+FFFF are not XML characters, nor are C0 controls other than tab, newline + // and carriage return; the xlsx escapes drop them and this writer does the same, so the + // OMML it hands the drawing part is well formed. U+FDD0 is a Unicode noncharacter but an + // XML character, kept as the xlsx escapes keep it. + assert_eq!(esc("a\u{FFFE}b\u{FFFF}c\u{1}d\u{B}e\u{7F}f"), "abcde\u{7F}f"); + assert_eq!(esc("a\tb\nc\rd\u{FDD0}e"), "a\tb\nc d\u{FDD0}e"); + for (src, bad) in [("x\u{FFFE}y", '\u{FFFE}'), ("x\u{FFFF}y", '\u{FFFF}'), ("\\text{a\u{FFFF}b}", '\u{FFFF}')] { + if let Ok(o) = to_omml(src, 11.0, None) { + assert!(!o.contains(bad), "{src:?}: {o}"); + } + } + } + + #[test] + fn only_a_trailing_empty_row_is_dropped() { + // RaTeX drops the empty last row a trailing `\\` leaves (its `environments.rs`), and the + // writer keeps every row it is given, so a blank row in the middle, written as `\\ \\` or + // as `{}`, reaches Office as the sheet shows it. A RaTeX release that stopped dropping the + // trailing row would show up here as a third row. + assert_eq!(bare("\\begin{matrix} a \\\\ b \\\\ \\end{matrix}").matches("").count(), 2); + assert_eq!(bare("\\begin{cases} x \\\\ \\\\ y \\end{cases}").matches("").count(), 3); + assert_eq!(bare("\\begin{matrix} a \\\\ {} \\\\ b \\end{matrix}").matches("").count(), 3); + assert_eq!(bare("\\begin{matrix} \\\\ \\end{matrix}").matches("").count(), 1, "one blank row is still a row"); + } + + #[test] + fn every_gallery_example_converts() { + for s in [ + "x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}", + "a^2 + b^2 = c^2", + "(x + a)^n = \\sum_{k=0}^{n} \\binom{n}{k} x^k a^{n-k}", + "A = \\pi r^2", + "e^{i\\pi} + 1 = 0", + "\\begin{pmatrix} a & b \\\\ c & d \\end{pmatrix}", + "f(x) = \\begin{cases} x & x \\ge 0 \\\\ -x & x < 0 \\end{cases}", + ] { + assert!(to_omml(s, 11.0, None).is_ok(), "{s}: {:?}", to_omml(s, 11.0, None)); + } + } +} diff --git a/crates/math/tests/gate.rs b/crates/math/tests/gate.rs new file mode 100644 index 000000000..1b7d7c7f0 --- /dev/null +++ b/crates/math/tests/gate.rs @@ -0,0 +1,506 @@ +//! The RaTeX gate, kept as a regression suite so that a version bump re-runs it: hostile and +//! random input never panics, nesting is bounded below the stack, macro bombs stop, and the +//! syntax an equation editor needs is covered. Every call goes through `catch_unwind`, so a panic +//! is counted, not fatal. `docs/decisions.md` holds the full gate record. + +use std::panic::{AssertUnwindSafe, catch_unwind}; + +#[derive(Debug, PartialEq)] +enum Outcome { + Laid(usize), + Rejected(String), + Panicked(String), +} + +fn run(src: &str) -> Outcome { + let r = catch_unwind(AssertUnwindSafe(|| match ratex_parser::parse(src) { + Ok(nodes) => { + let lb = ratex_layout::layout(&nodes, &ratex_layout::LayoutOptions::default()); + Ok(ratex_layout::to_display_list(&lb).items.len()) + } + Err(e) => Err(e.message), + })); + match r { + Ok(Ok(n)) => Outcome::Laid(n), + Ok(Err(m)) => Outcome::Rejected(m), + Err(p) => { + Outcome::Panicked(p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::().cloned()).unwrap_or_default()) + } + } +} + +/// xorshift64: deterministic, so a failure reproduces. +struct Rng(u64); + +impl Rng { + fn next(&mut self) -> u64 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + self.0 + } + fn below(&mut self, n: usize) -> usize { + (self.next() % n.max(1) as u64) as usize + } + fn pick<'a>(&mut self, v: &[&'a str]) -> &'a str { + v[self.below(v.len())] + } +} + +const TOKENS: &[&str] = &[ + "{", + "}", + "[", + "]", + "(", + ")", + "^", + "_", + "&", + "\\\\", + "x", + "1", + "+", + "=", + "\\frac", + "\\sqrt", + "\\left", + "\\right", + "\\middle", + "\\sum", + "\\int", + "\\begin{matrix}", + "\\end{matrix}", + "\\begin{pmatrix}", + "\\end{pmatrix}", + "\\begin{cases}", + "\\end{cases}", + "\\begin{aligned}", + "\\end{aligned}", + "\\begin{array}{cc}", + "\\end{array}", + "\\text{", + "\\mathbb", + "\\hat", + "\\overline", + "\\underbrace", + "\\overbrace", + "\\binom", + "\\color{red}", + "\\def\\a{\\a}", + "\\a", + "\\newcommand", + "\\boxed", + "\\xrightarrow", + "\\stackrel", + "\\over", + "\\atop", + "\\choose", + "\\displaystyle", + "\\big", + "\\Bigg", + "|", + "\\|", + "\\langle", + ".", + "\\ce{", + "\\pu{", + "H2O", + "->", + "\\operatorname", + "\\mathrm", + "\\verb|x|", + "\\char", + "\\kern", + "\\hspace{", + "1em", + "-1000em", + "\\rule{", + "\\raisebox{", + "\\phantom", + "\\cancel", + "\\not", + "\\limits", + "\\tag{", + "$", + "%", + "#", + "~", + "'", + "\\vec", + "\\mathchoice", + "\\href{", + "\\includegraphics", + "\\cr", + "\\hline", + "\\multicolumn", + "\\begin{CD}", + "\\end{CD}", + "@>>>", + "\\infty", + "\\alpha", + "\\partial", + "\u{1F600}", + "\u{0301}", + "\u{0000}", + "\u{FFFF}", + "\\sideset", + "\\genfrac", + "\\substack{", + "\\smash", + "\\llap", + "\\textcolor{", + "#ff0000", + "\\colorbox{", + "\\sqrt[", + "\\lim", + "\\overset", + "\\underset", + "\\Huge", + "\\begin{align}", + "\\end{align}", + "\\begin{alignat}{2}", + "\\end{alignat}", + "\\begin{rcases}", + "\\expandafter", + "\\noexpand", + "\\let", + "\\gdef", + "\\global", + "\\edef", + "\\boldsymbol", + "\\mathcal", + "\\mathfrak", + "\\char\"41", + "\\unicode", + "\\infer", +]; + +/// What the editor's gallery and quick-insert buttons produce, plus everyday notation. +const COVERAGE: &[&str] = &[ + "x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}", + "a^2 + b^2 = c^2", + "(x + a)^n = \\sum_{k=0}^{n} \\binom{n}{k} x^k a^{n-k}", + "A = \\pi r^2", + "e^{i\\pi} + 1 = 0", + "\\begin{pmatrix} a & b \\\\ c & d \\end{pmatrix}", + "\\begin{bmatrix}1&0\\\\0&1\\end{bmatrix}", + "\\begin{vmatrix}a&b\\\\c&d\\end{vmatrix}", + "\\int_0^\\infty e^{-x^2}\\,dx", + "\\iint \\oint \\prod_{i} \\coprod", + "\\lim_{n\\to\\infty}\\left(1+\\frac1n\\right)^n", + "f(x) = \\begin{cases} x & x \\ge 0 \\\\ -x & x < 0 \\end{cases}", + "\\begin{aligned} a &= b \\\\ c &= d \\end{aligned}", + "\\overbrace{a+b}^{n} \\underbrace{c}_{m}", + "\\vec v \\cdot \\hat n \\times \\overline{z} \\tilde a \\dot x \\ddot y \\bar b", + "\\mathbb{R}^n \\mathcal{L} \\mathfrak g \\mathrm{d} \\mathbf{v} \\mathit{x} \\mathsf{s} \\mathtt{t}", + "\\sqrt[3]{x}", + "\\left\\langle \\psi \\middle| \\phi \\right\\rangle", + "\\left\\{ x \\right\\} \\left[ y \\right] \\left| z \\right| \\left\\lfloor w \\right\\rceil", + "\\color{red}{x}\\textcolor{blue}{y}", + "\\text{if } x \\in A", + "\\xrightarrow[below]{above} \\xleftarrow{a}", + "\\begin{array}{c|c} 1 & 2 \\\\ \\hline 3 & 4 \\end{array}", + "\\alpha\\beta\\gamma\\delta\\epsilon\\varepsilon\\zeta\\eta\\theta\\vartheta\\iota\\kappa\\lambda\\mu\\nu\\xi\\pi\\rho\\sigma\\tau\\upsilon\\phi\\varphi\\chi\\psi\\omega", + "\\Gamma\\Delta\\Theta\\Lambda\\Xi\\Pi\\Sigma\\Upsilon\\Phi\\Psi\\Omega", + "\\le\\ge\\ne\\approx\\equiv\\sim\\simeq\\cong\\propto\\ll\\gg\\subset\\supseteq\\in\\notin\\ni", + "\\pm\\mp\\times\\div\\cdot\\circ\\bullet\\oplus\\otimes\\cup\\cap\\wedge\\vee\\setminus", + "\\infty\\partial\\nabla\\forall\\exists\\emptyset\\angle\\triangle\\hbar\\ell\\Re\\Im\\aleph", + "\\to\\leftarrow\\Rightarrow\\Leftrightarrow\\mapsto\\uparrow\\iff\\implies", + "\\sin\\cos\\tan\\log\\ln\\exp\\max\\min\\det\\gcd\\operatorname{sgn}", + "\\displaystyle\\sum_{i=1}^n i = \\frac{n(n+1)}{2}", + "\\binom{n}{k} \\dbinom{a}{b} \\tfrac12 \\dfrac{1}{2} \\cfrac{1}{1+\\cfrac{1}{2}}", + "x_{i,j}^{(k)} x' x''", + "\\boxed{E=mc^2}", + "\\cancel{x} \\bcancel{y}", + "\\overset{!}{=} \\underset{x}{\\arg\\max} \\stackrel{def}{=}", + "\\begin{matrix}1&2\\\\3&4\\end{matrix}", + "\\begin{smallmatrix}1&2\\end{smallmatrix}", + "\\begin{gather} a \\\\ b \\end{gather}", + "\\begin{align} a &= b \\end{align}", + "\\ldots\\cdots\\vdots\\ddots", + "a\\quad b\\qquad c\\,d\\;e\\!f", + "\\langle x \\rangle \\lVert v \\rVert", + "\\frac{\\partial f}{\\partial x}", + "\\int\\limits_a^b", + "a\\phantom{x}b\\hphantom{y}c", + "\\widehat{abc} \\widetilde{xyz} \\overrightarrow{AB}", + "a \\pmod{n} \\bmod b", + "\\Big( \\bigg[ \\Bigg\\{", + "\\not= \\not\\in", + "1{,}000 \\%\\$\\#\\&\\_\\{\\}", + "\\textbf{bold} \\textit{it}", + "\\sum_{\\substack{a\\\\b}}", +]; + +#[test] +fn random_token_soup_never_panics() { + let mut rng = Rng(0x9e37_79b9_7f4a_7c15); + let mut panics = Vec::new(); + for _ in 0..3000 { + let mut s = String::new(); + for _ in 0..1 + rng.below(24) { + if rng.below(10) == 0 { + s.push(char::from_u32(rng.below(0x3000) as u32).unwrap_or('?')); + } else { + s.push_str(rng.pick(TOKENS)); + } + } + if let Outcome::Panicked(p) = run(&s) { + panics.push((s, p)); + } + } + assert!(panics.is_empty(), "RaTeX panicked: {panics:?}"); +} + +#[test] +fn mutated_valid_input_never_panics() { + let mut rng = Rng(0x2545_f491_4f6c_dd1d); + let mut panics = Vec::new(); + for _ in 0..3000 { + let mut v: Vec = rng.pick(COVERAGE).chars().collect(); + for _ in 0..1 + rng.below(4) { + if v.is_empty() { + break; + } + let at = rng.below(v.len() + 1); + match rng.below(4) { + 0 if at < v.len() => { + v.remove(at); + } + 1 => { + for c in rng.pick(TOKENS).chars().rev() { + v.insert(at.min(v.len()), c); + } + } + 2 if at < v.len() => v[at] = char::from_u32(rng.below(0x2200) as u32).unwrap_or('?'), + _ => { + let b = rng.below(v.len()); + let e = (b + rng.below(12)).min(v.len()); + let seg: Vec = v[b..e].to_vec(); + for c in seg.into_iter().rev() { + v.insert(at.min(v.len()), c); + } + } + } + } + let s: String = v.into_iter().collect(); + if let Outcome::Panicked(p) = run(&s) { + panics.push((s, p)); + } + } + assert!(panics.is_empty(), "RaTeX panicked: {panics:?}"); +} + +#[test] +fn nesting_is_refused_before_the_stack_runs_out() { + for (open, close) in [("{", "}"), ("\\frac{", "}{1}"), ("\\sqrt{", "}"), ("\\left(", "\\right)"), ("x^{", "}")] { + let at = |d: usize| format!("{}x{}", open.repeat(d), close.repeat(d)); + assert!(matches!(run(&at(32)), Outcome::Laid(_)), "{open}: 32 levels lay out"); + for d in [64, 2000] { + assert_eq!(run(&at(d)), Outcome::Rejected("Recursion limit exceeded".into()), "{open}: {d} levels"); + } + } +} + +#[test] +fn nesting_built_by_macros_is_refused_by_the_parser() { + // `check_caps` in the wrapper counts the braces, `\left`s and `\begin`s of the source text, + // so nesting that macro expansion builds reaches the parser; its own recursion guard refuses + // it, well within the 1,000-expansion limit (64 levels take 128 expansions). + let built = |d: usize| format!("\\def\\o{{\\left(}}\\def\\c{{\\right)}}{}x{}", "\\o ".repeat(d), "\\c".repeat(d)); + assert!(matches!(run(&built(32)), Outcome::Laid(_)), "32 macro-built levels lay out"); + assert_eq!(run(&built(64)), Outcome::Rejected("Recursion limit exceeded".into()), "64 macro-built levels"); + let frac = |d: usize| format!("\\def\\o#1{{\\frac{{#1}}{{1}}}}{}x{}", "\\o{".repeat(d), "}".repeat(d)); + assert!(matches!(run(&frac(32)), Outcome::Laid(_)), "32 macro-built fractions lay out"); + assert_eq!(run(&frac(64)), Outcome::Rejected("Recursion limit exceeded".into()), "64 macro-built fractions"); + // The wrapper's text count sees no nesting in the first source, so the parser's guard is the + // one that holds there too. + assert!(gridcraft_math::layout(&built(64)).is_err()); +} + +#[test] +fn macro_bombs_and_huge_dimensions_stop() { + for bomb in [ + "\\def\\a{\\a\\a}\\a", + "\\def\\a{xxxxxxxxxx}\\def\\b{\\a\\a\\a\\a\\a\\a\\a\\a\\a\\a}\\def\\c{\\b\\b\\b\\b\\b\\b\\b\\b\\b\\b}\\def\\d{\\c\\c\\c\\c\\c\\c\\c\\c\\c\\c}\\d", + ] { + assert!(matches!(run(bomb), Outcome::Rejected(m) if m.contains("Too many expansions"))); + } + // Absurd sizes lay out (the wrapper in this crate caps them); NaN is refused. + for s in ["\\kern99999999em x", "\\rule{1e308em}{1e308em}", "\\hspace{-99999em}x"] { + assert!(matches!(run(s), Outcome::Laid(_)), "{s}"); + } + assert!(matches!(run("\\rule{nanem}{1em}"), Outcome::Rejected(_))); + assert!(matches!(run(&"x+".repeat(2048)), Outcome::Laid(4096))); +} + +#[test] +fn editor_syntax_is_covered() { + let missing: Vec<(&str, Outcome)> = COVERAGE.iter().map(|s| (*s, run(s))).filter(|(_, o)| !matches!(o, Outcome::Laid(n) if *n > 0)).collect(); + assert!(missing.is_empty(), "unsupported: {missing:?}"); +} + +/// Whether every number in `list` is finite and within the caps the wrapper promises. +fn within_caps(list: &gridcraft_math::MathList) -> bool { + use gridcraft_math::{Item, MAX_EM, MAX_ITEMS, PathCmd}; + let ok = |v: f32| v.is_finite() && v.abs() <= MAX_EM; + let path_ok = |c: &PathCmd| match *c { + PathCmd::MoveTo(a, b) | PathCmd::LineTo(a, b) => ok(a) && ok(b), + PathCmd::QuadTo(a, b, c, d) => [a, b, c, d].into_iter().all(ok), + PathCmd::CubicTo(a, b, c, d, e, f) => [a, b, c, d, e, f].into_iter().all(ok), + PathCmd::Close => true, + }; + list.items.len() <= MAX_ITEMS + && [list.width, list.height, list.depth].into_iter().all(ok) + && list.items.iter().all(|i| match i { + Item::Glyph(g) => [g.x, g.y, g.size, g.width, g.height, g.depth].into_iter().all(ok), + Item::Rule(r) => [r.x, r.y, r.width, r.height].into_iter().all(ok), + Item::Path(p) => ok(p.x) && ok(p.y) && p.commands.iter().all(path_ok), + }) +} + +#[test] +fn the_guarded_entry_points_survive_hostile_input() { + // What every caller (commands, the editor's preview, the xlsx writer) reaches: `layout`, + // `check` and `to_omml`, with their caps. Random token soup, mutations of valid input, and + // inputs at and past the caps; none may panic, a layout stays within its caps, and an error's + // span stays on char boundaries inside the source. + let mut rng = Rng(0x6a09_e667_f3bc_c909); + let mut inputs: Vec = Vec::new(); + for _ in 0..1500 { + let mut s = String::new(); + for _ in 0..1 + rng.below(32) { + match rng.below(12) { + 0 => s.push(char::from_u32(rng.below(0x11_0000) as u32).unwrap_or('\u{FFFD}')), + 1 => s.push_str(rng.pick(COVERAGE)), + _ => s.push_str(rng.pick(TOKENS)), + } + } + inputs.push(s); + } + for (open, close) in [("{", "}"), ("\\left(", "\\right)"), ("\\begin{matrix}", "\\end{matrix}"), ("\\sqrt{", "}")] { + for d in [31, 32, 33, 500] { + inputs.push(format!("{}x{}", open.repeat(d), close.repeat(d))); + } + } + let cap = gridcraft_math::MAX_SOURCE_BYTES; + inputs.push("x".repeat(cap)); + inputs.push("x".repeat(cap + 1)); + inputs.push(format!("{}\u{e9}", "x".repeat(cap - 1))); + inputs.push("\\rule{1e308em}{1e308em}".into()); + inputs.push("\\def\\a{\\a\\a}\\a".into()); + let mut failures = Vec::new(); + for s in &inputs { + let r = catch_unwind(AssertUnwindSafe(|| { + let laid = gridcraft_math::layout(s); + let checked = gridcraft_math::check(s); + let omml = gridcraft_math::to_omml(s, 11.0, None); + (laid, checked, omml) + })); + let Ok((laid, checked, omml)) = r else { + failures.push(format!("panicked: {s:?}")); + continue; + }; + if let Ok(list) = &laid + && !within_caps(list) + { + failures.push(format!("outside the caps: {s:?}")); + } + for e in [laid.as_ref().err(), checked.as_ref().err(), omml.as_ref().err()].into_iter().flatten() { + if let Some(span) = &e.span + && !(span.start <= span.end && span.end <= s.len() && s.is_char_boundary(span.start) && s.is_char_boundary(span.end)) + { + failures.push(format!("span {span:?} off the source: {s:?}")); + } + } + if laid.is_ok() && checked.is_err() { + failures.push(format!("lays out but does not check: {s:?}")); + } + } + assert!(failures.is_empty(), "{failures:#?}"); +} + +#[test] +#[allow(clippy::disallowed_methods)] // tests run only off wasm, where the clock exists +fn pathological_source_at_the_cap_finishes_in_bounded_time() { + // Sources of exactly `MAX_SOURCE_BYTES` built to make a parser or a regular expression work + // hardest: groups and delimiters nested to the depth cap, long runs of backslashes, braces and + // scripts, a `(a+)+b` shape, wide and tall environments, and chemistry. `\ce` and `\pu` are + // the one place RaTeX runs regular expressions over the source (its mhchem port uses + // `fancy-regex`, which stops a match after a million backtracking steps); the rest of the + // parser is hand written. Each source lays out, checks and converts, or is refused, well + // inside the bound (the slowest, `\ce{+-+-...}`, takes 0.8 s in a debug build on an Apple M + // series machine; catastrophic backtracking would take minutes), so a regression in RaTeX or + // its dependencies shows up here without making a slow CI runner fail. + use gridcraft_math::{MAX_DEPTH, MAX_SOURCE_BYTES, check, layout, to_omml}; + use std::time::{Duration, Instant}; + let fill = |head: &str, body: &str, tail: &str| { + let room = MAX_SOURCE_BYTES.saturating_sub(head.len() + tail.len()); + let mut s = String::from(head); + while s.len() + body.len() <= head.len() + room { + s.push_str(body); + } + while s.len() < MAX_SOURCE_BYTES - tail.len() { + s.push(' '); + } + s.push_str(tail); + assert_eq!(s.len(), MAX_SOURCE_BYTES); + s + }; + let deep = |open: &str, close: &str| fill(&open.repeat(MAX_DEPTH), "x", &close.repeat(MAX_DEPTH)); + let sources = [ + deep("{", "}"), + deep("\\left(", "\\right)"), + deep("\\frac{", "}{1}"), + deep("\\sqrt{", "}"), + deep("x^{", "}"), + deep("\\hat{", "}"), + deep("\\text{", "}"), + deep("\\begin{matrix}", "\\end{matrix}"), + fill("", "\\\\", ""), + fill("", "\\{", ""), + fill("", "{}", ""), + fill("", "(a+)+", "b"), + fill("", "x^", "x"), + fill("", "'", ""), + fill("", "\\,", ""), + fill("", "&", ""), + fill("\\begin{matrix}", "&", "\\end{matrix}"), + fill("\\begin{aligned}", "\\\\", "\\end{aligned}"), + fill("\\begin{cases}", "x&\\\\", "\\end{cases}"), + fill("", "\\def\\a{x}", ""), + fill("", "\\color{red}", ""), + fill("", "\u{0301}", ""), + fill("", "\\mathbb{R}", ""), + fill("", "\\operatorname{f}", ""), + fill("\\ce{", "1", "}"), + fill("\\ce{", "0.", "}"), + fill("\\ce{", "(a", "}"), + fill("\\ce{", "->", "}"), + fill("\\ce{", "^_", "}"), + fill("\\ce{", "$", "}"), + fill("\\ce{", "H2O ", "}"), + fill("\\ce{", "\\ce{", "}"), + fill("\\ce{", "+-", "}"), + fill("\\ce{", "1/2", "}"), + fill("\\pu{", "+-", "}"), + fill("\\pu{", "1e", "}"), + fill("\\pu{", "10^", "}"), + fill("\\pu{", "kJ//", "}"), + ]; + let bound = Duration::from_secs(10); + let mut slow = Vec::new(); + for s in &sources { + let start = Instant::now(); + let r = catch_unwind(AssertUnwindSafe(|| (layout(s).is_ok(), check(s).is_ok(), to_omml(s, 11.0, None).is_ok()))); + let took = start.elapsed(); + let head: String = s.chars().take(24).collect(); + match r { + Ok(_) if took <= bound => {} + Ok(_) => slow.push(format!("{head:?}: {took:?}")), + Err(_) => slow.push(format!("{head:?}: panicked")), + } + } + assert!(slow.is_empty(), "over {bound:?}: {slow:#?}"); +} diff --git a/crates/model/Cargo.toml b/crates/model/Cargo.toml index d7d7fff4d..ff15c6795 100644 --- a/crates/model/Cargo.toml +++ b/crates/model/Cargo.toml @@ -12,6 +12,7 @@ workspace = true gridcraft-core = { workspace = true } sha2 = { workspace = true } gridcraft-formula = { workspace = true } +gridcraft-math = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } diff --git a/crates/model/src/features.rs b/crates/model/src/features.rs index db2b83dd5..8c89e47af 100644 --- a/crates/model/src/features.rs +++ b/crates/model/src/features.rs @@ -631,6 +631,9 @@ pub enum ShapeKind { /// A freehand ink stroke; `Shape::text` holds the points as `x,y;x,y;…` relative to the /// anchor (sheet points), `Shape::line` the ink colour and `fill` unused. Ink, + /// An equation: `Shape::text` holds its LaTeX source, `Shape::line` its ink colour + /// (`Color::Auto` for the sheet's text colour) and `Shape::math` its cached layout. + Equation, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -644,10 +647,103 @@ pub struct Shape { /// Outline visibility and text formatting (DrawingML `a:ln`, `a:bodyPr`, `a:pPr`, `a:rPr`). #[serde(default)] pub format: ShapeFormat, + /// An equation's layout, rebuilt from `text` and never saved. + #[serde(skip)] + pub math: EquationCache, +} + +/// The laid-out form of an equation's LaTeX source. It is derived data: rebuilt by +/// [`EquationCache::refresh`] whenever the source changes, never saved, and ignored when shapes +/// are compared. Painters read it and never lay equations out themselves, so nothing in a paint +/// path can reach the layout engine. +/// +/// An entry answers only for the source it was built from, compared in full: a hash match alone +/// is not enough, so two sources whose hashes collide never share a layout. A source over +/// [`gridcraft_math::MAX_SOURCE_BYTES`] never lays out, so [`EquationCache::get`] answers for it +/// by its length alone, with [`gridcraft_math::SOURCE_TOO_LONG`], and an entry built from one holds +/// neither the source nor its hash: a painter asking every frame about a source of megabytes read +/// from a file does no work on it. +#[derive(Clone, Debug, Default)] +pub struct EquationCache { + /// A hash of the source the entry was built from (a quick first check). + key: u64, + /// The source's length in bytes. + len: usize, + /// The source itself, when it is within the length cap. + source: Option>, + layout: Option, gridcraft_math::MathError>>, +} + +impl PartialEq for EquationCache { + fn eq(&self, _: &Self) -> bool { + true + } +} + +fn source_key(src: &str) -> u64 { + use std::hash::{Hash, Hasher}; + #[cfg(test)] + equation_cache_tests::HASHES.with(|h| h.set(h.get() + 1)); + let mut h = std::collections::hash_map::DefaultHasher::new(); + src.hash(&mut h); + h.finish() +} + +impl EquationCache { + /// Whether this entry was built from `src`. Over the cap the length is the match (an entry for + /// such a source is always the same error); within it, the hash and then the source itself. + fn holds(&self, src: &str) -> bool { + if self.layout.is_none() || self.len != src.len() { + return false; + } + if src.len() > gridcraft_math::MAX_SOURCE_BYTES { + return self.source.is_none(); + } + self.key == source_key(src) && self.source.as_deref() == Some(src) + } + + /// Makes this the entry for `src`, with `layout`. A source over the cap is neither copied nor + /// hashed. + fn store(&mut self, src: &str, layout: Result, gridcraft_math::MathError>) { + let within = src.len() <= gridcraft_math::MAX_SOURCE_BYTES; + self.key = if within { source_key(src) } else { 0 }; + self.len = src.len(); + self.source = within.then(|| std::sync::Arc::from(src)); + self.layout = Some(layout); + } + + /// The layout of `src`, or why it has none; `None` until [`EquationCache::refresh`] has run + /// for this source. A source over [`gridcraft_math::MAX_SOURCE_BYTES`] is answered at once, + /// whatever the entry holds, by its length alone. + pub fn get(&self, src: &str) -> Option> { + if src.len() > gridcraft_math::MAX_SOURCE_BYTES { + return Some(Err(&*gridcraft_math::SOURCE_TOO_LONG)); + } + if !self.holds(src) { + return None; + } + self.layout.as_ref().map(|r| r.as_ref().map(|l| l.as_ref())) + } + + /// Lays `src` out again unless the cache already holds its layout. True when it did. + pub fn refresh(&mut self, src: &str) -> bool { + if self.holds(src) { + return false; + } + self.store(src, gridcraft_math::layout(src).map(std::sync::Arc::new)); + true + } + + /// Records that `src` was not laid out, and why, without running the layout engine. + pub fn refuse(&mut self, src: &str, message: &str) { + self.store(src, Err(gridcraft_math::MathError { span: None, message: message.to_string() })); + } } /// The kinds of floating object a sheet holds. Commands name an object by kind and /// workbook-unique id (`{kind: "chart", id}`); [`ObjKind::name`] is that parameter's spelling. +/// Equations are stored as shapes ([`ShapeKind::Equation`]) but select, open and delete as their +/// own kind. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum ObjKind { @@ -655,10 +751,11 @@ pub enum ObjKind { Image, Shape, Slicer, + Equation, } impl ObjKind { - pub const ALL: [ObjKind; 4] = [ObjKind::Chart, ObjKind::Image, ObjKind::Shape, ObjKind::Slicer]; + pub const ALL: [ObjKind; 5] = [ObjKind::Chart, ObjKind::Image, ObjKind::Shape, ObjKind::Slicer, ObjKind::Equation]; /// The name used in command parameters. pub fn name(self) -> &'static str { @@ -667,6 +764,7 @@ impl ObjKind { ObjKind::Image => "image", ObjKind::Shape => "shape", ObjKind::Slicer => "slicer", + ObjKind::Equation => "equation", } } @@ -822,3 +920,75 @@ mod trendline_forecast_tests { assert_eq!(Trendline::clamp_forecast(1e300), Trendline::MAX_FORECAST); } } + +#[cfg(test)] +mod equation_cache_tests { + use super::{EquationCache, source_key}; + + thread_local! { + /// How many sources `source_key` has hashed on this thread. + pub(super) static HASHES: std::cell::Cell = const { std::cell::Cell::new(0) }; + } + + fn glyphs(c: &EquationCache, src: &str) -> Option { + let list = c.get(src)?.ok()?; + Some(list.items.iter().filter_map(|i| if let gridcraft_math::Item::Glyph(g) = i { Some(g.ch) } else { None }).collect()) + } + + #[test] + fn a_hash_collision_never_returns_another_sources_layout() { + let (a, b) = ("x^2", "y^3"); + let mut c = EquationCache::default(); + assert!(c.refresh(a)); + assert_eq!(glyphs(&c, a).as_deref(), Some("x2")); + // Forge a collision: the entry claims `b`'s hash; `b` has `a`'s length, so only the + // full comparison tells them apart. + c.key = source_key(b); + assert!(c.get(b).is_none(), "`b` is a miss, not `a`'s layout"); + assert!(c.refresh(b), "`b` is laid out afresh"); + assert_eq!(glyphs(&c, b).as_deref(), Some("y3")); + assert!(c.get(a).is_none(), "the entry is `b`'s now"); + // A refusal does not answer for a colliding source either. + c.refuse(a, "not laid out"); + c.key = source_key(b); + assert!(c.get(b).is_none()); + } + + #[test] + fn sources_over_the_cap_are_not_copied_hashed_or_laid_out() { + let long = "x".repeat(gridcraft_math::MAX_SOURCE_BYTES + 1); + let mut c = EquationCache::default(); + HASHES.with(|h| h.set(0)); + assert!(matches!(c.get(&long), Some(Err(e)) if e.message.contains("4096")), "answered before any refresh"); + assert!(c.refresh(&long)); + assert!(c.source.is_none(), "an over-cap source is not kept"); + assert_eq!(c.key, 0, "nor hashed"); + assert!(matches!(c.get(&long), Some(Err(_)))); + assert!(!c.refresh(&long), "still cached"); + let other = "y".repeat(gridcraft_math::MAX_SOURCE_BYTES + 1); + assert!(matches!(c.get(&other), Some(Err(_))), "another over-cap source gets the same answer"); + assert_eq!(HASHES.with(|h| h.get()), 0, "nothing over the cap is hashed"); + // Within the cap the lookup is unchanged: a hash, then the source compared in full. + assert!(c.refresh("x")); + assert!(c.get("x").is_some_and(|r| r.is_ok())); + assert!(HASHES.with(|h| h.get()) >= 2); + } + + #[test] + #[allow(clippy::disallowed_methods)] // tests run only off wasm, where the clock exists + fn a_source_of_a_megabyte_is_looked_up_without_being_read() { + // The sheet painter asks the cache every frame and the PDF export every draw; a source + // read from a file can be megabytes, and the lookup must not scan it each time. + let big = "l".repeat(1 << 20); + let mut c = EquationCache::default(); + c.refuse(&big, "budget"); + HASHES.with(|h| h.set(0)); + let start = std::time::Instant::now(); + for _ in 0..1000 { + assert!(matches!(c.get(&big), Some(Err(_)))); + } + let took = start.elapsed(); + assert_eq!(HASHES.with(|h| h.get()), 0, "a thousand lookups read nothing"); + assert!(took < std::time::Duration::from_millis(500), "{took:?}"); + } +} diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index b9b67f519..a6a27faca 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -29,7 +29,10 @@ pub use style::{ Alignment, BorderLine, BorderStyle, Borders, Color, Fill, Font, HAlign, NumFmt, PatternType, Style, StyleId, StyleTable, Theme, Underline, VAlign, VertAlign, }; -pub use workbook::{CalcMode, CalcSettings, DefinedName, DocProps, PhoneticProps, PhoneticRun, PhoneticString, ShowObjects, Workbook, WorkbookView}; +pub use workbook::{ + CalcMode, CalcSettings, DefinedName, DocProps, EQUATION_BUDGET_MESSAGE, MAX_EQUATION_LAYOUT_BYTES, PhoneticProps, PhoneticRun, PhoneticString, + ShowObjects, Workbook, WorkbookView, +}; /// Font used for new workbooks. Only a name: renderers substitute an available open font /// (e.g. the metric-compatible Carlito) when it isn't installed. diff --git a/crates/model/src/sheet.rs b/crates/model/src/sheet.rs index e295515ee..3ee16a64f 100644 --- a/crates/model/src/sheet.rs +++ b/crates/model/src/sheet.rs @@ -8,8 +8,8 @@ use serde::{Deserialize, Serialize}; use crate::cell::{Cell, CellPicture}; use crate::features::{ - Anchor, AutoFilter, Chart, Comment, CondFormat, Hyperlink, Image, LineInfo, ObjKind, ObjRef, PivotTable, PrintSettings, Shape, SheetProtection, - Slicer, Sparkline, Table, Validation, + Anchor, AutoFilter, Chart, Comment, CondFormat, Hyperlink, Image, LineInfo, ObjKind, ObjRef, PivotTable, PrintSettings, Shape, ShapeKind, + SheetProtection, Slicer, Sparkline, Table, Validation, }; use crate::store::CellStore; use crate::style::{Color, StyleId}; @@ -244,8 +244,8 @@ impl Sheet { ObjKind::Chart } else if self.images.iter().any(|o| o.id == id) { ObjKind::Image - } else if self.shapes.iter().any(|o| o.id == id) { - ObjKind::Shape + } else if let Some(s) = self.shapes.iter().find(|o| o.id == id) { + if s.kind == ShapeKind::Equation { ObjKind::Equation } else { ObjKind::Shape } } else if self.slicers.iter().any(|o| o.id == id) { ObjKind::Slicer } else { @@ -254,7 +254,8 @@ impl Sheet { Some(ObjRef::new(kind, id)) } - /// Whether the floating object `o` exists on this sheet. + /// Whether the floating object `o` exists on this sheet. `Shape` accepts any shape (an + /// equation included); `Equation` only an equation. pub fn has_object(&self, o: ObjRef) -> bool { self.object_anchor(o).is_some() } @@ -266,6 +267,7 @@ impl Sheet { ObjKind::Image => self.images.iter().find(|c| c.id == o.id).map(|c| c.anchor), ObjKind::Shape => self.shapes.iter().find(|c| c.id == o.id).map(|c| c.anchor), ObjKind::Slicer => self.slicers.iter().find(|c| c.id == o.id).map(|c| c.anchor), + ObjKind::Equation => self.shapes.iter().find(|c| c.id == o.id && c.kind == ShapeKind::Equation).map(|c| c.anchor), } } @@ -275,6 +277,7 @@ impl Sheet { ObjKind::Image => self.images.iter_mut().find(|c| c.id == o.id).map(|c| &mut c.anchor), ObjKind::Shape => self.shapes.iter_mut().find(|c| c.id == o.id).map(|c| &mut c.anchor), ObjKind::Slicer => self.slicers.iter_mut().find(|c| c.id == o.id).map(|c| &mut c.anchor), + ObjKind::Equation => self.shapes.iter_mut().find(|c| c.id == o.id && c.kind == ShapeKind::Equation).map(|c| &mut c.anchor), } } @@ -290,6 +293,7 @@ impl Sheet { ObjKind::Image => take(&mut self.images, |c| c.id == o.id), ObjKind::Shape => take(&mut self.shapes, |c| c.id == o.id), ObjKind::Slicer => take(&mut self.slicers, |c| c.id == o.id), + ObjKind::Equation => take(&mut self.shapes, |c| c.id == o.id && c.kind == ShapeKind::Equation), } } @@ -738,12 +742,13 @@ mod tests { assert_eq!(s.col_left(2), 128.0); } - use crate::features::{ChartKind, LegendPos, ShapeFormat, ShapeKind}; + use crate::features::{ChartKind, EquationCache, LegendPos, ShapeFormat, ShapeKind}; /// Adds a floating object of `kind` with id `id` at `anchor`. The match is exhaustive on /// purpose: a new kind of floating object does not compile here until this test covers it, /// and the counts in `object_anchor_accessors_visit_the_same_objects_in_the_same_order` - /// then catch an accessor that was not taught about it. + /// then catch an accessor that was not taught about it. An equation is a shape of + /// `ShapeKind::Equation`, so it lives in `shapes` and is visited among them. fn push_object(s: &mut Sheet, kind: ObjKind, id: u32, anchor: Anchor) { match kind { ObjKind::Chart => s.charts.push(Chart { @@ -764,14 +769,15 @@ mod tests { pivot: None, }), ObjKind::Image => s.images.push(Image { id, anchor, data: Arc::new(vec![]), mime: String::new(), alt: String::new() }), - ObjKind::Shape => s.shapes.push(Shape { + ObjKind::Shape | ObjKind::Equation => s.shapes.push(Shape { id, - kind: ShapeKind::default(), + kind: if kind == ObjKind::Equation { ShapeKind::Equation } else { ShapeKind::default() }, anchor, fill: Color::default(), line: Color::default(), text: String::new(), format: ShapeFormat::default(), + math: EquationCache::default(), }), ObjKind::Slicer => { s.slicers.push(Slicer { id, table: String::new(), column: String::new(), caption: String::new(), anchor, selected: None, columns: 1 }) @@ -796,7 +802,9 @@ mod tests { (ObjKind::Chart, 9), (ObjKind::Slicer, 1), (ObjKind::Image, 4), + (ObjKind::Equation, 6), (ObjKind::Shape, 12), + (ObjKind::Equation, 8), ]; for &(kind, id) in &inserted { push_object(&mut s, kind, id, Anchor { cell: CellRef::new(id, 0), ..Default::default() }); @@ -813,9 +821,12 @@ mod tests { let marks: Vec = s.object_anchors().map(|a| a.dx).collect(); assert_eq!(marks, (0..per_kind).map(|i| i as f32).collect::>()); // The sequence is the documented one (charts, images, shapes, slicers, each in insertion - // order) and every object appears in it once. - let documented: Vec = - ObjKind::ALL.iter().flat_map(|&k| inserted.iter().filter(move |(kind, _)| *kind == k).map(|&(_, id)| id)).collect(); + // order; equations are shapes) and every object appears in it once. + let collection = |k: ObjKind| if k == ObjKind::Equation { ObjKind::Shape } else { k }; + let documented: Vec = [ObjKind::Chart, ObjKind::Image, ObjKind::Shape, ObjKind::Slicer] + .iter() + .flat_map(|&k| inserted.iter().filter(move |(kind, _)| collection(*kind) == k).map(|&(_, id)| id)) + .collect(); let visited: Vec = s.object_anchors().map(|a| a.cell.row).collect(); assert_eq!(visited, documented); // The mark written through the bulk accessor landed on the object `object_anchor` finds diff --git a/crates/model/src/workbook.rs b/crates/model/src/workbook.rs index c1a7f2913..2d16b46b5 100644 --- a/crates/model/src/workbook.rs +++ b/crates/model/src/workbook.rs @@ -11,6 +11,13 @@ use crate::cell::Formula; use crate::sheet::Sheet; use crate::style::{StyleTable, Theme}; +/// Most equation source, in bytes, one [`Workbook::refresh_equations`] lays out: about 2,500 +/// typical equations, or a few seconds of the slowest 4 KB sources. +pub const MAX_EQUATION_LAYOUT_BYTES: usize = 128 * 1024; + +/// Why an equation past [`MAX_EQUATION_LAYOUT_BYTES`] has no layout. +pub const EQUATION_BUDGET_MESSAGE: &str = "This workbook has more equations than are laid out at once; edit this equation to lay it out."; + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum CalcMode { #[default] @@ -463,6 +470,30 @@ impl Workbook { } None } + /// Rebuilds the cached layout of every equation whose source changed (or was never laid + /// out, as after loading a file). One call lays out at most [`MAX_EQUATION_LAYOUT_BYTES`] of + /// source: one equation can take a tenth of a second to lay out, so a file with thousands of + /// them would otherwise hold the app for minutes. Equations past the budget get an error + /// instead (they show their source; editing one lays it out). + pub fn refresh_equations(&mut self) { + let stale = |s: &crate::Shape| s.kind == crate::ShapeKind::Equation && s.math.get(&s.text).is_none(); + let mut budget = MAX_EQUATION_LAYOUT_BYTES; + // Only sheets with a stale equation are copied (sheets are shared with undo snapshots). + for sh in self.sheets.iter_mut().filter(|sh| sh.shapes.iter().any(stale)) { + for s in Arc::make_mut(sh).shapes.iter_mut().filter(|s| stale(s)) { + // A source over the length cap never gets here: the cache answers for it without + // a lookup. An empty source still costs a call. + let cost = s.text.len().max(1); + if cost > budget { + s.math.refuse(&s.text, EQUATION_BUDGET_MESSAGE); + continue; + } + budget -= cost; + s.math.refresh(&s.text); + } + } + } + /// A free id for a new chart, picture, shape, table or slicer: one more than the largest in /// use, or, when that is `u32::MAX` (a file can carry any id), the lowest free one. pub fn next_object_id(&self) -> u32 { diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 5270d4311..68983828f 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -3,7 +3,8 @@ //! - The 14 standard fonts (no embedding) with WinAnsiEncoding, plus approximate width tables //! ([`text_width`]) so callers can measure, align and wrap text. Characters outside //! WinAnsiEncoding (CJK and other scripts) are set in a predefined, non-embedded Adobe-GB1 CID -//! font with the `UniGB-UTF16-H` CMap instead of being replaced by `?`. +//! font with the `UniGB-UTF16-H` CMap instead of being replaced by `?`. Symbol uses its own +//! built-in encoding ([`symbol_code`]), for Greek letters and mathematical operators. //! - Vector drawing: rectangles, lines (solid or dashed), polygons, circles (Bézier), clipping. //! - RGBA images as RGB image XObjects with a soft mask for alpha (run-length encoded). //! - Coordinates are points with a **top-left origin** (y grows down); text `y` is the baseline. @@ -14,6 +15,9 @@ #![forbid(unsafe_code)] mod metrics; +mod symbol; + +pub use symbol::{symbol_code, symbol_width}; use std::fmt::Write as _; @@ -48,7 +52,7 @@ impl Rect { } } -/// The standard 14 fonts minus Symbol and ZapfDingbats. +/// The standard 14 fonts minus ZapfDingbats. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord)] pub enum Font { #[default] @@ -64,8 +68,13 @@ pub enum Font { CourierBold, CourierOblique, CourierBoldOblique, + /// Greek letters and mathematical symbols, in the font's own encoding. + Symbol, } +/// How many fonts a document can use. +const FONT_COUNT: usize = 13; + /// A font family for [`Font::styled`]. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] pub enum Family { @@ -76,7 +85,7 @@ pub enum Family { } impl Font { - pub const ALL: [Font; 12] = [ + pub const ALL: [Font; FONT_COUNT] = [ Font::Helvetica, Font::HelveticaBold, Font::HelveticaOblique, @@ -89,6 +98,7 @@ impl Font { Font::CourierBold, Font::CourierOblique, Font::CourierBoldOblique, + Font::Symbol, ]; /// PostScript base font name. @@ -106,6 +116,7 @@ impl Font { Font::CourierBold => "Courier-Bold", Font::CourierOblique => "Courier-Oblique", Font::CourierBoldOblique => "Courier-BoldOblique", + Font::Symbol => "Symbol", } } @@ -146,8 +157,20 @@ impl Font { } } + /// Whether this font can show `c` by itself (in its own encoding; for the text faces, + /// without the CID fallback). + pub fn can_encode(self, c: char) -> bool { + match self { + Font::Symbol => symbol_code(c).is_some(), + _ => c == '?' || win_ansi(c) != b'?', + } + } + /// Advance width of a character in 1/1000 em. pub fn char_width(self, c: char) -> u16 { + if self == Font::Symbol { + return symbol_code(c).and_then(symbol_width).unwrap_or(500); + } let table: Option<&[u16; 95]> = match self { Font::Helvetica | Font::HelveticaOblique => Some(&metrics::HELVETICA), Font::HelveticaBold | Font::HelveticaBoldOblique => Some(&metrics::HELVETICA_BOLD), @@ -249,10 +272,16 @@ fn pdf_utf16_string(text: &str) -> String { /// A PDF literal string `( … )` for `text` in WinAnsiEncoding. fn pdf_string(text: &str) -> String { + pdf_string_in(text, false) +} + +/// A PDF literal string for `text` in WinAnsiEncoding, or in Symbol's encoding (where a +/// character Symbol lacks becomes `?`). +fn pdf_string_in(text: &str, symbol: bool) -> String { let mut s = String::with_capacity(text.len() + 2); s.push('('); for c in text.chars() { - let b = win_ansi(c); + let b = if symbol { symbol_code(c).unwrap_or(b'?') } else { win_ansi(c) }; match b { b'(' | b')' | b'\\' => { s.push('\\'); @@ -586,6 +615,16 @@ impl Page { let (sn, cs) = if angle.is_finite() { angle.sin_cos() } else { (0.0, 1.0) }; // Clockwise in y-down = counter-clockwise negated in PDF's y-up space. let mut s = format!("BT\n{}\n{} {} {} {} {} {} Tm\n", rgb_op(color, false), n(cs), n(-sn), n(sn), n(cs), n(origin.x), n(self.y(origin.y))); + // Symbol text is one run in Symbol's own encoding (the CID font has no maths symbols). + if font == Font::Symbol { + if let Some(f) = self.fonts.get_mut(fi) { + *f = true; + } + let _ = writeln!(s, "/F{} {} Tf\n{} Tj", fi + 1, size, pdf_string_in(text, true)); + s.push_str("ET"); + self.op(&s); + return; + } // Runs of WinAnsi text use `font`; runs it cannot encode use the CID font. Each `Tj` // advances the text position, so the runs follow each other on the baseline. let mut rest = text; diff --git a/crates/pdf/src/symbol.rs b/crates/pdf/src/symbol.rs new file mode 100644 index 000000000..c8a1edadd --- /dev/null +++ b/crates/pdf/src/symbol.rs @@ -0,0 +1,239 @@ +//! The standard Symbol font: its built-in encoding and advance widths. +//! +//! Source: the character codes and glyph advance widths ("C" and "WX" values) of Adobe's Core 14 +//! AFM metrics file for Symbol (`Symbol.afm`, version 001.008), and the Unicode values of its glyph +//! names from Unicode, Inc.'s `symbol.txt` ("Adobe Symbol Encoding to Unicode", table version +//! 1.0). Adobe distributes the Core 14 metrics with permission to use, copy and distribute them +//! for any purpose (the notice is quoted in `metrics.rs`); Unicode, Inc. grants the right to use +//! the information in its file in products supporting the Unicode Standard (its notice is +//! recorded in `ATTRIBUTION.md`). Only the numbers are reproduced here, typed in from the public +//! tables; neither file is included. The stretchable +//! bracket, brace and integral pieces (codes 0xE6 to 0xEF and 0xF3 to 0xFE) and the sans-serif +//! duplicates of the registered, copyright and trademark signs are left out of the Unicode map: +//! they have no Unicode counterpart of their own. + +/// The Symbol-encoding code for `c`, or `None` when Symbol has no such glyph. +pub fn symbol_code(c: char) -> Option { + let u = c as u32; + // Characters Symbol encodes at their ASCII code. + if matches!( + c, + ' ' | '!' | '#' | '%' | '&' | '(' | ')' | '+' | ',' | '.' | '/' | '0' + ..='9' | ':' | ';' | '<' | '=' | '>' | '?' | '[' | ']' | '_' | '{' | '|' | '}' + ) { + return Some(u as u8); + } + Some(match c { + '\u{2200}' => 0x22, // for all + '\u{2203}' => 0x24, // there exists + '\u{220B}' => 0x27, // such that (contains as member) + '\u{2217}' | '*' => 0x2A, + '\u{2212}' | '-' => 0x2D, // minus + '\u{2245}' => 0x40, // approximately equal (congruent) + '\u{0391}' => 0x41, + '\u{0392}' => 0x42, + '\u{03A7}' => 0x43, + '\u{0394}' | '\u{2206}' => 0x44, + '\u{0395}' => 0x45, + '\u{03A6}' => 0x46, + '\u{0393}' => 0x47, + '\u{0397}' => 0x48, + '\u{0399}' => 0x49, + '\u{03D1}' => 0x4A, // theta1 + '\u{039A}' => 0x4B, + '\u{039B}' => 0x4C, + '\u{039C}' => 0x4D, + '\u{039D}' => 0x4E, + '\u{039F}' => 0x4F, + '\u{03A0}' => 0x50, + '\u{0398}' => 0x51, + '\u{03A1}' => 0x52, + '\u{03A3}' => 0x53, + '\u{03A4}' => 0x54, + '\u{03A5}' => 0x55, + '\u{03C2}' => 0x56, // final sigma (sigma1) + '\u{03A9}' | '\u{2126}' => 0x57, + '\u{039E}' => 0x58, + '\u{03A8}' => 0x59, + '\u{0396}' => 0x5A, + '\u{2234}' => 0x5C, // therefore + '\u{22A5}' => 0x5E, // perpendicular + '\u{03B1}' => 0x61, + '\u{03B2}' => 0x62, + '\u{03C7}' => 0x63, + '\u{03B4}' => 0x64, + '\u{03B5}' | '\u{03F5}' => 0x65, + '\u{03C6}' => 0x66, + '\u{03B3}' => 0x67, + '\u{03B7}' => 0x68, + '\u{03B9}' => 0x69, + '\u{03D5}' => 0x6A, // phi1 + '\u{03BA}' => 0x6B, + '\u{03BB}' => 0x6C, + '\u{03BC}' | '\u{00B5}' => 0x6D, + '\u{03BD}' => 0x6E, + '\u{03BF}' => 0x6F, + '\u{03C0}' => 0x70, + '\u{03B8}' => 0x71, + '\u{03C1}' => 0x72, + '\u{03C3}' => 0x73, + '\u{03C4}' => 0x74, + '\u{03C5}' => 0x75, + '\u{03D6}' => 0x76, // omega1 (pi symbol) + '\u{03C9}' => 0x77, + '\u{03BE}' => 0x78, + '\u{03C8}' => 0x79, + '\u{03B6}' => 0x7A, + '\u{223C}' | '~' => 0x7E, // similar + '\u{20AC}' => 0xA0, // euro + '\u{03D2}' => 0xA1, // Upsilon1 + '\u{2032}' => 0xA2, // minute (prime) + '\u{2264}' => 0xA3, + '\u{2044}' => 0xA4, // fraction slash + '\u{221E}' => 0xA5, + '\u{0192}' => 0xA6, + '\u{2663}' => 0xA7, + '\u{2666}' => 0xA8, + '\u{2665}' => 0xA9, + '\u{2660}' => 0xAA, + '\u{2194}' => 0xAB, + '\u{2190}' => 0xAC, + '\u{2191}' => 0xAD, + '\u{2192}' => 0xAE, + '\u{2193}' => 0xAF, + '\u{00B0}' => 0xB0, + '\u{00B1}' => 0xB1, + '\u{2033}' => 0xB2, // second (double prime) + '\u{2265}' => 0xB3, + '\u{00D7}' => 0xB4, + '\u{221D}' => 0xB5, + '\u{2202}' => 0xB6, + '\u{2022}' | '\u{2219}' => 0xB7, + '\u{00F7}' => 0xB8, + '\u{2260}' => 0xB9, + '\u{2261}' => 0xBA, + '\u{2248}' => 0xBB, + '\u{2026}' => 0xBC, + '\u{21B5}' => 0xBF, + '\u{2135}' => 0xC0, + '\u{2111}' => 0xC1, + '\u{211C}' => 0xC2, + '\u{2118}' => 0xC3, + '\u{2297}' => 0xC4, + '\u{2295}' => 0xC5, + '\u{2205}' => 0xC6, + '\u{2229}' => 0xC7, + '\u{222A}' => 0xC8, + '\u{2283}' => 0xC9, + '\u{2287}' => 0xCA, + '\u{2284}' => 0xCB, + '\u{2282}' => 0xCC, + '\u{2286}' => 0xCD, + '\u{2208}' => 0xCE, + '\u{2209}' => 0xCF, + '\u{2220}' => 0xD0, + '\u{2207}' => 0xD1, + '\u{00AE}' => 0xD2, + '\u{00A9}' => 0xD3, + '\u{2122}' => 0xD4, + '\u{220F}' => 0xD5, + '\u{221A}' => 0xD6, + '\u{22C5}' | '\u{00B7}' => 0xD7, // dot operator + '\u{00AC}' => 0xD8, + '\u{2227}' => 0xD9, + '\u{2228}' => 0xDA, + '\u{21D4}' => 0xDB, + '\u{21D0}' => 0xDC, + '\u{21D1}' => 0xDD, + '\u{21D2}' => 0xDE, + '\u{21D3}' => 0xDF, + '\u{25CA}' => 0xE0, + '\u{2329}' | '\u{27E8}' => 0xE1, // angle bracket left + '\u{2211}' => 0xE5, + '\u{232A}' | '\u{27E9}' => 0xF1, // angle bracket right + '\u{222B}' => 0xF2, + _ => return None, + }) +} + +/// Advance widths in 1/1000 em for codes 0x20..=0xFF (0 where Symbol has no glyph). +const WIDTHS: [u16; 224] = [ + // 0x20 + 250, 333, 713, 500, 549, 833, 778, 439, 333, 333, 500, 549, 250, 549, 250, 278, // + // 0x30 + 500, 500, 500, 500, 500, 500, 500, 500, 500, 500, 278, 278, 549, 549, 549, 444, // + // 0x40 + 549, 722, 667, 722, 612, 611, 763, 603, 722, 333, 631, 722, 686, 889, 722, 722, // + // 0x50 + 768, 741, 556, 592, 611, 690, 439, 768, 645, 795, 611, 333, 863, 333, 658, 500, // + // 0x60 + 500, 631, 549, 549, 494, 439, 521, 411, 603, 329, 603, 549, 549, 576, 521, 549, // + // 0x70 + 549, 521, 549, 603, 439, 576, 713, 686, 493, 686, 494, 480, 200, 480, 549, 0, // + // 0x80 + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // + // 0x90 + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // + // 0xA0 + 750, 620, 247, 549, 167, 713, 500, 753, 753, 753, 753, 1042, 987, 603, 987, 603, // + // 0xB0 + 400, 549, 411, 549, 549, 713, 494, 460, 549, 549, 549, 549, 1000, 603, 1000, 658, // + // 0xC0 + 823, 686, 795, 987, 768, 768, 823, 768, 768, 713, 713, 713, 713, 713, 713, 713, // + // 0xD0 + 768, 713, 790, 790, 890, 823, 549, 250, 713, 603, 603, 1042, 987, 603, 987, 603, // + // 0xE0 + 494, 329, 790, 790, 786, 713, 384, 384, 384, 384, 384, 384, 494, 494, 494, 494, // + // 0xF0 + 0, 329, 274, 686, 686, 686, 384, 384, 384, 384, 384, 384, 494, 494, 494, 0, // +]; + +/// The advance width of code `code` in 1/1000 em, or `None` for an empty slot. +pub fn symbol_width(code: u8) -> Option { + let w = *WIDTHS.get(usize::from(code).checked_sub(0x20)?)?; + (w > 0).then_some(w) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn maths_symbols_are_encoded_with_their_widths() { + for (c, code, w) in [ + ('α', 0x61, 631), + ('π', 0x70, 549), + ('Σ', 0x53, 592), + ('∑', 0xE5, 713), + ('∫', 0xF2, 274), + ('√', 0xD6, 549), + ('≤', 0xA3, 549), + ('≥', 0xB3, 549), + ('±', 0xB1, 549), + ('×', 0xB4, 549), + ('÷', 0xB8, 549), + ('∞', 0xA5, 713), + ('∂', 0xB6, 494), + ('−', 0x2D, 549), + ('=', 0x3D, 549), + ('2', 0x32, 500), + ] { + assert_eq!(symbol_code(c), Some(code), "{c}"); + assert_eq!(symbol_width(code), Some(w), "{c}"); + } + assert_eq!(symbol_code('A'), None, "Latin letters belong to the text faces"); + assert_eq!(symbol_code('ℝ'), None); + assert_eq!(symbol_width(0x7F), None); + assert_eq!(symbol_width(0x10), None); + assert_eq!(symbol_width(0xFF), None); + } + + #[test] + fn every_encoded_character_has_a_width() { + for u in 0..0x3000u32 { + if let Some(code) = char::from_u32(u).and_then(symbol_code) { + assert!(symbol_width(code).is_some(), "U+{u:04X} -> {code:#x}"); + } + } + } +} diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index bd1ad73a8..1e7a0ae70 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -332,3 +332,21 @@ fn cjk_text_is_not_replaced_by_question_marks() -> Result<(), Box> { assert_eq!(text_width(Font::Helvetica, 10.0, "签字"), 20.0); Ok(()) } + +#[test] +fn symbol_font_draws_maths_in_its_own_encoding() { + let mut doc = PdfDoc::new(); + let p = doc.add_page(200.0, 200.0); + p.text(10.0, 50.0, 12.0, Font::Symbol, [0, 0, 0], "\u{2211}\u{03B1}\u{2264}\u{221E}x"); + p.text(10.0, 80.0, 12.0, Font::TimesItalic, [0, 0, 0], "x"); + let pdf = doc.finish(); + let s = String::from_utf8_lossy(&pdf); + assert!(s.contains("/BaseFont /Symbol >>"), "Symbol has no WinAnsi encoding"); + assert!(s.contains("/BaseFont /Times-Italic /Encoding /WinAnsiEncoding")); + // The sum, alpha, less-or-equal and infinity codes; x is not in Symbol and becomes `?`. + assert!(s.contains("(\\345a\\243\\245?) Tj"), "{s}"); + check_xref(&pdf).unwrap(); + assert!(Font::Symbol.can_encode('\u{2211}') && !Font::Symbol.can_encode('x')); + assert!(Font::TimesRoman.can_encode('x') && !Font::TimesRoman.can_encode('\u{2211}')); + assert_eq!(text_width(Font::Symbol, 10.0, "\u{2211}"), 7.13); +} diff --git a/crates/ui-egui/src/chartview.rs b/crates/ui-egui/src/chartview.rs index efa19d3d7..5c51d087b 100644 --- a/crates/ui-egui/src/chartview.rs +++ b/crates/ui-egui/src/chartview.rs @@ -1,5 +1,5 @@ //! Floating objects on the sheet: charts (rendered by `gridcraft-chart` into primitives that -//! we paint with egui), pictures, shapes and slicers. +//! we paint with egui), pictures, shapes, equations and slicers. use egui::epaint::{PathShape, PathStroke}; use egui::{Align2, Color32, FontId, Painter, Pos2, Rect, Stroke, StrokeKind, pos2, vec2}; @@ -332,6 +332,10 @@ pub(crate) fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workboo } label = false; } + ShapeKind::Equation => { + crate::equation::paint_shape(p, r, sp, wb, geo.z); + label = false; + } ShapeKind::Line | ShapeKind::Arrow => { p.line_segment([r.left_top(), r.right_bottom()], Stroke::new(2.0, line)); if sp.kind == ShapeKind::Arrow { @@ -519,7 +523,7 @@ pub fn selected_handle(app: &SheetApp, geo: &Geo, sh: &Sheet, p: Pos2) -> Option } /// The topmost object under `p` and its rect: slicers paint on top, so they are hit first, then -/// charts, pictures and shapes, each from the top of its stack. Nothing is hit outside the cell +/// charts, pictures and shapes (equations included), each from the top of its stack. Nothing is hit outside the cell /// area or while objects are hidden. pub fn hit(app: &SheetApp, geo: &Geo, sh: &Sheet, p: Pos2) -> Option<(ObjRef, Rect)> { if !geo.cells.contains(p) || app.session.active().is_some_and(|d| objects_hidden(&d.wb)) { @@ -546,7 +550,8 @@ pub fn hit(app: &SheetApp, geo: &Geo, sh: &Sheet, p: Pos2) -> Option<(ObjRef, Re for s in sh.shapes.iter().rev() { let r = anchor_rect(geo, sh, &s.anchor); if r.expand(3.0).contains(p) { - return Some((ObjRef::new(ObjKind::Shape, s.id), r)); + let kind = if s.kind == ShapeKind::Equation { ObjKind::Equation } else { ObjKind::Shape }; + return Some((ObjRef::new(kind, s.id), r)); } } None diff --git a/crates/ui-egui/src/code_editor.rs b/crates/ui-egui/src/code_editor.rs index ee65b7ec8..53ac8ddbb 100644 --- a/crates/ui-egui/src/code_editor.rs +++ b/crates/ui-egui/src/code_editor.rs @@ -226,6 +226,45 @@ pub fn layout_job(text: &str, lang: Lang, font: &FontId, plain: egui::Color32, s job } +/// Underlines the byte range `mark` of `job` in `color` (an error's position), splitting the +/// sections it crosses. An empty range marks the character before it (the end of the text, for +/// an error about missing input). A range past the end of the text is clamped to it, and its +/// ends are moved out to char boundaries: the mark may come from an earlier version of the text +/// (the equation editor computes it before this frame's edit), and egui slices the text at every +/// section boundary. +pub fn underline(job: &mut egui::text::LayoutJob, mark: Range, color: egui::Color32) { + let text = job.text.as_str(); + let floor = |i: usize| (0..=i.min(text.len())).rev().find(|&b| text.is_char_boundary(b)).unwrap_or(0); + let ceil = |i: usize| (i.min(text.len())..=text.len()).find(|&b| text.is_char_boundary(b)).unwrap_or(text.len()); + let start = floor(mark.start); + let mut mark = start..ceil(mark.end).max(start); + if mark.is_empty() { + let start = text.get(..mark.start).and_then(|t| t.char_indices().next_back()).map_or(0, |(i, _)| i); + mark = start..mark.start.max(start + text.get(start..).and_then(|t| t.chars().next()).map_or(0, char::len_utf8)); + } + let mut out = Vec::with_capacity(job.sections.len() + 2); + for sec in job.sections.drain(..) { + let r = sec.byte_range.start.0..sec.byte_range.end.0; + let (a, b) = (mark.start.clamp(r.start, r.end), mark.end.clamp(r.start, r.end)); + for (piece, marked) in [(r.start..a, false), (a..b, true), (b..r.end, false)] { + if piece.is_empty() { + continue; + } + let mut s = sec.clone(); + s.byte_range = egui::text::ByteIndex(piece.start)..egui::text::ByteIndex(piece.end); + if marked { + s.format.underline = egui::Stroke::new(1.5, color); + s.format.color = color; + } + if s.byte_range.start.0 != r.start { + s.leading_space = 0.0; + } + out.push(s); + } + } + job.sections = out; +} + fn byte_of(text: &str, char_index: usize) -> usize { text.char_indices().nth(char_index).map(|(b, _)| b).unwrap_or(text.len()) } @@ -501,6 +540,11 @@ pub fn indent(text: &mut String, sel: (usize, usize), dedent: bool) -> Edit { /// The code editor. `max_lines` rows are shown before it scrolls; the scroll area follows the /// caret. pub fn code_editor(ui: &mut Ui, id: egui::Id, text: &mut String, lang: Lang, max_lines: usize) -> Response { + code_editor_marked(ui, id, text, lang, max_lines, None) +} + +/// [`code_editor`] with the byte range `mark` underlined in the error colour (see [`underline`]). +pub fn code_editor_marked(ui: &mut Ui, id: egui::Id, text: &mut String, lang: Lang, max_lines: usize, mark: Option>) -> Response { let t = Tokens::get(ui.ctx()); let font = font(); let row_h = ui.ctx().fonts_mut(|f| f.row_height(&font)); @@ -534,8 +578,12 @@ pub fn code_editor(ui: &mut Ui, id: egui::Id, text: &mut String, lang: Lang, max } let syntax = t.syntax; let plain = t.text; + let danger = t.danger; let mut layouter = |ui: &Ui, buf: &dyn egui::TextBuffer, wrap_width: f32| { - let job = layout_job(buf.as_str(), lang, &self::font(), plain, &syntax, wrap_width); + let mut job = layout_job(buf.as_str(), lang, &self::font(), plain, &syntax, wrap_width); + if let Some(m) = mark.clone() { + underline(&mut job, m, danger); + } ui.fonts_mut(|f| f.layout_job(job)) }; let frame_h = row_h * max_lines as f32 + 8.0; diff --git a/crates/ui-egui/src/code_editor/tests.rs b/crates/ui-egui/src/code_editor/tests.rs index ba3c08efa..69363100c 100644 --- a/crates/ui-egui/src/code_editor/tests.rs +++ b/crates/ui-egui/src/code_editor/tests.rs @@ -25,6 +25,64 @@ fn python_tokens() { assert_eq!(spans("hello", Lang::Plain), vec![]); } +#[test] +fn underline_splits_sections_at_the_mark() { + let t = Tokens::light(); + let font = FontId::new(12.0, FontFamily::Monospace); + let marked = |job: &egui::text::LayoutJob| -> Vec { + job.sections + .iter() + .filter(|s| s.format.underline.width > 0.0) + .map(|s| job.text[s.byte_range.start.0..s.byte_range.end.0].to_string()) + .collect() + }; + let mut job = layout_job("a + \\foo", Lang::Latex, &font, t.text, &t.syntax, 100.0); + underline(&mut job, 4..8, t.danger); + assert_eq!(marked(&job), vec!["\\foo"]); + let total: usize = job.sections.iter().map(|s| s.byte_range.end.0 - s.byte_range.start.0).sum(); + assert_eq!(total, job.text.len(), "sections still cover the text"); + // An empty mark at the end underlines the last character; out-of-range marks are clamped. + let mut job = layout_job("x^", Lang::Latex, &font, t.text, &t.syntax, 100.0); + underline(&mut job, 2..2, t.danger); + assert_eq!(marked(&job), vec!["^"]); + let mut job = layout_job("\u{e9}", Lang::Latex, &font, t.text, &t.syntax, 100.0); + underline(&mut job, 50..99, t.danger); + assert!(job.sections.iter().any(|s| s.format.underline.width > 0.0)); +} + +#[test] +fn a_stale_mark_inside_a_multibyte_char_snaps_to_char_boundaries() { + // The editor's mark comes from the text before this frame's edit: `a\foo` had its error at + // 1..5, then an `é` (two bytes) was typed at the start, so byte 1 is inside it. + let t = Tokens::light(); + let font = FontId::new(12.0, FontFamily::Monospace); + let ctx = egui::Context::default(); + for text in ["\u{e9}\\foo", "\u{e9}\u{e9}\u{4e2d}x", "\u{1F600}", "a\u{e9}"] { + for start in 0..=text.len() + 1 { + for end in 0..=text.len() + 1 { + let mut job = layout_job(text, Lang::Latex, &font, t.text, &t.syntax, 200.0); + underline(&mut job, start..end, t.danger); + for s in &job.sections { + let r = s.byte_range.start.0..s.byte_range.end.0; + assert!(text.is_char_boundary(r.start) && text.is_char_boundary(r.end), "{text:?} {start}..{end}: {r:?}"); + } + let total: usize = job.sections.iter().map(|s| s.byte_range.end.0 - s.byte_range.start.0).sum(); + assert_eq!(total, text.len(), "sections still cover {text:?}"); + // Laying the job out slices the text at every section: it must not panic. + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + let _ = ui.fonts_mut(|f| f.layout_job(job.clone())); + }); + out.textures_delta.clear(); + } + } + } + let mut job = layout_job("\u{e9}\\foo", Lang::Latex, &font, t.text, &t.syntax, 200.0); + underline(&mut job, 1..5, t.danger); + let marked: Vec<&str> = + job.sections.iter().filter(|s| s.format.underline.width > 0.0).map(|s| &job.text[s.byte_range.start.0..s.byte_range.end.0]).collect(); + assert_eq!(marked.concat(), "\u{e9}\\fo", "the mark grows out to the whole characters it touches"); +} + #[test] fn latex_tokens() { let s = spans("\\frac{a^2}{3} % half\n\\{", Lang::Latex); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index d791b2851..71c68249b 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -69,7 +69,7 @@ pub struct Dialog { picture_target: Option<(u64, usize)>, /// The dialog this one was opened from (Name Manager › New…): it comes back when this closes. back: Option>, - focus_initialized: bool, + pub(crate) focus_initialized: bool, /// The formula box that takes references clicked or dragged on the grid (New Formatting Rule). pick: Option, /// The linked cell's initial display text; unchanged text must not replace a number or formula. @@ -627,6 +627,7 @@ impl Dialog { } d } + "equation" => return crate::equation::open_editor(app, ¶ms), "goalSeek" => Dialog::custom("goalSeek", "Goal Seek", json!({"set": active, "to": "0", "changing": ""})), "forecastSheet" => Dialog::form( "forecastSheet", diff --git a/crates/ui-egui/src/dialogs/command_search.rs b/crates/ui-egui/src/dialogs/command_search.rs index aed9843fc..44b518ff9 100644 --- a/crates/ui-egui/src/dialogs/command_search.rs +++ b/crates/ui-egui/src/dialogs/command_search.rs @@ -1,5 +1,6 @@ use super::{Dialog, Key, SheetApp, json, vec2}; +/// Shows enabled commands and their shortcuts from engine metadata. pub(super) fn command_search(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) { let (down, up, home, end, pgup, pgdn) = ui.input_mut(|i| { ( @@ -19,14 +20,15 @@ pub(super) fn command_search(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dial d.focus_initialized = true; } + let mac = ui.ctx().os() == egui::os::OperatingSystem::Mac; let q = d.search.to_ascii_lowercase(); - let cmds: Vec<(String, String, String)> = app + let cmds: Vec<(String, String, String, Option)> = app .session .commands() .into_iter() .filter(|c| c.enabled) .filter(|c| q.is_empty() || c.label.to_ascii_lowercase().contains(&q) || c.id.to_ascii_lowercase().contains(&q)) - .map(|c| (c.id.to_string(), c.label.to_string(), c.menu.join(" › "))) + .map(|c| (c.id.to_string(), c.label.to_string(), c.menu.join(" › "), c.shortcut_for_platform(mac))) .take(40) .collect(); @@ -62,8 +64,12 @@ pub(super) fn command_search(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dial egui::ScrollArea::vertical().max_height(LIST_HEIGHT).auto_shrink([true, false]).show(ui, |ui| { let scroll_area_width = ui.available_width(); - for (i, (id, label, menu)) in cmds.iter().enumerate() { - let r = ui.add(egui::Button::selectable(i == d.list_index, format!("{label} {menu}")).min_size(vec2(scroll_area_width, 20.0))); + for (i, (id, label, menu, shortcut)) in cmds.iter().enumerate() { + let text = match shortcut { + Some(keys) => format!("{label} ({keys}) {menu}"), + None => format!("{label} {menu}"), + }; + let r = ui.add(egui::Button::selectable(i == d.list_index, text.clone()).truncate().min_size(vec2(scroll_area_width, 20.0))); if (down || up || home || end || pgup || pgdn) && i == d.list_index && !ui.clip_rect().contains_rect(r.rect) { r.scroll_to_me(Some(if down || end || pgdn { egui::Align::Max } else { egui::Align::Min })); @@ -74,7 +80,7 @@ pub(super) fn command_search(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dial *confirm = true; } - r.on_hover_text(id); + r.on_hover_text(format!("{text}\n{id}")); } }); if ui.input(|i| i.key_pressed(Key::Enter)) { diff --git a/crates/ui-egui/src/dialogs/show.rs b/crates/ui-egui/src/dialogs/show.rs index b42e96abe..320f06371 100644 --- a/crates/ui-egui/src/dialogs/show.rs +++ b/crates/ui-egui/src/dialogs/show.rs @@ -148,7 +148,7 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) { let width = match d.name.as_str() { "formatCells" => 560.0, "about" => 660.0, - "insertFunction" | "commandSearch" | "nameManager" | "manageRules" | "journal" | "agents" => 520.0, + "insertFunction" | "commandSearch" | "nameManager" | "manageRules" | "journal" | "agents" | "equation" => 520.0, "macros" => 480.0, "recommendedCharts" => 600.0, "analysisToolPak" => 420.0, @@ -189,6 +189,7 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) { "selectData" => select_data(app, ui, &mut d, &mut confirm, &mut open), "commandSearch" => command_search(app, ui, &mut d, &mut confirm), "goalSeek" => goal_seek(app, ui, &mut d, &mut confirm), + "equation" => crate::equation::editor(app, ui, &mut d, &mut confirm, &mut open), "pasteSpecial" => paste_special(app, ui, &mut d, &mut confirm, &mut open), "analysisToolPak" => analysis_toolpak(app, ui, &mut d, &mut confirm, &mut open), "solver" => solver(app, ui, &mut d, &mut confirm, &mut open), diff --git a/crates/ui-egui/src/equation.rs b/crates/ui-egui/src/equation.rs new file mode 100644 index 000000000..93afc72b3 --- /dev/null +++ b/crates/ui-egui/src/equation.rs @@ -0,0 +1,533 @@ +//! Equations: painting a laid-out [`MathList`] with egui. +//! +//! Painting only ever reads the layout cached on the shape (`Shape::math`); it never lays an +//! equation out, so nothing here can reach the layout engine. Glyphs are drawn with the system's +//! maths fonts (`theme::MATH*`); a glyph no font has becomes a `[?]` box the size of its TeX box. + +use std::sync::Arc; + +use egui::epaint::{Mesh, PathShape, PathStroke}; +use egui::text::{CCursor, CCursorRange}; +use egui::{Color32, FontFamily, FontId, Key, Painter, Pos2, Rect, Stroke, StrokeKind, TextEdit, pos2, vec2}; +use gridcraft_engine::math::{Face, Item, MathError, MathList, PathCmd}; +use gridcraft_engine::model::{Shape, ShapeKind, Workbook}; +use serde_json::{Value as Json, json}; + +use crate::SheetApp; +use crate::code_editor::{Lang, code_editor_marked}; +use crate::dialogs::Dialog; +use crate::theme::{self, Tokens}; + +/// Most points one outline may have; longer ones are stroked instead of filled. +const MAX_FILL_POINTS: usize = 4000; + +fn family(face: Face) -> FontFamily { + FontFamily::Name( + match face { + Face::Roman | Face::Large(_) => theme::MATH, + Face::Italic => theme::MATH_ITALIC, + Face::Bold | Face::BoldItalic => theme::MATH_BOLD, + Face::Sans => theme::CELL, + Face::SansBold => theme::CELL_BOLD, + Face::SansItalic => theme::CELL_ITALIC, + Face::Mono => theme::MONO, + } + .into(), + ) +} + +// ---------------------------------------------------------------- editor + +/// The editor's quick-insert buttons: (label, LaTeX, tooltip). The caret lands in the first `{}`. +pub const QUICK: &[(&str, &str, &str)] = &[ + ("a/b", "\\frac{}{}", "Fraction"), + ("\u{221A}x", "\\sqrt{}", "Square root"), + ("x^n", "^{}", "Superscript"), + ("x_i", "_{}", "Subscript"), + ("\u{2211}", "\\sum_{i=1}^{n} ", "Summation"), + ("\u{222B}", "\\int_{a}^{b} ", "Integral"), + ("lim", "\\lim_{x \\to {}} ", "Limit"), + ("( )", "\\left( {} \\right)", "Brackets that grow"), + ("[ ]", "\\begin{bmatrix} {} & b \\\\ c & d \\end{bmatrix}", "Matrix"), + ("\u{03B1}", "\\alpha ", "alpha"), + ("\u{03B2}", "\\beta ", "beta"), + ("\u{03B8}", "\\theta ", "theta"), + ("\u{03C0}", "\\pi ", "pi"), + ("\u{0394}", "\\Delta ", "Delta"), + ("\u{00B1}", "\\pm ", "Plus or minus"), + ("\u{00D7}", "\\times ", "Times"), + ("\u{00F7}", "\\div ", "Divided by"), + ("\u{2264}", "\\le ", "Less than or equal to"), + ("\u{2265}", "\\ge ", "Greater than or equal to"), + ("\u{2260}", "\\ne ", "Not equal to"), + ("\u{221E}", "\\infty ", "Infinity"), + ("\u{2192}", "\\to ", "Tends to"), +]; + +/// The editor dialog: a new equation at the active cell, or `{id}` to edit one. +pub fn open_editor(app: &SheetApp, params: &Json) -> Option { + let id = params.get("id").and_then(Json::as_u64); + let mut d = match id { + Some(id) => { + let source = app + .session + .active() + .and_then(|doc| doc.wb.active()) + .and_then(|sh| sh.shapes.iter().find(|s| s.id as u64 == id && s.kind == ShapeKind::Equation)) + .map(|s| s.text.clone())?; + let mut d = Dialog::custom("equation", "Edit Equation", json!({"id": id, "latex": source})); + d.command = Some("equation.edit"); + d + } + None => { + let at = app.session.active().map(|doc| doc.selection.active.a1()).unwrap_or_default(); + let latex = params.get("latex").and_then(Json::as_str).unwrap_or(""); + let mut d = Dialog::custom("equation", "Insert Equation", json!({"at": at, "latex": latex})); + d.command = Some("insert.equation"); + d + } + }; + d.result = None; + Some(d) +} + +/// The preview's layout, kept in egui memory and rebuilt only when the source changes. +#[derive(Clone)] +struct Preview { + source: String, + result: Result, MathError>, +} + +/// The source laid out (guarded, capped) for the preview; the editor is one of the two places, +/// with commands, that may lay equations out. +fn preview(ctx: &egui::Context, id: egui::Id, source: &str) -> Preview { + if let Some(p) = ctx.data(|d| d.get_temp::(id)).filter(|p| p.source == source) { + return p; + } + let p = Preview { source: source.to_string(), result: gridcraft_engine::math::layout(source).map(Arc::new) }; + ctx.data_mut(|d| d.insert_temp(id, p.clone())); + p +} + +/// Inserts `snippet` at the editor's caret (replacing a selection) and puts the caret in its +/// first `{}`. +fn insert_snippet(ctx: &egui::Context, id: egui::Id, text: &mut String, snippet: &str) { + let mut state = TextEdit::load_state(ctx, id).unwrap_or_default(); + let count = text.chars().count(); + let (a, b) = state + .cursor + .char_range() + .map(|r| -> (usize, usize) { (r.primary.index.into(), r.secondary.index.into()) }) + .map_or((count, count), |(p, q)| (p.min(q), p.max(q))); + let byte = |c: usize| text.char_indices().nth(c).map_or(text.len(), |(i, _)| i); + let (ba, bb) = (byte(a.min(count)), byte(b.min(count))); + text.replace_range(ba..bb, snippet); + let caret = a + snippet.find("{}").map_or(snippet.chars().count(), |i| snippet.get(..i).map_or(0, |s| s.chars().count()) + 1); + state.cursor.set_char_range(Some(CCursorRange::one(CCursor::new(caret)))); + state.store(ctx, id); + // Give the source the focus back once it has been drawn this frame. + ctx.data_mut(|m| m.insert_temp(id.with("refocus"), true)); +} + +/// The editor's body: quick-insert buttons, the LaTeX source with its error underlined, a live +/// preview on a sheet-coloured card, and OK/Cancel. +pub fn editor(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { + let _ = app; + let t = Tokens::get(ui.ctx()); + let edit_id = ui.scope_id().with("equation-source"); + let mut text = d.values.get("latex").and_then(Json::as_str).unwrap_or("").to_string(); + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = vec2(3.0, 3.0); + for (label, snippet, tip) in QUICK { + let b = ui.add(egui::Button::new(*label).min_size(vec2(30.0, 22.0))).on_hover_text(*tip); + if b.clicked() { + insert_snippet(ui.ctx(), edit_id, &mut text, snippet); + } + } + }); + ui.add_space(6.0); + ui.label("LaTeX:"); + let pv = preview(ui.ctx(), ui.scope_id().with("equation-preview"), &text); + let mark = pv.result.as_ref().err().and_then(|e| e.span.clone()).or_else(|| pv.result.as_ref().err().map(|_| text.len()..text.len())); + let mark = mark.filter(|_| !text.trim().is_empty()); + let r = code_editor_marked(ui, edit_id, &mut text, Lang::Latex, 6, mark); + // Focus the source when the dialog opens and after a quick-insert button. The "opened" flag + // lives on the dialog, so every way of closing it (OK, Cancel, Escape, the close button) + // leaves the next one to focus its source again. + let refocus = ui.ctx().data_mut(|m| m.remove_temp::(edit_id.with("refocus")).unwrap_or(false)); + if refocus || !d.focus_initialized { + r.request_focus(); + d.focus_initialized = true; + } + let pv = preview(ui.ctx(), ui.scope_id().with("equation-preview"), &text); + d.values.insert("latex".into(), json!(text)); + ui.add_space(4.0); + match &pv.result { + Err(e) if !text.trim().is_empty() => { + let msg = match e.char_pos(&text) { + Some(p) => format!("Error at character {p}: {}", e.message), + None => e.message.clone(), + }; + ui.colored_label(t.danger, msg); + } + _ => { + ui.label(egui::RichText::new("Preview").color(t.text_dim)); + } + } + // The preview card is the sheet's colour, with the sheet's ink, in both themes. + let (card, _) = ui.allocate_exact_size(vec2(ui.available_width(), 110.0), egui::Sense::hover()); + ui.painter().rect_filled(card, 4.0, t.grid_bg); + ui.painter().rect_stroke(card, 4.0, Stroke::new(1.0, t.input_border), StrokeKind::Inside); + match &pv.result { + Ok(list) if !list.items.is_empty() => { + // Fit, but never larger than 24 pt. + let s = fit(list, card, 10.0).min(24.0 * 96.0 / 72.0); + let o = card.center() - vec2(list.width, list.total_height()) * s / 2.0; + paint_at(&ui.painter().with_clip_rect(card), o, list, t.cell_text, s); + } + _ => { + let hint = if text.trim().is_empty() { "Type an equation in LaTeX, such as x^2 + y^2 = r^2" } else { "" }; + ui.painter().text(card.center(), egui::Align2::CENTER_CENTER, hint, theme::ui_font(12.0), t.sheet_control_text); + } + } + ui.add_space(8.0); + let valid = pv.result.as_ref().is_ok_and(|l| !l.items.is_empty()); + let cmd_enter = ui.input_mut(|i| i.consume_key(egui::Modifiers::COMMAND, Key::Enter)); + ui.allocate_ui_with_layout(vec2(ui.available_width(), 28.0), egui::Layout::right_to_left(egui::Align::Center), |ui| { + let ok = ui.add_enabled(valid, egui::Button::new(egui::RichText::new("OK").color(t.on_accent)).fill(t.accent).min_size(vec2(64.0, 0.0))); + if ok.clicked() || (valid && cmd_enter) { + *confirm = true; + } + if ui.add(egui::Button::new("Cancel").min_size(vec2(64.0, 0.0))).clicked() { + *open = false; + } + let verb = if d.command == Some("equation.edit") { "apply" } else { "insert" }; + ui.label(egui::RichText::new(format!("\u{2318}/Ctrl+Enter to {verb}")).small().color(t.text_dim)); + }); +} + +/// The gallery's layouts, made once (guarded, like every layout) the first time the menu opens. +fn gallery_layouts() -> &'static [(&'static str, &'static str, Option)] { + static G: std::sync::OnceLock)>> = std::sync::OnceLock::new(); + G.get_or_init(|| gridcraft_engine::math::GALLERY.iter().map(|(name, src)| (*name, *src, gridcraft_engine::math::layout(src).ok())).collect()) +} + +/// The gallery as menu rows: a name over a preview on a sheet-coloured card. Returns the source +/// of the row clicked. +pub fn gallery(ui: &mut egui::Ui) -> Option<&'static str> { + let t = Tokens::get(ui.ctx()); + let mut picked = None; + for (name, src, list) in gallery_layouts() { + let (r, resp) = ui.allocate_exact_size(vec2(290.0, 62.0), egui::Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(r, 4.0, t.hover); + } + ui.painter().text(r.left_top() + vec2(6.0, 4.0), egui::Align2::LEFT_TOP, *name, theme::ui_font(12.0), t.text); + let card = Rect::from_min_max(r.left_top() + vec2(6.0, 21.0), r.right_bottom() - vec2(6.0, 3.0)); + ui.painter().rect_filled(card, 3.0, t.grid_bg); + if let Some(list) = list { + let s = fit(list, card, 3.0).min(16.0); + paint_at(&ui.painter().with_clip_rect(card), card.center() - vec2(list.width, list.total_height()) * s / 2.0, list, t.cell_text, s); + } + if resp.on_hover_text(*src).clicked() { + picked = Some(*src); + } + } + picked +} + +/// Draws an equation shape in its frame `r` (screen pixels, zoom `z`). +pub fn paint_shape(p: &Painter, r: Rect, sp: &Shape, wb: &Workbook, z: f32) { + let t = Tokens::get(p.ctx()); + let ink = sp.line.resolve(&wb.theme).map(theme::color32).unwrap_or(t.cell_text); + let p = p.with_clip_rect(r.intersect(p.clip_rect())); + match sp.math.get(&sp.text) { + Some(Ok(list)) => { + paint_list(&p, r, list, ink, gridcraft_engine::cmd::equation::PADDING * z); + } + // No layout (a source that doesn't parse, from a file): show the source in a dashed + // frame so it can be found and fixed. + other => { + let font = FontId::new(13.0 * z, FontFamily::Name(theme::MONO.into())); + let source = gridcraft_engine::cmd::equation::source_excerpt(&sp.text).to_owned(); + let g = p.layout(source, font, t.cell_text, (r.width() - 8.0 * z).max(1.0)); + p.galley(r.min + vec2(4.0, 4.0) * z, g, t.cell_text); + if matches!(other, Some(Err(_))) { + let dash = egui::Shape::dashed_line( + &[r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom(), r.left_top()], + Stroke::new(1.0, t.sheet_control_border), + 4.0, + 3.0, + ); + p.extend(dash); + } + } + } +} + +/// The scale (screen pixels per em) at which `list` fits `r` inside `pad`. +pub fn fit(list: &MathList, r: Rect, pad: f32) -> f32 { + let (w, h) = (list.width.max(0.01), list.total_height().max(0.01)); + let s = ((r.width() - 2.0 * pad) / w).min((r.height() - 2.0 * pad) / h); + if s.is_finite() { s.clamp(0.0, 4000.0) } else { 0.0 } +} + +/// Draws `list` centred in `r` at the largest scale that fits inside `pad`. Returns the scale. +pub fn paint_list(p: &Painter, r: Rect, list: &MathList, ink: Color32, pad: f32) -> f32 { + let s = fit(list, r, pad); + paint_at(p, r.center() - vec2(list.width, list.total_height()) * s / 2.0, list, ink, s); + s +} + +/// Draws `list` with its box's top-left at `o`, at `s` screen pixels per em. +pub fn paint_at(p: &Painter, o: Pos2, list: &MathList, ink: Color32, s: f32) { + if s < 0.05 { + return; + } + let col = |c: Option<[u8; 3]>| c.map_or(ink, |[r, g, b]| Color32::from_rgb(r, g, b)); + for item in &list.items { + match item { + Item::Glyph(g) => { + let color = col(g.color); + let (x, baseline) = (o.x + g.x * s, o.y + g.y * s); + let large = matches!(g.face, Face::Large(_)); + // A large operator or delimiter is drawn at the height of its TeX box. + let px = if large { (g.height + g.depth) * s } else { g.size * s }.clamp(0.0, 4000.0); + if px < 1.0 { + continue; + } + let font = FontId::new(px, family(g.face)); + let ch = if renders(p, &font, g.ch) { Some(g.ch) } else { g.alt.filter(|a| renders(p, &font, *a)) }; + let Some(ch) = ch else { + unknown_box( + p, + Rect::from_min_max(pos2(x, baseline - g.height * s), pos2(x + g.width.max(0.3) * s, baseline + g.depth * s)), + color, + ); + continue; + }; + let galley = p.layout_no_wrap(ch.to_string(), font, color); + let ascent = galley.rows.first().and_then(|row| row.glyphs.first().map(|gl| row.pos.y + gl.pos.y)).unwrap_or(px * 0.8); + let (gx, gy) = if large { + // Centre the glyph on its box: maths fonts centre these on the maths axis, + // about a quarter em above the baseline. + let mid = o.y + (g.y - (g.height - g.depth) / 2.0) * s; + (x + (g.width * s - galley.size().x) / 2.0, mid + 0.25 * px - ascent) + } else { + (x, baseline - ascent) + }; + p.galley(pos2(gx, gy), galley, color); + } + Item::Rule(rule) => { + let h = if rule.height > 0.0 { (rule.height * s).max(1.0) } else { 0.0 }; + let w = if rule.width > 0.0 { (rule.width * s).max(1.0) } else { 0.0 }; + if w > 0.0 && h > 0.0 { + p.rect_filled(Rect::from_min_size(pos2(o.x + rule.x * s, o.y + rule.y * s), vec2(w, h)), 0.0, col(rule.color)); + } + } + Item::Path(path) => { + let at = pos2(o.x + path.x * s, o.y + path.y * s); + let color = col(path.color); + for sub in flatten(&path.commands, at, s) { + if path.fill && sub.len() >= 3 && sub.len() <= MAX_FILL_POINTS { + p.add(fill_polygon(&sub, color)); + } else if sub.len() >= 2 { + p.add(PathShape::line(sub, PathStroke::new(1.0, color))); + } + } + } + } + } +} + +/// Whether some face in `font`'s family draws `c` (rather than the replacement glyph). +/// +/// `Fonts::has_glyph` alone reports a false negative for every character of the face that also +/// supplies the replacement glyph (a maths font usually has `◻`), so a "no" is double-checked by +/// comparing the glyph's atlas slot with the replacement glyph's. +pub fn renders(p: &Painter, font: &FontId, c: char) -> bool { + if p.ctx().fonts_mut(|f| f.has_glyph(font, c)) { + return true; + } + let uv = |c: char| { + let g = p.layout_no_wrap(c.to_string(), font.clone(), Color32::WHITE); + g.rows.first().and_then(|r| r.glyphs.first().map(|gl| gl.uv_rect)) + }; + let Some(mine) = uv(c) else { return false }; + if mine.is_nothing() { + return false; + } + ['\u{25FB}', '?'].iter().all(|r| *r == c || uv(*r) != Some(mine)) +} + +/// A glyph no font has: a box the size of its TeX box with a question mark. +fn unknown_box(p: &Painter, r: Rect, color: Color32) { + if r.width() < 4.0 || r.height() < 4.0 { + p.rect_filled(r, 0.0, color.gamma_multiply(0.4)); + return; + } + p.rect_stroke(r.shrink(0.5), 0.0, Stroke::new(1.0, color), StrokeKind::Inside); + let size = (r.height() * 0.6).min(r.width() * 0.9).max(4.0); + p.text(r.center(), egui::Align2::CENTER_CENTER, "?", FontId::new(size, FontFamily::Name(theme::CELL.into())), color); +} + +/// The outline as screen-space polygons, one per subpath, with curves as short lines. +fn flatten(cmds: &[PathCmd], at: Pos2, s: f32) -> Vec> { + let pt = |x: f32, y: f32| pos2(at.x + x * s, at.y + y * s); + let mut out: Vec> = Vec::new(); + let mut cur: Vec = Vec::new(); + for c in cmds { + match *c { + PathCmd::MoveTo(x, y) => { + if cur.len() > 1 { + out.push(std::mem::take(&mut cur)); + } + cur.clear(); + cur.push(pt(x, y)); + } + PathCmd::LineTo(x, y) => cur.push(pt(x, y)), + PathCmd::QuadTo(x1, y1, x, y) => { + let p0 = cur.last().copied().unwrap_or(pt(x1, y1)); + let (c1, p1) = (pt(x1, y1), pt(x, y)); + for k in 1..=8 { + let t = k as f32 / 8.0; + let u = 1.0 - t; + cur.push(pos2(u * u * p0.x + 2.0 * u * t * c1.x + t * t * p1.x, u * u * p0.y + 2.0 * u * t * c1.y + t * t * p1.y)); + } + } + PathCmd::CubicTo(x1, y1, x2, y2, x, y) => { + let p0 = cur.last().copied().unwrap_or(pt(x1, y1)); + let (c1, c2, p1) = (pt(x1, y1), pt(x2, y2), pt(x, y)); + for k in 1..=10 { + let t = k as f32 / 10.0; + let u = 1.0 - t; + let (a, b, c, d) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t); + cur.push(pos2(a * p0.x + b * c1.x + c * c2.x + d * p1.x, a * p0.y + b * c1.y + c * c2.y + d * p1.y)); + } + } + PathCmd::Close => { + if cur.len() > 1 { + out.push(std::mem::take(&mut cur)); + } + } + } + } + if cur.len() > 1 { + out.push(cur); + } + out +} + +/// A filled simple polygon (convex or not), triangulated by ear clipping. +fn fill_polygon(pts: &[Pos2], color: Color32) -> Mesh { + let mut mesh = Mesh::default(); + let mut pts: Vec = pts.to_vec(); + pts.dedup_by(|a, b| (*a - *b).length_sq() < 1e-6); + if pts.len() > 2 && pts.first().zip(pts.last()).is_some_and(|(a, b)| (*a - *b).length_sq() < 1e-6) { + pts.pop(); + } + for q in &pts { + mesh.colored_vertex(*q, color); + } + for [a, b, c] in triangulate(&pts) { + mesh.add_triangle(a as u32, b as u32, c as u32); + } + mesh +} + +fn cross(o: Pos2, a: Pos2, b: Pos2) -> f32 { + (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x) +} + +/// Ear clipping of a simple polygon. A degenerate polygon that has no ear left is finished as a +/// fan, so the result is always a valid (if imperfect) set of triangles. +fn triangulate(pts: &[Pos2]) -> Vec<[usize; 3]> { + let n = pts.len(); + if n < 3 { + return vec![]; + } + let area: f32 = + (0..n).map(|i| cross(Pos2::ZERO, pts.get(i).copied().unwrap_or_default(), pts.get((i + 1) % n).copied().unwrap_or_default())).sum(); + let ccw = area > 0.0; + let at = |i: usize| pts.get(i).copied().unwrap_or_default(); + let mut idx: Vec = (0..n).collect(); + let mut tris = Vec::with_capacity(n); + let mut guard = 0usize; + while idx.len() > 3 && guard < n * n { + guard += 1; + let m = idx.len(); + let mut clipped = false; + for k in 0..m { + let (ia, ib, ic) = + (idx.get((k + m - 1) % m).copied().unwrap_or(0), idx.get(k).copied().unwrap_or(0), idx.get((k + 1) % m).copied().unwrap_or(0)); + let (a, b, c) = (at(ia), at(ib), at(ic)); + let turn = cross(a, b, c); + if (turn > 0.0) != ccw || turn.abs() < 1e-9 { + continue; + } + let inside = idx.iter().any(|&j| { + if j == ia || j == ib || j == ic { + return false; + } + let q = at(j); + let (d1, d2, d3) = (cross(a, b, q), cross(b, c, q), cross(c, a, q)); + if ccw { d1 >= 0.0 && d2 >= 0.0 && d3 >= 0.0 } else { d1 <= 0.0 && d2 <= 0.0 && d3 <= 0.0 } + }); + if inside { + continue; + } + tris.push([ia, ib, ic]); + idx.remove(k); + clipped = true; + break; + } + if !clipped { + break; + } + } + // Whatever is left (a triangle, or a degenerate remainder) as a fan. + if let Some((&first, rest)) = idx.split_first() { + for w in rest.windows(2) { + if let [b, c] = *w { + tris.push([first, b, c]); + } + } + } + tris +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ear_clipping_covers_a_concave_polygon() { + // An L shape: area 3 unit squares. + let l = [pos2(0.0, 0.0), pos2(2.0, 0.0), pos2(2.0, 1.0), pos2(1.0, 1.0), pos2(1.0, 2.0), pos2(0.0, 2.0)]; + for pts in [l.to_vec(), l.iter().rev().copied().collect::>()] { + let t = triangulate(&pts); + assert_eq!(t.len(), 4); + let area: f32 = t.iter().map(|[a, b, c]| cross(pts[*a], pts[*b], pts[*c]).abs() / 2.0).sum(); + assert!((area - 3.0).abs() < 1e-4, "{area}"); + } + assert!(triangulate(&[pos2(0.0, 0.0), pos2(1.0, 1.0)]).is_empty()); + // Collinear points still come back as (degenerate) triangles rather than looping. + assert!(triangulate(&[pos2(0.0, 0.0), pos2(1.0, 0.0), pos2(2.0, 0.0), pos2(3.0, 0.0)]).len() <= 2); + } + + #[test] + fn curves_are_flattened_per_subpath() { + let cmds = [ + PathCmd::MoveTo(0.0, 0.0), + PathCmd::QuadTo(1.0, 1.0, 2.0, 0.0), + PathCmd::Close, + PathCmd::MoveTo(5.0, 5.0), + PathCmd::CubicTo(6.0, 6.0, 7.0, 6.0, 8.0, 5.0), + ]; + let subs = flatten(&cmds, Pos2::ZERO, 10.0); + assert_eq!(subs.len(), 2); + assert_eq!(subs[0].len(), 9); + assert_eq!(subs[1].len(), 11); + assert_eq!(subs[1].last().copied(), Some(pos2(80.0, 50.0))); + } +} diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index d64ad30bf..42f812339 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -105,6 +105,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Text to\nColumns" => "Text do\nsloupců", "Link" => "Odkaz", "Symbol" => "Symbol", + "Equation" => "Rovnice", + "Insert New Equation..." => "Vložit novou rovnici", "Sparklines" => "Minigrafy", "Header & Footer" => "Záhlaví a zápatí", "Draw with ink" => "Kreslit od ruky", diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 52d12709d..7c67a444e 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -109,6 +109,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Text to\nColumns" => "Text in\nSpalten", "Link" => "Link", "Symbol" => "Symbol", + "Equation" => "Formel", + "Insert New Equation..." => "Neue Formel einfügen", "Sparklines" => "Sparklines", "Header & Footer" => "Kopf- und Fußzeile", "Draw with ink" => "Mit Freihand zeichnen", diff --git a/crates/ui-egui/src/i18n/el.rs b/crates/ui-egui/src/i18n/el.rs index a5a232f6c..347723e8c 100644 --- a/crates/ui-egui/src/i18n/el.rs +++ b/crates/ui-egui/src/i18n/el.rs @@ -104,6 +104,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Text to\nColumns" => "Κείμενο σε\nστήλες", "Link" => "Σύνδεση", "Symbol" => "Σύμβολο", + "Equation" => "Εξίσωση", + "Insert New Equation..." => "Εισαγωγή νέας εξίσωσης", "Sparklines" => "Μικρογραφήματα", "Header & Footer" => "Κεφαλίδα και υποσέλιδο", "Draw with ink" => "Σχεδίαση με μελάνι", diff --git a/crates/ui-egui/src/i18n/es.rs b/crates/ui-egui/src/i18n/es.rs index ad05f2082..fcbd8f168 100644 --- a/crates/ui-egui/src/i18n/es.rs +++ b/crates/ui-egui/src/i18n/es.rs @@ -108,6 +108,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Text to\nColumns" => "Texto en\ncolumnas", "Link" => "Vínculo", "Symbol" => "Símbolo", + "Equation" => "Ecuación", + "Insert New Equation..." => "Insertar nueva ecuación", "Sparklines" => "Minigráficos", "Header & Footer" => "Encabezado y pie de página", "Draw with ink" => "Dibujar con lápiz", diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs index 0a4d67271..5fbcc0b1f 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -115,6 +115,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Text to\nColumns" => "Convertir le texte\nen colonnes", "Link" => "Lien", "Symbol" => "Symbole", + "Equation" => "Équation", + "Insert New Equation..." => "Insérer une nouvelle équation", "Sparklines" => "Courbes de tendance", "Checkbox" => "Case à cocher", "Insert checkboxes in the selected cells" => "Insérer des cases à cocher dans les cellules sélectionnées", diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index addc5070b..0ee594233 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -108,6 +108,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Text to\nColumns" => "Testo in\ncolonne", "Link" => "Collegamento", "Symbol" => "Simbolo", + "Equation" => "Equazione", + "Insert New Equation..." => "Inserisci nuova equazione", "Sparklines" => "Sparkline", "Checkbox" => "Casella di controllo", "Insert checkboxes in the selected cells" => "Inserisci caselle di controllo nelle celle selezionate", diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 5bd3e51d6..813ee9c27 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -112,6 +112,8 @@ pub(super) const CORE: &[(&str, &str)] = &[ ("Text to\nColumns", "区切り位置"), ("Link", "リンク"), ("Symbol", "記号"), + ("Equation", "数式"), + ("Insert New Equation...", "新しい数式の挿入"), ("Sparklines", "スパークライン"), ("Checkbox", "チェックボックス"), ("Insert checkboxes in the selected cells", "選択したセルにチェックボックスを挿入"), diff --git a/crates/ui-egui/src/i18n/ko.rs b/crates/ui-egui/src/i18n/ko.rs index f8b687725..15dad1c87 100644 --- a/crates/ui-egui/src/i18n/ko.rs +++ b/crates/ui-egui/src/i18n/ko.rs @@ -110,6 +110,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Text to\nColumns" => "텍스트\n나누기", "Link" => "링크", "Symbol" => "기호", + "Equation" => "수식", + "Insert New Equation..." => "새 수식 삽입", "Sparklines" => "스파크라인", "Checkbox" => "확인란", "Insert checkboxes in the selected cells" => "선택한 셀에 확인란 삽입", diff --git a/crates/ui-egui/src/i18n/nl.rs b/crates/ui-egui/src/i18n/nl.rs index ad8fae6d1..f3966cb87 100644 --- a/crates/ui-egui/src/i18n/nl.rs +++ b/crates/ui-egui/src/i18n/nl.rs @@ -109,6 +109,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Text to\nColumns" => "Tekst naar\nkolommen", "Link" => "Link", "Symbol" => "Symbool", + "Equation" => "Vergelijking", + "Insert New Equation..." => "Nieuwe vergelijking invoegen", "Sparklines" => "Sparklines", "Checkbox" => "Checkbox", "Insert checkboxes in the selected cells" => "Checkboxes invoegen in de geselecteerde cellen", diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index c11f43e1b..8f69d4b99 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -104,6 +104,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Text to\nColumns" => "Tekst jako\nkolumny", "Link" => "Łącze", "Symbol" => "Symbol", + "Equation" => "Równanie", + "Insert New Equation..." => "Wstaw nowe równanie", "Sparklines" => "Wykresy przebiegu w czasie", "Checkbox" => "Pole wyboru", "Insert checkboxes in the selected cells" => "Wstaw pola wyboru w zaznaczonych komórkach", diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs index b11b9d9de..dfa82d5c5 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -107,6 +107,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Text to\nColumns" => "Texto para\nColunas", "Link" => "Vínculo", "Symbol" => "Símbolo", + "Equation" => "Equação", + "Insert New Equation..." => "Inserir Nova Equação", "Sparklines" => "Minigráficos", "Checkbox" => "Caixa de Seleção", "Insert checkboxes in the selected cells" => "Inserir caixas de seleção nas células selecionadas", diff --git a/crates/ui-egui/src/i18n/ro.rs b/crates/ui-egui/src/i18n/ro.rs index 9de0ccaa4..d8eceafec 100644 --- a/crates/ui-egui/src/i18n/ro.rs +++ b/crates/ui-egui/src/i18n/ro.rs @@ -111,6 +111,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Text to\nColumns" => "Text în\ncoloane", "Link" => "Link", "Symbol" => "Simbol", + "Equation" => "Ecuație", + "Insert New Equation..." => "Inserare ecuație nouă", "Sparklines" => "Diagrame sparkline", "Checkbox" => "Casetă de selectare", "Insert checkboxes in the selected cells" => "Inserați casete de selectare în celulele selectate", diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index a3315d4bf..f972c6d1b 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -110,6 +110,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Text to\nColumns" => "Текст\nпо столбцам", "Link" => "Ссылка", "Symbol" => "Символ", + "Equation" => "Уравнение", + "Insert New Equation..." => "Вставить новое уравнение", "Sparklines" => "Спарклайны", "Checkbox" => "Флажок", "Insert checkboxes in the selected cells" => "Вставить флажки в выбранные ячейки", diff --git a/crates/ui-egui/src/i18n/sr.rs b/crates/ui-egui/src/i18n/sr.rs index 0f2e6cf77..cb46312bb 100644 --- a/crates/ui-egui/src/i18n/sr.rs +++ b/crates/ui-egui/src/i18n/sr.rs @@ -111,6 +111,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Text to\nColumns" => "Tekst u\nkolone", "Link" => "Veza", "Symbol" => "Simbol", + "Equation" => "Jednačina", + "Insert New Equation..." => "Umetni novu jednačinu", "Sparklines" => "Mini grafikoni", "Checkbox" => "Polje za potvrdu", "Insert checkboxes in the selected cells" => "Umetni polja za potvrdu u izabrane ćelije", diff --git a/crates/ui-egui/src/i18n/tr.rs b/crates/ui-egui/src/i18n/tr.rs index 9234cc25f..73dffbdf9 100644 --- a/crates/ui-egui/src/i18n/tr.rs +++ b/crates/ui-egui/src/i18n/tr.rs @@ -107,6 +107,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Text to\nColumns" => "Metni Sütunlara\nDönüştür", "Link" => "Köprü", "Symbol" => "Simge", + "Equation" => "Denklem", + "Insert New Equation..." => "Yeni Denklem Ekle", "Sparklines" => "Mini Grafikler", "Header & Footer" => "Üstbilgi ve Altbilgi", "Draw with ink" => "Mürekkeple Çiz", diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 716fadd2e..ae98ce8a5 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -106,6 +106,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Text to\nColumns" => "Текст\nу стовпці", "Link" => "Посилання", "Symbol" => "Символ", + "Equation" => "Рівняння", + "Insert New Equation..." => "Вставити нове рівняння", "Sparklines" => "Спарклайни", "Checkbox" => "Прапорець", "Insert checkboxes in the selected cells" => "Вставити прапорці у виділені комірки", diff --git a/crates/ui-egui/src/i18n/vi.rs b/crates/ui-egui/src/i18n/vi.rs index 6ab6a71c4..de6b0da93 100644 --- a/crates/ui-egui/src/i18n/vi.rs +++ b/crates/ui-egui/src/i18n/vi.rs @@ -112,6 +112,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Text to\nColumns" => "Tách\ncột", "Link" => "Liên kết", "Symbol" => "Ký hiệu", + "Equation" => "Phương trình", + "Insert New Equation..." => "Chèn phương trình mới", "Sparklines" => "Biểu đồ thu nhỏ", "Checkbox" => "Hộp kiểm", "Insert checkboxes in the selected cells" => "Chèn hộp kiểm vào các ô đã chọn", diff --git a/crates/ui-egui/src/i18n/zh.rs b/crates/ui-egui/src/i18n/zh.rs index c45e5c610..412e8040d 100644 --- a/crates/ui-egui/src/i18n/zh.rs +++ b/crates/ui-egui/src/i18n/zh.rs @@ -110,6 +110,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Text to\nColumns" => "分列", "Link" => "链接", "Symbol" => "符号", + "Equation" => "公式", + "Insert New Equation..." => "插入新公式", "Sparklines" => "迷你图", "Checkbox" => "复选框", "Insert checkboxes in the selected cells" => "在所选单元格中插入复选框", diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index e542e627b..33b55f89d 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -104,6 +104,8 @@ pub enum Icon { Note, TextBox, Symbol, + /// An equation: a radical sign over an italic x. + Equation, Sparkline, Checkbox, Function, @@ -607,6 +609,10 @@ fn paint_with(p: &Painter, rect: Rect, icon: Icon, c: Color32, pal: &Palette) { g.text(8.0, 8.0, "A", 8.0, c, (false, false)); } Icon::Symbol => g.text(8.0, 8.0, "Ω", 12.0, c, (false, false)), + Icon::Equation => { + g.line(&[(1.5, 9.0), (3.5, 8.0), (5.5, 13.5), (8.5, 2.5), (14.5, 2.5)], c, 1.2); + g.text(11.0, 8.5, "x", 8.0, blue, (false, true)); + } Icon::Function => g.text(8.0, 8.0, "fx", 11.0, c, (false, true)), Icon::Name => { g.rect(1.5, 4.0, 13.0, 8.0, None, Some(c)); diff --git a/crates/ui-egui/src/keytips/keytip_table/generated.rs b/crates/ui-egui/src/keytips/keytip_table/generated.rs index 6774474fd..3124a4b1f 100644 --- a/crates/ui-egui/src/keytips/keytip_table/generated.rs +++ b/crates/ui-egui/src/keytips/keytip_table/generated.rs @@ -158,7 +158,7 @@ pub const SEQUENCES: &[(&str, Entry)] = &[ ("NCM", Entry::Finish(Action::Dialog("comment"))), // Excel: Excel: New Comment (modern) ("NCB", Entry::Finish(Action::Command("insert.checkbox"))), // Excel: Checkbox ("ND", Entry::Finish(Action::Control("insert.chart.scatter"))), // Excel: Scatter Chart; Scatter (kind TBD) - ("NE", Entry::Finish(Action::Pending("insert.equation"))), // pending; Excel: Equations; Equations; reserved until the command lands + ("NE", Entry::Finish(Action::Dialog("equation"))), // Excel: Equation; the Equation split button's main half opens the editor ("NF", Entry::Continue), // Excel: Online Pictures; group level: containment(Picture) ("NFP", Entry::Continue), // Excel: From Picture Menu; group level: containment(Picture) ("NFPF", Entry::Finish(Action::Command("insert.picture"))), // Excel: Picture From File; containment(Picture) diff --git a/crates/ui-egui/src/keytips/keytips.json b/crates/ui-egui/src/keytips/keytips.json index d90053e2f..90eab4909 100644 --- a/crates/ui-egui/src/keytips/keytips.json +++ b/crates/ui-egui/src/keytips/keytips.json @@ -1081,11 +1081,10 @@ }, { "seq": "NE", - "kind": "command", - "target": "insert.equation", - "pending": true, - "excel": "Equations", - "note": "Equations; reserved until the command lands" + "kind": "dialog", + "target": "equation", + "excel": "Equation", + "note": "the Equation split button's main half opens the editor" }, { "seq": "NF", diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index cd2143b3e..95f861f14 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -28,6 +28,7 @@ pub mod dnd; #[cfg(not(target_arch = "wasm32"))] pub mod doc_windows; pub mod editor; +pub mod equation; mod floating_pictures; pub mod fonts; pub mod formula_bar; @@ -267,7 +268,7 @@ pub struct SheetApp { /// false (a bare test/embedder that never runs the hook) capture code falls back to egui's /// current input instead of reusing a stale recorded value. pub(crate) capture_geometry_from_raw: bool, - /// The floating object selected on the sheet (chart, picture, shape or slicer). + /// The floating object selected on the sheet (chart, picture, shape, equation or slicer). pub selected_object: Option, /// The system clipboard's text when GridCraft last copied without publishing to it (a /// control-channel or MCP `edit.copy`/`edit.cut`). While the system clipboard still holds this diff --git a/crates/ui-egui/src/objects.rs b/crates/ui-egui/src/objects.rs index 004f07d09..9688f6750 100644 --- a/crates/ui-egui/src/objects.rs +++ b/crates/ui-egui/src/objects.rs @@ -1,5 +1,5 @@ -//! Selecting, resizing, deleting and opening floating objects (charts, pictures, shapes and -//! slicers). Painting and hit-testing live in `chartview`. +//! Selecting, resizing, deleting and opening floating objects (charts, pictures, shapes, +//! equations and slicers). Painting and hit-testing live in `chartview`. use egui::{CursorIcon, Pos2, Rect, Vec2}; use gridcraft_engine::model::{ObjKind, ObjRef, ShapeKind, Sheet}; @@ -116,6 +116,8 @@ pub enum OpenAction { EditText(u32), /// The Format Chart pane. FormatChart(u32), + /// The equation editor. + EditEquation(u32), /// Nothing beyond selecting it. Select, } @@ -123,13 +125,15 @@ pub enum OpenAction { pub fn open_action(sh: &Sheet, o: ObjRef) -> OpenAction { match o.kind { ObjKind::Chart => OpenAction::FormatChart(o.id), + ObjKind::Equation => OpenAction::EditEquation(o.id), ObjKind::Shape if sh.shapes.iter().any(|s| s.id == o.id && s.kind == ShapeKind::TextBox) => OpenAction::EditText(o.id), ObjKind::Shape | ObjKind::Image | ObjKind::Slicer => OpenAction::Select, } } impl SheetApp { - /// Double-click on an object: selects it and opens what edits it. + /// Double-click on an object: selects it and opens what edits it (a text box in place, the + /// Format Chart pane, the equation editor). pub fn open_object(&mut self, o: ObjRef) -> OpenAction { self.selected_object = Some(o); let action = self.session.active().and_then(|d| d.wb.active()).map_or(OpenAction::Select, |sh| open_action(sh, o)); @@ -138,6 +142,7 @@ impl SheetApp { self.begin_text_box_edit(id); } OpenAction::FormatChart(_) => self.grid.pane = Some("formatChart".into()), + OpenAction::EditEquation(id) => self.open_dialog("equation", json!({"id": id})), OpenAction::Select => {} } action diff --git a/crates/ui-egui/src/ribbon/insert.rs b/crates/ui-egui/src/ribbon/insert.rs index 63f61f755..595b74657 100644 --- a/crates/ui-egui/src/ribbon/insert.rs +++ b/crates/ui-egui/src/ribbon/insert.rs @@ -1,5 +1,5 @@ use super::controls::{act, anchor_keytip, control_keytip, invoke, popup_command, sep}; -use super::{Icon, Sense, SheetApp, Stroke, StrokeKind, Tokens, Ui, big_button, icons, json, small_button, vec2}; +use super::{Icon, Sense, SheetApp, Stroke, StrokeKind, Tokens, Ui, big_button, big_split_button, icons, json, small_button, vec2}; /// Draws the Insert tab: PivotTable, Table, Pictures, Shapes, Icons, charts, slicer, sparklines, checkbox, links, comments, text box, header/footer and symbols. pub(super) fn insert(app: &mut SheetApp, ui: &mut Ui) { @@ -192,6 +192,25 @@ pub(super) fn insert(app: &mut SheetApp, ui: &mut Ui) { // As in Excel: Page Layout view, editing the header in place (the dialog is under Page Setup). crate::paper_view::start_editing(app); } + // Symbols: Equation, then Symbol. + sep(ui); + let (eq, eq_menu) = big_split_button(ui, Icon::Equation, "Equation", "Insert an equation, or pick one from the gallery"); + control_keytip(app, ui, &eq, "dialog:equation"); + if eq.clicked() { + app.open_dialog("equation", json!({})); + } + egui::Popup::menu(&eq_menu).show(|ui| { + ui.set_width(300.0); + if ui.add(egui::Button::new(app.ui.language.tr("Insert New Equation...")).frame(false).min_size(vec2(290.0, 24.0))).clicked() { + app.open_dialog("equation", json!({})); + ui.close(); + } + ui.separator(); + if let Some(latex) = crate::equation::gallery(ui) { + act(app, "insert.equation", json!({"latex": latex})); + ui.close(); + } + }); let sym = big_button(ui, Icon::Symbol, "Symbol", "Symbol", true); control_keytip(app, ui, &sym, "insert.symbol"); egui::Popup::menu(&sym).show(|ui| { diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index fe31dfb6a..3233e7443 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -28,9 +28,9 @@ pub use font_fallback::symbol_face_installed; use font_fallback::{cjk_han_japanese, cjk_han_simplified, cjk_hangul, fallback_faces, hebrew, lao, symbols_more, thai}; use font_setup::pick; pub use font_setup::{ - CELL, CELL_BOLD, CELL_BOLD_ITALIC, CELL_ITALIC, CraftFont, HanOrder, MONO, SERIF, UI, UI_BOLD, available_text, cell_family, cell_text_width, - chinese_font_loaded, craft_font_scripts, craft_fonts_cover, craft_fonts_loaded, craft_fonts_required, font_definitions, - font_definitions_for_language, font_definitions_with, pinned_font_definitions, symbol_fallback, ui_bold, ui_font, + CELL, CELL_BOLD, CELL_BOLD_ITALIC, CELL_ITALIC, CraftFont, HanOrder, MATH, MATH_BOLD, MATH_ITALIC, MONO, SERIF, UI, UI_BOLD, available_text, + cell_family, cell_text_width, chinese_font_loaded, craft_font_scripts, craft_fonts_cover, craft_fonts_loaded, craft_fonts_required, + font_definitions, font_definitions_for_language, font_definitions_with, pinned_font_definitions, symbol_fallback, ui_bold, ui_font, }; #[cfg(test)] use font_setup::{candidates, craft_font_faces, try_load}; diff --git a/crates/ui-egui/src/theme/font_setup.rs b/crates/ui-egui/src/theme/font_setup.rs index 916ba4026..3489b1443 100644 --- a/crates/ui-egui/src/theme/font_setup.rs +++ b/crates/ui-egui/src/theme/font_setup.rs @@ -41,6 +41,14 @@ pub const SERIF: &str = "serif"; pub const MONO: &str = "mono"; +/// Equation faces: a maths font (upright letters, operators, Greek, double-struck and script +/// letters), its italic for variables, and a bold. System fonts only; nothing is bundled. +pub const MATH: &str = "math"; + +pub const MATH_ITALIC: &str = "math-italic"; + +pub const MATH_BOLD: &str = "math-bold"; + pub fn ui_font(size: f32) -> FontId { FontId::new(size, FontFamily::Name(UI.into())) } @@ -365,6 +373,43 @@ pub(super) fn candidates(role: &str) -> Vec<(String, u32)> { add("/usr/share/fonts/liberation-serif-fonts/LiberationSerif-Regular.ttf".into(), 0); add("/usr/share/fonts/dejavu-serif-fonts/DejaVuSerif.ttf".into(), 0); } + "math" => { + add(format!("{mac_sup}/STIXTwoMath.otf"), 0); + add(format!("{win}\\cambria.ttc"), 1); + for p in in_dirs( + &lin, + &[ + "opentype/stix/STIXTwoMath-Regular.otf", + "stix/STIXTwoMath-Regular.otf", + "opentype/lmodern/latinmodern-math.otf", + "dejavu/DejaVuSerif.ttf", + "liberation/LiberationSerif-Regular.ttf", + ], + ) { + add(p, 0); + } + add(format!("{mac_sup}/Times New Roman.ttf"), 0); + add(format!("{win}\\times.ttf"), 0); + } + "math-italic" => { + add(format!("{mac_sup}/STIXTwoText-Italic.ttf"), 0); + add(format!("{mac_sup}/Times New Roman Italic.ttf"), 0); + add(format!("{win}\\cambriai.ttf"), 0); + add(format!("{win}\\timesi.ttf"), 0); + for p in + in_dirs(&lin, &["opentype/stix/STIXTwoText-Italic.otf", "liberation/LiberationSerif-Italic.ttf", "dejavu/DejaVuSerif-Italic.ttf"]) + { + add(p, 0); + } + } + "math-bold" => { + add(format!("{mac_sup}/Times New Roman Bold.ttf"), 0); + add(format!("{win}\\cambriab.ttf"), 0); + add(format!("{win}\\timesbd.ttf"), 0); + for p in in_dirs(&lin, &["opentype/stix/STIXTwoText-Bold.otf", "liberation/LiberationSerif-Bold.ttf", "dejavu/DejaVuSerif-Bold.ttf"]) { + add(p, 0); + } + } "mono" => { add(format!("{mac_sys}/SFNSMono.ttf"), 0); add(format!("{mac_sys}/Menlo.ttc"), 0); @@ -441,7 +486,7 @@ fn font_definitions_impl(language: crate::i18n::Language, used: &[String], syste // and epaint picks the first face that has the glyph, so they only catch their own scripts. let fallback = if system { load_fallback(&mut fonts, HanOrder::of(language)) } else { Vec::new() }; let hack = fonts.font_data.contains_key(HACK).then_some(HACK); - for role in [UI, UI_BOLD, CELL, CELL_BOLD, CELL_ITALIC, CELL_BOLD_ITALIC, SERIF, MONO] { + for role in [UI, UI_BOLD, CELL, CELL_BOLD, CELL_ITALIC, CELL_BOLD_ITALIC, SERIF, MONO, MATH, MATH_ITALIC, MATH_BOLD] { let chain = build_role_chain(&mut fonts, role, &base_prop, &base_mono, &fallback, hack, system); fonts.families.insert(FontFamily::Name(role.into()), chain); bound.push(role.to_string()); @@ -503,6 +548,14 @@ fn build_role_chain( } else if role.starts_with("cell-") { chain.push(format!("sys-{CELL}")); } + // Equation faces fall back to the maths font (for the symbols a text face lacks), then to the + // serif face. + if role == MATH_ITALIC || role == MATH_BOLD { + chain.push(format!("sys-{MATH}")); + } + if role.starts_with("math") { + chain.push(format!("sys-{SERIF}")); + } chain.extend(if role == MONO { base_mono.to_vec() } else { base_prop.to_vec() }); // egui bundles Hack, a monospace face rich in arrows, triangles and keyboard symbols, but // 0.37 only puts it in the Monospace family. The interface draws ↑, ▲, ▼, ▸, ▾ and ↳ as diff --git a/crates/ui-egui/src/theme/tests.rs b/crates/ui-egui/src/theme/tests.rs index 38cbbd257..31dc653ca 100644 --- a/crates/ui-egui/src/theme/tests.rs +++ b/crates/ui-egui/src/theme/tests.rs @@ -13,7 +13,7 @@ fn fallback_faces_are_appended_to_every_family() { if fb_keys.is_empty() { return; // no fallback face on this machine (minimal Linux) } - for role in [UI, UI_BOLD, CELL, CELL_BOLD, CELL_ITALIC, CELL_BOLD_ITALIC, SERIF, MONO] { + for role in [UI, UI_BOLD, CELL, CELL_BOLD, CELL_ITALIC, CELL_BOLD_ITALIC, SERIF, MONO, MATH, MATH_ITALIC, MATH_BOLD] { let chain = fonts.families.get(&FontFamily::Name(role.into())).map(Vec::as_slice).unwrap_or_default(); // The optional craft-fonts faces follow the system fallbacks at the very end. let end = chain.iter().rposition(|k| !k.starts_with("craft-")).map_or(0, |i| i + 1); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index a2e749921..28c02840e 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -47,7 +47,7 @@ pub fn tooltip_shortcut(tip: &str, mac: bool) -> Option<&'static str> { fn tip_text(ui: &Ui, tip: &str) -> String { // A name shared with a two-line ribbon label (Czech "Automatický\nsoučet") reads as one line. let name = tr(ui, tip).replace('\n', " "); - let text = match tooltip_shortcut(tip, cfg!(target_os = "macos")) { + let text = match tooltip_shortcut(tip, ui.ctx().os() == egui::os::OperatingSystem::Mac) { Some(keys) => format!("{name} ({keys})"), None => name, }; @@ -754,4 +754,23 @@ mod tooltip_shortcut_tests { assert!(mac.starts_with('⌘') || mac.starts_with('⌃'), "{name}: {mac}"); } } + + #[test] + fn ribbon_tip_text_obeys_runtime_os() { + use egui::os::OperatingSystem; + + for (os, expected) in [(OperatingSystem::Windows, "Flash Fill (Ctrl+E)"), (OperatingSystem::Mac, "Flash Fill (⌘E)")] { + let ctx = egui::Context::default(); + ctx.set_os(os); + + let mut actual = String::new(); + + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { + actual = super::tip_text(ui, "Flash Fill"); + }); + output.textures_delta.clear(); + + assert_eq!(actual, expected, "{os:?}"); + } + } } diff --git a/crates/ui-egui/tests/it/dialog_focus.rs b/crates/ui-egui/tests/it/dialog_focus.rs index 98133c371..ca3bfe925 100644 --- a/crates/ui-egui/tests/it/dialog_focus.rs +++ b/crates/ui-egui/tests/it/dialog_focus.rs @@ -2,6 +2,7 @@ //! column width without clicking into the field first. use egui::{Event, Key, vec2}; +use egui_kittest::kittest::Queryable; use gridcraft_engine::Session; use gridcraft_engine::core::CellRef; use gridcraft_ui_egui::SheetApp; @@ -81,3 +82,168 @@ fn command_search_arrow_and_page_keys_move_the_selection_and_keep_the_search_foc assert!(after_page > 2, "PageDown moved past the third row, got {after_page}"); assert!(h.ctx.memory(|m| m.focused()).is_some(), "the search field kept focus"); } + +/// The palette displays the engine metadata unchanged on either runtime OS. +#[test] +fn command_search_shows_engine_shortcuts() { + use egui::os::OperatingSystem; + + for os in [OperatingSystem::Windows, OperatingSystem::Mac] { + let mut h = harness(); + h.ctx.set_os(os); + h.run_steps(2); + + h.state_mut().open_dialog("commandSearch", json!({})); + h.run_steps(6); + + for (query, expected) in [ + ("home.bold", if os == OperatingSystem::Mac { "Bold (Cmd+B)" } else { "Bold (Ctrl+B)" }), + ("data.filter", "Filter (Ctrl+Shift+L)"), + ("edit.copy", if os == OperatingSystem::Mac { "Copy (Cmd+C)" } else { "Copy (Ctrl+C)" }), + ("edit.paste", if os == OperatingSystem::Mac { "Paste (Cmd+V)" } else { "Paste (Ctrl+V)" }), + ("home.italic", if os == OperatingSystem::Mac { "Italic (Cmd+I)" } else { "Italic (Ctrl+I)" }), + ("home.underline", if os == OperatingSystem::Mac { "Underline (Cmd+U)" } else { "Underline (Ctrl+U)" }), + ("edit.fillDown", if os == OperatingSystem::Mac { "Fill Down (Cmd+D)" } else { "Fill Down (Ctrl+D)" }), + ("edit.flashFill", "Flash Fill (Ctrl+E)"), + ("home.alignLeft", if os == OperatingSystem::Mac { "Align Left (Cmd+L)" } else { "Align Left Home" }), + ("home.alignCenter", if os == OperatingSystem::Mac { "Center (Cmd+E)" } else { "Center Home" }), + ("selection.row", "Select Entire Row (Shift+Space)"), + ("selection.column", "Select Entire Column (Ctrl+Space)"), + ("data.group", if os == OperatingSystem::Mac { "Group (Cmd+Shift+K)" } else { "Group (Alt+Shift+Right)" }), + ("data.ungroup", if os == OperatingSystem::Mac { "Ungroup (Cmd+Shift+J)" } else { "Ungroup (Alt+Shift+Left)" }), + ] { + h.state_mut().dialog.as_mut().unwrap().search = query.into(); + h.run_steps(3); + h.get_by_label_contains(expected); + } + } +} + +/// Paste Special uses the engine's shortcut metadata when enabled. +#[test] +fn command_search_shows_paste_special_metadata() { + use egui::os::OperatingSystem; + + for os in [OperatingSystem::Windows, OperatingSystem::Mac] { + let mut h = harness(); + h.ctx.set_os(os); + h.run_steps(2); + + h.state_mut().session.execute("cell.set", json!({"cell": "C3", "input": "copied"})).unwrap(); + + h.state_mut().session.execute("edit.copy", json!({"range": "C3"})).unwrap(); + + h.state_mut().open_dialog("commandSearch", json!({})); + h.run_steps(6); + + h.state_mut().dialog.as_mut().unwrap().search = "edit.pasteSpecial".into(); + h.run_steps(3); + + h.get_by_label_contains(if os == OperatingSystem::Mac { "Paste Special… (Ctrl+Cmd+V)" } else { "Paste Special… (Ctrl+Alt+V)" }); + } +} + +/// The engine registry is the authoritative shortcut source. +#[test] +fn command_palette_shortcuts_come_from_engine_metadata() { + let session = gridcraft_engine::Session::new(); + let commands = session.commands(); + + for (id, mac, other) in [ + ("home.bold", Some("Cmd+B"), Some("Ctrl+B")), + ("edit.find", Some("Cmd+F"), Some("Ctrl+F")), + ("edit.selectAll", Some("Cmd+A"), Some("Ctrl+A")), + ("edit.undo", Some("Cmd+Z"), Some("Ctrl+Z")), + ("edit.redo", Some("Cmd+Y"), Some("Ctrl+Y")), + ("edit.copy", Some("Cmd+C"), Some("Ctrl+C")), + ("edit.cut", Some("Cmd+X"), Some("Ctrl+X")), + ("edit.paste", Some("Cmd+V"), Some("Ctrl+V")), + ("edit.fillDown", Some("Cmd+D"), Some("Ctrl+D")), + ("edit.fillRight", Some("Cmd+R"), Some("Ctrl+R")), + ("home.italic", Some("Cmd+I"), Some("Ctrl+I")), + ("home.underline", Some("Cmd+U"), Some("Ctrl+U")), + ("edit.pasteSpecial", Some("Ctrl+Cmd+V"), Some("Ctrl+Alt+V")), + ("home.alignLeft", Some("Cmd+L"), None), + ("home.alignCenter", Some("Cmd+E"), None), + ("data.group", Some("Cmd+Shift+K"), Some("Alt+Shift+Right")), + ("data.ungroup", Some("Cmd+Shift+J"), Some("Alt+Shift+Left")), + ("edit.flashFill", Some("Ctrl+E"), Some("Ctrl+E")), + ("data.filter", Some("Ctrl+Shift+L"), Some("Ctrl+Shift+L")), + ("selection.row", Some("Shift+Space"), Some("Shift+Space")), + ("selection.column", Some("Ctrl+Space"), Some("Ctrl+Space")), + ] { + let command = commands.iter().find(|c| c.id == id).unwrap(); + assert_eq!(command.shortcut_for_platform(true), mac.map(str::to_string), "{id}: macOS"); + assert_eq!(command.shortcut_for_platform(false), other.map(str::to_string), "{id}: Windows/Linux"); + } + + for (id, legacy) in + [("data.group", "Alt+Shift+Right"), ("data.ungroup", "Alt+Shift+Left"), ("home.alignLeft", "Cmd+L"), ("home.alignCenter", "Cmd+E")] + { + let command = commands.iter().find(|c| c.id == id).unwrap(); + assert_eq!(command.shortcut, Some(legacy), "{id}: legacy CLI shortcut"); + } + + let command = commands.iter().find(|c| c.id == "home.alignRight").unwrap(); + assert_eq!(command.shortcut_for_platform(true), None); + assert_eq!(command.shortcut_for_platform(false), None); +} +/// Current Region displays the shortcut declared in engine metadata. +#[test] +fn command_search_shows_current_region_shortcut() { + use egui::os::OperatingSystem; + + for (os, expected) in + [(OperatingSystem::Windows, "Select Current Region (Ctrl+Shift+8)"), (OperatingSystem::Mac, "Select Current Region (Ctrl+Shift+8)")] + { + let mut h = harness(); + h.ctx.set_os(os); + h.run_steps(2); + + h.state_mut().open_dialog("commandSearch", json!({})); + h.run_steps(6); + + h.state_mut().dialog.as_mut().unwrap().search = "selection.currentRegion".into(); + + h.run_steps(3); + h.get_by_label_contains(expected); + } +} + +/// Disabled commands are absent; commands without shortcuts show no invented keys. +#[test] +fn command_search_hides_disabled_and_handles_missing_shortcuts() { + use egui::os::OperatingSystem; + + for os in [OperatingSystem::Windows, OperatingSystem::Mac] { + let mut h = harness(); + h.ctx.set_os(os); + h.run_steps(2); + + // Undo is disabled in a fresh workbook with no undo history. + let undo = h.state().session.commands().into_iter().find(|c| c.id == "edit.undo").unwrap(); + + assert!(!undo.enabled, "Undo should initially be disabled"); + + h.state_mut().open_dialog("commandSearch", json!({})); + h.run_steps(6); + + h.state_mut().dialog.as_mut().unwrap().search = "edit.undo".into(); + h.run_steps(3); + + assert!( + h.query_by_label("Undo (Cmd+Z) Edit").is_none() && h.query_by_label("Undo (Ctrl+Z) Edit").is_none(), + "Disabled Undo must not appear in the palette" + ); + + // Align Right has no shortcut on either platform. + h.state_mut().dialog.as_mut().unwrap().search = "home.alignRight".into(); + h.run_steps(3); + + let align = h.state().session.commands().into_iter().find(|c| c.id == "home.alignRight").unwrap(); + + assert_eq!(align.shortcut_for_platform(os == OperatingSystem::Mac), None); + + h.get_by_label_contains("Align Right Home"); + } +} diff --git a/crates/ui-egui/tests/it/equation_editor.rs b/crates/ui-egui/tests/it/equation_editor.rs new file mode 100644 index 000000000..c7ef5c654 --- /dev/null +++ b/crates/ui-egui/tests/it/equation_editor.rs @@ -0,0 +1,201 @@ +//! The equation editor through real input: opening it from the ribbon and its access keys, +//! typing LaTeX, the quick-insert buttons, the error in the preview, inserting, and editing an +//! equation by double-clicking it. + +use egui::{Event, Key, Modifiers, PointerButton, Pos2, os::OperatingSystem, vec2}; +use egui_kittest::kittest::Queryable as _; +use gridcraft_engine::Session; +use gridcraft_engine::model::{ObjKind, ObjRef, Shape, ShapeKind}; +use gridcraft_ui_egui::{SheetApp, chartview, grid::Geo}; +use serde_json::{Value as Json, json}; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +fn harness(s: Session) -> Harness { + let mut h = + egui_kittest::Harness::builder().with_os(OperatingSystem::Mac).with_size(vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SheetApp::new(s, Default::default()), + ); + h.run_steps(4); + h +} + +fn blank() -> Session { + let mut s = Session::new(); + s.new_workbook(); + s +} + +fn key_event(key: Key, pressed: bool, modifiers: Modifiers) -> Event { + Event::Key { key, physical_key: None, pressed, repeat: false, modifiers } +} + +fn type_text(h: &mut Harness, text: &str) { + h.input_mut().events.push(Event::Text(text.into())); + h.run_steps(2); +} + +fn cmd_enter(h: &mut Harness) { + h.input_mut().events.extend([key_event(Key::Enter, true, Modifiers::COMMAND), key_event(Key::Enter, false, Modifiers::COMMAND)]); + h.run_steps(3); +} + +fn dialog_latex(h: &Harness) -> Option { + h.state().dialog.as_ref().filter(|d| d.name() == "equation").map(|d| d.values.get("latex").and_then(Json::as_str).unwrap_or("").to_string()) +} + +fn equations(h: &Harness) -> Vec { + h.state().session.doc().unwrap().wb.active().unwrap().shapes.iter().filter(|s| s.kind == ShapeKind::Equation).cloned().collect() +} + +#[test] +fn the_ribbon_button_opens_the_editor_and_typing_inserts_an_equation() { + let mut h = harness(blank()); + h.state_mut().run("ui.ribbonTab", json!({"tab": "Insert"})).unwrap(); + h.run_steps(2); + h.get_by_label("Equation").click(); + h.run_steps(3); + assert_eq!(dialog_latex(&h).as_deref(), Some(""), "a new, empty equation"); + // The source has focus: typing goes straight in. + type_text(&mut h, "x^2 + y^2 = r^2"); + assert_eq!(dialog_latex(&h).as_deref(), Some("x^2 + y^2 = r^2")); + assert!(h.query_by_label_contains("Error at character").is_none()); + cmd_enter(&mut h); + assert!(h.state().dialog.is_none(), "Cmd+Enter inserts and closes"); + let eqs = equations(&h); + assert_eq!(eqs.len(), 1); + assert_eq!(eqs[0].text, "x^2 + y^2 = r^2"); + assert_eq!(eqs[0].anchor.cell.a1(), "A1", "at the active cell"); +} + +#[test] +fn errors_show_their_position_and_block_ok() { + let mut h = harness(blank()); + h.state_mut().open_dialog("equation", json!({})); + h.run_steps(3); + type_text(&mut h, "a + \\foo"); + assert!(h.query_by_label_contains("Error at character 5").is_some(), "the error names the position"); + cmd_enter(&mut h); + assert!(h.state().dialog.is_some(), "an equation with an error can't be inserted"); + assert!(equations(&h).is_empty()); + // The formula bar's cancel button shares the label; the dialog's comes last. + h.get_all_by_label("Cancel").last().expect("the dialog's Cancel").click(); + h.run_steps(3); + assert!(h.state().dialog.is_none()); + assert!(equations(&h).is_empty()); +} + +#[test] +fn quick_insert_buttons_insert_at_the_caret() { + let mut h = harness(blank()); + h.state_mut().open_dialog("equation", json!({})); + h.run_steps(3); + type_text(&mut h, "y = "); + h.get_by_label("a/b").click(); + h.run_steps(3); + assert_eq!(dialog_latex(&h).as_deref(), Some("y = \\frac{}{}")); + // The caret sits in the numerator. + type_text(&mut h, "1"); + assert_eq!(dialog_latex(&h).as_deref(), Some("y = \\frac{1}{}")); + h.get_by_label("\u{03C0}").click(); + h.run_steps(3); + assert_eq!(dialog_latex(&h).as_deref(), Some("y = \\frac{1\\pi }{}")); +} + +#[test] +fn the_access_keys_open_the_editor() { + let mut h = harness(blank()); + h.input_mut().events.extend([key_event(Key::F10, true, Modifiers::NONE), key_event(Key::F10, false, Modifiers::NONE)]); + h.run_steps(2); + for k in [Key::N, Key::E] { + h.input_mut().events.extend([ + key_event(k, true, Modifiers::NONE), + Event::Text(k.name().to_ascii_lowercase()), + key_event(k, false, Modifiers::NONE), + ]); + h.run_steps(2); + } + h.run_steps(2); + assert_eq!(dialog_latex(&h).as_deref(), Some(""), "N, E opens Insert Equation"); +} + +#[test] +fn double_click_edits_an_equation_in_one_undo_step() { + let mut s = blank(); + let id = + s.execute("insert.equation", json!({"latex": "a^2 + b^2 = c^2", "cell": "C4", "size": 18})).unwrap()["equation"].as_u64().unwrap() as u32; + let mut h = harness(s); + let o = ObjRef::new(ObjKind::Equation, id); + let r = { + let app = h.state(); + let sh = app.session.doc().unwrap().wb.active().unwrap(); + chartview::object_rect(&Geo::new(sh, app.grid.rect.unwrap(), app.view().scroll), sh, o).unwrap() + }; + h.run_steps(45); + for _ in 0..2 { + for pressed in [true, false] { + let p: Pos2 = r.center(); + h.input_mut().events.push(Event::PointerMoved(p)); + h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.step(); + } + h.run_steps(2); + } + assert_eq!(h.state().selected_object, Some(o)); + assert_eq!(dialog_latex(&h).as_deref(), Some("a^2 + b^2 = c^2"), "the editor opens on the equation's source"); + assert_eq!(h.state().dialog.as_ref().unwrap().title, "Edit Equation"); + h.state_mut().dialog.as_mut().unwrap().values.insert("latex".into(), json!("a^2 + b^2 = c^2 + d^2")); + h.run_steps(2); + cmd_enter(&mut h); + assert!(h.state().dialog.is_none()); + assert_eq!(equations(&h)[0].text, "a^2 + b^2 = c^2 + d^2"); + h.state_mut().run("edit.undo", json!({})).unwrap(); + h.run_steps(2); + assert_eq!(equations(&h)[0].text, "a^2 + b^2 = c^2"); +} + +#[test] +fn the_gallery_inserts_its_examples() { + let mut h = harness(blank()); + h.state_mut().run("ui.ribbonTab", json!({"tab": "Insert"})).unwrap(); + h.run_steps(2); + h.get_by_label("Equation options").click(); + h.run_steps(3); + assert!(h.query_by_label("Insert New Equation...").is_some()); + // Gallery rows are painted previews; each carries its source as hover text. Click the first. + let first = h.get_by_label("Insert New Equation...").rect(); + let row = first.center() + vec2(0.0, first.height() / 2.0 + 40.0); + h.input_mut().events.push(Event::PointerMoved(row)); + h.run_steps(2); + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerButton { pos: row, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.step(); + } + h.run_steps(3); + let eqs = equations(&h); + assert_eq!(eqs.len(), 1, "one gallery example inserted"); + assert_eq!(eqs[0].text, gridcraft_engine::math::GALLERY[0].1); +} + +#[test] +fn reopening_after_escape_focuses_the_source_again() { + let mut h = harness(blank()); + h.state_mut().run("ui.ribbonTab", json!({"tab": "Insert"})).unwrap(); + h.run_steps(2); + for round in 0..2 { + h.get_by_label("Equation").click(); + h.run_steps(3); + assert_eq!(dialog_latex(&h).as_deref(), Some(""), "round {round}: the editor is open"); + type_text(&mut h, "x"); + assert_eq!(dialog_latex(&h).as_deref(), Some("x"), "round {round}: typing goes into the source"); + // Escape closes the dialog without OK or Cancel. + h.input_mut().events.extend([key_event(Key::Escape, true, Modifiers::NONE), key_event(Key::Escape, false, Modifiers::NONE)]); + h.run_steps(3); + assert!(h.state().dialog.is_none(), "round {round}: Escape closes the editor"); + } + assert!(equations(&h).is_empty(), "nothing was inserted"); +} diff --git a/crates/ui-egui/tests/it/keytips_generated.rs b/crates/ui-egui/tests/it/keytips_generated.rs index c563d0d13..3dc225a76 100644 --- a/crates/ui-egui/tests/it/keytips_generated.rs +++ b/crates/ui-egui/tests/it/keytips_generated.rs @@ -1143,10 +1143,7 @@ fn keytip_nd_() { fn keytip_ne_() { let mut h = harness(blank()); sequence(&mut h, "NE", false); - assert!(h.state().dialog.is_none(), "NE must not open a dialog"); - assert!(h.state().message.is_none(), "NE must not open a message box"); - assert!(h.state().toast.is_none(), "NE must not raise a toast"); - assert!(h.state().session.journal.is_empty(), "NE must not journal"); + assert!(h.state().dialog.is_some(), "NE must open the equation dialog"); } #[test] diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 3af22cb99..eb0a63c33 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -29,6 +29,7 @@ mod dialog_confirm; mod dialog_focus; mod dialog_move; mod drag_cursor; +mod equation_editor; mod error_checking_menu; mod file_menu; mod fill_handle_double_click; diff --git a/crates/ui-egui/tests/it/objects.rs b/crates/ui-egui/tests/it/objects.rs index e0c272817..2dbb94460 100644 --- a/crates/ui-egui/tests/it/objects.rs +++ b/crates/ui-egui/tests/it/objects.rs @@ -16,6 +16,7 @@ struct Objects { image: ObjRef, shape: ObjRef, text_box: ObjRef, + equation: ObjRef, /// A slicer lying over the chart's right edge: slicers paint on top and take the click. slicer: ObjRef, } @@ -41,12 +42,14 @@ fn fixture() -> (Harness, Objects) { id(&s.execute("insert.chart", json!({"range": "B1:B5", "type": "column", "at": "D8", "width": 220, "height": 140})).unwrap(), "chart"); let shape = id(&s.execute("insert.shape", json!({"kind": "rectangle", "at": "C20", "width": 120, "height": 80})).unwrap(), "shape"); let text_box = id(&s.execute("insert.textBox", json!({"at": "H20", "width": 140, "height": 60, "text": "Note"})).unwrap(), "shape"); + let equation = id(&s.execute("insert.equation", json!({"latex": "a^2+b^2=c^2", "cell": "C26", "size": 16})).unwrap(), "equation"); let image = id(&s.execute("insert.picture", json!({"base64": tiny_png(), "at": "M8", "width": 60, "height": 60})).unwrap(), "image"); let objs = Objects { chart: ObjRef::new(ObjKind::Chart, chart), image: ObjRef::new(ObjKind::Image, image), shape: ObjRef::new(ObjKind::Shape, shape), text_box: ObjRef::new(ObjKind::Shape, text_box), + equation: ObjRef::new(ObjKind::Equation, equation), slicer: ObjRef::new(ObjKind::Slicer, slicer), }; let mut h = egui_kittest::Harness::builder() @@ -139,7 +142,7 @@ fn kept(h: &Harness) -> Value { #[test] fn a_click_selects_every_kind_and_slicers_win_over_charts() { let (mut h, o) = fixture(); - for obj in [o.chart, o.image, o.shape, o.text_box, o.slicer] { + for obj in [o.chart, o.image, o.shape, o.text_box, o.equation, o.slicer] { let p = grab_point(&h, &o, obj); click(&mut h, p); assert_eq!(h.state().selected_object, Some(obj), "{obj:?}"); @@ -155,7 +158,7 @@ fn a_click_selects_every_kind_and_slicers_win_over_charts() { #[test] fn every_handle_resizes_only_its_own_edges() { let (mut h, o) = fixture(); - for obj in [o.shape, o.image, o.slicer, o.chart] { + for obj in [o.shape, o.image, o.equation, o.slicer, o.chart] { let p = grab_point(&h, &o, obj); click(&mut h, p); for handle in Handle::ALL { @@ -251,6 +254,7 @@ fn double_clicks_route_by_kind() { let sh = h.state().session.doc().unwrap().wb.active().unwrap(); assert_eq!(open_action(sh, o.text_box), OpenAction::EditText(o.text_box.id)); assert_eq!(open_action(sh, o.chart), OpenAction::FormatChart(o.chart.id)); + assert_eq!(open_action(sh, o.equation), OpenAction::EditEquation(o.equation.id)); for obj in [o.shape, o.image, o.slicer] { assert_eq!(open_action(sh, obj), OpenAction::Select, "{obj:?}"); } @@ -267,6 +271,12 @@ fn double_clicks_route_by_kind() { assert_eq!(h.state().grid.pane.as_deref(), Some("formatChart")); assert_eq!(h.state().selected_object, Some(o.chart)); + let p = grab_point(&h, &o, o.equation); + double_click(&mut h, p); + assert_eq!(h.state().selected_object, Some(o.equation)); + assert_eq!(h.state().dialog.as_ref().map(|d| (d.name(), d.title.as_str())), Some(("equation", "Edit Equation")), "equations open the editor"); + h.state_mut().dialog = None; + for obj in [o.shape, o.image, o.slicer] { let p = grab_point(&h, &o, obj); double_click(&mut h, p); diff --git a/crates/ui-egui/tests/keytips.rs b/crates/ui-egui/tests/keytips.rs index e3fc7a9dc..c29f58453 100644 --- a/crates/ui-egui/tests/keytips.rs +++ b/crates/ui-egui/tests/keytips.rs @@ -507,15 +507,15 @@ fn pending_sequences_are_silent_no_ops() { fn pending_sequences_go_live_when_the_command_lands() { // The command lands in the catalog (here: a test-registered stand-in). // The sequence must run it with no table edit. - gridcraft_engine::register_test_command("insert.equation", |s, _| { + gridcraft_engine::register_test_command("insert.object", |s, _| { s.execute("cell.set", json!({"cell": "Z9", "input": "live"}))?; Ok(json!({})) }); let mut h = harness(blank()); - sequence(&mut h, &[Key::N, Key::E], false); // NE -> insert.equation + sequence(&mut h, &[Key::N, Key::J], false); // NJ -> insert.object assert_eq!(value(&h, "Z9"), Value::from("live"), "the pending sequence ran the landed command"); assert_eq!( - h.state().session.journal.iter().filter(|(id, _)| id == "insert.equation").count(), + h.state().session.journal.iter().filter(|(id, _)| id == "insert.object").count(), 1, "the landed command is journaled like any live command" ); diff --git a/crates/xlsx/Cargo.toml b/crates/xlsx/Cargo.toml index 562afff11..60a993680 100644 --- a/crates/xlsx/Cargo.toml +++ b/crates/xlsx/Cargo.toml @@ -16,6 +16,7 @@ test-support = [] [dependencies] gridcraft-core = { workspace = true } gridcraft-formula = { workspace = true } +gridcraft-math = { workspace = true } gridcraft-model = { workspace = true } thiserror = { workspace = true } quick-xml = { workspace = true } diff --git a/crates/xlsx/src/drawing.rs b/crates/xlsx/src/drawing.rs index 0668ca9d8..288927283 100644 --- a/crates/xlsx/src/drawing.rs +++ b/crates/xlsx/src/drawing.rs @@ -1,5 +1,10 @@ -//! DrawingML spreadsheet drawings (`xl/drawings/drawingN.xml`): anchors, pictures, shapes and -//! chart frames. +//! DrawingML spreadsheet drawings (`xl/drawings/drawingN.xml`): anchors, pictures, shapes, +//! equations and chart frames. +//! +//! An equation is written as `mc:AlternateContent`: the `a14` choice is a text box holding the +//! equation as Office Math (`a14:m`), the fallback the same text box holding the LaTeX as plain +//! text. Both carry the source in `descr="gridcraft:latex:…"`, which wins on reading; without it, +//! Office Math in the subset `omml` reads is converted back to LaTeX. use std::fmt::Write as _; @@ -127,15 +132,21 @@ pub fn read_drawing(cx: &mut Ctx<'_>, part: &str, sheet: &mut Sheet) -> Result<( for (n, a) in x.children.iter().enumerate() { let Some(anchor) = read_anchor(a, sheet) else { continue }; let n = n + 1; - // Objects in markup-compatibility blocks: the first choice (chartex frames), else the - // fallback. + // Objects in markup-compatibility blocks: the first choice (chartex frames, or Office Math + // that converts to LaTeX), else the fallback. let objs: Vec<&El> = a .children .iter() .flat_map(|o| match o.name.as_str() { "AlternateContent" => o .child("Choice") - .filter(|c| c.child("graphicFrame").is_some()) + .filter(|c| { + // Office Math is taken only when the box holds one equation that + // converts: otherwise the fallback (a picture or text from the writer) + // keeps everything visible. + c.child("graphicFrame").is_some() + || c.child("sp").and_then(|sp| sp.child("txBody")).and_then(crate::omml::equation_source).is_some() + }) .or_else(|| o.child("Fallback")) .map(|c| c.children.iter().collect()) .unwrap_or_default(), @@ -220,17 +231,41 @@ fn read_shape(obj: &El, anchor: Anchor) -> Option { "rightArrow" => ShapeKind::Arrow, _ => ShapeKind::Rectangle, }; + // An equation: GridCraft's own source while the marker in the description still matches the + // body (another application that edits the Office Math, or the text of a box without Office + // Math, leaves the description alone, as Excel does), else the Office Math converted back to + // LaTeX. A box without Office Math whose text was edited is a text box with the edited text, + // not the stale source, which the next save would write over the edit; as the marker joins + // run text across paragraphs, such a box must also hold the paragraphs the writer wrote. + let descr = obj.path(&["nvSpPr", "cNvPr"]).and_then(|c| c.attr("descr")).unwrap_or(""); + let tb = obj.child("txBody"); + let has_math = tb.is_some_and(|tb| crate::omml::find_math(tb, 0).is_some()); + let source = match (has_math, described_source(descr)) { + (_, Some((marker, src))) if tb.is_some_and(|tb| crate::omml::body_marker(tb) == marker && (has_math || holds_written_text(tb, src))) => { + Some(src.to_string()) + } + (true, _) => tb.and_then(crate::omml::equation_source), + (false, _) => None, + }; + if let Some(text) = source { + let ink = obj.child("txBody").and_then(|tb| first_run_color(tb, 0)).unwrap_or_default(); + return Some(Shape { + id: 0, + kind: ShapeKind::Equation, + anchor, + fill: Color::Auto, + line: ink, + text, + format: Default::default(), + math: Default::default(), + }); + } let fill = sp.child("solidFill").and_then(dml_color).unwrap_or_default(); let line = ln.and_then(|l| l.child("solidFill")).and_then(dml_color).unwrap_or_default(); - let text = obj - .child("txBody") - .map(|tb| { - tb.kids("p").map(|p| p.kids("r").filter_map(|r| r.child("t")).map(|t| t.text.as_str()).collect::()).collect::>().join("\n") - }) - .unwrap_or_default(); + let text = obj.child("txBody").map(body_text).unwrap_or_default(); let mut format = read_text_format(obj.child("txBody")); format.no_line = ln.is_some_and(|l| l.child("noFill").is_some()); - Some(Shape { id: 0, kind, anchor, fill, line, text, format }) + Some(Shape { id: 0, kind, anchor, fill, line, text, format, math: Default::default() }) } /// A shape's text formatting: vertical anchor (`a:bodyPr anchor`), the first paragraph's @@ -269,8 +304,104 @@ fn read_text_format(body: Option<&El>) -> ShapeFormat { f } +/// The `descr` prefix that carries an equation's LaTeX source, as +/// `gridcraft:latex::`: the marker is [`crate::omml::body_marker`] of the text +/// body written beside it, 16 hex digits. +const LATEX_DESCR: &str = "gridcraft:latex:"; + +/// The marker and source of a `gridcraft:latex::` description; `None` for any +/// other description, a malformed marker or a blank source. +fn described_source(descr: &str) -> Option<(&str, &str)> { + let rest = descr.strip_prefix(LATEX_DESCR)?; + let (marker, src) = (rest.get(..16)?, rest.get(17..)?); + (rest.as_bytes().get(16) == Some(&b':') && marker.bytes().all(|b| b.is_ascii_hexdigit()) && !src.trim().is_empty()).then_some((marker, src)) +} + +/// The colour of the first run (DrawingML `rPr` with a fill) under `e`. +fn first_run_color(e: &El, depth: usize) -> Option { + if depth > 12 { + return None; + } + if e.name == "rPr" + && let Some(c) = e.child("solidFill").and_then(dml_color) + { + return Some(c); + } + e.children.iter().find_map(|c| first_run_color(c, depth + 1)) +} + // ---------------------------------------------------------------- writing +/// An equation shape (inside the anchor): Office Math with a plain-text fallback when the source +/// is in the subset OMML covers, else the plain-text box alone. Both carry the source. +fn write_equation(s: &mut String, sh: &Shape, id: usize, theme: &Theme) { + // The font size the equation is drawn at (it scales with its frame), in points. + let size_pt = sh + .math + .get(&sh.text) + .and_then(Result::ok) + .map(|l| { + let (w, h) = (l.width.max(0.01), l.total_height().max(0.01)); + ((sh.anchor.width - 8.0) / w).min((sh.anchor.height - 8.0) / h) * 72.0 / 96.0 + }) + .filter(|v| v.is_finite() && *v >= 1.0) + .unwrap_or(11.0) + .min(409.0); + let fill = srgb(&sh.line, theme).map(|c| format!("{c}")).unwrap_or_default(); + let sz = (size_pt * 100.0).round() as u32; + // Each box carries the marker of its own body beside the source in its description, the + // Office Math box and the plain-text fallback alike; the reader takes the source only while + // the body it reads still matches its marker. + let tx = |body: &str| { + format!( + "{body}" + ) + }; + let marker = |tx: &str| crate::xml::parse(tx.as_bytes()).map(|e| crate::omml::body_marker(&e)).unwrap_or_else(|_| "0".repeat(16)); + let sp = |tx: &str, marker: &str| { + format!( + "{}{tx}", + id - 1, + esc_attr(&format!("{LATEX_DESCR}{marker}:{}", sh.text)), + xfrm(&sh.anchor, "a:xfrm") + ) + }; + let plain = plain_paragraphs(&sh.text, &format!("{fill}")); + match gridcraft_math::to_omml(&sh.text, size_pt, sh.line.resolve(theme)) { + Ok(omml) => { + let math = format!( + "{omml}" + ); + let _ = write!( + s, + "{}{}", + sp(&tx(&math), &marker(&tx(&math))), + sp(&tx(&plain), &marker(&tx(&plain))) + ); + } + Err(_) => s.push_str(&sp(&tx(&plain), &marker(&tx(&plain)))), + } +} + +/// The paragraphs of an equation's plain text, one per line of `text`, each a run with `rpr` as +/// its properties: the fallback body, and the body of an equation outside the Office Math subset. +fn plain_paragraphs(text: &str, rpr: &str) -> String { + text.split('\n').map(|line| format!("{rpr}{}", esc(line))).collect() +} + +/// The text of a text body: the runs of each paragraph, paragraphs joined with newlines. +fn body_text(tb: &El) -> String { + tb.kids("p").map(|p| p.kids("r").filter_map(|r| r.child("t")).map(|t| t.text.as_str()).collect::()).collect::>().join("\n") +} + +/// Whether a body without Office Math still holds the text the writer wrote for `src`: the same +/// paragraphs, read back through the same parser. The marker joins run text across paragraphs, +/// so a paragraph split that changes no character passes it; the text does not. +fn holds_written_text(tb: &El, src: &str) -> bool { + let written = format!("{}", plain_paragraphs(src, "")); + crate::xml::parse(written.as_bytes()).is_ok_and(|w| body_text(&w) == body_text(tb)) +} + /// The opening `` for a model anchor, carrying Excel's `editAs` when the mode /// is not the default: omitted for "Move and size with cells", `editAs="oneCell"` for "Move but /// don't size", `editAs="absolute"` for "Don't move or size". (Excel writes pictures this way; @@ -366,6 +497,12 @@ pub fn write_drawing(sheet: &Sheet, theme: &Theme, objs: &[Obj<'_>]) -> String { xfrm(&ch.anchor, "xdr:xfrm") ); } + Obj::Shape(sh) if sh.kind == ShapeKind::Equation => { + let (open, close) = anchor_open(sheet, &sh.anchor); + close_tag = close; + s.push_str(&open); + write_equation(&mut s, sh, id, theme); + } Obj::Shape(sh) => { let (open, close) = anchor_open(sheet, &sh.anchor); close_tag = close; @@ -382,7 +519,8 @@ pub fn write_drawing(sheet: &Sheet, theme: &Theme, objs: &[Obj<'_>]) -> String { ShapeKind::Ellipse => ("ellipse", false), ShapeKind::Triangle => ("triangle", false), ShapeKind::Line | ShapeKind::Arrow => ("line", false), - ShapeKind::TextBox => ("rect", true), + // Equations are written by `write_equation`; this arm keeps the match whole. + ShapeKind::TextBox | ShapeKind::Equation => ("rect", true), ShapeKind::Icon | ShapeKind::Ink => ("rect", false), }; let _ = write!( diff --git a/crates/xlsx/src/drawing/ink.rs b/crates/xlsx/src/drawing/ink.rs index b5d21f044..0bfc0911b 100644 --- a/crates/xlsx/src/drawing/ink.rs +++ b/crates/xlsx/src/drawing/ink.rs @@ -179,7 +179,16 @@ pub(super) fn read(obj: &El, anchor: Anchor) -> Option { let (path, w, h) = validate_cust_geom(sp)?; let (width, line) = validate_line(sp)?; let text = parse_points(path, &anchor, w, h)?; - Some(Shape { id: 0, kind: ShapeKind::Ink, anchor, fill: Color::Rgb((width as u32) << 16), line, text, format: Default::default() }) + Some(Shape { + id: 0, + kind: ShapeKind::Ink, + anchor, + fill: Color::Rgb((width as u32) << 16), + line, + text, + format: Default::default(), + math: Default::default(), + }) } /// Invalid/native out-of-range data uses the existing rectangle fallback. A successful diff --git a/crates/xlsx/src/lib.rs b/crates/xlsx/src/lib.rs index 56c5661f4..5b94531c4 100644 --- a/crates/xlsx/src/lib.rs +++ b/crates/xlsx/src/lib.rs @@ -21,6 +21,7 @@ mod drawing; mod feature_bags; mod fmla; mod ods; +mod omml; mod package; mod passthrough; mod pivot; diff --git a/crates/xlsx/src/omml.rs b/crates/xlsx/src/omml.rs new file mode 100644 index 000000000..1490b9ea5 --- /dev/null +++ b/crates/xlsx/src/omml.rs @@ -0,0 +1,932 @@ +//! Equations in drawings: Office Math Markup (ECMA-376 Part 1 §22.1) read back into LaTeX for the +//! common subset, the inverse of `gridcraft_math::to_omml`. A construct outside the subset makes +//! the whole equation unreadable as maths (`None`), and the caller keeps it as a text box. + +use crate::xml::El; + +/// Deepest OMML nesting converted. +const MAX_DEPTH: usize = 64; +/// Longest LaTeX the conversion produces before it gives up. `gridcraft_math::check` refuses a +/// longer source anyway, so converting the rest of a wide or long equation would only spend time +/// and memory on a hostile file. +const MAX_OUT_BYTES: usize = gridcraft_math::MAX_SOURCE_BYTES; + +/// The LaTeX for an `m:oMath` (or `m:oMathPara`) element, if every part is in the subset and the +/// result lays out. +pub fn to_latex(e: &El) -> Option { + let out = convert(e)?; + (!out.is_empty() && gridcraft_math::check(&out).is_ok()).then_some(out) +} + +/// The conversion itself, before the final check: `None` for a construct outside the subset, an +/// equation nested past [`MAX_DEPTH`] or one whose LaTeX exceeds [`MAX_OUT_BYTES`]. +fn convert(e: &El) -> Option { + let mut out = String::new(); + match e.name.as_str() { + "oMathPara" => { + let maths: Vec<&El> = e.kids("oMath").collect(); + for (i, m) in maths.iter().enumerate() { + if i > 0 { + out.push_str(" \\\\ "); + } + seq(m, &mut out, 0)?; + } + } + "oMath" => seq(e, &mut out, 0)?, + _ => return None, + } + Some(out.trim().to_string()) +} + +/// The first `m:oMath`/`m:oMathPara` under `e` (an `a14:m` element or a paragraph), searched to a +/// bounded depth. +pub fn find_math(e: &El, depth: usize) -> Option<&El> { + if depth > 8 { + return None; + } + if e.name == "oMathPara" || e.name == "oMath" { + return Some(e); + } + e.children.iter().find_map(|c| find_math(c, depth + 1)) +} + +/// The LaTeX of a DrawingML text body (`a:txBody`) that holds one equation and nothing else: no +/// text in its runs or fields, and no second `m:oMath`/`m:oMathPara`. A body that mixes text and +/// maths, or holds several equations, is `None`, so the caller keeps it as a text box (or reads +/// the writer's fallback) instead of dropping the rest. +pub fn equation_source(tb: &El) -> Option { + let has_text = tb + .kids("p") + .any(|p| p.children.iter().any(|c| matches!(c.name.as_str(), "r" | "fld") && c.child("t").is_some_and(|t| !t.text.trim().is_empty()))); + if has_text || count_math(tb, 0) != 1 { + return None; + } + find_math(tb, 0).and_then(to_latex) +} + +/// How many `m:oMath`/`m:oMathPara` elements are under `e` (not counting those inside one), to +/// the same bounded depth as [`find_math`], stopping at two. +fn count_math(e: &El, depth: usize) -> usize { + if depth > 8 { + return 0; + } + if e.name == "oMathPara" || e.name == "oMath" { + return 1; + } + let mut n = 0; + for c in &e.children { + n += count_math(c, depth + 1); + if n > 1 { + break; + } + } + n +} + +/// A marker for the equation a text body holds: 16 hex digits of an FNV-1a 64-bit hash over what +/// carries the equation's meaning, the OMML element names in document order, the text of the +/// runs, and the properties the converter reads ([`PROPERTIES`]: a run's `sty`, a delimiter's +/// characters, an operator's limits and so on). The writer puts it in the shape's description +/// beside the LaTeX source; the reader takes the source only while the marker still matches the +/// body, so an equation whose Office Math another application edited (Excel leaves the +/// description alone) is converted from that Office Math instead of coming back as the old +/// source. What a re-serialisation may change without changing the equation is left out: every +/// other property (`ctrlPr`, DrawingML run properties such as size and colour), extension lists, +/// the DrawingML containers (`txBody`, `p`, `r`, `fld`, `lstStyle`), attributes, namespace +/// prefixes and whitespace between elements; adjacent runs without read properties are joined, +/// so splitting or merging them changes nothing, while a run with one is a unit of its own, as +/// the converter converts it (it recognises function names per run). A text change, an added +/// run, a structural change, a change to a property the converter reads or a styled run split in +/// two does. +pub fn body_marker(tb: &El) -> String { + let mut h: u64 = 0xcbf2_9ce4_8422_2325; + let mut text = String::new(); + mark(tb, &mut h, &mut text, 0); + mark_text(&mut h, &mut text); + format!("{h:016x}") +} + +fn mark(e: &El, h: &mut u64, text: &mut String, depth: usize) { + if depth > MAX_DEPTH { + return; + } + for c in &e.children { + if c.name.ends_with("Pr") { + // Only the properties the converter reads, each as `name=val`, in a fixed order; they + // close the run text gathered so far, so a property moving between runs is a change. + let read: Vec<(&str, &str)> = + read_properties(&c.name).iter().filter_map(|n| c.child(n).map(|p| (*n, p.attr("val").unwrap_or("")))).collect(); + if !read.is_empty() { + mark_text(h, text); + mark_bytes(h, b"["); + mark_bytes(h, c.name.as_bytes()); + for (n, v) in read { + mark_bytes(h, b" "); + mark_bytes(h, n.as_bytes()); + mark_bytes(h, b"="); + mark_bytes(h, v.as_bytes()); + } + mark_bytes(h, b"]"); + } + continue; + } + if c.name == "extLst" { + continue; + } + match c.name.as_str() { + "r" => { + // A run with a property the converter reads is converted on its own (a function + // name such as `sin` is recognised per run), so its text is hashed as a unit: the + // property closes the text before it, and the run's end closes its own. Runs + // without such properties convert character by character and stay joined. + let styled = c.children.iter().any(|p| p.name.ends_with("Pr") && read_properties(&p.name).iter().any(|n| p.child(n).is_some())); + mark(c, h, text, depth + 1); + if styled { + mark_text(h, text); + } + } + "txBody" | "p" | "fld" | "lstStyle" => mark(c, h, text, depth + 1), + "t" => text.push_str(&c.text), + "br" => text.push('\n'), + name => { + mark_text(h, text); + mark_bytes(h, b"<"); + mark_bytes(h, name.as_bytes()); + mark(c, h, text, depth + 1); + mark_text(h, text); + mark_bytes(h, b">"); + } + } + } +} + +/// Hashes the run text gathered so far, if any, and clears it. +fn mark_text(h: &mut u64, text: &mut String) { + if !text.is_empty() { + mark_bytes(h, b"\""); + mark_bytes(h, text.as_bytes()); + mark_bytes(h, b"\""); + text.clear(); + } +} + +fn mark_bytes(h: &mut u64, bytes: &[u8]) { + for b in bytes { + *h ^= u64::from(*b); + *h = h.wrapping_mul(0x0000_0100_0000_01b3); + } +} + +/// Children in order, skipping property elements. +fn seq(e: &El, out: &mut String, depth: usize) -> Option<()> { + if depth > MAX_DEPTH { + return None; + } + for c in &e.children { + node(c, out, depth + 1)?; + if out.len() > MAX_OUT_BYTES { + return None; + } + } + Some(()) +} + +/// `{…}` around a child's contents (empty group when the child is missing). +fn group(e: Option<&El>, out: &mut String, depth: usize) -> Option<()> { + out.push('{'); + if let Some(e) = e { + seq(e, out, depth)?; + } + out.push('}'); + Some(()) +} + +/// The property elements and the properties in them that this converter reads, so the ones +/// that decide what an equation says beyond its structure and text. [`body_marker`] hashes +/// exactly these, and [`val`] and [`on`] read only these, so the two cannot drift: a property +/// read here without a row in this table reads as absent, which the conversion tests show. +const PROPERTIES: &[(&str, &[&str])] = &[ + ("rPr", &["nor", "sty", "scr"]), + ("fPr", &["type"]), + ("radPr", &["degHide"]), + ("naryPr", &["chr", "limLoc", "subHide", "supHide"]), + ("dPr", &["begChr", "sepChr", "endChr"]), + ("accPr", &["chr"]), + ("barPr", &["pos"]), + ("groupChrPr", &["chr", "pos"]), +]; + +/// The properties of the property element `pr` that the converter reads. +fn read_properties(pr: &str) -> &'static [&'static str] { + PROPERTIES.iter().find(|(name, _)| *name == pr).map_or(&[], |(_, names)| names) +} + +/// The `val` of property `name` in the property element `props`, when the converter reads it. +fn val<'a>(props: Option<&'a El>, name: &str) -> Option<&'a str> { + props.filter(|p| read_properties(&p.name).contains(&name)).and_then(|p| p.child(name)).and_then(|c| c.attr("val")) +} + +/// Whether the switch `name` in the property element `props` is on, when the converter reads it. +fn on(props: Option<&El>, name: &str) -> bool { + props + .filter(|p| read_properties(&p.name).contains(&name)) + .and_then(|p| p.child(name)) + .is_some_and(|c| c.attr("val").is_none_or(|v| v == "1" || v == "on" || v == "true")) +} + +/// LaTeX for one character of a math run. +fn char_latex(c: char) -> Option<&'static str> { + Some(match c { + 'α' => "\\alpha ", + 'β' => "\\beta ", + 'γ' => "\\gamma ", + 'δ' => "\\delta ", + 'ε' => "\\varepsilon ", + 'ϵ' => "\\epsilon ", + 'ζ' => "\\zeta ", + 'η' => "\\eta ", + 'θ' => "\\theta ", + 'ϑ' => "\\vartheta ", + 'ι' => "\\iota ", + 'κ' => "\\kappa ", + 'λ' => "\\lambda ", + 'μ' => "\\mu ", + 'ν' => "\\nu ", + 'ξ' => "\\xi ", + 'π' => "\\pi ", + 'ρ' => "\\rho ", + 'σ' => "\\sigma ", + 'τ' => "\\tau ", + 'υ' => "\\upsilon ", + 'φ' => "\\varphi ", + 'ϕ' => "\\phi ", + 'χ' => "\\chi ", + 'ψ' => "\\psi ", + 'ω' => "\\omega ", + 'Γ' => "\\Gamma ", + 'Δ' => "\\Delta ", + 'Θ' => "\\Theta ", + 'Λ' => "\\Lambda ", + 'Ξ' => "\\Xi ", + 'Π' => "\\Pi ", + 'Σ' => "\\Sigma ", + 'Υ' => "\\Upsilon ", + 'Φ' => "\\Phi ", + 'Ψ' => "\\Psi ", + 'Ω' => "\\Omega ", + '±' => "\\pm ", + '∓' => "\\mp ", + '×' => "\\times ", + '÷' => "\\div ", + '⋅' | '·' => "\\cdot ", + '−' => "-", + '≤' => "\\le ", + '≥' => "\\ge ", + '≠' => "\\ne ", + '≈' => "\\approx ", + '≡' => "\\equiv ", + '∼' => "\\sim ", + '∝' => "\\propto ", + '∞' => "\\infty ", + '∂' => "\\partial ", + '∇' => "\\nabla ", + '∀' => "\\forall ", + '∃' => "\\exists ", + '∈' => "\\in ", + '∉' => "\\notin ", + '⊂' => "\\subset ", + '⊆' => "\\subseteq ", + '⊃' => "\\supset ", + '∪' => "\\cup ", + '∩' => "\\cap ", + '∅' => "\\emptyset ", + '→' => "\\to ", + '←' => "\\leftarrow ", + '⇒' => "\\Rightarrow ", + '⇔' => "\\Leftrightarrow ", + '…' => "\\ldots ", + '⋯' => "\\cdots ", + '∘' => "\\circ ", + '{' => "\\{", + '}' => "\\}", + '%' => "\\%", + '#' => "\\#", + '&' => "\\&", + '$' => "\\$", + '_' => "\\_", + '\\' => "\\backslash ", + '~' => "\\sim ", + '^' => "\\hat{}", + _ => return None, + }) +} + +fn run_text(r: &El) -> String { + r.kids("t").map(|t| t.text.as_str()).collect() +} + +/// Appends `s` escaped for `\text{…}`; `None` once the LaTeX exceeds [`MAX_OUT_BYTES`], checked +/// after every character so that a long run never expands in full. +fn escape_text(s: &str, out: &mut String) -> Option<()> { + for c in s.chars() { + match c { + '{' | '}' | '%' | '#' | '&' | '$' | '_' => { + out.push('\\'); + out.push(c); + } + '\\' => out.push_str("\\textbackslash "), + '^' | '~' => out.push(' '), + c => out.push(c), + } + if out.len() > MAX_OUT_BYTES { + return None; + } + } + Some(()) +} + +/// Known operator names written upright in a run (`sin`, `log`, `lim`). +const FUNCTIONS: &[&str] = &[ + "sin", "cos", "tan", "cot", "sec", "csc", "arcsin", "arccos", "arctan", "sinh", "cosh", "tanh", "log", "ln", "lg", "exp", "lim", "max", "min", + "sup", "inf", "det", "gcd", "deg", "dim", "ker", "arg", "Pr", "liminf", "limsup", +]; + +fn run(r: &El, out: &mut String) -> Option<()> { + let text = run_text(r); + if text.is_empty() { + return Some(()); + } + let rpr = r.child("rPr"); + if on(rpr, "nor") { + out.push_str("\\text{"); + escape_text(&text, out)?; + out.push('}'); + return Some(()); + } + let plain = val(rpr, "sty") == Some("p"); + if plain && FUNCTIONS.contains(&text.trim()) { + out.push('\\'); + out.push_str(text.trim()); + out.push(' '); + return Some(()); + } + let wrap = match (val(rpr, "scr"), val(rpr, "sty")) { + (Some("double-struck"), _) => Some("\\mathbb{"), + (Some("script"), _) => Some("\\mathcal{"), + (Some("fraktur"), _) => Some("\\mathfrak{"), + (Some("sans-serif"), _) => Some("\\mathsf{"), + (Some("monospace"), _) => Some("\\mathtt{"), + (_, Some("b")) => Some("\\mathbf{"), + (_, Some("bi")) => Some("\\boldsymbol{"), + (_, Some("p")) if text.chars().any(|c| c.is_alphabetic()) => Some("\\mathrm{"), + _ => None, + }; + if let Some(w) = wrap { + out.push_str(w); + } + for c in text.chars() { + match char_latex(c) { + Some(l) => out.push_str(l), + None => out.push(c), + } + // Checked per character, not from the run's byte length: a character may map to a + // longer macro or a shorter sign, so only the LaTeX produced counts. + if out.len() > MAX_OUT_BYTES { + return None; + } + } + if wrap.is_some() { + out.push('}'); + } + Some(()) +} + +/// A delimiter character as a `\left`/`\right` argument. +fn delim(c: Option<&str>, default: &str) -> String { + let c = c.unwrap_or(default); + match c { + "" => ".".into(), + "{" => "\\{".into(), + "}" => "\\}".into(), + "⟨" | "〈" => "\\langle".into(), + "⟩" | "〉" => "\\rangle".into(), + "⌊" => "\\lfloor".into(), + "⌋" => "\\rfloor".into(), + "⌈" => "\\lceil".into(), + "⌉" => "\\rceil".into(), + "‖" => "\\|".into(), + "∣" => "|".into(), + other => other.into(), + } +} + +fn nary_op(chr: &str) -> Option<&'static str> { + Some(match chr { + "∑" => "\\sum", + "∏" => "\\prod", + "∐" => "\\coprod", + "∫" => "\\int", + "∬" => "\\iint", + "∭" => "\\iiint", + "∮" => "\\oint", + "⋃" => "\\bigcup", + "⋂" => "\\bigcap", + "⋁" => "\\bigvee", + "⋀" => "\\bigwedge", + "⨁" => "\\bigoplus", + "⨂" => "\\bigotimes", + _ => return None, + }) +} + +fn accent(chr: &str) -> Option<&'static str> { + Some(match chr { + "\u{0302}" | "^" => "\\hat", + "\u{030C}" => "\\check", + "\u{0303}" | "~" => "\\tilde", + "\u{0301}" => "\\acute", + "\u{0300}" => "\\grave", + "\u{0307}" => "\\dot", + "\u{0308}" => "\\ddot", + "\u{0306}" => "\\breve", + "\u{0305}" | "\u{0304}" | "¯" => "\\bar", + "\u{20D7}" | "→" => "\\vec", + "\u{20D6}" => "\\overleftarrow", + "\u{030A}" => "\\mathring", + _ => return None, + }) +} + +fn node(e: &El, out: &mut String, depth: usize) -> Option<()> { + if depth > MAX_DEPTH { + return None; + } + match e.name.as_str() { + // Properties and run-level DrawingML are not content. + n if n.ends_with("Pr") => {} + "r" => run(e, out)?, + "f" => fraction(e, out, depth)?, + "rad" => radical(e, out, depth)?, + "sSup" | "sSub" | "sSubSup" => scripts(e, out, depth)?, + "nary" => nary(e, out, depth)?, + "d" => delimiters(e, out, depth)?, + "m" => matrix(e, out, depth)?, + "eqArr" => equation_array(e, out, depth)?, + "acc" => { + out.push_str(accent(val(e.child("accPr"), "chr").unwrap_or("\u{0302}"))?); + group(e.child("e"), out, depth)?; + } + "bar" => { + out.push_str(if val(e.child("barPr"), "pos") == Some("top") { "\\overline" } else { "\\underline" }); + group(e.child("e"), out, depth)?; + } + "groupChr" => group_char(e, out, depth)?, + "limLow" | "limUpp" => limit(e, out, depth)?, + "func" => { + seq(e.child("fName")?, out, depth)?; + out.push(' '); + group(e.child("e"), out, depth)?; + } + "borderBox" => { + out.push_str("\\boxed"); + group(e.child("e"), out, depth)?; + } + "phant" => { + out.push_str("\\phantom"); + group(e.child("e"), out, depth)?; + } + "box" => group(e.child("e"), out, depth)?, + _ => return None, + } + Some(()) +} + +/// `m:f`: a fraction, or a binomial-style stack without a bar. +fn fraction(e: &El, out: &mut String, depth: usize) -> Option<()> { + if val(e.child("fPr"), "type") == Some("noBar") { + out.push_str("\\genfrac{}{}{0pt}{}"); + } else { + out.push_str("\\frac"); + } + group(e.child("num"), out, depth)?; + group(e.child("den"), out, depth) +} + +/// `m:rad`: a square root, or a root with a degree. +fn radical(e: &El, out: &mut String, depth: usize) -> Option<()> { + let hide = on(e.child("radPr"), "degHide"); + match e.child("deg").filter(|d| !hide && !d.children.is_empty()) { + Some(d) => { + out.push_str("\\sqrt["); + seq(d, out, depth)?; + out.push(']'); + } + None => out.push_str("\\sqrt"), + } + group(e.child("e"), out, depth) +} + +/// `m:sSup`, `m:sSub`, `m:sSubSup`: a base with scripts. +fn scripts(e: &El, out: &mut String, depth: usize) -> Option<()> { + group(e.child("e"), out, depth)?; + if let Some(sub) = e.child("sub") { + out.push('_'); + group(Some(sub), out, depth)?; + } + if let Some(sup) = e.child("sup") { + out.push('^'); + group(Some(sup), out, depth)?; + } + Some(()) +} + +/// `m:nary`: a large operator with its limits and operand. +fn nary(e: &El, out: &mut String, depth: usize) -> Option<()> { + let p = e.child("naryPr"); + let op = nary_op(val(p, "chr").unwrap_or("∫"))?; + out.push_str(op); + if val(p, "limLoc") == Some("undOvr") && op.contains("int") { + out.push_str("\\limits"); + } + if !on(p, "subHide") { + out.push('_'); + group(e.child("sub"), out, depth)?; + } + if !on(p, "supHide") { + out.push('^'); + group(e.child("sup"), out, depth)?; + } + out.push(' '); + group(e.child("e"), out, depth) +} + +/// `m:d`: delimiters around one or more parts, with a separator between them. +fn delimiters(e: &El, out: &mut String, depth: usize) -> Option<()> { + let p = e.child("dPr"); + out.push_str("\\left"); + out.push_str(&delim(val(p, "begChr"), "(")); + out.push(' '); + let sep = delim(val(p, "sepChr"), "|"); + for (i, part) in e.kids("e").enumerate() { + if i > 0 { + out.push_str(" \\middle"); + out.push_str(&sep); + out.push(' '); + } + seq(part, out, depth)?; + } + out.push_str(" \\right"); + out.push_str(&delim(val(p, "endChr"), ")")); + Some(()) +} + +/// `m:m`: a matrix, row by row. +fn matrix(e: &El, out: &mut String, depth: usize) -> Option<()> { + out.push_str("\\begin{matrix}"); + for (i, row) in e.kids("mr").enumerate() { + if i > 0 { + out.push_str(" \\\\ "); + } + for (k, cell) in row.kids("e").enumerate() { + if k > 0 { + out.push_str(" & "); + } + seq(cell, out, depth)?; + } + } + out.push_str("\\end{matrix}"); + Some(()) +} + +/// `m:eqArr`: equations stacked one per row. +fn equation_array(e: &El, out: &mut String, depth: usize) -> Option<()> { + out.push_str("\\begin{gathered}"); + for (i, row) in e.kids("e").enumerate() { + if i > 0 { + out.push_str(" \\\\ "); + } + seq(row, out, depth)?; + } + out.push_str("\\end{gathered}"); + Some(()) +} + +/// Effective brace orientation, including the character default and its precedence over position. +fn group_char_is_over(e: &El) -> bool { + let p = e.child("groupChrPr"); + match val(p, "chr").unwrap_or("\u{23DF}") { + "\u{23DE}" => true, + "\u{23DF}" => false, + _ => val(p, "pos") == Some("top"), + } +} + +/// `m:groupChr`: a brace over or under its content. +fn group_char(e: &El, out: &mut String, depth: usize) -> Option<()> { + out.push_str(if group_char_is_over(e) { "\\overbrace" } else { "\\underbrace" }); + group(e.child("e"), out, depth) +} + +/// `m:limLow`, `m:limUpp`: a limit under or over its base. +fn limit(e: &El, out: &mut String, depth: usize) -> Option<()> { + let low = e.name == "limLow"; + let base = e.child("e"); + // A function name with a limit (lim, max) takes it as a subscript with \limits. + let base_is_fn = base.is_some_and(|b| { + let t: String = b.kids("r").map(run_text).collect(); + b.children.iter().all(|c| c.name == "r") && FUNCTIONS.contains(&t.trim()) + }); + // A brace alone under the limit is a labelled brace, as `gridcraft_math::to_omml` writes + // `\underbrace{x}_{n}` and `\overbrace{x}^{n}`: the label is the brace's own script, below + // for an underbrace and above for an overbrace. An opposite-side limit keeps its placement. + let brace = base + .and_then(|b| { + let mut content = b.children.iter().filter(|c| !c.name.ends_with("Pr")); + content.next().filter(|c| c.name == "groupChr" && content.next().is_none()) + }) + .filter(|brace| low != group_char_is_over(brace)); + if base_is_fn { + seq(base?, out, depth)?; + out.push_str(if low { "\\limits_" } else { "\\limits^" }); + group(e.child("lim"), out, depth)?; + } else if let Some(brace) = brace { + group_char(brace, out, depth + 1)?; + out.push(if low { '_' } else { '^' }); + group(e.child("lim"), out, depth)?; + } else { + out.push_str(if low { "\\underset" } else { "\\overset" }); + group(e.child("lim"), out, depth)?; + group(base, out, depth)?; + } + Some(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn el(xml: &str) -> El { + crate::xml::parse(format!("{xml}").as_bytes()).unwrap() + } + + fn latex(xml: &str) -> Option { + to_latex(&el(xml)) + } + + #[test] + fn the_subset_reads_back() { + assert_eq!(latex("x+1").as_deref(), Some("x+1")); + assert_eq!(latex("ab").as_deref(), Some("\\frac{a}{b}")); + assert_eq!(latex("x2").as_deref(), Some("{x}^{2}")); + assert_eq!( + latex("π").as_deref(), + Some("\\sqrt{\\pi }") + ); + let sum = latex( + "i=1ni", + ); + assert_eq!(sum.as_deref(), Some("\\sum_{i=1}^{n} {i}")); + // An integral without a character, with hidden limits. + assert_eq!(latex("f").as_deref(), Some("\\int {f}")); + let m = latex( + "1001", + ); + assert_eq!(m.as_deref(), Some("\\left[ \\begin{matrix}1 & 0 \\\\ 0 & 1\\end{matrix} \\right]")); + assert_eq!(latex("sinx").as_deref(), Some("\\sin x")); + assert_eq!(latex("R").as_deref(), Some("\\mathbb{R}")); + assert_eq!(latex("if {x}").as_deref(), Some("\\text{if \\{x\\}}")); + assert_eq!( + latex("x").as_deref(), + Some("\\hat{x}") + ); + } + + #[test] + fn what_is_outside_the_subset_is_none() { + assert_eq!(latex(""), None); + assert_eq!(latex(""), None, "an empty equation is not maths"); + assert_eq!(latex(""), None); + // Deep nesting stops instead of recursing without bound. + let deep = format!("{}x{}", "".repeat(100), "".repeat(100)); + assert_eq!(latex(&deep), None); + } + + #[test] + #[allow(clippy::disallowed_methods)] // tests run only off wasm, where the clock exists + fn latex_past_the_source_cap_stops_converting_and_falls_back() { + // 100,000 sibling runs: the conversion stops once its LaTeX exceeds the cap that + // `gridcraft_math::check` enforces, instead of building the whole string and then + // refusing it, so the caller falls back to the writer's text in bounded time. + let wide = el(&"x".repeat(100_000)); + let start = std::time::Instant::now(); + assert_eq!(convert(&wide), None); + assert_eq!(to_latex(&wide), None); + assert!(start.elapsed() < std::time::Duration::from_secs(5), "{:?}", start.elapsed()); + // One run whose text alone is past the cap is refused before it is escaped (each + // backslash would otherwise grow to `\textbackslash `). + let long = el(&format!("{}", "\\".repeat(MAX_OUT_BYTES + 1))); + assert_eq!(convert(&long), None); + // Groups count the braces they add even when no run adds text. + let groups = el(&"".repeat(MAX_OUT_BYTES / 2 + 1)); + assert_eq!(convert(&groups), None); + // At the cap the equation still reads back, and the cap counts the LaTeX produced, not + // the run's bytes: a minus sign (three bytes of UTF-8) becomes a one-byte `-`. + let at_cap = el(&"x".repeat(MAX_OUT_BYTES)); + assert_eq!(to_latex(&at_cap).map(|s| s.len()), Some(MAX_OUT_BYTES)); + let minus = el(&format!("{}", "\u{2212}".repeat(MAX_OUT_BYTES))); + assert_eq!(to_latex(&minus), Some("-".repeat(MAX_OUT_BYTES))); + } + + #[test] + fn a_blank_middle_row_comes_back_and_a_trailing_one_does_not() { + // The writer keeps an author's blank row and drops only the empty row a trailing `\\` + // leaves; the reader joins rows with `\\`, so the count of `\\` is the row count less one. + let back = |src: &str| { + let omml = gridcraft_math::to_omml(src, 11.0, None).unwrap(); + to_latex(&crate::xml::parse(format!("{omml}").as_bytes()).unwrap().children[0].clone()).unwrap() + }; + let cases = back("\\begin{cases} x \\\\ \\\\ y \\end{cases}"); + assert_eq!(cases.matches("\\\\").count(), 2, "three rows: {cases}"); + let trailing = back("\\begin{matrix} a \\\\ b \\\\ \\end{matrix}"); + assert_eq!(trailing.matches("\\\\").count(), 1, "two rows: {trailing}"); + } + + #[test] + fn a_space_in_text_mode_survives_the_round_trip() { + let omml = gridcraft_math::to_omml("\\text{if } x", 11.0, None).unwrap(); + let back = + to_latex(&crate::xml::parse(format!("{omml}").as_bytes()).unwrap().children[0].clone()).unwrap(); + assert!(back.contains("\\text{ }") || back.contains("if }"), "the space after `if` is still there: {back}"); + let spaces = |l: &gridcraft_math::MathList| l.items.iter().filter(|i| matches!(i, gridcraft_math::Item::Glyph(g) if g.ch == ' ')).count(); + let (a, b) = (gridcraft_math::layout("\\text{if } x").unwrap(), gridcraft_math::layout(&back).unwrap()); + assert_eq!(spaces(&a), spaces(&b), "{back}"); + } + + #[test] + fn a_limit_on_the_opposite_side_of_a_brace_keeps_its_placement() { + for (properties, brace, opposite, same, script) in [ + (r#""#, "underbrace", "limUpp", "limLow", '_'), + (r#""#, "overbrace", "limLow", "limUpp", '^'), + (r#""#, "underbrace", "limUpp", "limLow", '_'), + (r#""#, "overbrace", "limLow", "limUpp", '^'), + ] { + let base = format!("{properties}x"); + let limit = |kind: &str| format!("{base}n"); + let placement = if opposite == "limLow" { "underset" } else { "overset" }; + assert_eq!(latex(&limit(opposite)), Some(format!("\\{placement}{{n}}{{\\{brace}{{x}}}}")), "{properties}"); + assert_eq!(latex(&limit(same)), Some(format!("\\{brace}{{x}}{script}{{n}}")), "{properties}"); + } + } + + #[test] + fn a_labelled_brace_reads_back_with_a_script_on_its_other_side() { + // A limit over a brace alone is the brace's label, which LaTeX writes as the brace's own + // script: `\underbrace{x}_{n}` and `\overbrace{x}^{n}`, as the writer writes them. A + // script around the whole (`m:sSup` around `m:limLow`) then comes back as well, so + // `\underbrace{x}_{n}^{2}` keeps its exponent through the round trip. A limit over any + // other base is still \underset or \overset. + let brace = |chr: &str, pos: &str| { + format!( + "x" + ) + }; + let lim = "n"; + let under = format!("{}{lim}", brace("\u{23DF}", "bot")); + assert_eq!(latex(&under).as_deref(), Some("\\underbrace{x}_{n}")); + assert_eq!( + latex(&format!("{under}2")).as_deref(), + Some("{\\underbrace{x}_{n}}^{2}") + ); + assert_eq!(latex(&format!("{}{lim}", brace("\u{23DE}", "top"))).as_deref(), Some("\\overbrace{x}^{n}")); + // Control properties beside the brace do not make it a second child. + assert_eq!( + latex(&format!("{}{lim}", brace("\u{23DF}", "bot"))).as_deref(), + Some("\\underbrace{x}_{n}") + ); + assert_eq!(latex(&format!("a{lim}")).as_deref(), Some("\\underset{n}{a}")); + assert_eq!( + latex(&format!("{}+1{lim}", brace("\u{23DF}", "bot"))).as_deref(), + Some("\\underset{n}{\\underbrace{x}+1}") + ); + // Written by GridCraft and read back, both scripts are there and the glyphs are the same. + let back = |src: &str| { + let omml = gridcraft_math::to_omml(src, 11.0, None).unwrap(); + to_latex(&crate::xml::parse(format!("{omml}").as_bytes()).unwrap().children[0].clone()).unwrap() + }; + let glyphs = |l: &str| -> Vec { + let mut g: Vec = gridcraft_math::layout(l) + .unwrap() + .items + .iter() + .filter_map(|i| if let gridcraft_math::Item::Glyph(g) = i { Some(g.ch) } else { None }) + .filter(|c| !c.is_whitespace()) + .collect(); + g.sort_unstable(); + g + }; + for (src, expected) in [ + ("\\underbrace{x}_{n}^{2}", "{\\underbrace{x}_{n}}^{2}"), + ("\\overbrace{x}^{n}_{m}", "{\\overbrace{x}^{n}}_{m}"), + ("\\underbrace{x}^{2}", "{\\underbrace{x}}^{2}"), + ("\\underbrace{x}_{n}", "\\underbrace{x}_{n}"), + ] { + let b = back(src); + assert_eq!(b, expected); + assert_eq!(glyphs(&b), glyphs(src), "{src} came back as {b}"); + } + } + + #[test] + fn the_body_marker_follows_the_equation_not_its_serialisation() { + let tb = |inner: &str| { + crate::xml::parse( + format!( + "{inner}" + ) + .as_bytes(), + ) + .unwrap() + }; + let base = body_marker(&tb("x+1")); + assert_eq!(base.len(), 16); + assert!(base.bytes().all(|b| b.is_ascii_hexdigit())); + // Run boundaries, whitespace between elements, attributes, and properties the converter + // does not read (`ctrlPr`, an extension list, DrawingML size, colour and font) are not + // the equation: what Excel re-serialises differently leaves the marker alone. + assert_eq!(body_marker(&tb("x+1")), base); + assert_eq!(body_marker(&tb("\n \n x+1\n")), base); + assert_eq!(body_marker(&tb("x+1")), base); + assert_eq!(body_marker(&tb("x+1")), base); + assert_eq!( + body_marker(&tb( + "x+1" + )), + base + ); + assert_eq!( + body_marker(&tb("x+1")), + base, + "a property the converter never reads" + ); + // Properties the converter reads are: a run's style, an accent's character, a delimiter's + // brackets, an operator's limits; the same property on another run is a change too. + let styled = |sty: &str| body_marker(&tb(&format!("x+1"))); + assert_ne!(styled("p"), base); + assert_ne!(styled("p"), styled("b")); + assert_ne!(styled("p"), body_marker(&tb("x+1"))); + // The converter converts a run with a read property on its own and recognises `sin` per + // run, so a styled `sin` split into `s` and `in` (the second styled or not) is a change, + // while plain runs convert character by character and may be split or joined freely. + let upright = |runs: &[(&str, bool)]| { + let xml: String = runs + .iter() + .map(|(t, p)| format!("{}{t}", if *p { "" } else { "" })) + .collect(); + body_marker(&tb(&xml)) + }; + assert_ne!(upright(&[("sin", true)]), upright(&[("s", true), ("in", false)])); + assert_ne!(upright(&[("sin", true)]), upright(&[("s", true), ("in", true)])); + assert_ne!(upright(&[("sin", true), ("x", false)]), upright(&[("s", true), ("inx", false)])); + assert_eq!(upright(&[("ab", false)]), upright(&[("a", false), ("b", false)])); + assert_eq!(upright(&[("sin", true), ("xy", false)]), upright(&[("sin", true), ("x", false), ("y", false)])); + let accented = + |chr: &str| body_marker(&tb(&format!("x"))); + assert_ne!(accented("\u{0302}"), accented("\u{0303}")); + let bracketed = |b: &str, e: &str| { + body_marker(&tb(&format!( + "x" + ))) + }; + assert_ne!(bracketed("(", ")"), bracketed("[", "]")); + assert_ne!( + body_marker(&tb("i")), + body_marker(&tb( + "i" + )) + ); + // Text and structure are the equation. + assert_ne!(body_marker(&tb("x+2")), base); + assert_ne!(body_marker(&tb("x+1 ")), base, "a space added is a change"); + assert_ne!(body_marker(&tb("x+1")), base); + assert_ne!(body_marker(&tb("x+1")), base); + assert_ne!(body_marker(&tb("x+1")), body_marker(&tb(""))); + } + + #[test] + fn writing_then_reading_keeps_the_maths() { + for (_, src) in gridcraft_math::GALLERY { + let omml = gridcraft_math::to_omml(src, 11.0, None).unwrap(); + let back = + to_latex(&crate::xml::parse(format!("{omml}").as_bytes()).unwrap().children[0].clone()).unwrap(); + let (a, b) = (gridcraft_math::layout(src).unwrap(), gridcraft_math::layout(&back).unwrap()); + let glyphs = |l: &gridcraft_math::MathList| -> String { + l.items + .iter() + .filter_map(|i| if let gridcraft_math::Item::Glyph(g) = i { Some(g.ch) } else { None }) + .filter(|c| !c.is_whitespace()) + .collect() + }; + assert_eq!(glyphs(&a), glyphs(&b), "{src} came back as {back}"); + } + } +} diff --git a/crates/xlsx/src/tests.rs b/crates/xlsx/src/tests.rs index c03ff01bc..91d04f281 100644 --- a/crates/xlsx/src/tests.rs +++ b/crates/xlsx/src/tests.rs @@ -9,6 +9,7 @@ mod chart_data_table; mod chart_ref_offsets; mod csv_tests; mod encrypted_tests; +mod equation_tests; mod fixtures; mod google_sheets_dummy; mod ink_roundtrip; diff --git a/crates/xlsx/src/tests/equation_tests.rs b/crates/xlsx/src/tests/equation_tests.rs new file mode 100644 index 000000000..998364465 --- /dev/null +++ b/crates/xlsx/src/tests/equation_tests.rs @@ -0,0 +1,471 @@ +//! Equations in xlsx: Office Math with a plain-text fallback, the LaTeX source in `descr`, and +//! Office Math alone (as other writers save it) read back into LaTeX. + +use std::collections::BTreeMap; +use std::io::{Read, Write}; +use std::sync::Arc; + +use gridcraft_core::CellRef; +use gridcraft_model::*; + +use crate::{read_xlsx, write_xlsx}; + +fn equation(id: u32, cell: &str, latex: &str, ink: Color) -> Shape { + Shape { + id, + kind: ShapeKind::Equation, + anchor: Anchor { cell: CellRef::parse(cell).unwrap(), width: 200.0, height: 60.0, mode: AnchorMode::MoveOnly, ..Default::default() }, + fill: Color::Auto, + line: ink, + text: latex.into(), + format: Default::default(), + math: Default::default(), + } +} + +fn book(shapes: Vec) -> Workbook { + let mut wb = Workbook::new(); + let mut sh = (*wb.sheets[0]).clone(); + for mut s in shapes { + s.math.refresh(&s.text.clone()); + sh.shapes.push(s); + } + wb.sheets[0] = Arc::new(sh); + wb +} + +fn unzip(bytes: &[u8]) -> BTreeMap> { + let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let mut m = BTreeMap::new(); + for i in 0..z.len() { + let mut f = z.by_index(i).unwrap(); + let mut b = Vec::new(); + f.read_to_end(&mut b).unwrap(); + m.insert(f.name().to_string(), b); + } + m +} + +fn rezip(parts: &BTreeMap>) -> Vec { + let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + for (name, data) in parts { + out.start_file(name.as_str(), zip::write::SimpleFileOptions::default()).unwrap(); + out.write_all(data).unwrap(); + } + out.finish().unwrap().into_inner() +} + +fn drawing(parts: &BTreeMap>) -> String { + String::from_utf8(parts.iter().find(|(k, _)| k.starts_with("xl/drawings/drawing")).unwrap().1.clone()).unwrap() +} + +fn shapes(wb: &Workbook) -> Vec { + wb.sheets[0].shapes.clone() +} + +/// Whether the drawing `d` describes an equation with `src`: `descr="gridcraft:latex:<16 hex>:src"`. +fn described(d: &str, src: &str) -> bool { + d.match_indices("descr=\"gridcraft:latex:").any(|(i, m)| { + let rest = &d[i + m.len()..]; + rest.len() > 17 + && rest.as_bytes()[..16].iter().all(u8::is_ascii_hexdigit) + && rest[16..].starts_with(':') + && rest[17..].starts_with(&format!("{src}\"")) + }) +} + +#[test] +fn equations_write_office_math_with_a_text_fallback_and_read_back() { + let quad = "x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}"; + let cd = "\\begin{CD} A @>f>> B \\end{CD}"; + let wb = book(vec![ + equation(1, "B2", quad, Color::Rgb(0x1F4E79)), + equation(2, "B8", cd, Color::Auto), + equation(3, "B12", "a < b \\text{ \"q\" }\nc", Color::Auto), + ]); + let bytes = write_xlsx(&wb).unwrap(); + let parts = unzip(&bytes); + let d = drawing(&parts); + // The quadratic formula: a choice with Office Math and a fallback with the source as text. + assert!(d.contains("")); + assert!(d.contains("")); + assert!(d.contains("") && d.contains("")); + assert!(described(&d, "x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}"), "{d}"); + assert!(d.contains("x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}"), "the fallback shows the source"); + assert!(d.contains(""), "the ink colour is kept"); + // The colour sits on the Office Math runs themselves, not only on the text fallback (which + // the reader would take if the Choice were ever filtered out). + let math = &d[d.find("").unwrap()..d.find("").unwrap()]; + assert!( + math.contains(""), + "the Office Math runs carry the ink colour: {math}" + ); + // A commutative diagram is outside the Office Math subset: text only, no choice. + assert_eq!(d.matches(" String { + gridcraft_math::layout(t) + .unwrap() + .items + .iter() + .filter_map(|i| if let gridcraft_math::Item::Glyph(g) = i { Some(g.ch) } else { None }) + .collect() + }; + assert_eq!(glyphs(&x.text), glyphs(src), "{} from {src}", x.text); + } +} + +#[test] +fn a_text_box_stays_a_text_box_and_hostile_sources_survive() { + let mut wb = book(vec![equation(1, "B2", &"{".repeat(40), Color::Auto), equation(2, "B6", "\u{0}\u{1F600} & ]]> ", Color::Auto)]); + let mut sh = (*wb.sheets[0]).clone(); + sh.shapes.push(Shape { + id: 3, + kind: ShapeKind::TextBox, + anchor: Anchor { cell: CellRef::parse("D2").unwrap(), width: 80.0, height: 30.0, ..Default::default() }, + fill: Color::Auto, + line: Color::Auto, + text: "plain".into(), + format: Default::default(), + math: Default::default(), + }); + wb.sheets[0] = Arc::new(sh); + let (back, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap(); + let s = shapes(&back); + assert_eq!(s.len(), 3); + assert_eq!(s[0].text, "{".repeat(40)); + assert_eq!(s[0].kind, ShapeKind::Equation); + assert_eq!(s[1].text, "\u{1F600} & ]]> ", "XML-illegal characters are dropped, the rest kept"); + assert_eq!(s[2].kind, ShapeKind::TextBox); + assert_eq!(s[2].text, "plain"); +} + +#[test] +fn office_math_edited_elsewhere_reads_back_as_edited() { + // GridCraft writes the source in the description and Office Math in the body. Another + // application that edits the Office Math (Excel) leaves the description alone; the marker in + // the description no longer matches the body, so the Office Math is converted and the stale + // source dropped, and the next save writes the edited equation. + let wb = book(vec![equation(1, "B2", "x^2", Color::Rgb(0x1F4E79))]); + let mut parts = unzip(&write_xlsx(&wb).unwrap()); + let name = parts.keys().find(|k| k.starts_with("xl/drawings/drawing")).unwrap().clone(); + let mut d = drawing(&parts); + assert!(described(&d, "x^2")); + // Excel's edit: the base `x` becomes `y`, in the Office Math only. + let (m0, m1) = (d.find("").unwrap(), d.find("").unwrap()); + let edited = d[m0..m1].replacen("x", "y", 1); + assert_ne!(edited, d[m0..m1]); + d.replace_range(m0..m1, &edited); + parts.insert(name, d.into_bytes()); + let (back, _) = read_xlsx(&rezip(&parts)).unwrap(); + let s = shapes(&back); + assert_eq!(s.len(), 1); + assert_eq!(s[0].kind, ShapeKind::Equation); + assert_eq!(s[0].text, "{y}^{2}", "the edited Office Math, converted"); + assert_eq!(s[0].line, Color::Rgb(0x1F4E79)); + // Saving writes the edited equation; the old source is gone. + let again = drawing(&unzip(&write_xlsx(&back).unwrap())); + assert!(described(&again, "{y}^{2}") && !again.contains("x^2"), "{again}"); +} + +#[test] +fn a_marker_over_garbage_office_math_falls_back_without_panicking() { + // Office Math that is not the marker's and not in the subset either: the Choice is left + // alone and the fallback text box, which carries the description, is read as the equation. + let wb = book(vec![equation(1, "B2", "x^2", Color::Auto)]); + let mut parts = unzip(&write_xlsx(&wb).unwrap()); + let name = parts.keys().find(|k| k.starts_with("xl/drawings/drawing")).unwrap().clone(); + let mut d = drawing(&parts); + let (m0, m1) = (d.find("").unwrap(), d.find("").unwrap()); + d.replace_range(m0..m1 + "".len(), "junk"); + parts.insert(name.clone(), d.into_bytes()); + let (back, _) = read_xlsx(&rezip(&parts)).unwrap(); + let s = shapes(&back); + assert_eq!(s.len(), 1); + assert_eq!(s[0].kind, ShapeKind::Equation); + assert_eq!(s[0].text, "x^2"); + // A description whose marker is malformed is not GridCraft's source: with Office Math the + // maths is converted; without it the box is text. + let d2 = drawing(&unzip(&write_xlsx(&wb).unwrap())).replace("descr=\"gridcraft:latex:", "descr=\"gridcraft:latex:zz:"); + parts.insert(name, d2.into_bytes()); + let (back, _) = read_xlsx(&rezip(&parts)).unwrap(); + let s = shapes(&back); + assert_eq!(s.len(), 1); + assert_eq!((s[0].kind, s[0].text.as_str()), (ShapeKind::Equation, "{x}^{2}")); +} + +#[test] +fn office_math_outside_the_subset_reads_the_fallback() { + let wb = book(vec![equation(1, "B2", "x^2", Color::Auto)]); + let mut parts = unzip(&write_xlsx(&wb).unwrap()); + let name = parts.keys().find(|k| k.starts_with("xl/drawings/drawing")).unwrap().clone(); + let mut d = drawing(&parts); + // Another writer: no GridCraft source, Office Math the reader does not convert (a + // pre-subscript), and a fallback text box with its own text. + while let Some(a) = d.find(" descr=\"gridcraft:latex:") { + let b = d[a + 8..].find('"').unwrap() + a + 9; + d.replace_range(a..b, ""); + } + let (m0, m1) = (d.find("").unwrap(), d.find("").unwrap()); + d.replace_range( + m0..m1 + "".len(), + "12y", + ); + let fb = d.find("").unwrap(); + let t = d[fb..].find("x^2").unwrap() + fb; + d.replace_range(t..t + "x^2".len(), "the writer's fallback"); + parts.insert(name, d.into_bytes()); + let (back, _) = read_xlsx(&rezip(&parts)).unwrap(); + let s = shapes(&back); + assert_eq!(s.len(), 1); + assert_eq!(s[0].kind, ShapeKind::TextBox, "not an equation"); + assert_eq!(s[0].text, "the writer's fallback", "the fallback is read, not the empty Office Math box"); +} + +#[test] +fn hostile_sources_round_trip_exactly() { + // Sources built from the characters XML and LaTeX treat specially: each is saved (as Office + // Math with a fallback, or as text) and read back as the same equation source. + const PARTS: &[&str] = &[ + "x", + "\\frac{a}{b}", + "\"", + "'", + "<", + ">", + "&", + "&", + "]]>", + "