text-to-canvas 3.0.0 → 4.0.0
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/CHANGELOG.md +13 -0
- package/README.md +22 -2
- package/dist/text-to-canvas.cjs +90 -46
- package/dist/text-to-canvas.esm.min.js +289 -259
- package/dist/text-to-canvas.esm.min.js.map +1 -1
- package/dist/text-to-canvas.min.js +3 -1
- package/dist/text-to-canvas.min.js.map +1 -1
- package/dist/text-to-canvas.mjs +90 -46
- package/dist/text-to-canvas.umd.min.js +3 -1
- package/dist/text-to-canvas.umd.min.js.map +1 -1
- package/dist/types/index.d.ts +9 -4
- package/dist/types/model.d.ts +60 -6
- package/dist/types/util/split.d.ts +11 -4
- package/dist/types/util/whitespace.d.ts +19 -1
- package/package.json +21 -21
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,19 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
The format is inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## v4.0.0
|
|
8
|
+
|
|
9
|
+
- __Breaking:__ Support for [line breaks](./README.md#line-breaks). Breaking because of Words containing characters and one or more line breaks being treated as a single line break (any additional characters ignored).
|
|
10
|
+
- Also fixes a bug where line breaks were treated as space characters instead of breaking text to new lines.
|
|
11
|
+
- When inferring whitespace (`inferWhitespace` option of `splitWords()`), generated space characters now inherit the format of the previous word.
|
|
12
|
+
- New `DrawTextResults` exported type for the object returned by `drawText()` instead of relying on an inferred type which could too easily change without being noticed.
|
|
13
|
+
- New `DrawTextConfig.textWrap: TextWrap` config option to control whether [text wraps](./README.md#text-wrapping) at the horizontal boundaries of the render box.
|
|
14
|
+
- Note that "clipping", spreadsheet-style, is a combination of `textWrap='none'` and `overflow=false` options.
|
|
15
|
+
|
|
16
|
+
## v3.0.1
|
|
17
|
+
|
|
18
|
+
- Fixed a bug where specifying any allowed property of `TextFormat.underline` or `TextFormat.strikethrough` as `undefined` was not resulting in the default behavior. `undefined` is treated as equivalent to the absence of the property.
|
|
19
|
+
|
|
7
20
|
## v3.0.0
|
|
8
21
|
|
|
9
22
|
- __Breaking:__ The `RenderSpec.textBaseline` property type has changed from `CanvasTextBaseline` to `RenderTextBaseline` which is a narrowing of possible baselines used for actual rendering.
|
package/README.md
CHANGED
|
@@ -187,11 +187,12 @@ You can run this demo locally with `npm run node:demo`
|
|
|
187
187
|
| `fontColor` | `'black'` | Base font color, same as css color. Examples: `blue`, `#00ff00`. |
|
|
188
188
|
| `strokeColor` | `'black'` | Base stroke color, same as css color. Examples: `blue`, `#00ff00`. |
|
|
189
189
|
| `strokeWidth` | `0` | Base stroke width. Positive number; `<=0` means none. Can be fractional. ⚠️ Word splitting does not take into account the stroke, which is applied on the __center__ of the edges of the text via the [strokeText()](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/strokeText) Canvas API. Setting a thick stroke will cause it to bleed out of the text box. |
|
|
190
|
-
| `justify` | `false` | Justify text if `true
|
|
190
|
+
| `justify` | `false` | Justify text if `true`. It will insert spaces between words when necessary. __Ignored__ if `textWrap != 'wrap'` |
|
|
191
191
|
| `underline` | `false` | If the text or word should be underlined. Can also be an object with customization options like color, thickness, and offset. |
|
|
192
192
|
| `strikethrough` | `false` | If the text or word should have a strikethrough. Can also be an object with customization options like color, thickness, and offset. |
|
|
193
193
|
| `inferWhitespace` | `true` | If whitespace in the text should be inferred. Only applies if the text given to `drawText()` is a `Word[]`. If the text is a `string`, this config setting is ignored. |
|
|
194
|
-
| `overflow` | `true` | Allows the text to overflow out of the box if the box is too narrow/short to fit it all. `false` will clip the text to the box's boundaries. |
|
|
194
|
+
| `overflow` | `true` | Allows the text to overflow out of the box if the box is too narrow/short to fit it all. `false` will clip the text to the box's boundaries. Use in conjunction with `textWrap='none'` to achieve a typical spreadsheet clipping effect. |
|
|
195
|
+
| `textWrap` | `'wrap'` | Whether the text should wrap at supported newline characters (LF, LS, or PS) as well as at the box's horizontal boundaries in order to keep as much of the text visible, or extend beyond the horizontal boundaries of the box even if it doesn't fit. This is __separate__ from whether the text extending beyond the box's horizontal boundaries is visible (i.e. "clipped"). Use the `overflow` option to control clipping (e.g. `clip = textWrap='none' + overflow=false`. Other values: `'none'` (no wrapping other than at hard breaks using `\n` characters). |
|
|
195
196
|
| `debug` | `false` | Draws the border and alignment lines of the text box for debugging purposes. |
|
|
196
197
|
|
|
197
198
|
## Functions
|
|
@@ -227,6 +228,25 @@ import {
|
|
|
227
228
|
|
|
228
229
|
TypeScript integration should provide helpful JSDocs for every function and each of its parameters to further help with their use.
|
|
229
230
|
|
|
231
|
+
## Line Breaks
|
|
232
|
+
|
|
233
|
+
Newline characters (i.e. hard breaks) are supported in text and Words, but only the following characters are considered line breaks:
|
|
234
|
+
|
|
235
|
+
- Line Feed (LF): `\n`
|
|
236
|
+
- Line Separator (LS): `\u2028`
|
|
237
|
+
- Paragraph Separator (PS): `\u2029`
|
|
238
|
+
|
|
239
|
+
> 🔺 Any `Word` that has at least one line break character in it will be treated as a __single__ line break regardless of any other characters it contains, even if they are additional line breaks. If you generate your own `Word` array to provide to `splitWords()` or `drawText()`, make sure you separate all line breaks into separate words.
|
|
240
|
+
|
|
241
|
+
## Text Wrapping
|
|
242
|
+
|
|
243
|
+
Text wrapping is supported via the `DrawTextConfig.textWrap` config option. Two modes are supported:
|
|
244
|
+
|
|
245
|
+
- `'wrap'`: (Default) Text wraps at the horizontal boundaries of the render box.
|
|
246
|
+
- `'none'`: Text does not wrap and will either overflow past the horizontal boundaries of the render box, or if `overflow=false`, get __clipped__ at the boundaries.
|
|
247
|
+
|
|
248
|
+
> 💡 To achieve spreadsheet-style clipping, use `textWrap='none'` and `overflow=false`.
|
|
249
|
+
|
|
230
250
|
# Examples
|
|
231
251
|
|
|
232
252
|
## Web Worker and OffscreenCanvas
|
package/dist/text-to-canvas.cjs
CHANGED
|
@@ -27,6 +27,18 @@ const _formatMerge = (...sources) => {
|
|
|
27
27
|
offset: 0
|
|
28
28
|
}
|
|
29
29
|
};
|
|
30
|
+
const targetSpecified = {
|
|
31
|
+
underline: {
|
|
32
|
+
color: false,
|
|
33
|
+
offset: false,
|
|
34
|
+
thickness: false
|
|
35
|
+
},
|
|
36
|
+
strikethrough: {
|
|
37
|
+
color: false,
|
|
38
|
+
offset: false,
|
|
39
|
+
thickness: false
|
|
40
|
+
}
|
|
41
|
+
};
|
|
30
42
|
sources.forEach((source) => {
|
|
31
43
|
if (source && typeof source === "object" && !Array.isArray(source)) {
|
|
32
44
|
Object.entries(source).forEach(([sourceKey, sourceValue]) => {
|
|
@@ -37,25 +49,33 @@ const _formatMerge = (...sources) => {
|
|
|
37
49
|
target[sourceKey].color = "";
|
|
38
50
|
target[sourceKey].thickness = NaN;
|
|
39
51
|
target[sourceKey].offset = NaN;
|
|
52
|
+
targetSpecified[sourceKey].color = true;
|
|
53
|
+
targetSpecified[sourceKey].thickness = true;
|
|
54
|
+
targetSpecified[sourceKey].offset = true;
|
|
55
|
+
} else {
|
|
56
|
+
target[sourceKey].thickness = 0;
|
|
57
|
+
targetSpecified[sourceKey].thickness = true;
|
|
40
58
|
}
|
|
41
59
|
} else if (sourceValue && typeof sourceValue === "object" && !Array.isArray(sourceValue)) {
|
|
42
60
|
const underStrikeSource = sourceValue;
|
|
43
61
|
const underStrikeSourceKeys = Object.keys(
|
|
44
62
|
underStrikeSource
|
|
45
|
-
);
|
|
63
|
+
).filter((k) => underStrikeSource[k] !== void 0);
|
|
46
64
|
underStrikeSourceKeys.forEach((k) => {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
}
|
|
65
|
+
target[sourceKey][k] = underStrikeSource[k];
|
|
66
|
+
targetSpecified[sourceKey][k] = true;
|
|
50
67
|
});
|
|
51
|
-
if (!underStrikeSourceKeys.includes("color")) {
|
|
68
|
+
if (!underStrikeSourceKeys.includes("color") && !targetSpecified[sourceKey].color) {
|
|
52
69
|
target[sourceKey].color = "";
|
|
70
|
+
targetSpecified[sourceKey].color = true;
|
|
53
71
|
}
|
|
54
|
-
if (!underStrikeSourceKeys.includes("thickness")) {
|
|
72
|
+
if (!underStrikeSourceKeys.includes("thickness") && !targetSpecified[sourceKey].thickness) {
|
|
55
73
|
target[sourceKey].thickness = NaN;
|
|
74
|
+
targetSpecified[sourceKey].thickness = true;
|
|
56
75
|
}
|
|
57
|
-
if (!underStrikeSourceKeys.includes("offset")) {
|
|
76
|
+
if (!underStrikeSourceKeys.includes("offset") && !targetSpecified[sourceKey].offset) {
|
|
58
77
|
target[sourceKey].offset = NaN;
|
|
78
|
+
targetSpecified[sourceKey].offset = true;
|
|
59
79
|
}
|
|
60
80
|
}
|
|
61
81
|
} else {
|
|
@@ -72,32 +92,7 @@ const getTextFormat = (format, baseFormat) => {
|
|
|
72
92
|
"underline",
|
|
73
93
|
"strikethrough"
|
|
74
94
|
];
|
|
75
|
-
const
|
|
76
|
-
...baseFormat
|
|
77
|
-
};
|
|
78
|
-
const fullFormat = {
|
|
79
|
-
...format
|
|
80
|
-
};
|
|
81
|
-
underStrikeProps.forEach((prop) => {
|
|
82
|
-
[fullBaseFormat, fullFormat].forEach((obj) => {
|
|
83
|
-
if (obj[prop] !== void 0) {
|
|
84
|
-
if (obj[prop] === true) {
|
|
85
|
-
obj[prop] = {
|
|
86
|
-
thickness: NaN
|
|
87
|
-
};
|
|
88
|
-
} else if (obj[prop] === false) {
|
|
89
|
-
obj[prop] = {
|
|
90
|
-
thickness: 0
|
|
91
|
-
};
|
|
92
|
-
} else {
|
|
93
|
-
obj[prop] = {
|
|
94
|
-
...obj[prop]
|
|
95
|
-
};
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
});
|
|
99
|
-
});
|
|
100
|
-
const result = _formatMerge(fullBaseFormat, fullFormat);
|
|
95
|
+
const result = _formatMerge(baseFormat, format);
|
|
101
96
|
const fontSizeFactor = result.fontSize / 24;
|
|
102
97
|
underStrikeProps.forEach((prop) => {
|
|
103
98
|
if (result[prop].color === "") {
|
|
@@ -130,6 +125,21 @@ const getTextStyle = ({
|
|
|
130
125
|
}) => {
|
|
131
126
|
return `${fontStyle || ""} ${fontVariant || ""} ${fontWeight || ""} ${fontSize ?? DEFAULT_FONT_SIZE}px ${fontFamily || DEFAULT_FONT_FAMILY}`.trim();
|
|
132
127
|
};
|
|
128
|
+
const _lineBreaks = [
|
|
129
|
+
"\n",
|
|
130
|
+
// LF (line feed)
|
|
131
|
+
"\u2028",
|
|
132
|
+
// LS (line separator)
|
|
133
|
+
"\u2029"
|
|
134
|
+
// PS (paragraph separator)
|
|
135
|
+
];
|
|
136
|
+
const _lineBreakRE = new RegExp(`[${_lineBreaks.join("")}]`);
|
|
137
|
+
const hasLineBreak = (text) => {
|
|
138
|
+
return _lineBreakRE.test(text);
|
|
139
|
+
};
|
|
140
|
+
const isLineBreak = (char) => {
|
|
141
|
+
return char.length === 1 && hasLineBreak(char);
|
|
142
|
+
};
|
|
133
143
|
const isWhitespace = (text) => {
|
|
134
144
|
return !!text.match(/^\s+$/);
|
|
135
145
|
};
|
|
@@ -234,15 +244,38 @@ const HAIR = " ";
|
|
|
234
244
|
const SPACE = " ";
|
|
235
245
|
let fontBoundingBoxSupported;
|
|
236
246
|
const _getWordHash = (word) => {
|
|
237
|
-
return `${word.text}${word.format ? JSON.stringify(
|
|
247
|
+
return `${word.text}${word.format ? JSON.stringify(
|
|
248
|
+
Object.entries(word.format).map(([name, propValue]) => {
|
|
249
|
+
let value = propValue;
|
|
250
|
+
if ((name === "underline" || name === "strikethrough") && typeof propValue === "object") {
|
|
251
|
+
value = Object.entries(propValue);
|
|
252
|
+
}
|
|
253
|
+
return [name, value];
|
|
254
|
+
})
|
|
255
|
+
) : ""}`;
|
|
238
256
|
};
|
|
239
|
-
const
|
|
257
|
+
const _splitIntoHardLines = (words, inferWhitespace = true) => {
|
|
240
258
|
const lines = [[]];
|
|
259
|
+
let prevLine;
|
|
260
|
+
let beforePrevLine;
|
|
241
261
|
let wasWhitespace = false;
|
|
242
262
|
words.forEach((word, wordIdx) => {
|
|
243
|
-
if (word.text
|
|
244
|
-
|
|
245
|
-
|
|
263
|
+
if (hasLineBreak(word.text)) {
|
|
264
|
+
lines.push([]);
|
|
265
|
+
prevLine = lines.at(-2);
|
|
266
|
+
beforePrevLine = lines.at(-3);
|
|
267
|
+
if (prevLine && prevLine.length < 1) {
|
|
268
|
+
const { format } = beforePrevLine?.at(-1) || {};
|
|
269
|
+
prevLine.push({
|
|
270
|
+
text: HAIR,
|
|
271
|
+
format: {
|
|
272
|
+
...format,
|
|
273
|
+
// remove any underline/strikethrough so it doesn't render on a HAIR which is
|
|
274
|
+
// technically not whitespace which would otherwise be skipped
|
|
275
|
+
underline: false,
|
|
276
|
+
strikethrough: false
|
|
277
|
+
}
|
|
278
|
+
});
|
|
246
279
|
}
|
|
247
280
|
wasWhitespace = true;
|
|
248
281
|
return;
|
|
@@ -256,7 +289,13 @@ const _splitIntoLines = (words, inferWhitespace = true) => {
|
|
|
256
289
|
return;
|
|
257
290
|
}
|
|
258
291
|
if (inferWhitespace && !wasWhitespace && wordIdx > 0) {
|
|
259
|
-
lines.at(-1)?.push({
|
|
292
|
+
lines.at(-1)?.push({
|
|
293
|
+
text: SPACE,
|
|
294
|
+
// inherit format of previous Word on on current line, if it has a format
|
|
295
|
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment -- because of TSC ridiculousness
|
|
296
|
+
// @ts-ignore -- ridiculous TSC won't recognize Array.at() is a method
|
|
297
|
+
format: lines.at(-1)?.at(-1)?.format
|
|
298
|
+
});
|
|
260
299
|
}
|
|
261
300
|
lines.at(-1)?.push(word);
|
|
262
301
|
wasWhitespace = false;
|
|
@@ -427,6 +466,7 @@ const splitWords = ({
|
|
|
427
466
|
justify,
|
|
428
467
|
format: baseFormat,
|
|
429
468
|
inferWhitespace = true,
|
|
469
|
+
textWrap = "wrap",
|
|
430
470
|
...positioning
|
|
431
471
|
// rest of params are related to positioning
|
|
432
472
|
}) => {
|
|
@@ -438,7 +478,7 @@ const splitWords = ({
|
|
|
438
478
|
let splitPoint = 0;
|
|
439
479
|
lineWords.every((word, idx) => {
|
|
440
480
|
const wordWidth = _measureWord({ ctx, word, wordMap, baseTextFormat });
|
|
441
|
-
if (!force && lineWidth + wordWidth > boxWidth) {
|
|
481
|
+
if (!force && textWrap !== "none" && lineWidth + wordWidth > boxWidth) {
|
|
442
482
|
if (idx === 0) {
|
|
443
483
|
splitPoint = 1;
|
|
444
484
|
lineWidth = wordWidth;
|
|
@@ -452,7 +492,7 @@ const splitWords = ({
|
|
|
452
492
|
return { lineWidth, splitPoint };
|
|
453
493
|
};
|
|
454
494
|
ctx.save();
|
|
455
|
-
const hardLines =
|
|
495
|
+
const hardLines = _splitIntoHardLines(
|
|
456
496
|
trimLine(words).trimmedLine,
|
|
457
497
|
inferWhitespace
|
|
458
498
|
);
|
|
@@ -470,7 +510,7 @@ const splitWords = ({
|
|
|
470
510
|
const wrappedLines = [];
|
|
471
511
|
for (const hardLine of hardLines) {
|
|
472
512
|
let { splitPoint } = measureLine(hardLine);
|
|
473
|
-
if (splitPoint >= hardLine.length) {
|
|
513
|
+
if (textWrap !== "wrap" || splitPoint >= hardLine.length) {
|
|
474
514
|
wrappedLines.push(hardLine);
|
|
475
515
|
} else {
|
|
476
516
|
let softLine = hardLine.concat();
|
|
@@ -486,7 +526,7 @@ const splitWords = ({
|
|
|
486
526
|
wrappedLines.push(softLine);
|
|
487
527
|
}
|
|
488
528
|
}
|
|
489
|
-
if (justify && wrappedLines.length > 1) {
|
|
529
|
+
if (textWrap === "wrap" && justify && wrappedLines.length > 1) {
|
|
490
530
|
wrappedLines.forEach((wrappedLine, idx) => {
|
|
491
531
|
if (idx < wrappedLines.length - 1) {
|
|
492
532
|
const justifiedLine = justifyLine({
|
|
@@ -513,13 +553,16 @@ const textToWords = (text) => {
|
|
|
513
553
|
let word = void 0;
|
|
514
554
|
let wasWhitespace = false;
|
|
515
555
|
Array.from(text.trim()).forEach((c) => {
|
|
556
|
+
if (c === "\r") {
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
516
559
|
const charIsWhitespace = isWhitespace(c);
|
|
517
|
-
if (charIsWhitespace && !wasWhitespace || !charIsWhitespace && wasWhitespace) {
|
|
560
|
+
if (charIsWhitespace && !wasWhitespace || !charIsWhitespace && wasWhitespace || charIsWhitespace && wasWhitespace) {
|
|
518
561
|
wasWhitespace = charIsWhitespace;
|
|
519
562
|
if (word) {
|
|
520
563
|
words.push(word);
|
|
521
564
|
}
|
|
522
|
-
word = { text: c };
|
|
565
|
+
word = { text: isLineBreak(c) ? "\n" : c };
|
|
523
566
|
} else {
|
|
524
567
|
if (!word) {
|
|
525
568
|
word = { text: "" };
|
|
@@ -672,7 +715,8 @@ const drawText = (ctx, text, config) => {
|
|
|
672
715
|
align: config.align,
|
|
673
716
|
vAlign: config.vAlign,
|
|
674
717
|
justify: config.justify,
|
|
675
|
-
format: baseFormat
|
|
718
|
+
format: baseFormat,
|
|
719
|
+
textWrap: config.textWrap
|
|
676
720
|
});
|
|
677
721
|
ctx.save();
|
|
678
722
|
ctx.textAlign = textAlign;
|