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::{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        }
259        if wrap_math {
260            self.text = true;
261            self.push("}");
262        }
263        let start = self.start_after_sep(start);
264        if let Some(spans) = &mut self.spans {
265            spans.push_node(node, start..self.out.len());
266        }
267        start
268    }
269
270    /// Wraps the base in the under decoration, then the over one, clean export drops empty labels.
271    fn emit_under_over(&mut self, node: NodeId, index: usize, base: SeqId, labels: [(Option<SeqId>, Deco, bool); 2]) {
272        let shown = |ex: &Self, l: Option<SeqId>| l.filter(|&s| ex.placeholders || !ex.tree.is_empty(s));
273        let [over, under] = labels;
274        // Outer wrapper first, so the over decoration encloses the under one.
275        let mut closers: Vec<(Option<SeqId>, Deco, bool)> = Vec::new();
276        for (label, deco, is_over) in [over, under] {
277            if label.is_none() {
278                continue;
279            }
280            let label = shown(self, label);
281            match (deco, label) {
282                (Deco::Brace, _) => self.push(if is_over { "\\overbrace{" } else { "\\underbrace{" }),
283                (_, Some(l)) => {
284                    self.push(if is_over { "\\overset" } else { "\\underset" });
285                    self.emit_braced(node, index, l);
286                    self.push("{");
287                }
288                (_, None) => {}
289            }
290            match deco {
291                Deco::Arrow => self.push(if is_over { "\\overrightarrow{" } else { "\\underrightarrow{" }),
292                Deco::Line => self.push(if is_over { "\\overline{" } else { "\\underline{" }),
293                Deco::None | Deco::Brace => {}
294            }
295            closers.push((label, deco, is_over));
296        }
297        self.emit_slot(node, index, base);
298        for (label, deco, is_over) in closers.into_iter().rev() {
299            if matches!(deco, Deco::Arrow | Deco::Line) {
300                self.push("}");
301            }
302            match (deco, label) {
303                (Deco::Brace, Some(l)) => {
304                    self.push(if is_over { "}^" } else { "}_" });
305                    self.emit_braced(node, index, l);
306                }
307                (Deco::Brace, None) | (_, Some(_)) => self.push("}"),
308                (_, None) => {}
309            }
310        }
311    }
312
313    fn emit_matrix(&mut self, node: NodeId, index: usize, env: MatrixEnv, rows: &[Vec<SeqId>]) {
314        let name = matrix_env_name(env);
315        self.push(&format!("\\begin{{{name}}}"));
316        if env == MatrixEnv::Array {
317            let cols = rows.first().map_or(0, Vec::len);
318            self.push(&format!("{{{}}}", "c".repeat(cols)));
319        }
320        for (ri, row) in rows.iter().enumerate() {
321            if ri > 0 {
322                self.push(" \\\\ ");
323            }
324            for (ci, &cell) in row.iter().enumerate() {
325                if ci > 0 {
326                    self.push(" & ");
327                }
328                self.emit_slot(node, index, cell);
329            }
330        }
331        self.push(&format!("\\end{{{name}}}"));
332    }
333}
334
335/// Whether `s` ends in an unescaped control word such as `\alpha`, which a letter would extend.
336fn ends_in_control_word(s: &str) -> bool {
337    let letters = s.chars().rev().take_while(|c| c.is_alphabetic()).map(char::len_utf8).sum::<usize>();
338    if letters == 0 {
339        return false;
340    }
341    let slashes = s[..s.len() - letters].chars().rev().take_while(|&c| c == '\\').count();
342    slashes % 2 == 1
343}
344
345/// The text mode spelling of an atom's math LaTeX, anything without one goes through `\ensuremath`.
346fn text_latex(latex: &str) -> Cow<'_, str> {
347    let mut chars = latex.chars();
348    if let (Some(c), None) = (chars.next(), chars.next()) {
349        return match c {
350            '_' => Cow::Borrowed("\\_"),
351            '^' => Cow::Borrowed("\\textasciicircum{}"),
352            '~' => Cow::Borrowed("\\textasciitilde{}"),
353            _ => Cow::Borrowed(latex),
354        };
355    }
356    match latex {
357        "\\%" | "\\#" | "\\&" | "\\$" | "\\_" | "\\{" | "\\}" | "\\ " => Cow::Borrowed(latex),
358        "\\sim" => Cow::Borrowed("\\textasciitilde{}"),
359        "\\backslash" => Cow::Borrowed("\\textbackslash{}"),
360        "\\prime" => Cow::Borrowed("'"),
361        _ => match latex.strip_prefix("\\text{").and_then(|l| l.strip_suffix('}')) {
362            Some(inner) => Cow::Owned(inner.to_string()),
363            None => Cow::Owned(format!("\\ensuremath{{{latex}}}")),
364        },
365    }
366}
367
368fn frac_cmd(style: FracStyle) -> &'static str {
369    match style {
370        FracStyle::Bar => "\\frac",
371        FracStyle::Display => "\\dfrac",
372        FracStyle::Text => "\\tfrac",
373        FracStyle::Binom => "\\binom",
374        // amsmath warns about `\atop`, the generalized fraction draws the same thing.
375        FracStyle::Atop => "\\genfrac{}{}{0pt}{}",
376    }
377}
378
379fn delim_tex(c: char) -> &'static str {
380    match c {
381        '(' => "(",
382        ')' => ")",
383        '[' => "[",
384        ']' => "]",
385        '{' => "\\{",
386        '}' => "\\}",
387        '|' => "|",
388        '‖' => "\\|",
389        '/' => "/",
390        '⌈' => "\\lceil",
391        '⌉' => "\\rceil",
392        '⌊' => "\\lfloor",
393        '⌋' => "\\rfloor",
394        '⟨' => "\\langle",
395        '⟩' => "\\rangle",
396        _ => ".",
397    }
398}
399
400fn accent_cmd(mark: Mark) -> &'static str {
401    match mark {
402        Mark::Hat => "\\hat",
403        Mark::Vec => "\\vec",
404        Mark::Bar => "\\bar",
405        Mark::Tilde => "\\tilde",
406        Mark::Dot => "\\dot",
407        Mark::Ddot => "\\ddot",
408        Mark::Widehat => "\\widehat",
409        Mark::Widetilde => "\\widetilde",
410        Mark::Overline => "\\overline",
411        Mark::Underline => "\\underline",
412        Mark::Check => "\\check",
413        Mark::Breve => "\\breve",
414    }
415}
416
417fn variant_cmd(v: Variant) -> &'static str {
418    match v {
419        Variant::Normal => "\\mathnormal",
420        Variant::Bold => "\\mathbf",
421        Variant::Blackboard => "\\mathbb",
422        Variant::Calligraphic => "\\mathcal",
423        Variant::Fraktur => "\\mathfrak",
424        Variant::Roman => "\\mathrm",
425        Variant::SansSerif => "\\mathsf",
426        Variant::Typewriter => "\\mathtt",
427        Variant::Text => "\\text",
428        Variant::OperatorName => "\\operatorname",
429    }
430}
431
432fn matrix_env_name(env: MatrixEnv) -> &'static str {
433    match env {
434        MatrixEnv::Matrix => "matrix",
435        MatrixEnv::Pmatrix => "pmatrix",
436        MatrixEnv::Bmatrix => "bmatrix",
437        MatrixEnv::Vmatrix => "vmatrix",
438        MatrixEnv::Cases => "cases",
439        MatrixEnv::Aligned => "aligned",
440        MatrixEnv::Array => "array",
441    }
442}