Skip to main content

merman_typst_plugin/
lib.rs

1//! Typst WebAssembly plugin bridge for `merman`.
2//!
3//! This crate intentionally mirrors the shared binding facade instead of exposing a
4//! Typst-specific rendering stack. The Typst package can pass the same options JSON
5//! used by the browser/native bindings, while the compiled wasm exports the minimal
6//! protocol functions that Typst can call.
7
8use std::fmt::{self, Display, Formatter};
9
10#[cfg(target_arch = "wasm32")]
11wasm_minimal_protocol::initiate_protocol!();
12
13pub const TYPST_PLUGIN_ABI_VERSION: &str = "1";
14
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct TypstPluginError {
17    message: String,
18}
19
20impl TypstPluginError {
21    fn new(message: impl Into<String>) -> Self {
22        Self {
23            message: message.into(),
24        }
25    }
26}
27
28impl Display for TypstPluginError {
29    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
30        f.write_str(&self.message)
31    }
32}
33
34impl std::error::Error for TypstPluginError {}
35
36impl From<merman_bindings_core::BindingError> for TypstPluginError {
37    fn from(error: merman_bindings_core::BindingError) -> Self {
38        Self::new(format!(
39            "{}: {}",
40            error.status().code_name(),
41            error.message()
42        ))
43    }
44}
45
46#[cfg_attr(target_arch = "wasm32", wasm_minimal_protocol::wasm_func)]
47pub fn abi_version() -> &'static [u8] {
48    TYPST_PLUGIN_ABI_VERSION.as_bytes()
49}
50
51#[cfg_attr(target_arch = "wasm32", wasm_minimal_protocol::wasm_func)]
52pub fn package_version() -> Vec<u8> {
53    env!("CARGO_PKG_VERSION").as_bytes().to_vec()
54}
55
56#[cfg_attr(target_arch = "wasm32", wasm_minimal_protocol::wasm_func)]
57pub fn capabilities_json() -> Vec<u8> {
58    typst_capabilities_json().unwrap_or_else(fallback_capabilities_json)
59}
60
61#[cfg(feature = "render")]
62fn typst_capabilities_json() -> Option<Vec<u8>> {
63    let mut capabilities = merman_bindings_core::binding_capabilities();
64    capabilities.text_measurement.host_callback = false;
65    capabilities.text_measurement.font_assets = false;
66    serde_json::to_vec(&capabilities).ok()
67}
68
69#[cfg(not(feature = "render"))]
70fn typst_capabilities_json() -> Option<Vec<u8>> {
71    merman_bindings_core::binding_capabilities_json().ok()
72}
73
74fn fallback_capabilities_json() -> Vec<u8> {
75    br#"{"render":false,"analysis":false,"ascii":false,"core_full":false,"core_host":false,"ratex_math":false,"editor_language":false,"text_measurement":{"vendored":false,"deterministic":false,"host_callback":false,"font_assets":false}}"#.to_vec()
76}
77
78#[cfg_attr(target_arch = "wasm32", wasm_minimal_protocol::wasm_func)]
79pub fn render_svg_json(source: &[u8], options_json: &[u8]) -> Vec<u8> {
80    let options_json = typst_options_json(options_json);
81    match merman_bindings_core::render_svg(source, &options_json) {
82        Ok(svg) => match std::str::from_utf8(&svg) {
83            Ok(svg) => merman_bindings_core::render_payload_json_bytes(
84                merman_bindings_core::BindingStatus::Ok,
85                None,
86                Some(svg),
87            ),
88            Err(error) => {
89                let message = format!("render_svg returned non-UTF-8 SVG: {error}");
90                merman_bindings_core::render_payload_json_bytes(
91                    merman_bindings_core::BindingStatus::InternalError,
92                    Some(message.as_str()),
93                    None,
94                )
95            }
96        },
97        Err(error) => merman_bindings_core::render_payload_json_bytes(
98            error.status(),
99            Some(error.message()),
100            None,
101        ),
102    }
103}
104
105#[cfg_attr(target_arch = "wasm32", wasm_minimal_protocol::wasm_func)]
106pub fn validate_json(source: &[u8], options_json: &[u8]) -> Result<Vec<u8>, TypstPluginError> {
107    let options_json = typst_options_json(options_json);
108    merman_bindings_core::validate_json(source, &options_json).map_err(TypstPluginError::from)
109}
110
111#[cfg(feature = "render")]
112fn typst_options_json(options_json: &[u8]) -> Vec<u8> {
113    if options_json.is_empty() {
114        return br#"{"resources":{"profile":"typst-package"}}"#.to_vec();
115    }
116
117    let Ok(mut value) = serde_json::from_slice::<serde_json::Value>(options_json) else {
118        return options_json.to_vec();
119    };
120    let Some(object) = value.as_object_mut() else {
121        return options_json.to_vec();
122    };
123    object
124        .entry("resources")
125        .or_insert_with(|| serde_json::json!({ "profile": "typst-package" }));
126    serde_json::to_vec(&value).unwrap_or_else(|_| options_json.to_vec())
127}
128
129#[cfg(not(feature = "render"))]
130fn typst_options_json(options_json: &[u8]) -> Vec<u8> {
131    options_json.to_vec()
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137    use serde_json::Value;
138
139    #[test]
140    fn abi_version_is_stable() {
141        assert_eq!(TYPST_PLUGIN_ABI_VERSION, "1");
142        assert_eq!(abi_version(), b"1");
143    }
144
145    #[test]
146    fn package_version_matches_crate_version() {
147        assert_eq!(package_version(), env!("CARGO_PKG_VERSION").as_bytes());
148    }
149
150    #[test]
151    fn capabilities_json_reports_text_measurement_boundary() {
152        let payload: Value = serde_json::from_slice(&capabilities_json()).expect("valid JSON");
153
154        assert_eq!(payload["render"], cfg!(feature = "render"));
155        assert_eq!(payload["analysis"], cfg!(feature = "analysis"));
156        assert_eq!(
157            payload["text_measurement"]["vendored"],
158            cfg!(feature = "render")
159        );
160        assert_eq!(
161            payload["text_measurement"]["deterministic"],
162            cfg!(feature = "render")
163        );
164        assert_eq!(payload["text_measurement"]["host_callback"], false);
165        assert_eq!(payload["text_measurement"]["font_assets"], false);
166    }
167
168    #[cfg(feature = "render")]
169    #[test]
170    fn render_svg_json_returns_success_payload() {
171        let payload: Value = serde_json::from_slice(&render_svg_json(
172            b"flowchart TD\nA[Hello] --> B[World]",
173            b"",
174        ))
175        .expect("valid JSON payload");
176
177        assert_eq!(payload["version"], 1);
178        assert_eq!(payload["ok"], true);
179        assert_eq!(payload["code_name"], "MERMAN_OK");
180        assert!(payload["message"].is_null());
181        assert!(payload["svg"].as_str().unwrap().contains("<svg"));
182        assert!(payload["svg"].as_str().unwrap().contains("Hello"));
183    }
184
185    #[cfg(feature = "render")]
186    #[test]
187    fn render_svg_json_renders_flowchart_elk_from_default_artifact() {
188        let payload: Value = serde_json::from_slice(&render_svg_json(
189            b"flowchart-elk TD\nA[Hello] --> B[World]",
190            b"",
191        ))
192        .expect("valid JSON payload");
193
194        assert_eq!(payload["ok"], true);
195        assert_eq!(payload["code_name"], "MERMAN_OK");
196        assert!(payload["svg"].as_str().unwrap().contains("Hello"));
197    }
198
199    #[cfg(feature = "render")]
200    #[test]
201    fn render_svg_json_uses_typst_resource_profile_by_default() {
202        let source = format!("flowchart TD\nA[{}]", "x".repeat(1024 * 1024));
203        let payload: Value = serde_json::from_slice(&render_svg_json(source.as_bytes(), b""))
204            .expect("valid JSON payload");
205
206        assert_eq!(payload["ok"], false);
207        assert_eq!(payload["code_name"], "MERMAN_RESOURCE_LIMIT_EXCEEDED");
208        assert!(payload["message"]
209            .as_str()
210            .unwrap()
211            .contains("max_source_bytes"));
212    }
213
214    #[cfg(feature = "render")]
215    #[test]
216    fn render_svg_json_returns_error_payload() {
217        let payload: Value =
218            serde_json::from_slice(&render_svg_json(b"", b"")).expect("valid JSON payload");
219
220        assert_eq!(payload["version"], 1);
221        assert_eq!(payload["ok"], false);
222        assert_eq!(payload["code_name"], "MERMAN_NO_DIAGRAM");
223        assert!(!payload["message"].as_str().unwrap().is_empty());
224        assert!(payload["svg"].is_null());
225    }
226}