From 012f23a71808218bc730e551738862cb7a9ddabb Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Sat, 15 Aug 2026 02:58:15 +0200 Subject: [PATCH] spec: a bare figure fence is one figure of ordered panels PART 9 gains the composite-figure host carve#1122 asked for: a bare ::: figure container is a figure_group - one figure-numbering unit whose direct captionable children are its panels, with the caption after the CLOSING fence (caption placement's sixth host, this kind only) captioning and numbering the whole group. An opener carrying a quoted title or a label stays a generic container, as does a nested bare opener; both lint. Panels draw nothing from the document sequence; a panel id resolves a crossref as the group number plus a letter by panel order. The executable oracle parses and renders the construct, ten corpus cases pin the bytes, the AST schema gains the additive figure_group node, and the new slugs the pinned engine cannot reproduce are declared in the engine-pin-drift file; the pin itself does not move here. --- CHANGELOG.md | 16 + docs/ast-json.md | 28 ++ docs/blocks-and-attributes.md | 9 + docs/cheatsheet.md | 17 +- docs/graceful-degradation.md | 9 + docs/implementation-comparison.md | 3 +- docs/index.md | 4 +- docs/profiles.md | 12 +- docs/versioning.md | 34 ++ resources/ast-schema.json | 50 +++ resources/engine-pin-drift.txt | 14 + resources/example-pages.txt | 1 + resources/examples/edge-cases.md | 412 +++++++++++++++++++++ resources/grammar.ebnf | 284 +++++++++++++- resources/normative-clauses.txt | 3 + scripts/spec/html.mjs | 87 ++++- scripts/spec/layout.mjs | 34 ++ tests/ast-schema.test.mjs | 5 + tests/corpus/318-composite-figures-10.crv | 20 + tests/corpus/318-composite-figures-10.html | 27 ++ tests/corpus/318-composite-figures-11.crv | 7 + tests/corpus/318-composite-figures-11.html | 11 + tests/corpus/318-composite-figures-2.crv | 17 + tests/corpus/318-composite-figures-2.html | 18 + tests/corpus/318-composite-figures-3.crv | 7 + tests/corpus/318-composite-figures-3.html | 12 + tests/corpus/318-composite-figures-4.crv | 5 + tests/corpus/318-composite-figures-4.html | 9 + tests/corpus/318-composite-figures-5.crv | 12 + tests/corpus/318-composite-figures-5.html | 14 + tests/corpus/318-composite-figures-6.crv | 7 + tests/corpus/318-composite-figures-6.html | 9 + tests/corpus/318-composite-figures-7.crv | 4 + tests/corpus/318-composite-figures-7.html | 4 + tests/corpus/318-composite-figures-8.crv | 9 + tests/corpus/318-composite-figures-8.html | 12 + tests/corpus/318-composite-figures-9.crv | 7 + tests/corpus/318-composite-figures-9.html | 11 + tests/corpus/318-composite-figures.crv | 11 + tests/corpus/318-composite-figures.html | 13 + tests/schema-fields-are-produced.test.mjs | 1 + tests/spec/318-composite-figures.test | 291 +++++++++++++++ 42 files changed, 1539 insertions(+), 21 deletions(-) create mode 100644 tests/corpus/318-composite-figures-10.crv create mode 100644 tests/corpus/318-composite-figures-10.html create mode 100644 tests/corpus/318-composite-figures-11.crv create mode 100644 tests/corpus/318-composite-figures-11.html create mode 100644 tests/corpus/318-composite-figures-2.crv create mode 100644 tests/corpus/318-composite-figures-2.html create mode 100644 tests/corpus/318-composite-figures-3.crv create mode 100644 tests/corpus/318-composite-figures-3.html create mode 100644 tests/corpus/318-composite-figures-4.crv create mode 100644 tests/corpus/318-composite-figures-4.html create mode 100644 tests/corpus/318-composite-figures-5.crv create mode 100644 tests/corpus/318-composite-figures-5.html create mode 100644 tests/corpus/318-composite-figures-6.crv create mode 100644 tests/corpus/318-composite-figures-6.html create mode 100644 tests/corpus/318-composite-figures-7.crv create mode 100644 tests/corpus/318-composite-figures-7.html create mode 100644 tests/corpus/318-composite-figures-8.crv create mode 100644 tests/corpus/318-composite-figures-8.html create mode 100644 tests/corpus/318-composite-figures-9.crv create mode 100644 tests/corpus/318-composite-figures-9.html create mode 100644 tests/corpus/318-composite-figures.crv create mode 100644 tests/corpus/318-composite-figures.html create mode 100644 tests/spec/318-composite-figures.test diff --git a/CHANGELOG.md b/CHANGELOG.md index 171b1a32..b78ec3db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Composite figures: a bare `::: figure` container is a captionable host** + (carve#1122, PART 9 §4c). One figure holding ordered panels: the direct + `figure`/`table` children are the panels, a `^ ` caption after the CLOSING + fence - caption placement's sixth host, this kind only - captions and + numbers the whole group, and the AST gains a `figure_group` node type + (PART 12 §16). The group is one figure-sequence unit; a panel id resolves + `` as the group number plus a letter ("Figure 2a"); a `#` in a panel + caption stays literal. An opener carrying a quoted title or `[label]` stays + a generic container, as does a nested bare `::: figure`; both lint. HTML + renders `carve-figure-group` / `carve-figure-panels` / `carve-figure-panel`, + and PART 11 §10g fixes the writer spelling and the non-HTML degradations. + This is an observable parsing change inside 0.1.x for documents that already + hold a bare `::: figure` fence: the container reclassifies from a generic + div, and a previously dangling `^ ` line after its closer starts consuming a + figure number, which can renumber later figures. + - **A compact language attribute, `{:TAG}`** (carve#1114). `[Le Bon Usage]{:fr}` is exact sugar for `{lang=fr}`, on inline spans and on block attribute lines alike. The empty form `{:}` means the language is explicitly UNKNOWN and diff --git a/docs/ast-json.md b/docs/ast-json.md index 55537237..a2a4686f 100644 --- a/docs/ast-json.md +++ b/docs/ast-json.md @@ -690,6 +690,34 @@ reasons: the same `
`. A structure two producers disagree about, which no output depends on, is an internal. +## Composite figures + +A bare `::: figure` container (PART 9 section 4c) serializes as its own node type, +`figure_group` (§16): + +```json +{"type": "figure_group", + "children": [ ... ], + "caption": [ ... ], + "attrs": { ... }, + "pos": { ... }} +``` + +`children` are ordinary block nodes in source order; the panels are the +`figure` and `table` nodes among them, and non-panel stray content sits +between them in place. There is **no** `panels` array - repeating the children +under a second key would let the two disagree, so a consumer derives the panel +list the way the renderer does: by type, in order. `caption` is the group +caption (the `^ ` line after the closing fence); absent means uncaptioned, not +an empty array. + +The node is discriminated by its `type`, deliberately: every `figure` carries +a `target`, the group does not, and a consumer probing for the missing field +instead of reading the type string would break silently the day either shape +grows a field. No `title`, no `label`, no `shortCaption`, no legend fields - +that design space belongs to carve#1118 and carve#1121 and is not claimed +here. + ## Open question **Citation items are plain objects**, carrying `key`, `prefix`, `locator` and diff --git a/docs/blocks-and-attributes.md b/docs/blocks-and-attributes.md index a42ca40c..1a847fbb 100644 --- a/docs/blocks-and-attributes.md +++ b/docs/blocks-and-attributes.md @@ -105,6 +105,15 @@ Header and label together - header first, label second. ::: ``` +One container kind is reserved: a **bare `::: figure` opener** (no title, no +label) is a *composite figure* (PART 9 §4c), not an admonition. Its captioned +children become panels and a `^ ` caption after the closing fence captions the +whole group - the one `:::` closer that hosts a caption. Add a quoted title or +a `[label]` to the opener and the line falls back to a generic +`
` container (with a `figure-group-opener-metadata` lint +warning): the group form has no title or label slot, its caption is the +metadata channel. + Two strictness rules to know: - The header must use **straight double quotes**. An unquoted trailing word (`::: note Custom Title`) or typographic quotes (`::: note “Custom”`, the kind word processors and CMS text filters substitute) make the line *not a fence at all* - the whole block degrades to a literal paragraph. If you see raw `:::` lines in your output, check the quotes first. diff --git a/docs/cheatsheet.md b/docs/cheatsheet.md index cda82475..a8a5102a 100644 --- a/docs/cheatsheet.md +++ b/docs/cheatsheet.md @@ -135,7 +135,7 @@ two + continuation cell | (+ = multi-line cell) ``` -## Captions (images, quotes, tables, code listings, equations) +## Captions (images, quotes, tables, code listings, equations, figure groups) ```carve ![Photo](img.jpg) @@ -150,6 +150,21 @@ two A `^` caption after a fenced code block makes a numbered *listing*; after a standalone `$$`-math block, a numbered *equation*. +A bare `::: figure` container is a *composite figure*: its captioned children +become lettered panels, and a `^` caption after the closing fence captions and +numbers the whole group (`` then renders as "Figure 2a"): + +```carve +::: figure +![one](a.png) +^ (a) One + +![two](b.png) +^ (b) Two +::: +^ Figure #: The pair +``` + A caption spans multiple lines like a paragraph — following lines fold in until a blank line or a block that would interrupt a paragraph (a list marker folds in, it does not end the caption): diff --git a/docs/graceful-degradation.md b/docs/graceful-degradation.md index dfecca0e..482ebd64 100644 --- a/docs/graceful-degradation.md +++ b/docs/graceful-degradation.md @@ -48,6 +48,15 @@ The table reflects the reference engines' renderer behavior. | Footnotes | jump links | print-native footnotes; `[^id]` preserved in Markdown | degrades natively | | Links / autolinks | clickable | clickable in PDF; URL preserved in plain text | degrades natively | | Cross-references / TOC | anchor links | internal PDF links; anchors preserved in Markdown | degrades natively | +| Composite figure (`::: figure`) | one `
` with a panels `
` and a group `
`; layout hints (`columns-2`) pass through as classes | Markdown: panels in order, panel captions as `*(...)*`, group caption last as `**...**`; plain/terminal: group caption first, then each panel's caption + host degradation | normative - PART 9 §4c + the writer/degradation clause in PART 11 | + +The composite figure's contract is the floor applied with no exceptions: every +panel, every panel caption, any stray content between the panels, and the group +caption (its number resolved) are **content**, and no target may silently +discard or reorder any of them. Layout hints degrade to nothing - order is the +only layout a non-HTML target keeps - but the text never does. Panel letters +exist in cross-reference text only ("Figure 2a"); no target invents visible +`(a)` labels the author did not write. Most constructs already degrade well because their distinguishing text is a **title** (a quoted `"..."` node the renderer emits) or **source** (kept diff --git a/docs/implementation-comparison.md b/docs/implementation-comparison.md index 7ba4b2a3..67573d72 100644 --- a/docs/implementation-comparison.md +++ b/docs/implementation-comparison.md @@ -106,7 +106,8 @@ under the same semantic-span rule, `314-a-footnote-in-an-unresolved-reference-is-not-a-reference`, `315-an-inline-note-s-content-resolves-after-the-note`, `316-an-image-s-alt-text-closes-where-a-link-s-text-closes`, -`317-an-editorial-comment-s-bracket-is-content-not-the-close`. +`317-an-editorial-comment-s-bracket-is-content-not-the-close`, +`318-composite-figures`. Those categories landed on hosts that could not retake the run above, so its numbers describe the corpus WITHOUT them. The alternative was to edit the diff --git a/docs/index.md b/docs/index.md index 666979df..9e32fd69 100644 --- a/docs/index.md +++ b/docs/index.md @@ -29,7 +29,7 @@ features: - title: Interactive Online, Readable Offline details: "Built for the interactive web first — diagrams, math, charts and tabs hydrate into rich output online. With no JavaScript every block degrades to clean semantic HTML: a Mermaid fence still shows its source,
stays native, tables stay tables." - title: Captions Everywhere - details: One ^ prefix captions images, blockquotes, tables, listings and equations — emitting semantic figure / figcaption / caption HTML. + details: One ^ prefix captions images, blockquotes, tables, listings, equations and composite figure groups — emitting semantic figure / figcaption / caption HTML. - title: Friendly Tables details: "|= for headers, ^ for rowspan, < for colspan, + for multi-line cells. No separator row required." - title: Built-in Extensions @@ -94,7 +94,7 @@ pinned to exact HTML in the [examples](./examples). **Carve 0.1 is specified and shipping.** Tier-1 core and Tier-2 standard extensions are normative and stable; Tier-3 app-level extensions ship but evolve -(see [Versioning](./versioning)). Conformance is pinned by 1006 corpus examples +(see [Versioning](./versioning)). Conformance is pinned by 1017 corpus examples with exact HTML output, and the three reference engines - carve-js (TypeScript), carve-php, and carve-rs - all run the same corpus. Where the corpus pins a rule ahead of an engine, the window is declared on the diff --git a/docs/profiles.md b/docs/profiles.md index 1adbcf58..d0cf27f3 100644 --- a/docs/profiles.md +++ b/docs/profiles.md @@ -31,7 +31,7 @@ spelling. `list_item`, `table`, `table_row`, `table_cell`, `thematic_break`, `div`, `admonition`, `raw_block`, `footnote`, `frontmatter`, `definition_list`, `definition_term`, `definition_description`, `section`, `line_block`, -`comment`, `figure`, `caption`, `abbreviation_def`, +`comment`, `figure`, `figure_group`, `caption`, `abbreviation_def`, `link_reference_definition`. **Inline:** `text`, `emphasis`, `strong`, `underline`, `strike`, @@ -60,11 +60,15 @@ containers has no way to express that if the kind lives in a class string. Which fences are callouts is the **Tier-1 canonical list** - `note`, `tip`, `warning`, `danger`, `info`, `success`, `example`, `quote`. A fence opened with -any other word (`::: sidebar`, `::: figure-group`, a name your own extension +any other word (`::: sidebar`, `::: aside-note`, a name your own extension claims) is a **generic container**: it renders as `
` rather than an `