1#![doc = include_str!("../README.md")]
20#![warn(missing_docs)]
21
22use core::time::Duration;
23use std::path::Path;
24use std::process::Stdio;
25
26use mxblocks::{Block, MAX_MATH_LINE, Rich};
27use serde_json::{Value, json};
28use tokio::{io::AsyncWriteExt as _, process::Command};
29
30pub const DEFAULT_RENDERER: &str = "mxblocks-math";
32pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
34pub const DEFAULT_MAX_PNG: u64 = 8 * 1024 * 1024;
36
37#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct Rendered {
40 pub png: Vec<u8>,
42 pub mimetype: String,
44 pub w: u64,
46 pub h: u64,
48}
49
50pub fn pick_renderer<'a>(
53 choices: impl IntoIterator<Item = Option<&'a str>>,
54 default: &str,
55) -> String {
56 choices
57 .into_iter()
58 .flatten()
59 .map(str::trim)
60 .find(|s| !s.is_empty())
61 .unwrap_or(default)
62 .to_owned()
63}
64
65#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct Renderer {
68 pub bin: String,
70 pub timeout: Duration,
72 pub max_png: u64,
74}
75
76impl Default for Renderer {
77 fn default() -> Self {
78 Self::new(DEFAULT_RENDERER)
79 }
80}
81
82impl Renderer {
83 pub fn new(bin: impl Into<String>) -> Self {
85 Self {
86 bin: bin.into(),
87 timeout: DEFAULT_TIMEOUT,
88 max_png: DEFAULT_MAX_PNG,
89 }
90 }
91
92 #[must_use]
94 pub fn timeout(mut self, timeout: Duration) -> Self {
95 self.timeout = timeout;
96 self
97 }
98
99 #[must_use]
101 pub fn max_png(mut self, bytes: u64) -> Self {
102 self.max_png = bytes;
103 self
104 }
105
106 pub async fn render(&self, tex: &str, display: bool) -> Result<Rendered, String> {
112 let out = std::env::temp_dir().join(format!(
113 "mxblocks-math-{}-{}-{}.png",
114 std::process::id(),
115 std::time::SystemTime::now()
116 .duration_since(std::time::UNIX_EPOCH)
117 .map_or(0, |d| d.as_millis()),
118 next_id()
119 ));
120 let r = self.render_to(tex, display, &out).await;
121 let _ = tokio::fs::remove_file(&out).await;
122 r
123 }
124
125 async fn render_to(&self, tex: &str, display: bool, out: &Path) -> Result<Rendered, String> {
126 let bin = &self.bin;
127 let mut cmd = Command::new(bin);
128 cmd.arg("render");
129 if !display {
130 cmd.arg("--inline");
131 }
132 cmd.arg("--out")
133 .arg(out)
134 .arg("-")
135 .stdin(Stdio::piped())
136 .stdout(Stdio::piped())
137 .stderr(Stdio::piped())
138 .kill_on_drop(true);
139 let mut child = cmd
140 .spawn()
141 .map_err(|e| format!("can't run the math renderer {bin}: {e}"))?;
142 if let Some(mut stdin) = child.stdin.take() {
143 let _ = stdin.write_all(tex.as_bytes()).await;
146 }
147 let res = tokio::time::timeout(self.timeout, child.wait_with_output())
149 .await
150 .map_err(|_| {
151 format!(
152 "the math renderer took over {} s; simplify the formula",
153 self.timeout.as_secs()
154 )
155 })?
156 .map_err(|e| format!("math renderer: {e}"))?;
157 if !res.status.success() {
158 let err = String::from_utf8_lossy(&res.stderr);
159 let err = err.trim();
160 return Err(if err.is_empty() {
161 format!("the math renderer failed ({})", res.status)
162 } else {
163 format!("LaTeX error (nothing sent): {}", short(err, 1500))
164 });
165 }
166 let line = String::from_utf8_lossy(&res.stdout);
167 let meta: Value = line
168 .lines()
169 .rev()
170 .find(|l| !l.trim().is_empty())
171 .and_then(|l| serde_json::from_str(l).ok())
172 .ok_or_else(|| format!("the math renderer printed no JSON: {}", line.trim()))?;
173 let (Some(w), Some(h)) = (meta["w"].as_u64(), meta["h"].as_u64()) else {
174 return Err(format!("the math renderer's JSON has no w/h: {meta}"));
175 };
176 let len = tokio::fs::metadata(out)
177 .await
178 .map_err(|e| format!("the math renderer wrote no image: {e}"))?
179 .len();
180 if len > self.max_png {
181 return Err(format!("the rendered formula is {len} bytes; too big"));
182 }
183 let png = tokio::fs::read(out)
184 .await
185 .map_err(|e| format!("reading the rendered formula: {e}"))?;
186 if !png.starts_with(b"\x89PNG\r\n\x1a\n") {
187 return Err("the math renderer's output isn't a PNG".into());
188 }
189 Ok(Rendered {
190 png,
191 mimetype: meta["mimetype"].as_str().unwrap_or("image/png").to_owned(),
192 w,
193 h,
194 })
195 }
196}
197
198fn next_id() -> u32 {
200 use core::sync::atomic::{AtomicU32, Ordering};
201 static N: AtomicU32 = AtomicU32::new(0);
202 N.fetch_add(1, Ordering::Relaxed)
203}
204
205fn short(s: &str, n: usize) -> String {
207 let s = s.split_whitespace().collect::<Vec<_>>().join(" ");
208 if s.chars().count() <= n {
209 return s;
210 }
211 let mut out: String = s.chars().take(n.saturating_sub(1)).collect();
212 out.push('…');
213 out
214}
215
216pub fn html_escape(s: &str) -> String {
218 s.replace('&', "&")
219 .replace('<', "<")
220 .replace('>', ">")
221 .replace('"', """)
222 .replace('\'', "'")
223}
224
225pub fn maths_html(tex: &str, display: bool) -> String {
228 let e = html_escape(tex);
229 let tag = if display { "div" } else { "span" };
230 format!("<{tag} data-mx-maths=\"{e}\"><code>{e}</code></{tag}>")
231}
232
233pub fn math_content(
238 tex: &str,
239 display: bool,
240 caption: &str,
241 r: &Rendered,
242 mxc: &str,
243 reply_to: Option<&str>,
244) -> Value {
245 let caption = caption.trim();
246 let (body, html) = if caption.is_empty() {
247 (tex.to_owned(), maths_html(tex, display))
248 } else {
249 (
250 format!("{caption}\n\n{tex}"),
251 format!(
252 "<p>{}</p>{}",
253 html_escape(caption),
254 maths_html(tex, display)
255 ),
256 )
257 };
258 let mut c = json!({
259 "msgtype": "m.image",
260 "body": body,
261 "filename": "math.png",
262 "format": "org.matrix.custom.html",
263 "formatted_body": html,
264 "url": mxc,
265 "info": {"mimetype": r.mimetype, "size": r.png.len(), "w": r.w, "h": r.h},
266 });
267 if let Some(ev) = reply_to.filter(|e| !e.is_empty()) {
268 c["m.relates_to"] = json!({"m.in_reply_to": {"event_id": ev}});
269 }
270 c
271}
272
273pub fn with_caption(rich: &Rich, caption: &str) -> Rich {
277 let mut r = rich.clone();
278 let caption = caption.trim();
279 if caption.is_empty() || caption.contains('\n') || caption.chars().count() > MAX_MATH_LINE {
280 return r;
281 }
282 for b in &mut r.blocks {
283 if let Block::Display(m) = b
284 && m.get("type").and_then(Value::as_str) == Some("math")
285 && !m.contains_key("caption")
286 {
287 m.insert("caption".into(), caption.into());
288 }
289 }
290 r
291}
292
293#[cfg(test)]
294mod tests {
295 use std::os::unix::fs::PermissionsExt as _;
296
297 use super::*;
298
299 fn fake(dir: &Path) -> String {
302 let png = dir.join("one.png");
303 let bytes: &[u8] = &[
305 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0x0d, 0x49, 0x48, 0x44, 0x52,
306 0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0, 0x1f, 0x15, 0xc4, 0x89, 0, 0, 0, 0x0d, 0x49,
307 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0, 1, 0, 0, 5, 0, 1, 0x0d, 0x0a, 0x2d, 0xb4, 0, 0,
308 0, 0, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
309 ];
310 std::fs::write(&png, bytes).unwrap();
311 let script = dir.join("mxblocks-math");
312 std::fs::write(
313 &script,
314 format!(
315 r#"#!/bin/sh
316[ "$1" = render ] || {{ echo "usage" >&2; exit 2; }}
317shift
318inline=0
319[ "$1" = --inline ] && {{ inline=1; shift; }}
320[ "$1" = --out ] || {{ echo "no --out" >&2; exit 2; }}
321out=$2
322[ "$3" = - ] || {{ echo "no -" >&2; exit 2; }}
323tex=$(cat)
324case "$tex" in
325 *'\bad'*) echo "! Undefined control sequence \\bad." >&2; exit 1 ;;
326 *'\slow'*) sleep 5 ;;
327esac
328cp {png} "$out"
329echo '{{"mimetype":"image/png","w":'"$((100 + inline))"',"h":20,"size":69}}'
330"#,
331 png = png.display()
332 ),
333 )
334 .unwrap();
335 std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
336 script.display().to_string()
337 }
338
339 #[tokio::test(flavor = "multi_thread")]
340 async fn renders_with_a_fake_renderer() {
341 let dir = tempfile::tempdir().unwrap();
342 let r = Renderer::new(fake(dir.path()));
343 let img = r.render(r"e^{i\pi} + 1 = 0", true).await.unwrap();
344 assert_eq!(
345 (img.w, img.h, img.mimetype.as_str()),
346 (100, 20, "image/png")
347 );
348 assert!(img.png.starts_with(b"\x89PNG"));
349 let inline = r.render("x^2", false).await.unwrap();
350 assert_eq!(inline.w, 101, "--inline passed");
351 r.render("-x", true).await.unwrap();
353
354 let err = r.render(r"\bad{x}", true).await.unwrap_err();
355 assert!(
356 err.contains("LaTeX error") && err.contains("Undefined control sequence"),
357 "{err}"
358 );
359 let err = r
360 .clone()
361 .timeout(Duration::from_millis(300))
362 .render(r"\slow", true)
363 .await
364 .unwrap_err();
365 assert!(err.contains("took over"), "{err}");
366 let err = r.clone().max_png(10).render("x", true).await.unwrap_err();
367 assert!(err.contains("too big"), "{err}");
368 let err = Renderer::new("/nonexistent/mxblocks-math")
369 .render("x", true)
370 .await
371 .unwrap_err();
372 assert!(err.contains("can't run"), "{err}");
373 }
374
375 #[test]
376 fn picks_the_renderer() {
377 let d = DEFAULT_RENDERER;
378 assert_eq!(pick_renderer([None, None], d), "mxblocks-math");
379 assert_eq!(
380 pick_renderer([None, Some("/opt/math/bin/m")], d),
381 "/opt/math/bin/m"
382 );
383 assert_eq!(
384 pick_renderer([Some("/tmp/fake"), Some("/opt/x")], d),
385 "/tmp/fake"
386 );
387 assert_eq!(pick_renderer([Some(" "), Some("/opt/x")], d), "/opt/x");
388 assert_eq!(pick_renderer([Some(" ")], "other"), "other");
389 assert_eq!(Renderer::default().bin, d);
390 }
391
392 #[test]
393 fn shortens_errors() {
394 assert_eq!(short(" a \n b ", 10), "a b");
395 assert_eq!(short("abcdef", 4), "abc…");
396 }
397
398 #[test]
399 fn escapes_and_builds_the_event() {
400 let tex = r#"a<b & c>"d" 'e'"#;
401 assert_eq!(
402 maths_html(tex, true),
403 "<div data-mx-maths=\"a<b & c>"d" 'e'\">\
404 <code>a<b & c>"d" 'e'</code></div>"
405 );
406 assert!(
407 maths_html("x", false).starts_with("<span data-mx-maths=\"x\"><code>x</code></span>")
408 );
409
410 let r = Rendered {
411 png: vec![0; 69],
412 mimetype: "image/png".into(),
413 w: 100,
414 h: 20,
415 };
416 let c = math_content(r"\frac{1}{2}", true, "", &r, "mxc://example.org/m", None);
417 assert_eq!(
418 c,
419 json!({
420 "msgtype": "m.image", "body": r"\frac{1}{2}", "filename": "math.png",
421 "format": "org.matrix.custom.html",
422 "formatted_body": r#"<div data-mx-maths="\frac{1}{2}"><code>\frac{1}{2}</code></div>"#,
423 "url": "mxc://example.org/m",
424 "info": {"mimetype": "image/png", "size": 69, "w": 100, "h": 20},
425 })
426 );
427 let c = math_content(
428 "x<1",
429 false,
430 " Area <m²> ",
431 &r,
432 "mxc://example.org/m",
433 Some("$e"),
434 );
435 assert_eq!(c["body"], "Area <m²>\n\nx<1");
436 assert_eq!(
437 c["formatted_body"],
438 "<p>Area <m²></p><span data-mx-maths=\"x<1\"><code>x<1</code></span>"
439 );
440 assert_eq!(c["m.relates_to"]["m.in_reply_to"]["event_id"], "$e");
441
442 let rich = mxblocks::from_args_math(&[], None, Some(("x", true)))
443 .unwrap()
444 .unwrap();
445 let v = serde_json::to_value(with_caption(&rich, "Area")).unwrap();
446 assert_eq!(v["blocks"][0]["caption"], "Area");
447 assert_eq!(with_caption(&rich, " "), rich);
448 assert_eq!(with_caption(&rich, "two\nlines"), rich);
449 assert_eq!(with_caption(&rich, &"x".repeat(MAX_MATH_LINE + 1)), rich);
450 }
451}