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

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 (43) 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 +86 -30
  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 +482 -105
  15. package/lib/recipe/appendPage.js +8 -1
  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 +154 -21
  22. package/lib/recipe/image.js +34 -9
  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 +379 -275
  28. package/lib/recipe/parameters.js +11 -2
  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 +260 -173
  33. package/lib/recipe/text.helper.js +176 -10
  34. package/lib/recipe/text.js +420 -115
  35. package/lib/recipe/utils.js +66 -3
  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 +150 -13
  39. package/lib/recipe/vector.js +150 -53
  40. package/lib/recipe/xObjectForm.js +73 -10
  41. package/lib/recipe-constants.js +373 -0
  42. package/muhammara.d.ts +3487 -152
  43. package/package.json +1 -1
@@ -1,12 +1,39 @@
1
1
  const ANNOTATION_PREFIX = "Annots";
2
2
 
3
+ /** Recipe page content-stream lifecycle states. */
4
+ const PAGE_CONTEXT_STATE = Object.freeze({
5
+ IDLE: "idle",
6
+ ACTIVE_NEW: "active-new",
7
+ ACTIVE_EDIT: "active-edit",
8
+ PAUSED_NEW: "paused-new",
9
+ PAUSED_EDIT: "paused-edit",
10
+ });
11
+
12
+ /**
13
+ * Copies nested option objects and arrays without discarding callback values.
14
+ * @private
15
+ * @param {*} value - The options or value to copy.
16
+ * @returns {*} The copy; functions and primitives are returned as they are.
17
+ */
18
+ function cloneOptions(value) {
19
+ if (Array.isArray(value)) return value.map(cloneOptions);
20
+ if (value && typeof value === "object") {
21
+ return Object.fromEntries(
22
+ Object.entries(value).map(([key, item]) => [key, cloneOptions(item)]),
23
+ );
24
+ }
25
+ return value;
26
+ }
27
+
3
28
  /**
4
29
  * Append PDF Page with annotations.
5
30
  *
6
31
  * @param {any} pdfWriter - Hummus writer.
7
- * @param {string|any} sourcePDFPath - The path for the output pdfs or Reader stream.
8
- * @param {number} pageNumber - page number.
32
+ * @param {string|any} sourcePDFPath - The source PDF path or read stream.
33
+ * @param {number} pageNumber - The zero-based page index.
9
34
  * @private
35
+ * @returns {void}
36
+ * @throws {Error} If the source cannot be read or the page does not exist.
10
37
  */
11
38
  function appendPDFPageFromPDFWithAnnotations(
12
39
  pdfWriter,
@@ -44,8 +71,13 @@ function appendPDFPageFromPDFWithAnnotations(
44
71
  * Append PDF Pages with annotations.
45
72
  *
46
73
  * @param {any} pdfWriter - Hummus writer.
47
- * @param {string|any} sourcePDFPath - The path for the output pdfs or Reader stream.
74
+ * @param {string|any} sourcePDFPath - The source PDF path or read stream.
75
+ * @param {Object} [options] - The options.
76
+ * @param {number[][]} [options.specificRanges] - Zero-based inclusive
77
+ * [start, end] page ranges; all pages when omitted.
48
78
  * @private
79
+ * @returns {void}
80
+ * @throws {Error} If the source cannot be read or a page does not exist.
49
81
  */
50
82
  function appendPDFPagesFromPDFWithAnnotations(
51
83
  pdfWriter,
@@ -72,7 +104,38 @@ function appendPDFPagesFromPDFWithAnnotations(
72
104
  }
73
105
  }
74
106
 
107
+ /**
108
+ * Resolves a text font size from `size`, its `fontSize` alias, or a fallback,
109
+ * rejecting a size that is not a finite number greater than zero. Zero,
110
+ * negative, and infinite sizes have
111
+ * no usable meaning: they draw nothing readable and measure to nonsensical font
112
+ * metrics, so they are reported as invalid input naming the option and the
113
+ * value. Omitting both options, or passing `null` or `undefined`, selects the
114
+ * fallback.
115
+ * @param {Object} [options] - Options holding `size` or `fontSize`.
116
+ * @param {number} [fallback] - Size used when neither option is given.
117
+ * @returns {number} The resolved font size in PDF points.
118
+ * @throws {RangeError} If the given size is not a finite number greater than
119
+ * zero.
120
+ */
121
+ function resolveFontSize(options = {}, fallback) {
122
+ const name = options.size == null ? "fontSize" : "size";
123
+ const size = options[name];
124
+ if (size == null) {
125
+ return fallback;
126
+ }
127
+ if (!(size > 0) || size === Infinity) {
128
+ throw new RangeError(
129
+ `Text ${name} must be a number greater than zero, received ${size}`,
130
+ );
131
+ }
132
+ return size;
133
+ }
134
+
75
135
  exports.ANNOTATION_PREFIX = ANNOTATION_PREFIX;
136
+ exports.PAGE_CONTEXT_STATE = PAGE_CONTEXT_STATE;
137
+ exports.cloneOptions = cloneOptions;
138
+ exports.resolveFontSize = resolveFontSize;
76
139
  exports.appendPDFPageFromPDFWithAnnotations =
77
140
  appendPDFPageFromPDFWithAnnotations;
78
141
  exports.appendPDFPagesFromPDFWithAnnotations =
@@ -1,11 +1,15 @@
1
+ const muhammara = require("../muhammara");
2
+ const { Colorspace } = require("../recipe-constants");
3
+
1
4
  /**
2
5
  * move the current position to target position
3
6
  * @name moveTo
4
7
  * @function
5
8
  * @memberof Recipe#
6
- * @param {number} x - The coordinate x
7
- * @param {number} y - The coordinate y
9
+ * @param {number|"center"} x - The coordinate x
10
+ * @param {number|"center"} y - The coordinate y
8
11
  * @returns {Recipe} The recipe instance.
12
+ * @throws {TypeError} If no page is active.
9
13
  */
10
14
  exports.moveTo = function moveTo(x, y) {
11
15
  const { nx, ny } = this._calibrateCoordinate(x, y);
@@ -21,53 +25,95 @@ exports.moveTo = function moveTo(x, y) {
21
25
  * @name lineTo
22
26
  * @function
23
27
  * @memberof Recipe#
24
- * @param {number} x - The coordinate x
25
- * @param {number} y - The coordinate y
28
+ * @param {number|"center"} x - The coordinate x
29
+ * @param {number|"center"} y - The coordinate y
26
30
  * @param {Object} [options] - The options
27
31
  * @returns {Recipe} The recipe instance.
32
+ * @throws {TypeError} If no page is active.
28
33
  * @param {string|number[]} [options.color] - HexColor, PercentColor or DecimalColor
29
34
  * @param {string|number[]} [options.stroke] - HexColor, PercentColor or DecimalColor
30
35
  * @param {number} [options.lineWidth] - The line width
31
36
  * @param {number} [options.opacity] - how transparent should line be, from 0: invisible to 1: opaque
32
37
  * @param {number[]} [options.dash] - The dash pattern [dashSize, gapSize] or [dashAndGapSize]
33
38
  * @param {number} [options.dashPhase] - distance into dash pattern at which to start dash (default: 0, immediately)
34
- * @param {string} [options.lineCap] - open line end style, 'butt', 'round', or 'square' (default: 'round')
35
- * @param {string} [options.lineJoin] - joined line end style, 'miter', 'round', or 'bevel' (default: 'round')
39
+ * @param {Recipe.LineCap} [options.lineCap] - open line end style, a `Recipe.LineCap` value (default: 'round')
40
+ * @param {Recipe.LineJoin} [options.lineJoin] - joined line end style, a `Recipe.LineJoin` value (default: 'round')
36
41
  * @param {number} [options.miterLimit] - limit at which 'miter' joins are forced to 'bevel' (default: 1.414)
37
- *
38
42
  */
39
43
  exports.lineTo = function lineTo(x, y, options = {}) {
40
- const fromX = this._position.x;
41
- const fromY = this._position.y;
44
+ const from = [this._position.x, this._position.y];
42
45
  const { nx, ny } = this._calibrateCoordinate(x, y);
43
- const context = this.pageContext;
46
+ this._strokePolyline([from, [nx, ny]], options);
47
+ this.moveTo(x, y);
48
+ return this;
49
+ };
50
+
51
+ /**
52
+ * Stroke one path through PDF points, so segments meet at the line join
53
+ * instead of overlapping, and a Separation line writes one form.
54
+ * @private
55
+ * @param {number[][]} points - Two or more [x, y] points in PDF coordinates.
56
+ * @param {Object} options - The line options of `lineTo()` and `line()`.
57
+ * @returns {void}
58
+ * @throws {TypeError} If no page is active.
59
+ */
60
+ exports._strokePolyline = function _strokePolyline(points, options) {
61
+ // _getPathOptions() may pause the page to write graphics states, and an
62
+ // edited page resumes into a new context, so read the context afterwards.
44
63
  const pathOptions = this._getPathOptions(options);
45
- pathOptions.type = "stroke";
64
+ const context = this.pageContext;
65
+ pathOptions.type = muhammara.DrawingPathType.STROKE;
46
66
 
47
67
  if (pathOptions.stroke !== undefined) {
48
68
  pathOptions.color = pathOptions.stroke;
49
69
  pathOptions.colorspace = pathOptions.strokeModel.colorspace;
50
70
  }
51
71
 
52
- context
53
- .q()
54
- .J(pathOptions.lineCap)
55
- .j(pathOptions.lineJoin)
56
- .d(pathOptions.dash, pathOptions.dashPhase)
57
- .M(pathOptions.miterLimit)
58
- .drawPath(fromX, fromY, nx, ny, pathOptions)
59
- .Q();
60
- this.moveTo(x, y);
61
- return this;
72
+ const colorModel = pathOptions.strokeModel || pathOptions.colorModel;
73
+ const drawLine = (ctx, originX, originY) =>
74
+ ctx
75
+ .J(pathOptions.lineCap)
76
+ .j(pathOptions.lineJoin)
77
+ .d(pathOptions.dash, pathOptions.dashPhase)
78
+ .M(pathOptions.miterLimit)
79
+ .drawPath(
80
+ ...points.flatMap(([px, py]) => [px - originX, py - originY]),
81
+ this._devicePathOptions(pathOptions),
82
+ );
83
+
84
+ if (colorModel.colorspace === Colorspace.SEPARATION) {
85
+ // A Separation color space is a resource, so draw through a form XObject
86
+ // as the other shapes do; the padding keeps caps and miter joins inside.
87
+ const padding = pathOptions.width * Math.max(1, pathOptions.miterLimit);
88
+ const xs = points.map((point) => point[0]);
89
+ const ys = points.map((point) => point[1]);
90
+ const left = Math.min(...xs) - padding;
91
+ const bottom = Math.min(...ys) - padding;
92
+ this._drawObject(
93
+ this,
94
+ left,
95
+ bottom,
96
+ Math.max(...xs) - Math.min(...xs) + padding * 2,
97
+ Math.max(...ys) - Math.min(...ys) + padding * 2,
98
+ {},
99
+ (ctx, xObject) => {
100
+ this._setSeparationColor(xObject, colorModel, true);
101
+ drawLine(ctx, left, bottom);
102
+ },
103
+ );
104
+ } else {
105
+ drawLine(context.q(), 0, 0).Q();
106
+ }
62
107
  };
63
108
 
64
109
  /**
65
- * Draw a line
110
+ * Draw a line through coordinate pairs, or from (startX, startY) to
111
+ * (endX, endY) when called as `line(startX, startY, endX, endY, options?)`.
66
112
  * @name line
67
113
  * @function
68
114
  * @memberof Recipe#
69
- * @param {number[]} coordinates - The array of coordinate [[x,y], [m,n]]
70
- * @param {Object} [options] - The options
115
+ * @param {number[][]|number} coordinates - The array of coordinate [[x,y], [m,n]], or the start x
116
+ * @param {Object} [options] - The options, or the start y in the four-number form
71
117
  * @returns {Recipe} The recipe instance.
72
118
  * @param {string|number[]} [options.color] - HexColor, PercentColor or DecimalColor
73
119
  * @param {string|number[]} [options.stroke] - HexColor, PercentColor or DecimalColor
@@ -75,20 +121,36 @@ exports.lineTo = function lineTo(x, y, options = {}) {
75
121
  * @param {number} [options.opacity] - how transparent should line be, from 0: invisible to 1: opaque
76
122
  * @param {number[]} [options.dash] - The dash pattern [dashSize, gapSize] or [dashAndGapSize]
77
123
  * @param {number} [options.dashPhase] - distance into dash pattern at which to start dash (default: 0, immediately)
78
- * @param {string} [options.lineCap] - open line end style, 'butt', 'round', or 'square' (default: 'round')
79
- * @param {string} [options.lineJoin] - joined line end style, 'miter', 'round', or 'bevel' (default: 'round')
80
- * @param {number} [options.miterLimit] - limit at which 'miter' joins are forced to 'bevel' (default: 1.414)*/
124
+ * @param {Recipe.LineCap} [options.lineCap] - open line end style, a `Recipe.LineCap` value (default: 'round')
125
+ * @param {Recipe.LineJoin} [options.lineJoin] - joined line end style, a `Recipe.LineJoin` value (default: 'round')
126
+ * @param {number} [options.miterLimit] - limit at which 'miter' joins are forced to 'bevel' (default: 1.414)
127
+ * @throws {TypeError} If no page is active.
128
+ */
81
129
  exports.line = function line(coordinates = [], options = {}) {
82
- coordinates.forEach((coordinate, index) => {
83
- if (index === 0) {
84
- this.moveTo(coordinate[0], coordinate[1]);
85
- if (this.editingPage) {
86
- // hack to force out first line when editing page
87
- this.lineTo(coordinate[0], coordinate[1], options);
88
- }
89
- } else {
90
- this.lineTo(coordinate[0], coordinate[1], options);
91
- }
92
- });
93
- return this;
130
+ // null arguments act like omitted ones.
131
+ if (coordinates === null) coordinates = [];
132
+ if (options === null) options = {};
133
+ if (typeof coordinates === "number") {
134
+ // line(startX, startY, endX, endY, options?), as in Wasm.
135
+ const [startX, startY, endX, endY, lineOptions = {}] = arguments;
136
+ coordinates = [
137
+ [startX, startY],
138
+ [endX, endY],
139
+ ];
140
+ options = lineOptions;
141
+ }
142
+ if (coordinates.length < 2) {
143
+ if (coordinates.length) this.moveTo(coordinates[0][0], coordinates[0][1]);
144
+ return this;
145
+ }
146
+ // Stroke every segment as one path, as Wasm does.
147
+ this._strokePolyline(
148
+ coordinates.map(([x, y]) => {
149
+ const { nx, ny } = this._calibrateCoordinate(x, y);
150
+ return [nx, ny];
151
+ }),
152
+ options,
153
+ );
154
+ const [lastX, lastY] = coordinates[coordinates.length - 1];
155
+ return this.moveTo(lastX, lastY);
94
156
  };
@@ -29,10 +29,20 @@
29
29
  * @param {number} [options.dashPhase] - distance into dash pattern at which to start dash (default: 0, immediately)
30
30
  * @param {number} [options.rotation] - Accept: +/- 0 through 360. Default: 0
31
31
  * @param {number[]} [options.rotationOrigin] - [originX, originY] Default: x, y
32
- * @param {string} [options.lineCap] - open line end style, 'butt', 'round', or 'square' (default: 'round')
33
- * @param {string} [options.lineJoin] - joined line end style, 'miter', 'round', or 'bevel' (default: 'round')
34
- * @param {number} [options.miterLimit] - limit at which 'miter' joins are forced to 'bevel' (default: 1.414) */
32
+ * @param {Recipe.LineCap} [options.lineCap] - open line end style, a `Recipe.LineCap` value (default: 'round')
33
+ * @param {Recipe.LineJoin} [options.lineJoin] - joined line end style, a `Recipe.LineJoin` value (default: 'round')
34
+ * @param {number} [options.miterLimit] - limit at which 'miter' joins are forced to 'bevel' (default: 1.414)
35
+ * @param {string} [options.link] - Make the polygon's bounding box open this URL.
36
+ * @throws {TypeError} If no page is active or there are no coordinates.
37
+ */
35
38
  exports.polygon = function polygon(coordinates = [], options = {}) {
39
+ // null options act like omitted options.
40
+ if (options === null) options = {};
41
+ if (!Array.isArray(coordinates) || coordinates.length === 0) {
42
+ throw new TypeError("A polygon needs at least one coordinate pair");
43
+ }
44
+ // Work on a copy so closing the path leaves the caller's array unchanged.
45
+ coordinates = coordinates.slice();
36
46
  // close polygon
37
47
  if (
38
48
  this._getDistance(coordinates[0], coordinates[coordinates.length - 1]) != 0
@@ -110,9 +120,11 @@ exports.polygon = function polygon(coordinates = [], options = {}) {
110
120
  }
111
121
 
112
122
  if (options.stroke || options.color || !options.fill) {
113
- if (pathOptions.stroke !== undefined) {
114
- colorModel = pathOptions.strokeModel;
115
- }
123
+ // The fill above may have replaced colorModel; stroke with `color`.
124
+ colorModel =
125
+ pathOptions.stroke !== undefined
126
+ ? pathOptions.strokeModel
127
+ : pathOptions.colorModel;
116
128
 
117
129
  this._drawObject(
118
130
  this,
@@ -1,5 +1,19 @@
1
1
  const { xObjectForm } = require("./xObjectForm");
2
-
2
+ const { resolveFontSize } = require("./utils");
3
+ const { Colorspace, LineCap, LineJoin } = require("../recipe-constants");
4
+
5
+ /**
6
+ * Resolve drawing and text options into path options: font, size, colors
7
+ * and color models, line style, opacity graphics states, rotation, skew and
8
+ * dash. Clamps `options.opacity` to 0..1 in place.
9
+ * @private
10
+ * @param {Object} [options] - The drawing or text options.
11
+ * @param {number} originX - The PDF x of the default rotation origin.
12
+ * @param {number} originY - The PDF y of the default rotation origin.
13
+ * @returns {Object} The resolved path options.
14
+ * @throws {RangeError} If a given font size is not greater than zero.
15
+ * @throws {Error} If the font cannot be loaded.
16
+ */
3
17
  exports._getPathOptions = function _getPathOptions(
4
18
  options = {},
5
19
  originX,
@@ -13,7 +27,7 @@ exports._getPathOptions = function _getPathOptions(
13
27
  originX,
14
28
  originY,
15
29
  font: this._getFont(options),
16
- size: options.size || this.current.defaultFontSize,
30
+ size: resolveFontSize(options, this.current.defaultFontSize),
17
31
  charSpace: options.charSpace || 0,
18
32
  underline: false,
19
33
  strikeOut: false,
@@ -46,20 +60,15 @@ exports._getPathOptions = function _getPathOptions(
46
60
  if (options.opacity == void 0 || isNaN(options.opacity)) {
47
61
  options.opacity = this.current.opacity ?? 1;
48
62
  } else {
49
- options.opacity =
50
- options.opacity < 0 ? 0 : options.opacity > 1 ? 1 : options.opacity;
63
+ // A numeric string such as "0.5" passes the isNaN check; write it as a
64
+ // number, as Wasm does.
65
+ options.opacity = Math.max(0, Math.min(1, Number(options.opacity)));
51
66
  }
52
67
  pathOptions.opacity = options.opacity;
53
68
  const extGStates = this._createExtGStates(options.opacity);
54
69
  pathOptions.strokeGsId = extGStates.stroke;
55
70
  pathOptions.fillGsId = extGStates.fill;
56
71
 
57
- if (options.size || options.fontSize) {
58
- const size = options.size || options.fontSize;
59
- if (!isNaN(size)) {
60
- pathOptions.size = size <= 0 ? 1 : size;
61
- }
62
- }
63
72
  if (options.width || options.lineWidth) {
64
73
  const width = options.width || options.lineWidth;
65
74
  if (!isNaN(width)) {
@@ -83,9 +92,13 @@ exports._getPathOptions = function _getPathOptions(
83
92
  pathOptions.fill = pathOptions.fillModel.color;
84
93
  }
85
94
 
95
+ // A fill or stroke replaces the default color. Resolving that default under
96
+ // colorName would write an unused Separation color space for the ink.
97
+ const paintsColor =
98
+ options.color || options.colour || (!options.fill && !options.stroke);
86
99
  pathOptions.colorModel = this._transformColor(
87
100
  options.color || options.colour,
88
- colorOpts,
101
+ paintsColor ? colorOpts : { ...colorOpts, colorName: undefined },
89
102
  );
90
103
  pathOptions.color = pathOptions.colorModel.color;
91
104
  pathOptions.colorspace = pathOptions.colorModel.colorspace;
@@ -131,6 +144,13 @@ exports._getPathOptions = function _getPathOptions(
131
144
  return pathOptions;
132
145
  };
133
146
 
147
+ /**
148
+ * The distance between two points.
149
+ * @private
150
+ * @param {number[]} coordA - The first [x, y] point.
151
+ * @param {number[]} coordB - The second [x, y] point.
152
+ * @returns {number} The distance.
153
+ */
134
154
  exports._getDistance = function _getDistance(coordA, coordB) {
135
155
  const disX = Math.abs(coordB[0] - coordA[0]);
136
156
  const disY = Math.abs(coordB[1] - coordA[1]);
@@ -141,6 +161,16 @@ exports._getDistance = function _getDistance(coordA, coordB) {
141
161
 
142
162
  exports._getTransformParams = getTransformParams;
143
163
 
164
+ /**
165
+ * The transformation matrix that rotates content around an origin.
166
+ * @private
167
+ * @param {number} inAngle - The rotation in degrees.
168
+ * @param {number} x - The origin x.
169
+ * @param {number} y - The origin y.
170
+ * @param {number} offsetX - The content x relative to the origin.
171
+ * @param {number} offsetY - The content y relative to the origin.
172
+ * @returns {number[]} The [a, b, c, d, e, f] matrix.
173
+ */
144
174
  function getTransformParams(inAngle, x, y, offsetX, offsetY) {
145
175
  const theta = toRadians(inAngle);
146
176
  const cosTheta = Math.cos(theta);
@@ -150,6 +180,17 @@ function getTransformParams(inAngle, x, y, offsetX, offsetY) {
150
180
  return [cosTheta, -sinTheta, sinTheta, cosTheta, x - nx, y - ny];
151
181
  }
152
182
 
183
+ /**
184
+ * Translate a content context to a point and apply the rotation options.
185
+ * @private
186
+ * @param {Object} context - The content context.
187
+ * @param {number} x - The PDF x.
188
+ * @param {number} y - The PDF y.
189
+ * @param {Object} options - The path options: rotation, rotationOrigin,
190
+ * useGivenCoords, originX, originY and deltaY.
191
+ * @returns {void}
192
+ * @throws {TypeError} If a Recipe rotation origin is converted without an active page.
193
+ */
153
194
  exports._setRotationContext = function _setRotationTransform(
154
195
  context,
155
196
  x,
@@ -190,6 +231,12 @@ exports._setRotationContext = function _setRotationTransform(
190
231
  }
191
232
  };
192
233
 
234
+ /**
235
+ * Whether the options carry an explicit [x, y] rotation origin.
236
+ * @private
237
+ * @param {Object} options - The path options.
238
+ * @returns {boolean} True for a two-element rotationOrigin array.
239
+ */
193
240
  function hasRotation(options) {
194
241
  return (
195
242
  options.rotationOrigin &&
@@ -198,10 +245,23 @@ function hasRotation(options) {
198
245
  );
199
246
  }
200
247
 
248
+ /**
249
+ * Convert degrees to radians, wrapping at 360.
250
+ * @private
251
+ * @param {number} angle - The angle in degrees.
252
+ * @returns {number} The angle in radians.
253
+ */
201
254
  function toRadians(angle) {
202
255
  return 2 * Math.PI * ((angle % 360) / 360);
203
256
  }
204
257
 
258
+ /**
259
+ * The transformation matrix for skew angles.
260
+ * @private
261
+ * @param {number} [skewXAngle=0] - The skew off the x axis, in degrees.
262
+ * @param {number} [skewYAngle=0] - The skew off the y axis, in degrees.
263
+ * @returns {number[]} The [a, b, c, d, e, f] matrix.
264
+ */
205
265
  function getSkewTransform(skewXAngle = 0, skewYAngle = 0) {
206
266
  const alpha = toRadians(skewXAngle);
207
267
  const beta = toRadians(skewYAngle);
@@ -211,6 +271,13 @@ function getSkewTransform(skewXAngle = 0, skewYAngle = 0) {
211
271
  return [1, tanAlpha, tanBeta, 1, 0, 0];
212
272
  }
213
273
 
274
+ /**
275
+ * Apply the skew options to a content context.
276
+ * @private
277
+ * @param {Object} context - The content context.
278
+ * @param {Object} options - The path options: skewX and skewY.
279
+ * @returns {void}
280
+ */
214
281
  exports._setSkewContext = function _setSkewTransform(context, options) {
215
282
  if (options.skewX || options.skewY) {
216
283
  const sm = getSkewTransform(options.skewX, options.skewY);
@@ -219,12 +286,68 @@ exports._setSkewContext = function _setSkewTransform(context, options) {
219
286
  }
220
287
  };
221
288
 
289
+ /**
290
+ * Apply a [scaleX, scaleY] ratio to a content context.
291
+ * @private
292
+ * @param {Object} context - The content context.
293
+ * @param {Object} options - The path options: ratio.
294
+ * @returns {void}
295
+ */
222
296
  exports._setScalingTransform = function _setScalingTransform(context, options) {
223
297
  if (options.ratio) {
224
298
  context.cm(options.ratio[0], 0, 0, options.ratio[1], 0, 0);
225
299
  }
226
300
  };
227
301
 
302
+ /**
303
+ * Options for a low-level drawing call. A separation color is not a device
304
+ * color: the caller sets it on the form XObject, so the low-level call gets
305
+ * neither its color nor its colorspace.
306
+ * @private
307
+ * @param {Object} pathOptions - The path options.
308
+ * @returns {Object} The path options, or a copy without color and colorspace
309
+ * for a separation color.
310
+ */
311
+ exports._devicePathOptions = function _devicePathOptions(pathOptions) {
312
+ if (pathOptions.colorspace !== Colorspace.SEPARATION) return pathOptions;
313
+ const { color, colorspace, ...deviceOptions } = pathOptions;
314
+ return deviceOptions;
315
+ };
316
+
317
+ /**
318
+ * Select a Separation color in a form XObject. Device colors are left to the
319
+ * low-level drawing call, so their output is unchanged.
320
+ * @private
321
+ * @param {Object} xObject - The form the color is selected in.
322
+ * @param {Object} [colorModel] - The color model from _transformColor().
323
+ * @param {boolean} stroke - Select the stroking instead of the fill color.
324
+ * @returns {void}
325
+ */
326
+ exports._setSeparationColor = function _setSeparationColor(
327
+ xObject,
328
+ colorModel,
329
+ stroke,
330
+ ) {
331
+ if (!colorModel || colorModel.colorspace !== Colorspace.SEPARATION) return;
332
+ if (stroke) xObject.stroke(colorModel);
333
+ else xObject.fill(colorModel);
334
+ };
335
+
336
+ /**
337
+ * Draw content through a form XObject, creating it with the callback unless
338
+ * `options.xObject` supplies one, then place it with rotation, skew and
339
+ * scaling.
340
+ * @private
341
+ * @param {Recipe} self - The recipe instance.
342
+ * @param {number} x - The PDF x.
343
+ * @param {number} y - The PDF y.
344
+ * @param {number} width - The form width.
345
+ * @param {number} height - The form height.
346
+ * @param {Object} options - The path options.
347
+ * @param {function(Object, xObjectForm): void} callback - Draws into a new form.
348
+ * @returns {void}
349
+ * @throws {Error} If no page content context is active.
350
+ */
228
351
  exports._drawObject = function _drawObject(
229
352
  self,
230
353
  x,
@@ -258,12 +381,19 @@ exports._drawObject = function _drawObject(
258
381
  context.doXObject(xObject).Q();
259
382
  };
260
383
 
384
+ /**
385
+ * The PDF line cap number of a `Recipe.LineCap` value.
386
+ * @private
387
+ * @param {Recipe.LineCap} [type] - The cap style; round when omitted or unknown.
388
+ * @returns {number} 0 for butt, 1 for round, 2 for square.
389
+ */
261
390
  exports._lineCap = function _lineCap(type) {
262
391
  const round = 1;
263
392
  let cap = round;
264
393
 
265
394
  if (type) {
266
- const capStyle = ["butt", "round", "square"];
395
+ // In PDF line cap order.
396
+ const capStyle = [LineCap.BUTT, LineCap.ROUND, LineCap.SQUARE];
267
397
  const capType = capStyle.indexOf(type);
268
398
  cap = capType !== -1 ? capType : round;
269
399
  }
@@ -271,12 +401,19 @@ exports._lineCap = function _lineCap(type) {
271
401
  return cap;
272
402
  };
273
403
 
404
+ /**
405
+ * The PDF line join number of a `Recipe.LineJoin` value.
406
+ * @private
407
+ * @param {Recipe.LineJoin} [type] - The join style; round when omitted or unknown.
408
+ * @returns {number} 0 for miter, 1 for round, 2 for bevel.
409
+ */
274
410
  exports._lineJoin = function _lineJoin(type) {
275
411
  const round = 1;
276
412
  let join = round;
277
413
 
278
414
  if (type) {
279
- const joinStyle = ["miter", "round", "bevel"];
415
+ // In PDF line join order.
416
+ const joinStyle = [LineJoin.MITER, LineJoin.ROUND, LineJoin.BEVEL];
280
417
  const joinType = joinStyle.indexOf(type);
281
418
  join = joinType !== -1 ? joinType : round;
282
419
  }