From 3d0c888ea960c720a7b137c3077ce2af710ee597 Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Sun, 16 Aug 2026 12:13:06 +0200 Subject: [PATCH] feat: the composite figure drops its panels wrapper PART 9 section 4c revision (markup-carve/carve#1122), ruled before any release carries the shape. The `
` wrapper is gone: panels and preserved stray content nest DIRECTLY inside `
`, one indent level up, with the group's `
` last and `carve-figure-panel` unchanged on each panel. HTML's content model for `figure` is one `figcaption` first-or-last plus flow content, and a `figure` is itself flow content - so the wrapper drew a box the element already provides, and a consumer styling the group can select `.carve-figure-group > .carve-figure-panel` exactly as it could select the div. Pandoc's subfigure HTML output has the same flat shape, which the sibling engines match byte for byte through the shared corpus. The HTML importer reads the flat shape back: panels are the direct `figure.carve-figure-panel` children of the group and the group caption is the direct `figcaption` child - a panel's own caption sits inside the panel figure, so the direct-child scan never confuses the two. An empty uncaptioned group keeps the bare-container empty-body form - the PART 10 section 4 exception the bare div takes, and the shape the oracle emits. The tests/spec submodule pin moves to 427b125 on spec main, the merged markup-carve/carve#1241, whose corpus pins the flat bytes; all eleven 318-composite-figures documents pass byte for byte. --- CHANGELOG.md | 5 +-- src/Converter/HtmlToCarve.php | 34 +++++++----------- src/Renderer/HtmlRenderer.php | 36 ++++++++++--------- ...ABareFigureFenceIsACompositeFigureTest.php | 18 ++++++++++ tests/spec | 2 +- 5 files changed, 55 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bfcd55f..6ab38e27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -116,8 +116,9 @@ This project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). from its label's sequence and a panel id resolves `` as the group number plus a letter ("Figure 2a"). An opener carrying a quoted title or `[label]` stays a generic container, and groups do not nest. HTML renders the - corpus-pinned `carve-figure-group` / `carve-figure-panels` / - `carve-figure-panel` shape; the Markdown, plain-text and ANSI targets degrade + corpus-pinned flat `carve-figure-group` / `carve-figure-panel` shape (panels + directly inside the group figure, per HTML's figure content model and + Pandoc's subfigure output); the Markdown, plain-text and ANSI targets degrade deterministically; `carve fmt` writes the authored form back; the AST wire carries `figure_group` with inline `caption` content; and the HTML importer turns the rendered shape back into `::: figure` source. diff --git a/src/Converter/HtmlToCarve.php b/src/Converter/HtmlToCarve.php index 337a5b5a..24ffdac7 100644 --- a/src/Converter/HtmlToCarve.php +++ b/src/Converter/HtmlToCarve.php @@ -3769,31 +3769,23 @@ protected function processFigureGroup(DOMElement $node): string { $attrs = $this->formatBlockAttributesWithoutClass($node, 'carve-figure-group'); - $panelsDiv = null; + // FLAT shape: panels and preserved stray content are DIRECT children + // of the group figure - no wrapper element - and the group's own + // `
` is the direct child handled below (a panel's caption + // sits inside the panel figure, so it never matches here). + $content = ''; foreach ($node->childNodes as $child) { + if ($child instanceof DOMElement && strtolower($child->tagName) === 'figcaption') { + continue; + } if ( $child instanceof DOMElement - && strtolower($child->tagName) === 'div' - && $this->hasClass($child, 'carve-figure-panels') + && strtolower($child->tagName) === 'figure' + && $this->hasClass($child, 'carve-figure-panel') ) { - $panelsDiv = $child; - - break; - } - } - - $content = ''; - if ($panelsDiv !== null) { - foreach ($panelsDiv->childNodes as $child) { - if ( - $child instanceof DOMElement - && strtolower($child->tagName) === 'figure' - && $this->hasClass($child, 'carve-figure-panel') - ) { - $content .= $this->processFigurePanel($child); - } else { - $content .= $this->processNode($child); - } + $content .= $this->processFigurePanel($child); + } else { + $content .= $this->processNode($child); } } $content = trim($content); diff --git a/src/Renderer/HtmlRenderer.php b/src/Renderer/HtmlRenderer.php index 8a27f695..b486134b 100644 --- a/src/Renderer/HtmlRenderer.php +++ b/src/Renderer/HtmlRenderer.php @@ -1667,10 +1667,10 @@ protected static function withLeadingClass(array $attrs, string $leadingClass): * A composite figure (PART 9 §4c, markup-carve/carve#1122). * * The corpus pins the byte shape (318-composite-figures): the group class - * leads, the panels div is UNCONDITIONAL - zero panels still wrap the - * preserved stray content - and a Figure panel renders as the `
` - * its host already produces with `carve-figure-panel` leading its classes. - * A table does not render as a figure on its own, so its panel wrapper is + * leads, panels and preserved stray content are DIRECT children of the + * group figure, and a Figure panel renders as the `
` its host + * already produces with `carve-figure-panel` leading its classes. A table + * does not render as a figure on its own, so its panel wrapper is * explicit and the table keeps its own attributes and its own ``. * No group caption, no trailing `
`. */ @@ -1680,32 +1680,36 @@ protected function renderFigureGroup(FigureGroup $node): string self::withLeadingClass($this->getRenderableAttributes($node), 'carve-figure-group'), ); - $inner = ''; + // FLAT: panels and preserved stray content nest DIRECTLY inside the + // group figure, the group caption last. HTML's figure content model is + // one figcaption first-or-last plus flow content, and a figure is + // itself flow content, so the wrapper div added nothing the element + // does not already provide - and Pandoc's subfigure HTML output has + // the same flat shape. + $body = ''; foreach ($node->getChildren() as $child) { if ($child instanceof Figure) { - $inner .= $this->renderFigure($child, 'carve-figure-panel'); + $body .= $this->renderFigure($child, 'carve-figure-panel'); } elseif ($child instanceof Table) { $table = rtrim($this->renderTable($child), "\n"); - $inner .= "
\n" + $body .= "
\n" . $this->indentBlock($table, 2) . "\n
\n"; } else { - $inner .= rtrim($this->renderNode($child), "\n") . "\n"; + $body .= rtrim($this->renderNode($child), "\n") . "\n"; } } - $body = "
\n"; - $inner = rtrim($inner, "\n"); - if ($inner !== '') { - $body .= $this->indentBlock($inner, 2) . "\n"; - } - $body .= "
\n"; - $caption = $node->getCaption(); if ($caption !== null) { $body .= '
' . $this->renderChildren($caption) . "
\n"; } - return '\n" . $this->indentBlock(rtrim($body, "\n"), 2) . "\n
\n"; + $body = rtrim($body, "\n"); + if ($body === '') { + return '\n
\n"; + } + + return '\n" . $this->indentBlock($body, 2) . "\n
\n"; } protected function renderTable(Table $node): string diff --git a/tests/TestCase/Parser/ABareFigureFenceIsACompositeFigureTest.php b/tests/TestCase/Parser/ABareFigureFenceIsACompositeFigureTest.php index efe6b372..17121a3b 100644 --- a/tests/TestCase/Parser/ABareFigureFenceIsACompositeFigureTest.php +++ b/tests/TestCase/Parser/ABareFigureFenceIsACompositeFigureTest.php @@ -56,6 +56,24 @@ public function testAnEmptyGroupIsAValidParseToo(): void $this->assertSame([], $node->getChildren()); } + public function testAnEmptyGroupRendersTheBareContainerEmptyBodyShape(): void + { + // The oracle keeps the PART 10 §4 exception the BARE div takes: an + // empty uncaptioned group closes on the next line, no blank body line. + $this->assertSame( + "
\n
\n", + (new CarveConverter())->convert("::: figure\n:::\n"), + ); + } + + public function testAnEmptyCaptionedGroupHoldsOnlyItsFigcaption(): void + { + $this->assertSame( + "
\n
Figure 1: G
\n
\n", + (new CarveConverter())->convert("::: figure\n:::\n^ Figure #: G\n"), + ); + } + public function testAQuotedTitleKeepsTheOpenerAGenericContainer(): void { // `figure_group` has no title slot BY DESIGN (§4c): the group's one diff --git a/tests/spec b/tests/spec index 35f24049..427b125c 160000 --- a/tests/spec +++ b/tests/spec @@ -1 +1 @@ -Subproject commit 35f2404937fb9efe8419399468587786c0fcfbd2 +Subproject commit 427b125cea2834a9e1f222f6669a2634c970ad54