text-to-canvas 1.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.
@@ -0,0 +1,569 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
3
+ const DEFAULT_FONT_FAMILY = "Arial";
4
+ const DEFAULT_FONT_SIZE = 14;
5
+ const DEFAULT_FONT_COLOR = "black";
6
+ const getTextFormat = (format, baseFormat) => {
7
+ return Object.assign(
8
+ {},
9
+ {
10
+ fontFamily: DEFAULT_FONT_FAMILY,
11
+ fontSize: DEFAULT_FONT_SIZE,
12
+ fontWeight: "400",
13
+ fontStyle: "",
14
+ fontVariant: "",
15
+ fontColor: DEFAULT_FONT_COLOR
16
+ },
17
+ baseFormat,
18
+ format
19
+ );
20
+ };
21
+ const getTextStyle = ({
22
+ fontFamily,
23
+ fontSize,
24
+ fontStyle,
25
+ fontVariant,
26
+ fontWeight
27
+ }) => {
28
+ return `${fontStyle || ""} ${fontVariant || ""} ${fontWeight || ""} ${fontSize ?? DEFAULT_FONT_SIZE}px ${fontFamily || DEFAULT_FONT_FAMILY}`.trim();
29
+ };
30
+ const isWhitespace = (text) => {
31
+ return !!text.match(/^\s+$/);
32
+ };
33
+ const _extractWords = (line) => {
34
+ return line.filter((word) => !isWhitespace(word.text));
35
+ };
36
+ const _cloneWord = (word) => {
37
+ const clone = { ...word };
38
+ if (word.format) {
39
+ clone.format = { ...word.format };
40
+ }
41
+ return clone;
42
+ };
43
+ const _joinWords = (words, joiner) => {
44
+ if (words.length <= 1 || joiner.length < 1) {
45
+ return [...words];
46
+ }
47
+ const phrase = [];
48
+ words.forEach((word, wordIdx) => {
49
+ phrase.push(word);
50
+ if (wordIdx < words.length - 1) {
51
+ joiner.forEach((jw) => phrase.push(_cloneWord(jw)));
52
+ }
53
+ });
54
+ return phrase;
55
+ };
56
+ const justifyLine = ({
57
+ line,
58
+ spaceWidth,
59
+ spaceChar,
60
+ boxWidth
61
+ }) => {
62
+ const words = _extractWords(line);
63
+ if (words.length <= 1) {
64
+ return line.concat();
65
+ }
66
+ const wordsWidth = words.reduce(
67
+ (width, word) => width + (word.metrics?.width ?? 0),
68
+ 0
69
+ );
70
+ const noOfSpacesToInsert = (boxWidth - wordsWidth) / spaceWidth;
71
+ if (words.length > 2) {
72
+ const spacesPerWord = Math.ceil(noOfSpacesToInsert / (words.length - 1));
73
+ const spaces2 = Array.from({ length: spacesPerWord }, () => ({
74
+ text: spaceChar
75
+ }));
76
+ const firstWords = words.slice(0, words.length - 1);
77
+ const firstPart = _joinWords(firstWords, spaces2);
78
+ const remainingSpaces = spaces2.slice(
79
+ 0,
80
+ Math.floor(noOfSpacesToInsert) - (firstWords.length - 1) * spaces2.length
81
+ );
82
+ const lastWord = words[words.length - 1];
83
+ return [...firstPart, ...remainingSpaces, lastWord];
84
+ }
85
+ const spaces = Array.from(
86
+ { length: Math.floor(noOfSpacesToInsert) },
87
+ () => ({ text: spaceChar })
88
+ );
89
+ return _joinWords(words, spaces);
90
+ };
91
+ const trimLine = (line, side = "both") => {
92
+ let leftTrim = 0;
93
+ if (side === "left" || side === "both") {
94
+ for (; leftTrim < line.length; leftTrim++) {
95
+ if (!isWhitespace(line[leftTrim].text)) {
96
+ break;
97
+ }
98
+ }
99
+ if (leftTrim >= line.length) {
100
+ return {
101
+ trimmedLeft: line.concat(),
102
+ trimmedRight: [],
103
+ trimmedLine: []
104
+ };
105
+ }
106
+ }
107
+ let rightTrim = line.length;
108
+ if (side === "right" || side === "both") {
109
+ rightTrim--;
110
+ for (; rightTrim >= 0; rightTrim--) {
111
+ if (!isWhitespace(line[rightTrim].text)) {
112
+ break;
113
+ }
114
+ }
115
+ rightTrim++;
116
+ if (rightTrim <= 0) {
117
+ return {
118
+ trimmedLeft: [],
119
+ trimmedRight: line.concat(),
120
+ trimmedLine: []
121
+ };
122
+ }
123
+ }
124
+ return {
125
+ trimmedLeft: line.slice(0, leftTrim),
126
+ trimmedRight: line.slice(rightTrim),
127
+ trimmedLine: line.slice(leftTrim, rightTrim)
128
+ };
129
+ };
130
+ const HAIR = " ";
131
+ const SPACE = " ";
132
+ let fontBoundingBoxSupported;
133
+ const _getWordHash = (word) => {
134
+ return `${word.text}${word.format ? JSON.stringify(word.format) : ""}`;
135
+ };
136
+ const _splitIntoLines = (words, inferWhitespace = true) => {
137
+ const lines = [[]];
138
+ let wasWhitespace = false;
139
+ words.forEach((word, wordIdx) => {
140
+ if (word.text.match(/^\n+$/)) {
141
+ for (let i = 0; i < word.text.length; i++) {
142
+ lines.push([]);
143
+ }
144
+ wasWhitespace = true;
145
+ return;
146
+ }
147
+ if (isWhitespace(word.text)) {
148
+ lines.at(-1)?.push(word);
149
+ wasWhitespace = true;
150
+ return;
151
+ }
152
+ if (word.text === "") {
153
+ return;
154
+ }
155
+ if (inferWhitespace && !wasWhitespace && wordIdx > 0) {
156
+ lines.at(-1)?.push({ text: SPACE });
157
+ }
158
+ lines.at(-1)?.push(word);
159
+ wasWhitespace = false;
160
+ });
161
+ return lines;
162
+ };
163
+ const _generateSpec = ({
164
+ wrappedLines,
165
+ wordMap,
166
+ positioning: {
167
+ width: boxWidth,
168
+ height: boxHeight,
169
+ x: boxX = 0,
170
+ y: boxY = 0,
171
+ align,
172
+ vAlign
173
+ }
174
+ }) => {
175
+ const xEnd = boxX + boxWidth;
176
+ const yEnd = boxY + boxHeight;
177
+ const getHeight = (word) => (
178
+ // NOTE: `metrics` must exist as every `word` MUST have been measured at this point
179
+ word.metrics.fontBoundingBoxAscent + word.metrics.fontBoundingBoxDescent
180
+ );
181
+ const lineHeights = wrappedLines.map(
182
+ (line) => line.reduce((acc, word) => {
183
+ return Math.max(acc, getHeight(word));
184
+ }, 0)
185
+ );
186
+ const totalHeight = lineHeights.reduce((acc, h) => acc + h, 0);
187
+ let lineY;
188
+ let textBaseline;
189
+ if (vAlign === "top") {
190
+ textBaseline = "top";
191
+ lineY = boxY;
192
+ } else if (vAlign === "bottom") {
193
+ textBaseline = "bottom";
194
+ lineY = yEnd - totalHeight;
195
+ } else {
196
+ textBaseline = "top";
197
+ lineY = boxY + boxHeight / 2 - totalHeight / 2;
198
+ }
199
+ const lines = wrappedLines.map((line, lineIdx) => {
200
+ const lineWidth = line.reduce(
201
+ // NOTE: `metrics` must exist as every `word` MUST have been measured at this point
202
+ (acc, word) => acc + word.metrics.width,
203
+ 0
204
+ );
205
+ const lineHeight = lineHeights[lineIdx];
206
+ let lineX;
207
+ if (align === "right") {
208
+ lineX = xEnd - lineWidth;
209
+ } else if (align === "left") {
210
+ lineX = boxX;
211
+ } else {
212
+ lineX = boxX + boxWidth / 2 - lineWidth / 2;
213
+ }
214
+ let wordX = lineX;
215
+ const posWords = line.map((word) => {
216
+ const hash = _getWordHash(word);
217
+ const { format } = wordMap.get(hash);
218
+ const x = wordX;
219
+ const height = getHeight(word);
220
+ let y;
221
+ if (vAlign === "top") {
222
+ y = lineY;
223
+ } else if (vAlign === "bottom") {
224
+ y = lineY + lineHeight;
225
+ } else {
226
+ y = lineY + (lineHeight - height) / 2;
227
+ }
228
+ wordX += word.metrics.width;
229
+ return {
230
+ word,
231
+ format,
232
+ // undefined IF base formatting should be used when rendering (i.e. `word.format` is undefined)
233
+ x,
234
+ y,
235
+ width: word.metrics.width,
236
+ height,
237
+ isWhitespace: isWhitespace(word.text)
238
+ };
239
+ });
240
+ lineY += lineHeight;
241
+ return posWords;
242
+ });
243
+ return {
244
+ lines,
245
+ textBaseline,
246
+ textAlign: "left",
247
+ // always per current algorithm
248
+ width: boxWidth,
249
+ height: totalHeight
250
+ };
251
+ };
252
+ const _jsonReplacer = function(key, value) {
253
+ if (key === "metrics" && value && typeof value === "object") {
254
+ const metrics = value;
255
+ return {
256
+ width: metrics.width,
257
+ fontBoundingBoxAscent: metrics.fontBoundingBoxAscent,
258
+ fontBoundingBoxDescent: metrics.fontBoundingBoxDescent
259
+ };
260
+ }
261
+ return value;
262
+ };
263
+ const specToJson = (specs) => {
264
+ return JSON.stringify(specs, _jsonReplacer);
265
+ };
266
+ const wordsToJson = (words) => {
267
+ return JSON.stringify(words, _jsonReplacer);
268
+ };
269
+ const _measureWord = ({
270
+ ctx,
271
+ word,
272
+ wordMap,
273
+ baseTextFormat
274
+ }) => {
275
+ const hash = _getWordHash(word);
276
+ if (word.metrics) {
277
+ if (!wordMap.has(hash)) {
278
+ let format2 = void 0;
279
+ if (word.format) {
280
+ format2 = getTextFormat(word.format, baseTextFormat);
281
+ }
282
+ wordMap.set(hash, { metrics: word.metrics, format: format2 });
283
+ }
284
+ return word.metrics.width;
285
+ }
286
+ if (wordMap.has(hash)) {
287
+ const { metrics: metrics2 } = wordMap.get(hash);
288
+ word.metrics = metrics2;
289
+ return metrics2.width;
290
+ }
291
+ let ctxSaved = false;
292
+ let format = void 0;
293
+ if (word.format) {
294
+ ctx.save();
295
+ ctxSaved = true;
296
+ format = getTextFormat(word.format, baseTextFormat);
297
+ ctx.font = getTextStyle(format);
298
+ }
299
+ if (!fontBoundingBoxSupported) {
300
+ if (!ctxSaved) {
301
+ ctx.save();
302
+ ctxSaved = true;
303
+ }
304
+ ctx.textBaseline = "bottom";
305
+ }
306
+ const metrics = ctx.measureText(word.text);
307
+ if (typeof metrics.fontBoundingBoxAscent === "number") {
308
+ fontBoundingBoxSupported = true;
309
+ } else {
310
+ fontBoundingBoxSupported = false;
311
+ metrics.fontBoundingBoxAscent = metrics.actualBoundingBoxAscent;
312
+ metrics.fontBoundingBoxDescent = 0;
313
+ }
314
+ word.metrics = metrics;
315
+ wordMap.set(hash, { metrics, format });
316
+ if (ctxSaved) {
317
+ ctx.restore();
318
+ }
319
+ return metrics.width;
320
+ };
321
+ const splitWords = ({
322
+ ctx,
323
+ words,
324
+ justify,
325
+ format: baseFormat,
326
+ inferWhitespace = true,
327
+ ...positioning
328
+ // rest of params are related to positioning
329
+ }) => {
330
+ const wordMap = /* @__PURE__ */ new Map();
331
+ const baseTextFormat = getTextFormat(baseFormat);
332
+ const { width: boxWidth } = positioning;
333
+ const measureLine = (lineWords, force = false) => {
334
+ let lineWidth = 0;
335
+ let splitPoint = 0;
336
+ lineWords.every((word, idx) => {
337
+ const wordWidth = _measureWord({ ctx, word, wordMap, baseTextFormat });
338
+ if (!force && lineWidth + wordWidth > boxWidth) {
339
+ if (idx === 0) {
340
+ splitPoint = 1;
341
+ lineWidth = wordWidth;
342
+ }
343
+ return false;
344
+ }
345
+ splitPoint++;
346
+ lineWidth += wordWidth;
347
+ return true;
348
+ });
349
+ return { lineWidth, splitPoint };
350
+ };
351
+ ctx.save();
352
+ const hardLines = _splitIntoLines(
353
+ trimLine(words).trimmedLine,
354
+ inferWhitespace
355
+ );
356
+ if (hardLines.length <= 0 || boxWidth <= 0 || positioning.height <= 0 || baseFormat && typeof baseFormat.fontSize === "number" && baseFormat.fontSize <= 0) {
357
+ return {
358
+ lines: [],
359
+ textAlign: "center",
360
+ textBaseline: "middle",
361
+ width: positioning.width,
362
+ height: 0
363
+ };
364
+ }
365
+ ctx.font = getTextStyle(baseTextFormat);
366
+ const hairWidth = justify ? _measureWord({ ctx, word: { text: HAIR }, wordMap, baseTextFormat }) : 0;
367
+ const wrappedLines = [];
368
+ for (const hardLine of hardLines) {
369
+ let { splitPoint } = measureLine(hardLine);
370
+ if (splitPoint >= hardLine.length) {
371
+ wrappedLines.push(hardLine);
372
+ } else {
373
+ let softLine = hardLine.concat();
374
+ while (splitPoint < softLine.length) {
375
+ const splitLine = trimLine(
376
+ softLine.slice(0, splitPoint),
377
+ "right"
378
+ ).trimmedLine;
379
+ wrappedLines.push(splitLine);
380
+ softLine = trimLine(softLine.slice(splitPoint), "left").trimmedLine;
381
+ ({ splitPoint } = measureLine(softLine));
382
+ }
383
+ wrappedLines.push(softLine);
384
+ }
385
+ }
386
+ if (justify && wrappedLines.length > 1) {
387
+ wrappedLines.forEach((wrappedLine, idx) => {
388
+ if (idx < wrappedLines.length - 1) {
389
+ const justifiedLine = justifyLine({
390
+ line: wrappedLine,
391
+ spaceWidth: hairWidth,
392
+ spaceChar: HAIR,
393
+ boxWidth
394
+ });
395
+ measureLine(justifiedLine, true);
396
+ wrappedLines[idx] = justifiedLine;
397
+ }
398
+ });
399
+ }
400
+ const spec = _generateSpec({
401
+ wrappedLines,
402
+ wordMap,
403
+ positioning
404
+ });
405
+ ctx.restore();
406
+ return spec;
407
+ };
408
+ const textToWords = (text) => {
409
+ const words = [];
410
+ let word = void 0;
411
+ let wasWhitespace = false;
412
+ Array.from(text.trim()).forEach((c) => {
413
+ const charIsWhitespace = isWhitespace(c);
414
+ if (charIsWhitespace && !wasWhitespace || !charIsWhitespace && wasWhitespace) {
415
+ wasWhitespace = charIsWhitespace;
416
+ if (word) {
417
+ words.push(word);
418
+ }
419
+ word = { text: c };
420
+ } else {
421
+ if (!word) {
422
+ word = { text: "" };
423
+ }
424
+ word.text += c;
425
+ }
426
+ });
427
+ if (word) {
428
+ words.push(word);
429
+ }
430
+ return words;
431
+ };
432
+ const splitText = ({ text, ...params }) => {
433
+ const words = textToWords(text);
434
+ const results = splitWords({
435
+ ...params,
436
+ words,
437
+ inferWhitespace: false
438
+ });
439
+ return results.lines.map(
440
+ (line) => line.map(({ word: { text: t } }) => t).join("")
441
+ );
442
+ };
443
+ const _getHeight = (ctx, text, style) => {
444
+ const previousTextBaseline = ctx.textBaseline;
445
+ const previousFont = ctx.font;
446
+ ctx.textBaseline = "bottom";
447
+ if (style) {
448
+ ctx.font = style;
449
+ }
450
+ const { actualBoundingBoxAscent: height } = ctx.measureText(text);
451
+ ctx.textBaseline = previousTextBaseline;
452
+ if (style) {
453
+ ctx.font = previousFont;
454
+ }
455
+ return height;
456
+ };
457
+ const getWordHeight = ({
458
+ ctx,
459
+ word
460
+ }) => {
461
+ return _getHeight(ctx, word.text, word.format && getTextStyle(word.format));
462
+ };
463
+ const getTextHeight = ({
464
+ ctx,
465
+ text,
466
+ style
467
+ }) => {
468
+ return _getHeight(ctx, text, style);
469
+ };
470
+ const drawText = (ctx, text, config) => {
471
+ const baseFormat = getTextFormat({
472
+ fontFamily: config.fontFamily,
473
+ fontSize: config.fontSize,
474
+ fontStyle: config.fontStyle,
475
+ fontVariant: config.fontVariant,
476
+ fontWeight: config.fontWeight
477
+ });
478
+ const {
479
+ lines: richLines,
480
+ height: totalHeight,
481
+ textBaseline,
482
+ textAlign
483
+ } = splitWords({
484
+ ctx,
485
+ words: Array.isArray(text) ? text : textToWords(text),
486
+ inferWhitespace: Array.isArray(text) ? config.inferWhitespace === void 0 || config.inferWhitespace : void 0,
487
+ // ignore since `text` is a string; we assume it already has all the whitespace it needs
488
+ x: config.x || 0,
489
+ y: config.y || 0,
490
+ width: config.width,
491
+ height: config.height,
492
+ align: config.align,
493
+ vAlign: config.vAlign,
494
+ justify: config.justify,
495
+ format: baseFormat
496
+ });
497
+ ctx.save();
498
+ ctx.textAlign = textAlign;
499
+ ctx.textBaseline = textBaseline;
500
+ ctx.font = getTextStyle(baseFormat);
501
+ ctx.fillStyle = baseFormat.fontColor || DEFAULT_FONT_COLOR;
502
+ richLines.forEach((line) => {
503
+ line.forEach((pw) => {
504
+ if (!pw.isWhitespace) {
505
+ if (pw.format) {
506
+ ctx.save();
507
+ ctx.font = getTextStyle(pw.format);
508
+ if (pw.format.fontColor) {
509
+ ctx.fillStyle = pw.format.fontColor;
510
+ }
511
+ }
512
+ ctx.fillText(pw.word.text, pw.x, pw.y);
513
+ if (pw.format) {
514
+ ctx.restore();
515
+ }
516
+ }
517
+ });
518
+ });
519
+ if (config.debug) {
520
+ const { width, height, x = 0, y = 0 } = config;
521
+ const xEnd = x + width;
522
+ const yEnd = y + height;
523
+ let textAnchor;
524
+ if (config.align === "right") {
525
+ textAnchor = xEnd;
526
+ } else if (config.align === "left") {
527
+ textAnchor = x;
528
+ } else {
529
+ textAnchor = x + width / 2;
530
+ }
531
+ let debugY = y;
532
+ if (config.vAlign === "bottom") {
533
+ debugY = yEnd;
534
+ } else if (config.vAlign === "middle") {
535
+ debugY = y + height / 2;
536
+ }
537
+ const debugColor = "#0C8CE9";
538
+ ctx.lineWidth = 1;
539
+ ctx.strokeStyle = debugColor;
540
+ ctx.strokeRect(x, y, width, height);
541
+ ctx.lineWidth = 1;
542
+ if (!config.align || config.align === "center") {
543
+ ctx.strokeStyle = debugColor;
544
+ ctx.beginPath();
545
+ ctx.moveTo(textAnchor, y);
546
+ ctx.lineTo(textAnchor, yEnd);
547
+ ctx.stroke();
548
+ }
549
+ if (!config.vAlign || config.vAlign === "middle") {
550
+ ctx.strokeStyle = debugColor;
551
+ ctx.beginPath();
552
+ ctx.moveTo(x, debugY);
553
+ ctx.lineTo(xEnd, debugY);
554
+ ctx.stroke();
555
+ }
556
+ }
557
+ ctx.restore();
558
+ return { height: totalHeight };
559
+ };
560
+ exports.drawText = drawText;
561
+ exports.getTextFormat = getTextFormat;
562
+ exports.getTextHeight = getTextHeight;
563
+ exports.getTextStyle = getTextStyle;
564
+ exports.getWordHeight = getWordHeight;
565
+ exports.specToJson = specToJson;
566
+ exports.splitText = splitText;
567
+ exports.splitWords = splitWords;
568
+ exports.textToWords = textToWords;
569
+ exports.wordsToJson = wordsToJson;