@muhammara/native-core 7.0.0-beta.3 → 7.0.0-rc.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.
Files changed (43) hide show
  1. package/README.md +57 -7
  2. package/THIRD_PARTY_NOTICES.md +39 -0
  3. package/index.js +120 -1
  4. package/lib/PDFRStreamForBuffer.js +52 -7
  5. package/lib/PDFRStreamForFile.js +56 -7
  6. package/lib/PDFStreamForResponse.js +22 -5
  7. package/lib/PDFWStreamForBuffer.js +49 -10
  8. package/lib/PDFWStreamForFile.js +26 -5
  9. package/lib/Recipe.js +86 -30
  10. package/lib/content-stream.js +503 -0
  11. package/lib/css-colors.js +149 -0
  12. package/lib/font-text.js +1461 -0
  13. package/lib/glyph-list.js +1127 -0
  14. package/lib/recipe/annotation.js +482 -105
  15. package/lib/recipe/appendPage.js +8 -1
  16. package/lib/recipe/colors.js +132 -35
  17. package/lib/recipe/coordinate.js +82 -16
  18. package/lib/recipe/encrypt.js +32 -13
  19. package/lib/recipe/font.js +70 -25
  20. package/lib/recipe/graphics-state.js +3 -2
  21. package/lib/recipe/htmlToTextObjects.js +154 -21
  22. package/lib/recipe/image.js +34 -9
  23. package/lib/recipe/info.js +93 -22
  24. package/lib/recipe/insertPage.js +12 -3
  25. package/lib/recipe/overlay.js +4 -2
  26. package/lib/recipe/page-references.js +436 -0
  27. package/lib/recipe/page.js +379 -275
  28. package/lib/recipe/parameters.js +11 -2
  29. package/lib/recipe/replaceText.js +464 -30
  30. package/lib/recipe/shapes.js +418 -45
  31. package/lib/recipe/split.js +6 -2
  32. package/lib/recipe/table.js +260 -173
  33. package/lib/recipe/text.helper.js +176 -10
  34. package/lib/recipe/text.js +420 -115
  35. package/lib/recipe/utils.js +66 -3
  36. package/lib/recipe/vector-line.js +101 -39
  37. package/lib/recipe/vector-polygon.js +18 -6
  38. package/lib/recipe/vector.helper.js +150 -13
  39. package/lib/recipe/vector.js +150 -53
  40. package/lib/recipe/xObjectForm.js +73 -10
  41. package/lib/recipe-constants.js +373 -0
  42. package/muhammara.d.ts +3487 -152
  43. package/package.json +1 -1
@@ -1,6 +1,40 @@
1
1
  const muhammara = require("../muhammara");
2
+ const { PAGE_CONTEXT_STATE } = require("./utils");
3
+ const { PageLayout, Colorspace } = require("../recipe-constants");
4
+ const {
5
+ planDeletedPageReferences,
6
+ pruneDeletedPageReferences,
7
+ setCatalogEntry,
8
+ } = require("./page-references");
2
9
 
3
- /** Builds the retained page tree while marking deleted leaf pages. @private */
10
+ // PDF dictionary keys and names the page-tree and page-label code reads.
11
+ const PdfName = Object.freeze({
12
+ PAGES: "Pages",
13
+ KIDS: "Kids",
14
+ COUNT: "Count",
15
+ PARENT: "Parent",
16
+ PAGE_LABELS: "PageLabels",
17
+ LIMITS: "Limits",
18
+ NUMS: "Nums",
19
+ });
20
+
21
+ /**
22
+ * Build the retained page tree while marking deleted leaf pages.
23
+ * @private
24
+ * @param {Object} parser - The source PDF parser.
25
+ * @param {number} objectID - The object ID of the Pages node to read.
26
+ * @param {Set<number>} deletedPages - One-based page numbers to delete.
27
+ * @param {Set<number>} modifiedPageIDs - Object IDs of pages edited in this Recipe.
28
+ * @param {Object} pageState - Running state: pageNumber, deletedPageIDs and
29
+ * retainedPageIDs, updated as leaves are visited.
30
+ * @param {number} [generation=0] - The generation of the node's reference.
31
+ * @param {Set<number>} [visited] - Pages nodes already read, to reject cycles.
32
+ * @param {number} [depth=0] - The nesting depth, limited to 1000.
33
+ * @returns {Object} The node: objectID, generation, values, retained
34
+ * children, retained page count and whether it changed.
35
+ * @throws {Error} If the page tree is cyclic, too deep or inconsistent, or a
36
+ * modified page has a nonzero generation.
37
+ */
4
38
  function readPageTree(
5
39
  parser,
6
40
  objectID,
@@ -20,11 +54,21 @@ function readPageTree(
20
54
  if (depth === 0 && values.Parent !== undefined) {
21
55
  throw new Error("deletePage requires a valid page tree");
22
56
  }
23
- const kids = parser.queryDictionaryObject(dictionary, "Kids").toPDFArray();
57
+ const kids = parser.queryDictionaryObject(dictionary, "Kids")?.toPDFArray();
58
+ if (!kids) {
59
+ throw new Error("deletePage requires a valid page tree");
60
+ }
24
61
  const mappedChildren = kids.toJSArray().map((entry) => {
62
+ // Kids must be references to page tree dictionaries with a /Type.
25
63
  const reference = entry.toPDFIndirectObjectReference();
26
- const childID = reference.getObjectID();
27
- const childDictionary = parser.parseNewObject(childID).toPDFDictionary();
64
+ const childID = reference?.getObjectID();
65
+ const childDictionary =
66
+ childID === undefined
67
+ ? null
68
+ : parser.parseNewObject(childID)?.toPDFDictionary();
69
+ if (!childDictionary) {
70
+ throw new Error("deletePage requires a valid page tree");
71
+ }
28
72
  const childValues = childDictionary.toJSObject();
29
73
  const parentReference = childValues.Parent?.toPDFIndirectObjectReference();
30
74
  if (
@@ -34,8 +78,11 @@ function readPageTree(
34
78
  ) {
35
79
  throw new Error("deletePage requires a valid page tree");
36
80
  }
37
- const type = childValues.Type.toPDFName().value;
38
- if (type === "Pages") {
81
+ const type = childValues.Type?.toPDFName()?.value;
82
+ if (type === undefined) {
83
+ throw new Error("deletePage requires a valid page tree");
84
+ }
85
+ if (type === PdfName.PAGES) {
39
86
  return readPageTree(
40
87
  parser,
41
88
  childID,
@@ -83,7 +130,15 @@ function readPageTree(
83
130
  };
84
131
  }
85
132
 
86
- /** Writes changed page-tree nodes back to the modified PDF. @private */
133
+ /**
134
+ * Write changed page-tree nodes back to the modified PDF with their new
135
+ * Count and Kids.
136
+ * @private
137
+ * @param {Object} writer - The PDF writer.
138
+ * @param {Object} copyingContext - Copies the unchanged entries.
139
+ * @param {Object} node - A node built by readPageTree().
140
+ * @returns {void}
141
+ */
87
142
  function writePageTree(writer, copyingContext, node) {
88
143
  node.children
89
144
  .filter((child) => child.children && child.changed)
@@ -94,13 +149,13 @@ function writePageTree(writer, copyingContext, node) {
94
149
  objectsContext.startModifiedIndirectObject(node.objectID);
95
150
  const dictionary = objectsContext.startDictionary();
96
151
  Object.keys(node.values).forEach((key) => {
97
- if (key === "Count" || key === "Kids") return;
152
+ if (key === PdfName.COUNT || key === PdfName.KIDS) return;
98
153
  dictionary.writeKey(key);
99
154
  copyingContext.copyDirectObjectAsIs(node.values[key]);
100
155
  });
101
- dictionary.writeKey("Count");
156
+ dictionary.writeKey(PdfName.COUNT);
102
157
  objectsContext.writeNumber(node.count);
103
- dictionary.writeKey("Kids");
158
+ dictionary.writeKey(PdfName.KIDS);
104
159
  objectsContext.startArray();
105
160
  node.children.forEach((child) => {
106
161
  objectsContext.writeIndirectObjectReference(
@@ -116,6 +171,10 @@ function writePageTree(writer, copyingContext, node) {
116
171
  * alike - depth-first. Shared by every deletePage() pass that needs to walk
117
172
  * the tree readPageTree() already built, instead of re-parsing or re-walking
118
173
  * it independently.
174
+ * @private
175
+ * @param {Object} tree - The root node built by readPageTree().
176
+ * @param {function(Object): void} visit - Called with every node.
177
+ * @returns {void}
119
178
  */
120
179
  function walkPageTree(tree, visit) {
121
180
  const pending = [tree];
@@ -126,15 +185,17 @@ function walkPageTree(tree, visit) {
126
185
  }
127
186
  }
128
187
 
129
- /** Adds retained Pages-node object IDs to a set. @private */
130
- function collectPageTreeObjectIDs(tree, objectIDs) {
131
- walkPageTree(tree, (node) => {
132
- if (node.children) objectIDs.add(node.objectID);
133
- });
134
- }
135
-
136
- /** Rejects page-tree objects that cannot be rewritten safely. @private */
137
- function assertSupportedModifiedGenerations(tree, pageLabels, root, writer) {
188
+ /**
189
+ * Reject page-tree and page-label objects that cannot be rewritten safely:
190
+ * changed objects must have generation 0. The catalog is never rewritten in
191
+ * place, so its generation does not matter.
192
+ * @private
193
+ * @param {Object} tree - The root node built by readPageTree().
194
+ * @param {Object|null} pageLabels - The result of preparePageLabels().
195
+ * @returns {void}
196
+ * @throws {Error} If a changed object has a nonzero generation.
197
+ */
198
+ function assertSupportedModifiedGenerations(tree, pageLabels) {
138
199
  walkPageTree(tree, (node) => {
139
200
  if (!node.children) return;
140
201
  if (node.changed && node.generation !== 0) {
@@ -148,162 +209,20 @@ function assertSupportedModifiedGenerations(tree, pageLabels, root, writer) {
148
209
  "deletePage does not support rewriting nonzero-generation objects",
149
210
  );
150
211
  }
151
- // A writer with _setPageLabelsObject() (wasm) always attaches a new
152
- // PageLabels entry through that dedicated hook and never rewrites the
153
- // catalog object in place, so the catalog's own generation never matters
154
- // there. Without it (native-core), writePageLabels() falls back to
155
- // rewriting the catalog via startModifiedIndirectObject(rootID), which
156
- // only supports generation 0.
157
- if (pageLabels && !pageLabels.reference && !writer._setPageLabelsObject) {
158
- if (root.getVersion() !== 0) {
159
- throw new Error(
160
- "deletePage does not support rewriting nonzero-generation objects",
161
- );
162
- }
163
- }
164
- }
165
-
166
- /** Rejects retained structures that reference a deleted page. @private */
167
- function assertNoDeletedPageReferences(
168
- parser,
169
- value,
170
- deletedPageIDs,
171
- skippedObjectIDs,
172
- visited = new Set(),
173
- depth = 0,
174
- ) {
175
- if (!value) return;
176
- if (depth > 1000) {
177
- throw new Error("deletePage cannot validate deeply nested references");
178
- }
179
- const reference = value.toPDFIndirectObjectReference?.();
180
- if (reference) {
181
- const objectID = reference.getObjectID();
182
- if (deletedPageIDs.has(objectID)) {
183
- throw new Error(
184
- "deletePage cannot remove a page referenced by retained document structures",
185
- );
186
- }
187
- if (skippedObjectIDs.has(objectID) || visited.has(objectID)) return;
188
- visited.add(objectID);
189
- assertNoDeletedPageReferences(
190
- parser,
191
- parser.parseNewObject(objectID),
192
- deletedPageIDs,
193
- skippedObjectIDs,
194
- visited,
195
- depth + 1,
196
- );
197
- return;
198
- }
199
- const array =
200
- value.toPDFArray?.() ||
201
- (typeof value.toJSArray === "function" ? value : null);
202
- if (array) {
203
- array
204
- .toJSArray()
205
- .forEach((entry) =>
206
- assertNoDeletedPageReferences(
207
- parser,
208
- entry,
209
- deletedPageIDs,
210
- skippedObjectIDs,
211
- visited,
212
- depth + 1,
213
- ),
214
- );
215
- return;
216
- }
217
- const dictionary =
218
- value.toPDFDictionary?.() ||
219
- (typeof value.toJSObject === "function" ? value : null);
220
- if (dictionary) {
221
- Object.values(dictionary.toJSObject()).forEach((entry) =>
222
- assertNoDeletedPageReferences(
223
- parser,
224
- entry,
225
- deletedPageIDs,
226
- skippedObjectIDs,
227
- visited,
228
- depth + 1,
229
- ),
230
- );
231
- return;
232
- }
233
- const stream = value.toPDFStream?.();
234
- if (stream) {
235
- assertNoDeletedPageReferences(
236
- parser,
237
- stream.getDictionary(),
238
- deletedPageIDs,
239
- skippedObjectIDs,
240
- visited,
241
- depth + 1,
242
- );
243
- }
244
- }
245
-
246
- /** Checks retained document structures for deleted-page references. @private */
247
- function validateDeletedPageReferences(
248
- parser,
249
- catalog,
250
- rootID,
251
- tree,
252
- pageLabels,
253
- deletedPageIDs,
254
- sourcePageCount,
255
- ) {
256
- const skippedObjectIDs = new Set([rootID]);
257
- collectPageTreeObjectIDs(tree, skippedObjectIDs);
258
- for (let pageIndex = 0; pageIndex < sourcePageCount; pageIndex += 1) {
259
- skippedObjectIDs.add(parser.getPageObjectID(pageIndex));
260
- }
261
- const visited = new Set();
262
- Object.entries(catalog)
263
- .filter(([key]) => !["PageLabels", "Pages"].includes(key))
264
- .forEach(([, value]) =>
265
- assertNoDeletedPageReferences(
266
- parser,
267
- value,
268
- deletedPageIDs,
269
- skippedObjectIDs,
270
- visited,
271
- ),
272
- );
273
- if (pageLabels) {
274
- Object.entries(pageLabels.values)
275
- .filter(([key]) => !["Kids", "Limits", "Nums"].includes(key))
276
- .forEach(([, value]) =>
277
- assertNoDeletedPageReferences(
278
- parser,
279
- value,
280
- deletedPageIDs,
281
- skippedObjectIDs,
282
- visited,
283
- ),
284
- );
285
- }
286
- // Walks the same tree readPageTree() already parsed - each retained leaf
287
- // carries the page dictionary values readPageTree() read, so there is no
288
- // need to re-parse every retained page's dictionary here.
289
- walkPageTree(tree, (node) => {
290
- const isLeaf = !node.children;
291
- const skipKeys = isLeaf ? ["Parent"] : ["Count", "Kids", "Parent"];
292
- Object.entries(node.values)
293
- .filter(([key]) => !skipKeys.includes(key))
294
- .forEach(([, value]) =>
295
- assertNoDeletedPageReferences(
296
- parser,
297
- value,
298
- deletedPageIDs,
299
- skippedObjectIDs,
300
- visited,
301
- ),
302
- );
303
- });
304
212
  }
305
213
 
306
- /** Collects page-label number-tree entries. @private */
214
+ /**
215
+ * Collect the entries of a page-label number tree.
216
+ * @private
217
+ * @param {Object} parser - The source PDF parser.
218
+ * @param {Object} dictionary - The number-tree node.
219
+ * @param {Object[]} entries - Receives {index, value} for every label.
220
+ * @param {Set<number>} [visited] - Nodes already read, to reject cycles.
221
+ * @param {number} [objectID=0] - The object ID of this node, if indirect.
222
+ * @param {number} [depth=0] - The nesting depth, limited to 1000.
223
+ * @returns {Object} The entries of this node's dictionary.
224
+ * @throws {Error} If the tree is cyclic, too deep or has invalid entries.
225
+ */
307
226
  function collectPageLabels(
308
227
  parser,
309
228
  dictionary,
@@ -335,7 +254,7 @@ function collectPageLabels(
335
254
  }
336
255
  if (values.Kids) {
337
256
  parser
338
- .queryDictionaryObject(dictionary, "Kids")
257
+ .queryDictionaryObject(dictionary, PdfName.KIDS)
339
258
  .toPDFArray()
340
259
  .toJSArray()
341
260
  .forEach((entry) => {
@@ -353,7 +272,14 @@ function collectPageLabels(
353
272
  return values;
354
273
  }
355
274
 
356
- /** Resolves an indirect chain used by a page-label number tree. @private */
275
+ /**
276
+ * Follow a chain of indirect references to the object it ends at.
277
+ * @private
278
+ * @param {Object} parser - The source PDF parser.
279
+ * @param {Object} value - The PDF object or reference.
280
+ * @returns {Object} The resolved object.
281
+ * @throws {Error} If the chain is cyclic or longer than 1000 references.
282
+ */
357
283
  function resolvePageLabelObject(parser, value) {
358
284
  const visited = new Set();
359
285
  let resolved = value;
@@ -368,7 +294,16 @@ function resolvePageLabelObject(parser, value) {
368
294
  return resolved;
369
295
  }
370
296
 
371
- /** Reads a page-label dictionary into a serializable value. @private */
297
+ /**
298
+ * Read a page-label dictionary into a plain value.
299
+ * @private
300
+ * @param {Object} parser - The source PDF parser.
301
+ * @param {Object} value - The page-label dictionary.
302
+ * @returns {{style: (string|undefined), prefix: (Object|undefined),
303
+ * start: (number|undefined)}} The label style name, prefix bytes with their
304
+ * string kind, and start number.
305
+ * @throws {Error} If the value is not a dictionary.
306
+ */
372
307
  function readPageLabel(parser, value) {
373
308
  const dictionary = value?.toPDFDictionary();
374
309
  if (!dictionary) {
@@ -380,6 +315,9 @@ function readPageLabel(parser, value) {
380
315
  const start = values.St ? resolvePageLabelObject(parser, values.St) : null;
381
316
  const prefixHex = prefix?.toPDFHexString();
382
317
  const prefixLiteral = prefix?.toPDFLiteralString();
318
+ if (values.P !== undefined && !prefixHex && !prefixLiteral) {
319
+ throw new Error("deletePage requires valid PageLabels entries");
320
+ }
383
321
  return {
384
322
  style: style?.toPDFName()?.value,
385
323
  prefix:
@@ -393,7 +331,13 @@ function readPageLabel(parser, value) {
393
331
  };
394
332
  }
395
333
 
396
- /** Writes indirect objects for normalized page-label values. @private */
334
+ /**
335
+ * Write one indirect page-label dictionary per entry.
336
+ * @private
337
+ * @param {Object} objectsContext - The writer objects context.
338
+ * @param {Object[]} entries - The normalized {index, value} entries.
339
+ * @returns {Object[]} The {index, objectID} of every written label.
340
+ */
397
341
  function writePageLabelObjects(objectsContext, entries) {
398
342
  return entries.map((entry) => {
399
343
  const objectID = objectsContext.startNewIndirectObject();
@@ -417,7 +361,17 @@ function writePageLabelObjects(objectsContext, entries) {
417
361
  });
418
362
  }
419
363
 
420
- /** Writes the number-tree dictionary for page labels. @private */
364
+ /**
365
+ * Write a flat page-label number tree: the copied extra entries and a Nums
366
+ * array referencing the written labels.
367
+ * @private
368
+ * @param {Object} objectsContext - The writer objects context.
369
+ * @param {Object} copyingContext - Copies the extra entries.
370
+ * @param {Object} dictionary - The dictionary context to write into.
371
+ * @param {Object} values - The source number-tree entries.
372
+ * @param {Object[]} entries - The {index, objectID} of every label.
373
+ * @returns {void}
374
+ */
421
375
  function writePageLabelsDictionary(
422
376
  objectsContext,
423
377
  copyingContext,
@@ -426,11 +380,12 @@ function writePageLabelsDictionary(
426
380
  entries,
427
381
  ) {
428
382
  Object.keys(values).forEach((key) => {
429
- if (key === "Kids" || key === "Limits" || key === "Nums") return;
383
+ if (key === PdfName.KIDS || key === PdfName.LIMITS || key === PdfName.NUMS)
384
+ return;
430
385
  dictionary.writeKey(key);
431
386
  copyingContext.copyDirectObjectAsIs(values[key]);
432
387
  });
433
- dictionary.writeKey("Nums");
388
+ dictionary.writeKey(PdfName.NUMS);
434
389
  objectsContext.startArray();
435
390
  entries.forEach((entry) => {
436
391
  objectsContext.writeNumber(entry.index);
@@ -439,7 +394,18 @@ function writePageLabelsDictionary(
439
394
  objectsContext.endArray();
440
395
  }
441
396
 
442
- /** Reindexes page labels after removing source pages. @private */
397
+ /**
398
+ * Reindex the page labels for the pages that remain after deletion, keeping
399
+ * each label's numbering continuous.
400
+ * @private
401
+ * @param {Object} parser - The source PDF parser.
402
+ * @param {Object} catalogDictionary - The catalog dictionary.
403
+ * @param {Set<number>} deletedPages - One-based page numbers to delete.
404
+ * @param {number} sourcePageCount - The source page count.
405
+ * @returns {Object|null} The PageLabels reference, the number tree values
406
+ * and the normalized entries; null without page labels.
407
+ * @throws {Error} If PageLabels is not a valid number tree.
408
+ */
443
409
  function preparePageLabels(
444
410
  parser,
445
411
  catalogDictionary,
@@ -508,15 +474,21 @@ function preparePageLabels(
508
474
  }
509
475
  });
510
476
  return {
511
- catalogValues,
512
477
  reference,
513
478
  values,
514
479
  entries: normalized,
515
480
  };
516
481
  }
517
482
 
518
- /** Writes updated page labels and attaches them to the catalog. @private */
519
- function writePageLabels(writer, copyingContext, rootID, pageLabels) {
483
+ /**
484
+ * Write the updated page labels and attach them to the catalog.
485
+ * @private
486
+ * @param {Object} writer - The PDF writer.
487
+ * @param {Object} copyingContext - Copies unchanged entries.
488
+ * @param {Object|null} pageLabels - The result of preparePageLabels().
489
+ * @returns {void}
490
+ */
491
+ function writePageLabels(writer, copyingContext, pageLabels) {
520
492
  if (!pageLabels) return;
521
493
 
522
494
  const objectsContext = writer.getObjectsContext();
@@ -548,21 +520,7 @@ function writePageLabels(writer, copyingContext, rootID, pageLabels) {
548
520
  );
549
521
  objectsContext.endDictionary(labelsDictionary).endIndirectObject();
550
522
 
551
- if (writer._setPageLabelsObject) {
552
- writer._setPageLabelsObject(labelsObjectID);
553
- return;
554
- }
555
-
556
- objectsContext.startModifiedIndirectObject(rootID);
557
- const dictionary = objectsContext.startDictionary();
558
- Object.keys(pageLabels.catalogValues).forEach((key) => {
559
- if (key === "PageLabels") return;
560
- dictionary.writeKey(key);
561
- copyingContext.copyDirectObjectAsIs(pageLabels.catalogValues[key]);
562
- });
563
- dictionary.writeKey("PageLabels");
564
- objectsContext.writeIndirectObjectReference(labelsObjectID);
565
- objectsContext.endDictionary(dictionary).endIndirectObject();
523
+ setCatalogEntry(writer, PdfName.PAGE_LABELS, labelsObjectID);
566
524
  }
567
525
 
568
526
  /**
@@ -572,8 +530,9 @@ function writePageLabels(writer, copyingContext, rootID, pageLabels) {
572
530
  * @name createPage
573
531
  * @function
574
532
  * @memberof Recipe#
575
- * @param {number|string} [pageWidth] - The page width, or name of medium size.
576
- * Known named medium sizes: executive, folio, legal, letter, ledger, tabloid, a0-a10, b0-b10, c0-c10, ra0-ra4, sra0-ara4
533
+ * @param {number|Recipe.PageSize} [pageWidth] - The page width, or a `Recipe.PageSize` name.
534
+ * Known named medium sizes: executive, folio, legal, letter, ledger, tabloid, a0-a10, b0-b10, c0-c10, ra0-ra4, sra0-sra4.
535
+ * Unknown names use the default letter size.
577
536
  * @param {number} [pageHeight] - The page height, or rotation (90) when page size name given.
578
537
  * @param {object} [margins] - page margin definitions.
579
538
  * @param {number} [margins.left] - Left margin.
@@ -581,6 +540,7 @@ function writePageLabels(writer, copyingContext, rootID, pageLabels) {
581
540
  * @param {number} [margins.top] - Top margin.
582
541
  * @param {number} [margins.bottom] - Bottom margin.
583
542
  * @returns {Recipe} The recipe instance.
543
+ * @throws {Error} If pages were deleted with deletePage() on this Recipe.
584
544
  */
585
545
  exports.createPage = function createPage(pageWidth, pageHeight, margins) {
586
546
  if (this.deletedPages?.size) {
@@ -615,10 +575,12 @@ exports.createPage = function createPage(pageWidth, pageHeight, margins) {
615
575
  }
616
576
  }
617
577
  // from 0
618
- this.metadata.pageCount += 1;
578
+ this.metadata.pageCount =
579
+ (this.metadata.pageCount ?? this.metadata.pages ?? 0) + 1;
619
580
  const pageNumber = this.metadata.pageCount;
620
581
  const dimensions = [0, 0, pageWidth, pageHeight];
621
- const layout = pageWidth > pageHeight ? "landscape" : "portrait";
582
+ const layout =
583
+ pageWidth > pageHeight ? PageLayout.LANDSCAPE : PageLayout.PORTRAIT;
622
584
  this.metadata[pageNumber] = {
623
585
  pageNumber,
624
586
  mediaBox: dimensions,
@@ -635,7 +597,7 @@ exports.createPage = function createPage(pageWidth, pageHeight, margins) {
635
597
  this.pageNumber = pageNumber;
636
598
  this.pageContext = this.writer.startPageContentContext(this.page);
637
599
  this.editingPage = false;
638
- this.contextState = "active-new";
600
+ this.contextState = PAGE_CONTEXT_STATE.ACTIVE_NEW;
639
601
  if (!this.isNewPDF) this.pagesCreated = true;
640
602
 
641
603
  if (margins) {
@@ -646,15 +608,55 @@ exports.createPage = function createPage(pageWidth, pageHeight, margins) {
646
608
  return this;
647
609
  };
648
610
 
611
+ // setPageBox() also accepts the PageBox names.
612
+ const PAGE_BOX_CONSTANTS = {
613
+ [muhammara.PageBox.MEDIA]: muhammara.ePDFPageBoxMediaBox,
614
+ [muhammara.PageBox.CROP]: muhammara.ePDFPageBoxCropBox,
615
+ [muhammara.PageBox.BLEED]: muhammara.ePDFPageBoxBleedBox,
616
+ [muhammara.PageBox.TRIM]: muhammara.ePDFPageBoxTrimBox,
617
+ [muhammara.PageBox.ART]: muhammara.ePDFPageBoxArtBox,
618
+ };
619
+
620
+ /**
621
+ * Validate a page rotation. PDF allows only multiples of 90 degrees.
622
+ * @private
623
+ * @param {*} page - The active page, if any.
624
+ * @param {*} rotation - The requested rotation.
625
+ * @throws {TypeError} If no page is active or `rotation` is not a number.
626
+ * @throws {RangeError} If `rotation` is not a multiple of 90.
627
+ */
628
+ function checkRotation(page, rotation) {
629
+ if (!page) {
630
+ throw new TypeError("rotate requires an active page");
631
+ }
632
+ if (typeof rotation !== "number") {
633
+ throw new TypeError("Rotation is not set to a number");
634
+ }
635
+ if (!Number.isInteger(rotation / 90)) {
636
+ throw new RangeError("Rotation must be a multiple of 90 degrees");
637
+ }
638
+ }
639
+
649
640
  /**
650
641
  * Set the rotation of the current page.
651
642
  * @name rotate
652
643
  * @function
653
644
  * @memberof Recipe#
654
- * @param {number} rotation - The page rotation in degrees.
645
+ * @param {number} rotation - The page rotation in degrees, a multiple of 90.
655
646
  * @returns {Recipe} The recipe instance.
647
+ * @throws {TypeError} If no page is active or `rotation` is not a number.
648
+ * @throws {RangeError} If `rotation` is not a multiple of 90.
649
+ * @throws {Error} If the active page was opened with `editPage()`.
656
650
  */
657
651
  exports.rotate = function rotate(rotation) {
652
+ // A page modifier keeps the source page's /Rotate, so the rotation would
653
+ // only change Recipe's coordinate bookkeeping.
654
+ if (this.editingPage) {
655
+ throw new Error(
656
+ "rotate() is only available on pages created with createPage()",
657
+ );
658
+ }
659
+ checkRotation(this.page, rotation);
658
660
  this.page.rotate = rotation;
659
661
  this.metadata[this.pageNumber].rotate = rotation;
660
662
  return this;
@@ -665,15 +667,20 @@ exports.rotate = function rotate(rotation) {
665
667
  * @name setPageBox
666
668
  * @function
667
669
  * @memberof Recipe#
668
- * @param {number} box - An `ePDFPageBox*` constant.
670
+ * @param {number|string} box - An `ePDFPageBox*` constant or a `PageBox` name.
669
671
  * @param {number} left - The PDF left coordinate.
670
672
  * @param {number} bottom - The PDF bottom coordinate.
671
673
  * @param {number} right - The PDF right coordinate.
672
674
  * @param {number} top - The PDF top coordinate.
673
675
  * @returns {Recipe} The recipe instance.
674
676
  * @throws {RangeError} If the page box constant is unknown.
677
+ * @throws {TypeError} If no page is active.
675
678
  */
676
679
  exports.setPageBox = function setPageBox(box, left, bottom, right, top) {
680
+ box = PAGE_BOX_CONSTANTS[box] ?? box;
681
+ if ([left, bottom, right, top].some((value) => typeof value === "bigint")) {
682
+ throw new TypeError("setPageBox coordinates must be numbers");
683
+ }
677
684
  const boxes = {
678
685
  [muhammara.ePDFPageBoxMediaBox]: "mediaBox",
679
686
  [muhammara.ePDFPageBoxCropBox]: "cropBox",
@@ -695,18 +702,19 @@ exports.setPageBox = function setPageBox(box, left, bottom, right, top) {
695
702
  mediaBox: pageBox,
696
703
  width,
697
704
  height,
698
- layout: width > height ? "landscape" : "portrait",
705
+ layout: width > height ? PageLayout.LANDSCAPE : PageLayout.PORTRAIT,
699
706
  });
700
707
  }
701
708
  return this;
702
709
  };
703
710
 
704
711
  /**
705
- * Finish a page
712
+ * Finish a page. Without an active page this does nothing.
706
713
  * @name endPage
707
714
  * @function
708
715
  * @memberof Recipe#
709
716
  * @returns {Recipe} The recipe instance.
717
+ * @throws {Error} If the page cannot be written.
710
718
  */
711
719
  exports.endPage = function endPage() {
712
720
  if (!this.page) {
@@ -714,7 +722,8 @@ exports.endPage = function endPage() {
714
722
  }
715
723
 
716
724
  if (this.page.endContext) {
717
- if (this.contextState === "active-edit") this.page.endContext();
725
+ if (this.contextState === PAGE_CONTEXT_STATE.ACTIVE_EDIT)
726
+ this.page.endContext();
718
727
  this.page.writePage();
719
728
  } else {
720
729
  this.writer.writePage(this.page);
@@ -723,7 +732,7 @@ exports.endPage = function endPage() {
723
732
  this.pageContext = null;
724
733
  this.pageNumber = 0;
725
734
  this.editingPage = false;
726
- this.contextState = "idle";
735
+ this.contextState = PAGE_CONTEXT_STATE.IDLE;
727
736
 
728
737
  return this;
729
738
  };
@@ -733,10 +742,17 @@ exports.endPage = function endPage() {
733
742
  * @name editPage
734
743
  * @function
735
744
  * @memberof Recipe#
736
- * @param {number} pageNumber - The page number to be edited.
745
+ * @param {number} pageNumber - The one-based page number to be edited.
737
746
  * @returns {Recipe} The recipe instance.
747
+ * @throws {Error} If the Recipe was not constructed from an existing PDF.
748
+ * @throws {Error} If the page does not exist in the source PDF.
738
749
  */
739
750
  exports.editPage = function editPage(pageNumber) {
751
+ if (this.isNewPDF) {
752
+ throw new Error(
753
+ "editPage requires a Recipe constructed from an existing PDF",
754
+ );
755
+ }
740
756
  const pdfWriter = this.writer;
741
757
  const pageIndex = pageNumber - 1;
742
758
  const pageModifier = new muhammara.PDFPageModifier(
@@ -748,7 +764,7 @@ exports.editPage = function editPage(pageNumber) {
748
764
  this.pageNumber = pageNumber;
749
765
  this.pageContext = pageModifier.startContext().getContext();
750
766
  this.editingPage = true;
751
- this.contextState = "active-edit";
767
+ this.contextState = PAGE_CONTEXT_STATE.ACTIVE_EDIT;
752
768
  this.modifiedSourcePages.add(pageNumber);
753
769
 
754
770
  this._resumePageRotation(pageNumber);
@@ -759,9 +775,9 @@ exports.editPage = function editPage(pageNumber) {
759
775
  const startX = mediaBox[0];
760
776
  const startY = mediaBox[1];
761
777
  const textOptions = {
762
- font: this.writer.getFontForFile(this.fonts["helvetica-bold"]),
778
+ font: this.writer.getFontForFile(this.fonts.helvetica.b),
763
779
  size: 50,
764
- colorspace: "gray",
780
+ colorspace: Colorspace.GRAY,
765
781
  color: 0x00,
766
782
  };
767
783
  context.writeText(
@@ -798,21 +814,105 @@ exports.editPage = function editPage(pageNumber) {
798
814
  return this;
799
815
  };
800
816
 
817
+ /**
818
+ * Read the page tree and check that the queued deletions can be applied:
819
+ * the page tree and page labels can be rewritten, and every retained
820
+ * reference to a deleted page can be pruned or there is none.
821
+ * @private
822
+ * @param {Object} recipe - The Recipe.
823
+ * @param {Object} copyingContext - A copying context for the modified file.
824
+ * @param {Set<number>} deletedPages - One-based page numbers to delete.
825
+ * @param {boolean} prune - Whether to prune references to deleted pages.
826
+ * @returns {Object} The page tree, page labels, catalog entries, deleted
827
+ * page object IDs and the pruning plan.
828
+ * @throws {Error} If the deletion cannot be applied.
829
+ */
830
+ function planPageDeletion(recipe, copyingContext, deletedPages, prune) {
831
+ const parser = copyingContext.getSourceDocumentParser();
832
+ const trailer = parser.getTrailer().toJSObject();
833
+ const rootID = trailer.Root.toPDFIndirectObjectReference().getObjectID();
834
+ const catalogDictionary = parser.parseNewObject(rootID).toPDFDictionary();
835
+ const catalog = catalogDictionary.toJSObject();
836
+ const pagesReference = catalog.Pages.toPDFIndirectObjectReference();
837
+ const pageLabels = preparePageLabels(
838
+ parser,
839
+ catalogDictionary,
840
+ deletedPages,
841
+ recipe.sourcePageCount,
842
+ );
843
+ const modifiedPageIDs = new Set(
844
+ Array.from(recipe.modifiedSourcePages, (pageNumber) =>
845
+ parser.getPageObjectID(pageNumber - 1),
846
+ ),
847
+ );
848
+ const pageState = {
849
+ pageNumber: 0,
850
+ deletedPageIDs: new Set(),
851
+ retainedPageIDs: new Set(),
852
+ };
853
+ const tree = readPageTree(
854
+ parser,
855
+ pagesReference.getObjectID(),
856
+ deletedPages,
857
+ modifiedPageIDs,
858
+ pageState,
859
+ pagesReference.getVersion(),
860
+ );
861
+ pageState.retainedPageIDs.forEach((objectID) =>
862
+ pageState.deletedPageIDs.delete(objectID),
863
+ );
864
+ assertSupportedModifiedGenerations(tree, pageLabels);
865
+ const plan = planDeletedPageReferences(
866
+ parser,
867
+ {
868
+ catalog,
869
+ rootID,
870
+ tree,
871
+ pageLabels,
872
+ deletedPageIDs: pageState.deletedPageIDs,
873
+ modifiedPageIDs,
874
+ sourcePageCount: recipe.sourcePageCount,
875
+ },
876
+ prune,
877
+ );
878
+ return {
879
+ catalog,
880
+ tree,
881
+ pageLabels,
882
+ deletedPageIDs: pageState.deletedPageIDs,
883
+ plan,
884
+ };
885
+ }
886
+
801
887
  /**
802
888
  * Delete one or more pages from an existing PDF.
803
889
  * Page numbers are one-based and refer to the original source document.
890
+ *
891
+ * By default a page that retained structures still reference - outline
892
+ * items, link annotations and named destinations, form widgets, tagged-PDF
893
+ * structure elements or the open action - cannot be deleted. With
894
+ * `pruneReferences`, those references are removed instead: a destination
895
+ * that targets a deleted page becomes null (so outline items keep their
896
+ * title and children, and links do nothing), and every other direct
897
+ * reference to a deleted page is dropped. Pruning applies to every queued
898
+ * deletion once any deletePage() call enables it.
804
899
  * @name deletePage
805
900
  * @function
806
901
  * @memberof Recipe#
807
902
  * @param {number|number[]} pageNumbers - Page number or page numbers to delete.
903
+ * @param {Object} [options] - Deletion options.
904
+ * @param {boolean} [options.pruneReferences=false] - Remove references to the
905
+ * deleted pages from retained structures instead of refusing the deletion.
808
906
  * @returns {Recipe} The recipe instance.
907
+ * @throws {TypeError} If options is not an object or pruneReferences is not
908
+ * a boolean.
809
909
  * @throws {RangeError} If a page number does not identify an original page.
810
910
  * @throws {Error} If the Recipe has no existing source, has ended, would delete
811
- * every page, or combines deletion with page composition. Page-tree,
812
- * retained-reference, and object-generation validation is deferred to
813
- * endPDF(), which throws those errors during finalization.
911
+ * every page, combines deletion with page composition, or the page tree,
912
+ * page labels or retained references cannot be rewritten. A failed call
913
+ * leaves the queued deletions unchanged.
814
914
  */
815
- exports.deletePage = function deletePage(pageNumbers) {
915
+ exports.deletePage = function deletePage(pageNumbers, options) {
816
916
  if (this.ended) {
817
917
  throw new Error("Cannot delete a page after endPDF");
818
918
  }
@@ -828,6 +928,16 @@ exports.deletePage = function deletePage(pageNumbers) {
828
928
  if (this.pagesCreated) {
829
929
  throw new Error("deletePage cannot be combined with createPage");
830
930
  }
931
+ if (
932
+ options !== undefined &&
933
+ (options === null || typeof options !== "object" || Array.isArray(options))
934
+ ) {
935
+ throw new TypeError("deletePage expects an options object");
936
+ }
937
+ const pruneReferences = options?.pruneReferences;
938
+ if (pruneReferences !== undefined && typeof pruneReferences !== "boolean") {
939
+ throw new TypeError("deletePage pruneReferences must be a boolean");
940
+ }
831
941
  pageNumbers = Array.isArray(pageNumbers) ? pageNumbers : [pageNumbers];
832
942
  const deletedPages = new Set(this.deletedPages || []);
833
943
  pageNumbers.forEach((pageNumber) => {
@@ -843,67 +953,48 @@ exports.deletePage = function deletePage(pageNumbers) {
843
953
  if (deletedPages.size >= this.sourcePageCount) {
844
954
  throw new Error("At least one page must remain in the PDF");
845
955
  }
956
+ const prune = Boolean(this.pruneReferences || pruneReferences);
957
+ const copyingContext = this.writer.createPDFCopyingContextForModifiedFile();
958
+ try {
959
+ planPageDeletion(this, copyingContext, deletedPages, prune);
960
+ } finally {
961
+ copyingContext.end();
962
+ }
846
963
  this.deletedPages = deletedPages;
964
+ this.pruneReferences = prune;
847
965
  return this;
848
966
  };
849
967
 
850
- /** Applies queued page deletions during finalization. @private */
968
+ /**
969
+ * Apply the queued page deletions during finalization: rewrite the page tree
970
+ * and page labels, prune references to deleted pages when enabled, and
971
+ * renumber metadata and pending annotations.
972
+ * @private
973
+ * @returns {Recipe} The recipe instance.
974
+ * @throws {Error} If the page tree, page labels or retained references
975
+ * cannot be rewritten safely, for example after editing a page that holds
976
+ * a pruned reference.
977
+ */
851
978
  exports._deletePages = function _deletePages() {
852
979
  if (!this.deletedPages?.size) return this;
853
980
 
854
981
  const copyingContext = this.writer.createPDFCopyingContextForModifiedFile();
855
982
  try {
856
- const parser = copyingContext.getSourceDocumentParser();
857
- const trailer = parser.getTrailer().toJSObject();
858
- const rootReference = trailer.Root.toPDFIndirectObjectReference();
859
- const rootID = rootReference.getObjectID();
860
- const catalogDictionary = parser.parseNewObject(rootID).toPDFDictionary();
861
- const catalog = catalogDictionary.toJSObject();
862
- const pagesReference = catalog.Pages.toPDFIndirectObjectReference();
863
- const pageLabels = preparePageLabels(
864
- parser,
865
- catalogDictionary,
983
+ const deletion = planPageDeletion(
984
+ this,
985
+ copyingContext,
866
986
  this.deletedPages,
867
- this.sourcePageCount,
868
- );
869
- const modifiedPageIDs = new Set(
870
- Array.from(this.modifiedSourcePages, (pageNumber) =>
871
- parser.getPageObjectID(pageNumber - 1),
872
- ),
987
+ Boolean(this.pruneReferences),
873
988
  );
874
- const pageState = {
875
- pageNumber: 0,
876
- deletedPageIDs: new Set(),
877
- retainedPageIDs: new Set(),
878
- };
879
- const tree = readPageTree(
880
- parser,
881
- pagesReference.getObjectID(),
882
- this.deletedPages,
883
- modifiedPageIDs,
884
- pageState,
885
- pagesReference.getVersion(),
886
- );
887
- pageState.retainedPageIDs.forEach((objectID) =>
888
- pageState.deletedPageIDs.delete(objectID),
889
- );
890
- assertSupportedModifiedGenerations(
891
- tree,
892
- pageLabels,
893
- rootReference,
989
+ writePageTree(this.writer, copyingContext, deletion.tree);
990
+ writePageLabels(this.writer, copyingContext, deletion.pageLabels);
991
+ pruneDeletedPageReferences(
894
992
  this.writer,
993
+ copyingContext,
994
+ deletion.catalog,
995
+ deletion.plan,
996
+ deletion.deletedPageIDs,
895
997
  );
896
- validateDeletedPageReferences(
897
- parser,
898
- catalog,
899
- rootID,
900
- tree,
901
- pageLabels,
902
- pageState.deletedPageIDs,
903
- this.sourcePageCount,
904
- );
905
- writePageTree(this.writer, copyingContext, tree);
906
- writePageLabels(this.writer, copyingContext, rootID, pageLabels);
907
998
  } finally {
908
999
  copyingContext.end();
909
1000
  }
@@ -940,6 +1031,15 @@ exports._deletePages = function _deletePages() {
940
1031
  return this;
941
1032
  };
942
1033
 
1034
+ /**
1035
+ * Apply the page rotation to an edited page's content context, so Recipe
1036
+ * coordinates stay upright on rotated source pages.
1037
+ * @private
1038
+ * @param {number} [pageNumber] - The one-based page number; defaults to the active page.
1039
+ * @param {Object} [context] - The content context; defaults to the page context.
1040
+ * @returns {Recipe} The recipe instance.
1041
+ * @throws {TypeError} If the page is unknown.
1042
+ */
943
1043
  exports._resumePageRotation = function _resumePageRotation(
944
1044
  pageNumber,
945
1045
  context,
@@ -981,11 +1081,15 @@ exports._resumePageRotation = function _resumePageRotation(
981
1081
  * @name pageInfo
982
1082
  * @function
983
1083
  * @memberof Recipe#
984
- * @param {number} pageNumber - The page number.
1084
+ * @param {number} pageNumber - The one-based page number.
985
1085
  * @returns {RecipePageInfo} The page information.
1086
+ * @throws {TypeError} If the page is unknown.
986
1087
  */
987
1088
  exports.pageInfo = function pageInfo(pageNumber) {
988
1089
  const pageInfo = this.metadata[pageNumber];
1090
+ if (!pageInfo) {
1091
+ throw new TypeError(`Unknown page number: ${pageNumber}`);
1092
+ }
989
1093
  return {
990
1094
  width: pageInfo.width,
991
1095
  height: pageInfo.height,
@@ -1016,13 +1120,13 @@ exports.getCurrentPageInfo = function getCurrentPageInfo() {
1016
1120
  * @throws {Error} If there is no active page content context.
1017
1121
  */
1018
1122
  exports.pauseContext = function pauseContext() {
1019
- if (this.contextState === "active-edit") {
1123
+ if (this.contextState === PAGE_CONTEXT_STATE.ACTIVE_EDIT) {
1020
1124
  this.page.endContext();
1021
1125
  this.pageContext = null;
1022
- this.contextState = "paused-edit";
1023
- } else if (this.contextState === "active-new") {
1126
+ this.contextState = PAGE_CONTEXT_STATE.PAUSED_EDIT;
1127
+ } else if (this.contextState === PAGE_CONTEXT_STATE.ACTIVE_NEW) {
1024
1128
  this.writer.pausePageContentContext(this.pageContext);
1025
- this.contextState = "paused-new";
1129
+ this.contextState = PAGE_CONTEXT_STATE.PAUSED_NEW;
1026
1130
  } else {
1027
1131
  throw new Error("No active page content context to pause");
1028
1132
  }
@@ -1038,12 +1142,12 @@ exports.pauseContext = function pauseContext() {
1038
1142
  * @throws {Error} If there is no paused page content context.
1039
1143
  */
1040
1144
  exports.resumeContext = function resumeContext() {
1041
- if (this.contextState === "paused-edit") {
1145
+ if (this.contextState === PAGE_CONTEXT_STATE.PAUSED_EDIT) {
1042
1146
  this.pageContext = this.page.startContext().getContext();
1043
1147
  this._resumePageRotation();
1044
- this.contextState = "active-edit";
1045
- } else if (this.contextState === "paused-new") {
1046
- this.contextState = "active-new";
1148
+ this.contextState = PAGE_CONTEXT_STATE.ACTIVE_EDIT;
1149
+ } else if (this.contextState === PAGE_CONTEXT_STATE.PAUSED_NEW) {
1150
+ this.contextState = PAGE_CONTEXT_STATE.ACTIVE_NEW;
1047
1151
  } else {
1048
1152
  throw new Error("No paused page content context to resume");
1049
1153
  }