@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/reverse.js
ADDED
|
@@ -0,0 +1,1457 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pandoc JSON AST -> Carve AST.
|
|
3
|
+
*
|
|
4
|
+
* The inverse of convert.ts: walks a Pandoc document (api 1.23) and builds
|
|
5
|
+
* the AST shape `@markup-carve/carve`'s `parse()` produces, so `renderCarve()`
|
|
6
|
+
* (the `carve fmt` serializer) can turn it into Carve source. Spans that
|
|
7
|
+
* convert.ts emits as classed degradations (mention, tag, symbol, ext-*, ...)
|
|
8
|
+
* are recognized and restored to their native Carve nodes, which makes
|
|
9
|
+
* carve -> pandoc -> carve round-trips clean.
|
|
10
|
+
*/
|
|
11
|
+
import { parse as parseCarve, renderCarve } from '@markup-carve/carve';
|
|
12
|
+
/**
|
|
13
|
+
* The node type THIS engine uses for an editorial comment.
|
|
14
|
+
*
|
|
15
|
+
* carve-js renamed `critic-comment` to `critic_comment`, and the nodes this
|
|
16
|
+
* module builds go straight to that engine's own writer - which accepts only
|
|
17
|
+
* its current spelling and throws on the other. A constant cannot satisfy both,
|
|
18
|
+
* and both are in the wild: the published release wants the hyphen, main wants
|
|
19
|
+
* the underscore.
|
|
20
|
+
*
|
|
21
|
+
* So it is PROBED rather than assumed, the same way carve-lsp probes the column
|
|
22
|
+
* unit. Parse one editorial comment and read back what the engine called it.
|
|
23
|
+
*/
|
|
24
|
+
let criticCommentType;
|
|
25
|
+
function criticCommentNodeType() {
|
|
26
|
+
if (criticCommentType)
|
|
27
|
+
return criticCommentType;
|
|
28
|
+
criticCommentType = 'critic_comment';
|
|
29
|
+
try {
|
|
30
|
+
const probe = parseCarve('{# c #}\n');
|
|
31
|
+
const para = (probe.children ?? [])[0];
|
|
32
|
+
const found = (para?.children ?? []).find((n) => String(n?.type ?? '').includes('critic'));
|
|
33
|
+
if (found?.type)
|
|
34
|
+
criticCommentType = found.type;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
// Keep the current spelling: an engine too old to parse the probe is
|
|
38
|
+
// older than either name this cares about.
|
|
39
|
+
}
|
|
40
|
+
return criticCommentType;
|
|
41
|
+
}
|
|
42
|
+
function warn(ctx, msg) {
|
|
43
|
+
ctx.warnings.push(msg);
|
|
44
|
+
}
|
|
45
|
+
function fromAttr(a) {
|
|
46
|
+
if (!a)
|
|
47
|
+
return undefined;
|
|
48
|
+
const [id, classes, kvs] = a;
|
|
49
|
+
const attrs = {};
|
|
50
|
+
const order = [];
|
|
51
|
+
if (id) {
|
|
52
|
+
attrs.id = id;
|
|
53
|
+
order.push('#id');
|
|
54
|
+
}
|
|
55
|
+
if (classes.length) {
|
|
56
|
+
attrs.classes = classes;
|
|
57
|
+
order.push('.class');
|
|
58
|
+
}
|
|
59
|
+
if (kvs.length) {
|
|
60
|
+
attrs.keyValues = Object.fromEntries(kvs);
|
|
61
|
+
order.push('key');
|
|
62
|
+
}
|
|
63
|
+
if (!order.length)
|
|
64
|
+
return undefined;
|
|
65
|
+
attrs.order = order;
|
|
66
|
+
return attrs;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Attrs for two Pandoc nodes that collapse into ONE Carve node: `outer` wins
|
|
70
|
+
* per field (it was the referenceable one), classes union, key/values merge
|
|
71
|
+
* with the outer taking precedence. `order` is recomputed from what survived.
|
|
72
|
+
*/
|
|
73
|
+
function mergeCAttrs(inner, outer) {
|
|
74
|
+
const id = outer?.id ?? inner?.id;
|
|
75
|
+
const classes = [...(inner?.classes ?? []), ...(outer?.classes ?? [])].filter((c, i, all) => all.indexOf(c) === i);
|
|
76
|
+
const keyValues = { ...(inner?.keyValues ?? {}), ...(outer?.keyValues ?? {}) };
|
|
77
|
+
const out = {};
|
|
78
|
+
const order = [];
|
|
79
|
+
if (id) {
|
|
80
|
+
out.id = id;
|
|
81
|
+
order.push('#id');
|
|
82
|
+
}
|
|
83
|
+
if (classes.length) {
|
|
84
|
+
out.classes = classes;
|
|
85
|
+
order.push('.class');
|
|
86
|
+
}
|
|
87
|
+
if (Object.keys(keyValues).length) {
|
|
88
|
+
out.keyValues = keyValues;
|
|
89
|
+
order.push('key');
|
|
90
|
+
}
|
|
91
|
+
if (!order.length)
|
|
92
|
+
return undefined;
|
|
93
|
+
out.order = order;
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
const text = (value) => ({ type: 'text', value });
|
|
97
|
+
/** Glyph and authored source of each quote mark, keyed by its resolved kind. */
|
|
98
|
+
const QUOTE_MARKS = {
|
|
99
|
+
left_double_quote: { value: '"', glyph: '“' },
|
|
100
|
+
right_double_quote: { value: '"', glyph: '”' },
|
|
101
|
+
left_single_quote: { value: "'", glyph: '‘' },
|
|
102
|
+
right_single_quote: { value: "'", glyph: '’' },
|
|
103
|
+
};
|
|
104
|
+
/**
|
|
105
|
+
* One resolved quote mark.
|
|
106
|
+
*
|
|
107
|
+
* `value` is what an author typed and what `renderCarve` writes, so the source
|
|
108
|
+
* carries a plain `"`; `glyph` is what the parser resolved it to, so a consumer
|
|
109
|
+
* reading the AST alone still gets the curly character. Both fields are what
|
|
110
|
+
* the engine's own parse of `"` produces, which is what makes the emitted
|
|
111
|
+
* source re-parse to an identical node.
|
|
112
|
+
*/
|
|
113
|
+
function quoteMark(kind) {
|
|
114
|
+
const mark = QUOTE_MARKS[kind];
|
|
115
|
+
return { type: 'smart_punctuation', kind, value: mark.value, glyph: mark.glyph };
|
|
116
|
+
}
|
|
117
|
+
/** Merge adjacent text nodes so renderCarve sees natural runs. */
|
|
118
|
+
function mergeText(nodes) {
|
|
119
|
+
const out = [];
|
|
120
|
+
for (const n of nodes) {
|
|
121
|
+
const prev = out[out.length - 1];
|
|
122
|
+
if (n.type === 'text' && prev?.type === 'text') {
|
|
123
|
+
prev.value = String(prev.value) + String(n.value);
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
out.push(n);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
// --- Inlines ---
|
|
132
|
+
function inlines(ctx, xs) {
|
|
133
|
+
if (!xs)
|
|
134
|
+
return [];
|
|
135
|
+
const out = [];
|
|
136
|
+
for (const x of xs)
|
|
137
|
+
out.push(...inline(ctx, x));
|
|
138
|
+
return mergeText(out);
|
|
139
|
+
}
|
|
140
|
+
function wrapped(ctx, type, xs) {
|
|
141
|
+
return [{ type, children: inlines(ctx, xs) }];
|
|
142
|
+
}
|
|
143
|
+
function inline(ctx, n) {
|
|
144
|
+
const c = n.c;
|
|
145
|
+
switch (n.t) {
|
|
146
|
+
case 'Str':
|
|
147
|
+
return [text(String(c))];
|
|
148
|
+
case 'Space':
|
|
149
|
+
return [text(' ')];
|
|
150
|
+
case 'SoftBreak':
|
|
151
|
+
return [{ type: 'soft_break' }];
|
|
152
|
+
case 'LineBreak':
|
|
153
|
+
return [{ type: 'hard_break' }];
|
|
154
|
+
case 'Emph':
|
|
155
|
+
return wrapped(ctx, 'emphasis', c);
|
|
156
|
+
case 'Strong':
|
|
157
|
+
// Strong[Emph[..]] needs no special case: it reverses to a strong
|
|
158
|
+
// wrapping an emphasis, which is exactly how carve represents
|
|
159
|
+
// bold-italic now that it has no node type of its own.
|
|
160
|
+
return wrapped(ctx, 'strong', c);
|
|
161
|
+
case 'Underline':
|
|
162
|
+
return wrapped(ctx, 'underline', c);
|
|
163
|
+
case 'Strikeout':
|
|
164
|
+
return wrapped(ctx, 'strike', c);
|
|
165
|
+
case 'Superscript':
|
|
166
|
+
return wrapped(ctx, 'superscript', c);
|
|
167
|
+
case 'Subscript':
|
|
168
|
+
return wrapped(ctx, 'subscript', c);
|
|
169
|
+
// POLICY: Carve has no small-caps node and is not getting one - the
|
|
170
|
+
// typographic distinction is a presentation choice, which is what the
|
|
171
|
+
// `.smallcaps` span already carries. The degradation is not one-way:
|
|
172
|
+
// `convert.ts` reads that class back as a pandoc `SmallCaps` (pandoc's
|
|
173
|
+
// own markdown reader defines the same convention), so the construct
|
|
174
|
+
// survives Pandoc -> Carve -> Pandoc intact. The warning stays because
|
|
175
|
+
// a consumer reading the Carve document itself sees a class, not a
|
|
176
|
+
// semantic - it says what the Carve side holds, not that the value is
|
|
177
|
+
// lost.
|
|
178
|
+
case 'SmallCaps':
|
|
179
|
+
warn(ctx, 'SmallCaps has no Carve form - degraded to a .smallcaps span');
|
|
180
|
+
return [
|
|
181
|
+
{
|
|
182
|
+
type: 'span',
|
|
183
|
+
children: inlines(ctx, c),
|
|
184
|
+
attrs: { classes: ['smallcaps'], order: ['.class'] },
|
|
185
|
+
},
|
|
186
|
+
];
|
|
187
|
+
case 'Quoted': {
|
|
188
|
+
// The two QUOTE MARKS, as the nodes Carve's own parser produces for
|
|
189
|
+
// them - not the literal glyphs this used to write.
|
|
190
|
+
//
|
|
191
|
+
// The old form was a genuine one-way loss, and the note explaining
|
|
192
|
+
// it was wrong about why: Carve does have a node here. `"` and `'`
|
|
193
|
+
// resolve to `smart_punctuation` carrying the KIND
|
|
194
|
+
// (`left_double_quote` ... ), which is what `pairQuotes` on the way
|
|
195
|
+
// back reads to rebuild pandoc's wrapping `Quoted`. Writing the
|
|
196
|
+
// glyph instead threw that kind away - a literal `“` in the source
|
|
197
|
+
// is ordinary text to the parser, so the quotation came back as
|
|
198
|
+
// `Str "“alpha”"`.
|
|
199
|
+
//
|
|
200
|
+
// It is also unambiguous, which is what makes it safe: the writer
|
|
201
|
+
// ESCAPES a quote character that is ordinary text (`it\'s`,
|
|
202
|
+
// `\"x\"`), so an unescaped mark in the emitted source is always
|
|
203
|
+
// one the bridge put there.
|
|
204
|
+
const [kind, xs] = c;
|
|
205
|
+
const single = kind.t === 'SingleQuote';
|
|
206
|
+
return [
|
|
207
|
+
quoteMark(single ? 'left_single_quote' : 'left_double_quote'),
|
|
208
|
+
...inlines(ctx, xs),
|
|
209
|
+
quoteMark(single ? 'right_single_quote' : 'right_double_quote'),
|
|
210
|
+
];
|
|
211
|
+
}
|
|
212
|
+
case 'Cite': {
|
|
213
|
+
const [citations, xs] = c;
|
|
214
|
+
return [citeGroup(ctx, citations ?? [], xs ?? [])];
|
|
215
|
+
}
|
|
216
|
+
case 'Code': {
|
|
217
|
+
const [a, s] = c;
|
|
218
|
+
const node = { type: 'code', value: s };
|
|
219
|
+
const attrs = fromAttr(a);
|
|
220
|
+
if (attrs)
|
|
221
|
+
node.attrs = attrs;
|
|
222
|
+
return [node];
|
|
223
|
+
}
|
|
224
|
+
case 'Math': {
|
|
225
|
+
const [style, s] = c;
|
|
226
|
+
return [{ type: 'math', display: style.t === 'DisplayMath', content: s }];
|
|
227
|
+
}
|
|
228
|
+
case 'RawInline': {
|
|
229
|
+
const [format, s] = c;
|
|
230
|
+
return [{ type: 'raw_inline', format, content: s }];
|
|
231
|
+
}
|
|
232
|
+
case 'Link':
|
|
233
|
+
return link(ctx, c);
|
|
234
|
+
case 'Image': {
|
|
235
|
+
const [a, alt, [src, title]] = c;
|
|
236
|
+
const node = { type: 'image', src, alt: stringify(alt) };
|
|
237
|
+
if (title)
|
|
238
|
+
node.title = title;
|
|
239
|
+
const attrs = fromAttr(a);
|
|
240
|
+
if (attrs)
|
|
241
|
+
node.attrs = attrs;
|
|
242
|
+
return [node];
|
|
243
|
+
}
|
|
244
|
+
case 'Note': {
|
|
245
|
+
const noteBlocks = c;
|
|
246
|
+
// The post-split names. carve-js renamed these to
|
|
247
|
+
// `inline_footnote` and `footnote_ref` (markup-carve/carve#405),
|
|
248
|
+
// and 0.1.3 is the release that carries the split, so the engine
|
|
249
|
+
// this produces for now knows them.
|
|
250
|
+
//
|
|
251
|
+
// A producer cannot satisfy both spellings the way a consumer can
|
|
252
|
+
// accept both, because the type string is a single value. That is
|
|
253
|
+
// why this waited for the lockfile to move rather than emitting
|
|
254
|
+
// the new names defensively.
|
|
255
|
+
if (noteBlocks.length === 1 && (noteBlocks[0].t === 'Para' || noteBlocks[0].t === 'Plain')) {
|
|
256
|
+
return [{ type: 'inline_footnote', inline: inlines(ctx, noteBlocks[0].c) }];
|
|
257
|
+
}
|
|
258
|
+
const id = `fn${++ctx.noteCounter}`;
|
|
259
|
+
ctx.footnoteDefs[id] = blocks(ctx, noteBlocks);
|
|
260
|
+
return [{ type: 'footnote_ref', id }];
|
|
261
|
+
}
|
|
262
|
+
case 'Span':
|
|
263
|
+
return span(ctx, c);
|
|
264
|
+
default:
|
|
265
|
+
warn(ctx, `inline: pandoc node "${n.t}" has no Carve mapping - dropped`);
|
|
266
|
+
return [];
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
function link(ctx, c) {
|
|
270
|
+
const [a, xs, [href, title]] = c;
|
|
271
|
+
const [, classes] = a;
|
|
272
|
+
if (classes.includes('uri') || classes.includes('email')) {
|
|
273
|
+
return [{ type: 'autolink', href, text: stringify(xs) }];
|
|
274
|
+
}
|
|
275
|
+
if (classes.includes('crossref')) {
|
|
276
|
+
return [{ type: 'heading_ref', target: href.replace(/^#/, '') }];
|
|
277
|
+
}
|
|
278
|
+
const node = { type: 'link', href, children: inlines(ctx, xs) };
|
|
279
|
+
if (title)
|
|
280
|
+
node.title = title;
|
|
281
|
+
const attrs = fromAttr(a);
|
|
282
|
+
if (attrs)
|
|
283
|
+
node.attrs = attrs;
|
|
284
|
+
return [node];
|
|
285
|
+
}
|
|
286
|
+
/** Restore convert.ts's classed-span degradations to native Carve nodes. */
|
|
287
|
+
function span(ctx, c) {
|
|
288
|
+
const [a, xs] = c;
|
|
289
|
+
const [, classes, kvs] = a;
|
|
290
|
+
const kv = Object.fromEntries(kvs);
|
|
291
|
+
const cls = classes[0];
|
|
292
|
+
switch (cls) {
|
|
293
|
+
case 'mark':
|
|
294
|
+
return wrapped(ctx, 'highlight', xs);
|
|
295
|
+
case 'mention':
|
|
296
|
+
if (kv['data-user'])
|
|
297
|
+
return [{ type: 'mention', user: kv['data-user'] }];
|
|
298
|
+
break;
|
|
299
|
+
case 'tag':
|
|
300
|
+
if (kv['data-tag'])
|
|
301
|
+
return [{ type: 'tag', name: kv['data-tag'] }];
|
|
302
|
+
break;
|
|
303
|
+
case 'symbol':
|
|
304
|
+
if (kv['data-symbol'])
|
|
305
|
+
return [{ type: 'symbol', name: kv['data-symbol'] }];
|
|
306
|
+
break;
|
|
307
|
+
case 'abbr':
|
|
308
|
+
if (kv['title']) {
|
|
309
|
+
const abbr = stringify(xs);
|
|
310
|
+
// renderCarve only re-emits `*[abbr]: expansion` definitions;
|
|
311
|
+
// the inline node alone would serialize as plain text.
|
|
312
|
+
ctx.abbrevDefs.set(abbr, kv['title']);
|
|
313
|
+
return [{ type: 'abbreviation', abbr, expansion: kv['title'] }];
|
|
314
|
+
}
|
|
315
|
+
break;
|
|
316
|
+
case 'insertion':
|
|
317
|
+
return wrapped(ctx, 'insert', xs);
|
|
318
|
+
case 'deletion':
|
|
319
|
+
return wrapped(ctx, 'delete', xs);
|
|
320
|
+
case 'substitution': {
|
|
321
|
+
const parts = xs.filter((x) => x.t === 'Span');
|
|
322
|
+
if (parts.length === 2) {
|
|
323
|
+
const [oldC, newC] = parts.map((p) => p.c[1]);
|
|
324
|
+
return [
|
|
325
|
+
{ type: 'substitution', oldText: stringify(oldC), newText: stringify(newC) },
|
|
326
|
+
];
|
|
327
|
+
}
|
|
328
|
+
break;
|
|
329
|
+
}
|
|
330
|
+
case 'comment-annotation':
|
|
331
|
+
return [{ type: criticCommentNodeType(), text: stringify(xs) }];
|
|
332
|
+
}
|
|
333
|
+
if (cls?.startsWith('ext-')) {
|
|
334
|
+
return [{ type: 'inline_extension', name: cls.slice(4), content: inlines(ctx, xs) }];
|
|
335
|
+
}
|
|
336
|
+
const node = { type: 'span', children: inlines(ctx, xs) };
|
|
337
|
+
const attrs = fromAttr(a);
|
|
338
|
+
if (attrs)
|
|
339
|
+
node.attrs = attrs;
|
|
340
|
+
return [node];
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* pandoc `Cite` -> PART 9 §22 `citation_group`.
|
|
344
|
+
*
|
|
345
|
+
* The inverse of `convert.ts`'s `citationGroup`. Two asymmetries:
|
|
346
|
+
*
|
|
347
|
+
* - **Mode.** Pandoc carries one mode per item; Carve's integral `+` is a
|
|
348
|
+
* cluster property. A group is integral when any item is `AuthorInText` -
|
|
349
|
+
* which is exactly the shape the forward direction emits, since an integral
|
|
350
|
+
* group's suppressed items become `SuppressAuthor` and never
|
|
351
|
+
* `NormalCitation`. A foreign group that mixes `AuthorInText` with
|
|
352
|
+
* `NormalCitation` cannot be spelled and is reported.
|
|
353
|
+
* - **Locator typing.** `locatorLabel`/`locatorValue` are NOT rebuilt here.
|
|
354
|
+
* They are derived fields: §4.2's label table lives in the engine, and a
|
|
355
|
+
* second copy in this bridge is the drift this tracker exists to kill. The
|
|
356
|
+
* locator TEXT round-trips, so parsing the emitted source with the citations
|
|
357
|
+
* extension re-derives the same pair.
|
|
358
|
+
*/
|
|
359
|
+
function citeGroup(ctx, citations, content) {
|
|
360
|
+
const modes = citations.map((cit) => cit.citationMode?.t ?? 'NormalCitation');
|
|
361
|
+
const keys = citations.map((cit) => String(cit.citationId ?? ''));
|
|
362
|
+
// `raw` is required by the schema AND is what `renderCarve` writes out
|
|
363
|
+
// verbatim, so it decides whether the source round-trips byte for byte.
|
|
364
|
+
// Pandoc's own markdown reader stores the source here too, which is why
|
|
365
|
+
// recovering it beats rebuilding it - but only while it still DESCRIBES the
|
|
366
|
+
// records, since a filter may have rewritten them and left the display text
|
|
367
|
+
// behind.
|
|
368
|
+
const recovered = carveShapedSource(content, keys);
|
|
369
|
+
// Pandoc's markdown reader has no integral marker: it reads Carve's `[+@k]`
|
|
370
|
+
// as `NormalCitation` with a `+` prefix. The recovered source is the only
|
|
371
|
+
// place that fact survives, and reading it back is what keeps `mode` from
|
|
372
|
+
// contradicting the `raw` sitting next to it.
|
|
373
|
+
const integral = modes.includes('AuthorInText') || (recovered?.startsWith('[+') ?? false);
|
|
374
|
+
if (integral && modes.includes('NormalCitation') && !recovered?.startsWith('[+')) {
|
|
375
|
+
warn(ctx, 'Cite mixes AuthorInText with NormalCitation - Carve\'s integral marker is a property of the whole group, so the group is emitted as integral');
|
|
376
|
+
}
|
|
377
|
+
if (!ctx.bibliographyWarned) {
|
|
378
|
+
ctx.bibliographyWarned = true;
|
|
379
|
+
warn(ctx, 'Cite mapped to a Carve citation group; bibliography entries live in pandoc metadata, so no `[@key]:` definitions are emitted and an undefined key renders verbatim');
|
|
380
|
+
}
|
|
381
|
+
const items = citations.map((cit) => {
|
|
382
|
+
// A citation item is a plain object, not a node: it has no `type`.
|
|
383
|
+
const item = {
|
|
384
|
+
key: String(cit.citationId ?? ''),
|
|
385
|
+
suppressAuthor: (cit.citationMode?.t ?? '') === 'SuppressAuthor',
|
|
386
|
+
};
|
|
387
|
+
const prefix = citationPrefixNodes(ctx, cit.citationPrefix);
|
|
388
|
+
if (prefix.length)
|
|
389
|
+
item.prefix = prefix;
|
|
390
|
+
const locator = locatorNodes(ctx, cit.citationSuffix);
|
|
391
|
+
if (locator.length)
|
|
392
|
+
item.locator = locator;
|
|
393
|
+
return item;
|
|
394
|
+
});
|
|
395
|
+
const group = { type: 'citation_group', items };
|
|
396
|
+
group.raw = recovered ?? synthesizeRaw(items, integral);
|
|
397
|
+
if (integral)
|
|
398
|
+
group.mode = 'integral';
|
|
399
|
+
return group;
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* The `+` pandoc's markdown reader leaves in the prefix when it reads Carve's
|
|
403
|
+
* own integral spelling is the group marker, not prose - it is read off the
|
|
404
|
+
* modes instead, so carrying it here would print `[+see +@k]`.
|
|
405
|
+
*/
|
|
406
|
+
function citationPrefixNodes(ctx, prefix) {
|
|
407
|
+
if (!prefix?.length)
|
|
408
|
+
return [];
|
|
409
|
+
const stripped = prefix[0]?.t === 'Str' && String(prefix[0].c) === '+' ? prefix.slice(1) : prefix;
|
|
410
|
+
const nodes = mergeText(inlines(ctx, stripped));
|
|
411
|
+
const first = nodes[0];
|
|
412
|
+
if (first?.type === 'text') {
|
|
413
|
+
const trimmed = String(first.value ?? '').replace(/^\s+/, '');
|
|
414
|
+
if (!trimmed)
|
|
415
|
+
return nodes.slice(1);
|
|
416
|
+
first.value = trimmed;
|
|
417
|
+
}
|
|
418
|
+
return nodes;
|
|
419
|
+
}
|
|
420
|
+
/** Drop citeproc's `, ` separator; what remains is Carve's locator run. */
|
|
421
|
+
function locatorNodes(ctx, suffix) {
|
|
422
|
+
if (!suffix?.length)
|
|
423
|
+
return [];
|
|
424
|
+
const nodes = mergeText(inlines(ctx, suffix));
|
|
425
|
+
const first = nodes[0];
|
|
426
|
+
if (first?.type === 'text') {
|
|
427
|
+
const trimmed = String(first.value ?? '').replace(/^\s*,?\s*/, '');
|
|
428
|
+
if (!trimmed)
|
|
429
|
+
return nodes.slice(1);
|
|
430
|
+
first.value = trimmed;
|
|
431
|
+
}
|
|
432
|
+
return nodes;
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* A Cite's content is Carve source when it is a tail-less bracket holding
|
|
436
|
+
* exactly this Cite's keys, in order - the shape §4.1 claims. Two things fail
|
|
437
|
+
* the test and both must: a rendered "(Smith 2020)" out of a docx is prose
|
|
438
|
+
* about the citation rather than the citation, and content a filter left stale
|
|
439
|
+
* after rewriting the records would otherwise be written back as source and
|
|
440
|
+
* silently restore the old keys on the next parse.
|
|
441
|
+
*/
|
|
442
|
+
function carveShapedSource(content, keys) {
|
|
443
|
+
const raw = stringify(content).trim();
|
|
444
|
+
if (!raw.startsWith('[') || !raw.endsWith(']'))
|
|
445
|
+
return null;
|
|
446
|
+
return sameKeys(citedKeys(raw), keys) ? raw : null;
|
|
447
|
+
}
|
|
448
|
+
/** The `@key` run of a Carve citation group; `\@` is literal and not a key. */
|
|
449
|
+
function citedKeys(raw) {
|
|
450
|
+
const out = [];
|
|
451
|
+
for (const m of raw.matchAll(/(^|[^\\])@([^\s;,\]]+)/g)) {
|
|
452
|
+
out.push(String(m[2]).replace(/[.,;:]+$/, ''));
|
|
453
|
+
}
|
|
454
|
+
return out;
|
|
455
|
+
}
|
|
456
|
+
function sameKeys(found, keys) {
|
|
457
|
+
return found.length === keys.length && found.every((k, i) => k === keys[i]);
|
|
458
|
+
}
|
|
459
|
+
function synthesizeRaw(items, integral) {
|
|
460
|
+
const body = items
|
|
461
|
+
.map((item) => {
|
|
462
|
+
const prefix = plainOf(item.prefix);
|
|
463
|
+
const locator = plainOf(item.locator);
|
|
464
|
+
return ((prefix ? `${prefix} ` : '')
|
|
465
|
+
+ (item.suppressAuthor ? '-' : '')
|
|
466
|
+
+ `@${item.key}`
|
|
467
|
+
+ (locator ? `, ${locator}` : ''));
|
|
468
|
+
})
|
|
469
|
+
.join('; ');
|
|
470
|
+
return `[${integral ? '+' : ''}${body}]`;
|
|
471
|
+
}
|
|
472
|
+
function plainOf(nodes) {
|
|
473
|
+
if (!nodes?.length)
|
|
474
|
+
return '';
|
|
475
|
+
return nodes.map((n) => (typeof n.value === 'string' ? n.value : plainOf(n.children))).join('');
|
|
476
|
+
}
|
|
477
|
+
function stringify(xs) {
|
|
478
|
+
let out = '';
|
|
479
|
+
for (const x of xs) {
|
|
480
|
+
if (x.t === 'Str')
|
|
481
|
+
out += String(x.c);
|
|
482
|
+
else if (x.t === 'Space' || x.t === 'SoftBreak')
|
|
483
|
+
out += ' ';
|
|
484
|
+
else if (Array.isArray(x.c))
|
|
485
|
+
out += stringify(x.c);
|
|
486
|
+
else if (Array.isArray(x.c?.[1]))
|
|
487
|
+
out += stringify(x.c[1]);
|
|
488
|
+
}
|
|
489
|
+
return out;
|
|
490
|
+
}
|
|
491
|
+
// --- Blocks ---
|
|
492
|
+
function blocks(ctx, xs) {
|
|
493
|
+
if (!xs)
|
|
494
|
+
return [];
|
|
495
|
+
const out = [];
|
|
496
|
+
for (const x of xs)
|
|
497
|
+
out.push(...block(ctx, x));
|
|
498
|
+
return out;
|
|
499
|
+
}
|
|
500
|
+
const KNOWN_ADMONITIONS = new Set(['note', 'tip', 'warning', 'danger', 'info', 'caution', 'important']);
|
|
501
|
+
function block(ctx, n) {
|
|
502
|
+
const c = n.c;
|
|
503
|
+
switch (n.t) {
|
|
504
|
+
case 'Para':
|
|
505
|
+
case 'Plain':
|
|
506
|
+
return [{ type: 'paragraph', children: inlines(ctx, c) }];
|
|
507
|
+
case 'LineBlock': {
|
|
508
|
+
// Carve HAS a form for this - the `::: |` line block (PART 9 SS23) -
|
|
509
|
+
// and the warning here used to say it did not, while flattening the
|
|
510
|
+
// verse into one paragraph of hard breaks. An EMPTY line is a stanza
|
|
511
|
+
// break, which is a blank line between the paragraphs of the block.
|
|
512
|
+
const lines = c;
|
|
513
|
+
const stanzas = [[]];
|
|
514
|
+
for (const line of lines) {
|
|
515
|
+
if (line.length === 0) {
|
|
516
|
+
stanzas.push([]);
|
|
517
|
+
continue;
|
|
518
|
+
}
|
|
519
|
+
const stanza = stanzas[stanzas.length - 1];
|
|
520
|
+
if (stanza.length > 0)
|
|
521
|
+
stanza.push({ type: 'hard_break' });
|
|
522
|
+
stanza.push(...inlines(ctx, line));
|
|
523
|
+
}
|
|
524
|
+
const children = stanzas
|
|
525
|
+
.filter((stanza) => stanza.length > 0)
|
|
526
|
+
.map((stanza) => ({ type: 'paragraph', children: mergeText(stanza) }));
|
|
527
|
+
// The `line_block` NODE of PART 9 §23, which is what the engine's
|
|
528
|
+
// own parser produces for `::: |`. This used to be the
|
|
529
|
+
// `{.line-block}` div instead: `0.1.2`'s writer threw
|
|
530
|
+
// "renderCarve: unknown block line_block", and the two spellings
|
|
531
|
+
// parse to the same rendering, so the div was the one both could
|
|
532
|
+
// carry. `0.1.3` writes the node, and the floor moved to it.
|
|
533
|
+
return [{ type: 'line_block', children }];
|
|
534
|
+
}
|
|
535
|
+
case 'Header': {
|
|
536
|
+
const [level, a, xs] = c;
|
|
537
|
+
const node = { type: 'heading', level, children: inlines(ctx, xs) };
|
|
538
|
+
const attrs = fromAttr(a);
|
|
539
|
+
if (attrs)
|
|
540
|
+
node.attrs = attrs;
|
|
541
|
+
return [node];
|
|
542
|
+
}
|
|
543
|
+
case 'BlockQuote':
|
|
544
|
+
return [{ type: 'block_quote', children: blocks(ctx, c) }];
|
|
545
|
+
case 'CodeBlock': {
|
|
546
|
+
const [a, content] = c;
|
|
547
|
+
const [id, classes, kvs] = a;
|
|
548
|
+
const node = { type: 'code_block', content };
|
|
549
|
+
if (classes[0])
|
|
550
|
+
node.lang = classes[0];
|
|
551
|
+
const kv = Object.fromEntries(kvs);
|
|
552
|
+
if (kv['title'])
|
|
553
|
+
node.header = kv['title'];
|
|
554
|
+
const rest = fromAttr([id, classes.slice(1), kvs.filter(([k]) => k !== 'title')]);
|
|
555
|
+
if (rest)
|
|
556
|
+
node.attrs = rest;
|
|
557
|
+
return [node];
|
|
558
|
+
}
|
|
559
|
+
case 'RawBlock': {
|
|
560
|
+
const [format, content] = c;
|
|
561
|
+
return [{ type: 'raw_block', format, content }];
|
|
562
|
+
}
|
|
563
|
+
case 'HorizontalRule':
|
|
564
|
+
return [{ type: 'thematic_break' }];
|
|
565
|
+
case 'BulletList':
|
|
566
|
+
return [bulletList(ctx, c)];
|
|
567
|
+
case 'OrderedList':
|
|
568
|
+
return [orderedList(ctx, c)];
|
|
569
|
+
case 'DefinitionList':
|
|
570
|
+
return [definitionList(ctx, c)];
|
|
571
|
+
case 'Table':
|
|
572
|
+
return [table(ctx, c, null)];
|
|
573
|
+
case 'Figure':
|
|
574
|
+
return figure(ctx, c);
|
|
575
|
+
case 'Div':
|
|
576
|
+
return div(ctx, c);
|
|
577
|
+
default:
|
|
578
|
+
warn(ctx, `block: pandoc node "${n.t}" has no Carve mapping - dropped`);
|
|
579
|
+
return [];
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
// --- Lists ---
|
|
583
|
+
function listItems(ctx, items) {
|
|
584
|
+
let tight = true;
|
|
585
|
+
const converted = items.map((item) => {
|
|
586
|
+
if (item.some((b) => b.t === 'Para'))
|
|
587
|
+
tight = false;
|
|
588
|
+
const children = blocks(ctx, item);
|
|
589
|
+
const node = { type: 'list_item', children };
|
|
590
|
+
stripTaskMarker(node);
|
|
591
|
+
return node;
|
|
592
|
+
});
|
|
593
|
+
return { items: converted, tight };
|
|
594
|
+
}
|
|
595
|
+
/** Detect the ballot-box prefix convert.ts (and pandoc's gfm reader) emit. */
|
|
596
|
+
function stripTaskMarker(item) {
|
|
597
|
+
const first = item.children[0];
|
|
598
|
+
if (first?.type !== 'paragraph')
|
|
599
|
+
return;
|
|
600
|
+
const children = first.children;
|
|
601
|
+
const lead = children[0];
|
|
602
|
+
if (lead?.type !== 'text')
|
|
603
|
+
return;
|
|
604
|
+
const value = String(lead.value);
|
|
605
|
+
const marker = value.startsWith('☒') ? true : value.startsWith('☐') ? false : null;
|
|
606
|
+
if (marker === null)
|
|
607
|
+
return;
|
|
608
|
+
item.checked = marker;
|
|
609
|
+
const rest = value.slice(1).replace(/^ /, '');
|
|
610
|
+
if (rest)
|
|
611
|
+
lead.value = rest;
|
|
612
|
+
else
|
|
613
|
+
children.shift();
|
|
614
|
+
}
|
|
615
|
+
function bulletList(ctx, c) {
|
|
616
|
+
const { items, tight } = listItems(ctx, c);
|
|
617
|
+
return { type: 'list', ordered: false, tight, items };
|
|
618
|
+
}
|
|
619
|
+
const OL_STYLE = {
|
|
620
|
+
LowerAlpha: 'a',
|
|
621
|
+
UpperAlpha: 'A',
|
|
622
|
+
LowerRoman: 'i',
|
|
623
|
+
UpperRoman: 'I',
|
|
624
|
+
};
|
|
625
|
+
function orderedList(ctx, c) {
|
|
626
|
+
const [[start, style, delim], rawItems] = c;
|
|
627
|
+
const { items, tight } = listItems(ctx, rawItems);
|
|
628
|
+
const node = { type: 'list', ordered: true, tight, items };
|
|
629
|
+
if (start !== 1)
|
|
630
|
+
node.start = start;
|
|
631
|
+
const olType = OL_STYLE[style.t];
|
|
632
|
+
if (olType)
|
|
633
|
+
node.olType = olType;
|
|
634
|
+
// Source-style metadata for AST consumers; renderCarve currently
|
|
635
|
+
// normalizes to `1.` regardless (fmt canonical form).
|
|
636
|
+
if (delim?.t === 'OneParen' || delim?.t === 'TwoParens')
|
|
637
|
+
node.delim = ')';
|
|
638
|
+
else if (delim?.t === 'Period')
|
|
639
|
+
node.delim = '.';
|
|
640
|
+
// Pandoc's `Example` list is `(@)` numbering: ONE counter shared by every
|
|
641
|
+
// example list in the document, and items can be labelled and referenced
|
|
642
|
+
// from prose. PART 12 section 6 admits `a`/`A`/`i`/`I` and decimal, so the
|
|
643
|
+
// list becomes an ordinary decimal one.
|
|
644
|
+
//
|
|
645
|
+
// What survives is the numbers: pandoc has already resolved the counter
|
|
646
|
+
// into `start`, so a second example list arrives with `start: 3` and still
|
|
647
|
+
// prints 3. What does not is the counter itself - editing the first list
|
|
648
|
+
// no longer renumbers the second - and the marker, which becomes `1)`.
|
|
649
|
+
// Pandoc always pairs `Example` with `TwoParens`, so this is the one and
|
|
650
|
+
// only diagnostic for that shape; a `(1)` marker on any other style gets
|
|
651
|
+
// its own below.
|
|
652
|
+
if (style.t === 'Example') {
|
|
653
|
+
warn(ctx, 'ordered list: pandoc\'s example-list numbering `(@)` has no Carve form '
|
|
654
|
+
+ '- emitted as a decimal list. The resolved numbers are kept, the '
|
|
655
|
+
+ 'document-wide counter is not, so the lists no longer renumber '
|
|
656
|
+
+ 'each other');
|
|
657
|
+
}
|
|
658
|
+
else if (delim?.t === 'TwoParens') {
|
|
659
|
+
// `(1)` has no Carve spelling either - section 6 has `.` and `)` - so
|
|
660
|
+
// the closing paren is kept and the opening one goes. Reported because
|
|
661
|
+
// the marker the author chose is not the marker that comes out.
|
|
662
|
+
warn(ctx, 'ordered list: the `(1)` marker has no Carve form - emitted as `1)`, '
|
|
663
|
+
+ 'without the opening parenthesis');
|
|
664
|
+
}
|
|
665
|
+
return node;
|
|
666
|
+
}
|
|
667
|
+
function definitionList(ctx, c) {
|
|
668
|
+
const rawItems = c;
|
|
669
|
+
const items = rawItems.map(([termInlines, defs]) => {
|
|
670
|
+
// convert.ts joins multiple Carve terms with LineBreak - split them back.
|
|
671
|
+
const terms = [];
|
|
672
|
+
let current = [];
|
|
673
|
+
for (const x of termInlines) {
|
|
674
|
+
if (x.t === 'LineBreak') {
|
|
675
|
+
terms.push(inlines(ctx, current));
|
|
676
|
+
current = [];
|
|
677
|
+
}
|
|
678
|
+
else {
|
|
679
|
+
current.push(x);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
terms.push(inlines(ctx, current));
|
|
683
|
+
return {
|
|
684
|
+
terms,
|
|
685
|
+
definitions: defs.map((d) => blocks(ctx, d)),
|
|
686
|
+
};
|
|
687
|
+
});
|
|
688
|
+
return { type: 'definition_list', items };
|
|
689
|
+
}
|
|
690
|
+
// --- Tables ---
|
|
691
|
+
const ALIGN_BACK = {
|
|
692
|
+
AlignLeft: 'left',
|
|
693
|
+
AlignRight: 'right',
|
|
694
|
+
AlignCenter: 'center',
|
|
695
|
+
};
|
|
696
|
+
/**
|
|
697
|
+
* Whether a partition says anything the flat `rows` cannot.
|
|
698
|
+
*
|
|
699
|
+
* A single body with no intermediate header, no row-head columns, no attrs and
|
|
700
|
+
* no foot IS what every consumer derives from the rows themselves, so emitting
|
|
701
|
+
* it would add a field that carries no information. Absent means exactly that
|
|
702
|
+
* structure (PART 12 §15).
|
|
703
|
+
*/
|
|
704
|
+
function carriesMoreThanFlatRows(groups) {
|
|
705
|
+
if (groups.footRows > 0)
|
|
706
|
+
return true;
|
|
707
|
+
if (groups.bodies.length > 1)
|
|
708
|
+
return true;
|
|
709
|
+
return groups.bodies.some((b) => b.headRows > 0 || (b.rowHeadColumns ?? 0) > 0 || b.attrs !== undefined);
|
|
710
|
+
}
|
|
711
|
+
/**
|
|
712
|
+
* Rebuild Carve's grid model: pandoc puts rowSpan/colSpan on the origin Cell
|
|
713
|
+
* and omits covered positions; Carve lists every position and marks covered
|
|
714
|
+
* ones as continuation cells (`span: "colspan" | "rowspan"`).
|
|
715
|
+
*/
|
|
716
|
+
function table(ctx, c, captionOverride, shortCaptionOverride = null) {
|
|
717
|
+
const [a, capt, colspecs, thead, tbodies, tfoot] = c;
|
|
718
|
+
// POLICY: a ColSpec's alignment is read, its ColWidth is dropped, and the
|
|
719
|
+
// drop is deliberate and silent. Carve's table model has no width slot at
|
|
720
|
+
// any level (nothing named `width` appears in `resources/ast-schema.json`)
|
|
721
|
+
// and PART 9 §16's pipe-table source cannot spell one, so there is nowhere
|
|
722
|
+
// to put the number and no syntax that could reproduce it. No diagnostic,
|
|
723
|
+
// because the width is usually not the author's: pandoc DERIVES a ColWidth
|
|
724
|
+
// for every grid and multiline table from the ASCII column widths, so a
|
|
725
|
+
// warning would fire on the ordinary case and report a value nobody chose.
|
|
726
|
+
const colAligns = colspecs.map((cs) => ALIGN_BACK[cs[0].t] ?? '');
|
|
727
|
+
const nCols = colspecs.length;
|
|
728
|
+
const headRaw = thead[1];
|
|
729
|
+
const footRaw = tfoot[1];
|
|
730
|
+
// Carve spells COLUMN alignment on the header cell marker (`|=>Name|`), so
|
|
731
|
+
// a table with no LEADING header row has nowhere to put it - and pandoc's
|
|
732
|
+
// grid tables are allowed to be headerless while still carrying alignment.
|
|
733
|
+
// The alignment is written onto every cell of the column instead, which
|
|
734
|
+
// renders the same and is the only slot left, and the move is reported
|
|
735
|
+
// because the column-level fact becomes a per-cell one.
|
|
736
|
+
//
|
|
737
|
+
// Only pandoc's `TableHead` counts. A body's INTERMEDIATE header row is a
|
|
738
|
+
// `|=` row in the middle of the table, and the engine reads that as a row
|
|
739
|
+
// header (`<th scope="row">`) whose alignment applies to itself alone -
|
|
740
|
+
// measured, not assumed. Counting it here would leave the surrounding body
|
|
741
|
+
// cells unaligned and suppress the diagnostic that says so.
|
|
742
|
+
const hasHeaderRow = headRaw.length > 0;
|
|
743
|
+
// The flat `rows` Carve carries, plus the counts that say where pandoc's
|
|
744
|
+
// sections were. A body's intermediate header rows are header rows too,
|
|
745
|
+
// which is why §15 states the head count instead of deriving it from the
|
|
746
|
+
// leading run.
|
|
747
|
+
const allRaw = [...headRaw];
|
|
748
|
+
const isHeaderRow = headRaw.map(() => true);
|
|
749
|
+
const groupBodies = [];
|
|
750
|
+
for (const body of tbodies) {
|
|
751
|
+
const bodyHead = body[2];
|
|
752
|
+
const bodyRows = body[3];
|
|
753
|
+
allRaw.push(...bodyHead, ...bodyRows);
|
|
754
|
+
isHeaderRow.push(...bodyHead.map(() => true), ...bodyRows.map(() => false));
|
|
755
|
+
const group = { headRows: bodyHead.length, bodyRows: bodyRows.length };
|
|
756
|
+
const rowHeadColumns = body[1];
|
|
757
|
+
if (typeof rowHeadColumns === 'number' && rowHeadColumns > 0) {
|
|
758
|
+
group.rowHeadColumns = rowHeadColumns;
|
|
759
|
+
}
|
|
760
|
+
const bodyAttrs = fromAttr(body[0]);
|
|
761
|
+
if (bodyAttrs)
|
|
762
|
+
group.attrs = bodyAttrs;
|
|
763
|
+
groupBodies.push(group);
|
|
764
|
+
}
|
|
765
|
+
allRaw.push(...footRaw);
|
|
766
|
+
isHeaderRow.push(...footRaw.map(() => false));
|
|
767
|
+
// Occupancy grid: pending[r][c] = continuation marker owed at that position.
|
|
768
|
+
// Decided BEFORE the grid walk: the pipe path flattens as it goes and warns
|
|
769
|
+
// while doing it, and that warning is wrong for a table that is about to be
|
|
770
|
+
// emitted as a list-table instead.
|
|
771
|
+
const hasBlockCells = allRaw.some((row) => row[1].some((raw) => {
|
|
772
|
+
const cellBlocks = raw[4];
|
|
773
|
+
return Array.isArray(cellBlocks) && !isInlineShaped(cellBlocks);
|
|
774
|
+
}));
|
|
775
|
+
// ROW-HEAD COLUMNS are the second reason to leave the pipe form, and the
|
|
776
|
+
// only one that is a straight gain: `header-cols=N` (extensions.md §5.1) IS
|
|
777
|
+
// pandoc's `RowHeadColumns`, so the list-table spells exactly what the pipe
|
|
778
|
+
// table drops. The other things `rowGroups` can carry - a foot, a second
|
|
779
|
+
// body, a body's intermediate header rows - have no list-table spelling
|
|
780
|
+
// either, so a table that has only those keeps the readable pipe form and
|
|
781
|
+
// `reportUnspellableGroups` names the loss instead.
|
|
782
|
+
//
|
|
783
|
+
// ONLY WHEN THE BODIES AGREE. `header-cols` is one number for the whole
|
|
784
|
+
// table, so a table whose bodies differ - and `1` beside a plain `0` is the
|
|
785
|
+
// ordinary way they differ - would come back with row headers ADDED to the
|
|
786
|
+
// bodies that had none. Widening the markup is worse than the flattening it
|
|
787
|
+
// was meant to avoid: dropping a row header renders a `td` where a `th`
|
|
788
|
+
// belonged, while inventing one asserts a heading the source never made.
|
|
789
|
+
// Such a table keeps the pipe form, where the loss is only a loss and is
|
|
790
|
+
// reported. A table forced onto the list-table path by its block cells has
|
|
791
|
+
// no such choice, and `listTable` reports the widening there instead.
|
|
792
|
+
const rowHeadCounts = new Set(groupBodies.map((b) => b.rowHeadColumns ?? 0));
|
|
793
|
+
const hasRowHeadColumns = ctx.target === 'source'
|
|
794
|
+
&& rowHeadCounts.size === 1
|
|
795
|
+
&& (groupBodies[0]?.rowHeadColumns ?? 0) > 0;
|
|
796
|
+
const useListTable = hasBlockCells || hasRowHeadColumns;
|
|
797
|
+
const pending = allRaw.map(() => Array(nCols));
|
|
798
|
+
const rows = [];
|
|
799
|
+
// The pandoc blocks that sat at each grid position, kept alongside the
|
|
800
|
+
// inline-flattened cells so the list-table fallback below can convert them
|
|
801
|
+
// as blocks without walking the occupancy grid a second time.
|
|
802
|
+
const cellBlocksAt = allRaw.map(() => []);
|
|
803
|
+
for (let r = 0; r < allRaw.length; r++) {
|
|
804
|
+
const isHeader = isHeaderRow[r] === true;
|
|
805
|
+
const cells = [];
|
|
806
|
+
const rawCells = allRaw[r][1];
|
|
807
|
+
let rawIdx = 0;
|
|
808
|
+
for (let col = 0; col < nCols; col++) {
|
|
809
|
+
if (pending[r][col]) {
|
|
810
|
+
cells.push({ type: 'table_cell', header: isHeader, children: [], span: 'rowspan' });
|
|
811
|
+
continue;
|
|
812
|
+
}
|
|
813
|
+
const raw = rawCells[rawIdx++];
|
|
814
|
+
if (!raw) {
|
|
815
|
+
cells.push({ type: 'table_cell', header: isHeader, children: [] });
|
|
816
|
+
continue;
|
|
817
|
+
}
|
|
818
|
+
const [cellAttr, cellAlign, rowSpan, colSpan, cellBlocks] = raw;
|
|
819
|
+
cellBlocksAt[r][col] = cellBlocks;
|
|
820
|
+
const cell = {
|
|
821
|
+
type: 'table_cell',
|
|
822
|
+
header: isHeader,
|
|
823
|
+
children: useListTable ? [] : cellInlines(ctx, cellBlocks),
|
|
824
|
+
};
|
|
825
|
+
const align = ALIGN_BACK[cellAlign.t]
|
|
826
|
+
?? (isHeader || !hasHeaderRow ? colAligns[col] : '');
|
|
827
|
+
if (align)
|
|
828
|
+
cell.align = align;
|
|
829
|
+
const cellAttrs = fromAttr(cellAttr);
|
|
830
|
+
if (cellAttrs)
|
|
831
|
+
cell.attrs = cellAttrs;
|
|
832
|
+
cells.push(cell);
|
|
833
|
+
for (let j = 1; j < colSpan && col + j < nCols; j++) {
|
|
834
|
+
cells.push({ type: 'table_cell', header: isHeader, children: [], span: 'colspan' });
|
|
835
|
+
}
|
|
836
|
+
// A 2D block gets a rowspan continuation at EVERY covered column
|
|
837
|
+
// of the lower rows - that is how Carve's grid expresses it.
|
|
838
|
+
for (let k = 1; k < rowSpan && r + k < allRaw.length; k++) {
|
|
839
|
+
for (let j = 0; j < colSpan && col + j < nCols; j++) {
|
|
840
|
+
pending[r + k][col + j] = 'rowspan';
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
col += colSpan - 1;
|
|
844
|
+
}
|
|
845
|
+
const rowNode = { type: 'table_row', cells };
|
|
846
|
+
// Pandoc gives a Row its own Attr and Carve spells it after the closing
|
|
847
|
+
// pipe (`| a | b |{.cls}`), so it is the same slot, not a degradation.
|
|
848
|
+
const rowAttrs = fromAttr(allRaw[r][0]);
|
|
849
|
+
if (rowAttrs)
|
|
850
|
+
rowNode.attrs = rowAttrs;
|
|
851
|
+
rows.push(rowNode);
|
|
852
|
+
}
|
|
853
|
+
const captionInlinesFor = () => captionOverride ?? captionFromBlocks(ctx, capt[1]);
|
|
854
|
+
if (useListTable) {
|
|
855
|
+
const short = shortCaptionOverride ?? captionFromInlines(ctx, capt[0]);
|
|
856
|
+
if (short?.length) {
|
|
857
|
+
warn(ctx, 'list-table: the short caption is dropped - the extension has one quoted title and no second slot');
|
|
858
|
+
}
|
|
859
|
+
return listTable(ctx, {
|
|
860
|
+
rows,
|
|
861
|
+
cellBlocksAt,
|
|
862
|
+
headRows: headRaw.length,
|
|
863
|
+
footRows: footRaw.length,
|
|
864
|
+
bodies: groupBodies,
|
|
865
|
+
caption: captionInlinesFor(),
|
|
866
|
+
attrs: fromAttr(a),
|
|
867
|
+
colAligns,
|
|
868
|
+
reason: hasBlockCells ? 'block-cells' : 'row-head-columns',
|
|
869
|
+
});
|
|
870
|
+
}
|
|
871
|
+
if (!hasHeaderRow && colAligns.some((a) => a)) {
|
|
872
|
+
warn(ctx, 'table: column alignment on a table with no header row is written on '
|
|
873
|
+
+ 'each cell instead - Carve spells column alignment on the header '
|
|
874
|
+
+ 'marker (`|=> Name |`), and there is no header to carry it');
|
|
875
|
+
}
|
|
876
|
+
const node = { type: 'table', rows };
|
|
877
|
+
// The counts come from the same arrays `rows` was built from, one row
|
|
878
|
+
// pushed per raw row, so §15's sum holds by construction here. It is
|
|
879
|
+
// checked where it can actually fail instead: on a partition that arrived
|
|
880
|
+
// from outside (see readRowGroups).
|
|
881
|
+
const groups = { headRows: headRaw.length, bodies: groupBodies, footRows: footRaw.length };
|
|
882
|
+
if (carriesMoreThanFlatRows(groups)) {
|
|
883
|
+
node.rowGroups = groups;
|
|
884
|
+
reportUnspellableGroups(ctx, groups);
|
|
885
|
+
}
|
|
886
|
+
const attrs = fromAttr(a);
|
|
887
|
+
if (attrs)
|
|
888
|
+
node.attrs = attrs;
|
|
889
|
+
const captionInlines = captionInlinesFor();
|
|
890
|
+
if (captionInlines?.length)
|
|
891
|
+
node.caption = captionInlines;
|
|
892
|
+
const shortCaption = shortCaptionOverride
|
|
893
|
+
?? captionFromInlines(ctx, capt[0]);
|
|
894
|
+
if (shortCaption?.length)
|
|
895
|
+
node.shortCaption = shortCaption;
|
|
896
|
+
return node;
|
|
897
|
+
}
|
|
898
|
+
/**
|
|
899
|
+
* What a `rowGroups` partition says that the PIPE form cannot say back.
|
|
900
|
+
*
|
|
901
|
+
* The partition itself is not a degradation - it reaches the exchange AST
|
|
902
|
+
* intact, and `pandocToCarveAst` hands it on whole. The loss happens one step
|
|
903
|
+
* later, in the source writer: PART 9 §16's pipe table spells a leading run of
|
|
904
|
+
* header rows and nothing else, so a foot, a second body group, a body's own
|
|
905
|
+
* intermediate header rows, its row-head columns and its attributes all come
|
|
906
|
+
* out as ordinary body rows. PART 12 §15 says so in as many words and asks for
|
|
907
|
+
* exactly this - "a canonical Carve writer loses it ... conversion APIs with
|
|
908
|
+
* diagnostics should report that loss".
|
|
909
|
+
*
|
|
910
|
+
* The wording follows the `shortCaption` precedent: it names where the value
|
|
911
|
+
* DOES survive, because on the `pandocToCarveAst` path nothing is lost at all
|
|
912
|
+
* and a bare "dropped" would be false there. The list-table path reports the
|
|
913
|
+
* same facts in its own vocabulary; this is the pipe path's half of it, which
|
|
914
|
+
* was silent.
|
|
915
|
+
*/
|
|
916
|
+
function reportUnspellableGroups(ctx, groups) {
|
|
917
|
+
const lost = [];
|
|
918
|
+
if (groups.footRows > 0)
|
|
919
|
+
lost.push(`a foot of ${groups.footRows} row(s)`);
|
|
920
|
+
if (groups.bodies.length > 1)
|
|
921
|
+
lost.push(`${groups.bodies.length} body groups`);
|
|
922
|
+
if (groups.bodies.some((b) => b.headRows > 0))
|
|
923
|
+
lost.push("a body's intermediate header rows");
|
|
924
|
+
if (groups.bodies.some((b) => (b.rowHeadColumns ?? 0) > 0))
|
|
925
|
+
lost.push('row-head columns');
|
|
926
|
+
if (groups.bodies.some((b) => b.attrs !== undefined))
|
|
927
|
+
lost.push("a body group's attributes");
|
|
928
|
+
if (!lost.length)
|
|
929
|
+
return;
|
|
930
|
+
warn(ctx, `table: ${lost.join(', ')} - preserved in the Carve AST as \`rowGroups\`, `
|
|
931
|
+
+ 'but a pipe table spells only a leading run of header rows, so the '
|
|
932
|
+
+ 'emitted source flattens them into body rows');
|
|
933
|
+
}
|
|
934
|
+
/**
|
|
935
|
+
* Whether a cell's blocks fit Carve's pipe-table cell, which holds INLINES.
|
|
936
|
+
*
|
|
937
|
+
* One `Plain`/`Para` fits. Two do not: the joining soft break serializes as a
|
|
938
|
+
* literal newline inside the row, which splits the table at that line. Anything
|
|
939
|
+
* else (a list, a code block, a nested table) has no inline form at all.
|
|
940
|
+
*/
|
|
941
|
+
function isInlineShaped(cellBlocks) {
|
|
942
|
+
if (cellBlocks.length === 0)
|
|
943
|
+
return true;
|
|
944
|
+
if (cellBlocks.length > 1)
|
|
945
|
+
return false;
|
|
946
|
+
const only = cellBlocks[0].t;
|
|
947
|
+
return only === 'Plain' || only === 'Para';
|
|
948
|
+
}
|
|
949
|
+
/**
|
|
950
|
+
* A pandoc table with block content in a cell, as `::: list-table`.
|
|
951
|
+
*
|
|
952
|
+
* PART 9 §16's pipe-table cell holds inlines, so a docx or LaTeX table with a
|
|
953
|
+
* list or two paragraphs in a cell has no pipe form. Flattening it was not a
|
|
954
|
+
* degradation but a loss: measured on a pandoc grid table, a `BulletList` cell
|
|
955
|
+
* emitted NOTHING (`stringifyBlocks` walks `{t, c}` nodes and a list's `c` is a
|
|
956
|
+
* list of block LISTS), and a two-paragraph cell put a literal newline inside
|
|
957
|
+
* the row, so the two-row table re-parsed as a one-row table plus a paragraph.
|
|
958
|
+
*
|
|
959
|
+
* The list-table extension (extensions.md §5) exists for exactly this shape:
|
|
960
|
+
* cells are list items, so they hold full block content. It is lossless in
|
|
961
|
+
* structure. Three things it cannot spell are reported rather than dropped
|
|
962
|
+
* quietly: per-column alignment (§5.5 leaves it out), a foot, and a body's
|
|
963
|
+
* intermediate header rows.
|
|
964
|
+
*/
|
|
965
|
+
function listTable(ctx, input) {
|
|
966
|
+
const { rows, cellBlocksAt, headRows, footRows, bodies, caption, attrs, colAligns, reason } = input;
|
|
967
|
+
warn(ctx, reason === 'block-cells'
|
|
968
|
+
? 'table: a cell holds block content, which a pipe table cannot spell - emitted as a `::: list-table` (structure preserved)'
|
|
969
|
+
: 'table: row-head columns, which a pipe table cannot spell - emitted as a `::: list-table` with `header-cols` (structure preserved)');
|
|
970
|
+
if (colAligns.some((a) => a)) {
|
|
971
|
+
warn(ctx, 'list-table: per-column alignment is dropped - the extension has no alignment marker (extensions.md §5.5)');
|
|
972
|
+
}
|
|
973
|
+
if (footRows > 0) {
|
|
974
|
+
warn(ctx, `list-table: the table's ${footRows} foot row(s) become ordinary body rows - the extension has head rows only`);
|
|
975
|
+
}
|
|
976
|
+
if (bodies.some((b) => b.headRows > 0)) {
|
|
977
|
+
warn(ctx, 'list-table: a body group\'s intermediate header rows become ordinary body rows - `header-rows` counts only the leading run');
|
|
978
|
+
}
|
|
979
|
+
if (bodies.length > 1) {
|
|
980
|
+
warn(ctx, `list-table: the table's ${bodies.length} body groups merge into one - the extension has no body boundary`);
|
|
981
|
+
}
|
|
982
|
+
if (bodies.some((b) => b.attrs)) {
|
|
983
|
+
warn(ctx, 'list-table: a body group\'s attributes are dropped - the extension has no body to hang them on');
|
|
984
|
+
}
|
|
985
|
+
// A body with NO row-head columns disagrees with one that has them, and it
|
|
986
|
+
// is the common way they disagree - so the zeros count here. Leaving them
|
|
987
|
+
// out made the one case that CHANGES the markup the one case that said
|
|
988
|
+
// nothing: `header-cols` applies to every row, so the bodies that had none
|
|
989
|
+
// come back with row headers they never had.
|
|
990
|
+
if (new Set(bodies.map((b) => b.rowHeadColumns ?? 0)).size > 1) {
|
|
991
|
+
warn(ctx, 'list-table: the body groups disagree on their row-head column count - `header-cols` is one number for the whole table, so the first non-zero count is applied to every row, and the rows that had none gain row headers');
|
|
992
|
+
}
|
|
993
|
+
const headerCols = bodies.find((b) => b.rowHeadColumns)?.rowHeadColumns ?? 0;
|
|
994
|
+
const rowItems = rows.map((row, r) => {
|
|
995
|
+
const cells = row.cells ?? [];
|
|
996
|
+
const cellItems = cells.map((cell, c) => {
|
|
997
|
+
// A covered position is a lone `^`/`<` item, the same markers the
|
|
998
|
+
// pipe grid uses (§5.1).
|
|
999
|
+
if (cell.span === 'rowspan')
|
|
1000
|
+
return listItem([paragraphOf('^')]);
|
|
1001
|
+
if (cell.span === 'colspan')
|
|
1002
|
+
return listItem([paragraphOf('<')]);
|
|
1003
|
+
const cellBlocks = cellBlocksAt[r]?.[c];
|
|
1004
|
+
const children = cellBlocks?.length ? blocks(ctx, cellBlocks) : [];
|
|
1005
|
+
return listItem(children.length ? children : [paragraphOf('')]);
|
|
1006
|
+
});
|
|
1007
|
+
return listItem([bulletListOf(cellItems)]);
|
|
1008
|
+
});
|
|
1009
|
+
const node = {
|
|
1010
|
+
type: 'admonition',
|
|
1011
|
+
kind: 'list-table',
|
|
1012
|
+
children: [bulletListOf(rowItems)],
|
|
1013
|
+
};
|
|
1014
|
+
if (caption?.length)
|
|
1015
|
+
node.title = caption;
|
|
1016
|
+
const keyValues = { ...(attrs?.keyValues ?? {}) };
|
|
1017
|
+
const order = [...(attrs?.order ?? [])];
|
|
1018
|
+
const addKey = (key, value) => {
|
|
1019
|
+
if (value <= 0)
|
|
1020
|
+
return;
|
|
1021
|
+
keyValues[key] = String(value);
|
|
1022
|
+
if (!order.includes('key'))
|
|
1023
|
+
order.push('key');
|
|
1024
|
+
};
|
|
1025
|
+
addKey('header-rows', headRows);
|
|
1026
|
+
addKey('header-cols', headerCols);
|
|
1027
|
+
const merged = {};
|
|
1028
|
+
if (attrs?.id)
|
|
1029
|
+
merged.id = attrs.id;
|
|
1030
|
+
if (attrs?.classes?.length)
|
|
1031
|
+
merged.classes = attrs.classes;
|
|
1032
|
+
if (Object.keys(keyValues).length)
|
|
1033
|
+
merged.keyValues = keyValues;
|
|
1034
|
+
if (Object.keys(merged).length) {
|
|
1035
|
+
merged.order = order.length ? order : Object.keys(keyValues).length ? ['key'] : [];
|
|
1036
|
+
node.attrs = merged;
|
|
1037
|
+
}
|
|
1038
|
+
return node;
|
|
1039
|
+
}
|
|
1040
|
+
const listItem = (children) => ({ type: 'list_item', children });
|
|
1041
|
+
const paragraphOf = (value) => ({
|
|
1042
|
+
type: 'paragraph',
|
|
1043
|
+
children: value ? [text(value)] : [],
|
|
1044
|
+
});
|
|
1045
|
+
/**
|
|
1046
|
+
* A list is tight when no item holds more than one block. fmt reads the flag to
|
|
1047
|
+
* decide whether to separate items with a blank line, and a cell holding two
|
|
1048
|
+
* paragraphs needs that line or the second paragraph joins the first. A single
|
|
1049
|
+
* nested list under an item does not - that is every row of a list-table.
|
|
1050
|
+
*/
|
|
1051
|
+
const bulletListOf = (items) => ({
|
|
1052
|
+
type: 'list',
|
|
1053
|
+
ordered: false,
|
|
1054
|
+
tight: items.every((item) => (item.children ?? []).length <= 1),
|
|
1055
|
+
items,
|
|
1056
|
+
bulletChar: '-',
|
|
1057
|
+
});
|
|
1058
|
+
function cellInlines(ctx, cellBlocks) {
|
|
1059
|
+
const out = [];
|
|
1060
|
+
cellBlocks.forEach((b, i) => {
|
|
1061
|
+
if (b.t === 'Plain' || b.t === 'Para') {
|
|
1062
|
+
if (i > 0)
|
|
1063
|
+
out.push({ type: 'soft_break' });
|
|
1064
|
+
out.push(...inlines(ctx, b.c));
|
|
1065
|
+
}
|
|
1066
|
+
else {
|
|
1067
|
+
warn(ctx, `table: block-level cell content "${b.t}" flattened to text`);
|
|
1068
|
+
out.push(text(stringifyBlocks([b])));
|
|
1069
|
+
}
|
|
1070
|
+
});
|
|
1071
|
+
return mergeText(out);
|
|
1072
|
+
}
|
|
1073
|
+
function stringifyBlocks(xs) {
|
|
1074
|
+
return xs
|
|
1075
|
+
.map((b) => (Array.isArray(b.c) ? stringify(b.c) : ''))
|
|
1076
|
+
.join(' ')
|
|
1077
|
+
.trim();
|
|
1078
|
+
}
|
|
1079
|
+
function captionFromBlocks(ctx, capBlocks) {
|
|
1080
|
+
if (!capBlocks?.length)
|
|
1081
|
+
return null;
|
|
1082
|
+
const first = capBlocks[0];
|
|
1083
|
+
if (first.t === 'Plain' || first.t === 'Para') {
|
|
1084
|
+
return inlines(ctx, first.c);
|
|
1085
|
+
}
|
|
1086
|
+
return null;
|
|
1087
|
+
}
|
|
1088
|
+
function captionFromInlines(ctx, caption) {
|
|
1089
|
+
return caption?.length ? inlines(ctx, caption) : null;
|
|
1090
|
+
}
|
|
1091
|
+
// --- Figures and divs ---
|
|
1092
|
+
/**
|
|
1093
|
+
* The pandoc blocks a `figure` can wrap, which is `figure.target`'s own list in
|
|
1094
|
+
* `resources/ast-schema.json`: an image, a quote, a table, a code block, a
|
|
1095
|
+
* paragraph (a display-math figure is the paragraph case). Anything else has no
|
|
1096
|
+
* `figure` to become and is unwrapped below.
|
|
1097
|
+
*
|
|
1098
|
+
* This used to be PANEL_HOSTS and was consulted only for a §4c panel, because
|
|
1099
|
+
* outside a group a captioned quote was rerouted to a quote carrying an
|
|
1100
|
+
* attribution and every other single-block Figure fell to the unwrap. With
|
|
1101
|
+
* §4a withdrawn (carve#1213) a captioned host is a `figure` wherever it sits,
|
|
1102
|
+
* so the list is not panel-specific and there is no `asPanel` reading left.
|
|
1103
|
+
*/
|
|
1104
|
+
const FIGURE_HOSTS = new Set(['Plain', 'Para', 'BlockQuote', 'Table', 'CodeBlock']);
|
|
1105
|
+
function figure(ctx, c) {
|
|
1106
|
+
const [a, capt, body] = c;
|
|
1107
|
+
const caption = captionFromBlocks(ctx, capt[1]);
|
|
1108
|
+
const shortCaption = captionFromInlines(ctx, capt[0]);
|
|
1109
|
+
const single = body.length === 1 ? body[0] : undefined;
|
|
1110
|
+
if (single?.t === 'Plain' || single?.t === 'Para') {
|
|
1111
|
+
const xs = single.c;
|
|
1112
|
+
if (xs.length === 1 && xs[0].t === 'Image') {
|
|
1113
|
+
const [img] = inline(ctx, xs[0]);
|
|
1114
|
+
const node = { type: 'figure', target: img };
|
|
1115
|
+
if (caption)
|
|
1116
|
+
node.caption = caption;
|
|
1117
|
+
if (shortCaption)
|
|
1118
|
+
node.shortCaption = shortCaption;
|
|
1119
|
+
const attrs = fromAttr(a);
|
|
1120
|
+
if (attrs)
|
|
1121
|
+
node.attrs = attrs;
|
|
1122
|
+
return [node];
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
if (single?.t === 'Table') {
|
|
1126
|
+
// The wrapper and the Table collapse into ONE Carve node, so their
|
|
1127
|
+
// attrs merge rather than the inner one silently winning: pandoc's
|
|
1128
|
+
// readers put the label on the Figure, not on the Table it wraps, and
|
|
1129
|
+
// dropping it took the id a `</#id>` resolves against with it. The
|
|
1130
|
+
// outer id wins, classes union, key/values merge with the outer taking
|
|
1131
|
+
// precedence.
|
|
1132
|
+
//
|
|
1133
|
+
// A table is the only host that collapses this way, because it is the
|
|
1134
|
+
// only one Carve gives a caption of its own. Every other host keeps
|
|
1135
|
+
// both nodes - the wrapper stays a `figure` and the host's own attrs
|
|
1136
|
+
// ride on the target - which is what the Div arm below sorts out.
|
|
1137
|
+
//
|
|
1138
|
+
// It matters most for a §4c table PANEL, whose id is what resolves as
|
|
1139
|
+
// the group's number plus a letter.
|
|
1140
|
+
const node = table(ctx, single.c, caption, shortCaption);
|
|
1141
|
+
const outer = fromAttr(a);
|
|
1142
|
+
if (outer)
|
|
1143
|
+
node.attrs = mergeCAttrs(node.attrs, outer);
|
|
1144
|
+
return [node];
|
|
1145
|
+
}
|
|
1146
|
+
// A HOST THAT CARRIES ITS OWN ATTRIBUTES ARRIVES INSIDE A DIV. Pandoc's
|
|
1147
|
+
// BlockQuote, Para and CodeBlock have no Attr slot, so `block()` in the
|
|
1148
|
+
// forward direction wraps an attributed one in a Div. A `div` is not a
|
|
1149
|
+
// legal `figure.target` - `resources/ast-schema.json` lists an image, a
|
|
1150
|
+
// quote, a table, a code block and a paragraph - so a Figure holding one
|
|
1151
|
+
// is that wrapper rather than an authored container, and the attributes
|
|
1152
|
+
// belong on the target. Roundtrip mode marks the wrapper with
|
|
1153
|
+
// `carve-block`; plain mode cannot, which is why the schema's own target
|
|
1154
|
+
// list is what decides here.
|
|
1155
|
+
let host = single;
|
|
1156
|
+
let hostAttrs;
|
|
1157
|
+
if (single?.t === 'Div') {
|
|
1158
|
+
const [divAttr, divBody] = single.c;
|
|
1159
|
+
const only = divBody.length === 1 ? divBody[0] : undefined;
|
|
1160
|
+
if (only && FIGURE_HOSTS.has(only.t)) {
|
|
1161
|
+
host = only;
|
|
1162
|
+
hostAttrs = fromAttr([
|
|
1163
|
+
divAttr[0],
|
|
1164
|
+
divAttr[1],
|
|
1165
|
+
divAttr[2].filter(([k]) => k !== 'carve-block'),
|
|
1166
|
+
]);
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
if (host && FIGURE_HOSTS.has(host.t)) {
|
|
1170
|
+
// A single-host `Figure` is an ordinary `figure` around that host -
|
|
1171
|
+
// the generic captioned wrapper of PART 9 §4b, which is what the
|
|
1172
|
+
// forward direction emits for a captioned quote, code listing or
|
|
1173
|
+
// display-math block alike. A group PANEL is the same shape and used
|
|
1174
|
+
// to be the only way in here.
|
|
1175
|
+
const [target] = block(ctx, host);
|
|
1176
|
+
if (target) {
|
|
1177
|
+
if (hostAttrs) {
|
|
1178
|
+
target.attrs = mergeCAttrs(target.attrs, hostAttrs);
|
|
1179
|
+
}
|
|
1180
|
+
const node = { type: 'figure', target };
|
|
1181
|
+
if (caption)
|
|
1182
|
+
node.caption = caption;
|
|
1183
|
+
if (shortCaption)
|
|
1184
|
+
node.shortCaption = shortCaption;
|
|
1185
|
+
const attrs = fromAttr(a);
|
|
1186
|
+
if (attrs)
|
|
1187
|
+
node.attrs = attrs;
|
|
1188
|
+
return [node];
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
if (body.some((b) => b.t === 'Figure' || b.t === 'Table')) {
|
|
1192
|
+
// A Figure whose blocks are themselves Figures (or Tables) is pandoc's
|
|
1193
|
+
// SUBFIGURE shape, and PART 9 §4c has the node for it: a `figure_group`
|
|
1194
|
+
// whose direct `figure`/`table` children are the panels, with any other
|
|
1195
|
+
// block preserved in place between them. Before this arm it fell to the
|
|
1196
|
+
// unwrap below, which dropped the grouping entirely and turned the
|
|
1197
|
+
// group caption into a trailing paragraph.
|
|
1198
|
+
//
|
|
1199
|
+
// The single-block Figures above keep their existing mapping on
|
|
1200
|
+
// purpose - a lone captioned image or table is one figure, not a group
|
|
1201
|
+
// of one, and the reader has no way to tell a subfigure-shaped document
|
|
1202
|
+
// from that.
|
|
1203
|
+
const children = body.flatMap((b) => b.t === 'Figure' ? figure(ctx, b.c) : block(ctx, b));
|
|
1204
|
+
const node = { type: 'figure_group', children };
|
|
1205
|
+
if (caption)
|
|
1206
|
+
node.caption = caption;
|
|
1207
|
+
if (shortCaption) {
|
|
1208
|
+
// PART 12 §16: "NO `shortCaption`, NO legends, NO label fields" on
|
|
1209
|
+
// the group - whether it ever reaches there is carve#1118's design
|
|
1210
|
+
// space, and inventing the field here would put a property on the
|
|
1211
|
+
// wire that the schema rejects on ingest (§11).
|
|
1212
|
+
warn(ctx, 'figure group: short caption dropped (a composite figure has no navigation-caption slot)');
|
|
1213
|
+
}
|
|
1214
|
+
const attrs = fromAttr(a);
|
|
1215
|
+
if (attrs)
|
|
1216
|
+
node.attrs = attrs;
|
|
1217
|
+
return [node];
|
|
1218
|
+
}
|
|
1219
|
+
warn(ctx, 'figure: general figure content unwrapped (caption kept as a trailing paragraph)');
|
|
1220
|
+
const out = blocks(ctx, body);
|
|
1221
|
+
if (caption)
|
|
1222
|
+
out.push({ type: 'paragraph', children: caption });
|
|
1223
|
+
return out;
|
|
1224
|
+
}
|
|
1225
|
+
function div(ctx, c) {
|
|
1226
|
+
const [a, body] = c;
|
|
1227
|
+
const [id, classes, rawKvs] = a;
|
|
1228
|
+
// Roundtrip mode carries a fenced div's grouping `[label]` as a kv rather
|
|
1229
|
+
// than a flattened caption; pull it back out and rebuild the label token.
|
|
1230
|
+
// The key's `.` cannot appear in a user-authored Carve attribute (the grammar
|
|
1231
|
+
// rejects dotted keys), so this never consumes real user data.
|
|
1232
|
+
const labelEntry = rawKvs.find(([k]) => k === 'carve.label');
|
|
1233
|
+
const label = labelEntry?.[1];
|
|
1234
|
+
const kvs = label !== undefined ? rawKvs.filter(([k]) => k !== 'carve.label') : rawKvs;
|
|
1235
|
+
// convert.ts's attr-wrapper marker: restore attrs onto the inner block.
|
|
1236
|
+
const marker = kvs.find(([k]) => k === 'carve-block');
|
|
1237
|
+
if (marker && body.length === 1) {
|
|
1238
|
+
const [child] = block(ctx, body[0]);
|
|
1239
|
+
if (child) {
|
|
1240
|
+
const attrs = fromAttr([id, classes, kvs.filter(([k]) => k !== 'carve-block')]);
|
|
1241
|
+
if (attrs)
|
|
1242
|
+
child.attrs = attrs;
|
|
1243
|
+
return [child];
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
let kind;
|
|
1247
|
+
let rest = [];
|
|
1248
|
+
if (classes.includes('admonition')) {
|
|
1249
|
+
rest = classes.filter((x) => x !== 'admonition');
|
|
1250
|
+
kind = rest.shift();
|
|
1251
|
+
}
|
|
1252
|
+
else if (classes.length === 1 && KNOWN_ADMONITIONS.has(classes[0])) {
|
|
1253
|
+
kind = classes[0];
|
|
1254
|
+
}
|
|
1255
|
+
if (kind) {
|
|
1256
|
+
const node = { type: 'admonition', kind, children: [] };
|
|
1257
|
+
let children = body;
|
|
1258
|
+
// convert.ts emits the admonition title as a leading Para[Strong[..]].
|
|
1259
|
+
const first = body[0];
|
|
1260
|
+
if (classes.includes('admonition') && first?.t === 'Para') {
|
|
1261
|
+
const xs = first.c;
|
|
1262
|
+
if (xs.length === 1 && xs[0].t === 'Strong') {
|
|
1263
|
+
node.title = inlines(ctx, xs[0].c);
|
|
1264
|
+
children = body.slice(1);
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
node.children = blocks(ctx, children);
|
|
1268
|
+
if (label !== undefined)
|
|
1269
|
+
node.label = label;
|
|
1270
|
+
const attrs = fromAttr([id, rest, kvs]);
|
|
1271
|
+
if (attrs)
|
|
1272
|
+
node.attrs = attrs;
|
|
1273
|
+
return [node];
|
|
1274
|
+
}
|
|
1275
|
+
const node = { type: 'div', children: blocks(ctx, body) };
|
|
1276
|
+
if (label !== undefined)
|
|
1277
|
+
node.label = label;
|
|
1278
|
+
const attrs = fromAttr([id, classes, kvs]);
|
|
1279
|
+
if (attrs)
|
|
1280
|
+
node.attrs = attrs;
|
|
1281
|
+
return [node];
|
|
1282
|
+
}
|
|
1283
|
+
// --- Metadata ---
|
|
1284
|
+
function metaToYaml(ctx, meta) {
|
|
1285
|
+
return mappingToYaml(ctx, meta, 0).join('\n');
|
|
1286
|
+
}
|
|
1287
|
+
function yamlScalar(s) {
|
|
1288
|
+
if (/^[A-Za-z0-9][A-Za-z0-9 ._\/-]*$/.test(s))
|
|
1289
|
+
return s;
|
|
1290
|
+
return JSON.stringify(s);
|
|
1291
|
+
}
|
|
1292
|
+
/**
|
|
1293
|
+
* A pandoc `Meta` map as YAML, to whatever depth it has.
|
|
1294
|
+
*
|
|
1295
|
+
* `MetaMap` and a `MetaList` of non-scalars used to hit the `default: return
|
|
1296
|
+
* null` arm and be dropped with a warning, so a `bibliography` map or the
|
|
1297
|
+
* `author: [ - name:, affiliation: ]` every pandoc template reads did not
|
|
1298
|
+
* survive an import at all. A list of plain scalars keeps the flow form
|
|
1299
|
+
* (`tags: [a, b]`), which is what frontmatter conventionally looks like.
|
|
1300
|
+
*/
|
|
1301
|
+
function mappingToYaml(ctx, map, depth) {
|
|
1302
|
+
const pad = ' '.repeat(depth);
|
|
1303
|
+
const out = [];
|
|
1304
|
+
for (const [key, value] of Object.entries(map)) {
|
|
1305
|
+
const inline = scalarToYaml(value);
|
|
1306
|
+
if (inline !== null) {
|
|
1307
|
+
out.push(`${pad}${yamlKey(key)}: ${inline}`);
|
|
1308
|
+
continue;
|
|
1309
|
+
}
|
|
1310
|
+
if (value.t === 'MetaBlocks') {
|
|
1311
|
+
const body = blockScalarBody(ctx, value.c, depth + 1);
|
|
1312
|
+
if (body === null) {
|
|
1313
|
+
warn(ctx, `meta: key "${key}" (MetaBlocks) is empty - skipped`);
|
|
1314
|
+
continue;
|
|
1315
|
+
}
|
|
1316
|
+
out.push(`${pad}${yamlKey(key)}: |`, ...body);
|
|
1317
|
+
continue;
|
|
1318
|
+
}
|
|
1319
|
+
const nested = nestedToYaml(ctx, value, depth + 1, key);
|
|
1320
|
+
if (nested === null) {
|
|
1321
|
+
warn(ctx, `meta: key "${key}" (${value.t}) has no YAML form - skipped`);
|
|
1322
|
+
continue;
|
|
1323
|
+
}
|
|
1324
|
+
out.push(`${pad}${yamlKey(key)}:`, ...nested);
|
|
1325
|
+
}
|
|
1326
|
+
return out;
|
|
1327
|
+
}
|
|
1328
|
+
function yamlKey(key) {
|
|
1329
|
+
return /^[A-Za-z0-9_][A-Za-z0-9_.-]*$/.test(key) ? key : JSON.stringify(key);
|
|
1330
|
+
}
|
|
1331
|
+
/**
|
|
1332
|
+
* Block content in metadata as the lines UNDER a `key: |`.
|
|
1333
|
+
*
|
|
1334
|
+
* A YAML literal block scalar keeps every line and every blank line between
|
|
1335
|
+
* them, so the paragraph structure the value has is the paragraph structure it
|
|
1336
|
+
* arrives with - which is the objection that used to make this a skip. The
|
|
1337
|
+
* other half of that objection, that the markup inside "nothing on the reading
|
|
1338
|
+
* side parses", is answered by `parseMeta` reading the scalar back through the
|
|
1339
|
+
* same parser. Pandoc's markdown writer emits exactly this form for the same
|
|
1340
|
+
* value, and its reader turns exactly this form back into `MetaBlocks`.
|
|
1341
|
+
*
|
|
1342
|
+
* Written with the ENGINE's own serializer, so the value carries whatever
|
|
1343
|
+
* `carve fmt` guarantees and is not a second, ad-hoc Carve writer.
|
|
1344
|
+
*/
|
|
1345
|
+
function blockScalarBody(ctx, pandocBlocks, depth) {
|
|
1346
|
+
const children = blocks(ctx, pandocBlocks ?? []);
|
|
1347
|
+
if (!children.length)
|
|
1348
|
+
return null;
|
|
1349
|
+
const source = renderCarve({ type: 'document', children });
|
|
1350
|
+
const pad = ' '.repeat(depth);
|
|
1351
|
+
const body = source.replace(/\n+$/, '').split('\n');
|
|
1352
|
+
if (!body.length)
|
|
1353
|
+
return null;
|
|
1354
|
+
// A blank line inside the scalar stays EMPTY rather than padded: trailing
|
|
1355
|
+
// whitespace on an otherwise blank line is what a stricter YAML reader
|
|
1356
|
+
// complains about, and the indentation is set by the non-blank lines.
|
|
1357
|
+
return body.map((line) => (line === '' ? '' : pad + line));
|
|
1358
|
+
}
|
|
1359
|
+
/**
|
|
1360
|
+
* The one-line form of a value, or null when it needs lines of its own.
|
|
1361
|
+
*
|
|
1362
|
+
* `MetaBlocks` deliberately returns null here and is reported by the caller
|
|
1363
|
+
* rather than serialized. This is decision D5(b) of the conversion tracker
|
|
1364
|
+
* (markup-carve/carve#1210): block content inside metadata has no honest YAML
|
|
1365
|
+
* string form - flattening `abstract: |` to one scalar throws away the
|
|
1366
|
+
* paragraph structure, and writing Carve source into a YAML value makes the
|
|
1367
|
+
* frontmatter carry markup that nothing on the reading side parses. The warn is
|
|
1368
|
+
* the honest outcome, and it is policy, not an omission.
|
|
1369
|
+
*/
|
|
1370
|
+
function scalarToYaml(value) {
|
|
1371
|
+
switch (value.t) {
|
|
1372
|
+
case 'MetaString':
|
|
1373
|
+
return yamlScalar(String(value.c));
|
|
1374
|
+
case 'MetaBool':
|
|
1375
|
+
return value.c ? 'true' : 'false';
|
|
1376
|
+
case 'MetaInlines':
|
|
1377
|
+
return yamlScalar(stringify(value.c));
|
|
1378
|
+
case 'MetaList': {
|
|
1379
|
+
const items = value.c.map(scalarToYaml);
|
|
1380
|
+
if (items.some((x) => x === null))
|
|
1381
|
+
return null;
|
|
1382
|
+
return `[${items.join(', ')}]`;
|
|
1383
|
+
}
|
|
1384
|
+
case 'MetaMap':
|
|
1385
|
+
// An empty map has no block form - `key:` with nothing under it
|
|
1386
|
+
// reads back as an empty value, not an empty map - so it takes the
|
|
1387
|
+
// flow spelling. A populated one needs lines of its own.
|
|
1388
|
+
return Object.keys(value.c).length ? null : '{}';
|
|
1389
|
+
default:
|
|
1390
|
+
return null;
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
function nestedToYaml(ctx, value, depth, key) {
|
|
1394
|
+
if (value.t === 'MetaMap') {
|
|
1395
|
+
const lines = mappingToYaml(ctx, value.c, depth);
|
|
1396
|
+
return lines.length ? lines : null;
|
|
1397
|
+
}
|
|
1398
|
+
if (value.t === 'MetaList')
|
|
1399
|
+
return listToYaml(ctx, value.c, depth, key);
|
|
1400
|
+
return null;
|
|
1401
|
+
}
|
|
1402
|
+
function listToYaml(ctx, items, depth, key) {
|
|
1403
|
+
const pad = ' '.repeat(depth);
|
|
1404
|
+
const out = [];
|
|
1405
|
+
for (const item of items) {
|
|
1406
|
+
const inline = scalarToYaml(item);
|
|
1407
|
+
if (inline !== null) {
|
|
1408
|
+
out.push(`${pad}- ${inline}`);
|
|
1409
|
+
continue;
|
|
1410
|
+
}
|
|
1411
|
+
const nested = nestedToYaml(ctx, item, depth + 1, key);
|
|
1412
|
+
if (nested === null) {
|
|
1413
|
+
warn(ctx, `meta: an item of "${key}" (${item.t}) has no YAML form - skipped`);
|
|
1414
|
+
continue;
|
|
1415
|
+
}
|
|
1416
|
+
// `- ` replaces the first two spaces of the child's own indent, so the
|
|
1417
|
+
// keys after it stay aligned under the first one.
|
|
1418
|
+
out.push(`${pad}- ${nested[0].slice(pad.length + 2)}`, ...nested.slice(1));
|
|
1419
|
+
}
|
|
1420
|
+
return out.length ? out : null;
|
|
1421
|
+
}
|
|
1422
|
+
// --- Entry point ---
|
|
1423
|
+
export function pandocToCarve(doc, target = 'source') {
|
|
1424
|
+
const ctx = {
|
|
1425
|
+
warnings: [],
|
|
1426
|
+
footnoteDefs: {},
|
|
1427
|
+
target,
|
|
1428
|
+
noteCounter: 0,
|
|
1429
|
+
abbrevDefs: new Map(),
|
|
1430
|
+
bibliographyWarned: false,
|
|
1431
|
+
quotedWarned: false,
|
|
1432
|
+
};
|
|
1433
|
+
const children = blocks(ctx, doc.blocks);
|
|
1434
|
+
if (containsShortCaption(children)) {
|
|
1435
|
+
warn(ctx, 'short caption: preserved in the Carve AST; Carve 0.1 source has no spelling for it');
|
|
1436
|
+
}
|
|
1437
|
+
for (const [abbr, expansion] of [...ctx.abbrevDefs].reverse()) {
|
|
1438
|
+
children.unshift({ type: 'abbreviation_def', abbr, expansion });
|
|
1439
|
+
}
|
|
1440
|
+
const ast = { type: 'document', children };
|
|
1441
|
+
if (Object.keys(ctx.footnoteDefs).length)
|
|
1442
|
+
ast.footnoteDefs = ctx.footnoteDefs;
|
|
1443
|
+
const yaml = metaToYaml(ctx, doc.meta ?? {});
|
|
1444
|
+
if (yaml)
|
|
1445
|
+
ast.frontmatter = { format: 'yaml', content: yaml };
|
|
1446
|
+
return { ast, warnings: ctx.warnings };
|
|
1447
|
+
}
|
|
1448
|
+
function containsShortCaption(value) {
|
|
1449
|
+
if (Array.isArray(value))
|
|
1450
|
+
return value.some(containsShortCaption);
|
|
1451
|
+
if (!value || typeof value !== 'object')
|
|
1452
|
+
return false;
|
|
1453
|
+
const record = value;
|
|
1454
|
+
if (Array.isArray(record.shortCaption) && record.shortCaption.length > 0)
|
|
1455
|
+
return true;
|
|
1456
|
+
return Object.values(record).some(containsShortCaption);
|
|
1457
|
+
}
|