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