From 9d7ba5fd18d6e9fd9936de9394317242e17c0907 Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Sat, 15 Aug 2026 03:44:26 +0200 Subject: [PATCH] feat: a bare figure fence is one figure of ordered panels PART 9 section 4c (markup-carve/carve#1122). A bare `::: figure` container - the fence, its separator, and the kind word, nothing else - parses as the new `figure_group` node: ONE figure whose direct captionable children are its PANELS, in source order. The body parses under the unchanged inner rules, so a panel is simply the `figure` or `table` node those rules already produce - a captioned image paragraph, a captioned quote, a captioned code listing, captioned display math, or a table captioned or not - and everything else is plain group content, preserved in place. The `^ ` line after the CLOSING fence is section 4's sixth caption host, this kind only, and it reaches the group through the same tryParseCaption slot every other host uses, so the one-blank-line allowance, the two-blank detachment, the invisible-interrupter rule and the second-caption rule all hold without a second spelling. An opener carrying a quoted title or a `[label]` does not match the production and stays a generic Tier-2 container, title and label preserved; a bare figure opener anywhere inside an open group's body stays one too, because groups do not nest. NUMBERING (PART 9R R5 through section 4c): the group is one sequence unit. Its caption's `#` draws one number from the label's own sequence, the group id registers as "Label N", and each panel id registers as "Label N" plus a letter by panel order - a..z, then aa - but only when the group itself drew a number. Panels draw nothing: the resolver skips them, and a `#` in a panel caption stays LITERAL. That last word is why renderCaptionNumber() now prints `#` for an unresolved placeholder instead of the empty string - the Markdown, plain-text and ANSI targets already printed the literal, and the empty string was unreachable before this change because every placeholder the walk found got a number. The wire shape is PART 12 section 16: `type`, `children`, an optional `caption` holding inline content - the same flattening a table's caption already gets - `attrs` and `pos`, and deliberately no target, title or label. The vendored resources/ast-schema.json moves to the spec pin that names it, and the ProseMirror bridge lists the type as unmapped beside `figure`. The canonical writer emits the authored form and does not escape the group caption's caret (it is the caption the closer hosts, not text in that position) while a paragraph that merely sits where the allowance would reach the closer keeps its `\^ ` escape; Markdown, plain text and ANSI degrade per PART 11 section 10g; the HTML importer reads the rendered carve-figure-group shape back to `::: figure` source, own output only. The tests/spec submodule pin moves to 9cd27d8 on spec main, the merged markup-carve/carve#1215, which carries the corpus category 318-composite-figures on top of the quote-attribution withdrawal this engine already made. A cross-engine parity pass against carve-php and carve-rs settled three divergences before this landed. Markdown separates a panel host from its emphasized caption with a blank line - the caption is its own paragraph. The canonical writer escapes ONLY the structural caret of a detached caption paragraph: the caret sat outside the minimal escape class, so the minimal form always failed the redundancy check and the whole document escalated to conservative escaping, writing escaped parens and hashes where the other engines write the characters bare. And a panel caption's placeholder stays a TYPED caption_number node on the wire, un-numbered, rather than flattening to text - the unresolved-reference precedent: keep the node, render its authored spelling, which the HTML arm now does for an unresolved placeholder instead of rendering nothing. A branch review then caught the class of miss that produces most cross-engine drift here: an existing traversal that switches on block types and lacks the new arm. The Markdown prepass never entered a group, so a heading inside one lost its anchor stamp and every crossref to it degraded to plain text; the lint heading index had the same hole, reporting valid references as broken and missing duplicate ids inside groups. Both descend now, and a sweep over every block-type switch in src/ (via the block_quote arms they all share) confirms the remaining walkers either carry the figure_group arm or traverse generically by field name, which covers it by construction. --- CHANGELOG.md | 15 ++ resources/ast-schema.json | 50 +++++ resources/prosemirror-schema-map.json | 1 + src/Ast/AstCodec.php | 11 +- src/Converter/HtmlToCarve.php | 108 ++++++++++ src/Node/Block/FigureGroup.php | 77 ++++++++ src/NodeType.php | 6 + src/Parser/BlockParser.php | 117 +++++++++++ src/Renderer/AnsiRenderer.php | 40 ++++ src/Renderer/CarveRenderer.php | 34 ++++ src/Renderer/CrossReferenceResolver.php | 80 ++++++++ src/Renderer/HtmlRenderer.php | 86 +++++++- src/Renderer/MarkdownRenderer.php | 43 ++++ src/Renderer/PlainTextRenderer.php | 42 ++++ tests/CarveCorpusTest.php | 1 + .../Ast/ACompositeFigureRidesTheWireTest.php | 97 +++++++++ ...mpositeFigureImportsFromItsOwnHtmlTest.php | 57 ++++++ .../ProfileVocabularyConformanceTest.php | 1 + ...ABareFigureFenceIsACompositeFigureTest.php | 185 ++++++++++++++++++ .../ACompositeFigureNumbersAsOneUnitTest.php | 98 ++++++++++ ...CompositeFigureSurvivesEveryTargetTest.php | 63 ++++++ ...erSpellsACompositeFigureAsAuthoredTest.php | 67 +++++++ tests/fixtures/ast-schema.json | 6 + tests/spec | 2 +- 24 files changed, 1281 insertions(+), 6 deletions(-) create mode 100644 src/Node/Block/FigureGroup.php create mode 100644 tests/TestCase/Ast/ACompositeFigureRidesTheWireTest.php create mode 100644 tests/TestCase/Converter/ACompositeFigureImportsFromItsOwnHtmlTest.php create mode 100644 tests/TestCase/Parser/ABareFigureFenceIsACompositeFigureTest.php create mode 100644 tests/TestCase/Renderer/ACompositeFigureNumbersAsOneUnitTest.php create mode 100644 tests/TestCase/Renderer/ACompositeFigureSurvivesEveryTargetTest.php create mode 100644 tests/TestCase/Renderer/TheWriterSpellsACompositeFigureAsAuthoredTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index a0f50c22..97d562ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,21 @@ This project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Added +- **Composite figures: `::: figure` is a captionable host** (PART 9 §4c, + markup-carve/carve#1122). A bare `::: figure` container parses as the new + `figure_group` AST node: one figure holding ordered panels - its direct + captionable children (captioned images, quotes, code listings, display math, + and tables captioned or not) - with stray content preserved in place. The `^ ` + line after the closing fence is the GROUP caption; the group draws ONE number + 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 + 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. + - **The ProseMirror bridge carries every authored construct** (PART 12 vocabulary, the schema map's former `unmapped` list). Figures with their captions - a captioned quote, image or code block used to vanish from the diff --git a/resources/ast-schema.json b/resources/ast-schema.json index a7bbc5ab..b1a7ad24 100644 --- a/resources/ast-schema.json +++ b/resources/ast-schema.json @@ -214,6 +214,7 @@ "definition_term", "div", "figure", + "figure_group", "footnote", "frontmatter", "heading", @@ -397,6 +398,21 @@ "$ref": "#/$defs/figure" } }, + { + "if": { + "properties": { + "type": { + "const": "figure_group" + } + }, + "required": [ + "type" + ] + }, + "then": { + "$ref": "#/$defs/figure_group" + } + }, { "if": { "properties": { @@ -1118,6 +1134,40 @@ }, "additionalProperties": false }, + "figure_group": { + "type": "object", + "title": "figure_group (block)", + "description": "A composite figure (PART 9 \u00a74c, carve#1122): one figure-numbering unit whose direct captionable children - ordinary `figure` and `table` nodes among `children`, in source order - are its panels. There is no `panels` array: a consumer derives the panel list the way the renderer does, by type, in order, and non-panel stray content is preserved in place between them. Discriminated by `type` rather than by shape: every `figure` carries a `target`, this node deliberately does not, and probing for the missing field instead of the type string would break the day either shape grows a field. No title, no label, no `shortCaption`, no legend fields - the group's one authored metadata channel is `caption`, and the rest is carve#1118/carve#1121 design space, not claimed here.", + "required": [ + "type", + "children" + ], + "properties": { + "type": { + "const": "figure_group" + }, + "children": { + "type": "array", + "items": { + "$ref": "#/$defs/blockNode" + } + }, + "caption": { + "type": "array", + "description": "The GROUP caption (the `^ ` line after the closing fence). Absent means the group is uncaptioned - no empty-array placeholder.", + "items": { + "$ref": "#/$defs/inlineNode" + } + }, + "attrs": { + "$ref": "#/$defs/attrs" + }, + "pos": { + "$ref": "#/$defs/pos" + } + }, + "additionalProperties": false + }, "footnote": { "type": "object", "title": "footnote (block)", diff --git a/resources/prosemirror-schema-map.json b/resources/prosemirror-schema-map.json index e05ebe45..ab9f8108 100644 --- a/resources/prosemirror-schema-map.json +++ b/resources/prosemirror-schema-map.json @@ -279,6 +279,7 @@ } }, "unmapped": { + "figure_group": "composite figures (PART 9 4c) are not yet modeled; the bridge's figure node covers the panels it holds when they arrive as plain figures", "abbreviation_def": "abbreviation definitions ride on the doc node's attrs", "caption_number": "numbered captions are a resolution artifact, not editor content", "raw_text": "raw text is the payload of a raw block, not a node an editor holds", diff --git a/src/Ast/AstCodec.php b/src/Ast/AstCodec.php index 9a4c929f..a23b50bf 100644 --- a/src/Ast/AstCodec.php +++ b/src/Ast/AstCodec.php @@ -218,7 +218,8 @@ class AstCodec 'definition_term.children', 'delete.children', 'div.children', 'document.children', 'document.srcByteLength', 'emphasis.children', 'escaped_text.value', - 'figure.caption', 'figure.target', 'footnote.children', + 'figure.caption', 'figure.target', 'figure_group.children', + 'footnote.children', 'footnote.label', 'footnote_ref.id', 'frontmatter.content', 'frontmatter.format', 'heading.children', 'heading.level', 'heading_ref.target', @@ -2380,7 +2381,13 @@ private static function spanFromWire(array $data): array private static function captionFromWire(array $data): array { $caption = $data['caption'] ?? null; - if (($data['type'] ?? null) === 'table' && is_array($caption) && !isset($caption['type'])) { + // A table's caption and a composite figure's GROUP caption (PART 9 + // §4c) are both inline content on the wire and a Caption block here. + if ( + in_array($data['type'] ?? null, ['table', 'figure_group'], true) + && is_array($caption) + && !isset($caption['type']) + ) { $data['caption'] = ['type' => 'caption', 'children' => $caption]; } diff --git a/src/Converter/HtmlToCarve.php b/src/Converter/HtmlToCarve.php index 228a9991..d337e721 100644 --- a/src/Converter/HtmlToCarve.php +++ b/src/Converter/HtmlToCarve.php @@ -3517,6 +3517,12 @@ protected function getInnerHtml(DOMElement $node): string protected function processFigure(DOMElement $node): string { + // A composite figure this converter's own HTML renderer produced + // (PART 9 §4c) goes back to its `::: figure` source. + if ($this->hasClass($node, 'carve-figure-group')) { + return $this->processFigureGroup($node); + } + $output = "\n"; // Find img, blockquote, and figcaption @@ -3541,6 +3547,108 @@ protected function processFigure(DOMElement $node): string return $output . "\n\n"; } + /** + * `
` back to `::: figure` source + * (PART 9 §4c; own-output round trip). The structural classes are + * render-time vocabulary, not authored, so they are dropped; everything + * else goes back on the attribute lines. The trailing `
` is + * the group caption and comes back as the `^ ` line after the closer. + */ + protected function processFigureGroup(DOMElement $node): string + { + $attrs = $this->formatBlockAttributesWithoutClass($node, 'carve-figure-group'); + + $panelsDiv = null; + foreach ($node->childNodes as $child) { + if ( + $child instanceof DOMElement + && strtolower($child->tagName) === 'div' + && $this->hasClass($child, 'carve-figure-panels') + ) { + $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 = trim($content); + + $fence = $this->colonFenceFor($content); + $output = "\n" . $attrs . $fence . " figure\n"; + if ($content !== '') { + $output .= $content . "\n"; + } + $output .= $fence; + + $caption = $this->findFirstDirectChildByTagName($node, 'figcaption'); + if ($caption instanceof DOMElement) { + $captionText = rtrim($this->formatCaptionText(trim($this->processChildren($caption))), "\n"); + if ($captionText !== '') { + $output .= "\n" . $captionText; + } + } + + return $output . "\n\n"; + } + + /** + * One panel of a composite figure: the attribute line, the host content, + * then the panel caption's `^ ` line - the shape the inner caption rules + * re-attach on parse. A table panel's host keeps its own `` + * handling; the wrapper contributed nothing but the structural class. + */ + protected function processFigurePanel(DOMElement $node): string + { + $attrs = $this->formatBlockAttributesWithoutClass($node, 'carve-figure-panel'); + + $body = ''; + $captionText = ''; + foreach ($node->childNodes as $child) { + if ($child instanceof DOMElement && strtolower($child->tagName) === 'figcaption') { + $captionText = $this->formatCaptionText(trim($this->processChildren($child))); + + continue; + } + $body .= $this->processNode($child); + } + + $output = $attrs . trim($body) . "\n"; + if ($captionText !== '') { + $output .= $captionText; + } + + return $output . "\n"; + } + + /** + * The element's block-attribute line with ONE structural class removed. + */ + protected function formatBlockAttributesWithoutClass(DOMElement $node, string $structuralClass): string + { + $classes = array_values(array_diff($this->getElementClassList($node), [$structuralClass])); + $originalClass = $node->getAttribute('class'); + $node->setAttribute('class', implode(' ', $classes)); + try { + return $this->formatBlockAttributes($node); + } finally { + $node->setAttribute('class', $originalClass); + } + } + protected function hasOnlySupportedFigureContent(DOMElement $node): bool { $contentChildren = []; diff --git a/src/Node/Block/FigureGroup.php b/src/Node/Block/FigureGroup.php new file mode 100644 index 00000000..472e6117 --- /dev/null +++ b/src/Node/Block/FigureGroup.php @@ -0,0 +1,77 @@ +caption = $caption; + } + + public function getCaption(): ?Caption + { + return $this->caption; + } + + public function hasCaption(): bool + { + return $this->caption !== null; + } + + /** + * Whether a direct child is one of the group's PANELS (PART 9 §4c): a + * `figure` node the inner §4 rules already formed (captioned image + * paragraph, captioned code listing, captioned display math, promoted + * reference image) or a `table` node, captioned or not. One predicate, + * shared by the HTML renderer and the numbering resolver, so the panel + * wrapper and the panel letters can never disagree on what a panel is. + * + * @param \MarkupCarve\Carve\Node\Node $child + */ + public static function isPanel(Node $child): bool + { + return $child instanceof Figure || $child instanceof Table; + } + + /** + * The panels among the children, in source order. + * + * @return array<\MarkupCarve\Carve\Node\Node> + */ + public function getPanels(): array + { + return array_values(array_filter( + $this->getChildren(), + static fn (Node $child): bool => self::isPanel($child), + )); + } + + public function getType(): string + { + return 'figure_group'; + } +} diff --git a/src/NodeType.php b/src/NodeType.php index 5bb52474..04e641bf 100644 --- a/src/NodeType.php +++ b/src/NodeType.php @@ -135,6 +135,11 @@ final class NodeType */ public const FIGURE = 'figure'; + /** + * @var string + */ + public const FIGURE_GROUP = 'figure_group'; + /** * @var string */ @@ -341,6 +346,7 @@ public static function allBlockTypes(): array self::LINE_BLOCK, self::COMMENT, self::FIGURE, + self::FIGURE_GROUP, self::CAPTION, // Both definition kinds are in the normative Block vocabulary // (carve#771, ruled by carve#826). Without them here, diff --git a/src/Parser/BlockParser.php b/src/Parser/BlockParser.php index b621171b..57f73786 100644 --- a/src/Parser/BlockParser.php +++ b/src/Parser/BlockParser.php @@ -18,6 +18,7 @@ use MarkupCarve\Carve\Node\Block\DefinitionTerm; use MarkupCarve\Carve\Node\Block\Div; use MarkupCarve\Carve\Node\Block\Figure; +use MarkupCarve\Carve\Node\Block\FigureGroup; use MarkupCarve\Carve\Node\Block\Footnote; use MarkupCarve\Carve\Node\Block\Heading; use MarkupCarve\Carve\Node\Block\LineBlock; @@ -3500,6 +3501,22 @@ protected function tryParseDiv(Node $parent, array $lines, int $start): ?int $title = $tm[2] ?? null; } + // PART 9 §4c (markup-carve/carve#1122): a BARE `::: figure` opener - + // the kind word and nothing else - is a composite figure, not a + // container. An opener carrying a quoted title or a `[label]` does NOT + // match the figure production and stays a generic Tier-2 div, title + // and label preserved losslessly; and GROUPS DO NOT NEST - a bare + // figure opener anywhere inside an open group's body, any depth, is a + // generic container too. + if ( + $className === 'figure' + && $title === null + && $label === null + && $this->figureGroupDepth === 0 + ) { + return $this->parseFigureGroup($parent, $lines, $start, $fenceLength); + } + $div = new Div(); // The opener `[label]` is inert structured metadata (NOT rendered); a @@ -3575,6 +3592,77 @@ protected function tryParseDiv(Node $parent, array $lines, int $start): ?int return $i - $start; } + /** + * Whether the parser is currently inside a `::: figure` composite-figure + * body. Groups do not nest (PART 9 §4c): while this is non-zero a bare + * figure opener at ANY depth builds a generic container instead. + */ + protected int $figureGroupDepth = 0; + + /** + * Parse a bare `::: figure` fence into a FigureGroup (PART 9 §4c). + * + * The body parses under the unchanged inner rules - the existing caption + * pass already forms the Figure panels inside - and the GROUP caption is + * not consumed here: the `^ ` line after the closing fence reaches + * tryParseCaption() like any other caption slot, which is what gives it + * the shared one-blank-line allowance for free. + * + * @param \MarkupCarve\Carve\Node\Node $parent + * @param array $lines + * @param int $start + * @param int $fenceLength + */ + protected function parseFigureGroup(Node $parent, array $lines, int $start, int $fenceLength): int + { + $group = new FigureGroup(); + + // Leading block-attribute lines are the group's only attribute source + // (the opener is bare by definition); author source order is recorded + // for the formatter, exactly as for a div. + $authorOrder = []; + foreach (array_keys($this->pendingAttributes) as $name) { + $authorOrder[] = $name === 'id' ? '#id' : ($name === 'class' ? '.class' : (string)$name); + } + foreach ($this->pendingAttributes as $name => $value) { + if ($name === 'class') { + foreach (preg_split('/\s+/', trim((string)$value)) ?: [] as $class) { + if ($class !== '') { + $group->addClass($class); + } + } + } else { + $group->setAttribute($name, $value); + } + } + $group->setAttributeOrder($authorOrder); + $this->pendingAttributes = []; + $this->pendingAttributeOrder = []; + + $body = $this->collectColonFenceBody($lines, $start, $fenceLength, true); + $innerLines = $body['lines']; + $innerLineMap = $body['lineMap']; + $i = $start + $body['consumed']; + + $previousOffset = $this->lineOffset; + $this->lineOffset = $previousOffset + $start + 1; + $this->figureGroupDepth++; + try { + $this->parseBlocks($group, $innerLines, 0, $innerLineMap); + } finally { + $this->figureGroupDepth--; + } + // A dangling attribute line belongs to this container and dies at its + // boundary, exactly as in tryParseDiv() (carve#1028). + $this->pendingAttributes = []; + $this->pendingAttributeOrder = []; + $this->lineOffset = $previousOffset; + + $parent->appendChild($group); + + return $i - $start; + } + /** * Try to parse a local hard-break container (`::: \`). * @@ -9143,6 +9231,35 @@ protected function tryParseCaption(Node $parent, array $lines, int $start): ?int $linesConsumed = $i - $start; + // Handle FigureGroup - §4's SIXTH host (PART 9 §4c): a caption after + // the closing fence of a bare `::: figure` container is the caption of + // the WHOLE group. Only this kind; a `^ ` line after any other `:::` + // closer stays ordinary paragraph content. + if ($lastChild instanceof FigureGroup) { + // A SECOND `^ ` line does not replace an attached group caption - + // the same rule the table arm below spells out (carve-php#1199). + if ($lastChild->hasCaption()) { + return null; + } + + $caption = new Caption(); + $caption->setPos($this->wholeLineSpan($start)); + $this->inlineParser->parse( + $caption, + $captionText, + $start, + true, + $this->contiguousMapFor($start, $lines[$start], $captionText), + ); + $lastChild->setCaption($caption); + // The caption is the group's own child written after the closing + // fence, so the group's span reaches the end of the caption line - + // the same containment the table arm preserves (carve#565). + $this->widenSpanTo($lastChild, $caption->getPos()); + + return $linesConsumed; + } + // Handle Table - add caption directly to table if ($lastChild instanceof Table) { // A SECOND `^ ` line does not replace the caption already attached. diff --git a/src/Renderer/AnsiRenderer.php b/src/Renderer/AnsiRenderer.php index 72b5f554..fe8c0413 100644 --- a/src/Renderer/AnsiRenderer.php +++ b/src/Renderer/AnsiRenderer.php @@ -15,6 +15,7 @@ use MarkupCarve\Carve\Node\Block\DefinitionTerm; use MarkupCarve\Carve\Node\Block\Div; use MarkupCarve\Carve\Node\Block\Figure; +use MarkupCarve\Carve\Node\Block\FigureGroup; use MarkupCarve\Carve\Node\Block\Footnote; use MarkupCarve\Carve\Node\Block\Heading; use MarkupCarve\Carve\Node\Block\LineBlock; @@ -639,6 +640,7 @@ protected function renderNode(Node $node): string $node instanceof CodeBlock => $this->renderCodeBlock($node), $node instanceof Caption => $this->renderCaption($node), $node instanceof Comment => '', // Skip comments + $node instanceof FigureGroup => $this->renderFigureGroup($node), $node instanceof Figure => $this->renderFigure($node), $node instanceof RawBlock => $this->renderRawBlock($node), $node instanceof Section => $this->renderChildren($node), @@ -1363,6 +1365,44 @@ protected function stripControls(string $text): string : $text; } + /** + * A composite figure (grammar PART 11 §10g T2), same order as the plain + * text target: the GROUP caption first with its number resolved, a blank + * line, then each panel - its caption line, then its host's degradation - + * with a blank line between panels. Captions carry this target's usual + * caption styling. + */ + protected function renderFigureGroup(FigureGroup $node): string + { + $output = ''; + $caption = $node->getCaption(); + if ($caption !== null) { + $output .= $this->renderCaption($caption); + } + + foreach ($node->getChildren() as $child) { + if ($child instanceof Figure) { + $panelCaption = null; + $host = ''; + foreach ($child->getChildren() as $part) { + if ($part instanceof Caption) { + $panelCaption = $part; + } else { + $host .= $this->renderNode($part); + } + } + if ($panelCaption !== null) { + $output .= rtrim($this->renderCaption($panelCaption), "\n") . "\n"; + } + $output .= rtrim($host, "\n") . "\n\n"; + } else { + $output .= $this->renderNode($child); + } + } + + return $output; + } + protected function renderFigure(Figure $node): string { $target = null; diff --git a/src/Renderer/CarveRenderer.php b/src/Renderer/CarveRenderer.php index 63da6ed4..33572375 100644 --- a/src/Renderer/CarveRenderer.php +++ b/src/Renderer/CarveRenderer.php @@ -17,6 +17,7 @@ use MarkupCarve\Carve\Node\Block\DefinitionTerm; use MarkupCarve\Carve\Node\Block\Div; use MarkupCarve\Carve\Node\Block\Figure; +use MarkupCarve\Carve\Node\Block\FigureGroup; use MarkupCarve\Carve\Node\Block\Footnote; use MarkupCarve\Carve\Node\Block\Heading; use MarkupCarve\Carve\Node\Block\LineBlock; @@ -612,6 +613,15 @@ private static function hostsACaption(Node $block): bool return true; } + // The closing fence of a bare `::: figure` container is §4's sixth + // caption host (PART 9 §4c), so a paragraph starting with `^` right + // after a composite figure needs its escape - and the group's own + // caption, written by renderFigureGroup() itself, does not pass + // through here at all. + if ($block instanceof FigureGroup) { + return true; + } + if ($block instanceof Image) { return UnresolvedReference::sourceOf($block) === null; } @@ -691,6 +701,7 @@ protected function renderBlock(Node $node): string $node instanceof Div => $withAttrs($this->renderDiv($node)), $node instanceof LineBlock => $withAttrs($this->renderLineBlock($node)), $node instanceof DefinitionList => $withAttrs($this->renderDefinitionList($node)), + $node instanceof FigureGroup => $withAttrs($this->renderFigureGroup($node)), $node instanceof Figure => $withAttrs($this->renderFigure($node)), $node instanceof RawBlock => $withAttrs($this->renderRawBlock($node)), $node instanceof Comment => $this->renderComment($node), @@ -1598,6 +1609,29 @@ protected function renderFigure(Figure $node): string return $caption === '' ? $target : $target . "\n" . $caption; } + /** + * The canonical writer emits the AUTHORED form (grammar PART 11 §10g): the + * bare `::: figure` opener - a figure_group has no title or label to spell + * - the children with one blank line between them, the closer at the + * opener's width, and the group caption as a `^ ` line after the closer. + * The caret is NOT escaped there: the group caption is the caption the + * closer hosts, not text in that position, and `\^ ` would re-parse as a + * paragraph and break `parse(fmt(x)) == parse(x)`. + */ + protected function renderFigureGroup(FigureGroup $node): string + { + $fence = $this->colonFenceFor($node); + $body = $this->renderColonFenceBody($node); + $out = $fence . ' figure' . self::fencedDivBody($body) . $fence; + + $caption = $node->getCaption(); + if ($caption !== null) { + $out .= "\n^ " . $this->renderInlines($caption->getChildren()); + } + + return $out; + } + protected function renderRawBlock(RawBlock $node): string { $content = $node->getContent(); diff --git a/src/Renderer/CrossReferenceResolver.php b/src/Renderer/CrossReferenceResolver.php index 4f130d6a..616f2a43 100644 --- a/src/Renderer/CrossReferenceResolver.php +++ b/src/Renderer/CrossReferenceResolver.php @@ -6,6 +6,7 @@ use MarkupCarve\Carve\Node\Block\Caption; use MarkupCarve\Carve\Node\Block\Figure; +use MarkupCarve\Carve\Node\Block\FigureGroup; use MarkupCarve\Carve\Node\Block\Heading; use MarkupCarve\Carve\Node\Block\Table; use MarkupCarve\Carve\Node\Document; @@ -332,6 +333,23 @@ protected function resolveNumberedCaptionsInNode(Node $node, HeadingIdTracker $t return; } + if ($node instanceof FigureGroup) { + // PART 9 §4c: the group is ONE numbering unit. Its caption draws + // one number and registers the panel ids with letters; the PANELS + // draw nothing from the document sequence - a `#` in a panel + // caption stays literal - so they are skipped below, while stray + // non-panel content inside the group still numbers normally. + $this->resolveFigureGroupCaption($node, $tracker, $counters); + foreach ($node->getChildren() as $child) { + if (FigureGroup::isPanel($child)) { + continue; + } + $this->resolveNumberedCaptionsInNode($child, $tracker, $counters, $depth + 1); + } + + return; + } + if ($node instanceof Figure) { $caption = $this->findFigureCaption($node); if ($caption !== null) { @@ -352,6 +370,68 @@ protected function resolveNumberedCaptionsInNode(Node $node, HeadingIdTracker $t } } + /** + * Number a composite figure's GROUP caption and register its crossref + * texts (PART 9 §4c, markup-carve/carve#1122). + * + * The group's own id resolves as "Label N"; each PANEL id resolves as + * "Label N" plus a letter by panel order among the panels (a..z, then aa, + * ab, ...). Panel ids register only when the group itself drew a number - + * an unnumbered group's panels are anchors but not caption crossref + * targets, exactly as an id on an uncaptioned figure is today. + * + * @param \MarkupCarve\Carve\Node\Block\FigureGroup $group + * @param \MarkupCarve\Carve\Renderer\HeadingIdTracker $tracker + * @param array $counters + */ + protected function resolveFigureGroupCaption(FigureGroup $group, HeadingIdTracker $tracker, array &$counters): void + { + $caption = $group->getCaption(); + if ($caption === null) { + return; + } + + $result = $this->captionTextBeforeNumber($caption); + $numberNode = $result['node']; + if (!$numberNode instanceof CaptionNumber) { + return; + } + + $label = rtrim($result['text']); + $counters[$label] = ($counters[$label] ?? 0) + 1; + $number = $counters[$label]; + $numberNode->setNumber($number); + + $id = $group->getAttribute('id') ?? ''; + if ($id !== '') { + $tracker->setTextForId($id, $label . ' ' . $number); + } + + foreach ($group->getPanels() as $index => $panel) { + $panelId = $panel->getAttribute('id') ?? ''; + if ($panelId !== '') { + $tracker->setTextForId($panelId, $label . ' ' . $number . self::panelLetter($index)); + } + } + } + + /** + * The letter a panel's position resolves to: a..z, then aa, ab, ... + * (spreadsheet-column style, zero-based). + */ + protected static function panelLetter(int $index): string + { + $letter = ''; + $index++; + while ($index > 0) { + $index--; + $letter = chr(97 + ($index % 26)) . $letter; + $index = intdiv($index, 26); + } + + return $letter; + } + protected function findFigureCaption(Figure $figure): ?Caption { foreach ($figure->getChildren() as $child) { diff --git a/src/Renderer/HtmlRenderer.php b/src/Renderer/HtmlRenderer.php index 843d1453..8a27f695 100644 --- a/src/Renderer/HtmlRenderer.php +++ b/src/Renderer/HtmlRenderer.php @@ -17,6 +17,7 @@ use MarkupCarve\Carve\Node\Block\DefinitionTerm; use MarkupCarve\Carve\Node\Block\Div; use MarkupCarve\Carve\Node\Block\Figure; +use MarkupCarve\Carve\Node\Block\FigureGroup; use MarkupCarve\Carve\Node\Block\Footnote; use MarkupCarve\Carve\Node\Block\Heading; use MarkupCarve\Carve\Node\Block\LineBlock; @@ -202,6 +203,7 @@ protected function initNodeRenderers(): void ThematicBreak::class => 'renderThematicBreak', Div::class => 'renderDiv', Figure::class => 'renderFigure', + FigureGroup::class => 'renderFigureGroup', Caption::class => 'renderCaption', Table::class => 'renderTable', TableRow::class => 'renderTableRow', @@ -1607,9 +1609,13 @@ protected function renderLineBlock(LineBlock $node): string return str_replace("\u{00A0}", ' ', $html); } - protected function renderFigure(Figure $node): string + protected function renderFigure(Figure $node, ?string $leadingClass = null): string { - $attrs = $this->renderAttributes($node); + $attrArray = $this->getRenderableAttributes($node); + if ($leadingClass !== null) { + $attrArray = self::withLeadingClass($attrArray, $leadingClass); + } + $attrs = $this->renderAttributeArray($attrArray); $body = ''; foreach ($node->getChildren() as $child) { @@ -1632,6 +1638,76 @@ protected function renderCaption(Caption $node): string return '
' . $this->renderChildren($node) . "
\n"; } + /** + * Class-first, the typed-container convention (PART 9 §4c): the structural + * class leads, authored classes merge after it DEDUPLICATED - the oracle's + * class merge keeps one token per name, so an authored copy of the + * structural class does not double it - and the id and remaining + * attributes follow in source order. + * + * @param array $attrs + * @param string $leadingClass + * + * @return array + */ + protected static function withLeadingClass(array $attrs, string $leadingClass): array + { + $classes = [$leadingClass]; + foreach (preg_split('/\s+/', trim($attrs['class'] ?? '')) ?: [] as $class) { + if ($class !== '' && !in_array($class, $classes, true)) { + $classes[] = $class; + } + } + unset($attrs['class']); + + return ['class' => implode(' ', $classes)] + $attrs; + } + + /** + * 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 + * explicit and the table keeps its own attributes and its own ``. + * No group caption, no trailing `
`. + */ + protected function renderFigureGroup(FigureGroup $node): string + { + $attrs = $this->renderAttributeArray( + self::withLeadingClass($this->getRenderableAttributes($node), 'carve-figure-group'), + ); + + $inner = ''; + foreach ($node->getChildren() as $child) { + if ($child instanceof Figure) { + $inner .= $this->renderFigure($child, 'carve-figure-panel'); + } elseif ($child instanceof Table) { + $table = rtrim($this->renderTable($child), "\n"); + $inner .= "
\n" + . $this->indentBlock($table, 2) . "\n
\n"; + } else { + $inner .= 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"; + } + protected function renderTable(Table $node): string { $attrs = $this->renderAttributes($node); @@ -2160,9 +2236,13 @@ protected function renderHeadingRef(HeadingRef $node): string protected function renderCaptionNumber(CaptionNumber $node): string { + // An unresolved placeholder stays LITERAL - the visible failure the + // language prefers to a silent one (PART 9 §4c: a `#` in a composite + // figure's PANEL caption has no sequence to draw from). The Markdown, + // plain-text and ANSI targets already render it this way. $number = $node->getNumber(); - return $number === null ? '' : (string)$number; + return $number === null ? '#' : (string)$number; } protected function renderMention(Mention $node): string diff --git a/src/Renderer/MarkdownRenderer.php b/src/Renderer/MarkdownRenderer.php index 9e5b370e..d6c6c2f1 100644 --- a/src/Renderer/MarkdownRenderer.php +++ b/src/Renderer/MarkdownRenderer.php @@ -16,6 +16,7 @@ use MarkupCarve\Carve\Node\Block\DefinitionTerm; use MarkupCarve\Carve\Node\Block\Div; use MarkupCarve\Carve\Node\Block\Figure; +use MarkupCarve\Carve\Node\Block\FigureGroup; use MarkupCarve\Carve\Node\Block\Footnote; use MarkupCarve\Carve\Node\Block\Heading; use MarkupCarve\Carve\Node\Block\LineBlock; @@ -594,6 +595,7 @@ protected function renderNode(Node $node): string // ordered list. EscapedText only ever holds escaped ASCII // punctuation, all of which CommonMark allows a `\` before. $node instanceof EscapedText => $this->renderEscapedText($node), + $node instanceof FigureGroup => $this->renderFigureGroup($node), $node instanceof Figure => $this->renderFigure($node), $node instanceof Caption => $this->renderCaption($node), $node instanceof Abbreviation => $this->renderAbbreviation($node), @@ -1472,6 +1474,47 @@ protected function renderCaption(Caption $node): string return trim($this->renderChildren($node), StringUtil::TRIMMABLE_WHITESPACE) . "\n\n"; } + /** + * A composite figure (grammar PART 11 §10g T1). Markdown has no figure + * grouping, so this is the spelling the admonition title rule already + * uses for authored text with no native slot: panels in order, each host + * degraded as usual, each PANEL caption as an emphasized `*...*` paragraph + * after its host; preserved stray content in place; the GROUP caption + * last, as a bold `**...**` paragraph, its number resolved. + */ + protected function renderFigureGroup(FigureGroup $node): string + { + $output = ''; + foreach ($node->getChildren() as $child) { + if ($child instanceof Figure) { + $panelCaption = null; + $host = ''; + foreach ($child->getChildren() as $part) { + if ($part instanceof Caption) { + $panelCaption = $part; + } else { + $host .= $this->renderNode($part); + } + } + $output .= rtrim($host, StringUtil::TRIMMABLE_WHITESPACE) . "\n\n"; + if ($panelCaption !== null) { + $output .= '*' . trim($this->renderChildren($panelCaption), StringUtil::TRIMMABLE_WHITESPACE) . "*\n\n"; + } + } else { + // A table panel keeps its caption inside its own degradation, + // and stray non-panel content is preserved in place. + $output .= $this->renderNode($child); + } + } + + $caption = $node->getCaption(); + if ($caption !== null) { + $output .= '**' . trim($this->renderChildren($caption), StringUtil::TRIMMABLE_WHITESPACE) . "**\n\n"; + } + + return $output; + } + /** * Markdown has no abbreviation syntax; emit inline so the title is * preserved (mirrors how subscript/superscript fall back to inline HTML). diff --git a/src/Renderer/PlainTextRenderer.php b/src/Renderer/PlainTextRenderer.php index a2000c2f..6725864e 100644 --- a/src/Renderer/PlainTextRenderer.php +++ b/src/Renderer/PlainTextRenderer.php @@ -16,6 +16,7 @@ use MarkupCarve\Carve\Node\Block\DefinitionTerm; use MarkupCarve\Carve\Node\Block\Div; use MarkupCarve\Carve\Node\Block\Figure; +use MarkupCarve\Carve\Node\Block\FigureGroup; use MarkupCarve\Carve\Node\Block\Footnote; use MarkupCarve\Carve\Node\Block\Heading; use MarkupCarve\Carve\Node\Block\LineBlock; @@ -385,6 +386,7 @@ protected function renderNode(Node $node): string $node instanceof Paragraph => $this->renderParagraph($node), $node instanceof Heading => $this->renderHeading($node), $node instanceof CodeBlock => $this->renderCodeBlock($node), + $node instanceof FigureGroup => $this->renderFigureGroup($node), $node instanceof Figure => $this->renderFigure($node), $node instanceof Comment => '', // Skip comments $node instanceof RawBlock => '', // Skip raw blocks (format-specific) @@ -630,6 +632,46 @@ protected function renderCodeBlock(CodeBlock $node): string return $prefix . $this->stripControls($node->getContent()) . "\n\n"; } + /** + * A composite figure (grammar PART 11 §10g T2): the GROUP caption comes + * FIRST, its number resolved, then a blank line, then each panel - its + * caption line, then its host's degradation - with a blank line between + * panels. Caption-first because on a caption-less target the group caption + * is the only line that says what the following blocks are one of. Stray + * non-panel content is preserved in place; a table panel keeps its caption + * inside its own degradation. + */ + protected function renderFigureGroup(FigureGroup $node): string + { + $output = ''; + $caption = $node->getCaption(); + if ($caption !== null) { + $output .= trim($this->renderChildren($caption), StringUtil::TRIMMABLE_WHITESPACE) . "\n\n"; + } + + foreach ($node->getChildren() as $child) { + if ($child instanceof Figure) { + $panelCaption = null; + $host = ''; + foreach ($child->getChildren() as $part) { + if ($part instanceof Caption) { + $panelCaption = $part; + } else { + $host .= $this->renderNode($part); + } + } + if ($panelCaption !== null) { + $output .= trim($this->renderChildren($panelCaption), StringUtil::TRIMMABLE_WHITESPACE) . "\n"; + } + $output .= rtrim($host, "\n") . "\n\n"; + } else { + $output .= $this->renderNode($child); + } + } + + return $output; + } + protected function renderFigure(Figure $node): string { $target = null; diff --git a/tests/CarveCorpusTest.php b/tests/CarveCorpusTest.php index 8a8771bf..76c08923 100644 --- a/tests/CarveCorpusTest.php +++ b/tests/CarveCorpusTest.php @@ -381,6 +381,7 @@ class CarveCorpusTest extends TestCase 'a-footnote-in-an-unresolved-reference-is-not-a-reference', 'an-image-s-alt-text-closes-where-a-link-s-text-closes', 'an-editorial-comment-s-bracket-is-content-not-the-close', + 'composite-figures', ]; /** diff --git a/tests/TestCase/Ast/ACompositeFigureRidesTheWireTest.php b/tests/TestCase/Ast/ACompositeFigureRidesTheWireTest.php new file mode 100644 index 00000000..d222e8b2 --- /dev/null +++ b/tests/TestCase/Ast/ACompositeFigureRidesTheWireTest.php @@ -0,0 +1,97 @@ +codec = new AstCodec(); + $this->converter = new CarveConverter(); + } + + /** + * @return array + */ + protected function encodeFirst(string $source): array + { + $encoded = $this->codec->encode($this->converter->parse($source)); + + return $encoded['children'][0]; + } + + public function testTheWireShapeIsTypeChildrenCaptionAttrs(): void + { + $group = $this->encodeFirst("{#fig-x}\n::: figure\n![one](a.png)\n^ (a) One\n:::\n^ Figure #: G\n"); + + $this->assertSame('figure_group', $group['type']); + $this->assertSame('fig-x', $group['attrs']['id']); + $this->assertIsArray($group['children']); + $this->assertSame('figure', $group['children'][0]['type']); + // The GROUP caption is inline content, not a node wrapping it - the + // same flattening a table's caption already gets. + $this->assertIsArray($group['caption']); + $this->assertArrayNotHasKey('type', $group['caption']); + $this->assertArrayNotHasKey('target', $group); + } + + public function testAnUncaptionedGroupPublishesNoCaptionKey(): void + { + // Absent means uncaptioned - no empty-array placeholder (ast-schema). + $group = $this->encodeFirst("::: figure\n![one](a.png)\n^ (a) One\n:::\n"); + + $this->assertArrayNotHasKey('caption', $group); + $this->assertArrayHasKey('children', $group); + } + + public function testAnEmptyGroupStillPublishesChildren(): void + { + // The schema requires `children` on every figure_group. + $group = $this->encodeFirst("::: figure\n:::\n"); + + $this->assertSame([], $group['children']); + } + + public function testTheGroupSurvivesAJsonRoundTrip(): void + { + $source = "{#fig-x .columns-2}\n::: figure\n{#a}\n![one](a.png)\n^ (a) One\n\n| k |\n|---|\n:::\n^ Figure #: G\n"; + $document = $this->converter->parse($source); + $decoded = $this->codec->decodeJson($this->codec->encodeJson($document)); + + $this->assertSame( + $this->converter->render($document), + $this->converter->render($decoded), + 'HTML must survive decode(encode(parse(x)))', + ); + $this->assertSame( + CarveConverter::toCarve($source), + (new CarveRenderer())->render($decoded), + 'the authored form must survive the wire', + ); + } + + public function testTheDecodedTreeEncodesIdentically(): void + { + $source = "::: figure\n![one](a.png)\n^ (a) One\n:::\n^ Figure #: G\n"; + $encoded = $this->codec->encode($this->converter->parse($source)); + + $this->assertSame($encoded, $this->codec->encode($this->codec->decode($encoded))); + } +} diff --git a/tests/TestCase/Converter/ACompositeFigureImportsFromItsOwnHtmlTest.php b/tests/TestCase/Converter/ACompositeFigureImportsFromItsOwnHtmlTest.php new file mode 100644 index 00000000..e5d4b1ad --- /dev/null +++ b/tests/TestCase/Converter/ACompositeFigureImportsFromItsOwnHtmlTest.php @@ -0,0 +1,57 @@ +` - the shape this engine's own HTML + * renderer produces for PART 9 §4c - imports back to `::: figure` source. + * Own-output round trip only: the structural classes are render-time + * vocabulary and are dropped, everything else returns on attribute lines. + */ +class ACompositeFigureImportsFromItsOwnHtmlTest extends TestCase +{ + protected function roundTrips(string $source): void + { + $converter = new CarveConverter(); + $html = $converter->convert($source); + $imported = (new HtmlToCarve())->convert($html); + + $this->assertSame( + $html, + (new CarveConverter())->convert($imported), + "importing the rendered HTML must reproduce it; imported source was:\n" . $imported, + ); + } + + public function testATwoPanelGroupRoundTrips(): void + { + $this->roundTrips( + "{#fig-x .columns-2}\n::: figure\n{#fig-x-a}\n![one](a.png)\n^ (a) One\n\n{#fig-x-b}\n![two](b.png)\n^ (b) Two\n:::\n^ Figure #: Group caption\n", + ); + } + + public function testAnUncaptionedGroupRoundTrips(): void + { + $this->roundTrips("::: figure\n![one](a.png)\n^ (a) One\n:::\n"); + } + + public function testStrayContentInsideThePanelsDivRoundTrips(): void + { + $this->roundTrips("::: figure\nShot the same day.\n\n![one](a.png)\n^ (a) One\n:::\n^ Figure #: G\n"); + } + + public function testTheImportedSourceUsesTheFigureFence(): void + { + $html = (new CarveConverter())->convert("::: figure\n![one](a.png)\n^ (a) One\n:::\n^ Figure #: G\n"); + $imported = (new HtmlToCarve())->convert($html); + + $this->assertStringContainsString('::: figure', $imported); + $this->assertStringNotContainsString('carve-figure', $imported, 'structural classes must not leak into the source'); + } +} diff --git a/tests/TestCase/Filter/ProfileVocabularyConformanceTest.php b/tests/TestCase/Filter/ProfileVocabularyConformanceTest.php index 13acbf0e..d608d358 100644 --- a/tests/TestCase/Filter/ProfileVocabularyConformanceTest.php +++ b/tests/TestCase/Filter/ProfileVocabularyConformanceTest.php @@ -84,6 +84,7 @@ public static function vocabularyProvider(): array 'line_block' => ["::: |\na\nb\n:::\n", $block], 'comment' => ["%% a comment\n", $block], 'figure' => ["![alt](/i.png)\n^ caption\n", $block], + 'figure_group' => ["::: figure\n![alt](/i.png)\n^ caption\n:::\n^ Figure #: group\n", $block], 'caption' => ["![alt](/i.png)\n^ caption\n", $block], 'text' => ['plain words', $inline], diff --git a/tests/TestCase/Parser/ABareFigureFenceIsACompositeFigureTest.php b/tests/TestCase/Parser/ABareFigureFenceIsACompositeFigureTest.php new file mode 100644 index 00000000..efe6b372 --- /dev/null +++ b/tests/TestCase/Parser/ABareFigureFenceIsACompositeFigureTest.php @@ -0,0 +1,185 @@ +parse($source)->getChildren(); + $this->assertNotSame([], $children); + + return $children[0]; + } + + public function testABareOpenerProducesAFigureGroup(): void + { + $node = $this->parseFirst("::: figure\n![one](a.png)\n^ (a) One\n:::\n"); + + $this->assertInstanceOf(FigureGroup::class, $node); + $this->assertSame('figure_group', $node->getType()); + } + + public function testAZeroPanelGroupIsAValidParse(): void + { + // Degenerate counts are lint findings, not parse errors (§4c). + $node = $this->parseFirst("::: figure\nJust prose.\n:::\n"); + + $this->assertInstanceOf(FigureGroup::class, $node); + $this->assertSame([], $node->getPanels()); + $this->assertCount(1, $node->getChildren(), 'the stray paragraph is preserved in place'); + } + + public function testAnEmptyGroupIsAValidParseToo(): void + { + $node = $this->parseFirst("::: figure\n:::\n"); + + $this->assertInstanceOf(FigureGroup::class, $node); + $this->assertSame([], $node->getChildren()); + } + + public function testAQuotedTitleKeepsTheOpenerAGenericContainer(): void + { + // `figure_group` has no title slot BY DESIGN (§4c): the group's one + // authored metadata channel is the caption, and a second spelling for + // "the text above the figure" would claim carve#1121's design space. + $node = $this->parseFirst("::: figure \"T\"\nBody.\n:::\n"); + + $this->assertInstanceOf(Div::class, $node); + $this->assertSame('T', $node->getHeader()); + } + + public function testALabelKeepsTheOpenerAGenericContainer(): void + { + $node = $this->parseFirst("::: figure [g]\nBody.\n:::\n"); + + $this->assertInstanceOf(Div::class, $node); + $this->assertSame('g', $node->getLabel()); + } + + public function testGroupsDoNotNest(): void + { + // The inner bare figure opener - ANY depth inside an open group's + // body - is a generic Tier-2 container, not an inner group. + $node = $this->parseFirst("::: figure\n:::: figure\n![one](a.png)\n^ (a) One\n::::\n:::\n"); + + $this->assertInstanceOf(FigureGroup::class, $node); + $inner = $node->getChildren()[0]; + $this->assertInstanceOf(Div::class, $inner); + } + + public function testABareFigureBelowAnotherContainerInsideTheGroupStaysGenericToo(): void + { + // "Anywhere inside an open group's body -- any depth" (§4c): the + // nesting rule is not limited to direct children. + $node = $this->parseFirst("::: figure\n:::: note\n::::: figure\nDeep.\n:::::\n::::\n:::\n"); + + $this->assertInstanceOf(FigureGroup::class, $node); + $note = $node->getChildren()[0]; + $this->assertInstanceOf(Div::class, $note); + $deep = $note->getChildren()[0]; + $this->assertInstanceOf(Div::class, $deep, 'a bare figure at depth two inside a group must not open a group'); + } + + public function testABareFigureInsideAnOrdinaryContainerIsStillAGroup(): void + { + // The no-nesting rule is about GROUPS: a note admonition is not one, + // so a bare figure inside it opens a composite figure normally. + $node = $this->parseFirst("::: note\n:::: figure\n![one](a.png)\n^ (a) One\n::::\n:::\n"); + + $this->assertInstanceOf(Div::class, $node); + $this->assertInstanceOf(FigureGroup::class, $node->getChildren()[0]); + } + + public function testTheCaptionAfterTheCloserIsTheGroupCaption(): void + { + $node = $this->parseFirst("::: figure\n![one](a.png)\n^ (a) One\n:::\n^ Figure #: Group\n"); + + $this->assertInstanceOf(FigureGroup::class, $node); + $this->assertTrue($node->hasCaption()); + $this->assertCount(1, $node->getPanels(), 'the inner caption stays on its local panel host'); + } + + public function testOneBlankLineStillAttachesTheGroupCaption(): void + { + $node = $this->parseFirst("::: figure\n![one](a.png)\n^ (a) One\n:::\n\n^ Figure #: Group\n"); + + $this->assertInstanceOf(FigureGroup::class, $node); + $this->assertTrue($node->hasCaption()); + } + + public function testTwoBlankLinesDetachTheGroupCaption(): void + { + // The shared caption_slot allowance (PART 2), corpus + // 318-composite-figures-6: the detached line is an ordinary paragraph. + $document = (new CarveConverter())->parse("::: figure\n![one](a.png)\n^ (a) One\n:::\n\n\n^ Figure #: Detached\n"); + + $group = $document->getChildren()[0]; + $this->assertInstanceOf(FigureGroup::class, $group); + $this->assertFalse($group->hasCaption()); + $this->assertCount(2, $document->getChildren()); + } + + public function testASecondCaptionLineDoesNotReplaceTheGroupCaption(): void + { + // The rule the table already has (carve-php#1199): the second `^ ` + // line has no captionable block left and is ordinary paragraph text. + $html = (new CarveConverter())->convert("::: figure\n![one](a.png)\n:::\n^ First\n^ Second\n"); + + $this->assertStringContainsString('
First
', $html); + $this->assertStringContainsString('

^ Second

', $html); + } + + public function testACaretAfterAnyOtherContainerCloserStaysAParagraph(): void + { + // Only kind `figure` grew the caption slot (§4c); corpus + // 318-composite-figures-7 pins the note case. + $html = (new CarveConverter())->convert("::: note\nBody.\n:::\n^ Not a caption\n"); + + $this->assertStringContainsString('

^ Not a caption

', $html); + $this->assertStringNotContainsString('figcaption', $html); + } + + public function testPanelsAreTheCaptionableDirectChildrenInSourceOrder(): void + { + // A table is a panel captioned or not; an image paragraph needs its + // caption to become a figure; loose prose is content, not a panel. + $node = $this->parseFirst( + "::: figure\nProse between.\n\n| a |\n|---|\n\n![one](a.png)\n^ (a) One\n:::\n", + ); + + $this->assertInstanceOf(FigureGroup::class, $node); + $this->assertCount(3, $node->getChildren()); + $panels = $node->getPanels(); + $this->assertCount(2, $panels); + $this->assertSame('table', $panels[0]->getType()); + $this->assertSame('figure', $panels[1]->getType()); + } + + public function testAPrecedingAttributeLineLandsOnTheGroup(): void + { + $node = $this->parseFirst("{#fig-x .columns-2}\n::: figure\n![one](a.png)\n^ (a) One\n:::\n"); + + $this->assertInstanceOf(FigureGroup::class, $node); + $this->assertSame('fig-x', $node->getAttribute('id')); + $this->assertSame('columns-2', $node->getAttribute('class')); + } +} diff --git a/tests/TestCase/Renderer/ACompositeFigureNumbersAsOneUnitTest.php b/tests/TestCase/Renderer/ACompositeFigureNumbersAsOneUnitTest.php new file mode 100644 index 00000000..e447db1f --- /dev/null +++ b/tests/TestCase/Renderer/ACompositeFigureNumbersAsOneUnitTest.php @@ -0,0 +1,98 @@ +` with the group's number plus a letter by panel order. + */ +class ACompositeFigureNumbersAsOneUnitTest extends TestCase +{ + protected function convert(string $source): string + { + return (new CarveConverter())->convert($source); + } + + public function testTheGroupDrawsOneNumberFromTheSharedSequence(): void + { + // Corpus 318-composite-figures-2 pins the full bytes; this pins the + // COUNT: a two-panel group between two plain figures advances the + // Figure sequence by one, not by three. + $html = $this->convert( + "![a](a.png)\n^ Figure #: First\n\n" + . "::: figure\n![b](b.png)\n^ (a) B\n\n![c](c.png)\n^ (b) C\n:::\n^ Figure #: Group\n\n" + . "![d](d.png)\n^ Figure #: Last\n", + ); + + $this->assertStringContainsString('
Figure 1: First
', $html); + $this->assertStringContainsString('
Figure 2: Group
', $html); + $this->assertStringContainsString('
Figure 3: Last
', $html); + } + + public function testAPanelIdResolvesWithTheGroupNumberAndALetter(): void + { + $html = $this->convert( + "{#g}\n::: figure\n{#p-a}\n![a](a.png)\n^ (a) A\n\n{#p-b}\n![b](b.png)\n^ (b) B\n:::\n^ Figure #: Group\n\n" + . "See , and .\n", + ); + + $this->assertStringContainsString('Figure 1', $html); + $this->assertStringContainsString('Figure 1a', $html); + $this->assertStringContainsString('Figure 1b', $html); + } + + public function testTheLetterCountsPanelsNotChildren(): void + { + // Stray content between the panels occupies a child slot but no panel + // slot, so the second panel is still "b". + $html = $this->convert( + "::: figure\n{#p-a}\n![a](a.png)\n^ (a) A\n\nA note between.\n\n{#p-b}\n![b](b.png)\n^ (b) B\n:::\n^ Figure #: G\n\n" + . "See .\n", + ); + + $this->assertStringContainsString('Figure 1b', $html); + } + + public function testANumberPlaceholderInAPanelCaptionStaysLiteral(): void + { + // Panels are not sequence units; the visible failure this language + // prefers to a silent one (§4c). + $html = $this->convert("::: figure\n![a](a.png)\n^ Panel # here\n:::\n^ Figure #: G\n"); + + $this->assertStringContainsString('
Panel # here
', $html); + $this->assertStringContainsString('
Figure 1: G
', $html); + } + + public function testAnUnnumberedGroupRegistersNoPanelCrossrefText(): void + { + // Panel ids register only when the group itself drew a number, so the + // crossref stays unresolved - exactly what `` to an id on an + // uncaptioned plain figure produces today (§4c). + $html = $this->convert( + "::: figure\n{#p-a}\n![a](a.png)\n^ (a) A\n:::\n\nSee .\n", + ); + + $this->assertStringContainsString('

See </#p-a>.

', $html); + $this->assertStringNotContainsString('Figure', $html); + } + + public function testTheGroupCountsInItsOwnLabelSequenceOnly(): void + { + // Label sequences stay per-label: a table before the group does not + // advance the Figure sequence the group draws from. + $html = $this->convert( + "| a |\n|---|\n^ Table #: T\n\n::: figure\n![b](b.png)\n^ (a) B\n:::\n^ Figure #: G\n", + ); + + $this->assertStringContainsString('Table 1: T', $html); + $this->assertStringContainsString('
Figure 1: G
', $html); + } +} diff --git a/tests/TestCase/Renderer/ACompositeFigureSurvivesEveryTargetTest.php b/tests/TestCase/Renderer/ACompositeFigureSurvivesEveryTargetTest.php new file mode 100644 index 00000000..90a653a5 --- /dev/null +++ b/tests/TestCase/Renderer/ACompositeFigureSurvivesEveryTargetTest.php @@ -0,0 +1,63 @@ +assertSame($expected, CarveConverter::markdown()->convert(self::SOURCE)); + } + + public function testPlainTextPutsTheGroupCaptionFirst(): void + { + // T2: caption-first, because on a caption-less target the group + // caption is the only line that says what the following blocks are + // one of; then per panel its caption line, then its host. + $expected = "Figure 1: Group caption\n\n(a) One\none\n\n(b) Two\ntwo\n"; + + $this->assertSame($expected, CarveConverter::plainText()->convert(self::SOURCE)); + } + + public function testTheTerminalFollowsThePlainTextOrderWithItsCaptionStyling(): void + { + $ansi = CarveConverter::ansi()->convert(self::SOURCE); + $stripped = (string)preg_replace('/\x1b\[[0-9;]*m/', '', $ansi); + + $groupAt = strpos($stripped, 'Figure 1: Group caption'); + $panelAt = strpos($stripped, '(a) One'); + $this->assertNotFalse($groupAt); + $this->assertNotFalse($panelAt); + $this->assertLessThan($panelAt, $groupAt, 'the group caption line comes first on the terminal target'); + } + + public function testStrayContentIsPreservedInPlaceOnEveryTarget(): void + { + // §10g floor: preserved stray content is CONTENT and no target may + // silently discard it. + $source = "::: figure\nShot the same day.\n\n![one](a.png)\n^ (a) One\n:::\n^ Figure #: G\n"; + + $this->assertStringContainsString('Shot the same day.', CarveConverter::markdown()->convert($source)); + $this->assertStringContainsString('Shot the same day.', CarveConverter::plainText()->convert($source)); + } +} diff --git a/tests/TestCase/Renderer/TheWriterSpellsACompositeFigureAsAuthoredTest.php b/tests/TestCase/Renderer/TheWriterSpellsACompositeFigureAsAuthoredTest.php new file mode 100644 index 00000000..3cca2a2c --- /dev/null +++ b/tests/TestCase/Renderer/TheWriterSpellsACompositeFigureAsAuthoredTest.php @@ -0,0 +1,67 @@ +assertSame($source, CarveConverter::toCarve($source)); + } + + public function testTheGroupCaptionCaretIsNotEscaped(): void + { + $fmt = CarveConverter::toCarve("::: figure\n![one](a.png)\n^ (a) One\n:::\n^ Figure #: G\n"); + + $this->assertStringContainsString("\n^ Figure #: G", $fmt); + $this->assertStringNotContainsString('\\^ Figure', $fmt); + } + + public function testADetachedCaretParagraphKeepsItsEscape(): void + { + // Corpus 318-composite-figures-6: fmt normalizes the blank-line run, + // so without the escape the paragraph would re-parse as the group + // caption and `parse(fmt(x)) == parse(x)` would break. + $source = "::: figure\n![one](a.png)\n^ (a) One\n:::\n\n\n^ Figure #: Detached\n"; + $fmt = CarveConverter::toCarve($source); + + $this->assertStringContainsString('\\^ Figure #: Detached', $fmt); + $converter = new CarveConverter(); + $this->assertSame($converter->convert($source), $converter->convert($fmt)); + } + + public function testTheInnerFenceWidensInward(): void + { + // A nested generic container inside the group takes the wider fence, + // the discipline PART 9 §12 already sets for containers. + $source = "::: figure\n:::: figure\n![one](a.png)\n^ (a) One\n::::\n:::\n^ Figure #: Outer only\n"; + + $this->assertSame($source, CarveConverter::toCarve($source)); + } + + public function testFmtIsIdempotentOnACompositeFigure(): void + { + $source = "{#g}\n::: figure\nProse.\n\n| a |\n|---|\n\n![one](a.png)\n^ (a) One\n:::\n^ Figure #: Mixed\n"; + $once = CarveConverter::toCarve($source); + + $this->assertSame($once, CarveConverter::toCarve($once)); + } +} diff --git a/tests/fixtures/ast-schema.json b/tests/fixtures/ast-schema.json index 3041c757..265d6696 100644 --- a/tests/fixtures/ast-schema.json +++ b/tests/fixtures/ast-schema.json @@ -130,6 +130,12 @@ ], "required": [] }, + "figure_group": { + "fields": [ + "caption" + ], + "required": [] + }, "footnote": { "fields": [ "label" diff --git a/tests/spec b/tests/spec index 0c9e8e8c..9cd27d86 160000 --- a/tests/spec +++ b/tests/spec @@ -1 +1 @@ -Subproject commit 0c9e8e8c34453b2d3cc050904f01875368183708 +Subproject commit 9cd27d86da9c3e4e51d1f483e03b15667c78c537