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 { .. } => Some('\\'),
114            // A big operator emits an operator command, so it starts with a backslash.
115            Kind::BigOp { .. } => Some('\\'),
116            Kind::Script { base, .. } => self.seq_first_char(*base),
117            Kind::UnderOver { base, over, under, .. } => {
118                if over.is_none() && under.is_none() {
119                    self.seq_first_char(*base)
120                } else {
121                    Some('\\')
122                }
123            }
124        }
125    }
126
127    /// Reports the first character a sequence will emit.
128    fn seq_first_char(&self, seq: SeqId) -> Option<char> {
129        if self.tree.is_empty(seq) {
130            Some('\\')
131        } else {
132            self.node_first_char(self.tree.items(seq)[0])
133        }
134    }
135
136    fn emit_braced(&mut self, seq: SeqId) {
137        self.out.push('{');
138        self.emit_seq(seq);
139        self.out.push('}');
140    }
141
142    /// Emits a script argument, dropping braces only for a single character atom.
143    fn emit_arg(&mut self, seq: SeqId) {
144        let items = self.tree.items(seq);
145        let bare = items.len() == 1
146            && matches!(
147                self.tree.kind(items[0]),
148                Some(Kind::Atom(s)) if s.latex.chars().count() == 1
149            );
150        if bare {
151            self.emit_seq(seq);
152        } else {
153            self.emit_braced(seq);
154        }
155    }
156
157    fn emit_node(&mut self, node: NodeId) {
158        let start = self.out.len();
159        let Some(kind) = self.tree.kind(node).cloned() else {
160            return;
161        };
162        match kind {
163            Kind::Atom(s) => {
164                self.out.push_str(&s.latex);
165            }
166            Kind::Frac { num, den, style } => match style {
167                FracStyle::Atop => {
168                    self.out.push('{');
169                    self.emit_seq(num);
170                    self.out.push_str("\\atop ");
171                    self.emit_seq(den);
172                    self.out.push('}');
173                }
174                _ => {
175                    self.out.push_str(frac_cmd(style));
176                    self.emit_braced(num);
177                    self.emit_braced(den);
178                }
179            },
180            Kind::Script { base, sub, sup } => {
181                self.emit_seq(base);
182                if let Some(s) = sub {
183                    self.out.push('_');
184                    self.emit_arg(s);
185                }
186                if let Some(s) = sup {
187                    self.out.push('^');
188                    self.emit_arg(s);
189                }
190            }
191            Kind::BigOp { op, lower, upper } => {
192                // Rendering keeps empty limits as placeholders, while clean export drops them.
193                self.out.push_str(&op.latex);
194                if self.placeholders || !self.tree.is_empty(lower) {
195                    self.out.push('_');
196                    self.emit_arg(lower);
197                }
198                if self.placeholders || !self.tree.is_empty(upper) {
199                    self.out.push('^');
200                    self.emit_arg(upper);
201                }
202            }
203            Kind::Sqrt { index, radicand } => {
204                self.out.push_str("\\sqrt");
205                // Rendering keeps an empty degree as a placeholder, while clean export drops it.
206                if self.placeholders || !self.tree.is_empty(index) {
207                    self.out.push('[');
208                    self.emit_seq(index);
209                    self.out.push(']');
210                }
211                self.emit_braced(radicand);
212            }
213            Kind::Delim { open, close, body } => {
214                self.out.push_str("\\left");
215                self.out.push_str(&delim(open));
216                self.emit_seq(body);
217                self.out.push_str("\\right");
218                self.out.push_str(&delim(close));
219            }
220            Kind::Accent { mark, base } => {
221                self.out.push_str(accent_cmd(mark));
222                self.emit_braced(base);
223            }
224            Kind::UnderOver {
225                base,
226                over,
227                under,
228                over_deco,
229                under_deco,
230            } => self.emit_under_over(base, over, under, over_deco, under_deco),
231            Kind::Styled { variant, content } => {
232                self.out.push_str(variant_cmd(variant));
233                self.emit_braced(content);
234            }
235            Kind::Matrix { env, rows } => self.emit_matrix(env, &rows),
236        }
237        self.spans.node.insert(node, start..self.out.len());
238    }
239
240    fn emit_under_over(
241        &mut self,
242        base: SeqId,
243        over: Option<SeqId>,
244        under: Option<SeqId>,
245        over_deco: Deco,
246        under_deco: Deco,
247    ) {
248        match (over, under) {
249            (Some(o), None) => match over_deco {
250                Deco::Brace => {
251                    self.out.push_str("\\overbrace");
252                    self.emit_braced(base);
253                    self.out.push('^');
254                    self.emit_braced(o);
255                }
256                _ => {
257                    self.out.push_str("\\overset");
258                    self.emit_braced(o);
259                    self.emit_braced(base);
260                }
261            },
262            (None, Some(u)) => match under_deco {
263                Deco::Brace => {
264                    self.out.push_str("\\underbrace");
265                    self.emit_braced(base);
266                    self.out.push('_');
267                    self.emit_braced(u);
268                }
269                _ => {
270                    self.out.push_str("\\underset");
271                    self.emit_braced(u);
272                    self.emit_braced(base);
273                }
274            },
275            (Some(o), Some(u)) => {
276                self.out.push_str("\\overset");
277                self.emit_braced(o);
278                self.out.push('{');
279                self.out.push_str("\\underset");
280                self.emit_braced(u);
281                self.emit_braced(base);
282                self.out.push('}');
283            }
284            (None, None) => self.emit_seq(base),
285        }
286    }
287
288    fn emit_matrix(&mut self, env: MatrixEnv, rows: &[Vec<SeqId>]) {
289        let name = matrix_env_name(env);
290        self.out.push_str("\\begin{");
291        self.out.push_str(name);
292        self.out.push('}');
293        if matches!(env, MatrixEnv::Array) {
294            let cols = rows.first().map_or(0, |r| r.len());
295            self.out.push('{');
296            self.out.push_str(&"c".repeat(cols));
297            self.out.push('}');
298        }
299        for (ri, row) in rows.iter().enumerate() {
300            if ri > 0 {
301                self.out.push_str(" \\\\ ");
302            }
303            for (ci, &cell) in row.iter().enumerate() {
304                if ci > 0 {
305                    self.out.push_str(" & ");
306                }
307                self.emit_seq(cell);
308            }
309        }
310        self.out.push_str("\\end{");
311        self.out.push_str(name);
312        self.out.push('}');
313    }
314}
315
316fn is_control_word(latex: &str) -> bool {
317    latex.starts_with('\\')
318        && latex
319            .chars()
320            .last()
321            .is_some_and(|c| c.is_ascii_alphabetic())
322}
323
324fn frac_cmd(style: FracStyle) -> &'static str {
325    match style {
326        FracStyle::Bar => "\\frac",
327        FracStyle::Display => "\\dfrac",
328        FracStyle::Text => "\\tfrac",
329        FracStyle::Binom => "\\binom",
330        FracStyle::Atop => "\\frac",
331    }
332}
333
334fn delim(c: char) -> String {
335    match c {
336        '{' => "\\{".to_string(),
337        '}' => "\\}".to_string(),
338        c => c.to_string(),
339    }
340}
341
342fn accent_cmd(mark: Mark) -> &'static str {
343    match mark {
344        Mark::Hat => "\\hat",
345        Mark::Vec => "\\vec",
346        Mark::Bar => "\\bar",
347        Mark::Tilde => "\\tilde",
348        Mark::Dot => "\\dot",
349        Mark::Ddot => "\\ddot",
350        Mark::Widehat => "\\widehat",
351        Mark::Widetilde => "\\widetilde",
352        Mark::Overline => "\\overline",
353        Mark::Underline => "\\underline",
354        Mark::Check => "\\check",
355        Mark::Breve => "\\breve",
356    }
357}
358
359fn variant_cmd(v: Variant) -> &'static str {
360    match v {
361        Variant::Normal => "\\mathnormal",
362        Variant::Bold => "\\mathbf",
363        Variant::Blackboard => "\\mathbb",
364        Variant::Calligraphic => "\\mathcal",
365        Variant::Fraktur => "\\mathfrak",
366        Variant::Roman => "\\mathrm",
367        Variant::SansSerif => "\\mathsf",
368        Variant::Typewriter => "\\mathtt",
369        Variant::Text => "\\text",
370        Variant::OperatorName => "\\operatorname",
371    }
372}
373
374fn matrix_env_name(env: MatrixEnv) -> &'static str {
375    match env {
376        MatrixEnv::Matrix => "matrix",
377        MatrixEnv::Pmatrix => "pmatrix",
378        MatrixEnv::Bmatrix => "bmatrix",
379        MatrixEnv::Vmatrix => "vmatrix",
380        MatrixEnv::Cases => "cases",
381        MatrixEnv::Aligned => "aligned",
382        MatrixEnv::Array => "array",
383    }
384}
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389    use crate::model::{Cursor, MathClass, ScriptSlot, Symbol};
390
391    fn atom(c: &str) -> Symbol {
392        Symbol {
393            latex: c.into(),
394            class: MathClass::Ord,
395        }
396    }
397
398    #[test]
399    fn export_fraction_with_byte_spans() {
400        let mut t = Tree::new();
401        let root = t.root();
402        let cnum = t.insert_fraction(Cursor { seq: root, index: 0 }, FracStyle::Bar, None);
403        t.insert_atom(cnum, atom("a"));
404        let frac = t.items(root)[0];
405        let (num, den) = (t.child_seqs(frac)[0], t.child_seqs(frac)[1]);
406        t.insert_atom(Cursor { seq: den, index: 0 }, atom("b"));
407
408        let (s, spans) = t.export_latex();
409        assert_eq!(s, "\\frac{a}{b}");
410        assert_eq!(&s[spans.node[&frac].clone()], "\\frac{a}{b}");
411        assert_eq!(&s[spans.seq[&num].clone()], "a");
412        assert_eq!(&s[spans.seq[&den].clone()], "b");
413    }
414
415    #[test]
416    fn empty_slot_emits_phantom_with_a_span() {
417        let mut t = Tree::new();
418        let root = t.root();
419        t.insert_fraction(Cursor { seq: root, index: 0 }, FracStyle::Bar, None);
420        let frac = t.items(root)[0];
421        let num = t.child_seqs(frac)[0];
422        let (s, spans) = t.export_latex();
423        // Empty fraction slots each emit a phantom box for host placeholders.
424        assert_eq!(s, "\\frac{\\phantom{x}}{\\phantom{x}}");
425        assert_eq!(&s[spans.seq[&num].clone()], "\\phantom{x}");
426    }
427
428    #[test]
429    fn typed_specials_export_escaped() {
430        let mut t = Tree::new();
431        let root = t.root();
432        let mut c = Cursor { seq: root, index: 0 };
433        for ch in "50%&$".chars() {
434            c = t.insert_atom(c, Symbol::from_char(ch));
435        }
436        let (s, _) = t.export_latex();
437        assert_eq!(s, "50\\%\\&\\$");
438    }
439
440    #[test]
441    fn export_script_and_sqrt() {
442        let mut t = Tree::new();
443        let root = t.root();
444        t.insert_atom(Cursor { seq: root, index: 0 }, atom("x"));
445        let c = t.attach_script(Cursor { seq: root, index: 1 }, ScriptSlot::Sup);
446        t.insert_atom(c, atom("2"));
447        let (s, _) = t.export_latex();
448        assert_eq!(s, "x^2");
449    }
450
451    #[test]
452    fn control_word_before_scripted_letter_keeps_separator() {
453        // The space after `\sum` must survive when `x` becomes a script base.
454        let mut t = Tree::new();
455        let root = t.root();
456        let mut c = Cursor { seq: root, index: 0 };
457        c = t.insert_atom(c, atom("\\sum"));
458        c = t.insert_atom(c, atom("x"));
459        let sup = t.attach_script(c, ScriptSlot::Sup);
460        t.insert_atom(sup, atom("2"));
461        let (s, _) = t.export_latex();
462        assert_eq!(s, "\\sum x^2");
463    }
464
465    #[test]
466    fn control_word_before_command_needs_no_separator() {
467        // The following backslash already terminates `\sum`.
468        let mut t = Tree::new();
469        let root = t.root();
470        let c = t.insert_atom(Cursor { seq: root, index: 0 }, atom("\\sum"));
471        let num = t.insert_fraction(c, FracStyle::Bar, None);
472        t.insert_atom(num, atom("a"));
473        let (s, _) = t.export_latex();
474        assert_eq!(s, "\\sum\\frac{a}{\\phantom{x}}");
475    }
476
477    #[test]
478    fn script_multichar_keeps_braces() {
479        let mut t = Tree::new();
480        let root = t.root();
481        t.insert_atom(Cursor { seq: root, index: 0 }, atom("x"));
482        let c = t.attach_script(Cursor { seq: root, index: 1 }, ScriptSlot::Sup);
483        let c = t.insert_atom(c, atom("1"));
484        t.insert_atom(c, atom("2"));
485        let (s, _) = t.export_latex();
486        assert_eq!(s, "x^{12}");
487    }
488}