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/README.md +23 -6
- package/lib/css-resolver.d.ts +10 -0
- package/lib/css-resolver.d.ts.map +1 -0
- package/lib/css-resolver.js +1231 -0
- package/lib/css-resolver.js.map +1 -0
- package/lib/index.d.ts +2 -6
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +5 -29
- package/lib/index.js.map +1 -1
- package/lib/layout.d.ts +5 -5
- package/lib/layout.d.ts.map +1 -1
- package/lib/layout.js +364 -292
- package/lib/layout.js.map +1 -1
- package/lib/render-tag.umd.js +6 -6
- package/lib/render-tag.umd.js.map +1 -1
- package/lib/render.d.ts.map +1 -1
- package/lib/render.js +32 -67
- package/lib/render.js.map +1 -1
- package/lib/style-resolver.d.ts.map +1 -1
- package/lib/style-resolver.js +22 -3
- package/lib/style-resolver.js.map +1 -1
- package/lib/types.d.ts +9 -19
- package/lib/types.d.ts.map +1 -1
- package/package.json +7 -1
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
|
-
// ───
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
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
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
const ascent =
|
|
182
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
378
|
-
|
|
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:
|
|
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
|
|
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
|
|
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 &&
|
|
550
|
+
if (needsBreak && candidateWidth > contentWidth && current) {
|
|
558
551
|
pieces.push({ ...word, text: current, width: currentWidth });
|
|
559
|
-
current =
|
|
560
|
-
currentWidth =
|
|
552
|
+
current = char;
|
|
553
|
+
currentWidth = cachedMeasureWidth(ctx, char);
|
|
554
|
+
continue;
|
|
561
555
|
}
|
|
562
|
-
current
|
|
563
|
-
currentWidth
|
|
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
|
|
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
|
|
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
|
-
//
|
|
779
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
880
|
-
|
|
881
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
928
|
-
//
|
|
929
|
-
|
|
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
|
|
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 —
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
972
|
-
|
|
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:
|
|
1012
|
+
x: group.x + group.width, // x = right edge for RTL textAlign
|
|
978
1013
|
y: lineBaselineY,
|
|
979
|
-
width:
|
|
1014
|
+
width: group.width,
|
|
980
1015
|
style: { ...group.style, direction: 'rtl' },
|
|
981
1016
|
});
|
|
982
1017
|
}
|
|
983
1018
|
}
|
|
984
1019
|
else {
|
|
985
|
-
// LTR:
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
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:
|
|
999
|
-
y:
|
|
1000
|
-
width:
|
|
1078
|
+
x: curX,
|
|
1079
|
+
y: baselineY,
|
|
1080
|
+
width: effectiveWidth,
|
|
1001
1081
|
style: word.style,
|
|
1002
1082
|
});
|
|
1003
|
-
curX +=
|
|
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
|
|
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
|
|
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
|
|
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
|