Skip to content

fix: read a table's own caption on HTML import - #1071

Merged
dereuromark merged 1 commit into
mainfrom
fix/import-table-caption
Aug 15, 2026
Merged

fix: read a table's own caption on HTML import#1071
dereuromark merged 1 commit into
mainfrom
fix/import-table-caption

Conversation

@dereuromark

Copy link
Copy Markdown
Contributor

The bug

<table><caption> is how HTML captions a table, and it is what pandoc emits for every captioned table. The importer dropped it silently:

htmlToCarve('<table><caption>Fruit prices</caption><thead><tr><th>A</th></tr></thead>…</table>')
// value:       "|=A|\n| 1 |\n"        <- caption gone
// diagnostics: []                     <- nothing said

The returned AST node came back as {type, rows} with no caption field.

table() builds its rows by walking for tr elements, so a <caption> child is simply never visited.

Why this was never a representation problem

The slot already existed on every side:

  • resources/ast-schema.json defines table.caption
  • the parser fills it from ^ text after the rows
  • carveToHtml renders it back to <caption>

So the shape was always spellable — the element was just unread.

The second case it exposed

A figure-wrapped table can arrive with two captions: its own <caption> and the figure's <figcaption>. Carve spells one ^ line per host, so emitting both produced:

| 1 |
^ Inner
^ Outer     <- re-reads as <p>^ Outer</p>

That's worse than the silent drop. The figure wrapper has no Carve spelling at all (markup-carve/carve#1211 documents exactly this), so the figure's caption is the one that cannot survive. The table keeps its own, and the figcaption is reported as table-degraded — the code the contract already defines as "a table could not be represented structurally", which avoids adding a code the spec's html-import-schema.json would have to enumerate first.

Behavior after

Input Carve Diagnostics
<table><caption>Fruit prices</caption>… |=A| / | 1 | / ^ Fruit prices none
<figure><table>…<figcaption>Outer | 1 | / ^ Outer none
<figure><table><caption>Inner…<figcaption>Outer | 1 | / ^ Inner table-degraded

The middle row is unchanged — a figure-wrapped table with no caption of its own still puts the figcaption in the slot the table left empty, losing the wrapper element but no text.

Cross-engine

Measured on the same input:

Engine Before
carve-php already reads it (findFirstDirectChildByTagName($node, 'caption'))
carve-js dropped it — fixed here
carve-rs same gap: html_import.rs hardcodes caption: None

carve-rs needs the equivalent fix; filing separately.

Verification

Full suite: 383 files, 9484 tests, 0 failures. npm run typecheck clean. Three regression tests added covering the table's own caption, the figure-wrapped table with an empty slot, and the two-caption conflict (including that the output contains no leaked <p>^).

`<table><caption>` is how HTML captions a table, and it is what pandoc
emits for every captioned table. The importer's row walk looks only for
`tr`, so the caption element was skipped: its text left the document, the
returned node had no `caption` field, and `report.diagnostics` was empty.
Nothing said anything.

The slot was already there. `table.caption` is in the AST schema, the
parser fills it, and Carve spells it `^ text` after the rows - so this was
never an unrepresentable shape, only an unread element.

Reading it exposed a second case. A figure-wrapped table can arrive
carrying TWO captions, its own `<caption>` and the figure's `<figcaption>`,
and Carve spells one `^ ` line per host. Emitting both wrote a second `^ `
line that re-read as a literal paragraph, which is worse than the silent
drop it replaced. The figure WRAPPER has no Carve spelling at all, so its
caption is the one that cannot survive: the table keeps its own and the
figcaption is reported as `table-degraded`, the code the contract already
defines as "a table could not be represented structurally".

A figure-wrapped table with no caption of its own is unchanged - the
figcaption still lands in the slot the table left empty, which loses only
the wrapper element and no text.

Measured across the engines: carve-php already reads the caption, carve-rs
has the same gap (html_import.rs hardcodes `caption: None`).
@dereuromark
dereuromark merged commit a0061cf into main Aug 15, 2026
4 checks passed
@dereuromark
dereuromark deleted the fix/import-table-caption branch August 15, 2026 01:46
dereuromark added a commit to markup-carve/carve-rs that referenced this pull request Aug 15, 2026
`<table><caption>` is how HTML captions a table, and it is what pandoc
emits for every captioned table. The importer's row walk descends looking
only for `tr`, so the caption element was never visited: `Table::caption`
was hardcoded to `None`, the text left the document, and no diagnostic
said so.

The slot was already there. `Table::caption` exists on the node, the
parser fills it, and Carve spells it `^ text` after the rows - so this was
never an unrepresentable shape, only an unread element.

carve-js had the same gap (markup-carve/carve-js#1071); carve-php already
read it. This brings the third engine into line.

Unlike carve-js, this importer has no `<figure>` branch, so the
two-captions-one-slot case that fix had to settle does not arise here.
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