@1agh/maude 1.0.6 → 1.0.8

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 (48) hide show
  1. package/apps/studio/bin/_html-playwright.mjs +198 -19
  2. package/apps/studio/bin/_pdf-playwright.mjs +1 -0
  3. package/apps/studio/bin/_png-playwright.mjs +28 -8
  4. package/apps/studio/bin/_pptx-playwright.mjs +4 -1
  5. package/apps/studio/bin/_pw-launch.mjs +48 -0
  6. package/apps/studio/bin/_svg-playwright.mjs +63 -149
  7. package/apps/studio/bin/_video-playwright.mjs +123 -11
  8. package/apps/studio/canvas-edit.ts +33 -0
  9. package/apps/studio/canvas-lib.tsx +136 -0
  10. package/apps/studio/client/app.jsx +316 -28
  11. package/apps/studio/client/export-lane.js +191 -0
  12. package/apps/studio/dist/client.bundle.js +907 -907
  13. package/apps/studio/dist/runtime/.min-sizes.json +2 -1
  14. package/apps/studio/dist/runtime/dom-to-svg.js +31 -0
  15. package/apps/studio/export-dialog.tsx +24 -19
  16. package/apps/studio/exporters/_browser-bundles.ts +30 -0
  17. package/apps/studio/exporters/_runtime.ts +31 -1
  18. package/apps/studio/exporters/capture-chrome.ts +111 -0
  19. package/apps/studio/exporters/capture-core.ts +947 -0
  20. package/apps/studio/exporters/format-scopes.ts +88 -0
  21. package/apps/studio/exporters/html.ts +3 -2
  22. package/apps/studio/exporters/jobs.ts +180 -18
  23. package/apps/studio/exporters/pdf.ts +21 -1
  24. package/apps/studio/exporters/pptx.ts +46 -11
  25. package/apps/studio/exporters/remote.ts +94 -11
  26. package/apps/studio/exporters/scope.ts +14 -3
  27. package/apps/studio/exporters/svg.ts +17 -3
  28. package/apps/studio/exporters/video.ts +75 -8
  29. package/apps/studio/http.ts +175 -7
  30. package/apps/studio/runtime-bundle.ts +6 -0
  31. package/apps/studio/test/canvas-hide-chrome.test.ts +30 -0
  32. package/apps/studio/test/capture-core.test.ts +32 -0
  33. package/apps/studio/test/export-assemble.test.ts +53 -0
  34. package/apps/studio/test/export-browser-lane.test.ts +126 -0
  35. package/apps/studio/test/export-capture-fidelity.test.ts +146 -0
  36. package/apps/studio/test/export-capture-hygiene.test.ts +675 -0
  37. package/apps/studio/test/export-e2e-lanes.test.ts +857 -0
  38. package/apps/studio/test/export-format-scope-coherence.test.ts +89 -0
  39. package/apps/studio/test/exporters/jobs.test.ts +73 -0
  40. package/apps/studio/test/exporters/scope.test.ts +33 -0
  41. package/apps/studio/test/fixtures/tiny-subset.woff2 +0 -0
  42. package/apps/studio/test/pdf-print-boxes.test.ts +33 -0
  43. package/apps/studio/test/photo-canvas-bundle.test.ts +18 -0
  44. package/apps/studio/test/workspace-containment.test.ts +8 -0
  45. package/apps/studio/whats-new.json +18 -0
  46. package/apps/studio/workspace-mode.ts +15 -2
  47. package/package.json +8 -8
  48. package/plugins/design/templates/_shell.html +2 -1
@@ -0,0 +1,947 @@
1
+ // capture-core.ts — the SHARED in-page capture spine (DDR-231 single-spine).
2
+ //
3
+ // Pure browser code, two hosts, ONE source:
4
+ // 1. The playwright SVG shim (`bin/_svg-playwright.mjs`) injects this file
5
+ // as a module bundle (`getCaptureCoreBundle()`) and calls it inside
6
+ // `page.evaluate` — the desktop / render-worker lane.
7
+ // 2. The canvas runtime (`canvas-lib.tsx` export-capture listener) imports
8
+ // it directly — the workspace `browser` lane, where the member's own
9
+ // browser does the capture (DDR-231: images render client-side).
10
+ //
11
+ // History: this logic lived inline in `_svg-playwright.mjs`'s serializeOne
12
+ // (backdrop rect from the artboard's effective background, oklch→sRGB
13
+ // normalization for vector editors, dom-to-svg inlineResources). Extracting it
14
+ // is DDR-231's BUILDER condition — if the two hosts fork, the browser lane
15
+ // rots into a second implementation. Do not re-inline.
16
+ //
17
+ // NO Node imports allowed here — this file must bundle for `target: 'browser'`
18
+ // with zero shims. Loaded as a side-effect module: assigns
19
+ // `window.__maudeCaptureCore` so the shim can reach it without an importmap.
20
+
21
+ import { applyCaptureChrome, CAPTURE_HIDDEN_SELECTORS } from './capture-chrome.ts';
22
+
23
+ /** The subset of dom-to-svg the capture uses (IIFE `window.domToSvg` in the
24
+ * shim host; `await import('dom-to-svg')` through the importmap in the canvas
25
+ * runtime host). */
26
+ export interface DomToSvgApi {
27
+ elementToSVG(el: Element): XMLDocument;
28
+ /**
29
+ * Present on the module but NOT used — dom-to-svg 0.12.2's own inlining is
30
+ * broken for images (it writes `xlink:href`, reads `href.baseVal`). Kept in
31
+ * the type so the shape of the injected module still documents itself; the
32
+ * working implementation is {@link inlineCaptureResources}.
33
+ */
34
+ inlineResources?(el: Element): Promise<void>;
35
+ }
36
+
37
+ // Raster ceilings — mirrored from bin/_png-playwright.mjs (feature-2-print-
38
+ // artboards T4): 16k px matches common canvas/texture limits across engines;
39
+ // ~600MB is the RGBA-buffer ceiling that still admits a 600dpi A0 poster.
40
+ export const MAX_OUTPUT_SIDE_PX = 16000;
41
+ export const MAX_OUTPUT_BYTES = 600 * 1024 * 1024;
42
+
43
+ /** Throws with an actionable message (max supported DPI for this artboard)
44
+ * when the requested raster exceeds the render guard. */
45
+ export function assertRasterSizeOk(widthCss: number, heightCss: number, scale: number): void {
46
+ const outW = Math.ceil(widthCss * scale);
47
+ const outH = Math.ceil(heightCss * scale);
48
+ const estBytes = outW * outH * 4;
49
+ if (outW > MAX_OUTPUT_SIDE_PX || outH > MAX_OUTPUT_SIDE_PX || estBytes > MAX_OUTPUT_BYTES) {
50
+ const maxScaleForSide = MAX_OUTPUT_SIDE_PX / Math.max(widthCss, heightCss);
51
+ const maxScaleForBytes = Math.sqrt(MAX_OUTPUT_BYTES / 4 / (widthCss * heightCss));
52
+ const maxScale = Math.min(maxScaleForSide, maxScaleForBytes);
53
+ const maxDpi = Math.floor(maxScale * 96);
54
+ throw new Error(
55
+ `requested output ${outW}×${outH}px at ${scale}× exceeds the render guard ` +
56
+ `(max side ${MAX_OUTPUT_SIDE_PX}px / ~${Math.round(MAX_OUTPUT_BYTES / 1024 / 1024)}MB). ` +
57
+ `For this artboard (${Math.round(widthCss)}×${Math.round(heightCss)}px) the max ` +
58
+ `supported DPI is ~${maxDpi}.`
59
+ );
60
+ }
61
+ }
62
+
63
+ /** Pin an element's enclosing artboard to (0,0); returns the style values to
64
+ * hand back to {@link restoreArtboard}. Per-target save/restore (not a global
65
+ * reset) — the multi-artboard scatter-bug lesson from the playwright shims. */
66
+ export function pinArtboard(el: Element): { left: string; top: string } {
67
+ const ab = (el.closest('[data-dc-screen]') ?? el) as HTMLElement;
68
+ const prev = { left: ab.style.left, top: ab.style.top };
69
+ ab.style.left = '0px';
70
+ ab.style.top = '0px';
71
+ return prev;
72
+ }
73
+
74
+ export function restoreArtboard(el: Element, prev: { left: string; top: string }): void {
75
+ const ab = (el.closest('[data-dc-screen]') ?? el) as HTMLElement;
76
+ ab.style.left = prev.left;
77
+ ab.style.top = prev.top;
78
+ }
79
+
80
+ /** Reset the world plane's pan/zoom so artboards measure at their declared
81
+ * dimensions (CSS `zoom` shrinks LAYOUT — getBoundingClientRect lies under
82
+ * it). Returns the saved styles for {@link restoreWorldPlane}; the live-canvas
83
+ * host MUST restore (the member is looking at the plane), the throwaway shim
84
+ * page never bothers. */
85
+ export function restoreWorldPlane(saved: { zoom: string; transform: string } | null): void {
86
+ const world = document.querySelector<HTMLElement>('.dc-world');
87
+ if (!world || !saved) return;
88
+ world.style.zoom = saved.zoom;
89
+ world.style.transform = saved.transform;
90
+ }
91
+
92
+ export function resetWorldPlane(): { zoom: string; transform: string } | null {
93
+ const world = document.querySelector<HTMLElement>('.dc-world');
94
+ if (!world) return null;
95
+ const saved = { zoom: world.style.zoom, transform: world.style.transform };
96
+ world.style.zoom = '1';
97
+ world.style.transform = 'none';
98
+ return saved;
99
+ }
100
+
101
+ /** Convert ANY computed color (modern Chromium serializes `oklch(...)`
102
+ * verbatim from getComputedStyle) to sRGB rgb()/rgba(). Affinity Designer /
103
+ * older Illustrator / Inkscape don't parse CSS Color 4 and silently drop the
104
+ * fill. A 1×1 canvas normalizes any CSS color string to sRGB bytes; null when
105
+ * the value can't be painted (keep the original rather than emit a wrong
106
+ * colour). */
107
+ function makeToSrgb(): (color: string) => string | null {
108
+ const cache = new Map<string, string | null>();
109
+ const canvas = document.createElement('canvas');
110
+ canvas.width = 1;
111
+ canvas.height = 1;
112
+ const ctx = canvas.getContext('2d', { willReadFrequently: true });
113
+ return (color: string) => {
114
+ const hit = cache.get(color);
115
+ if (hit !== undefined) return hit;
116
+ let result: string | null = null;
117
+ try {
118
+ if (ctx) {
119
+ ctx.clearRect(0, 0, 1, 1);
120
+ ctx.fillStyle = '#000';
121
+ ctx.fillStyle = color; // ignored (stays #000) if syntax unsupported
122
+ ctx.fillRect(0, 0, 1, 1);
123
+ const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data;
124
+ if (a !== 0) {
125
+ result =
126
+ a === 255 ? `rgb(${r}, ${g}, ${b})` : `rgba(${r}, ${g}, ${b}, ${(a / 255).toFixed(3)})`;
127
+ }
128
+ }
129
+ } catch {
130
+ result = null;
131
+ }
132
+ cache.set(color, result);
133
+ return result;
134
+ };
135
+ }
136
+
137
+ const isOpaque = (bg: string | null): bg is string =>
138
+ !!bg &&
139
+ bg !== 'transparent' &&
140
+ !/^rgba\(\s*0\s*,\s*0\s*,\s*0\s*,\s*0\s*\)$/.test(bg) &&
141
+ !/^rgba\(\s*0\s*,\s*0\s*,\s*0\s*,\s*0(\.0+)?\s*\)$/.test(bg);
142
+
143
+ /** Effective background of the artboard region: the DS theme bg usually sits
144
+ * on an inner full-bleed wrapper (`.app` / `.mdcc`), occasionally on the
145
+ * artboard element itself, sometimes on an ancestor. Descend first for a
146
+ * near-full-bleed opaque bg, then fall back to ancestors. An intentionally
147
+ * transparent artboard returns null → stays transparent. */
148
+ function effectiveBg(node: Element): string | null {
149
+ const abRect = node.getBoundingClientRect();
150
+ const abArea = Math.max(1, abRect.width * abRect.height);
151
+ const queue: Element[] = [node];
152
+ let guard = 0;
153
+ while (queue.length && guard < 80) {
154
+ const e = queue.shift() as Element;
155
+ guard += 1;
156
+ const bg = getComputedStyle(e).backgroundColor;
157
+ if (isOpaque(bg)) {
158
+ const r = e.getBoundingClientRect();
159
+ if (r.width * r.height >= abArea * 0.6) return bg;
160
+ }
161
+ for (const c of e.children) queue.push(c);
162
+ }
163
+ let cur = node.parentElement;
164
+ let up = 0;
165
+ while (cur && up < 12) {
166
+ const bg = getComputedStyle(cur).backgroundColor;
167
+ if (isOpaque(bg)) return bg;
168
+ cur = cur.parentElement;
169
+ up += 1;
170
+ }
171
+ return null;
172
+ }
173
+
174
+ // ── paint polyfills for dom-to-svg ──────────────────────────────────────────
175
+ //
176
+ // dom-to-svg reconstructs CSS paint as SVG primitives and has no equivalent for
177
+ // two things design systems lean on for signature decoration: a
178
+ // `repeating-linear-gradient` (it matches `linear-gradient` only) and a
179
+ // `clip-path: polygon()` (it opens a stacking context and drops the clip). The
180
+ // alligators "Sash" — a diagonal yellow stripe band, clipped to a corner
181
+ // triangle — is both at once, and vanished from every dom-to-svg lane (SVG
182
+ // everywhere, browser PNG, PPTX deck) while the screenshot lanes kept it.
183
+ //
184
+ // SVG has exact analogues: <linearGradient spreadMethod="repeat"> and
185
+ // <clipPath>. So for a LEAF decorative element (no element children — the only
186
+ // shape where substituting the paint cannot re-layout anything) we insert an
187
+ // equivalent inline <svg> for the duration of elementToSVG — dom-to-svg copies
188
+ // inline SVG verbatim — and restore the element right after. Synchronous,
189
+ // bracketing a synchronous call: the live canvas never paints the substitute.
190
+
191
+ interface ParsedStop {
192
+ color: string;
193
+ px: number;
194
+ }
195
+ interface ParsedRepeatingGradient {
196
+ angleDeg: number;
197
+ stops: ParsedStop[];
198
+ }
199
+
200
+ /** Split on top-level commas (colors carry commas inside `rgb(...)`). */
201
+ function splitTopLevel(s: string): string[] {
202
+ const out: string[] = [];
203
+ let depth = 0;
204
+ let cur = '';
205
+ for (const ch of s) {
206
+ if (ch === '(') depth += 1;
207
+ if (ch === ')') depth -= 1;
208
+ if (ch === ',' && depth === 0) {
209
+ out.push(cur.trim());
210
+ cur = '';
211
+ } else cur += ch;
212
+ }
213
+ if (cur.trim()) out.push(cur.trim());
214
+ return out;
215
+ }
216
+
217
+ /** Parse a COMPUTED `repeating-linear-gradient(...)` (stops already expanded
218
+ * to `<color> <px|%>` pairs by the engine). `null` for anything else. */
219
+ function parseRepeatingLinearGradient(
220
+ value: string,
221
+ w: number,
222
+ h: number
223
+ ): ParsedRepeatingGradient | null {
224
+ const m = /^repeating-linear-gradient\((.*)\)$/.exec(value.trim());
225
+ if (!m) return null;
226
+ const parts = splitTopLevel(m[1] ?? '');
227
+ if (!parts.length) return null;
228
+ let angleDeg = 180;
229
+ let first = parts[0] ?? '';
230
+ const angle = /^(-?[\d.]+)deg$/.exec(first);
231
+ const corner = Math.atan2(w, h) * (180 / Math.PI);
232
+ const keyword: Record<string, number> = {
233
+ 'to top': 0,
234
+ 'to right': 90,
235
+ 'to bottom': 180,
236
+ 'to left': 270,
237
+ 'to top right': corner,
238
+ 'to right top': corner,
239
+ 'to top left': 360 - corner,
240
+ 'to left top': 360 - corner,
241
+ 'to bottom right': 180 - corner,
242
+ 'to right bottom': 180 - corner,
243
+ 'to bottom left': 180 + corner,
244
+ 'to left bottom': 180 + corner,
245
+ };
246
+ if (angle) {
247
+ angleDeg = Number.parseFloat(angle[1] ?? '180');
248
+ parts.shift();
249
+ } else if (first in keyword) {
250
+ angleDeg = keyword[first] as number;
251
+ parts.shift();
252
+ }
253
+ first = '';
254
+ // Gradient-line length per CSS Images: |w·sin a| + |h·cos a|.
255
+ const rad = (angleDeg * Math.PI) / 180;
256
+ const lineLen = Math.abs(w * Math.sin(rad)) + Math.abs(h * Math.cos(rad));
257
+ const stops: ParsedStop[] = [];
258
+ for (const part of parts) {
259
+ const sm = /^(.*?)\s+(-?[\d.]+)(px|%)$/.exec(part);
260
+ if (!sm) return null; // a stop without a position — not the computed form
261
+ const px =
262
+ sm[3] === '%'
263
+ ? (Number.parseFloat(sm[2] ?? '0') / 100) * lineLen
264
+ : Number.parseFloat(sm[2] ?? '0');
265
+ stops.push({ color: (sm[1] ?? '').trim(), px });
266
+ }
267
+ if (stops.length < 2) return null;
268
+ return { angleDeg, stops };
269
+ }
270
+
271
+ /** `polygon(...)` of a computed clip-path → absolute points in the box. */
272
+ function parsePolygonClip(value: string, w: number, h: number): string | null {
273
+ const m = /^polygon\((.*)\)$/.exec(value.trim());
274
+ if (!m) return null;
275
+ const pts: string[] = [];
276
+ for (const pair of splitTopLevel(m[1] ?? '')) {
277
+ const [xs, ys] = pair.split(/\s+/);
278
+ if (!xs || !ys) return null;
279
+ const len = (v: string, ref: number) =>
280
+ v.endsWith('%') ? (Number.parseFloat(v) / 100) * ref : Number.parseFloat(v);
281
+ const x = len(xs, w);
282
+ const y = len(ys, h);
283
+ if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
284
+ pts.push(`${x},${y}`);
285
+ }
286
+ return pts.length >= 3 ? pts.join(' ') : null;
287
+ }
288
+
289
+ let polyfillSeq = 0;
290
+
291
+ /**
292
+ * Substitute unsupported paint on leaf elements under `target` with inline
293
+ * SVG; returns the undo. Call immediately before `elementToSVG`, undo
294
+ * immediately after.
295
+ */
296
+ export function applyPaintPolyfills(target: Element): () => void {
297
+ const undos: Array<() => void> = [];
298
+ const NS = 'http://www.w3.org/2000/svg';
299
+ const candidates: HTMLElement[] = [];
300
+ const consider = (el: Element) => {
301
+ if (!(el instanceof HTMLElement)) return;
302
+ if (el.children.length > 0) return; // leaf only
303
+ const cs = getComputedStyle(el);
304
+ const hasRepeating = cs.backgroundImage.startsWith('repeating-linear-gradient(');
305
+ const hasPolygon = cs.clipPath.startsWith('polygon(');
306
+ if (hasRepeating || hasPolygon) candidates.push(el);
307
+ };
308
+ consider(target);
309
+ for (const el of target.querySelectorAll('*')) consider(el);
310
+
311
+ for (const el of candidates) {
312
+ const cs = getComputedStyle(el);
313
+ const w = el.clientWidth;
314
+ const h = el.clientHeight;
315
+ if (!w || !h) continue;
316
+ polyfillSeq += 1;
317
+ const uid = `maude-pp-${polyfillSeq}`;
318
+ const svg = document.createElementNS(NS, 'svg');
319
+ svg.setAttribute('width', String(w));
320
+ svg.setAttribute('height', String(h));
321
+ svg.setAttribute('viewBox', `0 0 ${w} ${h}`);
322
+ svg.setAttribute('aria-hidden', 'true');
323
+ svg.setAttribute('data-maude-paint-polyfill', '1');
324
+ svg.style.cssText =
325
+ 'position:absolute;left:0;top:0;width:100%;height:100%;pointer-events:none;display:block';
326
+ const defs = document.createElementNS(NS, 'defs');
327
+ svg.appendChild(defs);
328
+
329
+ let fill: string | null = null;
330
+ let paintNode: Element | null = null;
331
+ const grad = parseRepeatingLinearGradient(cs.backgroundImage, w, h);
332
+ if (grad) {
333
+ const rad = (grad.angleDeg * Math.PI) / 180;
334
+ const dx = Math.sin(rad);
335
+ const dy = -Math.cos(rad);
336
+ const lineLen = Math.abs(w * dx) + Math.abs(h * Math.cos(rad));
337
+ const cx = w / 2;
338
+ const cy = h / 2;
339
+ const sx = cx - (dx * lineLen) / 2;
340
+ const sy = cy - (dy * lineLen) / 2;
341
+ const firstPx = grad.stops[0]?.px ?? 0;
342
+ const lastPx = grad.stops[grad.stops.length - 1]?.px ?? firstPx + 1;
343
+ const period = Math.max(0.01, lastPx - firstPx);
344
+ const lg = document.createElementNS(NS, 'linearGradient');
345
+ lg.setAttribute('id', `${uid}-g`);
346
+ lg.setAttribute('gradientUnits', 'userSpaceOnUse');
347
+ lg.setAttribute('spreadMethod', 'repeat');
348
+ lg.setAttribute('x1', String(sx + dx * firstPx));
349
+ lg.setAttribute('y1', String(sy + dy * firstPx));
350
+ lg.setAttribute('x2', String(sx + dx * lastPx));
351
+ lg.setAttribute('y2', String(sy + dy * lastPx));
352
+ for (const st of grad.stops) {
353
+ const stop = document.createElementNS(NS, 'stop');
354
+ stop.setAttribute('offset', String((st.px - firstPx) / period));
355
+ stop.setAttribute('stop-color', st.color);
356
+ lg.appendChild(stop);
357
+ }
358
+ defs.appendChild(lg);
359
+ fill = `url(#${uid}-g)`;
360
+ } else if (isOpaque(cs.backgroundColor) && cs.backgroundImage === 'none') {
361
+ fill = cs.backgroundColor;
362
+ }
363
+ const bgUrl = /^url\(["']?([^"')]+)["']?\)$/.exec(cs.backgroundImage)?.[1];
364
+ if (!fill && bgUrl) {
365
+ // A clipped background IMAGE (the camo corner): <image> with the cover
366
+ // mapping; the inliner embeds it like every other <image>.
367
+ const im = document.createElementNS(NS, 'image');
368
+ im.setAttribute('href', bgUrl);
369
+ im.setAttribute('xlink:href', bgUrl);
370
+ im.setAttribute('x', '0');
371
+ im.setAttribute('y', '0');
372
+ im.setAttribute('width', String(w));
373
+ im.setAttribute('height', String(h));
374
+ im.setAttribute(
375
+ 'preserveAspectRatio',
376
+ cs.backgroundSize === 'cover'
377
+ ? 'xMidYMid slice'
378
+ : cs.backgroundSize === 'contain'
379
+ ? 'xMidYMid meet'
380
+ : 'none'
381
+ );
382
+ paintNode = im;
383
+ } else if (fill) {
384
+ const rect = document.createElementNS(NS, 'rect');
385
+ rect.setAttribute('x', '0');
386
+ rect.setAttribute('y', '0');
387
+ rect.setAttribute('width', String(w));
388
+ rect.setAttribute('height', String(h));
389
+ rect.setAttribute('fill', fill);
390
+ paintNode = rect;
391
+ }
392
+ if (!paintNode) continue;
393
+
394
+ const poly = parsePolygonClip(cs.clipPath, w, h);
395
+ if (poly) {
396
+ const cp = document.createElementNS(NS, 'clipPath');
397
+ cp.setAttribute('id', `${uid}-c`);
398
+ cp.setAttribute('clipPathUnits', 'userSpaceOnUse');
399
+ const pg = document.createElementNS(NS, 'polygon');
400
+ pg.setAttribute('points', poly);
401
+ cp.appendChild(pg);
402
+ defs.appendChild(cp);
403
+ paintNode.setAttribute('clip-path', `url(#${uid}-c)`);
404
+ }
405
+ svg.appendChild(paintNode);
406
+
407
+ // Swap: hide the CSS paint dom-to-svg would mis-render, mount the SVG.
408
+ const prev = {
409
+ backgroundImage: el.style.backgroundImage,
410
+ backgroundColor: el.style.backgroundColor,
411
+ position: el.style.position,
412
+ };
413
+ el.style.backgroundImage = 'none';
414
+ if (fill && fill === cs.backgroundColor) el.style.backgroundColor = 'transparent';
415
+ if (cs.position === 'static') el.style.position = 'relative';
416
+ el.appendChild(svg);
417
+ undos.push(() => {
418
+ svg.remove();
419
+ el.style.backgroundImage = prev.backgroundImage;
420
+ el.style.backgroundColor = prev.backgroundColor;
421
+ el.style.position = prev.position;
422
+ });
423
+ }
424
+ return () => {
425
+ for (const u of undos.reverse()) u();
426
+ };
427
+ }
428
+
429
+ export interface SvgCaptureOptions {
430
+ /** Capture the enclosing `[data-dc-screen]` rather than the element itself. */
431
+ widenToArtboard?: boolean;
432
+ /**
433
+ * Ceiling on the pre-capture asset wait AND on each resource inline, in ms.
434
+ * A stalled asset must delay a capture, never hang it.
435
+ */
436
+ assetTimeoutMs?: number;
437
+ }
438
+
439
+ const DEFAULT_ASSET_TIMEOUT_MS = 10_000;
440
+
441
+ const withTimeout = <T>(p: Promise<T>, ms: number, fallback: T): Promise<T> =>
442
+ Promise.race([p, new Promise<T>((r) => setTimeout(() => r(fallback), ms))]);
443
+
444
+ /** Resolve once an `<img>` has finished loading — successfully or not. */
445
+ function imageSettled(img: HTMLImageElement): Promise<void> {
446
+ if (img.complete) return img.decode().catch(() => {});
447
+ return new Promise<void>((resolve) => {
448
+ const done = () => {
449
+ img.removeEventListener('load', done);
450
+ img.removeEventListener('error', done);
451
+ resolve();
452
+ };
453
+ img.addEventListener('load', done);
454
+ img.addEventListener('error', done);
455
+ }).then(() => img.decode().catch(() => {}));
456
+ }
457
+
458
+ /**
459
+ * Wait for the things a capture silently drops if it races them: webfonts, and
460
+ * every `<img>` inside the target.
461
+ *
462
+ * The playwright hosts get this for free — they navigate and wait for `load`
463
+ * before evaluating. The browser lane captures a LIVE canvas whose images may
464
+ * still be in flight ("musí se počkat na obrázky a assety"), so the wait has to
465
+ * live in the shared spine instead of in one host's navigation options.
466
+ *
467
+ * Bounded: a broken or hanging asset costs `timeoutMs` and then the capture
468
+ * proceeds without it. A late image beats no export.
469
+ */
470
+ export async function waitForCaptureAssets(
471
+ target: Element,
472
+ timeoutMs: number = DEFAULT_ASSET_TIMEOUT_MS
473
+ ): Promise<void> {
474
+ const doc = target.ownerDocument ?? document;
475
+ const waits: Promise<unknown>[] = [];
476
+ const fonts = (doc as Document & { fonts?: FontFaceSet }).fonts;
477
+ if (fonts?.ready) waits.push(fonts.ready.catch(() => {}));
478
+ const imgs: HTMLImageElement[] = [];
479
+ if (target instanceof HTMLImageElement) imgs.push(target);
480
+ for (const img of target.querySelectorAll('img')) imgs.push(img);
481
+ for (const img of imgs) waits.push(imageSettled(img));
482
+ await withTimeout(
483
+ Promise.all(waits).then(() => undefined),
484
+ timeoutMs,
485
+ undefined
486
+ );
487
+ }
488
+
489
+ /**
490
+ * Drop the serialized remains of hidden editor chrome.
491
+ *
492
+ * `applyCaptureChrome` makes the chrome render nothing, but dom-to-svg still
493
+ * emits an (empty) `<g>` for a `display:none` element, carrying its class and
494
+ * `aria-label` — so the artboard's title text survived into the file as
495
+ * metadata even once it stopped painting. Nothing downstream wants it: it is
496
+ * an editor affordance, not design content.
497
+ */
498
+ export function stripChromeNodes(root: Element): void {
499
+ const removedIds = new Set<string>();
500
+ for (const selector of CAPTURE_HIDDEN_SELECTORS) {
501
+ // ids are rewritten during serialization — class/attribute selectors are
502
+ // the ones that survive, and they cover every entry that can appear here.
503
+ if (selector.startsWith('#')) continue;
504
+ for (const node of Array.from(root.querySelectorAll(selector))) {
505
+ const id = node.getAttribute('id');
506
+ if (id) removedIds.add(id);
507
+ node.remove();
508
+ }
509
+ }
510
+ if (!removedIds.size) return;
511
+ // dom-to-svg mirrors the DOM's accessibility tree onto the output, so the
512
+ // artboard still points at the label it no longer contains. Leave no dangling
513
+ // idref: it is invalid ARIA, and it is how the label's name survived the
514
+ // node's removal.
515
+ for (const owner of Array.from(root.querySelectorAll('[aria-owns]'))) {
516
+ const kept = (owner.getAttribute('aria-owns') ?? '')
517
+ .split(/\s+/)
518
+ .filter((id) => id && !removedIds.has(id));
519
+ if (kept.length) owner.setAttribute('aria-owns', kept.join(' '));
520
+ else owner.removeAttribute('aria-owns');
521
+ }
522
+ }
523
+
524
+ /**
525
+ * Remove any `<image>` element still pointing at a remote `http(s)` URL after
526
+ * inlining — a beacon the capture CSP blocked from embedding but that would
527
+ * fire on open. Data URIs and in-document `#fragment` refs are kept.
528
+ */
529
+ export function dropRemoteRefs(root: Element): void {
530
+ for (const node of Array.from(root.querySelectorAll('image'))) {
531
+ const href = node.getAttribute('xlink:href') ?? node.getAttribute('href') ?? '';
532
+ if (/^\s*https?:/i.test(href)) node.remove();
533
+ }
534
+ }
535
+
536
+ const isEmbeddable = (href: string | null): href is string =>
537
+ !!href && !href.startsWith('data:') && !href.startsWith('#');
538
+
539
+ /** Read whichever href attribute dom-to-svg happened to write. */
540
+ function readHref(el: Element): string | null {
541
+ return el.getAttribute('xlink:href') ?? el.getAttribute('href');
542
+ }
543
+
544
+ /** Write BOTH, so every downstream consumer sees the embedded bytes. */
545
+ function writeHref(el: Element, value: string): void {
546
+ el.setAttribute('xlink:href', value);
547
+ el.setAttribute('href', value);
548
+ }
549
+
550
+ function blobToDataUrl(blob: Blob): Promise<string> {
551
+ return new Promise((resolve, reject) => {
552
+ const reader = new FileReader();
553
+ reader.onerror = () => reject(new Error('failed to read resource blob'));
554
+ reader.onload = () => resolve(String(reader.result));
555
+ reader.readAsDataURL(blob);
556
+ });
557
+ }
558
+
559
+ /** Fetch a resource and return it as a `data:` URI — original bytes, original
560
+ * MIME (so an SVG logo stays vector). Same-origin by construction for canvas
561
+ * assets, so the browser attaches whatever credential the live `<img>` used. */
562
+ async function dataUrlByFetch(url: string, timeoutMs: number): Promise<string | null> {
563
+ try {
564
+ const res = await withTimeout(fetch(url), timeoutMs, null as Response | null);
565
+ if (!res?.ok) return null;
566
+ return await blobToDataUrl(await res.blob());
567
+ } catch {
568
+ return null;
569
+ }
570
+ }
571
+
572
+ /**
573
+ * Last-resort inline: repaint an ALREADY-DECODED `<img>` through a canvas.
574
+ * No network, so it survives a resource the capture context may not re-fetch
575
+ * (a credentialed URL, an opaque-origin document, a revoked object URL). A
576
+ * cross-origin bitmap without CORS taints the canvas and `toDataURL` throws —
577
+ * caught, and the ref is left remote rather than emitting a wrong picture.
578
+ */
579
+ function dataUrlFromDecodedImage(img: HTMLImageElement, filter?: string): string | null {
580
+ if (!img.naturalWidth || !img.naturalHeight) return null;
581
+ try {
582
+ const canvas = document.createElement('canvas');
583
+ canvas.width = img.naturalWidth;
584
+ canvas.height = img.naturalHeight;
585
+ const ctx = canvas.getContext('2d');
586
+ if (!ctx) return null;
587
+ if (filter) {
588
+ // Bake the element's CSS `filter` into the bitmap. Canvas 2D accepts the
589
+ // same grammar as CSS — including `url(#id)` against an SVG filter in
590
+ // THIS document — so a duotone/posterize treatment survives into the
591
+ // artifact. An engine that doesn't support it leaves `ctx.filter` at
592
+ // 'none'; detect that and report failure so the caller falls back to
593
+ // the unfiltered bytes rather than silently shipping a wrong picture.
594
+ ctx.filter = filter;
595
+ if (ctx.filter !== filter) return null;
596
+ }
597
+ ctx.drawImage(img, 0, 0);
598
+ return canvas.toDataURL('image/png');
599
+ } catch {
600
+ return null;
601
+ }
602
+ }
603
+
604
+ /** The element's computed CSS `filter`, or null when it paints unfiltered. */
605
+ function imageFilterOf(img: HTMLImageElement): string | null {
606
+ const f = getComputedStyle(img).filter;
607
+ return f && f !== 'none' ? f : null;
608
+ }
609
+
610
+ /**
611
+ * The SVG `preserveAspectRatio` equivalent of an <img>'s `object-fit` +
612
+ * `object-position`, or null for the default (`contain`-like `meet` at centre).
613
+ * `object-position` maps to the nine alignment keywords by thirds — exact for
614
+ * the keyword/percentage values designs use, approximate for arbitrary lengths.
615
+ */
616
+ function preserveAspectRatioFor(img: HTMLImageElement): string | null {
617
+ const cs = getComputedStyle(img);
618
+ const fit = cs.objectFit;
619
+ if (fit === 'fill') return 'none';
620
+ const align = (pos: string, size: number): 'Min' | 'Mid' | 'Max' => {
621
+ const n = pos.endsWith('%') ? Number.parseFloat(pos) : (Number.parseFloat(pos) / size) * 100;
622
+ if (!Number.isFinite(n)) return 'Mid';
623
+ return n < 33.4 ? 'Min' : n > 66.6 ? 'Max' : 'Mid';
624
+ };
625
+ const [px = '50%', py = '50%'] = cs.objectPosition.split(/\s+/);
626
+ const x = align(px, img.clientWidth || 1);
627
+ const y = align(py, img.clientHeight || 1);
628
+ if (fit === 'cover') return `x${x}Y${y} slice`;
629
+ if (fit === 'contain' || fit === 'scale-down') return `x${x}Y${y} meet`;
630
+ return null; // `none` — no scaling; SVG has no exact analogue, keep default
631
+ }
632
+
633
+ /** Index the live `<img>` elements by the URL they actually loaded. */
634
+ function liveImagesByUrl(target: Element): Map<string, HTMLImageElement> {
635
+ const map = new Map<string, HTMLImageElement>();
636
+ const add = (img: HTMLImageElement) => {
637
+ for (const u of [img.currentSrc, img.src]) if (u) map.set(u, img);
638
+ };
639
+ if (target instanceof HTMLImageElement) add(target);
640
+ for (const img of target.querySelectorAll('img')) add(img);
641
+ return map;
642
+ }
643
+
644
+ /**
645
+ * Embed every external resource the serialized SVG still points at.
646
+ *
647
+ * WHY THIS EXISTS INSTEAD OF `domToSvg.inlineResources` (the DDR-231 Phase 2
648
+ * root cause): dom-to-svg 0.12.2 WRITES image URLs to `xlink:href` but READS
649
+ * them back as `element.href.baseVal`, which reflects the SVG2 `href`
650
+ * attribute. In a document built with `createElementNS` that is `''`, so its
651
+ * own `assert(url, 'No URL passed')` throws for every image — and it catches
652
+ * its own error and `console.error`s it. The result was an export whose
653
+ * `<image>` refs all stayed remote, with zero `data:` URIs and no thrown error
654
+ * anywhere: broken pictures in the SVG, and NOTHING in the PNG, because an
655
+ * SVG-as-image loads no external resources at all. It never worked; the desktop
656
+ * looked fine only because its PNG comes from a real screenshot, not this path.
657
+ *
658
+ * Order is deliberate — `fetch` first (keeps the original bytes and MIME, so a
659
+ * vector logo stays vector), decoded-`<img>` repaint second (no network, so it
660
+ * still works where the fetch is refused).
661
+ */
662
+ export async function inlineCaptureResources(
663
+ root: Element,
664
+ target: Element,
665
+ timeoutMs: number = DEFAULT_ASSET_TIMEOUT_MS
666
+ ): Promise<void> {
667
+ const live = liveImagesByUrl(target);
668
+ const cache = new Map<string, string | null>();
669
+ const resolve = async (url: string): Promise<string | null> => {
670
+ const hit = cache.get(url);
671
+ if (hit !== undefined) return hit;
672
+ const img = live.get(url);
673
+ // A FILTERED image (CSS `filter:` — a brand duotone, a posterize via an
674
+ // in-page SVG filter) must be baked, not fetched: dom-to-svg carries no
675
+ // `filter` onto its <image>, so the original bytes would render flat.
676
+ // DDR-232 follow-up — the cloud PNG/SVG/PPTX lost the photo treatment
677
+ // the desktop screenshot kept. Unfiltered images stay fetch-first (the
678
+ // original bytes and MIME; a vector logo stays vector).
679
+ const filter = img ? imageFilterOf(img) : null;
680
+ let out: string | null = null;
681
+ if (img && filter) out = dataUrlFromDecodedImage(img, filter);
682
+ if (!out) out = await dataUrlByFetch(url, timeoutMs);
683
+ if (!out && img) out = dataUrlFromDecodedImage(img);
684
+ cache.set(url, out);
685
+ return out;
686
+ };
687
+
688
+ const images = Array.from(root.querySelectorAll('image'));
689
+ await Promise.all(
690
+ images.map(async (node) => {
691
+ const href = readHref(node);
692
+ if (!isEmbeddable(href)) return;
693
+ const abs = new URL(href, document.baseURI).href;
694
+ // dom-to-svg writes an <img> as <image x y width height> and nothing
695
+ // else — `object-fit: cover` (every hero photo) therefore rendered
696
+ // LETTERBOXED under SVG's default `xMidYMid meet`. Carry the fit over.
697
+ const img = live.get(abs);
698
+ if (img && !node.hasAttribute('preserveAspectRatio')) {
699
+ const par = preserveAspectRatioFor(img);
700
+ if (par) node.setAttribute('preserveAspectRatio', par);
701
+ }
702
+ const dataUrl = await resolve(abs);
703
+ if (dataUrl) writeHref(node, dataUrl);
704
+ })
705
+ );
706
+
707
+ // `@font-face` (and any other `url()`) inside the emitted <style> blocks —
708
+ // dom-to-svg puts the captured stylesheet there. Without this the SVG falls
709
+ // back to a system font the moment it leaves the serving origin, which is
710
+ // the classic silent fidelity killer.
711
+ appendInlineFontFaces(root);
712
+ await Promise.all(
713
+ Array.from(root.querySelectorAll('style')).map(async (styleEl) => {
714
+ const css = styleEl.textContent ?? '';
715
+ const urls = new Set<string>();
716
+ for (const m of css.matchAll(/url\(\s*(['"]?)([^)'"]+)\1\s*\)/g)) {
717
+ const raw = m[2]?.trim();
718
+ if (isEmbeddable(raw ?? null)) urls.add(raw as string);
719
+ }
720
+ if (!urls.size) return;
721
+ const pairs = await Promise.all(
722
+ Array.from(urls, async (u) => {
723
+ let abs: string;
724
+ try {
725
+ abs = new URL(u, document.baseURI).href;
726
+ } catch {
727
+ return [u, null] as const;
728
+ }
729
+ return [u, await resolve(abs)] as const;
730
+ })
731
+ );
732
+ let next = css;
733
+ for (const [original, dataUrl] of pairs) {
734
+ if (!dataUrl) continue;
735
+ next = next.split(original).join(dataUrl);
736
+ }
737
+ styleEl.textContent = next;
738
+ })
739
+ );
740
+ }
741
+
742
+ /**
743
+ * Serialize one element (usually an artboard) to a portable SVG string:
744
+ * dom-to-svg primitives + a backdrop `<rect>` from the effective background
745
+ * (dom-to-svg doesn't paint the root's fill — the "artboard bg vanished in
746
+ * Affinity" bug) + base64-inlined fonts/images + a global CSS-Color-4 → sRGB
747
+ * rewrite so vector editors keep every fill.
748
+ */
749
+ export async function svgForElement(
750
+ el: Element,
751
+ api: DomToSvgApi,
752
+ opts: SvgCaptureOptions = {}
753
+ ): Promise<string> {
754
+ const target = opts.widenToArtboard ? (el.closest('[data-dc-screen]') ?? el) : el;
755
+ const timeoutMs = opts.assetTimeoutMs ?? DEFAULT_ASSET_TIMEOUT_MS;
756
+ // Editor chrome OFF before anything is measured: the shim host gets this from
757
+ // `?hide-chrome=1`, the live-canvas host has no navigation to carry it, and
758
+ // hiding the artboard's `.dc-artboard-label` header also reflows the body
759
+ // into its space — so it must precede getComputedStyle, not follow it.
760
+ const releaseChrome = applyCaptureChrome(target.ownerDocument ?? document);
761
+ try {
762
+ return await serializeCapture(target, api, timeoutMs);
763
+ } finally {
764
+ releaseChrome();
765
+ }
766
+ }
767
+
768
+ async function serializeCapture(
769
+ target: Element,
770
+ api: DomToSvgApi,
771
+ timeoutMs: number
772
+ ): Promise<string> {
773
+ const toSrgb = makeToSrgb();
774
+ await waitForCaptureAssets(target, timeoutMs);
775
+
776
+ const bg = effectiveBg(target);
777
+ const fillColor = bg ? toSrgb(bg) : null;
778
+ // Bracket the synchronous serialization ONLY — the substitute paint must
779
+ // never be on screen across an await.
780
+ const undoPaint = applyPaintPolyfills(target);
781
+ let svgDoc: XMLDocument;
782
+ try {
783
+ svgDoc = api.elementToSVG(target);
784
+ } finally {
785
+ undoPaint();
786
+ }
787
+ const root = svgDoc.documentElement;
788
+ if (fillColor) {
789
+ const NS = 'http://www.w3.org/2000/svg';
790
+ const rect = svgDoc.createElementNS(NS, 'rect');
791
+ const vb = (root.getAttribute('viewBox') || '').split(/\s+/).map(Number);
792
+ if (vb.length === 4 && vb.every((n) => !Number.isNaN(n))) {
793
+ rect.setAttribute('x', String(vb[0]));
794
+ rect.setAttribute('y', String(vb[1]));
795
+ rect.setAttribute('width', String(vb[2]));
796
+ rect.setAttribute('height', String(vb[3]));
797
+ } else {
798
+ rect.setAttribute('x', '0');
799
+ rect.setAttribute('y', '0');
800
+ rect.setAttribute('width', '100%');
801
+ rect.setAttribute('height', '100%');
802
+ }
803
+ rect.setAttribute('fill', fillColor);
804
+ root.insertBefore(rect, root.firstChild);
805
+ }
806
+ // base64-embeds fonts + images so the SVG is portable outside the serving
807
+ // origin — and, for the raster path, so the <img> decode is self-contained
808
+ // (an SVG-as-image loads NO external resources; a data: font/image does).
809
+ // `api.inlineResources` is deliberately NOT used — see inlineCaptureResources.
810
+ void api;
811
+ stripChromeNodes(root);
812
+ try {
813
+ await inlineCaptureResources(root, target, timeoutMs);
814
+ } catch {
815
+ /* best-effort — a missing resource beats a missing primitive */
816
+ }
817
+ // BOUNDARY, not a fixture assertion (security review F5): any `<image>` whose
818
+ // href is STILL remote after inlining — a tenant `background:url(http://
819
+ // attacker/beacon)` the capture CSP refused to fetch — would fire when a
820
+ // DIFFERENT user opens the delivered file (outside any CSP). Drop it so the
821
+ // artifact carries no phone-home. A data:/#-fragment ref is fine.
822
+ dropRemoteRefs(root);
823
+ let out = new XMLSerializer().serializeToString(svgDoc);
824
+ out = out.replace(/(?:oklch|oklab|lch|lab|color)\([^)]*\)/gi, (m) => toSrgb(m) || m);
825
+ return out;
826
+ }
827
+
828
+ /**
829
+ * Carry over `@font-face` rules dom-to-svg drops.
830
+ *
831
+ * dom-to-svg copies `@font-face` into the output ONLY from stylesheets that
832
+ * have an `href` (it needs one to absolutize relative `src` URLs) — a rule
833
+ * declared in an inline `<style>` is skipped outright, so a canvas whose font
834
+ * arrives that way renders in a fallback face everywhere but the live page.
835
+ * Append those rules, `src` made absolute against the document, so the
836
+ * `url()` inliner below treats them like every other font.
837
+ */
838
+ function appendInlineFontFaces(root: Element): void {
839
+ const doc = root.ownerDocument;
840
+ const styleEl = root.querySelector('style');
841
+ if (!styleEl) return;
842
+ const extra: string[] = [];
843
+ for (const sheet of Array.from(document.styleSheets)) {
844
+ if (sheet.href) continue; // dom-to-svg already copied these
845
+ let rules: CSSRuleList;
846
+ try {
847
+ rules = sheet.cssRules;
848
+ } catch {
849
+ continue; // cross-origin sheet — unreadable, and not ours to embed
850
+ }
851
+ for (const rule of Array.from(rules)) {
852
+ if (rule.type !== CSSRule.FONT_FACE_RULE) continue;
853
+ const text = rule.cssText.replace(
854
+ /url\(\s*(['"]?)([^)'"]+)\1\s*\)/g,
855
+ (_m, q: string, u: string) => {
856
+ try {
857
+ return `url(${q}${new URL(u, document.baseURI).href}${q})`;
858
+ } catch {
859
+ return _m;
860
+ }
861
+ }
862
+ );
863
+ extra.push(text);
864
+ }
865
+ }
866
+ if (!extra.length) return;
867
+ void doc;
868
+ styleEl.textContent = `${styleEl.textContent ?? ''}\n${extra.join('\n')}`;
869
+ }
870
+
871
+ /** Parse the root viewBox (or width/height) out of a serialized SVG. */
872
+ export function svgDimensions(svg: string): { width: number; height: number } | null {
873
+ const doc = new DOMParser().parseFromString(svg, 'image/svg+xml');
874
+ const root = doc.documentElement;
875
+ if (root.nodeName === 'parsererror') return null;
876
+ const vb = (root.getAttribute('viewBox') || '').split(/\s+/).map(Number);
877
+ if (vb.length === 4 && vb.every((n) => !Number.isNaN(n)) && vb[2] > 0 && vb[3] > 0) {
878
+ return { width: vb[2], height: vb[3] };
879
+ }
880
+ const w = Number.parseFloat(root.getAttribute('width') || '');
881
+ const h = Number.parseFloat(root.getAttribute('height') || '');
882
+ return Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0
883
+ ? { width: w, height: h }
884
+ : null;
885
+ }
886
+
887
+ /**
888
+ * Rasterize a self-contained SVG string to a PNG Blob at `scale`×. The SVG
889
+ * must already have its resources inlined ({@link svgForElement} does) — an
890
+ * external URL inside an SVG-as-image silently doesn't load, and a
891
+ * cross-origin bitmap would taint the canvas and make toBlob throw.
892
+ */
893
+ export async function rasterizeSvg(svg: string, scale: number): Promise<Blob> {
894
+ const dims = svgDimensions(svg);
895
+ if (!dims) throw new Error('capture produced an SVG with no measurable dimensions');
896
+ assertRasterSizeOk(dims.width, dims.height, scale);
897
+ const url = URL.createObjectURL(new Blob([svg], { type: 'image/svg+xml' }));
898
+ try {
899
+ const img = new Image();
900
+ img.decoding = 'async';
901
+ await new Promise<void>((resolve, reject) => {
902
+ img.onload = () => resolve();
903
+ img.onerror = () => reject(new Error('SVG rasterization failed to decode'));
904
+ img.src = url;
905
+ });
906
+ const canvas = document.createElement('canvas');
907
+ canvas.width = Math.max(1, Math.ceil(dims.width * scale));
908
+ canvas.height = Math.max(1, Math.ceil(dims.height * scale));
909
+ const ctx = canvas.getContext('2d');
910
+ if (!ctx) throw new Error('2d canvas context unavailable');
911
+ ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
912
+ const blob = await new Promise<Blob | null>((resolve) => {
913
+ canvas.toBlob(resolve, 'image/png');
914
+ });
915
+ if (!blob) throw new Error('canvas.toBlob returned null');
916
+ return blob;
917
+ } finally {
918
+ URL.revokeObjectURL(url);
919
+ }
920
+ }
921
+
922
+ const core = {
923
+ MAX_OUTPUT_SIDE_PX,
924
+ MAX_OUTPUT_BYTES,
925
+ assertRasterSizeOk,
926
+ pinArtboard,
927
+ restoreArtboard,
928
+ resetWorldPlane,
929
+ restoreWorldPlane,
930
+ svgForElement,
931
+ svgDimensions,
932
+ rasterizeSvg,
933
+ waitForCaptureAssets,
934
+ inlineCaptureResources,
935
+ stripChromeNodes,
936
+ applyPaintPolyfills,
937
+ dropRemoteRefs,
938
+ CAPTURE_HIDDEN_SELECTORS,
939
+ };
940
+
941
+ export type CaptureCore = typeof core;
942
+
943
+ // Side-effect global for the playwright-shim host (injected via addScriptTag,
944
+ // no importmap there) — mirrors video-encode-lib's `window.__maudeEnc`.
945
+ if (typeof window !== 'undefined') {
946
+ (window as unknown as { __maudeCaptureCore: CaptureCore }).__maudeCaptureCore = core;
947
+ }