diff --git a/src/export/site.rs b/src/export/site.rs index a90375d..b396161 100644 --- a/src/export/site.rs +++ b/src/export/site.rs @@ -941,20 +941,29 @@ items: let rec = record(); let frags = solution_fragments(&cat, &rec, &form()).unwrap(); let open = &frags.iter().find(|(id, _)| id == "q-open").unwrap().1; - assert!(open.contains("
"), "model answer shown:\n{open}"); + assert!( + open.contains("
"), + "model answer shown:\n{open}" + ); assert_eq!( open.matches("

").count(), 2, "each explanation paragraph is its own styled

:\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!( open.contains("reads the enthalpy change directly"), "second paragraph present:\n{open}" ); let model_at = open.find("sol-model").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] @@ -1016,4 +1025,4 @@ items: assert!(assets[0].1.contains(".qsol")); assert!(assets[1].1.contains("AES-GCM")); } -} \ No newline at end of file +} diff --git a/src/export/typst/config.rs b/src/export/typst/config.rs index 2c8675c..59aa16c 100644 --- a/src/export/typst/config.rs +++ b/src/export/typst/config.rs @@ -607,6 +607,8 @@ defaults: extra: accent: '#017ab9' font: 'Libertinus Serif' + show-bubbles: true + bubble-radius: '0.42em' variants: exam: diff --git a/src/export/typst/payload.rs b/src/export/typst/payload.rs index 718ffbd..a8ba3fb 100644 --- a/src/export/typst/payload.rs +++ b/src/export/typst/payload.rs @@ -1070,8 +1070,61 @@ fn numeric_map(map: &BTreeMap) -> 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 { + let source = rewrite_latex_math(source); if content { Value::content(source) } else { @@ -1221,3 +1274,17 @@ mod tests { 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"); +} diff --git a/src/export/typst/templates/answer-sheet.typ b/src/export/typst/templates/answer-sheet.typ index 7258c86..be776fd 100644 --- a/src/export/typst/templates/answer-sheet.typ +++ b/src/export/typst/templates/answer-sheet.typ @@ -12,6 +12,8 @@ // 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. +#import "@preview/mitex:0.2.7": mi + // coursebank:begin data #let cb-data = ( course: (code: "COURSE 101", title: "Sample Course", term: "2026s"), diff --git a/src/export/typst/templates/exam.typ b/src/export/typst/templates/exam.typ index bdad42a..fa803ca 100644 --- a/src/export/typst/templates/exam.typ +++ b/src/export/typst/templates/exam.typ @@ -24,6 +24,8 @@ // copy — export the `key` variant instead, or the day you forget an `if` is the // day the class gets the answers. +#import "@preview/mitex:0.2.7": mi + // ───────────────────────────────────────────────────────────────────────────── // Metadata // ───────────────────────────────────────────────────────────────────────────── @@ -52,12 +54,14 @@ // how a course changes the look without editing this file at all. #let extra = cb-meta.at("extra", default: (:)) #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 paper = extra.at("paper", default: "us-letter") #let show-name-block = extra.at("name-block", default: true) #let show-points = extra.at("show-points", default: true) #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 { " · 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 // ───────────────────────────────────────────────────────────────────────────── @@ -154,11 +163,19 @@ block(inset: (left: 1.2em))[ #for opt in q.at("options", default: ()) { - grid( - columns: (1.4em, 1fr), - gutter: 0.2em, - [#(opt.letter + ".")], [#markup(opt.text)], - ) + if show-bubbles { + grid( + columns: (1.6em, 1.4em, 1fr), + 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) } ] diff --git a/src/export/typst/templates/key.typ b/src/export/typst/templates/key.typ index 00c6c26..0770de0 100644 --- a/src/export/typst/templates/key.typ +++ b/src/export/typst/templates/key.typ @@ -13,6 +13,8 @@ // paper's config, and it is why these are two templates rather than one with a // flag. +#import "@preview/mitex:0.2.7": mi + // coursebank:begin data #let cb-data = ( course: (code: "COURSE 101", title: "Sample Course", term: "2026s"), diff --git a/src/util/markup.rs b/src/util/markup.rs index ea99217..d2aa64a 100644 --- a/src/util/markup.rs +++ b/src/util/markup.rs @@ -139,10 +139,25 @@ pub fn to_markdown(src: &str) -> 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 -/// escapes the few characters Typst treats specially in content mode. +/// Outside math this only escapes the characters Typst treats specially in +/// 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 /// @@ -152,17 +167,84 @@ pub fn to_markdown(src: &str) -> String { /// /// Typst content-mode markup. 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()); - for ch in src.trim().chars() { - match ch { - // A bare `@` or `<` starts a Typst reference or label. + let mut i = 0; + while i < chars.len() { + 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(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 { + 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 + out.push('"'); } /// Escapes the five XML-significant characters. @@ -423,3 +505,27 @@ mod tests { 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"); +}