Skip to content

Add crossref.numbering: external; sanction the Lua functions external front ends already call as globals - #14913

Closed
gordonwoodhull wants to merge 6 commits into
mainfrom
quarto2-crossref-numbering
Closed

gordonwoodhull wants to merge 6 commits into
mainfrom
quarto2-crossref-numbering

Conversation

@gordonwoodhull

Copy link
Copy Markdown
Member

Summary

Adds crossref.numbering: external, a document-level opt-out that skips Quarto's own
cross-reference numbering, index, and @ref-resolution passes, for front ends that
compute their own numbers and just want Quarto's Lua filters to render whatever number
is already on the node. Currently supported for docx, odt, and pptx; latex,
pdf, beamer, and typst raise a diagnostic rather than the (previously silent)
malformed output those formats produced when this option reached them.

Also sanctions eight Lua functions — subrefNumber, refPrefix, refDelim,
refHyperlink, refNumberOption, crossrefOption, renderEquation, and
nbspString — that external front ends already call as undefended bare globals when
reconstructing Quarto's crossref/equation custom nodes outside the normal filter
pipeline. There is existing precedent for this
(quarto.doc.crossref.decorate_caption_with_crossref,
customnodes/floatreftarget.lua:239); this PR extends the same pattern to the eight
functions actually in use.

Across six commits:

  1. crossref_present() / assign_crossref_numbers() predicates — two new
    predicates in a new module, src/resources/filters/modules/crossref_numbering.lua,
    answering "should captions/refs still be decorated?" and "should Quarto compute its
    own numbers?" as a function of enable-crossref and the new crossref-numbering
    param. The existing gates across the crossref filter stage
    (customnodes/floatreftarget.lua, layout/ipynb.lua, modules/callouts.lua) now
    call these instead of checking enable-crossref directly.

  2. enable-crossref: false scoping + byte-identity verification. See the scoping
    statement below.

  3. crossref.numbering: external reachable from user YAML: schema entry
    (src/resources/schema/document-crossref.yml, enum [quarto, external], default
    quarto), the kCrossrefNumbering constant (src/config/constants.ts), and the
    TypeScript read that puts it on the filter-params blob (crossrefFilterParams in
    src/command/render/crossref.ts). Without this, the option is only reachable by a
    consumer that builds the params blob itself, not by a quarto-cli user setting
    YAML front matter.

  4. Fail fast on latex/pdf/beamer/typst. The predicate in (1) gates the
    entire quarto_crossref_filters group, which also runs
    crossrefMetaInject's LaTeX preamble setup (the codelisting/theorem environment
    definitions LaTeX output needs) and resolveRefs's Typst @id-collision guard
    (Typst has its own native @id citation syntax that collides with Quarto's crossref
    @id syntax unless resolved first). Neither of those is reachable under
    enable-crossref: false today, so skipping them was never previously observable —
    but crossref.numbering: external makes the same gate reachable from YAML for any
    format, and a document with a labeled float or theorem then failed deep inside the
    LaTeX or Typst compiler instead of failing cleanly (reproduced: LaTeX Error: Environment codelisting undefined; Typst "document does not contain a bibliography"). Fixed by failing fast at the same gate, before Pandoc's writer ever
    sees the malformed intermediate output, naming the unsupported format in the
    diagnostic. New test: numbering-external-unsupported-format.test.ts.

  5. Route-N sanctioned exports: the eight functions named above, exposed as
    quarto.doc.crossref.* (all but one) and quarto.utils.nbspString (nbspString is
    a generic string helper, not crossref-specific):

    • src/resources/filters/crossref/format.lua: subrefNumber, refPrefix,
      refDelim, refHyperlink, refNumberOption
    • src/resources/filters/crossref/options.lua: crossrefOption
    • src/resources/filters/crossref/equations.lua: renderEquation
    • src/resources/filters/common/pandoc.lua: nbspString

    titlePrefix (which refPrefix/float_title_prefix route through) is not
    exposed here — it sits outside this list and exposing it is left to whoever needs
    it next. While exposing these, found and fixed a real bug: refNumberOption's
    appendix branch dereferences crossref.startAppendix, which is nil under external
    mode (it's only ever set inside the group (1) skips) — a direct caller of this
    export would crash. Guarded it to degrade to non-appendix numbering instead;
    crossref-exports.test.lua reproduces the crash without the guard and passes with
    it. Also added lua-types declarations for all eight exports
    (lua-types/quarto/{doc,utils}.lua) and a changelog entry (news/changelog-1.11.md)
    — both missing from the original patch, added here.

Files changed (upstream quarto-cli, all commits)

  • src/resources/filters/modules/crossref_numbering.lua (new)
  • src/resources/filters/modules/import_all.lua
  • src/resources/filters/main.lua
  • src/resources/filters/customnodes/floatreftarget.lua
  • src/resources/filters/layout/ipynb.lua
  • src/resources/filters/modules/callouts.lua
  • src/resources/filters/crossref/format.lua
  • src/resources/filters/crossref/options.lua
  • src/resources/filters/crossref/equations.lua
  • src/resources/filters/common/pandoc.lua
  • src/resources/lua-types/quarto/doc.lua
  • src/resources/lua-types/quarto/utils.lua
  • src/resources/schema/document-crossref.yml
  • src/config/constants.ts
  • src/command/render/crossref.ts
  • src/command/render/pandoc.ts (unrelated one-line robustness fix picked up in
    passing: guard captureRenderCommand's defaults-YAML rewrite for formats, like
    docx, whose defaults don't reference a template)
  • src/resources/editor/tools/* (regenerated schema/YAML-intelligence artifacts)
  • news/changelog-1.11.md
  • Tests: tests/docs/crossrefs/*.qmd (new fixtures, including
    numbering-valid.qmd), tests/smoke/crossref/*.test.ts (new + extended, including
    numbering-external-unsupported-format.test.ts),
    tests/smoke/manuscript/render-manuscript.test.ts (extended),
    tests/smoke/lua-unit/lua-unit.test.ts (registers two new Lua unit test files),
    tests/unit-lua/crossref-numbering.test.lua (new),
    tests/unit-lua/crossref-exports.test.lua (new)

enable-crossref: false scoping statement

enable-crossref: false is not user-settable: it is set internally, and only, at
src/command/render/filters.ts:534, when format.render["produce-source-notebook"]
is true. That flag is in turn only set for manuscript supporting notebooks (.qmd
files that are not the manuscript's main article — see
src/project/types/manuscript/manuscript.ts:474-476), when they are rendered to their
.out.ipynb source-notebook form. No other code path in quarto-cli sets or reads
this flag as false. So "bit-for-bit on enable-crossref: false" means, concretely and
only: the manuscript ipynb-source-notebook outputs (notebooks/*.out.ipynb /
article.out.ipynb, produced for supporting notebooks in a manuscript project) are
byte-identical, modulo the notebook's own per-cell random UUIDs, before and after this
patch. It is not a claim about any other render path, format, or project type — none
of which can reach enable-crossref: false today.

Verified by a two-commit byte-identity procedure (comparand commits 83d48d8e8
pre-patch, 8061f246a post-patch): word/document.xml and word/numbering.xml from a
docx crossref render are byte-identical; the manuscript supporting notebook's
.out.ipynb is identical after normalizing out Jupyter's per-cell random UUID (shown
to vary run-to-run on the same commit, independent of this patch). No new warnings
appeared in either render's log. This is a dev-only verification procedure, not
committed as a CI script.

A note on this patch's origin

This PR grew out of work on Quarto 2 (a from-scratch Rust reimplementation currently
in development) — specifically its "Route-N" path, which reconstructs a subset of
Quarto's Pandoc custom AST nodes and re-dispatches them into these filters rather than
reimplementing crossref/equation rendering independently. That context explains why
these particular eight functions and not others, and why crossref.numbering: external exists at all: Route-N needs a way to tell Quarto's filters "render this
number, don't recompute it." Nothing in this PR is Q2-specific — the YAML key, the
schema, and the exports are ordinary quarto-cli surface, useful to any front end with
the same need.

Testing

  • tests/unit-lua/crossref-numbering.test.lua (10 cases): the 2×2×unknown-value
    cross-product of crossref_present() / assign_crossref_numbers().
  • tests/unit-lua/crossref-exports.test.lua (8 cases, plus the appendix-nil-deref
    regression added in the fix round): one assertNotNil per exported name,
    individually named (not a loop over a locally-defined list) so a rename that updates
    both the export and the list can't mask a break in the contract external consumers
    depend on.
  • tests/smoke/crossref/numbering-schema.test.ts: crossref.numbering: external
    passes schema validation; an invalid value fails naming the allowed values.
  • tests/smoke/crossref/numbering-external.test.ts: full TS → filter-params → Lua
    param() path — a docx render with crossref.numbering: external keeps the caption
    text but carries no Quarto-assigned "Figure N" prefix, and Lua's numbering pass logs
    the resulting missing order field.
  • tests/smoke/crossref/numbering-external-unsupported-format.test.ts: --to latex
    and --to typst under crossref.numbering: external produce the new diagnostic, not
    a compiler crash.
  • tests/smoke/crossref/callout-order-guard.test.ts, tests/smoke/crossref/docx.test.ts:
    regression coverage for the gate conversions in (1).
  • tests/smoke/manuscript/render-manuscript.test.ts: extended to assert the manuscript
    supporting-notebook render does not fall through to the generic placeholder
    FloatRefTarget renderer, and that the notebook's figure keeps its identifier/caption
    undecorated (no crossref number) — closes a blind spot in the pre-existing test
    (which only checked output file existence and a log-level filter that doesn't catch
    Lua's INFO-level warn() output).
  • All of the above pass locally (cd tests && ./run-fast-tests.sh -k <path> per file).
    package/dist/bin/quarto (no args) typechecks clean.

Not included in this PR

  • Exposing titlePrefix as a ninth export.
  • Any _quarto.modules migration cleanup for crossref/ (tracked separately as
    quarto-dev/quarto-cli#14702; this PR's changes predate that migration reaching
    crossref/, and are cheap to make now, expensive after).

Splits the previously conflated enable-crossref flag into two questions:
whether captions/refs should still be decorated with numbers
(crossref_present, for an external numberer) vs. whether quarto itself
should compute and assign those numbers (assign_crossref_numbers).

The single existing assignment-group gate in main.lua (the
`if enableCrossRef then` around the crossref filter group) now uses
assign_crossref_numbers(), the opposite-polarity predicate from
crossref_present(). Render-decoration gate sites are left for a
follow-up task.

Predicates live in a new require-able module
(modules/crossref_numbering.lua) so they can be unit tested with a
mocked param(), per tests/unit-lua/crossref-numbering.test.lua.
…7 order-nil guard

Converts the four render-decoration gate sites (A2/A3 in
decorate_caption_with_crossref/full_caption_prefix, A4/A5 in the ipynb
FloatRefTarget renderer pair, A6 in decorate_callout_title_with_crossref)
from reading param("enable-crossref", true) directly to calling
_quarto.modules.crossref_numbering.crossref_present(), added in the prior
task. layout/ipynb.lua's PanelLayout pair is left reading the param
directly (documented in place): both branches dispatch to the same
render_ipynb_layout callback, so it's a verified no-op either way.

Adds A7: callout_title_prefix was missing the order-nil guard its
float-side analog (float_title_prefix) already has. The site is
unreachable upstream today, but crossref-numbering: external (landing in
a later task) can reach render-decoration with no order assigned, so the
guard makes that state degrade (warn, no prefix) instead of crash.

Also fixes a latent bug in captureRenderCommand's debug capability
(QUARTO_CAPTURE_RENDER_COMMAND): it assumed every format's defaults YAML
references a template, which docx's does not, crashing before the
feature could be used at all. Needed to write T3.5, which exercises the
A7 guard by capturing a real render's pandoc invocation and replaying it
with crossref-numbering patched to "external".
Task 4 of the pandoc-hybrid-P3 implementation: enable-crossref: false is
only reachable via the manuscript ipynb-source-notebook path for
supporting notebooks (src/command/render/filters.ts:534). The existing
render-manuscript.test.ts only asserted output file existence, so a
regression in the A4/A5 FloatRefTarget renderer pair (floatreftarget.lua)
that routes the source-notebook path through the generic placeholder
renderer instead would go unnoticed (confirmed by Task 3's review and
independently reproduced here). Add a log-message check for the
placeholder renderer's warning and a content assertion on
notebooks/seismic-monitoring-stations.out.ipynb's #fig-stations image,
which pins that the image keeps its identifier and caption undecorated
(no crossref number) rather than falling back to the placeholder.

A two-commit byte-identity comparison (dev-only, not committed) confirmed
the docx default path (A1/A2) and this manuscript ipynb-false path are
unchanged across 83d48d8..8061f24; see the Task 4 report.
…k 8)

Adds the TypeScript half that Tasks 2-4's Lua crossref_present()/
assign_crossref_numbers() predicates were reading via param() but nothing
ever wrote: a crossref.numbering YAML key (enum quarto/external, default
quarto), its document-crossref.yml schema entry (required since the
crossref object is closed: true), and the metadata read in
crossrefFilterParams that emits crossref-numbering into the filter params
blob only when the key is set.

Adds T8.1 (schema validation, pass/fail on the enum), T8.2 (a new
numbering-external.qmd fixture rendered to docx, asserting the caption
loses its Quarto-assigned prefix and stderr carries the Lua
"order is missing" warning), and T8.3 (existing docx.test.ts as the
absent-key regression guard).
…o.utils

P5's Route-N Lua shim reconstructs Q1 custom nodes outside the normal
filter pipeline and calls these eight functions by name today, as
undefended bare globals. Sanction them following the existing precedent
at customnodes/floatreftarget.lua:239 (decorate_caption_with_crossref).

- crossref/format.lua: subrefNumber, refPrefix, refDelim, refHyperlink,
  refNumberOption
- crossref/options.lua: crossrefOption
- crossref/equations.lua: renderEquation
- common/pandoc.lua: nbspString (on quarto.utils, not quarto.doc.crossref,
  since it's a generic helper, not crossref-specific)

titlePrefix is not exposed: per the plan brief it is a ninth function
outside the frozen list of eight, routed through by refPrefix/
float_title_prefix, and exposing it is left to a future PR author's call.

Adds tests/unit-lua/crossref-exports.test.lua (T5.1): eight individually
named assertNotNil assertions, one per export, rather than a loop over a
list defined in the test, so a rename that updates both the export and a
locally-defined list can't mask a break in the contract P5's shim relies
on. format.lua/options.lua/equations.lua require() cleanly in isolation;
common/pandoc.lua cannot be require()d under its module name "pandoc"
because pandoc's Lua environment preloads a module of that same name, so
this test loads it via dofile() on its absolute path instead, extending
LUA_PATH (in lua-unit.test.ts) to include src/resources/pandoc/datadir so
its own require("readqmd") resolves.
Final-review fix round for the pandoc-hybrid-P3 crossref-numbering patch
(no second wave per policy). Task 2 gated the whole quarto_crossref_filters
group on assign_crossref_numbers(), which also skips crossrefMetaInject()'s
LaTeX preamble (codelisting/theorem environments) and resolveRefs() (which
stops Typst's native @id citation syntax from colliding with quarto's @id
crossref syntax). Task 8 then made crossref-numbering: external reachable
from user YAML for any format, so a document with a labeled float or
theorem now crashes deep inside the LaTeX/Typst compiler instead of
failing cleanly.

- main.lua: fail fast at the same gate, before Pandoc's writer ever sees
  the malformed output, when external mode is combined with a LaTeX-family
  or typst target. Reproduced both crashes first (LaTeX Error: Environment
  codelisting undefined; Typst "document does not contain a bibliography"),
  then verified the guard replaces both with a clear diagnostic and no
  output file.
- crossref/format.lua: refNumberOption's appendix branch derefs
  crossref.startAppendix, which is nil under external mode (only ever set
  inside the skipped group). Guard it so a caller invoking the Task-5
  export directly degrades to non-appendix numbering instead of crashing.
  Added a unit test (crossref-exports.test.lua) that reproduces the crash
  without the guard and passes with it.
- document-crossref.yml (+ regenerated editor-tooling artifacts): document
  the docx/odt/pptx-only support for `numbering: external`.
- news/changelog-1.11.md: add the missing YAML-key and Lua API entries.
- lua-types/quarto/{doc,utils}.lua: declare the eight Task-5 exports.
- Rewrite the eight Route-N export comments for an upstream audience
  instead of repeating private vocabulary (Q2/Route-N/Q1) verbatim.
- callout-order-guard.test.ts: replace hand-rolled existsSyncOrFail with
  deno_ral's existsSync.
- numbering-schema.test.ts: give the schema test its own fixture
  (numbering-valid.qmd) instead of sharing numbering-external.qmd's output
  path with numbering-external.test.ts.
- numbering-external-unsupported-format.test.ts: new smoke test asserting
  the clean diagnostic (not the compiler crash) for --to latex and --to
  typst.
@posit-snyk-bot

posit-snyk-bot commented Sep 19, 2026 •

Copy link
Copy Markdown
Collaborator

✅ Snyk checks have passed. No issues have been found so far.

Status Scan Engine Critical High Medium Low Total (0)
✅ Open Source Security 0 0 0 0 0 issues
✅ Licenses 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

@gordonwoodhull
gordonwoodhull marked this pull request as draft September 19, 2026 23:07
@gordonwoodhull

Copy link
Copy Markdown
Member Author

This is going to need a more comprehensive approach, since q2 needs a few things to work differently, and it only needs to run part of the Lua filter pipeline.

I hope to bring ideas to discuss soon.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants