rapier-markdown-kit 1.1.102 → 1.2.3

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 (82) hide show
  1. package/LICENSE +5 -2
  2. package/README.md +245 -54
  3. package/dist/agent/vendor/hyphen-en-us/LICENSE +14 -0
  4. package/dist/agent/vendor/hyphen-en-us/SOURCE.json +33 -0
  5. package/dist/agent/vendor/hyphen-en-us/hyph-en-us.hyp.txt +14 -0
  6. package/dist/agent/vendor/hyphen-en-us/hyph-en-us.pat.txt +4938 -0
  7. package/dist/agent/vendor/hyphen-en-us/patterns.js +25 -0
  8. package/dist/agent/vendor/pretext/LICENSE +208 -0
  9. package/dist/agent/vendor/pretext/NOTICE +26 -13
  10. package/dist/agent/vendor/pretext/SOURCE.json +213 -45
  11. package/dist/agent/vendor/pretext/analysis.js +675 -1224
  12. package/dist/agent/vendor/pretext/entry-geometry.js +183 -0
  13. package/dist/agent/vendor/pretext/gecko-line-breaks.js +688 -0
  14. package/dist/agent/vendor/pretext/generated/engine-break-data.js +57 -0
  15. package/dist/agent/vendor/pretext/generated/webkit-generic-families.js +80 -0
  16. package/dist/agent/vendor/pretext/graphemes.js +98 -0
  17. package/dist/agent/vendor/pretext/han-kerning.js +253 -0
  18. package/dist/agent/vendor/pretext/layout.js +352 -290
  19. package/dist/agent/vendor/pretext/line-break.js +1154 -588
  20. package/dist/agent/vendor/pretext/line-breaks.js +892 -0
  21. package/dist/agent/vendor/pretext/line-text.js +81 -39
  22. package/dist/agent/vendor/pretext/measurement.js +1105 -176
  23. package/dist/agent/vendor/pretext/prepare.js +807 -0
  24. package/dist/agent/vendor/pretext/rich-inline.js +1242 -273
  25. package/dist/agent/will.mjs +2 -0
  26. package/dist/images/gif.mjs +47 -0
  27. package/dist/images/playback.mjs +101 -0
  28. package/dist/kit/assets.mjs +5 -1
  29. package/dist/kit/conformance/03-wrap-around-silhouette.md +1 -1
  30. package/dist/kit/conformance/04-wrap-box.md +1 -1
  31. package/dist/kit/conformance/05-behind.md +1 -1
  32. package/dist/kit/conformance/06-front.md +1 -1
  33. package/dist/kit/conformance/07-rotate-raster.md +1 -1
  34. package/dist/kit/conformance/08-drawing-rotation.md +1 -1
  35. package/dist/kit/conformance/09-drawing-ring-interior.expected.json +18 -18
  36. package/dist/kit/conformance/09-drawing-ring-interior.md +1 -1
  37. package/dist/kit/conformance/10-both-sides.md +1 -1
  38. package/dist/kit/conformance/11-neighbour-skips-image.md +1 -1
  39. package/dist/kit/conformance/{12-heading-barrier.expected.json → 12-rule-barrier.expected.json} +1 -1
  40. package/dist/kit/conformance/{12-heading-barrier.md → 12-rule-barrier.md} +2 -2
  41. package/dist/kit/conformance/README.md +19 -28
  42. package/dist/kit/conformance/run.mjs +13 -11
  43. package/dist/kit/export-fonts.mjs +26 -11
  44. package/dist/kit/index.mjs +1 -0
  45. package/dist/kit/ledger/carried.mjs +17 -10
  46. package/dist/kit/ledger/data.mjs +73 -13
  47. package/dist/kit/ledger/format.mjs +176 -54
  48. package/dist/kit/ledger/hash.mjs +91 -18
  49. package/dist/kit/ledger/history.mjs +156 -116
  50. package/dist/kit/ledger/inverse-source.mjs +158 -19
  51. package/dist/kit/ledger/journal-records.mjs +292 -67
  52. package/dist/kit/ledger/merge.mjs +4 -3
  53. package/dist/kit/model.mjs +1 -1
  54. package/dist/kit/pdf-glyphs.mjs +4 -4
  55. package/dist/kit/pdf-vectors.mjs +4 -1
  56. package/dist/kit/rapier-ledger.mjs +2 -2
  57. package/dist/kit/render-ink.mjs +1 -1
  58. package/dist/kit/render-markdown.mjs +400 -148
  59. package/dist/kit/render-print.mjs +131 -55
  60. package/dist/kit/render-sanitize.mjs +30 -7
  61. package/dist/kit/render-work.mjs +99 -20
  62. package/dist/kit/render.mjs +133 -140
  63. package/dist/kit/typography.mjs +2 -0
  64. package/dist/layout/hyphenation.mjs +127 -0
  65. package/dist/layout/line-plan.mjs +88 -96
  66. package/dist/layout/model.mjs +71 -88
  67. package/dist/layout/paragraph-fit.mjs +191 -0
  68. package/dist/layout/preparation.mjs +373 -0
  69. package/dist/layout/prepared-paragraph.mjs +223 -0
  70. package/dist/layout/typography.mjs +345 -111
  71. package/dist/packages/rapier-diff/index.mjs +359 -224
  72. package/dist/spec/document-fonts.mjs +14 -5
  73. package/dist/spec/document-settings.mjs +11 -164
  74. package/dist/spec/html-blocks.mjs +618 -79
  75. package/dist/spec/md-assets.mjs +178 -36
  76. package/dist/spec/md-layout.mjs +13 -2
  77. package/dist/spec/md-marks.mjs +220 -127
  78. package/dist/spec/md-source.mjs +157 -0
  79. package/package.json +2 -1
  80. package/style.css +15 -50
  81. package/dist/agent/vendor/pretext/bidi.js +0 -151
  82. package/dist/agent/vendor/pretext/generated/bidi-data.js +0 -3831
@@ -71,18 +71,21 @@ export function readDocument(source) {
71
71
 
72
72
  // One portable baseline. A digest is integrity evidence, not authentication of the named author.
73
73
  export function readBase(value) {
74
- if (!value || typeof value !== 'object' || typeof value.text !== 'string' ||
75
- typeof value.sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(value.sha256) || sha256(value.text) !== value.sha256)
74
+ if (!value || typeof value !== 'object') throw fault('comparison base SHA-256 differs from its text');
75
+ // Capture each caller-owned field once; admission and serialization use these same scalars.
76
+ const {text, sha256: digest, revision, name, by, at, fonts} = value;
77
+ if (typeof text !== 'string' || typeof digest !== 'string' || !/^[a-f0-9]{64}$/.test(digest) || sha256(text) !== digest)
76
78
  throw fault('comparison base SHA-256 differs from its text');
77
- if (typeof value.revision !== 'string' || !value.revision || value.revision.length > 256 || /[\u0000-\u001f\u007f]/.test(value.revision))
79
+ if (typeof revision !== 'string' || !revision || revision.length > 256 || /[\u0000-\u001f\u007f]/.test(revision))
78
80
  throw fault('invalid comparison base revision');
79
- if (typeof value.name !== 'string' || !value.name || [...value.name].length > 256 || /[\\/\u0000-\u001f\u007f]/.test(value.name))
81
+ if (typeof name !== 'string' || !name || [...name].length > 256 || /[\\/\u0000-\u001f\u007f]/.test(name))
80
82
  throw fault('invalid comparison base name');
81
- if (typeof value.by !== 'string' || !value.by.trim() || value.by.length > 96 || /[\u0000-\u001f\u007f]/.test(value.by))
83
+ if (typeof by !== 'string' || !by.trim() || by.length > 96 || /[\u0000-\u001f\u007f]/.test(by))
82
84
  throw fault('a comparison needs its author name');
83
- if (typeof value.at !== 'string' || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/.test(value.at) || !Number.isFinite(Date.parse(value.at)))
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)))
84
86
  throw fault('the time of a comparison is an ISO 8601 UTC timestamp');
85
- return {text: value.text, sha256: value.sha256, revision: value.revision, name: value.name, by: value.by, at: value.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} : {})};
86
89
  }
87
90
  const escapeAttribute = value => value.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
88
91
  const readAttribute = value => value.replace(/&(quot|lt|gt|amp);/g, (_, name) => ({quot: '"', lt: '<', gt: '>', amp: '&'})[name]);
@@ -91,7 +94,8 @@ export function writeBase(text, value) {
91
94
  const base = readBase(value);
92
95
  if (base.text === text) return '';
93
96
  return '<script type="text/markdown" id="rapier-base"' + ['name', 'sha256', 'revision', 'by', 'at']
94
- .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>';
95
99
  }
96
100
  export function readBaseElements(elements) {
97
101
  const rows = [...elements].filter(element => element.id === 'rapier-base');
@@ -100,7 +104,10 @@ export function readBaseElements(elements) {
100
104
  if (rows.length !== 1 || row.type !== 'text/markdown' || (row.tagName && row.tagName.toLowerCase() !== 'script'))
101
105
  throw fault('duplicate or executable comparison base');
102
106
  const get = name => row.getAttribute ? row.getAttribute('data-' + name) : row[name];
103
- 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)]))});
104
111
  }
105
112
  export function takeBase(html) {
106
113
  const elements = [];
@@ -113,7 +120,7 @@ export function takeBase(html) {
113
120
  seen.add(key); values[key] = value;
114
121
  }
115
122
  elements.push({id: values.id, type: values.type, textContent: content,
116
- ...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]]))});
117
124
  return '';
118
125
  });
119
126
  return {html: stripped, base: readBaseElements(elements)};
@@ -1,10 +1,7 @@
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
- export function canonicalJSON(value, depth = 0, active = new Set()) {
5
- if (value === null || typeof value === 'boolean') return JSON.stringify(value);
6
- if (typeof value === 'string') { if (/[\uD800-\uDFFF]/u.test(value)) throw fault('unpaired surrogate'); return JSON.stringify(value); }
7
- if (typeof value === 'number' && Number.isFinite(value) && !Object.is(value, -0)) return JSON.stringify(value);
4
+ function fields(value, depth, active, sorted = true) {
8
5
  if (!value || typeof value !== 'object' || depth > 64 || active.has(value)) throw fault('not plain JSON data');
9
6
  const array = Array.isArray(value), proto = Object.getPrototypeOf(value);
10
7
  if (!array && proto !== null && (Object.getPrototypeOf(proto) !== null || Object.getOwnPropertyDescriptor(proto, 'constructor')?.value?.name !== 'Object')) throw fault('not a plain object');
@@ -15,19 +12,82 @@ export function canonicalJSON(value, depth = 0, active = new Set()) {
15
12
  if (typeof key !== 'string' || ['__proto__', 'constructor', 'prototype'].includes(key) || !Object.hasOwn(field, 'value') || !field.enumerable) throw fault('non-data property');
16
13
  }
17
14
  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
+ return {array, descriptors, keys: array ? keys.filter(key => key !== 'length') : sorted ? keys.sort() : keys};
16
+ }
17
+ function scalar(value) {
18
+ if (value === null || typeof value === 'boolean') return true;
19
+ if (typeof value === 'string') { if (/[\uD800-\uDFFF]/u.test(value)) throw fault('unpaired surrogate'); return true; }
20
+ return typeof value === 'number' && Number.isFinite(value) && !Object.is(value, -0);
21
+ }
22
+ // A canonical copy does not need a serialized document and a second parsed document.
23
+ // Sorted insertion preserves the previous plainData result, including numeric-key ordering.
24
+ export function plainData(value) {
25
+ if (typeof value === 'string') value = JSON.parse(value);
26
+ const active = new Set();
27
+ const copy = (value, depth) => {
28
+ if (scalar(value)) return value;
29
+ const {array, descriptors, keys} = fields(value, depth, active);
30
+ const out = array ? [] : {};
31
+ active.add(value);
32
+ for (const key of keys) out[key] = copy(descriptors[key].value, depth + 1);
33
+ active.delete(value); return out;
34
+ };
35
+ return copy(value, 0);
36
+ }
37
+ // Each yielded string is bounded, even when one value contains the whole document.
38
+ // JSON.stringify still owns escaping; scalar boundaries keep its spelling exact.
39
+ function* quoted(text, whole = true) {
40
+ yield '"';
41
+ for (let at = 0; at < text.length;) {
42
+ let end = Math.min(text.length, at + 16384);
43
+ if (end < text.length && (text.charCodeAt(end - 1) & 0xfc00) === 0xd800 && (text.charCodeAt(end) & 0xfc00) === 0xdc00) end--;
44
+ const part = text.slice(at, end);
45
+ if (whole && /[\uD800-\uDFFF]/u.test(part)) throw fault('unpaired surrogate');
46
+ yield JSON.stringify(part).slice(1, -1); at = end;
47
+ }
48
+ yield '"';
49
+ }
50
+ function* dataParts(value, depth, active, sorted) {
51
+ if (typeof value === 'string') { yield* quoted(value); return; }
52
+ if (value === null || typeof value === 'boolean' || typeof value === 'number' && Number.isFinite(value) && !Object.is(value, -0)) {
53
+ yield JSON.stringify(value); return;
54
+ }
55
+ const {array, descriptors, keys} = fields(value, depth, active, sorted);
18
56
  active.add(value);
19
- const encode = item => canonicalJSON(item, depth + 1, active);
20
- const out = array ? '[' + Array.from({length: value.length}, (_, i) => encode(descriptors[i].value)).join(',') + ']'
21
- : '{' + keys.sort().map(key => JSON.stringify(key) + ':' + encode(descriptors[key].value)).join(',') + '}';
22
- active.delete(value); return out;
57
+ yield array ? '[' : '{';
58
+ for (let i = 0; i < keys.length; i++) {
59
+ if (i) yield ',';
60
+ const key = keys[i];
61
+ if (!array) { yield* quoted(key, false); yield ':'; }
62
+ yield* dataParts(descriptors[key].value, depth + 1, active, sorted);
63
+ }
64
+ yield array ? ']' : '}';
65
+ active.delete(value);
23
66
  }
24
- export function plainData(value) { return JSON.parse(canonicalJSON(typeof value === 'string' ? JSON.parse(value) : value)); }
67
+ export function canonicalParts(value, depth = 0, active = new Set()) { return dataParts(value, depth, active, true); }
68
+ export function jsonParts(value) { return dataParts(value, 0, new Set(), false); }
69
+ export function canonicalJSON(value, depth = 0, active = new Set()) { return Array.from(canonicalParts(value, depth, active)).join(''); }
25
70
  export function exactKeys(value, keys) {
26
71
  return value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key));
27
72
  }
28
- export function documentText(text) {
29
- if (typeof text !== 'string' || /[\uD800-\uDFFF]/u.test(text)) throw fault('document is not whole-character text');
30
- if (new TextEncoder().encode(text).length > 64 * 1024 * 1024) throw fault('document exceeds the editor text limit');
31
- return text;
73
+ // TextEncoder's exact byte count, without allocating an encoded copy. Whole-character
74
+ // admission is performed in this same pass when the document owner requests it.
75
+ export function utf8Length(text, whole = false) {
76
+ let bytes = 0;
77
+ for (let at = 0; at < text.length; at++) {
78
+ const code = text.charCodeAt(at);
79
+ if (code < 0x80) bytes++;
80
+ else if (code < 0x800) bytes += 2;
81
+ else if ((code & 0xfc00) === 0xd800 && (text.charCodeAt(at + 1) & 0xfc00) === 0xdc00) { bytes += 4; at++; }
82
+ else { if (whole && code >= 0xd800 && code <= 0xdfff) throw fault('document is not whole-character text'); bytes += 3; }
83
+ }
84
+ return bytes;
85
+ }
86
+ export function documentBytes(text) {
87
+ if (typeof text !== 'string') throw fault('document is not whole-character text');
88
+ const bytes = utf8Length(text, true);
89
+ if (bytes > 64 * 1024 * 1024) throw fault('document exceeds the editor text limit');
90
+ return bytes;
32
91
  }
92
+ export function documentText(text) { documentBytes(text); return text; }
33
93
  export const nonnegative = value => Number.isSafeInteger(value) && value >= 0 && !Object.is(value, -0);
@@ -1,13 +1,33 @@
1
1
  // SPDX-License-Identifier: MIT
2
- import {_rapierValidLedgerRecord, _rapierRecordSplices, _rapierTransformSplices, _rapierRecordMetadata, _rapierValidMetadata, _rapierTransformMetadata, _rapierReplayMetadata, _rapierHistoryEffects} from './journal-records.mjs';
2
+ import {_rapierPrepareHistoryRecords, _rapierValidMetadata, _rapierTransformMetadata} from './journal-records.mjs';
3
3
  import {pictureHistorySplices} from './history.mjs';
4
+ import {HistorySource} from './inverse-source.mjs';
4
5
  import {authoredHistory} from './transport.mjs';
5
- import {textRoot, rootAfter, sha256} from './hash.mjs';
6
- import {canonicalJSON, plainData, documentText, exactKeys, nonnegative, fault} from './data.mjs';
6
+ import {textRoot, rootAfter, sha256, sha256Parts, createSHA256} from './hash.mjs';
7
+ import {canonicalJSON, canonicalParts, plainData, documentText, utf8Length, exactKeys, nonnegative, fault} from './data.mjs';
7
8
  export {textRoot, rootAfter};
8
- const digest = value => sha256(canonicalJSON(value));
9
+ const digest = value => sha256Parts(canonicalParts(value));
9
10
  const rootShape = value => typeof value === 'string' && /^\d+:[a-z0-9]+:[a-z0-9]+$/.test(value);
10
- export function readLedger(input, expectedText, expectedMetadata) {
11
+ // Structural/history admission is shared by the reader and the producer. The producer
12
+ // proves source replay by reversing exact admitted splices; it need not replay its output.
13
+ function* checkedRecords(ledger, prepared) {
14
+ if (!prepared.ok || ledger.records.length !== prepared.records.length) throw fault('record chain differs');
15
+ let root = ledger.start.root, revision = ledger.start.revision;
16
+ for (let index = 0; index < prepared.records.length; index++) {
17
+ const record = ledger.records[index];
18
+ if (!Array.isArray(record.splices) || !prepared.validRecord(index,
19
+ ledger.documentAuthority, 64 * 1024 * 1024, utf8Length, !ledger.complete) ||
20
+ record.transaction.baseRevision < revision || (record.transaction.baseRevision !== revision && ledger.complete) ||
21
+ (record.transaction.baseRevision === revision && record.beforeHash !== root)) throw fault('record chain differs');
22
+ if (record.transaction.baseRevision !== revision) root = record.beforeHash;
23
+ for (const row of record.splices) root = rootAfter(root, row);
24
+ if (root !== record.afterHash) throw fault('record root differs');
25
+ revision = record.transaction.revision;
26
+ yield record;
27
+ }
28
+ if (ledger.head.revision !== revision || ledger.head.root !== root) throw fault('history does not describe this document');
29
+ }
30
+ function readLedgerState(input, expectedText, expectedMetadata) {
11
31
  const ledger = plainData(input);
12
32
  if (!exactKeys(ledger, ['format','documentAuthority','start','records','head','complete','sha256']) || ledger.format !== 'rapier-ledger/2' ||
13
33
  typeof ledger.documentAuthority !== 'string' || !ledger.documentAuthority || ledger.documentAuthority.length > 256 ||
@@ -19,102 +39,204 @@ export function readLedger(input, expectedText, expectedMetadata) {
19
39
  if (!_rapierValidMetadata(ledger.start.metadata) || !_rapierValidMetadata(ledger.head.metadata)) throw fault('invalid document metadata');
20
40
  if (!nonnegative(revision) || !rootShape(root) || (revision === 0 && root !== textRoot(text)) || ledger.start.sha256 !== sha256(text) ||
21
41
  (ledger.complete && revision !== 0)) throw fault('invalid start');
22
- const earlier = [];
23
- for (const record of ledger.records) {
24
- if (!Array.isArray(record.splices) || !_rapierValidLedgerRecord(record, earlier.at(-1) || null, earlier,
25
- ledger.documentAuthority, 64 * 1024 * 1024, text => new TextEncoder().encode(text).length, !ledger.complete) ||
26
- record.transaction.baseRevision < revision || (record.transaction.baseRevision !== revision && ledger.complete) ||
27
- (record.transaction.baseRevision === revision && record.beforeHash !== root)) throw fault('record chain differs');
28
- // A pruned cancelling pair leaves a stated revision gap, just as in the editor's restore reader.
29
- if (record.transaction.baseRevision !== revision) root = record.beforeHash;
30
- const next = _rapierTransformSplices(text, record.splices);
42
+ const prepared = _rapierPrepareHistoryRecords(ledger.records);
43
+ if (!prepared.ok) throw fault('record chain differs');
44
+ let source = new HistorySource(text), bytes = utf8Length(text);
45
+ for (const record of checkedRecords(ledger, prepared)) {
46
+ const next = source.apply(record.splices);
31
47
  if (next === null) throw fault('splice does not replay');
32
- text = documentText(next);
33
- for (const row of record.splices) root = rootAfter(root, row);
34
- if (root !== record.afterHash) throw fault('record root differs');
35
- revision = record.transaction.revision; earlier.push(record);
48
+ source = next;
49
+ for (const row of record.splices) bytes += utf8Length(row.inserted) - utf8Length(row.removed);
50
+ if (bytes > 64 * 1024 * 1024) throw fault('document exceeds the editor text limit');
51
+ root = record.afterHash; revision = record.transaction.revision;
36
52
  }
53
+ text = source.slice();
37
54
  if (ledger.records.some(row => Object.hasOwn(row, 'authored'))) {
38
55
  try { authoredHistory(ledger.start.text, ledger.records); } catch (_) { throw fault('authored origin differs'); }
39
56
  }
40
- const metadata = _rapierReplayMetadata(ledger.start.metadata, ledger.records);
57
+ const metadata = prepared.replayMetadata(ledger.start.metadata);
41
58
  if (!metadata || canonicalJSON(metadata) !== canonicalJSON(ledger.head.metadata) ||
42
59
  (expectedMetadata !== undefined && (!_rapierValidMetadata(expectedMetadata) || canonicalJSON(metadata) !== canonicalJSON(expectedMetadata))))
43
60
  throw fault('metadata does not describe this document');
44
61
  if (ledger.head.revision !== revision || ledger.head.root !== root || ledger.head.sha256 !== sha256(text) ||
45
62
  (expectedText !== undefined && text !== expectedText)) throw fault('history does not describe this document');
46
- return {ledger, text, metadata, root, revision, complete: ledger.complete};
63
+ return {value: {ledger, text, metadata, root, revision, complete: ledger.complete}, prepared, source};
64
+ }
65
+ // A stored copy may hold the head text alone. Its start is that text with every record reversed; readLedger proves it.
66
+ export function ledgerWithStart(input, text) {
67
+ let source = new HistorySource(documentText(text));
68
+ for (let i = input.records.length - 1; i >= 0; i--) {
69
+ source = source.apply(input.records[i].splices, true);
70
+ if (source === null) throw fault('history does not reverse from this document');
71
+ }
72
+ return {...input, start: {...input.start, text: source.slice()}};
47
73
  }
48
- export function exportLedger({text, metadata, records, documentAuthority, revision, root, complete = false, earlier = records} = {}) {
49
- documentText(text);
74
+ export function readLedger(input, expectedText, expectedMetadata) {
75
+ return readLedgerState(input, expectedText, expectedMetadata).value;
76
+ }
77
+ function* checksum(parts) {
78
+ const hash = createSHA256();
79
+ for (const part of parts) { hash.update(part); yield; }
80
+ return hash.digest();
81
+ }
82
+ function* sourceFacts(text) {
83
+ if (typeof text !== 'string') throw fault('document is not whole-character text');
84
+ const hash = createSHA256();
85
+ let fnv = 0x811c9dc5, a = 1, b = 0, bytes = 0;
86
+ for (let at = 0; at < text.length;) {
87
+ const start = at, end = Math.min(text.length, at + 65536);
88
+ for (; at < end;) {
89
+ const stop = Math.min(end, at + 4096);
90
+ for (; at < stop; at++) {
91
+ const code = text.charCodeAt(at);
92
+ fnv = Math.imul(fnv ^ code, 16777619); a += code; b += a;
93
+ if (code < 0x80) bytes++;
94
+ else if (code < 0x800) bytes += 2;
95
+ else if ((code & 0xfc00) === 0xd800) {
96
+ if ((text.charCodeAt(at + 1) & 0xfc00) !== 0xdc00) throw fault('document is not whole-character text');
97
+ bytes += 3;
98
+ } else if ((code & 0xfc00) === 0xdc00) {
99
+ if (!at || (text.charCodeAt(at - 1) & 0xfc00) !== 0xd800) throw fault('document is not whole-character text');
100
+ bytes++;
101
+ } else bytes += 3;
102
+ }
103
+ a %= 65521; b %= 65521;
104
+ }
105
+ if (bytes > 64 * 1024 * 1024) throw fault('document exceeds the editor text limit');
106
+ hash.update(text.slice(start, end)); yield;
107
+ }
108
+ return {utf8Bytes: bytes, sha256: hash.digest(), contentRoot: text.length + ':' +
109
+ (fnv >>> 0).toString(36) + ':' + (((b << 16) | a) >>> 0).toString(36)};
110
+ }
111
+ function* produceLedger({text, metadata, records, documentAuthority, revision, root, complete = false, earlier = records} = {}) {
50
112
  if (!_rapierValidMetadata(metadata)) throw fault('document metadata required');
113
+ // Metadata has two admitted scalar fields. Keep its insertion order in the
114
+ // emitted envelope while capturing it before the first cooperative boundary.
115
+ metadata = {...metadata};
51
116
  if (!Array.isArray(records) || !Array.isArray(earlier)) throw fault('records required');
52
117
  // Inspect before touching properties: exporting must not call accessors on an alleged record.
53
- records = plainData(records); earlier = plainData(earlier);
54
- const expanded = records.map(record => {
55
- const rows = _rapierRecordSplices(record, earlier), effect = _rapierRecordMetadata(record, earlier);
118
+ const prepared = _rapierPrepareHistoryRecords(records, earlier);
119
+ if (!prepared.ok) throw fault('navigation target was not retained');
120
+ const headFacts = yield* sourceFacts(text);
121
+ let bytes = headFacts.utf8Bytes;
122
+ const expanded = [];
123
+ for (const {record, splices: rows, metadata: effect} of prepared.rows) {
56
124
  if (!rows || effect === undefined) throw fault('navigation target was not retained');
57
- return {transaction: record.transaction, splices: rows.map(row => ({pos: row.pos, removed: row.removed, inserted: row.inserted})),
58
- beforeHash: record.beforeHash, afterHash: record.afterHash, ...(Object.hasOwn(record, 'authored') ? {authored: record.authored} : {}), ...(effect ? {metadata: effect} : {}),
59
- ...(record.changeSet && Object.hasOwn(record.changeSet, 'label') ? {changeSet: {label: record.changeSet.label}} : {}),
60
- ...(record.derivedCommentIndex == null ? {} : {derivedCommentIndex: record.derivedCommentIndex})};
61
- });
62
- let startText = text, startMetadata = {...metadata};
125
+ expanded.push({transaction: plainData(record.transaction), splices: rows.map(row => ({pos: row.pos, removed: row.removed, inserted: row.inserted})),
126
+ beforeHash: record.beforeHash, afterHash: record.afterHash, ...(Object.hasOwn(record, 'authored') ? {authored: plainData(record.authored)} : {}), ...(effect ? {metadata: plainData(effect)} : {}),
127
+ ...(record.changeSet && Object.hasOwn(record.changeSet, 'label') ? {changeSet: {label: typeof record.changeSet.label === 'string' ? record.changeSet.label : plainData(record.changeSet.label)}} : {}),
128
+ ...(record.derivedCommentIndex == null ? {} : {derivedCommentIndex: typeof record.derivedCommentIndex === 'string' ? record.derivedCommentIndex : plainData(record.derivedCommentIndex)})});
129
+ yield;
130
+ }
131
+ const headBytes = bytes;
132
+ let startSource = new HistorySource(text), startMetadata = {...metadata};
63
133
  for (let i = expanded.length - 1; i >= 0; i--) {
64
- startText = _rapierTransformSplices(startText, expanded[i].splices, true);
134
+ startSource = startSource.apply(expanded[i].splices, true);
65
135
  startMetadata = _rapierTransformMetadata(startMetadata, expanded[i].metadata, true);
66
- if (startText === null || !startMetadata) throw fault('history does not reverse from this document');
136
+ if (startSource === null || !startMetadata) throw fault('history does not reverse from this document');
137
+ // Whole scalars and exact splice boundaries were just checked. Count the changed
138
+ // bytes, rather than re-encoding every reconstructed whole-document revision.
139
+ for (const row of expanded[i].splices) bytes += utf8Length(row.removed) - utf8Length(row.inserted);
140
+ if (bytes > 64 * 1024 * 1024) throw fault('document exceeds the editor text limit');
141
+ yield;
67
142
  }
143
+ const startText = startSource.slice();
144
+ const unchangedText = expanded.every(row => !row.splices.length);
145
+ const contentRoot = headFacts.contentRoot;
68
146
  revision ??= expanded.at(-1)?.transaction.revision ?? 0;
69
- root ??= expanded.at(-1)?.afterHash ?? textRoot(text);
147
+ root ??= expanded.at(-1)?.afterHash ?? contentRoot;
70
148
  documentAuthority ??= expanded[0]?.transaction.documentAuthority;
71
- const startRevision = expanded[0]?.transaction.baseRevision ?? revision;
149
+ const startRevision = expanded[0]?.transaction.baseRevision ?? revision, startRoot = expanded[0]?.beforeHash ?? root;
150
+ if (typeof documentAuthority !== 'string' || !documentAuthority || documentAuthority.length > 256) throw fault('unsupported or invalid envelope');
151
+ if (!nonnegative(startRevision) || !rootShape(startRoot)) throw fault('invalid start');
152
+ const startFacts = unchangedText ? headFacts : yield* sourceFacts(startText);
153
+ if (startRevision === 0 && startRoot !== startFacts.contentRoot) throw fault('invalid start');
154
+ const headSHA = headFacts.sha256, startSHA = startFacts.sha256;
72
155
  const payload = {format: 'rapier-ledger/2', documentAuthority,
73
- start: {text: startText, revision: startRevision, root: expanded[0]?.beforeHash ?? root, sha256: sha256(startText), metadata: startMetadata},
74
- records: expanded, head: {revision, root, sha256: sha256(text), metadata: {...metadata}}, complete: complete === true && startRevision === 0 && expanded.every((row, i) => !i || row.transaction.baseRevision === expanded[i - 1].transaction.revision)};
75
- const ledger = {...payload, sha256: digest(payload)};
76
- readLedger(ledger, text, metadata); return ledger;
156
+ start: {text: startText, revision: startRevision, root: startRoot, sha256: startSHA, metadata: startMetadata},
157
+ records: expanded, head: {revision, root, sha256: headSHA, metadata: {...metadata}}, complete: complete === true && startRevision === 0 && expanded.every((row, i) => !i || row.transaction.baseRevision === expanded[i - 1].transaction.revision)};
158
+ // Expanded rows are the exact canonical projection of this owned preparation.
159
+ // Keep its navigation/effect proof instead of admitting another copied graph.
160
+ for (const _record of checkedRecords(payload, prepared)) yield;
161
+ if (expanded.some(row => Object.hasOwn(row, 'authored'))) {
162
+ try { authoredHistory(startText, expanded); } catch (_) { throw fault('authored origin differs'); }
163
+ }
164
+ const replayedMetadata = prepared.replayMetadata(startMetadata);
165
+ if (!replayedMetadata || canonicalJSON(replayedMetadata) !== canonicalJSON(metadata)) throw fault('metadata does not describe this document');
166
+ const ledger = {...payload, sha256: yield* checksum(canonicalParts(payload))};
167
+ return {ledger, contentRoot, utf8Bytes: headBytes};
168
+ }
169
+ function finish(producer) {
170
+ let step;
171
+ do { step = producer.next(); } while (!step.done);
172
+ return step.value;
173
+ }
174
+ export function exportLedger(input) { return finish(produceLedger(input)).ledger; }
175
+ // Both synchronous public export and cooperative recovery use the same producer.
176
+ // Its returned facts were computed while admitting/hashing the exact head, never supplied.
177
+ export function prepareLedger(input) { return finish(produceLedger(input)); }
178
+ export async function prepareLedgerAsync(input, {yield: yieldWork, signal} = {}) {
179
+ const producer = produceLedger(input);
180
+ let step;
181
+ do {
182
+ signal?.throwIfAborted();
183
+ step = producer.next();
184
+ if (!step.done && yieldWork) {
185
+ // The caller owns scheduling and its clock; ledger admission reads neither.
186
+ const pending = yieldWork();
187
+ if (pending) await pending;
188
+ }
189
+ } while (!step.done);
190
+ signal?.throwIfAborted();
191
+ return step.value;
77
192
  }
78
193
  // Derived navigation, never a second history. A target older than start is intentionally unavailable.
79
- export function historyEnvelope(input, segmentIdentity = []) {
80
- const {ledger, text, metadata} = readLedger(input), ids = new Set(ledger.records.map(row => row.transaction.id));
81
- const pictures = _rapierHistoryEffects(ledger.records).pictures;
194
+ export function historyEnvelope(input, segmentIdentity = [], expectedText, expectedMetadata) {
195
+ const {value: {ledger, text, metadata}, prepared, source} = readLedgerState(input, expectedText, expectedMetadata);
196
+ const {byId, effects: {pictures}} = prepared;
82
197
  let branch = [], cursor = 0;
83
198
  for (const {transaction: tx} of ledger.records) {
84
199
  if (pictures.has(tx.id)) continue;
85
200
  const target = tx.reverts || tx.reapplies;
86
201
  if (target) {
87
- if (!ids.has(target)) continue;
202
+ if (!byId.has(target)) continue;
88
203
  if (tx.reverts && branch[cursor - 1] === target) cursor--;
89
204
  else if (tx.reapplies && branch[cursor] === target) cursor++;
90
205
  else throw fault('invalid navigation order');
91
- } else { branch = branch.slice(0, cursor); branch.push(tx.id); cursor = branch.length; }
206
+ } else { branch.length = cursor; branch.push(tx.id); cursor = branch.length; }
92
207
  }
93
- const byId = new Map(ledger.records.map(row => [row.transaction.id, row]));
94
208
  const step = (source, id, redo) => {
95
- const target = byId.get(id), folded = pictureHistorySplices({source, records: ledger.records}, target, redo);
96
- return _rapierTransformSplices(source, folded || target.splices, !folded && !redo);
209
+ const target = byId.get(id), folded = pictures.size
210
+ ? pictureHistorySplices({get source() { return source.slice(); }, records: prepared.records}, target, redo) : null;
211
+ return source.apply(folded || target.splices, !folded && !redo);
97
212
  };
98
- let traversed = text, traversedMetadata = {...metadata};
213
+ let traversed = source, traversedMetadata = {...metadata};
99
214
  for (let i = cursor - 1; i >= 0; i--) {
100
215
  traversed = step(traversed, branch[i], false);
101
216
  traversedMetadata = _rapierTransformMetadata(traversedMetadata, byId.get(branch[i]).metadata, true);
102
217
  if (traversed === null || !traversedMetadata) throw fault('undo branch does not reverse');
103
218
  }
104
- traversed = text; traversedMetadata = {...metadata};
219
+ if (ledger.complete) {
220
+ if (ledger.records.length && !branch.length) throw fault('complete history lost its branch');
221
+ // Literal admitted splices are reversible. Proving the actual head back to
222
+ // the exact start also proves its forward active branch, without replaying it.
223
+ if (traversed.length !== ledger.start.text.length || !traversed.matches(0, ledger.start.text) ||
224
+ canonicalJSON(traversedMetadata) !== canonicalJSON(ledger.start.metadata)) throw fault('complete branch differs');
225
+ }
226
+ traversed = source; traversedMetadata = {...metadata};
105
227
  for (let i = cursor; i < branch.length; i++) {
106
228
  traversed = step(traversed, branch[i], true);
107
229
  traversedMetadata = _rapierTransformMetadata(traversedMetadata, byId.get(branch[i]).metadata);
108
230
  if (traversed === null || !traversedMetadata) throw fault('redo branch does not replay');
109
231
  }
110
- if (ledger.complete) {
111
- if (ledger.records.length && !branch.length) throw fault('complete history lost its branch');
112
- let active = ledger.start.text, activeMetadata = {...ledger.start.metadata};
113
- if (!cursor && (active !== text || canonicalJSON(activeMetadata) !== canonicalJSON(metadata))) throw fault('complete history start differs');
232
+ // Picture folding may relocate finished definitions while navigating. Keep its
233
+ // distinct forward proof; the literal-only case above needs no second traversal.
234
+ if (ledger.complete && pictures.size) {
235
+ let active = new HistorySource(ledger.start.text), activeMetadata = {...ledger.start.metadata};
114
236
  for (let i = 0; i < branch.length; i++) {
115
237
  active = step(active, branch[i], true);
116
238
  activeMetadata = _rapierTransformMetadata(activeMetadata, byId.get(branch[i]).metadata);
117
- if (active === null || !activeMetadata || (i + 1 === cursor && (active !== text || canonicalJSON(activeMetadata) !== canonicalJSON(metadata)))) throw fault('complete branch differs');
239
+ if (active === null || !activeMetadata || (i + 1 === cursor && (active.length !== text.length || !active.matches(0, text) || canonicalJSON(activeMetadata) !== canonicalJSON(metadata)))) throw fault('complete branch differs');
118
240
  }
119
241
  }
120
242
  return {schemaVersion: 5, documentAuthority: ledger.documentAuthority, documentRevision: ledger.head.revision,
@@ -1,12 +1,20 @@
1
1
  // SPDX-License-Identifier: MIT
2
2
  // The editor's path-dependent root, plus synchronous SHA-256 for file readers (including offline wrappers).
3
3
  export function textRoot(text) {
4
- let fnv = 0x811c9dc5, a = 1, b = 0;
5
- for (let i = 0; i < text.length; i++) {
6
- const c = text.charCodeAt(i);
7
- fnv = Math.imul(fnv ^ c, 16777619); a = (a + c) % 65521; b = (b + a) % 65521;
4
+ return textRootParts([text]);
5
+ }
6
+ // The same UTF-16 root over shared source pieces, without joining a historical body.
7
+ export function textRootParts(parts) {
8
+ let fnv = 0x811c9dc5, a = 1, b = 0, length = 0;
9
+ for (const text of parts) {
10
+ length += text.length;
11
+ for (let i = 0; i < text.length;) {
12
+ const end = Math.min(text.length, i + 4096);
13
+ for (; i < end; i++) { const c = text.charCodeAt(i); fnv = Math.imul(fnv ^ c, 16777619); a += c; b += a; }
14
+ a %= 65521; b %= 65521;
15
+ }
8
16
  }
9
- return text.length + ':' + (fnv >>> 0).toString(36) + ':' + (((b << 16) | a) >>> 0).toString(36);
17
+ return length + ':' + (fnv >>> 0).toString(36) + ':' + (((b << 16) | a) >>> 0).toString(36);
10
18
  }
11
19
  export function rootAfter(root, row) {
12
20
  return textRoot(String(root || '') + ':' + row.pos + ':' + textRoot(row.removed) + ':' + textRoot(row.inserted));
@@ -22,26 +30,91 @@ const K = new Uint32Array([
22
30
  0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2,
23
31
  ]);
24
32
  const rotate = (x, n) => (x >>> n) | (x << (32 - n));
25
- export function sha256(text) {
26
- const bytes = new TextEncoder().encode(text);
27
- const padded = new Uint8Array(Math.ceil((bytes.length + 9) / 64) * 64);
28
- padded.set(bytes); padded[bytes.length] = 0x80;
29
- const data = new DataView(padded.buffer), bits = bytes.length * 8;
30
- data.setUint32(padded.length - 8, Math.floor(bits / 0x100000000)); data.setUint32(padded.length - 4, bits >>> 0);
31
- const h = new Uint32Array([0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19]), w = new Uint32Array(64);
32
- for (let offset = 0; offset < padded.length; offset += 64) {
33
- for (let i = 0; i < 16; i++) w[i] = data.getUint32(offset + i * 4);
33
+ // One bounded SHA-256 compression state for file bytes and encoded source text.
34
+ export function sha256State() {
35
+ const h = new Uint32Array([0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19]);
36
+ const w = new Uint32Array(64), block = new Uint8Array(64);
37
+ let used = 0, bytes = 0, finished = false;
38
+ const compress = (input, offset) => {
39
+ for (let i = 0; i < 16; i++) {
40
+ const at = offset + i * 4;
41
+ w[i] = (input[at] << 24) | (input[at + 1] << 16) | (input[at + 2] << 8) | input[at + 3];
42
+ }
34
43
  for (let i = 16; i < 64; i++) {
35
44
  const x = w[i - 15], y = w[i - 2];
36
45
  w[i] = w[i - 16] + (rotate(x, 7) ^ rotate(x, 18) ^ (x >>> 3)) + w[i - 7] + (rotate(y, 17) ^ rotate(y, 19) ^ (y >>> 10));
37
46
  }
38
- let [a,b,c,d,e,f,g,k] = h;
47
+ let a=h[0], b=h[1], c=h[2], d=h[3], e=h[4], f=h[5], g=h[6], k=h[7];
39
48
  for (let i = 0; i < 64; i++) {
40
49
  const t1 = (k + (rotate(e, 6) ^ rotate(e, 11) ^ rotate(e, 25)) + ((e & f) ^ (~e & g)) + K[i] + w[i]) >>> 0;
41
50
  const t2 = ((rotate(a, 2) ^ rotate(a, 13) ^ rotate(a, 22)) + ((a & b) ^ (a & c) ^ (b & c))) >>> 0;
42
51
  k = g; g = f; f = e; e = (d + t1) >>> 0; d = c; c = b; b = a; a = (t1 + t2) >>> 0;
43
52
  }
44
- for (const [i, value] of [a,b,c,d,e,f,g,k].entries()) h[i] += value;
45
- }
46
- return [...h].map(x => x.toString(16).padStart(8, '0')).join('');
53
+ h[0]+=a; h[1]+=b; h[2]+=c; h[3]+=d; h[4]+=e; h[5]+=f; h[6]+=g; h[7]+=k;
54
+ };
55
+ const feed = (input, size) => {
56
+ bytes += size;
57
+ let at = 0;
58
+ if (used) {
59
+ const take = Math.min(64 - used, size);
60
+ block.set(input.subarray(0, take), used); used += take; at = take;
61
+ if (used === 64) { compress(block, 0); used = 0; }
62
+ }
63
+ for (; at + 64 <= size; at += 64) compress(input, at);
64
+ if (at < size) { block.set(input.subarray(at, size), 0); used = size - at; }
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;
90
+ const write = text => {
91
+ for (let at = 0; at < text.length;) {
92
+ // Bound both the substring and byte allocation; encodeInto never cuts a scalar.
93
+ let end = Math.min(text.length, at + 32768);
94
+ if (end < text.length && (text.charCodeAt(end - 1) & 0xfc00) === 0xd800 && (text.charCodeAt(end) & 0xfc00) === 0xdc00) end--;
95
+ const part = encoder.encodeInto(text.slice(at, end), encoded);
96
+ hash.update(encoded.subarray(0, part.written)); at += part.read;
97
+ }
98
+ };
99
+ return {
100
+ update(value) {
101
+ if (finished) throw new Error('SHA-256 is finished');
102
+ let text = high + String(value === undefined ? '' : value); high = '';
103
+ if (text.length && (text.charCodeAt(text.length - 1) & 0xfc00) === 0xd800) { high = text.slice(-1); text = text.slice(0, -1); }
104
+ write(text); return this;
105
+ },
106
+ digest() {
107
+ if (finished) throw new Error('SHA-256 is finished');
108
+ if (high) { write(high); high = ''; }
109
+ finished = true;
110
+ return hash.finish();
111
+ },
112
+ get bytes() { return hash.bytes; },
113
+ };
114
+ }
115
+ export function sha256Parts(parts) {
116
+ const hash = createSHA256();
117
+ for (const part of parts) hash.update(part);
118
+ return hash.digest();
47
119
  }
120
+ export function sha256(text) { return createSHA256().update(text).digest(); }