diff --git a/docs/ast-json.md b/docs/ast-json.md index a2a4686f..940a130d 100644 --- a/docs/ast-json.md +++ b/docs/ast-json.md @@ -54,6 +54,19 @@ of source serialization: a Carve 0.1 writer omits the field, and conversion APIs with diagnostics should report that loss. It is also independent of the proposed `^^` author syntax. +A `figure` may target a `table` (§17). That is a different document from a +table carrying its own `caption`: the wrapper renders `
` and +`
` around the table, while `table.caption` renders `` +inside it. Carve 0.1 source spells only the second, so the wrapper reaches a +tree through a format bridge - an HTML importer reading +`
` - and a canonical Carve writer loses it, writing +the table and its caption line. Conversion APIs with diagnostics should report +that loss. Every other captionable host - an image, a quote, a code block, a +display-math paragraph - becomes a `figure` from source, so its wrapper is +written back exactly. A composite figure's table panel is not this wrapper +either: a table inside a `::: figure` group is a plain `table` child of the +`figure_group` (§16), not a `figure` targeting one. + A `table` may likewise carry an optional `rowGroups` object (§15), which partitions its `rows` into a head, any number of body groups and a foot: diff --git a/resources/grammar.ebnf b/resources/grammar.ebnf index 4ed49d0c..87e272ce 100644 --- a/resources/grammar.ebnf +++ b/resources/grammar.ebnf @@ -8809,3 +8809,57 @@ EOF = (* end of file *) ; carve#1121's design space and is not claimed here. The schema (`resources/ast-schema.json`) pins the shape; an unknown property on a `figure_group` is rejected on ingest like any other (§11). + + 17. A FIGURE MAY WRAP A TABLE, AND NO CARVE SOURCE SPELLS IT -- NORMATIVE. + `figure.target` admits a `table`, and a `table` carries its own optional + `caption`. Those are two trees, not two spellings of one, and both render: + + figure{ target: table, caption } ->
+
...
+
...
+
+ + table{ caption } -> + + ... +
...
+ + PART 9 §4 rules the SOURCE spelling, and it produces only the second: a + `^ ` line after a table attaches to the table, so a parser never produces + the first. The producer is a format bridge -- an HTML importer reading + `
`, a shape HTML itself distinguishes from a + table carrying a `
`. + + CARVE 0.1 SOURCE HAS NO SPELLING FOR THE WRAPPER. Like `shortCaption` in + §14 and `rowGroups` in §15, it enters a tree through an AST consumer or a + format bridge and survives AST encode/decode by §6. Being able to + REPRESENT the wrapper is not being able to AUTHOR one. + + A CANONICAL CARVE WRITER EMITS THE TARGET AND ITS CAPTION -- the table + followed by the `^ ` line -- and MUST NOT invent source syntax for the + wrapper. What it writes re-reads as `table{caption}`, a different tree + with different HTML, so the wrapper is LOST rather than preserved. A + bridge or API which exposes conversion diagnostics SHOULD report that + structural loss. This is the one caption shape whose source round trip + changes the rendering, which is why it is written down here rather than + left to each writer to discover. + + A FIGURE GROUP'S TABLE PANEL IS NOT THIS WRAPPER. PART 9 §4c has since + made `:::` a captionable host (carve#1122), and a reader arriving here + will ask whether `::: figure` around a table now spells the shape. It + does not. §16 holds a `figure_group`'s panels in `children` as exactly + the nodes the inner caption rules built, so a table panel is a plain + `table` child of the group -- carrying its own `caption` if it has one -- + and never a `figure` whose target is a table. The two say different + things: a group wraps a SEQUENCE of panels and captions the sequence, + while this wrapper wraps ONE table and captions that table. Carve 0.1 + source still spells no `figure{target: table}`, which is what makes the + writer's loss unavoidable rather than a choice. + + THE TABLE IS THE ONLY HOST IN THIS POSITION. PART 9 §4b states what a + caption makes of its host: every other captionable host -- an image, a + quote, a code block, a display-math paragraph -- becomes a `figure` when + a `^ ` line follows it, so its wrapper IS what the source spells and the + round trip is exact. A table is the one host whose caption has a slot of + its own to go into, so the wrapper around it says something the caption + inside it does not, and only that difference is unspellable. diff --git a/resources/normative-clauses.txt b/resources/normative-clauses.txt index 8733fe03..dcaff5b6 100644 --- a/resources/normative-clauses.txt +++ b/resources/normative-clauses.txt @@ -31,6 +31,7 @@ 1 A DERIVED ATTRIBUTE YIELDS TO AN AUTHORED ONE OF THE SAME NAME 1 A DESTINATION IS THE AUTHOR'S TEXT, UNSANITIZED 1 A FENCED BODY IS NOT A PARAGRAPH +1 A FIGURE MAY WRAP A TABLE, AND NO CARVE SOURCE SPELLS IT 1 A FLATTENED OPENER IS ORDINARY PARAGRAPH TEXT 1 A FOOTNOTE DEFINITION WITH NO BLOCKS IS WRITTEN WITH THE SENTINEL `{empty}` 1 A FRONTMATTER OPENER IS WRITTEN `---yaml` diff --git a/tests/ast-schema.test.mjs b/tests/ast-schema.test.mjs index da39ef21..d0cd1ea8 100644 --- a/tests/ast-schema.test.mjs +++ b/tests/ast-schema.test.mjs @@ -64,6 +64,30 @@ test('figures and tables accept an optional structural short caption', () => { assert.equal(validate({ type: 'document', children: [{ ...figure, shortCaption: 'label' }], srcByteLength: 1 }), false) }) +test('a figure targets a table, which no Carve source spells', () => { + // PART 12 §17 from both directions. The table branch is the one an HTML + // importer produces from `
` and no Carve source + // spells; the quote and image branches are the ordinary ones a captioned + // document produces. Pinning the set here is what makes a change to it a + // decision rather than a drift - carve#1161 removed the quote branch and one + // engine kept decoding it, because nothing said the set was closed. + const pos = { startLine: 1, endLine: 1, startColumn: 1, endColumn: 2, startOffset: 0, endOffset: 1 } + const figure = (target) => ({ + type: 'document', + srcByteLength: 1, + children: [{ type: 'figure', target, caption: [{ type: 'text', value: 'Cap', pos }], pos }], + }) + const cell = { type: 'table_cell', header: true, children: [{ type: 'text', value: 'A', pos }], pos } + const table = { type: 'table', rows: [{ type: 'table_row', cells: [cell], pos }], pos } + const quote = { type: 'block_quote', children: [{ type: 'paragraph', children: [], pos }], pos } + + assert.equal(validate(figure(table)), true, firstErrors()) + assert.equal(validate(figure(quote)), true, firstErrors()) + assert.equal(validate(figure({ type: 'image', src: '/x.png', alt: 'x', pos })), true, firstErrors()) + // A heading is not a captionable host, so it is not a target either. + assert.equal(validate(figure({ type: 'heading', level: 1, children: [], pos })), false) +}) + test('a table accepts an optional row grouping, and only a complete one', () => { const pos = { startLine: 1, endLine: 1, startColumn: 1, endColumn: 2, startOffset: 0, endOffset: 1 } const table = (rowGroups) => ({