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 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`, it will insert spaces between words when necessary. |
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
@@ -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
- if (underStrikeSource[k] !== void 0) {
48
- target[sourceKey][k] = underStrikeSource[k];
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 fullBaseFormat = {
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(word.format) : ""}`;
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 _splitIntoLines = (words, inferWhitespace = true) => {
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.match(/^\n+$/)) {
244
- for (let i = 0; i < word.text.length; i++) {
245
- lines.push([]);
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({ text: SPACE });
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 = _splitIntoLines(
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;