Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .github/workflows/wasm.yml
Original file line number Diff line number Diff line change
@@ -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')"
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
.idea
.DS_Store
target
bindings
build.rs.orig
Python/libmathcat.pyd
Cargo.lock
Expand Down
3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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"] }
Expand Down
23 changes: 22 additions & 1 deletion docs/callers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
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.)
2 changes: 2 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
218 changes: 218 additions & 0 deletions src/wasm_api.rs
Original file line number Diff line number Diff line change
@@ -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<MathCatError> {
Box::new(MathCatError(interface::errors_to_string(&e)))
}

/// A list of strings, for the handful of functions that return `Vec<String>`.
#[diplomat::opaque]
pub struct MathCatStringList(Vec<String>);

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<MathCatError>> {
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<MathCatError>> {
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<MathCatError>> {
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<MathCatError>> {
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<MathCatError>> {
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<MathCatError>> {
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<MathCatError>> {
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<MathCatError>> {
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<MathCatError>> {
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<MathCatError>> {
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<MathCatError>> {
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<usize, Box<MathCatError>> {
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<usize, Box<MathCatError>> {
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<BraillePosition, Box<MathCatError>> {
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<usize, Box<MathCatError>> {
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<MathCatStringList>, Box<MathCatError>> {
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<MathCatStringList>, Box<MathCatError>> {
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<MathCatStringList>, Box<MathCatError>> {
interface::get_supported_speech_styles(lang)
.map(|v| Box::new(MathCatStringList(v)))
.map_err(to_ffi_error)
}
}
}