Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions docs/ast-json.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<figure>` and
`<figcaption>` around the table, while `table.caption` renders `<caption>`
inside it. Carve 0.1 source spells only the second, so the wrapper reaches a
tree through a format bridge - an HTML importer reading
`<figure><table>…<figcaption>` - 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:

Expand Down
54 changes: 54 additions & 0 deletions resources/grammar.ebnf
Original file line number Diff line number Diff line change
Expand Up @@ -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 } -> <figure>
<table>...</table>
<figcaption>...</figcaption>
</figure>

table{ caption } -> <table>
<caption>...</caption>
...
</table>

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
`<figure><table>…<figcaption>`, a shape HTML itself distinguishes from a
table carrying a `<caption>`.

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.
1 change: 1 addition & 0 deletions resources/normative-clauses.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
24 changes: 24 additions & 0 deletions tests/ast-schema.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<figure><table>…<figcaption>` 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) => ({
Expand Down