Skip to main content

ravenrs_sheet/engine/cmd/
equation.rs

1//! Equations (Insert › Symbols › Equation): floating shapes holding LaTeX source, laid out by
2//! `gridcraft-math` and drawn from the cached layout.
3//!
4//! An equation is drawn to fit its frame, so resizing the frame scales it. Inserting sizes the
5//! frame for the requested font size; editing keeps the current scale and resizes the frame to
6//! the new content.
7
8use crate::math::{MathError, MathList};
9use crate::model::{Anchor, AnchorMode, Color, EquationCache, Shape, ShapeKind};
10use serde_json::{Value as Json, json};
11
12use super::{CommandSpec, EngineError, PROTECTED, Result, Session, bad, cell_param, cmd, edit, f64_param, has_doc, ok, str_param, u32_param};
13
14pub fn specs() -> Vec<CommandSpec> {
15    vec![
16        cmd!(
17            "insert.equation",
18            "Equation",
19            ["Insert", "Symbols"],
20            None,
21            "{latex: \"x^2\", cell?|at?: \"B2\", size?: 11 (points), color?: hex|\"auto\"}",
22            has_doc,
23            insert_equation
24        ),
25        cmd!("equation.edit", "Edit Equation", [], None, "{id, latex}", has_doc, edit_equation),
26    ]
27}
28
29/// Default equation font size, in points.
30pub const DEFAULT_SIZE_PT: f64 = 11.0;
31/// Space between an equation's frame and its content, in sheet pixels.
32pub const PADDING: f32 = 4.0;
33/// Sheet pixels per point.
34const PX_PER_PT: f64 = 96.0 / 72.0;
35
36/// The scale (sheet pixels per em) at which `list` fits a frame of `w` by `h` pixels.
37pub fn fit_scale(list: &MathList, w: f32, h: f32) -> f32 {
38    let (cw, ch) = (list.width.max(0.01), list.total_height().max(0.01));
39    let s = ((w - 2.0 * PADDING) / cw).min((h - 2.0 * PADDING) / ch);
40    if s.is_finite() { s.max(0.0) } else { 0.0 }
41}
42
43/// The frame size for `list` at `scale` pixels per em.
44fn frame_for(list: &MathList, scale: f32) -> (f32, f32) {
45    let w = list.width.max(0.5) * scale + 2.0 * PADDING;
46    let h = list.total_height().max(1.0) * scale + 2.0 * PADDING;
47    (w.clamp(8.0, 20_000.0), h.clamp(8.0, 20_000.0))
48}
49
50/// `latex` laid out, or a readable error with the position of the problem.
51fn lay_out(cmd: &str, latex: &str) -> Result<MathList> {
52    crate::math::layout(latex).map_err(|e| bad(cmd, describe(latex, &e)))
53}
54
55/// "Error at character 5: Undefined control sequence: \foo".
56pub fn describe(latex: &str, e: &MathError) -> String {
57    match e.char_pos(latex) {
58        Some(p) => format!("equation error at character {p}: {}", e.message),
59        None => format!("equation error: {}", e.message),
60    }
61}
62
63fn insert_equation(s: &mut Session, p: &Json) -> Result<Json> {
64    const CMD: &str = "insert.equation";
65    let latex = str_param(p, "latex").ok_or_else(|| bad(CMD, "`latex` must be the equation's LaTeX source, such as \"x^2 + y^2 = r^2\""))?;
66    if latex.trim().is_empty() {
67        return Err(bad(CMD, "`latex` is empty"));
68    }
69    let size = f64_param(p, "size").unwrap_or(DEFAULT_SIZE_PT);
70    if !(1.0..=409.0).contains(&size) {
71        return Err(bad(CMD, "`size` must be between 1 and 409 points"));
72    }
73    let color = match p.get("color") {
74        None | Some(Json::Null) => Color::Auto,
75        Some(c) => super::format::color_param(Some(c)).ok_or_else(|| bad(CMD, "`color` must be a hex colour such as \"#1F4E79\", or \"auto\""))?,
76    };
77    let list = lay_out(CMD, latex)?;
78    let at = match cell_param(s, p, "cell")? {
79        Some(c) => Some(c),
80        None => cell_param(s, p, "at")?,
81    };
82    let d = s.doc()?;
83    if d.wb.active().is_some_and(|sh| sh.is_protected()) {
84        return Err(EngineError::Other(PROTECTED.into()));
85    }
86    let at = at.unwrap_or(d.selection.active);
87    let id = d.wb.next_object_id();
88    let (width, height) = frame_for(&list, (size * PX_PER_PT) as f32);
89    let mut shape = Shape {
90        id,
91        kind: ShapeKind::Equation,
92        anchor: Anchor { cell: at, dx: 0.0, dy: 0.0, width, height, mode: AnchorMode::MoveOnly },
93        fill: Color::Auto,
94        line: color,
95        text: latex.to_string(),
96        format: Default::default(),
97        math: Default::default(),
98    };
99    shape.math.refresh(latex);
100    let sheet = d.wb.active_sheet();
101    edit(s, |cx| {
102        cx.sheet_mut(sheet)?.shapes.push(shape);
103        Ok(json!({"equation": id}))
104    })
105}
106
107fn edit_equation(s: &mut Session, p: &Json) -> Result<Json> {
108    const CMD: &str = "equation.edit";
109    let id = u32_param(p, "id").ok_or_else(|| bad(CMD, "`id` must be an equation id"))?;
110    let latex = str_param(p, "latex").ok_or_else(|| bad(CMD, "`latex` must be the equation's LaTeX source"))?;
111    if latex.trim().is_empty() {
112        return Err(bad(CMD, "`latex` is empty; delete the equation instead"));
113    }
114    let d = s.doc()?;
115    let sheet = d.wb.active_sheet();
116    let sh = d.wb.active().ok_or_else(|| bad(CMD, "no such equation"))?;
117    let shape = sh.shapes.iter().find(|x| x.id == id && x.kind == ShapeKind::Equation).ok_or_else(|| bad(CMD, "no such equation"))?;
118    if sh.is_protected() {
119        return Err(EngineError::Other(PROTECTED.into()));
120    }
121    // The same source is a no-op, unless it has no layout yet (a file with more equations than
122    // are laid out at once): then OK lays it out.
123    if shape.text == latex && matches!(shape.math.get(latex), Some(Ok(_))) {
124        return ok();
125    }
126    let list = lay_out(CMD, latex)?;
127    // Keep the size the equation is drawn at now; without a layout, use the default size.
128    let scale = match shape.math.get(&shape.text) {
129        Some(Ok(old)) => fit_scale(old, shape.anchor.width, shape.anchor.height),
130        _ => 0.0,
131    };
132    let scale = if scale > 0.1 { scale } else { (DEFAULT_SIZE_PT * PX_PER_PT) as f32 };
133    let (width, height) = frame_for(&list, scale);
134    edit(s, |cx| {
135        let shape = cx.sheet_mut(sheet)?.shapes.iter_mut().find(|x| x.id == id).ok_or_else(|| bad(CMD, "no such equation"))?;
136        shape.text = latex.to_string();
137        shape.math = EquationCache::default();
138        shape.math.refresh(latex);
139        shape.anchor.width = width;
140        shape.anchor.height = height;
141        Ok(json!({"equation": id}))
142    })
143}
144
145// ---------------------------------------------------------------- PDF
146
147/// The first [`crate::math::MAX_SOURCE_BYTES`] bytes of an equation's source, cut at a char
148/// boundary. A longer source never lays out (the editor refuses it, and a file can carry one of
149/// megabytes), so what shows it as text, the sheet painter and the PDF fallback, shows this much
150/// and does no work on the rest.
151pub fn source_excerpt(text: &str) -> &str {
152    let cap = crate::math::MAX_SOURCE_BYTES;
153    if text.len() <= cap {
154        return text;
155    }
156    let mut end = cap;
157    while end > 0 && !text.is_char_boundary(end) {
158        end -= 1;
159    }
160    text.get(..end).unwrap_or("")
161}
162
163/// The PDF face for a glyph: Symbol for Greek letters and operators, else the Times (or
164/// Helvetica, Courier) face for its style. `None` when no standard font can show `c`.
165fn pdf_font(c: char, face: crate::math::Face) -> Option<crate::pdf::Font> {
166    use crate::math::Face;
167    use crate::pdf::Font;
168    let text = match face {
169        Face::Italic => Font::TimesItalic,
170        Face::Bold => Font::TimesBold,
171        Face::BoldItalic => Font::TimesBoldItalic,
172        Face::Sans => Font::Helvetica,
173        Face::SansBold => Font::HelveticaBold,
174        Face::SansItalic => Font::HelveticaOblique,
175        Face::Mono => Font::Courier,
176        Face::Roman | Face::Large(_) => Font::TimesRoman,
177    };
178    // Letters and digits stay in the text face; everything Symbol has goes to Symbol, whose
179    // operators (minus, equals, plus) match the maths better than the text faces'.
180    if c.is_ascii_alphanumeric() {
181        return Some(text);
182    }
183    if Font::Symbol.can_encode(c) {
184        return Some(Font::Symbol);
185    }
186    text.can_encode(c).then_some(text)
187}
188
189/// Draws an equation's layout into the box `(x, y, w, h)` (page points) of a PDF page, with
190/// `pad` points of padding. Glyphs no standard font has are drawn as `[?]` boxes.
191pub(crate) fn draw_pdf(pg: &mut crate::pdf::Page, list: &MathList, rect: (f32, f32, f32, f32), pad: f32, ink: crate::pdf::Rgb) {
192    use crate::math::{Face, Item, PathCmd};
193    let (x, y, w, h) = rect;
194    let (cw, ch) = (list.width.max(0.01), list.total_height().max(0.01));
195    let s = ((w - 2.0 * pad) / cw).min((h - 2.0 * pad) / ch);
196    if !(s.is_finite() && s > 0.01) {
197        return;
198    }
199    let s = s.min(4000.0);
200    let (ox, oy) = (x + (w - cw * s) / 2.0, y + (h - ch * s) / 2.0);
201    let col = |c: Option<[u8; 3]>| c.unwrap_or(ink);
202    for item in list.items.iter().take(crate::math::MAX_ITEMS) {
203        match item {
204            Item::Glyph(g) => {
205                let color = col(g.color);
206                let (gx, base) = (ox + g.x * s, oy + g.y * s);
207                // The radical sign: Symbol's glyph (AFM bounding box -147 to 917 high, right edge
208                // at 558, in 1/1000 em) fitted to the TeX box, so its top meets the vinculum.
209                if g.ch == '\u{221A}' {
210                    let size = (g.height + g.depth) * s / 1.064;
211                    if size.is_finite() && size >= 0.5 {
212                        let top = base - g.height * s;
213                        pg.text(gx + g.width * s - 0.558 * size, top + 0.917 * size, size, crate::pdf::Font::Symbol, color, "\u{221A}");
214                    }
215                    continue;
216                }
217                let large = matches!(g.face, Face::Large(_));
218                let size = if large { (g.height + g.depth) * s } else { g.size * s };
219                if !(size.is_finite() && size >= 0.5) {
220                    continue;
221                }
222                let pick = pdf_font(g.ch, g.face).map(|f| (g.ch, f)).or_else(|| g.alt.and_then(|a| pdf_font(a, g.face).map(|f| (a, f))));
223                match pick {
224                    Some((c, font)) => {
225                        let text = c.to_string();
226                        if large {
227                            // Centred on the glyph's box, like the screen.
228                            let mid = oy + (g.y - (g.height - g.depth) / 2.0) * s;
229                            let tw = crate::pdf::text_width(font, size, &text);
230                            pg.text(gx + (g.width * s - tw) / 2.0, mid + 0.25 * size, size, font, color, &text);
231                        } else {
232                            pg.text(gx, base, size, font, color, &text);
233                        }
234                    }
235                    None => {
236                        // [?]: the glyph's box with a question mark.
237                        let (bw, bh) = ((g.width.max(0.3)) * s, (g.height + g.depth) * s);
238                        pg.rect_stroke(gx, base - g.height * s, bw, bh, (0.05 * s).clamp(0.2, 1.0), color);
239                        let qs = (bh * 0.6).min(bw * 1.2).max(1.0);
240                        let qw = crate::pdf::text_width(crate::pdf::Font::Helvetica, qs, "?");
241                        pg.text(gx + (bw - qw) / 2.0, base - g.height * s + (bh + qs * 0.7) / 2.0, qs, crate::pdf::Font::Helvetica, color, "?");
242                    }
243                }
244            }
245            Item::Rule(r) => {
246                if r.width > 0.0 && r.height > 0.0 {
247                    pg.fill_rect(ox + r.x * s, oy + r.y * s, r.width * s, (r.height * s).max(0.25), col(r.color));
248                }
249            }
250            Item::Path(p) => {
251                let (px, py) = (ox + p.x * s, oy + p.y * s);
252                let mut subs: Vec<Vec<[f32; 2]>> = vec![];
253                let mut cur: Vec<[f32; 2]> = vec![];
254                let at = |a: f32, b: f32| [px + a * s, py + b * s];
255                for c in &p.commands {
256                    match *c {
257                        PathCmd::MoveTo(a, b) => {
258                            if cur.len() > 1 {
259                                subs.push(std::mem::take(&mut cur));
260                            }
261                            cur = vec![at(a, b)];
262                        }
263                        PathCmd::LineTo(a, b) => cur.push(at(a, b)),
264                        PathCmd::QuadTo(x1, y1, a, b) => {
265                            let p0 = cur.last().copied().unwrap_or(at(x1, y1));
266                            let (c1, p1) = (at(x1, y1), at(a, b));
267                            for k in 1..=8 {
268                                let t = k as f32 / 8.0;
269                                let u = 1.0 - t;
270                                cur.push([u * u * p0[0] + 2.0 * u * t * c1[0] + t * t * p1[0], u * u * p0[1] + 2.0 * u * t * c1[1] + t * t * p1[1]]);
271                            }
272                        }
273                        PathCmd::CubicTo(x1, y1, x2, y2, a, b) => {
274                            let p0 = cur.last().copied().unwrap_or(at(x1, y1));
275                            let (c1, c2, p1) = (at(x1, y1), at(x2, y2), at(a, b));
276                            for k in 1..=10 {
277                                let t = k as f32 / 10.0;
278                                let u = 1.0 - t;
279                                let (a3, b3, c3, d3) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t);
280                                cur.push([a3 * p0[0] + b3 * c1[0] + c3 * c2[0] + d3 * p1[0], a3 * p0[1] + b3 * c1[1] + c3 * c2[1] + d3 * p1[1]]);
281                            }
282                        }
283                        PathCmd::Close => {
284                            if cur.len() > 1 {
285                                subs.push(std::mem::take(&mut cur));
286                            }
287                        }
288                    }
289                }
290                if cur.len() > 1 {
291                    subs.push(cur);
292                }
293                let color = col(p.color);
294                for sub in subs.iter().filter(|v| v.len() <= 4000) {
295                    if p.fill && sub.len() >= 3 {
296                        // The PDF fill rule handles concave outlines itself.
297                        pg.polygon(sub, Some(color), None);
298                    } else {
299                        pg.polyline(sub, (0.05 * s).clamp(0.2, 1.0), color, &[]);
300                    }
301                }
302            }
303        }
304    }
305}