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
+ // `` 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, '\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('\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\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\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', () => {