web-doc 0.12.1 → 0.13.1

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.
@@ -36,9 +36,9 @@ export declare class PdfEditDocument {
36
36
  get pageCount(): number;
37
37
  get pdfium(): Pdfium;
38
38
  /**
39
- * Checks a batch against the current document. Operations are checked one
40
- * after another against the state before the batch, which is exact for
41
- * everything but a page count another operation of the batch changes.
39
+ * Preflights a batch against its starting document. Applying revalidates
40
+ * each operation against earlier changes in the batch; created-element
41
+ * references are checked once their targets exist.
42
42
  */
43
43
  validate(operations: readonly PdfOrUnknownOperation[]): OperationIssue[];
44
44
  /**
@@ -87,6 +87,12 @@ const CREATING_OPERATIONS = new Set([
87
87
  "insertImage",
88
88
  "insertTable",
89
89
  ]);
90
+ /** Operations that change page order, and with it the index a loaded page is kept by. */
91
+ const PAGE_ORDER_OPERATIONS = new Set([
92
+ "insertPage",
93
+ "deletePage",
94
+ "movePage",
95
+ ]);
90
96
  function referenceOf(operation) {
91
97
  const target = operation.target;
92
98
  return typeof target === "string" ? parseReference(target) : undefined;
@@ -129,6 +135,8 @@ export class PdfEditDocument {
129
135
  #batches = 0;
130
136
  /** Browser faces of the document's fonts, by font program; see `textFont`. */
131
137
  #faces = new Map();
138
+ /** Pages the running batch changed, by index; see `#batch`. */
139
+ #written;
132
140
  constructor(pdfium, original, fonts = new FontLibrary(async () => {
133
141
  throw new Error("No font source is configured");
134
142
  }), limits = defaultResourceLimits, assets = new AssetStore(), compact = compactPdf) {
@@ -167,9 +175,9 @@ export class PdfEditDocument {
167
175
  return this.#pdfium;
168
176
  }
169
177
  /**
170
- * Checks a batch against the current document. Operations are checked one
171
- * after another against the state before the batch, which is exact for
172
- * everything but a page count another operation of the batch changes.
178
+ * Preflights a batch against its starting document. Applying revalidates
179
+ * each operation against earlier changes in the batch; created-element
180
+ * references are checked once their targets exist.
173
181
  */
174
182
  validate(operations) {
175
183
  const issues = [];
@@ -205,6 +213,9 @@ export class PdfEditDocument {
205
213
  * the next batch number as their state id.
206
214
  */
207
215
  apply(input) {
216
+ return this.#batch(() => this.#apply(input));
217
+ }
218
+ #apply(input) {
208
219
  const batch = Array.isArray(input)
209
220
  ? { stateId: this.#batches + 1, operations: input }
210
221
  : input;
@@ -225,11 +236,13 @@ export class PdfEditDocument {
225
236
  details: { signatures: this.#signatures, features: this.#features },
226
237
  });
227
238
  operations.forEach((raw, operationIndex) => {
239
+ if (PAGE_ORDER_OPERATIONS.has(raw.op))
240
+ this.#writeBack();
228
241
  const handler = handlers[raw.op];
229
242
  if (!handler)
230
243
  throw new ViewerError("internal", `Unknown pdf operation ${raw.op}`);
231
244
  const context = this.#context(stateId, operationIndex);
232
- const operation = this.#resolveReference(raw, operationIndex, createdByOperation, handler, context);
245
+ const operation = this.#resolveAndValidate(raw, operationIndex, createdByOperation, handler, context);
233
246
  const result = handler.apply(operation, context);
234
247
  createdIds.push(...result.createdIds);
235
248
  createdByOperation[operationIndex] = [...result.createdIds];
@@ -251,23 +264,23 @@ export class PdfEditDocument {
251
264
  };
252
265
  }
253
266
  /**
254
- * Turns a `"$<n>"` target into the id operation `n` created, then runs the
255
- * handler's own checks on the resolved operation: a reference that lands
256
- * on an element the operation cannot act on fails the batch the same way
257
- * validation would have.
267
+ * Resolves a `"$<n>"` target, then checks every operation against the
268
+ * current document. Earlier operations may have moved, resized or removed
269
+ * an ordinary target since the batch's initial preflight.
258
270
  */
259
- #resolveReference(operation, operationIndex, createdByOperation, handler, context) {
271
+ #resolveAndValidate(operation, operationIndex, createdByOperation, handler, context) {
260
272
  const reference = referenceOf(operation);
261
- if (reference === undefined)
262
- return operation;
263
273
  const issues = [];
264
274
  const issue = issueCollector(operationIndex, issues);
265
- const id = createdByOperation[reference]?.[0];
266
- if (id === undefined) {
267
- issue("/target", "unknown-target", `Operation ${reference} created no element for "$${reference}"`);
268
- throw invalidOperationError(issues);
275
+ let resolved = operation;
276
+ if (reference !== undefined && "target" in operation) {
277
+ const id = createdByOperation[reference]?.[0];
278
+ if (id === undefined) {
279
+ issue("/target", "unknown-target", `Operation ${reference} created no element for "$${reference}"`);
280
+ throw invalidOperationError(issues);
281
+ }
282
+ resolved = { ...operation, target: id };
269
283
  }
270
- const resolved = { ...operation, target: id };
271
284
  handler.validate(resolved, context, issue);
272
285
  if (issues.length > 0)
273
286
  throw invalidOperationError(issues);
@@ -693,28 +706,71 @@ export class PdfEditDocument {
693
706
  }
694
707
  : undefined;
695
708
  }
696
- /** Loads a page, lets `use` change it, regenerates its content stream. */
709
+ /**
710
+ * Loads a page and lets `use` change it. The page stays loaded until the
711
+ * batch ends, so what the batch reads next sees the change; see `#batch`.
712
+ */
697
713
  #writePage(pageIndex, use) {
698
- const { lib } = this.#pdfium;
699
- const page = lib.FPDF_LoadPage(this.#document.handle, pageIndex);
700
- if (!page)
701
- throw new ViewerError("render-failed", "PDFium could not load the page", {
702
- details: { pageIndex },
703
- });
714
+ if (!this.#written)
715
+ return this.#batch(() => this.#writePage(pageIndex, use));
716
+ let page = this.#written.get(pageIndex);
717
+ if (page === undefined) {
718
+ page = this.#pdfium.lib.FPDF_LoadPage(this.#document.handle, pageIndex);
719
+ if (!page)
720
+ throw new ViewerError("render-failed", "PDFium could not load the page", { details: { pageIndex } });
721
+ this.#written.set(pageIndex, page);
722
+ }
723
+ // Objects are assigned before the change so appended ones follow them.
724
+ this.#objectsOf(pageIndex, page);
725
+ const result = use(page);
726
+ delete this.#pages[pageIndex].elements;
727
+ delete this.#pages[pageIndex].paragraphs;
728
+ return result;
729
+ }
730
+ /**
731
+ * Runs `run` with the pages it changes kept loaded, then regenerates each
732
+ * page's content stream once. PDFium rewrites a whole stream however
733
+ * little changed, which takes seconds on a page of thousands of objects,
734
+ * and object reads (text, bounds) need no regenerated content. A failing
735
+ * batch closes its pages unwritten: the caller restores the working copy.
736
+ */
737
+ #batch(run) {
738
+ if (this.#written)
739
+ return run();
740
+ const written = new Map();
741
+ this.#written = written;
704
742
  try {
705
- // Objects are assigned before the change so appended ones follow them.
706
- this.#objectsOf(pageIndex, page);
707
- const result = use(page);
708
- if (!lib.FPDFPage_GenerateContent(page))
709
- throw new ViewerError("edit-failed", "PDFium could not rewrite the page", {
710
- details: { stage: "apply", pageIndex },
711
- });
712
- delete this.#pages[pageIndex].elements;
713
- delete this.#pages[pageIndex].paragraphs;
743
+ const result = run();
744
+ this.#writeBack();
714
745
  return result;
715
746
  }
716
747
  finally {
717
- lib.FPDF_ClosePage(page);
748
+ this.#written = undefined;
749
+ for (const [pageIndex, page] of written) {
750
+ this.#pdfium.lib.FPDF_ClosePage(page);
751
+ delete this.#pages[pageIndex]?.elements;
752
+ delete this.#pages[pageIndex]?.paragraphs;
753
+ }
754
+ }
755
+ }
756
+ /** Regenerates and closes the pages the running batch changed. */
757
+ #writeBack() {
758
+ const written = this.#written;
759
+ if (!written)
760
+ return;
761
+ const { lib } = this.#pdfium;
762
+ for (const [pageIndex, page] of written) {
763
+ written.delete(pageIndex);
764
+ try {
765
+ if (!lib.FPDFPage_GenerateContent(page))
766
+ throw new ViewerError("edit-failed", "PDFium could not rewrite the page", { details: { stage: "apply", pageIndex } });
767
+ }
768
+ finally {
769
+ lib.FPDF_ClosePage(page);
770
+ }
771
+ // Elements are read again from the regenerated content.
772
+ delete this.#pages[pageIndex].elements;
773
+ delete this.#pages[pageIndex].paragraphs;
718
774
  }
719
775
  }
720
776
  #originalPages() {
@@ -888,12 +944,28 @@ export class PdfEditDocument {
888
944
  const records = this.#objectsOf(pageIndex, page);
889
945
  const { byObject, elements } = scanPage(this.#pdfium, page, textPage, pageIndex, geometry, records);
890
946
  const offsets = this.#charOffsets(page, textPage, byObject, new Map(elements.map((element) => [element.id, element.text ?? ""])));
891
- return use({ page, textPage, geometry, byObject, elements, offsets });
947
+ // A text box lays its lines out in the rect its mark keeps; only
948
+ // fresh marks are still on the records.
949
+ const frames = new Map();
950
+ for (const record of records)
951
+ if (record.mark?.kind === "textBox" && !frames.has(record.id))
952
+ frames.set(record.id, roundRect(record.mark.rect));
953
+ return use({
954
+ page,
955
+ textPage,
956
+ geometry,
957
+ byObject,
958
+ elements,
959
+ offsets,
960
+ frames,
961
+ });
892
962
  });
893
963
  }
894
964
  #withPage(pageIndex, use) {
895
965
  const { lib } = this.#pdfium;
896
- const page = lib.FPDF_LoadPage(this.#document.handle, pageIndex);
966
+ // A page the running batch changed is read as it stands in memory.
967
+ const written = this.#written?.get(pageIndex);
968
+ const page = written ?? lib.FPDF_LoadPage(this.#document.handle, pageIndex);
897
969
  if (!page)
898
970
  throw new ViewerError("render-failed", "PDFium could not load the page", {
899
971
  details: { pageIndex },
@@ -908,7 +980,8 @@ export class PdfEditDocument {
908
980
  }
909
981
  }
910
982
  finally {
911
- lib.FPDF_ClosePage(page);
983
+ if (written === undefined)
984
+ lib.FPDF_ClosePage(page);
912
985
  }
913
986
  }
914
987
  *#matches(textPage, query, options) {
@@ -12,6 +12,8 @@ export interface TextPageScan {
12
12
  readonly elements: readonly PdfElement[];
13
13
  /** Per character of the text page: its element and offset, when an object draws it. */
14
14
  readonly offsets: readonly (TextPosition | undefined)[];
15
+ /** Element id → the rectangle its text is laid out in, for text boxes. */
16
+ readonly frames?: ReadonlyMap<string, PageRect>;
15
17
  }
16
18
  /** The layout of one element, or `undefined` when it draws no text. */
17
19
  export declare function layoutOf(pdfium: Pdfium, scan: TextPageScan, element: PdfElement): TextLayout | undefined;
@@ -8,10 +8,12 @@ export function layoutOf(pdfium, scan, element) {
8
8
  if (!TEXT_KINDS.has(element.kind))
9
9
  return undefined;
10
10
  const glyphs = glyphsOf(pdfium, scan, (position) => position.elementId === element.id);
11
+ const lines = linesOf(pdfium, scan, glyphs);
11
12
  return {
12
13
  elementId: element.id,
13
14
  pageIndex: element.pageIndex,
14
- lines: linesOf(pdfium, scan, glyphs),
15
+ frame: frameOf(scan, element, lines),
16
+ lines,
15
17
  };
16
18
  }
17
19
  /** The layouts of every text element of the page, in reading order, from one pass. */
@@ -30,14 +32,30 @@ export function layoutsOf(pdfium, scan) {
30
32
  const own = byElement.get(element.id);
31
33
  if (!own || !TEXT_KINDS.has(element.kind))
32
34
  continue;
35
+ const lines = linesOf(pdfium, scan, own);
33
36
  layouts.push({
34
37
  elementId: element.id,
35
38
  pageIndex: element.pageIndex,
36
- lines: linesOf(pdfium, scan, own),
39
+ frame: frameOf(scan, element, lines),
40
+ lines,
37
41
  });
38
42
  }
39
43
  return layouts;
40
44
  }
45
+ /**
46
+ * The box an element's text is laid out in: a paragraph's frame (its bounds
47
+ * already start at the pen and span its widest advance), a text box's own
48
+ * rect, else the union of the lines' advance boxes.
49
+ */
50
+ function frameOf(scan, element, lines) {
51
+ if (element.kind === "paragraph")
52
+ return element.bounds;
53
+ const own = scan.frames?.get(element.id);
54
+ if (own)
55
+ return own;
56
+ const boxes = lines.flatMap((line) => line.advanceBounds ? [line.advanceBounds] : []);
57
+ return boxes.length > 0 ? roundRect(unionRects(boxes)) : element.bounds;
58
+ }
41
59
  /** The caret position nearest to a page-space point, or none without text. */
42
60
  export function positionIn(pdfium, scan, point) {
43
61
  const glyphs = glyphsOf(pdfium, scan, () => true);
@@ -105,6 +123,7 @@ function glyphsOf(pdfium, scan, keep) {
105
123
  position,
106
124
  char: String.fromCodePoint(lib.FPDFText_GetUnicode(textPage, index)),
107
125
  box: roundRect(userRectToPage(geometry, left, bottom, right, top)),
126
+ loose: roundRect(userRectToPage(geometry, looseLeft, looseBottom, looseRight, looseTop)),
108
127
  advance: round(Math.abs(looseRight - looseLeft)),
109
128
  origin: roundPoint(userToPage(geometry, origin[0], origin[1])),
110
129
  });
@@ -136,11 +155,15 @@ function linesOf(pdfium, scan, glyphs) {
136
155
  },
137
156
  text: members.map((glyph) => glyph.char).join(""),
138
157
  bounds: roundRect(unionRects(members.map((glyph) => glyph.box))),
158
+ // PDFium's loose boxes run from each origin to origin plus advance,
159
+ // ascent to descent: their union is the line a text field must hold.
160
+ advanceBounds: roundRect(unionRects(members.map((glyph) => glyph.loose))),
139
161
  baseline: members[0].origin,
140
- glyphs: members.map(({ position, box, advance }) => ({
162
+ glyphs: members.map(({ position, box, advance, origin }) => ({
141
163
  offset: position.offset,
142
164
  box,
143
165
  advance,
166
+ origin,
144
167
  })),
145
168
  fontFamily: style.fontFamily,
146
169
  fontSize: style.fontSize,
@@ -68,4 +68,4 @@ export declare function placeUpright(pdfium: Pdfium, object: number, geometry: P
68
68
  export declare function setText(pdfium: Pdfium, object: number, text: string): void;
69
69
  export declare function parseColor(color: string): [number, number, number];
70
70
  export declare function resolveStyle(style: PdfTextBoxStyle): TextBoxSpec["style"];
71
- export declare function validateRect(rect: PageRect, geometry: PageGeometry, issue: Issue, path?: string): void;
71
+ export declare function validateRect(rect: PageRect, geometry: PageGeometry, issue: Issue, path?: string, current?: PageRect): void;
@@ -319,12 +319,27 @@ export function resolveStyle(style) {
319
319
  function definedFields(style) {
320
320
  return Object.fromEntries(Object.entries(style).filter(([, value]) => value !== undefined));
321
321
  }
322
- export function validateRect(rect, geometry, issue, path = "/rect") {
322
+ export function validateRect(rect, geometry, issue, path = "/rect", current) {
323
323
  const size = displayedSize(geometry);
324
324
  const tolerance = 0.01;
325
- if (rect.x < -tolerance ||
326
- rect.y < -tolerance ||
327
- rect.x + rect.width > size.width + tolerance ||
328
- rect.y + rect.height > size.height + tolerance)
329
- issue(path, "range", `The rectangle must lie within the ${size.width}×${size.height} pt page`);
325
+ // Imported objects may already bleed outside the visible crop. Transforms
326
+ // can keep or reduce that overflow; the page tolerance must not accumulate
327
+ // on their current edges and permit repeated small outward steps.
328
+ const left = Math.min(-tolerance, current?.x ?? 0);
329
+ const top = Math.min(-tolerance, current?.y ?? 0);
330
+ const right = Math.max(size.width + tolerance, current ? current.x + current.width : 0);
331
+ const bottom = Math.max(size.height + tolerance, current ? current.y + current.height : 0);
332
+ const missesPage = current !== undefined &&
333
+ (rect.x >= size.width ||
334
+ rect.y >= size.height ||
335
+ rect.x + rect.width <= 0 ||
336
+ rect.y + rect.height <= 0);
337
+ if (rect.x < left ||
338
+ rect.y < top ||
339
+ rect.x + rect.width > right ||
340
+ rect.y + rect.height > bottom ||
341
+ missesPage)
342
+ issue(path, "range", current
343
+ ? `The rectangle must preserve or reduce existing overflow and remain partly visible within the ${size.width}×${size.height} pt page`
344
+ : `The rectangle must lie within the ${size.width}×${size.height} pt page`);
330
345
  }
@@ -20,7 +20,7 @@ export const moveElement = {
20
20
  return;
21
21
  }
22
22
  const delta = moveDelta(operation, target.element.bounds);
23
- validateRect(shifted(target.element.bounds, delta.dx, delta.dy), context.geometry(target.location.pageIndex), issue, operation.to ? "/to" : "/by");
23
+ validateRect(shifted(target.element.bounds, delta.dx, delta.dy), context.geometry(target.location.pageIndex), issue, operation.to ? "/to" : "/by", target.element.bounds);
24
24
  },
25
25
  apply(operation, context) {
26
26
  const { location, element } = anyTarget(operation.target, context);
@@ -61,7 +61,7 @@ export const resizeElement = {
61
61
  issue("/target", "unsupported-target", "Tables and imported paragraphs cannot be resized");
62
62
  return;
63
63
  }
64
- validateRect(operation.rect, context.geometry(target.location.pageIndex), issue);
64
+ validateRect(operation.rect, context.geometry(target.location.pageIndex), issue, "/rect", target.element.bounds);
65
65
  },
66
66
  apply(operation, context) {
67
67
  const { location, element } = anyTarget(operation.target, context);
@@ -255,6 +255,12 @@ export interface TextLayoutGlyph {
255
255
  readonly box: PageRect;
256
256
  /** Advance width along the baseline, in points. */
257
257
  readonly advance: number;
258
+ /**
259
+ * Pen position on the baseline where the glyph starts, in page space. The
260
+ * steps between neighbours are the advances the file draws with, character
261
+ * and word spacing and TJ kerning included.
262
+ */
263
+ readonly origin?: PagePoint;
258
264
  }
259
265
  /** One line of a text element: one PDFium text object, as the file stores it. */
260
266
  export interface TextLayoutLine {
@@ -263,6 +269,15 @@ export interface TextLayoutLine {
263
269
  readonly text: string;
264
270
  /** Union of the glyph boxes, in page space. */
265
271
  readonly bounds: PageRect;
272
+ /**
273
+ * The box the line's advances fill, the size a text field needs to hold the
274
+ * line unwrapped: from the first glyph's origin to the last glyph's origin
275
+ * plus its advance, and from the font's ascent to its descent at the line's
276
+ * size. It differs from `bounds` by the glyphs' side bearings, usually
277
+ * wider; in page space and axis-aligned like `bounds`, so it turns with the
278
+ * text.
279
+ */
280
+ readonly advanceBounds?: PageRect;
266
281
  /** Start of the baseline, in page space. */
267
282
  readonly baseline: PagePoint;
268
283
  readonly glyphs: readonly TextLayoutGlyph[];
@@ -305,10 +320,17 @@ export interface TextFont {
305
320
  */
306
321
  readonly missing?: "not-embedded" | "cid-keyed" | "type1" | "no-unicode" | "unreadable";
307
322
  }
308
- /** The drawn geometry of a `text`, `textBox` or `table` element. */
323
+ /** The drawn geometry of a `text`, `textBox`, `paragraph` or `table` element. */
309
324
  export interface TextLayout {
310
325
  readonly elementId: string;
311
326
  readonly pageIndex: number;
327
+ /**
328
+ * The box the element's text is laid out in, in page space: a text box's or
329
+ * a paragraph's own rectangle, otherwise the union of its lines'
330
+ * `advanceBounds`. Place an inline text field here rather than on the ink
331
+ * `bounds`, which a browser's advance-based layout outgrows.
332
+ */
333
+ readonly frame?: PageRect;
312
334
  /** Lines in reading order: a text box's lines, a table's cells. */
313
335
  readonly lines: readonly TextLayoutLine[];
314
336
  }
@@ -6,6 +6,11 @@ import { buildToolSet, callTool as runTool } from "./ai/tools.js";
6
6
  import { assetIdOf, AssetStore, binaryFields, isAssetReference, } from "./assets.js";
7
7
  import { batchOf, EditHistory, modeOf } from "./history.js";
8
8
  import { assertBatchSize, checkOperations, freezeOperations, invalidOperationError, parseReference, } from "./operations.js";
9
+ /**
10
+ * A state the engine would take at least this long to rebuild by replaying
11
+ * batches keeps its bytes, so undo, redo and recovery reopen it instead.
12
+ */
13
+ const SLOW_REPLAY_MS = 1000;
9
14
  /**
10
15
  * The format-independent editing session: validation, history, revisions,
11
16
  * saving and the viewer refresh. Format modules wrap it to add typed methods.
@@ -26,6 +31,8 @@ export class EditSessionController {
26
31
  /** Materialized bytes of some committed states, by state id, so restores replay less. */
27
32
  #checkpoints = new Map();
28
33
  #checkpointBytes = 0;
34
+ /** Engine time each reachable state took to build from the state before it. */
35
+ #buildMs = new Map();
29
36
  /** Named checkpoints by id, in creation order. */
30
37
  #named = new Map();
31
38
  /** State ids named checkpoints pin, with how many name each; never evicted. */
@@ -106,10 +113,12 @@ export class EditSessionController {
106
113
  });
107
114
  }
108
115
  const before = this.#history.pageCount;
109
- const { change, shown } = await this.#transaction(signal, "apply", async () => {
116
+ const { change, shown, buildMs } = await this.#transaction(signal, "apply", async () => {
117
+ const started = performance.now();
110
118
  const result = await this.#engine.apply(engineBatch, signal);
111
119
  return {
112
120
  change: result,
121
+ buildMs: performance.now() - started,
113
122
  shown: await this.#show(signal, this.#pagesOf(result.changedPages, result.reflowFrom)),
114
123
  };
115
124
  });
@@ -126,7 +135,7 @@ export class EditSessionController {
126
135
  pageCountBefore: before,
127
136
  pageCountAfter: shown.pageCount,
128
137
  });
129
- this.#commit("apply", shown.changedPages, shown);
138
+ this.#commit("apply", shown.changedPages, shown, buildMs);
130
139
  return this.#receipt(false, batch.length, change.createdIds, {
131
140
  ...change,
132
141
  changedPages: shown.changedPages,
@@ -293,8 +302,11 @@ export class EditSessionController {
293
302
  return this.#noop();
294
303
  const before = this.#history.pageCount;
295
304
  const changedPages = allPages(Math.max(before, named.pageCount));
305
+ let buildMs = 0;
296
306
  const shown = await this.#transaction(signal, "apply", async () => {
307
+ const started = performance.now();
297
308
  await this.#engine.restore(this.#targetFor(named.entries), signal);
309
+ buildMs = performance.now() - started;
298
310
  return this.#show(signal, changedPages);
299
311
  });
300
312
  const diff = entryDiff(this.#history.entriesAt(this.#history.position), named.entries);
@@ -307,7 +319,7 @@ export class EditSessionController {
307
319
  pageCountAfter: shown.pageCount,
308
320
  base: { stateId: named.stateId, batches: named.batches },
309
321
  }, named.stateId);
310
- this.#commit("restore", shown.changedPages, shown);
322
+ this.#commit("restore", shown.changedPages, shown, buildMs);
311
323
  return this.#receipt(false, 0, diff.created, {
312
324
  removedIds: diff.removed,
313
325
  changedPages: shown.changedPages,
@@ -509,10 +521,11 @@ export class EditSessionController {
509
521
  clearTimeout(timer);
510
522
  }
511
523
  }
512
- #commit(reason, changedPages, shown) {
524
+ /** `buildMs`: the engine time the new state took, for an apply or a restore. */
525
+ #commit(reason, changedPages, shown, buildMs = 0) {
513
526
  this.#committedBytes = shown.bytes;
514
527
  if (reason === "apply" || reason === "restore")
515
- this.#keepCheckpoint(shown.bytes);
528
+ this.#keepCheckpoint(shown.bytes, buildMs);
516
529
  else if (reason === "reset")
517
530
  this.#dropCheckpoints();
518
531
  this.#revision += 1;
@@ -595,8 +608,11 @@ export class EditSessionController {
595
608
  throw invalidOperationError(issues);
596
609
  return Object.freeze(result);
597
610
  }
598
- /** Keeps every stride-th committed state's bytes within the memory budget. */
599
- #keepCheckpoint(bytes) {
611
+ /**
612
+ * Keeps a committed state's bytes, within the memory budget, every
613
+ * stride-th state and whenever replaying to it would take SLOW_REPLAY_MS.
614
+ */
615
+ #keepCheckpoint(bytes, buildMs) {
600
616
  const stride = Math.max(1, Math.floor(this.#host.limits.maxEditHistory / 4));
601
617
  const stateId = this.#history.stateId;
602
618
  // Entries dropped by a new change after an undo can never be restored;
@@ -605,11 +621,32 @@ export class EditSessionController {
605
621
  for (const [id, kept] of this.#checkpoints)
606
622
  if (!reachable.has(id) && !this.#pinned.has(id))
607
623
  this.#forgetCheckpoint(id, kept);
608
- if (stateId === 0 ||
609
- this.#checkpoints.has(stateId) ||
610
- stateId % stride !== 0)
624
+ for (const id of this.#buildMs.keys())
625
+ if (!reachable.has(id))
626
+ this.#buildMs.delete(id);
627
+ if (stateId === 0 || this.#checkpoints.has(stateId))
611
628
  return;
612
- this.#retain(stateId, bytes);
629
+ this.#buildMs.set(stateId, buildMs);
630
+ if (stateId % stride === 0 || this.#replayMs() >= SLOW_REPLAY_MS)
631
+ this.#retain(stateId, bytes);
632
+ }
633
+ /**
634
+ * The engine time a restore of the current state would spend replaying:
635
+ * what its entries took since the newest retained bytes, or since the
636
+ * original or a restore entry's rebuild.
637
+ */
638
+ #replayMs() {
639
+ const entries = this.#history.entriesAt(this.#history.position);
640
+ let total = 0;
641
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
642
+ const entry = entries[index];
643
+ if (this.#checkpoints.has(entry.stateId))
644
+ break;
645
+ total += this.#buildMs.get(entry.stateId) ?? 0;
646
+ if (entry.base)
647
+ break;
648
+ }
649
+ return total;
613
650
  }
614
651
  /** Stores a state's bytes when the budget, less the pinned states, can hold them. */
615
652
  #retain(stateId, bytes) {
@@ -6759,10 +6759,12 @@ function layoutOf(pdfium, scan, element) {
6759
6759
  scan,
6760
6760
  (position) => position.elementId === element.id
6761
6761
  );
6762
+ const lines = linesOf(pdfium, scan, glyphs);
6762
6763
  return {
6763
6764
  elementId: element.id,
6764
6765
  pageIndex: element.pageIndex,
6765
- lines: linesOf(pdfium, scan, glyphs)
6766
+ frame: frameOf(scan, element, lines),
6767
+ lines
6766
6768
  };
6767
6769
  }
6768
6770
  function layoutsOf(pdfium, scan) {
@@ -6777,14 +6779,25 @@ function layoutsOf(pdfium, scan) {
6777
6779
  for (const element of scan.elements) {
6778
6780
  const own = byElement.get(element.id);
6779
6781
  if (!own || !TEXT_KINDS.has(element.kind)) continue;
6782
+ const lines = linesOf(pdfium, scan, own);
6780
6783
  layouts.push({
6781
6784
  elementId: element.id,
6782
6785
  pageIndex: element.pageIndex,
6783
- lines: linesOf(pdfium, scan, own)
6786
+ frame: frameOf(scan, element, lines),
6787
+ lines
6784
6788
  });
6785
6789
  }
6786
6790
  return layouts;
6787
6791
  }
6792
+ function frameOf(scan, element, lines) {
6793
+ if (element.kind === "paragraph") return element.bounds;
6794
+ const own = scan.frames?.get(element.id);
6795
+ if (own) return own;
6796
+ const boxes = lines.flatMap(
6797
+ (line) => line.advanceBounds ? [line.advanceBounds] : []
6798
+ );
6799
+ return boxes.length > 0 ? roundRect(unionRects(boxes)) : element.bounds;
6800
+ }
6788
6801
  function positionIn(pdfium, scan, point) {
6789
6802
  const glyphs = glyphsOf(pdfium, scan, () => true);
6790
6803
  if (glyphs.length === 0) return void 0;
@@ -6859,6 +6872,9 @@ function glyphsOf(pdfium, scan, keep) {
6859
6872
  position,
6860
6873
  char: String.fromCodePoint(lib.FPDFText_GetUnicode(textPage, index)),
6861
6874
  box: roundRect(userRectToPage(geometry, left, bottom, right, top)),
6875
+ loose: roundRect(
6876
+ userRectToPage(geometry, looseLeft, looseBottom, looseRight, looseTop)
6877
+ ),
6862
6878
  advance: round(Math.abs(looseRight - looseLeft)),
6863
6879
  origin: roundPoint(userToPage(geometry, origin[0], origin[1]))
6864
6880
  });
@@ -6886,11 +6902,15 @@ function linesOf(pdfium, scan, glyphs) {
6886
6902
  },
6887
6903
  text: members.map((glyph) => glyph.char).join(""),
6888
6904
  bounds: roundRect(unionRects(members.map((glyph) => glyph.box))),
6905
+ // PDFium's loose boxes run from each origin to origin plus advance,
6906
+ // ascent to descent: their union is the line a text field must hold.
6907
+ advanceBounds: roundRect(unionRects(members.map((glyph) => glyph.loose))),
6889
6908
  baseline: members[0].origin,
6890
- glyphs: members.map(({ position, box, advance: advance2 }) => ({
6909
+ glyphs: members.map(({ position, box, advance: advance2, origin }) => ({
6891
6910
  offset: position.offset,
6892
6911
  box,
6893
- advance: advance2
6912
+ advance: advance2,
6913
+ origin
6894
6914
  })),
6895
6915
  fontFamily: style.fontFamily,
6896
6916
  fontSize: style.fontSize,
@@ -9069,14 +9089,25 @@ function definedFields(style) {
9069
9089
  Object.entries(style).filter(([, value]) => value !== void 0)
9070
9090
  );
9071
9091
  }
9072
- function validateRect(rect, geometry, issue, path = "/rect") {
9092
+ function validateRect(rect, geometry, issue, path = "/rect", current) {
9073
9093
  const size = displayedSize(geometry);
9074
9094
  const tolerance = 0.01;
9075
- if (rect.x < -tolerance || rect.y < -tolerance || rect.x + rect.width > size.width + tolerance || rect.y + rect.height > size.height + tolerance)
9095
+ const left = Math.min(-tolerance, current?.x ?? 0);
9096
+ const top = Math.min(-tolerance, current?.y ?? 0);
9097
+ const right = Math.max(
9098
+ size.width + tolerance,
9099
+ current ? current.x + current.width : 0
9100
+ );
9101
+ const bottom = Math.max(
9102
+ size.height + tolerance,
9103
+ current ? current.y + current.height : 0
9104
+ );
9105
+ const missesPage = current !== void 0 && (rect.x >= size.width || rect.y >= size.height || rect.x + rect.width <= 0 || rect.y + rect.height <= 0);
9106
+ if (rect.x < left || rect.y < top || rect.x + rect.width > right || rect.y + rect.height > bottom || missesPage)
9076
9107
  issue(
9077
9108
  path,
9078
9109
  "range",
9079
- `The rectangle must lie within the ${size.width}\xD7${size.height} pt page`
9110
+ current ? `The rectangle must preserve or reduce existing overflow and remain partly visible within the ${size.width}\xD7${size.height} pt page` : `The rectangle must lie within the ${size.width}\xD7${size.height} pt page`
9080
9111
  );
9081
9112
  }
9082
9113
 
@@ -10277,7 +10308,8 @@ var moveElement = {
10277
10308
  shifted(target.element.bounds, delta.dx, delta.dy),
10278
10309
  context.geometry(target.location.pageIndex),
10279
10310
  issue,
10280
- operation2.to ? "/to" : "/by"
10311
+ operation2.to ? "/to" : "/by",
10312
+ target.element.bounds
10281
10313
  );
10282
10314
  },
10283
10315
  apply(operation2, context) {
@@ -10330,7 +10362,9 @@ var resizeElement = {
10330
10362
  validateRect(
10331
10363
  operation2.rect,
10332
10364
  context.geometry(target.location.pageIndex),
10333
- issue
10365
+ issue,
10366
+ "/rect",
10367
+ target.element.bounds
10334
10368
  );
10335
10369
  },
10336
10370
  apply(operation2, context) {
@@ -11422,6 +11456,11 @@ var CREATING_OPERATIONS = /* @__PURE__ */ new Set([
11422
11456
  "insertImage",
11423
11457
  "insertTable"
11424
11458
  ]);
11459
+ var PAGE_ORDER_OPERATIONS = /* @__PURE__ */ new Set([
11460
+ "insertPage",
11461
+ "deletePage",
11462
+ "movePage"
11463
+ ]);
11425
11464
  function referenceOf(operation2) {
11426
11465
  const target = operation2.target;
11427
11466
  return typeof target === "string" ? parseReference(target) : void 0;
@@ -11464,6 +11503,8 @@ var PdfEditDocument = class {
11464
11503
  #batches = 0;
11465
11504
  /** Browser faces of the document's fonts, by font program; see `textFont`. */
11466
11505
  #faces = /* @__PURE__ */ new Map();
11506
+ /** Pages the running batch changed, by index; see `#batch`. */
11507
+ #written;
11467
11508
  constructor(pdfium, original, fonts = new FontLibrary(async () => {
11468
11509
  throw new Error("No font source is configured");
11469
11510
  }), limits = defaultResourceLimits, assets = new AssetStore(), compact = compactPdf) {
@@ -11506,9 +11547,9 @@ var PdfEditDocument = class {
11506
11547
  return this.#pdfium;
11507
11548
  }
11508
11549
  /**
11509
- * Checks a batch against the current document. Operations are checked one
11510
- * after another against the state before the batch, which is exact for
11511
- * everything but a page count another operation of the batch changes.
11550
+ * Preflights a batch against its starting document. Applying revalidates
11551
+ * each operation against earlier changes in the batch; created-element
11552
+ * references are checked once their targets exist.
11512
11553
  */
11513
11554
  validate(operations) {
11514
11555
  const issues = [];
@@ -11545,6 +11586,9 @@ var PdfEditDocument = class {
11545
11586
  * the next batch number as their state id.
11546
11587
  */
11547
11588
  apply(input) {
11589
+ return this.#batch(() => this.#apply(input));
11590
+ }
11591
+ #apply(input) {
11548
11592
  const batch = Array.isArray(input) ? { stateId: this.#batches + 1, operations: input } : input;
11549
11593
  const { stateId, operations } = batch;
11550
11594
  const createdIds = [];
@@ -11560,11 +11604,12 @@ var PdfEditDocument = class {
11560
11604
  details: { signatures: this.#signatures, features: this.#features }
11561
11605
  });
11562
11606
  operations.forEach((raw, operationIndex) => {
11607
+ if (PAGE_ORDER_OPERATIONS.has(raw.op)) this.#writeBack();
11563
11608
  const handler = handlers[raw.op];
11564
11609
  if (!handler)
11565
11610
  throw new ViewerError("internal", `Unknown pdf operation ${raw.op}`);
11566
11611
  const context = this.#context(stateId, operationIndex);
11567
- const operation2 = this.#resolveReference(
11612
+ const operation2 = this.#resolveAndValidate(
11568
11613
  raw,
11569
11614
  operationIndex,
11570
11615
  createdByOperation,
@@ -11591,26 +11636,27 @@ var PdfEditDocument = class {
11591
11636
  };
11592
11637
  }
11593
11638
  /**
11594
- * Turns a `"$<n>"` target into the id operation `n` created, then runs the
11595
- * handler's own checks on the resolved operation: a reference that lands
11596
- * on an element the operation cannot act on fails the batch the same way
11597
- * validation would have.
11639
+ * Resolves a `"$<n>"` target, then checks every operation against the
11640
+ * current document. Earlier operations may have moved, resized or removed
11641
+ * an ordinary target since the batch's initial preflight.
11598
11642
  */
11599
- #resolveReference(operation2, operationIndex, createdByOperation, handler, context) {
11643
+ #resolveAndValidate(operation2, operationIndex, createdByOperation, handler, context) {
11600
11644
  const reference = referenceOf(operation2);
11601
- if (reference === void 0) return operation2;
11602
11645
  const issues = [];
11603
11646
  const issue = issueCollector(operationIndex, issues);
11604
- const id = createdByOperation[reference]?.[0];
11605
- if (id === void 0) {
11606
- issue(
11607
- "/target",
11608
- "unknown-target",
11609
- `Operation ${reference} created no element for "$${reference}"`
11610
- );
11611
- throw invalidOperationError(issues);
11647
+ let resolved = operation2;
11648
+ if (reference !== void 0 && "target" in operation2) {
11649
+ const id = createdByOperation[reference]?.[0];
11650
+ if (id === void 0) {
11651
+ issue(
11652
+ "/target",
11653
+ "unknown-target",
11654
+ `Operation ${reference} created no element for "$${reference}"`
11655
+ );
11656
+ throw invalidOperationError(issues);
11657
+ }
11658
+ resolved = { ...operation2, target: id };
11612
11659
  }
11613
- const resolved = { ...operation2, target: id };
11614
11660
  handler.validate(resolved, context, issue);
11615
11661
  if (issues.length > 0) throw invalidOperationError(issues);
11616
11662
  return resolved;
@@ -12030,30 +12076,73 @@ var PdfEditDocument = class {
12030
12076
  record: { ...first, id, mark: paragraph.spec }
12031
12077
  } : void 0;
12032
12078
  }
12033
- /** Loads a page, lets `use` change it, regenerates its content stream. */
12079
+ /**
12080
+ * Loads a page and lets `use` change it. The page stays loaded until the
12081
+ * batch ends, so what the batch reads next sees the change; see `#batch`.
12082
+ */
12034
12083
  #writePage(pageIndex, use) {
12035
- const { lib } = this.#pdfium;
12036
- const page = lib.FPDF_LoadPage(this.#document.handle, pageIndex);
12037
- if (!page)
12038
- throw new ViewerError("render-failed", "PDFium could not load the page", {
12039
- details: { pageIndex }
12040
- });
12041
- try {
12042
- this.#objectsOf(pageIndex, page);
12043
- const result3 = use(page);
12044
- if (!lib.FPDFPage_GenerateContent(page))
12084
+ if (!this.#written)
12085
+ return this.#batch(() => this.#writePage(pageIndex, use));
12086
+ let page = this.#written.get(pageIndex);
12087
+ if (page === void 0) {
12088
+ page = this.#pdfium.lib.FPDF_LoadPage(this.#document.handle, pageIndex);
12089
+ if (!page)
12045
12090
  throw new ViewerError(
12046
- "edit-failed",
12047
- "PDFium could not rewrite the page",
12048
- {
12049
- details: { stage: "apply", pageIndex }
12050
- }
12091
+ "render-failed",
12092
+ "PDFium could not load the page",
12093
+ { details: { pageIndex } }
12051
12094
  );
12052
- delete this.#pages[pageIndex].elements;
12053
- delete this.#pages[pageIndex].paragraphs;
12095
+ this.#written.set(pageIndex, page);
12096
+ }
12097
+ this.#objectsOf(pageIndex, page);
12098
+ const result3 = use(page);
12099
+ delete this.#pages[pageIndex].elements;
12100
+ delete this.#pages[pageIndex].paragraphs;
12101
+ return result3;
12102
+ }
12103
+ /**
12104
+ * Runs `run` with the pages it changes kept loaded, then regenerates each
12105
+ * page's content stream once. PDFium rewrites a whole stream however
12106
+ * little changed, which takes seconds on a page of thousands of objects,
12107
+ * and object reads (text, bounds) need no regenerated content. A failing
12108
+ * batch closes its pages unwritten: the caller restores the working copy.
12109
+ */
12110
+ #batch(run2) {
12111
+ if (this.#written) return run2();
12112
+ const written = /* @__PURE__ */ new Map();
12113
+ this.#written = written;
12114
+ try {
12115
+ const result3 = run2();
12116
+ this.#writeBack();
12054
12117
  return result3;
12055
12118
  } finally {
12056
- lib.FPDF_ClosePage(page);
12119
+ this.#written = void 0;
12120
+ for (const [pageIndex, page] of written) {
12121
+ this.#pdfium.lib.FPDF_ClosePage(page);
12122
+ delete this.#pages[pageIndex]?.elements;
12123
+ delete this.#pages[pageIndex]?.paragraphs;
12124
+ }
12125
+ }
12126
+ }
12127
+ /** Regenerates and closes the pages the running batch changed. */
12128
+ #writeBack() {
12129
+ const written = this.#written;
12130
+ if (!written) return;
12131
+ const { lib } = this.#pdfium;
12132
+ for (const [pageIndex, page] of written) {
12133
+ written.delete(pageIndex);
12134
+ try {
12135
+ if (!lib.FPDFPage_GenerateContent(page))
12136
+ throw new ViewerError(
12137
+ "edit-failed",
12138
+ "PDFium could not rewrite the page",
12139
+ { details: { stage: "apply", pageIndex } }
12140
+ );
12141
+ } finally {
12142
+ lib.FPDF_ClosePage(page);
12143
+ }
12144
+ delete this.#pages[pageIndex].elements;
12145
+ delete this.#pages[pageIndex].paragraphs;
12057
12146
  }
12058
12147
  }
12059
12148
  #originalPages() {
@@ -12244,12 +12333,25 @@ var PdfEditDocument = class {
12244
12333
  byObject,
12245
12334
  new Map(elements.map((element) => [element.id, element.text ?? ""]))
12246
12335
  );
12247
- return use({ page, textPage, geometry, byObject, elements, offsets });
12336
+ const frames = /* @__PURE__ */ new Map();
12337
+ for (const record of records)
12338
+ if (record.mark?.kind === "textBox" && !frames.has(record.id))
12339
+ frames.set(record.id, roundRect(record.mark.rect));
12340
+ return use({
12341
+ page,
12342
+ textPage,
12343
+ geometry,
12344
+ byObject,
12345
+ elements,
12346
+ offsets,
12347
+ frames
12348
+ });
12248
12349
  });
12249
12350
  }
12250
12351
  #withPage(pageIndex, use) {
12251
12352
  const { lib } = this.#pdfium;
12252
- const page = lib.FPDF_LoadPage(this.#document.handle, pageIndex);
12353
+ const written = this.#written?.get(pageIndex);
12354
+ const page = written ?? lib.FPDF_LoadPage(this.#document.handle, pageIndex);
12253
12355
  if (!page)
12254
12356
  throw new ViewerError("render-failed", "PDFium could not load the page", {
12255
12357
  details: { pageIndex }
@@ -12262,7 +12364,7 @@ var PdfEditDocument = class {
12262
12364
  lib.FPDFText_ClosePage(textPage);
12263
12365
  }
12264
12366
  } finally {
12265
- lib.FPDF_ClosePage(page);
12367
+ if (written === void 0) lib.FPDF_ClosePage(page);
12266
12368
  }
12267
12369
  }
12268
12370
  *#matches(textPage, query, options) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "web-doc",
3
- "version": "0.12.1",
3
+ "version": "0.13.1",
4
4
  "description": "web-doc — embeddable browser-only document viewer with Rust/WASM adapters (a fork of Zrimo)",
5
5
  "keywords": [
6
6
  "document-viewer",