diff --git a/CHANGELOG.md b/CHANGELOG.md
index 786e9ce8..60ddf4bd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -26,6 +26,36 @@ This project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
On the shared corpus, 791 documents report nothing lost and round-trip to
byte-identical HTML, and 215 report what they lost.
+- **Composite figures: a bare `::: figure` container is one figure of ordered
+ panels** (PART 9 §4c, markup-carve/carve#1122). Its direct captionable
+ children - the `figure` nodes the unchanged inner caption rules build, and
+ `table` nodes - are the PANELS, in source order; stray content between them
+ is preserved in place. The `^ ` caption after the CLOSING fence is the
+ caption of the whole group - caption placement's sixth host, this container
+ kind only - with the usual one-optional-blank-line allowance; two blank
+ lines detach it. The group is ONE numbering unit: `^ Figure #:` draws one
+ number, panels draw nothing, and a panel with an id resolves `#id>` as the
+ group number plus a letter by panel order ("Figure 2a"). A `#` placeholder
+ in a PANEL caption stays the literal `#` on every target. HTML renders the
+ class-first `carve-figure-group` / `carve-figure-panel` shape with an
+ unconditional `carve-figure-panels` div; Markdown, plain text and the
+ terminal degrade deterministically per PART 11 §10g; the canonical writer
+ emits the authored form back; AST JSON gains the additive `figure_group`
+ node (PART 12 §16), and the HTML importer reads the engine's own group
+ shape back. The ProseMirror bridge has no editor node for it - the
+ vendored carve-grammars schema map predates §4c - so a group degrades to the
+ generic container there, keeping every panel and the group caption and
+ reporting the grouping it could not hold. An opener carrying a quoted title or a `[label]`, and a bare
+ opener nested inside an open group, stay generic containers - `carve lint`
+ reports them as `figure-group-opener-metadata` / `figure-group-nested`, and
+ a panel-caption placeholder as `figure-group-panel-number`.
+
+- **An unresolved caption-number placeholder renders as the literal `#` in
+ HTML**, matching what the Markdown, plain-text and terminal targets already
+ emitted. Reachable from a parse only inside a figure group's panel captions;
+ on the ingest path a `caption_number` without a number now shows the visible
+ `#` instead of disappearing.
+
- **`lint_carve` / `lint_carve_with_options`, and the `LintWarning` they
return** (markup-carve/carve#1131, markup-carve/carve#1132). carve-rs' first
lint surface, carrying the two diagnostics PART 9 §10 implies and had nowhere
diff --git a/README.md b/README.md
index e448e3d6..d7f1aaa3 100644
--- a/README.md
+++ b/README.md
@@ -66,6 +66,7 @@ new corpus pairs fail CI until the parser and renderer support them.
| 16 | inline extensions (`:type[…]`) | passing |
| 17 | attribute blocks (`{#id .class}`) | passing |
| 18 | YAML frontmatter | passing |
+| 318-composite-figures | `::: figure` groups: panels, group caption on the closer, `Figure 2a` crossrefs | passing |
## Library use
@@ -100,14 +101,21 @@ let warnings = carve::lint_carve("`c`{kbd}\n");
assert_eq!(warnings[0].rule, "semantic-attribute-outside-span");
```
-Two rules today, both about the compact semantic span attributes (spec PART 9
-§10):
+The compact semantic span attribute rules (spec PART 9 §10):
| rule | fires on |
| --- | --- |
| `semantic-attribute-value-ignored` | a value on a reserved name that only selects a wrapper: `[x]{kbd="V"}` renders `x` and `V` reaches no output |
| `semantic-attribute-outside-span` | a reserved name anywhere other than an ordinary `[content]{attrs}` span, where it stays a raw attribute: `` `c`{kbd} `` renders `c` |
+The composite-figure rules (spec PART 9 §4c):
+
+| rule | fires on |
+| --- | --- |
+| `figure-group-opener-metadata` | a `::: figure` opener carrying a quoted title or a `[label]`, which stays a generic container - the group has no title or label slot |
+| `figure-group-nested` | a bare `::: figure` opener inside an open group's body, which stays a generic container - groups do not nest |
+| `figure-group-panel-number` | a `#` placeholder in a PANEL caption, which stays literal - panels are not sequence units |
+
Both are tier-aware. `abbr`, `time` and `kbd` are reserved in core; `samp`,
`var`, `cite` and `dfn` only become elements once the `SemanticSpan` extension
is registered, and until then they are ordinary attributes whose value reaches
diff --git a/resources/prosemirror-schema-map.json b/resources/prosemirror-schema-map.json
index d2ddd99e..3febfd68 100644
--- a/resources/prosemirror-schema-map.json
+++ b/resources/prosemirror-schema-map.json
@@ -271,11 +271,13 @@
"raw_text": "raw text is the payload of a raw block, not a node an editor holds",
"escaped_text": "escaping is a source-level concern; the editor holds the escaped character as text",
"smart_punctuation": "smart-typography output is lossy on reparse, so it is not modeled",
- "soft_break": "a soft break is whitespace in the ProseMirror model"
+ "soft_break": "a soft break is whitespace in the ProseMirror model",
+ "figure_group": "the editor schema has no composite figure; the panels and the group caption survive inside the generic container, but the fact that they were one figure does not"
},
"_provenance": {
"source": "markup-carve/carve-grammars tiptap/schema-map.json",
"commit": "e09b460c23fceaf532f724f3ef73e0b6a355d4f7",
- "refresh": "copy the file from carve-grammars and update commit; the schema-map test asserts every AST type still has a decision"
+ "refresh": "copy the file from carve-grammars and update commit; the schema-map test asserts every AST type still has a decision",
+ "pendingUpstream": "the `figure_group` entry under unmapped is local: PART 9 section 4c is newer than the schema map, so carve-grammars carries no decision for it yet. Keep it across the next refresh until upstream does."
}
}
diff --git a/src/ast.rs b/src/ast.rs
index f0e7478a..d48e31fc 100644
--- a/src/ast.rs
+++ b/src/ast.rs
@@ -176,6 +176,7 @@ pub enum BlockNode {
LineBlock(LineBlock),
DefinitionList(DefinitionList),
Figure(Figure),
+ FigureGroup(FigureGroup),
AbbreviationDef(AbbreviationDef),
LinkReferenceDefinition(LinkReferenceDefinition),
RawBlock(RawBlock),
@@ -466,6 +467,27 @@ pub enum FigureTarget {
Paragraph(Paragraph),
}
+/// A composite figure: one figure-numbering unit holding ordered panels
+/// (PART 9 §4c, a bare `::: figure` container).
+///
+/// `children` are the body's blocks in source order; the PANELS are the
+/// `Figure` and `Table` nodes among them, derived by type rather than stored
+/// in a second list, and stray non-panel content is preserved in place.
+/// Discriminated from `Figure` by the node TYPE: every `Figure` carries a
+/// `target`, this node deliberately does not, and it has no title, label or
+/// short-caption slot - its one authored metadata channel is the group
+/// caption on the closing fence (carve#1118/carve#1121 own the rest).
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct FigureGroup {
+ pub attrs: Option,
+ pub children: Vec,
+ /// The group caption (the `^ ` line after the closing fence). `None`
+ /// means the group is uncaptioned - never an empty placeholder.
+ pub caption: Option>,
+ /// Span in the original source, when the parser could determine it.
+ pub pos: Option,
+}
+
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AbbreviationDef {
pub abbr: String,
diff --git a/src/ast_json.rs b/src/ast_json.rs
index a70eac04..1d74bb76 100644
--- a/src/ast_json.rs
+++ b/src/ast_json.rs
@@ -596,6 +596,7 @@ pub(crate) fn block_pos(node: &BlockNode) -> Option<&Pos> {
BlockNode::LineBlock(n) => n.pos.as_ref(),
BlockNode::DefinitionList(n) => n.pos.as_ref(),
BlockNode::Figure(n) => n.pos.as_ref(),
+ BlockNode::FigureGroup(n) => n.pos.as_ref(),
BlockNode::AbbreviationDef(n) => n.pos.as_ref(),
BlockNode::RawBlock(n) => n.pos.as_ref(),
BlockNode::Comment(n) => n.pos.as_ref(),
@@ -749,6 +750,21 @@ fn write_block(out: &mut String, node: &BlockNode) {
write_pos_field(&mut w, &n.pos);
w.finish();
}
+ BlockNode::FigureGroup(n) => {
+ // PART 12 §16: `children` in source order (a consumer derives the
+ // panel list by type, the way the renderer does - there is no
+ // second `panels` key to disagree with them), `caption` only when
+ // the closer hosted one - absent means uncaptioned, never an
+ // empty placeholder.
+ let mut w = typed(out, "figure_group");
+ w.field("children", |out| write_blocks(out, &n.children));
+ if let Some(caption) = &n.caption {
+ w.field("caption", |out| write_inlines(out, caption));
+ }
+ write_attrs_field(&mut w, &n.attrs);
+ write_pos_field(&mut w, &n.pos);
+ w.finish();
+ }
BlockNode::LinkReferenceDefinition(n) => {
// PART 12 §10: `label` and `href` are required, `title` and `attrs`
// ride along when the definition line carried them.
@@ -1584,6 +1600,12 @@ fn decode_block(value: &Json) -> Result {
short_caption: optional_inlines(obj, "shortCaption")?,
pos: optional_pos(obj, "figure")?,
})),
+ "figure_group" => Ok(BlockNode::FigureGroup(FigureGroup {
+ attrs: optional_attrs(obj)?,
+ children: decode_blocks(required_array(obj, "figure_group", "children")?)?,
+ caption: optional_inlines(obj, "caption")?,
+ pos: optional_pos(obj, "figure_group")?,
+ })),
"link_reference_definition" => Ok(BlockNode::LinkReferenceDefinition(
LinkReferenceDefinition {
label: required_string(obj, "link_reference_definition", "label")?.to_string(),
diff --git a/src/citations.rs b/src/citations.rs
index e752870f..0abdaeff 100644
--- a/src/citations.rs
+++ b/src/citations.rs
@@ -808,6 +808,14 @@ fn annotate_citations_block(
}
}
}
+ BlockNode::FigureGroup(g) => {
+ for child in &mut g.children {
+ annotate_citations_block(child, defs, mode, has_bib, seen, order, uses);
+ }
+ if let Some(caption) = &mut g.caption {
+ annotate_citations_inline(caption, defs, mode, has_bib, seen, order, uses);
+ }
+ }
BlockNode::Figure(f) => {
annotate_citations_inline(&mut f.caption, defs, mode, has_bib, seen, order, uses);
match &mut f.target {
diff --git a/src/document_ids.rs b/src/document_ids.rs
index e36740ed..04978c6f 100644
--- a/src/document_ids.rs
+++ b/src/document_ids.rs
@@ -367,6 +367,13 @@ impl Seeder {
}
self.walk_inlines(&f.caption);
}
+ BlockNode::FigureGroup(g) => {
+ self.reserve_attrs(&g.attrs);
+ self.walk_blocks(&g.children);
+ if let Some(caption) = &g.caption {
+ self.walk_inlines(caption);
+ }
+ }
BlockNode::Extension(e) => {
self.reserve_attrs(&e.attrs);
self.walk_blocks(&e.children);
diff --git a/src/extensions/external_links.rs b/src/extensions/external_links.rs
index 432863af..6a916d20 100644
--- a/src/extensions/external_links.rs
+++ b/src/extensions/external_links.rs
@@ -155,6 +155,14 @@ impl ExternalLinks {
self.visit_inlines(&mut f.caption);
self.visit_figure_target(f);
}
+ BlockNode::FigureGroup(g) => {
+ if let Some(caption) = &mut g.caption {
+ self.visit_inlines(caption);
+ }
+ for child in &mut g.children {
+ self.visit_block(child);
+ }
+ }
BlockNode::Extension(e) => {
for child in &mut e.children {
self.visit_block(child);
diff --git a/src/extensions/heading_level_shift.rs b/src/extensions/heading_level_shift.rs
index a4d4e1e0..9b937f73 100644
--- a/src/extensions/heading_level_shift.rs
+++ b/src/extensions/heading_level_shift.rs
@@ -104,6 +104,7 @@ fn shift_block(block: &mut BlockNode, shift: u8) {
shift_blocks(&mut quote.children, shift);
}
}
+ BlockNode::FigureGroup(group) => shift_blocks(&mut group.children, shift),
_ => {}
}
}
diff --git a/src/extensions/heading_numbers.rs b/src/extensions/heading_numbers.rs
index b34292e0..d6aed9f2 100644
--- a/src/extensions/heading_numbers.rs
+++ b/src/extensions/heading_numbers.rs
@@ -165,6 +165,7 @@ fn number_blocks(blocks: &mut [BlockNode], in_blockquote: bool, state: &mut Numb
BlockNode::BlockQuote(b) => number_blocks(&mut b.children, true, state),
BlockNode::Div(d) => number_blocks(&mut d.children, in_blockquote, state),
BlockNode::Admonition(a) => number_blocks(&mut a.children, in_blockquote, state),
+ BlockNode::FigureGroup(g) => number_blocks(&mut g.children, in_blockquote, state),
BlockNode::List(l) => {
for item in &mut l.items {
number_blocks(&mut item.children, in_blockquote, state);
@@ -334,6 +335,12 @@ fn rewrite_links_blocks(
rewrite_links_blocks(&mut b.children, by_id, opts);
}
}
+ BlockNode::FigureGroup(g) => {
+ if let Some(caption) = &mut g.caption {
+ rewrite_links_inlines(caption, by_id, opts);
+ }
+ rewrite_links_blocks(&mut g.children, by_id, opts);
+ }
BlockNode::Extension(e) => rewrite_links_blocks(&mut e.children, by_id, opts),
_ => {}
}
diff --git a/src/extensions/index_terms.rs b/src/extensions/index_terms.rs
index 62b2902b..f1b85b72 100644
--- a/src/extensions/index_terms.rs
+++ b/src/extensions/index_terms.rs
@@ -215,6 +215,14 @@ fn rewrite_markers_block(
}
}
}
+ BlockNode::FigureGroup(g) => {
+ for child in &mut g.children {
+ rewrite_markers_block(child, counts, display);
+ }
+ if let Some(caption) = &mut g.caption {
+ rewrite_markers_inline(caption, counts, display);
+ }
+ }
BlockNode::Figure(f) => {
rewrite_markers_inline(&mut f.caption, counts, display);
match &mut f.target {
diff --git a/src/extensions/smart_quotes.rs b/src/extensions/smart_quotes.rs
index 9ef99a75..2d1af2a4 100644
--- a/src/extensions/smart_quotes.rs
+++ b/src/extensions/smart_quotes.rs
@@ -141,6 +141,12 @@ impl SmartQuotes {
FigureTarget::CodeBlock(_) | FigureTarget::Image(_) => {}
}
}
+ BlockNode::FigureGroup(g) => {
+ if let Some(caption) = &mut g.caption {
+ self.visit_inlines(caption);
+ }
+ self.visit_blocks(&mut g.children);
+ }
BlockNode::Extension(e) => self.visit_blocks(&mut e.children),
BlockNode::CodeBlock(_)
| BlockNode::AbbreviationDef(_)
diff --git a/src/extensions/tab_normalize.rs b/src/extensions/tab_normalize.rs
index f067d77f..2b7a5038 100644
--- a/src/extensions/tab_normalize.rs
+++ b/src/extensions/tab_normalize.rs
@@ -135,6 +135,14 @@ impl TabNormalize {
self.visit_inlines(&mut f.caption);
self.visit_figure_target(f);
}
+ BlockNode::FigureGroup(g) => {
+ if let Some(caption) = &mut g.caption {
+ self.visit_inlines(caption);
+ }
+ for child in &mut g.children {
+ self.visit_block(child);
+ }
+ }
BlockNode::Extension(e) => {
for child in &mut e.children {
self.visit_block(child);
diff --git a/src/html_import.rs b/src/html_import.rs
index 47c97133..95b5b162 100644
--- a/src/html_import.rs
+++ b/src/html_import.rs
@@ -174,6 +174,7 @@ impl<'a> Importer<'a> {
| "nav"
| "header"
| "footer"
+ | "figure"
)
}
@@ -486,6 +487,15 @@ impl<'a> Importer<'a> {
pos: None,
})]);
}
+ // This engine's own composite-figure shape (PART 9 §4c) comes back as
+ // the node it left as. Only the exact own-output classes take these
+ // paths; any other stays an unsupported element below.
+ if tag == "figure" && Self::first_class(h).as_deref() == Some("carve-figure-group") {
+ return self.figure_group(h, path, depth, attrs);
+ }
+ if tag == "figure" && Self::first_class(h).as_deref() == Some("carve-figure-panel") {
+ return self.figure_panel(h, path, depth, attrs);
+ }
if self.opts.mode == HtmlImportMode::Roundtrip {
self.diag(
HtmlImportDiagnosticCode::RawPreserved,
@@ -798,6 +808,142 @@ impl<'a> Importer<'a> {
path,
);
}
+ /// The element's FIRST class, the slot this engine's structural classes
+ /// lead from (`carve-figure-group`, `carve-figure-panel`).
+ fn first_class(handle: &Handle) -> Option {
+ Self::attr(handle, "class")?
+ .split_whitespace()
+ .next()
+ .map(str::to_string)
+ }
+
+ /// Drop a structural class the renderer injected; what remains is what the
+ /// author wrote. `None` when nothing else was carried.
+ fn without_structural_class(attrs: Option, class: &str) -> Option {
+ let mut attrs = attrs?;
+ attrs.classes.retain(|c| c != class);
+ if attrs.classes.is_empty() && attrs.id.is_none() && attrs.key_values.is_empty() {
+ return None;
+ }
+ Some(attrs)
+ }
+
+ /// `` back to the `figure_group` node
+ /// it rendered from: the unconditional panels div unwraps into `children`
+ /// (each panel routed back through [`Self::figure_panel`]), and the
+ /// trailing `` is the group caption (PART 9 §4c).
+ fn figure_group(
+ &mut self,
+ h: &Handle,
+ path: &str,
+ depth: usize,
+ attrs: Option,
+ ) -> Result, HtmlImportError> {
+ let attrs = Self::without_structural_class(attrs, "carve-figure-group");
+ let mut children = Vec::new();
+ let mut caption = None;
+ for (i, child) in h.children.borrow().iter().enumerate() {
+ match Self::tag(child).as_deref() {
+ Some("div")
+ if Self::first_class(child).as_deref() == Some("carve-figure-panels") =>
+ {
+ let p = format!("{path}/div[{}]", i + 1);
+ children = self.blocks(&child.children.borrow(), &p, depth + 1)?;
+ }
+ Some("figcaption") => {
+ let p = format!("{path}/figcaption[{}]", i + 1);
+ caption = Some(self.inlines(&child.children.borrow(), &p, depth + 1)?);
+ }
+ _ => {}
+ }
+ }
+ Ok(vec![BlockNode::FigureGroup(FigureGroup {
+ attrs,
+ children,
+ caption,
+ pos: None,
+ })])
+ }
+
+ /// `` back to the node it wrapped: the
+ /// host plus its `` rebuild the `figure` the caption pass
+ /// produced; a bare wrapped table (whose caption is its own `
`)
+ /// unwraps to the table node.
+ fn figure_panel(
+ &mut self,
+ h: &Handle,
+ path: &str,
+ depth: usize,
+ attrs: Option,
+ ) -> Result, HtmlImportError> {
+ let attrs = Self::without_structural_class(attrs, "carve-figure-panel");
+ let mut caption = None;
+ let mut host = Vec::new();
+ for child in h.children.borrow().iter() {
+ if Self::tag(child).as_deref() == Some("figcaption") {
+ caption = Some(self.inlines(&child.children.borrow(), path, depth + 1)?);
+ continue;
+ }
+ // Pretty-printed margins between the wrapper and its host. Kept,
+ // they lead the rebuilt image paragraph with a space, and the
+ // writer's indented image line then re-parses as prose.
+ if let NodeData::Text { contents } = &child.data {
+ if contents.borrow().trim().is_empty() {
+ continue;
+ }
+ }
+ host.push(child.clone());
+ }
+ let mut blocks = self.blocks(&host, path, depth + 1)?;
+ let Some(caption) = caption else {
+ return Ok(blocks);
+ };
+ if blocks.len() == 1 {
+ let target = match blocks.remove(0) {
+ // A sole image renders bare inside the panel, so it comes back
+ // as a one-image paragraph; the figure the parser builds holds
+ // the IMAGE as its target.
+ BlockNode::Paragraph(p)
+ if p.children.len() == 1 && matches!(p.children[0], InlineNode::Image(_)) =>
+ {
+ match p.children.into_iter().next() {
+ Some(InlineNode::Image(img)) => FigureTarget::Image(img),
+ _ => unreachable!("the match guard saw an image"),
+ }
+ }
+ BlockNode::Paragraph(p) => FigureTarget::Paragraph(p),
+ BlockNode::BlockImage(img) => FigureTarget::Image(img),
+ BlockNode::BlockQuote(quote) => FigureTarget::BlockQuote(quote),
+ BlockNode::Table(table) => FigureTarget::Table(table),
+ BlockNode::CodeBlock(code) => FigureTarget::CodeBlock(code),
+ other => {
+ blocks.insert(0, other);
+ blocks.push(BlockNode::Paragraph(Paragraph {
+ attrs: None,
+ children: caption,
+ at_content_column: true,
+ pos: None,
+ }));
+ return Ok(blocks);
+ }
+ };
+ return Ok(vec![BlockNode::Figure(Figure {
+ attrs,
+ target,
+ caption,
+ short_caption: None,
+ pos: None,
+ })]);
+ }
+ blocks.push(BlockNode::Paragraph(Paragraph {
+ attrs: None,
+ children: caption,
+ at_content_column: true,
+ pos: None,
+ }));
+ Ok(blocks)
+ }
+
fn table(
&mut self,
h: &Handle,
diff --git a/src/lint.rs b/src/lint.rs
index ad847544..ec38fb92 100644
--- a/src/lint.rs
+++ b/src/lint.rs
@@ -26,6 +26,27 @@
//! `render_html_with_options`, and the diagnostics describe the output the
//! author will actually get.
//!
+//! PART 9 §4c adds the composite-figure findings (markup-carve/carve#1122),
+//! each a diagnostic over a valid parse:
+//!
+//! `figure-group-opener-metadata`
+//! : a `::: figure` opener carrying a quoted title or a `[label]` stays a
+//! generic container - the group has no title or label slot by design.
+//!
+//! `figure-group-nested`
+//! : a bare `::: figure` opener inside an open group's body stays a generic
+//! container; groups do not nest.
+//!
+//! `figure-group-panel-number`
+//! : a `#` placeholder in a PANEL caption has nothing to resolve against and
+//! stays literal - panels are not sequence units.
+//!
+//! §4c also names `figure-group-empty` and `figure-group-single-panel` as
+//! STRICT-PROFILE findings. This surface has no profile or severity axis yet,
+//! and reporting them unconditionally would flag documents the clause calls
+//! valid and ordinary, so they wait for that axis rather than shipping under
+//! the wrong severity.
+//!
//! Rule ids and messages match carve-js' `lintCarve` (`src/lint.ts`) - "same
//! rule, same id" is what parity means here, and a consumer reading
//! diagnostics from two engines must not see one warning under two spellings.
@@ -115,10 +136,116 @@ pub fn lint_carve_with_options(source: &str, options: &Options<'_>) -> Vec usize,
+ out: &mut Vec,
+) {
+ for block in blocks {
+ match block {
+ BlockNode::Admonition(a) => {
+ if a.kind == "figure" {
+ if a.title.is_some() || a.label.is_some() {
+ out.push(warning(
+ a.pos,
+ to_byte,
+ "figure-group-opener-metadata",
+ "A `::: figure` opener carrying a quoted title or a [label] stays a \
+ generic container, not a composite figure: the group has no title or \
+ label slot (PART 9 \u{a7}4c). Its one authored metadata channel is \
+ the `^ ` caption after the closing fence."
+ .to_string(),
+ ));
+ } else if in_group {
+ out.push(warning(
+ a.pos,
+ to_byte,
+ "figure-group-nested",
+ "Composite figures do not nest (PART 9 \u{a7}4c): a bare `::: figure` \
+ opener inside an open group's body stays a generic container. Close \
+ the outer group first, or drop the inner fence."
+ .to_string(),
+ ));
+ }
+ }
+ collect_figure_group_warnings(&a.children, in_group, to_byte, out);
+ }
+ BlockNode::FigureGroup(g) => {
+ for child in &g.children {
+ let panel_caption = match child {
+ BlockNode::Figure(f) => Some(&f.caption),
+ BlockNode::Table(t) => t.caption.as_ref(),
+ _ => None,
+ };
+ if let Some(caption) = panel_caption {
+ for node in caption.iter() {
+ if let InlineNode::CaptionNumber(n) = node {
+ out.push(warning(
+ n.pos,
+ to_byte,
+ "figure-group-panel-number",
+ "A `#` placeholder in a panel caption stays literal: panels \
+ are not sequence units, so it has nothing to resolve against \
+ (PART 9 \u{a7}4c). Number the GROUP caption instead, or drop \
+ the `#`."
+ .to_string(),
+ ));
+ }
+ }
+ }
+ }
+ collect_figure_group_warnings(&g.children, true, to_byte, out);
+ }
+ BlockNode::Div(d) => collect_figure_group_warnings(&d.children, in_group, to_byte, out),
+ BlockNode::BlockQuote(b) => {
+ collect_figure_group_warnings(&b.children, in_group, to_byte, out)
+ }
+ BlockNode::LineBlock(lb) => {
+ collect_figure_group_warnings(&lb.children, in_group, to_byte, out)
+ }
+ BlockNode::List(l) => {
+ for item in &l.items {
+ collect_figure_group_warnings(&item.children, in_group, to_byte, out);
+ }
+ }
+ BlockNode::DefinitionList(dl) => {
+ for item in &dl.items {
+ for def in &item.definitions {
+ collect_figure_group_warnings(&def.children, in_group, to_byte, out);
+ }
+ }
+ }
+ BlockNode::Figure(f) => {
+ if let FigureTarget::BlockQuote(b) = &f.target {
+ collect_figure_group_warnings(&b.children, in_group, to_byte, out);
+ }
+ }
+ BlockNode::Extension(e) => {
+ collect_figure_group_warnings(&e.children, in_group, to_byte, out)
+ }
+ _ => {}
+ }
+ }
+}
+
/// Byte offset of each codepoint in `source`, plus one past the end.
///
/// `None` for an all-ASCII source, where the two units coincide and the table
@@ -368,6 +495,13 @@ fn walk_block(node: &BlockNode, visit: &mut Visit<'_>) {
walk_inlines(short, visit);
}
}
+ BlockNode::FigureGroup(n) => {
+ report("figure_group", &n.attrs, n.pos, visit);
+ walk_blocks(&n.children, visit);
+ if let Some(caption) = &n.caption {
+ walk_inlines(caption, visit);
+ }
+ }
// Carries no `attrs` field and no children.
BlockNode::AbbreviationDef(_) => {}
BlockNode::LinkReferenceDefinition(n) => {
diff --git a/src/parse.rs b/src/parse.rs
index 37961d0a..1c0f2912 100644
--- a/src/parse.rs
+++ b/src/parse.rs
@@ -120,6 +120,48 @@ impl Drop for DepthGuard {
}
}
+thread_local! {
+ // GROUPS DO NOT NEST (PART 9 §4c): a bare `::: figure` opener anywhere
+ // inside an open group's body - at ANY depth, through divs, quotes and
+ // list items - is a generic Tier-2 container, not an inner group. The
+ // body parses through several re-entrant helpers on this thread, so the
+ // state lives beside the depth counter they already share.
+ //
+ // Plain initializer for the same MSRV reason as `NESTING_DEPTH` above.
+ #[allow(clippy::missing_const_for_thread_local)]
+ static IN_FIGURE_GROUP: Cell = Cell::new(false);
+}
+
+/// RAII guard that marks the current thread as parsing a figure group's body,
+/// restoring the previous state on drop (panic unwind included), the same
+/// discipline [`DepthGuard`] keeps for the depth counter.
+struct FigureGroupGuard {
+ previous: bool,
+}
+
+impl FigureGroupGuard {
+ fn enter() -> FigureGroupGuard {
+ let previous = IN_FIGURE_GROUP.with(Cell::get);
+ IN_FIGURE_GROUP.with(|flag| flag.set(true));
+ FigureGroupGuard { previous }
+ }
+}
+
+impl Drop for FigureGroupGuard {
+ fn drop(&mut self) {
+ let previous = self.previous;
+ IN_FIGURE_GROUP.with(|flag| flag.set(previous));
+ }
+}
+
+/// Whether a container opener line is the BARE `::: figure` form - the fence,
+/// its separator and the kind word, NOTHING else (PART 9 §4c,
+/// `figure_group_open`). An opener carrying a quoted title or a label matches
+/// `admonition_open` instead and stays a generic container.
+fn is_bare_figure_open(open: &ContainerOpen) -> bool {
+ open.kind.as_deref() == Some("figure") && open.title.is_none() && open.label.is_none()
+}
+
pub fn parse(source: &str) -> Document {
parse_with_options(source, &Options::default())
}
@@ -420,6 +462,12 @@ fn fill_crossref_hrefs(doc: &mut Document, lowercase_ids: bool) {
}
BlockNode::Div(d) => blocks(&mut d.children, index),
BlockNode::Figure(f) => inlines(&mut f.caption, index),
+ BlockNode::FigureGroup(g) => {
+ blocks(&mut g.children, index);
+ if let Some(caption) = &mut g.caption {
+ inlines(caption, index);
+ }
+ }
BlockNode::List(l) => {
for item in &mut l.items {
blocks(&mut item.children, index);
@@ -2772,6 +2820,7 @@ fn fill_offsets(blocks: &mut [BlockNode], line_starts: &[usize]) {
BlockNode::Table(t) => t.pos.as_mut(),
BlockNode::LineBlock(l) => l.pos.as_mut(),
BlockNode::Figure(f) => f.pos.as_mut(),
+ BlockNode::FigureGroup(g) => g.pos.as_mut(),
BlockNode::BlockImage(i) => i.pos.as_mut(),
BlockNode::DefinitionList(d) => d.pos.as_mut(),
BlockNode::AbbreviationDef(a) => a.pos.as_mut(),
@@ -2903,6 +2952,12 @@ fn fill_offsets(blocks: &mut [BlockNode], line_starts: &[usize]) {
}
}
}
+ BlockNode::FigureGroup(g) => {
+ if let Some(caption) = &mut g.caption {
+ apply_inline_offsets(caption, line_starts);
+ }
+ fill_offsets(&mut g.children, line_starts);
+ }
_ => {}
}
}
@@ -2932,6 +2987,7 @@ fn include_comment_indentation(blocks: &mut [BlockNode], source: &str, line_star
BlockNode::BlockQuote(n) => walk(&mut n.children, lines, starts),
BlockNode::Div(n) => walk(&mut n.children, lines, starts),
BlockNode::Admonition(n) => walk(&mut n.children, lines, starts),
+ BlockNode::FigureGroup(n) => walk(&mut n.children, lines, starts),
BlockNode::List(n) => {
for item in &mut n.items {
walk(&mut item.children, lines, starts);
@@ -3181,10 +3237,24 @@ fn parse_eof_closed_colon_ladder(
let available = MAX_NESTING_DEPTH.saturating_sub(current_depth);
let take = opens.len().min(available);
+ // PART 9 §4c along this fast path too: the outermost bare `::: figure`
+ // opener not already inside a group is a composite figure; every bare
+ // opener under it stays a generic container (groups do not nest). Walked
+ // outer-in, seeded from the thread state, so a ladder inside an open
+ // group's body demotes exactly as the ordinary path would.
+ let mut in_group = IN_FIGURE_GROUP.with(Cell::get);
+ let mut opens_a_group: Vec = Vec::with_capacity(take);
+ for open in opens.iter().take(take) {
+ let bare = is_bare_figure_open(open) && !in_group;
+ opens_a_group.push(bare);
+ in_group = in_group || bare;
+ }
+
let tail = &lines[take..];
let mut children = if tail.iter().all(|line| is_blank_line(line)) {
Vec::new()
} else {
+ let _guard = in_group.then(FigureGroupGuard::enter);
if opens.len() > available {
// PART 9 §25: past the cap an opener "becomes literal paragraph
// text" - it degrades, it does not vanish. This used to locate the
@@ -3208,7 +3278,18 @@ fn parse_eof_closed_colon_ladder(
}
};
- for open in opens.into_iter().take(take).rev() {
+ for (open, opens_group) in opens.into_iter().take(take).zip(opens_a_group).rev() {
+ if opens_group {
+ // An EOF-closed group never wrote its closer, so it has no line
+ // for a caption to hang on (§4c).
+ children = vec![BlockNode::FigureGroup(FigureGroup {
+ attrs: open.attrs,
+ children,
+ caption: None,
+ pos: None,
+ })];
+ continue;
+ }
children = vec![if let Some(kind) = open.kind {
BlockNode::Admonition(Admonition {
attrs: open.attrs,
@@ -4464,8 +4545,13 @@ fn skip_opaque_span_into(inner: &mut LineBuffer, cur: &mut LineCursor<'_>) -> bo
false
}
-fn collect_colon_container_body(cur: &mut LineCursor<'_>, opener_len: usize) -> LineBuffer {
+/// Returns the collected body and whether the container's own CLOSER was
+/// consumed - `false` means end of input closed it (PART 9 §12). The flag
+/// exists for the figure group, whose caption slot hangs on the closing fence
+/// (§4c): a group closed by end of input has no closer line for a caption.
+fn collect_colon_container_body(cur: &mut LineCursor<'_>, opener_len: usize) -> (LineBuffer, bool) {
let mut inner = LineBuffer::default();
+ let mut closed = false;
let mut stack = vec![opener_len];
while cur.peek().is_some() {
let top = *stack.last().unwrap();
@@ -4476,6 +4562,7 @@ fn collect_colon_container_body(cur: &mut LineCursor<'_>, opener_len: usize) ->
if exact_colon_fence_len(cur.peek().unwrap()) == Some(top) {
if stack.len() == 1 {
cur.consume();
+ closed = true;
break;
}
push_current_line(&mut inner, cur);
@@ -4522,7 +4609,7 @@ fn collect_colon_container_body(cur: &mut LineCursor<'_>, opener_len: usize) ->
push_current_line(&mut inner, cur);
cur.consume();
}
- inner
+ (inner, closed)
}
fn find_line_block_end(lines: &[&str], start: usize, fence_len: usize) -> usize {
@@ -7025,6 +7112,12 @@ fn block_ends_with_open_paragraph(block: Option<&BlockNode>, colon_open: usize)
Some(BlockNode::Admonition(a)) if colon_open > 0 => {
block_ends_with_open_paragraph(a.children.last(), colon_open - 1)
}
+ // A bare `::: figure` fence is a container like the two above; left
+ // unterminated it still holds its open paragraph (§4c defers to §12's
+ // container rules for body and closer discipline).
+ Some(BlockNode::FigureGroup(g)) if colon_open > 0 => {
+ block_ends_with_open_paragraph(g.children.last(), colon_open - 1)
+ }
_ => false,
}
}
@@ -9484,7 +9577,35 @@ fn parse_container(cur: &mut LineCursor, options: &Options<'_>) -> BlockNode {
let open = detect_container_open(cur.peek().unwrap()).unwrap();
let span_start = cur.pos;
cur.consume();
- let inner = collect_colon_container_body(cur, open.fence_len);
+ // PART 9 §4c: a BARE `::: figure` opener opens a COMPOSITE FIGURE, unless
+ // it sits inside an open group's body (groups do not nest - the inner one
+ // stays the generic container the Admonition arm below builds). The body
+ // and closer follow the unchanged container rules; what §4c adds is the
+ // caption slot hanging on the CLOSING fence, this kind only.
+ if is_bare_figure_open(&open) && !IN_FIGURE_GROUP.with(Cell::get) {
+ let (children, closed) = {
+ let _guard = FigureGroupGuard::enter();
+ let (inner, closed) = collect_colon_container_body(cur, open.fence_len);
+ (parse_capped_colon_body(inner, options), closed)
+ };
+ // The slot hangs on the closer. A group left open at end of input
+ // closed there without one, so there is no line for a caption to
+ // attach to (§4c).
+ let caption = if closed {
+ consume_caption(cur, options)
+ } else {
+ None
+ };
+ // Through the caption the cursor just consumed, like a figure's span.
+ let pos = span_of(cur, span_start, cur.pos, options);
+ return BlockNode::FigureGroup(FigureGroup {
+ attrs: None,
+ children,
+ caption,
+ pos,
+ });
+ }
+ let (inner, _closed) = collect_colon_container_body(cur, open.fence_len);
let children = parse_capped_colon_body(inner, options);
// The span covers the opening fence through the closing one.
let pos = span_of(cur, span_start, cur.pos, options);
@@ -9961,7 +10082,7 @@ fn parse_hardbreaks_block(cur: &mut LineCursor, options: &Options<'_>) -> BlockN
let fence_len = detect_hardbreaks_block_open(opener).unwrap();
let span_start = cur.pos;
cur.consume();
- let inner = collect_colon_container_body(cur, fence_len);
+ let (inner, _closed) = collect_colon_container_body(cur, fence_len);
// The span covers the opening fence through the closing one, like any other
// colon fence.
let pos = span_of(cur, span_start, cur.pos, options);
@@ -10631,6 +10752,7 @@ fn stamp_source_line(node: &mut BlockNode, line: usize) {
BlockNode::LineBlock(n) => Some(&mut n.attrs),
BlockNode::DefinitionList(n) => Some(&mut n.attrs),
BlockNode::Figure(n) => Some(&mut n.attrs),
+ BlockNode::FigureGroup(n) => Some(&mut n.attrs),
BlockNode::Extension(n) => Some(&mut n.attrs),
BlockNode::BlockImage(n) => Some(&mut n.attrs),
BlockNode::AbbreviationDef(_) | BlockNode::RawBlock(_) | BlockNode::Comment(_) => None,
@@ -10682,6 +10804,9 @@ fn apply_attrs_to_block(node: &mut BlockNode, attrs: Attrs) {
BlockNode::LineBlock(n) => merge_leading_attrs(&mut n.attrs, attrs),
BlockNode::DefinitionList(n) => n.attrs = Some(attrs),
BlockNode::Figure(n) => n.attrs = Some(attrs),
+ // The bare opener carries nothing of its own (§4c), so the preceding
+ // block-attribute line is the group's only attribute source.
+ BlockNode::FigureGroup(n) => n.attrs = Some(attrs),
BlockNode::Extension(n) => n.attrs = Some(attrs),
// A direct block image (`{#id}\n`) carries the leading attrs on
// the `` itself; the image's own inline attrs win on conflict (§15).
@@ -13748,6 +13873,14 @@ fn apply_abbreviations_block(block: &mut BlockNode, index: &AbbreviationIndex<'_
apply_abbreviations_block(child, index);
}
}
+ BlockNode::FigureGroup(g) => {
+ for child in &mut g.children {
+ apply_abbreviations_block(child, index);
+ }
+ if let Some(caption) = &mut g.caption {
+ apply_abbreviations_inline(caption, index);
+ }
+ }
// A `:::` div and a block extension were missing, so an abbreviation
// never expanded inside one -- even with the definition at the top
// level, where collection was never in doubt. carve-js expands it.
@@ -14436,6 +14569,14 @@ fn resolve_reference_links_block(
resolve_reference_links_block(child, defs, heading_index);
}
}
+ BlockNode::FigureGroup(g) => {
+ for child in &mut g.children {
+ resolve_reference_links_block(child, defs, heading_index);
+ }
+ if let Some(caption) = &mut g.caption {
+ resolve_reference_links_inline(caption, defs, heading_index);
+ }
+ }
BlockNode::Div(d) => {
for child in &mut d.children {
resolve_reference_links_block(child, defs, heading_index);
@@ -14923,6 +15064,9 @@ fn promote_block_images(blocks: &mut [BlockNode], figures_only: bool) {
match block {
BlockNode::BlockQuote(b) => promote_block_images(&mut b.children, figures_only),
BlockNode::Admonition(a) => promote_block_images(&mut a.children, figures_only),
+ // Descend into the group so an image-with-caption paragraph built
+ // from a resolved reference image still becomes a panel (§4c).
+ BlockNode::FigureGroup(g) => promote_block_images(&mut g.children, figures_only),
BlockNode::Div(d) => promote_block_images(&mut d.children, figures_only),
BlockNode::List(l) => {
for item in &mut l.items {
@@ -14990,6 +15134,7 @@ fn collect_explicit_ids(blocks: &[BlockNode], out: &mut std::collections::BTreeS
}
BlockNode::BlockQuote(b) => collect_explicit_ids(&b.children, out),
BlockNode::Admonition(a) => collect_explicit_ids(&a.children, out),
+ BlockNode::FigureGroup(g) => collect_explicit_ids(&g.children, out),
BlockNode::Div(d) => collect_explicit_ids(&d.children, out),
BlockNode::DefinitionList(d) => {
for item in &d.items {
@@ -15094,6 +15239,13 @@ fn collect_heading_titles(
explicit_ids,
in_blockquote,
),
+ BlockNode::FigureGroup(g) => collect_heading_titles(
+ &g.children,
+ scan,
+ lowercase_ids,
+ explicit_ids,
+ in_blockquote,
+ ),
BlockNode::Div(d) => collect_heading_titles(
&d.children,
scan,
@@ -15148,6 +15300,35 @@ fn number_captioned_blocks(
| FigureTarget::Paragraph(_) => {}
}
}
+ BlockNode::FigureGroup(group) => {
+ // THE GROUP IS ONE NUMBERING UNIT (§4c). Its caption draws
+ // first - before anything inside the body, matching the
+ // oracle - and that one draw is also what the panel ids
+ // register under, with a letter by panel order.
+ let drew = group.caption.as_mut().and_then(|caption| {
+ number_caption(caption, group.attrs.as_ref(), counts, titles)
+ });
+ if let Some((label, number)) = &drew {
+ register_panel_titles(&group.children, label, *number, titles);
+ }
+ // PANELS ARE NOT SEQUENCE UNITS: a panel's own caption draws
+ // nothing (a `#` there stays literal, §4c), but content
+ // inside a quote panel and every non-panel child numbers
+ // normally.
+ for child in &mut group.children {
+ match child {
+ BlockNode::Figure(f) => {
+ if let FigureTarget::BlockQuote(b) = &mut f.target {
+ number_captioned_blocks(&mut b.children, counts, titles);
+ }
+ }
+ BlockNode::Table(_) => {}
+ other => {
+ number_captioned_blocks(std::slice::from_mut(other), counts, titles)
+ }
+ }
+ }
+ }
BlockNode::List(l) => {
for item in &mut l.items {
number_captioned_blocks(&mut item.children, counts, titles);
@@ -15178,18 +15359,18 @@ fn number_table_caption(
}
}
+/// Returns the label and number the caption drew, when it held a `#`
+/// placeholder - the figure group's arm derives its panels' crossref text
+/// from that draw (§4c).
fn number_caption(
caption: &mut [InlineNode],
attrs: Option<&Attrs>,
counts: &mut BTreeMap,
titles: &mut BTreeMap,
-) {
- let Some(idx) = caption
+) -> Option<(String, usize)> {
+ let idx = caption
.iter()
- .position(|node| matches!(node, InlineNode::CaptionNumber(_)))
- else {
- return;
- };
+ .position(|node| matches!(node, InlineNode::CaptionNumber(_)))?;
let label = plain_inlines_parse(&caption[..idx])
.trim_end_matches(char::is_whitespace)
.to_string();
@@ -15204,6 +15385,47 @@ fn number_caption(
.entry(id.clone())
.or_insert_with(|| format!("{label} {number}"));
}
+ Some((label, number))
+}
+
+/// A panel's crossref letter by its order among the group's panels: a..z,
+/// then aa, ab, ... (PART 9 §4c; the letters exist in crossref text only).
+fn panel_letter(index: usize) -> String {
+ let mut out = Vec::new();
+ let mut n = index + 1;
+ while n > 0 {
+ n -= 1;
+ out.push(b'a' + (n % 26) as u8);
+ n /= 26;
+ }
+ out.reverse();
+ String::from_utf8(out).expect("ascii letters")
+}
+
+/// Register a numbered group's panel ids as "Label N" plus a letter by panel
+/// order (§4c). Panels are the `Figure` and `Table` nodes among the group's
+/// direct children; an unnumbered group's panels stay plain anchors, exactly
+/// as an id on an uncaptioned figure does.
+fn register_panel_titles(
+ children: &[BlockNode],
+ label: &str,
+ number: usize,
+ titles: &mut BTreeMap,
+) {
+ let mut panel_index = 0usize;
+ for child in children {
+ let id = match child {
+ BlockNode::Figure(f) => f.attrs.as_ref().and_then(|attrs| attrs.id.clone()),
+ BlockNode::Table(t) => t.attrs.as_ref().and_then(|attrs| attrs.id.clone()),
+ _ => continue,
+ };
+ if let Some(id) = id {
+ titles
+ .entry(id)
+ .or_insert_with(|| format!("{label} {number}{}", panel_letter(panel_index)));
+ }
+ panel_index += 1;
+ }
}
fn collect_caption_titles(blocks: &[BlockNode], titles: &mut BTreeMap) {
@@ -15220,6 +15442,29 @@ fn collect_caption_titles(blocks: &[BlockNode], titles: &mut BTreeMap {}
}
}
+ BlockNode::FigureGroup(g) => {
+ // The ingest twin of the group arm in `number_captioned_blocks`:
+ // read the number the group's caption already carries, register
+ // the group id and the panel letters from that one draw, and
+ // skip the panel captions exactly as the numbering pass does.
+ if let Some(caption) = &g.caption {
+ collect_caption_title(caption, g.attrs.as_ref(), titles);
+ if let Some((label, number)) = numbered_caption_draw(caption) {
+ register_panel_titles(&g.children, &label, number, titles);
+ }
+ }
+ for child in &g.children {
+ match child {
+ BlockNode::Figure(f) => {
+ if let FigureTarget::BlockQuote(b) = &f.target {
+ collect_caption_titles(&b.children, titles);
+ }
+ }
+ BlockNode::Table(_) => {}
+ other => collect_caption_titles(std::slice::from_ref(other), titles),
+ }
+ }
+ }
BlockNode::List(l) => {
for item in &l.items {
collect_caption_titles(&item.children, titles);
@@ -15240,6 +15485,22 @@ fn collect_caption_titles(blocks: &[BlockNode], titles: &mut BTreeMap Option<(String, usize)> {
+ let idx = caption
+ .iter()
+ .position(|node| matches!(node, InlineNode::CaptionNumber(_)))?;
+ let number = match &caption[idx] {
+ InlineNode::CaptionNumber(n) => n.number?,
+ _ => return None,
+ };
+ let label = plain_inlines_parse(&caption[..idx])
+ .trim_end_matches(char::is_whitespace)
+ .to_string();
+ Some((label, number))
+}
+
fn collect_table_caption_title(table: &Table, titles: &mut BTreeMap) {
if let Some(caption) = &table.caption {
collect_caption_title(caption, table.attrs.as_ref(), titles);
@@ -15334,6 +15595,14 @@ fn coalesce_block(block: &mut BlockNode) {
coalesce_block(child);
}
}
+ BlockNode::FigureGroup(g) => {
+ if let Some(caption) = &mut g.caption {
+ coalesce_inlines(caption);
+ }
+ for child in &mut g.children {
+ coalesce_block(child);
+ }
+ }
BlockNode::Div(d) => {
for child in &mut d.children {
coalesce_block(child);
@@ -15765,6 +16034,7 @@ fn stamp_heading_ids_in(blocks: &mut [BlockNode], next: &mut impl Iterator stamp_heading_ids_in(&mut b.children, next),
BlockNode::Div(d) => stamp_heading_ids_in(&mut d.children, next),
BlockNode::Admonition(a) => stamp_heading_ids_in(&mut a.children, next),
+ BlockNode::FigureGroup(g) => stamp_heading_ids_in(&mut g.children, next),
BlockNode::List(l) => {
for item in l.items.iter_mut() {
stamp_heading_ids_in(&mut item.children, next);
diff --git a/src/profile.rs b/src/profile.rs
index 7b09e2d2..69b0c9c2 100644
--- a/src/profile.rs
+++ b/src/profile.rs
@@ -64,6 +64,7 @@ pub const CANONICAL_BLOCK_TYPES: &[&str] = &[
"line_block",
"comment",
"figure",
+ "figure_group",
"caption",
// Both definition kinds are in the normative Block vocabulary
// (markup-carve/carve#771, ruled by markup-carve/carve#826). Without them
@@ -149,6 +150,7 @@ pub fn canonical_block_type(node: &BlockNode) -> Option<&'static str> {
BlockNode::LineBlock(_) => Some("line_block"),
BlockNode::DefinitionList(_) => Some("definition_list"),
BlockNode::Figure(_) => Some("figure"),
+ BlockNode::FigureGroup(_) => Some("figure_group"),
BlockNode::RawBlock(_) => Some("raw_block"),
BlockNode::Comment(_) => Some("comment"),
BlockNode::BlockImage(_) => Some("image"),
diff --git a/src/profile_filter.rs b/src/profile_filter.rs
index 4c71fa3e..e58dde8f 100644
--- a/src/profile_filter.rs
+++ b/src/profile_filter.rs
@@ -394,6 +394,12 @@ impl ProfileFilter<'_> {
self.filter_inlines(&mut fig.caption, depth + 1)?;
self.recurse_figure_target(fig, depth + 1)?;
}
+ BlockNode::FigureGroup(group) => {
+ if let Some(caption) = &mut group.caption {
+ self.filter_inlines(caption, depth + 1)?;
+ }
+ self.filter_blocks(&mut group.children, depth)?;
+ }
BlockNode::Extension(ext) => self.filter_blocks(&mut ext.children, depth)?,
}
Ok(())
@@ -917,6 +923,24 @@ fn extract_block_text(node: &BlockNode, smart: SmartTypographyMode) -> String {
BlockNode::Div(div) => block_children_join(&div.children, smart),
BlockNode::LineBlock(lb) => block_children_join(&lb.children, smart),
BlockNode::Extension(ext) => block_children_join(&ext.children, smart),
+ BlockNode::FigureGroup(group) => {
+ let mut parts: Vec = group
+ .children
+ .iter()
+ .map(|child| extract_block_text(child, smart))
+ .filter(|text| !text.is_empty())
+ .collect();
+ if let Some(caption) = &group.caption {
+ let caption: String = caption
+ .iter()
+ .map(|n| extract_inline_text(n, smart))
+ .collect();
+ if !caption.is_empty() {
+ parts.push(caption);
+ }
+ }
+ parts.join("\n")
+ }
BlockNode::Figure(fig) => {
let target = match &fig.target {
FigureTarget::Image(img) => image_text(img),
@@ -1106,6 +1130,12 @@ fn cleanup_block_children(block: &mut BlockNode) {
}
}
BlockNode::Figure(fig) => cleanup_inlines(&mut fig.caption),
+ BlockNode::FigureGroup(group) => {
+ if let Some(caption) = &mut group.caption {
+ cleanup_inlines(caption);
+ }
+ cleanup_blocks(&mut group.children);
+ }
BlockNode::Extension(ext) => cleanup_blocks(&mut ext.children),
_ => {}
}
@@ -1156,6 +1186,10 @@ fn is_empty_block(node: &BlockNode) -> bool {
BlockNode::LineBlock(lb) => lb.children.is_empty(),
BlockNode::DefinitionList(dl) => dl.items.is_empty(),
BlockNode::Figure(_) => false,
+ // The panels div is unconditional (PART 9 SS4c), so an emptied group
+ // still renders a coherent shell only when it truly has nothing left:
+ // no children and no caption.
+ BlockNode::FigureGroup(group) => group.children.is_empty() && group.caption.is_none(),
BlockNode::Extension(ext) => ext.children.is_empty(),
}
}
diff --git a/src/prosemirror/to_pm.rs b/src/prosemirror/to_pm.rs
index 5fa68728..73fead17 100644
--- a/src/prosemirror/to_pm.rs
+++ b/src/prosemirror/to_pm.rs
@@ -272,6 +272,25 @@ impl Renderer {
}
(self.name("figure")?, attrs(n.attrs.as_ref()), children)
}
+ BlockNode::FigureGroup(n) => {
+ // The vendored carve-grammars map has no name for
+ // `figure_group`: the editor schema predates PART 9 §4c, and
+ // adding one HERE would fork the map this bridge exists to
+ // read rather than repeat. So the group degrades to the
+ // generic container the map does have - the same `carveDiv`
+ // an admonition rides on - keeping every panel and the group
+ // caption, and losing only the fact that they were one figure.
+ self.degrade("figure_group");
+ let mut children = self.blocks(&n.children);
+ if let Some(caption) = &n.caption {
+ children.push(node_with(
+ self.name("caption")?,
+ Object::new(),
+ self.inlines(caption, &[]),
+ ));
+ }
+ (self.name("div")?, attrs(n.attrs.as_ref()), children)
+ }
BlockNode::AbbreviationDef(_) => {
self.drop_type("abbreviation_def", None);
return None;
diff --git a/src/render.rs b/src/render.rs
index 58ede28d..8c36abd4 100644
--- a/src/render.rs
+++ b/src/render.rs
@@ -427,6 +427,30 @@ fn collect_footnotes_block(
);
}
}
+ BlockNode::FigureGroup(g) => {
+ // Children first: the panels precede the group caption in the
+ // rendered output, so their footnote references number first.
+ for child in &mut g.children {
+ collect_footnotes_block(
+ assign_ref_ids,
+ child,
+ def_labels,
+ label_indices,
+ seen,
+ order,
+ );
+ }
+ if let Some(caption) = &mut g.caption {
+ collect_footnotes_inline(
+ assign_ref_ids,
+ caption,
+ def_labels,
+ label_indices,
+ seen,
+ order,
+ );
+ }
+ }
BlockNode::LineBlock(lb) => {
for child in &mut lb.children {
collect_footnotes_block(
@@ -776,6 +800,7 @@ fn block_source_line(block: &BlockNode) -> Option<&str> {
BlockNode::LineBlock(n) => n.attrs.as_ref(),
BlockNode::DefinitionList(n) => n.attrs.as_ref(),
BlockNode::Figure(n) => n.attrs.as_ref(),
+ BlockNode::FigureGroup(n) => n.attrs.as_ref(),
BlockNode::Extension(n) => n.attrs.as_ref(),
BlockNode::BlockImage(n) => n.attrs.as_ref(),
BlockNode::AbbreviationDef(_) | BlockNode::RawBlock(_) | BlockNode::Comment(_) => None,
@@ -1014,6 +1039,7 @@ fn render_block(
BlockNode::LineBlock(lb) => render_line_block(out, lb, level, options, state),
BlockNode::DefinitionList(d) => render_definition_list(out, d, level, options, state),
BlockNode::Figure(f) => render_figure(out, f, level, options, state),
+ BlockNode::FigureGroup(g) => render_figure_group(out, g, level, options, state),
BlockNode::AbbreviationDef(_) => {}
BlockNode::RawBlock(r) => {
if r.format == "html" {
@@ -2068,6 +2094,35 @@ fn render_figure(
) {
indent(out, level);
out.push_str(&format!("", render_attrs(&f.attrs)));
+ render_figure_contents(out, f, level, options, state);
+}
+
+/// The class-first attribute string a typed wrapper opens with: the structural
+/// class leads, the author's classes merge after it, then the id and remaining
+/// attributes in source order - the `admonition {kind}` convention, reused by
+/// the figure group and its panels (PART 9 §4c).
+fn class_first_attrs(base: &str, attrs: &Option) -> String {
+ let (class, rest) = match attrs {
+ Some(at) if !at.classes.is_empty() => (
+ dedup_class_str(&format!("{} {}", base, at.classes.join(" "))),
+ render_attrs_after_class(at),
+ ),
+ Some(at) => (base.to_string(), render_attrs_after_class(at)),
+ None => (base.to_string(), String::new()),
+ };
+ format!(" class=\"{}\"{}", escape_attr(&class), rest)
+}
+
+/// Everything of a figure after its opening tag: the target, the caption and
+/// the closing tag. Split out so a PANEL of a figure group renders the same
+/// body under its class-first opener.
+fn render_figure_contents(
+ out: &mut String,
+ f: &Figure,
+ level: usize,
+ options: &Options<'_>,
+ state: &mut RenderState,
+) {
out.push('\n');
match &f.target {
FigureTarget::Image(img) => {
@@ -2101,6 +2156,73 @@ fn render_figure(
out.push_str("");
}
+/// A composite figure (PART 9 §4c): one `` carrying the
+/// `carve-figure-group` class first, an UNCONDITIONAL `carve-figure-panels`
+/// div wrapping the children, and the group caption - when the closer hosted
+/// one - as the trailing ``. Panels are the `Figure` and `Table`
+/// children, in source order: a `Figure` renders its usual body under a
+/// class-first `carve-figure-panel` opener, a `Table` is wrapped in an
+/// explicit panel `` (a table does not render as a figure on its own)
+/// and keeps its own `