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 " 100 + print(f" ✓ Data URI created: {data_uri[:80]}...") + + +def test_math_directive_svg(): + """Test block math directive with SVG output.""" + print("Testing block math directive (SVG)...") + + latex = r"\frac{-b \pm \sqrt{b^2 - 4ac}}{2a}" + svg = ratex_py.render_svg(latex, font_size=32.0, display_mode=True) + + assert isinstance(svg, str) + assert " len(png_1x) + + print(f" ✓ 1x: {len(png_1x)} bytes, 2x: {len(png_2x)} bytes") + + +def test_sphinx_workflow(): + """Simulate a Sphinx document build workflow.""" + print("Testing Sphinx workflow...") + + # Simulate Sphinx rendering different math in a document + document = { + "title": "Linear Algebra", + "sections": [ + { + "title": "Vectors", + "math": [ + r"\vec{v} = (x, y, z)", + r"|\vec{v}| = \sqrt{x^2 + y^2 + z^2}", + ], + }, + { + "title": "Matrices", + "math": [ + r"A = \begin{pmatrix} a & b \\ c & d \end{pmatrix}", + ], + }, + ], + } + + # Collect all formulas + all_formulas = [] + for section in document["sections"]: + all_formulas.extend(section["math"]) + + # Parse all formulas once (per-document cache) + display_lists = {} + for formula in all_formulas: + display_lists[formula] = ratex_py.render_display_list(formula) + + # Render for HTML + html_images = [] + for formula in all_formulas: + dl = display_lists[formula] + svg = ratex_py.render_svg_from_display_list(dl, embed_glyphs=True) + html_images.append(svg) + assert "{e}" + return self.render_cache[text] + + def render_directive(self, name, arguments, options, content, lineno): + """Render .. math:: directive.""" + latex = '\n'.join(content) + try: + svg = ratex_py.render_svg(latex, display_mode=True) + return svg + except ValueError as e: + return f"{e}" + + def stats(self): + """Extension statistics.""" + return {"cached_renders": len(self.render_cache)} + + # Create extension + ext = MathExtension(config={}) + + # Test role rendering + svg1 = ext.render_role("math", "", r"\alpha", 1) + svg2 = ext.render_role("math", "", r"\beta", 2) + svg3 = ext.render_role("math", "", r"\alpha", 3) # Same as first + + assert "=1.7,<2.0"] +build-backend = "maturin" + +[project] +name = "ratex-py" +version = "0.1.9" +description = "Python bindings for RaTeX — KaTeX-compatible math rendering in pure Rust" +requires-python = ">=3.9" +license = { text = "MIT" } +readme = "README.md" +# authors = "" # TODO +# classifiers = [] # TODO +# urls = [] # TODO + +[tool.maturin] +module-name = "ratex_py" +manifest-path = "crates/ratex-py/Cargo.toml" +features = ["extension-module", "embed-fonts"]