Skip to main content

mxblocks_tex/
lib.rs

1//! Math messages: a formula rendered to a PNG by the `mxblocks-math`
2//! program (`packages/mxblocks-math/` in this repository) and sent as an `m.image`.
3//!
4//! The renderer's contract: `mxblocks-math render [--inline] --out PATH.png -`
5//! with the LaTeX on stdin, exit 0 and one JSON line on stdout
6//! (`{"mimetype":"image/png","w":W,"h":H,"size":BYTES}`, w/h the display
7//! size of a 2x PNG); non-zero with the TeX error on stderr. Any program
8//! that keeps it works ([`Renderer::new`] takes its path).
9//!
10//! The event ([`math_content`]): `msgtype: m.image` with the upload's `url`
11//! and `info`, `filename: math.png`, `body` the raw LaTeX (readable on
12//! clients that render nothing), `formatted_body` MSC2191 markup
13//! ([`maths_html`]: `<div data-mx-maths="…"><code>…</code></div>`, a
14//! `<span>` inline, which Element's LaTeX labs flag renders). The sender
15//! adds the mxblocks payload with the math block ([`with_caption`]). A
16//! caption goes first in `body` and `formatted_body` (a blank line, a
17//! `<p>`), and on the math block as `caption`.
18
19#![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
30/// The default renderer (on `PATH`).
31pub const DEFAULT_RENDERER: &str = "mxblocks-math";
32/// The default time the renderer may take.
33pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
34/// The default largest PNG accepted, in bytes.
35pub const DEFAULT_MAX_PNG: u64 = 8 * 1024 * 1024;
36
37/// A rendered formula.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct Rendered {
40    /// The PNG (2x the display size).
41    pub png: Vec<u8>,
42    /// Its MIME type (`image/png`).
43    pub mimetype: String,
44    /// Display width.
45    pub w: u64,
46    /// Display height.
47    pub h: u64,
48}
49
50/// The first non-blank of `choices` (an env var, a config value, ...),
51/// trimmed, else `default`.
52pub 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/// A renderer program and its limits.
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct Renderer {
68    /// The program (a path, or a name on `PATH`).
69    pub bin: String,
70    /// How long one render may take; the program is killed after it.
71    pub timeout: Duration,
72    /// The largest PNG accepted, in bytes.
73    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    /// `bin` with the default limits.
84    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    /// With this timeout.
93    #[must_use]
94    pub fn timeout(mut self, timeout: Duration) -> Self {
95        self.timeout = timeout;
96        self
97    }
98
99    /// With this PNG size cap.
100    #[must_use]
101    pub fn max_png(mut self, bytes: u64) -> Self {
102        self.max_png = bytes;
103        self
104    }
105
106    /// Render `tex` (`display`: a display formula, else inline).
107    ///
108    /// # Errors
109    /// The TeX error, a timeout, or a broken renderer, as a message for the
110    /// person who wrote the formula.
111    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            // A renderer that exits early (bad flags) closes the pipe: its
144            // exit status says why.
145            let _ = stdin.write_all(tex.as_bytes()).await;
146        }
147        // Timed out: dropping the child (kill_on_drop) kills it.
148        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
198/// A per-call number for the temp file name.
199fn 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
205/// `s` with its whitespace runs collapsed, cut to `n` characters (`…`).
206fn 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
216/// HTML-escaped `s` (quotes too).
217pub fn html_escape(s: &str) -> String {
218    s.replace('&', "&amp;")
219        .replace('<', "&lt;")
220        .replace('>', "&gt;")
221        .replace('"', "&quot;")
222        .replace('\'', "&#x27;")
223}
224
225/// MSC2191 markup for `tex`: a `div` (display) or `span` (inline) with
226/// `data-mx-maths` and the source in `<code>` for clients without it.
227pub 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
233/// The `m.image` content for a rendered formula uploaded to `mxc`, without
234/// the mxblocks payload (write [`with_caption`]'s under the content key).
235/// `caption`: text to go with the formula, if any; `reply_to`: an event to
236/// reply to.
237pub 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
273/// The payload with the caption put on its math block (`caption`, kept
274/// only when it is one line of at most [`MAX_MATH_LINE`] characters, as
275/// the spec requires; a longer caption stays in the body only).
276pub 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    /// A fake renderer: a shell script that checks its arguments, copies
300    /// a 1x1 PNG to `--out` and prints the JSON, or fails on `\bad`.
301    fn fake(dir: &Path) -> String {
302        let png = dir.join("one.png");
303        // A real 1x1 PNG.
304        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        // A formula that starts with '-' isn't taken for a flag (stdin).
352        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&lt;b &amp; c&gt;&quot;d&quot; &#x27;e&#x27;\">\
404             <code>a&lt;b &amp; c&gt;&quot;d&quot; &#x27;e&#x27;</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 &lt;m²&gt;</p><span data-mx-maths=\"x&lt;1\"><code>x&lt;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}