domotion-svg 0.6.0 → 0.7.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.
@@ -115,9 +115,19 @@ export async function rasterizeBitmapGlyphs(page, tree, viewport) {
115
115
  // identical textareas dedupe to one screenshot.
116
116
  if (el.elementRaster != null) {
117
117
  const er = el.elementRaster;
118
+ // DM-936: include text-decoration + text-underline-position in the
119
+ // dedupe key so 3 identical-text `.vert.pos-{left,right,auto}`
120
+ // columns don't collapse to the same screenshot (the underline
121
+ // paints in different places per pos-* but tag+text+color+size
122
+ // alone hashes them all together → wrong-side underline in 2/3
123
+ // of the columns). Same for text-shadow / writing-mode variants.
124
+ // Several of the keys aren't on the strict CapturedStyles surface;
125
+ // cast through Record so the optional reads compile.
126
+ const sty = el.styles;
127
+ const tdKey = `${sty.textDecorationLine ?? sty.textDecoration ?? ""}|${sty.textUnderlinePosition ?? ""}|${sty.textUnderlineOffset ?? ""}|${sty.textDecorationStyle ?? ""}|${sty.textDecorationColor ?? ""}|${sty.textDecorationThickness ?? ""}|${sty.textShadow ?? ""}|${sty.writingMode ?? ""}`;
118
128
  candidates.push({
119
129
  rect: { x: er.x, y: er.y, width: er.width, height: er.height },
120
- key: `el|${el.tag}|${el.text}|${el.styles.color}|${el.styles.fontSize}|${er.width}x${er.height}`,
130
+ key: `el|${el.tag}|${el.text}|${el.styles.color}|${el.styles.fontSize}|${er.width}x${er.height}|${tdKey}`,
121
131
  setDataUri: (uri) => { er.dataUri = uri; },
122
132
  });
123
133
  }
@@ -163,7 +173,23 @@ export async function rasterizeBitmapGlyphs(page, tree, viewport) {
163
173
  // a rectangular PNG).
164
174
  const elFs = parseFloat(el.styles.fontSize ?? "") || 0;
165
175
  const fs = seg.fontSize ?? (elFs > 0 ? elFs : Math.max(g.rect.width, g.rect.height));
166
- g.rect.x += (g.rect.width - fs) / 2;
176
+ // DM-919: Chrome's per-emoji paint origin = advance start
177
+ // (rect.x) when there's NO letter-spacing — the bitmap
178
+ // sits flush at the left of the advance. When letter-
179
+ // spacing > 0, Chrome ADDS the letter-spacing to the
180
+ // advance, which captures as a wider rect — the bitmap
181
+ // is still at rect.x (the spacing pads to the right).
182
+ // The original DM-381 centering pass mis-handled this:
183
+ // it shifted the bitmap by `(rect.width - fs) / 2`,
184
+ // moving the emoji right whenever letter-spacing >0.
185
+ // Pull out the captured letter-spacing and subtract it
186
+ // from rect.width FIRST, then center the bitmap inside
187
+ // the REAL advance — handles both the centered emoji-
188
+ // alone case (where rect.w ≈ fs) and the letter-spaced
189
+ // case (where rect.w = fs + letter-spacing).
190
+ const ls = parseFloat(el.styles.letterSpacing ?? "") || 0;
191
+ const advanceW = Math.max(fs, g.rect.width - Math.max(0, ls));
192
+ g.rect.x += (advanceW - fs) / 2;
167
193
  g.rect.y += (g.rect.height - fs) / 2;
168
194
  g.rect.width = fs;
169
195
  g.rect.height = fs;
@@ -54,7 +54,7 @@ export const captureScript = (args) => {
54
54
  const { resolveCounterStyle, resolveCounterValue, isCustomCounterStyle } = createCounterStyleResolver({ counterStyles: _counterStyles });
55
55
  const { captureListsCounters } = createListsCountersHandler({ normColor, resolveCounterStyle, isCustomCounterStyle });
56
56
  const { handleReplacedElement } = createReplacedElementsHandler({ vp });
57
- const { discoverMasks, discoverClipPaths, maskDefs: _maskDefs, maskRasters: _maskRasters, clipPathDefs: _clipPathDefs } = createMasksClipsHandler({ vp, warn });
57
+ const { discoverMasks, discoverClipPaths, discoverFilters, maskDefs: _maskDefs, maskRasters: _maskRasters, clipPathDefs: _clipPathDefs, filterDefs: _filterDefs } = createMasksClipsHandler({ vp, warn });
58
58
  const { captureFormControls } = createFormControlsHandler({ normColor, resolvePseudo: _resolvePseudo });
59
59
  const { wrapWithFrozenTransform, threadFrozenTransform } = createTransformsHandler();
60
60
  const { captureBordersBackgrounds } = createBordersBackgroundsHandler({
@@ -194,6 +194,11 @@ export const captureScript = (args) => {
194
194
  // the mask discovery above; collects inline <clipPath> defs the
195
195
  // renderer copies into the output SVG. See docs/39.
196
196
  discoverClipPaths(el, cs, sel);
197
+ // DM-934: CSS `filter: url(#id)` referencing an inline SVG <filter>.
198
+ // Collect the def so the renderer can copy it into the output SVG;
199
+ // the existing pass-through of cs.filter as an inline style then
200
+ // resolves against that same-document def.
201
+ discoverFilters(el, cs, sel);
197
202
  if (cs.borderImageSource && cs.borderImageSource !== 'none') {
198
203
  warn(sel, 'border-image', '9-slice composition pending (SK-466); border-image-source ignored');
199
204
  }
@@ -968,7 +973,20 @@ export const captureScript = (args) => {
968
973
  textDecorationStyle: cs.textDecorationStyle,
969
974
  textDecorationThickness: cs.textDecorationThickness,
970
975
  textUnderlineOffset: cs.textUnderlineOffset,
976
+ textUnderlinePosition: cs.textUnderlinePosition,
971
977
  textDecorationSkipInk: cs.textDecorationSkipInk,
978
+ // DM-920: text-emphasis. Captured per CSS Text Decoration 3 §3.5.
979
+ // `text-emphasis-style` resolved string ("filled circle" / "open
980
+ // sesame" / `"★"` etc.) maps to a single mark character in the
981
+ // renderer per Chromium's `ComputedStyle::TextEmphasisMarkString`
982
+ // (third_party/blink/renderer/core/style/computed_style.cc:
983
+ // kBullet U+2022 / kWhiteBullet U+25E6 / kBlackCircle U+25CF /
984
+ // kWhiteCircle U+25CB / kFisheye U+25C9 / kBullseye U+25CE /
985
+ // kBlackUpPointingTriangle U+25B2 / kWhiteUpPointingTriangle
986
+ // U+25B3 / kSesameDot U+FE45 / kWhiteSesameDot U+FE46).
987
+ textEmphasisStyle: cs.textEmphasisStyle,
988
+ textEmphasisColor: cs.textEmphasisColor,
989
+ textEmphasisPosition: cs.textEmphasisPosition,
972
990
  },
973
991
  children, imageSrc, imageIntrinsic, imageBroken, imageAlt, svgContent, pseudoImages,
974
992
  pseudoBoxes: pseudoBoxes.length > 0 ? pseudoBoxes : undefined,
@@ -1067,6 +1085,36 @@ export const captureScript = (args) => {
1067
1085
  _a = _a.parentElement;
1068
1086
  }
1069
1087
  }
1088
+ // DM-937: detect "inline-with-block-descendant" — an inline element
1089
+ // (e.g. a `<label>`) that wraps a block-level child (`display:block`
1090
+ // /list-item/flex/grid). Per CSS 2.1 §9.2.1.1 Chrome inserts
1091
+ // "anonymous block boxes" around the block-level descendants and
1092
+ // fragments the inline accordingly, but the painted border on each
1093
+ // resulting fragment looks like a CLONE box (all four corners
1094
+ // rounded, all four sides drawn) rather than the inline-axis SLICE
1095
+ // semantics (first owns left, last owns right). Fixture: the .drop
1096
+ // label in 06-forms-style-file with `border-radius: 10px` and
1097
+ // `<strong>`/`<small>` block children — Chrome paints each
1098
+ // fragment as a self-contained rounded box.
1099
+ var _hasBlockDescendant = false;
1100
+ if (_isInline && _hasPaint) {
1101
+ var _stack = [el];
1102
+ while (_stack.length > 0) {
1103
+ var _n = _stack.pop();
1104
+ var _kids = _n.children;
1105
+ for (var _ki = 0; _ki < _kids.length; _ki++) {
1106
+ var _k = _kids[_ki];
1107
+ var _kd = window.getComputedStyle(_k).display;
1108
+ if (_kd === 'block' || _kd === 'list-item' || _kd === 'flex' || _kd === 'grid' || _kd === 'flow-root' || _kd === 'table') {
1109
+ _hasBlockDescendant = true;
1110
+ break;
1111
+ }
1112
+ _stack.push(_k);
1113
+ }
1114
+ if (_hasBlockDescendant)
1115
+ break;
1116
+ }
1117
+ }
1070
1118
  if (_hasPaint && (_isInline || _inMultiColumn)) {
1071
1119
  var _cr = el.getClientRects();
1072
1120
  if (_cr != null && _cr.length > 1) {
@@ -1094,6 +1142,17 @@ export const captureScript = (args) => {
1094
1142
  // Both axes produce vertically-stacked frag rects so we can't
1095
1143
  // distinguish them geometrically at render time.
1096
1144
  _captured.fragmentAxis = _isInline ? 'inline' : 'block';
1145
+ // DM-937: when the inline has block-level descendants, Chrome
1146
+ // paints each fragment as a complete rounded box (every side,
1147
+ // every corner) — equivalent to `box-decoration-break: clone`
1148
+ // — rather than the inline-axis slice it normally uses. Force
1149
+ // clone here so the renderer's per-fragment path keeps all
1150
+ // sides + corners. Author-set `box-decoration-break: slice` is
1151
+ // overridden (rare in practice — the painted look in Chrome
1152
+ // wins per the project's fidelity rule).
1153
+ if (_hasBlockDescendant) {
1154
+ _captured.styles.boxDecorationBreak = 'clone';
1155
+ }
1097
1156
  }
1098
1157
  }
1099
1158
  }
@@ -1561,6 +1620,13 @@ export const captureScript = (args) => {
1561
1620
  if (_clipPathDefs.size > 0 && result.length > 0) {
1562
1621
  result[0].clipPathDefs = Array.from(_clipPathDefs.values());
1563
1622
  }
1623
+ // DM-934: same shape — inline <filter> defs referenced by CSS `filter:
1624
+ // url(#id)`. The renderer copies these into the output SVG and the
1625
+ // existing pass-through of cs.filter as an inline style on the wrapping
1626
+ // group references them.
1627
+ if (_filterDefs.size > 0 && result.length > 0) {
1628
+ result[0].filterDefs = Array.from(_filterDefs.values());
1629
+ }
1564
1630
  // DM-494: attach mask raster references (mask-image: element(#id)). Skip
1565
1631
  // null entries (display:none / zero-area / not-found targets). The post-
1566
1632
  // capture rasterize pass on the Node side fills in dataUri.
@@ -4,7 +4,9 @@ export declare const createMasksClipsHandler: ({ vp, warn }: {
4
4
  }) => {
5
5
  discoverMasks: (el: any, cs: any, sel: any) => void;
6
6
  discoverClipPaths: (el: any, cs: any, sel: any) => void;
7
+ discoverFilters: (el: any, cs: any, sel: any) => void;
7
8
  maskDefs: Map<any, any>;
8
9
  maskRasters: Map<any, any>;
9
10
  clipPathDefs: Map<any, any>;
11
+ filterDefs: Map<any, any>;
10
12
  };
@@ -197,5 +197,34 @@ export const createMasksClipsHandler = ({ vp, warn }) => {
197
197
  warn(sel, 'clip-path', 'external-file SVG fragment ref (url("./file.svg#id")) could not be resolved — element renders unclipped');
198
198
  }
199
199
  };
200
- return { discoverMasks, discoverClipPaths, maskDefs, maskRasters, clipPathDefs };
200
+ // DM-934: CSS `filter: url(#id)` referencing an inline SVG `<filter>` def.
201
+ // Same shape as discoverClipPaths but for filters — collect the def keyed
202
+ // by id, the renderer copies it into the output SVG <defs> verbatim and
203
+ // the existing pass-through emit of `cs.filter` as an inline style on the
204
+ // element's group wrapper does the rest (the browser's SVG renderer
205
+ // resolves `filter="url(#id)"` against the same-document def).
206
+ //
207
+ // Multi-value forms like `filter: blur(2px) url(#svg-glow)` collect every
208
+ // url(#id) found in the value; each gets its own captured def.
209
+ const filterDefs = new Map();
210
+ const discoverFilters = (el, cs, sel) => {
211
+ const f = cs.filter;
212
+ if (!f || f === 'none' || f === '')
213
+ return;
214
+ const re = /url\(\s*(?:"|')?#([^"')\s]+)(?:"|')?\s*\)/gi;
215
+ let m;
216
+ while ((m = re.exec(f)) != null) {
217
+ const fragId = m[1];
218
+ if (filterDefs.has(fragId))
219
+ continue;
220
+ const target = document.getElementById(fragId);
221
+ if (target != null && target.tagName.toLowerCase() === 'filter') {
222
+ filterDefs.set(fragId, { id: fragId, outerHTML: target.outerHTML });
223
+ }
224
+ else {
225
+ warn(sel, 'filter', 'filter fragment "#' + fragId + '" did not resolve to an inline <filter> element');
226
+ }
227
+ }
228
+ };
229
+ return { discoverMasks, discoverClipPaths, discoverFilters, maskDefs, maskRasters, clipPathDefs, filterDefs };
201
230
  };
@@ -116,9 +116,51 @@ export const createPseudoInjectHandler = () => {
116
116
  const lastSeg = textSegments[textSegments.length - 1];
117
117
  const bs = p.boxStyles || {};
118
118
  const mLa = parseFloat(window.getComputedStyle(el, '::after').marginLeft) || 0;
119
- p.seg.x = lastSeg.x + lastSeg.width + mLa + (bs.borL || 0) + (bs.padL || 0);
120
- p.seg.y = lastSeg.y;
121
- p.seg.height = lastSeg.height;
119
+ // DM-926: when the host is a flex container with a non-default
120
+ // `justify-content` (e.g. `summary { display: flex;
121
+ // justify-content: space-between }` for a `<details>` accordion
122
+ // marker), the ::after isn't laid out flush after the last
123
+ // text segment — it's pushed to the right edge by the flex
124
+ // distribution. The xPos pseudo-content.ts pre-computed
125
+ // (`elLeft + rect.width - pseudoWidth - 2 × padR`) IS the
126
+ // right-edge position; KEEP it instead of overwriting with the
127
+ // adjacent-to-text anchor that's wrong here.
128
+ const hcs = window.getComputedStyle(el);
129
+ const hostIsFlex = hcs.display === 'flex' || hcs.display === 'inline-flex';
130
+ const hostJc = hcs.justifyContent;
131
+ const flexSpread = hostIsFlex && hostJc != null && hostJc !== ''
132
+ && hostJc !== 'flex-start' && hostJc !== 'start' && hostJc !== 'normal';
133
+ if (flexSpread) {
134
+ // Keep p.seg.x as computed by pseudo-content.ts; only re-anchor y / height.
135
+ p.seg.y = lastSeg.y;
136
+ p.seg.height = lastSeg.height;
137
+ }
138
+ else {
139
+ p.seg.x = lastSeg.x + lastSeg.width + mLa + (bs.borL || 0) + (bs.padL || 0);
140
+ p.seg.y = lastSeg.y;
141
+ p.seg.height = lastSeg.height;
142
+ }
143
+ // DM-944: when the host element's painted box extends BELOW the
144
+ // last main-text line by more than one line-height, Chrome wrapped
145
+ // the ::after content to a new line because it didn't fit on the
146
+ // current line. Detect that gap by comparing the host's
147
+ // `getBoundingClientRect().bottom` (which DOES include the
148
+ // pseudo's painted area) to the last text segment's bottom
149
+ // (which doesn't). If the gap is ≥ ~80% of one line-height, the
150
+ // pseudo wrapped — bump `p.seg.y` down by the gap and reset its
151
+ // x to the host's content-box left edge so the renderer paints
152
+ // it on the new line at the host's left margin like Chrome did.
153
+ const elRect = el.getBoundingClientRect();
154
+ const ecs = window.getComputedStyle(el);
155
+ const pdL = parseFloat(ecs.paddingLeft) || 0;
156
+ const bdL = parseFloat(ecs.borderLeftWidth) || 0;
157
+ const lastBottom = lastSeg.y + lastSeg.height;
158
+ const elBottom = elRect.bottom - (parseFloat(ecs.paddingBottom) || 0) - (parseFloat(ecs.borderBottomWidth) || 0);
159
+ const wrapThreshold = lastSeg.height * 0.8;
160
+ if (elBottom - lastBottom >= wrapThreshold) {
161
+ p.seg.x = elRect.x + bdL + pdL;
162
+ p.seg.y = lastBottom; // start of the next line
163
+ }
122
164
  textSegments.push(p.seg);
123
165
  }
124
166
  else {
@@ -112,11 +112,48 @@ export const computeElementRaster = (el, cs, tag, rect, vp) => {
112
112
  const br = parseFloat(cs.borderRightWidth) || 0;
113
113
  const bt = parseFloat(cs.borderTopWidth) || 0;
114
114
  const bb = parseFloat(cs.borderBottomWidth) || 0;
115
+ // DM-936: vertical writing mode with text-decoration: underline paints
116
+ // a vertical underline OUTSIDE the inline content box (to the LEFT for
117
+ // `text-underline-position: left` in vertical-rl, to the RIGHT for
118
+ // `right`, etc.). Tight content-box clipping erases that vertical
119
+ // underline from the screenshot. Walk descendants to detect any
120
+ // text-decoration-line that mentions `underline`/`overline`/`line-
121
+ // through` AND expand the clip rect outward by enough margin (8 px
122
+ // on each side covers thicknesses up to ~6 px plus offsets). Also
123
+ // expand for descendant `text-shadow` for similar reasons. Horizontal
124
+ // writing modes already include the underline area inside the line-
125
+ // box height so the existing tight clip suffices.
126
+ let marginX = 0;
127
+ let marginY = 0;
128
+ if (hasNonHorizontalText) {
129
+ let hasDecoration = false;
130
+ const walk = (node) => {
131
+ if (hasDecoration || node == null)
132
+ return;
133
+ const ncs = node.nodeType === 1 ? window.getComputedStyle(node) : null;
134
+ if (ncs != null) {
135
+ const td = ncs.textDecorationLine || ncs.textDecoration || '';
136
+ if (td !== '' && td !== 'none' && /\b(?:underline|overline|line-through)\b/.test(td)) {
137
+ hasDecoration = true;
138
+ return;
139
+ }
140
+ }
141
+ const kids = node.children;
142
+ if (kids != null)
143
+ for (let i = 0; i < kids.length; i++)
144
+ walk(kids[i]);
145
+ };
146
+ walk(el);
147
+ if (hasDecoration) {
148
+ marginX = 4;
149
+ marginY = 0;
150
+ }
151
+ }
115
152
  return {
116
- x: rect.left - vp.x + bl + pl,
117
- y: rect.top - vp.y + bt + pt,
118
- width: Math.max(1, rect.width - bl - br - pl - pr),
119
- height: Math.max(1, rect.height - bt - bb - pt - pb),
153
+ x: rect.left - vp.x + bl + pl - marginX,
154
+ y: rect.top - vp.y + bt + pt - marginY,
155
+ width: Math.max(1, rect.width - bl - br - pl - pr + 2 * marginX),
156
+ height: Math.max(1, rect.height - bt - bb - pt - pb + 2 * marginY),
120
157
  };
121
158
  };
122
159
  export const createTextSegmentsHandler = ({ vp, measureFontMetrics, needsRaster }) => {
@@ -235,17 +272,63 @@ export const createTextSegmentsHandler = ({ vp, measureFontMetrics, needsRaster
235
272
  // shaping pipeline picks up.
236
273
  if (mathItalicizeMi)
237
274
  ch = mathItalicChar(ch);
238
- const charRec = { ch, left: cr.left, top: cr.top, right: cr.right, bottom: cr.bottom };
239
- if (cur == null || Math.abs(cr.top - cur.top) > 1) {
275
+ // DM-942: when a character is the FIRST glyph of a wrapped line
276
+ // immediately after a soft-hyphen or hyphenation break, Chrome's
277
+ // Range.getBoundingClientRect() returns a UNION rect spanning both
278
+ // lines (top = previous line's top, bottom = next line's bottom,
279
+ // height ≈ 2 × normalCharHeight, width ≈ entire wrap region). The
280
+ // glyph itself paints at the START of the next line. If we trust
281
+ // cr.top, the char gets bucketed into the previous line and the
282
+ // wrapped line silently loses its first character. Detect the
283
+ // anomaly (height significantly larger than the current line's
284
+ // chars) and synthesise the correct per-glyph top/left/right from
285
+ // the next char (peek ahead) — or, when no next char exists, use
286
+ // cr.bottom minus a normal char height.
287
+ let topForGroup = cr.top;
288
+ let leftForGroup = cr.left;
289
+ let rightForGroup = cr.right;
290
+ let bottomForGroup = cr.bottom;
291
+ if (cur != null && cur.chars.length > 0) {
292
+ const refH = (cur.bottom - cur.top) || 16;
293
+ if (cr.height > refH * 1.5 && cr.bottom > cur.bottom + refH * 0.5) {
294
+ // Peek ahead one char to get the actual line-2 top/x.
295
+ const peekI = i + step;
296
+ if (peekI < raw.length) {
297
+ const pr = document.createRange();
298
+ pr.setStart(node, peekI);
299
+ pr.setEnd(node, peekI + 1);
300
+ const pcr = pr.getBoundingClientRect();
301
+ if (pcr.height > 0 && pcr.height < refH * 1.5) {
302
+ topForGroup = pcr.top;
303
+ bottomForGroup = pcr.bottom;
304
+ // The first char's left/right are unknown from cr (it's a
305
+ // union spanning both lines). Place it just before the
306
+ // peeked char, with width = (peek.left - cr-leftmost-of-line-2).
307
+ // The peek's left is line 2's second char; estimate this
308
+ // char's right edge at the peek's left, and its left at
309
+ // cr.left (which IS the line-2 leftmost x when the union
310
+ // spans into line 2).
311
+ leftForGroup = cr.left;
312
+ rightForGroup = pcr.left;
313
+ }
314
+ }
315
+ else {
316
+ // No next char — derive the next-line top from cr.bottom.
317
+ topForGroup = cr.bottom - refH;
318
+ }
319
+ }
320
+ }
321
+ const charRec = { ch, left: leftForGroup, top: topForGroup, right: rightForGroup, bottom: bottomForGroup };
322
+ if (cur == null || Math.abs(topForGroup - cur.top) > 1) {
240
323
  if (cur != null)
241
324
  lines.push(cur);
242
- cur = { chars: [charRec], top: cr.top, bottom: cr.bottom, left: cr.left, right: cr.right };
325
+ cur = { chars: [charRec], top: topForGroup, bottom: bottomForGroup, left: leftForGroup, right: rightForGroup };
243
326
  }
244
327
  else {
245
328
  cur.chars.push(charRec);
246
- cur.left = Math.min(cur.left, cr.left);
247
- cur.right = Math.max(cur.right, cr.right);
248
- cur.bottom = Math.max(cur.bottom, cr.bottom);
329
+ cur.left = Math.min(cur.left, leftForGroup);
330
+ cur.right = Math.max(cur.right, rightForGroup);
331
+ cur.bottom = Math.max(cur.bottom, bottomForGroup);
249
332
  }
250
333
  i += step - 1;
251
334
  }
@@ -328,6 +411,8 @@ export const createTextSegmentsHandler = ({ vp, measureFontMetrics, needsRaster
328
411
  // first-char width.
329
412
  let rasterTop = cRec.top - vp.y;
330
413
  let rasterHeight = cRec.bottom - cRec.top;
414
+ let rasterLeft = cRec.left - vp.x;
415
+ let rasterWidth = cRec.right - cRec.left;
331
416
  if (isFirstLetter) {
332
417
  const ilRaw = flStyle.initialLetter || flStyle.webkitInitialLetter || '';
333
418
  const ilN = parseFloat(ilRaw);
@@ -343,13 +428,33 @@ export const createTextSegmentsHandler = ({ vp, measureFontMetrics, needsRaster
343
428
  rasterHeight = expectedHeight;
344
429
  }
345
430
  }
431
+ // DM-931: decorative ::first-letter drop caps frequently
432
+ // declare PADDING + BACKGROUND-IMAGE on the pseudo (e.g.
433
+ // `linear-gradient` behind the cap). The base char rect
434
+ // measures the GLYPH only — the padding-extended background
435
+ // box extends beyond on every side. Without expansion the
436
+ // rasterized PNG captures just the glyph and the gradient
437
+ // box renders truncated. Expand by the ::first-letter
438
+ // padding (top / right / bottom / left). Same omitBackground
439
+ // semantics: extra captured area outside the painted box is
440
+ // transparent and inert.
441
+ const padT = parseFloat(flStyle.paddingTop) || 0;
442
+ const padR = parseFloat(flStyle.paddingRight) || 0;
443
+ const padB = parseFloat(flStyle.paddingBottom) || 0;
444
+ const padL = parseFloat(flStyle.paddingLeft) || 0;
445
+ if (padT > 0 || padR > 0 || padB > 0 || padL > 0) {
446
+ rasterTop -= padT;
447
+ rasterLeft -= padL;
448
+ rasterWidth += padL + padR;
449
+ rasterHeight += padT + padB;
450
+ }
346
451
  }
347
452
  rasterGlyphs.push({
348
453
  charIndex: utf16Idx,
349
454
  rect: {
350
- x: cRec.left - vp.x,
455
+ x: rasterLeft,
351
456
  y: rasterTop,
352
- width: cRec.right - cRec.left,
457
+ width: rasterWidth,
353
458
  height: rasterHeight,
354
459
  },
355
460
  // Suppress the underlying glyph emit. Two cases:
@@ -85,8 +85,108 @@ const transformHasRotationOrSkew = (transformStr) => {
85
85
  const c = parseFloat(m3[3]);
86
86
  return Math.abs(b) > 1e-6 || Math.abs(c) > 1e-6;
87
87
  }
88
+ // DM-943: composed-effective-transform strings from CSS Transforms 2
89
+ // standalone properties may carry plain function forms like
90
+ // `rotate(20deg)` or `skewX(30deg)` instead of a resolved matrix(). The
91
+ // freeze path passes the un-resolved transform list through verbatim;
92
+ // detect the function names so threadFrozenTransform stashes them too.
93
+ if (/\brotate(?:[XYZ])?\(/.test(transformStr))
94
+ return true;
95
+ if (/\bskew(?:[XY])?\(/.test(transformStr))
96
+ return true;
88
97
  return false;
89
98
  };
99
+ // DM-943: CSS Transforms Level 2 introduced standalone `translate`,
100
+ // `rotate`, `scale` properties that compose with `transform` per spec
101
+ // order: translate → rotate → scale → transform. Chrome keeps them on
102
+ // SEPARATE computed-style entries (cs.rotate / cs.scale / cs.translate)
103
+ // — they do NOT merge into cs.transform. Compose them into a single
104
+ // CSS matrix() string in the page context via DOMMatrix so the existing
105
+ // renderer (which only parses matrix() / matrix3d()) picks them up
106
+ // unchanged. Returns 'none' when all four properties are absent /
107
+ // 'none', otherwise a `matrix(a, b, c, d, e, f)` string.
108
+ const composeEffectiveTransform = (cs) => {
109
+ const t = cs.translate;
110
+ const r = cs.rotate;
111
+ const s = cs.scale;
112
+ const tr = cs.transform;
113
+ const hasT = t && t !== 'none';
114
+ const hasR = r && r !== 'none';
115
+ const hasS = s && s !== 'none';
116
+ const hasTr = tr && tr !== 'none';
117
+ if (!hasT && !hasR && !hasS && !hasTr)
118
+ return 'none';
119
+ if (!hasT && !hasR && !hasS)
120
+ return tr;
121
+ // Build the composed matrix via DOMMatrix in spec order. DOMMatrix
122
+ // multiplications are LEFT to RIGHT post-multiply (each multiplySelf
123
+ // applies AFTER prior ops in the local coord system), so we apply
124
+ // translate first, then rotate, scale, transform — matching CSS T2 §3.
125
+ let m = new DOMMatrix();
126
+ if (hasT) {
127
+ // cs.translate is "<x> [<y>] [<z>]" in px / computed length.
128
+ const ts = t.split(/\s+/).map((v) => parseFloat(v));
129
+ const tx = isFinite(ts[0]) ? ts[0] : 0;
130
+ const ty = ts.length > 1 && isFinite(ts[1]) ? ts[1] : 0;
131
+ const tz = ts.length > 2 && isFinite(ts[2]) ? ts[2] : 0;
132
+ m = m.translate(tx, ty, tz);
133
+ }
134
+ if (hasR) {
135
+ // cs.rotate is "<angle>" or "<x> <y> <z> <angle>" (3D axis form).
136
+ // Parse all numeric tokens; the angle is the last one (with deg/rad/grad/turn unit).
137
+ const tokens = r.trim().split(/\s+/);
138
+ const last = tokens[tokens.length - 1];
139
+ const angleDeg = parseAngleToDeg(last);
140
+ if (tokens.length === 4) {
141
+ const ax = parseFloat(tokens[0]);
142
+ const ay = parseFloat(tokens[1]);
143
+ const az = parseFloat(tokens[2]);
144
+ m = m.rotateAxisAngle(ax, ay, az, angleDeg);
145
+ }
146
+ else {
147
+ m = m.rotate(angleDeg);
148
+ }
149
+ }
150
+ if (hasS) {
151
+ const ts = s.trim().split(/\s+/).map((v) => parseFloat(v));
152
+ const sx = isFinite(ts[0]) ? ts[0] : 1;
153
+ const sy = ts.length > 1 && isFinite(ts[1]) ? ts[1] : sx;
154
+ const sz = ts.length > 2 && isFinite(ts[2]) ? ts[2] : 1;
155
+ m = m.scale(sx, sy, sz);
156
+ }
157
+ if (hasTr) {
158
+ // tr is already a CSS matrix() / matrix3d() string (or rarely the
159
+ // un-resolved form when inline style is read; getComputedStyle always
160
+ // resolves to a matrix). DOMMatrix accepts both.
161
+ try {
162
+ m = m.multiply(new DOMMatrix(tr));
163
+ }
164
+ catch {
165
+ // Unparseable — leave m as the standalone-property-only product.
166
+ }
167
+ }
168
+ // Emit as 2D matrix() when the 3D parts are identity; else matrix3d().
169
+ if (m.is2D) {
170
+ return `matrix(${m.a}, ${m.b}, ${m.c}, ${m.d}, ${m.e}, ${m.f})`;
171
+ }
172
+ return `matrix3d(${m.m11}, ${m.m12}, ${m.m13}, ${m.m14}, ${m.m21}, ${m.m22}, ${m.m23}, ${m.m24}, ${m.m31}, ${m.m32}, ${m.m33}, ${m.m34}, ${m.m41}, ${m.m42}, ${m.m43}, ${m.m44})`;
173
+ };
174
+ const parseAngleToDeg = (a) => {
175
+ const m = /^([-+]?\d*\.?\d+(?:[eE][-+]?\d+)?)(deg|rad|grad|turn)?$/.exec(a);
176
+ if (!m)
177
+ return 0;
178
+ const n = parseFloat(m[1]);
179
+ const u = m[2] || 'deg';
180
+ if (u === 'deg')
181
+ return n;
182
+ if (u === 'rad')
183
+ return (n * 180) / Math.PI;
184
+ if (u === 'grad')
185
+ return (n * 360) / 400;
186
+ if (u === 'turn')
187
+ return n * 360;
188
+ return n;
189
+ };
90
190
  export const createTransformsHandler = () => {
91
191
  const wrapWithFrozenTransform = (el, cs, captureInner) => {
92
192
  // DM-587: for pure translate/scale transforms, do NOT clear — read the
@@ -98,21 +198,45 @@ export const createTransformsHandler = () => {
98
198
  // HTML elements would paint as their axis-aligned bounding boxes (and
99
199
  // overlap their neighbors) since `getBoundingClientRect` post-rotation
100
200
  // returns the rotated AABB, not the original 160×160 layout box.
101
- const originalTransform = cs.transform;
201
+ //
202
+ // DM-943: compose the effective transform from `transform` PLUS the
203
+ // CSS Transforms Level 2 standalone `translate` / `rotate` / `scale`
204
+ // properties — Chrome keeps them separate in computed style and our
205
+ // capture used to only read `cs.transform`, so a `rotate: 20deg` (no
206
+ // `transform:` prefix) produced cs.transform === 'none' and rendered
207
+ // the box axis-aligned. The composed string flows through the same
208
+ // freeze logic as a plain `transform:` of the same shape.
209
+ const originalTransform = composeEffectiveTransform(cs);
102
210
  const hasTransform = originalTransform && originalTransform !== 'none';
103
211
  if (!hasTransform) {
104
212
  return captureInner(el, cs, null, null);
105
213
  }
106
- if (transformHasRotationOrSkew(originalTransform)) {
214
+ // Heuristic: do we need to clear inlines to capture an un-rotated layout
215
+ // box? Pure translate/scale leaves the layout box axis-aligned so we
216
+ // keep the live-rect model. Anything containing a rotate(...) or skew
217
+ // function — including the freshly composed string — needs the freeze.
218
+ const needsFreeze = transformHasRotationOrSkew(originalTransform) ||
219
+ /\brotate\b/.test(originalTransform) ||
220
+ /\bskew/.test(originalTransform);
221
+ if (needsFreeze) {
107
222
  // Old model: clear so getBoundingClientRect returns the un-rotated
108
223
  // layout box; renderer re-applies the original transform.
109
- const inline = el.style.transform;
224
+ const inlineTransform = el.style.transform;
225
+ const inlineRotate = el.style.rotate;
226
+ const inlineScale = el.style.scale;
227
+ const inlineTranslate = el.style.translate;
110
228
  el.style.transform = 'translate(0)';
229
+ el.style.rotate = 'none';
230
+ el.style.scale = 'none';
231
+ el.style.translate = 'none';
111
232
  try {
112
233
  return captureInner(el, cs, originalTransform, cs.transformOrigin);
113
234
  }
114
235
  finally {
115
- el.style.transform = inline;
236
+ el.style.transform = inlineTransform;
237
+ el.style.rotate = inlineRotate;
238
+ el.style.scale = inlineScale;
239
+ el.style.translate = inlineTranslate;
116
240
  }
117
241
  }
118
242
  // Pure translate/scale: keep new live-rect model (no clear, no wrap).