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.
Files changed (60) hide show
  1. package/LICENSE +8 -4
  2. package/README.md +50 -7
  3. package/dist/images/gif.mjs +49 -0
  4. package/dist/images/playback.mjs +103 -0
  5. package/dist/kit/conformance/03-wrap-around-silhouette.md +1 -1
  6. package/dist/kit/conformance/04-wrap-box.md +1 -1
  7. package/dist/kit/conformance/05-behind.md +1 -1
  8. package/dist/kit/conformance/06-front.md +1 -1
  9. package/dist/kit/conformance/07-rotate-raster.md +1 -1
  10. package/dist/kit/conformance/08-drawing-rotation.md +1 -1
  11. package/dist/kit/conformance/09-drawing-ring-interior.expected.json +18 -18
  12. package/dist/kit/conformance/09-drawing-ring-interior.md +1 -1
  13. package/dist/kit/conformance/10-both-sides.md +1 -1
  14. package/dist/kit/conformance/11-neighbour-skips-image.md +1 -1
  15. package/dist/kit/conformance/{12-heading-barrier.expected.json → 12-rule-barrier.expected.json} +1 -1
  16. package/dist/kit/conformance/{12-heading-barrier.md → 12-rule-barrier.md} +2 -2
  17. package/dist/kit/conformance/README.md +19 -28
  18. package/dist/kit/conformance/run.mjs +10 -8
  19. package/dist/kit/ledger/authored-origin.mjs +49 -0
  20. package/dist/kit/ledger/authorship.mjs +18 -9
  21. package/dist/kit/ledger/carried.mjs +10 -5
  22. package/dist/kit/ledger/data.mjs +7 -4
  23. package/dist/kit/ledger/format.mjs +37 -11
  24. package/dist/kit/ledger/hash.mjs +31 -15
  25. package/dist/kit/ledger/history.mjs +48 -10
  26. package/dist/kit/ledger/inverse-source.mjs +111 -49
  27. package/dist/kit/ledger/journal-records.mjs +24 -29
  28. package/dist/kit/ledger/merge.mjs +51 -15
  29. package/dist/kit/ledger/text-merge.mjs +75 -29
  30. package/dist/kit/ledger/transport.mjs +30 -45
  31. package/dist/kit/pdf-vectors.mjs +6 -2
  32. package/dist/kit/print-capitals.mjs +0 -1
  33. package/dist/kit/rapier-ledger.mjs +4 -2
  34. package/dist/kit/render-ink.mjs +1 -1
  35. package/dist/kit/render-markdown.mjs +155 -31
  36. package/dist/kit/render-print.mjs +173 -61
  37. package/dist/kit/render-sanitize.mjs +25 -15
  38. package/dist/kit/render-styles.mjs +3 -3
  39. package/dist/kit/render.mjs +37 -115
  40. package/dist/layout/model.mjs +10 -8
  41. package/dist/layout/paragraph-fit.mjs +1 -15
  42. package/dist/layout/typography.mjs +2 -1
  43. package/dist/packages/rapier-diff/concerns.mjs +163 -0
  44. package/dist/packages/rapier-diff/diff.mjs +68 -0
  45. package/dist/packages/rapier-diff/index.mjs +4 -701
  46. package/dist/packages/rapier-diff/matching.mjs +157 -0
  47. package/dist/packages/rapier-diff/merge.mjs +226 -0
  48. package/dist/packages/rapier-diff/moves.mjs +130 -0
  49. package/dist/packages/rapier-diff/structure.mjs +50 -0
  50. package/dist/packages/rapier-diff/tokens.mjs +72 -0
  51. package/dist/packages/rapier-diff/walk.mjs +219 -0
  52. package/dist/spec/document-fonts.mjs +1 -1
  53. package/dist/spec/document-settings.mjs +11 -164
  54. package/dist/spec/html-blocks.mjs +618 -79
  55. package/dist/spec/md-assets.mjs +29 -6
  56. package/dist/spec/md-layout.mjs +9 -1
  57. package/dist/spec/md-marks.mjs +25 -147
  58. package/dist/spec/md-source.mjs +69 -26
  59. package/package.json +1 -1
  60. package/style.css +7 -51
@@ -0,0 +1,49 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Immutable authored origins shared by causal transport and inverse projection.
3
+ import {_rapierTransformSplices, _rapierValidAuthored} from './journal-records.mjs';
4
+ import {sha256} from './hash.mjs';
5
+
6
+ // The frontier names only causal maxima. Linear history needs one predecessor,
7
+ // and concurrent predecessors remain separate until an act observes both.
8
+ function authoredFrontier(records, visit = null) {
9
+ const dependencies = new Map(), frontier = new Set();
10
+ for (const record of records) {
11
+ const id = record.transaction.id, basis = record.authored?.basis;
12
+ if (visit) visit(record, basis ?? [...frontier].sort());
13
+ if (!basis) { dependencies.set(id, [...frontier]); frontier.clear(); }
14
+ else {
15
+ const pending = [...basis], seen = new Set();
16
+ while (pending.length) {
17
+ const prior = pending.pop();
18
+ if (seen.has(prior)) continue;
19
+ if (!dependencies.has(prior)) throw new RangeError('canonical_origin_invalid');
20
+ seen.add(prior); frontier.delete(prior); pending.push(...dependencies.get(prior));
21
+ }
22
+ dependencies.set(id, basis);
23
+ }
24
+ frontier.add(id);
25
+ }
26
+ return [...frontier].sort();
27
+ }
28
+
29
+ export function authoredBasis(records) { return authoredFrontier(records); }
30
+
31
+ export function authoredPlacements(base, records) {
32
+ const origins = new Map(); let source = base;
33
+ authoredFrontier(records, (record, basis) => {
34
+ origins.set(record.transaction.id, record.authored ?? authoredPlacement(source, record.splices, basis));
35
+ source = _rapierTransformSplices(source, record.splices);
36
+ if (source === null) throw new RangeError('canonical_origin_invalid');
37
+ });
38
+ return origins;
39
+ }
40
+
41
+ // Immutable authored placement stays on its own act. Basis IDs refer only to this
42
+ // canonical history; no source snapshot or duplicate history is stored.
43
+ export function authoredPlacement(source, splices, basis) {
44
+ const origin = {basis: basis.slice().sort(), source: sha256(source), splices: splices.map(row => ({...row}))};
45
+ if (!_rapierValidAuthored(origin) || _rapierTransformSplices(source, splices) === null)
46
+ throw new RangeError('canonical_origin_invalid');
47
+ return origin;
48
+ }
49
+
@@ -1,6 +1,6 @@
1
1
  // SPDX-License-Identifier: MIT
2
2
  import {readLedger, exportLedger, textRoot, rootAfter} from './format.mjs';
3
- import {_rapierTransformSplices} from './journal-records.mjs';
3
+ import {_rapierTransformSplices, _rapierHistoryEffects} from './journal-records.mjs';
4
4
  import {sha256} from './hash.mjs';
5
5
  import {plainData, documentText, nonnegative, exactKeys, fault} from './data.mjs';
6
6
  function coalesce(runs) {
@@ -14,16 +14,25 @@ function coalesce(runs) {
14
14
  return out;
15
15
  }
16
16
  export function authorship(input) {
17
- const {ledger, text} = readLedger(input);
17
+ const {ledger, text} = readLedger(input), references = _rapierHistoryEffects(ledger.records).references, removed = new Map();
18
18
  let runs = ledger.start.text ? [[0, ledger.start.text.length, 'system', 'unattributed', 0]] : [];
19
- for (const record of ledger.records) for (const row of record.splices) {
20
- const cut = row.pos + row.removed.length, delta = row.inserted.length - row.removed.length, before = [], after = [];
21
- for (const run of runs) {
22
- if (run[0] < row.pos) before.push([run[0], Math.min(run[1], row.pos), ...run.slice(2)]);
23
- if (run[1] > cut) after.push([Math.max(run[0], cut) + delta, run[1] + delta, ...run.slice(2)]);
19
+ for (const record of ledger.records) {
20
+ // An Undo or Redo puts back text the act it references removed: that text keeps the runs it had.
21
+ const tx = record.transaction, gone = [], kept = (references?.get(tx.id) || []).flatMap(id => removed.get(id) || []);
22
+ for (const row of record.splices) {
23
+ const cut = row.pos + row.removed.length, delta = row.inserted.length - row.removed.length, before = [], after = [], inside = [];
24
+ for (const run of runs) {
25
+ if (run[0] < row.pos) before.push([run[0], Math.min(run[1], row.pos), ...run.slice(2)]);
26
+ if (run[1] > cut) after.push([Math.max(run[0], cut) + delta, run[1] + delta, ...run.slice(2)]);
27
+ if (run[0] < cut && run[1] > row.pos) inside.push([Math.max(run[0], row.pos) - row.pos, Math.min(run[1], cut) - row.pos, ...run.slice(2)]);
28
+ }
29
+ if (row.removed) gone.push({text: row.removed, runs: inside});
30
+ const at = row.inserted ? kept.findIndex(entry => entry.text === row.inserted) : -1;
31
+ const put = at < 0 ? [[row.pos, row.pos + row.inserted.length, tx.actor.kind, tx.actor.id, tx.createdAt]]
32
+ : kept.splice(at, 1)[0].runs.map(run => [run[0] + row.pos, run[1] + row.pos, ...run.slice(2)]);
33
+ runs = coalesce([...before, ...put, ...after]);
24
34
  }
25
- const tx = record.transaction;
26
- runs = coalesce([...before, [row.pos, row.pos + row.inserted.length, tx.actor.kind, tx.actor.id, tx.createdAt], ...after]);
35
+ removed.set(tx.id, gone);
27
36
  }
28
37
  return {format: 'rapier-authorship/1', sha256: sha256(text), runs};
29
38
  }
@@ -73,7 +73,7 @@ export function readDocument(source) {
73
73
  export function readBase(value) {
74
74
  if (!value || typeof value !== 'object') throw fault('comparison base SHA-256 differs from its text');
75
75
  // Capture each caller-owned field once; admission and serialization use these same scalars.
76
- const {text, sha256: digest, revision, name, by, at} = value;
76
+ const {text, sha256: digest, revision, name, by, at, fonts} = value;
77
77
  if (typeof text !== 'string' || typeof digest !== 'string' || !/^[a-f0-9]{64}$/.test(digest) || sha256(text) !== digest)
78
78
  throw fault('comparison base SHA-256 differs from its text');
79
79
  if (typeof revision !== 'string' || !revision || revision.length > 256 || /[\u0000-\u001f\u007f]/.test(revision))
@@ -84,7 +84,8 @@ export function readBase(value) {
84
84
  throw fault('a comparison needs its author name');
85
85
  if (typeof at !== 'string' || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/.test(at) || !Number.isFinite(Date.parse(at)))
86
86
  throw fault('the time of a comparison is an ISO 8601 UTC timestamp');
87
- return {text, sha256: digest, revision, name, by, at};
87
+ if (fonts != null && typeof fonts !== 'string') throw fault('invalid comparison font resources');
88
+ return {text, sha256: digest, revision, name, by, at, ...(fonts ? {fonts} : {})};
88
89
  }
89
90
  const escapeAttribute = value => value.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
90
91
  const readAttribute = value => value.replace(/&(quot|lt|gt|amp);/g, (_, name) => ({quot: '"', lt: '<', gt: '>', amp: '&'})[name]);
@@ -93,7 +94,8 @@ export function writeBase(text, value) {
93
94
  const base = readBase(value);
94
95
  if (base.text === text) return '';
95
96
  return '<script type="text/markdown" id="rapier-base"' + ['name', 'sha256', 'revision', 'by', 'at']
96
- .map(key => ' data-' + key + '="' + escapeAttribute(base[key]) + '"').join('') + '>' + encodeCarried(base.text) + '</script>';
97
+ .map(key => ' data-' + key + '="' + escapeAttribute(base[key]) + '"').join('') +
98
+ (base.fonts ? ' data-fonts="' + escapeAttribute(JSON.stringify(base.fonts)) + '"' : '') + '>' + encodeCarried(base.text) + '</script>';
97
99
  }
98
100
  export function readBaseElements(elements) {
99
101
  const rows = [...elements].filter(element => element.id === 'rapier-base');
@@ -102,7 +104,10 @@ export function readBaseElements(elements) {
102
104
  if (rows.length !== 1 || row.type !== 'text/markdown' || (row.tagName && row.tagName.toLowerCase() !== 'script'))
103
105
  throw fault('duplicate or executable comparison base');
104
106
  const get = name => row.getAttribute ? row.getAttribute('data-' + name) : row[name];
105
- return readBase({text: decodeCarried(row.textContent), ...Object.fromEntries(['name', 'sha256', 'revision', 'by', 'at'].map(key => [key, get(key)]))});
107
+ let fonts;
108
+ try { const value = get('fonts'); if (value != null) fonts = JSON.parse(value); }
109
+ catch (_) { throw fault('invalid comparison font resources'); }
110
+ return readBase({text: decodeCarried(row.textContent), fonts, ...Object.fromEntries(['name', 'sha256', 'revision', 'by', 'at'].map(key => [key, get(key)]))});
106
111
  }
107
112
  export function takeBase(html) {
108
113
  const elements = [];
@@ -115,7 +120,7 @@ export function takeBase(html) {
115
120
  seen.add(key); values[key] = value;
116
121
  }
117
122
  elements.push({id: values.id, type: values.type, textContent: content,
118
- ...Object.fromEntries(['name', 'sha256', 'revision', 'by', 'at'].map(key => [key, values['data-' + key]]))});
123
+ ...Object.fromEntries(['name', 'sha256', 'revision', 'by', 'at', 'fonts'].map(key => [key, values['data-' + key]]))});
119
124
  return '';
120
125
  });
121
126
  return {html: stripped, base: readBaseElements(elements)};
@@ -1,10 +1,13 @@
1
1
  // SPDX-License-Identifier: MIT
2
2
  // Only plain JSON data enters the public format. Accessors and toJSON are never executed.
3
3
  export function fault(message) { return Object.assign(new Error('Ledger: ' + message), {code: 'ledger_invalid'}); }
4
- function fields(value, depth, active, sorted = true) {
4
+ // A plain object's prototype is null or Object's own. Both lookups read descriptors, so no getter runs.
5
+ export const plainPrototype = proto => proto === null || Object.getPrototypeOf(proto) === null &&
6
+ Object.getOwnPropertyDescriptor(Object.getOwnPropertyDescriptor(proto, 'constructor')?.value || {}, 'name')?.value === 'Object';
7
+ export function fields(value, depth, active, sorted = true) {
5
8
  if (!value || typeof value !== 'object' || depth > 64 || active.has(value)) throw fault('not plain JSON data');
6
- const array = Array.isArray(value), proto = Object.getPrototypeOf(value);
7
- if (!array && proto !== null && (Object.getPrototypeOf(proto) !== null || Object.getOwnPropertyDescriptor(proto, 'constructor')?.value?.name !== 'Object')) throw fault('not a plain object');
9
+ const array = Array.isArray(value);
10
+ if (!array && !plainPrototype(Object.getPrototypeOf(value))) throw fault('not a plain object');
8
11
  const descriptors = Object.getOwnPropertyDescriptors(value), keys = Reflect.ownKeys(descriptors);
9
12
  for (const key of keys) {
10
13
  if (array && key === 'length') continue;
@@ -14,7 +17,7 @@ function fields(value, depth, active, sorted = true) {
14
17
  if (array && (keys.length !== value.length + 1 || keys.some(k => k !== 'length' && (!/^(0|[1-9]\d*)$/.test(k) || +k >= value.length)))) throw fault('sparse array');
15
18
  return {array, descriptors, keys: array ? keys.filter(key => key !== 'length') : sorted ? keys.sort() : keys};
16
19
  }
17
- function scalar(value) {
20
+ export function scalar(value) {
18
21
  if (value === null || typeof value === 'boolean') return true;
19
22
  if (typeof value === 'string') { if (/[\uD800-\uDFFF]/u.test(value)) throw fault('unpaired surrogate'); return true; }
20
23
  return typeof value === 'number' && Number.isFinite(value) && !Object.is(value, -0);
@@ -27,12 +27,34 @@ function* checkedRecords(ledger, prepared) {
27
27
  }
28
28
  if (ledger.head.revision !== revision || ledger.head.root !== root) throw fault('history does not describe this document');
29
29
  }
30
+ // A ledger object read again is proved once: a boot reads one stored history through several records. The proof holds only
31
+ // while that object still carries the exact proved data (its start text compared whole, the rest as canonical JSON), so a
32
+ // changed byte is proved again, and refused. The proof lives as long as the object does.
33
+ const proofs = new WeakMap();
30
34
  function readLedgerState(input, expectedText, expectedMetadata) {
31
35
  const ledger = plainData(input);
36
+ // The identifier rapier-ledger/2 is unchanged and is not backward compatible. This reader
37
+ // admits an empty physical splice whose authored rows name the same replacement. A reader
38
+ // from before that rule rejects the chain. Mixed-version sync throws notes_history_unavailable
39
+ // and leaves the older store unchanged; that refusal is not a held or caughtUp result.
32
40
  if (!exactKeys(ledger, ['format','documentAuthority','start','records','head','complete','sha256']) || ledger.format !== 'rapier-ledger/2' ||
33
41
  typeof ledger.documentAuthority !== 'string' || !ledger.documentAuthority || ledger.documentAuthority.length > 256 ||
34
42
  typeof ledger.complete !== 'boolean' || !Array.isArray(ledger.records) ||
35
43
  !exactKeys(ledger.start, ['text','revision','root','sha256','metadata']) || !exactKeys(ledger.head, ['revision','root','sha256','metadata'])) throw fault('unsupported or invalid envelope');
44
+ const shell = canonicalJSON({...ledger, start: {...ledger.start, text: ''}});
45
+ let proof = typeof input === 'object' ? proofs.get(input) : undefined;
46
+ if (!proof || proof.shell !== shell || proof.startText !== ledger.start.text) {
47
+ proof = {shell, startText: ledger.start.text, ...proveLedger(ledger)};
48
+ if (typeof input === 'object') proofs.set(input, proof);
49
+ }
50
+ proofs.set(ledger, proof);
51
+ const {text, metadata, root, revision, prepared, source} = proof;
52
+ if (expectedMetadata !== undefined && (!_rapierValidMetadata(expectedMetadata) || canonicalJSON(metadata) !== canonicalJSON(expectedMetadata)))
53
+ throw fault('metadata does not describe this document');
54
+ if (expectedText !== undefined && text !== expectedText) throw fault('history does not describe this document');
55
+ return {value: {ledger, text, metadata: {...metadata}, root, revision, complete: ledger.complete}, prepared, source};
56
+ }
57
+ function proveLedger(ledger) {
36
58
  const {sha256: claimed, ...payload} = ledger;
37
59
  if (claimed !== digest(payload)) throw fault('envelope checksum differs');
38
60
  let text = documentText(ledger.start.text), root = ledger.start.root, revision = ledger.start.revision;
@@ -55,12 +77,18 @@ function readLedgerState(input, expectedText, expectedMetadata) {
55
77
  try { authoredHistory(ledger.start.text, ledger.records); } catch (_) { throw fault('authored origin differs'); }
56
78
  }
57
79
  const metadata = prepared.replayMetadata(ledger.start.metadata);
58
- if (!metadata || canonicalJSON(metadata) !== canonicalJSON(ledger.head.metadata) ||
59
- (expectedMetadata !== undefined && (!_rapierValidMetadata(expectedMetadata) || canonicalJSON(metadata) !== canonicalJSON(expectedMetadata))))
60
- throw fault('metadata does not describe this document');
61
- if (ledger.head.revision !== revision || ledger.head.root !== root || ledger.head.sha256 !== sha256(text) ||
62
- (expectedText !== undefined && text !== expectedText)) throw fault('history does not describe this document');
63
- return {value: {ledger, text, metadata, root, revision, complete: ledger.complete}, prepared, source};
80
+ if (!metadata || canonicalJSON(metadata) !== canonicalJSON(ledger.head.metadata)) throw fault('metadata does not describe this document');
81
+ if (ledger.head.revision !== revision || ledger.head.root !== root || ledger.head.sha256 !== sha256(text)) throw fault('history does not describe this document');
82
+ return {text, metadata, root, revision, prepared, source};
83
+ }
84
+ // A stored copy may hold the head text alone. Its start is that text with every record reversed; readLedger proves it.
85
+ export function ledgerWithStart(input, text) {
86
+ let source = new HistorySource(documentText(text));
87
+ for (let i = input.records.length - 1; i >= 0; i--) {
88
+ source = source.apply(input.records[i].splices, true);
89
+ if (source === null) throw fault('history does not reverse from this document');
90
+ }
91
+ return {...input, start: {...input.start, text: source.slice()}};
64
92
  }
65
93
  export function readLedger(input, expectedText, expectedMetadata) {
66
94
  return readLedgerState(input, expectedText, expectedMetadata).value;
@@ -163,9 +191,7 @@ function finish(producer) {
163
191
  return step.value;
164
192
  }
165
193
  export function exportLedger(input) { return finish(produceLedger(input)).ledger; }
166
- // Both synchronous public export and cooperative recovery use the same producer.
167
194
  // Its returned facts were computed while admitting/hashing the exact head, never supplied.
168
- export function prepareLedger(input) { return finish(produceLedger(input)); }
169
195
  export async function prepareLedgerAsync(input, {yield: yieldWork, signal} = {}) {
170
196
  const producer = produceLedger(input);
171
197
  let step;
@@ -182,8 +208,8 @@ export async function prepareLedgerAsync(input, {yield: yieldWork, signal} = {})
182
208
  return step.value;
183
209
  }
184
210
  // Derived navigation, never a second history. A target older than start is intentionally unavailable.
185
- export function historyEnvelope(input, segmentIdentity = []) {
186
- const {value: {ledger, text, metadata}, prepared, source} = readLedgerState(input);
211
+ export function historyEnvelope(input, segmentIdentity = [], expectedText, expectedMetadata) {
212
+ const {value: {ledger, text, metadata}, prepared, source} = readLedgerState(input, expectedText, expectedMetadata);
187
213
  const {byId, effects: {pictures}} = prepared;
188
214
  let branch = [], cursor = 0;
189
215
  for (const {transaction: tx} of ledger.records) {
@@ -198,7 +224,7 @@ export function historyEnvelope(input, segmentIdentity = []) {
198
224
  }
199
225
  const step = (source, id, redo) => {
200
226
  const target = byId.get(id), folded = pictures.size
201
- ? pictureHistorySplices({source: source.slice(), records: prepared.records}, target, redo) : null;
227
+ ? pictureHistorySplices({get source() { return source.slice(); }, records: prepared.records}, target, redo) : null;
202
228
  return source.apply(folded || target.splices, !folded && !redo);
203
229
  };
204
230
  let traversed = source, traversedMetadata = {...metadata};
@@ -30,13 +30,11 @@ const K = new Uint32Array([
30
30
  0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2,
31
31
  ]);
32
32
  const rotate = (x, n) => (x >>> n) | (x << (32 - n));
33
- // A bounded encoder and one compression block. No full UTF-8 or padded-input copy.
34
- // Updates retain a trailing high surrogate so an arbitrary string boundary stays exact.
35
- export function createSHA256() {
33
+ // One bounded SHA-256 compression state for file bytes and encoded source text.
34
+ export function sha256State() {
36
35
  const h = new Uint32Array([0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19]);
37
- const w = new Uint32Array(64), block = new Uint8Array(64), encoded = new Uint8Array(65536);
38
- const encoder = new TextEncoder();
39
- let used = 0, bytes = 0, high = '', finished = false;
36
+ const w = new Uint32Array(64), block = new Uint8Array(64);
37
+ let used = 0, bytes = 0, finished = false;
40
38
  const compress = (input, offset) => {
41
39
  for (let i = 0; i < 16; i++) {
42
40
  const at = offset + i * 4;
@@ -65,13 +63,37 @@ export function createSHA256() {
65
63
  for (; at + 64 <= size; at += 64) compress(input, at);
66
64
  if (at < size) { block.set(input.subarray(at, size), 0); used = size - at; }
67
65
  };
66
+ return {
67
+ update(input) {
68
+ if (finished || !(input instanceof Uint8Array)) throw new TypeError('SHA-256 needs bytes before finish');
69
+ if (!Number.isSafeInteger(bytes + input.length)) throw new Error('SHA-256 input length is not exact');
70
+ feed(input, input.length);
71
+ },
72
+ finish() {
73
+ if (finished) throw new Error('SHA-256 is already finished');
74
+ finished = true;
75
+ block[used++] = 0x80; block.fill(0, used);
76
+ if (used > 56) { compress(block, 0); block.fill(0); }
77
+ const view = new DataView(block.buffer);
78
+ view.setUint32(56, Math.floor(bytes / 0x20000000)); view.setUint32(60, (bytes * 8) >>> 0);
79
+ compress(block, 0);
80
+ return Array.from(h, x => x.toString(16).padStart(8, '0')).join('');
81
+ },
82
+ get bytes() { return bytes; },
83
+ };
84
+ }
85
+ // UTF-16 chunks share that byte state. Retain a trailing high surrogate so a
86
+ // caller can split a character between updates without changing its UTF-8 digest.
87
+ export function createSHA256() {
88
+ const hash = sha256State(), encoded = new Uint8Array(65536), encoder = new TextEncoder();
89
+ let high = '', finished = false;
68
90
  const write = text => {
69
91
  for (let at = 0; at < text.length;) {
70
92
  // Bound both the substring and byte allocation; encodeInto never cuts a scalar.
71
93
  let end = Math.min(text.length, at + 32768);
72
94
  if (end < text.length && (text.charCodeAt(end - 1) & 0xfc00) === 0xd800 && (text.charCodeAt(end) & 0xfc00) === 0xdc00) end--;
73
95
  const part = encoder.encodeInto(text.slice(at, end), encoded);
74
- feed(encoded, part.written); at += part.read;
96
+ hash.update(encoded.subarray(0, part.written)); at += part.read;
75
97
  }
76
98
  };
77
99
  return {
@@ -85,15 +107,9 @@ export function createSHA256() {
85
107
  if (finished) throw new Error('SHA-256 is finished');
86
108
  if (high) { write(high); high = ''; }
87
109
  finished = true;
88
- const bits = bytes * 8;
89
- block[used++] = 0x80; block.fill(0, used);
90
- if (used > 56) { compress(block, 0); block.fill(0); }
91
- const view = new DataView(block.buffer);
92
- view.setUint32(56, Math.floor(bits / 0x100000000)); view.setUint32(60, bits >>> 0);
93
- compress(block, 0);
94
- return Array.from(h, x => x.toString(16).padStart(8, '0')).join('');
110
+ return hash.finish();
95
111
  },
96
- get bytes() { return bytes; },
112
+ get bytes() { return hash.bytes; },
97
113
  };
98
114
  }
99
115
  export function sha256Parts(parts) {
@@ -1,10 +1,10 @@
1
1
  // SPDX-License-Identifier: MIT
2
2
  // Transient projections and selective inverses of the existing canonical journal.
3
- import {_rapierTransformSplices, _rapierRecordSplices, _rapierRecordMetadata, _rapierValidMetadata, _rapierTransformMetadata, _rapierMetadataDelta, _rapierHistoryEffects, _rapierMetadataState, _rapierReplayMetadata, _rapierPrepareHistoryRecords} from './journal-records.mjs';
3
+ import {_rapierTransformSplices, _rapierValidMetadata, _rapierTransformMetadata, _rapierMetadataDelta, _rapierHistoryEffects, _rapierMetadataState, _rapierPrepareHistoryRecords} from './journal-records.mjs';
4
4
  import {projectInverseSource, HistorySource} from './inverse-source.mjs';
5
5
  import {textRootParts, rootAfter} from './hash.mjs';
6
- import {authoredHistory} from './transport.mjs';
7
- import {plainData} from './data.mjs';
6
+ import {authoredHistory, authoredPlacements} from './transport.mjs';
7
+ import {plainData, canonicalJSON} from './data.mjs';
8
8
 
9
9
  const historyFailure = reason => ({ok: false, reason});
10
10
  const historyInteger = value => Number.isSafeInteger(value) && value >= 0 && !Object.is(value, -0);
@@ -43,11 +43,15 @@ export function groupHistoryActs(acts) {
43
43
 
44
44
  // A picture's codec replacement travels with its original navigation step.
45
45
  // Match every complete replacement exactly before moving any source bytes.
46
- export function pictureHistorySplices({source, records}, target, redo) {
46
+ export function pictureHistorySplices(input, target, redo) {
47
+ const {records} = input;
47
48
  const id = target?.transaction?.id, at = records.indexOf(target);
48
49
  if (!id || at < 0 || target.transaction.operation === 'document.finish-picture' || !Array.isArray(target.splices)) return null;
49
50
  const later = records.slice(at + 1).filter(row => row?.transaction?.operation === 'document.finish-picture' && Array.isArray(row.splices) && row.splices.length);
50
51
  if (!later.length) return null;
52
+ // A caller may hold a persistent source tree: request its text only when
53
+ // a later picture replacement can actually affect this navigation step.
54
+ const source = input.source;
51
55
  const own = new Set(later.filter(row => row.transaction.sourceTransactionId === id));
52
56
  const step = redo ? target.splices : target.splices.slice().reverse().map(row => ({pos: row.pos, removed: row.inserted, inserted: row.removed}));
53
57
  const plan = moveOthers => {
@@ -100,8 +104,11 @@ function prepareHistory(input) {
100
104
  !(tx.createdAt === null || historyInteger(tx.createdAt)) ||
101
105
  (tx.turnId != null && (typeof tx.turnId !== 'string' || !tx.turnId || tx.turnId.length > 160))) return historyFailure('history_invalid');
102
106
  if (tx.baseRevision !== prior) complete = false;
107
+ const coincidentWitness = !splices.length && record.authored && Array.isArray(record.authored.splices) &&
108
+ record.authored.splices.some(row => row.removed.length);
103
109
  if (!splices || effect === undefined || (!splices.length &&
104
- !Object.values(effect || {}).some(pair => pair.before !== pair.after) && !effects.references.get(tx.id)?.length)) return historyFailure('history_unavailable');
110
+ !Object.values(effect || {}).some(pair => pair.before !== pair.after) && !effects.references.get(tx.id)?.length &&
111
+ !coincidentWitness)) return historyFailure('history_unavailable');
105
112
  const row = {record, splices, metadata: effect};
106
113
  rows.push(row); byId.set(tx.id, row); prior = tx.revision;
107
114
  }
@@ -116,7 +123,9 @@ function prepareHistory(input) {
116
123
  row.beforeSource = initial; row.beforeMetadata = initialMetadata;
117
124
  }
118
125
  const earliestRevision = records[0]?.transaction.baseRevision ?? revision;
119
- let root = earliestRevision === 0 ? textRootParts(initial.parts()) : null, rootRevision = earliestRevision;
126
+ // The window's owner may name its root (the source store mints it once per text); otherwise hash the initial text.
127
+ let root = earliestRevision === 0 ? (typeof input.earliestRoot === 'string' && input.earliestRoot || textRootParts(initial.parts())) : null,
128
+ rootRevision = earliestRevision;
120
129
  for (const {record, splices} of rows) {
121
130
  const hashed = Object.hasOwn(record, 'beforeHash') || Object.hasOwn(record, 'afterHash');
122
131
  if (hashed && (![record.beforeHash, record.afterHash].every(value =>
@@ -281,10 +290,19 @@ export function historyPlaces(input, blocks) {
281
290
  }
282
291
  touched.set(node, after);
283
292
  });
293
+ // Blocks one splice covers share its range (a whole-document delete maps every block to all it removed): slice each range once.
294
+ const slices = new Map(), cut = (source, side, from, to) => {
295
+ const key = side + from + ':' + to;
296
+ if (!slices.has(key)) slices.set(key, source.slice(from, to));
297
+ return slices.get(key);
298
+ };
299
+ // One record's act is read once; the blocks it touched share its fields (a whole-document delete touches every block).
300
+ let act = null;
284
301
  for (const [node, afterRange] of touched) {
285
- const beforeRange = ranges.range(node), previous = {...beforeRange, source: beforeSource.slice(beforeRange.from, beforeRange.to)},
286
- after = {...afterRange, source: afterSource.slice(afterRange.from, afterRange.to)};
287
- if (previous.source !== after.source) node.block.acts.push({...historyAct(record), before: previous, after});
302
+ const beforeRange = ranges.range(node), previous = {...beforeRange, source: cut(beforeSource, 'b', beforeRange.from, beforeRange.to)},
303
+ after = {...afterRange, source: cut(afterSource, 'a', afterRange.from, afterRange.to)};
304
+ // A point (where words were deleted) has no words to compare: the act that touched it wrote there.
305
+ if (previous.source !== after.source || afterRange.from === afterRange.to) node.block.acts.push({...(act ||= historyAct(record)), before: previous, after});
288
306
  }
289
307
  }
290
308
 
@@ -322,7 +340,27 @@ export function selectiveUndo(input, actIds, options = {}) {
322
340
  const metadata = _rapierMetadataDelta(prepared.metadata, afterMetadata), effectChanged = selected.size > 0;
323
341
  const sourceEffectChanged = records.some(record => record.splices.length && !prepared.effects.references.get(record.transaction.id)?.length &&
324
342
  prepared.effects.active.has(record.transaction.id) !== projectedEffects.active.has(record.transaction.id));
325
- if (!sourceEffectChanged) return {ok: true, splices: [], after: prepared.source, actIds: [...requested],
343
+ // One shared replacement is visible while any of its authors is active. That
344
+ // includes an author stored with no second physical splice, and an inverse
345
+ // whose only effect is to make such an author active again.
346
+ let coverVisibilityChanged = false;
347
+ if (!sourceEffectChanged && selected.size) {
348
+ let origins = null;
349
+ try { origins = authoredPlacements(prepared.initialSource, records); } catch { origins = null; }
350
+ const groups = new Map();
351
+ if (origins) for (const record of records) {
352
+ const tx = record.transaction, origin = origins.get(tx.id);
353
+ if (!origin?.splices.some(row => row.removed.length) || tx.reverts || tx.reapplies ||
354
+ tx.sourceTransactionId || tx.sourceTransactionIds?.length) continue;
355
+ const key = canonicalJSON({basis: origin.basis, splices: origin.splices});
356
+ if (!groups.has(key)) groups.set(key, []);
357
+ groups.get(key).push(tx.id);
358
+ }
359
+ const covered = (effects, ids) => ids.some(id => effects.active.has(id));
360
+ for (const ids of groups.values()) if (ids.length > 1 && covered(prepared.effects, ids) !== covered(projectedEffects, ids))
361
+ coverVisibilityChanged = true;
362
+ }
363
+ if (!sourceEffectChanged && !coverVisibilityChanged) return {ok: true, splices: [], after: prepared.source, actIds: [...requested],
326
364
  sourceTransactionIds: [...selected], metadata, afterMetadata, effectChanged, unchanged: !metadata && !effectChanged,
327
365
  superseded: [], overlaps: []};
328
366
  try {