From 7d8f094e23bd12c55cc326057be747badfd0792a Mon Sep 17 00:00:00 2001 From: Carlos Scheidegger Date: Mon, 21 Sep 2026 18:01:31 -0500 Subject: [PATCH] Equation numbering as a format-specific post-filter stage (bd-vlhi2zkj) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `CrossrefRenderTransform` appended `\tag{N}` to every numbered equation's TeX. `\tag` is an amsmath command only MathJax and KaTeX read: Quarto 1 emits it solely for those two engines and uses `\qquad(N)` otherwise, and a math-only converter (Pandoc's, or quarto-math's for the coming `html-math-method: mathml`) rejects the whole expression. The encoding is a presentation decision, so it moves out of the format-agnostic transform. - crossref-render now leaves the `Math` text byte-identical and records the number on the reserved span attribute `quarto-eq-number` (`EQ_NUMBER_ATTR` in `crossref/mod.rs`). - New `EquationNumberStage` (after `UserFiltersStage::post`, before `CodeHighlightStage`) picks the encoding from the format and `html-math-method`: `\tag{N}` for MathJax/KaTeX, ` \qquad(N)` for `plain`/unknown, a sibling `span.quarto-eq-number` outside the math (plus a `quarto-eq-sibling-number` modifier class) for `mathml`, nothing for non-HTML formats. It removes the attribute in every case. Running after post filters is the point: a Lua filter can read, rewrite or delete `el.attributes["quarto-eq-number"]`. - New `math_method.rs` parses `html-math-method` once (string and object forms); `MathEngine::from_meta` in math-js maps over it so the two stages cannot drift. - New shared SCSS layer `equation-number.scss` (loaded at every HTML compile site and in `assemble_reveal_scss`, like `copy-code.scss`) lays the sibling label out at the right edge of the equation row. - Design doc gains a "post-filter presentation slot" section; the Lua filters guide documents the attribute; `Equation.tsx`'s comment now points at the stage. Tests (written first): crossref-render unit tests assert the attribute instead of the tag; 13 stage unit tests (encodings, selection, walker); 13 end-to-end tests in `equation_numbering_pipeline.rs` covering every encoding, revealjs, KaTeX, the object form, unlabelled math, sequential numbering, and Lua post filters rewriting/deleting the number plus the pin that pre filters never see it; quarto-sass asserts the layer in the HTML and revealjs CSS. Collateral test updates: HTML stage-list assertions (25 → 26 stages); the `styles.css` byte-identity baseline in `tests/fixtures/phase5-single-doc-baseline/expected_hashes.txt` is re-captured with a dated note (additive layer; doc.html unchanged). End-to-end (real binary, output inspected, recorded in the plan): default → `\tag{1}` inside the math with MathJax loaded; `plain` → ` \qquad(1)`; `mathml` → `(1)` after the math, no engine loaded; the compiled stylesheet carries the three `.quarto-eq-sibling-number` rules. Plan: claude-notes/plans/2026-09-21-equation-numbering-and-mathml.md Co-Authored-By: Claude Fable 5.1 --- .../designs/transform-pipeline-phases.md | 35 + ...026-09-21-equation-numbering-and-mathml.md | 340 +++++++++ crates/quarto-core/src/crossref/mod.rs | 16 + crates/quarto-core/src/lib.rs | 1 + crates/quarto-core/src/math_method.rs | 185 +++++ crates/quarto-core/src/pipeline.rs | 44 +- crates/quarto-core/src/stage/mod.rs | 10 +- .../src/stage/stages/equation_number.rs | 657 ++++++++++++++++++ .../quarto-core/src/stage/stages/math_js.rs | 62 +- crates/quarto-core/src/stage/stages/mod.rs | 8 + .../src/transforms/crossref_render.rs | 131 ++-- .../expected_hashes.txt | 14 +- .../equation_numbering_pipeline.rs | 309 ++++++++ crates/quarto-core/tests/integration/main.rs | 1 + crates/quarto-sass/src/bundle.rs | 31 +- crates/quarto-sass/src/compile.rs | 59 +- docs/guides/authoring/lua-filters.qmd | 25 + .../scss/html/templates/equation-number.scss | 36 + .../src/q2-preview/custom/Equation.tsx | 17 +- 19 files changed, 1850 insertions(+), 131 deletions(-) create mode 100644 claude-notes/plans/2026-09-21-equation-numbering-and-mathml.md create mode 100644 crates/quarto-core/src/math_method.rs create mode 100644 crates/quarto-core/src/stage/stages/equation_number.rs create mode 100644 crates/quarto-core/tests/integration/equation_numbering_pipeline.rs create mode 100644 resources/scss/html/templates/equation-number.scss diff --git a/claude-notes/designs/transform-pipeline-phases.md b/claude-notes/designs/transform-pipeline-phases.md index 346534b6f..e5b8d4043 100644 --- a/claude-notes/designs/transform-pipeline-phases.md +++ b/claude-notes/designs/transform-pipeline-phases.md @@ -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. diff --git a/claude-notes/plans/2026-09-21-equation-numbering-and-mathml.md b/claude-notes/plans/2026-09-21-equation-numbering-and-mathml.md new file mode 100644 index 000000000..2741b77a5 --- /dev/null +++ b/claude-notes/plans/2026-09-21-equation-numbering-and-mathml.md @@ -0,0 +1,340 @@ +# Format-specific equation numbering and `html-math-method: mathml` + +**Status:** approved 2026-09-21 (all five open decisions settled with the +user, each as recommended); executing Phase 1. +**Strands:** bd-vlhi2zkj (Phase 1, equation numbering; branch +`braid/bd-vlhi2zkj-equation-numbering` off `main`), bd-9z83tcv0 (Phase 2, +MathML writer, p2), bd-3evfzwal (Phase 3, `MathMlStage`; blocked on the +other two). +**Parent work:** `claude-notes/plans/2026-09-21-quarto-math-and-native-docx.md` +(quarto-math, PR #706). This plan is the "interlude": it gives quarto-math a +real consumer (`format: html`) before the docx writer exists. + +## Overview + +Two things, in dependency order. + +1. **Equation numbering becomes a format-specific presentation step.** + Today `render_equation` (`crates/quarto-core/src/transforms/crossref_render.rs`) + appends `\tag{N}` to the TeX of every numbered equation, unconditionally, + inside the format-agnostic `CrossrefRenderTransform`. `\tag` is an + `amsmath` command that only MathJax and KaTeX understand: Quarto 1 + (`crossref/equations.lua`, `renderEquation`) emits it *only* for HTML with + `mathjax`/`katex`, uses `\qquad(N)` for every other math engine, and + defers to the writer for LaTeX (`equation` + `\label`) and Typst + (`#math.equation(numbering:)`). Pandoc's converter rejects `\tag` in + `$$…$$` (verified with pandoc 3.9: warning + verbatim fallback under + `--mathml` and `-t docx`), and quarto-math's spec marks it unsupported + (verified: an `Error` node, output withheld). The number encoding is + presentation, so it moves out of crossref-render into a step selected by + the format and `html-math-method`. + + The hand-off between the two is a **reserved attribute on the equation + `Span`**. Crossref-render leaves the `Math` node byte-identical to the + source and records the number as `quarto-eq-number="N"`; the numbering + step consumes and removes it. Because the step runs *after* user post + filters, a Lua filter can read, rewrite or drop the attribute — the + escape hatch — without knowing anything else about the pipeline. + +2. **`html-math-method: mathml`** renders each `Inline::Math` to MathML Core + at render time through a new `quarto_math` writer, so common documents + ship no MathJax. Today the option is a silent no-op (verified on the PR + #706 tip: raw `\(…\)` TeX, no renderer loaded). With (1) in place the + MathML path needs no `\tag` support: the number is a sibling inline + outside ``, which MathML Core requires anyway (`mlabeledtr` was + dropped from Core). + +### Why the numbering step is a *stage*, not a Finalization transform + +The HTML stage list (`build_html_pipeline_stages_with_options`, +`crates/quarto-core/src/pipeline.rs`) is + +``` +… → UserFiltersStage::pre → AstTransformsStage (all four phases, incl. +CrossrefRenderTransform) → UserFiltersStage::post → ResourceReportStage → +CodeHighlightStage → MathJsStage → RenderHtmlBodyStage → ApplyTemplateStage +``` + +Every Finalization transform runs inside `AstTransformsStage`, *before* post +filters. A numbering transform there would consume the reserved attribute +before any user filter could see it. Placing the step as a stage between +`UserFiltersStage::post` and `MathJsStage` (the slot `CodeHighlightStage` +already occupies: AST-level, format-aware, post-filter) keeps the attribute +visible to post filters and still satisfies the phase contract's intent +(presentation after crossref-render). The design doc gets a paragraph naming +this slot (item in Phase 1). + +### The reserved attribute + +- Key: `quarto-eq-number` (kv on the `Span#eq-… .quarto-math-with-attribute` + that `render_equation` already emits). `quarto-` prefix matches the other + reserved names transforms consume (`quarto-template-params`, + `quarto-reuse`, `quarto-xref`). +- Value: the number as text, exactly what would be typeset (`1`, later + `2.3` when section-prefixed numbering lands; that is out of scope here). +- Lifetime: written by `CrossrefRenderTransform`, readable by post filters, + removed by `EquationNumberStage` in every strategy (so it never reaches the + writer, in any format). +- Lua view: `el.attributes["quarto-eq-number"]` on a `Span` whose + `identifier` starts with `eq-`. Deleting the attribute suppresses the + number; changing it changes the label. Documented for filter authors. + +### Numbering strategies + +Selected by `EquationNumberStage` from `ctx.format` and the document's +`html-math-method` (already flattened to top level by `resolve_format_config`; +parsed by a shared `MathMethod` enum, see Phase 1): + +| Strategy | When | Effect on `[Math(Display, t)]` | +| --- | --- | --- | +| `TexTag` | HTML-based format, method `mathjax` / `katex` / absent | `t` becomes `t\tag{N}` (today's behaviour, with the same `text_source` concat provenance) | +| `Qquad` | HTML-based, method `plain` / `webtex` / `gladtex` / unknown | `t` becomes `t \qquad(N)` (Quarto 1's non-JS encoding) | +| `Sibling` | HTML-based, method `mathml` | `Math` untouched; a `Span.quarto-eq-number` containing `Str("(N)")` is appended after it inside the equation span, and the equation span gains class `quarto-eq-sibling-number` for CSS | +| `Writer` | LaTeX / Typst / docx (native writers, future) | `Math` untouched; the writer numbers. Placeholder: q2 renders only HTML and revealjs natively today, and PR #704 routes docx/Typst through Quarto 1's Lua filters, which do their own numbering | + +`Sibling` is robust to a failed MathML conversion: if the expression falls +back to TeX + MathJax (Phase 3 hybrid), the number is still there, outside +the math. + +### MathML output shape + +```html + + + + + ORIGINAL TEX + + + +``` + +The existing `span.math.inline` / `span.math.display` wrappers stay (user +CSS and the preview-parity tooling key on them; Pandoc drops the span for +display math, we do not). The annotation carries the source TeX for +copy/paste and assistive tools. Emitted as `RawInline("html", …)` replacing +the `Inline::Math`, which the HTML writer passes through verbatim. + +## Checklist + +### Phase 0 — setup + +- [x] (done 2026-09-21: bd-vlhi2zkj, bd-3evfzwal; bd-9z83tcv0 → p2) File the two new strands (numbering restructure; HTML `mathml` stage) + with `discovered-from: bd-entbg6x3`, `related: bd-9z83tcv0`; link this + plan. Reprioritise bd-9z83tcv0 from p3 to p2. +- [x] (branch created 2026-09-21) Phase 1 branches off `main` (it does not touch quarto-math) and lands + as its own PR, same pattern as decision 5 of the parent plan. Phases 2 + and 3 branch off `feature/bd-entbg6x3-quarto-math` and stack on #706; + Phase 3 merges `main` once Phase 1 is in. + +### Phase 1 — equation numbering as a format-specific stage (new strand, PR against `main`) + +Tests first, in this order. + +- [x] (2026-09-21) **Unit tests in `crossref_render.rs`**: `render_equation` leaves + `math.text` and `math.text_source` untouched and sets + `quarto-eq-number` on the span; an unnumbered equation sets no + attribute. Rewrite the three existing tests that assert `\tag{1}` in + the math text (`…:2494`, `:2536`, `:2595`) to assert the attribute. +- [x] (2026-09-21; 13 tests in `equation_number.rs`) **`EquationNumberStage` unit tests** (one per strategy): `TexTag` + appends `\tag{N}` and extends `text_source` as a concat with a + synthesized piece (move the provenance code from `render_equation`); + `Qquad` appends ` \qquad(N)`; `Sibling` appends the label span and the + modifier class; every strategy removes the attribute; a span without + the attribute is untouched; an equation span whose first inline is not + `Math(DisplayMath)` is left alone with a debug trace (mirrors the + preview's `Equation.tsx` fallback rules). +- [x] (2026-09-21; `math_method.rs` + `NumberEncoding::for_document` tests) **Strategy selection tests**: `MathMethod::from_meta` for the string + and object forms (`mathjax`, `katex`, `mathml`, `plain`, `webtex`, + `gladtex`, unknown, absent); `strategy_for(format, method)` table. +- [x] (2026-09-21; new file `tests/integration/equation_numbering_pipeline.rs`, 13 tests, so the math-js suite stays about engine injection) **End-to-end tests** + (drive `render_to_file`): default → `\tag{1}` present and MathJax + loaded (the existing `labelled_equation_emits_mathjax_and_tag` keeps + passing); `html-math-method: plain` → `\qquad(1)` present, no + `\tag`, no loader; `html-math-method: mathml` → `span.quarto-eq-number` + present, `\tag` absent (the math itself stays TeX until Phase 3); + revealjs → `\tag{1}`. +- [x] (2026-09-21; same file; also pins that a *pre* filter does not see the attribute) **Lua escape-hatch test**: a post filter that reads + `el.attributes["quarto-eq-number"]` and rewrites it to `A` yields + `\tag{A}` in the output; a filter that deletes it yields an unnumbered + equation. Place next to the existing user-filter integration tests. +- [x] (2026-09-21) Implement: `MathMethod` enum in a new + `crates/quarto-core/src/math_method.rs` (string + object forms; the + only parser of `html-math-method`), and make `MathEngine::from_meta` + in `math_js.rs` a thin mapping over it so the two stages cannot drift. +- [x] (2026-09-21; `EQ_NUMBER_ATTR` lives in `crossref/mod.rs` next to the `EQUATION` type name) Implement: `render_equation` writes the attribute instead of the tag. +- [x] (2026-09-21; registered between `ResourceReportStage` and `CodeHighlightStage`) Implement: `crates/quarto-core/src/stage/stages/equation_number.rs`, + registered between `UserFiltersStage::post` and `CodeHighlightStage` + in `build_html_pipeline_stages_with_options`. Included in the + q2-preview stage list (it is a no-op there: the preview excludes + crossref-render, so no span carries the attribute, and `Equation.tsx` + keeps its own KaTeX `\tag` append). Add the stage name to the + preview-exclusion validator's known list if the test requires it. +- [x] (2026-09-21; **placement changed from the plan**: not `_bootstrap-rules.scss` but a shared layer `resources/scss/html/templates/equation-number.scss`, loaded by `load_equation_number_layer` in `quarto-sass` at the five HTML compile sites and in `assemble_reveal_scss`, exactly like `copy-code.scss`. Reason: revealjs is HTML-based and gets the `Sibling` encoding too, and the bootstrap rules file is not bundled into decks. Tests: `test_compile_default_css`, `test_compile_reveal_theme_includes_equation_number_rules`.) CSS for `Sibling`: `.quarto-eq-sibling-number` + as a flex row with the math centered and the label pushed right. +- [x] (2026-09-21; new section "The post-filter presentation slot") Design doc: add the "post-filter presentation stage" slot to + `claude-notes/designs/transform-pipeline-phases.md`, naming + `CodeHighlightStage` and `EquationNumberStage` as its members and + stating the rule: a step that must remain visible to user post filters + is a stage here, not a Finalization transform. +- [x] (2026-09-21; placed in `docs/guides/authoring/lua-filters.qmd` as its own section, since that is where filter authors look; the cross-reference page in that directory is misnamed `cross-references.cmd`, flagged to the user) Docs (user-facing): + one short "for filter authors" note on `quarto-eq-number`. +- [x] (2026-09-21) Update the `Equation.tsx` comment that cites `render_equation`'s + line numbers and the `\tag` port, so the two stay traceable. +- [x] (2026-09-21: full `cargo xtask verify` green — 14049 Rust tests, ts-packages, hub-client build + tests. Two collateral test updates: the HTML stage-list/count assertions in `pipeline.rs` (25 → 26 stages) and the `styles.css` byte-identity baseline in `tests/fixtures/phase5-single-doc-baseline/expected_hashes.txt`, re-captured with a dated note because the new SCSS layer is additive to every compiled stylesheet.) `cargo xtask verify` (full: `quarto-core` changed). Record the + end-to-end invocation and the inspected output here. + +**End-to-end verification (2026-09-21, real binary, output inspected).** +Three copies of the same document, differing only in front matter +(`html-math-method` absent / `mathml` / `plain`), each with +`$$\sum_{i=1}^{n} \alpha_i = \int_0^\infty e^{-x}\,dx$$ {#eq-one}` and a +`@eq-one` reference, rendered with +`cargo run --bin q2 -- render /{default,mathml,plain}.qmd`. The +equation markup in each `.html`: + +```html + +\[ +\sum_{i=1}^{n} \alpha_i = \int_0^\infty e^{-x}\,dx +\tag{1}\] + +\[ +\sum_{i=1}^{n} \alpha_i = \int_0^\infty e^{-x}\,dx +\](1) + +\[ +\sum_{i=1}^{n} \alpha_i = \int_0^\infty e^{-x}\,dx + \qquad(1)\] +``` + +`See Equation 1.` in all three; +`quarto-eq-number="…"` appears in none. `_files/styles.css` of each +contains the three `.quarto-eq-sibling-number…` rules from +`equation-number.scss`. + +**Follow-up for the #706 rebase.** On `main` today `Math` has no +`text_source`; PR #705 adds it and PR #706's `render_equation` extends the +mapping with a synthesized piece for the appended `\tag`. After this phase +merges, that provenance code belongs in `EquationNumberStage::encode_number` +(the `TexTag` and `Qquad` arms are the only places that append to the +text). Noted in the parent plan's merge-readiness section on the #706 +branch when it rebases. + +### Phase 2 — MathML writer in quarto-math (bd-9z83tcv0, stacked on #706) + +Mirrors `typst.rs`: one file, one snapshot per fixture, a corpus-wide +validity check standing in for the compile check Typst has. + +- [ ] **Validity test** (`tests/integration/mathml.rs`): every fixture's + output parses with `quick-xml`, uses only MathML Core elements + (`math mrow mi mn mo mtext mspace ms msub msup msubsup munder mover + munderover mfrac msqrt mroot mtable mtr mtd mstyle mpadded mphantom + merror semantics annotation`, plus `menclose`, see decisions), and + the only `mathvariant` value is `normal`. Snapshot per fixture. +- [ ] **Structural tests**: `Run` splits to `mi`/`mn`/`mo` via + `split::split_run` with a single-letter `mi` italic by default; + `Nary` + `Scripts` → `munderover` in display / `msubsup` inline + (`LimLoc` rules identical to OMML); `Func` → `mi` + U+2061 function + application; `Delimited` → `mrow` with stretchy fence `mo`s and + `\middle` separators; `Frac` styles (`NoBar` → `linethickness="0"`, + `Binom` wrapped in parentheses, `Display`/`Text` → `mstyle + displaystyle`); `Sqrt` → `msqrt`/`mroot`; `Accent` → `mover + accent="true"`; `Bar`/`GroupChr`/`LimPos`/`XArrow` → `munder`/`mover`; + `Matrix` layouts → `mtable` (`Cases` with a left `{` fence and + `columnalign="left"`, `Aligned` with alternating right/left + alignment, `Gathered` centered, `Matrix` inside its fences); + `Break` → the whole row becomes a two-row `mtable`; `Space` → + `mspace width="…em"` (negative widths allowed in Core); + `Phantom` → `mphantom` (+ `mpadded` for `h`/`v` only); + `Color` → `mstyle mathcolor`; `Text` → `mtext`. +- [ ] **Style variants**: `Style { variant }` maps each letter and digit of + its body into the Mathematical Alphanumeric Symbols block + (`U+1D400…`), with the reserved-codepoint holes table (`ℎ ℬ ℰ ℱ ℋ ℐ + ℒ ℳ ℛ ℂ ℍ ℕ ℙ ℚ ℝ ℤ ℭ ℌ ℑ ℜ ℨ`, plus the `Roman` variant which is + `mathvariant="normal"` on a single-letter `mi`). Unit test the table + against the Unicode chart for one letter per variant and every hole. +- [ ] **Escaping**: `<`, `&`, `>` in `mo`/`mi`/`mtext`/`annotation`. +- [ ] Implement `crates/quarto-math/src/mathml.rs`, `Target::MathMl` in + `convert.rs`, `render()` arm, crate docs. Runs `split_run` on every + `Run` (the pass the parent plan reserved for this). +- [ ] `cargo xtask verify --skip-hub-build` (quarto-math only), then the + full verify once Phase 3 touches `quarto-core`. + +### Phase 3 — `MathMlStage` for `format: html` (new strand, stacked on #706 + Phase 1) + +- [ ] **End-to-end tests in `math_mode_pipeline.rs`**: `html-math-method: + mathml` with inline + display + numbered math → ` 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, +} + +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())); + } +} diff --git a/crates/quarto-core/src/pipeline.rs b/crates/quarto-core/src/pipeline.rs index aa55e8828..e126db139 100644 --- a/crates/quarto-core/src/pipeline.rs +++ b/crates/quarto-core/src/pipeline.rs @@ -60,11 +60,11 @@ use crate::stage::stages::BootstrapJsStage; 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::{ @@ -353,6 +353,15 @@ pub fn build_html_pipeline_stages_with_options( // 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 @@ -2169,9 +2178,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) — 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"); @@ -2220,22 +2229,27 @@ mod tests { 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] diff --git a/crates/quarto-core/src/stage/mod.rs b/crates/quarto-core/src/stage/mod.rs index 604addbfd..e2e185dcd 100644 --- a/crates/quarto-core/src/stage/mod.rs +++ b/crates/quarto-core/src/stage/mod.rs @@ -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 diff --git a/crates/quarto-core/src/stage/stages/equation_number.rs b/crates/quarto-core/src/stage/stages/equation_number.rs new file mode 100644 index 000000000..f3fec9b05 --- /dev/null +++ b/crates/quarto-core/src/stage/stages/equation_number.rs @@ -0,0 +1,657 @@ +/* + * stage/stages/equation_number.rs + * Copyright (c) 2026 Posit, PBC + * + * Encode equation numbers the way the document's math renderer needs. + */ + +//! Encode each numbered equation's number for the selected math renderer. +//! +//! ## Why this is a stage after user post filters +//! +//! `CrossrefRenderTransform` (Finalization phase, inside +//! `AstTransformsStage`) numbers equations but does not decide how the +//! number is *typeset*: it leaves the `Math` text byte-identical to the +//! source and records the number as the reserved +//! [`EQ_NUMBER_ATTR`](crate::crossref::EQ_NUMBER_ATTR) attribute on the +//! equation span. That encoding is a presentation decision that depends on +//! the format and on `html-math-method` — `amsmath`'s `\tag{N}` is only +//! understood by MathJax and KaTeX; a converter that reads math alone +//! (MathML, Pandoc's, quarto-math's) rejects it — so it is made here, in a +//! stage that runs after `UserFiltersStage::post`. Running after the post +//! filters is the point: a Lua filter can read, rewrite or delete +//! `el.attributes["quarto-eq-number"]` and this stage honours the result. +//! (A Finalization *transform* would run before those filters and consume +//! the attribute first.) `CodeHighlightStage` occupies the same slot for +//! the same reason. Design: `claude-notes/designs/transform-pipeline-phases.md`; +//! plan: `claude-notes/plans/2026-09-21-equation-numbering-and-mathml.md`. +//! +//! ## Encodings +//! +//! | [`NumberEncoding`] | when | effect on `Span[Math(Display, t)]` | +//! |---|---|---| +//! | `TexTag` | HTML-based format, MathJax or KaTeX | `t` → `t\tag{N}` | +//! | `Qquad` | HTML-based, `plain` or an unknown method | `t` → `t \qquad(N)` (Quarto 1's non-JS encoding) | +//! | `Sibling` | HTML-based, `mathml` | `t` untouched; a `Span.quarto-eq-number` with `(N)` follows the math, and the equation span gains `quarto-eq-sibling-number` | +//! | `Writer` | every other format | `t` untouched; the format's writer numbers | +//! +//! In every case the attribute is removed, so it never reaches a writer. +//! A span whose first inline is not `Math(DisplayMath)` (a filter replaced +//! it) only loses the attribute; it is traced, not changed. + +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, MathType, Span, Str}; + +use crate::crossref::EQ_NUMBER_ATTR; +use crate::format::Format; +use crate::math_method::{MathMethod, MathMethodConfig}; +use crate::stage::{ + EventLevel, PipelineData, PipelineDataKind, PipelineError, PipelineStage, StageContext, +}; +use crate::trace_event; + +/// Class of the sibling label span the `Sibling` encoding appends after +/// the math: `(1)`. +pub const EQ_NUMBER_LABEL_CLASS: &str = "quarto-eq-number"; + +/// Modifier class the `Sibling` encoding adds to the equation span so the +/// stylesheet can lay the math and its label out as one row. +pub const EQ_SIBLING_NUMBER_CLASS: &str = "quarto-eq-sibling-number"; + +/// How an equation number is written into the document. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NumberEncoding { + /// `\tag{N}` appended to the TeX (MathJax, KaTeX). + TexTag, + /// ` \qquad(N)` appended to the TeX (no engine reads `\tag`). + Qquad, + /// A label span after the math, outside it (MathML). + Sibling, + /// Nothing: the format's writer numbers equations itself. + Writer, +} + +impl NumberEncoding { + /// The encoding for a document rendered to `format` with the given + /// math method. + pub fn for_document(format: &Format, method: &MathMethod) -> Self { + if !format.identifier.is_html_based() { + return Self::Writer; + } + match method { + MathMethod::Mathjax | MathMethod::Katex => Self::TexTag, + MathMethod::MathMl => Self::Sibling, + MathMethod::Plain | MathMethod::Unknown(_) => Self::Qquad, + } + } +} + +/// Encode every `quarto-eq-number` attribute for the document's renderer +/// and remove it. +pub struct EquationNumberStage; + +impl EquationNumberStage { + pub fn new() -> Self { + Self + } +} + +impl Default for EquationNumberStage { + fn default() -> Self { + Self::new() + } +} + +#[async_trait(?Send)] +impl PipelineStage for EquationNumberStage { + fn name(&self) -> &str { + "equation-number" + } + + 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; + 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); + } + + if outcome.encoded > 0 || outcome.non_canonical > 0 { + trace_event!( + ctx, + EventLevel::Debug, + "equation-number: {:?} for {} equation(s), {} left as-is (first inline is not display math)", + encoding, + outcome.encoded, + outcome.non_canonical + ); + } + + Ok(PipelineData::DocumentAst(doc)) + } +} + +#[derive(Default)] +struct Outcome { + encoded: usize, + non_canonical: usize, +} + +/// Apply `encoding` to one equation span that carried `number`. Returns +/// `false` when the span is not the canonical `[Math(DisplayMath), …]` +/// shape, in which case nothing but the attribute changes. +pub fn encode_number(span: &mut Span, number: &str, encoding: NumberEncoding) -> bool { + let Some(Inline::Math(math)) = span.content.first_mut() else { + return false; + }; + if math.math_type != MathType::DisplayMath { + return false; + } + match encoding { + NumberEncoding::TexTag => math.text.push_str(&format!("\\tag{{{number}}}")), + NumberEncoding::Qquad => math.text.push_str(&format!(" \\qquad({number})")), + NumberEncoding::Sibling => { + let source_info = span.source_info.clone(); + span.attr.1.push(EQ_SIBLING_NUMBER_CLASS.to_string()); + span.content.insert( + 1, + Inline::Span(Span { + attr: ( + String::new(), + vec![EQ_NUMBER_LABEL_CLASS.to_string()], + Default::default(), + ), + content: vec![Inline::Str(Str { + text: format!("({number})"), + source_info: source_info.clone(), + })], + source_info, + attr_source: AttrSourceInfo::empty(), + }), + ); + } + NumberEncoding::Writer => {} + } + true +} + +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); + } + } + 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), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::format::FormatIdentifier; + use hashlink::LinkedHashMap; + use quarto_pandoc_types::inline::Math; + use quarto_source_map::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(), + }) + } + + /// The span `CrossrefRenderTransform` emits for a numbered equation. + fn numbered_span(number: &str, content: Vec) -> Span { + let mut kvs = LinkedHashMap::new(); + kvs.insert(EQ_NUMBER_ATTR.to_string(), number.to_string()); + Span { + attr: ( + "eq-x".to_string(), + vec!["quarto-math-with-attribute".to_string()], + kvs, + ), + content, + source_info: si(), + attr_source: AttrSourceInfo::empty(), + } + } + + fn math_text(span: &Span) -> &str { + let Inline::Math(m) = &span.content[0] else { + panic!("first inline is not Math: {:?}", span.content[0]); + }; + &m.text + } + + // ── encode_number ────────────────────────────────────────────── + + #[test] + fn tex_tag_appends_amsmath_tag() { + let mut span = numbered_span("3", vec![math(MathType::DisplayMath, "e = mc^2")]); + assert!(encode_number(&mut span, "3", NumberEncoding::TexTag)); + assert_eq!(math_text(&span), "e = mc^2\\tag{3}"); + assert_eq!(span.content.len(), 1); + assert_eq!(span.attr.1, vec!["quarto-math-with-attribute"]); + } + + #[test] + fn qquad_appends_spaced_parenthesized_number() { + let mut span = numbered_span("3", vec![math(MathType::DisplayMath, "e = mc^2")]); + assert!(encode_number(&mut span, "3", NumberEncoding::Qquad)); + assert_eq!(math_text(&span), "e = mc^2 \\qquad(3)"); + assert_eq!(span.content.len(), 1); + } + + #[test] + fn sibling_appends_label_span_and_modifier_class() { + let mut span = numbered_span("3", vec![math(MathType::DisplayMath, "e = mc^2")]); + assert!(encode_number(&mut span, "3", NumberEncoding::Sibling)); + assert_eq!(math_text(&span), "e = mc^2", "the TeX is untouched"); + assert_eq!( + span.attr.1, + vec!["quarto-math-with-attribute", EQ_SIBLING_NUMBER_CLASS] + ); + assert_eq!(span.content.len(), 2); + let Inline::Span(label) = &span.content[1] else { + panic!("expected the label span, got {:?}", span.content[1]); + }; + assert_eq!(label.attr.0, ""); + assert_eq!(label.attr.1, vec![EQ_NUMBER_LABEL_CLASS]); + assert!(label.attr.2.is_empty()); + let [Inline::Str(s)] = label.content.as_slice() else { + panic!("label content: {:?}", label.content); + }; + assert_eq!(s.text, "(3)"); + } + + #[test] + fn sibling_label_goes_right_after_the_math() { + let mut span = numbered_span( + "1", + vec![ + math(MathType::DisplayMath, "x"), + Inline::Str(Str { + text: "trailing".to_string(), + source_info: si(), + }), + ], + ); + assert!(encode_number(&mut span, "1", NumberEncoding::Sibling)); + assert!(matches!(&span.content[0], Inline::Math(_))); + assert!(matches!(&span.content[1], Inline::Span(l) if l.attr.1 == [EQ_NUMBER_LABEL_CLASS])); + 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); + } + + #[test] + fn labels_are_used_verbatim() { + // A post filter may have rewritten the number to any text. + let mut span = numbered_span("A.2", vec![math(MathType::DisplayMath, "x")]); + assert!(encode_number(&mut span, "A.2", NumberEncoding::TexTag)); + assert_eq!(math_text(&span), "x\\tag{A.2}"); + } + + #[test] + fn non_canonical_first_inline_is_not_touched() { + for content in [ + vec![math(MathType::InlineMath, "x")], + vec![Inline::Str(Str { + text: "replaced".to_string(), + source_info: si(), + })], + vec![], + ] { + for encoding in [ + NumberEncoding::TexTag, + NumberEncoding::Qquad, + NumberEncoding::Sibling, + ] { + let mut span = numbered_span("1", content.clone()); + let before = span.clone(); + assert!( + !encode_number(&mut span, "1", encoding), + "{encoding:?} on {content:?}" + ); + assert_eq!(span, before, "{encoding:?} must not change {content:?}"); + } + } + } + + // ── NumberEncoding::for_document ─────────────────────────────── + + fn format(identifier: FormatIdentifier) -> Format { + Format { + identifier, + ..Format::html() + } + } + + #[test] + fn html_and_revealjs_pick_by_method() { + for id in [FormatIdentifier::Html, FormatIdentifier::Revealjs] { + let f = format(id); + assert_eq!( + NumberEncoding::for_document(&f, &MathMethod::Mathjax), + NumberEncoding::TexTag + ); + assert_eq!( + NumberEncoding::for_document(&f, &MathMethod::Katex), + NumberEncoding::TexTag + ); + assert_eq!( + NumberEncoding::for_document(&f, &MathMethod::MathMl), + NumberEncoding::Sibling + ); + assert_eq!( + NumberEncoding::for_document(&f, &MathMethod::Plain), + NumberEncoding::Qquad + ); + assert_eq!( + NumberEncoding::for_document(&f, &MathMethod::Unknown("webtex".into())), + NumberEncoding::Qquad + ); + } + } + + #[test] + fn non_html_formats_defer_to_the_writer() { + for id in [ + FormatIdentifier::Pdf, + FormatIdentifier::Docx, + FormatIdentifier::Typst, + ] { + for method in [MathMethod::Mathjax, MathMethod::MathMl, MathMethod::Plain] { + assert_eq!( + NumberEncoding::for_document(&format(id), &method), + NumberEncoding::Writer, + "{id:?} {method:?}" + ); + } + } + } + + // ── the walker ───────────────────────────────────────────────── + + fn walk(blocks: &mut [Block], encoding: NumberEncoding) -> Outcome { + let mut out = Outcome::default(); + visit_blocks(blocks, encoding, &mut out); + out + } + + fn para(content: Vec) -> Block { + Block::Paragraph(quarto_pandoc_types::block::Paragraph { + content, + source_info: si(), + }) + } + + #[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::inline::Emph; + + let eq = || Inline::Span(numbered_span("1", vec![math(MathType::DisplayMath, "x")])); + let mut custom = CustomNode::new("Anything", quarto_pandoc_types::attr::empty_attr(), si()); + custom + .slots + .insert("content".to_string(), Slot::Inlines(vec![eq()])); + let mut blocks = vec![ + para(vec![eq()]), + Block::Div(Div { + attr: quarto_pandoc_types::attr::empty_attr(), + content: vec![para(vec![Inline::Emph(Emph { + content: vec![eq()], + source_info: si(), + })])], + source_info: si(), + attr_source: AttrSourceInfo::empty(), + }), + Block::BulletList(BulletList { + content: vec![vec![para(vec![eq()])]], + source_info: si(), + }), + Block::Custom(custom), + ]; + let out = walk(&mut blocks, NumberEncoding::TexTag); + assert_eq!(out.encoded, 4); + assert_eq!(out.non_canonical, 0); + + // Every span lost its attribute and gained the tag. + let mut seen = 0; + let mut check = |inline: &Inline| { + if let Inline::Span(s) = inline { + assert!(!s.attr.2.contains_key(EQ_NUMBER_ATTR)); + assert_eq!(math_text(s), "x\\tag{1}"); + seen += 1; + } + }; + for block in &blocks { + match block { + Block::Paragraph(p) => p.content.iter().for_each(&mut check), + Block::Div(d) => { + let Block::Paragraph(p) = &d.content[0] else { + panic!() + }; + let Inline::Emph(e) = &p.content[0] else { + panic!() + }; + e.content.iter().for_each(&mut check); + } + Block::BulletList(bl) => { + let Block::Paragraph(p) = &bl.content[0][0] else { + panic!() + }; + p.content.iter().for_each(&mut check); + } + Block::Custom(c) => { + let Some(Slot::Inlines(is)) = c.slots.get("content") else { + panic!() + }; + is.iter().for_each(&mut check); + } + other => panic!("{other:?}"), + } + } + assert_eq!(seen, 4); + } + + #[test] + fn walker_removes_the_attribute_even_when_it_cannot_encode() { + let mut blocks = vec![para(vec![Inline::Span(numbered_span( + "1", + vec![math(MathType::InlineMath, "x")], + ))])]; + let out = walk(&mut blocks, NumberEncoding::TexTag); + assert_eq!((out.encoded, out.non_canonical), (0, 1)); + let Block::Paragraph(p) = &blocks[0] else { + panic!() + }; + let Inline::Span(s) = &p.content[0] else { + panic!() + }; + assert!(!s.attr.2.contains_key(EQ_NUMBER_ATTR)); + assert_eq!(math_text(s), "x"); + } + + #[test] + fn walker_ignores_spans_without_the_attribute() { + let mut span = numbered_span("1", vec![math(MathType::DisplayMath, "x")]); + span.attr.2.clear(); + let mut blocks = vec![para(vec![Inline::Span(span.clone())])]; + let out = walk(&mut blocks, NumberEncoding::TexTag); + assert_eq!((out.encoded, out.non_canonical), (0, 0)); + let Block::Paragraph(p) = &blocks[0] else { + panic!() + }; + assert_eq!(p.content[0], Inline::Span(span)); + } +} diff --git a/crates/quarto-core/src/stage/stages/math_js.rs b/crates/quarto-core/src/stage/stages/math_js.rs index 02693c320..07d2ce747 100644 --- a/crates/quarto-core/src/stage/stages/math_js.rs +++ b/crates/quarto-core/src/stage/stages/math_js.rs @@ -59,6 +59,7 @@ use quarto_pandoc_types::inline::Inline; use quarto_pandoc_types::pandoc::Pandoc; use quarto_source_map::{By, SourceInfo}; +use crate::math_method::{MathMethod, MathMethodConfig}; use crate::stage::{ EventLevel, PipelineData, PipelineDataKind, PipelineError, PipelineStage, StageContext, }; @@ -101,53 +102,30 @@ impl MathEngine { } } - /// Parse the `html-math-method` value out of the document metadata. + /// Select the engine from the document's `html-math-method`. /// - /// Accepts both forms supported by Quarto 1 / Pandoc: - /// - **String form** — `html-math-method: mathjax | katex`. Engine - /// is selected; URL falls back to the engine default. - /// - **Object form** — `html-math-method: { method: ..., url: ... }`. - /// Both fields honored; `url` overrides the default. The `method` - /// key is required in this shape; if missing, we fall back to the - /// default engine. + /// Parsing is shared with `EquationNumberStage` through + /// [`MathMethodConfig`] so the two stages read the option identically. + /// Both Quarto 1 / Pandoc shapes are accepted (`html-math-method: + /// katex`, or `{ method: ..., url: ... }` where `url` overrides the + /// engine's default loader location). /// - /// Unknown method strings (e.g. `webtex`, `gladtex`) are *not* - /// supported in v1 and produce `None`. The caller should treat - /// `None` as "math rendering is not q2's responsibility for this - /// document" and skip injection. (Today this only applies if the - /// user explicitly opts out; `None` is never returned for absent / - /// `mathjax` / `katex`.) + /// 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`. pub fn from_meta(meta: &ConfigValue) -> Option { - let Some(value) = meta.get("html-math-method") else { - return Some(Self::default_engine()); - }; - - // Object form first: { method: ..., url?: ... }. - if value.is_map() { - let method = value.get("method").and_then(|v| v.as_plain_text()); - let url = value.get("url").and_then(|v| v.as_plain_text()); - return match method.as_deref() { - Some("mathjax") | None => Some(Self::Mathjax { - url: url.unwrap_or_else(|| DEFAULT_MATHJAX_URL.to_string()), - }), - Some("katex") => Some(Self::Katex { - url_base: url.unwrap_or_else(|| DEFAULT_KATEX_URL_BASE.to_string()), - }), - Some(_other) => None, - }; - } - - // String form. - match value.as_plain_text().as_deref() { - Some("mathjax") => Some(Self::Mathjax { - url: DEFAULT_MATHJAX_URL.to_string(), + let MathMethodConfig { method, url } = MathMethodConfig::from_meta(meta); + match method { + MathMethod::Mathjax => Some(Self::Mathjax { + url: url.unwrap_or_else(|| DEFAULT_MATHJAX_URL.to_string()), }), - Some("katex") => Some(Self::Katex { - url_base: DEFAULT_KATEX_URL_BASE.to_string(), + MathMethod::Katex => Some(Self::Katex { + url_base: url.unwrap_or_else(|| DEFAULT_KATEX_URL_BASE.to_string()), }), - // Other strings (webtex, gladtex, mathml, plain) — defer. - Some(_) => None, - None => Some(Self::default_engine()), + MathMethod::MathMl | MathMethod::Plain | MathMethod::Unknown(_) => None, } } diff --git a/crates/quarto-core/src/stage/stages/mod.rs b/crates/quarto-core/src/stage/stages/mod.rs index 1199b88fe..a57f2ccfa 100644 --- a/crates/quarto-core/src/stage/stages/mod.rs +++ b/crates/quarto-core/src/stage/stages/mod.rs @@ -46,6 +46,11 @@ mod code_highlight; mod compile_theme_css; mod document_profile; mod engine_execution; +// Equation-number encoding (bd-vlhi2zkj): turns the reserved +// `quarto-eq-number` attribute into `\tag{N}` / ` \qquad(N)` / a sibling +// label for the selected math renderer. Runs after user post filters so +// Lua can rewrite the attribute first. Included on native and WASM. +mod equation_number; mod include_expansion; mod include_resolve; mod language_resolve; @@ -90,6 +95,9 @@ pub use compile_theme_css::{ }; pub use document_profile::DocumentProfileStage; pub use engine_execution::{ENGINE_CAPTURE_KIND, EngineExecutionStage}; +pub use equation_number::{ + EQ_NUMBER_LABEL_CLASS, EQ_SIBLING_NUMBER_CLASS, EquationNumberStage, NumberEncoding, +}; pub use include_expansion::{ IncludeExpansionStage, collect_include_paths, expand_document_includes, extract_include_path, }; diff --git a/crates/quarto-core/src/transforms/crossref_render.rs b/crates/quarto-core/src/transforms/crossref_render.rs index 0559c54df..8d035683b 100644 --- a/crates/quarto-core/src/transforms/crossref_render.rs +++ b/crates/quarto-core/src/transforms/crossref_render.rs @@ -41,12 +41,14 @@ use quarto_pandoc_types::attr::{Attr, AttrSourceInfo, TargetSourceInfo}; use quarto_pandoc_types::block::{Block, Blocks, Div, Figure}; use quarto_pandoc_types::caption::Caption; use quarto_pandoc_types::custom::{CustomNode, Slot}; -use quarto_pandoc_types::inline::{Inline, Inlines, Link, Math, Span, Str}; +use quarto_pandoc_types::inline::{Inline, Inlines, Link, Span, Str}; use quarto_pandoc_types::pandoc::Pandoc; use quarto_source_map::SourceInfo; use crate::Result; -use crate::crossref::{CROSSREF_RESOLVED_REF, EQUATION, FLOAT_REF_TARGET, PROOF, THEOREM}; +use crate::crossref::{ + CROSSREF_RESOLVED_REF, EQ_NUMBER_ATTR, EQUATION, FLOAT_REF_TARGET, PROOF, THEOREM, +}; use crate::language::LanguageTerms; use crate::render::RenderContext; use crate::transform::{AstTransform, TransformPhase}; @@ -1045,18 +1047,24 @@ fn render_proof(node: CustomNode, terms: Option<&LanguageTerms>) -> Block { } /// Convert an Equation custom node into a `Span(id=...)` containing the -/// original `Math(DisplayMath, ...)` with `\tag{N}` appended for MathJax -/// numbering. +/// original `Math(DisplayMath, ...)`, byte-identical to the source, with +/// the equation's number on the reserved [`EQ_NUMBER_ATTR`] +/// (`quarto-eq-number`) attribute. /// /// Output shape: /// /// ```html -/// $$e = mc^2\tag{1}$$ +/// $$e = mc^2$$ /// ``` /// -/// The `\tag{}` command tells MathJax/KaTeX to display the equation number -/// in the right margin, matching Q1's approach. The Span wrapper carries -/// the id for anchor linking from `@eq-xxx` references. +/// How the number is *typeset* is not decided here: `\tag{N}` is an +/// `amsmath` command only MathJax and KaTeX understand, while a converter +/// that reads math alone needs ` \qquad(N)` or a label outside the math. +/// That choice depends on the format and `html-math-method`, so it is made +/// by `EquationNumberStage`, which runs after user post filters (so a Lua +/// filter can rewrite or delete the attribute) and removes the attribute. +/// The Span wrapper carries the id for anchor linking from `@eq-xxx` +/// references. fn render_equation(node: CustomNode) -> Inline { let number = node .plain_data @@ -1066,43 +1074,22 @@ fn render_equation(node: CustomNode) -> Inline { .map(|n| n as u32); let source_info = node.source_info.clone(); - let attr = node.attr.clone(); + let mut attr = node.attr.clone(); + if let Some(n) = number { + attr.2.insert(EQ_NUMBER_ATTR.to_string(), n.to_string()); + } - // Extract the math inline from the content slot. + // Extract the math inline from the content slot. The math text is + // passed through untouched (see the doc comment). let mut slots = node.slots; - let math_inline = match slots.remove("content") { - Some(Slot::Inlines(mut is)) if !is.is_empty() => is.remove(0), - _ => { - // Fallback: no content slot — return an empty Span. - return Inline::Span(Span { - attr, - content: vec![], - source_info, - attr_source: AttrSourceInfo::empty(), - }); - } - }; - - // If we have a number, append \tag{N} to the math text. - let content_inline = if let Some(n) = number { - match math_inline { - Inline::Math(math) => { - let tagged_text = format!("{}\\tag{{{}}}", math.text, n); - Inline::Math(Math { - math_type: math.math_type, - text: tagged_text, - source_info: math.source_info, - }) - } - other => other, - } - } else { - math_inline + let content = match slots.remove("content") { + Some(Slot::Inlines(mut is)) if !is.is_empty() => vec![is.remove(0)], + _ => vec![], }; Inline::Span(Span { attr, - content: vec![content_inline], + content, source_info, attr_source: AttrSourceInfo::empty(), }) @@ -2459,28 +2446,67 @@ mod tests { }) } + /// After rendering, the equation CustomNode becomes a Span carrying + /// the number as the reserved `quarto-eq-number` attribute. The math + /// text is left byte-identical: the number's *encoding* (`\tag{N}`, + /// `\qquad(N)`, a sibling label) is a format decision that + /// `EquationNumberStage` makes later, after user post filters. #[tokio::test] - async fn equation_renders_to_span_with_tag() { + async fn equation_renders_to_span_with_number_attribute() { let ast = run_full(vec![eq_para("eq-einstein", "e = mc^2")]).await; let Block::Paragraph(p) = &ast.blocks[0] else { panic!("expected Paragraph, got {:?}", ast.blocks[0]); }; - // After rendering, the equation CustomNode becomes a Span with the - // original DisplayMath but with \tag{1} appended. let Inline::Span(span) = &p.content[0] else { panic!("expected Span, got {:?}", p.content[0]); }; assert_eq!(span.attr.0, "eq-einstein"); + assert_eq!( + span.attr.2.get(EQ_NUMBER_ATTR).map(String::as_str), + Some("1"), + "the number rides on the reserved attribute; attrs: {:?}", + span.attr.2 + ); assert_eq!(span.content.len(), 1); let Inline::Math(math) = &span.content[0] else { panic!("expected Math, got {:?}", span.content[0]); }; assert_eq!(math.math_type, MathType::DisplayMath); - assert!( - math.text.contains("\\tag{1}"), - "expected \\tag{{1}} in math text, got: {}", - math.text + assert_eq!(math.text, "e = mc^2", "the math text is untouched"); + } + + /// A custom node without an `order` (nothing numbered it) renders to + /// a span with no `quarto-eq-number` attribute and untouched math. + #[test] + fn unnumbered_equation_gets_no_attribute() { + let mut slots = LinkedHashMap::new(); + slots.insert( + "content".to_string(), + Slot::Inlines(vec![Inline::Math(Math { + math_type: MathType::DisplayMath, + text: "x".to_string(), + source_info: si(), + })]), ); + let node = CustomNode { + type_name: EQUATION.to_string(), + slots, + plain_data: serde_json::json!({}), + attr: ( + "eq-plain".to_string(), + vec!["quarto-math-with-attribute".to_string()], + LinkedHashMap::new(), + ), + source_info: si(), + }; + let Inline::Span(span) = render_equation(node) else { + panic!("expected Span"); + }; + assert!(!span.attr.2.contains_key(EQ_NUMBER_ATTR)); + let Inline::Math(math) = &span.content[0] else { + panic!("expected Math"); + }; + assert_eq!(math.text, "x"); } #[tokio::test] @@ -2524,12 +2550,15 @@ mod tests { let Inline::Math(math) = &span.content[0] else { panic!(); }; - let expected_tag = format!("\\tag{{{}}}", i + 1); + assert_eq!( + span.attr.2.get(EQ_NUMBER_ATTR).map(String::as_str), + Some((i + 1).to_string().as_str()), + "eq #{i}: attrs {:?}", + span.attr.2 + ); assert!( - math.text.contains(&expected_tag), - "eq #{}: expected {} in '{}' ", - i, - expected_tag, + !math.text.contains("\\tag"), + "eq #{i}: crossref-render must not encode the number; got '{}'", math.text ); } diff --git a/crates/quarto-core/tests/fixtures/phase5-single-doc-baseline/expected_hashes.txt b/crates/quarto-core/tests/fixtures/phase5-single-doc-baseline/expected_hashes.txt index ab9860efb..bce3e7e49 100644 --- a/crates/quarto-core/tests/fixtures/phase5-single-doc-baseline/expected_hashes.txt +++ b/crates/quarto-core/tests/fixtures/phase5-single-doc-baseline/expected_hashes.txt @@ -410,5 +410,17 @@ # and deliberately NOT unified with these — the two treatments differ in kind. # doc.html hash unchanged: CSS-only change, and a single-doc render has no # footer at all, so the new selectors match nothing in the fixture's body. +# Re-captured 2026-09-21 (bd-vlhi2zkj, format-specific equation numbering): +# doc_files/styles.css hash updated because a new built-in SCSS layer +# `equation-number.scss` (`load_equation_number_layer`, bundled at every +# HTML compile site and in `assemble_reveal_scss`, like `copy-code.scss`) +# ships the three `.quarto-eq-sibling-number…` rules that lay out an +# equation number placed OUTSIDE the math (the `Sibling` encoding +# `EquationNumberStage` uses under `html-math-method: mathml`). The layer +# is purely additive; no existing rule changed. doc.html hash unchanged: +# the fixture has no labelled equation, so no `quarto-eq-*` class appears +# in its body, and the encoding change itself (crossref-render now records +# `quarto-eq-number` and the stage appends `\tag{N}` for MathJax) is +# byte-neutral for a document without numbered equations. doc.html a0540297514ad41734a23e91c9511134b1fa469281ec470339571ffd5d404494 -doc_files/styles.css a184a29137e14e4b35c4c791156314888268503c3d313889e94177295f0c5c2d +doc_files/styles.css adf15c061a67cfbd61915e5d6fa9ce70956c24e3119254f99a53a382fe326d08 diff --git a/crates/quarto-core/tests/integration/equation_numbering_pipeline.rs b/crates/quarto-core/tests/integration/equation_numbering_pipeline.rs new file mode 100644 index 000000000..21766d052 --- /dev/null +++ b/crates/quarto-core/tests/integration/equation_numbering_pipeline.rs @@ -0,0 +1,309 @@ +/* + * tests/integration/equation_numbering_pipeline.rs + * Copyright (c) 2026 Posit, PBC + * + * End-to-end tests for format-specific equation numbering (bd-vlhi2zkj). + */ + +//! Numbered display equations (`$$…$$ {#eq-x}`) reach the writer with +//! their number encoded the way the selected math engine needs: +//! +//! - `\tag{N}` inside the TeX for MathJax / KaTeX (the default), +//! - ` \qquad(N)` inside the TeX for engines that only read math, +//! - a sibling `span.quarto-eq-number` outside the math for MathML. +//! +//! `CrossrefRenderTransform` records the number as the reserved +//! `quarto-eq-number` attribute on the equation span and leaves the TeX +//! alone; `EquationNumberStage`, which runs *after* user post filters, +//! picks the encoding and removes the attribute. Lua post filters can +//! therefore read, rewrite or delete the number; pre filters never see +//! it (crossref has not run yet). Plan: +//! `claude-notes/plans/2026-09-21-equation-numbering-and-mathml.md`. + +use std::path::Path; +use std::sync::Arc; + +use tempfile::TempDir; + +use quarto_core::render_to_file::{RenderToFileOptions, render_to_file}; +use quarto_system_runtime::{NativeRuntime, SystemRuntime}; + +/// Substring of the MathJax inline config block `MathJsStage` emits. +const MATHJAX_CONFIG_SENTINEL: &str = "window.MathJax"; + +fn write_file(path: &Path, contents: &str) { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + std::fs::write(path, contents).unwrap(); +} + +/// Render `doc.qmd` (plus any `extra` sibling files) to `format` and +/// return the HTML. +fn render_with(qmd: &str, format: &str, extra: &[(&str, &str)]) -> String { + let temp = TempDir::new().unwrap(); + for (name, contents) in extra { + write_file(&temp.path().join(name), contents); + } + let qmd_path = temp.path().join("doc.qmd"); + write_file(&qmd_path, qmd); + let runtime: Arc = Arc::new(NativeRuntime::new()); + let result = render_to_file(&qmd_path, format, &RenderToFileOptions::default(), runtime) + .expect("render"); + std::fs::read_to_string(&result.output_path).expect("read output") +} + +fn render(qmd: &str, format: &str) -> String { + render_with(qmd, format, &[]) +} + +/// One labelled equation under the given front matter. +fn labelled_doc(front_matter: &str) -> String { + format!( + "---\ntitle: Labelled\n{front_matter}---\n\n$$E = mc^2$$ {{#eq-einstein}}\n\nSee @eq-einstein.\n" + ) +} + +// ── Encodings ─────────────────────────────────────────────────────────── + +/// Default engine (MathJax): the number is a `\tag{N}` inside the TeX, +/// the reserved attribute never reaches the HTML, and the crossref link +/// still resolves. +#[test] +fn default_method_encodes_tag_inside_tex() { + let html = render(&labelled_doc(""), "html"); + assert!( + html.contains("\\[E = mc^2\\tag{1}\\]"), + "expected \\tag{{1}} appended to the display math; got:\n{html}" + ); + assert!( + !html.contains("quarto-eq-number"), + "the reserved attribute must be consumed before the writer; got:\n{html}" + ); + assert!(html.contains(MATHJAX_CONFIG_SENTINEL)); + assert!( + html.contains("Equation\u{a0}1"), + "the @eq-einstein reference must still render its number" + ); +} + +#[test] +fn katex_method_encodes_tag_inside_tex() { + let html = render(&labelled_doc("html-math-method: katex\n"), "html"); + assert!(html.contains("\\[E = mc^2\\tag{1}\\]"), "got:\n{html}"); + assert!(!html.contains("quarto-eq-number")); +} + +#[test] +fn object_form_method_encodes_tag_inside_tex() { + let html = render( + &labelled_doc( + "html-math-method:\n method: mathjax\n url: https://example.invalid/mj.js\n", + ), + "html", + ); + assert!(html.contains("\\[E = mc^2\\tag{1}\\]"), "got:\n{html}"); +} + +#[test] +fn revealjs_encodes_tag_inside_tex() { + let html = render(&labelled_doc(""), "revealjs"); + assert!(html.contains("\\[E = mc^2\\tag{1}\\]"), "got:\n{html}"); + assert!(!html.contains("quarto-eq-number")); +} + +/// `plain` loads no engine, so `\tag` would be a bare TeX command in the +/// page. Quarto 1's encoding for engines that only read math is +/// ` \qquad(N)`. +#[test] +fn plain_method_encodes_qquad_inside_tex() { + let html = render(&labelled_doc("html-math-method: plain\n"), "html"); + assert!( + html.contains("\\[E = mc^2 \\qquad(1)\\]"), + "expected ` \\qquad(1)` appended; got:\n{html}" + ); + assert!(!html.contains("\\tag{")); + assert!(!html.contains("quarto-eq-number")); + assert!(!html.contains(MATHJAX_CONFIG_SENTINEL)); +} + +/// Unknown method strings are treated like `plain` (no engine we know +/// of will read `\tag`). +#[test] +fn unknown_method_encodes_qquad_inside_tex() { + let html = render(&labelled_doc("html-math-method: gladtex\n"), "html"); + 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, +/// 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"); + assert!( + html.contains("\\[E = mc^2\\]"), + "the math text must be untouched; got:\n{html}" + ); + assert!( + html.contains("(1)"), + "expected the sibling label; got:\n{html}" + ); + assert!( + html.contains("class=\"quarto-math-with-attribute quarto-eq-sibling-number\""), + "expected the modifier class on the equation span; got:\n{html}" + ); + assert!(!html.contains("\\tag{")); + assert!(!html.contains("quarto-eq-number=\"")); +} + +/// The sibling label follows the math inside the equation span, so CSS +/// can lay them out as one row. +#[test] +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 label = html[start..].find("quarto-eq-number\">(1)").expect("label") + start; + assert!( + label > math_end, + "label must come after the math span; got:\n{}", + &html[start..] + ); + let close = html[label..] + .find("") + .expect("both spans close") + + label; + assert!( + !html[label..close].contains(" Result { parse_layer(content, Some("embed-example.scss")) } +/// Load the equation-number layout SCSS layer (bd-vlhi2zkj). +/// +/// Reads `equation-number.scss` from the embedded templates directory. The +/// layer lays out the `Sibling` encoding `EquationNumberStage` emits when +/// the math renderer cannot typeset the number itself (`html-math-method: +/// mathml`): `span.quarto-eq-sibling-number` becomes a row with the math +/// centered and `span.quarto-eq-number` at the right edge, where MathJax +/// and KaTeX put a `\tag`. +/// +/// Shared by the HTML path (`compile_*`) and the revealjs path +/// (`assemble_reveal_scss`) like [`load_copy_code_layer`]; included as a +/// built-in user layer so themes can restyle the label. +pub fn load_equation_number_layer() -> Result { + use crate::resources::TEMPLATES_RESOURCES; + + let content = TEMPLATES_RESOURCES + .read_str(Path::new("equation-number.scss")) + .ok_or_else(|| SassError::CompilationFailed { + message: "equation-number.scss not found in templates resources".to_string(), + })?; + + parse_layer(content, Some("equation-number.scss")) +} + /// Load the listing (cards / table / category chips / pagination) SCSS /// layer (bd-57y4). /// @@ -552,9 +576,14 @@ pub fn assemble_reveal_scss(theme_layers: &[SassLayer]) -> Result = Vec::with_capacity(2 + theme_layers.len()); + // Equation-number layout (`equation-number.scss`), the same layer the + // HTML path includes: revealjs is HTML-based, so `EquationNumberStage` + // applies the `Sibling` encoding to decks too (bd-vlhi2zkj). + let equation_number = load_equation_number_layer()?; + let mut combined: Vec = Vec::with_capacity(3 + theme_layers.len()); combined.push(highlight); combined.push(copy_code); + combined.push(equation_number); combined.extend_from_slice(theme_layers); let merged = merge_layers(&combined); diff --git a/crates/quarto-sass/src/compile.rs b/crates/quarto-sass/src/compile.rs index d5320090b..4795bc41c 100644 --- a/crates/quarto-sass/src/compile.rs +++ b/crates/quarto-sass/src/compile.rs @@ -75,8 +75,8 @@ pub fn assemble_theme_scss( context: &ThemeContext<'_>, ) -> Result<(String, Vec), SassError> { use crate::bundle::{ - load_copy_code_layer, load_embed_example_layer, load_highlight_layer, load_listing_layer, - load_title_block_layer, + load_copy_code_layer, load_embed_example_layer, load_equation_number_layer, + load_highlight_layer, load_listing_layer, load_title_block_layer, }; // Process theme specs into layers @@ -90,6 +90,7 @@ pub fn assemble_theme_scss( load_highlight_layer(config.highlight_style.as_ref().map(|h| h.name.as_str()))?; let embed_example_layer = load_embed_example_layer()?; let copy_code_layer = load_copy_code_layer()?; + let equation_number_layer = load_equation_number_layer()?; let listing_layer = load_listing_layer()?; let mut user_layers = Vec::new(); // `title-block-style: plain|none` drops the title-block layer @@ -101,6 +102,7 @@ pub fn assemble_theme_scss( highlight_layer, embed_example_layer, copy_code_layer, + equation_number_layer, listing_layer, ]); user_layers.extend(result.layers); @@ -209,8 +211,8 @@ pub fn compile_with_doc_vars( doc_vars: &crate::SassLayer, ) -> Result { use crate::bundle::{ - load_copy_code_layer, load_embed_example_layer, load_highlight_layer, load_listing_layer, - load_title_block_layer, + load_copy_code_layer, load_embed_example_layer, load_equation_number_layer, + load_highlight_layer, load_listing_layer, load_title_block_layer, }; use crate::themes::process_theme_specs; use quarto_system_runtime::sass_native::compile_scss_with_embedded; @@ -246,6 +248,7 @@ pub fn compile_with_doc_vars( load_highlight_layer(config.highlight_style.as_ref().map(|h| h.name.as_str()))?; let embed_example_layer = load_embed_example_layer()?; let copy_code_layer = load_copy_code_layer()?; + let equation_number_layer = load_equation_number_layer()?; let listing_layer = load_listing_layer()?; let mut user_layers = Vec::new(); if config.title_block_layer { @@ -255,6 +258,7 @@ pub fn compile_with_doc_vars( highlight_layer, embed_example_layer, copy_code_layer, + equation_number_layer, listing_layer, ]); @@ -370,8 +374,8 @@ pub fn compile_default_css( minified: bool, ) -> Result { use crate::bundle::{ - load_copy_code_layer, load_embed_example_layer, load_highlight_layer, load_listing_layer, - load_title_block_layer, + load_copy_code_layer, load_embed_example_layer, load_equation_number_layer, + load_highlight_layer, load_listing_layer, load_title_block_layer, }; use quarto_system_runtime::sass_native::compile_scss_with_embedded; @@ -386,6 +390,7 @@ pub fn compile_default_css( let highlight_layer = load_highlight_layer(None)?; let embed_example_layer = load_embed_example_layer()?; let copy_code_layer = load_copy_code_layer()?; + let equation_number_layer = load_equation_number_layer()?; let listing_layer = load_listing_layer()?; // Assemble SCSS: Bootstrap + Quarto + title block + highlight + @@ -395,6 +400,7 @@ pub fn compile_default_css( highlight_layer, embed_example_layer, copy_code_layer, + equation_number_layer, listing_layer, ])?; @@ -516,8 +522,8 @@ pub async fn compile_with_doc_vars( doc_vars: &crate::SassLayer, ) -> Result { use crate::bundle::{ - load_copy_code_layer, load_embed_example_layer, load_highlight_layer, load_listing_layer, - load_title_block_layer, + load_copy_code_layer, load_embed_example_layer, load_equation_number_layer, + load_highlight_layer, load_listing_layer, load_title_block_layer, }; use crate::themes::process_theme_specs; @@ -544,6 +550,7 @@ pub async fn compile_with_doc_vars( load_highlight_layer(config.highlight_style.as_ref().map(|h| h.name.as_str()))?; let embed_example_layer = load_embed_example_layer()?; let copy_code_layer = load_copy_code_layer()?; + let equation_number_layer = load_equation_number_layer()?; let listing_layer = load_listing_layer()?; let mut user_layers = Vec::new(); if config.title_block_layer { @@ -553,6 +560,7 @@ pub async fn compile_with_doc_vars( highlight_layer, embed_example_layer, copy_code_layer, + equation_number_layer, listing_layer, ]); @@ -622,8 +630,8 @@ pub async fn compile_default_css( minified: bool, ) -> Result { use crate::bundle::{ - load_copy_code_layer, load_embed_example_layer, load_highlight_layer, load_listing_layer, - load_title_block_layer, + load_copy_code_layer, load_embed_example_layer, load_equation_number_layer, + load_highlight_layer, load_listing_layer, load_title_block_layer, }; // Return cached version if available (only for minified, matching @@ -644,6 +652,7 @@ pub async fn compile_default_css( let highlight_layer = load_highlight_layer(None)?; let embed_example_layer = load_embed_example_layer()?; let copy_code_layer = load_copy_code_layer()?; + let equation_number_layer = load_equation_number_layer()?; let listing_layer = load_listing_layer()?; // Assemble SCSS: Bootstrap + Quarto + title block + highlight + @@ -653,6 +662,7 @@ pub async fn compile_default_css( highlight_layer, embed_example_layer, copy_code_layer, + equation_number_layer, listing_layer, ])?; @@ -1211,6 +1221,21 @@ mod tests { "Should contain the .code-copy-outer-scaffold positioning context" ); + // Should have the equation-number layout from the shared + // equation-number.scss layer (bd-vlhi2zkj): the `Sibling` encoding + // of `EquationNumberStage` (html-math-method: mathml) puts the + // number in a `span.quarto-eq-number` after the math, and this + // rule lays the two out as one row. + assert!( + css.contains(".quarto-eq-sibling-number"), + "Should contain the .quarto-eq-sibling-number row rule from equation-number.scss" + ); + assert!( + css.contains(".quarto-eq-sibling-number>.quarto-eq-number") + || css.contains(".quarto-eq-sibling-number > .quarto-eq-number"), + "Should contain the .quarto-eq-number label rule from equation-number.scss" + ); + // Should have Quarto page-footer layout rules (ported from Q1). assert!( css.contains(".nav-footer"), @@ -1284,6 +1309,20 @@ mod tests { ); } + /// `format: revealjs` is HTML-based, so `EquationNumberStage` applies + /// the same `Sibling` encoding under `html-math-method: mathml`; the + /// deck must bundle `equation-number.scss` like the HTML path does + /// (bd-vlhi2zkj), or the label lands unstyled after the math. + #[test] + fn test_compile_reveal_theme_includes_equation_number_rules() { + let runtime = NativeRuntime::new(); + let css = compile_reveal_theme_css(&runtime, true, &[], &[]).unwrap(); + assert!( + css.contains(".quarto-eq-sibling-number"), + "reveal theme CSS must contain .quarto-eq-sibling-number from equation-number.scss" + ); + } + #[test] fn test_compile_default_css_expanded() { let runtime = NativeRuntime::new(); diff --git a/docs/guides/authoring/lua-filters.qmd b/docs/guides/authoring/lua-filters.qmd index c7402d9b0..f86380b22 100644 --- a/docs/guides/authoring/lua-filters.qmd +++ b/docs/guides/authoring/lua-filters.qmd @@ -198,6 +198,31 @@ function Meta(meta) end ``` +## Equation numbers: the `quarto-eq-number` attribute + +A labelled display equation (`$$…$$ {#eq-name}`) reaches your filters as a +`Span` whose `identifier` is the label and whose `attributes` carry the +equation's number under the reserved key `quarto-eq-number`. Quarto writes +the number into the page only after your filters have run, in the form the +selected math renderer understands (`\tag{N}` for MathJax and KaTeX, for +example). So a filter can change what is displayed by editing the +attribute, without touching the TeX: + +``` lua +function Span(el) + local n = el.attributes["quarto-eq-number"] + if n then + el.attributes["quarto-eq-number"] = "S" .. n -- shows as (S1), (S2), … + return el + end +end +``` + +Delete the attribute (`el.attributes["quarto-eq-number"] = nil`) to leave +an equation unnumbered. The attribute exists only once cross-references +have been resolved, so list the filter *after* the `quarto` entry in +`filters:`; a filter listed before it runs too early to see the number. + ## Attaching scripts and stylesheets: `quarto.doc.add_html_dependency` A filter that needs JavaScript or CSS in the rendered page registers an diff --git a/resources/scss/html/templates/equation-number.scss b/resources/scss/html/templates/equation-number.scss new file mode 100644 index 000000000..3924ba863 --- /dev/null +++ b/resources/scss/html/templates/equation-number.scss @@ -0,0 +1,36 @@ +/*-- scss:rules --*/ + +// Layout for an equation number placed *outside* the math. +// +// `EquationNumberStage` (`crates/quarto-core/src/stage/stages/equation_number.rs`) +// uses this encoding when the math renderer cannot typeset the number +// itself — `html-math-method: mathml`, whose `` has no room for +// `\tag{N}` — and emits +// +// +// +// (1) +// +// +// The equation span becomes a row: the math is centered in the remaining +// width and the label sits at the right edge, which is where MathJax and +// KaTeX put a `\tag`. Shared by the HTML path (`compile_*` in +// `quarto-sass/src/compile.rs`) and the revealjs path +// (`assemble_reveal_scss` in `bundle.rs`), like `copy-code.scss`, so +// both formats honor the encoding with one source of truth. Included as a +// built-in user layer, so a theme can restyle `.quarto-eq-number`. + +.quarto-eq-sibling-number { + display: flex; + align-items: center; + + > .math.display { + flex: 1 1 auto; + text-align: center; + } + + > .quarto-eq-number { + flex: 0 0 auto; + margin-left: 1em; + } +} diff --git a/ts-packages/preview-renderer/src/q2-preview/custom/Equation.tsx b/ts-packages/preview-renderer/src/q2-preview/custom/Equation.tsx index 065f8b14d..25d4a50e8 100644 --- a/ts-packages/preview-renderer/src/q2-preview/custom/Equation.tsx +++ b/ts-packages/preview-renderer/src/q2-preview/custom/Equation.tsx @@ -9,14 +9,19 @@ import { Node } from '../../framework'; import { makeSlotSetter } from '../utils'; /** - * Equation — q2-preview port of `render_equation` at - * `crates/quarto-core/src/transforms/crossref_render.rs:601-650`. + * Equation — q2-preview counterpart of `render_equation` + * (`crates/quarto-core/src/transforms/crossref_render.rs`) plus the + * `TexTag` encoding of `EquationNumberStage` + * (`crates/quarto-core/src/stage/stages/equation_number.rs`). * * `CrossrefRenderTransform` is excluded from q2-preview's pipeline - * (see `Q2_PREVIEW_TRANSFORM_EXCLUDED` at `pipeline.rs:1071`), so the - * `Equation` CustomNode wrapper survives into the iframe. q2-preview - * ports the `\tag{N}` append from Rust into JS so KaTeX can render - * the equation number natively. + * (see `Q2_PREVIEW_TRANSFORM_EXCLUDED` in `pipeline.rs`), so the + * `Equation` CustomNode wrapper survives into the iframe. In the native + * render path crossref-render records the number on the reserved + * `quarto-eq-number` span attribute and `EquationNumberStage` later + * encodes it per math engine (`\tag{N}` for MathJax/KaTeX, ` \qquad(N)` + * otherwise, a sibling label for MathML). The preview always typesets + * with KaTeX, so it applies the `\tag{N}` encoding directly here. * * Output: `{Math (with \tag{N} appended)}`. *