Skip to main content

oxframe_2d/
math.rs

1//! A formula set by Typst: `Math::new("e^(i pi) + 1 = 0")` (feature `math`).
2//! Every glyph and rule is a path, so a formula can be written on glyph by
3//! glyph; one signal colors all of them.
4
5use std::ops::Deref;
6
7use oxframe_core::{Color, Signal};
8use oxframe_text::TextStyle;
9
10use crate::builder::{BaseProps, NodeBuilder, SvgBuilder};
11use crate::graph::{NodeId, SceneGraph};
12use crate::handles::{Node, Path, Svg};
13use crate::theme::{Ink, IntoPaint};
14
15/// Builder for a [`Math`] node: `Math::new("a^2 + b^2 = c^2").size(64.0)`.
16#[derive(Debug)]
17#[must_use = "a builder does nothing until it is passed to `add`"]
18pub struct MathBuilder {
19    formula: String,
20    display: bool,
21    size: f64,
22    color: Option<Color>,
23    svg: SvgBuilder,
24}
25
26impl MathBuilder {
27    /// Font size in pixels, as for [`Txt`](crate::Txt). Default: the same as
28    /// `Txt`.
29    pub fn size(mut self, v: f64) -> Self {
30        self.size = v;
31        self
32    }
33
34    /// Color of every glyph and rule. Default: the theme's text color.
35    pub fn color(mut self, v: impl IntoPaint) -> Self {
36        self.color = Some(v.into_paint());
37        self
38    }
39
40    /// Sets the formula on its own line, as `$ … $` in Typst: sums and
41    /// integrals get their limits above and below, fractions are full size.
42    /// The default is inline math, as in running text.
43    pub fn display(mut self) -> Self {
44        self.display = true;
45        self
46    }
47}
48
49impl NodeBuilder for MathBuilder {
50    type Handle = Math;
51
52    fn base(&mut self) -> &mut BaseProps {
53        self.svg.base()
54    }
55
56    fn build(self, g: &SceneGraph, parent: NodeId) -> Math {
57        let set = oxframe_math::to_svg(&self.formula, self.display)
58            .unwrap_or_else(|e| panic!("Math {:?}: {e}", self.formula));
59        // Typst lays out at LAYOUT_PT, one em; scale that em to `size` px.
60        let k = self.size / oxframe_math::LAYOUT_PT;
61        let svg = self
62            .svg
63            .src(set.svg.as_bytes())
64            .width(set.width * k)
65            .height(set.height * k)
66            .build(g, parent);
67        let color = Signal::new(self.color.unwrap_or_else(|| Ink::Text.into_paint()));
68        for path in g.descendant_paths(svg.id()) {
69            let n = g.node(path);
70            if n.fill.peek().to_rgba8()[3] > 0 {
71                let c = color.clone();
72                n.fill.bind(move || c.get());
73            }
74            if n.stroke.peek().to_rgba8()[3] > 0 {
75                let c = color.clone();
76                n.stroke.bind(move || c.get());
77            }
78        }
79        Math {
80            svg,
81            color,
82            formula: self.formula,
83        }
84    }
85}
86
87/// Handle to a formula. Derefs to [`Node`]; [`paths`](Self::paths) are the
88/// glyphs and rules in reading order.
89#[derive(Debug, Clone)]
90pub struct Math {
91    svg: Svg,
92    color: Signal<Color>,
93    formula: String,
94}
95
96impl Math {
97    /// A formula in [Typst math syntax](https://typst.app/docs/reference/math/),
98    /// without the `$`: `"x^2"`, `"sqrt(a + b)"`, `"sum_(k=1)^n k"`,
99    /// `"(a + b) / c"`, `"alpha, beta, pi"`.
100    ///
101    /// # Panics
102    ///
103    /// When the node is added, if Typst cannot set the formula (an unknown
104    /// symbol, unbalanced brackets); the message is Typst's.
105    #[allow(clippy::new_ret_no_self)]
106    pub fn new(formula: impl Into<String>) -> MathBuilder {
107        MathBuilder {
108            formula: formula.into(),
109            display: false,
110            size: TextStyle::default().size,
111            color: None,
112            svg: Svg::new(),
113        }
114    }
115
116    /// The untyped node handle.
117    pub fn node(&self) -> Node {
118        *self.svg
119    }
120
121    /// The color of every glyph and rule.
122    pub fn color(&self) -> Signal<Color> {
123        self.color.clone()
124    }
125
126    /// Glyphs and rules (fraction bars, roots) in reading order, for
127    /// `stagger` and `draw_on`.
128    pub fn paths(&self) -> Vec<Path> {
129        self.svg.paths()
130    }
131
132    /// The source the node was made from.
133    pub fn formula(&self) -> &str {
134        &self.formula
135    }
136}
137
138impl Deref for Math {
139    type Target = Node;
140    fn deref(&self) -> &Node {
141        &self.svg
142    }
143}
144
145impl From<Math> for Node {
146    fn from(h: Math) -> Node {
147        *h.svg
148    }
149}
150
151impl From<&Math> for Node {
152    fn from(h: &Math) -> Node {
153        *h.svg
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160    use crate::View;
161
162    #[test]
163    fn a_formula_is_sized_by_its_font_size_and_colored_by_one_signal() {
164        SceneGraph::new(800, 400).enter(|| {
165            let small = View::current().add(Math::new("x^2 + y^2 = r^2").size(32.0));
166            let big = View::current().add(
167                Math::new("x^2 + y^2 = r^2")
168                    .size(64.0)
169                    .color(Color::rgb(1.0, 0.0, 0.0)),
170            );
171            let (sw, sh) = small.size();
172            let (bw, bh) = big.size();
173            assert!((bw / sw - 2.0).abs() < 1e-6 && (bh / sh - 2.0).abs() < 1e-6);
174            // Inline math keeps to the line: the box is ascender to descender.
175            assert!(
176                (bh - 64.0).abs() < 1.0,
177                "one em, ascender to descender ({bh})"
178            );
179            let paths = big.paths();
180            assert!(paths.len() >= 8, "one path per glyph ({})", paths.len());
181            assert_eq!(paths[0].fill().get(), Color::rgb(1.0, 0.0, 0.0));
182            big.color().set(Color::WHITE);
183            assert!(paths.iter().all(|p| p.fill().get() == Color::WHITE));
184            assert_eq!(big.formula(), "x^2 + y^2 = r^2");
185        });
186    }
187
188    #[test]
189    fn display_math_sets_limits_above_and_below() {
190        SceneGraph::new(800, 400).enter(|| {
191            let inline = View::current().add(Math::new("sum_(k=1)^n k"));
192            let display = View::current().add(Math::new("sum_(k=1)^n k").display());
193            assert!(display.size().1 > inline.size().1);
194        });
195    }
196
197    #[test]
198    #[should_panic(expected = "Math \"#nosuch\"")]
199    fn a_bad_formula_names_itself() {
200        SceneGraph::new(200, 200).enter(|| {
201            let _ = View::current().add(Math::new("#nosuch"));
202        });
203    }
204}