@1agh/maude 1.0.6 → 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.
- package/apps/studio/bin/_png-playwright.mjs +5 -2
- package/apps/studio/bin/_svg-playwright.mjs +26 -145
- package/apps/studio/canvas-lib.tsx +129 -0
- package/apps/studio/client/app.jsx +235 -8
- package/apps/studio/client/export-lane.js +156 -0
- package/apps/studio/dist/client.bundle.js +850 -850
- package/apps/studio/dist/runtime/.min-sizes.json +2 -1
- package/apps/studio/dist/runtime/dom-to-svg.js +31 -0
- package/apps/studio/exporters/_browser-bundles.ts +30 -0
- package/apps/studio/exporters/capture-core.ts +291 -0
- package/apps/studio/exporters/pptx.ts +46 -11
- package/apps/studio/exporters/remote.ts +23 -9
- package/apps/studio/exporters/svg.ts +17 -3
- package/apps/studio/http.ts +101 -0
- package/apps/studio/runtime-bundle.ts +6 -0
- package/apps/studio/test/capture-core.test.ts +32 -0
- package/apps/studio/test/export-assemble.test.ts +53 -0
- package/apps/studio/test/export-browser-lane.test.ts +126 -0
- package/apps/studio/test/export-capture-fidelity.test.ts +146 -0
- package/apps/studio/test/photo-canvas-bundle.test.ts +18 -0
- package/apps/studio/test/workspace-containment.test.ts +8 -0
- package/apps/studio/whats-new.json +9 -0
- package/apps/studio/workspace-mode.ts +15 -2
- package/package.json +8 -8
- package/plugins/design/templates/_shell.html +2 -1
|
@@ -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
|
|
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' &&
|
|
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' :
|
|
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
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
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'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.
|
|
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
|
+
}
|