render-tag 0.1.28 → 0.1.30
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 +1 -0
- package/lib/css-resolver.d.ts.map +1 -1
- package/lib/css-resolver.js +74 -5
- package/lib/css-resolver.js.map +1 -1
- package/lib/index.d.ts +1 -0
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +1 -0
- package/lib/index.js.map +1 -1
- package/lib/layout.d.ts +13 -0
- package/lib/layout.d.ts.map +1 -1
- package/lib/layout.js +224 -108
- package/lib/layout.js.map +1 -1
- package/lib/path/index.d.ts.map +1 -1
- package/lib/path/index.js +16 -9
- package/lib/path/index.js.map +1 -1
- package/lib/render-tag.umd.js +7 -7
- package/lib/render-tag.umd.js.map +1 -1
- package/lib/render.d.ts +16 -1
- package/lib/render.d.ts.map +1 -1
- package/lib/render.js +43 -8
- package/lib/render.js.map +1 -1
- package/lib/types.d.ts +11 -0
- package/lib/types.d.ts.map +1 -1
- package/package.json +2 -2
package/lib/layout.js
CHANGED
|
@@ -157,12 +157,80 @@ function getLineHeight(ctx, style, useBulletProbe = false) {
|
|
|
157
157
|
return ascent + descent;
|
|
158
158
|
}
|
|
159
159
|
/**
|
|
160
|
-
*
|
|
161
|
-
*
|
|
160
|
+
* Which engine's line rules to follow. Only the UA string can say, because
|
|
161
|
+
* `accuracy: 'performance'` promises not to touch the DOM: Gecko is the one
|
|
162
|
+
* engine that still sends a real `Gecko/<date>` product token (Blink and
|
|
163
|
+
* WebKit carry only the "like Gecko" comment, which has no slash), and Blink
|
|
164
|
+
* is the one that says `Chrome/` — matched with NO word boundary, because
|
|
165
|
+
* headless Chrome says `HeadlessChrome/`. Safari sends neither token, which is
|
|
166
|
+
* how it is told apart. With no navigator (Node, workers) we take the Blink
|
|
167
|
+
* branch, for the same target.
|
|
162
168
|
*/
|
|
163
|
-
|
|
169
|
+
const UA = typeof navigator === 'undefined' ? '' : navigator.userAgent;
|
|
170
|
+
const IS_BLINK = UA === '' || /Chrome\/\d/.test(UA);
|
|
171
|
+
const IS_GECKO = /\bGecko\/\d/.test(UA);
|
|
172
|
+
/**
|
|
173
|
+
* True where the engine floors a line's baseline onto a whole CSS pixel.
|
|
174
|
+
*
|
|
175
|
+
* Blink alone does (`FontHeight::AddLeading`). Gecko and WebKit both lay the
|
|
176
|
+
* exact half-leading out — measured over the whole 530-case corpus, giving
|
|
177
|
+
* Safari the Blink branch cost 214 wins against 223 losses (avg 7.50% ->
|
|
178
|
+
* 9.21%) where the exact value wins 48 against 3 (7.50% -> 6.52%).
|
|
179
|
+
*
|
|
180
|
+
* Public API: the parity suites expect per engine, and every renderer that has
|
|
181
|
+
* to place a baseline beside a render-tag canvas (@polotno/svg-export) must
|
|
182
|
+
* round the same way this does.
|
|
183
|
+
*/
|
|
184
|
+
export const FLOORS_LINE_BASELINE = IS_BLINK;
|
|
185
|
+
/**
|
|
186
|
+
* `super` and `sub` are engine constants, not CSS. Blink and WebKit share
|
|
187
|
+
* theirs (`fontSize/3 + 1`, `fontSize/5 + 1`); Gecko raises by 0.34em and
|
|
188
|
+
* lowers by 0.20em. This is a separate question from the baseline rounding
|
|
189
|
+
* above — Safari rounds like nobody and shifts like Blink.
|
|
190
|
+
*/
|
|
191
|
+
const BLINK_SUPER_SUB = !IS_GECKO;
|
|
192
|
+
/**
|
|
193
|
+
* Baseline offset from the top of a line box, the way the engine places it.
|
|
194
|
+
*
|
|
195
|
+
* The CSS half-leading is `(lineHeight - (ascent + descent)) / 2`, and the
|
|
196
|
+
* baseline sits that far below the line top, plus the ascent. Blink FLOORS that
|
|
197
|
+
* sum to a whole CSS pixel (`FontHeight::AddLeading`), so its DOM text stands up
|
|
198
|
+
* to 1px HIGHER than the exact value. Gecko lays the exact value out in app
|
|
199
|
+
* units. WebKit floors on 80 of 90 measured size × line-height combinations and
|
|
200
|
+
* has no exact rule we can state, so it takes the Blink branch — the one that
|
|
201
|
+
* fits it best, not one it matches everywhere.
|
|
202
|
+
*
|
|
203
|
+
* So the rounding is the engine's, not a style choice: each browser's canvas
|
|
204
|
+
* lands on the baseline that browser's own DOM would use, which is what keeps a
|
|
205
|
+
* canvas render and a contenteditable overlay of the same text on one line.
|
|
206
|
+
*/
|
|
207
|
+
function lineBaselineOffset(lineHeight, ascent, descent) {
|
|
208
|
+
const exact = (lineHeight - (ascent + descent)) / 2 + ascent;
|
|
209
|
+
return FLOORS_LINE_BASELINE ? Math.floor(exact) : exact;
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* The vertical space an inline-block's margin box adds around its content, over
|
|
213
|
+
* and above the font's own leading. Written once because the wrap pass grows
|
|
214
|
+
* the line by the same six values.
|
|
215
|
+
*/
|
|
216
|
+
function inlineBlockExtra(bs) {
|
|
217
|
+
return {
|
|
218
|
+
top: bs.marginTop + bs.borderTopWidth + bs.paddingTop,
|
|
219
|
+
bottom: bs.paddingBottom + bs.borderBottomWidth + bs.marginBottom,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* One box's half of a line: how far it reaches above its own baseline and how
|
|
224
|
+
* far below, over its OWN line-height. This is the inline box CSS 2.1 §10.8
|
|
225
|
+
* talks about — the font's content area plus its half-leading — not the bare
|
|
226
|
+
* font metrics. `vertical-align: text-top` and `text-bottom` align THIS box's
|
|
227
|
+
* edges, and the line box is the union of these over everything on the line.
|
|
228
|
+
*/
|
|
229
|
+
function leadedBox(ctx, style, useBulletProbe = false) {
|
|
164
230
|
const { ascent, descent } = getFontMetrics(ctx, style);
|
|
165
|
-
|
|
231
|
+
const lineHeight = getLineHeight(ctx, style, useBulletProbe);
|
|
232
|
+
const boxAscent = lineBaselineOffset(lineHeight, ascent, descent);
|
|
233
|
+
return { ascent: boxAscent, descent: lineHeight - boxAscent };
|
|
166
234
|
}
|
|
167
235
|
function applyTextTransform(text, transform) {
|
|
168
236
|
switch (transform) {
|
|
@@ -210,25 +278,48 @@ export function getFontMetrics(ctx, style) {
|
|
|
210
278
|
* pass (the box position depends on the final line box it helps size), so they
|
|
211
279
|
* fall back to baseline rather than being approximated wrongly.
|
|
212
280
|
*
|
|
213
|
-
* - super/sub
|
|
214
|
-
*
|
|
281
|
+
* - super/sub the engine's own rule, measured off the DOM across
|
|
282
|
+
* 8-56px × sans-serif/serif/monospace and fitting every
|
|
283
|
+
* point to within 0.06px (LayoutUnit's 1/64). Neither
|
|
284
|
+
* engine reads the font's metrics — the family does not
|
|
285
|
+
* move the number.
|
|
286
|
+
* - text-top/-bottom the box's LEADED edge against the parent's CONTENT-area
|
|
287
|
+
* edge (bare ascent/descent, no leading). Taking the box's
|
|
288
|
+
* bare metrics instead costs 25px on a line holding both.
|
|
215
289
|
* - middle box midpoint at parent baseline + half the x-height
|
|
216
290
|
* - <length>/<%> raise (positive value) by the length / % of line-height
|
|
217
291
|
*/
|
|
218
|
-
function verticalAlignShift(va,
|
|
292
|
+
function verticalAlignShift(va, ctx, style, parentStyle, useBulletProbe) {
|
|
219
293
|
switch (va) {
|
|
220
|
-
case 'super':
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
case '
|
|
224
|
-
|
|
294
|
+
case 'super':
|
|
295
|
+
return BLINK_SUPER_SUB
|
|
296
|
+
? -(parentStyle.fontSize / 3 + 1) : -parentStyle.fontSize * 0.34;
|
|
297
|
+
case 'sub':
|
|
298
|
+
return BLINK_SUPER_SUB
|
|
299
|
+
? parentStyle.fontSize / 5 + 1 : parentStyle.fontSize * 0.2;
|
|
300
|
+
// Against the PARENT's content area (CSS 2.1 §10.8.1) — its bare
|
|
301
|
+
// ascent/descent, no leading. Measured against Chrome, taking the line's
|
|
302
|
+
// tallest box instead of the real parent put this 14px out.
|
|
303
|
+
case 'text-top':
|
|
304
|
+
return leadedBox(ctx, style, useBulletProbe).ascent - getFontMetrics(ctx, parentStyle).ascent;
|
|
305
|
+
case 'text-bottom':
|
|
306
|
+
return getFontMetrics(ctx, parentStyle).descent - leadedBox(ctx, style, useBulletProbe).descent;
|
|
307
|
+
case 'middle': {
|
|
308
|
+
const { ascent, descent } = getFontMetrics(ctx, style);
|
|
309
|
+
return -(parentStyle.fontSize * 0.25) - (descent - ascent) / 2;
|
|
310
|
+
}
|
|
225
311
|
default: {
|
|
226
|
-
// baseline / top / bottom / '' all parseFloat to NaN → 0
|
|
227
|
-
//
|
|
312
|
+
// baseline / top / bottom / '' all parseFloat to NaN → 0, which is what
|
|
313
|
+
// an unshifted run wants — the line-box pass calls this for every word.
|
|
228
314
|
const n = parseFloat(va);
|
|
229
315
|
if (!Number.isFinite(n))
|
|
230
316
|
return 0;
|
|
231
|
-
|
|
317
|
+
// A percentage resolves against the ELEMENT's own line-height (CSS 2.1
|
|
318
|
+
// §10.8.1), not the line's. Measured against Chrome: the line's put the
|
|
319
|
+
// box 10px out on a line whose tallest run was not this one.
|
|
320
|
+
return va.endsWith('%')
|
|
321
|
+
? -(n / 100) * getLineHeight(ctx, style, useBulletProbe)
|
|
322
|
+
: -n;
|
|
232
323
|
}
|
|
233
324
|
}
|
|
234
325
|
}
|
|
@@ -258,7 +349,11 @@ export function sameDecorationBand(a, b) {
|
|
|
258
349
|
da.fontVariantCaps === db.fontVariantCaps &&
|
|
259
350
|
// The declarer's own vertical-align decides which baseline an underline
|
|
260
351
|
// hangs off, so two declarers that differ there draw two bands.
|
|
261
|
-
da.verticalAlign === db.verticalAlign
|
|
352
|
+
da.verticalAlign === db.verticalAlign &&
|
|
353
|
+
// Explicit offset/thickness are band geometry too — two declarers that
|
|
354
|
+
// differ there must not merge into one band.
|
|
355
|
+
da.textUnderlineOffset === db.textUnderlineOffset &&
|
|
356
|
+
da.textDecorationThickness === db.textDecorationThickness);
|
|
262
357
|
}
|
|
263
358
|
/** Same decoration set: entries must match pairwise, so runs whose decorations
|
|
264
359
|
* would paint differently don't merge and take the first one's band. */
|
|
@@ -347,6 +442,9 @@ function applyEllipsisToLine(ctx, line, maxWidth) {
|
|
|
347
442
|
return;
|
|
348
443
|
const lastStyle = line.words[styleIdx].style;
|
|
349
444
|
const boxStyle = line.words[styleIdx].boxStyle;
|
|
445
|
+
// The ellipsis takes the trimmed run's style, so it has to take the parent
|
|
446
|
+
// that style's vertical-align measures against too.
|
|
447
|
+
const parentStyle = line.words[styleIdx].parentStyle;
|
|
350
448
|
applyFont(ctx, lastStyle);
|
|
351
449
|
// ALWAYS assign (don't gate on truthy) — otherwise a previous segment's
|
|
352
450
|
// non-zero letter-spacing leaks into the ellipsis measurement.
|
|
@@ -382,6 +480,7 @@ function applyEllipsisToLine(ctx, line, maxWidth) {
|
|
|
382
480
|
text: '…',
|
|
383
481
|
width: ellipsisWidth,
|
|
384
482
|
style: lastStyle,
|
|
483
|
+
parentStyle,
|
|
385
484
|
isSpace: false,
|
|
386
485
|
boxStyle,
|
|
387
486
|
};
|
|
@@ -396,9 +495,15 @@ function applyEllipsisToLine(ctx, line, maxWidth) {
|
|
|
396
495
|
*/
|
|
397
496
|
function collectTextRuns(node) {
|
|
398
497
|
const runs = [];
|
|
399
|
-
function walk(n, boxStyle, clipStyle, strokeImageStyle) {
|
|
498
|
+
function walk(n, boxStyle, clipStyle, strokeImageStyle, parentStyle) {
|
|
400
499
|
if (n.tagName === '#text' && n.textContent) {
|
|
401
|
-
|
|
500
|
+
// A #text node carries its parent ELEMENT's style, so the element that
|
|
501
|
+
// owns any vertical-align here is that parent — and what the shift
|
|
502
|
+
// measures against is ITS parent, which is the `parentStyle` handed to
|
|
503
|
+
// this element's walk.
|
|
504
|
+
runs.push({
|
|
505
|
+
text: n.textContent, style: n.style, parentStyle, boxStyle, clipStyle, strokeImageStyle,
|
|
506
|
+
});
|
|
402
507
|
return;
|
|
403
508
|
}
|
|
404
509
|
const isInlineBlock = n.style.display === 'inline-block';
|
|
@@ -421,6 +526,7 @@ function collectTextRuns(node) {
|
|
|
421
526
|
runs.push({
|
|
422
527
|
text: allText,
|
|
423
528
|
style: n.style,
|
|
529
|
+
parentStyle,
|
|
424
530
|
boxStyle: newBoxStyle,
|
|
425
531
|
clipStyle: newClipStyle,
|
|
426
532
|
strokeImageStyle: newStrokeImageStyle,
|
|
@@ -441,7 +547,11 @@ function collectTextRuns(node) {
|
|
|
441
547
|
runs.push({ text: '', style: n.style, boxStyle: newBoxStyle, boxOpen: n.style });
|
|
442
548
|
}
|
|
443
549
|
for (const child of n.children) {
|
|
444
|
-
walk(child, isBox ? newBoxStyle : boxStyle, newClipStyle, newStrokeImageStyle
|
|
550
|
+
walk(child, isBox ? newBoxStyle : boxStyle, newClipStyle, newStrokeImageStyle,
|
|
551
|
+
// An element child measures against this element; a text child's
|
|
552
|
+
// vertical-align belongs to this element, so it measures against what
|
|
553
|
+
// this element measures against.
|
|
554
|
+
child.tagName === '#text' ? parentStyle : n.style);
|
|
445
555
|
}
|
|
446
556
|
if (hasHorizSpacing) {
|
|
447
557
|
runs.push({ text: '', style: n.style, boxStyle: newBoxStyle, boxClose: n.style });
|
|
@@ -461,8 +571,9 @@ function collectTextRuns(node) {
|
|
|
461
571
|
runs.push(...seg);
|
|
462
572
|
}
|
|
463
573
|
}
|
|
574
|
+
// The block itself is the parent every top-level run measures against.
|
|
464
575
|
for (const child of node.children) {
|
|
465
|
-
walk(child);
|
|
576
|
+
walk(child, undefined, undefined, undefined, node.style);
|
|
466
577
|
}
|
|
467
578
|
return runs;
|
|
468
579
|
}
|
|
@@ -546,6 +657,7 @@ function tokenizeString(ctx, text, run, allWords, cumState) {
|
|
|
546
657
|
text: '\t',
|
|
547
658
|
width: tabStopInterval, // placeholder — recalculated in flowWordsIntoLines
|
|
548
659
|
style: run.style,
|
|
660
|
+
parentStyle: run.parentStyle,
|
|
549
661
|
isSpace: true,
|
|
550
662
|
isTab: true,
|
|
551
663
|
boxStyle: run.boxStyle,
|
|
@@ -559,6 +671,7 @@ function tokenizeString(ctx, text, run, allWords, cumState) {
|
|
|
559
671
|
text: w,
|
|
560
672
|
width: cachedMeasureWidth(ctx, w),
|
|
561
673
|
style: run.style,
|
|
674
|
+
parentStyle: run.parentStyle,
|
|
562
675
|
isSpace,
|
|
563
676
|
boxStyle: run.boxStyle,
|
|
564
677
|
clipStyle: run.clipStyle,
|
|
@@ -596,6 +709,7 @@ function tokenizeString(ctx, text, run, allWords, cumState) {
|
|
|
596
709
|
text: ' ',
|
|
597
710
|
width: spaceWidth,
|
|
598
711
|
style: run.style,
|
|
712
|
+
parentStyle: run.parentStyle,
|
|
599
713
|
isSpace: true,
|
|
600
714
|
boxStyle: run.boxStyle,
|
|
601
715
|
clipStyle: run.clipStyle,
|
|
@@ -616,6 +730,7 @@ function tokenizeString(ctx, text, run, allWords, cumState) {
|
|
|
616
730
|
text: s,
|
|
617
731
|
width: cumWidth - prevCum,
|
|
618
732
|
style: run.style,
|
|
733
|
+
parentStyle: run.parentStyle,
|
|
619
734
|
isSpace: false,
|
|
620
735
|
boxStyle: run.boxStyle,
|
|
621
736
|
clipStyle: run.clipStyle,
|
|
@@ -641,6 +756,7 @@ function tokenizeString(ctx, text, run, allWords, cumState) {
|
|
|
641
756
|
text: w,
|
|
642
757
|
width,
|
|
643
758
|
style: run.style,
|
|
759
|
+
parentStyle: run.parentStyle,
|
|
644
760
|
isSpace: false,
|
|
645
761
|
boxStyle: run.boxStyle,
|
|
646
762
|
clipStyle: run.clipStyle,
|
|
@@ -684,6 +800,7 @@ function tokenizeRuns(ctx, runs) {
|
|
|
684
800
|
text,
|
|
685
801
|
width: totalWidth,
|
|
686
802
|
style: run.style,
|
|
803
|
+
parentStyle: run.parentStyle,
|
|
687
804
|
isSpace: false,
|
|
688
805
|
boxStyle: run.boxStyle,
|
|
689
806
|
boxOpen: run.boxOpen,
|
|
@@ -958,6 +1075,7 @@ function flowWordsIntoLines(ctx, words, contentWidth, whiteSpace, useBulletProbe
|
|
|
958
1075
|
text: '-',
|
|
959
1076
|
width: hyphenWidth,
|
|
960
1077
|
style: lastWord.style,
|
|
1078
|
+
parentStyle: lastWord.parentStyle,
|
|
961
1079
|
isSpace: false,
|
|
962
1080
|
// The visible hyphen continues the broken word, so it inherits the
|
|
963
1081
|
// word's clip/stroke-image declarer (else it paints transparent).
|
|
@@ -987,9 +1105,11 @@ function flowWordsIntoLines(ctx, words, contentWidth, whiteSpace, useBulletProbe
|
|
|
987
1105
|
let wordLineHeight = getLineHeight(ctx, word.style, useBulletProbe);
|
|
988
1106
|
// Inline-block elements expand line height with their vertical padding+margin
|
|
989
1107
|
if (word.boxStyle && word.boxStyle.display === 'inline-block') {
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
1108
|
+
// Clamped at 0: negative margins shrink the margin box, but the original
|
|
1109
|
+
// `Math.max(h, h + extra)` never let them shrink the LINE, and nothing
|
|
1110
|
+
// here is measuring a case that says they should.
|
|
1111
|
+
const extra = inlineBlockExtra(word.boxStyle);
|
|
1112
|
+
wordLineHeight += Math.max(0, extra.top + extra.bottom);
|
|
993
1113
|
}
|
|
994
1114
|
if (word.text === '\n') {
|
|
995
1115
|
if (currentLine.words.length === 0) {
|
|
@@ -1038,6 +1158,7 @@ function flowWordsIntoLines(ctx, words, contentWidth, whiteSpace, useBulletProbe
|
|
|
1038
1158
|
cells.push({
|
|
1039
1159
|
ch,
|
|
1040
1160
|
style: words[j].style,
|
|
1161
|
+
parentStyle: words[j].parentStyle,
|
|
1041
1162
|
clipStyle: words[j].clipStyle,
|
|
1042
1163
|
strokeImageStyle: words[j].strokeImageStyle,
|
|
1043
1164
|
});
|
|
@@ -1072,6 +1193,7 @@ function flowWordsIntoLines(ctx, words, contentWidth, whiteSpace, useBulletProbe
|
|
|
1072
1193
|
// word), so capturing it at the run start covers every push below.
|
|
1073
1194
|
const clipStyle = cs[i].clipStyle;
|
|
1074
1195
|
const strokeImageStyle = cs[i].strokeImageStyle;
|
|
1196
|
+
const parentStyle = cs[i].parentStyle;
|
|
1075
1197
|
applyFont(ctx, st);
|
|
1076
1198
|
ctx.letterSpacing = formatLetterSpacing(st.letterSpacing);
|
|
1077
1199
|
const lh = getLineHeight(ctx, st, useBulletProbe);
|
|
@@ -1084,7 +1206,7 @@ function flowWordsIntoLines(ctx, words, contentWidth, whiteSpace, useBulletProbe
|
|
|
1084
1206
|
if (chars && currentLine.totalWidth + candW > effWidth() &&
|
|
1085
1207
|
(currentLine.words.length > 0 || cur)) {
|
|
1086
1208
|
if (cur) {
|
|
1087
|
-
currentLine.words.push({ text: cur, width: curW, style: st, isSpace: false, clipStyle, strokeImageStyle });
|
|
1209
|
+
currentLine.words.push({ text: cur, width: curW, style: st, isSpace: false, parentStyle, clipStyle, strokeImageStyle });
|
|
1088
1210
|
currentLine.totalWidth += curW;
|
|
1089
1211
|
currentLine.lineHeight = Math.max(currentLine.lineHeight, lh);
|
|
1090
1212
|
}
|
|
@@ -1100,7 +1222,7 @@ function flowWordsIntoLines(ctx, words, contentWidth, whiteSpace, useBulletProbe
|
|
|
1100
1222
|
i++;
|
|
1101
1223
|
}
|
|
1102
1224
|
if (cur) {
|
|
1103
|
-
currentLine.words.push({ text: cur, width: curW, style: st, isSpace: false, clipStyle, strokeImageStyle });
|
|
1225
|
+
currentLine.words.push({ text: cur, width: curW, style: st, isSpace: false, parentStyle, clipStyle, strokeImageStyle });
|
|
1104
1226
|
currentLine.totalWidth += curW;
|
|
1105
1227
|
currentLine.lineHeight = Math.max(currentLine.lineHeight, lh);
|
|
1106
1228
|
afterHardBreak = false;
|
|
@@ -1454,7 +1576,9 @@ function layoutInlineContent(ctx, node, x, y, contentWidth, useBulletProbe = fal
|
|
|
1454
1576
|
// only content is a SMALLER inline font sits on the strut baseline (lower in
|
|
1455
1577
|
// the box), not centered in it. Seed each line's ascent/descent with the
|
|
1456
1578
|
// block font's metrics so the baseline lands where the DOM puts it.
|
|
1457
|
-
|
|
1579
|
+
// The block is the parent of any run with no inline ancestor, and the emit
|
|
1580
|
+
// loop shadows `node` with the LayoutText it builds.
|
|
1581
|
+
const blockStyle = node.style;
|
|
1458
1582
|
let curY = y;
|
|
1459
1583
|
for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
|
|
1460
1584
|
const line = lines[lineIdx];
|
|
@@ -1462,7 +1586,6 @@ function layoutInlineContent(ctx, node, x, y, contentWidth, useBulletProbe = fal
|
|
|
1462
1586
|
curY += line.lineHeight;
|
|
1463
1587
|
continue;
|
|
1464
1588
|
}
|
|
1465
|
-
const lineHeight = line.lineHeight;
|
|
1466
1589
|
const isLastLine = lineIdx === lines.length - 1;
|
|
1467
1590
|
const isFirstLine = lineIdx === 0;
|
|
1468
1591
|
// Per-line alignment: lines ending at a forced break or the last line
|
|
@@ -1513,85 +1636,78 @@ function layoutInlineContent(ctx, node, x, y, contentWidth, useBulletProbe = fal
|
|
|
1513
1636
|
const lineLeftX = curX;
|
|
1514
1637
|
// Inline background boxes and text are emitted after baseline computation
|
|
1515
1638
|
// (below) so that emitInlineBox can use line-level metrics for alignment.
|
|
1516
|
-
//
|
|
1517
|
-
//
|
|
1518
|
-
//
|
|
1519
|
-
//
|
|
1520
|
-
//
|
|
1521
|
-
|
|
1522
|
-
let
|
|
1639
|
+
// The line box is the union of every box on it — strut, run, shifted run,
|
|
1640
|
+
// inline-block — each carrying its own leading over its own line-height:
|
|
1641
|
+
// lineAscent = max(ascent - shift), lineDescent = max(descent + shift).
|
|
1642
|
+
// One font, one line-height and no shift collapse that back to the plain
|
|
1643
|
+
// half-leading every single-style line already had.
|
|
1644
|
+
const strutBox = leadedBox(ctx, node.style, useBulletProbe);
|
|
1645
|
+
let lineAscent = strutBox.ascent;
|
|
1646
|
+
let lineDescent = strutBox.descent;
|
|
1523
1647
|
for (const word of line.words) {
|
|
1524
1648
|
if (word.text === '')
|
|
1525
1649
|
continue;
|
|
1526
|
-
//
|
|
1527
|
-
//
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
maxDescent = descent;
|
|
1544
|
-
break;
|
|
1650
|
+
// A wrapper element with no text of its own — `<span lh:3><span>x</span>`
|
|
1651
|
+
// — never becomes a Word, but it is still a box on the line and still
|
|
1652
|
+
// brings its own line-height. Its run children carry it as `parentStyle`,
|
|
1653
|
+
// so take it from there, AT ITS OWN SHIFT: added unshifted, a wrapper
|
|
1654
|
+
// that carries a vertical-align and direct text enters the union twice
|
|
1655
|
+
// at two different places, and the line spans both (measured 60px where
|
|
1656
|
+
// the DOM has 40). With the shift it is idempotent — a wrapper with
|
|
1657
|
+
// direct text contributes the identical box through its own run.
|
|
1658
|
+
if (word.parentStyle) {
|
|
1659
|
+
const parentBox = leadedBox(ctx, word.parentStyle, useBulletProbe);
|
|
1660
|
+
const parentShift = verticalAlignShift(word.parentStyle.verticalAlign, ctx, word.parentStyle, blockStyle, useBulletProbe);
|
|
1661
|
+
if (parentBox.ascent - parentShift > lineAscent) {
|
|
1662
|
+
lineAscent = parentBox.ascent - parentShift;
|
|
1663
|
+
}
|
|
1664
|
+
if (parentBox.descent + parentShift > lineDescent) {
|
|
1665
|
+
lineDescent = parentBox.descent + parentShift;
|
|
1666
|
+
}
|
|
1545
1667
|
}
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
verticalAlignShift(va, wAscent, wDescent, parentFontSize, maxAscent, maxDescent, lineHeight);
|
|
1571
|
-
const wordTop = shiftedBaseline - wAscent;
|
|
1572
|
-
const wordBottom = shiftedBaseline + wDescent;
|
|
1573
|
-
if (wordTop < lineTop)
|
|
1574
|
-
lineTop = wordTop;
|
|
1575
|
-
if (wordBottom > lineBottom)
|
|
1576
|
-
lineBottom = wordBottom;
|
|
1577
|
-
}
|
|
1578
|
-
const effectiveLineHeight = lineBottom - lineTop;
|
|
1668
|
+
const box = leadedBox(ctx, word.style, useBulletProbe);
|
|
1669
|
+
// An inline-block joins the line as an ATOMIC box: its own content
|
|
1670
|
+
// baseline with its margin box stacked around it. It takes the extra
|
|
1671
|
+
// space, but no shift — the emit pass puts its content on the line
|
|
1672
|
+
// baseline and does not honour vertical-align on it, so shifting the box
|
|
1673
|
+
// here would grow the line one way while the paint went the other.
|
|
1674
|
+
const atomic = word.boxStyle?.display === 'inline-block' ? word.boxStyle : null;
|
|
1675
|
+
if (atomic) {
|
|
1676
|
+
const extra = inlineBlockExtra(atomic);
|
|
1677
|
+
box.ascent += extra.top;
|
|
1678
|
+
box.descent += extra.bottom;
|
|
1679
|
+
}
|
|
1680
|
+
// A shift moves the box, not the line's baseline: positive is downward,
|
|
1681
|
+
// so it lifts the box's demand on the ascent side and adds to the descent
|
|
1682
|
+
// side. The parent-size fallback is the one the emit pass uses, so a line
|
|
1683
|
+
// whose only content is shifted still sizes around it.
|
|
1684
|
+
const shift = atomic ? 0 : verticalAlignShift(word.style.verticalAlign, ctx, word.style, word.parentStyle ?? blockStyle, useBulletProbe);
|
|
1685
|
+
if (box.ascent - shift > lineAscent)
|
|
1686
|
+
lineAscent = box.ascent - shift;
|
|
1687
|
+
if (box.descent + shift > lineDescent)
|
|
1688
|
+
lineDescent = box.descent + shift;
|
|
1689
|
+
}
|
|
1690
|
+
const lineBoxHeight = lineAscent + lineDescent;
|
|
1691
|
+
const lineBaselineY = curY + lineAscent;
|
|
1579
1692
|
// Emit inline background box using line-level baseline for vertical alignment.
|
|
1580
1693
|
// Uses the line's ascent/descent (not the box's own font) so box aligns with text.
|
|
1581
1694
|
const emitInlineBox = (style, bx, bw) => {
|
|
1582
|
-
//
|
|
1583
|
-
//
|
|
1584
|
-
|
|
1695
|
+
// The box's OWN font decides its height, not the line's largest. An
|
|
1696
|
+
// inline-block's content box is its LINE-HEIGHT, though, not the bare
|
|
1697
|
+
// font metrics — measured against Chrome, bare metrics put it at
|
|
1698
|
+
// y=6 h=29 where the DOM has y=4 h=33.2.
|
|
1699
|
+
const { ascent: boxAscent, descent: boxDescent } = style.display === 'inline-block'
|
|
1700
|
+
? leadedBox(ctx, style, useBulletProbe)
|
|
1701
|
+
: getFontMetrics(ctx, style);
|
|
1585
1702
|
const padTop = style.paddingTop + style.borderTopWidth;
|
|
1586
1703
|
const padBottom = style.paddingBottom + style.borderBottomWidth;
|
|
1587
1704
|
const boxHeight = boxAscent + boxDescent + padTop + padBottom;
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
}
|
|
1705
|
+
// Every inline box hangs off the line's baseline, an inline-block too:
|
|
1706
|
+
// its content is emitted on that baseline, so a box pinned to the line
|
|
1707
|
+
// TOP instead detached from its own glyphs as soon as something taller
|
|
1708
|
+
// shared the line — measured, a background at y 4..33 around text whose
|
|
1709
|
+
// baseline was 46.
|
|
1710
|
+
const boxY = lineBaselineY - boxAscent - padTop;
|
|
1595
1711
|
results.push({
|
|
1596
1712
|
type: 'box', style, x: bx, y: boxY, width: bw, height: boxHeight,
|
|
1597
1713
|
tagName: 'span', children: [],
|
|
@@ -1782,9 +1898,7 @@ function layoutInlineContent(ctx, node, x, y, contentWidth, useBulletProbe = fal
|
|
|
1782
1898
|
let baselineY = lineBaselineY;
|
|
1783
1899
|
const va = word.style.verticalAlign;
|
|
1784
1900
|
if (isShiftedVAlign(va)) {
|
|
1785
|
-
|
|
1786
|
-
const { ascent: wA, descent: wD } = getFontMetrics(ctx, word.style);
|
|
1787
|
-
baselineY += verticalAlignShift(va, wA, wD, pfs, maxAscent, maxDescent, lineHeight);
|
|
1901
|
+
baselineY += verticalAlignShift(va, ctx, word.style, word.parentStyle ?? blockStyle, useBulletProbe);
|
|
1788
1902
|
}
|
|
1789
1903
|
const effectiveWidth = word.width + (word.isSpace ? justifyExtraPerSpace : 0);
|
|
1790
1904
|
const node = {
|
|
@@ -1818,14 +1932,15 @@ function layoutInlineContent(ctx, node, x, y, contentWidth, useBulletProbe = fal
|
|
|
1818
1932
|
text: line.words.map(w => w.text).join(''),
|
|
1819
1933
|
bounds: {
|
|
1820
1934
|
x: lineLeftX,
|
|
1821
|
-
//
|
|
1822
|
-
//
|
|
1823
|
-
|
|
1935
|
+
// The line box starts at curY: shifted content grew the box through
|
|
1936
|
+
// `lineAscent`/`lineDescent` above, so nothing on the line reaches
|
|
1937
|
+
// outside it.
|
|
1938
|
+
y: curY,
|
|
1824
1939
|
width: lineWidth,
|
|
1825
|
-
height:
|
|
1940
|
+
height: lineBoxHeight,
|
|
1826
1941
|
},
|
|
1827
1942
|
});
|
|
1828
|
-
curY +=
|
|
1943
|
+
curY += lineBoxHeight;
|
|
1829
1944
|
}
|
|
1830
1945
|
assignInlineFragmentBoxes(ctx, results, clipRuns, (node, s, box) => {
|
|
1831
1946
|
node.clip = {
|
|
@@ -2184,9 +2299,10 @@ function addListMarker(ctx, box, node) {
|
|
|
2184
2299
|
const ms = node.markerStyle;
|
|
2185
2300
|
const markerStyleObj = ms ? { ...style, ...ms } : style;
|
|
2186
2301
|
ctx.font = buildCanvasFont(markerStyleObj);
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2302
|
+
// ascent + descent is the li's line-height by construction, so one call
|
|
2303
|
+
// gives both the marker's baseline and the box it reports.
|
|
2304
|
+
const strut = leadedBox(ctx, style);
|
|
2305
|
+
const baselineY = box.y + style.borderTopWidth + style.paddingTop + strut.ascent;
|
|
2190
2306
|
const markerWidth = cachedMeasureWidth(ctx, node.listMarker);
|
|
2191
2307
|
const isRTL = style.direction === 'rtl';
|
|
2192
2308
|
const isBullet = BULLET_MARKERS.has(style.listStyleType);
|
|
@@ -2285,7 +2401,7 @@ function addListMarker(ctx, box, node) {
|
|
|
2285
2401
|
x: markerLeftX,
|
|
2286
2402
|
y: box.y + style.borderTopWidth + style.paddingTop,
|
|
2287
2403
|
width: markerDrawWidth,
|
|
2288
|
-
height:
|
|
2404
|
+
height: strut.ascent + strut.descent,
|
|
2289
2405
|
},
|
|
2290
2406
|
});
|
|
2291
2407
|
}
|