@muhammara/native-core 7.0.0-beta.4 → 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 +79 -28
  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 +464 -101
  15. package/lib/recipe/appendPage.js +2 -0
  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 +121 -24
  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 +363 -262
  28. package/lib/recipe/parameters.js +8 -1
  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 +41 -8
  33. package/lib/recipe/text.helper.js +172 -8
  34. package/lib/recipe/text.js +294 -56
  35. package/lib/recipe/utils.js +17 -6
  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 +148 -6
  39. package/lib/recipe/vector.js +150 -51
  40. package/lib/recipe/xObjectForm.js +73 -10
  41. package/lib/recipe-constants.js +373 -0
  42. package/muhammara.d.ts +3469 -155
  43. package/package.json +1 -1
@@ -1,7 +1,40 @@
1
1
  const muhammara = require("../muhammara");
2
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");
3
9
 
4
- /** 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
+ */
5
38
  function readPageTree(
6
39
  parser,
7
40
  objectID,
@@ -21,11 +54,21 @@ function readPageTree(
21
54
  if (depth === 0 && values.Parent !== undefined) {
22
55
  throw new Error("deletePage requires a valid page tree");
23
56
  }
24
- 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
+ }
25
61
  const mappedChildren = kids.toJSArray().map((entry) => {
62
+ // Kids must be references to page tree dictionaries with a /Type.
26
63
  const reference = entry.toPDFIndirectObjectReference();
27
- const childID = reference.getObjectID();
28
- 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
+ }
29
72
  const childValues = childDictionary.toJSObject();
30
73
  const parentReference = childValues.Parent?.toPDFIndirectObjectReference();
31
74
  if (
@@ -35,8 +78,11 @@ function readPageTree(
35
78
  ) {
36
79
  throw new Error("deletePage requires a valid page tree");
37
80
  }
38
- const type = childValues.Type.toPDFName().value;
39
- 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) {
40
86
  return readPageTree(
41
87
  parser,
42
88
  childID,
@@ -84,7 +130,15 @@ function readPageTree(
84
130
  };
85
131
  }
86
132
 
87
- /** 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
+ */
88
142
  function writePageTree(writer, copyingContext, node) {
89
143
  node.children
90
144
  .filter((child) => child.children && child.changed)
@@ -95,13 +149,13 @@ function writePageTree(writer, copyingContext, node) {
95
149
  objectsContext.startModifiedIndirectObject(node.objectID);
96
150
  const dictionary = objectsContext.startDictionary();
97
151
  Object.keys(node.values).forEach((key) => {
98
- if (key === "Count" || key === "Kids") return;
152
+ if (key === PdfName.COUNT || key === PdfName.KIDS) return;
99
153
  dictionary.writeKey(key);
100
154
  copyingContext.copyDirectObjectAsIs(node.values[key]);
101
155
  });
102
- dictionary.writeKey("Count");
156
+ dictionary.writeKey(PdfName.COUNT);
103
157
  objectsContext.writeNumber(node.count);
104
- dictionary.writeKey("Kids");
158
+ dictionary.writeKey(PdfName.KIDS);
105
159
  objectsContext.startArray();
106
160
  node.children.forEach((child) => {
107
161
  objectsContext.writeIndirectObjectReference(
@@ -117,6 +171,10 @@ function writePageTree(writer, copyingContext, node) {
117
171
  * alike - depth-first. Shared by every deletePage() pass that needs to walk
118
172
  * the tree readPageTree() already built, instead of re-parsing or re-walking
119
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}
120
178
  */
121
179
  function walkPageTree(tree, visit) {
122
180
  const pending = [tree];
@@ -127,15 +185,17 @@ function walkPageTree(tree, visit) {
127
185
  }
128
186
  }
129
187
 
130
- /** Adds retained Pages-node object IDs to a set. @private */
131
- function collectPageTreeObjectIDs(tree, objectIDs) {
132
- walkPageTree(tree, (node) => {
133
- if (node.children) objectIDs.add(node.objectID);
134
- });
135
- }
136
-
137
- /** Rejects page-tree objects that cannot be rewritten safely. @private */
138
- 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) {
139
199
  walkPageTree(tree, (node) => {
140
200
  if (!node.children) return;
141
201
  if (node.changed && node.generation !== 0) {
@@ -149,162 +209,20 @@ function assertSupportedModifiedGenerations(tree, pageLabels, root, writer) {
149
209
  "deletePage does not support rewriting nonzero-generation objects",
150
210
  );
151
211
  }
152
- // A writer with _setPageLabelsObject() (wasm) always attaches a new
153
- // PageLabels entry through that dedicated hook and never rewrites the
154
- // catalog object in place, so the catalog's own generation never matters
155
- // there. Without it (native-core), writePageLabels() falls back to
156
- // rewriting the catalog via startModifiedIndirectObject(rootID), which
157
- // only supports generation 0.
158
- if (pageLabels && !pageLabels.reference && !writer._setPageLabelsObject) {
159
- if (root.getVersion() !== 0) {
160
- throw new Error(
161
- "deletePage does not support rewriting nonzero-generation objects",
162
- );
163
- }
164
- }
165
- }
166
-
167
- /** Rejects retained structures that reference a deleted page. @private */
168
- function assertNoDeletedPageReferences(
169
- parser,
170
- value,
171
- deletedPageIDs,
172
- skippedObjectIDs,
173
- visited = new Set(),
174
- depth = 0,
175
- ) {
176
- if (!value) return;
177
- if (depth > 1000) {
178
- throw new Error("deletePage cannot validate deeply nested references");
179
- }
180
- const reference = value.toPDFIndirectObjectReference?.();
181
- if (reference) {
182
- const objectID = reference.getObjectID();
183
- if (deletedPageIDs.has(objectID)) {
184
- throw new Error(
185
- "deletePage cannot remove a page referenced by retained document structures",
186
- );
187
- }
188
- if (skippedObjectIDs.has(objectID) || visited.has(objectID)) return;
189
- visited.add(objectID);
190
- assertNoDeletedPageReferences(
191
- parser,
192
- parser.parseNewObject(objectID),
193
- deletedPageIDs,
194
- skippedObjectIDs,
195
- visited,
196
- depth + 1,
197
- );
198
- return;
199
- }
200
- const array =
201
- value.toPDFArray?.() ||
202
- (typeof value.toJSArray === "function" ? value : null);
203
- if (array) {
204
- array
205
- .toJSArray()
206
- .forEach((entry) =>
207
- assertNoDeletedPageReferences(
208
- parser,
209
- entry,
210
- deletedPageIDs,
211
- skippedObjectIDs,
212
- visited,
213
- depth + 1,
214
- ),
215
- );
216
- return;
217
- }
218
- const dictionary =
219
- value.toPDFDictionary?.() ||
220
- (typeof value.toJSObject === "function" ? value : null);
221
- if (dictionary) {
222
- Object.values(dictionary.toJSObject()).forEach((entry) =>
223
- assertNoDeletedPageReferences(
224
- parser,
225
- entry,
226
- deletedPageIDs,
227
- skippedObjectIDs,
228
- visited,
229
- depth + 1,
230
- ),
231
- );
232
- return;
233
- }
234
- const stream = value.toPDFStream?.();
235
- if (stream) {
236
- assertNoDeletedPageReferences(
237
- parser,
238
- stream.getDictionary(),
239
- deletedPageIDs,
240
- skippedObjectIDs,
241
- visited,
242
- depth + 1,
243
- );
244
- }
245
- }
246
-
247
- /** Checks retained document structures for deleted-page references. @private */
248
- function validateDeletedPageReferences(
249
- parser,
250
- catalog,
251
- rootID,
252
- tree,
253
- pageLabels,
254
- deletedPageIDs,
255
- sourcePageCount,
256
- ) {
257
- const skippedObjectIDs = new Set([rootID]);
258
- collectPageTreeObjectIDs(tree, skippedObjectIDs);
259
- for (let pageIndex = 0; pageIndex < sourcePageCount; pageIndex += 1) {
260
- skippedObjectIDs.add(parser.getPageObjectID(pageIndex));
261
- }
262
- const visited = new Set();
263
- Object.entries(catalog)
264
- .filter(([key]) => !["PageLabels", "Pages"].includes(key))
265
- .forEach(([, value]) =>
266
- assertNoDeletedPageReferences(
267
- parser,
268
- value,
269
- deletedPageIDs,
270
- skippedObjectIDs,
271
- visited,
272
- ),
273
- );
274
- if (pageLabels) {
275
- Object.entries(pageLabels.values)
276
- .filter(([key]) => !["Kids", "Limits", "Nums"].includes(key))
277
- .forEach(([, value]) =>
278
- assertNoDeletedPageReferences(
279
- parser,
280
- value,
281
- deletedPageIDs,
282
- skippedObjectIDs,
283
- visited,
284
- ),
285
- );
286
- }
287
- // Walks the same tree readPageTree() already parsed - each retained leaf
288
- // carries the page dictionary values readPageTree() read, so there is no
289
- // need to re-parse every retained page's dictionary here.
290
- walkPageTree(tree, (node) => {
291
- const isLeaf = !node.children;
292
- const skipKeys = isLeaf ? ["Parent"] : ["Count", "Kids", "Parent"];
293
- Object.entries(node.values)
294
- .filter(([key]) => !skipKeys.includes(key))
295
- .forEach(([, value]) =>
296
- assertNoDeletedPageReferences(
297
- parser,
298
- value,
299
- deletedPageIDs,
300
- skippedObjectIDs,
301
- visited,
302
- ),
303
- );
304
- });
305
212
  }
306
213
 
307
- /** 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
+ */
308
226
  function collectPageLabels(
309
227
  parser,
310
228
  dictionary,
@@ -336,7 +254,7 @@ function collectPageLabels(
336
254
  }
337
255
  if (values.Kids) {
338
256
  parser
339
- .queryDictionaryObject(dictionary, "Kids")
257
+ .queryDictionaryObject(dictionary, PdfName.KIDS)
340
258
  .toPDFArray()
341
259
  .toJSArray()
342
260
  .forEach((entry) => {
@@ -354,7 +272,14 @@ function collectPageLabels(
354
272
  return values;
355
273
  }
356
274
 
357
- /** 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
+ */
358
283
  function resolvePageLabelObject(parser, value) {
359
284
  const visited = new Set();
360
285
  let resolved = value;
@@ -369,7 +294,16 @@ function resolvePageLabelObject(parser, value) {
369
294
  return resolved;
370
295
  }
371
296
 
372
- /** 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
+ */
373
307
  function readPageLabel(parser, value) {
374
308
  const dictionary = value?.toPDFDictionary();
375
309
  if (!dictionary) {
@@ -381,6 +315,9 @@ function readPageLabel(parser, value) {
381
315
  const start = values.St ? resolvePageLabelObject(parser, values.St) : null;
382
316
  const prefixHex = prefix?.toPDFHexString();
383
317
  const prefixLiteral = prefix?.toPDFLiteralString();
318
+ if (values.P !== undefined && !prefixHex && !prefixLiteral) {
319
+ throw new Error("deletePage requires valid PageLabels entries");
320
+ }
384
321
  return {
385
322
  style: style?.toPDFName()?.value,
386
323
  prefix:
@@ -394,7 +331,13 @@ function readPageLabel(parser, value) {
394
331
  };
395
332
  }
396
333
 
397
- /** 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
+ */
398
341
  function writePageLabelObjects(objectsContext, entries) {
399
342
  return entries.map((entry) => {
400
343
  const objectID = objectsContext.startNewIndirectObject();
@@ -418,7 +361,17 @@ function writePageLabelObjects(objectsContext, entries) {
418
361
  });
419
362
  }
420
363
 
421
- /** 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
+ */
422
375
  function writePageLabelsDictionary(
423
376
  objectsContext,
424
377
  copyingContext,
@@ -427,11 +380,12 @@ function writePageLabelsDictionary(
427
380
  entries,
428
381
  ) {
429
382
  Object.keys(values).forEach((key) => {
430
- if (key === "Kids" || key === "Limits" || key === "Nums") return;
383
+ if (key === PdfName.KIDS || key === PdfName.LIMITS || key === PdfName.NUMS)
384
+ return;
431
385
  dictionary.writeKey(key);
432
386
  copyingContext.copyDirectObjectAsIs(values[key]);
433
387
  });
434
- dictionary.writeKey("Nums");
388
+ dictionary.writeKey(PdfName.NUMS);
435
389
  objectsContext.startArray();
436
390
  entries.forEach((entry) => {
437
391
  objectsContext.writeNumber(entry.index);
@@ -440,7 +394,18 @@ function writePageLabelsDictionary(
440
394
  objectsContext.endArray();
441
395
  }
442
396
 
443
- /** 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
+ */
444
409
  function preparePageLabels(
445
410
  parser,
446
411
  catalogDictionary,
@@ -509,15 +474,21 @@ function preparePageLabels(
509
474
  }
510
475
  });
511
476
  return {
512
- catalogValues,
513
477
  reference,
514
478
  values,
515
479
  entries: normalized,
516
480
  };
517
481
  }
518
482
 
519
- /** Writes updated page labels and attaches them to the catalog. @private */
520
- 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) {
521
492
  if (!pageLabels) return;
522
493
 
523
494
  const objectsContext = writer.getObjectsContext();
@@ -549,21 +520,7 @@ function writePageLabels(writer, copyingContext, rootID, pageLabels) {
549
520
  );
550
521
  objectsContext.endDictionary(labelsDictionary).endIndirectObject();
551
522
 
552
- if (writer._setPageLabelsObject) {
553
- writer._setPageLabelsObject(labelsObjectID);
554
- return;
555
- }
556
-
557
- objectsContext.startModifiedIndirectObject(rootID);
558
- const dictionary = objectsContext.startDictionary();
559
- Object.keys(pageLabels.catalogValues).forEach((key) => {
560
- if (key === "PageLabels") return;
561
- dictionary.writeKey(key);
562
- copyingContext.copyDirectObjectAsIs(pageLabels.catalogValues[key]);
563
- });
564
- dictionary.writeKey("PageLabels");
565
- objectsContext.writeIndirectObjectReference(labelsObjectID);
566
- objectsContext.endDictionary(dictionary).endIndirectObject();
523
+ setCatalogEntry(writer, PdfName.PAGE_LABELS, labelsObjectID);
567
524
  }
568
525
 
569
526
  /**
@@ -573,8 +530,9 @@ function writePageLabels(writer, copyingContext, rootID, pageLabels) {
573
530
  * @name createPage
574
531
  * @function
575
532
  * @memberof Recipe#
576
- * @param {number|string} [pageWidth] - The page width, or name of medium size.
577
- * 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.
578
536
  * @param {number} [pageHeight] - The page height, or rotation (90) when page size name given.
579
537
  * @param {object} [margins] - page margin definitions.
580
538
  * @param {number} [margins.left] - Left margin.
@@ -582,6 +540,7 @@ function writePageLabels(writer, copyingContext, rootID, pageLabels) {
582
540
  * @param {number} [margins.top] - Top margin.
583
541
  * @param {number} [margins.bottom] - Bottom margin.
584
542
  * @returns {Recipe} The recipe instance.
543
+ * @throws {Error} If pages were deleted with deletePage() on this Recipe.
585
544
  */
586
545
  exports.createPage = function createPage(pageWidth, pageHeight, margins) {
587
546
  if (this.deletedPages?.size) {
@@ -620,7 +579,8 @@ exports.createPage = function createPage(pageWidth, pageHeight, margins) {
620
579
  (this.metadata.pageCount ?? this.metadata.pages ?? 0) + 1;
621
580
  const pageNumber = this.metadata.pageCount;
622
581
  const dimensions = [0, 0, pageWidth, pageHeight];
623
- const layout = pageWidth > pageHeight ? "landscape" : "portrait";
582
+ const layout =
583
+ pageWidth > pageHeight ? PageLayout.LANDSCAPE : PageLayout.PORTRAIT;
624
584
  this.metadata[pageNumber] = {
625
585
  pageNumber,
626
586
  mediaBox: dimensions,
@@ -648,15 +608,55 @@ exports.createPage = function createPage(pageWidth, pageHeight, margins) {
648
608
  return this;
649
609
  };
650
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
+
651
640
  /**
652
641
  * Set the rotation of the current page.
653
642
  * @name rotate
654
643
  * @function
655
644
  * @memberof Recipe#
656
- * @param {number} rotation - The page rotation in degrees.
645
+ * @param {number} rotation - The page rotation in degrees, a multiple of 90.
657
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()`.
658
650
  */
659
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);
660
660
  this.page.rotate = rotation;
661
661
  this.metadata[this.pageNumber].rotate = rotation;
662
662
  return this;
@@ -667,15 +667,20 @@ exports.rotate = function rotate(rotation) {
667
667
  * @name setPageBox
668
668
  * @function
669
669
  * @memberof Recipe#
670
- * @param {number} box - An `ePDFPageBox*` constant.
670
+ * @param {number|string} box - An `ePDFPageBox*` constant or a `PageBox` name.
671
671
  * @param {number} left - The PDF left coordinate.
672
672
  * @param {number} bottom - The PDF bottom coordinate.
673
673
  * @param {number} right - The PDF right coordinate.
674
674
  * @param {number} top - The PDF top coordinate.
675
675
  * @returns {Recipe} The recipe instance.
676
676
  * @throws {RangeError} If the page box constant is unknown.
677
+ * @throws {TypeError} If no page is active.
677
678
  */
678
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
+ }
679
684
  const boxes = {
680
685
  [muhammara.ePDFPageBoxMediaBox]: "mediaBox",
681
686
  [muhammara.ePDFPageBoxCropBox]: "cropBox",
@@ -697,18 +702,19 @@ exports.setPageBox = function setPageBox(box, left, bottom, right, top) {
697
702
  mediaBox: pageBox,
698
703
  width,
699
704
  height,
700
- layout: width > height ? "landscape" : "portrait",
705
+ layout: width > height ? PageLayout.LANDSCAPE : PageLayout.PORTRAIT,
701
706
  });
702
707
  }
703
708
  return this;
704
709
  };
705
710
 
706
711
  /**
707
- * Finish a page
712
+ * Finish a page. Without an active page this does nothing.
708
713
  * @name endPage
709
714
  * @function
710
715
  * @memberof Recipe#
711
716
  * @returns {Recipe} The recipe instance.
717
+ * @throws {Error} If the page cannot be written.
712
718
  */
713
719
  exports.endPage = function endPage() {
714
720
  if (!this.page) {
@@ -736,10 +742,17 @@ exports.endPage = function endPage() {
736
742
  * @name editPage
737
743
  * @function
738
744
  * @memberof Recipe#
739
- * @param {number} pageNumber - The page number to be edited.
745
+ * @param {number} pageNumber - The one-based page number to be edited.
740
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.
741
749
  */
742
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
+ }
743
756
  const pdfWriter = this.writer;
744
757
  const pageIndex = pageNumber - 1;
745
758
  const pageModifier = new muhammara.PDFPageModifier(
@@ -762,9 +775,9 @@ exports.editPage = function editPage(pageNumber) {
762
775
  const startX = mediaBox[0];
763
776
  const startY = mediaBox[1];
764
777
  const textOptions = {
765
- font: this.writer.getFontForFile(this.fonts["helvetica-bold"]),
778
+ font: this.writer.getFontForFile(this.fonts.helvetica.b),
766
779
  size: 50,
767
- colorspace: "gray",
780
+ colorspace: Colorspace.GRAY,
768
781
  color: 0x00,
769
782
  };
770
783
  context.writeText(
@@ -801,21 +814,105 @@ exports.editPage = function editPage(pageNumber) {
801
814
  return this;
802
815
  };
803
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
+
804
887
  /**
805
888
  * Delete one or more pages from an existing PDF.
806
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.
807
899
  * @name deletePage
808
900
  * @function
809
901
  * @memberof Recipe#
810
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.
811
906
  * @returns {Recipe} The recipe instance.
907
+ * @throws {TypeError} If options is not an object or pruneReferences is not
908
+ * a boolean.
812
909
  * @throws {RangeError} If a page number does not identify an original page.
813
910
  * @throws {Error} If the Recipe has no existing source, has ended, would delete
814
- * every page, or combines deletion with page composition. Page-tree,
815
- * retained-reference, and object-generation validation is deferred to
816
- * 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.
817
914
  */
818
- exports.deletePage = function deletePage(pageNumbers) {
915
+ exports.deletePage = function deletePage(pageNumbers, options) {
819
916
  if (this.ended) {
820
917
  throw new Error("Cannot delete a page after endPDF");
821
918
  }
@@ -831,6 +928,16 @@ exports.deletePage = function deletePage(pageNumbers) {
831
928
  if (this.pagesCreated) {
832
929
  throw new Error("deletePage cannot be combined with createPage");
833
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
+ }
834
941
  pageNumbers = Array.isArray(pageNumbers) ? pageNumbers : [pageNumbers];
835
942
  const deletedPages = new Set(this.deletedPages || []);
836
943
  pageNumbers.forEach((pageNumber) => {
@@ -846,67 +953,48 @@ exports.deletePage = function deletePage(pageNumbers) {
846
953
  if (deletedPages.size >= this.sourcePageCount) {
847
954
  throw new Error("At least one page must remain in the PDF");
848
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
+ }
849
963
  this.deletedPages = deletedPages;
964
+ this.pruneReferences = prune;
850
965
  return this;
851
966
  };
852
967
 
853
- /** 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
+ */
854
978
  exports._deletePages = function _deletePages() {
855
979
  if (!this.deletedPages?.size) return this;
856
980
 
857
981
  const copyingContext = this.writer.createPDFCopyingContextForModifiedFile();
858
982
  try {
859
- const parser = copyingContext.getSourceDocumentParser();
860
- const trailer = parser.getTrailer().toJSObject();
861
- const rootReference = trailer.Root.toPDFIndirectObjectReference();
862
- const rootID = rootReference.getObjectID();
863
- const catalogDictionary = parser.parseNewObject(rootID).toPDFDictionary();
864
- const catalog = catalogDictionary.toJSObject();
865
- const pagesReference = catalog.Pages.toPDFIndirectObjectReference();
866
- const pageLabels = preparePageLabels(
867
- parser,
868
- catalogDictionary,
983
+ const deletion = planPageDeletion(
984
+ this,
985
+ copyingContext,
869
986
  this.deletedPages,
870
- this.sourcePageCount,
871
- );
872
- const modifiedPageIDs = new Set(
873
- Array.from(this.modifiedSourcePages, (pageNumber) =>
874
- parser.getPageObjectID(pageNumber - 1),
875
- ),
987
+ Boolean(this.pruneReferences),
876
988
  );
877
- const pageState = {
878
- pageNumber: 0,
879
- deletedPageIDs: new Set(),
880
- retainedPageIDs: new Set(),
881
- };
882
- const tree = readPageTree(
883
- parser,
884
- pagesReference.getObjectID(),
885
- this.deletedPages,
886
- modifiedPageIDs,
887
- pageState,
888
- pagesReference.getVersion(),
889
- );
890
- pageState.retainedPageIDs.forEach((objectID) =>
891
- pageState.deletedPageIDs.delete(objectID),
892
- );
893
- assertSupportedModifiedGenerations(
894
- tree,
895
- pageLabels,
896
- rootReference,
989
+ writePageTree(this.writer, copyingContext, deletion.tree);
990
+ writePageLabels(this.writer, copyingContext, deletion.pageLabels);
991
+ pruneDeletedPageReferences(
897
992
  this.writer,
993
+ copyingContext,
994
+ deletion.catalog,
995
+ deletion.plan,
996
+ deletion.deletedPageIDs,
898
997
  );
899
- validateDeletedPageReferences(
900
- parser,
901
- catalog,
902
- rootID,
903
- tree,
904
- pageLabels,
905
- pageState.deletedPageIDs,
906
- this.sourcePageCount,
907
- );
908
- writePageTree(this.writer, copyingContext, tree);
909
- writePageLabels(this.writer, copyingContext, rootID, pageLabels);
910
998
  } finally {
911
999
  copyingContext.end();
912
1000
  }
@@ -943,6 +1031,15 @@ exports._deletePages = function _deletePages() {
943
1031
  return this;
944
1032
  };
945
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
+ */
946
1043
  exports._resumePageRotation = function _resumePageRotation(
947
1044
  pageNumber,
948
1045
  context,
@@ -984,11 +1081,15 @@ exports._resumePageRotation = function _resumePageRotation(
984
1081
  * @name pageInfo
985
1082
  * @function
986
1083
  * @memberof Recipe#
987
- * @param {number} pageNumber - The page number.
1084
+ * @param {number} pageNumber - The one-based page number.
988
1085
  * @returns {RecipePageInfo} The page information.
1086
+ * @throws {TypeError} If the page is unknown.
989
1087
  */
990
1088
  exports.pageInfo = function pageInfo(pageNumber) {
991
1089
  const pageInfo = this.metadata[pageNumber];
1090
+ if (!pageInfo) {
1091
+ throw new TypeError(`Unknown page number: ${pageNumber}`);
1092
+ }
992
1093
  return {
993
1094
  width: pageInfo.width,
994
1095
  height: pageInfo.height,