render-tag 0.1.29 → 0.1.31

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;AAmHD;;;;;;;;;;;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,86 @@ 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.
162
+ *
163
+ * Blink is the DEFAULT, and the other two are what we detect: a server-side
164
+ * render (no navigator, or jsdom) targets headless Chrome, so anything we
165
+ * cannot positively identify has to round the way Chrome does.
166
+ *
167
+ * - Gecko is the one engine that still sends a real `Gecko/<date>` product
168
+ * token; Blink and WebKit carry only the "like Gecko" comment, no slash.
169
+ * - Safari is WebKit that says neither `Chrome/` nor `jsdom/`. jsdom borrows
170
+ * WebKit's UA and would otherwise be mistaken for it.
171
+ * - `Chrome/` is matched with NO word boundary, because headless Chrome sends
172
+ * `HeadlessChrome/`.
173
+ */
174
+ const UA = typeof navigator === 'undefined' ? '' : navigator.userAgent;
175
+ const IS_GECKO = /\bGecko\/\d/.test(UA);
176
+ const IS_SAFARI = /AppleWebKit/.test(UA) && !/Chrome\/\d/.test(UA) && !/\bjsdom\//.test(UA);
177
+ const IS_BLINK = !IS_GECKO && !IS_SAFARI;
178
+ /**
179
+ * True where the engine floors a line's baseline onto a whole CSS pixel.
180
+ *
181
+ * Blink alone does (`FontHeight::AddLeading`). Gecko and WebKit both lay the
182
+ * exact half-leading out — measured over the whole 530-case corpus, giving
183
+ * Safari the Blink branch cost 214 wins against 223 losses (avg 7.50% ->
184
+ * 9.21%) where the exact value wins 48 against 3 (7.50% -> 6.52%).
185
+ *
186
+ * Public API: the parity suites expect per engine, and every renderer that has
187
+ * to place a baseline beside a render-tag canvas (@polotno/svg-export) must
188
+ * round the same way this does.
189
+ */
190
+ export const FLOORS_LINE_BASELINE = IS_BLINK;
191
+ /**
192
+ * `super` and `sub` are engine constants, not CSS. Blink and WebKit share
193
+ * theirs (`fontSize/3 + 1`, `fontSize/5 + 1`); Gecko raises by 0.34em and
194
+ * lowers by 0.20em. This is a separate question from the baseline rounding
195
+ * above — Safari rounds like nobody and shifts like Blink.
196
+ */
197
+ const BLINK_SUPER_SUB = !IS_GECKO;
198
+ /**
199
+ * Baseline offset from the top of a line box, the way the engine places it.
200
+ *
201
+ * The CSS half-leading is `(lineHeight - (ascent + descent)) / 2`, and the
202
+ * baseline sits that far below the line top, plus the ascent. Blink FLOORS that
203
+ * sum to a whole CSS pixel (`FontHeight::AddLeading`), so its DOM text stands up
204
+ * to 1px HIGHER than the exact value. Gecko lays the exact value out in app
205
+ * units. WebKit floors on 80 of 90 measured size × line-height combinations and
206
+ * has no exact rule we can state, so it takes the Blink branch — the one that
207
+ * fits it best, not one it matches everywhere.
208
+ *
209
+ * So the rounding is the engine's, not a style choice: each browser's canvas
210
+ * lands on the baseline that browser's own DOM would use, which is what keeps a
211
+ * canvas render and a contenteditable overlay of the same text on one line.
212
+ */
213
+ function lineBaselineOffset(lineHeight, ascent, descent) {
214
+ const exact = (lineHeight - (ascent + descent)) / 2 + ascent;
215
+ return FLOORS_LINE_BASELINE ? Math.floor(exact) : exact;
216
+ }
217
+ /**
218
+ * The vertical space an inline-block's margin box adds around its content, over
219
+ * and above the font's own leading. Written once because the wrap pass grows
220
+ * the line by the same six values.
221
+ */
222
+ function inlineBlockExtra(bs) {
223
+ return {
224
+ top: bs.marginTop + bs.borderTopWidth + bs.paddingTop,
225
+ bottom: bs.paddingBottom + bs.borderBottomWidth + bs.marginBottom,
226
+ };
227
+ }
228
+ /**
229
+ * One box's half of a line: how far it reaches above its own baseline and how
230
+ * far below, over its OWN line-height. This is the inline box CSS 2.1 §10.8
231
+ * talks about — the font's content area plus its half-leading — not the bare
232
+ * font metrics. `vertical-align: text-top` and `text-bottom` align THIS box's
233
+ * edges, and the line box is the union of these over everything on the line.
162
234
  */
163
- function computeBaselineY(ctx, style, lineHeight) {
235
+ function leadedBox(ctx, style, useBulletProbe = false) {
164
236
  const { ascent, descent } = getFontMetrics(ctx, style);
165
- return (ascent - descent) / 2 + lineHeight / 2;
237
+ const lineHeight = getLineHeight(ctx, style, useBulletProbe);
238
+ const boxAscent = lineBaselineOffset(lineHeight, ascent, descent);
239
+ return { ascent: boxAscent, descent: lineHeight - boxAscent };
166
240
  }
167
241
  function applyTextTransform(text, transform) {
168
242
  switch (transform) {
@@ -210,25 +284,48 @@ export function getFontMetrics(ctx, style) {
210
284
  * pass (the box position depends on the final line box it helps size), so they
211
285
  * fall back to baseline rather than being approximated wrongly.
212
286
  *
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
287
+ * - super/sub the engine's own rule, measured off the DOM across
288
+ * 8-56px × sans-serif/serif/monospace and fitting every
289
+ * point to within 0.06px (LayoutUnit's 1/64). Neither
290
+ * engine reads the font's metrics — the family does not
291
+ * move the number.
292
+ * - text-top/-bottom the box's LEADED edge against the parent's CONTENT-area
293
+ * edge (bare ascent/descent, no leading). Taking the box's
294
+ * bare metrics instead costs 25px on a line holding both.
215
295
  * - middle box midpoint at parent baseline + half the x-height
216
296
  * - <length>/<%> raise (positive value) by the length / % of line-height
217
297
  */
218
- function verticalAlignShift(va, wAscent, wDescent, parentFontSize, maxAscent, maxDescent, lineHeight) {
298
+ function verticalAlignShift(va, ctx, style, parentStyle, useBulletProbe) {
219
299
  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;
300
+ case 'super':
301
+ return BLINK_SUPER_SUB
302
+ ? -(parentStyle.fontSize / 3 + 1) : -parentStyle.fontSize * 0.34;
303
+ case 'sub':
304
+ return BLINK_SUPER_SUB
305
+ ? parentStyle.fontSize / 5 + 1 : parentStyle.fontSize * 0.2;
306
+ // Against the PARENT's content area (CSS 2.1 §10.8.1) — its bare
307
+ // ascent/descent, no leading. Measured against Chrome, taking the line's
308
+ // tallest box instead of the real parent put this 14px out.
309
+ case 'text-top':
310
+ return leadedBox(ctx, style, useBulletProbe).ascent - getFontMetrics(ctx, parentStyle).ascent;
311
+ case 'text-bottom':
312
+ return getFontMetrics(ctx, parentStyle).descent - leadedBox(ctx, style, useBulletProbe).descent;
313
+ case 'middle': {
314
+ const { ascent, descent } = getFontMetrics(ctx, style);
315
+ return -(parentStyle.fontSize * 0.25) - (descent - ascent) / 2;
316
+ }
225
317
  default: {
226
- // baseline / top / bottom / '' all parseFloat to NaN → 0 (callers gate
227
- // on isShiftedVAlign, so those never actually reach here).
318
+ // baseline / top / bottom / '' all parseFloat to NaN → 0, which is what
319
+ // an unshifted run wants the line-box pass calls this for every word.
228
320
  const n = parseFloat(va);
229
321
  if (!Number.isFinite(n))
230
322
  return 0;
231
- return va.endsWith('%') ? -(n / 100) * lineHeight : -n;
323
+ // A percentage resolves against the ELEMENT's own line-height (CSS 2.1
324
+ // §10.8.1), not the line's. Measured against Chrome: the line's put the
325
+ // box 10px out on a line whose tallest run was not this one.
326
+ return va.endsWith('%')
327
+ ? -(n / 100) * getLineHeight(ctx, style, useBulletProbe)
328
+ : -n;
232
329
  }
233
330
  }
234
331
  }
@@ -351,6 +448,9 @@ function applyEllipsisToLine(ctx, line, maxWidth) {
351
448
  return;
352
449
  const lastStyle = line.words[styleIdx].style;
353
450
  const boxStyle = line.words[styleIdx].boxStyle;
451
+ // The ellipsis takes the trimmed run's style, so it has to take the parent
452
+ // that style's vertical-align measures against too.
453
+ const parentStyle = line.words[styleIdx].parentStyle;
354
454
  applyFont(ctx, lastStyle);
355
455
  // ALWAYS assign (don't gate on truthy) — otherwise a previous segment's
356
456
  // non-zero letter-spacing leaks into the ellipsis measurement.
@@ -386,6 +486,7 @@ function applyEllipsisToLine(ctx, line, maxWidth) {
386
486
  text: '…',
387
487
  width: ellipsisWidth,
388
488
  style: lastStyle,
489
+ parentStyle,
389
490
  isSpace: false,
390
491
  boxStyle,
391
492
  };
@@ -400,9 +501,15 @@ function applyEllipsisToLine(ctx, line, maxWidth) {
400
501
  */
401
502
  function collectTextRuns(node) {
402
503
  const runs = [];
403
- function walk(n, boxStyle, clipStyle, strokeImageStyle) {
504
+ function walk(n, boxStyle, clipStyle, strokeImageStyle, parentStyle) {
404
505
  if (n.tagName === '#text' && n.textContent) {
405
- runs.push({ text: n.textContent, style: n.style, boxStyle, clipStyle, strokeImageStyle });
506
+ // A #text node carries its parent ELEMENT's style, so the element that
507
+ // owns any vertical-align here is that parent — and what the shift
508
+ // measures against is ITS parent, which is the `parentStyle` handed to
509
+ // this element's walk.
510
+ runs.push({
511
+ text: n.textContent, style: n.style, parentStyle, boxStyle, clipStyle, strokeImageStyle,
512
+ });
406
513
  return;
407
514
  }
408
515
  const isInlineBlock = n.style.display === 'inline-block';
@@ -425,6 +532,7 @@ function collectTextRuns(node) {
425
532
  runs.push({
426
533
  text: allText,
427
534
  style: n.style,
535
+ parentStyle,
428
536
  boxStyle: newBoxStyle,
429
537
  clipStyle: newClipStyle,
430
538
  strokeImageStyle: newStrokeImageStyle,
@@ -445,7 +553,11 @@ function collectTextRuns(node) {
445
553
  runs.push({ text: '', style: n.style, boxStyle: newBoxStyle, boxOpen: n.style });
446
554
  }
447
555
  for (const child of n.children) {
448
- walk(child, isBox ? newBoxStyle : boxStyle, newClipStyle, newStrokeImageStyle);
556
+ walk(child, isBox ? newBoxStyle : boxStyle, newClipStyle, newStrokeImageStyle,
557
+ // An element child measures against this element; a text child's
558
+ // vertical-align belongs to this element, so it measures against what
559
+ // this element measures against.
560
+ child.tagName === '#text' ? parentStyle : n.style);
449
561
  }
450
562
  if (hasHorizSpacing) {
451
563
  runs.push({ text: '', style: n.style, boxStyle: newBoxStyle, boxClose: n.style });
@@ -465,8 +577,9 @@ function collectTextRuns(node) {
465
577
  runs.push(...seg);
466
578
  }
467
579
  }
580
+ // The block itself is the parent every top-level run measures against.
468
581
  for (const child of node.children) {
469
- walk(child);
582
+ walk(child, undefined, undefined, undefined, node.style);
470
583
  }
471
584
  return runs;
472
585
  }
@@ -550,6 +663,7 @@ function tokenizeString(ctx, text, run, allWords, cumState) {
550
663
  text: '\t',
551
664
  width: tabStopInterval, // placeholder — recalculated in flowWordsIntoLines
552
665
  style: run.style,
666
+ parentStyle: run.parentStyle,
553
667
  isSpace: true,
554
668
  isTab: true,
555
669
  boxStyle: run.boxStyle,
@@ -563,6 +677,7 @@ function tokenizeString(ctx, text, run, allWords, cumState) {
563
677
  text: w,
564
678
  width: cachedMeasureWidth(ctx, w),
565
679
  style: run.style,
680
+ parentStyle: run.parentStyle,
566
681
  isSpace,
567
682
  boxStyle: run.boxStyle,
568
683
  clipStyle: run.clipStyle,
@@ -600,6 +715,7 @@ function tokenizeString(ctx, text, run, allWords, cumState) {
600
715
  text: ' ',
601
716
  width: spaceWidth,
602
717
  style: run.style,
718
+ parentStyle: run.parentStyle,
603
719
  isSpace: true,
604
720
  boxStyle: run.boxStyle,
605
721
  clipStyle: run.clipStyle,
@@ -620,6 +736,7 @@ function tokenizeString(ctx, text, run, allWords, cumState) {
620
736
  text: s,
621
737
  width: cumWidth - prevCum,
622
738
  style: run.style,
739
+ parentStyle: run.parentStyle,
623
740
  isSpace: false,
624
741
  boxStyle: run.boxStyle,
625
742
  clipStyle: run.clipStyle,
@@ -645,6 +762,7 @@ function tokenizeString(ctx, text, run, allWords, cumState) {
645
762
  text: w,
646
763
  width,
647
764
  style: run.style,
765
+ parentStyle: run.parentStyle,
648
766
  isSpace: false,
649
767
  boxStyle: run.boxStyle,
650
768
  clipStyle: run.clipStyle,
@@ -688,6 +806,7 @@ function tokenizeRuns(ctx, runs) {
688
806
  text,
689
807
  width: totalWidth,
690
808
  style: run.style,
809
+ parentStyle: run.parentStyle,
691
810
  isSpace: false,
692
811
  boxStyle: run.boxStyle,
693
812
  boxOpen: run.boxOpen,
@@ -962,6 +1081,7 @@ function flowWordsIntoLines(ctx, words, contentWidth, whiteSpace, useBulletProbe
962
1081
  text: '-',
963
1082
  width: hyphenWidth,
964
1083
  style: lastWord.style,
1084
+ parentStyle: lastWord.parentStyle,
965
1085
  isSpace: false,
966
1086
  // The visible hyphen continues the broken word, so it inherits the
967
1087
  // word's clip/stroke-image declarer (else it paints transparent).
@@ -991,9 +1111,11 @@ function flowWordsIntoLines(ctx, words, contentWidth, whiteSpace, useBulletProbe
991
1111
  let wordLineHeight = getLineHeight(ctx, word.style, useBulletProbe);
992
1112
  // Inline-block elements expand line height with their vertical padding+margin
993
1113
  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);
1114
+ // Clamped at 0: negative margins shrink the margin box, but the original
1115
+ // `Math.max(h, h + extra)` never let them shrink the LINE, and nothing
1116
+ // here is measuring a case that says they should.
1117
+ const extra = inlineBlockExtra(word.boxStyle);
1118
+ wordLineHeight += Math.max(0, extra.top + extra.bottom);
997
1119
  }
998
1120
  if (word.text === '\n') {
999
1121
  if (currentLine.words.length === 0) {
@@ -1042,6 +1164,7 @@ function flowWordsIntoLines(ctx, words, contentWidth, whiteSpace, useBulletProbe
1042
1164
  cells.push({
1043
1165
  ch,
1044
1166
  style: words[j].style,
1167
+ parentStyle: words[j].parentStyle,
1045
1168
  clipStyle: words[j].clipStyle,
1046
1169
  strokeImageStyle: words[j].strokeImageStyle,
1047
1170
  });
@@ -1076,6 +1199,7 @@ function flowWordsIntoLines(ctx, words, contentWidth, whiteSpace, useBulletProbe
1076
1199
  // word), so capturing it at the run start covers every push below.
1077
1200
  const clipStyle = cs[i].clipStyle;
1078
1201
  const strokeImageStyle = cs[i].strokeImageStyle;
1202
+ const parentStyle = cs[i].parentStyle;
1079
1203
  applyFont(ctx, st);
1080
1204
  ctx.letterSpacing = formatLetterSpacing(st.letterSpacing);
1081
1205
  const lh = getLineHeight(ctx, st, useBulletProbe);
@@ -1088,7 +1212,7 @@ function flowWordsIntoLines(ctx, words, contentWidth, whiteSpace, useBulletProbe
1088
1212
  if (chars && currentLine.totalWidth + candW > effWidth() &&
1089
1213
  (currentLine.words.length > 0 || cur)) {
1090
1214
  if (cur) {
1091
- currentLine.words.push({ text: cur, width: curW, style: st, isSpace: false, clipStyle, strokeImageStyle });
1215
+ currentLine.words.push({ text: cur, width: curW, style: st, isSpace: false, parentStyle, clipStyle, strokeImageStyle });
1092
1216
  currentLine.totalWidth += curW;
1093
1217
  currentLine.lineHeight = Math.max(currentLine.lineHeight, lh);
1094
1218
  }
@@ -1104,7 +1228,7 @@ function flowWordsIntoLines(ctx, words, contentWidth, whiteSpace, useBulletProbe
1104
1228
  i++;
1105
1229
  }
1106
1230
  if (cur) {
1107
- currentLine.words.push({ text: cur, width: curW, style: st, isSpace: false, clipStyle, strokeImageStyle });
1231
+ currentLine.words.push({ text: cur, width: curW, style: st, isSpace: false, parentStyle, clipStyle, strokeImageStyle });
1108
1232
  currentLine.totalWidth += curW;
1109
1233
  currentLine.lineHeight = Math.max(currentLine.lineHeight, lh);
1110
1234
  afterHardBreak = false;
@@ -1458,7 +1582,9 @@ function layoutInlineContent(ctx, node, x, y, contentWidth, useBulletProbe = fal
1458
1582
  // only content is a SMALLER inline font sits on the strut baseline (lower in
1459
1583
  // the box), not centered in it. Seed each line's ascent/descent with the
1460
1584
  // block font's metrics so the baseline lands where the DOM puts it.
1461
- const strutMetrics = getFontMetrics(ctx, node.style);
1585
+ // The block is the parent of any run with no inline ancestor, and the emit
1586
+ // loop shadows `node` with the LayoutText it builds.
1587
+ const blockStyle = node.style;
1462
1588
  let curY = y;
1463
1589
  for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
1464
1590
  const line = lines[lineIdx];
@@ -1466,7 +1592,6 @@ function layoutInlineContent(ctx, node, x, y, contentWidth, useBulletProbe = fal
1466
1592
  curY += line.lineHeight;
1467
1593
  continue;
1468
1594
  }
1469
- const lineHeight = line.lineHeight;
1470
1595
  const isLastLine = lineIdx === lines.length - 1;
1471
1596
  const isFirstLine = lineIdx === 0;
1472
1597
  // Per-line alignment: lines ending at a forced break or the last line
@@ -1517,85 +1642,78 @@ function layoutInlineContent(ctx, node, x, y, contentWidth, useBulletProbe = fal
1517
1642
  const lineLeftX = curX;
1518
1643
  // Inline background boxes and text are emitted after baseline computation
1519
1644
  // (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;
1645
+ // The line box is the union of every box on it — strut, run, shifted run,
1646
+ // inline-block each carrying its own leading over its own line-height:
1647
+ // lineAscent = max(ascent - shift), lineDescent = max(descent + shift).
1648
+ // One font, one line-height and no shift collapse that back to the plain
1649
+ // half-leading every single-style line already had.
1650
+ const strutBox = leadedBox(ctx, node.style, useBulletProbe);
1651
+ let lineAscent = strutBox.ascent;
1652
+ let lineDescent = strutBox.descent;
1527
1653
  for (const word of line.words) {
1528
1654
  if (word.text === '')
1529
1655
  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;
1656
+ // A wrapper element with no text of its own — `<span lh:3><span>x</span>`
1657
+ // never becomes a Word, but it is still a box on the line and still
1658
+ // brings its own line-height. Its run children carry it as `parentStyle`,
1659
+ // so take it from there, AT ITS OWN SHIFT: added unshifted, a wrapper
1660
+ // that carries a vertical-align and direct text enters the union twice
1661
+ // at two different places, and the line spans both (measured 60px where
1662
+ // the DOM has 40). With the shift it is idempotent — a wrapper with
1663
+ // direct text contributes the identical box through its own run.
1664
+ if (word.parentStyle) {
1665
+ const parentBox = leadedBox(ctx, word.parentStyle, useBulletProbe);
1666
+ const parentShift = verticalAlignShift(word.parentStyle.verticalAlign, ctx, word.parentStyle, blockStyle, useBulletProbe);
1667
+ if (parentBox.ascent - parentShift > lineAscent) {
1668
+ lineAscent = parentBox.ascent - parentShift;
1669
+ }
1670
+ if (parentBox.descent + parentShift > lineDescent) {
1671
+ lineDescent = parentBox.descent + parentShift;
1672
+ }
1549
1673
  }
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;
1674
+ const box = leadedBox(ctx, word.style, useBulletProbe);
1675
+ // An inline-block joins the line as an ATOMIC box: its own content
1676
+ // baseline with its margin box stacked around it. It takes the extra
1677
+ // space, but no shift the emit pass puts its content on the line
1678
+ // baseline and does not honour vertical-align on it, so shifting the box
1679
+ // here would grow the line one way while the paint went the other.
1680
+ const atomic = word.boxStyle?.display === 'inline-block' ? word.boxStyle : null;
1681
+ if (atomic) {
1682
+ const extra = inlineBlockExtra(atomic);
1683
+ box.ascent += extra.top;
1684
+ box.descent += extra.bottom;
1685
+ }
1686
+ // A shift moves the box, not the line's baseline: positive is downward,
1687
+ // so it lifts the box's demand on the ascent side and adds to the descent
1688
+ // side. The parent-size fallback is the one the emit pass uses, so a line
1689
+ // whose only content is shifted still sizes around it.
1690
+ const shift = atomic ? 0 : verticalAlignShift(word.style.verticalAlign, ctx, word.style, word.parentStyle ?? blockStyle, useBulletProbe);
1691
+ if (box.ascent - shift > lineAscent)
1692
+ lineAscent = box.ascent - shift;
1693
+ if (box.descent + shift > lineDescent)
1694
+ lineDescent = box.descent + shift;
1695
+ }
1696
+ const lineBoxHeight = lineAscent + lineDescent;
1697
+ const lineBaselineY = curY + lineAscent;
1583
1698
  // Emit inline background box using line-level baseline for vertical alignment.
1584
1699
  // Uses the line's ascent/descent (not the box's own font) so box aligns with text.
1585
1700
  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);
1701
+ // The box's OWN font decides its height, not the line's largest. An
1702
+ // inline-block's content box is its LINE-HEIGHT, though, not the bare
1703
+ // font metrics — measured against Chrome, bare metrics put it at
1704
+ // y=6 h=29 where the DOM has y=4 h=33.2.
1705
+ const { ascent: boxAscent, descent: boxDescent } = style.display === 'inline-block'
1706
+ ? leadedBox(ctx, style, useBulletProbe)
1707
+ : getFontMetrics(ctx, style);
1589
1708
  const padTop = style.paddingTop + style.borderTopWidth;
1590
1709
  const padBottom = style.paddingBottom + style.borderBottomWidth;
1591
1710
  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
- }
1711
+ // Every inline box hangs off the line's baseline, an inline-block too:
1712
+ // its content is emitted on that baseline, so a box pinned to the line
1713
+ // TOP instead detached from its own glyphs as soon as something taller
1714
+ // shared the line — measured, a background at y 4..33 around text whose
1715
+ // baseline was 46.
1716
+ const boxY = lineBaselineY - boxAscent - padTop;
1599
1717
  results.push({
1600
1718
  type: 'box', style, x: bx, y: boxY, width: bw, height: boxHeight,
1601
1719
  tagName: 'span', children: [],
@@ -1786,9 +1904,7 @@ function layoutInlineContent(ctx, node, x, y, contentWidth, useBulletProbe = fal
1786
1904
  let baselineY = lineBaselineY;
1787
1905
  const va = word.style.verticalAlign;
1788
1906
  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);
1907
+ baselineY += verticalAlignShift(va, ctx, word.style, word.parentStyle ?? blockStyle, useBulletProbe);
1792
1908
  }
1793
1909
  const effectiveWidth = word.width + (word.isSpace ? justifyExtraPerSpace : 0);
1794
1910
  const node = {
@@ -1822,14 +1938,15 @@ function layoutInlineContent(ctx, node, x, y, contentWidth, useBulletProbe = fal
1822
1938
  text: line.words.map(w => w.text).join(''),
1823
1939
  bounds: {
1824
1940
  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,
1941
+ // The line box starts at curY: shifted content grew the box through
1942
+ // `lineAscent`/`lineDescent` above, so nothing on the line reaches
1943
+ // outside it.
1944
+ y: curY,
1828
1945
  width: lineWidth,
1829
- height: effectiveLineHeight,
1946
+ height: lineBoxHeight,
1830
1947
  },
1831
1948
  });
1832
- curY += effectiveLineHeight;
1949
+ curY += lineBoxHeight;
1833
1950
  }
1834
1951
  assignInlineFragmentBoxes(ctx, results, clipRuns, (node, s, box) => {
1835
1952
  node.clip = {
@@ -2188,9 +2305,10 @@ function addListMarker(ctx, box, node) {
2188
2305
  const ms = node.markerStyle;
2189
2306
  const markerStyleObj = ms ? { ...style, ...ms } : style;
2190
2307
  ctx.font = buildCanvasFont(markerStyleObj);
2191
- const lineHeight = getLineHeight(ctx, style);
2192
- const baselineY = box.y + style.borderTopWidth + style.paddingTop +
2193
- computeBaselineY(ctx, style, lineHeight);
2308
+ // ascent + descent is the li's line-height by construction, so one call
2309
+ // gives both the marker's baseline and the box it reports.
2310
+ const strut = leadedBox(ctx, style);
2311
+ const baselineY = box.y + style.borderTopWidth + style.paddingTop + strut.ascent;
2194
2312
  const markerWidth = cachedMeasureWidth(ctx, node.listMarker);
2195
2313
  const isRTL = style.direction === 'rtl';
2196
2314
  const isBullet = BULLET_MARKERS.has(style.listStyleType);
@@ -2289,7 +2407,7 @@ function addListMarker(ctx, box, node) {
2289
2407
  x: markerLeftX,
2290
2408
  y: box.y + style.borderTopWidth + style.paddingTop,
2291
2409
  width: markerDrawWidth,
2292
- height: lineHeight,
2410
+ height: strut.ascent + strut.descent,
2293
2411
  },
2294
2412
  });
2295
2413
  }