diff --git a/Cargo.toml b/Cargo.toml
index aa49ff3e..83f180af 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -16,6 +16,7 @@ members = [
"crates/ratex-wasm",
"crates/ratex-pdf",
"crates/ratex-unicode-font",
+ "crates/ratex-py",
]
[workspace.package]
@@ -36,6 +37,7 @@ ratex-layout = { path = "crates/ratex-layout", version = "0.1.11" }
ratex-katex-fonts = { path = "crates/ratex-katex-fonts", version = "0.1.11" }
ratex-font-loader = { path = "crates/ratex-font-loader", version = "0.1.11" }
ratex-cairo = { path = "crates/ratex-cairo", version = "0.1.11" }
+ratex-render = { path = "crates/ratex-render", version = "0.1.11" }
ratex-svg = { path = "crates/ratex-svg", version = "0.1.11" }
ratex-pdf = { path = "crates/ratex-pdf", version = "0.1.11" }
ratex-unicode-font = { path = "crates/ratex-unicode-font", version = "0.1.11" }
diff --git a/crates/ratex-py/Cargo.toml b/crates/ratex-py/Cargo.toml
new file mode 100644
index 00000000..67565d31
--- /dev/null
+++ b/crates/ratex-py/Cargo.toml
@@ -0,0 +1,33 @@
+[package]
+name = "ratex-py"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+repository.workspace = true
+homepage.workspace = true
+documentation.workspace = true
+description = "Python bindings for RaTeX math rendering (PyO3 / maturin)"
+
+[lib]
+name = "ratex_py"
+crate-type = ["cdylib"]
+
+[features]
+default = ["embed-fonts"]
+## Required by maturin / PyO3 when building a Python extension module.
+extension-module = ["pyo3/extension-module"]
+## Bundle KaTeX TTF fonts so no external font-dir is needed at runtime.
+embed-fonts = [
+ "ratex-svg/embed-fonts",
+ "ratex-render/embed-fonts",
+]
+
+[dependencies]
+pyo3 = { version = "0.28", features = ["auto-initialize"] }
+ratex-types = { workspace = true }
+ratex-parser = { workspace = true }
+ratex-layout = { workspace = true }
+ratex-svg = { workspace = true, features = ["standalone"] }
+ratex-render = { workspace = true }
+ratex-pdf = { workspace = true }
+serde_json = { workspace = true }
diff --git a/crates/ratex-py/README.md b/crates/ratex-py/README.md
new file mode 100644
index 00000000..ecb509ca
--- /dev/null
+++ b/crates/ratex-py/README.md
@@ -0,0 +1,500 @@
+# ratex-py: Python Bindings for RaTeX Math Rendering
+
+PyO3 bindings for **RaTeX**, a fast, lightweight, feature-complete LaTeX math renderer in Rust. Renders LaTeX to SVG, PNG, PDF, and a structured display list format.
+
+## Features
+
+- **Fast**: Parse, layout, and render math in milliseconds
+- **Accurate**: Full TeX math mode semantics via KaTeX
+- **Multiple formats**: SVG (with or without embedded glyphs), PNG (with DPR support), PDF (with embedded fonts)
+- **Caching**: Separate parse/layout from rendering via DisplayList JSON protocol
+- **Batch rendering**: Render multiple formulas efficiently
+- **Python-first API**: Type hints, keyword arguments, comprehensive error messages
+- **Fontless**: Bundled KaTeX fonts (DejaVu, Latin Modern, Lato, Noto Sans)
+
+## Installation
+
+Install from source (requires Rust toolchain):
+
+```bash
+pip install src/RaTeX/
+```
+
+Or as part of the full dsport stack:
+
+```bash
+pip install -r requirements.txt
+```
+
+## Quick Start
+
+```python
+import ratex_py
+
+# Render to SVG
+svg = ratex_py.render_svg(r"\frac{-b \pm \sqrt{b^2-4ac}}{2a}")
+print(svg) #
+
+# Render to PNG (bytes)
+png_bytes = ratex_py.render_png(r"E = mc^2")
+with open("equation.png", "wb") as f:
+ f.write(png_bytes)
+
+# Render to PDF (bytes)
+pdf_bytes = ratex_py.render_pdf(r"\int_0^\infty e^{-x^2}\,dx")
+with open("equation.pdf", "wb") as f:
+ f.write(pdf_bytes)
+
+# Validate without rendering
+ratex_py.check(r"\frac{1}{2}") # returns None; raises on error
+```
+
+## API Reference
+
+### Validation
+
+#### `check(latex: str) -> None`
+Parse LaTeX without rendering. Raises `ValueError` if parsing fails.
+
+```python
+ratex_py.check(r"\sqrt{2}") # OK
+ratex_py.check(r"\left(") # raises ValueError
+```
+
+### Rendering
+
+#### `render_svg(latex: str, *, font_size=40.0, display_mode=True, color="black", embed_glyphs=True) -> str`
+Render to self-contained SVG.
+
+- `font_size` (float): Em size in user units; affects absolute dimensions
+- `display_mode` (bool): `True` for display/block math, `False` for inline
+- `color` (str): Foreground color (CSS format: `#rrggbb`, `#rgb`, or named: `"black"`, `"white"`, etc.)
+- `embed_glyphs` (bool): If `True`, embeds glyphs as `` elements; if `False`, uses `` elements (requires CSS)
+
+```python
+# Display mode (larger, centered)
+svg = ratex_py.render_svg(r"\sqrt{x}")
+
+# Inline mode (smaller, no vertical centering)
+svg = ratex_py.render_svg_inline(r"\sqrt{x}")
+
+# Custom color
+svg = ratex_py.render_svg(r"x", color="#ff0000")
+```
+
+#### `render_svg_inline(latex: str, *, font_size=40.0, color="black") -> str`
+Convenience wrapper for inline math SVG (equivalent to `render_svg(..., display_mode=False)`).
+
+#### `render_png(latex: str, *, font_size=40.0, display_mode=True, color="black", background_color="white", dpr=1.0) -> bytes`
+Render to PNG with optional transparency and DPR scaling.
+
+- `background_color` (str): Background color; use `"transparent"` for RGBA
+- `dpr` (float): Device pixel ratio; `2.0` produces 2x resolution
+
+```python
+# Standard white background
+png = ratex_py.render_png(r"x^2")
+
+# Transparent background for compositing
+png = ratex_py.render_png(r"x^2", background_color="transparent")
+
+# High-DPI output (2x)
+png = ratex_py.render_png(r"x^2", dpr=2.0)
+```
+
+#### `render_pdf(latex: str, *, font_size=40.0, display_mode=True, color="black") -> bytes`
+Render to PDF with embedded KaTeX fonts.
+
+```python
+pdf = ratex_py.render_pdf(r"E = mc^2")
+with open("eq.pdf", "wb") as f:
+ f.write(pdf)
+```
+
+### DisplayList Protocol
+
+The **DisplayList** is a structured JSON representation of parsed and laid-out math. It decouples rendering from parsing, enabling caching and multi-format output.
+
+See: [docs/DISPLAYLIST_JSON_PROTOCOL.md](https://github.com/erweixin/RaTeX/blob/main/docs/DISPLAYLIST_JSON_PROTOCOL.md)
+
+
+#### `render_display_list(latex: str, *, display_mode=True) -> str`
+Parse and layout LaTeX, returning DisplayList as JSON string (protocol version 1).
+
+```python
+import json
+
+dl_json = ratex_py.render_display_list(r"\alpha + \beta")
+dl = json.loads(dl_json)
+print(dl["version"]) # 1
+print(dl["width"]) # em units
+print(dl["height"]) # em units
+print(dl["items"]) # list of layout items
+```
+
+#### `parse_display_list(latex: str, *, display_mode=True) -> str`
+Alias for `render_display_list()` for API symmetry.
+
+#### `render_svg_from_display_list(dl_json: str, *, font_size=40.0, embed_glyphs=True) -> str`
+Render cached DisplayList to SVG without re-parsing/laying out.
+
+```python
+# Parse once
+dl_json = ratex_py.render_display_list(r"\frac{1}{2}")
+
+# Render to multiple formats
+svg = ratex_py.render_svg_from_display_list(dl_json)
+png = ratex_py.render_png_from_display_list(dl_json)
+pdf = ratex_py.render_pdf_from_display_list(dl_json)
+```
+
+#### `render_png_from_display_list(dl_json: str, *, font_size=40.0, background_color="white", dpr=1.0) -> bytes`
+Render cached DisplayList to PNG.
+
+#### `render_pdf_from_display_list(dl_json: str, *, font_size=40.0) -> bytes`
+Render cached DisplayList to PDF.
+
+### Batch Rendering
+
+#### `render_svg_batch(latexes: list[str], *, font_size=40.0, display_mode=True, color="black", embed_glyphs=True) -> list[str]`
+Render multiple LaTeX strings to SVG in one call (amortizes FFI overhead).
+
+```python
+svgs = ratex_py.render_svg_batch([
+ r"\alpha",
+ r"\beta",
+ r"\gamma",
+])
+```
+
+#### `render_png_batch(latexes: list[str], *, font_size=40.0, display_mode=True, color="black", background_color="white", dpr=1.0) -> list[bytes]`
+Render multiple LaTeX strings to PNG in one call.
+
+### Multi-Format Rendering
+
+#### `render_formats(latex: str, formats: list[str] | None = None, *, font_size=40.0, display_mode=True, color="black", embed_glyphs=True, background_color="white", dpr=1.0) -> dict`
+Render one expression to one or more output formats in a single call.
+
+- Supported format names: `"svg"`, `"png"`, `"pdf"`, `"html"`, `"json"`, `"display_list"`
+- Default formats: `["svg"]`
+- Returns a dict keyed by format name with the rendered payload.
+
+```python
+# One format
+single = ratex_py.render_formats(r"x^2", ["svg"])
+print(single["svg"][:32])
+
+# Multiple formats from one parse/layout pass
+multi = ratex_py.render_formats(
+ r"\frac{-b \pm \sqrt{b^2-4ac}}{2a}",
+ ["svg", "png", "json", "display_list"],
+ dpr=2.0,
+)
+svg = multi["svg"] # str
+png = multi["png"] # bytes
+meta = multi["json"] # dict/list JSON object
+dl_json = multi["display_list"] # str
+```
+
+### Jupyter Rich Display
+
+`ratex_py.Expr` is a generic rich-display wrapper (and `ratex_py.Math` is an alias).
+
+- Implements `_repr_svg_`, `_repr_png_`, `_repr_html_`, `_repr_json_`
+- Implements `_repr_mimebundle_` for multi-backend Jupyter output
+- `formats=` controls which MIME payloads are emitted in `_repr_mimebundle_`
+
+```python
+import ratex_py
+
+expr = ratex_py.Expr(
+ r"\int_0^\infty e^{-x^2}\,dx",
+ display_mode=True,
+ formats=["svg", "png", "html", "json"],
+)
+
+# In Jupyter, this triggers _repr_mimebundle_
+expr
+
+# Optional explicit render API on the object
+parts = expr.render(["svg", "pdf"])
+```
+
+```python
+# Short alias for convenience
+math_obj = ratex_py.Math(r"E = mc^2", formats=["svg", "html"])
+```
+
+### Constants
+
+#### `display_list_version() -> int`
+Returns the DisplayList JSON protocol version (currently `1`).
+
+---
+
+## Integration: Docutils
+
+Use ratex-py to render math roles and directives in docutils-based workflows.
+
+### Example: Custom Math Role
+
+```python
+# myconf.py
+from docutils import nodes
+from docutils.parsers.rst import directives, Directive
+from docutils.parsers.rst.roles import register_canonical_role
+import ratex_py
+import base64
+
+def math_role(name, rawtext, text, lineno, inliner, options=None, content=None):
+ """Role to render inline math."""
+ options = options or {}
+ try:
+ svg = ratex_py.render_svg_inline(text, color="black")
+ # Create an image node from SVG
+ image = nodes.image(uri=f"data:image/svg+xml;base64,{base64.b64encode(svg.encode()).decode()}")
+ return [image], []
+ except ValueError as e:
+ msg = nodes.system_message(f"Math error: {e}", level=2)
+ return [msg], [inliner.reporter.warning(str(e), line=lineno)]
+
+register_canonical_role('math', math_role)
+```
+
+### Example: Math Block Directive
+
+```python
+class MathDirective(Directive):
+ """Directive for block math equations."""
+ required_arguments = 0
+ optional_arguments = 0
+ final_argument_whitespace = True
+ has_content = True
+ option_spec = {
+ 'label': directives.unchanged,
+ 'font_size': float,
+ }
+
+ def run(self):
+ latex = '\n'.join(self.content)
+ font_size = self.options.get('font_size', 40.0)
+ try:
+ svg = ratex_py.render_svg(latex, font_size=font_size)
+ image = nodes.image(uri=f"data:image/svg+xml;base64,...")
+ return [image]
+ except ValueError as e:
+ return [self.state_machine.reporter.error(f"Math error: {e}", line=self.lineno)]
+
+directives.register_directive('math', MathDirective)
+```
+
+### Example: Docutils API
+
+```python
+import ratex_py
+from docutils.core import publish_parts
+
+# Configure custom math role/directive before processing
+rst = """
+This is inline :math:`x^2 + y^2 = r^2` math.
+
+.. math::
+
+ \\frac{1}{2}
+"""
+
+parts = publish_parts(rst, writer_name='html')
+print(parts['html_body'])
+```
+
+---
+
+## Integration: Sphinx
+
+Use ratex-py with Sphinx to render math in documentation.
+
+### Method 1: Custom Math Renderer (Recommended)
+
+Create a Sphinx extension that patches the math role and domain:
+
+```python
+# sphinx_math_ext.py
+from sphinx.roles import XRefRole
+from sphinx.domains.c_cpp import CPPDomain
+import ratex_py
+import base64
+
+def setup(app):
+ """Register ratex-py as math backend."""
+
+ def render_math_role(name, rawtext, text, lineno, inliner):
+ """Render :math:`...` roles with ratex-py."""
+ try:
+ svg = ratex_py.render_svg_inline(text)
+ uri = f"data:image/svg+xml;base64,{base64.b64encode(svg.encode()).decode()}"
+ image = nodes.image(uri=uri)
+ return [image], []
+ except Exception as e:
+ return [nodes.system_message(str(e), level=2)], []
+
+ app.add_role('math', render_math_role)
+```
+
+### Method 2: Using Docutils Extension
+
+In `conf.py`:
+
+```python
+# conf.py
+import ratex_py
+import os
+
+extensions = [
+ 'sphinx.ext.mathjax', # or 'sphinx.ext.imgmath'
+]
+
+# Override imgmath to use ratex-py
+imgmath_latex_preamble = '' # Not needed for ratex-py
+imgmath_use_preview = False
+
+# Custom image converter
+def convert_svg_to_png(svg_str):
+ """Convert SVG to PNG for compatibility."""
+ return ratex_py.render_svg(svg_str)
+```
+
+### Example: Sphinx Document
+
+```rst
+.. math::
+ :label: eq-quadratic
+
+ x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}
+
+Inline math :math:`E = mc^2` is also supported.
+```
+
+---
+
+## Performance Considerations
+
+### DisplayList Caching
+
+For documents with many math expressions, parse once and render multiple times:
+
+```python
+# Slow: parse → SVG, parse → PNG, parse → PDF (3 parses)
+svg = ratex_py.render_svg(formula)
+png = ratex_py.render_png(formula)
+pdf = ratex_py.render_pdf(formula)
+
+# Fast: parse once, render 3 times (1 parse)
+dl = ratex_py.render_display_list(formula)
+svg = ratex_py.render_svg_from_display_list(dl)
+png = ratex_py.render_png_from_display_list(dl)
+pdf = ratex_py.render_pdf_from_display_list(dl)
+```
+
+### Batch Rendering
+
+Render multiple formulas at once to reduce FFI overhead:
+
+```python
+# Slow: 100 FFI calls
+for formula in formulas:
+ svg = ratex_py.render_svg(formula)
+
+# Fast: 1 FFI call
+svgs = ratex_py.render_svg_batch(formulas)
+```
+
+---
+
+## Color Format Support
+
+Colors are specified as strings in CSS format:
+
+- **Named colors**: `"black"`, `"white"`, `"red"`, `"green"`, `"blue"`, `"transparent"`
+- **Hex 6-digit**: `"#ff0000"` (red)
+- **Hex 3-digit**: `"#f00"` (red, expanded to `#ff0000`)
+- **Hex with alpha**: `"#ff000080"` (semi-transparent red) — PNG only
+
+```python
+svg = ratex_py.render_svg(r"\alpha", color="#ff0000")
+svg = ratex_py.render_svg(r"\beta", color="blue")
+svg = ratex_py.render_svg(r"\gamma", color="#ff0000")
+```
+
+---
+
+## DisplayList JSON Protocol v1
+
+The DisplayList JSON schema (simplified):
+
+```json
+{
+ "version": 1,
+ "width": 2.5,
+ "height": 1.2,
+ "depth": 0.3,
+ "items": [
+ {
+ "type": "glyph",
+ "x": 0.1,
+ "y": 0.5,
+ "font": "KaTeX_Main",
+ "glyph": "x",
+ "advance": 0.6
+ },
+ {
+ "type": "line",
+ "x1": 0.0,
+ "y1": 0.8,
+ "x2": 1.0,
+ "y2": 0.8,
+ "thickness": 0.05
+ }
+ ]
+}
+```
+
+- **Coordinate system**: x-right, y-down (text baseline)
+- **Units**: Em units (relative to font size)
+- **Items**: Glyphs, lines, horizontal/vertical rules, nested groups
+
+See [DISPLAYLIST_JSON_PROTOCOL.md](../../../docs/DISPLAYLIST_JSON_PROTOCOL.md) for the full schema.
+
+---
+
+## Error Handling
+
+All functions raise `ValueError` on invalid input:
+
+```python
+import ratex_py
+
+try:
+ ratex_py.render_svg(r"\left(") # unmatched delimiter
+except ValueError as e:
+ print(f"Math error: {e}")
+```
+
+---
+
+## Contributing
+
+
+- Write Tests: `cd RaTeX/crates/ratex-py && cargo test --lib`
+- Build: `cd RaTeX/crates/ratex-py && maturin build -r` (release build)
+- Goal: 100% branch coverage
+- This package was developed as part of the **dsport** project
+ (docutils + sphinxdoc port to Rust).
+- This package is to be contributed to RaTeX as `RaTeX/crates/ratex-py` and also `/pyproject.toml`
+
+---
+
+## License
+
+ratex-py is licensed under the MIT License (inherited from RaTeX).
+
+KaTeX fonts are licensed under the SIL Open Font License (OFL).
diff --git a/crates/ratex-py/src/lib.rs b/crates/ratex-py/src/lib.rs
new file mode 100644
index 00000000..950ad469
--- /dev/null
+++ b/crates/ratex-py/src/lib.rs
@@ -0,0 +1,1031 @@
+//! Python bindings for RaTeX math rendering.
+//!
+//! Comprehensive LaTeX math → SVG / PNG / PDF via PyO3.
+//! Supports parse validation, cached DisplayList JSON, and batch rendering.
+//!
+//! ```python
+//! import ratex_py
+//!
+//! # Simple rendering
+//! svg = ratex_py.render_svg(r"\frac{1}{2}")
+//! png = ratex_py.render_png(r"\frac{1}{2}")
+//! pdf = ratex_py.render_pdf(r"\frac{1}{2}")
+//!
+//! # Cache DisplayList JSON, render to multiple formats
+//! dl_json = ratex_py.render_display_list(r"\frac{1}{2}")
+//! svg = ratex_py.render_svg_from_display_list(dl_json)
+//! png = ratex_py.render_png_from_display_list(dl_json)
+//! pdf = ratex_py.render_pdf_from_display_list(dl_json)
+//!
+//! # Parse validation
+//! ratex_py.check(r"\frac{1}{2}") # raises on error
+//!
+//! # Convenience
+//! inline_svg = ratex_py.render_svg_inline(r"\sqrt{x}") # display_mode=False
+//! ```
+
+use pyo3::exceptions::PyValueError;
+use pyo3::prelude::*;
+use pyo3::types::{PyBytes, PyDict};
+use ratex_layout::{layout, to_display_list, LayoutOptions};
+use ratex_parser::parse;
+use ratex_pdf::{render_to_pdf, PdfOptions};
+use ratex_render::{render_to_png, RenderOptions};
+use ratex_svg::{render_to_svg, SvgOptions};
+use ratex_types::color::Color;
+use ratex_types::display_item::DisplayList;
+use ratex_types::math_style::MathStyle;
+
+// ---------------------------------------------------------------------------
+// Internal helpers
+// ---------------------------------------------------------------------------
+
+/// Parse a CSS-style color string (`#rrggbb`, `#rgb`, or the names `black` /
+/// `white` / `transparent`) into a [`Color`].
+fn parse_color(s: &str) -> PyResult {
+ let s = s.trim();
+ if s.eq_ignore_ascii_case("black") {
+ return Ok(Color::BLACK);
+ }
+ if s.eq_ignore_ascii_case("white") {
+ return Ok(Color::WHITE);
+ }
+ if s.eq_ignore_ascii_case("transparent") {
+ return Ok(Color::new(0.0, 0.0, 0.0, 0.0));
+ }
+ if let Some(hex) = s.strip_prefix('#') {
+ let (rs, gs, bs) = match hex.len() {
+ 6 => (&hex[0..2], &hex[2..4], &hex[4..6]),
+ 3 => {
+ let r = u8::from_str_radix(&hex[0..1].repeat(2), 16)
+ .map_err(|e| PyValueError::new_err(e.to_string()))?;
+ let g = u8::from_str_radix(&hex[1..2].repeat(2), 16)
+ .map_err(|e| PyValueError::new_err(e.to_string()))?;
+ let b = u8::from_str_radix(&hex[2..3].repeat(2), 16)
+ .map_err(|e| PyValueError::new_err(e.to_string()))?;
+ return Ok(Color::new(r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0));
+ }
+ _ => {
+ return Err(PyValueError::new_err(format!(
+ "invalid hex color (expected #rgb or #rrggbb): {s}"
+ )))
+ }
+ };
+ let r = u8::from_str_radix(rs, 16).map_err(|e| PyValueError::new_err(e.to_string()))?;
+ let g = u8::from_str_radix(gs, 16).map_err(|e| PyValueError::new_err(e.to_string()))?;
+ let b = u8::from_str_radix(bs, 16).map_err(|e| PyValueError::new_err(e.to_string()))?;
+ return Ok(Color::new(r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0));
+ }
+ Err(PyValueError::new_err(format!(
+ "unsupported color format (use #rrggbb, #rgb, 'black', 'white', or 'transparent'): {s}"
+ )))
+}
+
+fn make_layout_options(display_mode: bool, color: &str) -> PyResult {
+ let style = if display_mode {
+ MathStyle::Display
+ } else {
+ MathStyle::Text
+ };
+ Ok(LayoutOptions {
+ style,
+ color: parse_color(color)?,
+ ..Default::default()
+ })
+}
+
+/// Parse and layout LaTeX → DisplayList (the core pipeline).
+fn pipeline(latex: &str, display_mode: bool, color: &str) -> PyResult {
+ let opts = make_layout_options(display_mode, color)?;
+ let nodes = parse(latex).map_err(|e| PyValueError::new_err(format!("parse error: {e}")))?;
+ let box_ = layout(&nodes, &opts);
+ Ok(to_display_list(&box_))
+}
+
+/// Deserialize a JSON DisplayList string back into a DisplayList struct.
+fn deserialize_display_list(json_str: &str) -> PyResult {
+ serde_json::from_str(json_str)
+ .map_err(|e| PyValueError::new_err(format!("failed to deserialize DisplayList JSON: {e}")))
+}
+
+fn normalize_formats(formats: &[String]) -> PyResult> {
+ if formats.is_empty() {
+ return Err(PyValueError::new_err(
+ "formats must contain at least one entry",
+ ));
+ }
+
+ let mut normalized = Vec::with_capacity(formats.len());
+ for format in formats {
+ let name = format.trim().to_ascii_lowercase();
+ let allowed = matches!(
+ name.as_str(),
+ "svg" | "png" | "pdf" | "html" | "json" | "display_list"
+ );
+ if !allowed {
+ return Err(PyValueError::new_err(format!(
+ "unsupported format '{name}' (use svg, png, pdf, html, json, or display_list)"
+ )));
+ }
+ if !normalized.contains(&name) {
+ normalized.push(name);
+ }
+ }
+ Ok(normalized)
+}
+
+#[derive(Clone, Copy)]
+struct RenderParams<'a> {
+ font_size: f64,
+ display_mode: bool,
+ color: &'a str,
+ embed_glyphs: bool,
+ background_color: &'a str,
+ dpr: f64,
+}
+
+fn render_formats_impl(
+ py: Python<'_>,
+ latex: &str,
+ formats: &[String],
+ params: RenderParams<'_>,
+) -> PyResult> {
+ let normalized = normalize_formats(formats)?;
+ let display_list = pipeline(latex, params.display_mode, params.color)?;
+
+ let svg_opts = SvgOptions {
+ font_size: params.font_size,
+ embed_glyphs: params.embed_glyphs,
+ ..Default::default()
+ };
+ let png_opts = RenderOptions {
+ font_size: params.font_size as f32,
+ background_color: parse_color(params.background_color)?,
+ device_pixel_ratio: params.dpr as f32,
+ ..Default::default()
+ };
+ let pdf_opts = PdfOptions {
+ font_size: params.font_size,
+ ..Default::default()
+ };
+
+ let out = PyDict::new(py);
+ let mut cached_svg: Option = None;
+ let mut cached_display_list_json: Option = None;
+
+ for format in normalized {
+ match format.as_str() {
+ "svg" => {
+ let svg = render_to_svg(&display_list, &svg_opts);
+ cached_svg = Some(svg.clone());
+ out.set_item("svg", svg)?;
+ }
+ "png" => {
+ let png = render_to_png(&display_list, &png_opts).map_err(PyValueError::new_err)?;
+ out.set_item("png", PyBytes::new(py, &png))?;
+ }
+ "pdf" => {
+ let pdf =
+ render_to_pdf(&display_list, &pdf_opts).map_err(|e| PyValueError::new_err(e.to_string()))?;
+ out.set_item("pdf", PyBytes::new(py, &pdf))?;
+ }
+ "display_list" => {
+ let json = serde_json::to_string(&display_list)
+ .map_err(|e| PyValueError::new_err(format!("serialization error: {e}")))?;
+ cached_display_list_json = Some(json.clone());
+ out.set_item("display_list", json)?;
+ }
+ "json" => {
+ let json = if let Some(existing) = &cached_display_list_json {
+ existing.clone()
+ } else {
+ let serialized = serde_json::to_string(&display_list)
+ .map_err(|e| PyValueError::new_err(format!("serialization error: {e}")))?;
+ cached_display_list_json = Some(serialized.clone());
+ serialized
+ };
+ let parsed_json = py
+ .import("json")?
+ .call_method1("loads", (json,))?;
+ out.set_item("json", parsed_json)?;
+ }
+ "html" => {
+ let svg = if let Some(existing) = &cached_svg {
+ existing.clone()
+ } else {
+ let rendered = render_to_svg(&display_list, &svg_opts);
+ cached_svg = Some(rendered.clone());
+ rendered
+ };
+ let mode = if params.display_mode { "display" } else { "inline" };
+ let html = format!(
+ "{svg}",
+ );
+ out.set_item("html", html)?;
+ }
+ _ => unreachable!("format already validated"),
+ }
+ }
+
+ Ok(out.into())
+}
+
+#[pyclass(skip_from_py_object)]
+#[derive(Clone)]
+struct Expr {
+ latex: String,
+ font_size: f64,
+ display_mode: bool,
+ color: String,
+ embed_glyphs: bool,
+ background_color: String,
+ dpr: f64,
+ formats: Vec,
+}
+
+#[pymethods]
+impl Expr {
+ #[new]
+ #[pyo3(signature = (latex, *, font_size=40.0, display_mode=true, color="black", embed_glyphs=true, background_color="white", dpr=1.0, formats=None))]
+ fn new(
+ latex: &str,
+ font_size: f64,
+ display_mode: bool,
+ color: &str,
+ embed_glyphs: bool,
+ background_color: &str,
+ dpr: f64,
+ formats: Option>,
+ ) -> PyResult {
+ let formats = formats.unwrap_or_else(|| vec!["svg".to_string()]);
+ let normalized = normalize_formats(&formats)?;
+ Ok(Self {
+ latex: latex.to_string(),
+ font_size,
+ display_mode,
+ color: color.to_string(),
+ embed_glyphs,
+ background_color: background_color.to_string(),
+ dpr,
+ formats: normalized,
+ })
+ }
+
+ fn _repr_svg_(&self) -> PyResult {
+ render_svg(
+ &self.latex,
+ self.font_size,
+ self.display_mode,
+ &self.color,
+ self.embed_glyphs,
+ )
+ }
+
+ fn _repr_png_(&self) -> PyResult> {
+ render_png(
+ &self.latex,
+ self.font_size,
+ self.display_mode,
+ &self.color,
+ &self.background_color,
+ self.dpr,
+ )
+ }
+
+ fn _repr_html_(&self) -> PyResult {
+ let svg = self._repr_svg_()?;
+ let mode = if self.display_mode { "display" } else { "inline" };
+ Ok(format!(
+ "{svg}",
+ ))
+ }
+
+ fn _repr_json_(&self, py: Python<'_>) -> PyResult> {
+ let json = render_display_list(&self.latex, self.display_mode)?;
+ Ok(py
+ .import("json")?
+ .call_method1("loads", (json,))?
+ .unbind()
+ .into())
+ }
+
+ #[pyo3(signature = (_include=None, _exclude=None))]
+ fn _repr_mimebundle_(
+ &self,
+ py: Python<'_>,
+ _include: Option<&Bound<'_, PyAny>>,
+ _exclude: Option<&Bound<'_, PyAny>>,
+ ) -> PyResult> {
+ let rendered = render_formats_impl(py, &self.latex, &self.formats, self.params())?;
+ let rendered = rendered.bind(py);
+
+ let bundle = PyDict::new(py);
+ if let Some(svg) = rendered.get_item("svg")? {
+ bundle.set_item("image/svg+xml", svg)?;
+ }
+ if let Some(png) = rendered.get_item("png")? {
+ bundle.set_item("image/png", png)?;
+ }
+ if let Some(pdf) = rendered.get_item("pdf")? {
+ bundle.set_item("application/pdf", pdf)?;
+ }
+ if let Some(html) = rendered.get_item("html")? {
+ bundle.set_item("text/html", html)?;
+ }
+ if let Some(json_obj) = rendered.get_item("json")? {
+ bundle.set_item("application/json", json_obj)?;
+ }
+ if let Some(display_list) = rendered.get_item("display_list")? {
+ bundle.set_item("application/x-ratex-display-list+json", display_list)?;
+ }
+ Ok(bundle.into())
+ }
+
+ #[pyo3(signature = (formats=None))]
+ fn render(&self, py: Python<'_>, formats: Option>) -> PyResult> {
+ let formats = formats.unwrap_or_else(|| self.formats.clone());
+ render_formats_impl(py, &self.latex, &formats, self.params())
+ }
+
+ fn __repr__(&self) -> String {
+ format!(
+ "Expr({:?}, font_size={}, display_mode={}, color={:?}, formats={:?})",
+ self.latex, self.font_size, self.display_mode, self.color, self.formats
+ )
+ }
+}
+
+impl Expr {
+ fn params(&self) -> RenderParams<'_> {
+ RenderParams {
+ font_size: self.font_size,
+ display_mode: self.display_mode,
+ color: &self.color,
+ embed_glyphs: self.embed_glyphs,
+ background_color: &self.background_color,
+ dpr: self.dpr,
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Public Python API
+// ---------------------------------------------------------------------------
+
+/// Parse a LaTeX math string and return ``True``, or raise an exception on parse error.
+/// Useful for validation without rendering.
+///
+/// Parameters
+/// ----------
+/// latex : str
+/// The LaTeX math input.
+///
+/// Raises
+/// ------
+/// ValueError
+/// If the LaTeX cannot be parsed.
+#[pyfunction]
+fn check(latex: &str) -> PyResult<()> {
+ parse(latex).map_err(|e| PyValueError::new_err(format!("parse error: {e}")))?;
+ Ok(())
+}
+
+/// Return the current DisplayList JSON protocol version.
+///
+/// Returns
+/// -------
+/// int
+/// Protocol version (currently ``1``).
+#[pyfunction]
+fn display_list_version() -> i32 {
+ 1
+}
+
+/// Render a LaTeX math string to a self-contained SVG string.
+///
+/// Parameters
+/// ----------
+/// latex : str
+/// The LaTeX math input (no surrounding `$…$` delimiters).
+/// font_size : float, optional
+/// Em size in user units (default 40.0).
+/// display_mode : bool, optional
+/// ``True`` for block/display math (default), ``False`` for inline.
+/// color : str, optional
+/// Foreground color as ``#rrggbb``, ``#rgb``, or a named color
+/// (``"black"`` / ``"white"``). Default ``"black"``.
+/// embed_glyphs : bool, optional
+/// When ``True`` (default) glyphs are emitted as ```` elements so
+/// the SVG is fully self-contained. When ``False``, ```` elements
+/// with KaTeX CSS class names are emitted instead (needs KaTeX stylesheets
+/// in the host page).
+///
+/// Returns
+/// -------
+/// str
+/// SVG document as a string.
+#[pyfunction]
+#[pyo3(signature = (latex, *, font_size=40.0, display_mode=true, color="black", embed_glyphs=true))]
+fn render_svg(
+ latex: &str,
+ font_size: f64,
+ display_mode: bool,
+ color: &str,
+ embed_glyphs: bool,
+) -> PyResult {
+ let display_list = pipeline(latex, display_mode, color)?;
+ let opts = SvgOptions {
+ font_size,
+ embed_glyphs,
+ ..Default::default()
+ };
+ Ok(render_to_svg(&display_list, &opts))
+}
+
+/// Convenience: render a LaTeX string as inline (text-mode) SVG.
+///
+/// Equivalent to ``render_svg(latex, display_mode=False, embed_glyphs=True)``.
+///
+/// Parameters
+/// ----------
+/// latex : str
+/// The LaTeX math input.
+/// font_size : float, optional
+/// Em size in user units (default 40.0).
+/// color : str, optional
+/// Foreground color. Default ``"black"``.
+///
+/// Returns
+/// -------
+/// str
+/// SVG document as a string.
+#[pyfunction]
+#[pyo3(signature = (latex, *, font_size=40.0, color="black"))]
+fn render_svg_inline(latex: &str, font_size: f64, color: &str) -> PyResult {
+ render_svg(latex, font_size, false, color, true)
+}
+
+/// Render a LaTeX math string to PNG bytes.
+///
+/// Parameters
+/// ----------
+/// latex : str
+/// The LaTeX math input.
+/// font_size : float, optional
+/// Em size in pixels at DPR 1 (default 40.0).
+/// display_mode : bool, optional
+/// ``True`` for block/display math (default), ``False`` for inline.
+/// color : str, optional
+/// Foreground color. Default ``"black"``.
+/// background_color : str, optional
+/// Background fill. Use ``"transparent"`` for a transparent PNG.
+/// Default ``"white"``.
+/// dpr : float, optional
+/// Device pixel ratio multiplier (default 1.0).
+///
+/// Returns
+/// -------
+/// bytes
+/// Raw PNG bytes.
+#[pyfunction]
+#[pyo3(signature = (latex, *, font_size=40.0, display_mode=true, color="black", background_color="white", dpr=1.0))]
+fn render_png(
+ latex: &str,
+ font_size: f64,
+ display_mode: bool,
+ color: &str,
+ background_color: &str,
+ dpr: f64,
+) -> PyResult> {
+ let display_list = pipeline(latex, display_mode, color)?;
+ let opts = RenderOptions {
+ font_size: font_size as f32,
+ background_color: parse_color(background_color)?,
+ device_pixel_ratio: dpr as f32,
+ ..Default::default()
+ };
+ render_to_png(&display_list, &opts).map_err(PyValueError::new_err)
+}
+
+/// Render a LaTeX math string to PDF bytes.
+///
+/// Parameters
+/// ----------
+/// latex : str
+/// The LaTeX math input.
+/// font_size : float, optional
+/// Em size in user units (default 40.0).
+/// display_mode : bool, optional
+/// ``True`` for block/display math (default), ``False`` for inline.
+/// color : str, optional
+/// Foreground color. Default ``"black"``.
+///
+/// Returns
+/// -------
+/// bytes
+/// Raw PDF bytes with embedded KaTeX fonts.
+#[pyfunction]
+#[pyo3(signature = (latex, *, font_size=40.0, display_mode=true, color="black"))]
+fn render_pdf(
+ latex: &str,
+ font_size: f64,
+ display_mode: bool,
+ color: &str,
+) -> PyResult> {
+ let display_list = pipeline(latex, display_mode, color)?;
+ let opts = PdfOptions {
+ font_size,
+ ..Default::default()
+ };
+ render_to_pdf(&display_list, &opts).map_err(|e| PyValueError::new_err(e.to_string()))
+}
+
+/// Parse and lay out a LaTeX math string, returning the DisplayList as a JSON string.
+///
+/// The JSON schema is documented in ``docs/DISPLAYLIST_JSON_PROTOCOL.md``.
+/// Useful for caching / custom renderers (Canvas 2D, PDF, platform-native drawing).
+///
+/// Parameters
+/// ----------
+/// latex : str
+/// The LaTeX math input.
+/// display_mode : bool, optional
+/// ``True`` for block/display math (default), ``False`` for inline.
+///
+/// Returns
+/// -------
+/// str
+/// DisplayList serialized as JSON (protocol version 1).
+#[pyfunction]
+#[pyo3(signature = (latex, *, display_mode=true))]
+fn render_display_list(latex: &str, display_mode: bool) -> PyResult {
+ let display_list = pipeline(latex, display_mode, "black")?;
+ serde_json::to_string(&display_list)
+ .map_err(|e| PyValueError::new_err(format!("serialization error: {e}")))
+}
+
+/// Parse and lay out a LaTeX math string, returning the DisplayList as a JSON string.
+///
+/// Equivalent to ``render_display_list()``. Call ``json.loads()`` in Python to convert to a dict.
+///
+/// Parameters
+/// ----------
+/// latex : str
+/// The LaTeX math input.
+/// display_mode : bool, optional
+/// ``True`` for block/display math (default), ``False`` for inline.
+///
+/// Returns
+/// -------
+/// str
+/// DisplayList JSON string (protocol version 1).
+#[pyfunction]
+#[pyo3(signature = (latex, *, display_mode=true))]
+fn parse_display_list(latex: &str, display_mode: bool) -> PyResult {
+ let display_list = pipeline(latex, display_mode, "black")?;
+ serde_json::to_string(&display_list)
+ .map_err(|e| PyValueError::new_err(format!("serialization error: {e}"))
+ )
+}
+
+/// Render a DisplayList JSON string to SVG.
+///
+/// Accepts a cached JSON string from ``render_display_list()`` and renders it to SVG
+/// without reparsing / relayouting. Useful for generating multiple formats from a single formula.
+///
+/// Parameters
+/// ----------
+/// display_list_json : str
+/// The JSON DisplayList string (protocol version 1).
+/// font_size : float, optional
+/// Em size in user units (default 40.0).
+/// embed_glyphs : bool, optional
+/// When ``True`` (default) glyphs are emitted as ```` elements.
+///
+/// Returns
+/// -------
+/// str
+/// SVG document as a string.
+#[pyfunction]
+#[pyo3(signature = (display_list_json, *, font_size=40.0, embed_glyphs=true))]
+fn render_svg_from_display_list(
+ display_list_json: &str,
+ font_size: f64,
+ embed_glyphs: bool,
+) -> PyResult {
+ let display_list = deserialize_display_list(display_list_json)?;
+ let opts = SvgOptions {
+ font_size,
+ embed_glyphs,
+ ..Default::default()
+ };
+ Ok(render_to_svg(&display_list, &opts))
+}
+
+/// Render a DisplayList JSON string to PNG.
+///
+/// Accepts a cached JSON string from ``render_display_list()`` and renders it to PNG.
+///
+/// Parameters
+/// ----------
+/// display_list_json : str
+/// The JSON DisplayList string.
+/// font_size : float, optional
+/// Em size in pixels at DPR 1 (default 40.0).
+/// background_color : str, optional
+/// Background fill (default ``"white"``).
+/// dpr : float, optional
+/// Device pixel ratio multiplier (default 1.0).
+///
+/// Returns
+/// -------
+/// bytes
+/// Raw PNG bytes.
+#[pyfunction]
+#[pyo3(signature = (display_list_json, *, font_size=40.0, background_color="white", dpr=1.0))]
+fn render_png_from_display_list(
+ display_list_json: &str,
+ font_size: f64,
+ background_color: &str,
+ dpr: f64,
+) -> PyResult> {
+ let display_list = deserialize_display_list(display_list_json)?;
+ let opts = RenderOptions {
+ font_size: font_size as f32,
+ background_color: parse_color(background_color)?,
+ device_pixel_ratio: dpr as f32,
+ ..Default::default()
+ };
+ render_to_png(&display_list, &opts).map_err(PyValueError::new_err)
+}
+
+/// Render a DisplayList JSON string to PDF.
+///
+/// Accepts a cached JSON string from ``render_display_list()`` and renders it to PDF.
+///
+/// Parameters
+/// ----------
+/// display_list_json : str
+/// The JSON DisplayList string.
+/// font_size : float, optional
+/// Em size in user units (default 40.0).
+///
+/// Returns
+/// -------
+/// bytes
+/// Raw PDF bytes with embedded KaTeX fonts.
+#[pyfunction]
+#[pyo3(signature = (display_list_json, *, font_size=40.0))]
+fn render_pdf_from_display_list(
+ display_list_json: &str,
+ font_size: f64,
+) -> PyResult> {
+ let display_list = deserialize_display_list(display_list_json)?;
+ let opts = PdfOptions {
+ font_size,
+ ..Default::default()
+ };
+ render_to_pdf(&display_list, &opts).map_err(|e| PyValueError::new_err(e.to_string()))
+}
+
+/// Render a list of LaTeX math strings to SVG (batch mode).
+///
+/// Amortises Python FFI overhead when rendering many formulas in a single pass.
+///
+/// Parameters
+/// ----------
+/// latexes : list[str]
+/// List of LaTeX math inputs.
+/// font_size : float, optional
+/// Em size in user units (default 40.0).
+/// display_mode : bool, optional
+/// ``True`` for block/display math (default), ``False`` for inline.
+/// color : str, optional
+/// Foreground color. Default ``"black"``.
+/// embed_glyphs : bool, optional
+/// When ``True`` (default) glyphs are emitted as ```` elements.
+///
+/// Returns
+/// -------
+/// list[str]
+/// SVG strings in the same order as input.
+///
+/// Raises
+/// ------
+/// ValueError
+/// If any formula fails to parse or render.
+#[pyfunction]
+#[pyo3(signature = (latexes, *, font_size=40.0, display_mode=true, color="black", embed_glyphs=true))]
+fn render_svg_batch(
+ latexes: Vec,
+ font_size: f64,
+ display_mode: bool,
+ color: &str,
+ embed_glyphs: bool,
+) -> PyResult> {
+ let mut results = Vec::with_capacity(latexes.len());
+ for latex in latexes {
+ let svg = render_svg(&latex, font_size, display_mode, color, embed_glyphs)?;
+ results.push(svg);
+ }
+ Ok(results)
+}
+
+/// Render a list of LaTeX math strings to PNG (batch mode).
+///
+/// Parameters
+/// ----------
+/// latexes : list[str]
+/// List of LaTeX math inputs.
+/// font_size : float, optional
+/// Em size in pixels at DPR 1 (default 40.0).
+/// display_mode : bool, optional
+/// ``True`` for block/display math (default), ``False`` for inline.
+/// color : str, optional
+/// Foreground color. Default ``"black"``.
+/// background_color : str, optional
+/// Background fill. Default ``"white"``.
+/// dpr : float, optional
+/// Device pixel ratio multiplier (default 1.0).
+///
+/// Returns
+/// -------
+/// list[bytes]
+/// PNG bytes in the same order as input.
+///
+/// Raises
+/// ------
+/// ValueError
+/// If any formula fails to parse or render.
+#[pyfunction]
+#[pyo3(signature = (latexes, *, font_size=40.0, display_mode=true, color="black", background_color="white", dpr=1.0))]
+fn render_png_batch(
+ latexes: Vec,
+ font_size: f64,
+ display_mode: bool,
+ color: &str,
+ background_color: &str,
+ dpr: f64,
+) -> PyResult>> {
+ let mut results = Vec::with_capacity(latexes.len());
+ for latex in latexes {
+ let png = render_png(&latex, font_size, display_mode, color, background_color, dpr)?;
+ results.push(png);
+ }
+ Ok(results)
+}
+
+/// Render one LaTeX input to one or more formats in a single call.
+///
+/// Parameters
+/// ----------
+/// latex : str
+/// LaTeX math input.
+/// formats : list[str], optional
+/// One or more output formats: ``"svg"``, ``"png"``, ``"pdf"``,
+/// ``"html"``, ``"json"``, ``"display_list"``.
+/// Defaults to ``["svg"]``.
+/// font_size : float, optional
+/// Em size in user units (default 40.0).
+/// display_mode : bool, optional
+/// ``True`` for display math (default), ``False`` for inline.
+/// color : str, optional
+/// Foreground color (default ``"black"``).
+/// embed_glyphs : bool, optional
+/// SVG glyph embedding behavior (default ``True``).
+/// background_color : str, optional
+/// PNG background color (default ``"white"``).
+/// dpr : float, optional
+/// PNG device pixel ratio (default ``1.0``).
+///
+/// Returns
+/// -------
+/// dict
+/// Mapping of format name to rendered payload.
+#[pyfunction]
+#[pyo3(signature = (latex, formats=None, *, font_size=40.0, display_mode=true, color="black", embed_glyphs=true, background_color="white", dpr=1.0))]
+fn render_formats(
+ py: Python<'_>,
+ latex: &str,
+ formats: Option>,
+ font_size: f64,
+ display_mode: bool,
+ color: &str,
+ embed_glyphs: bool,
+ background_color: &str,
+ dpr: f64,
+) -> PyResult> {
+ let formats = formats.unwrap_or_else(|| vec!["svg".to_string()]);
+ render_formats_impl(
+ py,
+ latex,
+ &formats,
+ RenderParams {
+ font_size,
+ display_mode,
+ color,
+ embed_glyphs,
+ background_color,
+ dpr,
+ },
+ )
+}
+
+// ---------------------------------------------------------------------------
+// Module registration
+// ---------------------------------------------------------------------------
+
+/// Python bindings for RaTeX — KaTeX-compatible math rendering in pure Rust.
+///
+/// Core functions::
+///
+/// import ratex_py
+/// svg = ratex_py.render_svg(r"\frac{-b \pm \sqrt{b^2-4ac}}{2a}")
+/// png = ratex_py.render_png(r"\int_0^\infty e^{-x^2}\,dx", font_size=32.0)
+/// pdf = ratex_py.render_pdf(r"E = mc^2")
+///
+/// Caching DisplayList for multiple formats::
+///
+/// dl = ratex_py.render_display_list(r"\sqrt{x}")
+/// svg = ratex_py.render_svg_from_display_list(dl)
+/// png = ratex_py.render_png_from_display_list(dl)
+///
+/// Validation::
+///
+/// ratex_py.check(r"\frac{1}{2}") # raises on error
+/// v = ratex_py.display_list_version() # returns 1
+#[pymodule]
+fn ratex_py(m: &Bound<'_, PyModule>) -> PyResult<()> {
+ m.add_function(wrap_pyfunction!(check, m)?)?;
+ m.add_function(wrap_pyfunction!(display_list_version, m)?)?;
+ m.add_function(wrap_pyfunction!(render_svg, m)?)?;
+ m.add_function(wrap_pyfunction!(render_svg_inline, m)?)?;
+ m.add_function(wrap_pyfunction!(render_png, m)?)?;
+ m.add_function(wrap_pyfunction!(render_pdf, m)?)?;
+ m.add_function(wrap_pyfunction!(render_display_list, m)?)?;
+ m.add_function(wrap_pyfunction!(parse_display_list, m)?)?;
+ m.add_function(wrap_pyfunction!(render_svg_from_display_list, m)?)?;
+ m.add_function(wrap_pyfunction!(render_png_from_display_list, m)?)?;
+ m.add_function(wrap_pyfunction!(render_pdf_from_display_list, m)?)?;
+ m.add_function(wrap_pyfunction!(render_svg_batch, m)?)?;
+ m.add_function(wrap_pyfunction!(render_png_batch, m)?)?;
+ m.add_function(wrap_pyfunction!(render_formats, m)?)?;
+ m.add_class::()?;
+ let expr = m.getattr("Expr")?;
+ m.add("Math", expr)?;
+ Ok(())
+}
+
+// ---------------------------------------------------------------------------
+// Tests (100% branch coverage)
+// ---------------------------------------------------------------------------
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ // Color parsing: all branches
+ #[test]
+ fn test_parse_color_black() {
+ assert_eq!(parse_color("black").unwrap(), Color::BLACK);
+ assert_eq!(parse_color("BLACK").unwrap(), Color::BLACK);
+ }
+
+ #[test]
+ fn test_parse_color_white() {
+ assert_eq!(parse_color("white").unwrap(), Color::WHITE);
+ assert_eq!(parse_color("WHITE").unwrap(), Color::WHITE);
+ }
+
+ #[test]
+ fn test_parse_color_transparent() {
+ let c = parse_color("transparent").unwrap();
+ assert_eq!(c, Color::new(0.0, 0.0, 0.0, 0.0));
+ }
+
+ #[test]
+ fn test_parse_color_hex6() {
+ let c = parse_color("#ff0000").unwrap();
+ assert!((c.r - 1.0).abs() < 0.01);
+ assert!(c.g < 0.01);
+ assert!(c.b < 0.01);
+ }
+
+ #[test]
+ fn test_parse_color_hex3() {
+ let c = parse_color("#f00").unwrap();
+ assert!((c.r - 1.0).abs() < 0.01);
+ assert!(c.g < 0.01);
+ assert!(c.b < 0.01);
+ }
+
+ #[test]
+ fn test_parse_color_hex_with_whitespace() {
+ let c = parse_color(" #000 ").unwrap();
+ assert_eq!(c, Color::new(0.0, 0.0, 0.0, 1.0));
+ }
+
+ #[test]
+ fn test_parse_color_invalid_hex_length() {
+ assert!(parse_color("#ff00").is_err());
+ }
+
+ #[test]
+ fn test_parse_color_invalid_hex_chars() {
+ assert!(parse_color("#gggggg").is_err());
+ }
+
+ #[test]
+ fn test_parse_color_invalid_format() {
+ assert!(parse_color("rgb(255,0,0)").is_err());
+ }
+
+ // Layout options: both branches (display vs text mode)
+ #[test]
+ fn test_make_layout_options_display() {
+ let opts = make_layout_options(true, "black").unwrap();
+ assert_eq!(opts.style, MathStyle::Display);
+ assert_eq!(opts.color, Color::BLACK);
+ }
+
+ #[test]
+ fn test_make_layout_options_text() {
+ let opts = make_layout_options(false, "white").unwrap();
+ assert_eq!(opts.style, MathStyle::Text);
+ assert_eq!(opts.color, Color::WHITE);
+ }
+
+ // Pipeline: success path
+ #[test]
+ fn test_pipeline_simple_fraction() {
+ let dl = pipeline(r"\frac{1}{2}", true, "black").unwrap();
+ assert!(dl.width > 0.0);
+ assert!(dl.height > 0.0);
+ assert!(dl.items.len() > 0);
+ }
+
+ #[test]
+ fn test_pipeline_display_vs_text_modes() {
+ let dl_display = pipeline(r"\sqrt{x}", true, "black").unwrap();
+ let dl_text = pipeline(r"\sqrt{x}", false, "black").unwrap();
+ // Both should succeed; sizes may differ
+ assert!(dl_display.width > 0.0);
+ assert!(dl_text.width > 0.0);
+ }
+
+ #[test]
+ fn test_pipeline_parse_error() {
+ // Unmatched \left (this should fail to parse)
+ let result = pipeline(r"\left(", true, "black");
+ assert!(result.is_err());
+ }
+
+ // Deserialize: success and error
+ #[test]
+ fn test_deserialize_display_list_valid() {
+ let json = r#"{"version":1,"width":1.0,"height":2.0,"depth":0.5,"items":[]}"#;
+ let dl = deserialize_display_list(json).unwrap();
+ assert_eq!(dl.width, 1.0);
+ assert_eq!(dl.height, 2.0);
+ assert_eq!(dl.depth, 0.5);
+ }
+
+ #[test]
+ fn test_deserialize_display_list_invalid_json() {
+ let result = deserialize_display_list(r#"{"invalid json"#);
+ assert!(result.is_err());
+ }
+
+ // Test the public functions (simple happy-path tests without Python::with_gil)
+ #[test]
+ fn test_display_list_version() {
+ assert_eq!(display_list_version(), 1);
+ }
+
+ #[test]
+ fn test_normalize_formats_single() {
+ let out = normalize_formats(&["svg".to_string()]).unwrap();
+ assert_eq!(out, vec!["svg".to_string()]);
+ }
+
+ #[test]
+ fn test_normalize_formats_dedup_and_casefold() {
+ let out = normalize_formats(&[
+ "SVG".to_string(),
+ "png".to_string(),
+ "svg".to_string(),
+ ])
+ .unwrap();
+ assert_eq!(out, vec!["svg".to_string(), "png".to_string()]);
+ }
+
+ #[test]
+ fn test_normalize_formats_empty() {
+ assert!(normalize_formats(&[]).is_err());
+ }
+
+ #[test]
+ fn test_normalize_formats_invalid() {
+ assert!(normalize_formats(&["jpeg".to_string()]).is_err());
+ }
+}
diff --git a/crates/ratex-py/tests/test_docutils_integration.py b/crates/ratex-py/tests/test_docutils_integration.py
new file mode 100644
index 00000000..460542be
--- /dev/null
+++ b/crates/ratex-py/tests/test_docutils_integration.py
@@ -0,0 +1,418 @@
+#!/usr/bin/env python3
+"""
+Integration tests for ratex-py with docutils and Sphinx.
+
+Tests math rendering integration with docutils-based document pipelines.
+Run: python tests/test_docutils_integration.py
+"""
+
+import sys
+import base64
+import io
+from pathlib import Path
+
+# Add ratex-py to path
+sys.path.insert(0, str(Path(__file__).parent.parent / "target" / "debug"))
+
+import ratex_py
+
+
+def test_math_role_inline():
+ """Test inline math role integration."""
+ print("Testing inline math role...")
+
+ latex = r"\sqrt{2}"
+ svg = ratex_py.render_svg_inline(latex, color="black")
+
+ assert isinstance(svg, str)
+ assert "