@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,10 +1,17 @@
1
1
  const LineBreaker = require("linebreak");
2
2
  const { Word, Line, Column } = require("./text.helper");
3
- const { htmlToTextObjects } = require("./htmlToTextObjects");
3
+ const { htmlToTextObjects, HtmlTag } = require("./htmlToTextObjects");
4
4
  const { Color, xObjectForm } = require("./xObjectForm");
5
5
  const { linkPdf } = require("./annotation");
6
6
  const muhammara = require("../muhammara");
7
7
  const { UsedFont } = require("../muhammara");
8
+ const {
9
+ TextWrap,
10
+ TextAlign,
11
+ VerticalAlign,
12
+ HorizontalAlign,
13
+ Colorspace,
14
+ } = require("../recipe-constants");
8
15
 
9
16
  // Table indicating how to specify coloration of elements
10
17
  // -------------------------------------------------------------------
@@ -40,6 +47,15 @@ const { UsedFont } = require("../muhammara");
40
47
  // return target
41
48
  // }
42
49
 
50
+ /**
51
+ * Merge text options: arrays and non-object values from `source` replace those
52
+ * in `target`, and nested objects are merged. `source` objects are updated in
53
+ * place; the result is a new object.
54
+ * @private
55
+ * @param {Object} target - The base options.
56
+ * @param {Object} source - The overriding options.
57
+ * @returns {Object} The merged options.
58
+ */
43
59
  exports._merge = function merge(target, source) {
44
60
  // Iterate through `source` properties and if an `Object` set property to merge of `target` and `source` properties
45
61
  for (const key of Object.keys(source)) {
@@ -63,6 +79,32 @@ exports._merge = function merge(target, source) {
63
79
  return Object.assign({}, target, source);
64
80
  };
65
81
 
82
+ /**
83
+ * Reject a character spacing that cannot become a `Tc` operand.
84
+ * @private
85
+ * @param {Object} [options] - The text options of one text() call.
86
+ * @returns {void}
87
+ * @throws {TypeError} If `options.charSpace` is given and is not a finite number.
88
+ */
89
+ function _validateCharSpace(options) {
90
+ const charSpace = options?.charSpace ?? 0;
91
+ if (!Number.isFinite(charSpace)) {
92
+ throw new TypeError("charSpace must be a finite number");
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Resolve the text position and options for text(): a call without
98
+ * coordinates continues the flow or starts at the margins, a call with them
99
+ * starts a new text box; previous options are merged in.
100
+ * @private
101
+ * @param {Recipe} self - The recipe instance.
102
+ * @param {number|"center"|Object} [x] - The x coordinate, or the options.
103
+ * @param {number|"center"} [y] - The y coordinate.
104
+ * @param {Object} [options] - The text options.
105
+ * @returns {Object} The merged text options.
106
+ * @throws {TypeError} If no page is active.
107
+ */
66
108
  function _initOptions(self, x = {}, y, options = {}) {
67
109
  // This allows user to skip providing x/y coordinates
68
110
  if (typeof x === "object") {
@@ -138,10 +180,25 @@ function _initOptions(self, x = {}, y, options = {}) {
138
180
  return mergedOpts;
139
181
  }
140
182
 
183
+ /**
184
+ * Whether a value is missing or an empty plain object.
185
+ * @private
186
+ * @param {*} obj - The value.
187
+ * @returns {boolean} True for a falsy value or an empty plain object.
188
+ */
141
189
  function isEmpty(obj) {
142
190
  return !obj || (Object.keys(obj).length === 0 && obj.constructor === Object);
143
191
  }
144
192
 
193
+ /**
194
+ * Wrap plain text as a one-element list of text layout objects, the same
195
+ * shape htmlToTextObjects() returns.
196
+ * @private
197
+ * @param {string} text - The text.
198
+ * @param {number} size - The font size.
199
+ * @param {Object} options - The text options: font, bold and italic.
200
+ * @returns {Object[]} The text layout objects.
201
+ */
145
202
  exports._makeTextObject = function _makeTextObject(text, size, options) {
146
203
  return [
147
204
  {
@@ -162,6 +219,14 @@ exports._makeTextObject = function _makeTextObject(text, size, options) {
162
219
  ];
163
220
  };
164
221
 
222
+ /**
223
+ * Build the text box from the textBox options; without them the text is
224
+ * simple, page-wide text.
225
+ * @private
226
+ * @param {Object} options - The text options.
227
+ * @returns {Object} The text box: width, height, lineHeight, padding,
228
+ * minHeight, style, textAlign, clipping and wrap settings.
229
+ */
165
230
  exports._makeTextBox = function _makeTextBox(options) {
166
231
  return isEmpty(options.textBox)
167
232
  ? {
@@ -171,7 +236,7 @@ exports._makeTextBox = function _makeTextBox(options) {
171
236
  lineHeight: 0,
172
237
  padding: 0,
173
238
  minHeight: 0,
174
- wrap: "auto",
239
+ wrap: TextWrap.AUTO,
175
240
  }
176
241
  : {
177
242
  width: options.textBox.width || 100,
@@ -184,7 +249,9 @@ exports._makeTextBox = function _makeTextBox(options) {
184
249
  clipIfExceedsBox: options.textBox.clipIfExceedsBox,
185
250
  onClip: options.textBox.onClip,
186
251
  wrap:
187
- options.textBox.wrap !== undefined ? options.textBox.wrap : "auto",
252
+ options.textBox.wrap !== undefined
253
+ ? options.textBox.wrap
254
+ : TextWrap.AUTO,
188
255
  };
189
256
  };
190
257
 
@@ -195,8 +262,8 @@ exports._makeTextBox = function _makeTextBox(options) {
195
262
  * @todo support break words
196
263
  * @memberof Recipe#
197
264
  * @param {string} [text=''] - The text content
198
- * @param {number} x - The coordinate x
199
- * @param {number} y - The coordinate y
265
+ * @param {number|"center"|Object} [x] - The coordinate x, or the options to continue at the current position
266
+ * @param {number|"center"} [y] - The coordinate y
200
267
  * @param {Object} [options] - The options
201
268
  * @param {string|number[]} [options.color] - Text color (HexColor, PercentColor or DecimalColor)
202
269
  * @param {number} [options.opacity=1] - opacity
@@ -206,8 +273,8 @@ exports._makeTextBox = function _makeTextBox(options) {
206
273
  * @param {number} [options.size=14] - The font size
207
274
  * @param {number} [options.charSpace=0] - space to be added between characters, units in points.
208
275
  * @param {string} [options.align='left top'] - This is the alignment of the text in relationship to its position
209
- * coordinates, specified as 'horizontal vertical', where horizontal is either 'left', 'center' or 'right
210
- * and vertical is either 'top', 'center' or bottom.
276
+ * coordinates, specified as 'horizontal vertical': a `Recipe.HorizontalAlign` value, optionally followed by a
277
+ * space and a `Recipe.VerticalAlign` value.
211
278
  * @param {Object|Boolean} [options.highlight] - Text markup annotation.
212
279
  * @param {Object|Boolean} [options.underline] - Text markup annotation.
213
280
  * @param {Object|Boolean} [options.strikeOut] - Text markup annotation.
@@ -230,11 +297,11 @@ exports._makeTextBox = function _makeTextBox(options) {
230
297
  * @param {number} [options.textBox.minHeight=0] - Text Box minimum height
231
298
  * @param {number|number[]} [options.textBox.padding=0] - Text Box padding, [top, right, bottom, left]
232
299
  * @param {number} [options.textBox.lineHeight=0] - Text Box line height
233
- * @param {string|Boolean} [options.textBox.wrap='auto'] - Text wrapping mechanism, may be true, false,
234
- * 'auto', 'clip', 'trim', 'ellipsis'. All the option values that are not equivalent to 'auto' dictate
300
+ * @param {Recipe.TextWrap|Boolean} [options.textBox.wrap='auto'] - Text wrapping mechanism, may be true, false,
301
+ * or a `Recipe.TextWrap` value: 'auto', 'clip', 'trim', 'ellipsis'. All the option values that are not equivalent to 'auto' dictate
235
302
  * how the text which does not fit on a line is to be truncated. True is equivalent to 'auto'. False is equivalent to 'ellipsis'.
236
303
  * @param {string} [options.textBox.textAlign='left top'] - Alignment inside text box, specified as 'horizontal vertical',
237
- * where horizontal is one of: 'left', 'center', 'right', 'justify' and vertical is one of: 'top', 'center', 'bottom'.
304
+ * where horizontal is a `Recipe.TextAlign` value and vertical a `Recipe.VerticalAlign` value.
238
305
  * @param {boolean} [options.textBox.clipIfExceedsBox=false] - Render only complete lines that fit within the text box height.
239
306
  * @param {function} [options.textBox.onClip] - Called as onClip(recipe, result) when clipping leaves text unrendered.
240
307
  * Do not call endPage() or endPDF() in this callback because the text operation is still active.
@@ -248,16 +315,31 @@ exports._makeTextBox = function _makeTextBox(options) {
248
315
  * @param {string} [options.title] - Title of annotation
249
316
  * @param {boolean} [options.open=false] - Open the annotation. Annotation will be closed by default. Specific to text annotations; subtype='Text'
250
317
  * @param {boolean} [options.richText] - Rich text in annotation
251
- * @param {'invisible'|'hidden'|'print'|'nozoom'|'norotate'|'noview'|'readonly'|'locked'|'togglenoview'} [options.flag] - The annotation flag.
252
- * @param {'Comment'|'Key'|'Note'|'Help'|'NewParagraph'|'Paragraph'|'Insert'} [options.icon='Note'] - The icon of annotation. Specific to text annotations.
318
+ * @param {Recipe.AnnotFlag} [options.flag] - The annotation flag, a `Recipe.AnnotFlag` value.
319
+ * @param {Recipe.AnnotIcon} [options.icon='Note'] - The icon of annotation, a `Recipe.AnnotIcon` value. Specific to text annotations.
253
320
  * @param {string} [options.date] - Date of text to show up on annotation
254
321
  * @param {string} [options.subject] - Subject of annotation.
255
- * @returns {Recipe} The recipe instance.
322
+ * @param {string} [options.link] - Make the text open this URL.
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.
325
+ * @throws {Error} If an overflow callback names an undefined layout, or a font cannot be loaded.
256
326
  */
257
327
  exports.text = function text(text = "", x, y, options = {}) {
328
+ // null options act like omitted options.
329
+ if (options === null) options = {};
258
330
  if (!this.pageContext) {
259
331
  return this;
260
332
  }
333
+ // Validate before _initOptions moves the text position or resets the flow.
334
+ const rawOptions = (typeof x === "object" ? x : options) || {};
335
+ _validateCharSpace(rawOptions);
336
+ // Reject invalid markup annotations before any text is drawn.
337
+ for (let key in rawOptions) {
338
+ if (this._getTextMarkupAnnotationSubtype(key) && rawOptions[key]) {
339
+ const markup = typeof rawOptions[key] === "object" ? rawOptions[key] : {};
340
+ this._validateAnnot({ ...markup, flag: rawOptions.flag });
341
+ }
342
+ }
261
343
  options = _initOptions(this, x, y, options);
262
344
  const linkX = this.x;
263
345
  const linkY = this.y;
@@ -361,16 +443,15 @@ exports.text = function text(text = "", x, y, options = {}) {
361
443
  ? toWriteTextObjects[0].writeOptions.alignVertical
362
444
  : undefined
363
445
  ) {
364
- case "center":
446
+ case VerticalAlign.CENTER:
365
447
  textYpos -=
366
448
  (textBox.height - textBox.textHeight) / 2 - textBox.paddingTop;
367
449
  break;
368
- case "bottom":
450
+ case VerticalAlign.BOTTOM:
369
451
  textYpos -= textBox.height - textBox.textHeight - textBox.paddingBottom;
370
452
  break;
371
453
  }
372
454
 
373
- let context = this.pageContext;
374
455
  let currentY = textYpos - textBox.paddingTop;
375
456
  let boxTop = currentY;
376
457
  let currentLineID;
@@ -379,7 +460,6 @@ exports.text = function text(text = "", x, y, options = {}) {
379
460
  let columnIndex = 1;
380
461
 
381
462
  toWriteTextObjects.some((toWriteTextObject, index) => {
382
- const isHTML = toWriteTextObject.writeOptions.html;
383
463
  const { text, lineHeight, lineWidth, lineID, spaceWidth } =
384
464
  toWriteTextObject;
385
465
 
@@ -390,10 +470,10 @@ exports.text = function text(text = "", x, y, options = {}) {
390
470
  let spaceWidth = content.text.endsWith(" ") ? content.spaceWidth : 0;
391
471
  let offsetX;
392
472
  switch (content.writeOptions.alignHorizontal) {
393
- case "center":
473
+ case TextAlign.CENTER:
394
474
  offsetX = (textBox.width - currentLineWidth) / 2;
395
475
  break;
396
- case "right":
476
+ case TextAlign.RIGHT:
397
477
  offsetX =
398
478
  textBox.width -
399
479
  textBox.paddingRight -
@@ -413,9 +493,22 @@ exports.text = function text(text = "", x, y, options = {}) {
413
493
  if (options.underline) {
414
494
  const underlineY = y - options.textHeight * 0.1;
415
495
  const width = options.lineWidth;
496
+ ctx.q();
497
+ if (options.colorModel.xObject) {
498
+ this._setSeparationColor(
499
+ options.colorModel.xObject,
500
+ options.colorModel,
501
+ true,
502
+ );
503
+ }
416
504
  ctx
417
- .q()
418
- .drawPath(x, underlineY, x + width, underlineY, options)
505
+ .drawPath(
506
+ x,
507
+ underlineY,
508
+ x + width,
509
+ underlineY,
510
+ this._devicePathOptions(options),
511
+ )
419
512
  .Q();
420
513
  }
421
514
  };
@@ -425,9 +518,22 @@ exports.text = function text(text = "", x, y, options = {}) {
425
518
  if (options.strikeOut) {
426
519
  const strikeOutY = y + options.textHeight * 0.2;
427
520
  const width = options.lineWidth;
521
+ ctx.q();
522
+ if (options.colorModel.xObject) {
523
+ this._setSeparationColor(
524
+ options.colorModel.xObject,
525
+ options.colorModel,
526
+ true,
527
+ );
528
+ }
428
529
  ctx
429
- .q()
430
- .drawPath(x, strikeOutY, x + width, strikeOutY, options)
530
+ .drawPath(
531
+ x,
532
+ strikeOutY,
533
+ x + width,
534
+ strikeOutY,
535
+ this._devicePathOptions(options),
536
+ )
431
537
  .Q();
432
538
  }
433
539
  };
@@ -475,7 +581,7 @@ exports.text = function text(text = "", x, y, options = {}) {
475
581
  }
476
582
  };
477
583
 
478
- const writeText = (context, x, y, wto) => {
584
+ const writeText = (x, y, wto) => {
479
585
  const options = wto.writeOptions;
480
586
  const { lineWidth, lineHeight, text, baseline } = wto;
481
587
  let next_x = 0;
@@ -484,6 +590,9 @@ exports.text = function text(text = "", x, y, options = {}) {
484
590
  // nothing to write, so simply escape.
485
591
  return next_x;
486
592
  }
593
+ // The opacity and rotation path below resets x to the text box edge;
594
+ // text-markup annotations need the line's own start.
595
+ const lineX = x;
487
596
 
488
597
  if (options.underline || options.strikeOut) {
489
598
  options.lineWidth = lineWidth;
@@ -498,12 +607,12 @@ exports.text = function text(text = "", x, y, options = {}) {
498
607
  // The hiliting rectangle cannot use the text box line
499
608
  // width when justification is activated because the
500
609
  // spaces between words is calculated dynamically.
501
- if (options.alignHorizontal === "justify") {
610
+ if (options.alignHorizontal === TextAlign.JUSTIFY) {
502
611
  bxWidth = justify(nx, x, wto, textBox) - x;
503
612
 
504
613
  // Except for 'right' alignment cases, have to consider
505
614
  // text on line ending with spaces to tweak box width.
506
- } else if (options.alignHorizontal !== "right") {
615
+ } else if (options.alignHorizontal !== TextAlign.RIGHT) {
507
616
  if (text.endsWith(" ")) {
508
617
  bxWidth += wto.spaceWidth;
509
618
  }
@@ -519,14 +628,18 @@ exports.text = function text(text = "", x, y, options = {}) {
519
628
  }
520
629
 
521
630
  // Note that the last line of a text box ignores justification.
522
- const _justify = options.alignHorizontal === "justify" && !wto.lastLine;
631
+ const _justify =
632
+ options.alignHorizontal === TextAlign.JUSTIFY && !wto.lastLine;
523
633
 
524
634
  // write directly to page when not dealing with opacity, rotation and special colorspace.
525
635
  if (
526
636
  options.opacity === 1 &&
527
- options.colorspace !== "separation" &&
637
+ options.colorspace !== Colorspace.SEPARATION &&
528
638
  (options.rotation === 0 || options.rotation === undefined)
529
639
  ) {
640
+ // Read the context here: a hilite rectangle or an earlier line may
641
+ // have paused an edited page, which resumes into a new context.
642
+ const context = this.pageContext;
530
643
  context.q();
531
644
 
532
645
  if (_justify) {
@@ -542,7 +655,7 @@ exports.text = function text(text = "", x, y, options = {}) {
542
655
  },
543
656
  );
544
657
  } else {
545
- if (textBox.wrap !== "auto") {
658
+ if (textBox.wrap !== TextWrap.AUTO) {
546
659
  // This applies a clipping region around the text
547
660
  context
548
661
  .m(nx, y + lineHeight)
@@ -607,20 +720,39 @@ exports.text = function text(text = "", x, y, options = {}) {
607
720
 
608
721
  const { textHeight } = options;
609
722
 
723
+ var markupLeft = lineX;
724
+ var markupBottom = y - textHeight * 0.2;
725
+ var markupWidth = _justify ? next_x - lineX : currentLineWidth;
726
+ var markupHeight = textHeight * 1.4;
727
+ if (textBox.wrap === TextWrap.CLIP) {
728
+ // Clipped runs retain the first overflowing word, so measure the
729
+ // drawn text instead of using the preceding fitting line's width.
730
+ var markupRight = Math.min(
731
+ lineX + new Word(text, options).dimensions.xMax,
732
+ nx + textBox.width,
733
+ );
734
+ var markupTop = Math.min(markupBottom + markupHeight, y + lineHeight);
735
+ markupLeft = Math.max(markupLeft, nx);
736
+ markupBottom = Math.max(markupBottom, y);
737
+ markupWidth = markupRight - markupLeft;
738
+ markupHeight = markupTop - markupBottom;
739
+ if (markupWidth <= 0 || markupHeight <= 0) return next_x;
740
+ }
741
+
610
742
  for (let key in targetAnnotations) {
611
743
  const subtype = this._getTextMarkupAnnotationSubtype(key);
612
- if (subtype) {
744
+ if (subtype && targetAnnotations[key]) {
745
+ // Copy so the caller's markup options are not modified.
613
746
  const markupOption =
614
747
  typeof targetAnnotations[key] != "object"
615
748
  ? {}
616
- : targetAnnotations[key];
749
+ : { ...targetAnnotations[key] };
617
750
  const { title, open, richText, flag, icon, date, subject } =
618
751
  targetAnnotations;
619
752
  Object.assign(markupOption, {
620
- height: textHeight * 1.4,
621
- width: currentLineWidth,
753
+ height: markupHeight,
754
+ width: markupWidth,
622
755
  text: markupOption.text || "",
623
- _textHeight: textHeight,
624
756
  // add options to annotation
625
757
  title: title || "",
626
758
  open: Boolean(open),
@@ -630,7 +762,11 @@ exports.text = function text(text = "", x, y, options = {}) {
630
762
  date: date || "",
631
763
  subject: subject || "",
632
764
  });
633
- const { ox, oy } = this._reverseCoordinate(x, y - textHeight * 0.2);
765
+ // annot() takes the rectangle's top-left corner.
766
+ const { ox, oy } = this._reverseCoordinate(
767
+ markupLeft,
768
+ markupBottom + markupHeight,
769
+ );
634
770
 
635
771
  this.annot(ox, oy, subtype, markupOption);
636
772
  }
@@ -639,22 +775,37 @@ exports.text = function text(text = "", x, y, options = {}) {
639
775
  return next_x;
640
776
  };
641
777
 
778
+ /** Queues a text link limited to the run's visible clipping region. */
779
+ var queueTextLink = function (content, x, y, nextX) {
780
+ if (!content.writeOptions.link) return;
781
+ var left = x;
782
+ var width = nextX ? nextX - x : content.lineWidth;
783
+ if (textBox.wrap === TextWrap.CLIP) {
784
+ var right = Math.min(
785
+ x + new Word(content.text, content.writeOptions).dimensions.xMax,
786
+ nx + textBox.width,
787
+ );
788
+ left = Math.max(left, nx);
789
+ width = right - left;
790
+ if (width <= 0) return;
791
+ }
792
+ linkAnnotations.push({
793
+ url: content.writeOptions.link,
794
+ left,
795
+ bottom: y,
796
+ width,
797
+ height: content.lineHeight,
798
+ });
799
+ };
800
+
642
801
  if (!isContinued) {
643
802
  // flush out current line before processing next one
644
803
  let next_x = 0;
645
804
  toWriteContents.forEach((content) => {
646
805
  const x = next_x || getStartX(content.startX, content);
647
806
  const y = currentY;
648
- next_x = writeText(context, x, y, content);
649
- if (content.writeOptions.link) {
650
- linkAnnotations.push({
651
- url: content.writeOptions.link,
652
- left: x,
653
- bottom: y,
654
- width: next_x ? next_x - x : content.lineWidth,
655
- height: content.lineHeight,
656
- });
657
- }
807
+ next_x = writeText(x, y, content);
808
+ queueTextLink(content, x, y, next_x);
658
809
  });
659
810
  // The line offset from the last line in the
660
811
  // group determines Y positioning for next line.
@@ -683,6 +834,7 @@ exports.text = function text(text = "", x, y, options = {}) {
683
834
  }
684
835
 
685
836
  if (overflow && this._overflowNotifier) {
837
+ flushTextLinks(this, linkAnnotations);
686
838
  let orders = this._overflowNotifier(this);
687
839
  if (orders === true) {
688
840
  return true; // stop processing remaining text.
@@ -710,7 +862,6 @@ exports.text = function text(text = "", x, y, options = {}) {
710
862
  updateTextBox(this._columns[columnIndex - 1]);
711
863
  updateVertical = false;
712
864
  }
713
- context = this.pageContext; // in case user changed page
714
865
  }
715
866
 
716
867
  if (updateVertical) {
@@ -723,18 +874,16 @@ exports.text = function text(text = "", x, y, options = {}) {
723
874
 
724
875
  const startX = nx + currentLineWidth;
725
876
 
726
- // This text will only ever have this strange tag when the
727
- // HTML option is being used and an explicit linebreak encountered.
728
- if (text != "[@@DONOT_RENDER_THIS@@]") {
729
- toWriteTextObject.startX = toWriteTextObject.startX || startX;
730
- toWriteContents.push(toWriteTextObject);
731
- }
877
+ toWriteTextObject.startX = toWriteTextObject.startX || startX;
878
+ toWriteContents.push(toWriteTextObject);
732
879
 
733
880
  // To handle text that has been split in middle of word,
734
- // need to decide if current text ends with a space.
881
+ // need to decide if current text ends with a space. HTML segments carry
882
+ // their own separating space, so adding one unconditionally in HTML mode
883
+ // measured every line a space wider than it is drawn and pushed aligned
884
+ // lines left.
735
885
  currentLineWidth +=
736
- lineWidth +
737
- (isHTML || toWriteTextObject.text.endsWith(" ") ? spaceWidth : 0);
886
+ lineWidth + (toWriteTextObject.text.endsWith(" ") ? spaceWidth : 0);
738
887
 
739
888
  // Processing last text object?
740
889
  if (index === toWriteTextObjects.length - 1) {
@@ -752,16 +901,8 @@ exports.text = function text(text = "", x, y, options = {}) {
752
901
  const content = toWriteContents[ii];
753
902
  const x = next_x || getStartX(content.startX, content);
754
903
  const y = currentY;
755
- next_x = writeText(context, x, y, content);
756
- if (content.writeOptions.link) {
757
- linkAnnotations.push({
758
- url: content.writeOptions.link,
759
- left: x,
760
- bottom: y,
761
- width: next_x ? next_x - x : content.lineWidth,
762
- height: content.lineHeight,
763
- });
764
- }
904
+ next_x = writeText(x, y, content);
905
+ queueTextLink(content, x, y, next_x);
765
906
  }
766
907
 
767
908
  // Flush any left over text objects.
@@ -772,31 +913,56 @@ exports.text = function text(text = "", x, y, options = {}) {
772
913
  });
773
914
 
774
915
  if (clipResult && typeof textBox.onClip === "function") {
775
- // The active text operation owns the page context until it returns.
916
+ // The callback may finish the page or start drawing on another one.
917
+ flushTextLinks(this, linkAnnotations);
776
918
  textBox.onClip(this, clipResult);
777
919
  }
778
920
  }
779
921
 
780
- linkAnnotations.forEach((annotation) => {
922
+ flushTextLinks(this, linkAnnotations);
923
+ return this;
924
+ };
925
+
926
+ /**
927
+ * Write pending text links before a callback can change the active page.
928
+ * @private
929
+ * @param {Recipe} recipe - The recipe instance.
930
+ * @param {Object[]} annotations - Pending {url, left, bottom, width, height}
931
+ * links; emptied.
932
+ * @returns {void}
933
+ */
934
+ function flushTextLinks(recipe, annotations) {
935
+ for (var annotation of annotations.splice(0)) {
781
936
  linkPdf(
782
- this,
937
+ recipe,
783
938
  annotation.url,
784
939
  annotation.left,
785
940
  annotation.bottom,
786
941
  annotation.width,
787
942
  annotation.height,
788
943
  );
789
- });
790
- return this;
791
- };
944
+ }
945
+ }
792
946
 
947
+ /**
948
+ * Lay out text objects in a text box: resolve padding and wrap, walk HTML
949
+ * children (list bullets, numbering and indentation), split them into lines
950
+ * and turn line breaks into line state.
951
+ * @private
952
+ * @param {Object[]} textObjects - The text layout objects.
953
+ * @param {Object} textBox - The text box; wrap and padding are normalized in place.
954
+ * @param {Object} pathOptions - The resolved text options.
955
+ * @returns {{toWriteTextObjects: Object[], textHeight: number}} The laid-out
956
+ * runs and the total text height.
957
+ * @throws {Error} If a font cannot be loaded.
958
+ */
793
959
  exports._layoutText = function _layoutText(textObjects, textBox, pathOptions) {
794
960
  let totalHeight = 0;
795
961
  // allow user to treat wrap as boolean
796
962
  if (textBox.wrap === true) {
797
- textBox.wrap = "auto";
963
+ textBox.wrap = TextWrap.AUTO;
798
964
  } else if (textBox.wrap === false) {
799
- textBox.wrap = "ellipsis";
965
+ textBox.wrap = TextWrap.ELLIPSIS;
800
966
  }
801
967
 
802
968
  // Allows user to enter a single number which will be used for all text box sides,
@@ -831,19 +997,52 @@ exports._layoutText = function _layoutText(textObjects, textBox, pathOptions) {
831
997
 
832
998
  let firstLineHeight;
833
999
  let toWriteTextObjects = [];
834
- /** Reports whether a layout node contains content other than the break sentinel. */
1000
+ /** Reports whether a layout node contains text, not only line breaks. */
835
1001
  const hasRenderableContent = (textObject) =>
836
1002
  (textObject.value !== undefined &&
837
1003
  textObject.value !== null &&
838
- textObject.value !== "" &&
839
- textObject.value !== "[@@DONOT_RENDER_THIS@@]") ||
1004
+ textObject.value !== "") ||
840
1005
  textObject.childs?.some(hasRenderableContent);
841
1006
 
1007
+ /** Adds laid-out objects, recording the first line height. */
1008
+ const addLaidOutObjects = (newToWriteObjects, paragraphHeight) => {
1009
+ toWriteTextObjects = [...toWriteTextObjects, ...newToWriteObjects];
1010
+
1011
+ if (!firstLineHeight) {
1012
+ this._lineHeight = firstLineHeight = toWriteTextObjects[0].lineHeight;
1013
+ if (!this._firstLineHeight) {
1014
+ this._firstLineHeight = this._lineHeight; // used in textbox coordinate computation
1015
+ }
1016
+ }
1017
+ totalHeight += paragraphHeight;
1018
+ };
1019
+
842
1020
  const writeValue = (textObject) => {
843
1021
  textObject.lineID = textObject.lineID || Date.now() * Math.random();
844
- textObject.lineID = textObject.needsLineBreaker
845
- ? Date.now() * Math.random()
846
- : textObject.lineID;
1022
+ textObject.lineID =
1023
+ textObject.needsLineBreaker || textObject.lineBreak
1024
+ ? Date.now() * Math.random()
1025
+ : textObject.lineID;
1026
+ if (textObject.lineBreak) {
1027
+ // A line break lays out as one empty line sized like its text; the
1028
+ // normalization below turns it into line state, never into text.
1029
+ const { toWriteTextObjects: newToWriteObjects, paragraphHeight } =
1030
+ makeTextObjects(
1031
+ this,
1032
+ Object.assign({}, textObject, { value: "" }),
1033
+ pathOptions,
1034
+ textBox,
1035
+ );
1036
+ const lineBreak = newToWriteObjects[newToWriteObjects.length - 1];
1037
+ lineBreak.lineBreak = true;
1038
+ // Flowed text marks the last word of the previous line, so an empty
1039
+ // break line still carries one empty word.
1040
+ if (!lineBreak.wordsInLine.length) {
1041
+ lineBreak.wordsInLine.push(new Word("", pathOptions));
1042
+ }
1043
+ addLaidOutObjects(newToWriteObjects, paragraphHeight);
1044
+ return;
1045
+ }
847
1046
  // Want to allow empty string to pass through. Undefined and null elements, stay out!
848
1047
  if (textObject.value !== undefined && textObject.value !== null) {
849
1048
  textObject.styles.color = textObject.styles.color
@@ -851,16 +1050,7 @@ exports._layoutText = function _layoutText(textObjects, textBox, pathOptions) {
851
1050
  : pathOptions.color;
852
1051
  const { toWriteTextObjects: newToWriteObjects, paragraphHeight } =
853
1052
  makeTextObjects(this, textObject, pathOptions, textBox);
854
-
855
- toWriteTextObjects = [...toWriteTextObjects, ...newToWriteObjects];
856
-
857
- if (!firstLineHeight) {
858
- this._lineHeight = firstLineHeight = toWriteTextObjects[0].lineHeight;
859
- if (!this._firstLineHeight) {
860
- this._firstLineHeight = this._lineHeight; // used in textbox coordinate computation
861
- }
862
- }
863
- totalHeight += paragraphHeight;
1053
+ addLaidOutObjects(newToWriteObjects, paragraphHeight);
864
1054
  }
865
1055
  if (textObject.tag && textObject.childs.length) {
866
1056
  // console.log(textObject);
@@ -874,34 +1064,36 @@ exports._layoutText = function _layoutText(textObjects, textBox, pathOptions) {
874
1064
  textObject.currentIndex = 0;
875
1065
  let prependValue = textObject.prependValue;
876
1066
 
1067
+ const tag = (textObject.tag || "").toLowerCase();
877
1068
  textObject.childs.forEach((child) => {
878
- if (textObject.tag == "ul") {
1069
+ const childTag = (child.tag || "").toLowerCase();
1070
+ if (tag === HtmlTag.UL) {
879
1071
  child.prependValue = "* ";
880
1072
  child.layer = textObject.layer + 1;
881
1073
  // child.indent = 4 * child.layer;
882
1074
  }
883
- if (textObject.tag == "ol") {
884
- if (child.tag != "ol") {
1075
+ if (tag === HtmlTag.OL) {
1076
+ if (childTag !== HtmlTag.OL) {
885
1077
  textObject.currentIndex++;
886
1078
  child.prependValue = `${textObject.currentIndex.toString()}. `;
887
1079
  }
888
1080
  child.layer = textObject.layer + 1;
889
1081
  // child.indent = 4 * child.layer;
890
1082
  }
891
- if (textObject.tag == "li") {
892
- if (child.tag == "ol" || child.tag == "ul") {
1083
+ if (tag === HtmlTag.LI) {
1084
+ if (childTag === HtmlTag.OL || childTag === HtmlTag.UL) {
893
1085
  child.layer = textObject.layer - 1;
894
1086
  }
895
1087
  }
896
1088
  if (
897
1089
  prependValue &&
898
- !["ol", "ul"].includes(child.tag) &&
1090
+ ![HtmlTag.OL, HtmlTag.UL].includes(childTag) &&
899
1091
  hasRenderableContent(child)
900
1092
  ) {
901
1093
  child.prependValue = prependValue;
902
1094
  prependValue = null;
903
1095
  textObject.indent =
904
- textObject.tag == "li"
1096
+ tag === HtmlTag.LI
905
1097
  ? 2 * textObject.layer
906
1098
  : textObject.indent || 2 * textObject.layer;
907
1099
  }
@@ -931,14 +1123,18 @@ exports._layoutText = function _layoutText(textObjects, textBox, pathOptions) {
931
1123
  });
932
1124
  }
933
1125
  };
1126
+ // Top-level nodes share a line, as children of a block element do, so
1127
+ // inline runs outside any element are not split onto separate lines.
1128
+ const topLevelLineID = Date.now() * Math.random();
934
1129
  textObjects.forEach((textObject) => {
1130
+ textObject.lineID = textObject.lineID || topLevelLineID;
935
1131
  writeValue(textObject);
936
1132
  });
937
1133
 
938
1134
  const normalizedTextObjects = [];
939
1135
  let pendingBreaks = [];
940
1136
  const replacementLineIDs = new Map();
941
- /** Converts pending break sentinels into line state without rendering their text. */
1137
+ /** Converts pending line breaks into line state: blank lines and line IDs. */
942
1138
  const appendPendingBreaks = (nextTextObject) => {
943
1139
  const previous = normalizedTextObjects[normalizedTextObjects.length - 1];
944
1140
  if (previous) previous.lineComplete = true;
@@ -963,12 +1159,13 @@ exports._layoutText = function _layoutText(textObjects, textBox, pathOptions) {
963
1159
  pendingBreaks = [];
964
1160
  };
965
1161
  toWriteTextObjects.forEach((textObject, index, objects) => {
966
- const sentinel = textObject.text.trim() == "[@@DONOT_RENDER_THIS@@]";
967
- const beforeSentinel =
1162
+ // Whitespace that only precedes a line break would start a blank line.
1163
+ const beforeLineBreak =
1164
+ !textObject.lineBreak &&
968
1165
  textObject.text.trim() == "" &&
969
- objects[index + 1]?.text.trim() == "[@@DONOT_RENDER_THIS@@]";
970
- if (beforeSentinel) return;
971
- if (sentinel) {
1166
+ objects[index + 1]?.lineBreak;
1167
+ if (beforeLineBreak) return;
1168
+ if (textObject.lineBreak) {
972
1169
  pendingBreaks.push(textObject);
973
1170
  return;
974
1171
  }
@@ -986,6 +1183,13 @@ exports._layoutText = function _layoutText(textObjects, textBox, pathOptions) {
986
1183
  };
987
1184
  };
988
1185
 
1186
+ /**
1187
+ * The height of laid-out text: the sum of the line heights, counting each
1188
+ * line once.
1189
+ * @private
1190
+ * @param {Object[]} textObjs - The laid-out runs.
1191
+ * @returns {number} The text height.
1192
+ */
989
1193
  function getTextBoxHeight(textObjs) {
990
1194
  let previousLineID;
991
1195
  let height = 0;
@@ -1009,6 +1213,15 @@ function getTextBoxHeight(textObjs) {
1009
1213
  return height;
1010
1214
  }
1011
1215
 
1216
+ /**
1217
+ * Keep the complete lines that fit a height.
1218
+ * @private
1219
+ * @param {Object[]} textObjs - The laid-out runs.
1220
+ * @param {number} availableHeight - The height available for text.
1221
+ * @returns {{textObjects: Object[], linesWritten: number, clipped: boolean,
1222
+ * remainder: string}} The fitting runs, their line count, whether lines were
1223
+ * dropped, and the dropped text with its line breaks.
1224
+ */
1012
1225
  function clipTextToBox(textObjs, availableHeight) {
1013
1226
  const lines = [];
1014
1227
  let line = [];
@@ -1053,6 +1266,15 @@ function clipTextToBox(textObjs, availableHeight) {
1053
1266
  };
1054
1267
  }
1055
1268
 
1269
+ /**
1270
+ * The PDF position of a text box, after its alignment offset.
1271
+ * @private
1272
+ * @param {Recipe} self - The recipe instance.
1273
+ * @param {Object} textBox - The laid-out text box.
1274
+ * @param {Object} pathOptions - The resolved text options.
1275
+ * @returns {number[]} The [x, y] PDF position.
1276
+ * @throws {TypeError} If no page is active.
1277
+ */
1056
1278
  function getTextBoxPosition(self, textBox, pathOptions) {
1057
1279
  const { offsetX, offsetY } = self._getTextBoxOffset(textBox, pathOptions);
1058
1280
  const { nx, ny } = self._calibrateCoordinate(
@@ -1064,6 +1286,16 @@ function getTextBoxPosition(self, textBox, pathOptions) {
1064
1286
  return [nx, ny];
1065
1287
  }
1066
1288
 
1289
+ /**
1290
+ * Draw the text box border and background from its style.
1291
+ * @private
1292
+ * @param {Recipe} self - The recipe instance.
1293
+ * @param {number} nx - The PDF x of the box.
1294
+ * @param {number} ny - The PDF y of the first line.
1295
+ * @param {Object} textBox - The laid-out text box with its style.
1296
+ * @param {Object} pathOptions - The resolved text options, for rotation.
1297
+ * @returns {void}
1298
+ */
1067
1299
  function drawTextBox(self, nx, ny, textBox, pathOptions) {
1068
1300
  const textBoxWidth = textBox.width; //+ textBox.paddingLeft + textBox.paddingRight;
1069
1301
  let borderRadius = textBox.style ? textBox.style.borderRadius : 0;
@@ -1093,6 +1325,7 @@ function drawTextBox(self, nx, ny, textBox, pathOptions) {
1093
1325
  * @param {Object[]} wto is a write object
1094
1326
  * @param {Object} textBox holds text box properties
1095
1327
  * @param {Function} [position] used to place given word at a postion on the line
1328
+ * @returns {number} The x where the next run on the line starts.
1096
1329
  */
1097
1330
  function justify(left, x, wto, textBox, position) {
1098
1331
  // For some reason, textWidth is smaller than lineWidth. My suspicions lie in the fact
@@ -1149,6 +1382,15 @@ function justify(left, x, wto, textBox, position) {
1149
1382
  return word.value.endsWith(" ") ? x : x - spaceBetweenWords;
1150
1383
  }
1151
1384
 
1385
+ /**
1386
+ * The word between the previous break and the next one.
1387
+ * @private
1388
+ * @param {string} text - The text being broken.
1389
+ * @param {Object} brk - The line break opportunity: position and required.
1390
+ * @param {number} previousPosition - The position of the previous break.
1391
+ * @param {Object} pathOptions - The resolved text options.
1392
+ * @returns {Word} The word; trimmed at a required break.
1393
+ */
1152
1394
  function nextWord(text, brk, previousPosition, pathOptions) {
1153
1395
  let nextWord = text.slice(previousPosition, brk.position);
1154
1396
 
@@ -1160,10 +1402,21 @@ function nextWord(text, brk, previousPosition, pathOptions) {
1160
1402
  return new Word(nextWord, pathOptions);
1161
1403
  }
1162
1404
 
1405
+ /**
1406
+ * Handle the first word that does not fit a line when wrapping is off: CLIP
1407
+ * keeps it for the clipping region, ELLIPSIS shortens it with "…", and TRIM
1408
+ * drops it.
1409
+ * @private
1410
+ * @param {Object} textBox - The text box with its wrap mode.
1411
+ * @param {Line} line - The full line; updated in place.
1412
+ * @param {Word} word - The word that did not fit.
1413
+ * @param {Object} pathOptions - The resolved text options.
1414
+ * @returns {void}
1415
+ */
1163
1416
  function elideNonFittingText(textBox, line, word, pathOptions) {
1164
- if (textBox.wrap === "clip") {
1417
+ if (textBox.wrap === TextWrap.CLIP) {
1165
1418
  line.addWord(word);
1166
- } else if (textBox.wrap === "ellipsis") {
1419
+ } else if (textBox.wrap === TextWrap.ELLIPSIS) {
1167
1420
  // This is more complicated than the other no-wrap options.
1168
1421
  // It makes an initial attempt to take the word that was
1169
1422
  // too big and make it shrink in size until it and the
@@ -1194,6 +1447,17 @@ function elideNonFittingText(textBox, line, word, pathOptions) {
1194
1447
  }
1195
1448
  }
1196
1449
 
1450
+ /**
1451
+ * Turn a finished line into a laid-out run and record the line.
1452
+ * @private
1453
+ * @param {Line[]} lines - The finished lines; the line is appended.
1454
+ * @param {Line} line - The line.
1455
+ * @param {number} lineID - The ID tying the first HTML line to its group.
1456
+ * @param {Object} textBox - The laid-out text box.
1457
+ * @param {Object} [options] - html, lastLine, lineComplete, wordCount,
1458
+ * totalTextWidth and writeOptions.
1459
+ * @returns {Object} The run: text, line metrics and justification data.
1460
+ */
1197
1461
  function makeTextObject(lines, line, lineID, textBox, options = {}) {
1198
1462
  const lineHeight = line.height;
1199
1463
  const spaceSz =
@@ -1230,6 +1494,15 @@ function makeTextObject(lines, line, lineID, textBox, options = {}) {
1230
1494
  };
1231
1495
  }
1232
1496
 
1497
+ /**
1498
+ * Carry justification totals across runs that share a line.
1499
+ * @private
1500
+ * @param {Line} line - The line being finished.
1501
+ * @param {Object[]} textObjects - The runs laid out so far; updated in place.
1502
+ * @param {number} wordCount - The words counted so far on the line.
1503
+ * @param {number} totalTextWidth - The text width counted so far on the line.
1504
+ * @returns {number[]} The updated [wordCount, totalTextWidth].
1505
+ */
1233
1506
  function bindTextToLine(line, textObjects, wordCount, totalTextWidth) {
1234
1507
  // Apply justification information to previous text objects.
1235
1508
  if (wordCount > 0) {
@@ -1267,6 +1540,18 @@ function bindTextToLine(line, textObjects, wordCount, totalTextWidth) {
1267
1540
  return [wordCount, totalTextWidth];
1268
1541
  }
1269
1542
 
1543
+ /**
1544
+ * Break one text layout object into lines that fit the text box, continuing
1545
+ * any unfinished flowed lines.
1546
+ * @private
1547
+ * @param {Recipe} self - The recipe instance.
1548
+ * @param {Object} [textObject] - The text layout object.
1549
+ * @param {Object} pathOptions - The resolved text options.
1550
+ * @param {Object} [textBox] - The text box; line and baseline heights are set in place.
1551
+ * @returns {{toWriteTextObjects: Object[], paragraphHeight: number}} The runs,
1552
+ * including the carried-over flowed ones, and the paragraph height.
1553
+ * @throws {Error} If the font cannot be loaded.
1554
+ */
1270
1555
  function makeTextObjects(self, textObject = {}, pathOptions, textBox = {}) {
1271
1556
  const toWriteTextObjects = [...self._previousTextObjects];
1272
1557
  let text =
@@ -1297,7 +1582,8 @@ function makeTextObjects(self, textObject = {}, pathOptions, textBox = {}) {
1297
1582
  opacity: parseFloat(textObject.styles.opacity || pathOptions.opacity || 1),
1298
1583
  underline: textObject.underline || pathOptions.underline,
1299
1584
  strikeOut: textObject.strikeOut || pathOptions.strikeOut,
1300
- size: textObject.size,
1585
+ // Text outside any HTML element has no inherited size of its own.
1586
+ size: size,
1301
1587
  alignHorizontal: alignHorizontal,
1302
1588
  alignVertical: alignVertical,
1303
1589
  font: self._getFont(textObject),
@@ -1339,7 +1625,7 @@ function makeTextObjects(self, textObject = {}, pathOptions, textBox = {}) {
1339
1625
  const previousLine = toWriteTextObjects[end];
1340
1626
  let lineComplete, fini;
1341
1627
 
1342
- if (text === "" && !self._flow) {
1628
+ if (text === "" && !self._flow && !textObject.lineBreak) {
1343
1629
  // turning off flow with empty text so
1344
1630
  previousLine.lastLine = true; // need to make previous line, the last.
1345
1631
  }
@@ -1396,7 +1682,7 @@ function makeTextObjects(self, textObject = {}, pathOptions, textBox = {}) {
1396
1682
  }
1397
1683
  } else {
1398
1684
  // remove any trailing space on previous word so right justification works appropriately
1399
- if (previousWord && textBox.wrap === "auto") {
1685
+ if (previousWord && textBox.wrap === TextWrap.AUTO) {
1400
1686
  newLine.replaceLastWord(previousWord.value.trim());
1401
1687
  }
1402
1688
 
@@ -1425,7 +1711,7 @@ function makeTextObjects(self, textObject = {}, pathOptions, textBox = {}) {
1425
1711
  }
1426
1712
 
1427
1713
  // now deal with text line wrap (what happens to text that doesn't fit in line)
1428
- if (textBox.wrap !== "auto") {
1714
+ if (textBox.wrap !== TextWrap.AUTO) {
1429
1715
  flushLine = true;
1430
1716
  elideNonFittingText(textBox, newLine, word, pathOptions);
1431
1717
 
@@ -1499,7 +1785,7 @@ function makeTextObjects(self, textObject = {}, pathOptions, textBox = {}) {
1499
1785
  }
1500
1786
  }
1501
1787
 
1502
- let isLastLine = alignHorizontal === "justify" && !self._flow;
1788
+ let isLastLine = alignHorizontal === TextAlign.JUSTIFY && !self._flow;
1503
1789
 
1504
1790
  if (!flushLine) {
1505
1791
  [wordCount, totalTextWidth] = bindTextToLine(
@@ -1535,6 +1821,14 @@ function makeTextObjects(self, textObject = {}, pathOptions, textBox = {}) {
1535
1821
  };
1536
1822
  }
1537
1823
 
1824
+ /**
1825
+ * Mark the last run as ending its line: trim it, mark its last word and
1826
+ * optionally move the next line down.
1827
+ * @private
1828
+ * @param {Object[]} toWriteTextObjects - The runs; the last one is updated.
1829
+ * @param {number|null} [lines=null] - The line offset for the next line.
1830
+ * @returns {void}
1831
+ */
1538
1832
  function markLineComplete(toWriteTextObjects, lines = null) {
1539
1833
  // Get last element in text objects and mark it.
1540
1834
  const textObj = toWriteTextObjects[toWriteTextObjects.length - 1];
@@ -1560,8 +1854,9 @@ function markLineComplete(toWriteTextObjects, lines = null) {
1560
1854
  exports.movedown = function movedown(lines = 1, returnCoords = false) {
1561
1855
  if (!this._flow || this._previousTextObjects.length === 0) {
1562
1856
  this._previousTextObjects = [];
1563
- this.y += this._lineHeight * lines;
1564
- this.x = this.box.x;
1857
+ // Before any text is written there is no cursor or line height yet.
1858
+ this.y = (this.y || 0) + (this._lineHeight || 14) * lines;
1859
+ this.x = this.box ? this.box.x : this.x || 0;
1565
1860
  } else {
1566
1861
  // This handles continuous text positioning
1567
1862
  markLineComplete(this._previousTextObjects, lines);
@@ -1572,6 +1867,15 @@ exports.movedown = function movedown(lines = 1, returnCoords = false) {
1572
1867
  return returnCoords ? [this.x, this.y] : this;
1573
1868
  };
1574
1869
 
1870
+ /**
1871
+ * Move a column layout so its first column starts at a point, keeping the
1872
+ * column spacing.
1873
+ * @private
1874
+ * @param {Column[]} columns - The layout columns; updated in place.
1875
+ * @param {number} x - The new x of the first column.
1876
+ * @param {number} y - The new y of the first column.
1877
+ * @returns {void}
1878
+ */
1575
1879
  function adjustcolumnPosition(columns, x, y) {
1576
1880
  const ydiff = y - columns[0].y;
1577
1881
  for (const column of columns) {
@@ -1586,16 +1890,17 @@ function adjustcolumnPosition(columns, x, y) {
1586
1890
  * @function
1587
1891
  * @memberof Recipe#
1588
1892
  * @param {number|string} id - The identifier to be associated with the layout. (See 'text' layout option)
1589
- * @param {number} x - The coordinate x used to position text columns on page. When zero, left margin used.
1590
- * @param {number} y - The coordinate y used to position text columns on page. When zero, top margin used.
1591
- * @param {number} width - The width of a text column. When zero, space between left and right margin used.
1592
- * @param {number} height - The height of a text column. When zero, space between top and bottom margin used.
1893
+ * @param {number} [x] - The coordinate x used to position text columns on page. When zero or omitted, left margin used.
1894
+ * @param {number} [y] - The coordinate y used to position text columns on page. When zero or omitted, top margin used.
1895
+ * @param {number} [width] - The width of a text column. When zero or omitted, space between left and right margin used.
1896
+ * @param {number} [height] - The height of a text column. When zero or omitted, space between top and bottom margin used.
1593
1897
  * @param {object} [options] - The options.
1594
1898
  * @param {number} [options.columns] - Represents the number of columns in which to divide the given width.
1595
1899
  * @param {number} [options.gap=18] - Defines the separation between layout columns, units in points.
1596
1900
  * @param {boolean} [options.reset] - True indicates that the a new layout should be produced for the given
1597
1901
  * layout id, so any previous layout associated with the given id will be lost.
1598
1902
  * @returns {Recipe} The recipe instance.
1903
+ * @throws {TypeError} If width or height is omitted while no page is active.
1599
1904
  */
1600
1905
  exports.layout = function layout(id, x, y, width, height, options = {}) {
1601
1906
  this._layouts = this._layouts || {};