fix: rendering in typst
Pipeline / check (pull_request) Successful in 3m8s
Pipeline / docs (pull_request) Skipped
Pipeline / nightly (pull_request) Skipped
Pipeline / release (pull_request) Skipped

This commit is contained in:
2026-09-22 00:19:40 -04:00
parent 003340dc6f
commit cfe8a3216c
8 changed files with 414 additions and 81 deletions
+301 -4
View File
@@ -150,7 +150,9 @@ pub fn to_markdown(src: &str) -> String {
/// rather than naming a symbol, so `\Delta`, `\times`, `\ln` compile without
/// error and print wrong. Each `$...$` span is instead handed whole to
/// mitex's `mi`, which parses LaTeX grammar on purpose: `$\phi$` becomes
/// `#mi("\\phi")`. The `@`/`<`/`>` escaping above is skipped for anything
/// `#mi("\\phi")`. mhchem's `\ce{...}` is the one exception, and goes to
/// whalogen's `ce` instead, via `push_math_span`. The `@`/`<`/`>` escaping
/// above is skipped for anything
/// inside the span, since it reaches Typst as a string argument, not as
/// markup — escaping `<` there would corrupt the LaTeX rather than protect
/// anything.
@@ -174,6 +176,26 @@ pub fn to_typst(src: &str) -> String {
while i < chars.len() {
let (_, c) = chars[i];
// A `\ce{...}` written outside math is still chemistry and still cannot
// reach Typst as a backslash: `\c` is an escape in content mode. Checked
// before the escaped-pair rule below, which would otherwise copy `\c`
// through and leave `e{...}` as literal text. The cheap two-character
// guard keeps a stem full of `\Delta` and `\times` from rescanning the
// remainder at every backslash.
if c == '\\'
&& chars.get(i + 1).map(|c| c.1) == Some('c')
&& chars.get(i + 2).map(|c| c.1) == Some('e')
{
if let Some(span) = find_ce(&src[chars[i].0..]) {
if span.start == 0 {
let at = chars[i].0;
push_ce(&mut out, &src[at + span.arg.0..at + span.arg.1]);
i = char_index(&chars, at + span.end);
continue;
}
}
}
// An escaped pair is copied verbatim and never reconsidered, so `\$`
// can't be mistaken for the start of math and an `\@`/`\<`/`\>` an
// author already wrote is not escaped a second time.
@@ -189,9 +211,7 @@ pub fn to_typst(src: &str) -> String {
Some(close) => {
let start = chars[i + 1].0;
let end = chars[close].0;
out.push_str("#mi(");
push_typst_string(&mut out, &src[start..end]);
out.push(')');
push_math_span(&mut out, &src[start..end]);
i = close + 1;
continue;
}
@@ -229,6 +249,190 @@ fn find_math_close(chars: &[(usize, char)], open: usize) -> Option<usize> {
None
}
/// The import line a template needs before it can receive a `#ce(...)` call.
///
/// mitex renders the LaTeX in a stem, but it ships no package support, so
/// mhchem's `\ce` is simply an unknown command to it. whalogen is a Typst port
/// of mhchem, and `ce` is the function this module emits chemistry as.
pub const CHEM_IMPORT: &str = "#import \"@preview/whalogen:0.3.0\": ce";
/// Whether `body` needs [`CHEM_IMPORT`] and `template` does not provide it.
///
/// A template is checked for the package name rather than for the exact import
/// line, so a template that pins a different whalogen version, imports the
/// package under an alias, or defines its own `ce` is left alone.
///
/// # Arguments
///
/// * `template` - the template source the body will be injected into.
/// * `body` - the generated Typst source.
///
/// # Returns
///
/// Whether the pair would fail to compile for want of the import.
pub fn needs_chem_import(template: &str, body: &str) -> bool {
body.contains("#ce(") && !template.contains("whalogen") && !template.contains("let ce")
}
/// A `\ce{...}` command located in a source fragment, as byte offsets from the
/// start of that fragment.
struct CeSpan {
/// Offset of the backslash.
start: usize,
/// Offset just past the closing brace.
end: usize,
/// The argument, without its braces.
arg: (usize, usize),
}
/// Finds the first `\ce{...}` in `s`.
///
/// Three details matter, and all three come from the same place: the scan has to
/// agree with what LaTeX itself would read.
///
/// A backslash-escaped pair is skipped as a unit, so the line break `\\`
/// followed by the letters `ce` is not read as the command. `$a \\ ce{x}$` is a
/// break and then literal text; mhchem's own command is a single backslash.
///
/// The name must end at the `e`, so `\cellcolor` and `\century` are left for
/// mitex rather than half-consumed here.
///
/// The argument is matched on brace depth rather than on the first `}`, so
/// `\ce{Fe^{2+}}` keeps its superscript. An unbalanced argument yields `None`:
/// there is nothing safe to convert, and mitex's own error names the line.
fn find_ce(s: &str) -> Option<CeSpan> {
let chars: Vec<(usize, char)> = s.char_indices().collect();
let mut i = 0;
while i < chars.len() {
if chars[i].1 != '\\' {
i += 1;
continue;
}
if matches!(chars.get(i + 1), Some((_, '\\'))) {
i += 2;
continue;
}
let named = chars.get(i + 1).map(|c| c.1) == Some('c')
&& chars.get(i + 2).map(|c| c.1) == Some('e')
&& !matches!(chars.get(i + 3), Some((_, c)) if c.is_ascii_alphabetic());
if !named {
i += 1;
continue;
}
// LaTeX allows whitespace between a control word and its argument.
let mut open = i + 3;
while matches!(chars.get(open), Some((_, c)) if c.is_whitespace()) {
open += 1;
}
if !matches!(chars.get(open), Some((_, '{'))) {
i += 1;
continue;
}
let mut depth = 1usize;
let mut k = open + 1;
while k < chars.len() {
match chars[k].1 {
'\\' => k += 2,
'{' => {
depth += 1;
k += 1;
}
'}' => {
depth -= 1;
if depth == 0 {
return Some(CeSpan {
start: chars[i].0,
end: chars[k].0 + 1,
arg: (chars[open].0 + 1, chars[k].0),
});
}
k += 1;
}
_ => k += 1,
}
}
return None;
}
None
}
/// The index into `chars` of the entry at byte offset `byte`, or the length when
/// the offset is past the end.
fn char_index(chars: &[(usize, char)], byte: usize) -> usize {
chars
.iter()
.position(|(at, _)| *at >= byte)
.unwrap_or(chars.len())
}
/// Emits one `$...$` span as Typst content.
///
/// Most of a span goes to mitex's `mi`, which parses LaTeX on purpose. The
/// exception is mhchem: `\ce{...}` is not a LaTeX primitive but a package
/// command with its own character-level grammar, and mitex implements no
/// packages, so the plugin aborts the whole document with `unknown command:
/// \ce` rather than degrading to something printable. Those runs are handed to
/// whalogen's `ce` instead and the rest of the span still goes to `mi`, so an
/// equation that mixes chemistry with ordinary math renders both.
///
/// A span with no chemistry in it produces exactly one `mi` call, as it always
/// has.
///
/// # Arguments
///
/// * `out` - the buffer to append to.
/// * `latex` - the LaTeX between the dollar signs.
fn push_math_span(out: &mut String, latex: &str) {
if find_ce(latex).is_none() {
push_mi(out, latex);
return;
}
let mut rest = latex;
while let Some(span) = find_ce(rest) {
push_run(out, &rest[..span.start]);
push_ce(out, &rest[span.arg.0..span.arg.1]);
rest = &rest[span.end..];
}
push_run(out, rest);
}
/// Emits a non-chemistry run of a math span, dropping an empty one and keeping a
/// whitespace-only one as the single space it separates two calls with.
fn push_run(out: &mut String, latex: &str) {
if latex.is_empty() {
return;
}
if latex.trim().is_empty() {
out.push(' ');
return;
}
push_mi(out, latex);
}
/// Emits a `#mi(...)` call wrapping LaTeX math.
fn push_mi(out: &mut String, latex: &str) {
out.push_str("#mi(");
push_typst_string(out, latex);
out.push(')');
}
/// Emits a `#ce(...)` call wrapping an mhchem argument.
///
/// The argument is passed through as written. whalogen reads the same formula,
/// charge, bond, and arrow syntax mhchem does, so `H2O`, `<=>`, `[AgCl2]-`, and
/// `->[H2O]` need no translation. Its isotope and oxidation-number spellings do
/// differ (`@Th,227,90@` against mhchem's `^{227}_{90}Th`), and so do `\pu` and
/// `\bond`, which whalogen has no equivalent for. Those print oddly rather than
/// failing the build, so a bank that uses them needs its own pass.
fn push_ce(out: &mut String, argument: &str) {
out.push_str("#ce(");
push_typst_string(out, argument.trim());
out.push(')');
}
/// Writes `s` as a quoted Typst string. Kept local, duplicating the five-case
/// match in `typst::value::write_string`, rather than reaching into the
/// Typst-specific value writer for one small helper.
@@ -529,3 +733,96 @@ fn escaped_and_unmatched_dollar_signs_are_left_or_escaped() {
assert_eq!(to_typst("costs \\$5 total"), "costs \\$5 total");
assert_eq!(to_typst("just $5"), "just \\$5");
}
#[test]
fn mhchem_goes_to_whalogen_rather_than_mitex() {
// The regression: mitex implements no LaTeX packages, so `\ce` reached the
// plugin as an unknown command and failed the whole document rather than
// printing badly.
assert_eq!(to_typst("$\\ce{H2O}$"), "#ce(\"H2O\")");
assert_eq!(
to_typst("water ionizes, $\\ce{H2O <=> H+ + OH-}$, giving"),
"water ionizes, #ce(\"H2O <=> H+ + OH-\"), giving"
);
}
#[test]
fn a_span_can_mix_chemistry_with_ordinary_math() {
// The chemistry leaves the span and the rest of it still reaches mitex, so
// an equation that needs both renders both.
assert_eq!(to_typst("$K_w = \\ce{H2O}$"), "#mi(\"K_w = \")#ce(\"H2O\")");
// The space between the two runs stays inside the `mi` call rather than
// becoming content-mode whitespace, so the spacing is TeX's to decide.
assert_eq!(
to_typst("$\\ce{H2O} \\to \\Delta H$"),
"#ce(\"H2O\")#mi(\" \\\\to \\\\Delta H\")"
);
}
#[test]
fn mhchem_arguments_keep_their_nested_braces() {
// Matched on brace depth, not on the first `}`, or the charge is orphaned
// and the remaining `}` closes the `#ce(` call early.
assert_eq!(to_typst("$\\ce{Fe^{2+}}$"), "#ce(\"Fe^{2+}\")");
assert_eq!(
to_typst("$\\ce{SO4^{2-} + Ba^{2+}}$"),
"#ce(\"SO4^{2-} + Ba^{2+}\")"
);
}
#[test]
fn a_latex_line_break_is_not_read_as_the_chemistry_command() {
// `\\` is an escaped backslash followed by the letters `ce`, not `\ce`.
// Scanning that skips escaped pairs as a unit keeps the two apart; scanning
// that does not would convert a line break into a formula.
assert_eq!(to_typst("$a \\\\ce{x}$"), "#mi(\"a \\\\\\\\ce{x}\")");
}
#[test]
fn commands_that_merely_start_with_ce_are_left_to_mitex() {
// The name has to end at the `e`, or `\cellcolor` is half-consumed and the
// conversion invents a formula out of its argument.
assert_eq!(
to_typst("$\\cellcolor{red} x$"),
"#mi(\"\\\\cellcolor{red} x\")"
);
}
#[test]
fn unbalanced_chemistry_is_left_for_mitex_to_report() {
// Nothing safe to convert. mitex's own error names the file and line, which
// is more useful than a silently truncated formula.
assert_eq!(to_typst("$\\ce{H2O$"), "#mi(\"\\\\ce{H2O\")");
}
#[test]
fn chemistry_outside_math_is_converted_too() {
// A bare `\ce` never reaches mitex at all, and `\c` is an escape in Typst
// content mode, so leaving it alone produces a document that either fails
// or prints the letters.
assert_eq!(
to_typst("the backbone \\ce{-NH} group"),
"the backbone #ce(\"-NH\") group"
);
}
#[test]
fn a_template_without_the_chemistry_import_is_named() {
let body = "#question((stem: [#ce(\"H2O\")]))";
assert!(needs_chem_import(
"#import \"@preview/mitex:0.2.7\": mi",
body
));
// An import of any whalogen version, or a template's own `ce`, is enough.
assert!(!needs_chem_import(CHEM_IMPORT, body));
assert!(!needs_chem_import(
"#import \"@preview/whalogen:0.2.0\": ce as ce",
body
));
assert!(!needs_chem_import("#let ce(f) = f", body));
// No chemistry in the body, nothing to warn about.
assert!(!needs_chem_import(
"#import \"@preview/mitex:0.2.7\": mi",
"#mi(\"x\")"
));
}