render-tag 0.1.0

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 ADDED
@@ -0,0 +1,1323 @@
1
+ // Module-level flag controlling DOM measurement usage.
2
+ // Set by buildLayoutTree() based on the useDomMeasurements option.
3
+ let _useDomMeasurements = true;
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;
69
+ }
70
+ /**
71
+ * Check if a line has mixed fonts (different fontFamily/fontSize/fontWeight/fontStyle).
72
+ */
73
+ function hasMixedFonts(words) {
74
+ let font = '';
75
+ for (const w of words) {
76
+ if (!w.text || w.isSpace)
77
+ continue;
78
+ const f = buildCanvasFont(w.style);
79
+ if (font && f !== font)
80
+ return true;
81
+ font = f;
82
+ }
83
+ return false;
84
+ }
85
+ // ─── Canvas font helpers ───────────────────────────────────────────────
86
+ /**
87
+ * Set canvas font and kerning from resolved style.
88
+ */
89
+ function applyFont(ctx, style) {
90
+ ctx.font = buildCanvasFont(style);
91
+ ctx.fontKerning = style.fontKerning === 'none' ? 'none' : 'normal';
92
+ }
93
+ /**
94
+ * Build a canvas font string from resolved style.
95
+ */
96
+ export function buildCanvasFont(style) {
97
+ const parts = [];
98
+ if (style.fontStyle !== 'normal')
99
+ parts.push(style.fontStyle);
100
+ if (style.fontWeight !== 400)
101
+ parts.push(String(style.fontWeight));
102
+ parts.push(`${style.fontSize}px`);
103
+ parts.push(style.fontFamily);
104
+ return parts.join(' ');
105
+ }
106
+ /**
107
+ * Cache for DOM-measured line heights.
108
+ * Key: "font|lineHeight|probeType" → actual pixel height from the browser.
109
+ */
110
+ const _lineHeightCache = new Map();
111
+ // Probe elements: a <div> for general use, and a <ul><li> for unordered list items.
112
+ // Firefox renders <ul><li> with bullet markers (disc/circle/square) 1.5px taller
113
+ // than other elements for the same line-height, due to the ::marker pseudo-element.
114
+ // <ol><li> items do NOT have this extra height.
115
+ let _blockProbe = null;
116
+ let _ulProbeContainer = null;
117
+ let _ulProbeLi = null;
118
+ const BULLET_MARKERS = new Set(['disc', 'circle', 'square']);
119
+ /**
120
+ * Measure the actual line height using a hidden DOM element.
121
+ * Uses an actual <li> inside a <ul> when listStyleType is a bullet marker
122
+ * (disc/circle/square) to capture Firefox's ::marker line box contribution.
123
+ * Results are cached per font+lineHeight+probeType combination.
124
+ */
125
+ function measureDomLineHeight(font, lineHeight, useBulletProbe = false) {
126
+ const key = `${font}|${lineHeight}|${useBulletProbe ? 'ul-li' : 'block'}`;
127
+ const cached = _lineHeightCache.get(key);
128
+ if (cached !== undefined)
129
+ return cached;
130
+ let probe;
131
+ if (useBulletProbe) {
132
+ if (!_ulProbeContainer) {
133
+ _ulProbeContainer = document.createElement('ul');
134
+ _ulProbeContainer.style.cssText =
135
+ 'position:absolute;top:-9999px;left:-9999px;visibility:hidden;padding:0;margin:0;border:0;list-style:disc;';
136
+ _ulProbeLi = document.createElement('li');
137
+ _ulProbeLi.style.cssText = 'white-space:nowrap;padding:0;margin:0;border:0;';
138
+ _ulProbeLi.textContent = 'Mg';
139
+ _ulProbeContainer.appendChild(_ulProbeLi);
140
+ document.body.appendChild(_ulProbeContainer);
141
+ }
142
+ probe = _ulProbeLi;
143
+ }
144
+ else {
145
+ if (!_blockProbe) {
146
+ _blockProbe = document.createElement('div');
147
+ _blockProbe.style.cssText =
148
+ 'position:absolute;top:-9999px;left:-9999px;visibility:hidden;white-space:nowrap;padding:0;margin:0;border:0;';
149
+ _blockProbe.textContent = 'Mg';
150
+ document.body.appendChild(_blockProbe);
151
+ }
152
+ probe = _blockProbe;
153
+ }
154
+ probe.style.font = font;
155
+ probe.style.lineHeight = lineHeight;
156
+ const height = probe.getBoundingClientRect().height;
157
+ _lineHeightCache.set(key, height);
158
+ return height;
159
+ }
160
+ /**
161
+ * Get the effective line height for a style.
162
+ * Uses DOM measurement for accuracy across browsers (Firefox vs Chrome).
163
+ * Falls back to canvas metrics for "normal" line-height.
164
+ */
165
+ function getLineHeight(ctx, style, useBulletProbe = false) {
166
+ if (style.lineHeight > 0) {
167
+ if (_useDomMeasurements) {
168
+ const font = buildCanvasFont(style);
169
+ return measureDomLineHeight(font, `${style.lineHeight}px`, useBulletProbe);
170
+ }
171
+ // Canvas-only: use the CSS line-height value directly
172
+ return style.lineHeight;
173
+ }
174
+ if (_useDomMeasurements) {
175
+ const font = buildCanvasFont(style);
176
+ return measureDomLineHeight(font, 'normal', useBulletProbe);
177
+ }
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;
184
+ }
185
+ /**
186
+ * Compute the baseline Y offset within a line.
187
+ * Uses the Konva approach: center (ascent - descent) within lineHeight.
188
+ */
189
+ 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;
194
+ return (ascent - descent) / 2 + lineHeight / 2;
195
+ }
196
+ function applyTextTransform(text, transform) {
197
+ switch (transform) {
198
+ case 'uppercase': return text.toUpperCase();
199
+ case 'lowercase': return text.toLowerCase();
200
+ case 'capitalize': return text.replace(/\b\w/g, c => c.toUpperCase());
201
+ default: return text;
202
+ }
203
+ }
204
+ function isInline(node) {
205
+ if (node.tagName === '#text')
206
+ return true;
207
+ const d = node.style.display;
208
+ return d === 'inline' || d === 'inline-block';
209
+ }
210
+ function hasOnlyInlineChildren(node) {
211
+ return node.children.length > 0 && node.children.every(isInline);
212
+ }
213
+ function isTransparent(color) {
214
+ return !color || color === 'transparent' || color === 'rgba(0, 0, 0, 0)';
215
+ }
216
+ function hasVisibleBoxStyles(style) {
217
+ if (!isTransparent(style.backgroundColor))
218
+ return true;
219
+ if (style.borderTopWidth > 0 && style.borderTopStyle !== 'none')
220
+ return true;
221
+ if (style.borderRightWidth > 0 && style.borderRightStyle !== 'none')
222
+ return true;
223
+ if (style.borderBottomWidth > 0 && style.borderBottomStyle !== 'none')
224
+ return true;
225
+ if (style.borderLeftWidth > 0 && style.borderLeftStyle !== 'none')
226
+ return true;
227
+ return false;
228
+ }
229
+ // ─── Inline layout ─────────────────────────────────────────────────────
230
+ /**
231
+ * Collect text runs from inline children, preserving style and tracking
232
+ * inline elements with visible backgrounds. Emits open/close markers
233
+ * for inline boxes so padding/border can be applied.
234
+ */
235
+ function collectTextRuns(node) {
236
+ const runs = [];
237
+ function walk(n, boxStyle) {
238
+ if (n.tagName === '#text' && n.textContent) {
239
+ runs.push({ text: n.textContent, style: n.style, boxStyle });
240
+ return;
241
+ }
242
+ const isInlineBlock = n.style.display === 'inline-block';
243
+ // Inline-block always needs box treatment (padding/margin affect layout)
244
+ const isBox = isInlineBlock || (isInline(n) && hasVisibleBoxStyles(n.style));
245
+ const newBoxStyle = isBox ? n.style : boxStyle;
246
+ const hasHorizSpacing = isBox && (n.style.paddingLeft > 0 || n.style.paddingRight > 0 ||
247
+ n.style.borderLeftWidth > 0 || n.style.borderRightWidth > 0);
248
+ if (isInlineBlock) {
249
+ // Inline-block is fully atomic — the entire element (margins + padding + text)
250
+ // wraps as one unit. We emit a single "atomic" TextRun with a special marker
251
+ // so the tokenizer creates one non-splittable word with the full box width.
252
+ const allText = n.element?.textContent || '';
253
+ runs.push({
254
+ text: allText,
255
+ style: n.style,
256
+ boxStyle: newBoxStyle,
257
+ // Store the full box info for atomic inline-block handling
258
+ boxOpen: n.style, // signals this is a boxed element
259
+ boxClose: n.style,
260
+ });
261
+ return;
262
+ }
263
+ if (hasHorizSpacing) {
264
+ runs.push({ text: '', style: n.style, boxStyle: newBoxStyle, boxOpen: n.style });
265
+ }
266
+ for (const child of n.children) {
267
+ walk(child, isBox ? newBoxStyle : boxStyle);
268
+ }
269
+ if (hasHorizSpacing) {
270
+ runs.push({ text: '', style: n.style, boxStyle: newBoxStyle, boxClose: n.style });
271
+ }
272
+ }
273
+ for (const child of node.children) {
274
+ walk(child);
275
+ }
276
+ return runs;
277
+ }
278
+ /**
279
+ * Check if text needs Intl.Segmenter for word breaking (Thai, Khmer, Lao, Myanmar).
280
+ * These scripts don't use spaces between words.
281
+ */
282
+ function needsSegmenter(text) {
283
+ for (let i = 0; i < text.length; i++) {
284
+ const code = text.codePointAt(i);
285
+ if ((code >= 0x0E00 && code <= 0x0E7F) || // Thai
286
+ (code >= 0x0E80 && code <= 0x0EFF) || // Lao
287
+ (code >= 0x1000 && code <= 0x109F) || // Myanmar
288
+ (code >= 0x1780 && code <= 0x17FF) // Khmer
289
+ )
290
+ return true;
291
+ if (code > 0xFFFF)
292
+ i++; // skip surrogate pair
293
+ }
294
+ return false;
295
+ }
296
+ let _segmenter;
297
+ function getSegmenter() {
298
+ if (_segmenter)
299
+ return _segmenter;
300
+ if (typeof Intl !== 'undefined' && Intl.Segmenter) {
301
+ _segmenter = new Intl.Segmenter(undefined, { granularity: 'word' });
302
+ return _segmenter;
303
+ }
304
+ return null;
305
+ }
306
+ /**
307
+ * Tokenize a single string into words based on whitespace mode.
308
+ */
309
+ function tokenizeString(ctx, text, run, allWords) {
310
+ // Split on zero-width spaces and soft hyphens (break opportunities)
311
+ if (text.includes('\u200B') || text.includes('\u00AD')) {
312
+ const parts = text.split(/[\u200B\u00AD]/);
313
+ for (const part of parts) {
314
+ if (part)
315
+ tokenizeString(ctx, part, run, allWords);
316
+ }
317
+ return;
318
+ }
319
+ const isPreserve = run.style.whiteSpace === 'pre' ||
320
+ run.style.whiteSpace === 'pre-wrap' ||
321
+ run.style.whiteSpace === 'pre-line';
322
+ if (isPreserve) {
323
+ // Split on spaces and tabs, keeping delimiters
324
+ const words = text.split(/( +|\t)/);
325
+ const tabStopInterval = ctx.measureText(' ').width * 8; // CSS default: 8 spaces
326
+ for (const w of words) {
327
+ if (w === '')
328
+ continue;
329
+ if (w === '\t') {
330
+ // Tab width depends on current position — mark it for dynamic calculation
331
+ allWords.push({
332
+ text: '\t',
333
+ width: tabStopInterval, // placeholder — recalculated in flowWordsIntoLines
334
+ style: run.style,
335
+ isSpace: true,
336
+ isTab: true,
337
+ boxStyle: run.boxStyle,
338
+ });
339
+ continue;
340
+ }
341
+ const isSpace = /^ +$/.test(w);
342
+ allWords.push({
343
+ text: w,
344
+ width: ctx.measureText(w).width,
345
+ style: run.style,
346
+ isSpace,
347
+ boxStyle: run.boxStyle,
348
+ });
349
+ }
350
+ }
351
+ else {
352
+ // Split on whitespace but NOT on non-breaking spaces (\u00A0)
353
+ const words = text.split(/([ \t\n\r\f\v]+)/);
354
+ // Use cumulative measurement to avoid rounding error accumulation
355
+ // within a single text run.
356
+ let cumText = '';
357
+ let cumWidth = 0;
358
+ for (const w of words) {
359
+ if (w === '')
360
+ continue;
361
+ const isSpace = /^[ \t\n\r\f\v]+$/.test(w);
362
+ if (isSpace) {
363
+ const prevCum = cumWidth;
364
+ cumText += ' ';
365
+ cumWidth = ctx.measureText(cumText).width;
366
+ allWords.push({
367
+ text: ' ',
368
+ width: cumWidth - prevCum,
369
+ style: run.style,
370
+ isSpace: true,
371
+ boxStyle: run.boxStyle,
372
+ });
373
+ continue;
374
+ }
375
+ // Use Intl.Segmenter for scripts without spaces (Thai, Khmer, etc.)
376
+ if (needsSegmenter(w)) {
377
+ const segmenter = getSegmenter();
378
+ if (segmenter) {
379
+ for (const seg of segmenter.segment(w)) {
380
+ const s = seg.segment;
381
+ const prevCum = cumWidth;
382
+ cumText += s;
383
+ cumWidth = ctx.measureText(cumText).width;
384
+ allWords.push({
385
+ text: s,
386
+ width: cumWidth - prevCum,
387
+ style: run.style,
388
+ isSpace: false,
389
+ boxStyle: run.boxStyle,
390
+ });
391
+ }
392
+ continue;
393
+ }
394
+ }
395
+ const prevCum = cumWidth;
396
+ cumText += w;
397
+ cumWidth = ctx.measureText(cumText).width;
398
+ let width = cumWidth - prevCum;
399
+ const directWidth = ctx.measureText(w).width;
400
+ if (_debug) {
401
+ _debug({
402
+ type: 'measure-word',
403
+ message: `"${w}" delta=${width.toFixed(2)} direct=${directWidth.toFixed(2)} diff=${(width - directWidth).toFixed(2)} cumText="${cumText}"`,
404
+ data: { text: w, deltaWidth: width, directWidth, cumWidth, prevCum, font: run.style.fontFamily, fontSize: run.style.fontSize },
405
+ });
406
+ }
407
+ allWords.push({
408
+ text: w,
409
+ width,
410
+ style: run.style,
411
+ isSpace: false,
412
+ boxStyle: run.boxStyle,
413
+ });
414
+ }
415
+ }
416
+ }
417
+ /**
418
+ * Tokenize text runs into words for line wrapping.
419
+ */
420
+ function tokenizeRuns(ctx, runs) {
421
+ const allWords = [];
422
+ for (const run of runs) {
423
+ // Handle inline-block margins (empty text, no boxOpen/boxClose)
424
+ if (run.text === '' && !run.boxOpen && !run.boxClose) {
425
+ const margin = run.style.display === 'inline-block'
426
+ ? (run.style.marginLeft || run.style.marginRight || 0)
427
+ : 0;
428
+ if (margin > 0) {
429
+ allWords.push({ text: '', width: margin, style: run.style, isSpace: false, boxStyle: run.boxStyle });
430
+ }
431
+ continue;
432
+ }
433
+ // Atomic inline-block: entire element (margin + padding + text) is one word
434
+ // Must check before boxOpen/boxClose handlers since atomic has both set.
435
+ if (run.boxOpen && run.boxClose && run.text) {
436
+ applyFont(ctx, run.style);
437
+ ctx.letterSpacing = run.style.letterSpacing > 0 ? `${run.style.letterSpacing}px` : '0px';
438
+ const text = applyTextTransform(run.text, run.style.textTransform);
439
+ const s = run.style;
440
+ const textWidth = ctx.measureText(text).width;
441
+ const totalWidth = s.marginLeft + s.borderLeftWidth + s.paddingLeft +
442
+ textWidth + s.paddingRight + s.borderRightWidth + s.marginRight;
443
+ allWords.push({
444
+ text,
445
+ width: totalWidth,
446
+ style: run.style,
447
+ isSpace: false,
448
+ boxStyle: run.boxStyle,
449
+ boxOpen: run.boxOpen,
450
+ boxClose: run.boxClose,
451
+ });
452
+ continue;
453
+ }
454
+ // Handle inline box open/close markers (padding)
455
+ if (run.boxOpen) {
456
+ const pad = run.boxOpen.paddingLeft + run.boxOpen.borderLeftWidth;
457
+ if (pad > 0) {
458
+ allWords.push({ text: '', width: pad, style: run.style, isSpace: false, boxStyle: run.boxStyle, boxOpen: run.boxOpen });
459
+ }
460
+ continue;
461
+ }
462
+ if (run.boxClose) {
463
+ const pad = run.boxClose.paddingRight + run.boxClose.borderRightWidth;
464
+ if (pad > 0) {
465
+ allWords.push({ text: '', width: pad, style: run.style, isSpace: false, boxStyle: run.boxStyle, boxClose: run.boxClose });
466
+ }
467
+ continue;
468
+ }
469
+ applyFont(ctx, run.style);
470
+ ctx.letterSpacing = run.style.letterSpacing > 0 ? `${run.style.letterSpacing}px` : '0px';
471
+ const text = applyTextTransform(run.text, run.style.textTransform);
472
+ // Handle explicit newlines (from <br> or pre-wrap) — always force line break
473
+ if (text.includes('\n')) {
474
+ const parts = text.split('\n');
475
+ for (let i = 0; i < parts.length; i++) {
476
+ if (i > 0) {
477
+ allWords.push({ text: '\n', width: 0, style: run.style, isSpace: false, boxStyle: run.boxStyle });
478
+ }
479
+ if (parts[i]) {
480
+ tokenizeString(ctx, parts[i], run, allWords);
481
+ }
482
+ }
483
+ }
484
+ else {
485
+ tokenizeString(ctx, text, run, allWords);
486
+ }
487
+ }
488
+ return allWords;
489
+ }
490
+ /**
491
+ * Check if a character is CJK (Chinese/Japanese/Korean) — these wrap at character level.
492
+ */
493
+ function isCJK(char) {
494
+ const code = char.codePointAt(0) || 0;
495
+ return ((code >= 0x4E00 && code <= 0x9FFF) || // CJK Unified
496
+ (code >= 0x3400 && code <= 0x4DBF) || // CJK Extension A
497
+ (code >= 0x3000 && code <= 0x303F) || // CJK Symbols
498
+ (code >= 0x3040 && code <= 0x309F) || // Hiragana
499
+ (code >= 0x30A0 && code <= 0x30FF) || // Katakana
500
+ (code >= 0xAC00 && code <= 0xD7AF) || // Hangul
501
+ (code >= 0xFF00 && code <= 0xFFEF) || // Fullwidth
502
+ (code >= 0x20000 && code <= 0x2A6DF) // CJK Extension B
503
+ );
504
+ }
505
+ /**
506
+ * Break a word into character-level pieces if it contains CJK or if
507
+ * overflow-wrap: break-word is set and the word is too wide.
508
+ */
509
+ function breakWordIfNeeded(ctx, word, contentWidth, currentLineWidth) {
510
+ // Check if word has CJK characters — always break at character level
511
+ const hasCJK = [...word.text].some(isCJK);
512
+ // Check if word needs break-word splitting
513
+ const needsBreak = word.width > contentWidth &&
514
+ (word.style.overflowWrap === 'break-word' || word.style.wordBreak === 'break-all');
515
+ if (!hasCJK && !needsBreak)
516
+ return [word];
517
+ // Split into characters
518
+ ctx.font = buildCanvasFont(word.style);
519
+ const chars = [...word.text];
520
+ const pieces = [];
521
+ let current = '';
522
+ let currentWidth = 0;
523
+ for (const char of chars) {
524
+ const charWidth = ctx.measureText(char).width;
525
+ // CJK chars always get their own word for wrapping
526
+ if (isCJK(char)) {
527
+ if (current) {
528
+ pieces.push({ ...word, text: current, width: currentWidth });
529
+ current = '';
530
+ currentWidth = 0;
531
+ }
532
+ pieces.push({ ...word, text: char, width: charWidth });
533
+ continue;
534
+ }
535
+ // For break-word: break when adding this char would exceed container
536
+ if (needsBreak && currentWidth + charWidth > contentWidth && current) {
537
+ pieces.push({ ...word, text: current, width: currentWidth });
538
+ current = '';
539
+ currentWidth = 0;
540
+ }
541
+ current += char;
542
+ currentWidth += charWidth;
543
+ }
544
+ if (current) {
545
+ pieces.push({ ...word, text: current, width: currentWidth });
546
+ }
547
+ return pieces;
548
+ }
549
+ /**
550
+ * Flow words into lines that fit within contentWidth.
551
+ * Handles: word wrapping, nowrap, break-word, CJK character wrapping.
552
+ */
553
+ function flowWordsIntoLines(ctx, words, contentWidth, whiteSpace, useBulletProbe = false) {
554
+ const lines = [];
555
+ let currentLine = { words: [], totalWidth: 0, lineHeight: 0 };
556
+ const noWrap = whiteSpace === 'nowrap' || whiteSpace === 'pre';
557
+ const isPreWrap = whiteSpace === 'pre-wrap' || whiteSpace === 'pre' || whiteSpace === 'pre-line';
558
+ function pushLine() {
559
+ const hadWords = currentLine.words.length > 0;
560
+ // Trim trailing spaces
561
+ while (currentLine.words.length > 0 && currentLine.words[currentLine.words.length - 1].isSpace) {
562
+ currentLine.totalWidth -= currentLine.words[currentLine.words.length - 1].width;
563
+ currentLine.words.pop();
564
+ }
565
+ // In pre-wrap mode, space-only lines still need height (they are content)
566
+ if (currentLine.words.length > 0 || (hadWords && isPreWrap)) {
567
+ if (_debug) {
568
+ const text = currentLine.words.map(w => w.text).join('');
569
+ _debug({
570
+ type: 'line-commit',
571
+ message: `Line ${lines.length}: "${text}" width=${currentLine.totalWidth.toFixed(2)} / ${contentWidth}`,
572
+ data: { lineIndex: lines.length, text, totalWidth: currentLine.totalWidth, contentWidth },
573
+ });
574
+ }
575
+ lines.push(currentLine);
576
+ }
577
+ currentLine = { words: [], totalWidth: 0, lineHeight: 0 };
578
+ }
579
+ let afterHardBreak = true; // start of content is like after a hard break
580
+ for (const word of words) {
581
+ let wordLineHeight = getLineHeight(ctx, word.style, useBulletProbe);
582
+ // Inline-block elements expand line height with their vertical padding+margin
583
+ if (word.boxStyle && word.boxStyle.display === 'inline-block') {
584
+ const bs = word.boxStyle;
585
+ wordLineHeight = Math.max(wordLineHeight, wordLineHeight + bs.paddingTop + bs.paddingBottom + bs.marginTop + bs.marginBottom
586
+ + bs.borderTopWidth + bs.borderBottomWidth);
587
+ }
588
+ if (word.text === '\n') {
589
+ if (currentLine.words.length === 0) {
590
+ currentLine.lineHeight = wordLineHeight;
591
+ lines.push(currentLine);
592
+ currentLine = { words: [], totalWidth: 0, lineHeight: 0 };
593
+ }
594
+ else {
595
+ pushLine();
596
+ }
597
+ afterHardBreak = true;
598
+ continue;
599
+ }
600
+ // No wrapping mode — everything on one line
601
+ if (noWrap) {
602
+ currentLine.words.push(word);
603
+ currentLine.totalWidth += word.width;
604
+ currentLine.lineHeight = Math.max(currentLine.lineHeight, wordLineHeight);
605
+ continue;
606
+ }
607
+ // Break long words / CJK characters if needed
608
+ const pieces = (!word.isSpace && word.text.length > 1)
609
+ ? breakWordIfNeeded(ctx, word, contentWidth, currentLine.totalWidth)
610
+ : [word];
611
+ for (const piece of pieces) {
612
+ // Would this piece overflow?
613
+ if (!piece.isSpace && currentLine.words.length > 0 &&
614
+ currentLine.totalWidth + piece.width > contentWidth) {
615
+ const overflow = currentLine.totalWidth + piece.width - contentWidth;
616
+ // For borderline cases (overflow < 1px), word-by-word delta
617
+ // accumulation may introduce rounding errors. Re-measure the
618
+ // full candidate line as a single string for accuracy.
619
+ // Only works for single-font lines — mixed fonts can't be
620
+ // measured as one string.
621
+ let reallyOverflows = true;
622
+ if (overflow < 1 && !hasMixedFonts([...currentLine.words, piece])) {
623
+ applyFont(ctx, piece.style);
624
+ const fullText = currentLine.words.map(w => w.text).join('') + piece.text;
625
+ const fullWidth = ctx.measureText(fullText).width;
626
+ // Allow tiny sub-pixel overflow — canvas measureText and DOM
627
+ // text layout can differ by fractions of a pixel.
628
+ if (fullWidth <= contentWidth + 0.1) {
629
+ reallyOverflows = false;
630
+ }
631
+ }
632
+ if (reallyOverflows) {
633
+ if (_debug) {
634
+ const lineText = currentLine.words.map(w => w.text).join('');
635
+ _debug({
636
+ type: 'line-wrap',
637
+ message: `"${piece.text}" overflow=${overflow.toFixed(2)} wrap=true lineWidth=${currentLine.totalWidth.toFixed(2)} pieceWidth=${piece.width.toFixed(2)} contentWidth=${contentWidth} line="${lineText}"`,
638
+ data: { text: piece.text, overflow, lineWidth: currentLine.totalWidth, pieceWidth: piece.width, contentWidth, lineText },
639
+ });
640
+ }
641
+ pushLine();
642
+ afterHardBreak = false;
643
+ }
644
+ }
645
+ // Skip leading spaces after soft wraps, but preserve after hard breaks (\n)
646
+ if (piece.isSpace && currentLine.words.length === 0 && !afterHardBreak)
647
+ continue;
648
+ // Reverse check: canvas says it fits, but with mixed fonts near boundary,
649
+ // DOM might say it doesn't fit. Verify before committing.
650
+ // Only check when line is >80% full to avoid excessive DOM measurements.
651
+ if (_useDomMeasurements && !piece.isSpace && currentLine.words.length > 0 &&
652
+ currentLine.totalWidth > contentWidth * 0.8) {
653
+ const remaining = contentWidth - (currentLine.totalWidth + piece.width);
654
+ if (remaining >= 0 && remaining < 5 && hasMixedFonts(currentLine.words)) {
655
+ const candidateWords = [...currentLine.words, piece];
656
+ const domWidth = measureLineWidthViaDom(candidateWords);
657
+ if (domWidth > contentWidth) {
658
+ if (_debug) {
659
+ _debug({
660
+ type: 'line-wrap',
661
+ message: `REVERSE WRAP: "${piece.text}" canvas says fits (remaining=${remaining.toFixed(2)}) but DOM says overflow (domWidth=${domWidth.toFixed(2)})`,
662
+ data: { text: piece.text, remaining, domWidth, contentWidth },
663
+ });
664
+ }
665
+ pushLine();
666
+ afterHardBreak = false;
667
+ }
668
+ }
669
+ }
670
+ // Tab: snap to next tab stop based on current position
671
+ let pieceWidth = piece.width;
672
+ if (piece.isTab) {
673
+ const tabStop = piece.width; // tabStopInterval stored as width
674
+ const currentPos = currentLine.totalWidth;
675
+ const nextStop = Math.ceil((currentPos + 0.1) / tabStop) * tabStop;
676
+ pieceWidth = nextStop - currentPos;
677
+ piece.width = pieceWidth;
678
+ }
679
+ currentLine.words.push(piece);
680
+ currentLine.totalWidth += pieceWidth;
681
+ currentLine.lineHeight = Math.max(currentLine.lineHeight, wordLineHeight);
682
+ if (!piece.isSpace)
683
+ afterHardBreak = false;
684
+ }
685
+ }
686
+ pushLine();
687
+ return lines;
688
+ }
689
+ /**
690
+ * Layout inline content: text wrapping + positioning using pure canvas measurement.
691
+ * Returns layout nodes and the total height consumed.
692
+ */
693
+ function layoutInlineContent(ctx, node, x, y, contentWidth, useBulletProbe = false) {
694
+ const results = [];
695
+ const runs = collectTextRuns(node);
696
+ if (runs.length === 0)
697
+ return { nodes: results, height: 0 };
698
+ const words = tokenizeRuns(ctx, runs);
699
+ const lines = flowWordsIntoLines(ctx, words, contentWidth, node.style.whiteSpace, useBulletProbe);
700
+ const isRTL = node.style.direction === 'rtl';
701
+ let textAlign = node.style.textAlign;
702
+ // In RTL, default alignment is right; 'start'='right', 'end'='left'
703
+ if (textAlign === 'start')
704
+ textAlign = isRTL ? 'right' : 'left';
705
+ if (textAlign === 'end')
706
+ textAlign = isRTL ? 'left' : 'right';
707
+ let curY = y;
708
+ for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
709
+ const line = lines[lineIdx];
710
+ if (line.words.length === 0) {
711
+ curY += line.lineHeight;
712
+ continue;
713
+ }
714
+ const lineHeight = line.lineHeight;
715
+ const isLastLine = lineIdx === lines.length - 1;
716
+ // Justify: expand spaces to fill the line (except last line)
717
+ let justifyExtraPerSpace = 0;
718
+ if (textAlign === 'justify' && !isLastLine && line.totalWidth < contentWidth) {
719
+ const spaceCount = line.words.filter(w => w.isSpace).length;
720
+ if (spaceCount > 0) {
721
+ justifyExtraPerSpace = (contentWidth - line.totalWidth) / spaceCount;
722
+ }
723
+ }
724
+ // text-align
725
+ let curX = x;
726
+ if (textAlign === 'center') {
727
+ curX = x + (contentWidth - line.totalWidth) / 2;
728
+ }
729
+ else if (textAlign === 'right' || (textAlign !== 'justify' && isRTL)) {
730
+ curX = x + contentWidth - line.totalWidth;
731
+ }
732
+ else if (isRTL) {
733
+ curX = x + contentWidth - line.totalWidth;
734
+ }
735
+ // Two-pass: first collect inline background boxes, then text.
736
+ // This ensures backgrounds are rendered before (behind) text.
737
+ // Pass 1: find inline background box regions
738
+ {
739
+ let scanX = curX;
740
+ let boxStartX = scanX;
741
+ let currentBoxStyle;
742
+ let boxHasText = false; // track if region has visible text (not just padding/spaces)
743
+ const emitBox = (style, startX, endX) => {
744
+ // Don't emit background box if this line segment has no visible text
745
+ // (e.g. only a boxOpen padding marker + trailing space before wrap)
746
+ if (!boxHasText)
747
+ return;
748
+ ctx.font = buildCanvasFont(style);
749
+ const metrics = ctx.measureText('Mgy');
750
+ const ascent = metrics.fontBoundingBoxAscent ?? metrics.actualBoundingBoxAscent;
751
+ const descent = metrics.fontBoundingBoxDescent ?? metrics.actualBoundingBoxDescent;
752
+ const emHeight = ascent + descent;
753
+ const boxHeight = emHeight + style.paddingTop + style.paddingBottom
754
+ + style.borderTopWidth + style.borderBottomWidth;
755
+ // For inline-block: position with margin offset. For regular inline: center.
756
+ let boxY;
757
+ if (style.display === 'inline-block') {
758
+ boxY = curY + style.marginTop;
759
+ }
760
+ else {
761
+ boxY = curY + (lineHeight - boxHeight) / 2;
762
+ }
763
+ results.push({
764
+ type: 'box',
765
+ style,
766
+ x: startX,
767
+ y: boxY,
768
+ width: endX - startX,
769
+ height: boxHeight,
770
+ tagName: 'span',
771
+ children: [],
772
+ });
773
+ };
774
+ for (const word of line.words) {
775
+ // Atomic inline-block: emit box with margin offset
776
+ if (word.boxOpen && word.boxClose && word.text) {
777
+ if (currentBoxStyle) {
778
+ emitBox(currentBoxStyle, boxStartX, scanX);
779
+ currentBoxStyle = undefined;
780
+ boxHasText = false;
781
+ }
782
+ const s = word.style;
783
+ const textWidth = word.width - s.marginLeft - s.borderLeftWidth - s.paddingLeft
784
+ - s.paddingRight - s.borderRightWidth - s.marginRight;
785
+ const boxX = scanX + s.marginLeft;
786
+ const boxW = s.borderLeftWidth + s.paddingLeft + textWidth + s.paddingRight + s.borderRightWidth;
787
+ boxHasText = true;
788
+ emitBox(s, boxX, boxX + boxW);
789
+ boxHasText = false;
790
+ scanX += word.width;
791
+ continue;
792
+ }
793
+ if (word.boxStyle !== currentBoxStyle) {
794
+ if (currentBoxStyle) {
795
+ emitBox(currentBoxStyle, boxStartX, scanX);
796
+ }
797
+ currentBoxStyle = word.boxStyle;
798
+ boxStartX = scanX;
799
+ boxHasText = false;
800
+ }
801
+ // Track if we've seen actual text content (not spaces or empty padding markers)
802
+ if (word.text && !word.isSpace) {
803
+ boxHasText = true;
804
+ }
805
+ scanX += word.width + (word.isSpace ? justifyExtraPerSpace : 0);
806
+ }
807
+ if (currentBoxStyle) {
808
+ emitBox(currentBoxStyle, boxStartX, scanX);
809
+ }
810
+ }
811
+ // Compute a single shared baseline for the entire line.
812
+ // Exclude sub/sup words — they sit above/below the baseline and
813
+ // shouldn't influence where the baseline is positioned.
814
+ let maxAscent = 0;
815
+ let maxDescent = 0;
816
+ for (const word of line.words) {
817
+ if (word.text === '')
818
+ continue;
819
+ const va = word.style.verticalAlign;
820
+ if (va === 'super' || va === 'sub')
821
+ continue; // skip sub/sup for baseline calc
822
+ ctx.font = buildCanvasFont(word.style);
823
+ const m = ctx.measureText('M');
824
+ const a = m.fontBoundingBoxAscent ?? m.actualBoundingBoxAscent;
825
+ const d = m.fontBoundingBoxDescent ?? m.actualBoundingBoxDescent;
826
+ if (a > maxAscent)
827
+ maxAscent = a;
828
+ if (d > maxDescent)
829
+ maxDescent = d;
830
+ }
831
+ // If only sub/sup words on the line, use the first word's metrics
832
+ if (maxAscent === 0) {
833
+ for (const word of line.words) {
834
+ if (word.text === '')
835
+ continue;
836
+ ctx.font = buildCanvasFont(word.style);
837
+ const m = ctx.measureText('M');
838
+ maxAscent = m.fontBoundingBoxAscent ?? m.actualBoundingBoxAscent;
839
+ maxDescent = m.fontBoundingBoxDescent ?? m.actualBoundingBoxDescent;
840
+ break;
841
+ }
842
+ }
843
+ // Center the text block (ascent + descent) within the lineHeight
844
+ const textBlockHeight = maxAscent + maxDescent;
845
+ let lineBaselineY = curY + (lineHeight - textBlockHeight) / 2 + maxAscent;
846
+ // Expand line height if sub/sup extends beyond the line box.
847
+ // Browsers grow the line box to fit all content, but keep
848
+ // the normal text baseline position unchanged.
849
+ let effectiveLineHeight = lineHeight;
850
+ {
851
+ const lineNormalWords = line.words.filter(w => w.text !== '' && w.style.verticalAlign !== 'super' && w.style.verticalAlign !== 'sub');
852
+ const parentFontSize = lineNormalWords.length > 0
853
+ ? Math.max(...lineNormalWords.map(w => w.style.fontSize)) : 0;
854
+ let minTop = curY;
855
+ let maxBottom = curY + lineHeight;
856
+ for (const word of line.words) {
857
+ if (word.text === '')
858
+ continue;
859
+ const va = word.style.verticalAlign;
860
+ if (va !== 'super' && va !== 'sub')
861
+ continue;
862
+ if (parentFontSize === 0)
863
+ break;
864
+ ctx.font = buildCanvasFont(word.style);
865
+ const m = ctx.measureText('M');
866
+ const wAscent = m.fontBoundingBoxAscent ?? m.actualBoundingBoxAscent;
867
+ const wDescent = m.fontBoundingBoxDescent ?? m.actualBoundingBoxDescent;
868
+ let shiftedBaseline = lineBaselineY;
869
+ if (va === 'super') {
870
+ shiftedBaseline -= parentFontSize * 0.4;
871
+ }
872
+ else {
873
+ shiftedBaseline += parentFontSize * 0.26;
874
+ }
875
+ const wordTop = shiftedBaseline - wAscent;
876
+ const wordBottom = shiftedBaseline + wDescent;
877
+ if (wordTop < minTop)
878
+ minTop = wordTop;
879
+ if (wordBottom > maxBottom)
880
+ maxBottom = wordBottom;
881
+ }
882
+ effectiveLineHeight = maxBottom - minTop;
883
+ }
884
+ // Pass 2: emit text
885
+ // For RTL lines with uniform style, emit as a single text node
886
+ // so the canvas can handle BiDi glyph shaping and connected letters.
887
+ const textWords = line.words.filter(w => w.text !== '');
888
+ const allSameStyle = textWords.length > 0 && textWords.every(w => w.style.fontFamily === textWords[0].style.fontFamily &&
889
+ w.style.fontSize === textWords[0].style.fontSize &&
890
+ w.style.fontWeight === textWords[0].style.fontWeight &&
891
+ w.style.fontStyle === textWords[0].style.fontStyle &&
892
+ w.style.color === textWords[0].style.color);
893
+ if (isRTL) {
894
+ // RTL: render words right-to-left
895
+ // Join consecutive words with same style into groups for proper glyph shaping
896
+ let rtlX = curX + line.totalWidth; // start from right edge
897
+ const groups = [];
898
+ let currentGroup = null;
899
+ for (const word of line.words) {
900
+ if (word.text === '') {
901
+ // Padding marker — flush current group and add spacing
902
+ if (currentGroup) {
903
+ groups.push(currentGroup);
904
+ currentGroup = null;
905
+ }
906
+ rtlX -= word.width;
907
+ continue;
908
+ }
909
+ if (currentGroup &&
910
+ currentGroup.style.fontFamily === word.style.fontFamily &&
911
+ currentGroup.style.fontSize === word.style.fontSize &&
912
+ currentGroup.style.fontWeight === word.style.fontWeight &&
913
+ currentGroup.style.fontStyle === word.style.fontStyle &&
914
+ currentGroup.style.color === word.style.color) {
915
+ currentGroup.text += word.text;
916
+ currentGroup.width += word.width;
917
+ }
918
+ else {
919
+ if (currentGroup)
920
+ groups.push(currentGroup);
921
+ currentGroup = { text: word.text, style: word.style, width: word.width };
922
+ }
923
+ }
924
+ if (currentGroup)
925
+ groups.push(currentGroup);
926
+ // Emit groups right-to-left
927
+ for (const group of groups) {
928
+ ctx.font = buildCanvasFont(group.style);
929
+ const measuredWidth = ctx.measureText(group.text).width;
930
+ rtlX -= measuredWidth;
931
+ results.push({
932
+ type: 'text',
933
+ text: group.text,
934
+ x: rtlX + measuredWidth, // x = right edge for RTL textAlign
935
+ y: lineBaselineY,
936
+ width: measuredWidth,
937
+ style: { ...group.style, direction: 'rtl' },
938
+ });
939
+ }
940
+ }
941
+ else {
942
+ // LTR: word by word
943
+ for (const word of line.words) {
944
+ if (word.text === '') {
945
+ curX += word.width;
946
+ continue;
947
+ }
948
+ // Atomic inline-block: position text inside the box (after margin + padding)
949
+ if (word.boxOpen && word.boxClose) {
950
+ const s = word.style;
951
+ const textX = curX + s.marginLeft + s.borderLeftWidth + s.paddingLeft;
952
+ results.push({
953
+ type: 'text',
954
+ text: word.text,
955
+ x: textX,
956
+ y: lineBaselineY,
957
+ width: ctx.measureText(word.text).width,
958
+ style: word.style,
959
+ });
960
+ curX += word.width;
961
+ continue;
962
+ }
963
+ // Adjust baseline for vertical-align
964
+ let baselineY = lineBaselineY;
965
+ const va = word.style.verticalAlign;
966
+ if (va === 'super' || va === 'sub') {
967
+ // Find parent font size (the normal-sized text on this line)
968
+ const normalWords = textWords.filter(w => w.style.verticalAlign !== 'super' && w.style.verticalAlign !== 'sub');
969
+ const parentFontSize = normalWords.length > 0
970
+ ? Math.max(...normalWords.map(w => w.style.fontSize))
971
+ : word.style.fontSize;
972
+ if (va === 'super') {
973
+ // Chrome raises super baseline by ~0.4em of parent font size
974
+ baselineY -= parentFontSize * 0.4;
975
+ }
976
+ else {
977
+ // Chrome lowers sub baseline by ~0.26em of parent font size
978
+ baselineY += parentFontSize * 0.26;
979
+ }
980
+ }
981
+ const effectiveWidth = word.width + (word.isSpace ? justifyExtraPerSpace : 0);
982
+ results.push({
983
+ type: 'text',
984
+ text: word.text,
985
+ x: curX,
986
+ y: baselineY,
987
+ width: effectiveWidth,
988
+ style: word.style,
989
+ });
990
+ curX += effectiveWidth;
991
+ }
992
+ }
993
+ curY += effectiveLineHeight;
994
+ }
995
+ return { nodes: results, height: curY - y };
996
+ }
997
+ // ─── Block layout ──────────────────────────────────────────────────────
998
+ /**
999
+ * Collapse margins between two adjacent block elements.
1000
+ * Returns the effective spacing (max of the two margins, not sum).
1001
+ */
1002
+ function collapseMargins(prevMarginBottom, nextMarginTop) {
1003
+ // Both positive: take the larger
1004
+ if (prevMarginBottom >= 0 && nextMarginTop >= 0) {
1005
+ return Math.max(prevMarginBottom, nextMarginTop);
1006
+ }
1007
+ // Both negative: take the more negative
1008
+ if (prevMarginBottom < 0 && nextMarginTop < 0) {
1009
+ return Math.min(prevMarginBottom, nextMarginTop);
1010
+ }
1011
+ // One positive, one negative: sum them
1012
+ return prevMarginBottom + nextMarginTop;
1013
+ }
1014
+ /**
1015
+ * Check if a node is a block-level display.
1016
+ */
1017
+ function isBlock(node) {
1018
+ const d = node.style.display;
1019
+ return d === 'block' || d === 'list-item' || d === 'flex' || d === 'table' ||
1020
+ d === 'table-row' || d === 'table-cell' || d === 'table-row-group' ||
1021
+ d === 'table-header-group' || d === 'table-footer-group';
1022
+ }
1023
+ /**
1024
+ * Layout a block-level element and all its children.
1025
+ * Returns the LayoutBox and total height consumed (including margins).
1026
+ */
1027
+ function layoutBlock(ctx, node, x, y, availableWidth) {
1028
+ const style = node.style;
1029
+ // Box model
1030
+ const marginLeft = style.marginLeft;
1031
+ const marginRight = style.marginRight;
1032
+ const borderLeft = style.borderLeftWidth;
1033
+ const borderRight = style.borderRightWidth;
1034
+ const borderTop = style.borderTopWidth;
1035
+ const borderBottom = style.borderBottomWidth;
1036
+ const padLeft = style.paddingLeft;
1037
+ const padRight = style.paddingRight;
1038
+ const padTop = style.paddingTop;
1039
+ const padBottom = style.paddingBottom;
1040
+ const boxX = x + marginLeft;
1041
+ // If element has explicit width, use it; otherwise fill available width
1042
+ const boxWidth = (style.width > 0)
1043
+ ? style.width
1044
+ : availableWidth - marginLeft - marginRight;
1045
+ const contentX = boxX + borderLeft + padLeft;
1046
+ const contentWidth = Math.max(0, boxWidth - borderLeft - borderRight - padLeft - padRight);
1047
+ const boxY = y;
1048
+ const contentStartY = boxY + borderTop + padTop;
1049
+ const box = {
1050
+ type: 'box',
1051
+ style,
1052
+ x: boxX,
1053
+ y: boxY,
1054
+ width: boxWidth,
1055
+ height: 0, // computed below
1056
+ tagName: node.tagName,
1057
+ children: [],
1058
+ listMarker: node.listMarker,
1059
+ };
1060
+ // Flex layout
1061
+ if (style.display === 'flex') {
1062
+ const result = layoutFlex(ctx, node, contentX, contentStartY, contentWidth);
1063
+ box.children = result.children;
1064
+ box.height = borderTop + padTop + result.height + padBottom + borderBottom;
1065
+ return { box, height: box.height, marginBottomOut: style.marginBottom };
1066
+ }
1067
+ // Table layout
1068
+ if (style.display === 'table') {
1069
+ const result = layoutTable(ctx, node, contentX, contentStartY, contentWidth);
1070
+ box.children = result.children;
1071
+ box.height = borderTop + padTop + result.height + padBottom + borderBottom;
1072
+ return { box, height: box.height, marginBottomOut: style.marginBottom };
1073
+ }
1074
+ // Empty block elements: zero content height (CSS spec — no line boxes created).
1075
+ // Only min-height or padding/border contribute to height.
1076
+ if (node.children.length === 0) {
1077
+ box.height = borderTop + padTop + padBottom + borderBottom;
1078
+ if (style.minHeight > 0)
1079
+ box.height = Math.max(box.height, style.minHeight);
1080
+ return { box, height: box.height, marginBottomOut: style.marginBottom };
1081
+ }
1082
+ // Layout children
1083
+ if (hasOnlyInlineChildren(node)) {
1084
+ // Inline formatting context
1085
+ const bulletProbe = node.tagName === 'li' && BULLET_MARKERS.has(style.listStyleType);
1086
+ const { nodes, height } = layoutInlineContent(ctx, node, contentX, contentStartY, contentWidth, bulletProbe);
1087
+ box.children = nodes;
1088
+ box.height = borderTop + padTop + height + padBottom + borderBottom;
1089
+ }
1090
+ else {
1091
+ // Block formatting context — stack children vertically
1092
+ let curY = contentStartY;
1093
+ let prevMarginBottom = 0;
1094
+ let hasContent = false; // tracks whether we've placed any content
1095
+ // Margin collapsing through parent: only for list elements.
1096
+ const allowCollapseThrough = node.tagName === 'li' || node.tagName === 'ul' || node.tagName === 'ol' ||
1097
+ node.tagName === 'dd' || node.tagName === 'dt';
1098
+ for (let ci = 0; ci < node.children.length; ci++) {
1099
+ const child = node.children[ci];
1100
+ if (child.tagName === '#text' || isInline(child)) {
1101
+ // Collect ALL consecutive inline/text children into one group
1102
+ const inlineChildren = [child];
1103
+ while (ci + 1 < node.children.length) {
1104
+ const next = node.children[ci + 1];
1105
+ if (next.tagName === '#text' || isInline(next)) {
1106
+ inlineChildren.push(next);
1107
+ ci++;
1108
+ }
1109
+ else {
1110
+ break;
1111
+ }
1112
+ }
1113
+ // Apply pending margin before inline content
1114
+ if (prevMarginBottom > 0) {
1115
+ curY += prevMarginBottom;
1116
+ prevMarginBottom = 0;
1117
+ }
1118
+ const inlineGroup = {
1119
+ element: null,
1120
+ tagName: 'div',
1121
+ style: { ...node.style, display: 'block', marginTop: 0, marginBottom: 0, paddingTop: 0, paddingBottom: 0, borderTopWidth: 0, borderBottomWidth: 0 },
1122
+ children: inlineChildren,
1123
+ textContent: null,
1124
+ };
1125
+ const bulletProbe2 = node.tagName === 'li' && BULLET_MARKERS.has(style.listStyleType);
1126
+ const { nodes, height } = layoutInlineContent(ctx, inlineGroup, contentX, curY, contentWidth, bulletProbe2);
1127
+ box.children.push(...nodes);
1128
+ curY += height;
1129
+ prevMarginBottom = 0;
1130
+ hasContent = true;
1131
+ continue;
1132
+ }
1133
+ // Block child — collapse margins
1134
+ const childMarginTop = child.style.marginTop;
1135
+ // First child margin-top collapses through parent if parent has no top border/padding
1136
+ // Only for elements that don't establish a new BFC (not root, not flex, not overflow)
1137
+ // First child margin-top collapses through parent if parent has no
1138
+ // top padding/border and doesn't establish a new BFC.
1139
+ if (!hasContent && padTop === 0 && borderTop === 0 && allowCollapseThrough) {
1140
+ // Skip — margin collapses with parent's margin
1141
+ }
1142
+ else {
1143
+ const collapsed = collapseMargins(prevMarginBottom, childMarginTop);
1144
+ curY += collapsed;
1145
+ }
1146
+ const { box: childBox, height: childTotalHeight, marginBottomOut } = layoutBlock(ctx, child, contentX, curY, contentWidth);
1147
+ box.children.push(childBox);
1148
+ curY += childTotalHeight;
1149
+ prevMarginBottom = marginBottomOut;
1150
+ hasContent = true;
1151
+ }
1152
+ // Last child's margin-bottom collapses through parent if no bottom border/padding.
1153
+ // Root container does NOT collapse last-child margin (it defines the content height).
1154
+ let marginBottomOut = style.marginBottom;
1155
+ const canCollapseThrough = padBottom === 0 && borderBottom === 0 && allowCollapseThrough;
1156
+ if (canCollapseThrough && prevMarginBottom > 0) {
1157
+ // Last child's margin passes through to become parent's effective margin-bottom
1158
+ marginBottomOut = Math.max(style.marginBottom, prevMarginBottom);
1159
+ }
1160
+ // Include last child's margin-bottom in parent height when it can't collapse through
1161
+ let contentEnd = curY - contentStartY;
1162
+ if (!canCollapseThrough && prevMarginBottom > 0) {
1163
+ contentEnd += prevMarginBottom;
1164
+ }
1165
+ box.height = borderTop + padTop + contentEnd + padBottom + borderBottom;
1166
+ if (style.minHeight > 0)
1167
+ box.height = Math.max(box.height, style.minHeight);
1168
+ return { box, height: box.height, marginBottomOut };
1169
+ }
1170
+ if (style.minHeight > 0)
1171
+ box.height = Math.max(box.height, style.minHeight);
1172
+ return { box, height: box.height, marginBottomOut: style.marginBottom };
1173
+ }
1174
+ // ─── Table layout ──────────────────────────────────────────────────────
1175
+ function layoutTable(ctx, node, contentX, contentY, contentWidth) {
1176
+ const children = [];
1177
+ // Collect rows from thead, tbody, tfoot, or direct tr children
1178
+ const rows = [];
1179
+ for (const child of node.children) {
1180
+ if (child.tagName === 'tr') {
1181
+ rows.push(child);
1182
+ }
1183
+ else if (['thead', 'tbody', 'tfoot'].includes(child.tagName)) {
1184
+ for (const grandchild of child.children) {
1185
+ if (grandchild.tagName === 'tr')
1186
+ rows.push(grandchild);
1187
+ }
1188
+ }
1189
+ }
1190
+ if (rows.length === 0)
1191
+ return { children, height: 0 };
1192
+ // Determine column count from first row
1193
+ const colCount = Math.max(...rows.map(r => r.children.filter(c => c.tagName === 'td' || c.tagName === 'th').length));
1194
+ if (colCount === 0)
1195
+ return { children, height: 0 };
1196
+ // Equal column widths (simple approach)
1197
+ const colWidth = contentWidth / colCount;
1198
+ let curY = contentY;
1199
+ for (const row of rows) {
1200
+ const cells = row.children.filter(c => c.tagName === 'td' || c.tagName === 'th');
1201
+ let maxCellHeight = 0;
1202
+ const cellBoxes = [];
1203
+ for (let i = 0; i < cells.length; i++) {
1204
+ const cell = cells[i];
1205
+ const cellX = contentX + i * colWidth;
1206
+ const { box: cellBox, height: cellHeight } = layoutBlock(ctx, cell, cellX, curY, colWidth);
1207
+ cellBoxes.push(cellBox);
1208
+ maxCellHeight = Math.max(maxCellHeight, cellHeight);
1209
+ }
1210
+ // Normalize cell heights to the tallest cell in the row
1211
+ for (const cellBox of cellBoxes) {
1212
+ cellBox.height = maxCellHeight;
1213
+ children.push(cellBox);
1214
+ }
1215
+ curY += maxCellHeight;
1216
+ }
1217
+ return { children, height: curY - contentY };
1218
+ }
1219
+ // ─── Flex layout ───────────────────────────────────────────────────────
1220
+ function layoutFlex(ctx, node, contentX, contentY, contentWidth) {
1221
+ const style = node.style;
1222
+ const gap = style.gap;
1223
+ const children = [];
1224
+ const flexChildren = node.children.filter(c => c.tagName !== '#text' || c.textContent?.trim());
1225
+ if (flexChildren.length === 0)
1226
+ return { children, height: 0 };
1227
+ if (style.flexDirection === 'row' || style.flexDirection === '') {
1228
+ // Row layout
1229
+ const totalGaps = gap * (flexChildren.length - 1);
1230
+ const totalGrow = flexChildren.reduce((s, c) => s + (c.style.flexGrow || 0), 0);
1231
+ const flexBasis = (contentWidth - totalGaps) / (totalGrow || flexChildren.length);
1232
+ let curX = contentX;
1233
+ let maxHeight = 0;
1234
+ for (const child of flexChildren) {
1235
+ if (child.tagName === '#text')
1236
+ continue;
1237
+ const grow = child.style.flexGrow || (totalGrow === 0 ? 1 : 0);
1238
+ const childWidth = flexBasis * grow;
1239
+ const { box, height } = layoutBlock(ctx, child, curX, contentY, childWidth);
1240
+ children.push(box);
1241
+ maxHeight = Math.max(maxHeight, height);
1242
+ curX += childWidth + gap;
1243
+ }
1244
+ return { children, height: maxHeight };
1245
+ }
1246
+ // Column layout (fallback)
1247
+ let curY = contentY;
1248
+ for (const child of flexChildren) {
1249
+ if (child.tagName === '#text')
1250
+ continue;
1251
+ const { box, height } = layoutBlock(ctx, child, contentX, curY, contentWidth);
1252
+ children.push(box);
1253
+ curY += height + gap;
1254
+ }
1255
+ return { children, height: curY - contentY };
1256
+ }
1257
+ // ─── List marker layout ────────────────────────────────────────────────
1258
+ /**
1259
+ * Add list marker to a layout box if applicable.
1260
+ */
1261
+ function addListMarker(ctx, box, node) {
1262
+ if (!node.listMarker)
1263
+ return;
1264
+ const style = node.style;
1265
+ ctx.font = buildCanvasFont(style);
1266
+ const lineHeight = getLineHeight(ctx, style);
1267
+ const baselineY = box.y + style.borderTopWidth + style.paddingTop +
1268
+ computeBaselineY(ctx, style, lineHeight);
1269
+ const markerWidth = ctx.measureText(node.listMarker).width;
1270
+ // Content starts at box.x + borderLeft + paddingLeft
1271
+ // Place marker right-aligned within the padding area, with a small gap before content
1272
+ const contentStartX = box.x + style.borderLeftWidth + style.paddingLeft;
1273
+ const gap = style.fontSize * 0.15; // small gap between marker and content
1274
+ const markerX = contentStartX - markerWidth - gap;
1275
+ box.children.unshift({
1276
+ type: 'text',
1277
+ text: node.listMarker,
1278
+ x: markerX,
1279
+ y: baselineY,
1280
+ width: markerWidth,
1281
+ style: { ...style, textDecorationLine: 'none', fontWeight: 400, fontStyle: 'normal' },
1282
+ });
1283
+ }
1284
+ // ─── Main entry ────────────────────────────────────────────────────────
1285
+ /**
1286
+ * Build the layout tree from the styled tree using pure canvas measurement.
1287
+ * No DOM measurements used — all positions computed from CSS values + canvas.measureText.
1288
+ */
1289
+ export function getDomMeasureCount() { return _domMeasureCount; }
1290
+ export function resetDomMeasureCount() { _domMeasureCount = 0; }
1291
+ export function buildLayoutTree(ctx, styledTree, containerWidth, useDomMeasurements = true, debug) {
1292
+ _useDomMeasurements = useDomMeasurements;
1293
+ _debug = debug;
1294
+ // Clear line height cache — fonts may have loaded since last call
1295
+ _lineHeightCache.clear();
1296
+ // The styledTree root is our container div — layout its children as a block flow
1297
+ const { box, height } = layoutBlock(ctx, styledTree, 0, 0, containerWidth);
1298
+ // Add list markers post-layout
1299
+ addListMarkersRecursive(ctx, box, styledTree);
1300
+ return { root: box, height };
1301
+ }
1302
+ function addListMarkersRecursive(ctx, box, node) {
1303
+ addListMarker(ctx, box, node);
1304
+ // Match children — box.children may have extra text/inline nodes,
1305
+ // so we correlate by walking both in parallel
1306
+ let boxChildIdx = 0;
1307
+ for (const styledChild of node.children) {
1308
+ if (styledChild.tagName === '#text' || isInline(styledChild)) {
1309
+ continue;
1310
+ }
1311
+ // Find the matching LayoutBox
1312
+ while (boxChildIdx < box.children.length) {
1313
+ const layoutChild = box.children[boxChildIdx];
1314
+ if (layoutChild.type === 'box' && layoutChild.tagName === styledChild.tagName) {
1315
+ addListMarkersRecursive(ctx, layoutChild, styledChild);
1316
+ boxChildIdx++;
1317
+ break;
1318
+ }
1319
+ boxChildIdx++;
1320
+ }
1321
+ }
1322
+ }
1323
+ //# sourceMappingURL=layout.js.map