text-to-canvas 1.2.1 → 3.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
@@ -2,8 +2,18 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
- The format is inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
- and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
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
+
7
+ ## v3.0.0
8
+
9
+ - __Breaking:__ The `RenderSpec.textBaseline` property type has changed from `CanvasTextBaseline` to `RenderTextBaseline` which is a narrowing of possible baselines used for actual rendering.
10
+ - Added new `TextFormat.underline` and `TextFormat.strikethrough` options ([#600](https://github.com/stefcameron/text-to-canvas/pull/600))
11
+ - Set to true to enable with all defaults, or specify an object with `{ color?, thickness?, offset? }` properties to have more customization.
12
+ - By default, the thickness and offset scale with the font size based on `24px` font size (which yields a `1px` thick line that is just beneath the text for underline and centered on the text for strikethrough). This simply seemed to be the best balance for the handful of fonts that were tested (all fonts currently available in the demo app). Tweak as needed for the font(s) you will use.
13
+
14
+ ## v2.0.0
15
+
16
+ - __Breaking:__ Updated minimum supported of Node to `>=22` and builds now target `ES2022` at minimum.
7
17
 
8
18
  ## v1.2.1
9
19
 
@@ -12,7 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
12
22
  ## v1.2.0
13
23
 
14
24
  - New `strokeColor` and `strokeWidth` text formatting options to control the outline of the text ([#292](https://github.com/stefcameron/text-to-canvas/issues/292)).
15
- - Note that due to how the `strokeText()` and `measureText()` Canvas APIs work, the stroke is __not considered__ in text placement. Setting a large width will result in the stroke "bleeding" outside the text box.
25
+ - Note that due to how the `strokeText()` and `measureText()` Canvas APIs work, the stroke is __not considered__ in text placement. Setting a large width will result in the stroke "bleeding" outside the text box.
16
26
 
17
27
  ## v1.1.2
18
28
 
package/README.md CHANGED
@@ -76,8 +76,8 @@ Use in Node is only supported to the extent that appropriate bundles are provide
76
76
 
77
77
  Two bundles are provided for this type of target:
78
78
 
79
- - `./dist/text-to-canvas.esm.min.js` (ESM, `import`, ES2020+)
80
- - `./dist/text-to-canvas.min.js` (CJS, `require()`, ES2019+)
79
+ - `./dist/text-to-canvas.esm.min.js` (ESM, `import`, ES2022+)
80
+ - `./dist/text-to-canvas.min.js` (CJS, `require()`, ES2022+)
81
81
 
82
82
  Used implicitly when using the library in a larger app bundled with a bundler like Webpack, Rollup, or Vite.
83
83
 
@@ -129,7 +129,7 @@ const { height } = drawText(...);
129
129
 
130
130
  One bundle is provided for this type of target:
131
131
 
132
- - `./dist/text-to-canvas.umd.min.js` (UMD, ES2019+)
132
+ - `./dist/text-to-canvas.umd.min.js` (UMD, ES2022+)
133
133
 
134
134
  Used implicitly when loading the library directly in a browser:
135
135
 
@@ -148,8 +148,8 @@ Used implicitly when loading the library directly in a browser:
148
148
 
149
149
  Two bundles are provided for this type of target:
150
150
 
151
- - `./dist/text-to-canvas.mjs` (ESM/MJS, `import`, Node v20.11.1+)
152
- - `./dist/text-to-canvas.cjs` (CJS, `require()`, Node v20.11.1+)
151
+ - `./dist/text-to-canvas.mjs` (ESM/MJS, `import`, Node v22+)
152
+ - `./dist/text-to-canvas.cjs` (CJS, `require()`, Node v22+)
153
153
 
154
154
  > ⚠️ Other than the bundles, __Node is not formally supported by this library__, and neither is the `node-canvas` library used in the demo. Whatever "Node Canvas" library you use, make sure it supports the [HTMLCanvasElement](https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement) API and it _should_ work.
155
155
 
@@ -188,6 +188,8 @@ You can run this demo locally with `npm run node:demo`
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
190
  | `justify` | `false` | Justify text if `true`, it will insert spaces between words when necessary. |
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
+ | `strikethrough` | `false` | If the text or word should have a strikethrough. Can also be an object with customization options like color, thickness, and offset. |
191
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. |
192
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. |
193
195
  | `debug` | `false` | Draws the border and alignment lines of the text box for debugging purposes. |
@@ -6,22 +6,120 @@ const DEFAULT_FONT_COLOR = "black";
6
6
  const DEFAULT_STROKE_COLOR = DEFAULT_FONT_COLOR;
7
7
  const DEFAULT_STROKE_WIDTH = 0;
8
8
  const DEFAULT_STROKE_JOIN = "round";
9
- const getTextFormat = (format, baseFormat) => {
10
- return Object.assign(
11
- {},
12
- {
13
- fontFamily: DEFAULT_FONT_FAMILY,
14
- fontSize: DEFAULT_FONT_SIZE,
15
- fontWeight: "400",
16
- fontStyle: "",
17
- fontVariant: "",
18
- fontColor: DEFAULT_FONT_COLOR,
19
- strokeColor: DEFAULT_STROKE_COLOR,
20
- strokeWidth: DEFAULT_STROKE_WIDTH
9
+ const _formatMerge = (...sources) => {
10
+ const target = {
11
+ fontFamily: DEFAULT_FONT_FAMILY,
12
+ fontSize: DEFAULT_FONT_SIZE,
13
+ fontWeight: "400",
14
+ fontStyle: "",
15
+ fontVariant: "",
16
+ fontColor: DEFAULT_FONT_COLOR,
17
+ strokeColor: DEFAULT_STROKE_COLOR,
18
+ strokeWidth: DEFAULT_STROKE_WIDTH,
19
+ underline: {
20
+ color: DEFAULT_FONT_COLOR,
21
+ thickness: 0,
22
+ offset: 0
21
23
  },
22
- baseFormat,
23
- format
24
- );
24
+ strikethrough: {
25
+ color: DEFAULT_FONT_COLOR,
26
+ thickness: 0,
27
+ offset: 0
28
+ }
29
+ };
30
+ sources.forEach((source) => {
31
+ if (source && typeof source === "object" && !Array.isArray(source)) {
32
+ Object.entries(source).forEach(([sourceKey, sourceValue]) => {
33
+ if (sourceValue !== void 0) {
34
+ if (sourceKey === "underline" || sourceKey === "strikethrough") {
35
+ if (typeof sourceValue === "boolean") {
36
+ if (sourceValue) {
37
+ target[sourceKey].color = "";
38
+ target[sourceKey].thickness = NaN;
39
+ target[sourceKey].offset = NaN;
40
+ }
41
+ } else if (sourceValue && typeof sourceValue === "object" && !Array.isArray(sourceValue)) {
42
+ const underStrikeSource = sourceValue;
43
+ const underStrikeSourceKeys = Object.keys(
44
+ underStrikeSource
45
+ );
46
+ underStrikeSourceKeys.forEach((k) => {
47
+ if (underStrikeSource[k] !== void 0) {
48
+ target[sourceKey][k] = underStrikeSource[k];
49
+ }
50
+ });
51
+ if (!underStrikeSourceKeys.includes("color")) {
52
+ target[sourceKey].color = "";
53
+ }
54
+ if (!underStrikeSourceKeys.includes("thickness")) {
55
+ target[sourceKey].thickness = NaN;
56
+ }
57
+ if (!underStrikeSourceKeys.includes("offset")) {
58
+ target[sourceKey].offset = NaN;
59
+ }
60
+ }
61
+ } else {
62
+ target[sourceKey] = sourceValue;
63
+ }
64
+ }
65
+ });
66
+ }
67
+ });
68
+ return target;
69
+ };
70
+ const getTextFormat = (format, baseFormat) => {
71
+ const underStrikeProps = [
72
+ "underline",
73
+ "strikethrough"
74
+ ];
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);
101
+ const fontSizeFactor = result.fontSize / 24;
102
+ underStrikeProps.forEach((prop) => {
103
+ if (result[prop].color === "") {
104
+ result[prop].color = result.fontColor;
105
+ }
106
+ if (Number.isNaN(result[prop].thickness)) {
107
+ result[prop].thickness = fontSizeFactor;
108
+ }
109
+ if (Number.isNaN(result[prop].offset)) {
110
+ if (result.fontFamily.startsWith("Roboto") || result.fontFamily.startsWith("Verdana")) {
111
+ result[prop].offset = -2;
112
+ } else if (result.fontFamily === "Inter" || result.fontFamily.startsWith("Montserrat")) {
113
+ result[prop].offset = -1;
114
+ } else if (result.fontFamily.startsWith("Comic Sans")) {
115
+ result[prop].offset = -5;
116
+ } else {
117
+ result[prop].offset = 0;
118
+ }
119
+ result[prop].offset *= fontSizeFactor;
120
+ }
121
+ });
122
+ return result;
25
123
  };
26
124
  const getTextStyle = ({
27
125
  fontFamily,
@@ -362,7 +460,7 @@ const splitWords = ({
362
460
  return {
363
461
  lines: [],
364
462
  textAlign: "center",
365
- textBaseline: "middle",
463
+ textBaseline: "top",
366
464
  width: positioning.width,
367
465
  height: 0
368
466
  };
@@ -472,6 +570,72 @@ const getTextHeight = ({
472
570
  }) => {
473
571
  return _getHeight(ctx, text, style);
474
572
  };
573
+ const _drawUnderline = ({
574
+ pw,
575
+ textBaseline,
576
+ baseFormat,
577
+ ctx
578
+ }) => {
579
+ const lineWidth = pw.format?.underline.thickness ?? baseFormat.underline.thickness;
580
+ if (lineWidth <= 0) {
581
+ return;
582
+ }
583
+ ctx.save();
584
+ ctx.lineWidth = lineWidth;
585
+ ctx.strokeStyle = pw.format?.underline.color ?? baseFormat.underline.color;
586
+ let underlineY = pw.y;
587
+ switch (textBaseline) {
588
+ case "top":
589
+ underlineY += pw.height - (pw.word.metrics?.fontBoundingBoxDescent ?? 0);
590
+ break;
591
+ case "bottom":
592
+ underlineY -= (pw.word.metrics?.fontBoundingBoxDescent ?? 0) / 2;
593
+ break;
594
+ default: {
595
+ const never = textBaseline;
596
+ return never;
597
+ }
598
+ }
599
+ underlineY += pw.format?.underline.offset ?? baseFormat.underline.offset;
600
+ ctx.beginPath();
601
+ ctx.moveTo(pw.x, underlineY);
602
+ ctx.lineTo(pw.x + pw.width, underlineY);
603
+ ctx.stroke();
604
+ ctx.restore();
605
+ };
606
+ const _drawStrikethrough = ({
607
+ pw,
608
+ textBaseline,
609
+ baseFormat,
610
+ ctx
611
+ }) => {
612
+ const lineWidth = pw.format?.strikethrough.thickness ?? baseFormat.strikethrough.thickness;
613
+ if (lineWidth <= 0) {
614
+ return;
615
+ }
616
+ ctx.save();
617
+ ctx.lineWidth = lineWidth;
618
+ ctx.strokeStyle = pw.format?.strikethrough.color ?? baseFormat.strikethrough.color;
619
+ let strikethroughY = pw.y;
620
+ switch (textBaseline) {
621
+ case "top":
622
+ strikethroughY += pw.height / 2;
623
+ break;
624
+ case "bottom":
625
+ strikethroughY -= pw.height / 2 - (pw.word.metrics?.fontBoundingBoxDescent ?? 0) / 3;
626
+ break;
627
+ default: {
628
+ const never = textBaseline;
629
+ return never;
630
+ }
631
+ }
632
+ strikethroughY += pw.format?.strikethrough.offset ?? baseFormat.strikethrough.offset;
633
+ ctx.beginPath();
634
+ ctx.moveTo(pw.x, strikethroughY);
635
+ ctx.lineTo(pw.x + pw.width, strikethroughY);
636
+ ctx.stroke();
637
+ ctx.restore();
638
+ };
475
639
  const drawText = (ctx, text, config) => {
476
640
  const baseFormat = getTextFormat({
477
641
  fontFamily: config.fontFamily,
@@ -481,7 +645,9 @@ const drawText = (ctx, text, config) => {
481
645
  fontWeight: config.fontWeight,
482
646
  fontColor: config.fontColor,
483
647
  strokeColor: config.strokeColor,
484
- strokeWidth: config.strokeWidth
648
+ strokeWidth: config.strokeWidth,
649
+ underline: config.underline,
650
+ strikethrough: config.strikethrough
485
651
  });
486
652
  const {
487
653
  width: boxWidth,
@@ -544,6 +710,8 @@ const drawText = (ctx, text, config) => {
544
710
  ctx.restore();
545
711
  }
546
712
  }
713
+ _drawUnderline({ pw, textBaseline, baseFormat, ctx });
714
+ _drawStrikethrough({ pw, textBaseline, baseFormat, ctx });
547
715
  });
548
716
  });
549
717
  if (config.debug) {