rapier-markdown-kit 1.1.104 → 1.2.5
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/LICENSE +8 -4
- package/README.md +50 -7
- package/dist/images/gif.mjs +49 -0
- package/dist/images/playback.mjs +103 -0
- package/dist/kit/conformance/03-wrap-around-silhouette.md +1 -1
- package/dist/kit/conformance/04-wrap-box.md +1 -1
- package/dist/kit/conformance/05-behind.md +1 -1
- package/dist/kit/conformance/06-front.md +1 -1
- package/dist/kit/conformance/07-rotate-raster.md +1 -1
- package/dist/kit/conformance/08-drawing-rotation.md +1 -1
- package/dist/kit/conformance/09-drawing-ring-interior.expected.json +18 -18
- package/dist/kit/conformance/09-drawing-ring-interior.md +1 -1
- package/dist/kit/conformance/10-both-sides.md +1 -1
- package/dist/kit/conformance/11-neighbour-skips-image.md +1 -1
- package/dist/kit/conformance/{12-heading-barrier.expected.json → 12-rule-barrier.expected.json} +1 -1
- package/dist/kit/conformance/{12-heading-barrier.md → 12-rule-barrier.md} +2 -2
- package/dist/kit/conformance/README.md +19 -28
- package/dist/kit/conformance/run.mjs +10 -8
- package/dist/kit/ledger/authored-origin.mjs +49 -0
- package/dist/kit/ledger/authorship.mjs +18 -9
- package/dist/kit/ledger/carried.mjs +10 -5
- package/dist/kit/ledger/data.mjs +7 -4
- package/dist/kit/ledger/format.mjs +37 -11
- package/dist/kit/ledger/hash.mjs +31 -15
- package/dist/kit/ledger/history.mjs +48 -10
- package/dist/kit/ledger/inverse-source.mjs +111 -49
- package/dist/kit/ledger/journal-records.mjs +24 -29
- package/dist/kit/ledger/merge.mjs +51 -15
- package/dist/kit/ledger/text-merge.mjs +75 -29
- package/dist/kit/ledger/transport.mjs +30 -45
- package/dist/kit/pdf-vectors.mjs +6 -2
- package/dist/kit/print-capitals.mjs +0 -1
- package/dist/kit/rapier-ledger.mjs +4 -2
- package/dist/kit/render-ink.mjs +1 -1
- package/dist/kit/render-markdown.mjs +155 -31
- package/dist/kit/render-print.mjs +173 -61
- package/dist/kit/render-sanitize.mjs +25 -15
- package/dist/kit/render-styles.mjs +3 -3
- package/dist/kit/render.mjs +37 -115
- package/dist/layout/model.mjs +10 -8
- package/dist/layout/paragraph-fit.mjs +1 -15
- package/dist/layout/typography.mjs +2 -1
- package/dist/packages/rapier-diff/concerns.mjs +163 -0
- package/dist/packages/rapier-diff/diff.mjs +68 -0
- package/dist/packages/rapier-diff/index.mjs +4 -701
- package/dist/packages/rapier-diff/matching.mjs +157 -0
- package/dist/packages/rapier-diff/merge.mjs +226 -0
- package/dist/packages/rapier-diff/moves.mjs +130 -0
- package/dist/packages/rapier-diff/structure.mjs +50 -0
- package/dist/packages/rapier-diff/tokens.mjs +72 -0
- package/dist/packages/rapier-diff/walk.mjs +219 -0
- package/dist/spec/document-fonts.mjs +1 -1
- package/dist/spec/document-settings.mjs +11 -164
- package/dist/spec/html-blocks.mjs +618 -79
- package/dist/spec/md-assets.mjs +29 -6
- package/dist/spec/md-layout.mjs +9 -1
- package/dist/spec/md-marks.mjs +25 -147
- package/dist/spec/md-source.mjs +69 -26
- package/package.json +1 -1
- package/style.css +7 -51
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Subsequence search over token keys, bounded by a shared work budget.
|
|
3
|
+
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
|
+
function matches(a, b) {
|
|
8
|
+
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
|
+
const chain = (pairs, a0, a1, b0, b1, split, near) => {
|
|
11
|
+
const tails = [];
|
|
12
|
+
for (const [i, j] of pairs) {
|
|
13
|
+
let lo = 0, hi = tails.length;
|
|
14
|
+
while (lo < hi) { const middle = lo + hi >>> 1; if (tails[middle][1] < j) lo = middle + 1; else hi = middle; }
|
|
15
|
+
tails[lo] = [i, j, tails[lo - 1]];
|
|
16
|
+
}
|
|
17
|
+
const kept = [];
|
|
18
|
+
for (let node = tails.at(-1); node; node = node[2]) kept.push(node);
|
|
19
|
+
let x = a0, y = b0;
|
|
20
|
+
for (const [i, j] of kept.reverse()) {
|
|
21
|
+
found[i] = j;
|
|
22
|
+
if (split && x < i && y < j) pending.push([x, i, y, j, near]);
|
|
23
|
+
x = i + 1; y = j + 1;
|
|
24
|
+
}
|
|
25
|
+
if (split && x < a1 && y < b1) pending.push([x, a1, y, b1, near]);
|
|
26
|
+
};
|
|
27
|
+
while (pending.length) {
|
|
28
|
+
let [a0, a1, b0, b1, near] = pending.pop();
|
|
29
|
+
while (a0 < a1 && b0 < b1 && a[a0] === b[b0]) found[a0++] = b0++;
|
|
30
|
+
while (a0 < a1 && b0 < b1 && a[a1 - 1] === b[b1 - 1]) found[--a1] = --b1;
|
|
31
|
+
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
|
+
if (near && shortest(a, b, a0, a1, b0, b1, found, 4 * (a1 - a0 + b1 - b0))) continue;
|
|
34
|
+
const positions = new Map();
|
|
35
|
+
for (let j = b0; j < b1; j++) {
|
|
36
|
+
const list = positions.get(b[j]);
|
|
37
|
+
if (list) list.push(j); else positions.set(b[j], [0, j]);
|
|
38
|
+
}
|
|
39
|
+
for (let i = a0; i < a1; i++) {
|
|
40
|
+
const list = positions.get(a[i]); if (list) list[0]++;
|
|
41
|
+
}
|
|
42
|
+
let unique = 0, count = 0;
|
|
43
|
+
for (const list of positions.values()) {
|
|
44
|
+
count += list[0] * (list.length - 1);
|
|
45
|
+
if (list[0] === 1 && list.length === 2) unique++;
|
|
46
|
+
}
|
|
47
|
+
if (!count) continue;
|
|
48
|
+
// With no unique anchors, equal-length repeated runs prefer their witnessed positions to an arbitrary LIS.
|
|
49
|
+
if (!unique && a1 - a0 === b1 - b0) {
|
|
50
|
+
const anchors = diagonal(a, b, a0, a1, b0, b1);
|
|
51
|
+
if (anchors) { chain(anchors, a0, a1, b0, b1, true, true); continue; }
|
|
52
|
+
}
|
|
53
|
+
// Charge the chain before building it: one that cannot be afforded falls through to cheaper anchors.
|
|
54
|
+
const chained = unique || count, cost = chained * Math.ceil(Math.log2(chained + 2));
|
|
55
|
+
if (cost <= budget.left) {
|
|
56
|
+
const pairs = [];
|
|
57
|
+
for (let i = a0; i < a1; i++) {
|
|
58
|
+
const list = positions.get(a[i]);
|
|
59
|
+
if (!list || unique && (list[0] !== 1 || list.length !== 2)) continue;
|
|
60
|
+
for (let p = list.length - 1; p > 0; p--) pairs.push([i, list[p]]);
|
|
61
|
+
}
|
|
62
|
+
spend(cost);
|
|
63
|
+
chain(pairs, a0, a1, b0, b1, unique > 0);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
// Few changes make a shortest script cheap (about n + d²); try that first at a bounded cost.
|
|
67
|
+
if (shortest(a, b, a0, a1, b0, b1, found, a1 - a0 + b1 - b0)) continue;
|
|
68
|
+
const anchors = runs(a, b, a0, a1, b0, b1) || diagonal(a, b, a0, a1, b0, b1);
|
|
69
|
+
if (anchors) chain(anchors, a0, a1, b0, b1, true, true);
|
|
70
|
+
else if (!shortest(a, b, a0, a1, b0, b1, found) && budget.left >= 0) (found.coarse ||= new Set()).add(a0);
|
|
71
|
+
}
|
|
72
|
+
return found;
|
|
73
|
+
}
|
|
74
|
+
|
|
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
|
+
function runs(a, b, a0, a1, b0, b1) {
|
|
78
|
+
const ids = new Map(), id = key => ids.get(key) ?? (ids.set(key, ids.size), ids.size - 1);
|
|
79
|
+
const left = Int32Array.from({length: a1 - a0}, (_, k) => id(a[a0 + k])), right = Int32Array.from({length: b1 - b0}, (_, k) => id(b[b0 + k]));
|
|
80
|
+
for (const k of [16, 4, 64]) {
|
|
81
|
+
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
|
+
const P = 2147483647, B = 1000003;
|
|
84
|
+
let power = 1;
|
|
85
|
+
for (let n = 1; n < k; n++) power = power * B % P;
|
|
86
|
+
const windows = list => {
|
|
87
|
+
const seen = new Map();
|
|
88
|
+
let hash = 0;
|
|
89
|
+
for (let n = 0; n < list.length; n++) {
|
|
90
|
+
if (n >= k) hash = (hash + P - (list[n - k] + 1) * power % P) % P;
|
|
91
|
+
hash = (hash * B + list[n] + 1) % P;
|
|
92
|
+
if (n >= k - 1) seen.set(hash, seen.has(hash) ? -1 : n - k + 1);
|
|
93
|
+
}
|
|
94
|
+
return seen;
|
|
95
|
+
};
|
|
96
|
+
const mine = windows(left), theirs = windows(right), pairs = [];
|
|
97
|
+
for (const [hash, i] of mine) {
|
|
98
|
+
const j = theirs.get(hash);
|
|
99
|
+
if (i < 0 || j === undefined || j < 0) continue;
|
|
100
|
+
let n = 0;
|
|
101
|
+
while (n < k && left[i + n] === right[j + n]) n++;
|
|
102
|
+
if (n === k) pairs.push([a0 + i, b0 + j]);
|
|
103
|
+
}
|
|
104
|
+
// Overlapping windows add nothing a gap's equal-run trim does not find; keep one per k tokens.
|
|
105
|
+
pairs.sort((p, q) => p[0] - q[0]);
|
|
106
|
+
const spaced = [];
|
|
107
|
+
for (const pair of pairs) if (!spaced.length || pair[0] >= spaced.at(-1)[0] + k) spaced.push(pair);
|
|
108
|
+
if (spaced.length && spend(pairs.length + spaced.length * Math.ceil(Math.log2(spaced.length + 2)))) return spaced;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
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
|
+
function diagonal(a, b, a0, a1, b0, b1) {
|
|
115
|
+
const n = a1 - a0, m = b1 - b0, shift = m - n, size = Math.min(n, m);
|
|
116
|
+
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
|
+
const head = new Int32Array(n + 1), tail = new Int32Array(n + 2);
|
|
119
|
+
for (let i = 0; i < n; i++) head[i + 1] = head[i] + (i < m && a[a0 + i] === b[b0 + i]);
|
|
120
|
+
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
|
+
let best = -1, split = 0;
|
|
123
|
+
for (let s = 0; s <= n; s++) {
|
|
124
|
+
const rest = Math.min(n, s + Math.max(0, -shift)), kept = head[s] + tail[rest];
|
|
125
|
+
if (kept > best) { best = kept; split = s; }
|
|
126
|
+
}
|
|
127
|
+
if (best * 2 < size) return;
|
|
128
|
+
const pairs = [], rest = Math.min(n, split + Math.max(0, -shift));
|
|
129
|
+
for (let i = 0; i < split; i++) if (i < m && a[a0 + i] === b[b0 + i]) pairs.push([a0 + i, b0 + i]);
|
|
130
|
+
for (let i = rest; i < n; i++) if (i + shift >= 0 && a[a0 + i] === b[b0 + i + shift]) pairs.push([a0 + i, b0 + i + shift]);
|
|
131
|
+
return pairs;
|
|
132
|
+
}
|
|
133
|
+
|
|
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
|
+
function shortest(a, b, a0, a1, b0, b1, found, share = Infinity) {
|
|
137
|
+
const n = a1 - a0, m = b1 - b0, v = new Int32Array(2 * (n + m) + 3), o = n + m + 1, trace = [];
|
|
138
|
+
const step = (k = 1) => (share -= k) >= 0 && spend(k);
|
|
139
|
+
for (let d = 0; step(2 * d + 1); d++) {
|
|
140
|
+
trace.push(v.slice(o - d, o + d + 1));
|
|
141
|
+
for (let k = -d; k <= d; k += 2) {
|
|
142
|
+
let x = k === -d || k !== d && v[o + k - 1] < v[o + k + 1] ? v[o + k + 1] : v[o + k - 1] + 1, y = x - k;
|
|
143
|
+
while (x < n && y < m && a[a0 + x] === b[b0 + y]) { if (!step()) return; x++; y++; }
|
|
144
|
+
v[o + k] = x;
|
|
145
|
+
if (x < n || y < m) continue;
|
|
146
|
+
for (x = n, y = m; d >= 0; d--) {
|
|
147
|
+
const w = trace[d], k = x - y, prev = k === -d || k !== d && w[d + k - 1] < w[d + k + 1] ? k + 1 : k - 1;
|
|
148
|
+
const px = d ? w[d + prev] : 0, py = px - prev;
|
|
149
|
+
while (x > px && y > py) if (a[a0 + --x] === b[b0 + --y]) found[a0 + x] = b0 + y;
|
|
150
|
+
x = px; y = py;
|
|
151
|
+
}
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return matches;
|
|
157
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
import {diff} from './diff.mjs';
|
|
3
|
+
import {observe} from './structure.mjs';
|
|
4
|
+
import {boundaries, words, wordSegmenter} from './tokens.mjs';
|
|
5
|
+
|
|
6
|
+
export function merge(base, mine, theirs, options = {}) {
|
|
7
|
+
if (mine === theirs || base === theirs || base === mine) return {text: base === mine ? theirs : mine, conflicts: []};
|
|
8
|
+
const budget = {...options, _unit: 'char', _structure: true,
|
|
9
|
+
_budget: options._budget || {left: Number.isFinite(options.limit) ? Math.max(0, options.limit) : 1e7}};
|
|
10
|
+
const sources = [base, mine, theirs];
|
|
11
|
+
const complete = () => ({text: mine, conflicts: [{at: [0, mine.length], theirs}]});
|
|
12
|
+
const spend = (n = 1) => (budget._budget.left -= n) >= 0;
|
|
13
|
+
const check = n => { if (!spend(n)) throw null; };
|
|
14
|
+
const safe = options.structure && boundaries();
|
|
15
|
+
const trees = options.structure && !options._edits
|
|
16
|
+
// A reader that throws gives no structure: the texts merge by source coordinates.
|
|
17
|
+
? [base, mine, base, theirs].map(text => observe(text, options.structure, spend, safe, false, true)).map(tree => tree?.threw ? null : tree) : [];
|
|
18
|
+
const repeatedBase = trees.splice(2, 1)[0];
|
|
19
|
+
const equal = (a, b) => a.length === b.length && a.every((item, i) => item === b[i]);
|
|
20
|
+
if (trees.some(Boolean) || repeatedBase) {
|
|
21
|
+
try {
|
|
22
|
+
if (!trees.every(tree => tree && !tree.invalid) || !repeatedBase || repeatedBase.invalid) return complete();
|
|
23
|
+
const a = trees[0].flat, b = repeatedBase.flat;
|
|
24
|
+
check(a.length + b.length);
|
|
25
|
+
if (a.length !== b.length) return complete();
|
|
26
|
+
for (let i = 0; i < a.length; i++) {
|
|
27
|
+
const p = a[i], q = b[i];
|
|
28
|
+
if (p.start !== q.start || p.end !== q.end || p.kind !== q.kind || p.name !== q.name || p.key !== q.key ||
|
|
29
|
+
p.children.length !== q.children.length || !equal(p.nameAt || [], q.nameAt || [])) return complete();
|
|
30
|
+
}
|
|
31
|
+
for (const tree of trees) for (const part of tree.flat)
|
|
32
|
+
part.id = part.name && part.kind ? part.kind.length + ':' + part.kind + part.name : null;
|
|
33
|
+
const removed = [new Map(), new Map()], added = [new Map(), new Map()];
|
|
34
|
+
let container = 0;
|
|
35
|
+
const content = (part, side) => sources[side].slice(part.start, part.end);
|
|
36
|
+
function align(nodes) {
|
|
37
|
+
const original = content(nodes[0], 0);
|
|
38
|
+
if (original === content(nodes[1], 1) && original === content(nodes[2], 2)) {
|
|
39
|
+
nodes[0].common = original; return;
|
|
40
|
+
}
|
|
41
|
+
const hidden = nodes.flatMap(node => node.children.filter(part => !part.id));
|
|
42
|
+
while (hidden.length) {
|
|
43
|
+
const part = hidden.pop(); check(1);
|
|
44
|
+
if (part.id) throw null;
|
|
45
|
+
for (const child of part.children) hidden.push(child);
|
|
46
|
+
}
|
|
47
|
+
const rows = nodes.map(node => node.children.filter(part => part.id));
|
|
48
|
+
const width = rows.reduce((n, list) => n + list.length, 0);
|
|
49
|
+
check(1 + width);
|
|
50
|
+
let repeated = false;
|
|
51
|
+
const index = nameOf => {
|
|
52
|
+
const duplicates = new Set();
|
|
53
|
+
const maps = rows.map((list, side) => {
|
|
54
|
+
const map = new Map();
|
|
55
|
+
for (const part of list) {
|
|
56
|
+
const name = nameOf(part, side);
|
|
57
|
+
if (!name) continue;
|
|
58
|
+
const old = map.get(name);
|
|
59
|
+
if (!old) map.set(name, part);
|
|
60
|
+
else {
|
|
61
|
+
duplicates.add(name);
|
|
62
|
+
if (Array.isArray(old)) old.push(part); else map.set(name, [old, part]);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return map;
|
|
66
|
+
});
|
|
67
|
+
for (const name of duplicates) {
|
|
68
|
+
repeated = true;
|
|
69
|
+
const values = maps.map((map, side) => {
|
|
70
|
+
const part = map.get(name), list = Array.isArray(part) ? part : part ? [part] : [];
|
|
71
|
+
check(list.length); map.set(name, null);
|
|
72
|
+
return list.map(part => content(part, side));
|
|
73
|
+
});
|
|
74
|
+
// Each occurrence keeps its base text on one side, or the whole list does.
|
|
75
|
+
if (!equal(values[0], values[1]) && !equal(values[0], values[2]) && !(values.every(list => list.length === values[0].length) &&
|
|
76
|
+
values[0].every((text, k) => text === values[1][k] || text === values[2][k]))) throw null;
|
|
77
|
+
}
|
|
78
|
+
return maps;
|
|
79
|
+
};
|
|
80
|
+
// A part renamed on one side keeps its reader key and aligns with its base part.
|
|
81
|
+
const keyed = new Map(rows[0].filter(part => part.nameAt && part.key != null).map(part => [part.key, part]));
|
|
82
|
+
const own = new Set(rows[0].map(part => part.id)), renamed = new Map();
|
|
83
|
+
for (const side of [1, 2]) {
|
|
84
|
+
const names = new Set(rows[side].map(part => part.id));
|
|
85
|
+
for (const part of rows[side]) {
|
|
86
|
+
const old = keyed.get(part.key);
|
|
87
|
+
if (old && part.nameAt && old.kind === part.kind && !own.has(part.id) && !names.has(old.id)) renamed.set(part, old.id);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const id = part => renamed.get(part) ?? part.id;
|
|
91
|
+
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
|
+
const within = (a, b) => { let k = 0; for (const name of b) if (name === a[k]) k++; return k === a.length; };
|
|
94
|
+
const reordered = [1, 2].map(side => {
|
|
95
|
+
const a = orders[0].filter(name => maps[side].has(name)), b = orders[side].filter(name => maps[0].has(name));
|
|
96
|
+
check(a.length + b.length); return !within(a, b) && !within(b, a);
|
|
97
|
+
});
|
|
98
|
+
index((part, side) => {
|
|
99
|
+
check(1);
|
|
100
|
+
if (part.nameAt) return part.kind.length + ':' + part.kind + sources[side].slice(...part.nameAt);
|
|
101
|
+
});
|
|
102
|
+
const parent = container++;
|
|
103
|
+
for (const side of [1, 2]) {
|
|
104
|
+
const peer = 3 - side;
|
|
105
|
+
for (const [name, part] of maps[0]) if (!maps[side].has(name)) {
|
|
106
|
+
removed[side - 1].set(parent + ':' + name, [part.kind, parent]);
|
|
107
|
+
const other = maps[peer].get(name);
|
|
108
|
+
if (part?.nameAt && other?.nameAt && content(part, 0) !== content(other, peer)) throw null;
|
|
109
|
+
}
|
|
110
|
+
for (const [name, part] of maps[side]) if (!maps[0].has(name))
|
|
111
|
+
added[side - 1].set(parent + ':' + name, [part.kind, parent]);
|
|
112
|
+
}
|
|
113
|
+
let frame = -1;
|
|
114
|
+
if (reordered.some(Boolean)) {
|
|
115
|
+
if (repeated || !maps.every(map => map.size === maps[0].size && [...maps[0].keys()].every(name => map.has(name)))) throw null;
|
|
116
|
+
if (reordered.every(Boolean) && !equal(orders[1], orders[2])) throw null;
|
|
117
|
+
frame = reordered[0] ? 1 : 2;
|
|
118
|
+
const gaps = rows.map((list, side) => {
|
|
119
|
+
let at = nodes[side].start;
|
|
120
|
+
return [...list.map(part => { const gap = sources[side].slice(at, part.start); at = part.end; return gap; }),
|
|
121
|
+
sources[side].slice(at, nodes[side].end)];
|
|
122
|
+
});
|
|
123
|
+
if (!equal(gaps[3 - frame], gaps[reordered.every(Boolean) ? frame : 0])) throw null;
|
|
124
|
+
}
|
|
125
|
+
const children = new Map();
|
|
126
|
+
for (const [name, part] of maps[0]) if (part && maps[1].get(name) && maps[2].get(name)) {
|
|
127
|
+
const child = align([part, maps[1].get(name), maps[2].get(name)]);
|
|
128
|
+
if (child) children.set(name, child);
|
|
129
|
+
}
|
|
130
|
+
if (frame < 0 && !children.size) return;
|
|
131
|
+
// A plan is a tree of range triples; a string is an already-admitted gap.
|
|
132
|
+
const plan = [];
|
|
133
|
+
if (frame >= 0) {
|
|
134
|
+
let at = nodes[frame].start;
|
|
135
|
+
for (const part of rows[frame]) {
|
|
136
|
+
plan.push(sources[frame].slice(at, part.start), children.get(id(part)) || (maps[0].get(id(part)).common ?? {ranges: maps.map(map => map.get(id(part)))}));
|
|
137
|
+
at = part.end;
|
|
138
|
+
}
|
|
139
|
+
plan.push(sources[frame].slice(at, nodes[frame].end));
|
|
140
|
+
} else {
|
|
141
|
+
let at = nodes.map(node => node.start);
|
|
142
|
+
for (const child of children.values()) {
|
|
143
|
+
const ranges = child.nodes;
|
|
144
|
+
plan.push({ranges: ranges.map((node, side) => ({start: at[side], end: node.start}))}, child);
|
|
145
|
+
at = ranges.map(node => node.end);
|
|
146
|
+
}
|
|
147
|
+
plan.push({ranges: nodes.map((node, side) => ({start: at[side], end: node.end}))});
|
|
148
|
+
}
|
|
149
|
+
return {nodes, plan};
|
|
150
|
+
}
|
|
151
|
+
const plan = align(trees.map((tree, side) => ({start: 0, end: sources[side].length, children: tree.parts})));
|
|
152
|
+
for (const side of [0, 1]) {
|
|
153
|
+
const destinations = new Map();
|
|
154
|
+
check(added[side].size + removed[side].size);
|
|
155
|
+
for (const [kind, parent] of added[side].values())
|
|
156
|
+
destinations.set(kind, destinations.has(kind) && destinations.get(kind) !== parent ? null : parent);
|
|
157
|
+
for (const [kind, parent] of removed[side].values())
|
|
158
|
+
if (destinations.has(kind) && destinations.get(kind) !== parent) throw null;
|
|
159
|
+
}
|
|
160
|
+
if ([...removed[0].keys()].some(name => removed[1].has(name)) &&
|
|
161
|
+
(added[0].size !== added[1].size || [...added[0].keys()].some(name => !added[1].has(name)))) throw null;
|
|
162
|
+
if (plan) {
|
|
163
|
+
// Project original ranges once. Nested moves never rebuild aligned documents.
|
|
164
|
+
const chunks = [], conflicts = [];
|
|
165
|
+
let length = 0;
|
|
166
|
+
function project(item) {
|
|
167
|
+
if (item.plan) { for (const child of item.plan) project(child); return; }
|
|
168
|
+
check(1);
|
|
169
|
+
let text = item;
|
|
170
|
+
if (typeof item !== 'string') {
|
|
171
|
+
const result = merge(...item.ranges.map(content), {...budget, structure: undefined, from: undefined});
|
|
172
|
+
check(0); text = result.text;
|
|
173
|
+
for (const conflict of result.conflicts)
|
|
174
|
+
conflicts.push({at: conflict.at.map(at => at + length), theirs: conflict.theirs});
|
|
175
|
+
}
|
|
176
|
+
chunks.push(text); length += text.length;
|
|
177
|
+
}
|
|
178
|
+
project(plan);
|
|
179
|
+
return {text: chunks.join(''), conflicts};
|
|
180
|
+
}
|
|
181
|
+
} catch (error) { if (error !== null) throw error; return complete(); }
|
|
182
|
+
}
|
|
183
|
+
const changes = options._edits || [mine, theirs].map((text, side) => diff(base, text,
|
|
184
|
+
{...budget, structure: trees[0] && options.structure, _observed: trees[0] ? [trees[0], trees[side + 1]] : undefined}).edits);
|
|
185
|
+
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
|
+
const spans = words(wordSegmenter(), base);
|
|
189
|
+
const word = at => {
|
|
190
|
+
let low = 0, high = spans.length;
|
|
191
|
+
while (low < high) { const mid = low + high >> 1; if (spans[mid][1] <= at) low = mid + 1; else high = mid; }
|
|
192
|
+
return spans[low]?.[0] <= at ? spans[low] : null;
|
|
193
|
+
};
|
|
194
|
+
const edits = [mine, theirs].flatMap((text, side) => changes[side].map(({before: [from, to], after}) =>
|
|
195
|
+
[from, to, text.slice(...after), side, from && (word(from - 1)?.[0] ?? from), word(to)?.[1] ?? to]));
|
|
196
|
+
edits.sort((a, b) => a[0] - b[0] || a[3] - b[3] || a[1] - b[1]);
|
|
197
|
+
const conflicts = [];
|
|
198
|
+
let at = 0, text = '';
|
|
199
|
+
for (let index = 0; index < edits.length;) {
|
|
200
|
+
const start = edits[index][0], sides = [['', start], ['', start]];
|
|
201
|
+
let end = start, reach = start, changed = 0;
|
|
202
|
+
for (;;) {
|
|
203
|
+
const edit = edits[index++], side = edit[3], row = sides[side];
|
|
204
|
+
reach = Math.max(reach, edit[5]);
|
|
205
|
+
if (edit[1] >= end) {
|
|
206
|
+
if (edit[1] > end) sides[1 - side][2] = null;
|
|
207
|
+
end = edit[1]; row[2] = edit;
|
|
208
|
+
}
|
|
209
|
+
row[0] += base.slice(row[1], edit[0]) + edit[2]; changed |= 1 << side;
|
|
210
|
+
row[1] = edit[1];
|
|
211
|
+
if (index === edits.length) break;
|
|
212
|
+
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
|
+
if (!(a < end || a === start || from < reach || edits[index - 1][0] === a && edits[index - 1][3] === side2 || peer &&
|
|
215
|
+
(a === end && peer[0] === end || a === b && peer[0] < end &&
|
|
216
|
+
a - end < t.length && (peer[2] + base.slice(end, a)).endsWith(t)))) break;
|
|
217
|
+
}
|
|
218
|
+
for (const row of sides) row[0] += base.slice(row[1], end);
|
|
219
|
+
text += base.slice(at, start);
|
|
220
|
+
const from = text.length;
|
|
221
|
+
text += sides[changed & 1 ? 0 : 1][0];
|
|
222
|
+
if (changed === 3 && sides[0][0] !== sides[1][0]) conflicts.push({at: [from, text.length], theirs: sides[1][0]});
|
|
223
|
+
at = end;
|
|
224
|
+
}
|
|
225
|
+
return {text: text + base.slice(at), conflicts};
|
|
226
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
import {separate, phrases} from './concerns.mjs';
|
|
3
|
+
|
|
4
|
+
// Moves bind equal transported fragments at both ends; renames group a binding's sites.
|
|
5
|
+
export function moves(c) {
|
|
6
|
+
const {before, after, spend, safe, budget, raw, code, tokens, matches, walk, structures, pa, pb, la, aliases} = c;
|
|
7
|
+
const overlaps = (a, b) => a[0] < b[1] && a[1] > b[0];
|
|
8
|
+
const inside = (c, a, b) => [a, b].every((range, side) => c[side][0] === c[side][1] ||
|
|
9
|
+
c[side][0] >= range[0] && c[side][1] <= range[1]);
|
|
10
|
+
try { if (!raw && budget.left >= 0) {
|
|
11
|
+
const check = n => { if (!spend(n)) throw null; };
|
|
12
|
+
// Split transported text at wording boundaries; positions and words own distinct edits.
|
|
13
|
+
const deleted = new Map();
|
|
14
|
+
const moveKey = (item, side, text, parts = []) => {
|
|
15
|
+
const [from, to] = item[side];
|
|
16
|
+
let k = 0, hi = parts.length;
|
|
17
|
+
while (k < hi) { const mid = k + hi >>> 1; if (parts[mid].start < from) k = mid + 1; else hi = mid; }
|
|
18
|
+
for (; k < parts.length && parts[k].start <= to; k++) {
|
|
19
|
+
const p = parts[k]; check(1);
|
|
20
|
+
if (p.end <= to && p.name === item[3][2] && !/\S/.test(text.slice(from, p.start) + text.slice(p.end, to))) return item[3][1] + '\0' + item[3][2];
|
|
21
|
+
}
|
|
22
|
+
return text.slice(from, to).trim();
|
|
23
|
+
};
|
|
24
|
+
for (const item of c.result) if (item[3][0] === 0 && !item[2]) {
|
|
25
|
+
const value = before.slice(...item[0]), key = moveKey(item, 0, before, pa);
|
|
26
|
+
if (item[3][2] || value.trim().length >= 4 && /[\n ]/.test(value)) deleted.set(key, deleted.has(key) ? null : item);
|
|
27
|
+
}
|
|
28
|
+
for (const item of c.result) if (item[3][0] === 2 && !item[2]) {
|
|
29
|
+
const value = after.slice(...item[1]), key = moveKey(item, 1, after, pb);
|
|
30
|
+
let old = deleted.get(key), score = 0;
|
|
31
|
+
// Edited wording still travels: plain text, or a prose part of the same kind.
|
|
32
|
+
const text = value.trim(), prose = !structures || /^(paragraph|item|task|row|quote|section)$/.test(item[3][1]);
|
|
33
|
+
if (!old && prose && text.length >= 24) for (const candidate of deleted.values()) {
|
|
34
|
+
check(1);
|
|
35
|
+
if (!candidate || candidate[4] || candidate[3][1] !== item[3][1]) continue;
|
|
36
|
+
const prior = before.slice(...candidate[0]).trim();
|
|
37
|
+
const a = tokens(prior, 0, prior.length, 2), b = tokens(text, 0, text.length, 2);
|
|
38
|
+
if (!a || !b) throw null;
|
|
39
|
+
const n = matches(a[0], b[0]).reduce((n, j, i) => n + a[1][i + 1] - a[1][i], 0);
|
|
40
|
+
check(0);
|
|
41
|
+
if (n * 5 > Math.max(prior.length, text.length) * 3 && n >= score) { old = n === score ? null : candidate; score = n; }
|
|
42
|
+
}
|
|
43
|
+
if (!old || old[4]) continue;
|
|
44
|
+
// Separators that only one end carries stay outside the transported text.
|
|
45
|
+
const x = [...old[0]], y = [...item[1]];
|
|
46
|
+
for (const end of [0, 1]) {
|
|
47
|
+
const pattern = end ? /\s*$/ : /^\s*/, left = before.slice(...x).match(pattern)[0], right = after.slice(...y).match(pattern)[0];
|
|
48
|
+
let common = 0;
|
|
49
|
+
while (common < Math.min(left.length, right.length) && left.at(end ? common : -1 - common) === right.at(end ? common : -1 - common)) common++;
|
|
50
|
+
const dx = (left.length - common) * (end ? -1 : 1), dy = (right.length - common) * (end ? -1 : 1);
|
|
51
|
+
if (safe(before, x[end] + dx) && safe(after, y[end] + dy)) { x[end] += dx; y[end] += dy; }
|
|
52
|
+
}
|
|
53
|
+
const outer = c.result; c.result = [];
|
|
54
|
+
walk(...x, ...y, 1);
|
|
55
|
+
// A changed heading can occupy a whole line while its marker still anchors the move.
|
|
56
|
+
if (c.result.length && (c.result[0][0][0] === x[0] || c.result[0][1][0] === y[0] ||
|
|
57
|
+
c.result.at(-1)[0][1] === x[1] || c.result.at(-1)[1][1] === y[1])) {
|
|
58
|
+
c.result = []; walk(...x, ...y, 2);
|
|
59
|
+
}
|
|
60
|
+
separate(c, x[1], y[1]);
|
|
61
|
+
phrases(c);
|
|
62
|
+
const inner = c.result; c.result = outer;
|
|
63
|
+
// Equal boundary fragments make the exact extent recoverable without display context.
|
|
64
|
+
if (inner.length && (inner[0][0][0] === x[0] || inner[0][1][0] === y[0] ||
|
|
65
|
+
inner.at(-1)[0][1] === x[1] || inner.at(-1)[1][1] === y[1] || inner.some(c => c[2]))) continue;
|
|
66
|
+
const move = [3, item[3][2] ? item[3][1] : code ? 'line' : 'block', item[3][2], [x, y]];
|
|
67
|
+
old[4] = []; item[4] = [];
|
|
68
|
+
const fragment = (a0, a1, b0, b1, group) => {
|
|
69
|
+
if (a0 < a1) old[4].push([[a0, a1], [old[1][0], old[1][0]], false, group]);
|
|
70
|
+
if (b0 < b1) item[4].push([[item[0][0], item[0][0]], [b0, b1], false, group]);
|
|
71
|
+
};
|
|
72
|
+
// Unmatched outer separators travel with the move, so restoring it alone keeps blocks apart.
|
|
73
|
+
fragment(old[0][0], x[0], 0, 0, move); fragment(0, 0, item[1][0], y[0], move);
|
|
74
|
+
let a = x[0], b = y[0], wording;
|
|
75
|
+
for (const edit of inner) {
|
|
76
|
+
if (edit[3][3]) wording = edit[3];
|
|
77
|
+
else if (a !== edit[0][0] || b !== edit[1][0]) wording = [edit[3][0], edit[3][1]];
|
|
78
|
+
wording[2] = item[3][2]; wording[3] = move[3];
|
|
79
|
+
if (wording[0] !== edit[3][0]) wording[0] = 1;
|
|
80
|
+
fragment(a, edit[0][0], b, edit[1][0], move);
|
|
81
|
+
fragment(...edit[0], ...edit[1], wording);
|
|
82
|
+
a = edit[0][1]; b = edit[1][1];
|
|
83
|
+
}
|
|
84
|
+
fragment(a, x[1], b, y[1], move);
|
|
85
|
+
fragment(x[1], old[0][1], 0, 0, move);
|
|
86
|
+
fragment(0, 0, y[1], item[1][1], move);
|
|
87
|
+
}
|
|
88
|
+
c.result = c.result.flatMap(item => item[4] || [item]);
|
|
89
|
+
|
|
90
|
+
if (structures) {
|
|
91
|
+
check(la.length * Math.ceil(Math.log2(la.length + 1)) + c.lb.length + pa.length + pb.length);
|
|
92
|
+
const mapped = {}; let cursor = 0, delta = 0;
|
|
93
|
+
for (const link of [...la].sort((a, b) => a.at[0] - b.at[0])) {
|
|
94
|
+
while (cursor < c.result.length && c.result[cursor][0][1] <= link.at[0]) { delta = c.result[cursor][1][1] - c.result[cursor][0][1]; cursor++; }
|
|
95
|
+
if (cursor === c.result.length || c.result[cursor][0][0] >= link.at[1]) mapped[[link.at[0] + delta, link.at[1] + delta]] = link.to;
|
|
96
|
+
}
|
|
97
|
+
c.lb = c.lb.map(l => l.key != null ? l : {...l, to: mapped[l.at] || l.to});
|
|
98
|
+
for (const [p, old] of aliases) {
|
|
99
|
+
if (before.slice(...old.nameAt) === after.slice(...p.nameAt)) continue;
|
|
100
|
+
check(la.length + c.lb.length);
|
|
101
|
+
const left = la.filter(l => l.key === old.key), right = c.lb.filter(l => l.key === p.key);
|
|
102
|
+
if (left.length !== right.length) continue;
|
|
103
|
+
const x = [old.nameAt], y = [p.nameAt], owned = [];
|
|
104
|
+
for (let i = 0; i < left.length; i++) {
|
|
105
|
+
x.push(left[i].at); y.push(right[i].at);
|
|
106
|
+
if (left[i].labelAt && right[i].labelAt && before.slice(...left[i].labelAt) === before.slice(...old.nameAt) &&
|
|
107
|
+
after.slice(...right[i].labelAt) === after.slice(...p.nameAt)) { x.push(left[i].labelAt); y.push(right[i].labelAt); }
|
|
108
|
+
}
|
|
109
|
+
x.sort((a, b) => a[0] - b[0]); y.sort((a, b) => a[0] - b[0]);
|
|
110
|
+
let complete = true;
|
|
111
|
+
for (let i = 0; i < x.length; i++) {
|
|
112
|
+
check(c.result.length * 2);
|
|
113
|
+
if (c.result.some(c => c[3]?.[0] !== 3 && (overlaps(c[0], x[i]) || overlaps(c[1], y[i])) && !inside(c, x[i], y[i]))) { complete = false; break; }
|
|
114
|
+
const found = c.result.filter(c => c[3][0] < 3 && inside(c, x[i], y[i]));
|
|
115
|
+
if (!found.length) { complete = false; break; }
|
|
116
|
+
owned.push(...found);
|
|
117
|
+
}
|
|
118
|
+
if (!complete) continue;
|
|
119
|
+
check(c.result.length + owned.length * 2);
|
|
120
|
+
const owners = new Set(owned.map(c => c[3]).filter(Boolean)), members = new Set(owned);
|
|
121
|
+
if (c.result.some(c => owners.has(c[3]) && !members.has(c))) continue;
|
|
122
|
+
const group = [4, p.kind, old.name, [[...old.nameAt], [...p.nameAt]]];
|
|
123
|
+
for (const item of owned) item[3] = group;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
} } catch (error) {
|
|
127
|
+
if (error !== null) throw error;
|
|
128
|
+
c.result = c.result.flatMap(item => item[4] || [item]);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Capture only the reader contract before another observation can reuse its objects.
|
|
3
|
+
export function observe(text, reader, spend, safe, references = true, lenient = false) {
|
|
4
|
+
let value;
|
|
5
|
+
try { value = reader(text); } catch (error) { return {invalid: true, threw: true, error}; }
|
|
6
|
+
try {
|
|
7
|
+
const flat = [], links = [], meaning = references && value?.meaning;
|
|
8
|
+
const span = (at, from, end) => {
|
|
9
|
+
if (!Array.isArray(at) || at.length !== 2 || !at.every(Number.isSafeInteger) ||
|
|
10
|
+
at[0] < from || at[1] < at[0] || at[1] > end) throw null;
|
|
11
|
+
if (safe(text, at[0]) && safe(text, at[1])) return [at[0], at[1]];
|
|
12
|
+
if (!lenient) throw null;
|
|
13
|
+
};
|
|
14
|
+
const scalar = (value, key = false) => {
|
|
15
|
+
if (value != null && typeof value !== 'string' && !(key && (typeof value === 'boolean' || Number.isFinite(value)))) throw false;
|
|
16
|
+
return value;
|
|
17
|
+
};
|
|
18
|
+
let count = 0;
|
|
19
|
+
function copy(rows, from, end, depth, retained = true) {
|
|
20
|
+
if (!Array.isArray(rows) || rows.length > 131072 || depth > 128) throw null;
|
|
21
|
+
const parts = [];
|
|
22
|
+
let at = from;
|
|
23
|
+
for (const row of rows) {
|
|
24
|
+
if (!spend() || count++ >= 131072) throw null;
|
|
25
|
+
const {start, end: stop, kind, name, key, nameAt, children} = row;
|
|
26
|
+
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(stop) ||
|
|
27
|
+
start < at || stop < start || stop > end) throw null;
|
|
28
|
+
at = stop;
|
|
29
|
+
const usable = safe(text, start) && safe(text, stop);
|
|
30
|
+
if (!usable && !lenient) throw null;
|
|
31
|
+
const part = {start, end: stop, kind: scalar(kind), name: scalar(name), key: scalar(key, true)};
|
|
32
|
+
if (nameAt != null) part.nameAt = span(nameAt, start, stop);
|
|
33
|
+
// Discard unsafe evidence only after validating its complete subtree.
|
|
34
|
+
if (retained && usable) { parts.push(part); flat.push(part); }
|
|
35
|
+
part.children = children == null ? [] : copy(children, start, stop, depth + 1, retained && usable);
|
|
36
|
+
}
|
|
37
|
+
return parts;
|
|
38
|
+
}
|
|
39
|
+
const parts = copy(value?.parts, 0, text.length, 0), rows = references && value.links || [];
|
|
40
|
+
if (!Array.isArray(rows) || rows.length > 131072) throw null;
|
|
41
|
+
for (const row of rows) {
|
|
42
|
+
if (!spend()) throw null;
|
|
43
|
+
const {at, to, key, labelAt} = row;
|
|
44
|
+
if (typeof to !== 'string') throw null;
|
|
45
|
+
const place = span(at, 0, text.length), label = labelAt == null ? null : span(labelAt, 0, text.length), binding = scalar(key, true);
|
|
46
|
+
if (place) links.push({at: place, to, key: binding, ...(label && {labelAt: label})});
|
|
47
|
+
}
|
|
48
|
+
return {parts, flat, links, meaning: typeof meaning === 'string' ? meaning : undefined};
|
|
49
|
+
} catch (error) { return error === false ? {invalid: true} : null; }
|
|
50
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
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
|
+
export const SPACELESS = /[\u0e00-\u0eff\u1000-\u109f\u1780-\u17ff\u3040-\u30ff\u3400-\u9fff\uf900-\ufaff]/;
|
|
4
|
+
export const words = (segmenter, text) => [...text.matchAll(/[\p{L}\p{N}\p{M}_]+/gu)].flatMap(word => SPACELESS.test(word[0])
|
|
5
|
+
? [...segmenter().segment(word[0])].map(s => [word.index + s.index, word.index + s.index + s.segment.length])
|
|
6
|
+
: [[word.index, word.index + word[0].length]]);
|
|
7
|
+
export const wordSegmenter = () => { let segmenter; return () => segmenter ||= new Intl.Segmenter('en', {granularity: 'word'}); };
|
|
8
|
+
|
|
9
|
+
// Segment only requested non-ASCII boundaries; observations never share a global document cache.
|
|
10
|
+
export function boundaries() {
|
|
11
|
+
const segments = Object.create(null);
|
|
12
|
+
let segmenter, last = [];
|
|
13
|
+
return function safe(text, at) {
|
|
14
|
+
if (!at || at === text.length) return true;
|
|
15
|
+
const a = text.charCodeAt(at - 1), b = text.charCodeAt(at);
|
|
16
|
+
if ((a & 0xfc00) === 0xd800 && (b & 0xfc00) === 0xdc00 || a === 13 && b === 10) return false;
|
|
17
|
+
if (a < 0x300 && b < 0x300) return true;
|
|
18
|
+
// Retreats probe one long cluster many times; answer inside the last cluster without the segmenter.
|
|
19
|
+
if (last[0] === text && at > last[1] && at < last[2]) return false;
|
|
20
|
+
const found = (segments[text] ||= (segmenter ||= new Intl.Segmenter('en', {granularity: 'grapheme'})).segment(text)).containing(at);
|
|
21
|
+
last = [text, found.index, found.index + found.segment.length];
|
|
22
|
+
return found.index === at;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Letters are code points: an astral letter is one letter, never two lone surrogates.
|
|
27
|
+
const pair = (text, at) => (text.charCodeAt(at - 1) & 0xfc00) === 0xd800 && (text.charCodeAt(at) & 0xfc00) === 0xdc00;
|
|
28
|
+
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
|
+
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
|
+
|
|
31
|
+
// Tokens at a level: 0 parts, 1 lines, 2 words, spaces and marks, 3 characters.
|
|
32
|
+
export function tokenizer(c) {
|
|
33
|
+
const {spend, safe, segmenter, raw, aliases} = c;
|
|
34
|
+
return function tokens(text, start, end, level, parts) {
|
|
35
|
+
// Each source boundary is stored once, alongside comparison keys and optional owners.
|
|
36
|
+
const keys = [], at = [start], owners = [];
|
|
37
|
+
const out = [keys, at, owners];
|
|
38
|
+
const add = (a, b, part, value) => {
|
|
39
|
+
if (b <= a) return;
|
|
40
|
+
if (!part && owners[keys.length - 1]?.name && !/\S/.test(text.slice(a, b))) at[at.length - 1] = b;
|
|
41
|
+
else {
|
|
42
|
+
at.push(b); if (part) owners[keys.length] = part;
|
|
43
|
+
const key = part?.name ? '\0' + part.kind + '\0' + (aliases?.get(part)?.name || part.name) : value ?? text.slice(a, b);
|
|
44
|
+
keys.push(level === 1 && !raw ? key.trimEnd() : key);
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
if (parts?.length && level === 0) {
|
|
48
|
+
let at = start;
|
|
49
|
+
for (const part of parts) {
|
|
50
|
+
if (!spend()) return null;
|
|
51
|
+
if (part.start < at || part.end <= part.start || part.end > end) continue;
|
|
52
|
+
if (at < part.start) add(at, part.start);
|
|
53
|
+
add(part.start, part.end, part); at = part.end;
|
|
54
|
+
}
|
|
55
|
+
add(at, end); return out;
|
|
56
|
+
}
|
|
57
|
+
const pattern = level === 1 ? raw ? /[^\n]*\n|[^\n]+$/g : /[^\n]*\n(?:\r?\n)*|[^\n]+$/g : level === 2 ? /[\p{L}\p{M}\p{N}_]+|[^\S\r\n]+|\r\n|[^]/gu : /[^]/gu;
|
|
58
|
+
pattern.lastIndex = start;
|
|
59
|
+
let match;
|
|
60
|
+
while (pattern.lastIndex < end && (match = pattern.exec(text))) {
|
|
61
|
+
if (!spend()) return null;
|
|
62
|
+
const a = match.index, b = Math.min(end, pattern.lastIndex);
|
|
63
|
+
if (keys.length && !safe(text, a)) {
|
|
64
|
+
at[at.length - 1] = b; keys[keys.length - 1] = text.slice(at[at.length - 2], b);
|
|
65
|
+
} else if (level === 2 && b - a > 1 && SPACELESS.test(match[0])) {
|
|
66
|
+
for (const [c, d] of words(segmenter, text.slice(a, b))) { if (!spend()) return null; add(a + c, a + d); }
|
|
67
|
+
} else add(a, b, undefined, b === pattern.lastIndex ? match[0] : undefined);
|
|
68
|
+
if (b === end) break;
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
};
|
|
72
|
+
}
|