Skip to content

feat: a bare figure fence is one figure of ordered panels - #1073

Merged
dereuromark merged 1 commit into
mainfrom
feat/1122-composite-figures
Aug 15, 2026
Merged

feat: a bare figure fence is one figure of ordered panels#1073
dereuromark merged 1 commit into
mainfrom
feat/1122-composite-figures

Conversation

@dereuromark

@dereuromark dereuromark commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Implements composite figures (PART 9 §4c): a bare ::: figure fence is one numbered figure of ordered, independently captionable panels.

Part of markup-carve/carve#1122; spec: markup-carve/carve#1215 (merged).

What

  • Parser: a bare ::: figure opener (kind only; no quoted title, no [label]) produces the new figure_group node. The closing fence is §4's sixth caption host: a ^ line after it attaches as the group caption through the same slot idiom the five parse-time hosts use (adjacent or one blank line attaches, two detach; an EOF-closed group has no slot). An opener carrying a title or label stays a generic container, and a bare opener anywhere inside an open group's body is demoted the same way (groups do not nest) - the flag rides the sub-lexer chain, so the rule follows the recursion.
  • AST / wire: FigureGroup { children, caption?, attrs?, pos }, discriminated by type. Panels are derived, not stored: the figure and table children in source order - one predicate (figureGroupPanels) shared by numbering and lint. src/wire-fields.ts regenerated from the pinned schema; encode/decode and validation ride the generated tables.
  • Numbering: the group is ONE sequence unit - only its caption draws a number; its draw registers the group id as "Figure N" and each panel id as "Figure N" plus a letter (a..z, then aa, by order among panels), so </#panel-id> resolves as Figure 2a. A # in a panel caption stays a typed caption_number node without n on the wire (carve-php / carve-rs parity; the unresolved-reference precedent), and the HTML arm now renders an unresolved placeholder as its authored #, matching the Markdown/plain/ANSI arms.
  • HTML: renderFigureGroup emits the corpus byte shape - figure.carve-figure-group wrapper (class-first, deduplicated merge), unconditional div.carve-figure-panels, panels as figure.carve-figure-panel (a table panel gets an explicit bare wrapper and keeps its own <caption>), stray content preserved in place, trailing <figcaption> only when captioned.
  • fmt: writes the authored form back - attrs line, ::: figure, body, closer, unescaped ^ group caption. A detached ^ paragraph after a group comes back with exactly one structural escape (\^); the caption-opening caret now escapes in the minimal pass too, so such documents no longer escalate to conservative escaping (\(a\), \#).
  • Markdown/plain/ANSI: deterministic degradation - Markdown writes panels in order, each host separated from its emphasized caption by a blank line, with a bold group caption at the end; plain text and ANSI lead with the group caption line, then caption-over-host per panel.
  • Lint: figure-group-nested, figure-group-opener-metadata, figure-group-panel-number, figure-group-empty, figure-group-single-panel; a numbered group registers its id and its panels' ids as valid crossref targets. (This engine's lint carries a single severity, so the two advisory codes emit as ordinary warnings like every other rule.)
  • HTML import: figure.carve-figure-group comes back as figure_group (own-output round trip); the bare table-panel wrapper unwraps to its table so the table keeps its caption and attrs.
  • Every traversal that switches on block types grew the figure_group arm (heading ids/numbers/level shift, footnote numbering, heading references, block-image promotion, profile filter/vocabulary, the Markdown prepass, the lint heading index); the generic field-name walkers cover it by construction.

Fixtures

  • Corpus: the eleven 318-composite-figures cases (basic group, numbering + crossrefs, captionless, one-panel, stray content, two-blank-line detach, non-host closers, opener metadata, nested demotion, mixed panels, group-before-children numbering order) run as real tests via the composite-figures IMPLEMENTED entry.
  • Unit tests: 51 tests across seven files - parse shapes and the no-nesting recursion, lettered numbering (tables included), fmt round trip + idempotence + HTML parity, AST-JSON round trip + ingest renumbering + the panel-placeholder wire shape, the five lint codes plus heading-in-group crossref/duplicate-id coverage, the three degraded targets (with the heading-anchor parity repro), and the import round trip.

Cross-engine parity

A parity pass against carve-php and carve-rs settled three divergences (Markdown panel-caption paragraph separation, minimal-escape fmt output for the detached-caption shape, typed un-numbered caption_number on the wire for panel placeholders), and a branch review caught two traversals missing the new arm (Markdown prepass, lint heading index) - all fixed here with regression tests.

Spec pin

spec is pinned to 9cd27d8, the merged spec commit on carve main that carries the §4c grammar, corpus, schema and docs. Satellites (pandoc-carve, carve-lsp, carve-hexapdf, carve-sile, wysiwyg) are follow-up tickets, filed not implemented.

PART 9 §4c (markup-carve/carve#1122). A bare `::: figure` opener - the fence
and the kind word, no quoted title, no `[label]` - parses as the new
`figure_group` node: ONE figure whose captionable direct children are its
PANELS, in source order. The body parses under the unchanged inner rules, so
a panel is simply the `figure` or `table` node those rules already produce -
a captioned image, quote, code listing or display math, a promoted reference
image, or a table captioned or not - and everything else is plain group
content, preserved in place. The `^ ` line after the CLOSING fence is §4's
sixth caption host, this kind only, using the same slot idiom as the five
parse-time hosts (adjacent or across one blank line attaches, two detach),
and reachable only when a closer exists, so an EOF-closed group has no slot
by construction. An opener carrying a title or label never matches the
production and stays a generic container; a bare opener anywhere inside an
open group's body stays one too - the flag rides the sub-lexer chain, so the
no-nesting rule follows the recursion rather than guarding direct children
only.

Panels are DERIVED, not stored: one predicate (figureGroupPanels), shared by
the numbering pass and the lint, so the two cannot drift. The group is one
sequence unit (PART 9R R5 through §4c): its caption's `#` draws one number
from the label's own sequence, the group id registers as "Label N", and each
panel id registers with a letter by panel order (a..z, then aa) - but only
when the group itself drew a number. Panels draw nothing, and a `#` in a
panel caption stays a typed caption_number node WITHOUT `n`, un-numbered on
the wire, exactly as carve-php and carve-rs publish it. That literal-`#`
rule is why the HTML caption_number arm now renders an unresolved
placeholder as its authored `#` instead of the empty string - the Markdown,
plain-text and ANSI arms already did, and the empty string was unreachable
before this change because every placeholder the walk found got a number.

The HTML shape is the corpus's byte shape: a class-first, deduplicated
`figure.carve-figure-group` wrapper, an UNCONDITIONAL panels div (zero
panels still wrap the preserved content), panel figures leading with
`carve-figure-panel`, a table panel behind an explicit bare wrapper keeping
its own caption and attrs, and a trailing figcaption only when captioned.
The canonical writer emits the authored form back with the group caption
unescaped - and, because the group now hosts a caption, a DETACHED `^ `
paragraph after one is caret-escaped, with exactly ONE structural escape:
the caption-opening caret sat outside the minimal escape class, so the
minimal pass always failed the writer's redundancy check and the whole
document escalated to conservative escaping, writing escaped parens and
hashes where the other engines write the characters bare. Markdown degrades
panels in place, each host separated from its emphasized caption by a blank
line, with the bold group caption last; plain text and ANSI lead with the
group caption and write caption-over-host per panel.

Five lint codes name the shapes that parse fine and do less than they look
like they do: figure-group-nested, figure-group-opener-metadata,
figure-group-panel-number, figure-group-empty and figure-group-single-panel,
and a numbered group registers its id and its panels' ids as valid crossref
targets. The HTML importer reads the rendered carve-figure-group shape back
to the node, own output only, unwrapping the bare table-panel wrapper to its
table.

Every existing traversal that switches on block types grew the new arm -
the Markdown prepass and the lint heading index were caught missing it by
review (a heading inside a group lost its anchor and its crossrefs degraded
or falsely linted broken), and a sweep over the block_quote arms every typed
walker shares confirms the rest carry it or traverse generically by field
name, which covers it by construction.

The spec submodule pins the merged spec commit that carries the §4c corpus,
schema and docs; src/wire-fields.ts is regenerated from it, and the eleven
318-composite-figures corpus documents run as real tests.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant