@markup-carve/pandoc-carve 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +324 -0
- package/dist/ast-json.d.ts +77 -0
- package/dist/ast-json.js +255 -0
- package/dist/cli.d.ts +11 -0
- package/dist/cli.js +204 -0
- package/dist/convert.d.ts +57 -0
- package/dist/convert.js +1816 -0
- package/dist/index.d.ts +55 -0
- package/dist/index.js +93 -0
- package/dist/pandoc.d.ts +136 -0
- package/dist/pandoc.js +117 -0
- package/dist/parse-options.d.ts +35 -0
- package/dist/parse-options.js +10 -0
- package/dist/reverse.d.ts +21 -0
- package/dist/reverse.js +1457 -0
- package/dist/row-groups.d.ts +44 -0
- package/dist/row-groups.js +86 -0
- package/package.json +59 -0
package/dist/convert.js
ADDED
|
@@ -0,0 +1,1816 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Carve AST -> Pandoc JSON AST.
|
|
3
|
+
*
|
|
4
|
+
* Walks the SERIALIZED AST that PART 12 of the Carve spec defines - the shape
|
|
5
|
+
* `spec/resources/ast-schema.json` pins, as written by any engine's
|
|
6
|
+
* `--to-json` - and emits a Pandoc document (api-version 1.23.1). It is not the
|
|
7
|
+
* runtime tree of any particular implementation: `src/ast-json.ts` maps that on
|
|
8
|
+
* the way in, so every node type and field name switched on below is spec
|
|
9
|
+
* surface. Anything that cannot be mapped faithfully degrades to a classed
|
|
10
|
+
* Span/Div and reports a warning - nothing degrades silently.
|
|
11
|
+
*/
|
|
12
|
+
import * as P from './pandoc.js';
|
|
13
|
+
import { readRowGroups } from './row-groups.js';
|
|
14
|
+
function warn(ctx, msg) {
|
|
15
|
+
ctx.warnings.push(msg);
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* A warning carries enough of the dropped content to find it in the source,
|
|
19
|
+
* and not a whole fenced block.
|
|
20
|
+
*/
|
|
21
|
+
function truncateForWarning(content) {
|
|
22
|
+
const flat = content.replace(/\s+/g, ' ').trim();
|
|
23
|
+
return flat.length > 80 ? `${flat.slice(0, 77)}...` : flat;
|
|
24
|
+
}
|
|
25
|
+
function toAttr(attrs) {
|
|
26
|
+
const a = (attrs ?? {});
|
|
27
|
+
const kvs = Object.entries(a.keyValues ?? {});
|
|
28
|
+
return P.attr(a.id, a.classes, kvs);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* POLICY: Carve has no small-caps node, and `.smallcaps` is the span that
|
|
32
|
+
* stands in for one - in both directions. `reverse.ts` writes that class for a
|
|
33
|
+
* pandoc `SmallCaps`; this reads it back, so the construct survives
|
|
34
|
+
* Pandoc -> Carve -> Pandoc instead of arriving at the LaTeX/Typst/DOCX writers
|
|
35
|
+
* as a bare `Span` they render without any small-caps at all.
|
|
36
|
+
*
|
|
37
|
+
* The rule is pandoc's own, not an invention here: its markdown reader turns
|
|
38
|
+
* `[x]{.smallcaps}` into `SmallCaps`, strips the class, and keeps whatever
|
|
39
|
+
* other attributes the span had by wrapping the result in a `Span`. Matched
|
|
40
|
+
* exactly, case included (`.SmallCaps` is an ordinary class to pandoc too), so
|
|
41
|
+
* a document that goes through pandoc-flavored markdown and one that goes
|
|
42
|
+
* through this bridge produce the same tree.
|
|
43
|
+
*/
|
|
44
|
+
function smallCapsOrSpan(a, xs) {
|
|
45
|
+
const [id, classes, kvs] = a;
|
|
46
|
+
if (!classes.includes('smallcaps'))
|
|
47
|
+
return P.Span(a, xs);
|
|
48
|
+
const rest = classes.filter((c) => c !== 'smallcaps');
|
|
49
|
+
const caps = P.SmallCaps(xs);
|
|
50
|
+
if (!id && rest.length === 0 && kvs.length === 0)
|
|
51
|
+
return caps;
|
|
52
|
+
return P.Span([id, rest, kvs], [caps]);
|
|
53
|
+
}
|
|
54
|
+
/** Carve's heading slug: case-preserving, GitHub-style. */
|
|
55
|
+
function slugify(text) {
|
|
56
|
+
return text
|
|
57
|
+
.trim()
|
|
58
|
+
.replace(/[^\p{L}\p{N}\s_-]/gu, '')
|
|
59
|
+
.replace(/\s+/g, '-');
|
|
60
|
+
}
|
|
61
|
+
function plainText(nodes) {
|
|
62
|
+
let out = '';
|
|
63
|
+
for (const n of nodes) {
|
|
64
|
+
if (typeof n.value === 'string')
|
|
65
|
+
out += n.value;
|
|
66
|
+
// An inline literal renders as visible prose (PART 9 SS27), so its
|
|
67
|
+
// verbatim content IS text and must reach heading slugs - carve-js
|
|
68
|
+
// folds it into its own heading ids for the same reason. Other
|
|
69
|
+
// `content`-carrying nodes stay excluded on purpose: a comment renders
|
|
70
|
+
// nothing at all, and math/raw are not plain prose.
|
|
71
|
+
else if (n.type === 'literal_inline' && typeof n.content === 'string')
|
|
72
|
+
out += n.content;
|
|
73
|
+
else if (Array.isArray(n.children))
|
|
74
|
+
out += plainText(n.children);
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
/** True when an attribute block carries anything worth preserving. */
|
|
79
|
+
function hasAttrs(a) {
|
|
80
|
+
return Boolean(a && (a.id || a.classes?.length || Object.keys(a.keyValues ?? {}).length));
|
|
81
|
+
}
|
|
82
|
+
function hasLabel(n) {
|
|
83
|
+
return typeof n.label === 'string' && n.label.length > 0;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* A fenced div's grouping `[label]` rendered as a caption Para, or [] when the
|
|
87
|
+
* node has none. Normative graceful-degradation rule: a `[label]` that no group
|
|
88
|
+
* extension consumes (tabs/code-group are extensions; this bridge runs none)
|
|
89
|
+
* MUST survive as a visible caption, else a reader of the LaTeX/Typst/DOCX
|
|
90
|
+
* output cannot tell the panels apart. The reference HTML renderer emits
|
|
91
|
+
* `<p class="div-label">`; here it is a bold line - the same slot the quoted
|
|
92
|
+
* title uses, and title comes first when a block carries both.
|
|
93
|
+
*
|
|
94
|
+
* In roundtrip mode the label is preserved structurally instead (see
|
|
95
|
+
* `labelKv`), so the reverse importer can rebuild the `[label]` grouping token
|
|
96
|
+
* exactly; a flattened caption Para would be indistinguishable from a title.
|
|
97
|
+
*/
|
|
98
|
+
function labelCaption(ctx, n) {
|
|
99
|
+
return !ctx.roundtrip && hasLabel(n) ? [P.Para([P.Strong(textInlines(n.label))])] : [];
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Roundtrip-only kv that carries the grouping `[label]` for exact re-import.
|
|
103
|
+
* The `.` in the key is deliberate: Carve's attribute grammar rejects dotted
|
|
104
|
+
* keys, so a document can never carry a user-authored `carve.label` attribute
|
|
105
|
+
* that the reverse importer would mistake for this internal marker.
|
|
106
|
+
*/
|
|
107
|
+
function labelKv(ctx, n) {
|
|
108
|
+
return ctx.roundtrip && hasLabel(n) ? [['carve.label', n.label]] : [];
|
|
109
|
+
}
|
|
110
|
+
/** Split text into Str/Space the way pandoc readers do. */
|
|
111
|
+
// Glyphs for the smart-typography kinds that do not carry one on the node.
|
|
112
|
+
// A quote's glyph is locale-dependent and is chosen during parsing, so the node
|
|
113
|
+
// records it; the rest are fixed and resolvable from the kind.
|
|
114
|
+
//
|
|
115
|
+
// Duplicated from carve-js, which exports SMART_PUNCTUATION_GLYPHS from ast.ts
|
|
116
|
+
// but not from the package root - see carve#355. Drop this table once the
|
|
117
|
+
// export lands.
|
|
118
|
+
const SMART_PUNCTUATION_GLYPHS = {
|
|
119
|
+
ellipsis: '\u2026',
|
|
120
|
+
em_dash: '\u2014',
|
|
121
|
+
en_dash: '\u2013',
|
|
122
|
+
left_right_arrow: '\u2194',
|
|
123
|
+
rightwards_arrow: '\u2192',
|
|
124
|
+
leftwards_arrow: '\u2190',
|
|
125
|
+
rightwards_double_arrow: '\u21D2',
|
|
126
|
+
less_than_or_equal: '\u2264',
|
|
127
|
+
greater_than_or_equal: '\u2265',
|
|
128
|
+
not_equal: '\u2260',
|
|
129
|
+
plus_minus: '\u00B1',
|
|
130
|
+
copyright: '\u00A9',
|
|
131
|
+
registered: '\u00AE',
|
|
132
|
+
trademark: '\u2122',
|
|
133
|
+
};
|
|
134
|
+
function smartPunctuationText(n) {
|
|
135
|
+
const glyph = n.glyph;
|
|
136
|
+
if (glyph)
|
|
137
|
+
return glyph;
|
|
138
|
+
const kind = String(n.kind ?? '');
|
|
139
|
+
return SMART_PUNCTUATION_GLYPHS[kind] ?? String(n.value ?? '');
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* The engines' SENTINEL for a no-break space the parser resolved - from an
|
|
143
|
+
* escaped space, or from a line block's preserved indentation - is U+E000, a
|
|
144
|
+
* PRIVATE-USE codepoint (markup-carve/carve#721). It is spec surface a consumer has to
|
|
145
|
+
* map: passing it through put a private-use character into Pandoc JSON, so
|
|
146
|
+
* every writer downstream - docx, LaTeX, HTML - rendered a tofu box where a
|
|
147
|
+
* no-break space belonged, and nothing warned.
|
|
148
|
+
*
|
|
149
|
+
* U+00A0 is the right target: Pandoc has no separate representation for a
|
|
150
|
+
* resolved space, and a literal no-break space is what the source means. A
|
|
151
|
+
* U+00A0 the author typed is published by the engines as itself and needs no
|
|
152
|
+
* mapping.
|
|
153
|
+
*/
|
|
154
|
+
const RESOLVED_NBSP = /\uE000/g;
|
|
155
|
+
function resolvedSpaces(value) {
|
|
156
|
+
return value.replace(RESOLVED_NBSP, '\u00A0');
|
|
157
|
+
}
|
|
158
|
+
function textInlines(raw) {
|
|
159
|
+
const value = resolvedSpaces(raw);
|
|
160
|
+
const out = [];
|
|
161
|
+
const parts = value.split(/( +)/);
|
|
162
|
+
for (const part of parts) {
|
|
163
|
+
if (part === '')
|
|
164
|
+
continue;
|
|
165
|
+
if (/^ +$/.test(part))
|
|
166
|
+
out.push(P.Space);
|
|
167
|
+
else
|
|
168
|
+
out.push(P.Str(part));
|
|
169
|
+
}
|
|
170
|
+
return out;
|
|
171
|
+
}
|
|
172
|
+
// Like textInlines, but preserves the exact number of spaces by emitting one
|
|
173
|
+
// Space per space character instead of one per run. Prose collapsing is correct
|
|
174
|
+
// for ordinary text, but an inline literal captures its content VERBATIM, so
|
|
175
|
+
// `` !`a b` `` must not reach a writer as "a b".
|
|
176
|
+
function verbatimInlines(raw) {
|
|
177
|
+
const value = resolvedSpaces(raw);
|
|
178
|
+
const out = [];
|
|
179
|
+
for (const part of value.split(/( )/)) {
|
|
180
|
+
if (part === '')
|
|
181
|
+
continue;
|
|
182
|
+
if (part === ' ')
|
|
183
|
+
out.push(P.Space);
|
|
184
|
+
else
|
|
185
|
+
out.push(P.Str(part));
|
|
186
|
+
}
|
|
187
|
+
return out;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* The label a caption numbers under - the word before its `#`.
|
|
191
|
+
*
|
|
192
|
+
* The engine keeps one sequence PER LABEL, not per element kind: `Figure #`,
|
|
193
|
+
* `Listing #`, `Figure #` on three figures numbers them Figure 1, Listing 1,
|
|
194
|
+
* Figure 2. Keying on figure-versus-table would have made that Listing 2.
|
|
195
|
+
*/
|
|
196
|
+
function captionLabel(nodes) {
|
|
197
|
+
if (!Array.isArray(nodes))
|
|
198
|
+
return undefined;
|
|
199
|
+
const at = nodes.findIndex((x) => x?.type === 'caption_number');
|
|
200
|
+
if (at <= 0)
|
|
201
|
+
return undefined;
|
|
202
|
+
// ALL the text before the placeholder, markup included.
|
|
203
|
+
//
|
|
204
|
+
// docs-extensions.md: "the label is the text before it". Three narrower
|
|
205
|
+
// readings were wrong, each caught in review:
|
|
206
|
+
// - the element kind `Listing #` after `Figure #` numbered 2, not 1
|
|
207
|
+
// - the last word `Supplementary Figure` merged into `Figure`
|
|
208
|
+
// - only `text` nodes `*Figure*` fell to a generic counter, where
|
|
209
|
+
// the engine shares the `Figure` sequence and
|
|
210
|
+
// numbers it 2
|
|
211
|
+
// So the label is the flattened inline TEXT, which is what the resolver
|
|
212
|
+
// compares.
|
|
213
|
+
const label = nodes.slice(0, at).map(inlineText).join('').trim();
|
|
214
|
+
return label || undefined;
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* The text content of an inline node, markup flattened away.
|
|
218
|
+
*
|
|
219
|
+
* Carve's inline nodes do not agree on where their text lives: most use
|
|
220
|
+
* `value`, a literal inline uses `content`, a symbol carries its resolved
|
|
221
|
+
* glyph. Reading only `value` sent `!`Figure`` to a generic counter, where the
|
|
222
|
+
* engine shares the `Figure` sequence - the fourth narrowing this function
|
|
223
|
+
* needed, all four found in review.
|
|
224
|
+
*/
|
|
225
|
+
function inlineText(n) {
|
|
226
|
+
if (!n || typeof n !== 'object')
|
|
227
|
+
return '';
|
|
228
|
+
for (const key of ['value', 'content', 'glyph', 'text']) {
|
|
229
|
+
const v = n[key];
|
|
230
|
+
if (typeof v === 'string')
|
|
231
|
+
return v;
|
|
232
|
+
}
|
|
233
|
+
const kids = n.children ?? [];
|
|
234
|
+
return kids.map(inlineText).join('');
|
|
235
|
+
}
|
|
236
|
+
// --- Inlines ---
|
|
237
|
+
function inlines(ctx, nodes) {
|
|
238
|
+
if (!nodes)
|
|
239
|
+
return [];
|
|
240
|
+
const out = [];
|
|
241
|
+
for (const n of pairQuotes(nodes))
|
|
242
|
+
out.push(...inline(ctx, n));
|
|
243
|
+
return joinAdjacentStr(out);
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* The synthetic node a matched pair of quote marks becomes, between
|
|
247
|
+
* {@link pairQuotes} and the `Quoted` it converts to. Not a Carve type and
|
|
248
|
+
* never serialized - the name is deliberately unspellable so it cannot collide
|
|
249
|
+
* with a node type an engine might add.
|
|
250
|
+
*/
|
|
251
|
+
const QUOTED = '__pandocQuoted';
|
|
252
|
+
const QUOTE_OPENS = {
|
|
253
|
+
left_double_quote: 'DoubleQuote',
|
|
254
|
+
left_single_quote: 'SingleQuote',
|
|
255
|
+
};
|
|
256
|
+
const QUOTE_CLOSES = {
|
|
257
|
+
right_double_quote: 'DoubleQuote',
|
|
258
|
+
right_single_quote: 'SingleQuote',
|
|
259
|
+
};
|
|
260
|
+
const quoteRole = (n) => {
|
|
261
|
+
if (n?.type !== 'smart_punctuation')
|
|
262
|
+
return null;
|
|
263
|
+
const kind = String(n.kind ?? '');
|
|
264
|
+
const open = QUOTE_OPENS[kind];
|
|
265
|
+
if (open)
|
|
266
|
+
return [open, 'open'];
|
|
267
|
+
const close = QUOTE_CLOSES[kind];
|
|
268
|
+
return close ? [close, 'close'] : null;
|
|
269
|
+
};
|
|
270
|
+
/**
|
|
271
|
+
* Rebuild pandoc's WRAPPING quotation from Carve's two standalone marks.
|
|
272
|
+
*
|
|
273
|
+
* Carve's parser resolves `"` and `'` to `smart_punctuation` nodes carrying
|
|
274
|
+
* the kind (`left_double_quote`, `right_single_quote`, ...), which is strictly
|
|
275
|
+
* more information than the glyph - and exactly the information pandoc's
|
|
276
|
+
* `Quoted` needs. Without this a quotation crossed the bridge as three
|
|
277
|
+
* separate pieces and came back as `Str "“alpha”"`, so a document quoting
|
|
278
|
+
* anything could not survive pandoc -> Carve -> pandoc.
|
|
279
|
+
*
|
|
280
|
+
* WHAT IS DELIBERATELY NOT PAIRED. Only marks that are SIBLINGS pair, and only
|
|
281
|
+
* a close against a still-open mark of the same kind:
|
|
282
|
+
*
|
|
283
|
+
* - An APOSTROPHE (`it's`) resolves to a lone `right_single_quote` with no
|
|
284
|
+
* opener. It stays a mark, which is the whole reason the match runs from
|
|
285
|
+
* the closing side against a stack rather than pairing greedily.
|
|
286
|
+
* - An UNCLOSED `"` stays a mark too - a `Quoted` running to the end of the
|
|
287
|
+
* paragraph would assert a quotation the author never closed.
|
|
288
|
+
* - A quotation crossing an emphasis boundary (`"a /b" c/`) puts its two
|
|
289
|
+
* marks in different sibling arrays, where neither can see the other, and
|
|
290
|
+
* both stay marks.
|
|
291
|
+
*
|
|
292
|
+
* In all three the glyph still renders; the node just is not promoted.
|
|
293
|
+
*/
|
|
294
|
+
function pairQuotes(nodes) {
|
|
295
|
+
const closerOf = new Map();
|
|
296
|
+
const kindOf = new Map();
|
|
297
|
+
const open = [];
|
|
298
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
299
|
+
const role = quoteRole(nodes[i]);
|
|
300
|
+
if (!role)
|
|
301
|
+
continue;
|
|
302
|
+
const [kind, side] = role;
|
|
303
|
+
if (side === 'open') {
|
|
304
|
+
open.push({ at: i, kind });
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
// The NEAREST unclosed opener of the same kind, so nesting works and a
|
|
308
|
+
// stray closer of the other kind cannot consume it.
|
|
309
|
+
for (let j = open.length - 1; j >= 0; j--) {
|
|
310
|
+
if (open[j].kind !== kind)
|
|
311
|
+
continue;
|
|
312
|
+
closerOf.set(open[j].at, i);
|
|
313
|
+
kindOf.set(open[j].at, kind);
|
|
314
|
+
open.length = j;
|
|
315
|
+
break;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
if (closerOf.size === 0)
|
|
319
|
+
return nodes;
|
|
320
|
+
// The pairs nest by construction, so one recursive walk builds the tree.
|
|
321
|
+
const build = (from, to) => {
|
|
322
|
+
const out = [];
|
|
323
|
+
for (let i = from; i < to;) {
|
|
324
|
+
const close = closerOf.get(i);
|
|
325
|
+
if (close !== undefined && close < to) {
|
|
326
|
+
out.push({ type: QUOTED, quote: kindOf.get(i), children: build(i + 1, close) });
|
|
327
|
+
i = close + 1;
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
out.push(nodes[i]);
|
|
331
|
+
i++;
|
|
332
|
+
}
|
|
333
|
+
return out;
|
|
334
|
+
};
|
|
335
|
+
return build(0, nodes.length);
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* Merge neighbouring `Str` nodes into one.
|
|
339
|
+
*
|
|
340
|
+
* Pandoc's own readers emit one `Str` per WORD, with `Space` between; two
|
|
341
|
+
* `Str` nodes touching is legal but not a shape pandoc produces, and a
|
|
342
|
+
* consumer doing word-level work sees three words where there is one.
|
|
343
|
+
*
|
|
344
|
+
* Carve's tree splits a word wherever a construct sits inside it, and that got
|
|
345
|
+
* more common when carve-js stopped substituting smart-punctuation characters
|
|
346
|
+
* and started publishing them as nodes: `Carve's` is now `text`,
|
|
347
|
+
* `smart_punctuation`, `text`, so a direct mapping yields Str("Carve"),
|
|
348
|
+
* Str("’"), Str("s"). The rendered text is identical - writers concatenate -
|
|
349
|
+
* but the AST is not the one pandoc would have built, and it is the AST this
|
|
350
|
+
* package exists to hand over.
|
|
351
|
+
*/
|
|
352
|
+
function joinAdjacentStr(xs) {
|
|
353
|
+
const out = [];
|
|
354
|
+
for (const x of xs) {
|
|
355
|
+
const prev = out[out.length - 1];
|
|
356
|
+
if (x.t === 'Str' && prev?.t === 'Str') {
|
|
357
|
+
out[out.length - 1] = P.Str(String(prev.c) + String(x.c));
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
out.push(x);
|
|
361
|
+
}
|
|
362
|
+
return out;
|
|
363
|
+
}
|
|
364
|
+
function kids(ctx, n) {
|
|
365
|
+
return inlines(ctx, n.children);
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* A reference link or image whose label nothing defines.
|
|
369
|
+
*
|
|
370
|
+
* PART 12 section 3a publishes the resolution BESIDE the authored construct
|
|
371
|
+
* rather than in place of it: `[a][t]` is a `link` carrying `ref`/`rawRef`
|
|
372
|
+
* whether or not the document defines `t`, and `href` (`src` for an image) is
|
|
373
|
+
* empty ONLY where nothing resolved it. So the pair "a reference was written"
|
|
374
|
+
* and "the destination is empty" is the wire's statement that the reference
|
|
375
|
+
* did not resolve, and it is the only one - there is no way to author an empty
|
|
376
|
+
* destination on a definition line (`[t]: <>` defines the literal `<>`).
|
|
377
|
+
*
|
|
378
|
+
* Carve renders such a reference as the LITERAL SOURCE, so this returns
|
|
379
|
+
* `rawRef` as text. Emitting a `Link` with an empty target instead would
|
|
380
|
+
* invent a node the document does not contain, and downstream it renders as a
|
|
381
|
+
* broken anchor rather than as the text the reader is meant to see.
|
|
382
|
+
*
|
|
383
|
+
* And it warns, because the sibling path does. An unresolved footnote has said
|
|
384
|
+
* `footnote: missing definition for [^f]` all along; a missing link definition
|
|
385
|
+
* has no reason to be the quieter of the two, and a conversion that loses the
|
|
386
|
+
* author's meaning with a clean exit code is the failure this pairs with
|
|
387
|
+
* (markup-carve/pandoc-carve#91).
|
|
388
|
+
*
|
|
389
|
+
* Returns null when the node is not an unresolved reference, so the caller
|
|
390
|
+
* converts it normally.
|
|
391
|
+
*/
|
|
392
|
+
function unresolvedReference(ctx, n, kind) {
|
|
393
|
+
if (n.ref === undefined)
|
|
394
|
+
return null;
|
|
395
|
+
const destination = String((kind === 'link' ? n.href : n.src) ?? '');
|
|
396
|
+
if (destination !== '')
|
|
397
|
+
return null;
|
|
398
|
+
const ref = String(n.ref ?? '');
|
|
399
|
+
warn(ctx, `${kind}: missing definition for [${ref}] - emitting the literal source`);
|
|
400
|
+
const raw = String(n.rawRef ?? '');
|
|
401
|
+
// rawRef is required beside ref, so the fallback is for a tree assembled by
|
|
402
|
+
// hand: keep the visible text rather than dropping the construct entirely.
|
|
403
|
+
if (raw === '')
|
|
404
|
+
return kind === 'link' ? kids(ctx, n) : textInlines(String(n.alt ?? ''));
|
|
405
|
+
// A reference may be written across lines. Its literal source is prose now,
|
|
406
|
+
// so the line break is a SoftBreak - a newline left inside a Str would
|
|
407
|
+
// reach every writer verbatim.
|
|
408
|
+
const out = [];
|
|
409
|
+
raw.split(/\r?\n/).forEach((line, at) => {
|
|
410
|
+
if (at > 0)
|
|
411
|
+
out.push(P.SoftBreak);
|
|
412
|
+
out.push(...textInlines(line));
|
|
413
|
+
});
|
|
414
|
+
return out;
|
|
415
|
+
}
|
|
416
|
+
function inline(ctx, n) {
|
|
417
|
+
switch (n.type) {
|
|
418
|
+
case 'text':
|
|
419
|
+
return textInlines(String(n.value ?? ''));
|
|
420
|
+
case 'soft_break':
|
|
421
|
+
return [P.SoftBreak];
|
|
422
|
+
case 'hard_break':
|
|
423
|
+
return [P.LineBreak];
|
|
424
|
+
case 'emphasis':
|
|
425
|
+
return [P.Emph(kids(ctx, n))];
|
|
426
|
+
case 'strong':
|
|
427
|
+
return [P.Strong(kids(ctx, n))];
|
|
428
|
+
case 'underline':
|
|
429
|
+
return [P.Underline(kids(ctx, n))];
|
|
430
|
+
case 'strike':
|
|
431
|
+
return [P.Strikeout(kids(ctx, n))];
|
|
432
|
+
case 'highlight':
|
|
433
|
+
return [P.Span(P.attr(undefined, ['mark']), kids(ctx, n))];
|
|
434
|
+
case 'subscript':
|
|
435
|
+
return [P.Subscript(kids(ctx, n))];
|
|
436
|
+
case 'superscript':
|
|
437
|
+
return [P.Superscript(kids(ctx, n))];
|
|
438
|
+
case 'code':
|
|
439
|
+
return [P.Code(toAttr(n.attrs), String(n.value ?? ''))];
|
|
440
|
+
case 'literal_inline': {
|
|
441
|
+
// PART 9 SS27: verbatim capture rendered as ORDINARY PROSE. The
|
|
442
|
+
// `<code>` wrapper is deliberately dropped, so Pandoc `Code` would
|
|
443
|
+
// invert the construct's entire purpose (it implies monospace/code
|
|
444
|
+
// styling - the exact thing the literal exists to avoid). Emit
|
|
445
|
+
// plain text, and wrap in a Span only when attributes need
|
|
446
|
+
// somewhere to live - mirroring carve-js, which emits a `<span>`
|
|
447
|
+
// only when the attribute block is present and bare text otherwise.
|
|
448
|
+
// verbatimInlines, not textInlines: the content is captured verbatim,
|
|
449
|
+
// so runs of spaces must survive rather than collapse to one Space.
|
|
450
|
+
const text = verbatimInlines(String(n.content ?? ''));
|
|
451
|
+
return hasAttrs(n.attrs)
|
|
452
|
+
? [P.Span(toAttr(n.attrs), text)]
|
|
453
|
+
: text;
|
|
454
|
+
}
|
|
455
|
+
case 'link': {
|
|
456
|
+
const unresolved = unresolvedReference(ctx, n, 'link');
|
|
457
|
+
if (unresolved)
|
|
458
|
+
return unresolved;
|
|
459
|
+
return [
|
|
460
|
+
P.Link(toAttr(n.attrs), kids(ctx, n), [
|
|
461
|
+
String(n.href ?? ''),
|
|
462
|
+
String(n.title ?? ''),
|
|
463
|
+
]),
|
|
464
|
+
];
|
|
465
|
+
}
|
|
466
|
+
case 'autolink': {
|
|
467
|
+
const href = String(n.href ?? '');
|
|
468
|
+
const cls = href.startsWith('mailto:') ? 'email' : 'uri';
|
|
469
|
+
return [P.Link(P.attr(undefined, [cls]), [P.Str(String(n.text ?? href))], [href, ''])];
|
|
470
|
+
}
|
|
471
|
+
case 'image': {
|
|
472
|
+
const unresolved = unresolvedReference(ctx, n, 'image');
|
|
473
|
+
if (unresolved)
|
|
474
|
+
return unresolved;
|
|
475
|
+
return [
|
|
476
|
+
P.Image(toAttr(n.attrs), textInlines(String(n.alt ?? '')), [
|
|
477
|
+
String(n.src ?? ''),
|
|
478
|
+
String(n.title ?? ''),
|
|
479
|
+
]),
|
|
480
|
+
];
|
|
481
|
+
}
|
|
482
|
+
case 'caption_number': {
|
|
483
|
+
// The `#` in `^ Figure #: text`. It has no value in the tree - the
|
|
484
|
+
// renderer assigns one - so it was degrading to empty and the
|
|
485
|
+
// caption reached pandoc as `Figure : text`, silently unnumbered
|
|
486
|
+
// in every writer.
|
|
487
|
+
//
|
|
488
|
+
// Inside a panel it draws nothing and prints as the author wrote
|
|
489
|
+
// it (PART 9 §4c, corpus 318-composite-figures-8's sibling lint
|
|
490
|
+
// `figure-group-panel-number`).
|
|
491
|
+
if (ctx.inPanel)
|
|
492
|
+
return [P.Str('#')];
|
|
493
|
+
const kind = ctx.captionKind ?? 'caption';
|
|
494
|
+
const next = (ctx.captionCounts.get(kind) ?? 0) + 1;
|
|
495
|
+
ctx.captionCounts.set(kind, next);
|
|
496
|
+
return [P.Str(String(next))];
|
|
497
|
+
}
|
|
498
|
+
case 'heading_ref': {
|
|
499
|
+
// Nested inside a crossref's own resolution: the engine emits
|
|
500
|
+
// nothing for it, so `# A </#a>` yields the link text `A ` rather
|
|
501
|
+
// than recurring. Matches corpus 118.
|
|
502
|
+
if (ctx.inCrossref)
|
|
503
|
+
return [];
|
|
504
|
+
const target = String(n.target ?? '');
|
|
505
|
+
const found = ctx.crossrefTargets.get(target) ??
|
|
506
|
+
ctx.crossrefTargets.get(target.toLowerCase()) ??
|
|
507
|
+
findCaseInsensitive(ctx.crossrefTargets, target);
|
|
508
|
+
if (found) {
|
|
509
|
+
ctx.inCrossref = true;
|
|
510
|
+
try {
|
|
511
|
+
return [
|
|
512
|
+
P.Link(P.attr(undefined, ['crossref']), inlines(ctx, found), [
|
|
513
|
+
`#${target}`,
|
|
514
|
+
'',
|
|
515
|
+
]),
|
|
516
|
+
];
|
|
517
|
+
}
|
|
518
|
+
finally {
|
|
519
|
+
ctx.inCrossref = false;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
warn(ctx, `crossref: unresolved target "${target}" - emitting target text`);
|
|
523
|
+
return [P.Link(P.attr(undefined, ['crossref', 'unresolved']), [P.Str(target)], [`#${target}`, ''])];
|
|
524
|
+
}
|
|
525
|
+
// The wire has two nodes here, split so that a profile can deny one
|
|
526
|
+
// without the other (markup-carve/carve#405): `footnote_ref` (`[^a]`)
|
|
527
|
+
// and `inline_footnote` (`^[…]`). The pre-split spelling a pinned
|
|
528
|
+
// engine still emits is folded onto these two in `src/ast-json.ts`, so
|
|
529
|
+
// this switch never has to know it existed.
|
|
530
|
+
case 'footnote_ref':
|
|
531
|
+
case 'inline_footnote': {
|
|
532
|
+
// The note's own content is converted BEFORE the counter moves, so
|
|
533
|
+
// a citation inside the note carries that note's number rather than
|
|
534
|
+
// the next one - which is what pandoc's markdown reader produces.
|
|
535
|
+
if (Array.isArray(n.inline)) {
|
|
536
|
+
const note = P.Note([P.Para(inlines(ctx, n.inline))]);
|
|
537
|
+
ctx.noteCount++;
|
|
538
|
+
return [note];
|
|
539
|
+
}
|
|
540
|
+
const id = String(n.id ?? '');
|
|
541
|
+
const def = ctx.footnoteDefs[id];
|
|
542
|
+
if (!def) {
|
|
543
|
+
warn(ctx, `footnote: missing definition for [^${id}]`);
|
|
544
|
+
return [P.Superscript([P.Str(id)])];
|
|
545
|
+
}
|
|
546
|
+
const note = P.Note(blocks(ctx, def));
|
|
547
|
+
ctx.noteCount++;
|
|
548
|
+
return [note];
|
|
549
|
+
}
|
|
550
|
+
case 'math':
|
|
551
|
+
return [
|
|
552
|
+
n.display
|
|
553
|
+
? P.MathDisplay(String(n.content ?? ''))
|
|
554
|
+
: P.MathInline(String(n.content ?? '')),
|
|
555
|
+
];
|
|
556
|
+
case 'raw_inline':
|
|
557
|
+
return [P.RawInline(String(n.format ?? ''), String(n.content ?? ''))];
|
|
558
|
+
case 'mention': {
|
|
559
|
+
const user = String(n.user ?? '');
|
|
560
|
+
return [P.Span(P.attr(undefined, ['mention'], [['data-user', user]]), [P.Str(`@${user}`)])];
|
|
561
|
+
}
|
|
562
|
+
case 'tag': {
|
|
563
|
+
const name = String(n.name ?? '');
|
|
564
|
+
return [P.Span(P.attr(undefined, ['tag'], [['data-tag', name]]), [P.Str(`#${name}`)])];
|
|
565
|
+
}
|
|
566
|
+
case 'symbol': {
|
|
567
|
+
const name = String(n.name ?? '');
|
|
568
|
+
const mapped = ctx.symbols[name];
|
|
569
|
+
if (mapped !== undefined)
|
|
570
|
+
return textInlines(mapped);
|
|
571
|
+
warn(ctx, `symbol: :${name}: has no entry in the symbols map - emitting literal source form`);
|
|
572
|
+
return [P.Span(P.attr(undefined, ['symbol'], [['data-symbol', name]]), [P.Str(`:${name}:`)])];
|
|
573
|
+
}
|
|
574
|
+
case 'abbreviation': {
|
|
575
|
+
const abbr = String(n.abbr ?? '');
|
|
576
|
+
const expansion = String(n.expansion ?? '');
|
|
577
|
+
return [P.Span(P.attr(undefined, ['abbr'], [['title', expansion]]), [P.Str(abbr)])];
|
|
578
|
+
}
|
|
579
|
+
case 'inline_extension': {
|
|
580
|
+
const name = String(n.name ?? '');
|
|
581
|
+
const content = Array.isArray(n.content)
|
|
582
|
+
? inlines(ctx, n.content)
|
|
583
|
+
: textInlines(String(n.content ?? ''));
|
|
584
|
+
warn(ctx, `extension: :${name}[..] degraded to a Span with class "ext-${name}"`);
|
|
585
|
+
return [P.Span(P.attr(undefined, [`ext-${name}`]), content)];
|
|
586
|
+
}
|
|
587
|
+
case 'citation_group':
|
|
588
|
+
return [citationGroup(ctx, n)];
|
|
589
|
+
case 'span':
|
|
590
|
+
return [smallCapsOrSpan(toAttr(n.attrs), kids(ctx, n))];
|
|
591
|
+
case 'insert':
|
|
592
|
+
return [P.Span(P.attr(undefined, ['insertion']), kids(ctx, n))];
|
|
593
|
+
case 'delete':
|
|
594
|
+
return [P.Span(P.attr(undefined, ['deletion']), kids(ctx, n))];
|
|
595
|
+
case 'substitution': {
|
|
596
|
+
const oldText = textInlines(String(n.oldText ?? ''));
|
|
597
|
+
const newText = textInlines(String(n.newText ?? ''));
|
|
598
|
+
return [
|
|
599
|
+
P.Span(P.attr(undefined, ['substitution']), [
|
|
600
|
+
P.Span(P.attr(undefined, ['deletion']), oldText),
|
|
601
|
+
P.Str('→'),
|
|
602
|
+
P.Span(P.attr(undefined, ['insertion']), newText),
|
|
603
|
+
]),
|
|
604
|
+
];
|
|
605
|
+
}
|
|
606
|
+
// `critic_comment` is the schema's spelling and the only one the wire
|
|
607
|
+
// carries; the hyphenated name a pinned engine still emits internally
|
|
608
|
+
// is folded onto it in `src/ast-json.ts`.
|
|
609
|
+
case 'critic_comment':
|
|
610
|
+
return [P.Span(P.attr(undefined, ['comment-annotation']), textInlines(String(n.text ?? '')))];
|
|
611
|
+
case 'comment':
|
|
612
|
+
// Pandoc's AST has no comment node, so dropping is the conversion -
|
|
613
|
+
// but a silent drop is not (markup-carve/pandoc-carve#75). The
|
|
614
|
+
// warning names the content, so a migration can see what did not
|
|
615
|
+
// make the trip.
|
|
616
|
+
warn(ctx, `comment: dropped - Pandoc's AST has no comment node: ${truncateForWarning(String(n.content ?? ''))}`);
|
|
617
|
+
return [];
|
|
618
|
+
case QUOTED:
|
|
619
|
+
return [
|
|
620
|
+
P.Quoted(n.quote ?? 'DoubleQuote', inlines(ctx, n.children)),
|
|
621
|
+
];
|
|
622
|
+
case 'smart_punctuation':
|
|
623
|
+
// The resolved glyph, not the author's source run. Pandoc applies
|
|
624
|
+
// its own smart punctuation when READING markdown, not when
|
|
625
|
+
// consuming a JSON AST, so handing it `--` yields a literal double
|
|
626
|
+
// hyphen downstream. Carve already made the decision; keeping it is
|
|
627
|
+
// what carries the typography into LaTeX, DOCX and the rest.
|
|
628
|
+
return [P.Str(smartPunctuationText(n))];
|
|
629
|
+
case 'escaped_text':
|
|
630
|
+
// The character the author escaped. The backslash is authoring
|
|
631
|
+
// syntax and does not survive into pandoc's AST; the character does,
|
|
632
|
+
// and stays literal because pandoc will not re-smarten AST input -
|
|
633
|
+
// which is exactly what the escape asked for.
|
|
634
|
+
return [P.Str(String(n.value ?? ''))];
|
|
635
|
+
default:
|
|
636
|
+
warn(ctx, `inline: unknown node type "${n.type}" degraded to its text content`);
|
|
637
|
+
return textInlines(plainText([n]));
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
/**
|
|
641
|
+
* PART 9 §22 citation group -> pandoc `Cite`.
|
|
642
|
+
*
|
|
643
|
+
* Three things do not line up, and each is handled the way citeproc itself
|
|
644
|
+
* does it rather than by inventing a convention:
|
|
645
|
+
*
|
|
646
|
+
* - **Mode.** Carve's integral `+` is a property of the whole cluster;
|
|
647
|
+
* pandoc's mode is per item. Integral maps to `AuthorInText` on each item.
|
|
648
|
+
* An item's own `-` (suppress author) is narrower than the group's mode and
|
|
649
|
+
* wins, because dropping it would print an author the source asked to hide.
|
|
650
|
+
* - **Locator.** `Citation` has no locator slot at all - pandoc's markdown
|
|
651
|
+
* reader puts `, p. 33` in `citationSuffix` and citeproc parses it back out
|
|
652
|
+
* there. So the locator TEXT crosses intact and citeproc re-derives the same
|
|
653
|
+
* `{label, value}` pair Carve had parsed; only the typing is lost, which is
|
|
654
|
+
* what the warning says.
|
|
655
|
+
* - **Content.** The Cite's second field is the verbatim source, which is
|
|
656
|
+
* exactly what `citation_group.raw` holds. Every non-citeproc writer prints
|
|
657
|
+
* that, so a document converted without `--citeproc` still reads correctly.
|
|
658
|
+
*/
|
|
659
|
+
function citationGroup(ctx, n) {
|
|
660
|
+
const integral = n.mode === 'integral';
|
|
661
|
+
const items = n.items ?? [];
|
|
662
|
+
const citations = items.map((item) => {
|
|
663
|
+
const key = String(item.key ?? '');
|
|
664
|
+
let mode = integral ? 'AuthorInText' : 'NormalCitation';
|
|
665
|
+
if (item.suppressAuthor) {
|
|
666
|
+
if (integral) {
|
|
667
|
+
warn(ctx, `citation: @${key} suppresses its author inside an integral group - pandoc's mode is per item, so the item keeps SuppressAuthor`);
|
|
668
|
+
}
|
|
669
|
+
mode = 'SuppressAuthor';
|
|
670
|
+
}
|
|
671
|
+
if (item.locatorLabel) {
|
|
672
|
+
warn(ctx, `citation: @${key}'s typed locator (${item.locatorLabel}) is serialized into the pandoc citation suffix - pandoc's Citation has no locator field`);
|
|
673
|
+
}
|
|
674
|
+
return P.citation(key, mode, item.prefix?.length ? inlines(ctx, item.prefix) : [], citationSuffix(ctx, item), ctx.noteCount + 1);
|
|
675
|
+
});
|
|
676
|
+
return P.Cite(citations, textInlines(String(n.raw ?? '')));
|
|
677
|
+
}
|
|
678
|
+
/**
|
|
679
|
+
* The suffix citeproc reads: the locator text after its `, ` separator, which
|
|
680
|
+
* is the whole post-comma run - Carve's `suffix` is the tail of `locator`, not
|
|
681
|
+
* a sibling of it, so emitting both would print the tail twice.
|
|
682
|
+
*/
|
|
683
|
+
function citationSuffix(ctx, item) {
|
|
684
|
+
if (item.locator?.length)
|
|
685
|
+
return [P.Str(','), P.Space, ...inlines(ctx, item.locator)];
|
|
686
|
+
if (item.suffix?.length)
|
|
687
|
+
return [P.Space, ...inlines(ctx, item.suffix)];
|
|
688
|
+
return [];
|
|
689
|
+
}
|
|
690
|
+
/**
|
|
691
|
+
* A `[@key]: entry` bibliography line (PART 12 section 18) as citeproc's own
|
|
692
|
+
* bibliography entry: `Div ("ref-<key>", ["csl-entry"], ...)`.
|
|
693
|
+
*
|
|
694
|
+
* There was no arm for this node at all, so a definition fell to the generic
|
|
695
|
+
* "unknown node type" path and left as a paragraph of its text - the entry
|
|
696
|
+
* printed in the body of the document, where Carve renders nothing, and the
|
|
697
|
+
* key that binds it to its citations was dropped on the floor.
|
|
698
|
+
*
|
|
699
|
+
* `csl-entry` under `ref-<key>` is not a shape invented here. It is what
|
|
700
|
+
* `pandoc --citeproc` writes for a resolved bibliography entry and what
|
|
701
|
+
* pandoc's markdown reader reads back, so the mapping lands in the vocabulary
|
|
702
|
+
* the rest of the pandoc ecosystem already keys on: a filter or template that
|
|
703
|
+
* styles a bibliography finds one.
|
|
704
|
+
*
|
|
705
|
+
* The `{author= year=}` metadata rides along as the Div's key-values. Pandoc
|
|
706
|
+
* has no slot for it - citeproc takes those from the CSL data, not from the
|
|
707
|
+
* document - but dropping a field the node carries is the thing this bridge
|
|
708
|
+
* does not do, and an attribute pandoc preserves costs nothing to keep.
|
|
709
|
+
*/
|
|
710
|
+
function citationDefinition(ctx, n) {
|
|
711
|
+
const key = String(n.key ?? '');
|
|
712
|
+
const [id, classes, kvs] = toAttr(n.attrs);
|
|
713
|
+
const entry = inlines(ctx, n.children);
|
|
714
|
+
return P.Div([id || (key ? `ref-${key}` : ''), ['csl-entry', ...classes], kvs],
|
|
715
|
+
// Section 18 allows an empty entry, and an empty `Para` is not a shape
|
|
716
|
+
// pandoc's own readers produce.
|
|
717
|
+
entry.length ? [P.Para(entry)] : []);
|
|
718
|
+
}
|
|
719
|
+
function findCaseInsensitive(map, target) {
|
|
720
|
+
const lower = target.toLowerCase();
|
|
721
|
+
for (const [k, v] of map) {
|
|
722
|
+
if (k.toLowerCase() === lower)
|
|
723
|
+
return v;
|
|
724
|
+
}
|
|
725
|
+
return undefined;
|
|
726
|
+
}
|
|
727
|
+
// --- Blocks ---
|
|
728
|
+
function blocks(ctx, nodes) {
|
|
729
|
+
if (!nodes)
|
|
730
|
+
return [];
|
|
731
|
+
const out = [];
|
|
732
|
+
for (const n of nodes)
|
|
733
|
+
out.push(...block(ctx, n));
|
|
734
|
+
return out;
|
|
735
|
+
}
|
|
736
|
+
/** Block types whose Pandoc form carries the Attr itself. */
|
|
737
|
+
const ATTR_CARRYING = new Set([
|
|
738
|
+
'heading',
|
|
739
|
+
'code_block',
|
|
740
|
+
'table',
|
|
741
|
+
'figure',
|
|
742
|
+
'figure_group',
|
|
743
|
+
'div',
|
|
744
|
+
'admonition',
|
|
745
|
+
// Its Div IS the entry, and its attrs are the `{author= year=}` block that
|
|
746
|
+
// belongs on it. Left out, the wrapper below adds a SECOND Div carrying the
|
|
747
|
+
// same key-values around the one that already has them.
|
|
748
|
+
'citation_definition',
|
|
749
|
+
]);
|
|
750
|
+
function block(ctx, n) {
|
|
751
|
+
const result = blockInner(ctx, n);
|
|
752
|
+
// A block-attribute line can attach attrs to ANY block. Pandoc's Para,
|
|
753
|
+
// BlockQuote, lists etc. have no Attr slot - preserve via a Div wrapper.
|
|
754
|
+
const a = n.attrs;
|
|
755
|
+
if (!ATTR_CARRYING.has(n.type) && hasAttrs(a)) {
|
|
756
|
+
// In roundtrip mode the carve-block marker lets the reverse direction
|
|
757
|
+
// restore the attrs onto the inner block instead of keeping a wrapper.
|
|
758
|
+
const [id, classes, kvs] = toAttr(a);
|
|
759
|
+
const marked = ctx.roundtrip
|
|
760
|
+
? [...kvs, ['carve-block', n.type]]
|
|
761
|
+
: kvs;
|
|
762
|
+
return [P.Div([id, classes, marked], result)];
|
|
763
|
+
}
|
|
764
|
+
return result;
|
|
765
|
+
}
|
|
766
|
+
/**
|
|
767
|
+
* True for the div form of a line block: the `line-block` class and nothing else
|
|
768
|
+
* to preserve. A div that ALSO carries an id, other classes or key/values is a
|
|
769
|
+
* div the author attributed, and Pandoc's LineBlock has no attribute slot to put
|
|
770
|
+
* them in - so those stay a Div rather than lose the attributes.
|
|
771
|
+
*/
|
|
772
|
+
function isLineBlockDiv(n, classes) {
|
|
773
|
+
if (!classes.includes('line-block'))
|
|
774
|
+
return false;
|
|
775
|
+
const [id, , kvs] = toAttr(n.attrs);
|
|
776
|
+
return classes.length === 1 && id === '' && kvs.length === 0;
|
|
777
|
+
}
|
|
778
|
+
/**
|
|
779
|
+
* A line block's stanzas are its child paragraphs; within a stanza the lines are
|
|
780
|
+
* separated by hard breaks. Pandoc's LineBlock is a flat list of lines, and a
|
|
781
|
+
* blank line between stanzas is an EMPTY line - the same shape pandoc's own
|
|
782
|
+
* markdown reader produces for a `|` line with nothing after it.
|
|
783
|
+
*/
|
|
784
|
+
function lineBlock(ctx, n) {
|
|
785
|
+
const lines = [];
|
|
786
|
+
const stanzas = n.children ?? [];
|
|
787
|
+
stanzas.forEach((stanza, i) => {
|
|
788
|
+
if (i > 0)
|
|
789
|
+
lines.push([]);
|
|
790
|
+
const kids = stanza.children ?? [stanza];
|
|
791
|
+
let current = [];
|
|
792
|
+
for (const kid of kids) {
|
|
793
|
+
if (kid.type === 'hard_break' || kid.type === 'soft_break') {
|
|
794
|
+
lines.push(inlines(ctx, current));
|
|
795
|
+
current = [];
|
|
796
|
+
continue;
|
|
797
|
+
}
|
|
798
|
+
current.push(kid);
|
|
799
|
+
}
|
|
800
|
+
lines.push(inlines(ctx, current));
|
|
801
|
+
});
|
|
802
|
+
return P.LineBlock(lines);
|
|
803
|
+
}
|
|
804
|
+
function blockInner(ctx, n) {
|
|
805
|
+
switch (n.type) {
|
|
806
|
+
case 'paragraph': {
|
|
807
|
+
const xs = kids(ctx, n);
|
|
808
|
+
return [ctx.tight ? P.Plain(xs) : P.Para(xs)];
|
|
809
|
+
}
|
|
810
|
+
case 'heading':
|
|
811
|
+
return [P.Header(Number(n.level ?? 1), toAttr(n.attrs), kids(ctx, n))];
|
|
812
|
+
case 'block_quote':
|
|
813
|
+
return [P.BlockQuote(untight(ctx, () => blocks(ctx, n.children)))];
|
|
814
|
+
case 'code_block': {
|
|
815
|
+
const lang = n.lang ? [String(n.lang)] : [];
|
|
816
|
+
const a = (n.attrs ?? {});
|
|
817
|
+
const kvs = Object.entries(a.keyValues ?? {});
|
|
818
|
+
return [
|
|
819
|
+
P.CodeBlock(P.attr(a.id, [...lang, ...(a.classes ?? [])], kvs), String(n.content ?? '')),
|
|
820
|
+
];
|
|
821
|
+
}
|
|
822
|
+
case 'raw_block':
|
|
823
|
+
return [P.RawBlock(String(n.format ?? ''), String(n.content ?? ''))];
|
|
824
|
+
case 'citation_definition':
|
|
825
|
+
return [citationDefinition(ctx, n)];
|
|
826
|
+
case 'thematic_break':
|
|
827
|
+
return [P.HorizontalRule];
|
|
828
|
+
case 'list':
|
|
829
|
+
return [list(ctx, n)];
|
|
830
|
+
case 'definition_list':
|
|
831
|
+
return [definitionList(ctx, n)];
|
|
832
|
+
case 'table':
|
|
833
|
+
return [table(ctx, n, null)];
|
|
834
|
+
case 'figure':
|
|
835
|
+
return figure(ctx, n);
|
|
836
|
+
case 'figure_group':
|
|
837
|
+
return figureGroup(ctx, n);
|
|
838
|
+
case 'admonition': {
|
|
839
|
+
const kind = String(n.kind ?? 'note');
|
|
840
|
+
if (kind === 'list-table' && ctx.listTable) {
|
|
841
|
+
const converted = listTableToTable(ctx, n);
|
|
842
|
+
if (converted)
|
|
843
|
+
return [converted];
|
|
844
|
+
warn(ctx, 'list-table: structure not table-shaped - kept as the degraded Div (content preserved)');
|
|
845
|
+
}
|
|
846
|
+
const title = Array.isArray(n.title)
|
|
847
|
+
? [P.Para([P.Strong(inlines(ctx, n.title))])]
|
|
848
|
+
: [];
|
|
849
|
+
const body = untight(ctx, () => blocks(ctx, n.children));
|
|
850
|
+
const [id, classes, kvs] = toAttr(n.attrs);
|
|
851
|
+
return [
|
|
852
|
+
P.Div([id, ['admonition', kind, ...classes], [...kvs, ...labelKv(ctx, n)]], [...title, ...labelCaption(ctx, n), ...body]),
|
|
853
|
+
];
|
|
854
|
+
}
|
|
855
|
+
// A LINE BLOCK (`::: |`, PART 9 SS23) is verse: each newline is a line of
|
|
856
|
+
// its own and the leading whitespace is preserved. Pandoc has `LineBlock`
|
|
857
|
+
// for exactly that.
|
|
858
|
+
//
|
|
859
|
+
// BOTH spellings are handled, because the arm for the node type alone was
|
|
860
|
+
// unreachable: the PINNED published engine models `::: |` as a div
|
|
861
|
+
// carrying the `line-block` class, and only carve-js main emits a
|
|
862
|
+
// dedicated `line_block` node. So every line block a user could actually
|
|
863
|
+
// produce fell through to the Div branch and reached the writers as a
|
|
864
|
+
// classed paragraph, while the code that would have handled it sat
|
|
865
|
+
// waiting for a pin bump.
|
|
866
|
+
case 'line_block':
|
|
867
|
+
return [lineBlock(ctx, n)];
|
|
868
|
+
case 'div': {
|
|
869
|
+
const [id, classes, kvs] = toAttr(n.attrs);
|
|
870
|
+
if (isLineBlockDiv(n, classes))
|
|
871
|
+
return [lineBlock(ctx, n)];
|
|
872
|
+
return [
|
|
873
|
+
P.Div([id, classes, [...kvs, ...labelKv(ctx, n)]], [
|
|
874
|
+
...labelCaption(ctx, n),
|
|
875
|
+
...untight(ctx, () => blocks(ctx, n.children)),
|
|
876
|
+
]),
|
|
877
|
+
];
|
|
878
|
+
}
|
|
879
|
+
case 'image':
|
|
880
|
+
// A sole image on its own line is a block-level node in Carve.
|
|
881
|
+
return [P.Para(inline(ctx, n))];
|
|
882
|
+
case 'comment':
|
|
883
|
+
// Unlike the definitions below, a dropped comment IS authored
|
|
884
|
+
// content leaving the document - so it is named rather than
|
|
885
|
+
// silent (markup-carve/pandoc-carve#75).
|
|
886
|
+
warn(ctx, `comment: dropped - Pandoc's AST has no comment node: ${truncateForWarning(String(n.content ?? ''))}`);
|
|
887
|
+
return [];
|
|
888
|
+
case 'abbreviation_def':
|
|
889
|
+
case 'link_reference_definition':
|
|
890
|
+
// Definitions are document metadata, not output blocks. PART 12
|
|
891
|
+
// section 3a puts the resolved destination and title directly on
|
|
892
|
+
// each link/image node, so dropping the definition here loses no
|
|
893
|
+
// information from the Pandoc document.
|
|
894
|
+
return [];
|
|
895
|
+
default: {
|
|
896
|
+
// An inline node at block level (defensive) or an unknown block.
|
|
897
|
+
warn(ctx, `block: unknown node type "${n.type}" degraded to a paragraph of its text`);
|
|
898
|
+
return [P.Para(textInlines(plainText([n])))];
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
function untight(ctx, fn) {
|
|
903
|
+
const prev = ctx.tight;
|
|
904
|
+
ctx.tight = false;
|
|
905
|
+
const result = fn();
|
|
906
|
+
ctx.tight = prev;
|
|
907
|
+
return result;
|
|
908
|
+
}
|
|
909
|
+
// --- Lists ---
|
|
910
|
+
const OL_TYPE = {
|
|
911
|
+
a: 'LowerAlpha',
|
|
912
|
+
A: 'UpperAlpha',
|
|
913
|
+
i: 'LowerRoman',
|
|
914
|
+
I: 'UpperRoman',
|
|
915
|
+
};
|
|
916
|
+
function list(ctx, n) {
|
|
917
|
+
const tight = Boolean(n.tight);
|
|
918
|
+
const items = n.items ?? [];
|
|
919
|
+
const converted = items.map((item) => {
|
|
920
|
+
const prev = ctx.tight;
|
|
921
|
+
ctx.tight = tight;
|
|
922
|
+
let itemBlocks = blocks(ctx, item.children);
|
|
923
|
+
ctx.tight = prev;
|
|
924
|
+
if (typeof item.checked === 'boolean') {
|
|
925
|
+
itemBlocks = prefixTaskMarker(itemBlocks, item.checked);
|
|
926
|
+
}
|
|
927
|
+
return itemBlocks;
|
|
928
|
+
});
|
|
929
|
+
if (n.ordered) {
|
|
930
|
+
const style = OL_TYPE[String(n.olType ?? '')] ?? 'Decimal';
|
|
931
|
+
// `delim` lands in the Carve AST with carve-js PR 342; older parsers
|
|
932
|
+
// simply leave it undefined and we emit pandoc's default.
|
|
933
|
+
const delim = n.delim === ')' ? 'OneParen' : n.delim === '.' ? 'Period' : 'DefaultDelim';
|
|
934
|
+
return P.OrderedList(Number(n.start ?? 1), style, converted, delim);
|
|
935
|
+
}
|
|
936
|
+
return P.BulletList(converted);
|
|
937
|
+
}
|
|
938
|
+
/** GFM-style task markers: pandoc renders `- [x]` as a leading ballot-box Str. */
|
|
939
|
+
function prefixTaskMarker(itemBlocks, checked) {
|
|
940
|
+
const marker = [P.Str(checked ? '☒' : '☐'), P.Space];
|
|
941
|
+
const first = itemBlocks[0];
|
|
942
|
+
if (first && (first.t === 'Plain' || first.t === 'Para')) {
|
|
943
|
+
return [
|
|
944
|
+
{ t: first.t, c: [...marker, ...first.c] },
|
|
945
|
+
...itemBlocks.slice(1),
|
|
946
|
+
];
|
|
947
|
+
}
|
|
948
|
+
return [P.Plain(marker), ...itemBlocks];
|
|
949
|
+
}
|
|
950
|
+
/**
|
|
951
|
+
* A definition list, from the FLAT wire sequence (PART 12): `definition_term`
|
|
952
|
+
* and `definition_description` nodes in document order, exactly as `<dt>` and
|
|
953
|
+
* `<dd>` appear in the rendered list.
|
|
954
|
+
*
|
|
955
|
+
* Pandoc's `DefinitionList` is grouped, so the grouping is recovered by the
|
|
956
|
+
* rule the renderers already use and the engines agree on: a run of
|
|
957
|
+
* descriptions belongs to the run of terms before it.
|
|
958
|
+
*/
|
|
959
|
+
function definitionList(ctx, n) {
|
|
960
|
+
const entries = n.items ?? [];
|
|
961
|
+
const converted = [];
|
|
962
|
+
let terms = [];
|
|
963
|
+
let defs = [];
|
|
964
|
+
const flush = () => {
|
|
965
|
+
if (!terms.length && !defs.length)
|
|
966
|
+
return;
|
|
967
|
+
// Pandoc has one term per item; multiple Carve terms join with LineBreak.
|
|
968
|
+
const term = terms.length
|
|
969
|
+
? terms.reduce((acc, t) => (acc.length ? [...acc, P.LineBreak, ...t] : t), [])
|
|
970
|
+
: [];
|
|
971
|
+
converted.push([term, defs]);
|
|
972
|
+
terms = [];
|
|
973
|
+
defs = [];
|
|
974
|
+
};
|
|
975
|
+
for (const entry of entries) {
|
|
976
|
+
if (entry?.type === 'definition_term') {
|
|
977
|
+
// A term after a description starts the next entry.
|
|
978
|
+
if (defs.length)
|
|
979
|
+
flush();
|
|
980
|
+
terms.push(inlines(ctx, entry.children));
|
|
981
|
+
}
|
|
982
|
+
else if (entry?.type === 'definition_description') {
|
|
983
|
+
defs.push(untight(ctx, () => blocks(ctx, entry.children)));
|
|
984
|
+
}
|
|
985
|
+
else {
|
|
986
|
+
warn(ctx, `definition list: unknown entry type "${String(entry?.type)}" - skipped`);
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
flush();
|
|
990
|
+
return P.DefinitionList(converted);
|
|
991
|
+
}
|
|
992
|
+
const ALIGN = {
|
|
993
|
+
left: 'AlignLeft',
|
|
994
|
+
right: 'AlignRight',
|
|
995
|
+
center: 'AlignCenter',
|
|
996
|
+
};
|
|
997
|
+
/**
|
|
998
|
+
* Convert a Carve table.
|
|
999
|
+
*
|
|
1000
|
+
* Carve marks *continuation* cells (`span: "colspan" | "rowspan"` on the cell
|
|
1001
|
+
* that is covered); pandoc puts rowSpan/colSpan counts on the origin Cell and
|
|
1002
|
+
* omits covered positions. We walk the grid, resolve each continuation to its
|
|
1003
|
+
* origin transitively, bump the origin's span, and emit only origin cells.
|
|
1004
|
+
*/
|
|
1005
|
+
function table(ctx, n, caption, shortCaption = null) {
|
|
1006
|
+
if (!caption && Array.isArray(n.caption)) {
|
|
1007
|
+
ctx.captionKind = captionLabel(n.caption);
|
|
1008
|
+
caption = inlines(ctx, n.caption);
|
|
1009
|
+
ctx.captionKind = undefined;
|
|
1010
|
+
}
|
|
1011
|
+
if (!shortCaption && Array.isArray(n.shortCaption)) {
|
|
1012
|
+
shortCaption = inlines(ctx, n.shortCaption);
|
|
1013
|
+
}
|
|
1014
|
+
const rows = (n.rows ?? []).map((r) => r.cells ?? []);
|
|
1015
|
+
// PART 12 §15: an explicit partition of `rows` into head, body groups and
|
|
1016
|
+
// foot. Absent means the implicit structure below, which is what every
|
|
1017
|
+
// renderer derives anyway.
|
|
1018
|
+
const read = readRowGroups(n.rowGroups, rows.length);
|
|
1019
|
+
if (read.error) {
|
|
1020
|
+
warn(ctx, `table: ${read.error} - converted with the implicit head/body split instead`);
|
|
1021
|
+
}
|
|
1022
|
+
const groups = read.groups;
|
|
1023
|
+
// Split leading all-header rows into the table head.
|
|
1024
|
+
let headCount = 0;
|
|
1025
|
+
while (headCount < rows.length && rows[headCount].length > 0 && rows[headCount].every((c) => c.header)) {
|
|
1026
|
+
headCount++;
|
|
1027
|
+
}
|
|
1028
|
+
if (groups)
|
|
1029
|
+
headCount = groups.headRows;
|
|
1030
|
+
// Which pandoc row list each row belongs to. A cell may not span across
|
|
1031
|
+
// two of them: head, a body group's intermediate header, that group's
|
|
1032
|
+
// rows and the foot are separate `[Row]` lists in pandoc's model.
|
|
1033
|
+
const sectionOf = Array(rows.length).fill(0);
|
|
1034
|
+
{
|
|
1035
|
+
let at = 0;
|
|
1036
|
+
let section = 0;
|
|
1037
|
+
const take = (howMany) => {
|
|
1038
|
+
for (let i = 0; i < howMany && at < rows.length; i++)
|
|
1039
|
+
sectionOf[at++] = section;
|
|
1040
|
+
section++;
|
|
1041
|
+
};
|
|
1042
|
+
take(headCount);
|
|
1043
|
+
if (groups) {
|
|
1044
|
+
for (const body of groups.bodies) {
|
|
1045
|
+
take(body.headRows);
|
|
1046
|
+
take(body.bodyRows);
|
|
1047
|
+
}
|
|
1048
|
+
take(groups.footRows);
|
|
1049
|
+
}
|
|
1050
|
+
else {
|
|
1051
|
+
take(rows.length - headCount);
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
// Column alignment comes from the first row's cells, but ONLY when that row
|
|
1055
|
+
// is the head. Carve spells column alignment on the header marker
|
|
1056
|
+
// (`|=> Name |`), and a marker on a body cell aligns that cell alone -
|
|
1057
|
+
// measured on the engine: `|>a| b |` styles `a` and leaves the cell below
|
|
1058
|
+
// it untouched. Reading a body row's markers as the column's therefore
|
|
1059
|
+
// aligned cells the author had not aligned, in every pandoc writer, because
|
|
1060
|
+
// a cell with `AlignDefault` inherits its ColSpec.
|
|
1061
|
+
//
|
|
1062
|
+
// The head case moves the alignment rather than copying it: the marker on a
|
|
1063
|
+
// head cell IS the column's alignment, so it becomes the ColSpec and the
|
|
1064
|
+
// cell keeps `AlignDefault`. That is pandoc's own model, and it makes the
|
|
1065
|
+
// crossing exact in both directions instead of adding a per-cell override
|
|
1066
|
+
// that was never in the source.
|
|
1067
|
+
const firstRowIsHead = headCount > 0;
|
|
1068
|
+
const firstRow = rows[0] ?? [];
|
|
1069
|
+
const colAligns = firstRow.map((c) => (firstRowIsHead ? ALIGN[c.align ?? ''] ?? 'AlignDefault' : 'AlignDefault'));
|
|
1070
|
+
const origin = rows.map(() => []);
|
|
1071
|
+
const emitted = rows.map((row) => row.map(() => null));
|
|
1072
|
+
for (let r = 0; r < rows.length; r++) {
|
|
1073
|
+
const row = rows[r];
|
|
1074
|
+
for (let c = 0; c < row.length; c++) {
|
|
1075
|
+
const cc = row[c];
|
|
1076
|
+
// Pandoc omits covered positions entirely, so a continuation that
|
|
1077
|
+
// RESOLVES leaves nowhere to hang its attributes. The grammar
|
|
1078
|
+
// cannot produce that shape (a cell carrying attributes is never a
|
|
1079
|
+
// bare span cell); a wire AST can. An ORPHAN continuation is a
|
|
1080
|
+
// different story - it falls through and becomes a real cell below,
|
|
1081
|
+
// which keeps them, so the warning belongs on the resolving paths
|
|
1082
|
+
// only.
|
|
1083
|
+
const droppedAttrs = () => {
|
|
1084
|
+
if (hasAttrs(cc.attrs)) {
|
|
1085
|
+
warn(ctx, `table: attributes on the continuation cell at row ${r + 1}, col ${c + 1} are dropped - pandoc omits covered positions`);
|
|
1086
|
+
}
|
|
1087
|
+
};
|
|
1088
|
+
if (cc.span === 'colspan') {
|
|
1089
|
+
const org = origin[r][c - 1];
|
|
1090
|
+
if (org) {
|
|
1091
|
+
// max() keeps 2D blocks correct: interior continuations of
|
|
1092
|
+
// a lower row must not widen the origin again.
|
|
1093
|
+
if (r === org.row)
|
|
1094
|
+
org.cell.colSpan = Math.max(org.cell.colSpan, c - org.col + 1);
|
|
1095
|
+
origin[r][c] = org;
|
|
1096
|
+
droppedAttrs();
|
|
1097
|
+
continue;
|
|
1098
|
+
}
|
|
1099
|
+
warn(ctx, `table: colspan continuation at row ${r + 1}, col ${c + 1} has no origin - emitting empty cell`);
|
|
1100
|
+
}
|
|
1101
|
+
else if (cc.span === 'rowspan') {
|
|
1102
|
+
const org = r > 0 ? origin[r - 1][c] : undefined;
|
|
1103
|
+
if (org) {
|
|
1104
|
+
// POLICY: clip, do not restructure. Carve is the richer
|
|
1105
|
+
// model here - its rows are one flat list, so a rowspan may
|
|
1106
|
+
// start in the head and continue into the body; pandoc's
|
|
1107
|
+
// TableHead and TableBody hold separate row lists and a
|
|
1108
|
+
// Cell's rowSpan is confined to the section that owns it,
|
|
1109
|
+
// so the covering cell has no shape on that side. The
|
|
1110
|
+
// alternatives both lie about the source: moving the
|
|
1111
|
+
// boundary so the span fits silently reclassifies a header
|
|
1112
|
+
// row as a body row (or the reverse), and duplicating the
|
|
1113
|
+
// origin's content into the body invents a second cell the
|
|
1114
|
+
// author never wrote. Clipping to an empty body cell keeps
|
|
1115
|
+
// the grid the right size, and the warning below says what
|
|
1116
|
+
// was lost.
|
|
1117
|
+
if (sectionOf[org.row] !== sectionOf[r]) {
|
|
1118
|
+
warn(ctx, `table: rowspan crossing the header/body boundary at row ${r + 1}, col ${c + 1} - clipped to an empty body cell (pandoc cannot represent it)`);
|
|
1119
|
+
}
|
|
1120
|
+
else {
|
|
1121
|
+
// max(): a 2D block has one rowspan continuation per
|
|
1122
|
+
// covered column - count rows, not continuations.
|
|
1123
|
+
org.cell.rowSpan = Math.max(org.cell.rowSpan, r - org.row + 1);
|
|
1124
|
+
origin[r][c] = org;
|
|
1125
|
+
droppedAttrs();
|
|
1126
|
+
continue;
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
else {
|
|
1130
|
+
warn(ctx, `table: rowspan continuation at row ${r + 1}, col ${c + 1} has no origin - emitting empty cell`);
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
const cellBlocks = cc.children?.length
|
|
1134
|
+
? [P.Plain(untight(ctx, () => inlines(ctx, cc.children)))]
|
|
1135
|
+
: [];
|
|
1136
|
+
// A head cell's marker became the ColSpec above, so repeating it
|
|
1137
|
+
// here would emit an override pandoc does not need and the source
|
|
1138
|
+
// never had.
|
|
1139
|
+
const cellAlign = firstRowIsHead && r === 0
|
|
1140
|
+
? 'AlignDefault'
|
|
1141
|
+
: ALIGN[cc.align ?? ''] ?? 'AlignDefault';
|
|
1142
|
+
const pc = P.cell(cellBlocks, cellAlign, toAttr(cc.attrs));
|
|
1143
|
+
origin[r][c] = { cell: pc, row: r, col: c };
|
|
1144
|
+
emitted[r][c] = pc;
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
const toRows = (from, to) => {
|
|
1148
|
+
const out = [];
|
|
1149
|
+
for (let r = from; r < to; r++) {
|
|
1150
|
+
const cells = emitted[r].filter((x) => x !== null);
|
|
1151
|
+
const rowAttrs = n.rows[r]?.attrs;
|
|
1152
|
+
out.push(P.row(cells, hasAttrs(rowAttrs) ? toAttr(rowAttrs) : undefined));
|
|
1153
|
+
}
|
|
1154
|
+
return out;
|
|
1155
|
+
};
|
|
1156
|
+
let bodies;
|
|
1157
|
+
let footRows = [];
|
|
1158
|
+
if (groups) {
|
|
1159
|
+
let at = headCount;
|
|
1160
|
+
bodies = groups.bodies.map((body) => {
|
|
1161
|
+
const headTo = at + body.headRows;
|
|
1162
|
+
const bodyTo = headTo + body.bodyRows;
|
|
1163
|
+
const converted = {
|
|
1164
|
+
attr: toAttr(body.attrs),
|
|
1165
|
+
headRows: toRows(at, headTo),
|
|
1166
|
+
bodyRows: toRows(headTo, bodyTo),
|
|
1167
|
+
};
|
|
1168
|
+
if (body.rowHeadColumns)
|
|
1169
|
+
converted.rowHeadColumns = body.rowHeadColumns;
|
|
1170
|
+
at = bodyTo;
|
|
1171
|
+
return converted;
|
|
1172
|
+
});
|
|
1173
|
+
footRows = toRows(at, at + groups.footRows);
|
|
1174
|
+
}
|
|
1175
|
+
else {
|
|
1176
|
+
// The implicit structure: everything after the head is one body.
|
|
1177
|
+
bodies = [{ bodyRows: toRows(headCount, rows.length) }];
|
|
1178
|
+
}
|
|
1179
|
+
return P.Table(toAttr(n.attrs), caption, colAligns, toRows(0, headCount), bodies, footRows, shortCaption);
|
|
1180
|
+
}
|
|
1181
|
+
// --- List tables (listTable extension semantics) ---
|
|
1182
|
+
/** Sole-content span marker of a list-table cell: `<` joins left, `^` joins up. */
|
|
1183
|
+
function spanMarker(cellBlocks) {
|
|
1184
|
+
if (cellBlocks.length !== 1)
|
|
1185
|
+
return null;
|
|
1186
|
+
const only = cellBlocks[0];
|
|
1187
|
+
if (only.type !== 'paragraph')
|
|
1188
|
+
return null;
|
|
1189
|
+
const children = only.children ?? [];
|
|
1190
|
+
if (children.length !== 1)
|
|
1191
|
+
return null;
|
|
1192
|
+
const t = children[0];
|
|
1193
|
+
if (t.type !== 'text')
|
|
1194
|
+
return null;
|
|
1195
|
+
const v = String(t.value).trim();
|
|
1196
|
+
return v === '<' ? 'colspan' : v === '^' ? 'rowspan' : null;
|
|
1197
|
+
}
|
|
1198
|
+
/**
|
|
1199
|
+
* Resolve `header-rows` like the canonical listTable extension: absent -> 0,
|
|
1200
|
+
* boolean form `{header-rows}` (stored as "") -> 1, explicit number -> count.
|
|
1201
|
+
*/
|
|
1202
|
+
function headerCount(value) {
|
|
1203
|
+
if (value === undefined)
|
|
1204
|
+
return 0;
|
|
1205
|
+
if (value.trim() === '')
|
|
1206
|
+
return 1;
|
|
1207
|
+
const parsed = parseInt(value, 10);
|
|
1208
|
+
return Number.isNaN(parsed) ? 0 : Math.max(0, parsed);
|
|
1209
|
+
}
|
|
1210
|
+
/**
|
|
1211
|
+
* Returns null when the block is not strictly table-shaped (extra siblings
|
|
1212
|
+
* around the outer list, or a row item that is not exactly one nested list) -
|
|
1213
|
+
* mirroring the canonical extension, which defers those to the degraded Div
|
|
1214
|
+
* so authored content is never dropped.
|
|
1215
|
+
*/
|
|
1216
|
+
function listTableToTable(ctx, n) {
|
|
1217
|
+
const a = (n.attrs ?? {});
|
|
1218
|
+
const headerRows = headerCount(a.keyValues?.['header-rows']);
|
|
1219
|
+
// `header-cols` promotes the first N cells of every row to row headers
|
|
1220
|
+
// (extensions.md §5.1), which is exactly pandoc's `RowHeadColumns`. It was
|
|
1221
|
+
// read by nobody and left behind as an ordinary table attribute.
|
|
1222
|
+
const headerCols = headerCount(a.keyValues?.['header-cols']);
|
|
1223
|
+
const caption = Array.isArray(n.title) ? inlines(ctx, n.title) : null;
|
|
1224
|
+
// Strict shape: exactly one child, the outer list; every row item holds
|
|
1225
|
+
// exactly one child, the inner cell list.
|
|
1226
|
+
const children = n.children ?? [];
|
|
1227
|
+
if (children.length !== 1 || children[0].type !== 'list')
|
|
1228
|
+
return null;
|
|
1229
|
+
const rowItems = children[0].items ?? [];
|
|
1230
|
+
const grid = [];
|
|
1231
|
+
for (const rowItem of rowItems) {
|
|
1232
|
+
const rowChildren = rowItem.children ?? [];
|
|
1233
|
+
if (rowChildren.length !== 1 || rowChildren[0].type !== 'list')
|
|
1234
|
+
return null;
|
|
1235
|
+
grid.push((rowChildren[0].items ?? []).map((cellItem) => cellItem.children ?? []));
|
|
1236
|
+
}
|
|
1237
|
+
const nCols = Math.max(0, ...grid.map((r) => r.length));
|
|
1238
|
+
const origin = grid.map(() => []);
|
|
1239
|
+
const emitted = grid.map((r) => r.map(() => null));
|
|
1240
|
+
for (let r = 0; r < grid.length; r++) {
|
|
1241
|
+
for (let c = 0; c < grid[r].length; c++) {
|
|
1242
|
+
const cellBlocks = grid[r][c];
|
|
1243
|
+
const marker = spanMarker(cellBlocks);
|
|
1244
|
+
if (marker === 'colspan' && origin[r][c - 1]) {
|
|
1245
|
+
const org = origin[r][c - 1];
|
|
1246
|
+
if (r === org.row)
|
|
1247
|
+
org.cell.colSpan = Math.max(org.cell.colSpan, c - org.col + 1);
|
|
1248
|
+
origin[r][c] = org;
|
|
1249
|
+
continue;
|
|
1250
|
+
}
|
|
1251
|
+
if (marker === 'rowspan' && r > 0 && origin[r - 1][c]) {
|
|
1252
|
+
const org = origin[r - 1][c];
|
|
1253
|
+
// The list-table half of the clip-do-not-restructure policy the
|
|
1254
|
+
// pipe-table path states in full; same reason, same outcome.
|
|
1255
|
+
const crossesHead = org.row < headerRows && r >= headerRows;
|
|
1256
|
+
if (!crossesHead) {
|
|
1257
|
+
org.cell.rowSpan = Math.max(org.cell.rowSpan, r - org.row + 1);
|
|
1258
|
+
origin[r][c] = org;
|
|
1259
|
+
continue;
|
|
1260
|
+
}
|
|
1261
|
+
warn(ctx, `list-table: rowspan crossing the header/body boundary at row ${r + 1} - clipped`);
|
|
1262
|
+
}
|
|
1263
|
+
const pc = P.cell(untight(ctx, () => blocks(ctx, cellBlocks)));
|
|
1264
|
+
origin[r][c] = { cell: pc, row: r, col: c };
|
|
1265
|
+
emitted[r][c] = pc;
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
const toRows = (from, to) => {
|
|
1269
|
+
const out = [];
|
|
1270
|
+
for (let r = from; r < to; r++) {
|
|
1271
|
+
out.push(P.row(emitted[r].filter((x) => x !== null)));
|
|
1272
|
+
}
|
|
1273
|
+
return out;
|
|
1274
|
+
};
|
|
1275
|
+
const head = Math.min(headerRows, grid.length);
|
|
1276
|
+
const [id, classes, kvs] = toAttr(a);
|
|
1277
|
+
const body = { bodyRows: toRows(head, grid.length) };
|
|
1278
|
+
if (headerCols > 0)
|
|
1279
|
+
body.rowHeadColumns = Math.min(headerCols, nCols);
|
|
1280
|
+
return P.Table([id, classes, kvs.filter(([k]) => k !== 'header-rows' && k !== 'header-cols')], caption, Array(nCols).fill('AlignDefault'), toRows(0, head), [body]);
|
|
1281
|
+
}
|
|
1282
|
+
// --- Figures ---
|
|
1283
|
+
/**
|
|
1284
|
+
* A captioned host, which is pandoc's `Figure` - the generic captioned wrapper
|
|
1285
|
+
* on both sides (PART 9 §4b).
|
|
1286
|
+
*
|
|
1287
|
+
* A QUOTE IS NOT A SPECIAL HOST. carve#1161 briefly made a caption on a quote
|
|
1288
|
+
* an `attribution` rendered inside the `<blockquote>`, and this function
|
|
1289
|
+
* rerouted a quote target into that shape; the clause is withdrawn
|
|
1290
|
+
* (carve#1213), and PART 9 §4b now cites the HTML Standard in the other
|
|
1291
|
+
* direction - attribution "must be placed outside the `blockquote` element",
|
|
1292
|
+
* with a `<figure>` + `<figcaption>` given as the way to relate the two. So a
|
|
1293
|
+
* captioned quote takes the same path as a captioned code listing or a
|
|
1294
|
+
* captioned equation, and corpus 07-blockquote-with-attribution pins the
|
|
1295
|
+
* `<figure><blockquote>…<figcaption>` bytes.
|
|
1296
|
+
*
|
|
1297
|
+
* WHAT THE REROUTE BOUGHT, AND WHY IT IS NOT ENOUGH. Pandoc's plain and rst
|
|
1298
|
+
* writers drop a `Figure` caption wholesale, and the latex writer floats it -
|
|
1299
|
+
* measured on pandoc 3.5, and the reason §4a was implemented here. But they do
|
|
1300
|
+
* that to EVERY figure: a captioned code block loses its caption in both
|
|
1301
|
+
* writers today. That is a degradation of those two writers, one level below
|
|
1302
|
+
* the AST this bridge produces, and paying for it with a node shape the spec
|
|
1303
|
+
* denies would put the attribution in the one place the HTML Standard names as
|
|
1304
|
+
* wrong.
|
|
1305
|
+
*/
|
|
1306
|
+
function figure(ctx, n) {
|
|
1307
|
+
const target = n.target;
|
|
1308
|
+
ctx.captionKind = captionLabel(n.caption);
|
|
1309
|
+
const caption = Array.isArray(n.caption) ? inlines(ctx, n.caption) : null;
|
|
1310
|
+
const shortCaption = Array.isArray(n.shortCaption)
|
|
1311
|
+
? inlines(ctx, n.shortCaption)
|
|
1312
|
+
: null;
|
|
1313
|
+
ctx.captionKind = undefined;
|
|
1314
|
+
if (!target)
|
|
1315
|
+
return [];
|
|
1316
|
+
if (target.type === 'table') {
|
|
1317
|
+
// Pandoc tables carry a native caption; no Figure wrapper needed.
|
|
1318
|
+
return [table(ctx, target, caption, shortCaption)];
|
|
1319
|
+
}
|
|
1320
|
+
if (target.type === 'image') {
|
|
1321
|
+
const img = inline(ctx, target);
|
|
1322
|
+
return [P.Figure(toAttr(n.attrs), caption, [P.Plain(img)], shortCaption)];
|
|
1323
|
+
}
|
|
1324
|
+
// Any other captionable target, a QUOTE INCLUDED - this is the arm a
|
|
1325
|
+
// captioned quote takes, at document level and as a §4c panel alike. It
|
|
1326
|
+
// used to be unreachable for a quote outside a group, because §4a rerouted
|
|
1327
|
+
// that case; nothing reroutes it now.
|
|
1328
|
+
return [P.Figure(toAttr(n.attrs), caption, untight(ctx, () => block(ctx, target)), shortCaption)];
|
|
1329
|
+
}
|
|
1330
|
+
/** The §4c panels of a group: its `figure` and `table` children, in order. */
|
|
1331
|
+
function isPanel(n) {
|
|
1332
|
+
return n?.type === 'figure' || n?.type === 'table';
|
|
1333
|
+
}
|
|
1334
|
+
/**
|
|
1335
|
+
* A composite figure (PART 9 §4c, carve#1122): a bare `::: figure` container
|
|
1336
|
+
* is ONE figure of ordered panels, and Pandoc has that model natively - a
|
|
1337
|
+
* `Figure` whose blocks are themselves `Figure`s is its subfigure shape. So
|
|
1338
|
+
* the group becomes the outer `Figure`, its caption the outer caption, and its
|
|
1339
|
+
* children the blocks, with the panels among them lowering to nested Figures.
|
|
1340
|
+
*
|
|
1341
|
+
* Before the engine pin carried the node, `::: figure` parsed as a generic
|
|
1342
|
+
* admonition and crossed as `Div ["admonition","figure"]`. That Div is gone;
|
|
1343
|
+
* a filter keyed on it has to key on the Figure nesting instead.
|
|
1344
|
+
*
|
|
1345
|
+
* THE NUMBER IS DRAWN AT THE OPENING FENCE, which is why the caption is
|
|
1346
|
+
* converted BEFORE the children even though its `^ ` line is the construct's
|
|
1347
|
+
* last: corpus 318-composite-figures-11 numbers the group "Figure 1" and a
|
|
1348
|
+
* captioned figure nested deeper inside it "Figure 2".
|
|
1349
|
+
*
|
|
1350
|
+
* PANELS ARE THE DIRECT `figure`/`table` CHILDREN, and everything else is
|
|
1351
|
+
* plain group content preserved IN PLACE between them (§4c, corpus
|
|
1352
|
+
* 318-composite-figures-5) - so the children convert in source order and
|
|
1353
|
+
* nothing is re-sorted into a panel array.
|
|
1354
|
+
*
|
|
1355
|
+
* A DIRECT-CHILD CAPTIONED QUOTE IS A PANEL like any other captioned host -
|
|
1356
|
+
* "the quote is not a special host inside the group either" (corpus
|
|
1357
|
+
* 318-composite-figures-10, where it renders as a panel with a figcaption).
|
|
1358
|
+
* It reads that way because a captioned quote is a `figure` EVERYWHERE now,
|
|
1359
|
+
* not because the group holds a reroute the rest of the document does not; the
|
|
1360
|
+
* §4a attribution that once needed one is withdrawn (carve#1213).
|
|
1361
|
+
*/
|
|
1362
|
+
function figureGroup(ctx, n) {
|
|
1363
|
+
ctx.captionKind = captionLabel(n.caption);
|
|
1364
|
+
const caption = Array.isArray(n.caption) ? inlines(ctx, n.caption) : null;
|
|
1365
|
+
ctx.captionKind = undefined;
|
|
1366
|
+
const children = n.children ?? [];
|
|
1367
|
+
const body = untight(ctx, () => children.flatMap((child) => {
|
|
1368
|
+
if (!isPanel(child))
|
|
1369
|
+
return block(ctx, child);
|
|
1370
|
+
const prev = ctx.inPanel;
|
|
1371
|
+
ctx.inPanel = true;
|
|
1372
|
+
try {
|
|
1373
|
+
return child.type === 'figure' ? figure(ctx, child) : block(ctx, child);
|
|
1374
|
+
}
|
|
1375
|
+
finally {
|
|
1376
|
+
ctx.inPanel = prev;
|
|
1377
|
+
}
|
|
1378
|
+
}));
|
|
1379
|
+
return [P.Figure(toAttr(n.attrs), caption, body)];
|
|
1380
|
+
}
|
|
1381
|
+
// --- Metadata (frontmatter) ---
|
|
1382
|
+
/**
|
|
1383
|
+
* The YAML subset typical frontmatter uses, read to the depth pandoc's `Meta`
|
|
1384
|
+
* has: nested maps, block and flow sequences, scalars.
|
|
1385
|
+
*
|
|
1386
|
+
* Not a YAML implementation. Anchors, tags, multi-document streams, block
|
|
1387
|
+
* scalars and flow maps are out; a line that does not fit is reported and
|
|
1388
|
+
* skipped rather than guessed at. What IS in is the shape a real document
|
|
1389
|
+
* carries - `author: [ - name:, affiliation: ]`, `keywords:` as a block list,
|
|
1390
|
+
* a `bibliography` map - which used to end as an EMPTY `MetaInlines` under the
|
|
1391
|
+
* parent key, with one "line not understood" per child line and nothing said
|
|
1392
|
+
* about the parent having been emptied.
|
|
1393
|
+
*/
|
|
1394
|
+
function parseMeta(ctx, frontmatter) {
|
|
1395
|
+
const fm = frontmatter;
|
|
1396
|
+
if (!fm?.content)
|
|
1397
|
+
return {};
|
|
1398
|
+
if (fm.format && fm.format !== 'yaml') {
|
|
1399
|
+
warn(ctx, `frontmatter: format "${fm.format}" not supported - skipped`);
|
|
1400
|
+
return {};
|
|
1401
|
+
}
|
|
1402
|
+
const lines = [];
|
|
1403
|
+
// Block scalars come out FIRST, while the blank lines inside them are still
|
|
1404
|
+
// there: the line reader below drops blank lines (they carry no structure
|
|
1405
|
+
// in a mapping), and a blank line inside a literal scalar is a paragraph
|
|
1406
|
+
// break. Each becomes one sentinel scalar the ordinary reader can carry.
|
|
1407
|
+
for (const raw of liftBlockScalars(ctx, fm.content).split('\n')) {
|
|
1408
|
+
if (!raw.trim() || raw.trim().startsWith('#'))
|
|
1409
|
+
continue;
|
|
1410
|
+
lines.push({ indent: raw.length - raw.trimStart().length, text: raw.trim() });
|
|
1411
|
+
}
|
|
1412
|
+
const reader = { ctx, lines, at: 0 };
|
|
1413
|
+
const map = readMapping(reader, lines[0]?.indent ?? 0);
|
|
1414
|
+
// A line the mapping reader could not use never advances past it; the guard
|
|
1415
|
+
// is what keeps a malformed document from spinning here.
|
|
1416
|
+
while (reader.at < lines.length) {
|
|
1417
|
+
warn(ctx, `frontmatter: line not understood, skipped: ${lines[reader.at].text}`);
|
|
1418
|
+
reader.at++;
|
|
1419
|
+
}
|
|
1420
|
+
return map;
|
|
1421
|
+
}
|
|
1422
|
+
/**
|
|
1423
|
+
* A `key: |` line and the lines indented under it, replaced by one sentinel.
|
|
1424
|
+
*
|
|
1425
|
+
* The literal block scalar is the ONE YAML form that carries block content,
|
|
1426
|
+
* and it is the form pandoc's own markdown writer emits for `MetaBlocks` and
|
|
1427
|
+
* its reader turns back into `MetaBlocks` - key-agnostically, so `abstract`
|
|
1428
|
+
* gets no special case here either. Lifting it out before the line reader runs
|
|
1429
|
+
* keeps two things that would otherwise fight: the reader drops blank lines,
|
|
1430
|
+
* and a blank line inside the scalar is a paragraph break.
|
|
1431
|
+
*
|
|
1432
|
+
* Only the plain `|` is read. `|-`, `|+`, `>` and an explicit indentation
|
|
1433
|
+
* indicator are not what the writer emits, and this file's rule for the rest of
|
|
1434
|
+
* YAML applies to them too: a line that does not fit a known shape is reported
|
|
1435
|
+
* and skipped, never guessed at.
|
|
1436
|
+
*/
|
|
1437
|
+
const BLOCK_SCALAR_OPEN = /^(\s*)("[^"]*"|'[^']*'|[^:"'#][^:]*|[^:"'#]):[ \t]*\|[ \t]*$/;
|
|
1438
|
+
function liftBlockScalars(ctx, content) {
|
|
1439
|
+
if (!content.includes('|'))
|
|
1440
|
+
return content;
|
|
1441
|
+
const lines = content.split('\n');
|
|
1442
|
+
const out = [];
|
|
1443
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1444
|
+
const open = BLOCK_SCALAR_OPEN.exec(lines[i]);
|
|
1445
|
+
if (!open) {
|
|
1446
|
+
out.push(lines[i]);
|
|
1447
|
+
continue;
|
|
1448
|
+
}
|
|
1449
|
+
const keyIndent = open[1].length;
|
|
1450
|
+
const body = [];
|
|
1451
|
+
let j = i + 1;
|
|
1452
|
+
for (; j < lines.length; j++) {
|
|
1453
|
+
const line = lines[j];
|
|
1454
|
+
const deeper = line.trim() !== '' && line.length - line.trimStart().length > keyIndent;
|
|
1455
|
+
if (!deeper && line.trim() !== '')
|
|
1456
|
+
break;
|
|
1457
|
+
body.push(line);
|
|
1458
|
+
}
|
|
1459
|
+
// Trailing blank lines belong to whatever follows, not to the scalar.
|
|
1460
|
+
while (body.length && body[body.length - 1].trim() === '')
|
|
1461
|
+
body.pop();
|
|
1462
|
+
if (!body.length) {
|
|
1463
|
+
out.push(lines[i]);
|
|
1464
|
+
continue;
|
|
1465
|
+
}
|
|
1466
|
+
const strip = Math.min(...body.filter((l) => l.trim() !== '').map((l) => l.length - l.trimStart().length));
|
|
1467
|
+
// A NUL cannot occur in the frontmatter of a real document and is
|
|
1468
|
+
// not whitespace, so the sentinel survives the reader's `trim()`
|
|
1469
|
+
// and can never be confused with a scalar the author wrote.
|
|
1470
|
+
const sentinel = `\u0000block${ctx.blockScalars.size}\u0000`;
|
|
1471
|
+
ctx.blockScalars.set(sentinel, body.map((l) => l.slice(strip)).join('\n') + '\n');
|
|
1472
|
+
out.push(`${open[1]}${open[2]}: ${sentinel}`);
|
|
1473
|
+
i = j - 1;
|
|
1474
|
+
}
|
|
1475
|
+
return out.join('\n');
|
|
1476
|
+
}
|
|
1477
|
+
/**
|
|
1478
|
+
* The Carve markup inside a literal block scalar, as `MetaBlocks`.
|
|
1479
|
+
*
|
|
1480
|
+
* This is the half of the round trip that used to be missing, and its absence
|
|
1481
|
+
* was the whole argument for dropping `MetaBlocks` on the way out: the value
|
|
1482
|
+
* "makes the frontmatter carry markup that nothing on the reading side
|
|
1483
|
+
* parses". Something does now, so the value survives both ways - the same
|
|
1484
|
+
* bargain pandoc's own markdown reader and writer strike over `abstract: |`.
|
|
1485
|
+
*/
|
|
1486
|
+
function blockScalarValue(ctx, key, source) {
|
|
1487
|
+
if (!ctx.parseBlocks) {
|
|
1488
|
+
// The AST entry points supply the parser; a caller reaching `convert`
|
|
1489
|
+
// directly has no engine to lend, and the old skip is still the honest
|
|
1490
|
+
// answer there.
|
|
1491
|
+
warn(ctx, `frontmatter: block content under "${key}" needs a Carve parser to read - `
|
|
1492
|
+
+ 'use carveToPandoc / carveAstToPandoc, which supply one; skipped');
|
|
1493
|
+
return P.MetaInlines([]);
|
|
1494
|
+
}
|
|
1495
|
+
const children = ctx.parseBlocks(source);
|
|
1496
|
+
return P.MetaBlocks(blocks(ctx, children));
|
|
1497
|
+
}
|
|
1498
|
+
/**
|
|
1499
|
+
* A `key:` line. The unquoted alternative must not OPEN with a quote, or a
|
|
1500
|
+
* quoted scalar holding a colon (`- "scope: local"`) matches it and becomes a
|
|
1501
|
+
* map whose key is `"scope` - pandoc reads that item as one string.
|
|
1502
|
+
*/
|
|
1503
|
+
const KEY_LINE = /^("[^"]*"|'[^']*'|[^:"'][^:]*|[^:"']):(?:\s+(.*))?$/;
|
|
1504
|
+
/** A mapping runs while lines sit at exactly `indent` and look like keys. */
|
|
1505
|
+
function readMapping(r, indent) {
|
|
1506
|
+
const out = {};
|
|
1507
|
+
while (r.at < r.lines.length) {
|
|
1508
|
+
const line = r.lines[r.at];
|
|
1509
|
+
if (line.indent !== indent || line.text.startsWith('- ') || line.text === '-')
|
|
1510
|
+
break;
|
|
1511
|
+
const m = KEY_LINE.exec(line.text);
|
|
1512
|
+
if (!m)
|
|
1513
|
+
break;
|
|
1514
|
+
const key = unquoteYaml(m[1]);
|
|
1515
|
+
const inline = (m[2] ?? '').trim();
|
|
1516
|
+
r.at++;
|
|
1517
|
+
out[key] = inline !== '' ? scalarValue(r.ctx, key, inline) : readChild(r, indent, key);
|
|
1518
|
+
}
|
|
1519
|
+
return out;
|
|
1520
|
+
}
|
|
1521
|
+
/** A sequence runs while lines sit at exactly `indent` and open with `-`. */
|
|
1522
|
+
function readSequence(r, indent) {
|
|
1523
|
+
const out = [];
|
|
1524
|
+
while (r.at < r.lines.length) {
|
|
1525
|
+
const line = r.lines[r.at];
|
|
1526
|
+
if (line.indent !== indent || !(line.text === '-' || line.text.startsWith('- ')))
|
|
1527
|
+
break;
|
|
1528
|
+
const rest = line.text.slice(1).trim();
|
|
1529
|
+
if (rest === '') {
|
|
1530
|
+
r.at++;
|
|
1531
|
+
out.push(readChild(r, indent, ''));
|
|
1532
|
+
continue;
|
|
1533
|
+
}
|
|
1534
|
+
const m = KEY_LINE.exec(rest);
|
|
1535
|
+
if (m) {
|
|
1536
|
+
// `- name: Ada` opens a map whose later keys align under `name`, so
|
|
1537
|
+
// the item's own indent is where that key starts, not the dash.
|
|
1538
|
+
const keyIndent = indent + (line.text.length - rest.length);
|
|
1539
|
+
r.lines[r.at] = { indent: keyIndent, text: rest };
|
|
1540
|
+
out.push(P.MetaMap(readMapping(r, keyIndent)));
|
|
1541
|
+
continue;
|
|
1542
|
+
}
|
|
1543
|
+
r.at++;
|
|
1544
|
+
out.push(scalarValue(r.ctx, '', rest));
|
|
1545
|
+
}
|
|
1546
|
+
return out;
|
|
1547
|
+
}
|
|
1548
|
+
/**
|
|
1549
|
+
* The value written under a key that had none on its own line: whatever sits at
|
|
1550
|
+
* the next deeper indent. Nothing deeper means an empty value, which is YAML's
|
|
1551
|
+
* own reading of `key:` alone.
|
|
1552
|
+
*/
|
|
1553
|
+
function readChild(r, indent, key) {
|
|
1554
|
+
const next = r.lines[r.at];
|
|
1555
|
+
if (!next || next.indent <= indent)
|
|
1556
|
+
return P.MetaInlines([]);
|
|
1557
|
+
if (next.text === '-' || next.text.startsWith('- ')) {
|
|
1558
|
+
return P.MetaList(readSequence(r, next.indent));
|
|
1559
|
+
}
|
|
1560
|
+
const map = readMapping(r, next.indent);
|
|
1561
|
+
if (Object.keys(map).length)
|
|
1562
|
+
return P.MetaMap(map);
|
|
1563
|
+
// Nothing consumed at that indent: the reader would not advance, so report
|
|
1564
|
+
// it here rather than leaving the caller to spin.
|
|
1565
|
+
warn(r.ctx, `frontmatter: value under "${key}" not understood, skipped: ${next.text}`);
|
|
1566
|
+
r.at++;
|
|
1567
|
+
return P.MetaInlines([]);
|
|
1568
|
+
}
|
|
1569
|
+
function unquoteYaml(s) {
|
|
1570
|
+
const t = s.trim();
|
|
1571
|
+
if ((t.startsWith('"') && t.endsWith('"') && t.length > 1)
|
|
1572
|
+
|| (t.startsWith("'") && t.endsWith("'") && t.length > 1)) {
|
|
1573
|
+
return t.slice(1, -1);
|
|
1574
|
+
}
|
|
1575
|
+
return t;
|
|
1576
|
+
}
|
|
1577
|
+
/**
|
|
1578
|
+
* The boolean and null spellings YAML resolves as such, matching what pandoc's
|
|
1579
|
+
* own frontmatter reader does - it reads the 1.1 set, so `on`, `yes` and a bare
|
|
1580
|
+
* `y` are booleans there too, not strings.
|
|
1581
|
+
*
|
|
1582
|
+
* Matching that set exactly is the point. A `draft: true` arriving as
|
|
1583
|
+
* `MetaInlines "true"` is not a near miss: every pandoc template and filter
|
|
1584
|
+
* tests metadata for truthiness, and a non-empty string is true, so
|
|
1585
|
+
* `draft: false` written in Carve turns the draft flag ON once it crosses the
|
|
1586
|
+
* bridge. The value only has to survive a `MetaBool` on the way out (the writer
|
|
1587
|
+
* has always emitted `true`/`false`) for the round-trip to keep the type.
|
|
1588
|
+
*
|
|
1589
|
+
* A QUOTED scalar is never a boolean, which is why the test runs on the raw
|
|
1590
|
+
* text before {@link unquoteYaml} sees it.
|
|
1591
|
+
*/
|
|
1592
|
+
const YAML_TRUE = /^(y|Y|yes|Yes|YES|true|True|TRUE|on|On|ON)$/;
|
|
1593
|
+
const YAML_FALSE = /^(n|N|no|No|NO|false|False|FALSE|off|Off|OFF)$/;
|
|
1594
|
+
const YAML_NULL = /^(~|null|Null|NULL)$/;
|
|
1595
|
+
function scalarValue(ctx, key, raw) {
|
|
1596
|
+
const blockScalar = ctx.blockScalars.get(raw);
|
|
1597
|
+
if (blockScalar !== undefined)
|
|
1598
|
+
return blockScalarValue(ctx, key, blockScalar);
|
|
1599
|
+
// `{}` is the only flow map read: an EMPTY one, which is what the writer
|
|
1600
|
+
// emits for a `MetaMap` with no entries. A populated flow map is not in the
|
|
1601
|
+
// subset and falls through to being read as a string, same as before.
|
|
1602
|
+
if (raw.replace(/\s+/g, '') === '{}')
|
|
1603
|
+
return P.MetaMap({});
|
|
1604
|
+
if (raw.startsWith('[') && raw.endsWith(']')) {
|
|
1605
|
+
const items = raw
|
|
1606
|
+
.slice(1, -1)
|
|
1607
|
+
.split(',')
|
|
1608
|
+
.map((s) => s.trim())
|
|
1609
|
+
.filter((s) => s !== '' && unquoteYaml(s) !== '');
|
|
1610
|
+
// Items are typed by the same rules as a scalar on its own line, so a
|
|
1611
|
+
// `[true, draft]` list keeps the boolean and the word apart.
|
|
1612
|
+
return P.MetaList(items.map((s) => scalarValue(ctx, '', s)));
|
|
1613
|
+
}
|
|
1614
|
+
if (YAML_TRUE.test(raw))
|
|
1615
|
+
return P.MetaBool(true);
|
|
1616
|
+
if (YAML_FALSE.test(raw))
|
|
1617
|
+
return P.MetaBool(false);
|
|
1618
|
+
// Pandoc reads a null scalar as the empty string rather than dropping the
|
|
1619
|
+
// key, so the key stays present and empty here too.
|
|
1620
|
+
if (YAML_NULL.test(raw))
|
|
1621
|
+
return P.MetaString('');
|
|
1622
|
+
const value = unquoteYaml(raw);
|
|
1623
|
+
// Lists where pandoc conventionally expects lists, inlines elsewhere.
|
|
1624
|
+
if (key === 'author' || key === 'authors') {
|
|
1625
|
+
return P.MetaList([P.MetaInlines(textInlines(value))]);
|
|
1626
|
+
}
|
|
1627
|
+
return P.MetaInlines(textInlines(value));
|
|
1628
|
+
}
|
|
1629
|
+
// --- Entry point ---
|
|
1630
|
+
export function convert(ast, options = {}) {
|
|
1631
|
+
// PART 12 section 7: frontmatter and footnote DEFINITIONS are block nodes
|
|
1632
|
+
// in `children`, not fields on the root. They are lifted out here rather
|
|
1633
|
+
// than converted in place - frontmatter becomes pandoc `meta`, and a
|
|
1634
|
+
// definition is emitted at each reference, inside the Note.
|
|
1635
|
+
const children = ast.children ?? [];
|
|
1636
|
+
const footnoteDefs = {};
|
|
1637
|
+
let frontmatter;
|
|
1638
|
+
const body = [];
|
|
1639
|
+
for (const child of children) {
|
|
1640
|
+
if (child?.type === 'frontmatter' && frontmatter === undefined) {
|
|
1641
|
+
frontmatter = child;
|
|
1642
|
+
}
|
|
1643
|
+
else if (child?.type === 'footnote' && typeof child.label === 'string') {
|
|
1644
|
+
// First definition wins, matching the engine's own resolution.
|
|
1645
|
+
footnoteDefs[child.label] ??= child.children ?? [];
|
|
1646
|
+
}
|
|
1647
|
+
else {
|
|
1648
|
+
body.push(child);
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
const ctx = {
|
|
1652
|
+
warnings: [],
|
|
1653
|
+
footnoteDefs,
|
|
1654
|
+
crossrefTargets: new Map(),
|
|
1655
|
+
tight: false,
|
|
1656
|
+
inCrossref: false,
|
|
1657
|
+
captionCounts: new Map(),
|
|
1658
|
+
captionKind: undefined,
|
|
1659
|
+
inPanel: false,
|
|
1660
|
+
roundtrip: options.roundtrip ?? false,
|
|
1661
|
+
symbols: options.symbols ?? {},
|
|
1662
|
+
listTable: options.listTable ?? true,
|
|
1663
|
+
noteCount: 0,
|
|
1664
|
+
parseBlocks: options.parseBlocks,
|
|
1665
|
+
blockScalars: new Map(),
|
|
1666
|
+
};
|
|
1667
|
+
// Pass 1: collect crossref targets - heading ids (explicit, plus computed
|
|
1668
|
+
// slugs) and numbered figure/table caption ids - before any block is
|
|
1669
|
+
// converted, so `heading_ref` resolves regardless of where a crossref
|
|
1670
|
+
// sits relative to its target.
|
|
1671
|
+
collectCrossrefTargets(ctx, body, new Map());
|
|
1672
|
+
const meta = parseMeta(ctx, frontmatter);
|
|
1673
|
+
return {
|
|
1674
|
+
doc: {
|
|
1675
|
+
'pandoc-api-version': [...P.PANDOC_API_VERSION],
|
|
1676
|
+
meta,
|
|
1677
|
+
blocks: blocks(ctx, body),
|
|
1678
|
+
},
|
|
1679
|
+
warnings: ctx.warnings,
|
|
1680
|
+
};
|
|
1681
|
+
}
|
|
1682
|
+
/**
|
|
1683
|
+
* Pass 1: walk the whole document before any block is converted, registering
|
|
1684
|
+
* every target a `heading_ref` (`</#id>`) can resolve to.
|
|
1685
|
+
*
|
|
1686
|
+
* Two kinds of target:
|
|
1687
|
+
* - a heading's own id (explicit, or slugged from its text) -> its inline
|
|
1688
|
+
* children, rendered verbatim as the crossref's link text.
|
|
1689
|
+
* - a numbered figure/table caption's id (the `{#id}` attached to the
|
|
1690
|
+
* figure or table itself) -> the computed "Label N" text, e.g.
|
|
1691
|
+
* "Figure 1" - docs-extensions.md: "`</#id>` to the element resolves to
|
|
1692
|
+
* label + number".
|
|
1693
|
+
*
|
|
1694
|
+
* The caption number is assigned per LABEL in document order (`captionLabel`
|
|
1695
|
+
* plus a running count) - exactly like `ctx.captionCounts` in pass 2 (see the
|
|
1696
|
+
* `caption_number` case in `inline()`). Pass 1 runs before pass 2 and cannot
|
|
1697
|
+
* read its counts, so it keeps its OWN counter here (`captionCounts`,
|
|
1698
|
+
* threaded through the recursion), walking captioned nodes in the same order
|
|
1699
|
+
* pass 2 will. The two counters land on identical numbers only because both
|
|
1700
|
+
* walk the same document the same way - a captioned element with no `{#id}`
|
|
1701
|
+
* still has to bump the counter (it consumes a number in pass 2 even though
|
|
1702
|
+
* nothing can ever crossref it), or a LATER captioned element's number here
|
|
1703
|
+
* would drift out of sync with what pass 2 actually renders.
|
|
1704
|
+
*
|
|
1705
|
+
* A `figure`/`table` node's own `caption` field is read directly - the same
|
|
1706
|
+
* field `figure()`/`table()` read in pass 2 - which also handles a table
|
|
1707
|
+
* nested under a figure with no caption of its own: the recursion below
|
|
1708
|
+
* reaches that inner `table` node too, and its own `caption`/`attrs.id` are
|
|
1709
|
+
* picked up there instead, mirroring `table()`'s fallback. Table cells are
|
|
1710
|
+
* not recursed into, matching the pre-existing limitation for headings
|
|
1711
|
+
* nested inside a table.
|
|
1712
|
+
*
|
|
1713
|
+
* `inPanel` is the third kind of target and the §4c suppression in one flag:
|
|
1714
|
+
* inside a composite figure's panel nothing draws a number, so nothing there
|
|
1715
|
+
* registers either - see `figureGroupTargets`.
|
|
1716
|
+
*/
|
|
1717
|
+
function collectCrossrefTargets(ctx, nodes, captionCounts, inPanel = false) {
|
|
1718
|
+
for (const n of nodes) {
|
|
1719
|
+
if (n.type === 'figure_group') {
|
|
1720
|
+
figureGroupTargets(ctx, n, captionCounts, inPanel);
|
|
1721
|
+
continue;
|
|
1722
|
+
}
|
|
1723
|
+
if (n.type === 'heading') {
|
|
1724
|
+
const children = n.children ?? [];
|
|
1725
|
+
const a = (n.attrs ?? {});
|
|
1726
|
+
const id = a.id ?? slugify(plainText(children));
|
|
1727
|
+
if (id && !ctx.crossrefTargets.has(id))
|
|
1728
|
+
ctx.crossrefTargets.set(id, children);
|
|
1729
|
+
}
|
|
1730
|
+
else if (!inPanel && (n.type === 'figure' || n.type === 'table')) {
|
|
1731
|
+
// Every figure counts, whatever it wraps. A quote used to be
|
|
1732
|
+
// excluded here, because §4a made a captioned quote an attribution
|
|
1733
|
+
// that drew no number; that clause is withdrawn (carve#1213), and
|
|
1734
|
+
// a quote figure numbers like the code listing beside it.
|
|
1735
|
+
const caption = n.caption;
|
|
1736
|
+
if (Array.isArray(caption) && caption.some((x) => x?.type === 'caption_number')) {
|
|
1737
|
+
const label = captionLabel(caption) ?? 'caption';
|
|
1738
|
+
const next = (captionCounts.get(label) ?? 0) + 1;
|
|
1739
|
+
captionCounts.set(label, next);
|
|
1740
|
+
const a = (n.attrs ?? {});
|
|
1741
|
+
if (a.id && !ctx.crossrefTargets.has(a.id)) {
|
|
1742
|
+
ctx.crossrefTargets.set(a.id, [{ type: 'text', value: `${label} ${next}` }]);
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
for (const key of ['children', 'items', 'target']) {
|
|
1747
|
+
const v = n[key];
|
|
1748
|
+
if (Array.isArray(v))
|
|
1749
|
+
collectCrossrefTargets(ctx, v, captionCounts, inPanel);
|
|
1750
|
+
else if (v && typeof v === 'object') {
|
|
1751
|
+
collectCrossrefTargets(ctx, [v], captionCounts, inPanel);
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
}
|
|
1755
|
+
}
|
|
1756
|
+
/**
|
|
1757
|
+
* The `</#id>` targets a composite figure contributes (PART 9 §4c).
|
|
1758
|
+
*
|
|
1759
|
+
* The GROUP draws one number from its label's sequence, at its OPENING fence -
|
|
1760
|
+
* before anything inside it, which is what makes corpus
|
|
1761
|
+
* 318-composite-figures-11 number the group "Figure 1" and a figure nested
|
|
1762
|
+
* inside its stray content "Figure 2".
|
|
1763
|
+
*
|
|
1764
|
+
* A PANEL - a direct `figure` or `table` child - draws nothing, and its id
|
|
1765
|
+
* resolves as the group's number plus a letter by panel order: "Figure 2a"
|
|
1766
|
+
* (corpus 318-composite-figures-2), a..z then aa, ab, ... A group whose
|
|
1767
|
+
* caption carries no `#` has no number to lend, so it registers nothing for
|
|
1768
|
+
* its panels either.
|
|
1769
|
+
*
|
|
1770
|
+
* The suppression covers everything a panel CONTAINS, not just the panel's own
|
|
1771
|
+
* caption. Stray non-panel content numbers normally, exactly as it would
|
|
1772
|
+
* outside the group.
|
|
1773
|
+
*/
|
|
1774
|
+
function figureGroupTargets(ctx, n, captionCounts, inPanel) {
|
|
1775
|
+
const caption = n.caption;
|
|
1776
|
+
let resolved;
|
|
1777
|
+
if (!inPanel &&
|
|
1778
|
+
Array.isArray(caption) &&
|
|
1779
|
+
caption.some((x) => x?.type === 'caption_number')) {
|
|
1780
|
+
const label = captionLabel(caption) ?? 'caption';
|
|
1781
|
+
const next = (captionCounts.get(label) ?? 0) + 1;
|
|
1782
|
+
captionCounts.set(label, next);
|
|
1783
|
+
resolved = `${label} ${next}`;
|
|
1784
|
+
const a = (n.attrs ?? {});
|
|
1785
|
+
if (a.id && !ctx.crossrefTargets.has(a.id)) {
|
|
1786
|
+
ctx.crossrefTargets.set(a.id, [{ type: 'text', value: resolved }]);
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
let panelIndex = 0;
|
|
1790
|
+
for (const child of n.children ?? []) {
|
|
1791
|
+
if (!isPanel(child)) {
|
|
1792
|
+
collectCrossrefTargets(ctx, [child], captionCounts, inPanel);
|
|
1793
|
+
continue;
|
|
1794
|
+
}
|
|
1795
|
+
const a = (child.attrs ?? {});
|
|
1796
|
+
const letter = panelLetter(panelIndex++);
|
|
1797
|
+
if (resolved && a.id && !ctx.crossrefTargets.has(a.id)) {
|
|
1798
|
+
ctx.crossrefTargets.set(a.id, [{ type: 'text', value: `${resolved}${letter}` }]);
|
|
1799
|
+
}
|
|
1800
|
+
collectCrossrefTargets(ctx, [child], captionCounts, true);
|
|
1801
|
+
}
|
|
1802
|
+
}
|
|
1803
|
+
/**
|
|
1804
|
+
* The §4c panel letter for panel index `k` (0-based): `a`..`z`, then `aa`,
|
|
1805
|
+
* `ab`, ... - bijective base 26, the same function the engine resolves with.
|
|
1806
|
+
*/
|
|
1807
|
+
function panelLetter(k) {
|
|
1808
|
+
let out = '';
|
|
1809
|
+
let i = k + 1;
|
|
1810
|
+
while (i > 0) {
|
|
1811
|
+
i--;
|
|
1812
|
+
out = String.fromCharCode(97 + (i % 26)) + out;
|
|
1813
|
+
i = Math.floor(i / 26);
|
|
1814
|
+
}
|
|
1815
|
+
return out;
|
|
1816
|
+
}
|