domotion-svg 0.6.0 → 0.8.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
  };
@@ -159,8 +159,19 @@ export const createPseudoContentHandler = ({ vp, normColor, measureFontMetrics,
159
159
  probe.style.marginRight = pcs.marginRight;
160
160
  probe.style.marginBottom = pcs.marginBottom;
161
161
  probe.style.marginLeft = pcs.marginLeft;
162
- probe.style.transform = pcs.transform && pcs.transform !== 'none' ? pcs.transform : '';
163
- probe.style.transformOrigin = pcs.transformOrigin || '';
162
+ // DM-928: deliberately DO NOT apply the pseudo's `transform` to the
163
+ // probe. CSS transforms only affect paint, not layout; the probe's
164
+ // `getBoundingClientRect()` returns the AXIS-ALIGNED bounding box of
165
+ // whatever box it currently paints. With a 45° rotation, that AABB is
166
+ // ~√2 × larger than the actual border-box and its top-left sits
167
+ // ~(diagonal-extra)/2 to the upper-left of the true border-box origin
168
+ // — re-rotating that AABB later via the `<g transform>` wrapper
169
+ // places the painted strokes at the wrong position (pricing-table
170
+ // checkmarks drift ~4 px left / 1 px up). Strip the transform here
171
+ // and let the unrotated probe report the actual border-box rect; the
172
+ // transform is re-applied at render time inside `flushPbTransformWrap`.
173
+ probe.style.transform = '';
174
+ probe.style.transformOrigin = '';
164
175
  // Pseudo lives logically inside the host; an absolute child of the host
165
176
  // inherits the same containing-block lookup.
166
177
  if (pseudo === '::before')
@@ -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,28 +411,81 @@ 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
- const ilRaw = flStyle.initialLetter || flStyle.webkitInitialLetter || '';
333
- const ilN = parseFloat(ilRaw);
334
- const parentLineHeight = parseFloat(cs.lineHeight);
335
- if (Number.isFinite(ilN) && ilN > 1 && Number.isFinite(parentLineHeight) && parentLineHeight > 0) {
336
- const expectedHeight = ilN * parentLineHeight;
337
- if (expectedHeight > rasterHeight) {
338
- // Extend downward only Chrome aligns the cap-top at the
339
- // first line's cap-height position; extra space the
340
- // expansion captures past the actual ink is empty (will
341
- // raster as transparent, with `omitBackground: true`) and
342
- // doesn't paint anything visible.
343
- rasterHeight = expectedHeight;
417
+ const flFloat = flStyle.float || flStyle.cssFloat || '';
418
+ const padT = parseFloat(flStyle.paddingTop) || 0;
419
+ const padR = parseFloat(flStyle.paddingRight) || 0;
420
+ const padB = parseFloat(flStyle.paddingBottom) || 0;
421
+ const padL = parseFloat(flStyle.paddingLeft) || 0;
422
+ if (flFloat === 'left' || flFloat === 'right') {
423
+ // DM-931: floated ::first-letter (drop cap) is positioned
424
+ // relative to the PARAGRAPH's content area, not the first
425
+ // character's line-box position. `Range.getBoundingClientRect`
426
+ // on the first character returns the GLYPH bounds at its
427
+ // line-1 position — which doesn't match where Chrome paints
428
+ // the float when `initial-letter` is set (the cap-top aligns
429
+ // to line-1's cap-top, shifting the painted box DOWN from
430
+ // the Range top by roughly the cap-height-vs-ascender delta).
431
+ // Compute the raster rect from the pseudo's computed
432
+ // padding-box (width/height + padding) + the paragraph's
433
+ // border-box origin + paragraph padding/border + pseudo
434
+ // margins.
435
+ const pBox = el.getBoundingClientRect();
436
+ const pPadT = parseFloat(cs.paddingTop) || 0;
437
+ const pPadL = parseFloat(cs.paddingLeft) || 0;
438
+ const pPadR = parseFloat(cs.paddingRight) || 0;
439
+ const pBorT = parseFloat(cs.borderTopWidth) || 0;
440
+ const pBorL = parseFloat(cs.borderLeftWidth) || 0;
441
+ const pBorR = parseFloat(cs.borderRightWidth) || 0;
442
+ const flMarT = parseFloat(flStyle.marginTop) || 0;
443
+ const flMarL = parseFloat(flStyle.marginLeft) || 0;
444
+ const flMarR = parseFloat(flStyle.marginRight) || 0;
445
+ const w = parseFloat(flStyle.width);
446
+ const h = parseFloat(flStyle.height);
447
+ const padW = (Number.isFinite(w) && w > 0 ? w : rasterWidth) + padL + padR;
448
+ const padH = (Number.isFinite(h) && h > 0 ? h : rasterHeight) + padT + padB;
449
+ rasterTop = pBox.y + pBorT + pPadT + flMarT - vp.y;
450
+ if (flFloat === 'left') {
451
+ rasterLeft = pBox.x + pBorL + pPadL + flMarL - vp.x;
452
+ }
453
+ else {
454
+ rasterLeft = pBox.x + pBox.width - pBorR - pPadR - flMarR - padW - vp.x;
455
+ }
456
+ rasterWidth = padW;
457
+ rasterHeight = padH;
458
+ }
459
+ else {
460
+ // Non-floated ::first-letter (raised cap via font-size only,
461
+ // or `display: inline`). The Range rect tracks the painted
462
+ // glyph correctly; just expand the rect by the pseudo's
463
+ // padding so a `background-color` / gradient behind the
464
+ // glyph isn't truncated. Apply the older `initial-letter`
465
+ // height fallback for safety against under-tall Range
466
+ // measurements on float-less drop caps.
467
+ const ilRaw = flStyle.initialLetter || flStyle.webkitInitialLetter || '';
468
+ const ilN = parseFloat(ilRaw);
469
+ const parentLineHeight = parseFloat(cs.lineHeight);
470
+ if (Number.isFinite(ilN) && ilN > 1 && Number.isFinite(parentLineHeight) && parentLineHeight > 0) {
471
+ const expectedHeight = ilN * parentLineHeight;
472
+ if (expectedHeight > rasterHeight)
473
+ rasterHeight = expectedHeight;
474
+ }
475
+ if (padT > 0 || padR > 0 || padB > 0 || padL > 0) {
476
+ rasterTop -= padT;
477
+ rasterLeft -= padL;
478
+ rasterWidth += padL + padR;
479
+ rasterHeight += padT + padB;
344
480
  }
345
481
  }
346
482
  }
347
483
  rasterGlyphs.push({
348
484
  charIndex: utf16Idx,
349
485
  rect: {
350
- x: cRec.left - vp.x,
486
+ x: rasterLeft,
351
487
  y: rasterTop,
352
- width: cRec.right - cRec.left,
488
+ width: rasterWidth,
353
489
  height: rasterHeight,
354
490
  },
355
491
  // Suppress the underlying glyph emit. Two cases: