web-doc 0.7.0 → 0.9.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 (65) hide show
  1. package/THIRD_PARTY_NOTICES.md +10 -3
  2. package/dist/contracts.d.ts +6 -0
  3. package/dist/edit/ai/outline.d.ts +47 -0
  4. package/dist/edit/ai/outline.js +338 -0
  5. package/dist/edit/ai/targets.d.ts +16 -0
  6. package/dist/edit/ai/targets.js +309 -0
  7. package/dist/edit/ai/tools.d.ts +28 -0
  8. package/dist/edit/ai/tools.js +607 -0
  9. package/dist/edit/ai/types.d.ts +175 -0
  10. package/dist/edit/ai/types.js +1 -0
  11. package/dist/edit/docx/elements.js +19 -1
  12. package/dist/edit/docx/engine.d.ts +10 -4
  13. package/dist/edit/docx/engine.js +80 -6
  14. package/dist/edit/docx/model.d.ts +2 -0
  15. package/dist/edit/docx/model.js +11 -0
  16. package/dist/edit/docx/operations.d.ts +10 -0
  17. package/dist/edit/docx/provider.d.ts +4 -1
  18. package/dist/edit/docx/provider.js +3 -0
  19. package/dist/edit/docx/session.d.ts +12 -1
  20. package/dist/edit/docx/session.js +41 -0
  21. package/dist/edit/docx/structure-ops.js +39 -4
  22. package/dist/edit/docx/table-ops.js +26 -4
  23. package/dist/edit/docx/text-ops.d.ts +22 -0
  24. package/dist/edit/docx/text-ops.js +49 -16
  25. package/dist/edit/docx/text.js +21 -0
  26. package/dist/edit/docx/tracked.d.ts +52 -0
  27. package/dist/edit/docx/tracked.js +347 -0
  28. package/dist/edit/docx/types.d.ts +24 -1
  29. package/dist/edit/docx/write.d.ts +19 -5
  30. package/dist/edit/docx/write.js +31 -8
  31. package/dist/edit/engine.d.ts +14 -3
  32. package/dist/edit/history.d.ts +27 -3
  33. package/dist/edit/history.js +29 -7
  34. package/dist/edit/pdf/engine/document.js +48 -3
  35. package/dist/edit/pdf/engine/elements.d.ts +6 -0
  36. package/dist/edit/pdf/engine/fonts.js +13 -10
  37. package/dist/edit/pdf/engine/pages.js +9 -1
  38. package/dist/edit/pdf/range-map.d.ts +4 -2
  39. package/dist/edit/pdf/schemas.js +4 -1
  40. package/dist/edit/pdf/session.d.ts +10 -0
  41. package/dist/edit/pdf/session.js +39 -0
  42. package/dist/edit/pdf/types.d.ts +15 -3
  43. package/dist/edit/pptx/handler.js +10 -2
  44. package/dist/edit/pptx/session.d.ts +10 -0
  45. package/dist/edit/pptx/session.js +31 -0
  46. package/dist/edit/session.d.ts +11 -0
  47. package/dist/edit/session.js +269 -27
  48. package/dist/edit/types.d.ts +17 -3
  49. package/dist/edit/worker-engine.d.ts +2 -2
  50. package/dist/edit/worker-engine.js +2 -2
  51. package/dist/fonts/THIRD_PARTY_NOTICES.md +6 -3
  52. package/dist/fonts/manifest.json +4 -4
  53. package/dist/fonts/noto-sans-latin-cyrillic.ttf +0 -0
  54. package/dist/fuzzy-alignment.d.ts +11 -4
  55. package/dist/fuzzy-alignment.js +3 -9
  56. package/dist/headless.d.ts +1 -1
  57. package/dist/headless.js +4 -1
  58. package/dist/index.d.ts +5 -2
  59. package/dist/index.js +11 -2
  60. package/dist/limits.js +3 -0
  61. package/dist/worker-protocol.d.ts +1 -1
  62. package/dist/workers/fuzzy-search-worker.js +1 -1
  63. package/dist/workers/ooxml-edit-worker.js +1415 -860
  64. package/dist/workers/pdf-edit-worker.js +64 -16
  65. package/package.json +1 -1
@@ -13,6 +13,12 @@ export function createOoxmlEditHandler() {
13
13
  throw new ViewerError("lifecycle-error", "No package is open for editing");
14
14
  return state;
15
15
  };
16
+ const docx = () => {
17
+ const current = engine();
18
+ if (!(current instanceof DocxEditEngine))
19
+ throw new ViewerError("edit-unsupported", "The open document is not a Word document", { details: { format: "pptx", reason: "no-reads" } });
20
+ return current;
21
+ };
16
22
  const pptx = () => {
17
23
  const current = engine();
18
24
  if (!(current instanceof PptxEditEngine))
@@ -41,8 +47,8 @@ export function createOoxmlEditHandler() {
41
47
  return result;
42
48
  }
43
49
  case "edit-validate": {
44
- const { operations } = payload;
45
- return engine().validate(operations, signal);
50
+ const { operations, mode } = payload;
51
+ return engine().validate(operations, signal, mode);
46
52
  }
47
53
  case "edit-apply": {
48
54
  const { batch } = payload;
@@ -80,6 +86,8 @@ export function createOoxmlEditHandler() {
80
86
  return pptx().slides(signal);
81
87
  case "edit-pptx-layouts":
82
88
  return pptx().layouts(signal);
89
+ case "edit-docx-revisions":
90
+ return docx().revisions(payload.id, signal);
83
91
  case "edit-dispose":
84
92
  await state?.dispose();
85
93
  state = undefined;
@@ -1,3 +1,4 @@
1
+ import type { DescribeOptions, DocumentDescription, EditCheckpoint, OutlineOptions, OutlineResult, TargetCandidate, TargetQuery, ToolCall, ToolCallOptions, ToolResult, ToolSet } from "../ai/types.js";
1
2
  import type { EditSessionCore } from "../engine.js";
2
3
  import type { ApplyOptions, AssetOptions, EditFindOptions, EditOperation, EditReceipt, EditState, ElementQuery, HistoryOptions, OperationSchemaSet, PagePoint, ReadItem, ReadOptions, ReadResult, SavedDocument, TextTarget } from "../types.js";
3
4
  import type { PptxDeleteElementOperation, PptxDeleteSlideOperation, PptxDuplicateSlideOperation, PptxEditSession, PptxElement, PptxFields, PptxInsertImageOperation, PptxInsertSlideOperation, PptxInsertTableOperation, PptxInsertTextBoxOperation, PptxLayoutInfo, PptxMoveElementOperation, PptxMoveSlideOperation, PptxOperation, PptxReplaceTextOperation, PptxResizeElementOperation, PptxSaveOptions, PptxSetShapeStyleOperation, PptxSetTableCellOperation, PptxSetTextStyleOperation, PptxSlideInfo } from "./types.js";
@@ -25,6 +26,15 @@ export declare class PptxSession implements PptxEditSession {
25
26
  getElement(id: string, options?: ReadOptions): Promise<ReadItem<PptxElement>>;
26
27
  elementsAt(pageIndex: number, point: PagePoint, options?: ReadOptions): Promise<ReadResult<PptxElement>>;
27
28
  findText(query: string, options?: EditFindOptions): Promise<ReadResult<TextTarget>>;
29
+ getOutline(options?: OutlineOptions): Promise<OutlineResult>;
30
+ describe(options?: DescribeOptions): Promise<ReadItem<DocumentDescription>>;
31
+ resolveTargets(query: TargetQuery, options?: ReadOptions): Promise<ReadResult<TargetCandidate>>;
32
+ createCheckpoint(label?: string): Promise<EditCheckpoint>;
33
+ listCheckpoints(): readonly EditCheckpoint[];
34
+ restoreCheckpoint(id: string, options?: HistoryOptions): Promise<EditReceipt>;
35
+ dropCheckpoint(id: string): void;
36
+ get tools(): ToolSet;
37
+ callTool(call: ToolCall, options?: ToolCallOptions): Promise<ToolResult>;
28
38
  getSlides(options?: ReadOptions): Promise<ReadResult<PptxSlideInfo>>;
29
39
  getLayouts(options?: ReadOptions): Promise<ReadResult<PptxLayoutInfo>>;
30
40
  replaceText(fields: PptxFields<PptxReplaceTextOperation>, options?: ApplyOptions): Promise<EditReceipt>;
@@ -1,4 +1,7 @@
1
1
  import { ViewerError } from "../../errors.js";
2
+ import { readDescription, readOutline } from "../ai/outline.js";
3
+ import { resolveTargets } from "../ai/targets.js";
4
+ import { buildToolSet, callTool as runTool } from "../ai/tools.js";
2
5
  /**
3
6
  * The PPTX session: the core session narrowed to PPTX operations and
4
7
  * elements, plus the slide and layout reads. Each typed method is `apply()`
@@ -7,6 +10,7 @@ import { ViewerError } from "../../errors.js";
7
10
  export class PptxSession {
8
11
  format = "pptx";
9
12
  #core;
13
+ #tools;
10
14
  constructor(core) {
11
15
  this.#core = core;
12
16
  }
@@ -55,6 +59,33 @@ export class PptxSession {
55
59
  findText(query, options) {
56
60
  return this.#core.findText(query, options);
57
61
  }
62
+ getOutline(options) {
63
+ return readOutline(this, this.#core.limits, options);
64
+ }
65
+ describe(options) {
66
+ return readDescription(this, this.#core.limits, options);
67
+ }
68
+ resolveTargets(query, options) {
69
+ return resolveTargets(this, query, options);
70
+ }
71
+ createCheckpoint(label) {
72
+ return this.#core.createCheckpoint(label);
73
+ }
74
+ listCheckpoints() {
75
+ return this.#core.listCheckpoints();
76
+ }
77
+ restoreCheckpoint(id, options) {
78
+ return this.#core.restoreCheckpoint(id, options);
79
+ }
80
+ dropCheckpoint(id) {
81
+ this.#core.dropCheckpoint(id);
82
+ }
83
+ get tools() {
84
+ return (this.#tools ??= buildToolSet(this.format, this.schemas));
85
+ }
86
+ callTool(call, options) {
87
+ return runTool(this, this.tools, call, options);
88
+ }
58
89
  getSlides(options) {
59
90
  return this.#core.readItems(options, (engine, signal) => pptxReads(engine).slides(signal));
60
91
  }
@@ -1,4 +1,5 @@
1
1
  import type { ResourceLimits, ViewerEventMap } from "../contracts.js";
2
+ import type { DescribeOptions, DocumentDescription, EditCheckpoint, OutlineOptions, OutlineResult, TargetCandidate, TargetQuery, ToolCall, ToolCallOptions, ToolResult, ToolSet } from "./ai/types.js";
2
3
  import type { EditEngine, EditSessionCore } from "./engine.js";
3
4
  import type { AssetOptions, ApplyOptions, EditableFormat, EditElement, EditFindOptions, EditOperation, EditReceipt, EditState, ElementQuery, HistoryOptions, ReadItem, ReadOptions, ReadResult, OperationSchemaSet, PagePoint, SavedDocument, SaveOptions, TextTarget } from "./types.js";
4
5
  /** What the viewer provides to a session: a place to show bytes, and events. */
@@ -38,6 +39,7 @@ export declare class EditSessionController implements EditSessionCore {
38
39
  readonly sessionId: string;
39
40
  constructor(engine: EditEngine, host: EditSessionHost, original: Uint8Array, originalPageCount: number);
40
41
  get state(): EditState;
42
+ get limits(): ResourceLimits;
41
43
  applyJson(operations: readonly EditOperation[], options?: ApplyOptions): Promise<EditReceipt>;
42
44
  apply(operations: readonly EditOperation[], options?: ApplyOptions): Promise<EditReceipt>;
43
45
  undo(options?: HistoryOptions): Promise<EditReceipt>;
@@ -50,6 +52,15 @@ export declare class EditSessionController implements EditSessionCore {
50
52
  getElement(id: string, options?: ReadOptions): Promise<ReadItem<EditElement>>;
51
53
  elementsAt(pageIndex: number, point: PagePoint, options?: ReadOptions): Promise<ReadResult<EditElement>>;
52
54
  findText(query: string, options?: EditFindOptions): Promise<ReadResult<TextTarget>>;
55
+ getOutline(options?: OutlineOptions): Promise<OutlineResult>;
56
+ describe(options?: DescribeOptions): Promise<ReadItem<DocumentDescription>>;
57
+ resolveTargets(query: TargetQuery, options?: ReadOptions): Promise<ReadResult<TargetCandidate>>;
58
+ createCheckpoint(label?: string): Promise<EditCheckpoint>;
59
+ listCheckpoints(): readonly EditCheckpoint[];
60
+ restoreCheckpoint(id: string, options?: HistoryOptions): Promise<EditReceipt>;
61
+ dropCheckpoint(id: string): void;
62
+ get tools(): ToolSet;
63
+ callTool(call: ToolCall, options?: ToolCallOptions): Promise<ToolResult>;
53
64
  readItem<T>(options: ReadOptions | undefined, task: (engine: EditEngine, signal: AbortSignal) => Promise<T | undefined>): Promise<ReadItem<T>>;
54
65
  readItems<T>(options: ReadOptions | undefined, task: (engine: EditEngine, signal: AbortSignal) => Promise<readonly T[]>): Promise<ReadResult<T>>;
55
66
  /**
@@ -1,7 +1,10 @@
1
1
  import { linkedAbortController } from "../abort.js";
2
2
  import { abortError, ViewerError } from "../errors.js";
3
+ import { readDescription, readOutline } from "./ai/outline.js";
4
+ import { resolveTargets } from "./ai/targets.js";
5
+ import { buildToolSet, callTool as runTool } from "./ai/tools.js";
3
6
  import { assetIdOf, AssetStore, binaryFields, isAssetReference, } from "./assets.js";
4
- import { EditHistory } from "./history.js";
7
+ import { batchOf, EditHistory, modeOf } from "./history.js";
5
8
  import { assertBatchSize, checkOperations, freezeOperations, invalidOperationError, parseReference, } from "./operations.js";
6
9
  /**
7
10
  * The format-independent editing session: validation, history, revisions,
@@ -17,15 +20,20 @@ export class EditSessionController {
17
20
  #original;
18
21
  #originalPageCount;
19
22
  #ending = new AbortController();
20
- sessionId = newSessionId();
23
+ sessionId = randomId();
21
24
  /** Bytes of the last committed state; what the viewer shows and what a broken session saves. */
22
25
  #committedBytes;
23
26
  /** Materialized bytes of some committed states, by state id, so restores replay less. */
24
27
  #checkpoints = new Map();
25
28
  #checkpointBytes = 0;
29
+ /** Named checkpoints by id, in creation order. */
30
+ #named = new Map();
31
+ /** State ids named checkpoints pin, with how many name each; never evicted. */
32
+ #pinned = new Map();
26
33
  /** Binary payloads of this session's batches, by content id. */
27
34
  #assets = new AssetStore();
28
35
  #state;
36
+ #tools;
29
37
  #queue = Promise.resolve();
30
38
  #revision = 0;
31
39
  #savedStateId = 0;
@@ -46,6 +54,9 @@ export class EditSessionController {
46
54
  get state() {
47
55
  return this.#state;
48
56
  }
57
+ get limits() {
58
+ return this.#host.limits;
59
+ }
49
60
  applyJson(operations, options = {}) {
50
61
  return this.apply(operations, options);
51
62
  }
@@ -58,6 +69,9 @@ export class EditSessionController {
58
69
  return this.#enqueue(options.signal, async (signal) => {
59
70
  this.#assertRevision(options);
60
71
  assertBatchSize(batch, this.#host.limits.maxEditOperations);
72
+ const modeIssues = checkChangeMode(this.format, options);
73
+ if (modeIssues.length > 0)
74
+ throw invalidOperationError(modeIssues);
61
75
  const shapeIssues = checkOperations(batch, this.schemas);
62
76
  if (shapeIssues.length > 0)
63
77
  throw invalidOperationError(shapeIssues);
@@ -65,7 +79,8 @@ export class EditSessionController {
65
79
  if (referenceIssues.length > 0)
66
80
  throw invalidOperationError(referenceIssues);
67
81
  const interned = await this.#intern(batch, signal);
68
- const engineIssues = await this.#engine.validate(interned, signal);
82
+ const mode = modeOf(options);
83
+ const engineIssues = await this.#engine.validate(interned, signal, mode);
69
84
  throwIfAborted(signal);
70
85
  if (engineIssues.length > 0)
71
86
  throw invalidOperationError(engineIssues);
@@ -74,6 +89,7 @@ export class EditSessionController {
74
89
  const engineBatch = {
75
90
  stateId: this.#history.nextStateId,
76
91
  operations: interned,
92
+ ...mode,
77
93
  };
78
94
  if (options.dryRun) {
79
95
  const change = await this.#transaction(signal, "apply", async () => {
@@ -99,6 +115,7 @@ export class EditSessionController {
99
115
  });
100
116
  this.#history.push({
101
117
  operations: interned,
118
+ ...mode,
102
119
  ...(options.label === undefined ? {} : { label: options.label }),
103
120
  createdIds: change.createdIds,
104
121
  removedIds: change.removedIds,
@@ -230,6 +247,88 @@ export class EditSessionController {
230
247
  const { signal: own, ...engineOptions } = options;
231
248
  return this.#enqueue(own, async (signal) => this.#items(await this.#engine.findText(query, engineOptions, signal)));
232
249
  }
250
+ getOutline(options) {
251
+ return readOutline(this, this.#host.limits, options);
252
+ }
253
+ describe(options) {
254
+ return readDescription(this, this.#host.limits, options);
255
+ }
256
+ resolveTargets(query, options) {
257
+ return resolveTargets(this, query, options);
258
+ }
259
+ createCheckpoint(label) {
260
+ return this.#enqueue(undefined, async () => {
261
+ const limit = this.#host.limits.maxEditCheckpoints;
262
+ if (this.#named.size >= limit)
263
+ throw new ViewerError("resource-limit", "Too many edit checkpoints; drop one first", { details: { limit } });
264
+ const stateId = this.#history.stateId;
265
+ const checkpoint = Object.freeze({
266
+ id: randomId(),
267
+ ...(label === undefined ? {} : { label }),
268
+ revision: this.#revision,
269
+ createdAt: new Date().toISOString(),
270
+ });
271
+ const entries = this.#history.entriesAt(this.#history.position);
272
+ this.#named.set(checkpoint.id, {
273
+ checkpoint,
274
+ stateId,
275
+ pageCount: this.#history.pageCount,
276
+ entries,
277
+ batches: batchesFromOriginal(entries),
278
+ });
279
+ this.#pin(stateId, this.#committedBytes);
280
+ return checkpoint;
281
+ });
282
+ }
283
+ listCheckpoints() {
284
+ return Object.freeze([...this.#named.values()].map((named) => named.checkpoint));
285
+ }
286
+ restoreCheckpoint(id, options = {}) {
287
+ return this.#enqueue(options.signal, async (signal) => {
288
+ this.#assertRevision(options);
289
+ const named = this.#named.get(id);
290
+ if (!named)
291
+ throw new ViewerError("invalid-operation", `Unknown edit checkpoint ${id}`, { details: { checkpointId: id } });
292
+ if (named.stateId === this.#history.stateId)
293
+ return this.#noop();
294
+ const before = this.#history.pageCount;
295
+ const changedPages = allPages(Math.max(before, named.pageCount));
296
+ const shown = await this.#transaction(signal, "apply", async () => {
297
+ await this.#engine.restore(this.#targetFor(named.entries), signal);
298
+ return this.#show(signal, changedPages);
299
+ });
300
+ const diff = entryDiff(this.#history.entriesAt(this.#history.position), named.entries);
301
+ this.#history.push({
302
+ operations: [],
303
+ createdIds: diff.created,
304
+ removedIds: diff.removed,
305
+ changedPages: shown.changedPages,
306
+ pageCountBefore: before,
307
+ pageCountAfter: shown.pageCount,
308
+ base: { stateId: named.stateId, batches: named.batches },
309
+ }, named.stateId);
310
+ this.#commit("restore", shown.changedPages, shown);
311
+ return this.#receipt(false, 0, diff.created, {
312
+ removedIds: diff.removed,
313
+ changedPages: shown.changedPages,
314
+ pageCount: shown.pageCount,
315
+ warnings: [],
316
+ });
317
+ });
318
+ }
319
+ dropCheckpoint(id) {
320
+ const named = this.#named.get(id);
321
+ if (!named)
322
+ return;
323
+ this.#named.delete(id);
324
+ this.#unpin(named.stateId);
325
+ }
326
+ get tools() {
327
+ return (this.#tools ??= buildToolSet(this.format, this.schemas));
328
+ }
329
+ callTool(call, options) {
330
+ return runTool(this, this.tools, call, options);
331
+ }
233
332
  readItem(options, task) {
234
333
  return this.#enqueue(options?.signal, async (signal) => Object.freeze({
235
334
  sessionId: this.sessionId,
@@ -412,7 +511,7 @@ export class EditSessionController {
412
511
  }
413
512
  #commit(reason, changedPages, shown) {
414
513
  this.#committedBytes = shown.bytes;
415
- if (reason === "apply")
514
+ if (reason === "apply" || reason === "restore")
416
515
  this.#keepCheckpoint(shown.bytes);
417
516
  else if (reason === "reset")
418
517
  this.#dropCheckpoints();
@@ -500,49 +599,107 @@ export class EditSessionController {
500
599
  #keepCheckpoint(bytes) {
501
600
  const stride = Math.max(1, Math.floor(this.#host.limits.maxEditHistory / 4));
502
601
  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);
602
+ // Entries dropped by a new change after an undo can never be restored;
603
+ // a pinned state stays whatever the history does.
604
+ const reachable = this.#reachable();
505
605
  for (const [id, kept] of this.#checkpoints)
506
- if (!reachable.has(id))
606
+ if (!reachable.has(id) && !this.#pinned.has(id))
507
607
  this.#forgetCheckpoint(id, kept);
508
- if (stateId % stride !== 0)
608
+ if (stateId === 0 ||
609
+ this.#checkpoints.has(stateId) ||
610
+ stateId % stride !== 0)
509
611
  return;
612
+ this.#retain(stateId, bytes);
613
+ }
614
+ /** Stores a state's bytes when the budget, less the pinned states, can hold them. */
615
+ #retain(stateId, bytes) {
510
616
  const budget = this.#host.limits.maxEditCheckpointBytes;
511
617
  if (bytes.byteLength > budget)
512
618
  return;
513
- const oldestFirst = [...this.#checkpoints.keys()].sort((a, b) => a - b);
619
+ const evictable = [...this.#checkpoints.keys()]
620
+ .filter((id) => !this.#pinned.has(id))
621
+ .sort((a, b) => a - b);
514
622
  while (this.#checkpointBytes + bytes.byteLength > budget &&
515
- oldestFirst.length > 0) {
516
- const id = oldestFirst.shift();
623
+ evictable.length > 0) {
624
+ const id = evictable.shift();
517
625
  this.#forgetCheckpoint(id, this.#checkpoints.get(id));
518
626
  }
627
+ if (this.#checkpointBytes + bytes.byteLength > budget)
628
+ return;
519
629
  this.#checkpoints.set(stateId, bytes);
520
630
  this.#checkpointBytes += bytes.byteLength;
521
631
  }
632
+ /**
633
+ * Pins a state for a named checkpoint: its bytes are kept while any
634
+ * checkpoint names it, or rebuilt by replay when the budget cannot hold
635
+ * them. The original (state 0) needs no bytes.
636
+ */
637
+ #pin(stateId, bytes) {
638
+ if (stateId === 0)
639
+ return;
640
+ this.#pinned.set(stateId, (this.#pinned.get(stateId) ?? 0) + 1);
641
+ if (!this.#checkpoints.has(stateId))
642
+ this.#retain(stateId, bytes);
643
+ }
644
+ #unpin(stateId) {
645
+ const count = this.#pinned.get(stateId);
646
+ if (!count)
647
+ return;
648
+ if (count > 1) {
649
+ this.#pinned.set(stateId, count - 1);
650
+ return;
651
+ }
652
+ this.#pinned.delete(stateId);
653
+ const kept = this.#checkpoints.get(stateId);
654
+ if (kept && !this.#reachable().has(stateId))
655
+ this.#forgetCheckpoint(stateId, kept);
656
+ }
657
+ /** State ids a replay may start from: every entry's, and the base of every restore. */
658
+ #reachable() {
659
+ const reachable = new Set();
660
+ for (const entry of this.#history.allEntries) {
661
+ reachable.add(entry.stateId);
662
+ if (entry.base)
663
+ reachable.add(entry.base.stateId);
664
+ }
665
+ return reachable;
666
+ }
522
667
  #forgetCheckpoint(id, bytes) {
523
668
  this.#checkpoints.delete(id);
524
669
  this.#checkpointBytes -= bytes.byteLength;
525
670
  }
671
+ /** After a reset nothing in the history is reachable; pinned states stay. */
526
672
  #dropCheckpoints() {
527
- this.#checkpoints.clear();
528
- this.#checkpointBytes = 0;
673
+ for (const [id, kept] of this.#checkpoints)
674
+ if (!this.#pinned.has(id))
675
+ this.#forgetCheckpoint(id, kept);
529
676
  }
530
677
  /**
531
678
  * The cheapest way to rebuild the state at `position`: the newest
532
- * checkpoint at or before it, plus the batches after that checkpoint.
679
+ * retained bytes at or before it, plus the batches after them.
533
680
  */
534
681
  #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 };
682
+ return this.#targetFor(this.#history.entriesAt(position));
683
+ }
684
+ /**
685
+ * Walks `entries` from the end: the first with retained bytes is the base;
686
+ * a restore entry without them starts from the original plus the batches
687
+ * that built its checkpoint.
688
+ */
689
+ #targetFor(entries) {
690
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
691
+ const entry = entries[index];
692
+ const after = () => batchesOf(entries.slice(index + 1));
693
+ const retained = this.#checkpoints.get(entry.stateId);
694
+ if (retained)
695
+ return { base: retained, batches: after() };
696
+ // A restore entry carries its checkpoint's state id, so its bytes
697
+ // were just looked up; without them the checkpoint's batches rebuild
698
+ // it from the original.
699
+ if (entry.base)
700
+ return { batches: [...entry.base.batches, ...after()] };
701
+ }
702
+ return { batches: batchesOf(entries) };
546
703
  }
547
704
  #stateToken(stateId) {
548
705
  return `${this.sessionId}:${stateId}`;
@@ -667,6 +824,43 @@ function checkBatchReferences(operations) {
667
824
  });
668
825
  return issues;
669
826
  }
827
+ /**
828
+ * Tracked changes exist in DOCX only and always name their author (decision
829
+ * 8 of the ai-edit module): the batch is refused before any engine work.
830
+ */
831
+ function checkChangeMode(format, options) {
832
+ const issues = [];
833
+ // A timestamp is written into the file where a format records one, so
834
+ // it must be a date-time the file can hold whatever the mode.
835
+ if (options.timestamp !== undefined &&
836
+ (!DATE_TIME.test(options.timestamp) ||
837
+ Number.isNaN(Date.parse(options.timestamp))))
838
+ issues.push({
839
+ operationIndex: -1,
840
+ path: "/timestamp",
841
+ code: "invalid-value",
842
+ message: "The timestamp must be an ISO 8601 date-time",
843
+ });
844
+ if (options.changeMode !== "tracked")
845
+ return issues;
846
+ if (format !== "docx")
847
+ issues.push({
848
+ operationIndex: -1,
849
+ path: "",
850
+ code: "unsupported-change-mode",
851
+ message: `${format.toUpperCase()} has no tracked changes; apply directly and review with checkpoints`,
852
+ });
853
+ else if (!options.author || options.author.trim().length === 0)
854
+ issues.push({
855
+ operationIndex: -1,
856
+ path: "/author",
857
+ code: "required",
858
+ message: "Tracked changes name their author; pass ApplyOptions.author",
859
+ });
860
+ return issues;
861
+ }
862
+ /** ISO 8601 as `xsd:dateTime` takes it. */
863
+ const DATE_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?$/;
670
864
  /** The renderer owns the page count; an engine that disagrees is reported, not trusted. */
671
865
  function pageCountWarning(change, shown) {
672
866
  if (change.pageCount === undefined || change.pageCount === shown.pageCount)
@@ -679,8 +873,56 @@ function pageCountWarning(change, shown) {
679
873
  },
680
874
  ];
681
875
  }
682
- /** 128 random bits as URL-safe base64; unique across sessions and reloads. */
683
- function newSessionId() {
876
+ /** The engine batches of entries that carry operations; restore entries carry none. */
877
+ function batchesOf(entries) {
878
+ return entries.filter((entry) => entry.operations.length > 0).map(batchOf);
879
+ }
880
+ /** The batches from the original to the state after `entries`, through the last restore. */
881
+ function batchesFromOriginal(entries) {
882
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
883
+ const base = entries[index].base;
884
+ if (base)
885
+ return [...base.batches, ...batchesOf(entries.slice(index + 1))];
886
+ }
887
+ return batchesOf(entries);
888
+ }
889
+ /**
890
+ * The ids a move from the state after `current` to the state after `target`
891
+ * removes and creates: the entries past the common prefix are undone in
892
+ * reverse, then the target's are redone, each id netted out.
893
+ */
894
+ function entryDiff(current, target) {
895
+ let common = 0;
896
+ while (common < current.length &&
897
+ common < target.length &&
898
+ current[common].stateId === target[common].stateId)
899
+ common += 1;
900
+ const created = new Set();
901
+ const removed = new Set();
902
+ const create = (id) => {
903
+ if (removed.has(id))
904
+ removed.delete(id);
905
+ else
906
+ created.add(id);
907
+ };
908
+ const remove = (id) => {
909
+ if (created.has(id))
910
+ created.delete(id);
911
+ else
912
+ removed.add(id);
913
+ };
914
+ for (const entry of current.slice(common).reverse()) {
915
+ entry.createdIds.forEach(remove);
916
+ entry.removedIds.forEach(create);
917
+ }
918
+ for (const entry of target.slice(common)) {
919
+ entry.removedIds.forEach(remove);
920
+ entry.createdIds.forEach(create);
921
+ }
922
+ return { created: [...created], removed: [...removed] };
923
+ }
924
+ /** 128 random bits as 22 URL-safe base64 characters; unique across sessions and reloads. */
925
+ function randomId() {
684
926
  const bytes = globalThis.crypto.getRandomValues(new Uint8Array(16));
685
927
  let binary = "";
686
928
  for (const byte of bytes)
@@ -1,4 +1,5 @@
1
1
  import type { ViewerWarning } from "../contracts.js";
2
+ import type { EditSessionReads } from "./ai/types.js";
2
3
  /** Formats an edit session can be started for. */
3
4
  export type EditableFormat = "pdf" | "pptx" | "docx";
4
5
  export interface EditOptions {
@@ -163,16 +164,27 @@ export interface EditState {
163
164
  readonly canRedo: boolean;
164
165
  readonly pageCount: number;
165
166
  }
167
+ /** How a batch is written: in place, or as tracked changes (DOCX) for a person to accept or reject. */
168
+ export type ChangeMode = "direct" | "tracked";
166
169
  export interface ApplyOptions {
167
170
  /** Rejects the call with `edit-conflict` unless `state.revision` equals this value. */
168
171
  readonly expectedRevision?: number;
172
+ /**
173
+ * Default `direct`. `tracked` writes Word revisions (`w:ins`, `w:del`,
174
+ * `w:rPrChange`, `w:pPrChange`) with `author` and `timestamp`; refused
175
+ * with `unsupported-change-mode` by formats and operations without a
176
+ * tracked form, and with `required` at `/author` without an author.
177
+ */
178
+ readonly changeMode?: ChangeMode;
179
+ /** The `w:author` of tracked changes; required with `changeMode: "tracked"`. */
180
+ readonly author?: string;
169
181
  /** Rejects with `edit-conflict` unless it equals `sessionId`; for callers that outlive a session. */
170
182
  readonly expectedSessionId?: string;
171
183
  /** Validates and simulates the batch without changing anything. */
172
184
  readonly dryRun?: boolean;
173
185
  /** Free text stored with the history entry, for host UIs and audit logs. */
174
186
  readonly label?: string;
175
- /** Reserved: ISO 8601 time a format records with the change; never generated by web-doc. */
187
+ /** ISO 8601 time a format records with the change (the `w:date` of a tracked change); never generated by web-doc. */
176
188
  readonly timestamp?: string;
177
189
  readonly signal?: AbortSignal;
178
190
  }
@@ -228,7 +240,7 @@ export interface OperationIssue {
228
240
  readonly code: string;
229
241
  readonly message: string;
230
242
  }
231
- export interface EditSessionBase<TOperation extends EditOperation, TElement extends EditElement> {
243
+ export interface EditSessionBase<TOperation extends EditOperation, TElement extends EditElement> extends EditSessionReads {
232
244
  readonly format: EditableFormat;
233
245
  /** Unique per session; stamped on state, receipts, read results and events. */
234
246
  readonly sessionId: string;
@@ -258,7 +270,9 @@ export interface EditStateChange extends EditState {
258
270
  readonly active: boolean;
259
271
  readonly format?: EditableFormat;
260
272
  }
261
- export type DocumentChangeReason = "apply" | "undo" | "redo" | "reset";
273
+ export type DocumentChangeReason = "apply" | "undo" | "redo" | "reset"
274
+ /** `restoreCheckpoint()`: the content of a named checkpoint, as one history entry. */
275
+ | "restore";
262
276
  export interface DocumentChange {
263
277
  readonly sessionId: string;
264
278
  readonly revision: number;
@@ -1,6 +1,6 @@
1
1
  import type { WorkerRpcClient } from "../worker-client.js";
2
2
  import type { EditWorkerOperation } from "../worker-protocol.js";
3
- import type { EditEngine, EditEngineContext, EngineBatch, EngineChange, MaterializedDocument, MaterializeOptions, RestoreTarget } from "./engine.js";
3
+ import type { EditEngine, EditEngineContext, BatchMode, EngineBatch, EngineChange, MaterializedDocument, MaterializeOptions, RestoreTarget } from "./engine.js";
4
4
  import type { EditElement, EditFindOptions, EditOperation, ElementQuery, OperationIssue, OperationSchemaSet, PagePoint, TextTarget } from "./types.js";
5
5
  export declare abstract class WorkerEngineClient implements EditEngine {
6
6
  #private;
@@ -8,7 +8,7 @@ export declare abstract class WorkerEngineClient implements EditEngine {
8
8
  protected readonly rpc: WorkerRpcClient;
9
9
  protected readonly context: EditEngineContext;
10
10
  constructor(rpc: WorkerRpcClient, context: EditEngineContext);
11
- validate(operations: readonly EditOperation[], signal: AbortSignal): Promise<readonly OperationIssue[]>;
11
+ validate(operations: readonly EditOperation[], signal: AbortSignal, mode?: BatchMode): Promise<readonly OperationIssue[]>;
12
12
  /** Plain operation arrays, as the unit tests pass them, become the next batch. */
13
13
  apply(input: EngineBatch | readonly EditOperation[], signal: AbortSignal): Promise<EngineChange>;
14
14
  /** A bare signal, as the unit tests pass it, asks for the shown form. */
@@ -12,8 +12,8 @@ export class WorkerEngineClient {
12
12
  this.rpc = rpc;
13
13
  this.context = context;
14
14
  }
15
- validate(operations, signal) {
16
- return this.request("edit-validate", { operations }, signal);
15
+ validate(operations, signal, mode = {}) {
16
+ return this.request("edit-validate", { operations, mode }, signal);
17
17
  }
18
18
  /** Plain operation arrays, as the unit tests pass them, become the next batch. */
19
19
  apply(input, signal) {
@@ -6,8 +6,11 @@ The WOFF2 files in this directory are modified/subset builds of Noto Sans and No
6
6
  - Noto Sans CJK sources: `notofonts/noto-cjk` commit `f8d157532fbfaeda587e826d4cd5b21a49186f7c`.
7
7
  - License: SIL Open Font License 1.1; see `OFL-1.1.txt`.
8
8
  - Modified font family used by the viewer: `Zrimo Noto`.
9
- - `noto-sans-latin-cyrillic.ttf` is the same Latin/Cyrillic subset saved as an
10
- uncompressed TrueType file; the PDF editor embeds it into documents whose
11
- text the standard fonts cannot encode.
9
+ - `noto-sans-latin-cyrillic.ttf` is the same Latin/Cyrillic subset plus Noto
10
+ Sans's General Punctuation, Superscripts and Subscripts, Currency Symbols,
11
+ Letterlike Symbols and Number Forms (ranges in `manifest.json`; the Arrows
12
+ and Mathematical Operators ranges are requested too, but Noto Sans has no
13
+ glyphs there), saved as an uncompressed TrueType file; the PDF editor embeds it into
14
+ documents whose text the standard fonts cannot encode.
12
15
 
13
16
  No proprietary Microsoft font is included.
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "family": "Zrimo Noto",
4
- "generated": "2026-07-16",
5
- "tooling": "fonttools pyftsubset with all layout features, then woff2_compress; the .ttf is the Latin/Cyrillic subset saved uncompressed with fonttools for embedding into PDFs",
4
+ "generated": "2026-10-02",
5
+ "tooling": "fonttools pyftsubset with all layout features, then woff2_compress; the .ttf is the Latin/Cyrillic subset saved uncompressed with fonttools for embedding into PDFs, from hinted/ttf/NotoSans/NotoSans-Regular.ttf with U+0000,U+000D,U+0020-007E,U+00A0-024F,U+0300-036F,U+0400-052F,U+1E00-1EFF plus General Punctuation, Superscripts and Subscripts, Currency Symbols, Letterlike Symbols, Number Forms, Arrows, Mathematical Operators, U+25CC and U+FFFD (U+2000-206F,U+2070-209F,U+20A0-20CF,U+2100-214F,U+2150-218F,U+2190-21FF,U+2200-22FF,U+25CC,U+FFFD) so text typed with dashes, quotes, currency signs and the numero sign can be embedded; Noto Sans has no glyphs in the Arrows and Mathematical Operators ranges, so those two add none",
6
6
  "license": "OFL-1.1",
7
7
  "sources": {
8
8
  "noto-fonts": "https://github.com/notofonts/noto-fonts/tree/ffebf8c1ee449e544955a7e813c54f9b73848eac",
@@ -81,8 +81,8 @@
81
81
  },
82
82
  {
83
83
  "file": "noto-sans-latin-cyrillic.ttf",
84
- "bytes": 218796,
85
- "sha256": "6a23e5413988b4b523ceb4aa81a1de85dad21336868997f52eb6d42159345262"
84
+ "bytes": 260552,
85
+ "sha256": "94094981da300fdb704d64cb91354360d37bf69b26f8584da7d44fdc2e0bdc84"
86
86
  }
87
87
  ]
88
88
  }