use std::fmt::Write;
use ratex_font::{get_char_metrics, FontId};
use ratex_layout::{layout, to_display_list, validate_layout, LayoutOptions};
use ratex_parser::parser::parse;
use ratex_svg::{render_to_svg, SvgOptions};
use ratex_types::color::Color;
use ratex_types::display_item::{DisplayItem, DisplayList};
use ratex_types::math_style::MathStyle;
use ratex_types::path_command::PathCommand;
use thiserror::Error;
use super::typewriter::TerminalFont;
pub const EM: f32 = 10.0;
pub const PADDING: f32 = EM * 0.1;
const SVG_END: &str = "</svg>";
const BREAK_AFTER: [char; 1] = [','];
const BREAK_TOLERANCE: f64 = 1e-6;
pub struct Typeset {
pub svg: String,
pub breaks: Vec<Break>,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Break {
pub end: f32,
pub start: f32,
}
impl Break {
pub fn across(self, width: f32) -> Self {
Self {
end: self.end * width,
start: self.start * width,
}
}
}
#[derive(Debug, Error)]
#[error("RaTeX cannot set it: {0}")]
pub struct Unset(String);
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct Rgb(pub [u8; 3]);
#[cfg(test)]
pub fn svg(tex: &str, Rgb([r, g, b]): Rgb) -> Result<String, Unset> {
svg_colored(tex, tex, Rgb([r, g, b])).map(|typeset| typeset.svg)
}
pub fn svg_colored(tex: &str, painted: &str, colour: Rgb) -> Result<Typeset, Unset> {
let mut original = display(tex, colour)?;
if tex != painted {
if let Ok(paint) = display(painted, colour) {
if original.items.len() == paint.items.len()
&& original
.items
.iter()
.zip(&paint.items)
.all(|(original, painted)| compatible(original, painted))
{
for (original, mut painted) in original.items.iter_mut().zip(paint.items) {
*item_color(original) = *item_color(&mut painted);
}
}
}
}
let svg = SvgOptions {
font_size: f64::from(EM),
padding: f64::from(PADDING),
stroke_width: 0.0,
embed_glyphs: true,
font_dir: String::new(),
};
let breaks = breaks(&original);
let typewriter = TerminalFont::get().map(|font| typewriter_paths(&mut original, font));
let mut svg = render_to_svg(&original, &svg);
if let Some(paths) = typewriter.filter(|paths| !paths.is_empty()) {
let end = svg.rfind(SVG_END).unwrap_or(svg.len());
svg.insert_str(end, &paths);
}
Ok(Typeset { svg, breaks })
}
fn breaks(list: &DisplayList) -> Vec<Break> {
let separator_font = FontId::MainRegular.as_str();
let extent = |item: &DisplayItem| match item {
DisplayItem::GlyphPath { x, .. } => (*x, *x),
DisplayItem::Line { x, width, .. } | DisplayItem::Rect { x, width, .. } => (*x, x + width),
DisplayItem::Path { x, commands, .. } => commands
.iter()
.filter_map(|command| match command {
PathCommand::MoveTo { x, .. }
| PathCommand::LineTo { x, .. }
| PathCommand::CubicTo { x, .. }
| PathCommand::QuadTo { x, .. } => Some(*x),
PathCommand::Close => None,
})
.fold(
(f64::INFINITY, f64::NEG_INFINITY),
|(left, right), offset| (left.min(x + offset), right.max(x + offset)),
),
};
let extents = list.items.iter().map(extent).collect::<Vec<_>>();
let em = f64::from(EM);
let padding = f64::from(PADDING);
let width = list.width * em + 2.0 * padding;
let mut breaks = list
.items
.iter()
.filter_map(|item| {
let DisplayItem::GlyphPath {
x,
scale,
font,
char_code,
..
} = item
else {
return None;
};
let separator = char::from_u32(*char_code).is_some_and(|c| BREAK_AFTER.contains(&c));
if !separator || font != separator_font {
return None;
}
let end = x + get_char_metrics(FontId::MainRegular, *char_code)?.width * scale;
let start = extents
.iter()
.map(|(left, _)| *left)
.filter(|left| *left >= end - BREAK_TOLERANCE)
.fold(f64::INFINITY, f64::min);
let reached_across = extents
.iter()
.any(|(left, right)| *left < end - BREAK_TOLERANCE && *right > start);
(start.is_finite() && !reached_across).then(|| Break {
end: ((end * em + 2.0 * padding) / width) as f32,
start: (start * em / width) as f32,
})
})
.collect::<Vec<_>>();
breaks.sort_by(|a, b| a.end.total_cmp(&b.end));
breaks
}
fn typewriter_paths(list: &mut DisplayList, font: &TerminalFont) -> String {
let typewriter = FontId::TypewriterRegular.as_str();
let em = f64::from(EM);
let padding = f64::from(PADDING);
let mut paths = String::new();
list.items.retain(|item| {
let DisplayItem::GlyphPath {
x,
y,
scale,
font: face,
char_code,
color,
} = item
else {
return true;
};
let outline = (face == typewriter)
.then(|| {
let character = char::from_u32(*char_code)?;
let advance = get_char_metrics(FontId::TypewriterRegular, *char_code)?.width;
font.outline(
character,
x * em + padding,
y * em + padding,
scale * em,
advance,
)
})
.flatten();
let Some(outline) = outline else {
return true;
};
let channel = |value: f32| (value.clamp(0.0, 1.0) * f32::from(u8::MAX)).round();
let _ = write!(
paths,
r#"<path d="{outline}" fill="rgb({},{},{})" fill-opacity="{}"/>"#,
channel(color.r),
channel(color.g),
channel(color.b),
color.a,
);
false
});
paths
}
fn display(tex: &str, Rgb([r, g, b]): Rgb) -> Result<DisplayList, Unset> {
let channel = |value: u8| f32::from(value) / f32::from(u8::MAX);
let ast = parse(tex).map_err(|error| Unset(error.to_string()))?;
let options = LayoutOptions::default()
.with_style(MathStyle::Display)
.with_color(Color::rgb(channel(r), channel(g), channel(b)));
let laid = layout(&ast, &options);
validate_layout(&laid).map_err(Unset)?;
Ok(to_display_list(&laid))
}
fn compatible(original: &DisplayItem, painted: &DisplayItem) -> bool {
match (original, painted) {
(
DisplayItem::GlyphPath {
font: a,
char_code: ac,
..
},
DisplayItem::GlyphPath {
font: b,
char_code: bc,
..
},
) => a == b && ac == bc,
(DisplayItem::Line { .. }, DisplayItem::Line { .. })
| (DisplayItem::Rect { .. }, DisplayItem::Rect { .. })
| (DisplayItem::Path { .. }, DisplayItem::Path { .. }) => true,
_ => false,
}
}
fn item_color(item: &mut DisplayItem) -> &mut Color {
match item {
DisplayItem::GlyphPath { color, .. }
| DisplayItem::Line { color, .. }
| DisplayItem::Rect { color, .. }
| DisplayItem::Path { color, .. } => color,
}
}
#[cfg(test)]
mod tests {
use super::*;
const INK: Rgb = Rgb([0x12, 0x34, 0x56]);
#[test]
fn ratex_sets_what_macaulay2_writes() {
for tex in [
r"\frac{{\mathbb Z}}{101}\mathopen{}\left[a\,{.}{.}\,d\right]",
r"\texttt{ideal}{}\left(x^{2},\,y\,z,\,z^{3}-x\right)",
r"\left(\!\begin{array}{cc} x&y^{2}\\ \frac{1}{2}&z \end{array}\!\right)",
r"\underset{\vphantom{\Bigg|}0}{R^{1}}\,\xleftarrow{\scriptsize \begin{bmatrix} x&y&z\end{bmatrix}}\,\underset{\vphantom{\Bigg|}1}{R^{3}}",
] {
let svg = svg(tex, INK).unwrap();
assert!(svg.starts_with("<svg"), "{tex}");
}
}
#[test]
fn theme_colors_preserve_every_glyph_and_its_geometry() {
let groups = crate::ui::controller::settings::theme::groups::Groups::default();
let colors = regex::Regex::new(r"rgba?\([^)]*\)").unwrap();
for tex in [
r"x+2=y",
r"x^{2}+\frac{1}{3}",
r"\mathbb{Q}\left[x,y\right]",
r"\left(\begin{matrix}x&2\\3&y\end{matrix}\right)",
r"\texttt{MdCode}\left\{\texttt{ring R}\right\},\,\texttt{ -- get the ring}",
] {
let painted = crate::ui::render::maths::colored_tex(tex, &groups);
let original = svg(tex, INK).unwrap();
let colored = svg_colored(tex, &painted, INK).unwrap().svg;
assert_ne!(
original, colored,
"colours were not transferred: {tex}: {painted}"
);
assert_eq!(
colors.replace_all(&original, "PAINT"),
colors.replace_all(&colored, "PAINT"),
"{tex}"
);
}
}
#[test]
fn ratex_paints_in_the_colour_asked() {
let svg = svg("x", INK).unwrap();
assert!(svg.contains("rgba(18,52,86"));
}
#[test]
fn typewriter_glyphs_are_drawn_from_another_font_in_their_cells() {
let Some(font) = TerminalFont::load(fontdb::Family::Monospace) else {
return;
};
let mut list = display(r"\texttt{ideal}\,x", INK).unwrap();
let glyphs = list.items.len();
let paths = typewriter_paths(&mut list, &font);
assert_eq!(paths.matches("<path").count(), "ideal".len());
assert_eq!(list.items.len(), glyphs - "ideal".len(), "x stays KaTeX's");
assert!(paths.contains("fill=\"rgb(18,52,86)\""), "{paths}");
}
}