LaTeX rendering with Katex

DATE : 08.02.2026

Introduction

In this post, I will present an approach to utilizing Katex for rendering LaTeX\LaTeX notation, utilizing Deno integrating to Rust.

This was primarily motivated by the desire to have a solid, easily maintainable method to do so (primarily for this website you are looking at). As far as I am aware at the time of writing, there is no currently maintained library to do so.

Technical motivation

Katex

Katex was selected for the following reasons:

Deno

Deno was selected as the JS runtime of choice for following reasons

Interfacing is done by simply calling the system deno executable with appropriate parameters and piping input/output.

I acknowledge there would have been other, perhaps more tightly embedded library solutions available. However, they almost certainly would have added additional complexity - the interface presented here does not need much in way of additional glue code.

Implementation

In this section, we’ll look at the central code snippets necessary for implementing the presented approach. It will not be a complete implementation; the reader is expected to supply appropriate glue code for their particular use-case ;)

Katex wrapper

Let’s start at the wrapper script. This script has the simple role of capturing input, passing it to Katex, and then passing the output back to the calling Rust program. Any options (e.g. display mode) to the render process itself are received via a command line argument.

import { renderToString } from "data:application/javascript;base64,<SUBSTITUTE-KATEX-DATA>";
import { stdin, stdout, stderr, exit } from "node:process";

// Initialize input buffer for collecting input from Rust
let inputBuf = "";

try {
    // Sanity check; require only one argument to be entered
    if (Deno.args.length != 1) {
        throw new Error("invalid argument count - provide only JSON arguments")
    }

    // Parse JSON arguments into a structure
    const jsonArgs = JSON.parse(Deno.args[0])

    // Set appropriate encoding for input, and read into buffer
    stdin.setEncoding("utf8");
    stdin.on("data", (chunk) => {
        inputBuf += chunk;
    })
    stdin.on("end", () => {
        // Once finished, use Katex to render into a string, and write output into standard output
        try {
            stdout.write(renderToString(inputBuf, jsonArgs));
            exit(0);
        } catch (e) {
            // If something unexpected occurred, write into stderror
            stderr.write(e.toString());
            exit(1);
        }
    })
} catch (e) {
    stderr.write(e.toString());
    exit(2);
}

Pay particular attention to the <SUBSTITUTE-KATEX-DATA> magic value. As we want to avoid having to deal with multiple files, Deno’s ability to import modules via data URLs is used here. During the build process, the magic value is replaced with a Base64 encoded representation of the Katex MJS file (ES module, not the CommonJS version).

Build script

This build script does the steps mentioned above, assuming appropriate files are provided

//! Internal build script for Katex wrapping
use std::{env, fs};
use std::fs::{read_to_string};
use std::path::Path;
use base64::prelude::*;

fn main() {
    println!("cargo:rerun-if-changed=js");
    println!("cargo:rerun-if-changed=vendor");
    println!("cargo:rerun-if-changed=build.rs");

    let katex_script = read_to_string("vendor/katex.mjs").expect("Katex file missing or inaccessible?");
    let mut katex_wrapper = read_to_string("js/katex-wrapper.js").expect("Katex wrapper module missing or inaccessible?");

    katex_wrapper = katex_wrapper.replace("<SUBSTITUTE-KATEX-DATA>", &BASE64_STANDARD.encode(&katex_script));

    let out_dir = env::var_os("OUT_DIR").unwrap();
    let file_output_path = Path::new(&out_dir).join("katex-wrapped-script.js");

    fs::write(&file_output_path, &katex_wrapper).expect("Unable to write wrapped script");
}

Rust glue

Rust glue, in its essence, is quite simple; call Deno with a pre-prepared file, pass in arguments and input, and capture output

use std::borrow::Borrow;
use std::io::Write;
use std::process::{Command, Stdio};
use std::sync::Arc;
use serde::Serialize;
use tempfile::NamedTempFile;
use thiserror::Error;

/// Instance of a Katex renderer. Can be safely cloned
#[derive(Clone, Debug)]
pub struct Katex {
    wrapper_temp_file: Arc<NamedTempFile>,
}

/// Convenience alias for results
pub type KatexResult<T> = Result<T, KatexError>;

/// Error types that the Katex wrapper can emit
#[derive(Error, Debug)]
pub enum KatexError {
    /// An I/O error occurred
    #[error("I/O error: {0}")]
    IOError(#[from] std::io::Error),
    /// Something entirely unexpected went wrong
    #[error("Unexpected failure: {0}")]
    UnexpectedFailure(String),
    /// Katex failed to render the given input and returned an error message
    #[error("Katex returned error: {0}")]
    KatexError(String),
    /// Serde conversion error
    #[error("JSON conversion error: {0}")]
    SerdeError(#[from] serde_json::Error),
}

/// Arguments given to Katex
#[derive(Serialize, Debug, Default)]
#[serde(rename_all = "camelCase")]
pub struct KatexArguments {
    display_mode: bool,
    trust: bool
}

impl Katex {
    /// Initialize a new Katex instance. This is by necessity an operation that can fail,
    /// due to the need to initialize a temporary file for the Katex library
    pub fn new() -> KatexResult<Katex> {
        let mut temp_file = NamedTempFile::new()?;
        temp_file.write_all(crate::js_data::KATEX_WRAPPER_SCRIPT.as_bytes())?;
        temp_file.flush()?;
        Ok(Katex {
            wrapper_temp_file: Arc::new(temp_file),
        })
    }

    /// Render markup into HTML using provided Katex settings
    pub fn render<I: Borrow<str>, A: Borrow<KatexArguments>>(&self, input: I, args: A) -> KatexResult<String> {
        // Convert args into JSON
        let args_as_json_string = serde_json::to_string(args.borrow())?;

        // Execute the wrapper script with Deno; essentially run a script stored in a temporary file
        let mut command = Command::new("deno")
            .arg("run")
            .arg("--no-config")
            .arg("--no-npm")
            .arg("--no-remote")
            .arg("--no-prompt")
            .arg(self.wrapper_temp_file.path())
            .arg(args_as_json_string)
            .stdin(Stdio::piped())
            .stdout(Stdio::piped())
            .stderr(Stdio::piped())
            .spawn()?;

        // Ingest into stdin, and close stream once complete
        let mut stdin = command
            .stdin
            .take()
            .ok_or(KatexError::UnexpectedFailure("failed to take stdin".into()))?;
        stdin.write_all(input.borrow().as_bytes())?;
        stdin.flush()?;
        drop(stdin);

        // Read output
        let output = command.wait_with_output()?;
        if output.status.success() {
            let as_str = String::from_utf8_lossy(&output.stdout);
            Ok(as_str.to_string())
        } else {
            let as_str = String::from_utf8_lossy(&output.stderr);
            Err(KatexError::KatexError(as_str.to_string()))
        }
    }
}

Libraries used include: