domotion-svg 0.2.2 → 0.3.1
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/FEATURES.md +1 -0
- package/README.md +29 -0
- package/dist/animation/animator.js +25 -14
- package/dist/animation/animator.test.js +54 -21
- package/dist/animation/cursor-overlay.js +0 -2
- package/dist/capture/emoji.js +29 -18
- package/dist/capture/index.js +5 -4
- package/dist/capture/script/color-norm.d.ts +1 -0
- package/dist/capture/script/color-norm.js +43 -1
- package/dist/capture/script/emoji-detect.js +14 -0
- package/dist/capture/script/index.js +593 -65
- package/dist/capture/script/walker/borders-backgrounds.d.ts +24 -17
- package/dist/capture/script/walker/borders-backgrounds.js +123 -7
- package/dist/capture/script/walker/counter-style-resolver.d.ts +7 -0
- package/dist/capture/script/walker/counter-style-resolver.js +218 -0
- package/dist/capture/script/walker/input-value.js +14 -1
- package/dist/capture/script/walker/lists-counters.d.ts +3 -1
- package/dist/capture/script/walker/lists-counters.js +22 -2
- package/dist/capture/script/walker/masks-clips.d.ts +2 -0
- package/dist/capture/script/walker/masks-clips.js +41 -1
- package/dist/capture/script/walker/pseudo-content.d.ts +14 -1
- package/dist/capture/script/walker/pseudo-content.js +301 -61
- package/dist/capture/script/walker/pseudo-inject.js +20 -0
- package/dist/capture/script/walker/text-segments.js +98 -4
- package/dist/capture/script/walker/transforms.d.ts +1 -0
- package/dist/capture/script/walker/transforms.js +16 -0
- package/dist/capture/script.generated.js +1 -1
- package/dist/capture/types.d.ts +213 -2
- package/dist/cli/animate.js +151 -15
- package/dist/mask.test.js +12 -7
- package/dist/render/borders.d.ts +9 -13
- package/dist/render/borders.js +379 -14
- package/dist/render/element-tree-to-svg.d.ts +11 -12
- package/dist/render/element-tree-to-svg.js +2046 -241
- package/dist/render/embedded-font-builder.d.ts +49 -0
- package/dist/render/embedded-font-builder.js +149 -0
- package/dist/render/form-controls.js +45 -24
- package/dist/render/gradients.d.ts +15 -0
- package/dist/render/gradients.js +103 -2
- package/dist/render/gradients.test.js +34 -0
- package/dist/render/text-to-path.d.ts +38 -1
- package/dist/render/text-to-path.js +654 -29
- package/dist/render/text-to-path.test.js +230 -9
- package/dist/render/text.d.ts +14 -0
- package/dist/render/text.js +344 -40
- package/dist/scroll/composer.d.ts +26 -0
- package/dist/scroll/composer.js +199 -11
- package/dist/scroll/composer.test.js +293 -16
- package/dist/scroll/executor.d.ts +3 -1
- package/dist/scroll/executor.js +15 -6
- package/dist/scroll/executor.test.js +25 -0
- package/dist/scroll/hoist-fixed.d.ts +48 -0
- package/dist/scroll/hoist-fixed.js +85 -0
- package/dist/scroll/hoist-fixed.test.d.ts +1 -0
- package/dist/scroll/hoist-fixed.test.js +103 -0
- package/dist/scroll/hoist-sticky.d.ts +45 -0
- package/dist/scroll/hoist-sticky.js +157 -0
- package/dist/scroll/hoist-sticky.test.d.ts +1 -0
- package/dist/scroll/hoist-sticky.test.js +154 -0
- package/dist/scroll/pattern.d.ts +22 -5
- package/dist/scroll/pattern.js +55 -7
- package/dist/scroll/pattern.test.js +48 -1
- package/dist/tree-ops/frame-merge.d.ts +10 -0
- package/dist/tree-ops/frame-merge.js +23 -5
- package/dist/tree-ops/frame-merge.test.js +45 -0
- package/dist/tree-ops/tree-diff.js +1 -1
- package/dist/tree-ops/viewbox-culling.js +32 -18
- package/dist/tree-ops/viewbox-culling.test.js +40 -6
- package/package.json +7 -2
- package/src/animation/animator.test.ts +56 -21
- package/src/animation/animator.ts +25 -14
- package/src/animation/cursor-overlay.ts +0 -2
- package/src/capture/emoji.ts +28 -18
- package/src/capture/index.ts +15 -14
- package/src/capture/script/color-norm.ts +38 -1
- package/src/capture/script/emoji-detect.ts +14 -0
- package/src/capture/script/index.ts +555 -48
- package/src/capture/script/walker/borders-backgrounds.ts +114 -7
- package/src/capture/script/walker/counter-style-resolver.ts +184 -0
- package/src/capture/script/walker/input-value.ts +14 -1
- package/src/capture/script/walker/lists-counters.ts +24 -2
- package/src/capture/script/walker/masks-clips.ts +40 -1
- package/src/capture/script/walker/pseudo-content.ts +297 -55
- package/src/capture/script/walker/pseudo-inject.ts +20 -0
- package/src/capture/script/walker/text-segments.ts +93 -4
- package/src/capture/script/walker/transforms.ts +14 -0
- package/src/capture/script.generated.ts +1 -1
- package/src/capture/types.ts +202 -2
- package/src/cli/animate.ts +135 -15
- package/src/mask.test.ts +12 -7
- package/src/render/borders.ts +383 -17
- package/src/render/element-tree-to-svg.ts +2051 -238
- package/src/render/embedded-font-builder.ts +221 -0
- package/src/render/form-controls.ts +45 -24
- package/src/render/gradients.test.ts +46 -0
- package/src/render/gradients.ts +94 -2
- package/src/render/opentype.js.d.ts +7 -0
- package/src/render/text-to-path.test.ts +246 -9
- package/src/render/text-to-path.ts +702 -31
- package/src/render/text.ts +344 -40
- package/src/scroll/composer.test.ts +322 -16
- package/src/scroll/composer.ts +246 -13
- package/src/scroll/executor.test.ts +27 -0
- package/src/scroll/executor.ts +19 -10
- package/src/scroll/hoist-fixed.test.ts +117 -0
- package/src/scroll/hoist-fixed.ts +95 -0
- package/src/scroll/hoist-sticky.test.ts +173 -0
- package/src/scroll/hoist-sticky.ts +193 -0
- package/src/scroll/pattern.test.ts +58 -1
- package/src/scroll/pattern.ts +71 -8
- package/src/tree-ops/frame-merge.test.ts +51 -0
- package/src/tree-ops/frame-merge.ts +24 -6
- package/src/tree-ops/tree-diff.ts +3 -1
- package/src/tree-ops/viewbox-culling.test.ts +42 -6
- package/src/tree-ops/viewbox-culling.ts +32 -18
package/FEATURES.md
CHANGED
|
@@ -26,6 +26,7 @@ Each feature has a visual regression test that compares HTML-to-PNG with SVG-to-
|
|
|
26
26
|
- [x] **border-solid**: Solid borders with color
|
|
27
27
|
- [x] **border-radius**: Rounded corners
|
|
28
28
|
- [x] **border-radius-pill**: Fully rounded (pill shape)
|
|
29
|
+
- [x] **inline-box-decoration-break**: wrapped inline backgrounds / borders paint per line-fragment (`slice` + `clone`); first/last fragment own the start/end edges in slice mode, every fragment paints a full box in clone mode
|
|
29
30
|
|
|
30
31
|
### Layout
|
|
31
32
|
- [x] **layout-flex-row**: Horizontal flex layout with gap
|
package/README.md
CHANGED
|
@@ -36,6 +36,35 @@ yourself to keep the first job's runtime down.
|
|
|
36
36
|
|
|
37
37
|
## Usage
|
|
38
38
|
|
|
39
|
+
The fastest way in is the `domotion` CLI — no TypeScript, no Playwright bring-up. Point it at a URL or HTML file:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
# Capture a URL as SVG.
|
|
43
|
+
domotion capture https://example.com -o example.svg
|
|
44
|
+
|
|
45
|
+
# Capture a local HTML file at a specific viewport, only the .hero region, optimized.
|
|
46
|
+
domotion capture ./demo.html \
|
|
47
|
+
--width 1200 --height 600 \
|
|
48
|
+
--selector ".hero" \
|
|
49
|
+
--optimize \
|
|
50
|
+
-o hero.svg
|
|
51
|
+
|
|
52
|
+
# Capture HTML piped on stdin.
|
|
53
|
+
cat demo.html | domotion capture - -o demo.svg
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
For a multi-frame animated SVG, write a small JSON config and run `domotion animate`:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
domotion animate ./demo.json
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
The config describes each frame (input URL or HTML file, duration, transition, optional pre-capture actions like `click` / `fill` / `scroll` / `hover`). See `domotion --help` for the full grammar and the [Quick start](https://brianwestphal.github.io/domotion/start/quickstart/) for a walkthrough.
|
|
63
|
+
|
|
64
|
+
### Scripting API
|
|
65
|
+
|
|
66
|
+
When you outgrow the CLI — custom interaction loops, programmatic frame composition, custom overlays — the same primitives are available as a library:
|
|
67
|
+
|
|
39
68
|
```ts
|
|
40
69
|
import { captureElementTree, elementTreeToSvg, launchChromium, wrapSvg } from "domotion-svg";
|
|
41
70
|
|
|
@@ -137,15 +137,22 @@ export function generateAnimatedSvg(config) {
|
|
|
137
137
|
const beforeStart = Math.max(0, startNum - 0.001).toFixed(3);
|
|
138
138
|
const afterEnd = Math.min(100, endNum + 0.001).toFixed(3);
|
|
139
139
|
// DM-599: cut already uses step-end on the opacity animation, so we
|
|
140
|
-
// fold
|
|
140
|
+
// fold visibility into the same keyframes block — both snap together.
|
|
141
|
+
// DM-641: this used to toggle `display`. The base `.f { display: none }`
|
|
142
|
+
// rule kept the element out of the render tree at t=0, and Chromium
|
|
143
|
+
// doesn't tick infinite animations on out-of-tree elements — so the
|
|
144
|
+
// 0% keyframe never ran and the frame stayed permanently hidden.
|
|
145
|
+
// Switching to `visibility` leaves the element in the render tree
|
|
146
|
+
// (still skips painting, which was the DM-599 goal) so the animation
|
|
147
|
+
// ticks normally.
|
|
141
148
|
keyframes.push(`
|
|
142
149
|
@keyframes fv-${i} {
|
|
143
|
-
0% { opacity: 0;
|
|
144
|
-
${beforeStart}% { opacity: 0;
|
|
145
|
-
${startNum.toFixed(3)}% { opacity: 1;
|
|
146
|
-
${endNum.toFixed(3)}% { opacity: 1;
|
|
147
|
-
${afterEnd}% { opacity: 0;
|
|
148
|
-
100% { opacity: 0;
|
|
150
|
+
0% { opacity: 0; visibility: hidden; }
|
|
151
|
+
${beforeStart}% { opacity: 0; visibility: hidden; }
|
|
152
|
+
${startNum.toFixed(3)}% { opacity: 1; visibility: visible; }
|
|
153
|
+
${endNum.toFixed(3)}% { opacity: 1; visibility: visible; }
|
|
154
|
+
${afterEnd}% { opacity: 0; visibility: hidden; }
|
|
155
|
+
100% { opacity: 0; visibility: hidden; }
|
|
149
156
|
}
|
|
150
157
|
.f-${i} { animation: fv-${i} ${totalSec.toFixed(2)}s infinite; animation-timing-function: step-end; }`);
|
|
151
158
|
}
|
|
@@ -216,7 +223,7 @@ export function generateAnimatedSvg(config) {
|
|
|
216
223
|
</defs>
|
|
217
224
|
<style>
|
|
218
225
|
:root { --scene-dur: ${totalSec.toFixed(2)}s; }
|
|
219
|
-
.f { opacity: 0;
|
|
226
|
+
.f { opacity: 0; visibility: hidden; }
|
|
220
227
|
${keyframes.join("\n")}${animationCss}${cullCss === "" ? "" : "\n" + cullCss}
|
|
221
228
|
</style>
|
|
222
229
|
<g clip-path="url(#viewport-clip)">
|
|
@@ -314,18 +321,22 @@ function pct(ms, total) {
|
|
|
314
321
|
* latter and the unmerged-path keyframes feed either form.
|
|
315
322
|
*/
|
|
316
323
|
function buildDisplayKeyframes(name, visibleStartPct, visibleEndPct) {
|
|
324
|
+
// DM-641: kept the function name for callers but the toggle is now on
|
|
325
|
+
// `visibility`, not `display`, for the same reason as `fv-${i}` above —
|
|
326
|
+
// animating `display` away from an element starting `display: none` never
|
|
327
|
+
// ticks in Chromium.
|
|
317
328
|
const start = parseFloat(String(visibleStartPct));
|
|
318
329
|
const end = parseFloat(String(visibleEndPct));
|
|
319
330
|
const startMinus = Math.max(0, start - 0.01).toFixed(3);
|
|
320
331
|
const endPlus = Math.min(100, end + 0.01).toFixed(3);
|
|
321
332
|
return `
|
|
322
333
|
@keyframes ${name} {
|
|
323
|
-
0% {
|
|
324
|
-
${startMinus}% {
|
|
325
|
-
${start.toFixed(3)}% {
|
|
326
|
-
${end.toFixed(3)}% {
|
|
327
|
-
${endPlus}% {
|
|
328
|
-
100% {
|
|
334
|
+
0% { visibility: hidden; }
|
|
335
|
+
${startMinus}% { visibility: hidden; }
|
|
336
|
+
${start.toFixed(3)}% { visibility: visible; }
|
|
337
|
+
${end.toFixed(3)}% { visibility: visible; }
|
|
338
|
+
${endPlus}% { visibility: hidden; }
|
|
339
|
+
100% { visibility: hidden; }
|
|
329
340
|
}`;
|
|
330
341
|
}
|
|
331
342
|
/**
|
|
@@ -172,12 +172,15 @@ describe("animator", () => {
|
|
|
172
172
|
expect(svg).toContain("transform: translateY(240px)");
|
|
173
173
|
expect(svg).toContain("transform: translateY(0px)");
|
|
174
174
|
});
|
|
175
|
-
it("DM-599: push-left frame gets a paired fd-N
|
|
175
|
+
it("DM-599/DM-641: push-left frame gets a paired fd-N visibility animation alongside fv-N", () => {
|
|
176
176
|
// push-left is unmergeable (the merge fast path only takes crossfade/cut),
|
|
177
177
|
// so it goes through the unmerged emit path that emits per-frame fv-/fp-
|
|
178
178
|
// blocks. The DM-599 optimization adds an fd-N keyframes block that
|
|
179
|
-
// toggles
|
|
180
|
-
// outside its show window.
|
|
179
|
+
// toggles a paint-skip property so the frame is dropped from paint
|
|
180
|
+
// outside its show window. DM-641 changed that toggle from `display`
|
|
181
|
+
// (which parks the CSS-animations engine when an element starts out of
|
|
182
|
+
// the render tree) to `visibility` (which keeps the element rendered
|
|
183
|
+
// but skips painting it).
|
|
181
184
|
const svg = generateAnimatedSvg({
|
|
182
185
|
width: 100, height: 100,
|
|
183
186
|
frames: [
|
|
@@ -190,17 +193,20 @@ describe("animator", () => {
|
|
|
190
193
|
expect(svg).toMatch(/@keyframes fd-1\s*{/);
|
|
191
194
|
// … alongside the existing fv-N opacity block.
|
|
192
195
|
expect(svg).toMatch(/@keyframes fv-0\s*{/);
|
|
193
|
-
// The keyframes flip
|
|
194
|
-
expect(svg).toMatch(/
|
|
195
|
-
expect(svg).toMatch(/
|
|
196
|
+
// The keyframes flip visibility (DM-641 — formerly display).
|
|
197
|
+
expect(svg).toMatch(/visibility:\s*hidden/);
|
|
198
|
+
expect(svg).toMatch(/visibility:\s*visible/);
|
|
199
|
+
// DM-641: must NOT emit display toggles anywhere — those broke the
|
|
200
|
+
// infinite animation in Chromium.
|
|
201
|
+
expect(svg).not.toMatch(/display:\s*none/);
|
|
202
|
+
expect(svg).not.toMatch(/display:\s*inline/);
|
|
196
203
|
// The frame's CSS rule lists BOTH animations, with the fd one tagged
|
|
197
|
-
// step-end so the
|
|
204
|
+
// step-end so the visibility flip is instant.
|
|
198
205
|
expect(svg).toMatch(/\.f-0\s*{\s*animation:[^}]*fv-0[^}]*,[^}]*fd-0[^}]*step-end/);
|
|
199
|
-
// The base .f rule sets display:none
|
|
200
|
-
|
|
201
|
-
expect(svg).toMatch(/\.f\s*{[^}]*display:\s*none/);
|
|
206
|
+
// The base .f rule sets visibility:hidden (was display:none pre-DM-641).
|
|
207
|
+
expect(svg).toMatch(/\.f\s*{[^}]*visibility:\s*hidden/);
|
|
202
208
|
});
|
|
203
|
-
it("DM-599: cut frames fold
|
|
209
|
+
it("DM-599/DM-641: cut frames fold visibility into fv-N (same step-end timing)", () => {
|
|
204
210
|
// Three explicit `cut` frames — the all-mergeable check trips and these
|
|
205
211
|
// route through the MERGE pipeline. But a non-mergeable transition mixed
|
|
206
212
|
// in (e.g. push-left) would route this through the unmerged path. We
|
|
@@ -213,19 +219,19 @@ describe("animator", () => {
|
|
|
213
219
|
{ svgContent: `<rect fill="green"/>`, duration: 1000 },
|
|
214
220
|
],
|
|
215
221
|
});
|
|
216
|
-
// The "cut" frame's fv-N keyframes carry the
|
|
217
|
-
// separate fd-N block) since cut already uses step-end on fv-N.
|
|
222
|
+
// The "cut" frame's fv-N keyframes carry the visibility toggle inline
|
|
223
|
+
// (no separate fd-N block) since cut already uses step-end on fv-N.
|
|
218
224
|
const fv1Match = svg.match(/@keyframes fv-1\s*{[\s\S]*?\n\s*}/);
|
|
219
225
|
expect(fv1Match).not.toBeNull();
|
|
220
|
-
expect(fv1Match[0]).toMatch(/
|
|
221
|
-
expect(fv1Match[0]).toMatch(/
|
|
226
|
+
expect(fv1Match[0]).toMatch(/visibility:\s*hidden/);
|
|
227
|
+
expect(fv1Match[0]).toMatch(/visibility:\s*visible/);
|
|
222
228
|
// The "cut" frame uses ONLY fv-1 (no fd-1 — it's folded in).
|
|
223
229
|
expect(svg).not.toMatch(/@keyframes fd-1\s*{/);
|
|
224
230
|
});
|
|
225
|
-
it("DM-599: merged-path keyframes emit
|
|
231
|
+
it("DM-599/DM-641: merged-path keyframes emit visibility alongside opacity", () => {
|
|
226
232
|
// Two crossfade frames with different content route through the merge
|
|
227
233
|
// pipeline. Per-element visibility classes (tN) now toggle BOTH opacity
|
|
228
|
-
// and
|
|
234
|
+
// and visibility so the browser can skip painting hidden elements.
|
|
229
235
|
const svg = generateAnimatedSvg({
|
|
230
236
|
width: 100, height: 100,
|
|
231
237
|
frames: [
|
|
@@ -233,12 +239,39 @@ describe("animator", () => {
|
|
|
233
239
|
{ svgContent: `<rect fill="blue" width="50" height="50"/>`, duration: 1000 },
|
|
234
240
|
],
|
|
235
241
|
});
|
|
236
|
-
// Each tN keyframe stop with opacity:1 also has
|
|
237
|
-
// opacity:0 stop has
|
|
242
|
+
// Each tN keyframe stop with opacity:1 also has visibility:visible; each
|
|
243
|
+
// opacity:0 stop has visibility:hidden.
|
|
238
244
|
const tN = svg.match(/@keyframes t\d+\s*{[\s\S]*?\n\s*}/);
|
|
239
245
|
expect(tN).not.toBeNull();
|
|
240
|
-
expect(tN[0]).toMatch(/opacity:\s*1;\s*
|
|
241
|
-
expect(tN[0]).toMatch(/opacity:\s*0;\s*
|
|
246
|
+
expect(tN[0]).toMatch(/opacity:\s*1;\s*visibility:\s*visible/);
|
|
247
|
+
expect(tN[0]).toMatch(/opacity:\s*0;\s*visibility:\s*hidden/);
|
|
248
|
+
});
|
|
249
|
+
it("DM-641: never emits `display: none` keyframes (would park Chromium's animation engine)", () => {
|
|
250
|
+
// Regression. The repro from the ticket: a multi-frame animation with
|
|
251
|
+
// `cut` transitions produced `@keyframes fv-0 { 0% { opacity:0; display:none } … }`
|
|
252
|
+
// plus `.f { display: none }`, which Chromium would never tick — so
|
|
253
|
+
// EVERY frame stayed permanently hidden when the SVG was loaded into a
|
|
254
|
+
// browser. The fix swapped both sites onto `visibility`. This test pins
|
|
255
|
+
// the fix on every code path that emits keyframes for the animator.
|
|
256
|
+
const cutSvg = generateAnimatedSvg({
|
|
257
|
+
width: 100, height: 100,
|
|
258
|
+
frames: [
|
|
259
|
+
{ svgContent: `<rect/>`, duration: 1000, transition: { type: "cut", duration: 0 } },
|
|
260
|
+
{ svgContent: `<rect/>`, duration: 1000, transition: { type: "cut", duration: 0 } },
|
|
261
|
+
{ svgContent: `<rect/>`, duration: 1000 },
|
|
262
|
+
],
|
|
263
|
+
});
|
|
264
|
+
expect(cutSvg).not.toMatch(/display:\s*none/);
|
|
265
|
+
expect(cutSvg).not.toMatch(/display:\s*inline/);
|
|
266
|
+
const pushSvg = generateAnimatedSvg({
|
|
267
|
+
width: 100, height: 100,
|
|
268
|
+
frames: [
|
|
269
|
+
{ svgContent: `<rect/>`, duration: 1000, transition: { type: "push-left", duration: 100 } },
|
|
270
|
+
{ svgContent: `<rect/>`, duration: 1000 },
|
|
271
|
+
],
|
|
272
|
+
});
|
|
273
|
+
expect(pushSvg).not.toMatch(/display:\s*none/);
|
|
274
|
+
expect(pushSvg).not.toMatch(/display:\s*inline/);
|
|
242
275
|
});
|
|
243
276
|
it("cut transition: timeline boundary is exactly at the frame edge", () => {
|
|
244
277
|
// For two frames each held 1000ms with cut transitions and no overlap,
|
|
@@ -114,13 +114,11 @@ function resolveMoveTarget(ev, curX, curY, frameIndex, resolveSelector) {
|
|
|
114
114
|
return { x: curX + ev.by.dx, y: curY + ev.by.dy };
|
|
115
115
|
if (ev.selector != null) {
|
|
116
116
|
if (resolveSelector == null) {
|
|
117
|
-
// eslint-disable-next-line no-console
|
|
118
117
|
console.warn(`cursor-overlay: selector "${ev.selector}" used but no resolveSelector provided; skipping`);
|
|
119
118
|
return null;
|
|
120
119
|
}
|
|
121
120
|
const rect = resolveSelector(ev.selector, frameIndex);
|
|
122
121
|
if (rect == null) {
|
|
123
|
-
// eslint-disable-next-line no-console
|
|
124
122
|
console.warn(`cursor-overlay: selector "${ev.selector}" matched no element in frame ${frameIndex}; skipping`);
|
|
125
123
|
return null;
|
|
126
124
|
}
|
package/dist/capture/emoji.js
CHANGED
|
@@ -31,7 +31,12 @@ function loadAppleColorEmojiFont() {
|
|
|
31
31
|
return null;
|
|
32
32
|
try {
|
|
33
33
|
const opened = fontkit.openSync(APPLE_COLOR_EMOJI_PATH);
|
|
34
|
-
|
|
34
|
+
if (opened == null) {
|
|
35
|
+
_aceFont = null;
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
_aceFont = opened.fonts != null ? opened.fonts[0] : opened;
|
|
39
|
+
}
|
|
35
40
|
}
|
|
36
41
|
catch {
|
|
37
42
|
_aceFont = null;
|
|
@@ -139,23 +144,29 @@ export async function rasterizeBitmapGlyphs(page, tree, viewport) {
|
|
|
139
144
|
const sbixPng = extractEmojiBitmap(cp, g.rect.width);
|
|
140
145
|
if (sbixPng != null) {
|
|
141
146
|
g.dataUri = `data:image/png;base64,${sbixPng.toString("base64")}`;
|
|
142
|
-
// sbix bitmaps are square
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
147
|
+
// sbix bitmaps are square (em-square sized). Chrome paints
|
|
148
|
+
// them centered horizontally on the glyph advance and bottom-
|
|
149
|
+
// aligned to the line-box. The captured rect spans the
|
|
150
|
+
// typographic line-box (advance × line-height), which is
|
|
151
|
+
// bigger than the em-square on either axis when letter-spacing
|
|
152
|
+
// or line-height add slack. DM-438: smiley rendered 20×17 in a
|
|
153
|
+
// 20-wide / 17-tall rect (height shorter than width) — fixed
|
|
154
|
+
// by extending the rect upward to a 20×20 square. DM-801: at
|
|
155
|
+
// font-size 48 with letter-spacing 8, the rect was 56×63
|
|
156
|
+
// (width INCLUDES letter-spacing, height bigger than em-
|
|
157
|
+
// square), so emoji painted as 56×63 — vertically stretched
|
|
158
|
+
// hearts and wide smileys. Snap to fontSize × fontSize
|
|
159
|
+
// centered horizontally on the rect's advance and bottom-
|
|
160
|
+
// aligned vertically; falls back to max(w,h) when fontSize
|
|
161
|
+
// isn't carried on the segment (only the SVG path needs it,
|
|
162
|
+
// not the existing screenshot path which already round-trips
|
|
163
|
+
// a rectangular PNG).
|
|
164
|
+
const elFs = parseFloat(el.styles.fontSize ?? "") || 0;
|
|
165
|
+
const fs = seg.fontSize ?? (elFs > 0 ? elFs : Math.max(g.rect.width, g.rect.height));
|
|
166
|
+
g.rect.x += (g.rect.width - fs) / 2;
|
|
167
|
+
g.rect.y += (g.rect.height - fs) / 2;
|
|
168
|
+
g.rect.width = fs;
|
|
169
|
+
g.rect.height = fs;
|
|
159
170
|
continue;
|
|
160
171
|
}
|
|
161
172
|
}
|
package/dist/capture/index.js
CHANGED
|
@@ -435,7 +435,7 @@ export async function discoverAndRegisterWebfonts(page, observedFontUrls = []) {
|
|
|
435
435
|
// ran in this scenario registers under fontkit's internal `familyName`,
|
|
436
436
|
// which for license-protected fonts (Sohne) is a copyright string —
|
|
437
437
|
// unmatchable against the CSS-declared `font-family: sohne-var` query.
|
|
438
|
-
for (const sheetUrl of fromPage.crossOriginSheetUrls
|
|
438
|
+
for (const sheetUrl of fromPage.crossOriginSheetUrls) {
|
|
439
439
|
let cssText;
|
|
440
440
|
try {
|
|
441
441
|
const resp = await page.context().request.get(sheetUrl);
|
|
@@ -482,10 +482,9 @@ export async function discoverAndRegisterWebfonts(page, observedFontUrls = []) {
|
|
|
482
482
|
// (e.g. an alias whose width happened to disagree with all candidates
|
|
483
483
|
// due to layout shaping that the simple sample didn't exercise).
|
|
484
484
|
const declaredWeight = parseWeightDescriptor(item.weight);
|
|
485
|
-
const declaredStyle =
|
|
485
|
+
const declaredStyle = item.style.toLowerCase();
|
|
486
486
|
const declaredItalic = declaredStyle !== "" && declaredStyle !== "normal";
|
|
487
|
-
const
|
|
488
|
-
const candidates = resolved != null ? [resolved] : item.localNames;
|
|
487
|
+
const candidates = item.resolvedLocalName != null ? [item.resolvedLocalName] : item.localNames;
|
|
489
488
|
for (const localName of candidates) {
|
|
490
489
|
const key = systemFontKeyForLocalName(localName);
|
|
491
490
|
if (key != null) {
|
|
@@ -592,6 +591,8 @@ async function readFontMetadata(buf) {
|
|
|
592
591
|
const fontkit = await import("fontkit");
|
|
593
592
|
try {
|
|
594
593
|
const f = fontkit.create(buf);
|
|
594
|
+
if (f == null)
|
|
595
|
+
return null;
|
|
595
596
|
const family = f.familyName ?? "";
|
|
596
597
|
if (family === "")
|
|
597
598
|
return null;
|
|
@@ -40,5 +40,47 @@ export const createColorNorm = () => {
|
|
|
40
40
|
catch (e) { /* fall through */ }
|
|
41
41
|
return c;
|
|
42
42
|
};
|
|
43
|
-
|
|
43
|
+
// DM-800: Chromium retains wide-gamut color functions verbatim inside
|
|
44
|
+
// computed gradient stops (e.g. `linear-gradient(90deg, oklch(0.89 0.04
|
|
45
|
+
// 264), …)`). The render-side `parseColor` doesn't speak oklch/lab/lch/
|
|
46
|
+
// oklab/hwb and falls back to black for any stop it can't decode,
|
|
47
|
+
// collapsing the tinted-gradient strip to mostly-black bars. Walk the
|
|
48
|
+
// gradient text and replace each wide-gamut color call with its
|
|
49
|
+
// normColor-resolved form so the renderer only sees rgb()/color(srgb).
|
|
50
|
+
// `color-mix(...)` doesn't appear here because Chromium pre-resolves it
|
|
51
|
+
// inside gradients to its target color space (e.g. `color-mix(in oklch,
|
|
52
|
+
// red, blue)` serializes as `oklch(...)`), but we match it defensively in
|
|
53
|
+
// case future Chromium versions change that.
|
|
54
|
+
const normGradientColors = (text, elColor) => {
|
|
55
|
+
if (text == null || text === '' || text === 'none')
|
|
56
|
+
return text;
|
|
57
|
+
// Match a color-function identifier followed by a balanced (...) group.
|
|
58
|
+
const fnRe = /\b(oklch|oklab|lab|lch|hwb|hsl|hsla|color|color-mix)\(/gi;
|
|
59
|
+
var out = '';
|
|
60
|
+
var i = 0;
|
|
61
|
+
while (i < text.length) {
|
|
62
|
+
fnRe.lastIndex = i;
|
|
63
|
+
const m = fnRe.exec(text);
|
|
64
|
+
if (m == null) {
|
|
65
|
+
out += text.slice(i);
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
out += text.slice(i, m.index);
|
|
69
|
+
// Walk forward consuming balanced parens.
|
|
70
|
+
var depth = 1;
|
|
71
|
+
var j = m.index + m[0].length;
|
|
72
|
+
while (j < text.length && depth > 0) {
|
|
73
|
+
const ch = text[j++];
|
|
74
|
+
if (ch === '(')
|
|
75
|
+
depth++;
|
|
76
|
+
else if (ch === ')')
|
|
77
|
+
depth--;
|
|
78
|
+
}
|
|
79
|
+
const call = text.slice(m.index, j);
|
|
80
|
+
out += normColor(call, elColor);
|
|
81
|
+
i = j;
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
};
|
|
85
|
+
return { normColor, normGradientColors };
|
|
44
86
|
};
|
|
@@ -24,6 +24,12 @@ export const createEmojiDetect = () => {
|
|
|
24
24
|
const rasterCps = new Set([
|
|
25
25
|
0x2713, 0x2714, 0x2716, 0x2717, 0x2728, 0x2753, 0x2754, 0x2755, 0x2757,
|
|
26
26
|
0x274C, 0x274E, 0x2795, 0x2796, 0x2797, 0x27A1, 0x27B0, 0x27BF,
|
|
27
|
+
// DM-728: U+2B?? "Miscellaneous Symbols and Arrows" block with default
|
|
28
|
+
// emoji presentation per Unicode emoji-data — Chrome paints these as
|
|
29
|
+
// Apple Color Emoji glyphs without needing the U+FE0F variation
|
|
30
|
+
// selector. The fixture's ⭐ U+2B50 in `20-deep-font-palette.html` was
|
|
31
|
+
// painting as a hollow tofu before this list was extended.
|
|
32
|
+
0x2B05, 0x2B06, 0x2B07, 0x2B1B, 0x2B1C, 0x2B50, 0x2B55,
|
|
27
33
|
]);
|
|
28
34
|
// Codepoints in the U+2600-26FF Misc Symbols block with EmojiPresentation=Yes
|
|
29
35
|
// per Unicode emoji-data: Chrome paints these as color emoji by default
|
|
@@ -49,6 +55,14 @@ export const createEmojiDetect = () => {
|
|
|
49
55
|
0x2696, 0x2697, 0x2699, 0x269B, 0x269C, 0x26A0, 0x26A7, 0x26B0,
|
|
50
56
|
0x26B1, 0x26C8, 0x26CF, 0x26D1, 0x26D3, 0x26E9, 0x26F0, 0x26F1,
|
|
51
57
|
0x26F4, 0x26F7, 0x26F8, 0x26F9,
|
|
58
|
+
// DM-728: Dingbats block (U+27??) codepoints with text-default
|
|
59
|
+
// presentation that flip to color emoji when paired with U+FE0F. The
|
|
60
|
+
// fixture's ❤️ (U+2764 + U+FE0F) heart was painting as a small black
|
|
61
|
+
// monochrome glyph before this entry was added; with it, the VS-16
|
|
62
|
+
// pairing routes through the raster overlay path so Apple Color Emoji
|
|
63
|
+
// paints the red heart Chrome shows.
|
|
64
|
+
0x2702, 0x2708, 0x2709, 0x270C, 0x270D, 0x270F, 0x2712, 0x2716,
|
|
65
|
+
0x2733, 0x2734, 0x2744, 0x2747, 0x2763, 0x2764,
|
|
52
66
|
]);
|
|
53
67
|
const needsRaster = (cp, nextCp) => {
|
|
54
68
|
if (rasterCps.has(cp))
|