@markup-carve/pandoc-carve 0.1.7 → 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/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;
package/dist/reverse.js CHANGED
@@ -1120,7 +1120,77 @@ function table(ctx, c, captionOverride, shortCaptionOverride = null) {
1120
1120
  // pushed per raw row, so §15's sum holds by construction here. It is
1121
1121
  // checked where it can actually fail instead: on a partition that arrived
1122
1122
  // from outside (see readRowGroups).
1123
- 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 };
1124
1194
  if (headAttrs)
1125
1195
  groups.headAttrs = headAttrs;
1126
1196
  if (footAttrs)
@@ -1130,18 +1200,10 @@ function table(ctx, c, captionOverride, shortCaptionOverride = null) {
1130
1200
  if (ctx.target === 'source')
1131
1201
  reportUnspellableGroups(ctx, groups);
1132
1202
  }
1133
- // BOTH OF THESE ARE THE SOURCE WRITER'S BUSINESS ONLY. On the `ast` target
1134
- // the partition stays in `rowGroups`, where it is exact: there is no
1135
- // attribute line to state it on, so stating it would invent attributes the
1136
- // pandoc table never carried, and no read-back to merge the bodies, so the
1137
- // merge has no cost to report.
1138
- const stated = ctx.target === 'source' && footRaw.length > 0;
1139
- reportRunsAStatedPartitionMerges(ctx, {
1140
- leads: allRaw.map((_, r) => (isHeaderRow[r] === true ? nCols : rowHeadCols[r] ?? 0)),
1141
- from: headRaw.length,
1142
- to: allRaw.length - footRaw.length,
1143
- stated,
1144
- });
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.
1145
1207
  const attrs = ctx.target === 'source' ? statePartition(fromAttr(a), groups) : fromAttr(a);
1146
1208
  if (attrs)
1147
1209
  node.attrs = attrs;
@@ -1155,33 +1217,20 @@ function table(ctx, c, captionOverride, shortCaptionOverride = null) {
1155
1217
  return node;
1156
1218
  }
1157
1219
  /**
1158
- * A STATED PARTITION IS ONE BODY, and one body carries one row-header count.
1159
- *
1160
- * The count is not written down anywhere - `RowHeadColumns` has no spelling on
1161
- * the attribute line - so the reader derives it from the leading `|=` run of the
1162
- * body rows. It can only take a count every row agrees on: a declared body's
1163
- * boundaries were stated, so it cannot split at a change the way the derived
1164
- * 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.
1165
1221
  *
1166
- * That only bites once the partition IS stated, which here means once there is a
1167
- * foot. Without one the reader splits freely and every run survives, which is
1168
- * why the pipe path's loss report does not name row-head columns in general.
1169
- *
1170
- * Reported from the writer because the writer is the only side that knows the
1171
- * runs it just wrote: by the time the source is read back, the disagreement is
1172
- * 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.
1173
1226
  */
1174
- function reportRunsAStatedPartitionMerges(ctx, input) {
1175
- const { leads, from, to, stated } = input;
1176
- if (!stated)
1177
- return;
1178
- const distinct = new Set(leads.slice(from, to));
1179
- if (distinct.size < 2)
1180
- return;
1181
- warn(ctx, 'table: the body rows disagree on how many leading cells are row headers '
1182
- + `(${[...distinct].sort((x, y) => x - y).join(', ')}), and stating the foot states `
1183
- + 'the whole partition as ONE body - the reader takes a count every row agrees on, so '
1184
- + '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;
1185
1234
  }
1186
1235
  /**
1187
1236
  * State the head and foot row counts on the pipe table's attribute line.
@@ -1217,9 +1266,23 @@ function reportRunsAStatedPartitionMerges(ctx, input) {
1217
1266
  */
1218
1267
  function statePartition(attrs, groups) {
1219
1268
  const keyValues = { ...(attrs?.keyValues ?? {}) };
1220
- delete keyValues['header-rows'];
1221
- delete keyValues['footer-rows'];
1222
- 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)) {
1223
1286
  if (groups.headRows > 0)
1224
1287
  keyValues['header-rows'] = String(groups.headRows);
1225
1288
  keyValues['footer-rows'] = String(groups.footRows);
@@ -1247,43 +1310,36 @@ function statePartition(attrs, groups) {
1247
1310
  /**
1248
1311
  * What a `rowGroups` partition says that the PIPE form cannot say back.
1249
1312
  *
1250
- * The partition itself is not a degradation - it reaches the exchange AST
1251
- * intact, and `pandocToCarveAst` hands it on whole. The loss happens one step
1252
- * later, in the source writer: a pipe table states the head and the foot on its
1253
- * attribute line and nothing else, so a second body group, a body's own
1254
- * intermediate header rows and its attributes come out as ordinary body rows.
1255
- * PART 12 §15 says so in as many words and asks for exactly this - "a canonical
1256
- * Carve writer loses it ... conversion APIs with diagnostics should report that
1257
- * 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.
1258
1318
  *
1259
- * THE FOOT IS NOT ON THAT LIST ANY MORE, AND IT WAS. `{header-rows=N
1260
- * footer-rows=M}` before a pipe table is what the `rowGroups` note in
1261
- * docs/ast-json.md calls "the simple partition spelled by `header-rows` /
1262
- * `footer-rows`", and the engine synthesizes it - measured, see the note beside
1263
- * `useListTable`. Naming it here reported a loss that no longer happens, on the
1264
- * 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.
1265
1327
  *
1266
- * ROW-HEAD COLUMNS ARE NOT ON IT EITHER, for the reason the note beside
1267
- * `rowHeadCols` gives: the pipe table marks those cells (`|= North | 11 |`) and
1268
- * the forward direction reads the count back off exactly that run. Measured in
1269
- * 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.
1270
1330
  *
1271
1331
  * AST output retains these groups. This diagnostic applies only when writing
1272
- * source, whose pipe-table syntax cannot express the full partition.
1332
+ * source.
1273
1333
  */
1274
1334
  function reportUnspellableGroups(ctx, groups) {
1275
1335
  const lost = [];
1276
- if (groups.bodies.length > 1)
1277
- lost.push(`${groups.bodies.length} body groups`);
1278
- if (groups.bodies.some((b) => b.headRows > 0))
1279
- lost.push("a body's intermediate header rows");
1280
1336
  if (groups.bodies.some((b) => b.attrs !== undefined))
1281
1337
  lost.push("a body group's attributes");
1282
1338
  if (!lost.length)
1283
1339
  return;
1284
1340
  warn(ctx, `table: ${lost.join(', ')} - preserved in the Carve AST as \`rowGroups\`, `
1285
- + 'but a pipe table states only its head and foot row counts, so the '
1286
- + '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');
1287
1343
  }
1288
1344
  /**
1289
1345
  * Whether a cell's blocks fit Carve's pipe-table cell, which holds INLINES.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markup-carve/pandoc-carve",
3
- "version": "0.1.7",
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",