@helping-ai-workflow/md2doc 3.17.6 → 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];
@@ -154,6 +162,13 @@
154
162
  return inner.indexOf(']') === -1;
155
163
  }
156
164
 
165
+ // The test serializeAnchor() uses to write `[[body]]` (v3.17.7: shared
166
+ // with serializeInline()'s read-back check).
167
+ function isCitationNode(node) {
168
+ const href = typeof node.getAttribute === 'function' ? node.getAttribute('href') : null;
169
+ const text = node.textContent;
170
+ return !!href && href.charAt(0) === '#' && /^\[.*\]$/.test(text) && isCitationEligible(node, text);
171
+ }
157
172
  function serializeAnchor(node, unsupported, opts) {
158
173
  const href = node.getAttribute('href');
159
174
  // A raw HTML anchor (`<a id="ref-x"></a>`, NO href attribute) is not a
@@ -391,10 +406,16 @@
391
406
  if (typeof node.getAttribute !== 'function') return false;
392
407
  return (' ' + (node.getAttribute('class') || '') + ' ').indexOf(' search-hit ') !== -1;
393
408
  }
394
- function walkChildren(nodes, unsupported, opts) {
409
+ // `ctx` (v3.17.7) says how a STRONG / EM nested inside another STRONG / EM
410
+ // is spelled; see serializeInline(). Absent means the plain `*` spelling.
411
+ // `touching` (v3.17.7) is the mark tag that ends right before these
412
+ // nodes, for a transparent wrapper whose first child may need the
413
+ // same-tag guard against it.
414
+ function walkChildren(nodes, unsupported, opts, ctx, touching) {
415
+ const c = ctx || PLAIN;
395
416
  let out = '';
396
417
  let firstSegment = true;
397
- let lastMarkTag = null;
418
+ let lastMarkTag = touching || null;
398
419
  for (let i = 0; i < nodes.length; i++) {
399
420
  const node = nodes[i];
400
421
  if (node.nodeType === 3) {
@@ -409,7 +430,7 @@
409
430
  if (name === 'DIV') {
410
431
  let added = '';
411
432
  if (!firstSegment) added += '<br>';
412
- const inner = walkChildren(node.childNodes, unsupported, opts);
433
+ const inner = walkChildren(node.childNodes, unsupported, opts, c);
413
434
  added += inner.md;
414
435
  out = appendPiece(out, added);
415
436
  if (added.length) lastMarkTag = inner.md.length ? inner.lastMarkTag : null;
@@ -419,7 +440,7 @@
419
440
  if (name === 'MARK' && markIsSearchHit(node)) {
420
441
  // v3.14.0 T9: the reader's search paints its hits as this element and
421
442
  // it is transient, so it serializes as what it wraps.
422
- const inner = walkChildren(node.childNodes, unsupported, opts);
443
+ const inner = walkChildren(node.childNodes, unsupported, opts, c, lastMarkTag);
423
444
  out = appendPiece(out, inner.md);
424
445
  if (inner.md.length) lastMarkTag = inner.lastMarkTag;
425
446
  firstSegment = false;
@@ -429,7 +450,7 @@
429
450
  if (spanHasAttributes(node)) {
430
451
  unsupported.push(name);
431
452
  } else {
432
- const inner = walkChildren(node.childNodes, unsupported, opts);
453
+ const inner = walkChildren(node.childNodes, unsupported, opts, c, lastMarkTag);
433
454
  out = appendPiece(out, inner.md);
434
455
  if (inner.md.length) lastMarkTag = inner.lastMarkTag;
435
456
  }
@@ -438,12 +459,12 @@
438
459
  }
439
460
  if (name === 'STRONG' || name === 'B' || name === 'EM' || name === 'I') {
440
461
  const tag = (name === 'STRONG' || name === 'B') ? 'STRONG' : 'EM';
441
- const delim = tag === 'STRONG' ? '**' : '*';
442
- const e = splitEdges(walkChildren(node.childNodes, unsupported, opts).md);
462
+ const e = splitEdges(walkChildren(node.childNodes, unsupported, opts, c.inner).md);
443
463
  if (e.core) {
444
464
  // a hoisted leading space already separates two same-tag runs
445
465
  const guard = !e.lead && lastMarkTag === tag ? '<!-- -->' : '';
446
- out = appendPiece(out, e.lead + guard + delim + e.core + delim + e.trail);
466
+ const delim = c.delims[tag];
467
+ out = appendPiece(out, e.lead + guard + c.before + delim + e.core + delim + c.after + e.trail);
447
468
  lastMarkTag = e.trail ? null : tag;
448
469
  } else if (e.lead || e.trail) {
449
470
  out = appendPiece(out, e.lead + e.trail);
@@ -458,9 +479,15 @@
458
479
  // accepted on input (some contenteditable/paste paths produce it)
459
480
  // but the toolbar/serializer always speak DEL, matching what marked
460
481
  // itself emits.
461
- const e = splitEdges(walkChildren(node.childNodes, unsupported, opts).md);
462
- out = appendPiece(out, e.core ? e.lead + '~~' + e.core + '~~' + e.trail : e.lead + e.trail);
463
- if (e.core || e.lead || e.trail) lastMarkTag = null;
482
+ //
483
+ // v3.17.7 N2: two strikethroughs side by side get the same guard as
484
+ // two bolds. `~~a~~~~b~~` reads as ONE del holding `a~~~~b` (measured
485
+ // on 71fedaa: strike `two` right after `~~one~~` wrote exactly that).
486
+ const e = splitEdges(walkChildren(node.childNodes, unsupported, opts, c).md);
487
+ const guard = e.core && !e.lead && lastMarkTag === 'DEL' ? '<!-- -->' : '';
488
+ out = appendPiece(out, e.core ? e.lead + guard + '~~' + e.core + '~~' + e.trail : e.lead + e.trail);
489
+ if (e.core) lastMarkTag = e.trail ? null : 'DEL';
490
+ else if (e.lead || e.trail) lastMarkTag = null;
464
491
  firstSegment = false;
465
492
  continue;
466
493
  }
@@ -475,14 +502,18 @@
475
502
  // backtick, literal `<u>` typed as plain text is already escaped by
476
503
  // the `<` -> `&lt;` rule above, so it can never collide with a real
477
504
  // toolbar-made underline.
478
- out = appendPiece(out, '<u>' + walkChildren(node.childNodes, unsupported, opts).md + '</u>');
505
+ out = appendPiece(out, '<u>' + walkChildren(node.childNodes, unsupported, opts, c).md + '</u>');
479
506
  lastMarkTag = null;
480
507
  firstSegment = false;
481
508
  continue;
482
509
  }
483
510
  if (name === 'CODE') {
484
- out = appendPiece(out, serializeCode(node, opts));
485
- lastMarkTag = null;
511
+ // v3.17.7 N3: two code spans side by side are one span to a reader
512
+ // (`` `one``two` `` is the code "one``two"), so they get a guard too.
513
+ // serializeCode() never writes an empty span, so this always fires
514
+ // between two real ones.
515
+ out = appendPiece(out, (lastMarkTag === 'CODE' ? '<!-- -->' : '') + serializeCode(node, opts));
516
+ lastMarkTag = 'CODE';
486
517
  firstSegment = false;
487
518
  continue;
488
519
  }
@@ -536,13 +567,227 @@
536
567
  return { md: out, lastMarkTag };
537
568
  }
538
569
 
570
+ // ── v3.17.7 N1: check the bytes with the reader's own parser ─────────────
571
+ //
572
+ // A STRONG / EM nested inside another STRONG / EM is written `*` / `**`
573
+ // like any other, and most of the time marked reads that back. Not always:
574
+ // marked's emphasis scan (emStrong in marked 14) walks from an opening run
575
+ // to its closer counting the `*` runs in between, and a run with word
576
+ // characters on both sides is a "mid" run it may skip under the rule of 3.
577
+ // Whether a nested mark survives therefore depends on the characters
578
+ // around its runs. Measured on 71fedaa, each from a toolbar gesture:
579
+ // `*`c`**i**tal*` -> the em is lost, its stars show
580
+ // `*~~a**a**~~*` -> `~~` is invisible to that scan
581
+ // `前面「引號<!-- -->*」**粗**後面,中文*。`
582
+ // while `*a**i**tal*` and `***ital* bold**` read fine. The rules are
583
+ // marked's, so marked decides: serializeInline() writes the bytes as
584
+ // before, parses them, and compares the marks on every character with the
585
+ // DOM it was given. Only when they differ does it try other spellings for
586
+ // the nested marks, in this order, keeping the first that reads back:
587
+ // 1. `__` / `_` for every STRONG / EM inside another (those runs are
588
+ // not `*`, so the outer scan does not count them);
589
+ // 2. the same with `<!-- -->` on both sides of each such mark (a run
590
+ // next to `<` is no longer flanked by a word character), then each of
591
+ // those guards dropped again if the bytes still read back without it.
592
+ // If neither reads back the original bytes are kept: what this release
593
+ // wrote before. Bytes that already read back are never touched, so no
594
+ // file written by an earlier release changes by being opened and saved.
595
+ // A fuzz over random strong / em / del / code trees (inline-md.test.js;
596
+ // 7725 of 20000 read back wrong on 71fedaa) needed nothing beyond step 2.
597
+ //
598
+ // A repaired spelling never keeps `<!--` at the start of a line: marked
599
+ // reads a line-start `<!--` as an HTML block and ends the paragraph there
600
+ // (v3.17.5 C1), and parseInline() cannot see that. It does not have to:
601
+ // the only guards step 2 adds sit before an opening run or after a
602
+ // closing one, and after a newline the character before an opening run
603
+ // is whitespace, which already does what the guard's `>` does (no
604
+ // right-flanking, opening allowed). So a step-2 guard at a line start is
605
+ // dropped before anything is parsed, whether or not the drop pass below
606
+ // runs (re-review: with the pass skipped past 16 guards, one survived
607
+ // after a hard break and the reader cut the paragraph there). The walk
608
+ // itself never writes one (needsFlankSeparator() sees `\n` as a non-word
609
+ // character).
610
+ //
611
+ // The parser is a private `new Marked()` with gfm on and nothing else, in
612
+ // node and in the editor page alike (server.js injects marked's UMD build
613
+ // before this file). The reader's own instance carries the md2doc
614
+ // renderer, whose text hook writes into the render's search index, and
615
+ // its sub / sup / KaTeX extensions only see `~` / `^` / `$`, which this
616
+ // comparison is not about. No parser (a page without marked) means no
617
+ // check: the bytes are written as before.
618
+ const GUARD = '<!-- -->';
619
+ const STAR = { STRONG: '**', EM: '*' };
620
+ const UNDERSCORE = { STRONG: '__', EM: '_' };
621
+ // PLAIN.inner is PLAIN: the spelling every release before this one used.
622
+ const PLAIN = { delims: STAR, before: '', after: '', inner: null };
623
+ PLAIN.inner = PLAIN;
624
+ function spelling(before) {
625
+ const nested = { delims: UNDERSCORE, before, after: before, inner: null };
626
+ nested.inner = nested;
627
+ return { delims: STAR, before: '', after: '', inner: nested };
628
+ }
629
+ const NESTED_UNDERSCORE = spelling('');
630
+ // One character stands in for each step-2 guard until it is decided, so
631
+ // only those guards are ever dropped, never one the walk wrote itself. It
632
+ // must be punctuation to needsFlankSeparator(), as the `<` it stands for
633
+ // is: with a word-like stand-in the walk skips a guard the real bytes need
634
+ // (measured: `ital**<!-- -->_…` lost the `<!-- -->` before its `**`).
635
+ // U+2E3B THREE-EM DASH (\p{Pd}); a block whose text holds one skips step 2.
636
+ const PENDING = '\u2E3B';
637
+ const NESTED_GUARDED = spelling(PENDING);
638
+
639
+ let inlineParser; // undefined: not looked up yet; null: none available
640
+ function getInlineParser() {
641
+ if (inlineParser !== undefined) return inlineParser;
642
+ inlineParser = null;
643
+ try {
644
+ let lib = null;
645
+ if (typeof module === 'object' && module.exports && typeof require === 'function') lib = require('marked');
646
+ else if (typeof self !== 'undefined' && self.marked) lib = self.marked;
647
+ if (lib && typeof lib.Marked === 'function') {
648
+ const m = new lib.Marked({ gfm: true, breaks: false });
649
+ inlineParser = (s) => m.parseInline(s);
650
+ }
651
+ } catch (e) {
652
+ inlineParser = null;
653
+ }
654
+ return inlineParser;
655
+ }
656
+ // Tests swap the parser: a function, null to switch the check off, or
657
+ // nothing to go back to the lookup above.
658
+ function setInlineParser(fn) {
659
+ inlineParser = typeof fn === 'function' ? fn : (fn === null ? null : undefined);
660
+ }
661
+
662
+ // One entry per non-space character: the character and the sorted set of
663
+ // S(trong) / E(m) / D(el) around it. A code span is one entry holding its
664
+ // text without whitespace (a reader strips one space at each end, and
665
+ // spacing is not what this check is about). Whitespace is left out
666
+ // (splitEdges() moves it across a delimiter on purpose), and so are line
667
+ // breaks, link targets and <u>. A citation is written `[[body]]`, which
668
+ // this private parser (no md2doc extension) reads as that literal text,
669
+ // so its DOM side counts the outer brackets too: without them the two
670
+ // sides never agreed and any block holding a citation was never repaired
671
+ // (final review).
672
+ const MARK_LETTER = { STRONG: 'S', B: 'S', EM: 'E', I: 'E', DEL: 'D', S: 'D' };
673
+ const SPACE = /\s/;
674
+ function domMarks(nodes, marks, out) {
675
+ for (let i = 0; i < nodes.length; i++) {
676
+ const node = nodes[i];
677
+ if (node.nodeType === 3) {
678
+ for (const ch of node.textContent) if (!SPACE.test(ch)) out.push(ch + '[' + marks + ']');
679
+ continue;
680
+ }
681
+ if (node.nodeType !== 1) continue;
682
+ if (node.nodeName === 'CODE') { out.push('`' + node.textContent.replace(/\s+/g, '') + '[' + marks + ']'); continue; }
683
+ if (node.nodeName === 'A' && isCitationNode(node)) {
684
+ for (const ch of '[' + node.textContent + ']') if (!SPACE.test(ch)) out.push(ch + '[' + marks + ']');
685
+ continue;
686
+ }
687
+ const k = MARK_LETTER[node.nodeName];
688
+ const inner = k && marks.indexOf(k) === -1 ? (marks + k).split('').sort().join('') : marks;
689
+ domMarks(node.childNodes, inner, out);
690
+ }
691
+ return out;
692
+ }
693
+ const HTML_TAG_MARK = { strong: 'S', em: 'E', del: 'D' };
694
+ const HTML_ENTITY = { amp: '&', lt: '<', gt: '>', quot: '"', '#39': "'" };
695
+ function htmlMarks(html) {
696
+ const out = [];
697
+ const open = [];
698
+ let code = null;
699
+ const re = /<!--[\s\S]*?-->|<(\/?)([a-zA-Z][a-zA-Z0-9]*)\b[^>]*>|&(amp|lt|gt|quot|#39);|([^<&]+|[<&])/g;
700
+ const current = () => open.slice().sort().filter((k, i, a) => a.indexOf(k) === i).join('');
701
+ let m;
702
+ while ((m = re.exec(html))) {
703
+ if (m[0].startsWith('<!--')) continue;
704
+ if (m[2]) {
705
+ const tag = m[2].toLowerCase();
706
+ if (tag === 'code') {
707
+ if (m[1]) { out.push('`' + (code || '').replace(/\s+/g, '') + '[' + current() + ']'); code = null; } else code = '';
708
+ continue;
709
+ }
710
+ const k = HTML_TAG_MARK[tag];
711
+ if (!k) continue;
712
+ if (m[1]) { const at = open.lastIndexOf(k); if (at !== -1) open.splice(at, 1); } else open.push(k);
713
+ continue;
714
+ }
715
+ const t = m[3] ? HTML_ENTITY[m[3]] : m[4];
716
+ if (code !== null) { code += t; continue; }
717
+ for (const ch of t) if (!SPACE.test(ch)) out.push(ch + '[' + current() + ']');
718
+ }
719
+ return out;
720
+ }
721
+
539
722
  // `opts` is optional; its one field, `codePipe`, is described at
540
723
  // serializeCode(). Every caller but table-md.js passes nothing.
541
724
  function serializeInline(rootEl, opts) {
542
725
  const unsupported = [];
543
726
  const md = walkChildren(rootEl.childNodes, unsupported, opts).md;
727
+ // The spellings below only differ where a STRONG / EM sits inside another
728
+ // one, so a block without that is written as walked, unparsed: no cost
729
+ // on the per-keystroke shortcut check or for callers that only want
730
+ // `unsupported`.
731
+ if (unsupported.length || !hasNestedStar(rootEl.childNodes, false)) return { md, unsupported };
732
+ const parse = getInlineParser();
733
+ if (!parse) return { md, unsupported };
734
+ const want = domMarks(rootEl.childNodes, '', []).join('|');
735
+ // table-md.js writes a code `|` as its placeholder; the cell shows `|`
736
+ // (GFM drops the `\` of `\|` before the cell is parsed inline).
737
+ const pipe = opts && opts.codePipe;
738
+ const readsBack = (s) => {
739
+ try {
740
+ return htmlMarks(parse(pipe ? s.split(pipe).join('|') : s)).join('|') === want;
741
+ } catch (e) {
742
+ return false;
743
+ }
744
+ };
745
+ if (readsBack(md)) return { md, unsupported };
746
+ const one = walkChildren(rootEl.childNodes, [], opts, NESTED_UNDERSCORE).md;
747
+ if (readsBack(one)) return { md: one, unsupported };
748
+ if (domText(rootEl.childNodes).indexOf(PENDING) === -1) {
749
+ const parts = walkChildren(rootEl.childNodes, [], opts, NESTED_GUARDED).md.split(PENDING);
750
+ // guard k sits right after parts[k]; none is kept at a line start
751
+ let before = '';
752
+ const keep = parts.slice(1).map((_, k) => {
753
+ before += parts[k];
754
+ return !/(^|\n)[ \t]*$/.test(before);
755
+ });
756
+ const build = () => parts.reduce((acc, part, i) =>
757
+ (i ? acc + (keep[i - 1] ? GUARD : '') : '') + part, '');
758
+ if (readsBack(build())) {
759
+ // Each drop is one more parse of the whole block. Past a handful of
760
+ // guards keep them all: correct bytes, a few extra `<!-- -->`
761
+ // (final review measured 400 repaired marks at 3.4 s otherwise).
762
+ for (let i = 0; i < keep.length && keep.length <= MAX_GUARDS_TO_DROP; i++) {
763
+ if (!keep[i]) continue;
764
+ keep[i] = false;
765
+ if (!readsBack(build())) keep[i] = true;
766
+ }
767
+ return { md: build(), unsupported };
768
+ }
769
+ }
544
770
  return { md, unsupported };
545
771
  }
772
+ const MAX_GUARDS_TO_DROP = 16;
773
+ const STAR_TAGS = { STRONG: 1, B: 1, EM: 1, I: 1 };
774
+ // Whether a STRONG / EM sits inside another one, through any wrapper the
775
+ // walk spells with the context it was given (not CODE, not a link label).
776
+ function hasNestedStar(nodes, inStar) {
777
+ for (let i = 0; i < nodes.length; i++) {
778
+ const node = nodes[i];
779
+ if (node.nodeType !== 1 || node.nodeName === 'CODE' || node.nodeName === 'A') continue;
780
+ const star = STAR_TAGS[node.nodeName] === 1;
781
+ if (star && inStar) return true;
782
+ if (hasNestedStar(node.childNodes, inStar || star)) return true;
783
+ }
784
+ return false;
785
+ }
786
+ function domText(nodes) {
787
+ let s = '';
788
+ for (let i = 0; i < nodes.length; i++) s += nodes[i].textContent || '';
789
+ return s;
790
+ }
546
791
 
547
792
  function canWysiwyg(rootEl) {
548
793
  return serializeInline(rootEl).unsupported.length === 0;
@@ -706,5 +951,5 @@
706
951
  return out;
707
952
  }
708
953
 
709
- return { serializeInline, canWysiwyg, escapeText, escapeBlockStarts, normalizeHref, isPastedUrl, parseImageParagraph, rewriteImage, setNamedReferenceDecoder };
954
+ return { serializeInline, canWysiwyg, escapeText, escapeBlockStarts, normalizeHref, isPastedUrl, parseImageParagraph, rewriteImage, setNamedReferenceDecoder, setInlineParser };
710
955
  });
@@ -215,8 +215,47 @@ function isHeadingRow(tr) {
215
215
  // against the header. Escape any `|` that isn't already an escaped `\|`
216
216
  // (the latter can arrive when a nested table's own already-escaped cell
217
217
  // content is flattened into its parent cell, see cellMarkdown below).
218
+ //
219
+ // v3.17.7 F2: "already escaped" means an ODD run of backslashes before the
220
+ // `|`, which is how marked's splitCells() decides it (the parity rule
221
+ // v3.17.6 wrote down in table-md.js). The old test was one backslash, any
222
+ // run: text `a \| b` (turndown writes its backslash as `\\`) came out
223
+ // `a \\| b`, an even run, and the cell was cut in two (measured on 71fedaa).
218
224
  function escapePipes(s) {
219
- return String(s).replace(/(?<!\\)\|/g, '\\|');
225
+ return String(s).replace(/(\\*)\|/g, function (m, run) {
226
+ return run.length % 2 ? m : run + '\\|';
227
+ });
228
+ }
229
+
230
+ // v3.17.7 F2: code whose text has an odd backslash run before a `|` has no
231
+ // spelling in a GFM table cell. The backslash that keeps the pipe in the
232
+ // cell is the one GFM deletes before the code span is read, so the code
233
+ // shows one backslash fewer (or, with one backslash more, the run turns even
234
+ // and the pipe ends the cell); table-md.js refuses the same code (v3.17.6).
235
+ // Paste cannot refuse, so such a span is written as text with EVERY ASCII
236
+ // punctuation character backslash-escaped (CommonMark allows that for all
237
+ // of them): then nothing in it is read as markup -- not `<!--`, not an
238
+ // entity, not md2doc's `^sup^` / `~sub~` / `$math$`. turndown's own
239
+ // escape() leaves those alone (final review: `a<!-- \| -->` lost its text,
240
+ // `^2^` became superscript). The `|` comes out `\|`, an odd run, which
241
+ // escapePipes() then leaves as it is. Every character arrives; only the
242
+ // code style does not.
243
+ var ODD_BACKSLASH_PIPE = /(?:^|[^\\])(?:\\\\)*\\\|/;
244
+ function addUnspellableCellCodeRule(service) {
245
+ service.addRule('unspellableCellCode', {
246
+ filter: function (node) {
247
+ if (node.nodeName !== 'CODE') return false;
248
+ if (node.parentNode && node.parentNode.nodeName === 'PRE') return false;
249
+ if (!ODD_BACKSLASH_PIPE.test(node.textContent || '')) return false;
250
+ for (var p = node.parentNode; p; p = p.parentNode) {
251
+ if (p.nodeName === 'TD' || p.nodeName === 'TH') return true;
252
+ }
253
+ return false;
254
+ },
255
+ replacement: function (content, node) {
256
+ return (node.textContent || '').replace(/[!-\/:-@\[-`{-~]/g, '\\$&');
257
+ }
258
+ });
220
259
  }
221
260
 
222
261
  // Only the ROWS THIS TABLE OWNS -- direct <tr> children, and <tr> children
@@ -342,9 +381,24 @@ function htmlToMarkdown(html) {
342
381
  codeBlockStyle: 'fenced',
343
382
  emDelimiter: '*'
344
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
+ };
345
398
  addStrikethroughRule(service);
346
399
  addFlankingEmphasisRules(service);
347
400
  addTableRules(service);
401
+ addUnspellableCellCodeRule(service);
348
402
  return service.turndown(html == null ? '' : String(html));
349
403
  }
350
404
 
@@ -8,6 +8,12 @@ const { renderMarkdown, buildKatexStyleTag, diagramEnginePaths } = require('../m
8
8
  const { createDotPreview } = require('./dot-preview.js');
9
9
 
10
10
  const LINEOPS_SRC = fs.readFileSync(path.join(__dirname, 'lineops.js'), 'utf8');
11
+ // v3.17.7: marked's browser build (the package's exported `marked.min.js`,
12
+ // UMD, defines the global `marked`), so inline-md.js can parse the bytes it
13
+ // is about to save and check that the reader shows the marks the editor held
14
+ // (serializeInline()). Injected before inline-md.js; inline-md.js looks the
15
+ // global up at its first serialize, so the order is for reading, not load.
16
+ const MARKED_SRC = fs.readFileSync(require.resolve('marked/marked.min.js'), 'utf8');
11
17
  const INLINE_MD_SRC = fs.readFileSync(path.join(__dirname, 'inline-md.js'), 'utf8');
12
18
  const TABLE_MD_SRC = fs.readFileSync(path.join(__dirname, 'table-md.js'), 'utf8');
13
19
  // Task 4 (Phase 3): list-md.js's browser factory reads `root.md2docInlineMd`
@@ -776,6 +782,7 @@ async function createEditorServer({
776
782
  const inject =
777
783
  `<script>window.__ED__ = ${payload.replace(/</g, '\\u003c')}</script>\n` +
778
784
  `<script>${LINEOPS_SRC}</script>\n` +
785
+ `<script>${MARKED_SRC}</script>\n` +
779
786
  `<script>${INLINE_MD_SRC}</script>\n` +
780
787
  `<script>${TABLE_MD_SRC}</script>\n` +
781
788
  `<script>${LIST_MD_SRC}</script>\n` +
@@ -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.6",
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",