@muhammara/native-core 7.0.0 → 7.1.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.
@@ -3,8 +3,8 @@ const { Word, Line, Column } = require("./text.helper");
3
3
  const { htmlToTextObjects, HtmlTag } = require("./htmlToTextObjects");
4
4
  const { Color, xObjectForm } = require("./xObjectForm");
5
5
  const { linkPdf } = require("./annotation");
6
- const muhammara = require("../muhammara");
7
- const { UsedFont } = require("../muhammara");
6
+ const { resolveFontSize, trimBreakableEnd } = require("./utils");
7
+ const { miterLimitOption, rotationOption } = require("../recipe-options");
8
8
  const {
9
9
  TextWrap,
10
10
  TextAlign,
@@ -13,6 +13,51 @@ const {
13
13
  Colorspace,
14
14
  } = require("../recipe-constants");
15
15
 
16
+ /**
17
+ * Matches HTML that ends with a closed block element, which ends its line.
18
+ */
19
+ const BLOCK_END = /<\/(?:p|div|ul|ol|li|h[1-6]|blockquote|pre)\s*>\s*$/i;
20
+
21
+ /**
22
+ * Matches HTML that opens with a block element, which starts a new line.
23
+ */
24
+ const BLOCK_START = /^\s*<(?:p|div|ul|ol|li|h[1-6]|blockquote|pre)\b/i;
25
+
26
+ /**
27
+ * Matches the line break that ends a word at a required break.
28
+ */
29
+ const LINE_BREAK_END = /(?:\r\n|[\n\r\v\f\u0085\u2028\u2029])$/;
30
+
31
+ /**
32
+ * Find the first node with text, unless a line break or block element comes
33
+ * before it.
34
+ * @private
35
+ * @param {Object[]} nodes - HTML layout nodes in drawing order.
36
+ * @returns {Object|undefined} The node, or undefined.
37
+ */
38
+ function firstInlineText(nodes) {
39
+ for (const node of nodes) {
40
+ if (node.lineBreak || node.needsLineBreaker) return undefined;
41
+ if (node.value) return node;
42
+ if (node.childs?.some(hasLayout)) return firstInlineText(node.childs);
43
+ }
44
+ return undefined;
45
+ }
46
+
47
+ /**
48
+ * Report whether an HTML layout node has text or a line break to lay out.
49
+ * @private
50
+ * @param {Object} textObject - The layout node.
51
+ * @returns {boolean} Whether laying the node out adds a run.
52
+ */
53
+ function hasLayout(textObject) {
54
+ return Boolean(
55
+ textObject.lineBreak ||
56
+ textObject.value ||
57
+ textObject.childs?.some(hasLayout),
58
+ );
59
+ }
60
+
16
61
  // Table indicating how to specify coloration of elements
17
62
  // -------------------------------------------------------------------
18
63
  // |Color | HexColor | DecimalColor | PercentColor |
@@ -267,7 +312,7 @@ exports._makeTextBox = function _makeTextBox(options) {
267
312
  * @param {Object} [options] - The options
268
313
  * @param {string|number[]} [options.color] - Text color (HexColor, PercentColor or DecimalColor)
269
314
  * @param {number} [options.opacity=1] - opacity
270
- * @param {number} [options.rotation=0] - Accept: +/- 0 through 360.
315
+ * @param {number} [options.rotation=0] - Clockwise rotation in degrees, +/- 0 through 360.
271
316
  * @param {number[]} [options.rotationOrigin=[x,y]] - [originX, originY]
272
317
  * @param {string} [options.font=Helvetica] - The font. 'Arial', 'Helvetica'...
273
318
  * @param {number} [options.size=14] - The font size
@@ -279,8 +324,9 @@ exports._makeTextBox = function _makeTextBox(options) {
279
324
  * @param {Object|Boolean} [options.underline] - Text markup annotation.
280
325
  * @param {Object|Boolean} [options.strikeOut] - Text markup annotation.
281
326
  * @param {Boolean} [options.html] - Interpret text as html
282
- * @param {Boolean} [options.flow=false] - Used to activate/deactivate text flow which is the
283
- * ability to use multiple calls to 'text' to create an overall text box.
327
+ * @param {Boolean} [options.flow] - Used to activate/deactivate text flow which is the
328
+ * ability to use multiple calls to 'text' to create an overall text box. Defaults to
329
+ * `true` for a call without coordinates and `false` for a call with them.
284
330
  * @param {number|string} [options.layout] - An identifier of the layout to be associated with given text.
285
331
  * @param {function} [options.overflow] - Called when the text is going to exceed the area
286
332
  * of the given text object. Intended for column layouts. Its parameter is (self) where 'self' is the recipe handle so
@@ -322,6 +368,8 @@ exports._makeTextBox = function _makeTextBox(options) {
322
368
  * @param {string} [options.link] - Make the text open this URL.
323
369
  * @returns {Recipe} The recipe instance. Without an active page nothing is drawn.
324
370
  * @throws {TypeError} If `options.charSpace` or `options.rotation` is not a finite number; nothing is drawn.
371
+ * @throws {RangeError} If `options.size` is not greater than zero or
372
+ * `options.miterLimit` is below 1; nothing is drawn.
325
373
  * @throws {Error} If an overflow callback names an undefined layout, or a font cannot be loaded.
326
374
  */
327
375
  exports.text = function text(text = "", x, y, options = {}) {
@@ -330,9 +378,15 @@ exports.text = function text(text = "", x, y, options = {}) {
330
378
  if (!this.pageContext) {
331
379
  return this;
332
380
  }
333
- // Validate before _initOptions moves the text position or resets the flow.
381
+ // A call with coordinates starts a new text box, so an open flow is drawn
382
+ // first instead of being discarded.
383
+ // Validate before _initOptions moves the text position or resets the flow,
384
+ // and before an open flow is drawn.
334
385
  const rawOptions = (typeof x === "object" ? x : options) || {};
335
386
  _validateCharSpace(rawOptions);
387
+ rotationOption(rawOptions.rotation);
388
+ miterLimitOption(rawOptions.miterLimit);
389
+ resolveFontSize(rawOptions);
336
390
  // Reject invalid markup annotations before any text is drawn.
337
391
  for (let key in rawOptions) {
338
392
  if (this._getTextMarkupAnnotationSubtype(key) && rawOptions[key]) {
@@ -340,6 +394,9 @@ exports.text = function text(text = "", x, y, options = {}) {
340
394
  this._validateAnnot({ ...markup, flag: rawOptions.flag });
341
395
  }
342
396
  }
397
+ if (typeof x !== "object" && x !== undefined) {
398
+ this._flushTextFlow();
399
+ }
343
400
  options = _initOptions(this, x, y, options);
344
401
  const linkX = this.x;
345
402
  const linkY = this.y;
@@ -364,9 +421,61 @@ exports.text = function text(text = "", x, y, options = {}) {
364
421
  // save text state for continued text?
365
422
  this._textOptions = this._flow ? options : { textBox: {} };
366
423
 
367
- const textObjects = options.html
368
- ? htmlToTextObjects(text, options)
369
- : this._makeTextObject(text, pathOptions.size, options);
424
+ // Line breaks that end a flowed run end its line, as movedown() does,
425
+ // instead of being drawn.
426
+ let trailingBreaks = 0;
427
+ if (this._flow) {
428
+ const breaks = /\n+$/.exec(text);
429
+ if (breaks) {
430
+ trailingBreaks = breaks[0].length;
431
+ text = text.slice(0, breaks.index);
432
+ if (text === "" && this._previousTextObjects.length) {
433
+ return this.movedown(trailingBreaks);
434
+ }
435
+ }
436
+ }
437
+
438
+ // HTML without text or line breaks has nothing to lay out, which would
439
+ // discard the runs of an open flow, so it is laid out as empty plain text.
440
+ let textObjects =
441
+ options.html && text !== "" ? htmlToTextObjects(text, options) : null;
442
+ if (
443
+ textObjects &&
444
+ this._previousTextObjects.length &&
445
+ !textObjects.some(hasLayout)
446
+ ) {
447
+ textObjects = this._makeTextObject("", pathOptions.size, options);
448
+ }
449
+ textObjects =
450
+ textObjects || this._makeTextObject(text, pathOptions.size, options);
451
+
452
+ // HTML continues the open line of a flow unless it opens with a block
453
+ // element or the run before closed one. It then keeps one space that
454
+ // starts it, as HTML text after inline content does, unless the run
455
+ // before ends with a space.
456
+ pathOptions.startsBlock = Boolean(options.html) && BLOCK_START.test(text);
457
+ const openRun =
458
+ this._previousTextObjects[this._previousTextObjects.length - 1];
459
+ if (
460
+ options.html &&
461
+ /^\s/.test(text) &&
462
+ openRun &&
463
+ !openRun.lineComplete &&
464
+ /\S$/.test(openRun.text) &&
465
+ !this._flowEndsBlock &&
466
+ !pathOptions.startsBlock
467
+ ) {
468
+ const first = firstInlineText(textObjects);
469
+ if (first && !/^\s/.test(first.value)) {
470
+ // A space before an element is a run of its own, as in one call.
471
+ if (textObjects.includes(first)) first.value = " " + first.value;
472
+ else {
473
+ textObjects.unshift(
474
+ ...this._makeTextObject(" ", pathOptions.size, options),
475
+ );
476
+ }
477
+ }
478
+ }
370
479
  const textBox = this._makeTextBox(options);
371
480
 
372
481
  if (textBox.onClip && !textBox.clipIfExceedsBox) {
@@ -391,7 +500,22 @@ exports.text = function text(text = "", x, y, options = {}) {
391
500
  // need to collect all the text that is 'flowing' before processing.
392
501
  if (this._flow) {
393
502
  this._previousTextObjects = [...toWriteTextObjects];
503
+ // A later HTML run starts a new line after a closed block element.
504
+ this._flowEndsBlock = Boolean(options.html) && BLOCK_END.test(text);
505
+ if (trailingBreaks) this.movedown(trailingBreaks);
394
506
  } else {
507
+ this._flowEndsBlock = false;
508
+ // Runs of different sizes on one line share its baseline and height; the
509
+ // first line moves down by what it grew so its tallest run fits the box,
510
+ // and movedown() moves by its full height.
511
+ // An empty first run that a later run replaced does not size it either.
512
+ const firstLineHeight = toWriteTextObjects[0]?.lineHeight;
513
+ alignLineBaselines(toWriteTextObjects);
514
+ if (firstLineHeight !== undefined) {
515
+ this._lineHeight += toWriteTextObjects[0].lineHeight - firstLineHeight;
516
+ this._firstLineHeight = toWriteTextObjects[0].lineHeight;
517
+ textBox.firstLineHeight = this._firstLineHeight;
518
+ }
395
519
  textBox.textHeight = getTextBoxHeight(toWriteTextObjects);
396
520
 
397
521
  let clipResult;
@@ -466,8 +590,23 @@ exports.text = function text(text = "", x, y, options = {}) {
466
590
  currentLineID = currentLineID || lineID;
467
591
  const isContinued = currentLineID == lineID ? true : false;
468
592
 
593
+ /**
594
+ * Where a line's first run starts, from the box edge and the line's
595
+ * alignment.
596
+ * @param {number} startX - The x of the box edge plus the width of the
597
+ * line's runs before the run.
598
+ * @param {Object} content - The run, whose alignment places the line.
599
+ * @returns {number} The x of the run.
600
+ */
469
601
  const getStartX = (startX, content) => {
470
- let spaceWidth = content.text.endsWith(" ") ? content.spaceWidth : 0;
602
+ // Right alignment leaves out the space that ends the line, which
603
+ // belongs to its last run with text; every run of the line shifts
604
+ // alike.
605
+ const lastContent =
606
+ toWriteContents.findLast((run) => run.text !== "") || content;
607
+ let spaceWidth = lastContent.text.endsWith(" ")
608
+ ? lastContent.spaceWidth
609
+ : 0;
471
610
  let offsetX;
472
611
  switch (content.writeOptions.alignHorizontal) {
473
612
  case TextAlign.CENTER:
@@ -777,7 +916,8 @@ exports.text = function text(text = "", x, y, options = {}) {
777
916
 
778
917
  /** Queues a text link limited to the run's visible clipping region. */
779
918
  var queueTextLink = function (content, x, y, nextX) {
780
- if (!content.writeOptions.link) return;
919
+ // The empty run that ends a flow inherits the link but covers nothing.
920
+ if (!content.writeOptions.link || !content.text) return;
781
921
  var left = x;
782
922
  var width = nextX ? nextX - x : content.lineWidth;
783
923
  if (textBox.wrap === TextWrap.CLIP) {
@@ -1003,10 +1143,38 @@ exports._layoutText = function _layoutText(textObjects, textBox, pathOptions) {
1003
1143
  textObject.value !== null &&
1004
1144
  textObject.value !== "") ||
1005
1145
  textObject.childs?.some(hasRenderableContent);
1006
-
1146
+ /**
1147
+ * Finds the node laid out last that has more than spaces, after its
1148
+ * parents' text.
1149
+ * @param {Object[]} nodes - Layout nodes in drawing order.
1150
+ * @returns {Object|undefined} The node, or undefined when none has text.
1151
+ */
1152
+ const lastTextNode = (nodes) => {
1153
+ for (let i = nodes.length - 1; i >= 0; i--) {
1154
+ const node = nodes[i];
1155
+ const child =
1156
+ node.tag && node.childs?.length && lastTextNode(node.childs);
1157
+ if (child) return child;
1158
+ if (!node.lineBreak && /\S/.test(node.value ?? "")) return node;
1159
+ }
1160
+ return undefined;
1161
+ };
1162
+ // Only the call's last text and the spaces after it end its line, so the
1163
+ // spaces between its HTML elements stay when the call ends a flow.
1164
+ const lastText = lastTextNode(textObjects);
1165
+ let atEnd = !lastText;
1166
+
1167
+ // Every object is laid out after the runs of an open flow, which each
1168
+ // result repeats; the first result keeps them and later ones drop them.
1169
+ const previousRuns = new Set(this._previousTextObjects);
1007
1170
  /** Adds laid-out objects, recording the first line height. */
1008
1171
  const addLaidOutObjects = (newToWriteObjects, paragraphHeight) => {
1009
- toWriteTextObjects = [...toWriteTextObjects, ...newToWriteObjects];
1172
+ toWriteTextObjects = [
1173
+ ...toWriteTextObjects,
1174
+ ...(toWriteTextObjects.length
1175
+ ? newToWriteObjects.filter((textObj) => !previousRuns.has(textObj))
1176
+ : newToWriteObjects),
1177
+ ];
1010
1178
 
1011
1179
  if (!firstLineHeight) {
1012
1180
  this._lineHeight = firstLineHeight = toWriteTextObjects[0].lineHeight;
@@ -1017,8 +1185,19 @@ exports._layoutText = function _layoutText(textObjects, textBox, pathOptions) {
1017
1185
  totalHeight += paragraphHeight;
1018
1186
  };
1019
1187
 
1188
+ /**
1189
+ * Lays out a layout node and then its HTML children, adding the runs.
1190
+ * @param {Object} textObject - The layout node; list markers, indents,
1191
+ * sizes and styles are passed on to its children in place.
1192
+ * @returns {void}
1193
+ */
1194
+ // A run whose first word wraps ends the open line of a flow; the call's
1195
+ // later runs continue the line it wrapped onto instead.
1196
+ const wrappedLineIDs = new Map();
1020
1197
  const writeValue = (textObject) => {
1021
1198
  textObject.lineID = textObject.lineID || Date.now() * Math.random();
1199
+ textObject.lineID =
1200
+ wrappedLineIDs.get(textObject.lineID) ?? textObject.lineID;
1022
1201
  textObject.lineID =
1023
1202
  textObject.needsLineBreaker || textObject.lineBreak
1024
1203
  ? Date.now() * Math.random()
@@ -1048,9 +1227,16 @@ exports._layoutText = function _layoutText(textObjects, textBox, pathOptions) {
1048
1227
  textObject.styles.color = textObject.styles.color
1049
1228
  ? this._transformColor(textObject.styles.color)
1050
1229
  : pathOptions.color;
1230
+ atEnd = atEnd || textObject === lastText;
1051
1231
  const { toWriteTextObjects: newToWriteObjects, paragraphHeight } =
1052
- makeTextObjects(this, textObject, pathOptions, textBox);
1232
+ makeTextObjects(this, textObject, pathOptions, textBox, atEnd);
1053
1233
  addLaidOutObjects(newToWriteObjects, paragraphHeight);
1234
+ if (openLine?.lineComplete && textObject.lineID === openLine.lineID) {
1235
+ wrappedLineIDs.set(
1236
+ openLine.lineID,
1237
+ newToWriteObjects[newToWriteObjects.length - 1].lineID,
1238
+ );
1239
+ }
1054
1240
  }
1055
1241
  if (textObject.tag && textObject.childs.length) {
1056
1242
  // console.log(textObject);
@@ -1124,8 +1310,17 @@ exports._layoutText = function _layoutText(textObjects, textBox, pathOptions) {
1124
1310
  }
1125
1311
  };
1126
1312
  // 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();
1313
+ // inline runs outside any element are not split onto separate lines. In a
1314
+ // flow they continue the line the previous run left open, unless both are
1315
+ // HTML and the previous one closed a block element or this one opens one.
1316
+ const openLine =
1317
+ this._previousTextObjects?.[this._previousTextObjects.length - 1];
1318
+ const topLevelLineID =
1319
+ openLine &&
1320
+ !openLine.lineComplete &&
1321
+ !(pathOptions.html && (this._flowEndsBlock || pathOptions.startsBlock))
1322
+ ? openLine.lineID
1323
+ : Date.now() * Math.random();
1129
1324
  textObjects.forEach((textObject) => {
1130
1325
  textObject.lineID = textObject.lineID || topLevelLineID;
1131
1326
  writeValue(textObject);
@@ -1145,7 +1340,15 @@ exports._layoutText = function _layoutText(textObjects, textBox, pathOptions) {
1145
1340
  ...breakObject,
1146
1341
  lineID: previous ? pendingBreaks[index].lineID : breakObject.lineID,
1147
1342
  text: "",
1148
- lineComplete: true,
1343
+ // A later call of the flow lays the blank lines out again; they are
1344
+ // no longer breaks, and the last one is the line it continues.
1345
+ lineBreak: false,
1346
+ blankLine: true,
1347
+ lineComplete: !(
1348
+ this._flow &&
1349
+ !nextTextObject &&
1350
+ index === blankBreaks.length - 1
1351
+ ),
1149
1352
  lineWidth: 0,
1150
1353
  textWidth: 0,
1151
1354
  });
@@ -1162,6 +1365,7 @@ exports._layoutText = function _layoutText(textObjects, textBox, pathOptions) {
1162
1365
  // Whitespace that only precedes a line break would start a blank line.
1163
1366
  const beforeLineBreak =
1164
1367
  !textObject.lineBreak &&
1368
+ !textObject.blankLine &&
1165
1369
  textObject.text.trim() == "" &&
1166
1370
  objects[index + 1]?.lineBreak;
1167
1371
  if (beforeLineBreak) return;
@@ -1176,6 +1380,17 @@ exports._layoutText = function _layoutText(textObjects, textBox, pathOptions) {
1176
1380
  });
1177
1381
  if (pendingBreaks.length) appendPendingBreaks();
1178
1382
  toWriteTextObjects = normalizedTextObjects;
1383
+ // Every line the call ends, ends with its last word, so its trailing
1384
+ // spaces do not widen it, as in Wasm. A flow's open line may continue
1385
+ // in a later call, and clipped text keeps its spaces.
1386
+ if (textBox.wrap !== TextWrap.CLIP) {
1387
+ toWriteTextObjects.forEach((textObj, index) => {
1388
+ const next = toWriteTextObjects[index + 1];
1389
+ if (next && next.lineID === textObj.lineID) return;
1390
+ if (this._flow && !next && !textObj.lineComplete) return;
1391
+ trimLineEnd(toWriteTextObjects, index);
1392
+ });
1393
+ }
1179
1394
 
1180
1395
  return {
1181
1396
  toWriteTextObjects: toWriteTextObjects,
@@ -1183,6 +1398,36 @@ exports._layoutText = function _layoutText(textObjects, textBox, pathOptions) {
1183
1398
  };
1184
1399
  };
1185
1400
 
1401
+ /**
1402
+ * Give the runs of each line one height and one baseline, so runs of
1403
+ * different sizes sit on the same baseline and the tallest one fits the
1404
+ * line.
1405
+ * @private
1406
+ * @param {Object[]} textObjs - The laid-out runs; updated in place.
1407
+ * @returns {void}
1408
+ */
1409
+ function alignLineBaselines(textObjs) {
1410
+ // Runs without visible text, such as an empty run or the space before a
1411
+ // wrapped word, size only a line that has nothing else.
1412
+ const visibleLines = new Set(
1413
+ textObjs.filter(({ text }) => /\S/.test(text)).map(({ lineID }) => lineID),
1414
+ );
1415
+ const lines = new Map();
1416
+ for (const { lineID, lineHeight, baseline, text } of textObjs) {
1417
+ if (visibleLines.has(lineID) && !/\S/.test(text)) continue;
1418
+ const line = lines.get(lineID) || { lineHeight: 0, ascent: 0 };
1419
+ line.lineHeight = Math.max(line.lineHeight, lineHeight);
1420
+ // Each run's baseline offset is its line height less its ascent.
1421
+ line.ascent = Math.max(line.ascent, lineHeight - baseline);
1422
+ lines.set(lineID, line);
1423
+ }
1424
+ for (const textObj of textObjs) {
1425
+ const line = lines.get(textObj.lineID);
1426
+ textObj.lineHeight = line.lineHeight;
1427
+ textObj.baseline = line.lineHeight - line.ascent;
1428
+ }
1429
+ }
1430
+
1186
1431
  /**
1187
1432
  * The height of laid-out text: the sum of the line heights, counting each
1188
1433
  * line once.
@@ -1389,14 +1634,18 @@ function justify(left, x, wto, textBox, position) {
1389
1634
  * @param {Object} brk - The line break opportunity: position and required.
1390
1635
  * @param {number} previousPosition - The position of the previous break.
1391
1636
  * @param {Object} pathOptions - The resolved text options.
1637
+ * @param {boolean} [keepEnd=false] - Whether a required break drops only
1638
+ * the line break, keeping the spaces before it, as clipped text does.
1392
1639
  * @returns {Word} The word; trimmed at a required break.
1393
1640
  */
1394
- function nextWord(text, brk, previousPosition, pathOptions) {
1641
+ function nextWord(text, brk, previousPosition, pathOptions, keepEnd = false) {
1395
1642
  let nextWord = text.slice(previousPosition, brk.position);
1396
1643
 
1397
1644
  if (brk.required) {
1398
1645
  // effectively saw a '\n' in text.
1399
- nextWord = nextWord.trim();
1646
+ nextWord = keepEnd
1647
+ ? nextWord.trimStart().replace(LINE_BREAK_END, "")
1648
+ : trimBreakableEnd(nextWord.trimStart());
1400
1649
  }
1401
1650
 
1402
1651
  return new Word(nextWord, pathOptions);
@@ -1460,8 +1709,12 @@ function elideNonFittingText(textBox, line, word, pathOptions) {
1460
1709
  */
1461
1710
  function makeTextObject(lines, line, lineID, textBox, options = {}) {
1462
1711
  const lineHeight = line.height;
1712
+ // Clipped text is measured with the spaces that end it, as in Wasm.
1463
1713
  const spaceSz =
1464
- options.lastLine && line.lastWord && line.lastWord.value.endsWith(" ")
1714
+ textBox.wrap !== TextWrap.CLIP &&
1715
+ options.lastLine &&
1716
+ line.lastWord &&
1717
+ line.lastWord.value.endsWith(" ")
1465
1718
  ? line.spaceWidth / 2
1466
1719
  : 0;
1467
1720
  let lid;
@@ -1501,12 +1754,20 @@ function makeTextObject(lines, line, lineID, textBox, options = {}) {
1501
1754
  * @param {Object[]} textObjects - The runs laid out so far; updated in place.
1502
1755
  * @param {number} wordCount - The words counted so far on the line.
1503
1756
  * @param {number} totalTextWidth - The text width counted so far on the line.
1757
+ * @param {boolean} [ends=true] - Whether the line ends here. A line that a
1758
+ * later flowed run continues keeps the space after its last word.
1504
1759
  * @returns {number[]} The updated [wordCount, totalTextWidth].
1505
1760
  */
1506
- function bindTextToLine(line, textObjects, wordCount, totalTextWidth) {
1761
+ function bindTextToLine(
1762
+ line,
1763
+ textObjects,
1764
+ wordCount,
1765
+ totalTextWidth,
1766
+ ends = true,
1767
+ ) {
1507
1768
  // Apply justification information to previous text objects.
1508
1769
  if (wordCount > 0) {
1509
- line.markLastWord();
1770
+ if (ends) line.markLastWord();
1510
1771
  wordCount += line.words.length;
1511
1772
  totalTextWidth += line.textWidth;
1512
1773
 
@@ -1548,11 +1809,19 @@ function bindTextToLine(line, textObjects, wordCount, totalTextWidth) {
1548
1809
  * @param {Object} [textObject] - The text layout object.
1549
1810
  * @param {Object} pathOptions - The resolved text options.
1550
1811
  * @param {Object} [textBox] - The text box; line and baseline heights are set in place.
1812
+ * @param {boolean} [atEnd=true] - Whether only spaces follow the object in
1813
+ * its call, so its line ends with the call when the call ends a flow.
1551
1814
  * @returns {{toWriteTextObjects: Object[], paragraphHeight: number}} The runs,
1552
1815
  * including the carried-over flowed ones, and the paragraph height.
1553
1816
  * @throws {Error} If the font cannot be loaded.
1554
1817
  */
1555
- function makeTextObjects(self, textObject = {}, pathOptions, textBox = {}) {
1818
+ function makeTextObjects(
1819
+ self,
1820
+ textObject = {},
1821
+ pathOptions,
1822
+ textBox = {},
1823
+ atEnd = true,
1824
+ ) {
1556
1825
  const toWriteTextObjects = [...self._previousTextObjects];
1557
1826
  let text =
1558
1827
  (textObject.prependValue ? textObject.prependValue : "") +
@@ -1602,6 +1871,8 @@ function makeTextObjects(self, textObject = {}, pathOptions, textBox = {}) {
1602
1871
  const lineMaxWidth = textBox.width
1603
1872
  ? textBox.width - textBox.paddingLeft - textBox.paddingRight
1604
1873
  : null;
1874
+ // Clipped text keeps the spaces that end its lines, as in Wasm.
1875
+ const keepLineEnd = textBox.wrap === TextWrap.CLIP;
1605
1876
  let remainderWidth = lineMaxWidth;
1606
1877
  let newLine;
1607
1878
  let last = 0;
@@ -1615,19 +1886,36 @@ function makeTextObjects(self, textObject = {}, pathOptions, textBox = {}) {
1615
1886
  let lineHeight =
1616
1887
  textHeight > textBox.lineHeight ? textHeight : textBox.lineHeight;
1617
1888
 
1889
+ // An empty flowed run that leaves its line open has no words to join, so
1890
+ // it is dropped and the next run starts that line instead of a new one.
1891
+ while (
1892
+ toWriteTextObjects.length > 0 &&
1893
+ toWriteTextObjects[toWriteTextObjects.length - 1].text === "" &&
1894
+ !toWriteTextObjects[toWriteTextObjects.length - 1].lineComplete &&
1895
+ !toWriteTextObjects[toWriteTextObjects.length - 1].blankLine
1896
+ ) {
1897
+ toWriteTextObjects.pop();
1898
+ }
1899
+
1618
1900
  // When text flow is involved, there may be lines that are
1619
1901
  // incomplete. So need to determine previous line word count
1620
1902
  // and remove last line marks because more text is being processed.
1621
1903
  if (toWriteTextObjects.length > 0) {
1622
1904
  let lineWidth = 0;
1623
- let spaceSz = 0;
1624
1905
  const end = toWriteTextObjects.length - 1;
1625
1906
  const previousLine = toWriteTextObjects[end];
1626
1907
  let lineComplete, fini;
1627
1908
 
1628
- if (text === "" && !self._flow && !textObject.lineBreak) {
1909
+ if (
1910
+ !self._flow &&
1911
+ !textObject.lineBreak &&
1912
+ (text === "" || (!pathOptions.html && /^[^\S\n]+$/.test(text)))
1913
+ ) {
1629
1914
  // turning off flow with empty text so
1630
1915
  previousLine.lastLine = true; // need to make previous line, the last.
1916
+ // The flow's line ends with its last run with text, as when a run with
1917
+ // text ends the flow.
1918
+ if (!previousLine.lineComplete) trimLineEnd(toWriteTextObjects, end);
1631
1919
  }
1632
1920
 
1633
1921
  for (let i = end; i >= 0; i--) {
@@ -1637,26 +1925,27 @@ function makeTextObjects(self, textObject = {}, pathOptions, textBox = {}) {
1637
1925
  if (textObj.lineID !== previousLine.lineID) {
1638
1926
  break;
1639
1927
  }
1640
- spaceSz = textObj.spaceWidth;
1641
1928
  textLine = textObj.text + textLine;
1642
1929
  totalTextWidth += textObj.textWidth;
1643
- lineWidth += textObj.lineWidth;
1930
+ // Each run is drawn after the one before it, plus a space after a run
1931
+ // that ends with one.
1932
+ lineWidth +=
1933
+ textObj.lineWidth +
1934
+ (textObj.text.endsWith(" ") ? textObj.spaceWidth : 0);
1644
1935
  if (i === end) {
1645
1936
  fini = lineComplete = textObj.lineComplete;
1646
1937
  } else {
1647
1938
  fini = textObj.lineComplete;
1648
1939
  }
1649
- textObj.wordsInLine[textObj.wordsInLine.length - 1].lastWord(fini);
1940
+ // An empty flowed run has no words.
1941
+ textObj.wordsInLine[textObj.wordsInLine.length - 1]?.lastWord(fini);
1650
1942
  }
1651
1943
 
1652
1944
  if (lineComplete) {
1653
1945
  totalTextWidth = 0;
1654
1946
  } else if (textLine) {
1655
1947
  wordCount = textLine.trim().split(/\s+/).length;
1656
- if (!textLine.endsWith(" ")) {
1657
- spaceSz = 0;
1658
- }
1659
- remainderWidth = lineMaxWidth - lineWidth - spaceSz;
1948
+ remainderWidth = lineMaxWidth - lineWidth;
1660
1949
  }
1661
1950
  }
1662
1951
 
@@ -1664,7 +1953,7 @@ function makeTextObjects(self, textObject = {}, pathOptions, textBox = {}) {
1664
1953
  newLine.indent(indent);
1665
1954
 
1666
1955
  while (bk) {
1667
- let word = nextWord(text, bk, last, pathOptions);
1956
+ let word = nextWord(text, bk, last, pathOptions, keepLineEnd);
1668
1957
 
1669
1958
  if (newLine.canFit(word)) {
1670
1959
  newLine.addWord(word);
@@ -1678,12 +1967,18 @@ function makeTextObjects(self, textObject = {}, pathOptions, textBox = {}) {
1678
1967
  // and mark last word of previous line in case justifying.
1679
1968
  wordCount = 0;
1680
1969
  totalTextWidth = 0;
1970
+ trimLineEnd(toWriteTextObjects, toWriteTextObjects.length - 1);
1681
1971
  markLineComplete(toWriteTextObjects);
1972
+ // The word wraps, so an HTML run's first line is a new line, not
1973
+ // the open one it would have continued.
1974
+ lineID = newLine.lineID;
1682
1975
  }
1683
1976
  } else {
1684
1977
  // remove any trailing space on previous word so right justification works appropriately
1685
1978
  if (previousWord && textBox.wrap === TextWrap.AUTO) {
1686
- newLine.replaceLastWord(previousWord.value.trim());
1979
+ newLine.replaceLastWord(
1980
+ trimBreakableEnd(previousWord.value.trimStart()),
1981
+ );
1687
1982
  }
1688
1983
 
1689
1984
  [wordCount, totalTextWidth] = bindTextToLine(
@@ -1706,6 +2001,12 @@ function makeTextObjects(self, textObject = {}, pathOptions, textBox = {}) {
1706
2001
  }),
1707
2002
  ),
1708
2003
  );
2004
+ // The line may end with this run's spaces, after earlier runs.
2005
+ // Clipped and ellipsized lines continue with the word that does
2006
+ // not fit, so they keep the space before it.
2007
+ if (textBox.wrap === TextWrap.AUTO) {
2008
+ trimLineEnd(toWriteTextObjects, toWriteTextObjects.length - 1);
2009
+ }
1709
2010
  wordCount = 0;
1710
2011
  totalTextWidth = 0;
1711
2012
  }
@@ -1743,7 +2044,14 @@ function makeTextObjects(self, textObject = {}, pathOptions, textBox = {}) {
1743
2044
  .join("");
1744
2045
  newLine.addWord(new Word(space, pathOptions));
1745
2046
  }
1746
- newLine.addWord(word);
2047
+ // A wrapped line starts with its first word, without the spaces
2048
+ // that started the run before it wrapped.
2049
+ const wrapped = word.value.replace(/^[^\S\u00a0]+/, "");
2050
+ if (wrapped) {
2051
+ newLine.addWord(
2052
+ wrapped === word.value ? word : new Word(wrapped, pathOptions),
2053
+ );
2054
+ }
1747
2055
  }
1748
2056
  }
1749
2057
 
@@ -1773,7 +2081,7 @@ function makeTextObjects(self, textObject = {}, pathOptions, textBox = {}) {
1773
2081
  Object.assign({ lineComplete: true, lastLine: true }, lineOpts),
1774
2082
  ),
1775
2083
  );
1776
- markLineComplete(toWriteTextObjects);
2084
+ markLineComplete(toWriteTextObjects, null, keepLineEnd);
1777
2085
  newLine = new Line(lineMaxWidth, lineHeight, size, pathOptions);
1778
2086
  }
1779
2087
  }
@@ -1793,6 +2101,7 @@ function makeTextObjects(self, textObject = {}, pathOptions, textBox = {}) {
1793
2101
  toWriteTextObjects,
1794
2102
  wordCount,
1795
2103
  totalTextWidth,
2104
+ !self._flow && atEnd,
1796
2105
  );
1797
2106
 
1798
2107
  toWriteTextObjects.push(
@@ -1821,28 +2130,82 @@ function makeTextObjects(self, textObject = {}, pathOptions, textBox = {}) {
1821
2130
  };
1822
2131
  }
1823
2132
 
2133
+ /**
2134
+ * Drop the spaces that end a line, back to its last word: the line's runs of
2135
+ * only spaces become empty, and the run with its last word loses the spaces
2136
+ * after it, as the last run of a line does. Non-breaking spaces stay.
2137
+ * @private
2138
+ * @param {Object[]} textObjs - The laid-out runs; the line's are updated in place.
2139
+ * @param {number} end - The index of the line's last run.
2140
+ * @returns {void}
2141
+ */
2142
+ function trimLineEnd(textObjs, end) {
2143
+ const { lineID } = textObjs[end];
2144
+ for (let i = end; i >= 0; i--) {
2145
+ const run = textObjs[i];
2146
+ if (run.lineID !== lineID || (i < end && run.lineComplete)) break;
2147
+ const lastWord = run.wordsInLine[run.wordsInLine.length - 1];
2148
+ const trimmed = trimBreakableEnd(run.text);
2149
+ if (lastWord && trimmed !== run.text) {
2150
+ lastWord.lastWord();
2151
+ run.text = trimmed;
2152
+ run.lineWidth = trimmed
2153
+ ? new Word(trimmed, run.writeOptions).dimensions.xMax
2154
+ : 0;
2155
+ }
2156
+ if (run.text) break;
2157
+ }
2158
+ }
2159
+
1824
2160
  /**
1825
2161
  * Mark the last run as ending its line: trim it, mark its last word and
1826
2162
  * optionally move the next line down.
1827
2163
  * @private
1828
2164
  * @param {Object[]} toWriteTextObjects - The runs; the last one is updated.
1829
2165
  * @param {number|null} [lines=null] - The line offset for the next line.
2166
+ * @param {boolean} [keepEnd=false] - Whether the run keeps the spaces that
2167
+ * end it, as clipped text does.
1830
2168
  * @returns {void}
1831
2169
  */
1832
- function markLineComplete(toWriteTextObjects, lines = null) {
2170
+ function markLineComplete(toWriteTextObjects, lines = null, keepEnd = false) {
1833
2171
  // Get last element in text objects and mark it.
1834
2172
  const textObj = toWriteTextObjects[toWriteTextObjects.length - 1];
1835
2173
  const lastWordIdx = textObj.wordsInLine.length - 1;
2174
+ const alreadyComplete = textObj.lineComplete;
1836
2175
 
1837
- textObj.wordsInLine[lastWordIdx].lastWord();
1838
- textObj.text = textObj.text.trim();
2176
+ // An empty flowed run has no words.
2177
+ textObj.wordsInLine[lastWordIdx]?.lastWord();
2178
+ textObj.text = textObj.text.trimStart();
2179
+ if (!keepEnd) textObj.text = trimBreakableEnd(textObj.text);
1839
2180
  textObj.lineComplete = true;
1840
2181
 
1841
2182
  if (lines) {
1842
- textObj.lineOffset = lines;
2183
+ // Ending a line that already ended adds blank lines, as one movedown()
2184
+ // with the summed count does.
2185
+ textObj.lineOffset = alreadyComplete
2186
+ ? (textObj.lineOffset || 1) + lines
2187
+ : lines;
1843
2188
  }
1844
2189
  }
1845
2190
 
2191
+ /**
2192
+ * Draw the open text flow, if any, as `text("", { flow: false })` would.
2193
+ * text() with coordinates, table(), and endPage() call it, so flowed text
2194
+ * that is never ended explicitly is still drawn.
2195
+ * @name _flushTextFlow
2196
+ * @function
2197
+ * @memberof Recipe#
2198
+ * @private
2199
+ * @returns {Recipe} The recipe instance.
2200
+ * @throws {Error} If an overflow callback names an undefined layout, or a font cannot be loaded.
2201
+ */
2202
+ exports._flushTextFlow = function _flushTextFlow() {
2203
+ if (this._flow && this.pageContext && this._previousTextObjects?.length) {
2204
+ this.text("", { flow: false });
2205
+ }
2206
+ return this;
2207
+ };
2208
+
1846
2209
  /** Move text positioning down N lines in text box
1847
2210
  * @name movedown
1848
2211
  * @function