@helping-ai-workflow/md2doc 3.17.7 → 3.17.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.
@@ -17387,7 +17387,16 @@
17387
17387
  // after a space (`**a **`) is not a closing delimiter in markdown.
17388
17388
  const r = document.createRange();
17389
17389
  r.selectNodeContents(cell);
17390
- if (trimRangeToText(r)) wrapRangeIn(r, tag, cell);
17390
+ if (trimRangeToText(r)) {
17391
+ // v3.17.8 D2 (final review I2): a cell whose text is one code span
17392
+ // (a signal-name column) trims to the code's own text node, and the
17393
+ // wrap landed inside the <code>, which the save drops. The mark goes
17394
+ // around the code instead (`**`b`**`), as it goes around every other
17395
+ // cell's text.
17396
+ const code = tag !== 'CODE' ? wholeSelectionMark(r, 'CODE', cell) : null;
17397
+ if (code) r.selectNode(code);
17398
+ wrapRangeIn(r, tag, cell);
17399
+ }
17391
17400
  }
17392
17401
  });
17393
17402
  snapBurstIfActive(burst.editEl, 'range-mark');
@@ -21616,6 +21625,15 @@
21616
21625
 
21617
21626
  // Applies the toggle policy for a plain mark type (STRONG/EM/CODE/DEL/U —
21618
21627
  // link has its own entry point below because it also needs a URL).
21628
+ // v3.17.8 D2: Markdown cannot hold a mark inside a code span (serializeCode()
21629
+ // writes the code's textContent), so a WRAP of anything but the code itself is
21630
+ // refused when the selection lies wholly inside one <code>. One helper, read
21631
+ // by selectionMarkStates() (the buttons' 'inert') and by the actions (the
21632
+ // keyboard reaches them without a button), so the two cannot drift. Unwrapping
21633
+ // a mark that wraps the code from outside stays allowed: that is spellable.
21634
+ function wrapRefusedInCode(range, tag, root) {
21635
+ return tag !== 'CODE' && !!wholeSelectionMark(range, 'CODE', root);
21636
+ }
21619
21637
  function applyMarkToggle(tag) {
21620
21638
  if (cellRangeBurst()) { applyMarkToCellRange(tag); return; }
21621
21639
  const root = selToolbarEditEl;
@@ -21646,6 +21664,11 @@
21646
21664
  return;
21647
21665
  }
21648
21666
  if (!trimRangeToText(range)) return; // 全空白/collapsed 選取:no-op
21667
+ // v3.17.8 D2: judged on the TRIMMED range, as the wrap sees it. A double
21668
+ // click on a code span's last word also selects the space after it; the
21669
+ // untrimmed range then ends outside the <code>, the trim pulls it back in,
21670
+ // and the wrap landed inside the code (final review I1).
21671
+ if (wrapRefusedInCode(range, tag, root)) return;
21649
21672
  snapBurstIfActive(root, 'mark-pre');
21650
21673
  reselectAndReposition(wrapRangeIn(range, tag, root));
21651
21674
  snapBurstIfActive(root, 'mark');
@@ -21990,6 +22013,7 @@
21990
22013
  // An all-whitespace range trims to nothing: bail before the field opens,
21991
22014
  // or accepting it would wrap a bare space in an <a>.
21992
22015
  if (!trimRangeToText(range)) return;
22016
+ if (wrapRefusedInCode(range, 'A', root)) return; // v3.17.8 D2
21993
22017
  openLinkField({ root, range, existing: null, prefill: '' });
21994
22018
  }
21995
22019
 
@@ -23382,7 +23406,9 @@
23382
23406
  // <strong> was gone, the paragraph's text was unchanged, and the file
23383
23407
  // saved back with no `**` in it. So drawing every button inert there
23384
23408
  // would grey out a button that acts.
23385
- // * 'A' never reaches it. The link button does not go through
23409
+ // * (v3.17.8 D2) A selection wholly inside one <code> makes every other
23410
+ // tag's would-wrap state 'inert' too, 'A' included: wrapRefusedInCode().
23411
+ // * Otherwise 'A' never reaches it. The link button does not go through
23386
23412
  // applyMarkToggle() at all — applyLinkToggle() has the same first two
23387
23413
  // branches, and since v3.4.0 its third refuses an all-whitespace
23388
23414
  // selection through trimRangeToText() before the URL field opens. The
@@ -23405,11 +23431,13 @@
23405
23431
  if (!hasFormattableSelection(sel)) return null;
23406
23432
  const root = selToolbarEditEl;
23407
23433
  const range = sel.getRangeAt(0);
23408
- const wrappable = trimRangeToText(range.cloneRange());
23434
+ const trimmed = range.cloneRange();
23435
+ const wrappable = trimRangeToText(trimmed);
23409
23436
  const out = {};
23410
23437
  for (const tag of MARK_TAGS) {
23411
23438
  if (wholeSelectionMark(range, tag, root)) out[tag] = 'whole';
23412
23439
  else if (overlappingMarks(range, tag, root).length > 0) out[tag] = 'partial';
23440
+ else if (wrapRefusedInCode(wrappable ? trimmed : range, tag, root)) out[tag] = 'inert'; // v3.17.8 D2, trimmed as the wrap sees it
23413
23441
  else if (wrappable || tag === 'A') out[tag] = 'none';
23414
23442
  else out[tag] = 'inert';
23415
23443
  }
@@ -26109,6 +26137,15 @@
26109
26137
  // file kept the plain word. This listener is registered ahead of the
26110
26138
  // `native-format-pre` one below (both capture phase on document, so they
26111
26139
  // run in that order), so the burst exists when that one snaps it.
26140
+ // v3.17.8 D2: the browser's own Ctrl+B / I / U must not wrap inside a code
26141
+ // span either (it would draw <b> in a <code> that the save then drops).
26142
+ document.addEventListener('beforeinput', (e) => {
26143
+ if (!NATIVE_FORMAT_INPUT_TYPES.has(e.inputType) || e.inputType === 'formatRemove') return;
26144
+ const surface = e.target && e.target.closest ? e.target.closest('.ed-wys-armed, .ed-wys-cell') : null;
26145
+ const sel = window.getSelection();
26146
+ if (!surface || !sel || !sel.rangeCount || sel.isCollapsed) return;
26147
+ if (wholeSelectionMark(sel.getRangeAt(0), 'CODE', surface)) e.preventDefault();
26148
+ }, true);
26112
26149
  document.addEventListener('beforeinput', (e) => {
26113
26150
  if (e.defaultPrevented) return;
26114
26151
  const t = e.inputType;
@@ -25,6 +25,7 @@
25
25
  return ch !== undefined && /\w/.test(ch);
26
26
  }
27
27
 
28
+ const REFERENCE_SHAPE = /^&#?\w+;/;
28
29
  function escapeText(s) {
29
30
  let out = '';
30
31
  for (let i = 0; i < s.length; i++) {
@@ -36,6 +37,13 @@
36
37
  if (c === ']') { out += '\\]'; continue; }
37
38
  if (c === '<') { out += '&lt;'; continue; }
38
39
  if (c === '~') { out += '\\~'; continue; }
40
+ // v3.17.8 D3: an `&` that starts something shaped like a character
41
+ // reference (`&amp;`, `&#38;`, `&x_1;`) is written `\&`, or the reader
42
+ // decodes what the person typed (`a &amp; b` showed `a & b`). The shape
43
+ // is marked's own, `&#?\w+;` (its escape() leaves exactly that alone),
44
+ // so it covers every reference a browser could decode. Any other `&`
45
+ // (`AT&T`, `&copy` with no `;`) is shown as typed and stays bare.
46
+ if (c === '&' && REFERENCE_SHAPE.test(s.slice(i, i + 40))) { out += '\\&'; continue; }
39
47
  if (c === '_') {
40
48
  const prev = s[i - 1];
41
49
  const next = s[i + 1];
@@ -381,6 +381,20 @@ function htmlToMarkdown(html) {
381
381
  codeBlockStyle: 'fenced',
382
382
  emDelimiter: '*'
383
383
  });
384
+ // v3.17.8 D3: turndown's escape() covers Markdown punctuation only, so
385
+ // pasted TEXT that spells HTML came back as markup: `<b>x</b>` turned bold,
386
+ // `<!-- c -->` vanished, `&amp;` showed as `&`. A `<` that could open a tag,
387
+ // a comment or an autolink is written `&lt;`, and an `&` shaped like a
388
+ // character reference is written `\&` -- the spellings inline-md.js's
389
+ // escapeText() gives typed text. A bare `<` (`1 < 2`) or `&` (`AT&T`) is
390
+ // already shown as typed and stays bare. Code is not escaped by turndown
391
+ // at all, and needs none: a code span is literal.
392
+ var baseEscape = service.escape;
393
+ service.escape = function (s) {
394
+ return baseEscape.call(this, s)
395
+ .replace(/&(?=#?\w+;)/g, '\\&')
396
+ .replace(/<(?=[A-Za-z\/!?])/g, '&lt;');
397
+ };
384
398
  addStrikethroughRule(service);
385
399
  addFlankingEmphasisRules(service);
386
400
  addTableRules(service);
@@ -29,14 +29,14 @@
29
29
  // nothing to do with text alignment. Reading them as alignment would be a
30
30
  // bug; they are deliberately never consulted below.
31
31
  //
32
- // ── Emission form (spec §4 / task-5 brief, amended by v3.0.1) ──────────
33
- // There are TWO emission forms, and exactly one gate selects between them:
34
- // whether serializeTable() was handed an `originalMd` it could turn into a
35
- // usable width source (columnWidthsOf() parsed it, AND the greedy
36
- // header-TEXT walk in serializeTable() matched EVERY original column, in
37
- // order — the all-or-nothing gate). No second argument, an unparseable or
38
- // ragged original, a renamed header, a dropped column: all of them land on
39
- // form A.
32
+ // ── Emission form (spec §4 / task-5 brief, amended by v3.0.1, v3.17.8) ─
33
+ // There are TWO emission forms for the padding. Form B applies when
34
+ // serializeTable() was handed an `originalMd` it could turn into a width
35
+ // source: columnWidthsOf() parsed it (not ragged) and the forward header
36
+ // scan in serializeTable() found no MOVED column. Since v3.0.2 a renamed
37
+ // or dropped column no longer voids it; only that column loses its width.
38
+ // No second argument, an unparseable or ragged original, a moved column:
39
+ // all of them land on form A.
40
40
  //
41
41
  // A. MINIMAL form (pre-v3.0.1, unchanged, and still what
42
42
  // `serializeTable(tableEl)` with no second argument emits — byte for
@@ -60,18 +60,30 @@
60
60
  // separators Prettier and VS Code emit ('| ----- | --- |') survive a
61
61
  // no-op instead of being rebuilt flush ('|-------|-----|').
62
62
  //
63
- // Independent of the form: a literal '|' inside
64
- // a cell -> '&#124;'; a cell newline is the literal '<br>' inline-md.js's
63
+ // Cell bytes (v3.17.8 F1, both forms): a cell whose original field reads
64
+ // the same as its current content (see readsSame()) is written with the
65
+ // original field's bytes, trimmed; every other cell gets the canonical
66
+ // spelling below. A header row or body row whose cells were ALL reused, in
67
+ // the original columns, with no column widened past that line's own
68
+ // fields, is the original line itself (trimmed), odd padding like '|a|b|'
69
+ // included. Body rows are paired with original rows by pairBodyRows(), so a
70
+ // row inserted, deleted or moved leaves the others as they were. With no
71
+ // parser nothing is reused, and the output is what it was before v3.17.8.
72
+ //
73
+ // Canonical spelling: a literal '|' inside a cell's text -> '&#124;'; a
74
+ // cell newline is the literal '<br>' inline-md.js's
65
75
  // serializeInline() already emits for both a real <br> node and a
66
76
  // contenteditable <div> line-split boundary — AND (final-review Finding 1
67
77
  // defense-in-depth) any raw '\n'/'\r' that still reaches a text node is
68
78
  // collapsed to '<br>' here too, via escapeNewlines() below, so a cell can
69
79
  // never split serializeTable()'s output into more physical lines than
70
- // it has rows. No trailing whitespace on any emitted line (every row/
71
- // separator line is built as '|'-delimited fields, so it always ends in
72
- // '|', never in whitespace, by construction). A '|' inside a <code> span
73
- // is written as '\|' instead (v3.17.6) — see codePipesOf() below; the one
74
- // code text that has no table spelling reports 'CODE' unsupported.
80
+ // it has rows. A '|' inside a <code> span is written as '\|' instead
81
+ // (v3.17.6) — see codePipesOf() below; the one code text that has no
82
+ // table spelling reports 'CODE' unsupported. A reused field never holds a
83
+ // newline or an unescaped '|' (it is one field of one source line). No
84
+ // trailing whitespace on any emitted line: a built row or separator is
85
+ // '|'-delimited fields, and an original line is trimmed and, to be read
86
+ // at all, ends in an unescaped '|' (splitFields()).
75
87
  //
76
88
  // Constrained (same as inline-md.js, and for the same reason: the node
77
89
  // test drives this with the hand-rolled element stub from
@@ -190,22 +202,35 @@
190
202
  // width of 3 is indistinguishable from this serializer's own minimal
191
203
  // form, which is exactly 3 on every row by construction).
192
204
  //
193
- // Source THIS serializer produced holds '\|' only inside code spans (plain
194
- // text gets '&#124;'), and a hand-written table may use '\|' anywhere, so
195
- // the split must not break on an escaped delimiter.
205
+ // A hand-written table may use '\|' anywhere, and since v3.17.8 so may
206
+ // this serializer's output (a reused field keeps the author's '\|'), so
207
+ // the split must not break on an escaped delimiter. Escaped means what
208
+ // marked's splitCells() means (v3.17.8 F3): an ODD run of backslashes
209
+ // before the '|'. A backslash and the character after it are taken as one
210
+ // pair, so '\\|' is an escaped backslash and then a delimiter; before this
211
+ // the split read that '|' as escaped, the row came out one field short and
212
+ // the whole table lost its width memory. A line whose last '|' is escaped
213
+ // has no closing delimiter and is not a row this can read (see the end).
196
214
  function splitFields(line) {
197
215
  const t = line.trim();
198
- if (t.charAt(0) !== '|' || t.charAt(t.length - 1) !== '|' || t.length < 2) return null;
199
- const inner = t.slice(1, -1);
216
+ if (t.charAt(0) !== '|' || t.length < 2) return null;
200
217
  const out = [];
201
218
  let cur = '';
202
- for (let i = 0; i < inner.length; i++) {
203
- const c = inner.charAt(i);
204
- if (c === '\\' && inner.charAt(i + 1) === '|') { cur += '\\|'; i += 1; continue; }
219
+ for (let i = 1; i < t.length; i++) {
220
+ const c = t.charAt(i);
221
+ if (c === '\\' && i + 1 < t.length) { cur += c + t.charAt(i + 1); i += 1; continue; }
205
222
  if (c === '|') { out.push(cur); cur = ''; continue; }
206
223
  cur += c;
207
224
  }
208
- out.push(cur);
225
+ // A line whose last '|' is escaped (`| p | q \|`) has no closing
226
+ // delimiter: not a row this can read, so it is never reused and the table
227
+ // has no width memory. v3.17.8's final-review round 1 split it the old way
228
+ // instead (last '|' closes the row) to keep the width memory, and the
229
+ // re-review found that this loses an edit: the field ` q \` read alone is
230
+ // `q \`, but in its line it reads `q |`, so an edit from `q |` to `q \`
231
+ // read "the same" and the original line came back (N1). Width memory
232
+ // for such a rare table is not worth that.
233
+ if (cur !== '') return null;
209
234
  return out;
210
235
  }
211
236
 
@@ -268,6 +293,231 @@
268
293
  return { widths, headerFields: first.map((f) => f.trim()), sepFields };
269
294
  }
270
295
 
296
+ // ── v3.17.8 F1: an untouched cell keeps its bytes ─────────────────────
297
+ // Every cell is serialized from the DOM, so a cell nobody edited comes
298
+ // back in this file's canonical spelling: a plain '[' as '\[', a
299
+ // plain-text '\|' as '&#124;'. The meaning is the same; the git diff is
300
+ // not. So a current cell reuses its ORIGINAL field (trimmed) when the two
301
+ // spellings READ the same, and that is decided by parsing, not by rules:
302
+ // a private `new Marked({ gfm: true, breaks: false })` (the same family as
303
+ // inline-md.js's getInlineParser(); server.js injects marked before the
304
+ // editor modules) renders each spelling as the one cell of a one-column
305
+ // table, and the two cell HTMLs are compared after character references
306
+ // in their text are decoded (marked passes '&#124;' through, the browser
307
+ // shows '|'). A cell is parsed once per string (readCache).
308
+ //
309
+ // The plain parser has none of md2doc's sub / sup / KaTeX extensions, so
310
+ // an original holding an unescaped '~', '^' or '$' is reused only when it
311
+ // is byte-equal to the canonical spelling (R5). No parser (a page without
312
+ // marked, or setCellParser(null)): nothing is reused and every byte is
313
+ // what the serializer wrote before v3.17.8.
314
+ let cellParser; // undefined: not looked up yet; null: none available
315
+ const readCache = new Map();
316
+ function getCellParser() {
317
+ if (cellParser !== undefined) return cellParser;
318
+ cellParser = null;
319
+ try {
320
+ let lib = null;
321
+ if (typeof module === 'object' && module.exports && typeof require === 'function') lib = require('marked');
322
+ else if (typeof self !== 'undefined' && self.marked) lib = self.marked;
323
+ if (lib && typeof lib.Marked === 'function') {
324
+ const m = new lib.Marked({ gfm: true, breaks: false });
325
+ cellParser = (s) => m.parse(s);
326
+ }
327
+ } catch (e) {
328
+ cellParser = null;
329
+ }
330
+ return cellParser;
331
+ }
332
+ // Tests swap the parser: a function (markdown -> HTML), null to switch
333
+ // reuse off, or nothing to go back to the lookup above.
334
+ function setCellParser(fn) {
335
+ cellParser = typeof fn === 'function' ? fn : (fn === null ? null : undefined);
336
+ readCache.clear();
337
+ }
338
+
339
+ const CELL_HEAD = '<table>\n<thead>\n<tr>\n<th>h</th>\n</tr>\n</thead>\n<tbody><tr>\n<td>';
340
+ const CELL_TAIL = '</td>\n</tr>\n</tbody></table>\n';
341
+ const NAMED_REF = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' };
342
+ // A named reference outside NAMED_REF cannot be decoded here, so a cell
343
+ // holding one has no reading at all (final review I3): left as it was, the
344
+ // `&` was re-escaped and `&copy;` (shows ©) read the same as the typed
345
+ // text `\&copy;` (shows &copy;), so an edit to that text was overwritten
346
+ // by the original bytes. Such a cell is now reused only when byte-equal.
347
+ const UNKNOWN_REF = {};
348
+ function decodeRef(m, dec, hex, name) {
349
+ if (name !== undefined) {
350
+ if (NAMED_REF[name] !== undefined) return NAMED_REF[name];
351
+ throw UNKNOWN_REF;
352
+ }
353
+ const n = dec !== undefined ? parseInt(dec, 10) : parseInt(hex, 16);
354
+ return n === 0 || n > 0x10ffff || (n >= 0xd800 && n <= 0xdfff) ? '�' : String.fromCodePoint(n);
355
+ }
356
+ // Text between tags: references decoded, then & < > " re-escaped, so two
357
+ // spellings of one character give one string. Tags are left as they are.
358
+ // HTML comments are dropped first: nothing shows them, the editor's DOM
359
+ // walk skips them (so the canonical spelling never has one), and
360
+ // inline-md.js's own read-back (htmlMarks()) skips them too. That keeps
361
+ // an untouched cell's comments, e.g. the `<!-- [[source, Cl.x]] -->`
362
+ // citation anchors in paperwork's hwe1 tables, which the canonical
363
+ // spelling would drop.
364
+ function normalizeCellHtml(html) {
365
+ return html.replace(/<!--[\s\S]*?-->/g, '').split(/(<[^>]*>)/).map((part, i) => (i % 2 ? part : part
366
+ .replace(/&(?:#([0-9]+)|#[xX]([0-9a-fA-F]+)|([A-Za-z][A-Za-z0-9]*));/g, decodeRef)
367
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'))).join('');
368
+ }
369
+ // The normalized HTML of `field` as the one cell of a one-column table, or
370
+ // null when it is not read as exactly that (then it never reads the same
371
+ // as anything).
372
+ function cellReading(parse, field) {
373
+ if (readCache.has(field)) return readCache.get(field);
374
+ let v = null;
375
+ try {
376
+ const html = parse('| h |\n| - |\n| ' + field + ' |');
377
+ if (typeof html === 'string' && html.startsWith(CELL_HEAD) && html.endsWith(CELL_TAIL)) {
378
+ const inner = html.slice(CELL_HEAD.length, html.length - CELL_TAIL.length);
379
+ if (inner.indexOf('</td>') === -1) v = normalizeCellHtml(inner);
380
+ }
381
+ } catch (e) {
382
+ v = null;
383
+ }
384
+ // FIFO, not a clear: a table larger than the cap read itself cold on every
385
+ // commit when the whole cache was dropped (final review M1).
386
+ if (readCache.size >= READ_CACHE_MAX) readCache.delete(readCache.keys().next().value);
387
+ readCache.set(field, v);
388
+ return v;
389
+ }
390
+ const READ_CACHE_MAX = 100000;
391
+ // One string per cell such that readsSame(a, b) === (readingKey(a) ===
392
+ // readingKey(b)): a cell with a reading is keyed by it, one without by its
393
+ // bytes, so it only ever pairs with itself. pairBodyRows() hashes rows on it.
394
+ function readingKey(parse, x) {
395
+ if (!parse) return 'B' + x;
396
+ const r = cellReading(parse, x);
397
+ return r === null ? 'B' + x : 'R' + r;
398
+ }
399
+ function readsSame(parse, a, b) {
400
+ if (a === b) return true;
401
+ const ra = cellReading(parse, a);
402
+ return ra !== null && ra === cellReading(parse, b);
403
+ }
404
+ // '~' (sub), '^' (sup) or '$' (KaTeX) after an even backslash run (R5).
405
+ const EXTENSION_CHAR = /(?:^|[^\\])(?:\\\\)*[~^$]/;
406
+
407
+ // The original table's rows for reuse: { header, body } where `header` is
408
+ // { fields, line } and `body` holds one { fields, line } per body row, or
409
+ // null for a line splitFields() cannot read (it is then never paired).
410
+ // `line` is the source line trimmed. Unlike columnWidthsOf(), a ragged row
411
+ // does not void the table: reuse is decided per cell. null when there is
412
+ // no original or its header line is not a row.
413
+ function originalRowsOf(originalMd) {
414
+ if (typeof originalMd !== 'string' || originalMd === '') return null;
415
+ const rows = originalMd.split('\n').filter((l) => l.trim() !== '');
416
+ if (rows.length < 2) return null;
417
+ const row = (l) => { const f = splitFields(l); return f ? { fields: f, line: l.trim() } : null; };
418
+ const header = row(rows[0]);
419
+ if (!header) return null;
420
+ return { header, body: rows.slice(2).map(row) };
421
+ }
422
+
423
+ // Pairs each current body row with an original body row (an index into
424
+ // `origBody`, or -1). A row "matches" when every mapped cell reads the
425
+ // same. (1) a forward greedy scan pairs matching rows in order — the
426
+ // anchors; (2) a row left over that matches some unused original anywhere
427
+ // is paired with it (a moved row); (3) the rows still left between two
428
+ // anchors are paired in order with the unused originals between the same
429
+ // anchors — that is an edited row, whose other cells are then reused one
430
+ // by one. A wrong pair only costs preserved bytes: every reuse is gated by
431
+ // readsSame() again, cell by cell.
432
+ // Linear in rows (final review M1: the scans were quadratic, 3000 rows
433
+ // that all differ took 3 s): each row is hashed on its mapped cells'
434
+ // readingKey(), so "matches" is one Map lookup.
435
+ function pairBodyRows(curRows, origBody, colMap, keyOf) {
436
+ const mapped = [];
437
+ colMap.forEach((k, i) => { if (k !== undefined) mapped.push(i); });
438
+ const pair = new Array(curRows.length).fill(-1);
439
+ if (mapped.length === 0) return pair;
440
+ const SEP = '\u0000';
441
+ const origKey = origBody.map((o) => {
442
+ if (!o) return null;
443
+ const parts = [];
444
+ for (let x = 0; x < mapped.length; x++) {
445
+ const f = o.fields[colMap[mapped[x]]];
446
+ if (f === undefined) return null;
447
+ parts.push(keyOf(f.trim()));
448
+ }
449
+ return parts.join(SEP);
450
+ });
451
+ const curKey = curRows.map((row) => mapped.map((i) => keyOf(row[i])).join(SEP));
452
+ // key -> { list: ascending original indices, p1: step-1 cursor, p2: step-2 cursor }
453
+ const byKey = new Map();
454
+ origKey.forEach((k, q) => {
455
+ if (k === null) return;
456
+ let e = byKey.get(k);
457
+ if (!e) { e = { list: [], p1: 0, p2: 0 }; byKey.set(k, e); }
458
+ e.list.push(q);
459
+ });
460
+ const used = new Array(origBody.length).fill(false);
461
+ const anchor = new Array(curRows.length).fill(false);
462
+ // (1) forward greedy: the first unused match at or after j. Every original
463
+ // used in this step lies before j, so a cursor that skips indices < j
464
+ // finds it, and it only moves forward.
465
+ let j = 0;
466
+ for (let c = 0; c < curRows.length; c++) {
467
+ const e = byKey.get(curKey[c]);
468
+ if (!e) continue;
469
+ while (e.p1 < e.list.length && e.list[e.p1] < j) e.p1++;
470
+ if (e.p1 < e.list.length) {
471
+ const q = e.list[e.p1++];
472
+ pair[c] = q; used[q] = true; anchor[c] = true; j = q + 1;
473
+ }
474
+ }
475
+ // (2) a leftover row that matches an unused original anywhere (moved).
476
+ for (let c = 0; c < curRows.length; c++) {
477
+ if (pair[c] !== -1) continue;
478
+ const e = byKey.get(curKey[c]);
479
+ if (!e) continue;
480
+ while (e.p2 < e.list.length && used[e.list[e.p2]]) e.p2++;
481
+ if (e.p2 < e.list.length) { const q = e.list[e.p2++]; pair[c] = q; used[q] = true; }
482
+ }
483
+ // (3) the rows still left between two anchors take the unused originals
484
+ // between the same anchors, in order.
485
+ const nextAnchorQ = new Array(curRows.length + 1);
486
+ nextAnchorQ[curRows.length] = origBody.length;
487
+ for (let c = curRows.length - 1; c >= 0; c--) nextAnchorQ[c] = anchor[c] ? pair[c] : nextAnchorQ[c + 1];
488
+ let q = 0;
489
+ for (let c = 0; c < curRows.length; c++) {
490
+ if (anchor[c]) { q = pair[c] + 1; continue; }
491
+ if (pair[c] !== -1) continue;
492
+ const nextQ = nextAnchorQ[c + 1];
493
+ while (q < nextQ && (used[q] || !origBody[q])) q++;
494
+ if (q < nextQ) { pair[c] = q; used[q] = true; q++; }
495
+ }
496
+ return pair;
497
+ }
498
+
499
+ // The contents a row is emitted with: each cell's original field where it
500
+ // may be reused, else its canonical content. `whole` is true when every
501
+ // cell was reused and the columns are the original's, in order — the row
502
+ // may then be the original line itself (decided at emission, once widths
503
+ // are known).
504
+ function reuseRow(parse, canon, cellBad, orig, colMap, nOrigCols) {
505
+ if (!orig) return { contents: canon, whole: false };
506
+ let whole = canon.length === orig.fields.length && canon.length === nOrigCols;
507
+ const contents = canon.map((c, i) => {
508
+ const k = colMap[i];
509
+ if (k !== i) whole = false;
510
+ const field = k === undefined ? undefined : orig.fields[k];
511
+ if (field === undefined || cellBad[i]) { whole = false; return c; }
512
+ const f = field.trim();
513
+ if (f === c) return c;
514
+ if (!EXTENSION_CHAR.test(f) && readsSame(parse, f, c)) return f;
515
+ whole = false;
516
+ return c;
517
+ });
518
+ return { contents, whole };
519
+ }
520
+
271
521
  // ' content ' right-padded out to `width`. NEVER truncates: a cell whose
272
522
  // content grew past the original column simply widens that column, and a
273
523
  // deliberately blank hand-aligned cell (e.g. `| |` kept wide on
@@ -380,13 +630,22 @@
380
630
  // Render every cell's content up front (same order the pre-v3.0.1 code
381
631
  // walked: header first, then each body row left-to-right), so the width
382
632
  // pass below and the emission pass after it share one rendering.
383
- const headerContents = headerCells.map((c) => cellContentOf(c, unsupported));
384
- const bodyContentRows = bodyRows.map((tr) =>
385
- elementChildren(tr).filter(isCell).map((c) => cellContentOf(c, unsupported)));
633
+ // `bad` marks a cell that reported unsupported: it is never reused, so a
634
+ // reuse can never stand in for (or hide) such a cell.
635
+ const cellOf = (c) => {
636
+ const before = unsupported.length;
637
+ const md = cellContentOf(c, unsupported);
638
+ return { md, bad: unsupported.length !== before };
639
+ };
640
+ const headerCellMd = headerCells.map(cellOf);
641
+ const bodyCellMd = bodyRows.map((tr) => elementChildren(tr).filter(isCell).map(cellOf));
642
+ // The canonical contents; replaced below by the reused ones (F1).
643
+ let headerContents = headerCellMd.map((x) => x.md);
644
+ let bodyContentRows = bodyCellMd.map((row) => row.map((x) => x.md));
386
645
 
387
646
  // Read the ORIGINAL widths first, then line each CURRENT header column
388
- // up with the original column it corresponds to BY TEXT, not by raw
389
- // index. A column inserted in the MIDDLE (insertColumn() in client.js)
647
+ // up with the original column it corresponds to BY TEXT (since v3.17.8,
648
+ // by how the text reads: `same` below), not by raw index. A column inserted in the MIDDLE (insertColumn() in client.js)
390
649
  // shifts every later column's index by one without changing what that
391
650
  // column actually is — matching by index alone would hand the shifted
392
651
  // column a stale neighbour's width, and the freshly-inserted empty
@@ -411,6 +670,17 @@
411
670
  // patch release carries. See test/table-width.test.js cases 14, 15, 20,
412
671
  // 21, 23 and 24, which pin every branch of the above.
413
672
  const original = columnWidthsOf(originalMd);
673
+ // F1: with a parser, names are compared by how they READ (a header
674
+ // spelled 'a\_b' in the file is the DOM's 'a_b'); without one, by bytes,
675
+ // as before. `src` is the original's rows for reuse (null: no reuse).
676
+ const parse = getCellParser();
677
+ const same = parse ? (a, b) => readsSame(parse, a, b) : (a, b) => a === b;
678
+ const src = parse ? originalRowsOf(originalMd) : null;
679
+ const origNames = original ? original.headerFields
680
+ : (src ? src.header.fields.map((f) => f.trim()) : null);
681
+ // colIdx[i]: the original column current column i was matched to.
682
+ const colIdx = new Array(headerContents.length);
683
+ let moved = false;
414
684
  let widths = null;
415
685
  // Set for a column that falls off the END of the original header list
416
686
  // (every original column has already been matched by the time this one
@@ -445,8 +715,7 @@
445
715
  // separator did not actually change (F1, final review) — see the
446
716
  // verbatim gate at the emission site.
447
717
  const matchedSepFields = new Array(headerContents.length);
448
- if (original) {
449
- const matched = new Array(headerContents.length);
718
+ if (origNames) {
450
719
  // v3.0.2: a FORWARD SCAN, not the pre-v3.0.2 lockstep compare. The
451
720
  // lockstep version left `j` parked on the first original column that
452
721
  // did not match, so it could never recover from one mismatch — and
@@ -460,15 +729,14 @@
460
729
  for (let i = 0; i < headerContents.length; i++) {
461
730
  const name = headerContents[i].trim();
462
731
  let k = -1;
463
- for (let q = j; q < original.headerFields.length; q++) {
464
- if (original.headerFields[q].trim() === name) { k = q; break; }
732
+ for (let q = j; q < origNames.length; q++) {
733
+ if (same(origNames[q].trim(), name)) { k = q; break; }
465
734
  }
466
735
  if (k >= 0) {
467
- matched[i] = original.widths[k];
468
- matchedSepFields[i] = original.sepFields[k];
736
+ colIdx[i] = k;
737
+ if (original) matchedSepFields[i] = original.sepFields[k];
469
738
  j = k + 1;
470
739
  } else {
471
- matched[i] = undefined;
472
740
  // The EMPTY name is exempt from the reorder test below, and the
473
741
  // exemption is load-bearing, not defensive: insertColumn()
474
742
  // (lib/editor/client.js:6448-6465) creates a th (or td) with no
@@ -479,7 +747,7 @@
479
747
  // whole-table refusal — a straight regression against v3.0.1 on
480
748
  // the ordinary insert-a-column-twice flow.
481
749
  if (name !== '') unmatchedNames.push(name);
482
- if (j >= original.headerFields.length) sepFallbackNatural[i] = true;
750
+ if (original && j >= origNames.length) sepFallbackNatural[i] = true;
483
751
  }
484
752
  }
485
753
  // A column that could not find itself in what remains of the original,
@@ -489,10 +757,50 @@
489
757
  // so it still discards the width source for the WHOLE table. A name
490
758
  // that is absent from the original entirely is an insert or a rename:
491
759
  // nothing stale to carry, so its neighbours keep their widths.
492
- const moved = unmatchedNames.some((n) =>
493
- original.headerFields.some((f) => f.trim() === n));
760
+ moved = unmatchedNames.some((n) =>
761
+ origNames.some((f) => same(f.trim(), n)));
762
+ // F1: the cells of a column the scan did not match (a moved one) are
763
+ // still reused when exactly one unmatched original column has its name.
764
+ // This feeds reuse only; the width refusal above stands.
765
+ if (src) {
766
+ const taken = new Set(colIdx.filter((k) => k !== undefined));
767
+ for (let i = 0; i < headerContents.length; i++) {
768
+ const name = headerContents[i].trim();
769
+ if (colIdx[i] !== undefined || name === '') continue;
770
+ const hits = [];
771
+ for (let q = 0; q < origNames.length; q++) {
772
+ if (!taken.has(q) && same(origNames[q].trim(), name)) hits.push(q);
773
+ }
774
+ if (hits.length === 1) { colIdx[i] = hits[0]; taken.add(hits[0]); }
775
+ }
776
+ }
777
+ }
778
+
779
+ // F1: reuse each untouched cell's original bytes (see reuseRow()), before
780
+ // the widths are settled, so the reused contents are what they measure.
781
+ let headerWhole = false;
782
+ const bodyWhole = bodyContentRows.map(() => false);
783
+ const bodyOrig = bodyContentRows.map(() => null);
784
+ if (src) {
785
+ const nOrigCols = src.header.fields.length;
786
+ const h = reuseRow(parse, headerContents, headerCellMd.map((x) => x.bad), src.header, colIdx, nOrigCols);
787
+ headerContents = h.contents;
788
+ headerWhole = h.whole;
789
+ const pair = pairBodyRows(bodyContentRows, src.body, colIdx, (x) => readingKey(parse, x));
790
+ bodyContentRows = bodyContentRows.map((canon, r) => {
791
+ const orig = pair[r] === -1 ? null : src.body[pair[r]];
792
+ const b = reuseRow(parse, canon, bodyCellMd[r].map((x) => x.bad), orig, colIdx, nOrigCols);
793
+ bodyWhole[r] = b.whole;
794
+ bodyOrig[r] = orig;
795
+ return b.contents;
796
+ });
797
+ }
798
+
799
+ if (original) {
494
800
  if (!moved) {
495
- widths = matched;
801
+ // Not moved means the name rescue above found nothing (an unmatched
802
+ // name is absent from the original), so colIdx is the scan's alone.
803
+ widths = colIdx.map((k) => (k === undefined ? undefined : original.widths[k]));
496
804
  // A matched column is then widened, for every row of that column
497
805
  // at once (header + separator + every body row), to whatever the
498
806
  // grown content now needs — the padding floor rises but never
@@ -515,8 +823,14 @@
515
823
  }
516
824
  }
517
825
 
826
+ // F1: a row whose cells were all reused, in the original columns, is the
827
+ // original line itself (trimmed), padding and all, unless a column it
828
+ // holds was widened past that line's own field.
829
+ const asWritten = (whole, orig) => whole && orig.fields.every((f, i) =>
830
+ !widths || widths[i] === undefined || widths[i] <= f.length);
518
831
  const lines = [];
519
- lines.push(serializeRow(headerContents, widths));
832
+ lines.push(asWritten(headerWhole, src && src.header) ? src.header.line
833
+ : serializeRow(headerContents, widths));
520
834
  lines.push('|' + headerCells.map((c, i) => {
521
835
  // Preference order for a column's separator width:
522
836
  // 1. widths[i] — this column is a full hand-aligned width source
@@ -592,10 +906,11 @@
592
906
  return sepCellFor(align, w);
593
907
  }).join('|') + '|');
594
908
 
595
- bodyContentRows.forEach((contents) => lines.push(serializeRow(contents, widths)));
909
+ bodyContentRows.forEach((contents, r) => lines.push(asWritten(bodyWhole[r], bodyOrig[r])
910
+ ? bodyOrig[r].line : serializeRow(contents, widths)));
596
911
 
597
912
  return { md: lines.join('\n'), unsupported };
598
913
  }
599
914
 
600
- return { serializeTable };
915
+ return { serializeTable, setCellParser };
601
916
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@helping-ai-workflow/md2doc",
3
- "version": "3.17.7",
3
+ "version": "3.17.8",
4
4
  "description": "Markdown → HTML / PDF renderer with WaveDrom, Mermaid, Graphviz, and draw.io support",
5
5
  "keywords": [
6
6
  "markdown",