Skip to main content

mathtex_editor_core/
export.rs

1//! LaTeX export with byte spans for every node, slot, and caret gap.
2
3use std::borrow::Cow;
4use std::collections::HashMap;
5use std::ops::Range;
6
7use crate::model::{Arrow, Deco, FracStyle, Kind, Mark, MatrixEnv, NodeId, SeqId, SeqRange, Tree, Variant};
8use crate::path::{CaretPath, Step};
9
10/// LaTeX for the typesetter plus the spans that map it back to the document.
11#[derive(Debug, Clone)]
12pub struct Source {
13    /// The LaTeX, which assumes amsmath and a `\hostbox` macro.
14    pub tex: String,
15    /// Byte ranges of every node, slot, and caret gap in `tex`.
16    pub spans: SpanMap,
17    /// The editor revision this source was exported at.
18    pub revision: u64,
19}
20
21impl Source {
22    /// Byte offset into `tex` of a caret gap, `None` when the path is not in this source.
23    pub fn caret_offset(&self, at: &CaretPath) -> Option<usize> {
24        self.spans.gaps.get(&at.steps)?.get(at.index).copied()
25    }
26}
27
28/// Opaque byte ranges from document elements into [`Source::tex`], in export order.
29#[derive(Debug, Clone, Default)]
30pub struct SpanMap {
31    pub(crate) owner: u64,
32    pub(crate) nodes: Vec<(NodeId, Range<usize>)>,
33    pub(crate) seqs: Vec<(SeqId, Range<usize>)>,
34    pub(crate) gaps: HashMap<Vec<Step>, Vec<usize>>,
35}
36
37impl SpanMap {
38    pub(crate) fn push_node(&mut self, id: NodeId, range: Range<usize>) {
39        self.nodes.push((id, range));
40    }
41
42    pub(crate) fn push_seq(&mut self, id: SeqId, range: Range<usize>) {
43        self.seqs.push((id, range));
44    }
45}
46
47/// Placeholder export for typesetting, empty slots become `\phantom{x}` boxes.
48pub(crate) fn source(tree: &Tree, owner: u64, revision: u64, placeholders: bool) -> Source {
49    let mut ex = Exporter::new(tree, placeholders, None);
50    ex.spans = Some(SpanMap { owner, ..SpanMap::default() });
51    ex.emit_seq(tree.root());
52    Source { tex: ex.out, spans: ex.spans.unwrap_or_default(), revision }
53}
54
55/// Clean export of the whole tree without spans.
56pub(crate) fn clean_tex<'a>(tree: &'a Tree, host_box: Option<&'a mut dyn FnMut(u32) -> Option<String>>) -> String {
57    let mut ex = Exporter::new(tree, false, host_box);
58    ex.emit_seq(tree.root());
59    ex.out
60}
61
62/// Clean export of a selected run, wrapped in `\text{}` when it comes from a text slot.
63pub(crate) fn range_tex(tree: &Tree, sel: SeqRange) -> String {
64    let mut ex = Exporter::new(tree, false, None);
65    ex.text = tree.is_text_slot(sel.seq);
66    if ex.text {
67        ex.push("\\text{");
68    }
69    let items = tree.items(sel.seq);
70    let hi = sel.hi().min(items.len());
71    for (i, &n) in items.iter().enumerate().take(hi).skip(sel.lo()) {
72        ex.emit_node(n, i);
73    }
74    if ex.text {
75        ex.push("}");
76    }
77    ex.out
78}
79
80struct Exporter<'a> {
81    tree: &'a Tree,
82    out: String,
83    /// Whether empty slots render as `\phantom{x}` placeholders.
84    placeholders: bool,
85    /// Whether atoms are being written inside `\text{}`.
86    text: bool,
87    spans: Option<SpanMap>,
88    path: Vec<Step>,
89    /// Offsets of separator spaces, so spans can start after them.
90    seps: Vec<usize>,
91    host_box: Option<&'a mut dyn FnMut(u32) -> Option<String>>,
92}
93
94impl<'a> Exporter<'a> {
95    fn new(tree: &'a Tree, placeholders: bool, host_box: Option<&'a mut dyn FnMut(u32) -> Option<String>>) -> Self {
96        Self { tree, out: String::new(), placeholders, text: false, spans: None, path: Vec::new(), seps: Vec::new(), host_box }
97    }
98
99    /// The single separator rule: a control word followed by a letter gets one space between them.
100    fn push(&mut self, s: &str) {
101        if s.starts_with(char::is_alphabetic) && ends_in_control_word(&self.out) {
102            self.seps.push(self.out.len());
103            self.out.push(' ');
104        }
105        self.out.push_str(s);
106    }
107
108    /// A span start recorded before a push, moved past a separator that push inserted.
109    fn start_after_sep(&self, start: usize) -> usize {
110        if self.seps.binary_search(&start).is_ok() { start + 1 } else { start }
111    }
112
113    fn emit_seq(&mut self, seq: SeqId) {
114        let start = self.out.len();
115        let items = self.tree.items(seq);
116        let mut gaps = Vec::with_capacity(items.len() + 1);
117        if items.is_empty() && self.placeholders {
118            self.push("\\phantom{x}");
119        }
120        for (i, &n) in items.iter().enumerate() {
121            gaps.push(self.emit_node(n, i));
122        }
123        let end = self.out.len();
124        let start = self.start_after_sep(start).min(end);
125        gaps.push(if items.is_empty() { start } else { end });
126        if let Some(spans) = &mut self.spans {
127            spans.push_seq(seq, start..end);
128            spans.gaps.insert(self.path.clone(), gaps);
129        }
130    }
131
132    /// Emits the slot `seq` of the node at `index`, tracking the path for gap lookups.
133    fn emit_slot(&mut self, node: NodeId, index: usize, seq: SeqId) {
134        let slot = self.tree.slot_of(node, seq);
135        if let Some(slot) = slot {
136            self.path.push(Step { node: index, slot });
137        }
138        self.emit_seq(seq);
139        if slot.is_some() {
140            self.path.pop();
141        }
142    }
143
144    fn emit_braced(&mut self, node: NodeId, index: usize, seq: SeqId) {
145        self.push("{");
146        self.emit_slot(node, index, seq);
147        self.push("}");
148    }
149
150    /// Script and limit arguments drop their braces only around a single one character atom.
151    fn emit_arg(&mut self, node: NodeId, index: usize, seq: SeqId) {
152        let items = self.tree.items(seq);
153        let bare = items.len() == 1
154            && matches!(self.tree.kind(items[0]), Some(Kind::Atom(s)) if s.latex.chars().count() == 1);
155        if bare { self.emit_slot(node, index, seq) } else { self.emit_braced(node, index, seq) }
156    }
157
158    /// Emits an optional attachment such as `^{..}`, clean export drops it when empty.
159    fn emit_attachment(&mut self, node: NodeId, index: usize, marker: &str, seq: Option<SeqId>) {
160        let Some(seq) = seq else { return };
161        if self.placeholders || !self.tree.is_empty(seq) {
162            self.push(marker);
163            self.emit_arg(node, index, seq);
164        }
165    }
166
167    /// A base can carry scripts bare only as one item that TeX reads as a single atom.
168    fn script_safe_base(&self, seq: SeqId) -> bool {
169        match self.tree.items(seq) {
170            [only] => matches!(
171                self.tree.kind(*only),
172                Some(Kind::Atom(_) | Kind::Frac { .. } | Kind::Sqrt { .. } | Kind::Delim { .. })
173                    | Some(Kind::Accent { .. } | Kind::Styled { .. })
174            ),
175            _ => false,
176        }
177    }
178
179    /// Emits a node and returns where its span starts.
180    fn emit_node(&mut self, node: NodeId, index: usize) -> usize {
181        let start = self.out.len();
182        let Some(kind) = self.tree.kind(node).cloned() else {
183            return start;
184        };
185        // Structures inside a text slot, only reachable from legacy documents, switch back to math.
186        let wrap_math = self.text && !matches!(kind, Kind::Atom(_) | Kind::HostBox { .. });
187        if wrap_math {
188            self.push("\\ensuremath{");
189            self.text = false;
190        }
191        match kind {
192            Kind::Atom(s) => {
193                let latex = if self.text { text_latex(&s.latex) } else { Cow::Borrowed(s.latex.as_str()) };
194                self.push(&latex);
195            }
196            // The editor emits only the token macro, the object's content stays host side.
197            Kind::HostBox { token } => {
198                let content = self.host_box.as_mut().and_then(|f| f(token));
199                match content {
200                    Some(c) => self.push(&c),
201                    None => self.push(&format!("\\hostbox{{{token}}}")),
202                }
203            }
204            Kind::Frac { num, den, style } => {
205                self.push(frac_cmd(style));
206                self.emit_braced(node, index, num);
207                self.emit_braced(node, index, den);
208            }
209            Kind::Script { base, sub, sup } => {
210                if self.script_safe_base(base) {
211                    self.emit_slot(node, index, base);
212                } else {
213                    self.emit_braced(node, index, base);
214                }
215                self.emit_attachment(node, index, "_", sub);
216                self.emit_attachment(node, index, "^", sup);
217            }
218            Kind::BigOp { op, lower, upper } => {
219                self.push(&op.latex);
220                self.emit_attachment(node, index, "_", Some(lower));
221                self.emit_attachment(node, index, "^", Some(upper));
222            }
223            Kind::Sqrt { index: degree, radicand } => {
224                self.push("\\sqrt");
225                if self.placeholders || !self.tree.is_empty(degree) {
226                    self.push("[{");
227                    self.emit_slot(node, index, degree);
228                    self.push("}]");
229                }
230                self.emit_braced(node, index, radicand);
231            }
232            Kind::Delim { open, close, body } => {
233                self.push("\\left");
234                self.push(delim_tex(open));
235                self.emit_slot(node, index, body);
236                self.push("\\right");
237                self.push(delim_tex(close));
238            }
239            Kind::Accent { mark, base } => {
240                self.push(accent_cmd(mark));
241                self.emit_braced(node, index, base);
242            }
243            Kind::UnderOver { base, over, under, over_deco, under_deco } => {
244                self.emit_under_over(node, index, base, [(over, over_deco, true), (under, under_deco, false)]);
245            }
246            Kind::Styled { variant: Variant::Text, content } => {
247                self.push("\\text{");
248                self.text = true;
249                self.emit_slot(node, index, content);
250                self.text = false;
251                self.push("}");
252            }
253            Kind::Styled { variant, content } => {
254                self.push(variant_cmd(variant));
255                self.emit_braced(node, index, content);
256            }
257            Kind::Matrix { env, rows } => self.emit_matrix(node, index, env, &rows),
258            // The colon is a relation so a ratio spaces like one, unlike the punctuation `\colon`.
259            Kind::Ratio { left, right } => {
260                self.emit_braced(node, index, left);
261                self.push("\\mathrel{:}");
262                self.emit_braced(node, index, right);
263            }
264            Kind::EvalBar { body, lower, upper } => {
265                self.push("\\left.");
266                self.emit_slot(node, index, body);
267                self.push("\\right|");
268                self.emit_attachment(node, index, "_", Some(lower));
269                self.emit_attachment(node, index, "^", Some(upper));
270            }
271            Kind::LabeledArrow { arrow, over, under } => {
272                self.push(arrow_cmd(arrow));
273                if self.placeholders || !self.tree.is_empty(under) {
274                    self.push("[{");
275                    self.emit_slot(node, index, under);
276                    self.push("}]");
277                }
278                self.emit_braced(node, index, over);
279            }
280        }
281        if wrap_math {
282            self.text = true;
283            self.push("}");
284        }
285        let start = self.start_after_sep(start);
286        if let Some(spans) = &mut self.spans {
287            spans.push_node(node, start..self.out.len());
288        }
289        start
290    }
291
292    /// Wraps the base in the under decoration, then the over one, clean export drops empty labels.
293    fn emit_under_over(&mut self, node: NodeId, index: usize, base: SeqId, labels: [(Option<SeqId>, Deco, bool); 2]) {
294        let shown = |ex: &Self, l: Option<SeqId>| l.filter(|&s| ex.placeholders || !ex.tree.is_empty(s));
295        let [over, under] = labels;
296        // Outer wrapper first, so the over decoration encloses the under one.
297        let mut closers: Vec<(Option<SeqId>, Deco, bool)> = Vec::new();
298        for (label, deco, is_over) in [over, under] {
299            if label.is_none() {
300                continue;
301            }
302            let label = shown(self, label);
303            match (deco, label) {
304                (Deco::Brace, _) => self.push(if is_over { "\\overbrace{" } else { "\\underbrace{" }),
305                (_, Some(l)) => {
306                    self.push(if is_over { "\\overset" } else { "\\underset" });
307                    self.emit_braced(node, index, l);
308                    self.push("{");
309                }
310                (_, None) => {}
311            }
312            match deco {
313                Deco::Arrow => self.push(if is_over { "\\overrightarrow{" } else { "\\underrightarrow{" }),
314                Deco::Line => self.push(if is_over { "\\overline{" } else { "\\underline{" }),
315                Deco::None | Deco::Brace => {}
316            }
317            closers.push((label, deco, is_over));
318        }
319        self.emit_slot(node, index, base);
320        for (label, deco, is_over) in closers.into_iter().rev() {
321            if matches!(deco, Deco::Arrow | Deco::Line) {
322                self.push("}");
323            }
324            match (deco, label) {
325                (Deco::Brace, Some(l)) => {
326                    self.push(if is_over { "}^" } else { "}_" });
327                    self.emit_braced(node, index, l);
328                }
329                (Deco::Brace, None) | (_, Some(_)) => self.push("}"),
330                (_, None) => {}
331            }
332        }
333    }
334
335    fn emit_matrix(&mut self, node: NodeId, index: usize, env: MatrixEnv, rows: &[Vec<SeqId>]) {
336        let name = matrix_env_name(env);
337        self.push(&format!("\\begin{{{name}}}"));
338        if env == MatrixEnv::Array {
339            let cols = rows.first().map_or(0, Vec::len);
340            self.push(&format!("{{{}}}", "c".repeat(cols)));
341        }
342        for (ri, row) in rows.iter().enumerate() {
343            if ri > 0 {
344                self.push(" \\\\ ");
345            }
346            for (ci, &cell) in row.iter().enumerate() {
347                if ci > 0 {
348                    self.push(" & ");
349                }
350                self.emit_slot(node, index, cell);
351            }
352        }
353        self.push(&format!("\\end{{{name}}}"));
354    }
355}
356
357/// Whether `s` ends in an unescaped control word such as `\alpha`, which a letter would extend.
358fn ends_in_control_word(s: &str) -> bool {
359    let letters = s.chars().rev().take_while(|c| c.is_alphabetic()).map(char::len_utf8).sum::<usize>();
360    if letters == 0 {
361        return false;
362    }
363    let slashes = s[..s.len() - letters].chars().rev().take_while(|&c| c == '\\').count();
364    slashes % 2 == 1
365}
366
367/// The text mode spelling of an atom's math LaTeX, anything without one goes through `\ensuremath`.
368fn text_latex(latex: &str) -> Cow<'_, str> {
369    let mut chars = latex.chars();
370    if let (Some(c), None) = (chars.next(), chars.next()) {
371        return match c {
372            '_' => Cow::Borrowed("\\_"),
373            '^' => Cow::Borrowed("\\textasciicircum{}"),
374            '~' => Cow::Borrowed("\\textasciitilde{}"),
375            _ => Cow::Borrowed(latex),
376        };
377    }
378    match latex {
379        "\\%" | "\\#" | "\\&" | "\\$" | "\\_" | "\\{" | "\\}" | "\\ " => Cow::Borrowed(latex),
380        "\\sim" => Cow::Borrowed("\\textasciitilde{}"),
381        "\\backslash" => Cow::Borrowed("\\textbackslash{}"),
382        "\\prime" => Cow::Borrowed("'"),
383        _ => match latex.strip_prefix("\\text{").and_then(|l| l.strip_suffix('}')) {
384            Some(inner) => Cow::Owned(inner.to_string()),
385            None => Cow::Owned(format!("\\ensuremath{{{latex}}}")),
386        },
387    }
388}
389
390fn frac_cmd(style: FracStyle) -> &'static str {
391    match style {
392        FracStyle::Bar => "\\frac",
393        FracStyle::Display => "\\dfrac",
394        FracStyle::Text => "\\tfrac",
395        FracStyle::Binom => "\\binom",
396        // amsmath warns about `\atop`, the generalized fraction draws the same thing.
397        FracStyle::Atop => "\\genfrac{}{}{0pt}{}",
398    }
399}
400
401fn delim_tex(c: char) -> &'static str {
402    match c {
403        '(' => "(",
404        ')' => ")",
405        '[' => "[",
406        ']' => "]",
407        '{' => "\\{",
408        '}' => "\\}",
409        '|' => "|",
410        '‖' => "\\|",
411        '/' => "/",
412        '⌈' => "\\lceil",
413        '⌉' => "\\rceil",
414        '⌊' => "\\lfloor",
415        '⌋' => "\\rfloor",
416        '⟨' => "\\langle",
417        '⟩' => "\\rangle",
418        _ => ".",
419    }
420}
421
422fn accent_cmd(mark: Mark) -> &'static str {
423    match mark {
424        Mark::Hat => "\\hat",
425        Mark::Vec => "\\vec",
426        Mark::Bar => "\\bar",
427        Mark::Tilde => "\\tilde",
428        Mark::Dot => "\\dot",
429        Mark::Ddot => "\\ddot",
430        Mark::Widehat => "\\widehat",
431        Mark::Widetilde => "\\widetilde",
432        Mark::Overline => "\\overline",
433        Mark::Underline => "\\underline",
434        Mark::Check => "\\check",
435        Mark::Breve => "\\breve",
436        Mark::Ring => "\\mathring",
437    }
438}
439
440fn arrow_cmd(arrow: Arrow) -> &'static str {
441    match arrow {
442        Arrow::Right => "\\xrightarrow",
443        Arrow::Left => "\\xleftarrow",
444    }
445}
446
447fn variant_cmd(v: Variant) -> &'static str {
448    match v {
449        Variant::Normal => "\\mathnormal",
450        Variant::Bold => "\\mathbf",
451        Variant::Blackboard => "\\mathbb",
452        Variant::Calligraphic => "\\mathcal",
453        Variant::Fraktur => "\\mathfrak",
454        Variant::Roman => "\\mathrm",
455        Variant::SansSerif => "\\mathsf",
456        Variant::Typewriter => "\\mathtt",
457        Variant::Text => "\\text",
458        Variant::OperatorName => "\\operatorname",
459    }
460}
461
462fn matrix_env_name(env: MatrixEnv) -> &'static str {
463    match env {
464        MatrixEnv::Matrix => "matrix",
465        MatrixEnv::Pmatrix => "pmatrix",
466        MatrixEnv::Bmatrix => "bmatrix",
467        MatrixEnv::Vmatrix => "vmatrix",
468        MatrixEnv::Cases => "cases",
469        MatrixEnv::Aligned => "aligned",
470        MatrixEnv::Array => "array",
471    }
472}