web-doc 0.6.2 → 0.7.0

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 (188) hide show
  1. package/THIRD_PARTY_NOTICES.md +26 -1
  2. package/dist/adapters/docx-images.d.ts +13 -4
  3. package/dist/adapters/docx-images.js +3 -0
  4. package/dist/adapters/docx-paragraphs.d.ts +65 -0
  5. package/dist/adapters/docx-paragraphs.js +165 -0
  6. package/dist/adapters/docx-prepass.d.ts +17 -0
  7. package/dist/adapters/docx-prepass.js +230 -0
  8. package/dist/adapters/office.d.ts +31 -5
  9. package/dist/adapters/office.js +60 -29
  10. package/dist/adapters/pdf.d.ts +9 -0
  11. package/dist/adapters/pdf.js +10 -0
  12. package/dist/assets/pdfium/pdfium.wasm +0 -0
  13. package/dist/contracts.d.ts +47 -2
  14. package/dist/edit/assets.d.ts +23 -0
  15. package/dist/edit/assets.js +75 -0
  16. package/dist/edit/docx/elements.d.ts +6 -0
  17. package/dist/edit/docx/elements.js +90 -0
  18. package/dist/edit/docx/engine.d.ts +51 -0
  19. package/dist/edit/docx/engine.js +473 -0
  20. package/dist/edit/docx/handlers.d.ts +3 -0
  21. package/dist/edit/docx/handlers.js +15 -0
  22. package/dist/edit/docx/ids.d.ts +45 -0
  23. package/dist/edit/docx/ids.js +101 -0
  24. package/dist/edit/docx/model.d.ts +92 -0
  25. package/dist/edit/docx/model.js +339 -0
  26. package/dist/edit/docx/operations.d.ts +37 -0
  27. package/dist/edit/docx/operations.js +3 -0
  28. package/dist/edit/docx/provider.d.ts +13 -0
  29. package/dist/edit/docx/provider.js +34 -0
  30. package/dist/edit/docx/schemas.d.ts +6 -0
  31. package/dist/edit/docx/schemas.js +192 -0
  32. package/dist/edit/docx/session.d.ts +37 -0
  33. package/dist/edit/docx/session.js +426 -0
  34. package/dist/edit/docx/structure-ops.d.ts +6 -0
  35. package/dist/edit/docx/structure-ops.js +494 -0
  36. package/dist/edit/docx/style.d.ts +45 -0
  37. package/dist/edit/docx/style.js +375 -0
  38. package/dist/edit/docx/table-ops.d.ts +4 -0
  39. package/dist/edit/docx/table-ops.js +219 -0
  40. package/dist/edit/docx/text-ops.d.ts +18 -0
  41. package/dist/edit/docx/text-ops.js +458 -0
  42. package/dist/edit/docx/text.d.ts +34 -0
  43. package/dist/edit/docx/text.js +263 -0
  44. package/dist/edit/docx/types.d.ts +191 -0
  45. package/dist/edit/docx/types.js +1 -0
  46. package/dist/edit/docx/write.d.ts +50 -0
  47. package/dist/edit/docx/write.js +352 -0
  48. package/dist/edit/engine.d.ts +117 -0
  49. package/dist/edit/engine.js +1 -0
  50. package/dist/edit/history.d.ts +53 -0
  51. package/dist/edit/history.js +101 -0
  52. package/dist/edit/ooxml/names.d.ts +20 -0
  53. package/dist/edit/ooxml/names.js +61 -0
  54. package/dist/edit/ooxml/opc.d.ts +50 -0
  55. package/dist/edit/ooxml/opc.js +150 -0
  56. package/dist/edit/ooxml/package.d.ts +82 -0
  57. package/dist/edit/ooxml/package.js +233 -0
  58. package/dist/edit/ooxml/patch.d.ts +51 -0
  59. package/dist/edit/ooxml/patch.js +250 -0
  60. package/dist/edit/ooxml/transaction.d.ts +39 -0
  61. package/dist/edit/ooxml/transaction.js +316 -0
  62. package/dist/edit/ooxml/worker.d.ts +8 -0
  63. package/dist/edit/ooxml/worker.js +12 -0
  64. package/dist/edit/ooxml/writer.d.ts +21 -0
  65. package/dist/edit/ooxml/writer.js +187 -0
  66. package/dist/edit/ooxml/xml.d.ts +74 -0
  67. package/dist/edit/ooxml/xml.js +451 -0
  68. package/dist/edit/ooxml/zip.d.ts +54 -0
  69. package/dist/edit/ooxml/zip.js +280 -0
  70. package/dist/edit/operations.d.ts +19 -0
  71. package/dist/edit/operations.js +137 -0
  72. package/dist/edit/pdf/engine/compact.d.ts +6 -0
  73. package/dist/edit/pdf/engine/compact.js +442 -0
  74. package/dist/edit/pdf/engine/document.d.ts +95 -0
  75. package/dist/edit/pdf/engine/document.js +868 -0
  76. package/dist/edit/pdf/engine/elements.d.ts +45 -0
  77. package/dist/edit/pdf/engine/elements.js +313 -0
  78. package/dist/edit/pdf/engine/existing-text.d.ts +4 -0
  79. package/dist/edit/pdf/engine/existing-text.js +424 -0
  80. package/dist/edit/pdf/engine/fonts.d.ts +78 -0
  81. package/dist/edit/pdf/engine/fonts.js +466 -0
  82. package/dist/edit/pdf/engine/geometry.d.ts +36 -0
  83. package/dist/edit/pdf/engine/geometry.js +97 -0
  84. package/dist/edit/pdf/engine/handler.d.ts +21 -0
  85. package/dist/edit/pdf/engine/handler.js +108 -0
  86. package/dist/edit/pdf/engine/images.d.ts +33 -0
  87. package/dist/edit/pdf/engine/images.js +188 -0
  88. package/dist/edit/pdf/engine/layout.d.ts +23 -0
  89. package/dist/edit/pdf/engine/layout.js +177 -0
  90. package/dist/edit/pdf/engine/operations.d.ts +67 -0
  91. package/dist/edit/pdf/engine/operations.js +3 -0
  92. package/dist/edit/pdf/engine/pages.d.ts +6 -0
  93. package/dist/edit/pdf/engine/pages.js +98 -0
  94. package/dist/edit/pdf/engine/pdfium.d.ts +195 -0
  95. package/dist/edit/pdf/engine/pdfium.js +249 -0
  96. package/dist/edit/pdf/engine/shapes.d.ts +4 -0
  97. package/dist/edit/pdf/engine/shapes.js +183 -0
  98. package/dist/edit/pdf/engine/tables.d.ts +44 -0
  99. package/dist/edit/pdf/engine/tables.js +315 -0
  100. package/dist/edit/pdf/engine/text-box.d.ts +71 -0
  101. package/dist/edit/pdf/engine/text-box.js +317 -0
  102. package/dist/edit/pdf/engine/text-layout.d.ts +28 -0
  103. package/dist/edit/pdf/engine/text-layout.js +67 -0
  104. package/dist/edit/pdf/engine/transform.d.ts +5 -0
  105. package/dist/edit/pdf/engine/transform.js +137 -0
  106. package/dist/edit/pdf/provider.d.ts +35 -0
  107. package/dist/edit/pdf/provider.js +92 -0
  108. package/dist/edit/pdf/range-map.d.ts +14 -0
  109. package/dist/edit/pdf/range-map.js +107 -0
  110. package/dist/edit/pdf/schemas.d.ts +11 -0
  111. package/dist/edit/pdf/schemas.js +291 -0
  112. package/dist/edit/pdf/selection.d.ts +5 -0
  113. package/dist/edit/pdf/selection.js +152 -0
  114. package/dist/edit/pdf/session.d.ts +53 -0
  115. package/dist/edit/pdf/session.js +273 -0
  116. package/dist/edit/pdf/types.d.ts +336 -0
  117. package/dist/edit/pdf/types.js +1 -0
  118. package/dist/edit/pptx/elements.d.ts +74 -0
  119. package/dist/edit/pptx/elements.js +301 -0
  120. package/dist/edit/pptx/engine.d.ts +37 -0
  121. package/dist/edit/pptx/engine.js +466 -0
  122. package/dist/edit/pptx/geometry.d.ts +72 -0
  123. package/dist/edit/pptx/geometry.js +193 -0
  124. package/dist/edit/pptx/handler.d.ts +7 -0
  125. package/dist/edit/pptx/handler.js +91 -0
  126. package/dist/edit/pptx/handlers.d.ts +3 -0
  127. package/dist/edit/pptx/handlers.js +21 -0
  128. package/dist/edit/pptx/image-table-ops.d.ts +5 -0
  129. package/dist/edit/pptx/image-table-ops.js +233 -0
  130. package/dist/edit/pptx/model.d.ts +69 -0
  131. package/dist/edit/pptx/model.js +170 -0
  132. package/dist/edit/pptx/operations.d.ts +50 -0
  133. package/dist/edit/pptx/operations.js +11 -0
  134. package/dist/edit/pptx/provider.d.ts +17 -0
  135. package/dist/edit/pptx/provider.js +40 -0
  136. package/dist/edit/pptx/schemas.d.ts +6 -0
  137. package/dist/edit/pptx/schemas.js +220 -0
  138. package/dist/edit/pptx/session.d.ts +44 -0
  139. package/dist/edit/pptx/session.js +114 -0
  140. package/dist/edit/pptx/shape-ops.d.ts +10 -0
  141. package/dist/edit/pptx/shape-ops.js +486 -0
  142. package/dist/edit/pptx/slide-ops.d.ts +6 -0
  143. package/dist/edit/pptx/slide-ops.js +353 -0
  144. package/dist/edit/pptx/style.d.ts +31 -0
  145. package/dist/edit/pptx/style.js +181 -0
  146. package/dist/edit/pptx/text-ops.d.ts +13 -0
  147. package/dist/edit/pptx/text-ops.js +267 -0
  148. package/dist/edit/pptx/text-write.d.ts +67 -0
  149. package/dist/edit/pptx/text-write.js +293 -0
  150. package/dist/edit/pptx/text.d.ts +41 -0
  151. package/dist/edit/pptx/text.js +99 -0
  152. package/dist/edit/pptx/types.d.ts +232 -0
  153. package/dist/edit/pptx/types.js +1 -0
  154. package/dist/edit/schema.d.ts +11 -0
  155. package/dist/edit/schema.js +275 -0
  156. package/dist/edit/session.d.ts +66 -0
  157. package/dist/edit/session.js +701 -0
  158. package/dist/edit/sessions.d.ts +8 -0
  159. package/dist/edit/sessions.js +1 -0
  160. package/dist/edit/types.d.ts +276 -0
  161. package/dist/edit/types.js +1 -0
  162. package/dist/edit/worker-engine.d.ts +31 -0
  163. package/dist/edit/worker-engine.js +91 -0
  164. package/dist/fonts/THIRD_PARTY_NOTICES.md +3 -0
  165. package/dist/fonts/manifest.json +6 -1
  166. package/dist/fonts/noto-sans-latin-cyrillic.ttf +0 -0
  167. package/dist/fonts.d.ts +2 -0
  168. package/dist/fonts.js +4 -0
  169. package/dist/headless.d.ts +4 -0
  170. package/dist/headless.js +4 -0
  171. package/dist/index.d.ts +5 -0
  172. package/dist/index.js +5 -0
  173. package/dist/limits.js +3 -0
  174. package/dist/ooxml-edit-worker.d.ts +1 -0
  175. package/dist/ooxml-edit-worker.js +8 -0
  176. package/dist/pdf-edit-worker.d.ts +1 -0
  177. package/dist/pdf-edit-worker.js +35 -0
  178. package/dist/spreadsheet-viewport.d.ts +5 -0
  179. package/dist/spreadsheet-viewport.js +11 -0
  180. package/dist/ui.js +7 -0
  181. package/dist/viewer.d.ts +6 -0
  182. package/dist/viewer.js +293 -19
  183. package/dist/viewport.d.ts +13 -0
  184. package/dist/viewport.js +126 -1
  185. package/dist/worker-protocol.d.ts +39 -2
  186. package/dist/workers/ooxml-edit-worker.js +8671 -0
  187. package/dist/workers/pdf-edit-worker.js +10844 -0
  188. package/package.json +3 -3
@@ -0,0 +1,701 @@
1
+ import { linkedAbortController } from "../abort.js";
2
+ import { abortError, ViewerError } from "../errors.js";
3
+ import { assetIdOf, AssetStore, binaryFields, isAssetReference, } from "./assets.js";
4
+ import { EditHistory } from "./history.js";
5
+ import { assertBatchSize, checkOperations, freezeOperations, invalidOperationError, parseReference, } from "./operations.js";
6
+ /**
7
+ * The format-independent editing session: validation, history, revisions,
8
+ * saving and the viewer refresh. Format modules wrap it to add typed methods.
9
+ * Calls run one at a time in call order.
10
+ */
11
+ export class EditSessionController {
12
+ format;
13
+ schemas;
14
+ #engine;
15
+ #host;
16
+ #history;
17
+ #original;
18
+ #originalPageCount;
19
+ #ending = new AbortController();
20
+ sessionId = newSessionId();
21
+ /** Bytes of the last committed state; what the viewer shows and what a broken session saves. */
22
+ #committedBytes;
23
+ /** Materialized bytes of some committed states, by state id, so restores replay less. */
24
+ #checkpoints = new Map();
25
+ #checkpointBytes = 0;
26
+ /** Binary payloads of this session's batches, by content id. */
27
+ #assets = new AssetStore();
28
+ #state;
29
+ #queue = Promise.resolve();
30
+ #revision = 0;
31
+ #savedStateId = 0;
32
+ #ended = false;
33
+ /** Set when a rollback failed: the engine no longer matches the history. */
34
+ #broken = false;
35
+ constructor(engine, host, original, originalPageCount) {
36
+ this.format = host.format;
37
+ this.schemas = engine.schemas;
38
+ this.#engine = engine;
39
+ this.#host = host;
40
+ this.#original = original;
41
+ this.#committedBytes = original;
42
+ this.#originalPageCount = originalPageCount;
43
+ this.#history = new EditHistory(host.limits.maxEditHistory, originalPageCount);
44
+ this.#state = this.#snapshot();
45
+ }
46
+ get state() {
47
+ return this.#state;
48
+ }
49
+ applyJson(operations, options = {}) {
50
+ return this.apply(operations, options);
51
+ }
52
+ apply(operations, options = {}) {
53
+ // Copied before the call is queued, so what the engine sees is what the
54
+ // caller passed, whatever it does to its objects while waiting.
55
+ const batch = Array.isArray(operations)
56
+ ? freezeOperations(operations)
57
+ : operations;
58
+ return this.#enqueue(options.signal, async (signal) => {
59
+ this.#assertRevision(options);
60
+ assertBatchSize(batch, this.#host.limits.maxEditOperations);
61
+ const shapeIssues = checkOperations(batch, this.schemas);
62
+ if (shapeIssues.length > 0)
63
+ throw invalidOperationError(shapeIssues);
64
+ const referenceIssues = checkBatchReferences(batch);
65
+ if (referenceIssues.length > 0)
66
+ throw invalidOperationError(referenceIssues);
67
+ const interned = await this.#intern(batch, signal);
68
+ const engineIssues = await this.#engine.validate(interned, signal);
69
+ throwIfAborted(signal);
70
+ if (engineIssues.length > 0)
71
+ throw invalidOperationError(engineIssues);
72
+ // The id the history will give this state; a dry run uses the same one,
73
+ // so its receipt names the ids a real apply would.
74
+ const engineBatch = {
75
+ stateId: this.#history.nextStateId,
76
+ operations: interned,
77
+ };
78
+ if (options.dryRun) {
79
+ const change = await this.#transaction(signal, "apply", async () => {
80
+ const result = await this.#engine.apply(engineBatch, signal);
81
+ // Producing the bytes catches what only saving would; then the
82
+ // working copy goes back to the current state.
83
+ await this.#engine.materialize("save", {}, signal);
84
+ await this.#engine.restore(this.#restoreTarget(), signal);
85
+ return result;
86
+ });
87
+ return this.#receipt(true, batch.length, change.createdIds, {
88
+ ...change,
89
+ pageCount: change.pageCount ?? this.#history.pageCount,
90
+ });
91
+ }
92
+ const before = this.#history.pageCount;
93
+ const { change, shown } = await this.#transaction(signal, "apply", async () => {
94
+ const result = await this.#engine.apply(engineBatch, signal);
95
+ return {
96
+ change: result,
97
+ shown: await this.#show(signal, this.#pagesOf(result.changedPages, result.reflowFrom)),
98
+ };
99
+ });
100
+ this.#history.push({
101
+ operations: interned,
102
+ ...(options.label === undefined ? {} : { label: options.label }),
103
+ createdIds: change.createdIds,
104
+ removedIds: change.removedIds,
105
+ changedPages: shown.changedPages,
106
+ ...(change.reflowFrom === undefined
107
+ ? {}
108
+ : { reflowFrom: change.reflowFrom }),
109
+ pageCountBefore: before,
110
+ pageCountAfter: shown.pageCount,
111
+ });
112
+ this.#commit("apply", shown.changedPages, shown);
113
+ return this.#receipt(false, batch.length, change.createdIds, {
114
+ ...change,
115
+ changedPages: shown.changedPages,
116
+ pageCount: shown.pageCount,
117
+ warnings: [...change.warnings, ...pageCountWarning(change, shown)],
118
+ });
119
+ });
120
+ }
121
+ undo(options = {}) {
122
+ return this.#enqueue(options.signal, async (signal) => {
123
+ this.#assertRevision(options);
124
+ const entry = this.#history.undoEntry;
125
+ if (!entry)
126
+ return this.#noop();
127
+ const shown = await this.#moveTo(this.#history.position - 1, this.#pagesOf(pagesTouched(entry, entry.pageCountBefore), entry.reflowFrom), signal);
128
+ this.#history.undo();
129
+ this.#commit("undo", shown.changedPages, shown);
130
+ return this.#receipt(false, entry.operations.length, [], {
131
+ removedIds: entry.createdIds,
132
+ changedPages: shown.changedPages,
133
+ pageCount: shown.pageCount,
134
+ warnings: [],
135
+ });
136
+ });
137
+ }
138
+ redo(options = {}) {
139
+ return this.#enqueue(options.signal, async (signal) => {
140
+ this.#assertRevision(options);
141
+ const entry = this.#history.redoEntry;
142
+ if (!entry)
143
+ return this.#noop();
144
+ const shown = await this.#moveTo(this.#history.position + 1, this.#pagesOf(pagesTouched(entry, entry.pageCountAfter), entry.reflowFrom), signal);
145
+ this.#history.redo();
146
+ this.#commit("redo", shown.changedPages, shown);
147
+ return this.#receipt(false, entry.operations.length, entry.createdIds, {
148
+ removedIds: entry.removedIds,
149
+ changedPages: shown.changedPages,
150
+ pageCount: shown.pageCount,
151
+ warnings: [],
152
+ });
153
+ });
154
+ }
155
+ reset(options = {}) {
156
+ return this.#enqueue(options.signal, async (signal) => {
157
+ this.#assertRevision(options);
158
+ const applied = this.#history.entriesAt(this.#history.position);
159
+ if (this.#history.stateId === 0 && this.#history.isPristine)
160
+ return this.#noop();
161
+ const changedPages = allPages(Math.max(this.#originalPageCount, this.#history.pageCount));
162
+ const shown = await this.#transaction(signal, "apply", async () => {
163
+ await this.#engine.restore({ batches: [] }, signal);
164
+ return this.#show(signal, changedPages);
165
+ });
166
+ this.#history.clear();
167
+ this.#commit("reset", changedPages, shown);
168
+ return this.#receipt(false, applied.reduce((count, entry) => count + entry.operations.length, 0), [], {
169
+ removedIds: applied.flatMap((entry) => entry.createdIds),
170
+ changedPages,
171
+ pageCount: shown.pageCount,
172
+ warnings: [],
173
+ });
174
+ });
175
+ }
176
+ save(options = {}) {
177
+ // Format fields (the PDF save mode) travel to the engine; the signal stays.
178
+ const { signal: own, ...format } = options;
179
+ return this.#enqueue(own, async (signal) => {
180
+ // A session whose recovery failed still hands out what it last
181
+ // showed; the history state matches those bytes.
182
+ const { bytes, warnings } = this.#broken
183
+ ? { bytes: this.#committedBytes.slice(), warnings: [] }
184
+ : this.#history.stateId === 0
185
+ ? { bytes: this.#original.slice(), warnings: [] }
186
+ : await this.#materialize("save", format, signal);
187
+ throwIfAborted(signal);
188
+ return Object.freeze({
189
+ bytes,
190
+ stateToken: this.#stateToken(this.#history.stateId),
191
+ sessionId: this.sessionId,
192
+ revision: this.#revision,
193
+ warnings: Object.freeze([...warnings]),
194
+ });
195
+ }, { allowBroken: true });
196
+ }
197
+ /** Records that the host persisted the state a `save()` token names. */
198
+ markSaved(stateToken) {
199
+ const stateId = this.#parseStateToken(stateToken);
200
+ if (stateId === undefined) {
201
+ reportError(new ViewerError("edit-conflict", "The save token belongs to another session", { details: { stateToken, sessionId: this.sessionId } }));
202
+ return;
203
+ }
204
+ const wasDirty = this.#state.dirty;
205
+ this.#savedStateId = stateId;
206
+ this.#state = this.#snapshot();
207
+ if (wasDirty !== this.#state.dirty)
208
+ this.#emitState();
209
+ }
210
+ getElements(query = {}, options = {}) {
211
+ return this.#enqueue(options.signal, async (signal) => this.#items(await this.#engine.getElements(query, signal)));
212
+ }
213
+ getElement(id, options = {}) {
214
+ return this.#enqueue(options.signal, async (signal) => {
215
+ const item = this.#engine.getElement
216
+ ? await this.#engine.getElement(id, signal)
217
+ : (await this.#engine.getElements({}, signal)).find((element) => element.id === id);
218
+ return Object.freeze({
219
+ sessionId: this.sessionId,
220
+ revision: this.#revision,
221
+ item,
222
+ });
223
+ });
224
+ }
225
+ elementsAt(pageIndex, point, options = {}) {
226
+ return this.#enqueue(options.signal, async (signal) => this.#items(await this.#engine.elementsAt(pageIndex, point, signal)));
227
+ }
228
+ findText(query, options = {}) {
229
+ // The signal stays on this side; the rest may cross to a worker.
230
+ const { signal: own, ...engineOptions } = options;
231
+ return this.#enqueue(own, async (signal) => this.#items(await this.#engine.findText(query, engineOptions, signal)));
232
+ }
233
+ readItem(options, task) {
234
+ return this.#enqueue(options?.signal, async (signal) => Object.freeze({
235
+ sessionId: this.sessionId,
236
+ revision: this.#revision,
237
+ item: await task(this.#engine, signal),
238
+ }));
239
+ }
240
+ readItems(options, task) {
241
+ return this.#enqueue(options?.signal, async (signal) => this.#items(await task(this.#engine, signal)));
242
+ }
243
+ /** Stamps a read with the state it describes: the revision at its queue position. */
244
+ #items(items) {
245
+ return Object.freeze({
246
+ sessionId: this.sessionId,
247
+ revision: this.#revision,
248
+ items: Object.freeze([...items]),
249
+ });
250
+ }
251
+ /**
252
+ * Ends the session: pending calls reject with `aborted`, later calls with
253
+ * `lifecycle-error`, the engine is disposed and `active: false` is emitted.
254
+ */
255
+ async end() {
256
+ if (this.#ended)
257
+ return;
258
+ this.#ended = true;
259
+ this.#ending.abort(abortError());
260
+ await this.#queue.catch(() => undefined);
261
+ try {
262
+ await this.#engine.dispose();
263
+ }
264
+ finally {
265
+ this.#emitState(false);
266
+ }
267
+ }
268
+ get ended() {
269
+ return this.#ended;
270
+ }
271
+ /** False once the session ended or a failed recovery left it inconsistent. */
272
+ get usable() {
273
+ return !this.#ended && !this.#broken;
274
+ }
275
+ /** Runs `task` after every earlier call, with a signal bounded by `maxOperationMs`. */
276
+ #enqueue(signal, task, options = {}) {
277
+ // Calls made after the session ended fail at once; calls still queued
278
+ // when it ends are cancelled, like any other pending work.
279
+ if (this.#ended)
280
+ return Promise.reject(new ViewerError("lifecycle-error", "The edit session has ended"));
281
+ const run = async () => {
282
+ if (this.#ending.signal.aborted)
283
+ throw abortError();
284
+ if (!options.allowBroken)
285
+ this.#assertAlive();
286
+ if (signal?.aborted)
287
+ throw abortError();
288
+ const controller = linkedAbortController(signal, this.#ending.signal);
289
+ let timedOut = false;
290
+ const timer = setTimeout(() => {
291
+ timedOut = true;
292
+ controller.abort(abortError());
293
+ }, this.#host.limits.maxOperationMs);
294
+ try {
295
+ return await task(controller.signal);
296
+ }
297
+ catch (error) {
298
+ // A dead engine worker cannot be recovered; the next edit() starts anew.
299
+ if (error instanceof ViewerError && error.code === "worker-crashed")
300
+ this.#broken = true;
301
+ if (timedOut)
302
+ throw new ViewerError("resource-limit", "Edit operation exceeded maxOperationMs", { details: { timeoutMs: this.#host.limits.maxOperationMs } });
303
+ throw error;
304
+ }
305
+ finally {
306
+ clearTimeout(timer);
307
+ }
308
+ };
309
+ const result = this.#queue.then(run, run);
310
+ this.#queue = result.catch(() => undefined);
311
+ return result;
312
+ }
313
+ /**
314
+ * Runs `work` against the engine and the viewer. On any failure the engine
315
+ * goes back to the current history state and the viewer is left untouched,
316
+ * so a failed call never leaks a partial change.
317
+ */
318
+ async #transaction(signal, stage, work) {
319
+ let failedStage = stage;
320
+ try {
321
+ return await work();
322
+ }
323
+ catch (error) {
324
+ if (error instanceof ViewerError && error.code === "edit-failed")
325
+ failedStage = error.details?.stage ?? stage;
326
+ await this.#rollback();
327
+ if (signal.aborted)
328
+ throw abortError();
329
+ if (error instanceof ViewerError &&
330
+ (error.code === "edit-failed" || error.code === "invalid-operation"))
331
+ throw error;
332
+ throw new ViewerError("edit-failed", `Editing failed while ${describe(failedStage)}; the document is unchanged`, { cause: error, details: { stage: failedStage } });
333
+ }
334
+ }
335
+ /**
336
+ * Materializes the working copy and shows it in the viewer in two phases:
337
+ * the preparation may fail or be aborted and is then discarded; the commit
338
+ * is synchronous and cannot fail, so once it ran the call completes
339
+ * whatever its signal says.
340
+ */
341
+ /** The engine's bytes with their warnings; an engine without the richer form warns of nothing. */
342
+ async #materialize(purpose, options, signal) {
343
+ if (this.#engine.materializeDocument)
344
+ return this.#engine.materializeDocument(purpose, options, signal);
345
+ return {
346
+ bytes: await this.#engine.materialize(purpose, options, signal),
347
+ warnings: [],
348
+ };
349
+ }
350
+ /**
351
+ * The pages a change repaints: the engine's list, or for a reflow every
352
+ * page from the paragraph's first page (asked of the host before the
353
+ * document is replaced) to the end of the shown document.
354
+ */
355
+ #pagesOf(changedPages, reflowFrom) {
356
+ if (reflowFrom === undefined)
357
+ return changedPages;
358
+ // Everything from the reflowed paragraph's page on, and the pages
359
+ // the batch names before it (an undo that moves a paragraph back).
360
+ const first = Math.min(this.#host.pageOf?.(reflowFrom) ?? 0, changedPages[0] ?? Number.POSITIVE_INFINITY);
361
+ return (pageCount) => Array.from({ length: Math.max(0, pageCount - first) }, (_, index) => first + index);
362
+ }
363
+ async #show(signal, changedPages) {
364
+ let bytes;
365
+ try {
366
+ ({ bytes } = await this.#materialize("show", {}, signal));
367
+ }
368
+ catch (error) {
369
+ throw stageError("materialize", error);
370
+ }
371
+ throwIfAborted(signal);
372
+ let prepared;
373
+ try {
374
+ prepared = await this.#host.prepareDocument(bytes, signal);
375
+ }
376
+ catch (error) {
377
+ throw stageError("reopen", error);
378
+ }
379
+ if (signal.aborted) {
380
+ this.#host.discardDocument(prepared);
381
+ throw abortError();
382
+ }
383
+ const pages = Object.freeze(typeof changedPages === "function"
384
+ ? [...changedPages(prepared.pageCount)]
385
+ : [...changedPages]);
386
+ return {
387
+ bytes,
388
+ pageCount: this.#host.commitDocument(prepared, pages),
389
+ changedPages: pages,
390
+ };
391
+ }
392
+ async #moveTo(position, changedPages, signal) {
393
+ return this.#transaction(signal, "apply", async () => {
394
+ await this.#engine.restore(this.#restoreTarget(position), signal);
395
+ return this.#show(signal, changedPages);
396
+ });
397
+ }
398
+ async #rollback() {
399
+ // Recovery must finish even when the caller's signal is gone, so it gets
400
+ // a fresh budget of its own.
401
+ const controller = new AbortController();
402
+ const timer = setTimeout(() => controller.abort(abortError()), this.#host.limits.maxOperationMs);
403
+ try {
404
+ await this.#engine.restore(this.#restoreTarget(), controller.signal);
405
+ }
406
+ catch {
407
+ this.#broken = true;
408
+ }
409
+ finally {
410
+ clearTimeout(timer);
411
+ }
412
+ }
413
+ #commit(reason, changedPages, shown) {
414
+ this.#committedBytes = shown.bytes;
415
+ if (reason === "apply")
416
+ this.#keepCheckpoint(shown.bytes);
417
+ else if (reason === "reset")
418
+ this.#dropCheckpoints();
419
+ this.#revision += 1;
420
+ this.#state = this.#snapshot();
421
+ this.#emitState();
422
+ this.#emit("documentchange", {
423
+ sessionId: this.sessionId,
424
+ revision: this.#revision,
425
+ reason,
426
+ changedPages: Object.freeze([...changedPages]),
427
+ pageCount: shown.pageCount,
428
+ });
429
+ }
430
+ /** Hands an event to the host; a throwing listener is reported, never propagated. */
431
+ #emit(type, event) {
432
+ try {
433
+ this.#host.emit(type, event);
434
+ }
435
+ catch (error) {
436
+ reportError(error);
437
+ }
438
+ }
439
+ addAsset(data, options = {}) {
440
+ return this.#enqueue(options.signal, async (signal) => {
441
+ if (data.byteLength > this.#host.limits.maxInputBytes)
442
+ throw new ViewerError("resource-limit", "Asset exceeds maxInputBytes", {
443
+ details: {
444
+ actual: data.byteLength,
445
+ limit: this.#host.limits.maxInputBytes,
446
+ },
447
+ });
448
+ return this.#register(data.slice(), signal);
449
+ });
450
+ }
451
+ /** Stores bytes under their content id and hands them to the engine once. */
452
+ async #register(bytes, signal) {
453
+ const id = await assetIdOf(bytes);
454
+ if (!this.#assets.has(id)) {
455
+ this.#assets.set(id, bytes);
456
+ await this.#engine.putAsset(id, bytes, signal);
457
+ }
458
+ return id;
459
+ }
460
+ /**
461
+ * Replaces inline binary payloads by asset references, so the history
462
+ * holds references only; unknown references are reported like any issue.
463
+ */
464
+ async #intern(batch, signal) {
465
+ const issues = [];
466
+ const result = [];
467
+ for (const [operationIndex, operation] of batch.entries()) {
468
+ const fields = binaryFields(this.schemas.operations[operation.op]);
469
+ if (fields.length === 0) {
470
+ result.push(operation);
471
+ continue;
472
+ }
473
+ const patched = { ...operation };
474
+ for (const field of fields) {
475
+ const value = patched[field];
476
+ if (value === undefined)
477
+ continue;
478
+ if (isAssetReference(value)) {
479
+ if (!this.#assets.has(value))
480
+ issues.push({
481
+ operationIndex,
482
+ path: `/${field}`,
483
+ code: "unknown-asset",
484
+ message: `Unknown asset ${value}`,
485
+ });
486
+ continue;
487
+ }
488
+ const bytes = value instanceof Uint8Array
489
+ ? value
490
+ : Uint8Array.from(atob(value), (c) => c.charCodeAt(0));
491
+ patched[field] = await this.#register(bytes, signal);
492
+ }
493
+ result.push(Object.freeze(patched));
494
+ }
495
+ if (issues.length > 0)
496
+ throw invalidOperationError(issues);
497
+ return Object.freeze(result);
498
+ }
499
+ /** Keeps every stride-th committed state's bytes within the memory budget. */
500
+ #keepCheckpoint(bytes) {
501
+ const stride = Math.max(1, Math.floor(this.#host.limits.maxEditHistory / 4));
502
+ const stateId = this.#history.stateId;
503
+ // Entries dropped by a new change after an undo can never be restored.
504
+ const reachable = new Set(this.#history.stateIds);
505
+ for (const [id, kept] of this.#checkpoints)
506
+ if (!reachable.has(id))
507
+ this.#forgetCheckpoint(id, kept);
508
+ if (stateId % stride !== 0)
509
+ return;
510
+ const budget = this.#host.limits.maxEditCheckpointBytes;
511
+ if (bytes.byteLength > budget)
512
+ return;
513
+ const oldestFirst = [...this.#checkpoints.keys()].sort((a, b) => a - b);
514
+ while (this.#checkpointBytes + bytes.byteLength > budget &&
515
+ oldestFirst.length > 0) {
516
+ const id = oldestFirst.shift();
517
+ this.#forgetCheckpoint(id, this.#checkpoints.get(id));
518
+ }
519
+ this.#checkpoints.set(stateId, bytes);
520
+ this.#checkpointBytes += bytes.byteLength;
521
+ }
522
+ #forgetCheckpoint(id, bytes) {
523
+ this.#checkpoints.delete(id);
524
+ this.#checkpointBytes -= bytes.byteLength;
525
+ }
526
+ #dropCheckpoints() {
527
+ this.#checkpoints.clear();
528
+ this.#checkpointBytes = 0;
529
+ }
530
+ /**
531
+ * The cheapest way to rebuild the state at `position`: the newest
532
+ * checkpoint at or before it, plus the batches after that checkpoint.
533
+ */
534
+ #restoreTarget(position = this.#history.position) {
535
+ const entries = this.#history.entriesAt(position);
536
+ let last = entries.length - 1;
537
+ while (last >= 0 && !this.#checkpoints.has(entries[last].stateId))
538
+ last -= 1;
539
+ const batches = entries.slice(last + 1).map((entry) => ({
540
+ stateId: entry.stateId,
541
+ operations: entry.operations,
542
+ }));
543
+ return last >= 0
544
+ ? { base: this.#checkpoints.get(entries[last].stateId), batches }
545
+ : { batches };
546
+ }
547
+ #stateToken(stateId) {
548
+ return `${this.sessionId}:${stateId}`;
549
+ }
550
+ #parseStateToken(token) {
551
+ const prefix = `${this.sessionId}:`;
552
+ if (!token.startsWith(prefix))
553
+ return undefined;
554
+ const stateId = Number(token.slice(prefix.length));
555
+ return Number.isSafeInteger(stateId) && stateId >= 0 ? stateId : undefined;
556
+ }
557
+ #snapshot() {
558
+ return Object.freeze({
559
+ sessionId: this.sessionId,
560
+ revision: this.#revision,
561
+ dirty: this.#history.stateId !== this.#savedStateId,
562
+ canUndo: this.#history.canUndo,
563
+ canRedo: this.#history.canRedo,
564
+ pageCount: this.#history.pageCount,
565
+ });
566
+ }
567
+ #emitState(active = true) {
568
+ this.#emit("editstatechange", {
569
+ ...this.#state,
570
+ active,
571
+ format: this.format,
572
+ });
573
+ }
574
+ #receipt(dryRun, operationCount, createdIds, change) {
575
+ return Object.freeze({
576
+ sessionId: this.sessionId,
577
+ revision: this.#revision,
578
+ dryRun,
579
+ operationCount,
580
+ createdIds: Object.freeze([...createdIds]),
581
+ removedIds: Object.freeze([...(change.removedIds ?? [])]),
582
+ ...(change.remappedIds
583
+ ? { remappedIds: Object.freeze({ ...change.remappedIds }) }
584
+ : {}),
585
+ changedPages: Object.freeze([...change.changedPages]),
586
+ pageCount: change.pageCount,
587
+ warnings: Object.freeze(change.warnings.map((warning) => Object.freeze({ ...warning }))),
588
+ });
589
+ }
590
+ #noop() {
591
+ return this.#receipt(false, 0, [], {
592
+ changedPages: [],
593
+ pageCount: this.#history.pageCount,
594
+ warnings: [],
595
+ });
596
+ }
597
+ #assertRevision(options) {
598
+ const { expectedRevision, expectedSessionId } = options;
599
+ const staleSession = expectedSessionId !== undefined && expectedSessionId !== this.sessionId;
600
+ const staleRevision = expectedRevision !== undefined && expectedRevision !== this.#revision;
601
+ if (staleSession || staleRevision)
602
+ throw new ViewerError("edit-conflict", staleSession
603
+ ? "The session the caller read from has ended"
604
+ : "The document changed since it was read", {
605
+ details: {
606
+ ...(expectedRevision === undefined ? {} : { expectedRevision }),
607
+ revision: this.#revision,
608
+ ...(expectedSessionId === undefined ? {} : { expectedSessionId }),
609
+ sessionId: this.sessionId,
610
+ },
611
+ });
612
+ }
613
+ #assertAlive() {
614
+ if (this.#ended)
615
+ throw new ViewerError("lifecycle-error", "The edit session has ended");
616
+ if (this.#broken)
617
+ throw new ViewerError("edit-failed", "The edit session is unusable after a failed recovery", { details: { stage: "apply", recovered: false } });
618
+ }
619
+ }
620
+ function stageError(stage, cause) {
621
+ if (cause instanceof ViewerError && cause.code === "aborted")
622
+ return cause;
623
+ return new ViewerError("edit-failed", `Editing failed while ${describe(stage)}; the document is unchanged`, { cause, details: { stage } });
624
+ }
625
+ function describe(stage) {
626
+ switch (stage) {
627
+ case "apply":
628
+ return "applying the change";
629
+ case "materialize":
630
+ return "producing the edited file";
631
+ case "reopen":
632
+ return "reopening the edited file";
633
+ }
634
+ }
635
+ function throwIfAborted(signal) {
636
+ if (signal.aborted)
637
+ throw abortError();
638
+ }
639
+ /** Pages to re-render when an entry is undone or redone. */
640
+ function pagesTouched(entry, pageCount) {
641
+ return entry.pageCountBefore === entry.pageCountAfter
642
+ ? [...entry.changedPages]
643
+ : allPages(pageCount);
644
+ }
645
+ function allPages(pageCount) {
646
+ return Array.from({ length: pageCount }, (_, index) => index);
647
+ }
648
+ /**
649
+ * Same-batch references: `"$<n>"` names the first element created by
650
+ * operation `n` of the batch, which must come earlier. The engine resolves
651
+ * them while applying; here only the form is checked.
652
+ */
653
+ function checkBatchReferences(operations) {
654
+ const issues = [];
655
+ operations.forEach((operation, operationIndex) => {
656
+ const target = operation.target;
657
+ const reference = typeof target === "string" ? parseReference(target) : undefined;
658
+ if (reference === undefined)
659
+ return;
660
+ if (reference >= operationIndex)
661
+ issues.push({
662
+ operationIndex,
663
+ path: "/target",
664
+ code: "unknown-target",
665
+ message: `"$${reference}" must name an earlier operation of the batch`,
666
+ });
667
+ });
668
+ return issues;
669
+ }
670
+ /** The renderer owns the page count; an engine that disagrees is reported, not trusted. */
671
+ function pageCountWarning(change, shown) {
672
+ if (change.pageCount === undefined || change.pageCount === shown.pageCount)
673
+ return [];
674
+ return [
675
+ {
676
+ code: "fidelity-degraded",
677
+ message: `The engine reports ${change.pageCount} pages but the renderer shows ${shown.pageCount}`,
678
+ details: { engine: change.pageCount, renderer: shown.pageCount },
679
+ },
680
+ ];
681
+ }
682
+ /** 128 random bits as URL-safe base64; unique across sessions and reloads. */
683
+ function newSessionId() {
684
+ const bytes = globalThis.crypto.getRandomValues(new Uint8Array(16));
685
+ let binary = "";
686
+ for (const byte of bytes)
687
+ binary += String.fromCharCode(byte);
688
+ return btoa(binary)
689
+ .replaceAll("+", "-")
690
+ .replaceAll("/", "_")
691
+ .replace(/=+$/, "");
692
+ }
693
+ /** Surfaces a listener's exception without failing the call that emitted the event. */
694
+ export function reportError(error) {
695
+ const report = globalThis
696
+ .reportError;
697
+ if (report)
698
+ report(error);
699
+ else
700
+ console.error(error);
701
+ }