Skip to main content

xpict_core/
markup.rs

1//! Chem-label markup → [`ChemGlyph`]s.
2//!
3//! **Internal dialect** (not KaTeX / Markdown / MathML). Port of the Python
4//! `richtext` symbol table + scripts + emphasis, aimed at Liberation Sans
5//! glyph outlines. See [`docs/label-markup.md`](../../../docs/label-markup.md).
6//!
7//! Rules that matter for labels:
8//! - Bare ``_`` is literal outside ``$…$`` (so ``my_name`` stays ``my_name``).
9//! - ``_{…}`` is always subscript; ``_x`` is subscript only inside ``$…$``.
10//! - ``^x`` / ``^{…}`` are always superscript.
11//! - ``\alpha`` etc. expand to Unicode; ``**bold**`` / ``*italic*`` set face.
12
13use crate::font::{ChemGlyph, FaceStyle, ScriptRole};
14
15/// Expand chem-label markup into outlined glyphs.
16///
17/// `base` is the call default face (Regular unless the caller passes Bold);
18/// markup bold/italic OR on top of it.
19pub fn parse_label_markup(raw: &str, base: FaceStyle) -> Vec<ChemGlyph> {
20    let (bold0, italic0) = face_flags(base);
21    let mut out = Vec::new();
22    parse_into(raw, &mut out, bold0, italic0, /*in_math*/ false);
23    out
24}
25
26fn face_flags(style: FaceStyle) -> (bool, bool) {
27    match style {
28        FaceStyle::Regular => (false, false),
29        FaceStyle::Bold => (true, false),
30        FaceStyle::Italic => (false, true),
31        FaceStyle::BoldItalic => (true, true),
32    }
33}
34
35fn push_char(out: &mut Vec<ChemGlyph>, ch: char, role: ScriptRole, bold: bool, italic: bool) {
36    let (ch, role) = match unicode_script_base(ch) {
37        Some((base, mapped)) if role == ScriptRole::Normal => (base, mapped),
38        Some((base, _)) => (base, role),
39        None => (ch, role),
40    };
41    out.push(ChemGlyph {
42        ch,
43        role,
44        face: FaceStyle::from_flags(bold, italic),
45    });
46}
47
48fn parse_into(src: &str, out: &mut Vec<ChemGlyph>, bold: bool, italic: bool, in_math: bool) {
49    let chars: Vec<char> = src.chars().collect();
50    let mut i = 0usize;
51    while i < chars.len() {
52        let c = chars[i];
53
54        // $…$ chem zone — enables bare _ scripts; strips delimiters.
55        if c == '$' {
56            if let Some(j) = find_char(&chars, i + 1, '$') {
57                let inner: String = chars[i + 1..j].iter().collect();
58                parse_into(&inner, out, bold, italic, true);
59                i = j + 1;
60                continue;
61            }
62            push_char(out, c, ScriptRole::Normal, bold, italic);
63            i += 1;
64            continue;
65        }
66
67        // Backslash: escape or symbol / style command.
68        if c == '\\' {
69            if i + 1 >= chars.len() {
70                push_char(out, '\\', ScriptRole::Normal, bold, italic);
71                i += 1;
72                continue;
73            }
74            let nxt = chars[i + 1];
75            if matches!(nxt, '\\' | '{' | '}' | '$' | '*' | '_' | '^') {
76                push_char(out, nxt, ScriptRole::Normal, bold, italic);
77                i += 2;
78                continue;
79            }
80            if nxt.is_ascii_alphabetic() {
81                let (name, after) = read_command_name(&chars, i + 1);
82                if let Some(sym) = lookup_symbol(&name) {
83                    for ch in sym.chars() {
84                        push_char(out, ch, ScriptRole::Normal, bold, italic);
85                    }
86                    i = after;
87                    continue;
88                }
89                if let Some(style_kind) = lookup_style_cmd(&name) {
90                    if after < chars.len() && chars[after] == '{' {
91                        if let Some((body, end)) = read_braced(&chars, after) {
92                            let (nb, ni) = match style_kind {
93                                StyleKind::Bold => (true, italic),
94                                StyleKind::Italic => (bold, true),
95                            };
96                            parse_into(&body, out, nb, ni, in_math);
97                            i = end;
98                            continue;
99                        }
100                    }
101                    // Unknown body — emit literal \name.
102                    push_char(out, '\\', ScriptRole::Normal, bold, italic);
103                    for ch in name.chars() {
104                        push_char(out, ch, ScriptRole::Normal, bold, italic);
105                    }
106                    i = after;
107                    continue;
108                }
109                // Unknown command — keep literal backslash + name.
110                push_char(out, '\\', ScriptRole::Normal, bold, italic);
111                for ch in name.chars() {
112                    push_char(out, ch, ScriptRole::Normal, bold, italic);
113                }
114                i = after;
115                continue;
116            }
117            push_char(out, '\\', ScriptRole::Normal, bold, italic);
118            i += 1;
119            continue;
120        }
121
122        // Markdown **bold**
123        if c == '*' && i + 1 < chars.len() && chars[i + 1] == '*' {
124            if let Some(j) = find_str(&chars, i + 2, &['*', '*']) {
125                let inner: String = chars[i + 2..j].iter().collect();
126                parse_into(&inner, out, true, italic, in_math);
127                i = j + 2;
128                continue;
129            }
130            push_char(out, '*', ScriptRole::Normal, bold, italic);
131            push_char(out, '*', ScriptRole::Normal, bold, italic);
132            i += 2;
133            continue;
134        }
135
136        // Markdown *italic* (single *; not part of **)
137        if c == '*' {
138            if let Some(j) = find_italic_close(&chars, i + 1) {
139                let inner: String = chars[i + 1..j].iter().collect();
140                parse_into(&inner, out, bold, true, in_math);
141                i = j + 1;
142                continue;
143            }
144            push_char(out, '*', ScriptRole::Normal, bold, italic);
145            i += 1;
146            continue;
147        }
148
149        // Scripts: ^ always; _ bare only in math; _{…} always.
150        if c == '^' || c == '_' {
151            let allow_bare = c == '^' || in_math;
152            let role = if c == '_' {
153                ScriptRole::Subscript
154            } else {
155                ScriptRole::Superscript
156            };
157            if i + 1 < chars.len() && chars[i + 1] == '{' {
158                let (body, end) = read_braced(&chars, i + 1).unwrap_or_else(|| (String::new(), i + 1));
159                for ch in body.chars() {
160                    push_char(out, ch, role, bold, italic);
161                }
162                i = end;
163                continue;
164            }
165            if allow_bare && i + 1 < chars.len() {
166                push_char(out, chars[i + 1], role, bold, italic);
167                i += 2;
168                continue;
169            }
170            // Literal underscore / caret.
171            push_char(out, c, ScriptRole::Normal, bold, italic);
172            i += 1;
173            continue;
174        }
175
176        push_char(out, c, ScriptRole::Normal, bold, italic);
177        i += 1;
178    }
179}
180
181#[derive(Clone, Copy)]
182enum StyleKind {
183    Bold,
184    Italic,
185}
186
187fn lookup_style_cmd(name: &str) -> Option<StyleKind> {
188    match name {
189        "textbf" | "mathbf" | "bf" => Some(StyleKind::Bold),
190        "textit" | "mathit" | "it" | "emph" => Some(StyleKind::Italic),
191        _ => None,
192    }
193}
194
195fn lookup_symbol(name: &str) -> Option<&'static str> {
196    // Longest-match is handled by read_command_name callers via longest known prefix.
197    SYMBOLS.iter().find(|(n, _)| *n == name).map(|(_, u)| *u)
198}
199
200fn read_command_name(chars: &[char], start: usize) -> (String, usize) {
201    let mut j = start;
202    while j < chars.len() && chars[j].is_ascii_alphabetic() {
203        j += 1;
204    }
205    let raw: String = chars[start..j].iter().collect();
206    // Longest match against known symbols / style cmds.
207    let mut name = raw.clone();
208    let mut end = j;
209    while !name.is_empty()
210        && lookup_symbol(&name).is_none()
211        && lookup_style_cmd(&name).is_none()
212    {
213        name.pop();
214        end -= 1;
215    }
216    if name.is_empty() {
217        (raw, j)
218    } else {
219        (name, end)
220    }
221}
222
223fn read_braced(chars: &[char], open_idx: usize) -> Option<(String, usize)> {
224    if open_idx >= chars.len() || chars[open_idx] != '{' {
225        return None;
226    }
227    let mut depth = 0i32;
228    let mut j = open_idx;
229    while j < chars.len() {
230        match chars[j] {
231            '{' => depth += 1,
232            '}' => {
233                depth -= 1;
234                if depth == 0 {
235                    let body: String = chars[open_idx + 1..j].iter().collect();
236                    return Some((body, j + 1));
237                }
238            }
239            _ => {}
240        }
241        j += 1;
242    }
243    None
244}
245
246fn find_char(chars: &[char], from: usize, target: char) -> Option<usize> {
247    (from..chars.len()).find(|&i| chars[i] == target)
248}
249
250fn find_str(chars: &[char], from: usize, pat: &[char]) -> Option<usize> {
251    let n = pat.len();
252    if n == 0 || from + n > chars.len() {
253        return None;
254    }
255    (from..=chars.len() - n).find(|&i| chars[i..i + n] == *pat)
256}
257
258fn find_italic_close(chars: &[char], from: usize) -> Option<usize> {
259    let mut j = from;
260    while j < chars.len() {
261        if chars[j] == '\\' {
262            j += 2;
263            continue;
264        }
265        if chars[j] == '*' {
266            // Don't close on **
267            if j + 1 < chars.len() && chars[j + 1] == '*' {
268                j += 2;
269                continue;
270            }
271            return Some(j);
272        }
273        j += 1;
274    }
275    None
276}
277
278fn unicode_script_base(ch: char) -> Option<(char, ScriptRole)> {
279    let sub = match ch {
280        '₀' => '0',
281        '₁' => '1',
282        '₂' => '2',
283        '₃' => '3',
284        '₄' => '4',
285        '₅' => '5',
286        '₆' => '6',
287        '₇' => '7',
288        '₈' => '8',
289        '₉' => '9',
290        _ => '\0',
291    };
292    if sub != '\0' {
293        return Some((sub, ScriptRole::Subscript));
294    }
295    let sup = match ch {
296        '⁰' => '0',
297        '¹' => '1',
298        '²' => '2',
299        '³' => '3',
300        '⁴' => '4',
301        '⁵' => '5',
302        '⁶' => '6',
303        '⁷' => '7',
304        '⁸' => '8',
305        '⁹' => '9',
306        '⁺' => '+',
307        '⁻' => '-',
308        _ => '\0',
309    };
310    if sup != '\0' {
311        return Some((sup, ScriptRole::Superscript));
312    }
313    None
314}
315
316// TeX-ish name → Unicode (Liberation Sans coverage). Same set as Python richtext.
317const SYMBOLS: &[(&str, &str)] = &[
318    ("alpha", "α"),
319    ("beta", "β"),
320    ("gamma", "γ"),
321    ("delta", "δ"),
322    ("epsilon", "ε"),
323    ("varepsilon", "ε"),
324    ("zeta", "ζ"),
325    ("eta", "η"),
326    ("theta", "θ"),
327    ("vartheta", "ϑ"),
328    ("iota", "ι"),
329    ("kappa", "κ"),
330    ("lambda", "λ"),
331    ("mu", "μ"),
332    ("nu", "ν"),
333    ("xi", "ξ"),
334    ("omicron", "ο"),
335    ("pi", "π"),
336    ("varpi", "ϖ"),
337    ("rho", "ρ"),
338    ("varrho", "ϱ"),
339    ("sigma", "σ"),
340    ("varsigma", "ς"),
341    ("tau", "τ"),
342    ("upsilon", "υ"),
343    ("phi", "φ"),
344    ("varphi", "ϕ"),
345    ("chi", "χ"),
346    ("psi", "ψ"),
347    ("omega", "ω"),
348    ("Alpha", "Α"),
349    ("Beta", "Β"),
350    ("Gamma", "Γ"),
351    ("Delta", "Δ"),
352    ("Epsilon", "Ε"),
353    ("Zeta", "Ζ"),
354    ("Eta", "Η"),
355    ("Theta", "Θ"),
356    ("Iota", "Ι"),
357    ("Kappa", "Κ"),
358    ("Lambda", "Λ"),
359    ("Mu", "Μ"),
360    ("Nu", "Ν"),
361    ("Xi", "Ξ"),
362    ("Omicron", "Ο"),
363    ("Pi", "Π"),
364    ("Rho", "Ρ"),
365    ("Sigma", "Σ"),
366    ("Tau", "Τ"),
367    ("Upsilon", "Υ"),
368    ("Phi", "Φ"),
369    ("Chi", "Χ"),
370    ("Psi", "Ψ"),
371    ("Omega", "Ω"),
372    ("degree", "°"),
373    ("circ", "°"),
374    ("pm", "±"),
375    ("mp", "∓"),
376    ("times", "×"),
377    ("cdot", "·"),
378    ("ast", "∗"),
379    ("dagger", "†"),
380    ("ddagger", "‡"),
381    ("prime", "′"),
382    ("infty", "∞"),
383    ("approx", "≈"),
384    ("neq", "≠"),
385    ("ne", "≠"),
386    ("leq", "≤"),
387    ("le", "≤"),
388    ("geq", "≥"),
389    ("ge", "≥"),
390    ("rightarrow", "→"),
391    ("to", "→"),
392    ("leftarrow", "←"),
393    ("leftrightarrow", "↔"),
394    ("uparrow", "↑"),
395    ("downarrow", "↓"),
396    ("micro", "µ"),
397    ("AA", "Å"),
398    ("angstrom", "Å"),
399];
400
401#[cfg(test)]
402mod tests {
403    use super::*;
404
405    fn text_roles(raw: &str) -> Vec<(char, ScriptRole, FaceStyle)> {
406        parse_label_markup(raw, FaceStyle::Regular)
407            .into_iter()
408            .map(|g| (g.ch, g.role, g.face))
409            .collect()
410    }
411
412    #[test]
413    fn my_name_literal_underscore() {
414        let g = parse_label_markup("my_name", FaceStyle::Regular);
415        assert_eq!(g.len(), 7);
416        assert!(g.iter().all(|x| x.role == ScriptRole::Normal));
417        assert_eq!(g.iter().map(|x| x.ch).collect::<String>(), "my_name");
418    }
419
420    #[test]
421    fn bare_r_1_literal_dollar_r_1_subscript() {
422        let bare = parse_label_markup("R_1", FaceStyle::Regular);
423        assert_eq!(bare.iter().map(|x| x.ch).collect::<String>(), "R_1");
424        assert_eq!(bare[1].role, ScriptRole::Normal);
425
426        let math = parse_label_markup("$R_1$", FaceStyle::Regular);
427        assert_eq!(math.len(), 2);
428        assert_eq!(math[0].ch, 'R');
429        assert_eq!(math[1].ch, '1');
430        assert_eq!(math[1].role, ScriptRole::Subscript);
431    }
432
433    #[test]
434    fn braced_sub_outside_math() {
435        let g = parse_label_markup("H_{2}", FaceStyle::Regular);
436        assert_eq!(g.len(), 2);
437        assert_eq!(g[1].role, ScriptRole::Subscript);
438        assert_eq!(g[1].ch, '2');
439    }
440
441    #[test]
442    fn caret_super_bare() {
443        let g = parse_label_markup("R^2", FaceStyle::Regular);
444        assert_eq!(g[1].role, ScriptRole::Superscript);
445        assert_eq!(g[1].ch, '2');
446    }
447
448    #[test]
449    fn greek_and_delta() {
450        let g = parse_label_markup(r"\alpha-\beta", FaceStyle::Regular);
451        assert_eq!(g.iter().map(|x| x.ch).collect::<String>(), "α-β");
452        let d = parse_label_markup(r"\Delta\DeltaG", FaceStyle::Regular);
453        assert_eq!(d.iter().map(|x| x.ch).collect::<String>(), "ΔΔG");
454    }
455
456    #[test]
457    fn markdown_bold_italic() {
458        let g = parse_label_markup("**Et**OH", FaceStyle::Regular);
459        assert_eq!(g[0].face, FaceStyle::Bold);
460        assert_eq!(g[1].face, FaceStyle::Bold);
461        assert_eq!(g[2].face, FaceStyle::Regular);
462        let it = parse_label_markup("*cis*", FaceStyle::Regular);
463        assert!(it.iter().all(|x| x.face == FaceStyle::Italic));
464    }
465
466    #[test]
467    fn math_with_alpha_sub() {
468        let g = parse_label_markup(r"$\alpha_D$", FaceStyle::Regular);
469        assert_eq!(g[0].ch, 'α');
470        assert_eq!(g[1].ch, 'D');
471        assert_eq!(g[1].role, ScriptRole::Subscript);
472    }
473
474    #[test]
475    fn base_face_or() {
476        let g = parse_label_markup("*cis*", FaceStyle::Bold);
477        assert!(g.iter().all(|x| x.face == FaceStyle::BoldItalic));
478    }
479
480    #[test]
481    fn roles_smoke() {
482        let t = text_roles("$R^{2+}$");
483        assert_eq!(t[0].1, ScriptRole::Normal);
484        assert_eq!(t[1].1, ScriptRole::Superscript);
485        assert_eq!(t[2].1, ScriptRole::Superscript);
486    }
487
488    #[test]
489    fn face_flags_all_variants() {
490        assert_eq!(face_flags(FaceStyle::Italic), (false, true));
491        assert_eq!(face_flags(FaceStyle::BoldItalic), (true, true));
492        let g = parse_label_markup("A", FaceStyle::Italic);
493        assert_eq!(g[0].face, FaceStyle::Italic);
494    }
495
496    #[test]
497    fn unclosed_dollar_is_literal() {
498        let g = parse_label_markup("$R_1", FaceStyle::Regular);
499        assert_eq!(g.iter().map(|x| x.ch).collect::<String>(), "$R_1");
500    }
501
502    #[test]
503    fn backslash_escapes_and_trailing() {
504        let g = parse_label_markup(r"\_\{ \} \$ \* \\", FaceStyle::Regular);
505        let s: String = g.iter().map(|x| x.ch).collect();
506        assert!(s.contains('_'));
507        assert!(s.contains('{'));
508        assert!(s.contains('\\'));
509        let trail = parse_label_markup(r"x\", FaceStyle::Regular);
510        assert_eq!(trail.last().unwrap().ch, '\\');
511        let lone = parse_label_markup(r"\1", FaceStyle::Regular);
512        assert_eq!(lone[0].ch, '\\');
513        assert_eq!(lone[1].ch, '1');
514    }
515
516    #[test]
517    fn tex_style_commands() {
518        let g = parse_label_markup(r"\textbf{Et}OH", FaceStyle::Regular);
519        assert_eq!(g[0].face, FaceStyle::Bold);
520        assert_eq!(g[1].face, FaceStyle::Bold);
521        assert_eq!(g[2].face, FaceStyle::Regular);
522        let it = parse_label_markup(r"\textit{cis}", FaceStyle::Regular);
523        assert!(it.iter().all(|x| x.face == FaceStyle::Italic));
524        let no_brace = parse_label_markup(r"\textbf x", FaceStyle::Regular);
525        assert_eq!(no_brace[0].ch, '\\');
526        assert!(no_brace.iter().any(|x| x.ch == 'b'));
527    }
528
529    #[test]
530    fn unknown_command_kept_literal() {
531        let g = parse_label_markup(r"\notasymbol", FaceStyle::Regular);
532        assert_eq!(g[0].ch, '\\');
533        assert_eq!(g.iter().map(|x| x.ch).collect::<String>(), r"\notasymbol");
534    }
535
536    #[test]
537    fn unclosed_markdown_bold_literal() {
538        let g = parse_label_markup("**Et", FaceStyle::Regular);
539        assert_eq!(g.iter().map(|x| x.ch).collect::<String>(), "**Et");
540    }
541
542    #[test]
543    fn italic_close_skips_escape_and_double_star() {
544        let g = parse_label_markup(r"*a\*b**c*", FaceStyle::Regular);
545        assert!(g.iter().any(|x| x.face == FaceStyle::Italic));
546        let s: String = g.iter().map(|x| x.ch).collect();
547        assert!(s.contains('a'));
548        assert!(s.contains('c') || s.contains('b'));
549    }
550
551    #[test]
552    fn unicode_script_digits_normalize() {
553        let sub = parse_label_markup("H₂O", FaceStyle::Regular);
554        assert_eq!(sub[1].ch, '2');
555        assert_eq!(sub[1].role, ScriptRole::Subscript);
556        let sup = parse_label_markup("R²⁺", FaceStyle::Regular);
557        assert_eq!(sup[1].ch, '2');
558        assert_eq!(sup[1].role, ScriptRole::Superscript);
559        assert_eq!(sup[2].ch, '+');
560        assert_eq!(sup[2].role, ScriptRole::Superscript);
561        // Already in a script role: keep caller role, still map base char.
562        let nested = parse_label_markup("R^{²}", FaceStyle::Regular);
563        assert_eq!(nested[1].ch, '2');
564        assert_eq!(nested[1].role, ScriptRole::Superscript);
565    }
566
567    #[test]
568    fn unclosed_braced_script() {
569        let g = parse_label_markup("H_{2", FaceStyle::Regular);
570        // Unclosed brace → empty body or literal; must not panic.
571        assert!(!g.is_empty());
572    }
573}