@1agh/maude 1.0.5 → 1.0.7

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.
@@ -28,6 +28,14 @@ import { sizingModeOf, sizingModePatch } from '../sizing-mode.ts';
28
28
  // above (a type-only SyncStatusSnapshot import that Bun erases).
29
29
  import { syncPresentation } from '../sync/presentation.ts';
30
30
  import { canvasUrl } from './canvas-url.js';
31
+ import {
32
+ BROWSER_CAPTURE_FORMATS,
33
+ BROWSER_SERVABLE_FORMATS,
34
+ browserCaptureEligible,
35
+ captureDeckViaBrowser,
36
+ captureScale,
37
+ sanitizeCapturedItems,
38
+ } from './export-lane.js';
31
39
  import { TreeRowMenu, useRowMenu } from './tree-row-menu.jsx';
32
40
  import { useTreeDrag } from './use-tree-drag.js';
33
41
  import ChatPanel from './panels/ChatPanel.jsx';
@@ -1530,6 +1538,21 @@ function AssetPicker({
1530
1538
  );
1531
1539
  }
1532
1540
 
1541
+ // Direct download for a browser-lane capture. export-center's autoDownloadBlob
1542
+ // fetches a JOB's bytes — a browser capture has no job, just the Blob.
1543
+ function downloadCapturedBlob(name, blob) {
1544
+ const url = URL.createObjectURL(blob);
1545
+ const a = document.createElement('a');
1546
+ a.href = url;
1547
+ a.download = name || 'export';
1548
+ document.body.appendChild(a);
1549
+ a.click();
1550
+ a.remove();
1551
+ // Revoke on a delay — an immediate revoke can race the download start in
1552
+ // some engines (the click only queues the fetch of the object URL).
1553
+ setTimeout(() => URL.revokeObjectURL(url), 10_000);
1554
+ }
1555
+
1533
1556
  function ExportDialog({
1534
1557
  mode,
1535
1558
  initialScope,
@@ -1539,22 +1562,27 @@ function ExportDialog({
1539
1562
  activeArtboardId = null,
1540
1563
  selection = null,
1541
1564
  exportLane = 'local',
1565
+ onBrowserCapture = null,
1542
1566
  onClose,
1543
1567
  }) {
1544
1568
  // feature-cloud-export-render-workers — on a cell with no render service
1545
1569
  // (`lane === 'none'`), formats that render through a browser are offered
1546
1570
  // disabled-with-a-reason instead of firing a request the proxy 404s. ZIP
1547
1571
  // (JSZip over project files) and the AI-handoff card (clipboard copy) need
1548
- // no browser and stay live in every lane.
1572
+ // no browser — and DDR-231's browser lane keeps png/svg live in every
1573
+ // workspace lane too (captured by the member's own browser, no service).
1549
1574
  const laneBlocked = useCallback(
1550
1575
  (c) =>
1551
- (exportLane === 'none' && !c.handoff && c.format !== 'zip') ||
1576
+ (exportLane === 'none' &&
1577
+ !c.handoff &&
1578
+ c.format !== 'zip' &&
1579
+ !BROWSER_SERVABLE_FORMATS.has(c.format)) ||
1552
1580
  // Canva bundles project files the render service can't reach — desktop only
1553
1581
  // in every workspace lane (exporters/remote.ts REMOTE_UNSUPPORTED_FORMATS).
1554
1582
  (exportLane !== 'local' && c.format === 'canva'),
1555
1583
  [exportLane]
1556
1584
  );
1557
- const [sel, setSel] = useState(mode === 'handoff' ? 'shadcn' : exportLane === 'none' ? 'zip' : 'png');
1585
+ const [sel, setSel] = useState(mode === 'handoff' ? 'shadcn' : 'png');
1558
1586
  const [scope, setScope] = useState(
1559
1587
  initialScope && EXPORT_SCOPE_LABELS[initialScope] ? initialScope : 'artboard'
1560
1588
  );
@@ -1606,6 +1634,15 @@ function ExportDialog({
1606
1634
  if (validScopes.length && !validScopes.includes(scope)) setScope(validScopes[0]);
1607
1635
  }, [validScopes, scope]);
1608
1636
 
1637
+ // DDR-231 T7 — wake the render service the moment the dialog opens on a
1638
+ // remote-lane workspace: the multi-GB Chromium container starts booting
1639
+ // while the member is still picking format/scope, so a video/PDF job lands
1640
+ // on a warm (or at least warming) service instead of a cold one.
1641
+ useEffect(() => {
1642
+ if (exportLane !== 'remote') return;
1643
+ fetch('/_api/export-warmup').catch(() => {});
1644
+ }, [exportLane]);
1645
+
1609
1646
  const loadRecent = useCallback(() => {
1610
1647
  fetch('/_api/export-history')
1611
1648
  .then((r) => r.json())
@@ -1678,6 +1715,88 @@ function ExportDialog({
1678
1715
  // the in-canvas dialog's captureScopeHints.
1679
1716
  if (activeArtboardId) options.artboardId = activeArtboardId;
1680
1717
  if (selection?.selector) options.selection = selection;
1718
+ // DDR-231 — the browser lane: in a workspace, png/svg of the active
1719
+ // artboard is captured by the member's OWN browser (the canvas already
1720
+ // renders here) — instant, no fleet wake. Everything else continues to
1721
+ // the jobs lane below.
1722
+ const browserEligible =
1723
+ browserCaptureEligible({
1724
+ exportLane,
1725
+ format: card.format,
1726
+ scope,
1727
+ artboardId: options.artboardId,
1728
+ }) && typeof onBrowserCapture === 'function';
1729
+ if (browserEligible) {
1730
+ try {
1731
+ const capScale = captureScale(options);
1732
+ setStatus({ ok: true, msg: 'Capturing…' });
1733
+ const items = await onBrowserCapture({
1734
+ format: card.format,
1735
+ artboardIds: [options.artboardId],
1736
+ scale: capScale,
1737
+ onProgress: (current, total) =>
1738
+ setStatus({ ok: true, msg: `Capturing ${current}/${total}…` }),
1739
+ });
1740
+ // Validate before writing to disk — the capture bridge shares a window
1741
+ // with tenant TSX, which can forge a reply (DDR-231 security pass, F1):
1742
+ // caps count/bytes, sniffs magic, forces name+MIME.
1743
+ const safeItems = await sanitizeCapturedItems(items, card.format);
1744
+ for (const it of safeItems) downloadCapturedBlob(it.name, it.blob);
1745
+ setBusy(false);
1746
+ onClose();
1747
+ return;
1748
+ } catch (err) {
1749
+ if (exportLane !== 'remote') {
1750
+ setStatus({ ok: false, msg: `Capture failed: ${(err && err.message) || err}` });
1751
+ setBusy(false);
1752
+ return;
1753
+ }
1754
+ // A render service exists — degrade to the slower jobs lane instead of
1755
+ // a dead end (and the fallback keeps the worker path exercised).
1756
+ }
1757
+ }
1758
+ // DDR-231 — the pptx deck: capture every artboard as PNG in THIS browser,
1759
+ // compose in-cell (/_api/export-assemble — the zip containment class).
1760
+ if (
1761
+ browserCaptureEligible({ exportLane, format: card.format, scope }) &&
1762
+ card.format === 'pptx' &&
1763
+ typeof onBrowserCapture === 'function'
1764
+ ) {
1765
+ try {
1766
+ setStatus({ ok: true, msg: 'Capturing artboards…' });
1767
+ const deckName =
1768
+ activePath && activePath !== SYSTEM_TAB
1769
+ ? basename(activePath).replace(/\.[^.]+$/, '')
1770
+ : 'export';
1771
+ const { filename, blob } = await captureDeckViaBrowser({
1772
+ capture: onBrowserCapture,
1773
+ name: deckName,
1774
+ onProgress: (current, total) =>
1775
+ setStatus({ ok: true, msg: `Capturing ${current}/${total}…` }),
1776
+ });
1777
+ downloadCapturedBlob(filename, blob);
1778
+ setBusy(false);
1779
+ onClose();
1780
+ return;
1781
+ } catch (err) {
1782
+ if (exportLane !== 'remote') {
1783
+ setStatus({ ok: false, msg: `Deck export failed: ${(err && err.message) || err}` });
1784
+ setBusy(false);
1785
+ return;
1786
+ }
1787
+ // Render service available — degrade to the jobs lane below.
1788
+ }
1789
+ }
1790
+ if (exportLane === 'none' && BROWSER_CAPTURE_FORMATS.has(card.format)) {
1791
+ // Without a render service the browser can only capture what it renders:
1792
+ // the active artboard. Other scopes have nowhere to run.
1793
+ setStatus({
1794
+ ok: false,
1795
+ msg: 'Without the render service this workspace exports PNG/SVG of the active artboard only — switch Scope to “Active artboard”, or ask your admin to add maude-render.',
1796
+ });
1797
+ setBusy(false);
1798
+ return;
1799
+ }
1681
1800
  try {
1682
1801
  // feature-background-export-notification-center — enqueue and close
1683
1802
  // immediately; the menubar notification center owns status, progress,
@@ -1745,10 +1864,11 @@ function ExportDialog({
1745
1864
  </div>
1746
1865
  {exportLane === 'none' && (
1747
1866
  <div className="st-dialog-note" data-testid="export-lane-note">
1748
- Rendered exports (PNG, PDF, video…) need the render service, which this
1749
- workspace doesn&apos;t have configured. ZIP and AI handoff still work here —
1750
- for everything else use the desktop app, or ask your admin to add the
1751
- <code> maude-render</code> service (see the self-hosting docs).
1867
+ PNG and SVG of the active artboard — and the PPTX deck — export right
1868
+ here in your browser; ZIP and AI handoff work too. PDF, video and other
1869
+ multi-artboard exports need the render service, which this workspace
1870
+ doesn&apos;t have configured — use the desktop app, or ask your admin to
1871
+ add the <code> maude-render</code> service (see the self-hosting docs).
1752
1872
  </div>
1753
1873
  )}
1754
1874
  {!card.handoff && (
@@ -10286,6 +10406,58 @@ function App() {
10286
10406
  [activePath]
10287
10407
  );
10288
10408
 
10409
+ // DDR-231 (hybrid export lanes) — ask the active canvas iframe to capture
10410
+ // its own artboards (canvas-lib's useExportCaptureBridge) and hand back the
10411
+ // blobs. The shell can't reach the cross-origin canvas DOM, and the sandbox
10412
+ // omits allow-downloads, so this is the division of labour: the CANVAS
10413
+ // captures (it renders the pixels), the SHELL downloads. Resolves with
10414
+ // [{ name, type, blob }]; rejects on canvas error or timeout.
10415
+ const browserCaptureSeq = useRef(0);
10416
+ const captureFromCanvas = useCallback(
10417
+ ({ format, artboardIds = null, scale = 1, onProgress, timeoutMs = 120_000 }) =>
10418
+ new Promise((resolve, reject) => {
10419
+ const el = activePath ? iframesRef.current.get(activePath) : null;
10420
+ if (!el || !el.contentWindow) {
10421
+ reject(new Error('no active canvas to capture'));
10422
+ return;
10423
+ }
10424
+ const cw = el.contentWindow;
10425
+ browserCaptureSeq.current += 1;
10426
+ const id = `cap-${Date.now().toString(36)}-${browserCaptureSeq.current}`;
10427
+ let settled = false;
10428
+ const finish = (fn, arg) => {
10429
+ if (settled) return;
10430
+ settled = true;
10431
+ window.removeEventListener('message', onMsg);
10432
+ clearTimeout(timer);
10433
+ fn(arg);
10434
+ };
10435
+ const onMsg = (e) => {
10436
+ // Only answers from the iframe we asked — a message with a matching
10437
+ // id from any other window is ignored.
10438
+ if (e.source !== cw) return;
10439
+ const m = e.data;
10440
+ if (!m || m.id !== id) return;
10441
+ if (m.dgn === 'export-capture-progress') onProgress?.(m.current, m.total);
10442
+ else if (m.dgn === 'export-capture-done')
10443
+ finish(resolve, Array.isArray(m.items) ? m.items : []);
10444
+ else if (m.dgn === 'export-capture-error')
10445
+ finish(reject, new Error(m.message || 'capture failed'));
10446
+ };
10447
+ const timer = setTimeout(
10448
+ () => finish(reject, new Error('capture timed out — the canvas did not answer')),
10449
+ timeoutMs
10450
+ );
10451
+ window.addEventListener('message', onMsg);
10452
+ try {
10453
+ cw.postMessage({ dgn: 'export-capture', id, format, artboardIds, scale }, '*');
10454
+ } catch (err) {
10455
+ finish(reject, err);
10456
+ }
10457
+ }),
10458
+ [activePath]
10459
+ );
10460
+
10289
10461
  // ── feature-photo-editor — the Photo tab's three channels ─────────────────
10290
10462
  // (1) live preview: broadcast the edit DOWN to the active canvas iframe, whose
10291
10463
  // canvas-lib `PhotoPreviewBridge` bakes the composite and swaps it directly
@@ -12991,6 +13163,16 @@ function App() {
12991
13163
  // report status back to the iframe. Origin is already validated
12992
13164
  // (e.origin === expectedOrigin) above, so only the real canvas iframe
12993
13165
  // can ask — this is NOT a generic fetch proxy.
13166
+ //
13167
+ // DDR-231 security (M1): additionally require the request come from the
13168
+ // ACTIVE canvas — captureFromCanvas + the browser-lane branch capture
13169
+ // and auto-download `activePath`'s pixels regardless of who asked, so
13170
+ // WITHOUT this gate a background (untrusted, DDR-054) same-origin canvas
13171
+ // could `postMessage({dgn:'export-request'})` with no user gesture and
13172
+ // force a silent download of whatever the user is currently looking at.
13173
+ // Same one-liner every sibling mutating branch already uses.
13174
+ const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
13175
+ if (e.source !== activeWin) return;
12994
13176
  void runBridgedExport(e.source, m.id, m.payload);
12995
13177
  } else if (m.dgn === 'export-history-request' && m.id) {
12996
13178
  // Same bridge for the dialog's Recent tab (/_api/export-history is
@@ -13006,6 +13188,49 @@ function App() {
13006
13188
  if (source) source.postMessage({ dgn: 'export-result', id, ...msg }, replyOrigin);
13007
13189
  } catch {}
13008
13190
  };
13191
+ // DDR-231 — the browser lane, mirrored for the IN-CANVAS dialog: png/svg
13192
+ // of a known artboard is captured by the asking canvas itself (the shell
13193
+ // relays the request back over the export-capture bridge and downloads
13194
+ // the blobs — the sandboxed iframe can't download, the shell can).
13195
+ const opts = payload?.options || {};
13196
+ const bridgedBrowserEligible = browserCaptureEligible({
13197
+ exportLane: cfg.exportLane || 'local',
13198
+ format: payload?.format,
13199
+ scope: payload?.scope,
13200
+ artboardId: opts.artboardId,
13201
+ });
13202
+ if (bridgedBrowserEligible) {
13203
+ try {
13204
+ if (payload.format === 'pptx') {
13205
+ const { filename, blob } = await captureDeckViaBrowser({
13206
+ capture: captureFromCanvas,
13207
+ name: activePath ? activePath.replace(/^.*\//, '').replace(/\.[^.]+$/, '') : 'export',
13208
+ });
13209
+ downloadCapturedBlob(filename, blob);
13210
+ } else {
13211
+ const capScale = captureScale(opts);
13212
+ const items = await captureFromCanvas({
13213
+ format: payload.format,
13214
+ artboardIds: [opts.artboardId],
13215
+ scale: capScale,
13216
+ });
13217
+ const safeItems = await sanitizeCapturedItems(items, payload.format);
13218
+ for (const it of safeItems) downloadCapturedBlob(it.name, it.blob);
13219
+ }
13220
+ reply({ ok: true, browser: true });
13221
+ return;
13222
+ } catch {
13223
+ if (cfg.exportLane !== 'remote') {
13224
+ reply({
13225
+ ok: false,
13226
+ error:
13227
+ 'Capturing in the browser failed and this workspace has no render service to fall back to — try again, or ask your admin to add maude-render.',
13228
+ });
13229
+ return;
13230
+ }
13231
+ // Render service available — degrade to the jobs lane below.
13232
+ }
13233
+ }
13009
13234
  // feature-cloud-export-render-workers — a workspace with no render
13010
13235
  // service can't produce browser-rendered formats; answer the in-canvas
13011
13236
  // dialog with the reason instead of relaying a request the proxy 404s.
@@ -13014,7 +13239,7 @@ function App() {
13014
13239
  reply({
13015
13240
  ok: false,
13016
13241
  error:
13017
- 'This format needs the render service, which this workspace doesn’t have configured. ZIP still works here; for rendered formats use the desktop app or ask your admin to add maude-render.',
13242
+ 'This format needs the render service, which this workspace doesn’t have configured. PNG/SVG of the active artboard and the PPTX deck export straight from the browser; ZIP works too. For the rest use the desktop app or ask your admin to add maude-render.',
13018
13243
  });
13019
13244
  return;
13020
13245
  }
@@ -13068,6 +13293,7 @@ function App() {
13068
13293
  broadcastChrome,
13069
13294
  activePath,
13070
13295
  postToActiveCanvas,
13296
+ captureFromCanvas,
13071
13297
  ]);
13072
13298
 
13073
13299
  // Tell the active canvas iframe to drop any persistent selection (canvas
@@ -15831,6 +16057,7 @@ function App() {
15831
16057
  activeArtboardId={selected?.artboardId ?? canvasActiveArtboard ?? null}
15832
16058
  selection={selected?.selector ? { selector: selected.selector, file: selected.file } : null}
15833
16059
  exportLane={cfg.exportLane || 'local'}
16060
+ onBrowserCapture={captureFromCanvas}
15834
16061
  onClose={() => setExportDialog(null)}
15835
16062
  />
15836
16063
  )}
@@ -0,0 +1,156 @@
1
+ // export-lane.js — DDR-231 (hybrid export lanes), client half.
2
+ //
3
+ // The SINGLE definition of "can the member's own browser capture this export
4
+ // request", shared by the shell ExportDialog and the bridged in-canvas
5
+ // handler in app.jsx — and unit-tested without a DOM
6
+ // (test/export-browser-lane.test.ts). If the rule forks between the two entry
7
+ // points, one dialog silently routes through the fleet the other avoids.
8
+ //
9
+ // The rule: a workspace (any non-local lane) captures png/svg of a KNOWN
10
+ // active artboard in the browser — the canvas already renders there, so the
11
+ // capture is instant and needs no render service. Everything else (other
12
+ // formats, multi-artboard scopes, selection captures) continues to the jobs
13
+ // lane: `remote` renders it on maude-render, `none` refuses with a remedy.
14
+
15
+ /** Formats the canvas-lib export-capture bridge can produce today. */
16
+ export const BROWSER_CAPTURE_FORMATS = new Set(['png', 'svg']);
17
+
18
+ /**
19
+ * Formats a workspace can serve WITHOUT the render service thanks to the
20
+ * browser lane — the dialog's card-blocking check on lane `none`. pptx rides
21
+ * the capture bridge for its PNGs and the in-cell assemble route
22
+ * (`/_api/export-assemble`) for composition.
23
+ */
24
+ export const BROWSER_SERVABLE_FORMATS = new Set(['png', 'svg', 'pptx']);
25
+
26
+ /**
27
+ * @param {{ exportLane?: string, format?: string, scope?: string, artboardId?: unknown }} req
28
+ * @returns {boolean}
29
+ */
30
+ export function browserCaptureEligible({ exportLane, format, scope, artboardId }) {
31
+ if (exportLane === 'local' || exportLane === undefined) return false;
32
+ if (BROWSER_CAPTURE_FORMATS.has(format)) return scope === 'artboard' && !!artboardId;
33
+ // The deck: every artboard captured as PNG in the browser, composed in-cell.
34
+ if (format === 'pptx') return scope === 'canvas-as-separate';
35
+ return false;
36
+ }
37
+
38
+ /**
39
+ * DDR-231 — the pptx browser lane, shared by the shell ExportDialog and the
40
+ * bridged in-canvas handler: capture EVERY artboard as PNG via the bridge,
41
+ * POST them to the in-cell assemble route, hand back the finished deck.
42
+ * 3× mirrors the desktop PNG-deck fallback's FALLBACK_SCALE (crisp in
43
+ * non-vector viewers).
44
+ *
45
+ * @param {{ capture: Function, name?: string, scale?: number, onProgress?: Function }} args
46
+ * @returns {Promise<{ filename: string, blob: Blob }>}
47
+ */
48
+ export async function captureDeckViaBrowser({ capture, name = 'export', scale = 3, onProgress }) {
49
+ const items = await capture({
50
+ format: 'png',
51
+ artboardIds: null,
52
+ scale,
53
+ onProgress,
54
+ timeoutMs: 300_000,
55
+ });
56
+ if (!items || !items.length) throw new Error('capture returned nothing');
57
+ const fd = new FormData();
58
+ fd.set('format', 'pptx');
59
+ fd.set('scale', String(scale));
60
+ fd.set('name', name);
61
+ for (const it of items) fd.append('image', it.blob, it.name);
62
+ const r = await fetch('/_api/export-assemble', { method: 'POST', body: fd });
63
+ if (!r.ok) throw new Error((await r.text()) || `deck assembly failed (${r.status})`);
64
+ const disp = r.headers.get('content-disposition') || '';
65
+ const filename = /filename="([^"]+)"/.exec(disp)?.[1] || `${name}.pptx`;
66
+ return { filename, blob: await r.blob() };
67
+ }
68
+
69
+ const SAFE_CAPTURE_MIME = { png: 'image/png', svg: 'image/svg+xml' };
70
+ const PNG_MAGIC = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
71
+ // A forged reply can't be allowed to drive an unbounded number of downloads or
72
+ // buffer an arbitrary blob into the member's browser (DDR-231 adversarial pass,
73
+ // F1 caps). A png/svg export targets ONE artboard, so these are generous.
74
+ export const MAX_CAPTURE_ITEMS = 64;
75
+ export const MAX_CAPTURE_TOTAL_BYTES = 96 * 1024 * 1024;
76
+
77
+ /** Safe download basename for a captured item: path-stripped, charset-limited,
78
+ * with the requested format's extension forced on. */
79
+ function safeCaptureName(name, format) {
80
+ const ext = format === 'svg' ? 'svg' : 'png';
81
+ const raw = typeof name === 'string' ? name : '';
82
+ let base = raw
83
+ .replace(/^.*[\\/]/, '') // drop any path
84
+ .replace(/[^A-Za-z0-9._-]/g, '_')
85
+ .replace(new RegExp(`\\.${ext}$`, 'i'), '');
86
+ if (!base || /^\.+$/.test(base)) base = 'artboard';
87
+ return `${base.slice(0, 200)}.${ext}`;
88
+ }
89
+
90
+ /** True iff `bytes` actually begins with the requested format's signature —
91
+ * PNG 8-byte magic, or SVG/XML text for svg. */
92
+ function magicMatches(bytes, format) {
93
+ if (format === 'png') return PNG_MAGIC.every((b, i) => bytes[i] === b);
94
+ // svg: allow a leading BOM/whitespace, then `<svg` or `<?xml`.
95
+ const head = new TextDecoder('utf-8', { fatal: false })
96
+ .decode(bytes.slice(0, 64))
97
+ .replace(/^/, '')
98
+ .trimStart()
99
+ .toLowerCase();
100
+ return head.startsWith('<svg') || head.startsWith('<?xml');
101
+ }
102
+
103
+ /**
104
+ * DDR-231 security (adversarial pass, F1) — the capture bridge lives in the
105
+ * CANVAS iframe, and tenant-authored TSX executes in that same window, so
106
+ * tenant code can FORGE an `export-capture-done` reply carrying arbitrary bytes
107
+ * + name that passes the shell's `e.source` check. Before the shell writes any
108
+ * of it to disk, treat the whole reply as untrusted DATA:
109
+ * - cap the item count and total bytes (a forged reply must not drive 1000
110
+ * downloads or buffer a giant blob);
111
+ * - for each item, SNIFF the leading magic bytes and reject anything that
112
+ * isn't actually the requested format (an HTML/exe payload disguised as a
113
+ * `.png` is dropped, not merely relabelled);
114
+ * - derive the filename from the request, not the reply, and force the
115
+ * format's extension + MIME.
116
+ *
117
+ * @param {Array<{ name?: string, blob: Blob }>} items
118
+ * @param {string} format 'png' | 'svg'
119
+ * @returns {Promise<Array<{ name: string, blob: Blob }>>}
120
+ */
121
+ export async function sanitizeCapturedItems(items, format) {
122
+ if (!Array.isArray(items) || !items.length) throw new Error('capture returned nothing');
123
+ if (items.length > MAX_CAPTURE_ITEMS)
124
+ throw new Error(`capture returned too many items (${items.length})`);
125
+ const mime = SAFE_CAPTURE_MIME[format] || 'application/octet-stream';
126
+ const out = [];
127
+ let total = 0;
128
+ for (const item of items) {
129
+ if (!(item?.blob instanceof Blob)) throw new Error('capture item is not a blob');
130
+ total += item.blob.size;
131
+ if (total > MAX_CAPTURE_TOTAL_BYTES) throw new Error('capture payload too large');
132
+ const head = new Uint8Array(await item.blob.slice(0, 64).arrayBuffer());
133
+ if (!magicMatches(head, format))
134
+ throw new Error(`capture item is not a valid ${format} — refusing to download`);
135
+ out.push({
136
+ name: safeCaptureName(item.name, format),
137
+ // Re-wrap so the download Content-Type is the format's MIME regardless of
138
+ // what the (untrusted) reply claimed.
139
+ blob: new Blob([item.blob], { type: mime }),
140
+ });
141
+ }
142
+ return out;
143
+ }
144
+
145
+ /**
146
+ * Fold the dialog's resolution options (scale multiplier OR physical dpi —
147
+ * dpi wins, mirroring exporters/png.ts resolveDeviceScale) into the capture
148
+ * bridge's deviceScale, clamped to the raster guard's 1–8 window
149
+ * (capture-core.ts assertRasterSizeOk enforces the byte/side ceilings).
150
+ *
151
+ * @param {{ dpi?: number, scale?: number }} [options]
152
+ * @returns {number}
153
+ */
154
+ export function captureScale(options = {}) {
155
+ return Math.max(1, Math.min(8, options.dpi ? options.dpi / 96 : options.scale || 1));
156
+ }