@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.
- package/apps/studio/bin/_html-playwright.mjs +198 -19
- package/apps/studio/bin/_pdf-playwright.mjs +1 -0
- package/apps/studio/bin/_png-playwright.mjs +28 -8
- package/apps/studio/bin/_pptx-playwright.mjs +4 -1
- package/apps/studio/bin/_pw-launch.mjs +48 -0
- package/apps/studio/bin/_svg-playwright.mjs +63 -149
- package/apps/studio/bin/_video-playwright.mjs +123 -11
- package/apps/studio/canvas-edit.ts +33 -0
- package/apps/studio/canvas-lib.tsx +136 -0
- package/apps/studio/client/app.jsx +316 -28
- package/apps/studio/client/export-lane.js +191 -0
- package/apps/studio/dist/client.bundle.js +907 -907
- package/apps/studio/dist/runtime/.min-sizes.json +2 -1
- package/apps/studio/dist/runtime/dom-to-svg.js +31 -0
- package/apps/studio/export-dialog.tsx +24 -19
- package/apps/studio/exporters/_browser-bundles.ts +30 -0
- package/apps/studio/exporters/_runtime.ts +31 -1
- package/apps/studio/exporters/capture-chrome.ts +111 -0
- package/apps/studio/exporters/capture-core.ts +947 -0
- package/apps/studio/exporters/format-scopes.ts +88 -0
- package/apps/studio/exporters/html.ts +3 -2
- package/apps/studio/exporters/jobs.ts +180 -18
- package/apps/studio/exporters/pdf.ts +21 -1
- package/apps/studio/exporters/pptx.ts +46 -11
- package/apps/studio/exporters/remote.ts +94 -11
- package/apps/studio/exporters/scope.ts +14 -3
- package/apps/studio/exporters/svg.ts +17 -3
- package/apps/studio/exporters/video.ts +75 -8
- package/apps/studio/http.ts +175 -7
- package/apps/studio/runtime-bundle.ts +6 -0
- package/apps/studio/test/canvas-hide-chrome.test.ts +30 -0
- package/apps/studio/test/capture-core.test.ts +32 -0
- package/apps/studio/test/export-assemble.test.ts +53 -0
- package/apps/studio/test/export-browser-lane.test.ts +126 -0
- package/apps/studio/test/export-capture-fidelity.test.ts +146 -0
- package/apps/studio/test/export-capture-hygiene.test.ts +675 -0
- package/apps/studio/test/export-e2e-lanes.test.ts +857 -0
- package/apps/studio/test/export-format-scope-coherence.test.ts +89 -0
- package/apps/studio/test/exporters/jobs.test.ts +73 -0
- package/apps/studio/test/exporters/scope.test.ts +33 -0
- package/apps/studio/test/fixtures/tiny-subset.woff2 +0 -0
- package/apps/studio/test/pdf-print-boxes.test.ts +33 -0
- package/apps/studio/test/photo-canvas-bundle.test.ts +18 -0
- package/apps/studio/test/workspace-containment.test.ts +8 -0
- package/apps/studio/whats-new.json +18 -0
- package/apps/studio/workspace-mode.ts +15 -2
- package/package.json +8 -8
- 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
|
+
}
|