@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.
- 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 +18 -0
- package/apps/studio/workspace-mode.ts +15 -2
- package/package.json +8 -8
- package/plugins/design/templates/_shell.html +2 -1
|
@@ -84,10 +84,13 @@ try {
|
|
|
84
84
|
// failure, so the success path and desktop output are unchanged.
|
|
85
85
|
const diag = [];
|
|
86
86
|
page.on('console', (m) => {
|
|
87
|
-
if (m.type() === 'error' || m.type() === 'warning')
|
|
87
|
+
if (m.type() === 'error' || m.type() === 'warning')
|
|
88
|
+
diag.push(`console.${m.type()}: ${m.text()}`);
|
|
88
89
|
});
|
|
89
90
|
page.on('pageerror', (e) => diag.push(`pageerror: ${e.message}`));
|
|
90
|
-
page.on('requestfailed', (r) =>
|
|
91
|
+
page.on('requestfailed', (r) =>
|
|
92
|
+
diag.push(`requestfailed: ${r.url()} — ${r.failure()?.errorText ?? '?'}`)
|
|
93
|
+
);
|
|
91
94
|
try {
|
|
92
95
|
await page.goto(url, { waitUntil: 'load', timeout: timeoutMs });
|
|
93
96
|
} catch (e) {
|
|
@@ -33,14 +33,16 @@ const {
|
|
|
33
33
|
out,
|
|
34
34
|
'out-dir': outDir,
|
|
35
35
|
'bundle-path': bundlePath,
|
|
36
|
+
'core-path': corePath,
|
|
36
37
|
'widen-to-artboard': widenFlag,
|
|
37
38
|
multi: multiFlag,
|
|
38
39
|
timeout = '12',
|
|
39
40
|
} = args;
|
|
40
41
|
|
|
41
|
-
if (!url || !bundlePath) {
|
|
42
|
+
if (!url || !bundlePath || !corePath) {
|
|
42
43
|
console.error(
|
|
43
|
-
'usage: _svg-playwright.mjs --url <url> --selector <css> --out <path>
|
|
44
|
+
'usage: _svg-playwright.mjs --url <url> --selector <css> --out <path> ' +
|
|
45
|
+
'--bundle-path <iife.js> --core-path <capture-core.js>'
|
|
44
46
|
);
|
|
45
47
|
process.exit(2);
|
|
46
48
|
}
|
|
@@ -71,9 +73,15 @@ try {
|
|
|
71
73
|
world.style.transform = 'none';
|
|
72
74
|
}
|
|
73
75
|
});
|
|
74
|
-
// Inject dom-to-svg
|
|
75
|
-
// `window.
|
|
76
|
+
// Inject dom-to-svg (IIFE — attaches `window.domToSvg`) and the SHARED
|
|
77
|
+
// capture spine (module — attaches `window.__maudeCaptureCore`). The core is
|
|
78
|
+
// the SAME source the canvas runtime's export-capture bridge imports
|
|
79
|
+
// (exporters/capture-core.ts, DDR-231 single-spine): the serializer logic
|
|
80
|
+
// must never be re-inlined here — it forked once and the browser lane would
|
|
81
|
+
// silently drift from this shim.
|
|
76
82
|
await page.addScriptTag({ path: bundlePath });
|
|
83
|
+
await page.addScriptTag({ path: corePath, type: 'module' });
|
|
84
|
+
await page.waitForFunction(() => !!window.__maudeCaptureCore, { timeout: timeoutMs });
|
|
77
85
|
|
|
78
86
|
const written = [];
|
|
79
87
|
|
|
@@ -82,155 +90,28 @@ try {
|
|
|
82
90
|
// multi-target loop must not leave an already-captured artboard sitting at
|
|
83
91
|
// the origin, overlapping the next target's geometry (the scatter bug).
|
|
84
92
|
const pinArtboard = async (handle) => {
|
|
85
|
-
const saved = await handle.evaluate((el) =>
|
|
86
|
-
const ab = el.closest('[data-dc-screen]') ?? el;
|
|
87
|
-
const prev = { left: ab.style.left, top: ab.style.top };
|
|
88
|
-
ab.style.left = '0px';
|
|
89
|
-
ab.style.top = '0px';
|
|
90
|
-
return prev;
|
|
91
|
-
});
|
|
93
|
+
const saved = await handle.evaluate((el) => window.__maudeCaptureCore.pinArtboard(el));
|
|
92
94
|
return async () => {
|
|
93
|
-
await handle.evaluate(
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
}, saved);
|
|
95
|
+
await handle.evaluate(
|
|
96
|
+
(el, prev) => window.__maudeCaptureCore.restoreArtboard(el, prev),
|
|
97
|
+
saved
|
|
98
|
+
);
|
|
98
99
|
};
|
|
99
100
|
};
|
|
100
101
|
|
|
101
102
|
// Single in-page serializer used by BOTH branches (single + multi) so they
|
|
102
103
|
// can't drift again (the `formatXML` 500 bug came from a divergent copy).
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
104
|
+
// The ENTIRE capture logic (backdrop rect from the effective background,
|
|
105
|
+
// oklch→sRGB normalization for vector editors, inlineResources) lives in the
|
|
106
|
+
// shared capture spine (exporters/capture-core.ts) injected above — the same
|
|
107
|
+
// code the canvas runtime's export-capture bridge runs in the member's
|
|
108
|
+
// browser (DDR-231). Nothing capture-shaped may be inlined here again.
|
|
108
109
|
const serializeOne = async (handle, widenToArtboard) => {
|
|
109
110
|
return await handle.evaluate(
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
// Convert ANY computed color (modern Chromium serializes `oklch(...)`
|
|
116
|
-
// verbatim from getComputedStyle) to sRGB rgb()/rgba(). Affinity
|
|
117
|
-
// Designer / older Illustrator / Inkscape don't parse CSS Color 4
|
|
118
|
-
// (`oklch`, `color()`) and silently drop the fill — the artboard then
|
|
119
|
-
// shows as transparent (the exact symptom: bg vanished in Affinity).
|
|
120
|
-
// A 1×1 canvas normalizes any CSS color string to sRGB bytes. Returns
|
|
121
|
-
// null if the value can't be painted (keep the rect off rather than
|
|
122
|
-
// emit a wrong colour).
|
|
123
|
-
const _srgbCache = new Map();
|
|
124
|
-
const _srgbCanvas = document.createElement('canvas');
|
|
125
|
-
_srgbCanvas.width = 1;
|
|
126
|
-
_srgbCanvas.height = 1;
|
|
127
|
-
const _srgbCtx = _srgbCanvas.getContext('2d');
|
|
128
|
-
const toSrgb = (color) => {
|
|
129
|
-
if (_srgbCache.has(color)) return _srgbCache.get(color);
|
|
130
|
-
let result = null;
|
|
131
|
-
try {
|
|
132
|
-
if (_srgbCtx) {
|
|
133
|
-
_srgbCtx.clearRect(0, 0, 1, 1);
|
|
134
|
-
_srgbCtx.fillStyle = '#000';
|
|
135
|
-
_srgbCtx.fillStyle = color; // ignored (stays #000) if syntax unsupported
|
|
136
|
-
_srgbCtx.fillRect(0, 0, 1, 1);
|
|
137
|
-
const [r, g, b, a] = _srgbCtx.getImageData(0, 0, 1, 1).data;
|
|
138
|
-
if (a !== 0) {
|
|
139
|
-
result =
|
|
140
|
-
a === 255
|
|
141
|
-
? `rgb(${r}, ${g}, ${b})`
|
|
142
|
-
: `rgba(${r}, ${g}, ${b}, ${(a / 255).toFixed(3)})`;
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
} catch {
|
|
146
|
-
result = null;
|
|
147
|
-
}
|
|
148
|
-
_srgbCache.set(color, result);
|
|
149
|
-
return result;
|
|
150
|
-
};
|
|
151
|
-
|
|
152
|
-
const isOpaque = (bg) =>
|
|
153
|
-
!!bg &&
|
|
154
|
-
bg !== 'transparent' &&
|
|
155
|
-
!/^rgba\(\s*0\s*,\s*0\s*,\s*0\s*,\s*0\s*\)$/.test(bg) &&
|
|
156
|
-
!/^rgba\(\s*0\s*,\s*0\s*,\s*0\s*,\s*0(\.0+)?\s*\)$/.test(bg);
|
|
157
|
-
// Effective background of the artboard region: the DS theme bg usually
|
|
158
|
-
// sits on an inner full-bleed wrapper (`.app` / `.mdcc`), occasionally
|
|
159
|
-
// on the artboard element itself, sometimes on body. Descend first to
|
|
160
|
-
// find a near-full-bleed opaque bg, then fall back to ancestors. An
|
|
161
|
-
// intentionally transparent artboard returns null → stays transparent.
|
|
162
|
-
const effectiveBg = (node) => {
|
|
163
|
-
const abRect = node.getBoundingClientRect();
|
|
164
|
-
const abArea = Math.max(1, abRect.width * abRect.height);
|
|
165
|
-
const queue = [node];
|
|
166
|
-
let guard = 0;
|
|
167
|
-
while (queue.length && guard < 80) {
|
|
168
|
-
const e = queue.shift();
|
|
169
|
-
guard += 1;
|
|
170
|
-
const bg = getComputedStyle(e).backgroundColor;
|
|
171
|
-
if (isOpaque(bg)) {
|
|
172
|
-
const r = e.getBoundingClientRect();
|
|
173
|
-
if (r.width * r.height >= abArea * 0.6) return bg;
|
|
174
|
-
}
|
|
175
|
-
for (const c of e.children) queue.push(c);
|
|
176
|
-
}
|
|
177
|
-
let cur = node.parentElement;
|
|
178
|
-
let up = 0;
|
|
179
|
-
while (cur && up < 12) {
|
|
180
|
-
const bg = getComputedStyle(cur).backgroundColor;
|
|
181
|
-
if (isOpaque(bg)) return bg;
|
|
182
|
-
cur = cur.parentElement;
|
|
183
|
-
up += 1;
|
|
184
|
-
}
|
|
185
|
-
return null;
|
|
186
|
-
};
|
|
187
|
-
|
|
188
|
-
const bg = effectiveBg(target);
|
|
189
|
-
// Normalize to sRGB so the fill survives in vector editors (item 4 /
|
|
190
|
-
// oklch follow-up). If conversion fails, skip the backdrop rather than
|
|
191
|
-
// emit an unparseable fill.
|
|
192
|
-
const fillColor = bg ? toSrgb(bg) : null;
|
|
193
|
-
const svgDoc = elementToSVG(target);
|
|
194
|
-
const root = svgDoc.documentElement;
|
|
195
|
-
if (fillColor) {
|
|
196
|
-
const NS = 'http://www.w3.org/2000/svg';
|
|
197
|
-
const rect = svgDoc.createElementNS(NS, 'rect');
|
|
198
|
-
const vb = (root.getAttribute('viewBox') || '').split(/\s+/).map(Number);
|
|
199
|
-
if (vb.length === 4 && vb.every((n) => !Number.isNaN(n))) {
|
|
200
|
-
rect.setAttribute('x', String(vb[0]));
|
|
201
|
-
rect.setAttribute('y', String(vb[1]));
|
|
202
|
-
rect.setAttribute('width', String(vb[2]));
|
|
203
|
-
rect.setAttribute('height', String(vb[3]));
|
|
204
|
-
} else {
|
|
205
|
-
rect.setAttribute('x', '0');
|
|
206
|
-
rect.setAttribute('y', '0');
|
|
207
|
-
rect.setAttribute('width', '100%');
|
|
208
|
-
rect.setAttribute('height', '100%');
|
|
209
|
-
}
|
|
210
|
-
rect.setAttribute('fill', fillColor);
|
|
211
|
-
root.insertBefore(rect, root.firstChild);
|
|
212
|
-
}
|
|
213
|
-
// base64-embeds fonts + images so the SVG is portable outside the
|
|
214
|
-
// dev-server origin. Some external fetches fail silently — Affinity
|
|
215
|
-
// tolerates missing resources better than missing primitives.
|
|
216
|
-
try {
|
|
217
|
-
await inlineResources(root);
|
|
218
|
-
} catch {
|
|
219
|
-
/* best-effort */
|
|
220
|
-
}
|
|
221
|
-
let out = new XMLSerializer().serializeToString(svgDoc);
|
|
222
|
-
// dom-to-svg copies computed colours through VERBATIM, and modern
|
|
223
|
-
// Chromium serializes DS tokens as `oklch(...)` — Affinity Designer /
|
|
224
|
-
// older Illustrator / Inkscape can't parse CSS Color 4 and drop every
|
|
225
|
-
// such fill/stroke (the artboard bg + many element colours vanish).
|
|
226
|
-
// Rewrite every CSS Color 4 colour function (oklch/oklab/lch/lab/color)
|
|
227
|
-
// anywhere in the serialized SVG to its sRGB rgb()/rgba() equivalent.
|
|
228
|
-
// Each token is a standalone colour (no nested parens), so a single
|
|
229
|
-
// `\([^)]*\)` match is safe; toSrgb returns null on an unconvertible
|
|
230
|
-
// value and we keep the original rather than corrupt it.
|
|
231
|
-
out = out.replace(/(?:oklch|oklab|lch|lab|color)\([^)]*\)/gi, (m) => toSrgb(m) || m);
|
|
232
|
-
return out;
|
|
233
|
-
},
|
|
111
|
+
(el, opts) =>
|
|
112
|
+
window.__maudeCaptureCore.svgForElement(el, window.domToSvg, {
|
|
113
|
+
widenToArtboard: opts.widenToArtboard,
|
|
114
|
+
}),
|
|
234
115
|
{ widenToArtboard }
|
|
235
116
|
);
|
|
236
117
|
};
|
|
@@ -124,6 +124,15 @@ import {
|
|
|
124
124
|
diffLayoutPositions,
|
|
125
125
|
} from './commands/move-artboards-command.ts';
|
|
126
126
|
import { scopedCdSelector, selectorIndex, shortText } from './dom-selection.ts';
|
|
127
|
+
import {
|
|
128
|
+
type DomToSvgApi,
|
|
129
|
+
pinArtboard,
|
|
130
|
+
rasterizeSvg,
|
|
131
|
+
resetWorldPlane,
|
|
132
|
+
restoreArtboard,
|
|
133
|
+
restoreWorldPlane,
|
|
134
|
+
svgForElement,
|
|
135
|
+
} from './exporters/capture-core.ts';
|
|
127
136
|
// Photo editor (feature-photo-editor) — schema.ts is DEPENDENCY-FREE (no pixi),
|
|
128
137
|
// so this static import is safe and adds zero bundle cost. The WebGL compositor
|
|
129
138
|
// (photo/pipeline.ts, which imports pixi.js) is loaded via a DYNAMIC import
|
|
@@ -1787,8 +1796,128 @@ export function DesignCanvas(props: DesignCanvasProps) {
|
|
|
1787
1796
|
}
|
|
1788
1797
|
DesignCanvas.displayName = 'DesignCanvas';
|
|
1789
1798
|
|
|
1799
|
+
// DDR-231 (hybrid export lanes) — the workspace `browser` export lane.
|
|
1800
|
+
//
|
|
1801
|
+
// In workspace mode the studio shell cannot capture the canvas itself: the
|
|
1802
|
+
// iframe is a separate (untrusted, DDR-054) origin, unreachable by DOM from
|
|
1803
|
+
// the shell. So the SHELL ASKS and the CANVAS CAPTURES — this bridge listens
|
|
1804
|
+
// for an `export-capture` postMessage from the embedding parent, renders the
|
|
1805
|
+
// requested artboards through the shared capture spine (capture-core.ts — the
|
|
1806
|
+
// same code the playwright SVG shim injects, DDR-231's single-spine rule) and
|
|
1807
|
+
// replies with per-artboard Blobs (structured-clonable across the origin
|
|
1808
|
+
// split). dom-to-svg loads LAZILY through the importmap (`dom-to-svg` runtime
|
|
1809
|
+
// bundle — the pixi.js pattern), so a canvas that never exports pays zero
|
|
1810
|
+
// bundle cost.
|
|
1811
|
+
//
|
|
1812
|
+
// Capture mutates live DOM (world zoom reset + artboard pinned to origin) and
|
|
1813
|
+
// restores everything afterwards — the member is looking at this plane. The
|
|
1814
|
+
// shell overlays a veil during capture to cover the brief reflow.
|
|
1815
|
+
function useExportCaptureBridge() {
|
|
1816
|
+
useEffect(() => {
|
|
1817
|
+
if (typeof window === 'undefined' || window.parent === window) return;
|
|
1818
|
+
let busy = false;
|
|
1819
|
+
const onMsg = (e: MessageEvent) => {
|
|
1820
|
+
// Only the embedding shell may drive capture — a message from any other
|
|
1821
|
+
// window (a nested iframe, an opened popup) is ignored outright.
|
|
1822
|
+
if (e.source !== window.parent) return;
|
|
1823
|
+
const m = e.data as {
|
|
1824
|
+
dgn?: string;
|
|
1825
|
+
id?: unknown;
|
|
1826
|
+
format?: unknown;
|
|
1827
|
+
artboardIds?: unknown;
|
|
1828
|
+
scale?: unknown;
|
|
1829
|
+
} | null;
|
|
1830
|
+
if (!m || m.dgn !== 'export-capture' || typeof m.id !== 'string') return;
|
|
1831
|
+
const requestId = m.id;
|
|
1832
|
+
// DDR-231 security (L1): reply to the ASKING origin, never `'*'`. The
|
|
1833
|
+
// done-message carries the rendered artboard blobs; a wildcard target
|
|
1834
|
+
// would leak them to whatever page happens to be the parent if the
|
|
1835
|
+
// shell's `frame-ancestors` CSP were ever absent (tests / misconfig).
|
|
1836
|
+
// `e.origin` here is the embedding shell (we already gated
|
|
1837
|
+
// `e.source === window.parent`); fall back to `'*'` only for an opaque
|
|
1838
|
+
// `"null"` origin where a targeted post is impossible.
|
|
1839
|
+
const replyOrigin = e.origin && e.origin !== 'null' ? e.origin : '*';
|
|
1840
|
+
const reply = (msg: Record<string, unknown>) => {
|
|
1841
|
+
try {
|
|
1842
|
+
window.parent.postMessage(msg, replyOrigin);
|
|
1843
|
+
} catch {
|
|
1844
|
+
/* parent gone — nothing to deliver to */
|
|
1845
|
+
}
|
|
1846
|
+
};
|
|
1847
|
+
if (busy) {
|
|
1848
|
+
reply({
|
|
1849
|
+
dgn: 'export-capture-error',
|
|
1850
|
+
id: requestId,
|
|
1851
|
+
message: 'a capture is already running on this canvas',
|
|
1852
|
+
});
|
|
1853
|
+
return;
|
|
1854
|
+
}
|
|
1855
|
+
busy = true;
|
|
1856
|
+
const format = m.format === 'svg' ? 'svg' : 'png';
|
|
1857
|
+
const scale = Math.max(1, Math.min(8, Number(m.scale) || 1));
|
|
1858
|
+
const wanted = Array.isArray(m.artboardIds)
|
|
1859
|
+
? m.artboardIds.filter((s): s is string => typeof s === 'string')
|
|
1860
|
+
: null;
|
|
1861
|
+
void (async () => {
|
|
1862
|
+
const savedWorld = resetWorldPlane();
|
|
1863
|
+
try {
|
|
1864
|
+
const all = Array.from(document.querySelectorAll('[data-dc-screen]'));
|
|
1865
|
+
const targets = wanted?.length
|
|
1866
|
+
? all.filter((el) => wanted.includes(el.getAttribute('data-dc-screen') ?? ''))
|
|
1867
|
+
: all;
|
|
1868
|
+
if (!targets.length) throw new Error('no artboards matched the capture request');
|
|
1869
|
+
const api = (await import('dom-to-svg')) as unknown as DomToSvgApi;
|
|
1870
|
+
const items: { name: string; type: string; blob: Blob }[] = [];
|
|
1871
|
+
for (let i = 0; i < targets.length; i += 1) {
|
|
1872
|
+
const el = targets[i] as Element;
|
|
1873
|
+
const abId = el.getAttribute('data-dc-screen') || `artboard-${i + 1}`;
|
|
1874
|
+
const prevPos = pinArtboard(el);
|
|
1875
|
+
try {
|
|
1876
|
+
const svg = await svgForElement(el, api);
|
|
1877
|
+
if (format === 'svg') {
|
|
1878
|
+
items.push({
|
|
1879
|
+
name: `${abId}.svg`,
|
|
1880
|
+
type: 'image/svg+xml',
|
|
1881
|
+
blob: new Blob([svg], { type: 'image/svg+xml' }),
|
|
1882
|
+
});
|
|
1883
|
+
} else {
|
|
1884
|
+
items.push({
|
|
1885
|
+
name: `${abId}.png`,
|
|
1886
|
+
type: 'image/png',
|
|
1887
|
+
blob: await rasterizeSvg(svg, scale),
|
|
1888
|
+
});
|
|
1889
|
+
}
|
|
1890
|
+
} finally {
|
|
1891
|
+
restoreArtboard(el, prevPos);
|
|
1892
|
+
}
|
|
1893
|
+
reply({
|
|
1894
|
+
dgn: 'export-capture-progress',
|
|
1895
|
+
id: requestId,
|
|
1896
|
+
current: i + 1,
|
|
1897
|
+
total: targets.length,
|
|
1898
|
+
});
|
|
1899
|
+
}
|
|
1900
|
+
reply({ dgn: 'export-capture-done', id: requestId, items });
|
|
1901
|
+
} catch (err) {
|
|
1902
|
+
reply({
|
|
1903
|
+
dgn: 'export-capture-error',
|
|
1904
|
+
id: requestId,
|
|
1905
|
+
message: err instanceof Error ? err.message : String(err),
|
|
1906
|
+
});
|
|
1907
|
+
} finally {
|
|
1908
|
+
restoreWorldPlane(savedWorld);
|
|
1909
|
+
busy = false;
|
|
1910
|
+
}
|
|
1911
|
+
})();
|
|
1912
|
+
};
|
|
1913
|
+
window.addEventListener('message', onMsg);
|
|
1914
|
+
return () => window.removeEventListener('message', onMsg);
|
|
1915
|
+
}, []);
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1790
1918
|
function DesignCanvasInner({ children, controls }: DesignCanvasProps) {
|
|
1791
1919
|
ensureEngineStyles();
|
|
1920
|
+
useExportCaptureBridge();
|
|
1792
1921
|
|
|
1793
1922
|
const hostRef = useRef<HTMLDivElement | null>(null);
|
|
1794
1923
|
const worldRef = useRef<HTMLDivElement | null>(null);
|