Skip to main content

graphitepdf_math/
lib.rs

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}