@muhammara/native-core 7.0.0-beta.4 → 7.0.0-rc.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.
Files changed (44) 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 +91 -38
  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 +385 -39
  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 +156 -14
  39. package/lib/recipe/vector.js +156 -52
  40. package/lib/recipe/xObjectForm.js +73 -10
  41. package/lib/recipe-constants.js +373 -0
  42. package/lib/recipe-options.js +47 -0
  43. package/muhammara.d.ts +3501 -160
  44. package/package.json +2 -3
@@ -1,28 +1,73 @@
1
+ const fs = require("fs");
2
+ const muhammara = require("../muhammara");
3
+ const { HorizontalAlign, VerticalAlign } = require("../recipe-constants");
4
+
1
5
  /**
2
6
  * Place images to pdf
3
7
  * @name image
4
8
  * @function
5
9
  * @memberof Recipe#
6
- * @param {string} imgSrc - The path for the image. [JPEG, PNG, TIFF]
7
- * @param {number} x - The coordinate x
8
- * @param {number} y - The coordinate y
10
+ * @param {string} imgSrc - The path for the image. [JPEG, PNG, TIFF, PDF]
11
+ * @param {number|"center"} x - The coordinate x of the top-left corner
12
+ * @param {number|"center"} y - The coordinate y of the top-left corner
9
13
  * @param {Object} [options] - The options
10
14
  * @returns {Recipe} The recipe instance.
11
- * @param {number} [options.width] - The new width
15
+ * @param {number} [options.page=1] - The one-based page of a PDF source, or the
16
+ * image of a multi-image TIFF, as in `overlay()`.
17
+ * @param {number} [options.width] - The new width. The frame's line width is
18
+ * `options.lineWidth`.
12
19
  * @param {number} [options.height] - The new height
13
20
  * @param {number} [options.scale] - Scale the image from the original width and height.
14
21
  * @param {boolean} [options.keepAspectRatio=true] - Keep the aspect ratio.
15
- * @param {number} [options.opacity] - The opacity.
16
- * @param {string} [options.align] - 'center center'...
22
+ * @param {number} [options.opacity] - The opacity of the image and its frame.
23
+ * @param {string} [options.align] - A `Recipe.HorizontalAlign` value, optionally
24
+ * followed by a space and a `Recipe.VerticalAlign` value, for example
25
+ * "center center". Horizontal center moves the image left by half its width
26
+ * and right moves it right by half; vertical center moves it up by half its
27
+ * height and bottom moves it down by half from its top-left placement.
28
+ * @param {number} [options.rotation] - Rotate the image, in degrees.
29
+ * @param {number[]} [options.rotationOrigin] - [x, y] of the rotation origin;
30
+ * the bottom-left corner of the drawn image when omitted.
31
+ * @param {number} [options.skewX] - Skew angle off the x axis, in degrees.
32
+ * @param {number} [options.skewY] - Skew angle off the y axis, in degrees.
33
+ * @param {string|number[]} [options.fill] - Paint the image box beneath the
34
+ * image in this color.
35
+ * @param {string|number[]} [options.stroke] - Outline the image box above the
36
+ * image in this color.
37
+ * @param {string|number[]} [options.color] - Outline color when `stroke` is
38
+ * not given.
39
+ * @param {Recipe.Colorspace} [options.colorspace] - The frame colorspace.
40
+ * @param {string} [options.colorName] - The Separation ink name of a
41
+ * `"separation"` frame color.
42
+ * @param {number} [options.lineWidth] - The outline width. The outline lies
43
+ * inside the image box, as a `rectangle()` stroke does.
44
+ * @param {number[]} [options.dash] - The outline dash pattern.
45
+ * @param {number} [options.dashPhase] - The outline dash phase.
46
+ * @param {Recipe.LineCap} [options.lineCap] - The outline dash cap.
47
+ * @param {Recipe.LineJoin} [options.lineJoin] - The outline corner join.
48
+ * @param {number} [options.miterLimit] - The outline miter limit.
49
+ * @param {boolean|number} [options.debug] - Outline the image box in green and
50
+ * mark the placement point in red.
51
+ * @param {string} [options.link] - Make the image open this URL.
52
+ * @throws {TypeError} If no page is active.
53
+ * @throws {RangeError} If `page` is not an integer from 1 to 4294967296,
54
+ * a size is not a finite number, or `miterLimit` is not a number of at
55
+ * least 1.
56
+ * @throws {TypeError} If `rotation` is not a finite number.
57
+ * @throws {Error} If the image, or the PDF page `page` selects, cannot be read.
17
58
  */
18
59
  exports.image = function image(imgSrc, x, y, options = {}) {
19
- const { width, height, offsetX, offsetY } = this._getImgOffset(
60
+ // null options act like omitted options.
61
+ if (options === null) options = {};
62
+ const index = imageIndex(options);
63
+ const { width, height, offsetX, offsetY, page } = this._getImgOffset(
20
64
  imgSrc,
21
65
  options,
22
66
  );
23
67
  const imgOptions = {
68
+ index,
24
69
  transformation: {
25
- fit: "always",
70
+ fit: muhammara.ImageFit.ALWAYS,
26
71
  // proportional: true,
27
72
  width,
28
73
  height,
@@ -32,7 +77,13 @@ exports.image = function image(imgSrc, x, y, options = {}) {
32
77
 
33
78
  const _options = this._getPathOptions(options, nx, ny);
34
79
  const gsId = _options.fillGsId;
35
- const xObjectKeyName = `${imgSrc}__${gsId}`;
80
+ const xObjectKeyName = `${imgSrc}__${index}__${gsId}`;
81
+
82
+ // `width` is the image width; only `lineWidth` sizes the frame outline.
83
+ const frame = { ...options, width: undefined };
84
+ if (options.fill) {
85
+ drawFrame(this, nx, ny, width, height, { ...frame, stroke: undefined });
86
+ }
36
87
 
37
88
  // See if this image has been seen already, so as to not duplicate it.
38
89
  let xObject = this.xObjects.find((element) => {
@@ -55,46 +106,339 @@ exports.image = function image(imgSrc, x, y, options = {}) {
55
106
  xObject.set("height", height);
56
107
 
57
108
  this.xObjects.push(xObject);
58
- ctx.gs(xObject.getGsName(gsId)).drawImage(0, 0, imgSrc, imgOptions);
109
+ ctx.gs(xObject.getGsName(gsId));
110
+ if (page) {
111
+ const placed = placedPage(page, width, height);
112
+ ctx.cm(...placed.matrix).drawImage(placed.x, placed.y, imgSrc, {
113
+ index,
114
+ transformation: { ...imgOptions.transformation, ...placed.size },
115
+ });
116
+ } else {
117
+ ctx.drawImage(0, 0, imgSrc, imgOptions);
118
+ }
59
119
  });
60
120
 
121
+ if (options.stroke || options.color) {
122
+ drawFrame(this, nx, ny, width, height, { ...frame, fill: undefined });
123
+ }
124
+
125
+ if (options.debug) {
126
+ drawFrame(this, nx, ny, width, height, {
127
+ stroke: DEBUG_OUTLINE,
128
+ lineWidth: 1,
129
+ rotation: options.rotation,
130
+ rotationOrigin: options.rotationOrigin,
131
+ skewX: options.skewX,
132
+ skewY: options.skewY,
133
+ });
134
+ this.circle(x, y, 2, { fill: DEBUG_ANCHOR });
135
+ }
136
+
61
137
  if (options.link) {
62
- this.link(options.link, x + offsetX, y - offsetY - height, width, height);
138
+ const [linkX, linkY] = this._centrify(x, y);
139
+ this.link(
140
+ options.link,
141
+ linkX + offsetX,
142
+ linkY - offsetY - height,
143
+ width,
144
+ height,
145
+ );
63
146
  }
64
147
 
65
148
  return this;
66
149
  };
67
150
 
151
+ /**
152
+ * Read the one-based `page` option as the zero-based index of the PDF page or
153
+ * TIFF image that PDFWriter takes.
154
+ * @private
155
+ * @param {Object} options - The image() options.
156
+ * @returns {number} The zero-based index; 0 when `page` is omitted.
157
+ * @throws {RangeError} If `page` is not an integer from 1 to 4294967296.
158
+ */
159
+ function imageIndex(options) {
160
+ const page = options.page ?? 1;
161
+ if (!Number.isInteger(page) || page < 1 || page > 0x100000000) {
162
+ throw new RangeError("image page must be an integer from 1 to 4294967296");
163
+ }
164
+ return page - 1;
165
+ }
166
+
167
+ /**
168
+ * Read a size option of `image()` as a number. A numeric string counts as its
169
+ * number, as in Wasm; a missing or other falsy value means not given.
170
+ * A negative size mirrors the image.
171
+ * @private
172
+ * @param {Object} options - The image() options.
173
+ * @param {string} key - `width`, `height`, or `scale`.
174
+ * @returns {number|undefined} The size, or undefined when not given.
175
+ * @throws {RangeError} If the value is not a finite number.
176
+ */
177
+ function imageSize(options, key) {
178
+ if (!options[key]) return undefined;
179
+ const size = Number(options[key]);
180
+ if (!Number.isFinite(size)) {
181
+ throw new RangeError(`image ${key} must be a finite number`);
182
+ }
183
+ return size;
184
+ }
185
+
186
+ /**
187
+ * Read the media box and rotation of a page of a PDF image source, caching
188
+ * them per file version on the Recipe. Returns null for a source that is not
189
+ * a readable PDF file, which is measured and drawn as an image.
190
+ * @private
191
+ * @param {Recipe} recipe - The recipe instance.
192
+ * @param {string} imgSrc - The image path.
193
+ * @param {number} index - The zero-based page.
194
+ * @returns {?{mediaBox: number[], rotate: number}} The page geometry.
195
+ * @throws {Error} If the PDF has no page at `index`: PDFWriter would read a
196
+ * page that does not exist.
197
+ */
198
+ function pdfPage(recipe, imgSrc, index) {
199
+ let key;
200
+ try {
201
+ const stat = fs.statSync(imgSrc);
202
+ key = `${imgSrc}\0${stat.mtimeMs}\0${stat.size}`;
203
+ } catch {
204
+ // A missing file measures 0 by 0 and is rejected as an unknown image.
205
+ return null;
206
+ }
207
+ recipe._pdfSources = recipe._pdfSources || new Map();
208
+ let source = recipe._pdfSources.get(key);
209
+ if (!source) {
210
+ source = {
211
+ pdf: recipe.writer.getImageType(imgSrc) === muhammara.PDFImageType.PDF,
212
+ pages: new Map(),
213
+ };
214
+ recipe._pdfSources.set(key, source);
215
+ }
216
+ if (!source.pdf) return null;
217
+ if (!source.pages.has(index)) {
218
+ source.pages.set(index, readPdfPage(imgSrc, index));
219
+ }
220
+ const page = source.pages.get(index);
221
+ if (!page) throw new Error(`Unknown image: ${imgSrc}`);
222
+ return page;
223
+ }
224
+
225
+ /**
226
+ * Read the media box and rotation of one page of a PDF file.
227
+ * @private
228
+ * @param {string} imgSrc - The PDF path.
229
+ * @param {number} index - The zero-based page.
230
+ * @returns {?{mediaBox: number[], rotate: number}} The page geometry, or null
231
+ * when the PDF cannot be read or has no such page.
232
+ */
233
+ function readPdfPage(imgSrc, index) {
234
+ let reader;
235
+ try {
236
+ reader = muhammara.createReader(imgSrc);
237
+ } catch {
238
+ return null;
239
+ }
240
+ try {
241
+ if (index >= reader.getPagesCount()) return null;
242
+ const page = reader.parsePage(index);
243
+ return { mediaBox: page.getMediaBox(), rotate: page.getRotate() };
244
+ } finally {
245
+ reader.end();
246
+ }
247
+ }
248
+
249
+ /**
250
+ * The size of a PDF page as it is displayed: its media box, turned by its
251
+ * `/Rotate`.
252
+ * @private
253
+ * @param {{mediaBox: number[], rotate: number}} page - The page geometry.
254
+ * @returns {{width: number, height: number}} The displayed size.
255
+ */
256
+ function displayedPageSize(page) {
257
+ const [left, bottom, right, top] = page.mediaBox;
258
+ return quarterTurns(page.rotate) % 2
259
+ ? { width: top - bottom, height: right - left }
260
+ : { width: right - left, height: top - bottom };
261
+ }
262
+
263
+ /**
264
+ * How to draw a PDF page into a displayed box: PDFWriter fits the unturned
265
+ * media box and ignores its origin, so the page is turned by its `/Rotate`
266
+ * and drawn at its origin scaled away.
267
+ * @private
268
+ * @param {{mediaBox: number[], rotate: number}} page - The page geometry.
269
+ * @param {number} width - The displayed width.
270
+ * @param {number} height - The displayed height.
271
+ * @returns {{matrix: number[], x: number, y: number, size: {width: number, height: number}}}
272
+ * The turning matrix, the drawing position, and the fitted unturned size.
273
+ */
274
+ function placedPage(page, width, height) {
275
+ const [left, bottom, right, top] = page.mediaBox;
276
+ const turns = quarterTurns(page.rotate);
277
+ const w = turns % 2 ? height : width;
278
+ const h = turns % 2 ? width : height;
279
+ const matrix = [
280
+ [1, 0, 0, 1, 0, 0],
281
+ [0, -1, 1, 0, 0, w],
282
+ [-1, 0, 0, -1, w, h],
283
+ [0, 1, -1, 0, h, 0],
284
+ ][turns];
285
+ return {
286
+ matrix,
287
+ x: (-left * w) / (right - left),
288
+ y: (-bottom * h) / (top - bottom),
289
+ size: { width: w, height: h },
290
+ };
291
+ }
292
+
293
+ /**
294
+ * The clockwise quarter turns of a page `/Rotate` value.
295
+ * @private
296
+ * @param {number} rotate - The `/Rotate` value, a multiple of 90.
297
+ * @returns {number} 0 to 3; 0 for a value that is not a multiple of 90.
298
+ */
299
+ function quarterTurns(rotate) {
300
+ const turns = (((Number(rotate) || 0) % 360) + 360) % 360;
301
+ return turns % 90 ? 0 : turns / 90;
302
+ }
303
+
304
+ // The diagnostic colors the shape `debug` options draw with.
305
+ const DEBUG_OUTLINE = "#00ff00";
306
+ const DEBUG_ANCHOR = "#ff0000";
307
+
308
+ /**
309
+ * Paint the box of a placed image, with the image's rotation and skew: a fill
310
+ * when `options.fill` is given, otherwise an outline inset by half its line
311
+ * width, as `rectangle()` strokes are.
312
+ * @private
313
+ * @param {Recipe} recipe - The recipe instance.
314
+ * @param {number} nx - The PDF x of the image's bottom-left corner.
315
+ * @param {number} ny - The PDF y of the image's bottom-left corner.
316
+ * @param {number} width - The drawn image width.
317
+ * @param {number} height - The drawn image height.
318
+ * @param {Object} options - The image() frame options, `width` excluded.
319
+ * @returns {void}
320
+ */
321
+ function drawFrame(recipe, nx, ny, width, height, options) {
322
+ const pathOptions = recipe._getPathOptions(options, nx, ny);
323
+ // A negative size mirrors the image into the other side of its corner.
324
+ const left = Math.min(0, width);
325
+ const bottom = Math.min(0, height);
326
+ const boxWidth = Math.abs(width);
327
+ const boxHeight = Math.abs(height);
328
+ if (options.fill) {
329
+ const colorModel = pathOptions.fillModel;
330
+ pathOptions.type = muhammara.DrawingPathType.FILL;
331
+ pathOptions.color = colorModel.color;
332
+ pathOptions.colorspace = colorModel.colorspace;
333
+ recipe._drawObject(
334
+ recipe,
335
+ nx,
336
+ ny,
337
+ width,
338
+ height,
339
+ pathOptions,
340
+ (ctx, xObject) => {
341
+ ctx.gs(xObject.getGsName(pathOptions.fillGsId));
342
+ xObject.fill(colorModel);
343
+ ctx.drawRectangle(
344
+ left,
345
+ bottom,
346
+ boxWidth,
347
+ boxHeight,
348
+ recipe._devicePathOptions(pathOptions),
349
+ );
350
+ },
351
+ );
352
+ return;
353
+ }
354
+ const colorModel =
355
+ pathOptions.stroke !== undefined
356
+ ? pathOptions.strokeModel
357
+ : pathOptions.colorModel;
358
+ pathOptions.type = muhammara.DrawingPathType.STROKE;
359
+ pathOptions.color = colorModel.color;
360
+ pathOptions.colorspace = colorModel.colorspace;
361
+ recipe._drawObject(
362
+ recipe,
363
+ nx,
364
+ ny,
365
+ width,
366
+ height,
367
+ pathOptions,
368
+ (ctx, xObject) => {
369
+ // The form clips at the image box, so the outline lies inside it.
370
+ const margin = pathOptions.width;
371
+ ctx.gs(xObject.getGsName(pathOptions.strokeGsId));
372
+ xObject.stroke(colorModel);
373
+ ctx
374
+ .J(pathOptions.lineCap)
375
+ .j(pathOptions.lineJoin)
376
+ .M(pathOptions.miterLimit)
377
+ .d(pathOptions.dash, pathOptions.dashPhase)
378
+ .drawRectangle(
379
+ left + Math.min(margin / 2, boxWidth / 2),
380
+ bottom + Math.min(margin / 2, boxHeight / 2),
381
+ Math.max(0, boxWidth - margin),
382
+ Math.max(0, boxHeight - margin),
383
+ recipe._devicePathOptions(pathOptions),
384
+ );
385
+ },
386
+ );
387
+ }
388
+
389
+ /**
390
+ * Compute the drawn size of an image and the offset its alignment applies.
391
+ * A missing `options.keepAspectRatio` keeps the aspect ratio.
392
+ * @private
393
+ * @param {string} [imgSrc=''] - The image path.
394
+ * @param {Object} [options] - The image() options; `page` selects the page or
395
+ * image that is measured.
396
+ * @returns {{width: number, height: number, offsetX: number, offsetY: number, page: ?Object}}
397
+ * The drawn size, the PDF offset from the placement point, and the page
398
+ * geometry of a PDF source.
399
+ * @throws {Error} If the image, or the PDF page `page` selects, cannot be read.
400
+ */
68
401
  exports._getImgOffset = function _getImgOffset(imgSrc = "", options = {}) {
69
- // set default to true
70
- options.keepAspectRatio =
71
- options.keepAspectRatio == void 0 ? true : options.keepAspectRatio;
72
- const dimensions = this.writer.getImageDimensions(imgSrc);
402
+ // A missing keepAspectRatio keeps it; the caller's options stay untouched.
403
+ const keepAspectRatio = options.keepAspectRatio ?? true;
404
+ const index = imageIndex(options);
405
+ const page = pdfPage(this, imgSrc, index);
406
+ const dimensions = page
407
+ ? displayedPageSize(page)
408
+ : this.writer.getImageDimensions(imgSrc, index);
409
+ // An unreadable image measures 0 by 0. Drawing it would leave an image
410
+ // object that is never written, so endPDF() could not finish the PDF.
411
+ if (!(dimensions.width > 0 && dimensions.height > 0)) {
412
+ throw new Error(`Unknown image: ${imgSrc}`);
413
+ }
73
414
  const ratio = dimensions.width / dimensions.height;
415
+ const scale = imageSize(options, "scale");
416
+ const wantedWidth = imageSize(options, "width");
417
+ const wantedHeight = imageSize(options, "height");
74
418
 
75
419
  let width = dimensions.width;
76
420
  let height = dimensions.height;
77
- if (options.scale) {
78
- width = width * options.scale;
79
- height = height * options.scale;
80
- } else if (options.width && !options.height) {
81
- width = options.width;
82
- height = options.width / ratio;
83
- } else if (!options.width && options.height) {
84
- width = options.height * ratio;
85
- height = options.height;
86
- } else if (options.width && options.height) {
87
- if (!options.keepAspectRatio) {
88
- width = options.width;
89
- height = options.height;
421
+ if (scale) {
422
+ width = width * scale;
423
+ height = height * scale;
424
+ } else if (wantedWidth && !wantedHeight) {
425
+ width = wantedWidth;
426
+ height = wantedWidth / ratio;
427
+ } else if (!wantedWidth && wantedHeight) {
428
+ width = wantedHeight * ratio;
429
+ height = wantedHeight;
430
+ } else if (wantedWidth && wantedHeight) {
431
+ if (!keepAspectRatio) {
432
+ width = wantedWidth;
433
+ height = wantedHeight;
90
434
  } else {
91
435
  // fit to the smaller
92
- if (options.width / ratio <= options.height) {
93
- width = options.width;
94
- height = options.width / ratio;
436
+ if (wantedWidth / ratio <= wantedHeight) {
437
+ width = wantedWidth;
438
+ height = wantedWidth / ratio;
95
439
  } else {
96
- width = options.height * ratio;
97
- height = options.height;
440
+ width = wantedHeight * ratio;
441
+ height = wantedHeight;
98
442
  }
99
443
  }
100
444
  }
@@ -105,10 +449,10 @@ exports._getImgOffset = function _getImgOffset(imgSrc = "", options = {}) {
105
449
  const alignments = options.align.split(" ");
106
450
  if (alignments[0]) {
107
451
  switch (alignments[0]) {
108
- case "center":
452
+ case HorizontalAlign.CENTER:
109
453
  offsetX = (-1 * width) / 2;
110
454
  break;
111
- case "right":
455
+ case HorizontalAlign.RIGHT:
112
456
  offsetX = width / 2;
113
457
  break;
114
458
  default:
@@ -116,15 +460,17 @@ exports._getImgOffset = function _getImgOffset(imgSrc = "", options = {}) {
116
460
  }
117
461
  if (alignments[1]) {
118
462
  switch (alignments[1]) {
119
- case "center":
463
+ case VerticalAlign.CENTER:
120
464
  offsetY = (-1 * height) / 2;
121
465
  break;
122
- case "bottom":
123
- offsetY = height / 2;
466
+ case VerticalAlign.BOTTOM:
467
+ // Down by half the height from the top-left placement, as
468
+ // documented; 6.x moved the image up by 1.5 times its height.
469
+ offsetY = -1.5 * height;
124
470
  break;
125
471
  default:
126
472
  }
127
473
  }
128
474
  }
129
- return { width, height, offsetX, offsetY };
475
+ return { width, height, offsetX, offsetY, page };
130
476
  };