Skip to content

Composite figures: a bare figure fence is one figure of ordered panels - #1265

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

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

Conversation

@dereuromark

@dereuromark dereuromark commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

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

A bare ::: figure container parses as the new figure_group node: one figure of ordered panels, per PART 9 §4c of the spec. All eleven 318-composite-figures corpus documents pass byte for byte.

  • Parser: bare opener (kind word only) -> FigureGroup; the ^ line after the closing fence is the group caption, reached through the shared tryParseCaption slot (adjacent or one blank line attaches, two detach, second caption stays a paragraph). An opener with a quoted title or [label] stays a generic container; a bare figure opener anywhere inside an open group's body stays generic too (groups do not nest).
  • Panels: the direct children the inner rules already made captionable things of - figure nodes (captioned image, quote, code listing, display math, promoted reference image) and table nodes, captioned or not - in source order; stray content is preserved in place.
  • Numbering: the group is one sequence unit; panel ids resolve </#id> as "Figure Na" (letter by panel order, a..z then aa) when the group drew a number; a # in a panel caption stays literal, and renderCaptionNumber() now prints the literal # for an unresolved placeholder, matching the other three targets (previously unreachable on the HTML path).
  • HTML: the corpus-pinned carve-figure-group / carve-figure-panels / carve-figure-panel shape - class-first with deduplicated merge, panels div unconditional, no trailing figcaption without a group caption.
  • AST: figure_group = type, children, optional inline caption, attrs, pos (PART 12 §16); vendored resources/ast-schema.json synced from the spec pin; ProseMirror bridge lists the type as unmapped beside figure.
  • fmt: authored form back - bare opener, one blank line between children, closer at opener width, group caption caret unescaped; a detached \^ paragraph keeps its escape. Fmt corpus (semantic + idempotence) green with no exclusions.
  • Degradation (PART 11 §10g): Markdown emits panels with emphasized panel captions and the bold group caption last; plain text and ANSI put the group caption first.
  • HTML import: <figure class="carve-figure-group"> back to ::: figure source (own-output round trip).

Out of scope, per the rollout plan: the D7b lint codes (figure-group-nested, figure-group-opener-metadata, figure-group-panel-number, figure-group-empty, figure-group-single-panel) have no home in this engine's current lint surface (a line-based Markdown-habit linter plus one attribute pass, no container diagnostics) and follow separately.

Spec pin

tests/spec is pinned to 9cd27d8 on spec main - the merged markup-carve/carve#1215 - which carries the eleven 318-composite-figures corpus documents on top of the quote-attribution withdrawal this engine already carries (#1264).

Satellites (pandoc-carve, carve-lsp, carve-hexapdf, carve-sile, wysiwyg) are follow-up tickets, filed not implemented.

Cross-engine parity pass

A parity run against carve-php and carve-rs (which agreed with each other and the spec) settled three divergences, fixed here with regression tests:

  • Markdown target: a panel host and its emphasized caption are separated by a blank line - the caption is its own paragraph, per the ticket's degradation example.
  • fmt escaping: the detached-caption shape (corpus 318-composite-figures-6) now writes exactly one structural escape (\^ on the detached line). 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 (\(a\), \# where the other engines write the characters bare).
  • Wire shape: a # placeholder in a PANEL caption stays a typed caption_number node without n, matching the carve-php/carve-rs serialization (the unresolved-reference precedent: keep the node, render its authored spelling). The HTML arm now renders an unresolved placeholder as the literal # it spells, like the Markdown/plain/ANSI arms already did.

A branch review then caught two existing traversals missing the new arm: the Markdown prepass (heading-id index / reference scan) and the lint heading index did not descend into groups, so a heading inside one lost its anchor and its crossrefs degraded (or falsely linted broken). Both fixed with regression tests, and a sweep over every block-type switch in src/ confirms the remaining walkers carry the arm or traverse generically by field name.

@dereuromark dereuromark added the enhancement New feature or request label Aug 15, 2026
@codecov

codecov Bot commented Aug 15, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.60474% with 1 line in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/Renderer/AnsiRenderer.php 94.73% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

PART 9 section 4c (markup-carve/carve#1122). A bare `::: figure` container -
the fence, its separator, and the kind word, nothing else - parses as the new
`figure_group` node: ONE figure whose direct captionable 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 paragraph, a captioned quote, a captioned code listing,
captioned display math, or a table captioned or not - and everything else is
plain group content, preserved in place. The `^ ` line after the CLOSING fence
is section 4's sixth caption host, this kind only, and it reaches the group
through the same tryParseCaption slot every other host uses, so the
one-blank-line allowance, the two-blank detachment, the invisible-interrupter
rule and the second-caption rule all hold without a second spelling. An opener
carrying a quoted title or a `[label]` does not match the production and stays
a generic Tier-2 container, title and label preserved; a bare figure opener
anywhere inside an open group's body stays one too, because groups do not
nest.

NUMBERING (PART 9R R5 through section 4c): the group is one sequence unit. Its
caption's `#` draws one number from the label's own sequence, the group id
registers as "Label N", and each panel id registers as "Label N" plus a letter
by panel order - a..z, then aa - but only when the group itself drew a number.
Panels draw nothing: the resolver skips them, and a `#` in a panel caption
stays LITERAL. That last word is why renderCaptionNumber() now prints `#` for
an unresolved placeholder instead of the empty string - the Markdown,
plain-text and ANSI targets already printed the literal, and the empty string
was unreachable before this change because every placeholder the walk found
got a number.

The wire shape is PART 12 section 16: `type`, `children`, an optional
`caption` holding inline content - the same flattening a table's caption
already gets - `attrs` and `pos`, and deliberately no target, title or label.
The vendored resources/ast-schema.json moves to the spec pin that names it,
and the ProseMirror bridge lists the type as unmapped beside `figure`. The
canonical writer emits the authored form and does not escape the group
caption's caret (it is the caption the closer hosts, not text in that
position) while a paragraph that merely sits where the allowance would reach
the closer keeps its `\^ ` escape; Markdown, plain text and ANSI degrade per
PART 11 section 10g; the HTML importer reads the rendered carve-figure-group
shape back to `::: figure` source, own output only.

The tests/spec submodule pin moves to 9cd27d8 on spec main, the merged
markup-carve/carve#1215, which carries the corpus category
318-composite-figures on top of the quote-attribution withdrawal this engine
already made.

A cross-engine parity pass against carve-php and carve-rs settled three
divergences before this landed. Markdown separates a panel host from its
emphasized caption with a blank line - the caption is its own paragraph. The
canonical writer escapes ONLY the structural caret of a detached caption
paragraph: the caret sat outside the minimal escape class, so the minimal
form always failed the redundancy check and the whole document escalated to
conservative escaping, writing escaped parens and hashes where the other
engines write the characters bare. And a panel caption's placeholder stays a
TYPED caption_number node on the wire, un-numbered, rather than flattening to
text - the unresolved-reference precedent: keep the node, render its
authored spelling, which the HTML arm now does for an unresolved placeholder
instead of rendering nothing.

A branch review then caught the class of miss that produces most cross-engine
drift here: an existing traversal that switches on block types and lacks the
new arm. The Markdown prepass never entered a group, so a heading inside one
lost its anchor stamp and every crossref to it degraded to plain text; the
lint heading index had the same hole, reporting valid references as broken
and missing duplicate ids inside groups. Both descend now, and a sweep over
every block-type switch in src/ (via the block_quote arms they all share)
confirms the remaining walkers either carry the figure_group arm or traverse
generically by field name, which covers it by construction.
@dereuromark
dereuromark force-pushed the feat/1122-composite-figures branch from 48404b8 to 9d7ba5f Compare August 15, 2026 10:53
@dereuromark
dereuromark marked this pull request as ready for review August 15, 2026 10:58
@dereuromark
dereuromark merged commit 2090bf2 into main Aug 15, 2026
6 checks passed
@dereuromark
dereuromark deleted the feat/1122-composite-figures branch August 15, 2026 10:59
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