domotion-svg 0.22.2 → 0.23.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/README.md +6 -0
- package/dist/capture/emoji.js +16 -0
- package/dist/capture/index.d.ts +3 -1
- package/dist/capture/index.js +2 -2
- package/dist/capture/script/font-feature-values.d.ts +2 -0
- package/dist/capture/script/font-feature-values.js +62 -0
- package/dist/capture/script/index.js +141 -41
- package/dist/capture/script/walker/pseudo-content.js +4 -3
- package/dist/capture/script/walker/text-segments.d.ts +8 -0
- package/dist/capture/script/walker/text-segments.js +80 -49
- package/dist/capture/script.generated.js +1 -1
- package/dist/capture/types.d.ts +34 -0
- package/dist/cli/capture.js +4 -2
- package/dist/cli/common.d.ts +6 -1
- package/dist/cli/common.js +7 -3
- package/dist/cli/index.js +3 -0
- package/dist/render/element-tree-to-svg.js +38 -5
- package/dist/render/embedded-font-builder.d.ts +24 -0
- package/dist/render/embedded-font-builder.js +73 -6
- package/dist/render/font-resolution.d.ts +6 -2
- package/dist/render/font-resolution.js +26 -11
- package/dist/render/text-to-path.d.ts +8 -0
- package/dist/render/text-to-path.js +43 -1
- package/dist/render/text.d.ts +9 -0
- package/dist/render/text.js +99 -68
- package/dist/render/vertical-text.d.ts +17 -9
- package/dist/render/vertical-text.js +67 -64
- package/dist/templates/builtin/kinetic-text.d.ts +1 -1
- package/dist/templates/render.js +1 -1
- package/dist/templates/types.d.ts +2 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -79,6 +79,12 @@ cat demo.html | domotion capture - -o demo.svg
|
|
|
79
79
|
domotion capture https://example.com --scroll "down:bottom/8s" -o scroll.svg
|
|
80
80
|
```
|
|
81
81
|
|
|
82
|
+
Navigation waits for the page's `load` event, then Domotion applies its normal
|
|
83
|
+
font/image/paint readiness checks. Pages with a finite request chain can opt
|
|
84
|
+
into Playwright's stricter network-idle heuristic with `--network-idle`; it is
|
|
85
|
+
off by default because analytics, long polling, and streaming requests may
|
|
86
|
+
never become idle.
|
|
87
|
+
|
|
82
88
|
Same-origin `<iframe>` content is recursed into the capture as native, selectable SVG rather than flattened to a screenshot; opt into cross-origin frames you trust with `--cross-origin-frames "<hosts>"`.
|
|
83
89
|
|
|
84
90
|
For a multi-frame animated SVG, write a small JSON config and run `domotion animate`:
|
package/dist/capture/emoji.js
CHANGED
|
@@ -392,6 +392,22 @@ export async function rasterizeBitmapGlyphs(page, tree, viewport) {
|
|
|
392
392
|
const candidates = [];
|
|
393
393
|
const sbixAligns = [];
|
|
394
394
|
forEachElement(tree, (el) => {
|
|
395
|
+
if (el.transformSubtreeRaster != null) {
|
|
396
|
+
const tr = el.transformSubtreeRaster;
|
|
397
|
+
candidates.push({
|
|
398
|
+
rect: { x: tr.x, y: tr.y, width: tr.width, height: tr.height },
|
|
399
|
+
key: `transform-subtree|${tr.x}|${tr.y}|${tr.width}x${tr.height}`,
|
|
400
|
+
setDataUri: (uri) => { tr.dataUri = uri; },
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
if (el.nativeControlRaster != null) {
|
|
404
|
+
const nr = el.nativeControlRaster;
|
|
405
|
+
candidates.push({
|
|
406
|
+
rect: { x: nr.x, y: nr.y, width: nr.width, height: nr.height },
|
|
407
|
+
key: `native-control|${el.tag}|${el.styles.inputType ?? ''}|${nr.x}|${nr.y}|${nr.width}x${nr.height}`,
|
|
408
|
+
setDataUri: (uri) => { nr.dataUri = uri; },
|
|
409
|
+
});
|
|
410
|
+
}
|
|
395
411
|
// Element-level raster (SK-1108): textarea content region, too
|
|
396
412
|
// involved to word-wrap in the path pipeline. Key on text+size+color so
|
|
397
413
|
// identical textareas dedupe to one screenshot.
|
package/dist/capture/index.d.ts
CHANGED
|
@@ -139,7 +139,9 @@ export declare class DemoRecorder {
|
|
|
139
139
|
constructor(baseUrl: string, opts: CaptureOptions);
|
|
140
140
|
init(opts: CaptureOptions): Promise<void>;
|
|
141
141
|
/** Navigate to a URL and capture the visible DOM as SVG. */
|
|
142
|
-
captureUrl(path: string, waitMs?: number, idPrefix?: string
|
|
142
|
+
captureUrl(path: string, waitMs?: number, idPrefix?: string, opts?: {
|
|
143
|
+
networkIdle?: boolean;
|
|
144
|
+
}): Promise<string>;
|
|
143
145
|
/**
|
|
144
146
|
* Shared post-capture pipeline (DM-1434): self-contained remote-image
|
|
145
147
|
* embedding + optional resize + conic-gradient rasterization, then reset the
|
package/dist/capture/index.js
CHANGED
|
@@ -191,10 +191,10 @@ export class DemoRecorder {
|
|
|
191
191
|
this.page.setDefaultNavigationTimeout(90_000);
|
|
192
192
|
}
|
|
193
193
|
/** Navigate to a URL and capture the visible DOM as SVG. */
|
|
194
|
-
async captureUrl(path, waitMs = 800, idPrefix = "") {
|
|
194
|
+
async captureUrl(path, waitMs = 800, idPrefix = "", opts) {
|
|
195
195
|
if (this.page == null)
|
|
196
196
|
throw new Error("Call init() first");
|
|
197
|
-
await this.page.goto(`${this.baseUrl}${path}`, { waitUntil: "networkidle" });
|
|
197
|
+
await this.page.goto(`${this.baseUrl}${path}`, { waitUntil: opts?.networkIdle === true ? "networkidle" : "load" });
|
|
198
198
|
await this.page.waitForTimeout(waitMs);
|
|
199
199
|
return this.captureCurrent(idPrefix);
|
|
200
200
|
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
/** Collect Blink's CSSOM view of author @font-feature-values rules. */
|
|
3
|
+
export function collectFontFeatureValues(doc) {
|
|
4
|
+
const families = {};
|
|
5
|
+
const categories = ["annotation", "ornaments", "stylistic", "swash", "characterVariant", "styleset"];
|
|
6
|
+
function familyNames(css) {
|
|
7
|
+
const out = [];
|
|
8
|
+
let token = "", quote = "";
|
|
9
|
+
for (const ch of css) {
|
|
10
|
+
if (quote) {
|
|
11
|
+
if (ch === quote)
|
|
12
|
+
quote = "";
|
|
13
|
+
else
|
|
14
|
+
token += ch;
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
if (ch === "\"" || ch === "'") {
|
|
18
|
+
quote = ch;
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
if (ch === ",") {
|
|
22
|
+
if (token.trim())
|
|
23
|
+
out.push(token.trim().toLowerCase());
|
|
24
|
+
token = "";
|
|
25
|
+
}
|
|
26
|
+
else
|
|
27
|
+
token += ch;
|
|
28
|
+
}
|
|
29
|
+
if (token.trim())
|
|
30
|
+
out.push(token.trim().toLowerCase());
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
function visit(rules) {
|
|
34
|
+
if (!rules)
|
|
35
|
+
return;
|
|
36
|
+
for (const rule of rules) {
|
|
37
|
+
if (typeof rule.fontFamily === "string" && rule.stylistic != null) {
|
|
38
|
+
for (const family of familyNames(rule.fontFamily)) {
|
|
39
|
+
const table = families[family] || (families[family] = {});
|
|
40
|
+
for (const category of categories) {
|
|
41
|
+
const entries = Array.from(rule[category].entries());
|
|
42
|
+
if (!entries.length)
|
|
43
|
+
continue;
|
|
44
|
+
const aliases = table[category] || (table[category] = {});
|
|
45
|
+
for (const [name, values] of entries)
|
|
46
|
+
aliases[name] = Array.from(values);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
else if (rule.cssRules) {
|
|
51
|
+
visit(rule.cssRules);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
for (const sheet of doc.styleSheets) {
|
|
56
|
+
try {
|
|
57
|
+
visit(sheet.cssRules);
|
|
58
|
+
}
|
|
59
|
+
catch { /* cross-origin sheet */ }
|
|
60
|
+
}
|
|
61
|
+
return families;
|
|
62
|
+
}
|
|
@@ -22,6 +22,7 @@ import { createDottedCircleDetect } from "./dotted-circle-detect.js";
|
|
|
22
22
|
import { createFontMetrics } from "./font-metrics.js";
|
|
23
23
|
import { createPlaceholderShown } from "./placeholder-shown.js";
|
|
24
24
|
import { createFontFamilyDefault } from "./font-family-default.js";
|
|
25
|
+
import { collectFontFeatureValues } from "./font-feature-values.js";
|
|
25
26
|
import { createPseudoRules } from "./pseudo-rules.js";
|
|
26
27
|
import { createWarnings } from "./warnings.js";
|
|
27
28
|
import { createCounterStyleResolver } from "./walker/counter-style-resolver.js";
|
|
@@ -58,6 +59,15 @@ const captureDocumentTree = (args) => {
|
|
|
58
59
|
const { measureFontMetrics: _measureFontMetrics, substituteAliasedFamilies: _substituteAliasedFamilies } = createFontMetrics();
|
|
59
60
|
const { resolvePlaceholderShownBg: _resolvePlaceholderShownBg } = createPlaceholderShown();
|
|
60
61
|
const { familyIsUADefault: _familyIsUADefault } = createFontFamilyDefault();
|
|
62
|
+
const _fontFeatureValuesByDocument = new WeakMap();
|
|
63
|
+
const _fontFeatureValuesFor = (doc) => {
|
|
64
|
+
let tables = _fontFeatureValuesByDocument.get(doc);
|
|
65
|
+
if (tables == null) {
|
|
66
|
+
tables = collectFontFeatureValues(doc);
|
|
67
|
+
_fontFeatureValuesByDocument.set(doc, tables);
|
|
68
|
+
}
|
|
69
|
+
return tables;
|
|
70
|
+
};
|
|
61
71
|
const { resolvePseudo: _resolvePseudo, resolveCornerRadius: _resolveCornerRadius } = createPseudoRules();
|
|
62
72
|
const { warn, shortSelector, warnings: _warnings } = createWarnings();
|
|
63
73
|
// DM-770: counter-style map is populated by the pre-walk below (which
|
|
@@ -206,7 +216,7 @@ const captureDocumentTree = (args) => {
|
|
|
206
216
|
// them so the fidelity gaps are self-documenting.
|
|
207
217
|
const sel = shortSelector(el);
|
|
208
218
|
if (cs.transform && cs.transform.startsWith('matrix3d')) {
|
|
209
|
-
warn(sel, 'transform-3d', '
|
|
219
|
+
warn(sel, 'transform-3d', 'non-affine 3D rendering context captured from Chromium as a bitmap snapshot');
|
|
210
220
|
}
|
|
211
221
|
if (cs.backdropFilter && cs.backdropFilter !== 'none') {
|
|
212
222
|
warn(sel, 'backdrop-filter', 'approximated via a frosted-glass background fallback for the transparent-backdrop case (doc 19); no true backdrop blur');
|
|
@@ -612,6 +622,11 @@ const captureDocumentTree = (args) => {
|
|
|
612
622
|
fontStretch: cs.fontStretch,
|
|
613
623
|
fontVariationSettings: cs.fontVariationSettings,
|
|
614
624
|
fontFeatureSettings: cs.fontFeatureSettings,
|
|
625
|
+
fontVariantAlternates: cs.fontVariantAlternates,
|
|
626
|
+
// The alias table is document-global but only alternate-bearing nodes
|
|
627
|
+
// can consume it; omit it from the common element shape to avoid
|
|
628
|
+
// repeating author rule data throughout the serialized tree.
|
|
629
|
+
fontFeatureValues: cs.fontVariantAlternates && cs.fontVariantAlternates !== 'normal' ? _fontFeatureValuesFor(el.ownerDocument) : undefined,
|
|
615
630
|
// CSS font-variant-caps. 'small-caps' / 'all-small-caps' route to
|
|
616
631
|
// the OpenType smcp feature; renderer applies synthesized small-caps
|
|
617
632
|
// when the active font lacks smcp (Helvetica, Times, etc.). DM-361.
|
|
@@ -711,6 +726,50 @@ const captureDocumentTree = (args) => {
|
|
|
711
726
|
// SK-1108 / SK-1128: textarea soft-wrap + writing-mode != horizontal-tb
|
|
712
727
|
// content-box raster rect — see walker/text-segments.ts.
|
|
713
728
|
elementRaster: computeElementRaster(el, cs, tag, rect, vp),
|
|
729
|
+
// DM-2150: SVG transforms are affine and cannot reproduce CSS
|
|
730
|
+
// perspective or preserve-3d flattening. Capture the top-level 3D
|
|
731
|
+
// rendering context as Chromium composited it. Descendant matrix3d
|
|
732
|
+
// nodes remain part of this one bitmap rather than being stamped
|
|
733
|
+
// independently. Include overflowing faces by unioning live descendant
|
|
734
|
+
// client rects before the walker freezes any child transforms.
|
|
735
|
+
transformSubtreeRaster: (function () {
|
|
736
|
+
const is3dRoot = cs.transformStyle === 'preserve-3d'
|
|
737
|
+
|| (cs.perspective != null && cs.perspective !== '' && cs.perspective !== 'none');
|
|
738
|
+
if (!is3dRoot)
|
|
739
|
+
return undefined;
|
|
740
|
+
let p = el.parentElement;
|
|
741
|
+
while (p != null) {
|
|
742
|
+
const pcs = getComputedStyle(p);
|
|
743
|
+
if (pcs.transformStyle === 'preserve-3d'
|
|
744
|
+
|| (pcs.perspective != null && pcs.perspective !== '' && pcs.perspective !== 'none'))
|
|
745
|
+
return undefined;
|
|
746
|
+
p = p.parentElement;
|
|
747
|
+
}
|
|
748
|
+
let left = rect.left, top = rect.top, right = rect.right, bottom = rect.bottom;
|
|
749
|
+
const descendants = el.getElementsByTagName('*');
|
|
750
|
+
for (let i = 0; i < descendants.length; i++) {
|
|
751
|
+
const dr = descendants[i].getBoundingClientRect();
|
|
752
|
+
if (dr.width <= 0 || dr.height <= 0)
|
|
753
|
+
continue;
|
|
754
|
+
left = Math.min(left, dr.left);
|
|
755
|
+
top = Math.min(top, dr.top);
|
|
756
|
+
right = Math.max(right, dr.right);
|
|
757
|
+
bottom = Math.max(bottom, dr.bottom);
|
|
758
|
+
}
|
|
759
|
+
return { x: left - vp.x, y: top - vp.y, width: right - left, height: bottom - top };
|
|
760
|
+
})(),
|
|
761
|
+
// DM-2149: `appearance:auto` controls are painted by Blink's platform
|
|
762
|
+
// LayoutTheme (including native shadow-DOM parts), so a single hardcoded
|
|
763
|
+
// SVG geometry/palette cannot match macOS, Windows, and Linux. Preserve
|
|
764
|
+
// author-owned `appearance:none` controls as vectors; snapshot only the
|
|
765
|
+
// native-themed host rectangle from the same Chromium doing capture.
|
|
766
|
+
nativeControlRaster: (function () {
|
|
767
|
+
const nativeTag = tag === 'input' || tag === 'select' || tag === 'textarea'
|
|
768
|
+
|| tag === 'button' || tag === 'progress' || tag === 'meter';
|
|
769
|
+
if (!nativeTag || cs.appearance === 'none' || rect.width <= 0 || rect.height <= 0)
|
|
770
|
+
return undefined;
|
|
771
|
+
return { x: rect.left - vp.x, y: rect.top - vp.y, width: rect.width, height: rect.height };
|
|
772
|
+
})(),
|
|
714
773
|
// DM-680: per-axis cumulative ancestor scale, exposed ONLY when
|
|
715
774
|
// anisotropic (sx ≠ sy within a small epsilon). The geometric mean is
|
|
716
775
|
// already folded into fontSize / fontAscent / fontDescent above, so
|
|
@@ -1516,23 +1575,72 @@ const captureDocumentTree = (args) => {
|
|
|
1516
1575
|
}
|
|
1517
1576
|
return out;
|
|
1518
1577
|
}
|
|
1519
|
-
//
|
|
1520
|
-
|
|
1578
|
+
// Blink keeps one stack per counter name. A counter introduced on an
|
|
1579
|
+
// element remains visible to later siblings because its originating
|
|
1580
|
+
// element's parent is still an ancestor of those siblings.
|
|
1581
|
+
const _counterStacks = new Map();
|
|
1582
|
+
const _isAncestorOrSelf = (ancestor, node) => ancestor === node || ancestor.contains(node);
|
|
1583
|
+
function _stack(name) {
|
|
1584
|
+
let stack = _counterStacks.get(name);
|
|
1585
|
+
if (stack == null) {
|
|
1586
|
+
stack = [];
|
|
1587
|
+
_counterStacks.set(name, stack);
|
|
1588
|
+
}
|
|
1589
|
+
return stack;
|
|
1590
|
+
}
|
|
1591
|
+
function _removeStale(name, el) {
|
|
1592
|
+
const stack = _stack(name);
|
|
1593
|
+
while (stack.length > 0) {
|
|
1594
|
+
const parent = stack[stack.length - 1].scopeParent;
|
|
1595
|
+
if (parent == null || _isAncestorOrSelf(parent, el))
|
|
1596
|
+
break;
|
|
1597
|
+
stack.pop();
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1521
1600
|
function _findInnermost(name) {
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1601
|
+
const stack = _stack(name);
|
|
1602
|
+
return stack.length ? stack[stack.length - 1] : null;
|
|
1603
|
+
}
|
|
1604
|
+
function _snapshotCounters() {
|
|
1605
|
+
const result = [];
|
|
1606
|
+
for (const [name, stack] of _counterStacks)
|
|
1607
|
+
for (const entry of stack)
|
|
1608
|
+
result.push({ name, value: entry.value });
|
|
1609
|
+
return result;
|
|
1610
|
+
}
|
|
1611
|
+
function _applyCounterStyle(owner, scopeParent, style) {
|
|
1612
|
+
const touched = new Set();
|
|
1613
|
+
const resets = _parseCounterDecl(style.counterReset, 0);
|
|
1614
|
+
const increments = _parseCounterDecl(style.counterIncrement, 1);
|
|
1615
|
+
const sets = _parseCounterDecl(style.counterSet, 0);
|
|
1616
|
+
for (const item of [...resets, ...increments, ...sets])
|
|
1617
|
+
touched.add(item.name);
|
|
1618
|
+
for (const name of touched)
|
|
1619
|
+
_removeStale(name, owner);
|
|
1620
|
+
for (const { name, value } of resets) {
|
|
1621
|
+
const stack = _stack(name);
|
|
1622
|
+
if (stack.length && stack[stack.length - 1].scopeParent === scopeParent)
|
|
1623
|
+
stack.pop();
|
|
1624
|
+
stack.push({ name, value, owner, scopeParent });
|
|
1525
1625
|
}
|
|
1526
|
-
|
|
1626
|
+
for (const { name, value } of increments) {
|
|
1627
|
+
const current = _findInnermost(name);
|
|
1628
|
+
if (current)
|
|
1629
|
+
current.value += value;
|
|
1630
|
+
else
|
|
1631
|
+
_stack(name).push({ name, value, owner, scopeParent });
|
|
1632
|
+
}
|
|
1633
|
+
for (const { name, value } of sets) {
|
|
1634
|
+
const current = _findInnermost(name);
|
|
1635
|
+
if (current)
|
|
1636
|
+
current.value = value;
|
|
1637
|
+
else
|
|
1638
|
+
_stack(name).push({ name, value, owner, scopeParent });
|
|
1639
|
+
}
|
|
1640
|
+
return touched;
|
|
1527
1641
|
}
|
|
1528
1642
|
function _counterPreWalk(el) {
|
|
1529
1643
|
const cs = window.getComputedStyle(el);
|
|
1530
|
-
const owned = [];
|
|
1531
|
-
_parseCounterDecl(cs.counterReset, 0).forEach(({ name, value }) => {
|
|
1532
|
-
const scope = { name, value, owner: el };
|
|
1533
|
-
_activeScopes.push(scope);
|
|
1534
|
-
owned.push(scope);
|
|
1535
|
-
});
|
|
1536
1644
|
// DM-705 / DM-706: CSS Lists 3 §2.3 ("Properties on a single element are
|
|
1537
1645
|
// processed in the order reset, increment, set") — increment runs BEFORE
|
|
1538
1646
|
// set. Our previous order (reset, set, increment) made
|
|
@@ -1540,36 +1648,28 @@ const captureDocumentTree = (args) => {
|
|
|
1540
1648
|
// section` paint as "100." instead of Chrome's "99." for the
|
|
1541
1649
|
// `.restart` h2 in `24-counters.html`. Same off-by-one (always +1) in
|
|
1542
1650
|
// `24-deep-counter-scope.html`.
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
const ns = { name, value, owner: el };
|
|
1549
|
-
_activeScopes.push(ns);
|
|
1550
|
-
owned.push(ns);
|
|
1551
|
-
}
|
|
1552
|
-
});
|
|
1553
|
-
_parseCounterDecl(cs.counterSet, 0).forEach(({ name, value }) => {
|
|
1554
|
-
const s = _findInnermost(name);
|
|
1555
|
-
if (s)
|
|
1556
|
-
s.value = value;
|
|
1557
|
-
else {
|
|
1558
|
-
const ns = { name, value, owner: el };
|
|
1559
|
-
_activeScopes.push(ns);
|
|
1560
|
-
owned.push(ns);
|
|
1561
|
-
}
|
|
1562
|
-
});
|
|
1563
|
-
// Snapshot the active scopes (shallow copy of name+value pairs).
|
|
1564
|
-
_counterSnapshot.set(el, _activeScopes.map((s) => ({ name: s.name, value: s.value })));
|
|
1651
|
+
const touched = _applyCounterStyle(el, el.parentElement, cs);
|
|
1652
|
+
const beforeStyle = window.getComputedStyle(el, '::before');
|
|
1653
|
+
for (const name of _applyCounterStyle(el, el, beforeStyle))
|
|
1654
|
+
touched.add(name);
|
|
1655
|
+
const snapshots = { element: _snapshotCounters(), '::before': _snapshotCounters(), '::after': null };
|
|
1565
1656
|
for (const child of el.children)
|
|
1566
1657
|
_counterPreWalk(child);
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1658
|
+
const afterStyle = window.getComputedStyle(el, '::after');
|
|
1659
|
+
for (const name of _applyCounterStyle(el, el, afterStyle))
|
|
1660
|
+
touched.add(name);
|
|
1661
|
+
snapshots['::after'] = _snapshotCounters();
|
|
1662
|
+
_counterSnapshot.set(el, snapshots);
|
|
1663
|
+
// Match CountersAttachmentContext::RemoveCounterIfAncestorExists: a
|
|
1664
|
+
// descendant-origin counter cannot remain atop an ancestor counter after
|
|
1665
|
+
// leaving its originating element.
|
|
1666
|
+
for (const name of touched) {
|
|
1667
|
+
const stack = _stack(name);
|
|
1668
|
+
if (stack.length < 2 || stack[stack.length - 1].owner !== el)
|
|
1669
|
+
continue;
|
|
1670
|
+
const previous = stack[stack.length - 2].owner;
|
|
1671
|
+
if (previous instanceof Element && previous.contains(el))
|
|
1672
|
+
stack.pop();
|
|
1573
1673
|
}
|
|
1574
1674
|
}
|
|
1575
1675
|
_counterPreWalk(root);
|
|
@@ -261,7 +261,7 @@ const buildPseudoContentHandler = ({ vp, normColor, measureFontMetrics, textNeed
|
|
|
261
261
|
// open-quote / close-quote / no-*-quote keywords (DM-602). Closes over the
|
|
262
262
|
// handler's pickQuoteChar / isCustomCounterStyle / resolveCounterValue.
|
|
263
263
|
// Extracted from capturePseudoContent (DM-1088).
|
|
264
|
-
const parsePseudoContent = (content, el, counterSnapshot) => {
|
|
264
|
+
const parsePseudoContent = (content, el, counterSnapshot, pseudo) => {
|
|
265
265
|
let text = '';
|
|
266
266
|
let imageUrl = '';
|
|
267
267
|
let i = 0;
|
|
@@ -328,7 +328,8 @@ const buildPseudoContentHandler = ({ vp, normColor, measureFontMetrics, textNeed
|
|
|
328
328
|
const out = resolveCounterValue(styleArg, v);
|
|
329
329
|
return out != null ? out : String(v);
|
|
330
330
|
};
|
|
331
|
-
const
|
|
331
|
+
const captured = counterSnapshot.get(el);
|
|
332
|
+
const snapshot = captured?.[pseudo] ?? captured?.element ?? captured ?? [];
|
|
332
333
|
const matches = snapshot.filter((s) => s.name === cname).map((s) => format(s.value));
|
|
333
334
|
if (isCounters) {
|
|
334
335
|
text += matches.length > 0 ? matches.join(sep) : format(0);
|
|
@@ -556,7 +557,7 @@ const buildPseudoContentHandler = ({ vp, normColor, measureFontMetrics, textNeed
|
|
|
556
557
|
const opacityNum = parseFloat(pcs.opacity);
|
|
557
558
|
if (Number.isFinite(opacityNum) && opacityNum === 0)
|
|
558
559
|
continue;
|
|
559
|
-
const { text, imageUrl } = parsePseudoContent(content, el, counterSnapshot);
|
|
560
|
+
const { text, imageUrl } = parsePseudoContent(content, el, counterSnapshot, pseudo);
|
|
560
561
|
if (text === '' && imageUrl === '') {
|
|
561
562
|
const box = captureEmptyContentBox(el, cs, pseudo, pcs, rect);
|
|
562
563
|
if (box != null)
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
export declare const computeElementRaster: (el: any, cs: any, tag: any, rect: any, vp: any) => undefined;
|
|
2
2
|
export declare const capitalizeCss: (s: any) => string;
|
|
3
|
+
/** Preserve the source span that produced every rendered text-transform chunk. */
|
|
4
|
+
export declare const transformTextWithSourceSpans: (source: any, transform: any, lang: any) => {
|
|
5
|
+
sourceStart: number;
|
|
6
|
+
sourceEnd: number;
|
|
7
|
+
sourceText: string;
|
|
8
|
+
rendered: string;
|
|
9
|
+
}[];
|
|
3
10
|
export declare const isTamilJoinerBrokenPrefix: (cp: any, nextCp: any, clusterHasBase: any) => boolean;
|
|
4
11
|
export declare const isMixedVerticalUpright: (cp: any) => boolean;
|
|
5
12
|
export declare const resolveCharOrientation: (ch: any, textOrientation: any) => "upright" | "rotated";
|
|
@@ -94,6 +101,7 @@ export declare const createTextSegmentsHandler: (dependencies: any) => {
|
|
|
94
101
|
} | undefined;
|
|
95
102
|
} | {
|
|
96
103
|
text: any;
|
|
104
|
+
sourceText: any;
|
|
97
105
|
x: number;
|
|
98
106
|
y: number;
|
|
99
107
|
width: number;
|
|
@@ -174,6 +174,41 @@ export const capitalizeCss = (s) => {
|
|
|
174
174
|
}
|
|
175
175
|
return out;
|
|
176
176
|
};
|
|
177
|
+
/** Preserve the source span that produced every rendered text-transform chunk. */
|
|
178
|
+
export const transformTextWithSourceSpans = (source, transform, lang) => {
|
|
179
|
+
const out = [];
|
|
180
|
+
let atWordStart = true;
|
|
181
|
+
for (let i = 0; i < source.length;) {
|
|
182
|
+
const cp = source.codePointAt(i);
|
|
183
|
+
const sourceText = String.fromCodePoint(cp);
|
|
184
|
+
let rendered = sourceText;
|
|
185
|
+
if (transform === 'uppercase')
|
|
186
|
+
rendered = lang ? sourceText.toLocaleUpperCase(lang) : sourceText.toUpperCase();
|
|
187
|
+
else if (transform === 'lowercase')
|
|
188
|
+
rendered = lang ? sourceText.toLocaleLowerCase(lang) : sourceText.toLowerCase();
|
|
189
|
+
else if (transform === 'capitalize' && atWordStart && _RE_LETTER.test(sourceText)) {
|
|
190
|
+
rendered = TITLECASE_DIGRAPHS[sourceText] || (lang ? sourceText.toLocaleUpperCase(lang) : sourceText.toUpperCase());
|
|
191
|
+
}
|
|
192
|
+
out.push({ sourceStart: i, sourceEnd: i + sourceText.length, sourceText, rendered });
|
|
193
|
+
atWordStart = !_RE_WORD_CHAR.test(sourceText) && _MIDWORD_CONNECTORS.indexOf(sourceText) < 0;
|
|
194
|
+
i += sourceText.length;
|
|
195
|
+
}
|
|
196
|
+
// Whole-string casing carries contextual rules such as Greek final sigma.
|
|
197
|
+
// When it preserves the per-span output lengths, substitute its characters
|
|
198
|
+
// without changing the source boundary map.
|
|
199
|
+
const joined = out.map((part) => part.rendered).join('');
|
|
200
|
+
const whole = transform === 'uppercase' ? (lang ? source.toLocaleUpperCase(lang) : source.toUpperCase())
|
|
201
|
+
: transform === 'lowercase' ? (lang ? source.toLocaleLowerCase(lang) : source.toLowerCase())
|
|
202
|
+
: transform === 'capitalize' ? capitalizeCss(source) : source;
|
|
203
|
+
if (whole.length === joined.length && whole !== joined) {
|
|
204
|
+
let offset = 0;
|
|
205
|
+
for (const part of out) {
|
|
206
|
+
part.rendered = whole.slice(offset, offset + part.rendered.length);
|
|
207
|
+
offset += part.rendered.length;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return out;
|
|
211
|
+
};
|
|
177
212
|
// HarfBuzz's Indic Ragel machine (`hb-ot-shaper-indic-machine.rl`, rev
|
|
178
213
|
// 4de187d) accepts `z* M` as a matra group inside a broken cluster. The one
|
|
179
214
|
// live gap established against Chromium is Tamil ZWJ + VOWEL SIGN E: the ZWJ
|
|
@@ -256,34 +291,24 @@ const buildTextSegmentsHandler = ({ vp, measureFontMetrics, needsRaster, normCol
|
|
|
256
291
|
for (const node of el.childNodes) {
|
|
257
292
|
if (node.nodeType !== Node.TEXT_NODE)
|
|
258
293
|
continue;
|
|
259
|
-
|
|
294
|
+
const sourceRaw = node.textContent || '';
|
|
260
295
|
const tt = cs.textTransform;
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
else if (tt === 'lowercase')
|
|
264
|
-
raw = raw.toLowerCase();
|
|
265
|
-
else if (tt === 'capitalize')
|
|
266
|
-
raw = capitalizeCss(raw);
|
|
296
|
+
const mapped = transformTextWithSourceSpans(sourceRaw, tt, cs.lang || el.lang || '');
|
|
297
|
+
const raw = mapped.map((part) => part.rendered).join('');
|
|
267
298
|
if (!raw.trim())
|
|
268
299
|
continue;
|
|
269
300
|
text += raw.trim() + ' ';
|
|
270
|
-
for (
|
|
271
|
-
const code = raw.charCodeAt(i);
|
|
272
|
-
const isHighSurrogate = code >= 0xD800 && code <= 0xDBFF && i + 1 < raw.length;
|
|
273
|
-
const step = isHighSurrogate ? 2 : 1;
|
|
301
|
+
for (const part of mapped) {
|
|
274
302
|
const r = document.createRange();
|
|
275
|
-
r.setStart(node,
|
|
276
|
-
r.setEnd(node,
|
|
303
|
+
r.setStart(node, part.sourceStart);
|
|
304
|
+
r.setEnd(node, part.sourceEnd);
|
|
277
305
|
const cr = r.getBoundingClientRect();
|
|
278
|
-
const ch =
|
|
279
|
-
const isWs =
|
|
306
|
+
const ch = part.rendered;
|
|
307
|
+
const isWs = /^\s+$/.test(part.sourceText);
|
|
280
308
|
// Skip whitespace with zero bbox (collapsed whitespace).
|
|
281
|
-
if (cr.height === 0 && (cr.width === 0 || isWs))
|
|
282
|
-
i += step - 1;
|
|
309
|
+
if (cr.height === 0 && (cr.width === 0 || isWs))
|
|
283
310
|
continue;
|
|
284
|
-
}
|
|
285
311
|
allChars.push({ ch, x: cr.left, y: cr.top, w: cr.width, h: cr.height, naturalW: measureNaturalWidth(ch) });
|
|
286
|
-
i += step - 1;
|
|
287
312
|
}
|
|
288
313
|
}
|
|
289
314
|
if (allChars.length === 0) {
|
|
@@ -749,14 +774,10 @@ const buildTextSegmentsHandler = ({ vp, measureFontMetrics, needsRaster, normCol
|
|
|
749
774
|
if (node.nodeType !== Node.TEXT_NODE)
|
|
750
775
|
continue;
|
|
751
776
|
// text-transform — see header comment.
|
|
752
|
-
|
|
777
|
+
const sourceRaw = node.textContent || '';
|
|
753
778
|
const tt = cs.textTransform;
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
else if (tt === 'lowercase')
|
|
757
|
-
raw = raw.toLowerCase();
|
|
758
|
-
else if (tt === 'capitalize')
|
|
759
|
-
raw = capitalizeCss(raw);
|
|
779
|
+
const mapped = transformTextWithSourceSpans(sourceRaw, tt, cs.lang || el.lang || '');
|
|
780
|
+
const raw = mapped.map((part) => part.rendered).join('');
|
|
760
781
|
if (!raw.trim())
|
|
761
782
|
continue;
|
|
762
783
|
// DM-747: when `<mi>` math-italic substitution applies, the element's
|
|
@@ -769,20 +790,16 @@ const buildTextSegmentsHandler = ({ vp, measureFontMetrics, needsRaster, normCol
|
|
|
769
790
|
// Group characters by their laid-out line (matching rect.top).
|
|
770
791
|
const lines = [];
|
|
771
792
|
let cur = null;
|
|
772
|
-
for (let
|
|
773
|
-
const
|
|
774
|
-
const isHighSurrogate = code >= 0xD800 && code <= 0xDBFF && i + 1 < raw.length;
|
|
775
|
-
const step = isHighSurrogate ? 2 : 1;
|
|
793
|
+
for (let sourcePartIndex = 0; sourcePartIndex < mapped.length; sourcePartIndex++) {
|
|
794
|
+
const part = mapped[sourcePartIndex];
|
|
776
795
|
const r = document.createRange();
|
|
777
|
-
r.setStart(node,
|
|
778
|
-
r.setEnd(node,
|
|
796
|
+
r.setStart(node, part.sourceStart);
|
|
797
|
+
r.setEnd(node, part.sourceEnd);
|
|
779
798
|
const cr = r.getBoundingClientRect();
|
|
780
|
-
const isWs =
|
|
781
|
-
if (cr.width === 0 && (cr.height === 0 || isWs))
|
|
782
|
-
i += step - 1;
|
|
799
|
+
const isWs = /^\s+$/.test(part.sourceText);
|
|
800
|
+
if (cr.width === 0 && (cr.height === 0 || isWs))
|
|
783
801
|
continue;
|
|
784
|
-
|
|
785
|
-
let ch = raw.slice(i, i + step);
|
|
802
|
+
let ch = part.rendered;
|
|
786
803
|
// DM-747: see `mathItalicizeMi` block above — apply the mathvariant=
|
|
787
804
|
// italic substitution AFTER the Range-based measurement so the Range
|
|
788
805
|
// offsets stay valid against the original textContent (`a`, 1 code
|
|
@@ -811,11 +828,11 @@ const buildTextSegmentsHandler = ({ vp, measureFontMetrics, needsRaster, normCol
|
|
|
811
828
|
const refH = (cur.bottom - cur.top) || 16;
|
|
812
829
|
if (cr.height > refH * 1.5 && cr.bottom > cur.bottom + refH * 0.5) {
|
|
813
830
|
// Peek ahead one char to get the actual line-2 top/x.
|
|
814
|
-
const
|
|
815
|
-
if (
|
|
831
|
+
const peekPart = mapped[sourcePartIndex + 1];
|
|
832
|
+
if (peekPart != null) {
|
|
816
833
|
const pr = document.createRange();
|
|
817
|
-
pr.setStart(node,
|
|
818
|
-
pr.setEnd(node,
|
|
834
|
+
pr.setStart(node, peekPart.sourceStart);
|
|
835
|
+
pr.setEnd(node, peekPart.sourceEnd);
|
|
819
836
|
const pcr = pr.getBoundingClientRect();
|
|
820
837
|
if (pcr.height > 0 && pcr.height < refH * 1.5) {
|
|
821
838
|
topForGroup = pcr.top;
|
|
@@ -837,7 +854,8 @@ const buildTextSegmentsHandler = ({ vp, measureFontMetrics, needsRaster, normCol
|
|
|
837
854
|
}
|
|
838
855
|
}
|
|
839
856
|
}
|
|
840
|
-
const charRec = { ch, left: leftForGroup, top: topForGroup, right: rightForGroup, bottom: bottomForGroup
|
|
857
|
+
const charRec = { ch, sourceText: part.sourceText, left: leftForGroup, top: topForGroup, right: rightForGroup, bottom: bottomForGroup,
|
|
858
|
+
transformedLengthChanged: ch.length !== part.sourceText.length };
|
|
841
859
|
if (cur == null || Math.abs(topForGroup - cur.top) > 1) {
|
|
842
860
|
if (cur != null)
|
|
843
861
|
lines.push(cur);
|
|
@@ -849,7 +867,6 @@ const buildTextSegmentsHandler = ({ vp, measureFontMetrics, needsRaster, normCol
|
|
|
849
867
|
cur.right = Math.max(cur.right, rightForGroup);
|
|
850
868
|
cur.bottom = Math.max(cur.bottom, bottomForGroup);
|
|
851
869
|
}
|
|
852
|
-
i += step - 1;
|
|
853
870
|
}
|
|
854
871
|
if (cur != null)
|
|
855
872
|
lines.push(cur);
|
|
@@ -899,12 +916,15 @@ const buildTextSegmentsHandler = ({ vp, measureFontMetrics, needsRaster, normCol
|
|
|
899
916
|
// Build text + xOffsets per line, preserving logical order.
|
|
900
917
|
for (const ln of lines) {
|
|
901
918
|
ln.text = ln.chars.map((c) => c.ch).join('');
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
919
|
+
ln.sourceText = ln.chars.map((c) => c.sourceText).join('');
|
|
920
|
+
if (!ln.chars.some((c) => c.transformedLengthChanged)) {
|
|
921
|
+
const xo = [];
|
|
922
|
+
for (const c of ln.chars) {
|
|
923
|
+
for (let k = 0; k < c.ch.length; k++)
|
|
924
|
+
xo.push(c.left);
|
|
925
|
+
}
|
|
926
|
+
ln.xOffsets = xo;
|
|
906
927
|
}
|
|
907
|
-
ln.xOffsets = xo;
|
|
908
928
|
}
|
|
909
929
|
for (const line of lines) {
|
|
910
930
|
const visualText = line.text.replace(/[\t\n\r]/g, ' ');
|
|
@@ -1024,11 +1044,16 @@ const buildTextSegmentsHandler = ({ vp, measureFontMetrics, needsRaster, normCol
|
|
|
1024
1044
|
}
|
|
1025
1045
|
textSegments.push({
|
|
1026
1046
|
text: visualText,
|
|
1047
|
+
sourceText: line.sourceText,
|
|
1027
1048
|
x: line.left - vp.x,
|
|
1028
1049
|
y: line.top - vp.y,
|
|
1029
1050
|
width: line.right - line.left,
|
|
1030
1051
|
height: line.bottom - line.top,
|
|
1031
|
-
|
|
1052
|
+
// A length-changing transform (e.g. ß → SS) has one DOM Range for
|
|
1053
|
+
// multiple rendered codepoints, so CSSOM exposes no internal glyph
|
|
1054
|
+
// anchors. Let HarfBuzz shape that rendered run normally instead of
|
|
1055
|
+
// inventing duplicate offsets from the source span.
|
|
1056
|
+
xOffsets: line.xOffsets?.map((v) => v - vp.x),
|
|
1032
1057
|
rasterGlyphs: rasterGlyphs.length > 0 ? rasterGlyphs : undefined,
|
|
1033
1058
|
dottedCircleMarks: dottedCircleMarks.length > 0 ? dottedCircleMarks : (probeConsulted ? [] : undefined),
|
|
1034
1059
|
});
|
|
@@ -1064,6 +1089,12 @@ const buildTextSegmentsHandler = ({ vp, measureFontMetrics, needsRaster, normCol
|
|
|
1064
1089
|
if (textSegments.length > flLineTargetIdx) {
|
|
1065
1090
|
const flLineStyle = window.getComputedStyle(el, '::first-line');
|
|
1066
1091
|
const firstSeg = textSegments[flLineTargetIdx];
|
|
1092
|
+
if (flLineStyle.textTransform !== cs.textTransform && firstSeg.sourceText != null) {
|
|
1093
|
+
const firstLineMap = transformTextWithSourceSpans(firstSeg.sourceText, flLineStyle.textTransform, flLineStyle.lang || cs.lang || el.lang || '');
|
|
1094
|
+
firstSeg.text = firstLineMap.map((part) => part.rendered).join('').replace(/[\t\n\r]/g, ' ');
|
|
1095
|
+
if (firstLineMap.some((part) => part.rendered.length !== part.sourceText.length))
|
|
1096
|
+
firstSeg.xOffsets = undefined;
|
|
1097
|
+
}
|
|
1067
1098
|
if (flLineStyle.fontVariant !== '' && flLineStyle.fontVariant !== cs.fontVariant) {
|
|
1068
1099
|
firstSeg.fontVariant = flLineStyle.fontVariant;
|
|
1069
1100
|
}
|