Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 13 additions & 20 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
`<figure><img src="a.png"></figure>`, 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
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand All @@ -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
Expand Down
72 changes: 3 additions & 69 deletions src/convert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -1512,13 +1452,7 @@ function collectCrossrefTargets(ctx: Ctx, nodes: CNode[], captionCounts: Map<str
const a = (n.attrs ?? {}) as CAttrs;
const id = a.id ?? slugify(plainText(children));
if (id && !ctx.crossrefTargets.has(id)) ctx.crossrefTargets.set(id, children);
} else if (
(n.type === 'figure' && (n.target as CNode | undefined)?.type !== 'block_quote') ||
n.type === 'table'
) {
// A quote-figure is an attribution under §4a: it takes no number
// (pass 2 keeps its `#` literal), so counting it here would drift
// every later caption number by one.
} else if (n.type === 'figure' || n.type === 'table') {
const caption = n.caption as CNode[] | undefined;
if (Array.isArray(caption) && caption.some((x) => x?.type === 'caption_number')) {
const label = captionLabel(caption) ?? 'caption';
Expand Down
54 changes: 1 addition & 53 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof carve.renderCarve>[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<string, unknown> = {};
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<string, unknown> = { 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`
Expand Down Expand Up @@ -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<typeof carve.renderCarve>[0]),
carve: carve.renderCarve(ast as Parameters<typeof carve.renderCarve>[0]),
warnings,
};
}
Expand Down
107 changes: 46 additions & 61 deletions src/reverse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ interface CNode {
}

export interface ReverseResult {
ast: CNode;
ast: CNode & { children: CNode[] };
warnings: string[];
}

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
* `<figure><img src="a.png"></figure>`. 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]);
Expand All @@ -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)];
Expand Down Expand Up @@ -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 };
Expand Down
Loading
Loading