diff --git a/Cargo.lock b/Cargo.lock index c8f0b7a8d..dae87edf0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6017,6 +6017,7 @@ dependencies = [ "quarto-error-catalog", "quarto-error-reporting", "quarto-highlight", + "quarto-math", "quarto-navigation", "quarto-ooxml-extract", "quarto-pandoc-types", diff --git a/claude-notes/plans/2026-09-23-merge-math-stack.md b/claude-notes/plans/2026-09-23-merge-math-stack.md new file mode 100644 index 000000000..9e02225b3 --- /dev/null +++ b/claude-notes/plans/2026-09-23-merge-math-stack.md @@ -0,0 +1,65 @@ +# Merging the math stack (PRs 705, 706, 708, 709, 710) onto main after #704 + +## Overview + +PR #704 (Pandoc-hybrid docx/pptx/epub/typst) landed on main on 2026-09-21. +The math stack — `Math.text_source` (#705), `quarto-math` (#706), the +equation-numbering stage (#708), the MathML writer (#709) and the +`MathMlStage` (#710) — was branched before it. This plan records the +conflict assessment (2026-09-23) and tracks the merge, one PR at a time. + +Parent plans: `2026-09-21-quarto-math-and-native-docx.md`, +`2026-09-21-equation-numbering-and-mathml.md`. + +## Assessment (2026-09-23, trial merges against origin/main 6d648d92) + +- **#705**: conflicts only in two regenerable artifacts + (`crates/pampa/snapshots/json/math-with-attr.snap`, + `ts-packages/annotated-qmd/examples/academic-paper.json`). Rust + auto-merges and compiles; pampa/quarto-core/pandoc-types tests pass after + regeneration. Pandoc 3.11 ignores the new `textS` sidecar on the + Pandoc leg. docx/typst output of a labelled equation unchanged. +- **#706**: `Cargo.lock` (both sides add packages; refresh from main), + `error_catalog.json` (main added Q-20/Q-21, 706 adds Q-22; union), + `docs/_quarto.yml` (errors sidebar: `pandoc`/`typst` vs `math` sections; + union). Lint passes on the union. +- **#708**: `tests/integration/main.rs` module list; and once #705 is on + main, `crossref_render.rs` (705 extended `text_source` in + `render_equation`, 708 rewrote it). #710 already carries the + resolution (`append_to_tex` in `equation_number.rs`) — take 710's + `crossref_render.rs`. docx/typst equation numbers are unaffected: + pandoc drops q2's `\tag{N}` anyway, and the number comes from the + vendored Quarto 1 `crossref/equations.lua` (`\qquad(N)`). Verified by + rendering before/after with the real binary. +- **#709**: plan-doc checkbox conflict only. +- **#710**: one real regression against #704 — `MathMlStage` gates on + `html-math-method` only, not on the format, and is not on + `PANDOC_STAGE_EXCLUDED`. A document with `html-math-method: mathml` + rendered `--to docx` converts its math to `RawInline` before the + Pandoc leg, and the vendored `equations.lua` crashes + (`attempt to concatenate a nil value (field 'text')`, pandoc exit 83, + Q-20-3). Decision (user, 2026-09-23): **ignore the option silently on + non-HTML formats**, matching `EquationNumberStage`'s `Writer` no-op and + Quarto 1 (which forwards the key to pandoc, whose non-HTML writers + ignore it). No warning: the `html-` prefix is what makes the key safe + in shared metadata for multi-format projects. + +## Merge order and checklist + +- [ ] **#705** — merge main, regenerate the two artifacts, tests, push, + mark ready, merge when CI is green. +- [ ] **#706** — retarget to main, merge main, union-resolve catalog + + sidebar, refresh `Cargo.lock`, build + tests + lint, push, merge. +- [ ] **#708** — merge main, take #710's `crossref_render.rs`, fix the + module list, build + tests, push, merge. +- [ ] **#709** — retarget to main, merge main, resolve plan doc, tests, + push, merge. +- [ ] **#710** — retarget to main, merge main; gate `MathMlStage` on + `is_html_based()`, add `math-ml` to `PANDOC_STAGE_EXCLUDED`, update + `t6_2_pandoc_stage_list_produces_exact_surviving_name_list`, add an + end-to-end test (mathml-method document `--to docx` keeps its OMML + equation); build + tests; push; merge. + +## Verification log + +(filled in as each PR lands) diff --git a/crates/quarto-core/Cargo.toml b/crates/quarto-core/Cargo.toml index 7baf70c7a..1f045ef02 100644 --- a/crates/quarto-core/Cargo.toml +++ b/crates/quarto-core/Cargo.toml @@ -61,6 +61,8 @@ pampa = { workspace = true, features = ["lua-filter", "json-filter"] } # `quarto-highlight` crate itself to avoid compiling wasmtime for the # wasm32-unknown-unknown target. quarto-highlight.workspace = true +# `MathMlStage` (html-math-method: mathml) converts math at render time. +quarto-math = { path = "../quarto-math" } # Used by the listings module's `b64_encode_unicode` helper (mirroring # Q1's `b64EncodeUnicode` for the categories click handler). Available diff --git a/crates/quarto-core/src/ast_walk.rs b/crates/quarto-core/src/ast_walk.rs new file mode 100644 index 000000000..30b9475cb --- /dev/null +++ b/crates/quarto-core/src/ast_walk.rs @@ -0,0 +1,151 @@ +/* + * ast_walk.rs + * Copyright (c) 2026 Posit, PBC + */ + +//! Mutable traversal of every inline in a document. +//! +//! Several post-filter stages need to visit each `Inline` wherever it +//! sits — paragraph, header, list item, table cell, caption, footnote, or +//! a custom node's slots — and either edit it in place or replace it. +//! This is that walk, written once. It is pre-order: `f` sees a node +//! before the walk descends into the node's own content, so a +//! replacement's children are visited too. + +use quarto_pandoc_types::block::Block; +use quarto_pandoc_types::custom::Slot; +use quarto_pandoc_types::inline::{Inline, Inlines}; + +/// Call `f` on every inline under `blocks`, in document order. +pub fn for_each_inline_mut(blocks: &mut [Block], f: &mut dyn FnMut(&mut Inline)) { + for block in blocks.iter_mut() { + visit_block(block, f); + } +} + +fn visit_block(block: &mut Block, f: &mut dyn FnMut(&mut Inline)) { + match block { + Block::Plain(p) => visit_inlines(&mut p.content, f), + Block::Paragraph(p) => visit_inlines(&mut p.content, f), + Block::LineBlock(lb) => { + for line in lb.content.iter_mut() { + visit_inlines(line, f); + } + } + Block::BlockQuote(bq) => for_each_inline_mut(&mut bq.content, f), + Block::OrderedList(ol) => { + for item in ol.content.iter_mut() { + for_each_inline_mut(item, f); + } + } + Block::BulletList(bl) => { + for item in bl.content.iter_mut() { + for_each_inline_mut(item, f); + } + } + Block::DefinitionList(dl) => { + for (term, defs) in dl.content.iter_mut() { + visit_inlines(term, f); + for def in defs.iter_mut() { + for_each_inline_mut(def, f); + } + } + } + Block::Header(h) => visit_inlines(&mut h.content, f), + Block::Div(d) => for_each_inline_mut(&mut d.content, f), + Block::Figure(fig) => { + if let Some(short) = fig.caption.short.as_mut() { + visit_inlines(short, f); + } + if let Some(long) = fig.caption.long.as_mut() { + for_each_inline_mut(long, f); + } + for_each_inline_mut(&mut fig.content, f); + } + Block::Table(t) => { + if let Some(short) = t.caption.short.as_mut() { + visit_inlines(short, f); + } + if let Some(long) = t.caption.long.as_mut() { + for_each_inline_mut(long, f); + } + for row in t.head.rows.iter_mut().chain(t.foot.rows.iter_mut()) { + for cell in row.cells.iter_mut() { + for_each_inline_mut(&mut cell.content, f); + } + } + for body in t.bodies.iter_mut() { + for row in body.head.iter_mut().chain(body.body.iter_mut()) { + for cell in row.cells.iter_mut() { + for_each_inline_mut(&mut cell.content, f); + } + } + } + } + Block::CaptionBlock(cb) => visit_inlines(&mut cb.content, f), + Block::Custom(c) => { + for (_name, slot) in c.slots.iter_mut() { + visit_slot(slot, f); + } + } + Block::CodeBlock(_) + | Block::RawBlock(_) + | Block::HorizontalRule(_) + | Block::BlockMetadata(_) + | Block::NoteDefinitionPara(_) + | Block::NoteDefinitionFencedBlock(_) => {} + } +} + +fn visit_inlines(inlines: &mut Inlines, f: &mut dyn FnMut(&mut Inline)) { + for inline in inlines.iter_mut() { + visit_inline(inline, f); + } +} + +fn visit_inline(inline: &mut Inline, f: &mut dyn FnMut(&mut Inline)) { + f(inline); + match inline { + Inline::Emph(e) => visit_inlines(&mut e.content, f), + Inline::Underline(u) => visit_inlines(&mut u.content, f), + Inline::Strong(s) => visit_inlines(&mut s.content, f), + Inline::Strikeout(s) => visit_inlines(&mut s.content, f), + Inline::Superscript(s) => visit_inlines(&mut s.content, f), + Inline::Subscript(s) => visit_inlines(&mut s.content, f), + Inline::SmallCaps(s) => visit_inlines(&mut s.content, f), + Inline::Quoted(q) => visit_inlines(&mut q.content, f), + Inline::Link(l) => visit_inlines(&mut l.content, f), + Inline::Image(i) => visit_inlines(&mut i.content, f), + Inline::Span(s) => visit_inlines(&mut s.content, f), + Inline::Note(n) => for_each_inline_mut(&mut n.content, f), + Inline::Insert(i) => visit_inlines(&mut i.content, f), + Inline::Delete(d) => visit_inlines(&mut d.content, f), + Inline::Highlight(h) => visit_inlines(&mut h.content, f), + Inline::Custom(c) => { + for (_name, slot) in c.slots.iter_mut() { + visit_slot(slot, f); + } + } + Inline::Str(_) + | Inline::Cite(_) + | Inline::Code(_) + | Inline::Space(_) + | Inline::SoftBreak(_) + | Inline::LineBreak(_) + | Inline::Math(_) + | Inline::RawInline(_) + | Inline::Shortcode(_) + | Inline::NoteReference(_) + | Inline::Attr(_) + | Inline::EditComment(_) => {} + } +} + +fn visit_slot(slot: &mut Slot, f: &mut dyn FnMut(&mut Inline)) { + match slot { + Slot::Block(b) => visit_block(b, f), + Slot::Blocks(bs) => for_each_inline_mut(bs, f), + Slot::Inline(i) => visit_inline(i, f), + Slot::Inlines(is) => visit_inlines(is, f), + } +} diff --git a/crates/quarto-core/src/lib.rs b/crates/quarto-core/src/lib.rs index d88b1ad7c..bd88b863c 100644 --- a/crates/quarto-core/src/lib.rs +++ b/crates/quarto-core/src/lib.rs @@ -38,6 +38,7 @@ pub mod artifact; pub mod artifact_flush; +pub mod ast_walk; pub mod attribution; pub mod brand_fonts; pub mod cell_options; diff --git a/crates/quarto-core/src/pipeline.rs b/crates/quarto-core/src/pipeline.rs index 53d2d5221..b7fdd8670 100644 --- a/crates/quarto-core/src/pipeline.rs +++ b/crates/quarto-core/src/pipeline.rs @@ -66,8 +66,8 @@ use crate::stage::{ ApplyTemplateStage, AstTransformsStage, AttributionGenerateStage, CompileThemeCssStage, DocumentProfileStage, EngineExecutionStage, EquationNumberStage, IncludeExpansionStage, IncludeResolveStage, LanguageResolveStage, LinkResolutionStage, ListingItemInfoStage, - LoadedSource, MathJsStage, MetadataMergeStage, ParseDocumentStage, Pipeline, PipelineData, - PipelineStage, PreEngineSugaringStage, RenderHtmlBodyStage, ResourceReportStage, + LoadedSource, MathJsStage, MathMlStage, MetadataMergeStage, ParseDocumentStage, Pipeline, + PipelineData, PipelineStage, PreEngineSugaringStage, RenderHtmlBodyStage, ResourceReportStage, SourceConversionStage, StageContext, UnwrapProfileStage, UserFiltersStage, }; use crate::transform::TransformPipeline; @@ -376,6 +376,12 @@ pub fn build_html_pipeline_stages_with_options( // and this stage honours the result. A no-op in q2-preview, where // crossref-render is excluded and `Equation.tsx` numbers client-side. stages.push(Box::new(EquationNumberStage::new())); + // Native MathML (bd-3evfzwal): under `html-math-method: mathml`, + // convert every `Inline::Math` to `` with quarto-math. Runs + // after equation-number (the number is already a sibling label, so + // the TeX is the author's) and before math-js, which loads MathJax + // only for the expressions this stage had to leave as TeX. + stages.push(Box::new(MathMlStage::new())); stages.push(Box::new(CodeHighlightStage::new())); // Math-mode (bd-w5ov): walk the post-transform AST and, when math // is present, populate `meta.math` with the engine's config + loader @@ -453,7 +459,8 @@ pub async fn render_qmd_to_pandoc( /// (`q2_preview_stage_excluded_names_exist_in_html_pipeline`) /// fails the test suite if any name here is not an actual stage in /// the full HTML pipeline (typo / rename guard). -const Q2_PREVIEW_STAGE_EXCLUDED: &[&str] = &["math-js", "render-html-body", "apply-template"]; +const Q2_PREVIEW_STAGE_EXCLUDED: &[&str] = + &["math-ml", "math-js", "render-html-body", "apply-template"]; /// Build the q2-preview pipeline stages (Plan 1). /// @@ -519,6 +526,10 @@ const PANDOC_STAGE_EXCLUDED: &[&str] = &[ "tabsets-js", "code-highlight", "math-js", + // `html-math-method` is an HTML option; MathMlStage is a no-op for + // every other format (see its module docs) and is excluded here so + // the Pandoc leg never hands a converted RawInline to pandoc. + "math-ml", "render-html-body", "apply-template", ]; @@ -2600,9 +2611,9 @@ mod tests { // Merged pipeline: SourceConversionStage at [0] (branch) plus two // stages main added — LanguageResolveStage after metadata-merge // (bd-llhlzd7p) and TabsetsJsStage in the JS block - // (bd-toc-tabset-titles-zq93gjvf) — plus EquationNumberStage after - // the post filters (bd-vlhi2zkj): 26. - assert_eq!(stages.len(), 26); + // (bd-toc-tabset-titles-zq93gjvf) — plus EquationNumberStage and + // MathMlStage after the post filters (bd-vlhi2zkj, bd-3evfzwal): 27. + assert_eq!(stages.len(), 27); // Pre-parse file-claim/convert (Task 10). assert_eq!(stages[0].name(), "source-conversion"); assert_eq!(stages[1].name(), "parse-document"); @@ -2655,14 +2666,18 @@ mod tests { // user-filters-post: a Lua post filter may rewrite or delete the // reserved `quarto-eq-number` attribute the stage consumes. assert_eq!(stages[21].name(), "equation-number"); - assert_eq!(stages[22].name(), "code-highlight"); + // Native MathML (bd-3evfzwal) follows equation-number (the TeX it + // converts carries no `\tag`) and precedes math-js (which loads + // MathJax only for what this stage left as TeX). + assert_eq!(stages[22].name(), "math-ml"); + assert_eq!(stages[23].name(), "code-highlight"); // Math-mode (bd-w5ov) walks the post-transform AST and // populates meta.math when math is present. Sits just before // render-html-body so any late-introduced math (sugar, user // filters, the `\tag{N}` equation-number encoding) is visible. - assert_eq!(stages[23].name(), "math-js"); - assert_eq!(stages[24].name(), "render-html-body"); - assert_eq!(stages[25].name(), "apply-template"); + assert_eq!(stages[24].name(), "math-js"); + assert_eq!(stages[25].name(), "render-html-body"); + assert_eq!(stages[26].name(), "apply-template"); } #[test] @@ -2670,8 +2685,8 @@ mod tests { let pipeline = build_html_pipeline(); // Merged pipeline carries both SourceConversionStage (Task 10, branch) // LanguageResolveStage and TabsetsJsStage (main), plus - // EquationNumberStage (bd-vlhi2zkj) → 26 stages. - assert_eq!(pipeline.len(), 26); + // EquationNumberStage and MathMlStage → 27 stages. + assert_eq!(pipeline.len(), 27); } #[test] diff --git a/crates/quarto-core/src/stage/mod.rs b/crates/quarto-core/src/stage/mod.rs index a361ac3fb..5d793d3eb 100644 --- a/crates/quarto-core/src/stage/mod.rs +++ b/crates/quarto-core/src/stage/mod.rs @@ -119,7 +119,7 @@ pub use stages::{ ApplyTemplateStage, AstTransformsStage, AttributionGenerateStage, CaptureSpliceStage, CompileThemeCssStage, DocumentProfileStage, EngineExecutionStage, EquationNumberStage, IncludeExpansionStage, IncludeResolveStage, LanguageResolveStage, LinkResolutionStage, - ListingItemInfoStage, MathJsStage, MetadataMergeStage, ParseDocumentStage, + ListingItemInfoStage, MathJsStage, MathMlStage, MetadataMergeStage, ParseDocumentStage, PreEngineSugaringStage, RenderHtmlBodyStage, ResourceReportStage, SourceConversionStage, UnwrapProfileStage, UserFiltersStage, expand_document_includes, }; diff --git a/crates/quarto-core/src/stage/stages/equation_number.rs b/crates/quarto-core/src/stage/stages/equation_number.rs index afcdaeaec..a0aaada7c 100644 --- a/crates/quarto-core/src/stage/stages/equation_number.rs +++ b/crates/quarto-core/src/stage/stages/equation_number.rs @@ -43,10 +43,10 @@ use async_trait::async_trait; use quarto_pandoc_types::attr::AttrSourceInfo; use quarto_pandoc_types::block::Block; -use quarto_pandoc_types::custom::Slot; -use quarto_pandoc_types::inline::{Inline, Inlines, Math, MathType, Span, Str}; +use quarto_pandoc_types::inline::{Inline, Math, MathType, Span, Str}; use quarto_source_map::SourceInfo; +use crate::ast_walk::for_each_inline_mut; use crate::crossref::EQ_NUMBER_ATTR; use crate::format::Format; use crate::math_method::{MathMethod, MathMethodConfig}; @@ -136,10 +136,7 @@ impl PipelineStage for EquationNumberStage { let method = MathMethodConfig::from_meta(&doc.ast.meta).method; let encoding = NumberEncoding::for_document(&ctx.format, &method); - let mut outcome = Outcome::default(); - for block in doc.ast.blocks.iter_mut() { - visit_block(block, encoding, &mut outcome); - } + let outcome = encode_document(&mut doc.ast.blocks, encoding); if outcome.encoded > 0 || outcome.non_canonical > 0 { trace_event!( @@ -156,10 +153,14 @@ impl PipelineStage for EquationNumberStage { } } +/// What one pass over a document did. #[derive(Default)] -struct Outcome { - encoded: usize, - non_canonical: usize, +pub struct Outcome { + /// Spans whose number was encoded. + pub encoded: usize, + /// Spans that carried the attribute but not `[Math(DisplayMath), …]`; + /// only the attribute was removed. + pub non_canonical: usize, } /// Apply `encoding` to one equation span that carried `number`. Returns @@ -214,146 +215,23 @@ fn append_to_tex(math: &mut Math, suffix: &str) { math.text.push_str(suffix); } -fn visit_block(block: &mut Block, encoding: NumberEncoding, out: &mut Outcome) { - match block { - Block::Plain(p) => visit_inlines(&mut p.content, encoding, out), - Block::Paragraph(p) => visit_inlines(&mut p.content, encoding, out), - Block::LineBlock(lb) => { - for line in lb.content.iter_mut() { - visit_inlines(line, encoding, out); - } - } - Block::BlockQuote(bq) => visit_blocks(&mut bq.content, encoding, out), - Block::OrderedList(ol) => { - for item in ol.content.iter_mut() { - visit_blocks(item, encoding, out); - } - } - Block::BulletList(bl) => { - for item in bl.content.iter_mut() { - visit_blocks(item, encoding, out); - } - } - Block::DefinitionList(dl) => { - for (term, defs) in dl.content.iter_mut() { - visit_inlines(term, encoding, out); - for def in defs.iter_mut() { - visit_blocks(def, encoding, out); - } - } - } - Block::Header(h) => visit_inlines(&mut h.content, encoding, out), - Block::Div(d) => visit_blocks(&mut d.content, encoding, out), - Block::Figure(f) => { - if let Some(short) = f.caption.short.as_mut() { - visit_inlines(short, encoding, out); - } - if let Some(long) = f.caption.long.as_mut() { - visit_blocks(long, encoding, out); - } - visit_blocks(&mut f.content, encoding, out); - } - Block::Table(t) => { - if let Some(short) = t.caption.short.as_mut() { - visit_inlines(short, encoding, out); - } - if let Some(long) = t.caption.long.as_mut() { - visit_blocks(long, encoding, out); - } - for row in t.head.rows.iter_mut().chain(t.foot.rows.iter_mut()) { - for cell in row.cells.iter_mut() { - visit_blocks(&mut cell.content, encoding, out); - } - } - for body in t.bodies.iter_mut() { - for row in body.head.iter_mut().chain(body.body.iter_mut()) { - for cell in row.cells.iter_mut() { - visit_blocks(&mut cell.content, encoding, out); - } - } - } - } - Block::CaptionBlock(cb) => visit_inlines(&mut cb.content, encoding, out), - Block::Custom(c) => { - for (_name, slot) in c.slots.iter_mut() { - visit_slot(slot, encoding, out); - } - } - Block::CodeBlock(_) - | Block::RawBlock(_) - | Block::HorizontalRule(_) - | Block::BlockMetadata(_) - | Block::NoteDefinitionPara(_) - | Block::NoteDefinitionFencedBlock(_) => {} - } -} - -fn visit_blocks(blocks: &mut [Block], encoding: NumberEncoding, out: &mut Outcome) { - for block in blocks.iter_mut() { - visit_block(block, encoding, out); - } -} - -fn visit_inlines(inlines: &mut Inlines, encoding: NumberEncoding, out: &mut Outcome) { - for inline in inlines.iter_mut() { - visit_inline(inline, encoding, out); - } -} - -fn visit_inline(inline: &mut Inline, encoding: NumberEncoding, out: &mut Outcome) { - match inline { - Inline::Span(s) => { - if let Some(number) = s.attr.2.remove(EQ_NUMBER_ATTR) { - if encode_number(s, &number, encoding) { - out.encoded += 1; - } else { - out.non_canonical += 1; - } - } - // A filter may have nested things; keep walking. - visit_inlines(&mut s.content, encoding, out); - } - Inline::Emph(e) => visit_inlines(&mut e.content, encoding, out), - Inline::Underline(u) => visit_inlines(&mut u.content, encoding, out), - Inline::Strong(s) => visit_inlines(&mut s.content, encoding, out), - Inline::Strikeout(s) => visit_inlines(&mut s.content, encoding, out), - Inline::Superscript(s) => visit_inlines(&mut s.content, encoding, out), - Inline::Subscript(s) => visit_inlines(&mut s.content, encoding, out), - Inline::SmallCaps(s) => visit_inlines(&mut s.content, encoding, out), - Inline::Quoted(q) => visit_inlines(&mut q.content, encoding, out), - Inline::Link(l) => visit_inlines(&mut l.content, encoding, out), - Inline::Image(i) => visit_inlines(&mut i.content, encoding, out), - Inline::Note(n) => visit_blocks(&mut n.content, encoding, out), - Inline::Insert(i) => visit_inlines(&mut i.content, encoding, out), - Inline::Delete(d) => visit_inlines(&mut d.content, encoding, out), - Inline::Highlight(h) => visit_inlines(&mut h.content, encoding, out), - Inline::Custom(c) => { - for (_name, slot) in c.slots.iter_mut() { - visit_slot(slot, encoding, out); - } +/// Encode every `quarto-eq-number` attribute under `blocks` and remove it. +pub fn encode_document(blocks: &mut [Block], encoding: NumberEncoding) -> Outcome { + let mut out = Outcome::default(); + for_each_inline_mut(blocks, &mut |inline| { + let Inline::Span(span) = inline else { + return; + }; + let Some(number) = span.attr.2.remove(EQ_NUMBER_ATTR) else { + return; + }; + if encode_number(span, &number, encoding) { + out.encoded += 1; + } else { + out.non_canonical += 1; } - Inline::Str(_) - | Inline::Cite(_) - | Inline::Code(_) - | Inline::Space(_) - | Inline::SoftBreak(_) - | Inline::LineBreak(_) - | Inline::Math(_) - | Inline::RawInline(_) - | Inline::Shortcode(_) - | Inline::NoteReference(_) - | Inline::Attr(_) - | Inline::EditComment(_) => {} - } -} - -fn visit_slot(slot: &mut Slot, encoding: NumberEncoding, out: &mut Outcome) { - match slot { - Slot::Block(b) => visit_block(b, encoding, out), - Slot::Blocks(bs) => visit_blocks(bs, encoding, out), - Slot::Inline(i) => visit_inline(i, encoding, out), - Slot::Inlines(is) => visit_inlines(is, encoding, out), - } + }); + out } #[cfg(test)] @@ -361,9 +239,7 @@ mod tests { use super::*; use crate::format::FormatIdentifier; use hashlink::LinkedHashMap; - use quarto_pandoc_types::inline::Math; use quarto_source_map::FileId; - use quarto_source_map::SourceInfo; fn si() -> SourceInfo { SourceInfo::for_test() @@ -460,6 +336,14 @@ mod tests { assert!(matches!(&span.content[2], Inline::Str(s) if s.text == "trailing")); } + #[test] + fn writer_leaves_the_span_alone() { + let mut span = numbered_span("3", vec![math(MathType::DisplayMath, "e = mc^2")]); + let before = span.clone(); + assert!(encode_number(&mut span, "3", NumberEncoding::Writer)); + assert_eq!(span, before); + } + /// The appended encoding must not throw away the reader's byte-for-byte /// mapping of the math text (bd-ieldbghj): the original keeps its /// provenance and the suffix is a synthesized, zero-source piece. @@ -500,11 +384,39 @@ mod tests { } #[test] - fn writer_leaves_the_span_alone() { - let mut span = numbered_span("3", vec![math(MathType::DisplayMath, "e = mc^2")]); - let before = span.clone(); - assert!(encode_number(&mut span, "3", NumberEncoding::Writer)); - assert_eq!(span, before); + fn qquad_extends_text_source_too() { + let node = SourceInfo::original(FileId(0), 0, 5); + let text = SourceInfo::original(FileId(0), 2, 3); + let mut span = numbered_span( + "2", + vec![Inline::Math(Math { + math_type: MathType::DisplayMath, + text: "x".to_string(), + source_info: node, + text_source: Some(text), + })], + ); + assert!(encode_number(&mut span, "2", NumberEncoding::Qquad)); + let Inline::Math(math) = &span.content[0] else { + panic!() + }; + let ts = math.text_source.as_ref().unwrap(); + assert_eq!(ts.length(), "x \\qquad(2)".len()); + let SourceInfo::Concat { pieces } = ts else { + panic!() + }; + assert_eq!(pieces[1].length, " \\qquad(2)".len()); + } + + /// Without a mapping there is nothing to extend, and nothing to invent. + #[test] + fn no_text_source_stays_none() { + let mut span = numbered_span("1", vec![math(MathType::DisplayMath, "x")]); + assert!(encode_number(&mut span, "1", NumberEncoding::TexTag)); + let Inline::Math(m) = &span.content[0] else { + panic!() + }; + assert!(m.text_source.is_none()); } #[test] @@ -597,9 +509,7 @@ mod tests { // ── the walker ───────────────────────────────────────────────── fn walk(blocks: &mut [Block], encoding: NumberEncoding) -> Outcome { - let mut out = Outcome::default(); - visit_blocks(blocks, encoding, &mut out); - out + encode_document(blocks, encoding) } fn para(content: Vec) -> Block { @@ -612,7 +522,7 @@ mod tests { #[test] fn walker_reaches_spans_nested_in_blocks_inlines_and_custom_slots() { use quarto_pandoc_types::block::{BulletList, Div}; - use quarto_pandoc_types::custom::CustomNode; + use quarto_pandoc_types::custom::{CustomNode, Slot}; use quarto_pandoc_types::inline::Emph; let eq = || Inline::Span(numbered_span("1", vec![math(MathType::DisplayMath, "x")])); diff --git a/crates/quarto-core/src/stage/stages/math_js.rs b/crates/quarto-core/src/stage/stages/math_js.rs index 046ae91d2..8c5cc1854 100644 --- a/crates/quarto-core/src/stage/stages/math_js.rs +++ b/crates/quarto-core/src/stage/stages/math_js.rs @@ -111,11 +111,18 @@ impl MathEngine { /// engine's default loader location). /// /// Returns `None` for methods q2 does not load an engine for: - /// `plain`, `mathml` (converted at render time by bd-3evfzwal), - /// `webtex`, `gladtex` and unknown strings. The caller then leaves - /// `meta.math` unset so the author can supply their own approach via - /// includes / a custom template. Absent, `mathjax` and `katex` never - /// yield `None`. + /// `plain`, `webtex`, `gladtex` and unknown strings. The caller then + /// leaves `meta.math` unset so the author can supply their own + /// approach via includes / a custom template. Absent, `mathjax` and + /// `katex` never yield `None`. + /// + /// `mathml` yields the default MathJax engine: `MathMlStage` runs + /// before this stage and converts every expression it can, so any + /// `Inline::Math` still in the document is one it had to leave as TeX + /// (bd-3evfzwal, the hybrid fallback). This stage only injects when + /// the walk finds math, so a fully converted document loads nothing. + /// The object form's `url` is ignored for `mathml` (it names a + /// MathML converter, not a loader). pub fn from_meta(meta: &ConfigValue) -> Option { let MathMethodConfig { method, url } = MathMethodConfig::from_meta(meta); match method { @@ -125,7 +132,8 @@ impl MathEngine { MathMethod::Katex => Some(Self::Katex { url_base: url.unwrap_or_else(|| DEFAULT_KATEX_URL_BASE.to_string()), }), - MathMethod::MathMl | MathMethod::Plain | MathMethod::Unknown(_) => None, + MathMethod::MathMl => Some(Self::default_engine()), + MathMethod::Plain | MathMethod::Unknown(_) => None, } } @@ -154,7 +162,11 @@ impl MathEngine { /// equations — `CrossrefRenderTransform` injects `\tag{N}` so this /// flag is required for equation numbering to render. /// - `options.skipHtmlTags` excludes elements MathJax must not typeset -/// (code blocks, scripts, pre, etc.) — Pandoc's defaults. +/// (scripts, pre, etc.). `annotation`/`annotation-xml` are MathJax's own +/// defaults and must stay: under `html-math-method: mathml` the converted +/// `` elements carry their TeX in an ``, and a loader +/// brought in for a leftover expression (bd-3evfzwal) would otherwise +/// re-typeset every `\begin{…}` it finds there. fn mathjax_slot_html(url: &str) -> String { format!( "\n\ @@ -410,7 +422,7 @@ impl PipelineStage for MathJsStage { // Read the user-selected engine (or fall back to default). // `from_meta` returns `None` for explicitly-unsupported methods - // (`webtex` / `gladtex` / `mathml` / `plain` — deferred). In + // (`webtex` / `gladtex` / `plain`). In // that case we leave `meta.math` unset so the document author // can supply their own approach via includes / custom template. let Some(engine) = MathEngine::from_meta(&doc.ast.meta) else { @@ -969,6 +981,46 @@ mod tests { ); } + /// `html-math-method: mathml` (bd-3evfzwal): MathJax is the fallback + /// for expressions `MathMlStage` left as TeX, so injection happens + /// iff `Inline::Math` survives — never for a fully converted page. + #[tokio::test] + async fn mathml_method_injects_mathjax_only_for_leftover_math() { + let leftover = run_and_get_math( + vec![paragraph(vec![inline_math("x + \\bogus y")])], + meta_with_string("html-math-method", "mathml"), + ) + .await + .expect("leftover TeX needs MathJax"); + assert!(leftover.contains(DEFAULT_MATHJAX_URL)); + + let converted = run_and_get_math( + vec![paragraph(vec![str_inline("only a element here")])], + meta_with_string("html-math-method", "mathml"), + ) + .await; + assert!(converted.is_none(), "no leftover math → nothing to load"); + } + + /// The MathJax skip list must keep MathJax's own `annotation` and + /// `annotation-xml` entries: under `html-math-method: mathml` the + /// converted `` elements carry the source TeX in an + /// ``, and when MathJax is loaded for a leftover + /// expression (bd-3evfzwal) it would otherwise re-typeset every + /// `\begin{…}` it finds in those annotations (seen in a browser: + /// zero-size assistive MathML inside the converted matrices). + #[test] + fn mathjax_skip_list_excludes_annotations() { + let slot = MathEngine::default_engine().render_math_slot(); + let skip = slot + .lines() + .find(|l| l.contains("skipHtmlTags")) + .expect("skipHtmlTags in the MathJax config"); + for tag in ["'annotation'", "'annotation-xml'", "'pre'", "'script'"] { + assert!(skip.contains(tag), "{tag} missing from {skip}"); + } + } + // ── Tests for the engine config parser (independent of stage) ── #[test] diff --git a/crates/quarto-core/src/stage/stages/math_ml.rs b/crates/quarto-core/src/stage/stages/math_ml.rs new file mode 100644 index 000000000..d61458025 --- /dev/null +++ b/crates/quarto-core/src/stage/stages/math_ml.rs @@ -0,0 +1,418 @@ +/* + * stage/stages/math_ml.rs + * Copyright (c) 2026 Posit, PBC + * + * Convert math to MathML at render time (`html-math-method: mathml`). + */ + +//! Native MathML for `format: html` (bd-3evfzwal). +//! +//! When the document selects `html-math-method: mathml`, every +//! `Inline::Math` is converted with `quarto_math` (`Target::MathMl`) and +//! replaced by +//! +//! ```html +//! +//! ``` +//! +//! (as a `Span` around a `RawInline`), the same wrapper the HTML writer +//! emits around TeX, so stylesheets and the preview-parity tooling keep +//! working. Every current browser renders MathML Core natively, so a +//! document whose math all converts ships no MathJax. +//! +//! An expression the converter cannot fully handle (an unknown command, an +//! unbalanced `\left`) is left as `Inline::Math`. `MathJsStage`, which runs +//! next, then sees leftover math and loads MathJax for the page, so the +//! reader still gets rendered math; the author gets the `Q-22-*` +//! diagnostic pointing at the offending characters in the `.qmd`, +//! downgraded to a warning because the page did render. The plan calls +//! this the hybrid fallback (decision 2 of +//! `claude-notes/plans/2026-09-21-equation-numbering-and-mathml.md`). +//! +//! Numbered equations reach this stage with their number already encoded +//! as a sibling label by `EquationNumberStage` (the `Sibling` encoding), +//! so the TeX handed to the converter is the author's, with no `\tag`. +//! +//! Excluded from the q2-preview pipeline, which renders `Inline::Math` +//! client-side with KaTeX. +//! +//! **HTML-based formats only.** `html-math-method` is an HTML option, and +//! it commonly sits in shared metadata of a project that also renders +//! docx or typst. On the Pandoc leg the stage is a no-op (and `math-ml` +//! is on `PANDOC_STAGE_EXCLUDED`), matching Quarto 1, which forwards the +//! key to pandoc, whose non-HTML writers ignore it, and matching +//! `EquationNumberStage`'s `Writer` encoding. Converting there would +//! hand a `RawInline` to the vendored `crossref/equations.lua`, which +//! crashes on it (pandoc exit 83, Q-20-3). No diagnostic is raised: the +//! `html-` prefix is exactly what makes the key safe to share. + +use async_trait::async_trait; + +use quarto_error_reporting::{DiagnosticKind, DiagnosticMessage}; +use quarto_math::convert::{Target, convert}; +use quarto_math::normalize::Mode; +use quarto_math::spec::Spec; +use quarto_pandoc_types::attr::AttrSourceInfo; +use quarto_pandoc_types::inline::{Inline, Math, MathType, RawInline, Span}; + +use crate::ast_walk::for_each_inline_mut; +use crate::format::Format; +use crate::math_method::{MathMethod, MathMethodConfig}; +use crate::stage::{ + EventLevel, PipelineData, PipelineDataKind, PipelineError, PipelineStage, StageContext, +}; +use crate::trace_event; + +/// Convert every `Inline::Math` to MathML when `html-math-method: mathml`. +pub struct MathMlStage; + +impl MathMlStage { + pub fn new() -> Self { + Self + } +} + +impl Default for MathMlStage { + fn default() -> Self { + Self::new() + } +} + +#[async_trait(?Send)] +impl PipelineStage for MathMlStage { + fn name(&self) -> &str { + "math-ml" + } + + fn input_kind(&self) -> PipelineDataKind { + PipelineDataKind::DocumentAst + } + + fn output_kind(&self) -> PipelineDataKind { + PipelineDataKind::DocumentAst + } + + async fn run( + &self, + input: PipelineData, + ctx: &mut StageContext, + ) -> Result { + let PipelineData::DocumentAst(mut doc) = input else { + return Err(PipelineError::unexpected_input( + self.name(), + self.input_kind(), + input.kind(), + )); + }; + + let method = MathMethodConfig::from_meta(&doc.ast.meta).method; + if !applies_to(&ctx.format, &method) { + return Ok(PipelineData::DocumentAst(doc)); + } + + let outcome = convert_document(&mut doc.ast.blocks); + ctx.add_diagnostics(outcome.diagnostics); + if outcome.converted > 0 || outcome.left > 0 { + trace_event!( + ctx, + EventLevel::Debug, + "math-ml: converted {} expression(s) to MathML, left {} as TeX for MathJax", + outcome.converted, + outcome.left + ); + } + + Ok(PipelineData::DocumentAst(doc)) + } +} + +/// Whether the stage converts anything for a document rendered to +/// `format` with `method`: only an HTML-based format that selected +/// `html-math-method: mathml`. Every other format ignores the option (see +/// the module docs). +pub fn applies_to(format: &Format, method: &MathMethod) -> bool { + format.identifier.is_html_based() && *method == MathMethod::MathMl +} + +/// What one pass over a document did. +#[derive(Default)] +pub struct Outcome { + /// Expressions replaced by MathML. + pub converted: usize, + /// Expressions left as `Inline::Math` for the JS fallback. + pub left: usize, + /// Every diagnostic the converter raised, located in the `.qmd`; + /// errors downgraded to warnings (see the module docs). + pub diagnostics: Vec, +} + +/// Convert every `Inline::Math` under `blocks` in place. +pub fn convert_document(blocks: &mut [quarto_pandoc_types::block::Block]) -> Outcome { + let mut outcome = Outcome::default(); + for_each_inline_mut(blocks, &mut |inline| { + let Inline::Math(math) = inline else { + return; + }; + match convert_math(math) { + (Some(replacement), diagnostics) => { + outcome.converted += 1; + outcome.diagnostics.extend(diagnostics); + *inline = replacement; + } + (None, diagnostics) => { + outcome.left += 1; + outcome + .diagnostics + .extend(diagnostics.into_iter().map(downgrade_to_warning)); + } + } + }); + outcome +} + +/// Convert one expression. `Some` is the replacement inline; `None` means +/// the converter withheld output and the TeX must stay. +fn convert_math(math: &Math) -> (Option, Vec) { + let mode = match math.math_type { + MathType::InlineMath => Mode::Inline, + MathType::DisplayMath => Mode::Display, + }; + // The text's own mapping when the reader recorded one (bd-ieldbghj), + // else the node: diagnostics then still land on the `$…$` block. + let text_source = math + .text_source + .clone() + .unwrap_or_else(|| math.source_info.clone()); + let conversion = convert( + &math.text, + mode, + Target::MathMl, + &text_source, + Spec::builtin(), + ); + let Some(mathml) = conversion.output else { + return (None, conversion.diagnostics); + }; + let class = match math.math_type { + MathType::InlineMath => "inline", + MathType::DisplayMath => "display", + }; + let replacement = Inline::Span(Span { + attr: ( + String::new(), + vec!["math".to_string(), class.to_string()], + Default::default(), + ), + content: vec![Inline::RawInline(RawInline { + format: "html".to_string(), + text: mathml, + source_info: math.source_info.clone(), + })], + source_info: math.source_info.clone(), + attr_source: AttrSourceInfo::empty(), + }); + (Some(replacement), conversion.diagnostics) +} + +/// The page still renders (MathJax takes the expression), so a conversion +/// error is a warning to the author, not a failed render. +fn downgrade_to_warning(mut d: DiagnosticMessage) -> DiagnosticMessage { + if d.kind == DiagnosticKind::Error { + d.kind = DiagnosticKind::Warning; + } + d +} + +#[cfg(test)] +mod tests { + use super::*; + use quarto_pandoc_types::block::{Block, Header, Paragraph}; + use quarto_pandoc_types::custom::{CustomNode, Slot}; + use quarto_pandoc_types::inline::Str; + use quarto_source_map::{FileId, SourceInfo}; + + fn si() -> SourceInfo { + SourceInfo::for_test() + } + + fn math(math_type: MathType, text: &str) -> Inline { + Inline::Math(Math { + math_type, + text: text.to_string(), + source_info: si(), + text_source: None, + }) + } + + fn para(content: Vec) -> Block { + Block::Paragraph(Paragraph { + content, + source_info: si(), + }) + } + + fn raw_html(inline: &Inline) -> &str { + let Inline::Span(span) = inline else { + panic!("expected the math span, got {inline:?}"); + }; + assert_eq!(span.attr.0, ""); + assert!(span.attr.2.is_empty()); + let [Inline::RawInline(raw)] = span.content.as_slice() else { + panic!("expected one RawInline, got {:?}", span.content); + }; + assert_eq!(raw.format, "html"); + &raw.text + } + + #[test] + fn inline_and_display_math_become_math_spans() { + let mut blocks = vec![para(vec![ + math(MathType::InlineMath, "x^2"), + Inline::Str(Str { + text: "and".to_string(), + source_info: si(), + }), + math(MathType::DisplayMath, "\\frac{a}{b}"), + ])]; + let out = convert_document(&mut blocks); + assert_eq!((out.converted, out.left), (2, 0)); + assert!(out.diagnostics.is_empty()); + let Block::Paragraph(p) = &blocks[0] else { + panic!() + }; + let Inline::Span(inline_span) = &p.content[0] else { + panic!() + }; + assert_eq!(inline_span.attr.1, vec!["math", "inline"]); + let inline_html = raw_html(&p.content[0]); + assert!( + inline_html + .starts_with(r#""#) + ); + assert!(inline_html.contains("x2")); + assert!(matches!(&p.content[1], Inline::Str(s) if s.text == "and")); + let Inline::Span(display_span) = &p.content[2] else { + panic!() + }; + assert_eq!(display_span.attr.1, vec!["math", "display"]); + let display_html = raw_html(&p.content[2]); + assert!(display_html.contains(r#"display="block""#)); + assert!(display_html.contains("ab")); + } + + #[test] + fn an_unconvertible_expression_stays_math_with_a_warning() { + let mut blocks = vec![para(vec![math(MathType::InlineMath, "x + \\bogus y")])]; + let out = convert_document(&mut blocks); + assert_eq!((out.converted, out.left), (0, 1)); + let Block::Paragraph(p) = &blocks[0] else { + panic!() + }; + assert!( + matches!(&p.content[0], Inline::Math(m) if m.text == "x + \\bogus y"), + "the TeX must be untouched for MathJax, got {:?}", + p.content[0] + ); + assert_eq!(out.diagnostics.len(), 1); + assert_eq!(out.diagnostics[0].code.as_deref(), Some("Q-22-1")); + assert_eq!(out.diagnostics[0].kind, DiagnosticKind::Warning); + } + + /// A conversion that succeeds with a caveat keeps its warning as-is. + #[test] + fn a_warning_on_a_converted_expression_is_kept() { + // `\not` on a non-symbol is unsupported and produces an Error node... + // so pick a real warning: ragged rows in an environment. + let mut blocks = vec![para(vec![math( + MathType::DisplayMath, + "\\begin{matrix} a & b \\\\ c \\end{matrix}", + )])]; + let out = convert_document(&mut blocks); + assert_eq!((out.converted, out.left), (1, 0)); + assert_eq!(out.diagnostics.len(), 1, "{:?}", out.diagnostics); + assert_eq!(out.diagnostics[0].kind, DiagnosticKind::Warning); + } + + #[test] + fn diagnostics_point_into_the_text_source_when_present() { + // `$x + \bogus y$` at file bytes 0..14; text at 1..13. + let node = SourceInfo::original(FileId(0), 0, 14); + let text = SourceInfo::original(FileId(0), 1, 13); + let mut blocks = vec![para(vec![Inline::Math(Math { + math_type: MathType::InlineMath, + text: "x + \\bogus y".to_string(), + source_info: node, + text_source: Some(text), + })])]; + let out = convert_document(&mut blocks); + let loc = out.diagnostics[0].location.as_ref().expect("located"); + // `\bogus` is at text offsets 4..10, so file bytes 5..11. + assert_eq!(loc.preimage_in(FileId(0)), Some(5..11)); + } + + #[test] + fn math_inside_headers_notes_and_custom_slots_is_converted() { + let mut custom = CustomNode::new("Anything", quarto_pandoc_types::attr::empty_attr(), si()); + custom.slots.insert( + "content".to_string(), + Slot::Inlines(vec![math(MathType::InlineMath, "c^2")]), + ); + let mut blocks = vec![ + Block::Header(Header { + level: 1, + attr: quarto_pandoc_types::attr::empty_attr(), + content: vec![math(MathType::InlineMath, "h^2")], + source_info: si(), + attr_source: AttrSourceInfo::empty(), + }), + para(vec![Inline::Note(quarto_pandoc_types::inline::Note { + content: vec![para(vec![math(MathType::InlineMath, "n^2")])], + source_info: si(), + })]), + Block::Custom(custom), + ]; + let out = convert_document(&mut blocks); + assert_eq!((out.converted, out.left), (3, 0)); + let mut seen = Vec::new(); + for_each_inline_mut(&mut blocks, &mut |inline| { + if let Inline::RawInline(raw) = inline { + for var in ["h", "n", "c"] { + if raw + .text + .contains(&format!("{var}2")) + { + seen.push(var); + } + } + } + }); + seen.sort_unstable(); + assert_eq!(seen, vec!["c", "h", "n"]); + } + + /// `html-math-method` is an HTML option: the stage runs for html and + /// revealjs with `mathml`, and for nothing else. + #[test] + fn applies_only_to_html_based_formats_with_mathml() { + use crate::format::FormatIdentifier; + let format = |identifier| Format { + identifier, + ..Format::html() + }; + for id in [FormatIdentifier::Html, FormatIdentifier::Revealjs] { + assert!(applies_to(&format(id), &MathMethod::MathMl), "{id:?}"); + assert!(!applies_to(&format(id), &MathMethod::Mathjax), "{id:?}"); + } + for id in [ + FormatIdentifier::Docx, + FormatIdentifier::Pptx, + FormatIdentifier::Typst, + FormatIdentifier::Pdf, + ] { + assert!( + !applies_to(&format(id), &MathMethod::MathMl), + "{id:?} must ignore html-math-method" + ); + } + } +} diff --git a/crates/quarto-core/src/stage/stages/mod.rs b/crates/quarto-core/src/stage/stages/mod.rs index a5fb2c540..3e38adb44 100644 --- a/crates/quarto-core/src/stage/stages/mod.rs +++ b/crates/quarto-core/src/stage/stages/mod.rs @@ -61,6 +61,10 @@ mod source_conversion; // when the document contains Math elements. Included on both native // and WASM pipelines (math display is safe under iframe reinit). mod math_js; +// Native MathML (bd-3evfzwal): converts every `Inline::Math` with +// quarto-math when `html-math-method: mathml`; leftovers fall back to +// MathJax via math_js. Included on native and WASM. +mod math_ml; mod metadata_merge; // Pandoc-hybrid leg's writer stage: shells out to a real `pandoc` // subprocess via `std::process::Command` and materializes the vendored @@ -119,6 +123,7 @@ pub use language_resolve::LanguageResolveStage; pub use link_resolution::LinkResolutionStage; pub use listing_item_info::ListingItemInfoStage; pub use math_js::{DEFAULT_KATEX_URL_BASE, DEFAULT_MATHJAX_URL, MathEngine, MathJsStage}; +pub use math_ml::MathMlStage; pub use metadata_merge::MetadataMergeStage; #[cfg(not(target_arch = "wasm32"))] pub use pandoc_write::{ diff --git a/crates/quarto-core/tests/integration/equation_numbering_pipeline.rs b/crates/quarto-core/tests/integration/equation_numbering_pipeline.rs index 21766d052..80cb5ca51 100644 --- a/crates/quarto-core/tests/integration/equation_numbering_pipeline.rs +++ b/crates/quarto-core/tests/integration/equation_numbering_pipeline.rs @@ -135,15 +135,17 @@ fn unknown_method_encodes_qquad_inside_tex() { assert!(html.contains("\\[E = mc^2 \\qquad(1)\\]"), "got:\n{html}"); } -/// `mathml`: the TeX is untouched (the MathML stage, bd-3evfzwal, will -/// convert it later) and the number is a sibling label outside the math, +/// `mathml`: the TeX is untouched (the MathML stage, bd-3evfzwal, converts +/// it next) and the number is a sibling label outside the math, /// with a modifier class on the equation span for the CSS that lays the /// two out. #[test] fn mathml_method_places_number_as_sibling_label() { let html = render(&labelled_doc("html-math-method: mathml\n"), "html"); + // The MathML stage (bd-3evfzwal) converts the math; the annotation + // carries the author's TeX, untouched (no `\tag`). assert!( - html.contains("\\[E = mc^2\\]"), + html.contains(r#"E = mc^2"#), "the math text must be untouched; got:\n{html}" ); assert!( @@ -164,7 +166,7 @@ fn mathml_method_places_number_as_sibling_label() { fn mathml_sibling_label_is_inside_the_equation_span() { let html = render(&labelled_doc("html-math-method: mathml\n"), "html"); let start = html.find("id=\"eq-einstein\"").expect("equation span"); - let math_end = html[start..].find("\\]").expect("math span end") + start; + let math_end = html[start..].find("").expect("math span end") + start; let label = html[start..].find("quarto-eq-number\">(1)").expect("label") + start; assert!( label > math_end, @@ -193,10 +195,13 @@ fn unlabelled_display_math_is_untouched() { &format!("---\ntitle: Unlabelled\n{method}---\n\n$$x^2$$\n"), "html", ); - assert!( - html.contains("\\[x^2\\]"), - "method {method:?}: got:\n{html}" - ); + let untouched = if method.contains("mathml") { + // Converted to MathML; the annotation carries the TeX. + r#"x^2"# + } else { + "\\[x^2\\]" + }; + assert!(html.contains(untouched), "method {method:?}: got:\n{html}"); assert!(!html.contains("\\tag{")); assert!(!html.contains("qquad")); assert!(!html.contains("quarto-eq-number")); diff --git a/crates/quarto-core/tests/integration/math_mode_pipeline.rs b/crates/quarto-core/tests/integration/math_mode_pipeline.rs index 81eed0f4c..e61c25411 100644 --- a/crates/quarto-core/tests/integration/math_mode_pipeline.rs +++ b/crates/quarto-core/tests/integration/math_mode_pipeline.rs @@ -33,7 +33,7 @@ use tempfile::TempDir; use quarto_core::format::Format; use quarto_core::project::ProjectContext; use quarto_core::project::orchestrator::{ProjectPipeline, project_type_for}; -use quarto_core::render_to_file::{RenderToFileOptions, render_to_file}; +use quarto_core::render_to_file::{RenderToFileOptions, render_document_to_file, render_to_file}; use quarto_core::stage::stages::{DEFAULT_KATEX_URL_BASE, DEFAULT_MATHJAX_URL}; use quarto_system_runtime::{NativeRuntime, SystemRuntime}; @@ -67,6 +67,59 @@ const KATEX_AUTO_RENDER_SENTINEL: &str = "renderMathInElement"; // ── Single-doc tests ──────────────────────────────────────────────────── +/// `html-math-method` is an HTML option. A document that selects `mathml` +/// and is rendered to docx (a common shape: the key sits in shared +/// metadata of a project that renders both) must reach pandoc with its +/// `Inline::Math` intact and come out with a native OMML equation, +/// numbered by the vendored `crossref/equations.lua`. Before `MathMlStage` +/// was gated on the format it converted the math to a `RawInline` on the +/// Pandoc leg too, and that Lua filter crashed on it (pandoc exit 83). +/// Requires a real `pandoc` on `PATH`, like every Pandoc-leg test. +#[test] +fn mathml_method_is_ignored_for_docx() { + let temp = TempDir::new().unwrap(); + let project_dir = temp.path().canonicalize().unwrap(); + let qmd_path = project_dir.join("doc.qmd"); + write_file( + &qmd_path, + "---\ntitle: Math\nhtml-math-method: mathml\n---\n\n$$\nE = mc^2\n$$ {#eq-e}\n\nSee @eq-e.\n", + ); + + let result = render_document_to_file( + &qmd_path, + "docx", + &RenderToFileOptions::default(), + None, + runtime_arc(), + None, + None, + Some("docx"), + ) + .expect("docx render must succeed with html-math-method: mathml"); + let bytes = std::fs::read(&result.output_path).unwrap(); + let extraction = quarto_ooxml_extract::extract_docx(&bytes).expect("extract docx"); + + assert_eq!( + extraction.math.len(), + 1, + "exactly one OMML equation expected; got {extraction}" + ); + let math = &extraction.math[0]; + assert!( + math.contains("mc") && math.contains("(1)"), + "the equation must be native OMML, numbered by the vendored Lua: {math:?}" + ); + let body: String = extraction + .paragraphs + .iter() + .map(|p| p.text.as_str()) + .collect(); + assert!( + !body.contains(" (String, Vec) { + let temp = TempDir::new().unwrap(); + let qmd_path = temp.path().join("doc.qmd"); + write_file( + &qmd_path, + &format!("---\ntitle: MathML\nhtml-math-method: mathml\n---\n\n{body}\n"), + ); + let runtime = runtime_arc(); + let options = RenderToFileOptions::default(); + let result = render_to_file(&qmd_path, "html", &options, runtime).expect("render"); + let html = read(&result.output_path); + (html, result.render_output.diagnostics) +} + +/// Every expression converts: native `` in the page, the TeX +/// delimiters gone, no JS engine loaded, and a numbered equation's label +/// sits outside the math. +#[test] +fn mathml_method_emits_native_mathml_and_no_loader() { + let (html, diagnostics) = render_mathml( + "Inline $x^2$ and display:\n\n$$\\frac{a}{b}$$\n\n$$E = mc^2$$ {#eq-e}\n\nSee @eq-e.", + ); + assert!( + html.contains(r#"x2"#), + "inline math must be native MathML inside the usual span; got:\n{html}" + ); + assert!( + html.contains(r#"ab"#), + "display math must be block MathML; got:\n{html}" + ); + assert!( + html.contains(r#"E = mc^2(1)"#), + "the numbered equation keeps its TeX untouched and carries the label as a sibling; got:\n{html}" + ); + assert!( + !html.contains("\\("), + "no inline TeX delimiters may remain:\n{html}" + ); + assert!( + !html.contains("\\["), + "no display TeX delimiters may remain:\n{html}" + ); + assert!(!html.contains("\\tag{")); + assert!( + !html.contains(MATHJAX_CONFIG_SENTINEL), + "no MathJax when everything converted" + ); + assert!(!html.contains("cdn.jsdelivr.net/npm/mathjax")); + assert!(!html.contains("cdn.jsdelivr.net/npm/katex")); + assert!( + html.contains("Equation\u{a0}1"), + "the crossref link still resolves" + ); + let math_codes: Vec<&str> = diagnostics + .iter() + .filter_map(|d| d.code.as_deref()) + .filter(|c| c.starts_with("Q-22-")) + .collect(); + assert!( + math_codes.is_empty(), + "no math diagnostics expected, got {math_codes:?}" + ); +} + +/// An expression the converter rejects stays TeX, and MathJax is loaded +/// for the page so the reader still sees it rendered (plan decision 2). +/// The author gets a `Q-22-1` *warning* pointing at the command. +#[test] +fn mathml_method_falls_back_to_mathjax_for_an_unconvertible_expression() { + let (html, diagnostics) = render_mathml("Good $a^2$ and bad $x + \\bogus y$."); + assert!( + html.contains("a2"), + "the convertible expression is MathML; got:\n{html}" + ); + assert!( + html.contains(r#"\(x + \bogus y\)"#), + "the unconvertible expression stays TeX for MathJax; got:\n{html}" + ); + assert!( + html.contains(MATHJAX_CONFIG_SENTINEL) && html.contains(DEFAULT_MATHJAX_URL), + "MathJax must be loaded for the leftover expression; got:\n{html}" + ); + // …and must not re-typeset the TeX inside the converted math's + // `` (MathJax's default skip list covers it; ours must too). + assert!( + html.contains("'annotation', 'annotation-xml'"), + "the MathJax skip list must include annotation elements; got:\n{html}" + ); + let bogus: Vec<_> = diagnostics + .iter() + .filter(|d| d.code.as_deref() == Some("Q-22-1")) + .collect(); + assert_eq!( + bogus.len(), + 1, + "one unknown-command diagnostic; got {diagnostics:?}" + ); + assert_eq!( + bogus[0].kind, + quarto_error_reporting::DiagnosticKind::Warning, + "the page still renders (MathJax), so this is a warning, not an error" + ); + assert!( + bogus[0].location.is_some(), + "the diagnostic points into the .qmd" + ); +} + +/// No math: neither `` nor a loader. +#[test] +fn mathml_method_without_math_emits_nothing() { + let (html, _) = render_mathml("Just prose."); + assert!(!html.contains("{var}2")), + "math in a container ({var}) must be converted; got:\n{html}" + ); + } + assert!(!html.contains("\\(")); + assert!(!html.contains(MATHJAX_CONFIG_SENTINEL)); +} + +/// Two-page site: the mathml page gets `` and no loader; the +/// math-free page gets neither. +#[test] +fn website_mathml_page_has_math_and_no_loader() { + let project_dir = render_website(|dir| { + write_file( + &dir.join("_quarto.yml"), + "project:\n type: website\nformat:\n html:\n html-math-method: mathml\n", + ); + write_file( + &dir.join("index.qmd"), + "---\ntitle: Home\n---\n\nNothing here.\n", + ); + write_file( + &dir.join("equations.qmd"), + "---\ntitle: Equations\n---\n\nWe have $x + 1 = y$.\n", + ); + }); + let site = project_dir.join("_site"); + let index_html = read(&site.join("index.html")); + let eq_html = read(&site.join("equations.html")); + assert!(eq_html.contains("` element. Every current browser +renders MathML Core natively, so the page loads no math library and the +math is part of the document rather than something a script draws after +the page loads. The converted element also carries the original TeX in an +annotation, so copying an equation still gives you its source. + +A few things to know: + +- **Fallback.** If an expression uses a command the converter does not + know, that expression is left as TeX and MathJax is loaded for the + page so it still renders. Quarto reports a `Q-22-*` warning that points + at the command in your `.qmd`; fixing it (or defining the macro in the + expression with `\newcommand`) removes the dependency on MathJax. See + [Q-22-1](../../../errors/math/Q-22-1.qmd) for the most common case. +- **Equation numbers.** Numbered equations (`$$…$$ {#eq-name}`) show + their number as a label to the right of the equation, outside the math. +- **Fonts.** Browsers lay out MathML with a math font when one is + installed (Windows ships Cambria Math; Firefox bundles STIX). Without + one, the browser falls back to the page font and some constructs, such + as large operators and stretchy delimiters, look less refined than with + MathJax or KaTeX. Quarto does not ship a math font. +- **`\cancel`.** The strike-through is drawn by Firefox but not by + Chrome, which shows the content without the line. + +## Unsupported methods + +Quarto 1's `webtex` and `gladtex` methods are not available. They are +treated like `plain`: the TeX stays in the page unrendered.