LaTeX rendering with Katex
DATE : 08.02.2026
Introduction
In this post, I will present an approach to utilizing Katex for rendering 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:
- Produces plain HTML/MathML notation, with no runtime JS required for rendering
- This is an important consideration for me, as I want the results to be reasonably accessible even where JS might be restricted or unavailable; this also disqualifies solutions like MathJax
- Can be ran in a NodeJS-like environment
- Is available as a self-contained bundle
Deno
Deno was selected as the JS runtime of choice for following reasons
- Secure by default
- Even though it is assumed that the Katex code is not malicious, it is sensible to make secure choices where possible; sandboxing in regular NodeJS is, best to my knowledge, substantially more complicated and not really something I wish to divert my attention to
- Commonly available, and easily installed in most environments
- Often as simple as
npm install -g deno@2, assuming a NodeJS setup already exists. Other setup methods are also available
- Often as simple as
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: