@muhammara/native-core 7.0.0-rc.1 → 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.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/%40muhammara%2Fnative-core.svg)](https://www.npmjs.com/package/@muhammara/native)
4
4
  [![License](https://img.shields.io/npm/l/%40muhammara%2Fnative-core.svg)](https://github.com/julianhille/MuhammaraJS/blob/develop/LICENSE)
5
- [![Documentation](https://img.shields.io/badge/docs-readthedocs-blue.svg)](https://muhammarajs.readthedocs.io/)
5
+ [![Documentation status](https://readthedocs.org/projects/muhammarajs/badge/?version=latest)](https://muhammarajs.readthedocs.io/)
6
6
 
7
7
  **This is an internal package. Do not install it directly.**
8
8
 
package/lib/Recipe.js CHANGED
@@ -1,7 +1,7 @@
1
1
  const muhammara = require("./muhammara");
2
2
  const path = require("path");
3
3
  const fs = require("fs");
4
- const streams = require("memory-streams");
4
+ const PDFWStreamForBuffer = require("./PDFWStreamForBuffer");
5
5
  var { standardInfoKeys } = require("./recipe-info");
6
6
  var { AnnotSubtype, PageLayout, Source } = require("./recipe-constants");
7
7
 
@@ -53,7 +53,7 @@ class Recipe {
53
53
  );
54
54
 
55
55
  if (this.isBufferSrc) {
56
- this.outStream = new streams.WritableStream();
56
+ this.outStream = new PDFWStreamForBuffer();
57
57
  this.output = output;
58
58
  } else {
59
59
  this.output = output || src;
@@ -109,7 +109,7 @@ class Recipe {
109
109
  );
110
110
  } else {
111
111
  this.writer = muhammara.createWriter(
112
- new muhammara.PDFStreamForResponse(this.outStream),
112
+ this.outStream,
113
113
  Object.assign({}, this.encryptOptions, {
114
114
  version: this._getVersion(this.options.version),
115
115
  log: this.logFile,
@@ -122,7 +122,7 @@ class Recipe {
122
122
  if (this.isBufferSrc) {
123
123
  this.writer = muhammara.createWriterToModify(
124
124
  new muhammara.PDFRStreamForBuffer(this.src),
125
- new muhammara.PDFStreamForResponse(this.outStream),
125
+ this.outStream,
126
126
  Object.assign({}, this.encryptOptions, {
127
127
  log: this.logFile,
128
128
  }),
@@ -319,7 +319,7 @@ class Recipe {
319
319
  if (this.ended) {
320
320
  if (!callback) return;
321
321
  if (!this.isBufferSrc) return callback();
322
- return callback(this.output || this.outStream.toBuffer());
322
+ return callback(this.output || this.outStream.buffer || Buffer.alloc(0));
323
323
  }
324
324
  var deletingPages = Boolean(this.deletedPages?.size);
325
325
  var deletionState = deletingPages
@@ -350,11 +350,13 @@ class Recipe {
350
350
  ) {
351
351
  if (this.isBufferSrc) {
352
352
  const oldStream = this.outStream;
353
- this.outStream = new streams.WritableStream();
353
+ this.outStream = new PDFWStreamForBuffer();
354
354
 
355
355
  this.writer = muhammara.createWriterToModify(
356
- new muhammara.PDFRStreamForBuffer(oldStream.toBuffer()),
357
- new muhammara.PDFStreamForResponse(this.outStream),
356
+ new muhammara.PDFRStreamForBuffer(
357
+ oldStream.buffer || Buffer.alloc(0),
358
+ ),
359
+ this.outStream,
358
360
  Object.assign({}, this.encryptOptions, {
359
361
  log: this.logFile,
360
362
  }),
@@ -401,7 +403,7 @@ class Recipe {
401
403
  }
402
404
 
403
405
  if (this.isBufferSrc && this.output) {
404
- fs.writeFileSync(this.output, this.outStream.toBuffer());
406
+ fs.writeFileSync(this.output, this.outStream.buffer || Buffer.alloc(0));
405
407
  }
406
408
 
407
409
  this.ended = true;
@@ -436,7 +438,7 @@ class Recipe {
436
438
  if (this.output) {
437
439
  return callback(this.output);
438
440
  } else {
439
- return callback(this.outStream.toBuffer());
441
+ return callback(this.outStream.buffer || Buffer.alloc(0));
440
442
  }
441
443
  } else {
442
444
  return callback();
@@ -1,3 +1,4 @@
1
+ const fs = require("fs");
1
2
  const muhammara = require("../muhammara");
2
3
  const { HorizontalAlign, VerticalAlign } = require("../recipe-constants");
3
4
 
@@ -11,26 +12,60 @@ const { HorizontalAlign, VerticalAlign } = require("../recipe-constants");
11
12
  * @param {number|"center"} y - The coordinate y of the top-left corner
12
13
  * @param {Object} [options] - The options
13
14
  * @returns {Recipe} The recipe instance.
14
- * @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`.
15
19
  * @param {number} [options.height] - The new height
16
20
  * @param {number} [options.scale] - Scale the image from the original width and height.
17
21
  * @param {boolean} [options.keepAspectRatio=true] - Keep the aspect ratio.
18
- * @param {number} [options.opacity] - The opacity.
22
+ * @param {number} [options.opacity] - The opacity of the image and its frame.
19
23
  * @param {string} [options.align] - A `Recipe.HorizontalAlign` value, optionally
20
24
  * followed by a space and a `Recipe.VerticalAlign` value, for example
21
25
  * "center center". Horizontal center moves the image left by half its width
22
26
  * and right moves it right by half; vertical center moves it up by half its
23
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.
24
51
  * @param {string} [options.link] - Make the image open this URL.
25
52
  * @throws {TypeError} If no page is active.
26
- * @throws {Error} If the image cannot be read.
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.
27
58
  */
28
59
  exports.image = function image(imgSrc, x, y, options = {}) {
29
- 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(
30
64
  imgSrc,
31
65
  options,
32
66
  );
33
67
  const imgOptions = {
68
+ index,
34
69
  transformation: {
35
70
  fit: muhammara.ImageFit.ALWAYS,
36
71
  // proportional: true,
@@ -42,7 +77,13 @@ exports.image = function image(imgSrc, x, y, options = {}) {
42
77
 
43
78
  const _options = this._getPathOptions(options, nx, ny);
44
79
  const gsId = _options.fillGsId;
45
- 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
+ }
46
87
 
47
88
  // See if this image has been seen already, so as to not duplicate it.
48
89
  let xObject = this.xObjects.find((element) => {
@@ -65,61 +106,339 @@ exports.image = function image(imgSrc, x, y, options = {}) {
65
106
  xObject.set("height", height);
66
107
 
67
108
  this.xObjects.push(xObject);
68
- 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
+ }
69
119
  });
70
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
+
71
137
  if (options.link) {
72
- 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
+ );
73
146
  }
74
147
 
75
148
  return this;
76
149
  };
77
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
+
78
389
  /**
79
390
  * Compute the drawn size of an image and the offset its alignment applies.
80
- * Sets `options.keepAspectRatio` to true when it is not given.
391
+ * A missing `options.keepAspectRatio` keeps the aspect ratio.
81
392
  * @private
82
393
  * @param {string} [imgSrc=''] - The image path.
83
- * @param {Object} [options] - The image() options.
84
- * @returns {{width: number, height: number, offsetX: number, offsetY: number}}
85
- * The drawn size and the PDF offset from the placement point.
86
- * @throws {Error} If the image cannot be read.
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.
87
400
  */
88
401
  exports._getImgOffset = function _getImgOffset(imgSrc = "", options = {}) {
89
- // set default to true
90
- options.keepAspectRatio =
91
- options.keepAspectRatio == void 0 ? true : options.keepAspectRatio;
92
- 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);
93
409
  // An unreadable image measures 0 by 0. Drawing it would leave an image
94
410
  // object that is never written, so endPDF() could not finish the PDF.
95
411
  if (!(dimensions.width > 0 && dimensions.height > 0)) {
96
412
  throw new Error(`Unknown image: ${imgSrc}`);
97
413
  }
98
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");
99
418
 
100
419
  let width = dimensions.width;
101
420
  let height = dimensions.height;
102
- if (options.scale) {
103
- width = width * options.scale;
104
- height = height * options.scale;
105
- } else if (options.width && !options.height) {
106
- width = options.width;
107
- height = options.width / ratio;
108
- } else if (!options.width && options.height) {
109
- width = options.height * ratio;
110
- height = options.height;
111
- } else if (options.width && options.height) {
112
- if (!options.keepAspectRatio) {
113
- width = options.width;
114
- 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;
115
434
  } else {
116
435
  // fit to the smaller
117
- if (options.width / ratio <= options.height) {
118
- width = options.width;
119
- height = options.width / ratio;
436
+ if (wantedWidth / ratio <= wantedHeight) {
437
+ width = wantedWidth;
438
+ height = wantedWidth / ratio;
120
439
  } else {
121
- width = options.height * ratio;
122
- height = options.height;
440
+ width = wantedHeight * ratio;
441
+ height = wantedHeight;
123
442
  }
124
443
  }
125
444
  }
@@ -145,11 +464,13 @@ exports._getImgOffset = function _getImgOffset(imgSrc = "", options = {}) {
145
464
  offsetY = (-1 * height) / 2;
146
465
  break;
147
466
  case VerticalAlign.BOTTOM:
148
- offsetY = height / 2;
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;
149
470
  break;
150
471
  default:
151
472
  }
152
473
  }
153
474
  }
154
- return { width, height, offsetX, offsetY };
475
+ return { width, height, offsetX, offsetY, page };
155
476
  };
@@ -321,7 +321,7 @@ exports._makeTextBox = function _makeTextBox(options) {
321
321
  * @param {string} [options.subject] - Subject of annotation.
322
322
  * @param {string} [options.link] - Make the text open this URL.
323
323
  * @returns {Recipe} The recipe instance. Without an active page nothing is drawn.
324
- * @throws {TypeError} If `options.charSpace` is not a finite number; nothing is drawn.
324
+ * @throws {TypeError} If `options.charSpace` or `options.rotation` is not a finite number; nothing is drawn.
325
325
  * @throws {Error} If an overflow callback names an undefined layout, or a font cannot be loaded.
326
326
  */
327
327
  exports.text = function text(text = "", x, y, options = {}) {
@@ -1,6 +1,7 @@
1
1
  const { xObjectForm } = require("./xObjectForm");
2
2
  const { resolveFontSize } = require("./utils");
3
3
  const { Colorspace, LineCap, LineJoin } = require("../recipe-constants");
4
+ const { miterLimitOption, rotationOption } = require("../recipe-options");
4
5
 
5
6
  /**
6
7
  * Resolve drawing and text options into path options: font, size, colors
@@ -11,7 +12,9 @@ const { Colorspace, LineCap, LineJoin } = require("../recipe-constants");
11
12
  * @param {number} originX - The PDF x of the default rotation origin.
12
13
  * @param {number} originY - The PDF y of the default rotation origin.
13
14
  * @returns {Object} The resolved path options.
14
- * @throws {RangeError} If a given font size is not greater than zero.
15
+ * @throws {RangeError} If a given font size is not greater than zero, or
16
+ * `miterLimit` is not a number of at least 1.
17
+ * @throws {TypeError} If `rotation` is not a finite number.
15
18
  * @throws {Error} If the font cannot be loaded.
16
19
  */
17
20
  exports._getPathOptions = function _getPathOptions(
@@ -20,6 +23,9 @@ exports._getPathOptions = function _getPathOptions(
20
23
  originY,
21
24
  ) {
22
25
  this.current = this.current || {};
26
+ // Validate before anything is resolved or drawn.
27
+ const rotation = rotationOption(options.rotation);
28
+ const miterLimit = miterLimitOption(options.miterLimit);
23
29
  const colorspace = options.colorspace || this.options.colorspace;
24
30
  const colorName = options.colorName;
25
31
 
@@ -94,18 +100,16 @@ exports._getPathOptions = function _getPathOptions(
94
100
 
95
101
  // A fill or stroke replaces the default color. Resolving that default under
96
102
  // colorName would write an unused Separation color space for the ink.
97
- const paintsColor =
98
- options.color || options.colour || (!options.fill && !options.stroke);
103
+ const paintsColor = options.color || (!options.fill && !options.stroke);
99
104
  pathOptions.colorModel = this._transformColor(
100
- options.color || options.colour,
105
+ options.color,
101
106
  paintsColor ? colorOpts : { ...colorOpts, colorName: undefined },
102
107
  );
103
108
  pathOptions.color = pathOptions.colorModel.color;
104
109
  pathOptions.colorspace = pathOptions.colorModel.colorspace;
105
110
 
106
111
  // rotation
107
- if (options.rotation !== void 0) {
108
- const rotation = parseFloat(options.rotation);
112
+ if (rotation !== undefined) {
109
113
  pathOptions.rotation = rotation;
110
114
  pathOptions.rotationOrigin = options.rotationOrigin || null;
111
115
  }
@@ -135,11 +139,7 @@ exports._getPathOptions = function _getPathOptions(
135
139
  pathOptions.lineCap = this._lineCap(options.lineCap);
136
140
  }
137
141
 
138
- if (options.miterLimit !== void 0) {
139
- if (!isNaN(options.miterLimit)) {
140
- pathOptions.miterLimit = options.miterLimit;
141
- }
142
- }
142
+ if (miterLimit !== undefined) pathOptions.miterLimit = miterLimit;
143
143
 
144
144
  return pathOptions;
145
145
  };
@@ -14,6 +14,7 @@
14
14
 
15
15
  const { linkPdf } = require("./annotation");
16
16
  const muhammara = require("../muhammara");
17
+ const { miterLimitOption } = require("../recipe-options");
17
18
 
18
19
  /**
19
20
  * Draw a circle
@@ -668,15 +669,19 @@ exports.pie = function pie(x, y, radius, startAngle, endAngle, options = {}) {
668
669
  * @param {number} [options.lineWidth] - Alias for width.
669
670
  * @param {number} [options.cap] - The PDF line cap style, a `LineCapStyle` value.
670
671
  * @param {number} [options.join] - The PDF line join style: 0 miter, 1 round, 2 bevel.
671
- * @param {number} [options.miterLimit] - The miter limit.
672
+ * @param {number} [options.miterLimit] - The miter limit, at least 1.
672
673
  * @param {number[]} [options.dash] - The dash pattern.
673
674
  * @param {number} [options.dashPhase] - The dash pattern phase.
674
675
  * @returns {Recipe} The recipe instance.
675
676
  * @throws {TypeError} If no page is active.
677
+ * @throws {RangeError} If `miterLimit` is not a number of at least 1.
676
678
  */
677
679
  exports.lineStyle = function lineStyle(options = {}) {
678
680
  // null options act like omitted options.
679
681
  if (options === null) options = {};
682
+ // Validate before anything is written.
683
+ const miterLimit = miterLimitOption(options.miterLimit);
684
+ if (miterLimit !== undefined) options = { ...options, miterLimit };
680
685
  this.current = this.current || {};
681
686
  this.current.lineStyle = this.current.lineStyle || {};
682
687
 
@@ -0,0 +1,47 @@
1
+ // Validation of Recipe drawing options shared by shapes, text, and images.
2
+
3
+ /**
4
+ * Read a `rotation` option in degrees. A numeric string counts as its number.
5
+ * @param {*} value - The option value.
6
+ * @returns {number|undefined} The rotation, or undefined when omitted or null.
7
+ * @throws {TypeError} If the value is not a finite number: it used to write
8
+ * NaN transformation matrices.
9
+ */
10
+ function rotationOption(value) {
11
+ if (value === undefined || value === null) return undefined;
12
+ const rotation = optionNumber(value);
13
+ if (!Number.isFinite(rotation)) {
14
+ throw new TypeError("rotation must be a finite number");
15
+ }
16
+ return rotation;
17
+ }
18
+
19
+ /**
20
+ * Read a `miterLimit` option. A numeric string counts as its number.
21
+ * @param {*} value - The option value.
22
+ * @returns {number|undefined} The miter limit, or undefined when omitted or
23
+ * null.
24
+ * @throws {RangeError} If the value is not a number of at least 1, which PDF
25
+ * requires.
26
+ */
27
+ function miterLimitOption(value) {
28
+ if (value === undefined || value === null) return undefined;
29
+ const miterLimit = optionNumber(value);
30
+ if (!(miterLimit >= 1) || !Number.isFinite(miterLimit)) {
31
+ throw new RangeError("miterLimit must be a number of at least 1");
32
+ }
33
+ return miterLimit;
34
+ }
35
+
36
+ /**
37
+ * The number of a numeric option: a number, or a string holding one.
38
+ * @param {*} value - The option value.
39
+ * @returns {number} The number; NaN for any other value.
40
+ */
41
+ function optionNumber(value) {
42
+ if (typeof value === "number") return value;
43
+ if (typeof value === "string" && value.trim() !== "") return Number(value);
44
+ return NaN;
45
+ }
46
+
47
+ module.exports = { miterLimitOption, rotationOption };
package/muhammara.d.ts CHANGED
@@ -3332,20 +3332,30 @@ declare namespace muhammara {
3332
3332
  userProtectionFlag?: number;
3333
3333
  }
3334
3334
 
3335
- interface ImageOptions {
3335
+ /**
3336
+ * Options of `image()`. `fill`, `stroke`, or `color` frame the drawn image
3337
+ * box: the fill beneath the image, the outline above it and inside the
3338
+ * box, as a `rectangle()` stroke. The line options style that outline.
3339
+ */
3340
+ interface ImageOptions extends PathOptions, TransformOptions {
3336
3341
  /** Make the rendered image open this URL. */
3337
3342
  link?: string;
3343
+ /** The drawn image width. `lineWidth` sets the frame outline width. */
3338
3344
  width?: number;
3339
3345
  height?: number;
3346
+ /** Scale the source size; wins over `width` and `height`. */
3340
3347
  scale?: number;
3341
3348
  keepAspectRatio?: boolean;
3349
+ /** The opacity of the image and its frame. */
3342
3350
  opacity?: number;
3343
3351
  /** `Recipe.HorizontalAlign`, optionally followed by a space and `Recipe.VerticalAlign`. */
3344
3352
  align?: ImageAlign;
3345
- rotation?: number;
3346
- rotationOrigin?: [number, number];
3347
- skewX?: number;
3348
- skewY?: number;
3353
+ /** The one-based page of a PDF source, or image of a multi-image TIFF, as in `overlay()`; defaults to 1. */
3354
+ page?: number;
3355
+ /** Paint the image box beneath the image. */
3356
+ fill?: Color;
3357
+ /** Outline the image box in green and mark the placement point in red. */
3358
+ debug?: boolean | number;
3349
3359
  }
3350
3360
 
3351
3361
  interface InfoOptions {
@@ -4151,19 +4161,36 @@ declare namespace muhammara {
4151
4161
  * @param y - The coordinate y of the top-left corner
4152
4162
  * @param options - The options
4153
4163
  * @returns The recipe instance.
4154
- * @param options.width - The new width
4164
+ * @param options.page - The one-based page of a PDF source, or image of a
4165
+ * multi-image TIFF, as in `overlay()`; defaults to 1.
4166
+ * @param options.width - The new width. The frame outline width is
4167
+ * `options.lineWidth`.
4155
4168
  * @param options.height - The new height
4156
4169
  * @param options.scale - Scale the image from the original width and height.
4157
4170
  * @param options.keepAspectRatio - Keep the aspect ratio; defaults to true.
4158
- * @param options.opacity - The opacity.
4171
+ * @param options.opacity - The opacity of the image and its frame.
4159
4172
  * @param options.align - A `Recipe.HorizontalAlign` value, optionally
4160
4173
  * followed by a space and a `Recipe.VerticalAlign` value, for example
4161
4174
  * "center center". Horizontal center moves the image left by half its width
4162
4175
  * and right moves it right by half; vertical center moves it up by half its
4163
4176
  * height and bottom moves it down by half from its top-left placement.
4177
+ * @param options.rotation - Rotate the image, in degrees.
4178
+ * @param options.rotationOrigin - [x, y] of the rotation origin; the
4179
+ * bottom-left corner of the drawn image when omitted.
4180
+ * @param options.fill - Paint the image box beneath the image.
4181
+ * @param options.stroke - Outline the image box above the image.
4182
+ * @param options.color - Outline color when `stroke` is not given.
4183
+ * @param options.lineWidth - The outline width; the outline lies inside
4184
+ * the image box, as a `rectangle()` stroke does.
4185
+ * @param options.debug - Outline the image box in green and mark the
4186
+ * placement point in red.
4164
4187
  * @param options.link - Make the image open this URL.
4165
4188
  * @throws {TypeError} If no page is active.
4166
- * @throws {Error} If the image cannot be read.
4189
+ * @throws {RangeError} If `page` is not an integer from 1 to 4294967296,
4190
+ * a size is not a finite number, or `miterLimit` is not a number of at
4191
+ * least 1.
4192
+ * @throws {TypeError} If `rotation` is not a finite number.
4193
+ * @throws {Error} If the image, or the PDF page `page` selects, cannot be read.
4167
4194
  */
4168
4195
  image(
4169
4196
  imgSrc: string,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@muhammara/native-core",
3
- "version": "7.0.0-rc.1",
3
+ "version": "7.0.0-rc.2",
4
4
  "description": "Shared JavaScript API for MuhammaraJS native packages",
5
5
  "homepage": "https://muhammarajs.readthedocs.io/",
6
6
  "license": "Apache-2.0",
@@ -31,8 +31,7 @@
31
31
  ],
32
32
  "dependencies": {
33
33
  "@xmldom/xmldom": "^0.9.10",
34
- "linebreak": "^1.1.0",
35
- "memory-streams": "^0.1.3"
34
+ "linebreak": "^1.1.0"
36
35
  },
37
36
  "publishConfig": {
38
37
  "access": "public",