@muhammara/native-core 7.0.0-beta.1 → 7.0.0-beta.3

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.
@@ -1,5 +1,570 @@
1
1
  const muhammara = require("../muhammara");
2
2
 
3
+ /** Builds the retained page tree while marking deleted leaf pages. @private */
4
+ function readPageTree(
5
+ parser,
6
+ objectID,
7
+ deletedPages,
8
+ modifiedPageIDs,
9
+ pageState,
10
+ generation = 0,
11
+ visited = new Set(),
12
+ depth = 0,
13
+ ) {
14
+ if (depth > 1000 || visited.has(objectID)) {
15
+ throw new Error("deletePage requires a valid acyclic page tree");
16
+ }
17
+ visited.add(objectID);
18
+ const dictionary = parser.parseNewObject(objectID).toPDFDictionary();
19
+ const values = dictionary.toJSObject();
20
+ if (depth === 0 && values.Parent !== undefined) {
21
+ throw new Error("deletePage requires a valid page tree");
22
+ }
23
+ const kids = parser.queryDictionaryObject(dictionary, "Kids").toPDFArray();
24
+ const mappedChildren = kids.toJSArray().map((entry) => {
25
+ const reference = entry.toPDFIndirectObjectReference();
26
+ const childID = reference.getObjectID();
27
+ const childDictionary = parser.parseNewObject(childID).toPDFDictionary();
28
+ const childValues = childDictionary.toJSObject();
29
+ const parentReference = childValues.Parent?.toPDFIndirectObjectReference();
30
+ if (
31
+ !parentReference ||
32
+ parentReference.getObjectID() !== objectID ||
33
+ parentReference.getVersion() !== generation
34
+ ) {
35
+ throw new Error("deletePage requires a valid page tree");
36
+ }
37
+ const type = childValues.Type.toPDFName().value;
38
+ if (type === "Pages") {
39
+ return readPageTree(
40
+ parser,
41
+ childID,
42
+ deletedPages,
43
+ modifiedPageIDs,
44
+ pageState,
45
+ reference.getVersion(),
46
+ visited,
47
+ depth + 1,
48
+ );
49
+ }
50
+ pageState.pageNumber += 1;
51
+ // Check deletion before the modified-generation restriction: a deleted
52
+ // page is dropped from the Kids array entirely, never rewritten, so its
53
+ // own generation is irrelevant even if it was also edited.
54
+ if (deletedPages.has(pageState.pageNumber)) {
55
+ pageState.deletedPageIDs.add(childID);
56
+ return null;
57
+ }
58
+ if (modifiedPageIDs.has(childID) && reference.getVersion() !== 0) {
59
+ throw new Error(
60
+ "deletePage does not support rewriting nonzero-generation objects",
61
+ );
62
+ }
63
+ pageState.retainedPageIDs.add(childID);
64
+ return {
65
+ objectID: childID,
66
+ generation: reference.getVersion(),
67
+ count: 1,
68
+ values: childValues,
69
+ };
70
+ });
71
+
72
+ const children = mappedChildren.filter((child) => child && child.count);
73
+ return {
74
+ objectID,
75
+ generation,
76
+ values,
77
+ children,
78
+ count: mappedChildren.reduce(
79
+ (count, child) => count + (child?.count || 0),
80
+ 0,
81
+ ),
82
+ changed: mappedChildren.some((child) => !child || child.changed),
83
+ };
84
+ }
85
+
86
+ /** Writes changed page-tree nodes back to the modified PDF. @private */
87
+ function writePageTree(writer, copyingContext, node) {
88
+ node.children
89
+ .filter((child) => child.children && child.changed)
90
+ .forEach((child) => writePageTree(writer, copyingContext, child));
91
+ if (!node.changed) return;
92
+
93
+ const objectsContext = writer.getObjectsContext();
94
+ objectsContext.startModifiedIndirectObject(node.objectID);
95
+ const dictionary = objectsContext.startDictionary();
96
+ Object.keys(node.values).forEach((key) => {
97
+ if (key === "Count" || key === "Kids") return;
98
+ dictionary.writeKey(key);
99
+ copyingContext.copyDirectObjectAsIs(node.values[key]);
100
+ });
101
+ dictionary.writeKey("Count");
102
+ objectsContext.writeNumber(node.count);
103
+ dictionary.writeKey("Kids");
104
+ objectsContext.startArray();
105
+ node.children.forEach((child) => {
106
+ objectsContext.writeIndirectObjectReference(
107
+ child.objectID,
108
+ child.generation || 0,
109
+ );
110
+ });
111
+ objectsContext.endArray().endDictionary(dictionary).endIndirectObject();
112
+ }
113
+
114
+ /**
115
+ * Visits every retained node in a page tree - Pages nodes and leaf pages
116
+ * alike - depth-first. Shared by every deletePage() pass that needs to walk
117
+ * the tree readPageTree() already built, instead of re-parsing or re-walking
118
+ * it independently.
119
+ */
120
+ function walkPageTree(tree, visit) {
121
+ const pending = [tree];
122
+ while (pending.length) {
123
+ const node = pending.pop();
124
+ visit(node);
125
+ if (node.children) pending.push(...node.children);
126
+ }
127
+ }
128
+
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) {
138
+ walkPageTree(tree, (node) => {
139
+ if (!node.children) return;
140
+ if (node.changed && node.generation !== 0) {
141
+ throw new Error(
142
+ "deletePage does not support rewriting nonzero-generation objects",
143
+ );
144
+ }
145
+ });
146
+ if (pageLabels?.reference && pageLabels.reference.getVersion() !== 0) {
147
+ throw new Error(
148
+ "deletePage does not support rewriting nonzero-generation objects",
149
+ );
150
+ }
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
+ }
305
+
306
+ /** Collects page-label number-tree entries. @private */
307
+ function collectPageLabels(
308
+ parser,
309
+ dictionary,
310
+ entries,
311
+ visited = new Set(),
312
+ objectID = 0,
313
+ depth = 0,
314
+ ) {
315
+ if (depth > 1000 || (objectID && visited.has(objectID))) {
316
+ throw new Error("deletePage requires valid acyclic PageLabels");
317
+ }
318
+ if (objectID) visited.add(objectID);
319
+ const values = dictionary.toJSObject();
320
+ if (values.Nums) {
321
+ const numbersArray = resolvePageLabelObject(
322
+ parser,
323
+ values.Nums,
324
+ ).toPDFArray();
325
+ const numbers = numbersArray.toJSArray();
326
+ for (let index = 0; index < numbers.length; index += 2) {
327
+ entries.push({
328
+ index: resolvePageLabelObject(parser, numbers[index]).toNumber(),
329
+ value: readPageLabel(
330
+ parser,
331
+ resolvePageLabelObject(parser, numbers[index + 1]),
332
+ ),
333
+ });
334
+ }
335
+ }
336
+ if (values.Kids) {
337
+ parser
338
+ .queryDictionaryObject(dictionary, "Kids")
339
+ .toPDFArray()
340
+ .toJSArray()
341
+ .forEach((entry) => {
342
+ const objectID = entry.toPDFIndirectObjectReference().getObjectID();
343
+ collectPageLabels(
344
+ parser,
345
+ resolvePageLabelObject(parser, entry).toPDFDictionary(),
346
+ entries,
347
+ visited,
348
+ objectID,
349
+ depth + 1,
350
+ );
351
+ });
352
+ }
353
+ return values;
354
+ }
355
+
356
+ /** Resolves an indirect chain used by a page-label number tree. @private */
357
+ function resolvePageLabelObject(parser, value) {
358
+ const visited = new Set();
359
+ let resolved = value;
360
+ while (resolved?.toPDFIndirectObjectReference?.()) {
361
+ const objectID = resolved.toPDFIndirectObjectReference().getObjectID();
362
+ if (visited.size > 1000 || visited.has(objectID)) {
363
+ throw new Error("deletePage requires valid acyclic PageLabels");
364
+ }
365
+ visited.add(objectID);
366
+ resolved = parser.parseNewObject(objectID);
367
+ }
368
+ return resolved;
369
+ }
370
+
371
+ /** Reads a page-label dictionary into a serializable value. @private */
372
+ function readPageLabel(parser, value) {
373
+ const dictionary = value?.toPDFDictionary();
374
+ if (!dictionary) {
375
+ throw new Error("deletePage requires valid PageLabels entries");
376
+ }
377
+ const values = dictionary.toJSObject();
378
+ const style = values.S ? resolvePageLabelObject(parser, values.S) : null;
379
+ const prefix = values.P ? resolvePageLabelObject(parser, values.P) : null;
380
+ const start = values.St ? resolvePageLabelObject(parser, values.St) : null;
381
+ const prefixHex = prefix?.toPDFHexString();
382
+ const prefixLiteral = prefix?.toPDFLiteralString();
383
+ return {
384
+ style: style?.toPDFName()?.value,
385
+ prefix:
386
+ values.P === undefined
387
+ ? undefined
388
+ : {
389
+ bytes: (prefixHex || prefixLiteral).toBytesArray(),
390
+ isHex: Boolean(prefixHex),
391
+ },
392
+ start: start?.toNumber(),
393
+ };
394
+ }
395
+
396
+ /** Writes indirect objects for normalized page-label values. @private */
397
+ function writePageLabelObjects(objectsContext, entries) {
398
+ return entries.map((entry) => {
399
+ const objectID = objectsContext.startNewIndirectObject();
400
+ const dictionary = objectsContext.startDictionary();
401
+ if (entry.value.style !== undefined) {
402
+ dictionary.writeKey("S");
403
+ objectsContext.writeName(entry.value.style);
404
+ }
405
+ if (entry.value.prefix !== undefined) {
406
+ dictionary.writeKey("P");
407
+ objectsContext[
408
+ entry.value.prefix.isHex ? "writeHexString" : "writeLiteralString"
409
+ ](entry.value.prefix.bytes);
410
+ }
411
+ if (entry.value.start !== undefined) {
412
+ dictionary.writeKey("St");
413
+ objectsContext.writeNumber(entry.value.start);
414
+ }
415
+ objectsContext.endDictionary(dictionary).endIndirectObject();
416
+ return { index: entry.index, objectID };
417
+ });
418
+ }
419
+
420
+ /** Writes the number-tree dictionary for page labels. @private */
421
+ function writePageLabelsDictionary(
422
+ objectsContext,
423
+ copyingContext,
424
+ dictionary,
425
+ values,
426
+ entries,
427
+ ) {
428
+ Object.keys(values).forEach((key) => {
429
+ if (key === "Kids" || key === "Limits" || key === "Nums") return;
430
+ dictionary.writeKey(key);
431
+ copyingContext.copyDirectObjectAsIs(values[key]);
432
+ });
433
+ dictionary.writeKey("Nums");
434
+ objectsContext.startArray();
435
+ entries.forEach((entry) => {
436
+ objectsContext.writeNumber(entry.index);
437
+ objectsContext.writeIndirectObjectReference(entry.objectID);
438
+ });
439
+ objectsContext.endArray();
440
+ }
441
+
442
+ /** Reindexes page labels after removing source pages. @private */
443
+ function preparePageLabels(
444
+ parser,
445
+ catalogDictionary,
446
+ deletedPages,
447
+ sourcePageCount,
448
+ ) {
449
+ const catalogValues = catalogDictionary.toJSObject();
450
+ if (!catalogValues.PageLabels) {
451
+ return null;
452
+ }
453
+ const pageLabelsValue = resolvePageLabelObject(
454
+ parser,
455
+ catalogValues.PageLabels,
456
+ );
457
+ if (pageLabelsValue.toPDFNull()) return null;
458
+ const labelsDictionary = pageLabelsValue.toPDFDictionary();
459
+ if (!labelsDictionary) {
460
+ throw new Error("deletePage requires PageLabels to be a number tree");
461
+ }
462
+ const entries = [];
463
+ const reference = catalogValues.PageLabels.toPDFIndirectObjectReference();
464
+ const values = collectPageLabels(
465
+ parser,
466
+ labelsDictionary,
467
+ entries,
468
+ new Set(),
469
+ reference?.getObjectID(),
470
+ );
471
+ const deletedIndices = new Set(
472
+ Array.from(deletedPages, (pageNumber) => pageNumber - 1),
473
+ );
474
+ const outputIndices = new Map();
475
+ let outputIndex = 0;
476
+ for (let index = 0; index < sourcePageCount; index += 1) {
477
+ if (deletedIndices.has(index)) continue;
478
+ outputIndices.set(index, outputIndex);
479
+ outputIndex += 1;
480
+ }
481
+ const sortedEntries = entries.sort((left, right) => left.index - right.index);
482
+ const normalized = [];
483
+ sortedEntries.forEach((entry, entryIndex) => {
484
+ const rangeEnd = Math.min(
485
+ sortedEntries[entryIndex + 1]?.index ?? sourcePageCount,
486
+ sourcePageCount,
487
+ );
488
+ let startsRun = true;
489
+ for (let index = entry.index; index < rangeEnd; index += 1) {
490
+ if (deletedIndices.has(index)) {
491
+ startsRun = true;
492
+ continue;
493
+ }
494
+ if (startsRun) {
495
+ const offset = index - entry.index;
496
+ normalized.push({
497
+ index: outputIndices.get(index),
498
+ value:
499
+ offset && entry.value.style !== undefined
500
+ ? {
501
+ ...entry.value,
502
+ start: (entry.value.start ?? 1) + offset,
503
+ }
504
+ : entry.value,
505
+ });
506
+ startsRun = false;
507
+ }
508
+ }
509
+ });
510
+ return {
511
+ catalogValues,
512
+ reference,
513
+ values,
514
+ entries: normalized,
515
+ };
516
+ }
517
+
518
+ /** Writes updated page labels and attaches them to the catalog. @private */
519
+ function writePageLabels(writer, copyingContext, rootID, pageLabels) {
520
+ if (!pageLabels) return;
521
+
522
+ const objectsContext = writer.getObjectsContext();
523
+ const entries = writePageLabelObjects(objectsContext, pageLabels.entries);
524
+ if (pageLabels.reference) {
525
+ objectsContext.startModifiedIndirectObject(
526
+ pageLabels.reference.getObjectID(),
527
+ );
528
+ const dictionary = objectsContext.startDictionary();
529
+ writePageLabelsDictionary(
530
+ objectsContext,
531
+ copyingContext,
532
+ dictionary,
533
+ pageLabels.values,
534
+ entries,
535
+ );
536
+ objectsContext.endDictionary(dictionary).endIndirectObject();
537
+ return;
538
+ }
539
+
540
+ const labelsObjectID = objectsContext.startNewIndirectObject();
541
+ const labelsDictionary = objectsContext.startDictionary();
542
+ writePageLabelsDictionary(
543
+ objectsContext,
544
+ copyingContext,
545
+ labelsDictionary,
546
+ pageLabels.values,
547
+ entries,
548
+ );
549
+ objectsContext.endDictionary(labelsDictionary).endIndirectObject();
550
+
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();
566
+ }
567
+
3
568
  /**
4
569
  * Create a new page, specifying either actual width and height, or the name
5
570
  * of a supported page size (eg. 'letter', 'letter-size')
@@ -18,6 +583,9 @@ const muhammara = require("../muhammara");
18
583
  * @returns {Recipe} The recipe instance.
19
584
  */
20
585
  exports.createPage = function createPage(pageWidth, pageHeight, margins) {
586
+ if (this.deletedPages?.size) {
587
+ throw new Error("createPage cannot be combined with deletePage");
588
+ }
21
589
  if (!pageWidth && !pageHeight) {
22
590
  [pageWidth, pageHeight] = this.default.pageSize;
23
591
  } else if (
@@ -67,6 +635,8 @@ exports.createPage = function createPage(pageWidth, pageHeight, margins) {
67
635
  this.pageNumber = pageNumber;
68
636
  this.pageContext = this.writer.startPageContentContext(this.page);
69
637
  this.editingPage = false;
638
+ this.contextState = "active-new";
639
+ if (!this.isNewPDF) this.pagesCreated = true;
70
640
 
71
641
  if (margins) {
72
642
  this.margins(margins);
@@ -76,6 +646,61 @@ exports.createPage = function createPage(pageWidth, pageHeight, margins) {
76
646
  return this;
77
647
  };
78
648
 
649
+ /**
650
+ * Set the rotation of the current page.
651
+ * @name rotate
652
+ * @function
653
+ * @memberof Recipe#
654
+ * @param {number} rotation - The page rotation in degrees.
655
+ * @returns {Recipe} The recipe instance.
656
+ */
657
+ exports.rotate = function rotate(rotation) {
658
+ this.page.rotate = rotation;
659
+ this.metadata[this.pageNumber].rotate = rotation;
660
+ return this;
661
+ };
662
+
663
+ /**
664
+ * Set a page box on the active new page.
665
+ * @name setPageBox
666
+ * @function
667
+ * @memberof Recipe#
668
+ * @param {number} box - An `ePDFPageBox*` constant.
669
+ * @param {number} left - The PDF left coordinate.
670
+ * @param {number} bottom - The PDF bottom coordinate.
671
+ * @param {number} right - The PDF right coordinate.
672
+ * @param {number} top - The PDF top coordinate.
673
+ * @returns {Recipe} The recipe instance.
674
+ * @throws {RangeError} If the page box constant is unknown.
675
+ */
676
+ exports.setPageBox = function setPageBox(box, left, bottom, right, top) {
677
+ const boxes = {
678
+ [muhammara.ePDFPageBoxMediaBox]: "mediaBox",
679
+ [muhammara.ePDFPageBoxCropBox]: "cropBox",
680
+ [muhammara.ePDFPageBoxBleedBox]: "bleedBox",
681
+ [muhammara.ePDFPageBoxTrimBox]: "trimBox",
682
+ [muhammara.ePDFPageBoxArtBox]: "artBox",
683
+ };
684
+ if (!(box in boxes)) {
685
+ throw new RangeError(`Unknown page box: ${box}`);
686
+ }
687
+
688
+ const pageBox = [left, bottom, right, top];
689
+ this.page[boxes[box]] = pageBox;
690
+ if (box === muhammara.ePDFPageBoxMediaBox) {
691
+ const page = this.metadata[this.pageNumber];
692
+ const width = right - left;
693
+ const height = top - bottom;
694
+ Object.assign(page, {
695
+ mediaBox: pageBox,
696
+ width,
697
+ height,
698
+ layout: width > height ? "landscape" : "portrait",
699
+ });
700
+ }
701
+ return this;
702
+ };
703
+
79
704
  /**
80
705
  * Finish a page
81
706
  * @name endPage
@@ -89,14 +714,16 @@ exports.endPage = function endPage() {
89
714
  }
90
715
 
91
716
  if (this.page.endContext) {
92
- this.page.endContext();
717
+ if (this.contextState === "active-edit") this.page.endContext();
93
718
  this.page.writePage();
94
719
  } else {
95
720
  this.writer.writePage(this.page);
96
721
  }
97
- // this.page = null;
98
- // this.pageContext = null;
99
- // this.pageNumber = 0;
722
+ this.page = null;
723
+ this.pageContext = null;
724
+ this.pageNumber = 0;
725
+ this.editingPage = false;
726
+ this.contextState = "idle";
100
727
 
101
728
  return this;
102
729
  };
@@ -121,6 +748,8 @@ exports.editPage = function editPage(pageNumber) {
121
748
  this.pageNumber = pageNumber;
122
749
  this.pageContext = pageModifier.startContext().getContext();
123
750
  this.editingPage = true;
751
+ this.contextState = "active-edit";
752
+ this.modifiedSourcePages.add(pageNumber);
124
753
 
125
754
  this._resumePageRotation(pageNumber);
126
755
 
@@ -169,6 +798,148 @@ exports.editPage = function editPage(pageNumber) {
169
798
  return this;
170
799
  };
171
800
 
801
+ /**
802
+ * Delete one or more pages from an existing PDF.
803
+ * Page numbers are one-based and refer to the original source document.
804
+ * @name deletePage
805
+ * @function
806
+ * @memberof Recipe#
807
+ * @param {number|number[]} pageNumbers - Page number or page numbers to delete.
808
+ * @returns {Recipe} The recipe instance.
809
+ * @throws {RangeError} If a page number does not identify an original page.
810
+ * @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.
814
+ */
815
+ exports.deletePage = function deletePage(pageNumbers) {
816
+ if (this.ended) {
817
+ throw new Error("Cannot delete a page after endPDF");
818
+ }
819
+ if (this.isNewPDF) {
820
+ throw new Error("deletePage requires an existing PDF");
821
+ }
822
+ if (this.needToInsertPages) {
823
+ throw new Error("deletePage cannot be combined with insertPage");
824
+ }
825
+ if (this.pagesAppended) {
826
+ throw new Error("deletePage cannot be combined with appendPage");
827
+ }
828
+ if (this.pagesCreated) {
829
+ throw new Error("deletePage cannot be combined with createPage");
830
+ }
831
+ pageNumbers = Array.isArray(pageNumbers) ? pageNumbers : [pageNumbers];
832
+ const deletedPages = new Set(this.deletedPages || []);
833
+ pageNumbers.forEach((pageNumber) => {
834
+ if (
835
+ !Number.isInteger(pageNumber) ||
836
+ pageNumber < 1 ||
837
+ pageNumber > this.sourcePageCount
838
+ ) {
839
+ throw new RangeError("pageNumber must identify an existing page");
840
+ }
841
+ deletedPages.add(pageNumber);
842
+ });
843
+ if (deletedPages.size >= this.sourcePageCount) {
844
+ throw new Error("At least one page must remain in the PDF");
845
+ }
846
+ this.deletedPages = deletedPages;
847
+ return this;
848
+ };
849
+
850
+ /** Applies queued page deletions during finalization. @private */
851
+ exports._deletePages = function _deletePages() {
852
+ if (!this.deletedPages?.size) return this;
853
+
854
+ const copyingContext = this.writer.createPDFCopyingContextForModifiedFile();
855
+ 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,
866
+ this.deletedPages,
867
+ this.sourcePageCount,
868
+ );
869
+ const modifiedPageIDs = new Set(
870
+ Array.from(this.modifiedSourcePages, (pageNumber) =>
871
+ parser.getPageObjectID(pageNumber - 1),
872
+ ),
873
+ );
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,
894
+ this.writer,
895
+ );
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
+ } finally {
908
+ copyingContext.end();
909
+ }
910
+
911
+ const retained = Object.keys(this.metadata)
912
+ .filter((key) => /^\d+$/.test(key))
913
+ .map(Number)
914
+ .filter((pageNumber) => !this.deletedPages.has(pageNumber))
915
+ .map((pageNumber) => this.metadata[pageNumber]);
916
+ Object.keys(this.metadata)
917
+ .filter((key) => /^\d+$/.test(key))
918
+ .forEach((key) => delete this.metadata[key]);
919
+ const pageNumberMap = new Map();
920
+ retained.forEach((page, index) => {
921
+ pageNumberMap.set(page.pageNumber, index + 1);
922
+ this.metadata[index + 1] = { ...page, pageNumber: index + 1 };
923
+ });
924
+ this.annotationsToWrite = this.annotationsToWrite
925
+ .filter((annotation) => pageNumberMap.has(annotation.pageNumber))
926
+ .map((annotation) => ({
927
+ ...annotation,
928
+ pageNumber: pageNumberMap.get(annotation.pageNumber),
929
+ }));
930
+ const annotations = [];
931
+ pageNumberMap.forEach((newPageNumber, oldPageNumber) => {
932
+ if (this.annotations[oldPageNumber - 1]) {
933
+ annotations[newPageNumber - 1] = this.annotations[oldPageNumber - 1];
934
+ }
935
+ });
936
+ this.annotations = annotations;
937
+ this.metadata.pages = retained.length;
938
+ this.metadata.pageCount = retained.length;
939
+ this.deletedPages = null;
940
+ return this;
941
+ };
942
+
172
943
  exports._resumePageRotation = function _resumePageRotation(
173
944
  pageNumber,
174
945
  context,
@@ -211,7 +982,7 @@ exports._resumePageRotation = function _resumePageRotation(
211
982
  * @function
212
983
  * @memberof Recipe#
213
984
  * @param {number} pageNumber - The page number.
214
- * @returns {{width: number, height: number, rotate: number, pageNumber: number}} The page information.
985
+ * @returns {RecipePageInfo} The page information.
215
986
  */
216
987
  exports.pageInfo = function pageInfo(pageNumber) {
217
988
  const pageInfo = this.metadata[pageNumber];
@@ -223,20 +994,39 @@ exports.pageInfo = function pageInfo(pageNumber) {
223
994
  };
224
995
  };
225
996
 
997
+ /**
998
+ * Get information about the current page.
999
+ * @name getCurrentPageInfo
1000
+ * @function
1001
+ * @memberof Recipe#
1002
+ * @returns {RecipePageInfo|null} The current page information, or null when no page has been created or edited.
1003
+ */
1004
+ exports.getCurrentPageInfo = function getCurrentPageInfo() {
1005
+ const pageNumber =
1006
+ this.pageNumber || this.metadata?.pageCount || this.metadata?.pages;
1007
+ return pageNumber ? this.pageInfo(pageNumber) : null;
1008
+ };
1009
+
226
1010
  /**
227
1011
  * Pause the current page content context.
228
1012
  * @name pauseContext
229
1013
  * @function
230
1014
  * @memberof Recipe#
231
- * @returns {void}
1015
+ * @returns {Recipe} The recipe instance.
1016
+ * @throws {Error} If there is no active page content context.
232
1017
  */
233
1018
  exports.pauseContext = function pauseContext() {
234
- if (this.page && this.page.endContext) {
1019
+ if (this.contextState === "active-edit") {
235
1020
  this.page.endContext();
236
- // this.writer.pausePageContentContext(this.pageContext);
237
- } else if (this.pageContext) {
1021
+ this.pageContext = null;
1022
+ this.contextState = "paused-edit";
1023
+ } else if (this.contextState === "active-new") {
238
1024
  this.writer.pausePageContentContext(this.pageContext);
1025
+ this.contextState = "paused-new";
1026
+ } else {
1027
+ throw new Error("No active page content context to pause");
239
1028
  }
1029
+ return this;
240
1030
  };
241
1031
 
242
1032
  /**
@@ -244,13 +1034,20 @@ exports.pauseContext = function pauseContext() {
244
1034
  * @name resumeContext
245
1035
  * @function
246
1036
  * @memberof Recipe#
247
- * @returns {void}
1037
+ * @returns {Recipe} The recipe instance.
1038
+ * @throws {Error} If there is no paused page content context.
248
1039
  */
249
1040
  exports.resumeContext = function resumeContext() {
250
- if (!this.isNewPDF && this.page) {
1041
+ if (this.contextState === "paused-edit") {
251
1042
  this.pageContext = this.page.startContext().getContext();
252
1043
  this._resumePageRotation();
1044
+ this.contextState = "active-edit";
1045
+ } else if (this.contextState === "paused-new") {
1046
+ this.contextState = "active-new";
1047
+ } else {
1048
+ throw new Error("No paused page content context to resume");
253
1049
  }
1050
+ return this;
254
1051
  };
255
1052
 
256
1053
  /**