ratatex 0.1.0

TeX-quality display math for Ratatui with Kitty Unicode placeholders
Documentation

CI Crates.io Docs.rs License

Try it · Embed it · Streaming · Selection · Scrolling

Ratatex turns Markdown display math into cell-aligned PNGs on bounded background workers:

Markdown display math
    → RaTeX parse + TeX layout in-process
    → embedded KaTeX fonts + tiny-skia at 3× resolution
    → antialiasing, padding, and cell alignment
    → content-addressed PNG cache
    → Kitty virtual placement + Unicode-placeholder cells

Rendering uses the open-source RaTeX parser, layout engine, embedded fonts, and rasterizer entirely in-process. Ratatex never invokes a system TeX installation, writes DVI, or starts a rendering subprocess. Unsupported expressions return a typed failure so the host application can keep its text representation.

The placeholder cells live in the normal Ratatui buffer. Equations therefore scroll and clip with the surrounding text instead of behaving like cursor-positioned overlays.

Install

cargo add ratatex

Requirements

  • A terminal implementing Kitty graphics Unicode placeholders.
  • Ratatui 0.29.
  • No TeX distribution or external rendering programs.

Ghostty, Kitty, WezTerm, and recent Konsole releases implement the graphics protocol. When running inside tmux, enable graphics passthrough:

set -g allow-passthrough on

Try it

From a compatible terminal:

cargo run --example demo

Press q, Esc, or Ctrl-C to leave the demo.

Embed it

Query the terminal after entering the alternate screen and before starting a competing input reader. Build one renderer for the application, not one per formula:

use std::{io::Write, time::Duration};

use ratatex::{FormulaState, FormulaWidget, Ratatex, TerminalProfile};
use ratatui::{Frame, layout::Rect};

let profile = TerminalProfile::query(Duration::from_millis(750));
let renderer = Ratatex::builder(profile)
    .on_update(|| {
        // Wake your application's event loop.
    })
    .build()?;

fn draw_formula(
    frame: &mut Frame<'_>,
    area: Rect,
    renderer: &Ratatex,
    tex: &str,
) {
    if let FormulaState::Ready(formula) = renderer.request(tex, area.width) {
        frame.render_widget(FormulaWidget::new(&formula), area);
    } else {
        // Keep a text fallback or a previously ready streaming frame.
    }
}

// Before the next Ratatui draw:
for command in renderer.drain_terminal_commands() {
    std::io::stdout().write_all(command.as_bytes())?;
}

# Ok::<(), std::io::Error>(())

request() is non-blocking. A cache miss returns Pending, renders in-process on a worker, and invokes on_update when the result is ready. Subsequent runs can load the content-addressed PNG directly from disk.

When the callback wakes your event loop:

  1. Invalidate cached layout if generation() changed.
  2. Drain terminal commands and write them before drawing their placeholders.
  3. Draw the next Ratatui frame.

See examples/demo.rs for a complete crossterm loop.

Streaming Markdown

You do not need to wait for the model to close \], $$, aligned, braces, or \left before beginning a render. heal_streaming_display_math() returns a temporary view with only the missing closing tokens appended:

use ratatex::{
    Formula, FormulaState, Ratatex, display_math, heal_streaming_display_math,
};
use std::sync::Arc;

fn update_streaming_formula(
    renderer: &Ratatex,
    markdown_prefix: &str,
    width: u16,
    last_ready: &mut Option<Arc<Formula>>,
) {
    let preview = heal_streaming_display_math(markdown_prefix);
    let Some(region) = display_math(&preview).last() else {
        return;
    };

    if let FormulaState::Ready(formula) = renderer.request(region.source(), width) {
        *last_ready = Some(formula);
    }
}

The helper never alters existing stream bytes. Keep the real Markdown for copying, and keep displaying last_ready while a newer prefix is Pending or temporarily unparsable. This makes a formula refine in place instead of showing raw TeX and flashing into an image only at the end.

For fast streams, retain a short history of submitted prefixes. A slightly older request may finish after a newer prefix has already arrived; accepting that completed frame gives the user useful progressive output while the newest request catches up.

display_math() and markdown_segments() recognize $$…$$, \[…\], and common AMS display environments while ignoring fenced and inline code. Ratatex intentionally leaves prose, inline math, and Markdown styling to the host application.

Copyable LaTeX

For native terminal selection and copying, render the same widget with its original display-math region during your application's selection pass:

let clicked_formula =
    ratatex::is_formula_placeholder(frame.buffer()[mouse_position].symbol());
let widget = FormulaWidget::new(&formula);
let widget = if clicked_formula || selection_intersects_formula {
    widget.compact_source_fallback(display_math.full_source())
} else {
    widget
};
frame.render_widget(widget, area);

Keep the fallback enabled after the first click while the user starts their selection. The compact fallback keeps the formula's existing rows and folds source newlines into spaces, so enabling it does not reflow the surrounding transcript or clip a closing delimiter in common display blocks. Your normal selection highlight can then operate on ordinary LaTeX cells instead of Kitty placeholder characters. Use source_fallback when exact source whitespace is more important and the application reserves enough rows for it.

Scrolling and clipping

Reserve exactly Formula::rows() transcript rows. When part of the formula is above the viewport, keep the formula's logical origin and pass its signed offset to the widget:

use ratatex::{FormulaWidget, SignedPosition};

FormulaWidget::new(&formula)
    .position(SignedPosition::new(0, -rows_scrolled_off_top))
    .render(visible_area, frame.buffer_mut());

Do not slice the placeholder grid and redraw it from tile (0, 0). Kitty Unicode placeholders encode each tile's source row and column; preserving those coordinates is what keeps partially visible equations intact while scrolling up and down.

Call reupload_all() after recreating the alternate screen and when a tmux pane regains focus. tmux retains the placeholder cells for hidden panes but normally drops graphics passthrough sent while the pane is not visible.

Rendering choices

The default is a transparent grayscale alpha mask with a neutral off-white foreground. It works across terminal themes without guessing the background. ColorScheme and Antialiasing::{LcdRgb,LcdBgr} provide LCD subpixel rendering when the application knows the exact background color.

TeX input, decoded images, queues, and caches are bounded, and unsupported or unsafe primitives are rejected before layout. Formula input is parsed as math; it is never passed to a shell or external TeX process. Run untrusted rendering in an OS sandbox when it requires a hard security boundary.

Scope

Ratatex renders display math. It does not own your Markdown parser, transcript model, scrolling state, input loop, or clipboard. The API is deliberately a small composable layer: detection and streaming healing, an asynchronous renderer/cache, terminal commands, and a clipping Ratatui widget.

The supported formula language is the KaTeX/AMS-style subset implemented by RaTeX. Ratatex intentionally does not load arbitrary TeX packages or fall back to a system TeX toolchain.

License

MIT