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.
- package/THIRD_PARTY_NOTICES.md +10 -3
- package/dist/contracts.d.ts +6 -0
- package/dist/edit/ai/outline.d.ts +47 -0
- package/dist/edit/ai/outline.js +338 -0
- package/dist/edit/ai/targets.d.ts +16 -0
- package/dist/edit/ai/targets.js +309 -0
- package/dist/edit/ai/tools.d.ts +28 -0
- package/dist/edit/ai/tools.js +607 -0
- package/dist/edit/ai/types.d.ts +175 -0
- package/dist/edit/ai/types.js +1 -0
- package/dist/edit/docx/elements.js +19 -1
- package/dist/edit/docx/engine.d.ts +10 -4
- package/dist/edit/docx/engine.js +80 -6
- package/dist/edit/docx/model.d.ts +2 -0
- package/dist/edit/docx/model.js +11 -0
- package/dist/edit/docx/operations.d.ts +10 -0
- package/dist/edit/docx/provider.d.ts +4 -1
- package/dist/edit/docx/provider.js +3 -0
- package/dist/edit/docx/session.d.ts +12 -1
- package/dist/edit/docx/session.js +41 -0
- package/dist/edit/docx/structure-ops.js +39 -4
- package/dist/edit/docx/table-ops.js +26 -4
- package/dist/edit/docx/text-ops.d.ts +22 -0
- package/dist/edit/docx/text-ops.js +49 -16
- package/dist/edit/docx/text.js +21 -0
- package/dist/edit/docx/tracked.d.ts +52 -0
- package/dist/edit/docx/tracked.js +347 -0
- package/dist/edit/docx/types.d.ts +24 -1
- package/dist/edit/docx/write.d.ts +19 -5
- package/dist/edit/docx/write.js +31 -8
- package/dist/edit/engine.d.ts +14 -3
- package/dist/edit/history.d.ts +27 -3
- package/dist/edit/history.js +29 -7
- package/dist/edit/pdf/engine/document.js +48 -3
- package/dist/edit/pdf/engine/elements.d.ts +6 -0
- package/dist/edit/pdf/engine/fonts.js +13 -10
- package/dist/edit/pdf/engine/pages.js +9 -1
- package/dist/edit/pdf/range-map.d.ts +4 -2
- package/dist/edit/pdf/schemas.js +4 -1
- package/dist/edit/pdf/session.d.ts +10 -0
- package/dist/edit/pdf/session.js +39 -0
- package/dist/edit/pdf/types.d.ts +15 -3
- package/dist/edit/pptx/handler.js +10 -2
- package/dist/edit/pptx/session.d.ts +10 -0
- package/dist/edit/pptx/session.js +31 -0
- package/dist/edit/session.d.ts +11 -0
- package/dist/edit/session.js +269 -27
- package/dist/edit/types.d.ts +17 -3
- package/dist/edit/worker-engine.d.ts +2 -2
- package/dist/edit/worker-engine.js +2 -2
- package/dist/fonts/THIRD_PARTY_NOTICES.md +6 -3
- package/dist/fonts/manifest.json +4 -4
- package/dist/fonts/noto-sans-latin-cyrillic.ttf +0 -0
- package/dist/fuzzy-alignment.d.ts +11 -4
- package/dist/fuzzy-alignment.js +3 -9
- package/dist/headless.d.ts +1 -1
- package/dist/headless.js +4 -1
- package/dist/index.d.ts +5 -2
- package/dist/index.js +11 -2
- package/dist/limits.js +3 -0
- package/dist/worker-protocol.d.ts +1 -1
- package/dist/workers/fuzzy-search-worker.js +1 -1
- package/dist/workers/ooxml-edit-worker.js +1415 -860
- package/dist/workers/pdf-edit-worker.js +64 -16
- 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
|
}
|
package/dist/edit/session.d.ts
CHANGED
|
@@ -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
|
/**
|
package/dist/edit/session.js
CHANGED
|
@@ -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 =
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
516
|
-
const id =
|
|
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
|
|
528
|
-
|
|
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
|
-
*
|
|
679
|
+
* retained bytes at or before it, plus the batches after them.
|
|
533
680
|
*/
|
|
534
681
|
#restoreTarget(position = this.#history.position) {
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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
|
-
/**
|
|
683
|
-
function
|
|
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)
|
package/dist/edit/types.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
10
|
-
|
|
11
|
-
|
|
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.
|
package/dist/fonts/manifest.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
3
|
"family": "Zrimo Noto",
|
|
4
|
-
"generated": "2026-
|
|
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":
|
|
85
|
-
"sha256": "
|
|
84
|
+
"bytes": 260552,
|
|
85
|
+
"sha256": "94094981da300fdb704d64cb91354360d37bf69b26f8584da7d44fdc2e0bdc84"
|
|
86
86
|
}
|
|
87
87
|
]
|
|
88
88
|
}
|