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.
- 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-content.js +13 -2
- package/dist/capture/script/walker/pseudo-inject.js +45 -3
- package/dist/capture/script/walker/text-segments.js +160 -24
- 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 +135 -8
- package/dist/render/text-to-path.js +153 -27
- package/dist/render/text.js +135 -3
- package/dist/review/client.bundle.generated.js +1 -1
- package/dist/review/client.js +82 -30
- package/dist/review/region-overlay.d.ts +7 -0
- package/dist/review/region-overlay.js +7 -1
- package/dist/review/server.js +12 -4
- package/package.json +2 -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
|
};
|
|
@@ -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
|
-
|
|
163
|
-
probe.
|
|
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
|
-
|
|
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,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
|
|
333
|
-
const
|
|
334
|
-
const
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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:
|
|
486
|
+
x: rasterLeft,
|
|
351
487
|
y: rasterTop,
|
|
352
|
-
width:
|
|
488
|
+
width: rasterWidth,
|
|
353
489
|
height: rasterHeight,
|
|
354
490
|
},
|
|
355
491
|
// Suppress the underlying glyph emit. Two cases:
|