From b4b487963fceb21c0c45370dcaec4cd4fc21732e Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Sat, 15 Aug 2026 14:28:53 +0200 Subject: [PATCH] fix: a captioned quote is a figure in both directions again The spec withdrew the quote attribution model in markup-carve/carve#1213, so a captioned quote is a `figure` whose target is a `block_quote` and whose caption is the `^ ` line. This bridge still carried the withdrawn model, and that was not merely stale bookkeeping - it was two live defects against the pinned engine, both measured rather than argued. The exchange AST did not validate. `pandocToCarveAst` put an `attribution` field on a `block_quote`, and `resources/ast-schema.json` refuses it: the node declares `additionalProperties: false`, so reversing a `Figure[BlockQuote]` produced a document the published schema rejects with `{"keyword":"additionalProperties","params":{"additionalProperty":"attribution"}}`. The one thing an exchange AST has to be is the exchange AST. Caption numbering disagreed with the engine. For a captioned quote followed by a captioned image, both spelling `Figure #`, the engine renders `Figure 1` then `Figure 2`. The bridge kept the quote's `#` literal and started the sequence at the image, so pandoc's HTML writer printed `Figure #: Hamlet` and `Figure 1: Second` - a placeholder that never resolves and an off-by-one on every later figure and cross-reference. A quote figure now lowers through the ordinary figure path, and a `Figure[BlockQuote]` reverses to `figure{target: block_quote}`. The runtime probe of the engine's serializer and the lowering that rewrote the shape on the way out are both gone with the model they served. The attribution lowering existed for a real reason and the reason still holds: pandoc's plain and rst writers drop a `Figure`'s caption. Measured on pandoc 3.5, that is not quote-specific. The plain writer drops the caption of EVERY figure, image figures included; the rst writer keeps one only through the `.. figure::` directive, which is the image case. A figure wrapping a code block loses it identically. So the loss belongs to pandoc's writers for non-image figures, and routing around it by moving the caption inside the quote puts the attribution in the one place the HTML Standard forbids - which is what the spec withdrew. latex, html, markdown, docbook and org all keep it, and the roundtrip test now asserts both halves of that table so the day pandoc changes, the test says so. Nothing had to be kept working: `block_quote.attribution` never appeared in any published engine (the carve npm package is at 0.1.3, which has no such field), and this bridge has no tag and no npm release, so no document produced by the withdrawn model exists outside a working tree. Reading the `[X]{.attribution}` span back in was therefore dropped rather than kept: nothing emits it anymore, and re-importing it would move content out of a blockquote on the strength of a class name this bridge itself invented. Measuring the new path surfaced a separate crash that predates it. Pandoc's own HTML reader emits a `Figure` with an EMPTY caption for `
`, and both figure branches built a `figure` node with no `caption` field from it - refused by the schema, which requires the field, and fatal in the writer with `Cannot read properties of undefined (reading 'forEach')`. Emitting `caption: []` instead is not the fix: `renderCarve` writes a lone `^` line for it, and that line re-parses as a lazy continuation, so `> q` comes back as the two-line paragraph `q\n^` inside the quote. An uncaptioned figure is emitted as its host instead - a shape Carve source can spell - and the dropped wrapper is reported. --- CHANGELOG.md | 33 +++++------ README.md | 6 +- src/convert.ts | 72 +----------------------- src/index.ts | 54 +----------------- src/reverse.ts | 107 ++++++++++++++++-------------------- test/ast-json.test.mjs | 18 +++++- test/new-ast-nodes.test.mjs | 69 +++++++---------------- test/reverse.test.mjs | 70 ++++++++++++++--------- test/roundtrip.test.mjs | 22 ++++---- test/unit.test.mjs | 40 ++++++-------- 10 files changed, 175 insertions(+), 316 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 573a216..4912738 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -121,32 +121,25 @@ ### Fixed +- **Captioned quotes map as figures again.** A `figure` whose target is a + `block_quote` now maps directly to pandoc's `Figure[BlockQuote]` in both + directions. The resulting exchange AST validates against the published + schema, and quote figures share caption numbering with every other figure. + +- **An uncaptioned pandoc `Figure` converts instead of throwing.** Pandoc's own + HTML reader emits a `Figure` with an empty caption for + `
`, and the bridge built a `figure` node + with no `caption` field from it - a shape the published schema refuses, + because the field is required, and one the writer crashed on. The content is + emitted without the wrapper now, which is the shape Carve source can spell, + and the dropped wrapper is reported. + - **A pandoc `Quoted` says that it degrades.** The quotation was rewritten to literal curly quote characters with nothing reported, and the text re-exports as a plain `Str`, so the quote kind and pandoc's locale-aware quoting left the document silently. The characters stay - they are what an author would have typed, and Carve has no quote node - but the conversion now reports the loss, once per document however many quotations it holds. -- **A quote attribution rides inside the `BlockQuote` and survives every pandoc - writer.** The spec made a caption on a quote an attribution rather than a - figure caption (PART 9 section 4a, markup-carve/carve#1159), and the bridge's - old `Figure`-wrapped lowering lost the attribution wholesale in pandoc's - plain and rst writers and numbered the quote as a float in latex. The quote - now lowers to a `BlockQuote` whose last block is a `Para` holding one - `attribution`-classed `Span`, which every writer keeps attached, and which - the reverse direction recognizes and folds back - `> quote` + `^ author` - round-trips to identical source, including through pandoc's own markdown. - Both input shapes convert identically: the `attribution` field an engine past - markup-carve/carve#1159 serializes, and the quote-figure shape the pinned - `^0.1.2` engine line still parses. On the way back, the serializer is probed - once - a pre-4a engine whose `renderCarve` would drop the field silently gets - the shape it can write, while `pandocToCarveAst` always keeps the spec shape. - A quote consumes no figure number anymore, so the numbering of every later - figure and its cross-references no longer drifts by one per attributed quote, - and a `#` placeholder in an attribution stays literal. An incoming - `Figure`-wrapped `BlockQuote` (this bridge's own earlier output) upgrades to - the attribution model; its short caption, which a quote cannot carry, is - dropped with a degradation warning instead of silently. - **Line blocks actually reach `LineBlock` now.** The arm for the `line_block` node type has been here since the smart-punctuation change, and no document could reach it: the PINNED published engine models `::: |` as a div carrying diff --git a/README.md b/README.md index 81ddedc..9430c5a 100644 --- a/README.md +++ b/README.md @@ -132,7 +132,7 @@ node -e "import('@markup-carve/pandoc-carve').then(m => process.stdout.write(m.c | Cell attributes `\|{#id .cls k=v} text`, row attributes `\| a \|{.cls}` | the `Attr` pandoc's Cell and Row already carry | | Footnotes (reference and inline `^[..]`) | Note | | Math `` $`..` `` / `` $$`..` `` | Math Inline / Display | -| Images/quotes with `^ caption` lines | Figure | +| Images/quotes with `^ caption` lines | Figure; a quote target maps to `Figure[BlockQuote]` | | `::: note` admonitions | Div `.admonition .note` (+ title paragraph) | | Tabs / code-group panels, grouping `[label]` | Div; each `[label]` becomes a bold caption so panels stay distinguishable (graceful degradation) | | `` `x`{=latex} `` / ```` ```=latex ```` | RawInline / RawBlock (target-routed by pandoc) | @@ -148,6 +148,10 @@ The complete node-by-node contract lives in the test goldens. Worked input/output pairs in both directions - including how interactive constructs degrade for print formats - are in [`examples/`](examples/README.md). +Pandoc's plain and rst writers drop captions from non-image figures, including +quote figures; this is writer behavior rather than a quote-specific bridge +mapping. Writers such as latex, html, and markdown preserve the caption. + ## Why a bridge, not a pandoc reader? A native `Text.Pandoc.Readers.Carve` upstream would be a fourth full Carve diff --git a/src/convert.ts b/src/convert.ts index 88e8283..dd1a2a9 100644 --- a/src/convert.ts +++ b/src/convert.ts @@ -272,35 +272,6 @@ function verbatimInlines(raw: string): P.Inline[] { * `Listing #`, `Figure #` on three figures numbers them Figure 1, Listing 1, * Figure 2. Keying on figure-versus-table would have made that Listing 2. */ -/** Outer (figure) attrs win per field; the inner quote keeps the rest. */ -function mergeAttrs(inner: CAttrs | undefined, outer: CAttrs | undefined): CAttrs { - const out: CAttrs = {}; - const id = outer?.id ?? inner?.id; - if (id) out.id = id; - const classes = [...(inner?.classes ?? []), ...(outer?.classes ?? [])].filter( - (c, i, all) => all.indexOf(c) === i, - ); - if (classes.length) out.classes = classes; - const keyValues = { ...(inner?.keyValues ?? {}), ...(outer?.keyValues ?? {}) }; - if (Object.keys(keyValues).length) out.keyValues = keyValues; - return out; -} - -/** - * A quote's attribution as Pandoc inlines (PART 9 §4a). A `#` placeholder has - * nothing to resolve against on a quote and stays a literal `#`, so the - * `caption_number` node is flattened to text BEFORE the numbering counter in - * `inline()` can see it - an engine pinned at `^0.1.2` still parses the - * construct as a numbered figure and hands the placeholder through. - */ -function attributionInlines(ctx: Ctx, nodes: CNode[] | undefined): P.Inline[] | null { - if (!Array.isArray(nodes)) return null; - return inlines( - ctx, - nodes.map((x) => (x?.type === 'caption_number' ? { type: 'text', value: '#' } : x)), - ); -} - function captionLabel(nodes: CNode[] | undefined): string | undefined { if (!Array.isArray(nodes)) return undefined; const at = nodes.findIndex((x) => x?.type === 'caption_number'); @@ -722,14 +693,8 @@ function blockInner(ctx: Ctx, n: CNode): P.Block[] { } case 'heading': return [P.Header(Number(n.level ?? 1), toAttr(n.attrs), kids(ctx, n))]; - case 'block_quote': { - const content = untight(ctx, () => blocks(ctx, n.children as CNode[])); - const attribution = attributionInlines(ctx, n.attribution as CNode[] | undefined); - if (attribution) { - content.push(P.Para([P.Span(P.attr('', ['attribution']), attribution)])); - } - return [P.BlockQuote(content)]; - } + case 'block_quote': + return [P.BlockQuote(untight(ctx, () => blocks(ctx, n.children as CNode[])))]; case 'code_block': { const lang = n.lang ? [String(n.lang)] : []; const a = (n.attrs ?? {}) as CAttrs; @@ -1229,31 +1194,6 @@ function listTableToTable(ctx: Ctx, n: CNode): P.Block | null { function figure(ctx: Ctx, n: CNode): P.Block[] { const target = n.target as CNode | undefined; - if (target?.type === 'block_quote') { - // PART 9 §4a (carve#1159): a captioned quote is a quote carrying an - // attribution, not a figure. Engines pinned at `^0.1.2` still parse - // the construct into this figure shape, so it is synthesized into the - // quote-with-attribution node and converted as one - both input - // shapes lower identically, and the attrs Div-wrapper in `block()` - // applies as for any quote. Before the reroute, the caption reached - // Pandoc as a `Figure` wrapper, which the plain and rst writers drop - // wholesale and the latex writer numbers as a float. - if (Array.isArray(n.shortCaption) && n.shortCaption.length) { - warn(ctx, 'quote attribution: short caption dropped (a quote has no navigation-caption slot)'); - } - const quote: CNode = { ...target }; - if (Array.isArray(n.caption)) quote.attribution = n.caption; - const figAttrs = n.attrs as CAttrs | undefined; - const quoteAttrs = target.attrs as CAttrs | undefined; - if (hasAttrs(figAttrs) || hasAttrs(quoteAttrs)) { - // The two nodes collapse into one, so their attrs merge rather - // than the figure's replacing the quote's: the figure's id wins - // (it was the referenceable one), classes union, key/values merge - // with the figure's taking precedence. - quote.attrs = mergeAttrs(quoteAttrs, figAttrs); - } - return block(ctx, quote); - } ctx.captionKind = captionLabel(n.caption as CNode[] | undefined); const caption = Array.isArray(n.caption) ? inlines(ctx, n.caption as CNode[]) : null; const shortCaption = Array.isArray(n.shortCaption) @@ -1512,13 +1452,7 @@ function collectCrossrefTargets(ctx: Ctx, nodes: CNode[], captionCounts: Map x?.type === 'caption_number')) { const label = captionLabel(caption) ?? 'caption'; diff --git a/src/index.ts b/src/index.ts index e505cfd..9b23888 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,57 +21,6 @@ export type { CarveAstDocument, CarveAstNode } from './ast-json.js'; */ const engineSerializer = (carve as unknown as { toAstJson?: (doc: unknown) => unknown }).toAstJson; -/** - * Whether the installed engine's `renderCarve` serializes the PART 9 §4a - * `attribution` field on a block quote. A pre-§4a engine drops the field - * SILENTLY - the probe renders a one-node document and looks for the marker, - * because there is no version to sniff that answers this reliably. - */ -let engineWritesAttributionCache: boolean | undefined; -function engineWritesAttribution(): boolean { - if (engineWritesAttributionCache === undefined) { - const probe = { - type: 'document', - children: [ - { - type: 'block_quote', - children: [ - { type: 'paragraph', children: [{ type: 'text', value: 'q' }] }, - ], - attribution: [{ type: 'text', value: 'carve-attribution-probe' }], - }, - ], - }; - try { - engineWritesAttributionCache = carve - .renderCarve(probe as unknown as Parameters[0]) - .includes('carve-attribution-probe'); - } catch { - engineWritesAttributionCache = false; - } - } - return engineWritesAttributionCache; -} - -/** - * Lower every attributed quote back to the pre-§4a figure shape - the shape a - * `^0.1.2`-line engine still parses AND serializes as `> quote` + `^ text`. - * Only the serialization path uses this; the exchange AST keeps the §4a shape. - */ -function lowerAttributionForEngine(value: unknown): unknown { - if (Array.isArray(value)) return value.map(lowerAttributionForEngine); - if (!value || typeof value !== 'object') return value; - const out: Record = {}; - for (const [k, v] of Object.entries(value)) out[k] = lowerAttributionForEngine(v); - if (out['type'] === 'block_quote' && Array.isArray(out['attribution'])) { - const { attribution, attrs, ...quote } = out; - const fig: Record = { type: 'figure', target: quote, caption: attribution }; - if (attrs) fig['attrs'] = attrs; - return fig; - } - return out; -} - /** * Parse Carve source to the serialized AST of PART 12 - the shape * `resources/ast-schema.json` pins, and the shape every engine's `--to-json` @@ -123,9 +72,8 @@ export function carveToPandocJson(source: string, options?: ConvertOptions): str export function pandocToCarve(doc: PandocDoc | string): { carve: string; warnings: string[] } { const parsed: PandocDoc = typeof doc === 'string' ? (JSON.parse(doc) as PandocDoc) : doc; const { ast, warnings } = reverse(parsed); - const writable = engineWritesAttribution() ? ast : lowerAttributionForEngine(ast); return { - carve: carve.renderCarve(writable as Parameters[0]), + carve: carve.renderCarve(ast as Parameters[0]), warnings, }; } diff --git a/src/reverse.ts b/src/reverse.ts index eedb3cd..b57c0ac 100644 --- a/src/reverse.ts +++ b/src/reverse.ts @@ -47,7 +47,7 @@ interface CNode { } export interface ReverseResult { - ast: CNode; + ast: CNode & { children: CNode[] }; warnings: string[]; } @@ -528,20 +528,8 @@ function block(ctx: Ctx, n: PandocNode): CNode[] { if (attrs) node.attrs = attrs; return [node]; } - case 'BlockQuote': { - const body = c as PandocNode[]; - const attribution = attributionFromBlocks(ctx, body); - if (attribution) { - return [ - { - type: 'block_quote', - children: blocks(ctx, body.slice(0, -1)), - attribution, - }, - ]; - } - return [{ type: 'block_quote', children: blocks(ctx, body) }]; - } + case 'BlockQuote': + return [{ type: 'block_quote', children: blocks(ctx, c as PandocNode[]) }]; case 'CodeBlock': { const [a, content] = c as [Attr, string]; const [id, classes, kvs] = a; @@ -1006,27 +994,44 @@ function captionFromInlines(ctx: Ctx, caption: PandocNode[] | null | undefined): return caption?.length ? inlines(ctx, caption) : null; } +// --- Figures and divs --- + /** - * A quote's attribution, when the last block is a `Para`/`Plain` holding - * exactly one Span whose whole Attr is the single class `attribution` - the - * shape convert.ts emits for PART 9 §4a. A Span that ALSO carries an id, more - * classes or key/values is someone's content and stays where it is; a foreign - * document using the bare idiom reads back as the attribution it claims to be. + * A `figure` node, or the bare host when the figure carries no caption. + * + * `figure.caption` is REQUIRED by `resources/ast-schema.json`, and an empty one + * has no Carve spelling: `renderCarve` writes a lone `^` line for it, and that + * line re-parses as a lazy continuation - `> q` plus `^` comes back as the + * two-line paragraph `q\n^` INSIDE the quote, not as a caption. So an + * uncaptioned figure is emitted as its host, which is a shape Carve source can + * spell, and the wrapper is reported rather than dropped in silence. + * + * Not a corner case: pandoc's own HTML reader emits exactly this for + * `
`. Before the guard, both branches built a + * `figure` with no `caption` field, which failed schema validation and made + * `renderCarve` throw `Cannot read properties of undefined (reading 'forEach')`. */ -function attributionFromBlocks(ctx: Ctx, body: PandocNode[]): CNode[] | null { - const last = body[body.length - 1]; - if (!last || (last.t !== 'Para' && last.t !== 'Plain')) return null; - const xs = last.c as PandocNode[]; - if (xs.length !== 1 || xs[0]!.t !== 'Span') return null; - const [[id, classes, kvs], inner] = xs[0]!.c as [Attr, PandocNode[]]; - if (id !== '' || kvs.length !== 0 || classes.length !== 1 || classes[0] !== 'attribution') { - return null; +function captionedFigure( + ctx: Ctx, + target: CNode, + caption: CNode[] | null, + shortCaption: CNode[] | null, + a: Attr, + host: () => CNode, +): CNode[] { + const attrs = fromAttr(a); + if (!caption) { + warn(ctx, 'figure: an uncaptioned figure has no Carve spelling - the wrapper is dropped and its content kept'); + const bare = host(); + if (attrs) bare.attrs = attrs; + return [bare]; } - return inlines(ctx, inner); + const node: CNode = { type: 'figure', target, caption }; + if (shortCaption) node.shortCaption = shortCaption; + if (attrs) node.attrs = attrs; + return [node]; } -// --- Figures and divs --- - function figure(ctx: Ctx, c: never): CNode[] { const [a, capt, body] = c as [Attr, [unknown, PandocNode[]], PandocNode[]]; const caption = captionFromBlocks(ctx, capt[1]); @@ -1036,38 +1041,18 @@ function figure(ctx: Ctx, c: never): CNode[] { if (single?.t === 'Plain' || single?.t === 'Para') { const xs = single.c as PandocNode[]; if (xs.length === 1 && xs[0]!.t === 'Image') { - const [img] = inline(ctx, xs[0]!); - const node: CNode = { type: 'figure', target: img }; - if (caption) node.caption = caption; - if (shortCaption) node.shortCaption = shortCaption; - const attrs = fromAttr(a); - if (attrs) node.attrs = attrs; - return [node]; + const img = inline(ctx, xs[0]!)[0]!; + // A block image is a paragraph holding the image, which is what + // `![alt](src)` on its own line parses to. + return captionedFigure(ctx, img, caption, shortCaption, a, () => ({ + type: 'paragraph', + children: [img], + })); } } if (single?.t === 'BlockQuote') { - // PART 9 §4a: a captioned quote is a quote carrying an attribution, - // not a figure. This branch also upgrades this bridge's own pre-§4a - // output, which wrapped the quote in a Figure. - const [bq] = block(ctx, single) as [CNode]; - if (caption) { - if (Array.isArray(bq.attribution)) { - // Both an inner attribution Span and an outer Figure caption: - // the caption is the outer author's statement, the inner one - // stays visible as an ordinary trailing paragraph. - (bq.children as CNode[]).push({ - type: 'paragraph', - children: bq.attribution as CNode[], - }); - } - bq.attribution = caption; - } - if (shortCaption) { - warn(ctx, 'quote attribution: short caption dropped (a quote has no navigation-caption slot)'); - } - const attrs = fromAttr(a); - if (attrs) bq.attrs = attrs; - return [bq]; + const [quote] = block(ctx, single) as [CNode]; + return captionedFigure(ctx, quote, caption, shortCaption, a, () => quote); } if (single?.t === 'Table') { return [table(ctx, single.c as never, caption, shortCaption)]; @@ -1261,7 +1246,7 @@ export function pandocToCarve(doc: PandocDoc): ReverseResult { for (const [abbr, expansion] of [...ctx.abbrevDefs].reverse()) { children.unshift({ type: 'abbreviation_def', abbr, expansion }); } - const ast: CNode = { type: 'document', children }; + const ast: CNode & { children: CNode[] } = { type: 'document', children }; if (Object.keys(ctx.footnoteDefs).length) ast.footnoteDefs = ctx.footnoteDefs; const yaml = metaToYaml(ctx, doc.meta ?? {}); if (yaml) ast.frontmatter = { format: 'yaml', content: yaml }; diff --git a/test/ast-json.test.mjs b/test/ast-json.test.mjs index 8f5ce1c..b2b8138 100644 --- a/test/ast-json.test.mjs +++ b/test/ast-json.test.mjs @@ -20,7 +20,7 @@ import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import Ajv2020 from 'ajv/dist/2020.js'; import { normalizeCarveAst, parseCarveAst, toCarveAst } from '../dist/ast-json.js'; -import { carveAstToPandoc, carveToCarveAst, carveToPandoc } from '../dist/index.js'; +import { carveAstToPandoc, carveToCarveAst, carveToPandoc, pandocToCarveAst } from '../dist/index.js'; const repo = join(dirname(fileURLToPath(import.meta.url)), '..'); const schema = JSON.parse(readFileSync(join(repo, 'spec', 'resources', 'ast-schema.json'), 'utf8')); @@ -111,6 +111,22 @@ test('the serialized document validates against the spec AST schema', () => { assertConforms(carveToCarveAst(SOURCE), 'serialized source'); }); +test('a reversed Figure containing a BlockQuote validates against the spec AST schema', () => { + const { ast } = pandocToCarveAst({ + 'pandoc-api-version': [1, 23, 1], + meta: {}, + blocks: [{ + t: 'Figure', + c: [ + ['', [], []], + [null, [{ t: 'Plain', c: [{ t: 'Str', c: 'Hamlet' }] }]], + [{ t: 'BlockQuote', c: [{ t: 'Para', c: [{ t: 'Str', c: 'To' }, { t: 'Space' }, { t: 'Str', c: 'be' }] }] }], + ], + }], + }); + assertConforms(ast, 'reversed quote figure'); +}); + // --- The other direction: a tree that arrived already serialized --- const WIRE = { diff --git a/test/new-ast-nodes.test.mjs b/test/new-ast-nodes.test.mjs index dd8ba45..603bf0b 100644 --- a/test/new-ast-nodes.test.mjs +++ b/test/new-ast-nodes.test.mjs @@ -178,33 +178,8 @@ test('caption numbering for crossref targets stays in sync when some figures car assert.ok(strs(result).includes('See Listing 1 and Figure 2 .')) }) -test('a §4a quote attribution rides inside the BlockQuote as an attribution Span', () => { - // The shape an engine past carve#1159 serializes: `attribution` on the - // block_quote itself, no figure wrapper. Hand-built because the pinned - // engine still parses the source into the old quote-figure shape - and the - // arm has to be here before the dependency bump, not after. +test('a captioned quote lowers as a Figure containing a BlockQuote', () => { const result = convert( - doc([ - { - type: 'block_quote', - children: [para([{ type: 'text', value: 'To be' }])], - attribution: [{ type: 'text', value: 'Hamlet' }], - }, - ]), - ) - assert.deepEqual(result.warnings, []) - const [quote] = result.doc.blocks - assert.equal(quote.t, 'BlockQuote') - const last = quote.c[quote.c.length - 1] - assert.deepEqual(last, { - t: 'Para', - c: [{ t: 'Span', c: [['', ['attribution'], []], [{ t: 'Str', c: 'Hamlet' }]] }], - }) -}) - -test('both attribution shapes lower to the identical Pandoc document', () => { - // Old shape: what a `^0.1.2`-line engine hands over for `> To be` + `^ Hamlet`. - const old = convert( doc([ { type: 'figure', @@ -213,22 +188,15 @@ test('both attribution shapes lower to the identical Pandoc document', () => { }, ]), ) - const neu = convert( - doc([ - { - type: 'block_quote', - children: [para([{ type: 'text', value: 'To be' }])], - attribution: [{ type: 'text', value: 'Hamlet' }], - }, - ]), - ) - assert.deepEqual(old.doc.blocks, neu.doc.blocks) - assert.deepEqual(old.warnings, []) + assert.deepEqual(result.warnings, []) + const [figure] = result.doc.blocks + assert.equal(figure.t, 'Figure') + assert.equal(figure.c[2][0].t, 'BlockQuote') + assert.equal(figure.c[1][1][0].c[0].c, 'Hamlet') + assert.ok(!JSON.stringify(figure).includes('attribution')) }) -test('a quote-figure short caption is dropped with a warning', () => { - // The §4a model has no navigation-caption slot on a quote; silent loss is - // the one thing a bridge must not do with it. +test('a quote-figure short caption is preserved', () => { const result = convert( doc([ { @@ -239,12 +207,11 @@ test('a quote-figure short caption is dropped with a warning', () => { }, ]), ) - assert.equal(result.warnings.length, 1) - assert.ok(result.warnings[0].includes('short caption'), result.warnings[0]) + assert.deepEqual(result.warnings, []) + assert.equal(result.doc.blocks[0].c[1][0][0].c, 'short') }) -test('a quote-figure upgrade merges figure and quote attrs instead of overwriting', () => { - // The two nodes collapse into one §4a quote; attrs on BOTH must survive. +test('a quote-figure keeps figure and quote attrs on their respective nodes', () => { const result = convert( doc([ { @@ -260,12 +227,14 @@ test('a quote-figure upgrade merges figure and quote attrs instead of overwritin ]), ) assert.deepEqual(result.warnings, []) - // BlockQuote has no Attr slot, so the merged attrs ride on the Div wrapper. - const [div] = result.doc.blocks - assert.equal(div.t, 'Div') - const [id, classes, kvs] = div.c[0] + const [figure] = result.doc.blocks + assert.equal(figure.t, 'Figure') + const [id, classes, kvs] = figure.c[0] assert.equal(id, 'outer') - assert.deepEqual(classes, ['kept', 'fancy']) - assert.deepEqual(Object.fromEntries(kvs), { a: '1', b: '3' }) + assert.deepEqual(classes, ['fancy']) + assert.deepEqual(Object.fromEntries(kvs), { b: '3' }) + const div = figure.c[2][0] + assert.equal(div.t, 'Div') + assert.deepEqual(div.c[0], ['inner', ['kept'], [['a', '1'], ['b', '2']]]) assert.equal(div.c[1][0].t, 'BlockQuote') }) diff --git a/test/reverse.test.mjs b/test/reverse.test.mjs index 372ba78..4aa4630 100644 --- a/test/reverse.test.mjs +++ b/test/reverse.test.mjs @@ -218,29 +218,23 @@ test('reverse: multi-block Note becomes a reference footnote with generated id', assert.ok(carve.includes('second')); }); -test('a quote attribution round-trips to identical Carve source', () => { - // Forward emits the attribution Span inside the BlockQuote; reverse detects - // it and rebuilds the §4a quote - then serializes through whatever shape - // the installed engine's renderCarve actually writes (probed, not sniffed). +test('a captioned quote round-trips to identical Carve source', () => { const src = '> To be, or not to be.\n^ Hamlet\n'; const { carve, warnings } = pandocToCarve(carveToPandoc(src).doc); assert.deepEqual(warnings, []); assert.equal(carve, src); }); -test('the exchange AST keeps the §4a attribution shape', () => { +test('the exchange AST keeps the quote figure shape', () => { const { ast, warnings } = pandocToCarveAst(carveToPandoc('> q\n^ Hamlet\n').doc); assert.deepEqual(warnings, []); - const [quote] = ast.children; - assert.equal(quote.type, 'block_quote'); - assert.deepEqual(quote.attribution, [{ type: 'text', value: 'Hamlet' }]); - assert.ok(!JSON.stringify(ast).includes('"figure"'), 'no quote-figure wrapper'); + const [figure] = ast.children; + assert.equal(figure.type, 'figure'); + assert.equal(figure.target.type, 'block_quote'); + assert.deepEqual(figure.caption, [{ type: 'text', value: 'Hamlet' }]); }); -test('a foreign Figure-wrapped BlockQuote upgrades to a quote with attribution', () => { - // This bridge's own pre-§4a output, and any pandoc filter that produced the - // same shape. The new schema refuses figure{target: block_quote}, so the - // reverse direction must not fabricate it. +test('a Figure-wrapped BlockQuote becomes a quote figure', () => { const doc = { 'pandoc-api-version': [1, 23, 1], meta: {}, @@ -260,25 +254,20 @@ test('a foreign Figure-wrapped BlockQuote upgrades to a quote with attribution', assert.equal(carve, '> wise\n^ Author\n'); }); -test('a Span carrying more than the attribution class is content, not attribution', () => { +test('a classed Span in a BlockQuote remains quote content', () => { const quoteWith = (span) => ({ 'pandoc-api-version': [1, 23, 1], meta: {}, blocks: [{ t: 'BlockQuote', c: [{ t: 'Para', c: [span] }] }], }); - // id present -> stays a paragraph inside the quote const kept = pandocToCarve( - quoteWith({ t: 'Span', c: [['x1', ['attribution'], []], [{ t: 'Str', c: 'A' }]] }), + quoteWith({ t: 'Span', c: [['x1', ['source'], []], [{ t: 'Str', c: 'A' }]] }), ); assert.ok(!kept.carve.includes('^ '), kept.carve); - // bare class -> attribution - const taken = pandocToCarve( - quoteWith({ t: 'Span', c: [['', ['attribution'], []], [{ t: 'Str', c: 'A' }]] }), - ); - assert.equal(taken.carve, '>\n^ A\n'); + assert.ok(kept.carve.includes('A'), kept.carve); }); -test('a Figure-wrapped quote with a short caption warns instead of losing it silently', () => { +test('a Figure-wrapped quote preserves its short caption in the exchange AST', () => { const doc = { 'pandoc-api-version': [1, 23, 1], meta: {}, @@ -293,7 +282,38 @@ test('a Figure-wrapped quote with a short caption warns instead of losing it sil }, ], }; - const { warnings } = pandocToCarve(doc); - assert.equal(warnings.length, 1); - assert.ok(warnings[0].includes('short caption'), warnings[0]); + const { ast } = pandocToCarveAst(doc); + assert.deepEqual(ast.children[0].shortCaption, [{ type: 'text', value: 'nav' }]); +}); + +const uncaptioned = (body) => ({ + 'pandoc-api-version': [1, 23, 1], + meta: {}, + blocks: [{ t: 'Figure', c: [['', [], []], [null, []], [body]] }], +}); + +test('an uncaptioned Figure keeps its content instead of throwing', () => { + // pandoc's own HTML reader emits `Figure` with an EMPTY caption for + // `
` and for a figure-wrapped quote, so + // this is ordinary input. A `figure` node with no `caption` fails the spec + // schema (the field is required) and made renderCarve throw + // `Cannot read properties of undefined (reading 'forEach')` - the wrapper is + // dropped and reported instead, because an empty caption has no spelling: + // renderCarve writes a lone `^` line for it and that line re-parses as a lazy + // continuation of the quote. + const quote = pandocToCarve( + uncaptioned({ t: 'BlockQuote', c: [{ t: 'Para', c: [{ t: 'Str', c: 'To be' }] }] }), + ); + assert.equal(quote.carve, '> To be\n'); + assert.equal(quote.warnings.length, 1); + assert.ok(quote.warnings[0].includes('uncaptioned figure'), quote.warnings[0]); + + const image = pandocToCarve( + uncaptioned({ + t: 'Plain', + c: [{ t: 'Image', c: [['', [], []], [{ t: 'Str', c: 'alt' }], ['a.png', '']] }], + }), + ); + assert.equal(image.carve, '![alt](a.png)\n'); + assert.equal(image.warnings.length, 1); }); diff --git a/test/roundtrip.test.mjs b/test/roundtrip.test.mjs index 18344a3..0765b38 100644 --- a/test/roundtrip.test.mjs +++ b/test/roundtrip.test.mjs @@ -115,26 +115,24 @@ test('a user carve-label attribute is not mistaken for the internal label marker assert.ok(!carve.includes('[mine]'), 'attribute not turned into a grouping label'); }); -test('a quote attribution stays attached in every pandoc writer', { skip: !pandoc && 'pandoc not found' }, () => { - // §4a's 10c principle carried through the bridge: the attribution rides - // INSIDE the BlockQuote, so no writer can detach or drop it. The old - // Figure-wrapped lowering lost the attribution WHOLESALE in the plain and - // rst writers, and latex numbered the quote as a figure float. +test('a quote-figure caption follows pandoc writer behavior', { skip: !pandoc && 'pandoc not found' }, () => { + // Pandoc 3.5's plain and rst writers drop captions from every non-image + // figure, while latex, html, and markdown preserve them. const { doc, warnings } = carveToPandoc('> To be, or not to be.\n^ Hamlet\n'); assert.deepEqual(warnings, []); const plain = pandocRender(pandoc, doc, 'plain'); - assert.ok(plain.includes('Hamlet'), 'plain keeps the attribution: ' + plain); + assert.ok(!plain.includes('Hamlet'), 'plain drops the non-image figure caption: ' + plain); const rst = pandocRender(pandoc, doc, 'rst'); - assert.ok(rst.includes('Hamlet'), 'rst keeps the attribution: ' + rst); + assert.ok(!rst.includes('Hamlet'), 'rst drops the non-image figure caption: ' + rst); const latex = pandocRender(pandoc, doc, 'latex'); - assert.ok(latex.includes('Hamlet'), 'latex keeps the attribution'); - assert.ok(!latex.includes('\\begin{figure}'), 'latex does not float the quote'); - const quoteEnv = latex.indexOf('\\end{quote}'); - assert.ok(latex.indexOf('Hamlet') < quoteEnv, 'latex attribution inside the quote env'); + assert.ok(latex.includes('Hamlet'), 'latex keeps the caption'); + + const html = pandocRender(pandoc, doc, 'html'); + assert.ok(html.includes('Hamlet'), 'html keeps the caption'); const md = pandocRender(pandoc, doc, 'markdown'); - assert.ok(md.includes('> [Hamlet]{.attribution}'), 'markdown spells the span inside the quote: ' + md); + assert.ok(md.includes('Hamlet'), 'markdown keeps the caption: ' + md); }); diff --git a/test/unit.test.mjs b/test/unit.test.mjs index 2b2ca14..bc7997a 100644 --- a/test/unit.test.mjs +++ b/test/unit.test.mjs @@ -1,5 +1,6 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; +import { carveToHtml } from '@markup-carve/carve'; import { carveToPandoc, carveAstToPandoc } from '../dist/index.js'; const blocks = (src) => carveToPandoc(src).doc.blocks; @@ -344,35 +345,26 @@ test('listTable: malformed structure falls back to Div, content preserved (codex assert.ok(r.warnings.some((w) => w.includes('not table-shaped'))); }); -test('figure from captioned image; blockquote attribution', () => { +test('captioned images and blockquotes become figures', () => { const [fig] = blocks('![alt](i.png)\n^ Figure 1: cap'); assert.equal(fig.t, 'Figure'); assert.equal(fig.c[2][0].c[0].t, 'Image'); - // PART 9 §4a: a captioned quote is a quote carrying an attribution, not a - // figure - the attribution rides INSIDE the BlockQuote as a trailing Span, - // so every pandoc writer keeps it attached (a Figure wrapper lost it - // wholesale in the plain and rst writers). - const [quote] = blocks('> wise words\n^ Author'); - assert.equal(quote.t, 'BlockQuote'); - const attribution = quote.c[quote.c.length - 1]; - assert.equal(attribution.t, 'Para'); - assert.equal(attribution.c[0].t, 'Span'); - assert.deepEqual(attribution.c[0].c[0], ['', ['attribution'], []]); - assert.equal(attribution.c[0].c[1][0].c, 'Author'); -}); - -test('a quote attribution consumes no figure number and keeps `#` literal', () => { - // §4a: the placeholder has nothing to resolve against on a quote. The quote - // must also not bump the Figure sequence, or the real figure after it would - // be numbered 2. - const out = blocks('> q\n^ Figure #: Src\n\n![alt](i.png)\n^ Figure #: real\n'); - const spanText = JSON.stringify(out[0]); - assert.ok(spanText.includes('"attribution"'), 'quote carries the attribution span'); - assert.ok(spanText.includes('#'), 'placeholder stays a literal #: ' + spanText); - const figText = JSON.stringify(out[1]); + const [quoteFigure] = blocks('> wise words\n^ Author'); + assert.equal(quoteFigure.t, 'Figure'); + assert.equal(quoteFigure.c[2][0].t, 'BlockQuote'); + assert.equal(quoteFigure.c[1][1][0].c[0].c, 'Author'); +}); + +test('a quote figure consumes a figure number', () => { + const src = '> To be\n^ Figure #: Hamlet\n\n![a](a.png)\n^ Figure #: Second\n'; + const out = blocks(src); + const engineCaptions = [...carveToHtml(src).matchAll(/
(.*?)<\/figcaption>/g)].map((m) => m[1]); + const pandocCaptions = out.map((figure) => figure.c[1][1][0].c.map((x) => x.c ?? ' ').join('')); + assert.equal(out[0].t, 'Figure'); + assert.equal(out[0].c[2][0].t, 'BlockQuote'); assert.equal(out[1].t, 'Figure'); - assert.ok(figText.includes('"1:"'), 'the image is Figure 1, not 2: ' + figText); + assert.deepEqual(pandocCaptions, engineCaptions); }); test('admonition becomes classed Div with title paragraph', () => {