Skip to content

spec: a figure may wrap a table, and no Carve source spells it - #1211

Merged
dereuromark merged 3 commits into
mainfrom
spec/figure-table-interchange
Aug 16, 2026
Merged

spec: a figure may wrap a table, and no Carve source spells it#1211
dereuromark merged 3 commits into
mainfrom
spec/figure-table-interchange

Conversation

@dereuromark

@dereuromark dereuromark commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

What

PART 12 gains section 17: a figure may wrap a table, and no Carve source spells that shape.

Why

figure.target admits a table, and a table carries its own optional caption. Those are two different trees for two different documents, and both render:

<figure>
  <table>...</table>
  <figcaption>...</figcaption>
</figure>
<table>
  <caption>...</caption>
  ...
</table>

PART 9 section 4 rules the source spelling, and it only ever produces the second. A ^ line after a table attaches to the table, so a parser never produces the wrapper.

The wrapper is not unused, which is why this is a clause rather than a deletion from the schema. carve-js's HTML importer produces it from a figure-wrapped table, a shape HTML itself distinguishes from a table carrying a caption element:

<figure>
<table><tr><td>1</td></tr></table>
<figcaption>Cap</figcaption>
</figure>

htmlToAst on that returns figure{target: table}, and all three engines decode and render it identically.

What nothing said is what happens on the way back out. The canonical writer has no spelling for the wrapper, so it writes the table and its caption line, which re-reads as table{caption}, whose HTML puts the caption inside the table. This is the one caption shape whose source round trip changes the rendering, and until now no clause said so and no diagnostic reported it. Section 14 already declares exactly this situation for shortCaption and section 15 for rowGroups: no source spelling, the writer omits it, a bridge SHOULD report the structural loss.

Re-measured for this rebase rather than inherited from the original run. Feeding figure{target: table, caption} through the pinned carve-js:

=== HTML of the wrapper ===
<figure>
  <table>
    <thead><tr><th scope="col">A</th></tr></thead>
    <tbody><tr><td>1</td></tr></tbody>
  </table>
  <figcaption>Cap</figcaption>
</figure>

The canonical writer emits:

|=A|
| 1 |
^ Cap

which reparses as a table carrying a caption, rendering:

<table>
  <caption>Cap</caption>
  <thead><tr><th scope="col">A</th></tr></thead>
  <tbody><tr><td>1</td></tr></tbody>
</table>

What the rebase changed

Section 16 was taken. Composite figures claimed it for figure_group while this sat, so the clause is section 17 and the references in docs/ast-json.md and the schema test move with it. Verified free rather than assumed: nothing else in PART 12 uses 17, and the other (section 17) hits in the grammar are the tight/loose rule in a different PART.

The clause predicted its own obsolescence, and the prediction was wrong. It said the wrapper would gain a source spelling once ::: became a captionable host. PART 9 section 4c has since made it one, and the wrapper still has no spelling. A figure_group holds its panels in children as the nodes the inner caption rules built, so a table panel is a plain table child of the group, never a figure targeting a table. A group wraps a sequence of panels and captions the sequence; this wrapper wraps one table and captions that table. That paragraph now states the distinction instead of predicting it, because it is the first question a reader hits after composite figures. docs/ast-json.md gets the same distinction in one sentence.

The other direction inverted. The original PR justified its schema test by section 4a having removed block_quote from figure.target. #1213 withdrew that, so a quote is a figure target again, and the earlier follow-up commit already pointed the clause at section 4b. The test now pins the quote branch as valid rather than as removed. It is still load-bearing, and measured so: deleting the table branch from figure.target in resources/ast-schema.json turns it red.

One thing that needs your call

The clause said the writer emits "the target and its caption" without saying whose, which is only unambiguous when the table has none. A table carries an optional caption, so figure{ target: table{ caption }, caption } is a schema-valid tree with two captions and one ^ slot.

Measured on the pinned carve-js:

table caption present: false
  writer -> "|=A|\n^ Outer\n"
  reparse -> table

table caption present: true
  writer -> "|=A|\n^ Inner\n^ Outer\n"
  reparse -> table, paragraph

With the slot free the figure's caption fills it. With the slot taken the writer emits a second ^ line, and that line is not a caption: a ^ line after a caption line is an ordinary paragraph, so the output carries "Outer" as literal body prose in a <p>. That is worse than losing the wrapper, because the loss stops being observable as a loss.

The last commit rules it the way the clause's own logic already points: the wrapper is lost, the figure's caption is part of the wrapper, so it goes with it, and a writer MUST NOT emit a second ^ line. It reads to me as a consequence rather than a new decision, but it is a normative sentence that carve-js currently violates, so it is worth your nod before this merges rather than after. Happy to drop that commit and file it separately if you would rather rule on it on its own.

Scope

The AST schema is untouched. The clause states the rule the schema already carries, and no engine behavior changes on the source path.

@dereuromark

Copy link
Copy Markdown
Contributor Author

Blocked until the section 4a revert lands. This PR's section 16 closes with a paragraph contrasting the table wrapper against a quote, and its schema test asserts that figure.target refuses a block_quote - both of which the revert reverses (WHATWG requires the attribution OUTSIDE the quote, and figure is already the generic captioned wrapper, so a captioned quote becomes a figure again). The clause itself, the table wrapper having no source spelling, still holds; only that closing paragraph and that one assertion change. Rebasing on the revert.

@dereuromark
dereuromark changed the base branch from main to spec/revert-quote-attribution August 15, 2026 00:37
@dereuromark
dereuromark force-pushed the spec/figure-table-interchange branch from 013e19e to 003d7fa Compare August 15, 2026 00:37
Base automatically changed from spec/revert-quote-attribution to main August 15, 2026 00:44
@dereuromark
dereuromark force-pushed the spec/figure-table-interchange branch from cb8c14d to 120abbc Compare August 15, 2026 00:50
dereuromark added a commit to markup-carve/carve-php that referenced this pull request Aug 15, 2026
`figure.target` admits an image, a table, a code block or a paragraph. Feed it a
`block_quote` - the type PART 9 §4a removed when a caption on a quote became its
attribution - and this engine refused the payload, correctly, and then described
the wrong problem:

    $.children[0].target is missing `src`, which the schema requires

`src` is the required property of the IMAGE branch, which is simply the first
alternative in the `oneOf`. A producer reading that message would add `src` to a
block quote. carve-js, from the same schema, says the useful thing:

    a "block_quote" node sits where the schema admits only code_block, image,
    paragraph, table

THE CAUSE IS ONE LINE. `checkComposition` returns `$first` - the failure of the
first branch that failed - whenever no branch of an `anyOf` / `oneOf` matched.
For a union of typed node definitions that is nearly always the wrong story,
because the branches differ by TYPE and the first one's missing field is an
artifact of branch order rather than a fact about the payload.

So a union of typed node definitions now reports the type mismatch:

    $.children[0].target holds a "block_quote" node where the schema admits only
    code_block, image, paragraph, table

BOTH CONDITIONS ARE REQUIRED before the message is built - the value identifies
itself as a node, and every branch pins a `type` constant - and anything else
keeps `$first` exactly as before. That is what stops the change from swallowing
a useful message: an `image` at `figure.target` with no `src` is a type the
position DOES admit, so the missing field is the real problem and is still what
gets reported. A target that is not even an object keeps its missing-`type`
report.

Which payloads are accepted and refused does not change; only what the refusal
says. The four tests pin the four paths: a refused type, an admitted type
missing a field, a value with no type at all, and a legitimate image target.

The reciprocal engine gap is markup-carve/carve-rs#982 - carve-rs accepted that
same payload rather than refusing it - and markup-carve/carve#1211 adds the
schema-side test for the admitted set.
dereuromark added a commit to markup-carve/carve-js that referenced this pull request Aug 15, 2026
`htmlToCarve` changed what a document renders and said nothing. HTML
distinguishes a figure wrapping a table from a table carrying a caption, and
the importer models that correctly:

    <figure>
    <table><tr><td>1</td></tr></table>
    <figcaption>Cap</figcaption>
    </figure>

comes back from `htmlToAst` as a `figure` whose target is the table, and
rendering that AST gives the same HTML again. Carve 0.1 source has no spelling
for the wrapper, so the canonical writer emits the table and a caption line:

    | 1 |
    ^ Cap

which re-reads as the table's OWN caption. The text moves from a `<figcaption>`
beside the table to a `<caption>` inside it, and the figure is gone.

`carve migrate --from html --check-loss` exited 0 on that input and `--report`
printed an empty diagnostics array. Not because the loss was judged acceptable:
the diagnostics are collected by the IMPORT, and this loss happens in the
WRITER, so the one path whose job is to report structural loss could not see it.

PART 12 §16 (markup-carve/carve#1211) rules the shape and asks for exactly this:
a canonical writer emits the target and its caption, and a bridge or API which
exposes conversion diagnostics SHOULD report the structural loss. §14 and §15
already say the same for `shortCaption` and `rowGroups`.

WHERE THE REPORTING SITS IS THE DESIGN. The importer records the path where it
built a figure over a table; `htmlToAst` ignores that and reports nothing,
because a consumer that keeps the AST keeps the wrapper and loses nothing.
`htmlToCarve` turns each recorded path into a `structure-unspellable` warning
through the importer's own `add`, so the paths are the HTML paths every other
diagnostic uses and the `maxDiagnostics` limit stays a single budget rather
than two.

Reporting it from a post-hoc walk over the AST was written first and dropped: it
duplicated the diagnostics limit, and it could only produce a JSON-pointer path
into the tree, which would have made `path` mean two different things depending
on the code.

The tests pin both directions - the same input reports nothing through
`htmlToAst` - plus a nested case, a plain captioned table, and a figure over an
IMAGE, whose wrapper does have a Carve spelling and round-trips.
dereuromark added a commit to markup-carve/carve-js that referenced this pull request Aug 15, 2026
#1069)

`htmlToCarve` changed what a document renders and said nothing. HTML
distinguishes a figure wrapping a table from a table carrying a caption, and
the importer models that correctly:

    <figure>
    <table><tr><td>1</td></tr></table>
    <figcaption>Cap</figcaption>
    </figure>

comes back from `htmlToAst` as a `figure` whose target is the table, and
rendering that AST gives the same HTML again. Carve 0.1 source has no spelling
for the wrapper, so the canonical writer emits the table and a caption line:

    | 1 |
    ^ Cap

which re-reads as the table's OWN caption. The text moves from a `<figcaption>`
beside the table to a `<caption>` inside it, and the figure is gone.

`carve migrate --from html --check-loss` exited 0 on that input and `--report`
printed an empty diagnostics array. Not because the loss was judged acceptable:
the diagnostics are collected by the IMPORT, and this loss happens in the
WRITER, so the one path whose job is to report structural loss could not see it.

PART 12 §16 (markup-carve/carve#1211) rules the shape and asks for exactly this:
a canonical writer emits the target and its caption, and a bridge or API which
exposes conversion diagnostics SHOULD report the structural loss. §14 and §15
already say the same for `shortCaption` and `rowGroups`.

WHERE THE REPORTING SITS IS THE DESIGN. The importer records the path where it
built a figure over a table; `htmlToAst` ignores that and reports nothing,
because a consumer that keeps the AST keeps the wrapper and loses nothing.
`htmlToCarve` turns each recorded path into a `structure-unspellable` warning
through the importer's own `add`, so the paths are the HTML paths every other
diagnostic uses and the `maxDiagnostics` limit stays a single budget rather
than two.

Reporting it from a post-hoc walk over the AST was written first and dropped: it
duplicated the diagnostics limit, and it could only produce a JSON-pointer path
into the tree, which would have made `path` mean two different things depending
on the code.

The tests pin both directions - the same input reports nothing through
`htmlToAst` - plus a nested case, a plain captioned table, and a figure over an
IMAGE, whose wrapper does have a Carve spelling and round-trips.
dereuromark added a commit to markup-carve/carve-php that referenced this pull request Aug 16, 2026
`figure.target` admits an image, a table, a code block or a paragraph. Feed it a
`block_quote` - the type PART 9 §4a removed when a caption on a quote became its
attribution - and this engine refused the payload, correctly, and then described
the wrong problem:

    $.children[0].target is missing `src`, which the schema requires

`src` is the required property of the IMAGE branch, which is simply the first
alternative in the `oneOf`. A producer reading that message would add `src` to a
block quote. carve-js, from the same schema, says the useful thing:

    a "block_quote" node sits where the schema admits only code_block, image,
    paragraph, table

THE CAUSE IS ONE LINE. `checkComposition` returns `$first` - the failure of the
first branch that failed - whenever no branch of an `anyOf` / `oneOf` matched.
For a union of typed node definitions that is nearly always the wrong story,
because the branches differ by TYPE and the first one's missing field is an
artifact of branch order rather than a fact about the payload.

So a union of typed node definitions now reports the type mismatch:

    $.children[0].target holds a "block_quote" node where the schema admits only
    code_block, image, paragraph, table

BOTH CONDITIONS ARE REQUIRED before the message is built - the value identifies
itself as a node, and every branch pins a `type` constant - and anything else
keeps `$first` exactly as before. That is what stops the change from swallowing
a useful message: an `image` at `figure.target` with no `src` is a type the
position DOES admit, so the missing field is the real problem and is still what
gets reported. A target that is not even an object keeps its missing-`type`
report.

Which payloads are accepted and refused does not change; only what the refusal
says. The four tests pin the four paths: a refused type, an admitted type
missing a field, a value with no type at all, and a legitimate image target.

The reciprocal engine gap is markup-carve/carve-rs#982 - carve-rs accepted that
same payload rather than refusing it - and markup-carve/carve#1211 adds the
schema-side test for the admitted set.
dereuromark added a commit to markup-carve/carve-php that referenced this pull request Aug 16, 2026
* fix: an ingest refusal at a typed union names the admitted types

`figure.target` admits an image, a table, a code block or a paragraph. Feed it a
`block_quote` - the type PART 9 §4a removed when a caption on a quote became its
attribution - and this engine refused the payload, correctly, and then described
the wrong problem:

    $.children[0].target is missing `src`, which the schema requires

`src` is the required property of the IMAGE branch, which is simply the first
alternative in the `oneOf`. A producer reading that message would add `src` to a
block quote. carve-js, from the same schema, says the useful thing:

    a "block_quote" node sits where the schema admits only code_block, image,
    paragraph, table

THE CAUSE IS ONE LINE. `checkComposition` returns `$first` - the failure of the
first branch that failed - whenever no branch of an `anyOf` / `oneOf` matched.
For a union of typed node definitions that is nearly always the wrong story,
because the branches differ by TYPE and the first one's missing field is an
artifact of branch order rather than a fact about the payload.

So a union of typed node definitions now reports the type mismatch:

    $.children[0].target holds a "block_quote" node where the schema admits only
    code_block, image, paragraph, table

BOTH CONDITIONS ARE REQUIRED before the message is built - the value identifies
itself as a node, and every branch pins a `type` constant - and anything else
keeps `$first` exactly as before. That is what stops the change from swallowing
a useful message: an `image` at `figure.target` with no `src` is a type the
position DOES admit, so the missing field is the real problem and is still what
gets reported. A target that is not even an object keeps its missing-`type`
report.

Which payloads are accepted and refused does not change; only what the refusal
says. The four tests pin the four paths: a refused type, an admitted type
missing a field, a value with no type at all, and a legitimate image target.

The reciprocal engine gap is markup-carve/carve-rs#982 - carve-rs accepted that
same payload rather than refusing it - and markup-carve/carve#1211 adds the
schema-side test for the admitted set.

* test: pick a figure target no version of the clause admits

The new case used a `block_quote` at `figure.target` as its example of a type the
schema refuses. That was true of the schema on the day it was written and is
about to stop being true: markup-carve/carve#1161 removed `block_quote` from the
admitted set and markup-carve/carve#1213 puts it back, so the assertion would
have flipped on a submodule bump and read as a regression in this diff.

A `heading` is not a captionable host under any version of the clause, so the
case pins the behavior under test - a typed union names the admitted set rather
than the first branch's required field - and nothing else. The assertion on the
set is a substring for the same reason.

The CHANGELOG entry drops its verbatim message quote, which carried the same
dependency and had a stray escape inside the code span.

* docs: say why three narrowing returns are not covered

codecov flags three `return null` lines in the new typed-union helper. They are
type narrowing rather than guards: both unions the published schema writes today
are typed node unions, so the branch shapes always resolve, and the checks exist
because decoded JSON reaches PHP as `mixed` and the function has to be total for
a union some later schema writes differently.

Saying so in the file is the honest resolution. Removing them to satisfy the
patch threshold would trade a coverage number for a crash on the first union that
is not a typed one, and PHPStan requires the narrowing regardless.

* test: expect the admitted set the pinned spec actually publishes

The expectation named "code_block, image, paragraph, table" while the spec
this branch pins already admits block_quote too: markup-carve/carve#1213
withdrew the attribution model and put a captioned quote back to a figure,
so figure.target regained the type carve#1161 had removed.

The refused type stays a heading, which is the point of choosing it - a
heading is not a captionable host under any version of the clause, so only
the admitted list moves with the pin. The docblock now says that rather
than describing the churn as still pending.

The changelog example moves off a block quote for the same reason: it is
admissible again, so it no longer illustrates a node the union rejects.
`figure.target` admits a `table`, and a `table` carries its own optional
`caption`. Those are two trees, and both render, but only one of them has a
source spelling: a `^ ` line after a table attaches to the table, so a parser
never produces the wrapper. Measured on all three engines, a captioned table in
source yields `table{caption}` every time.

THE WRAPPER IS NOT UNUSED, which is what makes it worth a clause rather than a
deletion. carve-js's HTML importer produces it from `<figure><table>…
<figcaption>`, a shape HTML itself distinguishes from a table carrying a
`<caption>`. All three engines then decode and render it identically:

    <figure>
      <table>...</table>
      <figcaption>...</figcaption>
    </figure>

WHAT NOTHING SAID is what happens on the way back out. The canonical writer has
no spelling for the wrapper, so it writes the table and its `^ ` caption line,
and that re-reads as `table{caption}` - a different tree whose HTML puts the
caption inside the table. So this is the one caption shape whose source round
trip changes the rendering, and until now no clause said so and no diagnostic
reported it. §14 already declares exactly this situation for `shortCaption` and
§15 for `rowGroups`: no source spelling, the writer omits it, a bridge SHOULD
report the structural loss. PART 12 §16 puts the figure-wrapped table on the
same footing.

IT ALSO PINS THE OTHER DIRECTION. §4a removed `block_quote` from
`figure.target` when a caption on a quote became its attribution, and nothing
in this repo asserted that the admitted set was closed. carve-rs went on
decoding `figure{target: block_quote}` and rendering the pre-4a
`<figure><blockquote>` from it, while carve-js and carve-php refuse it; the
engine fix is separate, but the schema test here is what would have caught the
divergence. The test was checked by adding the branch back to the schema and
watching it go red.

No engine behavior changes on the source path, and the schema is untouched: the
clause states the rule the schema already carries.
carve#1213 withdrew PART 9 section 4a - a caption on a quote is an
attribution - and replaced it with 4b, which is explanatory. This clause
cited 4a twice over in one sentence: for the section number, and for an
escape hatch a quotation no longer needs, because a captioned quote is a
figure again and has nothing to escape from.

The closing paragraph also restated what 4b now says about which hosts a
caption wraps. It defers to 4b and keeps only the part 4b does not cover:
that a table is the one host with a caption slot of its own, which is why
the wrapper around it means something the caption inside it does not.
Two things went stale while this sat.

PART 12 section 16 is taken. Composite figures claimed it for `figure_group`,
so the clause moves to section 17, and the references in docs/ast-json.md and
the schema test move with it. 17 is free: nothing else in PART 12 uses it, and
the `(section 17)` hits elsewhere in the grammar are the tight/loose rule in a
different PART.

The clause also predicted its own obsolescence, and the prediction was wrong.
It said the wrapper would gain a source spelling once `:::` became a
captionable host. PART 9 section 4c has since made it one, and the wrapper
still has no spelling: a `figure_group` holds its panels in `children` as the
nodes the inner caption rules built, so a table panel is a plain `table` child
of the group, never a `figure` targeting a table. A group wraps a sequence of
panels and captions the sequence; this wrapper wraps one table and captions
that table. That paragraph now states the distinction instead of predicting it,
because it is the first question a reader hits after composite figures.

The writer loss is re-measured rather than inherited: `figure{target: table}`
with a caption renders `<figure><table>...<figcaption>`, the canonical writer
emits the table followed by its `^ ` line, and that reparses as a `table`
carrying a `caption` whose HTML puts the caption inside the table.
@dereuromark
dereuromark force-pushed the spec/figure-table-interchange branch from 120abbc to f969118 Compare August 16, 2026 09:16
@dereuromark

Copy link
Copy Markdown
Contributor Author

The blocker is cleared. #1213 landed, so the two things you named are both handled:

  • The closing paragraph no longer contrasts the wrapper against a quote. It points at PART 9 section 4b and lists a quote among the hosts that DO become a figure from source, which is the post-revert reading.
  • The schema test now asserts the opposite of what it did: figure.target ACCEPTS a block_quote. It still pins the table and image branches, and still rejects a heading.

Rebased onto current main and renumbered, because section 16 was taken by figure_group in the meantime. Two further things the rebase surfaced, both written up in the description:

  • The clause predicted it would go obsolete once ::: became a captionable host. Section 4c has since made it one and the prediction did not come true, so that paragraph now states the distinction instead. Measured: ::: figure around a captioned table parses to a figure_group whose child is a plain table +caption, never a figure targeting a table.
  • A figure whose target is an ALREADY captioned table has two captions and one ^ slot. The writer currently emits a second ^ line, which is not a caption but an ordinary paragraph, so the outer caption lands in the output as literal body prose. The last commit rules that out. It reads as a consequence of the wrapper being lost rather than a new decision, but it is a normative sentence carve-js currently violates, so it is the one thing worth your explicit nod, and it is easy to drop into its own PR if you would rather rule on it separately.

@dereuromark

Copy link
Copy Markdown
Contributor Author

Rebased, renumbered and split.

Split: the fourth commit, which ruled the two-caption collision, is now markup-carve/carve#1236. It is a normative sentence that carve-js currently violates, so it wants your explicit nod rather than riding along with three descriptive commits. This PR is now purely descriptive: the wrapper exists, no Carve source spells it, and here is how it relates to a figure group's table panel.

Your stated blocker is cleared. This was drafted "blocked until the section 4a revert lands" - #1213 landed, and both things it named are handled here.

Renumbered to PART 12 §17. The PR originally claimed §16, which composite figures took while this sat. §17 was verified free by grep rather than assumed; the other (§17) hits in the grammar are the tight/loose rule in a different PART.

The measurement was re-done, not inherited. figure{target: table, caption} renders <figure><table>…<figcaption>; the writer emits the table plus its ^ line; that reparses as a table carrying a caption whose HTML puts the caption inside the table. And a ::: figure around a captioned table parses to a figure_group whose child is a plain table +caption, never a figure targeting a table. So the wrapper still has no source spelling after composite figures.

One correction to the original body, which is now fixed in place: the clause predicted its own obsolescence and the prediction was wrong. It said the wrapper would gain a spelling once ::: became a captionable host. §4c made ::: one, and the wrapper still has none. That paragraph now states the distinction instead of predicting it away.

@dereuromark
dereuromark marked this pull request as ready for review August 16, 2026 09:24
@dereuromark
dereuromark merged commit 881134e into main Aug 16, 2026
4 checks passed
@dereuromark
dereuromark deleted the spec/figure-table-interchange branch August 16, 2026 09:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant