1pub mod error;
2
3pub use error::*;
4pub use graphitepdf_svg::{SvgNode, SvgNodeKind};
5
6use std::fmt;
7
8use graphitepdf_primitives::Color;
9use graphitepdf_svg::SvgProps;
10use mathjax_svg_rs::{HorizontalAlign, Options as BackendOptions, render_tex};
11
12const DEFAULT_HEIGHT: f32 = 22.0;
13
14#[derive(Clone, Debug, PartialEq)]
15pub enum MathDimension {
16 Number(f32),
17 Value(String),
18}
19
20impl MathDimension {
21 fn to_svg_value(&self) -> String {
22 match self {
23 Self::Number(value) => format_number(*value),
24 Self::Value(value) => value.trim().to_string(),
25 }
26 }
27
28 fn parse_numeric_value(&self) -> Result<(f32, String)> {
29 match self {
30 Self::Number(value) => Ok((*value, String::new())),
31 Self::Value(value) => parse_numeric_with_unit(value),
32 }
33 }
34}
35
36impl fmt::Display for MathDimension {
37 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
38 f.write_str(&self.to_svg_value())
39 }
40}
41
42impl From<f32> for MathDimension {
43 fn from(value: f32) -> Self {
44 Self::Number(value)
45 }
46}
47
48impl From<f64> for MathDimension {
49 fn from(value: f64) -> Self {
50 Self::Number(value as f32)
51 }
52}
53
54impl From<i32> for MathDimension {
55 fn from(value: i32) -> Self {
56 Self::Number(value as f32)
57 }
58}
59
60impl From<u32> for MathDimension {
61 fn from(value: u32) -> Self {
62 Self::Number(value as f32)
63 }
64}
65
66impl From<String> for MathDimension {
67 fn from(value: String) -> Self {
68 Self::Value(value)
69 }
70}
71
72impl From<&str> for MathDimension {
73 fn from(value: &str) -> Self {
74 Self::Value(value.to_string())
75 }
76}
77
78#[derive(Clone, Debug, PartialEq)]
79pub struct MathOptions {
80 pub inline: bool,
81 pub width: Option<MathDimension>,
82 pub height: Option<MathDimension>,
83 pub color: String,
84 pub debug: bool,
85}
86
87impl MathOptions {
88 pub fn new() -> Self {
89 Self::default()
90 }
91
92 pub fn inline(mut self, inline: bool) -> Self {
93 self.inline = inline;
94 self
95 }
96
97 pub fn width(mut self, width: impl Into<MathDimension>) -> Self {
98 self.width = Some(width.into());
99 self
100 }
101
102 pub fn height(mut self, height: impl Into<MathDimension>) -> Self {
103 self.height = Some(height.into());
104 self
105 }
106
107 pub fn color(mut self, color: impl Into<String>) -> Self {
108 self.color = color.into();
109 self
110 }
111
112 pub fn color_from_primitives(mut self, color: Color) -> Self {
113 self.color = format!(
114 "#{:02x}{:02x}{:02x}{:02x}",
115 color.red, color.green, color.blue, color.alpha
116 );
117 self
118 }
119
120 pub fn debug(mut self, debug: bool) -> Self {
121 self.debug = debug;
122 self
123 }
124}
125
126impl Default for MathOptions {
127 fn default() -> Self {
128 Self {
129 inline: false,
130 width: None,
131 height: None,
132 color: String::from("black"),
133 debug: false,
134 }
135 }
136}
137
138#[derive(Clone, Debug, PartialEq, Eq)]
139pub struct MathRender {
140 pub source: String,
141 pub raw_svg: String,
142 pub svg: SvgNode,
143}
144
145impl MathRender {
146 pub fn into_svg(self) -> SvgNode {
147 self.svg
148 }
149}
150
151pub fn render_math(latex: &str) -> Result<MathRender> {
152 render_math_with_options(latex, &MathOptions::default())
153}
154
155pub fn render_math_with_options(latex: &str, options: &MathOptions) -> Result<MathRender> {
156 let raw_svg = render_latex_to_svg(latex, options)?;
157 let mut svg = graphitepdf_svg::try_parse_svg(&raw_svg)?;
158
159 if svg.kind != SvgNodeKind::Svg {
160 return Err(Error::InvalidSvgRoot);
161 }
162
163 let (width, height) = resolve_dimensions(&svg.props, options)?;
164 svg.props.insert(String::from("width"), width);
165 svg.props.insert(String::from("height"), height);
166 svg.props
167 .insert(String::from("color"), options.color.clone());
168
169 if options.debug {
170 svg.props
171 .insert(String::from("debug"), String::from("true"));
172 }
173
174 resolve_current_color(&mut svg, &options.color);
175
176 Ok(MathRender {
177 source: latex.to_string(),
178 raw_svg,
179 svg,
180 })
181}
182
183fn render_latex_to_svg(latex: &str, options: &MathOptions) -> Result<String> {
184 let wrapped_latex = wrap_latex_for_mode(latex, options.inline);
185 let backend_options = BackendOptions {
186 horizontal_align: if options.inline {
187 HorizontalAlign::Left
188 } else {
189 HorizontalAlign::Center
190 },
191 ..BackendOptions::default()
192 };
193
194 render_tex(&wrapped_latex, &backend_options).map_err(Error::MathBackend)
195}
196
197fn wrap_latex_for_mode(latex: &str, inline: bool) -> String {
198 let style = if inline {
199 r"\textstyle"
200 } else {
201 r"\displaystyle"
202 };
203
204 format!("{{{style} {latex}}}")
205}
206
207fn resolve_dimensions(props: &SvgProps, options: &MathOptions) -> Result<(String, String)> {
208 let aspect_ratio = extract_aspect_ratio(props)?;
209
210 match (options.width.as_ref(), options.height.as_ref()) {
211 (Some(width), Some(height)) => Ok((width.to_svg_value(), height.to_svg_value())),
212 (Some(width), None) => {
213 let (width_value, suffix) = width.parse_numeric_value()?;
214 let height = width_value / aspect_ratio;
215 Ok((width.to_svg_value(), format_length(height, &suffix)))
216 }
217 (None, Some(height)) => {
218 let (height_value, suffix) = height.parse_numeric_value()?;
219 let width = height_value * aspect_ratio;
220 Ok((format_length(width, &suffix), height.to_svg_value()))
221 }
222 (None, None) => Ok((
223 format_number(DEFAULT_HEIGHT * aspect_ratio),
224 format_number(DEFAULT_HEIGHT),
225 )),
226 }
227}
228
229fn extract_aspect_ratio(props: &SvgProps) -> Result<f32> {
230 if let Some(view_box) = props.get("viewBox") {
231 let values: Vec<f32> = view_box
232 .split(|character: char| character.is_ascii_whitespace() || character == ',')
233 .filter(|part| !part.is_empty())
234 .filter_map(|part| part.parse::<f32>().ok())
235 .collect();
236
237 if values.len() == 4 && values[2].is_finite() && values[3].is_finite() && values[3] != 0.0 {
238 return Ok(values[2].abs() / values[3].abs());
239 }
240 }
241
242 if let (Some(width), Some(height)) = (props.get("width"), props.get("height")) {
243 let (width_value, _) = parse_numeric_with_unit(width)?;
244 let (height_value, _) = parse_numeric_with_unit(height)?;
245
246 if height_value != 0.0 {
247 return Ok(width_value.abs() / height_value.abs());
248 }
249 }
250
251 Err(Error::InvalidViewBox)
252}
253
254fn resolve_current_color(node: &mut SvgNode, color: &str) {
255 for value in node.props.values_mut() {
256 if value == "currentColor" {
257 *value = color.to_string();
258 }
259 }
260
261 for child in &mut node.children {
262 resolve_current_color(child, color);
263 }
264}
265
266fn parse_numeric_with_unit(input: &str) -> Result<(f32, String)> {
267 let trimmed = input.trim();
268 let mut end = 0usize;
269 let mut has_digit = false;
270 let mut has_decimal_point = false;
271
272 for (index, character) in trimmed.char_indices() {
273 let is_first = index == 0;
274 let is_sign = is_first && (character == '+' || character == '-');
275
276 if character.is_ascii_digit() {
277 has_digit = true;
278 end = index + character.len_utf8();
279 continue;
280 }
281
282 if character == '.' && !has_decimal_point {
283 has_decimal_point = true;
284 end = index + character.len_utf8();
285 continue;
286 }
287
288 if is_sign {
289 end = index + character.len_utf8();
290 continue;
291 }
292
293 break;
294 }
295
296 if !has_digit || end == 0 {
297 return Err(Error::InvalidDimension {
298 input: input.to_string(),
299 });
300 }
301
302 let (number, suffix) = trimmed.split_at(end);
303 let value = number.parse::<f32>().map_err(|_| Error::InvalidDimension {
304 input: input.to_string(),
305 })?;
306
307 Ok((value, suffix.trim().to_string()))
308}
309
310fn format_length(value: f32, suffix: &str) -> String {
311 format!("{}{}", format_number(value), suffix)
312}
313
314fn format_number(value: f32) -> String {
315 let rounded = (value * 1000.0).round() / 1000.0;
316 let mut rendered = format!("{rounded:.3}");
317
318 while rendered.contains('.') && rendered.ends_with('0') {
319 rendered.pop();
320 }
321
322 if rendered.ends_with('.') {
323 rendered.pop();
324 }
325
326 if rendered == "-0" {
327 String::from("0")
328 } else {
329 rendered
330 }
331}
332
333#[cfg(test)]
334mod tests {
335 use super::*;
336
337 fn parse_dimension(value: &str) -> f32 {
338 parse_numeric_with_unit(value)
339 .expect("dimension should be numeric")
340 .0
341 }
342
343 fn contains_current_color(node: &SvgNode) -> bool {
344 node.props.values().any(|value| value == "currentColor")
345 || node.children.iter().any(contains_current_color)
346 }
347
348 #[test]
349 fn renders_display_math_with_default_dimensions() {
350 let rendered =
351 render_math(r"\frac{-b \pm \sqrt{b^2 - 4ac}}{2a}").expect("display math should render");
352
353 assert_eq!(rendered.svg.kind, SvgNodeKind::Svg);
354 assert!(rendered.raw_svg.contains("<svg"));
355 assert_eq!(rendered.svg.props.get("height"), Some(&String::from("22")));
356 assert!(rendered.svg.props.contains_key("width"));
357 assert_eq!(
358 rendered.svg.props.get("color"),
359 Some(&String::from("black"))
360 );
361 assert!(!contains_current_color(&rendered.svg));
362 }
363
364 #[test]
365 fn supports_explicit_dimensions_color_and_debug() {
366 let rendered = render_math_with_options(
367 r"e^{i\pi} + 1 = 0",
368 &MathOptions::new()
369 .width("180px")
370 .height(40.0)
371 .color("rebeccapurple")
372 .debug(true),
373 )
374 .expect("math with explicit options should render");
375
376 assert_eq!(
377 rendered.svg.props.get("width"),
378 Some(&String::from("180px"))
379 );
380 assert_eq!(rendered.svg.props.get("height"), Some(&String::from("40")));
381 assert_eq!(
382 rendered.svg.props.get("color"),
383 Some(&String::from("rebeccapurple"))
384 );
385 assert_eq!(rendered.svg.props.get("debug"), Some(&String::from("true")));
386 assert!(!contains_current_color(&rendered.svg));
387 }
388
389 #[test]
390 fn derives_missing_dimension_from_view_box_aspect_ratio() {
391 let rendered = render_math_with_options(
392 r"\sum_{n=1}^{\infty} \frac{1}{n^2}",
393 &MathOptions::new().width(180.0),
394 )
395 .expect("math with one dimension should render");
396
397 let width = parse_dimension(
398 rendered
399 .svg
400 .props
401 .get("width")
402 .expect("width should be populated"),
403 );
404 let height = parse_dimension(
405 rendered
406 .svg
407 .props
408 .get("height")
409 .expect("height should be derived"),
410 );
411 let aspect_ratio = extract_aspect_ratio(&rendered.svg.props).expect("viewBox should exist");
412
413 assert!((width / height - aspect_ratio).abs() < 0.01);
414 }
415
416 #[test]
417 fn differentiates_inline_and_display_rendering() {
418 let display = render_math_with_options(
419 r"\int_0^\infty e^{-x^2} \, dx = \sqrt{\pi}",
420 &MathOptions::default(),
421 )
422 .expect("display math should render");
423 let inline = render_math_with_options(
424 r"\int_0^\infty e^{-x^2} \, dx = \sqrt{\pi}",
425 &MathOptions::new().inline(true),
426 )
427 .expect("inline math should render");
428
429 assert_ne!(display.raw_svg, inline.raw_svg);
430 }
431
432 #[test]
433 fn rejects_non_numeric_single_dimension_strings() {
434 let error = render_math_with_options(r"E = mc^2", &MathOptions::new().width("wide"))
435 .expect_err("non-numeric width should fail when deriving height");
436
437 assert!(matches!(error, Error::InvalidDimension { .. }));
438 }
439
440 #[test]
441 fn supports_primitive_color_conversion() {
442 let options = MathOptions::new().color_from_primitives(Color::rgba(16, 32, 48, 255));
443 let rendered = render_math_with_options(r"x + y", &options).expect("math should render");
444
445 assert_eq!(
446 rendered.svg.props.get("color"),
447 Some(&String::from("#102030ff"))
448 );
449 }
450}