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 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`:
@@ -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.
@@ -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): Promise<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
@@ -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,2 @@
1
+ /** Collect Blink's CSSOM view of author @font-feature-values rules. */
2
+ export declare function collectFontFeatureValues(doc: any): {};
@@ -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', 'matrix3d/translate3d/rotate3d/perspective downgraded to 2D submatrix; z component + perspective dropped (SK-1135)');
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
- // Active counter scope stack: each entry { name, value, owner }.
1520
- const _activeScopes = [];
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
- for (let i = _activeScopes.length - 1; i >= 0; i--) {
1523
- if (_activeScopes[i].name === name)
1524
- return _activeScopes[i];
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
- return null;
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
- _parseCounterDecl(cs.counterIncrement, 1).forEach(({ name, value }) => {
1544
- const s = _findInnermost(name);
1545
- if (s)
1546
- s.value += value;
1547
- else {
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
- // Pop scopes owned by this element on exit (counter scope ends with the
1568
- // owner element's subtree).
1569
- while (_activeScopes.length > 0 && owned.length > 0
1570
- && _activeScopes[_activeScopes.length - 1] === owned[owned.length - 1]) {
1571
- _activeScopes.pop();
1572
- owned.pop();
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 snapshot = counterSnapshot.get(el) || [];
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
- let raw = node.textContent || '';
294
+ const sourceRaw = node.textContent || '';
260
295
  const tt = cs.textTransform;
261
- if (tt === 'uppercase')
262
- raw = raw.toUpperCase();
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 (let i = 0; i < raw.length; i++) {
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, i);
276
- r.setEnd(node, i + step);
303
+ r.setStart(node, part.sourceStart);
304
+ r.setEnd(node, part.sourceEnd);
277
305
  const cr = r.getBoundingClientRect();
278
- const ch = raw.slice(i, i + step);
279
- const isWs = step === 1 && /\s/.test(raw[i]);
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
- let raw = node.textContent || '';
777
+ const sourceRaw = node.textContent || '';
753
778
  const tt = cs.textTransform;
754
- if (tt === 'uppercase')
755
- raw = raw.toUpperCase();
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 i = 0; i < raw.length; i++) {
773
- const code = raw.charCodeAt(i);
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, i);
778
- r.setEnd(node, i + step);
796
+ r.setStart(node, part.sourceStart);
797
+ r.setEnd(node, part.sourceEnd);
779
798
  const cr = r.getBoundingClientRect();
780
- const isWs = step === 1 && /\s/.test(raw[i]);
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 peekI = i + step;
815
- if (peekI < raw.length) {
831
+ const peekPart = mapped[sourcePartIndex + 1];
832
+ if (peekPart != null) {
816
833
  const pr = document.createRange();
817
- pr.setStart(node, peekI);
818
- pr.setEnd(node, peekI + 1);
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
- const xo = [];
903
- for (const c of ln.chars) {
904
- for (let k = 0; k < c.ch.length; k++)
905
- xo.push(c.left);
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
- xOffsets: line.xOffsets.map((v) => v - vp.x),
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
  }