Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `</#id>` 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
Expand Down
50 changes: 50 additions & 0 deletions resources/ast-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,7 @@
"definition_term",
"div",
"figure",
"figure_group",
"footnote",
"frontmatter",
"heading",
Expand Down Expand Up @@ -397,6 +398,21 @@
"$ref": "#/$defs/figure"
}
},
{
"if": {
"properties": {
"type": {
"const": "figure_group"
}
},
"required": [
"type"
]
},
"then": {
"$ref": "#/$defs/figure_group"
}
},
{
"if": {
"properties": {
Expand Down Expand Up @@ -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)",
Expand Down
1 change: 1 addition & 0 deletions resources/prosemirror-schema-map.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
11 changes: 9 additions & 2 deletions src/Ast/AstCodec.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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];
}

Expand Down
108 changes: 108 additions & 0 deletions src/Converter/HtmlToCarve.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -3541,6 +3547,108 @@ protected function processFigure(DOMElement $node): string
return $output . "\n\n";
}

/**
* `<figure class="carve-figure-group">` 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 `<figcaption>` 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 `<caption>`
* 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 = [];
Expand Down
77 changes: 77 additions & 0 deletions src/Node/Block/FigureGroup.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
<?php

declare(strict_types=1);

namespace MarkupCarve\Carve\Node\Block;

use MarkupCarve\Carve\Node\Node;

/**
* Composite figure (grammar PART 9 §4c, markup-carve/carve#1122): the block a
* bare `::: figure` fence produces. One figure-numbering unit whose direct
* captionable children - `figure` and `table` nodes among the ordinary
* children, in source order - are its PANELS; everything else is plain group
* content, preserved in place.
*
* Discriminated by TYPE, not by shape: every `figure` node carries a target,
* this node deliberately does not, and it has no title, label or shortCaption
* slot either - the group's one authored metadata channel is the caption on
* its closing fence (the rest is markup-carve/carve#1118 / carve#1121 design
* space, not claimed here).
*
* The group caption is modeled the way a table's is: a Caption block kept
* beside the children rather than among them, so renderers walking children
* see the body only and the wire flattens it to inline content.
*/
class FigureGroup extends BlockNode
{
protected ?Caption $caption = null;

public function setCaption(Caption $caption): void
{
$this->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';
}
}
6 changes: 6 additions & 0 deletions src/NodeType.php
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,11 @@ final class NodeType
*/
public const FIGURE = 'figure';

/**
* @var string
*/
public const FIGURE_GROUP = 'figure_group';

/**
* @var string
*/
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading