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.
- package/dist/capture/emoji.js +28 -2
- package/dist/capture/script/index.js +67 -1
- package/dist/capture/script/walker/masks-clips.d.ts +2 -0
- package/dist/capture/script/walker/masks-clips.js +30 -1
- package/dist/capture/script/walker/pseudo-inject.js +45 -3
- package/dist/capture/script/walker/text-segments.js +117 -12
- package/dist/capture/script/walker/transforms.js +128 -4
- package/dist/capture/script.generated.js +1 -1
- package/dist/capture/types.d.ts +25 -0
- package/dist/render/element-tree-to-svg.js +122 -7
- package/dist/render/text-to-path.js +118 -21
- package/dist/render/text.js +135 -3
- package/dist/review/client.bundle.generated.js +1 -1
- package/dist/review/client.js +35 -24
- package/dist/review/region-overlay.d.ts +7 -0
- package/dist/review/region-overlay.js +7 -1
- package/package.json +1 -1
package/dist/capture/emoji.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
239
|
-
|
|
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:
|
|
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,
|
|
247
|
-
cur.right = Math.max(cur.right,
|
|
248
|
-
cur.bottom = Math.max(cur.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:
|
|
455
|
+
x: rasterLeft,
|
|
351
456
|
y: rasterTop,
|
|
352
|
-
width:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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).
|