diff --git a/spec b/spec index 0c9e8e8c..9cd27d86 160000 --- a/spec +++ b/spec @@ -1 +1 @@ -Subproject commit 0c9e8e8c34453b2d3cc050904f01875368183708 +Subproject commit 9cd27d86da9c3e4e51d1f483e03b15667c78c537 diff --git a/src/ast.ts b/src/ast.ts index 7a992d37..913f90f8 100644 --- a/src/ast.ts +++ b/src/ast.ts @@ -380,6 +380,27 @@ export interface Figure extends BaseNode { shortCaption?: InlineNode[] } +/** + * A composite figure: a bare `::: figure` fence (PART 9 §4c). + * + * `children` are ordinary blocks in source order; the PANELS are the `figure` + * and `table` nodes among them, derived by type rather than repeated under a + * second key, so the two can never disagree. `caption` is the group caption + * (the `^ ` line after the CLOSING fence); absent means uncaptioned, not empty. + * + * Discriminated by `type`, deliberately: every `figure` carries a `target`, + * the group does not, and a consumer probing for the missing field instead of + * reading the type string would break silently the day either shape grows a + * field. No `title`, no `label`, no `shortCaption` - that design space belongs + * to markup-carve/carve#1118 and markup-carve/carve#1121 and is not claimed + * here. + */ +export interface FigureGroup extends BaseNode { + type: 'figure_group' + children: BlockNode[] + caption?: InlineNode[] +} + export interface AbbreviationDef extends BaseNode { type: 'abbreviation_def' abbr: string @@ -436,6 +457,7 @@ export type BlockNode = | LineBlock | DefinitionList | Figure + | FigureGroup | Image | AbbreviationDef | LinkReferenceDefinition diff --git a/src/footnote-numbering.ts b/src/footnote-numbering.ts index 2aee8f06..863aca8c 100644 --- a/src/footnote-numbering.ts +++ b/src/footnote-numbering.ts @@ -101,6 +101,10 @@ function walkBlockInlines( if (node.target.type === 'block_quote' || node.target.type === 'table') walkBlockInlines(node.target, visit, depth + 1) break + case 'figure_group': + if (node.caption) visit(node.caption) + node.children.forEach((c) => walkBlockInlines(c, visit, depth + 1)) + break default: break } diff --git a/src/heading-ids.ts b/src/heading-ids.ts index 82790a57..4db2cc96 100644 --- a/src/heading-ids.ts +++ b/src/heading-ids.ts @@ -14,8 +14,10 @@ import type { CaptionNumber, Document, Figure, + FigureGroup, Image, InlineNode, + Table, Text, } from './ast.js' import { SMART_PUNCTUATION_GLYPHS } from './ast.js' @@ -649,6 +651,9 @@ export function resolveHeadingIds( case 'figure': if (b.target.type === 'block_quote') assignIds(b.target.children, true) break + case 'figure_group': + assignIds(b.children, inBlockquote) + break default: break } @@ -992,6 +997,10 @@ export function resolveHeadingIds( if (b.target.type === 'block_quote' || b.target.type === 'table') walkBlock(b.target, fn) break + case 'figure_group': + if (b.caption) fn(b.caption) + b.children.forEach((c) => walkBlock(c, fn)) + break default: break } @@ -1014,18 +1023,19 @@ export function resolveHeadingIds( // these on an ingested tree (carve#758). What stays here is the crossref // target registration, which only makes sense while resolution is running. const numberBlocks = (blocks: BlockNode[]): void => { - numberCaptionsIn(blocks, counters, (labelNodes, next, attrs) => { + numberCaptionsIn(blocks, counters, (labelNodes, next, attrs, suffix) => { const id = attrs?.id if (id === undefined || targets.has(id)) return // Clean "Label N" auto-text: clone the label inlines, trim trailing // whitespace on the final text node, then append " N". Markup in the - // label is preserved. + // label is preserved. A composite figure's PANEL arrives with a letter + // suffix (`Figure 2a`, §4c) on the group's own number. const autoNodes = labelNodes.map((n) => ({ ...n })) as InlineNode[] const last = autoNodes[autoNodes.length - 1] if (last && last.type === 'text') { last.value = last.value.replace(RE_TRAILING_LABEL_WS, '') } - autoNodes.push({ type: 'text', value: ` ${next}` } as Text) + autoNodes.push({ type: 'text', value: ` ${next}${suffix ?? ''}` } as Text) targets.set(id, autoNodes) }) } @@ -1265,6 +1275,7 @@ export function promoteBlockImages(blocks: BlockNode[], figuresOnly = false): vo case 'block_quote': case 'admonition': case 'div': + case 'figure_group': promoteBlockImages(b.children, figuresOnly) break case 'list': @@ -1288,8 +1299,37 @@ type CaptionNumbered = ( labelNodes: InlineNode[], n: number, attrs: Attrs | undefined, + /** + * PART 9 §4c: a composite figure's PANEL takes the GROUP's number plus a + * letter (`a`, `b`, …) derived from its order among the panels. The letter + * arrives here as a suffix on the registered auto-text (`Figure 2a`); the + * group's own registration and every non-panel caption pass none. + */ + suffix?: string, ) => void +/** + * The §4c panel letter for panel index `k` (0-based): `a`..`z`, then `aa`, + * `ab`, … - bijective base 26, matching the executable spec's `panelLetter`. + */ +function panelLetter(k: number): string { + let s = '' + k++ + while (k > 0) { + k-- + s = String.fromCharCode(97 + (k % 26)) + s + k = Math.floor(k / 26) + } + return s +} + +/** The §4c panels of a group: its `figure` and `table` children, in order. */ +export function figureGroupPanels(group: FigureGroup): (Figure | Table)[] { + return group.children.filter( + (c): c is Figure | Table => c.type === 'figure' || c.type === 'table', + ) +} + /** * Assign `caption_number.n` per label, in document order, over `blocks`. * @@ -1308,49 +1348,83 @@ export function numberCaptionsIn( counters: Map, onNumbered?: CaptionNumbered, ): void { - const numberCaption = (caption: InlineNode[], attrs: Attrs | undefined): void => { + const numberCaption = (caption: InlineNode[], attrs: Attrs | undefined): number | undefined => { const idx = caption.findIndex((n) => n.type === 'caption_number') - if (idx === -1) return + if (idx === -1) return undefined const labelNodes = caption.slice(0, idx) const label = inlineText(labelNodes).replace(RE_TRAILING_LABEL_WS, '') const next = (counters.get(label) ?? 0) + 1 counters.set(label, next) ;(caption[idx] as CaptionNumber).n = next onNumbered?.(labelNodes, next, attrs) + return idx } - const walk = (bs: BlockNode[]): void => { + // PART 9 §4c: a PANEL of a composite figure is not a sequence unit. Its + // caption's `#` placeholder draws no number and registers nothing - the + // caption_number node STAYS in the tree, un-numbered, and every renderer + // emits its authored spelling (the unresolved-reference precedent: keep the + // typed node, render what the author wrote). Decided here, in the one + // shared numbering pass, so the parse path and the AST-JSON ingest path + // (carve#758) publish the same wire shape as carve-php and carve-rs. + const walk = (bs: BlockNode[], inPanel: boolean): void => { for (const b of bs) { if (b.type === 'figure') { - numberCaption(b.caption, b.attrs) + if (!inPanel) numberCaption(b.caption, b.attrs) } else if (b.type === 'table' && b.caption) { - numberCaption(b.caption, b.attrs) + if (!inPanel) numberCaption(b.caption, b.attrs) } switch (b.type) { case 'block_quote': case 'admonition': case 'div': - walk(b.children) + walk(b.children, inPanel) break case 'list': - for (const it of b.items) walk(it.children) + for (const it of b.items) walk(it.children, inPanel) break case 'definition_list': - for (const it of b.items) for (const d of it.definitions) walk(d) + for (const it of b.items) for (const d of it.definitions) walk(d, inPanel) break case 'figure': // A figure wraps an image / blockquote / table; descend into a // blockquote or table target so a nested captioned element is // numbered too (mirrors walkBlock's figure-target descent). - if (b.target.type === 'block_quote') walk(b.target.children) - else if (b.target.type === 'table' && b.target.caption) + if (b.target.type === 'block_quote') walk(b.target.children, inPanel) + else if (b.target.type === 'table' && b.target.caption && !inPanel) { numberCaption(b.target.caption, b.target.attrs) + } break + case 'figure_group': { + // The group is ONE numbering unit (§4c): only its own caption draws + // from the sequence, and its draw also registers the panel ids with + // letters - so `` resolves as "Figure 2a". A group with + // no numbered caption registers nothing for its panels either. + const panels = figureGroupPanels(b) + if (!inPanel && b.caption) { + const labelIdx = numberCaption(b.caption, b.attrs) + if (labelIdx !== undefined && onNumbered) { + const labelNodes = b.caption.slice(0, labelIdx) + const n = (b.caption[labelIdx] as CaptionNumber).n! + panels.forEach((panel, k) => { + onNumbered(labelNodes, n, panel.attrs, panelLetter(k)) + }) + } + } + // Children walk: panels are not sequence units, and everything a + // panel CONTAINS is suppressed with it; non-panel stray content + // numbers normally, exactly as it would outside the group. + for (const c of b.children) { + const isPanel = c.type === 'figure' || c.type === 'table' + walk([c], inPanel || isPanel) + } + break + } default: break } } } - walk(blocks) + walk(blocks, false) } diff --git a/src/heading-level-shift.ts b/src/heading-level-shift.ts index c050e1c9..14a380a1 100644 --- a/src/heading-level-shift.ts +++ b/src/heading-level-shift.ts @@ -21,6 +21,7 @@ function shiftBlock(node: BlockNode, shift: number): void { case 'block_quote': case 'div': case 'admonition': + case 'figure_group': node.children.forEach((c) => shiftBlock(c, shift)) break case 'list': diff --git a/src/heading-numbers.ts b/src/heading-numbers.ts index f203b2b0..0274041f 100644 --- a/src/heading-numbers.ts +++ b/src/heading-numbers.ts @@ -165,6 +165,7 @@ function walkHeadings( } case 'div': case 'admonition': + case 'figure_group': descend((b as { children?: unknown }).children, inBlockquote) break case 'definition_list': { diff --git a/src/heading-reference.ts b/src/heading-reference.ts index de461133..1bcabf36 100644 --- a/src/heading-reference.ts +++ b/src/heading-reference.ts @@ -144,6 +144,7 @@ function walkBlock( case 'block_quote': case 'div': case 'admonition': + case 'figure_group': node.children.forEach((c) => walkBlock(c, targets, counts)) break case 'list': diff --git a/src/html-import.ts b/src/html-import.ts index 5cafaf7a..9e304e53 100644 --- a/src/html-import.ts +++ b/src/html-import.ts @@ -3,6 +3,7 @@ import type { Attrs, BlockNode, Document, + FigureGroup, InlineNode, List, TableCell, @@ -362,6 +363,12 @@ class Importer { } private figure(node: P5Node, path: string, depth: number, attrs?: Attrs): BlockNode[] { + // Our own composite-figure shape (PART 9 §4c): the group class marks the + // wrapper, the panels div holds the children. Own-output round trip only; + // a foreign nested figure without the class keeps the unwrap below. + if ((this.attr(node, 'class') ?? '').split(/\s+/).includes('carve-figure-group')) { + return this.figureGroup(node, path, depth, attrs) + } const captionNode = node.childNodes?.find((n) => n.tagName === 'figcaption') const body = (node.childNodes ?? []).filter((n) => n !== captionNode) const targets = this.blocks(body, path, depth + 1) @@ -515,6 +522,57 @@ class Importer { ) } } + + /** A copy of `attrs` without one class, `undefined` when nothing remains. */ + private stripClass(attrs: Attrs | undefined, className: string): Attrs | undefined { + if (!attrs?.classes) return attrs + const classes = attrs.classes.filter((c) => c !== className) + const next: Attrs = { ...attrs } + if (classes.length) next.classes = classes + else delete next.classes + if (next.id === undefined && next.classes === undefined && next.keyValues === undefined) { + return undefined + } + return next + } + + /** + * Our own `carve-figure-group` output back to a `figure_group` node (PART 9 + * §4c). The panels div unwraps; a `carve-figure-panel` figure comes back as + * the panel it rendered from - a bare `
` wrapper + * (the table panel, which carries no figcaption) unwraps to the table so the + * table's own caption and attrs stay its own. + */ + private figureGroup(node: P5Node, path: string, depth: number, attrs?: Attrs): BlockNode[] { + const captionNode = node.childNodes?.find((n) => n.tagName === 'figcaption') + const panelsDiv = node.childNodes?.find( + (n) => n.tagName === 'div' && (this.attr(n, 'class') ?? '').split(/\s+/).includes('carve-figure-panels'), + ) + const bodyNodes = panelsDiv + ? panelsDiv.childNodes ?? [] + : (node.childNodes ?? []).filter((n) => n !== captionNode) + const children = this.blocks(bodyNodes, path, depth + 1) + for (let i = 0; i < children.length; i++) { + const child = children[i]! + if (child.type !== 'figure' || !child.attrs?.classes?.includes('carve-figure-panel')) continue + const stripped = this.stripClass(child.attrs, 'carve-figure-panel') + if (stripped) child.attrs = stripped + else delete child.attrs + // The explicit table-panel wrapper renders with no figcaption; the + // generic figure import gave it an empty caption, which is not a shape + // the parser produces - unwrap back to the table itself. + if (child.target.type === 'table' && child.caption.length === 0 && child.attrs === undefined) { + children[i] = child.target + } + } + const group: FigureGroup = { type: 'figure_group', children } + if (captionNode) { + group.caption = this.inlines(captionNode.childNodes ?? [], `${path}/figcaption[1]`, depth + 1) + } + const groupAttrs = this.stripClass(attrs, 'carve-figure-group') + if (groupAttrs) group.attrs = groupAttrs + return [group] + } } export function htmlToAst(html: string, options: HtmlImportOptions = {}): HtmlImportResult { diff --git a/src/lint.ts b/src/lint.ts index 620c9b58..facabb7e 100644 --- a/src/lint.ts +++ b/src/lint.ts @@ -38,6 +38,7 @@ import { normalizeHeadingRefLabel, headingRefKeyFromLabel, isCollapsedRef, + figureGroupPanels, type AsciiHeadingIdMode, } from './heading-ids.js' import { readStamp, compareSpecVersions } from './stamp.js' @@ -434,6 +435,7 @@ export function lintCarve( break case 'admonition': case 'div': + case 'figure_group': indexHeadings(block.children, inBlockquote) break case 'list': @@ -463,6 +465,92 @@ export function lintCarve( if (node.type === 'figure' && captionHasNumber(node.caption)) used.add(attrs.id) }) + // Composite figures (PART 9 §4c): register the crossref targets a NUMBERED + // group creates - its own id and its panels' ids (resolved as "Figure Na") - + // and report the shapes that silently do less than they look like they do. + const checkFigureGroups = (blocks: BlockNode[]): void => { + for (const b of blocks) { + switch (b.type) { + case 'admonition': + // A bare `::: figure` only parses as an admonition when an OPEN + // group's body demoted it (groups do not nest); one carrying a + // title or [label] never matched the figure production at all. + if (b.kind === 'figure') { + if (b.title !== undefined || b.label !== undefined) { + out.push({ + ...locate(b, toUtf16), + rule: 'figure-group-opener-metadata', + message: + 'A "::: figure" opener carrying a quoted title or [label] is not a composite figure; it renders as a generic container. Drop the title/label to open a figure group.', + }) + } else { + out.push({ + ...locate(b, toUtf16), + rule: 'figure-group-nested', + message: + 'A "::: figure" inside a composite figure does not nest; it renders as a generic container. Move it out of the enclosing group.', + }) + } + } + checkFigureGroups(b.children) + break + case 'figure_group': { + // The panel predicate has ONE spelling (figureGroupPanels), shared + // with the numbering pass, so the lint cannot drift from what the + // resolver registers. + const panels = figureGroupPanels(b) + if (panels.length === 0) { + out.push({ + ...locate(b, toUtf16), + rule: 'figure-group-empty', + message: + 'This "::: figure" group holds no captionable panel; the panels wrapper renders around the preserved content only.', + }) + } else if (panels.length === 1) { + out.push({ + ...locate(b, toUtf16), + rule: 'figure-group-single-panel', + message: + 'This "::: figure" group holds a single panel; a plain captioned figure renders the same content without the group wrapper.', + }) + } + const numbered = captionHasNumber(b.caption) + if (numbered && b.attrs?.id !== undefined) used.add(b.attrs.id) + for (const panel of panels) { + if (captionHasNumber(panel.caption)) { + out.push({ + ...locate(panel, toUtf16), + rule: 'figure-group-panel-number', + message: + 'A "#" placeholder in a panel caption stays literal: panels are not numbering units, the group caption carries the number (and panel ids resolve with its letter).', + }) + } + if (numbered && panel.attrs?.id !== undefined) used.add(panel.attrs.id) + } + checkFigureGroups(b.children) + break + } + case 'block_quote': + case 'div': + checkFigureGroups(b.children) + break + case 'list': + for (const it of b.items) checkFigureGroups(it.children) + break + case 'definition_list': + for (const it of b.items) for (const d of it.definitions) checkFigureGroups(d) + break + case 'figure': + if (b.target.type === 'block_quote') checkFigureGroups(b.target.children) + break + default: + break + } + } + } + checkFigureGroups(doc.children) + for (const body of Object.values(doc.footnoteDefs ?? {})) checkFigureGroups(body) + // `used` now holds every valid id. A crossref to anything else degrades to // literal text in resolveHeadingIds. const usedFolded = new Set([...used].map(foldId)) diff --git a/src/parse.ts b/src/parse.ts index 11c517bc..cd7bf073 100644 --- a/src/parse.ts +++ b/src/parse.ts @@ -31,6 +31,7 @@ import type { Emphasis, Extension, Figure, + FigureGroup, Heading, HeadingLevel, Image, @@ -1227,6 +1228,15 @@ class Lexer { // (top and nested) — startsInterruptingBlock no longer branches on this — // but sub-lexers still set it to mark their context. nested = false + /** + * True while this lexer reads the body of an open `::: figure` group, at any + * depth (PART 9 §4c: groups do not nest). `parseAdmonition` sets it on the + * group's body sub-lexer and `nestedSubLexer` carries it into every deeper + * container, so a bare `::: figure` ANYWHERE inside an open group's body is + * demoted to a generic container. A sibling after the group parses from the + * parent lexer, where the flag was never set. + */ + inFigureGroup = false // Negative cache for fenceHasCloser (paragraph-interruption closer // lookahead), the same entry the container-local scans keep: per fence @@ -1461,6 +1471,7 @@ function nestedSubLexer( sub.footnoteDefPos = parent.footnoteDefPos sub.nested = true sub.depth = parent.depth + 1 + sub.inFigureGroup = parent.inFigureGroup attachDocumentOffsets(sub, parent, startLineIndex) return sub } @@ -3494,12 +3505,21 @@ function parseFootnoteDef(lexer: Lexer): null { return null } -function parseAdmonition(lexer: Lexer): Admonition { +function parseAdmonition(lexer: Lexer): Admonition | FigureGroup { const openLineIndex = lexer.pos const open = lexer.consume() const m = RE_ADMONITION_OPEN.exec(open)! const fence = m[1]!.length const kind = m[2]! + // PART 9 §4c: a BARE `::: figure` opener - kind only, no quoted title, no + // `[label]` - is a composite figure group, not an admonition. An opener + // carrying either piece of metadata does not match the figure production and + // stays a generic container (the group node has no title/label fields by + // design). A bare opener inside an OPEN group's body is demoted the same way: + // groups do not nest, which is what `inFigureGroup` carries through the + // recursion. + const isFigureGroup = + kind === 'figure' && m[3] === undefined && m[4] === undefined && !lexer.inFigureGroup // The opener carries an optional quoted title only (grammar // quoted_title; PART 9 §12). The quotes delimit the title and are // stripped (not part of the rendered text); an explicitly empty `""` @@ -3515,7 +3535,32 @@ function parseAdmonition(lexer: Lexer): Admonition { fenceWidth: fence, }) const subLexer = nestedSubLexer(lexer, inner.map((line) => line.text), openLineIndex + 1) + if (isFigureGroup) subLexer.inFigureGroup = true const children = parseBlocks(subLexer, 0) + if (isFigureGroup) { + const group: FigureGroup = { type: 'figure_group', children } + // The group's CLOSING fence is §4's sixth caption host: a `^ …` line + // directly after it (or across at most one blank line) attaches as the + // GROUP caption - the same slot idiom the five parse-time hosts use. + // A group auto-closed at EOF has no closer line to host the slot, and in + // that case the lexer is already exhausted, so the lookahead finds nothing. + let lookahead = 0 + while (!lexer.eof() && isBlankLine(lexer.peek(lookahead))) lookahead++ + const next = lexer.peek(lookahead) + if (next) { + const cap = RE_CAPTION.exec(next) + // §4: a caption attaches only when it immediately follows the block + // or is separated by at most ONE blank line. + if (cap && lookahead <= 1) { + for (let i = 0; i <= lookahead; i++) lexer.consume() + group.caption = parseCaptionInline(lexer, cap[1]!) + } + } + // A preceding block-attribute line is the only way to attribute the group + // (same as the admonition below); parseBlocks applies it to the returned + // node. + return group + } const node: Admonition = { type: 'admonition', kind, children } // `!== undefined` (not truthiness): an explicitly empty quoted title // `""` still emits a (empty)

per §12. diff --git a/src/profile-filter.ts b/src/profile-filter.ts index 8ee0ed3b..c0cadd13 100644 --- a/src/profile-filter.ts +++ b/src/profile-filter.ts @@ -131,6 +131,10 @@ function childArrays(node: NodeLike): ChildArray[] { case 'figure': if (node['caption']) push(node['caption'], false) break + case 'figure_group': + if (node['caption']) push(node['caption'], false) + push(node['children'], true) + break case 'footnote_ref': case 'inline_footnote': // Inline footnote content is inline. @@ -189,6 +193,7 @@ const BLOCK_CANONICAL = new Set([ 'line_block', 'comment', 'figure', + 'figure_group', 'caption', ]) @@ -204,6 +209,7 @@ const BLOCK_JS_TYPES = new Set([ 'div', 'definition_list', 'figure', + 'figure_group', 'image', 'abbreviation_def', 'raw_block', diff --git a/src/profile.ts b/src/profile.ts index ff252d94..ca3248de 100644 --- a/src/profile.ts +++ b/src/profile.ts @@ -47,6 +47,7 @@ export const CANONICAL_BLOCK_TYPES = [ 'line_block', 'comment', 'figure', + 'figure_group', 'caption', // A DEFINITION LINE IS CONTENT, so both definition types are deniable // (carve#826, the ruling on carve#771). They render nothing in HTML and are @@ -178,6 +179,8 @@ export function canonicalType(type: string): string { return 'definition_list' case 'figure': return 'figure' + case 'figure_group': + return 'figure_group' case 'comment': return 'comment' // ----- inline ----- diff --git a/src/render-ansi.ts b/src/render-ansi.ts index 7261d47b..3603c959 100644 --- a/src/render-ansi.ts +++ b/src/render-ansi.ts @@ -202,6 +202,18 @@ function renderBlock(node: BlockNode, ctx: AnsiContext): string { return renderDefinitionList(node.items, ctx, true) case 'figure': return renderFigure(node, ctx) + case 'figure_group': { + // PART 11 degradation (D8), matching the plain-text shape: the GROUP + // caption line first (styled like every caption on this target), a blank + // line, then each child in source order - a panel as its caption line + // over its host degradation, stray content as usual. + let out = '' + if (node.caption !== undefined) out += renderCaption(node.caption, ctx) + for (const child of node.children) { + out += child.type === 'figure' ? renderPanelFigure(child, ctx) : renderBlock(child, ctx) + } + return out + } case 'image': // Block-level (standalone) image: emit the trailing block separator so a // following block is not glued to it, matching carve-php / carve-rs. @@ -381,6 +393,20 @@ function renderCaption(nodes: InlineNode[], ctx: AnsiContext): string { return `${style(trimNonNbsp(renderInlines(nodes, ctx)), ITALIC + DIM)}\n\n` } +/** + * A composite figure's PANEL on this target: caption line first, then the host + * degradation (D8) - see the plain-text twin for why the order inverts. + */ +function renderPanelFigure(node: Figure, ctx: AnsiContext): string { + const target = + node.target.type === 'image' + ? renderImage(node.target) + : node.target.type === 'table' + ? trimEndNonNbsp(renderTable(node.target, ctx)) + : trimEndNonNbsp(renderBlock(node.target, ctx)) + return `${style(trimNonNbsp(renderInlines(node.caption, ctx)), ITALIC + DIM)}\n${target}\n\n` +} + function renderFootnoteDefs(ast: Document, ctx: AnsiContext): string { if (!ast.footnoteDefs) return '' let out = '' diff --git a/src/render-carve.ts b/src/render-carve.ts index 17611cf3..917d1ca9 100644 --- a/src/render-carve.ts +++ b/src/render-carve.ts @@ -536,11 +536,15 @@ function renderBlocks(blocks: BlockNode[], ctx: CarveContext): string { } function hostsCaption(block: BlockNode): boolean { + // A figure group's CLOSING fence hosts a caption (PART 9 §4c), so a literal + // `^ …` paragraph written after one must have its caret escaped or it would + // re-attach as the group caption on the way back (the F6 detached shape). if ( block.type === 'table' || block.type === 'code_block' || block.type === 'block_quote' || - block.type === 'image' + block.type === 'image' || + block.type === 'figure_group' ) return true if (block.type !== 'paragraph' || block.children.length !== 1) return false @@ -693,6 +697,17 @@ function renderBlock(node: BlockNode, ctx: CarveContext): string { return withAttrs(renderDefinitionList(node.items, ctx)) case 'figure': return withAttrs(renderFigure(node, ctx)) + case 'figure_group': { + // The canonical spelling is the authored form (PART 9 §4c): a bare + // `::: figure` fence, the children as an ordinary fence body, and the + // group caption as a `^ ` line after the CLOSING fence - unescaped, + // because the writer knows the closer hosts it. The `#` placeholder is + // written back by the caption_number arm like every numbered caption. + const fence = colonFenceFor(ctx) + const body = renderColonFenceBody(node.children, ctx) + const caption = node.caption !== undefined ? `\n^ ${renderInlines(node.caption, ctx)}` : '' + return withAttrs(`${fence} figure\n${body}\n${fence}${caption}`) + } case 'image': return renderImage(node) case 'raw_block': { @@ -2209,7 +2224,7 @@ const UNWRITABLE_CONTROLS = /[\u0000\u000d]/g function escapeText(text: string, captionCanOpen = false): string { const escapes = escapeMode === 'minimal' ? UNCONDITIONAL_ESCAPES : CANDIDATE_ESCAPES - const out = text + let out = text .replace(UNWRITABLE_CONTROLS, '') .replace(escapes, (char, offset: number) => { if (char !== '^') return `\\${char}` @@ -2219,6 +2234,21 @@ function escapeText(text: string, captionCanOpen = false): string { const opensInline = next === '[' || (text[offset - 1] ?? '') === '{' || next === '}' return opensCaption || opensInline ? '\\^' : '^' }) + // The caption-opening caret is escaped in EVERY mode, not only when `^` is + // in the candidate class: after a caption host (a figure group, an image, a + // table...) an unescaped `^ ` line re-attaches as the caption on re-parse, + // so the minimal form always failed the redundancy check and the WHOLE + // document escalated to conservative escaping - `\(a\)` and `\#` where + // carve-php and carve-rs write the characters bare. One structural escape + // keeps the minimal pass winnable (cross-engine fmt parity, PART 11 §4). + if ( + escapeMode === 'minimal' && + captionCanOpen && + out.startsWith('^') && + (out[1] === ' ' || out[1] === '\t') + ) { + out = '\\' + out + } if (escapeMode === 'minimal') return out // Escape a colon RUN that begins a line (see LINE_INITIAL_COLON). Run, not // single character: `:::` needs only its first colon neutralized to stop diff --git a/src/render-html.ts b/src/render-html.ts index 47bb574c..f761553e 100644 --- a/src/render-html.ts +++ b/src/render-html.ts @@ -14,6 +14,7 @@ import type { BlockQuote, Document, Figure, + FigureGroup, Heading, Image, InlineNode, @@ -1128,6 +1129,8 @@ function renderBlockNode(node: BlockNode, opts: RenderOptions, level: number): s } case 'figure': return renderFigure(node, opts, level) + case 'figure_group': + return renderFigureGroup(node, opts, level) case 'abbreviation_def': return '' case 'raw_block': @@ -1523,7 +1526,7 @@ function renderAdmonition(node: Admonition, opts: RenderOptions, level: number): return `${pad}<${tag}${sourceLineAttr(opts, node.pos?.startLine, restAttrs)} class="${classValue}"${rest}>\n${titleLine}${labelLine}${body}\n${pad}` } -function renderFigure(node: Figure, opts: RenderOptions, level: number): string { +function renderFigure(node: Figure, opts: RenderOptions, level: number, leadClass?: string): string { const pad = indent(level) let inner: string if (node.target.type === 'image') { @@ -1536,12 +1539,75 @@ function renderFigure(node: Figure, opts: RenderOptions, level: number): string } else { inner = renderTable(node.target, opts, level + 1) } - return `${pad}\n${inner}\n${pad}

${renderInlines( + // A composite figure's PANEL leads its classes with the panel marker, the + // way the group's own wrapper leads with `carve-figure-group` - the + // class-first injection renderAdmonition uses (PART 9 §4c). + const open = + leadClass === undefined + ? `${pad}` + : `${pad}` + return `${open}\n${inner}\n${pad}
${renderInlines( node.caption, opts, )}
\n${pad}` } +/** A copy of `attrs` with the class slot removed (the caller renders it). */ +function withoutClassSlot(attrs: Attrs | undefined): Attrs { + const rest: Attrs = {} + if (attrs?.id !== undefined) rest.id = attrs.id + if (attrs?.keyValues) rest.keyValues = attrs.keyValues + // The class is structurally first; the id/key attrs after it keep their + // source order (order minus the class slot) - same rule as renderAdmonition. + if (attrs?.order) rest.order = attrs.order.filter((s) => s !== '.class') + return rest +} + +function renderFigureGroup(node: FigureGroup, opts: RenderOptions, level: number): string { + const pad = indent(level) + // Class-first injection like renderAdmonition: `carve-figure-group` leads, + // attribute-line classes merge after it, id and the rest keep source order. + // DEDUPED, first occurrence kept - the oracle's renderBlockAttrs rule - so + // an authored `.carve-figure-group` does not double the marker. + const classValue = [...new Set(['carve-figure-group', ...(node.attrs?.classes ?? [])])] + .map(escapeAttr) + .join(' ') + const rest = withoutClassSlot(node.attrs) + const lines = [ + `${pad}`, + ] + // The panels div is UNCONDITIONAL (§4c): zero panels still wrap the + // preserved content, and an empty group holds an empty div. + lines.push(`${pad}
`) + const inner = node.children + .map((c) => { + // §4c panels: the `figure` and `table` children, in source order. A + // captioned host already renders as a
and takes the panel + // class; a table does not render as a figure on its own, so its panel + // wrapper is explicit and the table keeps its own attrs and
') + expect(out).toContain('
Table 2: T2
') + }) +}) diff --git a/test/a-figure-group-round-trips-through-ast-json.test.ts b/test/a-figure-group-round-trips-through-ast-json.test.ts new file mode 100644 index 00000000..69de4610 --- /dev/null +++ b/test/a-figure-group-round-trips-through-ast-json.test.ts @@ -0,0 +1,72 @@ +/* + * PART 12: `figure_group` on the wire is `{type, children, caption?, attrs?, + * pos?}` - children as ordinary block nodes in source order, no `panels` + * array (a consumer derives the panel list by type, the way the renderer + * does). On ingest, published caption numbers are RE-DERIVED (carve#758), and + * the group rule lives in the same shared pass, so both paths agree. + */ +import { describe, it, expect } from 'vitest' +import { carveToAstJson, fromAstJson, renderHtml, carveToHtml } from '../src/index.js' + +const F2 = + '{#fig-first}\n![lead](lead.png)\n^ Figure #: First\n\n{#fig-x}\n::: figure\n{#fig-x-a}\n![one](a.png)\n^ (a) One\n:::\n^ Figure #: Second\n' + +describe('a figure group round-trips through AST JSON', () => { + it('publishes children in order and the caption, with no panels array', () => { + const json = carveToAstJson(F2) + const group = json.children.find((c) => c.type === 'figure_group') as { + children: Array<{ type: string }> + caption?: unknown[] + panels?: unknown + } + expect(group).toBeDefined() + expect(group.children.map((c) => c.type)).toEqual(['figure']) + expect(group.caption).toBeDefined() + expect(group.panels).toBeUndefined() + }) + + it('keeps a panel placeholder as a typed caption_number without n', () => { + // §4c: the panel is not a sequence unit, so its `#` draws no number - but + // the node stays TYPED on the wire, un-numbered, exactly as carve-php and + // carve-rs publish it (the unresolved-reference precedent: keep the node, + // render its spelling). Flattening it to text would erase what the author + // wrote from every consumer. + const json = carveToAstJson( + '::: figure\n![one](a.png)\n^ Figure #: panel tries a number\n:::\n^ Figure #: Group\n', + ) + const group = json.children[0] as { + children: Array<{ caption: Array<{ type: string; n?: number }> }> + caption: Array<{ type: string; n?: number }> + } + const panelNumber = group.children[0]!.caption.find((c) => c.type === 'caption_number') + expect(panelNumber).toBeDefined() + expect(panelNumber!.n).toBeUndefined() + const groupNumber = group.caption.find((c) => c.type === 'caption_number') + expect(groupNumber?.n).toBe(1) + }) + + it('a captionless group publishes no caption key', () => { + const json = carveToAstJson('::: figure\n![a](x.png)\n^ (a) c\n:::\n') + const group = json.children[0] as { type: string; caption?: unknown } + expect(group.type).toBe('figure_group') + expect(group.caption).toBeUndefined() + }) + + it('renders the same HTML from the wire as from the source', () => { + const payload = JSON.parse(JSON.stringify(carveToAstJson(F2))) + expect(renderHtml(fromAstJson(payload))).toBe(carveToHtml(F2)) + }) + + it('re-derives the group number on ingest when the tree published numbers', () => { + // Delete the leading figure from a PUBLISHED (numbered) tree: the group + // was "Figure 2" in that document, and must come back "Figure 1" in this + // one - the §5 re-derivation, with the group as one sequence unit. + const payload = JSON.parse(JSON.stringify(carveToAstJson(F2))) as { + children: Array<{ type: string }> + } + payload.children = payload.children.filter((c) => c.type !== 'figure') + const html = renderHtml(fromAstJson(payload)) + expect(html).toContain('
Figure 1: Second
') + expect(html).not.toContain('Figure 2') + }) +}) diff --git a/test/corpus.test.ts b/test/corpus.test.ts index ebdd49cb..fa89d8e6 100644 --- a/test/corpus.test.ts +++ b/test/corpus.test.ts @@ -91,6 +91,7 @@ const IMPLEMENTED = new Set([ 'lists', 'task-lists', 'blockquote-with-attribution', + 'composite-figures', 'image-with-caption', 'tables', 'tables-with-rowspan-and-colspan', diff --git a/test/fmt-writes-a-figure-group-back-as-authored.test.ts b/test/fmt-writes-a-figure-group-back-as-authored.test.ts new file mode 100644 index 00000000..6bf15237 --- /dev/null +++ b/test/fmt-writes-a-figure-group-back-as-authored.test.ts @@ -0,0 +1,60 @@ +/* + * PART 11 §4 canonical writing for the §4c composite figure: the authored + * form - attrs line, `::: figure`, panels separated by one blank line, the + * closing fence, and the group caption as an UNESCAPED `^ ` line after it + * (the writer knows the closer hosts it). A literal `^ …` paragraph after a + * group without a caption is the one shape that must come back escaped, or it + * would re-attach as the group caption on the way back. + */ +import { describe, it, expect } from 'vitest' +import { carveToCarve, carveToHtml } from '../src/index.js' + +const F1 = + '{#fig-x .columns-2}\n::: figure\n{#fig-x-a}\n![one](a.png)\n^ (a) One\n\n{#fig-x-b}\n![two](b.png)\n^ (b) Two\n:::\n^ Figure #: Group caption\n' + +describe('fmt writes a figure group back as authored', () => { + it('reproduces the authored form, unescaped caption included', () => { + expect(carveToCarve(F1)).toBe(F1) + }) + + it('is idempotent on the canonical form', () => { + const once = carveToCarve(F1) + expect(carveToCarve(once)).toBe(once) + }) + + it('renders the same HTML before and after a format pass', () => { + expect(carveToHtml(carveToCarve(F1))).toBe(carveToHtml(F1)) + }) + + it('writes a captionless group with no trailing caret line', () => { + const src = '::: figure\n![a](x.png)\n^ (a) c\n:::\n' + expect(carveToCarve(src)).toBe(src) + }) + + it('escapes ONLY the detached caption caret, nothing else', () => { + // Two blank lines detached it in the source; the writer normalizes to one + // blank line, so the caret must be escaped or the paragraph would attach + // as the group caption on re-parse (the F6 shape). ONE structural escape: + // the panel caption's parens and the paragraph's `#` stay bare + // (carve-php / carve-rs parity) - the caret used to fall outside the + // minimal escape class, which failed the redundancy check and escalated + // the whole document to conservative escaping. + const src = '::: figure\n![a](x.png)\n^ (a) c\n:::\n\n\n^ Figure #: Detached\n' + const out = carveToCarve(src) + expect(out).toBe('::: figure\n![a](x.png)\n^ (a) c\n:::\n\n\\^ Figure #: Detached\n') + expect(carveToHtml(out)).toBe(carveToHtml(src)) + expect(carveToCarve(out)).toBe(out) + }) + + it('keeps the fence-width discipline inside another container', () => { + const src = '::: note\n:::: figure\n![a](x.png)\n^ (a) c\n::::\n^ Figure #: G\n:::\n' + expect(carveToCarve(src)).toBe(src) + expect(carveToHtml(carveToCarve(src))).toBe(carveToHtml(src)) + }) + + it('round-trips a demoted nested figure as the generic container it is', () => { + const src = '::: figure\n:::: figure\n![a](x.png)\n^ (a) c\n::::\n:::\n^ Figure #: Outer only\n' + expect(carveToCarve(src)).toBe(src) + expect(carveToHtml(carveToCarve(src))).toBe(carveToHtml(src)) + }) +}) diff --git a/test/html-import-reads-a-figure-group-back.test.ts b/test/html-import-reads-a-figure-group-back.test.ts new file mode 100644 index 00000000..6d709b63 --- /dev/null +++ b/test/html-import-reads-a-figure-group-back.test.ts @@ -0,0 +1,53 @@ +/* + * Own-output round trip for the §4c composite figure: the `carve-figure-group` + * class marks the wrapper, the panels div unwraps, a `carve-figure-panel` + * figure comes back as the panel it rendered from, and the bare table-panel + * wrapper (which carries no figcaption) unwraps to the table so its caption + * and attrs stay its own. A foreign nested
without the class keeps + * the pre-existing unwrap behavior. + */ +import { describe, it, expect } from 'vitest' +import { carveToHtml, htmlToCarve, htmlToAst } from '../src/index.js' + +describe('html import reads a figure group back', () => { + it('round-trips the two-panel group shape', () => { + const src = + '{#fig-x .columns-2}\n::: figure\n{#fig-x-a}\n![one](a.png)\n^ (a) One\n\n{#fig-x-b}\n![two](b.png)\n^ (b) Two\n:::\n^ Figure #: Group caption\n' + // The rendered page carries the RESOLVED number, so the reimported caption + // says "Figure 1" where the source said "#" - the same trade every + // numbered caption makes on this path. + expect(htmlToCarve(carveToHtml(src)).value).toBe( + '{#fig-x .columns-2}\n::: figure\n{#fig-x-a}\n![one](a.png)\n^ (a) One\n\n{#fig-x-b}\n![two](b.png)\n^ (b) Two\n:::\n^ Figure 1: Group caption\n', + ) + }) + + it('imports the group as a figure_group node with the marker classes stripped', () => { + const html = carveToHtml('::: figure\n![a](x.png)\n^ (a) c\n:::\n^ Figure #: G') + const doc = htmlToAst(html).value + const group = doc.children.find((c) => c.type === 'figure_group') + expect(group).toBeDefined() + expect(JSON.stringify(group)).not.toContain('carve-figure-') + }) + + it('unwraps a bare table-panel wrapper back to the table', () => { + const html = carveToHtml('::: figure\n| K |\n|---|\n| a |\n:::\n^ Figure #: G') + const doc = htmlToAst(html).value + const group = doc.children.find((c) => c.type === 'figure_group') as { + children: Array<{ type: string }> + } + expect(group.children.map((c) => c.type)).toEqual(['table']) + }) + + it('a captionless group comes back without a caption', () => { + const html = carveToHtml('::: figure\n![a](x.png)\n^ (a) c\n:::') + const doc = htmlToAst(html).value + const group = doc.children.find((c) => c.type === 'figure_group') as { caption?: unknown } + expect(group.caption).toBeUndefined() + }) + + it('leaves a foreign figure without the class on the pre-existing path', () => { + const html = '
a
c
' + const doc = htmlToAst(html).value + expect(doc.children[0]).toMatchObject({ type: 'figure' }) + }) +}) diff --git a/test/lint-reports-the-figure-group-shapes-that-do-less.test.ts b/test/lint-reports-the-figure-group-shapes-that-do-less.test.ts new file mode 100644 index 00000000..12909aef --- /dev/null +++ b/test/lint-reports-the-figure-group-shapes-that-do-less.test.ts @@ -0,0 +1,71 @@ +/* + * PART 9 §4c lint: the shapes that parse fine and silently do less than they + * look like they do. `figure-group-nested` (a demoted inner `::: figure`), + * `figure-group-opener-metadata` (title/label keeps the opener a generic + * container), `figure-group-panel-number` (a `#` in a panel caption stays + * literal), plus the advisory `figure-group-empty` / `figure-group-single-panel`. + * A NUMBERED group also registers its id and its panels' ids as valid + * crossref targets, so those references are not reported broken. + */ +import { describe, it, expect } from 'vitest' +import { lintCarve } from '../src/index.js' + +const rules = (src: string) => lintCarve(src).map((w) => w.rule) + +describe('lint reports the figure group shapes that do less', () => { + it('flags a nested bare figure fence as demoted', () => { + const src = '::: figure\n:::: figure\n![a](x.png)\n^ (a) c\n::::\n:::\n^ Figure #: G\n' + expect(rules(src)).toContain('figure-group-nested') + }) + + it('flags an opener carrying a title or label', () => { + expect(rules('::: figure "T"\n![a](x.png)\n^ c\n:::\n')).toContain( + 'figure-group-opener-metadata', + ) + expect(rules('::: figure [g]\nBody.\n:::\n')).toContain('figure-group-opener-metadata') + }) + + it('does not flag other admonition kinds', () => { + expect(rules('::: note\nBody.\n:::\n')).toEqual([]) + }) + + it('flags a # placeholder in a panel caption as literal', () => { + const src = '::: figure\n![a](x.png)\n^ Figure #: not a unit\n\n![b](y.png)\n^ (b) fine\n:::\n^ Figure #: G\n' + const found = lintCarve(src).filter((w) => w.rule === 'figure-group-panel-number') + expect(found).toHaveLength(1) + }) + + it('advises on empty and single-panel groups', () => { + expect(rules('::: figure\nOnly prose.\n:::\n')).toContain('figure-group-empty') + expect(rules('::: figure\n![a](x.png)\n^ (a) c\n:::\n')).toContain( + 'figure-group-single-panel', + ) + const two = '::: figure\n![a](x.png)\n^ a\n\n![b](y.png)\n^ b\n:::\n' + expect(rules(two)).not.toContain('figure-group-empty') + expect(rules(two)).not.toContain('figure-group-single-panel') + }) + + it('accepts crossrefs to a numbered group and its panels', () => { + const src = + '{#g}\n::: figure\n{#p-a}\n![a](x.png)\n^ a\n\n{#p-b}\n![b](y.png)\n^ b\n:::\n^ Figure #: G\n\nSee , and .\n' + expect(rules(src)).not.toContain('broken-crossref') + }) + + it('reports a crossref to a panel of an UNNUMBERED group as broken', () => { + const src = '::: figure\n{#p}\n![a](x.png)\n^ a\n:::\n\nSee .\n' + expect(rules(src)).toContain('broken-crossref') + }) + + it('a heading inside a group is a valid crossref target, not a broken one', () => { + // The heading index has to descend into the group like the resolver does, + // or every reference to a heading inside one is a false positive. + const src = + 'See .\n\n::: figure\n## Inner heading\n\n![x](x.png)\n^ (a) x\n:::\n^ Figure #: G\n' + expect(rules(src)).not.toContain('broken-crossref') + }) + + it('a duplicate heading id inside a group is still detected', () => { + const src = '# Same\n\n::: figure\n## Same\n\n![x](x.png)\n^ (a) x\n:::\n' + expect(rules(src)).toContain('duplicate-heading-id') + }) +})
. + if (c.type === 'figure') return renderFigure(c, opts, level + 2, 'carve-figure-panel') + if (c.type === 'table') { + const t = renderTable(c, opts, level + 3) + return `${pad}
\n${t}\n${pad}
` + } + // Non-panel stray content is preserved in place. + return renderBlock(c, opts, level + 2) + }) + .filter((s) => s !== '') + .join('\n') + if (inner !== '') lines.push(inner) + lines.push(`${pad} `) + if (node.caption !== undefined) { + lines.push(`${pad}
${renderInlines(node.caption, opts)}
`) + } + lines.push(`${pad}`) + return lines.join('\n') +} + function renderImage(img: Image, opts: RenderOptions): string { // An unresolved reference image is literal source, not an image (PART 12 // §3a), exactly like the unresolved reference link above. Without this the @@ -1892,8 +1958,13 @@ function renderInlineNode(node: InlineNode, opts: RenderOptions): string { // Unresolved: literal source, the same as an unresolved reference link. return `</#${escapeHtml(node.target)}>` case 'caption_number': - // Filled by resolve(); an unresolved placeholder renders empty. - return node.n === undefined ? '' : String(node.n) + // Filled by resolve(); an unresolved placeholder renders its authored + // spelling - the unresolved-reference precedent (PART 12 §3a), the + // visible failure this language prefers to a silent one, and what the + // Markdown/plain/ANSI arms already do. A composite figure's PANEL + // caption keeps its placeholder un-numbered by design (PART 9 §4c), so + // this arm is what makes it render as the literal `#` the author wrote. + return node.n === undefined ? '#' : String(node.n) case 'citation_group': { // Extension-produced node: per-extension resolution in registration order // (mirrors the block path). For each extension, static mode tries its diff --git a/src/render-markdown.ts b/src/render-markdown.ts index c565c208..e7c2767d 100644 --- a/src/render-markdown.ts +++ b/src/render-markdown.ts @@ -231,6 +231,21 @@ function renderBlock(node: BlockNode, ctx: MarkdownContext): string { return renderDefinitionList(node.items, ctx, true) case 'figure': return renderFigure(node, ctx) + case 'figure_group': { + // PART 11 degradation (D8): the panels in source order, each host + // degraded as usual with its caption as an EMPHASIZED paragraph after + // it; stray content in place; the group caption as a BOLD paragraph at + // the end. A table panel's caption is the table's own and stays where + // that renderer puts it. + let out = '' + for (const child of node.children) { + out += child.type === 'figure' ? renderPanelFigure(child, ctx) : renderBlock(child, ctx) + } + if (node.caption !== undefined) { + out += `**${trimNonNbsp(renderInlines(node.caption, ctx))}**\n\n` + } + return out + } case 'image': // Block-level (standalone) image: emit the trailing block separator so a // following block is not glued to it, matching carve-php / carve-rs. @@ -418,6 +433,25 @@ function renderFigure(node: Figure, ctx: MarkdownContext): string { return `${target}${sep}${renderInlines(node.caption, ctx)}\n\n` } +/** + * A composite figure's PANEL: the host degraded exactly as `renderFigure` + * degrades it, with the caption emphasized rather than plain - the D8 shape + * that keeps a panel caption visually subordinate to the group's bold one. + */ +function renderPanelFigure(node: Figure, ctx: MarkdownContext): string { + const target = + node.target.type === 'image' + ? renderImage(node.target) + : node.target.type === 'table' + ? trimNonNbsp(renderTable(node.target, ctx)) + : trimNonNbsp(renderBlock(node.target, ctx)) + // A BLANK line before the caption, for every host: the emphasized caption is + // its own paragraph (carve-php / carve-rs parity; the ticket's degradation + // example). The single-newline glue is the standalone figure's shape, not + // the panel's. + return `${target}\n\n*${trimNonNbsp(renderInlines(node.caption, ctx))}*\n\n` +} + function renderFootnoteDefs(ast: Document, ctx: MarkdownContext): string { if (!ast.footnoteDefs) return '' let out = '' @@ -1177,6 +1211,13 @@ function walkBlocks( if (block.target.type === 'block_quote') walkBlocks(block.target.children, visit, depth + 1) else if (block.target.type === 'table') walkBlocks([block.target], visit, depth + 1) break + case 'figure_group': + // The prepass feeds the heading-id index and the reference scan; a + // heading inside a composite figure is a crossref target like any + // other, and the group caption carries references of its own. + if (block.caption) visit(block, block.caption) + walkBlocks(block.children, visit, depth + 1) + break default: break } diff --git a/src/render-plain.ts b/src/render-plain.ts index 5cc0213a..3d552e92 100644 --- a/src/render-plain.ts +++ b/src/render-plain.ts @@ -165,6 +165,19 @@ function renderBlock(node: BlockNode, ctx: PlainContext): string { return renderDefinitionList(node.items, ctx, true) case 'figure': return renderFigure(node, ctx) + case 'figure_group': { + // PART 11 degradation (D8): the GROUP caption line first, a blank line, + // then each child in source order - a panel as its caption line over its + // host degradation, stray content as usual - with a blank line between. + let out = '' + if (node.caption !== undefined) { + out += `${trimNonNbsp(renderInlines(node.caption, ctx))}\n\n` + } + for (const child of node.children) { + out += child.type === 'figure' ? renderPanelFigure(child, ctx) : renderBlock(child, ctx) + } + return out + } case 'image': // Block-level (standalone) image: emit the trailing block separator so a // following block is not glued to it, matching carve-php / carve-rs. @@ -259,6 +272,22 @@ function renderFigure(node: Figure, ctx: PlainContext): string { return `${target}${sep}${renderInlines(node.caption, ctx)}\n\n` } +/** + * A composite figure's PANEL on this target: caption line first, then the host + * degradation (D8) - the inverse of the standalone figure, because with the + * group caption leading the whole block, a caption under its host would read + * as belonging to the NEXT panel. + */ +function renderPanelFigure(node: Figure, ctx: PlainContext): string { + const target = + node.target.type === 'image' + ? stripControls(node.target.alt) + : node.target.type === 'table' + ? trimNonNbsp(renderTable(node.target, ctx)) + : trimNonNbsp(renderBlock(node.target, ctx)) + return `${trimNonNbsp(renderInlines(node.caption, ctx))}\n${target}\n\n` +} + function renderFootnoteDefs(ast: Document, ctx: PlainContext): string { if (!ast.footnoteDefs) return '' let out = '' diff --git a/src/wire-fields.ts b/src/wire-fields.ts index b0c89efc..841d061f 100644 --- a/src/wire-fields.ts +++ b/src/wire-fields.ts @@ -27,6 +27,7 @@ export const WIRE_FIELDS: Readonly> = { "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"], @@ -144,6 +145,8 @@ export const NODE_POSITION_KIND: Readonly> = { "emphasis": ["children", "type"], "escaped_text": ["type", "value"], "figure": ["caption", "target", "type"], + "figure_group": ["children", "type"], "footnote": ["children", "label", "type"], "footnote_ref": ["type"], "frontmatter": ["content", "format", "type"], @@ -276,6 +280,7 @@ export const WIRE_VALUE_KINDS: Readonly> = { - "admonition.children": ["abbreviation_def", "admonition", "block_quote", "code_block", "comment", "definition_description", "definition_list", "definition_term", "div", "figure", "footnote", "frontmatter", "heading", "image", "line_block", "link_reference_definition", "list", "list_item", "paragraph", "raw_block", "table", "table_cell", "table_row", "thematic_break"], + "admonition.children": ["abbreviation_def", "admonition", "block_quote", "code_block", "comment", "definition_description", "definition_list", "definition_term", "div", "figure", "figure_group", "footnote", "frontmatter", "heading", "image", "line_block", "link_reference_definition", "list", "list_item", "paragraph", "raw_block", "table", "table_cell", "table_row", "thematic_break"], "admonition.title": ["abbreviation", "autolink", "caption_number", "citation_group", "code", "comment", "critic_comment", "delete", "emphasis", "escaped_text", "footnote_ref", "hard_break", "heading_ref", "highlight", "image", "inline_extension", "inline_footnote", "insert", "link", "literal_inline", "math", "mention", "raw_inline", "smart_punctuation", "soft_break", "span", "strike", "strong", "subscript", "substitution", "superscript", "symbol", "tag", "text", "underline"], - "block_quote.children": ["abbreviation_def", "admonition", "block_quote", "code_block", "comment", "definition_description", "definition_list", "definition_term", "div", "figure", "footnote", "frontmatter", "heading", "image", "line_block", "link_reference_definition", "list", "list_item", "paragraph", "raw_block", "table", "table_cell", "table_row", "thematic_break"], - "definition_description.children": ["abbreviation_def", "admonition", "block_quote", "code_block", "comment", "definition_description", "definition_list", "definition_term", "div", "figure", "footnote", "frontmatter", "heading", "image", "line_block", "link_reference_definition", "list", "list_item", "paragraph", "raw_block", "table", "table_cell", "table_row", "thematic_break"], + "block_quote.children": ["abbreviation_def", "admonition", "block_quote", "code_block", "comment", "definition_description", "definition_list", "definition_term", "div", "figure", "figure_group", "footnote", "frontmatter", "heading", "image", "line_block", "link_reference_definition", "list", "list_item", "paragraph", "raw_block", "table", "table_cell", "table_row", "thematic_break"], + "definition_description.children": ["abbreviation_def", "admonition", "block_quote", "code_block", "comment", "definition_description", "definition_list", "definition_term", "div", "figure", "figure_group", "footnote", "frontmatter", "heading", "image", "line_block", "link_reference_definition", "list", "list_item", "paragraph", "raw_block", "table", "table_cell", "table_row", "thematic_break"], "definition_list.items": ["definition_description", "definition_term"], "definition_term.children": ["abbreviation", "autolink", "caption_number", "citation_group", "code", "comment", "critic_comment", "delete", "emphasis", "escaped_text", "footnote_ref", "hard_break", "heading_ref", "highlight", "image", "inline_extension", "inline_footnote", "insert", "link", "literal_inline", "math", "mention", "raw_inline", "smart_punctuation", "soft_break", "span", "strike", "strong", "subscript", "substitution", "superscript", "symbol", "tag", "text", "underline"], "delete.children": ["abbreviation", "autolink", "caption_number", "citation_group", "code", "comment", "critic_comment", "delete", "emphasis", "escaped_text", "footnote_ref", "hard_break", "heading_ref", "highlight", "image", "inline_extension", "inline_footnote", "insert", "link", "literal_inline", "math", "mention", "raw_inline", "smart_punctuation", "soft_break", "span", "strike", "strong", "subscript", "substitution", "superscript", "symbol", "tag", "text", "underline"], - "div.children": ["abbreviation_def", "admonition", "block_quote", "code_block", "comment", "definition_description", "definition_list", "definition_term", "div", "figure", "footnote", "frontmatter", "heading", "image", "line_block", "link_reference_definition", "list", "list_item", "paragraph", "raw_block", "table", "table_cell", "table_row", "thematic_break"], - "document.children": ["abbreviation_def", "admonition", "block_quote", "code_block", "comment", "definition_description", "definition_list", "definition_term", "div", "figure", "footnote", "frontmatter", "heading", "image", "line_block", "link_reference_definition", "list", "list_item", "paragraph", "raw_block", "table", "table_cell", "table_row", "thematic_break"], + "div.children": ["abbreviation_def", "admonition", "block_quote", "code_block", "comment", "definition_description", "definition_list", "definition_term", "div", "figure", "figure_group", "footnote", "frontmatter", "heading", "image", "line_block", "link_reference_definition", "list", "list_item", "paragraph", "raw_block", "table", "table_cell", "table_row", "thematic_break"], + "document.children": ["abbreviation_def", "admonition", "block_quote", "code_block", "comment", "definition_description", "definition_list", "definition_term", "div", "figure", "figure_group", "footnote", "frontmatter", "heading", "image", "line_block", "link_reference_definition", "list", "list_item", "paragraph", "raw_block", "table", "table_cell", "table_row", "thematic_break"], "emphasis.children": ["abbreviation", "autolink", "caption_number", "citation_group", "code", "comment", "critic_comment", "delete", "emphasis", "escaped_text", "footnote_ref", "hard_break", "heading_ref", "highlight", "image", "inline_extension", "inline_footnote", "insert", "link", "literal_inline", "math", "mention", "raw_inline", "smart_punctuation", "soft_break", "span", "strike", "strong", "subscript", "substitution", "superscript", "symbol", "tag", "text", "underline"], "figure.caption": ["abbreviation", "autolink", "caption_number", "citation_group", "code", "comment", "critic_comment", "delete", "emphasis", "escaped_text", "footnote_ref", "hard_break", "heading_ref", "highlight", "image", "inline_extension", "inline_footnote", "insert", "link", "literal_inline", "math", "mention", "raw_inline", "smart_punctuation", "soft_break", "span", "strike", "strong", "subscript", "substitution", "superscript", "symbol", "tag", "text", "underline"], "figure.shortCaption": ["abbreviation", "autolink", "caption_number", "citation_group", "code", "comment", "critic_comment", "delete", "emphasis", "escaped_text", "footnote_ref", "hard_break", "heading_ref", "highlight", "image", "inline_extension", "inline_footnote", "insert", "link", "literal_inline", "math", "mention", "raw_inline", "smart_punctuation", "soft_break", "span", "strike", "strong", "subscript", "substitution", "superscript", "symbol", "tag", "text", "underline"], "figure.target": ["block_quote", "code_block", "image", "paragraph", "table"], - "footnote.children": ["abbreviation_def", "admonition", "block_quote", "code_block", "comment", "definition_description", "definition_list", "definition_term", "div", "figure", "footnote", "frontmatter", "heading", "image", "line_block", "link_reference_definition", "list", "list_item", "paragraph", "raw_block", "table", "table_cell", "table_row", "thematic_break"], + "figure_group.caption": ["abbreviation", "autolink", "caption_number", "citation_group", "code", "comment", "critic_comment", "delete", "emphasis", "escaped_text", "footnote_ref", "hard_break", "heading_ref", "highlight", "image", "inline_extension", "inline_footnote", "insert", "link", "literal_inline", "math", "mention", "raw_inline", "smart_punctuation", "soft_break", "span", "strike", "strong", "subscript", "substitution", "superscript", "symbol", "tag", "text", "underline"], + "figure_group.children": ["abbreviation_def", "admonition", "block_quote", "code_block", "comment", "definition_description", "definition_list", "definition_term", "div", "figure", "figure_group", "footnote", "frontmatter", "heading", "image", "line_block", "link_reference_definition", "list", "list_item", "paragraph", "raw_block", "table", "table_cell", "table_row", "thematic_break"], + "footnote.children": ["abbreviation_def", "admonition", "block_quote", "code_block", "comment", "definition_description", "definition_list", "definition_term", "div", "figure", "figure_group", "footnote", "frontmatter", "heading", "image", "line_block", "link_reference_definition", "list", "list_item", "paragraph", "raw_block", "table", "table_cell", "table_row", "thematic_break"], "heading.children": ["abbreviation", "autolink", "caption_number", "citation_group", "code", "comment", "critic_comment", "delete", "emphasis", "escaped_text", "footnote_ref", "hard_break", "heading_ref", "highlight", "image", "inline_extension", "inline_footnote", "insert", "link", "literal_inline", "math", "mention", "raw_inline", "smart_punctuation", "soft_break", "span", "strike", "strong", "subscript", "substitution", "superscript", "symbol", "tag", "text", "underline"], "highlight.children": ["abbreviation", "autolink", "caption_number", "citation_group", "code", "comment", "critic_comment", "delete", "emphasis", "escaped_text", "footnote_ref", "hard_break", "heading_ref", "highlight", "image", "inline_extension", "inline_footnote", "insert", "link", "literal_inline", "math", "mention", "raw_inline", "smart_punctuation", "soft_break", "span", "strike", "strong", "subscript", "substitution", "superscript", "symbol", "tag", "text", "underline"], "inline_extension.content": ["abbreviation", "autolink", "caption_number", "citation_group", "code", "comment", "critic_comment", "delete", "emphasis", "escaped_text", "footnote_ref", "hard_break", "heading_ref", "highlight", "image", "inline_extension", "inline_footnote", "insert", "link", "literal_inline", "math", "mention", "raw_inline", "smart_punctuation", "soft_break", "span", "strike", "strong", "subscript", "substitution", "superscript", "symbol", "tag", "text", "underline"], "inline_footnote.inline": ["abbreviation", "autolink", "caption_number", "citation_group", "code", "comment", "critic_comment", "delete", "emphasis", "escaped_text", "footnote_ref", "hard_break", "heading_ref", "highlight", "image", "inline_extension", "inline_footnote", "insert", "link", "literal_inline", "math", "mention", "raw_inline", "smart_punctuation", "soft_break", "span", "strike", "strong", "subscript", "substitution", "superscript", "symbol", "tag", "text", "underline"], "insert.children": ["abbreviation", "autolink", "caption_number", "citation_group", "code", "comment", "critic_comment", "delete", "emphasis", "escaped_text", "footnote_ref", "hard_break", "heading_ref", "highlight", "image", "inline_extension", "inline_footnote", "insert", "link", "literal_inline", "math", "mention", "raw_inline", "smart_punctuation", "soft_break", "span", "strike", "strong", "subscript", "substitution", "superscript", "symbol", "tag", "text", "underline"], - "line_block.children": ["abbreviation_def", "admonition", "block_quote", "code_block", "comment", "definition_description", "definition_list", "definition_term", "div", "figure", "footnote", "frontmatter", "heading", "image", "line_block", "link_reference_definition", "list", "list_item", "paragraph", "raw_block", "table", "table_cell", "table_row", "thematic_break"], + "line_block.children": ["abbreviation_def", "admonition", "block_quote", "code_block", "comment", "definition_description", "definition_list", "definition_term", "div", "figure", "figure_group", "footnote", "frontmatter", "heading", "image", "line_block", "link_reference_definition", "list", "list_item", "paragraph", "raw_block", "table", "table_cell", "table_row", "thematic_break"], "link.children": ["abbreviation", "autolink", "caption_number", "citation_group", "code", "comment", "critic_comment", "delete", "emphasis", "escaped_text", "footnote_ref", "hard_break", "heading_ref", "highlight", "image", "inline_extension", "inline_footnote", "insert", "link", "literal_inline", "math", "mention", "raw_inline", "smart_punctuation", "soft_break", "span", "strike", "strong", "subscript", "substitution", "superscript", "symbol", "tag", "text", "underline"], "list.items": ["list_item"], - "list_item.children": ["abbreviation_def", "admonition", "block_quote", "code_block", "comment", "definition_description", "definition_list", "definition_term", "div", "figure", "footnote", "frontmatter", "heading", "image", "line_block", "link_reference_definition", "list", "list_item", "paragraph", "raw_block", "table", "table_cell", "table_row", "thematic_break"], + "list_item.children": ["abbreviation_def", "admonition", "block_quote", "code_block", "comment", "definition_description", "definition_list", "definition_term", "div", "figure", "figure_group", "footnote", "frontmatter", "heading", "image", "line_block", "link_reference_definition", "list", "list_item", "paragraph", "raw_block", "table", "table_cell", "table_row", "thematic_break"], "paragraph.children": ["abbreviation", "autolink", "caption_number", "citation_group", "code", "comment", "critic_comment", "delete", "emphasis", "escaped_text", "footnote_ref", "hard_break", "heading_ref", "highlight", "image", "inline_extension", "inline_footnote", "insert", "link", "literal_inline", "math", "mention", "raw_inline", "smart_punctuation", "soft_break", "span", "strike", "strong", "subscript", "substitution", "superscript", "symbol", "tag", "text", "underline"], "span.children": ["abbreviation", "autolink", "caption_number", "citation_group", "code", "comment", "critic_comment", "delete", "emphasis", "escaped_text", "footnote_ref", "hard_break", "heading_ref", "highlight", "image", "inline_extension", "inline_footnote", "insert", "link", "literal_inline", "math", "mention", "raw_inline", "smart_punctuation", "soft_break", "span", "strike", "strong", "subscript", "substitution", "superscript", "symbol", "tag", "text", "underline"], "strike.children": ["abbreviation", "autolink", "caption_number", "citation_group", "code", "comment", "critic_comment", "delete", "emphasis", "escaped_text", "footnote_ref", "hard_break", "heading_ref", "highlight", "image", "inline_extension", "inline_footnote", "insert", "link", "literal_inline", "math", "mention", "raw_inline", "smart_punctuation", "soft_break", "span", "strike", "strong", "subscript", "substitution", "superscript", "symbol", "tag", "text", "underline"], @@ -408,6 +415,7 @@ export const WIRE_NESTED_RECORDS: Readonly< "emphasis": { "attrs": { record: "attrs", array: false }, "pos": { record: "pos", array: false } }, "escaped_text": { "attrs": { record: "attrs", array: false }, "pos": { record: "pos", array: false } }, "figure": { "attrs": { record: "attrs", array: false }, "pos": { record: "pos", array: false } }, + "figure_group": { "attrs": { record: "attrs", array: false }, "pos": { record: "pos", array: false } }, "footnote": { "attrs": { record: "attrs", array: false }, "pos": { record: "pos", array: false } }, "footnote_ref": { "attrs": { record: "attrs", array: false }, "pos": { record: "pos", array: false } }, "frontmatter": { "pos": { record: "pos", array: false } }, diff --git a/test/a-bare-figure-fence-is-one-figure-of-ordered-panels.test.ts b/test/a-bare-figure-fence-is-one-figure-of-ordered-panels.test.ts new file mode 100644 index 00000000..4eeda562 --- /dev/null +++ b/test/a-bare-figure-fence-is-one-figure-of-ordered-panels.test.ts @@ -0,0 +1,141 @@ +/* + * PART 9 §4c composite figures: a BARE `::: figure` opener always produces a + * `figure_group`, whatever its content count; an opener carrying a quoted + * title or a `[label]` never matches the figure production and stays a generic + * container; a bare opener inside an open group's body is demoted the same way + * (groups do not nest). The corpus (318-composite-figures*) pins the F1-F10 + * byte shapes; these tests pin the shapes and edges the corpus leaves out. + */ +import { describe, it, expect } from 'vitest' +import { carveToHtml, parse } from '../src/index.js' + +const h = (s: string) => carveToHtml(s) + +describe('a bare figure fence is one figure of ordered panels', () => { + it('parses to a figure_group node discriminated by type, with no target', () => { + const doc = parse('::: figure\n![a](x.png)\n^ (a) c\n:::\n') + expect(doc.children[0]).toMatchObject({ type: 'figure_group' }) + expect(doc.children[0]).not.toHaveProperty('target') + }) + + it('an empty group still renders the panels wrapper', () => { + expect(h('::: figure\n:::')).toBe( + '
\n' + + '
\n' + + '
\n' + + '
', + ) + }) + + it('a group auto-closed at end of input is a group without a caption slot', () => { + // §4c: the caption attaches at the CLOSING fence; a group closed by end of + // input has no closer line to host the slot - and no lines after it either. + expect(h('::: figure\n![a](x.png)\n^ (a) c')).toBe( + '
\n' + + '
\n' + + '
\n' + + ' a\n' + + '
(a) c
\n' + + '
\n' + + '
\n' + + '
', + ) + }) + + it('a group nests inside a blockquote like any block', () => { + expect(h('> ::: figure\n> ![a](x.png)\n> ^ (a) c\n> :::')).toBe( + '
\n' + + '
\n' + + '
\n' + + '
\n' + + ' a\n' + + '
(a) c
\n' + + '
\n' + + '
\n' + + '
\n' + + '
', + ) + }) + + it('the group caption attaches across at most one blank line', () => { + expect(h('::: figure\n![a](x.png)\n^ (a) c\n:::\n\n^ Figure #: Cap')).toContain( + '
Figure 1: Cap
', + ) + }) + + it('the group caption folds continuation lines like a paragraph', () => { + expect(h('::: figure\n![a](x.png)\n^ (a) c\n:::\n^ Figure #: Cap\nfolds on')).toContain( + '
Figure 1: Cap\nfolds on
', + ) + }) + + it('an opener with a title stays a generic container even nested-free', () => { + const doc = parse('::: figure "T"\nBody.\n:::\n') + expect(doc.children[0]).toMatchObject({ type: 'admonition', kind: 'figure', title: [{ type: 'text', value: 'T' }] }) + }) + + it('an opener with a [label] stays a generic container', () => { + const doc = parse('::: figure [g]\nBody.\n:::\n') + expect(doc.children[0]).toMatchObject({ type: 'admonition', kind: 'figure', label: 'g' }) + }) + + it('a bare figure opener demotes anywhere inside an open group body', () => { + // Not only as a DIRECT child: the no-nesting rule follows the recursion, + // so a bare `::: figure` inside a note inside a group is demoted too. + const doc = parse( + '::: figure\n:::: note\n::::: figure\n![a](x.png)\n^ (a) c\n:::::\n::::\n:::\n', + ) + const group = doc.children[0]! + expect(group.type).toBe('figure_group') + const note = (group as { children: Array<{ type: string; kind?: string; children?: unknown[] }> }) + .children[0]! + expect(note).toMatchObject({ type: 'admonition', kind: 'note' }) + expect(note.children![0]).toMatchObject({ type: 'admonition', kind: 'figure' }) + }) + + it('a sibling group after a closed group is a group again', () => { + const doc = parse('::: figure\n:::\n\n::: figure\n:::\n') + expect(doc.children.map((c) => c.type)).toEqual(['figure_group', 'figure_group']) + }) + + it('a reference image with a caption becomes a panel too', () => { + // The syntactic block-image pass only knows the inline `![…](…)` form; a + // reference image is promoted after resolution, and the promotion pass + // descends into group children. + expect(h('::: figure\n![a][r]\n^ (a) c\n:::\n\n[r]: /u.png')).toBe( + '
\n' + + '
\n' + + '
\n' + + ' a\n' + + '
(a) c
\n' + + '
\n' + + '
\n' + + '
', + ) + }) + + it('an authored marker class does not double the injected one', () => { + // The class merge keeps first occurrence and dedupes, the oracle's + // renderBlockAttrs rule - so `{.carve-figure-group}` on the group (or the + // panel marker on a panel) emits the token once. + const out = h( + '{.carve-figure-group}\n::: figure\n{.carve-figure-panel .wide}\n![a](x.png)\n^ (a) c\n:::', + ) + expect(out).toContain('
') + expect(out).toContain('
') + expect(out).not.toContain('carve-figure-group carve-figure-group') + expect(out).not.toContain('carve-figure-panel carve-figure-panel') + }) + + it('an uncaptioned image is group content, not a panel', () => { + // §4c: the panels are the figure and table children. A bare image never + // became a figure, so it sits in the wrapper as preserved content. + expect(h('::: figure\n![a](x.png)\n:::')).toBe( + '
\n' + + '
\n' + + ' a\n' + + '
\n' + + '
', + ) + }) +}) diff --git a/test/a-figure-group-degrades-deterministically.test.ts b/test/a-figure-group-degrades-deterministically.test.ts new file mode 100644 index 00000000..944170cf --- /dev/null +++ b/test/a-figure-group-degrades-deterministically.test.ts @@ -0,0 +1,67 @@ +/* + * PART 11 degradation for the §4c composite figure (D8): + * + * Markdown - panels in source order, each host degraded as usual with its + * caption as an EMPHASIZED paragraph after it, stray content in place, and + * the group caption as a BOLD paragraph at the end. + * + * Plain text / ANSI - the GROUP caption line first, a blank line, then per + * panel its caption line over its host degradation, blank line between. + * + * Tabs / `presentation=` hints are renderer-level and out of scope; hint + * classes pass through in HTML only. + */ +import { describe, it, expect } from 'vitest' +import { carveToMarkdown, carveToPlainText, carveToAnsi } from '../src/index.js' + +const F1 = + '{#fig-x .columns-2}\n::: figure\n{#fig-x-a}\n![one](a.png)\n^ (a) One\n\n{#fig-x-b}\n![two](b.png)\n^ (b) Two\n:::\n^ Figure #: Group caption\n' + +const MIXED = + '::: figure\nProse between.\n\n| K | N |\n|---|---|\n| a | 1 |\n\n``` js\nx\n```\n^ A listing\n:::\n^ Figure #: Mixed\n' + +describe('a figure group degrades deterministically', () => { + it('Markdown: hosts as usual, emphasized panel captions, bold group caption last', () => { + // A BLANK line separates each host from its emphasized caption - the + // caption is its own paragraph (carve-php / carve-rs parity). + expect(carveToMarkdown(F1)).toBe( + '![one](a.png)\n\n*(a) One*\n\n![two](b.png)\n\n*(b) Two*\n\n**Figure 1: Group caption**\n', + ) + }) + + it('Markdown: a table panel keeps its own degradation; stray prose stays in place', () => { + expect(carveToMarkdown(MIXED)).toBe( + 'Prose between.\n\n| K | N |\n| --- | --- |\n| a | 1 |\n\n```js\nx\n```\n\n*A listing*\n\n**Figure 1: Mixed**\n', + ) + }) + + it('plain text: group caption line first, then caption-over-host per panel', () => { + expect(carveToPlainText(F1)).toBe('Figure 1: Group caption\n\n(a) One\none\n\n(b) Two\ntwo\n') + }) + + it('plain text: stray prose stays in source order under the group caption', () => { + expect(carveToPlainText(MIXED)).toBe( + 'Figure 1: Mixed\n\nProse between.\n\nK | N\na | 1\n\nA listing\nx\n', + ) + }) + + it('Markdown: a heading inside a group is a crossref target with an anchor', () => { + // The prepass that indexes heading ids has to DESCEND into the group, or + // a `` to a heading inside one degrades to plain text while the + // heading loses its anchor stamp (carve-php / carve-rs parity). + const src = + 'See .\n\n::: figure\n## Inner heading\n\n![x](x.png)\n^ (a) x\n:::\n^ Figure #: G\n' + expect(carveToMarkdown(src)).toBe( + 'See [Inner heading](#Inner-heading).\n\n## Inner heading {#Inner-heading}\n\n![x](x.png)\n\n*(a) x*\n\n**Figure 1: G**\n', + ) + }) + + it('ANSI: the plain-text shape with caption styling', () => { + const E = '' + expect(carveToAnsi(F1)).toBe( + `${E}[3m${E}[2mFigure 1: Group caption${E}[0m\n\n` + + `${E}[3m${E}[2m(a) One${E}[0m\n${E}[35m[img:${E}[0m one${E}[35m]${E}[0m\n\n` + + `${E}[3m${E}[2m(b) Two${E}[0m\n${E}[35m[img:${E}[0m two${E}[35m]${E}[0m\n`, + ) + }) +}) diff --git a/test/a-figure-group-is-one-numbering-unit-with-lettered-panels.test.ts b/test/a-figure-group-is-one-numbering-unit-with-lettered-panels.test.ts new file mode 100644 index 00000000..df044292 --- /dev/null +++ b/test/a-figure-group-is-one-numbering-unit-with-lettered-panels.test.ts @@ -0,0 +1,59 @@ +/* + * PART 9 §4c numbering: the group consumes ONE number from the shared + * per-label sequence; panels consume none. A panel id resolves `` with + * the group's number plus a letter by panel order (`Figure 2a`); a `#` in a + * PANEL caption stays literal - panels are not sequence units. + */ +import { describe, it, expect } from 'vitest' +import { carveToHtml } from '../src/index.js' + +const h = (s: string) => carveToHtml(s) + +describe('a figure group is one numbering unit with lettered panels', () => { + it('the group draws one number; panels draw none', () => { + const out = h( + '![lead](l.png)\n^ Figure #: First\n\n::: figure\n![a](x.png)\n^ (a) c\n:::\n^ Figure #: Second\n\n![tail](t.png)\n^ Figure #: Third', + ) + expect(out).toContain('
Figure 1: First
') + expect(out).toContain('
Figure 2: Second
') + expect(out).toContain('
Figure 3: Third
') + }) + + it('panel ids resolve with the group number plus a letter, tables included', () => { + const out = h( + '{#g}\n::: figure\n{#p-a}\n![a](x.png)\n^ a\n\n{#p-b}\n| h |\n|---|\n| c |\n\n{#p-c}\n``` js\nx\n```\n^ c\n:::\n^ Figure #: G\n\n ', + ) + expect(out).toContain('Figure 1a') + expect(out).toContain('Figure 1b') + expect(out).toContain('Figure 1c') + expect(out).toContain('Figure 1') + }) + + it('letters count panels only, not stray content between them', () => { + const out = h( + '::: figure\nA note between panels.\n\n{#p-a}\n![a](x.png)\n^ a\n\nMore prose.\n\n{#p-b}\n![b](y.png)\n^ b\n:::\n^ Figure #: G\n\n', + ) + expect(out).toContain('Figure 1b') + }) + + it('a # in a panel caption stays literal', () => { + const out = h('::: figure\n![a](x.png)\n^ Figure #: not numbered\n:::\n^ Figure #: G') + expect(out).toContain('
Figure #: not numbered
') + expect(out).toContain('
Figure 1: G
') + }) + + it('an uncaptioned group numbers nothing and registers no panel ids', () => { + const out = h('::: figure\n{#p}\n![a](x.png)\n^ a\n:::\n\n') + // The crossref degrades to its literal source text, like any other + // `` whose target never registered an auto-text. + expect(out).toContain('</#p>') + }) + + it('the group label word keys the sequence, shared with plain figures', () => { + const out = h( + '| h |\n|---|\n| c |\n^ Table #: T1\n\n::: figure\n![a](x.png)\n^ a\n:::\n^ Table #: T2', + ) + expect(out).toContain('
Table 1: T1