@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.
- package/lib/editor/client.js +40 -3
- package/lib/editor/inline-md.js +260 -15
- package/lib/editor/paste-md.js +55 -1
- package/lib/editor/server.js +7 -0
- package/lib/editor/table-md.js +358 -43
- package/package.json +1 -1
package/lib/editor/client.js
CHANGED
|
@@ -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))
|
|
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
|
-
// *
|
|
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
|
|
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;
|
package/lib/editor/inline-md.js
CHANGED
|
@@ -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 += '<'; continue; }
|
|
38
39
|
if (c === '~') { out += '\\~'; continue; }
|
|
40
|
+
// v3.17.8 D3: an `&` that starts something shaped like a character
|
|
41
|
+
// reference (`&`, `&`, `&x_1;`) is written `\&`, or the reader
|
|
42
|
+
// decodes what the person typed (`a & 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`, `©` 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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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 `<` -> `<` 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
|
-
|
|
485
|
-
|
|
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
|
});
|
package/lib/editor/paste-md.js
CHANGED
|
@@ -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(/(
|
|
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, `&` showed as `&`. A `<` that could open a tag,
|
|
387
|
+
// a comment or an autolink is written `<`, 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, '<');
|
|
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
|
|
package/lib/editor/server.js
CHANGED
|
@@ -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` +
|
package/lib/editor/table-md.js
CHANGED
|
@@ -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
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
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
|
-
//
|
|
64
|
-
//
|
|
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 -> '|'; 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.
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
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
|
-
//
|
|
194
|
-
//
|
|
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.
|
|
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 =
|
|
203
|
-
const c =
|
|
204
|
-
if (c === '\\' &&
|
|
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
|
-
|
|
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 '|'. 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 '|' 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 `©` (shows ©) read the same as the typed
|
|
345
|
+
// text `\©` (shows ©), 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'))).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
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
|
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 (
|
|
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 <
|
|
464
|
-
if (
|
|
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
|
-
|
|
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 >=
|
|
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
|
-
|
|
493
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
});
|