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 `` 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 ``. Everything else is preserved in place. +fn render_figure_group( + out: &mut String, + g: &FigureGroup, + level: usize, + options: &Options<'_>, + state: &mut RenderState, +) { + indent(out, level); + out.push_str(&format!( + "", + class_first_attrs("carve-figure-group", &g.attrs) + )); + out.push('\n'); + indent(out, level + 1); + out.push_str("
"); + for child in &g.children { + let mut piece = String::new(); + match child { + BlockNode::Figure(f) => { + indent(&mut piece, level + 2); + piece.push_str(&format!( + "", + class_first_attrs("carve-figure-panel", &f.attrs) + )); + render_figure_contents(&mut piece, f, level + 2, options, state); + } + BlockNode::Table(t) => { + indent(&mut piece, level + 2); + piece.push_str("
"); + piece.push('\n'); + render_table(&mut piece, t, level + 3, options, state); + piece.push('\n'); + indent(&mut piece, level + 2); + piece.push_str("
"); + } + // Preserved in place; a block that renders nothing (a comment, a + // definition line) contributes no blank line to the div. + other => render_block(&mut piece, other, level + 2, options, state), + } + if !piece.is_empty() { + out.push('\n'); + out.push_str(&piece); + } + } + out.push('\n'); + indent(out, level + 1); + out.push_str("
"); + if let Some(caption) = &g.caption { + out.push('\n'); + indent(out, level + 1); + out.push_str("
"); + render_inlines(out, caption, options, state); + out.push_str("
"); + } + out.push('\n'); + indent(out, level); + out.push_str("
"); +} + fn render_block_extension( out: &mut String, node: &BlockExtension, @@ -2546,8 +2668,13 @@ fn render_inline_after( } } InlineNode::CaptionNumber(n) => { - if let Some(number) = n.number { - out.push_str(&number.to_string()); + match n.number { + Some(number) => out.push_str(&number.to_string()), + // An unresolved placeholder stays the literal `#` the author + // wrote - the visible failure this language prefers to a + // silent one (PART 9 §4c names the panel-caption case), and + // what the Markdown, plain and terminal targets already emit. + None => out.push('#'), } } InlineNode::Mention(m) => { diff --git a/src/render_ansi.rs b/src/render_ansi.rs index d784b114..c2d7435d 100644 --- a/src/render_ansi.rs +++ b/src/render_ansi.rs @@ -231,6 +231,7 @@ fn render_block(node: &BlockNode, ctx: &mut AnsiContext, depth: usize) -> String render_definition_list(&list.items, ctx, true, depth + 1) } BlockNode::Figure(figure) => render_figure(figure, ctx, depth + 1), + BlockNode::FigureGroup(group) => render_figure_group(group, ctx, depth + 1), // Terminate the block image so the next block is not glued onto it. BlockNode::BlockImage(image) => format!("{}\n\n", render_image(image)), BlockNode::RawBlock(raw) => format!( @@ -533,12 +534,66 @@ fn table_row(cells: &[RenderedCell], widths: &[usize]) -> String { format!("{sep}{}{sep}\n", parts.join(&sep)) } +/// PART 11 §10g T2, same shape as the plain-text target: group caption first +/// (styled like every caption on this target), then each panel's caption line +/// over its host's usual degradation, stray content in place, a blank line +/// between the pieces. +fn render_figure_group(node: &FigureGroup, ctx: &mut AnsiContext, depth: usize) -> String { + if depth > MAX_RENDER_DEPTH { + crate::render_depth::record("ansi"); + return String::new(); + } + let caption_style = ITALIC.to_string() + DIM; + let mut parts: Vec = Vec::new(); + if let Some(caption) = &node.caption { + parts.push(style( + trim_non_nbsp(&render_block_inlines(caption, ctx)), + &caption_style, + )); + } + for child in &node.children { + match child { + BlockNode::Figure(figure) => { + let caption = style( + trim_non_nbsp(&render_block_inlines(&figure.caption, ctx)), + &caption_style, + ); + let target = render_figure_target(figure, ctx, depth); + parts.push(format!("{caption}\n{target}")); + } + other => { + let piece = render_block(other, ctx, depth); + let piece = piece.trim_end(); + if !piece.is_empty() { + parts.push(piece.to_string()); + } + } + } + } + if parts.is_empty() { + return String::new(); + } + format!("{}\n\n", parts.join("\n\n")) +} + fn render_figure(node: &Figure, ctx: &mut AnsiContext, depth: usize) -> String { if depth > MAX_RENDER_DEPTH { crate::render_depth::record("ansi"); return String::new(); } - let target = match &node.target { + let target = render_figure_target(node, ctx, depth); + let sep = match &node.target { + FigureTarget::BlockQuote(_) => "\n\n", + _ => "\n", + }; + format!("{target}{sep}{}", render_caption(&node.caption, ctx)) +} + +/// A figure's TARGET degraded on its own, without the caption - shared by the +/// plain figure and the figure group, whose panels put the caption FIRST +/// (§10g T2). +fn render_figure_target(node: &Figure, ctx: &mut AnsiContext, depth: usize) -> String { + match &node.target { FigureTarget::Image(image) => render_image(image), FigureTarget::Table(table) => render_table(table, ctx).trim_end().to_string(), FigureTarget::BlockQuote(quote) => { @@ -556,12 +611,7 @@ fn render_figure(node: &Figure, ctx: &mut AnsiContext, depth: usize) -> String { .trim_end() .to_string() } - }; - let sep = match &node.target { - FigureTarget::BlockQuote(_) => "\n\n", - _ => "\n", - }; - format!("{target}{sep}{}", render_caption(&node.caption, ctx)) + } } fn render_caption(nodes: &[InlineNode], ctx: &mut AnsiContext) -> String { diff --git a/src/render_carve.rs b/src/render_carve.rs index 49823849..09746e95 100644 --- a/src/render_carve.rs +++ b/src/render_carve.rs @@ -161,6 +161,7 @@ fn strip_generated_ids(blocks: &mut [BlockNode], had_any: &mut bool) { strip_generated_ids(&mut b.children, had_any); } } + BlockNode::FigureGroup(g) => strip_generated_ids(&mut g.children, had_any), BlockNode::DefinitionList(dl) => { for entry in dl.items.iter_mut() { for definition in entry.definitions.iter_mut() { @@ -192,6 +193,7 @@ fn collect_heading_ids(blocks: &[BlockNode], out: &mut Vec>) { collect_heading_ids(&b.children, out); } } + BlockNode::FigureGroup(g) => collect_heading_ids(&g.children, out), BlockNode::DefinitionList(dl) => { for entry in dl.items.iter() { for definition in entry.definitions.iter() { @@ -285,6 +287,7 @@ fn emptied_description_lines(blocks: &[BlockNode], into: &mut HashSet) { emptied_description_lines("e.children, into); } } + BlockNode::FigureGroup(group) => emptied_description_lines(&group.children, into), BlockNode::List(list) => { for item in &list.items { // A definition the author wrote BETWEEN two of an item's @@ -703,6 +706,14 @@ fn normalize_escapes_block(block: &mut BlockNode) { normalize_escapes_inlines(&mut f.caption); normalize_escapes_figure_target(f); } + BlockNode::FigureGroup(g) => { + if let Some(caption) = &mut g.caption { + normalize_escapes_inlines(caption); + } + for child in &mut g.children { + normalize_escapes_block(child); + } + } BlockNode::Extension(e) => { for child in &mut e.children { normalize_escapes_block(child); @@ -834,6 +845,12 @@ fn hosts_caption(block: &BlockNode) -> bool { | BlockNode::CodeBlock(_) | BlockNode::BlockQuote(_) | BlockNode::BlockImage(_) => true, + // The group's closer hosts the caption slot (§4c). With the slot + // already filled, a following `^ ` paragraph re-parses as a paragraph + // either way and §4 asks for the minimal form - so only an + // UNCAPTIONED group makes the escape necessary (corpus + // 318-composite-figures-6 is the detached shape that needs it). + BlockNode::FigureGroup(group) => group.caption.is_none(), BlockNode::Paragraph(paragraph) if paragraph.children.len() == 1 => { match ¶graph.children[0] { InlineNode::Image(image) => !image.src.is_empty(), @@ -1218,6 +1235,22 @@ fn render_block(node: &BlockNode, ctx: &mut CarveContext) -> String { &with_reset_colon_fence_depth(ctx, |ctx| render_definition_list(&list.items, ctx)), ), BlockNode::Figure(figure) => with_block_attrs(&figure.attrs, &render_figure(figure, ctx)), + BlockNode::FigureGroup(group) => { + // §10g: the authored form - the attribute line where attributes + // exist, the bare opener, the children, the closer at the opener's + // width, and the group caption as a `^ ` line AFTER the closer. + let fence = colon_fence_for(ctx); + let body = render_inside_colon_container(&group.children, ctx); + let caption = group + .caption + .as_ref() + .map(|caption| format!("\n^ {}", render_inlines(caption, ctx))) + .unwrap_or_default(); + with_block_attrs( + &group.attrs, + &format!("{fence} figure\n{body}\n{fence}{caption}"), + ) + } BlockNode::BlockImage(image) => render_image(image), BlockNode::RawBlock(raw) => { let fence = safe_fence(&raw.content, 3); diff --git a/src/render_markdown.rs b/src/render_markdown.rs index ccbd6d71..b887c9e2 100644 --- a/src/render_markdown.rs +++ b/src/render_markdown.rs @@ -322,6 +322,7 @@ fn render_block(node: &BlockNode, ctx: &mut MarkdownContext, depth: usize) -> St render_definition_list(&list.items, ctx, true, depth + 1) } BlockNode::Figure(figure) => render_figure(figure, ctx, depth + 1), + BlockNode::FigureGroup(group) => render_figure_group(group, ctx, depth + 1), // A standalone block image is its own block: terminate it so the next // block is not glued onto the image (render_image stays newline-free // because it is shared with inline image rendering). @@ -615,30 +616,41 @@ fn render_table(node: &Table, ctx: &mut MarkdownContext) -> String { out } -fn render_figure(node: &Figure, ctx: &mut MarkdownContext, depth: usize) -> String { +/// PART 11 §10g T1: Markdown has no figure grouping, so the group degrades to +/// its content in order - each panel's host as it already degrades, each panel +/// caption as an emphasized `*...*` paragraph after its host, preserved stray +/// content in place, and the group caption LAST as a bold `**...**` paragraph +/// with its number resolved. Emphasis and bold rather than invented syntax: +/// the spelling the admonition title already uses for authored text with no +/// native slot. +fn render_figure_group(node: &FigureGroup, ctx: &mut MarkdownContext, depth: usize) -> String { if depth > MAX_RENDER_DEPTH { crate::render_depth::record("markdown"); return String::new(); } - let target = match &node.target { - FigureTarget::Image(image) => render_image(image), - FigureTarget::Table(table) => render_table(table, ctx).trim().to_string(), - FigureTarget::BlockQuote(quote) => { - render_block(&BlockNode::BlockQuote(quote.clone()), ctx, depth + 1) - .trim() - .to_string() - } - FigureTarget::CodeBlock(cb) => { - render_block(&BlockNode::CodeBlock(cb.clone()), ctx, depth + 1) - .trim() - .to_string() - } - FigureTarget::Paragraph(p) => { - render_block(&BlockNode::Paragraph(p.clone()), ctx, depth + 1) - .trim() - .to_string() + let mut out = String::new(); + for child in &node.children { + match child { + BlockNode::Figure(figure) => { + let target = render_figure_target(figure, ctx, depth); + let caption = render_block_inlines(&figure.caption, ctx); + out.push_str(&format!("{target}\n\n*{caption}*\n\n")); + } + other => out.push_str(&render_block(other, ctx, depth)), } - }; + } + if let Some(caption) = &node.caption { + out.push_str(&format!("**{}**\n\n", render_block_inlines(caption, ctx))); + } + out +} + +fn render_figure(node: &Figure, ctx: &mut MarkdownContext, depth: usize) -> String { + if depth > MAX_RENDER_DEPTH { + crate::render_depth::record("markdown"); + return String::new(); + } + let target = render_figure_target(node, ctx, depth); // The caption sits on its own line directly under the figure (`\n`) - an // image target used to glue it on (`![a](/u)cap`). A blockquote target keeps // the blank-line separation. @@ -663,6 +675,31 @@ fn render_figure(node: &Figure, ctx: &mut MarkdownContext, depth: usize) -> Stri ) } +/// A figure's TARGET degraded on its own, without the caption - shared by the +/// plain figure (which glues its caption under it) and the figure group +/// (whose panel captions take the §10g T1 emphasized-paragraph form instead). +fn render_figure_target(node: &Figure, ctx: &mut MarkdownContext, depth: usize) -> String { + match &node.target { + FigureTarget::Image(image) => render_image(image), + FigureTarget::Table(table) => render_table(table, ctx).trim().to_string(), + FigureTarget::BlockQuote(quote) => { + render_block(&BlockNode::BlockQuote(quote.clone()), ctx, depth + 1) + .trim() + .to_string() + } + FigureTarget::CodeBlock(cb) => { + render_block(&BlockNode::CodeBlock(cb.clone()), ctx, depth + 1) + .trim() + .to_string() + } + FigureTarget::Paragraph(p) => { + render_block(&BlockNode::Paragraph(p.clone()), ctx, depth + 1) + .trim() + .to_string() + } + } +} + fn render_footnote_defs(doc: &Document, ctx: &mut MarkdownContext) -> String { let mut out = String::new(); // SOURCE ORDER, not label order (§7; carve-rs#686). The map is a BTreeMap. @@ -1627,6 +1664,12 @@ where FigureTarget::Image(_) | FigureTarget::CodeBlock(_) => {} } } + BlockNode::FigureGroup(group) => { + if let Some(caption) = &group.caption { + visit(block, Some(caption)); + } + walk_blocks(&group.children, depth + 1, visit); + } BlockNode::Extension(extension) => walk_blocks(&extension.children, depth + 1, visit), _ => {} } diff --git a/src/render_plain.rs b/src/render_plain.rs index 44b76638..41c70447 100644 --- a/src/render_plain.rs +++ b/src/render_plain.rs @@ -225,6 +225,7 @@ fn render_block(node: &BlockNode, depth: usize) -> String { } BlockNode::DefinitionList(list) => render_definition_list(&list.items, true, depth + 1), BlockNode::Figure(figure) => render_figure(figure, depth + 1), + BlockNode::FigureGroup(group) => render_figure_group(group, depth + 1), // Terminate the block image so the next block is not glued onto it. BlockNode::BlockImage(image) => format!("{}\n\n", render_image(image)), BlockNode::Extension(extension) => render_blocks(&extension.children, depth + 1), @@ -366,26 +367,47 @@ fn render_table(node: &Table) -> String { out } -fn render_figure(node: &Figure, depth: usize) -> String { +/// PART 11 §10g T2: the group caption comes FIRST, its number resolved - on a +/// caption-less target it is the only line that says what the following blocks +/// are one of - then each panel as its caption line over its host's usual +/// degradation, stray content in place, a blank line between the pieces. +fn render_figure_group(node: &FigureGroup, depth: usize) -> String { if depth > MAX_RENDER_DEPTH { crate::render_depth::record("plain"); return String::new(); } - let target = match &node.target { - FigureTarget::Image(image) => render_image(image), - FigureTarget::Table(table) => render_table(table).trim().to_string(), - FigureTarget::BlockQuote(quote) => { - render_block(&BlockNode::BlockQuote(quote.clone()), depth + 1) - .trim() - .to_string() + let mut parts: Vec = Vec::new(); + if let Some(caption) = &node.caption { + parts.push(trim_non_nbsp(&render_inlines(caption)).to_string()); + } + for child in &node.children { + match child { + BlockNode::Figure(figure) => { + let caption = trim_non_nbsp(&render_inlines(&figure.caption)).to_string(); + let target = render_figure_target(figure, depth); + parts.push(format!("{caption}\n{target}")); + } + other => { + let piece = render_block(other, depth); + let piece = piece.trim_end(); + if !piece.is_empty() { + parts.push(piece.to_string()); + } + } } - FigureTarget::CodeBlock(cb) => render_block(&BlockNode::CodeBlock(cb.clone()), depth + 1) - .trim() - .to_string(), - FigureTarget::Paragraph(p) => render_block(&BlockNode::Paragraph(p.clone()), depth + 1) - .trim() - .to_string(), - }; + } + if parts.is_empty() { + return String::new(); + } + format!("{}\n\n", parts.join("\n\n")) +} + +fn render_figure(node: &Figure, depth: usize) -> String { + if depth > MAX_RENDER_DEPTH { + crate::render_depth::record("plain"); + return String::new(); + } + let target = render_figure_target(node, depth); // The caption sits on its own line directly under the figure (`\n`) - an // image target used to glue it on. A blockquote target keeps the blank-line // separation. End with the block separator so a following block is not glued @@ -404,6 +426,27 @@ fn render_figure(node: &Figure, depth: usize) -> String { format!("{target}{sep}{}\n\n", render_inlines(&node.caption)) } +/// A figure's TARGET degraded on its own, without the caption - shared by the +/// plain figure and the figure group, whose panels put the caption FIRST +/// (§10g T2). +fn render_figure_target(node: &Figure, depth: usize) -> String { + match &node.target { + FigureTarget::Image(image) => render_image(image), + FigureTarget::Table(table) => render_table(table).trim().to_string(), + FigureTarget::BlockQuote(quote) => { + render_block(&BlockNode::BlockQuote(quote.clone()), depth + 1) + .trim() + .to_string() + } + FigureTarget::CodeBlock(cb) => render_block(&BlockNode::CodeBlock(cb.clone()), depth + 1) + .trim() + .to_string(), + FigureTarget::Paragraph(p) => render_block(&BlockNode::Paragraph(p.clone()), depth + 1) + .trim() + .to_string(), + } +} + fn render_footnote_defs(doc: &Document) -> String { let mut out = String::new(); // SOURCE ORDER, not label order (§7; carve-rs#686). The map is a BTreeMap. diff --git a/src/render_text.rs b/src/render_text.rs index de1d7f87..640b3fe3 100644 --- a/src/render_text.rs +++ b/src/render_text.rs @@ -97,6 +97,12 @@ fn collect_block(block: &BlockNode, suppressed: bool, out: &mut ConsumedAbbrevia } collect_inlines(&f.caption, suppressed, out); } + BlockNode::FigureGroup(g) => { + collect_blocks(&g.children, suppressed, out); + if let Some(caption) = &g.caption { + collect_inlines(caption, suppressed, out); + } + } BlockNode::Extension(e) => collect_blocks(&e.children, suppressed, out), // A code block's text is never abbreviation-expanded, and the remaining // block kinds carry no inline children at all. diff --git a/src/wire_fields.rs b/src/wire_fields.rs index 63d4d1af..2338ce4b 100644 --- a/src/wire_fields.rs +++ b/src/wire_fields.rs @@ -28,6 +28,7 @@ pub(crate) const WIRE_FIELDS: &[(&str, &[&str])] = &[ ("emphasis", &["attrs", "children", "pos", "type"]), ("escaped_text", &["attrs", "pos", "type", "value"]), ("figure", &["attrs", "caption", "pos", "shortCaption", "target", "type"]), + ("figure_group", &["attrs", "caption", "children", "pos", "type"]), ("footnote", &["attrs", "children", "label", "pos", "type"]), ("footnote_ref", &["attrs", "id", "number", "pos", "type"]), ("frontmatter", &["content", "format", "pos", "type"]), diff --git a/tests/a_figure_group_degrades_deterministically.rs b/tests/a_figure_group_degrades_deterministically.rs new file mode 100644 index 00000000..a8938762 --- /dev/null +++ b/tests/a_figure_group_degrades_deterministically.rs @@ -0,0 +1,94 @@ +//! A composite figure survives every target, degraded the SAME way everywhere. +//! +//! PART 11 §10g: Markdown emits the panels in order, each panel caption as an +//! emphasized `*...*` paragraph after its host, and the group caption LAST as +//! a bold `**...**` paragraph with its number resolved. Plain text and the +//! terminal put the group caption FIRST - on a caption-less target it is the +//! only line that says what the following blocks are one of - then each +//! panel's caption line over its host's degradation. Stray content is +//! preserved in place on every target; nothing is silently dropped. + +const GROUP: &str = "\ +{#fig-x} +::: figure +{#fig-x-a} +![one](a.png) +^ (a) One + +Shot on the same day. + +{#fig-x-b} +![two](b.png) +^ (b) Two +::: +^ Figure #: Group caption +"; + +#[test] +fn markdown_puts_the_group_caption_last_in_bold() { + assert_eq!( + carve::to_markdown(GROUP), + "\ +![one](a.png) + +*(a) One* + +Shot on the same day. + +![two](b.png) + +*(b) Two* + +**Figure 1: Group caption** +" + ); +} + +#[test] +fn plain_text_puts_the_group_caption_first() { + assert_eq!( + carve::to_plain_text(GROUP), + "\ +Figure 1: Group caption + +(a) One +one + +Shot on the same day. + +(b) Two +two +" + ); +} + +#[test] +fn the_terminal_orders_like_plain_text() { + let out = carve::to_ansi(GROUP); + let group = out + .find("Figure 1: Group caption") + .expect("the group caption"); + let first_panel = out.find("(a) One").expect("the first panel caption"); + let stray = out + .find("Shot on the same day.") + .expect("the stray paragraph"); + let second_panel = out.find("(b) Two").expect("the second panel caption"); + assert!(group < first_panel, "{out:?}"); + assert!(first_panel < stray, "{out:?}"); + assert!(stray < second_panel, "{out:?}"); +} + +#[test] +fn an_uncaptioned_table_panel_degrades_as_a_table() { + let source = "\ +::: figure +| Kind | N | +|------|---| +| a | 1 | +::: +^ Figure #: Mixed +"; + let plain = carve::to_plain_text(source); + assert!(plain.starts_with("Figure 1: Mixed\n\n"), "{plain}"); + assert!(plain.contains("Kind"), "the table was dropped: {plain}"); +} diff --git a/tests/a_figure_group_imports_its_own_html.rs b/tests/a_figure_group_imports_its_own_html.rs new file mode 100644 index 00000000..d5a2cf87 --- /dev/null +++ b/tests/a_figure_group_imports_its_own_html.rs @@ -0,0 +1,77 @@ +//! The HTML importer reads this engine's own composite-figure shape back. +//! +//! `
` returns as a `figure_group`: the +//! structural classes come off, the panels div unwraps, each +//! `carve-figure-panel` figure rebuilds the figure it wrapped, and the +//! trailing `
` is the group caption - closing the render/import +//! round trip for PART 9 §4c output. A `
` WITHOUT the own-output +//! class is still an unsupported element and keeps the pre-§4c treatment. + +fn round_trip(source: &str) -> String { + let html = carve::to_html(source); + let result = carve::html_to_carve(&html, &carve::HtmlImportOptions::default()) + .expect("own output imports"); + carve::to_html(&result.value) +} + +#[test] +fn the_basic_group_round_trips_to_the_same_html() { + // The rendered caption carries the RESOLVED number, so the re-imported + // document captions the group with that literal text - and renders the + // same bytes. + let source = "\ +{#fig-x .columns-2} +::: figure +{#fig-x-a} +![one](a.png) +^ (a) One + +{#fig-x-b} +![two](b.png) +^ (b) Two +::: +^ Figure #: Group caption +"; + assert_eq!(round_trip(source), carve::to_html(source)); +} + +#[test] +fn stray_content_and_an_uncaptioned_group_survive() { + let source = "\ +::: figure +Shot on the same day. + +![one](a.png) +^ (a) One +::: +"; + assert_eq!(round_trip(source), carve::to_html(source)); +} + +#[test] +fn a_wrapped_table_panel_unwraps_to_the_table() { + let source = "\ +::: figure +| Kind | N | +|------|---| +| a | 1 | +::: +^ Figure #: Mixed +"; + assert_eq!(round_trip(source), carve::to_html(source)); +} + +#[test] +fn a_foreign_figure_is_still_unsupported() { + // No own-output class: the importer must not guess a group out of it. + let result = carve::html_to_carve( + "
\"one\"
One
", + &carve::HtmlImportOptions::default(), + ) + .expect("imports"); + assert!( + !result.value.contains("::: figure"), + "a foreign figure became a group: {}", + result.value + ); +} diff --git a/tests/a_figure_group_is_one_numbering_unit.rs b/tests/a_figure_group_is_one_numbering_unit.rs new file mode 100644 index 00000000..d7d8d5f9 --- /dev/null +++ b/tests/a_figure_group_is_one_numbering_unit.rs @@ -0,0 +1,114 @@ +//! A composite figure draws ONE number, and its panels draw letters off it. +//! +//! PART 9 §4c (markup-carve/carve#1122): the group caption's `#` takes one +//! number from its label's sequence, like any figure; the panels take NOTHING +//! from the document sequence. A panel with an id resolves `` as the +//! group's number plus a letter by panel order among the panels - and only +//! when the group itself drew a number; an unnumbered group's panels stay +//! plain anchors, exactly as an id on an uncaptioned figure does. + +const TWO_GROUPS: &str = "\ +{#fig-first} +![lead](lead.png) +^ Figure #: First + +{#fig-x} +::: figure +{#fig-x-a} +![one](a.png) +^ (a) One + +{#fig-x-b} +![two](b.png) +^ (b) Two +::: +^ Figure #: Second + +See and and . +"; + +#[test] +fn the_group_takes_one_number_from_the_shared_sequence() { + let html = carve::to_html(TWO_GROUPS); + assert!(html.contains("Figure 1: First"), "{html}"); + assert!(html.contains("Figure 2: Second"), "{html}"); + assert!( + !html.contains("Figure 3"), + "panels drew from the sequence: {html}" + ); +} + +#[test] +fn a_panel_id_resolves_with_the_group_number_and_a_letter() { + let html = carve::to_html(TWO_GROUPS); + assert!(html.contains("Figure 2"), "{html}"); + assert!( + html.contains("Figure 2a"), + "{html}" + ); + assert!( + html.contains("Figure 2b"), + "{html}" + ); +} + +#[test] +fn the_letter_counts_panels_not_children() { + // A stray paragraph between the panels is preserved content, not a panel, + // so it must not advance the letter. + let source = "\ +{#g} +::: figure +A note between panels. + +{#p-one} +![one](a.png) +^ (a) One + +{#p-two} +![two](b.png) +^ (b) Two +::: +^ Figure #: Group + +See . +"; + let html = carve::to_html(source); + assert!(html.contains("Figure 1b"), "{html}"); +} + +#[test] +fn an_unnumbered_group_registers_no_panel_letters() { + // No `#` in the group caption: the group never enters a counter, so its + // panel ids are anchors but not caption crossref targets. + let source = "\ +::: figure +{#p} +![one](a.png) +^ (a) One +::: +^ Just a caption + +See . +"; + let html = carve::to_html(source); + assert!( + !html.contains(">Figure"), + "a panel letter appeared without a group number: {html}" + ); +} + +#[test] +fn the_numbers_survive_the_json_ingest_path() { + // PART 12 §5/§6: numbering re-derives on ingest through the same pass the + // parse runs, so the two paths cannot disagree about the letters. + let doc = carve::parse(TWO_GROUPS); + let json = carve::to_json(&doc); + let back = carve::from_json(&json).expect("own output decodes"); + assert_eq!(carve::to_json(&back), json); + let html = carve::render_html(&back).expect("renders"); + assert!( + html.contains("Figure 2a"), + "{html}" + ); +} diff --git a/tests/a_figure_group_serializes_as_its_own_type.rs b/tests/a_figure_group_serializes_as_its_own_type.rs new file mode 100644 index 00000000..4c834e42 --- /dev/null +++ b/tests/a_figure_group_serializes_as_its_own_type.rs @@ -0,0 +1,88 @@ +//! A composite figure serializes as `figure_group`, discriminated by TYPE. +//! +//! PART 12 §16: `children` in source order (no second `panels` key to +//! disagree with them), `caption` present only when the closer hosted one - +//! absent means uncaptioned, never an empty placeholder - and no `target`, no +//! title, no label, no `shortCaption`. Ingest is closed like every node: an +//! unknown field on a `figure_group` is refused (§11). + +fn published(source: &str) -> String { + carve::to_json(&carve::parse(source)) +} + +#[test] +fn the_wire_shape_is_the_schema_shape() { + // A stray-content group, so the payload holds no panel figure: any + // `target` in it could only be the group's, and the group has none. + let json = published("::: figure\nstray\n:::\n^ Figure #: G\n"); + assert!(json.contains("\"type\":\"figure_group\""), "{json}"); + assert!(json.contains("\"children\":["), "{json}"); + assert!(json.contains("\"caption\":["), "{json}"); + assert!(!json.contains("\"target\""), "{json}"); +} + +#[test] +fn an_uncaptioned_group_publishes_no_caption_key() { + let json = published("::: figure\n![one](a.png)\n^ (a) One\n:::\n"); + assert!(json.contains("\"type\":\"figure_group\""), "{json}"); + assert!(!json.contains("\"caption\":[]"), "{json}"); + // The panel's own caption is the only `caption` in the payload. + let group_at = json.find("figure_group").expect("the group"); + assert!( + !json[group_at..json.find("\"figure\"").unwrap_or(json.len())].contains("\"caption\""), + "{json}" + ); +} + +#[test] +fn the_round_trip_holds_with_positions() { + // PART 12 §6: parse(x) serialized and deserialized equals parse(x). + let source = "{#g .columns-2}\n::: figure\n![one](a.png)\n^ (a) One\n:::\n^ Figure #: G\n"; + let doc = carve::parse_with_options(source, &carve::Options::default().with_positions(true)); + let json = carve::to_json(&doc); + let decoded = carve::from_json(&json).expect("own output decodes"); + assert_eq!(carve::to_json(&decoded), json); +} + +#[test] +fn ingest_refuses_a_field_the_schema_does_not_name() { + let payload = r#"{"type":"document","children":[{"type":"figure_group","children":[],"panels":[]}],"srcByteLength":0}"#; + let err = carve::from_json(payload).expect_err("`panels` is not a schema field"); + assert!(err.to_string().contains("panels"), "{err}"); +} + +#[test] +fn ingest_requires_children() { + let payload = r#"{"type":"document","children":[{"type":"figure_group"}],"srcByteLength":0}"#; + assert!(carve::from_json(payload).is_err()); +} + +#[test] +fn a_hand_built_group_renders_through_the_ingest_path() { + // A group the parse never made - stray content only, no panels - still + // renders the unconditional panels div (§4c). + let payload = r#"{"type":"document","children":[{"type":"figure_group","children":[{"type":"paragraph","children":[{"type":"text","value":"stray"}]}]}],"srcByteLength":0}"#; + let doc = carve::from_json(payload).expect("decodes"); + assert_eq!( + carve::render_html(&doc).expect("renders"), + "
\n
\n

stray

\n
\n
" + ); +} + +#[test] +fn a_denied_figure_group_strips_with_its_shell() { + let doc = carve::parse("::: figure\n![one](a.png)\n^ (a) One\n:::\n^ Figure #: G\n"); + let profile = carve::Profile::full() + .deny_block(&["figure_group"]) + .on_disallowed(carve::DisallowedAction::Strip); + let result = carve::apply_profile(doc, &profile, None).expect("strip mode never errors"); + assert!( + result + .violations + .iter() + .any(|v| v.node_type == "figure_group"), + "{:?}", + result.violations + ); + assert!(result.doc.children.is_empty(), "{:?}", result.doc.children); +} diff --git a/tests/a_figure_group_writes_back_as_authored.rs b/tests/a_figure_group_writes_back_as_authored.rs new file mode 100644 index 00000000..57421954 --- /dev/null +++ b/tests/a_figure_group_writes_back_as_authored.rs @@ -0,0 +1,133 @@ +//! The canonical writer emits a composite figure in the authored form. +//! +//! PART 11 §10g: the attribute line where attributes exist, the bare +//! `::: figure` opener, 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 with its `#` written back. The caret after a group closer is a +//! CAPTION POSITION, not text, so the writer must not spell it `\^ ` - while +//! a paragraph a blank line pair DETACHED from the closer keeps its escape, +//! because bare it would re-parse as the group caption. + +const AUTHORED: &str = "\ +{#fig-x .columns-2} +::: figure +{#fig-x-a} +![one](a.png) +^ (a) One + +{#fig-x-b} +![two](b.png) +^ (b) Two +::: +^ Figure #: Group caption +"; + +#[test] +fn fmt_reproduces_the_authored_form() { + assert_eq!(carve::to_carve(AUTHORED), AUTHORED); +} + +#[test] +fn fmt_is_idempotent_and_html_stable() { + let once = carve::to_carve(AUTHORED); + assert_eq!(carve::to_carve(&once), once); + assert_eq!(carve::to_html(&once), carve::to_html(AUTHORED)); +} + +#[test] +fn a_detached_caption_keeps_its_escape() { + // Corpus 318-composite-figures-6: two blank lines detach, and the `^ ` + // line is an ordinary paragraph. Written bare after the closer it would + // re-parse as the group caption, so the writer owes it the escape - and + // the round trip must hold. + let source = "\ +::: figure +![one](a.png) +^ (a) One +::: + + +^ Figure #: Detached +"; + let formatted = carve::to_carve(source); + assert!(formatted.contains("\\^ Figure #: Detached"), "{formatted}"); + assert_eq!(carve::to_html(&formatted), carve::to_html(source)); + assert_eq!(carve::to_carve(&formatted), formatted); +} + +#[test] +fn a_captioned_group_needs_no_escape_on_a_following_caret() { + // The slot is filled: a `^ ` paragraph after a captioned group re-parses + // as a paragraph bare, and PART 11 §4 asks for the minimal form. + let source = "\ +::: figure +![one](a.png) +^ (a) One +::: +^ Figure #: Group + +^ Not a caption +"; + let formatted = carve::to_carve(source); + assert!( + !formatted.contains("\\^ Not a caption"), + "over-escaped: {formatted}" + ); + assert_eq!(carve::to_html(&formatted), carve::to_html(source)); +} + +#[test] +fn the_metadata_spellings_round_trip_as_containers() { + // Corpus 318-composite-figures-8: a titled or labeled `::: figure` is a + // generic container and must write back as one. + let source = "\ +::: figure \"A titled figure div\" +![one](a.png) +^ (a) One +::: + +::: figure [g] +Body. +::: +"; + let formatted = carve::to_carve(source); + assert!( + formatted.contains("::: figure \"A titled figure div\""), + "{formatted}" + ); + assert!(formatted.contains("::: figure [g]"), "{formatted}"); + assert_eq!(carve::to_html(&formatted), carve::to_html(source)); +} + +#[test] +fn a_nested_demoted_figure_round_trips() { + // Corpus 318-composite-figures-9: the inner bare opener is a generic + // container; the writer's inward-widening fences must reproduce a parse + // with the same shape. + let source = "\ +::: figure +:::: figure +![one](a.png) +^ (a) One +:::: +::: +^ Figure #: Outer only +"; + let formatted = carve::to_carve(source); + assert_eq!(carve::to_html(&formatted), carve::to_html(source)); + assert_eq!(carve::to_carve(&formatted), formatted); +} + +#[test] +fn an_uncaptioned_group_round_trips() { + let source = "\ +::: figure +![one](a.png) +^ (a) One + +![two](b.png) +^ (b) Two +::: +"; + assert_eq!(carve::to_carve(source), source); +} diff --git a/tests/a_figure_spelling_that_is_not_a_group_lints.rs b/tests/a_figure_spelling_that_is_not_a_group_lints.rs new file mode 100644 index 00000000..0a1ec757 --- /dev/null +++ b/tests/a_figure_spelling_that_is_not_a_group_lints.rs @@ -0,0 +1,86 @@ +//! The `::: figure` spellings that are NOT a composite figure are diagnosed. +//! +//! PART 9 §4c, LINT NOT PARSE: an opener carrying a quoted title or a +//! `[label]` stays a generic container (`figure-group-opener-metadata`), and a +//! bare opener nested inside an open group's body - at any depth - stays one +//! too (`figure-group-nested`). Both are warnings over a valid parse; the +//! parse itself is pinned by corpus 318-composite-figures-8 and -9. + +#[test] +fn an_opener_with_a_title_reports_opener_metadata() { + let warnings = carve::lint_carve("::: figure \"A titled figure div\"\nBody.\n:::\n"); + assert!( + warnings + .iter() + .any(|w| w.rule == "figure-group-opener-metadata"), + "{warnings:?}" + ); +} + +#[test] +fn an_opener_with_a_label_reports_opener_metadata() { + let warnings = carve::lint_carve("::: figure [g]\nBody.\n:::\n"); + assert!( + warnings + .iter() + .any(|w| w.rule == "figure-group-opener-metadata"), + "{warnings:?}" + ); +} + +#[test] +fn a_nested_bare_opener_reports_nested() { + let source = "\ +::: figure +:::: figure +![one](a.png) +^ (a) One +:::: +::: +^ Figure #: Outer only +"; + let warnings = carve::lint_carve(source); + assert!( + warnings.iter().any(|w| w.rule == "figure-group-nested"), + "{warnings:?}" + ); +} + +#[test] +fn the_nesting_rule_reaches_any_depth() { + // Through an intermediate generic div: still inside the open group's body. + let source = "\ +::: figure +:::: note +::::: figure +text +::::: +:::: +::: +"; + let warnings = carve::lint_carve(source); + assert!( + warnings.iter().any(|w| w.rule == "figure-group-nested"), + "{warnings:?}" + ); +} + +#[test] +fn a_bare_group_outside_a_group_lints_clean() { + let source = "\ +::: figure +![one](a.png) +^ (a) One +::: +^ Figure #: Group +"; + assert_eq!(carve::lint_carve(source), vec![]); +} + +#[test] +fn a_generic_kind_reports_nothing() { + // `::: sidebar "T"` is an ordinary titled container; only the reserved + // kind word gets the diagnostic. + let warnings = carve::lint_carve("::: sidebar \"T\"\nBody.\n:::\n"); + assert_eq!(warnings, vec![]); +} diff --git a/tests/a_panel_caption_number_stays_literal.rs b/tests/a_panel_caption_number_stays_literal.rs new file mode 100644 index 00000000..d5bc0fa8 --- /dev/null +++ b/tests/a_panel_caption_number_stays_literal.rs @@ -0,0 +1,75 @@ +//! A `#` placeholder in a PANEL caption stays the literal `#` the author wrote. +//! +//! PART 9 §4c: panels are not sequence units, so a placeholder there has +//! nothing to resolve against. It stays LITERAL - the visible failure this +//! language prefers to a silent one - and `carve lint` reports it as +//! `figure-group-panel-number`. The node stays a `caption_number` without a +//! number, the same keep-the-typed-node discipline PART 12 §3a applies to an +//! unresolved reference, so the wire and the writer keep the authored `#`. + +const PANEL_PLACEHOLDER: &str = "\ +::: figure +![one](a.png) +^ Panel #: One +::: +^ Figure #: Group +"; + +#[test] +fn the_html_target_keeps_the_literal_hash() { + let html = carve::to_html(PANEL_PLACEHOLDER); + assert!( + html.contains("
Panel #: One
"), + "{html}" + ); + assert!( + html.contains("
Figure 1: Group
"), + "{html}" + ); +} + +#[test] +fn the_non_html_targets_agree() { + let markdown = carve::to_markdown(PANEL_PLACEHOLDER); + assert!(markdown.contains("*Panel #: One*"), "{markdown}"); + let plain = carve::to_plain_text(PANEL_PLACEHOLDER); + assert!(plain.contains("Panel #: One"), "{plain}"); +} + +#[test] +fn the_placeholder_never_advances_a_counter() { + // The panel's label bucket must not exist: a later REAL `Panel #:` caption + // outside the group starts at 1. + let source = format!("{PANEL_PLACEHOLDER}\n```\ncode\n```\n^ Panel #: Real\n"); + let html = carve::to_html(&source); + assert!(html.contains("Panel 1: Real"), "{html}"); +} + +#[test] +fn lint_reports_figure_group_panel_number() { + let warnings = carve::lint_carve(PANEL_PLACEHOLDER); + assert!( + warnings + .iter() + .any(|w| w.rule == "figure-group-panel-number"), + "{warnings:?}" + ); +} + +#[test] +fn a_group_without_a_panel_placeholder_lints_clean() { + let source = "\ +::: figure +![one](a.png) +^ (a) One +::: +^ Figure #: Group +"; + assert_eq!(carve::lint_carve(source), vec![]); +} + +#[test] +fn the_writer_writes_the_hash_back() { + let out = carve::to_carve(PANEL_PLACEHOLDER); + assert!(out.contains("^ Panel #: One"), "{out}"); +} diff --git a/tests/adjacent_text_runs.rs b/tests/adjacent_text_runs.rs index d83cf31d..d3bb40dd 100644 --- a/tests/adjacent_text_runs.rs +++ b/tests/adjacent_text_runs.rs @@ -354,6 +354,12 @@ fn walk_block<'a>( } } BlockNode::Figure(n) => inline_lists.push(&n.caption), + BlockNode::FigureGroup(n) => { + if let Some(caption) = &n.caption { + inline_lists.push(caption); + } + child_blocks.extend(n.children.iter()); + } BlockNode::Extension(n) => { if let Some(summary) = &n.summary { inline_lists.push(summary); diff --git a/tests/an_unterminated_figure_group_closes_at_end_of_input.rs b/tests/an_unterminated_figure_group_closes_at_end_of_input.rs new file mode 100644 index 00000000..0cc65794 --- /dev/null +++ b/tests/an_unterminated_figure_group_closes_at_end_of_input.rs @@ -0,0 +1,50 @@ +//! A group left open at end of input closes there - and has no caption slot. +//! +//! PART 9 §4c defers body and closer discipline to §12's container rules: an +//! unterminated `::: figure` closes at end of input like any container. The +//! caption slot hangs on the CLOSING fence, and that line was never written, +//! so an end-of-input group has no caption position - on the ordinary path +//! and on the closer-free ladder fast path alike. + +#[test] +fn the_body_still_forms_panels() { + let html = carve::to_html("::: figure\n![one](a.png)\n^ (a) One\n"); + assert_eq!( + html, + "
\n
\n
\n \"one\"\n
(a) One
\n
\n
\n
" + ); +} + +#[test] +fn the_ladder_fast_path_builds_the_same_group() { + // No closer anywhere: the EOF-closed colon ladder is a separate + // construction site and must agree (its guard is positions OFF). + let html = carve::to_html("::: figure\n:::: note\ntext\n"); + assert_eq!( + html, + "
\n
\n \n
\n
" + ); +} + +#[test] +fn a_nested_bare_opener_demotes_on_the_ladder_too() { + let html = carve::to_html("::: figure\n:::: figure\ntext\n"); + assert_eq!( + html, + "
\n
\n
\n

text

\n
\n
\n
" + ); +} + +#[test] +fn both_paths_agree_with_positions_on() { + // The ladder runs only with positions off; the ordinary path must build + // the identical tree with them on. + let source = "::: figure\n:::: figure\ntext\n"; + let plain = carve::to_html(source); + let with_positions = carve::render_html(&carve::parse_with_options( + source, + &carve::Options::default().with_positions(true), + )) + .expect("renders"); + assert_eq!(plain, with_positions); +} diff --git a/tests/ast_json.rs b/tests/ast_json.rs index 4447742e..f107930b 100644 --- a/tests/ast_json.rs +++ b/tests/ast_json.rs @@ -161,6 +161,12 @@ fn normalize(mut doc: carve::Document) -> carve::Document { blocks(&mut q.children); } } + carve::BlockNode::FigureGroup(g) => { + blocks(&mut g.children); + if let Some(caption) = &mut g.caption { + inlines(caption); + } + } _ => {} } } diff --git a/tests/corpus.rs b/tests/corpus.rs index f6dda046..42ac537d 100644 --- a/tests/corpus.rs +++ b/tests/corpus.rs @@ -387,6 +387,7 @@ const IMPLEMENTED: &[&str] = &[ "an-editorial-comment-s-bracket-is-content-not-the-close", "an-image-s-alt-text-closes-where-a-link-s-text-closes", "an-inline-note-s-content-resolves-after-the-note", + "composite-figures", ]; fn corpus_dir() -> PathBuf { diff --git a/tests/position_spans_match_source.rs b/tests/position_spans_match_source.rs index 3cecf463..7d04db48 100644 --- a/tests/position_spans_match_source.rs +++ b/tests/position_spans_match_source.rs @@ -219,6 +219,15 @@ fn check( }); } } + BlockNode::FigureGroup(group) => { + if let Some(caption) = &group.caption { + check_inline_nodes(caption, source, file, checked_inline_text, wrong); + } + group + .children + .iter() + .for_each(|c| check(c, source, file, checked_blocks, checked_inline_text, wrong)); + } _ => {} } } @@ -236,6 +245,7 @@ fn block_pos(block: &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::LinkReferenceDefinition(n) => n.pos.as_ref(), BlockNode::AbbreviationDef(n) => n.pos.as_ref(), BlockNode::RawBlock(n) => n.pos.as_ref(), diff --git a/tests/prosemirror_bridge.rs b/tests/prosemirror_bridge.rs index 438814d5..dc6725a0 100644 --- a/tests/prosemirror_bridge.rs +++ b/tests/prosemirror_bridge.rs @@ -272,8 +272,15 @@ fn fully_covered_corpus_documents_round_trip_through_prosemirror() { // document, so a change that quietly moved hundreds of documents out of // the strict set - by reporting a type as dropped rather than carrying it - // would not fail anything. Raise these when the numbers improve. - const STRICT: usize = 791; - const LOSSY: usize = 215; + // + // 791/215 to 793/224 is the eleven composite-figure documents arriving with + // the spec pin, and nothing else: the corpus went from 1006 pairs to 1017, + // the eleven added pairs are all `318-composite-figures*`, and no existing + // pair changed content. Two of them hold no node the editor schema lacks + // and round-trip strictly; the other nine carry a `figure_group`, which + // degrades to the generic container and is reported. + const STRICT: usize = 793; + const LOSSY: usize = 224; assert!( covered >= STRICT, "strict round trips fell from {STRICT} to {covered}" diff --git a/tests/spec b/tests/spec index 0c9e8e8c..3154f1a4 160000 --- a/tests/spec +++ b/tests/spec @@ -1 +1 @@ -Subproject commit 0c9e8e8c34453b2d3cc050904f01875368183708 +Subproject commit 3154f1a403003c38af9d0569cc553ee9facab5c9