1use 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
29pub const DEFAULT_SIZE_PT: f64 = 11.0;
31pub const PADDING: f32 = 4.0;
33const PX_PER_PT: f64 = 96.0 / 72.0;
35
36pub 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
43fn 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
50fn lay_out(cmd: &str, latex: &str) -> Result<MathList> {
52 crate::math::layout(latex).map_err(|e| bad(cmd, describe(latex, &e)))
53}
54
55pub 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 if shape.text == latex && matches!(shape.math.get(latex), Some(Ok(_))) {
124 return ok();
125 }
126 let list = lay_out(CMD, latex)?;
127 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
145pub 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
163fn 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 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
189pub(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 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 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 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 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}