@markup-carve/pandoc-carve 0.1.0 → 0.1.2

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
@@ -144,7 +144,7 @@ node -e "import('@markup-carve/pandoc-carve').then(m => process.stdout.write(m.c
144
144
  | `::: \|` line blocks (verse) | LineBlock, one entry per line, an empty entry per stanza break |
145
145
  | Ordered markers `1.` / `1)` / `a.` / `iv.` | OrderedList with the matching style and delimiter. Pandoc's example list `(@)` and its `(1)` marker have no Carve form and are reported |
146
146
  | A no-break space the parser resolved (`\ `) | U+00A0 (the engines publish a private-use sentinel for it) |
147
- | A reference link or image nothing defines (`[r][]`) | the literal source as text, which is what Carve renders, plus a warning naming the label - as an unresolved footnote already did |
147
+ | A reference link, image or footnote nothing defines (`[r][]`, `[^f]`) | the literal source as text, which is what Carve renders, plus a warning naming the label |
148
148
 
149
149
  The complete node-by-node contract lives in the test goldens. Worked
150
150
  input/output pairs in both directions - including how interactive constructs
@@ -204,13 +204,15 @@ a processor with neither extension enabled would render.
204
204
  per-column alignment, a foot, and a body group's intermediate header rows.
205
205
  It converts back to the pandoc table by default; `listTable: false` returns
206
206
  the degraded div instead.
207
- - A pandoc table with ROW-HEAD COLUMNS takes the same route on the source path,
208
- because `header-cols=N` is exactly pandoc's `RowHeadColumns` and a pipe table
209
- cannot say it. Only when every body group agrees on the count: `header-cols`
210
- is one number for the whole table, so a table whose bodies differ keeps the
211
- pipe form rather than come back with row headers ADDED to the rows that had
212
- none. The AST path keeps the plain table, where `rowGroups` carries the whole
213
- partition and nothing has to be traded for it.
207
+ - A pandoc table with ROW-HEAD COLUMNS stays a pipe table. `RowHeadColumns` says
208
+ the leading N cells of every body row are row headers, and a pipe table says
209
+ that per cell: `|= Mercury | 4,879.4 |` is a `<th scope="row">`. Marking the
210
+ cells needs no extension and round-trips, because the export direction derives
211
+ the count back from the leading run.
212
+ - On the way out, one TableBody per RUN of body rows that agree on that leading
213
+ run. Pandoc's `RowHeadColumns` is a count on a body and a `Table` holds a list
214
+ of bodies, so rows that disagree are simply different bodies. A table whose
215
+ body rows all agree emits exactly one body, as before.
214
216
  - Pandoc `SmallCaps` has no Carve node and is not getting one: it imports as a
215
217
  `[text]{.smallcaps}` span, with a warning saying so. The span is not a dead
216
218
  end, though - the export direction reads that class back as `SmallCaps`, the
@@ -266,9 +268,9 @@ a processor with neither extension enabled would render.
266
268
  rows or row-head columns) survives into the exchange AST, so
267
269
  `pandocToCarveAst` hands it on whole - but PART 9 section 16's pipe table
268
270
  spells only a leading run of header rows, so `pandocToCarve` flattens the
269
- rest into body rows and says so. Row-head columns are the exception - they
270
- have a list-table spelling and take it (above). Use the AST entry point when
271
- a foot or a second body group has to survive.
271
+ rest into body rows and says so. Row-head columns are the exception - the pipe
272
+ table marks them on the cells (above). Use the AST entry point when a foot or
273
+ a second body group has to survive.
272
274
  - A marker on a HEAD cell (`|=> Name |`) is the column's alignment and becomes
273
275
  pandoc's `ColSpec`; a marker on a BODY cell (`|> 12 |`) aligns that cell alone
274
276
  and becomes the cell's own `Alignment`. The two are not interchangeable: a
package/dist/convert.js CHANGED
@@ -389,6 +389,24 @@ function kids(ctx, n) {
389
389
  * Returns null when the node is not an unresolved reference, so the caller
390
390
  * converts it normally.
391
391
  */
392
+ /**
393
+ * The heading a COLLAPSED reference reaches, or null.
394
+ *
395
+ * `[Some Heading][]` with no definition anywhere links to the heading whose
396
+ * RENDERED text matches, case-insensitively - so `[plain one][]` reaches
397
+ * `# Plain One`. The engine resolves this after the parse, which is why the
398
+ * node still carries an empty `href` here.
399
+ *
400
+ * Only the collapsed form. `[text][Some Heading]` does not reach a heading,
401
+ * measured on the engine: it renders as its literal source. Detected on
402
+ * `rawRef` ending in `][]`, which is what makes the form collapsed.
403
+ */
404
+ function collapsedHeadingRef(ctx, n) {
405
+ if (!String(n.rawRef ?? '').endsWith('][]'))
406
+ return undefined;
407
+ const text = plainText(n.children ?? []).trim().toLowerCase();
408
+ return text ? ctx.headingIdByText.get(text) : undefined;
409
+ }
392
410
  function unresolvedReference(ctx, n, kind) {
393
411
  if (n.ref === undefined)
394
412
  return null;
@@ -453,6 +471,12 @@ function inline(ctx, n) {
453
471
  : text;
454
472
  }
455
473
  case 'link': {
474
+ // A collapsed reference to a heading is RESOLVED, not missing, so
475
+ // it is tried before the missing-definition path reports one.
476
+ const heading = String(n.href ?? '') === '' ? collapsedHeadingRef(ctx, n) : undefined;
477
+ if (heading !== undefined) {
478
+ return [P.Link(toAttr(n.attrs), kids(ctx, n), ['#' + heading, ''])];
479
+ }
456
480
  const unresolved = unresolvedReference(ctx, n, 'link');
457
481
  if (unresolved)
458
482
  return unresolved;
@@ -466,7 +490,18 @@ function inline(ctx, n) {
466
490
  case 'autolink': {
467
491
  const href = String(n.href ?? '');
468
492
  const cls = href.startsWith('mailto:') ? 'email' : 'uri';
469
- return [P.Link(P.attr(undefined, [cls]), [P.Str(String(n.text ?? href))], [href, ''])];
493
+ // An autolink takes a trailing attribute like any other inline
494
+ // (`<https://example.com>{.ext}`), and pandoc's Link has the slot
495
+ // for it, but only the synthesized `uri`/`email` class was written -
496
+ // so the author's id, classes and key-values left the document with
497
+ // nothing reported. They join the synthesized class rather than
498
+ // replace it: the class says what KIND of link this is, and a
499
+ // consumer keying on it should not lose that because the author
500
+ // added one of their own.
501
+ const [id, classes, kvs] = toAttr(n.attrs);
502
+ return [
503
+ P.Link([id, [cls, ...classes], kvs], [P.Str(String(n.text ?? href))], [href, '']),
504
+ ];
470
505
  }
471
506
  case 'image': {
472
507
  const unresolved = unresolvedReference(ctx, n, 'image');
@@ -506,10 +541,15 @@ function inline(ctx, n) {
506
541
  ctx.crossrefTargets.get(target.toLowerCase()) ??
507
542
  findCaseInsensitive(ctx.crossrefTargets, target);
508
543
  if (found) {
544
+ // A caption target cannot come back as a crossref (see
545
+ // `captionTargets`), so it goes out as a plain link.
546
+ const classes = ctx.captionTargets.has(target.toLowerCase())
547
+ ? []
548
+ : ['crossref'];
509
549
  ctx.inCrossref = true;
510
550
  try {
511
551
  return [
512
- P.Link(P.attr(undefined, ['crossref']), inlines(ctx, found), [
552
+ P.Link(P.attr(undefined, classes), inlines(ctx, found), [
513
553
  `#${target}`,
514
554
  '',
515
555
  ]),
@@ -540,8 +580,27 @@ function inline(ctx, n) {
540
580
  const id = String(n.id ?? '');
541
581
  const def = ctx.footnoteDefs[id];
542
582
  if (!def) {
543
- warn(ctx, `footnote: missing definition for [^${id}]`);
544
- return [P.Superscript([P.Str(id)])];
583
+ // THE LITERAL SOURCE, not a superscript.
584
+ //
585
+ // Carve renders an unresolved `[^f]` as the four characters the
586
+ // author typed. This used to emit `Superscript [Str "f"]`, which
587
+ // is a construct the document does not contain: the reader saw a
588
+ // raised `f` where the source says `[^f]`, and the round trip
589
+ // came back spelling it `{^f^}` - a superscript in Carve too, so
590
+ // the loss was permanent rather than merely cosmetic.
591
+ //
592
+ // The sibling path has been right all along. An unresolved link
593
+ // or image reference returns its `rawRef` as text for exactly
594
+ // this reason (see `unresolvedReference`), and there was no
595
+ // reason for a footnote to be the one construct that invented a
596
+ // node instead.
597
+ //
598
+ // A footnote reference carries no `rawRef`, so the source is
599
+ // rebuilt from the id. That is faithful even where the author
600
+ // wrote a trailing attribute: the engine drops the attribute
601
+ // along with the reference, so `[^a]{.ref}` renders `[^a]` too.
602
+ warn(ctx, `footnote: missing definition for [^${id}] - emitting the literal source`);
603
+ return textInlines(`[^${id}]`);
545
604
  }
546
605
  const note = P.Note(blocks(ctx, def));
547
606
  ctx.noteCount++;
@@ -752,6 +811,15 @@ function block(ctx, n) {
752
811
  // A block-attribute line can attach attrs to ANY block. Pandoc's Para,
753
812
  // BlockQuote, lists etc. have no Attr slot - preserve via a Div wrapper.
754
813
  const a = n.attrs;
814
+ // NOTHING IS NOT WRAPPED. A node that renders nothing where it sits - a
815
+ // link reference definition, an abbreviation definition - converts to no
816
+ // blocks, and wrapping that in a Div to carry its attrs put a visible empty
817
+ // element into every writer's output where Carve renders none:
818
+ // `[a]: /u {.c}` came back as `<div class="c"></div>`. The attrs are not
819
+ // lost by skipping it; they are already on whatever the definition feeds,
820
+ // which is where they render.
821
+ if (!result.length)
822
+ return result;
755
823
  if (!ATTR_CARRYING.has(n.type) && hasAttrs(a)) {
756
824
  // In roundtrip mode the carve-block marker lets the reverse direction
757
825
  // restore the attrs onto the inner block instead of keeping a wrapper.
@@ -1066,7 +1134,26 @@ function table(ctx, n, caption, shortCaption = null) {
1066
1134
  // that was never in the source.
1067
1135
  const firstRowIsHead = headCount > 0;
1068
1136
  const firstRow = rows[0] ?? [];
1069
- const colAligns = firstRow.map((c) => (firstRowIsHead ? ALIGN[c.align ?? ''] ?? 'AlignDefault' : 'AlignDefault'));
1137
+ // THE COLUMN COUNT IS THE WIDEST ROW, NOT THE FIRST. A Carve table is
1138
+ // RAGGED - PART 9 §16 keeps each row's own cell count - while a pandoc
1139
+ // table is rectangular and its ColSpec list DEFINES the width. Sizing the
1140
+ // list from the first row therefore emitted a table whose later rows have
1141
+ // cells past the last column, which is not a shape pandoc's own readers
1142
+ // produce: `| ~x~ |` above `| a | b |` declared one column, and `b` was
1143
+ // dropped on the way back with nothing reported. The alignment of a column
1144
+ // no first-row cell covers is simply unset.
1145
+ const width = Math.max(0, ...rows.map((row) => row.length));
1146
+ if (rows.some((row) => row.length < width)) {
1147
+ // Padding is the conversion, not a defect - but it is a change the
1148
+ // author can see, so it is reported like every other one.
1149
+ warn(ctx, `table: ${rows.filter((row) => row.length < width).length} row(s) shorter than the `
1150
+ + `widest (${width} cells) are padded with empty cells - a pandoc table is `
1151
+ + 'rectangular, and PART 9 §16 keeps each row\'s own cell count');
1152
+ }
1153
+ const colAligns = Array.from({ length: width }, (_, c) => {
1154
+ const cell = firstRow[c];
1155
+ return firstRowIsHead && cell ? ALIGN[cell.align ?? ''] ?? 'AlignDefault' : 'AlignDefault';
1156
+ });
1070
1157
  const origin = rows.map(() => []);
1071
1158
  const emitted = rows.map((row) => row.map(() => null));
1072
1159
  for (let r = 0; r < rows.length; r++) {
@@ -1173,8 +1260,50 @@ function table(ctx, n, caption, shortCaption = null) {
1173
1260
  footRows = toRows(at, at + groups.footRows);
1174
1261
  }
1175
1262
  else {
1176
- // The implicit structure: everything after the head is one body.
1177
- bodies = [{ bodyRows: toRows(headCount, rows.length) }];
1263
+ // ROW HEADERS ARE DERIVED WHEN NOTHING DECLARED THEM. A body row may
1264
+ // open with header cells (`|= Mercury | 4,879.4 |`), which the engine
1265
+ // renders as `<th scope="row">`. Pandoc says the same thing with
1266
+ // `RowHeadColumns`, a COUNT on the body - and the count was only ever
1267
+ // read from an explicit `rowGroups`, so a table that simply marked its
1268
+ // first cells came out as ordinary `<td>` and lost the row headers.
1269
+ //
1270
+ // ONE BODY PER RUN OF ROWS THAT AGREE, not one body for the table.
1271
+ //
1272
+ // This used to take the MINIMUM leading run across every body row,
1273
+ // because one `RowHeadColumns` is one number. A body whose rows disagree
1274
+ // therefore lost every row header above that minimum, and lost it
1275
+ // SILENTLY - the cells simply came out `<td>`. Corpus 354-2 is the
1276
+ // smallest case: a plain row followed by a row-header row takes the
1277
+ // minimum 0, so `<th scope="row">b c</th>` came back `<td>b c</td>`.
1278
+ //
1279
+ // Pandoc's own model has the slot for this. A `Table` holds a LIST of
1280
+ // bodies and each carries its own `RowHeadColumns`, so consecutive rows
1281
+ // that agree become one body and a change starts the next one. Nothing
1282
+ // is invented: a document with a single run still emits exactly one
1283
+ // body, which is what every table that agreed already produced.
1284
+ bodies = [];
1285
+ const leadOf = (row) => {
1286
+ let n = 0;
1287
+ while (n < row.length && row[n].header === true)
1288
+ n++;
1289
+ return Math.min(n, width);
1290
+ };
1291
+ let at = headCount;
1292
+ while (at < rows.length) {
1293
+ const lead = leadOf(rows[at]);
1294
+ let to = at + 1;
1295
+ while (to < rows.length && leadOf(rows[to]) === lead)
1296
+ to++;
1297
+ const group = { bodyRows: toRows(at, to) };
1298
+ if (lead > 0)
1299
+ group.rowHeadColumns = lead;
1300
+ bodies.push(group);
1301
+ at = to;
1302
+ }
1303
+ // A table with a head and no body rows at all still needs a body,
1304
+ // because that is the shape pandoc's own readers produce.
1305
+ if (!bodies.length)
1306
+ bodies = [{ bodyRows: [] }];
1178
1307
  }
1179
1308
  return P.Table(toAttr(n.attrs), caption, colAligns, toRows(0, headCount), bodies, footRows, shortCaption);
1180
1309
  }
@@ -1652,6 +1781,8 @@ export function convert(ast, options = {}) {
1652
1781
  warnings: [],
1653
1782
  footnoteDefs,
1654
1783
  crossrefTargets: new Map(),
1784
+ headingIdByText: new Map(),
1785
+ captionTargets: new Set(),
1655
1786
  tight: false,
1656
1787
  inCrossref: false,
1657
1788
  captionCounts: new Map(),
@@ -1726,6 +1857,10 @@ function collectCrossrefTargets(ctx, nodes, captionCounts, inPanel = false) {
1726
1857
  const id = a.id ?? slugify(plainText(children));
1727
1858
  if (id && !ctx.crossrefTargets.has(id))
1728
1859
  ctx.crossrefTargets.set(id, children);
1860
+ const text = plainText(children).trim().toLowerCase();
1861
+ // First heading wins, the same way the id map resolves a duplicate.
1862
+ if (id && text && !ctx.headingIdByText.has(text))
1863
+ ctx.headingIdByText.set(text, id);
1729
1864
  }
1730
1865
  else if (!inPanel && (n.type === 'figure' || n.type === 'table')) {
1731
1866
  // Every figure counts, whatever it wraps. A quote used to be
@@ -1740,6 +1875,7 @@ function collectCrossrefTargets(ctx, nodes, captionCounts, inPanel = false) {
1740
1875
  const a = (n.attrs ?? {});
1741
1876
  if (a.id && !ctx.crossrefTargets.has(a.id)) {
1742
1877
  ctx.crossrefTargets.set(a.id, [{ type: 'text', value: `${label} ${next}` }]);
1878
+ ctx.captionTargets.add(a.id.toLowerCase());
1743
1879
  }
1744
1880
  }
1745
1881
  }
@@ -1784,6 +1920,7 @@ function figureGroupTargets(ctx, n, captionCounts, inPanel) {
1784
1920
  const a = (n.attrs ?? {});
1785
1921
  if (a.id && !ctx.crossrefTargets.has(a.id)) {
1786
1922
  ctx.crossrefTargets.set(a.id, [{ type: 'text', value: resolved }]);
1923
+ ctx.captionTargets.add(a.id.toLowerCase());
1787
1924
  }
1788
1925
  }
1789
1926
  let panelIndex = 0;
package/dist/reverse.js CHANGED
@@ -270,7 +270,16 @@ function link(ctx, c) {
270
270
  const [a, xs, [href, title]] = c;
271
271
  const [, classes] = a;
272
272
  if (classes.includes('uri') || classes.includes('email')) {
273
- return [{ type: 'autolink', href, text: stringify(xs) }];
273
+ const node = { type: 'autolink', href, text: stringify(xs) };
274
+ // `uri`/`email` is the class the forward direction SYNTHESIZES to say
275
+ // what kind of link this is, so it is not the author's and does not
276
+ // come back. Anything else on the Attr is the author's - an autolink
277
+ // takes a trailing attribute like any other inline - and used to be
278
+ // dropped here without a word.
279
+ const rest = fromAttr([a[0], classes.filter((c) => c !== 'uri' && c !== 'email'), a[2]]);
280
+ if (rest)
281
+ node.attrs = rest;
282
+ return [node];
274
283
  }
275
284
  if (classes.includes('crossref')) {
276
285
  return [{ type: 'heading_ref', target: href.replace(/^#/, '') }];
@@ -746,12 +755,25 @@ function table(ctx, c, captionOverride, shortCaptionOverride = null) {
746
755
  // leading run.
747
756
  const allRaw = [...headRaw];
748
757
  const isHeaderRow = headRaw.map(() => true);
758
+ // How many LEADING cells of each row are row headers.
759
+ //
760
+ // This is pandoc's `RowHeadColumns`, and the pipe table spells it directly:
761
+ // a body row opening `|= Mercury |` is a row header, `<th scope="row">`,
762
+ // measured on the engine. It used to be treated as unspellable, so such a
763
+ // table left the pipe form for a `::: list-table {header-cols=N}` - more
764
+ // markup, an extension the reader has to enable, and one number for the
765
+ // whole table where the cells can each say it. Marking the cells keeps the
766
+ // readable form and round-trips, because the forward direction derives the
767
+ // count back from exactly this run.
768
+ const rowHeadCols = headRaw.map(() => 0);
749
769
  const groupBodies = [];
750
770
  for (const body of tbodies) {
751
771
  const bodyHead = body[2];
752
772
  const bodyRows = body[3];
753
773
  allRaw.push(...bodyHead, ...bodyRows);
754
774
  isHeaderRow.push(...bodyHead.map(() => true), ...bodyRows.map(() => false));
775
+ const heads = typeof body[1] === 'number' && body[1] > 0 ? body[1] : 0;
776
+ rowHeadCols.push(...bodyHead.map(() => 0), ...bodyRows.map(() => heads));
755
777
  const group = { headRows: bodyHead.length, bodyRows: bodyRows.length };
756
778
  const rowHeadColumns = body[1];
757
779
  if (typeof rowHeadColumns === 'number' && rowHeadColumns > 0) {
@@ -764,6 +786,7 @@ function table(ctx, c, captionOverride, shortCaptionOverride = null) {
764
786
  }
765
787
  allRaw.push(...footRaw);
766
788
  isHeaderRow.push(...footRaw.map(() => false));
789
+ rowHeadCols.push(...footRaw.map(() => 0));
767
790
  // Occupancy grid: pending[r][c] = continuation marker owed at that position.
768
791
  // Decided BEFORE the grid walk: the pipe path flattens as it goes and warns
769
792
  // while doing it, and that warning is wrong for a table that is about to be
@@ -772,28 +795,12 @@ function table(ctx, c, captionOverride, shortCaptionOverride = null) {
772
795
  const cellBlocks = raw[4];
773
796
  return Array.isArray(cellBlocks) && !isInlineShaped(cellBlocks);
774
797
  }));
775
- // ROW-HEAD COLUMNS are the second reason to leave the pipe form, and the
776
- // only one that is a straight gain: `header-cols=N` (extensions.md §5.1) IS
777
- // pandoc's `RowHeadColumns`, so the list-table spells exactly what the pipe
778
- // table drops. The other things `rowGroups` can carry - a foot, a second
779
- // body, a body's intermediate header rows - have no list-table spelling
780
- // either, so a table that has only those keeps the readable pipe form and
781
- // `reportUnspellableGroups` names the loss instead.
782
- //
783
- // ONLY WHEN THE BODIES AGREE. `header-cols` is one number for the whole
784
- // table, so a table whose bodies differ - and `1` beside a plain `0` is the
785
- // ordinary way they differ - would come back with row headers ADDED to the
786
- // bodies that had none. Widening the markup is worse than the flattening it
787
- // was meant to avoid: dropping a row header renders a `td` where a `th`
788
- // belonged, while inventing one asserts a heading the source never made.
789
- // Such a table keeps the pipe form, where the loss is only a loss and is
790
- // reported. A table forced onto the list-table path by its block cells has
791
- // no such choice, and `listTable` reports the widening there instead.
792
- const rowHeadCounts = new Set(groupBodies.map((b) => b.rowHeadColumns ?? 0));
793
- const hasRowHeadColumns = ctx.target === 'source'
794
- && rowHeadCounts.size === 1
795
- && (groupBodies[0]?.rowHeadColumns ?? 0) > 0;
796
- const useListTable = hasBlockCells || hasRowHeadColumns;
798
+ // Row-head columns no longer send a table to the list-table form: the pipe
799
+ // table marks those cells directly (see `rowHeadCols` above), which is
800
+ // readable, needs no extension, and round-trips. Block cells remain the one
801
+ // reason to leave, because a Carve `table_cell` holds INLINES and there is
802
+ // no pipe form for a cell holding blocks at all.
803
+ const useListTable = hasBlockCells;
797
804
  const pending = allRaw.map(() => Array(nCols));
798
805
  const rows = [];
799
806
  // The pandoc blocks that sat at each grid position, kept alongside the
@@ -801,29 +808,35 @@ function table(ctx, c, captionOverride, shortCaptionOverride = null) {
801
808
  // as blocks without walking the occupancy grid a second time.
802
809
  const cellBlocksAt = allRaw.map(() => []);
803
810
  for (let r = 0; r < allRaw.length; r++) {
804
- const isHeader = isHeaderRow[r] === true;
811
+ // A row is header cells throughout when it is a header ROW, and for its
812
+ // leading run when the body declared row-head columns.
813
+ const headTo = isHeaderRow[r] === true ? nCols : (rowHeadCols[r] ?? 0);
805
814
  const cells = [];
806
815
  const rawCells = allRaw[r][1];
807
816
  let rawIdx = 0;
808
817
  for (let col = 0; col < nCols; col++) {
809
818
  if (pending[r][col]) {
810
- cells.push({ type: 'table_cell', header: isHeader, children: [], span: 'rowspan' });
819
+ cells.push({ type: 'table_cell', header: col < headTo, children: [], span: 'rowspan' });
811
820
  continue;
812
821
  }
813
822
  const raw = rawCells[rawIdx++];
814
823
  if (!raw) {
815
- cells.push({ type: 'table_cell', header: isHeader, children: [] });
824
+ cells.push({ type: 'table_cell', header: col < headTo, children: [] });
816
825
  continue;
817
826
  }
818
827
  const [cellAttr, cellAlign, rowSpan, colSpan, cellBlocks] = raw;
819
828
  cellBlocksAt[r][col] = cellBlocks;
820
829
  const cell = {
821
830
  type: 'table_cell',
822
- header: isHeader,
831
+ header: col < headTo,
823
832
  children: useListTable ? [] : cellInlines(ctx, cellBlocks),
824
833
  };
834
+ // The column's alignment is carried by a HEADER ROW's cells, or by
835
+ // every cell when there is no header row. A row-head cell is not a
836
+ // header row, so it does not carry the column - its own marker
837
+ // aligns itself alone, which is what the engine does.
825
838
  const align = ALIGN_BACK[cellAlign.t]
826
- ?? (isHeader || !hasHeaderRow ? colAligns[col] : '');
839
+ ?? (isHeaderRow[r] === true || !hasHeaderRow ? colAligns[col] : '');
827
840
  if (align)
828
841
  cell.align = align;
829
842
  const cellAttrs = fromAttr(cellAttr);
@@ -831,7 +844,7 @@ function table(ctx, c, captionOverride, shortCaptionOverride = null) {
831
844
  cell.attrs = cellAttrs;
832
845
  cells.push(cell);
833
846
  for (let j = 1; j < colSpan && col + j < nCols; j++) {
834
- cells.push({ type: 'table_cell', header: isHeader, children: [], span: 'colspan' });
847
+ cells.push({ type: 'table_cell', header: col + j < headTo, children: [], span: 'colspan' });
835
848
  }
836
849
  // A 2D block gets a rowspan continuation at EVERY covered column
837
850
  // of the lower rows - that is how Carve's grid expresses it.
@@ -1222,6 +1235,48 @@ function figure(ctx, c) {
1222
1235
  out.push({ type: 'paragraph', children: caption });
1223
1236
  return out;
1224
1237
  }
1238
+ /**
1239
+ * The inverse of convert.ts's `citationDefinition`: citeproc's own bibliography
1240
+ * entry, `Div ("ref-<key>", ["csl-entry"], ...)`, back to a Carve `[@key]:`
1241
+ * definition line.
1242
+ *
1243
+ * The forward direction has written this shape since the arm was added, but
1244
+ * nothing read it, so a bibliography line went out as a definition and came
1245
+ * back as a fenced div carrying an id and a class - the entry rendered in the
1246
+ * body of the document, and the key that binds it to its citations survived
1247
+ * only as the text of an id. That asymmetry was invisible while the engine
1248
+ * still handed a definition line to the bridge as a paragraph holding a
1249
+ * citation group; it surfaced the moment carve-js started emitting a real
1250
+ * `citation_definition` node for one.
1251
+ *
1252
+ * Only the exact shape the forward direction produces converts back: a
1253
+ * `ref-`-prefixed id, `csl-entry` first among the classes, and a body that is
1254
+ * empty or one `Para`. A `--citeproc` bibliography whose entry runs to several
1255
+ * blocks has no Carve definition to be, so it stays the div it is rather than
1256
+ * being flattened into one.
1257
+ */
1258
+ function citationDefinition(ctx, id, classes, kvs, body) {
1259
+ if (classes[0] !== 'csl-entry')
1260
+ return undefined;
1261
+ if (!id.startsWith('ref-') || id === 'ref-')
1262
+ return undefined;
1263
+ if (body.length > 1)
1264
+ return undefined;
1265
+ const only = body[0];
1266
+ if (only && only.t !== 'Para')
1267
+ return undefined;
1268
+ const node = {
1269
+ type: 'citation_definition',
1270
+ key: id.slice('ref-'.length),
1271
+ children: only ? inlines(ctx, only.c) : [],
1272
+ };
1273
+ // The id is the key's carrier here, so it is spent; anything else the Attr
1274
+ // holds is the author's and rides back as the definition's attributes.
1275
+ const attrs = fromAttr(['', classes.slice(1), kvs]);
1276
+ if (attrs)
1277
+ node.attrs = attrs;
1278
+ return node;
1279
+ }
1225
1280
  function div(ctx, c) {
1226
1281
  const [a, body] = c;
1227
1282
  const [id, classes, rawKvs] = a;
@@ -1243,6 +1298,9 @@ function div(ctx, c) {
1243
1298
  return [child];
1244
1299
  }
1245
1300
  }
1301
+ const definition = citationDefinition(ctx, id, classes, kvs, body);
1302
+ if (definition)
1303
+ return [definition];
1246
1304
  let kind;
1247
1305
  let rest = [];
1248
1306
  if (classes.includes('admonition')) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markup-carve/pandoc-carve",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
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",
@@ -49,7 +49,7 @@
49
49
  "source": "https://github.com/markup-carve/pandoc-carve"
50
50
  },
51
51
  "dependencies": {
52
- "@markup-carve/carve": "git+https://github.com/markup-carve/carve-js.git#db224f30d10a3c2d7a192b0e60de66563404b7e5"
52
+ "@markup-carve/carve": "^0.1.4"
53
53
  },
54
54
  "devDependencies": {
55
55
  "@types/node": "^24.0.0",