@1agh/maude 1.0.7 → 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 +23 -6
- 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 +45 -12
- package/apps/studio/bin/_video-playwright.mjs +123 -11
- package/apps/studio/canvas-edit.ts +33 -0
- package/apps/studio/canvas-lib.tsx +7 -0
- package/apps/studio/client/app.jsx +84 -23
- package/apps/studio/client/export-lane.js +37 -2
- package/apps/studio/dist/client.bundle.js +601 -601
- package/apps/studio/export-dialog.tsx +24 -19
- package/apps/studio/exporters/_runtime.ts +31 -1
- package/apps/studio/exporters/capture-chrome.ts +111 -0
- package/apps/studio/exporters/capture-core.ts +659 -3
- 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/remote.ts +73 -4
- package/apps/studio/exporters/scope.ts +14 -3
- package/apps/studio/exporters/video.ts +75 -8
- package/apps/studio/http.ts +74 -7
- package/apps/studio/test/canvas-hide-chrome.test.ts +30 -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/whats-new.json +9 -0
- package/package.json +8 -8
|
@@ -18,12 +18,20 @@
|
|
|
18
18
|
// with zero shims. Loaded as a side-effect module: assigns
|
|
19
19
|
// `window.__maudeCaptureCore` so the shim can reach it without an importmap.
|
|
20
20
|
|
|
21
|
+
import { applyCaptureChrome, CAPTURE_HIDDEN_SELECTORS } from './capture-chrome.ts';
|
|
22
|
+
|
|
21
23
|
/** The subset of dom-to-svg the capture uses (IIFE `window.domToSvg` in the
|
|
22
24
|
* shim host; `await import('dom-to-svg')` through the importmap in the canvas
|
|
23
25
|
* runtime host). */
|
|
24
26
|
export interface DomToSvgApi {
|
|
25
27
|
elementToSVG(el: Element): XMLDocument;
|
|
26
|
-
|
|
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>;
|
|
27
35
|
}
|
|
28
36
|
|
|
29
37
|
// Raster ceilings — mirrored from bin/_png-playwright.mjs (feature-2-print-
|
|
@@ -163,9 +171,572 @@ function effectiveBg(node: Element): string | null {
|
|
|
163
171
|
return null;
|
|
164
172
|
}
|
|
165
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
|
+
|
|
166
429
|
export interface SvgCaptureOptions {
|
|
167
430
|
/** Capture the enclosing `[data-dc-screen]` rather than the element itself. */
|
|
168
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
|
+
);
|
|
169
740
|
}
|
|
170
741
|
|
|
171
742
|
/**
|
|
@@ -181,11 +752,38 @@ export async function svgForElement(
|
|
|
181
752
|
opts: SvgCaptureOptions = {}
|
|
182
753
|
): Promise<string> {
|
|
183
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> {
|
|
184
773
|
const toSrgb = makeToSrgb();
|
|
774
|
+
await waitForCaptureAssets(target, timeoutMs);
|
|
185
775
|
|
|
186
776
|
const bg = effectiveBg(target);
|
|
187
777
|
const fillColor = bg ? toSrgb(bg) : null;
|
|
188
|
-
|
|
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
|
+
}
|
|
189
787
|
const root = svgDoc.documentElement;
|
|
190
788
|
if (fillColor) {
|
|
191
789
|
const NS = 'http://www.w3.org/2000/svg';
|
|
@@ -208,16 +806,68 @@ export async function svgForElement(
|
|
|
208
806
|
// base64-embeds fonts + images so the SVG is portable outside the serving
|
|
209
807
|
// origin — and, for the raster path, so the <img> decode is self-contained
|
|
210
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);
|
|
211
812
|
try {
|
|
212
|
-
await
|
|
813
|
+
await inlineCaptureResources(root, target, timeoutMs);
|
|
213
814
|
} catch {
|
|
214
815
|
/* best-effort — a missing resource beats a missing primitive */
|
|
215
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);
|
|
216
823
|
let out = new XMLSerializer().serializeToString(svgDoc);
|
|
217
824
|
out = out.replace(/(?:oklch|oklab|lch|lab|color)\([^)]*\)/gi, (m) => toSrgb(m) || m);
|
|
218
825
|
return out;
|
|
219
826
|
}
|
|
220
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
|
+
|
|
221
871
|
/** Parse the root viewBox (or width/height) out of a serialized SVG. */
|
|
222
872
|
export function svgDimensions(svg: string): { width: number; height: number } | null {
|
|
223
873
|
const doc = new DOMParser().parseFromString(svg, 'image/svg+xml');
|
|
@@ -280,6 +930,12 @@ const core = {
|
|
|
280
930
|
svgForElement,
|
|
281
931
|
svgDimensions,
|
|
282
932
|
rasterizeSvg,
|
|
933
|
+
waitForCaptureAssets,
|
|
934
|
+
inlineCaptureResources,
|
|
935
|
+
stripChromeNodes,
|
|
936
|
+
applyPaintPolyfills,
|
|
937
|
+
dropRemoteRefs,
|
|
938
|
+
CAPTURE_HIDDEN_SELECTORS,
|
|
283
939
|
};
|
|
284
940
|
|
|
285
941
|
export type CaptureCore = typeof core;
|