render-tag 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/layout.js CHANGED
@@ -2,70 +2,19 @@
2
2
  // Set by buildLayoutTree() based on the useDomMeasurements option.
3
3
  let _useDomMeasurements = true;
4
4
  let _debug;
5
- // ─── DOM-based line width measurement ──────────────────────────────────
6
- /**
7
- * Reusable hidden DOM element for measuring mixed-font line widths.
8
- * Only used when canvas measureText precision is insufficient
9
- * (mixed fonts near the wrap boundary).
10
- */
11
- let _measureContainer = null;
12
- let _measureSpanPool = [];
13
- function getMeasureContainer() {
14
- if (_measureContainer && _measureContainer.parentNode)
15
- return _measureContainer;
16
- _measureContainer = document.createElement('div');
17
- _measureContainer.style.cssText =
18
- 'position:absolute;top:-9999px;left:-9999px;visibility:hidden;white-space:nowrap;height:auto;width:auto;';
19
- document.body.appendChild(_measureContainer);
20
- return _measureContainer;
21
- }
22
- function getMeasureSpan(index) {
23
- if (!_measureSpanPool[index]) {
24
- _measureSpanPool[index] = document.createElement('span');
25
- }
26
- return _measureSpanPool[index];
27
- }
28
- /**
29
- * Measure the exact width of a sequence of styled words using the DOM.
30
- * This is the ground truth — the browser's own text layout engine handles
31
- * kerning, shaping, and sub-pixel positioning across font boundaries.
32
- *
33
- * Only called when canvas measureText suggests a line is near the wrap
34
- * boundary and fonts are mixed (different weight/style/family on the line).
35
- */
36
- let _domMeasureCount = 0;
37
- function measureLineWidthViaDom(words) {
38
- _domMeasureCount++;
39
- const container = getMeasureContainer();
40
- const textWords = words.filter(w => w.text && w.text !== '\n');
41
- if (textWords.length === 0)
42
- return 0;
43
- // Build spans — group consecutive words with the same font
44
- let spanIdx = 0;
45
- let lastFont = '';
46
- for (const word of textWords) {
47
- const font = buildCanvasFont(word.style);
48
- if (font !== lastFont || spanIdx === 0) {
49
- const span = getMeasureSpan(spanIdx);
50
- span.style.font = font;
51
- span.textContent = word.text;
52
- if (!span.parentNode)
53
- container.appendChild(span);
54
- lastFont = font;
55
- spanIdx++;
56
- }
57
- else {
58
- // Same font as previous span — append text
59
- _measureSpanPool[spanIdx - 1].textContent += word.text;
60
- }
61
- }
62
- // Hide unused spans
63
- for (let i = spanIdx; i < _measureSpanPool.length; i++) {
64
- if (_measureSpanPool[i].parentNode) {
65
- _measureSpanPool[i].textContent = '';
66
- }
67
- }
68
- return container.getBoundingClientRect().width;
5
+ // ─── measureText width cache ──────────────────────────────────────────
6
+ // Caches ctx.measureText(text).width keyed by "font\0text".
7
+ // Cleared at the start of each buildLayoutTree() call.
8
+ const _measureCache = new Map();
9
+ function cachedMeasureWidth(ctx, text) {
10
+ // ctx.font must already be set by caller
11
+ const key = ctx.font + '\0' + text;
12
+ const cached = _measureCache.get(key);
13
+ if (cached !== undefined)
14
+ return cached;
15
+ const w = ctx.measureText(text).width;
16
+ _measureCache.set(key, w);
17
+ return w;
69
18
  }
70
19
  /**
71
20
  * Check if a line has mixed fonts (different fontFamily/fontSize/fontWeight/fontStyle).
@@ -91,9 +40,14 @@ function applyFont(ctx, style) {
91
40
  ctx.fontKerning = style.fontKerning === 'none' ? 'none' : 'normal';
92
41
  }
93
42
  /**
94
- * Build a canvas font string from resolved style.
43
+ * Build a canvas font string from resolved style. Results are cached.
95
44
  */
45
+ const _fontStringCache = new Map();
96
46
  export function buildCanvasFont(style) {
47
+ const key = `${style.fontStyle}|${style.fontWeight}|${style.fontSize}|${style.fontFamily}`;
48
+ const cached = _fontStringCache.get(key);
49
+ if (cached)
50
+ return cached;
97
51
  const parts = [];
98
52
  if (style.fontStyle !== 'normal')
99
53
  parts.push(style.fontStyle);
@@ -101,7 +55,9 @@ export function buildCanvasFont(style) {
101
55
  parts.push(String(style.fontWeight));
102
56
  parts.push(`${style.fontSize}px`);
103
57
  parts.push(style.fontFamily);
104
- return parts.join(' ');
58
+ const result = parts.join(' ');
59
+ _fontStringCache.set(key, result);
60
+ return result;
105
61
  }
106
62
  /**
107
63
  * Cache for DOM-measured line heights.
@@ -175,22 +131,18 @@ function getLineHeight(ctx, style, useBulletProbe = false) {
175
131
  const font = buildCanvasFont(style);
176
132
  return measureDomLineHeight(font, 'normal', useBulletProbe);
177
133
  }
178
- // Canvas-only fallback for "normal" line-height: use font metrics
179
- ctx.font = buildCanvasFont(style);
180
- const metrics = ctx.measureText('M');
181
- const ascent = metrics.fontBoundingBoxAscent ?? metrics.actualBoundingBoxAscent;
182
- const descent = metrics.fontBoundingBoxDescent ?? metrics.actualBoundingBoxDescent;
183
- return (ascent + descent) * 1.2;
134
+ // Canvas-only fallback for "normal" line-height: use font bounding box
135
+ // fontBoundingBoxAscent + fontBoundingBoxDescent already represents the
136
+ // full line box height, no multiplier needed.
137
+ const { ascent, descent } = getFontMetrics(ctx, style);
138
+ return ascent + descent;
184
139
  }
185
140
  /**
186
141
  * Compute the baseline Y offset within a line.
187
142
  * Uses the Konva approach: center (ascent - descent) within lineHeight.
188
143
  */
189
144
  function computeBaselineY(ctx, style, lineHeight) {
190
- ctx.font = buildCanvasFont(style);
191
- const metrics = ctx.measureText('M');
192
- const ascent = metrics.fontBoundingBoxAscent ?? metrics.actualBoundingBoxAscent;
193
- const descent = metrics.fontBoundingBoxDescent ?? metrics.actualBoundingBoxDescent;
145
+ const { ascent, descent } = getFontMetrics(ctx, style);
194
146
  return (ascent - descent) / 2 + lineHeight / 2;
195
147
  }
196
148
  function applyTextTransform(text, transform) {
@@ -210,9 +162,38 @@ function isInline(node) {
210
162
  function hasOnlyInlineChildren(node) {
211
163
  return node.children.length > 0 && node.children.every(isInline);
212
164
  }
213
- function isTransparent(color) {
165
+ export function isTransparent(color) {
214
166
  return !color || color === 'transparent' || color === 'rgba(0, 0, 0, 0)';
215
167
  }
168
+ /**
169
+ * Get font ascent and descent metrics. Results are cached per font string.
170
+ */
171
+ const _fontMetricsCache = new Map();
172
+ export function getFontMetrics(ctx, style) {
173
+ const font = buildCanvasFont(style);
174
+ const cached = _fontMetricsCache.get(font);
175
+ if (cached)
176
+ return cached;
177
+ ctx.font = font;
178
+ const m = ctx.measureText('M');
179
+ const ascent = m.fontBoundingBoxAscent ?? m.actualBoundingBoxAscent;
180
+ const descent = m.fontBoundingBoxDescent ?? m.actualBoundingBoxDescent;
181
+ const result = { ascent, descent };
182
+ _fontMetricsCache.set(font, result);
183
+ return result;
184
+ }
185
+ /**
186
+ * Check if two styles have the same text rendering properties.
187
+ */
188
+ function sameTextStyle(a, b) {
189
+ return a.fontFamily === b.fontFamily &&
190
+ a.fontSize === b.fontSize &&
191
+ a.fontWeight === b.fontWeight &&
192
+ a.fontStyle === b.fontStyle &&
193
+ a.color === b.color &&
194
+ a.textDecorationLine === b.textDecorationLine &&
195
+ a.backgroundColor === b.backgroundColor;
196
+ }
216
197
  function hasVisibleBoxStyles(style) {
217
198
  if (!isTransparent(style.backgroundColor))
218
199
  return true;
@@ -306,15 +287,17 @@ function getSegmenter() {
306
287
  /**
307
288
  * Tokenize a single string into words based on whitespace mode.
308
289
  */
309
- function tokenizeString(ctx, text, run, allWords) {
310
- // Split on zero-width spaces and soft hyphens (break opportunities)
290
+ function tokenizeString(ctx, text, run, allWords, cumState) {
291
+ // Split on zero-width spaces and soft hyphens (break opportunities).
292
+ // Pass cumulative state through so pieces are measured as one text run
293
+ // (preserving kerning accuracy across break points).
311
294
  if (text.includes('\u200B') || text.includes('\u00AD')) {
312
- // Split but keep delimiters to distinguish soft hyphens from zero-width spaces
313
295
  const parts = text.split(/(\u200B|\u00AD)/);
296
+ // Share cumulative state across all sub-parts for accurate measurement
297
+ const sharedState = cumState ?? { cumText: '', cumWidth: 0 };
314
298
  let nextIsSoftHyphen = false;
315
299
  for (const part of parts) {
316
300
  if (part === '\u00AD') {
317
- // Mark the PREVIOUS word as a soft-hyphen break point
318
301
  nextIsSoftHyphen = true;
319
302
  continue;
320
303
  }
@@ -323,15 +306,12 @@ function tokenizeString(ctx, text, run, allWords) {
323
306
  continue;
324
307
  }
325
308
  const prevLen = allWords.length;
326
- tokenizeString(ctx, part, run, allWords);
327
- // If the previous delimiter was a soft hyphen, mark the word
328
- // just before this part as having a soft-hyphen break opportunity
309
+ tokenizeString(ctx, part, run, allWords, sharedState);
329
310
  if (nextIsSoftHyphen && prevLen > 0) {
330
311
  allWords[prevLen - 1].isSoftHyphenBreak = true;
331
312
  }
332
313
  nextIsSoftHyphen = false;
333
314
  }
334
- // If the text ends with a soft hyphen, mark the last word
335
315
  if (nextIsSoftHyphen && allWords.length > 0) {
336
316
  allWords[allWords.length - 1].isSoftHyphenBreak = true;
337
317
  }
@@ -343,7 +323,7 @@ function tokenizeString(ctx, text, run, allWords) {
343
323
  if (isPreserve) {
344
324
  // Split on spaces and tabs, keeping delimiters
345
325
  const words = text.split(/( +|\t)/);
346
- const tabStopInterval = ctx.measureText(' ').width * 8; // CSS default: 8 spaces
326
+ const tabStopInterval = cachedMeasureWidth(ctx, ' ') * 8; // CSS default: 8 spaces
347
327
  for (const w of words) {
348
328
  if (w === '')
349
329
  continue;
@@ -362,7 +342,7 @@ function tokenizeString(ctx, text, run, allWords) {
362
342
  const isSpace = /^ +$/.test(w);
363
343
  allWords.push({
364
344
  text: w,
365
- width: ctx.measureText(w).width,
345
+ width: cachedMeasureWidth(ctx, w),
366
346
  style: run.style,
367
347
  isSpace,
368
348
  boxStyle: run.boxStyle,
@@ -373,9 +353,11 @@ function tokenizeString(ctx, text, run, allWords) {
373
353
  // Split on whitespace but NOT on non-breaking spaces (\u00A0)
374
354
  const words = text.split(/([ \t\n\r\f\v]+)/);
375
355
  // Use cumulative measurement to avoid rounding error accumulation
376
- // within a single text run.
377
- let cumText = '';
378
- let cumWidth = 0;
356
+ // within a single text run. When cumState is provided (from \u200B/\u00AD
357
+ // split), continue from the previous cumulative position to preserve
358
+ // kerning accuracy across break points.
359
+ let cumText = cumState?.cumText ?? '';
360
+ let cumWidth = cumState?.cumWidth ?? 0;
379
361
  for (const w of words) {
380
362
  if (w === '')
381
363
  continue;
@@ -384,9 +366,10 @@ function tokenizeString(ctx, text, run, allWords) {
384
366
  const prevCum = cumWidth;
385
367
  cumText += ' ';
386
368
  cumWidth = ctx.measureText(cumText).width;
369
+ const spaceWidth = cumWidth - prevCum + (run.style.wordSpacing || 0);
387
370
  allWords.push({
388
371
  text: ' ',
389
- width: cumWidth - prevCum,
372
+ width: spaceWidth,
390
373
  style: run.style,
391
374
  isSpace: true,
392
375
  boxStyle: run.boxStyle,
@@ -417,7 +400,7 @@ function tokenizeString(ctx, text, run, allWords) {
417
400
  cumText += w;
418
401
  cumWidth = ctx.measureText(cumText).width;
419
402
  let width = cumWidth - prevCum;
420
- const directWidth = ctx.measureText(w).width;
403
+ const directWidth = cachedMeasureWidth(ctx, w);
421
404
  if (_debug) {
422
405
  _debug({
423
406
  type: 'measure-word',
@@ -433,6 +416,11 @@ function tokenizeString(ctx, text, run, allWords) {
433
416
  boxStyle: run.boxStyle,
434
417
  });
435
418
  }
419
+ // Propagate cumulative state back to caller (for \u200B/\u00AD splits)
420
+ if (cumState) {
421
+ cumState.cumText = cumText;
422
+ cumState.cumWidth = cumWidth;
423
+ }
436
424
  }
437
425
  }
438
426
  /**
@@ -458,7 +446,7 @@ function tokenizeRuns(ctx, runs) {
458
446
  ctx.letterSpacing = run.style.letterSpacing > 0 ? `${run.style.letterSpacing}px` : '0px';
459
447
  const text = applyTextTransform(run.text, run.style.textTransform);
460
448
  const s = run.style;
461
- const textWidth = ctx.measureText(text).width;
449
+ const textWidth = cachedMeasureWidth(ctx, text);
462
450
  const totalWidth = s.marginLeft + s.borderLeftWidth + s.paddingLeft +
463
451
  textWidth + s.paddingRight + s.borderRightWidth + s.marginRight;
464
452
  allWords.push({
@@ -535,14 +523,15 @@ function breakWordIfNeeded(ctx, word, contentWidth, currentLineWidth) {
535
523
  (word.style.overflowWrap === 'break-word' || word.style.wordBreak === 'break-all');
536
524
  if (!hasCJK && !needsBreak)
537
525
  return [word];
538
- // Split into characters
526
+ // Split into characters using cumulative measurement for accuracy.
527
+ // Measuring each char individually ignores kerning — the sum of individual
528
+ // widths diverges from the true string width over many characters.
539
529
  ctx.font = buildCanvasFont(word.style);
540
530
  const chars = [...word.text];
541
531
  const pieces = [];
542
532
  let current = '';
543
533
  let currentWidth = 0;
544
534
  for (const char of chars) {
545
- const charWidth = ctx.measureText(char).width;
546
535
  // CJK chars always get their own word for wrapping
547
536
  if (isCJK(char)) {
548
537
  if (current) {
@@ -550,17 +539,22 @@ function breakWordIfNeeded(ctx, word, contentWidth, currentLineWidth) {
550
539
  current = '';
551
540
  currentWidth = 0;
552
541
  }
542
+ const charWidth = cachedMeasureWidth(ctx, char);
553
543
  pieces.push({ ...word, text: char, width: charWidth });
554
544
  continue;
555
545
  }
546
+ // Use cumulative measurement: measure the growing string, not individual chars
547
+ const candidateText = current + char;
548
+ const candidateWidth = cachedMeasureWidth(ctx, candidateText);
556
549
  // For break-word: break when adding this char would exceed container
557
- if (needsBreak && currentWidth + charWidth > contentWidth && current) {
550
+ if (needsBreak && candidateWidth > contentWidth && current) {
558
551
  pieces.push({ ...word, text: current, width: currentWidth });
559
- current = '';
560
- currentWidth = 0;
552
+ current = char;
553
+ currentWidth = cachedMeasureWidth(ctx, char);
554
+ continue;
561
555
  }
562
- current += char;
563
- currentWidth += charWidth;
556
+ current = candidateText;
557
+ currentWidth = candidateWidth;
564
558
  }
565
559
  if (current) {
566
560
  pieces.push({ ...word, text: current, width: currentWidth });
@@ -589,7 +583,7 @@ function flowWordsIntoLines(ctx, words, contentWidth, whiteSpace, useBulletProbe
589
583
  const lastWord = currentLine.words[currentLine.words.length - 1];
590
584
  if (lastWord.isSoftHyphenBreak) {
591
585
  applyFont(ctx, lastWord.style);
592
- const hyphenWidth = ctx.measureText('-').width;
586
+ const hyphenWidth = cachedMeasureWidth(ctx, '-');
593
587
  currentLine.words.push({
594
588
  text: '-',
595
589
  width: hyphenWidth,
@@ -665,13 +659,47 @@ function flowWordsIntoLines(ctx, words, contentWidth, whiteSpace, useBulletProbe
665
659
  if (overflow < 1 && !hasMixedFonts([...currentLine.words, piece])) {
666
660
  applyFont(ctx, piece.style);
667
661
  const fullText = currentLine.words.map(w => w.text).join('') + piece.text;
668
- const fullWidth = ctx.measureText(fullText).width;
662
+ const fullWidth = cachedMeasureWidth(ctx, fullText);
669
663
  // Allow tiny sub-pixel overflow — canvas measureText and DOM
670
664
  // text layout can differ by fractions of a pixel.
671
665
  if (fullWidth <= contentWidth + 0.1) {
672
666
  reallyOverflows = false;
673
667
  }
674
668
  }
669
+ // Hyphen break on current line: before wrapping the whole word,
670
+ // try fitting a hyphen prefix on the current line. Browsers prefer
671
+ // keeping content on the current line by splitting at hyphens.
672
+ if (reallyOverflows && piece.text.includes('-')) {
673
+ const parts = piece.text.split(/(?<=-)/);
674
+ if (parts.length > 1) {
675
+ applyFont(ctx, piece.style);
676
+ let fitted = '';
677
+ let fittedWidth = 0;
678
+ let partIdx = 0;
679
+ const available = contentWidth - currentLine.totalWidth;
680
+ for (; partIdx < parts.length; partIdx++) {
681
+ const candidate = fitted + parts[partIdx];
682
+ const candidateWidth = cachedMeasureWidth(ctx, candidate);
683
+ if (candidateWidth > available)
684
+ break;
685
+ fitted = candidate;
686
+ fittedWidth = candidateWidth;
687
+ }
688
+ if (partIdx > 0 && partIdx < parts.length) {
689
+ currentLine.words.push({ ...piece, text: fitted, width: fittedWidth });
690
+ currentLine.totalWidth += fittedWidth;
691
+ currentLine.lineHeight = Math.max(currentLine.lineHeight, wordLineHeight);
692
+ pushLine(true);
693
+ afterHardBreak = false;
694
+ const remainder = parts.slice(partIdx).join('');
695
+ const remainderWidth = cachedMeasureWidth(ctx, remainder);
696
+ currentLine.words.push({ ...piece, text: remainder, width: remainderWidth });
697
+ currentLine.totalWidth += remainderWidth;
698
+ currentLine.lineHeight = Math.max(currentLine.lineHeight, wordLineHeight);
699
+ continue;
700
+ }
701
+ }
702
+ }
675
703
  if (reallyOverflows) {
676
704
  if (_debug) {
677
705
  const lineText = currentLine.words.map(w => w.text).join('');
@@ -688,28 +716,6 @@ function flowWordsIntoLines(ctx, words, contentWidth, whiteSpace, useBulletProbe
688
716
  // Skip leading spaces after soft wraps, but preserve after hard breaks (\n)
689
717
  if (piece.isSpace && currentLine.words.length === 0 && !afterHardBreak)
690
718
  continue;
691
- // Reverse check: canvas says it fits, but with mixed fonts near boundary,
692
- // DOM might say it doesn't fit. Verify before committing.
693
- // Only check when line is >80% full to avoid excessive DOM measurements.
694
- if (_useDomMeasurements && !piece.isSpace && currentLine.words.length > 0 &&
695
- currentLine.totalWidth > contentWidth * 0.8) {
696
- const remaining = contentWidth - (currentLine.totalWidth + piece.width);
697
- if (remaining >= 0 && remaining < 5 && hasMixedFonts(currentLine.words)) {
698
- const candidateWords = [...currentLine.words, piece];
699
- const domWidth = measureLineWidthViaDom(candidateWords);
700
- if (domWidth > contentWidth) {
701
- if (_debug) {
702
- _debug({
703
- type: 'line-wrap',
704
- message: `REVERSE WRAP: "${piece.text}" canvas says fits (remaining=${remaining.toFixed(2)}) but DOM says overflow (domWidth=${domWidth.toFixed(2)})`,
705
- data: { text: piece.text, remaining, domWidth, contentWidth },
706
- });
707
- }
708
- pushLine(true);
709
- afterHardBreak = false;
710
- }
711
- }
712
- }
713
719
  // Tab: snap to next tab stop based on current position
714
720
  let pieceWidth = piece.width;
715
721
  if (piece.isTab) {
@@ -719,6 +725,48 @@ function flowWordsIntoLines(ctx, words, contentWidth, whiteSpace, useBulletProbe
719
725
  pieceWidth = nextStop - currentPos;
720
726
  piece.width = pieceWidth;
721
727
  }
728
+ // Hyphen break on a fresh line when word still too wide.
729
+ if (currentLine.words.length === 0 && pieceWidth > contentWidth &&
730
+ !piece.isSpace && piece.text.includes('-')) {
731
+ const subParts = piece.text.split(/(?<=-)/);
732
+ if (subParts.length > 1) {
733
+ applyFont(ctx, piece.style);
734
+ // Inject sub-parts as individual pieces — they'll flow through
735
+ // the normal overflow/wrap logic on subsequent iterations.
736
+ const newPieces = subParts.filter(p => p).map(p => ({
737
+ ...piece,
738
+ text: p,
739
+ width: cachedMeasureWidth(ctx, p),
740
+ }));
741
+ // Replace current piece with the sub-parts by splicing into the pieces array
742
+ // Since we're iterating `pieces`, we push remaining sub-parts after the first
743
+ // onto the current line normally, letting the overflow check handle wrapping.
744
+ let first = true;
745
+ for (const sp of newPieces) {
746
+ if (first) {
747
+ first = false;
748
+ // First sub-part: add to current line (it fits since it's smaller)
749
+ currentLine.words.push(sp);
750
+ currentLine.totalWidth += sp.width;
751
+ currentLine.lineHeight = Math.max(currentLine.lineHeight, wordLineHeight);
752
+ }
753
+ else if (currentLine.totalWidth + sp.width > contentWidth) {
754
+ // Overflow: wrap to next line
755
+ pushLine(true);
756
+ afterHardBreak = false;
757
+ currentLine.words.push(sp);
758
+ currentLine.totalWidth += sp.width;
759
+ currentLine.lineHeight = Math.max(currentLine.lineHeight, wordLineHeight);
760
+ }
761
+ else {
762
+ currentLine.words.push(sp);
763
+ currentLine.totalWidth += sp.width;
764
+ currentLine.lineHeight = Math.max(currentLine.lineHeight, wordLineHeight);
765
+ }
766
+ }
767
+ continue;
768
+ }
769
+ }
722
770
  currentLine.words.push(piece);
723
771
  currentLine.totalWidth += pieceWidth;
724
772
  currentLine.lineHeight = Math.max(currentLine.lineHeight, wordLineHeight);
@@ -775,82 +823,8 @@ function layoutInlineContent(ctx, node, x, y, contentWidth, useBulletProbe = fal
775
823
  else if (isRTL) {
776
824
  curX = x + contentWidth - line.totalWidth;
777
825
  }
778
- // Two-pass: first collect inline background boxes, then text.
779
- // This ensures backgrounds are rendered before (behind) text.
780
- // Pass 1: find inline background box regions
781
- {
782
- let scanX = curX;
783
- let boxStartX = scanX;
784
- let currentBoxStyle;
785
- let boxHasText = false; // track if region has visible text (not just padding/spaces)
786
- const emitBox = (style, startX, endX) => {
787
- // Don't emit background box if this line segment has no visible text
788
- // (e.g. only a boxOpen padding marker + trailing space before wrap)
789
- if (!boxHasText)
790
- return;
791
- ctx.font = buildCanvasFont(style);
792
- const metrics = ctx.measureText('Mgy');
793
- const ascent = metrics.fontBoundingBoxAscent ?? metrics.actualBoundingBoxAscent;
794
- const descent = metrics.fontBoundingBoxDescent ?? metrics.actualBoundingBoxDescent;
795
- const emHeight = ascent + descent;
796
- const boxHeight = emHeight + style.paddingTop + style.paddingBottom
797
- + style.borderTopWidth + style.borderBottomWidth;
798
- // For inline-block: position with margin offset. For regular inline: center.
799
- let boxY;
800
- if (style.display === 'inline-block') {
801
- boxY = curY + style.marginTop;
802
- }
803
- else {
804
- boxY = curY + (lineHeight - boxHeight) / 2;
805
- }
806
- results.push({
807
- type: 'box',
808
- style,
809
- x: startX,
810
- y: boxY,
811
- width: endX - startX,
812
- height: boxHeight,
813
- tagName: 'span',
814
- children: [],
815
- });
816
- };
817
- for (const word of line.words) {
818
- // Atomic inline-block: emit box with margin offset
819
- if (word.boxOpen && word.boxClose && word.text) {
820
- if (currentBoxStyle) {
821
- emitBox(currentBoxStyle, boxStartX, scanX);
822
- currentBoxStyle = undefined;
823
- boxHasText = false;
824
- }
825
- const s = word.style;
826
- const textWidth = word.width - s.marginLeft - s.borderLeftWidth - s.paddingLeft
827
- - s.paddingRight - s.borderRightWidth - s.marginRight;
828
- const boxX = scanX + s.marginLeft;
829
- const boxW = s.borderLeftWidth + s.paddingLeft + textWidth + s.paddingRight + s.borderRightWidth;
830
- boxHasText = true;
831
- emitBox(s, boxX, boxX + boxW);
832
- boxHasText = false;
833
- scanX += word.width;
834
- continue;
835
- }
836
- if (word.boxStyle !== currentBoxStyle) {
837
- if (currentBoxStyle) {
838
- emitBox(currentBoxStyle, boxStartX, scanX);
839
- }
840
- currentBoxStyle = word.boxStyle;
841
- boxStartX = scanX;
842
- boxHasText = false;
843
- }
844
- // Track if we've seen actual text content (not spaces or empty padding markers)
845
- if (word.text && !word.isSpace) {
846
- boxHasText = true;
847
- }
848
- scanX += word.width + (word.isSpace ? justifyExtraPerSpace : 0);
849
- }
850
- if (currentBoxStyle) {
851
- emitBox(currentBoxStyle, boxStartX, scanX);
852
- }
853
- }
826
+ // Inline background boxes and text are emitted after baseline computation
827
+ // (below) so that emitInlineBox can use line-level metrics for alignment.
854
828
  // Compute a single shared baseline for the entire line.
855
829
  // Exclude sub/sup words — they sit above/below the baseline and
856
830
  // shouldn't influence where the baseline is positioned.
@@ -862,10 +836,7 @@ function layoutInlineContent(ctx, node, x, y, contentWidth, useBulletProbe = fal
862
836
  const va = word.style.verticalAlign;
863
837
  if (va === 'super' || va === 'sub')
864
838
  continue; // skip sub/sup for baseline calc
865
- ctx.font = buildCanvasFont(word.style);
866
- const m = ctx.measureText('M');
867
- const a = m.fontBoundingBoxAscent ?? m.actualBoundingBoxAscent;
868
- const d = m.fontBoundingBoxDescent ?? m.actualBoundingBoxDescent;
839
+ const { ascent: a, descent: d } = getFontMetrics(ctx, word.style);
869
840
  if (a > maxAscent)
870
841
  maxAscent = a;
871
842
  if (d > maxDescent)
@@ -876,24 +847,24 @@ function layoutInlineContent(ctx, node, x, y, contentWidth, useBulletProbe = fal
876
847
  for (const word of line.words) {
877
848
  if (word.text === '')
878
849
  continue;
879
- ctx.font = buildCanvasFont(word.style);
880
- const m = ctx.measureText('M');
881
- maxAscent = m.fontBoundingBoxAscent ?? m.actualBoundingBoxAscent;
882
- maxDescent = m.fontBoundingBoxDescent ?? m.actualBoundingBoxDescent;
850
+ const { ascent, descent } = getFontMetrics(ctx, word.style);
851
+ maxAscent = ascent;
852
+ maxDescent = descent;
883
853
  break;
884
854
  }
885
855
  }
886
856
  // Center the text block (ascent + descent) within the lineHeight
887
857
  const textBlockHeight = maxAscent + maxDescent;
888
858
  let lineBaselineY = curY + (lineHeight - textBlockHeight) / 2 + maxAscent;
859
+ // Compute parent font size for sub/sup positioning (used in expansion + text emit)
860
+ const lineNormalWords = line.words.filter(w => w.text !== '' && w.style.verticalAlign !== 'super' && w.style.verticalAlign !== 'sub');
861
+ const parentFontSize = lineNormalWords.length > 0
862
+ ? Math.max(...lineNormalWords.map(w => w.style.fontSize)) : 0;
889
863
  // Expand line height if sub/sup extends beyond the line box.
890
864
  // Browsers grow the line box to fit all content, but keep
891
865
  // the normal text baseline position unchanged.
892
866
  let effectiveLineHeight = lineHeight;
893
867
  {
894
- const lineNormalWords = line.words.filter(w => w.text !== '' && w.style.verticalAlign !== 'super' && w.style.verticalAlign !== 'sub');
895
- const parentFontSize = lineNormalWords.length > 0
896
- ? Math.max(...lineNormalWords.map(w => w.style.fontSize)) : 0;
897
868
  let minTop = curY;
898
869
  let maxBottom = curY + lineHeight;
899
870
  for (const word of line.words) {
@@ -904,10 +875,7 @@ function layoutInlineContent(ctx, node, x, y, contentWidth, useBulletProbe = fal
904
875
  continue;
905
876
  if (parentFontSize === 0)
906
877
  break;
907
- ctx.font = buildCanvasFont(word.style);
908
- const m = ctx.measureText('M');
909
- const wAscent = m.fontBoundingBoxAscent ?? m.actualBoundingBoxAscent;
910
- const wDescent = m.fontBoundingBoxDescent ?? m.actualBoundingBoxDescent;
878
+ const { ascent: wAscent, descent: wDescent } = getFontMetrics(ctx, word.style);
911
879
  let shiftedBaseline = lineBaselineY;
912
880
  if (va === 'super') {
913
881
  shiftedBaseline -= parentFontSize * 0.4;
@@ -924,113 +892,196 @@ function layoutInlineContent(ctx, node, x, y, contentWidth, useBulletProbe = fal
924
892
  }
925
893
  effectiveLineHeight = maxBottom - minTop;
926
894
  }
927
- // Pass 2: emit text
928
- // For RTL lines with uniform style, emit as a single text node
929
- // so the canvas can handle BiDi glyph shaping and connected letters.
895
+ // Emit inline background box using line-level baseline for vertical alignment.
896
+ // Uses the line's ascent/descent (not the box's own font) so box aligns with text.
897
+ const emitInlineBox = (style, bx, bw) => {
898
+ // Use the box's OWN font for height (not the line's largest font),
899
+ // but align vertically to the line's baseline.
900
+ const { ascent: boxAscent, descent: boxDescent } = getFontMetrics(ctx, style);
901
+ const padTop = style.paddingTop + style.borderTopWidth;
902
+ const padBottom = style.paddingBottom + style.borderBottomWidth;
903
+ const boxHeight = boxAscent + boxDescent + padTop + padBottom;
904
+ let boxY;
905
+ if (style.display === 'inline-block') {
906
+ boxY = curY + style.marginTop;
907
+ }
908
+ else {
909
+ boxY = lineBaselineY - boxAscent - padTop;
910
+ }
911
+ results.push({
912
+ type: 'box', style, x: bx, y: boxY, width: bw, height: boxHeight,
913
+ tagName: 'span', children: [],
914
+ });
915
+ };
916
+ // LTR: emit inline background boxes (Pass 1) before text.
917
+ if (!isRTL) {
918
+ let scanX = curX;
919
+ let boxStartX = scanX;
920
+ let currentBoxStyle;
921
+ let boxHasText = false;
922
+ for (const word of line.words) {
923
+ if (word.boxOpen && word.boxClose && word.text) {
924
+ if (currentBoxStyle) {
925
+ if (boxHasText)
926
+ emitInlineBox(currentBoxStyle, boxStartX, scanX - boxStartX);
927
+ currentBoxStyle = undefined;
928
+ boxHasText = false;
929
+ }
930
+ const s = word.style;
931
+ const textWidth = word.width - s.marginLeft - s.borderLeftWidth - s.paddingLeft
932
+ - s.paddingRight - s.borderRightWidth - s.marginRight;
933
+ const boxX = scanX + s.marginLeft;
934
+ const boxW = s.borderLeftWidth + s.paddingLeft + textWidth + s.paddingRight + s.borderRightWidth;
935
+ emitInlineBox(s, boxX, boxW);
936
+ boxHasText = false;
937
+ scanX += word.width;
938
+ continue;
939
+ }
940
+ if (word.boxStyle !== currentBoxStyle) {
941
+ if (currentBoxStyle && boxHasText) {
942
+ emitInlineBox(currentBoxStyle, boxStartX, scanX - boxStartX);
943
+ }
944
+ currentBoxStyle = word.boxStyle;
945
+ boxStartX = scanX;
946
+ boxHasText = false;
947
+ }
948
+ if (word.text && !word.isSpace)
949
+ boxHasText = true;
950
+ scanX += word.width + (word.isSpace ? justifyExtraPerSpace : 0);
951
+ }
952
+ if (currentBoxStyle && boxHasText) {
953
+ emitInlineBox(currentBoxStyle, boxStartX, scanX - boxStartX);
954
+ }
955
+ }
956
+ // Emit text nodes.
930
957
  const textWords = line.words.filter(w => w.text !== '');
931
- const allSameStyle = textWords.length > 0 && textWords.every(w => w.style.fontFamily === textWords[0].style.fontFamily &&
932
- w.style.fontSize === textWords[0].style.fontSize &&
933
- w.style.fontWeight === textWords[0].style.fontWeight &&
934
- w.style.fontStyle === textWords[0].style.fontStyle &&
935
- w.style.color === textWords[0].style.color);
958
+ const allSameStyle = textWords.length > 0 && textWords.every(w => sameTextStyle(w.style, textWords[0].style));
936
959
  if (isRTL) {
937
- // RTL: render words right-to-left
938
- // Join consecutive words with same style into groups for proper glyph shaping
939
- let rtlX = curX + line.totalWidth; // start from right edge
940
960
  const groups = [];
941
961
  let currentGroup = null;
962
+ let pendingPad = 0;
942
963
  for (const word of line.words) {
943
964
  if (word.text === '') {
944
- // Padding marker — flush current group and add spacing
965
+ // Padding marker — accumulate for the next group boundary
945
966
  if (currentGroup) {
946
967
  groups.push(currentGroup);
947
968
  currentGroup = null;
948
969
  }
949
- rtlX -= word.width;
970
+ pendingPad += word.width;
950
971
  continue;
951
972
  }
952
- if (currentGroup &&
953
- currentGroup.style.fontFamily === word.style.fontFamily &&
954
- currentGroup.style.fontSize === word.style.fontSize &&
955
- currentGroup.style.fontWeight === word.style.fontWeight &&
956
- currentGroup.style.fontStyle === word.style.fontStyle &&
957
- currentGroup.style.color === word.style.color) {
973
+ if (currentGroup && sameTextStyle(currentGroup.style, word.style)) {
958
974
  currentGroup.text += word.text;
959
975
  currentGroup.width += word.width;
960
976
  }
961
977
  else {
962
978
  if (currentGroup)
963
979
  groups.push(currentGroup);
964
- currentGroup = { text: word.text, style: word.style, width: word.width };
980
+ currentGroup = { text: word.text, style: word.style, width: word.width, boxStyle: word.boxStyle, x: 0, padBefore: pendingPad };
981
+ pendingPad = 0;
965
982
  }
966
983
  }
967
984
  if (currentGroup)
968
985
  groups.push(currentGroup);
969
- // Emit groups right-to-left
986
+ // Compute positions right-to-left: group-level measureText for accuracy,
987
+ // with padding markers creating spacing between groups.
988
+ let rtlX = curX + line.totalWidth;
970
989
  for (const group of groups) {
971
- ctx.font = buildCanvasFont(group.style);
972
- const measuredWidth = ctx.measureText(group.text).width;
990
+ rtlX -= group.padBefore; // spacing from padding markers
991
+ applyFont(ctx, group.style);
992
+ const measuredWidth = cachedMeasureWidth(ctx, group.text);
973
993
  rtlX -= measuredWidth;
994
+ group.x = rtlX;
995
+ group.width = measuredWidth;
996
+ }
997
+ // Emit inline boxes first (behind text).
998
+ // Include padding/border from boxStyle in box dimensions.
999
+ for (const group of groups) {
1000
+ if (group.boxStyle && hasVisibleBoxStyles(group.boxStyle)) {
1001
+ const bs = group.boxStyle;
1002
+ const padLeft = bs.paddingLeft + bs.borderLeftWidth;
1003
+ const padRight = bs.paddingRight + bs.borderRightWidth;
1004
+ emitInlineBox(bs, group.x - padLeft, group.width + padLeft + padRight);
1005
+ }
1006
+ }
1007
+ // Emit text groups
1008
+ for (const group of groups) {
974
1009
  results.push({
975
1010
  type: 'text',
976
1011
  text: group.text,
977
- x: rtlX + measuredWidth, // x = right edge for RTL textAlign
1012
+ x: group.x + group.width, // x = right edge for RTL textAlign
978
1013
  y: lineBaselineY,
979
- width: measuredWidth,
1014
+ width: group.width,
980
1015
  style: { ...group.style, direction: 'rtl' },
981
1016
  });
982
1017
  }
983
1018
  }
984
1019
  else {
985
- // LTR: word by word
986
- for (const word of line.words) {
987
- if (word.text === '') {
988
- curX += word.width;
989
- continue;
990
- }
991
- // Atomic inline-block: position text inside the box (after margin + padding)
992
- if (word.boxOpen && word.boxClose) {
993
- const s = word.style;
994
- const textX = curX + s.marginLeft + s.borderLeftWidth + s.paddingLeft;
1020
+ // LTR with mixed BiDi scripts: emit the entire line as one fillText call
1021
+ // so the canvas engine handles BiDi reordering (Arabic/Hebrew in LTR).
1022
+ // Only do this when the line contains RTL characters — pure LTR lines
1023
+ // are more accurate with word-by-word positioning.
1024
+ const lineText = line.words.map(w => w.text).join('');
1025
+ const hasBidiMix = allSameStyle && /[\u0590-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF]/.test(lineText) &&
1026
+ !line.words.some(w => w.boxOpen || w.boxClose ||
1027
+ w.style.verticalAlign === 'super' || w.style.verticalAlign === 'sub');
1028
+ if (hasBidiMix) {
1029
+ applyFont(ctx, textWords[0].style);
1030
+ const measuredWidth = cachedMeasureWidth(ctx, lineText);
1031
+ results.push({
1032
+ type: 'text',
1033
+ text: lineText,
1034
+ x: curX,
1035
+ y: lineBaselineY,
1036
+ width: measuredWidth,
1037
+ style: textWords[0].style,
1038
+ });
1039
+ }
1040
+ else {
1041
+ // Mixed styles: word by word
1042
+ for (const word of line.words) {
1043
+ if (word.text === '') {
1044
+ curX += word.width;
1045
+ continue;
1046
+ }
1047
+ // Atomic inline-block: position text inside the box (after margin + padding)
1048
+ if (word.boxOpen && word.boxClose) {
1049
+ const s = word.style;
1050
+ const textX = curX + s.marginLeft + s.borderLeftWidth + s.paddingLeft;
1051
+ results.push({
1052
+ type: 'text',
1053
+ text: word.text,
1054
+ x: textX,
1055
+ y: lineBaselineY,
1056
+ width: cachedMeasureWidth(ctx, word.text),
1057
+ style: word.style,
1058
+ });
1059
+ curX += word.width;
1060
+ continue;
1061
+ }
1062
+ // Adjust baseline for vertical-align
1063
+ let baselineY = lineBaselineY;
1064
+ const va = word.style.verticalAlign;
1065
+ if (va === 'super' || va === 'sub') {
1066
+ const pfs = parentFontSize || word.style.fontSize;
1067
+ if (va === 'super') {
1068
+ baselineY -= pfs * 0.4;
1069
+ }
1070
+ else {
1071
+ baselineY += pfs * 0.26;
1072
+ }
1073
+ }
1074
+ const effectiveWidth = word.width + (word.isSpace ? justifyExtraPerSpace : 0);
995
1075
  results.push({
996
1076
  type: 'text',
997
1077
  text: word.text,
998
- x: textX,
999
- y: lineBaselineY,
1000
- width: ctx.measureText(word.text).width,
1078
+ x: curX,
1079
+ y: baselineY,
1080
+ width: effectiveWidth,
1001
1081
  style: word.style,
1002
1082
  });
1003
- curX += word.width;
1004
- continue;
1005
- }
1006
- // Adjust baseline for vertical-align
1007
- let baselineY = lineBaselineY;
1008
- const va = word.style.verticalAlign;
1009
- if (va === 'super' || va === 'sub') {
1010
- // Find parent font size (the normal-sized text on this line)
1011
- const normalWords = textWords.filter(w => w.style.verticalAlign !== 'super' && w.style.verticalAlign !== 'sub');
1012
- const parentFontSize = normalWords.length > 0
1013
- ? Math.max(...normalWords.map(w => w.style.fontSize))
1014
- : word.style.fontSize;
1015
- if (va === 'super') {
1016
- // Chrome raises super baseline by ~0.4em of parent font size
1017
- baselineY -= parentFontSize * 0.4;
1018
- }
1019
- else {
1020
- // Chrome lowers sub baseline by ~0.26em of parent font size
1021
- baselineY += parentFontSize * 0.26;
1022
- }
1083
+ curX += effectiveWidth;
1023
1084
  }
1024
- const effectiveWidth = word.width + (word.isSpace ? justifyExtraPerSpace : 0);
1025
- results.push({
1026
- type: 'text',
1027
- text: word.text,
1028
- x: curX,
1029
- y: baselineY,
1030
- width: effectiveWidth,
1031
- style: word.style,
1032
- });
1033
- curX += effectiveWidth;
1034
1085
  }
1035
1086
  }
1036
1087
  curY += effectiveLineHeight;
@@ -1309,19 +1360,39 @@ function addListMarker(ctx, box, node) {
1309
1360
  const lineHeight = getLineHeight(ctx, style);
1310
1361
  const baselineY = box.y + style.borderTopWidth + style.paddingTop +
1311
1362
  computeBaselineY(ctx, style, lineHeight);
1312
- const markerWidth = ctx.measureText(node.listMarker).width;
1313
- // Content starts at box.x + borderLeft + paddingLeft
1314
- // Place marker right-aligned within the padding area, with a small gap before content
1315
- const contentStartX = box.x + style.borderLeftWidth + style.paddingLeft;
1363
+ const markerWidth = cachedMeasureWidth(ctx, node.listMarker);
1316
1364
  const gap = style.fontSize * 0.15; // small gap between marker and content
1317
- const markerX = contentStartX - markerWidth - gap;
1365
+ const isRTL = style.direction === 'rtl';
1366
+ let markerX;
1367
+ let markerDirection = 'ltr';
1368
+ if (isRTL) {
1369
+ // RTL: marker in the parent's right padding area (outside the li box).
1370
+ const boxRightEdge = box.x + box.width;
1371
+ // Numbered markers ("1.") need RTL direction to display as ".1".
1372
+ // With textAlign='right', x is the right edge — so add markerWidth.
1373
+ // Bullet markers (•, ○, ■) stay LTR — they're symmetric.
1374
+ const isNumbered = /\d/.test(node.listMarker);
1375
+ if (isNumbered) {
1376
+ markerDirection = 'rtl';
1377
+ markerX = boxRightEdge + gap + markerWidth;
1378
+ }
1379
+ else {
1380
+ markerX = boxRightEdge + gap;
1381
+ }
1382
+ }
1383
+ else {
1384
+ // LTR: marker in the parent's left padding area (outside the li box).
1385
+ // Right-aligned within the padding, with a gap before content.
1386
+ const contentStartX = box.x + style.borderLeftWidth + style.paddingLeft;
1387
+ markerX = contentStartX - markerWidth - gap;
1388
+ }
1318
1389
  box.children.unshift({
1319
1390
  type: 'text',
1320
1391
  text: node.listMarker,
1321
1392
  x: markerX,
1322
1393
  y: baselineY,
1323
1394
  width: markerWidth,
1324
- style: { ...style, textDecorationLine: 'none', fontWeight: 400, fontStyle: 'normal' },
1395
+ style: { ...style, textDecorationLine: 'none', fontWeight: 400, fontStyle: 'normal', direction: markerDirection },
1325
1396
  });
1326
1397
  }
1327
1398
  // ─── Main entry ────────────────────────────────────────────────────────
@@ -1329,13 +1400,14 @@ function addListMarker(ctx, box, node) {
1329
1400
  * Build the layout tree from the styled tree using pure canvas measurement.
1330
1401
  * No DOM measurements used — all positions computed from CSS values + canvas.measureText.
1331
1402
  */
1332
- export function getDomMeasureCount() { return _domMeasureCount; }
1333
- export function resetDomMeasureCount() { _domMeasureCount = 0; }
1334
1403
  export function buildLayoutTree(ctx, styledTree, containerWidth, useDomMeasurements = true, debug) {
1335
1404
  _useDomMeasurements = useDomMeasurements;
1336
1405
  _debug = debug;
1337
- // Clear line height cache — fonts may have loaded since last call
1406
+ // Clear caches — fonts may have loaded since last call
1338
1407
  _lineHeightCache.clear();
1408
+ _fontMetricsCache.clear();
1409
+ _fontStringCache.clear();
1410
+ _measureCache.clear();
1339
1411
  // The styledTree root is our container div — layout its children as a block flow
1340
1412
  const { box, height } = layoutBlock(ctx, styledTree, 0, 0, containerWidth);
1341
1413
  // Add list markers post-layout