domotion-svg 0.13.2 → 0.14.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.
Files changed (63) hide show
  1. package/dist/animation/animator.js +44 -7
  2. package/dist/capture/emoji.d.ts +37 -0
  3. package/dist/capture/emoji.js +41 -21
  4. package/dist/capture/script/dotted-circle-detect.js +9 -2
  5. package/dist/capture/script/emoji-detect.js +51 -5
  6. package/dist/capture/script/index.js +321 -2
  7. package/dist/capture/script/utils.js +12 -6
  8. package/dist/capture/script/walker/form-controls.d.ts +9 -0
  9. package/dist/capture/script/walker/form-controls.js +28 -0
  10. package/dist/capture/script/walker/pseudo-inject.js +89 -3
  11. package/dist/capture/script/walker/text-segments.d.ts +1 -0
  12. package/dist/capture/script/walker/text-segments.js +76 -19
  13. package/dist/capture/script.generated.js +1 -1
  14. package/dist/capture/types.d.ts +43 -1
  15. package/dist/cli/animate.d.ts +30 -0
  16. package/dist/cli/animate.js +86 -2
  17. package/dist/cli/capture.js +18 -1
  18. package/dist/cli/index.d.ts +2 -1
  19. package/dist/cli/index.js +19 -1
  20. package/dist/cli/term.d.ts +16 -0
  21. package/dist/cli/term.js +192 -0
  22. package/dist/index.d.ts +5 -0
  23. package/dist/index.js +11 -0
  24. package/dist/render/borders.d.ts +46 -1
  25. package/dist/render/borders.js +78 -5
  26. package/dist/render/colors.js +154 -16
  27. package/dist/render/conic-raster.js +6 -0
  28. package/dist/render/device-chrome.d.ts +61 -0
  29. package/dist/render/device-chrome.js +187 -0
  30. package/dist/render/element-tree-to-svg.d.ts +15 -0
  31. package/dist/render/element-tree-to-svg.js +191 -33
  32. package/dist/render/embedded-font-builder.js +13 -0
  33. package/dist/render/form-controls.d.ts +28 -0
  34. package/dist/render/form-controls.js +321 -116
  35. package/dist/render/gradients.d.ts +6 -3
  36. package/dist/render/gradients.js +25 -20
  37. package/dist/render/harfbuzz-shaper.d.ts +67 -0
  38. package/dist/render/harfbuzz-shaper.js +151 -0
  39. package/dist/render/index.d.ts +1 -0
  40. package/dist/render/index.js +3 -0
  41. package/dist/render/text-to-path.d.ts +27 -0
  42. package/dist/render/text-to-path.js +602 -34
  43. package/dist/render/vertical-text.js +7 -4
  44. package/dist/scroll/composer.js +2 -0
  45. package/dist/scroll/executor.js +17 -2
  46. package/dist/scroll/pattern.d.ts +8 -1
  47. package/dist/scroll/pattern.js +30 -2
  48. package/dist/terminal/cast.d.ts +53 -0
  49. package/dist/terminal/cast.js +83 -0
  50. package/dist/terminal/emulator.d.ts +64 -0
  51. package/dist/terminal/emulator.js +122 -0
  52. package/dist/terminal/incremental.d.ts +77 -0
  53. package/dist/terminal/incremental.js +382 -0
  54. package/dist/terminal/index.d.ts +93 -0
  55. package/dist/terminal/index.js +142 -0
  56. package/dist/terminal/pty.d.ts +74 -0
  57. package/dist/terminal/pty.js +102 -0
  58. package/dist/terminal/render.d.ts +65 -0
  59. package/dist/terminal/render.js +156 -0
  60. package/dist/terminal/theme.d.ts +43 -0
  61. package/dist/terminal/theme.js +90 -0
  62. package/package.json +8 -1
  63. package/schemas/animate-config.schema.json +90 -0
@@ -273,6 +273,11 @@ export function generateAnimatedSvg(config) {
273
273
  const enterStartPct = entersViaOverlap
274
274
  ? pct(timeOffset - prevTransDur, totalDuration)
275
275
  : startPct;
276
+ // DM-1207: the last frame holds solid to 100% (no loop cross-dissolve)
277
+ // unless `loopFade` is set — same rule the crossfade/cut path applies via
278
+ // DM-1148 (see emitCrossfadeOrCutFrame). For the slide paths (push-left /
279
+ // scroll) this means: slide in, then hold (no slide-out / fade-out).
280
+ const holdLastFrame = i === frames.length - 1 && config.loopFade !== true;
276
281
  if (transType === "push-left") {
277
282
  // Push: slide in from right, slide out to left
278
283
  frameGroups.push(` <g class="f f-${i}"><clipPath id="fc-${i}"><rect width="${width}" height="${height}" /></clipPath><g clip-path="url(#fc-${i})" class="fp fp-${i}">\n${frame.svgContent}\n </g></g>`);
@@ -282,7 +287,7 @@ export function generateAnimatedSvg(config) {
282
287
  // Window is [enterStartPct .. transEndPct] (when the slide has fully
283
288
  // exited the viewBox); 0.01% pad on each side keeps the snap inside the
284
289
  // existing opacity:0 bookend.
285
- keyframes.push(slideKeyframes(i, "X", width, entersViaPush, enterStartPct, startPct, holdEndPct, transEndPct, enterStartPct, transEndPct, totalSec));
290
+ keyframes.push(slideKeyframes(i, "X", width, entersViaPush, enterStartPct, startPct, holdEndPct, transEndPct, enterStartPct, transEndPct, totalSec, holdLastFrame));
286
291
  }
287
292
  else if (transType === "scroll") {
288
293
  // DM-609: `scroll` now means real geometric scroll between two frames
@@ -294,7 +299,7 @@ export function generateAnimatedSvg(config) {
294
299
  // `fd-${i}` display animation). (`entersViaScroll` is already computed in
295
300
  // the outer scope above — same value, no need to redeclare/shadow it.)
296
301
  frameGroups.push(` <g class="f f-${i}"><clipPath id="fc-${i}"><rect width="${width}" height="${height}" /></clipPath><g clip-path="url(#fc-${i})" class="fp fp-${i}">\n${frame.svgContent}\n </g></g>`);
297
- keyframes.push(slideKeyframes(i, "Y", height, entersViaScroll, enterStartPct, startPct, holdEndPct, transEndPct, enterStartPct, transEndPct, totalSec));
302
+ keyframes.push(slideKeyframes(i, "Y", height, entersViaScroll, enterStartPct, startPct, holdEndPct, transEndPct, enterStartPct, transEndPct, totalSec, holdLastFrame));
298
303
  }
299
304
  else if (transType === "magic-move" && frame.magicMove != null) {
300
305
  // DM-898: magic-move. Frame i holds [start..holdEnd] then HARD-CUTS out;
@@ -444,6 +449,14 @@ function renderTypingOverlay(overlay, frameIdx, frameStart, frameEnd, totalDurat
444
449
  const speed = overlay.speed ?? 60;
445
450
  const fontSize = overlay.fontSize ?? 14;
446
451
  const charWidth = fontSize * 0.6; // monospace cell (overlay font is monospace)
452
+ // DM-1205: the typewriter reveal hides not-yet-typed text with a width-0 clip
453
+ // rect. Chrome renders a zero-area clip path as "clip everything" (text
454
+ // hidden, correct), but WebKit/Safari treats an EMPTY clip as "no clip" and
455
+ // paints the element in full — so on multi-line overlays every line past the
456
+ // first showed its whole text immediately (the shared text opacity is already
457
+ // 1 while the line waits its turn). Hiding with a tiny non-zero width keeps
458
+ // the clip non-empty so WebKit clips it too; 0.01px reveals no visible pixel.
459
+ const hiddenW = "0.01px";
447
460
  const lineHeight = Math.round(fontSize * 1.35);
448
461
  const color = overlay.color ?? "#e6edf3";
449
462
  const typeStartMs = frameStart + delay;
@@ -512,7 +525,7 @@ function renderTypingOverlay(overlay, frameIdx, frameStart, frameEnd, totalDurat
512
525
  const lineEndPct = pct(lineEndMs, totalDuration);
513
526
  lineTimings.push({ li, startMs: lineStartMs, endMs: lineEndMs, len: line.length });
514
527
  cumChars += line.length;
515
- parts.push(` <defs><clipPath id="${clipId}"><rect class="${id}-rev${li}" x="${overlay.x}" y="${lineY - fontSize}" width="0" height="${textHeight}" /></clipPath></defs>`);
528
+ parts.push(` <defs><clipPath id="${clipId}"><rect class="${id}-rev${li}" x="${overlay.x}" y="${lineY - fontSize}" width="${hiddenW}" height="${textHeight}" /></clipPath></defs>`);
516
529
  parts.push(` <text class="${id}-text" x="${overlay.x}" y="${lineY}" fill="${color}" font-size="${fontSize}" font-family="'SF Mono', Menlo, Monaco, monospace" clip-path="url(#${clipId})">${escapeHtml(line)}</text>`);
517
530
  // DM-1204: the reveal clip MUST sweep linearly so its right edge tracks the
518
531
  // caret (whose position track is `linear`). Without an explicit timing
@@ -520,8 +533,10 @@ function renderTypingOverlay(overlay, frameIdx, frameStart, frameEnd, totalDurat
520
533
  // through the sweep at the time-midpoint while the linear caret is only at
521
534
  // 50% — that desync read as the caret lagging ~10–20 chars behind the
522
535
  // revealed text mid-type, even though the endpoints (parked state) matched.
536
+ // DM-1205: the hidden state uses `hiddenW` (a tiny non-zero width), not 0,
537
+ // so WebKit's empty-clip-path fallback doesn't paint the whole line.
523
538
  cssRules.push(`
524
- @keyframes ${id}-rev${li} { 0%, ${lineStartPct} { width: 0; } ${lineEndPct} { width: ${lineWidth}px; } ${holdEndPct} { width: ${lineWidth}px; } ${disappearPct}, 100% { width: 0; } }
539
+ @keyframes ${id}-rev${li} { 0%, ${lineStartPct} { width: ${hiddenW}; } ${lineEndPct} { width: ${lineWidth}px; } ${holdEndPct} { width: ${lineWidth}px; } ${disappearPct}, 100% { width: ${hiddenW}; } }
525
540
  .${id}-rev${li} { animation: ${id}-rev${li} ${totalSec.toFixed(2)}s linear infinite; }`);
526
541
  });
527
542
  // Whole-overlay visibility — shared by every line's <text>.
@@ -676,17 +691,39 @@ function buildDisplayKeyframes(name, visibleStartPct, visibleEndPct) {
676
691
  * exits to `-size`. 0.1% pads on each bookend keep the snap inside the
677
692
  * opacity:0 frame. Emits the fp/fv/fd keyframes + the `.f-`/`.fp-` rules.
678
693
  */
679
- function slideKeyframes(i, axis, size, entersSliding, enterStartPct, startPct, holdEndPct, transEndPct, visStart, visEnd, totalSec) {
694
+ function slideKeyframes(i, axis, size, entersSliding, enterStartPct, startPct, holdEndPct, transEndPct, visStart, visEnd, totalSec,
695
+ /** DM-1207: the last frame, when the loop must NOT cross-dissolve, slides in
696
+ * and then HOLDS solid (transform 0, opacity 1, visible) to 100% — no
697
+ * slide-out / fade-out — and the loop hard-cuts back to frame 0. Mirrors the
698
+ * crossfade/cut path's DM-1148 holdToEnd. Without it the slide-out keyframes
699
+ * ramp the last frame to opacity 0 across its whole hold, washing it out. */
700
+ holdToEnd = false) {
701
+ const enterBound = padBefore(parseFloat(enterStartPct), KEYFRAME_EPSILON.slide, 2);
702
+ if (holdToEnd) {
703
+ return `
704
+ @keyframes fp-${i} {
705
+ 0%, ${enterBound}% { transform: translate${axis}(${entersSliding ? size : 0}px); }
706
+ ${startPct} { transform: translate${axis}(0); }
707
+ 100% { transform: translate${axis}(0); }
708
+ }
709
+ @keyframes fv-${i} {
710
+ 0%, ${enterBound}% { opacity: 0; }
711
+ ${enterStartPct} { opacity: 1; }
712
+ 100% { opacity: 1; }
713
+ }${buildDisplayKeyframes(`fd-${i}`, visStart, "100")}
714
+ .f-${i} { animation: fv-${i} ${totalSec.toFixed(2)}s infinite, fd-${i} ${totalSec.toFixed(2)}s infinite step-end; }
715
+ .fp-${i} { animation: fp-${i} ${totalSec.toFixed(2)}s infinite; }`;
716
+ }
680
717
  return `
681
718
  @keyframes fp-${i} {
682
- 0%, ${padBefore(parseFloat(enterStartPct), KEYFRAME_EPSILON.slide, 2)}% { transform: translate${axis}(${entersSliding ? size : 0}px); }
719
+ 0%, ${enterBound}% { transform: translate${axis}(${entersSliding ? size : 0}px); }
683
720
  ${startPct} { transform: translate${axis}(0); }
684
721
  ${holdEndPct} { transform: translate${axis}(0); }
685
722
  ${transEndPct} { transform: translate${axis}(-${size}px); }
686
723
  ${padAfter(parseFloat(transEndPct), KEYFRAME_EPSILON.slide, 2)}%, 100% { transform: translate${axis}(-${size}px); }
687
724
  }
688
725
  @keyframes fv-${i} {
689
- 0%, ${padBefore(parseFloat(enterStartPct), KEYFRAME_EPSILON.slide, 2)}% { opacity: 0; }
726
+ 0%, ${enterBound}% { opacity: 0; }
690
727
  ${enterStartPct} { opacity: 1; }
691
728
  ${transEndPct} { opacity: 1; }
692
729
  ${padAfter(parseFloat(transEndPct), KEYFRAME_EPSILON.slide, 2)}%, 100% { opacity: 0; }
@@ -17,6 +17,43 @@
17
17
  */
18
18
  import type { Page } from "@playwright/test";
19
19
  import type { CapturedElement } from "./types.js";
20
+ /**
21
+ * Snap an Apple Color Emoji sbix bitmap to the SQUARE box Chrome actually
22
+ * paints it in.
23
+ *
24
+ * Chrome paints a color-emoji glyph as a square whose side equals the glyph
25
+ * ADVANCE — the captured `Range.getBoundingClientRect()` width, minus any
26
+ * letter-spacing Chrome appends to the right of the advance. Two facts make
27
+ * the advance (not the font size) the correct side:
28
+ * - At small font sizes Chrome enforces a minimum emoji advance that exceeds
29
+ * the font size — e.g. a 20px advance at font-size 16 (~1.25×). Sizing the
30
+ * overlay to the font size painted the emoji ~20% too small (DM-1198: "our
31
+ * emojis seem a lot smaller").
32
+ * - The sbix PNG is a full square bitmap (the emoji fills a square em), so
33
+ * drawing it into an advance × advance box reproduces both full-bleed emoji
34
+ * and ones with transparent margins (e.g. 📈) without distortion.
35
+ *
36
+ * Geometry:
37
+ * - Horizontal: the bitmap sits flush at the advance's left (`rect.x`); any
38
+ * letter-spacing pads to the RIGHT, so no horizontal shift is applied
39
+ * (DM-919 — the original DM-381 centering pass wrongly shifted it right
40
+ * whenever letter-spacing > 0).
41
+ * - Vertical: the square is centered in the captured rect's line box, which
42
+ * matches Chrome within ~1px across sizes (DM-438 — a 20×17 rect extends
43
+ * upward to a 20×20 square; DM-801 — a 56×63 rect with 8px letter-spacing
44
+ * snaps to 48×48).
45
+ */
46
+ export declare function emojiSquareRect(rect: {
47
+ x: number;
48
+ y: number;
49
+ width: number;
50
+ height: number;
51
+ }, letterSpacing: number): {
52
+ x: number;
53
+ y: number;
54
+ width: number;
55
+ height: number;
56
+ };
20
57
  /**
21
58
  * For every text segment CAPTURE_SCRIPT flagged with a rasterRect (contains a
22
59
  * color-bitmap glyph like U+2713 ✓ or an emoji), ask Playwright for a
@@ -97,6 +97,42 @@ function extractEmojiBitmap(codepoint, paintedWidthPx) {
97
97
  _sbixCache.set(cacheKey, result);
98
98
  return result;
99
99
  }
100
+ /**
101
+ * Snap an Apple Color Emoji sbix bitmap to the SQUARE box Chrome actually
102
+ * paints it in.
103
+ *
104
+ * Chrome paints a color-emoji glyph as a square whose side equals the glyph
105
+ * ADVANCE — the captured `Range.getBoundingClientRect()` width, minus any
106
+ * letter-spacing Chrome appends to the right of the advance. Two facts make
107
+ * the advance (not the font size) the correct side:
108
+ * - At small font sizes Chrome enforces a minimum emoji advance that exceeds
109
+ * the font size — e.g. a 20px advance at font-size 16 (~1.25×). Sizing the
110
+ * overlay to the font size painted the emoji ~20% too small (DM-1198: "our
111
+ * emojis seem a lot smaller").
112
+ * - The sbix PNG is a full square bitmap (the emoji fills a square em), so
113
+ * drawing it into an advance × advance box reproduces both full-bleed emoji
114
+ * and ones with transparent margins (e.g. 📈) without distortion.
115
+ *
116
+ * Geometry:
117
+ * - Horizontal: the bitmap sits flush at the advance's left (`rect.x`); any
118
+ * letter-spacing pads to the RIGHT, so no horizontal shift is applied
119
+ * (DM-919 — the original DM-381 centering pass wrongly shifted it right
120
+ * whenever letter-spacing > 0).
121
+ * - Vertical: the square is centered in the captured rect's line box, which
122
+ * matches Chrome within ~1px across sizes (DM-438 — a 20×17 rect extends
123
+ * upward to a 20×20 square; DM-801 — a 56×63 rect with 8px letter-spacing
124
+ * snaps to 48×48).
125
+ */
126
+ export function emojiSquareRect(rect, letterSpacing) {
127
+ const ls = Math.max(0, letterSpacing) || 0;
128
+ const side = Math.max(1, rect.width - ls);
129
+ return {
130
+ x: rect.x,
131
+ y: rect.y + (rect.height - side) / 2,
132
+ width: side,
133
+ height: side,
134
+ };
135
+ }
100
136
  /**
101
137
  * For every text segment CAPTURE_SCRIPT flagged with a rasterRect (contains a
102
138
  * color-bitmap glyph like U+2713 ✓ or an emoji), ask Playwright for a
@@ -178,28 +214,12 @@ export async function rasterizeBitmapGlyphs(page, tree, viewport) {
178
214
  // isn't carried on the segment (only the SVG path needs it,
179
215
  // not the existing screenshot path which already round-trips
180
216
  // a rectangular PNG).
181
- const elFs = parseFloat(el.styles.fontSize ?? "") || 0;
182
- const fs = seg.fontSize ?? (elFs > 0 ? elFs : Math.max(g.rect.width, g.rect.height));
183
- // DM-919: Chrome's per-emoji paint origin = advance start
184
- // (rect.x) when there's NO letter-spacing — the bitmap
185
- // sits flush at the left of the advance. When letter-
186
- // spacing > 0, Chrome ADDS the letter-spacing to the
187
- // advance, which captures as a wider rect — the bitmap
188
- // is still at rect.x (the spacing pads to the right).
189
- // The original DM-381 centering pass mis-handled this:
190
- // it shifted the bitmap by `(rect.width - fs) / 2`,
191
- // moving the emoji right whenever letter-spacing >0.
192
- // Pull out the captured letter-spacing and subtract it
193
- // from rect.width FIRST, then center the bitmap inside
194
- // the REAL advance — handles both the centered emoji-
195
- // alone case (where rect.w ≈ fs) and the letter-spaced
196
- // case (where rect.w = fs + letter-spacing).
197
217
  const ls = parseFloat(el.styles.letterSpacing ?? "") || 0;
198
- const advanceW = Math.max(fs, g.rect.width - Math.max(0, ls));
199
- g.rect.x += (advanceW - fs) / 2;
200
- g.rect.y += (g.rect.height - fs) / 2;
201
- g.rect.width = fs;
202
- g.rect.height = fs;
218
+ const sq = emojiSquareRect(g.rect, ls);
219
+ g.rect.x = sq.x;
220
+ g.rect.y = sq.y;
221
+ g.rect.width = sq.width;
222
+ g.rect.height = sq.height;
203
223
  continue;
204
224
  }
205
225
  }
@@ -57,13 +57,20 @@ export const createDottedCircleDetect = () => {
57
57
  }
58
58
  return { cnt, w: cnt > 0 ? (maxx - minx + 1) : 0 };
59
59
  };
60
- // Does Chrome auto-insert a U+25CC before this lone mark in `font`?
60
+ // Does Chrome auto-insert a U+25CC before this lone mark/cluster-letter in
61
+ // `font`? Probes category M (combining marks), category Lo (some Brahmic
62
+ // cluster-initial LETTERS — e.g. Soyombo U+11A84) AND category Lm (modifier
63
+ // LETTERS the Universal Shaping Engine also circles when orphaned — e.g. Kirat
64
+ // Rai U+16D6B/6C, length / vowel modifiers that paint "◌ □" when stranded).
65
+ // The ink heuristic below is the real gate (a normal letter renders WITHOUT a
66
+ // circle, so bare ≠ comb → false), so including Lo / Lm only widens what's
67
+ // probed, never forces a false positive.
61
68
  const markGetsDottedCircle = (cp, ch, font) => {
62
69
  if (cp < 0x0900)
63
70
  return false;
64
71
  if (font == null || font === '')
65
72
  return false;
66
- if (!/\p{M}/u.test(ch))
73
+ if (!/\p{M}|\p{Lo}|\p{Lm}/u.test(ch))
67
74
  return false;
68
75
  const key = cp + '|' + font;
69
76
  const hit = _cache.get(key);
@@ -30,11 +30,20 @@ export const createEmojiDetect = () => {
30
30
  // painting as a dropped/empty path-mode glyph: ✅ ✊ ✋. Unconditional like
31
31
  // the ✨ ❌ ➡ family above — emoji presentation wins over the cascade.
32
32
  0x2705, 0x270A, 0x270B,
33
- // DM-728: U+2B?? "Miscellaneous Symbols and Arrows" block with default
34
- // emoji presentation per Unicode emoji-data Chrome paints these as
35
- // Apple Color Emoji glyphs without needing the U+FE0F variation
36
- // selector. The fixture's U+2B50 in `20-deep-font-palette.html` was
37
- // painting as a hollow tofu before this list was extended.
33
+ ]);
34
+ // DM-1165: the Miscellaneous Symbols and Arrows (U+2B??) code points with
35
+ // default emoji presentation ⬅⬆⬇ (2B05-07), ⬛⬜ (2B1B/1C), ⭐ (2B50),
36
+ // ⭕ (2B55). DM-728 added these to `rasterCps` unconditionally for the ⭐ in
37
+ // `20-deep-font-palette.html`, but Chrome's choice is actually CASCADE-
38
+ // DEPENDENT: when the element's font stack reaches a monochrome symbol/math
39
+ // font that covers them first, Chrome paints text, not color. Verified via
40
+ // `CSS.getPlatformFontsForNode` on the 2B00 fixture (cells lead with "Apple
41
+ // Symbols"): 2B05→Apple Symbols, 2B1B & 2B50→STIX Two Math — all MONOCHROME,
42
+ // so the unconditional raster was stamping blue emoji / a yellow star over
43
+ // Chrome's black arrows / hollow star. Probe per-element via `isColorGlyph`
44
+ // in `needsRaster` instead (color → raster, monochrome → path). A Set so the
45
+ // membership test in `needsRaster` is O(1).
46
+ const emojiPresentation2B = new Set([
38
47
  0x2B05, 0x2B06, 0x2B07, 0x2B1B, 0x2B1C, 0x2B50, 0x2B55,
39
48
  ]);
40
49
  // Codepoints in the U+2600-26FF Misc Symbols block with EmojiPresentation=Yes
@@ -200,6 +209,43 @@ export const createEmojiDetect = () => {
200
209
  // cascade DOES reach the color font still rasters correctly.
201
210
  if (cp >= 0x1F700 && cp <= 0x1F77F)
202
211
  return isColorGlyph(cp, font);
212
+ // DM-1168: the two Emoji_Presentation=Yes code points in the Enclosed CJK
213
+ // Letters and Months block (U+3200-32FF) — ㊗ U+3297 CIRCLED IDEOGRAPH
214
+ // CONGRATULATION and ㊙ U+3299 CIRCLED IDEOGRAPH SECRET. Chrome paints them
215
+ // as Apple Color Emoji by default (the fixture cells show the red circled
216
+ // ideographs). But several macOS text fonts (Hiragino, Arial Unicode) also
217
+ // cover them with a MONOCHROME glyph, so a `lang=ja` cascade that reaches
218
+ // Hiragino first paints text, not color. Probe Chrome's actual per-element
219
+ // choice via the canvas (color → raster, monochrome → path), exactly like
220
+ // the DM-1025 emojiPresentation26 / DM-1125 alchemical branches.
221
+ if (cp === 0x3297 || cp === 0x3299)
222
+ return isColorGlyph(cp, font);
223
+ // DM-1173: 〽 U+303D PART ALTERNATION MARK (CJK Symbols and Punctuation,
224
+ // U+3000-303F). Emoji=Yes but text-default presentation, so Chrome paints
225
+ // the color glyph only when the cascade reaches Apple Color Emoji and no
226
+ // text font covers it first (many do — Hiragino, M+ 1p, Shippori Mincho).
227
+ // The fixture cell paints the orange color mark, so probe per-element font
228
+ // (color → raster, monochrome → path) like the branches above.
229
+ if (cp === 0x303D)
230
+ return isColorGlyph(cp, font);
231
+ // DM-1165: the U+2B?? emoji-presentation symbols (arrows ⬅⬆⬇, squares ⬛⬜,
232
+ // ⭐, ⭕). Cascade-dependent — Chrome paints text when the stack reaches a
233
+ // monochrome symbol/math font first (Apple Symbols / STIX Two Math), color
234
+ // otherwise. See `emojiPresentation2B` above.
235
+ if (emojiPresentation2B.has(cp))
236
+ return isColorGlyph(cp, font);
237
+ // DM-1167: the ONLY two codepoints in the Misc Symbols & Pictographs block
238
+ // (U+1F300-1F5FF) that a macOS text font also covers monochrome are
239
+ // 🌐 U+1F310 (GLOBE WITH MERIDIANS) and 🎤 U+1F3A4 (MICROPHONE) — Apple
240
+ // Symbols carries both. When the element's cascade leads with Apple Symbols
241
+ // (the html-test `.f1` cells do; CSS.getPlatformFontsForNode confirms),
242
+ // Chrome paints the MONOCHROME path glyph, not the color emoji. Probe
243
+ // Chrome's actual per-element choice (color → raster, monochrome → path)
244
+ // exactly like the DM-1125 Alchemical / DM-1168 ㊗㊙ branches, instead of
245
+ // unconditionally stamping the Apple Color Emoji bitmap over Chrome's text
246
+ // glyph. The common Apple-Color-Emoji-first cell still rasters (probe → true).
247
+ if (cp === 0x1F310 || cp === 0x1F3A4)
248
+ return isColorGlyph(cp, font);
203
249
  // Main emoji blocks: Misc Symbols & Pictographs, Emoticons, Transport &
204
250
  // Map, Alchemical, Supplemental Symbols & Pictographs, Pictographs
205
251
  // Extended-A, Symbols & Pictographs Extended-B.