Skip to main content

mathtex_editor_core/
export.rs

1//! Exports the model to LaTeX with `SpanMap` byte ranges and phantom boxes for empty slots.
2
3use std::collections::HashMap;
4use std::ops::Range;
5
6use crate::model::{Deco, FracStyle, Kind, Mark, MatrixEnv, NodeId, Selection, SeqId, Tree, Variant};
7
8/// Byte ranges into the exported LaTeX for every node and slot.
9#[derive(Debug, Clone, Default)]
10pub struct SpanMap {
11    /// Byte ranges for exported nodes.
12    pub node: HashMap<NodeId, Range<usize>>,
13    /// Byte ranges for exported sequences.
14    pub seq: HashMap<SeqId, Range<usize>>,
15}
16
17impl Tree {
18    /// Exports the whole document to rendering LaTeX and records spans for placeholder matching.
19    pub fn export_latex(&self) -> (String, SpanMap) {
20        let mut ex = Exporter::new(self, true);
21        ex.emit_seq(self.root());
22        (ex.out, ex.spans)
23    }
24
25    /// Exports the whole document to clean display and copy LaTeX.
26    pub fn export_display(&self) -> String {
27        let mut ex = Exporter::new(self, false);
28        ex.emit_seq(self.root());
29        ex.out
30    }
31
32    /// Exports a selection's content to clean LaTeX for the host clipboard.
33    pub fn selection_latex(&self, sel: Selection) -> String {
34        let lo = sel.anchor.min(sel.focus);
35        let hi = sel.anchor.max(sel.focus).min(self.len(sel.seq));
36        let mut ex = Exporter::new(self, false);
37        let items = self.items(sel.seq)[lo..hi].to_vec();
38        for n in items {
39            ex.emit_node(n);
40        }
41        ex.out
42    }
43}
44
45struct Exporter<'a> {
46    tree: &'a Tree,
47    out: String,
48    spans: SpanMap,
49    /// Controls whether empty slots render as `\phantom{x}` placeholders.
50    placeholders: bool,
51}
52
53impl<'a> Exporter<'a> {
54    fn new(tree: &'a Tree, placeholders: bool) -> Self {
55        Self {
56            tree,
57            out: String::new(),
58            spans: SpanMap::default(),
59            placeholders,
60        }
61    }
62
63    /// Emits a sequence's content or a phantom box when empty, then records its span.
64    fn emit_seq(&mut self, seq: SeqId) {
65        let start = self.out.len();
66        if self.tree.is_empty(seq) {
67            // Rendering uses a phantom box so the host can draw the visible placeholder at this span.
68            if self.placeholders {
69                self.out.push_str("\\phantom{x}");
70            }
71        } else {
72            let items = self.tree.items(seq).to_vec();
73            for (i, &n) in items.iter().enumerate() {
74                self.emit_node(n);
75                if self.needs_separator(n, items.get(i + 1).copied()) {
76                    self.out.push(' ');
77                }
78            }
79        }
80        self.spans.seq.insert(seq, start..self.out.len());
81    }
82
83    /// A control word atom needs a space only when the next emitted character is a letter.
84    fn needs_separator(&self, cur: NodeId, next: Option<NodeId>) -> bool {
85        let cur_ends_in_control_word = match self.tree.kind(cur) {
86            Some(Kind::Atom(s)) => is_control_word(&s.latex),
87            // A clean exported big operator with empty limits is a bare control word.
88            Some(Kind::BigOp { op, lower, upper }) => {
89                !self.placeholders
90                    && self.tree.is_empty(*lower)
91                    && self.tree.is_empty(*upper)
92                    && is_control_word(&op.latex)
93            }
94            _ => false,
95        };
96        if !cur_ends_in_control_word {
97            return false;
98        }
99        next.and_then(|n| self.node_first_char(n))
100            .is_some_and(char::is_alphabetic)
101    }
102
103    /// Reports the first character this node will emit for control word spacing.
104    fn node_first_char(&self, node: NodeId) -> Option<char> {
105        match self.tree.kind(node)? {
106            Kind::Atom(s) => s.latex.chars().next(),
107            Kind::Frac { style: FracStyle::Atop, .. } => Some('{'),
108            Kind::Frac { .. }
109            | Kind::Sqrt { .. }
110            | Kind::Delim { .. }
111            | Kind::Accent { .. }
112            | Kind::Styled { .. }
113            | Kind::Matrix { .. }
114            | Kind::HostBox { .. } => Some('\\'),
115            // A big operator emits an operator command, so it starts with a backslash.
116            Kind::BigOp { .. } => Some('\\'),
117            Kind::Script { base, .. } => self.seq_first_char(*base),
118            Kind::UnderOver { base, over, under, .. } => {
119                if over.is_none() && under.is_none() {
120                    self.seq_first_char(*base)
121                } else {
122                    Some('\\')
123                }
124            }
125        }
126    }
127
128    /// Reports the first character a sequence will emit.
129    fn seq_first_char(&self, seq: SeqId) -> Option<char> {
130        if self.tree.is_empty(seq) {
131            Some('\\')
132        } else {
133            self.node_first_char(self.tree.items(seq)[0])
134        }
135    }
136
137    fn emit_braced(&mut self, seq: SeqId) {
138        self.out.push('{');
139        self.emit_seq(seq);
140        self.out.push('}');
141    }
142
143    /// Emits a script argument, dropping braces only for a single character atom.
144    fn emit_arg(&mut self, seq: SeqId) {
145        let items = self.tree.items(seq);
146        let bare = items.len() == 1
147            && matches!(
148                self.tree.kind(items[0]),
149                Some(Kind::Atom(s)) if s.latex.chars().count() == 1
150            );
151        if bare {
152            self.emit_seq(seq);
153        } else {
154            self.emit_braced(seq);
155        }
156    }
157
158    fn emit_node(&mut self, node: NodeId) {
159        let start = self.out.len();
160        let Some(kind) = self.tree.kind(node).cloned() else {
161            return;
162        };
163        match kind {
164            Kind::Atom(s) => {
165                self.out.push_str(&s.latex);
166            }
167            // The editor emits only the token macro, the object's content stays host side.
168            Kind::HostBox { token } => {
169                self.out.push_str("\\hostbox{");
170                self.out.push_str(&token.to_string());
171                self.out.push('}');
172            }
173            Kind::Frac { num, den, style } => match style {
174                FracStyle::Atop => {
175                    self.out.push('{');
176                    self.emit_seq(num);
177                    self.out.push_str("\\atop ");
178                    self.emit_seq(den);
179                    self.out.push('}');
180                }
181                _ => {
182                    self.out.push_str(frac_cmd(style));
183                    self.emit_braced(num);
184                    self.emit_braced(den);
185                }
186            },
187            Kind::Script { base, sub, sup } => {
188                self.emit_seq(base);
189                if let Some(s) = sub {
190                    self.out.push('_');
191                    self.emit_arg(s);
192                }
193                if let Some(s) = sup {
194                    self.out.push('^');
195                    self.emit_arg(s);
196                }
197            }
198            Kind::BigOp { op, lower, upper } => {
199                // Rendering keeps empty limits as placeholders, while clean export drops them.
200                self.out.push_str(&op.latex);
201                if self.placeholders || !self.tree.is_empty(lower) {
202                    self.out.push('_');
203                    self.emit_arg(lower);
204                }
205                if self.placeholders || !self.tree.is_empty(upper) {
206                    self.out.push('^');
207                    self.emit_arg(upper);
208                }
209            }
210            Kind::Sqrt { index, radicand } => {
211                self.out.push_str("\\sqrt");
212                // Rendering keeps an empty degree as a placeholder, while clean export drops it.
213                if self.placeholders || !self.tree.is_empty(index) {
214                    self.out.push('[');
215                    self.emit_seq(index);
216                    self.out.push(']');
217                }
218                self.emit_braced(radicand);
219            }
220            Kind::Delim { open, close, body } => {
221                self.out.push_str("\\left");
222                self.out.push_str(&delim(open));
223                self.emit_seq(body);
224                self.out.push_str("\\right");
225                self.out.push_str(&delim(close));
226            }
227            Kind::Accent { mark, base } => {
228                self.out.push_str(accent_cmd(mark));
229                self.emit_braced(base);
230            }
231            Kind::UnderOver {
232                base,
233                over,
234                under,
235                over_deco,
236                under_deco,
237            } => self.emit_under_over(base, over, under, over_deco, under_deco),
238            Kind::Styled { variant, content } => {
239                self.out.push_str(variant_cmd(variant));
240                self.emit_braced(content);
241            }
242            Kind::Matrix { env, rows } => self.emit_matrix(env, &rows),
243        }
244        self.spans.node.insert(node, start..self.out.len());
245    }
246
247    fn emit_under_over(
248        &mut self,
249        base: SeqId,
250        over: Option<SeqId>,
251        under: Option<SeqId>,
252        over_deco: Deco,
253        under_deco: Deco,
254    ) {
255        match (over, under) {
256            (Some(o), None) => match over_deco {
257                Deco::Brace => {
258                    self.out.push_str("\\overbrace");
259                    self.emit_braced(base);
260                    self.out.push('^');
261                    self.emit_braced(o);
262                }
263                _ => {
264                    self.out.push_str("\\overset");
265                    self.emit_braced(o);
266                    self.emit_braced(base);
267                }
268            },
269            (None, Some(u)) => match under_deco {
270                Deco::Brace => {
271                    self.out.push_str("\\underbrace");
272                    self.emit_braced(base);
273                    self.out.push('_');
274                    self.emit_braced(u);
275                }
276                _ => {
277                    self.out.push_str("\\underset");
278                    self.emit_braced(u);
279                    self.emit_braced(base);
280                }
281            },
282            (Some(o), Some(u)) => {
283                self.out.push_str("\\overset");
284                self.emit_braced(o);
285                self.out.push('{');
286                self.out.push_str("\\underset");
287                self.emit_braced(u);
288                self.emit_braced(base);
289                self.out.push('}');
290            }
291            (None, None) => self.emit_seq(base),
292        }
293    }
294
295    fn emit_matrix(&mut self, env: MatrixEnv, rows: &[Vec<SeqId>]) {
296        let name = matrix_env_name(env);
297        self.out.push_str("\\begin{");
298        self.out.push_str(name);
299        self.out.push('}');
300        if matches!(env, MatrixEnv::Array) {
301            let cols = rows.first().map_or(0, |r| r.len());
302            self.out.push('{');
303            self.out.push_str(&"c".repeat(cols));
304            self.out.push('}');
305        }
306        for (ri, row) in rows.iter().enumerate() {
307            if ri > 0 {
308                self.out.push_str(" \\\\ ");
309            }
310            for (ci, &cell) in row.iter().enumerate() {
311                if ci > 0 {
312                    self.out.push_str(" & ");
313                }
314                self.emit_seq(cell);
315            }
316        }
317        self.out.push_str("\\end{");
318        self.out.push_str(name);
319        self.out.push('}');
320    }
321}
322
323fn is_control_word(latex: &str) -> bool {
324    latex.starts_with('\\')
325        && latex
326            .chars()
327            .last()
328            .is_some_and(|c| c.is_ascii_alphabetic())
329}
330
331fn frac_cmd(style: FracStyle) -> &'static str {
332    match style {
333        FracStyle::Bar => "\\frac",
334        FracStyle::Display => "\\dfrac",
335        FracStyle::Text => "\\tfrac",
336        FracStyle::Binom => "\\binom",
337        FracStyle::Atop => "\\frac",
338    }
339}
340
341fn delim(c: char) -> String {
342    match c {
343        '{' => "\\{".to_string(),
344        '}' => "\\}".to_string(),
345        c => c.to_string(),
346    }
347}
348
349fn accent_cmd(mark: Mark) -> &'static str {
350    match mark {
351        Mark::Hat => "\\hat",
352        Mark::Vec => "\\vec",
353        Mark::Bar => "\\bar",
354        Mark::Tilde => "\\tilde",
355        Mark::Dot => "\\dot",
356        Mark::Ddot => "\\ddot",
357        Mark::Widehat => "\\widehat",
358        Mark::Widetilde => "\\widetilde",
359        Mark::Overline => "\\overline",
360        Mark::Underline => "\\underline",
361        Mark::Check => "\\check",
362        Mark::Breve => "\\breve",
363    }
364}
365
366fn variant_cmd(v: Variant) -> &'static str {
367    match v {
368        Variant::Normal => "\\mathnormal",
369        Variant::Bold => "\\mathbf",
370        Variant::Blackboard => "\\mathbb",
371        Variant::Calligraphic => "\\mathcal",
372        Variant::Fraktur => "\\mathfrak",
373        Variant::Roman => "\\mathrm",
374        Variant::SansSerif => "\\mathsf",
375        Variant::Typewriter => "\\mathtt",
376        Variant::Text => "\\text",
377        Variant::OperatorName => "\\operatorname",
378    }
379}
380
381fn matrix_env_name(env: MatrixEnv) -> &'static str {
382    match env {
383        MatrixEnv::Matrix => "matrix",
384        MatrixEnv::Pmatrix => "pmatrix",
385        MatrixEnv::Bmatrix => "bmatrix",
386        MatrixEnv::Vmatrix => "vmatrix",
387        MatrixEnv::Cases => "cases",
388        MatrixEnv::Aligned => "aligned",
389        MatrixEnv::Array => "array",
390    }
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396    use crate::model::{Cursor, MathClass, ScriptSlot, Symbol};
397
398    fn atom(c: &str) -> Symbol {
399        Symbol {
400            latex: c.into(),
401            class: MathClass::Ord,
402        }
403    }
404
405    #[test]
406    fn export_fraction_with_byte_spans() {
407        let mut t = Tree::new();
408        let root = t.root();
409        let cnum = t.insert_fraction(Cursor { seq: root, index: 0 }, FracStyle::Bar, None);
410        t.insert_atom(cnum, atom("a"));
411        let frac = t.items(root)[0];
412        let (num, den) = (t.child_seqs(frac)[0], t.child_seqs(frac)[1]);
413        t.insert_atom(Cursor { seq: den, index: 0 }, atom("b"));
414
415        let (s, spans) = t.export_latex();
416        assert_eq!(s, "\\frac{a}{b}");
417        assert_eq!(&s[spans.node[&frac].clone()], "\\frac{a}{b}");
418        assert_eq!(&s[spans.seq[&num].clone()], "a");
419        assert_eq!(&s[spans.seq[&den].clone()], "b");
420    }
421
422    #[test]
423    fn empty_slot_emits_phantom_with_a_span() {
424        let mut t = Tree::new();
425        let root = t.root();
426        t.insert_fraction(Cursor { seq: root, index: 0 }, FracStyle::Bar, None);
427        let frac = t.items(root)[0];
428        let num = t.child_seqs(frac)[0];
429        let (s, spans) = t.export_latex();
430        // Empty fraction slots each emit a phantom box for host placeholders.
431        assert_eq!(s, "\\frac{\\phantom{x}}{\\phantom{x}}");
432        assert_eq!(&s[spans.seq[&num].clone()], "\\phantom{x}");
433    }
434
435    #[test]
436    fn typed_specials_export_escaped() {
437        let mut t = Tree::new();
438        let root = t.root();
439        let mut c = Cursor { seq: root, index: 0 };
440        for ch in "50%&$".chars() {
441            c = t.insert_atom(c, Symbol::from_char(ch));
442        }
443        let (s, _) = t.export_latex();
444        assert_eq!(s, "50\\%\\&\\$");
445    }
446
447    #[test]
448    fn export_script_and_sqrt() {
449        let mut t = Tree::new();
450        let root = t.root();
451        t.insert_atom(Cursor { seq: root, index: 0 }, atom("x"));
452        let c = t.attach_script(Cursor { seq: root, index: 1 }, ScriptSlot::Sup);
453        t.insert_atom(c, atom("2"));
454        let (s, _) = t.export_latex();
455        assert_eq!(s, "x^2");
456    }
457
458    #[test]
459    fn control_word_before_scripted_letter_keeps_separator() {
460        // The space after `\sum` must survive when `x` becomes a script base.
461        let mut t = Tree::new();
462        let root = t.root();
463        let mut c = Cursor { seq: root, index: 0 };
464        c = t.insert_atom(c, atom("\\sum"));
465        c = t.insert_atom(c, atom("x"));
466        let sup = t.attach_script(c, ScriptSlot::Sup);
467        t.insert_atom(sup, atom("2"));
468        let (s, _) = t.export_latex();
469        assert_eq!(s, "\\sum x^2");
470    }
471
472    #[test]
473    fn control_word_before_command_needs_no_separator() {
474        // The following backslash already terminates `\sum`.
475        let mut t = Tree::new();
476        let root = t.root();
477        let c = t.insert_atom(Cursor { seq: root, index: 0 }, atom("\\sum"));
478        let num = t.insert_fraction(c, FracStyle::Bar, None);
479        t.insert_atom(num, atom("a"));
480        let (s, _) = t.export_latex();
481        assert_eq!(s, "\\sum\\frac{a}{\\phantom{x}}");
482    }
483
484    #[test]
485    fn export_host_box_with_one_node_span() {
486        let mut t = Tree::new();
487        let root = t.root();
488        let c = t.insert_atom(Cursor { seq: root, index: 0 }, atom("a"));
489        t.insert_host_box(c, 17);
490        let (s, spans) = t.export_latex();
491        assert_eq!(s, "a\\hostbox{17}");
492        // The whole macro is one node span, so the matcher treats the atom as one unit.
493        let hb = t.items(root)[1];
494        assert_eq!(&s[spans.node[&hb].clone()], "\\hostbox{17}");
495    }
496
497    #[test]
498    fn export_host_box_nested_in_numerator_superscript_and_text() {
499        let mut t = Tree::new();
500        let root = t.root();
501        let cnum = t.insert_fraction(Cursor { seq: root, index: 0 }, FracStyle::Bar, None);
502        t.insert_host_box(cnum, 17);
503        let (s, _) = t.export_latex();
504        assert_eq!(s, "\\frac{\\hostbox{17}}{\\phantom{x}}");
505
506        let mut t = Tree::new();
507        let root = t.root();
508        t.insert_atom(Cursor { seq: root, index: 0 }, atom("x"));
509        let c = t.attach_script(Cursor { seq: root, index: 1 }, ScriptSlot::Sup);
510        t.insert_host_box(c, 17);
511        let (s, _) = t.export_latex();
512        assert_eq!(s, "x^{\\hostbox{17}}");
513
514        let mut t = Tree::new();
515        let root = t.root();
516        let c = t.insert_styled(Cursor { seq: root, index: 0 }, crate::model::Variant::Text, None);
517        t.insert_host_box(c, 17);
518        let (s, _) = t.export_latex();
519        assert_eq!(s, "\\text{\\hostbox{17}}");
520    }
521
522    #[test]
523    fn script_multichar_keeps_braces() {
524        let mut t = Tree::new();
525        let root = t.root();
526        t.insert_atom(Cursor { seq: root, index: 0 }, atom("x"));
527        let c = t.attach_script(Cursor { seq: root, index: 1 }, ScriptSlot::Sup);
528        let c = t.insert_atom(c, atom("1"));
529        t.insert_atom(c, atom("2"));
530        let (s, _) = t.export_latex();
531        assert_eq!(s, "x^{12}");
532    }
533}