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/lib/layout.js CHANGED
@@ -157,12 +157,80 @@ function getLineHeight(ctx, style, useBulletProbe = false) {
157
157
  return ascent + descent;
158
158
  }
159
159
  /**
160
- * Compute the baseline Y offset within a line.
161
- * Uses the Konva approach: center (ascent - descent) within lineHeight.
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
- function computeBaselineY(ctx, style, lineHeight) {
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
- return (ascent - descent) / 2 + lineHeight / 2;
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 legacy fixed fractions of the parent font size
214
- * - text-top/-bottom align the box's ascent/descent edge with the line's
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, wAscent, wDescent, parentFontSize, maxAscent, maxDescent, lineHeight) {
292
+ function verticalAlignShift(va, ctx, style, parentStyle, useBulletProbe) {
219
293
  switch (va) {
220
- case 'super': return -parentFontSize * 0.4;
221
- case 'sub': return parentFontSize * 0.26;
222
- case 'text-top': return -(maxAscent - wAscent);
223
- case 'text-bottom': return maxDescent - wDescent;
224
- case 'middle': return -(parentFontSize * 0.25) - (wDescent - wAscent) / 2;
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 (callers gate
227
- // on isShiftedVAlign, so those never actually reach here).
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
- return va.endsWith('%') ? -(n / 100) * lineHeight : -n;
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
- runs.push({ text: n.textContent, style: n.style, boxStyle, clipStyle, strokeImageStyle });
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
- const bs = word.boxStyle;
991
- wordLineHeight = Math.max(wordLineHeight, wordLineHeight + bs.paddingTop + bs.paddingBottom + bs.marginTop + bs.marginBottom
992
- + bs.borderTopWidth + bs.borderBottomWidth);
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
- const strutMetrics = getFontMetrics(ctx, node.style);
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
- // Compute a single shared baseline for the entire line.
1517
- // Exclude sub/sup words they sit above/below the baseline and
1518
- // shouldn't influence where the baseline is positioned. Seeded with the
1519
- // block strut (block font) so smaller-only lines align to the block
1520
- // baseline rather than centering in the taller strut line box.
1521
- let maxAscent = strutMetrics.ascent;
1522
- let maxDescent = strutMetrics.descent;
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
- // Off-baseline content (sub/sup/middle/lengths/...) does not establish
1527
- // the line's baseline position only baseline-aligned content does.
1528
- if (isShiftedVAlign(word.style.verticalAlign))
1529
- continue;
1530
- const { ascent: a, descent: d } = getFontMetrics(ctx, word.style);
1531
- if (a > maxAscent)
1532
- maxAscent = a;
1533
- if (d > maxDescent)
1534
- maxDescent = d;
1535
- }
1536
- // If only off-baseline words on the line, use the first word's metrics
1537
- if (maxAscent === 0) {
1538
- for (const word of line.words) {
1539
- if (word.text === '')
1540
- continue;
1541
- const { ascent, descent } = getFontMetrics(ctx, word.style);
1542
- maxAscent = ascent;
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
- // Center the text block (ascent + descent) within the lineHeight
1548
- const textBlockHeight = maxAscent + maxDescent;
1549
- let lineBaselineY = curY + (lineHeight - textBlockHeight) / 2 + maxAscent;
1550
- // Parent font size for vertical-align positioning (used in expansion + text
1551
- // emit) the largest baseline-aligned font on the line.
1552
- const lineNormalWords = line.words.filter(w => w.text !== '' && !isShiftedVAlign(w.style.verticalAlign));
1553
- const parentFontSize = lineNormalWords.length > 0
1554
- ? Math.max(...lineNormalWords.map(w => w.style.fontSize)) : 0;
1555
- // Expand line height if vertically-shifted content extends beyond the line
1556
- // box. Browsers grow the line box to fit all content, but keep the normal
1557
- // text baseline position unchanged.
1558
- let lineTop = curY;
1559
- let lineBottom = curY + lineHeight;
1560
- for (const word of line.words) {
1561
- if (word.text === '')
1562
- continue;
1563
- const va = word.style.verticalAlign;
1564
- if (!isShiftedVAlign(va))
1565
- continue;
1566
- if (parentFontSize === 0)
1567
- break;
1568
- const { ascent: wAscent, descent: wDescent } = getFontMetrics(ctx, word.style);
1569
- const shiftedBaseline = lineBaselineY +
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
- // Use the box's OWN font for height (not the line's largest font),
1583
- // but align vertically to the line's baseline.
1584
- const { ascent: boxAscent, descent: boxDescent } = getFontMetrics(ctx, style);
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
- let boxY;
1589
- if (style.display === 'inline-block') {
1590
- boxY = curY + style.marginTop;
1591
- }
1592
- else {
1593
- boxY = lineBaselineY - boxAscent - padTop;
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
- const pfs = parentFontSize || word.style.fontSize;
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
- // Use the actual visual top (may be < curY when a super pushes the
1822
- // line box upward) so the rect covers ascenders/super content.
1823
- y: lineTop,
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: effectiveLineHeight,
1940
+ height: lineBoxHeight,
1826
1941
  },
1827
1942
  });
1828
- curY += effectiveLineHeight;
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
- const lineHeight = getLineHeight(ctx, style);
2188
- const baselineY = box.y + style.borderTopWidth + style.paddingTop +
2189
- computeBaselineY(ctx, style, lineHeight);
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: lineHeight,
2404
+ height: strut.ascent + strut.descent,
2289
2405
  },
2290
2406
  });
2291
2407
  }