render-tag 0.1.29 → 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 CHANGED
@@ -198,6 +198,7 @@ li::marker { content: none; font-size: 0; line-height: 0; }
198
198
 
199
199
  - **Chrome-first.** When a rendering choice must favor one browser over another, Chrome wins.
200
200
  - **Cross-browser consistency over per-browser DOM fidelity.** Same canvas output in every browser, not pixel-matching each browser's quirks. Use `accuracy: 'balanced'` if you'd rather match each browser's own DOM rendering.
201
+ - **The line-box baseline follows the engine's own rounding.** Blink floors half-leading + ascent onto a whole CSS pixel (`FontHeight::AddLeading`); Gecko and WebKit keep the exact value. render-tag does what the engine it runs in does, so canvas text lands on the baseline that browser's own DOM would use — which is what keeps a canvas render and a contenteditable overlay of the same text on one line. Safari is the closest fit rather than a match: its canvas metrics disagree with its own layout metrics, so no canvas-side rule reaches its DOM exactly. This is the one place per-browser fidelity wins over identical output.
201
202
 
202
203
  ## License
203
204
 
package/lib/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { RenderConfig, RenderResult, LayoutConfig, LayoutResult, DrawConfig, LayoutLine, AnyCanvas } from './types.js';
2
2
  export type { RenderConfig, RenderResult, LayoutConfig, LayoutResult, DrawConfig, LayoutLine };
3
3
  export { setDOMParser, type DOMParserLike } from './dom.js';
4
+ export { FLOORS_LINE_BASELINE } from './layout.js';
4
5
  /**
5
6
  * Compute layout for an HTML string without rendering.
6
7
  * Returns a reusable LayoutResult that can be drawn onto multiple targets via drawLayout().
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,YAAY,EAAE,YAAY,EAC1B,YAAY,EAAE,YAAY,EAAE,UAAU,EACtC,UAAU,EAAE,SAAS,EACtB,MAAM,YAAY,CAAC;AAMpB,YAAY,EAAE,YAAY,EAAE,YAAY,EAAE,YAAY,EAAE,YAAY,EAAE,UAAU,EAAE,UAAU,EAAE,CAAC;AAC/F,OAAO,EAAE,YAAY,EAAE,KAAK,aAAa,EAAE,MAAM,UAAU,CAAC;AAW5D;;;GAGG;AACH,wBAAgB,MAAM,CAAC,MAAM,EAAE,YAAY,GAAG,YAAY,CAgCzD;AAID;;;GAGG;AACH,wBAAgB,UAAU,CAAC,MAAM,EAAE,UAAU,GAAG;IAAE,MAAM,EAAE,SAAS,CAAA;CAAE,CAsCpE;AAID;;;;GAIG;AACH,wBAAgB,MAAM,CAAC,MAAM,EAAE,YAAY,GAAG,YAAY,CA8BzD"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,YAAY,EAAE,YAAY,EAC1B,YAAY,EAAE,YAAY,EAAE,UAAU,EACtC,UAAU,EAAE,SAAS,EACtB,MAAM,YAAY,CAAC;AAMpB,YAAY,EAAE,YAAY,EAAE,YAAY,EAAE,YAAY,EAAE,YAAY,EAAE,UAAU,EAAE,UAAU,EAAE,CAAC;AAC/F,OAAO,EAAE,YAAY,EAAE,KAAK,aAAa,EAAE,MAAM,UAAU,CAAC;AAC5D,OAAO,EAAE,oBAAoB,EAAE,MAAM,aAAa,CAAC;AAWnD;;;GAGG;AACH,wBAAgB,MAAM,CAAC,MAAM,EAAE,YAAY,GAAG,YAAY,CAgCzD;AAID;;;GAGG;AACH,wBAAgB,UAAU,CAAC,MAAM,EAAE,UAAU,GAAG;IAAE,MAAM,EAAE,SAAS,CAAA;CAAE,CAsCpE;AAID;;;;GAIG;AACH,wBAAgB,MAAM,CAAC,MAAM,EAAE,YAAY,GAAG,YAAY,CA8BzD"}
package/lib/index.js CHANGED
@@ -3,6 +3,7 @@ import { resolveStylesFromCSS } from './css-resolver.js';
3
3
  import { buildLayoutTree } from './layout.js';
4
4
  import { renderNode } from './render.js';
5
5
  export { setDOMParser } from './dom.js';
6
+ export { FLOORS_LINE_BASELINE } from './layout.js';
6
7
  import { createFallbackMeasureCtx } from './dom.js';
7
8
  // Default measurement context, created lazily and reused across layout()
8
9
  // calls — safe because font/letterSpacing state is set before every
package/lib/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/lib/layout.d.ts CHANGED
@@ -4,6 +4,19 @@ import type { StyledNode, LayoutBox, ResolvedStyle, LayoutLine, DecorationEntry
4
4
  */
5
5
  export declare function applyFont(ctx: CanvasRenderingContext2D, style: ResolvedStyle): void;
6
6
  export declare function buildCanvasFont(style: ResolvedStyle): string;
7
+ /**
8
+ * True where the engine floors a line's baseline onto a whole CSS pixel.
9
+ *
10
+ * Blink alone does (`FontHeight::AddLeading`). Gecko and WebKit both lay the
11
+ * exact half-leading out — measured over the whole 530-case corpus, giving
12
+ * Safari the Blink branch cost 214 wins against 223 losses (avg 7.50% ->
13
+ * 9.21%) where the exact value wins 48 against 3 (7.50% -> 6.52%).
14
+ *
15
+ * Public API: the parity suites expect per engine, and every renderer that has
16
+ * to place a baseline beside a render-tag canvas (@polotno/svg-export) must
17
+ * round the same way this does.
18
+ */
19
+ export declare const FLOORS_LINE_BASELINE: boolean;
7
20
  export declare function isTransparent(color: string): boolean;
8
21
  export declare function getFontMetrics(ctx: CanvasRenderingContext2D, style: ResolvedStyle): {
9
22
  ascent: number;
@@ -1 +1 @@
1
- {"version":3,"file":"layout.d.ts","sourceRoot":"","sources":["../src/layout.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAc,SAAS,EAAc,aAAa,EAAE,UAAU,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AA4C5H;;GAEG;AACH,wBAAgB,SAAS,CAAC,GAAG,EAAE,wBAAwB,EAAE,KAAK,EAAE,aAAa,GAAG,IAAI,CAGnF;AAgBD,wBAAgB,eAAe,CAAC,KAAK,EAAE,aAAa,GAAG,MAAM,CAc5D;AA8HD,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAEpD;AAMD,wBAAgB,cAAc,CAAC,GAAG,EAAE,wBAAwB,EAAE,KAAK,EAAE,aAAa,GAAG;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAWvH;AAoCD,uEAAuE;AACvE,wBAAgB,eAAe,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAEnD;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,kBAAkB,CAAC,CAAC,EAAE,eAAe,EAAE,CAAC,EAAE,eAAe,GAAG,OAAO,CAiBlF;AA4CD;;kFAEkF;AAClF,wBAAgB,WAAW,CAAC,KAAK,EAAE,aAAa,GAAG,OAAO,CAIzD;AAgrED;;;GAGG;AACH,wBAAgB,eAAe,CAC7B,GAAG,EAAE,wBAAwB,EAC7B,UAAU,EAAE,UAAU,EACtB,cAAc,EAAE,MAAM,EACtB,kBAAkB,UAAO,EACzB,KAAK,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,YAAY,EAAE,UAAU,KAAK,IAAI,GACvD;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,UAAU,EAAE,CAAA;CAAE,CAmD1D"}
1
+ {"version":3,"file":"layout.d.ts","sourceRoot":"","sources":["../src/layout.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAc,SAAS,EAAc,aAAa,EAAE,UAAU,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AA4C5H;;GAEG;AACH,wBAAgB,SAAS,CAAC,GAAG,EAAE,wBAAwB,EAAE,KAAK,EAAE,aAAa,GAAG,IAAI,CAGnF;AAgBD,wBAAgB,eAAe,CAAC,KAAK,EAAE,aAAa,GAAG,MAAM,CAc5D;AA4GD;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,oBAAoB,SAAW,CAAC;AAmF7C,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAEpD;AAMD,wBAAgB,cAAc,CAAC,GAAG,EAAE,wBAAwB,EAAE,KAAK,EAAE,aAAa,GAAG;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAWvH;AA0DD,uEAAuE;AACvE,wBAAgB,eAAe,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAEnD;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,kBAAkB,CAAC,CAAC,EAAE,eAAe,EAAE,CAAC,EAAE,eAAe,GAAG,OAAO,CAiBlF;AA4CD;;kFAEkF;AAClF,wBAAgB,WAAW,CAAC,KAAK,EAAE,aAAa,GAAG,OAAO,CAIzD;AA4tED;;;GAGG;AACH,wBAAgB,eAAe,CAC7B,GAAG,EAAE,wBAAwB,EAC7B,UAAU,EAAE,UAAU,EACtB,cAAc,EAAE,MAAM,EACtB,kBAAkB,UAAO,EACzB,KAAK,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,YAAY,EAAE,UAAU,KAAK,IAAI,GACvD;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,UAAU,EAAE,CAAA;CAAE,CAmD1D"}
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
  }
@@ -351,6 +442,9 @@ function applyEllipsisToLine(ctx, line, maxWidth) {
351
442
  return;
352
443
  const lastStyle = line.words[styleIdx].style;
353
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;
354
448
  applyFont(ctx, lastStyle);
355
449
  // ALWAYS assign (don't gate on truthy) — otherwise a previous segment's
356
450
  // non-zero letter-spacing leaks into the ellipsis measurement.
@@ -386,6 +480,7 @@ function applyEllipsisToLine(ctx, line, maxWidth) {
386
480
  text: '…',
387
481
  width: ellipsisWidth,
388
482
  style: lastStyle,
483
+ parentStyle,
389
484
  isSpace: false,
390
485
  boxStyle,
391
486
  };
@@ -400,9 +495,15 @@ function applyEllipsisToLine(ctx, line, maxWidth) {
400
495
  */
401
496
  function collectTextRuns(node) {
402
497
  const runs = [];
403
- function walk(n, boxStyle, clipStyle, strokeImageStyle) {
498
+ function walk(n, boxStyle, clipStyle, strokeImageStyle, parentStyle) {
404
499
  if (n.tagName === '#text' && n.textContent) {
405
- 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
+ });
406
507
  return;
407
508
  }
408
509
  const isInlineBlock = n.style.display === 'inline-block';
@@ -425,6 +526,7 @@ function collectTextRuns(node) {
425
526
  runs.push({
426
527
  text: allText,
427
528
  style: n.style,
529
+ parentStyle,
428
530
  boxStyle: newBoxStyle,
429
531
  clipStyle: newClipStyle,
430
532
  strokeImageStyle: newStrokeImageStyle,
@@ -445,7 +547,11 @@ function collectTextRuns(node) {
445
547
  runs.push({ text: '', style: n.style, boxStyle: newBoxStyle, boxOpen: n.style });
446
548
  }
447
549
  for (const child of n.children) {
448
- 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);
449
555
  }
450
556
  if (hasHorizSpacing) {
451
557
  runs.push({ text: '', style: n.style, boxStyle: newBoxStyle, boxClose: n.style });
@@ -465,8 +571,9 @@ function collectTextRuns(node) {
465
571
  runs.push(...seg);
466
572
  }
467
573
  }
574
+ // The block itself is the parent every top-level run measures against.
468
575
  for (const child of node.children) {
469
- walk(child);
576
+ walk(child, undefined, undefined, undefined, node.style);
470
577
  }
471
578
  return runs;
472
579
  }
@@ -550,6 +657,7 @@ function tokenizeString(ctx, text, run, allWords, cumState) {
550
657
  text: '\t',
551
658
  width: tabStopInterval, // placeholder — recalculated in flowWordsIntoLines
552
659
  style: run.style,
660
+ parentStyle: run.parentStyle,
553
661
  isSpace: true,
554
662
  isTab: true,
555
663
  boxStyle: run.boxStyle,
@@ -563,6 +671,7 @@ function tokenizeString(ctx, text, run, allWords, cumState) {
563
671
  text: w,
564
672
  width: cachedMeasureWidth(ctx, w),
565
673
  style: run.style,
674
+ parentStyle: run.parentStyle,
566
675
  isSpace,
567
676
  boxStyle: run.boxStyle,
568
677
  clipStyle: run.clipStyle,
@@ -600,6 +709,7 @@ function tokenizeString(ctx, text, run, allWords, cumState) {
600
709
  text: ' ',
601
710
  width: spaceWidth,
602
711
  style: run.style,
712
+ parentStyle: run.parentStyle,
603
713
  isSpace: true,
604
714
  boxStyle: run.boxStyle,
605
715
  clipStyle: run.clipStyle,
@@ -620,6 +730,7 @@ function tokenizeString(ctx, text, run, allWords, cumState) {
620
730
  text: s,
621
731
  width: cumWidth - prevCum,
622
732
  style: run.style,
733
+ parentStyle: run.parentStyle,
623
734
  isSpace: false,
624
735
  boxStyle: run.boxStyle,
625
736
  clipStyle: run.clipStyle,
@@ -645,6 +756,7 @@ function tokenizeString(ctx, text, run, allWords, cumState) {
645
756
  text: w,
646
757
  width,
647
758
  style: run.style,
759
+ parentStyle: run.parentStyle,
648
760
  isSpace: false,
649
761
  boxStyle: run.boxStyle,
650
762
  clipStyle: run.clipStyle,
@@ -688,6 +800,7 @@ function tokenizeRuns(ctx, runs) {
688
800
  text,
689
801
  width: totalWidth,
690
802
  style: run.style,
803
+ parentStyle: run.parentStyle,
691
804
  isSpace: false,
692
805
  boxStyle: run.boxStyle,
693
806
  boxOpen: run.boxOpen,
@@ -962,6 +1075,7 @@ function flowWordsIntoLines(ctx, words, contentWidth, whiteSpace, useBulletProbe
962
1075
  text: '-',
963
1076
  width: hyphenWidth,
964
1077
  style: lastWord.style,
1078
+ parentStyle: lastWord.parentStyle,
965
1079
  isSpace: false,
966
1080
  // The visible hyphen continues the broken word, so it inherits the
967
1081
  // word's clip/stroke-image declarer (else it paints transparent).
@@ -991,9 +1105,11 @@ function flowWordsIntoLines(ctx, words, contentWidth, whiteSpace, useBulletProbe
991
1105
  let wordLineHeight = getLineHeight(ctx, word.style, useBulletProbe);
992
1106
  // Inline-block elements expand line height with their vertical padding+margin
993
1107
  if (word.boxStyle && word.boxStyle.display === 'inline-block') {
994
- const bs = word.boxStyle;
995
- wordLineHeight = Math.max(wordLineHeight, wordLineHeight + bs.paddingTop + bs.paddingBottom + bs.marginTop + bs.marginBottom
996
- + 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);
997
1113
  }
998
1114
  if (word.text === '\n') {
999
1115
  if (currentLine.words.length === 0) {
@@ -1042,6 +1158,7 @@ function flowWordsIntoLines(ctx, words, contentWidth, whiteSpace, useBulletProbe
1042
1158
  cells.push({
1043
1159
  ch,
1044
1160
  style: words[j].style,
1161
+ parentStyle: words[j].parentStyle,
1045
1162
  clipStyle: words[j].clipStyle,
1046
1163
  strokeImageStyle: words[j].strokeImageStyle,
1047
1164
  });
@@ -1076,6 +1193,7 @@ function flowWordsIntoLines(ctx, words, contentWidth, whiteSpace, useBulletProbe
1076
1193
  // word), so capturing it at the run start covers every push below.
1077
1194
  const clipStyle = cs[i].clipStyle;
1078
1195
  const strokeImageStyle = cs[i].strokeImageStyle;
1196
+ const parentStyle = cs[i].parentStyle;
1079
1197
  applyFont(ctx, st);
1080
1198
  ctx.letterSpacing = formatLetterSpacing(st.letterSpacing);
1081
1199
  const lh = getLineHeight(ctx, st, useBulletProbe);
@@ -1088,7 +1206,7 @@ function flowWordsIntoLines(ctx, words, contentWidth, whiteSpace, useBulletProbe
1088
1206
  if (chars && currentLine.totalWidth + candW > effWidth() &&
1089
1207
  (currentLine.words.length > 0 || cur)) {
1090
1208
  if (cur) {
1091
- 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 });
1092
1210
  currentLine.totalWidth += curW;
1093
1211
  currentLine.lineHeight = Math.max(currentLine.lineHeight, lh);
1094
1212
  }
@@ -1104,7 +1222,7 @@ function flowWordsIntoLines(ctx, words, contentWidth, whiteSpace, useBulletProbe
1104
1222
  i++;
1105
1223
  }
1106
1224
  if (cur) {
1107
- 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 });
1108
1226
  currentLine.totalWidth += curW;
1109
1227
  currentLine.lineHeight = Math.max(currentLine.lineHeight, lh);
1110
1228
  afterHardBreak = false;
@@ -1458,7 +1576,9 @@ function layoutInlineContent(ctx, node, x, y, contentWidth, useBulletProbe = fal
1458
1576
  // only content is a SMALLER inline font sits on the strut baseline (lower in
1459
1577
  // the box), not centered in it. Seed each line's ascent/descent with the
1460
1578
  // block font's metrics so the baseline lands where the DOM puts it.
1461
- 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;
1462
1582
  let curY = y;
1463
1583
  for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
1464
1584
  const line = lines[lineIdx];
@@ -1466,7 +1586,6 @@ function layoutInlineContent(ctx, node, x, y, contentWidth, useBulletProbe = fal
1466
1586
  curY += line.lineHeight;
1467
1587
  continue;
1468
1588
  }
1469
- const lineHeight = line.lineHeight;
1470
1589
  const isLastLine = lineIdx === lines.length - 1;
1471
1590
  const isFirstLine = lineIdx === 0;
1472
1591
  // Per-line alignment: lines ending at a forced break or the last line
@@ -1517,85 +1636,78 @@ function layoutInlineContent(ctx, node, x, y, contentWidth, useBulletProbe = fal
1517
1636
  const lineLeftX = curX;
1518
1637
  // Inline background boxes and text are emitted after baseline computation
1519
1638
  // (below) so that emitInlineBox can use line-level metrics for alignment.
1520
- // Compute a single shared baseline for the entire line.
1521
- // Exclude sub/sup words they sit above/below the baseline and
1522
- // shouldn't influence where the baseline is positioned. Seeded with the
1523
- // block strut (block font) so smaller-only lines align to the block
1524
- // baseline rather than centering in the taller strut line box.
1525
- let maxAscent = strutMetrics.ascent;
1526
- 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;
1527
1647
  for (const word of line.words) {
1528
1648
  if (word.text === '')
1529
1649
  continue;
1530
- // Off-baseline content (sub/sup/middle/lengths/...) does not establish
1531
- // the line's baseline position only baseline-aligned content does.
1532
- if (isShiftedVAlign(word.style.verticalAlign))
1533
- continue;
1534
- const { ascent: a, descent: d } = getFontMetrics(ctx, word.style);
1535
- if (a > maxAscent)
1536
- maxAscent = a;
1537
- if (d > maxDescent)
1538
- maxDescent = d;
1539
- }
1540
- // If only off-baseline words on the line, use the first word's metrics
1541
- if (maxAscent === 0) {
1542
- for (const word of line.words) {
1543
- if (word.text === '')
1544
- continue;
1545
- const { ascent, descent } = getFontMetrics(ctx, word.style);
1546
- maxAscent = ascent;
1547
- maxDescent = descent;
1548
- 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
+ }
1549
1667
  }
1550
- }
1551
- // Center the text block (ascent + descent) within the lineHeight
1552
- const textBlockHeight = maxAscent + maxDescent;
1553
- let lineBaselineY = curY + (lineHeight - textBlockHeight) / 2 + maxAscent;
1554
- // Parent font size for vertical-align positioning (used in expansion + text
1555
- // emit) the largest baseline-aligned font on the line.
1556
- const lineNormalWords = line.words.filter(w => w.text !== '' && !isShiftedVAlign(w.style.verticalAlign));
1557
- const parentFontSize = lineNormalWords.length > 0
1558
- ? Math.max(...lineNormalWords.map(w => w.style.fontSize)) : 0;
1559
- // Expand line height if vertically-shifted content extends beyond the line
1560
- // box. Browsers grow the line box to fit all content, but keep the normal
1561
- // text baseline position unchanged.
1562
- let lineTop = curY;
1563
- let lineBottom = curY + lineHeight;
1564
- for (const word of line.words) {
1565
- if (word.text === '')
1566
- continue;
1567
- const va = word.style.verticalAlign;
1568
- if (!isShiftedVAlign(va))
1569
- continue;
1570
- if (parentFontSize === 0)
1571
- break;
1572
- const { ascent: wAscent, descent: wDescent } = getFontMetrics(ctx, word.style);
1573
- const shiftedBaseline = lineBaselineY +
1574
- verticalAlignShift(va, wAscent, wDescent, parentFontSize, maxAscent, maxDescent, lineHeight);
1575
- const wordTop = shiftedBaseline - wAscent;
1576
- const wordBottom = shiftedBaseline + wDescent;
1577
- if (wordTop < lineTop)
1578
- lineTop = wordTop;
1579
- if (wordBottom > lineBottom)
1580
- lineBottom = wordBottom;
1581
- }
1582
- 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;
1583
1692
  // Emit inline background box using line-level baseline for vertical alignment.
1584
1693
  // Uses the line's ascent/descent (not the box's own font) so box aligns with text.
1585
1694
  const emitInlineBox = (style, bx, bw) => {
1586
- // Use the box's OWN font for height (not the line's largest font),
1587
- // but align vertically to the line's baseline.
1588
- 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);
1589
1702
  const padTop = style.paddingTop + style.borderTopWidth;
1590
1703
  const padBottom = style.paddingBottom + style.borderBottomWidth;
1591
1704
  const boxHeight = boxAscent + boxDescent + padTop + padBottom;
1592
- let boxY;
1593
- if (style.display === 'inline-block') {
1594
- boxY = curY + style.marginTop;
1595
- }
1596
- else {
1597
- boxY = lineBaselineY - boxAscent - padTop;
1598
- }
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;
1599
1711
  results.push({
1600
1712
  type: 'box', style, x: bx, y: boxY, width: bw, height: boxHeight,
1601
1713
  tagName: 'span', children: [],
@@ -1786,9 +1898,7 @@ function layoutInlineContent(ctx, node, x, y, contentWidth, useBulletProbe = fal
1786
1898
  let baselineY = lineBaselineY;
1787
1899
  const va = word.style.verticalAlign;
1788
1900
  if (isShiftedVAlign(va)) {
1789
- const pfs = parentFontSize || word.style.fontSize;
1790
- const { ascent: wA, descent: wD } = getFontMetrics(ctx, word.style);
1791
- baselineY += verticalAlignShift(va, wA, wD, pfs, maxAscent, maxDescent, lineHeight);
1901
+ baselineY += verticalAlignShift(va, ctx, word.style, word.parentStyle ?? blockStyle, useBulletProbe);
1792
1902
  }
1793
1903
  const effectiveWidth = word.width + (word.isSpace ? justifyExtraPerSpace : 0);
1794
1904
  const node = {
@@ -1822,14 +1932,15 @@ function layoutInlineContent(ctx, node, x, y, contentWidth, useBulletProbe = fal
1822
1932
  text: line.words.map(w => w.text).join(''),
1823
1933
  bounds: {
1824
1934
  x: lineLeftX,
1825
- // Use the actual visual top (may be < curY when a super pushes the
1826
- // line box upward) so the rect covers ascenders/super content.
1827
- 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,
1828
1939
  width: lineWidth,
1829
- height: effectiveLineHeight,
1940
+ height: lineBoxHeight,
1830
1941
  },
1831
1942
  });
1832
- curY += effectiveLineHeight;
1943
+ curY += lineBoxHeight;
1833
1944
  }
1834
1945
  assignInlineFragmentBoxes(ctx, results, clipRuns, (node, s, box) => {
1835
1946
  node.clip = {
@@ -2188,9 +2299,10 @@ function addListMarker(ctx, box, node) {
2188
2299
  const ms = node.markerStyle;
2189
2300
  const markerStyleObj = ms ? { ...style, ...ms } : style;
2190
2301
  ctx.font = buildCanvasFont(markerStyleObj);
2191
- const lineHeight = getLineHeight(ctx, style);
2192
- const baselineY = box.y + style.borderTopWidth + style.paddingTop +
2193
- 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;
2194
2306
  const markerWidth = cachedMeasureWidth(ctx, node.listMarker);
2195
2307
  const isRTL = style.direction === 'rtl';
2196
2308
  const isBullet = BULLET_MARKERS.has(style.listStyleType);
@@ -2289,7 +2401,7 @@ function addListMarker(ctx, box, node) {
2289
2401
  x: markerLeftX,
2290
2402
  y: box.y + style.borderTopWidth + style.paddingTop,
2291
2403
  width: markerDrawWidth,
2292
- height: lineHeight,
2404
+ height: strut.ascent + strut.descent,
2293
2405
  },
2294
2406
  });
2295
2407
  }