@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 +8 -1
- package/dist/convert.js +32 -24
- package/dist/diagnostics.js +7 -4
- package/dist/reverse.js +165 -96
- package/package.json +2 -2
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
|
|
456
|
-
* `# Plain One
|
|
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 ?? [])
|
|
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
|
-
|
|
635
|
-
|
|
636
|
-
|
|
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(
|
|
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
|
-
`#${
|
|
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
|
|
1567
|
-
// reached `groups` above - leaving them here would
|
|
1568
|
-
// twice and put a stray attribute on the table in
|
|
1569
|
-
// The list-table reader
|
|
1570
|
-
|
|
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)
|
|
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
|
|
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
|
|
2270
|
+
ctx.captionTargets.add(a.id);
|
|
2263
2271
|
}
|
|
2264
2272
|
}
|
|
2265
2273
|
let panelIndex = 0;
|
package/dist/diagnostics.js
CHANGED
|
@@ -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: '
|
|
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:
|
|
44
|
-
{ test: /^list-table: the body groups
|
|
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: '
|
|
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
|
|
987
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
1116
|
-
//
|
|
1117
|
-
//
|
|
1118
|
-
//
|
|
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
|
-
*
|
|
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
|
-
*
|
|
1149
|
-
*
|
|
1150
|
-
*
|
|
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
|
-
|
|
1157
|
-
|
|
1158
|
-
if (!
|
|
1159
|
-
return;
|
|
1160
|
-
const
|
|
1161
|
-
|
|
1162
|
-
|
|
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
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
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
|
-
*
|
|
1233
|
-
* intact
|
|
1234
|
-
*
|
|
1235
|
-
*
|
|
1236
|
-
*
|
|
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
|
-
*
|
|
1242
|
-
*
|
|
1243
|
-
*
|
|
1244
|
-
*
|
|
1245
|
-
*
|
|
1246
|
-
*
|
|
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
|
-
*
|
|
1249
|
-
*
|
|
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
|
|
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
|
|
1268
|
-
+ 'emitted source
|
|
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
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
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.
|
|
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.
|
|
58
|
+
"@markup-carve/carve": "^0.1.10"
|
|
59
59
|
},
|
|
60
60
|
"devDependencies": {
|
|
61
61
|
"@types/node": "^24.0.0",
|