merman_typst_plugin/
lib.rs1use 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}