domotion-svg 0.23.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/dist/capture/backdrop-isolation.d.ts +14 -0
  2. package/dist/capture/backdrop-isolation.js +49 -0
  3. package/dist/capture/emoji.js +115 -1
  4. package/dist/capture/script/emoji-detect.js +22 -1
  5. package/dist/capture/script/index.js +36 -2
  6. package/dist/capture/script/walker/form-controls.d.ts +1 -19
  7. package/dist/capture/script/walker/form-controls.js +34 -3
  8. package/dist/capture/script/walker/fragmentation.js +60 -0
  9. package/dist/capture/script/walker/lists-counters.d.ts +3 -1
  10. package/dist/capture/script/walker/lists-counters.js +2 -1
  11. package/dist/capture/script/walker/pseudo-content.d.ts +2 -0
  12. package/dist/capture/script/walker/pseudo-content.js +53 -35
  13. package/dist/capture/script/walker/pseudo-inject.js +4 -2
  14. package/dist/capture/script/walker/text-segments.d.ts +2 -0
  15. package/dist/capture/script/walker/text-segments.js +2 -0
  16. package/dist/capture/script.generated.js +1 -1
  17. package/dist/capture/types.d.ts +46 -3
  18. package/dist/render/element-tree-to-svg.js +73 -55
  19. package/dist/render/font-resolution.js +7 -1
  20. package/dist/render/form-controls.js +34 -10
  21. package/dist/render/gradient-defs.js +113 -32
  22. package/dist/render/gradients.js +35 -27
  23. package/dist/render/harfbuzz-shaper.js +4 -4
  24. package/dist/render/helper-acquire.js +1 -1
  25. package/dist/render/list-marker-geometry.d.ts +24 -0
  26. package/dist/render/list-marker-geometry.js +57 -0
  27. package/dist/render/script-segmentation.js +19 -9
  28. package/dist/render/text-to-path.d.ts +5 -3
  29. package/dist/render/text-to-path.js +113 -40
  30. package/dist/render/text.js +2 -0
  31. package/dist/render/vertical-text.js +17 -20
  32. package/package.json +2 -1
@@ -0,0 +1,14 @@
1
+ export interface SnapshotNode {
2
+ backendNodeId: number;
3
+ parentIndex: number;
4
+ attributes: string[];
5
+ bounds?: [number, number, number, number];
6
+ paintOrder?: number;
7
+ layoutOrder?: number;
8
+ }
9
+ export interface IsolationPlan {
10
+ targetBackendNodeId: number;
11
+ hideBackendNodeIds: number[];
12
+ }
13
+ /** Pure CDP snapshot planner; DOM mutation and restoration stay in emoji.ts. */
14
+ export declare function planBackdropIsolation(nodes: SnapshotNode[], token: string): IsolationPlan | null;
@@ -0,0 +1,49 @@
1
+ function overlaps(a, b) {
2
+ return a[0] < b[0] + b[2] && a[0] + a[2] > b[0]
3
+ && a[1] < b[1] + b[3] && a[1] + a[3] > b[1];
4
+ }
5
+ function hasToken(node, token) {
6
+ for (let i = 0; i + 1 < node.attributes.length; i += 2) {
7
+ if (node.attributes[i] === "data-domotion-backdrop-raster" && node.attributes[i + 1] === token)
8
+ return true;
9
+ }
10
+ return false;
11
+ }
12
+ function isAncestor(nodes, ancestor, child) {
13
+ for (let i = child; i >= 0; i = nodes[i]?.parentIndex ?? -1)
14
+ if (i === ancestor)
15
+ return true;
16
+ return false;
17
+ }
18
+ /** Pure CDP snapshot planner; DOM mutation and restoration stay in emoji.ts. */
19
+ export function planBackdropIsolation(nodes, token) {
20
+ const target = nodes.findIndex((node) => hasToken(node, token));
21
+ if (target < 0)
22
+ return null;
23
+ const targetNode = nodes[target];
24
+ if (targetNode.bounds == null || targetNode.paintOrder == null)
25
+ return null;
26
+ const hide = [];
27
+ for (let i = 0; i < nodes.length; i++) {
28
+ const node = nodes[i];
29
+ if (i === target || node.bounds == null || node.paintOrder == null)
30
+ continue;
31
+ const paintsLater = node.paintOrder > targetNode.paintOrder
32
+ || (node.paintOrder === targetNode.paintOrder
33
+ && node.layoutOrder != null && targetNode.layoutOrder != null
34
+ && node.layoutOrder > targetNode.layoutOrder);
35
+ if (!paintsLater || !overlaps(node.bounds, targetNode.bounds))
36
+ continue;
37
+ if (isAncestor(nodes, i, target) || isAncestor(nodes, target, i))
38
+ continue;
39
+ // If an already-selected later ancestor hides this node, do not resolve and
40
+ // mutate the descendant too. Snapshot order is parent-before-child.
41
+ if (hide.some((id) => {
42
+ const ancestor = nodes.findIndex((n) => n.backendNodeId === id);
43
+ return ancestor >= 0 && isAncestor(nodes, ancestor, i);
44
+ }))
45
+ continue;
46
+ hide.push(node.backendNodeId);
47
+ }
48
+ return { targetBackendNodeId: targetNode.backendNodeId, hideBackendNodeIds: hide };
49
+ }
@@ -20,6 +20,7 @@ import * as fontkit from "fontkit";
20
20
  import sharp from "sharp";
21
21
  import { clipRectForScreenshot } from "./clip-rect.js";
22
22
  import { forEachElement } from "../tree-ops/for-each-element.js";
23
+ import { planBackdropIsolation } from "./backdrop-isolation.js";
23
24
  const APPLE_COLOR_EMOJI_PATH = "/System/Library/Fonts/Apple Color Emoji.ttc";
24
25
  let _aceFont = null;
25
26
  let _aceFontLoaded = false;
@@ -382,7 +383,119 @@ async function calibrateSbixOverlays(page, viewport, jobs) {
382
383
  catch { /* leave this overlay as stamped */ }
383
384
  }
384
385
  }
386
+ async function rasterizeBackdropFilters(page, tree, viewport) {
387
+ const targets = [];
388
+ forEachElement(tree, (el) => { if (el.backdropFilterRaster?.token != null)
389
+ targets.push(el.backdropFilterRaster); });
390
+ if (targets.length === 0)
391
+ return;
392
+ let cdp;
393
+ try {
394
+ cdp = await page.context().newCDPSession(page);
395
+ const snap = await cdp.send("DOMSnapshot.captureSnapshot", {
396
+ computedStyles: [], includePaintOrder: true, includeDOMRects: true,
397
+ });
398
+ const doc = snap.documents?.[0];
399
+ const strings = snap.strings ?? [];
400
+ const paintByNode = new Map();
401
+ for (let i = 0; i < (doc?.layout?.nodeIndex?.length ?? 0); i++) {
402
+ const nodeIndex = doc.layout.nodeIndex[i];
403
+ const bounds = doc.layout.bounds[i];
404
+ const paintOrder = doc.layout.paintOrders?.[i];
405
+ if (paintOrder != null && !paintByNode.has(nodeIndex))
406
+ paintByNode.set(nodeIndex, { bounds, paintOrder, layoutOrder: i });
407
+ }
408
+ const attributesByNode = new Map();
409
+ const rareAttributes = doc?.nodes?.attributes;
410
+ if (Array.isArray(rareAttributes)) {
411
+ for (let i = 0; i < rareAttributes.length; i++)
412
+ attributesByNode.set(i, rareAttributes[i]);
413
+ }
414
+ else {
415
+ for (let i = 0; i < (rareAttributes?.index?.length ?? 0); i++) {
416
+ attributesByNode.set(rareAttributes.index[i], rareAttributes.value[i]);
417
+ }
418
+ }
419
+ const nodes = (doc?.nodes?.backendNodeId ?? []).map((backendNodeId, i) => {
420
+ const attrIndexes = attributesByNode.get(i) ?? [];
421
+ const attributes = attrIndexes.map((idx) => strings[idx]);
422
+ const layout = paintByNode.get(i);
423
+ return {
424
+ backendNodeId,
425
+ parentIndex: doc.nodes.parentIndex?.[i] ?? -1,
426
+ attributes,
427
+ bounds: layout?.bounds,
428
+ paintOrder: layout?.paintOrder,
429
+ layoutOrder: layout?.layoutOrder,
430
+ };
431
+ });
432
+ for (const target of targets) {
433
+ const plan = planBackdropIsolation(nodes, target.token);
434
+ const restores = [];
435
+ try {
436
+ if (plan != null) {
437
+ for (const backendNodeId of plan.hideBackendNodeIds) {
438
+ try {
439
+ const resolved = await cdp.send("DOM.resolveNode", { backendNodeId });
440
+ const objectId = resolved.object?.objectId;
441
+ if (objectId == null)
442
+ continue;
443
+ const changed = await cdp.send("Runtime.callFunctionOn", {
444
+ objectId,
445
+ functionDeclaration: "function(){const v=this.style.getPropertyValue('visibility');const p=this.style.getPropertyPriority('visibility');this.style.setProperty('visibility','hidden','important');return {v,p};}",
446
+ returnByValue: true,
447
+ });
448
+ restores.push({ objectId, value: changed.result?.value?.v ?? "", priority: changed.result?.value?.p ?? "" });
449
+ }
450
+ catch { /* conservative fallback: leave this node painted */ }
451
+ }
452
+ }
453
+ const clip = clipRectForScreenshot(target, viewport);
454
+ const buf = await page.screenshot({ clip, omitBackground: true, type: "png" });
455
+ target.dataUri = `data:image/png;base64,${Buffer.from(buf).toString("base64")}`;
456
+ target.x = clip.x - viewport.x;
457
+ target.y = clip.y - viewport.y;
458
+ target.width = clip.width;
459
+ target.height = clip.height;
460
+ }
461
+ catch { /* retain vector fallback if screenshot itself fails */ }
462
+ finally {
463
+ for (let i = restores.length - 1; i >= 0; i--) {
464
+ const restore = restores[i];
465
+ try {
466
+ await cdp.send("Runtime.callFunctionOn", {
467
+ objectId: restore.objectId,
468
+ functionDeclaration: "function(v,p){if(v==='')this.style.removeProperty('visibility');else this.style.setProperty('visibility',v,p);}",
469
+ arguments: [{ value: restore.value }, { value: restore.priority }],
470
+ });
471
+ }
472
+ catch { /* page teardown */ }
473
+ }
474
+ }
475
+ }
476
+ }
477
+ catch {
478
+ // DOMSnapshot is Chromium-only and mapping can fail for pseudo/fragments.
479
+ // Fall back to the original full-page crop for every unresolved target.
480
+ for (const target of targets) {
481
+ try {
482
+ const clip = clipRectForScreenshot(target, viewport);
483
+ const buf = await page.screenshot({ clip, omitBackground: true, type: "png" });
484
+ target.dataUri = `data:image/png;base64,${Buffer.from(buf).toString("base64")}`;
485
+ }
486
+ catch { /* leave dataUri absent */ }
487
+ }
488
+ }
489
+ finally {
490
+ await cdp?.detach().catch(() => undefined);
491
+ await page.evaluate(() => {
492
+ for (const el of document.querySelectorAll("[data-domotion-backdrop-raster]"))
493
+ el.removeAttribute("data-domotion-backdrop-raster");
494
+ }).catch(() => undefined);
495
+ }
496
+ }
385
497
  export async function rasterizeBitmapGlyphs(page, tree, viewport) {
498
+ await rasterizeBackdropFilters(page, tree, viewport);
386
499
  // Two kinds of candidates share the pipeline:
387
500
  // - Segment-level rasterRect (SK-1058): the whole pseudo text is a color-
388
501
  // bitmap run; renderer emits one <image> and skips the text path.
@@ -403,9 +516,10 @@ export async function rasterizeBitmapGlyphs(page, tree, viewport) {
403
516
  if (el.nativeControlRaster != null) {
404
517
  const nr = el.nativeControlRaster;
405
518
  candidates.push({
406
- rect: { x: nr.x, y: nr.y, width: nr.width, height: nr.height },
519
+ rect: nr,
407
520
  key: `native-control|${el.tag}|${el.styles.inputType ?? ''}|${nr.x}|${nr.y}|${nr.width}x${nr.height}`,
408
521
  setDataUri: (uri) => { nr.dataUri = uri; },
522
+ snapRectToClip: true,
409
523
  });
410
524
  }
411
525
  // Element-level raster (SK-1108): textarea content region, too
@@ -338,6 +338,23 @@ export const createEmojiDetect = () => {
338
338
  // emoji over Chrome's text glyph.
339
339
  if (emojiPresentation26.has(cp))
340
340
  return isColorGlyph(cp, font);
341
+ // DM-2167: Emoji_Presentation is not confined to the 2600 block or the
342
+ // supplementary emoji planes. Misc Technical contains default-emoji
343
+ // characters such as WATCH, HOURGLASS and the media-control buttons
344
+ // (U+231A/U+231B/U+23E9..U+23F3). Linux Chromium routes these through
345
+ // Noto Color Emoji, but the old hand-partitioned ranges left them on the
346
+ // outline path, where a CBDT-only face has no vector glyph and vanished.
347
+ // Keep this cascade-sensitive: a leading monochrome face must still win.
348
+ if (cp >= 0x2300 && cp <= 0x23FF
349
+ && RE_EMOJI_PRESENTATION.test(String.fromCodePoint(cp)))
350
+ return isColorGlyph(cp, font);
351
+ // The Mahjong Tiles and Playing Cards blocks are another gap below the
352
+ // historical U+1F300 supplementary-plane floor. Chromium paints MAHJONG
353
+ // TILE RED DRAGON and PLAYING CARD BLACK JOKER from the color font when
354
+ // that is the resolved cascade face; keep text-font coverage authoritative.
355
+ if (cp >= 0x1F000 && cp <= 0x1F0FF
356
+ && RE_EMOJI_PRESENTATION.test(String.fromCodePoint(cp)))
357
+ return isColorGlyph(cp, font);
341
358
  // U+FE0F (Variation Selector-16) after a base emoji codepoint requests
342
359
  // emoji presentation — Chrome paints the colorful glyph instead of the
343
360
  // text-mode path glyph. DM-278.
@@ -425,8 +442,12 @@ export const createEmojiDetect = () => {
425
442
  // ⭐, ⭕). Cascade-dependent — Chrome paints text when the stack reaches a
426
443
  // monochrome symbol/math font first (Apple Symbols / STIX Two Math), color
427
444
  // otherwise. See `emojiPresentation2B` above.
445
+ // Some color-font glyphs in this set are intentionally achromatic (the
446
+ // black/white square pair). A saturation-only probe mistakes those bitmap
447
+ // glyphs for text. Color fonts ignore CSS fill while monochrome symbol
448
+ // fonts inherit it, so fill invariance supplies the missing discriminator.
428
449
  if (emojiPresentation2B.has(cp))
429
- return isColorGlyph(cp, font);
450
+ return isColorGlyph(cp, font) || ignoresFillColor(cp, font);
430
451
  // DM-1167: the ONLY two codepoints in the Misc Symbols & Pictographs block
431
452
  // (U+1F300-1F5FF) that a macOS text font also covers monochrome are
432
453
  // 🌐 U+1F310 (GLOBE WITH MERIDIANS) and 🎤 U+1F3A4 (MICROPHONE) — Apple
@@ -49,6 +49,7 @@ const captureDocumentTree = (args) => {
49
49
  // `--cross-origin-frames` value, passed in as `args.cof`). null in the
50
50
  // default (Phase 1) configuration — only same-origin frames recurse then.
51
51
  const _crossOriginAllow = parseCrossOriginAllowlist(args.cof);
52
+ let _backdropRasterSeq = 0;
52
53
  // Wire up per-concern helpers. Each factory closes over its own state and
53
54
  // returns the handles captureInner / the orchestration tail call. Renamed
54
55
  // (e.g. `warnings: _warnings`) to keep captureInner's existing references
@@ -79,7 +80,7 @@ const captureDocumentTree = (args) => {
79
80
  // against a recursed iframe's own document (`_runCounterStylePrewalk(doc)`).
80
81
  const _runCounterStylePrewalk = createCounterStylePrewalk({ counterStyles: _counterStyles });
81
82
  const { resolveCounterStyle, resolveCounterValue, isCustomCounterStyle } = createCounterStyleResolver({ counterStyles: _counterStyles });
82
- const { captureListsCounters } = createListsCountersHandler({ normColor, resolveCounterStyle, isCustomCounterStyle });
83
+ const { captureListsCounters } = createListsCountersHandler({ normColor, resolveCounterStyle, isCustomCounterStyle, measureFontMetrics: _measureFontMetrics });
83
84
  const { handleReplacedElement } = createReplacedElementsHandler({ vp });
84
85
  const { discoverMasks, discoverClipPaths, discoverFilters, maskDefs: _maskDefs, maskRasters: _maskRasters, clipPathDefs: _clipPathDefs, filterDefs: _filterDefs } = createMasksClipsHandler({ vp, warn });
85
86
  const { captureFormControls } = createFormControlsHandler({ normColor, resolvePseudo: _resolvePseudo });
@@ -768,7 +769,40 @@ const captureDocumentTree = (args) => {
768
769
  || tag === 'button' || tag === 'progress' || tag === 'meter';
769
770
  if (!nativeTag || cs.appearance === 'none' || rect.width <= 0 || rect.height <= 0)
770
771
  return undefined;
771
- return { x: rect.left - vp.x, y: rect.top - vp.y, width: rect.width, height: rect.height };
772
+ // Author outlines and validation-state focus rings paint outside the
773
+ // host border box even though the themed control itself is native.
774
+ // Blink's outline visual overflow is width + positive offset; include
775
+ // that surface in the same snapshot instead of clipping it at `rect`.
776
+ const outlineWidth = parseFloat(cs.outlineWidth) || 0;
777
+ const outlineOffset = parseFloat(cs.outlineOffset) || 0;
778
+ const expand = cs.outlineStyle !== 'none' && cs.outlineStyle !== 'hidden'
779
+ ? Math.max(0, outlineWidth + outlineOffset)
780
+ : 0;
781
+ // Skia AA coverage may extend one device-independent pixel past the
782
+ // layout border box (notably the lower edge of rounded author borders
783
+ // on otherwise native inputs). Preserve that visual-overflow fringe;
784
+ // the screenshot and emitted <image> use this same rect, so no scaling
785
+ // or fixture geometry is introduced.
786
+ const paintOverflow = 1;
787
+ const rasterExpand = expand + paintOverflow;
788
+ return {
789
+ x: rect.left - vp.x - rasterExpand,
790
+ y: rect.top - vp.y - rasterExpand,
791
+ width: rect.width + rasterExpand * 2,
792
+ height: rect.height + rasterExpand * 2,
793
+ };
794
+ })(),
795
+ // DM-2171: backdrop-filter samples already-painted content behind this
796
+ // element through a distinct Blink effect node. An img-rendered SVG has
797
+ // no equivalent input surface, so preserve Chromium's composited pixels
798
+ // for the complete isolation subtree at its paint-order position.
799
+ backdropFilterRaster: (function () {
800
+ const value = cs.backdropFilter || cs.webkitBackdropFilter || '';
801
+ if (value === '' || value === 'none' || rect.width <= 0 || rect.height <= 0)
802
+ return undefined;
803
+ const token = 'bf' + (_backdropRasterSeq++);
804
+ el.setAttribute('data-domotion-backdrop-raster', token);
805
+ return { x: rect.left - vp.x, y: rect.top - vp.y, width: rect.width, height: rect.height, token };
772
806
  })(),
773
807
  // DM-680: per-axis cumulative ancestor scale, exposed ONLY when
774
808
  // anisotropic (sx ≠ sy within a small epsilon). The geometric mean is
@@ -32,25 +32,7 @@ export declare const createFormControlsHandler: ({ normColor, resolvePseudo }: {
32
32
  } | undefined;
33
33
  selectChevron: boolean;
34
34
  selectDisplayText: any;
35
- selectListboxOptions: ({
36
- text: any;
37
- selected: boolean;
38
- disabled: boolean;
39
- isOptgroupLabel: boolean;
40
- isOptgroupChild?: undefined;
41
- } | {
42
- text: any;
43
- selected: boolean;
44
- disabled: boolean;
45
- isOptgroupChild: boolean;
46
- isOptgroupLabel?: undefined;
47
- } | {
48
- text: any;
49
- selected: boolean;
50
- disabled: boolean;
51
- isOptgroupLabel?: undefined;
52
- isOptgroupChild?: undefined;
53
- })[] | undefined;
35
+ selectListboxOptions: any[] | undefined;
54
36
  accentColor: any;
55
37
  caretColor: any;
56
38
  inputValue: any;
@@ -147,21 +147,52 @@ export const createFormControlsHandler = ({ normColor, resolvePseudo }) => {
147
147
  selectListboxOptions: tag === 'select' && (el.size > 1 || el.multiple)
148
148
  ? (function () {
149
149
  const list = [];
150
+ const hostRect = el.getBoundingClientRect();
151
+ const row = (o, extra) => {
152
+ const ocs = window.getComputedStyle(o);
153
+ const or = o.getBoundingClientRect();
154
+ const canvas = document.createElement('canvas');
155
+ const ctx = canvas.getContext('2d');
156
+ let fontAscent;
157
+ if (ctx != null) {
158
+ ctx.font = `${ocs.fontStyle} ${ocs.fontWeight} ${ocs.fontSize} ${ocs.fontFamily}`;
159
+ fontAscent = ctx.measureText('Mxgp').fontBoundingBoxAscent;
160
+ }
161
+ return {
162
+ text: (o.textContent || '').trim(), selected: !!o.selected,
163
+ disabled: !!o.disabled, ...extra,
164
+ // Unlike closed-select shadow parts, listbox OPTION elements
165
+ // expose real layout rects and computed author paint. Capture
166
+ // those browser-resolved facts instead of rebuilding row
167
+ // height and :checked styling from host-font heuristics.
168
+ x: or.left - hostRect.left, y: or.top - hostRect.top,
169
+ width: or.width, height: or.height,
170
+ backgroundColor: normColor(ocs.backgroundColor),
171
+ color: normColor(ocs.color),
172
+ paddingLeft: parseFloat(ocs.paddingLeft) || 0,
173
+ paddingTop: parseFloat(ocs.paddingTop) || 0,
174
+ fontSize: parseFloat(ocs.fontSize) || undefined,
175
+ fontFamily: ocs.fontFamily || undefined,
176
+ fontWeight: ocs.fontWeight || undefined,
177
+ fontStyle: ocs.fontStyle || undefined,
178
+ fontAscent,
179
+ };
180
+ };
150
181
  const kids = el.children;
151
182
  for (let ki = 0; ki < kids.length; ki++) {
152
183
  const c = kids[ki];
153
184
  if (c.tagName === 'OPTGROUP') {
154
- list.push({ text: c.label || '', selected: false, disabled: !!c.disabled, isOptgroupLabel: true });
185
+ list.push(row(c, { text: c.label || '', selected: false, isOptgroupLabel: true }));
155
186
  const og = c.children;
156
187
  for (let gi = 0; gi < og.length; gi++) {
157
188
  const o = og[gi];
158
189
  if (o.tagName !== 'OPTION')
159
190
  continue;
160
- list.push({ text: (o.textContent || '').trim(), selected: !!o.selected, disabled: !!o.disabled, isOptgroupChild: true });
191
+ list.push(row(o, { isOptgroupChild: true }));
161
192
  }
162
193
  }
163
194
  else if (c.tagName === 'OPTION') {
164
- list.push({ text: (c.textContent || '').trim(), selected: !!c.selected, disabled: !!c.disabled });
195
+ list.push(row(c, {}));
165
196
  }
166
197
  }
167
198
  return list;
@@ -7,6 +7,66 @@
7
7
  // across more than one line-box / column fragment. See the inline comments for
8
8
  // the trigger conditions (DM-754 / DM-937).
9
9
  export const detectInlineFragments = (el, cs, vp, captured) => {
10
+ // CSS Multi-column Layout paints rules in each column row. A
11
+ // column-span:all child ends the current row and starts another, so a
12
+ // rule reconstructed from the container's union box would incorrectly
13
+ // cross the spanner. Capture the physical row intervals here while the
14
+ // DOM's computed margins and border/padding geometry are available.
15
+ var _ownCount = parseInt(cs.columnCount, 10);
16
+ var _ownColumnWidth = parseFloat(cs.columnWidth || '');
17
+ var _ownGap = parseFloat(cs.columnGap || '');
18
+ if (!Number.isFinite(_ownGap))
19
+ _ownGap = parseFloat(cs.fontSize || '16') || 16;
20
+ var _ruleWidth = parseFloat(cs.columnRuleWidth || '0') || 0;
21
+ var _ruleStyle = cs.columnRuleStyle || 'none';
22
+ if (_ruleWidth > 0 && _ruleStyle !== 'none' && _ruleStyle !== 'hidden') {
23
+ var _box = el.getBoundingClientRect();
24
+ var _contentLeft = _box.left + (parseFloat(cs.borderLeftWidth) || 0) + (parseFloat(cs.paddingLeft) || 0);
25
+ var _contentRight = _box.right - (parseFloat(cs.borderRightWidth) || 0) - (parseFloat(cs.paddingRight) || 0);
26
+ var _contentTop = _box.top + (parseFloat(cs.borderTopWidth) || 0) + (parseFloat(cs.paddingTop) || 0);
27
+ var _contentBottom = _box.bottom - (parseFloat(cs.borderBottomWidth) || 0) - (parseFloat(cs.paddingBottom) || 0);
28
+ var _contentWidth = _contentRight - _contentLeft;
29
+ if (!(Number.isFinite(_ownCount) && _ownCount > 1) && Number.isFinite(_ownColumnWidth) && _ownColumnWidth > 0) {
30
+ _ownCount = Math.max(1, Math.floor((_contentWidth + _ownGap) / (_ownColumnWidth + _ownGap)));
31
+ }
32
+ if (Number.isFinite(_ownCount) && _ownCount > 1 && _contentWidth > 0 && _contentBottom > _contentTop) {
33
+ var _rows = [];
34
+ var _rowStart = _contentTop;
35
+ for (var _si = 0; _si < el.children.length; _si++) {
36
+ var _span = el.children[_si];
37
+ var _spanCs = window.getComputedStyle(_span);
38
+ if (_spanCs.columnSpan !== 'all')
39
+ continue;
40
+ var _spanRect = _span.getBoundingClientRect();
41
+ var _spanTop = _spanRect.top - (parseFloat(_spanCs.marginTop) || 0);
42
+ var _spanBottom = _spanRect.bottom + (parseFloat(_spanCs.marginBottom) || 0);
43
+ if (_spanTop > _rowStart)
44
+ _rows.push([_rowStart, Math.min(_spanTop, _contentBottom)]);
45
+ _rowStart = Math.max(_rowStart, _spanBottom);
46
+ }
47
+ if (_contentBottom > _rowStart)
48
+ _rows.push([_rowStart, _contentBottom]);
49
+ var _columnWidth = (_contentWidth - _ownGap * (_ownCount - 1)) / _ownCount;
50
+ var _rules = [];
51
+ for (var _ri = 1; _ri < _ownCount; _ri++) {
52
+ var _ruleX = _contentLeft + _ri * _columnWidth + (_ri - 0.5) * _ownGap - vp.x;
53
+ for (var _rsi = 0; _rsi < _rows.length; _rsi++) {
54
+ if (_rows[_rsi][1] <= _rows[_rsi][0])
55
+ continue;
56
+ _rules.push({
57
+ x: _ruleX,
58
+ y1: _rows[_rsi][0] - vp.y,
59
+ y2: _rows[_rsi][1] - vp.y,
60
+ width: _ruleWidth,
61
+ color: cs.columnRuleColor,
62
+ style: _ruleStyle,
63
+ });
64
+ }
65
+ }
66
+ if (_rules.length > 0)
67
+ captured.columnRules = _rules;
68
+ }
69
+ }
10
70
  var _bgC = captured.styles.backgroundColor;
11
71
  var _hasBg = _bgC != null && _bgC !== '' && _bgC !== 'transparent' && _bgC !== 'rgba(0, 0, 0, 0)';
12
72
  var _hasBgImage = captured.styles.backgroundImage != null
@@ -1,7 +1,8 @@
1
- export declare const createListsCountersHandler: ({ normColor, resolveCounterStyle, isCustomCounterStyle }: {
1
+ export declare const createListsCountersHandler: ({ normColor, resolveCounterStyle, isCustomCounterStyle, measureFontMetrics }: {
2
2
  normColor: any;
3
3
  resolveCounterStyle: any;
4
4
  isCustomCounterStyle: any;
5
+ measureFontMetrics: any;
5
6
  }) => {
6
7
  captureListsCounters: (el: any, cs: any, tag: any) => {
7
8
  listMarkerIntrinsic: {
@@ -12,6 +13,7 @@ export declare const createListsCountersHandler: ({ normColor, resolveCounterSty
12
13
  markerColor: any;
13
14
  markerFontWeight: string | undefined;
14
15
  markerFontSize: string | undefined;
16
+ markerFontAscent: any;
15
17
  markerContent: string | undefined;
16
18
  markerFontFamily: string | undefined;
17
19
  markerFirstLineDy: number | undefined;
@@ -13,7 +13,7 @@
13
13
  // no runtime imports of its own. Use `// @ts-nocheck` at the top because the
14
14
  // outer-page environment exposes `document` / `window` / `Image` without the
15
15
  // project's tsconfig DOM lib applying.
16
- export const createListsCountersHandler = ({ normColor, resolveCounterStyle, isCustomCounterStyle }) => {
16
+ export const createListsCountersHandler = ({ normColor, resolveCounterStyle, isCustomCounterStyle, measureFontMetrics }) => {
17
17
  const captureListsCounters = (el, cs, tag) => {
18
18
  // CSS treats any element with display:list-item as a list item — the tag
19
19
  // alone isn't enough. An <li> with display:inline-block (e.g. a horizontal
@@ -124,6 +124,7 @@ export const createListsCountersHandler = ({ normColor, resolveCounterStyle, isC
124
124
  markerColor: markerCs ? normColor(markerCs.color) : undefined,
125
125
  markerFontWeight: markerCs ? markerCs.fontWeight : undefined,
126
126
  markerFontSize: markerCs ? markerCs.fontSize : undefined,
127
+ markerFontAscent: markerCs ? measureFontMetrics(markerCs).ascent : undefined,
127
128
  markerContent,
128
129
  markerFontFamily: markerCs ? markerCs.fontFamily : undefined,
129
130
  markerFirstLineDy,
@@ -51,6 +51,8 @@ export declare const createPseudoContentHandler: (dependencies: any) => {
51
51
  borB: number;
52
52
  lineH: number;
53
53
  fontSize: number;
54
+ measuredWidth: number;
55
+ measuredHeight: number;
54
56
  backgroundColor: any;
55
57
  backgroundImage: string | undefined;
56
58
  borderRadius: number | undefined;
@@ -73,32 +73,43 @@ const buildPseudoContentHandler = ({ vp, normColor, measureFontMetrics, textNeed
73
73
  _emojiAdvCtx.font = pseudoCanvasFont(pcs);
74
74
  return _emojiAdvCtx.measureText(s).width;
75
75
  };
76
- // DM-785: Chrome's HarfBuzz-shaped layout width differs from
77
- // `canvas.measureText` by ~1-3px on bold uppercase short strings (the
78
- // gradient-pill / MOST POPULAR / NEW badge pattern). Measuring via an
79
- // off-screen <span> with the pseudo's resolved font properties and reading
80
- // `getBoundingClientRect().width` matches the painted width exactly because
81
- // it goes through the same shaping pipeline Chrome uses for layout. Only
82
- // matters for `width: auto` absolute pseudos — the DM-507 numeric `pcs.width`
83
- // path is still authoritative when present.
84
- const probePseudoTextWidth = (text, pcs) => {
76
+ // DM-2191: DOM APIs expose a pseudo's computed style, but not its layout
77
+ // object or rect. Materialize that resolved style on a real child and let
78
+ // Blink lay out the generated content. Copying every computed property (not
79
+ // a hand-picked font/box subset) preserves the resolved cascade, variables,
80
+ // calc(), writing mode, font features, intrinsic sizing, and logical props.
81
+ const probeGeneratedPseudoLayout = (el, pseudo, text, pcs) => {
85
82
  const span = document.createElement('span');
86
- span.style.cssText = 'position:absolute;visibility:hidden;pointer-events:none;left:-99999px;top:-99999px;white-space:pre;line-height:normal;margin:0;padding:0;border:0;text-indent:0';
87
- span.style.fontFamily = pcs.fontFamily || '';
88
- span.style.fontSize = pcs.fontSize || '';
89
- span.style.fontWeight = pcs.fontWeight || '';
90
- span.style.fontStyle = pcs.fontStyle || '';
91
- span.style.fontStretch = pcs.fontStretch || '';
92
- span.style.fontVariant = pcs.fontVariant || '';
93
- span.style.fontFeatureSettings = pcs.fontFeatureSettings || '';
94
- span.style.fontVariationSettings = pcs.fontVariationSettings || '';
95
- span.style.letterSpacing = pcs.letterSpacing || '';
96
- span.style.wordSpacing = pcs.wordSpacing || '';
83
+ for (let i = 0; i < pcs.length; i++) {
84
+ const name = pcs.item(i);
85
+ span.style.setProperty(name, pcs.getPropertyValue(name));
86
+ }
87
+ // Generated `content` has no effect on a real element; materialize it as a
88
+ // text node. Visibility keeps the probe non-painting while retaining layout.
89
+ span.style.setProperty('content', 'normal');
90
+ span.style.setProperty('visibility', 'hidden');
91
+ span.style.setProperty('pointer-events', 'none');
92
+ // Transforms affect only paint and turn getBoundingClientRect into an AABB;
93
+ // renderer applies them later around the untransformed layout box.
94
+ span.style.setProperty('transform', 'none');
95
+ span.style.setProperty('translate', 'none');
96
+ span.style.setProperty('rotate', 'none');
97
+ span.style.setProperty('scale', 'none');
97
98
  span.textContent = text;
98
- document.body.appendChild(span);
99
- const w = span.getBoundingClientRect().width;
100
- document.body.removeChild(span);
101
- return w;
99
+ if (pseudo === '::before')
100
+ el.insertBefore(span, el.firstChild);
101
+ else
102
+ el.appendChild(span);
103
+ const borderRect = span.getBoundingClientRect();
104
+ const range = document.createRange();
105
+ range.selectNodeContents(span);
106
+ const textRect = range.getBoundingClientRect();
107
+ const result = {
108
+ borderRect: { left: borderRect.left, top: borderRect.top, width: borderRect.width, height: borderRect.height },
109
+ textRect: { left: textRect.left, top: textRect.top, width: textRect.width, height: textRect.height },
110
+ };
111
+ span.remove();
112
+ return result;
102
113
  };
103
114
  // DM-768: when a static-flow pseudo declares `display: inline-block` (or
104
115
  // inline-flex / inline-grid / inline-table) the box participates in Chrome's
@@ -618,17 +629,22 @@ const buildPseudoContentHandler = ({ vp, normColor, measureFontMetrics, textNeed
618
629
  }
619
630
  if (text === '')
620
631
  continue;
621
- // DM-785: probe-span measurement matches Chrome's HarfBuzz-shaped
622
- // layout width — canvas.measureText drifted ~1-3px on bold uppercase
623
- // short strings (visible on rotated gradient pills as the text
624
- // overflowing the badge). DM-507 numeric-pcs.width override still
625
- // wins when the pseudo's box has an authored fixed width.
626
- let pseudoWidth = probePseudoTextWidth(text, pcs);
627
- if (pcs.position === 'absolute' || pcs.position === 'fixed') {
628
- const pcsW = parseFloat(pcs.width);
629
- if (!isNaN(pcsW) && pcsW > 0)
630
- pseudoWidth = pcsW;
631
- }
632
+ const pseudoLayout = probeGeneratedPseudoLayout(el, pseudo, text, pcs);
633
+ // TextSegment dimensions are physical viewport dimensions. Range geometry
634
+ // therefore handles vertical text without pretending its inline advance is
635
+ // always an x-axis width. A zero range (e.g. whitespace-only content)
636
+ // falls back to the measured content box.
637
+ const pPadLMeasured = parseFloat(pcs.paddingLeft) || 0;
638
+ const pPadRMeasured = parseFloat(pcs.paddingRight) || 0;
639
+ const pBorLMeasured = parseFloat(pcs.borderLeftWidth) || 0;
640
+ const pBorRMeasured = parseFloat(pcs.borderRightWidth) || 0;
641
+ const measuredContentWidth = Math.max(0, pseudoLayout.borderRect.width
642
+ - pPadLMeasured - pPadRMeasured - pBorLMeasured - pBorRMeasured);
643
+ const hasUsedBoxWidth = pcs.width !== '' && pcs.width !== 'auto'
644
+ && (pcs.position === 'absolute' || pcs.position === 'fixed');
645
+ const pseudoWidth = hasUsedBoxWidth
646
+ ? measuredContentWidth
647
+ : (pseudoLayout.textRect.width > 0 ? pseudoLayout.textRect.width : measuredContentWidth);
632
648
  // Position: ::before sits at the START of the host's text/content.
633
649
  // ::after sits at the END. We fall back to (elLeft, elTop) here
634
650
  // because the host's textLeft/textWidth aren't yet known — the
@@ -818,6 +834,8 @@ const buildPseudoContentHandler = ({ vp, normColor, measureFontMetrics, textNeed
818
834
  // on the line box).
819
835
  lineH,
820
836
  fontSize: elFontSize,
837
+ measuredWidth: pseudoLayout.borderRect.width,
838
+ measuredHeight: pseudoLayout.borderRect.height,
821
839
  backgroundColor: pseudoBgColor !== '' ? pseudoBgColor : undefined,
822
840
  backgroundImage: hasPseudoBgImg ? pseudoBgImgRaw : undefined,
823
841
  borderRadius: pseudoBR > 0 ? pseudoBR : undefined,
@@ -316,8 +316,10 @@ export const createPseudoInjectHandler = () => {
316
316
  const lineCenter = p.seg.y + bs.fontSize / 2;
317
317
  const boxTop = lineCenter - bs.lineH / 2 - bs.padT - bs.borT;
318
318
  const bx = p.seg.x - bs.padL - bs.borL;
319
- const bw = p.seg.width + bs.padL + bs.padR + bs.borL + bs.borR;
320
- const bh = bs.lineH + bs.padT + bs.padB + bs.borT + bs.borB;
319
+ // DM-2191: prefer Blink's measured generated-content border box. The
320
+ // arithmetic fallback exists only for older captured trees.
321
+ const bw = bs.measuredWidth || (p.seg.width + bs.padL + bs.padR + bs.borL + bs.borR);
322
+ const bh = bs.measuredHeight || (bs.lineH + bs.padT + bs.padB + bs.borT + bs.borB);
321
323
  if (bw > 0 && bh > 0) {
322
324
  p.seg.pseudoBox = {
323
325
  x: bx, y: boxTop, width: bw, height: bh,