@markup-carve/pandoc-carve 0.1.8 → 0.1.9

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/dist/cli.js CHANGED
@@ -234,8 +234,21 @@ async function importToCarve(args) {
234
234
  }
235
235
  json = result.stdout;
236
236
  }
237
- const { pandocToCarve } = await import('./index.js');
238
- const { carve, warnings, diagnostics } = pandocToCarve(json);
237
+ const { pandocToCarve, PandocImportLimitError } = await import('./index.js');
238
+ let converted;
239
+ try {
240
+ converted = pandocToCarve(json);
241
+ }
242
+ catch (error) {
243
+ if (!(error instanceof PandocImportLimitError))
244
+ throw error;
245
+ if (args.diagnosticsFile !== undefined) {
246
+ report(args, [error.message], [error.diagnostic], 'pandoc-json');
247
+ process.exit(1);
248
+ }
249
+ throw error;
250
+ }
251
+ const { carve, warnings, diagnostics } = converted;
239
252
  const reportDiagnostics = args.from === 'json' ? diagnostics : [
240
253
  ...diagnostics,
241
254
  {
@@ -315,7 +328,7 @@ function diagnosticsForIncludes(warnings, suppressed, root) {
315
328
  const NORMALIZED_INCLUDE_RULES = new Set([
316
329
  'include-footnote-rename',
317
330
  'include-heading-clamp',
318
- 'include-heading-id-rename',
331
+ 'include-id-rename',
319
332
  ]);
320
333
  function displayIncludeFile(file, root, fallback) {
321
334
  if (!file || !root)
package/dist/convert.js CHANGED
@@ -15,7 +15,7 @@ import { diagnostic, migrationReport } from './diagnostics.js';
15
15
  import { provenanceAttr } from './provenance.js';
16
16
  import { blankDeniedDestination, probeScheme } from './url-scheme.js';
17
17
  import { isDangerousAttrName, renderedAttrValue } from './attribute-sanitize.js';
18
- import { SMART_PUNCTUATION_GLYPHS } from '@markup-carve/carve';
18
+ import { SMART_PUNCTUATION_GLYPHS, fromAstJson, resolve } from '@markup-carve/carve';
19
19
  function warn(ctx, msg, details, sourceLocation) {
20
20
  ctx.warnings.push(msg);
21
21
  ctx.diagnostics.push(diagnostic('carve-to-pandoc', msg, details, sourceLocation));
@@ -89,12 +89,74 @@ function smallCapsOrSpan(a, xs) {
89
89
  return caps;
90
90
  return P.Span([id, rest, kvs], [caps]);
91
91
  }
92
- /** Carve's heading slug: case-preserving, GitHub-style. */
92
+ /**
93
+ * Fallback heading slug, for a tree the engine's own resolver refuses. Mirrors
94
+ * the engine's rule: NFC, invisible controls dropped, each run of non-
95
+ * alphanumeric ASCII one `-`, an `s-` prefix before a leading digit.
96
+ */
93
97
  function slugify(text) {
94
- return text
95
- .trim()
96
- .replace(/[^\p{L}\p{N}\s_-]/gu, '')
97
- .replace(/\s+/g, '-');
98
+ const s = text
99
+ .normalize('NFC')
100
+ .replace(/[\u202a-\u202e\u2066-\u2069\u200b-\u200d\u2060\ufeff\u00ad]/gu, '')
101
+ .replace(/[^0-9A-Za-z\u{80}-\u{10FFFF}]+/gu, '-')
102
+ .replace(/^-+|-+$/g, '');
103
+ if (s === '')
104
+ return 's';
105
+ return /^\p{N}/u.test(s) ? `s-${s}` : s;
106
+ }
107
+ const HEADING_TAG = 'data-pandoc-carve-heading';
108
+ /**
109
+ * The id the engine's `resolve()` gives every heading, keyed by the node.
110
+ *
111
+ * Asking the resolver rather than re-deriving: it owns the slug rule, the
112
+ * dedup suffix and the id namespace shared with explicit ids on other
113
+ * elements. Each heading in a copy is tagged so the answer maps back even
114
+ * though ingest moves footnote bodies around. Empty when the resolver refuses
115
+ * the tree; pass 1 then falls back to {@link slugify}.
116
+ */
117
+ function resolvedHeadingIds(ast) {
118
+ const byTag = [];
119
+ const copy = (value) => {
120
+ if (Array.isArray(value))
121
+ return value.map(copy);
122
+ if (!value || typeof value !== 'object')
123
+ return value;
124
+ const out = {};
125
+ for (const [k, v] of Object.entries(value))
126
+ out[k] = copy(v);
127
+ if (value.type === 'heading') {
128
+ const attrs = (out.attrs ?? {});
129
+ out.attrs = { ...attrs, keyValues: { ...(attrs.keyValues ?? {}), [HEADING_TAG]: String(byTag.length) } };
130
+ byTag.push(value);
131
+ }
132
+ return out;
133
+ };
134
+ const ids = new Map();
135
+ try {
136
+ const tagged = copy(ast);
137
+ if (byTag.length === 0)
138
+ return ids;
139
+ const resolved = resolve(fromAstJson(tagged));
140
+ const stack = [resolved];
141
+ while (stack.length) {
142
+ const value = stack.pop();
143
+ if (Array.isArray(value))
144
+ stack.push(...value);
145
+ else if (value && typeof value === 'object') {
146
+ const node = value;
147
+ const attrs = node.attrs;
148
+ const tag = node.type === 'heading' ? attrs?.keyValues?.[HEADING_TAG] : undefined;
149
+ const original = tag === undefined ? undefined : byTag[Number(tag)];
150
+ if (original && attrs?.id)
151
+ ids.set(original, attrs.id);
152
+ stack.push(...Object.values(node));
153
+ }
154
+ }
155
+ }
156
+ catch {
157
+ ids.clear();
158
+ }
159
+ return ids;
98
160
  }
99
161
  function plainText(nodes) {
100
162
  let out = '';
@@ -134,7 +196,41 @@ function hasLabel(n) {
134
196
  * exactly; a flattened caption Para would be indistinguishable from a title.
135
197
  */
136
198
  function labelCaption(ctx, n) {
137
- return !ctx.roundtrip && hasLabel(n) ? [P.Para([P.Strong(textInlines(n.label))])] : [];
199
+ return !ctx.roundtrip && hasLabel(n) ? [P.Para([P.Strong(labelInlines(ctx, n.label))])] : [];
200
+ }
201
+ /**
202
+ * A container label is an inline run (`CARVE-P9-041`), held as text on the
203
+ * node. It is parsed as a paragraph behind an escaped `.`, which no inline
204
+ * construct can absorb and which keeps a leading `#` or `-` from opening a
205
+ * block; the `.` is then taken back off.
206
+ */
207
+ function labelInlines(ctx, label) {
208
+ if (!ctx.parseBlocks || /[\r\n]/.test(label))
209
+ return textInlines(label);
210
+ let parsed;
211
+ try {
212
+ parsed = ctx.parseBlocks(`\\.${label}`);
213
+ }
214
+ catch {
215
+ return textInlines(label);
216
+ }
217
+ const para = parsed.length === 1 && parsed[0]?.type === 'paragraph' ? parsed[0] : undefined;
218
+ const nodes = [...(para?.children ?? [])];
219
+ const first = nodes[0];
220
+ if (first?.type === 'text' && String(first.value ?? '').startsWith('.')) {
221
+ const rest = String(first.value).slice(1);
222
+ if (rest)
223
+ nodes[0] = { ...first, value: rest };
224
+ else
225
+ nodes.shift();
226
+ }
227
+ else if (first?.type === 'escaped_text' && first.value === '.') {
228
+ nodes.shift();
229
+ }
230
+ else {
231
+ return textInlines(label);
232
+ }
233
+ return inlines(ctx, nodes);
138
234
  }
139
235
  /**
140
236
  * Roundtrip-only kv that carries the grouping `[label]` for exact re-import.
@@ -143,7 +239,8 @@ function labelCaption(ctx, n) {
143
239
  * that the reverse importer would mistake for this internal marker.
144
240
  */
145
241
  function labelKv(ctx, n) {
146
- return ctx.roundtrip && hasLabel(n) ? [['carve.label', n.label]] : [];
242
+ // An empty label still renders (an empty caption line), so it crosses too.
243
+ return ctx.roundtrip && typeof n.label === 'string' ? [['carve.label', n.label]] : [];
147
244
  }
148
245
  // Glyphs for the smart-typography kinds that do not carry one on the node.
149
246
  // A quote's glyph is locale-dependent and is chosen during parsing, so the node
@@ -395,8 +492,8 @@ function kids(ctx, n) {
395
492
  * this package's deliverable.
396
493
  *
397
494
  * One break, because that is what the clause adds. A payload that ends mid-line
398
- * publishes no break and keeps none here. `raw_block` is untouched: the same
399
- * clause says raw blocks keep their existing encoding.
495
+ * publishes no break and keeps none here. `raw_block.content` follows the same
496
+ * rule since spec #2916, and pandoc's `RawBlock` carries no final break either.
400
497
  */
401
498
  function codePayload(ctx, content, n) {
402
499
  if (!content.endsWith('\n'))
@@ -408,7 +505,8 @@ function codePayload(ctx, content, n) {
408
505
  // returns `""` for both a blank-line payload and an empty fence - but this
409
506
  // is the only place that still knows the difference, so it says so.
410
507
  if (payload === '') {
411
- warn(ctx, "code block: a blank-only payload reaches pandoc as an empty one - pandoc's CodeBlock cannot hold the difference", { construct: 'code_block' }, n.pos);
508
+ const raw = n.type === 'raw_block';
509
+ warn(ctx, `${raw ? 'raw block' : 'code block'}: a blank-only payload reaches pandoc as an empty one - pandoc's ${raw ? 'RawBlock' : 'CodeBlock'} cannot hold the difference`, { construct: raw ? 'raw_block' : 'code_block' }, n.pos);
412
510
  }
413
511
  return payload;
414
512
  }
@@ -468,7 +566,8 @@ function collapsedHeadingRef(ctx, n) {
468
566
  }
469
567
  /** The key a collapsed reference and a heading's text meet on (PART 9R R1). */
470
568
  function headingTextKey(text) {
471
- return text.replace(/[ \t\n\f\r]+/g, ' ').replace(/^ | $/g, '').normalize('NFC');
569
+ // A tab is key content, not a separator (corpus 565).
570
+ return text.replace(/[ \n\r]+/g, ' ').replace(/^ | $/g, '').normalize('NFC');
472
571
  }
473
572
  function unresolvedReference(ctx, n, kind) {
474
573
  if (n.ref === undefined)
@@ -494,7 +593,74 @@ function unresolvedReference(ctx, n, kind) {
494
593
  });
495
594
  return out;
496
595
  }
596
+ /**
597
+ * A Note, with any note referenced from inside it lifted out to follow it.
598
+ *
599
+ * Pandoc's notes do not nest - its writers drop a Note inside a Note - so a
600
+ * reference in a note body becomes a sibling note right after the outer one,
601
+ * and a diagnostic says the reference moved. A roundtrip conversion keeps the
602
+ * nesting: the reverse direction reads it back, and pandoc's JSON carries it.
603
+ */
604
+ function liftedNote(ctx, body) {
605
+ if (ctx.roundtrip) {
606
+ const note = P.Note(body());
607
+ ctx.noteCount++;
608
+ return [note];
609
+ }
610
+ if (ctx.noteDepth > 0) {
611
+ warn(ctx, 'footnote: a note referenced inside a note is lifted to follow it - pandoc\'s notes do not nest');
612
+ }
613
+ const outer = ctx.liftedNotes;
614
+ ctx.liftedNotes = [];
615
+ ctx.noteDepth++;
616
+ let content;
617
+ let lifted;
618
+ try {
619
+ content = body();
620
+ }
621
+ finally {
622
+ ctx.noteDepth--;
623
+ lifted = ctx.liftedNotes;
624
+ ctx.liftedNotes = outer;
625
+ }
626
+ ctx.noteCount++;
627
+ const notes = [P.Note(content), ...lifted];
628
+ if (ctx.noteDepth > 0) {
629
+ ctx.liftedNotes.push(...notes);
630
+ return [];
631
+ }
632
+ return notes;
633
+ }
634
+ /** Pandoc reads an empty title as no title, so an authored `""` cannot cross. */
635
+ function emptyTitle(ctx, n, kind) {
636
+ if (n.title === '') {
637
+ warn(ctx, `${kind}: an empty title is dropped - pandoc reads an empty title as none`, { construct: kind }, n.pos);
638
+ }
639
+ }
640
+ /**
641
+ * Inline nodes whose pandoc constructor has no Attr slot (or, for the classed
642
+ * Span stand-ins, whose slot the bridge's own class fills), so an attribute run
643
+ * on them is dropped - said once per node rather than silently.
644
+ */
645
+ const NO_ATTR_SLOT = {
646
+ emphasis: 'Emph',
647
+ strong: 'Strong',
648
+ underline: 'Underline',
649
+ strike: 'Strikeout',
650
+ subscript: 'Subscript',
651
+ superscript: 'Superscript',
652
+ highlight: 'Span .mark',
653
+ insert: 'Span .insertion',
654
+ delete: 'Span .deletion',
655
+ substitution: 'Span .substitution',
656
+ footnote_ref: 'Note',
657
+ inline_footnote: 'Note',
658
+ };
497
659
  function inline(ctx, n) {
660
+ const slotless = NO_ATTR_SLOT[n.type];
661
+ if (slotless && hasAttrs(n.attrs)) {
662
+ warn(ctx, `attribute: the attributes on a ${n.type} are dropped - pandoc's ${slotless} has no slot for them`, { construct: n.type }, n.pos);
663
+ }
498
664
  switch (n.type) {
499
665
  case 'text':
500
666
  return textInlines(String(n.value ?? ''));
@@ -504,6 +670,9 @@ function inline(ctx, n) {
504
670
  return [P.SoftBreak];
505
671
  case 'hard_break':
506
672
  return [P.LineBreak];
673
+ case 'cell_wrap':
674
+ // A table continuation-line join; the contract renders it as one space.
675
+ return [P.Space];
507
676
  case 'emphasis':
508
677
  return [P.Emph(kids(ctx, n))];
509
678
  case 'strong':
@@ -570,6 +739,7 @@ function inline(ctx, n) {
570
739
  const unresolved = unresolvedReference(ctx, n, 'link');
571
740
  if (unresolved)
572
741
  return unresolved;
742
+ emptyTitle(ctx, n, 'link');
573
743
  return [
574
744
  P.Link(toAttr(ctx, n.attrs), inLink(ctx, n), [
575
745
  safeDestination(ctx, String(n.href ?? ''), 'link'),
@@ -605,6 +775,7 @@ function inline(ctx, n) {
605
775
  const unresolved = unresolvedReference(ctx, n, 'image');
606
776
  if (unresolved)
607
777
  return unresolved;
778
+ emptyTitle(ctx, n, 'image');
608
779
  return [
609
780
  P.Image(toAttr(ctx, n.attrs), textInlines(String(n.alt ?? '')), [
610
781
  safeDestination(ctx, String(n.src ?? ''), 'image'),
@@ -658,8 +829,14 @@ function inline(ctx, n) {
658
829
  ctx.inCrossref = false;
659
830
  }
660
831
  }
661
- warn(ctx, `crossref: unresolved target "${target}" - emitting target text`);
662
- return [P.Link(P.attr(undefined, ['crossref', 'unresolved']), [P.Str(target)], [`#${target}`, ''])];
832
+ // The engine renders an unresolved crossref as its literal source.
833
+ // Roundtrip keeps the classed link so the reverse direction restores
834
+ // the crossref rather than an escaped `</\#id>`.
835
+ warn(ctx, `crossref: unresolved target "${target}" - emitting the literal source`);
836
+ if (ctx.roundtrip) {
837
+ return [P.Link(P.attr(undefined, ['crossref', 'unresolved']), textInlines(`</#${target}>`), [`#${target}`, ''])];
838
+ }
839
+ return textInlines(`</#${target}>`);
663
840
  }
664
841
  // The wire has two nodes here, split so that a profile can deny one
665
842
  // without the other (markup-carve/carve#405): `footnote_ref` (`[^a]`)
@@ -672,9 +849,7 @@ function inline(ctx, n) {
672
849
  // a citation inside the note carries that note's number rather than
673
850
  // the next one - which is what pandoc's markdown reader produces.
674
851
  if (Array.isArray(n.inline)) {
675
- const note = P.Note([P.Para(inlines(ctx, n.inline))]);
676
- ctx.noteCount++;
677
- return [note];
852
+ return liftedNote(ctx, () => [P.Para(inlines(ctx, n.inline))]);
678
853
  }
679
854
  const id = String(n.label ?? n.id ?? '');
680
855
  const def = ctx.footnoteDefs[id];
@@ -701,9 +876,21 @@ function inline(ctx, n) {
701
876
  warn(ctx, `footnote: missing definition for [^${id}] - emitting the literal source`);
702
877
  return textInlines(`[^${id}]`);
703
878
  }
704
- const note = P.Note(blocks(ctx, def));
705
- ctx.noteCount++;
706
- return [note];
879
+ if (ctx.openNotes.has(id)) {
880
+ warn(ctx, `footnote: a cyclic reference to [^${id}] inside its own note is dropped - pandoc's notes do not nest`);
881
+ return [];
882
+ }
883
+ if (ctx.citedNotes.has(id)) {
884
+ warn(ctx, `footnote: [^${id}] is referenced more than once - pandoc's Note has no identity, so each reference carries its own copy`, { reference: `[^${id}]` }, n.pos);
885
+ }
886
+ ctx.citedNotes.add(id);
887
+ ctx.openNotes.add(id);
888
+ try {
889
+ return liftedNote(ctx, () => blocks(ctx, def));
890
+ }
891
+ finally {
892
+ ctx.openNotes.delete(id);
893
+ }
707
894
  }
708
895
  case 'math':
709
896
  // PANDOC'S `Math` IS `Math MathType Text` - two children and no
@@ -969,22 +1156,32 @@ function block(ctx, n) {
969
1156
  */
970
1157
  function lineBlock(ctx, n) {
971
1158
  const lines = [];
1159
+ let emptyLine = false;
972
1160
  const stanzas = n.children ?? [];
973
1161
  stanzas.forEach((stanza, i) => {
974
1162
  if (i > 0)
975
1163
  lines.push([]);
976
1164
  const kids = stanza.children ?? [stanza];
977
1165
  let current = [];
1166
+ let broke = false;
978
1167
  for (const kid of kids) {
979
1168
  if (kid.type === 'hard_break' || kid.type === 'soft_break') {
1169
+ if (current.length === 0 && broke)
1170
+ emptyLine = true;
980
1171
  lines.push(inlines(ctx, current));
981
1172
  current = [];
1173
+ broke = true;
982
1174
  continue;
983
1175
  }
984
1176
  current.push(kid);
985
1177
  }
1178
+ if (current.length === 0 && broke)
1179
+ emptyLine = true;
986
1180
  lines.push(inlines(ctx, current));
987
1181
  });
1182
+ if (emptyLine) {
1183
+ warn(ctx, 'line block: a hard break that leaves an empty line becomes an empty entry, which pandoc cannot tell from a stanza break', { construct: 'line_block' }, n.pos);
1184
+ }
988
1185
  return P.LineBlock(lines);
989
1186
  }
990
1187
  function blockInner(ctx, n) {
@@ -994,18 +1191,21 @@ function blockInner(ctx, n) {
994
1191
  return [ctx.tight ? P.Plain(xs) : P.Para(xs)];
995
1192
  }
996
1193
  case 'heading':
997
- return [P.Header(Number(n.level ?? 1), toAttr(ctx, n.attrs), kids(ctx, n))];
1194
+ return [P.Header(Number(n.level ?? 1), toAttr(ctx, { ...(n.attrs ?? {}), id: ctx.headingIds.get(n) ?? n.attrs?.id }), kids(ctx, n))];
998
1195
  case 'block_quote':
999
1196
  return [P.BlockQuote(untight(ctx, () => blocks(ctx, n.children)))];
1000
1197
  case 'code_block': {
1001
1198
  const lang = n.lang ? [String(n.lang)] : [];
1002
1199
  const [id, classes, kvs] = toAttr(ctx, n.attrs);
1200
+ if (lang.length === 0 && classes.length > 0) {
1201
+ warn(ctx, `code block: a class without a language is read as the language by pandoc's writers - "${classes[0]}"`, { construct: 'code_block', class: classes[0] }, n.pos);
1202
+ }
1003
1203
  return [
1004
1204
  P.CodeBlock(P.attr(id, [...lang, ...classes], kvs), codePayload(ctx, String(n.content ?? ''), n)),
1005
1205
  ];
1006
1206
  }
1007
1207
  case 'raw_block':
1008
- return [P.RawBlock(String(n.format ?? ''), String(n.content ?? ''))];
1208
+ return [P.RawBlock(String(n.format ?? ''), codePayload(ctx, String(n.content ?? ''), n))];
1009
1209
  case 'citation_definition':
1010
1210
  return [citationDefinition(ctx, n)];
1011
1211
  case 'thematic_break':
@@ -1153,15 +1353,27 @@ function list(ctx, n) {
1153
1353
  ? [...kvs, ['carve-list-item', 'true'],
1154
1354
  ...(carried ? [[TASK_STATE_KEY, state]] : [])]
1155
1355
  : kvs;
1156
- itemBlocks = [P.Div([id, classes, marked], itemBlocks)];
1356
+ const lead = itemBlocks[0];
1357
+ if (typeof item.checked === 'boolean' && lead && (lead.t === 'Plain' || lead.t === 'Para')) {
1358
+ // Pandoc reads a task only from a ballot that OPENS the item's
1359
+ // first block, so a wrapping Div would hide it. The item's
1360
+ // attributes ride on a Span after the ballot instead.
1361
+ const [ballot, space, ...rest] = lead.c;
1362
+ itemBlocks = [
1363
+ { t: lead.t, c: [ballot, space, P.Span([id, classes, marked], rest)] },
1364
+ ...itemBlocks.slice(1),
1365
+ ];
1366
+ }
1367
+ else {
1368
+ itemBlocks = [P.Div([id, classes, marked], itemBlocks)];
1369
+ }
1157
1370
  }
1158
1371
  return itemBlocks;
1159
1372
  });
1160
1373
  if (n.ordered) {
1161
1374
  const style = OL_TYPE[String(n.olType ?? '')] ?? 'Decimal';
1162
- // `delim` lands in the Carve AST with carve-js PR 342; older parsers
1163
- // simply leave it undefined and we emit pandoc's default.
1164
- const delim = n.delim === ')' ? 'OneParen' : n.delim === '.' ? 'Period' : 'DefaultDelim';
1375
+ // AST contract 1.1 omits the default `.` delimiter, so absence means Period.
1376
+ const delim = n.delim === ')' ? 'OneParen' : 'Period';
1165
1377
  return P.OrderedList(Number(n.start ?? 1), style, converted, delim);
1166
1378
  }
1167
1379
  return P.BulletList(converted);
@@ -1270,6 +1482,11 @@ const ALIGN = {
1270
1482
  * origin transitively, bump the origin's span, and emit only origin cells.
1271
1483
  */
1272
1484
  function table(ctx, n, caption, shortCaption = null) {
1485
+ const valigned = (n.columns ?? []).some((c) => c?.valign)
1486
+ || (n.rows ?? []).some((r) => r?.cells?.some((c) => c?.valign));
1487
+ if (valigned) {
1488
+ warn(ctx, "table: vertical alignment is dropped - pandoc's Table has no slot for it", { construct: 'table' }, n.pos);
1489
+ }
1273
1490
  if (!caption && Array.isArray(n.caption)) {
1274
1491
  ctx.captionKind = captionLabel(n.caption);
1275
1492
  caption = inlines(ctx, n.caption);
@@ -1289,7 +1506,10 @@ function table(ctx, n, caption, shortCaption = null) {
1289
1506
  const groups = read.groups;
1290
1507
  // Split leading all-header rows into the table head.
1291
1508
  let headCount = 0;
1292
- while (headCount < rows.length && rows[headCount].length > 0 && rows[headCount].every((c) => c.header)) {
1509
+ // A span marker carries no header flag of its own, as in the engine's walk.
1510
+ while (headCount < rows.length
1511
+ && rows[headCount].some((c) => c.span === undefined)
1512
+ && rows[headCount].every((c) => c.header || c.span !== undefined)) {
1293
1513
  headCount++;
1294
1514
  }
1295
1515
  if (groups)
@@ -1354,8 +1574,15 @@ function table(ctx, n, caption, shortCaption = null) {
1354
1574
  const declared = ALIGN[columns[c]?.align ?? ''];
1355
1575
  if (declared)
1356
1576
  return declared;
1357
- const cell = firstRow[c];
1358
- return firstRowIsHead && cell ? ALIGN[cell.align ?? ''] ?? 'AlignDefault' : 'AlignDefault';
1577
+ if (!firstRowIsHead)
1578
+ return 'AlignDefault';
1579
+ // The last header row that marks the column sets its default, as in the engine.
1580
+ for (let r = headCount - 1; r >= 0; r--) {
1581
+ const marked = rows[r][c]?.align;
1582
+ if (marked)
1583
+ return ALIGN[marked] ?? 'AlignDefault';
1584
+ }
1585
+ return 'AlignDefault';
1359
1586
  });
1360
1587
  const origin = rows.map(() => []);
1361
1588
  const emitted = rows.map((row) => row.map(() => null));
@@ -1363,6 +1590,9 @@ function table(ctx, n, caption, shortCaption = null) {
1363
1590
  const row = rows[r];
1364
1591
  for (let c = 0; c < row.length; c++) {
1365
1592
  const cc = row[c];
1593
+ // Already claimed by the strip of a `^` to its left.
1594
+ if (origin[r][c])
1595
+ continue;
1366
1596
  // Pandoc omits covered positions entirely, so a continuation that
1367
1597
  // RESOLVES leaves nowhere to hang its attributes. The grammar
1368
1598
  // cannot produce that shape (a cell carrying attributes is never a
@@ -1377,11 +1607,10 @@ function table(ctx, n, caption, shortCaption = null) {
1377
1607
  };
1378
1608
  if (cc.span === 'colspan') {
1379
1609
  const org = origin[r][c - 1];
1380
- if (org) {
1381
- // max() keeps 2D blocks correct: interior continuations of
1382
- // a lower row must not widen the origin again.
1383
- if (r === org.row)
1384
- org.cell.colSpan = Math.max(org.cell.colSpan, c - org.col + 1);
1610
+ // Widening a cell that started in an earlier row would cover
1611
+ // cells already placed above (PART 9 section 13 T5).
1612
+ if (org && org.row === r) {
1613
+ org.cell.colSpan = Math.max(org.cell.colSpan, c - org.col + 1);
1385
1614
  origin[r][c] = org;
1386
1615
  droppedAttrs();
1387
1616
  continue;
@@ -1389,7 +1618,14 @@ function table(ctx, n, caption, shortCaption = null) {
1389
1618
  warn(ctx, `table: colspan continuation at row ${r + 1}, col ${c + 1} has no origin - emitting empty cell`);
1390
1619
  }
1391
1620
  else if (cc.span === 'rowspan') {
1392
- const org = r > 0 ? origin[r - 1][c] : undefined;
1621
+ const above = r > 0 ? origin[r - 1][c] : undefined;
1622
+ // T5: only the origin's own column extends it, and only when the
1623
+ // rest of its strip in this row is markers, so a span never
1624
+ // covers another cell.
1625
+ const org = above && above.col === c
1626
+ && row.slice(c + 1, c + above.cell.colSpan).every((next, k) => next.span !== undefined && !origin[r][c + 1 + k])
1627
+ ? above
1628
+ : undefined;
1393
1629
  if (org) {
1394
1630
  // POLICY: clip, do not restructure. Carve is the richer
1395
1631
  // model here - its rows are one flat list, so a rowspan may
@@ -1411,7 +1647,8 @@ function table(ctx, n, caption, shortCaption = null) {
1411
1647
  // max(): a 2D block has one rowspan continuation per
1412
1648
  // covered column - count rows, not continuations.
1413
1649
  org.cell.rowSpan = Math.max(org.cell.rowSpan, r - org.row + 1);
1414
- origin[r][c] = org;
1650
+ for (let k = c; k < c + org.cell.colSpan && k < row.length; k++)
1651
+ origin[r][k] = org;
1415
1652
  droppedAttrs();
1416
1653
  continue;
1417
1654
  }
@@ -1428,9 +1665,10 @@ function table(ctx, n, caption, shortCaption = null) {
1428
1665
  // A head cell's marker became the ColSpec above, so repeating it
1429
1666
  // here would emit an override pandoc does not need and the source
1430
1667
  // never had.
1431
- const cellAlign = firstRowIsHead && r === 0
1668
+ const ownAlign = ALIGN[cc.align ?? ''] ?? 'AlignDefault';
1669
+ const cellAlign = firstRowIsHead && r === 0 && ownAlign === colAligns[c]
1432
1670
  ? 'AlignDefault'
1433
- : ALIGN[cc.align ?? ''] ?? 'AlignDefault';
1671
+ : ownAlign;
1434
1672
  const pc = P.cell(cellBlocks, cellAlign, toAttr(ctx, cc.attrs));
1435
1673
  origin[r][c] = { cell: pc, row: r, col: c };
1436
1674
  emitted[r][c] = pc;
@@ -2097,6 +2335,42 @@ function scalarValue(ctx, key, raw) {
2097
2335
  }
2098
2336
  return P.MetaInlines(textInlines(value));
2099
2337
  }
2338
+ /**
2339
+ * Trojan-Source controls (U+202A-202E, U+2066-2069), mirrored from the
2340
+ * engine, which strips them from every rendered text and code run but keeps
2341
+ * LRM/RLM. Raw passthrough is left alone, as the engine leaves it.
2342
+ */
2343
+ const BIDI_CONTROLS = /[\u202a-\u202e\u2066-\u2069]/gu;
2344
+ /** Strip {@link BIDI_CONTROLS} in place from Str, Code, CodeBlock and Math; returns the count. */
2345
+ function stripBidi(value) {
2346
+ let count = 0;
2347
+ const strip = (s) => s.replace(BIDI_CONTROLS, () => {
2348
+ count++;
2349
+ return '';
2350
+ });
2351
+ const stack = [value];
2352
+ while (stack.length) {
2353
+ const v = stack.pop();
2354
+ if (Array.isArray(v)) {
2355
+ for (const x of v)
2356
+ stack.push(x);
2357
+ continue;
2358
+ }
2359
+ if (!v || typeof v !== 'object')
2360
+ continue;
2361
+ const node = v;
2362
+ if (node.t === 'Str' && typeof node.c === 'string')
2363
+ node.c = strip(node.c);
2364
+ else if ((node.t === 'Code' || node.t === 'CodeBlock' || node.t === 'Math') && Array.isArray(node.c)
2365
+ && typeof node.c[1] === 'string')
2366
+ node.c[1] = strip(node.c[1]);
2367
+ else if (node.t === 'RawInline' || node.t === 'RawBlock')
2368
+ continue;
2369
+ for (const x of Object.values(node))
2370
+ stack.push(x);
2371
+ }
2372
+ return count;
2373
+ }
2100
2374
  // --- Entry point ---
2101
2375
  export function convert(ast, options = {}) {
2102
2376
  // PART 12 section 7: frontmatter and footnote DEFINITIONS are block nodes
@@ -2125,6 +2399,7 @@ export function convert(ast, options = {}) {
2125
2399
  footnoteDefs,
2126
2400
  crossrefTargets: new Map(),
2127
2401
  headingIdByText: new Map(),
2402
+ headingIds: resolvedHeadingIds(ast),
2128
2403
  captionTargets: new Set(),
2129
2404
  tight: false,
2130
2405
  inCrossref: false,
@@ -2136,6 +2411,10 @@ export function convert(ast, options = {}) {
2136
2411
  symbols: options.symbols ?? {},
2137
2412
  listTable: options.listTable ?? true,
2138
2413
  noteCount: 0,
2414
+ noteDepth: 0,
2415
+ openNotes: new Set(),
2416
+ citedNotes: new Set(),
2417
+ liftedNotes: [],
2139
2418
  parseBlocks: options.parseBlocks,
2140
2419
  blockScalars: new Map(),
2141
2420
  };
@@ -2145,12 +2424,17 @@ export function convert(ast, options = {}) {
2145
2424
  // sits relative to its target.
2146
2425
  collectCrossrefTargets(ctx, body, new Map());
2147
2426
  const meta = parseMeta(ctx, frontmatter);
2427
+ const converted = blocks(ctx, body);
2428
+ const stripped = stripBidi(meta) + stripBidi(converted);
2429
+ if (stripped > 0) {
2430
+ warn(ctx, `bidi: ${stripped} bidi override or isolate control(s) stripped from text and code, as the engine's writers strip them`, { count: stripped });
2431
+ }
2148
2432
  const diagnostics = ctx.diagnostics;
2149
2433
  return {
2150
2434
  doc: {
2151
2435
  'pandoc-api-version': [...P.PANDOC_API_VERSION],
2152
2436
  meta,
2153
- blocks: blocks(ctx, body),
2437
+ blocks: converted,
2154
2438
  },
2155
2439
  warnings: ctx.warnings,
2156
2440
  diagnostics,
@@ -2201,7 +2485,14 @@ function collectCrossrefTargets(ctx, nodes, captionCounts, inPanel = false) {
2201
2485
  if (n.type === 'heading') {
2202
2486
  const children = n.children ?? [];
2203
2487
  const a = (n.attrs ?? {});
2204
- const id = a.id ?? slugify(plainText(children));
2488
+ let id = a.id ?? ctx.headingIds.get(n);
2489
+ if (id === undefined) {
2490
+ const base = slugify(plainText(children));
2491
+ id = base;
2492
+ for (let k = 2; [...ctx.headingIds.values()].includes(id); k++)
2493
+ id = `${base}-${k}`;
2494
+ }
2495
+ ctx.headingIds.set(n, id);
2205
2496
  if (id && !ctx.crossrefTargets.has(id))
2206
2497
  ctx.crossrefTargets.set(id, children);
2207
2498
  const text = headingTextKey(plainText(children));
@@ -2280,6 +2571,9 @@ function figureGroupTargets(ctx, n, captionCounts, inPanel) {
2280
2571
  const letter = panelLetter(panelIndex++);
2281
2572
  if (resolved && a.id && !ctx.crossrefTargets.has(a.id)) {
2282
2573
  ctx.crossrefTargets.set(a.id, [{ type: 'text', value: `${resolved}${letter}` }]);
2574
+ // A panel caption holds no `#` either, so the crossref has to
2575
+ // cross as the plain link it renders as (see `captionTargets`).
2576
+ ctx.captionTargets.add(a.id);
2283
2577
  }
2284
2578
  collectCrossrefTargets(ctx, [child], captionCounts, true);
2285
2579
  }
@@ -2,7 +2,8 @@
2
2
  const RULES = [
3
3
  { test: /^section:/, code: 'structure-unspellable', class: 'degraded' },
4
4
  { test: /^table: rowGroups\.(?:headAttrs|footAttrs|bodies\[\d+\]\.attrs) is dropped/, code: 'field-unspellable', class: 'lossy' },
5
- { test: /^code block: a blank-only payload/, code: 'field-unspellable', class: 'lossy' },
5
+ { test: /^(code|raw) block: a blank-only payload/, code: 'field-unspellable', class: 'lossy' },
6
+ { test: /^code: a line break inside a code span/, code: 'field-unspellable', class: 'degraded' },
6
7
  { test: /^comment:/, code: 'comment-dropped', class: 'lossy' },
7
8
  { test: /^inline: unknown node type/, code: 'unknown-carve-inline', class: 'degraded' },
8
9
  { test: /^block: unknown node type/, code: 'unknown-carve-block', class: 'degraded' },
@@ -25,8 +26,10 @@ const RULES = [
25
26
  { test: /^definition list:/, code: 'definition-entry-skipped', class: 'lossy' },
26
27
  { test: /^ordered list: pandoc's example-list numbering/, code: 'ordered-list-counter-degraded', class: 'degraded' },
27
28
  { test: /^ordered list:/, code: 'ordered-list-marker-normalized', class: 'normalized' },
29
+ { test: /^list: a tight item's second paragraph/, code: 'list-item-framing-lost', class: 'degraded' },
28
30
  { test: /^list: an empty item/, code: 'empty-list-item-spelled', class: 'normalized' },
29
31
  { test: /^task state:/, code: 'task-state-dropped', class: 'lossy' },
32
+ { test: /^bidi: /, code: 'bidi-control-stripped', class: 'normalized' },
30
33
  { test: /^math: attributes/, code: 'math-attributes-dropped', class: 'lossy' },
31
34
  { test: /^symbol:/, code: 'symbol-unresolved', class: 'degraded' },
32
35
  { test: /^url: a denied scheme/, code: 'unsafe-url-scheme', class: 'lossy' },
@@ -35,6 +38,14 @@ const RULES = [
35
38
  { test: /^extension: ruby/, code: 'ruby-flattened', class: 'degraded' },
36
39
  { test: /^extension: block extension/, code: 'block-extension-fallback-rendered', class: 'degraded' },
37
40
  { test: /^extension:/, code: 'inline-extension-degraded', class: 'degraded' },
41
+ { test: /^footnote: a cyclic reference/, code: 'nested-note-cycle-dropped', class: 'lossy' },
42
+ { test: /^footnote: a note referenced inside a note is lifted/, code: 'nested-note-lifted', class: 'degraded' },
43
+ { test: /^link: an empty destination/, code: 'link-empty-destination-dropped', class: 'lossy' },
44
+ { test: /^(?:link|image): an empty title is dropped/, code: 'empty-title-dropped', class: 'lossy' },
45
+ { test: /^attribute: the attributes on a /, code: 'inline-attributes-dropped', class: 'lossy' },
46
+ { test: /^footnote: \[\^[^\]]*\] is referenced more than once/, code: 'note-reference-duplicated', class: 'degraded' },
47
+ { test: /^code block: a class without a language/, code: 'code-block-class-read-as-language', class: 'degraded' },
48
+ { test: /^line block: a hard break/, code: 'line-block-hard-break-dropped', class: 'lossy' },
38
49
  { test: /^(?:link|image|footnote): missing definition/, code: 'reference-unresolved', class: 'degraded' },
39
50
  { test: /^crossref:/, code: 'crossref-unresolved', class: 'degraded' },
40
51
  { test: /^list-table: structure/, code: 'list-table-structure-degraded', class: 'degraded' },
@@ -52,12 +63,13 @@ const RULES = [
52
63
  { test: /^table: .*padded with empty cells/, code: 'table-groups-normalized', class: 'degraded' },
53
64
  { test: /^table: a cell holds block content/, code: 'table-groups-normalized', class: 'normalized' },
54
65
  { test: /^table: the merged id/, code: 'table-wrapper-id-dropped', class: 'lossy' },
55
- { test: /^table: a blank row has no pipe-table source spelling/, code: 'table-blank-row-spelled', class: 'normalized' },
56
66
  { test: /^table: .*converted with the implicit head\/body split/, code: 'table-row-groups-invalid', class: 'degraded' },
57
67
  { test: /^table: .*preserved in the Carve AST as `rowGroups`/, code: 'table-groups-flattened', class: 'degraded' },
58
68
  { test: /^table: a foot row's row header is dropped/, code: 'table-foot-row-header-dropped', class: 'lossy' },
59
69
  { test: /^table: the body rows disagree on how many leading cells/, code: 'table-row-heads-degraded', class: 'degraded' },
60
70
  { test: /^table: the rows of a declared body group disagree/, code: 'table-row-heads-degraded', class: 'degraded' },
71
+ { test: /^table: a hard break inside a cell/, code: 'table-cell-break-flattened', class: 'lossy' },
72
+ { test: /^table: vertical alignment/, code: 'table-vertical-alignment-dropped', class: 'lossy' },
61
73
  { test: /^table:/, code: 'table-unclassified-loss', class: 'unsupported' },
62
74
  ];
63
75
  export function diagnostic(direction, message, details, sourceLocation) {
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Nesting limits checked before a Pandoc document is imported: the reverse
3
+ * direction recurses per node, and the engine's Carve writer slows down steeply
4
+ * on deeply nested loose lists, so either could crash or hang the CLI.
5
+ */
6
+ import type { ConversionDiagnostic } from './diagnostics.js';
7
+ /** Deepest Pandoc node nesting accepted: the engine's own render cap. */
8
+ export declare const MAX_PANDOC_DEPTH: number;
9
+ /** Deepest list nesting accepted. */
10
+ export declare const MAX_LIST_DEPTH = 32;
11
+ export declare class PandocImportLimitError extends Error {
12
+ readonly diagnostic: ConversionDiagnostic;
13
+ constructor(kind: 'nodes' | 'lists', depth: number, limit: number);
14
+ }
15
+ /** Throw {@link PandocImportLimitError} when `doc` nests past a limit. Iterative, so it cannot overflow itself. */
16
+ export declare function checkImportLimits(doc: unknown): void;
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Nesting limits checked before a Pandoc document is imported: the reverse
3
+ * direction recurses per node, and the engine's Carve writer slows down steeply
4
+ * on deeply nested loose lists, so either could crash or hang the CLI.
5
+ */
6
+ import { MAX_RENDER_DEPTH } from '@markup-carve/carve';
7
+ /** Deepest Pandoc node nesting accepted: the engine's own render cap. */
8
+ export const MAX_PANDOC_DEPTH = MAX_RENDER_DEPTH;
9
+ /** Deepest list nesting accepted. */
10
+ export const MAX_LIST_DEPTH = 32;
11
+ const LISTS = new Set(['BulletList', 'OrderedList', 'DefinitionList']);
12
+ export class PandocImportLimitError extends Error {
13
+ diagnostic;
14
+ constructor(kind, depth, limit) {
15
+ const what = kind === 'lists' ? 'lists nest' : 'nodes nest';
16
+ const message = `pandoc document refused: ${what} ${depth} levels deep, past the import limit of ${limit}`;
17
+ super(message);
18
+ this.name = 'PandocImportLimitError';
19
+ this.diagnostic = {
20
+ code: 'pandoc-nesting-too-deep',
21
+ direction: 'pandoc-to-carve',
22
+ class: 'unsupported',
23
+ severity: 'error',
24
+ fidelity: 'dropped',
25
+ confidence: 'exact',
26
+ message,
27
+ details: { kind, depth, limit },
28
+ };
29
+ }
30
+ }
31
+ /** Throw {@link PandocImportLimitError} when `doc` nests past a limit. Iterative, so it cannot overflow itself. */
32
+ export function checkImportLimits(doc) {
33
+ const stack = [[doc, 0, 0]];
34
+ while (stack.length) {
35
+ const [value, depth, lists] = stack.pop();
36
+ if (Array.isArray(value)) {
37
+ for (const item of value)
38
+ stack.push([item, depth, lists]);
39
+ continue;
40
+ }
41
+ if (!value || typeof value !== 'object')
42
+ continue;
43
+ const t = value.t;
44
+ const isNode = typeof t === 'string';
45
+ const nextDepth = isNode ? depth + 1 : depth;
46
+ const nextLists = isNode && LISTS.has(t) ? lists + 1 : lists;
47
+ if (nextDepth > MAX_PANDOC_DEPTH)
48
+ throw new PandocImportLimitError('nodes', nextDepth, MAX_PANDOC_DEPTH);
49
+ if (nextLists > MAX_LIST_DEPTH)
50
+ throw new PandocImportLimitError('lists', nextLists, MAX_LIST_DEPTH);
51
+ for (const child of Object.values(value))
52
+ stack.push([child, nextDepth, nextLists]);
53
+ }
54
+ }
package/dist/index.d.ts CHANGED
@@ -5,6 +5,7 @@ import type { PandocDoc } from './pandoc.js';
5
5
  import type { ConversionDiagnostic } from './diagnostics.js';
6
6
  import { type MigrationReport } from './diagnostics.js';
7
7
  export { PANDOC_API_VERSION, type PandocDoc } from './pandoc.js';
8
+ export { PandocImportLimitError, MAX_PANDOC_DEPTH, MAX_LIST_DEPTH } from './import-limits.js';
8
9
  export type { ConvertOptions, ConvertResult } from './convert.js';
9
10
  export type { ParseOptions } from './parse-options.js';
10
11
  export type { ReverseResult } from './reverse.js';
package/dist/index.js CHANGED
@@ -3,8 +3,10 @@ import { normalizeCarveAst, parseCarveAst, toCarveAst } from './ast-json.js';
3
3
  import { convert } from './convert.js';
4
4
  import { parseExtensions } from './parse-options.js';
5
5
  import { pandocToCarve as reverse } from './reverse.js';
6
- import { migrationReport } from './diagnostics.js';
6
+ import { diagnostic, migrationReport } from './diagnostics.js';
7
+ import { checkImportLimits, PandocImportLimitError, MAX_PANDOC_DEPTH } from './import-limits.js';
7
8
  export { PANDOC_API_VERSION } from './pandoc.js';
9
+ export { PandocImportLimitError, MAX_PANDOC_DEPTH, MAX_LIST_DEPTH } from './import-limits.js';
8
10
  /**
9
11
  * The engine's own serializer, when the installed engine has one.
10
12
  *
@@ -228,14 +230,55 @@ export function carveToPandocJson(source, options) {
228
230
  */
229
231
  export function pandocToCarve(doc) {
230
232
  const parsed = typeof doc === 'string' ? JSON.parse(doc) : doc;
233
+ checkImportLimits(parsed);
231
234
  const { ast, warnings, diagnostics } = reverse(parsed);
232
235
  return {
233
- carve: carve.renderCarve(engineRenderTree(ast)),
236
+ carve: writeCarve(ast, warnings, diagnostics),
234
237
  warnings,
235
238
  diagnostics,
236
239
  report: migrationReport(diagnostics),
237
240
  };
238
241
  }
242
+ function writeCarve(ast, warnings, diagnostics) {
243
+ const write = (tree) => carve.renderCarve(engineRenderTree(tree));
244
+ try {
245
+ return write(ast);
246
+ }
247
+ catch (error) {
248
+ // The Carve tree can nest deeper than the Pandoc one (a list item is a
249
+ // node of its own), so the engine's cap can still be reached here.
250
+ if (error instanceof carve.RenderDepthError) {
251
+ throw new PandocImportLimitError('nodes', MAX_PANDOC_DEPTH + 1, MAX_PANDOC_DEPTH);
252
+ }
253
+ // The writer cannot spell some multiline code spans (a line block is
254
+ // one). Pandoc's own writers treat a newline in `Code` as a space, so
255
+ // fold it and say so rather than failing the whole document.
256
+ if (!(error instanceof Error) || !/cannot spell code/.test(error.message))
257
+ throw error;
258
+ let folded = false;
259
+ const fold = (node) => {
260
+ if (Array.isArray(node))
261
+ return node.map(fold);
262
+ if (!node || typeof node !== 'object')
263
+ return node;
264
+ const out = {};
265
+ for (const [key, value] of Object.entries(node))
266
+ out[key] = fold(value);
267
+ if (out['type'] === 'code' && typeof out['value'] === 'string' && /[\r\n]/.test(out['value'])) {
268
+ out['value'] = out['value'].replace(/\r\n?|\n/g, ' ');
269
+ folded = true;
270
+ }
271
+ return out;
272
+ };
273
+ const retried = fold(ast);
274
+ if (!folded)
275
+ throw error;
276
+ const message = 'code: a line break inside a code span has no Carve spelling here - written as a space';
277
+ warnings.push(message);
278
+ diagnostics.push(diagnostic('pandoc-to-carve', message));
279
+ return write(retried);
280
+ }
281
+ }
239
282
  /**
240
283
  * Convert Pandoc to the canonical Carve exchange AST without forcing it through
241
284
  * Carve 0.1 source. This preserves structural fields, notably Pandoc's optional
@@ -243,6 +286,7 @@ export function pandocToCarve(doc) {
243
286
  */
244
287
  export function pandocToCarveAst(doc) {
245
288
  const parsed = typeof doc === 'string' ? JSON.parse(doc) : doc;
289
+ checkImportLimits(parsed);
246
290
  const { ast, warnings, diagnostics } = reverse(parsed, 'ast');
247
291
  return { ast: toCarveAst(ast, engineSerializer), warnings, diagnostics, report: migrationReport(diagnostics) };
248
292
  }
package/dist/reverse.js CHANGED
@@ -8,7 +8,7 @@
8
8
  * are recognized and restored to their native Carve nodes, which makes
9
9
  * carve -> pandoc -> carve round-trips clean.
10
10
  */
11
- import { parse as parseCarve, renderCarve } from '@markup-carve/carve';
11
+ import { parse as parseCarve, renderCarve, resolve } from '@markup-carve/carve';
12
12
  import { diagnostic } from './diagnostics.js';
13
13
  import { readProvenance } from './provenance.js';
14
14
  /**
@@ -290,12 +290,16 @@ function inline(ctx, n) {
290
290
  // accept both, because the type string is a single value. That is
291
291
  // why this waited for the lockfile to move rather than emitting
292
292
  // the new names defensively.
293
- if (noteBlocks.length === 1 && (noteBlocks[0].t === 'Para' || noteBlocks[0].t === 'Plain')) {
293
+ // An inline note cannot hold another (`^[a ^[b]]` renders the inner
294
+ // one as literal text), so a note with a note inside is written as
295
+ // a definition, where a nested note does resolve.
296
+ if (noteBlocks.length === 1 && (noteBlocks[0].t === 'Para' || noteBlocks[0].t === 'Plain')
297
+ && !containsNote(noteBlocks[0].c)) {
294
298
  return [{ type: 'inline_footnote', inline: inlines(ctx, noteBlocks[0].c) }];
295
299
  }
296
300
  const id = `fn${++ctx.noteCounter}`;
297
301
  ctx.footnoteDefs[id] = blocks(ctx, noteBlocks);
298
- return [{ type: 'footnote_ref', id }];
302
+ return [{ type: 'footnote_ref', label: id }];
299
303
  }
300
304
  case 'Span':
301
305
  {
@@ -319,7 +323,7 @@ function inline(ctx, n) {
319
323
  function link(ctx, c) {
320
324
  const [a, xs, [href, title]] = c;
321
325
  const [, classes] = a;
322
- if (classes.includes('uri') || classes.includes('email')) {
326
+ if ((classes.includes('uri') || classes.includes('email')) && autolinkParses(href, stringify(xs))) {
323
327
  const node = { type: 'autolink', href, text: stringify(xs) };
324
328
  // `uri`/`email` is the class the forward direction SYNTHESIZES to say
325
329
  // what kind of link this is, so it is not the author's and does not
@@ -334,14 +338,39 @@ function link(ctx, c) {
334
338
  if (classes.includes('crossref')) {
335
339
  return [{ type: 'heading_ref', target: href.replace(/^#/, '') }];
336
340
  }
341
+ if (href === '') {
342
+ // `[text]()` is literal text in Carve, and `[text](<>)` links to `<>`.
343
+ warn(ctx, 'link: an empty destination has no Carve spelling - the link text is kept without the link');
344
+ return inlines(ctx, xs);
345
+ }
337
346
  const node = { type: 'link', href, children: inlines(ctx, xs) };
338
347
  if (title)
339
348
  node.title = title;
340
- const attrs = fromAttr(a);
349
+ // An autolink the engine cannot spell arrives here; its class is synthesized.
350
+ const label = stringify(xs);
351
+ const autolinkShaped = href === label || href === `mailto:${label}`;
352
+ const attrs = fromAttr([a[0], autolinkShaped ? classes.filter((c) => c !== 'uri' && c !== 'email') : classes, a[2]]);
341
353
  if (attrs)
342
354
  node.attrs = attrs;
343
355
  return [node];
344
356
  }
357
+ /**
358
+ * Whether `<text>` reads back as an autolink to `href`. The engine's autolink
359
+ * grammar is narrower than pandoc's (an internationalized address is one it
360
+ * rejects), and such a link is written as an ordinary link instead.
361
+ */
362
+ function autolinkParses(href, text) {
363
+ try {
364
+ const doc = parseCarve(`<${text}>`);
365
+ const only = doc.children?.[0]?.children;
366
+ // An empty href is a denied scheme the forward direction blanked; the
367
+ // engine blanks it again when it renders the autolink.
368
+ return only?.length === 1 && only[0]?.type === 'autolink' && (href === '' || only[0]?.href === href);
369
+ }
370
+ catch {
371
+ return false;
372
+ }
373
+ }
345
374
  /** Restore convert.ts's classed-span degradations to native Carve nodes. */
346
375
  function span(ctx, c) {
347
376
  const [a, xs] = c;
@@ -567,7 +596,6 @@ function blocks(ctx, xs) {
567
596
  out.push(...block(ctx, x));
568
597
  return out;
569
598
  }
570
- const KNOWN_ADMONITIONS = new Set(['note', 'tip', 'warning', 'danger', 'info', 'caution', 'important']);
571
599
  function block(ctx, n) {
572
600
  const c = n.c;
573
601
  switch (n.t) {
@@ -642,7 +670,11 @@ function block(ctx, n) {
642
670
  }
643
671
  case 'RawBlock': {
644
672
  const [format, content] = c;
645
- return [{ type: 'raw_block', format, content }];
673
+ // One final break, as a parsed fence carries (`CARVE-P12-064`).
674
+ // Unlike `CodeBlock`, pandoc's readers disagree here: markdown and
675
+ // rst publish `"a"`, gfm, commonmark_x, djot and org `"a\n"`.
676
+ const ends = content === '' || content.endsWith('\n');
677
+ return [{ type: 'raw_block', format, content: ends ? content : content + '\n' }];
646
678
  }
647
679
  case 'HorizontalRule':
648
680
  return [{ type: 'thematic_break' }];
@@ -678,14 +710,28 @@ function listItems(ctx, items) {
678
710
  let source = item;
679
711
  let attrs;
680
712
  let taskState = null;
681
- if (item.length === 1 && item[0]?.t === 'Div') {
682
- const [attr, inner] = item[0].c;
683
- if (attr[2].some(([key, value]) => key === 'carve-list-item' && value === 'true')) {
684
- taskState = readTaskState(attr[2]);
685
- attrs = fromAttr([attr[0], attr[1], attr[2].filter(([key]) => key !== 'carve-list-item' && key !== TASK_STATE_KEY)]);
713
+ const isEnvelope = (attr) => attr[2].some(([key, value]) => key === 'carve-list-item' && value === 'true');
714
+ const readEnvelope = (attr) => {
715
+ taskState = readTaskState(attr[2]);
716
+ attrs = fromAttr([attr[0], attr[1], attr[2].filter(([key]) => key !== 'carve-list-item' && key !== TASK_STATE_KEY)]);
717
+ };
718
+ const lead = item[0];
719
+ const leadInlines = lead && (lead.t === 'Plain' || lead.t === 'Para') ? lead.c : undefined;
720
+ const span = leadInlines?.length === 3 && leadInlines[0]?.t === 'Str' && /^[☐☒]$/.test(String(leadInlines[0].c))
721
+ && leadInlines[1]?.t === 'Space' && leadInlines[2]?.t === 'Span' ? leadInlines[2] : undefined;
722
+ if (item.length === 1 && lead?.t === 'Div') {
723
+ const [attr, inner] = lead.c;
724
+ if (isEnvelope(attr)) {
725
+ readEnvelope(attr);
686
726
  source = inner;
687
727
  }
688
728
  }
729
+ else if (span && isEnvelope(span.c[0])) {
730
+ // A task item's envelope rides on a Span after its ballot.
731
+ const [attr, inner] = span.c;
732
+ readEnvelope(attr);
733
+ source = [{ t: lead.t, c: [leadInlines[0], leadInlines[1], ...inner] }, ...item.slice(1)];
734
+ }
689
735
  if (source.some((b) => b.t === 'Para'))
690
736
  tight = false;
691
737
  const children = blocks(ctx, source);
@@ -781,12 +827,9 @@ function orderedList(ctx, c) {
781
827
  const olType = OL_STYLE[style.t];
782
828
  if (olType)
783
829
  node.olType = olType;
784
- // Source-style metadata for AST consumers; renderCarve currently
785
- // normalizes to `1.` regardless (fmt canonical form).
830
+ // AST contract 1.1 records only the non-default `)`; `.` is absence.
786
831
  if (delim?.t === 'OneParen' || delim?.t === 'TwoParens')
787
832
  node.delim = ')';
788
- else if (delim?.t === 'Period')
789
- node.delim = '.';
790
833
  // Pandoc's `Example` list is `(@)` numbering: ONE counter shared by every
791
834
  // example list in the document, and items can be labelled and referenced
792
835
  // from prose. PART 12 section 6 admits `a`/`A`/`i`/`I` and decimal, so the
@@ -984,7 +1027,8 @@ function table(ctx, c, captionOverride, shortCaptionOverride = null) {
984
1027
  // Row-head columns no longer send a table to the list-table form: the pipe
985
1028
  // table marks those cells directly (see `rowHeadCols` above), which is
986
1029
  // readable, needs no extension, and round-trips. Block cells need the
987
- // list-table form, as do blank rows without attributes or alignment.
1030
+ // list-table form. A wholly blank row is a pipe-table row since the spec
1031
+ // ruled an explicitly delimited row of blank cells a table (corpus 589).
988
1032
  //
989
1033
  // A FOOT USED TO BE A SECOND REASON, AND IT WAS A REAL ONE AT THE TIME.
990
1034
  // The clause read `|| (ctx.target === 'source' && footRaw.length > 0)`.
@@ -1004,25 +1048,7 @@ function table(ctx, c, captionOverride, shortCaptionOverride = null) {
1004
1048
  // (corpus 376, spec f074372), so at the time the pipe form genuinely could
1005
1049
  // not carry a foot and the list-table detour was the only way to keep it.
1006
1050
  // The limitation is gone, so the detour is too.
1007
- const occupied = allRaw.map(() => []);
1008
- const hasBlankRows = allRaw.some((row, r) => {
1009
- let col = 0;
1010
- let blank = nCols > 0;
1011
- for (const raw of row[1]) {
1012
- while (occupied[r][col])
1013
- col++;
1014
- const [attr, align, height, width, content] = raw;
1015
- if (width > 1 || height > 1 || content.length || fromAttr(attr) || align.t !== 'AlignDefault')
1016
- blank = false;
1017
- for (let down = 1; down < height && r + down < allRaw.length; down++) {
1018
- for (let across = 0; across < width; across++)
1019
- occupied[r + down][col + across] = true;
1020
- }
1021
- col += width;
1022
- }
1023
- return blank && !occupied[r].some(Boolean);
1024
- });
1025
- const useListTable = ctx.target === 'source' && (hasBlockCells || hasBlankRows);
1051
+ const useListTable = ctx.target === 'source' && hasBlockCells;
1026
1052
  const pending = allRaw.map(() => Array(nCols));
1027
1053
  const rows = [];
1028
1054
  // The pandoc blocks that sat at each grid position, kept alongside the
@@ -1110,7 +1136,6 @@ function table(ctx, c, captionOverride, shortCaptionOverride = null) {
1110
1136
  attrs: fromAttr(a),
1111
1137
  colAligns,
1112
1138
  colWidths: columns.map((column) => column.width),
1113
- blankRows: !hasBlockCells && hasBlankRows,
1114
1139
  });
1115
1140
  }
1116
1141
  const node = { type: 'table', rows };
@@ -1176,7 +1201,10 @@ function table(ctx, c, captionOverride, shortCaptionOverride = null) {
1176
1201
  && groupBodies.every((b) => b.bodyRows > 0)
1177
1202
  && adjacentRunsDiffer
1178
1203
  && !leadingRunFillsTheRow
1179
- && headRaw.length === 0 && footRaw.length === 0
1204
+ // A head is the leading run of fully marked rows, so it is safe beside
1205
+ // a collapse as long as the first body does not open with one
1206
+ // (`leadingRunFillsTheRow`). Corpus 589-31.
1207
+ && footRaw.length === 0
1180
1208
  && !headAttrs && !footAttrs;
1181
1209
  const collapsed = [{
1182
1210
  headRows: 0,
@@ -1373,9 +1401,7 @@ function isInlineShaped(cellBlocks) {
1373
1401
  */
1374
1402
  function listTable(ctx, input) {
1375
1403
  const { rows, cellBlocksAt, headRows, footRows, bodies, caption, attrs, colAligns, colWidths } = input;
1376
- warn(ctx, input.blankRows
1377
- ? 'table: a blank row has no pipe-table source spelling - emitted as a `::: list-table` (structure preserved)'
1378
- : 'table: a cell holds block content, which a pipe table cannot spell - emitted as a `::: list-table` (structure preserved)');
1404
+ warn(ctx, 'table: a cell holds block content, which a pipe table cannot spell - emitted as a `::: list-table` (structure preserved)');
1379
1405
  if (bodies.some((b) => b.attrs)) {
1380
1406
  warn(ctx, 'list-table: a body group\'s attributes are dropped - the extension has no body to hang them on');
1381
1407
  }
@@ -1493,12 +1519,27 @@ const bulletListOf = (items) => ({
1493
1519
  ordered: false,
1494
1520
  tight: items.every((item) => (item.children ?? []).length <= 1),
1495
1521
  items,
1496
- bulletChar: '-',
1497
1522
  });
1498
1523
  function cellInlines(ctx, cellBlocks) {
1499
1524
  // Block cells take the list-table path before this function is called.
1500
1525
  const first = cellBlocks[0];
1501
- return first ? mergeText(inlines(ctx, first.c)) : [];
1526
+ return first ? mergeText(flattenCellBreaks(ctx, inlines(ctx, first.c))) : [];
1527
+ }
1528
+ /**
1529
+ * A pipe-table row is one line, so a break inside a cell would end the row and
1530
+ * turn the whole table into a paragraph. A soft break is a space anyway; a hard
1531
+ * break has no spelling in a cell and is reported.
1532
+ */
1533
+ function flattenCellBreaks(ctx, nodes) {
1534
+ return nodes.map((n) => {
1535
+ if (n.type === 'soft_break')
1536
+ return text(' ');
1537
+ if (n.type === 'hard_break') {
1538
+ warn(ctx, 'table: a hard break inside a cell has no pipe-table spelling - written as a space');
1539
+ return text(' ');
1540
+ }
1541
+ return Array.isArray(n.children) ? { ...n, children: flattenCellBreaks(ctx, n.children) } : n;
1542
+ });
1502
1543
  }
1503
1544
  function captionFromBlocks(ctx, capBlocks) {
1504
1545
  if (!capBlocks?.length)
@@ -1736,13 +1777,12 @@ function div(ctx, c) {
1736
1777
  let kind;
1737
1778
  let rest = [];
1738
1779
  const wrapper = classes.includes('directive') ? 'directive' : classes.includes('admonition') ? 'admonition' : undefined;
1780
+ // Only the bridge's own `admonition`/`directive` marker makes one: a
1781
+ // pandoc Div classed `note` is an ordinary div that happens to use the word.
1739
1782
  if (wrapper) {
1740
1783
  rest = classes.filter((x) => x !== wrapper);
1741
1784
  kind = rest.shift();
1742
1785
  }
1743
- else if (classes.length === 1 && KNOWN_ADMONITIONS.has(classes[0])) {
1744
- kind = classes[0];
1745
- }
1746
1786
  if (kind) {
1747
1787
  const node = { type: wrapper === 'directive' ? 'directive' : 'admonition', kind, children: [] };
1748
1788
  let children = body;
@@ -1932,6 +1972,10 @@ export function pandocToCarve(doc, target = 'source') {
1932
1972
  const ast = { type: 'document', children };
1933
1973
  if (Object.keys(ctx.footnoteDefs).length)
1934
1974
  ast.footnoteDefs = ctx.footnoteDefs;
1975
+ dropDerivableHeadingIds(ast);
1976
+ if (target === 'source' && hasRejoinedFraming(children, false)) {
1977
+ warn(ctx, "list: a tight item's second paragraph inside an indented container is rejoined to its first by the Carve writer");
1978
+ }
1935
1979
  const yaml = metaToYaml(ctx, doc.meta ?? {});
1936
1980
  if (yaml)
1937
1981
  ast.frontmatter = { format: 'yaml', content: yaml };
@@ -1947,3 +1991,124 @@ function containsShortCaption(value) {
1947
1991
  return true;
1948
1992
  return Object.values(record).some(containsShortCaption);
1949
1993
  }
1994
+ const HEADING_TAG = 'data-pandoc-carve-heading';
1995
+ /**
1996
+ * Drop a heading id the engine would derive at that position anyway.
1997
+ *
1998
+ * The forward direction writes every heading's resolved id onto `Header`, so
1999
+ * without this a round trip would spell each one out as `{#id}`. Removal is
2000
+ * applied only when re-resolving proves every heading keeps its id; a pandoc
2001
+ * id that differs from the derived one stays explicit.
2002
+ */
2003
+ function dropDerivableHeadingIds(ast) {
2004
+ const headings = [];
2005
+ const walk = (value) => {
2006
+ if (Array.isArray(value))
2007
+ value.forEach(walk);
2008
+ else if (value && typeof value === 'object') {
2009
+ if (value.type === 'heading')
2010
+ headings.push(value);
2011
+ Object.values(value).forEach(walk);
2012
+ }
2013
+ };
2014
+ walk(ast);
2015
+ const explicit = headings.filter((h) => typeof h.attrs?.id === 'string');
2016
+ if (explicit.length === 0)
2017
+ return;
2018
+ const resolvedIds = (strip) => {
2019
+ const index = new Map(headings.map((h, i) => [h, i]));
2020
+ const copy = (value) => {
2021
+ if (Array.isArray(value))
2022
+ return value.map(copy);
2023
+ if (!value || typeof value !== 'object')
2024
+ return value;
2025
+ const out = {};
2026
+ for (const [k, v] of Object.entries(value))
2027
+ out[k] = copy(v);
2028
+ const at = index.get(value);
2029
+ if (at !== undefined) {
2030
+ const { id: _id, ...rest } = (out.attrs ?? {});
2031
+ const attrs = strip.has(value) ? rest : { ...rest, ...(_id ? { id: _id } : {}) };
2032
+ out.attrs = { ...attrs, keyValues: { ...(attrs.keyValues ?? {}), [HEADING_TAG]: String(at) } };
2033
+ }
2034
+ return out;
2035
+ };
2036
+ try {
2037
+ const ids = new Array(headings.length);
2038
+ const stack = [resolve(copy(ast))];
2039
+ while (stack.length) {
2040
+ const value = stack.pop();
2041
+ if (Array.isArray(value))
2042
+ stack.push(...value);
2043
+ else if (value && typeof value === 'object') {
2044
+ const node = value;
2045
+ const attrs = node.attrs;
2046
+ const tag = node.type === 'heading' ? attrs?.keyValues?.[HEADING_TAG] : undefined;
2047
+ if (tag !== undefined)
2048
+ ids[Number(tag)] = attrs?.id;
2049
+ stack.push(...Object.values(node));
2050
+ }
2051
+ }
2052
+ return ids;
2053
+ }
2054
+ catch {
2055
+ return null;
2056
+ }
2057
+ };
2058
+ const baseline = resolvedIds(new Set());
2059
+ const derived = resolvedIds(new Set(explicit));
2060
+ if (!baseline || !derived)
2061
+ return;
2062
+ const strip = new Set(explicit.filter((h) => derived[headings.indexOf(h)] === h.attrs.id));
2063
+ if (strip.size === 0)
2064
+ return;
2065
+ const after = resolvedIds(strip);
2066
+ if (!after || after.some((id, i) => id !== baseline[i]))
2067
+ return;
2068
+ for (const h of strip) {
2069
+ const { id: _id, ...rest } = h.attrs;
2070
+ const order = (rest.order ?? []).filter((o) => o !== '#id');
2071
+ if (order.length === 0)
2072
+ delete h.attrs;
2073
+ else
2074
+ h.attrs = { ...rest, order };
2075
+ }
2076
+ }
2077
+ function containsNote(value) {
2078
+ if (Array.isArray(value))
2079
+ return value.some(containsNote);
2080
+ if (!value || typeof value !== 'object')
2081
+ return false;
2082
+ if (value.t === 'Note')
2083
+ return true;
2084
+ return containsNote(value.c);
2085
+ }
2086
+ /**
2087
+ * Whether a tight list nested in a list item or a definition holds an item
2088
+ * with two adjacent paragraphs. The writer spells the second one as a `+`
2089
+ * continuation it places at the wrong column there, so it reads back joined
2090
+ * to the first.
2091
+ */
2092
+ function hasRejoinedFraming(nodes, indented) {
2093
+ if (!Array.isArray(nodes))
2094
+ return false;
2095
+ return nodes.some((value) => {
2096
+ if (!value || typeof value !== 'object')
2097
+ return false;
2098
+ const node = value;
2099
+ if (node.type === 'list') {
2100
+ const items = node.items ?? [];
2101
+ if (indented && node.tight && items.some((item) => {
2102
+ const kids = item.children ?? [];
2103
+ return kids.some((kid, i) => kid.type === 'paragraph' && kids[i + 1]?.type === 'paragraph');
2104
+ }))
2105
+ return true;
2106
+ return items.some((item) => hasRejoinedFraming(item.children, true));
2107
+ }
2108
+ if (node.type === 'definition_list') {
2109
+ const items = node.items ?? [];
2110
+ return items.some((item) => (item.definitions ?? []).some((d) => hasRejoinedFraming(d, true)));
2111
+ }
2112
+ return hasRejoinedFraming(node.children, indented);
2113
+ });
2114
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markup-carve/pandoc-carve",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "description": "Carve to Pandoc bridge: convert Carve markup to Pandoc's JSON AST and reach every pandoc output format (LaTeX, Typst, DOCX, PDF, ...)",
5
5
  "keywords": [
6
6
  "carve",
@@ -55,7 +55,7 @@
55
55
  "source": "https://github.com/markup-carve/pandoc-carve"
56
56
  },
57
57
  "dependencies": {
58
- "@markup-carve/carve": "^0.1.10"
58
+ "@markup-carve/carve": "^0.1.11"
59
59
  },
60
60
  "devDependencies": {
61
61
  "@types/node": "^24.0.0",