@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.
Files changed (34) 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 +23 -6
  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 +45 -12
  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 +7 -0
  10. package/apps/studio/client/app.jsx +84 -23
  11. package/apps/studio/client/export-lane.js +37 -2
  12. package/apps/studio/dist/client.bundle.js +601 -601
  13. package/apps/studio/export-dialog.tsx +24 -19
  14. package/apps/studio/exporters/_runtime.ts +31 -1
  15. package/apps/studio/exporters/capture-chrome.ts +111 -0
  16. package/apps/studio/exporters/capture-core.ts +659 -3
  17. package/apps/studio/exporters/format-scopes.ts +88 -0
  18. package/apps/studio/exporters/html.ts +3 -2
  19. package/apps/studio/exporters/jobs.ts +180 -18
  20. package/apps/studio/exporters/pdf.ts +21 -1
  21. package/apps/studio/exporters/remote.ts +73 -4
  22. package/apps/studio/exporters/scope.ts +14 -3
  23. package/apps/studio/exporters/video.ts +75 -8
  24. package/apps/studio/http.ts +74 -7
  25. package/apps/studio/test/canvas-hide-chrome.test.ts +30 -0
  26. package/apps/studio/test/export-capture-hygiene.test.ts +675 -0
  27. package/apps/studio/test/export-e2e-lanes.test.ts +857 -0
  28. package/apps/studio/test/export-format-scope-coherence.test.ts +89 -0
  29. package/apps/studio/test/exporters/jobs.test.ts +73 -0
  30. package/apps/studio/test/exporters/scope.test.ts +33 -0
  31. package/apps/studio/test/fixtures/tiny-subset.woff2 +0 -0
  32. package/apps/studio/test/pdf-print-boxes.test.ts +33 -0
  33. package/apps/studio/whats-new.json +9 -0
  34. 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
- inlineResources(el: Element): Promise<void>;
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
- const svgDoc = api.elementToSVG(target);
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 api.inlineResources(root);
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;