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/README.md +159 -0
- package/lib/index.d.ts +12 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +69 -0
- package/lib/index.js.map +1 -0
- package/lib/layout.d.ts +16 -0
- package/lib/layout.d.ts.map +1 -0
- package/lib/layout.js +1323 -0
- package/lib/layout.js.map +1 -0
- package/lib/parse.d.ts +9 -0
- package/lib/parse.d.ts.map +1 -0
- package/lib/parse.js +25 -0
- package/lib/parse.js.map +1 -0
- package/lib/render.d.ts +6 -0
- package/lib/render.d.ts.map +1 -0
- package/lib/render.js +351 -0
- package/lib/render.js.map +1 -0
- package/lib/style-resolver.d.ts +10 -0
- package/lib/style-resolver.d.ts.map +1 -0
- package/lib/style-resolver.js +257 -0
- package/lib/style-resolver.js.map +1 -0
- package/lib/types.d.ts +143 -0
- package/lib/types.d.ts.map +1 -0
- package/lib/types.js +2 -0
- package/lib/types.js.map +1 -0
- package/package.json +39 -0
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
|