1use ratex_layout::{LayoutOptions, layout, to_display_list};
23use ratex_parser::parse;
24use ratex_svg::{SvgColorSyntax, SvgOptions, render_to_svg_with_color_syntax};
25use ratex_types::color::Color;
26use ratex_types::math_style::MathStyle;
27use std::fmt;
28
29#[derive(Clone, Debug, PartialEq)]
31pub struct MathPicture {
32 pub svg: String,
37 pub width: f64,
39 pub height: f64,
43 pub depth: f64,
45}
46
47impl MathPicture {
48 pub fn px_width(&self, size: f64) -> f64 {
50 self.width * size
51 }
52
53 pub fn px_height(&self, size: f64) -> f64 {
56 (self.height + self.depth) * size
57 }
58}
59
60#[derive(Clone, Debug, PartialEq, Eq)]
65pub struct MathError {
66 pub message: String,
67 pub position: Option<usize>,
68}
69
70impl fmt::Display for MathError {
71 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
72 match self.position {
73 Some(at) => write!(f, "{} (at byte {at})", self.message),
74 None => f.write_str(&self.message),
75 }
76 }
77}
78
79impl std::error::Error for MathError {}
80
81pub fn typeset(
94 tex: &str,
95 display: bool,
96 size: f64,
97 color: [u8; 4],
98) -> Result<MathPicture, MathError> {
99 let ast = parse(tex.trim()).map_err(|e| MathError {
100 message: e.message,
101 position: e.loc.map(|l| l.start),
102 })?;
103 let style = if display {
104 MathStyle::Display
105 } else {
106 MathStyle::Text
107 };
108 let [r, g, b, a] = color.map(|c| f32::from(c) / 255.0);
109 let opts = LayoutOptions::default()
110 .with_style(style)
111 .with_color(Color::new(r, g, b, a));
112 let list = to_display_list(&layout(&ast, &opts));
113 let svg = render_to_svg_with_color_syntax(
114 &list,
115 &SvgOptions {
116 font_size: size,
117 padding: 0.0,
118 stroke_width: (size / 16.0).max(0.5),
119 embed_glyphs: true,
120 font_dir: String::new(),
121 },
122 SvgColorSyntax::Rgb,
126 );
127 Ok(MathPicture {
128 svg: in_pixels(svg),
129 width: list.width,
130 height: list.height,
131 depth: list.depth,
132 })
133}
134
135fn in_pixels(svg: String) -> String {
141 let Some(head_end) = svg.find('>') else {
142 return svg;
143 };
144 let (head, rest) = svg.split_at(head_end);
145 let mut head = head.to_string();
146 for attr in ["width=\"", "height=\""] {
147 if let Some(i) = head.find(attr) {
148 let v = i + attr.len();
149 if let Some(q) = head[v..].find('"') {
150 let value = head[v..v + q].trim_end_matches("pt").to_string();
151 head.replace_range(v..v + q, &value);
152 }
153 }
154 }
155 head.push_str(rest);
156 head
157}
158
159#[cfg(test)]
160mod tests {
161 use super::*;
162
163 const BLACK: [u8; 4] = [0, 0, 0, 255];
164
165 #[test]
166 fn an_inline_formula_is_a_standalone_svg_with_metrics() {
167 let p = typeset("E = mc^2", false, 16.0, BLACK).unwrap();
168 assert!(
169 p.svg
170 .starts_with("<svg xmlns=\"http://www.w3.org/2000/svg\"")
171 );
172 assert!(p.svg.ends_with("</svg>"));
173 assert!(p.svg.contains("<path"), "{}", p.svg);
175 assert!(!p.svg.contains("<text"), "{}", p.svg);
176 assert!(!p.svg.contains("font-family"), "{}", p.svg);
177 assert!(p.width > 3.0 && p.width < 5.0, "{}", p.width);
179 assert!(p.height > 0.6 && p.height < 1.0, "{}", p.height);
180 assert_eq!(p.depth, 0.0);
181 }
182
183 #[test]
184 fn a_display_formula_has_a_depth_and_a_display_style() {
185 let d = typeset(r"\sum_{i=0}^n i", true, 16.0, BLACK).unwrap();
186 let t = typeset(r"\sum_{i=0}^n i", false, 16.0, BLACK).unwrap();
187 assert!(d.height + d.depth > t.height + t.depth, "{d:?} vs {t:?}");
190 assert!(d.width < t.width, "{d:?} vs {t:?}");
191 assert!(d.depth > 0.0);
192 }
193
194 #[test]
195 fn the_root_is_sized_in_pixels_at_the_requested_size() {
196 let p = typeset("x", false, 20.0, BLACK).unwrap();
197 let head = &p.svg[..p.svg.find('>').unwrap()];
199 assert!(!head.contains("pt"), "{head}");
200 let attr = |name: &str| -> f64 {
202 head.split(&format!("{name}=\""))
203 .nth(1)
204 .unwrap()
205 .split('"')
206 .next()
207 .unwrap()
208 .parse()
209 .unwrap()
210 };
211 assert!((attr("width") - p.px_width(20.0)).abs() < 1e-3);
212 assert!((attr("height") - p.px_height(20.0)).abs() < 1e-3);
213 }
214
215 #[test]
216 fn colour_is_the_ink() {
217 let p = typeset("x", false, 16.0, [255, 0, 0, 255]).unwrap();
218 assert!(p.svg.contains("fill=\"rgb(255,0,0)\""), "{}", p.svg);
219 assert!(!p.svg.contains("rgba("), "{}", p.svg);
220 let p = typeset("x", false, 16.0, [0, 0, 255, 128]).unwrap();
221 assert!(
222 p.svg.contains("fill=\"rgb(0,0,255)\" fill-opacity=\"0.50"),
223 "{}",
224 p.svg
225 );
226 }
227
228 #[test]
229 fn surrounding_whitespace_is_insignificant() {
230 let a = typeset("\n x + y \n", false, 16.0, BLACK).unwrap();
231 let b = typeset("x + y", false, 16.0, BLACK).unwrap();
232 assert_eq!(a, b);
233 }
234
235 #[test]
236 fn unreadable_tex_is_an_error_that_names_a_byte() {
237 let e = typeset(r"\frac{a", false, 16.0, BLACK).unwrap_err();
238 assert!(!e.message.is_empty());
239 assert!(e.position.is_some(), "{e}");
240 assert!(e.to_string().contains("at byte"), "{e}");
241 }
242
243 #[test]
244 fn an_empty_formula_is_an_empty_picture_not_an_error() {
245 let p = typeset("", false, 16.0, BLACK).unwrap();
246 assert_eq!(p.width, 0.0);
247 assert!(p.svg.starts_with("<svg"));
248 }
249}