@1agh/maude 0.45.2 → 0.46.0

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 (54) hide show
  1. package/apps/studio/acp/bridge.ts +43 -1
  2. package/apps/studio/annotations-snap.ts +50 -10
  3. package/apps/studio/api.ts +344 -14
  4. package/apps/studio/artboard-guides-overlay.tsx +270 -0
  5. package/apps/studio/bin/_pdf-playwright.mjs +35 -4
  6. package/apps/studio/bin/_png-playwright.mjs +38 -4
  7. package/apps/studio/bin/_pw-launch.mjs +52 -0
  8. package/apps/studio/bin/_pw-launch.test.mjs +90 -0
  9. package/apps/studio/bin/_smart-frames.mjs +419 -0
  10. package/apps/studio/bin/_smart-frames.test.mjs +140 -0
  11. package/apps/studio/bin/smart-frames.sh +30 -0
  12. package/apps/studio/canvas-edit.ts +302 -3
  13. package/apps/studio/canvas-lib.tsx +203 -11
  14. package/apps/studio/canvas-shell.tsx +167 -28
  15. package/apps/studio/client/app.jsx +678 -176
  16. package/apps/studio/client/panels/ChatPanel.jsx +45 -1
  17. package/apps/studio/client/panels/SettingsPanel.jsx +211 -0
  18. package/apps/studio/client/panels/acp-capabilities.js +11 -0
  19. package/apps/studio/client/styles/6-acp-chat.css +41 -0
  20. package/apps/studio/dist/client.bundle.js +1398 -1398
  21. package/apps/studio/dist/comment-mount.js +2 -2
  22. package/apps/studio/dist/styles.css +1 -1
  23. package/apps/studio/dom-selection.ts +20 -0
  24. package/apps/studio/export-dialog.tsx +138 -18
  25. package/apps/studio/exporters/pdf.ts +332 -18
  26. package/apps/studio/exporters/png.ts +45 -3
  27. package/apps/studio/footage/schema.ts +17 -1
  28. package/apps/studio/generation/gemma-models.ts +224 -0
  29. package/apps/studio/generation/prefs.ts +43 -0
  30. package/apps/studio/http.ts +213 -7
  31. package/apps/studio/print/marks.ts +113 -0
  32. package/apps/studio/print/units.ts +269 -0
  33. package/apps/studio/print-overlay-content.tsx +132 -0
  34. package/apps/studio/test/acp-mode-banner.test.ts +45 -0
  35. package/apps/studio/test/acp-session-allowed-tools.test.ts +91 -0
  36. package/apps/studio/test/annotations-snap.test.ts +56 -0
  37. package/apps/studio/test/artboard-guides-overlay.test.tsx +152 -0
  38. package/apps/studio/test/artboard-kinds.test.tsx +83 -0
  39. package/apps/studio/test/artboard-selection-attrs.test.ts +69 -0
  40. package/apps/studio/test/canvas-meta-api.test.ts +167 -0
  41. package/apps/studio/test/element-structural-edit.test.ts +250 -0
  42. package/apps/studio/test/exporters/png.test.ts +49 -1
  43. package/apps/studio/test/pdf-print-boxes.test.ts +272 -0
  44. package/apps/studio/test/print-marks.test.ts +113 -0
  45. package/apps/studio/test/print-units.test.ts +173 -0
  46. package/apps/studio/test/use-snap-guides.test.ts +81 -0
  47. package/apps/studio/use-chrome-visibility.tsx +19 -0
  48. package/apps/studio/use-element-resize.tsx +21 -2
  49. package/apps/studio/use-snap-guides.tsx +73 -5
  50. package/apps/studio/use-spacing-handles.tsx +9 -5
  51. package/apps/studio/whats-new.json +28 -0
  52. package/cli/commands/design.mjs +9 -1
  53. package/package.json +8 -8
  54. package/plugins/design/dependencies.json +35 -0
@@ -0,0 +1,270 @@
1
+ /**
2
+ * @file artboard-guides-overlay.tsx — feature-1-artboard-kinds-foundation, T4.
3
+ * @scope apps/studio/artboard-guides-overlay.tsx
4
+ * @purpose Render-only chrome for an artboard's guides/marks — Figma-vocabulary
5
+ * generic layout guides (columns/rows/grid, T5) plus a per-kind
6
+ * content registry that downstream plans hang kind-specific chrome
7
+ * off of (feature-2-print-artboards: bleed/trim/margin/marks;
8
+ * feature-3-web-artboards: breakpoint band). Rendered by DCArtboard
9
+ * as a world-coord sibling of the `<article>` — same mount pattern
10
+ * as `ArtboardActivityOverlay` — so it pans/zooms with the artboard
11
+ * for free WITHOUT living inside `.dc-artboard`'s
12
+ * `contain:paint`/`content-visibility` subtree (that subtree exists
13
+ * to cull/freeze exported artboard CONTENT; guides are never
14
+ * exported/screenshotted and must never be culled with it).
15
+ *
16
+ * Decorative — `aria-hidden` + `pointer-events:none`, flat divs only (no
17
+ * filters/blends/gradients — Design Decision 2: WebKit does not
18
+ * GPU-accelerate those, and this layer sits on top of every artboard on a
19
+ * dense canvas).
20
+ */
21
+
22
+ import type { ReactNode } from 'react';
23
+
24
+ import type { ArtboardKind } from './canvas-lib.tsx';
25
+ import type { ArtboardPrintProp } from './print/units.ts';
26
+
27
+ export interface OverlayRect {
28
+ x: number;
29
+ y: number;
30
+ w: number;
31
+ h: number;
32
+ }
33
+
34
+ /** One axis of the Figma "columns"/"rows" layout-guide vocabulary. */
35
+ export interface BandGuideDef {
36
+ count: number;
37
+ gutter: number;
38
+ margin: number;
39
+ }
40
+
41
+ export interface GridGuideDef {
42
+ /** World-unit spacing between grid lines. */
43
+ size: number;
44
+ }
45
+
46
+ /**
47
+ * Generic layout guides (T5) — versioned design intent (a JSX prop on
48
+ * DCArtboard), kind-agnostic: any artboard can carry columns/rows/grid
49
+ * regardless of its `kind`.
50
+ */
51
+ export interface GuideDefinitions {
52
+ columns?: BandGuideDef;
53
+ rows?: BandGuideDef;
54
+ grid?: GridGuideDef;
55
+ }
56
+
57
+ /**
58
+ * Per-user show/hide state (T6) — lives in `_canvas-state/<slug>.view.json`
59
+ * (existing runtime file), never in the versioned `.meta.json`. A flat bag,
60
+ * not just a `guides` boolean, so downstream plans can add their own keys
61
+ * (`bleed`, `breakpoints`, …) without a schema change here.
62
+ */
63
+ export interface OverlayVisibility {
64
+ guides?: boolean;
65
+ [key: string]: boolean | undefined;
66
+ }
67
+
68
+ export interface KindOverlayProps {
69
+ rect: OverlayRect;
70
+ kind: ArtboardKind;
71
+ guides?: GuideDefinitions;
72
+ /** feature-2-print-artboards T2/T3 — the artboard's `print` JSX prop, when
73
+ * present. Only meaningful to the 'print' kind's registered renderer. */
74
+ print?: ArtboardPrintProp;
75
+ visibility: OverlayVisibility;
76
+ }
77
+
78
+ export type KindOverlayRenderFn = (props: KindOverlayProps) => ReactNode;
79
+
80
+ // ─────────────────────────────────────────────────────────────────────────────
81
+ // Per-kind content registry. Foundation registers nothing here — the generic
82
+ // guides below already apply to every kind. feature-2-print-artboards /
83
+ // feature-3-web-artboards call `registerKindOverlay` at module load to hang
84
+ // their own kind-specific chrome off the same mount point.
85
+
86
+ const kindOverlayRegistry = new Map<ArtboardKind, KindOverlayRenderFn>();
87
+
88
+ /**
89
+ * Register additional overlay content for one artboard kind. Last call for a
90
+ * given kind wins — safe to re-call on HMR (module re-eval re-registers).
91
+ */
92
+ export function registerKindOverlay(kind: ArtboardKind, renderFn: KindOverlayRenderFn): void {
93
+ kindOverlayRegistry.set(kind, renderFn);
94
+ }
95
+
96
+ /** Test-only: clear the registry between test files (module-scoped Map would
97
+ * otherwise leak registrations across `bun test`'s shared module graph). */
98
+ export function __resetKindOverlayRegistryForTests(): void {
99
+ kindOverlayRegistry.clear();
100
+ }
101
+
102
+ // ─────────────────────────────────────────────────────────────────────────────
103
+ // Generic guides (T5) — Figma vocabulary. Columns/rows render as translucent
104
+ // violet bands (Figma's own default), grid renders as red hairlines @ 10%
105
+ // opacity. Chrome-engine constants, not DS tokens — guides are never part of
106
+ // exported artboard content, same reasoning as SnapGuideOverlay's hardcoded
107
+ // magenta/gray.
108
+ const COLUMN_FILL = 'color-mix(in oklab, oklch(62% 0.19 300) 14%, transparent)';
109
+ const GRID_LINE = 'color-mix(in oklab, oklch(55% 0.22 25) 10%, transparent)';
110
+
111
+ /** Safety bound, not a coverage cap — guards against a pathological
112
+ * `grid.size` (e.g. 1) turning into thousands of divs on a large artboard. */
113
+ const MAX_GRID_LINES_PER_AXIS = 400;
114
+
115
+ /**
116
+ * Safety bound, not a coverage cap — same reasoning as MAX_GRID_LINES_PER_AXIS,
117
+ * for the columns/rows `count`. `guides` is an artboard-authored JSX prop, so
118
+ * it's part of the DDR-054 untrusted-canvas surface — a malicious/poisoned
119
+ * canvas (peer-pushed under phase-30 branch-scoped multiplayer, or a
120
+ * prompt-injected agent edit) can set `columns.count` to an arbitrary number
121
+ * with no server-side validation in between (the value never round-trips
122
+ * through `setArtboardGuidesOp`'s bounds — it's read directly from React
123
+ * props at render time). An uncapped loop here mounts one `<div>` per band,
124
+ * so a huge count is a real client-side DoS (viewer tab freeze/OOM), found
125
+ * independently by both the security-auditor and ethical-hacker passes on
126
+ * this feature. 64 comfortably exceeds any real layout-guide use.
127
+ */
128
+ const MAX_BANDS_PER_AXIS = 64;
129
+
130
+ function computeBands(def: BandGuideDef, extent: number): Array<{ start: number; size: number }> {
131
+ const count = Math.min(Math.max(0, Math.floor(def.count)), MAX_BANDS_PER_AXIS);
132
+ if (count <= 0 || extent <= 0) return [];
133
+ const usable = extent - def.margin * 2;
134
+ if (usable <= 0) return [];
135
+ const bandSize = (usable - def.gutter * Math.max(0, count - 1)) / count;
136
+ if (bandSize <= 0) return [];
137
+ const bands: Array<{ start: number; size: number }> = [];
138
+ for (let i = 0; i < count; i++) {
139
+ bands.push({ start: def.margin + i * (bandSize + def.gutter), size: bandSize });
140
+ }
141
+ return bands;
142
+ }
143
+
144
+ function gridLinePositions(size: number, extent: number): number[] {
145
+ if (size < 2 || extent <= 0) return [];
146
+ const out: number[] = [];
147
+ for (let pos = size; pos < extent && out.length < MAX_GRID_LINES_PER_AXIS; pos += size) {
148
+ out.push(pos);
149
+ }
150
+ return out;
151
+ }
152
+
153
+ function GenericGuides({ rect, guides }: { rect: OverlayRect; guides: GuideDefinitions }) {
154
+ const nodes: ReactNode[] = [];
155
+ if (guides.columns) {
156
+ for (const b of computeBands(guides.columns, rect.w)) {
157
+ nodes.push(
158
+ <div
159
+ key={`col-${b.start}`}
160
+ style={{
161
+ position: 'absolute',
162
+ left: b.start,
163
+ top: 0,
164
+ width: b.size,
165
+ height: rect.h,
166
+ background: COLUMN_FILL,
167
+ }}
168
+ />
169
+ );
170
+ }
171
+ }
172
+ if (guides.rows) {
173
+ for (const b of computeBands(guides.rows, rect.h)) {
174
+ nodes.push(
175
+ <div
176
+ key={`row-${b.start}`}
177
+ style={{
178
+ position: 'absolute',
179
+ left: 0,
180
+ top: b.start,
181
+ width: rect.w,
182
+ height: b.size,
183
+ background: COLUMN_FILL,
184
+ }}
185
+ />
186
+ );
187
+ }
188
+ }
189
+ if (guides.grid) {
190
+ for (const x of gridLinePositions(guides.grid.size, rect.w)) {
191
+ nodes.push(
192
+ <div
193
+ key={`gx-${x}`}
194
+ style={{
195
+ position: 'absolute',
196
+ left: x,
197
+ top: 0,
198
+ width: 1,
199
+ height: rect.h,
200
+ background: GRID_LINE,
201
+ }}
202
+ />
203
+ );
204
+ }
205
+ for (const y of gridLinePositions(guides.grid.size, rect.h)) {
206
+ nodes.push(
207
+ <div
208
+ key={`gy-${y}`}
209
+ style={{
210
+ position: 'absolute',
211
+ left: 0,
212
+ top: y,
213
+ width: rect.w,
214
+ height: 1,
215
+ background: GRID_LINE,
216
+ }}
217
+ />
218
+ );
219
+ }
220
+ }
221
+ return <>{nodes}</>;
222
+ }
223
+ GenericGuides.displayName = 'GenericGuides';
224
+
225
+ /**
226
+ * The mount point DCArtboard renders as a world-coord sibling of its
227
+ * `<article>`. Renders nothing when there's neither a visible generic guide
228
+ * definition nor a registered kind-specific renderer — so the common case
229
+ * (no `guides` prop, no downstream plan loaded yet) costs one map lookup and
230
+ * no extra DOM.
231
+ */
232
+ export function ArtboardGuidesOverlay({ rect, kind, guides, print, visibility }: KindOverlayProps) {
233
+ const KindContent = kindOverlayRegistry.get(kind);
234
+ // T6 Gotcha — a view.json predating the `overlays` lane has no `guides`
235
+ // key at all; that absence must resolve to HIDDEN, not shown, so opening
236
+ // an old canvas doesn't suddenly paint guides nobody asked to see. Only an
237
+ // explicit `true` (the View-menu toggle) shows them.
238
+ const showGenericGuides = visibility.guides === true && !!guides;
239
+ if (!showGenericGuides && !KindContent) return null;
240
+ return (
241
+ <div
242
+ className="dc-artboard-guides"
243
+ aria-hidden="true"
244
+ style={{
245
+ position: 'absolute',
246
+ left: rect.x,
247
+ top: rect.y,
248
+ width: rect.w,
249
+ height: rect.h,
250
+ overflow: 'hidden',
251
+ pointerEvents: 'none',
252
+ }}
253
+ >
254
+ {showGenericGuides && guides ? <GenericGuides rect={rect} guides={guides} /> : null}
255
+ {/* Kind-specific renderers see the full, ungated `visibility` bag and
256
+ decide their own show/hide (e.g. print's bleed toggle is a
257
+ different key than `guides`). */}
258
+ {KindContent ? (
259
+ <KindContent
260
+ rect={rect}
261
+ kind={kind}
262
+ guides={guides}
263
+ print={print}
264
+ visibility={visibility}
265
+ />
266
+ ) : null}
267
+ </div>
268
+ );
269
+ }
270
+ ArtboardGuidesOverlay.displayName = 'ArtboardGuidesOverlay';
@@ -5,13 +5,26 @@
5
5
  // Per DDR-041 (PDF via page.pdf()): print-media emulation, font readiness
6
6
  // wait, explicit page size matching the artboard rect.
7
7
  //
8
+ // feature-2-print-artboards T5 (dogfood follow-up) — a "vector PDF" is only
9
+ // vector for TEXT and drawn shapes; any raster <img>/<canvas> content on the
10
+ // artboard (a photo dropped onto a flyer, a large-format billboard authored
11
+ // at a small on-screen scale) still embeds as a bitmap, and that bitmap's
12
+ // pixel density is set by the CAPTURING context's deviceScaleFactor — 1× by
13
+ // default, which looks fine on screen but is visibly soft once printed large
14
+ // (e.g. a design authored at 1:10 scale for a billboard). `--scale` sets the
15
+ // context's deviceScaleFactor before `page.pdf()` runs, exactly like
16
+ // _png-playwright.mjs, so embedded raster content captures at print density
17
+ // while vector text/shapes stay crisp regardless (they're never rasterized).
18
+ // Page SIZE (in pt) is unaffected — it always comes from the artboard's own
19
+ // rect, not from this scale.
20
+ //
8
21
  // Invocation (Bun.spawn from exporters/pdf.ts — not invoked directly):
9
22
  // node _pdf-playwright.mjs --url <url> --selector <css> --out <path>
10
- // [--multi] [--out-dir <dir>] [--timeout 12]
23
+ // [--multi] [--out-dir <dir>] [--timeout 12] [--scale 3.125]
11
24
 
12
25
  import { mkdirSync, writeFileSync } from 'node:fs';
13
26
  import { dirname, join } from 'node:path';
14
- import { launchChromium } from './_pw-launch.mjs';
27
+ import { assertRenderOutputSizeOk, launchChromium, safeArtboardFilename } from './_pw-launch.mjs';
15
28
 
16
29
  const args = Object.fromEntries(
17
30
  process.argv.slice(2).reduce((acc, cur, i, all) => {
@@ -28,6 +41,7 @@ const {
28
41
  'widen-to-artboard': widenFlag,
29
42
  multi: multiFlag,
30
43
  timeout = '12',
44
+ scale = '1',
31
45
  } = args;
32
46
 
33
47
  if (!url) {
@@ -38,10 +52,21 @@ if (!url) {
38
52
  const widen = widenFlag !== undefined;
39
53
  const multi = multiFlag !== undefined;
40
54
  const timeoutMs = Number(timeout) * 1000;
55
+ // Same ceiling as _png-playwright.mjs's raised T4 ceiling (600dpi = 6.25×).
56
+ // The PDF *page* stays vector-sized regardless of this factor (only raster
57
+ // CONTENT density scales with it — see the file header) — but Chromium's
58
+ // rendering/rasterization surface for `page.pdf()` scales with
59
+ // deviceScaleFactor exactly like a screenshot buffer does, so the same
60
+ // output-size guard as PNG's DOES apply here (see assertRenderOutputSizeOk
61
+ // call below); a huge artboard + high DPI is just as costly to render.
62
+ const deviceScaleFactor = Math.max(1, Math.min(8, Number(scale) || 1));
41
63
 
42
64
  const browser = await launchChromium();
43
65
  try {
44
- const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
66
+ const ctx = await browser.newContext({
67
+ viewport: { width: 1440, height: 900 },
68
+ deviceScaleFactor,
69
+ });
45
70
  const page = await ctx.newPage();
46
71
  await page.goto(url, { waitUntil: 'load', timeout: timeoutMs });
47
72
  // Wait for web fonts so `@font-face` glyphs land in the PDF instead of
@@ -102,9 +127,15 @@ try {
102
127
  const r = el.getBoundingClientRect();
103
128
  return { w: r.width, h: r.height, x: r.left, y: r.top };
104
129
  });
130
+ assertRenderOutputSizeOk(rect.w, rect.h, deviceScaleFactor, '_pdf-playwright');
131
+ // feature-2-print-artboards T5 — name multi output by the artboard's
132
+ // OWN id (mirrors _png-playwright.mjs's multi-write scheme) rather than
133
+ // a positional index, so the PDF post-pass can correlate each written
134
+ // file back to its artboard's `print` prop via the filename alone.
135
+ const screenId = multi ? await screens[i].getAttribute('data-dc-screen') : null;
105
136
  // Set the page size to the artboard's pixel dimensions so the resulting
106
137
  // PDF is exactly one artboard per page with no margin.
107
- const targetPath = multi ? join(outDir, `artboard-${i + 1}.pdf`) : out;
138
+ const targetPath = multi ? join(outDir, safeArtboardFilename(screenId, i, 'pdf')) : out;
108
139
  // Crop trick: set the viewport to the artboard rect, scroll it into the
109
140
  // top-left corner, then page.pdf() with matching width/height.
110
141
  await page.setViewportSize({
@@ -7,7 +7,7 @@
7
7
 
8
8
  import { mkdirSync } from 'node:fs';
9
9
  import { dirname, join } from 'node:path';
10
- import { launchChromium } from './_pw-launch.mjs';
10
+ import { launchChromium, safeArtboardFilename } from './_pw-launch.mjs';
11
11
 
12
12
  const args = Object.fromEntries(
13
13
  process.argv.slice(2).reduce((acc, cur, i, all) => {
@@ -35,7 +35,38 @@ if (!url) {
35
35
  const widen = widenFlag !== undefined;
36
36
  const multi = multiFlag !== undefined;
37
37
  const timeoutMs = Number(timeout) * 1000;
38
- const deviceScaleFactor = Math.max(1, Math.min(4, Number(scale) || 1));
38
+ // feature-2-print-artboards T4 — ceiling raised 4→8 so a 300dpi export
39
+ // (3.125×) AND 600dpi (6.25×) both fit; exporters/png.ts resolveDeviceScale
40
+ // is the only caller that can reach beyond 3, via the `dpi` option.
41
+ const deviceScaleFactor = Math.max(1, Math.min(8, Number(scale) || 1));
42
+ // feature-2-print-artboards T4 — a raster export's actual pixel dimensions
43
+ // are only known once the target element's DOM rect is measured below (the
44
+ // adapter can't pre-compute them — see png.ts's own comment), so the "too
45
+ // big to render" guard lives here rather than in the Node-side adapter.
46
+ // 16,000px matches common canvas/texture limits across browser engines;
47
+ // ~600MB is a generous RGBA-buffer ceiling (width × height × 4 bytes) well
48
+ // under typical CI/desktop RAM, chosen so a legitimate 600dpi A0 poster still
49
+ // fits (9933×14043px × 4B ≈ 558MB) while a runaway value fails loud instead
50
+ // of OOM-killing the render process.
51
+ const MAX_OUTPUT_SIDE_PX = 16000;
52
+ const MAX_OUTPUT_BYTES = 600 * 1024 * 1024;
53
+ function assertOutputSizeOk(widthCss, heightCss) {
54
+ const outW = Math.ceil(widthCss * deviceScaleFactor);
55
+ const outH = Math.ceil(heightCss * deviceScaleFactor);
56
+ const estBytes = outW * outH * 4;
57
+ if (outW > MAX_OUTPUT_SIDE_PX || outH > MAX_OUTPUT_SIDE_PX || estBytes > MAX_OUTPUT_BYTES) {
58
+ const maxScaleForSide = MAX_OUTPUT_SIDE_PX / Math.max(widthCss, heightCss);
59
+ const maxScaleForBytes = Math.sqrt(MAX_OUTPUT_BYTES / 4 / (widthCss * heightCss));
60
+ const maxScale = Math.min(maxScaleForSide, maxScaleForBytes);
61
+ const maxDpi = Math.floor(maxScale * 96);
62
+ console.error(
63
+ `_png-playwright: requested output ${outW}×${outH}px at ${deviceScaleFactor}× exceeds the ` +
64
+ `render guard (max side ${MAX_OUTPUT_SIDE_PX}px / max ~${Math.round(MAX_OUTPUT_BYTES / 1024 / 1024)}MB). ` +
65
+ `For this artboard size (${Math.round(widthCss)}×${Math.round(heightCss)}px), the max supported DPI is ~${maxDpi}.`
66
+ );
67
+ process.exit(1);
68
+ }
69
+ }
39
70
 
40
71
  const browser = await launchChromium();
41
72
  try {
@@ -97,6 +128,9 @@ try {
97
128
  const r = el.getBoundingClientRect();
98
129
  return { x: r.left, y: r.top, width: r.width, height: r.height };
99
130
  });
131
+ // feature-2-print-artboards T4 — fail loud BEFORE resizing the viewport
132
+ // to a pathological size, not after.
133
+ assertOutputSizeOk(rect.width, rect.height);
100
134
  // Resize the viewport to fit the artboard so the screenshot doesn't
101
135
  // include the world plane's pan/zoom margin. The artboard then sits at
102
136
  // (0,0) with its native dimensions.
@@ -141,8 +175,8 @@ try {
141
175
  const screens = await page.locator(selector ?? '[data-dc-screen]').all();
142
176
  for (let i = 0; i < screens.length; i += 1) {
143
177
  const handle = screens[i];
144
- const id = (await handle.getAttribute('data-dc-screen')) ?? `artboard-${i + 1}`;
145
- await captureHandle(handle, join(outDir, `${id}.png`));
178
+ const id = await handle.getAttribute('data-dc-screen');
179
+ await captureHandle(handle, join(outDir, safeArtboardFilename(id, i, 'png')));
146
180
  console.log(`MAUDE_PROGRESS {"current":${i + 1},"total":${screens.length}}`);
147
181
  }
148
182
  } else {
@@ -113,3 +113,55 @@ export async function launchChromium(opts) {
113
113
  throw err;
114
114
  }
115
115
  }
116
+
117
+ // Shared by every multi-artboard export shim (_png/_pdf-playwright.mjs) that
118
+ // names its output file after an artboard's `data-dc-screen` id for T5's
119
+ // filename-correlation convenience. That id comes straight off a live DOM
120
+ // attribute — i.e. from canvas-authored JSX, DDR-054 untrusted content — and
121
+ // feeds directly into `path.join(outDir, ...)`, which does NOT sandbox `..`
122
+ // segments. Re-validates against the SAME shape api.ts's write-side artboard-id
123
+ // ops already enforce (`/^[A-Za-z][\w-]{0,63}$/`) before it's ever used as a
124
+ // path segment; anything else (or a `false`y id) falls back to the safe
125
+ // positional name so a malformed/malicious id can never escape `outDir`.
126
+ const SAFE_ARTBOARD_ID_RE = /^[A-Za-z][\w-]{0,63}$/;
127
+ export function safeArtboardFilename(id, index, ext) {
128
+ const safe =
129
+ typeof id === 'string' && SAFE_ARTBOARD_ID_RE.test(id) ? id : `artboard-${index + 1}`;
130
+ return `${safe}.${ext}`;
131
+ }
132
+
133
+ // Shared by every raster-capture export shim (_png/_pdf-playwright.mjs) that
134
+ // scales its render surface by `deviceScaleFactor` (feature-2-print-artboards
135
+ // T4's DPI support). The requested pixel output is only knowable once the
136
+ // target element's DOM rect is measured (the Node-side adapter can't
137
+ // pre-compute it), so this guard lives here rather than in exporters/*.ts.
138
+ // Applies EQUALLY to the PDF shim: `deviceScaleFactor` scales Chromium's
139
+ // whole rendering/rasterization surface for `page.pdf()` too, not just PNG's
140
+ // screenshot buffer — the PDF *page* stays vector-sized regardless, but the
141
+ // backing store Chromium allocates to paint it does not, so a huge
142
+ // artboard + high DPI is exactly as costly to render as it is for PNG, even
143
+ // though the resulting PDF bytes are typically far smaller than a raw PNG
144
+ // buffer. 16,000px matches common canvas/texture limits across browser
145
+ // engines; ~600MB is a generous RGBA-buffer ceiling (width × height × 4
146
+ // bytes) chosen so a legitimate 600dpi A0 poster still fits (9933×14043px ×
147
+ // 4B ≈ 558MB) while a runaway value fails loud instead of OOM-killing the
148
+ // render process.
149
+ const MAX_OUTPUT_SIDE_PX = 16000;
150
+ const MAX_OUTPUT_BYTES = 600 * 1024 * 1024;
151
+ export function assertRenderOutputSizeOk(widthCss, heightCss, deviceScaleFactor, label) {
152
+ const outW = Math.ceil(widthCss * deviceScaleFactor);
153
+ const outH = Math.ceil(heightCss * deviceScaleFactor);
154
+ const estBytes = outW * outH * 4;
155
+ if (outW > MAX_OUTPUT_SIDE_PX || outH > MAX_OUTPUT_SIDE_PX || estBytes > MAX_OUTPUT_BYTES) {
156
+ const maxScaleForSide = MAX_OUTPUT_SIDE_PX / Math.max(widthCss, heightCss);
157
+ const maxScaleForBytes = Math.sqrt(MAX_OUTPUT_BYTES / 4 / (widthCss * heightCss));
158
+ const maxScale = Math.min(maxScaleForSide, maxScaleForBytes);
159
+ const maxDpi = Math.floor(maxScale * 96);
160
+ console.error(
161
+ `${label}: requested output ${outW}×${outH}px at ${deviceScaleFactor}× exceeds the ` +
162
+ `render guard (max side ${MAX_OUTPUT_SIDE_PX}px / max ~${Math.round(MAX_OUTPUT_BYTES / 1024 / 1024)}MB). ` +
163
+ `For this artboard size (${Math.round(widthCss)}×${Math.round(heightCss)}px), the max supported DPI is ~${maxDpi}.`
164
+ );
165
+ process.exit(1);
166
+ }
167
+ }
@@ -0,0 +1,90 @@
1
+ // _pw-launch.test.mjs — the pure, security-relevant helpers shared by the
2
+ // multi-artboard export shims (_png/_pdf-playwright.mjs). Regression coverage
3
+ // for the arbitrary-file-write finding fixed in the print-artboards dogfood
4
+ // pass (DDR-182 addendum): a `data-dc-screen` id is DDR-054 untrusted canvas
5
+ // content, and used to be trusted verbatim as a `path.join` segment.
6
+
7
+ import { describe, expect, test } from 'bun:test';
8
+ import { assertRenderOutputSizeOk, safeArtboardFilename } from './_pw-launch.mjs';
9
+
10
+ describe('safeArtboardFilename', () => {
11
+ test('passes a well-formed id through unchanged', () => {
12
+ expect(safeArtboardFilename('ak-alpha', 0, 'pdf')).toBe('ak-alpha.pdf');
13
+ expect(safeArtboardFilename('Beta_2', 3, 'png')).toBe('Beta_2.png');
14
+ });
15
+
16
+ test('falls back to the positional name for a path-traversal id', () => {
17
+ expect(safeArtboardFilename('../../../../tmp/pwned', 2, 'pdf')).toBe('artboard-3.pdf');
18
+ expect(safeArtboardFilename('/etc/passwd', 0, 'png')).toBe('artboard-1.png');
19
+ expect(safeArtboardFilename('..', 1, 'pdf')).toBe('artboard-2.pdf');
20
+ });
21
+
22
+ test('falls back for null/undefined/empty/non-string ids', () => {
23
+ expect(safeArtboardFilename(null, 0, 'pdf')).toBe('artboard-1.pdf');
24
+ expect(safeArtboardFilename(undefined, 4, 'png')).toBe('artboard-5.png');
25
+ expect(safeArtboardFilename('', 1, 'pdf')).toBe('artboard-2.pdf');
26
+ });
27
+
28
+ test('falls back for an id starting with a digit or containing unsafe chars', () => {
29
+ expect(safeArtboardFilename('1abc', 0, 'pdf')).toBe('artboard-1.pdf');
30
+ expect(safeArtboardFilename('a/b', 0, 'pdf')).toBe('artboard-1.pdf');
31
+ expect(safeArtboardFilename('a b', 0, 'pdf')).toBe('artboard-1.pdf');
32
+ });
33
+
34
+ test('falls back for an id over the 64-char cap', () => {
35
+ expect(safeArtboardFilename(`a${'x'.repeat(64)}`, 0, 'pdf')).toBe('artboard-1.pdf');
36
+ });
37
+ });
38
+
39
+ describe('assertRenderOutputSizeOk', () => {
40
+ test('does not exit for a reasonable A4-at-300dpi request', () => {
41
+ const origExit = process.exit;
42
+ let exited = false;
43
+ process.exit = () => {
44
+ exited = true;
45
+ };
46
+ try {
47
+ assertRenderOutputSizeOk(818, 1146, 3.125, '_test');
48
+ } finally {
49
+ process.exit = origExit;
50
+ }
51
+ expect(exited).toBe(false);
52
+ });
53
+
54
+ test('exits when the requested output exceeds the max side guard', () => {
55
+ const origExit = process.exit;
56
+ const origError = console.error;
57
+ let exitCode;
58
+ process.exit = (code) => {
59
+ exitCode = code;
60
+ throw new Error('exit'); // stop the (test-only) function from continuing past exit
61
+ };
62
+ console.error = () => {};
63
+ try {
64
+ expect(() => assertRenderOutputSizeOk(20000, 20000, 1, '_test')).toThrow();
65
+ } finally {
66
+ process.exit = origExit;
67
+ console.error = origError;
68
+ }
69
+ expect(exitCode).toBe(1);
70
+ });
71
+
72
+ test('exits when the requested output exceeds the byte-size guard even under the side cap', () => {
73
+ const origExit = process.exit;
74
+ const origError = console.error;
75
+ let exitCode;
76
+ process.exit = (code) => {
77
+ exitCode = code;
78
+ throw new Error('exit');
79
+ };
80
+ console.error = () => {};
81
+ try {
82
+ // 15000×15000 stays under the 16000px side cap but blows the ~600MB estimate.
83
+ expect(() => assertRenderOutputSizeOk(15000, 15000, 1, '_test')).toThrow();
84
+ } finally {
85
+ process.exit = origExit;
86
+ console.error = origError;
87
+ }
88
+ expect(exitCode).toBe(1);
89
+ });
90
+ });