domotion-svg 0.2.2 → 0.3.2
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/FEATURES.md +1 -0
- package/README.md +29 -0
- package/dist/animation/animator.js +25 -14
- package/dist/animation/animator.test.js +54 -21
- package/dist/animation/cursor-overlay.js +0 -2
- package/dist/capture/emoji.js +29 -18
- package/dist/capture/index.js +5 -4
- package/dist/capture/script/color-norm.d.ts +1 -0
- package/dist/capture/script/color-norm.js +43 -1
- package/dist/capture/script/emoji-detect.js +14 -0
- package/dist/capture/script/index.js +593 -65
- package/dist/capture/script/walker/borders-backgrounds.d.ts +24 -17
- package/dist/capture/script/walker/borders-backgrounds.js +123 -7
- package/dist/capture/script/walker/counter-style-resolver.d.ts +7 -0
- package/dist/capture/script/walker/counter-style-resolver.js +218 -0
- package/dist/capture/script/walker/input-value.js +14 -1
- package/dist/capture/script/walker/lists-counters.d.ts +3 -1
- package/dist/capture/script/walker/lists-counters.js +22 -2
- package/dist/capture/script/walker/masks-clips.d.ts +2 -0
- package/dist/capture/script/walker/masks-clips.js +41 -1
- package/dist/capture/script/walker/pseudo-content.d.ts +14 -1
- package/dist/capture/script/walker/pseudo-content.js +301 -61
- package/dist/capture/script/walker/pseudo-inject.js +20 -0
- package/dist/capture/script/walker/text-segments.js +98 -4
- package/dist/capture/script/walker/transforms.d.ts +1 -0
- package/dist/capture/script/walker/transforms.js +16 -0
- package/dist/capture/script.generated.js +1 -1
- package/dist/capture/types.d.ts +213 -2
- package/dist/cli/animate.js +151 -15
- package/dist/mask.test.js +12 -7
- package/dist/render/borders.d.ts +9 -13
- package/dist/render/borders.js +379 -14
- package/dist/render/element-tree-to-svg.d.ts +11 -12
- package/dist/render/element-tree-to-svg.js +2046 -241
- package/dist/render/embedded-font-builder.d.ts +49 -0
- package/dist/render/embedded-font-builder.js +149 -0
- package/dist/render/form-controls.js +45 -24
- package/dist/render/gradients.d.ts +15 -0
- package/dist/render/gradients.js +103 -2
- package/dist/render/gradients.test.js +34 -0
- package/dist/render/text-to-path.d.ts +38 -1
- package/dist/render/text-to-path.js +654 -29
- package/dist/render/text-to-path.test.js +230 -9
- package/dist/render/text.d.ts +14 -0
- package/dist/render/text.js +344 -40
- package/dist/scroll/composer.d.ts +26 -0
- package/dist/scroll/composer.js +199 -11
- package/dist/scroll/composer.test.js +293 -16
- package/dist/scroll/executor.d.ts +3 -1
- package/dist/scroll/executor.js +15 -6
- package/dist/scroll/executor.test.js +25 -0
- package/dist/scroll/hoist-fixed.d.ts +48 -0
- package/dist/scroll/hoist-fixed.js +85 -0
- package/dist/scroll/hoist-fixed.test.d.ts +1 -0
- package/dist/scroll/hoist-fixed.test.js +103 -0
- package/dist/scroll/hoist-sticky.d.ts +45 -0
- package/dist/scroll/hoist-sticky.js +157 -0
- package/dist/scroll/hoist-sticky.test.d.ts +1 -0
- package/dist/scroll/hoist-sticky.test.js +154 -0
- package/dist/scroll/pattern.d.ts +22 -5
- package/dist/scroll/pattern.js +55 -7
- package/dist/scroll/pattern.test.js +48 -1
- package/dist/tree-ops/frame-merge.d.ts +10 -0
- package/dist/tree-ops/frame-merge.js +23 -5
- package/dist/tree-ops/frame-merge.test.js +45 -0
- package/dist/tree-ops/tree-diff.js +1 -1
- package/dist/tree-ops/viewbox-culling.js +32 -18
- package/dist/tree-ops/viewbox-culling.test.js +40 -6
- package/package.json +8 -2
- package/src/animation/animator.test.ts +56 -21
- package/src/animation/animator.ts +25 -14
- package/src/animation/cursor-overlay.ts +0 -2
- package/src/capture/emoji.ts +28 -18
- package/src/capture/index.ts +15 -14
- package/src/capture/script/color-norm.ts +38 -1
- package/src/capture/script/emoji-detect.ts +14 -0
- package/src/capture/script/index.ts +555 -48
- package/src/capture/script/walker/borders-backgrounds.ts +114 -7
- package/src/capture/script/walker/counter-style-resolver.ts +184 -0
- package/src/capture/script/walker/input-value.ts +14 -1
- package/src/capture/script/walker/lists-counters.ts +24 -2
- package/src/capture/script/walker/masks-clips.ts +40 -1
- package/src/capture/script/walker/pseudo-content.ts +297 -55
- package/src/capture/script/walker/pseudo-inject.ts +20 -0
- package/src/capture/script/walker/text-segments.ts +93 -4
- package/src/capture/script/walker/transforms.ts +14 -0
- package/src/capture/script.generated.ts +1 -1
- package/src/capture/types.ts +202 -2
- package/src/cli/animate.ts +135 -15
- package/src/mask.test.ts +12 -7
- package/src/render/borders.ts +383 -17
- package/src/render/element-tree-to-svg.ts +2051 -238
- package/src/render/embedded-font-builder.ts +221 -0
- package/src/render/form-controls.ts +45 -24
- package/src/render/gradients.test.ts +46 -0
- package/src/render/gradients.ts +94 -2
- package/src/render/opentype.js.d.ts +7 -0
- package/src/render/text-to-path.test.ts +246 -9
- package/src/render/text-to-path.ts +702 -31
- package/src/render/text.ts +344 -40
- package/src/scroll/composer.test.ts +322 -16
- package/src/scroll/composer.ts +246 -13
- package/src/scroll/executor.test.ts +27 -0
- package/src/scroll/executor.ts +19 -10
- package/src/scroll/hoist-fixed.test.ts +117 -0
- package/src/scroll/hoist-fixed.ts +95 -0
- package/src/scroll/hoist-sticky.test.ts +173 -0
- package/src/scroll/hoist-sticky.ts +193 -0
- package/src/scroll/pattern.test.ts +58 -1
- package/src/scroll/pattern.ts +71 -8
- package/src/tree-ops/frame-merge.test.ts +51 -0
- package/src/tree-ops/frame-merge.ts +24 -6
- package/src/tree-ops/tree-diff.ts +3 -1
- package/src/tree-ops/viewbox-culling.test.ts +42 -6
- package/src/tree-ops/viewbox-culling.ts +32 -18
|
@@ -53,7 +53,124 @@
|
|
|
53
53
|
// element's own textSegments. Those depend on text shaping state that
|
|
54
54
|
// hasn't been pulled out of captureInner yet — follow-up.
|
|
55
55
|
|
|
56
|
-
export const createPseudoContentHandler = ({ vp, normColor, measureFontMetrics, textNeedsRaster }) => {
|
|
56
|
+
export const createPseudoContentHandler = ({ vp, normColor, measureFontMetrics, textNeedsRaster, resolveCounterValue, isCustomCounterStyle }) => {
|
|
57
|
+
// DM-785: Chrome's HarfBuzz-shaped layout width differs from
|
|
58
|
+
// `canvas.measureText` by ~1-3px on bold uppercase short strings (the
|
|
59
|
+
// gradient-pill / MOST POPULAR / NEW badge pattern). Measuring via an
|
|
60
|
+
// off-screen <span> with the pseudo's resolved font properties and reading
|
|
61
|
+
// `getBoundingClientRect().width` matches the painted width exactly because
|
|
62
|
+
// it goes through the same shaping pipeline Chrome uses for layout. Only
|
|
63
|
+
// matters for `width: auto` absolute pseudos — the DM-507 numeric `pcs.width`
|
|
64
|
+
// path is still authoritative when present.
|
|
65
|
+
const probePseudoTextWidth = (text, pcs) => {
|
|
66
|
+
const span = document.createElement('span');
|
|
67
|
+
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';
|
|
68
|
+
span.style.fontFamily = pcs.fontFamily || '';
|
|
69
|
+
span.style.fontSize = pcs.fontSize || '';
|
|
70
|
+
span.style.fontWeight = pcs.fontWeight || '';
|
|
71
|
+
span.style.fontStyle = pcs.fontStyle || '';
|
|
72
|
+
span.style.fontStretch = pcs.fontStretch || '';
|
|
73
|
+
span.style.fontVariant = pcs.fontVariant || '';
|
|
74
|
+
span.style.fontFeatureSettings = pcs.fontFeatureSettings || '';
|
|
75
|
+
span.style.fontVariationSettings = pcs.fontVariationSettings || '';
|
|
76
|
+
span.style.letterSpacing = pcs.letterSpacing || '';
|
|
77
|
+
span.style.wordSpacing = pcs.wordSpacing || '';
|
|
78
|
+
span.textContent = text;
|
|
79
|
+
document.body.appendChild(span);
|
|
80
|
+
const w = span.getBoundingClientRect().width;
|
|
81
|
+
document.body.removeChild(span);
|
|
82
|
+
return w;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
// DM-768: when a static-flow pseudo declares `display: inline-block` (or
|
|
86
|
+
// inline-flex / inline-grid / inline-table) the box participates in Chrome's
|
|
87
|
+
// inline vertical-align math — `vertical-align: middle` aligns the pseudo's
|
|
88
|
+
// mid-point with the parent's baseline + 0.5 × x-height, `baseline` aligns
|
|
89
|
+
// the pseudo's bottom to the parent baseline, etc. The earlier formula
|
|
90
|
+
// (`rect.top + hostBorT + hostPadT + pMarT`) ignores that and places the
|
|
91
|
+
// pseudo at the host's content-area top — i.e. the line-box top — so an
|
|
92
|
+
// inline-block down-caret with `border-top: 5px solid` paints 6-7 px too
|
|
93
|
+
// high inside its parent button. Probe instead: insert a real sentinel
|
|
94
|
+
// mirroring the pseudo's box (display / size / borders / padding / margin /
|
|
95
|
+
// vertical-align) at the pseudo's logical position in the host and read its
|
|
96
|
+
// `getBoundingClientRect()`. Chrome lays out the sentinel exactly where the
|
|
97
|
+
// pseudo would have gone, so we get the correct x/y without re-deriving
|
|
98
|
+
// font metrics + vertical-align semantics ourselves.
|
|
99
|
+
const probePseudoStaticBoxRect = (el, pseudo, pcs) => {
|
|
100
|
+
const probe = document.createElement('span');
|
|
101
|
+
probe.style.cssText = 'pointer-events:none;visibility:hidden;box-sizing:content-box';
|
|
102
|
+
probe.style.display = pcs.display;
|
|
103
|
+
probe.style.width = pcs.width;
|
|
104
|
+
probe.style.height = pcs.height;
|
|
105
|
+
probe.style.paddingTop = pcs.paddingTop;
|
|
106
|
+
probe.style.paddingRight = pcs.paddingRight;
|
|
107
|
+
probe.style.paddingBottom = pcs.paddingBottom;
|
|
108
|
+
probe.style.paddingLeft = pcs.paddingLeft;
|
|
109
|
+
probe.style.borderTopWidth = pcs.borderTopWidth;
|
|
110
|
+
probe.style.borderRightWidth = pcs.borderRightWidth;
|
|
111
|
+
probe.style.borderBottomWidth = pcs.borderBottomWidth;
|
|
112
|
+
probe.style.borderLeftWidth = pcs.borderLeftWidth;
|
|
113
|
+
probe.style.borderStyle = 'solid';
|
|
114
|
+
probe.style.borderColor = 'transparent';
|
|
115
|
+
probe.style.marginTop = pcs.marginTop;
|
|
116
|
+
probe.style.marginRight = pcs.marginRight;
|
|
117
|
+
probe.style.marginBottom = pcs.marginBottom;
|
|
118
|
+
probe.style.marginLeft = pcs.marginLeft;
|
|
119
|
+
probe.style.verticalAlign = pcs.verticalAlign;
|
|
120
|
+
probe.style.font = ''; // inherit so line-box metrics match the pseudo's parent
|
|
121
|
+
if (pseudo === '::before') el.insertBefore(probe, el.firstChild);
|
|
122
|
+
else el.appendChild(probe);
|
|
123
|
+
const r = probe.getBoundingClientRect();
|
|
124
|
+
probe.remove();
|
|
125
|
+
return r;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
// For `position: absolute` / `position: fixed` pseudos, the containing block
|
|
129
|
+
// is the nearest positioned ancestor of the host (NOT the host itself when
|
|
130
|
+
// the host is `position: static`). NYT's mobile nav `.css-sdhjrl::after`
|
|
131
|
+
// fade-out is `position: absolute; right: 0; top:0; width: 24px; height: 40px`
|
|
132
|
+
// on a `position: static; display: flex; overflow: scroll` NAV — the pseudo's
|
|
133
|
+
// computed `top` / `left` resolve against a far-up ancestor, so naïvely adding
|
|
134
|
+
// them to the host's padding-box origin places the gradient ~3088px below the
|
|
135
|
+
// NAV (where there's no NAV to fade over). Instead, inject a real absolutely-
|
|
136
|
+
// positioned sentinel as a child of the host: it inherits the same containing
|
|
137
|
+
// block the pseudo would have, and Chrome lays it out at the exact rect the
|
|
138
|
+
// pseudo paints to. Read its `getBoundingClientRect` directly.
|
|
139
|
+
const probePseudoAbsoluteBoxRect = (el, pseudo, pcs) => {
|
|
140
|
+
const probe = document.createElement('div');
|
|
141
|
+
probe.style.cssText = 'pointer-events:none;visibility:hidden;box-sizing:content-box;margin:0';
|
|
142
|
+
probe.style.position = pcs.position;
|
|
143
|
+
probe.style.top = pcs.top;
|
|
144
|
+
probe.style.right = pcs.right;
|
|
145
|
+
probe.style.bottom = pcs.bottom;
|
|
146
|
+
probe.style.left = pcs.left;
|
|
147
|
+
probe.style.width = pcs.width;
|
|
148
|
+
probe.style.height = pcs.height;
|
|
149
|
+
probe.style.paddingTop = pcs.paddingTop;
|
|
150
|
+
probe.style.paddingRight = pcs.paddingRight;
|
|
151
|
+
probe.style.paddingBottom = pcs.paddingBottom;
|
|
152
|
+
probe.style.paddingLeft = pcs.paddingLeft;
|
|
153
|
+
probe.style.borderTopWidth = pcs.borderTopWidth;
|
|
154
|
+
probe.style.borderRightWidth = pcs.borderRightWidth;
|
|
155
|
+
probe.style.borderBottomWidth = pcs.borderBottomWidth;
|
|
156
|
+
probe.style.borderLeftWidth = pcs.borderLeftWidth;
|
|
157
|
+
probe.style.borderStyle = 'solid';
|
|
158
|
+
probe.style.borderColor = 'transparent';
|
|
159
|
+
probe.style.marginTop = pcs.marginTop;
|
|
160
|
+
probe.style.marginRight = pcs.marginRight;
|
|
161
|
+
probe.style.marginBottom = pcs.marginBottom;
|
|
162
|
+
probe.style.marginLeft = pcs.marginLeft;
|
|
163
|
+
probe.style.transform = pcs.transform && pcs.transform !== 'none' ? pcs.transform : '';
|
|
164
|
+
probe.style.transformOrigin = pcs.transformOrigin || '';
|
|
165
|
+
// Pseudo lives logically inside the host; an absolute child of the host
|
|
166
|
+
// inherits the same containing-block lookup.
|
|
167
|
+
if (pseudo === '::before') el.insertBefore(probe, el.firstChild);
|
|
168
|
+
else el.appendChild(probe);
|
|
169
|
+
const r = probe.getBoundingClientRect();
|
|
170
|
+
probe.remove();
|
|
171
|
+
return r;
|
|
172
|
+
};
|
|
173
|
+
|
|
57
174
|
const pickQuoteChar = (forEl, isOpen) => {
|
|
58
175
|
// Count q-element ancestors above this element (depth=0 = the first q
|
|
59
176
|
// not inside another q). The pseudo lives ON forEl so when forEl IS a
|
|
@@ -105,6 +222,16 @@ export const createPseudoContentHandler = ({ vp, normColor, measureFontMetrics,
|
|
|
105
222
|
const pcs = window.getComputedStyle(el, pseudo);
|
|
106
223
|
const content = pcs.content;
|
|
107
224
|
if (content == null || content === 'none' || content === 'normal' || content === '') continue;
|
|
225
|
+
// DM-665 / DM-677: pseudos with computed `opacity: 0` paint nothing in
|
|
226
|
+
// Chrome (Material-style ripple / hover overlays use this — Google's
|
|
227
|
+
// `a.gb_C::before` is the empty-content variant we already skipped;
|
|
228
|
+
// `a.gb_A::before` on the mobile "Sign in" pill is the same idea but
|
|
229
|
+
// with `content: " "` (a single space, non-empty) so it slipped past
|
|
230
|
+
// the previous gate). Skip ALL opacity-zero pseudos before doing any
|
|
231
|
+
// measurement / box-rect work; capturing them anyway would paint an
|
|
232
|
+
// opaque box over the host's actual content.
|
|
233
|
+
const opacityNum = parseFloat(pcs.opacity);
|
|
234
|
+
if (Number.isFinite(opacityNum) && opacityNum === 0) continue;
|
|
108
235
|
|
|
109
236
|
let text = '';
|
|
110
237
|
let imageUrl = '';
|
|
@@ -145,15 +272,26 @@ export const createPseudoContentHandler = ({ vp, normColor, measureFontMetrics,
|
|
|
145
272
|
});
|
|
146
273
|
const cname = args[0];
|
|
147
274
|
const sep = isCounters ? (args[1] ?? '') : '';
|
|
148
|
-
//
|
|
149
|
-
// counter
|
|
150
|
-
//
|
|
275
|
+
// DM-788: third arg of counters() / second arg of counter() is a
|
|
276
|
+
// `<counter-style>` name. When that name matches a custom
|
|
277
|
+
// `@counter-style` rule captured in the pre-walk, run each value
|
|
278
|
+
// through the resolver so prefix / suffix / pad / negative / range
|
|
279
|
+
// / fallback descriptors apply — e.g. `counter(step, prefixed)`
|
|
280
|
+
// produces "Step 01: " instead of plain decimal "1".
|
|
281
|
+
const styleArg = isCounters ? args[2] : args[1];
|
|
282
|
+
const useCustomStyle = styleArg != null && styleArg !== ''
|
|
283
|
+
&& isCustomCounterStyle != null && isCustomCounterStyle(styleArg);
|
|
284
|
+
const format = (v) => {
|
|
285
|
+
if (!useCustomStyle) return String(v);
|
|
286
|
+
const out = resolveCounterValue(styleArg, v);
|
|
287
|
+
return out != null ? out : String(v);
|
|
288
|
+
};
|
|
151
289
|
const snapshot = counterSnapshot.get(el) || [];
|
|
152
|
-
const matches = snapshot.filter((s) => s.name === cname).map((s) =>
|
|
290
|
+
const matches = snapshot.filter((s) => s.name === cname).map((s) => format(s.value));
|
|
153
291
|
if (isCounters) {
|
|
154
|
-
text += matches.length > 0 ? matches.join(sep) :
|
|
292
|
+
text += matches.length > 0 ? matches.join(sep) : format(0);
|
|
155
293
|
} else {
|
|
156
|
-
text += matches.length > 0 ? matches[matches.length - 1] :
|
|
294
|
+
text += matches.length > 0 ? matches[matches.length - 1] : format(0);
|
|
157
295
|
}
|
|
158
296
|
i = closeIdx + 1;
|
|
159
297
|
} else if (content.startsWith('open-quote', i)) {
|
|
@@ -180,13 +318,28 @@ export const createPseudoContentHandler = ({ vp, normColor, measureFontMetrics,
|
|
|
180
318
|
// side or a visible background.
|
|
181
319
|
const bgRaw = pcs.backgroundColor;
|
|
182
320
|
const hasBg = bgRaw && bgRaw !== '' && bgRaw !== 'rgba(0, 0, 0, 0)' && bgRaw !== 'transparent';
|
|
321
|
+
// DM-767: capture background-image (linear-gradient / radial-gradient /
|
|
322
|
+
// url) on empty-content pseudos too. The `.corner::after` accent stripe
|
|
323
|
+
// pattern in `24-deep-pseudo-shapes` is an absolutely-positioned 4 px
|
|
324
|
+
// strip with a `linear-gradient` background and no color / border —
|
|
325
|
+
// without this check the pseudoBox emit was skipped entirely.
|
|
326
|
+
const bgImgRaw = pcs.backgroundImage;
|
|
327
|
+
const hasBgImg = bgImgRaw != null && bgImgRaw !== '' && bgImgRaw !== 'none';
|
|
183
328
|
const bwT = parseFloat(pcs.borderTopWidth) || 0;
|
|
184
329
|
const bwR = parseFloat(pcs.borderRightWidth) || 0;
|
|
185
330
|
const bwB = parseFloat(pcs.borderBottomWidth) || 0;
|
|
186
331
|
const bwL = parseFloat(pcs.borderLeftWidth) || 0;
|
|
187
332
|
const hasBorder = bwT > 0 || bwR > 0 || bwB > 0 || bwL > 0;
|
|
188
333
|
const isBlockLike = pcs.display === 'block' || pcs.display === 'inline-block' || pcs.display === 'flex';
|
|
189
|
-
|
|
334
|
+
// DM-665: `opacity: 0` pseudos paint nothing (Material-style ripple
|
|
335
|
+
// / hover overlays use this pattern — Google's `a.gb_C::before` is
|
|
336
|
+
// a 40×40 dark-grey absolute box that's invisible at rest). Capturing
|
|
337
|
+
// them anyway would paint an opaque box over the host's content
|
|
338
|
+
// (the apps-grid SVG underneath the anchor). Skip empty-content
|
|
339
|
+
// pseudos whose `opacity: 0` makes them visually a no-op.
|
|
340
|
+
const opacityNum = parseFloat(pcs.opacity);
|
|
341
|
+
if (Number.isFinite(opacityNum) && opacityNum === 0) continue;
|
|
342
|
+
if (isBlockLike && (hasBg || hasBgImg || hasBorder)) {
|
|
190
343
|
const hostPadL = parseFloat(cs.paddingLeft) || 0;
|
|
191
344
|
const hostPadT = parseFloat(cs.paddingTop) || 0;
|
|
192
345
|
const hostBorL = parseFloat(cs.borderLeftWidth) || 0;
|
|
@@ -214,37 +367,97 @@ export const createPseudoContentHandler = ({ vp, normColor, measureFontMetrics,
|
|
|
214
367
|
let borderBoxX;
|
|
215
368
|
let borderBoxY;
|
|
216
369
|
if (pcs.position === 'absolute' || pcs.position === 'fixed') {
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
370
|
+
// Use a real positioned sentinel to find the pseudo's true painted
|
|
371
|
+
// rect. Chrome's containing-block lookup walks up from the host
|
|
372
|
+
// looking for a positioned ancestor (or a transformed / filtered
|
|
373
|
+
// / contained ancestor); the same lookup applies to a real child
|
|
374
|
+
// of the host. Probing avoids re-implementing that walk + all the
|
|
375
|
+
// containing-block-establishing properties. NYT mobile's nav fade-
|
|
376
|
+
// out `.css-sdhjrl::after` (`position: absolute; right: 0`) on a
|
|
377
|
+
// `position: static` NAV is the trigger case — the pseudo's
|
|
378
|
+
// resolved `top` / `left` are relative to a far-up positioned
|
|
379
|
+
// ancestor, not the NAV, so the prior additive math placed the
|
|
380
|
+
// gradient thousands of pixels off the NAV.
|
|
381
|
+
const pr = probePseudoAbsoluteBoxRect(el, pseudo, pcs);
|
|
382
|
+
borderBoxX = pr.left - vp.x;
|
|
383
|
+
borderBoxY = pr.top - vp.y;
|
|
231
384
|
} else {
|
|
232
385
|
const pMarT = parseFloat(pcs.marginTop) || 0;
|
|
233
386
|
borderBoxX = rect.left - vp.x + hostBorL + hostPadL + pMarL;
|
|
234
387
|
borderBoxY = rect.top - vp.y + hostBorT + hostPadT + pMarT;
|
|
388
|
+
// DM-768: static `display: inline-block` (and inline-flex / inline-grid /
|
|
389
|
+
// inline-table) pseudos participate in Chrome's inline vertical-align
|
|
390
|
+
// math — the formula above ignores `vertical-align` and pins the box to
|
|
391
|
+
// the host's content-area top, which is 6-7 px too high for a typical
|
|
392
|
+
// `vertical-align: middle` down-caret. Probe with a real sentinel that
|
|
393
|
+
// mirrors the pseudo's box properties.
|
|
394
|
+
//
|
|
395
|
+
// CSS render order on the line is: ::before → real children → ::after.
|
|
396
|
+
// The sentinel is a real child:
|
|
397
|
+
// - For ::before, the sentinel renders AFTER the pseudo. The
|
|
398
|
+
// pseudo's own position is unchanged; the sentinel just shifts
|
|
399
|
+
// subsequent content. So the pseudo's border-box left =
|
|
400
|
+
// probe.left − pMarR − borderBoxW − pMarL (back out the sentinel
|
|
401
|
+
// gap), and the pseudo's top equals probe.top (both lay out
|
|
402
|
+
// on the same line with matching `vertical-align`).
|
|
403
|
+
// - For ::after, the sentinel renders BEFORE the pseudo. Without
|
|
404
|
+
// the sentinel, the pseudo would take the slot the sentinel
|
|
405
|
+
// now occupies, so the pseudo's border-box left = probe.left
|
|
406
|
+
// and top = probe.top.
|
|
407
|
+
const dispIsInline = pcs.display === 'inline-block' || pcs.display === 'inline-flex' || pcs.display === 'inline-grid' || pcs.display === 'inline-table';
|
|
408
|
+
if (dispIsInline) {
|
|
409
|
+
const pr = probePseudoStaticBoxRect(el, pseudo, pcs);
|
|
410
|
+
borderBoxY = pr.top - vp.y;
|
|
411
|
+
if (pseudo === '::after') {
|
|
412
|
+
borderBoxX = pr.left - vp.x;
|
|
413
|
+
} else {
|
|
414
|
+
const pMarR = parseFloat(pcs.marginRight) || 0;
|
|
415
|
+
borderBoxX = pr.left - vp.x - pMarR - borderBoxW - pMarL;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
// DM-710: if the host has a CSS transform whose 2D submatrix is
|
|
420
|
+
// singular (zero determinant), the host's painted area collapses
|
|
421
|
+
// to a point / line and the pseudo paints nothing visible —
|
|
422
|
+
// Apple's `.globalnav-bag-badge` carries `transform: matrix(0, 0,
|
|
423
|
+
// 0, 0, 0, 0)` as the "no items in cart" state, and the empty
|
|
424
|
+
// ::before with `width: 13px; background: black; border-radius:
|
|
425
|
+
// 13px` would otherwise emit as a visible dot. Skip the pseudoBox
|
|
426
|
+
// in that case; the live-rect model already drops the host itself.
|
|
427
|
+
let degenerateHostTransform = false;
|
|
428
|
+
if (cs.transform && cs.transform !== 'none') {
|
|
429
|
+
const m2 = /^matrix\(\s*([-\d.eE]+)\s*,\s*([-\d.eE]+)\s*,\s*([-\d.eE]+)\s*,\s*([-\d.eE]+)/.exec(cs.transform);
|
|
430
|
+
if (m2) {
|
|
431
|
+
const a = parseFloat(m2[1]);
|
|
432
|
+
const b = parseFloat(m2[2]);
|
|
433
|
+
const c = parseFloat(m2[3]);
|
|
434
|
+
const d = parseFloat(m2[4]);
|
|
435
|
+
if (Math.abs(a * d - b * c) < 1e-9) degenerateHostTransform = true;
|
|
436
|
+
}
|
|
235
437
|
}
|
|
236
|
-
if (borderBoxW > 0 && borderBoxH > 0) {
|
|
438
|
+
if (borderBoxW > 0 && borderBoxH > 0 && !degenerateHostTransform) {
|
|
439
|
+
// DM-783: the pseudo's own `transform` (rotate/scale/translate/
|
|
440
|
+
// matrix) wraps the pseudoBox at render time. getComputedStyle
|
|
441
|
+
// returns the resolved matrix() form, and transformOrigin returns
|
|
442
|
+
// resolved px values relative to the pseudo's box top-left — both
|
|
443
|
+
// can be pasted directly into an SVG `<g>` wrapper. Captured only
|
|
444
|
+
// when non-`none` to keep the captured tree compact.
|
|
445
|
+
const pcsTransform = pcs.transform && pcs.transform !== 'none' ? pcs.transform : undefined;
|
|
446
|
+
const pcsTransformOrigin = pcsTransform != null ? (pcs.transformOrigin || undefined) : undefined;
|
|
237
447
|
pseudoBoxes.push({
|
|
238
448
|
x: borderBoxX,
|
|
239
449
|
y: borderBoxY,
|
|
240
450
|
width: borderBoxW,
|
|
241
451
|
height: borderBoxH,
|
|
242
452
|
backgroundColor: hasBg ? normColor(bgRaw) : undefined,
|
|
453
|
+
backgroundImage: hasBgImg ? bgImgRaw : undefined,
|
|
243
454
|
borderTopWidth: bwT, borderTopColor: bwT > 0 ? normColor(pcs.borderTopColor) : undefined, borderTopStyle: pcs.borderTopStyle,
|
|
244
455
|
borderRightWidth: bwR, borderRightColor: bwR > 0 ? normColor(pcs.borderRightColor) : undefined, borderRightStyle: pcs.borderRightStyle,
|
|
245
456
|
borderBottomWidth: bwB, borderBottomColor: bwB > 0 ? normColor(pcs.borderBottomColor) : undefined, borderBottomStyle: pcs.borderBottomStyle,
|
|
246
457
|
borderLeftWidth: bwL, borderLeftColor: bwL > 0 ? normColor(pcs.borderLeftColor) : undefined, borderLeftStyle: pcs.borderLeftStyle,
|
|
247
458
|
borderRadius: parseFloat(pcs.borderRadius) || 0,
|
|
459
|
+
transform: pcsTransform,
|
|
460
|
+
transformOrigin: pcsTransformOrigin,
|
|
248
461
|
});
|
|
249
462
|
}
|
|
250
463
|
}
|
|
@@ -303,22 +516,12 @@ export const createPseudoContentHandler = ({ vp, normColor, measureFontMetrics,
|
|
|
303
516
|
}
|
|
304
517
|
if (text === '') continue;
|
|
305
518
|
|
|
306
|
-
//
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
// canvas.measureText when available. For position:absolute pseudos
|
|
313
|
-
// with auto-width Chrome shrink-to-fits the box and
|
|
314
|
-
// getComputedStyle returns the resolved content-box width.
|
|
315
|
-
// canvas.measureText drifts ~1-2px from Chrome's actual layout in
|
|
316
|
-
// common bold / symbol-mix fixtures because the canvas font-
|
|
317
|
-
// shaping path differs slightly from Chrome's HarfBuzz paint
|
|
318
|
-
// pipeline. Falling back to canvas measurement when pcs.width is
|
|
319
|
-
// unavailable (typical for non-positioned inline pseudos) keeps
|
|
320
|
-
// the existing path.
|
|
321
|
-
let pseudoWidth = mctx.measureText(text).width;
|
|
519
|
+
// DM-785: probe-span measurement matches Chrome's HarfBuzz-shaped
|
|
520
|
+
// layout width — canvas.measureText drifted ~1-3px on bold uppercase
|
|
521
|
+
// short strings (visible on rotated gradient pills as the text
|
|
522
|
+
// overflowing the badge). DM-507 numeric-pcs.width override still
|
|
523
|
+
// wins when the pseudo's box has an authored fixed width.
|
|
524
|
+
let pseudoWidth = probePseudoTextWidth(text, pcs);
|
|
322
525
|
if (pcs.position === 'absolute' || pcs.position === 'fixed') {
|
|
323
526
|
const pcsW = parseFloat(pcs.width);
|
|
324
527
|
if (!isNaN(pcsW) && pcsW > 0) pseudoWidth = pcsW;
|
|
@@ -414,12 +617,15 @@ export const createPseudoContentHandler = ({ vp, normColor, measureFontMetrics,
|
|
|
414
617
|
width: pseudoWidth,
|
|
415
618
|
height: elFontSize,
|
|
416
619
|
// Carry pseudo-specific typography so the renderer can respect
|
|
417
|
-
// per-pseudo color, font-size, font-weight, font-family
|
|
418
|
-
// lets pseudos style independently of their parent
|
|
620
|
+
// per-pseudo color, font-size, font-weight, font-family, font-style
|
|
621
|
+
// (CSS lets pseudos style independently of their parent — Slashdot's
|
|
622
|
+
// "Most Discussed" carousel heading is a ::after that's italic+bordered
|
|
623
|
+
// on a non-italic host div).
|
|
419
624
|
color: pcs.color,
|
|
420
625
|
fontSize: elFontSize,
|
|
421
626
|
fontWeight: pcs.fontWeight,
|
|
422
627
|
fontFamily: pcs.fontFamily,
|
|
628
|
+
fontStyle: pcs.fontStyle,
|
|
423
629
|
fontAscent: pseudoMetrics.ascent,
|
|
424
630
|
};
|
|
425
631
|
// DM-497: stash pseudo's own background / border-radius on the
|
|
@@ -431,26 +637,51 @@ export const createPseudoContentHandler = ({ vp, normColor, measureFontMetrics,
|
|
|
431
637
|
const pseudoBgColor = pseudoBgRaw && pseudoBgRaw !== '' && pseudoBgRaw !== 'rgba(0, 0, 0, 0)' && pseudoBgRaw !== 'transparent'
|
|
432
638
|
? normColor(pseudoBgRaw) : '';
|
|
433
639
|
const pseudoBR = parseFloat(pcs.borderRadius) || 0;
|
|
434
|
-
// Capture a uniform border when all four sides match
|
|
435
|
-
//
|
|
436
|
-
//
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
640
|
+
// Capture a uniform border when all four sides match (renders as
|
|
641
|
+
// `<rect stroke=…>`). When a single side carries a border (e.g.
|
|
642
|
+
// `border-bottom: 1px solid rgba(255,255,255,0.5)` on Slashdot's
|
|
643
|
+
// `.carouselHeading::after`) we still capture per-side widths +
|
|
644
|
+
// colors so the renderer can emit a `<line>` for the visible side.
|
|
645
|
+
const bwTop = parseFloat(pcs.borderTopWidth) || 0;
|
|
646
|
+
const bwRight = parseFloat(pcs.borderRightWidth) || 0;
|
|
647
|
+
const bwBottom = parseFloat(pcs.borderBottomWidth) || 0;
|
|
648
|
+
const bwLeft = parseFloat(pcs.borderLeftWidth) || 0;
|
|
649
|
+
const bwUniform = bwTop > 0 && bwRight === bwTop && bwBottom === bwTop && bwLeft === bwTop;
|
|
442
650
|
const pseudoBC = bwUniform ? normColor(pcs.borderTopColor) : '';
|
|
651
|
+
const colorIsPaintable = (raw: string): boolean => raw !== '' && raw !== 'rgba(0, 0, 0, 0)' && raw !== 'transparent';
|
|
652
|
+
const sideBorderTopColor = bwTop > 0 ? normColor(pcs.borderTopColor) : '';
|
|
653
|
+
const sideBorderRightColor = bwRight > 0 ? normColor(pcs.borderRightColor) : '';
|
|
654
|
+
const sideBorderBottomColor = bwBottom > 0 ? normColor(pcs.borderBottomColor) : '';
|
|
655
|
+
const sideBorderLeftColor = bwLeft > 0 ? normColor(pcs.borderLeftColor) : '';
|
|
656
|
+
const hasPerSideBorder = !bwUniform && (
|
|
657
|
+
(bwTop > 0 && colorIsPaintable(sideBorderTopColor))
|
|
658
|
+
|| (bwRight > 0 && colorIsPaintable(sideBorderRightColor))
|
|
659
|
+
|| (bwBottom > 0 && colorIsPaintable(sideBorderBottomColor))
|
|
660
|
+
|| (bwLeft > 0 && colorIsPaintable(sideBorderLeftColor))
|
|
661
|
+
);
|
|
662
|
+
// DM-782: background-image (linear-gradient / radial-gradient / url())
|
|
663
|
+
// on text-content pseudos. The empty-content path already plumbs this
|
|
664
|
+
// (DM-767); the text-content path was dropping it, so "gradient badge"
|
|
665
|
+
// patterns (`.tier.popular::before { content: "MOST POPULAR"; background:
|
|
666
|
+
// linear-gradient(135deg, ...) }`) lost the pill bg behind the white
|
|
667
|
+
// glyphs.
|
|
668
|
+
const pseudoBgImgRaw = pcs.backgroundImage;
|
|
669
|
+
const hasPseudoBgImg = pseudoBgImgRaw != null && pseudoBgImgRaw !== '' && pseudoBgImgRaw !== 'none';
|
|
670
|
+
// DM-783: pseudo's own `transform` (rotate/scale/translate/matrix)
|
|
671
|
+
// wraps both the paint box AND the glyph emit at render time.
|
|
672
|
+
const pseudoTransform = pcs.transform && pcs.transform !== 'none' ? pcs.transform : undefined;
|
|
673
|
+
const pseudoTransformOrigin = pseudoTransform != null ? (pcs.transformOrigin || undefined) : undefined;
|
|
443
674
|
let pseudoBoxStyles = null;
|
|
444
|
-
if (pseudoBgColor !== '' || pseudoBR > 0 || (bwUniform && pseudoBC !== '' && pseudoBC !== 'rgba(0, 0, 0, 0)')) {
|
|
675
|
+
if (pseudoBgColor !== '' || hasPseudoBgImg || pseudoBR > 0 || (bwUniform && pseudoBC !== '' && pseudoBC !== 'rgba(0, 0, 0, 0)') || hasPerSideBorder || pseudoTransform != null) {
|
|
445
676
|
pseudoBoxStyles = {
|
|
446
677
|
padL: parseFloat(pcs.paddingLeft) || 0,
|
|
447
678
|
padR: parseFloat(pcs.paddingRight) || 0,
|
|
448
679
|
padT: parseFloat(pcs.paddingTop) || 0,
|
|
449
680
|
padB: parseFloat(pcs.paddingBottom) || 0,
|
|
450
|
-
borL:
|
|
451
|
-
borR:
|
|
452
|
-
borT:
|
|
453
|
-
borB:
|
|
681
|
+
borL: bwLeft,
|
|
682
|
+
borR: bwRight,
|
|
683
|
+
borT: bwTop,
|
|
684
|
+
borB: bwBottom,
|
|
454
685
|
// Inline-box bg paints at line-height, not at font-size — so
|
|
455
686
|
// the box's vertical extent is lineH + padding + border (not
|
|
456
687
|
// fontSize). Capture lineH alongside the metrics; the post-
|
|
@@ -459,9 +690,20 @@ export const createPseudoContentHandler = ({ vp, normColor, measureFontMetrics,
|
|
|
459
690
|
lineH,
|
|
460
691
|
fontSize: elFontSize,
|
|
461
692
|
backgroundColor: pseudoBgColor !== '' ? pseudoBgColor : undefined,
|
|
693
|
+
backgroundImage: hasPseudoBgImg ? pseudoBgImgRaw : undefined,
|
|
462
694
|
borderRadius: pseudoBR > 0 ? pseudoBR : undefined,
|
|
463
|
-
borderWidth: bwUniform ?
|
|
695
|
+
borderWidth: bwUniform ? bwTop : undefined,
|
|
464
696
|
borderColor: bwUniform && pseudoBC !== '' && pseudoBC !== 'rgba(0, 0, 0, 0)' ? pseudoBC : undefined,
|
|
697
|
+
transform: pseudoTransform,
|
|
698
|
+
transformOrigin: pseudoTransformOrigin,
|
|
699
|
+
// Per-side colors. Renderer reads these when no uniform border
|
|
700
|
+
// is set and emits a `<line>` for each side whose width > 0 and
|
|
701
|
+
// color is paintable. Undefined when the side has no visible
|
|
702
|
+
// border, keeping the captured tree compact in the common case.
|
|
703
|
+
borderTopColor: hasPerSideBorder && bwTop > 0 && colorIsPaintable(sideBorderTopColor) ? sideBorderTopColor : undefined,
|
|
704
|
+
borderRightColor: hasPerSideBorder && bwRight > 0 && colorIsPaintable(sideBorderRightColor) ? sideBorderRightColor : undefined,
|
|
705
|
+
borderBottomColor: hasPerSideBorder && bwBottom > 0 && colorIsPaintable(sideBorderBottomColor) ? sideBorderBottomColor : undefined,
|
|
706
|
+
borderLeftColor: hasPerSideBorder && bwLeft > 0 && colorIsPaintable(sideBorderLeftColor) ? sideBorderLeftColor : undefined,
|
|
465
707
|
};
|
|
466
708
|
}
|
|
467
709
|
// If the pseudo contains any codepoint Chrome paints via a color-
|
|
@@ -167,9 +167,29 @@ export const createPseudoInjectHandler = () => {
|
|
|
167
167
|
p.seg.pseudoBox = {
|
|
168
168
|
x: bx, y: boxTop, width: bw, height: bh,
|
|
169
169
|
backgroundColor: bs.backgroundColor,
|
|
170
|
+
// DM-782: gradient/url() bg-image plumbing — renderer threads
|
|
171
|
+
// each comma-separated layer through `buildBackgroundLayerDef`
|
|
172
|
+
// and paints rect(s) behind the glyphs (mirrors the empty-
|
|
173
|
+
// content pseudoBox path in `element-tree-to-svg.ts`).
|
|
174
|
+
backgroundImage: bs.backgroundImage,
|
|
170
175
|
borderRadius: bs.borderRadius,
|
|
171
176
|
borderWidth: bs.borderWidth,
|
|
172
177
|
borderColor: bs.borderColor,
|
|
178
|
+
// Per-side widths + colors for non-uniform borders (e.g. a
|
|
179
|
+
// bare `border-bottom` on a pseudo). Width fields are always
|
|
180
|
+
// emitted so the renderer doesn't have to fall back to zero
|
|
181
|
+
// when a `borderWidth` (uniform) shorthand is absent.
|
|
182
|
+
borL: bs.borL, borR: bs.borR, borT: bs.borT, borB: bs.borB,
|
|
183
|
+
borderTopColor: bs.borderTopColor,
|
|
184
|
+
borderRightColor: bs.borderRightColor,
|
|
185
|
+
borderBottomColor: bs.borderBottomColor,
|
|
186
|
+
borderLeftColor: bs.borderLeftColor,
|
|
187
|
+
// DM-783: pseudo's `transform` + `transformOrigin`. Renderer
|
|
188
|
+
// wraps the box + glyphs in a pre-baked
|
|
189
|
+
// translate-(transform)-translate matrix so the rotation/scale
|
|
190
|
+
// pivots around the box-relative origin instead of (0,0).
|
|
191
|
+
transform: bs.transform,
|
|
192
|
+
transformOrigin: bs.transformOrigin,
|
|
173
193
|
};
|
|
174
194
|
}
|
|
175
195
|
}
|
|
@@ -144,6 +144,53 @@ export const createTextSegmentsHandler = ({ vp, measureFontMetrics, needsRaster
|
|
|
144
144
|
const firstLetterStyled = flFsRaw > 0 && Math.abs(flFsRaw - elFsRaw) > 0.5;
|
|
145
145
|
let firstCharSeen = false;
|
|
146
146
|
|
|
147
|
+
// DM-747 / DM-791: MathML `<mi>` with a single token character is
|
|
148
|
+
// automatically painted with the Mathematical Italic alphabet (the
|
|
149
|
+
// mathvariant=italic mapping from MathML 4 / Core). Chrome paints
|
|
150
|
+
// `<mi>a</mi>` using U+1D44E (𝑎), `<mi>α</mi>` using U+1D6FC (𝛼), etc.
|
|
151
|
+
// The computed `font-style` stays `normal` and `font-family` stays
|
|
152
|
+
// `math`, so we can't detect this through CSS — we apply the mapping
|
|
153
|
+
// ourselves at capture time so the downstream text-shaping pipeline
|
|
154
|
+
// picks up the right glyphs from whatever math font the system has.
|
|
155
|
+
const elTag = el.tagName != null ? el.tagName.toLowerCase() : '';
|
|
156
|
+
const _miText = (el.textContent || '').trim();
|
|
157
|
+
const mathItalicizeMi = elTag === 'mi'
|
|
158
|
+
&& [..._miText].length === 1
|
|
159
|
+
&& /^[a-zA-ZΑ-ΩΆΈΉΊΌΎΏα-ωϐϑϕϖϗϰϱϵ∂∇]$/u.test(_miText);
|
|
160
|
+
const mathItalicChar = (ch) => {
|
|
161
|
+
const code = ch.codePointAt(0);
|
|
162
|
+
if (code == null) return ch;
|
|
163
|
+
// Latin Mathematical Italic Capital A..Z = U+1D434..U+1D44D.
|
|
164
|
+
if (code >= 0x41 && code <= 0x5A) return String.fromCodePoint(0x1D434 + (code - 0x41));
|
|
165
|
+
// Latin Mathematical Italic Small a..z = U+1D44E..U+1D467, except
|
|
166
|
+
// U+1D455 ("h") is reserved — Chrome paints U+210E (ℎ, PLANCK CONSTANT).
|
|
167
|
+
if (code >= 0x61 && code <= 0x7A) {
|
|
168
|
+
if (code === 0x68) return 'ℎ';
|
|
169
|
+
return String.fromCodePoint(0x1D44E + (code - 0x61));
|
|
170
|
+
}
|
|
171
|
+
// Greek Mathematical Italic Capital Α..Ω = U+1D6E2..U+1D6FA.
|
|
172
|
+
// The block is dense: 25 codepoints for Α (U+0391) through Ω (U+03A9),
|
|
173
|
+
// skipping nothing in the source range.
|
|
174
|
+
if (code >= 0x0391 && code <= 0x03A9) return String.fromCodePoint(0x1D6E2 + (code - 0x0391));
|
|
175
|
+
// Greek Mathematical Italic Small α..ω = U+1D6FC..U+1D71B.
|
|
176
|
+
// U+03C2 (final sigma ς) maps to the regular U+1D70D position; the
|
|
177
|
+
// block is contiguous.
|
|
178
|
+
if (code >= 0x03B1 && code <= 0x03C9) return String.fromCodePoint(0x1D6FC + (code - 0x03B1));
|
|
179
|
+
// Greek symbol variants — these alternate forms get their own italic
|
|
180
|
+
// codepoints at the tail of the lowercase Greek block.
|
|
181
|
+
switch (code) {
|
|
182
|
+
case 0x2202: return String.fromCodePoint(0x1D715); // ∂ → italic partial differential
|
|
183
|
+
case 0x03F5: return String.fromCodePoint(0x1D716); // ϵ (lunate) → italic epsilon symbol
|
|
184
|
+
case 0x03D1: return String.fromCodePoint(0x1D717); // ϑ (theta sym) → italic theta symbol
|
|
185
|
+
case 0x03F0: return String.fromCodePoint(0x1D718); // ϰ (kappa sym) → italic kappa symbol
|
|
186
|
+
case 0x03D5: return String.fromCodePoint(0x1D719); // ϕ (phi sym) → italic phi symbol
|
|
187
|
+
case 0x03F1: return String.fromCodePoint(0x1D71A); // ϱ (rho sym) → italic rho symbol
|
|
188
|
+
case 0x03D6: return String.fromCodePoint(0x1D71B); // ϖ (pi sym) → italic pi symbol
|
|
189
|
+
case 0x2207: return String.fromCodePoint(0x1D6FB); // ∇ (nabla) → italic nabla (upper-block tail)
|
|
190
|
+
default: return ch;
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
|
|
147
194
|
for (const node of el.childNodes) {
|
|
148
195
|
if (node.nodeType !== Node.TEXT_NODE) continue;
|
|
149
196
|
// text-transform — see header comment.
|
|
@@ -153,7 +200,13 @@ export const createTextSegmentsHandler = ({ vp, measureFontMetrics, needsRaster
|
|
|
153
200
|
else if (tt === 'lowercase') raw = raw.toLowerCase();
|
|
154
201
|
else if (tt === 'capitalize') raw = raw.replace(/\b\p{L}/gu, (ch) => ch.toUpperCase());
|
|
155
202
|
if (!raw.trim()) continue;
|
|
156
|
-
|
|
203
|
+
// DM-747: when `<mi>` math-italic substitution applies, the element's
|
|
204
|
+
// aggregate `text` field should carry the substituted codepoint too —
|
|
205
|
+
// it's used for aria-label / accessibility and matches the painted
|
|
206
|
+
// glyph. The per-character ranges still measure against the original
|
|
207
|
+
// textContent (see below).
|
|
208
|
+
const rawForText = mathItalicizeMi ? mathItalicChar(raw.trim()) : raw.trim();
|
|
209
|
+
text += rawForText + ' ';
|
|
157
210
|
|
|
158
211
|
// Group characters by their laid-out line (matching rect.top).
|
|
159
212
|
const lines = [];
|
|
@@ -168,7 +221,14 @@ export const createTextSegmentsHandler = ({ vp, measureFontMetrics, needsRaster
|
|
|
168
221
|
const cr = r.getBoundingClientRect();
|
|
169
222
|
const isWs = step === 1 && /\s/.test(raw[i]);
|
|
170
223
|
if (cr.width === 0 && (cr.height === 0 || isWs)) { i += step - 1; continue; }
|
|
171
|
-
|
|
224
|
+
let ch = raw.slice(i, i + step);
|
|
225
|
+
// DM-747: see `mathItalicizeMi` block above — apply the mathvariant=
|
|
226
|
+
// italic substitution AFTER the Range-based measurement so the Range
|
|
227
|
+
// offsets stay valid against the original textContent (`a`, 1 code
|
|
228
|
+
// unit) while the captured glyph string carries the surrogate-pair
|
|
229
|
+
// math-italic codepoint (`𝑎`, 2 code units) that the downstream
|
|
230
|
+
// shaping pipeline picks up.
|
|
231
|
+
if (mathItalicizeMi) ch = mathItalicChar(ch);
|
|
172
232
|
const charRec = { ch, left: cr.left, top: cr.top, right: cr.right, bottom: cr.bottom };
|
|
173
233
|
if (cur == null || Math.abs(cr.top - cur.top) > 1) {
|
|
174
234
|
if (cur != null) lines.push(cur);
|
|
@@ -239,13 +299,42 @@ export const createTextSegmentsHandler = ({ vp, measureFontMetrics, needsRaster
|
|
|
239
299
|
const isFirstLetter = firstLetterStyled && !firstCharSeen && /\S/.test(cRec.ch);
|
|
240
300
|
if (isFirstLetter) firstCharSeen = true;
|
|
241
301
|
if ((cp != null && needsRaster(cp, nextCp)) || isFirstLetter) {
|
|
302
|
+
// DM-823: for floated ::first-letter drop caps (initial-letter:
|
|
303
|
+
// N M), `Range.getBoundingClientRect()` returns the line-box of
|
|
304
|
+
// the first character in the NORMAL flow — but Chrome paints the
|
|
305
|
+
// float at a much larger box (~N × parent-line-height tall). The
|
|
306
|
+
// bitmap captured at the Range rect gets vertically truncated,
|
|
307
|
+
// visible as a clipped W / B / T drop cap in the rendered SVG.
|
|
308
|
+
// Expand the rasterRect downward by the difference between
|
|
309
|
+
// (N × parent-line-height) and the natural-flow Range height so
|
|
310
|
+
// the screenshot includes the full painted glyph. Width stays
|
|
311
|
+
// unchanged — Chrome's drop cap matches the natural-flow
|
|
312
|
+
// first-char width.
|
|
313
|
+
let rasterTop = cRec.top - vp.y;
|
|
314
|
+
let rasterHeight = cRec.bottom - cRec.top;
|
|
315
|
+
if (isFirstLetter) {
|
|
316
|
+
const ilRaw = flStyle.initialLetter || flStyle.webkitInitialLetter || '';
|
|
317
|
+
const ilN = parseFloat(ilRaw);
|
|
318
|
+
const parentLineHeight = parseFloat(cs.lineHeight);
|
|
319
|
+
if (Number.isFinite(ilN) && ilN > 1 && Number.isFinite(parentLineHeight) && parentLineHeight > 0) {
|
|
320
|
+
const expectedHeight = ilN * parentLineHeight;
|
|
321
|
+
if (expectedHeight > rasterHeight) {
|
|
322
|
+
// Extend downward only — Chrome aligns the cap-top at the
|
|
323
|
+
// first line's cap-height position; extra space the
|
|
324
|
+
// expansion captures past the actual ink is empty (will
|
|
325
|
+
// raster as transparent, with `omitBackground: true`) and
|
|
326
|
+
// doesn't paint anything visible.
|
|
327
|
+
rasterHeight = expectedHeight;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
242
331
|
rasterGlyphs.push({
|
|
243
332
|
charIndex: utf16Idx,
|
|
244
333
|
rect: {
|
|
245
334
|
x: cRec.left - vp.x,
|
|
246
|
-
y:
|
|
335
|
+
y: rasterTop,
|
|
247
336
|
width: cRec.right - cRec.left,
|
|
248
|
-
height:
|
|
337
|
+
height: rasterHeight,
|
|
249
338
|
},
|
|
250
339
|
// ::first-letter drop caps: suppress the path glyph so
|
|
251
340
|
// only the rasterized big letter paints (DM-439).
|
|
@@ -131,6 +131,20 @@ export const createTransformsHandler = () => {
|
|
|
131
131
|
? frozenTransform
|
|
132
132
|
: 'none',
|
|
133
133
|
transformOrigin: cs.transformOrigin,
|
|
134
|
+
// DM-751: extract `matrix3d` translateZ so the paint-order sort can
|
|
135
|
+
// honor 3D Z position when the parent element has
|
|
136
|
+
// `transform-style: preserve-3d` (which sorts children by Z in 3D
|
|
137
|
+
// space, not by z-index per CSS Transforms 2 §6). We can't represent
|
|
138
|
+
// perspective / actual 3D rendering in SVG; this is paint-order only.
|
|
139
|
+
translateZ: (function () {
|
|
140
|
+
const tt = cs.transform;
|
|
141
|
+
if (tt == null || tt === 'none' || tt === '') return undefined;
|
|
142
|
+
const m3 = /^matrix3d\(([^)]+)\)/.exec(tt);
|
|
143
|
+
if (m3 == null) return undefined;
|
|
144
|
+
const parts = m3[1].split(',').map((s) => parseFloat(s.trim()));
|
|
145
|
+
const tz = parts[14];
|
|
146
|
+
return Number.isFinite(tz) && tz !== 0 ? tz : undefined;
|
|
147
|
+
})(),
|
|
134
148
|
// DM-587: separately flag elements that ORIGINALLY had a non-none
|
|
135
149
|
// transform — even though we discard the value to suppress the wrap,
|
|
136
150
|
// CSS Transforms 2 §4 says any non-none transform creates a stacking
|