@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.
- package/index.js +230 -32
- package/lib/Recipe.js +30 -14
- package/lib/recipe/appendPage.js +1 -1
- package/lib/recipe/colors.js +1 -2
- package/lib/recipe/encrypt.js +1 -2
- package/lib/recipe/graphics-state.js +2 -1
- package/lib/recipe/image.js +9 -6
- package/lib/recipe/info.js +17 -10
- package/lib/recipe/insertPage.js +1 -2
- package/lib/recipe/page.js +54 -11
- package/lib/recipe/shapes.js +3 -3
- package/lib/recipe/split.js +1 -2
- package/lib/recipe/table.js +2 -0
- package/lib/recipe/text.helper.js +6 -4
- package/lib/recipe/text.js +403 -40
- package/lib/recipe/utils.js +12 -0
- package/lib/recipe/vector-line.js +1 -2
- package/lib/recipe/vector-polygon.js +1 -1
- package/lib/recipe/vector.js +7 -8
- package/lib/recipe-constants.js +1 -1
- package/licenses/AdobeGlyphList.txt +29 -0
- package/muhammara.d.ts +59 -9
- package/package.json +2 -1
package/lib/recipe/text.js
CHANGED
|
@@ -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
|
|
7
|
-
const {
|
|
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] -
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 = [
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
1838
|
-
textObj.
|
|
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
|
-
|
|
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
|