@nasser-sw/fabric 7.0.1-beta16 → 7.0.1-beta17

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 (95) hide show
  1. package/.claude/settings.local.json +7 -0
  2. package/dist/index.js +1982 -649
  3. package/dist/index.js.map +1 -1
  4. package/dist/index.min.js +1 -1
  5. package/dist/index.min.js.map +1 -1
  6. package/dist/index.min.mjs +1 -1
  7. package/dist/index.min.mjs.map +1 -1
  8. package/dist/index.mjs +1982 -649
  9. package/dist/index.mjs.map +1 -1
  10. package/dist/index.node.cjs +1982 -649
  11. package/dist/index.node.cjs.map +1 -1
  12. package/dist/index.node.mjs +1982 -649
  13. package/dist/index.node.mjs.map +1 -1
  14. package/dist/package.json.min.mjs +1 -1
  15. package/dist/package.json.mjs +1 -1
  16. package/dist/src/shapes/IText/IText.d.ts +31 -6
  17. package/dist/src/shapes/IText/IText.d.ts.map +1 -1
  18. package/dist/src/shapes/IText/IText.min.mjs +1 -1
  19. package/dist/src/shapes/IText/IText.min.mjs.map +1 -1
  20. package/dist/src/shapes/IText/IText.mjs +495 -126
  21. package/dist/src/shapes/IText/IText.mjs.map +1 -1
  22. package/dist/src/shapes/IText/ITextBehavior.d.ts +12 -0
  23. package/dist/src/shapes/IText/ITextBehavior.d.ts.map +1 -1
  24. package/dist/src/shapes/IText/ITextBehavior.min.mjs +1 -1
  25. package/dist/src/shapes/IText/ITextBehavior.min.mjs.map +1 -1
  26. package/dist/src/shapes/IText/ITextBehavior.mjs +127 -36
  27. package/dist/src/shapes/IText/ITextBehavior.mjs.map +1 -1
  28. package/dist/src/shapes/IText/ITextClickBehavior.d.ts.map +1 -1
  29. package/dist/src/shapes/IText/ITextClickBehavior.min.mjs +1 -1
  30. package/dist/src/shapes/IText/ITextClickBehavior.min.mjs.map +1 -1
  31. package/dist/src/shapes/IText/ITextClickBehavior.mjs +21 -4
  32. package/dist/src/shapes/IText/ITextClickBehavior.mjs.map +1 -1
  33. package/dist/src/shapes/IText/ITextKeyBehavior.min.mjs +1 -1
  34. package/dist/src/shapes/IText/ITextKeyBehavior.min.mjs.map +1 -1
  35. package/dist/src/shapes/IText/ITextKeyBehavior.mjs +17 -21
  36. package/dist/src/shapes/IText/ITextKeyBehavior.mjs.map +1 -1
  37. package/dist/src/shapes/Text/Text.d.ts +69 -1
  38. package/dist/src/shapes/Text/Text.d.ts.map +1 -1
  39. package/dist/src/shapes/Text/Text.min.mjs +1 -1
  40. package/dist/src/shapes/Text/Text.min.mjs.map +1 -1
  41. package/dist/src/shapes/Text/Text.mjs +374 -60
  42. package/dist/src/shapes/Text/Text.mjs.map +1 -1
  43. package/dist/src/shapes/Text/constants.d.ts.map +1 -1
  44. package/dist/src/shapes/Text/constants.min.mjs +1 -1
  45. package/dist/src/shapes/Text/constants.min.mjs.map +1 -1
  46. package/dist/src/shapes/Text/constants.mjs +2 -1
  47. package/dist/src/shapes/Text/constants.mjs.map +1 -1
  48. package/dist/src/shapes/Textbox.d.ts +8 -1
  49. package/dist/src/shapes/Textbox.d.ts.map +1 -1
  50. package/dist/src/shapes/Textbox.min.mjs +1 -1
  51. package/dist/src/shapes/Textbox.min.mjs.map +1 -1
  52. package/dist/src/shapes/Textbox.mjs +406 -63
  53. package/dist/src/shapes/Textbox.mjs.map +1 -1
  54. package/dist/src/text/hitTest.min.mjs +1 -1
  55. package/dist/src/text/hitTest.min.mjs.map +1 -1
  56. package/dist/src/text/hitTest.mjs +1 -198
  57. package/dist/src/text/hitTest.mjs.map +1 -1
  58. package/dist/src/text/layout.min.mjs +1 -1
  59. package/dist/src/text/layout.min.mjs.map +1 -1
  60. package/dist/src/text/layout.mjs +122 -5
  61. package/dist/src/text/layout.mjs.map +1 -1
  62. package/dist/src/text/overlayEditor.min.mjs +1 -1
  63. package/dist/src/text/overlayEditor.min.mjs.map +1 -1
  64. package/dist/src/text/overlayEditor.mjs +132 -142
  65. package/dist/src/text/overlayEditor.mjs.map +1 -1
  66. package/dist/src/text/unicode.d.ts +28 -0
  67. package/dist/src/text/unicode.d.ts.map +1 -1
  68. package/dist/src/text/unicode.min.mjs +1 -1
  69. package/dist/src/text/unicode.min.mjs.map +1 -1
  70. package/dist/src/text/unicode.mjs +294 -1
  71. package/dist/src/text/unicode.mjs.map +1 -1
  72. package/dist-extensions/src/shapes/IText/IText.d.ts +31 -6
  73. package/dist-extensions/src/shapes/IText/IText.d.ts.map +1 -1
  74. package/dist-extensions/src/shapes/IText/ITextBehavior.d.ts +12 -0
  75. package/dist-extensions/src/shapes/IText/ITextBehavior.d.ts.map +1 -1
  76. package/dist-extensions/src/shapes/IText/ITextClickBehavior.d.ts.map +1 -1
  77. package/dist-extensions/src/shapes/Text/Text.d.ts +69 -1
  78. package/dist-extensions/src/shapes/Text/Text.d.ts.map +1 -1
  79. package/dist-extensions/src/shapes/Text/constants.d.ts.map +1 -1
  80. package/dist-extensions/src/shapes/Textbox.d.ts +8 -1
  81. package/dist-extensions/src/shapes/Textbox.d.ts.map +1 -1
  82. package/dist-extensions/src/text/unicode.d.ts +28 -0
  83. package/dist-extensions/src/text/unicode.d.ts.map +1 -1
  84. package/package.json +164 -164
  85. package/rtl-debug.html +358 -200
  86. package/src/shapes/IText/IText.ts +524 -110
  87. package/src/shapes/IText/ITextBehavior.ts +174 -80
  88. package/src/shapes/IText/ITextClickBehavior.ts +20 -6
  89. package/src/shapes/IText/ITextKeyBehavior.ts +15 -15
  90. package/src/shapes/Text/Text.ts +488 -107
  91. package/src/shapes/Text/constants.ts +4 -2
  92. package/src/shapes/Textbox.ts +414 -65
  93. package/src/text/layout.ts +150 -23
  94. package/src/text/overlayEditor.ts +148 -148
  95. package/src/text/unicode.ts +177 -2
@@ -29,10 +29,11 @@ export const textLayoutProperties: string[] = [
29
29
  'pathSide',
30
30
  'pathAlign',
31
31
  'wrap',
32
- 'ellipsis',
32
+ 'ellipsis',
33
33
  'letterSpacing',
34
34
  'enableAdvancedLayout',
35
35
  'verticalAlign',
36
+ 'kashida',
36
37
  ];
37
38
 
38
39
  export const additionalProps = [
@@ -104,7 +105,8 @@ export const textDefaultValues: Partial<TClassProperties<FabricText>> = {
104
105
  letterSpacing: 0,
105
106
  enableAdvancedLayout: false,
106
107
  verticalAlign: 'top' as const,
107
-
108
+ kashida: 'none' as const,
109
+
108
110
  // Overlay editor properties
109
111
  useOverlayEditing: false,
110
112
 
@@ -10,6 +10,7 @@ import type { TextLinesInfo } from './Text/Text';
10
10
  import type { Control } from '../controls/Control';
11
11
  import { fontLacksEnglishGlyphsCached } from '../text/measure';
12
12
  import { layoutText } from '../text/layout';
13
+ import { findKashidaPoints, ARABIC_TATWEEL } from '../text/unicode';
13
14
 
14
15
  // @TODO: Many things here are configuration related and shouldn't be on the class nor prototype
15
16
  // regexes, list of properties that are not suppose to change by instances, magic consts.
@@ -137,7 +138,7 @@ export class Textbox<
137
138
  }
138
139
 
139
140
  // Skip if nothing changed
140
- const currentState = `${this.text}|${this.width}|${this.fontSize}|${this.fontFamily}|${this.textAlign}`;
141
+ const currentState = `${this.text}|${this.width}|${this.fontSize}|${this.fontFamily}|${this.textAlign}|${this.kashida}`;
141
142
  if (
142
143
  (this as any)._lastDimensionState === currentState &&
143
144
  this._textLines &&
@@ -255,12 +256,20 @@ export class Textbox<
255
256
  }
256
257
 
257
258
  // Use new layout engine
259
+ // When kashida is enabled, don't let layout engine apply justify - we'll handle it with kashida
260
+ const useKashidaJustify = this.kashida !== 'none' && this.textAlign.includes(JUSTIFY);
261
+ const effectiveAlign = useKashidaJustify
262
+ ? (this.direction === 'rtl' ? 'right' : 'left') // Natural alignment, kashida will justify
263
+ : (this as any)._mapTextAlignToAlign(this.textAlign);
264
+
258
265
  const layout = layoutText({
259
266
  text: this.text,
260
267
  width: this.width,
261
- height: this.height,
268
+ // Don't pass height constraint to allow vertical auto-expansion
269
+ // Only pass height if explicitly set to constrain (e.g., for ellipsis)
270
+ height: this.ellipsis ? this.height : undefined,
262
271
  wrap: this.wrap || 'word',
263
- align: (this as any)._mapTextAlignToAlign(this.textAlign),
272
+ align: effectiveAlign,
264
273
  ellipsis: this.ellipsis || false,
265
274
  fontSize: this.fontSize,
266
275
  lineHeight: this.lineHeight,
@@ -299,9 +308,275 @@ export class Textbox<
299
308
 
300
309
  // Generate style map for compatibility
301
310
  this._styleMap = this._generateStyleMapFromLayout(layout);
311
+
312
+ // Apply kashida for justified text in advanced layout mode
313
+ if (this.textAlign.includes(JUSTIFY) && this.kashida !== 'none') {
314
+ this._applyKashidaToLayout();
315
+ }
316
+
302
317
  this.dirty = true;
303
318
  }
304
319
 
320
+ /**
321
+ * Apply kashida (tatweel) characters to layout for Arabic text justification.
322
+ * This method INSERTS actual tatweel characters into the text lines.
323
+ * @private
324
+ */
325
+ _applyKashidaToLayout() {
326
+ if (!this._textLines || !this.__charBounds) {
327
+ return;
328
+ }
329
+
330
+ // Clear visual positions cache - it becomes stale when kashida is applied
331
+ // Check if cache exists (it's initialized in IText constructor which runs after this during construction)
332
+ if ((this as any)._visualPositionsCache) {
333
+ this._clearVisualPositionsCache();
334
+ }
335
+
336
+ const kashidaRatios: Record<string, number> = {
337
+ none: 0,
338
+ short: 0.25,
339
+ medium: 0.5,
340
+ long: 0.75,
341
+ stylistic: 1.0,
342
+ };
343
+ const kashidaRatio = kashidaRatios[this.kashida] || 0;
344
+
345
+ if (kashidaRatio === 0) {
346
+ return;
347
+ }
348
+
349
+ // Calculate tatweel width once
350
+ const canvas = document.createElement('canvas');
351
+ const ctx = canvas.getContext('2d');
352
+ if (!ctx) {
353
+ return;
354
+ }
355
+ ctx.font = this._getFontDeclaration();
356
+ const tatweelWidth = ctx.measureText(ARABIC_TATWEEL).width;
357
+
358
+ if (tatweelWidth <= 0) {
359
+ return;
360
+ }
361
+
362
+ // Reset kashida info
363
+ this.__kashidaInfo = [];
364
+
365
+ const totalLines = this._textLines.length;
366
+
367
+ for (let lineIndex = 0; lineIndex < totalLines; lineIndex++) {
368
+ this.__kashidaInfo[lineIndex] = [];
369
+ const line = this._textLines[lineIndex];
370
+
371
+ if (!this.__charBounds || !this.__charBounds[lineIndex]) {
372
+ continue;
373
+ }
374
+
375
+ // Don't apply kashida to the last line
376
+ const isLastLine = lineIndex === totalLines - 1;
377
+ if (isLastLine) {
378
+ continue;
379
+ }
380
+
381
+ const lineBounds = this.__charBounds[lineIndex];
382
+ const lastBound = lineBounds[lineBounds.length - 1];
383
+
384
+ // Calculate current line width
385
+ const currentLineWidth = lastBound ? (lastBound.left + lastBound.kernedWidth) : 0;
386
+ const totalExtraSpace = this.width - currentLineWidth;
387
+
388
+ // Only apply kashida if there's significant extra space to fill
389
+ if (totalExtraSpace <= 2) {
390
+ continue;
391
+ }
392
+
393
+ // Find kashida points
394
+ const kashidaPoints = findKashidaPoints(line);
395
+ if (kashidaPoints.length === 0) {
396
+ continue;
397
+ }
398
+
399
+ // Calculate kashida space
400
+ const kashidaSpace = totalExtraSpace * kashidaRatio;
401
+
402
+ // Calculate how many tatweels can fit
403
+ const totalTatweels = Math.floor(kashidaSpace / tatweelWidth);
404
+ if (totalTatweels === 0) {
405
+ continue;
406
+ }
407
+
408
+ // Limit kashida points
409
+ const maxKashidaPoints = Math.min(kashidaPoints.length, totalTatweels);
410
+ const usedKashidaPoints = kashidaPoints.slice(0, maxKashidaPoints);
411
+
412
+ // Distribute tatweels evenly
413
+ const tatweelsPerPoint = Math.floor(totalTatweels / maxKashidaPoints);
414
+ const extraTatweels = totalTatweels % maxKashidaPoints;
415
+
416
+ // console.log(`=== Inserting Kashida into line ${lineIndex} ===`);
417
+ // console.log(` totalTatweels: ${totalTatweels}, usedPoints: ${usedKashidaPoints.length}`);
418
+
419
+ // Sort by charIndex descending so we insert from the end (prevents index shifting issues)
420
+ const sortedPoints = [...usedKashidaPoints].sort((a, b) => b.charIndex - a.charIndex);
421
+
422
+ // Create new line with tatweels inserted
423
+ const newLine = [...line];
424
+ for (let i = 0; i < sortedPoints.length; i++) {
425
+ const point = sortedPoints[i];
426
+ const originalIndex = usedKashidaPoints.indexOf(point);
427
+ const count = tatweelsPerPoint + (originalIndex < extraTatweels ? 1 : 0);
428
+
429
+ if (count > 0) {
430
+ // Insert tatweels AFTER the character at charIndex
431
+ const tatweels = Array(count).fill(ARABIC_TATWEEL);
432
+ newLine.splice(point.charIndex + 1, 0, ...tatweels);
433
+ // console.log(` Inserted ${count} tatweels after char ${point.charIndex}`);
434
+
435
+ // Store kashida info for index conversion
436
+ this.__kashidaInfo[lineIndex].push({
437
+ charIndex: point.charIndex,
438
+ width: count * tatweelWidth,
439
+ tatweelCount: count,
440
+ });
441
+ }
442
+ }
443
+
444
+ // Update _textLines with the new line containing tatweels
445
+ this._textLines[lineIndex] = newLine;
446
+
447
+ // Update textLines (string version)
448
+ if (this.textLines) {
449
+ (this as any).textLines[lineIndex] = newLine.join('');
450
+ }
451
+
452
+ // Clear and recalculate charBounds for this line
453
+ this.__charBounds[lineIndex] = [];
454
+ this.__lineWidths[lineIndex] = undefined as any;
455
+ this._measureLine(lineIndex);
456
+
457
+ // Now expand spaces to fill any remaining gap
458
+ let newLineBounds = this.__charBounds[lineIndex];
459
+ if (newLineBounds && newLineBounds.length > 0) {
460
+ let newLastBound = newLineBounds[newLineBounds.length - 1];
461
+ let newLineWidth = newLastBound ? (newLastBound.left + newLastBound.kernedWidth) : 0;
462
+ let remainingGap = this.width - newLineWidth;
463
+
464
+ if (remainingGap > 0.5) {
465
+ // Count spaces in the new line
466
+ let spaceCount = 0;
467
+ for (let i = 0; i < newLine.length; i++) {
468
+ if (/\s/.test(newLine[i])) {
469
+ spaceCount++;
470
+ }
471
+ }
472
+
473
+ if (spaceCount > 0) {
474
+ const extraPerSpace = remainingGap / spaceCount;
475
+ let accumulatedExtra = 0;
476
+
477
+ // Expand space widths AND update left positions for subsequent chars
478
+ for (let i = 0; i < newLineBounds.length; i++) {
479
+ const bound = newLineBounds[i];
480
+ if (!bound) continue;
481
+
482
+ // Update left position to account for previous space expansions
483
+ bound.left += accumulatedExtra;
484
+
485
+ // If this is a space, expand it
486
+ if (/\s/.test(newLine[i])) {
487
+ bound.width += extraPerSpace;
488
+ bound.kernedWidth += extraPerSpace;
489
+ accumulatedExtra += extraPerSpace;
490
+ }
491
+ }
492
+ // Update the extra entry at the end (cursor position)
493
+ if (newLineBounds[newLine.length]) {
494
+ newLineBounds[newLine.length].left += accumulatedExtra;
495
+ }
496
+
497
+ // Recalculate remaining gap after space expansion
498
+ newLastBound = newLineBounds[newLineBounds.length - 1];
499
+ newLineWidth = newLastBound ? (newLastBound.left + newLastBound.kernedWidth) : 0;
500
+ remainingGap = this.width - newLineWidth;
501
+ }
502
+ }
503
+
504
+ // If there's still a gap after space expansion, distribute it across all kashida points
505
+ if (remainingGap > 0.5 && this.__kashidaInfo[lineIndex].length > 0) {
506
+ const kashidaPointCount = this.__kashidaInfo[lineIndex].length;
507
+ const extraPerKashida = remainingGap / kashidaPointCount;
508
+
509
+ // Find kashida positions in newLine and expand their widths
510
+ let kashidaIndex = 0;
511
+ let accumulatedExtra = 0;
512
+
513
+ for (let i = 0; i < newLineBounds.length; i++) {
514
+ const bound = newLineBounds[i];
515
+ if (!bound) continue;
516
+
517
+ // Update left position for accumulated expansion
518
+ bound.left += accumulatedExtra;
519
+
520
+ // Check if this is a tatweel character
521
+ if (newLine[i] === ARABIC_TATWEEL) {
522
+ // Distribute extra width among tatweels
523
+ const extraForThis = extraPerKashida / (this.__kashidaInfo[lineIndex][kashidaIndex]?.tatweelCount || 1);
524
+ bound.width += extraForThis;
525
+ bound.kernedWidth += extraForThis;
526
+ accumulatedExtra += extraForThis;
527
+
528
+ // Move to next kashida info when we've passed this group
529
+ const currentKashidaInfo = this.__kashidaInfo[lineIndex][kashidaIndex];
530
+ if (currentKashidaInfo && i > 0) {
531
+ // Check if next char is not tatweel - means we're done with this group
532
+ if (i + 1 >= newLine.length || newLine[i + 1] !== ARABIC_TATWEEL) {
533
+ kashidaIndex++;
534
+ }
535
+ }
536
+ }
537
+ }
538
+
539
+ // Update the extra entry at the end
540
+ if (newLineBounds[newLine.length]) {
541
+ newLineBounds[newLine.length].left += accumulatedExtra;
542
+ }
543
+ }
544
+ }
545
+
546
+ // Set line width to textbox width (for justified lines)
547
+ this.__lineWidths[lineIndex] = this.width;
548
+
549
+ // console.log(` New line length: ${newLine.length}, text: ${newLine.join('')}`);
550
+ }
551
+
552
+ // For justified lines with kashida, line width should equal textbox width
553
+ // Only set undefined widths (non-justified lines without kashida)
554
+ for (let i = 0; i < this._textLines.length; i++) {
555
+ if (this.__lineWidths[i] === undefined && this.__charBounds[i]) {
556
+ const bounds = this.__charBounds[i];
557
+ const lastBound = bounds[bounds.length - 1];
558
+ if (lastBound) {
559
+ this.__lineWidths[i] = lastBound.left + lastBound.kernedWidth;
560
+ }
561
+ }
562
+ }
563
+
564
+ // Update _text to match the new _textLines (required for editing)
565
+ this._text = this._textLines.flat();
566
+
567
+ // DON'T update this.text - keep the original text intact
568
+ // The tatweels are in _textLines and _text for rendering purposes only
569
+
570
+ (this as any)._justifyApplied = true;
571
+
572
+ // Debug log final kashida state
573
+ // console.log('=== _applyKashidaToLayout END ===');
574
+ // console.log('Final __kashidaInfo:', JSON.stringify(this.__kashidaInfo.map((lineInfo, i) => ({
575
+ // line: i,
576
+ // entries: lineInfo.map(k => ({ charIndex: k.charIndex, tatweelCount: k.tatweelCount }))
577
+ // }))));
578
+ }
579
+
305
580
  /**
306
581
  * Generate style map from new layout format
307
582
  * @private
@@ -876,9 +1151,9 @@ export class Textbox<
876
1151
  * @private
877
1152
  */
878
1153
  _extractJustifySpaceMeasurements(element: HTMLElement, lines: string[]) {
879
- console.log('=== _extractJustifySpaceMeasurements START ===');
880
- console.log('Textbox width:', this.width);
881
- console.log('Lines count:', lines.length);
1154
+ // console.log('=== _extractJustifySpaceMeasurements START ===');
1155
+ // console.log('Textbox width:', this.width);
1156
+ // console.log('Lines count:', lines.length);
882
1157
 
883
1158
  const measureCtx =
884
1159
  (this as any)._browserMeasureCtx ||
@@ -886,13 +1161,13 @@ export class Textbox<
886
1161
  .createElement('canvas')
887
1162
  .getContext('2d'));
888
1163
  if (!measureCtx) {
889
- console.log('ERROR: No measure context');
1164
+ // console.log('ERROR: No measure context');
890
1165
  return [];
891
1166
  }
892
1167
  measureCtx.font = `${this.fontStyle || 'normal'} ${this.fontWeight || 'normal'} ${this.fontSize}px "${this.fontFamily}"`;
893
1168
  const normalSpaceWidth = measureCtx.measureText(' ').width || 6;
894
- console.log('Font:', measureCtx.font);
895
- console.log('Normal space width:', normalSpaceWidth);
1169
+ // console.log('Font:', measureCtx.font);
1170
+ // console.log('Normal space width:', normalSpaceWidth);
896
1171
 
897
1172
  const spaceWidths: number[][] = [];
898
1173
 
@@ -901,7 +1176,7 @@ export class Textbox<
901
1176
  const spaceCount = (line.match(/\s/g) || []).length;
902
1177
  const isLastLine = lineIndex === lines.length - 1;
903
1178
 
904
- console.log(`\nLine ${lineIndex}: "${line.substring(0, 50)}..." spaces: ${spaceCount}, isLast: ${isLastLine}`);
1179
+ // console.log(`\nLine ${lineIndex}: "${line.substring(0, 50)}..." spaces: ${spaceCount}, isLast: ${isLastLine}`);
905
1180
 
906
1181
  if (spaceCount > 0 && !isLastLine) {
907
1182
  // Don't justify last line
@@ -910,8 +1185,8 @@ export class Textbox<
910
1185
  const extraPerSpace = remainingSpace > 0 ? remainingSpace / spaceCount : 0;
911
1186
  const expandedSpaceWidth = normalSpaceWidth + extraPerSpace;
912
1187
 
913
- console.log(` Natural width: ${naturalWidth.toFixed(2)}, Remaining: ${remainingSpace.toFixed(2)}`);
914
- console.log(` Extra per space: ${extraPerSpace.toFixed(2)}, Expanded space: ${expandedSpaceWidth.toFixed(2)}`);
1188
+ // console.log(` Natural width: ${naturalWidth.toFixed(2)}, Remaining: ${remainingSpace.toFixed(2)}`);
1189
+ // console.log(` Extra per space: ${extraPerSpace.toFixed(2)}, Expanded space: ${expandedSpaceWidth.toFixed(2)}`);
915
1190
 
916
1191
  const safeWidth = Math.max(normalSpaceWidth, expandedSpaceWidth);
917
1192
  for (let i = 0; i < spaceCount; i++) {
@@ -919,7 +1194,7 @@ export class Textbox<
919
1194
  }
920
1195
  } else if (spaceCount > 0) {
921
1196
  // Last line: keep natural space width
922
- console.log(` Last line - using normal space width: ${normalSpaceWidth}`);
1197
+ // console.log(` Last line - using normal space width: ${normalSpaceWidth}`);
923
1198
  for (let i = 0; i < spaceCount; i++) {
924
1199
  lineSpaces.push(normalSpaceWidth);
925
1200
  }
@@ -928,42 +1203,47 @@ export class Textbox<
928
1203
  spaceWidths.push(lineSpaces);
929
1204
  });
930
1205
 
931
- console.log('\nFinal spaceWidths:', spaceWidths);
932
- console.log('=== _extractJustifySpaceMeasurements END ===\n');
1206
+ // console.log('\nFinal spaceWidths:', spaceWidths);
1207
+ // console.log('=== _extractJustifySpaceMeasurements END ===\n');
933
1208
  return spaceWidths;
934
1209
  }
935
1210
 
936
1211
  /**
937
- * Apply justify space expansion using actual charBounds measurements
1212
+ * Apply justify space expansion using actual charBounds measurements.
1213
+ * Supports Arabic kashida (tatweel) justification when kashida property is set.
938
1214
  * @private
939
1215
  */
940
1216
  _applyBrowserJustifySpaces() {
941
- console.log('=== _applyBrowserJustifySpaces START ===');
942
- console.log('_textLines:', this._textLines?.length, 'lines');
943
- console.log('__charBounds:', this.__charBounds?.length, 'lines');
944
- console.log('textbox width:', this.width);
945
-
946
1217
  if (!this._textLines || !this.__charBounds) {
947
- console.log('EARLY RETURN: _textLines or __charBounds missing');
948
1218
  return;
949
1219
  }
950
1220
 
1221
+ // Kashida ratios: proportion of extra space distributed via kashida vs space expansion
1222
+ const kashidaRatios: Record<string, number> = {
1223
+ none: 0,
1224
+ short: 0.25,
1225
+ medium: 0.5,
1226
+ long: 0.75,
1227
+ stylistic: 1.0,
1228
+ };
1229
+ const kashidaRatio = kashidaRatios[this.kashida] || 0;
1230
+
1231
+ // Reset kashida info
1232
+ this.__kashidaInfo = [];
1233
+
951
1234
  const totalLines = this._textLines.length;
952
1235
 
953
1236
  this._textLines.forEach((line, lineIndex) => {
954
- const lineText = line.join('');
955
- const isLastLine = lineIndex === totalLines - 1;
956
-
957
- console.log(`\n--- Line ${lineIndex}: "${lineText}" isLast: ${isLastLine} ---`);
1237
+ // Initialize kashida info for this line
1238
+ this.__kashidaInfo[lineIndex] = [];
958
1239
 
959
1240
  if (!this.__charBounds || !this.__charBounds[lineIndex]) {
960
- console.log(' SKIP: No charBounds for this line');
961
1241
  return;
962
1242
  }
963
1243
 
964
1244
  // Don't justify the last line
1245
+ const isLastLine = lineIndex === totalLines - 1;
965
1246
  if (isLastLine) {
966
- console.log(' SKIP: Last line - no justify');
967
1247
  return;
968
1248
  }
969
1249
 
@@ -971,7 +1251,11 @@ export class Textbox<
971
1251
 
972
1252
  // Calculate current line width from charBounds
973
1253
  const currentLineWidth = lineBounds.reduce((sum, b) => sum + (b?.kernedWidth || 0), 0);
974
- console.log(' Current line width from charBounds:', currentLineWidth);
1254
+ const totalExtraSpace = this.width - currentLineWidth;
1255
+
1256
+ if (totalExtraSpace <= 0) {
1257
+ return;
1258
+ }
975
1259
 
976
1260
  // Count spaces and find space indices
977
1261
  const spaceIndices: number[] = [];
@@ -980,59 +1264,124 @@ export class Textbox<
980
1264
  spaceIndices.push(i);
981
1265
  }
982
1266
  }
983
-
984
1267
  const spaceCount = spaceIndices.length;
985
- console.log(' Space count:', spaceCount, 'at indices:', spaceIndices);
986
1268
 
987
- if (spaceCount === 0) {
988
- console.log(' SKIP: No spaces to expand');
989
- return;
1269
+ // Find kashida points if enabled
1270
+ const kashidaPoints = kashidaRatio > 0 ? findKashidaPoints(line) : [];
1271
+ const hasKashidaPoints = kashidaPoints.length > 0;
1272
+
1273
+ // Calculate space distribution
1274
+ let kashidaSpace = 0;
1275
+ let spaceExpansion = totalExtraSpace;
1276
+
1277
+ if (hasKashidaPoints && kashidaRatio > 0) {
1278
+ // Distribute between kashida and spaces
1279
+ kashidaSpace = totalExtraSpace * kashidaRatio;
1280
+ spaceExpansion = totalExtraSpace * (1 - kashidaRatio);
990
1281
  }
991
1282
 
992
- // Calculate how much extra space we need
993
- const remainingSpace = this.width - currentLineWidth;
994
- console.log(' Remaining space to fill:', remainingSpace);
1283
+ // Calculate per-kashida and per-space widths
1284
+ const perKashidaWidth = hasKashidaPoints ? kashidaSpace / kashidaPoints.length : 0;
1285
+ const perSpaceWidth = spaceCount > 0 ? spaceExpansion / spaceCount : 0;
1286
+
1287
+ // If kashida is enabled, insert actual tatweel characters
1288
+ if (hasKashidaPoints && perKashidaWidth > 0) {
1289
+ // console.log(`=== Inserting kashida in _applyBrowserJustifySpaces line ${lineIndex} ===`);
1290
+
1291
+ // Sort by charIndex descending to insert from end
1292
+ const sortedPoints = [...kashidaPoints].sort((a, b) => b.charIndex - a.charIndex);
1293
+
1294
+ // Calculate tatweel width
1295
+ const canvas = document.createElement('canvas');
1296
+ const ctx = canvas.getContext('2d');
1297
+ if (ctx) {
1298
+ ctx.font = this._getFontDeclaration();
1299
+ const tatweelWidth = ctx.measureText(ARABIC_TATWEEL).width;
1300
+ // console.log(` tatweelWidth: ${tatweelWidth}`);
1301
+
1302
+ if (tatweelWidth > 0) {
1303
+ const newLine = [...line];
1304
+
1305
+ for (const point of sortedPoints) {
1306
+ const tatweelCount = Math.max(1, Math.round(perKashidaWidth / tatweelWidth));
1307
+ // console.log(` Point ${point.charIndex}: inserting ${tatweelCount} tatweels`);
1308
+
1309
+ // Insert tatweels after the character
1310
+ for (let t = 0; t < tatweelCount; t++) {
1311
+ newLine.splice(point.charIndex + 1, 0, ARABIC_TATWEEL);
1312
+ }
1313
+
1314
+ // Store kashida info with tatweelCount for index conversion
1315
+ this.__kashidaInfo[lineIndex].push({
1316
+ charIndex: point.charIndex,
1317
+ width: perKashidaWidth,
1318
+ tatweelCount: tatweelCount,
1319
+ });
1320
+ }
995
1321
 
996
- if (remainingSpace <= 0) {
997
- console.log(' SKIP: Line already fills or exceeds width');
998
- return;
1322
+ // console.log(` New line: ${newLine.join('')}`);
1323
+
1324
+ // Update _textLines with kashida
1325
+ this._textLines[lineIndex] = newLine;
1326
+
1327
+ // Update textLines string version
1328
+ if (this.textLines && this.textLines[lineIndex] !== undefined) {
1329
+ (this as any).textLines[lineIndex] = newLine.join('');
1330
+ }
1331
+
1332
+ // Recalculate charBounds
1333
+ this.__charBounds[lineIndex] = [];
1334
+ this.__lineWidths[lineIndex] = undefined as any;
1335
+ this._measureLine(lineIndex);
1336
+ }
1337
+ }
1338
+ } else {
1339
+ // No kashida - just store info for reference (tatweelCount is 0 since no tatweels inserted)
1340
+ for (const point of kashidaPoints) {
1341
+ this.__kashidaInfo[lineIndex].push({ charIndex: point.charIndex, width: perKashidaWidth, tatweelCount: 0 });
1342
+ }
999
1343
  }
1000
1344
 
1001
- const extraPerSpace = remainingSpace / spaceCount;
1002
- console.log(' Extra per space:', extraPerSpace);
1003
-
1004
- // Apply expansion
1005
- let accumulated = 0;
1006
- for (let charIndex = 0; charIndex < line.length; charIndex++) {
1007
- const bound = lineBounds[charIndex];
1008
- if (!bound) continue;
1009
-
1010
- // Shift this character by accumulated expansion
1011
- bound.left += accumulated;
1012
-
1013
- // If this is a space, expand it
1014
- if (spaceIndices.includes(charIndex)) {
1015
- const oldWidth = bound.width;
1016
- const newWidth = oldWidth + extraPerSpace;
1017
- bound.width = newWidth;
1018
- bound.kernedWidth = newWidth;
1019
- accumulated += extraPerSpace;
1020
- console.log(` Space at char ${charIndex}: ${oldWidth.toFixed(2)} -> ${newWidth.toFixed(2)} (accumulated: ${accumulated.toFixed(2)})`);
1345
+ // Now apply space expansion to remaining extra space
1346
+ const newLineBounds = this.__charBounds[lineIndex];
1347
+ const newLineWidth = newLineBounds.reduce((sum, b) => sum + (b?.kernedWidth || 0), 0);
1348
+ const remainingSpace = this.width - newLineWidth;
1349
+
1350
+ if (remainingSpace > 0 && spaceCount > 0) {
1351
+ const extraPerSpace = remainingSpace / spaceCount;
1352
+ let accumulated = 0;
1353
+
1354
+ for (let charIndex = 0; charIndex < this._textLines[lineIndex].length; charIndex++) {
1355
+ const bound = newLineBounds[charIndex];
1356
+ if (!bound) continue;
1357
+
1358
+ bound.left += accumulated;
1359
+
1360
+ // Check if this is a space (need to check against the updated line)
1361
+ if (/\s/.test(this._textLines[lineIndex][charIndex])) {
1362
+ bound.width += extraPerSpace;
1363
+ bound.kernedWidth += extraPerSpace;
1364
+ accumulated += extraPerSpace;
1365
+ }
1021
1366
  }
1022
1367
  }
1023
1368
 
1024
1369
  // Update cached line width
1025
- const finalLineWidth = lineBounds.reduce((max, b) => Math.max(max, b.left + b.width), 0);
1370
+ const finalLineBounds = this.__charBounds[lineIndex];
1371
+ const finalLineWidth = finalLineBounds.reduce((max, b) => Math.max(max, (b?.left || 0) + (b?.width || 0)), 0);
1026
1372
  this.__lineWidths[lineIndex] = finalLineWidth;
1027
- console.log(' Final line width:', finalLineWidth.toFixed(2), 'target:', this.width);
1028
1373
  });
1029
1374
 
1030
- console.log('=== _applyBrowserJustifySpaces END ===\n');
1031
1375
  this.dirty = true;
1032
1376
  // Mark that justify has been applied - for debugging to detect if measureLine overwrites it
1033
1377
  (this as any)._justifyApplied = true;
1034
- // Don't call requestRenderAll here - it will be called by the caller
1035
- // and calling it here might trigger another initDimensions that clears justify
1378
+
1379
+ // Debug log final kashida state
1380
+ // console.log('=== _applyBrowserJustifySpaces END ===');
1381
+ // console.log('Final __kashidaInfo:', JSON.stringify(this.__kashidaInfo.map((lineInfo, i) => ({
1382
+ // line: i,
1383
+ // entries: lineInfo.map(k => ({ charIndex: k.charIndex, tatweelCount: k.tatweelCount }))
1384
+ // }))));
1036
1385
  }
1037
1386
 
1038
1387
  /**