fix: handle latex equations when rendering typst
This commit is contained in:
+12
-3
@@ -941,20 +941,29 @@ items:
|
|||||||
let rec = record();
|
let rec = record();
|
||||||
let frags = solution_fragments(&cat, &rec, &form()).unwrap();
|
let frags = solution_fragments(&cat, &rec, &form()).unwrap();
|
||||||
let open = &frags.iter().find(|(id, _)| id == "q-open").unwrap().1;
|
let open = &frags.iter().find(|(id, _)| id == "q-open").unwrap().1;
|
||||||
assert!(open.contains("<div class=\"sol-model\">"), "model answer shown:\n{open}");
|
assert!(
|
||||||
|
open.contains("<div class=\"sol-model\">"),
|
||||||
|
"model answer shown:\n{open}"
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
open.matches("<p class=\"sol-explain\">").count(),
|
open.matches("<p class=\"sol-explain\">").count(),
|
||||||
2,
|
2,
|
||||||
"each explanation paragraph is its own styled <p>:\n{open}"
|
"each explanation paragraph is its own styled <p>:\n{open}"
|
||||||
);
|
);
|
||||||
assert!(open.contains("folded into H"), "first paragraph present:\n{open}");
|
assert!(
|
||||||
|
open.contains("folded into H"),
|
||||||
|
"first paragraph present:\n{open}"
|
||||||
|
);
|
||||||
assert!(
|
assert!(
|
||||||
open.contains("reads the enthalpy change directly"),
|
open.contains("reads the enthalpy change directly"),
|
||||||
"second paragraph present:\n{open}"
|
"second paragraph present:\n{open}"
|
||||||
);
|
);
|
||||||
let model_at = open.find("sol-model").unwrap();
|
let model_at = open.find("sol-model").unwrap();
|
||||||
let explain_at = open.find("sol-explain").unwrap();
|
let explain_at = open.find("sol-explain").unwrap();
|
||||||
assert!(model_at < explain_at, "model answer comes before the explanation");
|
assert!(
|
||||||
|
model_at < explain_at,
|
||||||
|
"model answer comes before the explanation"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -607,6 +607,8 @@ defaults:
|
|||||||
extra:
|
extra:
|
||||||
accent: '#017ab9'
|
accent: '#017ab9'
|
||||||
font: 'Libertinus Serif'
|
font: 'Libertinus Serif'
|
||||||
|
show-bubbles: true
|
||||||
|
bubble-radius: '0.42em'
|
||||||
|
|
||||||
variants:
|
variants:
|
||||||
exam:
|
exam:
|
||||||
|
|||||||
@@ -1070,8 +1070,61 @@ fn numeric_map(map: &BTreeMap<String, f64>) -> Value {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Emits authored markup as either a content block or a quoted string.
|
/// Rewrites inline LaTeX math (`$...$`) into a call to mitex's `mi`, so Typst's
|
||||||
|
/// own math grammar never has to parse it. Typst's `\times`, `\Delta`, `\ln` and
|
||||||
|
/// friends are not valid Typst math — a bare backslash escapes the next
|
||||||
|
/// character instead of naming a symbol — which is why equations compile but
|
||||||
|
/// print wrong instead of failing outright. `\$` is left alone, matching LaTeX's
|
||||||
|
/// own convention for a literal dollar sign, and a `$` with no matching close is
|
||||||
|
/// left alone too, rather than swallowing the rest of the field.
|
||||||
|
fn rewrite_latex_math(source: &str) -> String {
|
||||||
|
let chars: Vec<(usize, char)> = source.char_indices().collect();
|
||||||
|
let mut out = String::with_capacity(source.len());
|
||||||
|
let mut i = 0;
|
||||||
|
while i < chars.len() {
|
||||||
|
let (_, c) = chars[i];
|
||||||
|
if c == '\\' && i + 1 < chars.len() {
|
||||||
|
out.push('\\');
|
||||||
|
out.push(chars[i + 1].1);
|
||||||
|
i += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if c != '$' {
|
||||||
|
out.push(c);
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut j = i + 1;
|
||||||
|
let close = loop {
|
||||||
|
if j >= chars.len() {
|
||||||
|
break None;
|
||||||
|
}
|
||||||
|
match chars[j].1 {
|
||||||
|
'\\' => j += 2,
|
||||||
|
'$' => break Some(j),
|
||||||
|
_ => j += 1,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match close {
|
||||||
|
Some(close_idx) => {
|
||||||
|
let start = chars[i + 1].0;
|
||||||
|
let end = chars[close_idx].0;
|
||||||
|
out.push_str("#mi(");
|
||||||
|
out.push_str(&Value::str(&source[start..end]).to_typst(0));
|
||||||
|
out.push(')');
|
||||||
|
i = close_idx + 1;
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
out.push('$');
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
fn markup_value(source: &str, content: bool) -> Value {
|
fn markup_value(source: &str, content: bool) -> Value {
|
||||||
|
let source = rewrite_latex_math(source);
|
||||||
if content {
|
if content {
|
||||||
Value::content(source)
|
Value::content(source)
|
||||||
} else {
|
} else {
|
||||||
@@ -1221,3 +1274,17 @@ mod tests {
|
|||||||
assert!(!balanced("closing ] first"));
|
assert!(!balanced("closing ] first"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn latex_math_becomes_a_mitex_call() {
|
||||||
|
assert_eq!(
|
||||||
|
rewrite_latex_math("angle $\\phi$ (phi)"),
|
||||||
|
"angle #mi(\"\\\\phi\") (phi)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn escaped_and_unmatched_dollar_signs_are_left_alone() {
|
||||||
|
assert_eq!(rewrite_latex_math("costs \\$5 total"), "costs \\$5 total");
|
||||||
|
assert_eq!(rewrite_latex_math("just $5"), "just $5");
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@
|
|||||||
// templates/typst.yaml under `extra` rather than editing the geometry here, and
|
// templates/typst.yaml under `extra` rather than editing the geometry here, and
|
||||||
// check one printed page against your scanner before running a class through it.
|
// check one printed page against your scanner before running a class through it.
|
||||||
|
|
||||||
|
#import "@preview/mitex:0.2.7": mi
|
||||||
|
|
||||||
// coursebank:begin data
|
// coursebank:begin data
|
||||||
#let cb-data = (
|
#let cb-data = (
|
||||||
course: (code: "COURSE 101", title: "Sample Course", term: "2026s"),
|
course: (code: "COURSE 101", title: "Sample Course", term: "2026s"),
|
||||||
|
|||||||
@@ -24,6 +24,8 @@
|
|||||||
// copy — export the `key` variant instead, or the day you forget an `if` is the
|
// copy — export the `key` variant instead, or the day you forget an `if` is the
|
||||||
// day the class gets the answers.
|
// day the class gets the answers.
|
||||||
|
|
||||||
|
#import "@preview/mitex:0.2.7": mi
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
// Metadata
|
// Metadata
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -52,12 +54,14 @@
|
|||||||
// how a course changes the look without editing this file at all.
|
// how a course changes the look without editing this file at all.
|
||||||
#let extra = cb-meta.at("extra", default: (:))
|
#let extra = cb-meta.at("extra", default: (:))
|
||||||
#let accent = rgb(extra.at("accent", default: "#1f4e79"))
|
#let accent = rgb(extra.at("accent", default: "#1f4e79"))
|
||||||
#let body-font = extra.at("font", default: "Libertinus Serif")
|
#let body-font = extra.at("font", default: "Roboto")
|
||||||
#let body-size = eval(extra.at("font-size", default: "11pt"))
|
#let body-size = eval(extra.at("font-size", default: "11pt"))
|
||||||
#let paper = extra.at("paper", default: "us-letter")
|
#let paper = extra.at("paper", default: "us-letter")
|
||||||
#let show-name-block = extra.at("name-block", default: true)
|
#let show-name-block = extra.at("name-block", default: true)
|
||||||
#let show-points = extra.at("show-points", default: true)
|
#let show-points = extra.at("show-points", default: true)
|
||||||
#let page-per-item = extra.at("page-per-item", default: false)
|
#let page-per-item = extra.at("page-per-item", default: false)
|
||||||
|
#let show-bubbles = extra.at("show-bubbles", default: true)
|
||||||
|
#let bubble-radius = eval(extra.at("bubble-radius", default: "0.42em"))
|
||||||
|
|
||||||
#let form-note = if cb-meta.form.at("count", default: 1) > 1 {
|
#let form-note = if cb-meta.form.at("count", default: 1) > 1 {
|
||||||
" · Form " + cb-meta.form.id
|
" · Form " + cb-meta.form.id
|
||||||
@@ -120,6 +124,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An unfilled bubble a student marks by hand. `bubble-radius` is the same knob
|
||||||
|
// the standalone answer sheet reads, so the two stay visually consistent if you
|
||||||
|
// ever generate both.
|
||||||
|
#let bubble() = circle(radius: bubble-radius, stroke: 0.5pt)
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
// The renderer
|
// The renderer
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -154,11 +163,19 @@
|
|||||||
|
|
||||||
block(inset: (left: 1.2em))[
|
block(inset: (left: 1.2em))[
|
||||||
#for opt in q.at("options", default: ()) {
|
#for opt in q.at("options", default: ()) {
|
||||||
grid(
|
if show-bubbles {
|
||||||
columns: (1.4em, 1fr),
|
grid(
|
||||||
gutter: 0.2em,
|
columns: (1.6em, 1.4em, 1fr),
|
||||||
[#(opt.letter + ".")], [#markup(opt.text)],
|
gutter: 0.2em,
|
||||||
)
|
align(horizon)[#bubble()], [#(opt.letter + ".")], [#markup(opt.text)],
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
grid(
|
||||||
|
columns: (1.4em, 1fr),
|
||||||
|
gutter: 0.2em,
|
||||||
|
[#(opt.letter + ".")], [#markup(opt.text)],
|
||||||
|
)
|
||||||
|
}
|
||||||
v(0.15em)
|
v(0.15em)
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -13,6 +13,8 @@
|
|||||||
// paper's config, and it is why these are two templates rather than one with a
|
// paper's config, and it is why these are two templates rather than one with a
|
||||||
// flag.
|
// flag.
|
||||||
|
|
||||||
|
#import "@preview/mitex:0.2.7": mi
|
||||||
|
|
||||||
// coursebank:begin data
|
// coursebank:begin data
|
||||||
#let cb-data = (
|
#let cb-data = (
|
||||||
course: (code: "COURSE 101", title: "Sample Course", term: "2026s"),
|
course: (code: "COURSE 101", title: "Sample Course", term: "2026s"),
|
||||||
|
|||||||
+113
-7
@@ -139,10 +139,25 @@ pub fn to_markdown(src: &str) -> String {
|
|||||||
.to_string()
|
.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Passes authoring markup through for Typst.
|
/// Passes authoring markup through for Typst, translating inline LaTeX math on
|
||||||
|
/// the way.
|
||||||
///
|
///
|
||||||
/// The markup is already a Typst subset, so this only normalizes whitespace and
|
/// Outside math this only escapes the characters Typst treats specially in
|
||||||
/// escapes the few characters Typst treats specially in content mode.
|
/// content mode: a bare `@` or `<` starts a reference or label.
|
||||||
|
///
|
||||||
|
/// Math is different. Authors write ordinary LaTeX between `$...$`, and Typst's
|
||||||
|
/// own math grammar is not LaTeX's — a backslash escapes the next character
|
||||||
|
/// 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
|
||||||
|
/// inside the span, since it reaches Typst as a string argument, not as
|
||||||
|
/// markup — escaping `<` there would corrupt the LaTeX rather than protect
|
||||||
|
/// anything.
|
||||||
|
///
|
||||||
|
/// `\$` is left alone, matching LaTeX's own convention for a literal dollar
|
||||||
|
/// sign. A `$` with no matching close is escaped the same way rather than left
|
||||||
|
/// to open Typst's own math mode on a stray price or a malformed source line.
|
||||||
///
|
///
|
||||||
/// # Arguments
|
/// # Arguments
|
||||||
///
|
///
|
||||||
@@ -152,17 +167,84 @@ pub fn to_markdown(src: &str) -> String {
|
|||||||
///
|
///
|
||||||
/// Typst content-mode markup.
|
/// Typst content-mode markup.
|
||||||
pub fn to_typst(src: &str) -> String {
|
pub fn to_typst(src: &str) -> String {
|
||||||
|
let src = src.trim();
|
||||||
|
let chars: Vec<(usize, char)> = src.char_indices().collect();
|
||||||
let mut out = String::with_capacity(src.len());
|
let mut out = String::with_capacity(src.len());
|
||||||
for ch in src.trim().chars() {
|
let mut i = 0;
|
||||||
match ch {
|
while i < chars.len() {
|
||||||
// A bare `@` or `<` starts a Typst reference or label.
|
let (_, c) = chars[i];
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
if c == '\\' && i + 1 < chars.len() {
|
||||||
|
out.push('\\');
|
||||||
|
out.push(chars[i + 1].1);
|
||||||
|
i += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if c == '$' {
|
||||||
|
match find_math_close(&chars, i) {
|
||||||
|
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(')');
|
||||||
|
i = close + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
out.push_str("\\$");
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match c {
|
||||||
'@' => out.push_str("\\@"),
|
'@' => out.push_str("\\@"),
|
||||||
'<' => out.push_str("\\<"),
|
'<' => out.push_str("\\<"),
|
||||||
'>' => out.push_str("\\>"),
|
'>' => out.push_str("\\>"),
|
||||||
|
_ => out.push(c),
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Finds the index into `chars` of the `$` matching the opener at `open`. A
|
||||||
|
/// backslash-escaped pair is skipped as a unit, so a `\$` inside the math span
|
||||||
|
/// doesn't close it early.
|
||||||
|
fn find_math_close(chars: &[(usize, char)], open: usize) -> Option<usize> {
|
||||||
|
let mut j = open + 1;
|
||||||
|
while j < chars.len() {
|
||||||
|
match chars[j].1 {
|
||||||
|
'\\' if j + 1 < chars.len() => j += 2,
|
||||||
|
'$' => return Some(j),
|
||||||
|
_ => j += 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
fn push_typst_string(out: &mut String, s: &str) {
|
||||||
|
out.push('"');
|
||||||
|
for ch in s.chars() {
|
||||||
|
match ch {
|
||||||
|
'"' => out.push_str("\\\""),
|
||||||
|
'\\' => out.push_str("\\\\"),
|
||||||
|
'\n' => out.push_str("\\n"),
|
||||||
|
'\r' => out.push_str("\\r"),
|
||||||
|
'\t' => out.push_str("\\t"),
|
||||||
_ => out.push(ch),
|
_ => out.push(ch),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
out
|
out.push('"');
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Escapes the five XML-significant characters.
|
/// Escapes the five XML-significant characters.
|
||||||
@@ -423,3 +505,27 @@ mod tests {
|
|||||||
assert_eq!(to_markdown("one\n\ntwo"), "one\n\ntwo");
|
assert_eq!(to_markdown("one\n\ntwo"), "one\n\ntwo");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn latex_math_becomes_a_mitex_call() {
|
||||||
|
assert_eq!(
|
||||||
|
to_typst("angle $\\phi$ (phi)"),
|
||||||
|
"angle #mi(\"\\\\phi\") (phi)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn comparison_operators_inside_math_are_not_escaped() {
|
||||||
|
assert_eq!(to_typst("$\\Delta H < 0$"), "#mi(\"\\\\Delta H < 0\")");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reference_starters_outside_math_are_still_escaped() {
|
||||||
|
assert_eq!(to_typst("see @fig:x and x < y"), "see \\@fig:x and x \\< y");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user