@meonode/canvas 1.0.0-beta.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.
Files changed (65) hide show
  1. package/CONTRIBUTING.md +75 -0
  2. package/LICENSE +21 -0
  3. package/Readme.md +382 -0
  4. package/dist/cjs/canvas/canvas.helper.d.ts +57 -0
  5. package/dist/cjs/canvas/canvas.helper.d.ts.map +1 -0
  6. package/dist/cjs/canvas/canvas.helper.js +239 -0
  7. package/dist/cjs/canvas/canvas.helper.js.map +1 -0
  8. package/dist/cjs/canvas/canvas.type.d.ts +657 -0
  9. package/dist/cjs/canvas/canvas.type.d.ts.map +1 -0
  10. package/dist/cjs/canvas/grid.canvas.util.d.ts +39 -0
  11. package/dist/cjs/canvas/grid.canvas.util.d.ts.map +1 -0
  12. package/dist/cjs/canvas/grid.canvas.util.js +263 -0
  13. package/dist/cjs/canvas/grid.canvas.util.js.map +1 -0
  14. package/dist/cjs/canvas/image.canvas.util.d.ts +34 -0
  15. package/dist/cjs/canvas/image.canvas.util.d.ts.map +1 -0
  16. package/dist/cjs/canvas/image.canvas.util.js +310 -0
  17. package/dist/cjs/canvas/image.canvas.util.js.map +1 -0
  18. package/dist/cjs/canvas/layout.canvas.util.d.ts +123 -0
  19. package/dist/cjs/canvas/layout.canvas.util.d.ts.map +1 -0
  20. package/dist/cjs/canvas/layout.canvas.util.js +785 -0
  21. package/dist/cjs/canvas/layout.canvas.util.js.map +1 -0
  22. package/dist/cjs/canvas/root.canvas.util.d.ts +42 -0
  23. package/dist/cjs/canvas/root.canvas.util.d.ts.map +1 -0
  24. package/dist/cjs/canvas/root.canvas.util.js +140 -0
  25. package/dist/cjs/canvas/root.canvas.util.js.map +1 -0
  26. package/dist/cjs/canvas/text.canvas.util.d.ts +148 -0
  27. package/dist/cjs/canvas/text.canvas.util.d.ts.map +1 -0
  28. package/dist/cjs/canvas/text.canvas.util.js +1112 -0
  29. package/dist/cjs/canvas/text.canvas.util.js.map +1 -0
  30. package/dist/cjs/constant/common.const.d.ts +37 -0
  31. package/dist/cjs/constant/common.const.d.ts.map +1 -0
  32. package/dist/cjs/constant/common.const.js +51 -0
  33. package/dist/cjs/constant/common.const.js.map +1 -0
  34. package/dist/cjs/index.d.ts +7 -0
  35. package/dist/cjs/index.d.ts.map +1 -0
  36. package/dist/cjs/index.js +31 -0
  37. package/dist/cjs/index.js.map +1 -0
  38. package/dist/esm/canvas/canvas.helper.d.ts +57 -0
  39. package/dist/esm/canvas/canvas.helper.d.ts.map +1 -0
  40. package/dist/esm/canvas/canvas.helper.js +214 -0
  41. package/dist/esm/canvas/canvas.type.d.ts +657 -0
  42. package/dist/esm/canvas/canvas.type.d.ts.map +1 -0
  43. package/dist/esm/canvas/grid.canvas.util.d.ts +39 -0
  44. package/dist/esm/canvas/grid.canvas.util.d.ts.map +1 -0
  45. package/dist/esm/canvas/grid.canvas.util.js +259 -0
  46. package/dist/esm/canvas/image.canvas.util.d.ts +34 -0
  47. package/dist/esm/canvas/image.canvas.util.d.ts.map +1 -0
  48. package/dist/esm/canvas/image.canvas.util.js +306 -0
  49. package/dist/esm/canvas/layout.canvas.util.d.ts +123 -0
  50. package/dist/esm/canvas/layout.canvas.util.d.ts.map +1 -0
  51. package/dist/esm/canvas/layout.canvas.util.js +777 -0
  52. package/dist/esm/canvas/root.canvas.util.d.ts +42 -0
  53. package/dist/esm/canvas/root.canvas.util.d.ts.map +1 -0
  54. package/dist/esm/canvas/root.canvas.util.js +116 -0
  55. package/dist/esm/canvas/text.canvas.util.d.ts +148 -0
  56. package/dist/esm/canvas/text.canvas.util.d.ts.map +1 -0
  57. package/dist/esm/canvas/text.canvas.util.js +1108 -0
  58. package/dist/esm/constant/common.const.d.ts +37 -0
  59. package/dist/esm/constant/common.const.d.ts.map +1 -0
  60. package/dist/esm/constant/common.const.js +23 -0
  61. package/dist/esm/index.d.ts +7 -0
  62. package/dist/esm/index.d.ts.map +1 -0
  63. package/dist/esm/index.js +7 -0
  64. package/dist/meonode-canvas-1.0.0-beta.1.tgz +0 -0
  65. package/package.json +79 -0
@@ -0,0 +1,1108 @@
1
+ import { Canvas } from 'skia-canvas';
2
+ import { BoxNode } from './layout.canvas.util.js';
3
+ import { Style } from '../constant/common.const.js';
4
+
5
+ /**
6
+ * Node for rendering text content with rich text styling support
7
+ * Supports color and weight variations through HTML-like tags
8
+ */
9
+ class TextNode extends BoxNode {
10
+ segments = [];
11
+ lines = [];
12
+ static measurementContext = null;
13
+ metricsString = 'Ag|\``';
14
+ lineHeights = [];
15
+ lineAscents = [];
16
+ lineContentHeights = [];
17
+ constructor(text = '', props = {}) {
18
+ const initialProps = {
19
+ name: 'TextNode',
20
+ flexShrink: 1,
21
+ lineGap: 0,
22
+ ...props,
23
+ children: undefined,
24
+ };
25
+ super(initialProps);
26
+ this.props = initialProps;
27
+ // Process escape sequences before parsing rich text
28
+ const processedText = this.processEscapeSequences(String(text ?? ''));
29
+ this.segments = this.parseRichText(processedText, {
30
+ color: this.props.color,
31
+ weight: this.props.fontWeight,
32
+ size: this.props.fontSize,
33
+ b: this.props.fontWeight === 'bold',
34
+ i: this.props.fontStyle === 'italic',
35
+ });
36
+ this.node.setMeasureFunc(this.measureText.bind(this));
37
+ this.applyDefaults();
38
+ }
39
+ applyDefaults() {
40
+ const textDefaults = {
41
+ fontSize: 16,
42
+ fontFamily: 'sans-serif',
43
+ fontWeight: 'normal',
44
+ fontStyle: 'normal',
45
+ color: 'black',
46
+ textAlign: 'left',
47
+ verticalAlign: 'top',
48
+ fontVariant: undefined,
49
+ lineHeight: undefined,
50
+ lineGap: 0,
51
+ maxLines: undefined,
52
+ ellipsis: false,
53
+ letterSpacing: undefined,
54
+ wordSpacing: undefined,
55
+ };
56
+ let defaultsApplied = false;
57
+ for (const key of Object.keys(textDefaults)) {
58
+ if (this.props[key] === undefined && textDefaults[key] !== undefined) {
59
+ this.props[key] = textDefaults[key];
60
+ defaultsApplied = true;
61
+ }
62
+ }
63
+ if (defaultsApplied && !this.node.isDirty()) {
64
+ const affectsMeasurement = [
65
+ 'fontSize',
66
+ 'fontFamily',
67
+ 'fontWeight',
68
+ 'fontStyle',
69
+ 'lineHeight',
70
+ 'maxLines',
71
+ 'lineGap',
72
+ 'letterSpacing',
73
+ 'wordSpacing',
74
+ ].some(measureKey => this.props[measureKey] === textDefaults[measureKey]);
75
+ if (affectsMeasurement) {
76
+ this.node.markDirty();
77
+ }
78
+ }
79
+ }
80
+ /**
81
+ * Processes Unix-like escape sequences in text strings.
82
+ * Converts escaped characters into their actual representations.
83
+ *
84
+ * Supported escape sequences:
85
+ * - \n - Newline (line feed)
86
+ * - \t - Tab (converted to 4 spaces)
87
+ * - \r - Carriage return (treated as newline)
88
+ * - \\ - Literal backslash
89
+ * - \' - Single quote
90
+ * - \" - Double quote
91
+ * - \0 - Null character (removed)
92
+ * - \b - Backspace (removed)
93
+ * - \f - Form feed (treated as newline)
94
+ * - \v - Vertical tab (treated as newline)
95
+ *
96
+ * @param input - Raw text string potentially containing escape sequences
97
+ * @returns Processed string with escape sequences converted
98
+ */
99
+ processEscapeSequences(input) {
100
+ return input.replace(/\\(.)/g, (match, char) => {
101
+ switch (char) {
102
+ case 'n':
103
+ return '\n'; // Newline
104
+ case 't':
105
+ return ' '; // Tab as 4 spaces
106
+ case 'r':
107
+ return '\n'; // Carriage return treated as newline
108
+ case '\\':
109
+ return '\\'; // Literal backslash
110
+ case "'":
111
+ return "'"; // Single quote
112
+ case '"':
113
+ return '"'; // Double quote
114
+ case '0':
115
+ return ''; // Null character (remove)
116
+ case 'b':
117
+ return ''; // Backspace (remove)
118
+ case 'f':
119
+ return '\n'; // Form feed as newline
120
+ case 'v':
121
+ return '\n'; // Vertical tab as newline
122
+ default:
123
+ // Unknown escape sequence - keep original
124
+ return match;
125
+ }
126
+ });
127
+ }
128
+ /**
129
+ * Parses input text with HTML-style markup into styled text segments.
130
+ *
131
+ * Supported tags:
132
+ * - <color="value"> - Sets text color (hex code or CSS color name)
133
+ * - <weight="value"> - Sets font weight (100-900 or keywords like "bold")
134
+ * - <size="value"> - Sets font size in pixels
135
+ * - <b> - Makes text bold (shorthand for weight="bold")
136
+ * - <i> - Makes text italic
137
+ *
138
+ * Tag values can use double quotes, single quotes, or no quotes:
139
+ * <color="red">, <color='red'>, <color=red>
140
+ *
141
+ * Tags can be nested and must be properly closed with </tag>
142
+ *
143
+ * @param input - Text string containing markup tags
144
+ * @param baseStyle - Default style properties to apply to all segments
145
+ * @returns Array of styled text segments with consistent style properties
146
+ */
147
+ parseRichText(input, baseStyle) {
148
+ // Match opening/closing tags with optional quoted/unquoted values
149
+ // Capture groups: (1) closing slash, (2) tag name, (3) double quoted value, (4) single quoted value, (5) unquoted value
150
+ const tagRegex = /<(\/?)(\w+)(?:=(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?>/g;
151
+ const stack = [];
152
+ const segments = [];
153
+ let lastIndex = 0;
154
+ let currentStyle = { ...baseStyle };
155
+ // Helper to create a styled segment ensuring all style properties are included
156
+ const applyStyle = (text) => {
157
+ if (!text)
158
+ return;
159
+ segments.push({
160
+ text,
161
+ color: currentStyle.color,
162
+ weight: currentStyle.weight,
163
+ size: currentStyle.size,
164
+ b: currentStyle.b,
165
+ i: currentStyle.i,
166
+ });
167
+ };
168
+ let match;
169
+ while ((match = tagRegex.exec(input))) {
170
+ const [, closingSlash, tagNameStr, quotedVal1, quotedVal2, unquotedVal] = match;
171
+ const tagName = tagNameStr.toLowerCase();
172
+ const value = quotedVal1 || quotedVal2 || unquotedVal;
173
+ // Process text content before the current tag
174
+ applyStyle(input.slice(lastIndex, match.index));
175
+ lastIndex = tagRegex.lastIndex;
176
+ if (!closingSlash) {
177
+ // Opening tag: Save current style state and apply new style
178
+ stack.push({ ...currentStyle });
179
+ switch (tagName) {
180
+ case 'color':
181
+ // Support any valid CSS color value
182
+ currentStyle.color = value;
183
+ break;
184
+ case 'weight':
185
+ // Support numeric weights (100-900) or keywords
186
+ currentStyle.weight = value;
187
+ break;
188
+ case 'size':
189
+ // Parse pixel size as number, revert to default if invalid
190
+ currentStyle.size = value ? Number(value) : undefined;
191
+ if (isNaN(currentStyle.size)) {
192
+ console.warn(`[TextNode ${this.key || ''}] Invalid numeric value for size tag: ${value}`);
193
+ currentStyle.size = undefined;
194
+ }
195
+ break;
196
+ case 'b':
197
+ // Simple bold flag
198
+ currentStyle.b = true;
199
+ break;
200
+ case 'i':
201
+ // Simple italic flag
202
+ currentStyle.i = true;
203
+ break;
204
+ }
205
+ }
206
+ else {
207
+ // Closing tag: Restore previous style state
208
+ currentStyle = stack.pop() || { ...baseStyle };
209
+ }
210
+ }
211
+ // Process remaining text after last tag
212
+ applyStyle(input.slice(lastIndex));
213
+ // Don't filter out empty segments - they might represent empty lines
214
+ return segments;
215
+ }
216
+ formatSpacing(value) {
217
+ if (typeof value === 'number')
218
+ return `${value}px`;
219
+ return value || 'normal';
220
+ }
221
+ parseSpacingToPx(spacingValue, fontSize) {
222
+ if (spacingValue === undefined || spacingValue === 'normal') {
223
+ return 0;
224
+ }
225
+ if (typeof spacingValue === 'number') {
226
+ return spacingValue; // Treat raw number as px
227
+ }
228
+ if (typeof spacingValue === 'string') {
229
+ const trimmed = spacingValue.trim();
230
+ if (trimmed.endsWith('px')) {
231
+ return parseFloat(trimmed) || 0;
232
+ }
233
+ if (trimmed.endsWith('em')) {
234
+ // Convert em based on the current font size
235
+ return (parseFloat(trimmed) || 0) * fontSize;
236
+ }
237
+ // Attempt to parse as a raw number (pixels) if no unit
238
+ const parsed = parseFloat(trimmed);
239
+ if (!isNaN(parsed)) {
240
+ return parsed;
241
+ }
242
+ }
243
+ return 0; // Default fallback
244
+ }
245
+ /**
246
+ * Generates a CSS font string by combining base TextProps with optional TextSegment styling.
247
+ * Follows browser font string format: "font-style font-weight font-size font-family"
248
+ *
249
+ * Priority for style properties:
250
+ * - Weight: segment <weight> tag > segment <b> tag > base fontWeight prop
251
+ * - Style: segment <i> > base fontStyle
252
+ * - Size: segment size > base fontSize
253
+ * - Family: base fontFamily
254
+ *
255
+ * @param segmentStyle - Optional TextSegment styling to override base props
256
+ * @returns Formatted CSS font string for canvas context
257
+ */
258
+ getFontString(segmentStyle) {
259
+ const baseStyle = this.props;
260
+ let effectiveWeight;
261
+ // Determine italic style - segment <i> tag overrides base style
262
+ const effectiveStyle = segmentStyle?.i ? 'italic' : baseStyle.fontStyle || 'normal';
263
+ // Determine font weight with priority:
264
+ // 1. Segment explicit weight (<weight> tag)
265
+ // 2. Segment bold flag (<b> tag)
266
+ // 3. Base font weight prop
267
+ if (segmentStyle?.weight) {
268
+ effectiveWeight = segmentStyle.weight;
269
+ }
270
+ else if (segmentStyle?.b) {
271
+ effectiveWeight = 'bold';
272
+ }
273
+ else {
274
+ effectiveWeight = baseStyle.fontWeight || 'normal';
275
+ }
276
+ // Use segment size if specified, otherwise base size with 16px default
277
+ const effectiveSize = segmentStyle?.size ? segmentStyle.size : baseStyle.fontSize || 16;
278
+ // Combine properties into CSS font string format
279
+ const style = {
280
+ fontStyle: effectiveStyle,
281
+ fontWeight: effectiveWeight,
282
+ fontSize: effectiveSize,
283
+ fontFamily: baseStyle.fontFamily || 'sans-serif',
284
+ };
285
+ return `${style.fontStyle} ${style.fontWeight} ${style.fontSize}px ${style.fontFamily}`;
286
+ }
287
+ /**
288
+ * Gets lines to process respecting maxLines constraint
289
+ */
290
+ getLinesToMeasureOrRender() {
291
+ const maxLines = this.props.maxLines;
292
+ if (maxLines !== undefined && maxLines > 0 && this.lines.length > maxLines) {
293
+ return this.lines.slice(0, maxLines);
294
+ }
295
+ return this.lines;
296
+ }
297
+ /**
298
+ * Measures text dimensions and calculates layout metrics for the YogaLayout engine.
299
+ * Handles text wrapping, line height calculations, and dynamic leading.
300
+ *
301
+ * Line heights are determined by:
302
+ * 1. Using props.lineHeight as fixed pixel value if provided
303
+ * 2. Otherwise calculating dynamic height based on largest font size per line
304
+ * 3. Adding leading space above/below text content
305
+ * 4. Including specified line gaps between lines
306
+ *
307
+ * @param widthConstraint - Maximum allowed width in pixels for text layout
308
+ * @param widthMode - YogaLayout mode determining how width constraint is applied
309
+ * @returns Calculated minimum dimensions required to render text content
310
+ * - width: Total width needed for text layout
311
+ * - height: Total height including line heights and gaps
312
+ */
313
+ measureText(widthConstraint, widthMode) {
314
+ // Create measurement canvas if not exists
315
+ if (!TextNode.measurementContext) {
316
+ TextNode.measurementContext = new Canvas(1, 1).getContext('2d');
317
+ }
318
+ const baseFontSize = this.props.fontSize || 16;
319
+ const ctx = TextNode.measurementContext;
320
+ ctx.save();
321
+ // Setup text measurement context
322
+ ctx.letterSpacing = this.formatSpacing(this.props.letterSpacing);
323
+ ctx.wordSpacing = 'normal'; // Handled manually via parsedWordSpacingPx
324
+ const parsedWordSpacingPx = this.parseSpacingToPx(this.props.wordSpacing, baseFontSize);
325
+ // Pre-measure each text segment width with its specific styling
326
+ for (const segment of this.segments) {
327
+ ctx.font = this.getFontString(segment);
328
+ if (typeof this.props.fontVariant === 'string') {
329
+ ctx.fontVariant = this.props.fontVariant;
330
+ }
331
+ else if (this.props.fontVariant !== undefined) {
332
+ console.warn(`[TextNode ${this.key || ''}] Invalid fontVariant prop type in measureText (segment width):`, this.props.fontVariant);
333
+ if (ctx.fontVariant !== 'normal')
334
+ ctx.fontVariant = 'normal';
335
+ }
336
+ else {
337
+ if (ctx.fontVariant !== 'normal')
338
+ ctx.fontVariant = 'normal';
339
+ }
340
+ segment.width = ctx.measureText(segment.text).width;
341
+ }
342
+ // Calculate available layout width
343
+ const availableWidthForContent = widthMode === Style.MeasureMode.Undefined ? Infinity : Math.max(0, widthConstraint);
344
+ const epsilon = 0.001; // Float precision compensation
345
+ // Wrap text into lines based on available width
346
+ this.lines = this.wrapTextRich(ctx, this.segments, availableWidthForContent + epsilon, parsedWordSpacingPx);
347
+ // Initialize line metrics arrays
348
+ this.lineHeights = []; // Final heights including leading
349
+ this.lineAscents = []; // Text ascent heights
350
+ this.lineContentHeights = []; // Raw content heights (ascent + descent)
351
+ let totalTextHeight = 0;
352
+ const linesToMeasure = this.getLinesToMeasureOrRender();
353
+ const numLines = linesToMeasure.length;
354
+ const defaultLineHeightMultiplier = 1.2; // Base leading multiplier
355
+ // Calculate metrics for each line
356
+ for (const line of linesToMeasure) {
357
+ let maxAscent = 0;
358
+ let maxDescent = 0;
359
+ let maxFontSizeOnLine = 0;
360
+ // Handle empty line metrics
361
+ if (line.length === 0) {
362
+ ctx.font = this.getFontString();
363
+ if (typeof this.props.fontVariant === 'string') {
364
+ ctx.fontVariant = this.props.fontVariant;
365
+ }
366
+ else if (this.props.fontVariant !== undefined) {
367
+ console.warn(`[TextNode ${this.key || ''}] Invalid fontVariant prop type in measureText (empty line):`, this.props.fontVariant);
368
+ if (ctx.fontVariant !== 'normal')
369
+ ctx.fontVariant = 'normal';
370
+ }
371
+ else {
372
+ if (ctx.fontVariant !== 'normal')
373
+ ctx.fontVariant = 'normal';
374
+ }
375
+ const metrics = ctx.measureText(this.metricsString);
376
+ maxAscent = metrics.actualBoundingBoxAscent ?? baseFontSize * 0.8;
377
+ maxDescent = metrics.actualBoundingBoxDescent ?? baseFontSize * 0.2;
378
+ maxFontSizeOnLine = baseFontSize;
379
+ }
380
+ else {
381
+ // Calculate max metrics across all segments in line
382
+ for (const segment of line) {
383
+ if (/^\s+$/.test(segment.text))
384
+ continue;
385
+ const segmentSize = segment.size || baseFontSize;
386
+ maxFontSizeOnLine = Math.max(maxFontSizeOnLine, segmentSize);
387
+ ctx.font = this.getFontString(segment);
388
+ if (typeof this.props.fontVariant === 'string') {
389
+ ctx.fontVariant = this.props.fontVariant;
390
+ }
391
+ else if (this.props.fontVariant !== undefined) {
392
+ console.warn(`[TextNode ${this.key || ''}] Invalid fontVariant prop type in measureText (segment height):`, this.props.fontVariant);
393
+ if (ctx.fontVariant !== 'normal')
394
+ ctx.fontVariant = 'normal';
395
+ }
396
+ else {
397
+ if (ctx.fontVariant !== 'normal')
398
+ ctx.fontVariant = 'normal';
399
+ }
400
+ const metrics = ctx.measureText(this.metricsString);
401
+ const ascent = metrics.actualBoundingBoxAscent ?? segmentSize * 0.8;
402
+ const descent = metrics.actualBoundingBoxDescent ?? segmentSize * 0.2;
403
+ maxAscent = Math.max(maxAscent, ascent);
404
+ maxDescent = Math.max(maxDescent, descent);
405
+ }
406
+ }
407
+ // Fallback metrics for lines with only whitespace
408
+ if (maxAscent === 0 && maxDescent === 0 && line.length > 0) {
409
+ ctx.font = this.getFontString();
410
+ if (typeof this.props.fontVariant === 'string') {
411
+ ctx.fontVariant = this.props.fontVariant;
412
+ }
413
+ else if (this.props.fontVariant !== undefined) {
414
+ console.warn(`[TextNode ${this.key || ''}] Invalid fontVariant prop type in measureText (fallback):`, this.props.fontVariant);
415
+ if (ctx.fontVariant !== 'normal')
416
+ ctx.fontVariant = 'normal';
417
+ }
418
+ else {
419
+ if (ctx.fontVariant !== 'normal')
420
+ ctx.fontVariant = 'normal';
421
+ }
422
+ const metrics = ctx.measureText(this.metricsString);
423
+ maxAscent = metrics.actualBoundingBoxAscent ?? baseFontSize * 0.8;
424
+ maxDescent = metrics.actualBoundingBoxDescent ?? baseFontSize * 0.2;
425
+ maxFontSizeOnLine = maxFontSizeOnLine || baseFontSize;
426
+ }
427
+ maxFontSizeOnLine = maxFontSizeOnLine || baseFontSize;
428
+ // Calculate total content height for line
429
+ const actualContentHeight = maxAscent + maxDescent;
430
+ // Determine final line box height with leading
431
+ const targetLineBoxHeight = typeof this.props.lineHeight === 'number' && this.props.lineHeight > 0
432
+ ? this.props.lineHeight
433
+ : maxFontSizeOnLine * defaultLineHeightMultiplier;
434
+ // Use larger of target height or content height to prevent clipping
435
+ const finalLineHeight = Math.max(actualContentHeight, targetLineBoxHeight);
436
+ // Store line metrics for rendering
437
+ this.lineHeights.push(finalLineHeight);
438
+ this.lineAscents.push(maxAscent);
439
+ this.lineContentHeights.push(actualContentHeight);
440
+ totalTextHeight += finalLineHeight;
441
+ }
442
+ // Add line gap spacing to total height
443
+ const lineGapValue = this.props.lineGap;
444
+ const totalGapHeight = Math.max(0, (numLines - 1) * lineGapValue);
445
+ const calculatedContentHeight = totalTextHeight + totalGapHeight;
446
+ // Calculate width required for text content
447
+ const spaceWidth = this.measureSpaceWidth(ctx);
448
+ let singleLineWidth = 0;
449
+ let firstWordInSingleLine = true;
450
+ for (const segment of this.segments) {
451
+ const words = segment.text.split(/(\s+)/).filter(Boolean);
452
+ for (const word of words) {
453
+ if (/^\s+$/.test(word))
454
+ continue;
455
+ ctx.font = this.getFontString(segment);
456
+ if (typeof this.props.fontVariant === 'string') {
457
+ ctx.fontVariant = this.props.fontVariant;
458
+ }
459
+ else if (this.props.fontVariant !== undefined) {
460
+ console.warn(`[TextNode ${this.key || ''}] Invalid fontVariant prop type in measureText (single line width):`, this.props.fontVariant);
461
+ if (ctx.fontVariant !== 'normal')
462
+ ctx.fontVariant = 'normal';
463
+ }
464
+ else {
465
+ if (ctx.fontVariant !== 'normal')
466
+ ctx.fontVariant = 'normal';
467
+ }
468
+ const wordWidth = ctx.measureText(word).width;
469
+ if (!firstWordInSingleLine) {
470
+ singleLineWidth += spaceWidth + parsedWordSpacingPx;
471
+ }
472
+ singleLineWidth += wordWidth;
473
+ firstWordInSingleLine = false;
474
+ }
475
+ }
476
+ // Determine final content width based on wrapping
477
+ let requiredContentWidth;
478
+ if (singleLineWidth <= availableWidthForContent) {
479
+ requiredContentWidth = singleLineWidth;
480
+ if (linesToMeasure.length > 1 && this.props.maxLines !== 1 && !this.segments.some(s => s.text.includes('\n'))) {
481
+ console.warn(`[TextNode ${this.key || ''}] Rich text should fit (${singleLineWidth.toFixed(2)} <= ${availableWidthForContent.toFixed(2)}) but wrapTextRich produced ${linesToMeasure.length} lines. Width calculation might be slightly off due to complex spacing/kerning.`);
482
+ let maxWrappedLineWidth = 0;
483
+ for (const line of linesToMeasure) {
484
+ let currentLineWidth = 0;
485
+ let firstWordOnWrappedLine = true;
486
+ for (const segment of line) {
487
+ const segmentWidth = segment.width ?? 0;
488
+ const isSpaceSegment = /^\s+$/.test(segment.text);
489
+ if (!isSpaceSegment) {
490
+ if (!firstWordOnWrappedLine) {
491
+ currentLineWidth += spaceWidth + parsedWordSpacingPx;
492
+ }
493
+ currentLineWidth += segmentWidth;
494
+ firstWordOnWrappedLine = false;
495
+ }
496
+ }
497
+ maxWrappedLineWidth = Math.max(maxWrappedLineWidth, currentLineWidth);
498
+ }
499
+ requiredContentWidth = Math.max(singleLineWidth, maxWrappedLineWidth);
500
+ }
501
+ }
502
+ else {
503
+ let maxWrappedLineWidth = 0;
504
+ for (const line of linesToMeasure) {
505
+ let currentLineWidth = 0;
506
+ let firstWordOnWrappedLine = true;
507
+ for (const segment of line) {
508
+ const segmentWidth = segment.width ?? 0;
509
+ const isSpaceSegment = /^\s+$/.test(segment.text);
510
+ if (!isSpaceSegment) {
511
+ if (!firstWordOnWrappedLine) {
512
+ currentLineWidth += spaceWidth + parsedWordSpacingPx;
513
+ }
514
+ currentLineWidth += segmentWidth;
515
+ firstWordOnWrappedLine = false;
516
+ }
517
+ }
518
+ maxWrappedLineWidth = Math.max(maxWrappedLineWidth, currentLineWidth);
519
+ }
520
+ requiredContentWidth = maxWrappedLineWidth;
521
+ }
522
+ // Constrain width if needed
523
+ let finalContentWidth = requiredContentWidth;
524
+ if (availableWidthForContent !== Infinity) {
525
+ finalContentWidth = Math.min(requiredContentWidth, availableWidthForContent);
526
+ }
527
+ ctx.restore();
528
+ return {
529
+ width: Math.max(0, finalContentWidth),
530
+ height: Math.max(0, calculatedContentHeight),
531
+ };
532
+ }
533
+ /**
534
+ * Wraps text segments into multiple lines while respecting width constraints and preserving styling.
535
+ * Handles rich text attributes (color, weight, size, bold, italic) and proper word wrapping.
536
+ * Also respects explicit newline characters (\n) for forced line breaks.
537
+ *
538
+ * @param ctx - Canvas rendering context used for text measurements
539
+ * @param segments - Array of text segments with styling information
540
+ * @param maxWidth - Maximum allowed width for each line in pixels
541
+ * @param parsedWordSpacingPx - Additional spacing to add between words in pixels
542
+ * @returns Array of lines, where each line contains styled text segments
543
+ */
544
+ wrapTextRich(ctx, segments, maxWidth, parsedWordSpacingPx) {
545
+ const lines = [];
546
+ if (segments.length === 0 || maxWidth <= 0)
547
+ return lines;
548
+ let currentLineSegments = [];
549
+ let currentLineWidth = 0;
550
+ const spaceWidth = this.measureSpaceWidth(ctx);
551
+ // Helper to finalize current line and start new one
552
+ const finalizeLine = (forceEmpty = false) => {
553
+ // Remove trailing whitespace segments unless we're forcing an empty line
554
+ if (!forceEmpty) {
555
+ while (currentLineSegments.length > 0 &&
556
+ /^\s+$/.test(currentLineSegments[currentLineSegments.length - 1].text)) {
557
+ currentLineSegments.pop();
558
+ }
559
+ }
560
+ // Always push the line, even if empty (for \n\n cases)
561
+ lines.push(currentLineSegments);
562
+ currentLineSegments = [];
563
+ currentLineWidth = 0;
564
+ };
565
+ for (const segment of segments) {
566
+ // Preserve all style attributes for consistency
567
+ const segmentStyle = {
568
+ color: segment.color,
569
+ weight: segment.weight,
570
+ size: segment.size,
571
+ b: segment.b,
572
+ i: segment.i,
573
+ };
574
+ // Check if segment contains newline characters
575
+ if (segment.text.includes('\n')) {
576
+ // Split by newlines and process each part
577
+ const parts = segment.text.split('\n');
578
+ for (let i = 0; i < parts.length; i++) {
579
+ const part = parts[i];
580
+ const isLastPart = i === parts.length - 1;
581
+ if (part.length > 0) {
582
+ // Process this part normally
583
+ const wordsAndSpaces = part.split(/(\s+)/).filter(Boolean);
584
+ for (const wordOrSpace of wordsAndSpaces) {
585
+ const isSpace = /^\s+$/.test(wordOrSpace);
586
+ let wordSegment;
587
+ let wordWidth;
588
+ if (isSpace) {
589
+ wordSegment = { text: wordOrSpace, ...segmentStyle, width: 0 };
590
+ wordWidth = 0;
591
+ }
592
+ else {
593
+ ctx.font = this.getFontString(segmentStyle);
594
+ if (this.props.fontVariant)
595
+ ctx.fontVariant = this.props.fontVariant;
596
+ wordWidth = ctx.measureText(wordOrSpace).width;
597
+ wordSegment = { text: wordOrSpace, ...segmentStyle, width: wordWidth };
598
+ }
599
+ const needsSpace = currentLineSegments.length > 0 &&
600
+ !/^\s+$/.test(currentLineSegments[currentLineSegments.length - 1].text);
601
+ const spaceToAdd = needsSpace ? spaceWidth + parsedWordSpacingPx : 0;
602
+ if (currentLineWidth + spaceToAdd + wordWidth <= maxWidth || currentLineSegments.length === 0) {
603
+ if (needsSpace) {
604
+ currentLineSegments.push({ text: ' ', ...segmentStyle, width: 0 });
605
+ currentLineWidth += spaceToAdd;
606
+ }
607
+ currentLineSegments.push(wordSegment);
608
+ currentLineWidth += wordWidth;
609
+ }
610
+ else {
611
+ if (currentLineSegments.length > 0) {
612
+ finalizeLine();
613
+ }
614
+ if (!isSpace) {
615
+ if (wordWidth > maxWidth && maxWidth > 0) {
616
+ const brokenParts = this.breakWordRich(ctx, wordSegment, maxWidth);
617
+ if (brokenParts.length > 0) {
618
+ for (let k = 0; k < brokenParts.length - 1; k++) {
619
+ lines.push([brokenParts[k]]);
620
+ }
621
+ currentLineSegments = [brokenParts[brokenParts.length - 1]];
622
+ currentLineWidth = brokenParts[brokenParts.length - 1].width ?? 0;
623
+ }
624
+ else {
625
+ currentLineSegments = [wordSegment];
626
+ currentLineWidth = wordWidth;
627
+ }
628
+ }
629
+ else {
630
+ currentLineSegments = [wordSegment];
631
+ currentLineWidth = wordWidth;
632
+ }
633
+ }
634
+ }
635
+ }
636
+ }
637
+ // Force line break after each part except the last
638
+ // If part is empty, this creates an empty line (like \n\n)
639
+ if (!isLastPart) {
640
+ finalizeLine(part.length === 0);
641
+ }
642
+ }
643
+ }
644
+ else {
645
+ // No newlines - process normally
646
+ const wordsAndSpaces = segment.text.split(/(\s+)/).filter(Boolean);
647
+ for (const wordOrSpace of wordsAndSpaces) {
648
+ const isSpace = /^\s+$/.test(wordOrSpace);
649
+ let wordSegment;
650
+ let wordWidth;
651
+ if (isSpace) {
652
+ wordSegment = { text: wordOrSpace, ...segmentStyle, width: 0 };
653
+ wordWidth = 0;
654
+ }
655
+ else {
656
+ ctx.font = this.getFontString(segmentStyle);
657
+ if (this.props.fontVariant)
658
+ ctx.fontVariant = this.props.fontVariant;
659
+ wordWidth = ctx.measureText(wordOrSpace).width;
660
+ wordSegment = { text: wordOrSpace, ...segmentStyle, width: wordWidth };
661
+ }
662
+ const needsSpace = currentLineSegments.length > 0 && !/^\s+$/.test(currentLineSegments[currentLineSegments.length - 1].text);
663
+ const spaceToAdd = needsSpace ? spaceWidth + parsedWordSpacingPx : 0;
664
+ if (currentLineWidth + spaceToAdd + wordWidth <= maxWidth || currentLineSegments.length === 0) {
665
+ if (needsSpace) {
666
+ currentLineSegments.push({ text: ' ', ...segmentStyle, width: 0 });
667
+ currentLineWidth += spaceToAdd;
668
+ }
669
+ currentLineSegments.push(wordSegment);
670
+ currentLineWidth += wordWidth;
671
+ }
672
+ else {
673
+ if (currentLineSegments.length > 0) {
674
+ finalizeLine();
675
+ }
676
+ if (!isSpace) {
677
+ if (wordWidth > maxWidth && maxWidth > 0) {
678
+ const brokenParts = this.breakWordRich(ctx, wordSegment, maxWidth);
679
+ if (brokenParts.length > 0) {
680
+ for (let k = 0; k < brokenParts.length - 1; k++) {
681
+ lines.push([brokenParts[k]]);
682
+ }
683
+ currentLineSegments = [brokenParts[brokenParts.length - 1]];
684
+ currentLineWidth = brokenParts[brokenParts.length - 1].width ?? 0;
685
+ }
686
+ else {
687
+ currentLineSegments = [wordSegment];
688
+ currentLineWidth = wordWidth;
689
+ }
690
+ }
691
+ else {
692
+ currentLineSegments = [wordSegment];
693
+ currentLineWidth = wordWidth;
694
+ }
695
+ }
696
+ }
697
+ }
698
+ }
699
+ }
700
+ finalizeLine();
701
+ return lines;
702
+ }
703
+ /**
704
+ * Breaks a word segment into multiple segments that each fit within the specified width constraint.
705
+ * Maintains all styling properties (color, weight, size, bold, italic) across broken segments.
706
+ *
707
+ * @param ctx - Canvas rendering context used for text measurements
708
+ * @param segmentToBreak - Original text segment to split
709
+ * @param maxWidth - Maximum width allowed for each resulting segment
710
+ * @returns Array of TextSegments, each fitting maxWidth, or original segment if no breaking needed
711
+ */
712
+ breakWordRich(ctx, segmentToBreak, maxWidth) {
713
+ const word = segmentToBreak.text;
714
+ // Copy all style properties to maintain consistent styling across broken segments
715
+ const style = {
716
+ color: segmentToBreak.color,
717
+ weight: segmentToBreak.weight,
718
+ size: segmentToBreak.size,
719
+ b: segmentToBreak.b,
720
+ i: segmentToBreak.i,
721
+ };
722
+ if (maxWidth <= 0)
723
+ return [segmentToBreak];
724
+ const brokenSegments = [];
725
+ let currentPartText = '';
726
+ // Configure context with segment style for accurate measurements
727
+ ctx.font = this.getFontString(style);
728
+ if (this.props.fontVariant)
729
+ ctx.fontVariant = this.props.fontVariant;
730
+ // Process word character by character to find valid break points
731
+ for (const char of word) {
732
+ const testPartText = currentPartText + char;
733
+ const testPartWidth = ctx.measureText(testPartText).width;
734
+ if (testPartWidth > maxWidth) {
735
+ // Current accumulated text exceeds width - create new segment
736
+ if (currentPartText) {
737
+ brokenSegments.push({
738
+ text: currentPartText,
739
+ ...style,
740
+ width: ctx.measureText(currentPartText).width,
741
+ });
742
+ }
743
+ // Handle current character that caused overflow
744
+ currentPartText = char;
745
+ const currentCharWidth = ctx.measureText(currentPartText).width;
746
+ if (currentCharWidth > maxWidth) {
747
+ // Single character is too wide - force break after it
748
+ brokenSegments.push({
749
+ text: currentPartText,
750
+ ...style,
751
+ width: currentCharWidth,
752
+ });
753
+ currentPartText = '';
754
+ }
755
+ }
756
+ else {
757
+ // Character fits - add to current part
758
+ currentPartText = testPartText;
759
+ }
760
+ }
761
+ // Handle any remaining text as final segment
762
+ if (currentPartText) {
763
+ brokenSegments.push({
764
+ text: currentPartText,
765
+ ...style,
766
+ width: ctx.measureText(currentPartText).width,
767
+ });
768
+ }
769
+ return brokenSegments.length > 0 ? brokenSegments : [segmentToBreak];
770
+ }
771
+ /**
772
+ * Measures width of space character using base font
773
+ */
774
+ measureSpaceWidth(ctx) {
775
+ const originalFont = ctx.font;
776
+ ctx.font = this.getFontString();
777
+ const width = ctx.measureText(' ').width;
778
+ ctx.font = originalFont;
779
+ return width > 0 ? width : (this.props.fontSize || 16) * 0.3;
780
+ }
781
+ /**
782
+ * Renders multi-line text content with rich text styling and layout features
783
+ *
784
+ * Core features:
785
+ * - Dynamic line heights with leading/spacing controls
786
+ * - Vertical text alignment (top/middle/bottom)
787
+ * - Horizontal text alignment (left/center/right/justify)
788
+ * - Text wrapping within bounds
789
+ * - Ellipsis truncation
790
+ * - Rich text styling per segment (color, weight, size, etc)
791
+ * - Performance optimizations (clipping, visibility checks)
792
+ *
793
+ * @param ctx - Canvas rendering context
794
+ * @param x - Content box left position in pixels
795
+ * @param y - Content box top position in pixels
796
+ * @param width - Content box total width including padding
797
+ * @param height - Content box total height including padding
798
+ */
799
+ _renderContent(ctx, x, y, width, height) {
800
+ super._renderContent(ctx, x, y, width, height);
801
+ const linesToRender = this.getLinesToMeasureOrRender();
802
+ const numLinesToRender = linesToRender.length;
803
+ // Validate required data is available
804
+ if (numLinesToRender === 0 ||
805
+ this.segments.length === 0 ||
806
+ this.lineHeights.length !== numLinesToRender ||
807
+ this.lineAscents.length !== numLinesToRender ||
808
+ this.lineContentHeights.length !== numLinesToRender) {
809
+ return;
810
+ }
811
+ ctx.save();
812
+ ctx.textBaseline = 'alphabetic';
813
+ ctx.letterSpacing = this.formatSpacing(this.props.letterSpacing);
814
+ ctx.wordSpacing = 'normal';
815
+ const baseFontSize = this.props.fontSize || 16;
816
+ const parsedWordSpacingPx = this.parseSpacingToPx(this.props.wordSpacing, baseFontSize);
817
+ // Calculate content box with padding
818
+ const paddingLeft = this.node.getComputedPadding(Style.Edge.Left) ?? 0;
819
+ const paddingTop = this.node.getComputedPadding(Style.Edge.Top) ?? 0;
820
+ const paddingRight = this.node.getComputedPadding(Style.Edge.Right) ?? 0;
821
+ const paddingBottom = this.node.getComputedPadding(Style.Edge.Bottom) ?? 0;
822
+ const contentX = x + paddingLeft;
823
+ const contentY = y + paddingTop;
824
+ const contentWidth = Math.max(0, width - paddingLeft - paddingRight);
825
+ const contentHeight = Math.max(0, height - paddingTop - paddingBottom);
826
+ if (contentWidth <= 0 || contentHeight <= 0) {
827
+ ctx.restore();
828
+ return;
829
+ }
830
+ // Calculate vertical alignment offset
831
+ const lineGapValue = this.props.lineGap;
832
+ const totalCalculatedTextHeight = this.lineHeights.reduce((sum, h) => sum + h, 0) + Math.max(0, numLinesToRender - 1) * lineGapValue;
833
+ let blockStartY;
834
+ switch (this.props.verticalAlign) {
835
+ case 'middle':
836
+ blockStartY = contentY + (contentHeight - totalCalculatedTextHeight) / 2;
837
+ break;
838
+ case 'bottom':
839
+ blockStartY = contentY + contentHeight - totalCalculatedTextHeight;
840
+ break;
841
+ case 'top':
842
+ default:
843
+ blockStartY = contentY;
844
+ }
845
+ let currentLineTopY = blockStartY;
846
+ // Setup text content clipping region
847
+ ctx.beginPath();
848
+ ctx.rect(contentX, contentY, contentWidth, contentHeight);
849
+ ctx.clip();
850
+ // Configure ellipsis if needed
851
+ const ellipsisChar = typeof this.props.ellipsis === 'string' ? this.props.ellipsis : '...';
852
+ const needsEllipsis = this.props.ellipsis && this.lines.length > numLinesToRender;
853
+ let ellipsisWidth = 0;
854
+ let ellipsisStyle = undefined;
855
+ if (needsEllipsis) {
856
+ const lastRenderedLine = linesToRender[numLinesToRender - 1];
857
+ const lastTextStyleSegment = [...lastRenderedLine].reverse().find(seg => !/^\s+$/.test(seg.text));
858
+ // Inherit styles from last non-whitespace segment
859
+ ellipsisStyle = lastTextStyleSegment
860
+ ? {
861
+ color: lastTextStyleSegment.color,
862
+ weight: lastTextStyleSegment.weight,
863
+ size: lastTextStyleSegment.size,
864
+ b: lastTextStyleSegment.b,
865
+ i: lastTextStyleSegment.i,
866
+ }
867
+ : undefined;
868
+ const originalFont = ctx.font;
869
+ const originalVariant = ctx.fontVariant;
870
+ ctx.font = this.getFontString(ellipsisStyle);
871
+ // Handle font variant setting and validation
872
+ if (typeof this.props.fontVariant === 'string') {
873
+ ctx.fontVariant = this.props.fontVariant;
874
+ }
875
+ else if (this.props.fontVariant !== undefined) {
876
+ console.warn(`[TextNode ${this.key || ''}] Invalid fontVariant prop type in _renderContent (ellipsis measure):`, this.props.fontVariant);
877
+ if (ctx.fontVariant !== 'normal')
878
+ ctx.fontVariant = 'normal';
879
+ }
880
+ else {
881
+ if (ctx.fontVariant !== 'normal')
882
+ ctx.fontVariant = 'normal';
883
+ }
884
+ ellipsisWidth = ctx.measureText(ellipsisChar).width;
885
+ ctx.font = originalFont;
886
+ if (originalVariant !== 'normal') {
887
+ ctx.fontVariant = originalVariant;
888
+ }
889
+ else if (ctx.fontVariant !== 'normal') {
890
+ ctx.fontVariant = 'normal';
891
+ }
892
+ }
893
+ const spaceWidth = this.measureSpaceWidth(ctx);
894
+ // Render text content line by line
895
+ for (let i = 0; i < numLinesToRender; i++) {
896
+ const lineSegments = linesToRender[i];
897
+ const currentLineFinalHeight = this.lineHeights[i];
898
+ const currentLineMaxAscent = this.lineAscents[i];
899
+ const currentLineContentHeight = this.lineContentHeights[i];
900
+ // Calculate line spacing metrics
901
+ const currentLineLeading = currentLineFinalHeight - currentLineContentHeight;
902
+ const currentLineSpaceAbove = Math.max(0, currentLineLeading / 2);
903
+ const lineY = currentLineTopY + currentLineSpaceAbove + currentLineMaxAscent;
904
+ // Visibility culling check
905
+ const lineTop = currentLineTopY;
906
+ const lineBottom = currentLineTopY + currentLineFinalHeight;
907
+ // Don't skip empty lines - they're intentional (from \n\n)
908
+ // Only skip if the line is completely outside the visible area
909
+ if (lineBottom <= contentY || lineTop >= contentY + contentHeight) {
910
+ currentLineTopY += currentLineFinalHeight + lineGapValue;
911
+ continue;
912
+ }
913
+ const isLastRenderedLine = i === numLinesToRender - 1;
914
+ // Calculate line width metrics for alignment
915
+ let totalLineWidth = 0;
916
+ let totalWordsWidth = 0;
917
+ let numWordGaps = 0;
918
+ let firstWordOnLine = true;
919
+ const noSpaceBeforePunctuation = /^[.,!?;:)\]}]/;
920
+ for (const segment of lineSegments) {
921
+ const segmentWidth = segment.width ?? 0;
922
+ const isSpaceSegment = /^\s+$/.test(segment.text);
923
+ if (!isSpaceSegment) {
924
+ if (!firstWordOnLine) {
925
+ totalLineWidth += spaceWidth + parsedWordSpacingPx;
926
+ if (!noSpaceBeforePunctuation.test(segment.text)) {
927
+ numWordGaps++;
928
+ }
929
+ }
930
+ totalLineWidth += segmentWidth;
931
+ totalWordsWidth += segmentWidth;
932
+ firstWordOnLine = false;
933
+ }
934
+ }
935
+ // Calculate horizontal alignment position
936
+ const isJustify = this.props.textAlign === 'justify' && !isLastRenderedLine;
937
+ const lineTextAlign = isJustify ? 'left' : this.props.textAlign || 'left';
938
+ let currentX;
939
+ switch (lineTextAlign) {
940
+ case 'center':
941
+ currentX = contentX + (contentWidth - totalLineWidth) / 2;
942
+ break;
943
+ case 'right':
944
+ case 'end':
945
+ currentX = contentX + contentWidth - totalLineWidth;
946
+ break;
947
+ case 'left':
948
+ case 'start':
949
+ default:
950
+ currentX = contentX;
951
+ }
952
+ currentX = Math.max(contentX, currentX);
953
+ // Calculate justification spacing
954
+ let spacePerWordGapPlusSpacing = spaceWidth + parsedWordSpacingPx;
955
+ if (isJustify && numWordGaps > 0 && totalLineWidth < contentWidth) {
956
+ const totalBaseSpacingWidth = numWordGaps * (spaceWidth + parsedWordSpacingPx);
957
+ const remainingSpace = contentWidth - totalWordsWidth - totalBaseSpacingWidth;
958
+ if (remainingSpace > 0) {
959
+ spacePerWordGapPlusSpacing += remainingSpace / numWordGaps;
960
+ }
961
+ }
962
+ // Render line segments (skip rendering for truly empty lines)
963
+ if (lineSegments.length > 0 && !lineSegments.every(s => s.text.trim() === '')) {
964
+ let accumulatedWidth = 0;
965
+ let ellipsisApplied = false;
966
+ let firstWordDrawn = false;
967
+ for (let j = 0; j < lineSegments.length; j++) {
968
+ const segment = lineSegments[j];
969
+ const segmentWidth = segment.width ?? 0;
970
+ const isLastSegmentOnLine = j === lineSegments.length - 1;
971
+ const isSpaceSegment = /^\s+$/.test(segment.text);
972
+ // Calculate word spacing
973
+ let spaceToAddBefore = 0;
974
+ if (!isSpaceSegment && firstWordDrawn && !noSpaceBeforePunctuation.test(segment.text)) {
975
+ spaceToAddBefore = isJustify ? spacePerWordGapPlusSpacing : spaceWidth + parsedWordSpacingPx;
976
+ }
977
+ // Apply segment styles
978
+ ctx.font = this.getFontString(segment);
979
+ ctx.fillStyle = segment.color || this.props.color || 'black';
980
+ if (typeof this.props.fontVariant === 'string') {
981
+ ctx.fontVariant = this.props.fontVariant;
982
+ }
983
+ else if (this.props.fontVariant !== undefined) {
984
+ console.warn(`[TextNode ${this.key || ''}] Invalid fontVariant prop type in _renderContent (segment render):`, this.props.fontVariant);
985
+ if (ctx.fontVariant !== 'normal')
986
+ ctx.fontVariant = 'normal';
987
+ }
988
+ else {
989
+ if (ctx.fontVariant !== 'normal')
990
+ ctx.fontVariant = 'normal';
991
+ }
992
+ // Handle text truncation and ellipsis
993
+ let textToDraw = segment.text;
994
+ let currentSegmentRenderWidth = segmentWidth;
995
+ let applyEllipsisAfter = false;
996
+ if (isLastRenderedLine && needsEllipsis && !isSpaceSegment) {
997
+ const currentTotalWidth = accumulatedWidth + spaceToAddBefore + segmentWidth;
998
+ const spaceNeededAfter = isLastSegmentOnLine
999
+ ? 0
1000
+ : isJustify
1001
+ ? spacePerWordGapPlusSpacing
1002
+ : spaceWidth + parsedWordSpacingPx;
1003
+ if (currentTotalWidth > contentWidth - spaceNeededAfter) {
1004
+ const availableWidthForSegment = contentWidth - accumulatedWidth - spaceToAddBefore - ellipsisWidth;
1005
+ if (availableWidthForSegment > 0) {
1006
+ let truncatedText = '';
1007
+ for (const char of segment.text) {
1008
+ if (ctx.measureText(truncatedText + char).width <= availableWidthForSegment) {
1009
+ truncatedText += char;
1010
+ }
1011
+ else {
1012
+ break;
1013
+ }
1014
+ }
1015
+ textToDraw = truncatedText;
1016
+ currentSegmentRenderWidth = ctx.measureText(textToDraw).width;
1017
+ }
1018
+ else {
1019
+ textToDraw = '';
1020
+ currentSegmentRenderWidth = 0;
1021
+ }
1022
+ applyEllipsisAfter = true;
1023
+ ellipsisApplied = true;
1024
+ }
1025
+ else if (isLastSegmentOnLine) {
1026
+ applyEllipsisAfter = true;
1027
+ ellipsisApplied = true;
1028
+ }
1029
+ }
1030
+ // Render text segment
1031
+ currentX += spaceToAddBefore;
1032
+ accumulatedWidth += spaceToAddBefore;
1033
+ const remainingRenderWidth = contentX + contentWidth - currentX;
1034
+ if (currentSegmentRenderWidth > 0 && remainingRenderWidth > 0 && !isSpaceSegment) {
1035
+ ctx.textAlign = 'left';
1036
+ const shadows = this.props.textShadow
1037
+ ? Array.isArray(this.props.textShadow)
1038
+ ? this.props.textShadow
1039
+ : [this.props.textShadow]
1040
+ : [];
1041
+ ctx.save();
1042
+ // Draw shadows
1043
+ for (const shadow of shadows) {
1044
+ ctx.shadowColor = shadow.color || 'transparent';
1045
+ ctx.shadowBlur = shadow.blur || 0;
1046
+ ctx.shadowOffsetX = shadow.offsetX || 0;
1047
+ ctx.shadowOffsetY = shadow.offsetY || 0;
1048
+ ctx.fillText(textToDraw, currentX, lineY, Math.max(0, remainingRenderWidth + 1));
1049
+ }
1050
+ // Reset shadow to draw the main text
1051
+ ctx.shadowColor = 'transparent';
1052
+ ctx.shadowBlur = 0;
1053
+ ctx.shadowOffsetX = 0;
1054
+ ctx.shadowOffsetY = 0;
1055
+ ctx.fillText(textToDraw, currentX, lineY, Math.max(0, remainingRenderWidth + 1));
1056
+ ctx.restore();
1057
+ firstWordDrawn = true;
1058
+ }
1059
+ currentX += currentSegmentRenderWidth;
1060
+ accumulatedWidth += currentSegmentRenderWidth;
1061
+ // Render ellipsis
1062
+ if (applyEllipsisAfter) {
1063
+ const ellipsisRemainingWidth = contentX + contentWidth - currentX;
1064
+ if (ellipsisRemainingWidth >= ellipsisWidth) {
1065
+ const originalFont = ctx.font;
1066
+ const originalVariant = ctx.fontVariant;
1067
+ const originalFill = ctx.fillStyle;
1068
+ ctx.font = this.getFontString(ellipsisStyle);
1069
+ if (typeof this.props.fontVariant === 'string') {
1070
+ ctx.fontVariant = this.props.fontVariant;
1071
+ }
1072
+ else if (this.props.fontVariant !== undefined) {
1073
+ console.warn(`[TextNode ${this.key || ''}] Invalid fontVariant prop type in _renderContent (ellipsis draw):`, this.props.fontVariant);
1074
+ if (ctx.fontVariant !== 'normal')
1075
+ ctx.fontVariant = 'normal';
1076
+ }
1077
+ else {
1078
+ if (ctx.fontVariant !== 'normal')
1079
+ ctx.fontVariant = 'normal';
1080
+ }
1081
+ ctx.fillStyle = ellipsisStyle?.color || this.props.color || 'black';
1082
+ ctx.fillText(ellipsisChar, currentX, lineY, Math.max(0, ellipsisRemainingWidth + 1));
1083
+ ctx.font = originalFont;
1084
+ if (originalVariant !== 'normal') {
1085
+ ctx.fontVariant = originalVariant;
1086
+ }
1087
+ else if (ctx.fontVariant !== 'normal') {
1088
+ ctx.fontVariant = 'normal';
1089
+ }
1090
+ ctx.fillStyle = originalFill;
1091
+ }
1092
+ break;
1093
+ }
1094
+ if (ellipsisApplied && currentX >= contentX + contentWidth)
1095
+ break;
1096
+ }
1097
+ }
1098
+ currentLineTopY += currentLineFinalHeight + lineGapValue;
1099
+ }
1100
+ ctx.restore();
1101
+ }
1102
+ }
1103
+ /**
1104
+ * Creates a new TextNode instance with rich text support
1105
+ */
1106
+ const Text = (text, props) => new TextNode(text, props);
1107
+
1108
+ export { Text, TextNode };