@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.
package/README.md CHANGED
@@ -3,3 +3,7 @@
3
3
  Internal shared JavaScript runtime for the MuhammaraJS native packages. Install
4
4
  `@muhammara/native` for prebuilt binaries or `@muhammara/native-with-source` for
5
5
  local source builds.
6
+
7
+ See the [Recipe API invariants](https://muhammarajs.readthedocs.io/latest/recipe/invariants/)
8
+ for behavior that is not obvious from the method signatures alone, notably the
9
+ direct-write behavior of file-backed `endPDF()`.
package/lib/Recipe.js CHANGED
@@ -2,6 +2,7 @@ const muhammara = require("./muhammara");
2
2
  const path = require("path");
3
3
  const fs = require("fs");
4
4
  const streams = require("memory-streams");
5
+ var { standardInfoKeys } = require("./recipe-info");
5
6
 
6
7
  /**
7
8
  * @name Recipe
@@ -15,7 +16,7 @@ const streams = require("memory-streams");
15
16
  * @param {string} [options.author] - The author
16
17
  * @param {string} [options.title] - The title
17
18
  * @param {string} [options.subject] - The subject
18
- * @param {string} [options.colorspace] - The default colorspace: rgb, cmyk, gray
19
+ * @param {string} [options.colorspace] - The default colorspace: rgb, cmyk, gray, separation
19
20
  * @param {string[]} [options.keywords] - The array of keywords
20
21
  * @param {string} [options.password] - permission password
21
22
  * @param {string} [options.userPassword] - this 'view' password also enables encryption
@@ -117,7 +118,11 @@ class Recipe {
117
118
  }
118
119
  }
119
120
 
120
- this.info(this.options);
121
+ var info = {};
122
+ standardInfoKeys.forEach((key) => {
123
+ if (this.options[key] !== undefined) info[key] = this.options[key];
124
+ });
125
+ this.info(info);
121
126
  }
122
127
 
123
128
  _getVersion(version) {
@@ -211,6 +216,7 @@ class Recipe {
211
216
  this._releaseReader();
212
217
  this.pdfReader = pdfReader;
213
218
  this.metadata = metadata;
219
+ this.sourcePageCount = pages;
214
220
  isAdopted = true;
215
221
  }
216
222
  return metadata;
@@ -255,73 +261,128 @@ class Recipe {
255
261
  }
256
262
 
257
263
  /**
258
- * End the pdfDoc
264
+ * End the pdfDoc. Finalization happens once; later calls do not rewrite the
265
+ * PDF and invoke the callback with the completed output when applicable.
266
+ * A failed finalization retires the Recipe and later calls rethrow the
267
+ * original error.
259
268
  * @function
260
269
  * @memberof Recipe
261
270
  * @param {function} [callback] - The callback function.
262
271
  * @returns {*} The callback result, if a callback is provided.
263
272
  */
264
273
  endPDF(callback) {
265
- this._writeInfo();
266
- this.writer.end();
267
- // This is a temporary work around for copying context will overwrite the current one
268
- // write annotations at the end.
269
- if (
270
- (this.annotations && this.annotations.length > 0) ||
271
- (this.annotationsToWrite && this.annotationsToWrite.length > 0)
272
- ) {
273
- if (this.isBufferSrc) {
274
- const oldStream = this.outStream;
275
- this.outStream = new streams.WritableStream();
274
+ if (this.endError) {
275
+ throw this.endError;
276
+ }
277
+ if (this.ended) {
278
+ if (!callback) return;
279
+ if (!this.isBufferSrc) return callback();
280
+ return callback(this.output || this.outStream.toBuffer());
281
+ }
282
+ var deletingPages = Boolean(this.deletedPages?.size);
283
+ var deletionState = deletingPages
284
+ ? {
285
+ metadata: this.metadata,
286
+ metadataValues: { ...this.metadata },
287
+ annotations: this.annotations,
288
+ annotationsToWrite: this.annotationsToWrite,
289
+ deletedPages: this.deletedPages,
290
+ }
291
+ : null;
292
+ if (deletingPages && this.page) {
293
+ throw new Error("Finish the current page before endPDF");
294
+ }
295
+ try {
296
+ this._deletePages();
297
+ this._writeInfo();
298
+ this.writer.end();
299
+ // This is a temporary work around for copying context will overwrite the current one
300
+ // write annotations at the end.
301
+ if (
302
+ (this.annotations && this.annotations.length > 0) ||
303
+ (this.annotationsToWrite && this.annotationsToWrite.length > 0)
304
+ ) {
305
+ if (this.isBufferSrc) {
306
+ const oldStream = this.outStream;
307
+ this.outStream = new streams.WritableStream();
276
308
 
277
- this.writer = muhammara.createWriterToModify(
278
- new muhammara.PDFRStreamForBuffer(oldStream.toBuffer()),
279
- new muhammara.PDFStreamForResponse(this.outStream),
280
- Object.assign({}, this.encryptOptions, {
281
- log: this.logFile,
282
- }),
283
- );
284
- } else {
285
- this.writer = muhammara.createWriterToModify(
286
- this.output,
287
- Object.assign({}, this.encryptOptions, {
288
- modifiedFilePath: this.output,
289
- log: this.logFile,
290
- }),
291
- );
309
+ this.writer = muhammara.createWriterToModify(
310
+ new muhammara.PDFRStreamForBuffer(oldStream.toBuffer()),
311
+ new muhammara.PDFStreamForResponse(this.outStream),
312
+ Object.assign({}, this.encryptOptions, {
313
+ log: this.logFile,
314
+ }),
315
+ );
316
+ } else {
317
+ this.writer = muhammara.createWriterToModify(
318
+ this.output,
319
+ Object.assign({}, this.encryptOptions, {
320
+ modifiedFilePath: this.output,
321
+ log: this.logFile,
322
+ }),
323
+ );
324
+ }
325
+
326
+ this._writeAnnotations();
327
+ this._writeInfo();
328
+ this.writer.end();
292
329
  }
293
330
 
294
- this._writeAnnotations();
295
- this._writeInfo();
296
- this.writer.end();
297
- }
331
+ // Every step above may still read the source; _insertPages() and _encrypt()
332
+ // below rename the output, which is the source itself when no separate
333
+ // output was given, and Windows refuses that while the reader holds it.
334
+ this._releaseReader();
298
335
 
299
- // Every step above may still read the source; _insertPages() and _encrypt()
300
- // below rename the output, which is the source itself when no separate
301
- // output was given, and Windows refuses that while the reader holds it.
302
- this._releaseReader();
336
+ if (this.needToInsertPages) {
337
+ if (this.isBufferSrc) {
338
+ // eslint-disable-next-line no-console
339
+ console.log(
340
+ "Feature: Inserting Pages is not supported in Buffer Mode yet.",
341
+ );
342
+ } else {
343
+ this._insertPages();
344
+ }
345
+ }
346
+ if (this.needToEncrypt) {
347
+ if (this.isBufferSrc) {
348
+ // eslint-disable-next-line no-console
349
+ console.log(
350
+ "Feature: Encryption is not supported in Buffer Mode yet.",
351
+ );
352
+ } else {
353
+ this._encrypt();
354
+ }
355
+ }
303
356
 
304
- if (this.needToInsertPages) {
305
- if (this.isBufferSrc) {
306
- // eslint-disable-next-line no-console
307
- console.log(
308
- "Feature: Inserting Pages is not supported in Buffer Mode yet.",
357
+ if (this.isBufferSrc && this.output) {
358
+ fs.writeFileSync(this.output, this.outStream.toBuffer());
359
+ }
360
+
361
+ this.ended = true;
362
+ } catch (error) {
363
+ if (deletingPages) {
364
+ Object.keys(deletionState.metadata).forEach(
365
+ (key) => delete deletionState.metadata[key],
309
366
  );
310
- } else {
311
- this._insertPages();
367
+ Object.assign(deletionState.metadata, deletionState.metadataValues);
368
+ this.metadata = deletionState.metadata;
369
+ this.annotations = deletionState.annotations;
370
+ this.annotationsToWrite = deletionState.annotationsToWrite;
371
+ this.deletedPages = deletionState.deletedPages;
312
372
  }
313
- }
314
- if (this.needToEncrypt) {
315
- if (this.isBufferSrc) {
316
- // eslint-disable-next-line no-console
317
- console.log("Feature: Encryption is not supported in Buffer Mode yet.");
318
- } else {
319
- this._encrypt();
373
+ this.endError = error;
374
+ this.ended = true;
375
+ try {
376
+ this.writer._abort();
377
+ } catch (_) {
378
+ // Preserve the finalization error if native cleanup also fails.
320
379
  }
321
- }
322
-
323
- if (this.isBufferSrc && this.output) {
324
- fs.writeFileSync(this.output, this.outStream.toBuffer());
380
+ try {
381
+ this._releaseReader();
382
+ } catch (_) {
383
+ // Preserve the finalization error if releasing its separate reader fails.
384
+ }
385
+ throw error;
325
386
  }
326
387
 
327
388
  if (callback) {
@@ -347,7 +408,7 @@ class Recipe {
347
408
  * @throws {string} If the callback function is unnamed when no key is provided.
348
409
  * @throws {string} If the key conflicts with an existing Recipe prototype member.
349
410
  * @throws {string} If the callback is not a function.
350
- * @returns {void}
411
+ * @returns {Recipe} The recipe instance.
351
412
  */
352
413
  register(key, callback) {
353
414
  // Assume simply registering a function which will have an embedded name
@@ -368,11 +429,12 @@ class Recipe {
368
429
  }
369
430
 
370
431
  this.__proto__[key] = callback;
432
+ return this;
371
433
  }
372
434
  }
373
435
 
374
436
  function loadPrototypes() {
375
- const ignores = ["xObjectForm.js"];
437
+ const ignores = ["utils.js", "xObjectForm.js"];
376
438
  fs.readdirSync(path.join(__dirname, "recipe"))
377
439
  .filter((file) => {
378
440
  return file[0] != "." && !ignores.includes(file);
@@ -11,6 +11,7 @@
11
11
  * @param {string} [options.date] - The date.
12
12
  * @param {boolean} [options.open=false] - Open the annotation by default?
13
13
  * @param {boolean} [options.richText] - Display with rich text format, text will be transformed automatically, or you may pass in your own rich text starts with "<?xml..."
14
+ * @param {Array} [options.replies] - Array of annotation replies, each with text and optional title, date, subject, richText, and flag.
14
15
  * @param {'invisible'|'hidden'|'print'|'nozoom'|'norotate'|'noview'|'readonly'|'locked'|'togglenoview'} [options.flag] - The flag property
15
16
  * @returns {Recipe} The recipe instance.
16
17
  */
@@ -19,17 +20,52 @@ exports.comment = function comment(text = "", x, y, options = {}) {
19
20
  subtype: "Text",
20
21
  pageNumber: this.pageNumber,
21
22
  args: { text, x, y, options: Object.assign({ icon: "Comment" }, options) },
23
+ replies: options.replies,
22
24
  });
23
25
  return this;
24
26
  };
25
27
 
28
+ /**
29
+ * Add a clickable URL link to the current page.
30
+ * @name link
31
+ * @function
32
+ * @memberof Recipe#
33
+ * @param {string} url - The URL to open.
34
+ * @param {number} x - The top-left x coordinate.
35
+ * @param {number} y - The top-left y coordinate.
36
+ * @param {number} width - The link width.
37
+ * @param {number} height - The link height.
38
+ * @returns {Recipe} The recipe instance.
39
+ */
40
+ exports.link = function link(url, x, y, width, height) {
41
+ const { nx, ny } = this._calibrateCoordinate(x, y, 0, -height);
42
+ return linkPdf(this, url, nx, ny, width, height);
43
+ };
44
+
45
+ function linkPdf(recipe, url, left, bottom, width, height) {
46
+ recipe.pauseContext();
47
+ try {
48
+ recipe.writer.attachURLLinktoCurrentPage(
49
+ url,
50
+ left,
51
+ bottom,
52
+ left + width,
53
+ bottom + height,
54
+ );
55
+ } finally {
56
+ recipe.resumeContext();
57
+ }
58
+ return recipe;
59
+ }
60
+
61
+ Object.defineProperty(exports, "linkPdf", { value: linkPdf });
62
+
26
63
  /**
27
64
  * Create an annotation
28
65
  * @name annot
29
66
  * @function
30
67
  * @memberof Recipe#
31
68
  * @todo support for rich text RC
32
- * @todo support for opacity CA
33
69
  * @param {number} x - The coordinate x
34
70
  * @param {number} y - The coordinate y
35
71
  * @param {string} subtype - The markup annotation type 'Text'|'Link'|'FreeText'|'Line'|'Square'|'Circle'|'Polygon'|'PolyLine'|'Highlight'|'Underline'|'Squiggly'|'StrikeOut'|'Caret'|'Stamp'|'Ink'|'Popup'|'FileAttachment'|'Sound'|'Movie'|'Screen'|'Widget'|'PrinterMark'|'TrapNet'|'Watermark'|'3D'|'Redact'|'Projection'|'RichMedia'
@@ -47,6 +83,7 @@ exports.comment = function comment(text = "", x, y, options = {}) {
47
83
  * @param {Array} [options.replies] - Array of annotation replies
48
84
  * @param {number} [options.border] - The border width.
49
85
  * @param {string|number[]} [options.color] - The annotation color.
86
+ * @param {number} [options.opacity=1] - Annotation opacity from 0 (transparent) to 1 (opaque).
50
87
  * @param {boolean} [options.followOriginalPageRotation=false] - Preserve the original page rotation when positioning the annotation.
51
88
  * @returns {Recipe} The recipe instance.
52
89
  */
@@ -146,6 +183,11 @@ exports._annot = function _annot(subtype, args = {}, pageNumber, ref) {
146
183
  .writeKey("F")
147
184
  .writeNumberValue(getFlagBitNumberByName(params.flag));
148
185
 
186
+ var opacity = (reply || options).opacity ?? 1;
187
+ if (opacity !== 1) {
188
+ this.dictionaryContext.writeKey("CA").writeNumberValue(opacity);
189
+ }
190
+
149
191
  /**
150
192
  * Rich Text Strings
151
193
  * 12.7.3.4
@@ -252,8 +294,12 @@ exports._writeAnnotations = function _writeAnnotations() {
252
294
 
253
295
  if (annot.replies) {
254
296
  annot.replies.forEach((reply) => {
255
- annot.args.reply = reply;
256
- this._annot(annot.subtype, annot.args, annot.pageNumber, ref);
297
+ this._annot(
298
+ annot.subtype,
299
+ { ...annot.args, reply },
300
+ annot.pageNumber,
301
+ ref,
302
+ );
257
303
  });
258
304
  }
259
305
  });
@@ -289,7 +335,10 @@ exports._writeAnnotation = function _writeAnnotation(pageIndex) {
289
335
  objectsContext.startArray();
290
336
  if (pageObject["Annots"] && pageObject["Annots"].toJSArray) {
291
337
  pageObject["Annots"].toJSArray().forEach((annot) => {
292
- objectsContext.writeIndirectObjectReference(annot.getObjectID());
338
+ objectsContext.writeIndirectObjectReference(
339
+ annot.getObjectID(),
340
+ annot.getVersion(),
341
+ );
293
342
  });
294
343
  }
295
344
  this.annotations[pageIndex].forEach((item) => {
@@ -7,13 +7,18 @@ const utils = require("./utils");
7
7
  * @function
8
8
  * @memberof Recipe#
9
9
  * @param {string} pdfSrc - The path for the other pdf.
10
- * @param {number|number[]} [pages=[]] - The page number or array of page numbers to append. Omitting it appends all pages.
10
+ * @param {number|Array.<number|Array.<number>>} [pages=[]] - A one-based page
11
+ * number or array of page numbers and inclusive ranges. Omitting it appends all
12
+ * pages; endpoints beyond the source are clamped to its final page.
11
13
  * @returns {Recipe} The recipe instance.
14
+ * @throws {RangeError} If a selection is not a positive integer or a two-value
15
+ * range in ascending order.
12
16
  */
13
17
  exports.appendPage = function appendPage(pdfSrc, pages = []) {
14
- if (!Array.isArray(pages) && !isNaN(pages)) {
15
- pages = [pages];
18
+ if (this.deletedPages?.size) {
19
+ throw new Error("appendPage cannot be combined with deletePage");
16
20
  }
21
+ if (!Array.isArray(pages)) pages = [pages];
17
22
  // Using stream so it can be closed to release reader resource (Issue #61)
18
23
  const instream = new muhammara.PDFRStreamForFile(pdfSrc);
19
24
  let pageCount;
@@ -30,18 +35,25 @@ exports.appendPage = function appendPage(pdfSrc, pages = []) {
30
35
  instream.close();
31
36
  }
32
37
 
33
- // prevent unmatched pagenumber
38
+ // Preserve the established upper-bound clamping after validating that the
39
+ // caller supplied meaningful one-based page numbers.
34
40
  const transformPageNumber = (pageNum) => {
35
41
  pageNum = pageNum > pageCount ? pageCount : pageNum;
36
- pageNum = pageNum < 1 ? 1 : pageNum;
37
42
  return pageNum - 1;
38
43
  };
39
44
  pages = pages.map((element) => {
40
- if (Array.isArray(element)) {
41
- return [transformPageNumber(element[0]), transformPageNumber(element[1])];
42
- } else {
43
- return [transformPageNumber(element), transformPageNumber(element)];
45
+ let range = Array.isArray(element) ? element : [element, element];
46
+ range = range.map(Number);
47
+ if (
48
+ range.length !== 2 ||
49
+ !range.every(
50
+ (pageNumber) => Number.isInteger(pageNumber) && pageNumber > 0,
51
+ ) ||
52
+ range[1] < range[0]
53
+ ) {
54
+ throw new RangeError("Page ranges use one-based inclusive page numbers");
44
55
  }
56
+ return range.map(transformPageNumber);
45
57
  });
46
58
  if (pages.length > 0) {
47
59
  utils.appendPDFPagesFromPDFWithAnnotations(this.writer, pdfSrc, {
@@ -50,5 +62,6 @@ exports.appendPage = function appendPage(pdfSrc, pages = []) {
50
62
  } else {
51
63
  utils.appendPDFPagesFromPDFWithAnnotations(this.writer, pdfSrc);
52
64
  }
65
+ this.pagesAppended = true;
53
66
  return this;
54
67
  };
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Rotate subsequent content around a point in Recipe coordinates.
3
+ * @name rotateContent
4
+ * @function
5
+ * @memberof Recipe#
6
+ * @param {number} degrees - Clockwise rotation in degrees.
7
+ * @param {number} [x=0] - Rotation origin x coordinate.
8
+ * @param {number} [y=0] - Rotation origin y coordinate.
9
+ * @returns {Recipe} The recipe instance.
10
+ */
11
+ exports.rotateContent = function rotateContent(degrees, x = 0, y = 0) {
12
+ const radians = (degrees * Math.PI) / 180;
13
+ const cosine = Math.cos(radians);
14
+ const sine = Math.sin(radians);
15
+ const point = this._calibrateCoordinate(x, y);
16
+ this.pageContext.cm(1, 0, 0, 1, point.nx, point.ny);
17
+ this.pageContext.cm(cosine, sine, -sine, cosine, 0, 0);
18
+ this.pageContext.cm(1, 0, 0, 1, -point.nx, -point.ny);
19
+ return this;
20
+ };
@@ -58,6 +58,10 @@ exports.image = function image(imgSrc, x, y, options = {}) {
58
58
  ctx.gs(xObject.getGsName(gsId)).drawImage(0, 0, imgSrc, imgOptions);
59
59
  });
60
60
 
61
+ if (options.link) {
62
+ this.link(options.link, x + offsetX, y - offsetY - height, width, height);
63
+ }
64
+
61
65
  return this;
62
66
  };
63
67
 
@@ -1,8 +1,9 @@
1
1
  const fs = require("fs");
2
2
  const muhammara = require("../muhammara");
3
+ var { recipeInfoKeys, standardInfoKeys } = require("../recipe-info");
3
4
  /**
4
5
  * @name info
5
- * @desc Add new PDF information, or retrieve existing PDF information.
6
+ * @desc Add standard and custom PDF information, or retrieve existing PDF information. Custom keys retain their spelling when written; array values are joined with a comma and space.
6
7
  * @memberof Recipe#
7
8
  * @function
8
9
  * @param {Object} [options] - The options (when missing obtains existing PDF information)
@@ -21,6 +22,14 @@ exports.info = function info(options) {
21
22
  } else {
22
23
  this.toWriteInfo_ = this.toWriteInfo_ || {};
23
24
  Object.assign(this.toWriteInfo_, options);
25
+ Object.entries(options).forEach(([key, value]) => {
26
+ if (!standardInfoKeys.includes(key)) {
27
+ this.custom(
28
+ key,
29
+ Array.isArray(value) ? value.join(", ") : String(value),
30
+ );
31
+ }
32
+ });
24
33
  result = this;
25
34
  }
26
35
 
@@ -105,24 +114,10 @@ exports._writeInfo = function _writeInfo() {
105
114
  */
106
115
 
107
116
  const infoDictionary = this.writer.getDocumentContext().getInfoDictionary();
108
- const fields = [
109
- {
110
- key: "author",
111
- type: "string",
112
- },
113
- {
114
- key: "title",
115
- type: "string",
116
- },
117
- {
118
- key: "subject",
119
- type: "string",
120
- },
121
- {
122
- key: "keywords",
123
- type: "array",
124
- },
125
- ];
117
+ var fields = standardInfoKeys.map((key) => ({
118
+ key,
119
+ type: key === recipeInfoKeys.keywords ? "array" : "string",
120
+ }));
126
121
  // const ignores = [
127
122
  // 'CreationDate', 'Creator', 'ModDate', 'Producer'
128
123
  // ];
@@ -11,26 +11,31 @@ const utils = require("./utils");
11
11
  * @param {number} srcPageNumber - The page number to be insterted from the other pdf.
12
12
  * @returns {Recipe} The recipe instance.
13
13
  * @throws {Error} If afterPageNumber is not a number.
14
+ * @throws {TypeError} If pdfSrc or srcPageNumber is missing.
14
15
  */
15
16
  exports.insertPage = function insertPage(
16
17
  afterPageNumber,
17
18
  pdfSrc,
18
19
  srcPageNumber,
19
20
  ) {
21
+ if (this.deletedPages?.size) {
22
+ throw new Error("insertPage cannot be combined with deletePage");
23
+ }
20
24
  if (isNaN(afterPageNumber)) {
21
25
  throw new Error("The afterPageNumber is inValid.");
22
26
  }
23
- this.needToInsertPages = true;
24
- if (pdfSrc && srcPageNumber) {
25
- this.insertInformation = this.insertInformation || {};
26
- this.insertInformation[afterPageNumber] =
27
- this.insertInformation[afterPageNumber] || [];
28
- this.insertInformation[afterPageNumber].push({
29
- afterPageNumber,
30
- pdfSrc,
31
- srcPageNumber,
32
- });
27
+ if (!pdfSrc || !srcPageNumber) {
28
+ throw new TypeError("insertPage requires pdfSrc and srcPageNumber");
33
29
  }
30
+ this.insertInformation = this.insertInformation || {};
31
+ this.insertInformation[afterPageNumber] =
32
+ this.insertInformation[afterPageNumber] || [];
33
+ this.insertInformation[afterPageNumber].push({
34
+ afterPageNumber,
35
+ pdfSrc,
36
+ srcPageNumber,
37
+ });
38
+ this.needToInsertPages = true;
34
39
  return this;
35
40
  };
36
41