Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions claude-notes/designs/transform-pipeline-phases.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,41 @@ it never mentions `revealjs`, only phases.

---

## The post-filter presentation slot (stages, not transforms)

Every Finalization transform runs inside `AstTransformsStage`, which the
HTML stage list places **between** `UserFiltersStage::pre` and
`UserFiltersStage::post`. So a Finalization transform, however late, runs
*before* any user post filter. That is the right place for most
presentation work, but not for a step whose intermediate representation is
part of the filter-author contract.

The concrete case is equation numbering (bd-vlhi2zkj). `crossref-render`
numbers an equation but does not choose how the number is typeset: `\tag{N}`
is an `amsmath` command only MathJax and KaTeX read, so a MathML converter
(or no engine at all) needs ` \qquad(N)` or a label outside the math. The
transform records the number on the reserved span attribute
`quarto-eq-number` and leaves the TeX alone; `EquationNumberStage` picks the
encoding from the format and `html-math-method` and removes the attribute.
Had that stage been a Finalization transform, a Lua post filter could never
see the attribute: it would already be gone. As a stage after
`UserFiltersStage::post` it can be read, rewritten or deleted from Lua
(`el.attributes["quarto-eq-number"]`), which is the escape hatch we want.

**Rule:** a format-specific step that consumes crossref output belongs in
`Finalization` (the author rule above) **unless** its input is a reserved
attribute or node that user post filters are meant to see; then it is a
*stage* between `UserFiltersStage::post` and `RenderHtmlBodyStage`, next to
`CodeHighlightStage` (AST-level annotation, same slot, same reason) and
`EquationNumberStage`. Such a stage still satisfies the invariant's intent:
it runs after every transform, so all crossref structure is final. It is
not covered by the transform ordering test; document the placement at the
`stages.push` site and cover it with an end-to-end test that runs a post
filter against the attribute (see
`crates/quarto-core/tests/integration/equation_numbering_pipeline.rs`).

---

## The preview-pipeline shape contract (the anti-recurrence rule)

The bug had a deeper enabler worth stating as its own rule.
Expand Down
340 changes: 340 additions & 0 deletions claude-notes/plans/2026-09-21-equation-numbering-and-mathml.md

Large diffs are not rendered by default.

16 changes: 16 additions & 0 deletions crates/quarto-core/src/crossref/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,22 @@ pub const PROOF: &str = "Proof";
/// `plain_data.order` (set by the indexer); the id prefix is always `"eq"`.
pub const EQUATION: &str = "Equation";

/// The reserved attribute `CrossrefRenderTransform` writes on a rendered
/// equation span (`Span#eq-… .quarto-math-with-attribute`) to carry the
/// equation's number, as text (`"1"`).
///
/// This is the hand-off between format-agnostic numbering and
/// format-specific *encoding* of the number: crossref-render leaves the
/// `Math` text byte-identical to the source and records the number here;
/// `EquationNumberStage` (which runs after user post filters) turns it into
/// `\tag{N}` for MathJax/KaTeX, ` \qquad(N)` for engines that only read
/// math, or a sibling label for MathML, and removes the attribute. Between
/// the two, a Lua post filter can read, rewrite or delete
/// `el.attributes["quarto-eq-number"]` — the supported way to customize
/// equation numbers from a filter. Plan:
/// `claude-notes/plans/2026-09-21-equation-numbering-and-mathml.md`.
pub const EQ_NUMBER_ATTR: &str = "quarto-eq-number";

/// The `type_name` used on `CustomNode` for resolved crossref references in
/// the front-end AST.
///
Expand Down
1 change: 1 addition & 0 deletions crates/quarto-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ pub mod format;
pub mod get_config;
pub mod glob;
pub mod language;
pub mod math_method;
pub mod metadata;
pub mod output_sink;
pub mod pipeline;
Expand Down
185 changes: 185 additions & 0 deletions crates/quarto-core/src/math_method.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
/*
* math_method.rs
* Copyright (c) 2026 Posit, PBC
*/

//! The `html-math-method` document option, parsed once for every consumer.
//!
//! Two stages read the option and must agree on what they read:
//! [`crate::stage::stages::MathJsStage`] (which engine, if any, to load
//! into the page) and [`crate::stage::stages::EquationNumberStage`] (how to
//! encode an equation number so that engine can typeset it). Parsing lives
//! here so neither can drift from the other.
//!
//! Both Quarto 1 / Pandoc shapes are accepted:
//!
//! ```yaml
//! html-math-method: katex
//! html-math-method:
//! method: mathjax
//! url: https://example.org/mathjax.js
//! ```
//!
//! An absent option means MathJax (Quarto 1's default). Values Quarto 1
//! recognizes but q2 does not implement (`webtex`, `gladtex`) and unknown
//! strings are kept verbatim as [`MathMethod::Unknown`] so callers can say
//! precisely what they were given.

use quarto_pandoc_types::config_value::ConfigValue;

/// Which math renderer a document asked for.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MathMethod {
/// MathJax 3 in the browser (the default).
Mathjax,
/// KaTeX in the browser.
Katex,
/// Native MathML, converted at render time (bd-3evfzwal).
MathMl,
/// No renderer: the TeX is left as written.
Plain,
/// Anything else, kept verbatim (`webtex`, `gladtex`, typos).
Unknown(String),
}

impl MathMethod {
fn from_name(name: &str) -> Self {
match name {
"mathjax" => Self::Mathjax,
"katex" => Self::Katex,
"mathml" => Self::MathMl,
"plain" => Self::Plain,
other => Self::Unknown(other.to_string()),
}
}
}

/// The parsed option: the method plus the optional loader URL the object
/// form can carry.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MathMethodConfig {
pub method: MathMethod,
/// `url:` from the object form, untouched. Only meaningful for the
/// browser engines; ignored otherwise.
pub url: Option<String>,
}

impl MathMethodConfig {
/// Read `html-math-method` from (format-flattened) document metadata.
/// Absent → MathJax with no URL override.
pub fn from_meta(meta: &ConfigValue) -> Self {
let Some(value) = meta.get("html-math-method") else {
return Self {
method: MathMethod::Mathjax,
url: None,
};
};

if value.is_map() {
let method = value
.get("method")
.and_then(|v| v.as_plain_text())
.map_or(MathMethod::Mathjax, |name| MathMethod::from_name(&name));
let url = value.get("url").and_then(|v| v.as_plain_text());
return Self { method, url };
}

let method = value
.as_plain_text()
.map_or(MathMethod::Mathjax, |name| MathMethod::from_name(&name));
Self { method, url: None }
}
}

#[cfg(test)]
mod tests {
use super::*;
use quarto_pandoc_types::{ConfigMapEntry, ConfigValueKind, MergeOp};
use quarto_source_map::SourceInfo;
use yaml_rust2::Yaml;

fn scalar(s: &str) -> ConfigValue {
ConfigValue {
value: ConfigValueKind::scalar(Yaml::String(s.to_string())),
source_info: SourceInfo::for_test(),
merge_op: MergeOp::Concat,
}
}

fn map(entries: Vec<(&str, ConfigValue)>) -> ConfigValue {
ConfigValue {
value: ConfigValueKind::Map(
entries
.into_iter()
.map(|(k, v)| ConfigMapEntry {
key: k.to_string(),
key_source: SourceInfo::for_test(),
value: v,
})
.collect(),
),
source_info: SourceInfo::for_test(),
merge_op: MergeOp::Concat,
}
}

fn meta_string(s: &str) -> ConfigValue {
map(vec![("html-math-method", scalar(s))])
}

#[test]
fn absent_means_mathjax_without_url() {
let cfg = MathMethodConfig::from_meta(&map(vec![]));
assert_eq!(cfg.method, MathMethod::Mathjax);
assert_eq!(cfg.url, None);
}

#[test]
fn string_form_names_every_method() {
for (name, expected) in [
("mathjax", MathMethod::Mathjax),
("katex", MathMethod::Katex),
("mathml", MathMethod::MathMl),
("plain", MathMethod::Plain),
("webtex", MathMethod::Unknown("webtex".to_string())),
("gladtex", MathMethod::Unknown("gladtex".to_string())),
("mathjx", MathMethod::Unknown("mathjx".to_string())),
] {
let cfg = MathMethodConfig::from_meta(&meta_string(name));
assert_eq!(cfg.method, expected, "{name}");
assert_eq!(cfg.url, None, "{name}");
}
}

#[test]
fn object_form_carries_method_and_url() {
let cfg = MathMethodConfig::from_meta(&map(vec![(
"html-math-method",
map(vec![
("method", scalar("katex")),
("url", scalar("https://example.org/katex/")),
]),
)]));
assert_eq!(cfg.method, MathMethod::Katex);
assert_eq!(cfg.url.as_deref(), Some("https://example.org/katex/"));
}

#[test]
fn object_form_without_method_defaults_to_mathjax() {
let cfg = MathMethodConfig::from_meta(&map(vec![(
"html-math-method",
map(vec![("url", scalar("https://example.org/mj.js"))]),
)]));
assert_eq!(cfg.method, MathMethod::Mathjax);
assert_eq!(cfg.url.as_deref(), Some("https://example.org/mj.js"));
}

#[test]
fn object_form_with_unknown_method_keeps_the_name() {
let cfg = MathMethodConfig::from_meta(&map(vec![(
"html-math-method",
map(vec![("method", scalar("webtex"))]),
)]));
assert_eq!(cfg.method, MathMethod::Unknown("webtex".to_string()));
}
}
44 changes: 29 additions & 15 deletions crates/quarto-core/src/pipeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,11 @@
use crate::stage::stages::ClipboardJsStage;
use crate::stage::{
ApplyTemplateStage, AstTransformsStage, AttributionGenerateStage, CompileThemeCssStage,
DocumentProfileStage, EngineExecutionStage, IncludeExpansionStage, IncludeResolveStage,
LanguageResolveStage, LinkResolutionStage, ListingItemInfoStage, LoadedSource, MathJsStage,
MetadataMergeStage, ParseDocumentStage, Pipeline, PipelineData, PipelineStage,
PreEngineSugaringStage, RenderHtmlBodyStage, ResourceReportStage, SourceConversionStage,
StageContext, UnwrapProfileStage, UserFiltersStage,
DocumentProfileStage, EngineExecutionStage, EquationNumberStage, IncludeExpansionStage,
IncludeResolveStage, LanguageResolveStage, LinkResolutionStage, ListingItemInfoStage,
LoadedSource, MathJsStage, MetadataMergeStage, ParseDocumentStage, Pipeline, PipelineData,
PipelineStage, PreEngineSugaringStage, RenderHtmlBodyStage, ResourceReportStage,
SourceConversionStage, StageContext, UnwrapProfileStage, UserFiltersStage,
};
use crate::transform::TransformPipeline;
use crate::transforms::{
Expand Down Expand Up @@ -353,6 +353,15 @@
// bd-o8pr Phase 3: finalize the per-doc resource report
// (defends against filters that mutate `meta.resources`).
stages.push(Box::new(ResourceReportStage::new()));
// Equation-number encoding (bd-vlhi2zkj). Crossref-render left each
// numbered equation's number on the reserved `quarto-eq-number`
// attribute; this stage turns it into `\tag{N}` (MathJax/KaTeX),
// ` \qquad(N)` (no engine reads `\tag`) or a sibling label (MathML)
// and removes the attribute. It sits *after* `UserFiltersStage::post`
// on purpose: a Lua post filter may rewrite or delete the attribute,
// 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()));
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
Expand Down Expand Up @@ -494,7 +503,7 @@
/// 1. `ParseDocumentStage` - Parse QMD to Pandoc AST
/// 2. `MetadataMergeStage` - Merge project/directory/document/runtime metadata
/// 3. `EngineExecutionStage` - Execute code cells (jupyter, knitr, or markdown passthrough)
/// 4. `CompileThemeCssStage` - Compile theme CSS from merged metadata

Check warning on line 506 in crates/quarto-core/src/pipeline.rs

View workflow job for this annotation

GitHub Actions / Run test suite (ubuntu-latest)

constant `PANDOC_STAGE_EXCLUDED` is never used

Check warning on line 506 in crates/quarto-core/src/pipeline.rs

View workflow job for this annotation

GitHub Actions / Run test suite (macos-latest)

constant `PANDOC_STAGE_EXCLUDED` is never used
/// 5. `UserFiltersStage::pre()` - Apply user filters before Quarto transforms
/// 6. `AstTransformsStage` - Run Quarto transforms (callouts, metadata, etc.)
/// 7. `UserFiltersStage::post()` - Apply user filters after Quarto transforms
Expand Down Expand Up @@ -2169,9 +2178,9 @@
// 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) — so the length is 25, not the 24
// either side had alone.
assert_eq!(stages.len(), 25);
// (bd-toc-tabset-titles-zq93gjvf) — plus EquationNumberStage after
// the post filters (bd-vlhi2zkj): 26.
assert_eq!(stages.len(), 26);
// Pre-parse file-claim/convert (Task 10).
assert_eq!(stages[0].name(), "source-conversion");
assert_eq!(stages[1].name(), "parse-document");
Expand Down Expand Up @@ -2220,22 +2229,27 @@
assert_eq!(stages[19].name(), "user-filters-post");
// bd-o8pr Phase 3: finalize per-doc resource report.
assert_eq!(stages[20].name(), "resource-report");
assert_eq!(stages[21].name(), "code-highlight");
// Equation-number encoding (bd-vlhi2zkj) must follow
// 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");
// 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, crossref `\tag{N}`) is visible.
assert_eq!(stages[22].name(), "math-js");
assert_eq!(stages[23].name(), "render-html-body");
assert_eq!(stages[24].name(), "apply-template");
// 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");
}

#[test]
fn test_build_html_pipeline() {
let pipeline = build_html_pipeline();
// Merged pipeline carries both SourceConversionStage (Task 10, branch)
// LanguageResolveStage and TabsetsJsStage (main) → 25 stages.
assert_eq!(pipeline.len(), 25);
// LanguageResolveStage and TabsetsJsStage (main), plus
// EquationNumberStage (bd-vlhi2zkj) → 26 stages.
assert_eq!(pipeline.len(), 26);
}

#[test]
Expand Down
10 changes: 5 additions & 5 deletions crates/quarto-core/src/stage/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,11 +115,11 @@ pub use stages::CodeHighlightStage;
pub use stages::TabsetsJsStage;
pub use stages::{
ApplyTemplateStage, AstTransformsStage, AttributionGenerateStage, CaptureSpliceStage,
CompileThemeCssStage, DocumentProfileStage, EngineExecutionStage, IncludeExpansionStage,
IncludeResolveStage, LanguageResolveStage, LinkResolutionStage, ListingItemInfoStage,
MathJsStage, MetadataMergeStage, ParseDocumentStage, PreEngineSugaringStage,
RenderHtmlBodyStage, ResourceReportStage, SourceConversionStage, UnwrapProfileStage,
UserFiltersStage, expand_document_includes,
CompileThemeCssStage, DocumentProfileStage, EngineExecutionStage, EquationNumberStage,
IncludeExpansionStage, IncludeResolveStage, LanguageResolveStage, LinkResolutionStage,
ListingItemInfoStage, MathJsStage, MetadataMergeStage, ParseDocumentStage,
PreEngineSugaringStage, RenderHtmlBodyStage, ResourceReportStage, SourceConversionStage,
UnwrapProfileStage, UserFiltersStage, expand_document_includes,
};

// Re-export the trace_event macro
Expand Down
Loading
Loading