feat: a bare figure fence is one figure of ordered panels - #986
Merged
Conversation
This was referenced Aug 15, 2026
dereuromark
added a commit
to markup-carve/carve-py
that referenced
this pull request
Aug 15, 2026
…drawal (#40) * chore: bump the engine past the table scope rule and the caption withdrawal `main` has been red at `test_corpus_renders_byte_identically` since 2026-08-13. The pin sat at carve-rs 9130a7c ("bump spec corpus submodule to carve b1e5f5d"), 39 commits behind that repo's `main`, and the spec corpus this binding is checked against moved underneath it. Measured against the corpus at carve 3154f1a, each pin built and installed as a wheel: pin 9130a7c 1017 pairs, 77 mismatches pin 9ff0878 1017 pairs, 9 mismatches 68 documents move from mismatching to byte-identical, none move the other way, and the 9 that remain are the same 9 in both runs. The compiled extension really moved rather than only the manifest. The embedded cargo checkout path in `carve.abi3.so` reads `checkouts/carve-rs-7867d825a4352386/9130a7c` before and `checkouts/carve-rs-7867d825a4352386/9ff0878` after, and the file's sha256 goes from 46d72f51 to c4f412e5. Of the 68, 44 gain `scope` on every `th`: PART 10 SST9, implemented in carve-rs 7660a02. They are not all named for tables, because a table inside a footnote body or under a caption carries the rule too (`203`, `225`, `281`, `282`, `285`, `47-numbered-cross-references-4`, `89-block-attribute-lines-4`). The other 24 come from the rest of the range: - `293`, `299`: the semantic-name rules and the tier split. - `300`: an attribute needs a separator before the next one. - `301`: a derived title yields to an authored one. - `302`: a math span's base class keeps the class slot in place. - `305`: an abbreviation expands inside an inline container. - `313`, `314`, `315`, `45-inline-extensions*`, `71-attribute-edge-cases-11`: the reference, footnote and inline-note resolution fixes. Every one is catch-up: the binding now agrees with a spec fixture it previously disagreed with. No corpus fixture was touched. `tests/test_carve.py::test_table` asserted `<th>a</th>`, which was the output of an engine predating SST9 rather than anything the spec asks for. 44 corpus fixtures spell `scope="col"`, so the hand-written expectation was the stale side. That is the failure mode the corpus gate exists to catch, and the reason it sits beside these unit tests. The remaining 9 are `318-composite-figures*`, and they are not pin drift. The spec added them at carve 9cd27d8 today, and carve-rs implements the rule only on the open draft `markup-carve/carve-rs#986`, so no merged carve-rs revision renders them. Pinning that branch would revert everything merged after it, so this stays on the engine's `main` tip and those 9 stay red here until that PR lands. * style: join the carve-rs use block rustfmt wants on one line `cargo fmt --check` has been failing on `src/lib.rs:14` since the import list last shrank: six names fit inside the width, so rustfmt joins them and the committed vertical form is a diff. CI here does not run `cargo fmt`, so nothing reported it. Kept separate from the engine bump because it shares no reasoning with it.
dereuromark
marked this pull request as ready for review
August 15, 2026 11:47
dereuromark
force-pushed
the
feat/1122-composite-figures
branch
from
August 15, 2026 12:54
629e547 to
c83d649
Compare
PART 9 section 4c: `::: figure` with nothing else on the opener is a composite figure - a `figure_group` node whose direct captionable children (the figures the unchanged inner caption rules build, and tables) are its panels, in source order, with stray content preserved in place between them. The `^ ` caption after the CLOSING fence is the caption of the whole group: caption placement's sixth host, this container kind only, one optional blank line, two detach. The group is one numbering unit. Its caption draws one number from the shared per-label sequence; panels draw nothing, and a panel with an id resolves a crossref as the group number plus a letter by panel order, a..z then aa, ab. Panel ids register only when the group drew. A `#` placeholder in a panel caption has nothing to resolve against and stays the literal `#` - which the HTML renderer now prints for any unresolved caption number, the way the Markdown, plain and terminal targets always did. An opener carrying a quoted title or a label stays a generic container, and so does a bare opener nested anywhere inside an open group's body: groups do not nest, and both spellings lint alongside the panel placeholder under the section's rule ids. The two strict-profile findings the section names wait for a lint severity axis rather than shipping unconditionally. The canonical writer emits the authored form back, the caret on the closer unescaped because that position is the caption slot, and escaped where a blank pair detached the caption into a paragraph. The non-HTML targets degrade per PART 11 section 10g: Markdown puts each panel caption after its host as an emphasized paragraph and the group caption last in bold; plain text and the terminal lead with the group caption, the only line on those targets that says what the following blocks are one of. AST JSON gains the additive figure_group node, closed on ingest like every other, renumbered on ingest through the same pass the parse runs; the HTML importer reads the engine's own group shape back. The spec submodule moves onto the branch that pins the ten corpus documents, and the wire-field map regenerates from its schema.
The bridge landed while this branch was out, and its outbound match is exhaustive over BlockNode, so the new node had to get a decision rather than a default. The vendored carve-grammars schema map has no name for `figure_group` - the editor schema predates PART 9 section 4c - and adding one here would fork the map this bridge exists to read rather than repeat. So the group degrades to the generic container the map does have, the same one an admonition rides on: every panel and the group caption survive, and the fact that they were one figure is reported as degraded. The reason lives in the map's `unmapped` section, where the bridge's own guard test requires a decision for every wire type, with a provenance note to keep it across the next refresh until carve-grammars carries one of its own. The corpus ratchet moves from 791/215 to 793/224. That is the eleven composite-figure documents arriving with the spec pin and nothing else: the corpus went from 1006 pairs to 1017, the eleven added pairs are all `318-composite-figures*`, and no existing pair changed content. Two hold no node the editor schema lacks and round-trip strictly; nine carry a group.
dereuromark
added a commit
to markup-carve/carve-go
that referenced
this pull request
Aug 15, 2026
…ibutes The previous rebuild in this branch left thirteen corpus documents diverging, all of them naming two constructs the engine did not yet have. Both have since landed in carve-rs: markup-carve/carve-rs#986 (PART 9 section 4c, composite figures) and markup-carve/carve-rs#996 (a cell's attributes bind after its kind and alignment markers). Rebuilt via this repository's own build-wasm.sh, which writes REV and the digest in the same step as the bytes. REV moves from c83d64922bcb080d5a85aecd2f53adf506fbe8c6 to 98de7874ad2e81f69e57764562c83f4918522ac2 (carve-rs main). Measured against carve 1436f39a, 1025 corpus pairs: before this commit 13 diverging after 0 diverging The thirteen that closed are the nine `318-composite-figures*` documents that were failing and the four `319-cell-attributes-*` documents that were failing. All eleven `318-composite-figures` documents and all six `319-cell-attributes` documents now match the spec. The control is no longer vacuous. `318-composite-figures-8` asserts that an opener carrying a title or a `[label]` stays a generic container. Before this rebuild it passed for the wrong reason: the engine had no rule separating a bare opener from a decorated one, so stripping the title and the label changed nothing but two metadata lines. It now discriminates structurally. The document as written renders: <div class="figure"> <p class="admonition-title">A titled figure div</p> <figure> <img src="a.png" alt="one"> <figcaption>(a) One</figcaption> </figure> </div> <p>^ Not a group caption</p> and the same document with the title and the label removed, so both openers are bare, renders a different production entirely: <figure class="carve-figure-group"> <div class="carve-figure-panels"> <figure class="carve-figure-panel"> <img src="a.png" alt="one"> <figcaption>(a) One</figcaption> </figure> </div> <figcaption>Not a group caption</figcaption> </figure> No allowlist and no drift file exists in this repository, and none is added.
dereuromark
added a commit
to markup-carve/carve-go
that referenced
this pull request
Aug 15, 2026
…gures (#39) * fix: the corpus population guard reads the pages where the spec keeps them The guard that decides whether a corpus run compared a whole corpus reads the spec's example pages and counts their `::: compare` blocks, because a count derived from the directory under test would move with the directory and could not detect truncation. It read them from docs/examples/. markup-carve/carve#1194 made those pages generated sources and filed them under resources/examples/, beside the other generator inputs. The read is fatal when it misses, by design, so the corpus job stopped at the first page instead of comparing anything: no corpus source page at .../docs/examples/core.md: no such file or directory. tests/corpus is generated from these pages; if the spec moved them, this helper has to move with them That is the failure the guard is supposed to produce, and it is also why every real divergence behind it went unreported: the job has been red since 2026-08-14 with a wiring error on top of a stale artifact, and only the wiring error was visible. The helper now takes the route corpus_ast_test.go already takes to resources/ast-schema.json. Measured against carve main: the job goes from fatal-before-first-comparison to 1023 pairs compared, 81 of them diverging. * chore: rebuild the embedded wasm, and name what the engine still cannot render This module has no parser. It embeds a wasm32-wasip1 build of carve-rs and renders by driving it over WASI stdio, so its conformance is the embedded artifact's conformance and "implementing" a construct here means rebuilding against a carve-rs that implements it. Rebuilt from carve-rs c83d64922bcb080d5a85aecd2f53adf506fbe8c6 (main). Measured against carve main, with the population guard fixed so the comparison runs at all: 81 of 1023 corpus documents diverged before, 13 after. The 68 that this closes are the ordinary staleness the corpus job exists to catch - tables, captions, attribute parsing, footnote and reference resolution. The 13 that remain are not staleness. They are two constructs carve-rs itself does not implement, so no rebuild reaches them: - 318-composite-figures{,-2,-3,-4,-5,-6,-9,-10,-11}: PART 9 §4c, composite figures. carve-rs#986 was the port and was closed unmerged. - 319-cell-attributes-bind-after-the-kind-and-alignment-markers{,-2,-3,-6}: an attribute block after a cell's `=` marker is still rendered as cell text. Two of the eleven composite-figure documents pass, and they are worth naming because of WHY they pass: -7 (a `^ ` line after a `::: note` closer is an ordinary paragraph) and -8 (an opener carrying a title or a label stays a generic container) both assert the PRE-§4c behavior, which this engine has always had. -8 in particular is the control for the whole clause, and today it passes vacuously: making its openers bare changes nothing in the output but the title and label lines, because the engine has no rule that tells a bare opener from a decorated one. It will only start meaning something once §4c lands upstream. The corpus job stays strict. Nothing here suppresses those 13; the job is red for them, which is the accurate report. TestToHTML_Table asserted a bare <th>. The header cell carries scope="col" now, pinned by the corpus documents this rebuild brings into line, so the hand-written expectation moves with it - the failure mode corpus_test.go's comment predicts for the hand-written half of this suite. THE NATIVE COMPARISON NOW REQUIRES CARVE_BIN. TestToHTML_ByteIdenticalToNative and the static byte check assert "the wasm renders what the native engine renders", which is a statement about ONE revision - the native side has to be the carve-rs internal/wasm/REV names. The helper picked its own reference off the filesystem instead: two hardcoded target/ paths, then `carve` on PATH, none of which is that revision by construction. It failed exactly that way here, with a stale local build predating scope="col" reporting a byte mismatch on tables against a wasm the corpus proves correct. A check that chooses its own reference cannot tell "the wasm is wrong" from "the thing I found is old", and it reported the first. It now skips until a caller vouches for a binary, which is what CI already did on every run. * chore: rebuild the embedded wasm past composite figures and cell attributes The previous rebuild in this branch left thirteen corpus documents diverging, all of them naming two constructs the engine did not yet have. Both have since landed in carve-rs: markup-carve/carve-rs#986 (PART 9 section 4c, composite figures) and markup-carve/carve-rs#996 (a cell's attributes bind after its kind and alignment markers). Rebuilt via this repository's own build-wasm.sh, which writes REV and the digest in the same step as the bytes. REV moves from c83d64922bcb080d5a85aecd2f53adf506fbe8c6 to 98de7874ad2e81f69e57764562c83f4918522ac2 (carve-rs main). Measured against carve 1436f39a, 1025 corpus pairs: before this commit 13 diverging after 0 diverging The thirteen that closed are the nine `318-composite-figures*` documents that were failing and the four `319-cell-attributes-*` documents that were failing. All eleven `318-composite-figures` documents and all six `319-cell-attributes` documents now match the spec. The control is no longer vacuous. `318-composite-figures-8` asserts that an opener carrying a title or a `[label]` stays a generic container. Before this rebuild it passed for the wrong reason: the engine had no rule separating a bare opener from a decorated one, so stripping the title and the label changed nothing but two metadata lines. It now discriminates structurally. The document as written renders: <div class="figure"> <p class="admonition-title">A titled figure div</p> <figure> <img src="a.png" alt="one"> <figcaption>(a) One</figcaption> </figure> </div> <p>^ Not a group caption</p> and the same document with the title and the label removed, so both openers are bare, renders a different production entirely: <figure class="carve-figure-group"> <div class="carve-figure-panels"> <figure class="carve-figure-panel"> <img src="a.png" alt="one"> <figcaption>(a) One</figcaption> </figure> </div> <figcaption>Not a group caption</figcaption> </figure> No allowlist and no drift file exists in this repository, and none is added.
dereuromark
added a commit
to markup-carve/carve-py
that referenced
this pull request
Aug 15, 2026
…#41) This binding has no parser. It calls carve-rs through PyO3, so a construct the engine gains is a construct this package gains the moment the pin moves, and until then thirteen corpus documents diverged here for reasons no Python change could reach. Both constructs have now landed upstream: markup-carve/carve-rs#986 (PART 9 section 4c, composite figures) and markup-carve/carve-rs#996 (a cell's attributes bind after its kind and alignment markers). The pin moves from 9ff087845ebbc31e650fed4faea01f4eea895b82 to 98de7874ad2e81f69e57764562c83f4918522ac2 (carve-rs main), in Cargo.toml and in Cargo.lock together. Measured against carve 1436f39a, 1025 corpus pairs, wheel rebuilt each time: at 9ff08784 13 diverging at 98de7874 0 diverging The thirteen were the nine `318-composite-figures*` documents and the four `319-cell-attributes-*` documents that were failing; all eleven of the former and all six of the latter now match the spec. The control for the clause, `318-composite-figures-8`, asserts that an opener carrying a title or a `[label]` stays a generic container. It passed at the old pin as well, but only because the engine had no rule separating a bare opener from a decorated one - both spellings rendered `<div class="figure">`, so the document could not have failed. At the new pin the same mutation, stripping the title and the `[label]` so both openers are bare, changes the production: <div class="figure"> <p class="admonition-title">A titled figure div</p> ... </div> <p>^ Not a group caption</p> becomes <figure class="carve-figure-group"> <div class="carve-figure-panels"> <figure class="carve-figure-panel"> <img src="a.png" alt="one"> <figcaption>(a) One</figcaption> </figure> </div> <figcaption>Not a group caption</figcaption> </figure> Nothing is allowlisted or skipped; the corpus test compares every pair. The CHANGELOG entry lands under the existing 0.1.1 heading rather than a new one. v0.1.1 is a draft, not a release, so the last released tag is v0.1.0 and a consumer upgrading from it sees a single engine move.
dereuromark
added a commit
to markup-carve/carve-wasm
that referenced
this pull request
Aug 15, 2026
…#34) This crate wraps carve-rs and exposes it through wasm-bindgen; it has no parser of its own. The pin had not moved since 6f1d9bfb, and the corpus measured the cost: 94 of 1025 documents rendered something other than what the spec pins. The pin moves to 98de7874ad2e81f69e57764562c83f4918522ac2 (carve-rs main), in Cargo.toml and Cargo.lock together. Cargo.lock also gains pulldown-cmark and unicase, which the engine now depends on. Measured against carve 1436f39a, artifact rebuilt with `wasm-pack build --target nodejs` for each row and driven through the JS API, which is what tests/corpus.mjs does: at 6f1d9bfb 94 diverging at 98de7874 0 diverging The 94 are dominated by tables (a header cell carries `scope`, cell attributes, alignment, spans, continuation rows), attribute parsing, footnote and reference resolution, semantic spans, and the language attribute. Thirteen of them are the two constructs this bump was taken for: nine `318-composite-figures*` documents and four `319-cell-attributes-*`. All eleven `318-composite-figures` documents and all six `319-cell-attributes` documents now match. Composite figures are PART 9 section 4c, landed upstream as markup-carve/carve-rs#986; the cell-attribute order is markup-carve/carve-rs#996. The clause's control, `318-composite-figures-8`, asserts that an opener carrying a title or a `[label]` stays a generic container. At the old pin it could not have failed: the engine had no rule separating a bare opener from a decorated one, which the corpus shows directly - `-8` passed while every bare-opener document diverged by rendering the same `<div class="figure">`. At the new pin the distinction is real. Stripping the title and the `[label]` from `-8`, so both openers are bare, changes the production: <div class="figure"> <p class="admonition-title">A titled figure div</p> <figure> <img src="a.png" alt="one"> <figcaption>(a) One</figcaption> </figure> </div> <p>^ Not a group caption</p> becomes <figure class="carve-figure-group"> <div class="carve-figure-panels"> <figure class="carve-figure-panel"> <img src="a.png" alt="one"> <figcaption>(a) One</figcaption> </figure> </div> <figcaption>Not a group caption</figcaption> </figure> Nothing is allowlisted; this repository has no allowlist and does not gain one. smoke.mjs and the AST schema check pass unchanged.
dereuromark
added a commit
to markup-carve/carve-rb
that referenced
this pull request
Aug 15, 2026
…#66) This gem has no parser. ext/carve is a magnus binding over carve-rs, pinned to an exact revision, so a construct the engine gains arrives here when the pin moves and not before. Thirteen corpus documents diverged for that reason alone. Both constructs have landed upstream: markup-carve/carve-rs#986 (PART 9 section 4c, composite figures) and markup-carve/carve-rs#996 (a cell's attributes bind after its kind and alignment markers). The pin moves from 8dada737f608da124e06a1f7e43f962f98a3e68a to 98de7874ad2e81f69e57764562c83f4918522ac2, in ext/carve/Cargo.toml and ext/carve/Cargo.lock together. Measured against carve 1436f39a, extension recompiled each time, 1025 corpus pairs: at 8dada737 13 diverging at 98de7874 0 diverging The thirteen were the nine `318-composite-figures*` documents and the four `319-cell-attributes-*` documents that were failing; all eleven of the former and all six of the latter now match the spec. EXPECTED in test/corpus_ast_types_test.rb gains `figure_group`. That list is a one-directional check: a recorded type going missing fails, a new one only warns, and this run warned. Recording it is what makes the type load-bearing - if a later pin drops the group node, the suite says which type went rather than leaving thirteen documents to explain it. It is a tightening, not a widening: the old pin produced no unrecorded type at all, so this line would have failed the suite before the bump. The control for the clause, `318-composite-figures-8`, asserts that an opener carrying a title or a `[label]` stays a generic container. At the old pin it could not have failed - the engine had no rule separating a bare opener from a decorated one, so both spellings rendered `<div class="figure">`, which is what every diverging bare-opener document showed. At the new pin the mutation that should break it does. Stripping the title and the `[label]` so both openers are bare turns <div class="figure"> <p class="admonition-title">A titled figure div</p> <figure> <img src="a.png" alt="one"> <figcaption>(a) One</figcaption> </figure> </div> <p>^ Not a group caption</p> into <figure class="carve-figure-group"> <div class="carve-figure-panels"> <figure class="carve-figure-panel"> <img src="a.png" alt="one"> <figcaption>(a) One</figcaption> </figure> </div> <figcaption>Not a group caption</figcaption> </figure> Nothing is allowlisted or skipped. The CHANGELOG entries go under the existing Unreleased heading, which already argues from v0.1.0, the only released tag.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Part of markup-carve/carve#1122; spec: markup-carve/carve#1215.
What
A bare
::: figurecontainer is a composite figure (PART 9 §4c): onefigure_groupnode whose direct captionable children - thefigurenodes the unchanged inner caption rules build, andtablenodes - are its PANELS, in source order, with stray content preserved in place between them. The^caption after the CLOSING fence is the caption of the whole group: caption placement's sixth host, this container kind only, with the usual one-optional-blank-line allowance.^ Figure #:draws one number; panels draw nothing; a panel with an id resolves</#id>as the group number plus a letter by panel order ("Figure 2a", a..z then aa, ab, ...). Panel ids register only when the group itself drew a number. The ingest path renumbers through the same pass the parse runs, so the two cannot disagree.carve-figure-groupon the group andcarve-figure-panelon each panel, an unconditionalcarve-figure-panelsdiv, the group<figcaption>last. A wrapped table panel keeps its own<caption>.[label]on the opener stays a generic container, as does a bare opener nested inside an open group at ANY depth (groups do not nest). Both parse exactly as before and lint.figure-group-opener-metadata,figure-group-nested,figure-group-panel-number. The two STRICT-PROFILE findings §4c names (figure-group-empty,figure-group-single-panel) are deliberately not shipped: this lint surface has no severity or profile axis yet, and reporting them unconditionally would flag documents the clause calls valid.^line on the closer, and the\^escape kept where a blank pair detached the caption into a paragraph (corpus 318-composite-figures-6).*...*paragraph and the group caption last in bold; plain text and the terminal put the group caption first, then each panel's caption line over its host's degradation.figure_groupnode (PART 12 §16) -children, optionalcaption,attrs,pos, notarget/title/label/shortCaption; ingest closed per §11.src/wire_fields.rsregenerated from the moved schema.carve-figure-groupoutput comes back as the node it left as; a<figure>without the own-output class stays an unsupported element.One deliberate behavior alignment outside the group itself: an UNRESOLVED
caption_numbernow renders as the literal#in HTML, which is what the Markdown, plain-text and terminal renderers already emitted. From a parse this state is reachable only in panel captions; on the ingest path acaption_numberwithoutnshows the visible#instead of disappearing.Sequenced after the quote-attribution withdrawal
This work was built on #984 and rebased onto
mainthe moment that PR merged, so the branch is one commit off currentmain. The dependency was structural, not stylistic: the spec pin this feature needs sits past upstream's quote-attribution withdrawal (markup-carve/carve#1213), and without 984 that pin is red on arrival - the regenerated wire-field map refuses theattributionproperty the engine still published, and the corpus round-trip sweeps (corpus_round_trip,adjacent_text_runs, the ingest-budget sweep) have no per-slug allowlist to carry the mismatch. With 984 onmain, no carve#1213 exclusions were needed: every corpus category stays in IMPLEMENTED and the ten new318-composite-figuresdocuments render byte-exact.Temporary spec pin
The spec submodule points at the spec draft branch commit
078c32d(markup-carve/carve#1215,spec/1122-composite-figures) so the ten318-composite-figurescorpus documents and thefigure_groupschema entry are present. The pin is temporary and moves to the merged main commit before this PR leaves draft.Rollout order
Engine PRs (carve-js, carve-php, carve-rs) merge first; the spec corpus pin moves after; satellites (pandoc-carve, carve-lsp, carve-hexapdf, carve-sile, wysiwyg) are follow-ups, filed not implemented.