@1agh/maude 0.45.1 → 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.
- package/apps/studio/acp/bridge.ts +628 -74
- package/apps/studio/acp/index.ts +172 -23
- package/apps/studio/acp/probe.ts +31 -7
- package/apps/studio/acp/transcript.ts +91 -5
- package/apps/studio/annotations-snap.ts +50 -10
- package/apps/studio/api.ts +344 -14
- package/apps/studio/artboard-guides-overlay.tsx +270 -0
- package/apps/studio/bin/_import-asset.mjs +35 -5
- package/apps/studio/bin/_pdf-playwright.mjs +35 -4
- package/apps/studio/bin/_png-playwright.mjs +38 -4
- package/apps/studio/bin/_pw-launch.mjs +52 -0
- package/apps/studio/bin/_pw-launch.test.mjs +90 -0
- package/apps/studio/bin/_smart-frames.mjs +419 -0
- package/apps/studio/bin/_smart-frames.test.mjs +140 -0
- package/apps/studio/bin/smart-frames.sh +30 -0
- package/apps/studio/canvas-edit.ts +302 -3
- package/apps/studio/canvas-lib.tsx +203 -11
- package/apps/studio/canvas-shell.tsx +167 -28
- package/apps/studio/client/app.jsx +678 -176
- package/apps/studio/client/panels/CapabilityBar.jsx +101 -0
- package/apps/studio/client/panels/ChatPanel.jsx +846 -133
- package/apps/studio/client/panels/ElicitationPrompt.jsx +429 -0
- package/apps/studio/client/panels/PermissionPrompt.jsx +119 -0
- package/apps/studio/client/panels/ReadinessList.jsx +17 -3
- package/apps/studio/client/panels/SettingsPanel.jsx +211 -0
- package/apps/studio/client/panels/ToolGroup.jsx +79 -0
- package/apps/studio/client/panels/acp-capabilities.js +82 -0
- package/apps/studio/client/panels/acp-elicitation.js +194 -0
- package/apps/studio/client/panels/acp-runtime.js +248 -9
- package/apps/studio/client/panels/acp-usage.js +65 -0
- package/apps/studio/client/panels/transcript-view.js +36 -0
- package/apps/studio/client/styles/6-acp-chat.css +689 -11
- package/apps/studio/dist/client.bundle.js +1738 -1736
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/dom-selection.ts +20 -0
- package/apps/studio/export-dialog.tsx +138 -18
- package/apps/studio/exporters/pdf.ts +332 -18
- package/apps/studio/exporters/png.ts +45 -3
- package/apps/studio/footage/schema.ts +17 -1
- package/apps/studio/generation/gemma-models.ts +224 -0
- package/apps/studio/generation/prefs.ts +43 -0
- package/apps/studio/generation/whisper-models.test.ts +28 -1
- package/apps/studio/generation/whisper-models.ts +22 -7
- package/apps/studio/http.ts +246 -9
- package/apps/studio/print/marks.ts +113 -0
- package/apps/studio/print/units.ts +269 -0
- package/apps/studio/print-overlay-content.tsx +132 -0
- package/apps/studio/test/acp-bridge.test.ts +11 -6
- package/apps/studio/test/acp-capabilities.test.ts +123 -0
- package/apps/studio/test/acp-caps-bridge.test.ts +274 -0
- package/apps/studio/test/acp-elicitation-bridge.test.ts +475 -0
- package/apps/studio/test/acp-elicitation.test.ts +251 -0
- package/apps/studio/test/acp-mode-banner.test.ts +45 -0
- package/apps/studio/test/acp-permission-prompt.test.ts +77 -0
- package/apps/studio/test/acp-permission.test.ts +262 -0
- package/apps/studio/test/acp-session-allowed-tools.test.ts +91 -0
- package/apps/studio/test/acp-toolgroup.test.ts +76 -0
- package/apps/studio/test/acp-transcript-view.test.ts +71 -0
- package/apps/studio/test/acp-transcript.test.ts +75 -2
- package/apps/studio/test/acp-usage-bridge.test.ts +136 -0
- package/apps/studio/test/acp-usage.test.ts +143 -0
- package/apps/studio/test/annotations-snap.test.ts +56 -0
- package/apps/studio/test/artboard-guides-overlay.test.tsx +152 -0
- package/apps/studio/test/artboard-kinds.test.tsx +83 -0
- package/apps/studio/test/artboard-selection-attrs.test.ts +69 -0
- package/apps/studio/test/canvas-meta-api.test.ts +167 -0
- package/apps/studio/test/element-structural-edit.test.ts +250 -0
- package/apps/studio/test/exporters/png.test.ts +49 -1
- package/apps/studio/test/fixtures/mock-acp-agent-caps.mjs +158 -0
- package/apps/studio/test/fixtures/mock-acp-agent-elicit-flood.mjs +46 -0
- package/apps/studio/test/fixtures/mock-acp-agent-elicit-url.mjs +42 -0
- package/apps/studio/test/fixtures/mock-acp-agent-elicit.mjs +63 -0
- package/apps/studio/test/fixtures/mock-acp-agent-permission-flood.mjs +51 -0
- package/apps/studio/test/fixtures/mock-acp-agent-permission.mjs +50 -0
- package/apps/studio/test/fixtures/mock-acp-agent-usage.mjs +56 -0
- package/apps/studio/test/import-asset.test.ts +31 -3
- package/apps/studio/test/pdf-print-boxes.test.ts +272 -0
- package/apps/studio/test/print-marks.test.ts +113 -0
- package/apps/studio/test/print-units.test.ts +173 -0
- package/apps/studio/test/use-snap-guides.test.ts +81 -0
- package/apps/studio/use-chrome-visibility.tsx +19 -0
- package/apps/studio/use-element-resize.tsx +21 -2
- package/apps/studio/use-snap-guides.tsx +73 -5
- package/apps/studio/use-spacing-handles.tsx +9 -5
- package/apps/studio/whats-new.json +62 -0
- package/cli/commands/design.mjs +116 -3
- package/cli/lib/pkg-root.mjs +42 -10
- package/cli/lib/pkg-root.test.mjs +33 -1
- package/package.json +11 -9
- 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';
|
|
@@ -248,11 +248,41 @@ export function svgPreParseReject(text) {
|
|
|
248
248
|
if (encMatch && !/^(utf-8|us-ascii|ascii)$/i.test(encMatch[1])) {
|
|
249
249
|
throw new ImportAssetError(3, `unsupported declared encoding: ${encMatch[1]}`);
|
|
250
250
|
}
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
251
|
+
// XXE / entity-expansion hardening. The attack surface is an ENTITY
|
|
252
|
+
// declaration or an internal DTD subset (`<!DOCTYPE svg [ … ]>`, where entities
|
|
253
|
+
// are declared) — NOT a plain external-identifier DOCTYPE. Adobe Illustrator,
|
|
254
|
+
// Affinity/Serif, and Inkscape all emit a benign `<!DOCTYPE svg PUBLIC "-//W3C//
|
|
255
|
+
// DTD SVG 1.1//EN" "…svg11.dtd">` with no internal subset; rejecting that whole
|
|
256
|
+
// class turned away real, clean brand logos (RCA G8). Reject the actual vector,
|
|
257
|
+
// allow the benign declaration (the allowlist sanitizer drops the DOCTYPE from
|
|
258
|
+
// the stored output regardless, and happy-dom treats the DOCTYPE as an inert
|
|
259
|
+
// node — it never fetches an external DTD). See DDR-167 + DDR-177.
|
|
260
|
+
if (/<!ENTITY/i.test(text)) {
|
|
261
|
+
throw new ImportAssetError(3, 'ENTITY declarations are rejected (XXE/entity-expansion class)');
|
|
262
|
+
}
|
|
263
|
+
// Internal-subset detection, quote-aware. A naive `/<!DOCTYPE\b(.*?)>/` is
|
|
264
|
+
// evadable: a `>` inside a quoted external-id literal (`SYSTEM "http://x/>y"
|
|
265
|
+
// [ … ]`) truncates the capture before the `[` (ethical-hacker review). Scan
|
|
266
|
+
// from `<!DOCTYPE` respecting quotes, and reject on a `[` (internal subset)
|
|
267
|
+
// reached before the closing `>`.
|
|
268
|
+
const dtStart = text.search(/<!DOCTYPE\b/i);
|
|
269
|
+
if (dtStart >= 0) {
|
|
270
|
+
let quote = null;
|
|
271
|
+
for (let i = dtStart + 9; i < text.length; i++) {
|
|
272
|
+
const c = text[i];
|
|
273
|
+
if (quote) {
|
|
274
|
+
if (c === quote) quote = null;
|
|
275
|
+
} else if (c === '"' || c === "'") {
|
|
276
|
+
quote = c;
|
|
277
|
+
} else if (c === '[') {
|
|
278
|
+
throw new ImportAssetError(
|
|
279
|
+
3,
|
|
280
|
+
'DOCTYPE with an internal subset is rejected (XXE/entity-expansion class)'
|
|
281
|
+
);
|
|
282
|
+
} else if (c === '>') {
|
|
283
|
+
break; // end of DOCTYPE, no internal subset seen
|
|
284
|
+
}
|
|
285
|
+
}
|
|
256
286
|
}
|
|
257
287
|
// Any processing instruction other than the single leading XML declaration.
|
|
258
288
|
const piRe = /<\?([^?]*)\?>/g;
|
|
@@ -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({
|
|
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,
|
|
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
|
-
|
|
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 =
|
|
145
|
-
await captureHandle(handle, join(outDir,
|
|
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
|
+
});
|