@muhammara/native-core 7.0.0-alpha.1 → 7.0.0-beta.2

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/index.js CHANGED
@@ -23,6 +23,51 @@ exports.createMuhammara = function createMuhammara(muhammara) {
23
23
  eventParams.writer = this;
24
24
  this.getEvents().emit(eventName, eventParams);
25
25
  };
26
+ muhammara.PDFWriter.prototype.replaceObject = function (
27
+ pageIndex,
28
+ sourceObjectId,
29
+ replacementObjectId,
30
+ options,
31
+ ) {
32
+ if (options && options.scope === "global") {
33
+ var copyingContext = this.createPDFCopyingContextForModifiedFile();
34
+ var pageCount = copyingContext.getSourceDocumentParser().getPagesCount();
35
+
36
+ copyingContext.end();
37
+ for (var index = 0; index < pageCount; ++index) {
38
+ this.replaceObject(index, sourceObjectId, replacementObjectId);
39
+ }
40
+ return this;
41
+ }
42
+
43
+ var copyingContext = this.createPDFCopyingContextForModifiedFile();
44
+ var parser = copyingContext.getSourceDocumentParser();
45
+ var pageObjectId = parser.getPageObjectID(pageIndex);
46
+ var pageObject = parser.parsePage(pageIndex).getDictionary().toJSObject();
47
+ var objectsContext = this.getObjectsContext();
48
+ var pageDictionary;
49
+
50
+ objectsContext.startModifiedIndirectObject(pageObjectId);
51
+ pageDictionary = objectsContext.startDictionary();
52
+
53
+ Object.getOwnPropertyNames(pageObject).forEach(function (key) {
54
+ var value = pageObject[key];
55
+
56
+ pageDictionary.writeKey(key);
57
+ if (
58
+ value.getType() === muhammara.ePDFObjectIndirectObjectReference &&
59
+ value.toPDFIndirectObjectReference().getObjectID() === sourceObjectId
60
+ ) {
61
+ pageDictionary.writeObjectReferenceValue(replacementObjectId);
62
+ } else {
63
+ copyingContext.copyDirectObjectAsIs(value);
64
+ }
65
+ });
66
+
67
+ objectsContext.endDictionary(pageDictionary).endIndirectObject();
68
+ copyingContext.end();
69
+ return this;
70
+ };
26
71
  muhammara.PDFStreamForResponse = require("./lib/PDFStreamForResponse");
27
72
  muhammara.PDFWStreamForFile = require("./lib/PDFWStreamForFile");
28
73
  muhammara.PDFRStreamForFile = require("./lib/PDFRStreamForFile");
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
@@ -11,11 +12,11 @@ const streams = require("memory-streams");
11
12
  * @param {string|Buffer} src - The file path or Buffer of the source file.
12
13
  * @param {string} [output] - The path of the output file uses src if its not a buffer.
13
14
  * @param {Object} [options] - The options for pdfDoc
14
- * @param {number} [options.version] - The pdf version
15
+ * @param {number} [options.version] - The pdf version: 1.0 through 1.7 or 2.0, defaults to 1.7
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,11 +118,15 @@ 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) {
124
- const supportedVersions = [1.0, 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7];
129
+ const supportedVersions = [1.0, 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 2.0];
125
130
  if (!supportedVersions.includes(version)) {
126
131
  version = 1.7;
127
132
  }
@@ -149,12 +154,14 @@ class Recipe {
149
154
  */
150
155
  read(inSrc) {
151
156
  const isForExternal = inSrc ? true : false;
157
+ let pdfReader = null;
158
+ let isAdopted = false;
152
159
  try {
153
160
  let src = isForExternal ? inSrc : this.src;
154
161
  if (this.isBufferSrc) {
155
162
  src = new muhammara.PDFRStreamForBuffer(this.src);
156
163
  }
157
- const pdfReader = muhammara.createReader(src, this.encryptOptions);
164
+ pdfReader = muhammara.createReader(src, this.encryptOptions);
158
165
  const pages = pdfReader.getPagesCount();
159
166
  if (pages == 0) {
160
167
  // broken or modify password protected
@@ -206,77 +213,176 @@ class Recipe {
206
213
  metadata[page.pageNumber] = page;
207
214
  }
208
215
  if (!isForExternal) {
216
+ this._releaseReader();
209
217
  this.pdfReader = pdfReader;
210
218
  this.metadata = metadata;
219
+ this.sourcePageCount = pages;
220
+ isAdopted = true;
211
221
  }
212
222
  return metadata;
213
223
  } catch (err) {
214
224
  throw new Error(err);
225
+ } finally {
226
+ // Only the recipe source reader outlives read(); anything else would keep
227
+ // the file open until the process exits.
228
+ if (pdfReader && !isAdopted) {
229
+ pdfReader.end();
230
+ }
231
+ }
232
+ }
233
+
234
+ /**
235
+ * Release the source reader and the file handle it holds.
236
+ * @private
237
+ * @returns {void}
238
+ */
239
+ _releaseReader() {
240
+ const pdfReader = this.pdfReader;
241
+ if (!pdfReader) {
242
+ return;
215
243
  }
244
+ this.pdfReader = null;
245
+ pdfReader.end();
216
246
  }
217
247
 
218
248
  /**
219
- * End the pdfDoc
249
+ * Get the source reader, which endPDF() releases.
250
+ * @private
251
+ * @returns {Object} The source PDF reader.
252
+ * @throws {Error} If the reader was already released by endPDF().
253
+ */
254
+ _getReader() {
255
+ if (!this.pdfReader) {
256
+ throw new Error(
257
+ "The source PDF reader has been released by endPDF(). Read the source before ending the document.",
258
+ );
259
+ }
260
+ return this.pdfReader;
261
+ }
262
+
263
+ /**
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.
220
268
  * @function
221
269
  * @memberof Recipe
222
270
  * @param {function} [callback] - The callback function.
223
271
  * @returns {*} The callback result, if a callback is provided.
224
272
  */
225
273
  endPDF(callback) {
226
- this._writeInfo();
227
- this.writer.end();
228
- // This is a temporary work around for copying context will overwrite the current one
229
- // write annotations at the end.
230
- if (
231
- (this.annotations && this.annotations.length > 0) ||
232
- (this.annotationsToWrite && this.annotationsToWrite.length > 0)
233
- ) {
234
- if (this.isBufferSrc) {
235
- const oldStream = this.outStream;
236
- 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();
237
308
 
238
- this.writer = muhammara.createWriterToModify(
239
- new muhammara.PDFRStreamForBuffer(oldStream.toBuffer()),
240
- new muhammara.PDFStreamForResponse(this.outStream),
241
- Object.assign({}, this.encryptOptions, {
242
- log: this.logFile,
243
- }),
244
- );
245
- } else {
246
- this.writer = muhammara.createWriterToModify(
247
- this.output,
248
- Object.assign({}, this.encryptOptions, {
249
- modifiedFilePath: this.output,
250
- log: this.logFile,
251
- }),
252
- );
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();
253
329
  }
254
330
 
255
- this._writeAnnotations();
256
- this._writeInfo();
257
- this.writer.end();
258
- }
259
- if (this.needToInsertPages) {
260
- if (this.isBufferSrc) {
261
- // eslint-disable-next-line no-console
262
- console.log(
263
- "Feature: Inserting Pages is not supported in Buffer Mode yet.",
264
- );
265
- } else {
266
- this._insertPages();
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();
335
+
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
+ }
267
345
  }
268
- }
269
- if (this.needToEncrypt) {
270
- if (this.isBufferSrc) {
271
- // eslint-disable-next-line no-console
272
- console.log("Feature: Encryption is not supported in Buffer Mode yet.");
273
- } else {
274
- this._encrypt();
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
+ }
275
355
  }
276
- }
277
356
 
278
- if (this.isBufferSrc && this.output) {
279
- fs.writeFileSync(this.output, this.outStream.toBuffer());
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],
366
+ );
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;
372
+ }
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.
379
+ }
380
+ try {
381
+ this._releaseReader();
382
+ } catch (_) {
383
+ // Preserve the finalization error if releasing its separate reader fails.
384
+ }
385
+ throw error;
280
386
  }
281
387
 
282
388
  if (callback) {
@@ -302,7 +408,7 @@ class Recipe {
302
408
  * @throws {string} If the callback function is unnamed when no key is provided.
303
409
  * @throws {string} If the key conflicts with an existing Recipe prototype member.
304
410
  * @throws {string} If the callback is not a function.
305
- * @returns {void}
411
+ * @returns {Recipe} The recipe instance.
306
412
  */
307
413
  register(key, callback) {
308
414
  // Assume simply registering a function which will have an embedded name
@@ -323,11 +429,12 @@ class Recipe {
323
429
  }
324
430
 
325
431
  this.__proto__[key] = callback;
432
+ return this;
326
433
  }
327
434
  }
328
435
 
329
436
  function loadPrototypes() {
330
- const ignores = ["xObjectForm.js"];
437
+ const ignores = ["utils.js", "xObjectForm.js"];
331
438
  fs.readdirSync(path.join(__dirname, "recipe"))
332
439
  .filter((file) => {
333
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) => {
@@ -1,5 +1,5 @@
1
1
  const muhammara = require("../muhammara");
2
- const hummusUtils = require("./utils");
2
+ const utils = require("./utils");
3
3
 
4
4
  /**
5
5
  * Append pages from the other pdf to the current pdf
@@ -7,39 +7,61 @@ const hummusUtils = 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
- const pdfReader = muhammara.createReader(instream);
20
- const pageCount = pdfReader.getPagesCount();
21
- pdfReader.end();
22
- instream.close();
24
+ let pageCount;
25
+ try {
26
+ const pdfReader = muhammara.createReader(instream);
27
+ try {
28
+ pageCount = pdfReader.getPagesCount();
29
+ } finally {
30
+ pdfReader.end();
31
+ }
32
+ } finally {
33
+ // An unreadable source must not leave the file open, which would keep it
34
+ // locked on Windows for the life of the process.
35
+ instream.close();
36
+ }
23
37
 
24
- // prevent unmatched pagenumber
38
+ // Preserve the established upper-bound clamping after validating that the
39
+ // caller supplied meaningful one-based page numbers.
25
40
  const transformPageNumber = (pageNum) => {
26
41
  pageNum = pageNum > pageCount ? pageCount : pageNum;
27
- pageNum = pageNum < 1 ? 1 : pageNum;
28
42
  return pageNum - 1;
29
43
  };
30
44
  pages = pages.map((element) => {
31
- if (Array.isArray(element)) {
32
- return [transformPageNumber(element[0]), transformPageNumber(element[1])];
33
- } else {
34
- 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");
35
55
  }
56
+ return range.map(transformPageNumber);
36
57
  });
37
58
  if (pages.length > 0) {
38
- hummusUtils.appendPDFPagesFromPDFWithAnnotations(this.writer, pdfSrc, {
59
+ utils.appendPDFPagesFromPDFWithAnnotations(this.writer, pdfSrc, {
39
60
  specificRanges: pages,
40
61
  });
41
62
  } else {
42
- hummusUtils.appendPDFPagesFromPDFWithAnnotations(this.writer, pdfSrc);
63
+ utils.appendPDFPagesFromPDFWithAnnotations(this.writer, pdfSrc);
43
64
  }
65
+ this.pagesAppended = true;
44
66
  return this;
45
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