use std::panic::{catch_unwind, AssertUnwindSafe};
use crate::image::{rasterize_svg, LoadedImage, SvgScaleMode, SvgSizing};
use super::common::{panic_message, DiagramError};
const MAX_LATEX_SOURCE_BYTES: usize = 64 * 1024;
const LATEX_EM_TO_CELL: f64 = 1.25 / (96.0 / 72.0 * 1.25);
const LATEX_PADDING_EMS: f64 = 0.3;
pub fn resolve_latex(
url: String,
source: &str,
max_cells: Option<(u16, u16)>,
font_size: Option<(u16, u16)>,
fg: [u8; 4],
bg: [u8; 4],
) -> Result<LoadedImage, DiagramError> {
let svg = render_latex_svg(source, fg, font_size)?;
let image = rasterize_svg(
&svg,
SvgSizing {
envelope: max_cells,
font_size,
mode: SvgScaleMode::Natural,
},
None, )
.map_err(DiagramError::from)?;
Ok(LoadedImage {
url,
image: fit_latex_to_cell_grid(image, font_size, bg),
scratch: None,
sliced: None,
direct: None,
})
}
fn fit_latex_to_cell_grid(
image: image::DynamicImage,
font_size: Option<(u16, u16)>,
bg: [u8; 4],
) -> image::DynamicImage {
let image = add_formula_breathing_room(image, font_size);
let image = flatten_to_background(image, bg);
center_on_cell_grid(image, font_size, bg)
}
fn center_on_cell_grid(
image: image::DynamicImage,
font_size: Option<(u16, u16)>,
bg: [u8; 4],
) -> image::DynamicImage {
use image::{GenericImageView, ImageBuffer, Rgba};
let cell_h = u32::from(font_size.map_or(16, |(_, h)| h.max(1)));
let (w, h) = image.dimensions();
let rows = h.div_ceil(cell_h).max(1);
let total = rows * cell_h;
if total <= h {
return image;
}
let extra = total - h;
let top = extra / 2;
let mut canvas = ImageBuffer::from_pixel(w, total, Rgba([bg[0], bg[1], bg[2], 255]));
image::imageops::overlay(&mut canvas, &image, 0, i64::from(top));
image::DynamicImage::ImageRgba8(canvas)
}
fn flatten_to_background(image: image::DynamicImage, bg: [u8; 4]) -> image::DynamicImage {
use image::GenericImageView;
let [br, bg_, bb, ba] = bg;
let (w, h) = image.dimensions();
let mut out = image::ImageBuffer::new(w, h);
let src = image.to_rgba8();
for (x, y, px) in src.enumerate_pixels() {
let [r, g, b, a] = px.0;
let alpha = f32::from(a) / 255.0;
let ba_ = f32::from(ba) / 255.0;
let mix = |s: u8, d: u8| {
(f32::from(s) * alpha + f32::from(d) * ba_ * (1.0 - alpha)).round() as u8
};
out.put_pixel(
x,
y,
image::Rgba([mix(r, br), mix(g, bg_), mix(b, bb), 255]),
);
}
image::DynamicImage::ImageRgba8(out)
}
fn add_formula_breathing_room(
image: image::DynamicImage,
font_size: Option<(u16, u16)>,
) -> image::DynamicImage {
use image::imageops::overlay;
use image::{GenericImageView, ImageBuffer, Rgba};
let cell_h = u32::from(font_size.map_or(16, |(_, h)| h.max(1)));
let pad = (cell_h / 10).clamp(1, 6);
let (w, h) = image.dimensions();
let mut canvas = ImageBuffer::from_pixel(w, h + 2 * pad, Rgba([0, 0, 0, 0]));
overlay(&mut canvas, &image.to_rgba8(), 0, i64::from(pad));
image::DynamicImage::ImageRgba8(canvas)
}
pub fn render_latex_svg(
source: &str,
fg: [u8; 4],
font_size: Option<(u16, u16)>,
) -> Result<String, DiagramError> {
if source.len() > MAX_LATEX_SOURCE_BYTES {
return Err(DiagramError::RenderFailed(format!(
"latex source too large: {} bytes (max {MAX_LATEX_SOURCE_BYTES})",
source.len()
)));
}
let outcome = {
let _expected = crate::terminal::ExpectedPanic::new();
catch_unwind(AssertUnwindSafe(|| {
render_latex_svg_inner(source, fg, font_size)
}))
}
.map_err(|payload| {
DiagramError::RenderFailed(format!("latex render panic: {}", panic_message(&payload)))
})?;
outcome.map_err(|e| DiagramError::RenderFailed(format!("{e:#}")))
}
fn render_latex_svg_inner(
source: &str,
fg: [u8; 4],
font_size: Option<(u16, u16)>,
) -> Result<String, ratex_parser::error::ParseError> {
use ratex_layout::layout_options::LayoutOptions;
use ratex_layout::{layout, to_display_list};
use ratex_parser::parse;
use ratex_svg::{render_to_svg, SvgOptions};
use ratex_types::color::Color;
let ast = parse(source)?;
let [r, g, b, a] = fg;
let options = LayoutOptions::default().with_color(Color::new(
f32::from(r) / 255.0,
f32::from(g) / 255.0,
f32::from(b) / 255.0,
f32::from(a) / 255.0,
));
let lbox = layout(&ast, &options);
let display_list = to_display_list(&lbox);
let em = font_size.map_or(16.0, |(_, h)| f64::from(h.max(1))) * LATEX_EM_TO_CELL;
let svg = render_to_svg(
&display_list,
&SvgOptions {
embed_glyphs: false,
font_size: em,
padding: em * LATEX_PADDING_EMS,
..SvgOptions::default()
},
);
Ok(svg)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn latex_display_math_renders_to_loaded_image() {
let loaded = resolve_latex(
"test".into(),
r"x^2 + y^2 = z^2",
Some((80, 24)),
Some((8, 16)),
[0xcc, 0xcc, 0xcc, 255],
[0x1a, 0x1a, 0x1a, 255],
)
.expect("trivial display math should render");
assert!(loaded.image.width() > 0);
assert!(loaded.image.height() > 0);
}
#[test]
fn latex_image_height_tracks_cell_font_size() {
let loaded = resolve_latex(
"test".into(),
r"x^2 + y^2 = z^2",
Some((80, 40)),
Some((8, 16)),
[0xcc, 0xcc, 0xcc, 255],
[0x1a, 0x1a, 0x1a, 255],
)
.expect("display math should render");
let h = loaded.image.height();
assert!(
(16..=64).contains(&h),
"single-line formula should be a few cells tall, got {h}px"
);
}
#[test]
fn breathing_room_pads_transparent_rows_above_and_below() {
use image::GenericImageView;
use image::Rgba;
let img = image::DynamicImage::ImageRgba8(image::ImageBuffer::from_pixel(
4,
8,
Rgba([200, 0, 0, 255]),
));
let padded = add_formula_breathing_room(img, Some((8, 30)));
assert_eq!(padded.dimensions(), (4, 8 + 2 * 3));
let rgba = padded.to_rgba8();
assert_eq!(rgba.get_pixel(0, 0).0[3], 0);
assert_eq!(rgba.get_pixel(0, 2).0[3], 0);
assert_eq!(rgba.get_pixel(0, 3).0, [200, 0, 0, 255]);
assert_eq!(rgba.get_pixel(0, 8 + 3).0[3], 0); assert_eq!(rgba.get_pixel(0, 8 + 2 * 3 - 1).0[3], 0); assert_eq!(rgba.get_pixel(0, 8 + 3 - 1).0, [200, 0, 0, 255]); }
#[test]
fn breathing_room_falls_back_to_a_default_cell_height() {
let img = image::DynamicImage::new_rgba8(2, 2);
let padded = add_formula_breathing_room(img, None);
assert_eq!(padded.height(), 2 + 2);
}
#[test]
fn flatten_composites_transparency_onto_the_document_background() {
use image::{GenericImageView, Rgba};
let img = image::DynamicImage::ImageRgba8(image::ImageBuffer::from_fn(2, 1, |x, _| {
if x == 0 {
Rgba([0, 0, 0, 0])
} else {
Rgba([200, 0, 0, 255])
}
}));
let out = flatten_to_background(img, [10, 20, 30, 255]);
let rgba = out.to_rgba8();
assert_eq!(
rgba.get_pixel(0, 0).0,
[10, 20, 30, 255],
"transparent → bg"
);
assert_eq!(
rgba.get_pixel(1, 0).0,
[200, 0, 0, 255],
"opaque content kept"
);
assert_eq!(rgba.get_pixel(0, 0).0[3], 255, "output fully opaque");
assert_eq!(out.dimensions(), (2, 1), "dimensions unchanged");
}
#[test]
fn cell_grid_centring_splits_rounding_slack_evenly() {
use image::{GenericImageView, Rgba};
let img = image::DynamicImage::ImageRgba8(image::ImageBuffer::from_pixel(
2,
10,
Rgba([200, 0, 0, 255]),
));
let out = center_on_cell_grid(img, Some((8, 30)), [10, 20, 30, 255]);
assert_eq!(out.dimensions(), (2, 30));
let rgba = out.to_rgba8();
assert_eq!(rgba.get_pixel(0, 0).0, [10, 20, 30, 255], "top pad = bg");
assert_eq!(rgba.get_pixel(0, 9).0, [10, 20, 30, 255], "top half slack");
assert_eq!(
rgba.get_pixel(0, 10).0,
[200, 0, 0, 255],
"content starts at 10"
);
assert_eq!(
rgba.get_pixel(0, 19).0,
[200, 0, 0, 255],
"content ends at 19"
);
assert_eq!(
rgba.get_pixel(0, 20).0,
[10, 20, 30, 255],
"bottom pad = bg"
);
assert_eq!(
rgba.get_pixel(0, 29).0,
[10, 20, 30, 255],
"bottom half slack"
);
}
#[test]
fn cell_grid_centring_is_a_noop_on_exact_multiples() {
let img = image::DynamicImage::new_rgba8(4, 60); let out = center_on_cell_grid(img, Some((8, 30)), [0, 0, 0, 255]);
assert_eq!(out.height(), 60);
}
}