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
@@ -1,4 +1,4 @@
1
- import type { ApplyOptions, BinaryData, EditColor, EditElement, EditReceipt, EditSessionBase, SavedDocument, SaveOptions, TextRange } from "../types.js";
1
+ import type { ApplyOptions, BinaryData, EditColor, EditElement, EditReceipt, EditSessionBase, ReadOptions, ReadResult, SavedDocument, SaveOptions, TextRange } from "../types.js";
2
2
  export type DocxElementKind = "paragraph" | "table" | "image" | "other";
3
3
  export type DocxTextAlign = "left" | "center" | "right" | "justify";
4
4
  /** Word's sixteen highlight colours (`w:highlight/@w:val`). */
@@ -54,6 +54,14 @@ export interface DocxElement extends EditElement {
54
54
  readonly textStyle?: DocxTextStyle;
55
55
  /** Present for a paragraph. */
56
56
  readonly paragraphStyle?: DocxParagraphStyle;
57
+ /**
58
+ * Present for a paragraph in a table cell: the cell's row and its place in
59
+ * the row, both from 0. Paragraphs move only among those of the same cell.
60
+ */
61
+ readonly cell?: {
62
+ readonly row: number;
63
+ readonly column: number;
64
+ };
57
65
  /** Present for a table: cell text by row. */
58
66
  readonly table?: {
59
67
  readonly rows: readonly (readonly string[])[];
@@ -165,11 +173,26 @@ export interface DocxInsertImageOperation {
165
173
  export type DocxOperation = DocxReplaceTextOperation | DocxSetTextStyleOperation | DocxSetParagraphStyleOperation | DocxInsertParagraphOperation | DocxDeleteElementOperation | DocxMoveElementOperation | DocxInsertTableOperation | DocxSetTableCellOperation | DocxInsertImageOperation;
166
174
  /** DOCX saves have no fields of their own. */
167
175
  export type DocxSaveOptions = SaveOptions;
176
+ /** One revision of a paragraph, as `getRevisions()` lists them in document order. */
177
+ export interface DocxRevision {
178
+ readonly kind: "ins" | "del" | "moveFrom" | "moveTo" | "rPrChange" | "pPrChange";
179
+ /** The `w:id`; -1 when the file leaves it out. */
180
+ readonly id: number;
181
+ readonly author?: string;
182
+ /** ISO 8601, as the file writes it. */
183
+ readonly date?: string;
184
+ /** Runs of the paragraph, its paragraph mark, or the paragraph's properties. */
185
+ readonly scope: "runs" | "mark" | "paragraph";
186
+ /** The inserted, deleted or restyled text; "\n" for a paragraph mark; absent for a property change. */
187
+ readonly text?: string;
188
+ }
168
189
  /** An operation's fields without its `op`, as the typed methods take them. */
169
190
  export type DocxFields<T extends DocxOperation> = T extends unknown ? Omit<T, "op"> : never;
170
191
  export interface DocxEditSession extends EditSessionBase<DocxOperation, DocxElement> {
171
192
  readonly format: "docx";
172
193
  save(options?: DocxSaveOptions): Promise<SavedDocument>;
194
+ /** The tracked changes a paragraph holds, in document order; empty for other elements. */
195
+ getRevisions(elementId: string, options?: ReadOptions): Promise<ReadResult<DocxRevision>>;
173
196
  /** Replaces the whole text of a paragraph, or the part a range covers. */
174
197
  replaceText(fields: DocxFields<DocxReplaceTextOperation>, options?: ApplyOptions): Promise<EditReceipt>;
175
198
  /** Changes run properties; unspecified ones keep their bytes. */
@@ -3,6 +3,10 @@ import { type XmlElement, type XmlPart } from "../ooxml/xml.js";
3
3
  import type { EditColor } from "../types.js";
4
4
  import { type DocxStyles } from "./style.js";
5
5
  import type { DocxParagraphStyleChange, DocxTextStyleChange } from "./types.js";
6
+ /** Children of `w:rPr` in schema order (CT_RPr). */
7
+ export declare const RPR_ORDER: string[];
8
+ /** Children of `w:pPr` in schema order (CT_PPr). */
9
+ export declare const PPR_ORDER: string[];
6
10
  /** Why a text cannot be written, or undefined when it can. */
7
11
  export declare function textProblem(text: string): string | undefined;
8
12
  /** Why a value cannot be written as an attribute: text problems plus the breaks text may hold. */
@@ -23,15 +27,25 @@ export declare function runXml(rPr: string, content: string): string;
23
27
  * where the schema puts them. Returns "" for no properties at all.
24
28
  */
25
29
  export declare function mergedProperties(part: XmlPart, source: XmlElement | undefined, tag: "w:rPr" | "w:pPr", order: readonly string[], set: ReadonlyMap<string, string | null>): string;
26
- /** `w:rPr` bytes with a text style change applied. */
27
- export declare function changedRunProperties(part: XmlPart, rPr: XmlElement | undefined, change: DocxTextStyleChange, styles: DocxStyles): string;
30
+ /**
31
+ * `w:rPr` bytes with a text style change applied. With `revision` (the
32
+ * attributes of a tracked change) the previous properties are kept in a
33
+ * `w:rPrChange`, so Word shows the change as a suggestion.
34
+ */
35
+ export declare function changedRunProperties(part: XmlPart, rPr: XmlElement | undefined, change: DocxTextStyleChange, styles: DocxStyles, revision?: string): string;
28
36
  /** `w:color` for a colour: RGB, automatic, or a theme name with the theme's value. */
29
37
  export declare function colorXml(color: EditColor, styles: DocxStyles): string;
30
- /** `w:pPr` bytes with alignment and spacing changed, other children kept. */
31
- export declare function changedParagraphProperties(part: XmlPart, pPr: XmlElement | undefined, change: DocxParagraphStyleChange): string;
38
+ /** The children of a properties element as bytes, the named ones left out. */
39
+ export declare function innerPropertiesXml(part: XmlPart, properties: XmlElement | undefined, without: readonly string[]): string;
40
+ /**
41
+ * `w:pPr` bytes with alignment and spacing changed, other children kept.
42
+ * With `revision` the previous paragraph properties are kept in a
43
+ * `w:pPrChange`.
44
+ */
45
+ export declare function changedParagraphProperties(part: XmlPart, pPr: XmlElement | undefined, change: DocxParagraphStyleChange, revision?: string): string;
32
46
  export declare function alignValue(align: NonNullable<DocxParagraphStyleChange["align"]>): string;
33
47
  /** `w:pPr` bytes with the paragraph mark's `w:rPr` changed (created when absent). */
34
- export declare function paragraphMarkProperties(part: XmlPart, pPr: XmlElement | undefined, change: DocxTextStyleChange, styles: DocxStyles): string;
48
+ export declare function paragraphMarkProperties(part: XmlPart, pPr: XmlElement | undefined, change: DocxTextStyleChange, styles: DocxStyles, revision?: string): string;
35
49
  /** `w:pPr` bytes without the section properties, for a paragraph copied next to one that ends a section. */
36
50
  export declare function paragraphPropertiesWithoutSection(part: XmlPart, pPr: XmlElement | undefined): string;
37
51
  /**
@@ -11,7 +11,7 @@ import { LINE_BREAK, PAGE_BREAK, TAB } from "./text.js";
11
11
  */
12
12
  const MC_NS = "http://schemas.openxmlformats.org/markup-compatibility/2006";
13
13
  /** Children of `w:rPr` in schema order (CT_RPr). */
14
- const RPR_ORDER = [
14
+ export const RPR_ORDER = [
15
15
  "rStyle",
16
16
  "rFonts",
17
17
  "b",
@@ -54,7 +54,7 @@ const RPR_ORDER = [
54
54
  "rPrChange",
55
55
  ];
56
56
  /** Children of `w:pPr` in schema order (CT_PPr). */
57
- const PPR_ORDER = [
57
+ export const PPR_ORDER = [
58
58
  "pStyle",
59
59
  "keepNext",
60
60
  "keepLines",
@@ -215,9 +215,15 @@ export function mergedProperties(part, source, tag, order, set) {
215
215
  function toggleXml(local, on) {
216
216
  return on ? `<w:${local}/>` : `<w:${local} w:val="0"/>`;
217
217
  }
218
- /** `w:rPr` bytes with a text style change applied. */
219
- export function changedRunProperties(part, rPr, change, styles) {
218
+ /**
219
+ * `w:rPr` bytes with a text style change applied. With `revision` (the
220
+ * attributes of a tracked change) the previous properties are kept in a
221
+ * `w:rPrChange`, so Word shows the change as a suggestion.
222
+ */
223
+ export function changedRunProperties(part, rPr, change, styles, revision) {
220
224
  const set = new Map();
225
+ if (revision !== undefined)
226
+ set.set("rPrChange", `<w:rPrChange${revision}>${wrapped("w:rPr", innerPropertiesXml(part, rPr, ["rPrChange"]))}</w:rPrChange>`);
221
227
  if (change.bold !== undefined) {
222
228
  set.set("b", toggleXml("b", change.bold));
223
229
  set.set("bCs", toggleXml("bCs", change.bold));
@@ -261,9 +267,26 @@ function fontsXml(part, rPr, family) {
261
267
  const rest = kept.map((a) => ` ${a.name}="${a.rawValue}"`).join("");
262
268
  return `<w:rFonts w:ascii="${face}" w:hAnsi="${face}"${rest}/>`;
263
269
  }
264
- /** `w:pPr` bytes with alignment and spacing changed, other children kept. */
265
- export function changedParagraphProperties(part, pPr, change) {
270
+ /** `inner` inside `tag`, self-closing when empty. */
271
+ function wrapped(tag, inner) {
272
+ return inner.length === 0 ? `<${tag}/>` : `<${tag}>${inner}</${tag}>`;
273
+ }
274
+ /** The children of a properties element as bytes, the named ones left out. */
275
+ export function innerPropertiesXml(part, properties, without) {
276
+ return (properties?.children ?? [])
277
+ .filter((child) => !without.includes(child.local))
278
+ .map((child) => sliceOf(part, child))
279
+ .join("");
280
+ }
281
+ /**
282
+ * `w:pPr` bytes with alignment and spacing changed, other children kept.
283
+ * With `revision` the previous paragraph properties are kept in a
284
+ * `w:pPrChange`.
285
+ */
286
+ export function changedParagraphProperties(part, pPr, change, revision) {
266
287
  const set = new Map();
288
+ if (revision !== undefined)
289
+ set.set("pPrChange", `<w:pPrChange${revision}>${wrapped("w:pPr", innerPropertiesXml(part, pPr, ["rPr", "sectPr", "pPrChange"]))}</w:pPrChange>`);
267
290
  if (change.align !== undefined)
268
291
  set.set("jc", `<w:jc w:val="${alignValue(change.align)}"/>`);
269
292
  if (change.spacing !== undefined) {
@@ -304,9 +327,9 @@ export function alignValue(align) {
304
327
  }
305
328
  }
306
329
  /** `w:pPr` bytes with the paragraph mark's `w:rPr` changed (created when absent). */
307
- export function paragraphMarkProperties(part, pPr, change, styles) {
330
+ export function paragraphMarkProperties(part, pPr, change, styles, revision) {
308
331
  const rPr = pPr?.children.find((child) => child.local === "rPr" && child.namespace === W_NS);
309
- const changed = changedRunProperties(part, rPr, change, styles);
332
+ const changed = changedRunProperties(part, rPr, change, styles, revision);
310
333
  return mergedProperties(part, pPr, "w:pPr", PPR_ORDER, new Map([["rPr", changed || null]]));
311
334
  }
312
335
  /** `w:pPr` bytes without the section properties, for a paragraph copied next to one that ends a section. */
@@ -1,6 +1,6 @@
1
1
  import type { DocumentFormat, RegisteredFont, ResourceLimits, TextRun, ViewerWarning } from "../contracts.js";
2
2
  import type { EditSession } from "./sessions.js";
3
- import type { EditableFormat, EditElement, EditFindOptions, EditOperation, EditSessionBase, ElementQuery, OperationIssue, OperationSchemaSet, PagePoint, ReadItem, ReadOptions, ReadResult, TextTarget } from "./types.js";
3
+ import type { ChangeMode, EditableFormat, EditElement, EditFindOptions, EditOperation, EditSessionBase, ElementQuery, OperationIssue, OperationSchemaSet, PagePoint, ReadItem, ReadOptions, ReadResult, TextTarget } from "./types.js";
4
4
  export interface EditEngineContext {
5
5
  readonly format: EditableFormat;
6
6
  readonly fileName?: string;
@@ -16,6 +16,8 @@ export interface EditEngineContext {
16
16
  * earlier calls and carry the same envelope as the core's own reads.
17
17
  */
18
18
  export interface EditSessionCore extends EditSessionBase<EditOperation, EditElement> {
19
+ /** The host's limits, for reads a typed session builds over the core. */
20
+ readonly limits: ResourceLimits;
19
21
  readItem<T>(options: ReadOptions | undefined, task: (engine: EditEngine, signal: AbortSignal) => Promise<T | undefined>): Promise<ReadItem<T>>;
20
22
  readItems<T>(options: ReadOptions | undefined, task: (engine: EditEngine, signal: AbortSignal) => Promise<readonly T[]>): Promise<ReadResult<T>>;
21
23
  }
@@ -44,8 +46,17 @@ export interface MaterializedDocument {
44
46
  /** For example `privacy-not-guaranteed` when a PDF full save could not be compacted. */
45
47
  readonly warnings: readonly ViewerWarning[];
46
48
  }
49
+ /** How a batch is written; travels with the batch to the engine and into the history, so a replay writes it the same way. */
50
+ export interface BatchMode {
51
+ /** Default `direct`; `tracked` writes revisions where the format has them. */
52
+ readonly changeMode?: ChangeMode;
53
+ /** The author of tracked changes. */
54
+ readonly author?: string;
55
+ /** ISO 8601, the date of tracked changes. */
56
+ readonly timestamp?: string;
57
+ }
47
58
  /** A batch with the identity the core assigned to the state after it. */
48
- export interface EngineBatch {
59
+ export interface EngineBatch extends BatchMode {
49
60
  /** Unique within the session and never reused; engines derive created ids from it. */
50
61
  readonly stateId: number;
51
62
  readonly operations: readonly EditOperation[];
@@ -82,7 +93,7 @@ export interface EngineChange {
82
93
  export interface EditEngine {
83
94
  readonly schemas: OperationSchemaSet;
84
95
  /** Validates against the current state; an empty result means the batch can be applied. */
85
- validate(operations: readonly EditOperation[], signal: AbortSignal): Promise<readonly OperationIssue[]>;
96
+ validate(operations: readonly EditOperation[], signal: AbortSignal, mode?: BatchMode): Promise<readonly OperationIssue[]>;
86
97
  /**
87
98
  * Applies an already validated batch to the working copy. Same-batch
88
99
  * references (`"$<n>"` targets) are resolved here; one that resolves to an
@@ -1,6 +1,6 @@
1
- import type { EngineBatch } from "./engine.js";
1
+ import type { BatchMode, EngineBatch } from "./engine.js";
2
2
  import type { EditOperation } from "./types.js";
3
- export interface HistoryEntry {
3
+ export interface HistoryEntry extends BatchMode {
4
4
  readonly operations: readonly EditOperation[];
5
5
  readonly label?: string;
6
6
  /** Ids the batch created; an undo removes them again. */
@@ -15,6 +15,19 @@ export interface HistoryEntry {
15
15
  readonly pageCountAfter: number;
16
16
  /** Identifies the content after this batch; equal ids mean equal content. */
17
17
  readonly stateId: number;
18
+ /**
19
+ * A restore to a named checkpoint: the content after this entry is the
20
+ * checkpoint's, so a replay starts from its retained bytes or from the
21
+ * original plus `batches`, never from the entries before.
22
+ */
23
+ readonly base?: HistoryBase;
24
+ }
25
+ /** Where a restore entry's content comes from. */
26
+ export interface HistoryBase {
27
+ /** The checkpoint's state id; its bytes may be retained under it. */
28
+ readonly stateId: number;
29
+ /** The batches that build the checkpoint's state from the original. */
30
+ readonly batches: readonly EngineBatch[];
18
31
  }
19
32
  /**
20
33
  * Linear undo history over an immutable original. Entries beyond the limit are
@@ -35,6 +48,8 @@ export declare class EditHistory {
35
48
  get pageCount(): number;
36
49
  /** State ids of every entry still in the history, folded and redo tail included. */
37
50
  get stateIds(): readonly number[];
51
+ /** Every entry still in the history: folded ones, then the undoable ones and the redo tail. */
52
+ get allEntries(): readonly HistoryEntry[];
38
53
  /** The entry `undo()` would revert, if any. */
39
54
  get undoEntry(): HistoryEntry | undefined;
40
55
  /** The entry `redo()` would re-apply, if any. */
@@ -46,8 +61,17 @@ export declare class EditHistory {
46
61
  /** Entries applied when `position` undoable entries are applied, folded ones first. */
47
62
  entriesAt(position: number): readonly HistoryEntry[];
48
63
  get position(): number;
49
- push(entry: Omit<HistoryEntry, "stateId">): HistoryEntry;
64
+ /**
65
+ * Appends an entry with a fresh state id, or with `stateId` when the entry
66
+ * reproduces a known state (a restore to a checkpoint): the same id means
67
+ * the same content, so `dirty` stays exact.
68
+ */
69
+ push(entry: Omit<HistoryEntry, "stateId">, stateId?: number): HistoryEntry;
50
70
  undo(): void;
51
71
  redo(): void;
52
72
  clear(): void;
53
73
  }
74
+ /** The engine batch an entry replays as: its operations and how they were written. */
75
+ export declare function batchOf(entry: HistoryEntry): EngineBatch;
76
+ /** The write mode fields of a batch or an entry, only those set. */
77
+ export declare function modeOf(mode: BatchMode): BatchMode;
@@ -42,7 +42,11 @@ export class EditHistory {
42
42
  }
43
43
  /** State ids of every entry still in the history, folded and redo tail included. */
44
44
  get stateIds() {
45
- return [...this.#folded, ...this.#entries].map((entry) => entry.stateId);
45
+ return this.allEntries.map((entry) => entry.stateId);
46
+ }
47
+ /** Every entry still in the history: folded ones, then the undoable ones and the redo tail. */
48
+ get allEntries() {
49
+ return [...this.#folded, ...this.#entries];
46
50
  }
47
51
  /** The entry `undo()` would revert, if any. */
48
52
  get undoEntry() {
@@ -58,10 +62,7 @@ export class EditHistory {
58
62
  }
59
63
  /** Batches applied to the original when `position` undoable entries are applied. */
60
64
  batchesAt(position) {
61
- return this.entriesAt(position).map((entry) => ({
62
- stateId: entry.stateId,
63
- operations: entry.operations,
64
- }));
65
+ return this.entriesAt(position).map(batchOf);
65
66
  }
66
67
  /** Entries applied when `position` undoable entries are applied, folded ones first. */
67
68
  entriesAt(position) {
@@ -70,10 +71,15 @@ export class EditHistory {
70
71
  get position() {
71
72
  return this.#position;
72
73
  }
73
- push(entry) {
74
+ /**
75
+ * Appends an entry with a fresh state id, or with `stateId` when the entry
76
+ * reproduces a known state (a restore to a checkpoint): the same id means
77
+ * the same content, so `dirty` stays exact.
78
+ */
79
+ push(entry, stateId) {
74
80
  const stored = Object.freeze({
75
81
  ...entry,
76
- stateId: this.#nextStateId++,
82
+ stateId: stateId ?? this.#nextStateId++,
77
83
  });
78
84
  // A new change after an undo drops the redo tail.
79
85
  this.#entries.length = this.#position;
@@ -99,3 +105,19 @@ export class EditHistory {
99
105
  this.#position = 0;
100
106
  }
101
107
  }
108
+ /** The engine batch an entry replays as: its operations and how they were written. */
109
+ export function batchOf(entry) {
110
+ return {
111
+ stateId: entry.stateId,
112
+ operations: entry.operations,
113
+ ...modeOf(entry),
114
+ };
115
+ }
116
+ /** The write mode fields of a batch or an entry, only those set. */
117
+ export function modeOf(mode) {
118
+ return {
119
+ ...(mode.changeMode === undefined ? {} : { changeMode: mode.changeMode }),
120
+ ...(mode.author === undefined ? {} : { author: mode.author }),
121
+ ...(mode.timestamp === undefined ? {} : { timestamp: mode.timestamp }),
122
+ };
123
+ }
@@ -535,8 +535,11 @@ export class PdfEditDocument {
535
535
  },
536
536
  // Ids name the state the batch leads to, the operation and the item,
537
537
  // so replaying the history reproduces them and an undone id is never
538
- // handed out again.
539
- newId: (pageIndex, suffix = "") => `${this.#pages[pageIndex].key}:n${stateId}.${operationIndex}.${created++}${suffix}`,
538
+ // handed out again. A file saved by an earlier session carries the ids
539
+ // that session numbered from 1 as well, so one already on the page
540
+ // gets the first free `~n` instead (ACTION-886); the saved objects are
541
+ // part of the base, so a replay meets them and picks the same id.
542
+ newId: (pageIndex, suffix = "") => `${this.#unusedId(pageIndex, `${this.#pages[pageIndex].key}:n${stateId}.${operationIndex}.${created++}`)}${suffix}`,
540
543
  withPage: (pageIndex, use) => this.#writePage(pageIndex, use),
541
544
  appendObjects: (pageIndex, records) => {
542
545
  const page = this.#pages[pageIndex];
@@ -686,10 +689,52 @@ export class PdfEditDocument {
686
689
  }
687
690
  }
688
691
  record.objects = objects.map((object, index) => object.mark && stale.has(object.id)
689
- ? { id: `${record.key}:o${index}`, type: object.type }
692
+ ? {
693
+ id: `${record.key}:o${index}`,
694
+ type: object.type,
695
+ staleMarkId: object.id,
696
+ }
690
697
  : object);
691
698
  return record.objects;
692
699
  }
700
+ /**
701
+ * `id`, or its first free `~n` when an object saved by an earlier session
702
+ * has it already. Only ids a session numbered (`<key>:n…`) can collide, so
703
+ * only those are gathered; the objects are read without the page's text.
704
+ */
705
+ #unusedId(pageIndex, id) {
706
+ const record = this.#pages[pageIndex];
707
+ const objects = record.objects ?? this.#loadObjects(pageIndex);
708
+ const numbered = `${record.key}:n`;
709
+ const taken = new Set();
710
+ for (const object of objects) {
711
+ if (object.id.startsWith(numbered))
712
+ taken.add(object.id);
713
+ if (object.staleMarkId?.startsWith(numbered))
714
+ taken.add(object.staleMarkId);
715
+ }
716
+ if (!taken.has(id))
717
+ return id;
718
+ let suffix = 1;
719
+ while (taken.has(`${id}~${suffix}`))
720
+ suffix += 1;
721
+ return `${id}~${suffix}`;
722
+ }
723
+ /** A page's objects, loading the page alone when they are not known yet. */
724
+ #loadObjects(pageIndex) {
725
+ const { lib } = this.#pdfium;
726
+ const page = lib.FPDF_LoadPage(this.#document.handle, pageIndex);
727
+ if (!page)
728
+ throw new ViewerError("render-failed", "PDFium could not load the page", {
729
+ details: { pageIndex },
730
+ });
731
+ try {
732
+ return this.#objectsOf(pageIndex, page);
733
+ }
734
+ finally {
735
+ lib.FPDF_ClosePage(page);
736
+ }
737
+ }
693
738
  /** Marks from other sessions keep their id only if it cannot collide with ours. */
694
739
  #ownMark(mark, pageKey) {
695
740
  return mark.id.startsWith(`${pageKey}:`)
@@ -21,6 +21,12 @@ export interface ObjectRecord {
21
21
  readonly id: string;
22
22
  readonly type: number;
23
23
  readonly mark?: MarkParams;
24
+ /**
25
+ * The id a mark the file still carries names, on an object listed as plain
26
+ * because the mark failed its check: a new id must not take it, or saving
27
+ * would join the new object to this one.
28
+ */
29
+ readonly staleMarkId?: string;
24
30
  }
25
31
  /**
26
32
  * Reads the objects of a loaded page in drawing order and turns them into
@@ -110,7 +110,13 @@ export class FontLibrary {
110
110
  return undefined;
111
111
  if (this.#fallback && this.#covers(this.#fallback, request.text))
112
112
  return undefined;
113
- const bad = firstNonWinAnsi(request.text) ?? firstUncovered(request.text, this);
113
+ // Name the character that actually stops the text: the first one the
114
+ // fallback lacks when it was tried, else the first the standard fonts
115
+ // cannot encode. Naming the first Cyrillic letter for a dash the
116
+ // fallback lacked sent people after the wrong character (ACTION-879).
117
+ const bad = this.#fallback
118
+ ? this.#firstMissing(this.#fallback, request.text)
119
+ : firstNonWinAnsi(request.text);
114
120
  return {
115
121
  path: "/text",
116
122
  code: "font-unavailable",
@@ -175,6 +181,10 @@ export class FontLibrary {
175
181
  return candidates.sort((a, b) => score(b.font) - score(a.font))[0];
176
182
  }
177
183
  #covers(bytes, text) {
184
+ return this.#firstMissing(bytes, text) === undefined;
185
+ }
186
+ /** The first character of `text` the font has no glyph for; layout handles line breaks and spaces. */
187
+ #firstMissing(bytes, text) {
178
188
  let coverage = this.#coverage.get(bytes);
179
189
  if (!coverage) {
180
190
  coverage = parseCmap(bytes);
@@ -185,9 +195,9 @@ export class FontLibrary {
185
195
  if (code === 0x0a || code === 0x20)
186
196
  continue;
187
197
  if (!coverage.has(code))
188
- return false;
198
+ return character;
189
199
  }
190
- return true;
200
+ return undefined;
191
201
  }
192
202
  #load(pdfium, document, bytes) {
193
203
  let handles = this.#handles.get(document);
@@ -242,13 +252,6 @@ function isBold(font) {
242
252
  function isItalic(font) {
243
253
  return font.style !== "normal";
244
254
  }
245
- function firstUncovered(text, library) {
246
- void library;
247
- for (const character of text)
248
- if (character !== "\n" && character !== " ")
249
- return character;
250
- return undefined;
251
- }
252
255
  /** The code points a TrueType font maps to glyphs, from its cmap table. */
253
256
  /**
254
257
  * Whether glyphs of a TrueType font have outline data: a subset font can keep
@@ -74,11 +74,19 @@ export const rotatePage = {
74
74
  validate(operation, context, issue) {
75
75
  if (operation.pageIndex >= context.pageCount)
76
76
  issue("/pageIndex", "unknown-target", `No page ${operation.pageIndex}`);
77
+ // A field given as undefined counts as absent, as it does everywhere else.
78
+ if ((operation.rotation === undefined) === (operation.by === undefined))
79
+ issue("", "one-of", "Give exactly one of `rotation` and `by`");
77
80
  },
78
81
  apply(operation, context) {
79
82
  const { lib } = context.pdfium;
80
83
  context.withPage(operation.pageIndex, (page) => {
81
- lib.FPDFPage_SetRotation(page, operation.rotation / 90);
84
+ // A turn reads the angle the page has now, so it composes with what
85
+ // the file was saved with and with turns earlier in the history.
86
+ const quarters = operation.by !== undefined
87
+ ? (lib.FPDFPage_GetRotation(page) + operation.by / 90) % 4
88
+ : operation.rotation / 90;
89
+ lib.FPDFPage_SetRotation(page, quarters);
82
90
  });
83
91
  context.invalidatePage(operation.pageIndex);
84
92
  return {
@@ -2,10 +2,12 @@ import type { EditOperation, EditReceipt, TextRange } from "../types.js";
2
2
  export interface MutationRecord {
3
3
  /** The revision the call produced. */
4
4
  readonly revision: number;
5
- readonly kind: "apply" | "undo" | "redo" | "reset";
5
+ readonly kind: "apply" | "undo" | "redo" | "reset" | "restore";
6
6
  /**
7
7
  * The operations the call applied (apply, redo) or took back (undo, reset);
8
- * for a reset every batch of the session, in order.
8
+ * for a reset every batch of the session, in order. A checkpoint restore
9
+ * carries none: its receipt's ids say what went and what came back, and
10
+ * offsets inside surviving elements are left as they were.
9
11
  */
10
12
  readonly operations: readonly EditOperation[];
11
13
  readonly receipt: EditReceipt;
@@ -170,10 +170,13 @@ export const pdfOperationSchemas = Object.freeze({
170
170
  from: { type: "integer", minimum: 0 },
171
171
  to: { type: "integer", minimum: 0 },
172
172
  }, ["from", "to"]),
173
+ // Exactly one of `rotation` (absolute) and `by` (a turn from the current
174
+ // angle); the handler reports a batch that gives both or neither.
173
175
  rotatePage: operation("rotatePage", {
174
176
  pageIndex: { type: "integer", minimum: 0 },
175
177
  rotation: { enum: [0, 90, 180, 270] },
176
- }, ["pageIndex", "rotation"]),
178
+ by: { enum: [90, 180, 270] },
179
+ }, ["pageIndex"]),
177
180
  insertShape: operation("insertShape", {
178
181
  pageIndex: { type: "integer", minimum: 0 },
179
182
  shape: { enum: ["rectangle", "ellipse", "line"] },
@@ -1,4 +1,5 @@
1
1
  import type { TextSelection } 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 { EditSessionCore } from "../engine.js";
3
4
  import type { ApplyOptions, AssetOptions, EditFindOptions, EditOperation, EditReceipt, EditState, ElementQuery, HistoryOptions, OperationSchemaSet, PagePoint, PageRect, ReadItem, ReadOptions, ReadResult, SavedDocument, TextPosition, TextRange, TextTarget } from "../types.js";
4
5
  import type { DeleteElementOperation, DeletePageOperation, Fields, InsertImageOperation, InsertPageOperation, InsertShapeOperation, InsertTableOperation, InsertTextBoxOperation, MoveElementOperation, MovePageOperation, PdfEditSession, PdfElement, PdfOperation, PdfSaveOptions, ReplaceTextOperation, ResizeElementOperation, RotatePageOperation, SetShapeStyleOperation, SetTableCellOperation, PageBitmap, PageLayout, RenderOptions, SetTextStyleOperation, TextLayout } from "./types.js";
@@ -28,6 +29,15 @@ export declare class PdfSession implements PdfEditSession {
28
29
  getElement(id: string, options?: ReadOptions): Promise<ReadItem<PdfElement>>;
29
30
  elementsAt(pageIndex: number, point: PagePoint, options?: ReadOptions): Promise<ReadResult<PdfElement>>;
30
31
  findText(query: string, options?: EditFindOptions): Promise<ReadResult<TextTarget>>;
32
+ getOutline(options?: OutlineOptions): Promise<OutlineResult>;
33
+ describe(options?: DescribeOptions): Promise<ReadItem<DocumentDescription>>;
34
+ resolveTargets(query: TargetQuery, options?: ReadOptions): Promise<ReadResult<TargetCandidate>>;
35
+ createCheckpoint(label?: string): Promise<EditCheckpoint>;
36
+ listCheckpoints(): readonly EditCheckpoint[];
37
+ restoreCheckpoint(id: string, options?: HistoryOptions): Promise<EditReceipt>;
38
+ dropCheckpoint(id: string): void;
39
+ get tools(): ToolSet;
40
+ callTool(call: ToolCall, options?: ToolCallOptions): Promise<ToolResult>;
31
41
  getTextLayout(elementId: string, options?: ReadOptions): Promise<ReadItem<TextLayout>>;
32
42
  positionAt(pageIndex: number, point: PagePoint, options?: ReadOptions): Promise<ReadItem<TextPosition>>;
33
43
  rangeRects(range: TextRange, options?: ReadOptions): Promise<ReadResult<PageRect>>;
@@ -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
  import { reportError } from "../session.js";
3
6
  import { rectContains } from "./engine/geometry.js";
4
7
  import { mapRangeThrough } from "./range-map.js";
@@ -11,6 +14,7 @@ import { resolveSelection } from "./selection.js";
11
14
  export class PdfSession {
12
15
  format = "pdf";
13
16
  #core;
17
+ #tools;
14
18
  /** Committed calls, oldest first, for `mapRange`; bounded by `LOG_LIMIT`. */
15
19
  #log = [];
16
20
  /** Batches applied and not undone, and those undone and not redone. */
@@ -81,6 +85,12 @@ export class PdfSession {
81
85
  this.#applied = [];
82
86
  this.#undone = [];
83
87
  break;
88
+ case "restore":
89
+ // The checkpoint's content replaces whatever the stacks describe;
90
+ // the undo of a restore is the core's to replay, not the log's.
91
+ this.#applied = [];
92
+ this.#undone = [];
93
+ break;
84
94
  }
85
95
  this.#log.push({
86
96
  revision: receipt.revision,
@@ -156,6 +166,35 @@ export class PdfSession {
156
166
  findText(query, options) {
157
167
  return this.#core.findText(query, options);
158
168
  }
169
+ getOutline(options) {
170
+ return readOutline(this, this.#core.limits, options);
171
+ }
172
+ describe(options) {
173
+ return readDescription(this, this.#core.limits, options);
174
+ }
175
+ resolveTargets(query, options) {
176
+ return resolveTargets(this, query, options);
177
+ }
178
+ createCheckpoint(label) {
179
+ return this.#core.createCheckpoint(label);
180
+ }
181
+ listCheckpoints() {
182
+ return this.#core.listCheckpoints();
183
+ }
184
+ async restoreCheckpoint(id, options) {
185
+ const receipt = await this.#core.restoreCheckpoint(id, options);
186
+ this.#record("restore", [], receipt);
187
+ return receipt;
188
+ }
189
+ dropCheckpoint(id) {
190
+ this.#core.dropCheckpoint(id);
191
+ }
192
+ get tools() {
193
+ return (this.#tools ??= buildToolSet(this.format, this.schemas));
194
+ }
195
+ callTool(call, options) {
196
+ return runTool(this, this.tools, call, options);
197
+ }
159
198
  getTextLayout(elementId, options) {
160
199
  return this.#core.readItem(options, (engine, signal) => pdfReads(engine).textLayout(elementId, signal));
161
200
  }
@@ -192,12 +192,24 @@ export interface SetShapeStyleOperation {
192
192
  /** A new fill, `null` to remove it, or absent to keep it. */
193
193
  readonly fill?: PdfFill | null;
194
194
  }
195
- export interface RotatePageOperation {
195
+ /**
196
+ * Sets a page's rotation, or turns it from where it stands: a host offering
197
+ * "rotate" does not need to know the angle a file was saved with, and a
198
+ * replay of the history turns from the same angle again.
199
+ */
200
+ export type RotatePageOperation = {
196
201
  readonly op: "rotatePage";
197
202
  readonly pageIndex: number;
198
203
  /** Absolute clockwise rotation in degrees. */
199
204
  readonly rotation: 0 | 90 | 180 | 270;
200
- }
205
+ readonly by?: never;
206
+ } | {
207
+ readonly op: "rotatePage";
208
+ readonly pageIndex: number;
209
+ /** Clockwise turn in degrees from the page's current rotation. */
210
+ readonly by: 90 | 180 | 270;
211
+ readonly rotation?: never;
212
+ };
201
213
  /** The PDF operation union; operations are added as they ship. */
202
214
  export type PdfOperation = InsertTextBoxOperation | ReplaceTextOperation | SetTextStyleOperation | ResizeElementOperation | MoveElementOperation | DeleteElementOperation | InsertPageOperation | DeletePageOperation | MovePageOperation | RotatePageOperation | InsertShapeOperation | SetShapeStyleOperation | InsertImageOperation | InsertTableOperation | SetTableCellOperation;
203
215
  export interface PdfSaveOptions extends SaveOptions {
@@ -321,7 +333,7 @@ export interface PdfEditSession extends EditSessionBase<PdfOperation, PdfElement
321
333
  deletePage(fields: Fields<DeletePageOperation>, options?: ApplyOptions): Promise<EditReceipt>;
322
334
  /** Reorders pages. */
323
335
  movePage(fields: Fields<MovePageOperation>, options?: ApplyOptions): Promise<EditReceipt>;
324
- /** Sets a page's rotation. */
336
+ /** Sets a page's rotation, or turns it from the one it has. */
325
337
  rotatePage(fields: Fields<RotatePageOperation>, options?: ApplyOptions): Promise<EditReceipt>;
326
338
  /** Draws a rectangle, ellipse or line as a path object. */
327
339
  insertShape(fields: Fields<InsertShapeOperation>, options?: ApplyOptions): Promise<EditReceipt>;