From e80b886335fda39300589717922ee0cfeac4c3a4 Mon Sep 17 00:00:00 2001 From: Tuomas Pyorre Date: Tue, 18 Aug 2026 18:27:43 -0400 Subject: [PATCH 1/5] Add diplomat dependensies and JS features --- Cargo.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Cargo.toml b/Cargo.toml index 4d2f06971..bc19db9bf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ exclude = ["src/main.rs", "docs", "BrailleDocs", "PythonScripts"] # should ha "include-zip" = [] "enable-logs" = ["android_logger"] "tts" = [ "natural-tts" ] +"js-api" = ["diplomat", "diplomat-runtime"] [dependencies] @@ -38,6 +39,8 @@ cfg-if = "1.0.1" fastrand = { version = "2.3.0" } clap = { version = "4.5.60", features = ["derive"] } html-escape = "0.2.13" +diplomat = { version = "0.16.0", optional = true } +diplomat-runtime = { version = "0.16.0", optional = true } [target.'cfg(target_family = "wasm")'.dependencies] zip = { version = "8.2", default-features = false, features = ["deflate"] } From 80caad9f7470704e16c1c76d3687a01e49f758ac Mon Sep 17 00:00:00 2001 From: Tuomas Pyorre Date: Tue, 18 Aug 2026 18:33:23 -0400 Subject: [PATCH 2/5] Add Diplomat bridge module --- src/lib.rs | 2 + src/wasm_api.rs | 218 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 220 insertions(+) create mode 100644 src/wasm_api.rs diff --git a/src/lib.rs b/src/lib.rs index a1b3dc4f7..cf12121d6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -25,6 +25,8 @@ pub mod errors { } pub mod interface; +#[cfg(feature = "js-api")] +pub mod wasm_api; #[cfg(feature = "include-zip")] pub use shim_filesystem::ZIPPED_RULE_FILES; diff --git a/src/wasm_api.rs b/src/wasm_api.rs new file mode 100644 index 000000000..22465181a --- /dev/null +++ b/src/wasm_api.rs @@ -0,0 +1,218 @@ +//! Diplomat FFI bridge over MathCAT's public interface (`crate::interface`). +//! +//! This module wraps `interface.rs`'s existing free functions, for consumption from JS/WASM +//! (and any other language Diplomat targets), without changing anything about the native +//! Rust interface used by `main.rs`, `mathml2text.rs`, or the test suite. +//! +//! MathCAT tracks "the current document"/"the current navigation node" in thread-locals rather +//! than through a caller-held handle (see `interface.rs`), so there's no per-instance state to +//! hold here. `MathCat` exists only because Diplomat requires bridge functions to be attached to +//! a type; it's used purely as a namespace of static methods (`MathCat.setMathml(...)` in JS). + +#[diplomat::bridge] +pub mod ffi { + use diplomat_runtime::DiplomatWrite; + use std::fmt::Write as _; + + use crate::interface; + + #[diplomat::opaque] + pub struct MathCat; + + /// A MathCAT error, exposed as an opaque handle so its message can be pulled out on demand. + #[diplomat::opaque] + pub struct MathCatError(String); + + impl MathCatError { + pub fn message_write(&self, write: &mut DiplomatWrite) { + let _ = write.write_str(&self.0); + write.flush(); + } + } + + fn to_ffi_error(e: crate::errors::Error) -> Box { + Box::new(MathCatError(interface::errors_to_string(&e))) + } + + /// A list of strings, for the handful of functions that return `Vec`. + #[diplomat::opaque] + pub struct MathCatStringList(Vec); + + impl MathCatStringList { + pub fn len(&self) -> usize { + self.0.len() + } + + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + /// Writes the string at `index` into `write`; returns `false` if `index` is out of range. + pub fn get(&self, index: usize, write: &mut DiplomatWrite) -> bool { + match self.0.get(index) { + Some(s) => { + let _ = write.write_str(s); + write.flush(); + true + } + None => false, + } + } + } + + /// The start/end braille cell positions returned by `get_braille_position`. + pub struct BraillePosition { + pub start: usize, + pub end: usize, + } + + impl MathCat { + /// See [`interface::set_rules_dir`]. + pub fn set_rules_dir(dir: &str) -> Result<(), Box> { + interface::set_rules_dir(dir).map_err(to_ffi_error) + } + + /// See [`interface::get_version`]. + pub fn get_version(write: &mut DiplomatWrite) { + let _ = write.write_str(&interface::get_version()); + write.flush(); + } + + /// See [`interface::set_mathml`]. Writes the cleaned-up (canonical) MathML into `write`. + pub fn set_mathml(mathml_str: &str, write: &mut DiplomatWrite) -> Result<(), Box> { + let cleaned = interface::set_mathml(mathml_str).map_err(to_ffi_error)?; + let _ = write.write_str(&cleaned); + write.flush(); + Ok(()) + } + + /// See [`interface::get_spoken_text`]. + pub fn get_spoken_text(write: &mut DiplomatWrite) -> Result<(), Box> { + let text = interface::get_spoken_text().map_err(to_ffi_error)?; + let _ = write.write_str(&text); + write.flush(); + Ok(()) + } + + /// See [`interface::get_overview_text`]. + pub fn get_overview_text(write: &mut DiplomatWrite) -> Result<(), Box> { + let text = interface::get_overview_text().map_err(to_ffi_error)?; + let _ = write.write_str(&text); + write.flush(); + Ok(()) + } + + /// See [`interface::get_preference`]. + pub fn get_preference(name: &str, write: &mut DiplomatWrite) -> Result<(), Box> { + let value = interface::get_preference(name).map_err(to_ffi_error)?; + let _ = write.write_str(&value); + write.flush(); + Ok(()) + } + + /// See [`interface::set_preference`]. + pub fn set_preference(name: &str, value: &str) -> Result<(), Box> { + interface::set_preference(name, value).map_err(to_ffi_error) + } + + /// See [`interface::get_braille`]. + pub fn get_braille(nav_node_id: &str, write: &mut DiplomatWrite) -> Result<(), Box> { + let braille = interface::get_braille(nav_node_id).map_err(to_ffi_error)?; + let _ = write.write_str(&braille); + write.flush(); + Ok(()) + } + + /// See [`interface::get_navigation_braille`]. + pub fn get_navigation_braille(write: &mut DiplomatWrite) -> Result<(), Box> { + let braille = interface::get_navigation_braille().map_err(to_ffi_error)?; + let _ = write.write_str(&braille); + write.flush(); + Ok(()) + } + + /// See [`interface::do_navigate_keypress`]. + pub fn do_navigate_keypress( + key: usize, + shift_key: bool, + control_key: bool, + alt_key: bool, + meta_key: bool, + write: &mut DiplomatWrite, + ) -> Result<(), Box> { + let speech = interface::do_navigate_keypress(key, shift_key, control_key, alt_key, meta_key) + .map_err(to_ffi_error)?; + let _ = write.write_str(&speech); + write.flush(); + Ok(()) + } + + /// See [`interface::do_navigate_command`]. + pub fn do_navigate_command(command: &str, write: &mut DiplomatWrite) -> Result<(), Box> { + let speech = interface::do_navigate_command(command).map_err(to_ffi_error)?; + let _ = write.write_str(&speech); + write.flush(); + Ok(()) + } + + /// See [`interface::set_navigation_node`]. + pub fn set_navigation_node(id: &str, offset: usize) -> Result<(), Box> { + interface::set_navigation_node(id, offset).map_err(to_ffi_error) + } + + /// See [`interface::get_navigation_mathml`]. Writes the MathML into `write`, returns the offset. + pub fn get_navigation_mathml(write: &mut DiplomatWrite) -> Result> { + let (mathml, offset) = interface::get_navigation_mathml().map_err(to_ffi_error)?; + let _ = write.write_str(&mathml); + write.flush(); + Ok(offset) + } + + /// See [`interface::get_navigation_mathml_id`]. Writes the id into `write`, returns the offset. + pub fn get_navigation_mathml_id(write: &mut DiplomatWrite) -> Result> { + let (id, offset) = interface::get_navigation_mathml_id().map_err(to_ffi_error)?; + let _ = write.write_str(&id); + write.flush(); + Ok(offset) + } + + /// See [`interface::get_braille_position`]. + pub fn get_braille_position() -> Result> { + let (start, end) = interface::get_braille_position().map_err(to_ffi_error)?; + Ok(BraillePosition { start, end }) + } + + /// See [`interface::get_navigation_node_from_braille_position`]. Writes the id into `write`, returns the offset. + pub fn get_navigation_node_from_braille_position( + position: usize, + write: &mut DiplomatWrite, + ) -> Result> { + let (id, offset) = + interface::get_navigation_node_from_braille_position(position).map_err(to_ffi_error)?; + let _ = write.write_str(&id); + write.flush(); + Ok(offset) + } + + /// See [`interface::get_supported_braille_codes`]. + pub fn get_supported_braille_codes() -> Result, Box> { + interface::get_supported_braille_codes() + .map(|v| Box::new(MathCatStringList(v))) + .map_err(to_ffi_error) + } + + /// See [`interface::get_supported_languages`]. + pub fn get_supported_languages() -> Result, Box> { + interface::get_supported_languages() + .map(|v| Box::new(MathCatStringList(v))) + .map_err(to_ffi_error) + } + + /// See [`interface::get_supported_speech_styles`]. + pub fn get_supported_speech_styles(lang: &str) -> Result, Box> { + interface::get_supported_speech_styles(lang) + .map(|v| Box::new(MathCatStringList(v))) + .map_err(to_ffi_error) + } + } +} From 41a4459772a2ab7754919c5e91ed631f3559bc91 Mon Sep 17 00:00:00 2001 From: Tuomas Pyorre Date: Tue, 18 Aug 2026 18:34:05 -0400 Subject: [PATCH 3/5] Add CI check for JS/WASM bindings --- .github/workflows/wasm.yml | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 .github/workflows/wasm.yml diff --git a/.github/workflows/wasm.yml b/.github/workflows/wasm.yml new file mode 100644 index 000000000..b81d1ed22 --- /dev/null +++ b/.github/workflows/wasm.yml @@ -0,0 +1,32 @@ +name: WASM/JS Bindings + +on: [push, pull_request] + +env: + CARGO_TERM_COLOR: always + RUSTFLAGS: "-Dwarnings" + +jobs: + wasm-check: + name: Build js-api feature for wasm32-unknown-unknown + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Add wasm32 target + run: rustup target add wasm32-unknown-unknown + - name: cargo check (wasm32, js-api) + run: cargo check --target wasm32-unknown-unknown --no-default-features --features "js-api,include-zip" --lib + - name: clippy (wasm32, js-api) + run: cargo clippy --target wasm32-unknown-unknown --no-default-features --features "js-api,include-zip" --lib + + diplomat-codegen: + name: Generate JS/TS bindings with diplomat-tool + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Install diplomat-tool + run: cargo install diplomat-tool --locked + - name: Generate bindings + run: diplomat-tool js bindings/js -e src/wasm_api.rs + - name: Sanity-check output exists + run: test -n "$(find bindings/js -name '*.mjs')" && test -n "$(find bindings/js -name '*.d.ts')" From 8a0cf0c313b3cbeb0c5a651bcfb4145393ab94ef Mon Sep 17 00:00:00 2001 From: Tuomas Pyorre Date: Tue, 18 Aug 2026 18:35:37 -0400 Subject: [PATCH 4/5] Documentation for JS/WASM use --- docs/callers.md | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/docs/callers.md b/docs/callers.md index 763a8484e..cc8a42ef5 100644 --- a/docs/callers.md +++ b/docs/callers.md @@ -162,8 +162,29 @@ except Exception as e: ``` +## JavaScript/WASM Users (Diplomat) + +MathCAT has a generated JavaScript/TypeScript interface, built with [Diplomat](https://github.com/rust-diplomat/diplomat) from the `js-api` feature's bridge module (`src/wasm_api.rs`). Diplomat handles cross-language memory management for you (no manual free calls, unlike the C interface below), and generates the bindings directly from the Rust source, so they can't drift out of sync the way a hand-written wrapper can. + +To build the WASM library and regenerate the bindings: +``` +rustup target add wasm32-unknown-unknown +cargo build --target wasm32-unknown-unknown --no-default-features --features "js-api,include-zip" --release +cargo install diplomat-tool --locked +diplomat-tool js bindings/js -e src/wasm_api.rs +``` +(`include-zip` is required alongside `js-api` for any wasm build -- the Rules directory is embedded as a zip file rather than read from the filesystem, and `build.rs` only produces that zip when `include-zip` is enabled.) + +This targets stable Rust using Diplomat's legacy wasm-ABI code path (not the nightly-only `-Zwasm-c-abi=spec` path), to keep it consistent with the rest of MathCAT's stable-only build and avoid requiring downstream consumers to use nightly Rust. + +The generated interface mirrors the Rust interface, camelCase and all: `set_mathml` becomes `MathCat.setMathml(mathmlStr)`, etc. Errors are thrown as a JS `Error` whose `.cause` is a `MathCatError` handle (`cause.toString()` gives the message) -- wrap calls in `try`/`catch` the same way as the Python interface's `try`/`except`. + +The old hand-rolled WASM build described below (used by [MathCatDemo](https://github.com/NSoiffer/MathCATDemo)) required manual "hand tweaks" during the build process; these generated bindings are meant to supersede that for the core API surface. MathCATDemo itself is a separate repo and isn't changed by this. + ## Web Users I built a web assembly version. Has a few compromises and requires some hand tweaks during the build process. Those need to be automated. It can be found at [MathCatDemo](https://github.com/NSoiffer/MathCATDemo). This builds a web page for demo purposes, so it is not a pure build for the Web. Nonetheless, it does demonstrate how that can be done. ## C/C++ Users -There is a C/C++ interface. It can be found at the related project [MathCatForC](https://github.com/NSoiffer/MathCATForC). Rust and C have separate memory managers, and so the interface is a little clunky because the memory needs to be free'd. That can be hidden by wrapping the calls in a small function as demonstrated by `SetMathCatPreference` in the [sample code](https://github.com/NSoiffer/MathCATForC/blob/main/c-example/test.cpp). Otherwise, it is easy to use. If someone knows a better way to deal with the memory issues, please let me know or submit a PR. This is new territory for me as a Rust programmer. \ No newline at end of file +There is a C/C++ interface. It can be found at the related project [MathCatForC](https://github.com/NSoiffer/MathCATForC). Rust and C have separate memory managers, and so the interface is a little clunky because the memory needs to be free'd. That can be hidden by wrapping the calls in a small function as demonstrated by `SetMathCatPreference` in the [sample code](https://github.com/NSoiffer/MathCATForC/blob/main/c-example/test.cpp). Otherwise, it is easy to use. If someone knows a better way to deal with the memory issues, please let me know or submit a PR. This is new territory for me as a Rust programmer. + +(Diplomat, used for the JavaScript interface above, can also generate a C/C++ interface from the same bridge module -- worth considering as a future replacement for this hand-rolled one, but not attempted yet.) From e839d5895302514bfeb07326e5d7105eafec1480 Mon Sep 17 00:00:00 2001 From: Tuomas Pyorre Date: Tue, 18 Aug 2026 18:35:54 -0400 Subject: [PATCH 5/5] ignore generated bindings --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index b4a54ec2c..707cbc062 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ .idea .DS_Store target +bindings build.rs.orig Python/libmathcat.pyd Cargo.lock