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', () => {