rapier-markdown-kit 1.2.7 → 1.2.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -30
- package/dist/agent/will.mjs +1 -6
- package/dist/images/gif.mjs +0 -3
- package/dist/images/playback.mjs +1 -10
- package/dist/kit/assets.mjs +0 -1
- package/dist/kit/conformance/measurer.mjs +0 -0
- package/dist/kit/conformance/run.mjs +1 -8
- package/dist/kit/export-fonts.mjs +0 -14
- package/dist/kit/index.mjs +1 -1
- package/dist/kit/ledger/authored-origin.mjs +0 -5
- package/dist/kit/ledger/authorship.mjs +0 -2
- package/dist/kit/ledger/carried.d.mts +20 -0
- package/dist/kit/ledger/carried.mjs +4 -7
- package/dist/kit/ledger/data.mjs +0 -8
- package/dist/kit/ledger/format.mjs +0 -25
- package/dist/kit/ledger/hash.mjs +11 -7
- package/dist/kit/ledger/history.mjs +10 -29
- package/dist/kit/ledger/inverse-source.mjs +0 -46
- package/dist/kit/ledger/journal-records.mjs +24 -56
- package/dist/kit/ledger/merge.mjs +0 -27
- package/dist/kit/ledger/text-merge.mjs +0 -20
- package/dist/kit/ledger/transport.mjs +0 -47
- package/dist/kit/ledger.d.mts +88 -0
- package/dist/kit/ledger.mjs +14 -0
- package/dist/kit/markdown-it.d.mts +4 -0
- package/dist/kit/markdown-it.mjs +36 -0
- package/dist/kit/pdf-glyphs.mjs +39 -45
- package/dist/kit/pdf-source.d.mts +5 -0
- package/dist/kit/pdf-source.mjs +125 -0
- package/dist/kit/pdf-vectors.mjs +0 -3
- package/dist/kit/print-capitals.mjs +0 -4
- package/dist/kit/render-entry.mjs +0 -1
- package/dist/kit/render-ink.mjs +0 -5
- package/dist/kit/render-markdown.mjs +15 -119
- package/dist/kit/render-print.mjs +4 -49
- package/dist/kit/render-sanitize.mjs +22 -58
- package/dist/kit/render-styles.mjs +0 -7
- package/dist/kit/render-work.mjs +0 -12
- package/dist/kit/render.mjs +18 -173
- package/dist/kit/will.d.mts +42 -0
- package/dist/layout/hyphenation.mjs +0 -23
- package/dist/layout/line-plan.mjs +0 -20
- package/dist/layout/model.mjs +0 -55
- package/dist/layout/paragraph-fit.mjs +50 -60
- package/dist/layout/preparation.mjs +23 -56
- package/dist/layout/prepared-paragraph.mjs +0 -25
- package/dist/layout/typography.mjs +103 -41
- package/dist/packages/rapier-diff/concerns.mjs +0 -16
- package/dist/packages/rapier-diff/diff.mjs +0 -7
- package/dist/packages/rapier-diff/index.mjs +0 -1
- package/dist/packages/rapier-diff/matching.mjs +0 -19
- package/dist/packages/rapier-diff/merge.mjs +0 -9
- package/dist/packages/rapier-diff/moves.mjs +0 -7
- package/dist/packages/rapier-diff/structure.mjs +0 -2
- package/dist/packages/rapier-diff/tokens.mjs +0 -6
- package/dist/packages/rapier-diff/walk.mjs +0 -12
- package/dist/spec/document-fonts.mjs +0 -9
- package/dist/spec/document-settings.mjs +6 -25
- package/dist/spec/frontmatter.mjs +0 -1
- package/dist/spec/html-blocks.mjs +3 -37
- package/dist/spec/md-assets.mjs +2 -61
- package/dist/spec/md-layout.mjs +0 -34
- package/dist/spec/md-marks.mjs +0 -51
- package/dist/spec/md-source.mjs +0 -16
- package/dist/spec/word-sidecar.mjs +0 -2
- package/package.json +21 -4
- package/style.css +38 -21
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MIT
|
|
2
2
|
import {letter, within, prev, next} from './tokens.mjs';
|
|
3
3
|
|
|
4
|
-
// Separator-only pieces join the edit beside them; adjacent blocks each carry their own line break.
|
|
5
4
|
export function separate(c, endA = c.before.length, endB = c.after.length) {
|
|
6
5
|
const {before, after, safe, raw, spend} = c;
|
|
7
|
-
// A separator-only piece beside another edit joins it; equal separators at its ends stay unchanged.
|
|
8
6
|
if (!raw) for (let i = c.result.length - 1; i > 0; i--) {
|
|
9
7
|
const p = c.result[i - 1], q = c.result[i], side = p[0][0] === p[0][1] ? 1 : p[1][0] === p[1][1] ? 0 : -1, text = side ? after : before;
|
|
10
8
|
if (side >= 0 && !/\S/.test(text.slice(...p[side]))) {
|
|
@@ -16,15 +14,12 @@ export function separate(c, endA = c.before.length, endB = c.after.length) {
|
|
|
16
14
|
if (p[2] || q[2] || p[0][1] !== q[0][0] || p[1][1] !== q[1][0] ||
|
|
17
15
|
![p, q].some(r => !/\S/.test(before.slice(...r[0]) + after.slice(...r[1])))) continue;
|
|
18
16
|
const x = [p[0][0], q[0][1]], y = [p[1][0], q[1][1]], keep = /\S/.test(before.slice(...q[0]) + after.slice(...q[1])) ? q : p;
|
|
19
|
-
// CRLF steps as one separator.
|
|
20
17
|
for (let k; x[0] < x[1] && y[0] < y[1] && /\s/.test(before[x[1] - 1]) && before[x[1] - 1] === after[y[1] - 1] &&
|
|
21
18
|
(k = before.endsWith('\r\n', x[1]) && after.endsWith('\r\n', y[1]) ? 2 : 1) <= Math.min(x[1] - x[0], y[1] - y[0]) &&
|
|
22
19
|
safe(before, x[1] - k) && safe(after, y[1] - k);) { x[1] -= k; y[1] -= k; }
|
|
23
20
|
keep[3][0] = x[0] === x[1] ? 2 : y[0] === y[1] ? 0 : 1;
|
|
24
21
|
c.result.splice(i - 1, 2, [x, y, false, keep[3]]);
|
|
25
22
|
}
|
|
26
|
-
// Repeated closing lines can attach to the wrong block. Rotate an exact insertion/deletion to the
|
|
27
|
-
// next blank-line boundary, without crossing another edit or splitting a character.
|
|
28
23
|
if (!raw) for (let i = 0; i < c.result.length; i++) {
|
|
29
24
|
const p = c.result[i], q = c.result[i + 1], side = p[0][0] === p[0][1] ? 1 : p[1][0] === p[1][1] ? 0 : -1;
|
|
30
25
|
if (side < 0 || p[2]) continue;
|
|
@@ -38,8 +33,6 @@ export function separate(c, endA = c.before.length, endB = c.after.length) {
|
|
|
38
33
|
if (shift && p.slice(0, 2).every((span, k) => safe(k ? after : before, span[0] + shift) && safe(k ? after : before, span[1] + shift)))
|
|
39
34
|
for (const span of p.slice(0, 2)) { span[0] += shift; span[1] += shift; }
|
|
40
35
|
}
|
|
41
|
-
// Adjacent blocks deleted or inserted together: when the first carries the line break before it, each carries
|
|
42
|
-
// its own, so any one restores apart from its neighbors.
|
|
43
36
|
if (!raw) for (let i = 1; i < c.result.length; i++) {
|
|
44
37
|
const p = c.result[i - 1], q = c.result[i], kind = p[3][0], side = kind ? 1 : 0, text = side ? after : before;
|
|
45
38
|
if (kind % 2 || q[3][0] !== kind || p[2] || q[2] || p[side][1] !== q[side][0] || p[1 - side][0] !== q[1 - side][0]) continue;
|
|
@@ -48,20 +41,16 @@ export function separate(c, endA = c.before.length, endB = c.after.length) {
|
|
|
48
41
|
}
|
|
49
42
|
}
|
|
50
43
|
|
|
51
|
-
// Refine the outer walk and each move's inner walk before assigning transport ownership.
|
|
52
44
|
export function phrases(c) {
|
|
53
45
|
const {before, after, raw, code} = c;
|
|
54
|
-
// Whole-word replacements separated only by spaces read and restore as one phrase, and one number stays one number.
|
|
55
46
|
if (!raw) for (let i = c.result.length - 1; i > 0; i--) {
|
|
56
47
|
const p = c.result[i - 1], q = c.result[i], gap = before.slice(p[0][1], q[0][0]);
|
|
57
48
|
if (p[3][0] !== 1 || q[3][0] !== 1 || p[3][3] || q[3][3] || p[2] || q[2] || gap !== after.slice(p[1][1], q[1][0])) continue;
|
|
58
|
-
// A time, a date, a version or a decimal is one number.
|
|
59
49
|
const number = /^(?:[.,:/-]\d*)+$/.test(gap) && /\d/.test(before[p[0][1] - 1] + after[p[1][1] - 1]) && /\d/.test(before[q[0][0]] + after[q[1][0]]);
|
|
60
50
|
if (!number && (code || !/^[^\S\r\n]+$/.test(gap) || within(before, p[0][0]) || within(after, p[1][0]) ||
|
|
61
51
|
within(before, q[0][1]) || within(after, q[1][1]))) continue;
|
|
62
52
|
p[0][1] = q[0][1]; p[1][1] = q[1][1]; c.result.splice(i, 1);
|
|
63
53
|
}
|
|
64
|
-
// Paired inline markers added or removed around unchanged text on one line are one concern.
|
|
65
54
|
if (!raw && !code) for (let i = 1; i < c.result.length; i++) {
|
|
66
55
|
const p = c.result[i - 1], q = c.result[i], kind = p[3][0], side = kind ? 1 : 0, text = side ? after : before;
|
|
67
56
|
if (kind % 2 || kind !== q[3][0] || p[2] || q[2] || p[3][3] || q[3][3]) continue;
|
|
@@ -72,12 +61,10 @@ export function phrases(c) {
|
|
|
72
61
|
}
|
|
73
62
|
}
|
|
74
63
|
|
|
75
|
-
// Readable concerns: phrases, numbers and paired markers, then the public edits and changes with context.
|
|
76
64
|
export function concerns(c) {
|
|
77
65
|
phrases(c);
|
|
78
66
|
const {before, after, spend, safe, budget, raw, code, evidence, structures, initial, options, pa, pb} = c, lb = c.lb;
|
|
79
67
|
const overlaps = (a, b) => a[0] < b[1] && a[1] > b[0];
|
|
80
|
-
// Siblings are ordered and disjoint: the parts holding a range are found by descent, outermost first.
|
|
81
68
|
const containing = (parts, from, to, out = []) => {
|
|
82
69
|
let lo = 0, hi = parts.length;
|
|
83
70
|
while (lo < hi) { const mid = lo + hi >>> 1; if (parts[mid].end < to) lo = mid + 1; else hi = mid; }
|
|
@@ -85,7 +72,6 @@ export function concerns(c) {
|
|
|
85
72
|
if (parts[k].end >= to) { out.push(parts[k]); containing(parts[k].children, from, to, out); }
|
|
86
73
|
return out;
|
|
87
74
|
};
|
|
88
|
-
// Edits are ordered and disjoint on the after side.
|
|
89
75
|
const touched = (edits, range) => {
|
|
90
76
|
let lo = 0, hi = edits.length;
|
|
91
77
|
while (lo < hi) { const mid = lo + hi >>> 1; if (edits[mid].after[1] <= range[0]) lo = mid + 1; else hi = mid; }
|
|
@@ -116,7 +102,6 @@ export function concerns(c) {
|
|
|
116
102
|
if (!raw && !group[3]) group[3] = [[...item[0]], [...item[1]]];
|
|
117
103
|
if (!group[4]) {
|
|
118
104
|
const context = group[3];
|
|
119
|
-
// Context completes a word only where an edge falls inside it.
|
|
120
105
|
if (!raw) for (const [text, span] of [[before, context[0]], [after, context[1]]]) {
|
|
121
106
|
const start = within(text, span[0]), end = within(text, span[1]);
|
|
122
107
|
for (let c; start && letter(c = prev(text, span[0])) && spend() && safe(text, span[0] - c.length);) span[0] -= c.length;
|
|
@@ -152,7 +137,6 @@ export function concerns(c) {
|
|
|
152
137
|
}
|
|
153
138
|
}
|
|
154
139
|
if (options._stats) Object.assign(options._stats, {work: Math.max(0, initial - Math.max(0, budget.left)), coarse: edits.some(c => c.coarse)});
|
|
155
|
-
// Concern tuples: kind, unit, name, context, edits, affects, breaks. Wording inside a move shares its context.
|
|
156
140
|
const moves = new Map(changes.map((change, i) => change[0] === 3 && [change[3], i + 1]).filter(Boolean));
|
|
157
141
|
return {edits, changes: changes.map(([kind, unit, name, context, owned, affects, breaks], i) => ({
|
|
158
142
|
id: i + 1, kind: ['delete', 'replace', 'insert', 'move', 'rename', 'format'][kind], unit, edits: owned,
|
|
@@ -1,6 +1,4 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MIT
|
|
2
|
-
// One comparison: observe structure, walk the source into exact records, find moves and renames, then read them as
|
|
3
|
-
// concerns. Each stage lives in its own module and shares one comparison context.
|
|
4
2
|
import {observe} from './structure.mjs';
|
|
5
3
|
import {boundaries, tokenizer, wordSegmenter} from './tokens.mjs';
|
|
6
4
|
import {matcher} from './matching.mjs';
|
|
@@ -12,10 +10,8 @@ export function diff(before, after, options = {}) {
|
|
|
12
10
|
if (before === after) return {edits: [], changes: []};
|
|
13
11
|
const budget = options._budget || {left: Number.isFinite(options.limit) ? Math.max(0, options.limit) : 1e7};
|
|
14
12
|
const spend = (n = 1) => (budget.left -= n) >= 0, safe = boundaries(), raw = options._unit;
|
|
15
|
-
// Internal records: [before range, after range, coarse, concern, transported fragments].
|
|
16
13
|
const c = {before, after, options, budget, spend, safe, raw, chars: options.unit === 'char', initial: budget.left,
|
|
17
14
|
segmenter: wordSegmenter(), result: [], code: options.as === 'code'};
|
|
18
|
-
// A Markdown fence holds code inside prose; only code outside fences makes the text code.
|
|
19
15
|
if (options.as !== 'prose' && options.as !== 'code') c.code = /(?:^|\n)\s*(?:[{}]|(?:function|class|const|let|var|import|export|def|fn|func|package)\s+\w[^]{0,80}[({=:;])/
|
|
20
16
|
.test((before.slice(0, 4096) + '\n' + after.slice(0, 4096)).replace(/^ {0,3}(`{3,}|~{3,})[^]*?(?:^ {0,3}\1[`~]*[ \t]*$|(?![^]))/gm, ''));
|
|
21
17
|
let fallback;
|
|
@@ -33,7 +29,6 @@ export function diff(before, after, options = {}) {
|
|
|
33
29
|
}
|
|
34
30
|
Object.assign(c, {structures, pa, pb, la: structures[0].links, lb: structures[1].links, aliases});
|
|
35
31
|
} catch {
|
|
36
|
-
// A failed reader preserves an explicit prose choice and reports why structure was not used.
|
|
37
32
|
const failed = structures?.find(value => !value || value.invalid);
|
|
38
33
|
fallback = {structure: failed?.threw ? 'threw' : budget.left < 0 ? 'limit' : 'invalid', ...(failed?.threw ? {error: failed.error} : {})};
|
|
39
34
|
c.code = options.as !== 'prose';
|
|
@@ -48,8 +43,6 @@ export function diff(before, after, options = {}) {
|
|
|
48
43
|
structures?.[0]?.parts, structures?.[1]?.parts);
|
|
49
44
|
separate(c);
|
|
50
45
|
moves(c);
|
|
51
|
-
// A heading edit can re-record its children. Compare both walks after transport is known: unchanged moved
|
|
52
|
-
// fragments cost nothing, so a smaller plain walk cannot discard a move just by aligning some of its lines.
|
|
53
46
|
if (structures && !evidence && !raw && c.result.some(r => r[0][0] === r[0][1]) && c.result.some(r => r[1][0] === r[1][1]) &&
|
|
54
47
|
budget.left >= c.initial - budget.left) {
|
|
55
48
|
const size = (rows, transport = false) => rows.reduce((n, r) => n + (!transport && r[3][0] === 3 ? 0 : r[0][1] - r[0][0] + r[1][1] - r[1][0]), 0);
|
|
@@ -1,12 +1,7 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MIT
|
|
2
|
-
// Subsequence search over token keys, bounded by a shared work budget.
|
|
3
2
|
export function matcher(spend, budget) {
|
|
4
|
-
// Unique anchors and fallback equal pairs share one bounded subsequence search. Repetitive text, where equal pairs
|
|
5
|
-
// are too many to visit, anchors on runs of tokens found once on each side, then on positions, then on a shortest
|
|
6
|
-
// script; only a range none of them can afford within the work limit stays coarse.
|
|
7
3
|
function matches(a, b) {
|
|
8
4
|
const found = new Array(a.length), pending = [[0, a.length, 0, b.length]];
|
|
9
|
-
// Patience chain over candidate pairs in a order; unique anchors split the range into searched gaps.
|
|
10
5
|
const chain = (pairs, a0, a1, b0, b1, split, near) => {
|
|
11
6
|
const tails = [];
|
|
12
7
|
for (const [i, j] of pairs) {
|
|
@@ -29,7 +24,6 @@ export function matcher(spend, budget) {
|
|
|
29
24
|
while (a0 < a1 && b0 < b1 && a[a0] === b[b0]) found[a0++] = b0++;
|
|
30
25
|
while (a0 < a1 && b0 < b1 && a[a1 - 1] === b[b1 - 1]) found[--a1] = --b1;
|
|
31
26
|
if (a0 === a1 || b0 === b1 || !spend(a1 - a0 + b1 - b0)) continue;
|
|
32
|
-
// Between anchored runs the change is usually small; a token found once by chance must not misalign it.
|
|
33
27
|
if (near && shortest(a, b, a0, a1, b0, b1, found, 4 * (a1 - a0 + b1 - b0))) continue;
|
|
34
28
|
const positions = new Map();
|
|
35
29
|
for (let j = b0; j < b1; j++) {
|
|
@@ -45,12 +39,10 @@ export function matcher(spend, budget) {
|
|
|
45
39
|
if (list[0] === 1 && list.length === 2) unique++;
|
|
46
40
|
}
|
|
47
41
|
if (!count) continue;
|
|
48
|
-
// With no unique anchors, equal-length repeated runs prefer their witnessed positions to an arbitrary LIS.
|
|
49
42
|
if (!unique && a1 - a0 === b1 - b0) {
|
|
50
43
|
const anchors = diagonal(a, b, a0, a1, b0, b1);
|
|
51
44
|
if (anchors) { chain(anchors, a0, a1, b0, b1, true, true); continue; }
|
|
52
45
|
}
|
|
53
|
-
// Charge the chain before building it: one that cannot be afforded falls through to cheaper anchors.
|
|
54
46
|
const chained = unique || count, cost = chained * Math.ceil(Math.log2(chained + 2));
|
|
55
47
|
if (cost <= budget.left) {
|
|
56
48
|
const pairs = [];
|
|
@@ -63,7 +55,6 @@ export function matcher(spend, budget) {
|
|
|
63
55
|
chain(pairs, a0, a1, b0, b1, unique > 0);
|
|
64
56
|
continue;
|
|
65
57
|
}
|
|
66
|
-
// Few changes make a shortest script cheap (about n + d²); try that first at a bounded cost.
|
|
67
58
|
if (shortest(a, b, a0, a1, b0, b1, found, a1 - a0 + b1 - b0)) continue;
|
|
68
59
|
const anchors = runs(a, b, a0, a1, b0, b1) || diagonal(a, b, a0, a1, b0, b1);
|
|
69
60
|
if (anchors) chain(anchors, a0, a1, b0, b1, true, true);
|
|
@@ -72,14 +63,11 @@ export function matcher(spend, budget) {
|
|
|
72
63
|
return found;
|
|
73
64
|
}
|
|
74
65
|
|
|
75
|
-
// Runs of k tokens found once on each side anchor text whose single tokens all repeat. Window hashes are checked
|
|
76
|
-
// token by token, so a collision costs work, never correctness.
|
|
77
66
|
function runs(a, b, a0, a1, b0, b1) {
|
|
78
67
|
const ids = new Map(), id = key => ids.get(key) ?? (ids.set(key, ids.size), ids.size - 1);
|
|
79
68
|
const left = Int32Array.from({length: a1 - a0}, (_, k) => id(a[a0 + k])), right = Int32Array.from({length: b1 - b0}, (_, k) => id(b[b0 + k]));
|
|
80
69
|
for (const k of [16, 4, 64]) {
|
|
81
70
|
if (k > Math.min(left.length, right.length) || !spend(2 * (left.length + right.length))) return;
|
|
82
|
-
// A prime modulus below 2^31 and a base below 2^21 keep every product exact in a double.
|
|
83
71
|
const P = 2147483647, B = 1000003;
|
|
84
72
|
let power = 1;
|
|
85
73
|
for (let n = 1; n < k; n++) power = power * B % P;
|
|
@@ -101,7 +89,6 @@ export function matcher(spend, budget) {
|
|
|
101
89
|
while (n < k && left[i + n] === right[j + n]) n++;
|
|
102
90
|
if (n === k) pairs.push([a0 + i, b0 + j]);
|
|
103
91
|
}
|
|
104
|
-
// Overlapping windows add nothing a gap's equal-run trim does not find; keep one per k tokens.
|
|
105
92
|
pairs.sort((p, q) => p[0] - q[0]);
|
|
106
93
|
const spaced = [];
|
|
107
94
|
for (const pair of pairs) if (!spaced.length || pair[0] >= spaced.at(-1)[0] + k) spaced.push(pair);
|
|
@@ -109,16 +96,12 @@ export function matcher(spend, budget) {
|
|
|
109
96
|
}
|
|
110
97
|
}
|
|
111
98
|
|
|
112
|
-
// Repeated lines align by position: tokens equal on the start diagonal before a split and on the end diagonal after
|
|
113
|
-
// it, at the split that keeps the most. Used only when they cover at least half of the shorter side.
|
|
114
99
|
function diagonal(a, b, a0, a1, b0, b1) {
|
|
115
100
|
const n = a1 - a0, m = b1 - b0, shift = m - n, size = Math.min(n, m);
|
|
116
101
|
if (!spend(2 * (n + m))) return;
|
|
117
|
-
// head[s]: equal pairs on the start diagonal among the first s tokens; tail[s]: on the end diagonal from s on.
|
|
118
102
|
const head = new Int32Array(n + 1), tail = new Int32Array(n + 2);
|
|
119
103
|
for (let i = 0; i < n; i++) head[i + 1] = head[i] + (i < m && a[a0 + i] === b[b0 + i]);
|
|
120
104
|
for (let i = n - 1; i >= 0; i--) tail[i] = tail[i + 1] + (i + shift >= 0 && a[a0 + i] === b[b0 + i + shift]);
|
|
121
|
-
// A deletion (shift < 0) skips -shift tokens of a between the diagonals.
|
|
122
105
|
let best = -1, split = 0;
|
|
123
106
|
for (let s = 0; s <= n; s++) {
|
|
124
107
|
const rest = Math.min(n, s + Math.max(0, -shift)), kept = head[s] + tail[rest];
|
|
@@ -131,8 +114,6 @@ export function matcher(spend, budget) {
|
|
|
131
114
|
return pairs;
|
|
132
115
|
}
|
|
133
116
|
|
|
134
|
-
// Myers' greedy search: O((N + M) D) steps; each frontier is kept for the backward pass. An attempt can stop at
|
|
135
|
-
// its own share of the work and report no script.
|
|
136
117
|
function shortest(a, b, a0, a1, b0, b1, found, share = Infinity) {
|
|
137
118
|
const n = a1 - a0, m = b1 - b0, v = new Int32Array(2 * (n + m) + 3), o = n + m + 1, trace = [];
|
|
138
119
|
const step = (k = 1) => (share -= k) >= 0 && spend(k);
|
|
@@ -13,7 +13,6 @@ export function merge(base, mine, theirs, options = {}) {
|
|
|
13
13
|
const check = n => { if (!spend(n)) throw null; };
|
|
14
14
|
const safe = options.structure && boundaries();
|
|
15
15
|
const trees = options.structure && !options._edits
|
|
16
|
-
// A reader that throws gives no structure: the texts merge by source coordinates.
|
|
17
16
|
? [base, mine, base, theirs].map(text => observe(text, options.structure, spend, safe, false, true)).map(tree => tree?.threw ? null : tree) : [];
|
|
18
17
|
const repeatedBase = trees.splice(2, 1)[0];
|
|
19
18
|
const equal = (a, b) => a.length === b.length && a.every((item, i) => item === b[i]);
|
|
@@ -71,13 +70,11 @@ export function merge(base, mine, theirs, options = {}) {
|
|
|
71
70
|
check(list.length); map.set(name, null);
|
|
72
71
|
return list.map(part => content(part, side));
|
|
73
72
|
});
|
|
74
|
-
// Each occurrence keeps its base text on one side, or the whole list does.
|
|
75
73
|
if (!equal(values[0], values[1]) && !equal(values[0], values[2]) && !(values.every(list => list.length === values[0].length) &&
|
|
76
74
|
values[0].every((text, k) => text === values[1][k] || text === values[2][k]))) throw null;
|
|
77
75
|
}
|
|
78
76
|
return maps;
|
|
79
77
|
};
|
|
80
|
-
// A part renamed on one side keeps its reader key and aligns with its base part.
|
|
81
78
|
const keyed = new Map(rows[0].filter(part => part.nameAt && part.key != null).map(part => [part.key, part]));
|
|
82
79
|
const own = new Set(rows[0].map(part => part.id)), renamed = new Map();
|
|
83
80
|
for (const side of [1, 2]) {
|
|
@@ -89,7 +86,6 @@ export function merge(base, mine, theirs, options = {}) {
|
|
|
89
86
|
}
|
|
90
87
|
const id = part => renamed.get(part) ?? part.id;
|
|
91
88
|
const maps = index(id), orders = rows.map(list => list.map(id));
|
|
92
|
-
// A vanished or added copy of a repeated name keeps the order; only a different order of shared names is a reorder.
|
|
93
89
|
const within = (a, b) => { let k = 0; for (const name of b) if (name === a[k]) k++; return k === a.length; };
|
|
94
90
|
const reordered = [1, 2].map(side => {
|
|
95
91
|
const a = orders[0].filter(name => maps[side].has(name)), b = orders[side].filter(name => maps[0].has(name));
|
|
@@ -128,7 +124,6 @@ export function merge(base, mine, theirs, options = {}) {
|
|
|
128
124
|
if (child) children.set(name, child);
|
|
129
125
|
}
|
|
130
126
|
if (frame < 0 && !children.size) return;
|
|
131
|
-
// A plan is a tree of range triples; a string is an already-admitted gap.
|
|
132
127
|
const plan = [];
|
|
133
128
|
if (frame >= 0) {
|
|
134
129
|
let at = nodes[frame].start;
|
|
@@ -160,7 +155,6 @@ export function merge(base, mine, theirs, options = {}) {
|
|
|
160
155
|
if ([...removed[0].keys()].some(name => removed[1].has(name)) &&
|
|
161
156
|
(added[0].size !== added[1].size || [...added[0].keys()].some(name => !added[1].has(name)))) throw null;
|
|
162
157
|
if (plan) {
|
|
163
|
-
// Project original ranges once. Nested moves never rebuild aligned documents.
|
|
164
158
|
const chunks = [], conflicts = [];
|
|
165
159
|
let length = 0;
|
|
166
160
|
function project(item) {
|
|
@@ -183,8 +177,6 @@ export function merge(base, mine, theirs, options = {}) {
|
|
|
183
177
|
const changes = options._edits || [mine, theirs].map((text, side) => diff(base, text,
|
|
184
178
|
{...budget, structure: trees[0] && options.structure, _observed: trees[0] ? [trees[0], trees[side + 1]] : undefined}).edits);
|
|
185
179
|
if (trees.some(Boolean) && budget._budget.left < 0) return complete();
|
|
186
|
-
// An edit reaches over the words it touches; edits whose reaches overlap form one group, so letters
|
|
187
|
-
// joined from both branches never form a word neither wrote.
|
|
188
180
|
const spans = words(wordSegmenter(), base);
|
|
189
181
|
const word = at => {
|
|
190
182
|
let low = 0, high = spans.length;
|
|
@@ -210,7 +202,6 @@ export function merge(base, mine, theirs, options = {}) {
|
|
|
210
202
|
row[1] = edit[1];
|
|
211
203
|
if (index === edits.length) break;
|
|
212
204
|
const [a, b, t, side2, from] = edits[index], peer = sides[1 - side2][2];
|
|
213
|
-
// One branch's insertion and replacement at the same base position are one contiguous edit.
|
|
214
205
|
if (!(a < end || a === start || from < reach || edits[index - 1][0] === a && edits[index - 1][3] === side2 || peer &&
|
|
215
206
|
(a === end && peer[0] === end || a === b && peer[0] < end &&
|
|
216
207
|
a - end < t.length && (peer[2] + base.slice(end, a)).endsWith(t)))) break;
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MIT
|
|
2
2
|
import {separate, phrases} from './concerns.mjs';
|
|
3
3
|
|
|
4
|
-
// Moves bind equal transported fragments at both ends; renames group a binding's sites.
|
|
5
4
|
export function moves(c) {
|
|
6
5
|
const {before, after, spend, safe, budget, raw, code, tokens, matches, walk, structures, pa, pb, la, aliases} = c;
|
|
7
6
|
const overlaps = (a, b) => a[0] < b[1] && a[1] > b[0];
|
|
@@ -9,7 +8,6 @@ export function moves(c) {
|
|
|
9
8
|
c[side][0] >= range[0] && c[side][1] <= range[1]);
|
|
10
9
|
try { if (!raw && budget.left >= 0) {
|
|
11
10
|
const check = n => { if (!spend(n)) throw null; };
|
|
12
|
-
// Split transported text at wording boundaries; positions and words own distinct edits.
|
|
13
11
|
const deleted = new Map();
|
|
14
12
|
const moveKey = (item, side, text, parts = []) => {
|
|
15
13
|
const [from, to] = item[side];
|
|
@@ -28,7 +26,6 @@ export function moves(c) {
|
|
|
28
26
|
for (const item of c.result) if (item[3][0] === 2 && !item[2]) {
|
|
29
27
|
const value = after.slice(...item[1]), key = moveKey(item, 1, after, pb);
|
|
30
28
|
let old = deleted.get(key), score = 0;
|
|
31
|
-
// Edited wording still travels: plain text, or a prose part of the same kind.
|
|
32
29
|
const text = value.trim(), prose = !structures || /^(paragraph|item|task|row|quote|section)$/.test(item[3][1]);
|
|
33
30
|
if (!old && prose && text.length >= 24) for (const candidate of deleted.values()) {
|
|
34
31
|
check(1);
|
|
@@ -41,7 +38,6 @@ export function moves(c) {
|
|
|
41
38
|
if (n * 5 > Math.max(prior.length, text.length) * 3 && n >= score) { old = n === score ? null : candidate; score = n; }
|
|
42
39
|
}
|
|
43
40
|
if (!old || old[4]) continue;
|
|
44
|
-
// Separators that only one end carries stay outside the transported text.
|
|
45
41
|
const x = [...old[0]], y = [...item[1]];
|
|
46
42
|
for (const end of [0, 1]) {
|
|
47
43
|
const pattern = end ? /\s*$/ : /^\s*/, left = before.slice(...x).match(pattern)[0], right = after.slice(...y).match(pattern)[0];
|
|
@@ -52,7 +48,6 @@ export function moves(c) {
|
|
|
52
48
|
}
|
|
53
49
|
const outer = c.result; c.result = [];
|
|
54
50
|
walk(...x, ...y, 1);
|
|
55
|
-
// A changed heading can occupy a whole line while its marker still anchors the move.
|
|
56
51
|
if (c.result.length && (c.result[0][0][0] === x[0] || c.result[0][1][0] === y[0] ||
|
|
57
52
|
c.result.at(-1)[0][1] === x[1] || c.result.at(-1)[1][1] === y[1])) {
|
|
58
53
|
c.result = []; walk(...x, ...y, 2);
|
|
@@ -60,7 +55,6 @@ export function moves(c) {
|
|
|
60
55
|
separate(c, x[1], y[1]);
|
|
61
56
|
phrases(c);
|
|
62
57
|
const inner = c.result; c.result = outer;
|
|
63
|
-
// Equal boundary fragments make the exact extent recoverable without display context.
|
|
64
58
|
if (inner.length && (inner[0][0][0] === x[0] || inner[0][1][0] === y[0] ||
|
|
65
59
|
inner.at(-1)[0][1] === x[1] || inner.at(-1)[1][1] === y[1] || inner.some(c => c[2]))) continue;
|
|
66
60
|
const move = [3, item[3][2] ? item[3][1] : code ? 'line' : 'block', item[3][2], [x, y]];
|
|
@@ -69,7 +63,6 @@ export function moves(c) {
|
|
|
69
63
|
if (a0 < a1) old[4].push([[a0, a1], [old[1][0], old[1][0]], false, group]);
|
|
70
64
|
if (b0 < b1) item[4].push([[item[0][0], item[0][0]], [b0, b1], false, group]);
|
|
71
65
|
};
|
|
72
|
-
// Unmatched outer separators travel with the move, so restoring it alone keeps blocks apart.
|
|
73
66
|
fragment(old[0][0], x[0], 0, 0, move); fragment(0, 0, item[1][0], y[0], move);
|
|
74
67
|
let a = x[0], b = y[0], wording;
|
|
75
68
|
for (const edit of inner) {
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MIT
|
|
2
|
-
// Capture only the reader contract before another observation can reuse its objects.
|
|
3
2
|
export function observe(text, reader, spend, safe, references = true, lenient = false) {
|
|
4
3
|
let value;
|
|
5
4
|
try { value = reader(text); } catch (error) { return {invalid: true, threw: true, error}; }
|
|
@@ -30,7 +29,6 @@ export function observe(text, reader, spend, safe, references = true, lenient =
|
|
|
30
29
|
if (!usable && !lenient) throw null;
|
|
31
30
|
const part = {start, end: stop, kind: scalar(kind), name: scalar(name), key: scalar(key, true)};
|
|
32
31
|
if (nameAt != null) part.nameAt = span(nameAt, start, stop);
|
|
33
|
-
// Discard unsafe evidence only after validating its complete subtree.
|
|
34
32
|
if (retained && usable) { parts.push(part); flat.push(part); }
|
|
35
33
|
part.children = children == null ? [] : copy(children, start, stop, depth + 1, retained && usable);
|
|
36
34
|
}
|
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MIT
|
|
2
|
-
// Space-free scripts (Thai, Lao, Myanmar, Khmer, kana, Han) have no word separator; the platform dictionary finds words.
|
|
3
2
|
export const SPACELESS = /[\u0e00-\u0eff\u1000-\u109f\u1780-\u17ff\u3040-\u30ff\u3400-\u9fff\uf900-\ufaff]/;
|
|
4
3
|
export const words = (segmenter, text) => [...text.matchAll(/[\p{L}\p{N}\p{M}_]+/gu)].flatMap(word => SPACELESS.test(word[0])
|
|
5
4
|
? [...segmenter().segment(word[0])].map(s => [word.index + s.index, word.index + s.index + s.segment.length])
|
|
6
5
|
: [[word.index, word.index + word[0].length]]);
|
|
7
6
|
export const wordSegmenter = () => { let segmenter; return () => segmenter ||= new Intl.Segmenter('en', {granularity: 'word'}); };
|
|
8
7
|
|
|
9
|
-
// Segment only requested non-ASCII boundaries; observations never share a global document cache.
|
|
10
8
|
export function boundaries() {
|
|
11
9
|
const segments = Object.create(null);
|
|
12
10
|
let segmenter, last = [];
|
|
@@ -15,7 +13,6 @@ export function boundaries() {
|
|
|
15
13
|
const a = text.charCodeAt(at - 1), b = text.charCodeAt(at);
|
|
16
14
|
if ((a & 0xfc00) === 0xd800 && (b & 0xfc00) === 0xdc00 || a === 13 && b === 10) return false;
|
|
17
15
|
if (a < 0x300 && b < 0x300) return true;
|
|
18
|
-
// Retreats probe one long cluster many times; answer inside the last cluster without the segmenter.
|
|
19
16
|
if (last[0] === text && at > last[1] && at < last[2]) return false;
|
|
20
17
|
const found = (segments[text] ||= (segmenter ||= new Intl.Segmenter('en', {granularity: 'grapheme'})).segment(text)).containing(at);
|
|
21
18
|
last = [text, found.index, found.index + found.segment.length];
|
|
@@ -23,16 +20,13 @@ export function boundaries() {
|
|
|
23
20
|
}
|
|
24
21
|
}
|
|
25
22
|
|
|
26
|
-
// Letters are code points: an astral letter is one letter, never two lone surrogates.
|
|
27
23
|
const pair = (text, at) => (text.charCodeAt(at - 1) & 0xfc00) === 0xd800 && (text.charCodeAt(at) & 0xfc00) === 0xdc00;
|
|
28
24
|
export const prev = (text, at) => text.slice(Math.max(0, at - 1 - pair(text, at - 1)), at), next = (text, at) => text.slice(at, at + 1 + pair(text, at + 1));
|
|
29
25
|
export const letter = c => c && /[\p{L}\p{M}\p{N}_]/u.test(c), within = (text, at) => letter(prev(text, at)) && letter(next(text, at));
|
|
30
26
|
|
|
31
|
-
// Tokens at a level: 0 parts, 1 lines, 2 words, spaces and marks, 3 characters.
|
|
32
27
|
export function tokenizer(c) {
|
|
33
28
|
const {spend, safe, segmenter, raw, aliases} = c;
|
|
34
29
|
return function tokens(text, start, end, level, parts) {
|
|
35
|
-
// Each source boundary is stored once, alongside comparison keys and optional owners.
|
|
36
30
|
const keys = [], at = [start], owners = [];
|
|
37
31
|
const out = [keys, at, owners];
|
|
38
32
|
const add = (a, b, part, value) => {
|
|
@@ -1,17 +1,13 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MIT
|
|
2
2
|
import {words, letter, within, prev, next} from './tokens.mjs';
|
|
3
3
|
|
|
4
|
-
// Exact source records for a range: lines, then words, then characters, or structural parts when supplied.
|
|
5
4
|
export function walker(c) {
|
|
6
5
|
const {before, after, spend, safe, budget, raw, chars, code, segmenter, tokens, matches} = c;
|
|
7
6
|
function record(a0, a1, b0, b1, unit, part, coarse = false) {
|
|
8
7
|
if (a1 - a0 === b1 - b0 && before.slice(a0, a1) === after.slice(b0, b1)) return;
|
|
9
|
-
// Internal records: before, after, coarse, concern, transported fragments.
|
|
10
8
|
c.result.push([[a0, a1], [b0, b1], coarse, [a0 === a1 ? 2 : b0 === b1 ? 0 : 1, part?.kind || unit, part?.name]]);
|
|
11
9
|
}
|
|
12
10
|
|
|
13
|
-
// A line deletion or insertion holding whole named parts and only blank text besides is one record per part,
|
|
14
|
-
// cut at line starts, so each keeps its own name (and its callers' breaks).
|
|
15
11
|
function name(from, ownersA, ownersB) {
|
|
16
12
|
for (let r = from; r < c.result.length; r++) {
|
|
17
13
|
const [[a0, a1], [b0, b1]] = c.result[r], side = a0 === a1 ? 1 : b0 === b1 ? 0 : -1;
|
|
@@ -34,7 +30,6 @@ export function walker(c) {
|
|
|
34
30
|
}
|
|
35
31
|
}
|
|
36
32
|
|
|
37
|
-
// A search step scans at most one fixed-size source chunk.
|
|
38
33
|
function common(a, b, end, step) {
|
|
39
34
|
let n = 0;
|
|
40
35
|
while (n < end && spend()) {
|
|
@@ -95,7 +90,6 @@ export function walker(c) {
|
|
|
95
90
|
}
|
|
96
91
|
} else if (level < 2 && raw !== 'line') lines(x.slice(ai, i + 1), y.slice(bi, j + 1), context);
|
|
97
92
|
else if (level === 2 && chars && !raw && i - ai <= 1 && j - bi <= 1 && x1 - x0 + y1 - y0 < 100) {
|
|
98
|
-
// A changed token is replaced whole unless the caller asks for the characters inside it.
|
|
99
93
|
walk(x0, x1, y0, y1, 3, null, null, context);
|
|
100
94
|
} else record(x0, x1, y0, y1, unit, context);
|
|
101
95
|
}
|
|
@@ -108,12 +102,9 @@ export function walker(c) {
|
|
|
108
102
|
}
|
|
109
103
|
}
|
|
110
104
|
|
|
111
|
-
// Unequal lines pair in order by shared words. A line sharing under a quarter of its words with every candidate is
|
|
112
|
-
// replaced whole: an inserted line never borrows a neighbor's words, and a rewrite never reads as stray fragments.
|
|
113
105
|
function lines(x, y, context) {
|
|
114
106
|
const n = x.length - 1, m = y.length - 1;
|
|
115
107
|
if (n * m > 4096 || !spend(n * m + x[n] - x[0] + y[m] - y[0])) return row(x[0], x[n], y[0], y[m], context);
|
|
116
|
-
// A renamed name's sites count as shared words.
|
|
117
108
|
if (!c.renames) {
|
|
118
109
|
c.renames = [[], []];
|
|
119
110
|
for (const [p, old] of c.aliases || []) if (before.slice(...old.nameAt) !== after.slice(...p.nameAt)) {
|
|
@@ -141,7 +132,6 @@ export function walker(c) {
|
|
|
141
132
|
}
|
|
142
133
|
let k = 0, l = 0;
|
|
143
134
|
for (const [p, q] of [...pairs, [n, m]]) {
|
|
144
|
-
// A merge compares one line against one line by character, whatever they share.
|
|
145
135
|
if (raw === 'char' && p - k === 1 && q - l === 1) row(x[k], x[p], y[l], y[q], context);
|
|
146
136
|
else if (k < p || l < q) record(x[k], x[p], y[l], y[q], 'line', context);
|
|
147
137
|
if (p < n) row(x[p], x[p + 1], y[q], y[q + 1], context);
|
|
@@ -149,7 +139,6 @@ export function walker(c) {
|
|
|
149
139
|
}
|
|
150
140
|
}
|
|
151
141
|
|
|
152
|
-
// A table row with the same number of cells on both sides changes cell by cell; a character diff stays in its line.
|
|
153
142
|
function row(a0, a1, b0, b1, context) {
|
|
154
143
|
if (raw === 'char') return walk(a0, a1, b0, b1, 3, null, null, context);
|
|
155
144
|
const cells = (text, a, b) => /^[ \t]*\|/.test(text.slice(a, b)) && [...text.slice(a, b).matchAll(/(?<!\\)\|/g)].map(m => a + m.index);
|
|
@@ -158,7 +147,6 @@ export function walker(c) {
|
|
|
158
147
|
for (let k = 0; k <= p.length; k++) walk(k ? p[k - 1] + 1 : a0, p[k] ?? a1, k ? q[k - 1] + 1 : b0, q[k] ?? b1, 2, null, null, context);
|
|
159
148
|
}
|
|
160
149
|
|
|
161
|
-
// Surviving source pieces keep the occurrence witnessed by the host.
|
|
162
150
|
function operations(rows) {
|
|
163
151
|
if (!Array.isArray(rows)) return null;
|
|
164
152
|
const pieces = [[0, before.length]];
|
|
@@ -1,15 +1,12 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MIT
|
|
2
|
-
// Embedded fonts are ordinary CommonMark reference definitions. Parsing owns spans, never bytes.
|
|
3
2
|
import {markdownParser, installMarkdownImages, normalizeLabel} from './md-assets.mjs';
|
|
4
3
|
import {splitOpeningFrontmatter} from './frontmatter.mjs';
|
|
5
4
|
import {readDocumentSettings, documentFont} from './document-settings.mjs';
|
|
6
5
|
import {normalizeFontFamily, fontFamilyCss, FONT_STACKS, typography} from './md-marks.mjs';
|
|
7
6
|
|
|
8
|
-
// Unquoted CSS generic families require no portable asset. Quoted names remain custom.
|
|
9
7
|
export const CSS_GENERIC_FONT_FAMILIES = Object.freeze(['serif', 'sans-serif', 'monospace', 'cursive', 'fantasy', 'system-ui',
|
|
10
8
|
'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded', 'emoji', 'math', 'fangsong', 'initial', 'revert', 'revert-layer']);
|
|
11
9
|
|
|
12
|
-
// No decoding or external resource loading happens here.
|
|
13
10
|
export function fontDataUrl(url) {
|
|
14
11
|
if (typeof url !== 'string') return null;
|
|
15
12
|
const match = /^data:font\/(woff2|woff|ttf|otf);base64,([A-Za-z0-9+/]+={0,2})$/i.exec(url);
|
|
@@ -123,8 +120,6 @@ function escapeFontLabel(label) {
|
|
|
123
120
|
return String(label).replace(/[\\[\]]/g, '\\$&').replace(/&/g, '&');
|
|
124
121
|
}
|
|
125
122
|
|
|
126
|
-
// Unchanged faces keep their original definition, whitespace and line ending. Only supplied font
|
|
127
|
-
// assets are replaced; pictures, other references and malformed authored source stay untouched.
|
|
128
123
|
export function writeFontAssets(markdown, assets, parser) {
|
|
129
124
|
const source = String(markdown ?? ''), md = parserFor(parser), index = readFontAssets(source, md), wanted = new Map();
|
|
130
125
|
for (const asset of assets || []) {
|
|
@@ -189,8 +184,6 @@ function htmlFontFamilies(html, md) {
|
|
|
189
184
|
for (const attr of tag[0].matchAll(/\s(data-md-font|style)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gi)) {
|
|
190
185
|
const value = decode(attr[2] ?? attr[3] ?? attr[4]), names = [];
|
|
191
186
|
if (/^<span(?=[\s>])/i.test(tag[0])) {
|
|
192
|
-
// Raw or unpaired spans use the same admission as paired typography. A CSS
|
|
193
|
-
// resource expression or authored data-* hook cannot request a font asset.
|
|
194
187
|
const family = attr[1].toLowerCase() === 'style' ? typography.parseStyle(value).family : null;
|
|
195
188
|
if (family?.custom) families.add(family.name);
|
|
196
189
|
continue;
|
|
@@ -212,8 +205,6 @@ function htmlFontFamilies(html, md) {
|
|
|
212
205
|
return families;
|
|
213
206
|
}
|
|
214
207
|
|
|
215
|
-
// Tokens supply decoded characters: reference payloads, link destinations and Markdown punctuation
|
|
216
|
-
// never inflate a subset. Each face in a family receives the family's complete text for shaping.
|
|
217
208
|
export function documentFontUsage(markdown, parser, defaultFont) {
|
|
218
209
|
const source = String(markdown ?? ''), md = parserFor(parser), settings = readDocumentSettings(source), use = new Map();
|
|
219
210
|
const family = setting => setting?.stack === 'named' ? setting.family : null;
|