Skip to content

Map figure_group to pandoc's subfigure model, both directions - #70

Merged
dereuromark merged 6 commits into
mainfrom
feat/62-composite-sat-6436a4ec
Aug 15, 2026
Merged

Map figure_group to pandoc's subfigure model, both directions#70
dereuromark merged 6 commits into
mainfrom
feat/62-composite-sat-6436a4ec

Conversation

@dereuromark

Copy link
Copy Markdown
Contributor

Closes #62.

Carve PART 9 section 4c gives a bare ::: figure container the figure_group node: one figure of ordered panels, whose ^ line after the closing fence captions the whole group. Pandoc has that model natively - a Figure whose blocks are themselves Figures is its subfigure shape - so this maps structurally in both directions rather than degrading.

Carve to pandoc

The group becomes the outer Figure, its caption the outer caption, its children the blocks. The panels - the direct figure and table children - lower to nested Figures and Tables, and everything else stays in place between them.

Input:

{#fig-x .columns-2}
::: figure
{#fig-x-a}
![one](a.png)
^ (a) One

{#fig-x-b}
![two](b.png)
^ (b) Two
:::
^ Figure #: Group caption

Through pandoc -t latex:

\begin{figure}
\centering
\begin{subfigure}[t]{0.45\linewidth}
\centering
\pandocbounded{\includegraphics[keepaspectratio]{a.png}}
\caption{(a) One}\label{fig-x-a}
\end{subfigure}
\hfill
\begin{subfigure}[t]{0.45\linewidth}
\centering
\pandocbounded{\includegraphics[keepaspectratio]{b.png}}
\caption{(b) Two}\label{fig-x-b}
\end{subfigure}
\caption{Figure 1: Group caption}\label{fig-x}
\end{figure}

This replaces the Div ["admonition","figure"] a bare ::: figure used to cross as. A filter keyed on that Div has to key on the Figure nesting instead; the changelog says so.

Pandoc to Carve

A Figure holding nested Figure/Table blocks used to hit the "general figure content unwrapped" path, which dropped the grouping and left the caption as a trailing paragraph. It imports as a figure_group now. Single-block Figures keep their existing mapping on purpose: a lone captioned image or table is one figure, not a group of one, and nothing in the document distinguishes the two if it were.

Numbering follows section 4c, not source order

  • The group draws ONE number at its OPENING fence, which is why its caption converts before its children. Corpus 318-composite-figures-11 numbers the group "Figure 1" and a captioned figure nested in its stray content "Figure 2", even though the group's ^ line is the construct's last.
  • A panel draws nothing, and neither does anything a panel CONTAINS. Its # prints as written - the visible failure the language prefers to a silent one. Suppressing the draw matters as much as suppressing the digit: a panel that consumed a number would shift every later caption in the document by one.
  • A panel id resolves </#id> as the group's number plus a bijective base-26 letter by panel order - "Figure 2a", corpus 318-composite-figures-2. A group with no numbered caption lends its panels nothing.

One asymmetry is deliberate and section 4c states it: inside a group a captioned quote is a PANEL, not a section 4a attribution, because "the quote is not a special host inside the group either" (corpus 318-composite-figures-10). The 4a reroute still governs everywhere else, including deeper inside the group's own stray content.

Engine pin

No published @markup-carve/carve carries the node - 0.1.3 parses ::: figure as a generic container, so no arm for the composite node compiles against it. The dependency is a git pin at carve-js 3f5dd8c (markup-carve/carve-js#1073) until an engine release carries it.

The lockfile's resolved url is written in the git+https:// form by hand. Hosts with a git insteadOf rule rewriting HTTPS to SSH make npm install record git+ssh://git@github.com/..., and npm ci follows the LOCKED url - which fails in CI with a publickey error on a public repository. Verified by deleting node_modules and running npm ci.

Also fixed

A pandoc Figure wrapping a single Table was dropping the wrapper's attributes when the two collapse into one Carve table. Pandoc's readers put the label on the Figure rather than on the Table it wraps, so the id a cross-reference resolves against went with the wrapper. They merge now - outer id wins, classes union, key/values merge with the outer taking precedence - which is the rule the 4a quote branch already applied to the same kind of collapse. It bites hardest on a section 4c table panel, whose id is what resolves with a letter.

Note on the ticket's numbering line

The ticket asked the round trip to pin "a panel caption's # placeholder survives as the typed node without n". It does not, and the test records what actually happens instead: a caption's # is a request and the rendered number is the answer, this bridge resolves on the way out for every figure, and the reverse importer never rebuilds a caption_number from printed text for anything. So the group's # comes back as "1" and a panel's comes back as the escaped character it printed - visually identical, still drawing nothing. Reconstructing the placeholder would mean guessing it back out of a rendered # and misreading an author's genuine one.

Every case has a CONTROL beside it, because the two spellings are one character apart and an engine without section 4c renders them identically - a group test can pass against an implementation that never discriminated at all. Both controls were mutated to feed them the group spelling and both then failed, so they discriminate rather than pass on anything.

The published 0.1.3 predates PART 9 4c, so `::: figure` parses as a generic
admonition there and no arm for the composite node can be written against it.
A git pin is the correct pin mid-development; it moves back to a range at the
next engine release.

The lockfile's `resolved` url is rewritten to the git+https form by hand: this
host rewrites HTTPS to SSH via insteadOf, npm records the rewritten url, and
`npm ci` follows the LOCKED url - which fails in CI with a publickey error on a
public repository.
PART 9 4c gives a bare `::: figure` container the `figure_group` node: one
figure of ordered panels, its `^ ` line after the closing fence the caption of
the whole group. Pandoc has that model natively - a `Figure` whose blocks are
themselves `Figure`s is its subfigure shape - so the mapping is structural in
both directions rather than a degradation.

Carve to pandoc: the group becomes the outer `Figure`, its caption the outer
caption, its children the blocks. The panels among them - the direct `figure`
and `table` children - lower to nested `Figure`s and `Table`s; everything else
stays in place between them, unmoved and unwrapped, because the group is one
sequence of children and not a panel array.

Pandoc to carve: a `Figure` holding nested `Figure`/`Table` blocks used to hit
the "general figure content unwrapped" path, which dropped the grouping and
left the caption as a trailing paragraph. It now becomes a `figure_group`. The
single-block Figures keep their existing mapping: a lone captioned image or
table is one figure, not a group of one.

Numbering follows 4c rather than the source order the caption line suggests.
The group draws its number at the OPENING fence, so its caption converts before
its children - corpus 318-composite-figures-11 numbers the group "Figure 1" and
a captioned figure nested in its stray content "Figure 2". A panel draws
nothing, and neither does anything a panel CONTAINS; its `#` stays literal, the
visible failure the language prefers. Suppressing the draw matters as much as
suppressing the digit, since a panel that consumed a number would shift every
later caption in the document by one. A panel id resolves `</#id>` as the
group's number plus a bijective base-26 letter by panel order - "Figure 2a",
corpus 318-composite-figures-2 - and a group with no numbered caption lends its
panels nothing.

One asymmetry is deliberate and 4c states it: inside a group a captioned quote
is a PANEL, not a 4a attribution, "the quote is not a special host inside the
group either". The 4a reroute still governs everywhere else, including deeper
inside the group's own stray content.

An opener carrying a quoted title or a `[label]` is NOT this production and is
untouched: it stays a generic container, and crosses as the same
`Div ["admonition","figure"]` it always did.
Every case carries the CONTROL alongside it, because the two spellings are one
character apart: a bare `::: figure` is the group, an opener with a quoted
title or a `[label]` is a generic container and must stay the
`Div ["admonition","figure"]` it always was. That control is not a formality -
an engine without 4c renders the two identically, so a group test can pass
against an implementation that never discriminated at all.

What is pinned: the nested-Figure shape both ways, the number drawn at the
opening fence rather than at the caption line, a panel drawing nothing and
neither does what a panel contains, the panel letters on `</#id>`, the
captioned quote's split between panel and 4a attribution, stray content kept in
place, and the group surviving the loop as a group over two laps.

The one asymmetry recorded rather than smoothed over: a panel's `#` returns as
the escaped character, not as the placeholder node. Resolution answers the
group's `#` on the way out and nothing answers the panel's, so the panel's is
still the literal text it printed - reconstructing the placeholder would mean
guessing it back out of a rendered `#` and misreading an author's genuine one.
The table row that mattered was missing the discrimination, not the mapping:
which `::: figure` opener is the group and which one stays a Div is what a
reader has to know before either row is useful.

The changelog entry states the replacement plainly - a filter keyed on
`Div ["admonition","figure"]` for a bare opener stops matching - and says the
engine pin is a git pin until an engine release carries the node.
A pandoc `Figure` around a single `Table` becomes one Carve `table`, and the
wrapper's Attr was going nowhere. Pandoc's readers put the label on the Figure
rather than on the Table it wraps, so the id a cross-reference resolves against
was being dropped with the wrapper - visible as a reference that resolves to
nothing.

The two nodes collapse into one, so their attrs merge instead: the outer id
wins because it was the referenceable one, classes union, key/values merge with
the outer taking precedence. That is the rule the 4a quote branch already
applies to the same kind of collapse, and it is now shared.

It bites hardest on a 4c table PANEL, whose id resolves as the group's number
plus a letter, which is what put the case in front of a reviewer.
@dereuromark
dereuromark merged commit 5c0852f into main Aug 15, 2026
2 checks passed
@dereuromark
dereuromark deleted the feat/62-composite-sat-6436a4ec branch August 15, 2026 17:26
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.

Map figure_group to Pandoc's subfigure model, both directions

1 participant