domotion-svg 0.1.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 +102 -0
- package/LICENSE +21 -0
- package/README.md +66 -0
- package/dist/animator.d.ts +158 -0
- package/dist/animator.js +424 -0
- package/dist/animator.test.d.ts +5 -0
- package/dist/animator.test.js +169 -0
- package/dist/border-radius.test.d.ts +1 -0
- package/dist/border-radius.test.js +148 -0
- package/dist/capture.d.ts +193 -0
- package/dist/capture.js +786 -0
- package/dist/chrome.d.ts +45 -0
- package/dist/chrome.js +107 -0
- package/dist/cli.d.ts +16 -0
- package/dist/cli.js +512 -0
- package/dist/client/dom.d.ts +10 -0
- package/dist/client/dom.js +17 -0
- package/dist/conic-raster.d.ts +58 -0
- package/dist/conic-raster.js +292 -0
- package/dist/conic-raster.test.d.ts +1 -0
- package/dist/conic-raster.test.js +187 -0
- package/dist/coretext-extractor.test.d.ts +1 -0
- package/dist/coretext-extractor.test.js +94 -0
- package/dist/coretext-helper.d.ts +60 -0
- package/dist/coretext-helper.js +205 -0
- package/dist/cross-origin-font-face.test.d.ts +1 -0
- package/dist/cross-origin-font-face.test.js +107 -0
- package/dist/cursor-overlay.d.ts +123 -0
- package/dist/cursor-overlay.js +207 -0
- package/dist/cursor-overlay.test.d.ts +1 -0
- package/dist/cursor-overlay.test.js +88 -0
- package/dist/dark-mode-capture.test.d.ts +1 -0
- package/dist/dark-mode-capture.test.js +158 -0
- package/dist/dark-mode-form-controls.test.d.ts +1 -0
- package/dist/dark-mode-form-controls.test.js +218 -0
- package/dist/dom-to-svg.d.ts +1016 -0
- package/dist/dom-to-svg.js +7717 -0
- package/dist/embed-remote-images.test.d.ts +1 -0
- package/dist/embed-remote-images.test.js +424 -0
- package/dist/form-controls.d.ts +70 -0
- package/dist/form-controls.js +1151 -0
- package/dist/frame-merge.d.ts +95 -0
- package/dist/frame-merge.js +374 -0
- package/dist/frame-merge.test.d.ts +6 -0
- package/dist/frame-merge.test.js +144 -0
- package/dist/gradients.d.ts +184 -0
- package/dist/gradients.js +937 -0
- package/dist/gradients.test.d.ts +1 -0
- package/dist/gradients.test.js +150 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +7 -0
- package/dist/jsx-runtime.d.ts +27 -0
- package/dist/jsx-runtime.js +96 -0
- package/dist/jsx-runtime.test.d.ts +1 -0
- package/dist/jsx-runtime.test.js +41 -0
- package/dist/kerfjs-imports.test.d.ts +1 -0
- package/dist/kerfjs-imports.test.js +36 -0
- package/dist/mask.test.d.ts +1 -0
- package/dist/mask.test.js +206 -0
- package/dist/optimize.d.ts +12 -0
- package/dist/optimize.js +32 -0
- package/dist/preserve-aspect-ratio.test.d.ts +1 -0
- package/dist/preserve-aspect-ratio.test.js +38 -0
- package/dist/resize-embedded-images.d.ts +33 -0
- package/dist/resize-embedded-images.js +164 -0
- package/dist/resize-embedded-images.test.d.ts +9 -0
- package/dist/resize-embedded-images.test.js +255 -0
- package/dist/stacking-context.test.d.ts +1 -0
- package/dist/stacking-context.test.js +927 -0
- package/dist/text-renderer.d.ts +42 -0
- package/dist/text-renderer.js +608 -0
- package/dist/text-renderer.test.d.ts +1 -0
- package/dist/text-renderer.test.js +150 -0
- package/dist/text-to-path.d.ts +265 -0
- package/dist/text-to-path.js +1800 -0
- package/dist/text-to-path.test.d.ts +1 -0
- package/dist/text-to-path.test.js +570 -0
- package/dist/utils/escapeHtml.d.ts +2 -0
- package/dist/utils/escapeHtml.js +15 -0
- package/dist/webfont-unicode-range.test.d.ts +1 -0
- package/dist/webfont-unicode-range.test.js +174 -0
- package/package.json +55 -0
- package/src/animator.test.ts +179 -0
- package/src/animator.ts +660 -0
- package/src/border-radius.test.ts +160 -0
- package/src/capture.ts +810 -0
- package/src/cli.ts +582 -0
- package/src/conic-raster.test.ts +213 -0
- package/src/conic-raster.ts +309 -0
- package/src/coretext-extractor.test.ts +130 -0
- package/src/coretext-helper.ts +256 -0
- package/src/cross-origin-font-face.test.ts +119 -0
- package/src/cursor-overlay.test.ts +95 -0
- package/src/cursor-overlay.ts +297 -0
- package/src/dark-mode-capture.test.ts +177 -0
- package/src/dark-mode-form-controls.test.ts +228 -0
- package/src/dom-to-svg.ts +8376 -0
- package/src/embed-remote-images.test.ts +461 -0
- package/src/form-controls.ts +1174 -0
- package/src/frame-merge.test.ts +157 -0
- package/src/frame-merge.ts +447 -0
- package/src/globals.d.ts +2 -0
- package/src/gradients.test.ts +175 -0
- package/src/gradients.ts +955 -0
- package/src/index.ts +12 -0
- package/src/kerf-jsx-augmentation.d.ts +36 -0
- package/src/kerfjs-imports.test.tsx +45 -0
- package/src/mask.test.ts +274 -0
- package/src/optimize.ts +34 -0
- package/src/preserve-aspect-ratio.test.ts +49 -0
- package/src/resize-embedded-images.test.ts +292 -0
- package/src/resize-embedded-images.ts +180 -0
- package/src/stacking-context.test.ts +967 -0
- package/src/text-renderer.test.ts +162 -0
- package/src/text-renderer.ts +623 -0
- package/src/text-to-path.test.ts +639 -0
- package/src/text-to-path.ts +1810 -0
- package/src/utils/escapeHtml.ts +16 -0
- package/src/webfont-unicode-range.test.ts +207 -0
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the `resizeEmbeddedImages` pre-pass spec'd in
|
|
3
|
+
* `docs/27-image-resize-on-embed.md`. Initial smoke coverage landed in
|
|
4
|
+
* DM-539; DM-541 extends it to cover every consumer path
|
|
5
|
+
* (`<img>` / `pseudoImages` / `backgroundImage` / `maskImage` /
|
|
6
|
+
* `borderImageSource` / `listStyleImage`), animated-GIF first-frame
|
|
7
|
+
* collapse, and the disabled-flag byte-identity guarantee.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
11
|
+
import sharp from "sharp";
|
|
12
|
+
import {
|
|
13
|
+
_dataUriCache,
|
|
14
|
+
_resizedDataUriCache,
|
|
15
|
+
embedResizedDataUri,
|
|
16
|
+
type CapturedElement,
|
|
17
|
+
} from "./dom-to-svg.js";
|
|
18
|
+
import { resizeEmbeddedImages } from "./resize-embedded-images.js";
|
|
19
|
+
|
|
20
|
+
// Build a minimal CapturedElement. Only the fields the resize walker reads
|
|
21
|
+
// are populated; everything else is stub-typed.
|
|
22
|
+
function makeImg(url: string, w: number, h: number): CapturedElement {
|
|
23
|
+
return {
|
|
24
|
+
tag: "img",
|
|
25
|
+
text: "",
|
|
26
|
+
x: 0, y: 0, width: w, height: h,
|
|
27
|
+
children: [],
|
|
28
|
+
imageSrc: url,
|
|
29
|
+
styles: {} as CapturedElement["styles"],
|
|
30
|
+
} as CapturedElement;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Helper: synthesize a PNG of the given pixel dims via sharp.
|
|
34
|
+
async function makePngDataUri(w: number, h: number): Promise<string> {
|
|
35
|
+
const buf = await sharp({
|
|
36
|
+
create: { width: w, height: h, channels: 4, background: { r: 200, g: 100, b: 50, alpha: 1 } },
|
|
37
|
+
}).png().toBuffer();
|
|
38
|
+
return `data:image/png;base64,${buf.toString("base64")}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function decodePngBytes(dataUri: string): Buffer {
|
|
42
|
+
const m = /^data:image\/png;base64,(.*)$/s.exec(dataUri);
|
|
43
|
+
if (m == null) throw new Error("not a PNG data URI");
|
|
44
|
+
return Buffer.from(m[1], "base64");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
beforeEach(() => {
|
|
48
|
+
_dataUriCache.clear();
|
|
49
|
+
_resizedDataUriCache.clear();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
afterEach(() => {
|
|
53
|
+
_dataUriCache.clear();
|
|
54
|
+
_resizedDataUriCache.clear();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("DM-539 resizeEmbeddedImages — core pre-pass", () => {
|
|
58
|
+
it("resizes when source is meaningfully larger than target × hiDPI", async () => {
|
|
59
|
+
const url = "https://example.com/big.png";
|
|
60
|
+
_dataUriCache.set(url, await makePngDataUri(1500, 1000));
|
|
61
|
+
const tree = [makeImg(url, 300, 200)];
|
|
62
|
+
await resizeEmbeddedImages(tree, { hiDPIFactor: 2 });
|
|
63
|
+
|
|
64
|
+
// Expected sizeKey: ceil(300 * 2) × ceil(200 * 2) = 600x400
|
|
65
|
+
const sizeCache = _resizedDataUriCache.get(url);
|
|
66
|
+
expect(sizeCache).toBeDefined();
|
|
67
|
+
expect(sizeCache!.has("600x400")).toBe(true);
|
|
68
|
+
const meta = await sharp(decodePngBytes(sizeCache!.get("600x400")!)).metadata();
|
|
69
|
+
// sharp with fit:"inside" keeps aspect; 1500x1000 → 600x400 (no clamp).
|
|
70
|
+
expect(meta.width).toBe(600);
|
|
71
|
+
expect(meta.height).toBe(400);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("passes the source through unchanged when already at-or-below target", async () => {
|
|
75
|
+
const url = "https://example.com/already-small.png";
|
|
76
|
+
const sourceDataUri = await makePngDataUri(600, 400);
|
|
77
|
+
_dataUriCache.set(url, sourceDataUri);
|
|
78
|
+
const tree = [makeImg(url, 300, 200)];
|
|
79
|
+
await resizeEmbeddedImages(tree, { hiDPIFactor: 2 });
|
|
80
|
+
|
|
81
|
+
const sizeCache = _resizedDataUriCache.get(url);
|
|
82
|
+
expect(sizeCache).toBeDefined();
|
|
83
|
+
// Cached entry should be the original bytes (no re-encode).
|
|
84
|
+
expect(sizeCache!.get("600x400")).toBe(sourceDataUri);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("dedupes consumers of the same URL at the same target size", async () => {
|
|
88
|
+
const url = "https://example.com/shared.png";
|
|
89
|
+
_dataUriCache.set(url, await makePngDataUri(1000, 1000));
|
|
90
|
+
const tree = [
|
|
91
|
+
makeImg(url, 100, 100),
|
|
92
|
+
makeImg(url, 100, 100),
|
|
93
|
+
makeImg(url, 100, 100),
|
|
94
|
+
];
|
|
95
|
+
await resizeEmbeddedImages(tree, { hiDPIFactor: 2 });
|
|
96
|
+
|
|
97
|
+
const sizeCache = _resizedDataUriCache.get(url);
|
|
98
|
+
expect(sizeCache).toBeDefined();
|
|
99
|
+
expect(sizeCache!.size).toBe(1); // one entry shared by 3 consumers
|
|
100
|
+
expect(sizeCache!.has("200x200")).toBe(true);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("creates distinct cache entries for distinct target sizes against the same source", async () => {
|
|
104
|
+
const url = "https://example.com/multi.png";
|
|
105
|
+
_dataUriCache.set(url, await makePngDataUri(2000, 2000));
|
|
106
|
+
const tree = [
|
|
107
|
+
makeImg(url, 100, 100),
|
|
108
|
+
makeImg(url, 250, 250),
|
|
109
|
+
];
|
|
110
|
+
await resizeEmbeddedImages(tree, { hiDPIFactor: 2 });
|
|
111
|
+
|
|
112
|
+
const sizeCache = _resizedDataUriCache.get(url);
|
|
113
|
+
expect(sizeCache).toBeDefined();
|
|
114
|
+
expect(sizeCache!.size).toBe(2);
|
|
115
|
+
expect(sizeCache!.has("200x200")).toBe(true);
|
|
116
|
+
expect(sizeCache!.has("500x500")).toBe(true);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("honors hiDPIFactor — factor 1 yields strictly smaller output than factor 2", async () => {
|
|
120
|
+
const url = "https://example.com/scale.png";
|
|
121
|
+
_dataUriCache.set(url, await makePngDataUri(2000, 2000));
|
|
122
|
+
|
|
123
|
+
await resizeEmbeddedImages([makeImg(url, 200, 200)], { hiDPIFactor: 1 });
|
|
124
|
+
const at1x = _resizedDataUriCache.get(url)!.get("200x200");
|
|
125
|
+
expect(at1x).toBeDefined();
|
|
126
|
+
|
|
127
|
+
_resizedDataUriCache.clear();
|
|
128
|
+
await resizeEmbeddedImages([makeImg(url, 200, 200)], { hiDPIFactor: 2 });
|
|
129
|
+
const at2x = _resizedDataUriCache.get(url)!.get("400x400");
|
|
130
|
+
expect(at2x).toBeDefined();
|
|
131
|
+
|
|
132
|
+
expect(decodePngBytes(at1x!).length).toBeLessThan(decodePngBytes(at2x!).length);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("skips URLs that aren't in the source-data cache (resize only acts on inlined bytes)", async () => {
|
|
136
|
+
const url = "https://example.com/never-fetched.png";
|
|
137
|
+
// `_dataUriCache` is empty; resize pass should be a no-op for this URL.
|
|
138
|
+
const tree = [makeImg(url, 100, 100)];
|
|
139
|
+
await resizeEmbeddedImages(tree, { hiDPIFactor: 2 });
|
|
140
|
+
expect(_resizedDataUriCache.has(url)).toBe(false);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("walks pseudoImages and CSS background-image consumers", async () => {
|
|
144
|
+
const pseudoUrl = "https://example.com/pseudo.png";
|
|
145
|
+
const bgUrl = "https://example.com/bg.png";
|
|
146
|
+
_dataUriCache.set(pseudoUrl, await makePngDataUri(500, 500));
|
|
147
|
+
_dataUriCache.set(bgUrl, await makePngDataUri(1200, 800));
|
|
148
|
+
const tree: CapturedElement[] = [{
|
|
149
|
+
tag: "div",
|
|
150
|
+
text: "",
|
|
151
|
+
x: 0, y: 0, width: 400, height: 300,
|
|
152
|
+
children: [],
|
|
153
|
+
pseudoImages: [{ url: pseudoUrl, x: 0, y: 0, width: 50, height: 50 }],
|
|
154
|
+
styles: {
|
|
155
|
+
backgroundImage: `url("${bgUrl}")`,
|
|
156
|
+
} as CapturedElement["styles"],
|
|
157
|
+
} as CapturedElement];
|
|
158
|
+
await resizeEmbeddedImages(tree, { hiDPIFactor: 2 });
|
|
159
|
+
|
|
160
|
+
expect(_resizedDataUriCache.get(pseudoUrl)?.has("100x100")).toBe(true);
|
|
161
|
+
expect(_resizedDataUriCache.get(bgUrl)?.has("800x600")).toBe(true);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// DM-541: per docs/27 § Render-rect inference, every CSS url() consumer
|
|
165
|
+
// gets its own consumer rect. Cover the ones the smoke suite skipped.
|
|
166
|
+
|
|
167
|
+
it("walks CSS mask-image consumers (consumer element rect)", async () => {
|
|
168
|
+
const maskUrl = "https://example.com/mask.png";
|
|
169
|
+
_dataUriCache.set(maskUrl, await makePngDataUri(1024, 1024));
|
|
170
|
+
const tree: CapturedElement[] = [{
|
|
171
|
+
tag: "div",
|
|
172
|
+
text: "",
|
|
173
|
+
x: 0, y: 0, width: 200, height: 100,
|
|
174
|
+
children: [],
|
|
175
|
+
styles: {
|
|
176
|
+
maskImage: `url("${maskUrl}")`,
|
|
177
|
+
} as CapturedElement["styles"],
|
|
178
|
+
} as CapturedElement];
|
|
179
|
+
await resizeEmbeddedImages(tree, { hiDPIFactor: 2 });
|
|
180
|
+
expect(_resizedDataUriCache.get(maskUrl)?.has("400x200")).toBe(true);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("walks CSS border-image-source consumers (full border box)", async () => {
|
|
184
|
+
const borderUrl = "https://example.com/border.png";
|
|
185
|
+
_dataUriCache.set(borderUrl, await makePngDataUri(1500, 1500));
|
|
186
|
+
const tree: CapturedElement[] = [{
|
|
187
|
+
tag: "div",
|
|
188
|
+
text: "",
|
|
189
|
+
x: 0, y: 0, width: 250, height: 80,
|
|
190
|
+
children: [],
|
|
191
|
+
styles: {
|
|
192
|
+
borderImageSource: `url("${borderUrl}")`,
|
|
193
|
+
} as CapturedElement["styles"],
|
|
194
|
+
} as CapturedElement];
|
|
195
|
+
await resizeEmbeddedImages(tree, { hiDPIFactor: 2 });
|
|
196
|
+
expect(_resizedDataUriCache.get(borderUrl)?.has("500x160")).toBe(true);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("walks CSS list-style-image consumers (em-box at element font-size)", async () => {
|
|
200
|
+
const listUrl = "https://example.com/bullet.png";
|
|
201
|
+
_dataUriCache.set(listUrl, await makePngDataUri(64, 64));
|
|
202
|
+
const tree: CapturedElement[] = [{
|
|
203
|
+
tag: "li",
|
|
204
|
+
text: "",
|
|
205
|
+
x: 0, y: 0, width: 300, height: 24,
|
|
206
|
+
children: [],
|
|
207
|
+
styles: {
|
|
208
|
+
listStyleImage: `url("${listUrl}")`,
|
|
209
|
+
fontSize: "20px",
|
|
210
|
+
} as CapturedElement["styles"],
|
|
211
|
+
} as CapturedElement];
|
|
212
|
+
await resizeEmbeddedImages(tree, { hiDPIFactor: 2 });
|
|
213
|
+
// em-box at 20px font-size × 2x hiDPI = 40x40
|
|
214
|
+
expect(_resizedDataUriCache.get(listUrl)?.has("40x40")).toBe(true);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("collapses an animated GIF to a single still PNG (first frame) when resize threshold is crossed", async () => {
|
|
218
|
+
const gifUrl = "https://example.com/animated.gif";
|
|
219
|
+
// Build a multi-page GIF whose source resolution (200×200) exceeds the
|
|
220
|
+
// target (50 × 2 = 100) so the resize threshold fires and the encoder
|
|
221
|
+
// path runs. (At-or-below-target sources skip re-encode and pass the
|
|
222
|
+
// original bytes through verbatim — docs/27 § Resize threshold.)
|
|
223
|
+
const animated = await sharp({
|
|
224
|
+
create: { width: 200, height: 200, channels: 4, background: { r: 220, g: 0, b: 0, alpha: 1 } },
|
|
225
|
+
pages: 2,
|
|
226
|
+
} as any).gif().toBuffer().catch(async () => {
|
|
227
|
+
// Fallback build path: composite a second frame onto the first.
|
|
228
|
+
const f1 = await sharp({ create: { width: 200, height: 200, channels: 4, background: { r: 220, g: 0, b: 0, alpha: 1 } } }).png().toBuffer();
|
|
229
|
+
const f2 = await sharp({ create: { width: 200, height: 200, channels: 4, background: { r: 0, g: 0, b: 220, alpha: 1 } } }).png().toBuffer();
|
|
230
|
+
return sharp(f1, { animated: true }).composite([{ input: f2 }]).gif().toBuffer();
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
_dataUriCache.set(gifUrl, `data:image/gif;base64,${animated.toString("base64")}`);
|
|
234
|
+
const tree = [makeImg(gifUrl, 50, 50)];
|
|
235
|
+
await resizeEmbeddedImages(tree, { hiDPIFactor: 2 });
|
|
236
|
+
|
|
237
|
+
const out = _resizedDataUriCache.get(gifUrl)?.get("100x100");
|
|
238
|
+
expect(out).toBeDefined();
|
|
239
|
+
expect(out!.startsWith("data:image/png;base64,")).toBe(true);
|
|
240
|
+
const outMeta = await sharp(decodePngBytes(out!), { animated: true }).metadata();
|
|
241
|
+
// Per docs/27, animated GIFs become a still image. PNG can't carry
|
|
242
|
+
// animation, so the output reports exactly one page.
|
|
243
|
+
expect(outMeta.pages ?? 1).toBe(1);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("disabled flag is a no-op — _dataUriCache unchanged, source bytes preserved verbatim", async () => {
|
|
247
|
+
const url = "https://example.com/untouched.png";
|
|
248
|
+
const sourceDataUri = await makePngDataUri(1500, 1000);
|
|
249
|
+
_dataUriCache.set(url, sourceDataUri);
|
|
250
|
+
const sourceCacheSnapshot = _dataUriCache.get(url);
|
|
251
|
+
|
|
252
|
+
// Skipping the resize pre-pass entirely (the "disabled" path) means
|
|
253
|
+
// `_resizedDataUriCache` stays empty and `_dataUriCache` is unchanged.
|
|
254
|
+
expect(_resizedDataUriCache.size).toBe(0);
|
|
255
|
+
expect(_dataUriCache.get(url)).toBe(sourceCacheSnapshot);
|
|
256
|
+
|
|
257
|
+
// The renderer-side helper falls back to the source data URI when no
|
|
258
|
+
// resized variant exists for the (url, sizeKey).
|
|
259
|
+
expect(embedResizedDataUri(url, 300, 200, 2)).toBe(sourceDataUri);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("CSS `none` and empty values for url() consumers are ignored", async () => {
|
|
263
|
+
// Defence-in-depth: a `none` literal or empty string for any CSS url()
|
|
264
|
+
// consumer should not raise nor populate the cache.
|
|
265
|
+
const tree: CapturedElement[] = [{
|
|
266
|
+
tag: "div",
|
|
267
|
+
text: "",
|
|
268
|
+
x: 0, y: 0, width: 100, height: 100,
|
|
269
|
+
children: [],
|
|
270
|
+
styles: {
|
|
271
|
+
backgroundImage: "none",
|
|
272
|
+
maskImage: "",
|
|
273
|
+
borderImageSource: "none",
|
|
274
|
+
listStyleImage: "none",
|
|
275
|
+
fontSize: "16px",
|
|
276
|
+
} as CapturedElement["styles"],
|
|
277
|
+
} as CapturedElement];
|
|
278
|
+
await resizeEmbeddedImages(tree, { hiDPIFactor: 2 });
|
|
279
|
+
expect(_resizedDataUriCache.size).toBe(0);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("clamps hiDPIFactor < 1 up to 1 (renderer + pre-pass agree on key)", async () => {
|
|
283
|
+
const url = "https://example.com/clamp.png";
|
|
284
|
+
_dataUriCache.set(url, await makePngDataUri(800, 600));
|
|
285
|
+
await resizeEmbeddedImages([makeImg(url, 200, 150)], { hiDPIFactor: 0.5 });
|
|
286
|
+
// 200 × clamp(0.5, min=1) = 200, 150 × 1 = 150
|
|
287
|
+
expect(_resizedDataUriCache.get(url)?.has("200x150")).toBe(true);
|
|
288
|
+
// The renderer-side lookup must clamp identically or the key misses.
|
|
289
|
+
const dataUri = embedResizedDataUri(url, 200, 150, 0.5);
|
|
290
|
+
expect(dataUri.startsWith("data:image/png;base64,")).toBe(true);
|
|
291
|
+
});
|
|
292
|
+
});
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DM-539 — image resize-on-embed pre-pass. See `docs/27-image-resize-on-embed.md`.
|
|
3
|
+
*
|
|
4
|
+
* Runs after `embedRemoteImages` (DM-512) populated `_dataUriCache` with
|
|
5
|
+
* source-resolution bytes. Walks the captured tree to collect every
|
|
6
|
+
* (URL, consumer rect) tuple, resizes the source down to
|
|
7
|
+
* `ceil(consumerW * hiDPIFactor) × ceil(consumerH * hiDPIFactor)` via sharp,
|
|
8
|
+
* re-encodes as PNG, and stashes the result in `_resizedDataUriCache` keyed
|
|
9
|
+
* by `(URL, "${w}x${h}")`. The renderer wiring (DM-540) consumes that cache
|
|
10
|
+
* via `embedResizedDataUri` when the resize flag is enabled.
|
|
11
|
+
*
|
|
12
|
+
* Skips a tuple when the source is already at-or-below target resolution —
|
|
13
|
+
* decoding and re-encoding a same-size image accumulates artifacts (JPEG)
|
|
14
|
+
* and may grow the file (PNG-of-tiny-icon). Skips entirely when the URL
|
|
15
|
+
* isn't in `_dataUriCache` (the resize pass only acts on what
|
|
16
|
+
* `embedRemoteImages` already inlined).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import sharp from "sharp";
|
|
20
|
+
import {
|
|
21
|
+
_dataUriCache,
|
|
22
|
+
_resizedDataUriCache,
|
|
23
|
+
type CapturedElement,
|
|
24
|
+
} from "./dom-to-svg.js";
|
|
25
|
+
|
|
26
|
+
export interface ResizeEmbeddedImagesOptions {
|
|
27
|
+
/**
|
|
28
|
+
* Multiplier on each consumer's render rect. Default 2.0 — leaves headroom
|
|
29
|
+
* for retina display / zoom without inlining the full source resolution.
|
|
30
|
+
* Values < 1 are clamped to 1 (going below render rect produces a visibly
|
|
31
|
+
* blurry SVG even at default zoom).
|
|
32
|
+
*/
|
|
33
|
+
hiDPIFactor?: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const DEFAULT_HIDPI_FACTOR = 2;
|
|
37
|
+
const MIN_HIDPI_FACTOR = 1;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Per-(URL, consumer-rect) downscale pre-pass. Mutates `_resizedDataUriCache`
|
|
41
|
+
* in place; returns nothing. Idempotent: re-running with the same tree and
|
|
42
|
+
* options is a no-op (every tuple's resized PNG is already cached).
|
|
43
|
+
*/
|
|
44
|
+
export async function resizeEmbeddedImages(
|
|
45
|
+
tree: CapturedElement[],
|
|
46
|
+
options: ResizeEmbeddedImagesOptions = {},
|
|
47
|
+
): Promise<void> {
|
|
48
|
+
const hiDPI = Math.max(MIN_HIDPI_FACTOR, options.hiDPIFactor ?? DEFAULT_HIDPI_FACTOR);
|
|
49
|
+
|
|
50
|
+
// Collect (URL, sizeKey, w, h) tuples. Only consider URLs that
|
|
51
|
+
// `embedRemoteImages` has already inlined — there's nothing to decode
|
|
52
|
+
// otherwise, and the renderer would fall back to the source URL anyway.
|
|
53
|
+
type Tuple = { url: string; sizeKey: string; w: number; h: number };
|
|
54
|
+
const tuples = new Map<string, Tuple>();
|
|
55
|
+
const consider = (url: string | undefined, w: number, h: number): void => {
|
|
56
|
+
if (url == null || url === "") return;
|
|
57
|
+
if (!_dataUriCache.has(url)) return;
|
|
58
|
+
const tw = Math.max(1, Math.ceil(w * hiDPI));
|
|
59
|
+
const th = Math.max(1, Math.ceil(h * hiDPI));
|
|
60
|
+
const sizeKey = `${tw}x${th}`;
|
|
61
|
+
const dedupeKey = `${url}\n${sizeKey}`;
|
|
62
|
+
if (tuples.has(dedupeKey)) return;
|
|
63
|
+
// Skip if a prior call already cached this size for this URL.
|
|
64
|
+
const sizeCache = _resizedDataUriCache.get(url);
|
|
65
|
+
if (sizeCache != null && sizeCache.has(sizeKey)) return;
|
|
66
|
+
tuples.set(dedupeKey, { url, sizeKey, w: tw, h: th });
|
|
67
|
+
};
|
|
68
|
+
const collectFromCss = (cssVal: string | undefined, consumerW: number, consumerH: number): void => {
|
|
69
|
+
if (cssVal == null || cssVal === "" || cssVal === "none") return;
|
|
70
|
+
const re = /url\(\s*(?:"([^"]+)"|'([^']+)'|([^)\s]+))\s*\)/g;
|
|
71
|
+
let m: RegExpExecArray | null;
|
|
72
|
+
while ((m = re.exec(cssVal)) != null) {
|
|
73
|
+
const u = (m[1] ?? m[2] ?? m[3] ?? "").trim();
|
|
74
|
+
consider(u, consumerW, consumerH);
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
const walk = (els: CapturedElement[]): void => {
|
|
78
|
+
for (const el of els) {
|
|
79
|
+
consider(el.imageSrc, el.width, el.height);
|
|
80
|
+
if (el.pseudoImages != null) {
|
|
81
|
+
for (const pi of el.pseudoImages) consider(pi.url, pi.width, pi.height);
|
|
82
|
+
}
|
|
83
|
+
collectFromCss(el.styles.backgroundImage, el.width, el.height);
|
|
84
|
+
collectFromCss(el.styles.maskImage, el.width, el.height);
|
|
85
|
+
// border-image renders to the full border box (== el.width × el.height
|
|
86
|
+
// in our captured rects, since x/y/width/height already cover the
|
|
87
|
+
// border box).
|
|
88
|
+
collectFromCss(el.styles.borderImageSource, el.width, el.height);
|
|
89
|
+
// list-style-image paints in an em-box at the element's font-size.
|
|
90
|
+
// Fall back to 16px if fontSize is missing or unparsable.
|
|
91
|
+
const fontPx = parseFloat(el.styles.fontSize ?? "16") || 16;
|
|
92
|
+
collectFromCss(el.styles.listStyleImage, fontPx, fontPx);
|
|
93
|
+
if (el.children.length > 0) walk(el.children);
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
walk(tree);
|
|
97
|
+
if (tuples.size === 0) return;
|
|
98
|
+
|
|
99
|
+
// Resize in parallel — each sharp call decodes + resizes + encodes
|
|
100
|
+
// independently. Failures are isolated per-tuple: a corrupt source falls
|
|
101
|
+
// back to the original data URI in the cache so the renderer just emits
|
|
102
|
+
// the source bytes (at source resolution) instead of crashing the capture.
|
|
103
|
+
const tasks = Array.from(tuples.values(), async ({ url, sizeKey, w, h }) => {
|
|
104
|
+
const sourceDataUri = _dataUriCache.get(url);
|
|
105
|
+
if (sourceDataUri == null) return;
|
|
106
|
+
const sourceBytes = decodeDataUri(sourceDataUri);
|
|
107
|
+
if (sourceBytes == null) return;
|
|
108
|
+
try {
|
|
109
|
+
// Resize threshold: skip when the source is already at-or-below the
|
|
110
|
+
// target on both axes. Re-encoding a same-size JPEG/PNG accumulates
|
|
111
|
+
// artifacts (or grows the file when re-encoding a small icon as PNG).
|
|
112
|
+
// Read intrinsic dims from sharp metadata so we don't need to parse
|
|
113
|
+
// headers ourselves.
|
|
114
|
+
const meta = await sharp(sourceBytes).metadata();
|
|
115
|
+
const sw = meta.width ?? 0;
|
|
116
|
+
const sh = meta.height ?? 0;
|
|
117
|
+
if (sw > 0 && sh > 0 && sw <= w && sh <= h) {
|
|
118
|
+
// Source is small enough — keep source bytes. Cache the original
|
|
119
|
+
// data URI under this sizeKey so the renderer's lookup is uniform.
|
|
120
|
+
rememberResized(url, sizeKey, sourceDataUri);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
// `fit: "inside"` preserves aspect ratio within the target box;
|
|
124
|
+
// `withoutEnlargement: true` is defence-in-depth in case the threshold
|
|
125
|
+
// check above mis-fires.
|
|
126
|
+
const out = await sharp(sourceBytes)
|
|
127
|
+
.resize(w, h, { fit: "inside", withoutEnlargement: true })
|
|
128
|
+
.png({ compressionLevel: 9 })
|
|
129
|
+
.toBuffer();
|
|
130
|
+
// DM-542: if the resized PNG ended up LARGER than the source bytes
|
|
131
|
+
// (PNG-of-photo can outweigh a tightly-compressed source JPEG when
|
|
132
|
+
// the resize ratio is small), keep the source. Otherwise we'd
|
|
133
|
+
// pessimise the SVG size for every photo whose target rect is close
|
|
134
|
+
// to source resolution. Validated empirically against
|
|
135
|
+
// apple-desktop-fold: without this guard the SVG GROWS by ~12.6 %
|
|
136
|
+
// (PNG re-encode of source-resolution photos > source JPEG bytes);
|
|
137
|
+
// with it, the resize pass is a no-op for those tiles and other
|
|
138
|
+
// captures still see their full reduction.
|
|
139
|
+
if (out.length >= sourceBytes.length) {
|
|
140
|
+
rememberResized(url, sizeKey, sourceDataUri);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
const dataUri = `data:image/png;base64,${out.toString("base64")}`;
|
|
144
|
+
rememberResized(url, sizeKey, dataUri);
|
|
145
|
+
} catch {
|
|
146
|
+
// Per-image failure: fall back to source bytes so the SVG still
|
|
147
|
+
// renders. The renderer's `embedResizedDataUri` lookup will hit the
|
|
148
|
+
// source data URI cached under this sizeKey.
|
|
149
|
+
rememberResized(url, sizeKey, sourceDataUri);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
await Promise.all(tasks);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function rememberResized(url: string, sizeKey: string, dataUri: string): void {
|
|
156
|
+
let sizeCache = _resizedDataUriCache.get(url);
|
|
157
|
+
if (sizeCache == null) {
|
|
158
|
+
sizeCache = new Map<string, string>();
|
|
159
|
+
_resizedDataUriCache.set(url, sizeCache);
|
|
160
|
+
}
|
|
161
|
+
sizeCache.set(sizeKey, dataUri);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function decodeDataUri(dataUri: string): Buffer | null {
|
|
165
|
+
// `embedRemoteImages` always emits `data:<mime>;base64,<payload>` form.
|
|
166
|
+
// Anything else (a passthrough URL the cache stored verbatim) we ignore —
|
|
167
|
+
// we can't decode source bytes from a non-data: cache entry.
|
|
168
|
+
const m = /^data:[^;]+;base64,(.*)$/s.exec(dataUri);
|
|
169
|
+
if (m == null) return null;
|
|
170
|
+
try {
|
|
171
|
+
return Buffer.from(m[1], "base64");
|
|
172
|
+
} catch {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// `embedResizedDataUri` lives in `dom-to-svg.ts` next to `embedAsDataUri` so
|
|
178
|
+
// the renderer's image-emission paths have a single uniform helper that
|
|
179
|
+
// handles file:// lazy-load + cache hits + resized-cache hits in one call.
|
|
180
|
+
// See `embedResizedDataUri` in `dom-to-svg.ts` for the renderer-facing API.
|