From 154c9cafbfffe64eb576b9fc808cb9e2557b73f7 Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Sat, 15 Aug 2026 03:15:21 +0200 Subject: [PATCH] fix: read a table's own caption on HTML import `') + expect(codes('
` 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 `` and the figure's `
`, 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`). --- src/html-import.ts | 30 +++++++++++++++++++++++++++++- test/html-import.test.ts | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/src/html-import.ts b/src/html-import.ts index aeba9627..4225d45a 100644 --- a/src/html-import.ts +++ b/src/html-import.ts @@ -286,6 +286,14 @@ class Importer { } private table(node: P5Node, path: string, depth: number, attrs?: Attrs): BlockNode { + /* + * `
` is a DIRECT child of the table and holds the table's own + * caption, which `table.caption` has a slot for and Carve spells `^ text` + * after the rows. The row walk below looks only for `tr`, so before this + * the element was skipped and the caption left the document silently - + * pandoc emits exactly this shape for every captioned table. + */ + const captionNode = (node.childNodes ?? []).find((n) => n.tagName === 'caption') const tr: P5Node[] = [] const walk = (n: P5Node): void => { if (n.tagName === 'tr') tr.push(n) @@ -344,7 +352,10 @@ class Importer { return { type: 'table_cell', header: cell.tagName === 'th', children: this.inlines(cell.childNodes ?? [], cellPath, depth + 1), ...(kept ? { attrs: kept } : {}) } }), })) - return { type: 'table', rows, ...(attrs ? { attrs } : {}) } + const caption = captionNode + ? this.inlines(captionNode.childNodes ?? [], `${path}/caption[1]`, depth + 1) + : undefined + return { type: 'table', rows, ...(caption ? { caption } : {}), ...(attrs ? { attrs } : {}) } } private figure(node: P5Node, path: string, depth: number, attrs?: Attrs): BlockNode[] { @@ -353,6 +364,23 @@ class Importer { const targets = this.blocks(body, path, depth + 1) const target = targets[0] if (target && ['image', 'block_quote', 'table', 'code_block', 'paragraph'].includes(target.type)) { + /* + * A table brings its own caption slot, so a figure-wrapped table can + * arrive carrying TWO captions - its own `` and the figure's + * `
`. Carve spells one `^ ` line per host, and the wrapper + * itself has no spelling at all, so the figure's caption is the one that + * cannot survive. Keeping both wrote two `^ ` lines, and the second + * re-read as a literal paragraph. + */ + if (target.type === 'table' && (target as { caption?: unknown }).caption && captionNode) { + this.add( + 'table-degraded', + 'Dropped a
from a figure wrapping a table that carries its own
: Carve spells one caption per table', + 'warning', + `${path}/figcaption[1]`, + ) + return [target, ...targets.slice(1)] + } return [{ type: 'figure', target: target as never, caption: this.inlines(captionNode?.childNodes ?? [], `${path}/figcaption[1]`, depth + 1), ...(attrs ? { attrs } : {}) }, ...targets.slice(1)] } this.add('element-unwrapped', 'Unwrapped figure without a representable target', 'warning', path) diff --git a/test/html-import.test.ts b/test/html-import.test.ts index dcab8386..4f778a70 100644 --- a/test/html-import.test.ts +++ b/test/html-import.test.ts @@ -181,3 +181,35 @@ describe('table cell scope on import', () => { expect(codes(html)).toContain('attribute-dropped') }) }) + +describe('table caption on import', () => { + const carve = (html: string) => htmlToCarve(html).value.trim() + const codes = (html: string) => htmlToCarve(html).report.diagnostics.map((d) => d.code) + + it('reads a table\'s own , which pandoc emits for every captioned table', () => { + // The row walk looks only for `tr`, so before this the element + // was skipped and its text left the document with no diagnostic at all. + const source = carve( + '
Fruit prices
A
1
', + ) + expect(source).toBe('|=A|\n| 1 |\n^ Fruit prices') + expect(carveToHtml(source)).toContain('
Fruit prices
C
1
')).toEqual([]) + }) + + it('puts a figure-wrapped table\'s figcaption in the caption slot the table left empty', () => { + expect(carve('
1
Outer
')) + .toBe('| 1 |\n^ Outer') + }) + + it('keeps the table\'s own caption when a figure also captions it, and says so', () => { + // Two captions, one slot. Carve cannot spell the figure WRAPPER around a + // table at all, so the figure's caption is the one that cannot survive. + // Writing both produced a second `^ ` line that re-read as a paragraph. + const html = + '
Inner
1
Outer
' + expect(carve(html)).toBe('| 1 |\n^ Inner') + expect(codes(html)).toEqual(['table-degraded']) + expect(carveToHtml(carve(html))).not.toContain('

^') + }) +})