use ratex_layout::{layout, to_display_list, LayoutOptions};
use ratex_parser::parse;
use ratex_svg::{render_to_svg, SvgOptions};
use ratex_types::math_style::MathStyle;
use resvg::usvg;
const CANONICAL_UNITS_PER_EM: f64 = 40.0;
const LINE_HEIGHT_EM: f64 = 1.2;
const INK_PADDING_CANONICAL: f64 = 1.0;
pub fn latex_to_svg(latex: &str, display: bool, color: &str) -> Option<String> {
let svg = render_raw(latex, display, color)?;
let svg = if display { svg } else { normalize_box(svg) };
svg.contains("<svg").then_some(svg)
}
fn render_raw(latex: &str, display: bool, color: &str) -> Option<String> {
let latex = latex.trim();
if latex.is_empty() {
return None;
}
let nodes = parse(latex).ok()?;
if nodes.is_empty() {
return None;
}
let opts = LayoutOptions {
style: if display {
MathStyle::Display
} else {
MathStyle::Text
},
..Default::default()
};
let root = layout(&nodes, &opts);
let list = to_display_list(&root);
let svg = render_to_svg(
&list,
&SvgOptions {
embed_glyphs: true,
..Default::default()
},
);
Some(recolor(svg, color))
}
fn recolor(svg: String, color: &str) -> String {
svg.replace("rgba(0,0,0,1)", color)
}
fn normalize_box(svg: String) -> String {
let Some((declared_w, declared_h)) = parse_declared_size(&svg) else {
return svg;
};
if !(declared_w > 0.0 && declared_h > 0.0) {
return svg;
}
let opt = usvg::Options {
fontdb: crate::preview::svg::shared_fontdb(),
..Default::default()
};
let Ok(tree) = usvg::Tree::from_str(&svg, &opt) else {
return svg;
};
let size = tree.size();
let (canvas_w, canvas_h) = (size.width() as f64, size.height() as f64);
if !(canvas_w.is_finite() && canvas_h.is_finite() && canvas_w > 0.0 && canvas_h > 0.0) {
return svg;
}
let k = ((canvas_w / declared_w) + (canvas_h / declared_h)) / 2.0;
if !(k.is_finite() && k > 0.0) {
return svg;
}
let bbox = tree.root().abs_stroke_bounding_box();
let (ink_x0_c, ink_y0_c, ink_w_c, ink_h_c) = (
bbox.x() as f64,
bbox.y() as f64,
bbox.width() as f64,
bbox.height() as f64,
);
if !(ink_w_c.is_finite() && ink_h_c.is_finite() && ink_w_c > 0.0 && ink_h_c > 0.0) {
return svg;
}
let line_height_c = LINE_HEIGHT_EM * CANONICAL_UNITS_PER_EM;
let new_h_c = (ink_h_c + 2.0 * INK_PADDING_CANONICAL).max(line_height_c);
let vpad_c = (new_h_c - ink_h_c) / 2.0;
let new_w_c = ink_w_c + 2.0 * INK_PADDING_CANONICAL;
let hpad_c = INK_PADDING_CANONICAL;
let new_x0 = (ink_x0_c - hpad_c) / k;
let new_y0 = (ink_y0_c - vpad_c) / k;
let new_w = new_w_c / k;
let new_h = new_h_c / k;
if !(new_w.is_finite() && new_h.is_finite() && new_w > 0.0 && new_h > 0.0) {
return svg;
}
rewrite_svg_box(&svg, new_x0, new_y0, new_w, new_h).unwrap_or(svg)
}
fn parse_declared_size(svg: &str) -> Option<(f64, f64)> {
let w = parse_quoted_number(svg, "width=\"", "pt\"")?;
let h = parse_quoted_number(svg, "height=\"", "pt\"")?;
Some((w, h))
}
fn parse_quoted_number(svg: &str, prefix: &str, suffix: &str) -> Option<f64> {
let start = svg.find(prefix)? + prefix.len();
let rest = &svg[start..];
let end = rest.find(suffix)?;
rest[..end].parse::<f64>().ok()
}
fn rewrite_svg_box(svg: &str, x0: f64, y0: f64, w: f64, h: f64) -> Option<String> {
let svg = replace_quoted(
svg,
"viewBox=\"",
"\"",
&format!("{x0:.4} {y0:.4} {w:.4} {h:.4}"),
)?;
let svg = replace_quoted(&svg, "width=\"", "pt\"", &format!("{w:.4}"))?;
let svg = replace_quoted(&svg, "height=\"", "pt\"", &format!("{h:.4}"))?;
Some(svg)
}
fn replace_quoted(svg: &str, prefix: &str, suffix: &str, new_inner: &str) -> Option<String> {
let start = svg.find(prefix)? + prefix.len();
let rest = &svg[start..];
let end = rest.find(suffix)?;
let mut out = String::with_capacity(svg.len());
out.push_str(&svg[..start]);
out.push_str(new_inner);
out.push_str(&rest[end..]);
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
#[test]
fn renders_simple_math_to_rasterizable_svg() {
for (latex, display) in [("x^2 + y^2 = z^2", true), ("\\frac{a}{b}", false)] {
let svg = latex_to_svg(latex, display, "#d0d0d0").expect("RaTeX renders basic math");
assert!(svg.contains("<svg"), "has an <svg> root: {latex}");
assert!(
svg.contains("<path") || svg.contains("<image"),
"standalone glyphs (path/image), not webfont <text>: {latex}"
);
let img =
crate::preview::svg::rasterize_bytes(svg.as_bytes(), Path::new("m.svg"), 1600)
.expect("resvg rasterizes the standalone SVG");
assert!(
img.width() > 0 && img.height() > 0,
"non-empty raster: {latex}"
);
use image::GenericImageView;
let ink = img.pixels().filter(|(_, _, p)| p.0[3] > 16).count();
assert!(
ink > 50,
"equation raster has visible ink: {latex} (ink={ink})"
);
}
}
#[test]
fn empty_or_garbage_returns_none_not_panic() {
assert!(latex_to_svg("", true, "#d0d0d0").is_none());
assert!(latex_to_svg(" ", false, "#d0d0d0").is_none());
let _ = latex_to_svg("\\frac{", true, "#d0d0d0");
let _ = latex_to_svg("\\undefinedcmd{x}", false, "#d0d0d0");
}
#[test]
fn display_and_inline_styles_both_render() {
let d = latex_to_svg("\\sum_{i=0}^{n} i", true, "#d0d0d0").expect("display");
let i = latex_to_svg("\\sum_{i=0}^{n} i", false, "#d0d0d0").expect("inline");
assert!(d.contains("<svg") && i.contains("<svg"));
}
#[test]
fn recolors_glyphs_away_from_invisible_black() {
use image::GenericImageView;
let svg = latex_to_svg("E = mc^2", false, "#d0d0d0").expect("renders");
assert!(
!svg.contains("rgba(0,0,0,1)"),
"不可視の純黒フィルが残っていない"
);
assert!(svg.contains("#d0d0d0"), "指定色でグリフを塗る");
let img = crate::preview::svg::rasterize_bytes(svg.as_bytes(), Path::new("m.svg"), 1024)
.expect("rasterizes");
let opaque_non_black = img
.pixels()
.filter(|(_, _, p)| p.0[3] > 200 && (p.0[0] > 40 || p.0[1] > 40 || p.0[2] > 40))
.count();
assert!(
opaque_non_black > 50,
"端末背景に映える明色インクが実在する (non_black={opaque_non_black})"
);
}
fn ink_em_height(svg: &str) -> f64 {
let opt = usvg::Options {
fontdb: crate::preview::svg::shared_fontdb(),
..Default::default()
};
let tree = usvg::Tree::from_str(svg, &opt).expect("normalized SVG must still parse");
let bbox = tree.root().abs_stroke_bounding_box();
bbox.height() as f64 / CANONICAL_UNITS_PER_EM
}
fn box_em_height(svg: &str) -> f64 {
let (_, h) =
crate::preview::svg::intrinsic_size_bytes(svg.as_bytes()).expect("intrinsic size");
h as f64 / CANONICAL_UNITS_PER_EM
}
#[test]
fn small_ink_clamps_to_one_line_height_regardless_of_how_little_ink_there_is() {
for latex in ["x", "H_2O", "E = mc^2", "\\alpha"] {
let raw = render_raw(latex, false, "#d0d0d0").expect("renders");
let ink_em = ink_em_height(&raw);
assert!(
ink_em < LINE_HEIGHT_EM,
"test fixture assumption: {latex}'s ink ({ink_em:.3}em) must be under one line \
for this test to be exercising the clamp branch"
);
let normalized = latex_to_svg(latex, false, "#d0d0d0").expect("renders");
let box_em = box_em_height(&normalized);
assert!(
(LINE_HEIGHT_EM..LINE_HEIGHT_EM + 0.1).contains(&box_em),
"{latex}: box should sit at ~one line height ({LINE_HEIGHT_EM}em), not the ink's \
own ({ink_em:.3}em) or RaTeX's original padded box: got {box_em:.3}em"
);
}
}
#[test]
fn tall_ink_ties_the_box_to_itself_instead_of_ratexs_own_padding() {
for latex in ["\\frac{a}{b}", "\\sum_{i=0}^{n} i", "\\int_0^1 x dx"] {
let raw = render_raw(latex, false, "#d0d0d0").expect("renders");
let raw_box_em = box_em_height(&raw);
let ink_em = ink_em_height(&raw);
assert!(
ink_em > LINE_HEIGHT_EM,
"test fixture assumption: {latex}'s ink ({ink_em:.3}em) must exceed one line for \
this test to be exercising the ink-tied branch"
);
let normalized = latex_to_svg(latex, false, "#d0d0d0").expect("renders");
let box_em = box_em_height(&normalized);
assert!(
box_em > LINE_HEIGHT_EM,
"{latex}: box ({box_em:.3}em) should stay above one line — the content genuinely \
doesn't fit in 1.2em"
);
assert!(
(box_em - ink_em).abs() < 0.15,
"{latex}: box ({box_em:.3}em) should sit close to the ink's own height \
({ink_em:.3}em), not RaTeX's padded layout box"
);
assert!(
box_em < raw_box_em - 0.1,
"{latex}: normalized box ({box_em:.3}em) should be meaningfully tighter than \
RaTeX's own padded box ({raw_box_em:.3}em)"
);
}
}
#[test]
fn normalization_never_crops_the_ink() {
use image::GenericImageView;
for latex in [
"x",
"E = mc^2",
"H_2O",
"\\frac{a}{b}",
"\\sum_{i=0}^{n} i",
"\\int_0^1 x dx",
"\\sqrt{2}",
"\\alpha",
"\\begin{pmatrix}a&b\\\\c&d\\end{pmatrix}",
] {
let svg = latex_to_svg(latex, false, "#d0d0d0").expect("renders");
let img = crate::preview::svg::rasterize_bytes(svg.as_bytes(), Path::new("m.svg"), 800)
.expect("rasterizes");
let (w, h) = img.dimensions();
let mut min_x = w;
let mut min_y = h;
let mut max_x = 0i64;
let mut max_y = 0i64;
let mut any_ink = false;
for (x, y, p) in img.pixels() {
if p.0[3] > 16 {
any_ink = true;
min_x = min_x.min(x);
min_y = min_y.min(y);
max_x = max_x.max(x as i64);
max_y = max_y.max(y as i64);
}
}
assert!(any_ink, "{latex}: raster must carry visible ink");
assert!(
min_x > 0 && min_y > 0 && (max_x as u32) < w - 1 && (max_y as u32) < h - 1,
"{latex}: ink must not touch the raster's edge (clipped): \
bbox=({min_x},{min_y})-({max_x},{max_y}) of {w}x{h}"
);
}
}
#[test]
fn normalization_tightens_the_horizontal_margin_too() {
let raw = render_raw("x", false, "#d0d0d0").expect("renders");
let (raw_w, _) = crate::preview::svg::intrinsic_size_bytes(raw.as_bytes()).unwrap();
let normalized = latex_to_svg("x", false, "#d0d0d0").expect("renders");
let (norm_w, _) = crate::preview::svg::intrinsic_size_bytes(normalized.as_bytes()).unwrap();
assert!(
norm_w < raw_w,
"normalized width ({norm_w}) should be tighter than RaTeX's own ({raw_w})"
);
}
#[test]
fn display_math_box_is_left_exactly_as_ratex_laid_it_out() {
for latex in ["\\sum_{i=0}^{n} i", "x", "\\frac{a}{b}"] {
let raw = render_raw(latex, true, "#d0d0d0").expect("renders");
let normalized = latex_to_svg(latex, true, "#d0d0d0").expect("renders");
assert_eq!(
raw, normalized,
"{latex}: display math must not be touched by box normalization"
);
}
}
}