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