@markup-carve/pandoc-carve 0.1.6 → 0.1.8

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/README.md CHANGED
@@ -20,7 +20,7 @@ npm install @markup-carve/pandoc-carve
20
20
  ```
21
21
 
22
22
  The CLI requires Pandoc 3.x on `PATH` for formats other than JSON. CI currently
23
- tests Pandoc 3.11. Pandoc is not bundled.
23
+ tests Pandoc 3.11 and 3.10.2. Pandoc is not bundled.
24
24
 
25
25
  ## CLI
26
26
 
@@ -105,6 +105,13 @@ them. Highlights include:
105
105
  - raw target content only survives in matching writers;
106
106
  - comments and attribute placement require round-trip metadata.
107
107
 
108
+ These limits are measured rather than asserted.
109
+ [pandoc-format-fidelity](https://github.com/markup-carve/pandoc-format-fidelity) runs Carve
110
+ source through this bridge and back for every writer it lists, and publishes the verdicts per
111
+ format: `results/carve-rt.json` holds the Carve round-trip lane, and
112
+ [the report](https://github.com/markup-carve/pandoc-format-fidelity/blob/main/docs/index.html)
113
+ covers every pandoc writer and reader.
114
+
108
115
  Dangerous URL schemes such as `javascript:` are blanked and reported. The
109
116
  [URL policy](docs/reference.md#dangerous-url-schemes-are-blanked) documents the
110
117
  exact behavior. The [mapping and limitations reference](docs/reference.md#what-maps-to-what)
package/dist/convert.js CHANGED
@@ -452,8 +452,8 @@ function inLink(ctx, n) {
452
452
  * The heading a COLLAPSED reference reaches, or null.
453
453
  *
454
454
  * `[Some Heading][]` with no definition anywhere links to the heading whose
455
- * RENDERED text matches, case-insensitively - so `[plain one][]` reaches
456
- * `# Plain One`. The engine resolves this after the parse, which is why the
455
+ * RENDERED text matches exactly, case included - so `[Plain One][]` reaches
456
+ * `# Plain One` and `[plain one][]` does not. The engine resolves this after the parse, which is why the
457
457
  * node still carries an empty `href` here.
458
458
  *
459
459
  * Only the collapsed form. `[text][Some Heading]` does not reach a heading,
@@ -463,9 +463,13 @@ function inLink(ctx, n) {
463
463
  function collapsedHeadingRef(ctx, n) {
464
464
  if (!String(n.rawRef ?? '').endsWith('][]'))
465
465
  return undefined;
466
- const text = plainText(n.children ?? []).trim().toLowerCase();
466
+ const text = headingTextKey(plainText(n.children ?? []));
467
467
  return text ? ctx.headingIdByText.get(text) : undefined;
468
468
  }
469
+ /** The key a collapsed reference and a heading's text meet on (PART 9R R1). */
470
+ function headingTextKey(text) {
471
+ return text.replace(/[ \t\n\f\r]+/g, ' ').replace(/^ | $/g, '').normalize('NFC');
472
+ }
469
473
  function unresolvedReference(ctx, n, kind) {
470
474
  if (n.ref === undefined)
471
475
  return null;
@@ -631,20 +635,21 @@ function inline(ctx, n) {
631
635
  if (ctx.inCrossref)
632
636
  return [];
633
637
  const target = String(n.target ?? '');
634
- const found = ctx.crossrefTargets.get(target) ??
635
- ctx.crossrefTargets.get(target.toLowerCase()) ??
636
- findCaseInsensitive(ctx.crossrefTargets, target);
638
+ // Ids compare exactly (PART 9R R4): the target as written, then
639
+ // its NFC form. No case fold.
640
+ const key = ctx.crossrefTargets.has(target) ? target : target.normalize('NFC');
641
+ const found = ctx.crossrefTargets.get(key);
637
642
  if (found) {
638
643
  // A caption target cannot come back as a crossref (see
639
644
  // `captionTargets`), so it goes out as a plain link.
640
- const classes = ctx.captionTargets.has(target.toLowerCase())
645
+ const classes = ctx.captionTargets.has(key)
641
646
  ? []
642
647
  : ['crossref'];
643
648
  ctx.inCrossref = true;
644
649
  try {
645
650
  return [
646
651
  P.Link(P.attr(undefined, classes), inlines(ctx, found), [
647
- `#${target}`,
652
+ `#${key}`,
648
653
  '',
649
654
  ]),
650
655
  ];
@@ -906,14 +911,6 @@ function citationDefinition(ctx, n) {
906
911
  // pandoc's own readers produce.
907
912
  entry.length ? [P.Para(entry)] : []);
908
913
  }
909
- function findCaseInsensitive(map, target) {
910
- const lower = target.toLowerCase();
911
- for (const [k, v] of map) {
912
- if (k.toLowerCase() === lower)
913
- return v;
914
- }
915
- return undefined;
916
- }
917
914
  // --- Blocks ---
918
915
  function blocks(ctx, nodes) {
919
916
  if (!nodes)
@@ -1563,11 +1560,22 @@ function table(ctx, n, caption, shortCaption = null) {
1563
1560
  + `${lead} unmarked one(s), which its model cannot express`, { construct: 'table', row: r + 1, rowHeadColumns: lead, dropped: strays }, n.pos);
1564
1561
  }
1565
1562
  return P.Table(
1566
- // `header-rows` / `footer-rows` are the partition, and the partition
1567
- // reached `groups` above - leaving them here would state the same fact
1568
- // twice and put a stray attribute on the table in every pandoc writer.
1569
- // The list-table reader already filters its own copies of both.
1570
- toAttrWithout(ctx, n.attrs, ['aligns', 'valigns', 'widths', 'header-rows', 'footer-rows']), caption, colAligns, toRows(0, headCount), bodies, footRows, shortCaption, Array.from({ length: width }, (_, c) => columns[c]?.width ?? null), toAttr(ctx, groups?.headAttrs), toAttr(ctx, groups?.footAttrs));
1563
+ // `header-rows` / `footer-rows` are the partition when a partition was
1564
+ // consumed, and it reached `groups` above - leaving them here would
1565
+ // state the same fact twice and put a stray attribute on the table in
1566
+ // every pandoc writer. The list-table reader filters its own copies too.
1567
+ //
1568
+ // WITHOUT A PARTITION THEY ARE ORDINARY ATTRIBUTES AND HAVE TO SURVIVE.
1569
+ // PART 12 section 15: invalid body metadata synthesizes no partition and
1570
+ // leaves every row-group name, `header-rows` and `footer-rows` included,
1571
+ // an ordinary attribute in HTML. The engine keeps the keys on `attrs`
1572
+ // either way and signals consumption by `rowGroups` alone, so that is
1573
+ // what this reads. Corpus 541: `{header-rows=1 body-rows=x}` renders
1574
+ // `<table header-rows="1" body-rows="x">` and filtering unconditionally
1575
+ // dropped the first of the two.
1576
+ toAttrWithout(ctx, n.attrs, groups
1577
+ ? ['aligns', 'valigns', 'widths', 'header-rows', 'footer-rows']
1578
+ : ['aligns', 'valigns', 'widths']), caption, colAligns, toRows(0, headCount), bodies, footRows, shortCaption, Array.from({ length: width }, (_, c) => columns[c]?.width ?? null), toAttr(ctx, groups?.headAttrs), toAttr(ctx, groups?.footAttrs));
1571
1579
  }
1572
1580
  /**
1573
1581
  * The row-head column count a declared body's rows agree on, or 0.
@@ -2196,7 +2204,7 @@ function collectCrossrefTargets(ctx, nodes, captionCounts, inPanel = false) {
2196
2204
  const id = a.id ?? slugify(plainText(children));
2197
2205
  if (id && !ctx.crossrefTargets.has(id))
2198
2206
  ctx.crossrefTargets.set(id, children);
2199
- const text = plainText(children).trim().toLowerCase();
2207
+ const text = headingTextKey(plainText(children));
2200
2208
  // First heading wins, the same way the id map resolves a duplicate.
2201
2209
  if (id && text && !ctx.headingIdByText.has(text))
2202
2210
  ctx.headingIdByText.set(text, id);
@@ -2214,7 +2222,7 @@ function collectCrossrefTargets(ctx, nodes, captionCounts, inPanel = false) {
2214
2222
  const a = (n.attrs ?? {});
2215
2223
  if (a.id && !ctx.crossrefTargets.has(a.id)) {
2216
2224
  ctx.crossrefTargets.set(a.id, [{ type: 'text', value: `${label} ${next}` }]);
2217
- ctx.captionTargets.add(a.id.toLowerCase());
2225
+ ctx.captionTargets.add(a.id);
2218
2226
  }
2219
2227
  }
2220
2228
  }
@@ -2259,7 +2267,7 @@ function figureGroupTargets(ctx, n, captionCounts, inPanel) {
2259
2267
  const a = (n.attrs ?? {});
2260
2268
  if (a.id && !ctx.crossrefTargets.has(a.id)) {
2261
2269
  ctx.crossrefTargets.set(a.id, [{ type: 'text', value: resolved }]);
2262
- ctx.captionTargets.add(a.id.toLowerCase());
2270
+ ctx.captionTargets.add(a.id);
2263
2271
  }
2264
2272
  }
2265
2273
  let panelIndex = 0;
@@ -10,7 +10,7 @@ const RULES = [
10
10
  { test: /^block: pandoc node/, code: 'unsupported-pandoc-block', class: 'unsupported' },
11
11
  { test: /^citation: .*typed locator/, code: 'citation-locator-flattened', class: 'normalized' },
12
12
  { test: /^citation: .*suppresses its author/, code: 'citation-mode-normalized', class: 'normalized' },
13
- { test: /^Cite mixes /, code: 'citation-mode-normalized', class: 'normalized' },
13
+ { test: /^Cite mixes /, code: 'citation-mode-normalized', class: 'degraded' },
14
14
  { test: /^Cite mapped /, code: 'citation-bibliography-not-emitted', class: 'degraded' },
15
15
  { test: /^SmallCaps /, code: 'smallcaps-degraded', class: 'degraded' },
16
16
  { test: /^short caption:/, code: 'short-caption-source-unavailable', class: 'unsupported' },
@@ -40,16 +40,19 @@ const RULES = [
40
40
  { test: /^list-table: structure/, code: 'list-table-structure-degraded', class: 'degraded' },
41
41
  { test: /^list-table: the short caption/, code: 'list-table-short-caption-dropped', class: 'lossy' },
42
42
  { test: /^list-table: a body group's attributes/, code: 'list-table-body-attributes-dropped', class: 'lossy' },
43
- { test: /^list-table: the table's .* body groups/, code: 'list-table-body-groups-merged', class: 'normalized' },
44
- { test: /^list-table: the body groups disagree/, code: 'list-table-row-heads-normalized', class: 'normalized' },
43
+ { test: /^list-table: a row's attributes/, code: 'list-table-row-attributes-dropped', class: 'lossy' },
44
+ { test: /^list-table: the table's .* body groups/, code: 'list-table-body-groups-merged', class: 'degraded' },
45
+ { test: /^list-table: the body groups disagree/, code: 'list-table-row-heads-normalized', class: 'degraded' },
45
46
  { test: /^list-table: rowspan/, code: 'list-table-rowspan-clipped', class: 'lossy' },
46
47
  { test: /^table: a row header outside/, code: 'table-row-head-outside-leading-run', class: 'lossy' },
47
48
  { test: /^table: attributes on/, code: 'table-continuation-attributes-dropped', class: 'lossy' },
48
49
  { test: /^table: colspan continuation/, code: 'table-colspan-origin-missing', class: 'degraded' },
49
50
  { test: /^table: rowspan crossing/, code: 'table-rowspan-clipped', class: 'lossy' },
50
51
  { test: /^table: rowspan continuation/, code: 'table-rowspan-origin-missing', class: 'degraded' },
51
- { test: /^table: .*padded with empty cells/, code: 'table-groups-normalized', class: 'normalized' },
52
+ { test: /^table: .*padded with empty cells/, code: 'table-groups-normalized', class: 'degraded' },
52
53
  { test: /^table: a cell holds block content/, code: 'table-groups-normalized', class: 'normalized' },
54
+ { 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' },
53
56
  { test: /^table: .*converted with the implicit head\/body split/, code: 'table-row-groups-invalid', class: 'degraded' },
54
57
  { test: /^table: .*preserved in the Carve AST as `rowGroups`/, code: 'table-groups-flattened', class: 'degraded' },
55
58
  { test: /^table: a foot row's row header is dropped/, code: 'table-foot-row-header-dropped', class: 'lossy' },
package/dist/reverse.js CHANGED
@@ -983,9 +983,8 @@ function table(ctx, c, captionOverride, shortCaptionOverride = null) {
983
983
  }));
984
984
  // Row-head columns no longer send a table to the list-table form: the pipe
985
985
  // table marks those cells directly (see `rowHeadCols` above), which is
986
- // readable, needs no extension, and round-trips. Block cells remain the one
987
- // reason to leave, because a Carve `table_cell` holds INLINES and there is
988
- // no pipe form for a cell holding blocks at all.
986
+ // readable, needs no extension, and round-trips. Block cells need the
987
+ // list-table form, as do blank rows without attributes or alignment.
989
988
  //
990
989
  // A FOOT USED TO BE A SECOND REASON, AND IT WAS A REAL ONE AT THE TIME.
991
990
  // The clause read `|| (ctx.target === 'source' && footRaw.length > 0)`.
@@ -1005,7 +1004,25 @@ function table(ctx, c, captionOverride, shortCaptionOverride = null) {
1005
1004
  // (corpus 376, spec f074372), so at the time the pipe form genuinely could
1006
1005
  // not carry a foot and the list-table detour was the only way to keep it.
1007
1006
  // The limitation is gone, so the detour is too.
1008
- const useListTable = ctx.target === 'source' && hasBlockCells;
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);
1009
1026
  const pending = allRaw.map(() => Array(nCols));
1010
1027
  const rows = [];
1011
1028
  // The pandoc blocks that sat at each grid position, kept alongside the
@@ -1093,6 +1110,7 @@ function table(ctx, c, captionOverride, shortCaptionOverride = null) {
1093
1110
  attrs: fromAttr(a),
1094
1111
  colAligns,
1095
1112
  colWidths: columns.map((column) => column.width),
1113
+ blankRows: !hasBlockCells && hasBlankRows,
1096
1114
  });
1097
1115
  }
1098
1116
  const node = { type: 'table', rows };
@@ -1102,7 +1120,77 @@ function table(ctx, c, captionOverride, shortCaptionOverride = null) {
1102
1120
  // pushed per raw row, so §15's sum holds by construction here. It is
1103
1121
  // checked where it can actually fail instead: on a partition that arrived
1104
1122
  // from outside (see readRowGroups).
1105
- const groups = { headRows: headRaw.length, bodies: groupBodies, footRows: footRaw.length };
1123
+ // PART 12 section 15, carve#2402: A ROW-HEAD COUNT THE CELLS ALREADY STATE
1124
+ // IS NOT A PARTITION. One body's data rows all carry the same leading `|=`
1125
+ // run, so the count is derivable from them and the field would state
1126
+ // nothing the rows do not - section 15 asks a producer to publish no
1127
+ // `rowGroups` for a table whose one departure is that count on its single
1128
+ // body. Measured on engine 0.1.10: keeping it made the writer spell
1129
+ // `{header-rows=1 body-rows=1 body-header-cols=1}` where the bare pipe
1130
+ // table renders byte-identical HTML. A body with no data row to carry the
1131
+ // markers keeps the field, and so does every multi-body partition, where
1132
+ // the counts are per body and the writer states them positionally.
1133
+ //
1134
+ // THE SAME RULE REACHES A SPLIT THAT ONLY ENCODES THOSE RUNS. Pandoc has no
1135
+ // per-row row-head count, so the FORWARD direction splits one Carve body at
1136
+ // every change in the marked run - that is the only way `|= b |` under
1137
+ // `| a |` keeps its scope through pandoc's model. Those bodies are an
1138
+ // encoding of the cells, not a partition the author wrote, and re-stating
1139
+ // them invents a `tbody` boundary the source never had: measured on corpus
1140
+ // 354-2, one `<tbody>` in becomes two out.
1141
+ //
1142
+ // A split that carries anything else - an intermediate header row, body
1143
+ // attributes, a head or a foot - is real and is stated.
1144
+ //
1145
+ // A LEADING RUN THAT COVERS EVERY COLUMN IS NOT DERIVABLE EITHER WAY. A row
1146
+ // whose every cell is marked reads as a HEADER ROW, and a leading run of
1147
+ // them becomes the table head - so leaving the first body's count to the
1148
+ // markers turns row headers into column headers. Measured on one column:
1149
+ // a single body of two rows at `RowHeadColumns = 1` came back as a two-row
1150
+ // `TableHead` over an empty body, and bodies at `[1, 0]` moved row 1 into
1151
+ // the head. Only the FIRST body matters, because only a LEADING run is read
1152
+ // as a head: corpus 354 collapses safely with counts `[0, 1]` over one
1153
+ // column, since nothing marks its first row.
1154
+ const leadingRunFillsTheRow = (groupBodies[0]?.rowHeadColumns ?? 0) >= nCols;
1155
+ //
1156
+ // EVERY ADJACENT PAIR MUST DISAGREE, because the forward direction splits at
1157
+ // every CHANGE in the run - so the bodies it synthesizes never put two equal
1158
+ // counts side by side. Counts of `[1, 1, 0]` hold a boundary no run change
1159
+ // could have produced, and collapsing the lot merged the first two sections.
1160
+ // "Some count somewhere differs" is not evidence about any one boundary.
1161
+ const adjacentRunsDiffer = groupBodies.every((body, i) => i === 0 || (body.rowHeadColumns ?? 0) !== (groupBodies[i - 1].rowHeadColumns ?? 0));
1162
+ //
1163
+ // AND IT IS THE SOURCE WRITER'S NORMALIZATION ONLY. The pipe table cannot
1164
+ // tell a derived split from an authored one, so writing it has to choose;
1165
+ // the exchange AST has no such constraint and `rowGroups` is exact there,
1166
+ // so `pandocToCarveAst` keeps every boundary the pandoc table carried.
1167
+ const onlyRowHeadRuns = ctx.target === 'source'
1168
+ && groupBodies.length > 1
1169
+ && groupBodies.every((b) => b.headRows === 0 && b.attrs === undefined)
1170
+ // AN EMPTY BODY IS NEVER A DERIVED ONE. The splitter cuts a run of data
1171
+ // rows, so it cannot produce a body with none, and section 15 asks the
1172
+ // writer to state even a single empty body. Collapsing one dropped the
1173
+ // boundary AND, with the empty body leading, left the count check
1174
+ // looking at a body with no rows to mark: `[0 (empty), 2]` over two
1175
+ // columns emitted one fully marked row, which came back as the head.
1176
+ && groupBodies.every((b) => b.bodyRows > 0)
1177
+ && adjacentRunsDiffer
1178
+ && !leadingRunFillsTheRow
1179
+ && headRaw.length === 0 && footRaw.length === 0
1180
+ && !headAttrs && !footAttrs;
1181
+ const collapsed = [{
1182
+ headRows: 0,
1183
+ bodyRows: groupBodies.reduce((sum, b) => sum + b.bodyRows, 0),
1184
+ }];
1185
+ const derivableRowHead = groupBodies.length === 1
1186
+ && groupBodies[0].bodyRows > 0
1187
+ && !leadingRunFillsTheRow;
1188
+ const publishedBodies = onlyRowHeadRuns
1189
+ ? collapsed
1190
+ : derivableRowHead
1191
+ ? groupBodies.map(({ rowHeadColumns: _derivable, ...rest }) => rest)
1192
+ : groupBodies;
1193
+ const groups = { headRows: headRaw.length, bodies: publishedBodies, footRows: footRaw.length };
1106
1194
  if (headAttrs)
1107
1195
  groups.headAttrs = headAttrs;
1108
1196
  if (footAttrs)
@@ -1112,18 +1200,10 @@ function table(ctx, c, captionOverride, shortCaptionOverride = null) {
1112
1200
  if (ctx.target === 'source')
1113
1201
  reportUnspellableGroups(ctx, groups);
1114
1202
  }
1115
- // BOTH OF THESE ARE THE SOURCE WRITER'S BUSINESS ONLY. On the `ast` target
1116
- // the partition stays in `rowGroups`, where it is exact: there is no
1117
- // attribute line to state it on, so stating it would invent attributes the
1118
- // pandoc table never carried, and no read-back to merge the bodies, so the
1119
- // merge has no cost to report.
1120
- const stated = ctx.target === 'source' && footRaw.length > 0;
1121
- reportRunsAStatedPartitionMerges(ctx, {
1122
- leads: allRaw.map((_, r) => (isHeaderRow[r] === true ? nCols : rowHeadCols[r] ?? 0)),
1123
- from: headRaw.length,
1124
- to: allRaw.length - footRaw.length,
1125
- stated,
1126
- });
1203
+ // STATING THE PARTITION IS THE SOURCE WRITER'S BUSINESS ONLY. On the `ast`
1204
+ // target it stays in `rowGroups`, where it is exact: there is no attribute
1205
+ // line to state it on, so stating it would invent attributes the pandoc
1206
+ // table never carried.
1127
1207
  const attrs = ctx.target === 'source' ? statePartition(fromAttr(a), groups) : fromAttr(a);
1128
1208
  if (attrs)
1129
1209
  node.attrs = attrs;
@@ -1137,33 +1217,20 @@ function table(ctx, c, captionOverride, shortCaptionOverride = null) {
1137
1217
  return node;
1138
1218
  }
1139
1219
  /**
1140
- * A STATED PARTITION IS ONE BODY, and one body carries one row-header count.
1141
- *
1142
- * The count is not written down anywhere - `RowHeadColumns` has no spelling on
1143
- * the attribute line - so the reader derives it from the leading `|=` run of the
1144
- * body rows. It can only take a count every row agrees on: a declared body's
1145
- * boundaries were stated, so it cannot split at a change the way the derived
1146
- * bodies do. Rows that disagree therefore come back as data cells.
1220
+ * Whether the table's attributes carry body metadata the engine did not consume.
1147
1221
  *
1148
- * That only bites once the partition IS stated, which here means once there is a
1149
- * foot. Without one the reader splits freely and every run survives, which is
1150
- * why the pipe path's loss report does not name row-head columns in general.
1151
- *
1152
- * Reported from the writer because the writer is the only side that knows the
1153
- * runs it just wrote: by the time the source is read back, the disagreement is
1154
- * all that is left of them.
1222
+ * The positional keys only reach `keyValues` when the engine read them as
1223
+ * invalid - a consumed partition is spent on the AST's `rowGroups` instead. So
1224
+ * their presence beside a flat partition is the signal, and PART 12 section 15
1225
+ * then makes every row-group name on that block an ordinary attribute.
1155
1226
  */
1156
- function reportRunsAStatedPartitionMerges(ctx, input) {
1157
- const { leads, from, to, stated } = input;
1158
- if (!stated)
1159
- return;
1160
- const distinct = new Set(leads.slice(from, to));
1161
- if (distinct.size < 2)
1162
- return;
1163
- warn(ctx, 'table: the body rows disagree on how many leading cells are row headers '
1164
- + `(${[...distinct].sort((x, y) => x - y).join(', ')}), and stating the foot states `
1165
- + 'the whole partition as ONE body - the reader takes a count every row agrees on, so '
1166
- + 'those cells come back as data cells');
1227
+ const POSITIONAL_BODY_KEYS = ['body-rows', 'body-header-rows', 'body-header-cols'];
1228
+ function carriesInvalidBodyMetadata(keyValues, groups) {
1229
+ if (!POSITIONAL_BODY_KEYS.some((key) => keyValues[key] !== undefined))
1230
+ return false;
1231
+ const flat = groups.headRows === 0 && groups.footRows === 0 && groups.bodies.length === 1
1232
+ && groups.bodies[0].headRows === 0 && groups.bodies[0].attrs === undefined;
1233
+ return flat;
1167
1234
  }
1168
1235
  /**
1169
1236
  * State the head and foot row counts on the pipe table's attribute line.
@@ -1199,9 +1266,23 @@ function reportRunsAStatedPartitionMerges(ctx, input) {
1199
1266
  */
1200
1267
  function statePartition(attrs, groups) {
1201
1268
  const keyValues = { ...(attrs?.keyValues ?? {}) };
1202
- delete keyValues['header-rows'];
1203
- delete keyValues['footer-rows'];
1204
- if (groups.footRows > 0) {
1269
+ // INVALID BODY METADATA MAKES THE WHOLE VOCABULARY ORDINARY ATTRIBUTES, and
1270
+ // an ordinary attribute is retained, not restated. PART 12 section 15:
1271
+ // "Invalid body metadata synthesizes no partition; all row-group metadata
1272
+ // attributes, including `header-rows` and `footer-rows`, remain ordinary
1273
+ // attributes in HTML." Corpus 541 exists to pin that, and dropping
1274
+ // `header-rows` from `{header-rows=1 body-rows=x}` broke it: the source
1275
+ // renders `<table header-rows="1" body-rows="x">` and the round trip
1276
+ // rendered `<table body-rows="x">`.
1277
+ //
1278
+ // The stale-key guard below still applies whenever no such key is present,
1279
+ // which is every table whose partition the engine actually consumed - a
1280
+ // consumed key never reaches `keyValues` at all.
1281
+ if (!carriesInvalidBodyMetadata(keyValues, groups)) {
1282
+ delete keyValues['header-rows'];
1283
+ delete keyValues['footer-rows'];
1284
+ }
1285
+ if (groups.footRows > 0 && !carriesInvalidBodyMetadata(keyValues, groups)) {
1205
1286
  if (groups.headRows > 0)
1206
1287
  keyValues['header-rows'] = String(groups.headRows);
1207
1288
  keyValues['footer-rows'] = String(groups.footRows);
@@ -1229,43 +1310,36 @@ function statePartition(attrs, groups) {
1229
1310
  /**
1230
1311
  * What a `rowGroups` partition says that the PIPE form cannot say back.
1231
1312
  *
1232
- * The partition itself is not a degradation - it reaches the exchange AST
1233
- * intact, and `pandocToCarveAst` hands it on whole. The loss happens one step
1234
- * later, in the source writer: a pipe table states the head and the foot on its
1235
- * attribute line and nothing else, so a second body group, a body's own
1236
- * intermediate header rows and its attributes come out as ordinary body rows.
1237
- * PART 12 §15 says so in as many words and asks for exactly this - "a canonical
1238
- * Carve writer loses it ... conversion APIs with diagnostics should report that
1239
- * loss".
1313
+ * Only a body group's own attributes, now. The partition itself reaches the
1314
+ * exchange AST intact and `pandocToCarveAst` hands it on whole, and the source
1315
+ * writer states the rest of it: `{header-rows=N footer-rows=M}` for the head
1316
+ * and foot, and PART 12 section 15's positional `body-rows`,
1317
+ * `body-header-rows` and `body-header-cols` for the bodies.
1240
1318
  *
1241
- * THE FOOT IS NOT ON THAT LIST ANY MORE, AND IT WAS. `{header-rows=N
1242
- * footer-rows=M}` before a pipe table is what the `rowGroups` note in
1243
- * docs/ast-json.md calls "the simple partition spelled by `header-rows` /
1244
- * `footer-rows`", and the engine synthesizes it - measured, see the note beside
1245
- * `useListTable`. Naming it here reported a loss that no longer happens, on the
1246
- * one construct the writer had just started spelling.
1319
+ * THREE THINGS CAME OFF THIS LIST, each because the writer started spelling
1320
+ * them. The foot went first. Row-head columns went next, read back off the
1321
+ * `|= North | 11 |` markers the writer puts on the cells. The body groups and
1322
+ * their intermediate header rows went with carve 0.1.10, which writes the
1323
+ * positional keys: measured on a head, two bodies of which the second leads
1324
+ * with a header row, and a foot, the round trip returns the same partition and
1325
+ * the HTML carries one `tbody` per body. Reporting them as lost named a loss
1326
+ * that no longer happens.
1247
1327
  *
1248
- * ROW-HEAD COLUMNS ARE NOT ON IT EITHER, for the reason the note beside
1249
- * `rowHeadCols` gives: the pipe table marks those cells (`|= North | 11 |`) and
1250
- * the forward direction reads the count back off exactly that run. Measured in
1251
- * both shapes, with a foot and without.
1328
+ * Section attributes have no attribute-line spelling at all (section 15 says
1329
+ * so), so they are what is left.
1252
1330
  *
1253
1331
  * AST output retains these groups. This diagnostic applies only when writing
1254
- * source, whose pipe-table syntax cannot express the full partition.
1332
+ * source.
1255
1333
  */
1256
1334
  function reportUnspellableGroups(ctx, groups) {
1257
1335
  const lost = [];
1258
- if (groups.bodies.length > 1)
1259
- lost.push(`${groups.bodies.length} body groups`);
1260
- if (groups.bodies.some((b) => b.headRows > 0))
1261
- lost.push("a body's intermediate header rows");
1262
1336
  if (groups.bodies.some((b) => b.attrs !== undefined))
1263
1337
  lost.push("a body group's attributes");
1264
1338
  if (!lost.length)
1265
1339
  return;
1266
1340
  warn(ctx, `table: ${lost.join(', ')} - preserved in the Carve AST as \`rowGroups\`, `
1267
- + 'but a pipe table states only its head and foot row counts, so the '
1268
- + 'emitted source flattens them into body rows');
1341
+ + 'but Carve source has no spelling for the attributes of a table section, '
1342
+ + 'so the emitted source drops them');
1269
1343
  }
1270
1344
  /**
1271
1345
  * Whether a cell's blocks fit Carve's pipe-table cell, which holds INLINES.
@@ -1299,14 +1373,15 @@ function isInlineShaped(cellBlocks) {
1299
1373
  */
1300
1374
  function listTable(ctx, input) {
1301
1375
  const { rows, cellBlocksAt, headRows, footRows, bodies, caption, attrs, colAligns, colWidths } = input;
1302
- // One reason is left to be here, so the diagnostic states it rather than
1303
- // selecting between three strings two of which nothing could reach: row-head
1304
- // columns are marked on the cells and a foot is stated on the attribute
1305
- // line, both in the pipe form.
1306
- warn(ctx, 'table: a cell holds block content, which a pipe table cannot spell - emitted as a `::: list-table` (structure preserved)');
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)');
1307
1379
  if (bodies.some((b) => b.attrs)) {
1308
1380
  warn(ctx, 'list-table: a body group\'s attributes are dropped - the extension has no body to hang them on');
1309
1381
  }
1382
+ if (rows.some((row) => row.attrs)) {
1383
+ warn(ctx, 'list-table: a row\'s attributes are dropped - the extension has no row-attribute spelling');
1384
+ }
1310
1385
  if (bodies.slice(1).some((b) => b.headRows === 0)) {
1311
1386
  warn(ctx, `list-table: the table's ${bodies.length} body groups merge where a later body has no header row to mark its boundary`);
1312
1387
  }
@@ -1471,27 +1546,6 @@ function figure(ctx, c) {
1471
1546
  return [node];
1472
1547
  }
1473
1548
  }
1474
- if (single?.t === 'Table') {
1475
- // The wrapper and the Table collapse into ONE Carve node, so their
1476
- // attrs merge rather than the inner one silently winning: pandoc's
1477
- // readers put the label on the Figure, not on the Table it wraps, and
1478
- // dropping it took the id a `</#id>` resolves against with it. The
1479
- // outer id wins, classes union, key/values merge with the outer taking
1480
- // precedence.
1481
- //
1482
- // A table is the only host that collapses this way, because it is the
1483
- // only one Carve gives a caption of its own. Every other host keeps
1484
- // both nodes - the wrapper stays a `figure` and the host's own attrs
1485
- // ride on the target - which is what the Div arm below sorts out.
1486
- //
1487
- // It matters most for a §4c table PANEL, whose id is what resolves as
1488
- // the group's number plus a letter.
1489
- const node = table(ctx, single.c, caption, shortCaption);
1490
- const outer = fromAttr(a);
1491
- if (outer)
1492
- node.attrs = mergeCAttrs(node.attrs, outer);
1493
- return [node];
1494
- }
1495
1549
  // A HOST THAT CARRIES ITS OWN ATTRIBUTES ARRIVES INSIDE A DIV. Pandoc's
1496
1550
  // BlockQuote, Para and CodeBlock have no Attr slot, so `block()` in the
1497
1551
  // forward direction wraps an attributed one in a Div. A `div` is not a
@@ -1515,6 +1569,21 @@ function figure(ctx, c) {
1515
1569
  ]);
1516
1570
  }
1517
1571
  }
1572
+ if (host?.t === 'Table') {
1573
+ // A table caption lives on the table, including when a reader puts
1574
+ // an alignment Div between it and the Figure. Outer attributes win.
1575
+ const node = table(ctx, host.c, caption, shortCaption);
1576
+ const outer = fromAttr(a);
1577
+ for (const attrs of [hostAttrs, outer]) {
1578
+ const current = node.attrs;
1579
+ if (current?.id && attrs?.id && current.id !== attrs.id) {
1580
+ warn(ctx, `table: the merged id "${current.id}" is dropped in favor of "${attrs.id}" - one Carve node has one id`);
1581
+ }
1582
+ if (attrs)
1583
+ node.attrs = mergeCAttrs(current, attrs);
1584
+ }
1585
+ return [node];
1586
+ }
1518
1587
  if (host && FIGURE_HOSTS.has(host.t)) {
1519
1588
  // A single-host `Figure` is an ordinary `figure` around that host -
1520
1589
  // the generic captioned wrapper of PART 9 §4b, which is what the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markup-carve/pandoc-carve",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
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.8"
58
+ "@markup-carve/carve": "^0.1.10"
59
59
  },
60
60
  "devDependencies": {
61
61
  "@types/node": "^24.0.0",